STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
stability.py
Go to the documentation of this file.
1"""Chug margin over the burn, on the drawing's feed impedance: ``diagnostics.stability``.
2
3DATA-CONTRACT section 3, ``stability``. Layer X audit D7 and appendix 9.4.
4
5What Layer X's replay reports today (the ``config`` basis) is EngineDesign's chug gate at 28
6replay points: the Nyquist gain margin of the lumped feed / injector / chamber loop with a double
7time lag (Leonardi et al. 2017), taken at the worst point of the unmeasured mixing-lag band. Three
8of its inputs do not come from the stand that is burning (audit 9.4 sections 1-3):
9
10* the feed **inertance** is ``feed_system.<side>.length / A_hydraulic`` from the engine config,
11 not the drawing's lines;
12* the feed **resistance** is the Borda exit dump alone, because the replay runs on the line-exit
13 copy of the config (``engine/layerx/card.py``), which zeroes K0;
14* **A_t and L*** are the design point's, while the chamber erodes.
15
16This module re-evaluates the same loop at the same points and offers the drawing's basis:
17
18``basis="drawing"`` (D7 B+C)
19 I = sum of L/A over the drawing's lines from the tank outlet to the injector inlet (the
20 assembled network's path, each line's own bore); R = 2 dp/mdot with dp = the twin's own
21 tank-outlet -> line-exit drop at that instant, plus the Borda dump EngineDesign prices at the
22 line exit (the same dump the config basis carries). A side whose drawing declares no line, or
23 a run with no outlet pressures, keeps the config's value for that side and says so: turned on
24 against a drawing that declares nothing, the basis changes nothing.
25``eroded=True`` (D7 D)
26 The stability call sees each point's eroded geometry (A_t, V, L*, bore, A_e) instead of the
27 design point's, exactly what ``TimeVaryingCoupledSolver(chug_eroded_geometry=True)`` does.
28``start_window`` (D7 E)
29 The margin is *graded* from the first fully-open step: the main valves' travel (the drawing's
30 ``travel_time``, else the twin's ``Setup.valve_travel_s``) plus ``settle_tau`` line time
31 constants. Steps inside that window are evaluated and reported (``worst``) but not graded
32 (``settled_min``): every one of them is a quasi-steady part-open state of an algebraic network
33 whose number is set by ``dt`` (audit 5.1: 1.367 / 1.308 / 1.161 / 0.797 at 50 / 10 / 5 / 2 ms).
34
35The config basis at the replay points reproduces the replay's ``chug_margin`` exactly (the chamber
36is re-solved on the replay's own geometry at the replay's own line-exit pressures, and the gate is
37EngineDesign's own ``_chug_fast`` + ``chug_band``); ``check`` in the output says by how much.
38
39Public functions
40----------------
41``stability_block(prep, result, config=None, basis="config", eroded=False, start_window="auto")``
42 -> the ``diagnostics.stability`` dict. Never raises: ``{"available": False, "error": ...}``.
43``drawing_feed(prep)`` -> per side, the drawing's lines on the propellant path and their inertance.
44"""
45
46from __future__ import annotations
47
48import copy
49import math
50from dataclasses import replace
51from typing import Any, Dict, List, Optional, Sequence, Tuple
52
53import numpy as np
54
55PSI = 6894.757293168361
56
57#: Line time constants allowed after the mains are fully open before the margin is graded.
58#: Assumed: 1 - e^-3 = 95 % of a first-order (R-L) line's response to the valve's last step. The
59#: twin's network is algebraic, so this is a statement about the real line, not the model.
60SETTLE_TAU = 3.0
61#: At most this many firing steps inside the start window are evaluated (first and last kept).
62START_POINTS_MAX = 24
63#: Points in the combustion-lag sweep across the model's mixing-lag band.
64TAU_SWEEP_POINTS = 41
65#: Nyquist plot frequency grid [Hz]: the chug model's own scan range, log spaced.
66NYQUIST_HZ = (2.0, 2000.0, 400)
67#: A line mode within this fraction of the chug frequency is flagged.
68NEAR_CHUG = 0.20
69
70SIDES = (("oxidiser", "oxidizer", "O", "ox"), ("fuel", "fuel", "F", "fuel"))
71
72#: Young's modulus of the feed tube [Pa] for the Korteweg wave-speed correction. Type 316
73#: stainless at room temperature: 28.0e6 psi (193 GPa), the producers' published value (e.g. the
74#: AK Steel 316/316L product data bulletin); ASME BPVC Section II Part D Table TM-1 gives 28.3e6
75#: psi (195 GPa) for austenitic stainless at 70 F. The same number as
76#: engine/pipeline/stability/analysis.py ``_TUBE_E_STAINLESS``. The drawing has no material field:
77#: "316" appears only in its wall-thickness reference text, so this is an assumption, and E rises
78#: a few per cent at 90 K, which is not modelled. +-10 % in E moves the LE4 wave speeds by
79#: ~+-0.3 % (audit 9.4 section 4).
80TUBE_MODULUS_PA = 193.0e9
81TUBE_MODULUS_SOURCE = ("assumed: type 316 stainless, 28.0e6 psi (193 GPa) room temperature (producers' data; "
82 "ASME BPVC II-D Table TM-1: 28.3e6 psi at 70 F); the drawing names no material, "
83 "only '316' in its wall reference text")
84
85
86# ---------------------------------------------------------------------------------------------
87# small helpers
88# ---------------------------------------------------------------------------------------------
89
90
91def _num(v: Any) -> Optional[float]:
92 try:
93 f = float(v)
94 except (TypeError, ValueError):
95 return None
96 return f if math.isfinite(f) else None
97
98
99def _param_record(param: Any, si: Optional[float], unit: str, fallback: str) -> Dict[str, Any]:
100 """``{value, unit, provenance}`` for one drawn number (a feedtwin ``Param``), in SI."""
101 if param is None:
102 return {"value": si, "unit": unit, "provenance": fallback}
103 src = getattr(getattr(param, "source", None), "value", None) or str(getattr(param, "source", ""))
104 ref = str(getattr(param, "reference", "") or "")
105 return {"value": si, "unit": unit,
106 "provenance": f"drawing ({src}): {param.value:g} {param.unit}" + (f" -- {ref}" if ref else "")}
107
108
109def _runner(prep: Any, config: Any) -> Any:
110 link = getattr(prep, "link", None)
111 sampler = getattr(link, "sampler", None) if link is not None else None
112 if sampler is not None:
113 return sampler.runner
114 if config is None:
115 raise ValueError("no EngineDesign link and no config")
116 from engine.core.runner import PintleEngineRunner
117 from engine.layerx.card import line_exit_config
118
119 return PintleEngineRunner(line_exit_config(config))
120
121
122# ---------------------------------------------------------------------------------------------
123# the drawing's feed path
124# ---------------------------------------------------------------------------------------------
125
126
127def drawing_feed(prep: Any) -> Dict[str, Dict[str, Any]]:
128 """Per side (``oxidiser``/``fuel``), the drawing's path from the tank outlet to the injector
129 inlet as the twin assembled it.
130
131 Returns ``{side: {"lines": [...], "others": [...], "inertance": I [1/m] or None,
132 "flags": [str]}}``. Each line: ``id, label, length_m, bore_m, area_m2, wall_m, inertance``
133 and the provenance of each number. ``others`` are the inline components on the path (valves,
134 fittings) with their ``travel_s`` where they are actuated. Read only: the drawing is the feed
135 system. A line the drawing gives no length or bore keeps the number the twin burned with
136 (feed-twin's fallback) and is flagged.
137 """
138 from engine.layerx.flight import _path_to_tank
139
140 model = prep.model
141 built = model.built
142 net = built.network
143 diagram = getattr(model, "diagram", None)
144 edges = {e.id: e for e in (diagram.edges if diagram is not None else ())}
145 nodes = {n.id: n for n in (diagram.nodes if diagram is not None else ())}
146 owner: Dict[str, str] = {}
147 for did, bids in (built.branches_of or {}).items():
148 for b in bids:
149 owner[b] = did
150 setup_travel = _num(getattr(getattr(prep, "setup", None), "valve_travel_s", None))
151
152 out: Dict[str, Dict[str, Any]] = {}
153 for side, _, _, _ in SIDES:
154 path = _path_to_tank(model, prep.inlet_nodes.get(side, ""), prep.roles.get(side, ""))
155 lines: List[Dict[str, Any]] = []
156 others: List[Dict[str, Any]] = []
157 flags: List[str] = []
158 seen_edges: set = set()
159 for bid in path:
160 did = owner.get(bid, bid)
161 br = net.branches[bid]
162 p = dict(getattr(br.component, "p", {}) or {})
163 edge = edges.get(did)
164 if edge is not None:
165 if did in seen_edges:
166 continue # a segmented line is several branches of one edge
167 seen_edges.add(did)
168 segs = list(getattr(edge.segments, "segments", ()) or ())
169 if segs:
170 for k, seg in enumerate(segs):
171 L = float(seg.tube_length())
172 d = float(seg.bore_si)
173 lines.append(_line_record(f"{did}#{k}", did, L, d, edge, seg.length, seg.bore,
174 elevation=getattr(seg, "elevation_change", None)))
175 else:
176 L = _num(p.get("length"))
177 d = _num(p.get("bore"))
178 lines.append(_line_record(did, did, L, d, edge, edge.params.get("length"),
179 edge.params.get("bore")))
180 continue
181 node = nodes.get(did)
182 travel_param = (node.params.get("travel_time") if node is not None else None)
183 travel = _num(p.get("travel_time"))
184 rec: Dict[str, Any] = {"id": did, "label": getattr(node, "label", did) if node else did,
185 "type": getattr(node, "type", type(br.component).__name__) if node else
186 type(br.component).__name__,
187 "bore_m": _num(p.get("bore")),
188 "actuated": did in (built.actuators or {})}
189 if rec["actuated"]:
190 if travel is not None and travel > 0:
191 rec["travel"] = _param_record(travel_param, travel, "s", "feed-twin component default")
192 else:
193 rec["travel"] = {"value": setup_travel, "unit": "s",
194 "provenance": "feed-twin Setup.valve_travel_s (the drawing gives no travel_time)"}
195 others.append(rec)
196 flags.append(f"{rec['label']}: no length on the drawing, so its body's inertance is not counted")
197 for ln in lines:
198 if ln["length_m"] is None or ln["bore_m"] is None or ln["bore_m"] <= 0:
199 flags.append(f"{ln['label']}: no usable length/bore; left out of the inertance")
200 elif ln["defaulted"]:
201 flags.append(f"{ln['label']}: length or bore not on the drawing; the twin's fallback is used")
202 usable = [ln for ln in lines if ln["inertance"] is not None]
203 inertance = float(sum(ln["inertance"] for ln in usable)) if usable else None
204 guessed = [ln["label"] for ln in lines if (ln.get("length_provenance") or "").startswith("drawing (estimated)")]
205 if guessed and len(guessed) == len(lines):
206 flags.append("every length on this path is 'estimated' on the drawing: measure the runs")
207 elif guessed:
208 flags.append(f"lengths 'estimated' on the drawing: {', '.join(guessed)}")
209 out[side] = {"lines": lines, "others": others, "inertance": inertance, "flags": flags,
210 "path": list(path)}
211 return out
212
213
214def _line_record(rid: str, did: str, L: Optional[float], d: Optional[float], edge: Any,
215 length_param: Any, bore_param: Any, elevation: Any = None) -> Dict[str, Any]:
216 wall_param = edge.params.get("wall_thickness") if edge is not None else None
217 wall = _num(getattr(wall_param, "si", None)) if wall_param is not None else None
218 area = math.pi * d * d / 4.0 if (d is not None and d > 0) else None
219 inert = (L / area) if (L is not None and L > 0 and area) else None
220 lp = _param_record(length_param, L, "m", "feed-twin fallback (not on the drawing)")
221 bp = _param_record(bore_param, d, "m", "feed-twin fallback (not on the drawing)")
222 wp = _param_record(wall_param, wall, "m", "not on the drawing")
223 elev = elevation if elevation is not None else (edge.params.get("elevation_change") if edge is not None else None)
224 return {
225 "id": rid, "edge": did, "label": did, "length_m": L, "bore_m": d, "area_m2": area,
226 "wall_m": wall, "inertance": inert,
227 "elevation_m": _num(getattr(elev, "si", None)) if elev is not None else None,
228 "length_provenance": lp["provenance"], "bore_provenance": bp["provenance"],
229 "wall_provenance": wp["provenance"],
230 "wall_reference": str(getattr(wall_param, "reference", "") or "") if wall_param is not None else "",
231 "defaulted": length_param is None or bore_param is None,
232 }
233
234
235# ---------------------------------------------------------------------------------------------
236# the evaluation points
237# ---------------------------------------------------------------------------------------------
238
239
240def _replay_geometry(rp: Dict[str, Any], design: Any) -> Optional[Dict[str, np.ndarray]]:
241 """The replay's geometry at its points, with the as-built geometry prepended at Fire (t = 0).
242
243 Newer replays carry V, A_e and the bore; an older one has A_t, L*, eps and the barrel
244 recession, from which the same numbers follow."""
245 t = rp.get("t") or []
246 n = len(t)
247 at = rp.get("A_throat_m2")
248 if not n or not at or len(at) != n:
249 return None
250
251 def col(key: str) -> Optional[List[Optional[float]]]:
252 v = rp.get(key)
253 return list(v) if isinstance(v, list) and len(v) == n else None
254
255 at = [float(v) for v in at]
256 V = col("V_chamber_m3")
257 if V is None and col("Lstar_m") is not None:
258 V = [ls * a if ls is not None else None for ls, a in zip(col("Lstar_m"), at)]
259 Ae = col("A_exit_m2")
260 if Ae is None and col("eps") is not None:
261 Ae = [e * a if e is not None else None for e, a in zip(col("eps"), at)]
262 D = col("D_chamber_mm")
263 D = [v * 1e-3 if v is not None else None for v in D] if D is not None else None
264 if D is None and col("recession_chamber_mm") is not None:
265 D = [float(design.chamber_diameter) + 2e-3 * (r or 0.0) for r in col("recession_chamber_mm")]
266
267 def fill(vals: Optional[List[Optional[float]]], v0: float) -> np.ndarray:
268 if vals is None:
269 vals = [v0] * n
270 return np.array([v0] + [float(v) if v is not None else v0 for v in vals])
271
272 return {
273 "t": np.array([0.0] + [float(v) for v in t]),
274 "A_throat": fill(at, float(design.A_throat)),
275 "volume": fill(V, float(design.volume)),
276 "A_exit": fill(Ae, float(design.A_exit)),
277 "chamber_diameter": fill(D, float(design.chamber_diameter)),
278 }
279
280
281def _geometry_at(geo: Optional[Dict[str, np.ndarray]], t: float, k: Optional[int]) -> Optional[Dict[str, float]]:
282 """Geometry at replay point ``k`` (exact) or at time ``t`` (linear between replay points)."""
283 if geo is None:
284 return None
285 if k is not None:
286 return {key: float(geo[key][k + 1]) for key in ("A_throat", "volume", "A_exit", "chamber_diameter")}
287 return {key: float(np.interp(t, geo["t"], geo[key])) for key in ("A_throat", "volume", "A_exit", "chamber_diameter")}
288
289
290def _with_geometry(config: Any, g: Optional[Dict[str, float]]) -> Any:
291 """A copy of ``config`` on geometry ``g``, set exactly as TimeVaryingCoupledSolver sets it."""
292 from engine.pipeline.config_schemas import ensure_chamber_geometry
293
294 c = copy.deepcopy(config)
295 if g is None:
296 ensure_chamber_geometry(c)
297 return c
298 cg = ensure_chamber_geometry(c)
299 cg.volume = g["volume"]
300 cg.A_throat = g["A_throat"]
301 cg.A_exit = g["A_exit"]
302 cg.expansion_ratio = g["A_exit"] / g["A_throat"]
303 cg.Lstar = g["volume"] / g["A_throat"]
304 cg.chamber_diameter = g["chamber_diameter"]
305 return c
306
307
308# ---------------------------------------------------------------------------------------------
309# the gate at one point
310# ---------------------------------------------------------------------------------------------
311
312
313def _shift(streams: Sequence[Any], dtau: float) -> List[Any]:
314 """Every stream's lag moved by ``dtau`` [s], floored at 0: chug_band's rule exactly."""
315 return [replace(s, tau_conv=max(float(s.tau_conv) + dtau, 0.0)) for s in streams]
316
317
318def _gate(inp: Dict[str, Any]) -> Dict[str, Any]:
319 """EngineDesign's chug gate for one set of stability inputs, with the frequency *at the gate*.
320
321 ``gate`` is exactly what ``compute_physical_stability`` reports as ``chug_gate_margin`` (the
322 same ``_chug_fast`` and ``chug_band``). ``f_gate_hz`` is the chug frequency of the loop at the
323 band point that sets the gate (the worst negative-real-axis crossing of L(iw) there), which is
324 the frequency the engine would ring at if it chugged; ``f_nominal_hz`` is the one at the
325 nominal lag, the only one the replay reported (audit 9.4 section 4: 34.6 vs ~22 Hz)."""
326 from engine.pipeline.stability.analysis import _chug_fast, chug_band
327
328 nominal = _chug_fast(inp["streams"], inp["chamber"])
329 gm = float(nominal.get("gain_margin", float("nan")))
330 band = chug_band(inp, gm)
331 gate = band["min"] if band is not None else gm
332 f_nom = _num(nominal.get("f_chug_hz"))
333 frac = None
334 f_gate = f_nom
335 if band is not None:
336 # chug_band's minimum includes the nominal point, but its ``at_min_fraction`` is the argmin
337 # over the band's own samples only. When the nominal fraction is off that grid and sits
338 # lower than every sample, the nominal *is* the gate: its lag and frequency are the gate's.
339 finite = [g for g in band["gain_margins"] if math.isfinite(g)]
340 if finite and math.isfinite(gm) and gm < min(finite):
341 frac = float(inp["mixing_lag_fraction"])
342 else:
343 frac = float(band["at_min_fraction"])
344 dtau = (frac - float(inp["mixing_lag_fraction"])) * float(inp["tau_mix_basis"])
345 if abs(dtau) > 0.0:
346 f_gate = _num(_chug_fast(_shift(inp["streams"], dtau), inp["chamber"]).get("f_chug_hz"))
347 return {"gate": float(gate), "gm_nominal": gm, "f_gate_hz": f_gate, "f_nominal_hz": f_nom,
348 "gate_fraction": frac, "band": band}
349
350
351def _nyquist(inp: Dict[str, Any], gate: Dict[str, Any], t: float) -> Dict[str, Any]:
352 """The open loop L(iw) of the chug model at one point, on a log frequency grid: at the lag
353 that sets the gate (``re``/``im``) and at the nominal lag (``re_nominal``/``im_nominal``).
354
355 The same transfer functions the gate is computed from (``chug._open_loop_grid``):
356 L(s) = K_c/(theta_c s + 1) * sum_k exp(-s tau_k) / (Z_reg + I_k s + R_k + 1/G_k). The loop is
357 open-loop stable, so the closed loop is stable when L does not encircle -1 (Nyquist); the gain
358 margin is 1/|L| where L crosses the negative real axis."""
359 from engine.pipeline.stability.chug import _open_loop_grid
360
361 lo, hi, n = NYQUIST_HZ
362 f = np.logspace(math.log10(lo), math.log10(hi), int(n))
363 omega = 2.0 * math.pi * f
364 dtau = 0.0
365 if gate.get("gate_fraction") is not None:
366 dtau = (float(gate["gate_fraction"]) - float(inp["mixing_lag_fraction"])) * float(inp["tau_mix_basis"])
367 with np.errstate(over="ignore", invalid="ignore"):
368 L = _open_loop_grid(omega, _shift(inp["streams"], dtau), inp["chamber"])
369 Ln = _open_loop_grid(omega, inp["streams"], inp["chamber"])
370 return {
371 "t": float(t), "omega": [float(w) for w in omega],
372 "re": [float(v) for v in L.real], "im": [float(v) for v in L.imag],
373 "re_nominal": [float(v) for v in Ln.real], "im_nominal": [float(v) for v in Ln.imag],
374 "lag_fraction": gate.get("gate_fraction"), "gain_margin": gate["gate"],
375 "f_cross_hz": gate.get("f_gate_hz"),
376 }
377
378
379def _tau_sweep(inp: Dict[str, Any], n: int = TAU_SWEEP_POINTS) -> Dict[str, Any]:
380 """Gate margin against the combustion time lag across the model's own band.
381
382 The double-time-lag model's unmeasured input is the mixing-lag fraction f in [0, 1]: each
383 stream's lag is tau_k(f) = tau_k(f0) + (f - f0) tau_mix_basis (``chug_band``). The axis is the
384 total lag of the rate-limiting stream. A lag model with no band (the d^2-law) is swept over
385 0.5-1.5 x its own lags instead, and says so."""
386 from engine.pipeline.stability.analysis import _chug_fast
387
388 streams, chamber = inp["streams"], inp["chamber"]
389 rl = str(inp.get("rate_limiting_stream") or streams[0].name)
390 st_rl = next((s for s in streams if s.name == rl), streams[0])
391 band = inp.get("chug_band")
392 out: Dict[str, Any] = {"stream": rl, "nominal_ms": float(st_rl.tau_conv) * 1e3}
393 taus, gms, fr = [], [], []
394 if band:
395 f0, basis = float(inp["mixing_lag_fraction"]), float(inp["tau_mix_basis"])
396 for f in np.linspace(float(band[0]), float(band[1]), int(n)):
397 dtau = (float(f) - f0) * basis
398 taus.append(max(float(st_rl.tau_conv) + dtau, 0.0) * 1e3)
399 gms.append(float(_chug_fast(_shift(streams, dtau), chamber).get("gain_margin", float("nan"))))
400 fr.append(float(f))
401 out.update(parameter="mixing_lag_fraction", fraction=fr, basis_ms=basis * 1e3,
402 band="the model's mixing-lag band (stability.chug_band_mixing_lag_fraction_min/max)")
403 else:
404 for k in np.linspace(0.5, 1.5, int(n)):
405 taus.append(float(st_rl.tau_conv) * float(k) * 1e3)
406 gms.append(float(_chug_fast([replace(s, tau_conv=float(s.tau_conv) * float(k)) for s in streams],
407 chamber).get("gain_margin", float("nan"))))
408 fr.append(float(k))
409 out.update(parameter="lag_scale", fraction=fr,
410 band="assumed 0.5-1.5 x the model's lags: this lag model has no mixing-lag band")
411 out["tau_ms"] = taus
412 out["margin"] = gms
413 finite = [(g, t) for g, t in zip(gms, taus) if math.isfinite(g)]
414 if finite:
415 g, t = min(finite)
416 out["min"] = {"margin": g, "tau_ms": t}
417 return out
418
419
420def _coolprop_liquid(fluid: str, T: float, p: float) -> Tuple[float, float, str]:
421 """(rho [kg/m3], a [m/s], CoolProp name) of the propellant at (T, p). CoolProp (Bell et al.
422 2014, Ind. Eng. Chem. Res. 53(6) 2498-2508): the speed of sound of the reference equation of
423 state at that state, not a handbook constant."""
424 import CoolProp.CoolProp as CP
425
426 name = str(CP.get_fluid_param_string(str(fluid), "name"))
427 return float(CP.PropsSI("D", "T", T, "P", p, name)), float(CP.PropsSI("A", "T", T, "P", p, name)), name
428
429
430def korteweg(a0: float, rho: float, bore: float, wall: float, E: float) -> float:
431 """Pressure-wave speed in a liquid-filled elastic tube [m/s].
432
433 a = a0 / sqrt(1 + K D / (E e)), K = rho a0^2 the liquid's bulk modulus, D the bore, e the wall,
434 E the tube's Young's modulus: Korteweg, D. J., "Ueber die Fortpflanzungsgeschwindigkeit des
435 Schalles in elastischen Roehren", Annalen der Physik und Chemie 241(12), 525-542 (1878); the
436 thin-walled wavespeed of Wylie, Streeter & Suo, Fluid Transients in Systems (Prentice Hall,
437 1993), with its pipe-support factor c1 = 1 (expansion joints throughout). A tube anchored
438 against axial movement has c1 = 1 - nu^2 (0.91 for steel), a ~0.3 % faster wave in LE4's
439 1/2 x 0.035 in. lines: immaterial here, and the slower c1 = 1 is the conservative side for
440 the near-chug flag. A wall of 0 or less, or no E, is a rigid tube (a = a0)."""
441 if not (wall and wall > 0 and E and E > 0 and bore and bore > 0):
442 return float(a0)
443 K = rho * a0 * a0
444 return float(a0 / math.sqrt(1.0 + K * bore / (E * wall)))
445
446
447def _wall_copied(prep: Any) -> Optional[str]:
448 """A flag when every line on the drawing carries the same wall thickness with the same
449 reference across different bores: a value copied from line to line, not measured."""
450 diagram = getattr(getattr(prep, "model", None), "diagram", None)
451 if diagram is None:
452 return None
453 seen: Dict[Tuple[float, str], set] = {}
454 total = 0
455 for e in diagram.edges:
456 w = e.params.get("wall_thickness")
457 b = e.params.get("bore")
458 if w is None:
459 continue
460 total += 1
461 key = (round(float(w.si), 9), str(w.reference or ""))
462 seen.setdefault(key, set()).add(round(float(b.si), 6) if b is not None else None)
463 if not seen:
464 return None
465 (wall, ref), bores = max(seen.items(), key=lambda kv: len(kv[1]))
466 count = sum(1 for e in diagram.edges if e.params.get("wall_thickness") is not None
467 and (round(float(e.params["wall_thickness"].si), 9), str(e.params["wall_thickness"].reference or "")) == (wall, ref))
468 if len(bores) >= 2 and count >= 3:
469 return (f"wall_thickness {wall * 1e3:.3f} mm with the same reference ('{ref[:60]}') on {count} of "
470 f"{total} lines across {len(bores)} bores ({', '.join(f'{b * 1e3:.3f}' for b in sorted(x for x in bores if x))} mm): "
471 "copied line to line, not measured; the Korteweg correction rests on it")
472 return None
473
474
475def acoustics(prep: Any, series: Dict[str, Any], feed_path: Dict[str, Dict[str, Any]], i: int,
476 f_chug: Sequence[float], E: float = TUBE_MODULUS_PA) -> Dict[str, Any]:
477 """Quarter- and half-wave frequencies of every liquid line on each propellant path, and of the
478 whole path, at series step ``i``; a mode within ``NEAR_CHUG`` of the chug band is flagged.
479
480 Each line: CoolProp's speed of sound at the tank's liquid temperature and the line's mean
481 pressure (tank outlet and line exit at that step), Korteweg-corrected for the drawing's bore
482 and wall. f_1/4 = a/(4L) (open at the tank, closed at the injector), f_1/2 = a/(2L). Which end
483 condition holds is set by the injector's impedance 2 dP_inj/mdot against the line's a/A: on LE4
484 that ratio is ~0.13 (1.5e6 vs 1.2e7 Pa s/kg on the fuel side), nearer an open end, so the first
485 mode lies between the two and the quarter-wave is the lower bound -- the side the near-chug
486 flag should err on. The path
487 uses its travel time sum(L_i/a_i). ``lumped_error`` is tan(kL)/kL - 1 at the top of the chug
488 band: how far the lumped inertance L/A the chug model uses is from the line's distributed
489 impedance there."""
490 lo = min(f_chug) if f_chug else float("nan")
491 hi = max(f_chug) if f_chug else float("nan")
492 rows: List[Dict[str, Any]] = []
493 flags: List[str] = []
494 inputs: Dict[str, Any] = {}
495 nodes = {n.id: n for n in getattr(getattr(prep.model, "diagram", None), "nodes", ())}
496
497 def near(*fs: float) -> bool:
498 return bool(math.isfinite(lo) and any(math.isfinite(f) and (1.0 - NEAR_CHUG) * lo <= f <= (1.0 + NEAR_CHUG) * hi
499 for f in fs))
500
501 for side, _, _, skey in SIDES:
502 tank = nodes.get(prep.roles.get(side, ""))
503 fluid = getattr(tank, "fluid", "") if tank is not None else ""
504 s = series.get(skey) or {}
505 T = _num((s.get("liquid_K") or [None] * (i + 1))[i])
506 p_in = _num((s.get("inlet_psia") or [None] * (i + 1))[i])
507 p_out = _num((s.get("outlet_psia") or [None] * (i + 1))[i]) or p_in
508 if not fluid or T is None or p_in is None:
509 flags.append(f"{side}: no fluid, liquid temperature or line pressure at this step; acoustics skipped")
510 continue
511 p = 0.5 * (p_in + p_out) * PSI
512 try:
513 rho, a0, name = _coolprop_liquid(fluid, T, p)
514 except Exception as exc: # noqa: BLE001 - a fluid CoolProp cannot state loses this side's rows, not the margin
515 flags.append(f"{side}: CoolProp has no liquid state for {fluid!r} at {T:.1f} K, {p / PSI:.1f} psia "
516 f"({type(exc).__name__}); acoustics skipped")
517 continue
518 if not (math.isfinite(rho) and math.isfinite(a0) and a0 > 0):
519 flags.append(f"{side}: CoolProp gave no finite sound speed for {name}; acoustics skipped")
520 continue
521 inputs[f"{side}.liquid"] = {"value": {"rho": rho, "a0": a0, "T_K": T, "p_psia": p / PSI}, "unit": "SI",
522 "provenance": f"CoolProp {name} at the tank's liquid temperature and the line's mean pressure"}
523 travel, length = 0.0, 0.0
524 for ln in (feed_path.get(side) or {}).get("lines", []):
525 L, d, e = ln.get("length_m"), ln.get("bore_m"), ln.get("wall_m")
526 if not L or not d:
527 continue
528 a = korteweg(a0, rho, d, e or 0.0, E)
529 if not e:
530 flags.append(f"{ln['label']}: no wall thickness on the drawing; treated as rigid (a = a0, an upper bound)")
531 fq, fh = a / (4.0 * L), a / (2.0 * L)
532 rows.append({"line": ln["label"], "side": side, "length_m": L, "bore_m": d, "wall_m": e,
533 "a0_m_s": a0, "a_m_s": a, "f_quarter_hz": fq, "f_half_hz": fh, "near_chug": near(fq, fh),
534 "length_provenance": ln.get("length_provenance"), "wall_provenance": ln.get("wall_provenance")})
535 travel += L / a
536 length += L
537 if travel > 0:
538 fq, fh = 1.0 / (4.0 * travel), 1.0 / (2.0 * travel)
539 kl = 2.0 * math.pi * hi * travel if math.isfinite(hi) else float("nan")
540 rows.append({"line": f"{prep.roles.get(side, side)} outlet -> injector (whole path)", "side": side,
541 "length_m": length, "a_m_s": length / travel, "f_quarter_hz": fq, "f_half_hz": fh,
542 "near_chug": near(fq, fh),
543 "lumped_error": (math.tan(kl) / kl - 1.0) if (math.isfinite(kl) and 0 < kl < math.pi / 2) else None})
544 copied = _wall_copied(prep)
545 if copied:
546 flags.append(copied)
547 if any(r["near_chug"] for r in rows):
548 flags.append("a feed-line mode sits within 20 % of the chug band: the lumped (no-compliance) line model is "
549 "not adequate there")
550 inputs["tube_modulus"] = {"value": E, "unit": "Pa", "provenance": TUBE_MODULUS_SOURCE}
551 inputs["near_chug_fraction"] = {"value": NEAR_CHUG, "unit": "-",
552 "provenance": "assumed: reporting threshold, a line mode within this fraction "
553 "of the chug band is flagged"}
554 return {"lines": rows, "flags": flags, "inputs": inputs, "chug_band_hz": [lo, hi]}
555
556
557def _solve_point(runner: Any, cfg_now: Any, p_o: float, p_f: float) -> Tuple[float, Dict[str, Any]]:
558 """EngineDesign's chamber at the line-exit pressures, on ``cfg_now``'s geometry, with the
559 diagnostics the stability call reads (as ``TimeVaryingCoupledSolver.solve_time_step``)."""
560 from engine.core.chamber_solver import ChamberSolver
561
562 Pc, d = ChamberSolver(cfg_now, runner.cea_cache).solve(p_o, p_f, Pc_guess=None)
563 MR, mt = d["MR"], d["mdot_total"]
564 diag = {**d, "mdot_O": mt * MR / (1.0 + MR), "mdot_F": mt / (1.0 + MR), "P_tank_O": p_o, "P_tank_F": p_f}
565 return float(Pc), diag
566
567
568def _inputs(stab_config: Any, Pc: float, diag: Dict[str, Any], feed: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
569 from engine.pipeline.config_schemas import ensure_chamber_geometry
570 from engine.pipeline.stability.analysis import build_stability_inputs
571
572 return build_stability_inputs(stab_config, Pc, diag["MR"], diag["mdot_total"], diag["cstar_actual"],
573 diag["gamma"], diag["R"], diag["Tc"], diag,
574 ensure_chamber_geometry(stab_config), feed=feed)
575
576
577def _step_point(series: Dict[str, Any], i: int) -> Dict[str, Any]:
578 """An evaluation point at the twin's own step ``i`` (not a replay point)."""
579 return {"t": float(series["t"][i]), "index": int(i), "k": None,
580 "p_o": float(series["ox"]["inlet_psia"][i]) * PSI,
581 "p_f": float(series["fuel"]["inlet_psia"][i]) * PSI}
582
583
584def start_window_of(prep: Any, result: Dict[str, Any], feed_path: Dict[str, Dict[str, Any]],
585 start_window: Any = "auto", settle_tau: float = SETTLE_TAU) -> Dict[str, Any]:
586 """When the chug margin starts to be graded [s after Fire] (audit D7-E).
587
588 ``t_open``: the main valves on the propellant paths are fully open. From the recorded valve
589 state when the run carries one (``result["network"]``), else Fire + the slowest main's travel
590 (the drawing's ``travel_time``; the twin's ``Setup.valve_travel_s`` where the drawing has none).
591
592 ``tau_line``: each side's line as an R-L element, tau = I mdot / (2 dP_leg), with I the
593 drawing's sum of L/A and dP_leg the twin's tank-outlet -> chamber drop at the first fully-open
594 step (audit 9.5: LOX 1.19 ms, fuel 4.94 ms on the helium drawing at t = 1.0 s). The injector
595 passages' own inertance is not included (it adds ~10 % LOX, ~2 % fuel there).
596
597 The window ends ``settle_tau`` line constants after ``t_open``. ``start_window`` given as a
598 number overrides it (seconds after Fire)."""
599 series = result["series"]
600 t = [float(v) for v in series["t"]]
601 fire = [i for i, f in enumerate(series["firing"]) if f]
602 out: Dict[str, Any] = {"settle_tau": float(settle_tau), "t_fire": 0.0}
603 travel: Dict[str, Dict[str, Any]] = {}
604 for side, _, _, _ in SIDES:
605 for o in (feed_path.get(side) or {}).get("others", []):
606 if o.get("actuated") and o.get("travel"):
607 travel[o["id"]] = o["travel"]
608 t_open = None
609 states = ((result.get("network") or {}).get("branches") or {})
610 recorded = [states[v]["state"] for v in travel if isinstance(states.get(v), dict) and states[v].get("state")]
611 if recorded and len(recorded) == len(travel) and fire and all(len(s) == len(t) for s in recorded):
612 # The recording shows the opening only if some main is seen short of open at or before the
613 # first firing step. feedtwin's trace reads a valve with no signal as fully open
614 # (network_trace._opening), so "open from the first step" is no evidence: fall back to the
615 # travel rule then, rather than grading from before Fire.
616 def is_open(j: int) -> bool:
617 return all((s[j] if s[j] is not None else 0.0) >= 0.999 for s in recorded)
618
619 shut = next((j for j in range(fire[0] + 1) if not is_open(j)), None)
620 k = next((j for j in range(shut, len(t)) if is_open(j)), None) if shut is not None else None
621 if k is not None:
622 t_open, out["t_open_basis"] = t[k], "recorded valve state"
623 if t_open is None:
624 slow = max((_num(v.get("value")) or 0.0 for v in travel.values()), default=0.0)
625 t_open = slow
626 out["t_open_basis"] = "Fire + the slowest main valve's travel"
627 out["valve_travel"] = travel
628 out["t_open"] = float(t_open)
629 i_open = next((i for i in fire if t[i] >= t_open - 1e-9), fire[0] if fire else None)
630 taus: Dict[str, Optional[float]] = {}
631 pc = (series.get("chamber") or {}).get("pc_psia")
632 for side, _, _, skey in SIDES:
633 s = series.get(skey) or {}
634 I = (feed_path.get(side) or {}).get("inertance")
635 taus[side] = None
636 if i_open is None or not I or not pc or not s.get("outlet_psia") or not s.get("mdot"):
637 continue
638 dp = (float(s["outlet_psia"][i_open]) - float(pc[i_open])) * PSI
639 m = float(s["mdot"][i_open])
640 if dp > 0 and m > 0:
641 taus[side] = float(I * m / (2.0 * dp))
642 out["tau_line_s"] = taus
643 longest = max((v for v in taus.values() if v is not None), default=0.0)
644 auto = float(t_open + settle_tau * longest)
645 out["auto_s"] = auto
646 if isinstance(start_window, (int, float)) and not isinstance(start_window, bool):
647 out["start_window_s"] = float(start_window)
648 out["window_basis"] = "given"
649 else:
650 out["start_window_s"] = auto
651 out["window_basis"] = f"t_open + {settle_tau:g} x the longest line time constant"
652 out["steps_inside"] = sum(1 for i in fire if t[i] < out["start_window_s"] - 1e-9)
653 return out
654
655
656# ---------------------------------------------------------------------------------------------
657# the block
658# ---------------------------------------------------------------------------------------------
659
660
661def _drawing_feed_at(feed_path: Dict[str, Dict[str, Any]], series: Dict[str, Any], i: Optional[int],
662 diag: Dict[str, Any]) -> Tuple[Dict[str, Dict[str, float]], List[str]]:
663 """The drawing basis's ``feed`` override at series index ``i``: per side, the drawing's
664 inertance and dP = (tank outlet - line exit) + EngineDesign's exit dump. A side with nothing
665 to give keeps the config's value (the key is left out)."""
666 out: Dict[str, Dict[str, float]] = {}
667 notes: List[str] = []
668 for side, cside, key, skey in SIDES:
669 f: Dict[str, float] = {}
670 I = (feed_path.get(side) or {}).get("inertance")
671 if I is not None and I > 0:
672 f["inertance"] = float(I)
673 else:
674 notes.append(f"{side}: the drawing declares no line on the path; the config's inertance is kept")
675 s = series.get(skey) or {}
676 out_p, in_p = s.get("outlet_psia"), s.get("inlet_psia")
677 dump = _num(diag.get(f"delta_p_feed_{key}"))
678 if i is not None and out_p and in_p and dump is not None:
679 line = (float(out_p[i]) - float(in_p[i])) * PSI
680 if line < 0.0:
681 notes.append(f"{side}: tank outlet below the line exit at t index {i} ({line / PSI:+.2f} psi); taken as 0")
682 line = 0.0
683 f["dP_feed"] = line + max(dump, 0.0)
684 else:
685 notes.append(f"{side}: no tank-outlet pressure in this run; the config's feed drop is kept")
686 if f:
687 out[cside] = f
688 return out, notes
689
690
691def stability_block(prep: Any, result: Dict[str, Any], config: Any = None, basis: str = "config",
692 eroded: bool = False, start_window: Any = "auto", *,
693 settle_tau: float = SETTLE_TAU, tube_modulus: float = TUBE_MODULUS_PA) -> Dict[str, Any]:
694 """``diagnostics.stability`` (DATA-CONTRACT section 3) for one Layer X result.
695
696 ``basis``: ``"config"`` (today's replay numbers, exactly) or ``"drawing"`` (the drawing's
697 inertance and the twin's line drop as resistance). The other basis is always computed too and
698 reported under ``other_basis``. ``eroded``: the stability call on each point's eroded geometry.
699 ``start_window``: ``"auto"`` (valve travel + ``settle_tau`` line time constants) or a time
700 after Fire [s]. Never raises; a failure is ``{"available": False, "error": ...}``.
701 """
702 try:
703 return _block(prep, result, config, basis, bool(eroded), start_window, float(settle_tau),
704 float(tube_modulus))
705 except Exception as exc: # noqa: BLE001 - a failed diagnostic never takes the burn with it
706 return {"available": False, "error": f"{type(exc).__name__}: {exc}"}
707
708
709def _block(prep: Any, result: Dict[str, Any], config: Any, basis: str, eroded: bool, start_window: Any,
710 settle_tau: float, tube_modulus: float = TUBE_MODULUS_PA) -> Dict[str, Any]:
711 from engine.pipeline.config_schemas import ensure_chamber_geometry
712
713 if basis not in ("config", "drawing"):
714 raise ValueError(f"basis must be 'config' or 'drawing', not {basis!r}")
715 series = result["series"]
716 rp = result.get("replay") or {}
717 runner = _runner(prep, config)
718 base = runner.config
719 design = ensure_chamber_geometry(copy.deepcopy(base))
720 replayed = bool(rp.get("available")) and bool(rp.get("t"))
721 geo = _replay_geometry(rp, design) if replayed else None
722 feed_path = drawing_feed(prep)
723
724 # ---- the points: the replay's (or, with no replay, the twin's own steps the replay would
725 # have taken), every firing step inside the start window, and the first step after it
726 if replayed:
727 pts = [{"t": float(t), "index": int(i), "k": k, "p_o": float(po) * PSI, "p_f": float(pf) * PSI}
728 for k, (t, i, po, pf) in enumerate(zip(rp["t"], rp["index"], rp["inlet_O_psia"], rp["inlet_F_psia"]))]
729 else:
730 from engine.layerx.replay import replay_indices
731
732 pts = [_step_point(series, i) for i in replay_indices(series["firing"])]
733 win = start_window_of(prep, result, feed_path, start_window, settle_tau)
734 have = {p["index"] for p in pts}
735 fire = [i for i, f in enumerate(series["firing"]) if f]
736 inside = [i for i in fire if float(series["t"][i]) < win["start_window_s"] - 1e-9]
737 if len(inside) > START_POINTS_MAX:
738 inside = [inside[round(j * (len(inside) - 1) / (START_POINTS_MAX - 1))] for j in range(START_POINTS_MAX)]
739 after = next((i for i in fire if float(series["t"][i]) >= win["start_window_s"] - 1e-9), None)
740 extra = [i for i in dict.fromkeys(inside + ([after] if after is not None else [])) if i not in have]
741 pts = sorted(pts + [_step_point(series, i) for i in extra], key=lambda p: (p["t"], p["k"] is None))
742
743 rows = []
744 for pt in pts:
745 g = _geometry_at(geo, pt["t"], pt["k"])
746 cfg_now = _with_geometry(base, g)
747 row: Dict[str, Any] = {"t": pt["t"], "index": pt["index"], "replay_point": pt["k"] is not None}
748 try:
749 Pc, diag = _solve_point(runner, cfg_now, pt["p_o"], pt["p_f"])
750 stab_cfg = cfg_now if (eroded and g is not None) else base
751 inp_c = _inputs(stab_cfg, Pc, diag)
752 fd, notes = _drawing_feed_at(feed_path, series, pt["index"], diag)
753 inp_d = _inputs(stab_cfg, Pc, diag, feed=fd) if fd else inp_c
754 row.update(pc_psia=Pc / PSI, config=_gate(inp_c), drawing=_gate(inp_d), notes=notes,
755 inp={"config": inp_c, "drawing": inp_d}, feed=fd)
756 except Exception as exc: # noqa: BLE001 - one point that does not solve is reported, not fatal
757 row["error"] = f"{type(exc).__name__}: {exc}"
758 rows.append(row)
759
760 ok = [r for r in rows if "error" not in r]
761 if not ok:
762 raise RuntimeError("no point solved: " + "; ".join(r["error"] for r in rows[:3]))
763
764 def series_of(b: str, key: str) -> List[Optional[float]]:
765 return [(_num(r[b][key]) if "error" not in r else None) for r in rows]
766
767 t_win = float(win["start_window_s"])
768 settled = [r for r in ok if r["t"] >= t_win - 1e-9]
769
770 def low(b: str, among: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
771 if not among:
772 return None
773 r = min(among, key=lambda r: r[b]["gate"])
774 return {"t": r["t"], "index": r["index"], "margin": r[b]["gate"], "frequency_hz": r[b]["f_gate_hz"]}
775
776 other = "drawing" if basis == "config" else "config"
777 o_set, o_all = low(other, settled), low(other, ok)
778 out: Dict[str, Any] = {
779 "available": True,
780 "basis": basis,
781 "eroded": bool(eroded and geo is not None),
782 "t": [r["t"] for r in rows],
783 "index": [r["index"] for r in rows],
784 "replay_point": [bool(r["replay_point"]) for r in rows],
785 "in_start": [r["t"] < t_win - 1e-9 for r in rows],
786 "margin": series_of(basis, "gate"),
787 "frequency_hz": series_of(basis, "f_gate_hz"),
788 "frequency_nominal_hz": series_of(basis, "f_nominal_hz"),
789 "margin_nominal": series_of(basis, "gm_nominal"),
790 # Every evaluated point, the start included: what the replay's "minimum" has always been.
791 "worst": low(basis, ok),
792 # The graded figure: from the first fully-open step plus the line settling (D7-E).
793 "settled_min": low(basis, settled),
794 "start_window_s": t_win,
795 "start": win,
796 "other_basis": {"basis": other,
797 "margin_min": o_set["margin"] if o_set else None,
798 "t": o_set["t"] if o_set else None,
799 "frequency_hz": o_set["frequency_hz"] if o_set else None,
800 "worst": o_all},
801 "margin_other": series_of(other, "gate"),
802 }
803 # The Nyquist plot and the lag sweep at the graded worst point (the start's worst when nothing
804 # is graded), on the active basis.
805 pick = min(settled or ok, key=lambda r: r[basis]["gate"])
806 inp = pick["inp"][basis]
807 out["nyquist"] = _nyquist(inp, pick[basis], pick["t"])
808 out["tau_sweep"] = {"t": pick["t"], **_tau_sweep(inp)}
809 f_band = [r[basis]["f_gate_hz"] for r in (settled or ok) if _num(r[basis]["f_gate_hz"])]
810 ac = acoustics(prep, series, feed_path, pick["index"], f_band, tube_modulus)
811 out["acoustic"] = ac["lines"]
812 out["acoustic_flags"] = ac["flags"]
813 out["check"] = _check(rows, rp, eroded)
814 out["feed"] = _feed_summary(feed_path, pick)
815 errs = [f"t={r['t']:.3f}: {r['error']}" for r in rows if "error" in r]
816 if errs:
817 out["errors"] = errs
818 flags: List[str] = []
819 for side, _, _, _ in SIDES:
820 flags += [f"{side}: {f}" for f in (feed_path.get(side) or {}).get("flags", [])]
821 if any((ln.get("elevation_m") or 0.0) != 0.0 for ln in (feed_path.get(side) or {}).get("lines", [])):
822 flags.append(f"{side}: a line on the path has a height; the tank-outlet -> line-exit drop then carries "
823 "the column's head, which is not resistance (drawing basis R overstated by it)")
824 flags += list(pick.get("notes") or [])
825 flags += ac["flags"]
826 out["flags"] = list(dict.fromkeys(flags))
827 out["model"] = _model(basis, out["eroded"], win, pick, feed_path, ac, tube_modulus)
828 out["unmeasured"] = _unmeasured(pick, feed_path, win, tube_modulus)
829 return out
830
831
832SOURCE = ("EngineDesign's chug loop (engine/pipeline/stability/chug.py): F(s) = 1 + K_c/(theta_c s + 1) "
833 "sum_k exp(-s tau_k)/(I_k s + R_k + 1/G_k), Nyquist gain margin over the mixing-lag band; double "
834 "time lag after Leonardi, Nasuti, Di Matteo & Steelant, Acta Astronautica 139 (2017) 344-356; feed "
835 "line inertia in the constant-lag chug loop after Summerfield, J. American Rocket Society 21(5) "
836 "(1951) 108-114. Line waves: Korteweg, Annalen der Physik 241(12) (1878) 525-542, with CoolProp "
837 "sound speeds (Bell et al., Ind. Eng. Chem. Res. 53(6) (2014) 2498-2508).")
838
839
840def _model(basis: str, eroded: bool, win: Dict[str, Any], pick: Dict[str, Any], feed_path: Dict[str, Dict[str, Any]],
841 ac: Dict[str, Any], E: float) -> Dict[str, Any]:
842 """The ``model`` block: what produced the margin, and every input with where it came from."""
843 inp = pick["inp"][basis]
844 ch = inp["chamber"]
845 inputs: Dict[str, Any] = {}
846 for side, cside, key, _ in SIDES:
847 st = next(x for x in inp["streams"] if x.name == key)
848 lines = (feed_path.get(side) or {}).get("lines", [])
849 drawn = basis == "drawing" and "inertance" in (pick.get("feed") or {}).get(cside, {})
850 inputs[f"inertance_{key}"] = {
851 "value": st.inertance(), "unit": "1/m",
852 "provenance": ("drawing: sum L/A of " + ", ".join(f"{ln['label']} {ln['length_m']:g} m [{ln['length_provenance']}]"
853 for ln in lines))
854 if drawn else f"engine config: feed_system.{cside}.length {st.feed_length:.4g} m / A_hydraulic {st.feed_area:.4g} m2"}
855 lined = basis == "drawing" and "dP_feed" in (pick.get("feed") or {}).get(cside, {})
856 inputs[f"dP_feed_{key}"] = {
857 "value": st.dP_feed / PSI, "unit": "psi",
858 "provenance": (f"twin: tank outlet -> line exit at t = {pick['t']:.3f} s, plus EngineDesign's exit dump "
859 "(K_exit rho v^2/2); R = 2 dP / mdot") if lined else
860 ("EngineDesign: the line-exit config's exit dump only (K0 and supply_K zeroed by "
861 "engine/layerx/card.py line_exit_config); R = 2 dP / mdot")}
862 inputs[f"eta_inj_{key}"] = {"value": st.eta_inj, "unit": "-", "provenance": "EngineDesign closure at this point"}
863 inputs[f"tau_{key}_ms"] = {"value": st.tau_conv * 1e3, "unit": "ms",
864 "provenance": f"{inp['lag_model']} lag (convection {inp['convection_model']}) "
865 f"from the closure's SMD {inp[f'D32_{key}'] * 1e6:.0f} um"}
866 inputs["K_c"] = {"value": ch.K_c(), "unit": "Pa.s/kg",
867 "provenance": f"c*/A_t, A_t {ch.A_t:.6g} m2 ({'eroded, this point' if eroded else 'design point, frozen'})"}
868 inputs["theta_c_ms"] = {"value": ch.theta_c() * 1e3, "unit": "ms",
869 "provenance": f"L* c*/(R Tc), L* {ch.Lstar:.4g} m ({'eroded' if eroded else 'design'})"}
870 band = inp.get("chug_band")
871 inputs["mixing_lag_fraction_band"] = {"value": list(band) if band else None, "unit": "-",
872 "provenance": "engine config stability.chug_band_mixing_lag_fraction_min/max: unmeasured"}
873 for vid, tr in (win.get("valve_travel") or {}).items():
874 inputs[f"travel_{vid}"] = tr
875 inputs["settle_tau"] = {"value": win["settle_tau"], "unit": "line time constants",
876 "provenance": "assumed: 95 % of a first-order line's response"}
877 inputs["start_window"] = {"value": win["start_window_s"], "unit": "s after Fire", "provenance": win["window_basis"]}
878 inputs["tube_modulus"] = {"value": E, "unit": "Pa", "provenance": TUBE_MODULUS_SOURCE}
879 inputs.update(ac.get("inputs") or {})
880 return {
881 "name": f"Chug gate, lumped feed / injector / chamber loop, {basis} feed basis"
882 + (", eroded geometry" if eroded else ", design-point geometry"),
883 "source": SOURCE,
884 "assumptions": [
885 "lumped lines (inertance and linearised resistance, no compliance): the line modes are checked "
886 "against the chug band (acoustic)",
887 "R = 2 dP/mdot, the slope of a quadratic loss. Line friction is slightly flatter (fluids' "
888 "Colebrook slope dln dP/dln mdot ~1.83 on LE4's fuel run, 1.93 on the LOX run, drawn-tube "
889 "roughness 1.5 um assumed); taking it would lower the drawing basis's gate by ~0.002 on LE4",
890 "the regulator and ullage are an ideal pressure source at chug frequencies (stability.regulator_Z_hf "
891 "0; audit 9.4 section 5 bounds them below 0.005 in GM); no manifold compliance",
892 "the gate is the minimum over 5 points of the mixing-lag band, as EngineDesign grades it; tau_sweep "
893 "shows the band densely",
894 "valve bodies carry no length on the drawing: their inertance is not counted",
895 "each point is quasi-steady: the twin's network is algebraic, so the start window is graded out, "
896 "not simulated",
897 "the line time constant leaves out the injector passages' own inertance",
898 ],
899 "inputs": inputs,
900 }
901
902
903def _unmeasured(pick: Dict[str, Any], feed_path: Dict[str, Dict[str, Any]], win: Dict[str, Any],
904 E: float) -> List[Dict[str, Any]]:
905 """The inputs nobody has measured that move this margin: for the uncertainty sweep."""
906 inp = pick["inp"]["config"]
907 band = inp.get("chug_band")
908 g = pick["config"]
909 out: List[Dict[str, Any]] = [
910 {"name": "stability.mixing_lag_fraction", "value": inp.get("mixing_lag_fraction"), "unit": "-",
911 "range": list(band) if band else None,
912 "why": (f"the gate is taken at fraction {g['gate_fraction']}: GM {g['gm_nominal']:.2f} at the nominal "
913 f"lag, {g['gate']:.2f} at the gate (config basis, t = {pick['t']:.3f} s; see tau_sweep)")},
914 {"name": "D32_F (ethanol SMD)", "value": inp["D32_F"] * 1e6, "unit": "um",
915 "why": "tau_vap scales with D32^2 and the fuel paces the lag"},
916 {"name": "D32_O (LOX SMD)", "value": inp["D32_O"] * 1e6, "unit": "um", "why": "the LOX lag"},
917 ]
918 for side, cside, _, _ in SIDES:
919 for ln in (feed_path.get(side) or {}).get("lines", []):
920 if "measured" not in (ln.get("length_provenance") or ""):
921 out.append({"name": f"{ln['label']}.length", "value": ln["length_m"], "unit": "m",
922 "why": f"{side} inertance on the drawing basis ({ln['length_provenance']})"})
923 for o in (feed_path.get(side) or {}).get("others", []):
924 out.append({"name": f"{o['label']} body length", "value": None, "unit": "m",
925 "why": "not on the drawing; its inertance is left out"})
926 out += [
927 {"name": "feed_system.<side>.K_exit", "value": None, "unit": "-",
928 "why": "the exit dump is part of R on both bases (Borda-Carnot 1.0 assumed by the schema)"},
929 {"name": "settle_tau", "value": win["settle_tau"], "unit": "-", "why": "where the graded window starts"},
930 {"name": "tube wall thickness", "value": None, "unit": "m",
931 "why": "copied on the drawing; moves the line wave speed (not the chug margin)"},
932 {"name": "tube Young's modulus", "value": E, "unit": "Pa",
933 "why": "material not on the drawing; moves the line wave speed by ~0.3 % per 10 %"},
934 ]
935 return out
936
937
938def _check(rows: List[Dict[str, Any]], rp: Dict[str, Any], eroded: bool) -> Optional[Dict[str, Any]]:
939 """The config basis against the replay's own ``chug_margin`` at the replay points: the
940 re-evaluation is the replay's computation, so this should be zero."""
941 ref = rp.get("chug_margin") or []
942 if not ref or bool(rp.get("chug_eroded_geometry", False)) != bool(eroded):
943 return None
944 diffs: List[float] = []
945 k = 0
946 for r in rows:
947 if not r.get("replay_point"):
948 continue
949 if "error" not in r and k < len(ref) and ref[k] is not None:
950 diffs.append(abs(r["config"]["gate"] - float(ref[k])))
951 k += 1
952 if not diffs:
953 return None
954 return {"against": "replay.chug_margin (config basis)", "points": len(diffs),
955 "max_abs_diff": float(max(diffs))}
956
957
958def _feed_summary(feed_path: Dict[str, Dict[str, Any]], r0: Dict[str, Any]) -> Dict[str, Any]:
959 """Per side, the two bases' feed impedance at point ``r0`` (the graded worst)."""
960 out: Dict[str, Any] = {}
961 for side, cside, key, _ in SIDES:
962 sc = next(s for s in r0["inp"]["config"]["streams"] if s.name == key)
963 sd = next(s for s in r0["inp"]["drawing"]["streams"] if s.name == key)
964 fp = feed_path.get(side) or {}
965 out[side] = {
966 "config": {"inertance": sc.inertance(), "resistance": sc.resistance(), "dP_feed_psi": sc.dP_feed / PSI},
967 "drawing": {"inertance": sd.inertance(), "resistance": sd.resistance(), "dP_feed_psi": sd.dP_feed / PSI},
968 "lines": [{k: ln[k] for k in ("id", "length_m", "bore_m", "inertance", "length_provenance", "bore_provenance")}
969 for ln in fp.get("lines", [])],
970 "flags": list(fp.get("flags", [])),
971 }
972 return out
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Optional[float] _num(Any v)
Definition stability.py:91
Dict[str, Dict[str, Any]] drawing_feed(Any prep)
Definition stability.py:127
Dict[str, Any] _feed_summary(Dict[str, Dict[str, Any]] feed_path, Dict[str, Any] r0)
Definition stability.py:958
Dict[str, Any] _gate(Dict[str, Any] inp)
Definition stability.py:318
Any _runner(Any prep, Any config)
Definition stability.py:109
Dict[str, Any] start_window_of(Any prep, Dict[str, Any] result, Dict[str, Dict[str, Any]] feed_path, Any start_window="auto", float settle_tau=SETTLE_TAU)
Definition stability.py:585
float korteweg(float a0, float rho, float bore, float wall, float E)
Definition stability.py:430
List[Any] _shift(Sequence[Any] streams, float dtau)
Definition stability.py:313
Any _with_geometry(Any config, Optional[Dict[str, float]] g)
Definition stability.py:290
Optional[str] _wall_copied(Any prep)
Definition stability.py:447
Optional[Dict[str, float]] _geometry_at(Optional[Dict[str, np.ndarray]] geo, float t, Optional[int] k)
Definition stability.py:281
Dict[str, Any] _inputs(Any stab_config, float Pc, Dict[str, Any] diag, Optional[Dict[str, Any]] feed=None)
Definition stability.py:568
Tuple[float, Dict[str, Any]] _solve_point(Any runner, Any cfg_now, float p_o, float p_f)
Definition stability.py:557
List[Dict[str, Any]] _unmeasured(Dict[str, Any] pick, Dict[str, Dict[str, Any]] feed_path, Dict[str, Any] win, float E)
Definition stability.py:904
Tuple[float, float, str] _coolprop_liquid(str fluid, float T, float p)
Definition stability.py:420
Dict[str, Any] _line_record(str rid, str did, Optional[float] L, Optional[float] d, Any edge, Any length_param, Any bore_param, Any elevation=None)
Definition stability.py:215
Dict[str, Any] _model(str basis, bool eroded, Dict[str, Any] win, Dict[str, Any] pick, Dict[str, Dict[str, Any]] feed_path, Dict[str, Any] ac, float E)
Definition stability.py:841
Dict[str, Any] acoustics(Any prep, Dict[str, Any] series, Dict[str, Dict[str, Any]] feed_path, int i, Sequence[float] f_chug, float E=TUBE_MODULUS_PA)
Definition stability.py:476
Tuple[Dict[str, Dict[str, float]], List[str]] _drawing_feed_at(Dict[str, Dict[str, Any]] feed_path, Dict[str, Any] series, Optional[int] i, Dict[str, Any] diag)
Definition stability.py:662
Dict[str, Any] stability_block(Any prep, Dict[str, Any] result, Any config=None, str basis="config", bool eroded=False, Any start_window="auto", *, float settle_tau=SETTLE_TAU, float tube_modulus=TUBE_MODULUS_PA)
Definition stability.py:693
Optional[Dict[str, Any]] _check(List[Dict[str, Any]] rows, Dict[str, Any] rp, bool eroded)
Definition stability.py:938
Optional[Dict[str, np.ndarray]] _replay_geometry(Dict[str, Any] rp, Any design)
Definition stability.py:240
Dict[str, Any] _block(Any prep, Dict[str, Any] result, Any config, str basis, bool eroded, Any start_window, float settle_tau, float tube_modulus=TUBE_MODULUS_PA)
Definition stability.py:710
Dict[str, Any] _step_point(Dict[str, Any] series, int i)
Definition stability.py:577
Dict[str, Any] _nyquist(Dict[str, Any] inp, Dict[str, Any] gate, float t)
Definition stability.py:351
Dict[str, Any] _tau_sweep(Dict[str, Any] inp, int n=TAU_SWEEP_POINTS)
Definition stability.py:379
Dict[str, Any] _param_record(Any param, Optional[float] si, str unit, str fallback)
Definition stability.py:99
static bool near(double a, double b, double tol)