STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
layerx_baseline.py
Go to the documentation of this file.
1"""LE4 Layer X baseline ("golden") burns.
2
3Runs Layer X in-process (no HTTP server) at today's default settings and reduces each burn to
4the numbers a design review reads. The JSON it writes is the reference for the overnight rule
5"a change that moves the LE4 baseline by more than 1 % must be reported"; the slow test
6``tests/test_layerx_golden.py`` holds the pad helium case to it.
7
8Cases (the engine is LE4, ``configs/ethalox_6800N.yaml`` unless ``--config`` says otherwise)::
9
10 he_pad copv_study_he on the pad, erosion replay on (the hot-fire drawing)
11 he_flight the same, flown at the design's liftoff mass (``liftoff_mass_kg`` unset: the
12 config's airframe plus motor, propellant and gases)
13 gn2_pad copv_study_gn2 on the pad (the drawing the Layer X tab selects by default)
14
15Supplementary (``--all`` or ``--case``)::
16
17 gn2_flight copv_study_gn2 flown: the tab's out-of-the-box run (the UI defaults flight on)
18 he_flight_he_ullage he_flight with config.{lox,fuel}_tank.ullage_gas = Helium in the run's copy
19 of the config: a what-if, because at defaults the flight prices the ullage
20 refill as nitrogen and refuses the helium bottle (see CASES)
21
22Settings are ``LayerXSettings``'s defaults except ``drawing_id``, ``flight`` and ``--dt``. The UI's
23``DEFAULT_SETTINGS`` (frontend/src/api/layerx.ts) equal them except that the UI turns ``flight``
24on; the pad cases are therefore one toggle away from the tab's out-of-the-box run.
25
26The run is the router's (backend/routers/layerx.py ``start_run``): a private deepcopy of the
27config, its own ``PintleEngineRunner`` (so the Forward cross-check runs), no restated drawing
28parameters, ``run_prepared(prep, runner=..., replay=settings.replay, config=...)``.
29
30Usage::
31
32 cd EngineDesign
33 PYTHONPATH=../lib/stardesign python3 scripts/layerx_baseline.py # the baseline's three
34 PYTHONPATH=../lib/stardesign python3 scripts/layerx_baseline.py --all \\
35 --out docs/layerx/baseline-2026-10-02.json # + supplementary
36 PYTHONPATH=../lib/stardesign python3 scripts/layerx_baseline.py --case he_pad --dt 0.02
37 PYTHONPATH=../lib/stardesign python3 scripts/layerx_baseline.py --config path/to/current.json
38
39``--config`` takes a YAML config, an app document (``.userdata/.../current.json``, its ``config``
40key), or a baseline JSON written by this script (its embedded ``inputs.config``), so a baseline
41can be re-burned on exactly the design it was taken on.
42
43Importable: ``run_case`` and ``run_baseline`` return plain dicts.
44"""
45
46from __future__ import annotations
47
48import argparse
49import copy
50import hashlib
51import json
52import math
53import platform
54import subprocess
55import sys
56import time
57from pathlib import Path
58from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
59
60ROOT = Path(__file__).resolve().parents[1]
61if str(ROOT) not in sys.path:
62 sys.path.insert(0, str(ROOT))
63
64PSI = 6894.757293168361
65DEFAULT_CONFIG = ROOT / "configs" / "ethalox_6800N.yaml"
66
67#: name -> drawing, flown, what it is. ``supplementary`` cases are not the baseline's three; they are
68#: run by ``--all`` (or by name) and say what they change. ``config_patch`` is applied to the run's
69#: private copy of the config only ({section: {field: value}}), never to a file.
70#: Tank lockup every case burns at [psia]: the study drawings' as-drawn dome gave it until the
71#: supply effect was measured from zero inlet (2026-10-07), which takes 17 x 4.5 = 76.5 psi off
72#: it at a full bottle. Pinned, so Layer X solves the dome for it and the baseline keeps meaning
73#: the same burn.
74STUDY_LOCKUP_PSIA = 564.7
75
76CASES: Dict[str, Dict[str, Any]] = {
77 "he_pad": {"drawing": "copv_study_he", "flight": False,
78 "label": "Helium hot-fire drawing, on the pad, erosion replay on"},
79 "he_flight": {"drawing": "copv_study_he", "flight": True,
80 "label": "Helium hot-fire drawing, flown at the design liftoff mass"},
81 # Nitrogen over LOX is refused for a hot fire since 2026-10-03 (prepare.gn2_on_lox); the GN2 study
82 # cases burn it anyway, acknowledged, so they stay comparable with the baseline (``settings``).
83 "gn2_pad": {"drawing": "copv_study_gn2", "flight": False, "settings": {"ack_gn2_condensation": True},
84 "label": "GN2 drawing (the Layer X tab's default drawing), on the pad"},
85 # The tab's out-of-the-box run: the GN2 drawing with the UI's flight: true.
86 "gn2_flight": {"drawing": "copv_study_gn2", "flight": True, "supplementary": True,
87 "settings": {"ack_gn2_condensation": True},
88 "label": "GN2 drawing flown at the design liftoff mass (the UI's DEFAULT_SETTINGS run)"},
89 # The flight simulation prices the ullage refill with config.<tank>.ullage_gas (Nitrogen in LE4's
90 # config) whatever gas the drawing's bottle holds, so he_flight fails its COPV budget at defaults.
91 # This what-if names the drawing's gas in the run's copy of the config so the helium burn can fly.
92 "he_flight_he_ullage": {"drawing": "copv_study_he", "flight": True, "supplementary": True,
93 "config_patch": {"lox_tank": {"ullage_gas": "Helium"},
94 "fuel_tank": {"ullage_gas": "Helium"}},
95 "label": "Helium drawing flown, config ullage_gas set to Helium (what-if, not default)"},
96}
97BASELINE_CASES = tuple(k for k, v in CASES.items() if not v.get("supplementary"))
98
99
100def _patched(config: Any, patch: Optional[Dict[str, Dict[str, Any]]]) -> Any:
101 if not patch:
102 return config
103 from engine.pipeline.config_schemas import PintleEngineConfig
104
105 raw = config.model_dump(mode="json")
106 for section, fields in patch.items():
107 raw.setdefault(section, {}).update(fields)
108 return PintleEngineConfig(**raw)
109
110
111# ------------------------------------------------------------------ inputs
112
113
114def _sha256_file(path: Optional[Path]) -> Optional[str]:
115 if path is None or not Path(path).is_file():
116 return None
117 h = hashlib.sha256()
118 with open(path, "rb") as f:
119 for chunk in iter(lambda: f.read(1 << 20), b""):
120 h.update(chunk)
121 return h.hexdigest()
122
123
124def _sha256_dir(path: Path, pattern: str = "*") -> Optional[str]:
125 if not path.is_dir():
126 return None
127 h = hashlib.sha256()
128 for p in sorted(path.glob(pattern)):
129 if p.is_file():
130 h.update(p.name.encode())
131 h.update(p.read_bytes())
132 return h.hexdigest()
133
134
135def load_engine_config(source: Any = None) -> Tuple[Any, Dict[str, Any]]:
136 """``(config, info)``. ``source``: None (LE4's YAML), a path (YAML, an app document JSON, or a
137 baseline JSON from this script), or an already-built config dict."""
138 from engine.pipeline.config_schemas import PintleEngineConfig
139 from engine.pipeline.io import load_config
140
141 if source is None:
142 source = DEFAULT_CONFIG
143 if isinstance(source, dict):
144 return PintleEngineConfig(**source), {"kind": "dict", "path": None}
145 path = Path(source)
146 if path.suffix.lower() in (".yaml", ".yml"):
147 return load_config(str(path)), {"kind": "yaml", "path": str(path)}
148 raw = json.loads(path.read_text())
149 if isinstance(raw.get("inputs"), dict) and isinstance(raw["inputs"].get("config"), dict):
150 return PintleEngineConfig(**raw["inputs"]["config"]), {"kind": "baseline", "path": str(path)}
151 if isinstance(raw.get("config"), dict):
152 return PintleEngineConfig(**raw["config"]), {"kind": "app document", "path": str(path)}
153 raise ValueError(f"{path}: not a YAML config, an app document or a baseline JSON")
154
155
156def find_drawing(name: str) -> Any:
157 from engine.layerx import DrawingStore
158
159 found = {d.name: d for d in DrawingStore(None).list()}
160 if name not in found:
161 raise FileNotFoundError(f"shipped drawing {name!r} not found (have: {', '.join(sorted(found))})")
162 return found[name]
163
164
165# ------------------------------------------------------------------ reduction
166
167
168def _num(v: Any) -> Optional[float]:
169 try:
170 f = float(v)
171 except (TypeError, ValueError):
172 return None
173 return f if math.isfinite(f) else None
174
175
176def _first(*values: Any) -> Optional[float]:
177 for v in values:
178 f = _num(v)
179 if f is not None:
180 return f
181 return None
182
183
184def metrics(result: Dict[str, Any], prep: Any) -> Dict[str, Any]:
185 """The baseline's figures from a ``run_prepared`` result.
186
187 Engine quantities (impulse, thrust, Pc, Isp) are the *delivered* ones, EngineDesign's eroding
188 replay, exactly as the Layer X tab's headline reads them (LayerXResult.tsx ``figureCells``);
189 the twin's own are kept beside them. Feed quantities (burn time, tanks, bottle, O/F, stiffness)
190 are the twin's last pass, also as the tab reads them."""
191 s = result["summary"]
192 d = ((result.get("delivered") or {}).get("summary")) or {}
193 ox, fu = s["ox"], s["fuel"]
194 lockup_ui = max(ox["t0_psia"], fu["t0_psia"]) # LayerXResult.tsx verdictItems
195 target = float(prep.derived.get("target_lockup_psia") or 0.0)
196 ambient_psia = float(prep.ambient_pa) / PSI
197 copv_end = _num(s.get("copv_end_psia"))
198 side = s.get("depleted_side") or ""
199 other = fu if side == "oxidiser" else ox if side == "fuel" else None
200
201 # Tank peaks during the burn alone (peak_psia also covers the hold).
202 series = result["series"]
203 fire = [i for i, f in enumerate(series["firing"]) if f]
204 burn_peak = {k: (max(series[k]["tank_psia"][i] for i in fire) if fire else None) for k in ("ox", "fuel")}
205
206 out: Dict[str, Any] = {
207 # ---- performance (delivered: the replay's engine on the twin's steps)
208 "total_impulse_Ns": _first(d.get("total_impulse_Ns"), s.get("total_impulse_Ns")),
209 "burn_time_s": _num(s.get("burn_time_s")),
210 "mean_thrust_N": _first(d.get("mean_thrust_N"), s.get("mean_thrust_N")),
211 "min_thrust_N": _first(d.get("min_thrust_N"), s.get("min_thrust_N")),
212 "max_thrust_N": _first(d.get("peak_thrust_N"), s.get("peak_thrust_N")),
213 "pc_mean_psia": _first(d.get("pc_mean_psia"), s.get("pc_mean_psia")),
214 "pc_min_psia": _first(d.get("pc_min_psia"), s.get("pc_min_psia")),
215 "pc_max_psia": _first(d.get("pc_max_psia"), s.get("pc_max_psia")),
216 "of_mean": _num(s.get("of_mean")),
217 "of_min": _num(s.get("of_min")),
218 "of_max": _num(s.get("of_max")),
219 "isp_mean_s": _first(d.get("isp_mean_s"), s.get("isp_mean_s")),
220 "propellant_used_kg": _num(s.get("propellant_used_kg")),
221 # ---- the twin's own engine figures (card, as-built thrust law), for the record
222 "twin": {
223 "total_impulse_Ns": _num(s.get("total_impulse_Ns")),
224 "impulse_to_depletion_Ns": _num(s.get("impulse_to_depletion_Ns")),
225 "mean_thrust_N": _num(s.get("mean_thrust_N")),
226 "min_thrust_N": _num(s.get("min_thrust_N")),
227 "max_thrust_N": _num(s.get("peak_thrust_N")),
228 "thrust_t0_N": _num(s.get("thrust_t0_N")),
229 "pc_mean_psia": _num(s.get("pc_mean_psia")),
230 "isp_mean_s": _num(s.get("isp_mean_s")),
231 },
232 # ---- pressurant
233 "lockup_target_psia": target,
234 "lockup_t0_psia": lockup_ui,
235 "dome_psig": _num(prep.derived.get("dome_psig")),
236 "copv_t0_psia": _num(s.get("copv_t0_psia")),
237 "copv_end_psia": copv_end,
238 "copv_over_lockup_psi": (copv_end - lockup_ui) if copv_end is not None else None,
239 "copv_over_tank_end_psi": (copv_end - max(ox["end_psia"], fu["end_psia"])) if copv_end is not None else None,
240 "copv_used_kg": _num(s.get("copv_used_kg")),
241 "pressurant": prep.derived.get("pressurant_gas"),
242 # ---- tanks
243 "ox_tank": {
244 "t0_psia": ox["t0_psia"], "min_psia": ox["min_psia"], "end_psia": ox["end_psia"],
245 "peak_psia": ox["peak_psia"], "peak_burn_psia": burn_peak["ox"],
246 "peak_across_wall_psi": ox["peak_psia"] - ambient_psia,
247 "ignition_dip_psi": ox["ignition_dip_psi"], "inlet_mean_psia": ox["inlet_mean_psia"],
248 "loaded_kg": ox["loaded_kg"], "residual_kg": ox["residual_kg"],
249 },
250 "fuel_tank": {
251 "t0_psia": fu["t0_psia"], "min_psia": fu["min_psia"], "end_psia": fu["end_psia"],
252 "peak_psia": fu["peak_psia"], "peak_burn_psia": burn_peak["fuel"],
253 "peak_across_wall_psi": fu["peak_psia"] - ambient_psia,
254 "ignition_dip_psi": fu["ignition_dip_psi"], "inlet_mean_psia": fu["inlet_mean_psia"],
255 "loaded_kg": fu["loaded_kg"], "residual_kg": fu["residual_kg"],
256 },
257 "tank_mawp_psi": prep.derived.get("tank_mawp_psi"),
258 # ---- injector stiffness (manifold to chamber over chamber; settled = after 0.2 s)
259 "ox_stiffness_min": _num(ox.get("stiffness_min")),
260 "fuel_stiffness_min": _num(fu.get("stiffness_min")),
261 "ox_stiffness_min_ignition": _num(ox.get("stiffness_min_ignition")),
262 "fuel_stiffness_min_ignition": _num(fu.get("stiffness_min_ignition")),
263 "ox_dp_injector_min_psi": _num(ox.get("dp_injector_min_psi")),
264 "fuel_dp_injector_min_psi": _num(fu.get("dp_injector_min_psi")),
265 "stiffness_band": prep.derived.get("stiffness_band"),
266 # ---- chug (EngineDesign's gain margin along the replay, worst over the mixing-lag band)
267 "chug_margin_min": _num(d.get("chug_margin_min")),
268 "chug_margin_min_t_s": _num(d.get("chug_margin_min_t")),
269 # ---- depletion
270 "depleted_side": side,
271 "depleted_tank": s.get("depleted_tank"),
272 "residual_other_kg": (other["residual_kg"] if other is not None else None),
273 "residual_depleted_kg": ((ox if side == "oxidiser" else fu)["residual_kg"] if side else None),
274 # ---- throat
275 "throat_area_growth": _num(d.get("throat_area_growth")),
276 "throat_recession_mm": _num(d.get("throat_recession_mm")),
277 # ---- solver health
278 "converged": result.get("converged"),
279 "passes": len(result.get("passes") or []) or 1,
280 "steps": s.get("steps"),
281 "failed_steps": s.get("failed_steps"),
282 "extrapolated_steps": s.get("extrapolated_steps"),
283 "card_outside_steps": s.get("card_outside_steps"),
284 "t0_settled": s.get("t0_settled"),
285 "engine_check_worst": (result.get("engine_check") or {}).get("worst"),
286 "replay_agreement_worst": (((result.get("passes") or [{}])[-1].get("agreement") or {}).get("worst")),
287 "cross_check": {r["key"]: r.get("rel") for r in ((result.get("cross_check") or {}).get("rows") or [])},
288 "wall_s": _num((result.get("provenance") or {}).get("wall_s")),
289 }
290 fl = result.get("flight")
291 if fl is not None:
292 ok = bool(fl.get("ok"))
293 out["flight"] = {
294 "ok": ok,
295 "error": fl.get("error"),
296 "apogee_agl_m": _num(fl.get("apogee_agl_m")) if ok else None,
297 "apogee_agl_ft": (_num(fl.get("apogee_agl_m")) or 0.0) / 0.3048 if ok else None,
298 "apogee_msl_m": _num(fl.get("apogee_msl_m")) if ok else None,
299 "apogee_time_s": _num(fl.get("apogee_time_s")) if ok else None,
300 "max_velocity_m_s": _num(fl.get("max_velocity_m_s")) if ok else None,
301 "max_mach": _num(fl.get("max_mach")) if ok else None,
302 "rail_exit_velocity_m_s": _num(fl.get("rail_exit_velocity_m_s")) if ok else None,
303 "liftoff_mass_kg": _num(fl.get("liftoff_mass_kg")) if ok else None,
304 "burnout_mass_kg": _num(fl.get("burnout_mass_kg")) if ok else None,
305 "liftoff_accel_g": _num(fl.get("liftoff_accel_g")) if ok else None,
306 "max_accel_g": _num(fl.get("max_accel_g")) if ok else None,
307 "pad": fl.get("pad"),
308 "in_flight": fl.get("in_flight"),
309 "mass_budget": fl.get("mass_budget"),
310 }
311 out["apogee_agl_m"] = (out.get("flight") or {}).get("apogee_agl_m")
312 return out
313
314
315# ------------------------------------------------------------------ running
316
317
318def _git_describe() -> str:
319 try:
320 r = subprocess.run(["git", "describe", "--always", "--dirty"], cwd=ROOT, capture_output=True, text=True, timeout=5)
321 return r.stdout.strip() or "unknown"
322 except (OSError, subprocess.SubprocessError):
323 return "unknown"
324
325
326def _versions() -> Dict[str, Any]:
327 out: Dict[str, Any] = {"python": platform.python_version(), "platform": platform.platform()}
328 for mod in ("numpy", "scipy", "CoolProp", "fluids", "rocketpy", "numba", "feedtwin"):
329 try:
330 m = __import__(mod)
331 out[mod] = getattr(m, "__version__", "?")
332 except Exception: # noqa: BLE001 - a missing optional module is recorded, not fatal
333 out[mod] = None
334 return out
335
336
337def run_case(name: str, *, config: Any = None, config_source: Any = None, dt: Optional[float] = None,
338 progress: Optional[Callable[[str, float], None]] = None, keep_result: bool = False) -> Dict[str, Any]:
339 """Burn one case and return ``{"case", "settings", "inputs", "metrics", ...}``.
340
341 ``config``: an engine config to use (it is deep-copied, as the router does); otherwise
342 ``config_source`` is loaded with :func:`load_engine_config`. ``dt`` overrides the default step."""
343 from engine.core.runner import PintleEngineRunner
344 from engine.layerx import LayerXSettings, prepare, run_prepared
345 from engine.layerx.fingerprint import config_fingerprint
346 from engine.layerx.sources import cea_table_path, machines_dir
347
348 spec = CASES[name]
349 info: Dict[str, Any] = {"kind": "given", "path": None}
350 if config is None:
351 config, info = load_engine_config(config_source)
352 drawing = find_drawing(spec["drawing"])
353 kwargs: Dict[str, Any] = {"drawing_id": drawing.id, "flight": bool(spec["flight"]),
354 "tank_pressure_psia": STUDY_LOCKUP_PSIA, **(spec.get("settings") or {})}
355 if dt is not None:
356 kwargs["dt"] = float(dt)
357 settings = LayerXSettings(**kwargs)
358
359 started = time.perf_counter()
360 cfg = _patched(copy.deepcopy(config), spec.get("config_patch"))
361 runner = PintleEngineRunner(cfg)
362 prep = prepare(cfg, runner, drawing, settings, [])
363 prep_s = time.perf_counter() - started
364 failing = [f"{c.key}: {c.detail}" for c in prep.checks if c.status == "fail"]
365 if not prep.ok:
366 raise RuntimeError(f"{name}: preflight failed: {failing}")
367 result = run_prepared(prep, runner=runner, replay=prep.settings.replay, config=cfg,
368 progress=progress or (lambda stage, frac: None))
369 total_s = time.perf_counter() - started
370 m = metrics(result, prep)
371 m["wall_total_s"] = total_s
372 m["wall_prepare_s"] = prep_s
373 cea = cea_table_path(cfg)
374 out = {
375 "case": name,
376 "label": spec["label"],
377 "supplementary": bool(spec.get("supplementary")),
378 "config_patch": spec.get("config_patch"),
379 "settings": {k: v for k, v in settings.__dict__.items()},
380 "inputs": {
381 "config_source": info,
382 "config_sha256": config_fingerprint(config),
383 "config_sha256_burned": config_fingerprint(cfg),
384 "drawing": {"name": drawing.name, "id": drawing.id, "sha256": drawing.sha256, "source": drawing.source},
385 "cea_table": {"name": cea.name if cea else None, "sha256": _sha256_file(cea)},
386 "state_machines_sha256": _sha256_dir(machines_dir()),
387 "ambient_pa": prep.ambient_pa,
388 },
389 "checks": [{"key": c.key, "status": c.status, "detail": c.detail} for c in prep.checks
390 if c.status in ("warn", "fail")],
391 "metrics": m,
392 }
393 if keep_result:
394 out["result"] = result
395 return out
396
397
398def run_baseline(cases: Optional[Iterable[str]] = None, *, config_source: Any = None, dt: Optional[float] = None,
399 embed_config: bool = True, verbose: bool = True) -> Dict[str, Any]:
400 """Every case in ``cases`` (all by default) on one config. The config is embedded so the
401 baseline can be burned again on exactly this design (``--config <baseline.json>``)."""
402 from dataclasses import asdict
403
404 from engine.layerx import LayerXSettings
405 from engine.layerx.fingerprint import config_fingerprint
406
407 config, info = load_engine_config(config_source)
408 names = list(cases or BASELINE_CASES)
409 out: Dict[str, Any] = {
410 "what": "LE4 Layer X baseline: in-process burns at today's default LayerXSettings",
411 "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
412 "code": _git_describe(),
413 "versions": _versions(),
414 "config_source": info,
415 "config_sha256": config_fingerprint(config),
416 # The library's defaults the cases ran on (drawing_id aside), so a changed default is seen.
417 "layerx_settings_defaults": {k: v for k, v in asdict(LayerXSettings(drawing_id="")).items()
418 if k != "drawing_id"},
419 "cases": {},
420 }
421 for name in names:
422 if verbose:
423 print(f"[layerx_baseline] {name}: {CASES[name]['label']} ...", flush=True)
424 out["cases"][name] = run_case(name, config=config, dt=dt)
425 out["cases"][name]["inputs"]["config_source"] = info
426 if verbose:
427 print(format_case(out["cases"][name]), flush=True)
428 if embed_config:
429 out["inputs"] = {"config": config.model_dump(mode="json")}
430 return out
431
432
433# ------------------------------------------------------------------ printing
434
435
436def _f(v: Any, nd: int = 1, unit: str = "") -> str:
437 f = _num(v)
438 return "-" if f is None else f"{f:,.{nd}f}{(' ' + unit) if unit else ''}"
439
440
441def format_case(case: Dict[str, Any]) -> str:
442 m = case["metrics"]
443 o, fu = m["ox_tank"], m["fuel_tank"]
444 lines = [
445 f" {case['case']} ({case['inputs']['drawing']['name']}, {m['pressurant']}, dt {case['settings']['dt']} s)",
446 f" impulse {_f(m['total_impulse_Ns'], 0, 'N·s')} burn {_f(m['burn_time_s'], 3, 's')} "
447 f"thrust mean/min/max {_f(m['mean_thrust_N'], 0)}/{_f(m['min_thrust_N'], 0)}/{_f(m['max_thrust_N'], 0)} N",
448 f" Pc mean {_f(m['pc_mean_psia'], 1, 'psia')} ({_f(m['pc_min_psia'], 1)}-{_f(m['pc_max_psia'], 1)}) "
449 f"O/F {_f(m['of_mean'], 4)} Isp {_f(m['isp_mean_s'], 2, 's')} burned {_f(m['propellant_used_kg'], 3, 'kg')}",
450 f" bottle {_f(m['copv_t0_psia'], 0)} -> {_f(m['copv_end_psia'], 1, 'psia')} at burnout, "
451 f"{_f(m['copv_over_lockup_psi'], 1, 'psi')} over lockup ({_f(m['copv_over_tank_end_psi'], 1, 'psi')} over the tanks at end), "
452 f"{_f(m['copv_used_kg'], 4, 'kg')} used",
453 f" tanks LOX t0/min/end/peak {_f(o['t0_psia'])}/{_f(o['min_psia'])}/{_f(o['end_psia'])}/{_f(o['peak_psia'])} psia "
454 f"fuel {_f(fu['t0_psia'])}/{_f(fu['min_psia'])}/{_f(fu['end_psia'])}/{_f(fu['peak_psia'])} psia",
455 f" min dP/Pc LOX {_f((m['ox_stiffness_min'] or 0) * 100, 2, '%')} (ign {_f((m['ox_stiffness_min_ignition'] or 0) * 100, 2, '%')}) "
456 f"fuel {_f((m['fuel_stiffness_min'] or 0) * 100, 2, '%')} (ign {_f((m['fuel_stiffness_min_ignition'] or 0) * 100, 2, '%')})",
457 f" chug margin min {_f(m['chug_margin_min'], 3)} at {_f(m['chug_margin_min_t_s'], 2, 's')} "
458 f"depleted {m['depleted_side']} ({_f(m['residual_other_kg'], 4, 'kg')} of the other left) "
459 f"throat +{_f((m['throat_area_growth'] or 0) * 100, 2, '%')} area, {_f(m['throat_recession_mm'], 3, 'mm')}",
460 f" converged {m['converged']} in {m['passes']} pass(es), failed steps {m['failed_steps']}, "
461 f"wall {_f(m['wall_total_s'], 1, 's')} (prepare {_f(m['wall_prepare_s'], 1, 's')})",
462 ]
463 fl = m.get("flight")
464 if fl is not None:
465 if fl.get("ok"):
466 lines.append(f" apogee {_f(fl['apogee_agl_m'], 0, 'm')} AGL ({_f(fl['apogee_agl_ft'], 0, 'ft')}), "
467 f"liftoff {_f(fl['liftoff_mass_kg'], 2, 'kg')} at {_f(fl['liftoff_accel_g'], 2, 'g')}, "
468 f"max {_f(fl['max_accel_g'], 2, 'g')}, Mach {_f(fl['max_mach'], 2)}")
469 else:
470 lines.append(f" flight FAILED: {fl.get('error')}")
471 return "\n".join(lines)
472
473
474def _json_default(o: Any) -> Any:
475 if hasattr(o, "item"):
476 return o.item()
477 return str(o)
478
479
480def main(argv: Optional[List[str]] = None) -> int:
481 ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
482 ap.add_argument("--case", action="append", choices=sorted(CASES),
483 help=f"case to run (repeatable; default the baseline's {', '.join(BASELINE_CASES)})")
484 ap.add_argument("--all", action="store_true", help="the baseline's cases and the supplementary ones")
485 ap.add_argument("--dt", type=float, default=None, help="time step [s] (default: LayerXSettings's, 0.05)")
486 ap.add_argument("--out", type=Path, default=None, help="write the JSON here")
487 ap.add_argument("--config", default=None,
488 help="engine config: YAML, app document JSON, or a baseline JSON (default configs/ethalox_6800N.yaml)")
489 ap.add_argument("--no-embed", action="store_true", help="do not embed the config in the JSON")
490 args = ap.parse_args(argv)
491
492 cases = list(CASES) if args.all else args.case
493 data = run_baseline(cases, config_source=args.config, dt=args.dt, embed_config=not args.no_embed)
494 if args.out:
495 args.out.parent.mkdir(parents=True, exist_ok=True)
496 args.out.write_text(json.dumps(data, indent=1, default=_json_default) + "\n")
497 print(f"[layerx_baseline] wrote {args.out}")
498 return 0
499
500
501if __name__ == "__main__":
502 raise SystemExit(main())
#define max(a, b)
Definition Arduino.h:43
Dict[str, Any] metrics(Dict[str, Any] result, Any prep)
Dict[str, Any] run_baseline(Optional[Iterable[str]] cases=None, *, Any config_source=None, Optional[float] dt=None, bool embed_config=True, bool verbose=True)
Tuple[Any, Dict[str, Any]] load_engine_config(Any source=None)
Any _json_default(Any o)
Optional[str] _sha256_file(Optional[Path] path)
str format_case(Dict[str, Any] case)
Any find_drawing(str name)
Dict[str, Any] run_case(str name, *, Any config=None, Any config_source=None, Optional[float] dt=None, Optional[Callable[[str, float], None]] progress=None, bool keep_result=False)
Any _patched(Any config, Optional[Dict[str, Dict[str, Any]]] patch)
Optional[float] _first(*Any values)
Dict[str, Any] _versions()
Optional[str] _sha256_dir(Path path, str pattern="*")
Optional[float] _num(Any v)
str _f(Any v, int nd=1, str unit="")
int main()