STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_phase5.py
Go to the documentation of this file.
1"""Layer X phase 5: measured inputs, and which unmeasured ones move the burn.
2
3* An override writes a stated number into the drawing before the twin reads it, with its
4 provenance, so the twin's own assembly report counts it as measured.
5* The uncertainty sweep varies each unmeasured input on its own. Checked against what each
6 input can and cannot do, not against a stored answer:
7 * nozzle efficiency changes thrust and nothing upstream of the nozzle;
8 * a lower mixing E_m costs impulse;
9 * a larger LOX Cd flows faster and burns out no later;
10 * a measured input's stated uncertainty replaces the default range.
11"""
12
13from __future__ import annotations
14
15import copy
16import json
17from pathlib import Path
18
19import pytest
20
21
22pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed")
23
24from engine.layerx import DrawingStore, LayerXSettings, prepare # noqa: E402
25from engine.layerx.measurements import MeasurementStore, Override, apply_overrides, parameter_table # noqa: E402
26from engine.layerx.sources import shipped_drawings_dir # noqa: E402
27from engine.pipeline.io import load_config # noqa: E402
28
29FIXTURE = Path(__file__).parent / "fixtures" / "ethalox_6500N_doublet_cad_2026-09-28.yaml"
30GN2 = "copv_study_gn2"
31# The GN2 drawing is a nitrogen-over-LOX hot fire, refused since 2026-10-03 unless acknowledged
32# (engine/layerx/prepare.py gn2_on_lox); these tests study its burn, so their settings acknowledge it.
33pytestmark = [
34 pytest.mark.skipif(not (shipped_drawings_dir() / f"{GN2}.json").is_file(),
35 reason="feed-twin's shipped drawings are not next to this checkout"),
36 # The sweep fixture runs every factor of the uncertainty study on four spawned
37 # workers, each importing and JIT-compiling the engine cold: 32 s on a laptop with
38 # warm caches, past CI's 120 s per-test guard on its runners -- and the guard's
39 # thread method ends the whole run, not just this test. pytest-timeout honours
40 # this; without the plugin it is an unknown mark and only warns.
41 pytest.mark.timeout(600),
42]
43
44
45@pytest.fixture(scope="module")
46def config():
47 return load_config(str(FIXTURE))
48
49
50@pytest.fixture(scope="module")
51def drawing():
52 return {d.name: d for d in DrawingStore(None).list()}[GN2]
53
54
55def droop(value: float, uncertainty=None) -> Override:
56 return Override(target="node:PR_D", parameter="flow_droop", value=value, unit="psi",
57 source="regulator flow test, GN2, 2026-10-05", uncertainty=uncertainty)
58
59
61 payload, applied, missing = apply_overrides(drawing.payload, [droop(6.0, 0.4)])
62 assert not missing and applied[0]["was"]["value"] == 8.3
63 node = next(n for n in payload["nodes"] if n["id"] == "PR_D")
64 p = node["data"]["params"]["flow_droop"]
65 assert p == {"value": 6.0, "unit": "psi", "source": "measured",
66 "reference": "regulator flow test, GN2, 2026-10-05; ±0.4 psi"}
67 # The drawing itself is untouched.
68 assert next(n for n in drawing.payload["nodes"] if n["id"] == "PR_D")["data"]["params"]["flow_droop"]["value"] == 8.3
69 _, _, missing = apply_overrides(drawing.payload, [Override(target="node:NOPE", parameter="x", value=1, unit="",
70 source="s")])
71 assert missing == ["node:NOPE.x"]
72
73
75 """Rail -> Measured values listed 'COPV pressure 3750 psi' beside a rail reading 3765 psia, and sent what
76 was typed with the bare 'psi', which the twin reads as gauge: typing the rail's 3765 gave the burn 3779.7
77 psia (ADR 0004/0007). The table now carries the psia each absolute pressure means, by the drawing rule."""
78 from engine.layerx.measurements import override_view
79
80 atm = 101325.0 / 6894.757293168361 # 14.6959 psi: a drawn bare psi is gauge
81 rows = {(r["target"], r["parameter"]): r for r in parameter_table(drawing.payload)}
82 assert rows[("node:KB1", "pressure")]["psia"] == pytest.approx(4500.0 + atm)
83 assert rows[("node:OXT", "MAWP")]["psia"] == pytest.approx(1000.0 + atm)
84 assert rows[("node:PR_D", "setpoint")]["psia"] == pytest.approx(500.0 + atm)
85 assert rows[("node:ENG", "pressure")]["psia"] == pytest.approx(350.0) # the chamber is quoted absolute
86 assert "psia" not in rows[("node:PR_D", "flow_droop")] # a difference stays as drawn
87 assert "psia" not in rows[("node:PR_D", "dome_bias")]
88 # A restatement saved before the table spoke psia (bare psi, gauge) reads back as what it means...
89 old = Override(target="node:KB1", parameter="pressure", value=3750.0, unit="psi", source="fill log")
90 assert override_view(old, list(rows.values()))["psia"] == pytest.approx(3750.0 + atm)
91 # ...and one typed in psia lands on the drawing as psia, the absolute pressure that was typed.
92 typed = Override(target="node:KB1", parameter="pressure", value=3765.0, unit="psia", source="fill log")
93 payload, _, _ = apply_overrides(drawing.payload, [typed])
94 landed = {(r["target"], r["parameter"]): r for r in parameter_table(payload)}[("node:KB1", "pressure")]
95 assert landed["unit"] == "psia" and landed["psia"] == pytest.approx(3765.0)
96
97
99 """The override is not a label: the regulator component the twin builds carries the
100 restated value, with the measured provenance, and the burn plan is prepared from it."""
101 from feedtwin.model.param import Provenance
102
103 def droop_param(prep):
104 built = prep.model.built
105 branch = next(built.network.branches[b] for b in built.branches_of["PR_D"] if b in built.network.branches)
106 return branch.component.instance.params["flow_droop"]
107
108 base = prepare(config, None, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True))
109 restated = prepare(config, None, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True), [droop(6.0, 0.4)])
110 assert droop_param(base).value == pytest.approx(8.3) # the stand's back-fit, on the drawing
111 p = droop_param(restated)
112 assert p.source is Provenance.MEASURED and p.value == pytest.approx(6.0)
113 assert p.reference.startswith("regulator flow test, GN2, 2026-10-05")
114 assert any(c.key == "overrides" for c in restated.checks)
115 assert restated.derived["overrides"][0]["key"] == "node:PR_D.flow_droop"
116
117
119 with pytest.raises(ValueError, match="source"):
120 Override.from_dict({"target": "node:PR_D", "parameter": "flow_droop", "value": 6, "unit": "psi", "source": " "})
121 store = MeasurementStore(tmp_path)
122 store.put("0123456789abcdef", [droop(6.0)])
123 assert store.get("0123456789abcdef")[0].value == 6.0
124 with pytest.raises(ValueError, match="same parameter"):
125 store.put("0123456789abcdef", [droop(6.0), droop(7.0)])
126
127
129 from engine.layerx.uncertainty import factors
130
131 prep = prepare(config, None, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True), [droop(8.3, 0.5)])
132 found, _ = factors(prep, config)
133 f = next(x for x in found if x.key == "node:PR_D.flow_droop")
134 lo, hi = (c.overrides[0].value for c in f.cases)
135 assert (lo, hi) == pytest.approx((7.8, 8.8))
136 assert f.basis.startswith("measured")
137 # Unrestated, the drawing's own word stands: its 8.3 psi droop says measured, with no ±, so it
138 # is held exact and the note says how to sweep it. Its supply effect says estimated: swept.
139 plain = prepare(config, None, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True))
140 found, notes = factors(plain, config)
141 assert not any(x.key == "node:PR_D.flow_droop" for x in found)
142 assert any("marked measured on the drawing" in n and "droop" in n.lower() for n in notes)
143 spe = next(x for x in found if x.key == "node:PR_D.supply_coefficient")
144 assert [c.overrides[0].value for c in spe.cases] == pytest.approx([0.5 * spe.nominal, 1.5 * spe.nominal])
145
146
147@pytest.fixture(scope="module")
148def sweep(config, drawing):
149 from engine.layerx.uncertainty import run_sweep
150
151 return run_sweep(config, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True), [], workers=4)
152
153
154def _case(sweep, key, side):
155 f = next(x for x in sweep["factors"] if x["key"] == key)
156 c = f["cases"][side]
157 assert c["ok"], c.get("error")
158 return c["delta"]
159
160
162 keys = {f["key"] for f in sweep["factors"]}
163 assert {"node:PR_D.supply_coefficient", "ullage_collapse", "engine.cd_O",
164 "engine.cd_F", "engine.em", "engine.nozzle"} <= keys
165 assert "node:PR_D.flow_droop" not in keys # measured on the drawing, held exact
166 assert sweep["band"]["total_impulse_Ns"] > 0
167 # The drawing's own guesses, the switches it burns without, and the stand on the day are swept too.
168 assert {"drawing.valve_cv", "drawing.line_k", "line_walls", "ullage_vapour", "op.lockup", "op.fill"} <= keys
169 assert sweep["cases"] == 1 + sum(len(f["cases"]) for f in sweep["factors"])
170
171
173 """A fixed load burnt dry gives the same impulse whatever the valves: the tornado ranked by
174 impulse said "the top bar is the next measurement" while the feed was invisible to it."""
175 lo = _case(sweep, "drawing.valve_cv", "low")
176 assert abs(lo["mean_thrust_N"]) > 5 * abs(lo["total_impulse_Ns"]) / max(sweep["nominal"]["burn_time_s"], 1e-9)
177 assert lo["mean_thrust_N"] < 0 # half the valve Cv: less flow, less thrust
178
179
181 """Mixing E_m costs far more impulse below than it adds above (0.70 vs 0.85 about 0.80):
182 one symmetric ± overstated the upside."""
183 lo, hi = sweep["band_low"]["total_impulse_Ns"], sweep["band_high"]["total_impulse_Ns"]
184 assert lo > 0 and hi > 0 and lo != pytest.approx(hi, rel=0.05)
185 assert isinstance(sweep["crossings"], list)
186
187
189 for side in ("low", "high"):
190 d = _case(sweep, "engine.nozzle", side)
191 assert d["burn_time_s"] == pytest.approx(0.0, abs=1e-9)
192 assert abs(d["pc_mean_psia"]) < 0.05
193 assert abs(d["total_impulse_Ns"]) > 100.0
194 assert _case(sweep, "engine.nozzle", "low")["total_impulse_Ns"] < 0 < _case(sweep, "engine.nozzle", "high")["total_impulse_Ns"]
195
196
198 assert _case(sweep, "engine.em", "low")["total_impulse_Ns"] < 0
199 assert _case(sweep, "engine.cd_O", "high")["burn_time_s"] <= 0
200 assert _case(sweep, "engine.cd_O", "high")["of_mean"] > 0 > _case(sweep, "engine.cd_O", "low")["of_mean"]
201
202
204 """The card samples the engine with the config's measurements applied, as the runner and the
205 replay do. A measured LOX Cd 5 % above the model's has to show up as more LOX flow at the
206 same line pressures; before this was fixed the card's fast path sampled the unmeasured
207 config and the measurement reached the replay but not the card."""
208 from engine.layerx.card import EngineSampler
209 from engine.pipeline.config_schemas import MeasuredValue, MeasurementsConfig
210
211 p = 578.0 * 6894.757293168361
212 plain = EngineSampler(config, 94070.0)
213 cd = plain.runner.evaluate(p, p, silent=True)["Cd_O"]
214 measured = config.model_copy(deep=True)
215 measured.measurements = MeasurementsConfig(cd_O=MeasuredValue(value=1.05 * cd, source="cold flow, water"))
216 sampler = EngineSampler(measured, 94070.0)
217 assert sampler.config.discharge["oxidizer"].Cd_inf == pytest.approx(1.05 * cd)
218 a, b = plain(p, p), sampler(p, p)
219 assert b["mdot_O"] > a["mdot_O"] * 1.02
220
221
223 """Keyed by what the drawing is (its shipped file, its pid-designer document), not by its
224 content: an edited drawing has a new id and keeps its restatements, and says which revision
225 they were entered against. An old id-keyed file still reads."""
226 import copy
227 from dataclasses import replace
228
229 store = MeasurementStore(tmp_path)
230 store.put(drawing, [droop(6.0, 0.4)])
231 edited = copy.deepcopy(drawing.payload)
232 edited["nodes"][0].setdefault("data", {})["label"] = "renamed"
233 revised = replace(drawing, payload=edited, id="fedcba9876543210", sha256="0" * 64)
234 overrides, problems, revision = store.load(revised)
235 assert [o.value for o in overrides] == [6.0] and not problems
236 assert revision == drawing.sha256 != revised.sha256
237 # Another drawing does not see them.
238 other = replace(drawing, source="shipped:copv_study_he.json", id="1111111111111111")
239 assert store.get(other) == []
240 # The old key, the content id, still reads.
241 legacy = MeasurementStore(tmp_path / "legacy")
242 legacy.put("2222222222222222", [droop(7.0)])
243 assert [o.value for o in legacy.get(replace(drawing, id="2222222222222222", source="upload"))] == [7.0]
244 # A non-finite number is refused at the door.
245 with pytest.raises(ValueError, match="finite"):
246 Override.from_dict({"target": "node:PR_D", "parameter": "flow_droop", "value": float("nan"), "unit": "psi",
247 "source": "x"})
#define max(a, b)
Definition Arduino.h:43
sweep(config, drawing)
test_a_measured_engine_input_reaches_the_card(config)
Override droop(float value, uncertainty=None)
test_impulse_is_blind_to_the_feed_and_thrust_is_not(sweep)
test_a_measured_uncertainty_replaces_the_default_range(config, drawing)
test_nozzle_efficiency_moves_thrust_and_nothing_upstream(sweep)
test_worse_mixing_costs_impulse_and_more_cd_flows_faster(sweep)
test_a_restated_parameter_reaches_the_twin_as_measured(config, drawing)
test_the_store_wants_a_source_and_one_value_per_parameter(tmp_path)
test_an_override_lands_in_the_drawing_with_its_provenance(drawing)
_case(sweep, key, side)
test_the_restatement_table_reads_and_takes_absolute_pressures_in_psia(drawing)
test_restatements_follow_the_drawing_through_an_edit(tmp_path, drawing)