1"""Numba-backed physics accelerator for the Layer-1 optimizer inner loop.
3Replaced the hand-written C port that used to live at engine/native (deleted once
4this reached parity and then overtook it). Every entry point returns None rather
5than raising when it cannot handle a config, because every caller treats None as
6"fall back to the authoritative Python path".
8Set ED_ACCEL=off to disable the accelerator entirely; everything then runs on the
9Python physics, which stays authoritative and is what the parity suite diffs
12from __future__
import annotations
17__all__ = [
"available",
"enabled",
"can_handle",
"can_handle_chamber",
18 "chamber_physics_not_mirrored",
19 "evaluate",
"solve",
"chamber_solve",
"warmup",
"require",
20 "chug_margin_fast",
"Outcome",
21 "evaluate_ex",
"solve_ex",
"chamber_solve_ex"]
25 """Why an accelerated call did not return a result.
27 These two failures look identical to callers today -- both surface as a bare
28 None -- but they mean opposite things:
30 NOT_HANDLED the accelerator has no implementation for this config (wrong
31 injector type, film/regen cooling, a non-3D CEA cache). Python
32 is the ONLY implementation, so falling back is mandatory and
33 the fallback does real work.
35 NO_SOLUTION the physics ran and did not converge. Measured over 102 such
36 candidates, the Python path then failed on every one -- it
37 re-derives "infeasible" at full cost and also gives up. So this
38 fallback is (almost always) wasted work.
40 Conflating them is what made that impossible to see or measure. The public
41 evaluate/solve/chamber_solve keep returning None exactly as before -- every
42 caller keys on `is None` and none of them change -- while the *_ex variants
43 expose the reason for instrumentation and for any future short-circuit.
47 NOT_HANDLED =
"not_handled"
48 NO_SOLUTION =
"no_solution"
52 """False (never raises) when numba is absent, so a missing dep degrades to Python."""
61 if os.environ.get(
"ED_ACCEL",
"numba") ==
"off":
63 if os.environ.get(
"ED_USE_NATIVE") ==
"0":
69 """Strict mode: a genuine accelerator failure raises instead of falling back.
71 Without it a broken accelerator is invisible -- every caller falls back to
72 Python and the suite passes green on the wrong path.
74 return os.environ.get(
"ED_REQUIRE_ACCEL") ==
"1"
78 """Impinging and pintle; regen-coupled feed loss is not ported for either."""
79 inj = getattr(config,
"injector",
None)
80 if inj
is None or inj.type
not in (
"impinging",
"pintle"):
82 regen = getattr(config,
"regen_cooling",
None)
83 if regen
is not None and getattr(regen,
"enabled",
False):
85 if inj.type ==
"impinging":
88 spray = getattr(config,
"spray",
None)
89 if spray
is not None and tn4222_transfer_model(spray.smd)
not in (
"tn4087",
"none"):
91 fs = getattr(config,
"feed_system",
None)
or {}
92 for side
in (
"oxidizer",
"fuel"):
93 if getattr(fs.get(side),
"roughness_m",
None)
is not None:
98 if inj.type ==
"pintle":
102 for side
in (
"oxidizer",
"fuel"):
103 if getattr(config.discharge[side],
"use_geometry_cd",
False):
110 """Python chamber physics engine.accel.chamber does not reproduce (empty: none).
112 Kept as the seam the parity suite and Layer 1 read: an entry here would send every chamber
113 call to the Python solve, which is slower but never different physics."""
118 """Adds the chamber gates on top of can_handle(): film and regen cooling are not mirrored
119 (their heat returns to the propellant and feeds the injector), nor the legacy efficiency
120 switch. The per-config pieces (the wide c* table, the mixing and blowing models) are checked
121 by chamber.chamber_inputs, which returns None for anything it does not mirror."""
124 fc = getattr(config,
"film_cooling",
None)
125 if fc
is not None and getattr(fc,
"enabled",
False):
127 eff = config.combustion.efficiency
128 if not getattr(eff,
"use_advanced_model",
True):
134 """(P vector, result vector, Outcome) of one accelerated chamber solve."""
140 return None,
None, Outcome.NOT_HANDLED
141 if not getattr(cache,
"use_3d",
False):
142 return None,
None, Outcome.NOT_HANDLED
144 P = _p.extract_params(config)
145 except AssertionError:
146 return None,
None, Outcome.NOT_HANDLED
147 CH = _ch.chamber_inputs(config, cache)
149 return None,
None, Outcome.NOT_HANDLED
150 arr = _k._cea_arrays_cached(cache)
151 ok, res = _ch.evaluate_core(P, CH, arr, float(P_tank_O), float(P_tank_F), float(P_ambient))
153 return P,
None, Outcome.NO_SOLUTION
154 return P, res, Outcome.OK
158 """Chamber and nozzle at one point, without diagnostics or stability: (dict | None, Outcome).
160 For callers that sample the engine hundreds of times and need only the operating point --
161 Layer X's engine card (engine/layerx/card.py). Same root as evaluate(); nothing here is a
162 second solve that could drift from it.
164 P, res, oc =
_chamber_run(config, cache, P_tank_O, P_tank_F, P_ambient)
165 if oc
is not Outcome.OK:
170 g =
lambda k: float(res[R[k]])
171 return {
"Pc":
g(
"PC"),
"mdot_O":
g(
"MDOT_O"),
"mdot_F":
g(
"MDOT_F"),
"MR":
g(
"MR"),
172 "F":
g(
"F"),
"Isp":
g(
"ISP"),
"P_exit":
g(
"P_EXIT"),
"cstar_actual":
g(
"CSTAR"),
173 "cstar_ideal":
g(
"CSTAR_IDEAL"),
"eta_cstar":
g(
"ETA")}, Outcome.OK
176def evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0):
177 """Single-call chamber + nozzle + thrust + stability. None => caller falls back."""
178 return evaluate_ex(config, cache, P_tank_O, P_tank_F, P_ambient)[0]
181def evaluate_ex(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0):
182 """As evaluate(), but returns (result_or_None, Outcome)."""
188 P, res, oc =
_chamber_run(config, cache, P_tank_O, P_tank_F, P_ambient)
189 if oc
is not Outcome.OK:
192 Pc = float(res[R[
"PC"]])
193 sol = _k._solve_injector(P, float(P_tank_O), float(P_tank_F), Pc)
195 return None, Outcome.NO_SOLUTION
196 diag = _diag.build_diag(P, sol, config, Pc)
197 g =
lambda k: float(res[R[k]])
198 mO, mF, mdt =
g(
"MDOT_O"),
g(
"MDOT_F"),
g(
"MDOT")
200 "mdot_O": mO,
"mdot_F": mF,
"mdot_total": mdt,
"Pc": Pc,
"MR":
g(
"MR"),
201 "cstar_ideal":
g(
"CSTAR_IDEAL"),
"cstar_actual":
g(
"CSTAR"),
"eta_cstar":
g(
"ETA"),
202 "gamma":
g(
"GAMMA"),
"R":
g(
"R"),
"Tc":
g(
"TC_EFF"),
"Tc_ideal":
g(
"TC_IDEAL"),
203 "M":
g(
"M_MOL"),
"Pc_ns": Pc /
g(
"KAPPA"),
"stagnation_loss_kappa":
g(
"KAPPA"),
204 "SMD":
max(sol[5], sol[6]),
205 "cstar_efficiency": {
206 "eta_cstar":
g(
"ETA"),
"eta_vaporization":
g(
"ETA_VAP"),
"eta_mixing":
g(
"ETA_MIX"),
207 "eta_heat_loss":
g(
"ETA_HL"),
"heat_lost_W":
g(
"Q_ABL"),
208 "fraction_vaporized":
g(
"F_VAP"),
"rupe_Em":
g(
"EM"),
212 stab = comprehensive_stability_analysis(
213 config=config, Pc=Pc, MR=
g(
"MR"), mdot_total=mdt,
214 cstar=
g(
"CSTAR"), gamma=
g(
"GAMMA"), R=
g(
"R"), Tc=
g(
"TC_EFF"), diagnostics=diag)
216 return None, Outcome.NO_SOLUTION
218 "Pc": Pc,
"mdot_O": mO,
"mdot_F": mF,
"mdot_total": mdt,
"MR":
g(
"MR"),
219 "F":
g(
"F"),
"Isp":
g(
"ISP"),
"v_exit":
g(
"V_EXIT"),
"P_exit":
g(
"P_EXIT"),
220 "P_throat":
g(
"P_THROAT"),
"T_exit":
g(
"T_EXIT"),
"T_throat":
g(
"T_THROAT"),
221 "M_exit":
g(
"M_EXIT"),
"Tc":
g(
"TC_EFF"),
222 "eps": float(config.chamber_geometry.expansion_ratio),
223 "A_throat": float(config.chamber_geometry.A_throat),
224 "A_exit": float(config.chamber_geometry.A_exit),
225 "cstar_actual":
g(
"CSTAR"),
"cstar_ideal":
g(
"CSTAR_IDEAL"),
"eta_cstar":
g(
"ETA"),
226 "gamma":
g(
"GAMMA"),
"R":
g(
"R"),
227 "Cf":
g(
"CF"),
"Cf_actual":
g(
"CF"),
"Cf_ideal":
g(
"CF_IDEAL"),
228 "Cd_O": sol[8],
"Cd_F": sol[9],
"A_geom_O": sol[14],
"A_geom_F": sol[15],
229 "stability": stab,
"stability_results": stab,
230 "diagnostics": diag,
"P_ambient": float(P_ambient),
231 "native_fast_eval":
True,
"numba_fast_eval":
True,
235def solve(config, P_tank_O, P_tank_F, Pc):
236 """Injector mass flows at a given Pc -> (mdot_O, mdot_F, diagnostics), or None."""
237 return solve_ex(config, P_tank_O, P_tank_F, Pc)[0]
241 """As solve(), but returns (result_or_None, Outcome).
243 Sits on the FALLBACK path: closure.flows
244 calls it on every residual iteration of the Python chamber solve, so it runs
245 far more often than evaluate() does.
247 The param vector is rebuilt per call rather than cached on the config, exactly
248 as the C path rebuilds its state per call. Caching would be wrong here: Layer 1
249 mutates the worker's config in place between candidates
250 (_apply_x_to_worker_config_inplace), so a config-keyed cache would serve stale
258 return None, Outcome.NOT_HANDLED
260 P = _p.extract_params(config)
261 except AssertionError:
262 return None, Outcome.NOT_HANDLED
263 sol = _k._solve_injector(P, float(P_tank_O), float(P_tank_F), float(Pc))
265 return None, Outcome.NO_SOLUTION
266 return (float(sol[1]), float(sol[2]), _diag.build_diag(P, sol, config, Pc)), Outcome.OK
270 """Whole chamber residual loop -> (Pc, diagnostics), or None."""
275 """As chamber_solve(), but returns (result_or_None, Outcome).
277 The only consumer (chamber_solver._accel_chamber_pc) reads element 0. Shares
278 chamber.evaluate_core's root with evaluate(): a second, subtly different root-find is how
279 the two paths would drift apart."""
282 P, res, oc =
_chamber_run(config, cache, P_tank_O, P_tank_F, 101325.0)
283 if oc
is not Outcome.OK:
286 Pc = float(res[R[
"PC"]])
287 if not (Pc > 0.0)
or Pc != Pc:
288 return None, Outcome.NO_SOLUTION
289 g =
lambda k: float(res[R[k]])
290 return (Pc, {
"Pc": Pc,
"mdot_O":
g(
"MDOT_O"),
"mdot_F":
g(
"MDOT_F"),
"mdot_total":
g(
"MDOT"),
291 "MR":
g(
"MR"),
"cstar_ideal":
g(
"CSTAR_IDEAL"),
"cstar_actual":
g(
"CSTAR"),
292 "eta_cstar":
g(
"ETA"),
"gamma":
g(
"GAMMA"),
"R":
g(
"R"),
"Tc":
g(
"TC_EFF"),
293 "Tc_ideal":
g(
"TC_IDEAL"),
"converged":
True}), Outcome.OK
297 """Force the JIT to compile/load before a ProcessPool is built.
299 @njit(cache=True) persists compiled code, but each worker process still
300 deserializes it on first call -- un-warmed, that lands inside the first CMA
301 generation and skews it. Call this in the parent AND in the pool's worker
304 Never raises: a warmup failure must not block optimization, exactly as the
316 """Chug gain/phase margin. Dispatches like the rest of this surface.
318 fast_acoustic deliberately has no counterpart here: it measured 10.5 us in
319 Python against 4.2 us in C, and acoustic.fast_acoustic has no loop to compile.
320 A 6 us difference does not justify a kernel, so that path stays pure Python.
323 return _stab.chug_margin_fast(streams, chamber, **kw)
solve_ex(config, P_tank_O, P_tank_F, Pc)
chamber_solve(config, cache, P_tank_O, P_tank_F)
evaluate_ex(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0)
tuple chamber_physics_not_mirrored(config=None)
chamber_solve_ex(config, cache, P_tank_O, P_tank_F)
solve(config, P_tank_O, P_tank_F, Pc)
_chamber_run(config, cache, P_tank_O, P_tank_F, P_ambient)
bool can_handle_chamber(config)
chamber_point(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0)
chug_margin_fast(streams, chamber, **kw)
evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0)