STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
importer.py
Go to the documentation of this file.
1"""Reading an EngineDesign Layer-1 config into a feed-system boundary.
2
3The contract this module keeps is narrow and worth stating: **an engine design
4is imported, never re-typed, and never edited on the way in.** Whatever Layer 1
5optimised to is what gets fired. If a number is missing the import says which
6one and stops; it does not substitute a plausible default, because a plausible
7default here produces a run that looks right and is not the engine anybody
8designed.
9
10What is read, and what is ignored
11---------------------------------
12Read: ``injector.type`` and ``injector.geometry``, ``discharge.*``,
13``fluids.*.name`` and ``.temperature``, ``chamber_geometry.A_throat``,
14``expansion_ratio``, ``volume``, ``design_pressure``, ``design_MR`` and
15``design_thrust``.
16
17Ignored, on purpose: ablative cooling, spray SMD correlations, optimizer
18weights, structural margins, CEA cache paths, stability scores. None of them is
19a feed-system boundary condition, and reading them would create a coupling that
20has to be maintained for no benefit.
21
22The file is YAML because EngineDesign writes YAML. That is the one place this
23library touches it -- and it is read with ``safe_load`` into plain dicts, so an
24engine config cannot execute anything on import.
25"""
26
27from __future__ import annotations
28
29from pathlib import Path
30from typing import Any, Mapping
31
32from feedtwin.engine.design import (
33 DischargeModel,
34 EngineDesign,
35 InjectorSide,
36 injector_areas,
37 registered_injector_types,
38 species_for,
39)
40from feedtwin.model.pressure import say
41
42
43class EngineImportError(ValueError):
44 """The config could not be read as a feed-system boundary.
45
46 Always names the key and the file. An import failure that says only
47 "missing field" sends someone hunting through a two-thousand-line YAML.
48 """
49
50 def __init__(self, source: str, detail: str) -> None:
51 super().__init__(f"{source}: {detail}")
52 self.source = source
53
54
55def _require(config: Mapping[str, Any], path: str, source: str) -> Any:
56 """Fetch a dotted path, or fail naming exactly what was missing."""
57 node: Any = config
58 walked: list[str] = []
59 for part in path.split("."):
60 if not isinstance(node, Mapping) or part not in node:
61 available = (
62 ", ".join(sorted(str(k) for k in node))
63 if isinstance(node, Mapping)
64 else "(not a mapping)"
65 )
66 at = ".".join(walked) or "(top level)"
68 source,
69 f"missing {path!r} -- no {part!r} under {at}. Available there: "
70 f"{available}",
71 )
72 walked.append(part)
73 node = node[part]
74 return node
75
76
77def _optional(config: Mapping[str, Any], path: str, default: float = 0.0) -> float:
78 node: Any = config
79 for part in path.split("."):
80 if not isinstance(node, Mapping) or part not in node:
81 return default
82 node = node[part]
83 return default if node is None else float(node)
84
85
86def _discharge(block: Mapping[str, Any] | None) -> DischargeModel:
87 """One side's discharge block, read literally.
88
89 EngineDesign does not read it literally. Its ``load_config`` re-stamps the
90 injector type's baseline over the Cd fields on every load
91 (``engine.pipeline.config_switch.apply_injector_type``; impinging: Cd_inf
92 0.60, a_Re 0.18, Cd_min 0.35, geometry Cd on, small-hole exponent 0.20),
93 keeping only a declared ``use_geometry_cd`` and the correction settings.
94 So this agrees with EngineDesign only where the block *is* that baseline --
95 every shipped ethalox config and LE4's engine -- and not on the older
96 methalox configs, nor on ``default.yaml``'s exponent of 0.0 (which the
97 ``or 0.20`` below happens to read as EngineDesign does). Matching
98 EngineDesign's schema defaults instead was tried and is wrong for the same
99 reason (2026-10-08). The engine card carries EngineDesign's own Cd; this is
100 the simplified engine's.
101 """
102 if not isinstance(block, Mapping):
103 return DischargeModel()
104 return DischargeModel(
105 cd_inf=float(block.get("Cd_inf", 0.6)),
106 a_re=float(block.get("a_Re", 0.0)),
107 cd_min=float(block.get("Cd_min", 0.15)),
108 use_pressure_correction=bool(block.get("use_pressure_correction", False)),
109 p_ref=float(block.get("P_ref", 5.0e6) or 5.0e6),
110 a_p=float(block.get("a_P", 0.0) or 0.0),
111 use_temperature_correction=bool(block.get("use_temperature_correction", False)),
112 t_ref=float(block.get("T_ref", 90.0) or 90.0),
113 a_t=float(block.get("a_T", 0.0) or 0.0),
114 use_geometry_cd=bool(block.get("use_geometry_cd", False)),
115 d_ref=float(block.get("d_ref_m", 2.0e-3) or 2.0e-3),
116 d_min=float(block.get("d_min_m", 4.0e-4) or 4.0e-4),
117 cd_small_hole_exponent=float(block.get("cd_small_hole_exponent", 0.20) or 0.20),
118 cd_large_hole_log_gain=float(
119 block.get("cd_large_hole_log_gain", 0.015) or 0.015
120 ),
121 cd_inf_max=float(block.get("cd_inf_max", 0.62) or 0.62),
122 cd_inf_min_geom=float(block.get("cd_inf_min_geom", 0.48) or 0.48),
123 )
124
125
127 config: Mapping[str, Any], *, name: str = "engine"
128) -> EngineDesign:
129 """Build a feed-system boundary from an already-parsed engine config.
130
131 Separated from :func:`load_engine` so a config that arrived over HTTP, or
132 one assembled in a test, imports through exactly the same path as one read
133 off disk. The parser is not the interesting part; the extraction is.
134 """
135 injector = _require(config, "injector", name)
136 if not isinstance(injector, Mapping):
137 raise EngineImportError(name, "'injector' is not a mapping")
138
139 injector_type = str(_require(config, "injector.type", name))
140 if injector_type not in registered_injector_types():
141 raise EngineImportError(
142 name,
143 f"injector type {injector_type!r} has no area extractor; known types "
144 f"are {', '.join(registered_injector_types())}. Register one rather "
145 "than letting it fall back -- a wrong injector area is a wrong mass "
146 "flow and every number after it stays plausible.",
147 )
148 geometry = _require(config, "injector.geometry", name)
149 if not isinstance(geometry, Mapping):
150 raise EngineImportError(name, "'injector.geometry' is not a mapping")
151
152 discharge = config.get("discharge") or {}
153 fluids = _require(config, "fluids", name)
154
155 sides: dict[str, InjectorSide] = {}
156 provenance: dict[str, str] = {
157 "injector_type": f"{name}:injector.type",
158 "throat_area": f"{name}:chamber_geometry.A_throat",
159 }
160 for side in ("oxidizer", "fuel"):
161 fluid = _require(config, f"fluids.{side}", name)
162 propellant = str(_require(config, f"fluids.{side}.name", name))
163 try:
164 species = species_for(propellant)
165 except KeyError as exc:
166 raise EngineImportError(name, str(exc)) from exc
167
168 try:
169 area, hydraulic, count = injector_areas(injector_type, geometry, side)
170 except KeyError as exc:
171 raise EngineImportError(name, f"{side} injector geometry: {exc}") from exc
172 if area <= 0.0:
173 raise EngineImportError(
174 name, f"{side} injector area came out as {area}, which cannot flow"
175 )
176
177 # The band Layer 1 constrained this side to, keyed by O/F rather than
178 # by the side's own name -- EngineDesign spells them
179 # injector_dp_ratio_O_min and injector_dp_ratio_F_min.
180 initial = "O" if side == "oxidizer" else "F"
181 band = (
182 _optional(config, f"design_requirements.injector_dp_ratio_{initial}_min"),
183 _optional(config, f"design_requirements.injector_dp_ratio_{initial}_max"),
184 )
185 if band[0] > 0.0 or band[1] > 0.0:
186 provenance[f"{side}.stiffness_band"] = (
187 f"{name}:design_requirements.injector_dp_ratio_{initial}_min/max "
188 f"= {band[0]:g}-{band[1]:g}"
189 )
190
191 side_discharge = discharge.get(side) if isinstance(discharge, Mapping) else None
192 sides[side] = InjectorSide(
193 propellant=species,
194 area=area,
195 hydraulic_diameter=hydraulic,
196 discharge=_discharge(side_discharge),
197 element_count=count,
198 temperature=float(fluid.get("temperature") or 0.0),
199 stiffness_band=band,
200 )
201 provenance[f"{side}.propellant"] = f"{name}:fluids.{side}.name = {propellant}"
202 provenance[f"{side}.area"] = (
203 f"derived from {injector_type} geometry ({count} element(s))"
204 )
205 provenance[f"{side}.Cd"] = (
206 f"{name}:discharge.{side}"
207 if side_discharge
208 else "feedtwin default (no discharge block in the config)"
209 )
210
211 throat_area = float(_require(config, "chamber_geometry.A_throat", name))
212
213 # A config carries the design point twice, in two places written at
214 # different times, and they drift.
215 #
216 # `design_requirements` is the *intent* -- what the optimiser was told to
217 # hit. `chamber_geometry` is what the geometry was last sized at, and on a
218 # config that has been re-run or switched propellant pair it is a leftover.
219 # So intent wins, for both the mixture ratio and the chamber pressure, and
220 # which one was used is recorded rather than warned about: a disagreement
221 # between the two is the normal state of a config that is being worked on,
222 # and a warning that fires on healthy input teaches people to skim past the
223 # ones that matter.
224 warnings: list[str] = []
225 design_mr = _optional(config, "chamber_geometry.design_MR")
226 optimal_mr = _optional(config, "design_requirements.optimal_of_ratio")
227 mixture_ratio = optimal_mr if optimal_mr > 0.0 else design_mr
228
229 design_pc = _optional(config, "chamber_geometry.design_pressure")
230 target_pc = _optional(config, "design_requirements.target_chamber_pressure_psi")
231 target_pa = target_pc * 6894.757293168361 if target_pc > 0.0 else 0.0
232 chamber_pressure = target_pa if target_pa > 0.0 else design_pc
233
234 # Thrust follows the same rule as the two above, and did not before.
235 #
236 # `chamber_geometry.design_thrust` is an *output*: what the last Layer 1 run
237 # achieved. `design_requirements.target_thrust` is the *input*: what was
238 # asked for. They agree right after an optimisation and drift apart when a
239 # target is edited without re-running, which is the ordinary state of a
240 # config being worked on. Reading only the output meant the shipped ethalox
241 # config imported as 7000 N against a delivered 7200 N -- 2.9% low, with no
242 # warning, while the very next two fields resolved the same conflict
243 # correctly.
244 design_thrust_out = _optional(config, "chamber_geometry.design_thrust")
245 target_thrust = _optional(config, "design_requirements.target_thrust")
246 thrust = target_thrust if target_thrust > 0.0 else design_thrust_out
247
248 provenance["design_thrust"] = (
249 f"{name}:design_requirements.target_thrust = {target_thrust:g} N"
250 if target_thrust > 0.0
251 else f"{name}:chamber_geometry.design_thrust = {design_thrust_out:g} N"
252 )
253 if target_thrust > 0.0 and design_thrust_out > 0.0:
254 if abs(target_thrust - design_thrust_out) / target_thrust > 0.005:
255 provenance["design_thrust"] += (
256 f" (chamber_geometry.design_thrust says {design_thrust_out:g} N; "
257 "that is the last optimiser run's achievement, and the target wins)"
258 )
259
260 provenance["mixture_ratio"] = (
261 f"{name}:design_requirements.optimal_of_ratio = {optimal_mr:g}"
262 + (
263 f" (chamber_geometry.design_MR says {design_mr:g}; intent wins)"
264 if design_mr > 0.0
265 and abs(design_mr - optimal_mr) / max(optimal_mr, 1e-9) > 0.05
266 else ""
267 )
268 if optimal_mr > 0.0
269 else f"{name}:chamber_geometry.design_MR = {design_mr:g} "
270 "(no optimal_of_ratio in the config)"
271 )
272 provenance["design_chamber_pressure"] = (
273 f"{name}:design_requirements.target_chamber_pressure_psi = {say(target_pa)}"
274 + (
275 f" (chamber_geometry.design_pressure says "
276 f"{say(design_pc)}; intent wins)"
277 if design_pc > 0.0
278 and abs(design_pc - target_pa) / max(target_pa, 1e-9) > 0.05
279 else ""
280 )
281 if target_pa > 0.0
282 else f"{name}:chamber_geometry.design_pressure = "
283 f"{say(design_pc)} (no target in the config)"
284 )
285
286 # eta_n is a scalar in the config and is read. eta_c* is not: Layer 1
287 # derives it from a spray, mixing and finite-rate chemistry model, and the
288 # config carries that model's *settings* rather than its answer. Guessing a
289 # number here would put a silent few percent between the two tools, so it
290 # stays at one and says so.
291 nozzle_efficiency = _optional(config, "chamber_geometry.nozzle_efficiency") or 1.0
292 provenance["nozzle_efficiency"] = (
293 f"{name}:chamber_geometry.nozzle_efficiency = {nozzle_efficiency:g}"
294 if nozzle_efficiency < 1.0
295 else "not stated; thrust and Isp are reported for an ideal nozzle"
296 )
297 # Provenance, not a warning. It is true of every import, and a warning that
298 # fires on healthy input is noise -- which is the fastest way to teach
299 # somebody to skim past the ones that matter.
300 provenance["cstar_efficiency"] = (
301 "1.0 -- EngineDesign derives eta_c* from a spray, mixing and "
302 "finite-rate model that feed-twin does not carry, so chamber pressure "
303 "here is an upper bound"
304 )
305
306 fire_load: dict[str, float] = {}
307 for side, key in (("lox", "lox_tank.mass"), ("fuel", "fuel_tank.mass")):
308 mass = _optional(config, key)
309 if mass > 0.0:
310 fire_load[side] = mass
311 provenance[f"fire_load.{side}"] = f"config {key}"
312
313 return EngineDesign(
314 name=name,
315 nozzle_efficiency=nozzle_efficiency,
316 injector_type=injector_type,
317 oxidiser=sides["oxidizer"],
318 fuel=sides["fuel"],
319 throat_area=throat_area,
320 expansion_ratio=_optional(config, "chamber_geometry.expansion_ratio"),
321 chamber_volume=_optional(config, "chamber_geometry.volume"),
322 design_chamber_pressure=chamber_pressure,
323 design_mixture_ratio=mixture_ratio,
324 design_thrust=thrust,
325 fire_load=fire_load,
326 provenance=provenance,
327 warnings=tuple(warnings),
328 )
329
330
331def load_engine(path: str | Path) -> EngineDesign:
332 """Import a Layer-1 engine config from disk.
333
334 The whole Phase 08 promise in one call: point it at what the optimizer
335 wrote and get a feed-system boundary, with nothing retyped.
336
337 Raises:
338 EngineImportError: the file is unreadable as an engine boundary, naming
339 the key that was missing and where it looked.
340 """
341 source = Path(path)
342 if not source.exists():
343 raise EngineImportError(str(source), "no such file")
344 try:
345 import yaml
346 except ImportError as exc: # pragma: no cover - dependency is declared
347 raise EngineImportError(
348 str(source),
349 "PyYAML is needed to read EngineDesign configs. Note that feed-twin's "
350 "own configs are TOML precisely because YAML parses 1e-3 as a string; "
351 "YAML here is a boundary format we read, not one we write.",
352 ) from exc
353
354 with source.open(encoding="utf-8") as handle:
355 config = yaml.safe_load(handle)
356 if not isinstance(config, Mapping):
357 raise EngineImportError(str(source), "the file is not a YAML mapping")
358 return engine_from_config(config, name=source.name)
#define max(a, b)
Definition Arduino.h:43
None __init__(self, str source, str detail)
Definition importer.py:50
float _optional(Mapping[str, Any] config, str path, float default=0.0)
Definition importer.py:77
EngineDesign load_engine(str|Path path)
Definition importer.py:331
Any _require(Mapping[str, Any] config, str path, str source)
Definition importer.py:55
DischargeModel _discharge(Mapping[str, Any]|None block)
Definition importer.py:86
EngineDesign engine_from_config(Mapping[str, Any] config, *, str name="engine")
Definition importer.py:128