39from typing
import Any, Callable, Dict, List, Mapping, Optional, Tuple
60def feed_solve(sampler: Callable[[float, float], Optional[Dict[str, float]]], p_out_O: float, p_out_F: float,
61 K_O: float, K_F: float, m0: Tuple[float, float]) -> Tuple[Optional[Dict[str, float]], int, float]:
62 """The as-built engine fed through fixed-K lines from tank-outlet pressures [Pa]:
63 ``inlet = p_out - K mdot^2`` (K in Pa/(kg/s)^2), solved by fixed point on the two flows.
64 Returns (engine point, iterations, last relative change)."""
68 for it
in range(1, FEED_ITERS + 1):
69 point = sampler(p_out_O - K_O * mO * mO, p_out_F - K_F * mF * mF)
71 return None, it, change
72 nO, nF = float(point[
"mdot_O"]), float(point[
"mdot_F"])
73 change =
max(abs(nO - mO) /
max(nO, 1e-12), abs(nF - mF) /
max(nF, 1e-12))
76 return point, it, change
77 return point, FEED_ITERS, change
90def shape_stats(t: np.ndarray, F: np.ndarray, mean: float, settle_t: float,
91 target: Optional[float]) -> Dict[str, Any]:
92 """Deviation statistics of ``F`` about ``mean`` over ``t >= settle_t`` (trapezoid weights)."""
93 ok = np.isfinite(F) & (t >= settle_t - 1e-9)
105 res: Dict[str, Any] = {
106 "dev_max_pct": scalar(np.max(np.abs(dev)) / mean * 100.0),
107 "dev_rms_pct": scalar(math.sqrt(float(np.sum(w * dev * dev) / np.sum(w))) / mean * 100.0),
108 "spread_pct": scalar((np.max(ff) - np.min(ff)) / mean * 100.0),
109 "min_N": scalar(np.min(ff)),
"max_N": scalar(np.max(ff)),
110 "end_minus_start_N": scalar(ff[-1] - ff[0]),
111 "window": [scalar(tt[0]), scalar(tt[-1])],
114 within = np.abs(ff - target) <= 0.02 * target
115 above = np.flatnonzero(ff >= target)
117 "mean_minus_target_N": scalar(mean - target),
118 "within_2pct_steps": int(within.sum()),
"steps": int(ff.size),
119 "t_first_at_target": scalar(tt[above[0]])
if above.size
else None,
125 sampler: Optional[Callable[[float, float], Optional[Dict[str, float]]]] =
None,
126 target_N: Optional[float] =
None, settle_s: float = SETTLE_S,
127 ambient_pa: Optional[float] =
None) -> Dict[str, Any]:
128 """``diagnostics.thrust_shape``. Never raises. ``sampler`` ((p_line_O, p_line_F) [Pa] ->
129 {F, mdot_O, mdot_F, ...}) defaults to EngineDesign at the line exit on ``config``."""
131 dl = result.get(
"delivered")
or {}
132 rep = result.get(
"replay")
or {}
133 series = result.get(
"series")
or {}
134 if not dl.get(
"thrust_N")
or not rep.get(
"thrust_N"):
135 return unavailable(
"no delivered thrust (the replay did not run)")
136 t_d = arr(dl.get(
"t"))
137 F_d = arr(dl.get(
"thrust_N"))
138 mean = float((dl.get(
"summary")
or {}).get(
"mean_thrust_N")
or np.nanmean(F_d))
139 fire_t = float(t_d[0])
if t_d.size
else 0.0
140 st = [k
for k, f
in enumerate(series.get(
"firing")
or [])
if f]
142 fire_t = float(series[
"t"][st[0]]) - float((series.get(
"dt")
or [0.0] * len(series[
"t"]))[st[0]]
or 0.0)
143 target, target_src =
_target(config, target_N)
144 stats =
shape_stats(t_d, F_d, mean, fire_t + settle_s, target)
147 t_r = arr(rep.get(
"t"))
148 F_del = arr(rep.get(
"thrust_N"))
149 idx = [int(i)
for i
in rep.get(
"index")
or []]
150 if len(idx) != t_r.size:
151 raise ValueError(
"replay.index does not follow replay.t")
152 settled = np.flatnonzero(np.isfinite(F_del) & (t_r >= fire_t + settle_s - 1e-9))
154 raise ValueError(
"fewer than two settled replay points")
157 def side(key: str, field: str) -> np.ndarray:
158 return arr([(series.get(key)
or {}).get(field)[i]
for i
in idx])
160 tank = {s: side(s,
"tank_psia") * PSI
for s
in (
"ox",
"fuel")}
161 outlet = {s: side(s,
"outlet_psia") * PSI
for s
in (
"ox",
"fuel")}
162 inlet = {s: side(s,
"inlet_psia") * PSI
for s
in (
"ox",
"fuel")}
163 mdot = {s: side(s,
"mdot")
for s
in (
"ox",
"fuel")}
165 if rep.get(
"inlet_O_psia")
is not None:
166 inlet[
"ox"] = arr(rep[
"inlet_O_psia"]) * PSI
167 inlet[
"fuel"] = arr(rep[
"inlet_F_psia"]) * PSI
168 K = {s: (outlet[s][k0] - inlet[s][k0]) / mdot[s][k0] ** 2
for s
in (
"ox",
"fuel")}
170 flight = result.get(
"flight")
or {}
171 sched = flight.get(
"schedule")
if flight.get(
"ok")
else None
172 if sched
and sched.get(
"t"):
173 a = np.interp(t_r, arr(sched[
"t"]), arr(sched[
"accel_m_s2"]))
174 a_basis =
"flight.schedule.accel_m_s2 (the flown pass's specific force)"
176 a = np.full(t_r.size, G0)
177 a_basis =
"pad: one g"
178 head = {s: outlet[s] - tank[s]
for s
in (
"ox",
"fuel")}
179 p_pad = {s: tank[s] + head[s] * G0 / a
for s
in (
"ox",
"fuel")}
183 return unavailable(
"the engine config is needed for the as-built engine")
184 amb = ambient_pa
or float(getattr(prep,
"ambient_pa", 0.0)
or 0.0)
or float(
185 ((result.get(
"provenance")
or {}).get(
"derived")
or {}).get(
"ambient_pa")
or 101325.0)
187 m0 = (float(mdot[
"ox"][k0]), float(mdot[
"fuel"][k0]))
189 F_ab = np.full(n, np.nan)
190 F_T_out = np.full(n, np.nan)
191 F_T_pad = np.full(n, np.nan)
192 worst_change, iters = 0.0, 0
194 pt = sampler(float(inlet[
"ox"][j]), float(inlet[
"fuel"][j]))
195 F_ab[j] = float(pt[
"F"])
if pt
else math.nan
196 for target_arr, p
in ((F_T_out, outlet), (F_T_pad, p_pad)):
197 if target_arr
is F_T_pad
and sched
is None:
199 sol, it, ch =
feed_solve(sampler, float(p[
"ox"][j]), float(p[
"fuel"][j]), K[
"ox"], K[
"fuel"], m0)
200 target_arr[j] = float(sol[
"F"])
if sol
else math.nan
201 worst_change =
max(worst_change, ch
if math.isfinite(ch)
else math.inf)
202 iters =
max(iters, it)
204 F_T_pad = F_T_out.copy()
205 dF = F_del - F_del[k0]
206 erosion = (F_del - F_ab) - (F_del[k0] - F_ab[k0])
207 tank_N = F_T_pad - F_T_pad[k0]
208 accel = (F_T_out - F_T_pad) - (F_T_out[k0] - F_T_pad[k0])
209 residual = dF - (tank_N + erosion + accel)
210 live = np.arange(n) >= k0
212 def mask(v: np.ndarray) -> List[Optional[float]]:
213 return out(np.where(live, v, np.nan))
215 end = int(settled[-1])
217 "mean_N": scalar(mean),
219 "target_N": scalar(target)
if target
else None,
220 "target_source": target_src,
222 "t": out(t_r),
"k0": k0,
"t0": scalar(t_r[k0]),
223 "dF_N": mask(dF),
"tank_pressure_N": mask(tank_N),
"erosion_N": mask(erosion),
224 "accel_head_N": mask(accel),
"residual_N": mask(residual),
225 "as_built_N": mask(F_ab),
"fixed_feed_N": mask(F_T_out),
226 "at_end": {
"t": scalar(t_r[end]),
"dF_N": scalar(dF[end]),
"tank_pressure_N": scalar(tank_N[end]),
227 "erosion_N": scalar(erosion[end]),
"accel_head_N": scalar(accel[end]),
228 "residual_N": scalar(residual[end])},
229 "feed_K_psi_per_kg2s2": {s: scalar(K[s] / PSI)
for s
in (
"ox",
"fuel")},
230 "feed_solve": {
"max_iterations": iters,
"worst_relative_change": scalar(worst_change),
231 "converged": bool(worst_change < 1e-6)},
232 "accel_basis": a_basis,
234 "model": model_block(
235 "thrust-flatness decomposition (as-built engine at the line exit; fixed-K drawn feed)",
236 "engine.layerx.card.EngineSampler (EngineDesign at the line exit, the replay's model without "
237 "recession); the twin's line losses as K mdot^2 at the first settled replay point",
238 [
"erosion = eroded minus as-built engine at the twin's line-exit pressures (exact attribution)",
239 "tank pressure = as-built engine through a fixed-K feed from the tank-outlet pressure on a one-g "
240 "basis (ullage + head g0/a)",
241 "acceleration head = the same feed at the flown head against one g (zero on the pad)",
242 "residual = the twin's feed beyond a fixed K (Reynolds-dependent friction, coupling)",
243 "replay points only: the delivered series is interpolated between them",
244 f
"shape over t >= {settle_s:g} s after Fire, about the delivered mean"],
245 {
"settle_s": inp(settle_s,
"s",
"replay.delivered's settled window"),
246 "target_N": inp(target,
"N", target_src)}),
248 except Exception
as exc:
249 return unavailable(f
"{type(exc).__name__}: {exc}")
Dict[str, Any] build_thrust_shape(Mapping[str, Any] result, Any prep=None, Any config=None, *, Optional[Callable[[float, float], Optional[Dict[str, float]]]] sampler=None, Optional[float] target_N=None, float settle_s=SETTLE_S, Optional[float] ambient_pa=None)
Tuple[Optional[Dict[str, float]], int, float] feed_solve(Callable[[float, float], Optional[Dict[str, float]]] sampler, float p_out_O, float p_out_F, float K_O, float K_F, Tuple[float, float] m0)