1"""EngineDesign's engine as an engine card for the feed twin (Layer X, phase 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.
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.
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:
18* the plate passages and their Cd;
19* the spray, mixing and heat-loss efficiency;
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.
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
37The fit is thin-plate RBF through the samples, resampled onto uniform grids, which the twin
38evaluates with Catmull-Rom.
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.
46from __future__
import annotations
52from collections
import OrderedDict
53from typing
import Any, Callable, Dict, List, Optional, Sequence, Tuple
57PSI = 6894.757293168361
65LEVELS = (0.40, 1.30, 21)
66RATIOS = (0.75, 1.30, 13)
73ENVELOPE_LEVELS = (0.70, 1.15)
74ENVELOPE_RATIOS = (0.90, 1.12)
76CHAMBER_GRID = (41, 41)
77INJECTOR_GRID = (41, 21)
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")
87 """A copy of ``config`` whose feed lines lose nothing but the exit dump into the manifold.
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)
101 side.phi_type =
"none"
102 if getattr(side,
"supply_K",
None):
104 if getattr(side,
"fittings",
None):
106 if getattr(side,
"roughness_m",
None)
is not None:
107 side.roughness_m =
None
112 """EngineDesign at the line exit, one operating point per call.
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.
119 def __init__(self, config: Any, ambient_pa: float) ->
None:
120 from engine
import accel
129 self.
fast = bool(accel.enabled()
and accel.can_handle_chamber(self.
config)
130 and getattr(self.
runner.cea_cache,
"use_3d",
False))
135 return "numba chamber kernel" if self.
fast else "Python chamber solve"
137 def __call__(self, p_O: float, p_F: float) -> Optional[Dict[str, float]]:
140 from engine
import accel
143 if point
is not None:
146 res = self.
runner.evaluate(p_O, p_F, silent=
True, P_ambient=self.
ambient_pa)
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"])}
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
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)
166def _table(fn, x_axis: np.ndarray, y_axis: np.ndarray, transform: Callable[[np.ndarray], np.ndarray]):
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))
177def _hull(points: np.ndarray) -> Tuple[Tuple[float, float], ...]:
178 from scipy.spatial
import ConvexHull
180 h = ConvexHull(points)
181 return tuple((float(points[k, 0]), float(points[k, 1]))
for k
in h.vertices)
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)
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``)."""
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
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])]
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]),
213 def injector(m: np.ndarray, p: np.ndarray) -> InjectorCard:
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]))
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)
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
234 chamber = card.chamber_model(ambient_pressure=ambient_pa)
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)
240 m_new = side.capacity(m, p) * math.sqrt(dp)
241 if abs(m_new - m) <= 1e-13 *
max(m, 1e-12):
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
250 hi =
min(p_O, p_F) * (1.0 - 1e-9)
251 lo = ambient_pa * 1.01
252 if g(lo) *
g(hi) > 0:
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}
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).
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.
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")}
272 def bump(key: str, got: float, want: float) ->
None:
274 worst[key] =
max(worst[key], abs(got / want - 1.0))
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)
284 worst[
"closed_pc"] =
max(worst[
"closed_pc"], 1.0)
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))
294def _scan(sampler: EngineSampler, center_pa: float, levels: Tuple[float, float, int],
295 ratios: Tuple[float, float, int]) -> List[Dict[str, float]]:
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})
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)
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})
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()
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)
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
350 from dataclasses
import replace
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,
367 "within_tolerance": worst <= TOLERANCE,
369 return replace(card, provenance=provenance, fit=fit)
376 """A card chamber whose vacuum exhaust velocity follows the eroding throat (audit D4-B, opt-in).
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).
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").
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.
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``.
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)
407 self.
eps0 = float(card.exit_area) / float(card.throat_area)
416 """``Cf_vac(eps(t))/Cf_vac(eps0)`` at this O/F and pressure; exactly 1 at the as-built throat."""
419 eps = float(self.card.exit_area) / float(self.
throat_area)
422 return float(self.
cf_vac(mixture_ratio, pressure, eps)) / float(self.
cf_vac(mixture_ratio, pressure, self.
eps0))
425 """The run record's description of this option (``{name, source, assumptions, inputs}``)."""
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)"),
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)",
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"},
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:
451 from dataclasses
import replace
455 total = res.mdot_total
460 table = self.card.chamber.cstar
461 burned = burning(res.mdot_oxidiser, res.mdot_fuel, (table.x0, table.x_max))
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)
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)),
474_EXPANSION_CLASS: Optional[type] =
None
478 """``ExpansionCardChamber``: :class:`_ExpansionScaling` over feedtwin's ``CardChamber``."""
479 global _EXPANSION_CLASS
480 if _EXPANSION_CLASS
is None:
483 _EXPANSION_CLASS = type(
"ExpansionCardChamber", (_ExpansionScaling, CardChamber),
484 {
"__doc__": _ExpansionScaling.__doc__,
"__module__": __name__})
485 return _EXPANSION_CLASS
489 if name ==
"ExpansionCardChamber":
491 raise AttributeError(f
"module {__name__!r} has no attribute {name!r}")
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."""
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))
507_CACHE:
"OrderedDict[Tuple[str, float, float], Any]" = OrderedDict()
508_CACHE_LOCK = threading.Lock()
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))
518 _CACHE.move_to_end(key)
520 card =
build_card(config, center_pa=center_pa, ambient_pa=ambient_pa)
523 while len(_CACHE) > _CACHE_SIZE:
524 _CACHE.popitem(last=
False)
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.
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.
541 from pathlib
import Path
546 if center_psia
is not None and not center_psia > 0.0:
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)
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"))),
None __init__(self, Any config, float ambient_pa)
Optional[Dict[str, float]] __call__(self, float p_O, float p_F)
Any evaluate(self, float mdot_oxidiser, float mdot_fuel)
None __init__(self, Any card, Callable[[float, float, float], float] cf_vac, *, float ambient_pressure=101325.0, float volume=0.0)
float expansion_scale(self, float mixture_ratio, float pressure)
Optional[Tuple[float, float]] eps_bounds
Dict[str, Any] model_block(self)
fit_card(Sequence[Dict[str, float]] samples, *, float throat_area, float exit_area, float ambient_pa, str name)
_table(fn, np.ndarray x_axis, np.ndarray y_axis, Callable[[np.ndarray], np.ndarray] transform)
np.ndarray _axis(np.ndarray values, int n)
type expansion_chamber_class()
List[Dict[str, float]] _random(EngineSampler sampler, float center_pa, int n, Tuple[float, float] levels, Tuple[float, float] ratios, int seed)
_rbf(np.ndarray points, np.ndarray values)
Any __getattr__(str name)
Dict[str, Any] card_for_config_text(str text, *, Optional[float] center_psia=None)
Any build_card(Any config, *, float center_pa, float ambient_pa, int holdout=40, int seed=7)
Any card_for(Any config, *, float center_pa, float ambient_pa)
Any expansion_chamber(Any card, Any cea_cache, *, float ambient_pressure, float volume=0.0)
Any line_exit_config(Any config)
Tuple[Tuple[float, float],...] _hull(np.ndarray points)
Dict[str, float] check_card(Any card, Sequence[Dict[str, float]] points, float ambient_pa)
List[Dict[str, float]] _scan(EngineSampler sampler, float center_pa, Tuple[float, float, int] levels, Tuple[float, float, int] ratios)
Optional[Dict[str, float]] solve_card(Any card, float p_O, float p_F, float ambient_pa)
TOML_NODISCARD_CTOR time() noexcept=default