STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
cea_cache.py
Go to the documentation of this file.
1"""RocketCEA wrapper with MR/Pc caching and bilinear interpolation"""
2
3import numpy as np
4import os
5import re
6import json
7import sys
8import threading
9import logging
10from typing import Tuple, Optional, List, Dict, Any
11from multiprocessing import Pool, cpu_count, Manager
12from functools import partial
13import time
14from .config_schemas import CEAConfig
15
16# ── In-process CEA-build gate ────────────────────────────────────────────────
17# A cold cache build is ~n_points^3 (≈39k) rocketcea calls: hours of CPU and a
18# steadily growing in-process cache that has taken the host to >12 GB when it
19# runs inside a request handler. Building is an OFFLINE job (see
20# .github/workflows/regenerate-cea-cache.yml); the committed output/cache/*.npz
21# tables are what everyone loads.
22#
23# The gate is ENVIRONMENT-based, not code-path based: running the app locally
24# over localhost is the same backend.main as production, just a different
25# environment, so we can't distinguish them by which code is executing. Instead
26# building defaults to ALLOWED, and the *production* environment opts out by
27# setting ED_ALLOW_CEA_BUILD=0 (see docker-compose.yml). That way local dev, the
28# regenerate workflow, tests, and direct runner/script use all keep building on
29# demand, while the deployed server fails fast on a missing/stale cache instead
30# of wedging the box. Set ED_ALLOW_CEA_BUILD=1 to force a build anywhere.
31def _cea_build_allowed() -> bool:
32 env = os.environ.get("ED_ALLOW_CEA_BUILD")
33 if env is None:
34 return True # default: local/CLI/CI/tests may build on demand
35 return env.strip().lower() in ("1", "true", "yes", "on")
36
37
38# Version of the SEMANTICS of the cached tables (not the .npz container format).
39# Bump whenever a table's meaning changes or a table is added, so stale caches on
40# disk — including locally generated ones git never touches — get rebuilt instead
41# of being silently served to code that expects the new meaning.
42# 1 (implicit; pre-2026-07): "Cf" stored get_PambCf()[0]; no "Cf_vac" table.
43# 2: "Cf" is the ambient-corrected coefficient get_PambCf()[1] (reproduces CEA's
44# own ambient Isp) and the "Cf_vac" table (RPA delivered-thrust basis) exists.
45# Caches whose meta lacks "table_schema" are treated as version 1.
46CEA_TABLE_SCHEMA_VERSION = 2
47
48
49def _isentropic_cf_vac(gamma: float, eps: float) -> float:
50 """Ideal vacuum thrust coefficient from gamma + area ratio (isentropic, frozen).
51
52 Fallback for caches built before the Cf_vac table existed. Slightly low vs CEA's
53 shifting-equilibrium Cf_vac (the same ~2% frozen gap documented in the thrust-model
54 analysis); regenerate the cache to get the exact CEA value. Self-contained to avoid
55 importing the nozzle solvers here.
56 """
57 g = float(gamma)
58 e = float(eps)
59 if not (g > 1.0) or not (e > 1.0):
60 return float("nan")
61 gm1, gp1 = g - 1.0, g + 1.0
62 # Supersonic area-Mach solve (Newton), same relation as mach_solver.
63 M = 2.5
64 for _ in range(100):
65 t = (2.0 / gp1) * (1.0 + 0.5 * gm1 * M * M)
66 AR = (1.0 / M) * t ** (gp1 / (2.0 * gm1))
67 dM = 1e-6
68 t2 = (2.0 / gp1) * (1.0 + 0.5 * gm1 * (M + dM) ** 2)
69 AR2 = (1.0 / (M + dM)) * t2 ** (gp1 / (2.0 * gm1))
70 d = (AR2 - AR) / dM
71 if d == 0.0:
72 break
73 Mn = M - (AR - e) / d
74 if Mn <= 1.0:
75 Mn = 0.5 * (M + 1.0)
76 if abs(Mn - M) < 1e-10:
77 M = Mn
78 break
79 M = Mn
80 pe_pc = (1.0 + 0.5 * gm1 * M * M) ** (-g / gm1)
81 cf = ((2.0 * g * g / gm1) * (2.0 / gp1) ** (gp1 / gm1) * (1.0 - pe_pc ** (gm1 / g))) ** 0.5 + pe_pc * e
82 return float(cf)
83
84
85def _scalar_clamp(x: float, lo: float, hi: float) -> float:
86 """float(np.clip(x, lo, hi)) for a scalar, without numpy's per-call overhead.
87 Matches np.clip exactly, including NaN pass-through (NaN < lo and NaN > hi are
88 both False, so NaN is returned). Hot path: called per CEA lookup."""
89 if x < lo:
90 return lo
91 if x > hi:
92 return hi
93 return x
94
95
96# Lazy import of rocketcea - only import if we need to build cache
97# This allows the module to work even if rocketcea is not installed/available
98# as long as a cache file exists
99_CEA_Obj = None
100
102 """Lazy import of CEA_Obj - only import when needed for cache building."""
103 global _CEA_Obj
104 if _CEA_Obj is None:
105 try:
106 from rocketcea.cea_obj import CEA_Obj
107 _CEA_Obj = CEA_Obj
108 except ImportError as e:
109 raise ImportError(
110 "rocketcea is required to build CEA cache. "
111 "If you have an existing cache file, make sure it's in the correct location. "
112 f"Original error: {e}"
113 )
114 return _CEA_Obj
115
116# Create module-level logger
117logger = logging.getLogger("evaluate")
118
119# Fix console encoding issues (Windows, WSL, etc.)
120_original_print = print
121def safe_print(*args, **kwargs):
122 """Log function that handles Unicode encoding errors gracefully"""
123 try:
124 # Build message string
125 message = ' '.join(str(arg) for arg in args)
126 logger.info(message)
127 except UnicodeEncodeError:
128 # Replace problematic characters with ASCII equivalents
129 safe_args = []
130 for arg in args:
131 if isinstance(arg, str):
132 # Replace Unicode characters that can't be encoded
133 safe_str = arg.encode('ascii', errors='replace').decode('ascii')
134 safe_args.append(safe_str)
135 else:
136 safe_args.append(arg)
137 try:
138 message = ' '.join(str(arg) for arg in safe_args)
139 logger.info(message)
140 except Exception:
141 # Last resort: convert everything to string and sanitize
142 safe_str_args = []
143 for arg in safe_args:
144 safe_str_args.append(str(arg).encode('ascii', errors='replace').decode('ascii'))
145 message = ' '.join(safe_str_args)
146 logger.info(message)
147
148# Replace built-in print with safe version
149print = safe_print
150
151# Also try to reconfigure stdout if possible
152try:
153 if hasattr(sys.stdout, 'reconfigure'):
154 sys.stdout.reconfigure(encoding='utf-8', errors='replace')
155 elif hasattr(sys.stdout, 'buffer'):
156 import io
157 sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
158except (AttributeError, ValueError, OSError):
159 pass # Safe print wrapper will handle it
160
161
162def parse_cea_basic(out: str) -> Tuple[float, float, float, float, float, float]:
163 """
164 Extract Tc, gamma, R, c*, and Isp from NASA CEA text output (chamber values only).
165
166 Returns:
167 --------
168 Tc : float [K]
169 gamma : float
170 R : float [J/(kg*K)]
171 cstar : float [m/s]
172 M : float [kg/kmol]
173 Isp : float [s] (ambient Isp, or NaN if not found)
174 """
175 # Extract the main performance block
176 block_match = re.search(r'THEORETICAL ROCKET PERFORMANCE[\s\S]+?MOLE FRACTIONS', out)
177 block = block_match.group(0) if block_match else out
178
179 # Extract chamber molecular weight M
180 mw_match = re.search(r'M,\s*\‍(1/n\‍)\s+([\d.E+-]+)', block)
181 if mw_match:
182 M = float(mw_match.group(1)) # molecular weight [kg/kmol]
183 R = 8314.462618 / M # J/(kg*K)
184 else:
185 M = np.nan
186 R = np.nan
187
188 # Extract chamber temperature (Tc)
189 Tc_match = re.search(r'T[, ]*K\s+([\d.E+-]+)', block)
190 Tc = float(Tc_match.group(1)) if Tc_match else np.nan
191
192 # Extract gamma (chamber)
193 gamma_match = re.search(r'GAMMAs\s+([\d.E+-]+)', block)
194 gamma = float(gamma_match.group(1)) if gamma_match else np.nan
195
196 # Extract c*
197 cstar_match = re.search(r'CSTAR[, ]*(?:M/SEC|FT/SEC)?\s+([\d.E+-]+)', block, re.IGNORECASE)
198 cstar = float(cstar_match.group(1)) if cstar_match else np.nan
199 # Convert ft/s -> m/s if needed
200 if re.search(r'CSTAR.*FT/SEC', block, re.IGNORECASE):
201 cstar *= 0.3048
202
203 # Extract Isp (ambient Isp, typically appears as "Isp, M/SEC" or "Isp, SEC")
204 # Look for Isp in the performance block - it may appear in different formats
205 isp_match = re.search(r'Isp[, ]*(?:M/SEC|SEC|M/S)?\s+([\d.E+-]+)', block, re.IGNORECASE)
206 if not isp_match:
207 # Try alternative pattern: look for "Isp" followed by numbers
208 isp_match = re.search(r'Isp[:\s]+([\d.E+-]+)', block, re.IGNORECASE)
209 Isp = float(isp_match.group(1)) if isp_match else np.nan
210
211 return Tc, gamma, R, cstar, M, Isp
212
213
215 chunk: List[Tuple[int, int, int, float, float, float]],
216 ox_name: str,
217 fuel_name: str,
218 expansion_ratio: Optional[float] = None,
219 lock: Optional[Any] = None,
220) -> List[Tuple[int, int, int, float, float, float, float, float, float]]:
221 """
222 Worker function for parallel CEA cache building.
223
224 Computes CEA properties for a chunk of grid points.
225 This function must be at module level for multiprocessing.
226
227 Parameters:
228 -----------
229 chunk : List[Tuple[int, int, int, float, float, float]]
230 List of (i, j, k_idx, Pc_psia, MR, eps) tuples
231 ox_name : str
232 Oxidizer name
233 fuel_name : str
234 Fuel name
235 expansion_ratio : float, optional
236 Fixed expansion ratio (for 2D cache). If None, uses eps from chunk (3D cache)
237 lock : multiprocessing.Lock, optional
238 Lock to serialize RocketCEA calls (RocketCEA is not process-safe)
239
240 Returns:
241 --------
242 results : List[Tuple[int, int, int, float, float, float, float, float, float]]
243 List of (i, j, k_idx, cstar, Cf, Tc, gamma, R, M) tuples
244 """
245 # Lazy import CEA_Obj only when needed
246 CEA_Obj = _get_CEA_Obj()
247 # Create CEA object per worker (each process gets its own)
248 chamber = CEA_Obj(oxName=ox_name, fuelName=fuel_name)
249 results = []
250
251 for i, j, k_idx, Pc_psia, MR, eps in chunk:
252 # Use eps from chunk if provided (3D), otherwise use fixed expansion_ratio (2D)
253 eps_to_use = eps if expansion_ratio is None else expansion_ratio
254
255 try:
256 # Serialize RocketCEA calls if lock is provided (RocketCEA uses shared temp files)
257 if lock is not None:
258 lock.acquire()
259 try:
260 out = chamber.get_full_cea_output(Pc=Pc_psia, MR=MR, eps=eps_to_use)
261 Tc, gamma, R, cstar, M, Isp = parse_cea_basic(out)
262
263 try:
264 # get_PambCf returns (CFcea, CFamb, mode); element [1] is the
265 # ambient-corrected Cf that reproduces CEA's own ambient Isp.
266 Cf_ideal = chamber.get_PambCf(Pc=Pc_psia, MR=MR, eps=eps_to_use)[1]
267 except Exception:
268 # Fallback: estimate from Isp
269 try:
270 isp = chamber.estimate_Ambient_Isp(Pc=Pc_psia, MR=MR, eps=eps_to_use)[0]
271 Cf_ideal = isp * 9.80665 / cstar if cstar > 0 else np.nan
272 except Exception:
273 Cf_ideal = np.nan
274 # Vacuum thrust coefficient (shifting equilibrium): Cf_vac = Isp_vac*g0/c*.
275 # This is the RPA delivered-thrust basis: F = zeta_n*Cf_vac*Pc*At - Pa*Ae.
276 try:
277 isp_vac = chamber.get_Isp(Pc=Pc_psia, MR=MR, eps=eps_to_use)
278 Cf_vac = isp_vac * 9.80665 / cstar if cstar > 0 else np.nan
279 except Exception:
280 Cf_vac = np.nan
281
282 results.append((i, j, k_idx, cstar, Cf_ideal, Tc, gamma, R, M, Cf_vac))
283 finally:
284 if lock is not None:
285 lock.release()
286 except Exception as e:
287 # On failure (including Fortran I/O errors), store NaN values
288 # This handles "I/O past end of record" and "End of file" errors
289 results.append((i, j, k_idx, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan))
290
291 return results
292
293
294# Decompressed CEA tables, keyed by (path, mtime, size). np.load returns a lazy
295# NpzFile: every table access re-inflates that member from the zip, and a fresh
296# CEACache is built for every PintleEngineRunner. Layer 1 builds a runner per parent
297# objective() call, so one short run re-read this file 73 times -- 0.80 s of a 3.22 s
298# parent, against 0.11 s for all the interpolation it actually fed. The tables are
299# written only in _load_cache/_build_cache and never mutated afterwards (no caller
300# assigns to a cache's attributes), so the arrays are safe to share by reference.
301# Keying on mtime+size means a regenerated .npz is picked up rather than served stale.
302_NPZ_TABLE_MEMO: Dict[Tuple[str, int, int], Dict[str, np.ndarray]] = {}
303
304
305def _load_npz_tables(path: str) -> Dict[str, np.ndarray]:
306 """Return the fully-materialised contents of a cache .npz, memoised per file version."""
307 try:
308 st = os.stat(path)
309 key = (os.path.abspath(path), st.st_mtime_ns, st.st_size)
310 except OSError:
311 key = None
312 if key is not None:
313 hit = _NPZ_TABLE_MEMO.get(key)
314 if hit is not None:
315 return hit
316 with np.load(path) as z:
317 tables = {name: z[name] for name in z.files}
318 if key is not None:
319 # Bounded: one entry per propellant cache file, and they are a few hundred KB each.
320 if len(_NPZ_TABLE_MEMO) > 8:
321 _NPZ_TABLE_MEMO.clear()
322 _NPZ_TABLE_MEMO[key] = tables
323 return tables
324
325
327 """CEA cache with bilinear interpolation"""
328
329 def __init__(self, config: CEAConfig):
330 self.config = config
331 # Resolve a deterministic cache path for this config.
332 # Important: never silently swap to a different *.npz file, which can hide
333 # fuel/range changes and make runs appear inconsistent.
334 if os.path.isabs(config.cache_file):
335 self.cache_file = config.cache_file
336 else:
337 project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
338 candidates = [
339 os.path.abspath(config.cache_file),
340 os.path.join(project_root, config.cache_file),
341 ]
342 resolved = None
343 for cand in candidates:
344 if os.path.exists(cand):
345 resolved = cand
346 break
347 self.cache_file = resolved if resolved is not None else candidates[1]
348
349 # Determine if using 3D cache (Pc, MR, eps) or 2D cache (Pc, MR)
350 self.use_3d = config.eps_range is not None
351
352 # Grid parameters
353 self.Pc_min, self.Pc_max = config.Pc_range
354 self.MR_min, self.MR_max = config.MR_range
355 self.n_points = config.n_points
356
357 # Create grids
358 self.Pc_grid = np.linspace(self.Pc_min, self.Pc_max, self.n_points)
359 self.MR_grid = np.linspace(self.MR_min, self.MR_max, self.n_points)
360
361 # 3D mode: add expansion ratio grid
362 if self.use_3d:
363 self.eps_min, self.eps_max = config.eps_range
364 self.eps_grid = np.linspace(self.eps_min, self.eps_max, self.n_points)
365 # print(f"[INFO] Using 3D CEA cache: {self.n_points}^3 = {self.n_points**3} points")
366 else:
367 self.eps_min = self.eps_max = config.expansion_ratio
368 self.eps_grid = None
369 # print(f"[INFO] Using 2D CEA cache: {self.n_points}^2 = {self.n_points**2} points")
370
371 # Lookup tables (initialized as None, loaded from cache or computed)
372 # Shape: (n, n, n) for 3D or (n, n) for 2D
373 self.cstar_table = None
374 self.Cf_table = None
375 self.Tc_table = None
376 self.gamma_table = None
377 self.R_table = None
378 self.M_table = None
379 self.Cf_vac_table = None # vacuum thrust coefficient (RPA delivered-thrust basis)
380
381 # Load from cache or build
382 if os.path.exists(self.cache_file):
383 self._load_cache()
384 else:
385 self._build_cache()
386
387 def _load_cache(self):
388 """Load CEA data from cache file"""
389 # print(f"[OK] Loading CEA cache from {self.cache_file}")
390 data = _load_npz_tables(self.cache_file)
391
392 meta_expected = {
393 "table_schema": CEA_TABLE_SCHEMA_VERSION,
394 "ox_name": self.config.ox_name,
395 "fuel_name": self.config.fuel_name,
396 "expansion_ratio": self.config.expansion_ratio,
397 "Pc_range": list(self.config.Pc_range),
398 "MR_range": list(self.config.MR_range),
399 "n_points": self.n_points,
400 "dimensions": 3 if self.use_3d else 2,
401 "eps_range": list(self.config.eps_range) if self.use_3d else None,
402 }
403
404 meta_loaded = None
405 if "meta" in data:
406 try:
407 meta_loaded = json.loads(data["meta"].tolist())
408 except Exception:
409 meta_loaded = None
410
411 def _meta_matches(meta_loaded_dict: Optional[dict], meta_expected_dict: dict) -> bool:
412 """Check if cache is usable - lenient matching: only check critical fields"""
413 if meta_loaded_dict is None:
414 return False
415 try:
416 # Critical: table SEMANTICS version must match. A stale-schema cache
417 # (e.g. pre-Cf_vac, or "Cf" stored on the old get_PambCf[0] basis)
418 # must be rebuilt, not silently served under the new meaning —
419 # git updates code but never touches locally generated caches.
420 loaded_schema = meta_loaded_dict.get("table_schema", 1)
421 if loaded_schema != meta_expected_dict["table_schema"]:
422 logger.warning(
423 f"CEA cache table-schema mismatch: cache has v{loaded_schema}, "
424 f"code expects v{meta_expected_dict['table_schema']}; regenerating."
425 )
426 return False
427 # Critical: propellant names must match
428 if meta_loaded_dict.get("ox_name") != meta_expected_dict["ox_name"]:
429 return False
430 if meta_loaded_dict.get("fuel_name") != meta_expected_dict["fuel_name"]:
431 return False
432 # Critical: dimensions must match (2D vs 3D)
433 loaded_dims = meta_loaded_dict.get("dimensions", 2)
434 expected_dims = meta_expected_dict["dimensions"]
435 if loaded_dims != expected_dims:
436 logger.warning(f"Cache dimension mismatch: {loaded_dims}D vs {expected_dims}D")
437 return False
438 # Non-critical: ranges and n_points can differ - we can still interpolate
439 # Just warn if they're very different
440 loaded_pc_range = meta_loaded_dict.get("Pc_range", [])
441 expected_pc_range = meta_expected_dict.get("Pc_range", [])
442 if loaded_pc_range and expected_pc_range:
443 # Check if requested range is within cached range (with some margin)
444 if expected_pc_range[0] < loaded_pc_range[0] * 0.9 or expected_pc_range[1] > loaded_pc_range[1] * 1.1:
445 logger.info(f"Requested Pc range {expected_pc_range} extends beyond cached range {loaded_pc_range}")
446 logger.info(f"Will use trilinear interpolation - some extrapolation may occur")
447
448 loaded_mr_range = meta_loaded_dict.get("MR_range", [])
449 expected_mr_range = meta_expected_dict.get("MR_range", [])
450 if loaded_mr_range and expected_mr_range:
451 if expected_mr_range[0] < loaded_mr_range[0] * 0.9 or expected_mr_range[1] > loaded_mr_range[1] * 1.1:
452 logger.info(f"Requested MR range {expected_mr_range} extends beyond cached range {loaded_mr_range}")
453 logger.info(f"Will use trilinear interpolation - some extrapolation may occur")
454
455 # For 3D caches, check eps_range similarly
456 if expected_dims == 3:
457 loaded_eps_range = meta_loaded_dict.get("eps_range", [])
458 expected_eps_range = meta_expected_dict.get("eps_range", [])
459 if loaded_eps_range and expected_eps_range:
460 if expected_eps_range[0] < loaded_eps_range[0] * 0.9 or expected_eps_range[1] > loaded_eps_range[1] * 1.1:
461 logger.info(f"Requested eps range {expected_eps_range} extends beyond cached range {loaded_eps_range}")
462 logger.info(f"Will use trilinear interpolation - some extrapolation may occur")
463 except Exception as e:
464 logger.warning(f"Error checking cache metadata: {e}")
465 return False
466 return True
467
468 if not _meta_matches(meta_loaded, meta_expected):
469 print("[WARNING] CEA cache metadata does not match configuration; regenerating...")
470 try:
471 os.remove(self.cache_file)
472 except OSError:
473 pass
474 self._build_cache()
475 return
476
477 self.cstar_table = data["cstar"]
478 self.Cf_table = data["Cf"]
479 # Cf_vac added later; old caches lack it -> None triggers an isentropic
480 # fallback in eval() until the cache is regenerated.
481 self.Cf_vac_table = data["Cf_vac"] if "Cf_vac" in data else None
482 self.Tc_table = data["Tc"]
483 self.gamma_table = data["gamma"]
484 self.R_table = data["R"]
485 if "M" in data:
486 self.M_table = data["M"]
487 else:
488 # Backwards compatibility: derive M from R
489 self.M_table = 8314.462618 / self.R_table
490
491 # Load grids from cache (use cached grids for interpolation)
492 # Trilinear interpolation can handle different grid ranges - no need to rebuild
493 Pc_loaded = data["Pc"]
494 MR_loaded = data["MR"]
495
496 # Use cached grids instead of regenerating - trilinear interpolation can handle range differences
497 # Only rebuild if grids are completely incompatible (different sizes)
498 if len(Pc_loaded) != len(self.Pc_grid) or len(MR_loaded) != len(self.MR_grid):
499 print("[WARNING] Cache grid size doesn't match config, rebuilding...")
500 self._build_cache()
501 return
502
503 # For 3D caches, check eps grid
504 if self.use_3d:
505 if "eps" in data:
506 eps_loaded = data["eps"]
507 if len(eps_loaded) != len(self.eps_grid):
508 print("[WARNING] Cache eps grid size doesn't match config, rebuilding...")
509 self._build_cache()
510 return
511 # Use cached eps grid
512 self.eps_grid = eps_loaded
513 else:
514 print("[WARNING] 3D cache missing eps grid, rebuilding...")
515 self._build_cache()
516 return
517
518 # Use cached grids for interpolation (they may have different ranges, that's OK)
519 # Update our grid references to use cached grids
520 self.Pc_grid = Pc_loaded
521 self.MR_grid = MR_loaded
522 # Update min/max from loaded grids
523 self.Pc_min = float(Pc_loaded.min())
524 self.Pc_max = float(Pc_loaded.max())
525 self.MR_min = float(MR_loaded.min())
526 self.MR_max = float(MR_loaded.max())
527 if self.use_3d:
528 self.eps_min = float(self.eps_grid.min())
529 self.eps_max = float(self.eps_grid.max())
530 print(f"[INFO] Using cached grids - trilinear interpolation will handle any range differences")
531
532 def _build_cache(self):
533 """Build CEA lookup tables with parallel processing support"""
534 # Fail fast where in-process builds are disabled (the web server). Every
535 # build path -- missing file, metadata mismatch, schema bump -- funnels
536 # through here, so this one check covers them all. A build in a request
537 # handler is a multi-hour, memory-runaway hazard (host hit >12 GB); the
538 # cure is a committed cache, not an on-demand build.
539 if not _cea_build_allowed():
540 raise RuntimeError(
541 f"CEA cache missing or stale for {self.config.ox_name}/{self.config.fuel_name} "
542 f"at {self.cache_file}, and in-process builds are disabled here. A cold build is "
543 f"~{self.n_points}^3 rocketcea calls (hours) and can exhaust memory. Regenerate it "
544 f"offline (.github/workflows/regenerate-cea-cache.yml) and commit the .npz, or set "
545 f"ED_ALLOW_CEA_BUILD=1 to force a build in this process."
546 )
547 print(f"[BUILDING] Building CEA cache (this will take a while)...")
548 print(f" Grid: Pc in [{self.Pc_min/1e6:.1f}, {self.Pc_max/1e6:.1f}] MPa ({self.n_points} points)")
549 print(f" MR in [{self.MR_min:.2f}, {self.MR_max:.2f}] ({self.n_points} points)")
550 if self.use_3d:
551 print(f" eps in [{self.eps_min:.2f}, {self.eps_max:.2f}] ({self.n_points} points)")
552 total_points = self.n_points**3
553 print(f" Total: {self.n_points}^3 = {total_points} points")
554 print(f" [WARNING] Sequential build would take ~{total_points * 0.5 / 60:.0f} minutes!")
555 else:
556 total_points = self.n_points**2
557 print(f" Total: {self.n_points}^2 = {total_points} points")
558 print(f" [INFO] Sequential build would take ~{total_points * 0.5 / 60:.0f} minutes")
559
560 # Check for parallel processing option
561 # RocketCEA (Fortran) is NOT process-safe - multiple processes collide on temp files
562 # Parallel processing causes Fortran I/O errors and hangs
563 # Disable parallel by default - sequential is slower but reliable
564 is_windows = sys.platform == 'win32'
565 use_parallel = getattr(self.config, 'use_parallel_cea_build', False) # Default to False
566 n_workers = getattr(self.config, 'cea_parallel_workers', None)
567
568 # Only parallelize if explicitly enabled and for large grids
569 should_use_parallel = use_parallel and total_points > 100
570
571 # macOS / Python: multiprocessing.Manager()/Pool started from a *non-main* thread
572 # (e.g. FastAPI Layer‑1 optimizer via ThreadPoolExecutor) often raises
573 # ``OSError: [Errno 1] Operation not permitted``. Sequential build avoids that.
574 if (
575 should_use_parallel
576 and sys.platform == "darwin"
577 and threading.current_thread() is not threading.main_thread()
578 ):
579 print(
580 " [WARNING] macOS + background thread: disabling parallel CEA build "
581 "(avoids multiprocessing OSError errno 1); using sequential cache build."
582 )
583 should_use_parallel = False
584
585 if should_use_parallel and not is_windows:
586 # Linux/Mac: use parallel processing with lock (serialized RocketCEA calls)
587 if n_workers is None:
588 n_workers = min(cpu_count(), 4) # Limit to 4 workers (RocketCEA is not safe)
589 print(f" [PARALLEL] Using {n_workers} workers with serialized RocketCEA calls")
590 print(f" [WARNING] RocketCEA is not process-safe - using lock to serialize calls")
591 print(f" [WARNING] Parallel mode may still hang - sequential is recommended")
592 try:
593 self._build_cache_parallel(n_workers, total_points)
594 except (NameError, ImportError, MemoryError, Exception) as e:
595 print(f" [ERROR] Parallel processing failed: {e}")
596 print(f" [FALLBACK] Building cache sequentially")
597 self._build_cache_sequential(total_points)
598 elif should_use_parallel and is_windows:
599 # Windows: Default to sequential due to multiprocessing spawn issues
600 print(f" [WARNING] Windows detected - using sequential mode to avoid multiprocessing issues")
601 print(f" [INFO] Sequential mode is slower but stable (ETA: ~{total_points * 0.5 / 60:.1f} min)")
602 self._build_cache_sequential(total_points)
603 else:
604 print(f" [SEQUENTIAL] Building cache sequentially (recommended for RocketCEA)")
605 print(f" [INFO] Sequential mode avoids Fortran I/O conflicts (ETA: ~{total_points * 0.5 / 60:.1f} min)")
606 self._build_cache_sequential(total_points)
607
608 # Save to cache
609 self._save_cache()
610 print(f"[SAVED] CEA cache saved to {self.cache_file}")
611
612 def _build_cache_sequential(self, total_points: int):
613 """Sequential CEA cache building (original method)"""
614 # Lazy import CEA_Obj only when needed
615 CEA_Obj = _get_CEA_Obj()
616 chamber = CEA_Obj(oxName=self.config.ox_name, fuelName=self.config.fuel_name)
617
618 # Initialize tables
619 shape = (self.n_points, self.n_points, self.n_points) if self.use_3d else (self.n_points, self.n_points)
620 self.cstar_table = np.zeros(shape)
621 self.Cf_table = np.zeros(shape)
622 self.Tc_table = np.zeros(shape)
623 self.gamma_table = np.zeros(shape)
624 self.R_table = np.zeros(shape)
625 self.M_table = np.zeros(shape)
626 self.Cf_vac_table = np.zeros(shape)
627
628 Pc_psia_grid = self.Pc_grid / 6894.76
629 point_count = 0
630 start_time = time.time()
631
632 for i, Pc_psia in enumerate(Pc_psia_grid):
633 for j, MR in enumerate(self.MR_grid):
634 eps_list = self.eps_grid if self.use_3d else [self.config.expansion_ratio]
635
636 for k_idx, eps in enumerate(eps_list):
637 point_count += 1
638 elapsed = time.time() - start_time
639 if point_count % max(10, total_points // 100) == 0 or point_count == 1:
640 pct = 100 * point_count / total_points
641 rate = point_count / elapsed if elapsed > 0 else 0
642 eta = (total_points - point_count) / rate if rate > 0 else 0
643 print(f" [{point_count}/{total_points}] {pct:.1f}% | Rate: {rate:.1f} pts/s | ETA: {eta/60:.1f} min | Pc={Pc_psia:.0f} psi, MR={MR:.2f}, eps={eps:.1f}")
644
645 try:
646 out = chamber.get_full_cea_output(Pc=Pc_psia, MR=MR, eps=eps)
647 Tc, gamma, R, cstar, M, Isp = parse_cea_basic(out)
648
649 try:
650 Cf_ideal = chamber.get_PambCf(Pc=Pc_psia, MR=MR, eps=eps)[1]
651 except:
652 isp = chamber.estimate_Ambient_Isp(Pc=Pc_psia, MR=MR, eps=eps)[0]
653 Cf_ideal = isp * 9.80665 / cstar if cstar > 0 else np.nan
654 try:
655 Cf_vac = chamber.get_Isp(Pc=Pc_psia, MR=MR, eps=eps) * 9.80665 / cstar if cstar > 0 else np.nan
656 except Exception:
657 Cf_vac = np.nan
658
659 if self.use_3d:
660 self.cstar_table[i, j, k_idx] = cstar
661 self.Cf_table[i, j, k_idx] = Cf_ideal
662 self.Cf_vac_table[i, j, k_idx] = Cf_vac
663 self.Tc_table[i, j, k_idx] = Tc
664 self.gamma_table[i, j, k_idx] = gamma
665 self.R_table[i, j, k_idx] = R
666 self.M_table[i, j, k_idx] = M
667 else:
668 self.cstar_table[i, j] = cstar
669 self.Cf_table[i, j] = Cf_ideal
670 self.Cf_vac_table[i, j] = Cf_vac
671 self.Tc_table[i, j] = Tc
672 self.gamma_table[i, j] = gamma
673 self.R_table[i, j] = R
674 self.M_table[i, j] = M
675
676 except Exception as e:
677 print(f" [WARNING] Error at Pc={Pc_psia:.1f} psia, MR={MR:.2f}, eps={eps:.1f}: {e}")
678 if self.use_3d:
679 self.cstar_table[i, j, k_idx] = np.nan
680 self.Cf_table[i, j, k_idx] = np.nan
681 self.Tc_table[i, j, k_idx] = np.nan
682 self.gamma_table[i, j, k_idx] = np.nan
683 self.R_table[i, j, k_idx] = np.nan
684 self.M_table[i, j, k_idx] = np.nan
685 else:
686 self.cstar_table[i, j] = np.nan
687 self.Cf_table[i, j] = np.nan
688 self.Tc_table[i, j] = np.nan
689 self.gamma_table[i, j] = np.nan
690 self.R_table[i, j] = np.nan
691 self.M_table[i, j] = np.nan
692
693 def _build_cache_parallel(self, n_workers: int, total_points: int):
694 """Parallel CEA cache building with chunking"""
695 # Create all grid points as list of tuples (i, j, k, Pc_psia, MR, eps)
696 grid_points = []
697 Pc_psia_grid = self.Pc_grid / 6894.76
698 eps_list = self.eps_grid if self.use_3d else [self.config.expansion_ratio]
699
700 for i, Pc_psia in enumerate(Pc_psia_grid):
701 for j, MR in enumerate(self.MR_grid):
702 for k_idx, eps in enumerate(eps_list):
703 grid_points.append((i, j, k_idx, Pc_psia, MR, eps))
704
705 # Initialize tables
706 shape = (self.n_points, self.n_points, self.n_points) if self.use_3d else (self.n_points, self.n_points)
707 self.cstar_table = np.zeros(shape)
708 self.Cf_table = np.zeros(shape)
709 self.Tc_table = np.zeros(shape)
710 self.gamma_table = np.zeros(shape)
711 self.R_table = np.zeros(shape)
712 self.M_table = np.zeros(shape)
713 self.Cf_vac_table = np.zeros(shape)
714
715 # Chunk grid points for parallel processing
716 chunk_size = max(1, len(grid_points) // (n_workers * 4)) # 4 chunks per worker
717 chunks = [grid_points[i:i+chunk_size] for i in range(0, len(grid_points), chunk_size)]
718 print(f" [PARALLEL] Split into {len(chunks)} chunks of ~{chunk_size} points each")
719
720 # Create a shared lock to serialize RocketCEA calls (RocketCEA uses shared temp files)
721 # This prevents Fortran I/O errors when multiple processes access RocketCEA simultaneously
722 manager = Manager()
723 cea_lock = manager.Lock()
724
725 # Prepare worker function
726 # The function is defined at module level (above), so it's directly accessible
727 # On Windows with 'spawn', workers will re-import this module and find the function
728 worker_func = partial(
729 _compute_cea_point_chunk,
730 ox_name=self.config.ox_name,
731 fuel_name=self.config.fuel_name,
732 expansion_ratio=self.config.expansion_ratio if not self.use_3d else None,
733 lock=cea_lock, # Pass lock to serialize RocketCEA calls
734 )
735
736 # Process chunks in parallel
737 start_time = time.time()
738 completed = 0
739
740 with Pool(processes=n_workers) as pool:
741 results = pool.imap_unordered(worker_func, chunks)
742
743 for chunk_results in results:
744 completed += len(chunk_results)
745 elapsed = time.time() - start_time
746 pct = 100 * completed / total_points
747 rate = completed / elapsed if elapsed > 0 else 0
748 eta = (total_points - completed) / rate if rate > 0 else 0
749 print(f" [{completed}/{total_points}] {pct:.1f}% | Rate: {rate:.1f} pts/s | ETA: {eta/60:.1f} min")
750
751 # Store results
752 for i, j, k_idx, cstar, Cf, Tc, gamma, R, M, Cf_vac in chunk_results:
753 if self.use_3d:
754 self.cstar_table[i, j, k_idx] = cstar
755 self.Cf_table[i, j, k_idx] = Cf
756 self.Cf_vac_table[i, j, k_idx] = Cf_vac
757 self.Tc_table[i, j, k_idx] = Tc
758 self.gamma_table[i, j, k_idx] = gamma
759 self.R_table[i, j, k_idx] = R
760 self.M_table[i, j, k_idx] = M
761 else:
762 self.cstar_table[i, j] = cstar
763 self.Cf_table[i, j] = Cf
764 self.Cf_vac_table[i, j] = Cf_vac
765 self.Tc_table[i, j] = Tc
766 self.gamma_table[i, j] = gamma
767 self.R_table[i, j] = R
768 self.M_table[i, j] = M
769
770 def _save_cache(self):
771 """Save CEA data to cache file"""
772 meta = {
773 "table_schema": CEA_TABLE_SCHEMA_VERSION,
774 "ox_name": self.config.ox_name,
775 "fuel_name": self.config.fuel_name,
776 "expansion_ratio": self.config.expansion_ratio,
777 "Pc_range": list(self.config.Pc_range),
778 "MR_range": list(self.config.MR_range),
779 "n_points": self.n_points,
780 "dimensions": 3 if self.use_3d else 2,
781 "eps_range": list(self.config.eps_range) if self.use_3d else None,
782 }
783
784 save_dict = {
785 "Pc": self.Pc_grid,
786 "MR": self.MR_grid,
787 "cstar": self.cstar_table,
788 "Cf": self.Cf_table,
789 "Cf_vac": self.Cf_vac_table,
790 "Tc": self.Tc_table,
791 "gamma": self.gamma_table,
792 "R": self.R_table,
793 "M": self.M_table,
794 "meta": np.array(json.dumps(meta))
795 }
796
797 # Add eps grid for 3D caches
798 if self.use_3d:
799 save_dict["eps"] = self.eps_grid
800
801 np.savez_compressed(self.cache_file, **save_dict)
802
803 def _bilinear_interpolate(self, Pc: float, MR: float, table: np.ndarray) -> float:
804 """
805 Bilinear interpolation in (Pc, MR) space with robust error handling.
806
807 Uses improved boundary handling and NaN management for numerical stability.
808 """
809 # Validate inputs
810 if not (np.isfinite(Pc) and np.isfinite(MR)):
811 raise ValueError(f"Non-finite interpolation point: Pc={Pc}, MR={MR}")
812
813 # Find indices using searchsorted (returns insertion point)
814 i_pc = np.searchsorted(self.Pc_grid, Pc)
815 i_mr = np.searchsorted(self.MR_grid, MR)
816
817 # Handle boundary cases robustly
818 # If at or beyond upper bound, use last two points
819 if i_pc >= len(self.Pc_grid):
820 i_pc = len(self.Pc_grid) - 1
821 if i_mr >= len(self.MR_grid):
822 i_mr = len(self.MR_grid) - 1
823
824 # If at or below lower bound, use first two points
825 if i_pc == 0:
826 i_pc = 1
827 if i_mr == 0:
828 i_mr = 1
829
830 # Ensure we have valid indices for interpolation
831 i_pc = np.clip(i_pc, 1, len(self.Pc_grid) - 1)
832 i_mr = np.clip(i_mr, 1, len(self.MR_grid) - 1)
833
834 # Get surrounding points
835 Pc0, Pc1 = self.Pc_grid[i_pc - 1], self.Pc_grid[i_pc]
836 MR0, MR1 = self.MR_grid[i_mr - 1], self.MR_grid[i_mr]
837
838 # Validate grid spacing
839 if Pc1 <= Pc0 or MR1 <= MR0:
840 raise ValueError(f"Invalid grid spacing: Pc grid or MR grid not monotonic")
841
842 # Get corner values
843 f00 = table[i_pc - 1, i_mr - 1]
844 f01 = table[i_pc - 1, i_mr]
845 f10 = table[i_pc, i_mr - 1]
846 f11 = table[i_pc, i_mr]
847
848 # Count valid (non-NaN) values
849 valid_values = [v for v in [f00, f01, f10, f11] if np.isfinite(v)]
850
851 if len(valid_values) == 0:
852 # All NaN - return NaN
853 return np.nan
854 elif len(valid_values) < 4:
855 # Some NaN - use weighted average of valid values only
856 # This is more robust than simple nearest neighbor
857 weights = []
858 values = []
859
860 # Calculate weights for each corner
861 for i, (f_val, pc_idx, mr_idx) in enumerate([
862 (f00, i_pc - 1, i_mr - 1),
863 (f01, i_pc - 1, i_mr),
864 (f10, i_pc, i_mr - 1),
865 (f11, i_pc, i_mr),
866 ]):
867 if np.isfinite(f_val):
868 # Distance-based weight (inverse distance)
869 pc_dist = abs(Pc - self.Pc_grid[pc_idx]) / (Pc1 - Pc0) if Pc1 != Pc0 else 1.0
870 mr_dist = abs(MR - self.MR_grid[mr_idx]) / (MR1 - MR0) if MR1 != MR0 else 1.0
871 weight = 1.0 / (1.0 + pc_dist + mr_dist)
872 weights.append(weight)
873 values.append(f_val)
874
875 if len(weights) > 0:
876 weights = np.array(weights)
877 weights = weights / np.sum(weights) # Normalize
878 result = np.sum(np.array(values) * weights)
879 else:
880 result = np.nan
881 else:
882 # All values valid - standard bilinear interpolation
883 # Interpolation weights (normalized to [0, 1])
884 wx = (Pc - Pc0) / (Pc1 - Pc0) if Pc1 != Pc0 else 0.0
885 wy = (MR - MR0) / (MR1 - MR0) if MR1 != MR0 else 0.0
886
887 # Clamp weights to [0, 1] for numerical stability
888 wx = np.clip(wx, 0.0, 1.0)
889 wy = np.clip(wy, 0.0, 1.0)
890
891 # Bilinear interpolation
892 result = (f00 * (1 - wx) * (1 - wy) +
893 f10 * wx * (1 - wy) +
894 f01 * (1 - wx) * wy +
895 f11 * wx * wy)
896
897 # Validate result
898 if not np.isfinite(result):
899 # Final fallback: use nearest valid neighbor
900 distances = [
901 (abs(Pc - Pc0) + abs(MR - MR0), f00),
902 (abs(Pc - Pc0) + abs(MR - MR1), f01),
903 (abs(Pc - Pc1) + abs(MR - MR0), f10),
904 (abs(Pc - Pc1) + abs(MR - MR1), f11),
905 ]
906 valid_distances = [(d, v) for d, v in distances if np.isfinite(v)]
907 if valid_distances:
908 result = min(valid_distances, key=lambda x: x[0])[1]
909 else:
910 result = np.nan
911
912 return float(result)
913
914 def _trilinear_interpolate(self, Pc: float, MR: float, eps: float, table: np.ndarray) -> float:
915 """Trilinear interpolation in (Pc, MR, eps) space"""
916 # Find indices
917 i_pc = np.searchsorted(self.Pc_grid, Pc)
918 i_mr = np.searchsorted(self.MR_grid, MR)
919 i_eps = np.searchsorted(self.eps_grid, eps)
920
921 # Clamp to valid range. Scalar min/max on a Python int is far cheaper than
922 # np.clip on a numpy scalar (which allocates + builds getlimits) and is
923 # identical for an in-range index. This runs 6x per CEA lookup.
924 i_pc = min(max(int(i_pc), 1), len(self.Pc_grid) - 1)
925 i_mr = min(max(int(i_mr), 1), len(self.MR_grid) - 1)
926 i_eps = min(max(int(i_eps), 1), len(self.eps_grid) - 1)
927
928 # Get surrounding points
929 Pc0, Pc1 = self.Pc_grid[i_pc - 1], self.Pc_grid[i_pc]
930 MR0, MR1 = self.MR_grid[i_mr - 1], self.MR_grid[i_mr]
931 eps0, eps1 = self.eps_grid[i_eps - 1], self.eps_grid[i_eps]
932
933 # Get 8 corner values
934 f000 = table[i_pc - 1, i_mr - 1, i_eps - 1]
935 f001 = table[i_pc - 1, i_mr - 1, i_eps]
936 f010 = table[i_pc - 1, i_mr, i_eps - 1]
937 f011 = table[i_pc - 1, i_mr, i_eps]
938 f100 = table[i_pc, i_mr - 1, i_eps - 1]
939 f101 = table[i_pc, i_mr - 1, i_eps]
940 f110 = table[i_pc, i_mr, i_eps - 1]
941 f111 = table[i_pc, i_mr, i_eps]
942
943 # Check for NaN values
944 corners = [f000, f001, f010, f011, f100, f101, f110, f111]
945 if any(np.isnan(c) for c in corners):
946 # Fallback to nearest neighbor
947 return table[i_pc - 1, i_mr - 1, i_eps - 1]
948
949 # Interpolation weights
950 wx = (Pc - Pc0) / (Pc1 - Pc0) if Pc1 != Pc0 else 0
951 wy = (MR - MR0) / (MR1 - MR0) if MR1 != MR0 else 0
952 wz = (eps - eps0) / (eps1 - eps0) if eps1 != eps0 else 0
953
954 # Trilinear interpolation
955 result = (
956 f000 * (1 - wx) * (1 - wy) * (1 - wz) +
957 f100 * wx * (1 - wy) * (1 - wz) +
958 f010 * (1 - wx) * wy * (1 - wz) +
959 f110 * wx * wy * (1 - wz) +
960 f001 * (1 - wx) * (1 - wy) * wz +
961 f101 * wx * (1 - wy) * wz +
962 f011 * (1 - wx) * wy * wz +
963 f111 * wx * wy * wz
964 )
965
966 return float(result)
967
968 def eval(self, MR: float, Pc: float, Pa: float = 101325.0, eps: Optional[float] = None) -> dict:
969 """
970 Evaluate CEA properties at given conditions.
971
972 Parameters:
973 -----------
974 MR : float
975 Mixture ratio (O/F)
976 Pc : float
977 Chamber pressure [Pa]
978 Pa : float
979 Ambient pressure [Pa] (default: sea level)
980 eps : float, optional
981 Expansion ratio (uses config default if None)
982
983 Returns:
984 --------
985 dict with keys:
986 cstar_ideal : float [m/s]
987 Cf_ideal : float, ambient thrust coefficient at THIS Pa: Cf_vac - Pa*eps/Pc
988 Cf_sl : float, the stored table (CEA get_PambCf at its default 14.7 psia)
989 Cf_vac : float
990 Tc : float [K]
991 gamma : float
992 R : float [J/(kg*K)]
993 M : float [kg/kmol] (molecular weight)
994 extrapolated : bool, True when Pc, MR or eps lay outside the table and were clamped
995 clamped : tuple of the clamped axis names ("Pc", "MR", "eps")
996 """
997 if eps is None:
998 eps = self.config.expansion_ratio
999
1000 Pc_in, MR_in, eps_in = float(Pc), float(MR), float(eps)
1001
1002 # Clamp (scalar; matches float(np.clip(...)) incl. NaN pass-through)
1003 Pc_clamped = _scalar_clamp(Pc_in, self.Pc_min, self.Pc_max)
1004 MR_clamped = _scalar_clamp(MR_in, self.MR_min, self.MR_max)
1005 eps_clamped = eps_in
1006 if self.use_3d:
1007 eps_clamped = _scalar_clamp(eps_in, self.eps_min, self.eps_max)
1008
1009 # print(
1010 # "[CEA DEBUG] in:",
1011 # f"Pc={Pc_in:.6g} Pa, MR={MR_in:.6g}, Pa={Pa:.6g} Pa, eps={eps_in:.6g}, use_3d={self.use_3d}"
1012 # )
1013 # print(
1014 # "[CEA DEBUG] bounds:",
1015 # f"Pc=[{self.Pc_min:.6g}, {self.Pc_max:.6g}]",
1016 # f"MR=[{self.MR_min:.6g}, {self.MR_max:.6g}]",
1017 # f"eps=[{self.eps_min:.6g}, {self.eps_max:.6g}]"
1018 # )
1019 # if (Pc_in != Pc_clamped) or (MR_in != MR_clamped) or (eps_in != eps_clamped):
1020 # print(
1021 # "[CEA DEBUG][CLAMPED]:",
1022 # f"Pc {Pc_in:.6g}->{Pc_clamped:.6g},",
1023 # f"MR {MR_in:.6g}->{MR_clamped:.6g},",
1024 # f"eps {eps_in:.6g}->{eps_clamped:.6g}"
1025 # )
1026
1027 # Interpolate
1028 if self.use_3d:
1029 cstar = float(self._trilinear_interpolate(Pc_clamped, MR_clamped, eps_clamped, self.cstar_table))
1030 Cf = float(self._trilinear_interpolate(Pc_clamped, MR_clamped, eps_clamped, self.Cf_table))
1031 Tc = float(self._trilinear_interpolate(Pc_clamped, MR_clamped, eps_clamped, self.Tc_table))
1032 gamma = float(self._trilinear_interpolate(Pc_clamped, MR_clamped, eps_clamped, self.gamma_table))
1033 R = float(self._trilinear_interpolate(Pc_clamped, MR_clamped, eps_clamped, self.R_table))
1034 M = float(self._trilinear_interpolate(Pc_clamped, MR_clamped, eps_clamped, self.M_table))
1035 Cf_vac = (float(self._trilinear_interpolate(Pc_clamped, MR_clamped, eps_clamped, self.Cf_vac_table))
1036 if self.Cf_vac_table is not None else _isentropic_cf_vac(gamma, eps_clamped))
1037 else:
1038 cstar = float(self._bilinear_interpolate(Pc_clamped, MR_clamped, self.cstar_table))
1039 Cf = float(self._bilinear_interpolate(Pc_clamped, MR_clamped, self.Cf_table))
1040 Tc = float(self._bilinear_interpolate(Pc_clamped, MR_clamped, self.Tc_table))
1041 gamma = float(self._bilinear_interpolate(Pc_clamped, MR_clamped, self.gamma_table))
1042 R = float(self._bilinear_interpolate(Pc_clamped, MR_clamped, self.R_table))
1043 M = float(self._bilinear_interpolate(Pc_clamped, MR_clamped, self.M_table))
1044 Cf_vac = (float(self._bilinear_interpolate(Pc_clamped, MR_clamped, self.Cf_vac_table))
1045 if self.Cf_vac_table is not None else _isentropic_cf_vac(gamma, eps_clamped))
1046
1047 # The ambient coefficient belongs to the Pa asked about, not to the table's 14.7 psia.
1048 Cf_amb = (Cf_vac - float(Pa) * eps_in / Pc_in) if (Pc_in > 0 and np.isfinite(Cf_vac)) else Cf
1049 clamped = tuple(name for name, a, b in (("Pc", Pc_in, Pc_clamped), ("MR", MR_in, MR_clamped),
1050 ("eps", eps_in, eps_clamped)) if a != b)
1051 out = {
1052 "cstar_ideal": cstar,
1053 "Cf_ideal": float(Cf_amb),
1054 "Cf_sl": Cf,
1055 "Cf_vac": Cf_vac, # vacuum thrust coefficient (RPA delivered-thrust basis)
1056 "Tc": Tc,
1057 "gamma": gamma,
1058 "R": R,
1059 "M": M,
1060 "extrapolated": bool(clamped),
1061 "clamped": clamped,
1062 }
1063
1064 # print(
1065 # "[CEA DEBUG] out:",
1066 # f"c*={cstar:.3f} m/s, Cf={Cf:.4f}, Tc={Tc:.1f} K, gamma={gamma:.4f}, R={R:.3f}, M={M:.3f}"
1067 # )
1068
1069 # Loud sanity flags (these are the ones that usually catch unit bugs instantly)
1070 if not (1200.0 <= cstar <= 2200.0):
1071 print("[CEA DEBUG][WARNING] c* is out of expected LOX/RP-1-ish range. Unit/table issue likely.")
1072 if not (1.05 <= gamma <= 1.40):
1073 print("[CEA DEBUG][WARNING] gamma is weird.")
1074 if not (2000.0 <= Tc <= 4200.0):
1075 print("[CEA DEBUG][WARNING] Tc is weird.")
1076 # Typical combustion-gas R is a few hundred J/(kg·K). If you see ~3000+, it's probably kJ/kmol-K or similar unit mismatch.
1077 if R > 2000.0 or R < 50.0:
1078 print("[CEA DEBUG][WARNING] R magnitude looks wrong. Possible units mismatch (J/kg-K vs something else).")
1079
1080 return out
1081
1082 def eval_cf_vac(self, MR: float, Pc: float, eps: Optional[float] = None) -> float:
1083 """Fast single-value vacuum thrust coefficient lookup (one interpolation).
1084
1085 Hot path for the native Layer-1 inner loop: avoids the full ``eval`` dict
1086 build + sanity prints. Same value ``eval(...)["Cf_vac"]`` returns.
1087 """
1088 if eps is None:
1089 eps = self.config.expansion_ratio
1090 Pc_c = _scalar_clamp(float(Pc), self.Pc_min, self.Pc_max)
1091 MR_c = _scalar_clamp(float(MR), self.MR_min, self.MR_max)
1092 if self.use_3d:
1093 eps_c = _scalar_clamp(float(eps), self.eps_min, self.eps_max)
1094 if self.Cf_vac_table is not None:
1095 return float(self._trilinear_interpolate(Pc_c, MR_c, eps_c, self.Cf_vac_table))
1096 g = float(self._trilinear_interpolate(Pc_c, MR_c, eps_c, self.gamma_table))
1097 return _isentropic_cf_vac(g, eps_c)
1098 if self.Cf_vac_table is not None:
1099 return float(self._bilinear_interpolate(Pc_c, MR_c, self.Cf_vac_table))
1100 g = float(self._bilinear_interpolate(Pc_c, MR_c, self.gamma_table))
1101 return _isentropic_cf_vac(g, float(eps))
1102
1103 @property
1104 def aux(self) -> "CEAAuxTables":
1105 """Transport, composition and exit-state tables for this propellant pair."""
1106 return get_aux_tables(self.config.ox_name, self.config.fuel_name)
1107
1108
1109# ── Auxiliary CEA tables ──────────────────────────────────────────────────────
1110# What the performance cache above does not carry, and the thermal and nozzle models need:
1111# * frozen transport (mu, cp, Pr) at the chamber and throat stations -- the Bartz inputs;
1112# * equilibrium mole fractions of the radiating and carbon-oxidising species, chamber and
1113# throat, and the throat molecular weight -- gas emissivity and graphite B';
1114# * the shifting-equilibrium exit state Pc/Pe, Te, Me over an area-ratio grid.
1115# About 50k rocketcea calls, ~40 s: built on demand, written next to the performance cache,
1116# and meant to be committed with it (CI has no rocketcea). The grid is fixed per propellant
1117# pair and wide enough for every preset, so one file serves every config of that pair.
1118CEA_AUX_SCHEMA_VERSION = 1
1119AUX_SPECIES = ("H2O", "CO2", "CO", "H2", "OH", "O2", "O", "H")
1120_AUX_MR = np.round(np.linspace(0.8, 4.0, 65), 6)
1121_AUX_PC = np.geomspace(1.0e5, 1.2e7, 16) # Pa
1122_AUX_EPS = np.geomspace(1.1, 60.0, 48)
1123_PSI = 6894.757293168361
1124_BTU_LBM_R = 4186.8 # J/(kg K)
1125_MILLIPOISE = 1.0e-4 # Pa s
1126_MCAL_CM_S_K = 0.4184 # W/(m K)
1127_AUX_MEMO: Dict[Tuple[str, str], "CEAAuxTables"] = {}
1128
1129
1130def _aux_axis(grid: np.ndarray, x: float, log: bool) -> Tuple[int, float, bool]:
1131 """Cell index, weight and clamp flag on a uniform (or log-uniform) grid."""
1132 lo, hi = float(grid[0]), float(grid[-1])
1133 xc = min(max(x, lo), hi)
1134 if log:
1135 f = (np.log(xc) - np.log(lo)) / (np.log(hi) - np.log(lo)) * (len(grid) - 1)
1136 else:
1137 f = (xc - lo) / (hi - lo) * (len(grid) - 1)
1138 i = min(int(f), len(grid) - 2)
1139 return i, f - i, xc != x
1140
1141
1142def _fill_nan_nearest(a: np.ndarray) -> np.ndarray:
1143 """Fill failed CEA points from the nearest good one along the first axis (MR), then Pc."""
1144 a = np.array(a, dtype=float)
1145 for axis in range(min(a.ndim, 2)):
1146 moved = np.moveaxis(a, axis, 0)
1147 for idx in np.ndindex(moved.shape[1:]):
1148 col = moved[(slice(None),) + idx]
1149 bad = ~np.isfinite(col)
1150 if bad.any() and (~bad).any():
1151 good = np.flatnonzero(~bad)
1152 for j in np.flatnonzero(bad):
1153 col[j] = col[good[np.argmin(np.abs(good - j))]]
1154 return a
1155
1156
1158 """Chamber/throat transport and composition on (MR, Pc); exit state on (MR, Pc, eps)."""
1159
1160 def __init__(self, ox_name: str, fuel_name: str, path: Optional[str] = None):
1161 self.ox_name, self.fuel_name = ox_name, fuel_name
1162 if path is None:
1163 root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
1164 path = os.path.join(root, "output", "cache", f"cea_aux_{ox_name}_{fuel_name}.npz")
1165 self.path = path
1166 self.MR, self.Pc, self.eps = _AUX_MR, _AUX_PC, _AUX_EPS
1167 self.t: Dict[str, np.ndarray] = {}
1168 if not self._load():
1169 self._build()
1170
1171 def _load(self) -> bool:
1172 if not os.path.exists(self.path):
1173 return False
1174 data = _load_npz_tables(self.path)
1175 try:
1176 meta = json.loads(data["meta"].tolist())
1177 except Exception:
1178 return False
1179 if (meta.get("schema") != CEA_AUX_SCHEMA_VERSION or meta.get("ox_name") != self.ox_name
1180 or meta.get("fuel_name") != self.fuel_name):
1181 return False
1182 if not (np.array_equal(data["MR"], self.MR) and np.allclose(data["Pc"], self.Pc)
1183 and np.allclose(data["eps"], self.eps)):
1184 return False
1185 self.t = {k: v for k, v in data.items() if k not in ("meta", "MR", "Pc", "eps")}
1186 return True
1187
1188 def _build(self) -> None:
1189 C = _get_CEA_Obj()(oxName=self.ox_name, fuelName=self.fuel_name)
1190 nM, nP, nE = len(self.MR), len(self.Pc), len(self.eps)
1191 t = {k: np.full((nM, nP), np.nan) for k in
1192 ("mu_c", "cp_c", "Pr_c", "mu_t", "cp_t", "Pr_t", "MW_t")}
1193 for stn in ("c", "t"):
1194 for sp in AUX_SPECIES:
1195 t[f"x_{sp}_{stn}"] = np.zeros((nM, nP))
1196 for k in ("lnPcPe", "Te", "Me"):
1197 t[k] = np.full((nM, nP, nE), np.nan)
1198 for i, MR in enumerate(self.MR):
1199 for j, Pc in enumerate(self.Pc):
1200 pc = float(Pc) / _PSI
1201 try:
1202 for stn, fn in (("c", C.get_Chamber_Transport), ("t", C.get_Throat_Transport)):
1203 cp, mu, _k, pr = fn(Pc=pc, MR=float(MR), eps=2.0, frozen=1)
1204 t[f"cp_{stn}"][i, j] = cp * _BTU_LBM_R
1205 t[f"mu_{stn}"][i, j] = mu * _MILLIPOISE
1206 t[f"Pr_{stn}"][i, j] = pr
1207 t["MW_t"][i, j] = C.get_Throat_MolWt_gamma(Pc=pc, MR=float(MR), eps=2.0)[0]
1208 _, xs = C.get_SpeciesMoleFractions(Pc=pc, MR=float(MR), eps=2.0, frozen=0,
1209 frozenAtThroat=0, min_fraction=1e-8)
1210 for key, vals in xs.items():
1211 sp = key.lstrip("*")
1212 if sp in AUX_SPECIES:
1213 t[f"x_{sp}_c"][i, j] = vals[1]
1214 t[f"x_{sp}_t"][i, j] = vals[2]
1215 except Exception:
1216 pass
1217 for k, e in enumerate(self.eps):
1218 try:
1219 pcpe = C.get_PcOvPe(Pc=pc, MR=float(MR), eps=float(e), frozen=0)
1220 te = C.get_Temperatures(Pc=pc, MR=float(MR), eps=float(e), frozen=0)[2] / 1.8
1221 me = C.get_MachNumber(Pc=pc, MR=float(MR), eps=float(e), frozen=0)
1222 if pcpe > 1.0 and te > 0 and me > 1.0:
1223 t["lnPcPe"][i, j, k] = np.log(pcpe)
1224 t["Te"][i, j, k] = te
1225 t["Me"][i, j, k] = me
1226 except Exception:
1227 pass
1228 self.t = {k: _fill_nan_nearest(v) for k, v in t.items()}
1229 meta = json.dumps({"schema": CEA_AUX_SCHEMA_VERSION, "ox_name": self.ox_name,
1230 "fuel_name": self.fuel_name,
1231 "note": "frozen transport, equilibrium composition (index 1 chamber, "
1232 "2 throat), shifting-equilibrium exit state"})
1233 os.makedirs(os.path.dirname(self.path), exist_ok=True)
1234 tmp = self.path + f".tmp{os.getpid()}.npz"
1235 np.savez_compressed(tmp, meta=np.array(meta), MR=self.MR, Pc=self.Pc, eps=self.eps, **self.t)
1236 os.replace(tmp, self.path)
1237
1238 def _bilinear(self, name: str, MR: float, Pc: float) -> Tuple[float, bool]:
1239 i, wi, ci = _aux_axis(self.MR, float(MR), False)
1240 j, wj, cj = _aux_axis(self.Pc, float(Pc), True)
1241 a = self.t[name]
1242 v = ((1 - wi) * (1 - wj) * a[i, j] + wi * (1 - wj) * a[i + 1, j]
1243 + (1 - wi) * wj * a[i, j + 1] + wi * wj * a[i + 1, j + 1])
1244 return float(v), (ci or cj)
1245
1246 def transport(self, MR: float, Pc: float, station: str = "chamber") -> Dict[str, float]:
1247 """Frozen-composition mu [Pa s], cp [J/(kg K)], Pr, k = mu cp / Pr [W/(m K)]."""
1248 s = "c" if station == "chamber" else "t"
1249 mu, e1 = self._bilinear(f"mu_{s}", MR, Pc)
1250 cp, e2 = self._bilinear(f"cp_{s}", MR, Pc)
1251 pr, e3 = self._bilinear(f"Pr_{s}", MR, Pc)
1252 return {"mu": mu, "cp": cp, "Pr": pr, "k": mu * cp / pr, "extrapolated": e1 or e2 or e3}
1253
1254 def composition(self, MR: float, Pc: float, station: str = "chamber") -> Dict[str, float]:
1255 """Equilibrium mole fractions of AUX_SPECIES; 'MW' is the throat molecular weight."""
1256 s = "c" if station == "chamber" else "t"
1257 out: Dict[str, float] = {}
1258 ext = False
1259 for sp in AUX_SPECIES:
1260 out[sp], e = self._bilinear(f"x_{sp}_{s}", MR, Pc)
1261 out[sp] = max(out[sp], 0.0)
1262 ext = ext or e
1263 out["MW"], _ = self._bilinear("MW_t", MR, Pc)
1264 out["extrapolated"] = ext
1265 return out
1266
1267 def _exit(self, name: str, MR: float, Pc: float, eps: float) -> Tuple[float, bool]:
1268 i, wi, ci = _aux_axis(self.MR, float(MR), False)
1269 j, wj, cj = _aux_axis(self.Pc, float(Pc), True)
1270 k, wk, ck = _aux_axis(self.eps, float(eps), True)
1271 a = self.t[name]
1272 v = 0.0
1273 for di, fi in ((0, 1 - wi), (1, wi)):
1274 for dj, fj in ((0, 1 - wj), (1, wj)):
1275 for dk, fk in ((0, 1 - wk), (1, wk)):
1276 v += fi * fj * fk * a[i + di, j + dj, k + dk]
1277 return float(v), (ci or cj or ck)
1278
1279 def exit_state(self, MR: float, Pc: float, eps: float) -> Dict[str, float]:
1280 """Shifting-equilibrium exit pressure [Pa], temperature [K] and Mach number."""
1281 ln_r, e1 = self._exit("lnPcPe", MR, Pc, eps)
1282 Te, _ = self._exit("Te", MR, Pc, eps)
1283 Me, _ = self._exit("Me", MR, Pc, eps)
1284 return {"P_exit": float(Pc) / np.exp(ln_r), "T_exit": Te, "M_exit": Me,
1285 "PcOvPe": float(np.exp(ln_r)), "extrapolated": e1}
1286
1287 def eps_for_exit_pressure(self, MR: float, Pc: float, Pe: float) -> float:
1288 """Area ratio whose shifting-equilibrium exit pressure is Pe (Sutton: optimum at Pe = Pa)."""
1289 target = np.log(float(Pc) / float(Pe))
1290 f = lambda e: self._exit("lnPcPe", MR, Pc, e)[0] - target
1291 lo, hi = float(self.eps[0]), float(self.eps[-1])
1292 flo, fhi = f(lo), f(hi)
1293 if flo >= 0:
1294 return lo
1295 if fhi <= 0:
1296 return hi
1297 from scipy.optimize import brentq
1298 return float(brentq(f, lo, hi, xtol=1e-10, rtol=1e-12))
1299
1300
1301def get_aux_tables(ox_name: str, fuel_name: str) -> CEAAuxTables:
1302 key = (ox_name, fuel_name)
1303 tab = _AUX_MEMO.get(key)
1304 if tab is None:
1305 tab = CEAAuxTables(ox_name, fuel_name)
1306 _AUX_MEMO[key] = tab
1307 return tab
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Dict[str, float] transport(self, float MR, float Pc, str station="chamber")
__init__(self, str ox_name, str fuel_name, Optional[str] path=None)
Dict[str, float] composition(self, float MR, float Pc, str station="chamber")
float eps_for_exit_pressure(self, float MR, float Pc, float Pe)
Tuple[float, bool] _exit(self, str name, float MR, float Pc, float eps)
Tuple[float, bool] _bilinear(self, str name, float MR, float Pc)
Dict[str, float] exit_state(self, float MR, float Pc, float eps)
float eval_cf_vac(self, float MR, float Pc, Optional[float] eps=None)
float _trilinear_interpolate(self, float Pc, float MR, float eps, np.ndarray table)
Definition cea_cache.py:914
dict eval(self, float MR, float Pc, float Pa=101325.0, Optional[float] eps=None)
Definition cea_cache.py:968
_build_cache_sequential(self, int total_points)
Definition cea_cache.py:612
__init__(self, CEAConfig config)
Definition cea_cache.py:329
_build_cache_parallel(self, int n_workers, int total_points)
Definition cea_cache.py:693
float _bilinear_interpolate(self, float Pc, float MR, np.ndarray table)
Definition cea_cache.py:803
Tuple[float, float, float, float, float, float] parse_cea_basic(str out)
Definition cea_cache.py:162
safe_print(*args, **kwargs)
Definition cea_cache.py:121
List[Tuple[int, int, int, float, float, float, float, float, float]] _compute_cea_point_chunk(List[Tuple[int, int, int, float, float, float]] chunk, str ox_name, str fuel_name, Optional[float] expansion_ratio=None, Optional[Any] lock=None)
Definition cea_cache.py:220
Dict[str, np.ndarray] _load_npz_tables(str path)
Definition cea_cache.py:305
CEAAuxTables get_aux_tables(str ox_name, str fuel_name)
float _scalar_clamp(float x, float lo, float hi)
Definition cea_cache.py:85
float _isentropic_cf_vac(float gamma, float eps)
Definition cea_cache.py:49
np.ndarray _fill_nan_nearest(np.ndarray a)
Tuple[int, float, bool] _aux_axis(np.ndarray grid, float x, bool log)
TOML_NODISCARD_CTOR time() noexcept=default
static void decode(const disk_t *d)