1"""RocketCEA wrapper with MR/Pc caching and bilinear interpolation"""
10from typing
import Tuple, Optional, List, Dict, Any
11from multiprocessing
import Pool, cpu_count, Manager
12from functools
import partial
14from .config_schemas
import CEAConfig
32 env = os.environ.get(
"ED_ALLOW_CEA_BUILD")
35 return env.strip().lower()
in (
"1",
"true",
"yes",
"on")
46CEA_TABLE_SCHEMA_VERSION = 2
50 """Ideal vacuum thrust coefficient from gamma + area ratio (isentropic, frozen).
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.
59 if not (g > 1.0)
or not (e > 1.0):
61 gm1, gp1 = g - 1.0, g + 1.0
65 t = (2.0 / gp1) * (1.0 + 0.5 * gm1 * M * M)
66 AR = (1.0 / M) * t ** (gp1 / (2.0 * gm1))
68 t2 = (2.0 / gp1) * (1.0 + 0.5 * gm1 * (M + dM) ** 2)
69 AR2 = (1.0 / (M + dM)) * t2 ** (gp1 / (2.0 * gm1))
76 if abs(Mn - M) < 1e-10:
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
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."""
102 """Lazy import of CEA_Obj - only import when needed for cache building."""
106 from rocketcea.cea_obj
import CEA_Obj
108 except ImportError
as e:
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}"
117logger = logging.getLogger(
"evaluate")
120_original_print =
print
122 """Log function that handles Unicode encoding errors gracefully"""
125 message =
' '.join(str(arg)
for arg
in args)
127 except UnicodeEncodeError:
131 if isinstance(arg, str):
133 safe_str = arg.encode(
'ascii', errors=
'replace').
decode(
'ascii')
134 safe_args.append(safe_str)
136 safe_args.append(arg)
138 message =
' '.join(str(arg)
for arg
in safe_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)
153 if hasattr(sys.stdout,
'reconfigure'):
154 sys.stdout.reconfigure(encoding=
'utf-8', errors=
'replace')
155 elif hasattr(sys.stdout,
'buffer'):
157 sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding=
'utf-8', errors=
'replace')
158except (AttributeError, ValueError, OSError):
164 Extract Tc, gamma, R, c*, and Isp from NASA CEA text output (chamber values only).
173 Isp : float [s] (ambient Isp, or NaN if not found)
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
180 mw_match = re.search(
r'M,\s*\(1/n\)\s+([\d.E+-]+)', block)
182 M = float(mw_match.group(1))
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
193 gamma_match = re.search(
r'GAMMAs\s+([\d.E+-]+)', block)
194 gamma = float(gamma_match.group(1))
if gamma_match
else np.nan
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
200 if re.search(
r'CSTAR.*FT/SEC', block, re.IGNORECASE):
205 isp_match = re.search(
r'Isp[, ]*(?:M/SEC|SEC|M/S)?\s+([\d.E+-]+)', block, re.IGNORECASE)
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
211 return Tc, gamma, R, cstar, M, Isp
215 chunk: List[Tuple[int, int, int, float, float, float]],
218 expansion_ratio: Optional[float] =
None,
219 lock: Optional[Any] =
None,
220) -> List[Tuple[int, int, int, float, float, float, float, float, float]]:
222 Worker function for parallel CEA cache building.
224 Computes CEA properties for a chunk of grid points.
225 This function must be at module level for multiprocessing.
229 chunk : List[Tuple[int, int, int, float, float, float]]
230 List of (i, j, k_idx, Pc_psia, MR, eps) tuples
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)
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
248 chamber = CEA_Obj(oxName=ox_name, fuelName=fuel_name)
251 for i, j, k_idx, Pc_psia, MR, eps
in chunk:
253 eps_to_use = eps
if expansion_ratio
is None else expansion_ratio
260 out = chamber.get_full_cea_output(Pc=Pc_psia, MR=MR, eps=eps_to_use)
266 Cf_ideal = chamber.get_PambCf(Pc=Pc_psia, MR=MR, eps=eps_to_use)[1]
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
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
282 results.append((i, j, k_idx, cstar, Cf_ideal, Tc, gamma, R, M, Cf_vac))
286 except Exception
as e:
289 results.append((i, j, k_idx, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan))
302_NPZ_TABLE_MEMO: Dict[Tuple[str, int, int], Dict[str, np.ndarray]] = {}
306 """Return the fully-materialised contents of a cache .npz, memoised per file version."""
309 key = (os.path.abspath(path), st.st_mtime_ns, st.st_size)
313 hit = _NPZ_TABLE_MEMO.get(key)
316 with np.load(path)
as z:
317 tables = {name: z[name]
for name
in z.files}
320 if len(_NPZ_TABLE_MEMO) > 8:
321 _NPZ_TABLE_MEMO.clear()
322 _NPZ_TABLE_MEMO[key] = tables
327 """CEA cache with bilinear interpolation"""
334 if os.path.isabs(config.cache_file):
337 project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
339 os.path.abspath(config.cache_file),
340 os.path.join(project_root, config.cache_file),
343 for cand
in candidates:
344 if os.path.exists(cand):
347 self.
cache_file = resolved
if resolved
is not None else candidates[1]
350 self.
use_3d = config.eps_range
is not None
388 """Load CEA data from cache file"""
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),
400 "dimensions": 3
if self.
use_3d else 2,
401 "eps_range": list(self.
config.eps_range)
if self.
use_3d else None,
407 meta_loaded = json.loads(data[
"meta"].tolist())
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:
420 loaded_schema = meta_loaded_dict.get(
"table_schema", 1)
421 if loaded_schema != meta_expected_dict[
"table_schema"]:
423 f
"CEA cache table-schema mismatch: cache has v{loaded_schema}, "
424 f
"code expects v{meta_expected_dict['table_schema']}; regenerating."
428 if meta_loaded_dict.get(
"ox_name") != meta_expected_dict[
"ox_name"]:
430 if meta_loaded_dict.get(
"fuel_name") != meta_expected_dict[
"fuel_name"]:
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")
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:
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")
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")
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}")
468 if not _meta_matches(meta_loaded, meta_expected):
469 print(
"[WARNING] CEA cache metadata does not match configuration; regenerating...")
481 self.
Cf_vac_table = data[
"Cf_vac"]
if "Cf_vac" in data
else None
493 Pc_loaded = data[
"Pc"]
494 MR_loaded = data[
"MR"]
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...")
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...")
514 print(
"[WARNING] 3D cache missing eps grid, rebuilding...")
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())
530 print(f
"[INFO] Using cached grids - trilinear interpolation will handle any range differences")
533 """Build CEA lookup tables with parallel processing support"""
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."
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)")
551 print(f
" eps in [{self.eps_min:.2f}, {self.eps_max:.2f}] ({self.n_points} points)")
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!")
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")
564 is_windows = sys.platform ==
'win32'
565 use_parallel = getattr(self.
config,
'use_parallel_cea_build',
False)
566 n_workers = getattr(self.
config,
'cea_parallel_workers',
None)
569 should_use_parallel = use_parallel
and total_points > 100
576 and sys.platform ==
"darwin"
577 and threading.current_thread()
is not threading.main_thread()
580 " [WARNING] macOS + background thread: disabling parallel CEA build "
581 "(avoids multiprocessing OSError errno 1); using sequential cache build."
583 should_use_parallel =
False
585 if should_use_parallel
and not is_windows:
587 if n_workers
is None:
588 n_workers =
min(cpu_count(), 4)
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")
594 except (NameError, ImportError, MemoryError, Exception)
as e:
595 print(f
" [ERROR] Parallel processing failed: {e}")
596 print(f
" [FALLBACK] Building cache sequentially")
598 elif should_use_parallel
and is_windows:
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)")
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)")
610 print(f
"[SAVED] CEA cache saved to {self.cache_file}")
613 """Sequential CEA cache building (original method)"""
616 chamber = CEA_Obj(oxName=self.
config.ox_name, fuelName=self.
config.fuel_name)
628 Pc_psia_grid = self.
Pc_grid / 6894.76
632 for i, Pc_psia
in enumerate(Pc_psia_grid):
633 for j, MR
in enumerate(self.
MR_grid):
636 for k_idx, eps
in enumerate(eps_list):
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}")
646 out = chamber.get_full_cea_output(Pc=Pc_psia, MR=MR, eps=eps)
650 Cf_ideal = chamber.get_PambCf(Pc=Pc_psia, MR=MR, eps=eps)[1]
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
655 Cf_vac = chamber.get_Isp(Pc=Pc_psia, MR=MR, eps=eps) * 9.80665 / cstar
if cstar > 0
else np.nan
661 self.
Cf_table[i, j, k_idx] = Cf_ideal
676 except Exception
as e:
677 print(f
" [WARNING] Error at Pc={Pc_psia:.1f} psia, MR={MR:.2f}, eps={eps:.1f}: {e}")
683 self.
R_table[i, j, k_idx] = np.nan
684 self.
M_table[i, j, k_idx] = np.nan
694 """Parallel CEA cache building with chunking"""
697 Pc_psia_grid = self.
Pc_grid / 6894.76
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))
716 chunk_size =
max(1, len(grid_points) // (n_workers * 4))
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")
723 cea_lock = manager.Lock()
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,
740 with Pool(processes=n_workers)
as pool:
741 results = pool.imap_unordered(worker_func, chunks)
743 for chunk_results
in results:
744 completed += len(chunk_results)
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")
752 for i, j, k_idx, cstar, Cf, Tc, gamma, R, M, Cf_vac
in chunk_results:
771 """Save CEA data to cache file"""
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),
780 "dimensions": 3
if self.
use_3d else 2,
781 "eps_range": list(self.
config.eps_range)
if self.
use_3d else None,
794 "meta": np.array(json.dumps(meta))
801 np.savez_compressed(self.
cache_file, **save_dict)
805 Bilinear interpolation in (Pc, MR) space with robust error handling.
807 Uses improved boundary handling and NaN management for numerical stability.
810 if not (np.isfinite(Pc)
and np.isfinite(MR)):
811 raise ValueError(f
"Non-finite interpolation point: Pc={Pc}, MR={MR}")
814 i_pc = np.searchsorted(self.
Pc_grid, Pc)
815 i_mr = np.searchsorted(self.
MR_grid, MR)
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)
839 if Pc1 <= Pc0
or MR1 <= MR0:
840 raise ValueError(f
"Invalid grid spacing: Pc grid or MR grid not monotonic")
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]
849 valid_values = [v
for v
in [f00, f01, f10, f11]
if np.isfinite(v)]
851 if len(valid_values) == 0:
854 elif len(valid_values) < 4:
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),
867 if np.isfinite(f_val):
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)
876 weights = np.array(weights)
877 weights = weights / np.sum(weights)
878 result = np.sum(np.array(values) * weights)
884 wx = (Pc - Pc0) / (Pc1 - Pc0)
if Pc1 != Pc0
else 0.0
885 wy = (MR - MR0) / (MR1 - MR0)
if MR1 != MR0
else 0.0
888 wx = np.clip(wx, 0.0, 1.0)
889 wy = np.clip(wy, 0.0, 1.0)
892 result = (f00 * (1 - wx) * (1 - wy) +
893 f10 * wx * (1 - wy) +
894 f01 * (1 - wx) * wy +
898 if not np.isfinite(result):
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),
906 valid_distances = [(d, v)
for d, v
in distances
if np.isfinite(v)]
908 result =
min(valid_distances, key=
lambda x: x[0])[1]
915 """Trilinear interpolation in (Pc, MR, eps) space"""
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)
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]
944 corners = [f000, f001, f010, f011, f100, f101, f110, f111]
945 if any(np.isnan(c)
for c
in corners):
947 return table[i_pc - 1, i_mr - 1, i_eps - 1]
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
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 +
968 def eval(self, MR: float, Pc: float, Pa: float = 101325.0, eps: Optional[float] =
None) -> dict:
970 Evaluate CEA properties at given conditions.
977 Chamber pressure [Pa]
979 Ambient pressure [Pa] (default: sea level)
980 eps : float, optional
981 Expansion ratio (uses config default if None)
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)
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")
998 eps = self.
config.expansion_ratio
1000 Pc_in, MR_in, eps_in = float(Pc), float(MR), float(eps)
1005 eps_clamped = eps_in
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)
1052 "cstar_ideal": cstar,
1053 "Cf_ideal": float(Cf_amb),
1060 "extrapolated": bool(clamped),
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.")
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).")
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).
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.
1089 eps = self.
config.expansion_ratio
1104 def aux(self) -> "CEAAuxTables":
1105 """Transport, composition and exit-state tables for this propellant 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)
1122_AUX_EPS = np.geomspace(1.1, 60.0, 48)
1123_PSI = 6894.757293168361
1126_MCAL_CM_S_K = 0.4184
1127_AUX_MEMO: Dict[Tuple[str, str],
"CEAAuxTables"] = {}
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])
1135 f = (np.log(xc) - np.log(lo)) / (np.log(hi) - np.log(lo)) * (len(grid) - 1)
1137 f = (xc - lo) / (hi - lo) * (len(grid) - 1)
1138 i =
min(int(f), len(grid) - 2)
1139 return i, f - i, xc != x
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))]]
1158 """Chamber/throat transport and composition on (MR, Pc); exit state on (MR, Pc, eps)."""
1160 def __init__(self, ox_name: str, fuel_name: str, path: Optional[str] =
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")
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():
1172 if not os.path.exists(self.
path):
1176 meta = json.loads(data[
"meta"].tolist())
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):
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)):
1185 self.
t = {k: v
for k, v
in data.items()
if k
not in (
"meta",
"MR",
"Pc",
"eps")}
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
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]
1217 for k, e
in enumerate(self.
eps):
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
1229 meta = json.dumps({
"schema": CEA_AUX_SCHEMA_VERSION,
"ox_name": self.
ox_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)
1238 def _bilinear(self, name: str, MR: float, Pc: float) -> Tuple[float, bool]:
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)
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}
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] = {}
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)
1263 out[
"MW"], _ = self.
_bilinear(
"MW_t", MR, Pc)
1264 out[
"extrapolated"] = ext
1267 def _exit(self, name: str, MR: float, Pc: float, eps: float) -> Tuple[float, bool]:
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)
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}
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)
1297 from scipy.optimize
import brentq
1298 return float(brentq(f, lo, hi, xtol=1e-10, rtol=1e-12))
1302 key = (ox_name, fuel_name)
1303 tab = _AUX_MEMO.get(key)
1306 _AUX_MEMO[key] = tab
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)
dict eval(self, float MR, float Pc, float Pa=101325.0, Optional[float] eps=None)
_build_cache_sequential(self, int total_points)
__init__(self, CEAConfig config)
_build_cache_parallel(self, int n_workers, int total_points)
float _bilinear_interpolate(self, float Pc, float MR, np.ndarray table)
Tuple[float, float, float, float, float, float] parse_cea_basic(str out)
safe_print(*args, **kwargs)
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)
Dict[str, np.ndarray] _load_npz_tables(str path)
CEAAuxTables get_aux_tables(str ox_name, str fuel_name)
float _scalar_clamp(float x, float lo, float hi)
bool _cea_build_allowed()
float _isentropic_cf_vac(float gamma, float eps)
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)