1"""An engine card: another tool's injector and chamber, tabulated for the twin.
3feedtwin's own engine is deliberately simple:
4* an orifice leg per side, with the config's Reynolds-law Cd;
5* ``p_c = mdot c* eta / A_t`` on the CEA table, with ``eta = 1``.
7EngineDesign's engine is not simple:
8* its Cd comes from the plate's passages and a ring-network manifold;
9* its c* efficiency comes from a spray march, Rupe stream-tube mixing and wall
11* its nozzle carries a Rayleigh stagnation loss and the real exit state.
13Calling that physics from inside the twin's chamber closure would cost seconds
14per step. A card is the same physics, sampled once and tabulated, in a form the
15twin's network and chamber closure can evaluate in microseconds.
17The boundary is the line exit
18-----------------------------
19The card starts where the drawing's feed line ends: the injector face node,
20whose pressure is the line-end pressure in the lumped K-factor convention every
21feedtwin line uses (``dp = (f L/D + sum K) rho v^2/2``, no acceleration term).
22Everything downstream of that point is the card's:
24* the discharge into the manifold (a Borda exit, K = 1 at the exit bore), which
25 no drawing line models;
31Nothing upstream of it is the card's. The caller that builds a card samples its
32engine with every feed-line loss removed, so the drawing's lines and the card
33never count the same loss twice.
35What is tabulated, and why in that form
36---------------------------------------
37* **Injector, per side.** The flow capacity ``phi = mdot / sqrt(dp)``
38 [kg/(s Pa^0.5)] over ``(mdot, inlet pressure)``. For an incompressible
39 orifice ``phi`` is nearly constant (``Cd A sqrt(2 rho)``), so the table holds
40 the slow part. ``dp = (mdot / phi)^2`` keeps the quadratic exact at any
41 flow, including flows outside the table.
43 * ``c*_eff = p_c A_t / mdot``: the effective characteristic velocity,
44 efficiency and stagnation loss included.
45 * ``v_vac = (F + p_a A_e) / mdot``: the vacuum exhaust velocity.
46 * Both are tabulated over ``(O/F, mdot_total)``. Thrust at any ambient is then
47 ``mdot v_vac - p_a A_e``. That is exact for a full-flowing nozzle, which is
48 what lets a card built at one site serve another, or serve a vehicle
49 climbing through the atmosphere.
51Tables are uniform grids evaluated with Catmull-Rom (C1, interpolating, exact on
52linear data, with linear ghost nodes at the edges). Outside the grid they clamp, which keeps every query physical. Every table also carries
53the convex hull of the points it was fitted to. A query outside the hull is
54reported as extrapolated, so a trace says when it left what the card knows.
56Opt-in. An :class:`~feedtwin.engine.design.InjectorSide` without a card, and a
57:class:`~feedtwin.engine.chamber.Chamber` that is not a :class:`CardChamber`,
58behave exactly as before.
61from __future__
import annotations
64from dataclasses
import dataclass, field, replace
65from typing
import Any, Mapping, Sequence
82def _catmull_rom(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
83 """Uniform Catmull-Rom between ``p1`` (t=0) and ``p2`` (t=1)."""
88 + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
89 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t2 * t
93@dataclass(frozen=True, slots=True)
95 """Values on a uniform grid, evaluated with clamped Catmull-Rom.
97 ``values[i][j]`` is the value at ``(x0 + i dx, y0 + j dy)``.
106 values: tuple[tuple[float, ...], ...]
109 if self.
nx < 2
or self.
ny < 2
or self.
dx <= 0.0
or self.
dy <= 0.0:
110 raise ValueError(
"a table needs at least 2 x 2 points on positive steps")
111 if len(self.
values) != self.
nx or any(len(r) != self.
ny for r
in self.
values):
113 f
"table values are not {self.nx} x {self.ny} as its axes say"
118 return self.
x0 + (self.
nx - 1) * self.
dx
122 return self.
y0 + (self.
ny - 1) * self.
dy
124 def in_box(self, x: float, y: float) -> bool:
130 i =
min(int(fx), self.
nx - 2)
131 j =
min(int(fy), self.
ny - 2)
132 tx, ty = fx - i, fy - j
134 return _catmull_rom(cols[0], cols[1], cols[2], cols[3], tx)
136 def _row(self, i: int) -> tuple[float, ...]:
137 """Row ``i``, with a linear ghost beyond either end so the interpolant
138 stays exact on linear data up to the edge of the table."""
141 return tuple(2.0 * a - b
for a, b
in zip(v[0], v[1]))
143 return tuple(2.0 * a - b
for a, b
in zip(v[-1], v[-2]))
146 def _column(self, i: int, j: int) -> tuple[float, float, float, float]:
148 lo = 2.0 * row[0] - row[1]
if j - 1 < 0
else row[j - 1]
149 hi = 2.0 * row[-1] - row[-2]
if j + 2 > self.
ny - 1
else row[j + 2]
150 return lo, row[j], row[j + 1], hi
160 "values": [list(r)
for r
in self.
values],
164 def from_dict(cls, raw: Mapping[str, Any]) ->
"Table2D":
172 values=tuple(tuple(float(v)
for v
in r)
for r
in raw[
"values"]),
176def _inside(polygon: Sequence[tuple[float, float]], x: float, y: float) -> bool:
177 """Point in polygon by ray casting, the boundary included. An empty polygon
180 Ray casting alone is half-open: of a square's four edges it keeps two, so a
181 sample on the far edge of the hull -- the highest flow or pressure the card was
182 built from -- read as outside it, and a query there counted as extrapolated.
183 The edge test runs on each axis scaled to the hull's extent, since the axes
184 differ by six orders of magnitude (kg/s against Pa).
189 xs = [p[0]
for p
in polygon]
190 ys = [p[1]
for p
in polygon]
192 sx = (
max(xs) - x0)
or 1.0
193 sy = (
max(ys) - y0)
or 1.0
194 pts = [((px - x0) / sx, (py - y0) / sy)
for px, py
in polygon]
195 u, v = (x - x0) / sx, (y - y0) / sy
200 x2, y2 = pts[(k + 1) % n]
203 abs((x2 - x1) * (v - y1) - (y2 - y1) * (u - x1)) <= eps
204 and min(x1, x2) - eps <= u <=
max(x1, x2) + eps
205 and min(y1, y2) - eps <= v <=
max(y1, y2) + eps
208 if (y1 > v) != (y2 > v):
209 cross = x1 + (v - y1) * (x2 - x1) / (y2 - y1)
215def _hull(raw: Any) -> tuple[tuple[float, float], ...]:
216 return tuple((float(p[0]), float(p[1]))
for p
in (raw
or ()))
219@dataclass(frozen=True, slots=True)
221 """One side's injector, inlet node to chamber, as a flow capacity.
223 ``capacity(mdot, p_inlet) = mdot / sqrt(dp)`` [kg/(s Pa^0.5)].
227 hull: tuple[tuple[float, float], ...] = ()
228 """Convex hull of the ``(mdot, p_inlet)`` points the table was fitted to."""
231 """Inlet-to-chamber pressure drop [Pa]. Signed with the flow."""
234 phi = self.capacity(abs(mdot), p_inlet)
236 raise ValueError(
"engine card injector capacity is not positive")
237 dp = (mdot / phi) ** 2
238 return dp
if mdot > 0.0
else -dp
240 def covers(self, mdot: float, p_inlet: float) -> bool:
245 "capacity": self.capacity.
to_dict(),
246 "hull": [list(p)
for p
in self.
hull],
250 def from_dict(cls, raw: Mapping[str, Any]) ->
"InjectorCard":
251 return cls(Table2D.from_dict(raw[
"capacity"]),
_hull(raw.get(
"hull")))
254@dataclass(frozen=True, slots=True)
256 """Chamber and nozzle over ``(O/F, mdot_total)``."""
259 """``p_c A_t / mdot`` [m/s]."""
260 vacuum_velocity: Table2D
261 """``(F + p_a A_e) / mdot`` [m/s]."""
262 hull: tuple[tuple[float, float], ...] = ()
263 """Convex hull of the ``(O/F, mdot_total)`` points the tables were fitted to."""
265 def covers(self, mixture_ratio: float, mdot_total: float) -> bool:
266 return _inside(self.
hull, mixture_ratio, mdot_total)
271 "vacuum_velocity": self.vacuum_velocity.
to_dict(),
272 "hull": [list(p)
for p
in self.
hull],
276 def from_dict(cls, raw: Mapping[str, Any]) ->
"ChamberCard":
278 Table2D.from_dict(raw[
"cstar"]),
279 Table2D.from_dict(raw[
"vacuum_velocity"]),
280 _hull(raw.get(
"hull")),
284@dataclass(frozen=True, slots=True)
286 """An engine downstream of its feed lines, tabulated.
288 ``fit`` is the card's own measured error against the tool that built it,
289 on points it was not fitted to, so a card that is wrong says so.
295 oxidiser: InjectorCard
298 provenance: Mapping[str, Any] = field(default_factory=dict)
299 fit: Mapping[str, float] = field(default_factory=dict)
302 self, *, ambient_pressure: float = 101325.0, volume: float = 0.0
304 return CardChamber(self, ambient_pressure=ambient_pressure, volume=volume)
306 def attach(self, design: EngineDesign) -> EngineDesign:
307 """``design`` with this card's injector characteristics on its two sides.
309 The throat has to be the one the card was built for: a card is a
310 sampled engine, and the same flows through a different throat are a
311 different chamber pressure.
317 f
"engine card {self.name!r} was built for a {self.throat_area * 1e6:.3f} "
318 f
"mm^2 throat; this design has {design.throat_area * 1e6:.3f} mm^2"
322 oxidiser=replace(design.oxidiser, card=self.oxidiser),
323 fuel=replace(design.fuel, card=self.fuel),
328 """The ambient the tool sampled the card at [Pa]; sea level if unsaid."""
329 return float(self.
provenance.get(
"ambient_pa_sampled", 101325.0))
332 self, design: EngineDesign, *, ambient_pressure: float |
None =
None
333 ) -> tuple[EngineDesign,
"CardChamber"]:
334 """``design`` running this card: its injector on the two sides, and its
335 chamber at ``ambient_pressure`` (the card's own site by default).
337 The one way an engine card goes onto an engine. The feed-twin cockpit
338 and EngineDesign's Layer X both install through here, so the same card
339 is the same engine in both.
341 attached = self.
attach(design)
346 ambient_pressure=ambient, volume=attached.chamber_volume
351 "schema": CARD_SCHEMA,
355 "oxidiser": self.oxidiser.
to_dict(),
357 "chamber": self.chamber.
to_dict(),
359 "fit": dict(self.
fit),
363 def from_dict(cls, raw: Mapping[str, Any]) ->
"EngineCard":
364 schema = int(raw.get(
"schema", 0))
365 if schema != CARD_SCHEMA:
367 f
"engine card schema {schema}; this feedtwin reads {CARD_SCHEMA}"
370 name=str(raw[
"name"]),
371 throat_area=float(raw[
"throat_area"]),
372 exit_area=float(raw[
"exit_area"]),
373 oxidiser=InjectorCard.from_dict(raw[
"oxidiser"]),
374 fuel=InjectorCard.from_dict(raw[
"fuel"]),
375 chamber=ChamberCard.from_dict(raw[
"chamber"]),
376 provenance=dict(raw.get(
"provenance")
or {}),
377 fit={str(k): float(v)
for k, v
in (raw.get(
"fit")
or {}).items()},
382 """The card as a ``CStarModel``, for the callers that ask by pressure.
384 :meth:`Chamber.fill_time` asks for ``c*`` at a chamber pressure. The card
385 knows ``c*`` at a flow, so this finds the flow that makes that pressure.
391 def combustion(self, pressure: float, mixture_ratio: float) -> CombustionState:
392 table = self.
card.chamber.cstar
393 mdot = pressure * self.
card.throat_area /
max(
table(mixture_ratio, 1.0), 1.0)
394 cstar =
table(mixture_ratio, mdot)
396 mdot = pressure * self.
card.throat_area /
max(cstar, 1.0)
397 cstar =
table(mixture_ratio, mdot)
400 extrapolated=
not self.
card.chamber.covers(mixture_ratio, mdot),
405 """A chamber whose pressure and thrust come from an engine card.
407 ``p_c = mdot c*_eff(O/F, mdot) / A_t``: no fixed point, because the card is
408 tabulated over the flows themselves. Thrust is ``mdot v_vac - p_a A_e`` at
409 this chamber's ``ambient_pressure``, which a caller may change as a vehicle
417 ambient_pressure: float = 101325.0,
424 ambient_pressure=ambient_pressure,
428 def evaluate(self, mdot_oxidiser: float, mdot_fuel: float) -> ChamberResult:
429 total = mdot_oxidiser + mdot_fuel
430 mixture_ratio = mdot_oxidiser / mdot_fuel
if mdot_fuel > 1e-12
else 0.0
435 mdot_oxidiser=mdot_oxidiser,
437 mixture_ratio=mixture_ratio,
440 specific_impulse=0.0,
442 if min(mdot_oxidiser, mdot_fuel) <= MIN_CHAMBER_FLOW:
444 chamber = self.
card.chamber
445 table = chamber.cstar
446 burned = burning(mdot_oxidiser, mdot_fuel, (table.x0, table.x_max))
447 cstar = chamber.cstar(mixture_ratio, burned)
453 burned * chamber.vacuum_velocity(mixture_ratio, burned)
457 coefficient = thrust / (pressure * self.
throat_area)
if firing
else 0.0
461 mdot_oxidiser=mdot_oxidiser,
463 mixture_ratio=mixture_ratio,
466 thrust_coefficient=coefficient,
467 extrapolated=
not chamber.covers(mixture_ratio, total),
471 thrust / (total * GRAVITY)
if firing
and thrust > 0.0
else 0.0
None __init__(self, EngineCard card, *, float ambient_pressure=101325.0, float volume=0.0)
ChamberResult evaluate(self, float mdot_oxidiser, float mdot_fuel)
dict[str, Any] to_dict(self)
"ChamberCard" from_dict(cls, Mapping[str, Any] raw)
bool covers(self, float mixture_ratio, float mdot_total)
"CardChamber" chamber_model(self, *, float ambient_pressure=101325.0, float volume=0.0)
tuple[EngineDesign, "CardChamber"] install(self, EngineDesign design, *, float|None ambient_pressure=None)
float ambient_pressure(self)
dict[str, Any] to_dict(self)
"EngineCard" from_dict(cls, Mapping[str, Any] raw)
EngineDesign attach(self, EngineDesign design)
float pressure_drop(self, float mdot, float p_inlet)
dict[str, Any] to_dict(self)
bool covers(self, float mdot, float p_inlet)
"InjectorCard" from_dict(cls, Mapping[str, Any] raw)
tuple[float, float, float, float] _column(self, int i, int j)
dict[str, Any] to_dict(self)
bool in_box(self, float x, float y)
"Table2D" from_dict(cls, Mapping[str, Any] raw)
tuple[float,...] _row(self, int i)
float __call__(self, float x, float y)
None __init__(self, EngineCard card)
CombustionState combustion(self, float pressure, float mixture_ratio)
float _catmull_rom(float p0, float p1, float p2, float p3, float t)
tuple[tuple[float, float],...] _hull(Any raw)
bool _inside(Sequence[tuple[float, float]] polygon, float x, float y)