STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
card.py
Go to the documentation of this file.
1"""An engine card: another tool's injector and chamber, tabulated for the twin.
2
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``.
6
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
10 heat loss;
11* its nozzle carries a Rayleigh stagnation loss and the real exit state.
12
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.
16
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:
23
24* the discharge into the manifold (a Borda exit, K = 1 at the exit bore), which
25 no drawing line models;
26* the manifold;
27* the orifices;
28* the chamber;
29* the nozzle.
30
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.
34
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.
42* **Chamber.**
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.
50
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.
55
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.
59"""
60
61from __future__ import annotations
62
63import math
64from dataclasses import dataclass, field, replace
65from typing import Any, Mapping, Sequence
66
67from feedtwin.engine.chamber import (
68 GRAVITY,
69 MIN_CHAMBER_FLOW,
70 Chamber,
71 ChamberResult,
72 CombustionState,
73 burning,
74 unlit,
75)
76from feedtwin.engine.design import EngineDesign
77
78#: Bumped whenever the serialised form changes meaning.
79CARD_SCHEMA = 1
80
81
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)."""
84 t2 = t * t
85 return 0.5 * (
86 2.0 * p1
87 + (p2 - p0) * t
88 + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
89 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t2 * t
90 )
91
92
93@dataclass(frozen=True, slots=True)
94class Table2D:
95 """Values on a uniform grid, evaluated with clamped Catmull-Rom.
96
97 ``values[i][j]`` is the value at ``(x0 + i dx, y0 + j dy)``.
98 """
99
100 x0: float
101 dx: float
102 nx: int
103 y0: float
104 dy: float
105 ny: int
106 values: tuple[tuple[float, ...], ...]
107
108 def __post_init__(self) -> None:
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):
112 raise ValueError(
113 f"table values are not {self.nx} x {self.ny} as its axes say"
114 )
115
116 @property
117 def x_max(self) -> float:
118 return self.x0 + (self.nx - 1) * self.dx
119
120 @property
121 def y_max(self) -> float:
122 return self.y0 + (self.ny - 1) * self.dy
123
124 def in_box(self, x: float, y: float) -> bool:
125 return self.x0 <= x <= self.x_max and self.y0 <= y <= self.y_max
126
127 def __call__(self, x: float, y: float) -> float:
128 fx = (min(max(x, self.x0), self.x_max) - self.x0) / self.dx
129 fy = (min(max(y, self.y0), self.y_max) - self.y0) / self.dy
130 i = min(int(fx), self.nx - 2)
131 j = min(int(fy), self.ny - 2)
132 tx, ty = fx - i, fy - j
133 cols = [_catmull_rom(*self._column(i + di, j), ty) for di in (-1, 0, 1, 2)]
134 return _catmull_rom(cols[0], cols[1], cols[2], cols[3], tx)
135
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."""
139 v = self.values
140 if i < 0:
141 return tuple(2.0 * a - b for a, b in zip(v[0], v[1]))
142 if i > self.nx - 1:
143 return tuple(2.0 * a - b for a, b in zip(v[-1], v[-2]))
144 return v[i]
145
146 def _column(self, i: int, j: int) -> tuple[float, float, float, float]:
147 row = self._row(i)
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
151
152 def to_dict(self) -> dict[str, Any]:
153 return {
154 "x0": self.x0,
155 "dx": self.dx,
156 "nx": self.nx,
157 "y0": self.y0,
158 "dy": self.dy,
159 "ny": self.ny,
160 "values": [list(r) for r in self.values],
161 }
162
163 @classmethod
164 def from_dict(cls, raw: Mapping[str, Any]) -> "Table2D":
165 return cls(
166 x0=float(raw["x0"]),
167 dx=float(raw["dx"]),
168 nx=int(raw["nx"]),
169 y0=float(raw["y0"]),
170 dy=float(raw["dy"]),
171 ny=int(raw["ny"]),
172 values=tuple(tuple(float(v) for v in r) for r in raw["values"]),
173 )
174
175
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
178 contains nothing.
179
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).
185 """
186 n = len(polygon)
187 if n == 0:
188 return False
189 xs = [p[0] for p in polygon]
190 ys = [p[1] for p in polygon]
191 x0, y0 = min(xs), min(ys)
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
196 eps = 1e-9
197 inside = False
198 for k in range(n):
199 x1, y1 = pts[k]
200 x2, y2 = pts[(k + 1) % n]
201 # On this edge: collinear with it and within its extent.
202 if (
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
206 ):
207 return True
208 if (y1 > v) != (y2 > v):
209 cross = x1 + (v - y1) * (x2 - x1) / (y2 - y1)
210 if u < cross:
211 inside = not inside
212 return inside
213
214
215def _hull(raw: Any) -> tuple[tuple[float, float], ...]:
216 return tuple((float(p[0]), float(p[1])) for p in (raw or ()))
217
218
219@dataclass(frozen=True, slots=True)
221 """One side's injector, inlet node to chamber, as a flow capacity.
222
223 ``capacity(mdot, p_inlet) = mdot / sqrt(dp)`` [kg/(s Pa^0.5)].
224 """
225
226 capacity: Table2D
227 hull: tuple[tuple[float, float], ...] = ()
228 """Convex hull of the ``(mdot, p_inlet)`` points the table was fitted to."""
229
230 def pressure_drop(self, mdot: float, p_inlet: float) -> float:
231 """Inlet-to-chamber pressure drop [Pa]. Signed with the flow."""
232 if mdot == 0.0:
233 return 0.0
234 phi = self.capacity(abs(mdot), p_inlet)
235 if phi <= 0.0:
236 raise ValueError("engine card injector capacity is not positive")
237 dp = (mdot / phi) ** 2
238 return dp if mdot > 0.0 else -dp
239
240 def covers(self, mdot: float, p_inlet: float) -> bool:
241 return _inside(self.hull, abs(mdot), p_inlet)
242
243 def to_dict(self) -> dict[str, Any]:
244 return {
245 "capacity": self.capacity.to_dict(),
246 "hull": [list(p) for p in self.hull],
247 }
248
249 @classmethod
250 def from_dict(cls, raw: Mapping[str, Any]) -> "InjectorCard":
251 return cls(Table2D.from_dict(raw["capacity"]), _hull(raw.get("hull")))
252
253
254@dataclass(frozen=True, slots=True)
256 """Chamber and nozzle over ``(O/F, mdot_total)``."""
257
258 cstar: Table2D
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."""
264
265 def covers(self, mixture_ratio: float, mdot_total: float) -> bool:
266 return _inside(self.hull, mixture_ratio, mdot_total)
267
268 def to_dict(self) -> dict[str, Any]:
269 return {
270 "cstar": self.cstar.to_dict(),
271 "vacuum_velocity": self.vacuum_velocity.to_dict(),
272 "hull": [list(p) for p in self.hull],
273 }
274
275 @classmethod
276 def from_dict(cls, raw: Mapping[str, Any]) -> "ChamberCard":
277 return cls(
278 Table2D.from_dict(raw["cstar"]),
279 Table2D.from_dict(raw["vacuum_velocity"]),
280 _hull(raw.get("hull")),
281 )
282
283
284@dataclass(frozen=True, slots=True)
286 """An engine downstream of its feed lines, tabulated.
287
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.
290 """
291
292 name: str
293 throat_area: float
294 exit_area: float
295 oxidiser: InjectorCard
296 fuel: InjectorCard
297 chamber: ChamberCard
298 provenance: Mapping[str, Any] = field(default_factory=dict)
299 fit: Mapping[str, float] = field(default_factory=dict)
300
302 self, *, ambient_pressure: float = 101325.0, volume: float = 0.0
303 ) -> "CardChamber":
304 return CardChamber(self, ambient_pressure=ambient_pressure, volume=volume)
305
306 def attach(self, design: EngineDesign) -> EngineDesign:
307 """``design`` with this card's injector characteristics on its two sides.
308
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.
312 """
313 if abs(design.throat_area - self.throat_area) > 1e-9 * max(
314 self.throat_area, 1e-12
315 ):
316 raise ValueError(
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"
319 )
320 return replace(
321 design,
322 oxidiser=replace(design.oxidiser, card=self.oxidiser),
323 fuel=replace(design.fuel, card=self.fuel),
324 )
325
326 @property
327 def ambient_pressure(self) -> float:
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))
330
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).
336
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.
340 """
341 attached = self.attach(design)
342 ambient = (
343 self.ambient_pressure if ambient_pressure is None else ambient_pressure
344 )
345 return attached, self.chamber_model(
346 ambient_pressure=ambient, volume=attached.chamber_volume
347 )
348
349 def to_dict(self) -> dict[str, Any]:
350 return {
351 "schema": CARD_SCHEMA,
352 "name": self.name,
353 "throat_area": self.throat_area,
354 "exit_area": self.exit_area,
355 "oxidiser": self.oxidiser.to_dict(),
356 "fuel": self.fuel.to_dict(),
357 "chamber": self.chamber.to_dict(),
358 "provenance": dict(self.provenance),
359 "fit": dict(self.fit),
360 }
361
362 @classmethod
363 def from_dict(cls, raw: Mapping[str, Any]) -> "EngineCard":
364 schema = int(raw.get("schema", 0))
365 if schema != CARD_SCHEMA:
366 raise ValueError(
367 f"engine card schema {schema}; this feedtwin reads {CARD_SCHEMA}"
368 )
369 return cls(
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()},
378 )
379
380
382 """The card as a ``CStarModel``, for the callers that ask by pressure.
383
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.
386 """
387
388 def __init__(self, card: EngineCard) -> None:
389 self.card = card
390
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)
395 for _ in range(6):
396 mdot = pressure * self.card.throat_area / max(cstar, 1.0)
397 cstar = table(mixture_ratio, mdot)
398 return CombustionState(
399 cstar=cstar,
400 extrapolated=not self.card.chamber.covers(mixture_ratio, mdot),
401 )
402
403
405 """A chamber whose pressure and thrust come from an engine card.
406
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
410 climbs.
411 """
412
414 self,
415 card: EngineCard,
416 *,
417 ambient_pressure: float = 101325.0,
418 volume: float = 0.0,
419 ) -> None:
420 super().__init__(
421 card.throat_area,
422 _CardCStar(card),
423 volume=volume,
424 ambient_pressure=ambient_pressure,
425 )
426 self.card = card
427
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
431 if total <= 0.0:
432 return ChamberResult(
433 pressure=self.ambient_pressure,
434 mdot_total=0.0,
435 mdot_oxidiser=mdot_oxidiser,
436 mdot_fuel=mdot_fuel,
437 mixture_ratio=mixture_ratio,
438 combustion=CombustionState(cstar=0.0),
439 thrust=0.0,
440 specific_impulse=0.0,
441 )
442 if min(mdot_oxidiser, mdot_fuel) <= MIN_CHAMBER_FLOW:
443 return unlit(self.ambient_pressure, mdot_oxidiser, mdot_fuel)
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)
448 pressure = max(burned * cstar / self.throat_area, self.ambient_pressure)
449 firing = pressure > self.ambient_pressure * 1.001
450 thrust = 0.0
451 if firing:
452 thrust = max(
453 burned * chamber.vacuum_velocity(mixture_ratio, burned)
454 - self.ambient_pressure * self.card.exit_area,
455 0.0,
456 )
457 coefficient = thrust / (pressure * self.throat_area) if firing else 0.0
458 return ChamberResult(
459 pressure=pressure,
460 mdot_total=total,
461 mdot_oxidiser=mdot_oxidiser,
462 mdot_fuel=mdot_fuel,
463 mixture_ratio=mixture_ratio,
464 combustion=CombustionState(
465 cstar=cstar,
466 thrust_coefficient=coefficient,
467 extrapolated=not chamber.covers(mixture_ratio, total),
468 ),
469 thrust=thrust,
470 specific_impulse=(
471 thrust / (total * GRAVITY) if firing and thrust > 0.0 else 0.0
472 ),
473 )
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None __init__(self, EngineCard card, *, float ambient_pressure=101325.0, float volume=0.0)
Definition card.py:419
ChamberResult evaluate(self, float mdot_oxidiser, float mdot_fuel)
Definition card.py:428
dict[str, Any] to_dict(self)
Definition card.py:268
"ChamberCard" from_dict(cls, Mapping[str, Any] raw)
Definition card.py:276
bool covers(self, float mixture_ratio, float mdot_total)
Definition card.py:265
"CardChamber" chamber_model(self, *, float ambient_pressure=101325.0, float volume=0.0)
Definition card.py:303
tuple[EngineDesign, "CardChamber"] install(self, EngineDesign design, *, float|None ambient_pressure=None)
Definition card.py:333
dict[str, Any] to_dict(self)
Definition card.py:349
"EngineCard" from_dict(cls, Mapping[str, Any] raw)
Definition card.py:363
EngineDesign attach(self, EngineDesign design)
Definition card.py:306
float pressure_drop(self, float mdot, float p_inlet)
Definition card.py:230
dict[str, Any] to_dict(self)
Definition card.py:243
bool covers(self, float mdot, float p_inlet)
Definition card.py:240
"InjectorCard" from_dict(cls, Mapping[str, Any] raw)
Definition card.py:250
tuple[float, float, float, float] _column(self, int i, int j)
Definition card.py:146
dict[str, Any] to_dict(self)
Definition card.py:152
bool in_box(self, float x, float y)
Definition card.py:124
"Table2D" from_dict(cls, Mapping[str, Any] raw)
Definition card.py:164
tuple[float,...] _row(self, int i)
Definition card.py:136
float __call__(self, float x, float y)
Definition card.py:127
None __init__(self, EngineCard card)
Definition card.py:388
CombustionState combustion(self, float pressure, float mixture_ratio)
Definition card.py:391
float _catmull_rom(float p0, float p1, float p2, float p3, float t)
Definition card.py:82
tuple[tuple[float, float],...] _hull(Any raw)
Definition card.py:215
bool _inside(Sequence[tuple[float, float]] polygon, float x, float y)
Definition card.py:176