STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_setpoint.py
Go to the documentation of this file.
1"""Set point (engine/layerx/setpoint.py): the dome, lockup and fill for a target mean thrust.
2
3The solve is checked against closed-form stands, where the root is known by hand, through the same
4``evaluate`` seam the burns go through. One real burn smoke test runs with ``LAYERX_SLOW=1``.
5
6Hand numbers used below:
7
8* Linear stand, F(L) = 7013 + 10.1 (L - 578) N (LE4's slope, docs/layerx/AUDIT.md 9.10 3.2):
9 F = 7200 N at L = 578 + 187/10.1 = 596.5149 psia. The secant is exact on a line, so two opening
10 burns and one Newton burn.
11* Coupled stand, the same thrust plus 0.015 N per psig of fill above 4500, and the bottle spare
12 S = F_fill + 14.7 - 6 L psi (a draw of 5 psi per psia of lockup, so 3000 psi at 600 psia):
13 S = 100 gives F_fill = 85.3 + 6 L; substituted, (10.1 + 0.09) L = 7200 - 7013 + 5837.8
14 + 0.015 * 4414.7 = 6091.0205, so L = 597.7449 psia and F_fill = 3671.77 psig.
15"""
16
17from __future__ import annotations
18
19import math
20import os
21import sys
22from types import SimpleNamespace
23from typing import Any, Dict, List, Optional, Tuple
24
25import pytest
26
27sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
29from engine.layerx import setpoint as sp # noqa: E402
30
31AMB = 13.64
32
33
34def _stand(thrust, spare=None, *, peak_rise: float = 41.0, meop: Optional[float] = None,
35 trip_above: Optional[float] = None, calls: Optional[list] = None):
36 """A closed-form stand behind the ``evaluate`` seam."""
37
38 def evaluate(points: List[Tuple[float, Optional[float], bool]]) -> List[Dict[str, Any]]:
39 out = []
40 for L, F, rp in points:
41 if calls is not None:
42 calls.append((L, F, rp))
43 if trip_above is not None and L > trip_above:
44 out.append({"ok": True, "preflight": [], "lockup_psia": L, "fill_psig": F, "replay": rp,
45 "tripped": {"vessel": "TK_FUEL", "t": 2.1, "p_psia": 764.7},
46 # What a trip looks like if it is not read: the frozen frame integrated to the
47 # horizon, a plausible-looking mean thrust (AUDIT finding 1).
48 "figures": {"mean_thrust_N": 7150.0, "copv_spare_psi": 900.0}, "limits": []})
49 continue
50 peak = L + peak_rise
51 limits = []
52 if meop is not None:
53 limits.append({"key": "tank_peak_meop.fuel", "label": "Fuel tank peak vs MEOP", "value": peak,
54 "limit": meop + AMB, "direction": "max",
55 "grade": "bad" if peak > meop + AMB else "ok"})
56 figs = {"mean_thrust_N": thrust(L, F), "of_mean": 1.5212 - 3e-5 * (L - 578.0),
57 "copv_spare_psi": spare(L, F) if spare else 900.0, "fuel_peak_psia": peak,
58 "total_impulse_Ns": thrust(L, F) * 3.45, "burn_time_s": 3.45}
59 out.append({"ok": True, "preflight": [], "lockup_psia": L, "fill_psig": F, "replay": rp,
60 "dome_psig": L - 64.4 + 0.017 * (F - 4500.0), "dome_per_1000psi_fill": 17.0,
61 "figures": figs, "limits": limits, "tripped": None,
62 "derived": {"stand_ids": {"loader": "PR_C", "regulator": "PR_D", "bottle": "KB1"},
63 "config_sha256": "c" * 64, "drawing": {"id": "d", "name": "he", "sha256": "s"}}})
64 return out
65
66 return evaluate
67
68
69def linear(L: float, F: float) -> float:
70 return 7013.0 + 10.1 * (L - 578.0)
71
72
73def coupled(L: float, F: float) -> float:
74 return linear(L, F) + 0.015 * (F - 4500.0)
75
76
77def coupled_spare(L: float, F: float) -> float:
78 return F + 14.7 - 6.0 * L
79
80
81CONFIG = SimpleNamespace(design_requirements=SimpleNamespace(target_thrust=7200.0, optimal_of_ratio=1.5),
82 lox_tank=SimpleNamespace(initial_pressure_psi=578.0))
83SETTINGS = SimpleNamespace(tank_pressure_psia=578.0, copv_pressure_psig=4500.0)
84STAND = {"lockup_psia": 578.0, "fill_psig": 4500.0, "max_fill_psig": 4500.0, "reference_thrust_N": 6751.0}
85
86
87def _run(evaluate, **req):
88 return sp.run_setpoint(CONFIG, None, SETTINGS, [], sp.SetpointRequest(**req), evaluate=evaluate, stand=STAND)
89
90
91# ------------------------------------------------------------------ the solve
92
93
95 out = _run(_stand(linear), solve_fill=False)
96 assert out["converged"] and out["burns"] == 3
97 assert out["settings_card"]["lockup_psia"] == pytest.approx(578.0 + 187.0 / 10.1, abs=1e-6) # 596.5149
98 assert out["solution"]["mean_thrust_N"] == pytest.approx(7200.0, abs=1e-6)
99 # The opening pair burned together, from EngineDesign's T-0 thrust: 578 * 7200 / 6751 = 616.44.
100 assert [round(h["asked"]["lockup_psia"], 2) for h in out["history"][:2]] == [578.0, 616.44]
101
102
104 """F = 300 sqrt(L): F = 7200 N at L = (7200/300)^2 = 576 psia."""
105 out = _run(_stand(lambda L, F: 300.0 * math.sqrt(L)), solve_fill=False, thrust_tol_rel=1e-5)
106 assert out["converged"]
107 assert out["settings_card"]["lockup_psia"] == pytest.approx(576.0, rel=2e-5)
108 assert out["burns"] <= 5
109
110
112 out = _run(_stand(coupled, coupled_spare), solve_fill=True, margin_psi=100.0, margin_tol_psi=1.0, max_burns=10)
113 assert out["converged"] and out["fill_status"] == "solved"
114 L, F = out["settings_card"]["lockup_psia"], out["settings_card"]["copv_fill_psig"]
115 # By hand (module docstring): 597.7449 psia, 3671.77 psig. Within the stated tolerances: 7.2 N is
116 # 0.71 psi of lockup, 1 psi of spare is ~1 psi of fill.
117 assert L == pytest.approx(597.7449, abs=0.75)
118 assert F == pytest.approx(3671.77, abs=6.0)
119 assert abs(out["solution"]["mean_thrust_N"] - 7200.0) <= 7.2
120 assert abs(out["solution"]["copv_spare_psi"] - 100.0) <= 1.0
121 assert out["burns"] <= 7
122
123
125 """Near dropout the spare rises only 0.5 psi per psi of fill, not the 1.0 the first Jacobian
126 assumes, and the thrust-fill coupling is not the 0 it assumes. Stand: thrust as ``coupled``;
127 spare S = 944 + 0.5 (F - 4500) - 5.5 (L - 600.36) (AUDIT 9.10 3.2/3.4 numbers). With
128 u = L - 578, v = F - 4500 the root solves 10.1 u + 0.015 v = 187, 0.5 v - 5.5 u = -966.98:
129 v = -1702.48, u = 21.0433, i.e. L = 599.043 psia, F = 2797.5 psig. A fixed first Jacobian closes the
130 spare only by half each burn and runs out of burns; Broyden's update does not."""
131 def spare(L, F):
132 return 944.0 + 0.5 * (F - 4500.0) - 5.5 * (L - 600.36)
133
134 out = _run(_stand(coupled, spare), solve_fill=True, margin_psi=100.0, margin_tol_psi=1.0, max_burns=8)
135 assert out["converged"] and out["fill_status"] == "solved"
136 assert out["settings_card"]["lockup_psia"] == pytest.approx(599.043, abs=0.75)
137 assert out["settings_card"]["copv_fill_psig"] == pytest.approx(2797.5, abs=4.0)
138 # Here the first Jacobian is wrong only in its fill column and the first fill step runs almost
139 # along the fill, so one rank-one secant update along that step nearly corrects it: three lockup
140 # burns and two fill burns. A wrong update (the outer product transposed) still creeps in, but
141 # takes a sixth burn (review mutation check).
142 assert out["burns"] <= 5
143
144
146 """The fill band is one-sided. Stand: spare rises 1.02 psi per psi of fill, where the first
147 Jacobian assumes 1.0, so the first fill step overshoots low by 2 % of the distance: from 965 psi
148 at the full bottle, a two-sided +-20 psi band around 100 would accept the 82.7 psi it lands on --
149 a "solved" fill that the shared ``bottle_margin`` limit (100 psi) grades bad. Aiming at 110 +- 10
150 it lands at 92.9, is refused, and Broyden's update brings it into [100, 120]."""
151 def spare(L, F):
152 return 944.0 + 1.02 * (F - 4500.0) - 5.5 * (L - 600.36)
153
154 out = _run(_stand(linear, spare), solve_fill=True, margin_psi=100.0, margin_tol_psi=20.0, max_burns=8)
155 assert out["converged"] and out["fill_status"] == "solved"
156 assert 100.0 <= out["solution"]["copv_spare_psi"] <= 120.0
157
158
160 """A burn's flat figures, on the delivered basis where there is one. The bottle's spare is over the
161 higher tank's T-0 pressure (the shared ``bottle_margin``'s basis), not the lockup asked for:
162 706.38 - 597.93 = 108.45 psi (the builder's real LE4 burn: 108.57 over the 597.81 lockup)."""
163 res = {"summary": {"mean_thrust_N": 7158.0, "peak_thrust_N": 7380.0, "min_thrust_N": 6950.0,
164 "total_impulse_Ns": 24700.0, "burn_time_s": 3.45, "of_mean": 1.5212, "copv_end_psia": 706.38,
165 "ox": {"peak_psia": 634.5, "t0_psia": 597.93, "loaded_kg": 6.5, "residual_kg": 0.0},
166 "fuel": {"peak_psia": 635.1, "t0_psia": 597.90, "loaded_kg": 4.3, "residual_kg": 0.06}},
167 "delivered": {"summary": {"mean_thrust_N": 7199.4, "peak_thrust_N": 7420.0, "min_thrust_N": 6990.0},
168 "thrust_N": [None, 6980.0, 7000.0]}}
169 f = sp.figures(res, 597.81)
170 assert f["mean_thrust_N"] == 7199.4 and f["thrust_t0_N"] == 6980.0 and f["replayed"] is True
171 assert f["thrust_spread_pct"] == pytest.approx((7420.0 - 6990.0) / 7199.4 * 100.0)
172 assert f["copv_spare_psi"] == pytest.approx(706.38 - 597.93)
173 assert f["fuel_used_kg"] == pytest.approx(4.24) and f["ox_used_kg"] == pytest.approx(6.5)
174 # A run that reports no tank T-0 pressure falls back to the lockup asked for.
175 bare = {"summary": {**res["summary"], "ox": {}, "fuel": {}}}
176 assert sp.figures(bare, 597.81)["copv_spare_psi"] == pytest.approx(706.38 - 597.81)
177 assert sp.figures(bare, 597.81)["replayed"] is False
178
179
181 out = _run(_stand(linear, lambda L, F: F + 14.7 - 7.9 * L), solve_fill=True, margin_psi=100.0)
182 # At 596.5 psia a full bottle leaves 4514.7 - 7.9 * 596.5 = -197.7 psi: short.
183 assert out["fill_status"] == "bottle_short"
184 assert out["settings_card"]["copv_fill_psig"] == 4500.0
185 assert any("cannot keep the margin" in n for n in out["notes"])
186
187
189 """Peak = lockup + 41 psi; MEOP 585 psi + 13.64 = 598.64 psia, reached at 557.64 psia lockup,
190 where the line gives 7013 + 10.1 (557.64 - 578) = 6807.4 N."""
191 out = _run(_stand(linear, meop=585.0), solve_fill=False)
192 assert out["converged"] and not out["feasible"]
193 (b,) = out["binding"]
194 assert b["key"] == "tank_peak_meop.fuel"
195 assert b["lockup_psia_est"] == pytest.approx(557.64, abs=1e-6)
196 assert b["mean_thrust_N_est"] == pytest.approx(6807.36, abs=0.01)
197
198
200 """The stand trips above 610 psia: the opening guess (616.4) trips, the solve still finds 596.5."""
201 calls: list = []
202 out = _run(_stand(linear, trip_above=610.0, calls=calls), solve_fill=False)
203 assert out["converged"]
204 assert out["settings_card"]["lockup_psia"] == pytest.approx(596.5149, abs=0.75)
205 assert not out["solution"].get("tripped")
206
207
209 """7400 N needs 616.3 psia on the line, but the stand trips above 605 psia. Each burn that trips
210 bounds the next step at halfway to it, so the solve closes on the highest usable lockup."""
211 out = _run(_stand(linear, trip_above=605.0), solve_fill=False, target_thrust_N=7400.0)
212 assert not out["converged"]
213 assert 604.0 < out["settings_card"]["lockup_psia"] <= 605.0
214 assert not out["solution"].get("tripped")
215 assert any("trips the stand" in n for n in out["notes"])
216
217
219 """The replay adds a constant +41 N (AUDIT 9.10 4a: 7013 vs 6972 N at 578 psia)."""
220 calls: list = []
221
222 def thrust(L, F):
223 return linear(L, F)
224
225 base = _stand(thrust, calls=calls)
226
227 def evaluate(points):
228 out = base(points)
229 for p, (_, _, rp) in zip(out, points):
230 if rp:
231 p["figures"]["mean_thrust_N"] += 41.0
232 return out
233
234 out = _run(evaluate, solve_fill=False, replay=False, verify=True)
235 assert [c[2] for c in calls][-2:] == [True, True] # verify, then the corrected verify
236 assert out["solution"]["mean_thrust_N"] == pytest.approx(7200.0, abs=1e-6)
237 assert out["settings_card"]["lockup_psia"] == pytest.approx(578.0 + (187.0 - 41.0) / 10.1, abs=1e-6)
238
239
240# ------------------------------------------------------------------ the outputs
241
242
244 from engine.layerx import diff
245
246 out = _run(_stand(linear), solve_fill=False)
247 card = out["settings_card"]
248 assert card["dome_psig"] == pytest.approx(card["lockup_psia"] - 64.4)
249 assert card["fuel_lead"].startswith("not modelled")
250 cl = out["change_list"]
251 assert cl["schema"] == diff.SCHEMA and cl["tool"] == "setpoint"
252 by = {c["target"]: c for c in cl["changes"]}
253 assert set(by) == {"op:dome_psig", "op:lockup_psia"} # the fill did not move
254 for c in cl["changes"]:
255 assert c["cad_impact"] == "setting only" and c["domain"] == "operation" and c["provenance"].startswith("solved")
256 assert set(c) >= {"component", "pid_node_id", "field", "before", "after", "unit", "provenance", "effect", "cad_impact"}
257 assert by["op:lockup_psia"]["pid_node_id"] == "PR_D" and by["op:dome_psig"]["pid_node_id"] == "PR_C"
258 assert by["op:lockup_psia"]["effect"]["mean_thrust_N"] == pytest.approx(187.0, abs=1e-6)
259 # The patch clears the rail's dome dial, which would otherwise win over the tank pressure it sets.
260 assert cl["exports"]["settings_patch"] == {"tank_pressure_psia": pytest.approx(596.51, abs=0.01), "dome_psia": None}
261 m = out["model"]
262 assert m["name"] and "Broyden" in m["source"] and m["assumptions"]
263 for name, v in m["inputs"].items():
264 assert set(v) == {"value", "unit", "provenance"}, name
265 assert out["of"]["settable_here"] is False and out["of"]["offset_rel"] == pytest.approx(
266 out["solution"]["of_mean"] / 1.5 - 1.0)
267 assert out["unmeasured"] and out["summary"]["lockup_psia"] == card["lockup_psia"]
268
269
271 assert sp.target_thrust(CONFIG, sp.SetpointRequest())[0] == 7200.0
272 assert sp.target_thrust(CONFIG, sp.SetpointRequest(target_thrust_N=6800.0)) == (6800.0, "request")
273 with pytest.raises(ValueError, match="target thrust"):
274 sp.target_thrust(SimpleNamespace(design_requirements=None), sp.SetpointRequest())
275
276
277# ------------------------------------------------------------------ the grade it uses
278
279
281 """Peaks 642.0 (LOX) / 642.5 (fuel) psia at 13.64 psia ambient: the tanks trip at 1000 psia (``tank_mawp_psi``
282 is absolute, prepare.vessel_trip; it is held as it is, not with the site's atmosphere added), ok;
283 the design cap 600 -> warn (AUDIT D11); fuel MEOP 625 psi -> 638.64 psia, bad by 3.86 psi."""
284 pytest.importorskip("feedtwin")
285 res = {"summary": {"ox": {"peak_psia": 642.0, "stiffness_min": 0.37, "min_psia": 600.0, "residual_kg": 0.0},
286 "fuel": {"peak_psia": 642.5, "stiffness_min": 0.36, "min_psia": 600.0, "residual_kg": 0.07},
287 "copv_end_psia": 1544.4, "total_impulse_Ns": 24000.0, "impulse_to_depletion_Ns": 24000.0,
288 "failed_steps": 0},
289 "converged": True}
290 prep = SimpleNamespace(derived={"target_lockup_psia": 600.36, "ambient_pa": AMB * sp.PSI,
291 "tank_mawp_psi": {"TK_LOX": 1000.0, "TK_FUEL": 1000.0},
292 "stiffness_band": {"oxidiser": [0.2, 0.4], "fuel": [0.2, 0.4]}},
293 roles={"oxidiser": "TK_LOX", "fuel": "TK_FUEL"})
294 config = SimpleNamespace(design_requirements=SimpleNamespace(max_lox_tank_pressure_psi=600.0,
295 max_fuel_tank_pressure_psi=600.0))
296 rows = {r["key"]: r for r in sp._fallback_limits(res, prep, config, {"margin_psi": 100.0})}
297 assert rows["dropout"]["value"] == pytest.approx(1544.4 - 600.36) and rows["dropout"]["grade"] == "ok"
298 assert rows["tank_peak_mawp.fuel"]["limit"] == pytest.approx(1000.0) and rows["tank_peak_mawp.fuel"]["grade"] == "ok"
299 assert rows["tank_peak_cap.oxidiser"]["grade"] == "warn"
300 graded, basis = sp.grade_limits(res, prep, config, {"margin_psi": 100.0, "meop_psi": {"fuel": 625.0}})
301 by = {r["key"]: r for r in graded}
302 assert by["tank_peak_meop.fuel"]["limit"] == pytest.approx(638.64) and by["tank_peak_meop.fuel"]["grade"] == "bad"
303 assert by["vessel_trip"]["grade"] == "ok"
304 assert not sp.feasible({"ok": True, "figures": {"mean_thrust_N": 1.0}, "limits": graded})
305 tripped, _ = sp.grade_limits({**res, "tripped": {"vessel": "TK_FUEL"}}, prep, config, {"margin_psi": 100.0})
306 assert next(r for r in tripped if r["key"] == "vessel_trip")["grade"] == "bad"
307
308
310 """With engine/layerx/diag/limits.py present, the request's MEOP is handed to it (graded on the
311 contract's basis, psi across the wall) and the set point adds no second MEOP row of its own."""
312 from engine.layerx.diag import limits
313
314 seen = {}
315
316 def shared(result, prep=None, config=None, *, meop_psi=None):
317 seen["meop_psi"] = meop_psi
318 across = 642.5 - AMB
319 return [{"key": "tank_meop_fuel", "label": "Fuel tank peak vs MEOP", "group": "tanks", "value": across,
320 "unit": "psi", "limit": 625.0, "warn": None, "direction": "max", "grade": "bad"}]
321
322 monkeypatch.setattr(limits, "grade", shared)
323 prep = SimpleNamespace(derived={"ambient_pa": AMB * sp.PSI})
324 rows, basis = sp.grade_limits({"summary": {"fuel": {"peak_psia": 642.5}}}, prep, None,
325 {"margin_psi": 100.0, "meop_psi": {"fuel": 625.0}})
326 assert seen["meop_psi"] == {"fuel": 625.0} and basis == "engine.layerx.diag.limits.grade"
327 assert [r["key"] for r in rows if "meop" in r["key"]] == ["tank_meop_fuel"]
328 assert [r["key"] for r in rows if r["key"] == "vessel_trip"] == ["vessel_trip"]
329
330
331# ------------------------------------------------------------------ the route
332
333
335 from fastapi import FastAPI
336 from fastapi.testclient import TestClient
337
338 from backend.routers import layerx as lx
339 from backend.session import UserSession, get_session
340
341 monkeypatch.setenv("USERDATA_DIR", str(tmp_path))
342 session = UserSession(f"setpoint-{tmp_path.name}")
343 app = FastAPI()
344 app.include_router(lx.router)
345 app.dependency_overrides[get_session] = lambda: session
346 client = TestClient(app)
347 bad = client.post("/api/layerx/setpoint", json={"settings": {"drawing_id": "d"}, "meop_psi": {"lox": 750}})
348 assert bad.status_code == 422
349 r = client.post("/api/layerx/setpoint", json={"settings": {"drawing_id": "d"}})
350 assert r.status_code in (400, 404) # no design loaded / no drawing: refused
351 with lx._JOBS_LOCK:
352 assert not [j for j in lx._JOBS.values() if j.user == session.user]
353
354
355# ------------------------------------------------------------------ one real burn pair
356
357
358@pytest.mark.skipif(os.environ.get("LAYERX_SLOW") != "1", reason="burns: set LAYERX_SLOW=1")
360 """LE4 on the He drawing, on the pad, no replay (as-built throat): AUDIT 9.10 3.2 measured 6972 N at
361 578 psia and 7196 N at 600 psia. Target 7100 N: the answer lies between, ~589.5 psia."""
362 pytest.importorskip("feedtwin")
363 from engine.layerx import DrawingStore, LayerXSettings
364 from engine.pipeline.io import load_config
365
366 drawings = {d.name: d for d in DrawingStore(None).list()}
367 if "copv_study_he" not in drawings:
368 pytest.skip("feed-twin's shipped drawings are not next to this checkout")
369 config = load_config(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
370 "configs", "ethalox_6800N.yaml"))
371 settings = LayerXSettings(drawing_id=drawings["copv_study_he"].id, replay=False, flight=False)
372 out = sp.run_setpoint(config, drawings["copv_study_he"], settings, [],
373 sp.SetpointRequest(target_thrust_N=7100.0, solve_fill=False, replay=False, verify=False,
374 thrust_tol_rel=5e-3, max_burns=3), workers=2)
375 assert out["burns"] <= 3
376 assert abs(out["solution"]["mean_thrust_N"] / 7100.0 - 1.0) < 5e-3
377 assert 580.0 < out["settings_card"]["lockup_psia"] < 600.0
378 assert out["settings_card"]["dome_psig"] is not None
379 assert out["limits"], "every burn is graded"
test_smoke_the_real_stand_reaches_a_target_in_three_burns()
test_the_secant_lands_on_the_hand_root_of_a_linear_stand_in_three_burns()
float coupled(float L, float F)
_stand(thrust, spare=None, *, float peak_rise=41.0, Optional[float] meop=None, Optional[float] trip_above=None, Optional[list] calls=None)
test_a_bottle_too_small_for_the_margin_is_said_not_hidden()
test_the_route_refuses_before_a_slot_is_taken(tmp_path, monkeypatch)
float linear(float L, float F)
test_without_the_replay_the_answer_is_verified_with_it_and_corrected_once()
test_a_target_beyond_a_trip_homes_on_the_trip_and_says_so()
float coupled_spare(float L, float F)
test_the_shared_grade_gets_the_meop_and_it_is_graded_once(monkeypatch)
test_a_set_point_over_the_meop_is_infeasible_and_says_where_the_limit_binds()