STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
report.py
Go to the documentation of this file.
1"""What the engine did over a burn, from the steps that burned.
2
3One summary for every caller that fires an engine through a feed system: the
4cockpit finds its burns in the session's history; a burn recorded as columns
5(:class:`~feedtwin.session.burn.BurnTrace`) goes through :func:`summarise`.
6Layer X keeps its own summary, and its impulse, mean thrust, O/F and Isp are
7held to this arithmetic by ``EngineDesign/tests/test_layerx_cockpit_parity.py``,
8so a disagreement between the tools is physics, not bookkeeping.
9
10The rules
11---------
12* **A step burns** when both propellants reach the chamber, each above
13 :data:`~feedtwin.session.core.MIN_CHAMBER_FLOW`. A burn is a run of burning
14 steps; one quiet step ends it. One propellant alone is not a burn: when the
15 LOX runs out first the fuel keeps flowing for a step or two, and the
16 chamber model, clamped at the edge of its combustion table, still reports
17 thrust for it (~2 kN on LE4). Nothing is burning, and none of it is counted.
18* **Totals are right-endpoint sums**: each burning sample carries the step that
19 ended at it, ``F_k (t_k - t_{k-1})``, where ``t_{k-1}`` is the sample before
20 it even when that one did not burn. That is how :func:`feedtwin.session.burn.burn`
21 steps, so the first step after Fire counts once, at its own thrust, and a
22 burn read from the cockpit's history totals exactly what Layer X totals from
23 the same steps.
24* **Mean O/F is the mass ratio** of what was burned (ox kg over fuel kg), not
25 the mean of the instantaneous ratios. **Isp is delivered**: impulse over the
26 weight of the propellant burned.
27* **Full flow** is the part of the burn whose total mass flow is at least
28 :data:`FULL_FLOW_FRACTION` of the burn's median. The start (valves still
29 travelling) and the tail-off are outside it. The minima a design rule is
30 judged on -- thrust, chamber pressure, injector stiffness -- are read there,
31 because a valve half open has no stiffness to speak of and that is not a
32 finding about the injector.
33"""
34
35from __future__ import annotations
36
37import math
38from dataclasses import dataclass
39from typing import Mapping, Sequence
40
41from feedtwin.engine.chamber import GRAVITY
42from feedtwin.session.core import MIN_CHAMBER_FLOW, Sample
43
44#: Fraction of the burn's median mass flow above which a step is at full flow.
45FULL_FLOW_FRACTION = 0.8
46
47
48@dataclass(frozen=True, slots=True)
50 """One burn, totalled. Absolute SI throughout."""
51
52 start_s: float
53 """Clock at the start of the first burning step [s]."""
54 end_s: float
55 """Clock at the last burning sample [s]."""
56 burning: bool
57 """The last sample given still burned: the burn is not over."""
58 steps: int
59 impulse_Ns: float
60 thrust_mean_N: float
61 """Impulse over duration."""
62 thrust_peak_N: float
63 thrust_min_N: float
64 """Lowest thrust at full flow."""
65 pc_mean_Pa: float
66 """Time-weighted mean chamber pressure."""
67 pc_min_Pa: float
68 """Lowest chamber pressure at full flow."""
69 pc_max_Pa: float
70 of_mean: float
71 """Oxidiser burned over fuel burned."""
72 of_min: float
73 """Over full flow."""
74 of_max: float
75 isp_s: float
76 """Delivered: impulse over the weight of propellant burned."""
77 cstar_mps: float
78 """Mass-weighted mean of the chamber's ``c*``."""
79 oxidiser_kg: float
80 fuel_kg: float
81 stiffness_oxidiser_min: float
82 """Lowest ``dp_injector / p_c`` on the oxidiser side at full flow, the
83 drop measured from the injector inlet; 0 when :func:`burns` was given no
84 inlet and the steps carried no mixture balance."""
85 stiffness_fuel_min: float
86 extrapolated_steps: int
87 """Steps whose combustion point was clamped at the edge of its table."""
88
89 @property
90 def duration_s(self) -> float:
91 return self.end_s - self.start_s
92
93 @property
94 def propellant_kg(self) -> float:
95 return self.oxidiser_kg + self.fuel_kg
96
97
99 t: Sequence[float],
100 thrust: Sequence[float],
101 pressure: Sequence[float],
102 mdot_oxidiser: Sequence[float],
103 mdot_fuel: Sequence[float],
104 *,
105 before: float,
106 cstar: Sequence[float] | None = None,
107 stiffness_oxidiser: Sequence[float] | None = None,
108 stiffness_fuel: Sequence[float] | None = None,
109 extrapolated: Sequence[bool] | None = None,
110 burning: bool = False,
111) -> BurnReport:
112 """Total one burn given as columns over its burning samples.
113
114 ``before`` is the clock of the sample preceding the first one: where the
115 first burning step began.
116 """
117 n = len(t)
118 if n == 0:
119 raise ValueError("a burn needs at least one burning sample")
120 dt = [t[0] - before] + [t[k] - t[k - 1] for k in range(1, n)]
121 duration = t[-1] - before
122 impulse = sum(f * d for f, d in zip(thrust, dt))
123 ox = sum(m * d for m, d in zip(mdot_oxidiser, dt))
124 fu = sum(m * d for m, d in zip(mdot_fuel, dt))
125 total = [o + f for o, f in zip(mdot_oxidiser, mdot_fuel)]
126 burned = ox + fu
127
128 median = sorted(total)[n // 2]
129 full = [k for k in range(n) if total[k] >= FULL_FLOW_FRACTION * median] or list(
130 range(n)
131 )
132
133 def lowest(values: Sequence[float] | None) -> float:
134 if values is None:
135 return 0.0
136 return min(values[k] for k in full)
137
138 ratios = [mdot_oxidiser[k] / mdot_fuel[k] for k in full if mdot_fuel[k] > 0.0]
139 return BurnReport(
140 start_s=before,
141 end_s=t[-1],
142 burning=burning,
143 steps=n,
144 impulse_Ns=impulse,
145 thrust_mean_N=impulse / duration if duration > 0.0 else 0.0,
146 thrust_peak_N=max(thrust),
147 thrust_min_N=lowest(thrust),
148 pc_mean_Pa=(
149 sum(p * d for p, d in zip(pressure, dt)) / duration
150 if duration > 0.0
151 else pressure[0]
152 ),
153 pc_min_Pa=lowest(pressure),
154 pc_max_Pa=max(pressure),
155 of_mean=ox / fu if fu > 0.0 else 0.0,
156 of_min=min(ratios) if ratios else 0.0,
157 of_max=max(ratios) if ratios else 0.0,
158 isp_s=impulse / (GRAVITY * burned) if burned > 0.0 else 0.0,
159 cstar_mps=(
160 sum(c * m * d for c, m, d in zip(cstar, total, dt)) / burned
161 if cstar is not None and burned > 0.0
162 else 0.0
163 ),
164 oxidiser_kg=ox,
165 fuel_kg=fu,
166 stiffness_oxidiser_min=lowest(stiffness_oxidiser),
167 stiffness_fuel_min=lowest(stiffness_fuel),
168 extrapolated_steps=sum(1 for e in extrapolated or () if e),
169 )
170
171
172def is_burning(sample: Sample) -> bool:
173 chamber = sample.chamber
174 return (
175 chamber is not None
176 and chamber.mdot_oxidiser > MIN_CHAMBER_FLOW
177 and chamber.mdot_fuel > MIN_CHAMBER_FLOW
178 )
179
180
182 samples: Sequence[Sample], inlets: Mapping[str, str] | None = None
183) -> list[BurnReport]:
184 """Every burn in ``samples``, oldest first.
185
186 ``inlets`` maps ``oxidiser``/``fuel`` to the injector inlet node
187 (:attr:`feedtwin.session.burn.Probes.injector_inlet`). With it, stiffness
188 is the drop from that node to the chamber over chamber pressure -- the
189 line exit to the chamber, which is the whole of what an engine card owns.
190 Without it, a sample's mixture balance is used when it carries one.
191
192 A burn whose first step is the first sample given has no sample before it
193 to start from; it is timed from that sample's own clock less the next
194 step, which is right when the steps are even and is said by
195 :attr:`BurnReport.start_s` either way.
196 """
197 out: list[BurnReport] = []
198 k = 0
199 n = len(samples)
200 while k < n:
201 if not is_burning(samples[k]):
202 k += 1
203 continue
204 first = k
205 while k < n and is_burning(samples[k]):
206 k += 1
207 run = samples[first:k]
208 if first > 0:
209 before = samples[first - 1].t
210 elif len(run) > 1:
211 before = run[0].t - (run[1].t - run[0].t)
212 else:
213 before = run[0].t
214 out.append(_summarise_samples(run, before, inlets or {}, burning=k == n))
215 return out
216
217
219 run: Sequence[Sample],
220 before: float,
221 inlets: Mapping[str, str],
222 *,
223 burning: bool,
224) -> BurnReport:
225 chambers = [s.chamber for s in run]
226 assert all(c is not None for c in chambers)
227
228 def stiffness(side: str) -> list[float] | None:
229 node = inlets.get(side)
230 if node and all(node in s.pressures for s in run):
231 return [
232 (s.pressures[node] - c.pressure) / c.pressure # type: ignore[union-attr]
233 for s, c in zip(run, chambers)
234 ]
235 out: list[float] = []
236 for s in run:
237 balance = s.balance
238 if balance is None:
239 return None
240 leg = getattr(balance, side)
241 out.append(leg.stiffness(balance.chamber_pressure))
242 return out
243
244 return summarise(
245 [s.t for s in run],
246 [c.thrust for c in chambers], # type: ignore[union-attr]
247 [c.pressure for c in chambers], # type: ignore[union-attr]
248 [c.mdot_oxidiser for c in chambers], # type: ignore[union-attr]
249 [c.mdot_fuel for c in chambers], # type: ignore[union-attr]
250 before=before,
251 cstar=[c.combustion.cstar for c in chambers], # type: ignore[union-attr]
252 stiffness_oxidiser=stiffness("oxidiser"),
253 stiffness_fuel=stiffness("fuel"),
254 extrapolated=[bool(c.combustion.extrapolated) for c in chambers], # type: ignore[union-attr]
255 burning=burning,
256 )
257
258
259def finite(value: float) -> float:
260 """``value``, or 0 where it is not a number (a wire format has no NaN)."""
261 return value if math.isfinite(value) else 0.0
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
BurnReport _summarise_samples(Sequence[Sample] run, float before, Mapping[str, str] inlets, *, bool burning)
Definition report.py:224
BurnReport summarise(Sequence[float] t, Sequence[float] thrust, Sequence[float] pressure, Sequence[float] mdot_oxidiser, Sequence[float] mdot_fuel, *, float before, Sequence[float]|None cstar=None, Sequence[float]|None stiffness_oxidiser=None, Sequence[float]|None stiffness_fuel=None, Sequence[bool]|None extrapolated=None, bool burning=False)
Definition report.py:111
bool is_burning(Sample sample)
Definition report.py:172
list[BurnReport] burns(Sequence[Sample] samples, Mapping[str, str]|None inlets=None)
Definition report.py:183
float finite(float value)
Definition report.py:259