STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
uncertainty.py
Go to the documentation of this file.
1"""Phase 5: which unmeasured input moves the burn, and by how much.
2
3A burn rests on inputs nobody has measured yet. Each is varied on its own, from the low end
4of its plausible range to the high end, with everything else held at nominal. This is a
5tornado. It answers two questions:
6
7* how wide the answer is: the band on impulse, burn time, thrust and margins, as the
8 root-sum-square of the one-at-a-time swings;
9* which measurement narrows it most: the factors ranked by how far they move the burn.
10
11A one-at-a-time sweep assumes the effects are roughly independent and linear over their
12ranges, which is what the RSS assumes too. It costs 2N burns rather than the hundreds a Monte
13Carlo needs, and it says *which* input, which a Monte Carlo cloud does not.
14
15The factors, and where their ranges come from
16----------------------------------------------
17Feed, from the drawing:
18
19* **Dome regulator supply-pressure effect, ×0.5–1.5.** One GN2 back-fit plus the vendor's sheet.
20* **Dome regulator droop, ×0.5–1.5.** Same basis.
21* **Ullage collapse, off → on.** A model choice. The collapse model is a stated lower bound.
22
23Engine, through EngineDesign's own measurement fields, so the engine card is rebuilt by
24EngineDesign under each perturbation rather than scaled by a guessed sensitivity:
25
26* **LOX and fuel orifice Cd, ±3 %.** The scatter of the Lichtarowicz correlation the plate is
27 sized with. Replace it with a cold-flow Cd.
28* **Rupe mixing E_m_opt, 0.70 / 0.85.** The literature range the spray report uses
29 (engine/core/injectors/spray_report.py). On the 6.5 kN engine that range is 6242–6601 N.
30* **Nozzle efficiency, ±0.02 about the config's value.** Assumed.
31
32A factor that has been measured uses its stated uncertainty instead. A measured value with
33no uncertainty is treated as exact and dropped from the sweep, which the result says.
34
35Burns run without the erosion replay. The replay moves every case the same way, and the
36question here is the difference between cases. They run in parallel worker processes, each
37seeded with the nominal engine card, so only the engine perturbations pay for a new card.
38"""
39
40from __future__ import annotations
41
42import math
43import time
44from dataclasses import dataclass, field, replace
45from typing import Any, Callable, Dict, List, Optional, Tuple
46
47from engine.layerx.diag.limits import THRESHOLDS as _LIMITS # noqa: E402 - the one copy of the thresholds
48
49#: The bottle above the tanks at burnout below which a case is said to break the margin [psi]: the
50#: shared limits' ``copv_headroom_psi`` (engine/layerx/diag/limits.py), which a run is graded by.
51SPARE_PSI = float(_LIMITS["copv_headroom_psi"]["value"])
52#: The injector dP/Pc floor when the design states no band: the shared limits' ``stiffness_floor``.
53STIFFNESS_FLOOR = float(_LIMITS["stiffness_floor"]["value"])
54#: Propellant left in the other tank above which a flip of the tank that runs dry first is worth a
55#: look: the shared limits' ``residual_kg`` ("Propellant left over"). Below it, which tank empties
56#: first is the Cd scatter's call and information, as a run grades it (limits.py; the team, 2026-10-03).
57RESIDUAL_KG = float(_LIMITS["residual_kg"]["value"])
58
59#: Outputs every case reports, and the summary path each is read from.
60METRICS: Dict[str, Tuple[str, ...]] = {
61 "total_impulse_Ns": ("total_impulse_Ns",),
62 "burn_time_s": ("burn_time_s",),
63 "mean_thrust_N": ("mean_thrust_N",),
64 "pc_mean_psia": ("pc_mean_psia",),
65 "of_mean": ("of_mean",),
66 "ox_min_psia": ("ox", "min_psia"),
67 "fuel_min_psia": ("fuel", "min_psia"),
68 "ox_stiffness_min": ("ox", "stiffness_min"),
69 "fuel_stiffness_min": ("fuel", "stiffness_min"),
70 "copv_end_psia": ("copv_end_psia",),
71}
72
73
74@dataclass
75class Case:
76 """One perturbation: what changes, and how it is described."""
77
78 factor: str
79 side: str
80 """``low`` or ``high`` (or ``on`` for a switch)."""
81 label: str
82 overrides: List[Any] = field(default_factory=list)
83 settings: Dict[str, Any] = field(default_factory=dict)
84 engine: Dict[str, Any] = field(default_factory=dict)
85 """``{"measure": key, "value": x}`` or ``{"field": dotted path, "value": x}``."""
86
87
88@dataclass
89class Factor:
90 key: str
91 label: str
92 group: str
93 basis: str
94 cases: List[Case]
95 nominal: Optional[float] = None
96 unit: str = ""
97
98
99def _get(obj: Any, path: str) -> Any:
100 for part in path.split("."):
101 obj = obj[part] if isinstance(obj, dict) else getattr(obj, part)
102 return obj
103
104
105def factors(prep: Any, config: Any) -> Tuple[List[Factor], List[str]]:
106 """The factors for this burn, each with its low and high cases, and notes about any dropped."""
107 from engine.layerx.measurements import Override
108
109 out: List[Factor] = []
110 notes: List[str] = []
111 measured = {o["key"]: o for o in prep.overrides if o.get("provenance") == "measured"}
112
113 # ---- the dome regulator, from the drawing ----
114 domes = [n for n in prep.model.diagram.nodes if n.type == "PR" and n.options.get("domeLoaded") == "yes"]
115 for node in domes[:1]:
116 for param, label in (("supply_coefficient", "Regulator supply-pressure effect"), ("flow_droop", "Regulator droop")):
117 p = node.params.get(param)
118 if p is None:
119 continue
120 key = f"node:{node.id}.{param}"
121 value, unit = float(p.value), str(p.unit)
122 m = measured.get(key)
123 if m is not None and m.get("uncertainty") is None:
124 notes.append(f"{label} is measured with no uncertainty stated; held exact.")
125 continue
126 if m is None and p.source.value == "measured":
127 # The drawing itself says measured: its provenance stands, as a restatement's would.
128 notes.append(f"{label} ({value:g} {unit}) is marked measured on the drawing with no uncertainty "
129 f"stated; held exact. Restate it with a ± under Parameters and measurements to sweep it.")
130 continue
131 if m is not None:
132 lo, hi = value - float(m["uncertainty"]), value + float(m["uncertainty"])
133 basis = f"measured {value:g} ± {m['uncertainty']:g} {unit} ({m['source']})"
134 else:
135 lo, hi = 0.5 * value, 1.5 * value
136 basis = (f"×0.5–1.5 of the drawing's {value:g} {unit} ({p.source.value}); the range is assumed, "
137 "wide enough for a regulator nobody has flow-tested")
138 def case(side: str, v: float, param=param, unit=unit, key=key) -> Case:
139 return Case(key, side, f"{v:g} {unit}", overrides=[Override(
140 target=f"node:{node.id}", parameter=param, value=v, unit=unit,
141 source=f"Layer X uncertainty sweep ({side})", provenance="estimated")])
142 out.append(Factor(key, label, "feed", basis, [case("low", lo), case("high", hi)], value, unit))
143
144 # ---- ullage collapse: a model choice ----
145 collapse_on = bool(getattr(getattr(prep, "setup", None), "ullage_collapse", prep.settings.ullage_collapse))
146 out.append(Factor("ullage_collapse", "Ullage collapse", "feed",
147 f"the collapse model switched {'off' if collapse_on else 'on'} (the feed twin's semi-infinite "
148 "conduction; a stated lower bound)",
149 [Case("ullage_collapse", "off" if collapse_on else "on", "off" if collapse_on else "on",
150 settings={"ullage_collapse": not collapse_on})]))
151
152 # ---- the engine, through EngineDesign's own measurement fields ----
153 ref = prep.link.reference if prep.link is not None else {}
154 ms = getattr(config, "measurements", None)
155 model_cd: Dict[str, float] = {}
156 if prep.link is not None and prep.link.sampler is not None:
157 # The model's Cd at T-0, from EngineDesign's full solve at the line exit (the card's fast
158 # path does not report it).
159 try:
160 full = prep.link.sampler.runner.evaluate(ref["p_O"], ref["p_F"], silent=True,
161 P_ambient=prep.ambient_pa)
162 model_cd = {"O": float(full.get("Cd_O", float("nan"))), "F": float(full.get("Cd_F", float("nan")))}
163 except Exception as exc: # noqa: BLE001
164 notes.append(f"Orifice Cd left out: EngineDesign's solve at T-0 failed ({type(exc).__name__}).")
165 for side, key, label in (("O", "cd_O", "LOX orifice Cd"), ("F", "cd_F", "Fuel orifice Cd")):
166 mv = getattr(ms, key, None) if ms is not None else None
167 nominal = float(mv.value) if mv is not None else model_cd.get(side, float("nan"))
168 if not math.isfinite(nominal):
169 notes.append(f"{label} left out: no nominal value to vary.")
170 continue
171 if mv is not None and mv.uncertainty is None:
172 notes.append(f"{label} is measured with no uncertainty stated; held exact.")
173 continue
174 d = float(mv.uncertainty) if mv is not None else 0.03 * nominal
175 basis = (f"measured {nominal:.3f} ± {d:.3f} ({mv.source})" if mv is not None
176 else f"±3 % of the model's {nominal:.3f}: the Lichtarowicz correlation's scatter")
177 out.append(Factor(f"engine.{key}", label, "engine", basis, [
178 Case(f"engine.{key}", "low", f"{nominal - d:.3f}", engine={"measure": key, "value": nominal - d}),
179 Case(f"engine.{key}", "high", f"{nominal + d:.3f}", engine={"measure": key, "value": nominal + d}),
180 ], nominal))
181 em = getattr(ms, "em", None) if ms is not None else None
182 if em is not None and em.uncertainty is None:
183 notes.append("Mixing E_m is measured with no uncertainty stated; held exact.")
184 elif em is not None:
185 e, u = float(em.value), float(em.uncertainty)
186 out.append(Factor("engine.em", "Mixing E_m", "engine", f"measured {e:.3f} ± {u:.3f} ({em.source})", [
187 Case("engine.em", "low", f"{e - u:.3f}", engine={"measure": "em", "value": e - u}),
188 Case("engine.em", "high", f"{e + u:.3f}", engine={"measure": "em", "value": e + u})], e))
189 else:
190 e0 = float(config.combustion.efficiency.rupe_Em_opt)
191 out.append(Factor("engine.em", "Mixing E_m", "engine",
192 f"Rupe E_m_opt 0.70 / 0.85 about the config's {e0:.2f}: the literature range the spray report uses", [
193 Case("engine.em", "low", "0.70", engine={"field": "combustion.efficiency.rupe_Em_opt", "value": 0.70}),
194 Case("engine.em", "high", "0.85", engine={"field": "combustion.efficiency.rupe_Em_opt", "value": 0.85})], e0))
195 zn = getattr(ms, "nozzle_efficiency", None) if ms is not None else None
196 cfg_z = getattr(config.chamber_geometry, "nozzle_efficiency", None)
197 z0 = float(zn.value) if zn is not None else (float(cfg_z) if cfg_z else None)
198 if z0 is None:
199 notes.append("Nozzle efficiency left out: the config states none to vary about.")
200 elif zn is not None and zn.uncertainty is None:
201 notes.append("Nozzle efficiency is measured with no uncertainty stated; held exact.")
202 else:
203 if zn is not None:
204 dz = float(zn.uncertainty)
205 lo_z, hi_z = z0 - dz, z0 + dz
206 basis = f"measured {z0:.3f} ± {dz:.3f} ({zn.source})"
207 else:
208 # Unsourced: from the config's value down 0.02, up to 0.99. An 80 % bell at this area
209 # ratio is typically 0.975-0.985 (divergence 0.983 alone), so the range must reach it.
210 lo_z, hi_z = z0 - 0.02, min(max(z0 + 0.02, 0.99), 0.995)
211 basis = f"{lo_z:.2f}–{hi_z:.2f} about the config's unsourced {z0:.2f}; an 80 % bell is typically 0.975–0.985"
212 out.append(Factor("engine.nozzle", "Nozzle efficiency", "engine", basis, [
213 Case("engine.nozzle", "low", f"{lo_z:.3f}", engine={"measure": "nozzle_efficiency", "value": lo_z}),
214 Case("engine.nozzle", "high", f"{hi_z:.3f}", engine={"measure": "nozzle_efficiency", "value": hi_z})], z0))
215
216 # ---- the drawing's own guesses: every estimated flow resistance, as one factor per kind ----
217 # Impulse cannot see these (a fixed load burns dry whatever the flow), but thrust, O/F, which
218 # tank runs dry and the injector's stiffness can: valve Cv 26 -> 8 moved thrust 1.3 % and
219 # flipped depletion on the 6.8 kN stand.
220 def guessed(param: Any, key: str) -> bool:
221 return (str(getattr(param.source, "value", param.source)).lower() in ("estimated", "default")
222 and key not in measured)
223
224 diagram = prep.model.diagram
225 groups = (
226 ("valve_cv", "Valve Cv", "Cv", [(f"node:{n.id}", n.params["Cv"]) for n in diagram.nodes
227 if n.type == "SOL" and "Cv" in n.params and guessed(n.params["Cv"], f"node:{n.id}.Cv")]),
228 ("line_k", "Line fitting losses", "K_minor", [(f"edge:{e.id}", e.params["K_minor"]) for e in diagram.edges
229 if "K_minor" in (e.params or {}) and float(e.params["K_minor"].value) > 0
230 and guessed(e.params["K_minor"], f"edge:{e.id}.K_minor")]),
231 ("line_length", "Line lengths", "length", [(f"edge:{e.id}", e.params["length"]) for e in diagram.edges
232 if "length" in (e.params or {}) and guessed(e.params["length"], f"edge:{e.id}.length")]),
233 )
234 for key, label, param, items in groups:
235 if not items:
236 continue
237
238 def scaled(side: str, k: float, param=param, items=items, key=key) -> Case:
239 return Case(f"drawing.{key}", side, f"×{k:g}", overrides=[Override(
240 target=t, parameter=param, value=float(p.value) * k, unit=str(p.unit),
241 source=f"Layer X uncertainty sweep ({side})", provenance="estimated") for t, p in items])
242 # More resistance is the "low" case for a Cv (smaller) and the "high" case for K and length.
243 cases = [scaled("low", 0.5), scaled("high", 1.5)]
244 out.append(Factor(f"drawing.{key}", label, "feed",
245 f"×0.5–1.5 of every estimated {param} on the drawing together ({len(items)} of them); "
246 "measure them, or restate them, to drop this", cases, unit=""))
247
248 # ---- the bottle's wall, which sets how much the expanding gas is warmed ----
249 copv_id = (getattr(prep, "derived", None) or {}).get("copv_id")
250 for b in [n for n in diagram.nodes if n.type == "KBOTTLE" and (copv_id is None or n.id == copv_id)][:1]:
251 p = b.params.get("wall_conductance")
252 if p is not None and guessed(p, f"node:{b.id}.wall_conductance"):
253 v = float(p.value)
254 out.append(Factor("drawing.bottle_wall", "Bottle wall heat transfer", "feed",
255 f"×0.25–2 of the drawing's estimated {v:g} {p.unit}: the bottle's spare at burnout moves with it",
256 [Case("drawing.bottle_wall", side, f"{v * k:g} {p.unit}", overrides=[Override(
257 target=f"node:{b.id}", parameter="wall_conductance", value=v * k, unit=str(p.unit),
258 source=f"Layer X uncertainty sweep ({side})", provenance="estimated")])
259 for side, k in (("low", 0.25), ("high", 2.0))], v, str(p.unit)))
260
261 # ---- model switches the burn runs without: each one-sided ----
262 # Each against what the burn actually ran: the feed twin's Setup, or the run's override of it.
263 for flag, label, why in (("line_walls", "Line-wall heat", "tubes and fittings warming the pressurant"),
264 ("ullage_vapour", "Propellant vapour", "boil-off into the ullage")):
265 on = bool(getattr(getattr(prep, "setup", None), flag, getattr(prep.settings, flag, False)))
266 out.append(Factor(flag, label, "feed", f"switched {'off' if on else 'on'}: {why}",
267 [Case(flag, "off" if on else "on", "off" if on else "on", settings={flag: not on})]))
268
269 # ---- the stand on the day: how well the operator can set what the rail says ----
270 lockup = float(prep.derived["target_lockup_psia"])
271 fill = float(prep.derived["copv_psig"])
272 # Each case clears the rail's own knob for what it moves: the dome dial and the absolute fill win
273 # over a tank pressure and a gauge fill in prepare(), so with either set on the rail both sides of
274 # these factors burned the nominal and read as a zero swing (prepare.at_operating_point).
275 out.append(Factor("op.lockup", "Tank pressure as set", "operation",
276 "±5 psi: the dome set by hand against a gauge (assumed)",
277 [Case("op.lockup", "low", f"{lockup - 5:.0f} psia",
278 settings={"tank_pressure_psia": lockup - 5, "dome_psia": None}),
279 Case("op.lockup", "high", f"{lockup + 5:.0f} psia",
280 settings={"tank_pressure_psia": lockup + 5, "dome_psia": None})], lockup, "psia"))
281 out.append(Factor("op.fill", "Bottle fill as filled", "operation",
282 "−150 / +0 psig: a fill that stops short, or cools after filling (assumed)",
283 [Case("op.fill", "low", f"{fill - 150:.0f} psig",
284 settings={"copv_pressure_psig": fill - 150, "copv_pressure_psia": None})], fill, "psig"))
285 return out, notes
286
287
288def _perturbed_config(config: Any, engine: Dict[str, Any]) -> Any:
289 if not engine:
290 return config
291 from engine.pipeline.config_schemas import MeasuredValue, MeasurementsConfig
292
293 cfg = config.model_copy(deep=True)
294 if "measure" in engine:
295 ms = cfg.measurements or MeasurementsConfig()
296 ms = ms.model_copy(update={engine["measure"]: MeasuredValue(value=float(engine["value"]),
297 source="Layer X uncertainty sweep")})
298 cfg.measurements = ms
299 else:
300 *head, last = engine["field"].split(".")
301 target = cfg
302 for part in head:
303 target = target[part] if isinstance(target, dict) else getattr(target, part)
304 setattr(target, last, float(engine["value"]))
305 return cfg
306
307
308def _metrics(summary: Dict[str, Any]) -> Dict[str, Optional[float]]:
309 out: Dict[str, Optional[float]] = {}
310 for name, path in METRICS.items():
311 v: Any = summary
312 for part in path:
313 v = v.get(part) if isinstance(v, dict) else None
314 out[name] = float(v) if isinstance(v, (int, float)) and math.isfinite(v) else None
315 return out
316
317
318# ------------------------------------------------------------------ workers
319
320_SEED: Dict[Any, Any] = {}
321
322
323# The pool and its worker seeding live in engine/layerx/pool.py; the names stay for old callers.
324from engine.layerx.pool import _init_worker # noqa: E402,F401
325
326
327def _burn_case(args: Tuple[Any, Any, Any, List[Any], Dict[str, Any]]) -> Dict[str, Any]:
328 """One case, start to finish, in whatever process runs it."""
329 config, drawing, settings, overrides, case = args
330 from engine.layerx.analysis import run_prepared
331 from engine.layerx.prepare import prepare
332
333 started = time.perf_counter()
334 try:
335 cfg = _perturbed_config(config, case.get("engine") or {})
336 # On the pad: a flown pass per case would multiply the sweep by the flight loop, and what
337 # ranks the inputs is the burn. The flight's own effect is the run's pad-against-flight table.
338 st = replace(settings, flight=False, **(case.get("settings") or {}))
339 prep = prepare(cfg, None, drawing, st, list(overrides) + list(case.get("overrides") or []))
340 if not prep.ok:
341 failing = [c.label for c in prep.checks if c.status == "fail"]
342 return {"ok": False, "error": "preflight: " + "; ".join(failing), "case": case["id"]}
343 res = run_prepared(prep, replay=False)
344 if res.get("tripped"):
345 # A burn stopped by a vessel trip is a failed case, not a short one: its totals end at the
346 # trip, and as a swing they would read as the input's effect on the burn.
347 return {"ok": False, "case": case["id"], "tripped": res["tripped"], "error": _trip_words(res["tripped"]),
348 "wall_s": time.perf_counter() - started}
349 s = res["summary"]
350 thrust = [(t, f) for t, f, on in zip(res["series"]["t"], res["series"]["chamber"]["thrust_N"],
351 res["series"]["firing"]) if on]
352 depleted = s.get("depleted_side") or ""
353 # What the tank that did not run dry still holds at burnout.
354 other = (s.get("fuel") if depleted == "oxidiser" else s.get("ox") if depleted == "fuel" else None) or {}
355 left = other.get("residual_kg")
356 return {"ok": True, "case": case["id"], "metrics": _metrics(s), "thrust": thrust,
357 "depleted": depleted, "lockup_psia": prep.derived.get("target_lockup_psia"),
358 "residual_kg": float(left) if isinstance(left, (int, float)) and math.isfinite(left) else None,
359 "wall_s": time.perf_counter() - started}
360 except Exception as exc: # noqa: BLE001 - one failed case is reported, the sweep goes on
361 out = {"ok": False, "case": case["id"], "error": f"{type(exc).__name__}: {exc}"}
362 record = getattr(exc, "record", None) # analysis.StandTripped: tripped in the settle
363 if isinstance(record, dict):
364 out.update({"tripped": record, "error": _trip_words(record)})
365 return out
366
367
368def _trip_words(trip: Dict[str, Any]) -> str:
369 """A tripped case, in the words a crossing lists it with."""
370 who = trip.get("label") or trip.get("vessel") or "a vessel"
371 p, m, t = trip.get("p_psia"), trip.get("mawp_psia"), trip.get("t")
372 at = f" at t = {float(t):.2f} s" if isinstance(t, (int, float)) else ""
373 over = f" ({float(p):.1f} psia, trips at {float(m):.1f})" if isinstance(p, (int, float)) and isinstance(m, (int, float)) else ""
374 return f"vessel trip: {who}{over}{at}"
375
376
377def run_sweep(config: Any, drawing: Any, settings: Any, overrides: List[Any], *,
378 progress: Optional[Callable[[str, float], None]] = None,
379 cancelled: Callable[[], bool] = lambda: False,
380 workers: Optional[int] = None) -> Dict[str, Any]:
381 """The nominal burn and every factor's cases, in parallel; the tornado and the band."""
382 from engine.layerx.pool import WorkerPool, default_workers
383 from engine.layerx.prepare import prepare
384
385 say = progress or (lambda stage, fraction: None)
386 started = time.perf_counter()
387 say("Preparing the nominal burn", 0.02)
388 flown = bool(getattr(settings, "flight", False))
389 st = replace(settings, replay=False, flight=False) if hasattr(settings, "replay") else settings
390 nominal_prep = prepare(config, None, drawing, st, overrides)
391 if not nominal_prep.ok:
392 raise ValueError("preflight has failing checks; fix them before sweeping")
393 found, notes = factors(nominal_prep, config)
394 if flown:
395 notes.append("Swept on the pad at one g: the flight's own effect is the run's pad-against-flight table.")
396
397 tasks: List[Dict[str, Any]] = [{"id": "nominal"}]
398 for f in found:
399 for c in f.cases:
400 tasks.append({"id": f"{f.key}|{c.side}", "overrides": c.overrides, "settings": c.settings,
401 "engine": c.engine})
402 n_workers = default_workers(len(tasks), workers)
403 args = [(config, drawing, st, overrides, t) for t in tasks]
404 say(f"Burning {len(tasks)} cases on {n_workers} workers", 0.05)
405 with WorkerPool(n_workers) as pool:
406 done = pool.map(_burn_case, args, cancelled=cancelled,
407 on_result=lambda k, r: say(f"Case {k} of {len(args)} done", 0.05 + 0.9 * k / len(args)))
408 results: Dict[str, Dict[str, Any]] = {r["case"]: r for r in done}
409
410 base = results.get("nominal")
411 if not base or not base.get("ok"):
412 # A nominal burn that trips has no swings to measure against (base["error"] says which vessel).
413 raise RuntimeError(f"the nominal case failed: {base.get('error') if base else 'no result'}")
414 nominal = base["metrics"]
415 rows = []
416 for f in found:
417 entry = {"key": f.key, "label": f.label, "group": f.group, "basis": f.basis, "cases": {}}
418 for c in f.cases:
419 r = results.get(f"{f.key}|{c.side}") or {"ok": False, "error": "no result"}
420 if not r.get("ok"):
421 entry["cases"][c.side] = {"label": c.label, "ok": False, "error": r.get("error")}
422 if r.get("tripped"):
423 entry["cases"][c.side]["tripped"] = r["tripped"]
424 continue
425 delta = {k: (v - nominal[k]) if v is not None and nominal.get(k) is not None else None
426 for k, v in r["metrics"].items()}
427 entry["cases"][c.side] = {"label": c.label, "ok": True, "metrics": r["metrics"], "delta": delta,
428 "thrust": r["thrust"]}
429 swing = {}
430 for k in METRICS:
431 ds = [abs(cs["delta"][k]) for cs in entry["cases"].values() if cs.get("ok") and cs["delta"].get(k) is not None]
432 swing[k] = max(ds) if ds else None
433 entry["swing"] = swing
434 rows.append(entry)
435 rows.sort(key=lambda e: -(e["swing"].get("total_impulse_Ns") or 0.0))
436 band = {}
437 band_low: Dict[str, Optional[float]] = {}
438 band_high: Dict[str, Optional[float]] = {}
439 for k in METRICS:
440 parts = [e["swing"][k] for e in rows if e["swing"].get(k) is not None]
441 band[k] = math.sqrt(sum(p * p for p in parts)) if parts else None
442 # Each side on its own: a factor that only moves the answer down adds nothing above it.
443 downs, ups = [], []
444 for e in rows:
445 ds = [cs["delta"][k] for cs in e["cases"].values() if cs.get("ok") and cs["delta"].get(k) is not None]
446 if ds:
447 downs.append(min(min(ds), 0.0))
448 ups.append(max(max(ds), 0.0))
449 band_low[k] = math.sqrt(sum(d * d for d in downs)) if downs else None
450 band_high[k] = math.sqrt(sum(u * u for u in ups)) if ups else None
451
452 # Every case graded against the limits a run is graded by: which inputs, at the end of their
453 # range, break the injector's stiffness floor or the bottle's margin, or flip the tank that
454 # runs dry first.
455 band_cfg = nominal_prep.derived.get("stiffness_band") or {}
456 floor = {"ox": (band_cfg.get("oxidiser") or [STIFFNESS_FLOOR])[0], "fuel": (band_cfg.get("fuel") or [STIFFNESS_FLOOR])[0]}
457 nominal_side = base.get("depleted", "")
458 crossings: List[Dict[str, Any]] = []
459 for e in rows:
460 for side, cs in e["cases"].items():
461 if cs.get("tripped"):
462 # The input, at this end of its range, trips the stand: the strongest crossing there is.
463 cs["breaks"] = [cs.get("error") or "vessel trip"]
464 crossings.append({"factor": e["label"], "side": side, "case": cs["label"], "breaks": cs["breaks"],
465 "tripped": True})
466 continue
467 if not cs.get("ok"):
468 continue
469 r = results.get(f"{e['key']}|{side}") or {}
470 m = cs["metrics"]
471 breaks = []
472 for name, key in (("LOX ΔP/Pc", "ox"), ("Fuel ΔP/Pc", "fuel")):
473 v = m.get(f"{key}_stiffness_min")
474 if v is not None and v < floor[key]:
475 breaks.append(f"{name} {v * 100:.1f} % < {floor[key] * 100:.0f} %")
476 spare = (m["copv_end_psia"] - float(r.get("lockup_psia") or 0.0)) if m.get("copv_end_psia") is not None else None
477 if spare is not None and spare < SPARE_PSI:
478 breaks.append(f"bottle spare {spare:.0f} psi < {SPARE_PSI:.0f}")
479 if r.get("depleted") and nominal_side and r["depleted"] != nominal_side:
480 # A flip is a crossing only when it strands propellant: with the loads at the burn's O/F
481 # either tank may empty first by grams (0.003 kg left on LE4 (3)), the Cd scatter's call.
482 left = r.get("residual_kg")
483 if left is None or left > RESIDUAL_KG:
484 word = {"oxidiser": "LOX", "fuel": "fuel"}
485 held = "fuel" if r["depleted"] == "oxidiser" else "LOX"
486 breaks.append(f"{word.get(nominal_side, nominal_side)}-first becomes "
487 f"{word.get(r['depleted'], r['depleted'])}-first"
488 + (f", {left:.2f} kg of {held} left > {RESIDUAL_KG:g} kg" if left is not None else ""))
489 cs["breaks"] = breaks
490 if breaks:
491 crossings.append({"factor": e["label"], "side": side, "case": cs["label"], "breaks": breaks})
492 failed = [e["key"] + "|" + side for e in rows for side, cs in e["cases"].items()
493 if not cs.get("ok") and not cs.get("tripped")]
494 trips = [e["key"] + "|" + side for e in rows for side, cs in e["cases"].items() if cs.get("tripped")]
495 say("Done", 1.0)
496 return {
497 "nominal": nominal,
498 "nominal_thrust": base["thrust"],
499 "factors": rows,
500 "band": band,
501 "band_low": band_low,
502 "band_high": band_high,
503 "crossings": crossings,
504 "notes": notes + ([f"{len(failed)} case(s) failed: {', '.join(failed)}"] if failed else [])
505 + ([f"{len(trips)} case(s) tripped the stand and are left out of the swings (see the crossings): "
506 f"{', '.join(trips)}"] if trips else []),
507 "cases": len(tasks),
508 "workers": n_workers,
509 "wall_s": time.perf_counter() - started,
510 "basis": ("One factor at a time, low and high, everything else nominal. Below and above are each the "
511 "root-sum-square of the factors' swings that way; ranges are bounds unless a measurement "
512 "states a one-sigma. Burns on the pad, without the erosion replay."),
513 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
Any _perturbed_config(Any config, Dict[str, Any] engine)
Dict[str, Optional[float]] _metrics(Dict[str, Any] summary)
Dict[str, Any] _burn_case(Tuple[Any, Any, Any, List[Any], Dict[str, Any]] args)
str _trip_words(Dict[str, Any] trip)
Dict[str, Any] run_sweep(Any config, Any drawing, Any settings, List[Any] overrides, *, Optional[Callable[[str, float], None]] progress=None, Callable[[], bool] cancelled=lambda:False, Optional[int] workers=None)
Any _get(Any obj, str path)
Tuple[List[Factor], List[str]] factors(Any prep, Any config)