STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
io.py
Go to the documentation of this file.
1"""Configuration loading and result saving"""
2
3from __future__ import annotations
4
5import logging
6import yaml
7from pathlib import Path
8from typing import Any, Dict, List, Union
9from .config_schemas import PintleEngineConfig
10
11_log = logging.getLogger(__name__)
12
13# Project root (…/EngineDesign); io.py lives at engine/pipeline/io.py
14_PROJECT_ROOT = Path(__file__).resolve().parents[2]
15
16
17def _preset_search_dirs(config_dir: Path) -> List[Path]:
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"]
20
21
22def _available_presets(config_dir: Path) -> List[str]:
23 names: List[str] = []
24 for d in _preset_search_dirs(config_dir):
25 if d.is_dir():
26 names.extend(sorted(p.stem for p in d.glob("*.yaml")))
27 return sorted(set(names))
28
29
30def _deep_merge_preset(preset: Any, override: Any, path: str = "") -> Any:
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):
34 merged = dict(preset)
35 for key, o_val in override.items():
36 if key in preset:
37 merged[key] = _deep_merge_preset(preset[key], o_val, f"{path}.{key}" if path else key)
38 else:
39 merged[key] = o_val
40 return merged
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
44
45
46def _check_preset_coherence(name: str, preset: Dict[str, Any], merged: Dict[str, Any]) -> None:
47 """Hard-fail when explicit config fields contradict the preset's PROPELLANT IDENTITY.
48
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.
52 """
53 identity_paths = [
54 ("combustion", "cea", "ox_name"), ("combustion", "cea", "fuel_name"),
55 ("fluids", "fuel", "name"), ("fluids", "oxidizer", "name"),
56 ]
57 for path in identity_paths:
58 p_val, m_val = preset, merged
59 for key in path:
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):
63 raise ValueError(
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."
68 )
69
70
71
72# Fluid names as they appear in ``fluids.*.name`` vs ``combustion.cea.*_name``. The two fields
73# come from different worlds -- one is a human label, one is a rocketcea card -- so they are
74# spelled differently for the same substance.
75_FLUID_ALIASES = {
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"},
82}
83
84
85def _canon_fluid(name) -> str:
86 """Canonical key for a fluid name, or the lowercased name when unrecognised."""
87 if not name:
88 return ""
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}:
92 return canon
93 return n
94
95
96def _check_fluid_cea_coherence(data: Dict[str, Any]) -> None:
97 """Fail when ``fluids`` and ``combustion.cea`` describe DIFFERENT propellants.
98
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.
105
106 The physics does not care whether a preset was named, so neither does this check.
107 """
108 fluids = (data.get("fluids") or {})
109 cea = ((data.get("combustion") or {}).get("cea") or {})
110 if not fluids or not cea:
111 return
112 pairs = (
113 ("fuel", (fluids.get("fuel") or {}).get("name"), cea.get("fuel_name")),
114 ("oxidizer", (fluids.get("oxidizer") or {}).get("name"), cea.get("ox_name")),
115 )
116 for side, fluid_name, cea_name in pairs:
117 if not fluid_name or not cea_name:
118 continue
119 a, b = _canon_fluid(fluid_name), _canon_fluid(cea_name)
120 if a and b and a != b:
121 raise ValueError(
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."
127 )
128 # The CEA cache file is named after the propellants it was built for; a stale one is the
129 # same failure wearing a different hat.
130 cache = str(cea.get("cache_file") or "")
131 if cache:
132 stem = cache.rsplit("/", 1)[-1].lower()
133 for side, _fl, cea_name in pairs:
134 c = _canon_fluid(cea_name)
135 if not c:
136 continue
137 known = {"lox": "lox", "methane": "ch4", "ethanol": "ethanol", "rp1": "rp1"}
138 tag = known.get(c)
139 if tag and tag not in stem.replace("-", "_").replace(".", "_"):
140 _log.warning(
141 "CEA cache %r does not mention the %s propellant %r — check it is not stale.",
142 cache, side, cea_name,
143 )
144
145
146def _apply_propellant_preset(data: Dict[str, Any], config_dir: Path) -> Dict[str, Any]:
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":
150 return data
151 name = str(name).strip().lower()
152 for d in _preset_search_dirs(config_dir):
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)
158 merged = _deep_merge_preset(preset, data)
159 merged["propellant_preset"] = name
160 _check_preset_coherence(name, preset, merged)
161 return merged
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)]})"
165 )
166
167
168def load_config(config_path: Union[str, Path]) -> PintleEngineConfig:
169 """
170 Load engine configuration from YAML file.
171
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).
176
177 Parameters:
178 -----------
179 config_path : str | Path
180 Path to YAML configuration file
181
182 Returns:
183 --------
184 config : PintleEngineConfig
185 Validated configuration object
186 """
187 path = Path(config_path)
188 if not path.exists():
189 raise FileNotFoundError(f"Config file not found: {config_path}")
190
191 with open(path, 'r', encoding='utf-8') as f:
192 data = yaml.safe_load(f)
193
194 data = _apply_propellant_preset(data, path.resolve().parent)
195
196 # ALWAYS, not just when a preset is named -- see _check_fluid_cea_coherence.
198
199 # Re-stamp spray/discharge bindings for the declared injector type (fixes stale pintle-era
200 # lefebvre SMD + fixed Cd left on impinging YAMLs — bogus ~1 µm D32 and supply-starved Pc).
201 inj_type = (data.get("injector") or {}).get("type")
202 if inj_type:
203 try:
204 from engine.pipeline.config_switch import apply_injector_type
205 data = apply_injector_type(data, inj_type)
206 except ValueError:
207 pass
208
209 # Validate and parse using Pydantic
210 config = PintleEngineConfig(**data)
211
212 # Injector-physics binding coherence (warn-only; UNIFICATION Phase 2b, engine/core/dispatch.py)
213 try:
214 from engine.core.dispatch import validate_config_bindings
215 for w in validate_config_bindings(config):
216 _log.warning("config binding: %s", w)
217 except ImportError:
218 pass
219
220 return config
221
222
223def save_results(results: Dict[str, Any], output_path: Union[str, Path]) -> None:
224 """
225 Save pipeline results to file (JSON or CSV).
226
227 Parameters:
228 -----------
229 results : Dict[str, Any]
230 Results dictionary from pipeline
231 output_path : str | Path
232 Output file path
233 """
234 import json
235
236 path = Path(output_path)
237 path.parent.mkdir(parents=True, exist_ok=True)
238
239 # Convert numpy types to native Python types for JSON serialization
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'): # numpy scalar
246 return obj.item()
247 elif hasattr(obj, 'tolist'): # numpy array
248 return obj.tolist()
249 else:
250 return obj
251
252 results_serializable = convert_types(results)
253
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)
Definition io.py:46
None _check_fluid_cea_coherence(Dict[str, Any] data)
Definition io.py:96
str _canon_fluid(name)
Definition io.py:85
List[Path] _preset_search_dirs(Path config_dir)
Definition io.py:17
PintleEngineConfig load_config(Union[str, Path] config_path)
Definition io.py:168
None save_results(Dict[str, Any] results, Union[str, Path] output_path)
Definition io.py:223
List[str] _available_presets(Path config_dir)
Definition io.py:22
Dict[str, Any] _apply_propellant_preset(Dict[str, Any] data, Path config_dir)
Definition io.py:146
Any _deep_merge_preset(Any preset, Any override, str path="")
Definition io.py:30