STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
card.py
Go to the documentation of this file.
1"""EngineDesign's engine as an engine card for the feed twin (Layer X, phase 2).
2
3A card (:mod:`feedtwin.engine.card`) is this engine downstream of its feed lines, tabulated so the
4twin can run it at every coupling step of a burn. This module builds one by sampling EngineDesign
5and fitting the tables, and measures how wrong the card is before anything uses it.
6
7The boundary: the line exit
8---------------------------
9The twin's lines end at the injector face node, at line-end pressure in the lumped K-factor
10convention. EngineDesign's ``feed_system`` is a tank-to-manifold model: ``K_eff`` along the
11line, plus a Borda dump of the exit bore's velocity head into the manifold.
12
13The card samples a copy of the config with every line loss zeroed (``K0``, ``K1``, fittings,
14friction) and the exit dump kept. Its "tank" pressure is then exactly the line-exit pressure,
15and the card owns everything downstream:
16* the dump;
17* the ring manifold;
18* the plate passages and their Cd;
19* the spray, mixing and heat-loss efficiency;
20* the stagnation loss;
21* the nozzle.
22
23The drawing owns the lines. On the 6.8 kN engine the dump alone is ~26 psi on the LOX side,
24which is why it cannot be left on neither side or put on both.
25
26Sampling and fitting
27--------------------
28EngineDesign is solved on a grid of line-exit pressures around the T-0 point. Each solve is
29~5-20 ms on the numba chamber kernel, which has 1e-9 parity with the Python solve. The fitted
30quantities are each nearly constant over the burn, which is why they tabulate well:
31* injector flow capacity ``mdot/sqrt(dp)`` over ``(mdot, p_inlet)``;
32* ``c*_eff = Pc A_t/mdot`` over ``(O/F, mdot)``;
33* the vacuum exhaust velocity ``(F + p_a A_e)/mdot`` over ``(O/F, mdot)``. EngineDesign's thrust
34 is exactly linear in ambient pressure for a full-flowing nozzle, so a card built at one
35 ambient serves any.
36
37The fit is thin-plate RBF through the samples, resampled onto uniform grids, which the twin
38evaluates with Catmull-Rom.
39
40Limit, measured: EngineDesign's efficiency has slope kinks at its CEA table's O/F nodes (its
41own trilinear lookup). No smooth table reproduces those, which sets a floor of a few hundredths
42of a percent in chamber pressure. The card reports its measured error, and a card whose error
43on held-out solves exceeds :data:`TOLERANCE` is flagged, not used quietly.
44"""
45
46from __future__ import annotations
47
48import copy
49import math
50import threading
51import time
52from collections import OrderedDict
53from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
54
55import numpy as np
56
57PSI = 6894.757293168361
58
59#: Largest relative error, against held-out EngineDesign solves inside the operating envelope,
60#: that a card may carry and still be used without a warning. docs/layer-x.md, phase 2.
61TOLERANCE = 2.0e-3
62
63#: The scan around T-0, as fractions of the T-0 line pressure (level) and fuel/LOX line
64#: pressure (ratio). Wide enough that the burn and the chamber closure's brackets stay inside.
65LEVELS = (0.40, 1.30, 21)
66RATIOS = (0.75, 1.30, 13)
67#: The operating envelope the tolerance is judged on: what a regulated burn actually visits
68#: (O/F about 1.35-1.75 on the 6.8 kN engine). Beyond it, above O/F ~1.9, EngineDesign's mixing
69#: efficiency falls in irregular steps (striated elements, stream tubes past the c* table) that no
70#: smooth table follows to better than ~0.2 %; the whole-scan error is reported as ``box_*`` so
71#: that is visible rather than averaged away. What a burn actually saw is checked afterwards,
72#: along its own trajectory (engine/layerx/analysis.py engine_check).
73ENVELOPE_LEVELS = (0.70, 1.15)
74ENVELOPE_RATIOS = (0.90, 1.12)
75#: Table resolution.
76CHAMBER_GRID = (41, 41)
77INJECTOR_GRID = (41, 21)
78
79BOUNDARY = ("line exit: EngineDesign's feed_system with K0, K1, fittings and friction zeroed and "
80 "the Borda exit dump kept; the drawing's lines carry every loss upstream of it")
81
82
83# ------------------------------------------------------------------ the engine at the line exit
84
85
86def line_exit_config(config: Any) -> Any:
87 """A copy of ``config`` whose feed lines lose nothing but the exit dump into the manifold.
88
89 ``supply_K`` goes with ``K0``: it is the share of ``K0`` that is the pressurant supply sagging
90 under flow (Layer X's feed fit writes it), and the chug loop subtracts it from the feed drop it
91 takes as line resistance (stability/analysis.py ``_chug_feed_drop``). With ``K0`` zeroed the
92 drop left is the exit dump alone, which holds no supply share; left in, a feed-fit write took
93 the chug gate from 1.330 to 1.248 with nothing physical changed (audit D7, 5.3). A config with
94 ``supply_K`` 0 (LE4) is unchanged by this."""
95 out = copy.deepcopy(config)
96 feeds = out.feed_system
97 sides = feeds.values() if isinstance(feeds, dict) else (feeds.oxidizer, feeds.fuel)
98 for side in sides:
99 side.K0 = 0.0
100 side.K1 = 0.0
101 side.phi_type = "none"
102 if getattr(side, "supply_K", None):
103 side.supply_K = 0.0
104 if getattr(side, "fittings", None):
105 side.fittings = []
106 if getattr(side, "roughness_m", None) is not None:
107 side.roughness_m = None
108 return out
109
110
112 """EngineDesign at the line exit, one operating point per call.
113
114 ``(p_line_O, p_line_F) -> {Pc, mdot_O, mdot_F, F, ...}`` at ``ambient_pa``, or ``None`` where
115 EngineDesign has no solution. The numba kernel when it handles this engine, otherwise the
116 Python solve: same physics, ~100x the cost.
117 """
118
119 def __init__(self, config: Any, ambient_pa: float) -> None:
120 from engine import accel
121 from engine.core.runner import PintleEngineRunner
122
123 self.runner = PintleEngineRunner(line_exit_config(config))
124 # The runner's copy, not ours: PintleEngineRunner applies the config's measurements
125 # (engine/pipeline/measurements.py -- a measured Cd, E_m, D32, nozzle efficiency) to the
126 # config it keeps, and the fast path has to sample the same engine the replay runs.
127 self.config = self.runner.config
128 self.ambient_pa = float(ambient_pa)
129 self.fast = bool(accel.enabled() and accel.can_handle_chamber(self.config)
130 and getattr(self.runner.cea_cache, "use_3d", False))
131 self.calls = 0
132
133 @property
134 def kind(self) -> str:
135 return "numba chamber kernel" if self.fast else "Python chamber solve"
136
137 def __call__(self, p_O: float, p_F: float) -> Optional[Dict[str, float]]:
138 self.calls += 1
139 if self.fast:
140 from engine import accel
141
142 point, _ = accel.chamber_point(self.config, self.runner.cea_cache, p_O, p_F, self.ambient_pa)
143 if point is not None:
144 return point
145 try:
146 res = self.runner.evaluate(p_O, p_F, silent=True, P_ambient=self.ambient_pa)
147 except Exception: # noqa: BLE001 - no solution here is a gap in the scan, not an error
148 return None
149 return {"Pc": float(res["Pc"]), "mdot_O": float(res["mdot_O"]), "mdot_F": float(res["mdot_F"]),
150 "MR": float(res["MR"]), "F": float(res["F"]), "Isp": float(res["Isp"])}
151
152
153# ------------------------------------------------------------------ fitting
154
155
156def _rbf(points: np.ndarray, values: np.ndarray):
157 """Thin-plate RBF through ``values`` at ``points`` (columns normalised), as a callable."""
158 from scipy.interpolate import RBFInterpolator
159
160 mu, sd = points.mean(axis=0), points.std(axis=0)
161 sd = np.where(sd > 0, sd, 1.0)
162 f = RBFInterpolator((points - mu) / sd, values, kernel="thin_plate_spline", degree=1)
163 return lambda q: f((np.atleast_2d(q) - mu) / sd)
164
165
166def _table(fn, x_axis: np.ndarray, y_axis: np.ndarray, transform: Callable[[np.ndarray], np.ndarray]):
167 from feedtwin.engine.card import Table2D
168
169 X, Y = np.meshgrid(x_axis, y_axis, indexing="ij")
170 q = transform(np.c_[X.ravel(), Y.ravel()])
171 V = np.asarray(fn(q)).reshape(X.shape)
172 return Table2D(x0=float(x_axis[0]), dx=float(x_axis[1] - x_axis[0]), nx=len(x_axis),
173 y0=float(y_axis[0]), dy=float(y_axis[1] - y_axis[0]), ny=len(y_axis),
174 values=tuple(tuple(float(v) for v in row) for row in V))
175
176
177def _hull(points: np.ndarray) -> Tuple[Tuple[float, float], ...]:
178 from scipy.spatial import ConvexHull
179
180 h = ConvexHull(points)
181 return tuple((float(points[k, 0]), float(points[k, 1])) for k in h.vertices)
182
183
184def _axis(values: np.ndarray, n: int) -> np.ndarray:
185 lo, hi = float(np.min(values)), float(np.max(values))
186 return np.linspace(lo, hi, n)
187
188
189def fit_card(samples: Sequence[Dict[str, float]], *, throat_area: float, exit_area: float,
190 ambient_pa: float, name: str):
191 """Tables through ``samples`` (line-exit operating points with ``p_O``, ``p_F``)."""
192 from feedtwin.engine.card import ChamberCard, EngineCard, InjectorCard
193
194 S = list(samples)
195 mo = np.array([s["mdot_O"] for s in S])
196 mf = np.array([s["mdot_F"] for s in S])
197 pc = np.array([s["Pc"] for s in S])
198 F = np.array([s["F"] for s in S])
199 mt, mr = mo + mf, mo / mf
200
201 # Chamber: fitted in (O/F, ln mdot), tabulated on uniform (O/F, mdot).
202 feat = np.c_[mr, np.log(mt)]
203 f_c = _rbf(feat, pc * throat_area / mt)
204 f_v = _rbf(feat, (F + ambient_pa * exit_area) / mt)
205 to_feat = lambda q: np.c_[q[:, 0], np.log(q[:, 1])] # noqa: E731
206 mr_ax, mt_ax = _axis(mr, CHAMBER_GRID[0]), _axis(mt, CHAMBER_GRID[1])
207 chamber = ChamberCard(
208 cstar=_table(f_c, mr_ax, mt_ax, to_feat),
209 vacuum_velocity=_table(f_v, mr_ax, mt_ax, to_feat),
210 hull=_hull(np.c_[mr, mt]),
211 )
212
213 def injector(m: np.ndarray, p: np.ndarray) -> InjectorCard:
214 dp = p - pc
215 f = _rbf(np.c_[m, p], m / np.sqrt(dp))
216 return InjectorCard(capacity=_table(f, _axis(m, INJECTOR_GRID[0]), _axis(p, INJECTOR_GRID[1]), lambda q: q),
217 hull=_hull(np.c_[m, p]))
218
219 p_O = np.array([s["p_O"] for s in S])
220 p_F = np.array([s["p_F"] for s in S])
221 return EngineCard(name=name, throat_area=float(throat_area), exit_area=float(exit_area),
222 oxidiser=injector(mo, p_O), fuel=injector(mf, p_F), chamber=chamber)
223
224
225# ------------------------------------------------------------------ checking a card
226
227
228def solve_card(card: Any, p_O: float, p_F: float, ambient_pa: float) -> Optional[Dict[str, float]]:
229 """The card's own operating point at line-exit pressures ``p_O``, ``p_F``: Pc such that the
230 flows each injector passes at that Pc are the flows that make that Pc. Independent of the twin's
231 network, so it checks the card and nothing else."""
232 from scipy.optimize import brentq
233
234 chamber = card.chamber_model(ambient_pressure=ambient_pa)
235
236 def flow(side: Any, p: float, pc: float) -> float:
237 dp = max(p - pc, 0.0)
238 m = side.capacity(1.0, p) * math.sqrt(dp)
239 for _ in range(30):
240 m_new = side.capacity(m, p) * math.sqrt(dp)
241 if abs(m_new - m) <= 1e-13 * max(m, 1e-12):
242 break
243 m = m_new
244 return m
245
246 def g(pc: float) -> float:
247 mo, mf = flow(card.oxidiser, p_O, pc), flow(card.fuel, p_F, pc)
248 return chamber.evaluate(mo, mf).pressure - pc
249
250 hi = min(p_O, p_F) * (1.0 - 1e-9)
251 lo = ambient_pa * 1.01
252 if g(lo) * g(hi) > 0:
253 return None
254 pc = brentq(g, lo, hi, xtol=1e-6, rtol=1e-12)
255 mo, mf = flow(card.oxidiser, p_O, pc), flow(card.fuel, p_F, pc)
256 res = chamber.evaluate(mo, mf)
257 return {"Pc": res.pressure, "mdot_O": mo, "mdot_F": mf, "F": res.thrust}
258
259
260def check_card(card: Any, points: Sequence[Dict[str, float]], ambient_pa: float) -> Dict[str, float]:
261 """Largest relative errors of ``card`` against EngineDesign solves ``points`` (each with p_O, p_F).
262
263 * ``chamber_*``: the chamber tables given EngineDesign's own flows.
264 * ``dp_*``: each injector table given EngineDesign's flow and line pressure.
265 * ``closed_*``: the card solved as a whole at the line pressures (:func:`solve_card`), against
266 EngineDesign at the same pressures. This is what a burn sees.
267 """
268 chamber = card.chamber_model(ambient_pressure=ambient_pa)
269 worst: Dict[str, float] = {k: 0.0 for k in ("chamber_pc", "chamber_thrust", "dp_O", "dp_F",
270 "closed_pc", "closed_thrust", "closed_mdot")}
271
272 def bump(key: str, got: float, want: float) -> None:
273 if want:
274 worst[key] = max(worst[key], abs(got / want - 1.0))
275
276 for s in points:
277 res = chamber.evaluate(s["mdot_O"], s["mdot_F"])
278 bump("chamber_pc", res.pressure, s["Pc"])
279 bump("chamber_thrust", res.thrust, s["F"])
280 bump("dp_O", card.oxidiser.pressure_drop(s["mdot_O"], s["p_O"]), s["p_O"] - s["Pc"])
281 bump("dp_F", card.fuel.pressure_drop(s["mdot_F"], s["p_F"]), s["p_F"] - s["Pc"])
282 closed = solve_card(card, s["p_O"], s["p_F"], ambient_pa)
283 if closed is None:
284 worst["closed_pc"] = max(worst["closed_pc"], 1.0)
285 continue
286 bump("closed_pc", closed["Pc"], s["Pc"])
287 bump("closed_thrust", closed["F"], s["F"])
288 bump("closed_mdot", closed["mdot_O"], s["mdot_O"])
289 bump("closed_mdot", closed["mdot_F"], s["mdot_F"])
290 worst["points"] = float(len(points))
291 return worst
292
293
294def _scan(sampler: EngineSampler, center_pa: float, levels: Tuple[float, float, int],
295 ratios: Tuple[float, float, int]) -> List[Dict[str, float]]:
296 out = []
297 for u in np.linspace(*levels):
298 for v in np.linspace(*ratios):
299 p_O, p_F = center_pa * u, center_pa * u * v
300 point = sampler(p_O, p_F)
301 if point is not None:
302 out.append({**point, "p_O": p_O, "p_F": p_F})
303 return out
304
305
306def _random(sampler: EngineSampler, center_pa: float, n: int, levels: Tuple[float, float],
307 ratios: Tuple[float, float], seed: int) -> List[Dict[str, float]]:
308 rng = np.random.default_rng(seed)
309 out = []
310 for u, v in zip(rng.uniform(*levels, n), rng.uniform(*ratios, n)):
311 p_O, p_F = center_pa * u, center_pa * u * v
312 point = sampler(p_O, p_F)
313 if point is not None:
314 out.append({**point, "p_O": p_O, "p_F": p_F})
315 return out
316
317
318from engine.layerx.fingerprint import config_fingerprint # noqa: E402,F401 - one definition
319
320
321def build_card(config: Any, *, center_pa: float, ambient_pa: float, holdout: int = 40, seed: int = 7) -> Any:
322 """Sample, fit, check. Returns an :class:`~feedtwin.engine.card.EngineCard` whose ``fit``
323 holds its measured errors (``envelope_*`` inside the operating envelope, ``box_*`` over the
324 whole scan) and whose ``provenance`` says how it was made."""
325 started = time.perf_counter()
326 sampler = EngineSampler(config, ambient_pa)
327 fast = sampler.fast
328 levels = LEVELS if fast else (LEVELS[0], LEVELS[1], 11)
329 ratios = RATIOS if fast else (RATIOS[0], RATIOS[1], 7)
330 samples = _scan(sampler, center_pa, levels, ratios)
331 if len(samples) < 12:
332 raise ValueError(f"EngineDesign solved only {len(samples)} of the card's scan points; "
333 "the engine has no operating region around this T-0")
334 cg = sampler.config.chamber_geometry
335 name = f"{getattr(config, 'name', None) or 'engine'} @ {center_pa / PSI:.1f} psia line exit"
336 card = fit_card(samples, throat_area=float(cg.A_throat), exit_area=float(cg.A_exit),
337 ambient_pa=ambient_pa, name=name)
338
339 n_hold = holdout if fast else max(holdout // 4, 6)
340 envelope = _random(sampler, center_pa, n_hold, ENVELOPE_LEVELS, ENVELOPE_RATIOS, seed)
341 box = _random(sampler, center_pa, n_hold // 2, (levels[0], levels[1]), (ratios[0], ratios[1]), seed + 1)
342 fit = {f"envelope_{k}": float(v) for k, v in check_card(card, envelope, ambient_pa).items()}
343 fit.update({f"box_{k}": float(v) for k, v in check_card(card, box, ambient_pa).items()})
344 worst = max(fit["envelope_closed_pc"], fit["envelope_closed_thrust"], fit["envelope_closed_mdot"],
345 fit["envelope_chamber_pc"], fit["envelope_chamber_thrust"], fit["envelope_dp_O"],
346 fit["envelope_dp_F"])
347 fit["envelope_worst"] = worst
348 fit["tolerance"] = TOLERANCE
349
350 from dataclasses import replace
351
352 provenance = {
353 "tool": "EngineDesign",
354 "config_sha256": config_fingerprint(config),
355 "boundary": BOUNDARY,
356 "sampler": sampler.kind,
357 "samples": len(samples),
358 "solves": sampler.calls,
359 "center_psia": center_pa / PSI,
360 "scan_levels": [levels[0], levels[1]],
361 "scan_ratios": [ratios[0], ratios[1]],
362 "envelope_levels": list(ENVELOPE_LEVELS),
363 "envelope_ratios": list(ENVELOPE_RATIOS),
364 "ambient_pa_sampled": ambient_pa,
365 "built_s": time.perf_counter() - started,
366 "built": time.time(),
367 "within_tolerance": worst <= TOLERANCE,
368 }
369 return replace(card, provenance=provenance, fit=fit)
370
371
372# ------------------------------------------------------------------ the nozzle following the throat
373
374
376 """A card chamber whose vacuum exhaust velocity follows the eroding throat (audit D4-B, opt-in).
377
378 Mixed into feedtwin's ``CardChamber`` as :data:`ExpansionCardChamber` (built on first use, so
379 this module still imports without feedtwin, as the rest of it does).
380
381 The card tabulates ``v_vac = (F + p_a A_e)/mdot`` at the as-built expansion ratio ``eps0``. When
382 the throat opens (``throat_area`` set per step from the replay's schedule) and the exit does
383 not, EngineDesign's nozzle runs at ``eps(t) = A_e/A_t(t) < eps0`` and its thrust is
384 ``zeta_n Cf_vac(eps(t)) P0 A_t - p_a A_e`` (engine/core/nozzle.py). Its flow is
385 ``mdot = P0 A_t / c*`` (chamber_solver.py), so ``v_vac = zeta_n Cf_vac c*``: at the same O/F and
386 flow it scales with ``Cf_vac`` alone, and this chamber multiplies the card's ``v_vac`` by
387 ``Cf_vac(O/F, Pc, eps(t)) / Cf_vac(O/F, Pc, eps0)`` from the CEA table EngineDesign uses
388 (shifting equilibrium, NASA CEA via rocketcea: Gordon & McBride, NASA RP-1311, 1994;
389 ``cf_vac`` is ``cea_cache.eval_cf_vac``). Without it the
390 twin's thrust ran +0.5 % high at burnout against a fresh EngineDesign solve at the eroded
391 geometry (audit D4, table "A. Keep").
392
393 Chamber pressure and flows are the card's exactly: c* does not depend on the nozzle. At the
394 as-built throat the result *is* :class:`CardChamber`'s, the same object, so a run that never
395 applies a throat schedule is unchanged bit for bit.
396
397 The ratio is read at the card's chamber pressure, where EngineDesign reads ``Cf_vac`` at the
398 nozzle's stagnation pressure ``Pc/kappa``; ``Cf_vac`` moves ~1e-5 relative over that 0.4 %
399 (LE4), far inside the card's fit. An ``eps`` outside the CEA table is clamped by the table and
400 counted in ``eps_clamped``.
401 """
402
403 def __init__(self, card: Any, cf_vac: Callable[[float, float, float], float], *,
404 ambient_pressure: float = 101325.0, volume: float = 0.0) -> None:
405 super().__init__(card, ambient_pressure=ambient_pressure, volume=volume)
406 self.cf_vac = cf_vac
407 self.eps0 = float(card.exit_area) / float(card.throat_area)
408 self.eps_bounds: Optional[Tuple[float, float]] = None
409 self.eps_clamped = 0
410 #: Whether the CEA table has an expansion-ratio axis (set by :func:`expansion_chamber`).
411 #: Without one a tabulated Cf_vac does not move with eps and the scale is 1: EngineDesign's
412 #: own thrust, read from the same table, is then equally blind to the eroding throat.
413 self.eps_axis: Optional[bool] = None
414
415 def expansion_scale(self, mixture_ratio: float, pressure: float) -> float:
416 """``Cf_vac(eps(t))/Cf_vac(eps0)`` at this O/F and pressure; exactly 1 at the as-built throat."""
417 if self.throat_area == self.card.throat_area:
418 return 1.0
419 eps = float(self.card.exit_area) / float(self.throat_area)
420 if self.eps_bounds is not None and not (self.eps_bounds[0] <= eps <= self.eps_bounds[1]):
421 self.eps_clamped += 1
422 return float(self.cf_vac(mixture_ratio, pressure, eps)) / float(self.cf_vac(mixture_ratio, pressure, self.eps0))
423
424 def model_block(self) -> Dict[str, Any]:
425 """The run record's description of this option (``{name, source, assumptions, inputs}``)."""
426 return {
427 "name": "card vacuum velocity scaled by Cf_vac(eps(t))/Cf_vac(eps0) (audit D4-B)",
428 "source": ("EngineDesign thrust model (engine/core/nozzle.py: F = zeta_n Cf_vac P0 A_t - p_a A_e, "
429 "mdot = P0 A_t / c*) and its CEA table (shifting equilibrium; Gordon & McBride, "
430 "NASA RP-1311, 1994, via rocketcea)"),
431 "assumptions": [
432 "A_e fixed (the nozzle does not ablate); eps(t) = A_e / A_t(t) from the applied throat schedule",
433 "zeta_n, c* and the card's fit unchanged by the throat: only the nozzle's expansion moves",
434 "Cf_vac read at the card's chamber pressure, not P0 = Pc/kappa (~1e-5 relative on LE4)",
435 "an eps outside the CEA table is clamped by the table (counted in eps_clamped)",
436 ],
437 "inputs": {
438 "eps0": {"value": self.eps0, "unit": "", "provenance": "card exit_area / throat_area (as built)"},
439 "cea_eps_axis": {"value": self.eps_axis, "unit": "",
440 "provenance": "cea_cache.use_3d (a 2-D table holds Cf_vac at the design eps only)"},
441 "eps_clamped": {"value": self.eps_clamped, "unit": "evaluations",
442 "provenance": "derived during the run"},
443 },
444 }
445
446 def evaluate(self, mdot_oxidiser: float, mdot_fuel: float) -> Any:
447 res = super().evaluate(mdot_oxidiser, mdot_fuel)
448 firing = res.pressure > self.ambient_pressure * 1.001
449 if not firing or res.mdot_total <= 0.0 or self.throat_area == self.card.throat_area:
450 return res
451 from dataclasses import replace
452
453 from feedtwin.engine.chamber import GRAVITY, burning
454
455 total = res.mdot_total
456 # The flow that burns, as CardChamber prices it: outside the card's O/F range the excess of one
457 # propellant passes the throat unburned and carries no vacuum velocity. Pricing thrust on the
458 # total gave kN where the card says unlit or lean (O/F 20: 2489 N against 0) once a throat
459 # schedule was applied; in range the two are the same flow, and the nominal burn is unchanged.
460 table = self.card.chamber.cstar
461 burned = burning(res.mdot_oxidiser, res.mdot_fuel, (table.x0, table.x_max))
462 scale = self.expansion_scale(res.mixture_ratio, res.pressure)
463 v_vac = self.card.chamber.vacuum_velocity(res.mixture_ratio, burned) * scale
464 thrust = max(burned * v_vac - self.ambient_pressure * self.card.exit_area, 0.0)
465
466 return replace(
467 res,
468 thrust=thrust,
469 specific_impulse=thrust / (total * GRAVITY) if thrust > 0.0 else 0.0,
470 combustion=replace(res.combustion, thrust_coefficient=thrust / (res.pressure * self.throat_area)),
471 )
472
473
474_EXPANSION_CLASS: Optional[type] = None
475
476
478 """``ExpansionCardChamber``: :class:`_ExpansionScaling` over feedtwin's ``CardChamber``."""
479 global _EXPANSION_CLASS
480 if _EXPANSION_CLASS is None:
481 from feedtwin.engine.card import CardChamber
482
483 _EXPANSION_CLASS = type("ExpansionCardChamber", (_ExpansionScaling, CardChamber),
484 {"__doc__": _ExpansionScaling.__doc__, "__module__": __name__})
485 return _EXPANSION_CLASS
486
487
488def __getattr__(name: str) -> Any: # PEP 562: ``from engine.layerx.card import ExpansionCardChamber``
489 if name == "ExpansionCardChamber":
491 raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
492
493
494def expansion_chamber(card: Any, cea_cache: Any, *, ambient_pressure: float, volume: float = 0.0) -> Any:
495 """``ExpansionCardChamber`` on EngineDesign's CEA table (``cea_cache.eval_cf_vac``): the chamber
496 to put in the twin in place of ``card.chamber_model(...)`` when the option is on."""
497 ch = expansion_chamber_class()(card, cea_cache.eval_cf_vac, ambient_pressure=ambient_pressure, volume=volume)
498 lo, hi = getattr(cea_cache, "eps_min", None), getattr(cea_cache, "eps_max", None)
499 ch.eps_axis = bool(getattr(cea_cache, "use_3d", False))
500 if lo is not None and hi is not None and ch.eps_axis:
501 ch.eps_bounds = (float(lo), float(hi))
502 return ch
503
504
505# ------------------------------------------------------------------ a small cache
506
507_CACHE: "OrderedDict[Tuple[str, float, float], Any]" = OrderedDict()
508_CACHE_LOCK = threading.Lock()
509_CACHE_SIZE = 8
510
511
512def card_for(config: Any, *, center_pa: float, ambient_pa: float) -> Any:
513 """:func:`build_card`, remembered per (config, T-0, ambient). Preflight and the run that follows
514 it share one card; a changed config is a different key, so a stale card is never served."""
515 key = (config_fingerprint(config), round(center_pa, 1), round(ambient_pa, 1))
516 with _CACHE_LOCK:
517 if key in _CACHE:
518 _CACHE.move_to_end(key)
519 return _CACHE[key]
520 card = build_card(config, center_pa=center_pa, ambient_pa=ambient_pa)
521 with _CACHE_LOCK:
522 _CACHE[key] = card
523 while len(_CACHE) > _CACHE_SIZE:
524 _CACHE.popitem(last=False)
525 return card
526
527
528# ------------------------------------------------------------------ a card for another tool
529
530
531def card_for_config_text(text: str, *, center_psia: Optional[float] = None) -> Dict[str, Any]:
532 """The engine card for an engine config given as YAML text: what the feed-twin cockpit asks
533 for when it imports an engine, so the stand fires the engine EngineDesign designed rather than
534 feedtwin's own simplified one.
535
536 Centred on the config's tank pressure (``lox_tank.initial_pressure_psi``) unless told
537 otherwise; the scan then covers 40-130 % of it, which is every dome the cockpit's regulator
538 can be set to. Sampled at the site's ambient (``environment.elevation``), as Layer X does.
539 """
540 import tempfile
541 from pathlib import Path
542
543 from engine.core.runner import compute_ambient_pressure_from_elevation
544 from engine.pipeline.io import load_config
545
546 if center_psia is not None and not center_psia > 0.0:
547 # The caller's own number, said as theirs: it read as "the config states no tank pressure" though
548 # the config stated 578 psia and the caller had sent -5.
549 raise ValueError(f"center_psia {center_psia!r} is not an absolute pressure: give one above 0 psia, or "
550 "leave it out to centre the card on the config's tank pressure")
551 with tempfile.TemporaryDirectory() as tmp:
552 path = Path(tmp) / "engine.yaml"
553 path.write_text(text, encoding="utf-8")
554 config = load_config(path)
555 if center_psia is None:
556 center_psia = float(getattr(config.lox_tank, "initial_pressure_psi", 0.0) or 0.0)
557 if not center_psia or center_psia <= 0.0:
558 raise ValueError("the config states no tank pressure (lox_tank.initial_pressure_psi) to centre the card on")
559 elevation = float(getattr(getattr(config, "environment", None), "elevation", 0.0) or 0.0)
560 ambient = compute_ambient_pressure_from_elevation(elevation) if elevation > 0 else 101325.0
561 card = card_for(config, center_pa=center_psia * PSI, ambient_pa=ambient)
562 return {
563 "card": card.to_dict(),
564 "config_sha256": config_fingerprint(config),
565 "center_psia": center_psia,
566 "ambient_pa": float(ambient),
567 "within_tolerance": bool(card.provenance.get("within_tolerance", False)),
568 "envelope_worst": float(card.fit.get("envelope_worst", float("nan"))),
569 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None __init__(self, Any config, float ambient_pa)
Definition card.py:119
Optional[Dict[str, float]] __call__(self, float p_O, float p_F)
Definition card.py:137
Any evaluate(self, float mdot_oxidiser, float mdot_fuel)
Definition card.py:446
None __init__(self, Any card, Callable[[float, float, float], float] cf_vac, *, float ambient_pressure=101325.0, float volume=0.0)
Definition card.py:404
float expansion_scale(self, float mixture_ratio, float pressure)
Definition card.py:415
Optional[Tuple[float, float]] eps_bounds
Definition card.py:408
Dict[str, Any] model_block(self)
Definition card.py:424
fit_card(Sequence[Dict[str, float]] samples, *, float throat_area, float exit_area, float ambient_pa, str name)
Definition card.py:190
_table(fn, np.ndarray x_axis, np.ndarray y_axis, Callable[[np.ndarray], np.ndarray] transform)
Definition card.py:166
np.ndarray _axis(np.ndarray values, int n)
Definition card.py:184
type expansion_chamber_class()
Definition card.py:477
List[Dict[str, float]] _random(EngineSampler sampler, float center_pa, int n, Tuple[float, float] levels, Tuple[float, float] ratios, int seed)
Definition card.py:307
_rbf(np.ndarray points, np.ndarray values)
Definition card.py:156
Any __getattr__(str name)
Definition card.py:488
Dict[str, Any] card_for_config_text(str text, *, Optional[float] center_psia=None)
Definition card.py:531
Any build_card(Any config, *, float center_pa, float ambient_pa, int holdout=40, int seed=7)
Definition card.py:321
Any card_for(Any config, *, float center_pa, float ambient_pa)
Definition card.py:512
Any expansion_chamber(Any card, Any cea_cache, *, float ambient_pressure, float volume=0.0)
Definition card.py:494
Any line_exit_config(Any config)
Definition card.py:86
Tuple[Tuple[float, float],...] _hull(np.ndarray points)
Definition card.py:177
Dict[str, float] check_card(Any card, Sequence[Dict[str, float]] points, float ambient_pa)
Definition card.py:260
List[Dict[str, float]] _scan(EngineSampler sampler, float center_pa, Tuple[float, float, int] levels, Tuple[float, float, int] ratios)
Definition card.py:295
Optional[Dict[str, float]] solve_card(Any card, float p_O, float p_F, float ambient_pa)
Definition card.py:228
TOML_NODISCARD_CTOR time() noexcept=default
static ArmGate g(kMe)