1"""Configuration loading and result saving"""
3from __future__
import annotations
7from pathlib
import Path
8from typing
import Any, Dict, List, Union
9from .config_schemas
import PintleEngineConfig
11_log = logging.getLogger(__name__)
14_PROJECT_ROOT = Path(__file__).resolve().parents[2]
18 """Preset lookup order: next to the config file first, then the repo's configs/propellants."""
19 return [config_dir /
"propellants", _PROJECT_ROOT /
"configs" /
"propellants"]
26 names.extend(sorted(p.stem
for p
in d.glob(
"*.yaml")))
27 return sorted(set(names))
31 """Recursive merge: ``override`` (explicit config) wins on every leaf; overridden preset leaves
32 are logged so silently-differing values stay visible (UNIFICATION_PLAN Phase 2a)."""
33 if isinstance(preset, dict)
and isinstance(override, dict):
35 for key, o_val
in override.items():
41 if preset
is not None and override
is not None and preset != override:
42 _log.info(
"propellant preset override: %s = %r (preset had %r)", path, override, preset)
43 return override
if override
is not None else preset
47 """Hard-fail when explicit config fields contradict the preset's PROPELLANT IDENTITY.
49 Explicit-wins is fine for property tweaks (density, viscosity, …), but a stale explicit CEA
50 section or fluid *name* from a previous propellant produces physically-wrong results that look
51 plausible (the hardcoded-Cd bug class). Identity mismatches are errors, not overrides.
54 (
"combustion",
"cea",
"ox_name"), (
"combustion",
"cea",
"fuel_name"),
55 (
"fluids",
"fuel",
"name"), (
"fluids",
"oxidizer",
"name"),
57 for path
in identity_paths:
58 p_val, m_val = preset, merged
60 p_val = p_val.get(key)
if isinstance(p_val, dict)
else None
61 m_val = m_val.get(key)
if isinstance(m_val, dict)
else None
62 if p_val
is not None and m_val
is not None and str(m_val) != str(p_val):
64 f
"propellant_preset '{name}' conflicts with explicit config: "
65 f
"{'.'.join(path)} = {m_val!r} but the preset defines {p_val!r}. "
66 f
"Delete the stale explicit section (it likely belongs to a previous propellant) "
67 f
"or use propellant_preset: custom."
76 "lox": {
"lox",
"o2(l)",
"oxygen",
"loxygen",
"lo2"},
77 "methane": {
"methane",
"ch4",
"lch4",
"ch4(l)"},
78 "ethanol": {
"ethanol",
"c2h5oh",
"etoh",
"c2h5oh(l)"},
79 "rp1": {
"rp1",
"rp-1",
"kerosene",
"jet-a",
"jeta"},
80 "nitrousoxide": {
"n2o",
"nitrousoxide",
"nitrous oxide"},
81 "ipa": {
"ipa",
"isopropanol",
"isopropyl alcohol",
"c3h8o"},
86 """Canonical key for a fluid name, or the lowercased name when unrecognised."""
89 n = str(name).strip().lower().replace(
"_",
"").replace(
" ",
"").replace(
"-",
"")
90 for canon, aliases
in _FLUID_ALIASES.items():
91 if n
in {a.replace(
" ",
"").replace(
"-",
"").replace(
"_",
"")
for a
in aliases}:
97 """Fail when ``fluids`` and ``combustion.cea`` describe DIFFERENT propellants.
99 This used to live inside _check_preset_coherence, which only runs when a config names a
100 ``propellant_preset``. Leave the preset unset -- which is exactly what the UI does when no
101 propellant is picked -- and the guard was skipped entirely: a config could carry methane
102 fluid properties while the chemistry came from the LOX/Ethanol CEA table, and it loaded
103 without a word. The injector would flow one propellant's density and the chamber would burn
104 another's thermochemistry, and every number downstream would look completely plausible.
106 The physics does not care whether a preset was named, so neither does this check.
108 fluids = (data.get(
"fluids")
or {})
109 cea = ((data.get(
"combustion")
or {}).get(
"cea")
or {})
110 if not fluids
or not cea:
113 (
"fuel", (fluids.get(
"fuel")
or {}).get(
"name"), cea.get(
"fuel_name")),
114 (
"oxidizer", (fluids.get(
"oxidizer")
or {}).get(
"name"), cea.get(
"ox_name")),
116 for side, fluid_name, cea_name
in pairs:
117 if not fluid_name
or not cea_name:
120 if a
and b
and a != b:
122 f
"Propellant mismatch on the {side} side: fluids.{side}.name = {fluid_name!r} "
123 f
"but combustion.cea.{'fuel_name' if side == 'fuel' else 'ox_name'} = "
124 f
"{cea_name!r}. The injector would flow one propellant while the chamber burns "
125 f
"another's chemistry. Fix the config, or set propellant_preset to a preset that "
126 f
"defines both consistently."
130 cache = str(cea.get(
"cache_file")
or "")
132 stem = cache.rsplit(
"/", 1)[-1].lower()
133 for side, _fl, cea_name
in pairs:
137 known = {
"lox":
"lox",
"methane":
"ch4",
"ethanol":
"ethanol",
"rp1":
"rp1"}
139 if tag
and tag
not in stem.replace(
"-",
"_").replace(
".",
"_"):
141 "CEA cache %r does not mention the %s propellant %r — check it is not stale.",
142 cache, side, cea_name,
147 """If ``propellant_preset`` is set, merge the preset under explicit-YAML-wins semantics."""
148 name = data.get(
"propellant_preset")
149 if not name
or str(name).strip().lower() ==
"custom":
151 name = str(name).strip().lower()
153 candidate = d / f
"{name}.yaml"
154 if candidate.exists():
155 with open(candidate,
"r", encoding=
"utf-8")
as f:
156 preset = yaml.safe_load(f)
or {}
157 _log.info(
"applying propellant preset '%s' from %s", name, candidate)
159 merged[
"propellant_preset"] = name
162 raise FileNotFoundError(
163 f
"Unknown propellant_preset '{name}'. Available presets: {_available_presets(config_dir)} "
164 f
"(searched: {[str(d) for d in _preset_search_dirs(config_dir)]})"
168def load_config(config_path: Union[str, Path]) -> PintleEngineConfig:
170 Load engine configuration from YAML file.
172 If the YAML carries ``propellant_preset: <name>`` (e.g. methalox, ethalox, kerolox), the file
173 ``configs/propellants/<name>.yaml`` is deep-merged in BEFORE validation: the preset supplies the
174 propellant baseline (fluids, CEA setup) and any field given explicitly in the config wins (each
175 such override is logged). ``custom`` or absent = no preset (legacy behavior, unchanged).
179 config_path : str | Path
180 Path to YAML configuration file
184 config : PintleEngineConfig
185 Validated configuration object
187 path = Path(config_path)
188 if not path.exists():
189 raise FileNotFoundError(f
"Config file not found: {config_path}")
191 with open(path,
'r', encoding=
'utf-8')
as f:
192 data = yaml.safe_load(f)
201 inj_type = (data.get(
"injector")
or {}).get(
"type")
205 data = apply_injector_type(data, inj_type)
215 for w
in validate_config_bindings(config):
216 _log.warning(
"config binding: %s", w)
223def save_results(results: Dict[str, Any], output_path: Union[str, Path]) ->
None:
225 Save pipeline results to file (JSON or CSV).
229 results : Dict[str, Any]
230 Results dictionary from pipeline
231 output_path : str | Path
236 path = Path(output_path)
237 path.parent.mkdir(parents=
True, exist_ok=
True)
240 def convert_types(obj):
241 if isinstance(obj, dict):
242 return {k: convert_types(v)
for k, v
in obj.items()}
243 elif isinstance(obj, (list, tuple)):
244 return [convert_types(item)
for item
in obj]
245 elif hasattr(obj,
'item'):
247 elif hasattr(obj,
'tolist'):
252 results_serializable = convert_types(results)
254 with open(path,
'w', encoding=
'utf-8')
as f:
255 json.dump(results_serializable, f, indent=2)
None _check_preset_coherence(str name, Dict[str, Any] preset, Dict[str, Any] merged)
None _check_fluid_cea_coherence(Dict[str, Any] data)
List[Path] _preset_search_dirs(Path config_dir)
PintleEngineConfig load_config(Union[str, Path] config_path)
None save_results(Dict[str, Any] results, Union[str, Path] output_path)
List[str] _available_presets(Path config_dir)
Dict[str, Any] _apply_propellant_preset(Dict[str, Any] data, Path config_dir)
Any _deep_merge_preset(Any preset, Any override, str path="")