STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
__init__.py
Go to the documentation of this file.
1"""Numba-backed physics accelerator for the Layer-1 optimizer inner loop.
2
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".
7
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
10against.
11"""
12from __future__ import annotations
13
14import enum
15import os
16
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"]
22
23
24class Outcome(enum.Enum):
25 """Why an accelerated call did not return a result.
26
27 These two failures look identical to callers today -- both surface as a bare
28 None -- but they mean opposite things:
29
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.
34
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.
39
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.
44 """
45
46 OK = "ok"
47 NOT_HANDLED = "not_handled"
48 NO_SOLUTION = "no_solution"
49
50
51def available() -> bool:
52 """False (never raises) when numba is absent, so a missing dep degrades to Python."""
53 try:
54 import numba # noqa: F401
55 except Exception:
56 return False
57 return True
58
59
60def enabled() -> bool:
61 if os.environ.get("ED_ACCEL", "numba") == "off":
62 return False
63 if os.environ.get("ED_USE_NATIVE") == "0": # historical switch, still honoured
64 return False
65 return available()
66
67
68def require() -> bool:
69 """Strict mode: a genuine accelerator failure raises instead of falling back.
70
71 Without it a broken accelerator is invisible -- every caller falls back to
72 Python and the suite passes green on the wrong path.
73 """
74 return os.environ.get("ED_REQUIRE_ACCEL") == "1"
75
76
77def can_handle(config) -> bool:
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"):
81 return False # coaxial has no port
82 regen = getattr(config, "regen_cooling", None)
83 if regen is not None and getattr(regen, "enabled", False):
84 return False # regen-coupled feed loss not ported
85 if inj.type == "impinging":
86 # kernels._tn4222 mirrors the 'tn4087' and 'none' TN 4222 property transfers only.
87 from engine.core.spray import tn4222_transfer_model
88 spray = getattr(config, "spray", None)
89 if spray is not None and tn4222_transfer_model(spray.smd) not in ("tn4087", "none"):
90 return False
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:
94 # Colebrook friction at the per-call Re needs the liquid viscosity inside
95 # kernels._dpf, which it is not passed; feed_loss.delta_p_feed runs it in Python.
96 # (Fittings ARE ported: params._feed folds them into K0.)
97 return False
98 if inj.type == "pintle":
99 # PintleInjector.solve calls cd_from_re WITHOUT an orifice diameter, so a
100 # config with geometry-based Cd enabled resolves Cd_inf differently there
101 # than the kernel would. Untested corner: hand it to Python.
102 for side in ("oxidizer", "fuel"):
103 if getattr(config.discharge[side], "use_geometry_cd", False):
104 return False
105 return True
106
107
108
109def chamber_physics_not_mirrored(config=None) -> tuple:
110 """Python chamber physics engine.accel.chamber does not reproduce (empty: none).
111
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."""
114 return ()
115
116
117def can_handle_chamber(config) -> bool:
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."""
122 if not can_handle(config):
123 return False
124 fc = getattr(config, "film_cooling", None)
125 if fc is not None and getattr(fc, "enabled", False):
126 return False
127 eff = config.combustion.efficiency
128 if not getattr(eff, "use_advanced_model", True):
129 return False
130 return True
131
132
133def _chamber_run(config, cache, P_tank_O, P_tank_F, P_ambient):
134 """(P vector, result vector, Outcome) of one accelerated chamber solve."""
135 from engine.accel import chamber as _ch
136 from engine.accel import kernels as _k
137 from engine.accel import params as _p
138
139 if not can_handle_chamber(config):
140 return None, None, Outcome.NOT_HANDLED
141 if not getattr(cache, "use_3d", False):
142 return None, None, Outcome.NOT_HANDLED
143 try:
144 P = _p.extract_params(config)
145 except AssertionError:
146 return None, None, Outcome.NOT_HANDLED # config outside the ported subset
147 CH = _ch.chamber_inputs(config, cache)
148 if CH is None:
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))
152 if not ok:
153 return P, None, Outcome.NO_SOLUTION
154 return P, res, Outcome.OK
155
156
157def chamber_point(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0):
158 """Chamber and nozzle at one point, without diagnostics or stability: (dict | None, Outcome).
159
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.
163 """
164 P, res, oc = _chamber_run(config, cache, P_tank_O, P_tank_F, P_ambient)
165 if oc is not Outcome.OK:
166 return None, oc
167 from engine.accel import chamber as _ch
168
169 R = _ch._R
170 g = lambda k: float(res[R[k]]) # noqa: E731
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
174
175
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]
179
180
181def evaluate_ex(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0):
182 """As evaluate(), but returns (result_or_None, Outcome)."""
183 from engine.accel import chamber as _ch
184 from engine.accel import diagnostics as _diag
185 from engine.accel import kernels as _k
186 from engine.pipeline.stability.analysis import comprehensive_stability_analysis
187
188 P, res, oc = _chamber_run(config, cache, P_tank_O, P_tank_F, P_ambient)
189 if oc is not Outcome.OK:
190 return None, oc
191 R = _ch._R
192 Pc = float(res[R["PC"]])
193 sol = _k._solve_injector(P, float(P_tank_O), float(P_tank_F), Pc)
194 if not sol[0]:
195 return None, Outcome.NO_SOLUTION
196 diag = _diag.build_diag(P, sol, config, Pc)
197 g = lambda k: float(res[R[k]]) # noqa: E731
198 mO, mF, mdt = g("MDOT_O"), g("MDOT_F"), g("MDOT")
199 diag.update({
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"),
209 },
210 })
211 try:
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)
215 except Exception:
216 return None, Outcome.NO_SOLUTION
217 return {
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,
232 }, Outcome.OK
233
234
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]
238
239
240def solve_ex(config, P_tank_O, P_tank_F, Pc):
241 """As solve(), but returns (result_or_None, Outcome).
242
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.
246
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
251 geometry.
252 """
253 from engine.accel import diagnostics as _diag
254 from engine.accel import kernels as _k
255 from engine.accel import params as _p
256
257 if not can_handle(config):
258 return None, Outcome.NOT_HANDLED
259 try:
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))
264 if not sol[0]:
265 return None, Outcome.NO_SOLUTION
266 return (float(sol[1]), float(sol[2]), _diag.build_diag(P, sol, config, Pc)), Outcome.OK
267
268
269def chamber_solve(config, cache, P_tank_O, P_tank_F):
270 """Whole chamber residual loop -> (Pc, diagnostics), or None."""
271 return chamber_solve_ex(config, cache, P_tank_O, P_tank_F)[0]
272
273
274def chamber_solve_ex(config, cache, P_tank_O, P_tank_F):
275 """As chamber_solve(), but returns (result_or_None, Outcome).
276
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."""
280 from engine.accel import chamber as _ch
281
282 P, res, oc = _chamber_run(config, cache, P_tank_O, P_tank_F, 101325.0)
283 if oc is not Outcome.OK:
284 return None, oc
285 R = _ch._R
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]]) # noqa: E731
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
294
295
296def warmup():
297 """Force the JIT to compile/load before a ProcessPool is built.
298
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
302 initialiser.
303
304 Never raises: a warmup failure must not block optimization, exactly as the
305 C prewarm didn't.
306 """
307 try:
308 from engine.accel import chamber as _ch
309 _ch.warmup()
310 return True
311 except Exception:
312 return False
313
314
315def chug_margin_fast(streams, chamber, **kw):
316 """Chug gain/phase margin. Dispatches like the rest of this surface.
317
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.
321 """
322 from engine.accel import stability as _stab
323 return _stab.chug_margin_fast(streams, chamber, **kw)
#define max(a, b)
Definition Arduino.h:43
bool require()
Definition __init__.py:68
solve_ex(config, P_tank_O, P_tank_F, Pc)
Definition __init__.py:240
chamber_solve(config, cache, P_tank_O, P_tank_F)
Definition __init__.py:269
bool can_handle(config)
Definition __init__.py:77
evaluate_ex(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0)
Definition __init__.py:181
tuple chamber_physics_not_mirrored(config=None)
Definition __init__.py:109
chamber_solve_ex(config, cache, P_tank_O, P_tank_F)
Definition __init__.py:274
solve(config, P_tank_O, P_tank_F, Pc)
Definition __init__.py:235
bool available()
Definition __init__.py:51
_chamber_run(config, cache, P_tank_O, P_tank_F, P_ambient)
Definition __init__.py:133
bool can_handle_chamber(config)
Definition __init__.py:117
chamber_point(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0)
Definition __init__.py:157
bool enabled()
Definition __init__.py:60
chug_margin_fast(streams, chamber, **kw)
Definition __init__.py:315
evaluate(config, cache, P_tank_O, P_tank_F, P_ambient=101325.0)
Definition __init__.py:176
static ArmGate g(kMe)