STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ladder.py
Go to the documentation of this file.
1"""The pressure ladder: bottle to chamber, element by element, per side, at every twin step.
2
3``build_ladder(result, prep=None, config=None)`` reads ``result["network"]`` (DATA-CONTRACT 2) and
4returns ``diagnostics.ladder`` (DATA-CONTRACT 3). It is bookkeeping, not a model: every drop is a
5difference of the twin's own node pressures, so the elements of one side telescope to the
6end-to-end drop by construction. The sum check is still made, on the branches' recorded
7``dp_psi`` against the end nodes, because a recorder that writes a component's own loss (static
8head left out, say) would break that identity and the ladder should say so rather than hide it.
9
10Conventions (AUDIT 9.6 B1-B4):
11
12* Node pressures only. ``series.copv_psia`` and ``series.*.tank_psia`` are post-step vessel
13 states; the network's nodes are the solve's boundaries (1645.6 against 1651.4 psia at burnout
14 on the bottle). Mixing the two in one ladder leaves a residual that is not a loss (AUDIT 5.5).
15* Liquid-line nodes carry **total** pressure (lumped K, Borda dump at the manifold), so a
16 wall-mounted PT reads about one dynamic head lower than the node.
17* The tank is its own element: ullage node -> outlet node is the liquid head (a gain, negative).
18* The engine card's injector branch (line exit -> chamber) is split, where the series carry the
19 split, into the Borda dump at the manifold entrance and the orifice drop (manifold -> chamber).
20
21The helpers at the top (``PSI``, ``arr``, ``out``, ``unavailable``, ``model_block``, ``inp``) are
22shared by the other ``engine.layerx.diag`` modules of this set.
23"""
24
25from __future__ import annotations
26
27import math
28from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
29
30import numpy as np
31
32PSI = 6894.757293168361
33"""Pa per psi."""
34
35SIDES = ("ox", "fuel")
36"""Network side keys (DATA-CONTRACT 2), which are also ``series`` keys."""
37
38SIDE_ROLE = {"ox": "oxidiser", "fuel": "fuel"}
39"""Network side -> ``prep.roles`` / ``derived.species`` key."""
40
41SIDE_CONFIG = {"ox": "oxidizer", "fuel": "fuel"}
42"""Network side -> EngineDesign config key (``fluids``, ``discharge``, ``injector.geometry``)."""
43
44SUM_TOLERANCE_PSI = 1e-6
45"""Largest |sum of element drops - end-to-end drop| the ladder accepts as closing [psi]. The drops
46are differences of the same node values, so anything above float noise is a recorder fault."""
47
48
49# ---- shared helpers -----------------------------------------------------------------------------
50
51def arr(values: Optional[Iterable[Any]], n: Optional[int] = None) -> np.ndarray:
52 """A float array with NaN where the input has None (JSON null) or a non-number."""
53 if values is None:
54 return np.full(n or 0, np.nan)
55 out = []
56 for v in values:
57 try:
58 out.append(float(v) if v is not None else math.nan)
59 except (TypeError, ValueError):
60 out.append(math.nan)
61 a = np.asarray(out, dtype=float)
62 if n is not None and a.size != n:
63 raise ValueError(f"series of length {a.size}, expected {n}")
64 return a
65
66
67def out(values: Iterable[float], digits: Optional[int] = None) -> List[Optional[float]]:
68 """A JSON-safe list: non-finite -> None."""
69 res: List[Optional[float]] = []
70 for v in values:
71 f = float(v)
72 if not math.isfinite(f):
73 res.append(None)
74 else:
75 res.append(round(f, digits) if digits is not None else f)
76 return res
77
78
79def scalar(v: Any) -> Optional[float]:
80 """A JSON-safe float or None."""
81 try:
82 f = float(v)
83 except (TypeError, ValueError):
84 return None
85 return f if math.isfinite(f) else None
86
87
88def unavailable(error: str) -> Dict[str, Any]:
89 """DATA-CONTRACT: a block that could not be computed. It never raises."""
90 return {"available": False, "error": str(error)}
91
92
93def inp(value: Any, unit: str, provenance: str) -> Dict[str, Any]:
94 """One entry of a model block's ``inputs``."""
95 if isinstance(value, (int, float, np.floating)) and not isinstance(value, bool):
96 value = scalar(value)
97 return {"value": value, "unit": unit, "provenance": provenance}
98
99
100def model_block(name: str, source: str, assumptions: Sequence[str],
101 inputs: Mapping[str, Dict[str, Any]]) -> Dict[str, Any]:
102 """DATA-CONTRACT ``model``: what produced a block, so the run record can list it."""
103 return {"name": name, "source": source, "assumptions": list(assumptions), "inputs": dict(inputs)}
104
105
106def series_t(result: Mapping[str, Any]) -> np.ndarray:
107 return arr((result.get("series") or {}).get("t"))
108
109
110def firing_mask(result: Mapping[str, Any], n: int) -> np.ndarray:
111 """``series.firing`` as booleans (all False when absent)."""
112 f = (result.get("series") or {}).get("firing")
113 if f is None or len(f) != n:
114 return np.zeros(n, dtype=bool)
115 return np.asarray([bool(x) for x in f], dtype=bool)
116
117
118def network_of(result: Mapping[str, Any]) -> Mapping[str, Any]:
119 """``result["network"]`` checked against ``series.t``; raises ValueError with the reason."""
120 net = result.get("network")
121 if not isinstance(net, Mapping) or not net.get("nodes") or not net.get("branches"):
122 raise ValueError("no feed network was recorded for this run (result.network)")
123 t = net.get("t")
124 st = (result.get("series") or {}).get("t")
125 if t is None or st is None or len(t) != len(st):
126 raise ValueError("result.network.t does not follow series.t")
127 return net
128
129
130def node_p(net: Mapping[str, Any], node: str, n: int) -> np.ndarray:
131 nd = net["nodes"].get(node)
132 if nd is None:
133 raise ValueError(f"node {node!r} is not in the recorded network")
134 return arr(nd.get("p_psia"), n)
135
136
137def node_T(net: Mapping[str, Any], node: str, n: int) -> np.ndarray:
138 """A node's temperature [K]; NaN where it is missing or not positive (the recorder writes 0.0
139 for a node the step's enthalpy walk did not reach)."""
140 nd = net["nodes"].get(node)
141 if not nd or nd.get("T_K") is None:
142 return np.full(n, np.nan)
143 T = arr(nd.get("T_K"), n)
144 return np.where(T > 0.0, T, np.nan)
145
146
147def branch_dp(net: Mapping[str, Any], bid: str, n: int) -> np.ndarray:
148 """A branch's own drop [psi]: the recorded ``dp_psi``, else from/to node pressures."""
149 br = net["branches"][bid]
150 if br.get("dp_psi") is not None:
151 return arr(br["dp_psi"], n)
152 return node_p(net, br["from"], n) - node_p(net, br["to"], n)
153
154
155# ---- the ladder ---------------------------------------------------------------------------------
156
157def _element(eid: str, label: str, kind: str, dp: np.ndarray, frm: str, to: str) -> Dict[str, Any]:
158 return {"id": eid, "label": label, "kind": kind, "from": frm, "to": to, "dp": dp}
159
160
161def side_elements(net: Mapping[str, Any], side: str, n: int,
162 series_side: Optional[Mapping[str, Any]] = None) -> List[Dict[str, Any]]:
163 """The elements along ``net["paths"][side]``, with every gap between consecutive branches
164 filled by a vessel element (a tank's liquid head) and the injector branch split into dump and
165 orifices when ``series_side`` carries ``dump_psi`` and ``dp_injector_psi``. The orifice
166 element's ``check_psi`` is the largest gap between its drop and ``series.*.dp_injector_psi``
167 on the steps the series carry one (0 on the card)."""
168 path = list((net.get("paths") or {}).get(side) or [])
169 if not path:
170 raise ValueError(f"no {side} path in result.network.paths")
171 nodes, branches = net["nodes"], net["branches"]
172 elements: List[Dict[str, Any]] = []
173 prev_to: Optional[str] = None
174 for bid in path:
175 br = branches.get(bid)
176 if br is None:
177 raise ValueError(f"path {side} names branch {bid!r}, which is not recorded")
178 frm, to = br["from"], br["to"]
179 if prev_to is not None and frm != prev_to:
180 # A vessel between two branches: the gas enters the ullage node, the liquid leaves
181 # from the outlet node. Its "drop" is the head of liquid (negative: a gain).
182 up, dn = nodes.get(prev_to, {}), nodes.get(frm, {})
183 kind = "tank_head" if up.get("kind") == "tank" or dn.get("kind") == "tank_outlet" else "vessel"
184 label = f"{up.get('label') or prev_to} head" if kind == "tank_head" else \
185 f"{up.get('label') or prev_to} -> {dn.get('label') or frm}"
186 elements.append(_element(f"{prev_to}->{frm}", label, kind,
187 node_p(net, prev_to, n) - node_p(net, frm, n), prev_to, frm))
188 dp = branch_dp(net, bid, n)
189 kind = str(br.get("kind") or "branch")
190 label = str(br.get("label") or bid)
191 split = None
192 if kind == "injector" and series_side is not None:
193 dump, orf = series_side.get("dump_psi"), series_side.get("dp_injector_psi")
194 if dump is not None and orf is not None and len(dump) == n and len(orf) == n:
195 split = (arr(dump, n), arr(orf, n))
196 if split is not None:
197 dump, orf_series = split
198 # Everything after the dump is the orifices' (manifold node -> chamber node), so the side
199 # still telescopes on steps where the series carry no split (before Fire the chamber
200 # node sits at ambient and the series hold zeros). Where the series do carry it, the
201 # two must agree: the gap is kept as ``check_psi`` on the element.
202 orf = dp - dump
203 gap = np.where(np.abs(orf_series) > 0.0, orf - orf_series, np.nan)
204 elements.append(_element(f"{bid}:dump", f"{label} manifold dump (Borda exit)", "dump",
205 dump, frm, f"{bid}:manifold"))
206 orifice = _element(f"{bid}:orifice", f"{label} orifices (manifold -> chamber)",
207 "orifice", orf, f"{bid}:manifold", to)
208 orifice["check_psi"] = scalar(np.nanmax(np.abs(gap))) if np.isfinite(gap).any() else None
209 elements.append(orifice)
210 else:
211 elements.append(_element(bid, label, kind, dp, frm, to))
212 prev_to = to
213 return elements
214
215
216def build_ladder(result: Mapping[str, Any], prep: Any = None, config: Any = None) -> Dict[str, Any]:
217 """``diagnostics.ladder`` from ``result["network"]``. Never raises.
218
219 Per side: ``elements`` [{id, label, kind, from, to, dp_psi, share}], ``total_psi`` (first node of
220 the path minus its last), ``sum_psi`` (the elements added up), ``residual_psi`` and
221 ``max_abs_residual_psi`` (sum minus total) and ``closes`` (the residual within
222 ``SUM_TOLERANCE_PSI``). ``share`` is each drop over ``total_psi`` (null where the total is 0).
223 ``prep`` and ``config`` are not needed; they are accepted for a uniform signature.
224 """
225 try:
226 net = network_of(result)
227 t = series_t(result)
228 n = t.size
229 series = result.get("series") or {}
230 block: Dict[str, Any] = {"t": out(t)}
231 for side in SIDES:
232 if not (net.get("paths") or {}).get(side):
233 continue
234 els = side_elements(net, side, n, series.get(side))
235 start = els[0]["from"]
236 end = els[-1]["to"]
237 total = node_p(net, start, n) - node_p(net, end, n)
238 total_sum = np.zeros(n)
239 for e in els:
240 total_sum = total_sum + e["dp"]
241 residual = total_sum - total
242 with np.errstate(divide="ignore", invalid="ignore"):
243 shares = [np.where(np.abs(total) > 1e-9, e["dp"] / total, np.nan) for e in els]
244 worst = float(np.nanmax(np.abs(residual))) if np.isfinite(residual).any() else math.nan
245 block[side] = {
246 "start": start,
247 "end": end,
248 "elements": [{"id": e["id"], "label": e["label"], "kind": e["kind"], "from": e["from"],
249 "to": e["to"], "dp_psi": out(e["dp"]), "share": out(s),
250 **({"check_psi": e["check_psi"]} if "check_psi" in e else {})}
251 for e, s in zip(els, shares)],
252 "total_psi": out(total),
253 "sum_psi": out(total_sum),
254 "residual_psi": out(residual),
255 "max_abs_residual_psi": scalar(worst),
256 "closes": bool(math.isfinite(worst) and worst <= SUM_TOLERANCE_PSI),
257 }
258 if not any(s in block for s in SIDES):
259 return unavailable("result.network.paths has neither an ox nor a fuel path")
260 block["basis"] = ("Differences of the twin's node pressures along each side's path (node values only, "
261 "never vessel states). Liquid-line nodes are total pressure (lumped K; the "
262 "Borda dump at the manifold carries the velocity head). The tank's liquid head is "
263 "its own element (ullage node -> outlet node; negative = gain). The injector "
264 "branch is split into the dump and the orifices from series.*.dump_psi and "
265 "series.*.dp_injector_psi.")
266 return block
267 except Exception as exc: # noqa: BLE001 - a diagnostic never takes the burn down
268 return unavailable(f"{type(exc).__name__}: {exc}")
269
270
271def element_series(ladder: Mapping[str, Any], side: str, kind: str) -> List[Dict[str, Any]]:
272 """The ladder elements of one ``kind`` on one side (for the regulator/solenoid blocks)."""
273 return [e for e in (ladder.get(side) or {}).get("elements", []) if e.get("kind") == kind]
Dict[str, Any] inp(Any value, str unit, str provenance)
Definition ladder.py:93
Optional[float] scalar(Any v)
Definition ladder.py:79
np.ndarray node_p(Mapping[str, Any] net, str node, int n)
Definition ladder.py:130
Dict[str, Any] build_ladder(Mapping[str, Any] result, Any prep=None, Any config=None)
Definition ladder.py:216
np.ndarray branch_dp(Mapping[str, Any] net, str bid, int n)
Definition ladder.py:147
np.ndarray node_T(Mapping[str, Any] net, str node, int n)
Definition ladder.py:137
Dict[str, Any] unavailable(str error)
Definition ladder.py:88
List[Dict[str, Any]] element_series(Mapping[str, Any] ladder, str side, str kind)
Definition ladder.py:271
Dict[str, Any] _element(str eid, str label, str kind, np.ndarray dp, str frm, str to)
Definition ladder.py:157
List[Optional[float]] out(Iterable[float] values, Optional[int] digits=None)
Definition ladder.py:67
np.ndarray series_t(Mapping[str, Any] result)
Definition ladder.py:106
np.ndarray arr(Optional[Iterable[Any]] values, Optional[int] n=None)
Definition ladder.py:51
np.ndarray firing_mask(Mapping[str, Any] result, int n)
Definition ladder.py:110
Dict[str, Any] model_block(str name, str source, Sequence[str] assumptions, Mapping[str, Dict[str, Any]] inputs)
Definition ladder.py:101
List[Dict[str, Any]] side_elements(Mapping[str, Any] net, str side, int n, Optional[Mapping[str, Any]] series_side=None)
Definition ladder.py:162
Mapping[str, Any] network_of(Mapping[str, Any] result)
Definition ladder.py:118