55def _require(config: Mapping[str, Any], path: str, source: str) -> Any:
56 """Fetch a dotted path, or fail naming exactly what was missing."""
58 walked: list[str] = []
59 for part
in path.split(
"."):
60 if not isinstance(node, Mapping)
or part
not in node:
62 ", ".join(sorted(str(k)
for k
in node))
63 if isinstance(node, Mapping)
64 else "(not a mapping)"
66 at =
".".join(walked)
or "(top level)"
69 f
"missing {path!r} -- no {part!r} under {at}. Available there: "
86def _discharge(block: Mapping[str, Any] |
None) -> DischargeModel:
87 """One side's discharge block, read literally.
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.
102 if not isinstance(block, Mapping):
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
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),
127 config: Mapping[str, Any], *, name: str =
"engine"
129 """Build a feed-system boundary from an already-parsed engine config.
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.
135 injector =
_require(config,
"injector", name)
136 if not isinstance(injector, Mapping):
139 injector_type = str(
_require(config,
"injector.type", name))
140 if injector_type
not in registered_injector_types():
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.",
148 geometry =
_require(config,
"injector.geometry", name)
149 if not isinstance(geometry, Mapping):
152 discharge = config.get(
"discharge")
or {}
153 fluids =
_require(config,
"fluids", name)
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",
160 for side
in (
"oxidizer",
"fuel"):
161 fluid =
_require(config, f
"fluids.{side}", name)
162 propellant = str(
_require(config, f
"fluids.{side}.name", name))
164 species = species_for(propellant)
165 except KeyError
as exc:
169 area, hydraulic, count = injector_areas(injector_type, geometry, side)
170 except KeyError
as exc:
174 name, f
"{side} injector area came out as {area}, which cannot flow"
180 initial =
"O" if side ==
"oxidizer" else "F"
182 _optional(config, f
"design_requirements.injector_dp_ratio_{initial}_min"),
183 _optional(config, f
"design_requirements.injector_dp_ratio_{initial}_max"),
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}"
191 side_discharge = discharge.get(side)
if isinstance(discharge, Mapping)
else None
195 hydraulic_diameter=hydraulic,
198 temperature=float(fluid.get(
"temperature")
or 0.0),
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))"
205 provenance[f
"{side}.Cd"] = (
206 f
"{name}:discharge.{side}"
208 else "feedtwin default (no discharge block in the config)"
211 throat_area = float(
_require(config,
"chamber_geometry.A_throat", name))
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
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
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
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"
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)"
260 provenance[
"mixture_ratio"] = (
261 f
"{name}:design_requirements.optimal_of_ratio = {optimal_mr:g}"
263 f
" (chamber_geometry.design_MR says {design_mr:g}; intent wins)"
265 and abs(design_mr - optimal_mr) /
max(optimal_mr, 1e-9) > 0.05
269 else f
"{name}:chamber_geometry.design_MR = {design_mr:g} "
270 "(no optimal_of_ratio in the config)"
272 provenance[
"design_chamber_pressure"] = (
273 f
"{name}:design_requirements.target_chamber_pressure_psi = {say(target_pa)}"
275 f
" (chamber_geometry.design_pressure says "
276 f
"{say(design_pc)}; intent wins)"
278 and abs(design_pc - target_pa) /
max(target_pa, 1e-9) > 0.05
282 else f
"{name}:chamber_geometry.design_pressure = "
283 f
"{say(design_pc)} (no target in the config)"
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"
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"
306 fire_load: dict[str, float] = {}
307 for side, key
in ((
"lox",
"lox_tank.mass"), (
"fuel",
"fuel_tank.mass")):
310 fire_load[side] = mass
311 provenance[f
"fire_load.{side}"] = f
"config {key}"
315 nozzle_efficiency=nozzle_efficiency,
316 injector_type=injector_type,
317 oxidiser=sides[
"oxidizer"],
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,
326 provenance=provenance,
327 warnings=tuple(warnings),