1"""What feed-twin needs from an engine design, and nothing more.
3An EngineDesign Layer-1 config is a large document -- ablative cooling, spray
4SMD correlations, optimizer weights, CEA cache paths, structural margins. Almost
5none of that is a feed-system boundary condition. What a feed system needs to
6know about the engine at its end is small and specific:
8* the **effective flow area** each propellant sees at the injector face,
9* the **discharge coefficient** on each side and how it moves with Reynolds,
10* which **propellants** those are, so the property layer can be asked,
11* the **throat area**, which converts total flow into chamber pressure,
12* and the **design point** -- thrust, mixture ratio, chamber pressure -- so the
13 import can be checked against what Layer 1 thought it had designed.
15:class:`EngineDesign` is that subset, and it is deliberately a plain record
16rather than a live handle on an EngineDesign object. Two reasons. It can be
17serialised into a feed-twin scenario, so a run is reproducible without the other
18repo present. And it makes the coupling *explicit*: every quantity crossing the
19boundary is named in one place, so nobody has to read two codebases to find out
20what the engine is being told.
22Injector types are not interchangeable
23--------------------------------------
24The area calculation is the one genuinely type-specific piece, and getting it
25from the wrong formula is silent -- a wrong area is a wrong flow is a wrong
26chamber pressure, and every number downstream stays plausible. So each type has
27its own extractor, registered by name, and an unrecognised type is refused
28rather than guessed at:
31 ``n_elements`` round jets per side. ``A = n . pi d_jet^2 / 4``.
34 Oxidiser through discrete orifices, fuel through the annulus between the
35 pintle tip and the reservoir wall. The two sides use *different formulae*,
36 which is exactly the trap.
39 Core ports and an outer annulus, same asymmetry.
42from __future__
import annotations
45from dataclasses
import dataclass, field
46from typing
import TYPE_CHECKING, Callable, Mapping
57PROPELLANT_ALIASES: dict[str, str] = {
61 "liquid oxygen":
"oxygen",
73 """A propellant name that does not map to a feed-twin species."""
78 close = difflib.get_close_matches(name.lower(), PROPELLANT_ALIASES, n=3)
79 hint = f
" Did you mean: {', '.join(close)}?" if close
else ""
81 f
"no feed-twin species for propellant {name!r}.{hint} Known names: "
82 f
"{', '.join(sorted(PROPELLANT_ALIASES))}. Add one with "
83 "register_propellant_alias() rather than renaming it in the engine "
84 "config -- the engine config is the source of truth for what the "
90 """Map an engine-config propellant name onto a feed-twin species."""
91 PROPELLANT_ALIASES[name.lower()] = species
95 key = name.strip().lower()
96 if key
not in PROPELLANT_ALIASES:
98 return PROPELLANT_ALIASES[key]
101@dataclass(frozen=True, slots=True)
103 """Reynolds- and geometry-dependent discharge coefficient.
105 ``Cd(Re) = Cd_inf(d) - a_Re / sqrt(Re)``, clamped to ``[Cd_min, Cd_inf(d)]``,
106 with optional pressure and temperature corrections. Reproduced rather than
107 re-derived: this must agree with EngineDesign's own answer at the same
108 operating point or the two tools are modelling different injectors, and a
109 boundary that disagrees with the thing it bounds is worse than no boundary.
111 ``Cd_inf`` depends on hole size when ``use_geometry_cd`` is set, which it is
112 on the current vehicle's config. Small drilled holes lose a little to their
113 rougher relative edge; large well-rounded ports gain a little. Leaving it out
114 was worth about six points of injector stiffness on the real engine --
115 enough to move a design from inside its 20-30% band to outside it, which is
116 exactly the kind of quiet disagreement between two tools that costs an
123 use_pressure_correction: bool =
False
126 use_temperature_correction: bool =
False
129 use_geometry_cd: bool =
False
130 d_ref: float = 2.0e-3
131 d_min: float = 4.0e-4
132 cd_small_hole_exponent: float = 0.20
133 cd_large_hole_log_gain: float = 0.015
134 cd_inf_max: float = 0.62
135 cd_inf_min_geom: float = 0.48
138 """High-Reynolds ``Cd`` for a hole of this size."""
141 if self.
d_ref <= 0.0:
155 pressure: float |
None =
None,
156 temperature: float |
None =
None,
157 diameter: float |
None =
None,
162 value = ceiling - self.
a_re / math.sqrt(
max(reynolds, 1e-6))
164 value *= 1.0 + self.
a_p * (pressure / self.
p_ref - 1.0)
167 and temperature
is not None
170 value *= 1.0 + self.
a_t * (temperature / self.
t_ref - 1.0)
174@dataclass(frozen=True, slots=True)
176 """One propellant's path through the injector face.
179 propellant: Feed-twin species name.
180 area: Total effective flow area [m^2] -- all elements, summed.
181 hydraulic_diameter: Characteristic bore for the Reynolds number [m].
182 Not the same as a jet diameter for an annulus, which is why it is
183 carried separately rather than derived from the area.
185 element_count: How many elements. Reporting only; the area already has
187 temperature: Design propellant temperature [K], from the engine config.
192 hydraulic_diameter: float
193 discharge: DischargeModel
194 element_count: int = 1
195 temperature: float = 0.0
196 stiffness_band: tuple[float, float] = (0.0, 0.0)
197 """``(min, max)`` injector pressure drop as a fraction of chamber pressure,
198 from the engine config's own design requirements. ``(0, 0)`` means the
199 config did not state one.
201 Worth carrying rather than assuming, because the usual "20% and up" is a
202 rule of thumb and *this* number is the band the optimiser was actually
203 constrained by. Checking a delivered stiffness against the rule when the
204 engine states its own is checking against the wrong thing -- and it has an
205 upper bound too, which the rule of thumb does not: an injector that is too
206 stiff is throwing away tank pressure it paid structural mass for."""
207 card: InjectorCard |
None =
None
208 """Another tool's injector characteristic, inlet node to chamber
209 (:mod:`feedtwin.engine.card`). When set it replaces the orifice relation and
210 the Cd model above; ``None`` (the default) leaves them exactly as they were."""
213 self, mdot: float, rho: float, mu: float, pressure: float |
None =
None
215 """Discharge coefficient at an operating point."""
216 if self.area <= 0.0
or rho <= 0.0
or mu <= 0.0:
217 return self.discharge.cd_inf
218 velocity = abs(mdot) / (rho * self.area)
219 reynolds = rho * velocity * self.hydraulic_diameter / mu
220 return self.discharge.cd(
224 diameter=self.hydraulic_diameter,
228@dataclass(frozen=True, slots=True)
230 """A Layer-1 engine, reduced to its feed-system boundary.
233 name: Where this came from -- a config path, usually.
234 injector_type: ``impinging``, ``pintle`` or ``coaxial``.
235 oxidiser: Ox side of the injector face.
237 throat_area: ``A_t`` [m^2]. Converts total flow into chamber pressure.
238 expansion_ratio: ``A_e / A_t``.
239 chamber_volume: For the chamber's own filling dynamics [m^3].
240 design_chamber_pressure: What Layer 1 sized it at [Pa].
241 design_mixture_ratio: O/F by mass.
243 cstar: Characteristic velocity [m/s] at the design point. A single
244 number is the honest default: the real ``c*`` moves with mixture
245 ratio, and :class:`~feedtwin.engine.chamber.Chamber` takes a curve
246 when one is available.
247 provenance: Where each number came from, keyed by field. Import fills
248 this so a run can say which numbers were read and which defaulted.
253 oxidiser: InjectorSide
256 expansion_ratio: float = 0.0
257 chamber_volume: float = 0.0
258 design_chamber_pressure: float = 0.0
259 design_mixture_ratio: float = 0.0
260 design_thrust: float = 0.0
262 nozzle_efficiency: float = 1.0
263 """``eta_n``, from the config. Multiplies the thrust coefficient. Left at 1
264 the model reports ideal-nozzle thrust, which is a few percent above what
265 Layer 1 predicts for the same flows -- a disagreement between the two tools
266 with no cause visible in either."""
268 cstar_efficiency: float = 1.0
269 """``eta_c*``. Layer 1 computes this from a spray, mixing and finite-rate
270 model that feed-twin does not have and should not reimplement, so it is 1
271 unless somebody states one. See :attr:`warnings`, which says so."""
273 fire_load: Mapping[str, float] = field(default_factory=dict)
274 """Propellant a fire is loaded with [kg], by side (``lox``, ``fuel``): the
275 config's ``lox_tank.mass`` and ``fuel_tank.mass``, what the vehicle carries
276 (fixed by the competition, not by the tank's size). The cockpit's T-0 and
277 pad fills load this, not a fraction of the drawn tank. Empty when the
278 config does not say."""
279 provenance: Mapping[str, str] = field(default_factory=dict)
280 warnings: tuple[str, ...] = ()
281 """Things the config says about itself that do not agree. Reported rather
282 than resolved: only the person who ran the optimiser knows which field is
283 the stale one, and picking for them would bury the disagreement."""
287 return math.sqrt(4.0 * self.throat_area / math.pi)
291 f
"{self.name}: {self.injector_type} injector, "
292 f
"{self.oxidiser.propellant}/{self.fuel.propellant}, "
293 f
"A_t {self.throat_area * 1e6:.1f} mm^2, "
294 f
"A_inj ox {self.oxidiser.area * 1e6:.2f} mm^2 / "
295 f
"fuel {self.fuel.area * 1e6:.2f} mm^2, "
296 f
"design {self.design_thrust:.0f} N at "
297 f
"{say(self.design_chamber_pressure)}, O/F "
298 f
"{self.design_mixture_ratio:.2f}"
302 return f
"EngineDesign({self.summary()})"
310AreaExtractor = Callable[[Mapping[str, object], str], tuple[float, float, int]]
312_EXTRACTORS: dict[str, AreaExtractor] = {}
316 _EXTRACTORS[name] = extractor
320 return sorted(_EXTRACTORS)
324 injector_type: str, geometry: Mapping[str, object], side: str
325) -> tuple[float, float, int]:
326 """Effective area, hydraulic diameter and element count for one side.
329 KeyError: unrecognised injector type. Refused rather than guessed:
330 a wrong area is a wrong mass flow is a wrong chamber pressure, and
331 every number downstream of it stays perfectly plausible.
333 if injector_type
not in _EXTRACTORS:
335 f
"unknown injector type {injector_type!r}; registered: "
336 f
"{', '.join(registered_injector_types())}. Add one with "
337 "register_injector_type() -- do not fall back to a default area."
339 return _EXTRACTORS[injector_type](geometry, side)
342def _as_float(block: Mapping[str, object], key: str, where: str) -> float:
343 value = block.get(key)
345 raise KeyError(f
"{where}: missing {key!r}")
349def _impinging(geometry: Mapping[str, object], side: str) -> tuple[float, float, int]:
350 """Round jets, ``n`` of them per side."""
351 block = geometry.get(side)
352 if not isinstance(block, Mapping):
354 f
"impinging geometry has no {side!r} block; found: "
355 f
"{', '.join(sorted(str(k) for k in geometry))}"
357 d =
_as_float(block,
"d_jet", f
"impinging.{side}")
358 n = int(
_as_float(block,
"n_elements", f
"impinging.{side}"))
359 return n * math.pi * d * d / 4.0, d, n
362def _pintle(geometry: Mapping[str, object], side: str) -> tuple[float, float, int]:
363 """Discrete orifices on the ox side, an annulus on the fuel side.
365 The asymmetry is the whole point, and it is where an injector-type-blind
366 importer goes wrong: applying the orifice formula to a fuel annulus produces
367 a number with the right units and the wrong value.
369 key =
"lox" if side ==
"oxidizer" else side
370 block = geometry.get(key)
371 if not isinstance(block, Mapping):
373 f
"pintle geometry has no {key!r} block; found: "
374 f
"{', '.join(sorted(str(k) for k in geometry))}"
377 d =
_as_float(block,
"d_orifice",
"pintle.lox")
378 n = int(
_as_float(block,
"n_orifices",
"pintle.lox"))
379 return n * math.pi * d * d / 4.0, d, n
381 tip =
_as_float(block,
"d_pintle_tip",
"pintle.fuel")
382 gap =
_as_float(block,
"h_gap",
"pintle.fuel")
384 r_outer = r_inner + gap
385 area = math.pi * (r_outer * r_outer - r_inner * r_inner)
387 hydraulic = block.get(
"d_hydraulic")
388 return area, float(hydraulic)
if hydraulic
else 2.0 * gap, 1
391def _coaxial(geometry: Mapping[str, object], side: str) -> tuple[float, float, int]:
392 """Core ports for one propellant, an outer annulus for the other.
394 Convention follows EngineDesign's schema: the *core* carries the oxidiser
395 and the annulus the fuel, which is the usual shear-coaxial arrangement.
397 if side ==
"oxidizer":
398 block = geometry.get(
"core")
399 if not isinstance(block, Mapping):
400 raise KeyError(
"coaxial geometry has no 'core' block")
401 d =
_as_float(block,
"d_port",
"coaxial.core")
402 n = int(
_as_float(block,
"n_ports",
"coaxial.core"))
403 return n * math.pi * d * d / 4.0, d, n
405 block = geometry.get(
"annulus")
406 if not isinstance(block, Mapping):
407 raise KeyError(
"coaxial geometry has no 'annulus' block")
408 inner =
_as_float(block,
"inner_diameter",
"coaxial.annulus")
409 gap =
_as_float(block,
"gap_thickness",
"coaxial.annulus")
410 r_inner = inner / 2.0
411 r_outer = r_inner + gap
412 return math.pi * (r_outer * r_outer - r_inner * r_inner), 2.0 * gap, 1
bool use_pressure_correction
float cd_infinite(self, float|None diameter=None)
float cd(self, float reynolds, *, float|None pressure=None, float|None temperature=None, float|None diameter=None)
float cd_small_hole_exponent
bool use_temperature_correction
float cd_large_hole_log_gain
float throat_diameter(self)
float cd_at(self, float mdot, float rho, float mu, float|None pressure=None)
None __init__(self, str name)
tuple[float, float, int] _impinging(Mapping[str, object] geometry, str side)
tuple[float, float, int] _pintle(Mapping[str, object] geometry, str side)
str species_for(str name)
float _as_float(Mapping[str, object] block, str key, str where)
tuple[float, float, int] _coaxial(Mapping[str, object] geometry, str side)
None register_propellant_alias(str name, str species)
list[str] registered_injector_types()
None register_injector_type(str name, AreaExtractor extractor)
tuple[float, float, int] injector_areas(str injector_type, Mapping[str, object] geometry, str side)