STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_phase4.py
Go to the documentation of this file.
1"""Layer X phase 4: the feed hardware that gets the most from the fixed load.
2
3* Impulse to depletion is arithmetic on the trace: checked by hand on a made-up one, and checked
4 not to jump when the depletion slides across a step.
5* Grading: violations are relative and add; feasible beats infeasible whatever the objective;
6 among feasible, more is better.
7* Bounds carry their reasons, from the config's requirements and the drawing's MAWPs.
8* End to end, on the 6.5 kN stand: impulse rises with lockup (a higher chamber pressure expands
9 further for the same propellant), so the search ends at the top of the range.
10"""
11
12from __future__ import annotations
13
14import math
15from pathlib import Path
16from types import SimpleNamespace
17
18import pytest
19
20
21#: The study drawings' lockup these tests were written at [psia] (scripts/layerx_baseline.py).
22STUDY_LOCKUP_PSIA = 564.7
23
24pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed")
25
26from engine.layerx import DrawingStore, LayerXSettings, prepare # noqa: E402
27from engine.layerx import optimize as opt # noqa: E402
28from engine.layerx.analysis import _impulse_to_depletion # noqa: E402
29from engine.layerx.sources import shipped_drawings_dir # noqa: E402
30from engine.pipeline.io import load_config # noqa: E402
31
32FIXTURE = Path(__file__).parent / "fixtures" / "ethalox_6500N_doublet_cad_2026-09-28.yaml"
33GN2 = "copv_study_gn2"
34# The GN2 drawing is a nitrogen-over-LOX hot fire, refused since 2026-10-03 unless acknowledged
35# (engine/layerx/prepare.py gn2_on_lox); these tests study its burn, so their settings acknowledge it.
36needs_drawing = pytest.mark.skipif(not (shipped_drawings_dir() / f"{GN2}.json").is_file(),
37 reason="feed-twin's shipped drawings are not next to this checkout")
38
39
40def _trace(n: int, dt: float, thrust: float, mo: float, mf: float, ox0: float, fu0: float):
41 """A constant burn: ``n`` firing steps, each burning ``mo``/``mf`` for ``dt``."""
42 t = [dt * (k + 1) for k in range(n)]
43 ox = [max(ox0 - mo * dt * (k + 1), 0.0) for k in range(n)]
44 fu = [max(fu0 - mf * dt * (k + 1), 0.0) for k in range(n)]
45 ch = {"thrust_N": [thrust] * n, "mdot_oxidiser": [mo] * n, "mdot_fuel": [mf] * n}
46 trace = SimpleNamespace(t=t, tank={"OX": {"liquid_mass_kg": ox}, "FU": {"liquid_mass_kg": fu}})
47 return trace, list(range(n)), ch
48
49
51 """2 kg/s LOX, 1.25 kg/s fuel (O/F 1.6), 7 kN, 50 ms steps; 6.61 kg LOX, 4.40 kg fuel. LOX runs
52 out first, at 3.305 s. Up to the last full step the burn makes 7000/3.25 N·s per kg; the load
53 it can burn is all the LOX and 6.61/1.6 kg of fuel."""
54 dt, mo, mf = 0.05, 2.0, 1.25
55 trace, fire, ch = _trace(67, dt, 7000.0, mo, mf, 6.61, 4.40)
56 impulse, t_dep = _impulse_to_depletion(trace, fire, dt, "OX", "FU", ch)
57 burnable = 6.61 * (1.0 + 1.0 / 1.6)
58 assert impulse == pytest.approx(7000.0 / 3.25 * burnable, rel=1e-12)
59 assert t_dep == pytest.approx(6.61 / 2.0, rel=1e-12)
60
61
63 """Slide the load so the depletion crosses a step boundary: the step-summed impulse jumps by a
64 step's worth, the impulse to depletion moves by exactly the extra propellant's share."""
65 dt, mo, mf = 0.05, 2.0, 1.25
66 out = []
67 for ox0 in (6.598, 6.602): # depletion at 3.299 s and 3.301 s: either side of 3.30
68 n = math.ceil(ox0 / (mo * dt) - 1e-9)
69 trace, fire, ch = _trace(n, dt, 7000.0, mo, mf, ox0, 4.40)
70 stepped = 7000.0 * dt * n
71 to_dep, _ = _impulse_to_depletion(trace, fire, dt, "OX", "FU", ch)
72 out.append((stepped, to_dep))
73 (s1, d1), (s2, d2) = out
74 assert s2 - s1 == pytest.approx(7000.0 * dt) # 350 N·s for 4 g of LOX
75 assert d2 - d1 == pytest.approx(7000.0 / 3.25 * 0.004 * (1 + 1 / 1.6), rel=1e-9) # ~13 N·s
76
77
78def _graded(**figures):
79 base = {"copv_end_psia": 1200.0, "ox_stiffness_min": 0.30, "fuel_stiffness_min": 0.30, "of_mean": 1.5,
80 "total_impulse_Ns": 24000.0, "impulse_to_depletion_Ns": 24000.0, "failed_steps": 0}
81 base.update(figures)
82 e = {"ok": True, "preflight": [], "figures": base, "x": {"lockup_psia": 578.0}, "lockup_psia": 578.0}
83 req = opt.OptimizeRequest(dropout_margin_psi=100.0, of_band_rel=0.02)
84 return opt.grade(e, req, {"oxidiser": [0.2, 0.4], "fuel": [0.2, 0.4]}, 1.5)
85
86
88 ok = _graded()
89 assert ok["violation"] == 0.0 and ok["objective"] == 24000.0
90 # Bottle 50 psi short of the 100 psi headroom: half the margin, violation 0.5.
91 short = _graded(copv_end_psia=578.0 + 50.0)
92 assert short["violation"] == pytest.approx(0.5)
93 # Fuel stiffness 0.15 against a 0.20 floor: a quarter short.
94 soft = _graded(fuel_stiffness_min=0.15)
95 assert soft["violation"] == pytest.approx(0.25)
96 # O/F 1.545 is 3 % off 1.5 against a 2 % band: half the band over.
97 rich = _graded(of_mean=1.545)
98 assert rich["violation"] == pytest.approx(0.5, rel=1e-9)
99 both = _graded(copv_end_psia=628.0, fuel_stiffness_min=0.15)
100 assert both["violation"] == pytest.approx(0.75)
101 # Feasible beats infeasible however much better the infeasible one's objective is.
102 better_but_short = _graded(copv_end_psia=628.0, total_impulse_Ns=30000.0)
103 assert opt._rank(ok) < opt._rank(better_but_short)
104 assert opt._rank(_graded(total_impulse_Ns=24100.0)) < opt._rank(ok)
105 # A preflight failure is infeasible.
106 failed = opt.grade({"ok": True, "preflight": ["MAWP"], "figures": None, "x": {}},
107 opt.OptimizeRequest(), {}, None)
108 assert opt._rank(ok) < opt._rank(failed)
109
110
111@pytest.fixture(scope="module")
112def config():
113 return load_config(str(FIXTURE))
114
115
116@pytest.fixture(scope="module")
118 return {d.name: d for d in DrawingStore(None).list()}[GN2]
119
120
121@needs_drawing
123 prep = prepare(config, None, drawing, LayerXSettings(drawing_id=drawing.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, ack_gn2_condensation=True))
124 v = {x.key: x for x in opt.default_variables(prep, config)}
125 lock = v["lockup_psia"]
126 # The floor is 85 % of the lockup the run starts from (the drawing's dome since 2026-10-07);
127 # +15 % is above 600, so the requirements cap the tanks at 600.
128 assert lock.hi == pytest.approx(600.0) and "max_lox_tank_pressure_psi" in lock.basis
129 assert lock.lo == round(prep.derived["target_lockup_psia"] * 0.85)
130 assert v["copv_psig"].hi == pytest.approx(4500.0) and "the bottle pressure the drawing states" in v["copv_psig"].basis
131 assert "assumed" in lock.basis and "assumed" in v["copv_psig"].basis
132 assert not v["copv_volume_L"].enabled
133
134
135@needs_drawing
137 """Lockup only, 550 to 600 psia, a handful of burns. Every burn's impulse to depletion rises
138 with its lockup, and the search ends on the cap and says so."""
139 req = opt.OptimizeRequest(objective="impulse", max_evaluations=6, verify=False,
140 variables={"lockup_psia": {"lo": 550.0, "hi": 600.0},
141 "copv_psig": {"enabled": False}})
142 res = opt.run_optimize(config, drawing, LayerXSettings(drawing_id=drawing.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, ack_gn2_condensation=True), [], req, workers=2)
143 pts = sorted((e["x"]["lockup_psia"], e["objective"]) for e in res["history"] if e["objective"] is not None)
144 assert len(pts) >= 3
145 assert all(b[1] > a[1] for a, b in zip(pts, pts[1:])), pts
146 assert res["best"]["x"]["lockup_psia"] == pytest.approx(600.0)
147 assert any("finished on a bound" in n for n in res["notes"])
148 assert res["best"]["violation"] == 0.0
#define max(a, b)
Definition Arduino.h:43
_trace(int n, float dt, float thrust, float mo, float mf, float ox0, float fu0)
test_impulse_to_depletion_does_not_step_with_the_step()
test_bounds_carry_their_reasons(config, drawing)
test_the_search_climbs_to_the_lockup_cap(config, drawing)