STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
chamber.py
Go to the documentation of this file.
1"""The chamber: total flow in, pressure and gas state out.
2
3One relation carries most of it. At steady state the throat passes exactly what
4the injector delivers, which fixes chamber pressure:
5
6.. code-block:: text
7
8 p_c = mdot_total . c* / A_throat
9
10and that is a *feedback* loop, not a formula evaluated once. More flow raises
11chamber pressure, which lowers the injector's pressure difference, which lowers
12the flow. Every pressure-fed engine sits at the fixed point of that loop, and
13the loop is why chamber pressure moves so much less than tank pressure does
14over a burn -- and why an injector stiffness ratio below about 20% makes the
15whole thing twitchy.
16
17Where ``c*`` comes from
18-----------------------
19``c*`` is not a constant. It depends on mixture ratio, and mixture ratio moves
20during a burn because the two propellant legs have different resistances and
21respond differently to the same upstream change. Modelling ``c*`` as fixed is
22what makes O/F drift invisible -- which is a shame, because O/F drift is the
23output most worth having.
24
25So the chamber takes a :class:`CStarModel`, and the one that matters is
26:class:`CEATable`, which reads EngineDesign's own ``.npz`` CEA cache. That gives
27``c*``, thrust coefficient, **chamber temperature** and ``gamma`` on the real
28``(p_c, O/F, epsilon)`` grid for the actual propellant pair -- so a firing
29trajectory reports chamber temperature that came from CEA rather than from a
30guess, and the two tools agree because they are reading the same table.
31"""
32
33from __future__ import annotations
34
35import json
36import math
37from dataclasses import dataclass
38from pathlib import Path
39from typing import Mapping, Protocol, runtime_checkable
40
41import numpy as np
42
43
44@dataclass(frozen=True, slots=True)
46 """What the gas in the chamber is doing."""
47
48 cstar: float
49 """Characteristic velocity [m/s]."""
50
51 temperature: float = 0.0
52 """Chamber stagnation temperature [K]. Zero when the model has no table."""
53
54 gamma: float = 0.0
55 """Ratio of specific heats. Zero when unknown."""
56
57 thrust_coefficient: float = 0.0
58 """C_F at the design expansion. Zero when unknown."""
59
60 extrapolated: bool = False
61 """The operating point was outside the combustion table and got clamped to
62 its edge. **Load-bearing, not cosmetic.** A burn whose mixture ratio drifts
63 past the range CEA was tabulated over reports a c* that stopped moving,
64 which looks like a physical plateau and is a table edge. Anything reading
65 a chamber temperature or a c* must be able to ask whether it was real."""
66
67
68@runtime_checkable
69class CStarModel(Protocol):
70 """Combustion properties as a function of the operating point."""
71
72 def combustion(self, pressure: float, mixture_ratio: float) -> CombustionState: ...
73
74
75@dataclass(frozen=True, slots=True)
77 """One number, honestly labelled.
78
79 Right for a first pass and for testing the coupling itself, and wrong for
80 anything that cares about mixture-ratio drift -- with a constant ``c*`` the
81 O/F trace still moves but nothing downstream of it does, which reads as
82 "O/F drift does not matter here" when it means "this model cannot see it".
83 """
84
85 cstar: float
86 temperature: float = 0.0
87 gamma: float = 0.0
88 thrust_coefficient: float = 0.0
89
90 def combustion(self, pressure: float, mixture_ratio: float) -> CombustionState:
91 return CombustionState(
92 cstar=self.cstar,
93 temperature=self.temperature,
94 gamma=self.gamma,
95 thrust_coefficient=self.thrust_coefficient,
96 )
97
98
100 """EngineDesign's own CEA cache, read directly.
101
102 The cache is a ``(p_c, O/F, epsilon)`` grid of ``c*``, ``C_F``, ``T_c``,
103 ``gamma``, ``R`` and ``M`` for one propellant pair. Reading it rather than
104 re-running CEA has two virtues: it is fast enough to sit inside an
105 integrator's right-hand side, and it guarantees that feed-twin and
106 EngineDesign are quoting the *same* combustion, so a disagreement between
107 them is a real disagreement rather than two CEA runs with different options.
108
109 Args:
110 path: A ``cea_cache_*.npz`` written by EngineDesign.
111 expansion_ratio: Where on the epsilon axis to sit. Defaults to the
112 table's own design value from its metadata.
113
114 Raises:
115 ValueError: the point is outside the table. Refused rather than
116 extrapolated -- the same discipline the property backends keep, and
117 for the same reason: a CEA table extrapolated past its mixture-ratio
118 range returns a number, and the number is fiction.
119 """
120
121 def __init__(self, path: str | Path, *, expansion_ratio: float | None = None):
122 from scipy.interpolate import RegularGridInterpolator
123
124 self.path = Path(path)
125 if not self.path.exists():
126 raise FileNotFoundError(f"no CEA cache at {self.path}")
127 data = np.load(self.path, allow_pickle=True)
128
129 self.pressures = np.asarray(data["Pc"], dtype=float)
130 self.mixture_ratios = np.asarray(data["MR"], dtype=float)
131 self.expansions = np.asarray(data["eps"], dtype=float)
132
133 meta_raw = data["meta"].item() if "meta" in data else "{}"
134 self.meta: Mapping[str, object] = (
135 json.loads(meta_raw) if isinstance(meta_raw, str) else dict(meta_raw)
136 )
137 raw_eps = self.meta.get("expansion_ratio", float(self.expansions[0]))
138 default_eps = float(raw_eps) # type: ignore[arg-type,unused-ignore]
140 default_eps if expansion_ratio is None else float(expansion_ratio)
141 )
142 lo, hi = float(self.expansions[0]), float(self.expansions[-1])
143 self.expansion_ratio = min(max(self.expansion_ratio, lo), hi)
144
145 grid = (self.pressures, self.mixture_ratios, self.expansions)
146 self._fields = {
147 name: RegularGridInterpolator(
148 grid, np.asarray(data[name], dtype=float), bounds_error=False
149 )
150 for name in ("cstar", "Cf", "Tc", "gamma")
151 if name in data
152 }
153
154 @property
155 def propellants(self) -> tuple[str, str]:
156 return (
157 str(self.meta.get("ox_name", "?")),
158 str(self.meta.get("fuel_name", "?")),
159 )
160
161 def _clamped(self, pressure: float, mixture_ratio: float) -> tuple[float, float]:
162 p = min(max(pressure, float(self.pressures[0])), float(self.pressures[-1]))
163 mr = min(
164 max(mixture_ratio, float(self.mixture_ratios[0])),
165 float(self.mixture_ratios[-1]),
166 )
167 return p, mr
168
169 def contains(self, pressure: float, mixture_ratio: float) -> bool:
170 """Whether this point is inside the table, before clamping."""
171 return bool(
172 float(self.pressures[0]) <= pressure <= float(self.pressures[-1])
173 and float(self.mixture_ratios[0])
174 <= mixture_ratio
175 <= float(self.mixture_ratios[-1])
176 )
177
178 def combustion(self, pressure: float, mixture_ratio: float) -> CombustionState:
179 """Interpolated combustion state.
180
181 Clamped to the table's edges rather than extrapolated. A transient
182 passes through startup, where chamber pressure begins near zero and
183 mixture ratio is briefly whatever the first propellant to arrive makes
184 it; refusing there would make every ignition unrunnable. Clamping is
185 the honest compromise, and :meth:`contains` is how a caller asks whether
186 the point it cares about was inside.
187 """
188 inside = self.contains(pressure, mixture_ratio)
189 p, mr = self._clamped(pressure, mixture_ratio)
190 point = np.array([[p, mr, self.expansion_ratio]])
191 values = {
192 name: float(interp(point)[0]) for name, interp in self._fields.items()
193 }
194 return CombustionState(
195 cstar=values.get("cstar", 0.0),
196 temperature=values.get("Tc", 0.0),
197 gamma=values.get("gamma", 0.0),
198 thrust_coefficient=values.get("Cf", 0.0),
199 extrapolated=not inside,
200 )
201
202 def __repr__(self) -> str:
203 ox, fuel = self.propellants
204 return (
205 f"CEATable({ox}/{fuel}, {len(self.pressures)}x"
206 f"{len(self.mixture_ratios)}x{len(self.expansions)}, "
207 f"eps={self.expansion_ratio:.2f})"
208 )
209
210
211@dataclass(frozen=True, slots=True)
213 """The engine's answer at one operating point."""
214
215 pressure: float
216 """Chamber stagnation pressure [Pa]."""
217
218 mdot_total: float
219 mdot_oxidiser: float
220 mdot_fuel: float
221 mixture_ratio: float
222 combustion: CombustionState
223 thrust: float
224 """[N]. Zero when the model has no thrust coefficient, or when the chamber
225 is at ambient and there is nothing to call thrust."""
226
227 specific_impulse: float
228 """[s]. Zero when thrust is unknown."""
229
230
231#: Standard gravity, for turning thrust into specific impulse [m/s^2].
232GRAVITY = 9.80665
233
234MIN_CHAMBER_FLOW = 1.0e-3
235"""Propellant arriving at the chamber that counts as "lit" [kg/s], per side.
236
237Well below a seat leak and far below any real injector flow, so it separates a
238shut stand from a burning one without a tuning question. Both propellants must
239arrive for anything to burn: one alone is unburned liquid passing through the
240throat, not a mixture at the c* table's leanest ratio."""
241
242
244 mdot_oxidiser: float,
245 mdot_fuel: float,
246 mixture_ratios: tuple[float, float] | None,
247) -> float:
248 """The flow that burns [kg/s]: all of it inside the table's O/F range;
249 beyond it, the scarce propellant and as much of the other as burns with
250 it at the table's edge.
251
252 The rest passes through the throat unburned, as :func:`unlit` already says
253 a lone propellant does. Clamping the O/F instead kept the edge's hot c*
254 for the whole flow: an Engine Abort that vented the fuel tank ran LOX-rich
255 at O/F 20-70 with the c* of O/F ~4, held the chamber at the fuel tank's
256 own pressure, and pushed the fuel line backwards on every other step. This
257 is continuous at the table's edge and goes to the unlit chamber as one
258 side goes to zero. ``mixture_ratios`` None: no range, all of it burns.
259 """
260 if mixture_ratios is None or mdot_fuel <= 0.0:
261 return mdot_oxidiser + mdot_fuel
262 low, high = mixture_ratios
263 ratio = mdot_oxidiser / mdot_fuel
264 if ratio > high > 0.0:
265 return mdot_fuel * (1.0 + high)
266 if 0.0 < ratio < low:
267 return mdot_oxidiser * (1.0 + 1.0 / low)
268 return mdot_oxidiser + mdot_fuel
269
270
272 ambient_pressure: float, mdot_oxidiser: float, mdot_fuel: float
273) -> "ChamberResult":
274 """The chamber with nothing burning in it: at ambient, no thrust.
275
276 A single propellant (a fuel lead, the step a tank runs dry) flows out
277 through the throat as liquid; its momentum thrust is a few newtons and its
278 pressure rise a psi or two, so it is reported as the unlit chamber it is.
279 Before this, a one-sided flow was evaluated at mixture ratio zero, which the
280 c* table clamps to its leanest point: fuel alone "burned" at 3.5 kN.
281 """
282 total = mdot_oxidiser + mdot_fuel
283 return ChamberResult(
284 pressure=ambient_pressure,
285 mdot_total=total,
286 mdot_oxidiser=mdot_oxidiser,
287 mdot_fuel=mdot_fuel,
288 # Zero, not infinity, with no fuel: the convention evaluate() keeps,
289 # and a number every reader of the result can serialise.
290 mixture_ratio=mdot_oxidiser / mdot_fuel if mdot_fuel > 1e-12 else 0.0,
291 combustion=CombustionState(cstar=0.0),
292 thrust=0.0,
293 specific_impulse=0.0,
294 )
295
296
298 """Chamber pressure from the flows the injector delivers.
299
300 Args:
301 throat_area: ``A_t`` [m^2].
302 cstar_model: Where combustion properties come from.
303 volume: Chamber volume [m^3]. Not integrated -- see
304 :meth:`fill_time`, which reports how long the chamber takes to
305 respond so the quasi-steady assumption is visible rather than
306 assumed.
307 nozzle_efficiency: ``eta_n``. Multiplies the thrust coefficient, so it
308 moves thrust and Isp and leaves chamber pressure alone.
309 efficiency: ``c*`` efficiency. Multiplies the table value, and is the
310 one place a real engine's losses enter this model.
311 ambient_pressure: What the chamber sits at with nothing flowing [Pa].
312 **Not a numerical guard.** A chamber is open to atmosphere through
313 its own nozzle, so ``mdot c* / A_t`` is only the chamber pressure
314 once that exceeds ambient; below it the nozzle is not choked and the
315 chamber is simply at ambient. Leaving it out lets a shut engine
316 report a near-vacuum chamber, which then asks the feed system to
317 solve a 650 psi drop across a shut valve into 0.5 psi -- a state no
318 hardware ever occupies and no solver enjoys being asked about.
319 Sea level by default; set it for altitude or a vacuum stand.
320 """
321
323 self,
324 throat_area: float,
325 cstar_model: CStarModel,
326 *,
327 volume: float = 0.0,
328 efficiency: float = 1.0,
329 nozzle_efficiency: float = 1.0,
330 ambient_pressure: float = 101325.0,
331 ) -> None:
332 if throat_area <= 0.0:
333 raise ValueError(f"throat area must be positive, got {throat_area}")
334 self.throat_area = throat_area
335 self.cstar_model = cstar_model
336 self.volume = volume
337 self.efficiency = efficiency
338 self.nozzle_efficiency = nozzle_efficiency
339 self.ambient_pressure = ambient_pressure
340
341 def evaluate(self, mdot_oxidiser: float, mdot_fuel: float) -> ChamberResult:
342 """Chamber state for a pair of propellant flows.
343
344 Chamber pressure is ``mdot c* / A_t``, but ``c*`` depends on mixture
345 ratio and (weakly) on pressure, so this iterates the pressure to
346 consistency. Three passes is ample -- the dependence on pressure is
347 gentle -- and it converges from any starting point because raising
348 pressure barely moves ``c*``.
349 """
350 total = mdot_oxidiser + mdot_fuel
351 mixture_ratio = mdot_oxidiser / mdot_fuel if mdot_fuel > 1e-12 else 0.0
352 if total <= 0.0:
353 return ChamberResult(
354 pressure=self.ambient_pressure,
355 mdot_total=0.0,
356 mdot_oxidiser=mdot_oxidiser,
357 mdot_fuel=mdot_fuel,
358 mixture_ratio=mixture_ratio,
359 combustion=CombustionState(cstar=0.0),
360 thrust=0.0,
361 specific_impulse=0.0,
362 )
363
364 if min(mdot_oxidiser, mdot_fuel) <= MIN_CHAMBER_FLOW:
365 return unlit(self.ambient_pressure, mdot_oxidiser, mdot_fuel)
366
367 table = self.cstar_model
368 ratios = (
369 (float(table.mixture_ratios[0]), float(table.mixture_ratios[-1]))
370 if isinstance(table, CEATable)
371 else None
372 )
373 burned = burning(mdot_oxidiser, mdot_fuel, ratios)
374 pressure = max(self.ambient_pressure, 1.0e5)
375 state = CombustionState(cstar=0.0)
376 for _ in range(3):
377 state = self.cstar_model.combustion(pressure, mixture_ratio)
378 pressure = max(
379 burned * state.cstar * self.efficiency / self.throat_area,
380 self.ambient_pressure,
381 )
382
383 # eta_n multiplies the thrust coefficient, not the chamber pressure: a
384 # lossy nozzle makes less thrust from the same chamber, it does not
385 # make a different chamber.
386 thrust = (
387 state.thrust_coefficient
388 * self.nozzle_efficiency
389 * pressure
390 * self.throat_area
391 if state.thrust_coefficient > 0.0
392 else 0.0
393 )
394 firing = pressure > self.ambient_pressure * 1.001
395 return ChamberResult(
396 pressure=pressure,
397 mdot_total=total,
398 mdot_oxidiser=mdot_oxidiser,
399 mdot_fuel=mdot_fuel,
400 mixture_ratio=mixture_ratio,
401 combustion=state,
402 thrust=thrust if firing else 0.0,
403 specific_impulse=(
404 thrust / (total * GRAVITY) if firing and thrust > 0.0 else 0.0
405 ),
406 )
407
408 def is_firing(self, result: ChamberResult) -> bool:
409 """Whether the throat is actually choked, or the engine is just open.
410
411 Below ambient-driven flow there is no combustion chamber to speak of;
412 reporting a thrust and an Isp there would be arithmetic, not physics.
413 """
414 return result.pressure > self.ambient_pressure * 1.001
415
416 def fill_time(self, pressure: float, mixture_ratio: float) -> float:
417 """Chamber residence time [s]: how fast chamber pressure can respond.
418
419 ``tau = V / (A_t c*)``, of order a millisecond on a small engine. It is
420 reported rather than integrated because it is three orders of magnitude
421 below anything a feed system does, and carrying it as a state would make
422 the whole system stiff for no answer anyone wants. When it stops being
423 small -- a very large chamber, or a startup transient resolved at the
424 millisecond level -- this is the number that says so.
425 """
426 if self.volume <= 0.0:
427 return 0.0
428 state = self.cstar_model.combustion(pressure, mixture_ratio)
429 if state.cstar <= 0.0:
430 return 0.0
431 return self.volume / (self.throat_area * state.cstar)
432
433 def __repr__(self) -> str:
434 return (
435 f"Chamber(A_t={self.throat_area * 1e6:.1f} mm^2, " f"{self.cstar_model!r})"
436 )
437
438
440 design_name: str, search_paths: tuple[str, ...] = ("output/cache",)
441) -> Path | None:
442 """Find a CEA cache matching a propellant pair, if one is lying around.
443
444 A convenience, not a contract: it returns ``None`` rather than guessing, so
445 a missing cache surfaces as "no table, using a constant" rather than as a
446 silently wrong propellant pair.
447 """
448 for directory in search_paths:
449 base = Path(directory)
450 if not base.is_dir():
451 continue
452 for candidate in sorted(base.glob("cea_cache_*.npz")):
453 if design_name.lower() in candidate.name.lower():
454 return candidate
455 return None
456
457
458def mixture_ratio_of(mdot_oxidiser: float, mdot_fuel: float) -> float:
459 """O/F by mass, with the zero-fuel case answered rather than raised."""
460 return mdot_oxidiser / mdot_fuel if abs(mdot_fuel) > 1e-12 else math.inf
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
__init__(self, str|Path path, *, float|None expansion_ratio=None)
Definition chamber.py:121
CombustionState combustion(self, float pressure, float mixture_ratio)
Definition chamber.py:178
tuple[str, str] propellants(self)
Definition chamber.py:155
bool contains(self, float pressure, float mixture_ratio)
Definition chamber.py:169
tuple[float, float] _clamped(self, float pressure, float mixture_ratio)
Definition chamber.py:161
CombustionState combustion(self, float pressure, float mixture_ratio)
Definition chamber.py:72
bool is_firing(self, ChamberResult result)
Definition chamber.py:408
ChamberResult evaluate(self, float mdot_oxidiser, float mdot_fuel)
Definition chamber.py:341
None __init__(self, float throat_area, CStarModel cstar_model, *, float volume=0.0, float efficiency=1.0, float nozzle_efficiency=1.0, float ambient_pressure=101325.0)
Definition chamber.py:331
float fill_time(self, float pressure, float mixture_ratio)
Definition chamber.py:416
CombustionState combustion(self, float pressure, float mixture_ratio)
Definition chamber.py:90
float burning(float mdot_oxidiser, float mdot_fuel, tuple[float, float]|None mixture_ratios)
Definition chamber.py:247
float mixture_ratio_of(float mdot_oxidiser, float mdot_fuel)
Definition chamber.py:458
Path|None cea_cache_for(str design_name, tuple[str,...] search_paths=("output/cache",))
Definition chamber.py:441
"ChamberResult" unlit(float ambient_pressure, float mdot_oxidiser, float mdot_fuel)
Definition chamber.py:273