STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
v3_ntau_duct.py
Go to the documentation of this file.
1"""Verification case V3: duct with a compact n-tau flame, closed/choked ends
2(paper Section VII.A: "active flame term, delay nonlinearity, complex lambda,
3all three solvers agree").
4
5This is the first case where the eigenproblem is genuinely NONLINEAR in lambda
6(the flame delay exp(-lambda*tau), Eq. 7a/11), so it is the first exercise of the
7paper's Section IV solver hierarchy: the frozen-delay fixed point (Algorithm 1) and
8the bordered Newton polish (Section IV.C). (The third solver of the hierarchy, Beyn
9contour integration, is a completeness AUDIT rather than a mode solver and is deferred
10to P3 per the paper's phasing — so "all three solvers" is, at P0, these two. Flagged
11rather than silently reinterpreted.)
12
13Configuration
14-------------
15A duct of length L, uniform sound speed c, rigid ("closed") end at x=0, compact
16choked-nozzle admittance y_noz = (gamma-1)*Mbar_e/2 (Appendix C) at x=L, and a compact
17(delta-in-x, uniform-in-r) pressure-coupled flame at x = x_f:
18
19 q_hat(x) = gain * exp(-lambda*tau) * p_hat(x_f) * delta(x - x_f)
20
21i.e. Eq. (7a) lumped to a single reference point and a single flame sheet, with
22``gain`` absorbing (gamma-1) * n_p * (qbar_dot/pbar) * (flame axial thickness)
23[units m/s]. The flame is uniform across the cross-section, so for the r-independent
24longitudinal modes compared here the 2-D axisymmetric FEM and the 1-D analytic model
25below describe *identical* physics.
26
27Discrete form solved (Eq. 11, rank-1 flame):
28
29 N(lambda) p = [K + lambda*C + lambda^2*M2 - lambda*gain*exp(-lambda*tau)*g b^T] p = 0
30
31with b = point sample of p at (x_f, 0) and g = the r-weighted disk load at x_f
32(two DIFFERENT vectors; see acoustics/assembly.py for why conflating them is a bug).
33
34Analytic reference (derived, not from the paper)
35------------------------------------------------
36Zone solutions satisfying the end conditions, with s = lambda/c:
37
38 zone 1 (0..x_f): P(x) = cosh(s*x) [P'(0)=0, rigid]
39 zone 2 (x_f..L): Q(x) = r2*exp(s*(x-L)) + exp(-s*(x-L))
40
41The admittance end condition p'(L) = -s*y_noz*p(L) (from Eq. 8 with y constant) gives
42
43 r2 = (1 - y_noz) / (1 + y_noz) [lambda-independent for a compact nozzle].
44
45CAUTION (documented wrong turn): an earlier draft had r2 = (y-s')/(y+s') -> the
46NEGATIVE of the correct value. The passive limit exposes it: with the wrong sign the
47flame-off roots do not reproduce the independently verified tanh(s*L) = -y_noz
48spectrum (sigma ~ -y*c/L, f ~ n*c/2L). Always check the passive limit of an
49active-flame dispersion relation first.
50
51At the flame, p is continuous and the slope jumps. The jump follows from the FEM's own
52strong form (integrate lambda^2*p - d/dx(c^2 p') = lambda*gain*e^{-lambda*tau}
53* p(x_f) * delta(x-x_f) across the sheet):
54
55 [p']_{x_f-}^{x_f+} = -lambda * beta * exp(-lambda*tau) * p(x_f),
56 beta = gain / c^2.
57
58CAUTION (the bug that cost the most time): beta is gain/c^2, NOT gain*(R^2/2)/c^2.
59The r-weighted disk load g sums to R^2/2, but the r dOmega measure multiplies every
60OTHER matrix (K, M2) by the same R^2/2, so it cancels exactly in the 1-D reduction.
61Double-counting it made the analytic flame coupling 1/(R^2/2) = 5000x too weak, which
62presented as "FEM growth rate 5000x larger than analytic" — with frequencies agreeing
63to 0.003%, the signature of a coupling-scale error rather than a discretization or
64solver error. (The FEM answer was mesh-converged and confirmed independently by
65first-order eigenvalue perturbation theory of the discrete system; the closed-form
66sensitivity for the fundamental mode of the rigid-rigid case,
67d(lambda)/d(gain) = exp(-lambda0*tau) * cos^2(pi*x_f/L) / L, in which R cancels
68entirely, is re-checked in the test suite.)
69
70Eliminating the two amplitudes with the two interface conditions gives the dispersion
71relation, root-found in pole-free product form (V2's lesson):
72
73 G(lambda) = P'(x_f) Q(x_f) - Q'(x_f) P(x_f)
74 - lambda*beta*exp(-lambda*tau) * P(x_f) Q(x_f) = 0.
75
76Phase convention worth knowing (physics finding, verified numerically three ways):
77with the pure-delay coupling (7a), the Rayleigh driving of a mode goes as
78+cos(omega*tau) — in-phase heat release drives, anti-phase damps — because the
79cycle-averaged p'q' is |p_hat|^2 * cos(omega*tau): the REAL part of the transfer. The
80lumped model (engine/pipeline/stability/acoustic.py) uses the DIFFERENCE form of the
81Crocco response, n*[p'(t) - p'(t-tau)], whose transfer n*(1-e^{-i*omega*tau}) has real
82part n*(1 - cos(omega*tau)) -- driving everywhere, most at omega*tau = pi. (It took the
83imaginary part, n*sin, until 2026-09-26; that is the quadrature component and does no
84work.) The two forms differ by the instantaneous n*p'(t) term, so their tau-bands are
85not the same; comparisons between this framework and the lumped tier must translate
86conventions first.
87"""
88
89from __future__ import annotations
90
91from dataclasses import dataclass
92
93import numpy as np
94
96 assemble_boundary_admittance,
97 assemble_passive,
98 disk_load_vector,
99 point_sampling_vector,
100)
101from engine.stability_hifi.acoustics.mesh import two_zone_duct_mesh
102from engine.stability_hifi.eigen.nlevp import fixed_point_solve, newton_polish, residual_N
103
104
105@dataclass
107 lam_fixed_point: complex # frozen-delay fixed point (Algorithm 1)
108 lam_newton: complex # after bordered Newton polish (Section IV.C)
109 lam_analytic: complex # root of the 1-D dispersion relation
110 fp_iterations: int
111 newton_iterations: int
112 nlevp_residual: float # ||N(lam) p|| / ||p|| at the Newton answer
113
114 @property
115 def solver_agreement(self) -> float:
116 """|lam_fp - lam_newton| / |lam_newton| — paper's V3 acceptance: < 1e-6."""
117 return abs(self.lam_fixed_point - self.lam_newton) / abs(self.lam_newton)
118
119 @property
120 def rel_error_vs_analytic(self) -> float:
121 """|lam_newton - lam_analytic| / |lam_analytic| — paper's V3 acceptance: < 1%."""
122 return abs(self.lam_newton - self.lam_analytic) / abs(self.lam_analytic)
123
124
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."""
128 s = lam / c
129 xi = x_f - L
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
136
137
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
140 ) -> complex:
141 """Bounded-step complex Newton on G, seeded near the expected root.
142
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).
150 """
151 lam = complex(lam_seed)
152 h = 1e-2
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)
157 step = d / dd
158 if abs(step) > max_step:
159 step = step / abs(step) * max_step
160 lam = lam - step
161 return lam
162
163
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.
168
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.
172
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).
176 """
177 y_noz = 0.0 if rigid_end else (gamma - 1.0) * Mbar_e / 2.0
178 beta = flame_gain / c ** 2 # NOT /(R^2/2) — see module docstring, the costly bug
179
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()
184 if rigid_end:
185 C = np.zeros_like(K)
186 else:
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)
191
192 # Seed at the passive closed/choked fundamental: f ~ c/2L, sigma ~ -y*c/L
193 # (Algorithm 2 step 5's "passive modes seed the shifts", in miniature).
194 lam_seed = complex(-y_noz * c / L, 2.0 * np.pi * c / (2.0 * L))
195
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))
200
201 lam_an = dispersion_root(lam_nw, c=c, L=L, x_f=x_f, y_noz=y_noz, beta=beta, tau=tau)
202
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)
complex dispersion_root(complex lam_seed, *, float c, float L, float x_f, float y_noz, float beta, float tau, int iters=60, float max_step=20.0)
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)
complex dispersion_G(complex lam, *, float c, float L, float x_f, float y_noz, float beta, float tau)