STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
start.py
Go to the documentation of this file.
1"""Layer X start transient: Fire to settled flow, as a reduced-order model of its own.
2
3``start_from_run(prep, result, config=None, settings=StartSettings())`` -> ``diagnostics.start``
4(DATA-CONTRACT 3). ``run_start(ox, fuel, chamber, ...)`` is the model on plain inputs.
5
6The main burn does not change. The twin's network is algebraic: its first firing step is
7already at full flow (AUDIT 9.5 §1, §5), so the start is a diagnostic beside the burn and a
8correction estimate (the impulse the start costs), never an edit of the burn.
9
10The model
11---------
12Each side is one liquid column, tank outlet to injector orifices, in lumped (rigid-column)
13form, the inertance-resistance line of AUDIT 7 item 1:
14
15 I dm/dt = p_tank - p_end - R m|m| I = sum L/A [1/m], R [Pa/(kg/s)^2]
16
17* **Valve.** The drawing's main valve opens on command as a linear travel over its drawn
18 ``travel_time``; its Cv follows the valve's own inherent characteristic (feedtwin
19 ``Valve.effective_cv``), and its loss is feedtwin's (``fluids`` Cv-to-K), so
20 ``R_valve(x) = R_valve(1) (Cv(1)/Cv(x))^2``.
21* **Lines.** Each element's quadratic loss is the twin's own ``pressure_drop`` (Darcy with the
22 drawing's friction correlation and ``K_minor``) at the main burn's first settled flow, divided
23 by that flow squared: the friction factor is frozen there.
24* **Priming.** Downstream of the main valve the line, the injector manifold and the orifices are
25 gas-filled at Fire (AUDIT 9.5 §4: the twin holds them liquid at 101 kPa, which would flash).
26 The liquid front fills that volume; the gas it displaces vents through the orifices into the
27 chamber. While it fills, the column's inertance grows with the length of downstream tube it
28 occupies, its end pressure is the chamber's (ambient before ignition: the displaced gas's
29 orifice drop, ``rho_g/2 (Q/A_orifices)^2``, is under 7 kPa at LE4's arrival flows against ~3.9 MPa of
30 drive, and is neglected), and the liquid
31 entering the gas volume loses ``K_exit_prime`` velocity heads at the last drawn tube's bore (1: the
32 "1" of Liou & Hunt's ``(1 + K)``, the velocity head the growing column carries, which is also the
33 Borda dump of a front entering a larger volume; their entrance ``K`` is the drawing's tank-exit
34 ``K_minor``, already in the line loss). This is the
35 rigid-column filling model: the unsteady Bernoulli/momentum balance on a column of growing
36 length entering from a reservoir, ``rho l dv/dt = p_res - p_front - (1 + K) rho v^2/2 - f l rho v^2/(2D)``
37 (Liou & Hunt, "Filling of pipelines with undulating elevation profiles", J. Hydraul. Eng.
38 122(10), 1996, rigid-column model). Once the manifold is full the injector's capacity takes
39 over: ``m = phi sqrt(p_line_exit - Pc)`` (the engine card's capacity, which owns the dump and
40 the orifices, engine/layerx/card.py), and the orifice passages add their inertance.
41* **Ignition** is when both manifolds are primed, plus ``ignition_delay_s`` (an explicit input:
42 0 = the igniter is lit and propellant ignites on arrival). Before it the chamber is at
43 ambient (cold flow through the throat costs ~1 kPa).
44* **Chamber filling** after ignition: ``dPc/dt = (R T / V)(m_in - Pc A_t / c*)`` with
45 ``R T = (Gamma c*)^2`` (the ideal-rocket identity c* = sqrt(R T)/Gamma, Sutton & Biblarz,
46 Rocket Propulsion Elements, the characteristic-velocity relation), c* from the engine card at the instantaneous O/F and
47 flow, and propellant burning on arrival (no vaporisation lag; the chug model owns that lag).
48 The chamber's time constant is ``L* / (Gamma^2 c*)`` (AUDIT 9.5 §1: 2.1 ms on LE4).
49* **Thrust** is the card's vacuum exhaust velocity times the nozzle flow, less ``p_a A_e``,
50 floored at zero (a separated nozzle at low Pc is not modelled; it would give more thrust than
51 this, so the deficit here is the larger figure).
52
53Integration is backward Euler on each column with the quadratic loss solved exactly (stable for
54any valve opening, exact for an inertance-only line), and backward Euler on the chamber, at a
55fixed ``dt`` (10 us default, 200x below the shortest line constant on LE4).
56
57What it reports
58---------------
59* ``prime_ox_s``, ``prime_fuel_s``, ``ignition_s``; Pc, flows and O/F through the start.
60* ``impulse_deficit_Ns``: the total impulse a burn with this start delivers less than the main
61 burn, at a **fixed propellant load**. The start window (Fire to settled) is credited at the
62 main burn's first settled thrust in the main burn (its first firing step is at full flow and
63 is copied back to Fire, replay.timeseries_payload); the propellant the start did not spend is
64 burned at the end at the main burn's end-of-burn exhaust velocity, limited by whichever tank
65 runs dry, so a fuel lead that eats into fuel the main burn strands costs less than its mass.
66 The mass that fills the downstream line and manifold is not charged: like the twin's massless
67 lines, it is pushed through at burnout.
68* ``hard_start``: a **heuristic** (no cited criterion): propellant both sides have injected and
69 not yet burned at ignition, paired at the design O/F, against the chamber's own steady gas
70 inventory ``Pc V / (Gamma c*)^2``. Over ``hard_start_ratio`` (1.0) of it, burning that pair at
71 once in the chamber volume would by itself exceed the steady chamber pressure.
72
73Limits (stated in the model block): tank pressure held at its T-0 value through the start; the
74line is incompressible (the arrival surge at the orifices is reported as the rigid-column
75orifice back-pressure; its compressible part is water hammer, ``diag.waterhammer``); the
76manifold fills before any orifice flows; the friction factor is frozen at the settled flow; the
77engine card is clipped to its table (flagged by how often).
78"""
79
80from __future__ import annotations
81
82import math
83from dataclasses import dataclass, field
84from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
85
86PSI = 6894.757293168361
87G0 = 9.80665
88SIDES = (("oxidiser", "ox"), ("fuel", "fuel"))
89
90MODEL_NAME = "start_transient_rigid_column"
91
92
93# ------------------------------------------------------------------ shared helpers
94
95
96def inp(value: Any, unit: str, provenance: str) -> Dict[str, Any]:
97 """One entry of a model block's ``inputs`` (DATA-CONTRACT)."""
98 if isinstance(value, float) and not math.isfinite(value):
99 value = None
100 return {"value": value, "unit": unit, "provenance": provenance}
101
102
103def model_block(name: str, source: str, assumptions: Sequence[str],
104 inputs: Mapping[str, Dict[str, Any]]) -> Dict[str, Any]:
105 """DATA-CONTRACT ``model``: what produced a block, so the run record can list it."""
106 return {"name": name, "source": source, "assumptions": list(assumptions), "inputs": dict(inputs)}
107
108
109def unavailable(error: str) -> Dict[str, Any]:
110 """DATA-CONTRACT: a block that could not be computed. It never raises."""
111 return {"available": False, "error": str(error)}
112
113
114def finite(x: Any) -> Optional[float]:
115 try:
116 f = float(x)
117 except (TypeError, ValueError):
118 return None
119 return f if math.isfinite(f) else None
120
121
122def param_provenance(param: Any, note: str = "") -> str:
123 """``"<source>: <reference>"`` for a feedtwin ``Param`` (a drawing value), plus a note."""
124 if param is None:
125 return note or "not drawn"
126 src = getattr(getattr(param, "source", None), "value", getattr(param, "source", ""))
127 ref = getattr(param, "reference", "") or ""
128 text = f"drawing, {src}" + (f": {ref}" if ref else "")
129 return text + (f" -- {note}" if note else "")
130
131
132# ------------------------------------------------------------------ the column and the chamber
133
134
135def column_step(m: float, dp: float, inertance: float, resistance: float, dt: float) -> float:
136 """One backward-Euler step of ``I dm/dt = dp - R m|m|``, the quadratic solved exactly.
137
138 ``I (m' - m)/dt = dp - R m'|m'|``. Unconditionally stable for ``R >= 0`` (a nearly shut
139 valve is a huge R and a tiny time constant ``I/(2 R m)``), and exact when ``R = 0``.
140 With no inertance it is the quasi-steady ``m = sign(dp) sqrt(|dp|/R)``.
141 """
142 if resistance <= 0.0:
143 if inertance <= 0.0:
144 raise ValueError("a column needs an inertance or a resistance")
145 return m + dp * dt / inertance
146 b = inertance / dt if inertance > 0.0 else 0.0
147 drive = dp + b * m
148 if drive == 0.0:
149 return 0.0
150 s = 1.0 if drive > 0.0 else -1.0
151 a = abs(drive)
152 # R m^2 + b m - a = 0 for m >= 0, written without cancellation.
153 return s * 2.0 * a / (b + math.sqrt(b * b + 4.0 * resistance * a))
154
155
156def chamber_step(pc: float, mdot_in: float, rt_over_v: float, at_over_cstar: float, dt: float) -> float:
157 """One backward-Euler step of ``dPc/dt = (RT/V)(m_in - Pc A_t/c*)``."""
158 k = rt_over_v * dt
159 return (pc + k * mdot_in) / (1.0 + k * at_over_cstar)
160
161
162def gamma_function(gamma: float) -> float:
163 """Vandenkerckhove's Gamma = sqrt(g) (2/(g+1))^((g+1)/(2(g-1))); the ideal-rocket mass-flow function (Sutton & Biblarz, Rocket Propulsion Elements)."""
164 return math.sqrt(gamma) * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
165
166
167def linear_characteristic(x: float) -> float:
168 return min(max(x, 0.0), 1.0)
169
170
171# ------------------------------------------------------------------ inputs
172
173
174@dataclass(frozen=True)
176 """One side's column, tank outlet to injector orifices. SI throughout."""
177
178 key: str
179 """``ox`` or ``fuel``."""
180 rho: float
181 p_tank: float
182 """Ullage pressure held through the start [Pa abs]."""
183 I_up: float
184 """Inertance upstream of the main valve, sum L/A [1/m]."""
185 R_up: float
186 """Quadratic loss upstream of the main valve [Pa/(kg/s)^2]."""
187 R_valve_open: float
188 travel_s: float
189 t_open: float
190 """Valve command time [s, Fire = 0]."""
191 I_dn: float = 0.0
192 """Inertance of the downstream tube when full [1/m]."""
193 V_dn_line: float = 0.0
194 """Downstream tube volume [m^3] (the part whose inertance grows as it fills)."""
195 R_dn: float = 0.0
196 V_manifold: float = 0.0
197 """Manifold, orifice passages and valve-body volume downstream of the seat [m^3]."""
198 A_exit: float = 0.0
199 """Bore area the liquid enters the gas volume through [m^2] (for the priming dump)."""
200 K_exit_prime: float = 1.0
201 """Velocity heads lost by the liquid entering the gas volume while priming."""
202 I_inj: float = 0.0
203 phi: float = math.inf
204 """Injector capacity m/sqrt(p_line_exit - Pc) [kg/s/sqrt(Pa)]; inf = no injector loss."""
205 dp_static: float = 0.0
206 """Static head helping the flow, -rho g dz summed along the line [Pa]."""
207 cv_fraction: Callable[[float], float] = linear_characteristic
208 inputs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
209
210 @property
211 def V_prime(self) -> float:
212 return max(self.V_dn_line, 0.0) + max(self.V_manifold, 0.0)
213
214 @property
215 def R_head(self) -> float:
216 if self.K_exit_prime <= 0.0 or self.A_exit <= 0.0:
217 return 0.0
218 return self.K_exit_prime / (2.0 * self.rho * self.A_exit ** 2)
219
220 @property
221 def R_inj(self) -> float:
222 return 0.0 if not math.isfinite(self.phi) else 1.0 / self.phi ** 2
223
224
225@dataclass(frozen=True)
227 volume: float
228 throat_area: float
229 exit_area: float
230 ambient_pa: float
231 gamma: float
232 cstar: Callable[[float, float], float]
233 """``(O/F, total mass flow) -> c*`` [m/s]."""
234 vvac: Callable[[float, float], float]
235 """``(O/F, total mass flow) -> vacuum exhaust velocity`` [m/s]."""
236 ignition_delay_s: float = 0.0
237 mr_design: float = 1.5
238 hard_start_ratio: float = 1.0
239 retained_fraction: float = 1.0
240 """Fraction of the propellant injected before ignition still in the chamber at ignition."""
241 inputs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
242
243
244@dataclass(frozen=True)
246 """What a person chooses for the start model. Every default is today's behaviour or stated."""
247
248 fuel_lead_s: float = 0.0
249 """Fuel main commanded this long before the LOX main (LOX main at Fire = 0). 0 = today's
250 DAQ table, both mains together in ``Fire``. The team's real sequence has a heavy lead."""
251 ignition_delay_s: float = 0.0
252 """From both manifolds primed to ignition. 0 = ignites on arrival. Unmeasured."""
253 valve_travel_s: Optional[float] = None
254 """Both mains' opening travel [s], restated for this diagnostic. None = each main's drawn
255 ``travel_time`` (the burn's own)."""
256 valve_body_volume_L: Tuple[float, float] = (0.0, 0.0)
257 """(LOX, fuel) valve-body volume downstream of the seat [L]: not drawn."""
258 K_exit_prime: float = 1.0
259 hard_start_ratio: float = 1.0
260 retained_fraction: float = 1.0
261 dt: float = 1.0e-5
262 horizon_s: float = 0.25
263 """Integrated this long past the last main reaching full travel."""
264 settle_tol: float = 0.01
265 record_every_s: float = 5.0e-4
266
267
268# ------------------------------------------------------------------ the model
269
270
271def _interp_time(t0: float, dt: float, v0: float, v1: float, target: float) -> float:
272 if v1 == v0:
273 return t0 + dt
274 return t0 + dt * min(max((target - v0) / (v1 - v0), 0.0), 1.0)
275
276
277def run_start(ox: StartLine, fuel: StartLine, chamber: StartChamber, *, dt: float = 1.0e-5,
278 horizon_s: float = 0.25, settle_tol: float = 0.01,
279 record_every_s: float = 5.0e-4) -> Dict[str, Any]:
280 """Integrate the start. Returns the time histories and the event times (SI, Pa)."""
281 lines = (ox, fuel)
282 opened = [ln for ln in lines if math.isfinite(ln.t_open)]
283 if not opened:
284 raise ValueError("neither main valve is ever commanded open")
285 t_begin = min(min(ln.t_open for ln in opened), 0.0)
286 t_full = max(ln.t_open + max(ln.travel_s, 0.0) for ln in opened)
287 t_end = t_full + horizon_s
288 n_steps = int(math.ceil((t_end - t_begin) / dt))
289 every = max(1, int(round(record_every_s / dt)))
290 Gam = gamma_function(chamber.gamma)
291 pa = chamber.ambient_pa
292
293 m = [0.0, 0.0] # line flow
294 vf = [0.0, 0.0] # liquid volume downstream of the seat
295 primed = [ln.V_prime <= 0.0 for ln in lines]
296 t_prime: List[Optional[float]] = [None, None]
297 arrival_m: List[Optional[float]] = [None, None]
298 pre_ign = [0.0, 0.0] # injected before ignition [kg]
299 injected = [0.0, 0.0] # injected over the whole start [kg]
300 pc = pa
301 ignited = False
302 t_ign: Optional[float] = None
303 spike_pa = 0.0
304 pair_kg = 0.0
305 clipped = 0
306 burning_steps = 0
307
308 rec: Dict[str, List[Any]] = {k: [] for k in ("t", "pc", "m_ox", "m_fu", "line_ox", "line_fu", "mr", "F",
309 "x_ox", "x_fu")}
310 thrust_trace: List[Tuple[float, float, float, float]] = [] # (t, F, m_inj_ox, m_inj_fu) every step
311
312 for n in range(n_steps + 1):
313 t = t_begin + n * dt
314 m_inj = [0.0, 0.0]
315 xs = [0.0, 0.0]
316 for i, ln in enumerate(lines):
317 if t <= ln.t_open:
318 continue
319 if t_prime[i] is None and primed[i]:
320 t_prime[i] = ln.t_open # nothing to fill: primed the moment it opens
321 arrival_m[i] = 0.0
322 x = 1.0 if ln.travel_s <= 0.0 else min((t - ln.t_open) / ln.travel_s, 1.0)
323 xs[i] = x
324 frac = ln.cv_fraction(x)
325 if frac <= 0.0:
326 m[i] = 0.0
327 continue
328 r_valve = ln.R_valve_open / (frac * frac)
329 p_end = pc
330 if not primed[i]:
331 f = min(vf[i] / ln.V_dn_line, 1.0) if ln.V_dn_line > 0.0 else 1.0
332 inert = ln.I_up + f * ln.I_dn
333 res = ln.R_up + r_valve + f * ln.R_dn + ln.R_head
334 else:
335 inert = ln.I_up + ln.I_dn + ln.I_inj
336 res = ln.R_up + r_valve + ln.R_dn + ln.R_inj
337 m_old = m[i]
338 m[i] = column_step(m_old, ln.p_tank + ln.dp_static - p_end, inert, res, dt)
339 if not primed[i]:
340 v_old = vf[i]
341 vf[i] = v_old + 0.5 * (m_old + m[i]) / ln.rho * dt
342 if vf[i] >= ln.V_prime:
343 primed[i] = True
344 t_prime[i] = _interp_time(t - dt, dt, v_old, vf[i], ln.V_prime)
345 arrival_m[i] = m[i]
346 # The liquid past the manifold this step has reached the orifices.
347 m_inj[i] = m[i] * (vf[i] - ln.V_prime) / max(vf[i] - v_old, 1e-300)
348 else:
349 m_inj[i] = m[i]
350 injected[i] += m_inj[i] * dt
351
352 both = primed[0] and primed[1] and t_prime[0] is not None and t_prime[1] is not None
353 if not ignited:
354 for i in range(2):
355 pre_ign[i] += m_inj[i] * dt
356 if both and t >= max(t_prime[0], t_prime[1]) + chamber.ignition_delay_s: # type: ignore[arg-type]
357 ignited = True
358 t_ign = max(t_prime[0], t_prime[1]) + chamber.ignition_delay_s # type: ignore[arg-type]
359 mr_d = chamber.mr_design
360 acc_ox = chamber.retained_fraction * pre_ign[0]
361 acc_fu = chamber.retained_fraction * pre_ign[1]
362 pair_kg = min(acc_ox, mr_d * acc_fu) * (1.0 + 1.0 / mr_d)
363 cs0, _ = _call(chamber.cstar, mr_d, max(m[0] + m[1], 1e-9))
364 spike_pa = pair_kg * (Gam * cs0) ** 2 / chamber.volume
365 pc = pa + spike_pa
366 F = 0.0
367 mdot_in = m_inj[0] + m_inj[1]
368 mr = (m_inj[0] / m_inj[1]) if m_inj[1] > 0.0 else (None if m_inj[0] <= 0.0 else math.inf)
369 if ignited:
370 burning_steps += 1
371 mr_eval = mr if (mr is not None and math.isfinite(mr)) else chamber.mr_design
372 cs, was_clipped = _call(chamber.cstar, mr_eval, mdot_in)
373 clipped += was_clipped
374 rt_v = (Gam * cs) ** 2 / chamber.volume
375 pc = chamber_step(pc, mdot_in, rt_v, chamber.throat_area / cs, dt)
376 m_noz = pc * chamber.throat_area / cs
377 vv, _ = _call(chamber.vvac, mr_eval, m_noz)
378 F = max(0.0, vv * m_noz - pa * chamber.exit_area)
379 else:
380 pc = pa
381 thrust_trace.append((t, F, m_inj[0], m_inj[1]))
382 if n % every == 0 or n == n_steps:
383 rec["t"].append(t)
384 rec["pc"].append(pc)
385 rec["m_ox"].append(m_inj[0])
386 rec["m_fu"].append(m_inj[1])
387 rec["line_ox"].append(m[0])
388 rec["line_fu"].append(m[1])
389 rec["mr"].append(None if mr is None or not math.isfinite(mr) else mr)
390 rec["F"].append(F)
391 rec["x_ox"].append(xs[0])
392 rec["x_fu"].append(xs[1])
393
394 # Settling: the last instant any of Pc and the two injected flows is outside the band of
395 # its final value.
396 final = thrust_trace[-1]
397 pc_end, F_end = pc, final[1]
398 m_end = (final[2], final[3])
399 t_settle = None
400 if ignited:
401 t_settle = _settle_time(thrust_trace, m_end, F_end, settle_tol, t_ign or 0.0)
402 return {
403 "t_begin": t_begin, "t_end": t_end, "dt": dt, "trace": thrust_trace, "rec": rec,
404 "t_prime": t_prime, "arrival_mdot": arrival_m, "t_ignition": t_ign, "ignited": ignited,
405 "pre_ignition_kg": pre_ign, "injected_kg": injected, "pair_kg": pair_kg, "spike_pa": spike_pa,
406 "pc_end": pc_end, "F_end": F_end, "mdot_end": m_end, "t_settle": t_settle,
407 "card_clipped_frac": (clipped / burning_steps) if burning_steps else 0.0,
408 "gamma_fn": Gam, "line_end": tuple(m),
409 }
410
411
412def _call(fn: Callable[[float, float], float], a: float, b: float) -> Tuple[float, int]:
413 """``fn(a, b)``; a function that clips (an engine card table) says so through a
414 ``(value, clipped)`` tuple, a plain function returns a float."""
415 out = fn(a, b)
416 if isinstance(out, tuple):
417 return float(out[0]), int(bool(out[1]))
418 return float(out), 0
419
420
421def _settle_time(trace: List[Tuple[float, float, float, float]], m_end: Tuple[float, float],
422 F_end: float, tol: float, t_ign: float) -> Optional[float]:
423 """The last instant thrust or either injected flow is outside ``tol`` of its final value."""
424 last_out = t_ign
425 for t, F, mo, mf in trace:
426 if t < t_ign:
427 continue
428 if (abs(F - F_end) > tol * abs(F_end) or abs(mo - m_end[0]) > tol * abs(m_end[0])
429 or abs(mf - m_end[1]) > tol * abs(m_end[1])):
430 last_out = t
431 return last_out
432
433
434# ------------------------------------------------------------------ the impulse the start costs
435
436
437def fixed_load_deficit(*, trace: Sequence[Tuple[float, float, float, float]], t_window: float,
438 F_settled: float, mdot_settled: Tuple[float, float],
439 scale_F: float = 1.0, scale_m: Tuple[float, float] = (1.0, 1.0),
440 c_end: float, mr_end: float, residual_extra: Tuple[float, float]) -> Dict[str, float]:
441 """Total impulse lost to the start at a fixed propellant load [N s].
442
443 The main burn credits ``F_settled`` from Fire to ``t_window`` and spends ``mdot_settled``
444 per side over it. The start delivers ``J_s`` and spends ``m_s`` (injected) over the same
445 window. After the window the two burns are the same burn, except at the end: the real one
446 has ``E = (mdot_settled t_w - m_s) + residual_extra`` more of each propellant than the main
447 burn had when it ended, burned at O/F ``mr_end`` until the first side runs out, at
448 ``c_end`` [m/s]. ``residual_extra`` is each side's main-burn residual over the dry mass.
449
450 deficit = (F_settled t_w - J_s) - c_end * min(E_ox, mr_end E_fu) (1 + 1/mr_end)
451
452 ``trace`` is ``(t, F, m_ox_injected, m_fuel_injected)`` at a fixed step from the start's
453 first valve command; ``scale_*`` put the model's own steady state on the main burn's scale.
454 """
455 J_s, m_ox, m_fu, J_w = 0.0, 0.0, 0.0, 0.0
456 for k in range(1, len(trace)):
457 t0, F0, o0, f0 = trace[k - 1]
458 t1, F1, o1, f1 = trace[k]
459 if t0 >= t_window:
460 break
461 h = min(t1, t_window) - t0
462 if h <= 0.0:
463 continue
464 J_s += 0.5 * (F0 + F1) * h
465 m_ox += 0.5 * (o0 + o1) * h
466 m_fu += 0.5 * (f0 + f1) * h
467 lo = max(t0, 0.0)
468 if min(t1, t_window) > lo:
469 J_w += 0.5 * (F0 + F1) * (min(t1, t_window) - lo)
470 J_s *= scale_F
471 J_w *= scale_F
472 m_ox *= scale_m[0]
473 m_fu *= scale_m[1]
474 window = F_settled * t_window - J_s
475 E_ox = mdot_settled[0] * t_window - m_ox + residual_extra[0]
476 E_fu = mdot_settled[1] * t_window - m_fu + residual_extra[1]
477 extra = min(E_ox, mr_end * E_fu) * (1.0 + 1.0 / mr_end)
478 return {
479 "deficit_Ns": window - c_end * extra,
480 "window_deficit_Ns": F_settled * t_window - J_w,
481 "start_impulse_Ns": J_s,
482 "start_ox_kg": m_ox,
483 "start_fuel_kg": m_fu,
484 "end_extension_Ns": c_end * extra,
485 "extra_ox_kg": E_ox,
486 "extra_fuel_kg": E_fu,
487 }
488
489
490# ------------------------------------------------------------------ the drawing and the engine
491
492
493def feed_path(net: Any, inlet: str, tank: str) -> List[str]:
494 """Branch ids from the tank down to the injector inlet (a breadth-first walk upstream, so a
495 fill or vent tee meeting the path is not taken for the feed). Same walk as
496 engine/layerx/flight.py ``_path_to_tank``."""
497 parent: Dict[str, Tuple[str, str]] = {}
498 frontier, seen = [inlet], {inlet}
499
500 def is_tank(node: str) -> bool:
501 return node == tank or node.split(".", 1)[0] == tank
502
503 reached = None
504 while frontier and reached is None:
505 here = frontier.pop(0)
506 for bid, br in net.branches.items():
507 if br.downstream == here and br.upstream not in seen:
508 seen.add(br.upstream)
509 parent[br.upstream] = (here, bid)
510 if is_tank(br.upstream):
511 reached = br.upstream
512 break
513 frontier.append(br.upstream)
514 if reached is None:
515 return []
516 out, node = [], reached
517 while node != inlet:
518 node, bid = parent[node]
519 out.append(bid)
520 return out
521
522
523@dataclass(frozen=True)
525 """One drawn element on a feed path, as the transient modules need it."""
526
527 id: str
528 kind: str
529 """``pipe``, ``valve`` or ``other``."""
530 R: float
531 """Quadratic loss at the reference flow over that flow squared [Pa/(kg/s)^2]."""
532 length: float = 0.0
533 bore: float = 0.0
534 wall: Optional[float] = None
535 dz: float = 0.0
536 travel_s: Optional[float] = None
537 cv_fraction: Optional[Callable[[float], float]] = None
538 inputs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
539
540 @property
541 def area(self) -> float:
542 return math.pi * self.bore ** 2 / 4.0 if self.bore > 0.0 else 0.0
543
544 @property
545 def inertance(self) -> float:
546 return self.length / self.area if self.area > 0.0 else 0.0
547
548 @property
549 def volume(self) -> float:
550 return self.length * self.area
551
552
553@dataclass(frozen=True)
555 """A side's feed path from the drawing: elements tank -> main valve -> line exit."""
556
557 side: str
558 key: str
559 tank: str
560 fluid: str
561 rho: float
562 mu: float
563 a_fluid: float
564 p_sat: float
565 upstream: Tuple[Element, ...]
566 valve: Optional[Element]
567 downstream: Tuple[Element, ...]
568 warnings: Tuple[str, ...] = ()
569
570 def sum(self, which: str, attr: str) -> float:
571 elems = self.upstream if which == "up" else self.downstream
572 return float(sum(getattr(e, attr) for e in elems))
573
574
575def _params_inputs(prefix: str, comp: Any, names: Sequence[str], notes: Mapping[str, str] = {}) -> Dict[str, Dict[str, Any]]:
576 out: Dict[str, Dict[str, Any]] = {}
577 inst = getattr(comp, "instance", None)
578 params = getattr(inst, "params", {}) or {}
579 for name in names:
580 p = params.get(name)
581 if p is None:
582 continue
583 out[f"{prefix}.{name}"] = inp(finite(p.value), str(p.unit), param_provenance(p, notes.get(name, "")))
584 return out
585
586
587def feed_line(prep: Any, side: str, *, mdot_ref: float, p_ref: float, T_ref: float) -> FeedLine:
588 """The drawn feed path of ``side`` with each element's loss priced by the twin's own
589 component at ``mdot_ref`` and the fluid's state at ``p_ref`` [Pa], ``T_ref`` [K]."""
590 from feedtwin.comps.base import FlowConditions
591 from feedtwin.props.fluid import Fluid
592
593 net = prep.model.built.network
594 tank = prep.roles[side]
595 inlet = prep.inlet_nodes[side]
596 species = (prep.derived.get("species") or {}).get(side)
597 if not species:
598 raise ValueError(f"no species for the {side} side")
599 fl = Fluid(species)
600 rho = fl.get("rho", p=p_ref, T=T_ref)
601 mu = fl.get("mu", p=p_ref, T=T_ref)
602 a_f = fl.get("a", p=p_ref, T=T_ref)
603 try:
604 p_sat = fl.get("p", T=T_ref, q=0.0)
605 except Exception: # noqa: BLE001 - a supercritical or unsupported state: no saturation
606 p_sat = 0.0
607 path = feed_path(net, inlet, tank)
608 if not path:
609 raise ValueError(f"no drawn path from {tank} to {inlet}")
610 flow = FlowConditions(rho=rho, mu=mu, p_upstream=p_ref)
611 m_ref = max(abs(mdot_ref), 1e-6)
612 elems: List[Element] = []
613 warnings: List[str] = []
614 for bid in path:
615 comp = net.branches[bid].component
616 cls = type(comp).__name__
617 p = getattr(comp, "p", {}) or {}
618 try:
619 R = float(comp.pressure_drop(m_ref, flow)) / m_ref ** 2
620 except Exception as exc: # noqa: BLE001
621 R = 0.0
622 warnings.append(f"{bid} ({cls}): loss not evaluated ({exc}); taken as lossless")
623 if cls == "Pipe" or ("length" in p and "bore" in p):
624 wall = p.get("wall_thickness")
625 elems.append(Element(
626 id=bid, kind="pipe", R=R, length=float(p["length"]), bore=float(p["bore"]),
627 wall=float(wall) if wall else None, dz=float(p.get("elevation_change", 0.0) or 0.0),
628 inputs=_params_inputs(bid, comp, ("length", "bore", "K_minor", "roughness", "wall_thickness",
629 "elevation_change"))))
630 elif hasattr(comp, "effective_cv"):
631 cv1 = float(comp.effective_cv(1.0))
632
633 def frac(x: float, _c: Any = comp, _cv1: float = cv1) -> float:
634 return float(_c.effective_cv(x)) / _cv1 if _cv1 > 0 else 0.0
635
636 travel = p.get("travel_time")
637 if travel is None:
638 travel = getattr(prep.setup, "valve_travel_s", None)
639 elems.append(Element(
640 id=bid, kind="valve", R=R, bore=float(p.get("bore", 0.0) or 0.0),
641 travel_s=float(travel) if travel is not None else None, cv_fraction=frac,
642 inputs=_params_inputs(bid, comp, ("Cv", "bore", "travel_time"))))
643 else:
644 elems.append(Element(id=bid, kind="other", R=R, inputs={}))
645 warnings.append(f"{bid} ({cls}): counted as a loss only (no inertance, no volume)")
646 valves = [i for i, e in enumerate(elems) if e.kind == "valve" and e.travel_s is not None]
647 key = dict(SIDES)[side]
648 if not valves:
649 return FeedLine(side, key, tank, species, rho, mu, a_f, p_sat, tuple(elems), None, (), tuple(
650 warnings + ["no main valve with a travel time on the path"]))
651 iv = valves[-1]
652 return FeedLine(side, key, tank, species, rho, mu, a_f, p_sat, tuple(elems[:iv]), elems[iv],
653 tuple(elems[iv + 1:]), tuple(warnings))
654
655
656def exit_area(feed: FeedLine) -> Tuple[float, bool]:
657 """The area the flow enters the injector manifold through [m^2], and whether it is the main
658 valve's own bore: the last drawn line below the main valve, else the last above it, else -- a
659 side drawn with no line at all, as LE4's LOX side is (tank outlet, OM-R, injector) -- the valve's
660 bore. That last case used to index an empty list, and the whole start went missing from the run.
661 With neither a line nor a valve bore on the drawing there is nothing to take it from: it raises."""
662 for elems in (feed.downstream, feed.upstream):
663 pipes = [e for e in elems if e.kind == "pipe"]
664 if pipes:
665 return pipes[-1].area, False
666 if feed.valve is not None and feed.valve.area > 0.0:
667 return feed.valve.area, True
668 raise ValueError(f"{feed.side}: no line and no main-valve bore on the drawing, so nothing says what area the "
669 "flow enters the manifold through")
670
671
672def manifold_volumes(config: Any) -> Dict[str, Dict[str, Any]]:
673 """Each side's injector volume downstream of its feed port, from the config's injector layout
674 (engine/core/injectors/layout.py): the ring channel (cross-section x 2 pi r_center) and the
675 orifice passages (n x pi/4 d^2 x through-length). Returns per ``ox``/``fuel``: ``V_m3``,
676 ``I_inj`` (passage length over total passage area) and the inputs. The plate's feed ports and
677 any back-cover passages are not in the layout and are not counted."""
678 from engine.core.injectors.layout import layout_from_config
679
680 lay = layout_from_config(config, drawings=False, checks=False)
681 if lay is None:
682 raise ValueError("the config's injector is not an impinging doublet with a layout")
683 inputs = lay.get("inputs") or {}
684 out: Dict[str, Dict[str, Any]] = {}
685 for k, key, side in (("O", "ox", "oxidizer"), ("F", "fuel", "fuel")):
686 ps = lay["passages"][k]
687 n = int(round(float((inputs.get(side) or {}).get("n_elements", 0) or 0)))
688 d = float(ps["bore"])
689 thru = float(ps["thru"])
690 a_or = n * math.pi * d * d / 4.0
691 ch = ps.get("channel") or {}
692 v_ring = float(ch.get("flow_area", 0.0)) * 2.0 * math.pi * float(ch.get("r_center", 0.0))
693 v_or = a_or * thru
694 src = "EngineDesign config injector layout (layout_from_config)"
695 out[key] = {
696 "V_m3": v_ring + v_or, "V_ring_m3": v_ring, "V_orifices_m3": v_or,
697 "I_inj": thru / a_or if a_or > 0 else 0.0,
698 "inputs": {
699 f"{key}.channel_flow_area": inp(ch.get("flow_area"), "m^2", src + ": ring channel cross-section"),
700 f"{key}.channel_r_center": inp(ch.get("r_center"), "m", src + ": ring channel centre radius"),
701 f"{key}.n_orifices": inp(n, "-", src),
702 f"{key}.orifice_d": inp(d, "m", src),
703 f"{key}.orifice_length": inp(thru, "m", src + ": passage through-length"),
704 },
705 }
706 return out
707
708
709# ------------------------------------------------------------------ from a Layer X run
710
711
712def _first_firing(series: Mapping[str, Any]) -> int:
713 for i, f in enumerate(series.get("firing") or []):
714 if f:
715 return i
716 raise ValueError("the burn never fired")
717
718
719def _last_firing(series: Mapping[str, Any]) -> int:
720 idx = [i for i, f in enumerate(series.get("firing") or []) if f]
721 if not idx:
722 raise ValueError("the burn never fired")
723 return idx[-1]
724
725
726def _card_fn(table: Any, label: str) -> Callable[[float, float], Tuple[float, bool]]:
727 lo_x, hi_x = float(table.x0), float(table.x_max)
728 lo_y, hi_y = float(table.y0), float(table.y_max)
729
730 def fn(mr: float, mdot: float) -> Tuple[float, bool]:
731 x = min(max(mr, lo_x), hi_x)
732 y = min(max(mdot, lo_y), hi_y)
733 return float(table(x, y)), (x != mr or y != mdot)
734
735 return fn
736
737
738def settled_point(result: Mapping[str, Any]) -> Dict[str, Any]:
739 """The main burn's first settled operating point: the delivered (replay) first point when the
740 replay ran, else the twin's first firing step."""
741 series = result["series"]
742 i0 = _first_firing(series)
743 deliv = result.get("delivered") or {}
744 out = {
745 "index": i0, "t": float(series["t"][i0]),
746 "p_inlet_ox": float(series["ox"]["inlet_psia"][i0]) * PSI,
747 "p_inlet_fu": float(series["fuel"]["inlet_psia"][i0]) * PSI,
748 "line_ox": float(series["ox"]["mdot"][i0]), "line_fu": float(series["fuel"]["mdot"][i0]),
749 "pc_twin": float(series["chamber"]["pc_psia"][i0]) * PSI,
750 }
751 if deliv.get("thrust_N"):
752 out.update(F=float(deliv["thrust_N"][0]), pc=float(deliv["pc_psia"][0]) * PSI,
753 m_ox=float(deliv["mdot_O"][0]), m_fu=float(deliv["mdot_F"][0]), basis="delivered (replay) first point")
754 else:
755 out.update(F=float(series["chamber"]["thrust_N"][i0]), pc=out["pc_twin"], m_ox=out["line_ox"],
756 m_fu=out["line_fu"], basis="twin first firing step")
757 return out
758
759
760def start_from_run(prep: Any, result: Mapping[str, Any], config: Any = None,
761 settings: StartSettings = StartSettings()) -> Dict[str, Any]:
762 """``diagnostics.start`` for a finished Layer X run. Never raises."""
763 try:
764 return _start_from_run(prep, result, config, settings)
765 except Exception as exc: # noqa: BLE001 - a diagnostic never takes the burn down
766 return unavailable(f"start transient: {exc}")
767
768
769def _start_from_run(prep: Any, result: Mapping[str, Any], config: Any,
770 settings: StartSettings) -> Dict[str, Any]:
771 series = result["series"]
772 summary = result.get("summary") or {}
773 sp = settled_point(result)
774 i0 = sp["index"]
775 if config is None and prep.link is not None and getattr(prep.link, "sampler", None) is not None:
776 config = prep.link.sampler.config
777 if config is None:
778 raise ValueError("no engine config to take the injector manifold from")
779 man = manifold_volumes(config)
780 inputs: Dict[str, Dict[str, Any]] = {}
781 lines: Dict[str, StartLine] = {}
782 feeds: Dict[str, FeedLine] = {}
783 for (side, key), vb in zip(SIDES, settings.valve_body_volume_L):
784 sub = series["ox" if key == "ox" else "fuel"]
785 # The tank outlet at the last step before Fire: the ullage plus the liquid's head over the
786 # outlet, as the twin has it.
787 i_pre = max(i0 - 1, 0)
788 p_series = sub.get("outlet_psia")
789 if p_series is None:
790 p_series = sub["tank_psia"]
791 p_tank = float(p_series[i_pre]) * PSI
792 T_liq = float(sub["liquid_K"][i0])
793 m_line = sp["line_ox"] if key == "ox" else sp["line_fu"]
794 feed = feed_line(prep, side, mdot_ref=m_line, p_ref=p_tank, T_ref=T_liq)
795 if feed.valve is None:
796 raise ValueError(f"{side}: " + "; ".join(feed.warnings))
797 feeds[key] = feed
798 rho = feed.rho
799 g = G0
800 dz = sum(e.dz for e in feed.upstream + feed.downstream if e.kind == "pipe")
801 p_in = sp["p_inlet_ox"] if key == "ox" else sp["p_inlet_fu"]
802 dp_inj = p_in - sp["pc_twin"]
803 if dp_inj <= 0:
804 raise ValueError(f"{side}: no injector drop at the first firing step")
805 phi = m_line / math.sqrt(dp_inj)
806 mv = man[key]
807 a_exit, through_valve = exit_area(feed)
808 if through_valve:
809 tag = next((n.label for n in prep.model.diagram.nodes if n.id == feed.valve.id and n.label), feed.valve.id)
810 inputs[f"{key}.A_exit"] = inp(a_exit, "m^2", f"the main valve {tag}'s drawn bore: no line is drawn on this "
811 "side, so the flow enters the manifold from the valve")
812 lines[key] = StartLine(
813 key=key, rho=rho, p_tank=p_tank,
814 I_up=feed.sum("up", "inertance"), R_up=feed.sum("up", "R"),
815 R_valve_open=feed.valve.R,
816 travel_s=(float(settings.valve_travel_s) if settings.valve_travel_s is not None
817 else float(feed.valve.travel_s or 0.0)),
818 t_open=(-settings.fuel_lead_s if key == "fuel" else 0.0),
819 I_dn=feed.sum("dn", "inertance"), V_dn_line=feed.sum("dn", "volume"), R_dn=feed.sum("dn", "R"),
820 V_manifold=mv["V_m3"] + float(vb) * 1e-3, A_exit=a_exit, K_exit_prime=settings.K_exit_prime,
821 I_inj=mv["I_inj"], phi=phi, dp_static=-rho * g * dz,
822 cv_fraction=feed.valve.cv_fraction or linear_characteristic)
823 for e in feed.upstream + (feed.valve,) + feed.downstream:
824 inputs.update(e.inputs)
825 inputs.update(mv["inputs"])
826 inputs[f"{key}.p_tank"] = inp(p_tank / PSI, "psia", "main burn tank outlet (ullage + liquid head) at the last "
827 "step before Fire (series outlet_psia), held through the start")
828 inputs[f"{key}.rho"] = inp(rho, "kg/m^3", f"feedtwin Fluid('{feed.fluid}') at T-0 tank pressure and {T_liq:.2f} K")
829 inputs[f"{key}.phi"] = inp(phi, "kg/s/sqrt(Pa)", "engine card capacity at the main burn's first firing step: "
830 "series mdot / sqrt(line-exit pressure - Pc)")
831 inputs[f"{key}.valve_body_volume"] = inp(float(vb), "L", "assumed 0: the valve body volume downstream of the seat is not drawn")
832 if settings.valve_travel_s is not None:
833 inputs[f"{key}.valve_travel"] = inp(float(settings.valve_travel_s), "s",
834 "setting valve_travel_s (restated for this diagnostic; the burn used "
835 f"the drawing's {float(feed.valve.travel_s or 0.0):g} s)")
836 inputs[f"{key}.V_manifold"] = inp(mv["V_m3"] * 1e3, "L", "ring channel + orifice passages from the config layout; "
837 "feed ports and back passages not counted")
838 link = prep.link
839 card = getattr(link, "card", None)
840 gamma_list = (result.get("replay") or {}).get("gamma") or []
841 gamma = float(gamma_list[0]) if gamma_list else 1.2
842 gamma_prov = ("main burn replay's chamber gamma at its first point (CEA, EngineDesign)" if gamma_list
843 else "assumed 1.2: no replay gamma on this run")
844 At = float(link.design.throat_area)
845 Vc = float(link.design.chamber_volume)
846 pa = float(prep.ambient_pa)
847 if card is not None:
848 Ae = float(card.exit_area)
849 cstar = _card_fn(card.chamber.cstar, "c*")
850 vvac = _card_fn(card.chamber.vacuum_velocity, "v_vac")
851 card_prov = "engine card (EngineDesign sampled at the line exit), clipped to its table"
852 else:
853 m_tot = sp["m_ox"] + sp["m_fu"]
854 cs0 = sp["pc"] * At / m_tot
855 Ae = float(getattr(link.design, "exit_area", 0.0) or 0.0)
856 v0 = (sp["F"] + pa * Ae) / m_tot
857 cstar = lambda mr, m, _c=cs0: _c # noqa: E731
858 vvac = lambda mr, m, _v=v0: _v # noqa: E731
859 card_prov = "constant at the main burn's first settled point (no engine card on this run)"
860 mr_design = sp["m_ox"] / sp["m_fu"]
861 ch = StartChamber(volume=Vc, throat_area=At, exit_area=Ae, ambient_pa=pa, gamma=gamma, cstar=cstar,
862 vvac=vvac, ignition_delay_s=settings.ignition_delay_s, mr_design=mr_design,
863 hard_start_ratio=settings.hard_start_ratio, retained_fraction=settings.retained_fraction)
864 run = run_start(lines["ox"], lines["fuel"], ch, dt=settings.dt, horizon_s=settings.horizon_s,
865 settle_tol=settings.settle_tol, record_every_s=settings.record_every_s)
866 if not run["ignited"]:
867 raise ValueError("the start never ignited inside the horizon")
868
869 # The model's steady state against the main burn's first settled point.
870 F_end = run["F_end"]
871 m_end = run["mdot_end"]
872 Gam = run["gamma_fn"]
873 steady = {
874 "thrust_rel": F_end / sp["F"] - 1.0, "pc_rel": run["pc_end"] / sp["pc"] - 1.0,
875 "mdot_ox_rel": m_end[0] / sp["m_ox"] - 1.0, "mdot_fuel_rel": m_end[1] / sp["m_fu"] - 1.0,
876 "basis": sp["basis"],
877 }
878 scale_F = sp["F"] / F_end if F_end > 0 else 1.0
879 scale_m = (sp["m_ox"] / m_end[0] if m_end[0] > 0 else 1.0, sp["m_fu"] / m_end[1] if m_end[1] > 0 else 1.0)
880
881 # The main burn's totals, for the fixed-load accounting.
882 il = _last_firing(series)
883 deliv = result.get("delivered") or {}
884 dsum = deliv.get("summary") or {}
885 J_main = finite(dsum.get("total_impulse_Ns")) or finite(summary.get("total_impulse_Ns"))
886 if deliv.get("thrust_N"):
887 F_last = float(deliv["thrust_N"][-1])
888 m_last = float(deliv["mdot_O"][-1]) + float(deliv["mdot_F"][-1])
889 mr_last = float(deliv["mdot_O"][-1]) / float(deliv["mdot_F"][-1])
890 else:
891 F_last = float(series["chamber"]["thrust_N"][il])
892 m_last = float(series["ox"]["mdot"][il]) + float(series["fuel"]["mdot"][il])
893 mr_last = float(series["ox"]["mdot"][il]) / float(series["fuel"]["mdot"][il])
894 c_end = F_last / m_last
895 dry = float(((result.get("provenance") or {}).get("settings") or {}).get("dry_kg") or 0.001)
896 res_ox = float((summary.get("ox") or {}).get("residual_kg") or dry)
897 res_fu = float((summary.get("fuel") or {}).get("residual_kg") or dry)
898 t_window = max(run["t_settle"] or 0.0, 0.0)
899 acc = fixed_load_deficit(trace=run["trace"], t_window=t_window, F_settled=sp["F"],
900 mdot_settled=(sp["m_ox"], sp["m_fu"]), scale_F=scale_F, scale_m=scale_m,
901 c_end=c_end, mr_end=mr_last,
902 residual_extra=(max(res_ox - dry, 0.0), max(res_fu - dry, 0.0)))
903
904 # Hard start (heuristic).
905 cs_set = sp["pc"] * At / (sp["m_ox"] + sp["m_fu"])
906 inventory = sp["pc"] * Vc / (Gam * cs_set) ** 2
907 ratio = run["pair_kg"] / inventory if inventory > 0 else 0.0
908 pair_rate = min(m_end[0], mr_design * m_end[1]) * (1.0 + 1.0 / mr_design)
909 delay_threshold = settings.hard_start_ratio * inventory / pair_rate if pair_rate > 0 else None
910 hard = ratio > settings.hard_start_ratio
911 lead_fuel = run["pre_ignition_kg"][1] if settings.fuel_lead_s > 0 else 0.0
912
913 rec = run["rec"]
914 t_prime = run["t_prime"]
915 surge = {}
916 for i, key in enumerate(("ox", "fuel")):
917 am = run["arrival_mdot"][i]
918 ln = lines[key]
919 surge[key] = (None if am is None or am <= 0 or not math.isfinite(ln.phi)
920 else (pa + (am / ln.phi) ** 2) / PSI)
921 inputs.update({
922 "fuel_lead_s": inp(settings.fuel_lead_s, "s", "setting; 0 = today's DAQ table (both mains in Fire). "
923 "The team's sequence has a heavy fuel lead: unmeasured"),
924 "ignition_delay_s": inp(settings.ignition_delay_s, "s", "assumed 0: ignites when both manifolds are primed. Unmeasured"),
925 "K_exit_prime": inp(settings.K_exit_prime, "velocity heads", "assumed 1: Borda dump of the liquid entering the gas volume"),
926 "chamber_volume": inp(Vc, "m^3", "EngineDesign geometry (link.design.chamber_volume)"),
927 "throat_area": inp(At, "m^2", "EngineDesign geometry, as built (link.design.throat_area)"),
928 "exit_area": inp(Ae, "m^2", "engine card exit area"),
929 "gamma": inp(gamma, "-", gamma_prov),
930 "cstar_and_vvac": inp(None, "m/s", card_prov),
931 "ambient": inp(pa / PSI, "psia", "site ambient (prep.ambient_pa)"),
932 "hard_start_ratio": inp(settings.hard_start_ratio, "-", "heuristic threshold: accumulated pair over the chamber's steady gas inventory"),
933 "retained_fraction": inp(settings.retained_fraction, "-", "assumed 1: upper bound, everything injected before ignition is still in the chamber"),
934 "F_settled": inp(sp["F"], "N", f"main burn first settled thrust ({sp['basis']})"),
935 "dt": inp(settings.dt, "s", "integration step"),
936 })
937 warnings: List[str] = []
938 for f in feeds.values():
939 warnings.extend(f.warnings)
940 if run["card_clipped_frac"] > 0:
941 warnings.append(f"engine card clipped to its table on {100 * run['card_clipped_frac']:.0f} % of burning steps "
942 "(low flow and O/F during the rise)")
943 model = model_block(
944 MODEL_NAME,
945 "Rigid-column (lumped inertance) feed lines with a linear-travel main valve, the rigid-column filling "
946 "model for the gas-filled downstream volume (Liou & Hunt, J. Hydraul. Eng. 122(10), 1996), and a "
947 "lumped chamber dPc/dt = (RT/V)(m_in - Pc A_t/c*) with RT = (Gamma c*)^2 (Sutton & Biblarz, Rocket "
948 "Propulsion Elements, ideal-rocket relations). Own module: the main burn is not changed.",
949 [
950 "tank outlet pressure held at its pre-Fire value through the start (the twin's ignition dip is 3-4 psi)",
951 "lines incompressible; the friction factor frozen at the main burn's first settled flow",
952 "downstream of each main valve the tube, manifold and orifices are gas at Fire; the manifold fills "
953 "before any orifice flows; the displaced gas's orifice drop (under 7 kPa on LE4) is neglected",
954 "the downstream hardware is taken as chilled: LOX boiling on a warm valve, tube or manifold (vapour "
955 "pushing back on the front) is not modelled, so the LOX priming time is a lower bound and the "
956 "LOX-before-fuel order holds only for a chilled-down manifold",
957 "the injector's ring channel adds volume but no inertance (only the orifice passages' L/A is counted)",
958 "the liquid entering the gas volume loses K_exit_prime velocity heads; once primed the engine card's "
959 "capacity (which owns the dump) takes over, at its first-settled-step value",
960 "ignition when both manifolds are primed plus ignition_delay_s; Pc is ambient before it",
961 "propellant burns on arrival after ignition (no vaporisation lag); c* and vacuum exhaust velocity "
962 "from the engine card at the instantaneous O/F and flow, clipped to the table",
963 "thrust floored at zero; a separated nozzle at low Pc is not modelled (the deficit is the larger figure)",
964 "the mass that fills the downstream line and manifold is pushed through at burnout, so it is not charged",
965 "the propellant the start did not spend is burned at the end at the main burn's end-of-burn exhaust "
966 "velocity and O/F until the first tank runs dry",
967 "hard start is a heuristic (no cited criterion): injected-not-burned pair at ignition against the "
968 "chamber's steady gas inventory. A single propellant pooled before the other arrives is not paired "
969 "(ignition is at the second arrival), so a pooled lead does not trip it",
970 "arrival_orifice_psia is the rigid-column orifice back-pressure at the arrival flow, an overestimate; "
971 "the compressible figure is water_hammer[].opening (diag.waterhammer)",
972 ],
973 inputs)
974 out: Dict[str, Any] = {
975 "available": True,
976 "t": [round(x, 6) for x in rec["t"]],
977 "pc_psia": [p / PSI for p in rec["pc"]],
978 "mdot_ox": rec["m_ox"], "mdot_fuel": rec["m_fu"],
979 "line_mdot_ox": rec["line_ox"], "line_mdot_fuel": rec["line_fu"],
980 "valve_ox": rec["x_ox"], "valve_fuel": rec["x_fu"],
981 "thrust_N": [f * scale_F for f in rec["F"]],
982 "mr": rec["mr"],
983 "fuel_lead_s": settings.fuel_lead_s,
984 "valve_travel_s": max(lines["ox"].travel_s, lines["fuel"].travel_s),
985 "prime_ox_s": t_prime[0], "prime_fuel_s": t_prime[1],
986 "ignition_s": run["t_ignition"], "settle_s": run["t_settle"],
987 "impulse_deficit_Ns": acc["deficit_Ns"],
988 "impulse_deficit_pct": (100.0 * acc["deficit_Ns"] / J_main) if J_main else None,
989 "window_deficit_Ns": acc["window_deficit_Ns"],
990 "accounting": acc,
991 "hard_start": bool(hard),
992 "hard_start_detail": {
993 "pair_kg": run["pair_kg"], "spike_psi": run["spike_pa"] / PSI, "inventory_kg": inventory,
994 "ratio": ratio, "threshold": settings.hard_start_ratio, "ignition_delay_threshold_s": delay_threshold,
995 "pre_ignition_ox_kg": run["pre_ignition_kg"][0], "pre_ignition_fuel_kg": run["pre_ignition_kg"][1],
996 "basis": "heuristic",
997 },
998 "lead_fuel_kg": lead_fuel,
999 "prime_volume_L": {"ox": lines["ox"].V_prime * 1e3, "fuel": lines["fuel"].V_prime * 1e3},
1000 "arrival_mdot": {"ox": run["arrival_mdot"][0], "fuel": run["arrival_mdot"][1]},
1001 "arrival_orifice_psia": surge,
1002 "steady_check": steady,
1003 "card_clipped_frac": run["card_clipped_frac"],
1004 "events": [e for e in (
1005 {"key": "fuel_lead", "t": -settings.fuel_lead_s, "label": "Fuel main open"} if settings.fuel_lead_s > 0 else None,
1006 {"key": "fire", "t": 0.0, "label": "LOX main open" if settings.fuel_lead_s > 0 else "Mains open"},
1007 {"key": "ignition", "t": run["t_ignition"], "label": "Ignition (start model)"},
1008 ) if e is not None],
1009 "warnings": warnings,
1010 "unmeasured": ["fuel_lead_s", "ignition_delay_s", "valve_body_volume_L",
1011 f"main valve travel ({max(lines['ox'].travel_s, lines['fuel'].travel_s):g} s, "
1012 "'measured: fast solenoid', no test cited)", "K_exit_prime", "retained_fraction",
1013 "feed port / back passage volume of the injector", "hard_start_ratio (heuristic threshold)",
1014 "manifold chill-down state at Fire (LOX boiling while priming)"],
1015 "model": model,
1016 }
1017 return out
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float sum(self, str which, str attr)
Definition start.py:570
Dict[str, Any] inp(Any value, str unit, str provenance)
Definition start.py:96
int _last_firing(Mapping[str, Any] series)
Definition start.py:719
float gamma_function(float gamma)
Definition start.py:162
Dict[str, Any] run_start(StartLine ox, StartLine fuel, StartChamber chamber, *, float dt=1.0e-5, float horizon_s=0.25, float settle_tol=0.01, float record_every_s=5.0e-4)
Definition start.py:279
Dict[str, Any] start_from_run(Any prep, Mapping[str, Any] result, Any config=None, StartSettings settings=StartSettings())
Definition start.py:761
Dict[str, float] fixed_load_deficit(*, Sequence[Tuple[float, float, float, float]] trace, float t_window, float F_settled, Tuple[float, float] mdot_settled, float scale_F=1.0, Tuple[float, float] scale_m=(1.0, 1.0), float c_end, float mr_end, Tuple[float, float] residual_extra)
Definition start.py:440
FeedLine feed_line(Any prep, str side, *, float mdot_ref, float p_ref, float T_ref)
Definition start.py:587
float column_step(float m, float dp, float inertance, float resistance, float dt)
Definition start.py:135
Optional[float] _settle_time(List[Tuple[float, float, float, float]] trace, Tuple[float, float] m_end, float F_end, float tol, float t_ign)
Definition start.py:422
Dict[str, Dict[str, Any]] _params_inputs(str prefix, Any comp, Sequence[str] names, Mapping[str, str] notes={})
Definition start.py:575
Dict[str, Any] settled_point(Mapping[str, Any] result)
Definition start.py:738
Optional[float] finite(Any x)
Definition start.py:114
Dict[str, Any] model_block(str name, str source, Sequence[str] assumptions, Mapping[str, Dict[str, Any]] inputs)
Definition start.py:104
Dict[str, Any] _start_from_run(Any prep, Mapping[str, Any] result, Any config, StartSettings settings)
Definition start.py:770
str param_provenance(Any param, str note="")
Definition start.py:122
float chamber_step(float pc, float mdot_in, float rt_over_v, float at_over_cstar, float dt)
Definition start.py:156
Dict[str, Dict[str, Any]] manifold_volumes(Any config)
Definition start.py:672
Dict[str, Any] unavailable(str error)
Definition start.py:109
Tuple[float, int] _call(Callable[[float, float], float] fn, float a, float b)
Definition start.py:412
float _interp_time(float t0, float dt, float v0, float v1, float target)
Definition start.py:271
Callable[[float, float], Tuple[float, bool]] _card_fn(Any table, str label)
Definition start.py:726
Tuple[float, bool] exit_area(FeedLine feed)
Definition start.py:656
float linear_characteristic(float x)
Definition start.py:167
int _first_firing(Mapping[str, Any] series)
Definition start.py:712
List[str] feed_path(Any net, str inlet, str tank)
Definition start.py:493