STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
network_trace.py
Go to the documentation of this file.
1"""Every node and branch of a burn, every step: the opt-in network recorder.
2
3A burn's ordinary trace (:class:`~feedtwin.session.burn.BurnTrace`) keeps its
4probes -- the tank ullages and outlets, the regulator outlets, the injector
5face, the chamber. That is what a summary needs and what the COPV study always
6read. A pressure *ladder* -- bottle to chamber, element by element -- needs all
7of it: every node's pressure and temperature, every branch's flow, its own drop
8and (valves) how far open it is, and the two paths a propellant's pressure
9travels. :class:`NetworkTrace` records that when
10:attr:`~feedtwin.session.burn.Probes.network` asks for it. It reads the
11:class:`~feedtwin.session.core.Sample` a step already produced and changes no
12physics; off (the default) the trace is exactly what it always was.
13
14Node values, not vessel states
15------------------------------
16Everything here is read off the sample: the pressures of the step's last network
17solve. A vessel node in there (the bottle, a tank ullage, a tank outlet) is the
18*boundary value that solve started from*, not the vessel's state after the step
19-- on the LE4 helium burn the bottle node read 1651.4 psia against the bottle
20state's 1645.6 at burnout (``EngineDesign/docs/layerx/AUDIT.md`` 5.5). The
21ladder is built from node values only, so its rungs telescope exactly to the
22first node minus the last of one solve. Mixing in the post-step vessel states
23(``BurnTrace.tank``, ``BurnTrace.bottle``) would put a step's worth of blowdown
24into one rung.
25
26What a rung's ``dp`` is
27-----------------------
28``p(from) - p(to)`` of the same solve: the element's own drop, positive
29downstream. Liquid nodes carry the lumped-K convention's pressures (total, not
30wall-static; see AUDIT.md 9.6 B4). A tank's liquid column is its own element,
31``<tank>.head``, from the ullage node to the outlet node: its ``dp`` is negative,
32a gain. A branch a path runs against its drawn direction is reported along the
33path (``from``/``to`` swapped, ``mdot`` and ``dp`` negated) so that "positive
34downstream" means downstream in the feed.
35"""
36
37from __future__ import annotations
38
39from collections import deque
40from dataclasses import dataclass, field
41from typing import TYPE_CHECKING, Any, Iterable, Mapping
42
43from feedtwin.session.gauge import PSI
44
45if TYPE_CHECKING: # pragma: no cover - typing only
46 from feedtwin.session.core import Sample, Session
47
48#: Species read as an oxidiser when nothing better says which side a tank is
49#: on (no engine attached, no colour on the drawing). Normalised: lower case,
50#: no spaces, dashes or underscores.
51OXIDISERS = frozenset(
52 {
53 "oxygen",
54 "o2",
55 "lox",
56 "nitrousoxide",
57 "n2o",
58 "hydrogenperoxide",
59 "h2o2",
60 "nitrogentetroxide",
61 "n2o4",
62 }
63)
64
65#: Drawing symbol type to the kind a ladder calls the element.
66SYMBOL_KINDS = {
67 "PR": "regulator",
68 "SOL": "solenoid",
69 "ROT": "valve",
70 "MOV": "valve",
71 "MAN": "valve",
72 "RV": "valve", # "relief" when it was built as one; see _branch_kind
73 "CV": "check",
74 "QD": "fitting",
75}
76
77#: Library component type to the kind, for lines and anything not a symbol.
78COMPONENT_KINDS = {
79 "pipe": "line",
80 "flex_hose": "line",
81 "fitting": "fitting",
82 "bend": "fitting",
83 "orifice": "orifice",
84 "valve": "valve",
85 "check_valve": "check",
86 "regulator": "regulator",
87 "relief_valve": "relief",
88}
89
90#: The tank head element's id suffix.
91HEAD = ".head"
92
93
94@dataclass(frozen=True, slots=True)
96 """What a node is, recorded once."""
97
98 id: str
99 label: str
100 kind: str
101 """``tank`` (an ullage), ``tank_outlet``, ``bottle``, ``chamber``,
102 ``injector_inlet``, ``ambient`` (any other fixed pressure: a vent),
103 ``manifold``, ``junction``, or ``port`` (one side of an inline symbol)."""
104 side: str | None
105 """``ox``, ``fuel`` or ``gas``; ``None`` for the chamber and anything the
106 drawing does not settle."""
107 phase: str
108 """``gas`` or ``liquid``, as the network prices it."""
109
110
111@dataclass(frozen=True, slots=True)
113 """What a branch (or a tank's head) is, recorded once."""
114
115 id: str
116 label: str
117 kind: str
118 """``line``, ``valve``, ``solenoid``, ``regulator``, ``orifice``,
119 ``fitting``, ``check``, ``relief``, ``injector`` or ``tank_head``."""
120 upstream: str
121 """Node the element is reported *from* (the contract's ``from``)."""
122 downstream: str
123 side: str | None
124 cv: float | None
125 """Flow coefficient at full open, where the component has one."""
126 valve: bool
127 """Whether :attr:`NetworkTrace.state` carries an open fraction for it."""
128 reversed: bool = False
129 """Reported against its drawn direction, because a path runs it that way."""
130
131
132@dataclass
134 """The whole network, sampled at every step of a burn. Absolute SI.
135
136 Built by :meth:`of` from a session, then fed each :class:`Sample` by
137 :meth:`append`. Columns are as long as the burn's ``t``.
138 """
139
140 nodes: dict[str, NodeInfo] = field(default_factory=dict)
141 branches: dict[str, BranchInfo] = field(default_factory=dict)
142 paths: dict[str, list[str]] = field(default_factory=dict)
143 """``ox`` / ``fuel`` to the element ids from a bottle to the chamber, in
144 order: gas leg, ``<tank>.head``, liquid leg, injector."""
145 pressure: dict[str, list[float]] = field(default_factory=dict)
146 """Node id to the solve's pressure [Pa] (boundary values for vessels)."""
147 temperature: dict[str, list[float]] = field(default_factory=dict)
148 """Node id to temperature [K], from the enthalpy walk."""
149 mdot: dict[str, list[float]] = field(default_factory=dict)
150 """Element id to mass flow [kg/s], positive ``upstream`` to ``downstream``."""
151 dp: dict[str, list[float]] = field(default_factory=dict)
152 """Element id to ``p(upstream) - p(downstream)`` [Pa], one solve."""
153 state: dict[str, list[float]] = field(default_factory=dict)
154 """Valve id to open fraction, 0 shut to 1 open."""
155 _flow_terms: dict[str, tuple[tuple[str, float], ...]] = field(
156 default_factory=dict, repr=False
157 )
158 _state_keys: dict[str, tuple[str, ...]] = field(default_factory=dict, repr=False)
159
160 # ------------------------------------------------------------ building
161
162 @classmethod
163 def of(cls, session: "Session") -> "NetworkTrace":
164 """The static description of ``session``'s network, columns empty."""
165 built = session.model.built
166 net = built.network
167 diagram = session.model.diagram
168 symbols = {n.id: n for n in diagram.nodes}
169 edges = {e.id for e in diagram.edges}
170
171 chamber = _chamber_node(session)
172 ports = built.engine_ports
173 injector_of: dict[str, str] = {}
174 inlets: dict[str, str] = {}
175 for side, key in (("ox", "oxidiser"), ("fuel", "fuel")):
176 branch_id = ports.get(key, "")
177 if branch_id in net.branches:
178 injector_of[branch_id] = side
179 inlets[net.branches[branch_id].upstream] = side
180
181 vessels = {sim.ullage_node for sim in session.tanks.values()}
182 vessels |= {sim.outlet_node for sim in session.tanks.values()}
183 vessels |= {b.node for b in session.bottles.values()}
184 fixed = {n for n, node in net.nodes.items() if node.pressure is not None}
185
186 def phase_of(node_id: str) -> str:
187 phase = net.nodes[node_id].phase
188 if phase in ("gas", "liquid"):
189 return str(phase)
190 return "gas" if node_id == chamber else "liquid"
191
192 # Edges, undirected, for the walks: (neighbour, branch id, forward).
193 adjacency: dict[str, list[tuple[str, str, bool]]] = {n: [] for n in net.nodes}
194 for branch_id, branch in net.branches.items():
195 adjacency[branch.upstream].append((branch.downstream, branch_id, True))
196 adjacency[branch.downstream].append((branch.upstream, branch_id, False))
197
198 # Which side each tank is on: the injector its liquid reaches, then the
199 # drawing's colour, then the species.
200 tank_side: dict[str, str] = {}
201 liquid_reach: dict[str, set[str]] = {}
202 for tank_id, sim in session.tanks.items():
203 reach = _walk(
204 sim.outlet_node,
205 adjacency,
206 lambda n: phase_of(n) == "liquid" and n not in fixed,
207 )
208 liquid_reach[tank_id] = reach
209 side = next((inlets[n] for n in reach if n in inlets), "")
210 if not side:
211 side = _side_from_drawing(symbols.get(tank_id))
212 if not side:
213 species = _normal(net.nodes[sim.outlet_node].fluid)
214 side = "ox" if species in OXIDISERS else "fuel"
215 tank_side[tank_id] = side
216
217 node_side: dict[str, str | None] = {}
218 for tank_id, reach in liquid_reach.items():
219 for node_id in reach:
220 node_side.setdefault(node_id, tank_side[tank_id])
221 for node_id in net.nodes:
222 if node_id in node_side:
223 continue
224 if node_id == chamber:
225 node_side[node_id] = None
226 elif node_id in inlets:
227 node_side[node_id] = inlets[node_id]
228 elif phase_of(node_id) == "gas":
229 node_side[node_id] = "gas"
230 else:
231 node_side[node_id] = None
232 # A liquid boundary off a propellant line (an inferred vent on a fill
233 # valve's free port) is on that line's side; the walk stops at it.
234 for node_id in net.nodes:
235 if node_side[node_id] is None and node_id in fixed and node_id != chamber:
236 near = [
237 node_side.get(far)
238 for far, _, _ in adjacency[node_id]
239 if node_side.get(far) in ("ox", "fuel")
240 ]
241 if near:
242 node_side[node_id] = near[0]
243
244 trace = cls()
245 bottles = {b.node for b in session.bottles.values()}
246 ullages = {sim.ullage_node: tank_id for tank_id, sim in session.tanks.items()}
247 outlets = {sim.outlet_node: tank_id for tank_id, sim in session.tanks.items()}
248 for node_id, node in net.nodes.items():
249 trace.nodes[node_id] = NodeInfo(
250 id=node_id,
251 label=_node_label(node_id, symbols, ullages, outlets, chamber, inlets),
252 kind=_node_kind(
253 node_id, symbols, bottles, ullages, outlets, chamber, inlets, fixed
254 ),
255 side=node_side.get(node_id),
256 phase=phase_of(node_id),
257 )
258
259 for branch_id, branch in net.branches.items():
260 component = branch.component
261 symbol = symbols.get(branch_id)
262 if branch_id in injector_of:
263 kind = "injector"
264 label = f"{injector_of[branch_id]} injector"
265 elif symbol is not None:
266 kind = _branch_kind(symbol.type, component)
267 label = symbol.label or branch_id
268 else:
269 kind = COMPONENT_KINDS.get(getattr(component, "type", ""), "line")
270 label = branch_id if branch_id in edges else str(component.id)
271 ends = (branch.upstream, branch.downstream)
272 liquid_sides = [
273 node_side[n] for n in ends if node_side.get(n) in ("ox", "fuel")
274 ]
275 if branch_id in injector_of:
276 branch_side: str | None = injector_of[branch_id]
277 elif liquid_sides:
278 branch_side = liquid_sides[0]
279 elif all(node_side.get(n) == "gas" for n in ends):
280 branch_side = "gas"
281 else:
282 branch_side = None
283 params = getattr(component, "p", {}) or {}
284 cv = params.get("Cv")
285 keys = _state_keys(component)
286 trace.branches[branch_id] = BranchInfo(
287 id=branch_id,
288 label=label,
289 kind=kind,
290 upstream=branch.upstream,
291 downstream=branch.downstream,
292 side=branch_side,
293 cv=float(cv) if cv is not None else None,
294 valve=bool(keys),
295 reversed=False,
296 )
297 trace._flow_terms[branch_id] = ((branch_id, 1.0),)
298 if keys:
299 trace._state_keys[branch_id] = keys
300
301 # A tank's liquid column, as its own element: ullage to outlet, carrying
302 # what leaves the outlet node.
303 for tank_id, sim in session.tanks.items():
304 head = f"{tank_id}{HEAD}"
305 label = symbols[tank_id].label if tank_id in symbols else tank_id
306 trace.branches[head] = BranchInfo(
307 id=head,
308 label=f"{label or tank_id} liquid head",
309 kind="tank_head",
310 upstream=sim.ullage_node,
311 downstream=sim.outlet_node,
312 side=tank_side[tank_id],
313 cv=None,
314 valve=False,
315 )
316 terms: list[tuple[str, float]] = []
317 for branch_id, branch in net.branches.items():
318 if branch.upstream == sim.outlet_node:
319 terms.append((branch_id, 1.0))
320 elif branch.downstream == sim.outlet_node:
321 terms.append((branch_id, -1.0))
322 trace._flow_terms[head] = tuple(terms)
323
324 # Bottle -> ... -> tank ullage -> head -> ... -> chamber, per side.
325 for tank_id, sim in session.tanks.items():
326 path_side = tank_side[tank_id]
327 if path_side in trace.paths:
328 continue # two tanks on one side: the first drawn is the path
329 gas = _shortest(
330 bottles,
331 sim.ullage_node,
332 adjacency,
333 lambda n: phase_of(n) == "gas" and n not in fixed,
334 )
335 liquid_leg = (
336 _shortest(
337 {sim.outlet_node},
338 chamber,
339 adjacency,
340 lambda n: n not in fixed and phase_of(n) == "liquid",
341 )
342 if chamber
343 else None
344 )
345 steps = (
346 list(gas or []) + [(f"{tank_id}{HEAD}", True)] + list(liquid_leg or [])
347 )
348 for branch_id, forward in steps:
349 if not forward and not trace.branches[branch_id].reversed:
350 trace._reverse(branch_id)
351 trace.paths[path_side] = [branch_id for branch_id, _ in steps]
352
353 for node_id in trace.nodes:
354 trace.pressure[node_id] = []
355 trace.temperature[node_id] = []
356 for branch_id, info in trace.branches.items():
357 trace.mdot[branch_id] = []
358 trace.dp[branch_id] = []
359 if info.valve:
360 trace.state[branch_id] = []
361 return trace
362
363 def _reverse(self, branch_id: str) -> None:
364 info = self.branches[branch_id]
365 self.branches[branch_id] = BranchInfo(
366 id=info.id,
367 label=info.label,
368 kind=info.kind,
369 upstream=info.downstream,
370 downstream=info.upstream,
371 side=info.side,
372 cv=info.cv,
373 valve=info.valve,
374 reversed=True,
375 )
376 self._flow_terms[branch_id] = tuple(
377 (term, -sign) for term, sign in self._flow_terms[branch_id]
378 )
379
380 # ------------------------------------------------------------ recording
381
382 def append(self, sample: "Sample") -> None:
383 """Record one step from the sample it produced."""
384 pressures = sample.pressures
385 temperatures = sample.temperatures
386 flows = sample.flows
387 signals = sample.signals
388 for node_id in self.nodes:
389 self.pressure[node_id].append(float(pressures.get(node_id, 0.0)))
390 self.temperature[node_id].append(float(temperatures.get(node_id, 0.0)))
391 for branch_id, info in self.branches.items():
392 self.mdot[branch_id].append(
393 float(
394 sum(
395 sign * flows.get(term, 0.0)
396 for term, sign in self._flow_terms[branch_id]
397 )
398 )
399 )
400 self.dp[branch_id].append(
401 float(
402 pressures.get(info.upstream, 0.0)
403 - pressures.get(info.downstream, 0.0)
404 )
405 )
406 keys = self._state_keys.get(branch_id)
407 if keys:
408 self.state[branch_id].append(_opening(signals, keys))
409
410 # -------------------------------------------------------------- export
411
412 def record(self, t: Iterable[float]) -> dict[str, Any]:
413 """The contract's ``result.network`` block: psia, psi, K, kg/s.
414
415 ``t`` is the burn's clock (``BurnTrace.t``), carried so the block stands
416 on its own.
417 """
418 nodes: dict[str, Any] = {}
419 for node_id, node in self.nodes.items():
420 nodes[node_id] = {
421 "label": node.label,
422 "kind": node.kind,
423 "side": node.side,
424 "phase": node.phase,
425 "p_psia": [p / PSI for p in self.pressure[node_id]],
426 "T_K": list(self.temperature[node_id]),
427 }
428 branches: dict[str, Any] = {}
429 for branch_id, info in self.branches.items():
430 entry: dict[str, Any] = {
431 "label": info.label,
432 "kind": info.kind,
433 "from": info.upstream,
434 "to": info.downstream,
435 "side": info.side,
436 "mdot": list(self.mdot[branch_id]),
437 "dp_psi": [d / PSI for d in self.dp[branch_id]],
438 }
439 if info.cv is not None:
440 entry["cv"] = info.cv
441 if info.valve:
442 entry["state"] = list(self.state[branch_id])
443 if info.reversed:
444 entry["reversed"] = True
445 branches[branch_id] = entry
446 return {
447 "t": list(t),
448 "nodes": nodes,
449 "branches": branches,
450 "paths": {side: list(path) for side, path in self.paths.items()},
451 "basis": (
452 "node values of each step's last network solve; a vessel node is "
453 "the boundary value that solve used, not the post-step vessel "
454 "state. dp = p(from) - p(to) of one solve, positive downstream; "
455 "<tank>.head is the liquid column (negative: a gain)."
456 ),
457 }
458
459
460# ---------------------------------------------------------------- helpers
461
462
463def _normal(name: object) -> str:
464 return "".join(c for c in str(name or "").lower() if c.isalnum())
465
466
467def _side_from_drawing(symbol: object) -> str:
468 """``ox`` / ``fuel`` from pid-designer's colour tag on a tank, or ''."""
469 tag = _normal(getattr(symbol, "fluid", ""))
470 if tag in ("lox", "ox", "oxidiser", "oxidizer") or tag in OXIDISERS:
471 return "ox"
472 if tag in ("fuel", "ethanol", "kerosene", "rp1", "ipa", "methanol"):
473 return "fuel"
474 return ""
475
476
477def _chamber_node(session: "Session") -> str:
478 """The chamber node with an engine attached, else the ENGINE symbol's node."""
479 built = session.model.built
480 net = built.network
481 found = built.engine_ports.get("chamber", "")
482 if found in net.nodes:
483 return found
484 for symbol in session.model.diagram.nodes:
485 if symbol.type in ("ENGINE", "INJECTOR"):
486 anchor = built.node_of.get(symbol.id, symbol.id)
487 if anchor in net.nodes:
488 return anchor
489 return ""
490
491
493 start: str,
494 adjacency: Mapping[str, list[tuple[str, str, bool]]],
495 passable: Any,
496) -> set[str]:
497 """Nodes reachable from ``start`` through nodes ``passable`` accepts.
498
499 ``start`` itself is always in; a node that is not passable is a wall and is
500 not entered.
501 """
502 seen = {start}
503 queue = deque([start])
504 while queue:
505 here = queue.popleft()
506 for far, _, _ in adjacency.get(here, ()):
507 if far in seen or not passable(far):
508 continue
509 seen.add(far)
510 queue.append(far)
511 return seen
512
513
515 starts: Iterable[str],
516 goal: str,
517 adjacency: Mapping[str, list[tuple[str, str, bool]]],
518 passable: Any,
519) -> list[tuple[str, bool]] | None:
520 """Fewest-branch path from any of ``starts`` to ``goal``, as
521 ``(branch id, forward)`` pairs; ``None`` if there is none.
522
523 Intermediate nodes must be ``passable``; the goal need not be (it is a
524 vessel or the chamber, both fixed pressures).
525 """
526 origin = list(starts)
527 if not origin or not goal:
528 return None
529 previous: dict[str, tuple[str, str, bool] | None] = {s: None for s in origin}
530 queue = deque(origin)
531 while queue:
532 here = queue.popleft()
533 if here == goal:
534 break
535 for far, branch_id, forward in adjacency.get(here, ()):
536 if far in previous:
537 continue
538 if far != goal and not passable(far):
539 continue
540 previous[far] = (here, branch_id, forward)
541 queue.append(far)
542 if goal not in previous:
543 return None
544 steps: list[tuple[str, bool]] = []
545 node = goal
546 while previous[node] is not None:
547 here, branch_id, forward = previous[node] # type: ignore[misc]
548 steps.append((branch_id, forward))
549 node = here
550 steps.reverse()
551 return steps
552
553
554def _branch_kind(symbol_type: str, component: object) -> str:
555 if getattr(component, "type", "") == "relief_valve":
556 return "relief"
557 return SYMBOL_KINDS.get(symbol_type, "valve")
558
559
560def _state_keys(component: object) -> tuple[str, ...]:
561 """The signal names a component's open fraction is read from, or ()."""
562 from feedtwin.comps.elements import Valve
563
564 ident = str(getattr(component, "id", ""))
565 if getattr(component, "type", "") == "relief_valve":
566 return (f"{ident}.lift", "lift")
567 if isinstance(component, Valve):
568 return (f"{ident}.command", "command")
569 return ()
570
571
572def _opening(signals: Mapping[str, float], keys: tuple[str, ...]) -> float:
573 """What the component itself reads: the qualified key, the bare one, else
574 fully open -- :meth:`HydraulicComponent.signal`'s own precedence."""
575 for key in keys:
576 if key in signals:
577 return float(min(max(signals[key], 0.0), 1.0))
578 return 1.0
579
580
582 node_id: str,
583 symbols: Mapping[str, Any],
584 ullages: Mapping[str, str],
585 outlets: Mapping[str, str],
586 chamber: str,
587 inlets: Mapping[str, str],
588) -> str:
589 def named(symbol_id: str) -> str:
590 symbol = symbols.get(symbol_id)
591 return str(getattr(symbol, "label", "") or symbol_id)
592
593 if node_id == chamber:
594 return "Chamber"
595 if node_id in inlets:
596 return f"Injector inlet ({inlets[node_id]})"
597 if node_id in ullages:
598 return f"{named(ullages[node_id])} ullage"
599 if node_id in outlets:
600 return f"{named(outlets[node_id])} outlet"
601 if node_id in symbols:
602 return named(node_id)
603 for suffix, word in ((".in", "inlet"), (".out", "outlet")):
604 if node_id.endswith(suffix) and node_id[: -len(suffix)] in symbols:
605 return f"{named(node_id[: -len(suffix)])} {word}"
606 return node_id
607
608
610 node_id: str,
611 symbols: Mapping[str, Any],
612 bottles: set[str],
613 ullages: Mapping[str, str],
614 outlets: Mapping[str, str],
615 chamber: str,
616 inlets: Mapping[str, str],
617 fixed: set[str],
618) -> str:
619 if node_id in bottles:
620 return "bottle"
621 if node_id in ullages:
622 return "tank"
623 if node_id in outlets:
624 return "tank_outlet"
625 if node_id == chamber:
626 return "chamber"
627 if node_id in inlets:
628 return "injector_inlet"
629 if node_id in fixed:
630 return "ambient"
631 symbol = symbols.get(node_id)
632 if symbol is not None:
633 return "manifold" if symbol.type == "MANIFOLD" else "junction"
634 for suffix in (".in", ".out"):
635 if node_id.endswith(suffix) and node_id[: -len(suffix)] in symbols:
636 return "port"
637 return "junction"
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
dict[str, Any] record(self, Iterable[float] t)
"NetworkTrace" of(cls, "Session" session)
str _branch_kind(str symbol_type, object component)
str _side_from_drawing(object symbol)
list[tuple[str, bool]]|None _shortest(Iterable[str] starts, str goal, Mapping[str, list[tuple[str, str, bool]]] adjacency, Any passable)
float _opening(Mapping[str, float] signals, tuple[str,...] keys)
tuple[str,...] _state_keys(object component)
str _chamber_node("Session" session)
set[str] _walk(str start, Mapping[str, list[tuple[str, str, bool]]] adjacency, Any passable)
str _node_label(str node_id, Mapping[str, Any] symbols, Mapping[str, str] ullages, Mapping[str, str] outlets, str chamber, Mapping[str, str] inlets)
str _node_kind(str node_id, Mapping[str, Any] symbols, set[str] bottles, Mapping[str, str] ullages, Mapping[str, str] outlets, str chamber, Mapping[str, str] inlets, set[str] fixed)