125def dispersion_G(lam: complex, *, c: float, L: float, x_f: float, y_noz: float,
126 beta: float, tau: float) -> complex:
127 """Pole-free dispersion residual G(lambda) (module docstring). Root <=> eigenvalue."""
130 r2 = (1.0 - y_noz) / (1.0 + y_noz)
131 Pf = np.cosh(s * x_f)
132 Pfp = s * np.sinh(s * x_f)
133 Qf = r2 * np.exp(s * xi) + np.exp(-s * xi)
134 Qfp = s * (r2 * np.exp(s * xi) - np.exp(-s * xi))
135 return Pfp * Qf - Qfp * Pf - lam * beta * np.exp(-lam * tau) * Pf * Qf
138def dispersion_root(lam_seed: complex, *, c: float, L: float, x_f: float, y_noz: float,
139 beta: float, tau: float, iters: int = 60, max_step: float = 20.0
141 """Bounded-step complex Newton on G, seeded near the expected root.
143 Deliberately LOCAL: we want the dispersion root nearest the seed (the FEM
144 eigenvalue being cross-checked), and an unbounded/global root-finder can wander to
145 a distant root and report success — scipy.fsolve did exactly that during
146 development, converging to an unrelated real root thousands of rad/s away.
147 Guaranteed-complete root surveys are the job of the Beyn audit tier (P3), not of a
148 verification cross-check. The derivative is numerical (central difference, step
149 h=1e-2 — G is smooth on O(1) lambda scales, so this is far below its variation).
151 lam = complex(lam_seed)
153 for _
in range(iters):
154 d =
dispersion_G(lam, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau)
155 dd = (
dispersion_G(lam + h, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau)
156 -
dispersion_G(lam - h, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau)) / (2.0 * h)
158 if abs(step) > max_step:
159 step = step / abs(step) * max_step
164def run_case(*, c: float, L: float, R: float, x_f: float, gamma: float, Mbar_e: float,
165 flame_gain: float, tau: float, nx1: int, nx2: int, nr: int,
166 rigid_end: bool =
False) -> V3Result:
167 """Full V3 run: FEM NLEVP (both solvers) vs the 1-D dispersion relation.
169 ``rigid_end=True`` replaces the choked-nozzle admittance at x=L with a rigid wall
170 (y_noz = 0) — the pure-flame sub-case, useful for isolating flame handling from
171 boundary-damping handling when something disagrees.
173 The mesh is built with ``two_zone_duct_mesh`` even though there is no material
174 jump, purely to guarantee an exact node column at x = x_f for the disk load
175 (see ``disk_load_vector``'s docstring).
177 y_noz = 0.0
if rigid_end
else (gamma - 1.0) * Mbar_e / 2.0
178 beta = flame_gain / c ** 2
180 mesh, _zone = two_zone_duct_mesh(x_f, L - x_f, R, nx1, nx2, nr)
181 c_field = np.full(mesh.n_nodes, c)
182 K, _Km, M2 = assemble_passive(mesh, c_field, m=0)
183 K, M2 = K.toarray(), M2.toarray()
187 C = assemble_boundary_admittance(mesh, mesh.nodes_at_x(L),
188 coefficient=c * y_noz).toarray()
189 g = disk_load_vector(mesh, mesh.nodes_at_x(x_f))
190 b = point_sampling_vector(mesh, x_f, 0.0)
194 lam_seed = complex(-y_noz * c / L, 2.0 * np.pi * c / (2.0 * L))
196 lam_fp, p_fp, n_fp = fixed_point_solve(K, C, M2, flame_gain, tau, g, b, lam_seed)
197 lam_nw, p_nw, n_nw = newton_polish(K, C, M2, flame_gain, tau, g, b, lam_fp, p_fp)
198 res = residual_N(K, C, M2, flame_gain, tau, g, b, lam_nw, p_nw)
199 res_norm = float(np.linalg.norm(res) / np.linalg.norm(p_nw))
201 lam_an =
dispersion_root(lam_nw, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau)
203 return V3Result(lam_fixed_point=lam_fp, lam_newton=lam_nw, lam_analytic=lam_an,
204 fp_iterations=n_fp, newton_iterations=n_nw, nlevp_residual=res_norm)
V3Result run_case(*, float c, float L, float R, float x_f, float gamma, float Mbar_e, float flame_gain, float tau, int nx1, int nx2, int nr, bool rigid_end=False)