1"""The pressure ladder: bottle to chamber, element by element, per side, at every twin step.
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.
10Conventions (AUDIT 9.6 B1-B4):
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).
21The helpers at the top (``PSI``, ``arr``, ``out``, ``unavailable``, ``model_block``, ``inp``) are
22shared by the other ``engine.layerx.diag`` modules of this set.
25from __future__
import annotations
28from typing
import Any, Dict, Iterable, List, Mapping, Optional, Sequence
32PSI = 6894.757293168361
36"""Network side keys (DATA-CONTRACT 2), which are also ``series`` keys."""
38SIDE_ROLE = {
"ox":
"oxidiser",
"fuel":
"fuel"}
39"""Network side -> ``prep.roles`` / ``derived.species`` key."""
41SIDE_CONFIG = {
"ox":
"oxidizer",
"fuel":
"fuel"}
42"""Network side -> EngineDesign config key (``fluids``, ``discharge``, ``injector.geometry``)."""
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."""
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."""
54 return np.full(n
or 0, np.nan)
58 out.append(float(v)
if v
is not None else math.nan)
59 except (TypeError, ValueError):
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}")
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]] = []
72 if not math.isfinite(f):
75 res.append(round(f, digits)
if digits
is not None else f)
79def scalar(v: Any) -> Optional[float]:
80 """A JSON-safe float or None."""
83 except (TypeError, ValueError):
85 return f
if math.isfinite(f)
else None
89 """DATA-CONTRACT: a block that could not be computed. It never raises."""
90 return {
"available":
False,
"error": str(error)}
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):
97 return {
"value": value,
"unit": unit,
"provenance": provenance}
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)}
106def series_t(result: Mapping[str, Any]) -> np.ndarray:
107 return arr((result.get(
"series")
or {}).get(
"t"))
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)
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)")
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")
130def node_p(net: Mapping[str, Any], node: str, n: int) -> np.ndarray:
131 nd = net[
"nodes"].get(node)
133 raise ValueError(f
"node {node!r} is not in the recorded network")
134 return arr(nd.get(
"p_psia"), n)
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)
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)
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}
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 [])
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
175 br = branches.get(bid)
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:
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))
189 kind = str(br.get(
"kind")
or "branch")
190 label = str(br.get(
"label")
or bid)
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
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)
211 elements.append(
_element(bid, label, kind, dp, frm, to))
216def build_ladder(result: Mapping[str, Any], prep: Any =
None, config: Any =
None) -> Dict[str, Any]:
217 """``diagnostics.ladder`` from ``result["network"]``. Never raises.
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.
229 series = result.get(
"series")
or {}
230 block: Dict[str, Any] = {
"t":
out(t)}
232 if not (net.get(
"paths")
or {}).get(side):
235 start = els[0][
"from"]
238 total_sum = np.zeros(n)
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
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),
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.")
267 except Exception
as exc:
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)
Optional[float] scalar(Any v)
np.ndarray node_p(Mapping[str, Any] net, str node, int n)
Dict[str, Any] build_ladder(Mapping[str, Any] result, Any prep=None, Any config=None)
np.ndarray branch_dp(Mapping[str, Any] net, str bid, int n)
np.ndarray node_T(Mapping[str, Any] net, str node, int n)
Dict[str, Any] unavailable(str error)
List[Dict[str, Any]] element_series(Mapping[str, Any] ladder, str side, str kind)
Dict[str, Any] _element(str eid, str label, str kind, np.ndarray dp, str frm, str to)
List[Optional[float]] out(Iterable[float] values, Optional[int] digits=None)
np.ndarray series_t(Mapping[str, Any] result)
np.ndarray arr(Optional[Iterable[Any]] values, Optional[int] n=None)
np.ndarray firing_mask(Mapping[str, Any] result, int n)
Dict[str, Any] model_block(str name, str source, Sequence[str] assumptions, Mapping[str, Dict[str, Any]] inputs)
List[Dict[str, Any]] side_elements(Mapping[str, Any] net, str side, int n, Optional[Mapping[str, Any]] series_side=None)
Mapping[str, Any] network_of(Mapping[str, Any] result)