STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_network_trace.py
Go to the documentation of this file.
1"""The opt-in full-network recorder (``Probes(network=True)``).
2
3Two claims to hold it to. Off, a burn's trace is exactly what it always was, and
4on, recording changes no physics: every probe column is bit-for-bit the same.
5And the ladder it records is one solve's: along each side's path the rungs
6telescope to the first node minus the last, the path really is contiguous from
7a bottle to the chamber, and every rung's drop is what its own component gives
8at the flow recorded beside it -- which is the check that flows, pressures and
9orientation were read off the same solve and the same way round.
10"""
11
12from __future__ import annotations
13
14import json
15from dataclasses import fields, replace
16from pathlib import Path
17
18import pytest
19
20from feedtwin.comps import conditions_from_fluid
21from feedtwin.pid import read_diagram
22from feedtwin.session import PSI, assemble_model, load_machine
23from feedtwin.session.burn import (
24 BurnPlan,
25 BurnTrace,
26 burn,
27 burn_setup,
28 find_probes,
29 network_record,
30 open_session,
31 prime_at_t0,
32 run_burn,
33)
34
35STAR = Path(__file__).resolve().parents[3]
36STAND = STAR / "feed-twin" / "backend" / "diagrams" / "ethalox_stand.json"
37TABLES = STAR / "feed-twin" / "backend" / "statemachines"
38ENGINE = STAR / "EngineDesign" / "configs" / "ethalox_doublet_7000N.yaml"
39CEA = STAR / "EngineDesign" / "output" / "cache" / "cea_cache_LOX_Ethanol_3D.npz"
40
41pytestmark = pytest.mark.skipif(
42 not (STAND.exists() and TABLES.is_dir() and ENGINE.exists() and CEA.exists()),
43 reason="stand drawing, tables, engine config or CEA table absent",
44)
45
46PLAN = BurnPlan(
47 tank_psi=550.0, loads={"OXT": 6.0, "FUT": 4.0}, settle=False, lead_in_s=0.1,
48 horizon_s=0.3,
49) # fmt: skip
50
51
52def _session(flip: tuple[str, ...] = (), **setup): # type: ignore[no-untyped-def]
53 """A session on the stand; ``flip`` names lines to draw the other way round."""
54 import yaml
55
56 from feedtwin.engine.importer import engine_from_config
57
58 payload = json.loads(STAND.read_text())
59 for edge in payload["edges"]:
60 if edge["id"] in flip:
61 edge["source"], edge["target"] = edge["target"], edge["source"]
62 diagram = read_diagram(payload, name="ethalox_stand")
63 design = engine_from_config(yaml.safe_load(ENGINE.read_text()), name="7000N")
64 model = assemble_model(
65 diagram, diagram_id="ethalox_stand.json", engine=design, cea_cache=str(CEA)
66 )
67 return open_session(model, load_machine(tables=TABLES), setup=burn_setup(**setup))
68
69
70def _burn(network: bool, flip: tuple[str, ...] = (), **setup): # type: ignore[no-untyped-def]
71 session = _session(flip, **setup)
72 prime_at_t0(session, PLAN)
73 trace = BurnTrace(probes=replace(find_probes(session), network=network))
74 trace.end = burn(session, PLAN, trace.recorder(session))
75 return session, trace
76
77
78#: The tight numerics Layer X burns with, so a rung's drop can be checked against
79#: its own component to a few hundredths of a psi.
80TIGHT = {
81 "network_tolerance": 1.0e-7,
82 "chamber_tolerance_psi": 0.02,
83}
84
85
86@pytest.fixture(scope="module")
87def recorded(): # type: ignore[no-untyped-def]
88 return _burn(True, **TIGHT)
89
90
92 _, off = _burn(False)
93 _, on = _burn(True)
94 assert off.network is None
95 assert network_record(off)["available"] is False
96 assert on.network is not None
97 for f in fields(BurnTrace):
98 if f.name in ("network", "end", "probes"):
99 continue
100 assert getattr(on, f.name) == getattr(off, f.name), f.name
101 assert on.end is not None and off.end is not None
102 assert (on.end.depleted_s, on.end.tank, on.end.steps, on.end.failed_steps) == (
103 off.end.depleted_s,
104 off.end.tank,
105 off.end.steps,
106 off.end.failed_steps,
107 )
108
109
111 plan = BurnPlan(
112 tank_psi=550.0, loads={"OXT": 6.0, "FUT": 4.0}, settle=False, lead_in_s=0.05,
113 horizon_s=0.05,
114 ) # fmt: skip
115 off = run_burn(_session(), plan)
116 on = run_burn(_session(), plan, network=True)
117 assert off.network is None and not off.probes.network
118 assert on.network is not None and on.probes.network
119 assert network_record(on)["t"] == on.t
120 assert on.pressure == off.pressure and on.tank == off.tank
121
122
123def test_each_path_runs_bottle_to_chamber_and_telescopes(recorded) -> None: # type: ignore[no-untyped-def]
124 session, trace = recorded
125 rec = network_record(trace)
126 nodes, branches, paths = rec["nodes"], rec["branches"], rec["paths"]
127 assert set(paths) == {"ox", "fuel"}
128 assert rec["t"] == trace.t
129 n = len(trace.t)
130 for side, path in paths.items():
131 first, last = branches[path[0]]["from"], branches[path[-1]]["to"]
132 assert nodes[first]["kind"] == "bottle", side
133 assert nodes[last]["kind"] == "chamber", side
134 kinds = [branches[e]["kind"] for e in path]
135 assert "regulator" in kinds and kinds.count("tank_head") == 1, kinds
136 assert kinds[-1] == "injector", kinds
137 for here, there in zip(path, path[1:]):
138 assert branches[here]["to"] == branches[there]["from"], (here, there)
139 liquid = path[path.index(next(e for e in path if ".head" in e)) :]
140 assert all(branches[e]["side"] == side for e in liquid), side
141 for k in range(n):
142 rungs = sum(branches[e]["dp_psi"][k] for e in path)
143 ends = nodes[first]["p_psia"][k] - nodes[last]["p_psia"][k]
144 assert rungs == pytest.approx(ends, abs=1e-9), (side, k)
145 for column in (nodes[first]["p_psia"], branches[path[0]]["mdot"]):
146 assert len(column) == n
147
148
149def test_every_rung_is_its_own_component_at_the_recorded_flow(recorded) -> None: # type: ignore[no-untyped-def]
150 """The drop recorded across a line or valve is the component's own loss at
151 the flow recorded beside it, priced at the upstream node of the same solve.
152 Reading a vessel node's post-step state instead, or a flow the wrong way
153 round, fails here.
154
155 The recorded temperature is the step's enthalpy walk, which runs *after* its
156 solve: the solve priced gas at the previous walk's temperature. So samples
157 where a gas node is still cooling fast (the first 0.1 s after Fire, 10-25 K a
158 step) are left out; elsewhere the two agree to ~0.1 %."""
159 session, trace = recorded
160 net = session.model.built.network
161 rec = network_record(trace)
162 branches = rec["branches"]
163 checked = 0
164 firing = [k for k, f in enumerate(trace.firing) if f and trace.converged[k]]
165 assert firing
166 for side, path in rec["paths"].items():
167 for element in path:
168 info = branches[element]
169 if info["kind"] not in ("line", "solenoid", "valve"):
170 continue
171 component = net.branches[element].component
172 for k in firing[1:]:
173 temperatures = rec["nodes"][info["from"]]["T_K"]
174 if abs(temperatures[k] - temperatures[k - 1]) > 3.0:
175 continue
176 upstream = net.nodes[info["from"]]
177 # The upstream state as the step recorded it, and the valve
178 # positions the step solved with.
179 p_up = rec["nodes"][info["from"]]["p_psia"][k] * PSI
180 T_up = rec["nodes"][info["from"]]["T_K"][k]
181 signals = {}
182 if "state" in info:
183 signals[f"{component.id}.command"] = info["state"][k]
184 flow = conditions_from_fluid(
185 net.fluid(upstream.fluid),
186 p_up,
187 T_up,
188 signals,
189 multiphase=net.multiphase,
190 phase=upstream.phase,
191 gravity=net.gravity,
192 )
193 own = component.total_dp(info["mdot"][k], flow) / PSI
194 assert own == pytest.approx(info["dp_psi"][k], rel=2e-3, abs=0.01), (
195 element,
196 k,
197 )
198 checked += 1
199 assert checked > 20
200
201
202def test_tank_head_carries_the_outflow_and_is_a_gain(recorded) -> None: # type: ignore[no-untyped-def]
203 session, trace = recorded
204 rec = network_record(trace)
205 branches = rec["branches"]
206 for side, path in rec["paths"].items():
207 head = next(e for e in path if e.endswith(".head"))
208 line = path[path.index(head) + 1]
209 firing = [k for k, f in enumerate(trace.firing) if f]
210 for k in firing:
211 assert branches[head]["mdot"][k] == pytest.approx(
212 branches[line]["mdot"][k], rel=1e-9, abs=1e-12
213 )
214 assert branches[head]["dp_psi"][k] < 0.0 # the column adds pressure
215 tank = head[: -len(".head")]
216 assert rec["nodes"][tank]["kind"] == "tank"
217 assert rec["nodes"][tank]["side"] == "gas"
218 assert rec["nodes"][branches[head]["to"]]["side"] == side
219
220
221def test_metadata_reads_the_drawing(recorded) -> None: # type: ignore[no-untyped-def]
222 session, trace = recorded
223 rec = network_record(trace)
224 nodes, branches = rec["nodes"], rec["branches"]
225 regulator = next(b for b in branches.values() if b["kind"] == "regulator")
226 assert regulator["side"] == "gas" and regulator["cv"] > 0.0
227 solenoids = [b for b in branches.values() if b["kind"] == "solenoid"]
228 assert solenoids and all("state" in b and "cv" in b for b in solenoids)
229 assert all(0.0 <= s <= 1.0 for b in solenoids for s in b["state"])
230 assert {nodes[n]["side"] for n in session.tanks} == {"gas"}
231 assert {nodes[sim.outlet_node]["side"] for sim in session.tanks.values()} == {
232 "ox",
233 "fuel",
234 }
235 for node in nodes.values():
236 assert node["phase"] in ("gas", "liquid")
237 # psia, absolute: the bottle node sits near the 4500 psig it was primed to.
238 bottle = next(n for n in nodes.values() if n["kind"] == "bottle")
239 assert 4000.0 < bottle["p_psia"][0] < 4600.0
240 # The mains' opening is the command the step solved with: shut through the
241 # lead-in, open by the end of the burn -- not "no signal, so open".
242 for side, path in rec["paths"].items():
243 main = next(
244 e
245 for e in path[path.index(f"{e_tank(path)}.head") :]
246 if "state" in branches[e]
247 )
248 state = branches[main]["state"]
249 lead_in = [k for k, f in enumerate(trace.firing) if not f]
250 assert lead_in and all(state[k] == 0.0 for k in lead_in), (side, main)
251 assert state[-1] == 1.0, (side, main)
252
253
254def e_tank(path: list[str]) -> str:
255 """The tank a path's head element belongs to."""
256 head = next(e for e in path if e.endswith(".head"))
257 return head[: -len(".head")]
258
259
260def test_a_line_drawn_backwards_is_reported_along_the_feed(recorded) -> None: # type: ignore[no-untyped-def]
261 """The bottle's line drawn manifold-to-bottle: the network branch runs
262 against the feed, so the path must report it the feed's way round -- from
263 the bottle, positive flow, positive drop -- and flag it, with the same
264 numbers the line drawn the right way gives."""
265 _, flipped = _burn(True, flip=("l_kb",), **TIGHT)
266 _, plain = recorded
267 rec, ref = network_record(flipped), network_record(plain)
268 assert flipped.network is not None
269 assert flipped.network.branches["l_kb"].reversed
270 line, want = rec["branches"]["l_kb"], ref["branches"]["l_kb"]
271 assert line.get("reversed") is True and "reversed" not in want
272 assert (line["from"], line["to"]) == (want["from"], want["to"])
273 assert rec["nodes"][line["from"]]["kind"] == "bottle"
274 firing = [k for k, f in enumerate(flipped.firing) if f]
275 assert all(line["mdot"][k] > 0.0 for k in firing)
276 assert line["mdot"] == pytest.approx(want["mdot"], rel=1e-6, abs=1e-9)
277 # The solve itself differs in the last digits with the branch turned round
278 # (1.5e-5 psi on a 0.3 psi drop); the sign and size are what matter here.
279 assert line["dp_psi"] == pytest.approx(want["dp_psi"], rel=1e-3, abs=1e-4)
280 for side, path in rec["paths"].items():
281 assert path[0] == "l_kb", side
282 for here, there in zip(path, path[1:]):
283 assert rec["branches"][here]["to"] == rec["branches"][there]["from"]
None test_every_rung_is_its_own_component_at_the_recorded_flow(recorded)
None test_a_line_drawn_backwards_is_reported_along_the_feed(recorded)
str e_tank(list[str] path)
None test_metadata_reads_the_drawing(recorded)
None test_off_is_the_old_trace_and_on_changes_no_physics()
_session(tuple[str,...] flip=(), **setup)
None test_tank_head_carries_the_outflow_and_is_a_gain(recorded)
None test_run_burn_records_the_network_only_when_asked()
_burn(bool network, tuple[str,...] flip=(), **setup)
None test_each_path_runs_bottle_to_chamber_and_telescopes(recorded)