STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_balance.py
Go to the documentation of this file.
1"""The mixture-ratio split: is it exact, and does it blame the right half?
2
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.
10"""
11
12from __future__ import annotations
13
14import math
15
16import pytest
17
18from feedtwin.engine.balance import (
19 SOFT_STIFFNESS,
20 MixtureBalance,
21 SideBalance,
22 balance_from,
23)
24from feedtwin.engine.design import DischargeModel, EngineDesign, InjectorSide
25
26
28 *, ox_area: float = 99.245e-6, fuel_area: float = 75.125e-6, mr: float = 1.65
29) -> EngineDesign:
30 """A two-side engine with nothing in it but the areas that matter here."""
31 return EngineDesign(
32 name="fixture",
33 injector_type="impinging",
34 oxidiser=InjectorSide("oxygen", ox_area, 2.2e-3, DischargeModel(), 26),
35 fuel=InjectorSide("ethanol", fuel_area, 1.92e-3, DischargeModel(), 26),
36 throat_area=1.7e-3,
37 design_mixture_ratio=mr,
38 )
39
40
41def _orifice(area: float, cd: float, rho: float, dp: float) -> float:
42 return cd * area * math.sqrt(2.0 * rho * dp)
43
44
46 *,
47 dp_ox: float,
48 dp_fuel: float,
49 cd: float = 0.6,
50 pc: float = 2.0e6,
51 rho_ox: float = 1140.0,
52 rho_fuel: float = 789.0,
53 design: EngineDesign | None = None,
54) -> MixtureBalance:
55 """A balance built from flows that genuinely obey the orifice law."""
56 design = design or _design()
57 return balance_from(
58 design,
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,
65 chamber_pressure=pc,
66 )
67
68
69# ------------------------------------------------------------- the identity
70
71
72@pytest.mark.parametrize(
73 ("dp_ox", "dp_fuel"),
74 [(3.0e5, 3.0e5), (3.6e5, 1.9e5), (1.0e5, 9.0e5), (5.0e4, 5.1e4)],
75)
76def test_identity_is_exact(dp_ox: float, dp_fuel: float) -> None:
77 """``O/F == face_ratio * feed_term``, to floating point and no further.
78
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.
82 """
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
87 )
88
89
91 """Change only the pressure split; the face term must not budge.
92
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.
96 """
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
101
102
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)
108
109
111 """``Cd A sqrt(rho)`` on each side, computed independently."""
112 design = _design()
113 balance = _balance(dp_ox=2.5e5, dp_fuel=2.5e5, design=design)
114 expected = (
115 design.oxidiser.area * math.sqrt(1140.0) / (design.fuel.area * math.sqrt(789.0))
116 )
117 assert balance.face_ratio == pytest.approx(expected, rel=1e-12)
118
119
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)
124 balance = _balance(
125 dp_ox=3.0e5, dp_fuel=3.0e5, rho_ox=1000.0, rho_fuel=1000.0, design=design
126 )
127 assert balance.face_ratio == pytest.approx(1.0, rel=1e-12)
128 assert balance.mixture_ratio == pytest.approx(1.0, rel=1e-12)
129
130
131# --------------------------------------------------------------- stiffness
132
133
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
138
139
141 """A supply below the chamber is a nonsense operating point, not a negative
142 loss that would read as the plumbing adding pressure."""
143 side = SideBalance(
144 "oxygen", 1.0, 1140.0, 1e-4, 0.6, injector_dp=4.0e5, supply_pressure=1.0e6
145 )
146 assert side.feed_loss(2.0e6) == 0.0
147 assert side.feed_loss(1.0e6) == 0.0
148
149
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())
153
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
158
159
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
167
168
170 design = _design(mr=1.61)
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
174
175
176# ------------------------------------------------------------------- trim
177
178
180 """Apply the recommended fuel-side drop and the O/F must come out right.
181
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.
185 """
186 design = _design(mr=1.65)
187 balance = _balance(dp_ox=3.6e5, dp_fuel=1.9e5, design=design)
188 trimmed = _balance(
189 dp_ox=3.6e5,
190 dp_fuel=balance.fuel.injector_dp + balance.trim_pressure(),
191 design=design,
192 )
193 assert trimmed.mixture_ratio == pytest.approx(1.65, rel=1e-9)
194
195
197 design = _design(mr=1.65)
198 balance = _balance(dp_ox=3.6e5, dp_fuel=1.9e5, design=design)
199 on_design = _balance(
200 dp_ox=3.6e5,
201 dp_fuel=balance.fuel.injector_dp + balance.trim_pressure(),
202 design=design,
203 )
204 assert on_design.trim_pressure() == pytest.approx(0.0, abs=1.0)
205
206
207# ---------------------------------------------------------------- degenerate
208
209
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."""
213 design = _design()
214 balance = balance_from(
215 design,
216 mdot_oxidiser=1.0,
217 mdot_fuel=0.0,
218 density_oxidiser=1140.0,
219 density_fuel=789.0,
220 injector_dp_oxidiser=4.0e5,
221 injector_dp_fuel=0.0,
222 chamber_pressure=1.0e6,
223 )
224 assert balance.mixture_ratio == 0.0
225 assert balance.feed_term == 0.0
226 assert balance.residual == 0.0
227
228
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)."""
234 design = _design()
235 balance = balance_from(
236 design,
237 mdot_oxidiser=0.01,
238 mdot_fuel=0.02,
239 density_oxidiser=1140.0,
240 density_fuel=789.0,
241 injector_dp_oxidiser=-3.0e3,
242 injector_dp_fuel=2.0e3,
243 chamber_pressure=1.0e5,
244 )
245 assert balance.feed_term == 0.0
246 assert balance.trim_pressure() == 0.0
247 assert isinstance(balance.notes(), list)
248
249
250# ------------------------------------------------ the engine's own band
251
252
253def _banded(ox: tuple[float, float], fuel: tuple[float, float]) -> EngineDesign:
254 base = _design()
255 from dataclasses import replace
256
257 return replace(
258 base,
259 oxidiser=replace(base.oxidiser, stiffness_band=ox),
260 fuel=replace(base.fuel, stiffness_band=fuel),
261 )
262
263
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))
269 # 22% -- fine under the rule of thumb, short of what this engine wanted.
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
274
275
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
280
281
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())
286
287
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
295
296
298 """The end-to-end claim: this number comes out of EngineDesign's own file."""
299 from pathlib import Path
300
301 from feedtwin.engine.importer import load_engine
302
303 config = (
304 Path(__file__).resolve().parents[3]
305 / "EngineDesign"
306 / "configs"
307 / "ethalox_doublet_7000N.yaml"
308 )
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)