1"""Chug margin over the burn, on the drawing's feed impedance: ``diagnostics.stability``.
3DATA-CONTRACT section 3, ``stability``. Layer X audit D7 and appendix 9.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):
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.
16This module re-evaluates the same loop at the same points and offers the drawing's basis:
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.
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).
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.
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.
46from __future__
import annotations
50from dataclasses
import replace
51from typing
import Any, Dict, List, Optional, Sequence, Tuple
55PSI = 6894.757293168361
66NYQUIST_HZ = (2.0, 2000.0, 400)
70SIDES = ((
"oxidiser",
"oxidizer",
"O",
"ox"), (
"fuel",
"fuel",
"F",
"fuel"))
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")
91def _num(v: Any) -> Optional[float]:
94 except (TypeError, ValueError):
96 return f
if math.isfinite(f)
else None
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."""
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 "")}
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
115 raise ValueError(
"no EngineDesign link and no config")
119 return PintleEngineRunner(line_exit_config(config))
128 """Per side (``oxidiser``/``fuel``), the drawing's path from the tank outlet to the injector
129 inlet as the twin assembled it.
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.
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():
150 setup_travel =
_num(getattr(getattr(prep,
"setup",
None),
"valve_travel_s",
None))
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()
160 did = owner.get(bid, bid)
161 br = net.branches[bid]
162 p = dict(getattr(br.component,
"p", {})
or {})
163 edge = edges.get(did)
165 if did
in seen_edges:
168 segs = list(getattr(edge.segments,
"segments", ())
or ())
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)))
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")))
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 {})}
190 if travel
is not None and travel > 0:
191 rec[
"travel"] =
_param_record(travel_param, travel,
"s",
"feed-twin component default")
193 rec[
"travel"] = {
"value": setup_travel,
"unit":
"s",
194 "provenance":
"feed-twin Setup.valve_travel_s (the drawing gives no travel_time)"}
196 flags.append(f
"{rec['label']}: no length on the drawing, so its body's inertance is not counted")
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")
208 flags.append(f
"lengths 'estimated' on the drawing: {', '.join(guessed)}")
209 out[side] = {
"lines": lines,
"others": others,
"inertance": inertance,
"flags": flags,
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)
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,
241 """The replay's geometry at its points, with the as-built geometry prepended at Fire (t = 0).
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 []
247 at = rp.get(
"A_throat_m2")
248 if not n
or not at
or len(at) != n:
251 def col(key: str) -> Optional[List[Optional[float]]]:
253 return list(v)
if isinstance(v, list)
and len(v) == n
else None
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")]
267 def fill(vals: Optional[List[Optional[float]]], v0: float) -> np.ndarray:
270 return np.array([v0] + [float(v)
if v
is not None else v0
for v
in vals])
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)),
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)."""
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")}
291 """A copy of ``config`` on geometry ``g``, set exactly as TimeVaryingCoupledSolver sets it."""
294 c = copy.deepcopy(config)
296 ensure_chamber_geometry(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"]
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]
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*.
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)."""
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"))
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"])
343 frac = float(band[
"at_min_fraction"])
344 dtau = (frac - float(inp[
"mixing_lag_fraction"])) * float(inp[
"tau_mix_basis"])
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}
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``).
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."""
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
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"])
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"),
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.
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."""
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 = [], [], []
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"))))
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)")
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"))))
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")
413 finite = [(g, t)
for g, t
in zip(gms, taus)
if math.isfinite(g)]
416 out[
"min"] = {
"margin": g,
"tau_ms": t}
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
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
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].
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):
444 return float(a0 / math.sqrt(1.0 + K * bore / (E * wall)))
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)
453 seen: Dict[Tuple[float, str], set] = {}
455 for e
in diagram.edges:
456 w = e.params.get(
"wall_thickness")
457 b = e.params.get(
"bore")
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)
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")
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.
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
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", ())}
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
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")
511 p = 0.5 * (p_in + p_out) * PSI
514 except Exception
as exc:
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")
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")
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")
528 a =
korteweg(a0, rho, d, e
or 0.0, 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")})
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})
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]}
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``)."""
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
568def _inputs(stab_config: Any, Pc: float, diag: Dict[str, Any], feed: Optional[Dict[str, Any]] =
None) -> Dict[str, Any]:
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)
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}
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).
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).
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).
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"]
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):
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)
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
622 t_open, out[
"t_open_basis"] = t[k],
"recorded valve state"
624 slow =
max((
_num(v.get(
"value"))
or 0.0
for v
in travel.values()), default=0.0)
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")
636 if i_open
is None or not I
or not pc
or not s.get(
"outlet_psia")
or not s.get(
"mdot"):
638 dp = (float(s[
"outlet_psia"][i_open]) - float(pc[i_open])) * PSI
639 m = float(s[
"mdot"][i_open])
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)
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"
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)
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)
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
681 notes.append(f
"{side}: tank outlet below the line exit at t index {i} ({line / PSI:+.2f} psi); taken as 0")
683 f[
"dP_feed"] = line +
max(dump, 0.0)
685 notes.append(f
"{side}: no tank-outlet pressure in this run; the config's feed drop is kept")
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.
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": ...}``.
703 return _block(prep, result, config, basis, bool(eroded), start_window, float(settle_tau),
705 except Exception
as exc:
706 return {
"available":
False,
"error": f
"{type(exc).__name__}: {exc}"}
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]:
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 {}
719 design = ensure_chamber_geometry(copy.deepcopy(base))
720 replayed = bool(rp.get(
"available"))
and bool(rp.get(
"t"))
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"]))]
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))
747 row: Dict[str, Any] = {
"t": pt[
"t"],
"index": pt[
"index"],
"replay_point": pt[
"k"]
is not None}
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)
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:
757 row[
"error"] = f
"{type(exc).__name__}: {exc}"
760 ok = [r
for r
in rows
if "error" not in r]
762 raise RuntimeError(
"no point solved: " +
"; ".join(r[
"error"]
for r
in rows[:3]))
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]
767 t_win = float(win[
"start_window_s"])
768 settled = [r
for r
in ok
if r[
"t"] >= t_win - 1e-9]
770 def low(b: str, among: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
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"]}
776 other =
"drawing" if basis ==
"config" else "config"
777 o_set, o_all = low(other, settled), low(other, ok)
778 out: Dict[str, Any] = {
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"),
791 "worst": low(basis, ok),
793 "settled_min": low(basis, settled),
794 "start_window_s": t_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,
801 "margin_other": series_of(other,
"gate"),
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)
815 errs = [f
"t={r['t']:.3f}: {r['error']}" for r
in rows
if "error" in r]
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 [])
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)
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).")
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]
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']}]"
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 {})
881 "name": f
"Chug gate, lumped feed / injector / chamber loop, {basis} feed basis"
882 + (
", eroded geometry" if eroded
else ", design-point geometry"),
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, "
897 "the line time constant leaves out the injector passages' own inertance",
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")
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"},
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"})
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 %"},
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):
944 diffs: List[float] = []
947 if not r.get(
"replay_point"):
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])))
954 return {
"against":
"replay.chug_margin (config basis)",
"points": len(diffs),
955 "max_abs_diff": float(
max(diffs))}
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 {}
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", [])),
Optional[float] _num(Any v)
Dict[str, Dict[str, Any]] drawing_feed(Any prep)
Dict[str, Any] _feed_summary(Dict[str, Dict[str, Any]] feed_path, Dict[str, Any] r0)
Dict[str, Any] _gate(Dict[str, Any] inp)
Any _runner(Any prep, Any config)
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)
float korteweg(float a0, float rho, float bore, float wall, float E)
List[Any] _shift(Sequence[Any] streams, float dtau)
Any _with_geometry(Any config, Optional[Dict[str, float]] g)
Optional[str] _wall_copied(Any prep)
Optional[Dict[str, float]] _geometry_at(Optional[Dict[str, np.ndarray]] geo, float t, Optional[int] k)
Dict[str, Any] _inputs(Any stab_config, float Pc, Dict[str, Any] diag, Optional[Dict[str, Any]] feed=None)
Tuple[float, Dict[str, Any]] _solve_point(Any runner, Any cfg_now, float p_o, float p_f)
List[Dict[str, Any]] _unmeasured(Dict[str, Any] pick, Dict[str, Dict[str, Any]] feed_path, Dict[str, Any] win, float E)
Tuple[float, float, str] _coolprop_liquid(str fluid, float T, float p)
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)
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)
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)
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)
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)
Optional[Dict[str, Any]] _check(List[Dict[str, Any]] rows, Dict[str, Any] rp, bool eroded)
Optional[Dict[str, np.ndarray]] _replay_geometry(Dict[str, Any] rp, Any design)
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)
Dict[str, Any] _step_point(Dict[str, Any] series, int i)
Dict[str, Any] _nyquist(Dict[str, Any] inp, Dict[str, Any] gate, float t)
Dict[str, Any] _tau_sweep(Dict[str, Any] inp, int n=TAU_SWEEP_POINTS)
Dict[str, Any] _param_record(Any param, Optional[float] si, str unit, str fallback)
static bool near(double a, double b, double tol)