STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
design.py
Go to the documentation of this file.
1"""What feed-twin needs from an engine design, and nothing more.
2
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:
7
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.
14
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.
21
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:
29
30``impinging``
31 ``n_elements`` round jets per side. ``A = n . pi d_jet^2 / 4``.
32
33``pintle``
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.
37
38``coaxial``
39 Core ports and an outer annulus, same asymmetry.
40"""
41
42from __future__ import annotations
43
44import math
45from dataclasses import dataclass, field
46from typing import TYPE_CHECKING, Callable, Mapping
47
48from feedtwin.model.pressure import say
49
50if TYPE_CHECKING: # card.py imports this module; the annotation is all that is needed
51 from feedtwin.engine.card import InjectorCard
52
53#: EngineDesign propellant names to feed-twin species. Kept explicit rather than
54#: lower-cased and hoped for: "LOX" and "oxygen" are the same fluid under two
55#: naming conventions, and a silent miss here would fall back to a default fluid
56#: and quietly change every density in the feed system.
57PROPELLANT_ALIASES: dict[str, str] = {
58 "lox": "oxygen",
59 "o2": "oxygen",
60 "oxygen": "oxygen",
61 "liquid oxygen": "oxygen",
62 "ethanol": "ethanol",
63 "etoh": "ethanol",
64 "c2h5oh": "ethanol",
65 "methane": "methane",
66 "ch4": "methane",
67 "lch4": "methane",
68 "lng": "methane",
69}
70
71
72class UnknownPropellant(KeyError):
73 """A propellant name that does not map to a feed-twin species."""
74
75 def __init__(self, name: str) -> None:
76 import difflib
77
78 close = difflib.get_close_matches(name.lower(), PROPELLANT_ALIASES, n=3)
79 hint = f" Did you mean: {', '.join(close)}?" if close else ""
80 super().__init__(
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 "
85 "engine burns."
86 )
87
88
89def register_propellant_alias(name: str, species: str) -> None:
90 """Map an engine-config propellant name onto a feed-twin species."""
91 PROPELLANT_ALIASES[name.lower()] = species
92
93
94def species_for(name: str) -> str:
95 key = name.strip().lower()
96 if key not in PROPELLANT_ALIASES:
97 raise UnknownPropellant(name)
98 return PROPELLANT_ALIASES[key]
99
100
101@dataclass(frozen=True, slots=True)
103 """Reynolds- and geometry-dependent discharge coefficient.
104
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.
110
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
117 afternoon to find.
118 """
119
120 cd_inf: float = 0.6
121 a_re: float = 0.0
122 cd_min: float = 0.15
123 use_pressure_correction: bool = False
124 p_ref: float = 5.0e6
125 a_p: float = 0.0
126 use_temperature_correction: bool = False
127 t_ref: float = 90.0
128 a_t: float = 0.0
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
136
137 def cd_infinite(self, diameter: float | None = None) -> float:
138 """High-Reynolds ``Cd`` for a hole of this size."""
139 if not self.use_geometry_cd or diameter is None or diameter <= 0.0:
140 return self.cd_inf
141 if self.d_ref <= 0.0:
142 return min(max(self.cd_inf, self.cd_inf_min_geom), self.cd_inf_max)
143 ratio = max(diameter, self.d_min) / self.d_ref
144 value = (
145 self.cd_inf * ratio ** max(self.cd_small_hole_exponent, 0.0)
146 if ratio < 1.0
147 else self.cd_inf + self.cd_large_hole_log_gain * math.log(ratio)
148 )
149 return min(max(value, self.cd_inf_min_geom), self.cd_inf_max)
150
151 def cd(
152 self,
153 reynolds: float,
154 *,
155 pressure: float | None = None,
156 temperature: float | None = None,
157 diameter: float | None = None,
158 ) -> float:
159 ceiling = self.cd_infinite(diameter)
160 if reynolds <= 0.0:
161 return self.cd_min
162 value = ceiling - self.a_re / math.sqrt(max(reynolds, 1e-6))
163 if self.use_pressure_correction and pressure is not None and self.p_ref > 0.0:
164 value *= 1.0 + self.a_p * (pressure / self.p_ref - 1.0)
165 if (
167 and temperature is not None
168 and self.t_ref > 0.0
169 ):
170 value *= 1.0 + self.a_t * (temperature / self.t_ref - 1.0)
171 return float(min(max(value, self.cd_min), ceiling))
172
173
174@dataclass(frozen=True, slots=True)
176 """One propellant's path through the injector face.
177
178 Args:
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.
184 discharge: Cd model.
185 element_count: How many elements. Reporting only; the area already has
186 it folded in.
187 temperature: Design propellant temperature [K], from the engine config.
188 """
189
190 propellant: str
191 area: float
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.
200
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."""
211
212 def cd_at(
213 self, mdot: float, rho: float, mu: float, pressure: float | None = None
214 ) -> float:
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(
221 reynolds,
222 pressure=pressure,
223 temperature=self.temperature or None,
224 diameter=self.hydraulic_diameter,
225 )
226
227
228@dataclass(frozen=True, slots=True)
230 """A Layer-1 engine, reduced to its feed-system boundary.
231
232 Args:
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.
236 fuel: Fuel side.
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.
242 design_thrust: [N].
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.
249 """
250
251 name: str
252 injector_type: str
253 oxidiser: InjectorSide
254 fuel: InjectorSide
255 throat_area: float
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
261 cstar: 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."""
267
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."""
272
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."""
284
285 @property
286 def throat_diameter(self) -> float:
287 return math.sqrt(4.0 * self.throat_area / math.pi)
288
289 def summary(self) -> str:
290 return (
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}"
299 )
300
301 def __repr__(self) -> str:
302 return f"EngineDesign({self.summary()})"
303
304
305# --------------------------------------------------------------- injector areas
306
307#: Extracts ``(area, hydraulic diameter, element count)`` for one side of one
308#: injector type, from that type's geometry block. Registered by injector type
309#: so a fourth type is a registration rather than a chain of ``if`` statements.
310AreaExtractor = Callable[[Mapping[str, object], str], tuple[float, float, int]]
311
312_EXTRACTORS: dict[str, AreaExtractor] = {}
313
314
315def register_injector_type(name: str, extractor: AreaExtractor) -> None:
316 _EXTRACTORS[name] = extractor
317
318
319def registered_injector_types() -> list[str]:
320 return sorted(_EXTRACTORS)
321
322
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.
327
328 Raises:
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.
332 """
333 if injector_type not in _EXTRACTORS:
334 raise KeyError(
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."
338 )
339 return _EXTRACTORS[injector_type](geometry, side)
340
341
342def _as_float(block: Mapping[str, object], key: str, where: str) -> float:
343 value = block.get(key)
344 if value is None:
345 raise KeyError(f"{where}: missing {key!r}")
346 return float(value) # type: ignore[arg-type]
347
348
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):
353 raise KeyError(
354 f"impinging geometry has no {side!r} block; found: "
355 f"{', '.join(sorted(str(k) for k in geometry))}"
356 )
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
360
361
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.
364
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.
368 """
369 key = "lox" if side == "oxidizer" else side
370 block = geometry.get(key)
371 if not isinstance(block, Mapping):
372 raise KeyError(
373 f"pintle geometry has no {key!r} block; found: "
374 f"{', '.join(sorted(str(k) for k in geometry))}"
375 )
376 if key == "lox":
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
380
381 tip = _as_float(block, "d_pintle_tip", "pintle.fuel")
382 gap = _as_float(block, "h_gap", "pintle.fuel")
383 r_inner = tip / 2.0
384 r_outer = r_inner + gap
385 area = math.pi * (r_outer * r_outer - r_inner * r_inner)
386 # An annulus's hydraulic diameter is twice the gap, not the tip diameter.
387 hydraulic = block.get("d_hydraulic")
388 return area, float(hydraulic) if hydraulic else 2.0 * gap, 1
389
390
391def _coaxial(geometry: Mapping[str, object], side: str) -> tuple[float, float, int]:
392 """Core ports for one propellant, an outer annulus for the other.
393
394 Convention follows EngineDesign's schema: the *core* carries the oxidiser
395 and the annulus the fuel, which is the usual shear-coaxial arrangement.
396 """
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
404
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
413
414
415register_injector_type("impinging", _impinging)
416register_injector_type("pintle", _pintle)
417register_injector_type("coaxial", _coaxial)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float cd_infinite(self, float|None diameter=None)
Definition design.py:137
float cd(self, float reynolds, *, float|None pressure=None, float|None temperature=None, float|None diameter=None)
Definition design.py:158
float cd_at(self, float mdot, float rho, float mu, float|None pressure=None)
Definition design.py:214
tuple[float, float, int] _impinging(Mapping[str, object] geometry, str side)
Definition design.py:349
tuple[float, float, int] _pintle(Mapping[str, object] geometry, str side)
Definition design.py:362
str species_for(str name)
Definition design.py:94
float _as_float(Mapping[str, object] block, str key, str where)
Definition design.py:342
tuple[float, float, int] _coaxial(Mapping[str, object] geometry, str side)
Definition design.py:391
None register_propellant_alias(str name, str species)
Definition design.py:89
list[str] registered_injector_types()
Definition design.py:319
None register_injector_type(str name, AreaExtractor extractor)
Definition design.py:315
tuple[float, float, int] injector_areas(str injector_type, Mapping[str, object] geometry, str side)
Definition design.py:325