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)."""
282 opened = [ln
for ln
in lines
if math.isfinite(ln.t_open)]
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)))
291 pa = chamber.ambient_pa
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]
299 injected = [0.0, 0.0]
302 t_ign: Optional[float] =
None
308 rec: Dict[str, List[Any]] = {k: []
for k
in (
"t",
"pc",
"m_ox",
"m_fu",
"line_ox",
"line_fu",
"mr",
"F",
310 thrust_trace: List[Tuple[float, float, float, float]] = []
312 for n
in range(n_steps + 1):
316 for i, ln
in enumerate(lines):
319 if t_prime[i]
is None and primed[i]:
320 t_prime[i] = ln.t_open
322 x = 1.0
if ln.travel_s <= 0.0
else min((t - ln.t_open) / ln.travel_s, 1.0)
324 frac = ln.cv_fraction(x)
328 r_valve = ln.R_valve_open / (frac * frac)
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
335 inert = ln.I_up + ln.I_dn + ln.I_inj
336 res = ln.R_up + r_valve + ln.R_dn + ln.R_inj
338 m[i] =
column_step(m_old, ln.p_tank + ln.dp_static - p_end, inert, res, dt)
341 vf[i] = v_old + 0.5 * (m_old + m[i]) / ln.rho * dt
342 if vf[i] >= ln.V_prime:
344 t_prime[i] =
_interp_time(t - dt, dt, v_old, vf[i], ln.V_prime)
347 m_inj[i] = m[i] * (vf[i] - ln.V_prime) /
max(vf[i] - v_old, 1e-300)
350 injected[i] += m_inj[i] * dt
352 both = primed[0]
and primed[1]
and t_prime[0]
is not None and t_prime[1]
is not None
355 pre_ign[i] += m_inj[i] * dt
356 if both
and t >=
max(t_prime[0], t_prime[1]) + chamber.ignition_delay_s:
358 t_ign =
max(t_prime[0], t_prime[1]) + chamber.ignition_delay_s
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
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)
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)
381 thrust_trace.append((t, F, m_inj[0], m_inj[1]))
382 if n % every == 0
or n == n_steps:
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)
391 rec[
"x_ox"].append(xs[0])
392 rec[
"x_fu"].append(xs[1])
396 final = thrust_trace[-1]
397 pc_end, F_end = pc, final[1]
398 m_end = (final[2], final[3])
401 t_settle =
_settle_time(thrust_trace, m_end, F_end, settle_tol, t_ign
or 0.0)
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),
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].
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.
450 deficit = (F_settled t_w - J_s) - c_end * min(E_ox, mr_end E_fu) (1 + 1/mr_end)
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.
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]
461 h =
min(t1, t_window) - t0
464 J_s += 0.5 * (F0 + F1) * h
465 m_ox += 0.5 * (o0 + o1) * h
466 m_fu += 0.5 * (f0 + f1) * h
468 if min(t1, t_window) > lo:
469 J_w += 0.5 * (F0 + F1) * (
min(t1, t_window) - lo)
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)
479 "deficit_Ns": window - c_end * extra,
480 "window_deficit_Ns": F_settled * t_window - J_w,
481 "start_impulse_Ns": J_s,
483 "start_fuel_kg": m_fu,
484 "end_extension_Ns": c_end * extra,
486 "extra_fuel_kg": E_fu,
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]."""
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)
598 raise ValueError(f
"no species for the {side} side")
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)
604 p_sat = fl.get(
"p", T=T_ref, q=0.0)
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] = []
615 comp = net.branches[bid].component
616 cls = type(comp).__name__
617 p = getattr(comp,
"p", {})
or {}
619 R = float(comp.pressure_drop(m_ref, flow)) / m_ref ** 2
620 except Exception
as exc:
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")
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))
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
636 travel = p.get(
"travel_time")
638 travel = getattr(prep.setup,
"valve_travel_s",
None)
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,
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]
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"]))
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))
770 settings: StartSettings) -> Dict[str, Any]:
771 series = result[
"series"]
772 summary = result.get(
"summary")
or {}
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
778 raise ValueError(
"no engine config to take the injector manifold from")
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"]
787 i_pre =
max(i0 - 1, 0)
788 p_series = sub.get(
"outlet_psia")
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))
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"]
804 raise ValueError(f
"{side}: no injector drop at the first firing step")
805 phi = m_line / math.sqrt(dp_inj)
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")
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")
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)
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"
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
858 vvac =
lambda mr, m, _v=v0: _v
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")
871 m_end = run[
"mdot_end"]
872 Gam = run[
"gamma_fn"]
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"],
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)
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])
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)
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)))
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
914 t_prime = run[
"t_prime"]
916 for i, key
in enumerate((
"ox",
"fuel")):
917 am = run[
"arrival_mdot"][i]
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)
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"),
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)")
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.",
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)",
974 out: Dict[str, Any] = {
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"]],
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"],
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",
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)"],
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)