1"""The opt-in full-network recorder (``Probes(network=True)``).
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.
12from __future__
import annotations
15from dataclasses
import fields, replace
16from pathlib
import Path
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"
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",
47 tank_psi=550.0, loads={
"OXT": 6.0,
"FUT": 4.0}, settle=
False, lead_in_s=0.1,
52def _session(flip: tuple[str, ...] = (), **setup):
53 """A session on the stand; ``flip`` names lines to draw the other way round."""
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)
67 return open_session(model, load_machine(tables=TABLES), setup=burn_setup(**setup))
70def _burn(network: bool, flip: tuple[str, ...] = (), **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))
81 "network_tolerance": 1.0e-7,
82 "chamber_tolerance_psi": 0.02,
86@pytest.fixture(scope="module")
88 return _burn(
True, **TIGHT)
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"):
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) == (
106 off.end.failed_steps,
112 tank_psi=550.0, loads={
"OXT": 6.0,
"FUT": 4.0}, settle=
False, lead_in_s=0.05,
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
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
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
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
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
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"]
164 firing = [k
for k, f
in enumerate(trace.firing)
if f
and trace.converged[k]]
166 for side, path
in rec[
"paths"].items():
168 info = branches[element]
169 if info[
"kind"]
not in (
"line",
"solenoid",
"valve"):
171 component = net.branches[element].component
173 temperatures = rec[
"nodes"][info[
"from"]][
"T_K"]
174 if abs(temperatures[k] - temperatures[k - 1]) > 3.0:
176 upstream = net.nodes[info[
"from"]]
179 p_up = rec[
"nodes"][info[
"from"]][
"p_psia"][k] * PSI
180 T_up = rec[
"nodes"][info[
"from"]][
"T_K"][k]
183 signals[f
"{component.id}.command"] = info[
"state"][k]
184 flow = conditions_from_fluid(
185 net.fluid(upstream.fluid),
189 multiphase=net.multiphase,
190 phase=upstream.phase,
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), (
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]
211 assert branches[head][
"mdot"][k] == pytest.approx(
212 branches[line][
"mdot"][k], rel=1e-9, abs=1e-12
214 assert branches[head][
"dp_psi"][k] < 0.0
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
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()} == {
235 for node
in nodes.values():
236 assert node[
"phase"]
in (
"gas",
"liquid")
238 bottle = next(n
for n
in nodes.values()
if n[
"kind"] ==
"bottle")
239 assert 4000.0 < bottle[
"p_psia"][0] < 4600.0
242 for side, path
in rec[
"paths"].items():
245 for e
in path[path.index(f
"{e_tank(path)}.head") :]
246 if "state" in branches[e]
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)
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")]
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)
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)
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)