STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ledger.py
Go to the documentation of this file.
1"""The assumption ledger: what EngineDesign's design point assumes, against what the burn delivered.
2
3``build_ledger(result, prep=None, config, *, forward=None, runner=None, injector=None)`` ->
4``diagnostics.ledger`` (DATA-CONTRACT 3): one row per AUDIT section-4 assumption that a result can
5fill, ``{key, label, design_value, unit, delivered: {min, max, mean, start, end}, series_ref,
6replaced, note}``.
7
8**The design column is one EngineDesign Forward solve at the burn's lockup on both tanks**
9(``engine.layerx.diag.injector.forward_design_point``), not the config's ``design_*`` / ``target_*``
10fields: AUDIT 4 found those stale (6.5 kN values on a 6.8 kN engine). Rows that are config constants
11EngineDesign uses as they are (densities, nozzle efficiency, A_t before erosion) take the config's
12value and say so. Without a Forward solve the Forward rows carry ``design_value: null``.
13
14**The delivered column** is min / max / time-mean / first / last over the firing steps of the series
15named in ``series_ref`` (``dt``-weighted mean), or over the replay's points for replay series
16(eta_c*), or one value where the quantity is a scalar (burn time, impulse).
17
18``replaced`` reads as AUDIT 4 does: ``yes`` when the burn takes the quantity from the drawing or
19the twin, ``partly`` when it takes the operating point but not the model's inputs, ``no`` when the
20design's own value is used unchanged.
21"""
22
23from __future__ import annotations
24
25import math
26from typing import Any, Dict, List, Mapping, Optional
27
28import numpy as np
29
30from engine.layerx.diag.ladder import PSI, arr, firing_mask, scalar, series_t, unavailable
31
32G0 = 9.80665
33
34
35def _stats(values: np.ndarray, weights: Optional[np.ndarray] = None) -> Optional[Dict[str, Optional[float]]]:
36 ok = np.isfinite(values)
37 if not ok.any():
38 return None
39 v = values[ok]
40 w = weights[ok] if weights is not None else np.ones(v.size)
41 if not np.isfinite(w).all() or w.sum() <= 0:
42 w = np.ones(v.size)
43 return {"min": scalar(v.min()), "max": scalar(v.max()), "mean": scalar(float(np.sum(v * w) / np.sum(w))),
44 "start": scalar(v[0]), "end": scalar(v[-1])}
45
46
47def _one(value: Any) -> Optional[Dict[str, Optional[float]]]:
48 v = scalar(value)
49 return None if v is None else {"min": v, "max": v, "mean": v, "start": v, "end": v}
50
51
52def _get(d: Any, path: str) -> Any:
53 for part in path.split("."):
54 if d is None:
55 return None
56 d = d.get(part) if isinstance(d, Mapping) else getattr(d, part, None)
57 return d
58
59
60def build_ledger(result: Mapping[str, Any], prep: Any = None, config: Any = None, *,
61 forward: Optional[Mapping[str, Any]] = None, runner: Any = None,
62 injector: Optional[Mapping[str, Any]] = None) -> Any:
63 """``diagnostics.ledger`` (a list of rows). Never raises: a failure is ``{available: false, error}``."""
64 try:
65 from engine.layerx.diag.injector import forward_design_point
66 from engine.layerx.diag.saturation import cd_effective, orifice_geometry, saturated_liquid_density, \
67 side_fluid
68
69 series = result.get("series") or {}
70 dl = result.get("delivered") or {}
71 rep = result.get("replay") or {}
72 t = series_t(result)
73 n = t.size
74 fire = firing_mask(result, n)
75 dt = arr(series.get("dt"), n) if series.get("dt") is not None and len(series["dt"]) == n else np.ones(n)
76
77 fwd = None
78 fwd_err = None
79 try:
80 fwd = forward_design_point(prep, config, runner, forward)
81 except Exception as exc: # noqa: BLE001
82 fwd_err = f"{type(exc).__name__}: {exc}"
83 fd = (fwd or {}).get("diagnostics") or {}
84 basis = (fwd or {}).get("_basis") or {}
85 lockup = basis.get("tank_psia") or ((getattr(prep, "derived", None) or
86 (result.get("provenance") or {}).get("derived") or {})
87 .get("target_lockup_psia"))
88 fwd_note = (f"EngineDesign Forward at {lockup} psia both tanks" if fwd else
89 f"no Forward solve ({fwd_err or 'pass forward= or runner='})")
90
91 def fwd_val(key: str, scale: float = 1.0) -> Optional[float]:
92 if fwd is None:
93 return None
94 v = fwd.get(key)
95 if v is None:
96 v = fd.get(key)
97 v = scalar(v)
98 return None if v is None else v / scale
99
100 def ser(path: str) -> np.ndarray:
101 return arr(_get(series, path), n)
102
103 def firing_stats(v: np.ndarray) -> Optional[Dict[str, Optional[float]]]:
104 return _stats(np.where(fire, v, np.nan), np.where(fire, dt, np.nan))
105
106 def dl_stats(key: str, scale: float = 1.0) -> Optional[Dict[str, Optional[float]]]:
107 v = dl.get(key)
108 if v is None:
109 return None
110 d_t = arr(dl.get("t"))
111 vv = arr(v) * scale
112 w = np.diff(d_t, prepend=d_t[0] - (d_t[1] - d_t[0] if d_t.size > 1 else 1.0)) if d_t.size else None
113 return _stats(vv, w)
114
115 rows: List[Dict[str, Any]] = []
116 # No replay (Nozzle erosion off, or one that did not run): what only the replay computes (the
117 # eroding throat, eta_c*, L*) is not in this run, and the row says so instead of a bare dash.
118 no_replay = not dl.get("t")
119 replay_off = ((result.get("provenance") or {}).get("settings") or {}).get("replay") is False
120 why = ("not computed: nozzle erosion off" if replay_off else "not computed: the run has no erosion replay") \
121 if no_replay else ""
122
123 def row(key: str, label: str, design: Optional[float], unit: str, delivered: Any, series_ref: str,
124 replaced: str, note: str = "", design_source: str = "") -> None:
125 rows.append({"key": key, "label": label, "design_value": scalar(design) if design is not None else None,
126 "design_source": design_source or fwd_note, "unit": unit, "delivered": delivered,
127 "series_ref": series_ref, "replaced": replaced, "note": note})
128 if delivered is None and why and series_ref.startswith(("delivered.", "replay.")):
129 rows[-1]["missing"] = "erosion off" if replay_off else "no replay"
130
131 sides = (("ox", "O", "LOX", "oxidizer"), ("fuel", "F", "Fuel", "fuel"))
132 for s, k, lab, _ in sides:
133 row(f"tank_pressure_{s}", f"{lab} tank pressure", lockup, "psia", firing_stats(ser(f"{s}.tank_psia")),
134 f"series.{s}.tank_psia", "yes",
135 "EngineDesign holds the tank at the lockup; the twin's ullage dips at ignition and rides the "
136 "regulator's supply-pressure effect", design_source="Forward input (the burn's lockup)")
137 regs = series.get("regulators") or {}
138 for rid, reg in regs.items():
139 row(f"regulator_outlet_{rid}", f"Regulator {reg.get('label') or rid} outlet", lockup, "psia",
140 firing_stats(arr(reg.get("outlet_psia"), n)), f"series.regulators.{rid}.outlet_psia", "yes",
141 "Forward has no regulator (a flat setpoint); the drawing's supply-pressure effect and droop act "
142 "here. Sources disagree on the supply effect (AUDIT D9).",
143 design_source="flat setpoint at the lockup (no regulator in Forward)")
144 for s, k, lab, _ in sides:
145 row(f"feed_loss_{s}", f"{lab} feed loss, tank to manifold", fwd_val(f"delta_p_feed_{k}", PSI), "psi",
146 firing_stats(ser(f"{s}.tank_psia") - ser(f"{s}.manifold_psia")),
147 f"series.{s}.tank_psia - series.{s}.manifold_psia", "yes",
148 "design: the config's lumped K0 + Borda dump; delivered: the drawing's lines, head and the dump")
149 # The engine's figures are the replay's (delivered, on the eroding throat) when the run has one;
150 # a burn with the erosion replay off still fired, on the twin's own chamber (series.chamber).
151 twin_note = "the twin's chamber on the as-built throat (no erosion replay)" if no_replay else ""
152
153 def engine_row(key: str, label: str, design: Optional[float], unit: str, dl_key: str, twin_path: str) -> None:
154 if not no_replay:
155 row(key, label, design, unit, dl_stats(dl_key), f"delivered.{dl_key}", "yes")
156 else:
157 row(key, label, design, unit, firing_stats(ser(twin_path)), f"series.{twin_path}", "yes", twin_note)
158
159 engine_row("pc", "Chamber pressure", fwd_val("Pc", PSI), "psia", "pc_psia", "chamber.pc_psia")
160 engine_row("thrust", "Thrust", fwd_val("F"), "N", "thrust_N", "chamber.thrust_N")
161 engine_row("of", "O/F", fwd_val("MR"), "-", "mr", "chamber.mr")
162 for s, k, lab, _ in sides:
163 engine_row(f"mdot_{s}", f"{lab} flow", fwd_val(f"mdot_{k}"), "kg/s", f"mdot_{k}", f"{s}.mdot")
164 engine_row("isp", "Isp", fwd_val("Isp"), "s", "isp_s", "chamber.isp_s")
165 pc_f = fwd_val("Pc", PSI)
166 for s, k, lab, _ in sides:
167 dpi = fwd_val(f"delta_p_injector_{k}", PSI)
168 row(f"stiffness_{s}", f"{lab} injector dP/Pc", (dpi / pc_f) if dpi is not None and pc_f else None, "-",
169 firing_stats(ser(f"{s}.stiffness")), f"series.{s}.stiffness", "yes",
170 "design band from design_requirements.injector_dp_ratio_*")
171 row(f"dp_injector_{s}", f"{lab} injector dP", dpi, "psi", firing_stats(ser(f"{s}.dp_injector_psi")),
172 f"series.{s}.dp_injector_psi", "yes")
173 row(f"manifold_{s}", f"{lab} manifold pressure", fwd_val(f"P_injector_{k}", PSI), "psia",
174 firing_stats(ser(f"{s}.manifold_psia")), f"series.{s}.manifold_psia", "yes")
175
176 # throat area: the design's (as built), delivered as A_t0 x the replay's growth
177 at0 = scalar(_get(config, "chamber_geometry.A_throat")) if config is not None else None
178 at0 = at0 or fwd_val("A_throat")
179 ratio = dl.get("throat_area_ratio")
180 row("throat_area", "Throat area", at0 * 1e6 if at0 else None, "mm^2",
181 dl_stats("throat_area_ratio", at0 * 1e6) if (at0 and ratio is not None) else None,
182 "delivered.throat_area_ratio x A_t0", "yes", why or "recession rates unvalidated (AUDIT 1 #10)",
183 design_source="config chamber_geometry.A_throat (as built)")
184 eps0 = scalar(_get(config, "chamber_geometry.expansion_ratio")) if config is not None else None
185 row("eps", "Expansion ratio", eps0, "-", dl_stats("eps"), "delivered.eps", "yes",
186 design_source="config chamber_geometry.expansion_ratio")
187 lstar0 = scalar(_get(config, "chamber_geometry.Lstar")) if config is not None else None
188 row("lstar", "L*", lstar0, "m", _stats(arr(rep.get("Lstar_m"))) if rep.get("Lstar_m") else None,
189 "replay.Lstar_m", "partly", "the twin's card stays as built (AUDIT 3 #10)",
190 design_source="config chamber_geometry.Lstar")
191 row("eta_cstar", "eta_c*", fwd_val("eta_cstar"), "-",
192 _stats(arr(rep.get("eta_cstar"))) if rep.get("eta_cstar") else None, "replay.eta_cstar", "partly",
193 why or "the same model at each replay point; its inputs (E_m, SMD) stay assumed")
194 if config is not None:
195 for s, k, lab, ck in sides:
196 geo = orifice_geometry(config, s)
197 cd = cd_effective(ser(f"{s}.mdot"), ser(f"{s}.dp_injector_psi"), geo["area"], geo["rho"])
198 row(f"cd_{s}", f"{lab} orifice Cd", fwd_val(f"Cd_{k}"), "-", firing_stats(cd),
199 f"derived: series.{s}.mdot / (A sqrt(2 rho series.{s}.dp_injector_psi))", "no",
200 "the card's capacity is EngineDesign's Cd model (Lichtarowicz); the burn does not measure it")
201 for s, k, lab, ck in sides:
202 rho_cfg = scalar(_get(config, f"fluids.{ck}.density")) if isinstance(config.fluids, Mapping) else None
203 fluid = side_fluid(prep, result, s) or ("oxygen" if s == "ox" else "ethanol")
204 T_l = ser(f"{s}.liquid_K")
205 rho_twin = np.array([saturated_liquid_density(fluid, x) if math.isfinite(x) else math.nan
206 for x in T_l])
207 row(f"density_{s}", f"{lab} density (engine)", rho_cfg, "kg/m^3", firing_stats(rho_twin),
208 f"CoolProp saturated liquid at series.{s}.liquid_K (the twin's basis)", "no",
209 "the twin prices liquid on the saturated line at T; the engine card keeps the config density "
210 "(AUDIT ledger #27)", design_source=f"config fluids.{ck}.density")
211 zeta = scalar(_get(config, "chamber_geometry.nozzle_efficiency"))
212 row("nozzle_efficiency", "Nozzle efficiency", zeta, "-", _one(zeta), "config (unchanged by the burn)",
213 "no", "schema default with no source (AUDIT D16)",
214 design_source="config chamber_geometry.nozzle_efficiency")
215 amb_d = fwd_val("P_ambient", PSI)
216 row("ambient", "Ambient pressure", amb_d, "psia", dl_stats("ambient_psia"), "delivered.ambient_psia",
217 "partly", "site ambient on the pad; altitude only inside RocketPy")
218 # specific force and the liquid heads it sets
219 flight = result.get("flight") or {}
220 sched = flight.get("schedule") if flight.get("ok") else None
221 if sched and sched.get("t"):
222 a = np.interp(t, arr(sched["t"]), arr(sched["accel_m_s2"])) / G0
223 a_ref, a_note = "flight.schedule.accel_m_s2 / g0", "the flown pass's axial specific force"
224 else:
225 a = np.ones(n)
226 a_ref, a_note = "Setup.body_acceleration (pad: 1 g)", "on the pad: one g"
227 row("specific_force", "Axial specific force", None, "g", firing_stats(a), a_ref, "partly",
228 f"EngineDesign has no head term at all; {a_note}", design_source="none (no head term)")
229 for s, k, lab, _ in sides:
230 row(f"tank_head_{s}", f"{lab} tank head", 0.0, "psi",
231 firing_stats(ser(f"{s}.outlet_psia") - ser(f"{s}.tank_psia")),
232 f"series.{s}.outlet_psia - series.{s}.tank_psia", "partly",
233 "no line heights on the drawing: only the tanks' own liquid feels the acceleration",
234 design_source="EngineDesign: no hydrostatic head")
235 if injector and injector.get("momentum_ratio") is not None:
236 row("momentum_ratio", "Momentum ratio R", injector.get("design_momentum_ratio"), "-",
237 firing_stats(arr(injector["momentum_ratio"], n)), "diagnostics.injector.momentum_ratio",
238 "partly", "derived on the twin's flows at the config densities")
239 summ = (dl.get("summary") or {})
240 burn_cfg = scalar(_get(config, "thrust.burn_time")) if config is not None else None
241 row("burn_time", "Burn time", burn_cfg, "s", _one((result.get("summary") or {}).get("burn_time_s")),
242 "summary.burn_time_s", "yes", "the config's burn time is a stale field (AUDIT 4 #22)",
243 design_source="config thrust.burn_time")
244 load = None
245 if config is not None:
246 load = (scalar(_get(config, "design_requirements.lox_tank_capacity_kg")) or 0.0) + \
247 (scalar(_get(config, "design_requirements.fuel_tank_capacity_kg")) or 0.0)
248 isp_f = fwd_val("Isp")
249 row("impulse", "Total impulse", (load * isp_f * G0) if (load and isp_f) else None, "N s",
250 _one(summ.get("total_impulse_Ns") if not no_replay
251 else (result.get("summary") or {}).get("total_impulse_Ns")),
252 "delivered.summary.total_impulse_Ns" if not no_replay else "summary.total_impulse_Ns", "yes",
253 "design: propellant load x Forward Isp x g0",
254 design_source="config load x Forward Isp")
255 return rows
256 except Exception as exc: # noqa: BLE001
257 return unavailable(f"{type(exc).__name__}: {exc}")
258
259
260def build_feed_diagnostics(result: Mapping[str, Any], prep: Any = None, config: Any = None, *,
261 runner: Any = None, forward: Optional[Mapping[str, Any]] = None,
262 target_N: Optional[float] = None, sampler: Any = None) -> Dict[str, Any]:
263 """Every feed-diagnostic block of DATA-CONTRACT 3 this package builds, keyed as the contract
264 keys them under ``result["diagnostics"]``: ladder, regulator, solenoids, pressurant,
265 saturation, cavitation, injector, thrust_shape, ledger. One Forward solve at the lockup is
266 shared by the injector and the ledger. Each block fails on its own (``{available: false,
267 error}``); this never raises."""
268 from engine.layerx.diag.injector import build_injector, forward_design_point
269 from engine.layerx.diag.ladder import build_ladder
270 from engine.layerx.diag.pressurant import build_pressurant
271 from engine.layerx.diag.regulator import build_regulator, build_solenoids
272 from engine.layerx.diag.saturation import build_cavitation, build_saturation
273 from engine.layerx.diag.thrustshape import build_thrust_shape
274
275 out: Dict[str, Any] = {}
276 try:
277 fwd = forward_design_point(prep, config, runner, forward)
278 except Exception: # noqa: BLE001 - the Forward rows go null, the rest stands
279 fwd = None
280 out["ladder"] = build_ladder(result, prep, config)
281 out["regulator"] = build_regulator(result, prep, config)
282 out["solenoids"] = build_solenoids(result, prep, config)
283 reg = out["regulator"] if isinstance(out["regulator"], Mapping) and "error" not in out["regulator"] else None
284 out["pressurant"] = build_pressurant(result, prep, config, regulator=reg)
285 out["saturation"] = build_saturation(result, prep, config)
286 out["cavitation"] = build_cavitation(result, prep, config)
287 out["injector"] = build_injector(result, prep, config, forward=fwd)
288 out["thrust_shape"] = build_thrust_shape(result, prep, config, sampler=sampler, target_N=target_N)
289 inj = out["injector"] if isinstance(out["injector"], Mapping) and "error" not in out["injector"] else None
290 out["ledger"] = build_ledger(result, prep, config, forward=fwd, injector=inj)
291 return out
Any build_ledger(Mapping[str, Any] result, Any prep=None, Any config=None, *, Optional[Mapping[str, Any]] forward=None, Any runner=None, Optional[Mapping[str, Any]] injector=None)
Definition ledger.py:62
Optional[Dict[str, Optional[float]]] _stats(np.ndarray values, Optional[np.ndarray] weights=None)
Definition ledger.py:35
Dict[str, Any] build_feed_diagnostics(Mapping[str, Any] result, Any prep=None, Any config=None, *, Any runner=None, Optional[Mapping[str, Any]] forward=None, Optional[float] target_N=None, Any sampler=None)
Definition ledger.py:262
Any _get(Any d, str path)
Definition ledger.py:52
Optional[Dict[str, Optional[float]]] _one(Any value)
Definition ledger.py:47