STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
thrustshape.py
Go to the documentation of this file.
1"""How flat the delivered thrust is, and where its change over the burn comes from.
2
3``build_thrust_shape(result, prep=None, config, *, sampler=None, target_N=None)`` ->
4``diagnostics.thrust_shape`` (DATA-CONTRACT 3).
5
6**Shape.** Over the delivered thrust (``delivered.thrust_N``, the replay's eroded engine at the
7twin's line-exit pressures) from the first settled step (``settle_s`` after Fire, 0.2 s: the same
8window ``replay.delivered`` takes its minimum over): the mean (``delivered.summary.mean_thrust_N``,
9the headline), the largest and the RMS deviation from it in percent, the spread, and against a
10target when there is one (the config's ``design_requirements.target_thrust``, or ``target_N``).
11
12**Breakdown**, on the replay's own points (the delivered thrust is interpolated between them),
13of the change from the first settled point k0, ``dF(t) = F_del(t) - F_del(k0)``:
14
15* ``erosion_N`` = ``[F_del - F_ab](t) - [F_del - F_ab](k0)``, where ``F_ab`` is the **as-built**
16 engine (EngineDesign at the line exit, ``engine.layerx.card.EngineSampler``: the replay's model
17 with no recession) at the same line-exit pressures. Geometry is the only difference between
18 the two, so this is exact.
19* ``tank_pressure_N`` = ``F_T(p_pad(t)) - F_T(p_pad(k0))``: the as-built engine fed through the
20 drawn lines **held at k0** -- each side's line loss as ``K mdot^2`` with ``K`` the twin's own
21 ``(outlet - inlet) / mdot^2`` at k0 -- from the tank outlet pressure on a one-g basis,
22 ``p_pad = p_ullage + head g0 / a``. The feed loop ``inlet = p_out - K mdot(inlet)^2`` is solved
23 (fixed point; its slope is the line drop over the orifice drop, ~0.1-0.2 here, and the solve
24 reports its own convergence).
25* ``accel_head_N`` (flown runs) = ``[F_T(p_out) - F_T(p_pad)](t) - [same](k0)``: the liquid
26 column's head at the flight's specific force ``a(t)`` (``flight.schedule.accel_m_s2``) against
27 the same column at one g. Zero on the pad.
28* ``residual_N`` = ``dF - (tank + erosion + accel)`` = ``[F_ab(t) - F_ab(k0)] - [F_T(p_out(t)) -
29 F_T(p_out(k0))]``: what the twin's feed did beyond a fixed-K line -- the line's K drifting with
30 Reynolds number, the network's coupling -- plus the fixed-point tolerance.
31
32The components are differences of one model, so they add exactly to ``dF`` by construction; the
33residual is the measure of how much the fixed-K feed explains.
34"""
35
36from __future__ import annotations
37
38import math
39from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
40
41import numpy as np
42
43from engine.layerx.diag.ladder import PSI, arr, inp, model_block, out, scalar, unavailable
44
45G0 = 9.80665
46SETTLE_S = 0.2
47"""Seconds after Fire before the shape is graded: ``replay.delivered`` takes its minimum over
48t >= 0.2 s for the same reason (the valve ramp and the first replay point)."""
49
50FEED_TOL = 1e-9
51FEED_ITERS = 60
52
53
54def _sampler(config: Any, ambient_pa: float) -> Callable[[float, float], Optional[Dict[str, float]]]:
55 from engine.layerx.card import EngineSampler
56
57 return EngineSampler(config, ambient_pa)
58
59
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)."""
65 mO, mF = m0
66 point = None
67 change = math.inf
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)
70 if point is None:
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))
74 mO, mF = nO, nF
75 if change < FEED_TOL:
76 return point, it, change
77 return point, FEED_ITERS, change
78
79
80def _target(config: Any, target_N: Optional[float]) -> Tuple[Optional[float], str]:
81 if target_N is not None:
82 return float(target_N), "restated for this run"
83 req = getattr(config, "design_requirements", None)
84 tgt = getattr(req, "target_thrust", None) if req is not None else None
85 if tgt:
86 return float(tgt), "config design_requirements.target_thrust"
87 return None, "no target"
88
89
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)
94 if ok.sum() < 2:
95 ok = np.isfinite(F)
96 tt, ff = t[ok], F[ok]
97 w = np.zeros(tt.size)
98 if tt.size >= 2:
99 d = np.diff(tt)
100 w[:-1] += d / 2.0
101 w[1:] += d / 2.0
102 else:
103 w[:] = 1.0
104 dev = ff - mean
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])],
112 }
113 if target:
114 within = np.abs(ff - target) <= 0.02 * target
115 above = np.flatnonzero(ff >= target)
116 res.update({
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,
120 })
121 return res
122
123
124def build_thrust_shape(result: Mapping[str, Any], prep: Any = None, config: Any = 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``."""
130 try:
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]
141 if st:
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)
145
146 # ---- breakdown on the replay points --------------------------------------------------
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))
153 if settled.size < 2:
154 raise ValueError("fewer than two settled replay points")
155 k0 = int(settled[0])
156
157 def side(key: str, field: str) -> np.ndarray:
158 return arr([(series.get(key) or {}).get(field)[i] for i in idx])
159
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")}
164 # the replay's own inlet pressures are what F_del was solved at
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")}
169
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)"
175 else:
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")}
180
181 if sampler is None:
182 if config is None:
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)
186 sampler = _sampler(config, amb)
187 m0 = (float(mdot["ox"][k0]), float(mdot["fuel"][k0]))
188 n = t_r.size
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
193 for j in range(n):
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:
198 continue
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)
203 if sched is None:
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
211
212 def mask(v: np.ndarray) -> List[Optional[float]]:
213 return out(np.where(live, v, np.nan))
214
215 end = int(settled[-1])
216 return {
217 "mean_N": scalar(mean),
218 **stats,
219 "target_N": scalar(target) if target else None,
220 "target_source": target_src,
221 "breakdown": {
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,
233 },
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)}),
247 }
248 except Exception as exc: # noqa: BLE001
249 return unavailable(f"{type(exc).__name__}: {exc}")
#define max(a, b)
Definition Arduino.h:43
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)
Callable[[float, float], Optional[Dict[str, float]]] _sampler(Any config, float ambient_pa)
Tuple[Optional[float], str] _target(Any config, Optional[float] target_N)
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)
Dict[str, Any] shape_stats(np.ndarray t, np.ndarray F, float mean, float settle_t, Optional[float] target)