1"""Layer X hardware read-outs: what the erosion replay keeps, and the hardware geometry built on it.
3What these check, and against what:
5* The replay keeps the per-step arrays the coupled solver computes (audit 5.2) without touching the
6 keys it had. The new arrays are checked against identities that do not go through the code under
7 test: the throat diameter against its area, the c* efficiency against its own breakdown, the
8 throat station's recession against the old throat-recession key, the net load against its parts.
9* An eroding engine is replayed on the coupled solver only (audit 5.3): a graphite-only engine,
10 which used to replay through the legacy loop at zero erosion, now erodes; the legacy path refuses.
12The replay here runs on LE4 (``configs/ethalox_6800N.yaml``) at synthetic line-exit pressures
13through a minimal stand-in for ``Prepared``: the replay reads only the runner, the as-built
14throat, the step and the ambient from it, and a twin burn would cost a minute per test.
17from __future__
import annotations
21from pathlib
import Path
22from types
import SimpleNamespace
32ROOT = Path(__file__).resolve().parents[1]
33LE4 = ROOT /
"configs" /
"ethalox_6800N.yaml"
34PSI = 6894.757293168361
37pytestmark = pytest.mark.skipif(
not LE4.is_file(), reason=
"LE4 config (configs/ethalox_6800N.yaml) not in this checkout")
41 "available",
"t",
"index",
"inlet_O_psia",
"inlet_F_psia",
"pc_psia",
"thrust_N",
"mdot_O",
"mdot_F",
42 "mr",
"isp_s",
"cstar",
"eta_cstar",
"gamma",
"A_throat_m2",
"throat_area_ratio",
"recession_throat_mm",
43 "recession_chamber_mm",
"eps",
"p_exit_psia",
"t_exit_K",
"gamma_exit",
"tc_K",
"Lstar_m",
44 "heat_flux_throat_MW_m2",
"heat_flux_chamber_MW_m2",
"T_graphite_surface_K",
"T_liner_surface_K",
45 "char_depth_peak_mm",
"chug_margin",
"throat_ablation",
"liner_ablation",
49def fake_prep(config, dt: float = 0.05, ambient: float = AMBIENT):
50 """What ``replay`` reads of a ``Prepared``: the line-exit runner, the as-built throat, dt, ambient."""
52 link = SimpleNamespace(sampler=SimpleNamespace(runner=runner, config=runner.config),
53 design=SimpleNamespace(throat_area=float(runner.config.chamber_geometry.A_throat)))
54 return SimpleNamespace(link=link, plan=SimpleNamespace(dt=dt), ambient_pa=ambient)
57def series(n: int = 69, dt: float = 0.05, po=(560.0, 600.0), pf=(565.0, 605.0)):
58 """A burn's line-exit pressures as the twin reports them: rising with the regulator's SPE."""
59 t = [dt * (k + 1)
for k
in range(n)]
60 return {
"t": t,
"firing": [
True] * n,
"dt": [dt] * n,
61 "ox": {
"inlet_psia": list(np.linspace(*po, n))},
"fuel": {
"inlet_psia": list(np.linspace(*pf, n))}}
64@pytest.fixture(scope="module")
66 return load_config(str(LE4))
69@pytest.fixture(scope="module")
74@pytest.fixture(scope="module")
79@pytest.fixture(scope="module")
82 rp = rpl.replay(prep, burn, sidecars=side, soak=
True)
83 assert rp[
"available"], rp.get(
"error")
92 missing = [k
for k
in OLD_KEYS
if k
not in rp]
95 assert n == rpl.REPLAY_POINTS
97 if isinstance(rp[k], list):
98 assert len(rp[k]) == n, k
104 for key
in list(rpl.RESULT_COLUMNS) + list(rpl.DIAGNOSTIC_COLUMNS):
106 assert isinstance(col, list)
and len(col) == n, key
108 assert all(v
is not None and math.isfinite(v)
for v
in col), key
113 for d_mm, a
in zip(rp[
"D_throat_mm"], rp[
"A_throat_m2"]):
114 assert d_mm == pytest.approx(math.sqrt(4.0 * a / math.pi) * 1e3, rel=1e-12)
118 """eta_c* = eta_vap * eta_mix * eta_HL (engine/pipeline/combustion_eff.py): the breakdown the
119 replay keeps must multiply back to the efficiency the chamber solve used."""
121 for eta, v, m, h
in zip(rp[
"eta_cstar"], rp[
"eta_vap"], rp[
"eta_mix"], rp[
"eta_heat_loss"]):
122 assert v * m * h == pytest.approx(eta, rel=1e-9)
124 for c, eta, ci
in zip(rp[
"cstar"], rp[
"eta_cstar"], rp[
"cstar_ideal"]):
125 assert c == pytest.approx(eta * ci, rel=1e-12)
129 """dp_injector is below line exit - Pc by exactly the Borda dump EngineDesign keeps at the line
130 exit: positive, and a few percent of the drop (the 6.8 kN LOX dump is ~26 psi at ~140 psi)."""
132 for p_in, pc, dp
in zip(rp[
"inlet_O_psia"], rp[
"pc_psia"], rp[
"dp_injector_O_psi"]):
133 dump = p_in - pc - dp
134 assert 0.0 < dump < 0.5 * (p_in - pc)
140 assert set(st) == {
"liner0",
"liner1",
"liner2",
"liner3",
"throat"}
142 assert thr[
"kind"] ==
"graphite" and thr[
"x_mm"] == 0.0
144 assert thr[
"recession_mm"] == pytest.approx(rp[
"recession_throat_mm"], rel=1e-12)
146 t_gr = config.graphite_insert.initial_thickness * 1e3
147 for rec, rem
in zip(thr[
"recession_mm"], thr[
"remaining_mm"]):
148 assert rem == pytest.approx(t_gr - rec, abs=1e-9)
149 t_ab = config.ablative_cooling.initial_thickness * 1e3
150 for rec, rem
in zip(st[
"liner0"][
"recession_mm"], st[
"liner0"][
"remaining_mm"]):
151 assert rem == pytest.approx(t_ab - rec, abs=1e-9)
153 assert st[
"liner0"][
"recession_mm"] == pytest.approx(rp[
"recession_chamber_mm"], rel=1e-12)
155 for name, s
in st.items():
156 for qn, qc, qr, qh
in zip(s[
"q_net_MW_m2"], s[
"q_conv_MW_m2"], s[
"q_rad_MW_m2"], s[
"q_chem_MW_m2"]):
157 assert qn == pytest.approx(qc + qr - qh, rel=1e-9, abs=1e-12), name
160 assert thr[
"T_back_K"] == pytest.approx(rp[
"T_graphite_back_K"], rel=1e-12)
161 assert config.stainless_steel_case
is not None and config.stainless_steel_case.thickness == pytest.approx(0.00635)
162 assert [lay[
"name"]
for lay
in rp[
"wall_layers"][
"throat"][
"layers"]] == [
"graphite",
"backing",
"case"]
166 """The station read-out (``_station_report``) against the barrel and throat loads the solver
167 already reported through its own path (``_wall_report``: q_conv/q_rad/q_chem_throat,
168 q_conv/q_rad_chamber). The throat is the same quantity both ways. At the barrel the station's
169 convection is the *blown* one the liner takes, the old key the unblown Bartz value: their ratio
170 is the pyrolysis-blowing reduction, a little under 1 (0.9931 at t = 0.05 s in audit 9.2)."""
172 thr, bar = rp[
"stations"][
"throat"], rp[
"stations"][
"liner0"]
173 assert thr[
"q_conv_MW_m2"] == pytest.approx(rp[
"q_conv_throat_MW_m2"], rel=1e-9)
174 assert thr[
"q_rad_MW_m2"] == pytest.approx(rp[
"q_rad_throat_MW_m2"], rel=1e-9)
175 assert thr[
"q_chem_MW_m2"] == pytest.approx(rp[
"q_chem_throat_MW_m2"], rel=1e-9)
176 assert bar[
"q_rad_MW_m2"] == pytest.approx(rp[
"q_rad_chamber_MW_m2"], rel=1e-9)
177 for blown, bare
in zip(bar[
"q_conv_MW_m2"], rp[
"q_conv_chamber_MW_m2"]):
178 assert 0.9 < blown / bare <= 1.0
184 assert ax[
"t"] == rp[
"t"]
186 assert ax[
"x_mm"] == sorted(ax[
"x_mm"])
187 for key
in (
"q_MW_m2",
"T_wall_K",
"recession_mm",
"T_back_K"):
188 assert len(ax[key]) == len(rp[
"t"])
and all(len(row) == nx
for row
in ax[key]), key
190 j = ax[
"station"].index(
"throat")
191 assert [row[j]
for row
in ax[
"recession_mm"]] == rp[
"stations"][
"throat"][
"recession_mm"]
193 assert len(prof[
"q_MW_m2"]) == len(rp[
"t"])
and all(len(r) == len(prof[
"x_mm"])
for r
in prof[
"q_MW_m2"])
195 assert prof[
"x_mm"][0] < ax[
"x_mm"][0]
and prof[
"x_mm"][-1] > 0.0
202 assert rpl.erodes(config)
203 c = config.model_copy(deep=
True)
204 c.ablative_cooling.enabled =
False
206 c.graphite_insert.enabled =
False
207 assert not rpl.erodes(c)
211 """Liner off, graphite on: the replay used to pass ``track_ablative_geometry=None``, which the
212 runner read from the liner alone, and replayed this engine at 0.000 % throat growth."""
213 c = config.model_copy(deep=
True)
214 c.ablative_cooling.enabled =
False
216 assert rp[
"available"], rp.get(
"error")
217 assert rp[
"coupled_solver"]
218 assert rp[
"throat_area_ratio"][-1] > 1.01
219 assert all(v
is not None for v
in rp[
"T_graphite_surface_K"])
223 c = config.model_copy(deep=
True)
224 c.ablative_cooling.track_geometry_evolution =
False
226 assert rp[
"available"]
and rp[
"coupled_solver"]
227 assert rp[
"recession_chamber_mm"][-1] > 0.5
232 runner =
fake_prep(config).link.sampler.runner
233 with pytest.raises(ValueError, match=
"strict_erosion"):
234 runner.evaluate_arrays_with_time(np.array([0.0, 1.0]), np.full(2, 560 * PSI), np.full(2, 565 * PSI),
235 track_ablative_geometry=
True, use_coupled_solver=
False,
242def _slab_solver(L=0.01, k=0.5, rho=1500.0, cp=1200.0, T0=400.0, dT=100.0):
243 """A coupled solver reduced to one wall station: a single constant-property slab whose
244 temperature is the first Fourier mode of an insulated slab, T0 + dT cos(pi y / L)."""
248 m = WallModel([Layer(L, k, rho, cp,
"slab")], T0)
249 m.T = T0 + dT * np.cos(np.pi * m.y / L)
250 solver = object.__new__(TimeVaryingCoupledSolver)
251 solver._walls = {
"slab": {
"x": 0.0,
"kind":
"liner",
"model": m}}
256 """3 L^2/alpha, alpha = k/(rho cp), L the liner left at the station that receded least
257 (LE4's 12.7 mm phenolic liner is ~900 s; the 6 mm graphite ~0.4 s)."""
259 abl = config.ablative_cooling
260 left =
min(rp[
"stations"][f
"liner{i}"][
"recession_mm"][-1]
for i
in range(4))
261 L = abl.initial_thickness - left * 1e-3
262 per = abl.material_density * abl.specific_heat / abl.thermal_conductivity
266 cg, gr = config.chamber_geometry, config.graphite_insert
267 gap = 0.5 * cg.chamber_diameter + abl.initial_thickness - (math.sqrt(4.0 * cg.A_throat / math.pi) / 2.0 + gr.initial_thickness)
268 tau =
max(tau, gap * gap * per)
270 assert soak[
"available"]
271 assert soak[
"duration_s"] == pytest.approx(3.0 * tau, rel=1e-9)
272 assert soak[
"model"][
"inputs"][
"slowest_L2_over_alpha"][
"value"] == pytest.approx(tau, rel=1e-9)
276 """Insulated slab, first mode only: T(L, t) = T0 - dT exp(-pi^2 alpha t / L^2) (Carslaw &
277 Jaeger 1959, ch. III). The back face rises from T0 - dT to T0, reaching 95 % of the rise at
278 t = ln(20) L^2 / (pi^2 alpha); energy is conserved, so the peak is the capacity-weighted mean."""
279 L, k, rho, cp, T0, dT = 0.01, 0.5, 1500.0, 1200.0, 400.0, 100.0
281 T_eq = float(np.sum(m._C * m.T) / np.sum(m._C))
282 alpha = k / (rho * cp)
283 res = solver.soak_back_history(factor=3.0)
284 assert res[
"duration_s"] == pytest.approx(3.0 * L * L / alpha, rel=1e-12)
285 s = res[
"stations"][
"slab"]
286 assert s[
"T_back_start"] == pytest.approx(T0 - dT, abs=1e-9)
287 assert s[
"T_back_peak"] == pytest.approx(T_eq, abs=0.01)
288 assert T_eq == pytest.approx(T0, abs=0.5)
289 t95 = math.log(20.0) * L * L / (math.pi ** 2 * alpha)
291 assert s[
"t_back_95_s"] == pytest.approx(t95, rel=0.02)
295 solver, _ =
_slab_solver(L=0.002, k=90.0, rho=1800.0, cp=700.0)
296 res = solver.soak_back_history(duration=600.0)
297 s = res[
"stations"][
"slab"]
298 assert s[
"t_back_peak_s"] < 0.05 * res[
"duration_s"]
305 assert soak[
"back_basis"] ==
"case outer face, adiabatic (upper bound)"
306 assert {r[
"station"]
for r
in soak[
"stations"]} == set(rp[
"stations"])
307 insert = next(r
for r
in soak[
"stations"]
if r[
"station"] ==
"throat")
311 assert insert[
"T_back_peak_K"] >= insert[
"T_back_start_K"]
312 assert insert[
"t_back_95_s"] > 60.0
313 assert insert[
"T_interface_peak_K"] > insert[
"T_back_peak_K"]
314 for r
in soak[
"stations"]:
315 assert r[
"T_back_peak_K"] >= r[
"T_back_start_K"] - 1e-9
319 bond =
max(soak[
"stations"], key=
lambda r: r[
"T_interface_peak_K"])
320 assert soak[
"interface_peak_K"] == bond[
"T_interface_peak_K"]
and soak[
"interface_station"] == bond[
"station"]
321 assert soak[
"t_interface_peak_s"] < 60.0 < soak[
"t_95_s"]
322 assert soak[
"asymptotic"]
is (soak[
"t_peak_s"] > 0.5 * soak[
"duration_s"])
324 assert m[
"name"]
and m[
"source"]
and m[
"assumptions"]
and m[
"inputs"]
325 assert all({
"value",
"unit",
"provenance"} <= set(v)
for v
in m[
"inputs"].values())
329 """The runner keeps only its last coupled solve. The soak-back runs on it only when it is the
330 replay asked about: same end time and same end throat; a replay that ended elsewhere, or at the
331 same time on another throat (another pass of the same burn), is refused, not soaked."""
333 rp = rpl.replay(p,
series(n=8, dt=0.4))
334 assert rp[
"available"], rp.get(
"error")
335 assert rpl.soak_back(p, rp)[
"available"]
336 later = dict(rp, t=list(rp[
"t"][:-1]) + [rp[
"t"][-1] + 1.0])
337 out = rpl.soak_back(p, later)
338 assert out[
"available"]
is False and "not this replay" in out[
"error"]
339 other_throat = dict(rp, A_throat_m2=list(rp[
"A_throat_m2"][:-1]) + [rp[
"A_throat_m2"][-1] * 1.001])
340 out = rpl.soak_back(p, other_throat)
341 assert out[
"available"]
is False and "not this replay" in out[
"error"]
348 """Run the coupled solver on a short burn and record the geometry each stability call saw."""
352 real = stab.comprehensive_stability_analysis
355 def spy(config, *a, **k):
356 cg = config.chamber_geometry
357 seen.append((float(cg.A_throat), float(cg.volume)))
358 return real(config, *a, **k)
360 monkeypatch.setattr(stab,
"comprehensive_stability_analysis", spy)
362 solver = TimeVaryingCoupledSolver(runner.config, runner.cea_cache, P_ambient=AMBIENT, **kw)
363 t = np.linspace(0.0, 3.4, 7)
364 solver.solve_time_series(t, np.linspace(560.0, 600.0, 7) * PSI, np.linspace(565.0, 605.0, 7) * PSI)
369 """Default off: every step's chug analysis runs on the as-built throat and volume, as it did
370 before the flag existed (time_varying_solver.py passed self.config)."""
372 assert solver.chug_eroded_geometry
is False
373 a0, v0 = solver.A_throat_initial, solver.V_chamber_initial
374 assert len(seen) == len(solver.state_history)
375 assert all(a == a0
and v == v0
for a, v
in seen)
377 assert solver.state_history[-1].A_throat > 1.02 * a0
381 """On: each step's chug loop sees that step's A_t and V, so its L* = V/A_t is the eroded one."""
382 on, seen_on =
_solve_capturing(config, monkeypatch, chug_eroded_geometry=
True)
383 for (a, v), s
in zip(seen_on, on.state_history):
384 assert a == pytest.approx(s.A_throat, rel=1e-12)
385 assert v == pytest.approx(s.V_chamber, rel=1e-12)
386 assert v / a == pytest.approx(s.Lstar, rel=1e-12)
388 m_on = [s.chugging_stability_margin
for s
in on.state_history]
389 m_off = [s.chugging_stability_margin
for s
in off.state_history]
392 assert m_on[0] == pytest.approx(m_off[0], rel=1e-12)
393 assert abs(m_on[-1] / m_off[-1] - 1.0) > 1e-3
395 for a, b
in zip(on.state_history, off.state_history):
396 assert a.Pc == b.Pc
and a.A_throat == b.A_throat
402@pytest.fixture(scope="module")
407 dv = rpl.delivered(rp, burn, 0.05)
408 dv[
"ambient_psia"] = [AMBIENT / PSI] * len(dv[
"t"])
409 hb = hardware_block(prep, rp, dv, burn)
410 assert hb[
"available"], hb.get(
"error")
416 assert hardware[
"t"] == rp[
"t"]
and hardware[
"index"] == rp[
"index"]
417 for key
in (
"throat_d_mm",
"At_ratio",
"eps",
"Lstar_m",
"contraction",
"liner_min_mm",
"insert_back_K"):
418 assert len(hardware[key]) == len(rp[
"t"]), key
423 cg = config.chamber_geometry
424 for k
in range(len(rp[
"t"])):
425 a_t = rp[
"A_throat_m2"][k]
427 assert rp[
"A_exit_m2"][k] == pytest.approx(cg.A_exit, rel=1e-12)
428 assert hardware[
"eps"][k] == pytest.approx(cg.A_exit / a_t, rel=1e-6)
430 assert hardware[
"Lstar_m"][k] == pytest.approx(rp[
"V_chamber_m3"][k] / a_t, rel=1e-6)
432 assert hardware[
"contraction"][k] == pytest.approx(rp[
"contraction_ratio"][k], rel=1e-6)
434 assert hardware[
"eps"][0] == pytest.approx(cg.A_exit / cg.A_throat, rel=1e-3)
435 assert hardware[
"Lstar_m"][0] == pytest.approx(cg.volume / cg.A_throat, rel=1e-3)
437 assert hardware[
"eps"][-1] < hardware[
"eps"][0]
443 t_l = config.ablative_cooling.initial_thickness * 1e3
444 for k
in range(len(rp[
"t"])):
445 want =
min(t_l - st[f
"liner{i}"][
"recession_mm"][k]
for i
in range(4))
446 assert hardware[
"liner_min_mm"][k] == pytest.approx(want, abs=1e-4)
448 assert hardware[
"insert_back_K"] == pytest.approx(st[
"throat"][
"T_interface_K"], abs=1e-3)
449 assert hardware[
"insert_back_basis"].startswith(
"insert back face")
450 assert any(
"upper bound" in a
for a
in hardware[
"model"][
"assumptions"])
454 """The first replay point has already eroded (the walls start at Fire): its A_t over as built is
455 the replay's own throat_area_ratio[0], a little above 1 (audit 9.2: 0.027 % on the He burn)."""
457 r0 = hardware[
"summary"][
"first_point_area_ratio"]
458 assert r0 == pytest.approx(rp[
"throat_area_ratio"][0], rel=1e-12)
459 assert 1.0 < r0 < 1.002
463 """With a case behind the insert the station's back face is the case's outer face; the insert's
464 own back face is the graphite/case interface. LE4 declares none, where the two are the same node."""
468 c = config.model_copy(deep=
True)
469 c.stainless_steel_case = StainlessSteelCaseConfig(thickness=0.00635)
471 burn =
series(n=8, dt=0.4)
472 rp = rpl.replay(p, burn)
473 assert rp[
"available"], rp.get(
"error")
474 thr = rp[
"stations"][
"throat"]
476 assert [lay[
"name"]
for lay
in rp[
"wall_layers"][
"throat"][
"layers"]] == [
"graphite",
"backing",
"case"]
477 hb = hardware_block(p, rp,
None, burn)
478 assert hb[
"available"], hb.get(
"error")
479 assert hb[
"insert_back_K"] == pytest.approx(thr[
"T_interface_K"], abs=1e-3)
481 assert thr[
"T_back_K"][-1] < thr[
"T_interface_K"][-1] - 1.0
482 assert hb[
"insert_back_basis"].startswith(
"insert back face")
487 c = hardware[
"contour"]
488 x, r0 = np.array(c[
"x_mm"]), np.array(c[
"r0_mm"])
489 cg = config.chamber_geometry
490 R_t = math.sqrt(cg.A_throat / math.pi) * 1e3
491 R_c = cg.chamber_diameter / 2.0 * 1e3
492 j0 = int(np.argmin(np.abs(x)))
493 assert x[j0] == pytest.approx(0.0, abs=1e-6)
and r0[j0] == pytest.approx(R_t, rel=1e-4)
494 assert r0[0] == pytest.approx(R_c, rel=1e-9)
496 assert c[
"x_face_mm"] == pytest.approx(-224.4, abs=0.1)
498 for k, r
in enumerate(c[
"frames"][
"r_mm"]):
501 assert r[j0] == pytest.approx(rp[
"D_throat_mm"][k] / 2.0, abs=1e-3)
503 assert r[0] - r0[0] == pytest.approx(st[
"liner0"][
"recession_mm"][k], abs=1e-3)
506 x_end = c[
"x_insert_mm"][1]
if c.get(
"x_insert_mm")
else 0.0
507 assert r.min() == pytest.approx(rp[
"D_throat_mm"][k] / 2.0, abs=1e-3)
508 assert np.allclose(r[x > x_end + 1e-6], r0[x > x_end + 1e-6], atol=1e-9)
510 je = int(np.argmin(np.abs(x - c[
"x_liner_end_mm"])))
511 if abs(x[je] - c[
"x_liner_end_mm"]) < 1e-6:
512 assert r[je] - r0[je] == pytest.approx(st[
"liner3"][
"recession_mm"][k], abs=1e-3)
513 assert c[
"liner_r_mm"][0] == pytest.approx(r0[0] + config.ablative_cooling.initial_thickness * 1e3, abs=1e-3)
514 assert c[
"insert_r_mm"][j0] == pytest.approx(r0[j0] + config.graphite_insert.initial_thickness * 1e3, abs=1e-3)
522 crit = (1.88 * 2.7 - 1.0) ** -0.64
523 assert crit == pytest.approx(0.40686, abs=1e-5)
524 pe = [0.45 * pa, 0.405 * pa, 0.35 * pa, 1.1 * pa]
525 out = separation([0.0, 1.0, 2.0, 3.0], pe, [pa] * 4, [2.7] * 4)
526 assert out[
"summerfield"] == [
False,
False,
True,
False]
527 assert out[
"schmucker"] == [
False,
True,
True,
False]
528 assert out[
"flag"]
is True
529 assert out[
"min_ratio"] == pytest.approx(0.35)
and out[
"t_min"] == 2.0
532 assert out[
"schmucker_pa_crit_psia"][3] == pytest.approx(1.1 * pa / crit / PSI, rel=1e-5)
533 assert out[
"schmucker_pe_sep_psia"][0] == pytest.approx(pa * crit / PSI, rel=1e-5)
534 full = separation([0.0], [1.05 * pa], [pa], [2.7])
535 assert full[
"flag"]
is False and full[
"margin"] == pytest.approx(1.05 / 0.4, rel=1e-6)
539 """Pad, He: p_e/p_a ~1.0-1.1 against Summerfield's 0.4 and Schmucker's ~0.41 (audit 9.2 s5)."""
540 sep = hardware[
"separation"]
541 assert sep[
"flag"]
is False
542 assert 0.95 < sep[
"min_ratio"] < 1.2
543 assert all(0.39 < c < 0.43
for c
in sep[
"schmucker_ratio"])
544 assert sep[
"pe_pa"][0] == pytest.approx(sep[
"pe_psia"][0] * PSI, rel=1e-6)
546 assert "Summerfield" in m[
"source"]
and "Schmucker" in m[
"source"]
551 isp = hardware[
"isp"]
552 cea = prep.link.sampler.runner.cea_cache
553 for k
in range(len(rp[
"t"])):
554 ideal, cl, nl, dl = isp[
"ideal_s"][k], isp[
"cstar_loss_s"][k], isp[
"nozzle_loss_s"][k], isp[
"delivered_s"][k]
555 assert ideal - cl - nl == pytest.approx(dl, abs=1e-5)
556 assert dl == pytest.approx(rp[
"isp_s"][k], abs=1e-6)
557 eta = rp[
"eta_cstar"][k]
558 assert cl == pytest.approx((1.0 - eta) * ideal, abs=1e-5)
560 Pc, MR, e, ka = rp[
"pc_psia"][k] * PSI, rp[
"mr"][k], rp[
"eps"][k], rp[
"kappa"][k]
561 cf_pc, cf_p0 = cea.eval_cf_vac(MR, Pc, e), cea.eval_cf_vac(MR, Pc / ka, e)
562 stag = eta * rp[
"cstar_ideal"][k] * (cf_pc - cf_p0 + (ka - 1.0) * AMBIENT * e / Pc) / 9.80665
563 assert isp[
"stagnation_loss_s"][k] == pytest.approx(stag, abs=2e-4)
565 assert isp[
"zeta_n_loss_s"][k] == pytest.approx(eta * rp[
"cstar_ideal"][k] * (1 - z) * cf_p0 / 9.80665, abs=2e-4)
566 assert "D16" in isp[
"model"][
"inputs"][
"nozzle_efficiency"][
"provenance"]
570 """The ideal is CEA's shifting-equilibrium Isp: checked against rocketcea itself at two points
571 (vacuum Isp and c* from CEA, less the ambient term p_a eps c*/(Pc g0)), not through the table."""
572 cea_obj = pytest.importorskip(
"rocketcea.cea_obj")
574 cache = prep.link.sampler.runner.cea_cache
575 C = cea_obj.CEA_Obj(oxName=cache.config.ox_name, fuelName=cache.config.fuel_name)
576 for k
in (0, len(rp[
"t"]) - 1):
577 pc_psia, MR, e = rp[
"pc_psia"][k], rp[
"mr"][k], rp[
"eps"][k]
578 isp_vac = C.get_Isp(Pc=pc_psia, MR=MR, eps=e)
579 cstar = C.get_Cstar(Pc=pc_psia, MR=MR) * 0.3048
580 hand = isp_vac - AMBIENT * e * cstar / (pc_psia * PSI * 9.80665)
581 assert hardware[
"isp"][
"ideal_s"][k] == pytest.approx(hand, rel=3e-3)
587 assert hardware_block(prep, {
"available":
False,
"error":
"x"})[
"available"]
is False
588 assert hardware_block(prep,
None)[
"available"]
is False
589 bad = hardware_block(prep, {
"available":
True,
"t": [0.1],
"stations": {},
"A_throat_m2": [
"?"]})
590 assert bad[
"available"]
is False and bad[
"error"]
597 """supply_K is the supply's share of K0. The line exit zeroes K0, so the drop left is the exit
598 dump alone and holds no supply share; left in, the chug loop subtracted a supply drop from the
599 dump (audit D7: gate 1.330 -> 1.248 after a feed-fit write, nothing physical changed)."""
602 c = config.model_copy(deep=
True)
603 for side
in c.feed_system.values():
605 out = line_exit_config(c)
606 assert all(side.supply_K == 0.0
for side
in out.feed_system.values())
607 assert all(side.supply_K == 0.6
for side
in c.feed_system.values())
610 assert _chug_feed_drop(out,
"oxidizer", dump, 1.87, 1140.0) == dump
612 assert _chug_feed_drop(c,
"oxidizer", dump, 1.87, 1140.0) < dump
616 """LE4 carries supply_K 0, so the D7 fix leaves its line-exit copy (and the baseline) as it was."""
617 assert all(side.supply_K == 0.0
for side
in config.feed_system.values())
618 a = line_exit_config(config)
619 b = copy.deepcopy(config)
620 for side
in b.feed_system.values():
621 side.K0, side.K1, side.phi_type, side.fittings, side.roughness_m = 0.0, 0.0,
"none", [],
None
622 assert a.model_dump() == b.model_dump()
626 """End to end: the replay's chug margin on a design that carries supply_K is the one on the same
627 design without it (both through the line-exit copy)."""
628 c = config.model_copy(deep=
True)
629 for side
in c.feed_system.values():
631 short =
series(n=8, dt=0.4)
634 assert a[
"available"]
and b[
"available"]
635 assert a[
"chug_margin"] == b[
"chug_margin"]
638@pytest.fixture(scope="module")
642 return build_card(config, center_pa=575.0 * PSI, ambient_pa=AMBIENT)
648 plain = card.chamber_model(ambient_pressure=AMBIENT)
649 ex = expansion_chamber(card, prep.link.sampler.runner.cea_cache, ambient_pressure=AMBIENT)
650 for mo, mf
in ((1.80, 1.20), (1.87, 1.29), (1.95, 1.25), (0.0, 0.0)):
651 assert ex.evaluate(mo, mf) == plain.evaluate(mo, mf)
652 assert ex.expansion_scale(1.5, 2.7e6) == 1.0
654 assert m[
"name"]
and m[
"source"]
and m[
"assumptions"]
655 assert m[
"inputs"][
"eps0"][
"value"] == pytest.approx(card.exit_area / card.throat_area, rel=1e-12)
657 assert m[
"inputs"][
"cea_eps_axis"][
"value"]
is True
661 """Throat 4 % larger in area, exit fixed: v_vac x Cf_vac(eps0/1.04)/Cf_vac(eps0), from the table;
662 chamber pressure and flows untouched."""
665 cea = prep.link.sampler.runner.cea_cache
666 plain = card.chamber_model(ambient_pressure=AMBIENT)
667 ex = expansion_chamber(card, cea, ambient_pressure=AMBIENT)
668 plain.throat_area = ex.throat_area = card.throat_area * 1.04
670 a, b = plain.evaluate(mo, mf), ex.evaluate(mo, mf)
671 assert b.pressure == a.pressure
and b.mdot_total == a.mdot_total
672 eps0 = card.exit_area / card.throat_area
673 ratio = cea.eval_cf_vac(a.mixture_ratio, a.pressure, eps0 / 1.04) / cea.eval_cf_vac(a.mixture_ratio, a.pressure, eps0)
675 assert (b.thrust + AMBIENT * card.exit_area) / (a.thrust + AMBIENT * card.exit_area) == pytest.approx(ratio, rel=1e-12)
676 assert b.specific_impulse == pytest.approx(b.thrust / (b.mdot_total * 9.80665), rel=1e-12)
680 """Off the card's O/F range the excess propellant passes the throat unburned (CardChamber's
681 ``burning``). With the Cf_vac ratio forced to 1 the expansion chamber must be the card's, at any
682 throat and any O/F: it priced thrust on the total flow instead (O/F 20 at a 0.1 % larger throat:
683 2489 N against the card's 0 on LE4 (3)'s card)."""
686 plain = card.chamber_model(ambient_pressure=AMBIENT)
687 ex = expansion_chamber(card, prep.link.sampler.runner.cea_cache, ambient_pressure=AMBIENT)
688 ex.cf_vac =
lambda mr, p, eps: 1.0
689 plain.throat_area = ex.throat_area = card.throat_area * 1.001
690 lo, hi = card.chamber.cstar.x0, card.chamber.cstar.x_max
691 cases = [(1.90, 1.27), (2.0, 0.1), (0.5, 1.0), (hi * 1.6, 1.0), (lo * 0.5, 1.0)]
692 assert any(
not lo <= mo / mf <= hi
for mo, mf
in cases)
694 a, b = plain.evaluate(mo, mf), ex.evaluate(mo, mf)
695 assert b.thrust == pytest.approx(a.thrust, rel=1e-12, abs=1e-9), (mo, mf)
696 assert b.pressure == a.pressure
and b.specific_impulse == pytest.approx(a.specific_impulse, rel=1e-12, abs=1e-12)
700 """At the replay's eroded throat and EngineDesign's own flows, the plain card's thrust runs high
701 (it keeps the as-built eps); the expansion-scaled card closes most of it (audit D4-B)."""
705 plain = card.chamber_model(ambient_pressure=AMBIENT)
706 ex = expansion_chamber(card, prep.link.sampler.runner.cea_cache, ambient_pressure=AMBIENT)
708 plain.throat_area = ex.throat_area = rp[
"A_throat_m2"][k]
709 F = rp[
"thrust_N"][k]
710 e_plain = plain.evaluate(rp[
"mdot_O"][k], rp[
"mdot_F"][k]).thrust / F - 1.0
711 e_ex = ex.evaluate(rp[
"mdot_O"][k], rp[
"mdot_F"][k]).thrust / F - 1.0
712 assert e_plain > 2e-3
713 assert abs(e_ex) < 0.4 * abs(e_plain)
714 assert ex.eps_clamped == 0
718 """The narrowest drawn wall must be D_t0 + 2 s_throat, the solver's throat: with the insert's
719 downstream half left as built, the drawing kept the as-built throat while the run reported the
720 throat area growing (2026-10-03)."""
725 x = np.linspace(-60.0, 40.0, 401)
727 r0 = r_t0 + 0.004 * x ** 2
728 stations = {
"throat": {
"kind":
"graphite",
"recession_mm": [0.0, 0.47]}}
729 for k, s
in enumerate((0.0, 0.47)):
730 wall = eroded_radius(x, r0, stations, k, x_liner_end=-12.0, x_insert_end=12.0)
731 assert 2 * wall.min() == pytest.approx(2 * (r_t0 + s), abs=1e-9)
733 wall = eroded_radius(x, r0, stations, 1, x_liner_end=-12.0, x_insert_end=12.0)
734 assert np.allclose(wall[x > 12.0 + 1e-9], r0[x > 12.0 + 1e-9])
738 """LE4's insert sits in the liner's phenolic out to the 304 case (~46 mm). Over a burn:
740 * the case stays at ambient: the phenolic's penetration depth sqrt(alpha t) is ~0.7 mm;
741 * the phenolic still draws ~k dT / sqrt(pi alpha t) ~0.4 MW/m2 from a ~2000 K insert, which a 6 mm
742 graphite slab (rho c t ~7.7 kJ/m2K) feels as ~100-200 K over 3 s: the insert runs cooler and its
743 throat erodes less than the adiabatic upper bound (no case declared, nothing behind the insert).
745 c_case = config.model_copy(deep=
True)
746 c_none = config.model_copy(deep=
True)
747 c_none.stainless_steel_case =
None
748 burn =
series(n=8, dt=0.4)
749 with_case = rpl.replay(
fake_prep(c_case), burn)
750 without = rpl.replay(
fake_prep(c_none), burn)
751 assert with_case[
"available"]
and without[
"available"]
752 thr_c, thr_n = with_case[
"stations"][
"throat"], without[
"stations"][
"throat"]
753 assert max(abs(v - 300.0)
for v
in thr_c[
"T_back_K"]) < 1.0
754 assert thr_c[
"T_interface_K"][-1] < thr_n[
"T_back_K"][-1] - 50.0
755 g_case = with_case[
"throat_area_ratio"][-1] - 1.0
756 g_none = without[
"throat_area_ratio"][-1] - 1.0
757 assert 0.0 < g_case < g_none
static emmc_card_info_t card
test_soak_refuses_another_replays_walls(config)
test_contour_as_built_and_eroded(hardware, replayed, config)
test_hardware_follows_the_replay_points(hardware, replayed)
test_axial_sidecar_is_rows_of_t_by_columns_of_x(replayed)
test_the_legacy_path_refuses_an_eroding_engine(config)
test_expansion_chamber_scales_vacuum_velocity_by_cf_vac(card, prep)
test_a_line_exit_replay_ignores_supply_k(config, burn)
test_expansion_chamber_follows_enginedesign_at_the_eroded_throat(card, prep, replayed)
test_an_untracked_liner_still_erodes(config, burn)
test_chug_sees_the_design_point_by_default(config, monkeypatch)
test_line_exit_is_unchanged_for_le4(config)
test_line_exit_drops_the_supply_share_with_k0(config)
test_le4_runs_full_on_the_pad(hardware)
hardware(prep, replayed, burn)
test_stations_carry_recession_temperatures_and_load(replayed, config)
test_chug_sees_the_eroded_chamber_when_asked(config, monkeypatch)
test_drawn_throat_is_the_solvers_throat()
test_a_graphite_only_engine_now_erodes(config, burn)
test_the_phenolic_backing_cools_the_insert_and_shields_the_case(config)
test_the_replay_keeps_its_old_keys(replayed)
test_every_kept_array_is_one_value_per_replay_point(replayed)
series(int n=69, float dt=0.05, po=(560.0, 600.0), pf=(565.0, 605.0))
test_isp_waterfall_closes_and_its_terms_are_the_thrust_identities(hardware, replayed, prep)
test_soak_on_le4_is_an_adiabatic_upper_bound(replayed)
test_hardware_never_raises(prep)
test_erodes_names_the_engines_with_a_receding_wall(config)
test_throat_diameter_is_its_area(replayed)
test_expansion_chamber_is_the_card_at_the_as_built_throat(card, prep)
_slab_solver(L=0.01, k=0.5, rho=1500.0, cp=1200.0, T0=400.0, dT=100.0)
test_insert_back_face_with_a_declared_case_is_the_bondline(config)
test_soak_window_is_three_conduction_times_of_the_slowest_layer(replayed, config)
test_injector_drop_is_line_exit_less_the_dump_and_chamber(replayed)
test_expansion_chamber_prices_thrust_on_the_flow_that_burns(card, prep)
test_eps_lstar_and_contraction_from_the_areas_by_hand(hardware, replayed, config)
test_summerfield_and_schmucker_by_hand()
test_summary_first_point_is_against_as_built(hardware, replayed)
test_station_loads_are_the_solvers_own_barrel_and_throat_loads(replayed)
test_isp_ideal_is_cea_by_hand(hardware, replayed, prep)
test_cstar_efficiency_is_its_breakdown(replayed)
_solve_capturing(config, monkeypatch, **kw)
test_liner_left_and_insert_back_face(hardware, replayed, config)
fake_prep(config, float dt=0.05, float ambient=AMBIENT)
test_soak_dates_a_plateau_when_it_is_reached()
test_insulated_slab_soaks_to_its_stored_heat_on_the_fourier_clock()