STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
v2_temperature_jump.py
Go to the documentation of this file.
1"""Verification case V2: 1-D duct with a temperature (sound-speed) jump, passive
2(paper Section VII.A: "checks: nonuniform-c-bar handling").
3
4The paper *names* this case but does not give its analytic reference solution, so the
5derivation below is original (not copied from the paper) — done directly from the
6governing equation the paper does give, Eq. (6):
7
8 lambda^2 p_hat - d/dx( c_bar^2 dp_hat/dx ) = 0 (1-D, no heat release)
9
10Setup: a duct of length L = L1 + L2, split at x = L1 into two uniform zones with sound
11speeds c1 (zone 1, 0 <= x <= L1) and c2 (zone 2, L1 <= x <= L1+L2). Rigid ("closed") ends
12at x=0 and x=L, matching Section III.E's rigid-wall Neumann condition. Only the
13sound-speed field is nonuniform; nothing about the boundary condition or the flame
14(there is none) is new relative to V1 — this isolates exactly the one new piece of
15physics V2 is meant to check.
16
17Derivation
18----------
19Looking for undamped natural frequencies (lambda = i*omega, omega real — same as V1;
20a lossless medium can't grow or decay), Eq. (6) in each uniform zone reduces to the
21textbook 1-D Helmholtz equation
22
23 d^2 p_hat/dx^2 = -k_i^2 p_hat, k_i = omega / c_i (i = 1, 2)
24
25with general solution p_hat(x) = A*cos(k_i*x) + B*sin(k_i*x). The rigid-end conditions
26p_hat'(0) = 0 and p_hat'(L) = 0 fix the form in each zone up to one free amplitude:
27
28 zone 1: p_hat_1(x) = A1 * cos(k1*x) [only cos satisfies p'(0)=0]
29 zone 2: p_hat_2(x) = A2 * cos(k2*(x - L)) [satisfies p'(L)=0 by construction]
30
31At the interface x=L1, we need two matching conditions. Pressure continuity, (a), is
32uncontroversial (nothing sources or sinks p there). Condition (b) is the one that is
33easy to get wrong, and an earlier draft of this derivation *did* get it wrong (kept
34below as a documented correction, since the mistake and the fix are both instructive):
35
36 WRONG first attempt: continuity of mass flux rho_bar*u_hat, on the (mistaken)
37 reasoning that this is the standard duct-acoustics matching condition when gas
38 properties change across an interface. Using u_hat = -grad(p_hat)/(lambda*rho_bar)
39 (given right after Eq. 6), rho1*u_hat_1 = rho2*u_hat_2 reduces (both rho AND lambda
40 cancel) to dp_hat_1/dx = dp_hat_2/dx, giving k1*tan(k1*L1) + k2*tan(k2*L2) = 0.
41
42 This numerically MISMATCHED the FEM solution by several percent at every mesh
43 resolution, with no improvement on refinement — a strong signal (per the mesh-
44 convergence-order test in V1) of a wrong reference formula, not a discretization
45 error. Tracking it down: the standard "mass-flux continuity" rule from general duct
46 acoustics assumes a mean flow physically carrying mass across the interface. But
47 Section III.C's Helmholtz reduction explicitly assumes a *quiescent* mean flow
48 (u_bar ~ 0) — there is no throughflow here at all, just a spatially-varying sound
49 speed in still gas. The right way to find the correct condition is to go back one
50 step further than Eq. 6, to the *pair* of first-order equations it was combined
51 from (Eq. 4): the energy equation "dp'/dt + gamma*p_bar*div(u') = (gamma-1)*q_dot'"
52 uses the volumetric dilatation div(u') directly (a purely kinematic quantity, not a
53 mass flux), and Appendix B's assumption (ii) is that gamma*p_bar is spatially
54 UNIFORM (mean pressure barely drops across the chamber) even though rho_bar is not.
55 Integrating that energy equation across a vanishingly thin control volume straddling
56 the interface forces the u' term's jump to vanish for the equation to stay finite —
57 i.e. **u_hat itself (not rho_bar*u_hat) must be continuous.** Equivalently, since
58 c_bar^2 = gamma*p_bar/rho_bar and gamma*p_bar is the same constant on both sides,
59 continuity of u_hat (using u_hat = -grad(p_hat)/(lambda*rho_bar)) is the same
60 statement as continuity of c_bar^2 * dp_hat/dx — which is also exactly the *natural*
61 (weak-form) interface condition that Eq. (10)'s FEM discretization enforces
62 automatically at any element boundary where c jumps. That the corrected physics
63 argument and the FEM's own automatic behavior agree is a good consistency check.
64
65Redoing the algebra with the corrected condition (b): c1^2 * dp_hat_1/dx = c2^2 *
66dp_hat_2/dx at x=L1. Substituting the two zone solutions into "p continuous" and
67"c^2 p' continuous" and eliminating A1, A2 (using k_i = omega/c_i, so c_i^2 * k_i =
68c_i * omega):
69
70 A1 cos(k1*L1) = A2 cos(k2*L2) (p continuous)
71 -c1*omega*A1 sin(k1*L1) = c2*omega*A2 sin(k2*L2) (c^2 p' continuous)
72
73gives the dispersion relation:
74
75 c1 * tan(k1*L1) + c2 * tan(k2*L2) = 0.
76
77Sanity check (uniform limit): if c1 = c2 = c, both this and the wrong first attempt
78collapse to the same tan(k*L1) = -tan(k*(L-L1)), i.e. f = n*c/(2L) — V1's closed-closed
79spectrum. **This is why the uniform-limit check alone did not catch the error**: any
80relation of the schematic form (function of c1, k1*L1) + (function of c2, k2*L2) = 0
81that is antisymmetric the same way trivially passes it regardless of which power of
82c_i actually belongs there. The uniform limit is a necessary but not sufficient check;
83comparing against the independently-built FEM solution (which does not share whatever
84mistake was made in the by-hand derivation) is what actually caught this one.
85
86Numerically, ``tan`` has poles wherever k_i*L_i passes through (2n+1)*pi/2, which are
87not physical roots but *look* like sign changes to a naive root-finder. To avoid that
88trap, the code below root-finds the equivalent POLE-FREE form obtained by multiplying
89through by cos(k1*L1)*cos(k2*L2):
90
91 g(omega) = c1*sin(k1*L1)*cos(k2*L2) + c2*cos(k1*L1)*sin(k2*L2) = 0
92
93which is smooth (entire) in omega, so every sign change of g really is a root.
94"""
95
96from __future__ import annotations
97
98from dataclasses import dataclass
99from typing import List
100
101import numpy as np
102from scipy.optimize import brentq
103
104from engine.stability_hifi.eigen.passive import solve_passive_modes
105from engine.stability_hifi.meanflow.spec import synthetic_two_zone_duct
106
107
108@dataclass
110 f_analytic_hz: float
111 f_fem_hz: float
112
113 @property
114 def rel_error(self) -> float:
115 return abs(self.f_fem_hz - self.f_analytic_hz) / self.f_analytic_hz
116
117
118def _dispersion_g(f_hz: float, c1: float, L1: float, c2: float, L2: float) -> float:
119 """Pole-free dispersion residual (see module docstring). Root at f_hz => a true mode.
120
121 Corrected form ``c1*sin(k1L1)cos(k2L2) + c2*cos(k1L1)sin(k2L2)`` (velocity-continuity
122 interface condition) — NOT ``k1*sin(...)+k2*sin(...)`` (mass-flux continuity), which
123 was tried first and shown to be wrong; see the module docstring for the full story.
124 """
125 omega = 2.0 * np.pi * f_hz
126 k1, k2 = omega / c1, omega / c2
127 return (c1 * np.sin(k1 * L1) * np.cos(k2 * L2)
128 + c2 * np.cos(k1 * L1) * np.sin(k2 * L2))
129
130
131def analytic_frequencies(c1: float, L1: float, c2: float, L2: float,
132 f_max_hz: float, samples_per_period: int = 40) -> List[float]:
133 """All positive roots of the dispersion relation below ``f_max_hz``.
134
135 Dense-sample ``g`` finely enough to resolve its fastest oscillation (set by the
136 shorter, faster zone) and Brent's method to refine each sign change found. Because
137 ``g`` (unlike the raw tan-tan form) has no poles, every sign change IS a root —
138 no risk of mistaking an asymptote for a mode, which is exactly why this pole-free
139 form was used instead of the more directly-derived tan-tan equation.
140 """
141 # g's fastest oscillation in frequency is set by whichever zone reaches a quarter-wave
142 # resonance soonest as f increases, i.e. the zone with the smaller c/(4L) -- sample
143 # several points per that period so no root (sign change) is skipped over.
144 c_min = min(c1, c2)
145 L_min = min(L1, L2)
146 f_period = c_min / (4.0 * L_min)
147 df = f_period / samples_per_period
148 n_samples = max(int(np.ceil(f_max_hz / df)) + 2, 100)
149 f_grid = np.linspace(1e-3, f_max_hz, n_samples) # start just above 0 (f=0 is a trivial root)
150 g_grid = np.array([_dispersion_g(f, c1, L1, c2, L2) for f in f_grid])
151
152 roots = []
153 for i in range(len(f_grid) - 1):
154 if g_grid[i] == 0.0:
155 roots.append(f_grid[i])
156 elif g_grid[i] * g_grid[i + 1] < 0.0:
157 root = brentq(_dispersion_g, f_grid[i], f_grid[i + 1], args=(c1, L1, c2, L2))
158 roots.append(root)
159 return sorted(roots)
160
161
162def run_case(*, c1: float, L1: float, c2: float, L2: float, R: float,
163 nx1: int, nx2: int, nr: int, n_compare: int = 4) -> List[ModeComparison]:
164 """Solve the FEM two-zone duct at m=0 and match the lowest ``n_compare`` modes
165 against the dispersion-relation roots (same sort-and-pair strategy as V1; see the
166 completeness pitfall noted in ``docs/stability/stability_hifi_p0_v1_notes.md`` --
167 the analytic root search above already returns every root up to ``f_max_hz``, with
168 no truncation-by-branch that could silently skip one, so that specific bug class
169 does not apply here, but the general "did I search far enough" question still does,
170 hence ``f_max_hz`` below is set generously past the highest mode we intend to compare).
171 """
172 # A rough frequency ceiling to search up to: comfortably past where we expect the
173 # n_compare-th mode. Uniform-limit estimate n*c_avg/(2L) as a starting scale, x3 margin.
174 c_avg = 0.5 * (c1 + c2)
175 L = L1 + L2
176 f_max_guess = 3.0 * n_compare * c_avg / (2.0 * L)
177 candidates = analytic_frequencies(c1, L1, c2, L2, f_max_guess)[:n_compare]
178
179 spec = synthetic_two_zone_duct(L1, L2, R, nx1, nx2, nr, c1=c1, c2=c2)
180 sigma = (2.0 * np.pi * 0.5 * candidates[0]) ** 2 # shift below the lowest true mode
181 freqs_hz, _ = solve_passive_modes(spec.mesh, spec.c_element, m=0,
182 n_modes=n_compare + 2, sigma=sigma)
183
184 f_min = candidates[0]
185 freqs_hz = np.sort(freqs_hz[freqs_hz > 0.05 * f_min])[:n_compare]
186
187 return [ModeComparison(f_analytic_hz=float(fa), f_fem_hz=float(ff))
188 for fa, ff in zip(candidates, freqs_hz)]
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
List[float] analytic_frequencies(float c1, float L1, float c2, float L2, float f_max_hz, int samples_per_period=40)
List[ModeComparison] run_case(*, float c1, float L1, float c2, float L2, float R, int nx1, int nx2, int nr, int n_compare=4)
float _dispersion_g(float f_hz, float c1, float L1, float c2, float L2)