1"""Live config switching for injector type & propellant (UNIFICATION_PLAN Phase 6 — UI exposure).
3The goal: injector type and propellant are FIRST-CLASS toggles in the UI, not buried in YAML. When the
4user flips either, the config must auto-reconcile to the RIGHT physics:
5 * injector type -> LOAD THE CANONICAL CONFIG for that type WHOLESALE (configs/canonical/<type>.yaml).
6 The two canonical configs are independent, proven starting guesses that deliberately do NOT stay
7 in sync — pintle carries fixed Cd 0.40/0.65 (main physics), impinging carries geometry-Cd 0.60
8 (fork physics). Switching types means "load a whole new config with different values for
9 everything", which is the point: the Cd that converges for a pintle annulus is nonsense for a
10 doublet jet, so each type owns its own coherent block. (apply_injector_type below is the legacy
11 in-place patcher, kept for same-type reconciliation and back-compat.)
12 * propellant -> overlay the preset's fluids + CEA identity (preset WINS here — this is a switch,
13 not a load-time merge, so stale fluid/CEA values are replaced, not preserved). Cheaper than an
14 injector switch: it just autofills propellant identity onto the current geometry.
16When a switch leaves the chamber geometry seeded for a different injector/propellant than is now live,
17we STAMP design_valid_for and forward mode warns ("needs re-optimization") rather than silently
18trusting a stale chamber (design_staleness).
20Operates on plain dicts so it composes with the config router's merge/validate flow.
23from __future__
import annotations
27from pathlib
import Path
28from typing
import Any, Dict, Optional
34_log = logging.getLogger(__name__)
35_PROJECT_ROOT = Path(__file__).resolve().parents[2]
36_CANONICAL_DIR = _PROJECT_ROOT /
"configs" /
"canonical"
40 """Path to the canonical starting config for an injector type (validates the type)."""
41 bindings_for(injector_type)
42 return _CANONICAL_DIR / f
"{injector_type}.yaml"
46 """Load the canonical starting config for ``injector_type`` as a fully-resolved dict.
48 Uses the real loader (io.load_config) so the propellant preset is merged exactly as a normal
49 load would — fluids/CEA identity included — then stamps design_valid_for to this config's own
50 {injector, propellant} so a fresh canonical load is NOT flagged stale (it is internally coherent).
51 Lazy import of io avoids an import cycle (io has no dependency on this module).
55 raise FileNotFoundError(
56 f
"No canonical config for injector '{injector_type}' at {path}. "
57 f
"Expected configs/canonical/{injector_type}.yaml")
59 model = load_config(str(path))
60 cfg = model.model_dump(mode=
"json")
61 cfg[
"design_valid_for"] = {
62 "injector": injector_type,
63 "propellant": cfg.get(
"propellant_preset"),
70 return sorted(INJECTOR_GEOMETRY_TEMPLATES)
74 d = _PROJECT_ROOT /
"configs" /
"propellants"
75 return sorted(p.stem
for p
in d.glob(
"*.yaml"))
if d.is_dir()
else []
79 keep_declared_cd: bool =
False) -> Dict[str, Any]:
80 """Switch injector type IN PLACE-ish (returns new dict): replace geometry with the type template
81 (preserve existing geometry only if the type is unchanged), then set the physics bindings.
83 ``keep_declared_cd``: on a SAME-type call, fill only the discharge keys the config leaves out
84 and never overwrite one it declares. The live switch path uses it (DEF-04). The load path
85 (io.load_config) still re-stamps, because three shipped methalox configs carry hand-written
86 Cd values that have only ever been evaluated after that overwrite."""
87 b = bindings_for(injector_type)
89 inj = dict(cfg.get(
"injector")
or {})
90 current_type = inj.get(
"type")
91 inj[
"type"] = injector_type
92 if current_type != injector_type
or "geometry" not in inj:
93 inj[
"geometry"] = default_geometry_for(injector_type)
98 spray = dict(cfg.get(
"spray")
or {})
99 smd = dict(spray.get(
"smd")
or {})
100 smd[
"model"] = b.expected_smd_model
if b.expected_smd_model
is not None else "lefebvre"
111 discharge = dict(cfg.get(
"discharge")
or {})
112 baseline = default_discharge_for(injector_type)
113 same_type = keep_declared_cd
and current_type == injector_type
114 for side
in (
"oxidizer",
"fuel"):
115 if side
not in discharge
or not isinstance(discharge[side], dict):
117 d = dict(discharge[side])
118 if baseline.get(side)
and same_type:
119 for k, v
in baseline[side].items():
122 elif k !=
"use_geometry_cd" and d[k] != v:
123 _log.info(
"discharge.%s.%s = %r kept (the %s baseline is %r)",
124 side, k, d[k], injector_type, v)
125 elif baseline.get(side):
129 user_ugc = d.get(
"use_geometry_cd",
None)
131 for stale
in (
"d_ref_m",
"cd_small_hole_exponent",
"cd_large_hole_log_gain",
132 "cd_inf_max",
"cd_inf_min_geom",
"d_min_m"):
134 d.update(baseline[side])
135 if user_ugc
is not None:
136 d[
"use_geometry_cd"] = bool(user_ugc)
138 d[
"use_geometry_cd"] = b.expected_use_geometry_cd
140 cfg[
"discharge"] = discharge
141 _log.info(
"switched injector -> %s (smd.model=%s, use_geometry_cd=%s)",
142 injector_type, b.expected_smd_model, b.expected_use_geometry_cd)
147 """Switch propellant: overlay the preset's fluids + CEA IDENTITY (preset wins — replaces stale
148 fluid names / CEA names from the previous propellant). Design-owned CEA fields (expansion_ratio,
149 ranges, n_points, cache_file) and everything else are preserved from the current config."""
150 name = str(preset_name).strip().lower()
154 inj_type = ((config
or {}).get(
"injector")
or {}).get(
"type")
157 cfg[
"propellant_preset"] =
"custom"
160 preset_path = _PROJECT_ROOT /
"configs" /
"propellants" / f
"{name}.yaml"
161 if not preset_path.exists():
162 raise FileNotFoundError(
163 f
"Unknown propellant '{name}'. Available: {available_propellants()}")
164 preset = yaml.safe_load(preset_path.read_text())
or {}
166 cfg[
"propellant_preset"] = name
169 if "fluids" in preset:
170 cfg[
"fluids"] = preset[
"fluids"]
173 preset_cea = (preset.get(
"combustion")
or {}).get(
"cea")
or {}
175 comb = dict(cfg.get(
"combustion")
or {})
176 cea = dict(comb.get(
"cea")
or {})
177 for identity_key
in (
"ox_name",
"fuel_name",
"Pc_range",
"MR_range",
"eps_range",
178 "n_points",
"cache_file"):
179 if identity_key
in preset_cea:
180 cea[identity_key] = preset_cea[identity_key]
182 cfg[
"combustion"] = comb
190 preset_smd = (preset.get(
"spray")
or {}).get(
"smd")
or {}
191 gas_keys = [k
for k
in (
"chamber_gas_R",
"chamber_gas_T")
if k
in preset_smd]
193 spray = dict(cfg.get(
"spray")
or {})
194 smd = dict(spray.get(
"smd")
or {})
196 smd[k] = preset_smd[k]
203 if old_preset != name:
204 rec_of = ((preset.get(
"design_requirements")
or {}).get(
"optimal_of_ratio"))
205 if rec_of
is not None:
206 req = dict(cfg.get(
"design_requirements")
or {})
207 prev = req.get(
"optimal_of_ratio")
208 req[
"optimal_of_ratio"] = float(rec_of)
209 cfg[
"design_requirements"] = req
210 cg = cfg.get(
"chamber_geometry")
211 if isinstance(cg, dict):
212 cfg[
"chamber_geometry"] = {**cg,
"design_MR": float(rec_of)}
213 _log.warning(
"propellant %s -> %s: O/F target %s -> %s (the %s preset's design point)",
214 old_preset, name, prev, rec_of, name)
218 _log.info(
"switched propellant -> %s (fluids+CEA identity+chamber gas overlaid)", name)
223 """{preset: (canonical oxidizer name, canonical fuel name)} for every shipped preset."""
228 p = yaml.safe_load((_PROJECT_ROOT /
"configs" /
"propellants" / f
"{name}.yaml").read_text())
231 fl = (p
or {}).get(
"fluids")
or {}
232 out[name] = (_canon_fluid((fl.get(
"oxidizer")
or {}).get(
"name")),
233 _canon_fluid((fl.get(
"fuel")
or {}).get(
"name")))
238 """The preset whose oxidizer and fuel a preset-less config carries; "custom" when its fluids
239 match no preset; None when it has no fluids at all."""
240 fl = (config
or {}).get(
"fluids")
or {}
244 pair = (_canon_fluid((fl.get(
"oxidizer")
or {}).get(
"name")),
245 _canon_fluid((fl.get(
"fuel")
or {}).get(
"name")))
257CHAMBER_GAS_REFERENCE_PC_PSI = 430.0
261 """Set spray.smd.chamber_gas_R/T from the CEA table at the design's O/F and target Pc.
263 The Ingebo Weber number and the evaporation model read one representative chamber gas; a
264 preset stamps it at the preset's own O/F, which is wrong as soon as the target moves (DEF-11:
265 3094 K at O/F 1.35 against 3225 K at 1.5). Leaves the config unchanged -- and says so -- when
266 the table is not on disk (never triggers a multi-minute CEA build from a UI toggle) or the
267 point lies outside it."""
270 cea = dict(((cfg.get(
"combustion")
or {}).get(
"cea"))
or {})
271 req = cfg.get(
"design_requirements")
or {}
272 of = req.get(
"optimal_of_ratio")
273 pc_psi = req.get(
"target_chamber_pressure_psi")
or CHAMBER_GAS_REFERENCE_PC_PSI
274 cache_file = cea.get(
"cache_file")
275 if of
is None or not cache_file
or not cea.get(
"MR_range"):
277 path = cache_file
if os.path.isabs(cache_file)
else str(_PROJECT_ROOT / cache_file)
278 if not os.path.exists(path):
279 _log.warning(
"chamber gas not derived: no CEA table at %s (keeping the preset's R/T)", path)
281 lo, hi = cea[
"MR_range"]
282 if not float(lo) <= float(of) <= float(hi):
283 _log.warning(
"chamber gas not derived: O/F %s outside the CEA table [%s, %s]", of, lo, hi)
291 meta = json.loads(_load_npz_tables(path)[
"meta"].tolist())
292 wanted = {
"table_schema": CEA_TABLE_SCHEMA_VERSION,
"ox_name": cea.get(
"ox_name"),
293 "fuel_name": cea.get(
"fuel_name"),
"n_points": cea.get(
"n_points"),
294 "dimensions": 3
if cea.get(
"eps_range")
is not None else 2}
295 if any(meta.get(k, 1
if k ==
"table_schema" else None) != v
for k, v
in wanted.items()):
296 _log.warning(
"chamber gas not derived: %s is not the table this config asks for", path)
298 if cea.get(
"expansion_ratio")
is None:
299 cea[
"expansion_ratio"] = float((cea.get(
"eps_range")
or [5.0])[0])
300 cea[
"cache_file"] = path
301 r = CEACache(CEAConfig(**cea)).eval(float(of), float(pc_psi) * 6894.757293168)
302 if r.get(
"extrapolated"):
303 _log.warning(
"chamber gas not derived: (O/F %s, %s psia) outside the CEA table", of, pc_psi)
305 T, R = float(r[
"Tc"]), float(r[
"R"])
306 except Exception
as e:
307 _log.warning(
"chamber gas not derived (%s); keeping the preset's R/T", e)
309 spray = dict(cfg.get(
"spray")
or {})
310 smd = dict(spray.get(
"smd")
or {})
311 smd[
"chamber_gas_T"] = round(T, 1)
312 smd[
"chamber_gas_R"] = round(R, 2)
315 _log.info(
"chamber gas from CEA at O/F %s, %s psia: T %.1f K, R %.2f J/(kg K)", of, pc_psi, T, R)
320 new_preset: str, inj_type: Optional[str]) ->
None:
321 """A propellant overlay keeps the EXISTING chamber geometry, which was seeded for the previous
322 propellant. If the propellant actually changed, record that prior {injector, propellant} as the
323 design's provenance so forward mode flags the chamber as stale (needs re-optimization). Does not
324 clobber an existing stamp (the chamber's true provenance is the FIRST mismatch, not the latest)."""
327 if old_preset
and old_preset != new_preset
and not cfg.get(
"design_valid_for"):
328 cfg[
"design_valid_for"] = {
"injector": inj_type,
"propellant": old_preset}
333_DESIGN_INTENT_KEYS = (
334 "target_thrust",
"target_burn_time",
"target_apogee",
"target_chamber_pressure_psi",
335 "max_chamber_outer_diameter",
"max_nozzle_exit_diameter",
"max_engine_length",
336 "max_chamber_length_m",
"max_lox_tank_pressure_psi",
"max_fuel_tank_pressure_psi",
341 """Copy the user's own requirements (thrust, burn time, envelope, tank caps) from the pre-swap
342 config onto the freshly loaded canonical, and keep its burn-time slots in step."""
343 old_req = old.get(
"design_requirements")
or {}
344 req = dict(cfg.get(
"design_requirements")
or {})
345 for k
in _DESIGN_INTENT_KEYS:
346 if old_req.get(k)
is not None:
349 cfg[
"design_requirements"] = req
350 bt = req.get(
"target_burn_time")
352 if isinstance(cfg.get(
"thrust"), dict):
353 cfg[
"thrust"] = {**cfg[
"thrust"],
"burn_time": bt}
354 if isinstance(cfg.get(
"pressure_curves"), dict):
355 cfg[
"pressure_curves"] = {**cfg[
"pressure_curves"],
"target_burn_time_s": bt}
360 """A "custom" propellant has no preset to re-apply: carry its fluids, CEA identity and chamber
361 gas verbatim, and stamp the canonical's own propellant so the seed reads as stale."""
363 seeded_for = cfg.get(
"propellant_preset")
364 if old.get(
"fluids"):
365 cfg[
"fluids"] = copy.deepcopy(old[
"fluids"])
366 old_cea = ((old.get(
"combustion")
or {}).get(
"cea"))
or {}
367 comb = dict(cfg.get(
"combustion")
or {})
368 cea = dict(comb.get(
"cea")
or {})
369 for k
in (
"ox_name",
"fuel_name",
"Pc_range",
"MR_range",
"eps_range",
"n_points",
"cache_file"):
373 cfg[
"combustion"] = comb
374 old_smd = ((old.get(
"spray")
or {}).get(
"smd"))
or {}
375 spray = dict(cfg.get(
"spray")
or {})
376 smd = dict(spray.get(
"smd")
or {})
377 for k
in (
"chamber_gas_R",
"chamber_gas_T"):
378 if old_smd.get(k)
is not None:
382 cfg[
"propellant_preset"] =
"custom"
383 cfg[
"design_valid_for"] = {
"injector": (cfg.get(
"injector")
or {}).get(
"type"),
384 "propellant": seeded_for}
388def switch_config(config: Dict[str, Any], *, injector_type: Optional[str] =
None,
389 propellant_preset: Optional[str] =
None) -> Dict[str, Any]:
390 """Apply an injector and/or propellant switch to a config dict and return the reconciled dict.
391 Caller validates (PintleEngineConfig(**result)) and stores.
393 Injector change => LOAD THE CANONICAL config for the new type wholesale (the two configs are
394 intentionally independent -- see module docstring), then put back what the user chose: the
395 propellant (named, or read off the fluids -- DEF-02: picking Doublet used to turn an ethalox
396 design into methalox), the O/F target when it lies inside the new CEA table, and the design
397 intent in _DESIGN_INTENT_KEYS. A same-type request re-stamps the bindings without touching a
398 declared Cd. Propellant change => overlay the preset onto whatever geometry is now live.
402 current_type = (old.get(
"injector")
or {}).get(
"type")
404 keep = str(keep).strip().lower()
if keep
else None
405 if injector_type
is not None and injector_type != current_type:
410 elif keep
and keep != cfg.get(
"propellant_preset"):
413 of = (old.get(
"design_requirements")
or {}).get(
"optimal_of_ratio")
414 mr = ((cfg.get(
"combustion")
or {}).get(
"cea")
or {}).get(
"MR_range")
416 if of
is not None and mr
and same_prop
and float(mr[0]) <= float(of) <= float(mr[1]):
417 cfg[
"design_requirements"] = {**cfg[
"design_requirements"],
"optimal_of_ratio": of}
418 if isinstance(cfg.get(
"chamber_geometry"), dict):
419 cfg[
"chamber_geometry"] = {**cfg[
"chamber_geometry"],
"design_MR": of}
422 propellant_preset =
None
423 elif injector_type
is not None:
425 if propellant_preset
is not None:
429 live_type = ((cfg
or {}).get(
"injector")
or {}).get(
"type")
436 """Return a human-readable warning if the live injector/propellant no longer match what the
437 chamber geometry was solved/seeded for (chamber_staleness), or if the fluid properties
438 differ from the propellant preset's (preset_fluid_drift), else None. Accepts a dict or a
439 PintleEngineConfig. This is the design warning forward mode shows."""
441 return " ".join(notes)
or None
445 """The "needs re-optimization" half of design_staleness: a sentence when the live
446 injector/propellant no longer match what the chamber geometry was solved/seeded for
447 (design_valid_for stamp), else None. Fluid drift is not part of it -- the chamber is still
448 the right one; the properties want refreshing (preset_fluid_drift)."""
451 if isinstance(config, dict):
452 dvf = config.get(
"design_valid_for")
453 cur_inj = ((config.get(
"injector")
or {})
or {}).get(
"type")
454 cur_prop = config.get(
"propellant_preset")
456 dvf = getattr(config,
"design_valid_for",
None)
457 cur_inj = getattr(getattr(config,
"injector",
None),
"type",
None)
458 cur_prop = getattr(config,
"propellant_preset",
None)
461 if dvf.get(
"injector")
and dvf[
"injector"] != cur_inj:
462 mism.append(f
"injector {dvf['injector']} -> {cur_inj}")
463 if dvf.get(
"propellant")
and dvf[
"propellant"] != cur_prop:
464 mism.append(f
"propellant {dvf['propellant']} -> {cur_prop}")
467 return (
"Chamber geometry was solved for " +
", ".join(mism) +
468 ". It is being used as a seed only — re-run optimization for a valid design.")
474_FLUID_DRIFT_REL = 0.05
478 path = _PROJECT_ROOT /
"configs" /
"propellants" / f
"{name}.yaml"
480 with open(path)
as f:
481 return ((yaml.safe_load(f)
or {}).get(
"fluids"))
or {}
482 except (OSError, yaml.YAMLError):
487 """A sentence when the config's fluid properties differ from its propellant preset's, else None.
489 An explicit ``fluids`` block wins over the preset on load (io._deep_merge_preset), so a design
490 saved before the preset was corrected keeps the old numbers silently. The ethalox preset fixed
491 LOX cp 2300 -> 1700, bulk modulus 1.5e9 -> 0.94e9 and viscosity 1.8e-4 -> 2.0e-4 J/(kg K), Pa,
492 Pa s against CoolProp; every ethalox config here (and the LE4 (3) stand's engine) still carries
493 the old block. Forward performance hardly moves (6870.29 -> 6870.31 N on the stand at 578 psia)
494 but the stability numbers do: chug gate 1.445 -> 1.420, LOX water hammer 3182 -> 2561 psi,
495 line quarter-wave 2697 -> 2170 Hz. A measured value may well differ on purpose, so this only
496 says so; it changes nothing.
498 if isinstance(config, dict):
499 name = config.get(
"propellant_preset")
500 fluids = config.get(
"fluids")
or {}
502 name = getattr(config,
"propellant_preset",
None)
503 fluids = getattr(config,
"fluids",
None)
or {}
508 for side
in (
"oxidizer",
"fuel"):
509 live = fluids.get(side)
if isinstance(fluids, dict)
else None
512 if not isinstance(live, dict):
513 live = live.model_dump()
if hasattr(live,
"model_dump")
else vars(live)
514 for key, ref
in (preset.get(side)
or {}).items():
516 if isinstance(ref, bool)
or not isinstance(ref, (int, float))
or not isinstance(val, (int, float)):
518 if ref
and abs(val - ref) > _FLUID_DRIFT_REL * abs(ref):
519 diffs.append(f
"{live.get('name') or side} {key} {val:g} (preset {ref:g})")
522 return (f
"Fluid properties differ from the {name} preset (configs/propellants/{name}.yaml, "
523 f
"sourced): " +
", ".join(diffs) +
". Refresh them from the preset unless they were measured.")
Optional[str] preset_fluid_drift(Any config)
Dict[str, Any] _carry_custom_propellant(Dict[str, Any] old, Dict[str, Any] cfg)
Path canonical_config_path(str injector_type)
list available_injectors()
Dict[str, Any] _preset_fluids(str name)
Optional[str] chamber_staleness(Any config)
Dict[str, Any] apply_injector_type(Dict[str, Any] config, str injector_type, *, bool keep_declared_cd=False)
Dict[str, tuple] _preset_fluid_names()
list available_propellants()
Dict[str, Any] load_canonical_config(str injector_type)
Optional[str] design_staleness(Any config)
Dict[str, Any] _carry_design_intent(Dict[str, Any] old, Dict[str, Any] cfg)
Dict[str, Any] apply_propellant(Dict[str, Any] config, str preset_name)
Optional[str] infer_preset_from_fluids(Optional[Dict[str, Any]] config)
None _stamp_propellant_change(Dict[str, Any] cfg, Optional[str] old_preset, str new_preset, Optional[str] inj_type)
Dict[str, Any] derive_chamber_gas(Dict[str, Any] config)
Dict[str, Any] switch_config(Dict[str, Any] config, *, Optional[str] injector_type=None, Optional[str] propellant_preset=None)