STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_engine_card.py
Go to the documentation of this file.
1"""Engine cards: another tool's injector and chamber, tabulated (feedtwin.engine.card).
2
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.
10
11And the opt-in rule: an injector without a card behaves exactly as before.
12"""
13
14from __future__ import annotations
15
16import json
17import math
18from dataclasses import replace
19from pathlib import Path
20
21import pytest
22
23from feedtwin.engine import (
24 CardChamber,
25 ChamberCard,
26 EngineCard,
27 InjectorCard,
28 Table2D,
29)
30from feedtwin.engine.component import injector_legs
31from feedtwin.comps.base import FlowConditions
32
33
34def table(fn, x=(0.0, 1.0, 5), y=(0.0, 2.0, 4)) -> Table2D: # type: ignore[no-untyped-def]
35 x0, x1, nx = x
36 y0, y1, ny = y
37 dx, dy = (x1 - x0) / (nx - 1), (y1 - y0) / (ny - 1)
38 return Table2D(
39 x0,
40 dx,
41 nx,
42 y0,
43 dy,
44 ny,
45 tuple(
46 tuple(float(fn(x0 + i * dx, y0 + j * dy)) for j in range(ny))
47 for i in range(nx)
48 ),
49 )
50
51
53 cstar: float = 1550.0,
54 vvac: float = 2600.0,
55 phi: float = 1.7e-3,
56 throat: float = 1.8e-3,
57 exit_area: float = 8.7e-3,
58 injector_hull: tuple[tuple[float, float], ...] = (
59 (0.0, 0.0),
60 (100.0, 0.0),
61 (100.0, 1e8),
62 (0.0, 1e8),
63 ),
64) -> EngineCard:
65 inj = InjectorCard(
66 table(lambda m, p: phi, (0.0, 5.0, 3), (1e5, 1e7, 3)), hull=injector_hull
67 )
68 ch = ChamberCard(
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)),
72 )
73 return EngineCard("constant", throat, exit_area, inj, replace(inj), ch)
74
75
76# ------------------------------------------------------------------ the table
77
78
80 t = table(lambda x, y: math.sin(3 * x) + y * y)
81 for i in range(t.nx):
82 for j in range(t.ny):
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)
88
89
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)
95
96
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)))
100
101
102# ------------------------------------------------------------------ the injector
103
104
106 card = constant_card(phi=2.0e-3)
107 for m in (
108 0.01,
109 0.9,
110 1.9,
111 7.0,
112 ): # 7 kg/s is outside the table; the quadratic still holds
113 assert card.oxidiser.pressure_drop(m, 3.0e6) == pytest.approx(
114 (m / 2.0e-3) ** 2, rel=1e-12
115 )
116 assert card.oxidiser.pressure_drop(-1.0, 3.0e6) == pytest.approx(
117 -((1.0 / 2.0e-3) ** 2)
118 )
119 assert card.oxidiser.pressure_drop(0.0, 3.0e6) == 0.0
120
121
123 from feedtwin.engine.design import DischargeModel, EngineDesign, InjectorSide
124
125 side = InjectorSide("oxygen", 5.0e-5, 1.6e-3, DischargeModel(cd_inf=0.6, a_re=0.0))
126 design = EngineDesign(
127 name="t",
128 injector_type="impinging",
129 oxidiser=side,
130 fuel=replace(side, propellant="ethanol"),
131 throat_area=1.8e-3,
132 expansion_ratio=4.8,
133 chamber_volume=2.4e-3,
134 )
135 flow = FlowConditions(rho=1140.0, mu=1.8e-4, p_upstream=3.7e6)
136 plain, _ = injector_legs(design)
137 # Without a card: the orifice relation on the Cd model, exactly as it always was.
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
140 )
141 card = constant_card(phi=1.7e-3, throat=1.8e-3)
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
145 )
146 # Its effective area is recovered from the card at the leg's density.
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
150 )
151
152
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.
156
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.
162 """
163 from feedtwin.engine.design import DischargeModel, EngineDesign, InjectorSide
164
165 side = InjectorSide("oxygen", 5.0e-5, 1.6e-3, DischargeModel(cd_inf=0.6, a_re=0.0))
166 design = EngineDesign(
167 name="t",
168 injector_type="impinging",
169 oxidiser=side,
170 fuel=replace(side, propellant="ethanol"),
171 throat_area=1.8e-3,
172 expansion_ratio=4.8,
173 chamber_volume=2.4e-3,
174 )
175 flow = FlowConditions(rho=789.0, mu=1.2e-3, p_upstream=3.9e6)
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)
181 assert forward > 0.0
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))
184
185
187 from feedtwin.engine.design import DischargeModel, EngineDesign, InjectorSide
188
189 side = InjectorSide("oxygen", 5.0e-5, 1.6e-3, DischargeModel())
190 design = EngineDesign(
191 name="t",
192 injector_type="impinging",
193 oxidiser=side,
194 fuel=side,
195 throat_area=2.0e-3,
196 expansion_ratio=4.8,
197 chamber_volume=2.4e-3,
198 )
199 with pytest.raises(ValueError, match="throat"):
200 constant_card(throat=1.8e-3).attach(design)
201
202
203# ------------------------------------------------------------------ the chamber
204
205
207 card = constant_card(cstar=1550.0, vvac=2600.0, throat=1.8e-3, exit_area=8.7e-3)
208 mo, mf = 1.85, 1.22
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
214 )
215 assert res.mixture_ratio == pytest.approx(mo / mf)
216
217
219 card = constant_card()
220 vac = {
221 pa: card.chamber_model(ambient_pressure=pa).evaluate(1.8, 1.2)
222 for pa in (0.0, 5.0e4, 101325.0)
223 }
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
227 )
228
229
231 card = constant_card()
232 inside = card.chamber_model().evaluate(1.8, 1.2)
233 outside = card.chamber_model().evaluate(9.0, 0.5) # O/F 18, far outside
234 assert not inside.combustion.extrapolated
235 assert outside.combustion.extrapolated
236 # No flow is ambient and no thrust, as for any chamber.
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
239
240
242 card = constant_card(cstar=1550.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
246 )
247
248
250 card = replace(
251 constant_card(), provenance={"tool": "test"}, fit={"envelope_worst": 1e-4}
252 )
253 again = EngineCard.from_dict(json.loads(json.dumps(card.to_dict())))
254 assert again == card
255 with pytest.raises(ValueError, match="schema"):
256 EngineCard.from_dict({**card.to_dict(), "schema": 99})
257
258
259# ------------------------------------------------------------------ in a burn
260
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"
265
266
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",
270)
271
272
273def _card_burn(injector_hull=None, **setup: object): # type: ignore[no-untyped-def]
274 """A short GN2 burn on a constant card: phi 1.6e-3, c* 1520 m/s."""
275 import yaml
276
277 from feedtwin.engine.importer import engine_from_config
278 from feedtwin.pid import read_diagram
279 from feedtwin.session import assemble_model, load_machine
280 from feedtwin.session.burn import BurnPlan, burn_setup, open_session, run_burn
281
282 design = engine_from_config(yaml.safe_load(ENGINE.read_text()), name="7000N")
283 card = constant_card(
284 cstar=1520.0, vvac=2550.0, phi=1.6e-3, throat=design.throat_area
285 )
286 if injector_hull is not None:
287 card = constant_card(
288 cstar=1520.0,
289 vvac=2550.0,
290 phi=1.6e-3,
291 throat=design.throat_area,
292 injector_hull=injector_hull,
293 )
294 design = card.attach(design)
295 model = assemble_model(
296 read_diagram(json.loads(STAND.read_text()), name="gn2"),
297 diagram_id="gn2",
298 engine=design,
299 chamber=card.chamber_model(ambient_pressure=94070.0),
300 )
301 session = open_session(
302 model,
303 load_machine(tables=TABLES),
304 setup=burn_setup(dome_psi=500.0, chamber_tolerance_psi=0.01, **setup),
305 )
306 trace = run_burn(
307 session,
308 BurnPlan(
309 loads={"OXT": 6.0, "FUT": 4.0}, settle=False, lead_in_s=0.1, horizon_s=0.4
310 ),
311 )
312 return trace, design
313
314
315@needs_stand
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."""
320 trace, design = _card_burn()
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
324 ch = trace.chamber
325 for i in firing[1:]:
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
329 )
330 for side, key in (("oxidiser", "mdot_oxidiser"), ("fuel", "mdot_fuel")):
331 dp = trace.pressure[inlet[side]][i] - ch["pressure_Pa"][i]
332 # The chamber is closed to 0.01 psi, but the network solve under it stops at the
333 # session's LIVE_TOL (1e-4 scaled residual), which leaves the injector drop within a few
334 # tenths of a percent of the card's -- measured 0.2 % here. A wrong card is off by
335 # tens of percent.
336 assert dp == pytest.approx((ch[key][i] / 1.6e-3) ** 2, rel=5e-3)
337
338
339@needs_stand
341 monkeypatch: pytest.MonkeyPatch,
342) -> None:
343 """Opting into a tight network tolerance has to buy the injector drop, not a burn of
344 held flows.
345
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.
351 """
352 import feedtwin.session.core as core
353
354 # Every solve, not every sample: a sample records only a tick's last coupling step,
355 # and the regulator's failure is one held solve at T-0 that a sample never shows.
356 solves: list[bool] = []
357 solve = core.solve_steady
358
359 def counted(*args, **kwargs): # type: ignore[no-untyped-def]
360 result = solve(*args, **kwargs)
361 solves.append(result.converged)
362 return result
363
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
372 ch = trace.chamber
373 for i in firing[1:]:
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)
377
378
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."""
383 from feedtwin.engine.card import _inside
384
385 hull = [(0.5, 2.0e6), (3.0, 2.0e6), (3.0, 5.0e6), (0.5, 5.0e6)]
386 for point in [
387 (0.5, 2.0e6),
388 (3.0, 5.0e6),
389 (3.0, 3.5e6),
390 (1.7, 5.0e6),
391 (1.7, 2.0e6),
392 (0.5, 3.0e6),
393 ]:
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)
398
399
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."""
403 from feedtwin.engine.design import DischargeModel, EngineDesign, InjectorSide
404
405 side = InjectorSide("oxygen", 5.0e-5, 1.6e-3, DischargeModel(cd_inf=0.6, a_re=0.0))
406 design = EngineDesign(
407 name="t",
408 injector_type="impinging",
409 oxidiser=side,
410 fuel=replace(side, propellant="ethanol"),
411 throat_area=1.8e-3,
412 expansion_ratio=4.8,
413 chamber_volume=2.4e-3,
414 )
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))
417 inside = FlowConditions(rho=1140.0, mu=1.8e-4, p_upstream=3.7e6)
418 vented = FlowConditions(rho=1140.0, mu=1.8e-4, p_upstream=1.0e6)
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"
425
426
427@needs_stand
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."""
432 inside, _ = _card_burn()
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))
436 off, _ = _card_burn(injector_hull=high)
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()