40def _frozen_A1(C: np.ndarray, flame_gain: float, tau: float,
41 g: np.ndarray, b: np.ndarray, lam: complex) -> np.ndarray:
42 """The coefficient of ``lambda`` in N(lambda), with the flame's delay factor
43 ``exp(-lambda*tau)`` FROZEN at the current iterate ``lam`` (Algorithm 1, step 3).
45 N(lambda) = K + lambda*[C - flame_gain*exp(-lam*tau)*g@b.T] + lambda^2*M2 once
46 frozen — a genuine QUADRATIC eigenvalue problem in lambda, since freezing the delay
47 factor at a fixed number removes the only source of nonlinearity.
49 return C - flame_gain * np.exp(-lam * tau) * np.outer(g, b)
52def _companion_solve(A0: np.ndarray, A1: np.ndarray, A2: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
53 """All eigenpairs of the quadratic pencil ``A0 + lambda*A1 + lambda^2*A2 = 0`` via
54 the standard companion linearization to a size-2N generalized eigenvalue problem
55 (Algorithm 1, step 5 — "companion-linearize to GEVP of size 2N_h").
57 Writing ``z = [p; lambda*p]``, one checks by direct substitution that
59 [[0, I], [-A0, -A1]] z = lambda * [[I, 0], [0, A2]] z
61 reproduces exactly ``A0 p + lambda A1 p + lambda^2 A2 p = 0`` (the top block row
62 gives the trivial identity ``lambda*p = lambda*p``; the bottom block row gives the
63 QEP once that identity is substituted in). Solved directly and densely
64 (``scipy.linalg.eig``) since P0's verification meshes are small — see module
65 docstring for why that is an adequate, deliberate scope choice here.
68 Z = np.zeros((n, n), dtype=complex)
69 I = np.eye(n, dtype=complex)
70 L0 = np.block([[Z, I], [-A0, -A1]])
71 L1 = np.block([[I, Z], [Z, A2]])
72 eigvals, eigvecs = sla.eig(L0, L1)
73 p_vecs = eigvecs[:n, :]
74 return eigvals, p_vecs
78 tau: float, g: np.ndarray, b: np.ndarray, lambda_seed: complex,
79 *, tol: float = 1e-9, max_iter: int = 50
80 ) -> Tuple[complex, np.ndarray, int]:
81 """Algorithm 1 (Section IV.B): frozen-delay fixed point, specialized to this
82 package's single-flame-reference, dense-small-scale case.
84 At each outer iterate, freeze ``exp(-lambda*tau)`` at the current guess, solve the
85 resulting *linear* (in the sense of ordinary QEP, not NLEVP) quadratic eigenvalue
86 problem exactly via companion linearization, and take whichever of its 2N
87 eigenvalues is nearest the current guess as the next iterate. Converges linearly;
88 the paper reports 3-8 outer iterations for realistic parameters, matched here.
90 Returns ``(lambda, p, n_iterations)``.
92 lam = complex(lambda_seed)
94 for it
in range(max_iter):
96 eigvals, eigvecs =
_companion_solve(K.astype(complex), A1, M2.astype(complex))
97 idx = int(np.argmin(np.abs(eigvals - lam)))
98 lam_new = eigvals[idx]
100 if abs(lam_new - lam) < tol *
max(abs(lam_new), 1.0):
101 return lam_new, p, it + 1
103 return lam, p, max_iter
106def residual_N(K: np.ndarray, C: np.ndarray, M2: np.ndarray, flame_gain: float, tau: float,
107 g: np.ndarray, b: np.ndarray, lam: complex, p: np.ndarray) -> np.ndarray:
108 """N(lambda) @ p — the raw NLEVP residual, used to check solver agreement/quality."""
109 N = K + lam * C + lam ** 2 * M2 - lam * flame_gain * np.exp(-lam * tau) * np.outer(g, b)
113def newton_polish(K: np.ndarray, C: np.ndarray, M2: np.ndarray, flame_gain: float, tau: float,
114 g: np.ndarray, b: np.ndarray, lambda0: complex, p0: np.ndarray,
115 *, tol: float = 1e-12, max_iter: int = 30
116 ) -> Tuple[complex, np.ndarray, int]:
117 """Bordered Newton polish (Section IV.C), solving the *true* NLEVP (delay left in,
118 not frozen) to quadratic convergence from the fixed-point solver's output.
120 The bordered system (paper's own equations, Section IV.C):
122 [[N(lambda), N'(lambda) p], [dp ] [ N(lambda) p ]
123 [c^H, 0 ]] @ [dlambda] = - [ c^H p - 1 ]
125 with the normalization row ``c^H p = 1`` fixing scale/phase (paper uses a generic
126 ``c``; here ``c = e_k``, a unit vector at whichever component of ``p`` has the
127 largest magnitude — a simple, standard pivoting choice so the constraint row is
128 never close to singular).
130 N'(lambda) = C + 2*lambda*M2 - flame_gain*exp(-lambda*tau)*(1 - lambda*tau)*outer(g,b)
131 (derivative of the "-lambda*flame_gain*exp(-lambda*tau)" term in N(lambda) w.r.t.
132 lambda; C and M2 contribute their usual constant/2*lambda derivatives).
134 lam = complex(lambda0)
135 p = p0.astype(complex).copy()
137 k = int(np.argmax(np.abs(p)))
140 for it
in range(max_iter):
141 N = K + lam * C + lam ** 2 * M2 - lam * flame_gain * np.exp(-lam * tau) * np.outer(g, b)
142 Nprime = (C + 2.0 * lam * M2
143 - flame_gain * np.exp(-lam * tau) * (1.0 - lam * tau) * np.outer(g, b))
145 bordered = np.zeros((n + 1, n + 1), dtype=complex)
147 bordered[:n, n] = Nprime @ p
150 rhs = np.zeros(n + 1, dtype=complex)
152 rhs[n] = -(p[k] - 1.0)
154 delta = np.linalg.solve(bordered, rhs)
155 dp, dlam = delta[:n], delta[n]
159 if abs(dlam) < tol *
max(abs(lam), 1.0)
and np.linalg.norm(dp) < tol *
max(np.linalg.norm(p), 1.0):
160 return lam, p, it + 1
162 return lam, p, max_iter
np.ndarray residual_N(np.ndarray K, np.ndarray C, np.ndarray M2, float flame_gain, float tau, np.ndarray g, np.ndarray b, complex lam, np.ndarray p)
Tuple[complex, np.ndarray, int] fixed_point_solve(np.ndarray K, np.ndarray C, np.ndarray M2, float flame_gain, float tau, np.ndarray g, np.ndarray b, complex lambda_seed, *, float tol=1e-9, int max_iter=50)
Tuple[complex, np.ndarray, int] newton_polish(np.ndarray K, np.ndarray C, np.ndarray M2, float flame_gain, float tau, np.ndarray g, np.ndarray b, complex lambda0, np.ndarray p0, *, float tol=1e-12, int max_iter=30)