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.
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.
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))
132 f_max_hz: float, samples_per_period: int = 40) -> List[float]:
133 """All positive roots of the dispersion relation below ``f_max_hz``.
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.
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)
150 g_grid = np.array([
_dispersion_g(f, c1, L1, c2, L2)
for f
in f_grid])
153 for i
in range(len(f_grid) - 1):
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))
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).
174 c_avg = 0.5 * (c1 + c2)
176 f_max_guess = 3.0 * n_compare * c_avg / (2.0 * L)
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
181 freqs_hz, _ = solve_passive_modes(spec.mesh, spec.c_element, m=0,
182 n_modes=n_compare + 2, sigma=sigma)
184 f_min = candidates[0]
185 freqs_hz = np.sort(freqs_hz[freqs_hz > 0.05 * f_min])[:n_compare]
187 return [
ModeComparison(f_analytic_hz=float(fa), f_fem_hz=float(ff))
188 for fa, ff
in zip(candidates, freqs_hz)]
List[ModeComparison] run_case(*, float c1, float L1, float c2, float L2, float R, int nx1, int nx2, int nr, int n_compare=4)