1"""Phase 08: a Layer-1 engine dropped onto the end of a feed system.
3The promise being tested is narrow and checkable: **point it at what the
4optimizer wrote, and nothing gets retyped.** So most of these tests read real
5EngineDesign configs off disk when the sibling repo is present, and fall back to
6inline fixtures when it is not -- the fixtures are copied from the real schema,
7so a schema change breaks the on-disk tests loudly rather than leaving the
8inline ones passing against a format nobody uses any more.
10The two things that fail silently get the most attention. An **injector area**
11from the wrong formula is a wrong mass flow and every number downstream stays
12plausible, so each injector type's area is checked against its closed form. And
13a **combustion table read outside its range** returns a clamped number that
14looks like a physical plateau, so extrapolation is surfaced rather than absorbed.
17from __future__
import annotations
20from pathlib
import Path
35 register_injector_type,
36 register_propellant_alias,
37 registered_injector_types,
43ENGINE_DESIGN = Path(__file__).resolve().parents[3] /
"EngineDesign"
44REAL_CONFIG = ENGINE_DESIGN /
"configs" /
"impinging_lox_ch4_8000N_optimal.yaml"
45REAL_CACHE = ENGINE_DESIGN /
"output" /
"cache" /
"cea_cache_LOX_CH4_3D.npz"
47needs_engine_design = pytest.mark.skipif(
48 not REAL_CONFIG.exists(), reason=
"EngineDesign configs not present"
50needs_cea = pytest.mark.skipif(
51 not REAL_CACHE.exists(), reason=
"EngineDesign CEA cache not present"
56 """The real schema, trimmed to what the importer reads."""
63 "d_jet": 0.0029682419398978075,
64 "impingement_angle": 76.4,
69 "d_jet": 0.0020731739365082054,
70 "impingement_angle": 71.7,
76 "oxidizer": {
"Cd_inf": 0.4,
"a_Re": 0.15,
"Cd_min": 0.15},
77 "fuel": {
"Cd_inf": 0.4,
"a_Re": 0.15,
"Cd_min": 0.15},
80 "oxidizer": {
"name":
"LOX",
"temperature": 90.0},
81 "fuel": {
"name":
"Methane",
"temperature": 112.0},
84 "A_throat": 0.0013982470588664619,
85 "expansion_ratio": 7.01837178880855,
86 "volume": 0.002081806580056309,
87 "design_pressure": 2413166.0,
89 "design_thrust": 8000.0,
99 """The Phase 08 promise, against the file the optimizer actually wrote."""
100 design = load_engine(REAL_CONFIG)
101 assert design.injector_type ==
"impinging"
102 assert design.oxidiser.propellant ==
"oxygen"
103 assert design.fuel.propellant ==
"methane"
104 assert design.throat_area > 0.0
105 assert design.design_thrust == 8000.0
106 assert design.design_mixture_ratio == 3.5
108 assert "fluids.oxidizer.name" in design.provenance[
"oxidizer.propellant"]
109 assert "discharge.oxidizer" in design.provenance[
"oxidizer.Cd"]
114 """A schema drift in EngineDesign should break here, loudly."""
115 configs = sorted((ENGINE_DESIGN /
"configs").glob(
"*.yaml"))
116 assert configs,
"no configs found to check against"
120 design = load_engine(path)
121 except EngineImportError:
123 assert design.throat_area > 0.0
124 assert design.oxidiser.area > 0.0
and design.fuel.area > 0.0
131 assert design.injector_type ==
"impinging"
132 assert design.oxidiser.element_count == 18
136 """An import failure saying only 'missing field' sends someone hunting."""
138 del config[
"chamber_geometry"][
"A_throat"]
139 with pytest.raises(EngineImportError, match=
"A_throat"):
140 engine_from_config(config, name=
"inline")
143 del config[
"injector"][
"geometry"][
"fuel"]
144 with pytest.raises(EngineImportError, match=
"fuel"):
145 engine_from_config(config, name=
"inline")
150 config[
"fluids"][
"fuel"][
"name"] =
"Methaine"
151 with pytest.raises(EngineImportError, match=
"methane"):
152 engine_from_config(config, name=
"inline")
156 """A wrong area is a wrong mass flow, and everything after it looks fine."""
158 config[
"injector"][
"type"] =
"swirl"
159 with pytest.raises(EngineImportError, match=
"no area extractor"):
160 engine_from_config(config, name=
"inline")
164 assert species_for(
"LOX") == species_for(
"oxygen") ==
"oxygen"
165 assert species_for(
"CH4") == species_for(
"Methane") ==
"methane"
166 assert species_for(
"EtOH") ==
"ethanol"
167 with pytest.raises(UnknownPropellant):
168 species_for(
"unobtainium")
169 register_propellant_alias(
"unobtainium",
"helium")
170 assert species_for(
"Unobtainium") ==
"helium"
178 area, hydraulic, count = injector_areas(
"impinging", geometry,
"oxidizer")
179 d = 0.0029682419398978075
180 assert area == pytest.approx(18 * math.pi * d * d / 4.0, rel=1e-12)
181 assert hydraulic == pytest.approx(d)
186 """The asymmetry is the trap: one formula applied to both is silently wrong."""
188 "lox": {
"n_orifices": 14,
"d_orifice": 0.00272},
189 "fuel": {
"d_pintle_tip": 0.030,
"h_gap": 0.0012},
191 ox_area, ox_d, ox_n = injector_areas(
"pintle", geometry,
"oxidizer")
192 assert ox_area == pytest.approx(14 * math.pi * 0.00272**2 / 4.0, rel=1e-12)
195 fuel_area, fuel_d, _ = injector_areas(
"pintle", geometry,
"fuel")
196 r_in, r_out = 0.015, 0.015 + 0.0012
197 assert fuel_area == pytest.approx(math.pi * (r_out**2 - r_in**2), rel=1e-12)
199 assert fuel_d == pytest.approx(2 * 0.0012)
203 assert fuel_area != pytest.approx(ox_area)
208 "core": {
"n_ports": 7,
"d_port": 0.0018},
209 "annulus": {
"inner_diameter": 0.006,
"gap_thickness": 0.0004},
211 ox_area, _, ox_n = injector_areas(
"coaxial", geometry,
"oxidizer")
212 assert ox_area == pytest.approx(7 * math.pi * 0.0018**2 / 4.0, rel=1e-12)
214 fuel_area, fuel_d, _ = injector_areas(
"coaxial", geometry,
"fuel")
215 assert fuel_area == pytest.approx(math.pi * (0.0034**2 - 0.003**2), rel=1e-12)
216 assert fuel_d == pytest.approx(0.0008)
220 assert set(registered_injector_types()) >= {
"impinging",
"pintle",
"coaxial"}
221 register_injector_type(
"slot",
lambda geometry, side: (1e-5, 1e-3, 1))
222 assert "slot" in registered_injector_types()
223 assert injector_areas(
"slot", {},
"fuel") == (1e-5, 1e-3, 1)
224 with pytest.raises(KeyError, match=
"unknown injector type"):
225 injector_areas(
"vortex", {},
"fuel")
232 """Must agree with EngineDesign's own answer or the two model different hardware."""
234 model = design.oxidiser.discharge
235 for reynolds
in (1e3, 1e4, 1e5, 1e6):
236 expected = 0.4 - 0.15 / math.sqrt(reynolds)
237 assert model.cd(reynolds) == pytest.approx(
238 min(
max(expected, 0.15), 0.4), rel=1e-12
244 model = design.oxidiser.discharge
247 assert model.cd(0.2) == pytest.approx(model.cd_min)
248 assert model.cd(1.0) == pytest.approx(0.25)
249 assert model.cd(1e12) <= model.cd_inf
250 assert model.cd(0.0) == model.cd_min
251 assert model.cd(-5.0) == model.cd_min
255 """Low-Re Cd is materially below its high-Re value, which matters at startup."""
257 side = design.oxidiser
258 low = side.cd_at(0.01, rho=1140.0, mu=1.8e-4)
259 high = side.cd_at(2.0, rho=1140.0, mu=1.8e-4)
260 assert low < high <= side.discharge.cd_inf
268 ox, _ = injector_legs(design)
271 area = ox.effective_area(mdot, flow)
272 assert ox.pressure_drop(mdot, flow) == pytest.approx(
273 mdot * mdot / (2.0 * 1140.0 * area * area), rel=1e-12
279 ox, _ = injector_legs(design)
281 assert ox.pressure_drop(1.0, flow) == pytest.approx(ox.pressure_drop(-1.0, flow))
283 ratio = ox.pressure_drop(2.0, flow) / ox.pressure_drop(1.0, flow)
284 assert 3.5 < ratio < 4.0
288 """A sanity check on the imported areas, in the units a designer thinks in."""
290 ox, fuel = injector_legs(design)
295 mr = design.design_mixture_ratio
296 mdot_ox = mdot_total * mr / (1.0 + mr)
297 mdot_fuel = mdot_total / (1.0 + mr)
299 pc = design.design_chamber_pressure
300 for leg, mdot, conditions
in ((ox, mdot_ox, lox), (fuel, mdot_fuel, ch4)):
301 stiffness = leg.pressure_drop(mdot, conditions) / pc
302 assert 0.05 < stiffness < 1.0, f
"{leg.id} stiffness {stiffness:.3f}"
310 result = chamber.evaluate(1.5, 0.4)
311 assert result.pressure == pytest.approx(1.9 * 1800.0 / 1.4e-3, rel=1e-12)
312 assert result.mixture_ratio == pytest.approx(1.5 / 0.4)
313 assert result.thrust == pytest.approx(1.5 * result.pressure * 1.4e-3, rel=1e-12)
317 """Not a numerical guard: a chamber is open to atmosphere through its nozzle.
319 Without this the loop drives chamber pressure toward zero when nothing is
320 flowing, and then asks the feed system to solve a 650 psi drop into 0.5 psi
321 -- a state no hardware occupies and no solver enjoys.
324 assert chamber.evaluate(0.0, 0.0).pressure == pytest.approx(101325.0)
325 trickle = chamber.evaluate(1e-6, 1e-6)
326 assert trickle.pressure == pytest.approx(101325.0)
327 assert trickle.thrust == 0.0
328 assert not chamber.is_firing(trickle)
329 assert chamber.is_firing(chamber.evaluate(1.5, 0.4))
334 assert vacuum.evaluate(0.0, 0.0).pressure == pytest.approx(1.0)
338 """Why the chamber is quasi-steady: it responds 1000x faster than the feed."""
340 tau = chamber.fill_time(3.0e6, 3.5)
341 assert 1e-4 < tau < 5e-3
346 table =
CEATable(REAL_CACHE, expansion_ratio=7.0)
347 assert table.propellants == (
"LOX",
"CH4")
348 state = table.combustion(3.0e6, 3.5)
349 assert 1700 < state.cstar < 1900
350 assert 3200 < state.temperature < 3600
351 assert 1.10 < state.gamma < 1.20
352 assert 1.3 < state.thrust_coefficient < 1.8
353 assert not state.extrapolated
358 """A real physical shape, not a monotone fit: LOX/CH4 peaks near O/F 2.8."""
359 table =
CEATable(REAL_CACHE, expansion_ratio=7.0)
360 ratios = [2.5, 2.8, 3.2, 3.6, 4.0]
361 cstars = [table.combustion(3.0e6, mr).cstar
for mr
in ratios]
362 peak = cstars.index(
max(cstars))
363 assert 0 <= peak <= 2, f
"c* peaked at O/F {ratios[peak]}"
364 assert cstars[-1] <
max(cstars)
369 """A clamped c* looks like a physical plateau on a plot. It is a table edge."""
370 table =
CEATable(REAL_CACHE, expansion_ratio=7.0)
371 inside = table.combustion(3.0e6, 3.5)
372 assert not inside.extrapolated
375 outside = table.combustion(3.0e6, 6.0)
376 assert outside.extrapolated
379 edge = table.combustion(3.0e6, float(table.mixture_ratios[-1]))
380 assert outside.cstar == pytest.approx(edge.cstar)
385 """The output that makes O/F drift visible instead of merely tracked."""
386 table =
CEATable(REAL_CACHE, expansion_ratio=7.0)
387 cold = table.combustion(3.0e6, 2.5).temperature
388 hot = table.combustion(3.0e6, 3.6).temperature
389 assert hot > cold + 100.0
398 """Why a pressure-fed engine is stable in the large.
400 Raise the feed pressure and chamber pressure rises, but by *less*, because
401 the rise eats into the injector's own pressure difference. If chamber
402 pressure tracked feed pressure one-for-one there would be no loop.
404 design = load_engine(REAL_CONFIG)
406 design.throat_area,
CEATable(REAL_CACHE, expansion_ratio=design.expansion_ratio)
408 ox, fuel = injector_legs(design)
410 def fire(feed: float) -> tuple[float, float]:
415 dp =
max(feed - pc, 1.0)
416 mdot_ox = math.sqrt(2.0 * 1140.0 * dp) * ox.effective_area(1.5, lox)
417 mdot_fuel = math.sqrt(2.0 * 422.6 * dp) * fuel.effective_area(0.4, ch4)
418 new = chamber.evaluate(mdot_ox, mdot_fuel).pressure
419 if abs(new - pc) < 1.0:
422 pc += 0.3 * (new - pc)
425 low_pc, low_dp = fire(4.0e6)
426 high_pc, high_dp = fire(5.0e6)
428 assert high_pc > low_pc
430 assert (high_pc - low_pc) < 1.0e6
432 assert high_dp > low_dp
438ETHALOX = ENGINE_DESIGN /
"configs" /
"ethalox_doublet_7000N.yaml"
439ETHALOX_CACHE = ENGINE_DESIGN /
"output" /
"cache" /
"cea_cache_LOX_Ethanol_3D.npz"
441needs_ethalox = pytest.mark.skipif(
442 not ETHALOX.exists()
or not ETHALOX_CACHE.exists(),
443 reason=
"the ethalox config or its CEA cache is not present",
449 """LOX/ethanol, impinging doublet. The engine actually being built."""
450 design = load_engine(ETHALOX)
451 assert design.injector_type ==
"impinging"
452 assert design.oxidiser.propellant ==
"oxygen"
453 assert design.fuel.propellant ==
"ethanol"
454 assert design.oxidiser.element_count == 26
455 assert design.fuel.element_count == 26
460 assert design.design_thrust == 7200.0
465 """A cross-check between the two tools, not a restatement of one of them.
467 The config asks for ``optimal_of_ratio: 1.65``. Reading c* straight off the
468 CEA table the optimiser used, the peak should land there — and if it does
469 not, one of the two is looking at the wrong propellant pair.
471 design = load_engine(ETHALOX)
472 table =
CEATable(ETHALOX_CACHE, expansion_ratio=design.expansion_ratio)
473 assert table.propellants == (
"LOX",
"Ethanol")
475 ratios = [1.2, 1.4, 1.65, 1.9, 2.2]
476 cstars = [table.combustion(2.9e6, mr).cstar
for mr
in ratios]
477 peak = ratios[cstars.index(
max(cstars))]
478 assert peak == pytest.approx(1.65, abs=0.3)
483 """The config asks for 20–30% injector stiffness on both legs."""
484 design = load_engine(ETHALOX)
485 ox, fuel = injector_legs(design)
490 pc = 420.0 * 6894.757293168361
491 table =
CEATable(ETHALOX_CACHE, expansion_ratio=design.expansion_ratio)
492 cstar = table.combustion(pc, 1.65).cstar
493 mdot_total = pc * design.throat_area / cstar
494 mdot_ox = mdot_total * 1.65 / 2.65
495 mdot_fuel = mdot_total / 2.65
497 for leg, mdot, conditions
in ((ox, mdot_ox, lox), (fuel, mdot_fuel, ethanol)):
498 stiffness = leg.pressure_drop(mdot, conditions) / pc
499 assert 0.10 < stiffness < 0.60, f
"{leg.id} stiffness {stiffness:.3f}"
506 """The ethalox config carries its design point twice, and they disagree.
508 ``chamber_geometry`` is what the geometry was last sized at:
509 ``design_MR`` 2.55, a leftover from when this config was LOX/methane, and
510 ``design_pressure`` 350 psi. ``design_requirements`` is what the optimiser
511 was told to hit: ``optimal_of_ratio`` 1.65, where the LOX/ethanol c*
512 actually peaks, and ``target_chamber_pressure_psi`` 420.
514 **Intent wins**, for both. The geometry block goes stale every time a
515 config is re-run or switches propellant pair, so a disagreement is the
516 normal state of a config being worked on -- and this used to raise a
517 warning on it, which is how a healthy config came to shout at its operator
518 twice on every import.
520 Not silently, though. Which field was used, and what the other one said, is
521 recorded in provenance, so the choice is auditable without being noise.
523 design = load_engine(ETHALOX)
525 assert design.design_mixture_ratio == pytest.approx(1.65)
526 assert design.design_chamber_pressure / 6894.757293168361 == pytest.approx(
529 assert not design.warnings,
"a config disagreeing with itself is not a warning"
532 (
"mixture_ratio",
"2.55"),
533 (
"design_chamber_pressure",
"350"),
535 note = design.provenance[key]
536 assert "intent wins" in note, f
"{key} does not say which field won"
537 assert stale
in note, f
"{key} does not record what the stale field said"
542 """A warning that fires on healthy input is noise, and noise gets ignored."""
543 assert load_engine(REAL_CONFIG).warnings == ()
548 """`use_geometry_cd` is on in this config, so hole size moves Cd_inf."""
549 design = load_engine(ETHALOX)
550 model = design.oxidiser.discharge
551 assert model.use_geometry_cd
554 assert model.cd_infinite(2.0e-3) == pytest.approx(model.cd_inf, rel=1e-9)
556 assert model.cd_infinite(1.0e-3) < model.cd_inf < model.cd_infinite(4.0e-3)
557 assert model.cd_infinite(1.0e-6) >= model.cd_inf_min_geom
558 assert model.cd_infinite(1.0) <= model.cd_inf_max
562 """The shipped ethalox config with a couple of keys changed.
564 Built from the real file rather than a hand-rolled dict: the importer
565 legitimately requires a full injector geometry, and a synthetic fixture
566 would be testing the fixture.
571 raw = yaml.safe_load(ETHALOX.read_text())
572 for dotted, value
in overrides.items():
573 section, key = dotted.split(
".")
575 raw.get(section, {}).pop(key,
None)
577 raw.setdefault(section, {})[key] = value
578 path = tmp_path /
"e.yaml"
579 path.write_text(yaml.safe_dump(raw))
580 return load_engine(path)
585 """Intent wins for all three, or the rule is not a rule.
587 `chamber_geometry.design_thrust` is the last optimiser run's achievement;
588 `design_requirements.target_thrust` is what was asked for. The importer
589 already preferred intent for O/F and Pc and read thrust bare, so the shipped
590 ethalox config imported as 7000 N against a delivered 7200 N -- silently.
593 assert engine.design_thrust == pytest.approx(7200.0)
594 assert "target_thrust" in engine.provenance[
"design_thrust"]
595 assert "7000" in engine.provenance[
"design_thrust"],
"name the value it overrode"
600 engine =
_config_with(tmp_path, **{
"design_requirements.target_thrust":
None})
601 assert engine.design_thrust == pytest.approx(7000.0)
602 assert "chamber_geometry.design_thrust" in engine.provenance[
"design_thrust"]
607 """A warning that fires on healthy input teaches people to skim past it."""
608 engine =
_config_with(tmp_path, **{
"chamber_geometry.design_thrust": 7200.0})
609 assert "that is the last optimiser run" not in engine.provenance[
"design_thrust"]
TOML_PURE_GETTER bool contains(std::string_view key) const noexcept
None test_every_shipped_config_imports()
None test_chamber_pressure_is_mdot_cstar_over_throat()
None test_cstar_peaks_below_the_stoichiometric_ratio()
None test_impinging_area_is_n_round_jets()
None test_the_geometry_cd_model_is_honoured_when_the_config_asks_for_it()
None test_pintle_uses_orifices_on_one_side_and_an_annulus_on_the_other()
dict[str, object] impinging_config()
None test_thrust_resolves_like_mixture_ratio_and_chamber_pressure(tmp_path)
( None) test_a_config_that_disagrees_with_itself_resolves_to_intent_and_records_it()
None test_chamber_fill_time_is_milliseconds()
None test_agreeing_thrust_values_are_not_flagged(tmp_path)
None test_thrust_falls_back_when_no_target_is_stated(tmp_path)
None test_chamber_at_altitude_takes_a_different_floor()
None test_cd_rises_with_flow()
None test_ethalox_injector_stiffness_is_in_the_design_band()
None test_injector_leg_follows_the_orifice_relation()
None test_leaving_the_table_is_reported_not_absorbed()
None test_an_unknown_propellant_is_refused_with_a_suggestion()
None test_cd_follows_the_engine_design_correlation()
None test_the_chamber_loop_absorbs_part_of_an_upstream_change()
None test_a_missing_key_names_itself_and_where_it_looked()
None test_an_unknown_injector_type_is_refused_not_guessed()
None test_injector_dp_is_symmetric_and_quadratic()
None test_injector_types_are_registered_not_hardcoded()
None test_ethalox_cstar_peaks_where_the_optimiser_put_the_mixture_ratio()
None test_a_shut_engine_sits_at_ambient_not_at_vacuum()
None test_cea_table_reproduces_real_combustion()
None test_injector_stiffness_at_the_design_point_is_sane()
None test_a_consistent_config_warns_about_nothing()
None test_chamber_temperature_moves_with_mixture_ratio()
None test_import_from_a_parsed_config()
_config_with(tmp_path, **overrides)
None test_the_current_vehicle_engine_imports()
None test_a_real_layer1_config_imports_with_nothing_retyped()
None test_cd_is_clamped_at_both_ends()
None test_coaxial_uses_core_ports_and_an_outer_annulus()
None test_propellant_aliases_cover_both_naming_conventions()