STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
config_switch.py
Go to the documentation of this file.
1"""Live config switching for injector type & propellant (UNIFICATION_PLAN Phase 6 — UI exposure).
2
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.
15
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).
19
20Operates on plain dicts so it composes with the config router's merge/validate flow.
21"""
22
23from __future__ import annotations
24
25import copy
26import logging
27from pathlib import Path
28from typing import Any, Dict, Optional
29
30import yaml
31
32from engine.core.dispatch import bindings_for, default_geometry_for, default_discharge_for
33
34_log = logging.getLogger(__name__)
35_PROJECT_ROOT = Path(__file__).resolve().parents[2]
36_CANONICAL_DIR = _PROJECT_ROOT / "configs" / "canonical"
37
38
39def canonical_config_path(injector_type: str) -> Path:
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"
43
44
45def load_canonical_config(injector_type: str) -> Dict[str, Any]:
46 """Load the canonical starting config for ``injector_type`` as a fully-resolved dict.
47
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).
52 """
53 path = canonical_config_path(injector_type)
54 if not path.exists():
55 raise FileNotFoundError(
56 f"No canonical config for injector '{injector_type}' at {path}. "
57 f"Expected configs/canonical/{injector_type}.yaml")
58 from engine.pipeline.io import load_config
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"),
64 }
65 return cfg
66
67
68def available_injectors() -> list:
69 from engine.core.dispatch import INJECTOR_GEOMETRY_TEMPLATES
70 return sorted(INJECTOR_GEOMETRY_TEMPLATES)
71
72
74 d = _PROJECT_ROOT / "configs" / "propellants"
75 return sorted(p.stem for p in d.glob("*.yaml")) if d.is_dir() else []
76
77
78def apply_injector_type(config: Dict[str, Any], injector_type: str, *,
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.
82
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) # validates the type
88 cfg = dict(config)
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) # incompatible structure -> replace
94 cfg["injector"] = inj
95
96 # SMD model binding: impinging needs an explicit model (ingebo); injectors that hard-bind their
97 # own correlation (pintle) get the inert "lefebvre" so the config doesn't carry a misleading model.
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"
101 spray["smd"] = smd
102 cfg["spray"] = spray
103
104 # Discharge-Cd baseline: stamp the per-injector Cd model (the "different Cd models we originally
105 # had for each" — pintle 0.40/0.65 fixed vs impinging 0.60 geometry-Cd). Overwrites the Cd-defining
106 # fields; preserves user-tunable correction settings (P_ref/T_ref/a_P/a_T/use_*_correction).
107 #
108 # With keep_declared_cd, a same-type call fills the keys the config leaves out and touches
109 # nothing it declares: the orifice's Cd belongs to the hardware, so a measured cold-flow value
110 # must survive a propellant switch (DEF-04; it used to come back as the 0.60 baseline).
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):
116 continue
117 d = dict(discharge[side])
118 if baseline.get(side) and same_type:
119 for k, v in baseline[side].items():
120 if d.get(k) is None:
121 d[k] = v
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):
126 # Preserve an explicitly-set use_geometry_cd: a user override of the type default wins,
127 # and validate_config_bindings() warns about the mismatch rather than us silently
128 # clobbering it. (The derive-default only fills the unset case.)
129 user_ugc = d.get("use_geometry_cd", None)
130 # drop stale geometry-Cd params from the previous injector, then apply this type's baseline
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"):
133 d.pop(stale, None)
134 d.update(baseline[side])
135 if user_ugc is not None:
136 d["use_geometry_cd"] = bool(user_ugc)
137 else:
138 d["use_geometry_cd"] = b.expected_use_geometry_cd # fallback if no baseline registered
139 discharge[side] = d
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)
143 return cfg
144
145
146def apply_propellant(config: Dict[str, Any], preset_name: str) -> Dict[str, Any]:
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()
151 # A config with no preset named (configs/default.yaml, a pasted YAML) still HAS a propellant:
152 # read it off the fluids, or the staleness stamp below never fires (DEF-06).
153 old_preset = (config or {}).get("propellant_preset") or infer_preset_from_fluids(config)
154 inj_type = ((config or {}).get("injector") or {}).get("type")
155 if name == "custom":
156 cfg = dict(config)
157 cfg["propellant_preset"] = "custom"
158 _stamp_propellant_change(cfg, old_preset, "custom", inj_type)
159 return cfg
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 {}
165 cfg = dict(config)
166 cfg["propellant_preset"] = name
167
168 # fluids: preset wins wholesale (it defines the propellant identity + properties)
169 if "fluids" in preset:
170 cfg["fluids"] = preset["fluids"]
171
172 # combustion.cea: take preset's propellant-identity fields, keep design-owned fields
173 preset_cea = (preset.get("combustion") or {}).get("cea") or {}
174 if preset_cea:
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]
181 comb["cea"] = cea
182 cfg["combustion"] = comb
183
184 # spray.smd: the chamber-gas pair (rho_g = Pc/(R*T) for the Ingebo aerodynamic Weber number)
185 # is a property of the PROPELLANT, so it must follow the switch. Without this the toggle left
186 # every propellant on the SMDConfig defaults (R=360, T=3500) -- one hardcoded chamber applied
187 # to all three, which is wrong for each of them at its own mixture ratio.
188 # Deliberately narrow: the correlation tuning next to it (model, C_ingebo, C/m/p, we_corr_max)
189 # is injector- and design-owned and must NOT be clobbered by a propellant change.
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]
192 if gas_keys:
193 spray = dict(cfg.get("spray") or {})
194 smd = dict(spray.get("smd") or {})
195 for k in gas_keys:
196 smd[k] = preset_smd[k]
197 spray["smd"] = smd
198 cfg["spray"] = spray
199
200 # The O/F target belongs to the propellant: methalox's 2.8 is past ethanol's stoichiometric
201 # 2.08. On a real change, take the preset's design O/F (its source is in the preset file) and
202 # move the chamber's design_MR with it. Re-selecting the live propellant changes nothing.
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)
215 cfg = derive_chamber_gas(cfg)
216
217 _stamp_propellant_change(cfg, old_preset, name, inj_type)
218 _log.info("switched propellant -> %s (fluids+CEA identity+chamber gas overlaid)", name)
219 return cfg
220
221
222def _preset_fluid_names() -> Dict[str, tuple]:
223 """{preset: (canonical oxidizer name, canonical fuel name)} for every shipped preset."""
224 from engine.pipeline.io import _canon_fluid
225 out = {}
226 for name in available_propellants():
227 try:
228 p = yaml.safe_load((_PROJECT_ROOT / "configs" / "propellants" / f"{name}.yaml").read_text())
229 except Exception:
230 continue
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")))
234 return out
235
236
237def infer_preset_from_fluids(config: Optional[Dict[str, Any]]) -> Optional[str]:
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 {}
241 if not fl:
242 return None
243 from engine.pipeline.io import _canon_fluid
244 pair = (_canon_fluid((fl.get("oxidizer") or {}).get("name")),
245 _canon_fluid((fl.get("fuel") or {}).get("name")))
246 if not all(pair):
247 return None
248 for name, names in _preset_fluid_names().items():
249 if names == pair:
250 return name
251 return "custom"
252
253
254# Chamber pressure at which the chamber gas is read when the design names no target Pc. It is the
255# 6500 N ethalox design point; R*T moves 0.15 % between 300 and 600 psia at O/F 1.5 (CEA), so the
256# choice barely matters -- Tc is what the evaporation model feels, and it moves 1 % over that span.
257CHAMBER_GAS_REFERENCE_PC_PSI = 430.0
258
259
260def derive_chamber_gas(config: Dict[str, Any]) -> Dict[str, Any]:
261 """Set spray.smd.chamber_gas_R/T from the CEA table at the design's O/F and target Pc.
262
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."""
268 import os
269 cfg = dict(config)
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"):
276 return cfg
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)
280 return cfg
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)
284 return cfg
285 try:
286 import json
287 from engine.pipeline.config_schemas import CEAConfig
288 from engine.pipeline.cea_cache import CEA_TABLE_SCHEMA_VERSION, CEACache, _load_npz_tables
289 # CEACache DELETES and rebuilds a table whose identity or grid differs from the request.
290 # A UI toggle must never do that: read the table only when it is the one asked for.
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)
297 return cfg
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)
304 return cfg
305 T, R = float(r["Tc"]), float(r["R"])
306 except Exception as e: # a derivation failure must not break a propellant switch
307 _log.warning("chamber gas not derived (%s); keeping the preset's R/T", e)
308 return cfg
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)
313 spray["smd"] = smd
314 cfg["spray"] = spray
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)
316 return cfg
317
318
319def _stamp_propellant_change(cfg: Dict[str, Any], old_preset: Optional[str],
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)."""
325 # NB: design_valid_for is a declared schema field, so it's always a key (value None when unset) —
326 # gate on its VALUE, not key presence, or the stamp never fires.
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}
329
330
331# Requirements that state what the USER asked for, not how the canonical seed was tuned. An
332# injector swap reloads the canonical wholesale; these follow the user across it.
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",
337)
338
339
340def _carry_design_intent(old: Dict[str, Any], cfg: Dict[str, Any]) -> Dict[str, Any]:
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:
347 req[k] = old_req[k]
348 cfg = dict(cfg)
349 cfg["design_requirements"] = req
350 bt = req.get("target_burn_time")
351 if bt is not None:
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}
356 return cfg
357
358
359def _carry_custom_propellant(old: Dict[str, Any], cfg: Dict[str, Any]) -> Dict[str, Any]:
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."""
362 cfg = dict(cfg)
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"):
370 if k in old_cea:
371 cea[k] = old_cea[k]
372 comb["cea"] = cea
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:
379 smd[k] = old_smd[k]
380 spray["smd"] = smd
381 cfg["spray"] = spray
382 cfg["propellant_preset"] = "custom"
383 cfg["design_valid_for"] = {"injector": (cfg.get("injector") or {}).get("type"),
384 "propellant": seeded_for}
385 return cfg
386
387
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.
392
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.
399 """
400 old = config or {}
401 cfg = config
402 current_type = (old.get("injector") or {}).get("type")
403 keep = propellant_preset or old.get("propellant_preset") or infer_preset_from_fluids(old)
404 keep = str(keep).strip().lower() if keep else None
405 if injector_type is not None and injector_type != current_type:
406 cfg = load_canonical_config(injector_type) # whole new config, different values for everything
407 cfg = _carry_design_intent(old, cfg)
408 if keep == "custom":
409 cfg = _carry_custom_propellant(old, cfg)
410 elif keep and keep != cfg.get("propellant_preset"):
411 cfg = apply_propellant(cfg, keep) # stamps the canonical's propellant -> stale
412 # The user's O/F survives the swap when it is still a valid target for this propellant.
413 of = (old.get("design_requirements") or {}).get("optimal_of_ratio")
414 mr = ((cfg.get("combustion") or {}).get("cea") or {}).get("MR_range")
415 same_prop = keep == (old.get("propellant_preset") or infer_preset_from_fluids(old))
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}
420 if keep != "custom":
421 cfg = derive_chamber_gas(cfg)
422 propellant_preset = None # carried above; do not overlay twice
423 elif injector_type is not None:
424 cfg = apply_injector_type(cfg, injector_type, keep_declared_cd=True) # same type: bindings only
425 if propellant_preset is not None:
426 cfg = apply_propellant(cfg, propellant_preset)
427 # Re-stamp the injector BINDINGS (SMD model, missing discharge keys) for the live type -- an
428 # uploaded impinging config can carry pintle's lefebvre. Never a declared Cd (DEF-04).
429 live_type = ((cfg or {}).get("injector") or {}).get("type")
430 if live_type:
431 cfg = apply_injector_type(cfg, live_type, keep_declared_cd=True)
432 return cfg
433
434
435def design_staleness(config: Any) -> Optional[str]:
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."""
440 notes = [n for n in (chamber_staleness(config), preset_fluid_drift(config)) if n]
441 return " ".join(notes) or None
442
443
444def chamber_staleness(config: Any) -> Optional[str]:
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)."""
449 if config is None:
450 return None
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")
455 else:
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)
459 mism = []
460 if dvf:
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}")
465 if not mism:
466 return None
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.")
469
470
471#: A fluid property further than this from its preset's value is reported. Wide enough that a
472#: rounding (LOX boiling point 90.19 vs 90.2) or a few kelvin of subcooling stays quiet; the stale
473#: LOX block the ethalox configs carry is 10 % (viscosity) to 60 % (bulk modulus) off.
474_FLUID_DRIFT_REL = 0.05
475
476
477def _preset_fluids(name: str) -> Dict[str, Any]:
478 path = _PROJECT_ROOT / "configs" / "propellants" / f"{name}.yaml"
479 try:
480 with open(path) as f:
481 return ((yaml.safe_load(f) or {}).get("fluids")) or {}
482 except (OSError, yaml.YAMLError):
483 return {}
484
485
486def preset_fluid_drift(config: Any) -> Optional[str]:
487 """A sentence when the config's fluid properties differ from its propellant preset's, else None.
488
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.
497 """
498 if isinstance(config, dict):
499 name = config.get("propellant_preset")
500 fluids = config.get("fluids") or {}
501 else:
502 name = getattr(config, "propellant_preset", None)
503 fluids = getattr(config, "fluids", None) or {}
504 if not name:
505 return None
506 preset = _preset_fluids(str(name))
507 diffs = []
508 for side in ("oxidizer", "fuel"):
509 live = fluids.get(side) if isinstance(fluids, dict) else None
510 if live is None:
511 continue
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():
515 val = live.get(key)
516 if isinstance(ref, bool) or not isinstance(ref, (int, float)) or not isinstance(val, (int, float)):
517 continue
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})")
520 if not diffs:
521 return None
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)
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()
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)