STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_export.py
Go to the documentation of this file.
1"""Exports of a Layer X run (engine/layerx/export.py): CSV, Parquet and the FEA bundle.
2
3What these check:
4
5* **Round trip, bit for bit.** Every per-step signal of a run (series, delivered, replay, network,
6 diagnostics) is written and read back to the same float, with its unit in the header.
7* **Nothing is interpolated.** A replay-point value sits on its own step and nowhere else; a block on
8 a clock that is not the twin's is left out and reported, not resampled.
9* **Every series leaf is there.** The test enumerates the series by its own walk and asserts each
10 numeric column appears, on a hand-built run and on the user's saved runs (read only).
11* **FEA bundle**: the files the contract names, the peak loads equal the maxima of the histories they
12 come from, the heat-flux table has one row per (t, x), and the zip is byte-identical run to run.
13"""
14
15from __future__ import annotations
16
17import copy
18import glob
19import io
20import json
21import zipfile
22from pathlib import Path
23
24import pytest
25
26from engine.layerx.export import columns, fea_bundle, read_csv, to_csv, to_parquet, unit_of
27
28RUNS = Path(__file__).resolve().parents[1] / ".userdata" / "local" / "engine" / "layerx" / "runs"
29PSI = 6894.757293168361
30
31
32def _run():
33 t = [-0.1, -0.05, 0.0, 0.05, 0.1, 0.15, 0.2, 0.25]
34 n = len(t)
35 fire = [False, False, False, True, True, True, True, True]
36 ramp = lambda a, b: [a + (b - a) * k / (n - 1) for k in range(n)] # noqa: E731
37 side = lambda p: {"tank_psia": ramp(p, p + 3.0), "stiffness": [0.0] * 3 + [0.31, 0.32, 0.33, 0.34, 0.35], # noqa: E731
38 "mdot": [0.0] * 3 + [1.9] * 5, "liquid_K": [90.1] * n, "fill_fraction": ramp(0.95, 0.9)}
39 series = {"t": t, "dt": [0.05] * n, "firing": fire, "converged": [True] * n, "copv_psia": ramp(4500.0, 4300.0),
40 "copv_mass_kg": ramp(0.2, 0.19), "copv_wall_K": [293.15] * n,
41 "regulators": {"PR_D": {"label": "PR-DOME", "outlet_psia": ramp(578.0, 580.0)}},
42 "instruments": {"PT_OXU": {"tag": "PT-OX-UP", "type": "PT", "unit": "psia", "values": ramp(578.0, 579.0)},
43 "TC_OXD": {"tag": "TC-OX-DN", "type": "TC", "unit": "K", "values": [90.0] * n}},
44 "ox": side(578.0), "fuel": side(578.1),
45 "chamber": {"pc_psia": [0.0] * 3 + [395.0, 396.0, 397.0, 398.0, 399.0], "thrust_N": [0.0] * 3 + [6900.0] * 5,
46 "extrapolated": [0.0] * n}}
47 fire_t = [t[i] for i in range(n) if fire[i]]
48 return {
49 "series": series,
50 "delivered": {"t": fire_t, "pc_psia": [394.0, 396.5, 397.0, 397.5, 401.25], "thrust_N": [6800.0, 6950.0, 7000.0, 7100.0, 7250.5],
51 "chug_margin": [1.3, 1.31, 1.32, 1.33, 1.34], "summary": {"chug_margin_min": 1.3}},
52 "replay": {"available": True, "t": [0.05, 0.15, 0.25], "index": [3, 5, 7], "eta_cstar": [0.91, 0.912, 0.913],
53 "A_throat_m2": [1.79e-3, 1.80e-3, 1.81e-3]},
54 "network": {"t": t, "nodes": {"MF2": {"label": "press manifold", "kind": "manifold", "side": "gas",
55 "p_psia": ramp(580.0, 582.0), "T_K": ramp(280.0, 270.0)}},
56 "branches": {"l_ox1": {"kind": "line", "from": "OXT", "to": "MVO", "side": "ox",
57 "mdot": [0.0] * 3 + [1.9] * 5, "dp_psi": [0.0] * 3 + [4.0] * 5},
58 "MVO": {"kind": "valve", "from": "MVO.in", "to": "MVO.out", "side": "ox",
59 "mdot": [0.0] * 3 + [1.9] * 5, "dp_psi": [0.0] * 3 + [1.2] * 5,
60 "state": [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0], "cv": 26.1}}},
61 "diagnostics": {
62 "regulator": {"t": t, "inlet_psia": ramp(4500.0, 4300.0), "wide_open": [False] * n, "cv": 0.8,
63 "model": {"name": "x", "inputs": {"a": {"value": 1.0}}}},
64 "ladder": {"t": t, "ox": {"elements": [{"id": "MVO", "label": "LOX main", "kind": "valve",
65 "dp_psi": [0.0] * 3 + [1.2] * 5, "share": [0.0] * 3 + [0.01] * 5}],
66 "total_psi": [0.0] * 3 + [180.0] * 5}},
67 "stability": {"basis": "config", "t": [0.05, 0.15, 0.25], "index": [3, 5, 7], "margin": [1.3, 1.35, 1.4],
68 "worst": {"t": 0.05, "margin": 1.3},
69 "nyquist": {"t": 0.05, "omega": [1.0, 2.0, 3.0], "re": [0.1, 0.2, 0.3], "im": [0.0, 0.1, 0.2]}},
70 "hardware": {"t": [0.05, 0.25], "throat_d_mm": [47.81, 48.72],
71 "separation": {"ratio": [1.1, 1.05], "summerfield": [False, False]},
72 "contour": {"x_mm": [0.0, 1.0, 2.0], "frames": {"t": [0.05], "r_mm": [[1.0, 2.0, 3.0]]}}},
73 "start": {"t": [0.0, 0.001, 0.002], "pc_psia": [14.7, 50.0, 120.0]},
74 "vv": {"mass": {"ox": {"error_pct": 0.0}}},
75 },
76 "provenance": {"config_sha256": "abc", "drawing": {"name": "copv_study_he", "sha256": "def"}},
77 "converged": True,
78 }
79
80
81def _series_leaves(series):
82 """The series' numeric columns, by an independent walk: (dotted name, values)."""
83 out = []
84 n = len(series["t"])
85 for key, v in series.items():
86 if key == "instruments":
87 for iid, inst in v.items():
88 out.append((f"instruments.{iid}.{inst['tag']}", inst["values"]))
89 elif key == "regulators":
90 for rid, reg in v.items():
91 out.extend((f"regulators.{rid}.{k}", c) for k, c in reg.items() if isinstance(c, list))
92 elif isinstance(v, dict):
93 out.extend((f"{key}.{k}", c) for k, c in v.items() if isinstance(c, list) and len(c) == n)
94 elif isinstance(v, list) and len(v) == n:
95 out.append((key, v))
96 return out
97
98
99def _num(v):
100 return None if v is None else float(v)
101
102
104 r = _run()
105 back = read_csv(to_csv(r))
106 n = len(r["series"]["t"])
107 for name, values in _series_leaves(r["series"]):
108 assert name in back, f"{name} missing from the CSV"
109 assert back[name][1] == [_num(v) for v in values], name
110 assert all(len(v[1]) == n for v in back.values())
111 assert back["ox.tank_psia"][0] == "psia" and back["ox.stiffness"][0] == "1" and back["ox.mdot"][0] == "kg/s"
112 assert back["instruments.TC_OXD.TC-OX-DN"][0] == "K"
113 assert back["firing"][1] == [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0]
114
115
117 back = read_csv(to_csv(_run()))
118 assert back["replay.eta_cstar"][1] == [None, None, None, 0.91, None, 0.912, None, 0.913]
119 assert back["delivered.pc_psia"][1] == [None, None, None, 394.0, 396.5, 397.0, 397.5, 401.25]
120 assert back["diagnostics.stability.margin"][1] == [None, None, None, 1.3, None, 1.35, None, 1.4]
121 assert back["diagnostics.hardware.throat_d_mm"][1] == [None, None, None, 47.81, None, None, None, 48.72]
122 assert back["diagnostics.hardware.separation.ratio"][1][3] == 1.1 and back["diagnostics.hardware.separation.ratio"][0] == "1"
123
124
126 back = read_csv(to_csv(_run()))
127 assert back["network.nodes.MF2.p_psia"][0] == "psia" and back["network.nodes.MF2.T_K"][0] == "K"
128 assert back["network.branches.MVO.state"][1][3] == 1.0
129 assert back["network.branches.l_ox1.mdot"][0] == "kg/s" and back["network.branches.l_ox1.dp_psi"][0] == "psi"
130 assert back["diagnostics.regulator.inlet_psia"][1][0] == 4500.0
131 assert back["diagnostics.regulator.wide_open"][0] == "bool"
132 assert back["diagnostics.ladder.ox.elements.MVO.dp_psi"][1][-1] == 1.2
133 assert back["diagnostics.ladder.ox.total_psi"][0] == "psi"
134
135
137 r = _run()
138 r["timeseries"] = {"data": {"time": [0.0, 0.05], "Pc_psi": [380.0, 395.0]}, "source": "layerx"}
139 cols, skipped = columns(r)
140 names = {c[0] for c in cols}
141 # A block the table does not carry is named, never dropped silently.
142 assert "timeseries" in skipped and not any(n.startswith("timeseries") for n in names)
143 assert "diagnostics.start" in skipped and not any(n.startswith("diagnostics.start.") for n in names)
144 assert not any("nyquist" in n or "contour" in n or ".model" in n or n.startswith("diagnostics.vv") for n in names)
145 assert "diagnostics.regulator.cv" not in names
146
147
149 a = _run()
150 b = copy.deepcopy(a)
151 b["series"]["ox"] = dict(reversed(list(b["series"]["ox"].items())))
152 b["network"]["branches"] = dict(reversed(list(b["network"]["branches"].items())))
153 assert to_csv(a).splitlines()[0] == to_csv(b).splitlines()[0]
154
155
157 assert unit_of("ox.tank_psia") == "psia" and unit_of("ox.dump_psi") == "psi"
158 assert unit_of("heat_flux_throat_MW_m2") == "MW/m^2" and unit_of("A_throat_m2") == "m^2"
159 assert unit_of("v_exit_m_s") == "m/s" and unit_of("isp_s") == "s" and unit_of("Lstar_m") == "m"
160 assert unit_of("chamber.cstar") == "m/s" and unit_of("mr") == "1" and unit_of("eps") == "1"
161 # Keys the replay writes (engine/layerx/replay.py): a recession rate is mm/s, not s, and the
162 # ideal c* (~1724 m/s on LE4) carries no suffix but is a velocity.
163 assert unit_of("replay.graphite_recession_rate_mm_s") == "mm/s"
164 assert unit_of("replay.liner_recession_rate_mm_s") == "mm/s"
165 assert unit_of("replay.cstar_ideal") == "m/s"
166
167
169 pq = pytest.importorskip("pyarrow.parquet")
170 r = _run()
171 table = pq.read_table(io.BytesIO(to_parquet(r)))
172 back = read_csv(to_csv(r))
173 assert table.column_names == list(back)
174 for name in table.column_names:
175 assert table.column(name).to_pylist() == back[name][1], name
176 assert table.schema.field(name).metadata[b"unit"].decode() == back[name][0]
177 meta = table.schema.metadata
178 assert meta[b"config_sha256"] == b"abc"
179 assert "diagnostics.start" in json.loads(meta[b"columns_skipped"])
180
181
183 """The UI offers "Every signal (Parquet)". Without pyarrow in the image (Dockerfile.api and CI install
184 requirements-base.txt) every click answered HTTP 501, and the test above skipped itself, so CI never
185 noticed. The base list carries it."""
186 reqs = (Path(__file__).resolve().parents[1] / "requirements-base.txt").read_text().splitlines()
187 assert any(line.split("#")[0].strip().lower().startswith("pyarrow") for line in reqs)
188
189
191 return {"x_mm": [0.0, 40.0, 80.0], "t": [0.05, 0.25],
192 "q_MW_m2": [[1.0, 8.0, 3.0], [1.5, 9.5, 2.0]], "T_wall_K": [[600.0, 1800.0, 900.0], [800.0, 2300.0, 950.0]],
193 "basis": "test stations",
194 "profile": {"x_mm": [0.0, 50.0], "q_MW_m2": [[1.0, 2.0], [1.1, 2.1]]}}
195
196
198 r = _run()
199 blob = fea_bundle(r, _sidecar())
200 z = zipfile.ZipFile(io.BytesIO(blob))
201 assert {"pc_t.csv", "thrust_t.csv", "heatflux_xt.csv", "loads.json", "README.txt"} <= set(z.namelist())
202 pc = read_csv(z.read("pc_t.csv").decode())
203 assert pc["pc_psia"][0] == "psia" and pc["pc_psia"][1] == r["delivered"]["pc_psia"]
204 assert pc["t"][1] == r["delivered"]["t"]
205 assert pc["pc_Pa"][0] == "Pa" and pc["pc_Pa"][1][-1] == pytest.approx(401.25 * PSI, rel=1e-15)
206 thrust = read_csv(z.read("thrust_t.csv").decode())
207 assert thrust["thrust_N"][1] == r["delivered"]["thrust_N"]
208 loads = json.loads(z.read("loads.json"))
209 assert loads["chamber_pressure"]["peak"] == {"value": 401.25, "t": 0.25, "value_Pa": 401.25 * PSI}
210 assert loads["thrust"]["peak"]["value"] == 7250.5 and "erosion replay" in loads["thrust"]["source"]
211 assert loads["tank_pressure"]["ox"]["value"] == max(r["series"]["ox"]["tank_psia"])
212 assert loads["bottle_pressure"]["value"] == 4500.0
213 assert loads["heat_flux"]["peak"] == {"value": 9.5, "t": 0.25, "x_mm": 40.0}
214 assert loads["wall_temperature"]["peak"]["value"] == 2300.0
215 heat = read_csv(z.read("heatflux_xt.csv").decode())
216 assert len(heat["t"][1]) == 2 * 3
217 assert heat["q_MW_m2"][1] == [1.0, 8.0, 3.0, 1.5, 9.5, 2.0] and heat["x_mm"][0] == "mm"
218 assert heat["T_wall_K"][1][4] == 2300.0 and heat["t"][1] == [0.05] * 3 + [0.25] * 3
219 assert "heatflux_profile_xt.csv" in z.namelist()
220 readme = z.read("README.txt").decode()
221 assert "psia" in readme and "MW/m^2" in readme and "from the Fire command" in readme
222 assert blob == fea_bundle(r, _sidecar()), "the same run must zip to the same bytes"
223
224
226 r = _run()
227 del r["delivered"]
228 z = zipfile.ZipFile(io.BytesIO(fea_bundle(r)))
229 assert "heatflux_xt.csv" not in z.namelist()
230 loads = json.loads(z.read("loads.json"))
231 assert loads["heat_flux"]["available"] is False
232 assert "as-built throat" in loads["chamber_pressure"]["source"]
233 assert loads["chamber_pressure"]["peak"]["value"] == 399.0
234
235
236SAVED = [f for f in sorted(glob.glob(str(RUNS / "*.json")))]
237
238
239@pytest.mark.skipif(not SAVED, reason="no saved Layer X runs in .userdata (the user's runs)")
241 done = 0
242 for f in SAVED:
243 d = json.loads(Path(f).read_text())
244 r = d.get("result") or {}
245 if d.get("kind") != "run" or not r.get("series"):
246 continue
247 back = read_csv(to_csv(r))
248 for name, values in _series_leaves(r["series"]):
249 assert name in back, f"{Path(f).name}: {name}"
250 assert back[name][1] == [_num(v) for v in values], f"{Path(f).name}: {name}"
251 if r.get("delivered"):
252 fire = [i for i, x in enumerate(r["series"]["firing"]) if x]
253 col = back["delivered.thrust_N"][1]
254 assert [col[i] for i in fire] == [_num(v) for v in r["delivered"]["thrust_N"]]
255 json.loads(zipfile.ZipFile(io.BytesIO(fea_bundle(r))).read("loads.json"))
256 done += 1
257 assert done
#define max(a, b)
Definition Arduino.h:43
test_saved_runs_export_every_series_column_and_round_trip()
test_network_and_series_clock_diagnostics_are_written()
test_without_a_replay_or_a_sidecar_the_bundle_says_where_its_numbers_came_from()
test_the_fea_bundle_carries_the_named_files_and_its_peaks_are_the_histories_maxima()
test_every_signal_round_trips_through_csv_exactly()
test_what_is_not_on_the_twins_clock_is_left_out_and_reported()
test_replay_points_sit_on_their_own_steps_and_nowhere_else()
static void decode(const disk_t *d)
static double read(int32_t code)