1"""The mixture-ratio split: is it exact, and does it blame the right half?
3The claim :mod:`feedtwin.engine.balance` makes is unusually strong for a
4diagnostic -- not "this correlates with" but "this is identically equal to". So
5the tests are correspondingly strict: the identity is checked to machine
6precision rather than to a tolerance, and the two terms are checked to be
7*independent* in the way the claim requires. A face ratio that moved when the
8plumbing changed would make the whole decomposition a lie, and it would be a
9comfortable lie -- the numbers would still add up.
12from __future__
import annotations
28 *, ox_area: float = 99.245e-6, fuel_area: float = 75.125e-6, mr: float = 1.65
30 """A two-side engine with nothing in it but the areas that matter here."""
33 injector_type=
"impinging",
37 design_mixture_ratio=mr,
41def _orifice(area: float, cd: float, rho: float, dp: float) -> float:
42 return cd * area * math.sqrt(2.0 * rho * dp)
51 rho_ox: float = 1140.0,
52 rho_fuel: float = 789.0,
53 design: EngineDesign |
None =
None,
55 """A balance built from flows that genuinely obey the orifice law."""
59 mdot_oxidiser=
_orifice(design.oxidiser.area, cd, rho_ox, dp_ox),
60 mdot_fuel=
_orifice(design.fuel.area, cd, rho_fuel, dp_fuel),
61 density_oxidiser=rho_ox,
62 density_fuel=rho_fuel,
63 injector_dp_oxidiser=dp_ox,
64 injector_dp_fuel=dp_fuel,
72@pytest.mark.parametrize(
74 [(3.0e5, 3.0e5), (3.6e5, 1.9e5), (1.0e5, 9.0e5), (5.0e4, 5.1e4)],
77 """``O/F == face_ratio * feed_term``, to floating point and no further.
79 This is the whole basis of the decomposition. A tolerance here would hide
80 exactly the algebra slip the test exists to catch, so it is checked at
81 1e-12 relative -- the residual on real solves comes out at 1e-16.
83 balance =
_balance(dp_ox=dp_ox, dp_fuel=dp_fuel)
84 assert balance.residual < 1e-12
85 assert balance.face_ratio * balance.feed_term == pytest.approx(
86 balance.mixture_ratio, rel=1e-12
91 """Change only the pressure split; the face term must not budge.
93 The independence claim. If the face ratio drifted with the feed system, the
94 split would still sum correctly and would no longer localise the fault --
95 which is the only reason to compute it.
97 balanced =
_balance(dp_ox=3.0e5, dp_fuel=3.0e5)
98 lopsided =
_balance(dp_ox=6.0e5, dp_fuel=1.0e5)
99 assert lopsided.face_ratio == pytest.approx(balanced.face_ratio, rel=1e-12)
100 assert lopsided.mixture_ratio > balanced.mixture_ratio
104 """With both legs at the same drop, the stand contributes exactly nothing."""
105 balance =
_balance(dp_ox=4.0e5, dp_fuel=4.0e5)
106 assert balance.feed_term == pytest.approx(1.0, rel=1e-12)
107 assert balance.mixture_ratio == pytest.approx(balance.face_ratio, rel=1e-12)
111 """``Cd A sqrt(rho)`` on each side, computed independently."""
113 balance =
_balance(dp_ox=2.5e5, dp_fuel=2.5e5, design=design)
115 design.oxidiser.area * math.sqrt(1140.0) / (design.fuel.area * math.sqrt(789.0))
117 assert balance.face_ratio == pytest.approx(expected, rel=1e-12)
121 """Same area, same fluid, same drop -- there is nowhere for a ratio to come
122 from, and a decomposition that produced one would be inventing it."""
123 design =
_design(ox_area=50e-6, fuel_area=50e-6)
125 dp_ox=3.0e5, dp_fuel=3.0e5, rho_ox=1000.0, rho_fuel=1000.0, design=design
127 assert balance.face_ratio == pytest.approx(1.0, rel=1e-12)
128 assert balance.mixture_ratio == pytest.approx(1.0, rel=1e-12)
135 side =
SideBalance(
"oxygen", 1.0, 1140.0, 1e-4, 0.6, injector_dp=4.0e5)
136 assert side.stiffness(2.0e6) == pytest.approx(0.20)
137 assert side.stiffness(0.0) == 0.0
141 """A supply below the chamber is a nonsense operating point, not a negative
142 loss that would read as the plumbing adding pressure."""
144 "oxygen", 1.0, 1140.0, 1e-4, 0.6, injector_dp=4.0e5, supply_pressure=1.0e6
146 assert side.feed_loss(2.0e6) == 0.0
147 assert side.feed_loss(1.0e6) == 0.0
151 stiff =
_balance(dp_ox=6.0e5, dp_fuel=6.0e5, pc=2.0e6)
152 assert all(
"stiffness" not in note
for note
in stiff.notes())
154 soft =
_balance(dp_ox=1.0e5, dp_fuel=1.0e5, pc=2.0e6)
155 notes =
" ".join(soft.notes())
156 assert "stiffness" in notes
157 assert f
"{SOFT_STIFFNESS * 100:.0f}%" in notes
161 """A face drilled off-design at a perfectly balanced stand must not be
162 reported as a plumbing problem -- that sends the wrong person to work."""
163 design =
_design(ox_area=140e-6, fuel_area=75.125e-6)
164 balance =
_balance(dp_ox=5.0e5, dp_fuel=5.0e5, design=design, pc=2.0e6)
165 note = next(n
for n
in balance.notes()
if "O/F is" in n)
166 assert "injector face itself" in note
171 balance =
_balance(dp_ox=7.2e5, dp_fuel=2.0e5, design=design, pc=2.0e6)
172 note = next(n
for n
in balance.notes()
if "O/F is" in n)
173 assert "feed system, not the face" in note
180 """Apply the recommended fuel-side drop and the O/F must come out right.
182 Checked by rebuilding the balance at the trimmed drop rather than by
183 re-deriving the formula, so an algebra error in :meth:`trim_pressure`
184 cannot be reproduced by the test that is supposed to catch it.
187 balance =
_balance(dp_ox=3.6e5, dp_fuel=1.9e5, design=design)
190 dp_fuel=balance.fuel.injector_dp + balance.trim_pressure(),
193 assert trimmed.mixture_ratio == pytest.approx(1.65, rel=1e-9)
198 balance =
_balance(dp_ox=3.6e5, dp_fuel=1.9e5, design=design)
201 dp_fuel=balance.fuel.injector_dp + balance.trim_pressure(),
204 assert on_design.trim_pressure() == pytest.approx(0.0, abs=1.0)
211 """Zero fuel flow is a real state during an ox lead. It must report zero
212 rather than divide, because the start transient is sampled through it."""
214 balance = balance_from(
218 density_oxidiser=1140.0,
220 injector_dp_oxidiser=4.0e5,
221 injector_dp_fuel=0.0,
222 chamber_pressure=1.0e6,
224 assert balance.mixture_ratio == 0.0
225 assert balance.feed_term == 0.0
226 assert balance.residual == 0.0
230 """An Engine Abort with the mains open trickles: the chamber can sit above
231 the ox injector inlet while fuel still drops forwards. sqrt(dp_ox/dp_fuel)
232 of that raised a math domain error, and the cockpit's /history failed on
233 every frame until the stand was reset (D-53)."""
235 balance = balance_from(
239 density_oxidiser=1140.0,
241 injector_dp_oxidiser=-3.0e3,
242 injector_dp_fuel=2.0e3,
243 chamber_pressure=1.0e5,
245 assert balance.feed_term == 0.0
246 assert balance.trim_pressure() == 0.0
247 assert isinstance(balance.notes(), list)
253def _banded(ox: tuple[float, float], fuel: tuple[float, float]) -> EngineDesign:
255 from dataclasses
import replace
259 oxidiser=replace(base.oxidiser, stiffness_band=ox),
260 fuel=replace(base.fuel, stiffness_band=fuel),
265 """EngineDesign states injector_dp_ratio_{O,F}_min in its own config. That
266 is what the optimiser was constrained by; the 20% rule is only the fallback
267 for a config that says nothing."""
268 design =
_banded((0.25, 0.35), (0.25, 0.35))
270 balance =
_balance(dp_ox=4.4e5, dp_fuel=4.4e5, pc=2.0e6, design=design)
271 assert balance.oxidiser.stiffness(2.0e6) == pytest.approx(0.22)
272 note = next(n
for n
in balance.notes()
if "oxidiser leg is soft" in n)
273 assert "25-35% this engine was designed to" in note
277 balance =
_balance(dp_ox=1.0e5, dp_fuel=1.0e5, pc=2.0e6)
278 note = next(n
for n
in balance.notes()
if "leg is soft" in n
or "metering" in n)
279 assert "rule of thumb" in note
283 design =
_banded((0.20, 0.30), (0.20, 0.30))
284 balance =
_balance(dp_ox=5.0e5, dp_fuel=5.0e5, pc=2.0e6, design=design)
285 assert not any(
"stiffness" in n
for n
in balance.notes())
289 """The band has an upper bound and the rule of thumb does not. Past it the
290 injector is spending tank pressure the design did not mean to spend."""
291 design =
_banded((0.20, 0.30), (0.20, 0.30))
292 balance =
_balance(dp_ox=8.0e5, dp_fuel=8.0e5, pc=2.0e6, design=design)
293 note = next(n
for n
in balance.notes()
if "oxidiser" in n)
294 assert "stiffer than designed" in note
298 """The end-to-end claim: this number comes out of EngineDesign's own file."""
299 from pathlib
import Path
304 Path(__file__).resolve().parents[3]
307 /
"ethalox_doublet_7000N.yaml"
309 if not config.exists():
310 pytest.skip(
"the ethalox config is not present")
311 design = load_engine(config)
312 assert design.oxidiser.stiffness_band == (0.2, 0.3)
313 assert design.fuel.stiffness_band == (0.2, 0.3)
None test_notes_blame_the_face_when_the_face_is_wrong()
None test_stiffness_is_the_share_of_chamber_pressure()
None test_the_rule_of_thumb_is_named_as_such_when_there_is_no_band()
None test_symmetric_injector_on_one_propellant_is_unity()
None test_a_leg_inside_its_band_draws_no_comment()
EngineDesign _design(*, float ox_area=99.245e-6, float fuel_area=75.125e-6, float mr=1.65)
None test_feed_loss_never_goes_negative()
None test_too_stiff_is_reported_too()
None test_a_leg_flowing_backwards_does_not_raise()
None test_trim_is_zero_when_already_on_design()
None test_face_ratio_matches_its_closed_form()
None test_equal_drop_is_the_face_ratio()
MixtureBalance _balance(*, float dp_ox, float dp_fuel, float cd=0.6, float pc=2.0e6, float rho_ox=1140.0, float rho_fuel=789.0, EngineDesign|None design=None)
None test_the_configs_band_is_used_over_the_rule_of_thumb()
None test_trim_pressure_lands_on_the_design_ratio()
None test_notes_blame_the_stand_when_the_face_is_right()
None test_identity_is_exact(float dp_ox, float dp_fuel)
None test_soft_leg_is_reported_and_stiff_one_is_not()
None test_a_band_is_read_off_the_real_config()
float _orifice(float area, float cd, float rho, float dp)
None test_a_dead_leg_does_not_raise()
None test_face_ratio_does_not_move_with_the_plumbing()
EngineDesign _banded(tuple[float, float] ox, tuple[float, float] fuel)