STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
vv.py
Go to the documentation of this file.
1"""Verification checks on a finished Layer X burn: does it conserve what it must? (DATA-CONTRACT 3, ``vv``)
2
3A burn that converges, plots smoothly and violates no assertion is not evidence (CLAUDE.md). These
4checks re-derive three balances from the recorded result, by a path independent of the twin's own
5bookkeeping where one exists, and report how far each fails to close:
6
7* **Propellant mass**, per side: what was loaded = what the chamber burned + what is left in the
8 tank + what the drawing's feed lines hold. ``burned`` integrates the chamber's flow (or, with the
9 full network recorded, the injector branches') step by step; ``loaded`` and ``residual`` are the
10 tank's own inventory. The twin's network is algebraic (no liquid inventory in a line), so the
11 trapped mass is the same at T-0 and at burnout: it enters both sides, and the error is the tank
12 inventory against the integrated flow. It is reported because a stand fills its lines too.
13* **Pressurant mass**: gas out of the bottle = gas gained by the ullages + gas vented. The bottle
14 figure is the twin's vessel state; each ullage is **re-priced with CoolProp** from the recorded
15 pressure, gas temperature and fill (``rho(p, T) * V_tank * (1 - fill)``), so the check does not
16 read the twin's ullage mass back. Vented gas needs the full network recorder (``result.network``);
17 without it the vents are taken as shut, which the burn plan does.
18* **Gas-side energy** (first law over the pressurant: bottle, lines, ullages), reported, never
19 graded: see :func:`energy_balance` for what it closes and what it cannot.
20* **Convergence** of the outer loops, per pass, from ``result.passes``.
21
22Every relative error is in percent of the quantity that entered the balance (the energy residual: of
23the expulsion work, see :func:`energy_balance`). The thresholds that grade them live with every other
24limit, in :data:`engine.layerx.diag.limits.THRESHOLDS`.
25
26Sources: mass conservation for a control volume and the first law for an open system,
27dU/dt = sum(m_in h_in) - sum(m_out h_out) + Q - p dV/dt (Moran & Shapiro, Fundamentals of Engineering
28Thermodynamics, ch. 4, the control-volume energy rate balance). Properties: CoolProp
29(Bell et al., Ind. Eng. Chem. Res. 53(6), 2014).
30"""
31
32from __future__ import annotations
33
34import math
35from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
36
37PSI = 6894.757293168361
38
39#: Display names of the twin's species to CoolProp's.
40COOLPROP = {"oxygen": "Oxygen", "ethanol": "Ethanol", "nitrogen": "Nitrogen", "helium": "Helium",
41 "water": "Water", "lox": "Oxygen", "ln2": "Nitrogen", "gn2": "Nitrogen", "he": "Helium"}
42
43
44def _num(v: Any) -> Optional[float]:
45 if v is None or isinstance(v, bool):
46 return None
47 try:
48 f = float(v)
49 except (TypeError, ValueError):
50 return None
51 return f if math.isfinite(f) else None
52
53
54def _dig(d: Any, path: str) -> Any:
55 for part in path.split("."):
56 if not isinstance(d, Mapping):
57 return None
58 d = d.get(part)
59 return d
60
61
62def _pct(err: Optional[float], ref: Optional[float]) -> Optional[float]:
63 if err is None or ref is None or ref == 0.0:
64 return None
65 return 100.0 * err / ref
66
67
68def _species(name: Optional[str]) -> Optional[str]:
69 if not name:
70 return None
71 return COOLPROP.get(str(name).lower(), str(name))
72
73
74def _steps(series: Mapping[str, Any]) -> List[float]:
75 t = series.get("t") or []
76 dt = series.get("dt")
77 if isinstance(dt, list) and len(dt) == len(t):
78 return [float(v) for v in dt]
79 # Older runs carry no per-step length: the gap to the previous sample (the first: the next gap).
80 out = [t[i] - t[i - 1] for i in range(1, len(t))]
81 return ([out[0]] if out else []) + out
82
83
84# ---------------------------------------------------------------------- the drawing's feed lines
85
86
87def feed_lines(document: Mapping[str, Any], tank_id: str) -> List[Dict[str, Any]]:
88 """The drawing's lines from ``tank_id`` to the engine: the walk the propellant takes, edge by
89 edge, while the next symbol carries the tank's fluid (a fill or vent line, which turns to the
90 pressurant, is not followed). Each line as ``{id, length_m, bore_m, volume_m3, provenance}``;
91 a line missing a length or bore is returned with ``volume_m3`` None."""
92 from feedtwin.model.param import to_si
93
94 nodes = {n.get("id"): n for n in (document.get("nodes") or []) if isinstance(n, Mapping)}
95 edges = [e for e in (document.get("edges") or []) if isinstance(e, Mapping)]
96 fluid = _dig(nodes.get(tank_id), "data.fluid")
97 if fluid is None:
98 return []
99
100 def is_engine(node_id: str) -> bool:
101 return str(_dig(nodes.get(node_id), "data.componentType") or "").upper() == "ENGINE"
102
103 out: List[Dict[str, Any]] = []
104 seen = {tank_id}
105 frontier = [tank_id]
106 while frontier:
107 here = frontier.pop()
108 for e in edges:
109 if e.get("source") != here or e.get("target") in seen:
110 continue
111 target = e.get("target")
112 if not (is_engine(target) or _dig(nodes.get(target), "data.fluid") == fluid):
113 continue
114 params = _dig(e, "data.params") or {}
115
116 def si(key: str) -> Tuple[Optional[float], Optional[str]]:
117 p = params.get(key)
118 if not isinstance(p, Mapping) or _num(p.get("value")) is None:
119 return None, None
120 try:
121 return float(to_si(float(p["value"]), str(p.get("unit") or ""))), str(p.get("source") or "")
122 except Exception: # noqa: BLE001 - an unknown unit is a line without a volume, said so
123 return None, None
124
125 length, l_src = si("length")
126 bore, b_src = si("bore")
127 vol = math.pi * bore * bore / 4.0 * length if length is not None and bore is not None else None
128 out.append({"id": e.get("id"), "from": here, "to": target, "length_m": length, "bore_m": bore,
129 "volume_m3": vol, "provenance": {"length": l_src, "bore": b_src}})
130 if not is_engine(target):
131 seen.add(target)
132 frontier.append(target)
133 return out
134
135
136def _document(result: Mapping[str, Any], prep: Any, document: Optional[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]:
137 if document is not None:
138 return document
139 payload = getattr(getattr(prep, "drawing", None), "payload", None)
140 if isinstance(payload, Mapping):
141 return payload
142 did = _dig(result, "provenance.drawing.id")
143 if not did:
144 return None
145 try:
146 from engine.layerx.sources import DrawingStore
147
148 d = DrawingStore(None).get(str(did))
149 return d.payload if d is not None else None
150 except Exception: # noqa: BLE001 - a run whose drawing is gone checks without its lines
151 return None
152
153
154# ---------------------------------------------------------------------- propellant
155
156
157def mass_balance(result: Mapping[str, Any], prep: Any = None,
158 document: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
159 """Per side: ``{loaded_kg, burned_kg, residual_kg, trapped_kg, error_pct, basis, lines}``.
160
161 ``loaded_kg`` is the tank at T-0 plus the feed lines' liquid; ``error_pct`` is
162 ``(loaded - burned - residual - trapped) / loaded``.
163 """
164 series = result.get("series") or {}
165 summary = result.get("summary") or {}
166 derived = _dig(result, "provenance.derived") or getattr(prep, "derived", None) or {}
167 roles = derived.get("roles") or {}
168 species = derived.get("species") or {}
169 firing = series.get("firing") or []
170 steps = _steps(series)
171 doc = _document(result, prep, document)
172 net = result.get("network") if isinstance(result.get("network"), Mapping) else None
173 out: Dict[str, Any] = {}
174 for side, short in (("oxidiser", "ox"), ("fuel", "fuel")):
175 s = summary.get(short) or {}
176 tank_kg, residual = _num(s.get("loaded_kg")), _num(s.get("residual_kg"))
177 if tank_kg is None or residual is None:
178 out[short] = {"available": False, "error": "the summary carries no tank inventory"}
179 continue
180 burned, how = None, ""
181 if net is not None:
182 injectors = [b for b in (net.get("branches") or {}).values()
183 if isinstance(b, Mapping) and b.get("kind") == "injector" and b.get("side") == short]
184 if injectors:
185 burned = sum(sum((_num(m) or 0.0) * h for m, h in zip(b.get("mdot") or [], steps)) for b in injectors)
186 how = "the injector branches' flow in the network record, each step times its length"
187 if burned is None:
188 mdot = (series.get(short) or {}).get("mdot") or []
189 burned = sum((_num(m) or 0.0) * h for m, h, f in zip(mdot, steps, firing) if f)
190 how = "the chamber's flow on the firing steps, each step times its length (analysis.reduce_trace's rule)"
191 lines: List[Dict[str, Any]] = []
192 trapped: Optional[float] = None
193 rho = None
194 tank_id = roles.get(side)
195 if doc is not None and tank_id:
196 lines = feed_lines(doc, tank_id)
197 vol = [ln["volume_m3"] for ln in lines]
198 liquid_K = [_num(v) for v in ((series.get(short) or {}).get("liquid_K") or [])]
199 T = next((v for v in reversed(liquid_K) if v is not None), None)
200 sp = _species(species.get(side))
201 if lines and None not in vol and T is not None and sp:
202 try:
203 import CoolProp.CoolProp as CP
204
205 # On the saturation line at the liquid's temperature: how the twin prices its liquid
206 # (feedtwin vessels/tank.py liquid_density).
207 rho = float(CP.PropsSI("D", "T", T, "Q", 0, sp))
208 trapped = rho * sum(vol) # type: ignore[arg-type]
209 except Exception: # noqa: BLE001 - outside CoolProp's range: reported without the lines
210 trapped = None
211 loaded = tank_kg + (trapped or 0.0)
212 err = loaded - burned - residual - (trapped or 0.0)
213 # With the ullage carrying propellant vapour, liquid that evaporated left the tank without
214 # passing the injector: it shows here as error, not as a leak.
215 # What the burn ran: the feed twin's Setup (provenance.setup), or the run's override of it.
216 vapour = bool(_dig(result, "provenance.setup.ullage_vapour") or _dig(result, "provenance.settings.ullage_vapour"))
217 out[short] = {
218 "available": True,
219 "loaded_kg": loaded, "tank_loaded_kg": tank_kg, "burned_kg": burned, "residual_kg": residual,
220 "trapped_kg": trapped, "error_kg": err, "error_pct": _pct(err, loaded),
221 "liquid_density_kg_m3": rho,
222 "lines": lines,
223 "basis": (f"Burned: {how}. Loaded and residual: the tank's own inventory at T-0 and at the last step. "
224 "Trapped: the drawing's tank-to-engine line volumes (pi/4 d^2 L) full of saturated liquid at "
225 "the last recorded liquid temperature (CoolProp); valve bodies and the injector manifold are "
226 "not in the drawing and not counted. The twin's lines hold no liquid inventory, so the "
227 "trapped mass is the same at both ends and the error is tank against flow."
228 + ("" if trapped is not None else " Lines not counted: the drawing was not available or a line "
229 "has no length or bore.")
230 + (" Ullage vapour is on: propellant that evaporated into the ullage is in the error, not "
231 "lost." if vapour else "")),
232 "includes_evaporation": vapour,
233 }
234 return out
235
236
237# ---------------------------------------------------------------------- pressurant
238
239
240def _ullage_masses(series: Mapping[str, Any], derived: Mapping[str, Any], gas: str,
241 i: int) -> Tuple[Optional[float], List[str]]:
242 import CoolProp.CoolProp as CP
243
244 roles = derived.get("roles") or {}
245 volumes = derived.get("tank_volumes_L") or {}
246 total = 0.0
247 notes = []
248 for side, short in (("oxidiser", "ox"), ("fuel", "fuel")):
249 s = series.get(short) or {}
250 v_L = _num(volumes.get(roles.get(side)))
251 p = _num((s.get("tank_psia") or [None] * (i + 1))[i])
252 T = _num((s.get("ullage_K") or [None] * (i + 1))[i])
253 fill = _num((s.get("fill_fraction") or [None] * (i + 1))[i])
254 if v_L is None or p is None or T is None or fill is None:
255 notes.append(f"{short}: no tank volume, ullage pressure, temperature or fill recorded")
256 return None, notes
257 rho = float(CP.PropsSI("D", "P", p * PSI, "T", T, gas))
258 total += rho * v_L * 1e-3 * max(1.0 - fill, 0.0)
259 return total, notes
260
261
262def _vented(result: Mapping[str, Any], steps: Sequence[float]) -> Tuple[Optional[float], Optional[float], str]:
263 """Gas that left the network to the atmosphere [kg] and the enthalpy it carried [J], from the
264 full network record: every gas branch whose downstream node is ambient (or a vent)."""
265 net = result.get("network")
266 if not isinstance(net, Mapping):
267 return None, None, "no network record: vents taken as shut (the burn plan holds them shut)"
268 nodes = net.get("nodes") or {}
269 kg = 0.0
270 found = []
271 for bid, b in (net.get("branches") or {}).items():
272 if not isinstance(b, Mapping) or b.get("side") not in ("gas", None):
273 continue
274 to = nodes.get(b.get("to")) or {}
275 if str(to.get("kind") or "").lower() not in ("ambient", "vent"):
276 continue
277 kg += sum(max(_num(m) or 0.0, 0.0) * h for m, h in zip(b.get("mdot") or [], steps))
278 found.append(str(bid))
279 return kg, None, ("vented through " + ", ".join(found) if found else "no gas branch to atmosphere in the network")
280
281
282def pressurant_balance(result: Mapping[str, Any], prep: Any = None) -> Dict[str, Any]:
283 """``{bottle_out_kg, ullage_in_kg, vented_kg, error_pct, basis}`` over the recorded window."""
284 series = result.get("series") or {}
285 derived = _dig(result, "provenance.derived") or getattr(prep, "derived", None) or {}
286 settings = _dig(result, "provenance.settings") or {}
287 gas = _species(derived.get("pressurant_gas"))
288 mass = [_num(v) for v in (series.get("copv_mass_kg") or [])]
289 if not gas or len(mass) < 2 or mass[0] is None or mass[-1] is None:
290 return {"available": False, "error": "no bottle mass or pressurant species recorded"}
291 if settings.get("ullage_vapour"):
292 return {"available": False,
293 "error": "the ullage carries propellant vapour (ullage_vapour on): pricing it as pure pressurant "
294 "would book the vapour as a pressurant error"}
295 m0, notes0 = _ullage_masses(series, derived, gas, 0)
296 m1, notes1 = _ullage_masses(series, derived, gas, len(mass) - 1)
297 if m0 is None or m1 is None:
298 return {"available": False, "error": "; ".join(notes0 + notes1)}
299 steps = _steps(series)
300 vented, _h, vent_note = _vented(result, steps)
301 bottle_out = mass[0] - mass[-1]
302 gain = m1 - m0
303 err = bottle_out - gain - (vented or 0.0)
304 return {
305 "available": True,
306 "species": gas,
307 "bottle_out_kg": bottle_out, "ullage_in_kg": gain, "vented_kg": vented,
308 "ullage_t0_kg": m0, "ullage_end_kg": m1,
309 "error_kg": err, "error_pct": _pct(err, bottle_out),
310 "window_s": [series["t"][0], series["t"][-1]],
311 "basis": ("Over the recorded window (lead-in and burn). Bottle: the twin's vessel mass. Ullages: re-priced "
312 "with CoolProp from the recorded ullage pressure and gas temperature over the drawing's tank volume "
313 "less the liquid's (1 - fill), pure pressurant. The network's lines hold no gas inventory. "
314 + vent_note + "."),
315 }
316
317
318# ---------------------------------------------------------------------- energy
319
320
321def _bottle_wall(result: Mapping[str, Any], prep: Any, document: Optional[Mapping[str, Any]]) -> Tuple[Optional[float], str]:
322 """Bottle wall heat capacity m*c [J/K] and where it came from: the drawing's own wall, else
323 the twin's Setup default (``bottle_wall_kg_per_L`` times the bottle volume, at feedtwin's
324 ``BOTTLE_WALL`` capacity)."""
325 derived = _dig(result, "provenance.derived") or getattr(prep, "derived", None) or {}
326 doc = _document(result, prep, document)
327 bid = derived.get("copv_id")
328 if doc is not None and bid:
329 from feedtwin.model.param import to_si
330
331 node = next((n for n in (doc.get("nodes") or []) if isinstance(n, Mapping) and n.get("id") == bid), None)
332 params = _dig(node, "data.params") or {}
333 m, c = params.get("wall_mass"), params.get("wall_capacity")
334 if isinstance(m, Mapping) and isinstance(c, Mapping):
335 try:
336 return (float(to_si(float(m["value"]), str(m.get("unit") or "kg")))
337 * float(to_si(float(c["value"]), str(c.get("unit") or "J/(kg.K)"))),
338 f"the drawing's {bid} wall ({m.get('source') or 'no source'})")
339 except Exception: # noqa: BLE001 - fall through to the Setup default
340 pass
341 setup = _dig(result, "provenance.setup") or {}
342 per_L, vol = _num(setup.get("bottle_wall_kg_per_L")), _num(derived.get("copv_volume_L"))
343 if per_L is not None and vol is not None:
344 try:
345 from feedtwin.session.core import BOTTLE_WALL
346
347 return per_L * vol * float(BOTTLE_WALL["capacity"]), "feed-twin's Setup default bottle wall (assumed)"
348 except Exception: # noqa: BLE001
349 pass
350 return None, "unknown"
351
352
353def energy_balance(result: Mapping[str, Any], prep: Any = None,
354 document: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
355 """The first law over the pressurant, over the recorded window.
356
357 For the bottle, ``-dU_bottle + Q_bottle_wall`` is the enthalpy that left it. For the ullages,
358 ``dU_ullage + W`` (``W = integral p dV`` on the liquid) is what arrived, less the heat the tank
359 walls and the liquid surface gave the gas. Lines and regulators hold no gas and do no work
360 (isenthalpic throttling). So the unclosed part,
361
362 R = (-dU_bottle + Q_bottle_wall) - dU_ullage - W - H_vented,
363
364 is minus the heat the tank walls, the liquid surface and (with ``line_walls``) the line walls put
365 into the gas, plus integration error. **It closes** the bottle (its wall from the drawing or the
366 Setup default, its gas priced with CoolProp at the recorded pressure and m/V), the ullages
367 (CoolProp at the recorded p, T) and the boundary work (trapezoidal p dV). **It cannot** separate
368 the tank-wall and interface heat from numerical error, because the series records neither the tank
369 wall temperatures nor the interface: ``error_pct`` is therefore not a defect measure and the
370 limits report it as information only. Vent enthalpy is not in the network record, so a run that
371 vents carries that term in R too.
372 """
373 import CoolProp.CoolProp as CP
374
375 series = result.get("series") or {}
376 derived = _dig(result, "provenance.derived") or getattr(prep, "derived", None) or {}
377 settings = _dig(result, "provenance.settings") or {}
378 gas = _species(derived.get("pressurant_gas"))
379 p_b = [_num(v) for v in (series.get("copv_psia") or [])]
380 m_b = [_num(v) for v in (series.get("copv_mass_kg") or [])]
381 wall = [_num(v) for v in (series.get("copv_wall_K") or [])]
382 v_b = _num(derived.get("copv_volume_L"))
383 if not gas or v_b is None or len(p_b) < 2 or None in (p_b[0], p_b[-1], m_b[0], m_b[-1]):
384 return {"available": False, "error": "no bottle pressure, mass, volume or pressurant recorded"}
385 if settings.get("ullage_vapour"):
386 return {"available": False, "error": "the ullage carries propellant vapour (ullage_vapour on)"}
387 V = v_b * 1e-3
388
389 def u_bottle(i: int) -> float:
390 return m_b[i] * float(CP.PropsSI("U", "P", p_b[i] * PSI, "D", m_b[i] / V, gas)) # type: ignore[operator]
391
392 du_b = u_bottle(len(p_b) - 1) - u_bottle(0)
393 mc, wall_src = _bottle_wall(result, prep, document)
394 q_bw = -mc * (wall[-1] - wall[0]) if mc is not None and wall and wall[0] is not None and wall[-1] is not None else None
395
396 roles = derived.get("roles") or {}
397 volumes = derived.get("tank_volumes_L") or {}
398 du_u = 0.0
399 work = 0.0
400 for side, short in (("oxidiser", "ox"), ("fuel", "fuel")):
401 s = series.get(short) or {}
402 v_L = _num(volumes.get(roles.get(side)))
403 p = [_num(v) for v in (s.get("tank_psia") or [])]
404 T = [_num(v) for v in (s.get("ullage_K") or [])]
405 fill = [_num(v) for v in (s.get("fill_fraction") or [])]
406 if v_L is None or not p or None in (p[0], p[-1], T[0], T[-1], fill[0], fill[-1]):
407 return {"available": False, "error": f"{short}: no tank volume or ullage state recorded"}
408 vol = [v_L * 1e-3 * max(1.0 - f, 0.0) if f is not None else None for f in fill]
409
410 def u_ullage(i: int) -> float:
411 rho = float(CP.PropsSI("D", "P", p[i] * PSI, "T", T[i], gas)) # type: ignore[operator]
412 return rho * vol[i] * float(CP.PropsSI("U", "P", p[i] * PSI, "T", T[i], gas)) # type: ignore[operator]
413
414 du_u += u_ullage(len(p) - 1) - u_ullage(0)
415 for i in range(1, len(p)):
416 if None in (p[i], p[i - 1], vol[i], vol[i - 1]):
417 continue
418 work += 0.5 * (p[i] + p[i - 1]) * PSI * (vol[i] - vol[i - 1]) # type: ignore[operator]
419 out_of_bottle = -du_b + (q_bw or 0.0)
420 unclosed = out_of_bottle - du_u - work
421 return {
422 "available": True,
423 "bottle_dU_J": du_b, "bottle_wall_heat_J": q_bw, "bottle_wall_basis": wall_src,
424 "ullage_dU_J": du_u, "boundary_work_J": work, "vented_enthalpy_J": None,
425 "unclosed_J": unclosed,
426 # Internal energies carry CoolProp's arbitrary reference state, which cancels in R only because
427 # the gas mass is conserved; a percentage of them would move with that reference. The work the
428 # gas did on the liquid is reference-free, so R is stated against it.
429 "error_pct": _pct(unclosed, work),
430 "basis": ("First law over the pressurant, recorded window: R = (-dU_bottle + Q_bottle_wall) - dU_ullages - "
431 "integral p dV, CoolProp internal energies at the recorded states, in percent of the expulsion "
432 "work integral p dV. R is minus the heat the tank walls, the liquid surface and the line walls "
433 "gave the gas, plus integration error, which the series cannot separate (tank-wall and interface "
434 "temperatures are not recorded): not a defect measure. Bottle wall: " + wall_src + "."),
435 }
436
437
438# ---------------------------------------------------------------------- convergence
439
440
441def convergence(result: Mapping[str, Any]) -> List[Dict[str, Any]]:
442 """Per outer pass: the throat schedule's and the flight acceleration's change against their
443 tolerances (``replay.THROAT_TOLERANCE``, ``flight.ACCEL_TOLERANCE``)."""
444 try:
445 from engine.layerx.flight import ACCEL_TOLERANCE
446 from engine.layerx.replay import THROAT_TOLERANCE
447 except Exception: # noqa: BLE001 - a result read without the solver still lists its passes
448 THROAT_TOLERANCE, ACCEL_TOLERANCE = None, None # type: ignore[assignment]
449 out = []
450 for p in result.get("passes") or []:
451 if not isinstance(p, Mapping):
452 continue
453 agree = _dig(p, "agreement.worst") or {}
454 out.append({
455 "pass": p.get("pass"),
456 "throat_residual": _num(p.get("schedule_change")),
457 "throat_tolerance": THROAT_TOLERANCE,
458 "accel_residual": _num(p.get("accel_change")),
459 "accel_tolerance": ACCEL_TOLERANCE if p.get("accel_change") is not None else None,
460 "throat_applied": p.get("throat_applied"), "accel_applied": p.get("accel_applied"),
461 "burn_time_s": _num(p.get("burn_time_s")),
462 "agreement_worst": max((abs(_num(v) or 0.0) for v in agree.values()), default=None) if agree else None,
463 })
464 return out
465
466
467# ---------------------------------------------------------------------- the block
468
469
470def check(result: Mapping[str, Any], prep: Any = None, *,
471 document: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
472 """The ``diagnostics.vv`` block. Each part that cannot be computed says why; nothing raises.
473
474 ``prep`` (a Prepared) and ``document`` (the drawing's ``{nodes, edges}``) are optional: the
475 result's own provenance names the drawing, and a shipped one is found by id.
476 """
477 out: Dict[str, Any] = {}
478 for key, fn in (("mass", lambda: mass_balance(result, prep, document)),
479 ("pressurant", lambda: pressurant_balance(result, prep)),
480 ("energy", lambda: energy_balance(result, prep, document)),
481 ("convergence", lambda: convergence(result))):
482 try:
483 out[key] = fn()
484 except Exception as exc: # noqa: BLE001 - DATA-CONTRACT: a failed block never fails the run
485 out[key] = {"available": False, "error": f"{type(exc).__name__}: {exc}"}
486 out["model"] = {
487 "name": "layerx_vv_balances",
488 "source": ("Control-volume mass conservation and the first law for an open system (Moran & Shapiro, "
489 "Fundamentals of Engineering Thermodynamics, ch. 4); CoolProp 6+ (Bell et al., Ind. Eng. Chem. "
490 "Res. 53(6), 2014) for the gas and liquid properties"),
491 "assumptions": [
492 "the twin's lines and regulators hold no gas or liquid inventory (an algebraic network)",
493 "the ullage is pure pressurant (checked: the balance is skipped when ullage_vapour is on)",
494 "trapped liquid fills the drawing's tank-to-engine lines at the saturated density of the last recorded "
495 "liquid temperature; valve bodies and the injector manifold are not counted",
496 "vented gas is read from the network record when present; otherwise the vents are shut",
497 ],
498 "inputs": {
499 "tank_volumes_L": {"value": _dig(result, "provenance.derived.tank_volumes_L"), "unit": "L",
500 "provenance": "drawing"},
501 "copv_volume_L": {"value": _dig(result, "provenance.derived.copv_volume_L"), "unit": "L",
502 "provenance": "drawing"},
503 "pressurant_gas": {"value": _dig(result, "provenance.derived.pressurant_gas"), "unit": "",
504 "provenance": "drawing"},
505 },
506 }
507 return out
#define max(a, b)
Definition Arduino.h:43
List[Dict[str, Any]] feed_lines(Mapping[str, Any] document, str tank_id)
Definition vv.py:87
Optional[float] _pct(Optional[float] err, Optional[float] ref)
Definition vv.py:62
Any _dig(Any d, str path)
Definition vv.py:54
Tuple[Optional[float], str] _bottle_wall(Mapping[str, Any] result, Any prep, Optional[Mapping[str, Any]] document)
Definition vv.py:321
Dict[str, Any] energy_balance(Mapping[str, Any] result, Any prep=None, Optional[Mapping[str, Any]] document=None)
Definition vv.py:354
Optional[Mapping[str, Any]] _document(Mapping[str, Any] result, Any prep, Optional[Mapping[str, Any]] document)
Definition vv.py:136
Tuple[Optional[float], List[str]] _ullage_masses(Mapping[str, Any] series, Mapping[str, Any] derived, str gas, int i)
Definition vv.py:241
Dict[str, Any] mass_balance(Mapping[str, Any] result, Any prep=None, Optional[Mapping[str, Any]] document=None)
Definition vv.py:158
List[float] _steps(Mapping[str, Any] series)
Definition vv.py:74
Dict[str, Any] pressurant_balance(Mapping[str, Any] result, Any prep=None)
Definition vv.py:282
Tuple[Optional[float], Optional[float], str] _vented(Mapping[str, Any] result, Sequence[float] steps)
Definition vv.py:262
List[Dict[str, Any]] convergence(Mapping[str, Any] result)
Definition vv.py:441
Optional[str] _species(Optional[str] name)
Definition vv.py:68
Optional[float] _num(Any v)
Definition vv.py:44
Dict[str, Any] check(Mapping[str, Any] result, Any prep=None, *, Optional[Mapping[str, Any]] document=None)
Definition vv.py:471