1"""The chamber: total flow in, pressure and gas state out.
3One relation carries most of it. At steady state the throat passes exactly what
4the injector delivers, which fixes chamber pressure:
8 p_c = mdot_total . c* / A_throat
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
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.
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.
33from __future__
import annotations
37from dataclasses
import dataclass
38from pathlib
import Path
39from typing
import Mapping, Protocol, runtime_checkable
44@dataclass(frozen=True, slots=True)
46 """What the gas in the chamber is doing."""
49 """Characteristic velocity [m/s]."""
51 temperature: float = 0.0
52 """Chamber stagnation temperature [K]. Zero when the model has no table."""
55 """Ratio of specific heats. Zero when unknown."""
57 thrust_coefficient: float = 0.0
58 """C_F at the design expansion. Zero when unknown."""
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."""
70 """Combustion properties as a function of the operating point."""
72 def combustion(self, pressure: float, mixture_ratio: float) -> CombustionState: ...
75@dataclass(frozen=True, slots=True)
77 """One number, honestly labelled.
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".
86 temperature: float = 0.0
88 thrust_coefficient: float = 0.0
90 def combustion(self, pressure: float, mixture_ratio: float) -> CombustionState:
100 """EngineDesign's own CEA cache, read directly.
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.
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.
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.
121 def __init__(self, path: str | Path, *, expansion_ratio: float |
None =
None):
122 from scipy.interpolate
import RegularGridInterpolator
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)
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)
137 raw_eps = self.
meta.get(
"expansion_ratio", float(self.
expansions[0]))
138 default_eps = float(raw_eps)
140 default_eps
if expansion_ratio
is None else float(expansion_ratio)
147 name: RegularGridInterpolator(
148 grid, np.asarray(data[name], dtype=float), bounds_error=
False
150 for name
in (
"cstar",
"Cf",
"Tc",
"gamma")
157 str(self.
meta.get(
"ox_name",
"?")),
158 str(self.
meta.get(
"fuel_name",
"?")),
161 def _clamped(self, pressure: float, mixture_ratio: float) -> tuple[float, float]:
169 def contains(self, pressure: float, mixture_ratio: float) -> bool:
170 """Whether this point is inside the table, before clamping."""
178 def combustion(self, pressure: float, mixture_ratio: float) -> CombustionState:
179 """Interpolated combustion state.
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.
188 inside = self.
contains(pressure, mixture_ratio)
189 p, mr = self.
_clamped(pressure, mixture_ratio)
192 name: float(interp(point)[0])
for name, interp
in self.
_fields.items()
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,
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})"
211@dataclass(frozen=True, slots=True)
213 """The engine's answer at one operating point."""
216 """Chamber stagnation pressure [Pa]."""
222 combustion: CombustionState
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."""
227 specific_impulse: float
228 """[s]. Zero when thrust is unknown."""
234MIN_CHAMBER_FLOW = 1.0e-3
235"""Propellant arriving at the chamber that counts as "lit" [kg/s], per side.
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."""
244 mdot_oxidiser: float,
246 mixture_ratios: tuple[float, float] |
None,
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.
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.
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
272 ambient_pressure: float, mdot_oxidiser: float, mdot_fuel: float
274 """The chamber with nothing burning in it: at ambient, no thrust.
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.
282 total = mdot_oxidiser + mdot_fuel
284 pressure=ambient_pressure,
286 mdot_oxidiser=mdot_oxidiser,
290 mixture_ratio=mdot_oxidiser / mdot_fuel
if mdot_fuel > 1e-12
else 0.0,
293 specific_impulse=0.0,
298 """Chamber pressure from the flows the injector delivers.
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
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.
325 cstar_model: CStarModel,
328 efficiency: float = 1.0,
329 nozzle_efficiency: float = 1.0,
330 ambient_pressure: float = 101325.0,
332 if throat_area <= 0.0:
333 raise ValueError(f
"throat area must be positive, got {throat_area}")
341 def evaluate(self, mdot_oxidiser: float, mdot_fuel: float) -> ChamberResult:
342 """Chamber state for a pair of propellant flows.
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*``.
350 total = mdot_oxidiser + mdot_fuel
351 mixture_ratio = mdot_oxidiser / mdot_fuel
if mdot_fuel > 1e-12
else 0.0
356 mdot_oxidiser=mdot_oxidiser,
358 mixture_ratio=mixture_ratio,
361 specific_impulse=0.0,
364 if min(mdot_oxidiser, mdot_fuel) <= MIN_CHAMBER_FLOW:
369 (float(table.mixture_ratios[0]), float(table.mixture_ratios[-1]))
370 if isinstance(table, CEATable)
373 burned =
burning(mdot_oxidiser, mdot_fuel, ratios)
377 state = self.
cstar_model.combustion(pressure, mixture_ratio)
387 state.thrust_coefficient
391 if state.thrust_coefficient > 0.0
398 mdot_oxidiser=mdot_oxidiser,
400 mixture_ratio=mixture_ratio,
402 thrust=thrust
if firing
else 0.0,
404 thrust / (total * GRAVITY)
if firing
and thrust > 0.0
else 0.0
409 """Whether the throat is actually choked, or the engine is just open.
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.
416 def fill_time(self, pressure: float, mixture_ratio: float) -> float:
417 """Chamber residence time [s]: how fast chamber pressure can respond.
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.
428 state = self.
cstar_model.combustion(pressure, mixture_ratio)
429 if state.cstar <= 0.0:
435 f
"Chamber(A_t={self.throat_area * 1e6:.1f} mm^2, " f
"{self.cstar_model!r})"
440 design_name: str, search_paths: tuple[str, ...] = (
"output/cache",)
442 """Find a CEA cache matching a propellant pair, if one is lying around.
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.
448 for directory
in search_paths:
449 base = Path(directory)
450 if not base.is_dir():
452 for candidate
in sorted(base.glob(
"cea_cache_*.npz")):
453 if design_name.lower()
in candidate.name.lower():
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
__init__(self, str|Path path, *, float|None expansion_ratio=None)
CombustionState combustion(self, float pressure, float mixture_ratio)
tuple[str, str] propellants(self)
bool contains(self, float pressure, float mixture_ratio)
tuple[float, float] _clamped(self, float pressure, float mixture_ratio)
CombustionState combustion(self, float pressure, float mixture_ratio)
bool is_firing(self, ChamberResult result)
ChamberResult evaluate(self, float mdot_oxidiser, float mdot_fuel)
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)
float fill_time(self, float pressure, float mixture_ratio)
CombustionState combustion(self, float pressure, float mixture_ratio)
float burning(float mdot_oxidiser, float mdot_fuel, tuple[float, float]|None mixture_ratios)
float mixture_ratio_of(float mdot_oxidiser, float mdot_fuel)
Path|None cea_cache_for(str design_name, tuple[str,...] search_paths=("output/cache",))
"ChamberResult" unlit(float ambient_pressure, float mdot_oxidiser, float mdot_fuel)