1"""Engine cards: another tool's injector and chamber, tabulated (feedtwin.engine.card).
3Checked against things the card cannot fake:
4* the interpolant reproduces its nodes and any linear function exactly;
5* ``dp = (mdot/phi)^2`` holds for any flow, signed;
6* ``F + p_a A_e`` is the same at every ambient pressure;
7* ``p_c = mdot c*/A_t`` holds without a fixed point;
8* a burn on a card with constant tables gives exactly that chamber pressure, step by
9 step, through the twin's network and chamber closure.
11And the opt-in rule: an injector without a card behaves exactly as before.
14from __future__
import annotations
18from dataclasses
import replace
19from pathlib
import Path
34def table(fn, x=(0.0, 1.0, 5), y=(0.0, 2.0, 4)) -> Table2D:
37 dx, dy = (x1 - x0) / (nx - 1), (y1 - y0) / (ny - 1)
46 tuple(float(fn(x0 + i * dx, y0 + j * dy))
for j
in range(ny))
53 cstar: float = 1550.0,
56 throat: float = 1.8e-3,
57 exit_area: float = 8.7e-3,
58 injector_hull: tuple[tuple[float, float], ...] = (
66 table(
lambda m, p: phi, (0.0, 5.0, 3), (1e5, 1e7, 3)), hull=injector_hull
69 table(
lambda r, m: cstar, (0.5, 3.0, 3), (0.1, 6.0, 3)),
70 table(
lambda r, m: vvac, (0.5, 3.0, 3), (0.1, 6.0, 3)),
71 hull=((0.5, 0.1), (3.0, 0.1), (3.0, 6.0), (0.5, 6.0)),
73 return EngineCard(
"constant", throat, exit_area, inj, replace(inj), ch)
80 t =
table(
lambda x, y: math.sin(3 * x) + y * y)
83 x, y = t.x0 + i * t.dx, t.y0 + j * t.dy
84 assert t(x, y) == pytest.approx(math.sin(3 * x) + y * y, abs=1e-12)
85 lin =
table(
lambda x, y: 2.0 + 3.0 * x - 0.5 * y)
86 for x, y
in ((0.13, 0.71), (0.5, 1.99), (0.99, 0.01), (0.37, 1.23)):
87 assert lin(x, y) == pytest.approx(2.0 + 3.0 * x - 0.5 * y, abs=1e-12)
91 t =
table(
lambda x, y: x + y)
92 assert t(-5.0, 1.0) == pytest.approx(t(0.0, 1.0))
93 assert t(0.5, 99.0) == pytest.approx(t(0.5, 2.0))
94 assert not t.in_box(1.5, 0.0)
and t.in_box(1.0, 2.0)
98 with pytest.raises(ValueError):
99 Table2D(0.0, 1.0, 3, 0.0, 1.0, 2, ((1.0, 2.0), (3.0, 4.0)))
113 assert card.oxidiser.pressure_drop(m, 3.0e6) == pytest.approx(
114 (m / 2.0e-3) ** 2, rel=1e-12
116 assert card.oxidiser.pressure_drop(-1.0, 3.0e6) == pytest.approx(
117 -((1.0 / 2.0e-3) ** 2)
119 assert card.oxidiser.pressure_drop(0.0, 3.0e6) == 0.0
125 side = InjectorSide(
"oxygen", 5.0e-5, 1.6e-3, DischargeModel(cd_inf=0.6, a_re=0.0))
126 design = EngineDesign(
128 injector_type=
"impinging",
130 fuel=replace(side, propellant=
"ethanol"),
133 chamber_volume=2.4e-3,
136 plain, _ = injector_legs(design)
138 assert plain.pressure_drop(1.8, flow) == pytest.approx(
139 1.8**2 / (2 * 1140.0 * (0.6 * 5.0e-5) ** 2), rel=1e-12
142 carded, _ = injector_legs(card.attach(design))
143 assert carded.pressure_drop(1.8, flow) == pytest.approx(
144 (1.8 / 1.7e-3) ** 2, rel=1e-12
147 area = carded.effective_area(1.8, flow)
148 assert 1.8**2 / (2 * 1140.0 * area**2) == pytest.approx(
149 (1.8 / 1.7e-3) ** 2, rel=1e-12
154 """``total_dp`` is what the network solves with, and run backwards an orifice drops
155 pressure backwards: it is odd in the flow, carded or not.
157 The card's own relation is signed, and the leg used to hand that signed drop to a
158 base class that signs it again, so reverse flow came out as a pressure *rise*,
159 ``+(mdot/phi)^2``. A chamber closure that brackets the chamber up to the higher
160 tank's pressure asks the other leg to run backwards; it had no root there, and a
161 network solve asked for better than ~3e-5 stalled at the bottom of the parabola.
165 side = InjectorSide(
"oxygen", 5.0e-5, 1.6e-3, DischargeModel(cd_inf=0.6, a_re=0.0))
166 design = EngineDesign(
168 injector_type=
"impinging",
170 fuel=replace(side, propellant=
"ethanol"),
173 chamber_volume=2.4e-3,
176 plain, _ = injector_legs(design)
177 carded, _ = injector_legs(
constant_card(phi=1.6e-3, throat=1.8e-3).attach(design))
178 for leg
in (plain, carded):
179 for m
in (0.006, 0.4, 1.8):
180 forward = leg.total_dp(m, flow)
182 assert leg.total_dp(-m, flow) == pytest.approx(-forward, rel=1e-12)
183 assert carded.total_dp(-0.4, flow) == pytest.approx(-((0.4 / 1.6e-3) ** 2))
189 side = InjectorSide(
"oxygen", 5.0e-5, 1.6e-3, DischargeModel())
190 design = EngineDesign(
192 injector_type=
"impinging",
197 chamber_volume=2.4e-3,
199 with pytest.raises(ValueError, match=
"throat"):
207 card =
constant_card(cstar=1550.0, vvac=2600.0, throat=1.8e-3, exit_area=8.7e-3)
209 res = card.chamber_model(ambient_pressure=94070.0).evaluate(mo, mf)
210 assert res.pressure == pytest.approx((mo + mf) * 1550.0 / 1.8e-3, rel=1e-12)
211 assert res.thrust == pytest.approx((mo + mf) * 2600.0 - 94070.0 * 8.7e-3, rel=1e-12)
212 assert res.specific_impulse == pytest.approx(
213 res.thrust / ((mo + mf) * 9.80665), rel=1e-12
215 assert res.mixture_ratio == pytest.approx(mo / mf)
221 pa: card.chamber_model(ambient_pressure=pa).evaluate(1.8, 1.2)
222 for pa
in (0.0, 5.0e4, 101325.0)
224 for pa, res
in vac.items():
225 assert res.thrust + pa * card.exit_area == pytest.approx(
226 3.0 * 2600.0, rel=1e-12
232 inside = card.chamber_model().evaluate(1.8, 1.2)
233 outside = card.chamber_model().evaluate(9.0, 0.5)
234 assert not inside.combustion.extrapolated
235 assert outside.combustion.extrapolated
237 shut = card.chamber_model(ambient_pressure=94070.0).evaluate(0.0, 0.0)
238 assert shut.pressure == 94070.0
and shut.thrust == 0.0
243 chamber = card.chamber_model(volume=2.4e-3)
244 assert chamber.fill_time(2.7e6, 1.5) == pytest.approx(
245 2.4e-3 / (1.8e-3 * 1550.0), rel=1e-9
251 constant_card(), provenance={
"tool":
"test"}, fit={
"envelope_worst": 1e-4}
253 again = EngineCard.from_dict(json.loads(json.dumps(card.to_dict())))
255 with pytest.raises(ValueError, match=
"schema"):
256 EngineCard.from_dict({**card.to_dict(),
"schema": 99})
261STAR = Path(__file__).resolve().parents[3]
262STAND = STAR /
"feed-twin" /
"backend" /
"diagrams" /
"copv_study_gn2.json"
263TABLES = STAR /
"feed-twin" /
"backend" /
"statemachines"
264ENGINE = STAR /
"EngineDesign" /
"configs" /
"ethalox_doublet_7000N.yaml"
267needs_stand = pytest.mark.skipif(
268 not (STAND.exists()
and TABLES.is_dir()
and ENGINE.exists()),
269 reason=
"stand drawing, tables or engine config absent",
274 """A short GN2 burn on a constant card: phi 1.6e-3, c* 1520 m/s."""
282 design = engine_from_config(yaml.safe_load(ENGINE.read_text()), name=
"7000N")
284 cstar=1520.0, vvac=2550.0, phi=1.6e-3, throat=design.throat_area
286 if injector_hull
is not None:
291 throat=design.throat_area,
292 injector_hull=injector_hull,
294 design = card.attach(design)
295 model = assemble_model(
296 read_diagram(json.loads(STAND.read_text()), name=
"gn2"),
299 chamber=card.chamber_model(ambient_pressure=94070.0),
301 session = open_session(
303 load_machine(tables=TABLES),
304 setup=burn_setup(dome_psi=500.0, chamber_tolerance_psi=0.01, **setup),
309 loads={
"OXT": 6.0,
"FUT": 4.0}, settle=
False, lead_in_s=0.1, horizon_s=0.4
317 """Constant tables make the chamber closed-form: p_c = mdot c*/A_t, and the injector
318 drop is (mdot/phi)^2. Every firing step of a burn through the twin's network and
319 chamber closure has to satisfy both, to the closure's tolerance."""
321 firing = [i
for i, f
in enumerate(trace.firing)
if f]
322 assert len(firing) > 4,
"the engine never fired"
323 inlet = trace.probes.injector_inlet
326 mt = ch[
"mdot_oxidiser"][i] + ch[
"mdot_fuel"][i]
327 assert ch[
"pressure_Pa"][i] == pytest.approx(
328 mt * 1520.0 / design.throat_area, rel=1e-9
330 for side, key
in ((
"oxidiser",
"mdot_oxidiser"), (
"fuel",
"mdot_fuel")):
331 dp = trace.pressure[inlet[side]][i] - ch[
"pressure_Pa"][i]
336 assert dp == pytest.approx((ch[key][i] / 1.6e-3) ** 2, rel=5e-3)
341 monkeypatch: pytest.MonkeyPatch,
343 """Opting into a tight network tolerance has to buy the injector drop, not a burn of
346 At 1e-5 this burn used to fail 36 of 45 solves: the regulator's outlet stepped
347 1.7 kPa across zero flow with the tanks primed inside the step, and a carded leg
348 run backwards gained pressure. Both rows had no root, and the residual floor was
349 2.5e-5. With both closed it converges at every step, and what is left of the
350 injector error is the chamber closure's own 0.01 psi against ~0.9 MPa of drop.
356 solves: list[bool] = []
357 solve = core.solve_steady
359 def counted(*args, **kwargs):
360 result = solve(*args, **kwargs)
361 solves.append(result.converged)
364 monkeypatch.setattr(core,
"solve_steady", counted)
365 trace, design =
_card_burn(network_tolerance=1.0e-6)
366 assert len(solves) > 40
and all(solves), f
"{solves.count(False)} solves held"
367 assert all(trace.converged)
368 firing = [i
for i, f
in enumerate(trace.firing)
if f]
369 assert len(firing) > 4,
"the engine never fired"
370 assert len(firing) > 4
371 inlet = trace.probes.injector_inlet
374 for side, key
in ((
"oxidiser",
"mdot_oxidiser"), (
"fuel",
"mdot_fuel")):
375 dp = trace.pressure[inlet[side]][i] - ch[
"pressure_Pa"][i]
376 assert dp == pytest.approx((ch[key][i] / 1.6e-3) ** 2, rel=2e-4)
380 """Every sample the card was built from is inside its hull, the corners and the
381 far edges included; a hair outside is not. Plain ray casting kept only two of a
382 square's four edges."""
385 hull = [(0.5, 2.0e6), (3.0, 2.0e6), (3.0, 5.0e6), (0.5, 5.0e6)]
394 assert _inside(hull, *point), point
395 for point
in [(3.001, 3.5e6), (1.7, 5.00001e6), (0.499, 3.0e6), (1.7, 1.99999e6)]:
396 assert not _inside(hull, *point), point
397 assert not _inside([], 1.0, 3.0e6)
401 """The leg reports whether its lookup was inside the hull the card was
402 fitted to, as the chamber does; Table2D clamps silently outside its box."""
405 side = InjectorSide(
"oxygen", 5.0e-5, 1.6e-3, DischargeModel(cd_inf=0.6, a_re=0.0))
406 design = EngineDesign(
408 injector_type=
"impinging",
410 fuel=replace(side, propellant=
"ethanol"),
413 chamber_volume=2.4e-3,
415 hull = ((0.5, 2.0e6), (3.0, 2.0e6), (3.0, 5.0e6), (0.5, 5.0e6))
416 leg, _ = injector_legs(
constant_card(injector_hull=hull).attach(design))
419 assert not leg.off_card(1.8, inside)
420 assert leg.off_card(1.8, vented)
421 assert leg.diagnostics(1.8, vented)[
"extrapolated"] == 1.0
422 assert leg.diagnostics(1.8, inside)[
"extrapolated"] == 0.0
423 plain, _ = injector_legs(design)
424 assert not plain.off_card(1.8, vented),
"no card, nothing to be off"
429 """Every injector lookup of this burn is below the card's sampled inlet
430 pressures, so every firing step is outside what the card knows, and the
431 step's flag says so as it does for the chamber's own table."""
433 firing = [i
for i, f
in enumerate(inside.firing)
if f]
434 assert firing
and not any(inside.chamber[
"extrapolated"][i]
for i
in firing)
435 high = ((0.0, 5.0e7), (100.0, 5.0e7), (100.0, 1.0e8), (0.0, 1.0e8))
437 firing = [i
for i, f
in enumerate(off.firing)
if f]
438 assert firing
and all(off.chamber[
"extrapolated"][i]
for i
in firing)
None test_fill_time_uses_the_cards_cstar()
None test_table_refuses_values_that_do_not_match_its_axes()
None test_table_reproduces_its_nodes_and_any_linear_function()
None test_a_caller_can_ask_the_network_for_the_cards_drop_to_the_closures_tolerance(pytest.MonkeyPatch monkeypatch)
Table2D table(fn, x=(0.0, 1.0, 5), y=(0.0, 2.0, 4))
None test_an_injector_leg_off_its_card_says_so()
None test_card_chamber_pressure_and_thrust_follow_the_tables()
None test_thrust_plus_ambient_times_exit_area_does_not_depend_on_ambient()
EngineCard constant_card(float cstar=1550.0, float vvac=2600.0, float phi=1.7e-3, float throat=1.8e-3, float exit_area=8.7e-3, tuple[tuple[float, float],...] injector_hull=((0.0, 0.0),(100.0, 0.0),(100.0, 1e8),(0.0, 1e8),))
None test_the_hull_includes_its_own_edges()
_card_burn(injector_hull=None, **object setup)
None test_a_burn_on_a_card_runs_the_cards_engine_at_every_step()
None test_a_card_refuses_a_design_with_a_different_throat()
None test_an_injector_leg_uses_its_card_and_only_when_it_has_one()
None test_a_card_round_trips_through_json()
None test_injector_card_is_the_orifice_relation_on_its_capacity()
None test_table_clamps_outside_its_box()
None test_reverse_flow_through_a_carded_leg_loses_pressure_backwards()
None test_a_burn_off_the_injector_card_counts_as_extrapolated()
None test_a_card_chamber_off_its_hull_says_so()