STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
prepare.py
Go to the documentation of this file.
1"""Everything a Layer X burn needs, assembled and checked before anything runs.
2
3A burn takes seconds to a minute, and the ways it can be wrong before it starts
4are cheap to find and expensive to discover afterwards. Examples: a drawing
5whose tanks hold the wrong propellant, a load that does not fit, a main valve
6the DAQ table cannot find, a target tank pressure above the drawing's MAWP. So
7preparation does all of the setup work and grades it. The preflight endpoint
8returns exactly what a run would start from.
9
10What is decided here
11--------------------
12* **Which tank is which.** Matched by species against the engine config's
13 propellants, never by a tag.
14* **The propellant load.** The config's tank masses by default, because the
15 load is fixed by rule rather than by a fill level. A drawing fill fraction
16 is used only on request.
17* **The dome setting.** It is solved from the drawing's own regulators, so the
18 tanks lock up at the requested pressure. The tank pressure is the target;
19 the dome is the knob that produces it.
20* **The engine.** The live config, imported into the twin and calibrated to
21 EngineDesign at T-0 (:mod:`engine.layerx.link`).
22* **The chamber's ambient pressure.** The site's, from the config's elevation.
23 It is not the gauge zero.
24"""
25
26from __future__ import annotations
27
28from dataclasses import asdict, dataclass, field
29from typing import Any, Callable, Dict, List, Optional, Tuple
30
31from engine.layerx.link import EngineLink, link_engine, twin_injector_check
32from engine.layerx.sources import Drawing, cea_table_path, machines_dir
33
34PSI = 6894.757293168361
35
36#: The test modes a run can declare. Only a hot fire is modelled; the field is reserved for the
37#: cold flows (water, LN2) the stand also runs (DATA-CONTRACT 4, ``result.test_mode``).
38TEST_MODES = ("hotfire",)
39#: The values the opt-in choices take. ``None`` in a setting is always the first: today's behaviour.
40CHUG_BASES = ("config", "drawing")
41FLIGHT_COUPLINGS = ("outer", "inline")
42
43#: Layer X's chamber closure tolerance [psi]. The twin's default (0.5 psi, 0.13 % of a 380 psia
44#: chamber) is larger than the engine card's whole error; 0.02 psi is 0.005 %.
45CHAMBER_TOLERANCE_PSI = 0.02
46
47#: Layer X's network solve tolerance (scaled residual). The console's 1e-4 is ~3 kPa on every
48#: branch against a full COPV and left the card's injector drop 0.47 % off its own relation. It
49#: used to be a floor: below it the solve failed (41 of 51 steps held at 3e-5), because a carded
50#: injector leg signed its reverse-flow drop twice and a regulator at lockup stepped by its supply
51#: effect at zero flow. With both fixed (lockup now always carries the supply effect), 1e-6 converges at every
52#: step and the injector drop sits 0.007 % off. Measured on the 6.8 kN burn: +6.7 N·s (0.03 %) of
53#: impulse, +5 s of wall time.
54NETWORK_TOLERANCE = 1e-6
55
56
57@dataclass(frozen=True)
59 """What a person chooses for a Layer X burn. Everything else is derived."""
60
61 drawing_id: str
62 tank_pressure_psia: Optional[float] = None
63 """Tank lockup at T-0 [psia]; the dome is solved for it. Ignored when ``dome_psia`` is set.
64 ``None`` (and no ``dome_psia``): the dial the drawing states on the dome regulator, else the
65 config's ``lox_tank.initial_pressure_psi``."""
66 copv_pressure_psig: Optional[float] = None
67 """Bottle at T-0 [psig], as the optimiser and settings saved before 2026-10-07 write it. The
68 rail writes ``copv_pressure_psia``, which wins. ``None`` (both): the drawing's bottle pressure."""
69 copv_pressure_psia: Optional[float] = None
70 """Bottle at T-0 [psia] (2026-10-07: every pressure on the rail absolute)."""
71 dome_psia: Optional[float] = None
72 """The dome dial [psia] on the regulator ``dome_regulator`` names; the tank lockup follows from
73 it. ``None``: ``tank_pressure_psia`` if set (the dome solved for it), else the drawing's dial."""
74 dome_regulator: Optional[str] = None
75 """Drawing id of the regulator the dome dial sets: a dome loader, a dome-loaded regulator with no
76 loader drawn, or a plain regulator. ``None``: the feed twin's own choice
77 (``feedtwin.session.hookup.suggest``), the one its cockpit's dome knob drives."""
78 load: str = "config"
79 """``config``: the config's propellant masses. ``fill``: ``fill_fraction`` of each tank."""
80 fill_fraction: float = 0.95
81 dry_kg: float = 0.001
82 """Liquid left in a tank when the burn is over [kg]: the propellant the vehicle cannot use
83 (sump, outlet line, vortex pull-through). Nobody has measured it, so the default burns the
84 tanks dry; 1 g is the solver's resolution, not an estimate. With it the simulated impulse
85 agrees with the twin's own extrapolation to empty within 3 N·s on the 6.8 kN burn, so there is
86 one impulse, not two. Enter a measured trapped mass here to charge for it."""
87 engine_model: str = "card"
88 """``card``: EngineDesign's injector and chamber across the burn (an engine card).
89 ``calibrated``: the twin's engine fitted to EngineDesign at T-0. ``native``: the twin's own."""
90 # The feed twin's thermal models (2026-10-03: "it's called feed twin cause it's supposed to be an
91 # exact twin of the feed system, so you genuinely need to just pull from it and trust it"). None
92 # takes the feed twin's own Setup, exactly as its cockpit runs; a value overrides it for this burn
93 # (the uncertainty sweep does that).
94 ullage_collapse: Optional[bool] = None
95 ullage_vapour: Optional[bool] = None
96 chilldown: Optional[float] = None
97 line_walls: Optional[bool] = None
98 hold_s: float = 300.0
99 dt: float = 0.05
100 horizon_s: float = 14.0
101 settle: bool = True
102 replay: bool = True
103 """Close the burn against EngineDesign's time-varying replay with the chamber eroding
104 (phase 3, engine/layerx/replay.py). Off: the twin's pass alone, as-built throat."""
105 card_center_psia: Optional[float] = None
106 """Centre the engine card here rather than at the lockup [psia]. The optimiser sets it once for
107 all its candidates; a run on its own leaves it unset."""
108 flight: bool = False
109 """Fly the delivered burn and feed the vehicle's acceleration back into it (phase 6,
110 engine/layerx/flight.py): every liquid column at thrust-less-drag over mass, and the
111 drawing's own line heights. Without ``replay`` the twin's own engine-card thrust is flown."""
112 liftoff_mass_kg: Optional[float] = None
113 """The vehicle as weighed on the rail, loaded and pressed [kg]. ``None``: the config's airframe
114 plus the motor, propellants and gases. Given, the airframe is what is left of it, so the flight
115 (and the acceleration it feeds back into the burn) is at this mass."""
116 pressurant: Optional[str] = None
117 """``nitrogen`` or ``helium``: the gas in the bottle and everything it presses, swapped on the
118 drawing for this burn (every component holding the drawing's pressurant). ``None``: as drawn."""
119
120 # ---- opt-in choices (2026-10-03). Every one defaults to ``None``, which is exactly the
121 # behaviour before it existed; :func:`options` resolves them. They are ``None`` rather than
122 # their resolved value so the settings a saved run records, and the baseline's list of
123 # defaults (tests/test_layerx_golden.py), are unchanged by their arrival.
124 chug_basis: Optional[str] = None
125 """Which feed impedance the *graded* chug margin uses: ``config`` (EngineDesign's
126 ``feed_system``, graded on the whole burn's minimum, start included, as before) or ``drawing``
127 (the drawing's line inertance and the twin's line drop as resistance, graded from the first
128 full-flow step; AUDIT D7 C+E). Both are always computed and shown. ``None``: ``config``."""
129 chug_eroded: Optional[bool] = None
130 """Give the chug model each replay point's eroded throat, chamber volume and L* instead of the
131 design point's (AUDIT D7-D). Changes the reported chug margins only. ``None``: off."""
132 card_eroded_nozzle: Optional[bool] = None
133 """Scale the engine card's vacuum velocity with Cf_vac(eps(t))/Cf_vac(eps0) as the replay's
134 throat opens (AUDIT D4-B, ``engine/layerx/card.py``). Changes the twin's thrust (not Pc or
135 flow) once a throat schedule is applied. ``None``: off, the card's as-built nozzle."""
136 flight_coupling: Optional[str] = None
137 """Retired (2026-10-03): the ascent is always stepped inside the burn. Accepted from saved
138 settings and ignored."""
139 fuel_lead_s: Optional[float] = None
140 """The start diagnostic only: fuel main commanded this long before the LOX main [s]. The
141 burn itself opens both mains in ``Fire`` (the DAQ table). ``None``: 0, the table's."""
142 valve_travel_s: Optional[float] = None
143 """The start diagnostic only: the main valves' opening travel [s]. ``None``: the drawing's
144 ``travel_time`` on each main."""
145 outlet_d_mm: Optional[Any] = None
146 """The gas-ingestion diagnostic only: the tank outlet bore [mm], one number for both tanks or
147 ``[LOX, fuel]``. ``None``: the first drawn line's bore (an assumption, said so)."""
148 ack_gn2_condensation: Optional[bool] = None
149 """Run a hot fire with nitrogen over LOX anyway. Without it preflight refuses one whose
150 nitrogen partial pressure is over nitrogen's saturation pressure at the LOX temperature: the
151 gas condenses into the propellant and on the cold wall, which the twin does not model.
152 ``None``: refuse."""
153 test_mode: Optional[str] = None
154 """What the stand is doing: only ``hotfire`` is modelled (the field is reserved for cold
155 flows). ``None``: ``hotfire``."""
156
157 @classmethod
158 def from_dict(cls, raw: Dict[str, Any]) -> "LayerXSettings":
159 known = {f for f in cls.__dataclass_fields__}
160 return cls(**{k: v for k, v in raw.items() if k in known and (v is not None or k == "drawing_id")})
161
162
163def at_operating_point(settings: LayerXSettings, *, lockup_psia: Optional[float] = None,
164 fill_psig: Optional[float] = None) -> LayerXSettings:
165 """``settings`` burning at this tank lockup [psia] and bottle fill [psig], whatever the rail's
166 knobs were. The rail writes the dome dial (``dome_psia``) and the fill (``copv_pressure_psia``)
167 as soon as a person turns either, and both win in :func:`prepare` (over ``tank_pressure_psia``
168 and ``copv_pressure_psig``): a set-point, optimiser, uncertainty or Hardware candidate that set
169 only the lockup or the fill burned at the rail's knobs every time, the set point's secant saw a
170 zero slope, and "use these settings" changed nothing. So the knob that governs what the
171 candidate sets is cleared with it."""
172 from dataclasses import replace
173
174 changes: Dict[str, Any] = {}
175 if lockup_psia is not None:
176 changes.update(tank_pressure_psia=float(lockup_psia), dome_psia=None)
177 if fill_psig is not None:
178 changes.update(copv_pressure_psig=float(fill_psig), copv_pressure_psia=None)
179 return replace(settings, **changes)
180
181
182def _outlet_pair(value: Any) -> Tuple[Optional[float], Optional[float]]:
183 """``outlet_d_mm`` as ``(LOX, fuel)``: one number is both tanks'."""
184 if value is None:
185 return (None, None)
186 if isinstance(value, (int, float)) and not isinstance(value, bool):
187 return (float(value), float(value))
188 pair = list(value)
189 if len(pair) != 2:
190 raise ValueError("outlet_d_mm is one bore for both tanks or [LOX, fuel]")
191 return tuple(None if v is None else float(v) for v in pair) # type: ignore[return-value]
192
193
194def feed_twin_setup(**changes: Any) -> Any:
195 """The feed twin's Setup, as its cockpit runs the stand, with only what a recorded burn needs
196 changed on top: no automatic vent at burnout (the trace is read past depletion), a burn's Newton
197 room and no wall-clock budget (``burn_setup``'s numerics), and the ``changes`` given.
198
199 Unlike :func:`feedtwin.session.burn.burn_setup`, the thermal closures are the twin's own (the
200 cockpit's: collapse, vapour, chilldown, stratification, boiling onset, nucleate boiling, wall
201 boiling), not switched off: Layer X trusts the twin (2026-10-03, AUDIT D1). ``burn_setup`` keeps
202 them off for the benchmark Study, whose expectations were set before they existed.
203 """
204 from dataclasses import fields
205
206 from feedtwin.session.core import Setup
207
208 base: Dict[str, Any] = {"auto_vent": False, "max_iterations": 120, "tick_budget": 1.0e9}
209 base.update(changes)
210 known = {f.name for f in fields(Setup)}
211 return Setup(**{k: v for k, v in base.items() if k in known})
212
213
214def options(settings: LayerXSettings) -> Dict[str, Any]:
215 """The opt-in choices with ``None`` resolved to today's behaviour: what a run actually did.
216 Recorded as ``provenance.derived.options`` so a saved run says it, whatever the defaults
217 become."""
218 return {
219 # D7, decided 2026-10-03 ("if it removes a time step artifact then yes do it"): the chug loop
220 # on the drawing's own lines, the eroded engine, graded from the first full-flow step.
221 # Physics, not options (the team, 2026-10-03: "there should never be options for wrong
222 # physics, just physics"): the chug on the drawing's own lines and the eroded engine, and the
223 # card's nozzle following the eroding throat, always. The fields stay readable on old runs.
224 "chug_basis": "drawing",
225 "chug_eroded": True,
226 "card_eroded_nozzle": True,
227 # The ascent stepped inside the burn (2026-10-03): on LE4 He it matches the outer RocketPy
228 # loop to 0.006 % impulse and 0.01 m apogee in 2 passes and 100 s against 5 passes and 247 s.
229 "flight_coupling": "inline",
230 "fuel_lead_s": float(settings.fuel_lead_s or 0.0),
231 # The main valves' travel and the tank outlets are on the drawing: never restated here.
232 "valve_travel_s": None,
233 "outlet_d_mm": [None, None],
234 "ack_gn2_condensation": bool(settings.ack_gn2_condensation),
235 "test_mode": settings.test_mode or "hotfire",
236 }
237
238
239def _option_problems(settings: LayerXSettings) -> List[str]:
240 """Values an opt-in choice cannot take (the router validates them too; a script may not)."""
241 bad: List[str] = []
242 if settings.chug_basis not in (None,) + CHUG_BASES:
243 bad.append(f"chug_basis {settings.chug_basis!r} is not one of {', '.join(CHUG_BASES)}")
244 if settings.flight_coupling not in (None,) + FLIGHT_COUPLINGS:
245 bad.append(f"flight_coupling {settings.flight_coupling!r} is not one of {', '.join(FLIGHT_COUPLINGS)}")
246 if settings.test_mode not in (None,) + TEST_MODES:
247 bad.append(f"test_mode {settings.test_mode!r}: only {', '.join(TEST_MODES)} is modelled (the field is "
248 "reserved for the cold flows)")
249 for name in ("fuel_lead_s", "valve_travel_s"):
250 v = getattr(settings, name)
251 if v is not None and not (isinstance(v, (int, float)) and 0.0 <= float(v) < 10.0):
252 bad.append(f"{name} {v!r} must be a time in seconds, 0-10")
253 try:
254 for v in _outlet_pair(settings.outlet_d_mm):
255 if v is not None and not 0.5 <= v <= 500.0:
256 bad.append(f"outlet_d_mm {v!r} must be a bore in mm, 0.5-500")
257 except (TypeError, ValueError) as exc:
258 bad.append(str(exc))
259 for name in ("dome_psia", "copv_pressure_psia", "tank_pressure_psia"):
260 v = getattr(settings, name)
261 if v is not None and not (isinstance(v, (int, float)) and 0.0 < float(v) < 20000.0):
262 bad.append(f"{name} {v!r} must be an absolute pressure in psia")
263 return bad
264
265
266def gn2_on_lox(pressurant: Optional[str], ox_tank: Any, lockup_pa: float, *, test_mode: str = "hotfire",
267 acknowledged: bool = False) -> Optional[Dict[str, str]]:
268 """Preflight's nitrogen-over-LOX check: ``{status, detail}``, or ``None`` when it does not apply.
269
270 The physical criterion (AUDIT D3-B/C, decided 2026-10-03): nitrogen condenses on liquid oxygen
271 when its partial pressure in the ullage is above its own saturation pressure at the LOX
272 temperature, ~52 psia at 90 K. The ullage's nitrogen is the lockup less LOX's vapour pressure
273 (Dalton, the ullage saturated with oxygen at the surface). Both saturation pressures are
274 CoolProp's (Bell, Wronski, Quoilin & Lemort, Ind. Eng. Chem. Res. 53(6), 2014). The LOX
275 temperature is the drawing's oxidiser tank ``temperature``; without one, LOX's normal boiling
276 point. At or above nitrogen's critical temperature nothing condenses.
277
278 A burn over the line is a ``warn``, never a ``fail``: the team compares helium and nitrogen on
279 the same drawing (the user, 2026-10-20: "we want the option to compare both ... not to fight
280 over it"), so the run goes ahead and says what it leaves out. The twin models neither the
281 condensation into the propellant nor on the cold wall, so its pressurant use and tank pressure
282 read optimistic. ``acknowledged`` (``ack_gn2_condensation``) is kept so saved settings still
283 load; it no longer changes the status. This replaces a warning above nitrogen's critical pressure (492.5 psia), which said nothing
284 for a 300-490 psia nitrogen lockup that condenses just the same."""
285 if (pressurant or "").lower() != "nitrogen" or ox_tank is None or (ox_tank.fluid or "").lower() != "oxygen":
286 return None
287 if test_mode != "hotfire":
288 return None
289 import CoolProp.CoolProp as CP
290
291 # The tank by the tag on the drawing (an operator reads "LOX-Tank", never the graph's node id).
292 tank = str(getattr(ox_tank, "label", "") or "") or "the LOX tank"
293 param = ox_tank.params.get("temperature") if getattr(ox_tank, "params", None) else None
294 if param is not None:
295 T = float(param.si)
296 source = str(getattr(param.source, "value", param.source) or "unstated")
297 t_src = f"{tank}'s temperature on the drawing, {source}"
298 else:
299 T = float(CP.PropsSI("T", "P", 101325.0, "Q", 0, "Oxygen"))
300 t_src = "LOX's normal boiling point: the drawing states no tank temperature"
301 t_crit = float(CP.PropsSI("Tcrit", "Nitrogen"))
302 if T >= t_crit:
303 return {"status": "info", "detail": f"LOX at {T:.1f} K ({t_src}) is above nitrogen's critical temperature "
304 f"({t_crit:.1f} K): nitrogen cannot condense on it."}
305 psat_n2 = float(CP.PropsSI("P", "T", T, "Q", 0, "Nitrogen"))
306 try:
307 pv_o2 = float(CP.PropsSI("P", "T", T, "Q", 0, "Oxygen"))
308 except Exception: # noqa: BLE001 - outside oxygen's two-phase range: count the ullage as all nitrogen
309 pv_o2 = 0.0
310 p_n2 = lockup_pa - pv_o2
311 # Two short sentences a person reads first (the rail shows them), then why, with the sources.
312 lead = (f"Nitrogen condenses on LOX at {T:.1f} K above {psat_n2 / PSI:.1f} psia, and this burn holds "
313 f"{p_n2 / PSI:.0f} psia of it over the LOX. The burn leaves the condensation out, so its tank "
314 "pressure and gas use read optimistic. ")
315 why = (f"LOX at {T:.1f} K ({t_src}); nitrogen's saturation pressure there is {psat_n2 / PSI:.1f} psia "
316 f"(CoolProp). At {lockup_pa / PSI:.0f} psia lockup the ullage's nitrogen is at {p_n2 / PSI:.0f} psia "
317 f"(lockup less LOX's {pv_o2 / PSI:.1f} psia vapour pressure), so it condenses into the propellant "
318 f"and on the cold tank wall. The model leaves that out, so the gas used and {tank}'s pressure it "
319 "reports are optimistic.")
320 if p_n2 <= psat_n2:
321 return {"status": "info", "detail": (f"LOX at {T:.1f} K ({t_src}); nitrogen condenses there above "
322 f"{psat_n2 / PSI:.1f} psia (CoolProp), and the ullage's nitrogen is at "
323 f"{p_n2 / PSI:.0f} psia: below it, so nothing condenses on the liquid.")}
324 return {"status": "warn", "detail": lead + why}
325
326
327def _stated_requirement(config: Any, key: str) -> Optional[float]:
328 """``design_requirements.<key>`` when the design states it; ``None`` when it is absent or only
329 the schema's default (pydantic's ``model_fields_set``), which has no source to check against."""
330 req = getattr(config, "design_requirements", None)
331 if req is None:
332 return None
333 stated = getattr(req, "model_fields_set", None)
334 if stated is not None and key not in stated:
335 return None
336 value = getattr(req, key, None)
337 return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
338
339
340def vessel_trip(node: Any, safety_factor: float) -> Optional[Dict[str, Any]]:
341 """The pressure the feed twin trips the stand at for this vessel, as the twin computes it
342 (``feedtwin.session.core._trip_limit``, which ``Session._check_limits`` compares the vessel's
343 absolute pressure with): the drawing's ``burst_pressure`` over ``Setup.burst_safety_factor``
344 across the wall when it gives one, else its ``MAWP``. Both are read as the drawing reads them
345 (ADR 0004: a bare psi is gauge, so ``.si`` is already absolute).
346
347 ``{trip_psia, basis, rating_psig, safety_factor, source, words}``: ``trip_psia`` is absolute,
348 ``basis`` ``burst`` or ``MAWP``, ``rating_psig`` the drawn number. ``None`` when the drawing
349 rates the vessel by neither: the twin then checks nothing."""
350 from feedtwin.session.core import _trip_limit
351 from feedtwin.session.gauge import ATMOSPHERE
352
353 limit = float(_trip_limit(node, safety_factor, []))
354 if limit <= 0.0:
355 return None
356 params = getattr(node, "params", {}) or {}
357 burst = params.get("burst_pressure")
358 if burst is not None and burst.si > ATMOSPHERE:
359 sf = max(float(safety_factor), 1.0)
360 param, basis = burst, "burst"
361 rating = (float(burst.si) - ATMOSPHERE) / PSI
362 how = f"burst {rating:.0f} psig over a safety factor of {sf:g}"
363 else:
364 sf = None
365 param, basis = params["MAWP"], "MAWP"
366 rating = (float(param.si) - ATMOSPHERE) / PSI
367 how = f"MAWP {rating:.0f} psig"
368 source = str(getattr(param.source, "value", param.source) or "unstated")
369 return {"trip_psia": limit / PSI, "basis": basis, "rating_psig": rating, "safety_factor": sf, "source": source,
370 "words": f"{limit / PSI:.1f} psia ({how}, {source})"}
371
372
373#: Drawing symbols a relief valve can sit behind and still see the vessel's own pressure: a manifold
374#: or a junction is a fitting, not a restriction (the drawn lines between them are walked too).
375_PASSIVE_SYMBOLS = frozenset({"MANIFOLD", "JUNCTION", "TEE", "CROSS"})
376
377
378def vessel_reliefs(diagram: Any, vessel_id: str) -> List[Tuple[str, float]]:
379 """``(label, set pressure [Pa, across the valve])`` for every relief valve with a drawn
380 ``set_pressure`` that sees ``vessel_id``'s pressure: drawn onto it, or behind manifolds and
381 junctions only (LE4 (3)'s COPV-RV sits on COPV-MFLD). Not one behind a valve or a regulator."""
382 nodes = {n.id: n for n in diagram.nodes}
383 neighbours: Dict[str, List[str]] = {}
384 for e in diagram.edges:
385 neighbours.setdefault(e.source, []).append(e.target)
386 neighbours.setdefault(e.target, []).append(e.source)
387 out: List[Tuple[str, float]] = []
388 seen, todo = {vessel_id}, [vessel_id]
389 while todo:
390 here = todo.pop()
391 for other in neighbours.get(here, ()):
392 node = nodes.get(other)
393 if node is None or other in seen:
394 continue
395 seen.add(other)
396 if node.type == "RV" and "set_pressure" in node.params:
397 out.append((node.label or node.id, float(node.params["set_pressure"].si)))
398 elif node.type in _PASSIVE_SYMBOLS:
399 todo.append(other)
400 return out
401
402
403def expected_tank_rise(model: Any, *, dome_psig: float, gas: str, bottle_pa: float, bottle_K: float,
404 bottle_m3: float, lockup_pa: float, ullage_m3: float, expelled_m3: float,
405 iterations: int = 12) -> Dict[str, Any]:
406 """The tank pressure at burnout the drawing's regulator law predicts before anything burns.
407
408 The regulator's outlet climbs as its supply falls (the supply-pressure effect), so the tanks end
409 the burn above their lockup. This estimates by how much:
410
411 * the gas the tanks need: CoolProp density at the end pressure times the ullage the burn ends
412 with (the T-0 ullage plus the liquid expelled), less what the T-0 ullage already holds at
413 lockup, both at the bottle's temperature;
414 * the bottle after giving it: its T-0 gas less that, expanded on the T-0 isentrope (a rigid
415 vessel with no heat: the largest drop, so the largest rise);
416 * the tank pressure: the drawing's own regulator law (:func:`lockup_for_dome`) at that supply,
417 at zero flow (droop would lower it).
418
419 Iterated, since the end pressure prices the gas. Returns ``{end_pa, rise_pa, bottle_end_pa,
420 gas_kg, converged}``; ``bottle_end_pa`` is ``None`` when the bottle cannot supply it."""
421 import CoolProp.CoolProp as CP
422
423 rho0 = float(CP.PropsSI("D", "P", bottle_pa, "T", bottle_K, gas))
424 s0 = float(CP.PropsSI("S", "P", bottle_pa, "T", bottle_K, gas))
425 m0 = rho0 * bottle_m3
426 rho_lock = float(CP.PropsSI("D", "P", lockup_pa, "T", bottle_K, gas))
427 end_pa, bottle_end, need = lockup_pa, None, 0.0
428 converged = False
429 for _ in range(iterations):
430 rho_end = float(CP.PropsSI("D", "P", end_pa, "T", bottle_K, gas))
431 need = rho_end * (ullage_m3 + expelled_m3) - rho_lock * ullage_m3
432 left = m0 - need
433 if left <= 0.0:
434 return {"end_pa": None, "rise_pa": None, "bottle_end_pa": None, "gas_kg": need, "converged": False}
435 bottle_end = float(CP.PropsSI("P", "D", left / bottle_m3, "S", s0, gas))
436 new = lockup_for_dome(model, dome_psig, bottle_end)
437 if new is None:
438 return {"end_pa": None, "rise_pa": None, "bottle_end_pa": bottle_end, "gas_kg": need, "converged": False}
439 converged = abs(new - end_pa) < 0.01 * PSI
440 end_pa = new
441 if converged:
442 break
443 return {"end_pa": end_pa, "rise_pa": end_pa - lockup_pa, "bottle_end_pa": bottle_end, "gas_kg": need,
444 "converged": converged}
445
446
447#: CoolProp's names for the pressurants a drawing's bottle holds.
448_COOLPROP_GAS = {"nitrogen": "Nitrogen", "helium": "Helium"}
449
450
451def _burnout_tank_check(model: Any, dome: float, bottle: Any, copv_psig: float, lockup_pa: float,
452 roles: Dict[str, str], tanks: List[Any], volumes: Dict[str, Optional[float]],
453 loads: Optional[Dict[str, float]], rho: Dict[str, float], settings: LayerXSettings,
454 ambient: float, config: Any, trips: Optional[Dict[str, Dict[str, Any]]] = None,
455 reliefs: Optional[Dict[str, List[Tuple[str, float]]]] = None) -> Optional[Dict[str, Any]]:
456 """Preflight's ``tank_rise`` check: :func:`expected_tank_rise` against the pressure each tank
457 trips the stand at (:func:`vessel_trip`, absolute, as the twin compares it), the relief valves
458 on it (:func:`vessel_reliefs`, set across the valve to the gauge zero) and the design's stated
459 tank cap (read as psia, as ``diag.limits`` reads it until AUDIT D11 is decided). ``warn`` over
460 any, ``info`` otherwise."""
461 from feedtwin.session.gauge import ATMOSPHERE, from_psig
462
463 gas = _COOLPROP_GAS.get((bottle.fluid or "").lower())
464 t_param, v_param = bottle.params.get("temperature"), bottle.params.get("volume")
465 if gas is None or t_param is None or v_param is None or len(roles) != 2:
466 return {"status": "info", "detail": "No estimate of the tanks' rise over the burn: the drawing's bottle states "
467 "no gas CoolProp knows, temperature or volume."}
468 expelled = ullage = 0.0
469 for side, tank_id in roles.items():
470 vol = volumes.get(tank_id)
471 if vol is None:
472 label = next((t.label for t in tanks if t.id == tank_id and getattr(t, "label", "")), tank_id)
473 return {"status": "info", "detail": f"No estimate of the tanks' rise over the burn: {label} has no volume "
474 "on the drawing."}
475 liquid = loads[tank_id] / rho[side] if loads else settings.fill_fraction * vol
476 expelled += liquid
477 ullage += max(vol - liquid, 0.0)
478 est = expected_tank_rise(model, dome_psig=dome, gas=gas, bottle_pa=from_psig(copv_psig), bottle_K=float(t_param.si),
479 bottle_m3=float(v_param.si), lockup_pa=lockup_pa, ullage_m3=ullage, expelled_m3=expelled)
480 if est["end_pa"] is None:
481 if est["bottle_end_pa"] is None:
482 return {"status": "warn", "detail": f"The bottle ({from_psig(copv_psig) / PSI:.0f} psia, {v_param.si * 1e3:.2f} L of {gas}) holds "
483 f"less than the ~{est['gas_kg']:.3f} kg the tanks need to stay at lockup "
484 "through the burn: it will fall below lockup."}
485 return {"status": "info", "detail": "No estimate of the tanks' rise: the drawing has no dome-loaded regulator."}
486 end, rise = float(est["end_pa"]), float(est["rise_pa"])
487 over = []
488 for t in tanks:
489 if t.id not in roles.values():
490 continue
491 trip = (trips or {}).get(t.id)
492 if trip is not None and end / PSI > trip["trip_psia"]:
493 over.append(f"the {trip['words']} {t.label or t.id} trips the stand at")
494 for label, set_pa in (reliefs or {}).get(t.id, ()):
495 if end > set_pa + ATMOSPHERE:
496 over.append(f"{label}'s {set_pa / PSI:.0f} psi set pressure on {t.label or t.id} "
497 f"({(set_pa + ATMOSPHERE) / PSI:.1f} psia): it lifts")
498 for key, name in (("max_lox_tank_pressure_psi", "LOX"), ("max_fuel_tank_pressure_psi", "fuel")):
499 cap = _stated_requirement(config, key)
500 if cap is not None and end / PSI > cap:
501 over.append(f"the design's {name} tank cap ({cap:g} psi, design_requirements.{key}, read as psia until "
502 "AUDIT D11 is decided)")
503 detail = (f"Locked up at {lockup_pa / PSI:.1f} psia from a {from_psig(copv_psig) / PSI:.0f} psia bottle, the bottle should end near "
504 f"{est['bottle_end_pa'] / PSI:.0f} psia after giving the tanks ~{est['gas_kg']:.3f} kg of {gas}; the "
505 f"regulator's supply-pressure effect then lifts the tanks to ~{end / PSI:.0f} psia (+{rise / PSI:.0f} psi). "
506 "Estimated from the drawing's regulator law at zero flow, the bottle on its isentrope and the gas at the "
507 "bottle's temperature: an upper estimate; the burn grades the real peak.")
508 if over:
509 detail += " That is over " + "; ".join(over) + "."
510 estimate = {"end_psia": end / PSI, "rise_psi": rise / PSI, "bottle_end_psia": est["bottle_end_pa"] / PSI,
511 "gas_kg": est["gas_kg"], "converged": est["converged"],
512 "basis": "drawing regulator law at zero flow; bottle isentropic from its drawn temperature; tank gas at "
513 "the bottle's temperature (prepare.expected_tank_rise)"}
514 return {"status": "warn" if over else "info", "detail": detail, "estimate": estimate}
515
516
517def _feed_line_gaps(prep: Any) -> List[str]:
518 """Per side, what the drawing leaves out of the tank-to-injector path the chug margin and the
519 line losses are computed on (``diag.stability.drawing_feed``): a path drawn with no line at all
520 (direct connections), and lines with no usable length or bore (a 0 mm bore is not a line)."""
521 try:
522 from engine.layerx.diag.stability import drawing_feed
523
524 feed = drawing_feed(prep)
525 except Exception: # noqa: BLE001 - a path that cannot be traced is the burn's to report, not preflight's
526 return []
527 gaps: List[str] = []
528 for side, word in (("oxidiser", "LOX"), ("fuel", "Fuel")):
529 lines = (feed.get(side) or {}).get("lines") or []
530 if not lines:
531 gaps.append(f"{word}: the drawing declares no line from the tank to the injector (direct connections), so "
532 "the path carries no line inertance or loss; the chug margin keeps EngineDesign's feed_system "
533 "inertance for it")
534 continue
535 bad = [str(ln.get("label") or ln.get("id")) for ln in lines
536 if ln.get("length_m") is None or ln.get("bore_m") is None or not ln["bore_m"] > 0.0]
537 if bad:
538 gaps.append(f"{word}: {', '.join(bad)} {'has' if len(bad) == 1 else 'have'} no usable length or bore "
539 "and is left out of the line inertance")
540 return gaps
541
542
543def _vehicle_dry_kg(config: Any) -> Optional[float]:
544 from engine.layerx.flight import config_dry_kg
545
546 try:
547 return config_dry_kg(config)
548 except Exception: # noqa: BLE001 - a readout, never a reason to refuse the burn
549 return None
550
551
552@dataclass
553class Check:
554 key: str
555 label: str
556 status: str
557 """``ok``, ``info``, ``warn`` or ``fail``. A ``fail`` blocks the run."""
558 detail: str = ""
559
560
561@dataclass
563 """A burn ready to run: the model, the plan, the engine link, and the checks."""
564
565 settings: LayerXSettings
566 drawing: Drawing
567 checks: List[Check] = field(default_factory=list)
568 derived: Dict[str, Any] = field(default_factory=dict)
569 model: Any = None
570 machine: Any = None
571 setup: Any = None
572 plan: Any = None
573 link: Optional[EngineLink] = None
574 roles: Dict[str, str] = field(default_factory=dict)
575 """``oxidiser``/``fuel`` -> tank id."""
576 inlet_nodes: Dict[str, str] = field(default_factory=dict)
577 overrides: List[Dict[str, Any]] = field(default_factory=list)
578 """Drawing parameters restated for this burn (engine/layerx/measurements.py)."""
579 assembler: Optional[Callable[[], Any]] = None
580 """A fresh model, identical to ``model``, for each burn: a session consumes the model it
581 runs (feedtwin.session.burn.open_session)."""
582 config_sha256: str = ""
583 ambient_pa: float = 101325.0
584 measurements: List[Any] = field(default_factory=list)
585 """The Override objects this burn was prepared with (the router's store), as given."""
586 vehicle_lines: List[Dict[str, Any]] = field(default_factory=list)
587 """Phase 6: each side's tank-to-injector drop and the line it was put on."""
588
589 @property
590 def ok(self) -> bool:
591 return self.model is not None and not any(c.status == "fail" for c in self.checks)
592
593 def preflight(self) -> Dict[str, Any]:
594 return {
595 "ok": self.ok,
596 "checks": [asdict(c) for c in self.checks],
597 "derived": self.derived,
598 "calibration": self.link.calibration if self.link else None,
599 "reference": self.link.reference if self.link else None,
600 }
601
602
603PRESSURANTS = ("nitrogen", "helium")
604
605
606def swap_pressurant(payload: Dict[str, Any], gas: str) -> Tuple[Dict[str, Any], int, str]:
607 """``payload`` with the bottle's gas replaced by ``gas`` wherever the drawing holds it (the
608 bottle, regulators, press lines' valves and vents): a copy, the count, and the gas it was."""
609 import copy
610
611 if gas not in PRESSURANTS:
612 raise ValueError(f"pressurant must be one of {PRESSURANTS}")
613 nodes = payload.get("nodes") or []
614 # The bottle as feedtwin reads it, not only as drawn: a COPV drawn with the tank symbol (a gas
615 # above its critical temperature) is the pressurant bottle too. Looking for KBOTTLE alone found
616 # nothing on such a drawing and swapped nothing, silently.
617 # Only the vehicle's gas is swapped (engine.layerx.vehicle): GSE K-bottles on the drawing's
618 # other page keep theirs; they fill the bottle on the pad and are not the burn's pressurant.
619 vehicle = None
620 try:
621 from feedtwin.pid import read_diagram
622
623 from engine.layerx.vehicle import vehicle_ids
624
625 read = read_diagram(payload, name="swap")
626 read_as_bottle = {n.id for n in read.nodes if n.type == "KBOTTLE"}
627 vehicle = vehicle_ids(read)
628 except Exception: # noqa: BLE001 - a drawing feedtwin cannot read is the preflight's to report
629 read_as_bottle = set()
630 bottle = next((n for n in nodes if isinstance(n, dict)
631 and (vehicle is None or str(n.get("id", "")) in vehicle)
632 and (str((n.get("data") or {}).get("componentType") or n.get("type")).upper() == "KBOTTLE"
633 or str(n.get("id", "")) in read_as_bottle)), None)
634 was = str(((bottle or {}).get("data") or {}).get("fluid") or "").lower()
635 if not was or was == gas:
636 return payload, 0, was or gas
637 out = copy.deepcopy(payload)
638 n = 0
639 for node in out.get("nodes") or []:
640 data = node.get("data") if isinstance(node, dict) else None
641 if vehicle is not None and str(node.get("id", "")) not in vehicle:
642 continue
643 if isinstance(data, dict) and str(data.get("fluid") or "").lower() == was:
644 data["fluid"] = gas
645 n += 1
646 return out, n, was
647
648
649from engine.layerx.vehicle import on_vehicle, vehicle_ids, vehicle_payload # noqa: E402
650from engine.layerx.fingerprint import config_fingerprint # noqa: E402,F401 - one definition
651
652
653def _inlet_nodes(built: Any) -> Dict[str, str]:
654 net = built.network
655 out: Dict[str, str] = {}
656 for side in ("oxidiser", "fuel"):
657 port = built.engine_ports.get(side, "")
658 if port in net.branches:
659 out[side] = net.branches[port].upstream
660 elif port in net.nodes:
661 out[side] = port
662 return out
663
664
665def _dome_regulators(model: Any) -> List[Tuple[str, Any, str]]:
666 """``(symbol id, component, upstream node)`` for every dome-loaded regulator."""
667 from feedtwin.comps.regulator import Regulator
668
669 built = model.built
670 out = []
671 for node in model.diagram.nodes:
672 if node.type != "PR" or node.options.get("domeLoaded") != "yes":
673 continue
674 for branch_id in built.branches_of.get(node.id, ()):
675 branch = built.network.branches.get(branch_id)
676 if branch is not None and isinstance(branch.component, Regulator):
677 out.append((node.id, branch.component, branch.upstream))
678 break
679 return out
680
681
682def dome_knobs(model: Any) -> List[Dict[str, Any]]:
683 """Every regulator a dome dial could set, as the feed twin's hookup lists them: ``{id, label,
684 kind, page, drawn_psia}`` with ``kind`` loader / dome / plain (feedtwin.session.hookup)."""
685 from feedtwin.session.hookup import regulators
686
687 from feedtwin.session.gauge import ATMOSPHERE
688
689 return [{"id": r.id, "label": r.label, "kind": r.kind, "page": r.page,
690 "drawn_psia": None if r.drawn_psig is None else r.drawn_psig + ATMOSPHERE / PSI}
691 for r in regulators(model)]
692
693
694def default_dome_regulator(model: Any) -> Optional[str]:
695 """The regulator the feed twin's own dome knob drives on this drawing (``hookup.suggest``)."""
696 from feedtwin.session.hookup import DOME, suggest
697
698 knob = next((k for k in suggest(model).knobs if k.id == DOME), None)
699 return knob.regulators[0] if knob is not None and knob.regulators else None
700
701
702def _knob_lockup(model: Any, regulator: str, dome_psig: float, supply_pa: float) -> Optional[float]:
703 """Tank lockup [Pa abs] with the dial on ``regulator`` alone: a loader through its own outlet into
704 the dome it loads, any other regulator through its own dome signal (a plain one reads it as its
705 setpoint). The regulator that sets the tanks is evaluated at zero flow, plus its lockup rise."""
706 from feedtwin.comps.regulator import Regulator
707 from feedtwin.session.gauge import from_psig
708
709 built = model.built
710 labels = {n.id: n.label or n.id for n in model.diagram.nodes}
711 signals: Dict[str, float] = {}
712 loader = built.dome_loaders.get(regulator)
713 if loader is not None:
714 cond = built.network.conditions(loader.supply_node, supply_pa,
715 {f"{loader.component.id}.dome": from_psig(dome_psig)})
716 signals[loader.signal] = float(loader.component.outlet_setpoint(0.0, cond))
717 target = next((nid for nid, lab in labels.items() if f"{lab}.dome" == loader.signal), None)
718 else:
719 signals[f"{labels.get(regulator, regulator)}.dome"] = from_psig(dome_psig)
720 target = regulator
721 for branch_id in built.branches_of.get(target or "", ()):
722 branch = built.network.branches.get(branch_id)
723 if branch is not None and isinstance(branch.component, Regulator):
724 cond = built.network.conditions(branch.upstream, supply_pa, signals)
725 return float(branch.component.outlet_setpoint(0.0, cond)) + float(branch.component.p.get("lockup_rise", 0.0))
726 return None
727
728
729def lockup_for_dome(model: Any, dome_psig: float, supply_pa: float, regulator: Optional[str] = None) -> Optional[float]:
730 """Tank lockup [Pa abs] the drawing's regulators deliver for a dome dial
731 setting and a bottle pressure: the loader evaluated against the bottle the
732 way the session does each tick, then the dome-loaded unit at zero flow plus
733 its seat's lockup rise. ``None`` when the drawing has no dome-loaded unit."""
734 from feedtwin.session.gauge import from_psig
735
736 if regulator is not None:
737 return _knob_lockup(model, regulator, dome_psig, supply_pa)
738 built = model.built
739 regulators = _dome_regulators(model)
740 if not regulators:
741 return None
742 signals: Dict[str, float] = {}
743 for loader in built.dome_loaders.values():
744 cond = built.network.conditions(
745 loader.supply_node, supply_pa, {f"{loader.component.id}.dome": from_psig(dome_psig)}
746 )
747 signals[loader.signal] = float(loader.component.outlet_setpoint(0.0, cond))
748 if not built.dome_loaders:
749 dome_signal = next((s for s in built.actuators.values() if s.endswith(".dome")), "")
750 if dome_signal:
751 signals[dome_signal] = from_psig(dome_psig)
752 _, component, upstream = regulators[0]
753 cond = built.network.conditions(upstream, supply_pa, signals)
754 return float(component.outlet_setpoint(0.0, cond)) + float(component.p.get("lockup_rise", 0.0))
755
756
757def dome_for_lockup(model: Any, target_pa: float, supply_pa: float, regulator: Optional[str] = None) -> Optional[float]:
758 """The dome dial [psig] that locks the tanks up at ``target_pa``. The map is
759 affine in the dial (loader outlet plus bias plus supply effect), so two
760 evaluations solve it exactly; a third confirms."""
761 a, b = 400.0, 600.0
762 la, lb = lockup_for_dome(model, a, supply_pa, regulator), lockup_for_dome(model, b, supply_pa, regulator)
763 if la is None or lb is None or abs(lb - la) < 1e-9:
764 return None
765 dome = a + (target_pa - la) * (b - a) / (lb - la)
766 check = lockup_for_dome(model, dome, supply_pa, regulator)
767 if check is None or abs(check - target_pa) > 0.01 * PSI:
768 return None
769 return dome
770
771
772def prepare(config: Any, runner: Any, drawing: Drawing, settings: LayerXSettings,
773 overrides: Optional[List[Any]] = None) -> Prepared:
774 """Read, match, size, calibrate and check. Never raises for a problem a
775 person can fix: that becomes a ``fail`` check naming it."""
776 from feedtwin.engine.importer import engine_from_config
777 from feedtwin.pid import DiagramError, read_diagram
778 from feedtwin.session import assemble_model, load_machine
779 from feedtwin.session.burn import BurnPlan
780 from feedtwin.session.gauge import ATMOSPHERE, from_psig, psig_from_psia
781
782 prep = Prepared(settings=settings, drawing=drawing, config_sha256=config_fingerprint(config),
783 measurements=list(overrides or []))
784 checks = prep.checks
785 add = lambda key, label, status, detail="": checks.append(Check(key, label, status, detail)) # noqa: E731
786
787 problems = _option_problems(settings)
788 if problems:
789 add("options", "Run options", "fail", "; ".join(problems) + ".")
790 return prep
791
792 # ---- the drawing, with what has been measured written in ----------------------
793 from engine.layerx.measurements import apply_overrides
794
795 payload, applied, missing = apply_overrides(drawing.payload, list(overrides or []))
796 prep.overrides = applied
797 if applied:
798 measured = sum(1 for a in applied if a["provenance"] == "measured")
799 add("overrides", "Restated drawing parameters", "info",
800 f"{len(applied)} parameter(s) restated ({measured} measured): "
801 + ", ".join(f"{a['key']} = {a['value']:g} {a['unit']}".rstrip() for a in applied[:6])
802 + (" …" if len(applied) > 6 else "") + ".")
803 if missing:
804 add("overrides_missing", "Restated parameters the drawing no longer has", "warn",
805 "The drawing has changed since these were entered: " + ", ".join(missing) + ".")
806 # The burn is the vehicle's (engine.layerx.vehicle): a GSE page joined only by paired
807 # disconnects is left out, its disconnect halves capped; Layer X primes what the GSE would.
808 try:
809 payload, ground = vehicle_payload(payload)
810 except Exception: # noqa: BLE001 - a drawing feedtwin cannot read is reported just below
811 ground = []
812 if ground:
813 add("ground_support", "Ground support on the drawing", "info",
814 f"{', '.join(ground)} {'is' if len(ground) == 1 else 'are'} joined to the vehicle only through "
815 "paired disconnects: ground support. The burn runs the vehicle alone, primed by Layer X "
816 "(loads, bottle fill, regulator dome), with those disconnects capped.")
817 if settings.pressurant:
818 payload, swapped, was = swap_pressurant(payload, settings.pressurant)
819 if swapped:
820 add("pressurant", "Pressurant", "info",
821 f"{was} swapped for {settings.pressurant} on {swapped} component(s) for this burn. The drawing's "
822 "regulator droop and line data were measured with its own gas.")
823 try:
824 diagram = read_diagram(payload, name=drawing.name)
825 except DiagramError as exc:
826 add("drawing", "Drawing reads", "fail", str(exc))
827 return prep
828
829 # ---- the engine, as the twin imports it --------------------------------
830 try:
831 native = engine_from_config(config.model_dump(mode="json"), name="engine")
832 except Exception as exc: # noqa: BLE001 - an import failure is a finding
833 add("engine", "Engine import into the feed model", "fail", str(exc))
834 return prep
835 species = {"oxidiser": native.oxidiser.propellant, "fuel": native.fuel.propellant}
836
837 # The vehicle's vessels only (engine.layerx.vehicle): a GSE transfer tank or cart bottle on the
838 # drawing's other page, joined by paired disconnects, is not a propellant tank or the pressurant.
839 vehicle = vehicle_ids(diagram)
840 tanks = on_vehicle([n for n in diagram.nodes if n.type == "TANK"], vehicle)
841 roles: Dict[str, str] = {}
842 # Tanks are named by their tags on the drawing in what a person reads; the roles keep the ids.
843 tag = {t.id: (t.label or t.id) for t in tanks}
844 for side, sp in species.items():
845 found = [t.id for t in tanks if (t.fluid or "").lower() == sp.lower()]
846 if len(found) == 1:
847 roles[side] = found[0]
848 elif not found:
849 add(f"tank_{side}", f"{side.capitalize()} tank on the drawing", "fail",
850 f"The engine burns {sp}; no tank on '{drawing.name}' holds it "
851 f"(tanks: {', '.join(f'{tag[t.id]} [{t.fluid}]' for t in tanks) or 'none'}).")
852 else:
853 add(f"tank_{side}", f"{side.capitalize()} tank on the drawing", "fail",
854 f"{len(found)} tanks on the vehicle hold {sp} ({', '.join(tag[f] for f in found)}); Layer X needs exactly one.")
855 if len(roles) == 2:
856 add("tanks", "Tanks match the engine's propellants", "ok",
857 f"{tag[roles['oxidiser']]} holds {species['oxidiser']}, {tag[roles['fuel']]} holds {species['fuel']}.")
858 prep.roles = roles
859
860 cea = cea_table_path(config)
861 if cea is None:
862 add("cea", "Combustion table", "warn",
863 "CEA cache not found; the feed model's chamber uses a constant c* and reports no thrust.")
864 else:
865 add("cea", "Combustion table", "ok", cea.name)
866
867 # ---- site ambient --------------------------------------------------------
868 from engine.core.runner import compute_ambient_pressure_from_elevation
869
870 elevation = float(getattr(getattr(config, "environment", None), "elevation", 0.0) or 0.0)
871 ambient = compute_ambient_pressure_from_elevation(elevation) if elevation > 0 else 101325.0
872 prep.ambient_pa = ambient
873 add("ambient", "Site ambient pressure", "info",
874 f"{ambient / 1e3:.2f} kPa at {elevation:.0f} m. Layer X reports absolute pressures; gauge "
875 "readings are referenced to 101.325 kPa, as on the DAQ.")
876
877 # ---- a first model, for the network's own fluid states -------------------
878 try:
879 probe_model = assemble_model(diagram, diagram_id=drawing.id, engine=native,
880 cea_cache=str(cea) if cea else "")
881 except Exception as exc: # noqa: BLE001
882 add("assembly", "Drawing assembles with the engine", "fail", str(exc))
883 return prep
884 inlets = _inlet_nodes(probe_model.built)
885 if set(inlets) != {"oxidiser", "fuel"}:
886 add("engine_ports", "Engine attaches to the drawing", "fail",
887 "The drawing's ENGINE symbol did not resolve to an oxidiser and a fuel injector inlet.")
888 return prep
889 prep.inlet_nodes = inlets
890
891 # ---- the line the injector is fed by, against the bore the design prices its dump on ----
892 # EngineDesign charges the dump of the line's velocity head into the manifold on its own exit
893 # bore; the drawing's last line is what actually arrives. A smaller tube makes that dump grow
894 # as the fourth power of the bore ratio, silently.
895 try:
896 from engine.layerx.flight import _path_to_tank
897
898 edges = {str(e.get("id")): e for e in payload.get("edges") or [] if isinstance(e, dict)}
899 mismatched = []
900 for side, cside in (("oxidiser", "oxidizer"), ("fuel", "fuel")):
901 path = _path_to_tank(probe_model, inlets.get(side, ""), roles.get(side, ""))
902 last = edges.get(path[-1]) if path else None
903 bore = ((last or {}).get("data") or {}).get("params", {}).get("bore") if last else None
904 feed = (getattr(config, "feed_system", None) or {}).get(cside)
905 if not isinstance(bore, dict) or feed is None:
906 continue
907 drawn_mm = float(bore["value"]) * {"mm": 1.0, "m": 1e3, "in": 25.4}.get(str(bore.get("unit", "mm")), 1.0)
908 d_exit = getattr(feed, "d_exit", None)
909 design_mm = (float(d_exit) if d_exit else (4.0 * float(feed.A_hydraulic) / 3.141592653589793) ** 0.5) * 1e3
910 if design_mm > 0 and abs(drawn_mm / design_mm - 1.0) > 0.10:
911 mismatched.append(f"{'LOX' if side == 'oxidiser' else 'fuel'}: drawn {drawn_mm:.2f} mm, design {design_mm:.2f} mm "
912 f"(dump ×{(design_mm / drawn_mm) ** 4:.1f})")
913 if mismatched:
914 add("exit_bore", "Line into the injector", "warn",
915 "The manifold dump is priced on the design's exit bore, not the drawing's last line: "
916 + "; ".join(mismatched) + ". Set feed_system.<side>.d_exit to the drawn bore, or redraw the line.")
917 except Exception: # noqa: BLE001 - a comparison that cannot be made is not a reason to refuse
918 pass
919
920 # ---- in flight, the drawing's lines carry the acceleration as drawn ----------------
921 if settings.flight:
922 from engine.layerx.flight import line_paths
923
924 try:
925 rows = line_paths(payload, probe_model, inlets, roles, [o.key() for o in (overrides or [])])
926 except Exception as exc: # noqa: BLE001 - a drawing the flight cannot trace is a finding
927 add("vehicle_lines", "Feed-line heights", "fail", f"{type(exc).__name__}: {exc}")
928 return prep
929 prep.vehicle_lines = rows
930 name = {"oxidiser": "LOX", "fuel": "Fuel"}
931 how = {"drawing": "as drawn", "restated": "as restated", "none": "no height on the drawing"}
932 missing = [r for r in rows if r["used"] == "none"]
933 steep = [b for r in rows for b in r["too_steep"]]
934 add("vehicle_lines", "Feed-line heights", "warn" if missing or steep else "info",
935 "; ".join(f"{name[r['side']]}: {r['length_m']:.2f} m of line from the tank, falls {r['drop_m']:.2f} m "
936 f"({how[r['used']]})" for r in rows) + "."
937 + (" In flight a line with no height carries no head; only the tank's own liquid feels the "
938 "acceleration. Put the heights on the drawing in pid-designer, or restate each line's "
939 "elevation_change under Enter measured values." if missing else "")
940 + (f" {', '.join(steep)} falls further than it is drawn long: check the drawing." if steep else ""))
941
942 def fluid_at(side: str, pressure: float) -> Tuple[float, float]:
943 cond = probe_model.built.network.conditions(inlets[side], pressure)
944 return float(cond.rho), float(cond.mu)
945
946 # ---- pressures: target lockup, bottle, dome ------------------------------
947 atm_psia = ATMOSPHERE / PSI
948 lox_psia = float(getattr(config.lox_tank, "initial_pressure_psi", 0.0) or 0.0)
949 fuel_psia = float(getattr(config.fuel_tank, "initial_pressure_psi", 0.0) or 0.0)
950
951 # ---- the bottle at T-0 ---------------------------------------------------
952 bottles = on_vehicle([n for n in diagram.nodes if n.type == "KBOTTLE"], vehicle)
953 drawn_copv = None
954 if bottles and "pressure" in bottles[0].params:
955 # Absolute in SI whatever the drawing wrote (bare psi reads as psig:
956 # feedtwin.model.pressure); the plan wants gauge.
957 drawn_copv = psig_from_psia(bottles[0].params["pressure"].si / PSI)
958 if not bottles:
959 add("bottle", "Pressurant bottle", "fail", "The vehicle has no bottle to press from.")
960 return prep
961 if settings.copv_pressure_psia is not None:
962 copv_psig = settings.copv_pressure_psia - atm_psia
963 else:
964 copv_psig = settings.copv_pressure_psig if settings.copv_pressure_psig is not None else drawn_copv
965 if copv_psig is None:
966 add("bottle_pressure", "Bottle pressure at T-0", "fail",
967 "Neither the rail nor the drawing's bottle states a fill pressure.")
968 return prep
969 supply_pa = from_psig(copv_psig)
970
971 # ---- the dome dial, and the tank lockup it gives (2026-10-07) -------------
972 # The dial is an input: the rail's, else the one the drawing states on the regulator it sets
973 # (the feed twin's dome knob unless the rail names another). A tank pressure on the rail is the
974 # other way in: the dial is solved for it. With neither, the design's tank pressure, as before.
975 knobs = dome_knobs(probe_model)
976 reg_id = settings.dome_regulator or default_dome_regulator(probe_model)
977 knob = next((k for k in knobs if k["id"] == reg_id), None)
978 if settings.dome_regulator and knob is None:
979 add("dome_regulator", "Dome regulator", "fail",
980 f"{settings.dome_regulator!r} is not a regulator a dial can set on the vehicle "
981 f"({', '.join(k['label'] for k in knobs) or 'none'}).")
982 return prep
983 named = reg_id if settings.dome_regulator else None # the session is told only when the rail chose
984 drawn_dome = None if knob is None or knob["drawn_psia"] is None else knob["drawn_psia"] - atm_psia
985 if settings.dome_psia is not None:
986 dome, source = settings.dome_psia - atm_psia, "set on the rail"
987 elif settings.tank_pressure_psia is not None:
988 dome, source = None, "solved for the rail's tank pressure"
989 elif drawn_dome is not None:
990 dome, source = drawn_dome, "as drawn"
991 else:
992 dome, source = None, "solved for the design's tank pressure"
993 target_psia = None
994 if dome is not None:
995 lock = lockup_for_dome(probe_model, dome, supply_pa, named)
996 if lock is None:
997 add("dome", "Regulator dome", "fail",
998 "No regulator on the vehicle takes a dome dial, so the dial cannot set the tanks.")
999 return prep
1000 target_psia = lock / PSI
1001 else:
1002 target_psia = settings.tank_pressure_psia or lox_psia
1003 if not target_psia:
1004 add("tank_pressure", "Tank pressure", "fail",
1005 "Neither the rail, the drawing's dome nor the config (lox_tank.initial_pressure_psi) "
1006 "states a tank pressure.")
1007 return prep
1008 if settings.tank_pressure_psia is None and fuel_psia and abs(fuel_psia - lox_psia) > 0.5:
1009 add("tank_pressures", "One tank pressure for both tanks", "warn",
1010 f"The config sets LOX {lox_psia:.1f} and fuel {fuel_psia:.1f} psia; the drawing feeds both "
1011 f"from one regulator, so both start at {target_psia:.1f} psia.")
1012 dome = dome_for_lockup(probe_model, target_psia * PSI, supply_pa, named)
1013 target_pa = target_psia * PSI
1014 regs = _dome_regulators(probe_model) if named is None else knobs
1015 where = f"on {knob['label']}" if knob is not None else "on the control regulator"
1016 if dome is None:
1017 add("dome", "Regulator dome", "warn" if not regs else "fail",
1018 "No dome-loaded regulator on the drawing: tank pressure is whatever its regulator's "
1019 "setpoint gives, and the target cannot be dialled." if not regs else
1020 "Could not solve the dome setting for that lockup.")
1021 dome = 500.0 # feedtwin's Setup.dome_psi default; nothing on this drawing reads it
1022 else:
1023 add("dome", "Regulator dome", "ok",
1024 f"{dome + atm_psia:.1f} psia {where} ({source}) gives {target_psia:.1f} psia tank lockup "
1025 f"with the bottle at {copv_psig + atm_psia:.0f} psia.")
1026
1027 # Each vessel against the pressure the twin trips the stand at (vessel_trip): the same absolute
1028 # limit Session._check_limits holds it to, burst over the safety factor or the MAWP, so a lockup
1029 # or fill that passes here cannot trip the stand at T-0. (The MAWP used to be read as a pressure
1030 # across the wall and held against the site's atmosphere, but feedtwin's MAWP is already absolute:
1031 # that passed lockups up to ~28 psi over the trip, which then died in the settle; and the burst
1032 # pressure LE4 (3) rates its vessels by was not read at all.)
1033 safety_factor = float(feed_twin_setup().burst_safety_factor)
1034 trips = {v.id: r for v in list(tanks) + list(bottles) if (r := vessel_trip(v, safety_factor)) is not None}
1035 reliefs = {v.id: r for v in list(tanks) + list(bottles) if (r := vessel_reliefs(diagram, v.id))}
1036 rated_tanks = {t.id: trips[t.id] for t in tanks if t.id in trips}
1037 for t in tanks:
1038 trip = trips.get(t.id)
1039 if trip is not None and target_psia > trip["trip_psia"]:
1040 add(f"mawp_{t.id}", f"{t.label or t.id} rating", "fail",
1041 f"Lockup {target_psia:.1f} psia is over the {trip['words']} {t.label or t.id} trips the stand at.")
1042 fill_psia = from_psig(copv_psig) / PSI
1043 for b in bottles:
1044 trip = trips.get(b.id)
1045 if trip is not None and fill_psia > trip["trip_psia"]:
1046 add(f"mawp_{b.id}", f"{b.label or b.id} rating", "fail",
1047 f"Bottle fill {fill_psia:.0f} psia is over the {trip['words']} {b.label or b.id} trips the stand at.")
1048 # A relief valve on a vessel lifts when the pressure across it reaches its set pressure: a lockup
1049 # or fill above it vents during the settle, and the burn starts from a different T-0 than asked.
1050 bottle_ids = {b.id for b in bottles}
1051 lifts = [f"{'bottle fill' if v.id in bottle_ids else 'lockup'} {p:.0f} psia is over {label}'s "
1052 f"{set_pa / PSI:.0f} psi set pressure on {v.label or v.id} ({(set_pa + ATMOSPHERE) / PSI:.1f} psia)"
1053 for v, p in [(b, fill_psia) for b in bottles] + [(t, target_psia) for t in tanks if t.id in roles.values()]
1054 for label, set_pa in reliefs.get(v.id, ()) if p * PSI > set_pa + ATMOSPHERE]
1055 if lifts:
1056 said = "; ".join(lifts)
1057 add("relief", "Relief valves at T-0", "warn",
1058 said[:1].upper() + said[1:] + ": it lifts during the settle to lockup, so the burn starts from what "
1059 "the relief leaves, not from the pressure asked (the run's T-0 event says what it was).")
1060
1061 # The ratings themselves: a pass against a rating nobody measured is only as good as the guess,
1062 # and a vessel with no rating on the drawing is checked against nothing at all.
1063 unrated = [v.label or v.id for v in list(tanks) + list(bottles) if v.id not in trips]
1064 guessed = [f"{v.label or v.id} trips at {trips[v.id]['words']}" for v in list(tanks) + list(bottles)
1065 if v.id in trips and trips[v.id]["source"].lower() in ("estimated", "default")]
1066 if unrated or guessed:
1067 add("ratings", "Vessel ratings", "warn",
1068 (f"No MAWP or burst pressure on the drawing for {', '.join(unrated)}: nothing checks those vessels. "
1069 if unrated else "")
1070 + (f"Estimated, not measured or from a datasheet: {'; '.join(guessed)}. The rating checks and the "
1071 "optimiser's tank-pressure ceiling stand on these." if guessed else ""))
1072
1073 gn2 = gn2_on_lox(bottles[0].fluid if bottles else None,
1074 next((t for t in tanks if t.id == roles.get("oxidiser")), None), target_pa,
1075 test_mode=settings.test_mode or "hotfire",
1076 acknowledged=bool(settings.ack_gn2_condensation))
1077 if gn2 is not None:
1078 add("gn2_condensation", "Nitrogen over LOX", gn2["status"], gn2["detail"])
1079
1080 # ---- the load -------------------------------------------------------------
1081 loads: Optional[Dict[str, float]] = None
1082 tank_volumes = {t.id: (t.params["volume"].si if "volume" in t.params else None) for t in tanks}
1083 rho_tank = {}
1084 if len(roles) == 2:
1085 for side in ("oxidiser", "fuel"):
1086 rho_tank[side] = fluid_at(side, target_pa)[0]
1087 if settings.load == "config":
1088 loads = {roles["oxidiser"]: float(config.lox_tank.mass), roles["fuel"]: float(config.fuel_tank.mass)}
1089 for side, tank_id in roles.items():
1090 vol = tank_volumes.get(tank_id)
1091 liquid = loads[tank_id] / rho_tank[side]
1092 if vol is None:
1093 continue
1094 frac = liquid / vol
1095 if frac >= 1.0:
1096 add(f"load_{side}", f"{side.capitalize()} load fits", "fail",
1097 f"{loads[tank_id]:.3f} kg is {liquid * 1e3:.2f} L in a {vol * 1e3:.2f} L tank.")
1098 elif frac > 0.95 or frac < 0.25:
1099 add(f"load_{side}", f"{side.capitalize()} load fits", "warn",
1100 f"{loads[tank_id]:.3f} kg fills {frac * 100:.0f} % of the {vol * 1e3:.2f} L tank.")
1101 else:
1102 add(f"load_{side}", f"{side.capitalize()} load fits", "ok",
1103 f"{loads[tank_id]:.3f} kg fills {frac * 100:.0f} % of the {vol * 1e3:.2f} L tank.")
1104 # The drawing's tanks against the config's.
1105 for side, cfg_tank in (("oxidiser", config.lox_tank), ("fuel", config.fuel_tank)):
1106 cfg_vol = getattr(cfg_tank, "tank_volume_m3", None)
1107 vol = tank_volumes.get(roles[side])
1108 if cfg_vol and vol and abs(vol - cfg_vol) / cfg_vol > 0.05:
1109 add(f"volume_{side}", f"{side.capitalize()} tank volume: drawing vs config", "warn",
1110 f"The drawing's tank is {vol * 1e3:.2f} L; the config's is {cfg_vol * 1e3:.2f} L. "
1111 "The burn follows the drawing. A different ullage is a different blowdown.")
1112 press = getattr(config, "press_tank", None)
1113 cfg_copv_L = getattr(press, "free_volume_L", None) if press is not None else None
1114 if bottles and cfg_copv_L and "volume" in bottles[0].params:
1115 drawn_L = bottles[0].params["volume"].si * 1e3
1116 if abs(drawn_L - cfg_copv_L) / cfg_copv_L > 0.05:
1117 add("volume_copv", "Bottle volume: drawing vs config", "warn",
1118 f"The drawing's bottle is {drawn_L:.3f} L; the config's is {cfg_copv_L:.3f} L.")
1119
1120 # ---- the tanks at burnout: lockup plus the regulator's supply-pressure effect ------------
1121 # The lockup is checked against the MAWP above; the regulator then lifts the tanks as the
1122 # bottle falls. Estimate the end pressure from the drawing's own regulator law and say when it
1123 # crosses a rating or the design's cap. A warning: the burn itself prices it exactly.
1124 try:
1125 rise = _burnout_tank_check(probe_model, dome, bottles[0], copv_psig, target_pa, roles, tanks, tank_volumes,
1126 loads, rho_tank, settings, ambient, config, trips=trips, reliefs=reliefs)
1127 except Exception as exc: # noqa: BLE001 - an estimate that cannot be made is said, not raised
1128 rise = {"status": "info", "detail": f"Could not estimate the tanks' rise over the burn ({type(exc).__name__}: {exc})."}
1129 rise_estimate = None
1130 if rise is not None:
1131 add("tank_rise", "Tanks at burnout (regulator supply effect)", rise["status"], rise["detail"])
1132 rise_estimate = rise.get("estimate")
1133
1134 # ---- the state machine ----------------------------------------------------
1135 try:
1136 machine = load_machine(tables=machines_dir())
1137 except Exception as exc: # noqa: BLE001
1138 add("machine", "Valve sequence (DAQ state machine)", "fail", str(exc))
1139 return prep
1140 from feedtwin.session.statemachine import bind
1141
1142 labels = {n.id: n.label for n in diagram.nodes if n.id in probe_model.built.actuators}
1143 binding = bind(machine, labels, roles=probe_model.built.valve_roles)
1144 mains = [a for a in machine.actuators if "main" in a.lower()]
1145 unbound_mains = [a for a in mains if a not in binding.to_symbol]
1146 if unbound_mains:
1147 add("mains", "Main valves bound to the Fire state", "fail",
1148 f"{', '.join(unbound_mains)} has no valve on the drawing, so Fire would not open it.")
1149 else:
1150 add("mains", "Main valves bound to the Fire state", "ok",
1151 ", ".join(f"{a} → {binding.to_symbol[a]}" for a in mains))
1152 prep.machine = machine
1153
1154 # ---- the engine, linked to EngineDesign -----------------------------------------
1155 try:
1156 link = link_engine(config, tank_pa_O=target_pa, tank_pa_F=target_pa, cea_path=cea,
1157 ambient_pa=ambient, fluid_at=fluid_at, mode=settings.engine_model,
1158 card_center_pa=(settings.card_center_psia * PSI if settings.card_center_psia else None))
1159 except Exception as exc: # noqa: BLE001
1160 add("engine_link", "EngineDesign solves at T-0", "fail", f"{type(exc).__name__}: {exc}")
1161 return prep
1162 prep.link = link
1163 if True:
1164 # AUDIT D4-B: the card's vacuum velocity follows the eroding throat's expansion ratio, always
1165 # (2026-10-03). At the as-built throat it is the plain card chamber, bit for bit.
1166 if link.mode != "card":
1167 add("card_eroded_nozzle", "Card nozzle follows the eroding throat", "info",
1168 f"The engine is '{link.mode}', not the engine card: nothing to scale.")
1169 else:
1170 from engine.layerx.card import expansion_chamber
1171
1172 link.chamber = expansion_chamber(link.card, link.sampler.runner.cea_cache, ambient_pressure=ambient,
1173 volume=link.design.chamber_volume)
1174 add("card_eroded_nozzle", "Card nozzle follows the eroding throat", "info",
1175 "The engine card's vacuum velocity is scaled by Cf_vac(ε(t))/Cf_vac(ε0) from EngineDesign's CEA table "
1176 "as the replay's throat opens (AUDIT D4-B)"
1177 + ("." if link.chamber.eps_axis else "; this CEA table has no expansion-ratio axis, so the scale is 1."))
1178 cal = link.calibration
1179 if link.mode == "card":
1180 fit = cal["fit"]
1181 prov = cal["card"]
1182 within = bool(prov.get("within_tolerance"))
1183 add("engine_card", "Engine card from EngineDesign", "ok" if within else "warn",
1184 f"{prov['samples']} EngineDesign solves at the line exit ({prov['sampler']}, "
1185 f"{prov['built_s']:.1f} s). The table reproduces EngineDesign's own model to Pc "
1186 f"{fit['envelope_closed_pc'] * 100:.2f} %, thrust {fit['envelope_closed_thrust'] * 100:.2f} %, flow "
1187 f"{fit['envelope_closed_mdot'] * 100:.2f} % on {int(fit['envelope_points'])} held-out solves (tolerance "
1188 f"{fit['tolerance'] * 100:.1f} %; whole scan Pc {fit['box_closed_pc'] * 100:.1f} %). That is agreement "
1189 "with the model, not accuracy: the model's own unknowns (Cd, mixing, nozzle efficiency) are the "
1190 "uncertainty sweep's.")
1191 # The reference is EngineDesign solved with the line exit at the lockup itself (link_engine is
1192 # given the tank pressure on both sides), so it is the drop with the drawing's lines lossless,
1193 # not the burn's: LE4 (3) read 153.1 / 153.1 psi here against ~114 / 108 psi once the lines
1194 # have taken their ~50 psi.
1195 add("engine_boundary", "Engine boundary at the line exit", "info",
1196 f"EngineDesign at the {target_psia:.1f} psia lockup with the drawing's lines taken as lossless "
1197 f"(line exit = tank): line exit to chamber LOX {link.reference['dp_line_O'] / PSI:.1f} psi, fuel "
1198 f"{link.reference['dp_line_F'] / PSI:.1f} psi, its exit dump into the manifold included. The burn's "
1199 "own drops are lower by what the drawing's lines lose (the run's injector ΔP). The drawing's lines "
1200 "carry everything upstream; EngineDesign's own feed_system K is not used.")
1201 elif link.mode == "calibrated":
1202 add("engine_link", "Engine fitted to EngineDesign at T-0", "warn",
1203 f"Cd {cal['cd_O']:.3f} / {cal['cd_F']:.3f} (twin's own law: {cal['native_cd_O']:.3f} / "
1204 f"{cal['native_cd_F']:.3f}); η_c* {cal['eta_cstar']:.4f}, η_n {cal['eta_n']:.4f}, held constant "
1205 "through the burn. The engine card follows EngineDesign everywhere instead.")
1206 else:
1207 gap = twin_injector_check(link, fluid_at)
1208 add("engine_link", "Engine: the twin's native model", "warn",
1209 "Not EngineDesign. At EngineDesign's T-0 flows the twin's injector drops "
1210 + ", ".join(f"{side} {tw / PSI:.0f} psi vs {ed / PSI:.0f}" for side, (tw, ed) in gap.items())
1211 + "; its chamber runs η_c* = 1, so Pc is an upper bound.")
1212 for note in link.notes:
1213 add("engine_note", "Engine link", "warn", note)
1214 if link.mode == "card":
1215 # The card's injector is a flow capacity tabulated at EngineDesign's propellant densities;
1216 # the twin's own density at the inlet does not move it. Say how far apart the two are.
1217 gaps = []
1218 for side, key in (("oxidiser", "oxidizer"), ("fuel", "fuel")):
1219 fluid = (config.fluids or {}).get(key)
1220 rho_ed = float(getattr(fluid, "density", 0.0) or 0.0)
1221 if rho_ed > 0.0:
1222 rho_twin = fluid_at(side, target_pa)[0]
1223 gaps.append((side, rho_ed, rho_twin, rho_twin / rho_ed - 1.0))
1224 if gaps:
1225 worst = max(abs(g[3]) for g in gaps)
1226 add("density", "Propellant density: EngineDesign vs feed model", "warn" if worst > 0.01 else "info",
1227 "; ".join(f"{side} {ed:.1f} vs {tw:.1f} kg/m³ ({gap * 100:+.1f} %)" for side, ed, tw, gap in gaps)
1228 + ". The engine card is tabulated at EngineDesign's densities; flow scales with √ρ, so the flow "
1229 "error is half the density gap.")
1230
1231 # ---- the calibrated model, the plan and the setup --------------------------
1232 def assembler() -> Any:
1233 return assemble_model(diagram, diagram_id=drawing.id, engine=link.design, chamber=link.chamber,
1234 engine_reference=f"live config {prep.config_sha256[:12]}",
1235 meta={"diagram_name": drawing.name, "diagram_sha256": drawing.sha256})
1236
1237 try:
1238 model = assembler()
1239 except Exception as exc: # noqa: BLE001
1240 add("assembly", "Drawing assembles with the engine", "fail", str(exc))
1241 return prep
1242 defaults = model.report.unchecked
1243 add("assumptions", "Unspecified drawing parameters", "info" if not defaults else "warn",
1244 f"{defaults} library default(s), {len(model.report.assumptions)} assumed value(s) in total; "
1245 "listed under Details.")
1246
1247 prep.model = model
1248 prep.assembler = assembler
1249 prep.setup = feed_twin_setup(
1250 # The chamber closure finer than the engine card's own error (~0.02 %), so a thrust
1251 # quoted to a tenth of a percent is the card's, not the solver's tolerance.
1252 chamber_tolerance_psi=CHAMBER_TOLERANCE_PSI,
1253 network_tolerance=NETWORK_TOLERANCE,
1254 # The regulator's supply-pressure effect at lockup and the line and fitting heat are the
1255 # feed twin's own (both on there since 2026-10-03); the first is also what lets 1e-6 solve
1256 # at lockup.
1257 dome_psi=dome,
1258 **{k: v for k, v in (("ullage_collapse", settings.ullage_collapse), ("ullage_vapour", settings.ullage_vapour),
1259 ("chilldown", settings.chilldown), ("line_walls", settings.line_walls)) if v is not None},
1260 )
1261 prep.plan = BurnPlan(
1262 tank_psi=psig_from_psia(target_psia),
1263 copv_psi=copv_psig,
1264 fill_fraction=settings.fill_fraction,
1265 loads=loads,
1266 hold_s=settings.hold_s,
1267 settle=settings.settle,
1268 dt=settings.dt,
1269 horizon_s=settings.horizon_s,
1270 tanks=(roles["oxidiser"], roles["fuel"]) if len(roles) == 2 else None,
1271 # The burn ends when the first tank runs dry, not on the step after: burn time, impulse
1272 # and the bottle at burnout are then continuous in the design, which every comparison
1273 # Layer X makes (sweep, optimiser, pad against flight) depends on.
1274 end_on_depletion=True,
1275 dry_kg=settings.dry_kg,
1276 )
1277 gaps = _feed_line_gaps(prep)
1278 if gaps:
1279 # Said up front (W14): the graded chug margin is on the drawing's own lines (2026-10-03), and the
1280 # line losses are the drawing's, so a line the drawing does not give is a gap in both. LE4 (3):
1281 # its LOX path is drawn as direct connections and its fuel tank line has a 0 mm bore; the graded
1282 # margin there is 1.004 (warn) against 1.21 on EngineDesign's own feed_system.
1283 add("feed_lines", "Feed lines on the drawing", "warn",
1284 "; ".join(gaps) + ". The graded chug margin and the line losses rest on what is drawn: give these "
1285 "lines a length and bore in pid-designer, or restate them under Enter measured values.")
1286 lead = float(getattr(prep.plan, "lead_in_s", 0.5))
1287 if abs(lead / settings.dt - round(lead / settings.dt)) > 1e-6:
1288 # The firing clock starts at Fire; a step that straddles it is counted short, and the first
1289 # firing step's impulse with it (3 % of the burn at 0.2 s).
1290 add("time_step", "Time step", "fail",
1291 f"{settings.dt * 1e3:g} ms does not divide the {lead:g} s before Fire into whole steps. "
1292 "Use 10, 20, 50 or 100 ms.")
1293 return prep
1294 add("residual", "Burn ends with the tanks empty", "info",
1295 f"The burn ends when the first tank is down to {settings.dry_kg * 1e3:g} g"
1296 + (": burnt dry. No unusable residual (sump, line, pull-through) is stated; enter a measured one "
1297 "to charge for it." if settings.dry_kg <= 0.002 else ", the unusable residual you stated."))
1298 req = getattr(config, "design_requirements", None)
1299
1300 def band(side: str) -> Optional[List[float]]:
1301 lo = getattr(req, f"injector_dp_ratio_{side}_min", None) if req is not None else None
1302 hi = getattr(req, f"injector_dp_ratio_{side}_max", None) if req is not None else None
1303 return [float(lo), float(hi)] if lo is not None and hi is not None else None
1304
1305 prep.derived = {
1306 # The thermal models the burn runs, as the feed twin sets them (or as this run overrides
1307 # them): the rail shows them, read-only, so the setup on screen is the twin's.
1308 "feed_twin_thermal": {
1309 k: getattr(prep.setup, k) for k in ("ullage_collapse", "ullage_vapour", "chilldown", "line_walls",
1310 "stratification", "wall_boiling", "chilldown_nucleate", "boiling_onset_K")
1311 if hasattr(prep.setup, k)},
1312 "stiffness_band": {"oxidiser": band("O"), "fuel": band("F")},
1313 "roles": roles,
1314 "species": species,
1315 "pressurant_gas": (bottles[0].fluid if bottles else None),
1316 "target_lockup_psia": target_psia,
1317 "dome_psig": dome,
1318 "copv_psig": copv_psig,
1319 "copv_psia": (from_psig(copv_psig)) / PSI,
1320 "copv_id": bottles[0].id if bottles else None,
1321 # A bottle the drawing gives no volume is the size the twin's burn assumed for it, not a blank:
1322 # the flight and the pressurant diagnostics must fly the bottle the burn emptied.
1323 "copv_volume_L": (None if not bottles else bottles[0].params["volume"].si * 1e3 if "volume" in bottles[0].params
1324 else getattr(prep.setup, "bottle_volume_L", None)),
1325 # The pressure each vessel trips the stand at [psia, absolute], as the twin holds it
1326 # (vessel_trip: burst / safety factor, else the MAWP). Named for the MAWP it held when only
1327 # an MAWP was read; it was always MAWP.si, absolute, and stays absolute.
1328 "copv_mawp_psi": (trips[bottles[0].id]["trip_psia"] if bottles and bottles[0].id in trips else None),
1329 "copv_drawn_psig": drawn_copv,
1330 # The rail's absolute pressures (2026-10-07): the dial, where it came from, which regulator it
1331 # sets, what the drawing says it is, every regulator it could be on, and the drawn bottle.
1332 "dome_psia": dome + atm_psia,
1333 "dome_source": source,
1334 "dome_regulator": knob["id"] if knob is not None else None,
1335 "dome_regulator_label": knob["label"] if knob is not None else None,
1336 "dome_drawn_psia": None if drawn_dome is None else drawn_dome + atm_psia,
1337 "dome_candidates": knobs,
1338 "copv_drawn_psia": None if drawn_copv is None else drawn_copv + atm_psia,
1339 "tank_mawp_psi": {tank_id: r["trip_psia"] for tank_id, r in rated_tanks.items()},
1340 # What each rated vessel trips at and why, for the words around those numbers.
1341 "vessel_ratings": {v.id: {"label": v.label or v.id, "kind": "bottle" if v.id in bottle_ids else "tank",
1342 **trips[v.id]}
1343 for v in list(tanks) + list(bottles) if v.id in trips},
1344 "loads_kg": loads,
1345 "fill_fraction": None if loads else settings.fill_fraction,
1346 "tank_volumes_L": {k: (v * 1e3 if v else None) for k, v in tank_volumes.items()},
1347 "ambient_pa": ambient,
1348 "gauge_zero_pa": ATMOSPHERE,
1349 "elevation_m": elevation,
1350 "inlet_nodes": inlets,
1351 "engine_model": link.mode,
1352 "cea_table": cea.name if cea else None,
1353 "config_sha256": prep.config_sha256,
1354 "binding": dict(binding.to_symbol),
1355 "unbound": list(binding.unmatched),
1356 "machine_warnings": list(machine.warnings),
1357 "overrides": prep.overrides,
1358 "vehicle_lines": prep.vehicle_lines or None,
1359 "vehicle_dry_kg": _vehicle_dry_kg(config),
1360 "tank_rise_estimate": rise_estimate,
1361 "options": options(settings),
1362 }
1363 return prep
#define max(a, b)
Definition Arduino.h:43
"LayerXSettings" from_dict(cls, Dict[str, Any] raw)
Definition prepare.py:158
Dict[str, Any] preflight(self)
Definition prepare.py:593
@ Setup
Definition main.cpp:72
Optional[Dict[str, Any]] vessel_trip(Any node, float safety_factor)
Definition prepare.py:340
Optional[float] lockup_for_dome(Any model, float dome_psig, float supply_pa, Optional[str] regulator=None)
Definition prepare.py:729
LayerXSettings at_operating_point(LayerXSettings settings, *, Optional[float] lockup_psia=None, Optional[float] fill_psig=None)
Definition prepare.py:164
Tuple[Optional[float], Optional[float]] _outlet_pair(Any value)
Definition prepare.py:182
Optional[Dict[str, str]] gn2_on_lox(Optional[str] pressurant, Any ox_tank, float lockup_pa, *, str test_mode="hotfire", bool acknowledged=False)
Definition prepare.py:267
Optional[float] _vehicle_dry_kg(Any config)
Definition prepare.py:543
Optional[Dict[str, Any]] _burnout_tank_check(Any model, float dome, Any bottle, float copv_psig, float lockup_pa, Dict[str, str] roles, List[Any] tanks, Dict[str, Optional[float]] volumes, Optional[Dict[str, float]] loads, Dict[str, float] rho, LayerXSettings settings, float ambient, Any config, Optional[Dict[str, Dict[str, Any]]] trips=None, Optional[Dict[str, List[Tuple[str, float]]]] reliefs=None)
Definition prepare.py:455
Any feed_twin_setup(**Any changes)
Definition prepare.py:194
Optional[float] dome_for_lockup(Any model, float target_pa, float supply_pa, Optional[str] regulator=None)
Definition prepare.py:757
List[Dict[str, Any]] dome_knobs(Any model)
Definition prepare.py:682
Optional[str] default_dome_regulator(Any model)
Definition prepare.py:694
Dict[str, Any] expected_tank_rise(Any model, *, float dome_psig, str gas, float bottle_pa, float bottle_K, float bottle_m3, float lockup_pa, float ullage_m3, float expelled_m3, int iterations=12)
Definition prepare.py:405
Optional[float] _stated_requirement(Any config, str key)
Definition prepare.py:327
List[str] _feed_line_gaps(Any prep)
Definition prepare.py:517
Optional[float] _knob_lockup(Any model, str regulator, float dome_psig, float supply_pa)
Definition prepare.py:702
Dict[str, str] _inlet_nodes(Any built)
Definition prepare.py:653
List[str] _option_problems(LayerXSettings settings)
Definition prepare.py:239
List[Tuple[str, float]] vessel_reliefs(Any diagram, str vessel_id)
Definition prepare.py:378
Dict[str, Any] options(LayerXSettings settings)
Definition prepare.py:214
List[Tuple[str, Any, str]] _dome_regulators(Any model)
Definition prepare.py:665
Tuple[Dict[str, Any], int, str] swap_pressurant(Dict[str, Any] payload, str gas)
Definition prepare.py:606