STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
config_schemas.py
Go to the documentation of this file.
1"""Pydantic schemas for YAML/JSON configuration validation"""
2
3from __future__ import annotations
4
5from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
6from typing import Any, Literal, Optional, Union, List, Dict, Tuple, Set
7import numpy as np
8
9
10class FluidConfig(BaseModel):
11 """Fluid property configuration"""
12 name: str
13 density: float = Field(gt=0, description="Density [kg/m³]")
14 viscosity: float = Field(gt=0, description="Dynamic viscosity [Pa·s]")
15 surface_tension: float = Field(gt=0, description="Surface tension [N/m]")
16 vapor_pressure: float = Field(ge=0, description="Vapor pressure [Pa]")
17 specific_heat: float = Field(default=2200.0, gt=0, description="Specific heat at constant pressure [J/(kg·K)]")
18 thermal_conductivity: float = Field(default=0.15, gt=0, description="Thermal conductivity [W/(m·K)]")
19 temperature: float = Field(default=293.15, gt=0, description="Bulk fluid temperature [K]")
20 # Fuel-specific properties for combustion physics
21 latent_heat: Optional[float] = Field(default=None, gt=0, description="Latent heat of vaporization [J/kg] (fuel only)")
22 boiling_point: Optional[float] = Field(default=None, gt=0, description="Boiling point at 1 atm [K] (fuel only)")
23 molecular_weight: Optional[float] = Field(default=None, gt=0, description="Molecular weight [g/mol] (fuel only)")
24 bulk_modulus_pa: Optional[float] = Field(default=None, gt=0, description="Liquid bulk modulus [Pa] — feed acoustics / water-hammer (stability). Preset-supplied; see configs/propellants/.")
25 critical_temperature: Optional[float] = Field(
26 default=None, gt=0,
27 description=(
28 "Liquid critical temperature [K]. Sets the evaporation constant in the chug time-lag "
29 "model (Leonardi 2017 eq. 9) — a PROPELLANT property, so it belongs here rather than in "
30 "a table inside the stability code. None = look it up (CoolProp, then handbook), and the "
31 "lookup is recorded in the assumptions registry."
32 ),
33 )
34 injection_phase: Optional[Literal["liquid", "gas"]] = Field(
35 default=None,
36 description=(
37 "Phase of this propellant AT THE INJECTOR FACE. A gas neither atomizes nor vaporizes, so "
38 "it carries only the mixing lag in the chug model; running a droplet-lifetime model on it "
39 "invents a lag that does not exist. None = infer from temperature vs critical point and "
40 "vapor pressure vs chamber pressure."
41 ),
42 )
43
44
45class PintleLOXConfig(BaseModel):
46 """LOX (oxidizer) pintle geometry - Axial flow through orifices"""
47 n_orifices: int = Field(gt=0, description="Number of orifices on pintle tip")
48 d_orifice: float = Field(gt=0, description="Diameter of each orifice [m]")
49 theta_orifice: float = Field(ge=0, le=90, description="Angle of orifices from axis [deg]")
50 A_entry: float = Field(gt=0, description="Single entry hole area [m²]")
51 d_hydraulic: float = Field(gt=0, description="Hydraulic diameter for Re calculation [m]")
52
53
54class PintleFuelConfig(BaseModel):
55 """Fuel (RP-1) pintle geometry - Reservoir with gap spillage"""
56 d_pintle_tip: float = Field(gt=0, description="Outer diameter of pintle tip [m]")
57 d_reservoir_inner: float = Field(gt=0, description="Inner diameter of fuel reservoir [m]")
58 h_gap: float = Field(gt=0, description="Gap height between pintle tip and reservoir [m]")
59 A_entry: float = Field(gt=0, description="Single entry port area into reservoir [m²]")
60 d_hydraulic: float = Field(gt=0, description="Hydraulic diameter for Re calculation [m] (gap hydraulic diameter)")
61
62
63class PintleGeometryConfig(BaseModel):
64 """Pintle injector geometry configuration"""
65 lox: PintleLOXConfig
66 fuel: PintleFuelConfig
67
68
69class InjectorBaseConfig(BaseModel):
70 """Base injector configuration with type identifier"""
71 type: Literal["pintle", "coaxial", "impinging"]
72
73
74class PintleInjectorConfig(InjectorBaseConfig):
75 """Complete pintle injector configuration"""
76 type: Literal["pintle"] = "pintle"
77 geometry: PintleGeometryConfig
78
79
80class CoaxialCoreConfig(BaseModel):
81 """Core (inner) element geometry for coaxial injector"""
82 n_ports: int = Field(gt=0, description="Number of core ports/nozzles")
83 d_port: float = Field(gt=0, description="Diameter of each core port [m]")
84 length: Optional[float] = Field(default=None, gt=0, description="Port length for loss modeling [m]")
85
86
87class CoaxialAnnulusConfig(BaseModel):
88 """Annular (outer) element geometry for coaxial injector"""
89 inner_diameter: float = Field(gt=0, description="Inner diameter of annulus (matches core OD) [m]")
90 gap_thickness: float = Field(gt=0, description="Annulus gap thickness [m]")
91 swirl_angle: float = Field(default=0.0, ge=0, le=90, description="Swirl angle for outer flow [deg]")
92
93
94class CoaxialInjectorGeometry(BaseModel):
95 """Complete geometry description for a shear coaxial injector"""
96 core: CoaxialCoreConfig
97 annulus: CoaxialAnnulusConfig
98
99
100class CoaxialInjectorConfig(InjectorBaseConfig):
101 """Coaxial injector configuration"""
102 type: Literal["coaxial"] = "coaxial"
103 geometry: CoaxialInjectorGeometry
104
105
106class ImpingingElementConfig(BaseModel):
107 """Geometry parameters for a single impinging jet element"""
108 n_elements: int = Field(gt=0, description="Number of elements (pairs or triplets)")
109 d_jet: float = Field(gt=0, description="Jet diameter [m]")
110 impingement_angle: float = Field(
111 gt=0, lt=90,
112 description=(
113 "This stream's jet angle from the chamber AXIS [deg], not the included angle. "
114 "The included angle of an unlike doublet is theta_O + theta_F; the face "
115 "incidence the drill sees is 90 - theta."
116 ),
117 )
118 spacing: float = Field(gt=0, description="Center-to-center spacing between jets [m]")
119
120
121class ImpingingInjectorGeometry(BaseModel):
122 """Complete geometry for an impinging injector"""
123 oxidizer: ImpingingElementConfig
124 fuel: ImpingingElementConfig
125
126
127class IgniterPortConfig(BaseModel):
128 """The igniter port through the centre of the injector plate.
129
130 Hardware, not a requirement: the thread is what was bought, and every keep-out follows from
131 it (engine/core/injectors/hardware_tables.NPT). Declaring this derives the centre keep-out
132 that ``layer1_injector_center_clear_dia_m`` used to be typed in as a bare number (1.5 in on
133 the shipped designs, against the ~25 mm a 1/2 NPT port needs); an explicit value for that
134 key still wins and the layout reports both.
135 """
136 thread: Literal["1/8 NPT", "1/4 NPT", "3/8 NPT", "1/2 NPT", "3/4 NPT"] = Field(
137 description="Igniter thread callout. ASME B1.20.1 dimensions are looked up from it.")
138 hub_thickness: Optional[float] = Field(default=None, gt=0.0,
139 description="Plate thickness AT the port [m] when the centre is left thicker than the "
140 "field -- a raised hub on the back, machined integral ('tall port'), as wide "
141 "as the port's keep-out (thread OD + 2 x layer1_injector_min_web_m). The "
142 "thread needs its effective length L2 of engagement (13.56 mm for 1/2 NPT). "
143 "None => the field thickness layer1_injector_plate_thickness_m.")
144
145
146class BackGrooveConfig(BaseModel):
147 """An annular groove in the plug's back face, as the designer drew it (a face seal's gland).
148 Drawn, exported and checked for clearance to the channels; never sized here."""
149 r_inner: float = Field(gt=0.0, description="Inner radius [m].")
150 r_outer: float = Field(gt=0.0, description="Outer radius [m].")
151 depth: float = Field(gt=0.0, description="Depth into the back face [m].")
152 corner_radius: float = Field(default=0.0, ge=0.0, description="Fillet at the bottom corners [m].")
153
154
155class RimGlandConfig(BaseModel):
156 """A groove round the plug's rim (a radial seal's gland), as the designer drew it."""
157 z_start: float = Field(ge=0.0, description="Axial distance from the face to the groove's face-side wall [m].")
158 width: float = Field(gt=0.0, description="Axial width [m].")
159 depth: float = Field(gt=0.0, description="Radial depth into the rim [m].")
160 corner_radius: float = Field(default=0.0, ge=0.0, description="Fillet at the bottom corners [m].")
161
162
163class InjectorPlateConfig(BaseModel):
164 """The injector as a plug in the chamber sleeve: how its face and back are machined.
165 Geometry, plus a closed-form plate-bending estimate -- FEA, seals, the manifold cover and the
166 plug's retention are the designer's; engine/core/injectors/layout.py reports the lands they get. The holes' L/d is
167 discharge.<side>.orifice_l_over_d: one number sets both the Cd and, with channels, how deep
168 the channels sit."""
169 face: Literal["flat", "contoured"] = Field(default="contoured",
170 description="flat: orifices leave an axis-normal face at the jet angle (elliptical exits, "
171 "the drill enters off square). contoured: the face is turned with an annular "
172 "groove whose flanks are normal to the jets, so each orifice leaves square and "
173 "round (SP-8089 p.43, 'local grooving ... to increase the effective angle').")
174 exit_land: Optional[float] = Field(default=None, ge=0.0,
175 description="Metal between a round exit and the edge of its flank, and between a passage "
176 "and its channel wall [m]. None => 0.5 mm (assumed).")
177 groove_bottom: Literal["flat", "v"] = Field(default="flat",
178 description="Contoured face: flat-bottomed groove just deep enough for each exit's land "
179 "(default), or the sharp V where the flanks meet.")
180 back: Literal["plenum", "channels"] = Field(default="channels",
181 description="plenum: passages run through to a flat back face (a manifold volume behind "
182 "it). channels: the back face is flat with one annular channel per ring; each "
183 "passage runs from its exit to its channel floor and the channel is centred on "
184 "where the passage meets it.")
185 channel_width: Optional[float] = Field(default=None, gt=0.0,
186 description="Channels back: channel width [m]. None or narrower than the passage footprint "
187 "=> footprint + 2 x exit_land.")
188 channel_floor: Literal["flat", "coned", "spot"] = Field(default="flat",
189 description="Channels back: flat floor (holes break through off square; deburr and "
190 "flow-test), a floor turned as a cone normal to the passages (square "
191 "breakthrough), or spot: a flat floor channel_width wide beside a drill-spot "
192 "facet square to the passages, channel_spot_length across (square breakthrough; "
193 "the stand's plate).")
194 channel_spot_length: Optional[float] = Field(default=None, gt=0.0,
195 description="Spot floor: the facet's length across the hole [m]. None => d + 2 x exit_land.")
196 channel_inlets: int = Field(default=1, ge=1,
197 description="Channels back: feed ports into each channel. Each splits two ways around "
198 "the ring, so a branch carries mdot / (2 x inlets) at the port; the layout "
199 "checks that branch's velocity head against the injector drop.")
200 support: Literal["simply_supported", "clamped"] = Field(default="simply_supported",
201 description="How the plug's edge is held, for the layout's plate-bending check (circular "
202 "plate under Pc over the bore). simply_supported (conservative for a plug on a "
203 "shoulder or retaining ring) or clamped (edge cannot rotate).")
204 yield_strength: Optional[float] = Field(default=None, gt=0.0,
205 description="Plate material yield strength at temperature [Pa]. None => the plate-bending "
206 "check reports stress only; declared, stress above yield / 1.5 is flagged.")
207 poisson_ratio: Optional[float] = Field(default=None, gt=0.0, lt=0.5,
208 description="Plate material Poisson ratio for the plate-bending check. None => 0.3 (assumed).")
209 channel_flow_height_O: Optional[float] = Field(default=None, gt=0.0,
210 description="Channels back: height [m] of the LOX manifold passage the flow sees (the "
211 "channel plus any groove in the cover). None => the channel depth the layout "
212 "draws. Flow area = channel_width x this.")
213 channel_flow_height_F: Optional[float] = Field(default=None, gt=0.0,
214 description="As channel_flow_height_O, for the fuel channel [m].")
215 manifold_model: Literal["ring_network", "plenum"] = Field(default="ring_network",
216 description="Channels back: 'ring_network' solves each channel as a dividing-flow ring "
217 "(each port splits two ways; holes fed by the local static pressure, NASA TN "
218 "D-5467; friction and momentum regain between holes, Acrivos/Bajura). 'plenum' "
219 "treats the manifold as still fluid at one pressure (the old model).")
220 channel_entry_K: float = Field(default=0.5, ge=0.0,
221 description="Loss from the feed port into each channel branch, on the branch velocity head "
222 "(sharp entrance, Idelchik diagram 3-1: 0.5). Assumed until flow-tested.")
223 channel_pressure_regain: float = Field(default=1.0, ge=0.0, le=1.0,
224 description="Static-pressure regain fraction as the branch slows past each hole (dividing-"
225 "flow manifold; 1 = ideal momentum recovery, Acrivos, Babcock & Pigford 1959; "
226 "measured 0.6-1). Assumed until flow-tested.")
227 channel_roughness: float = Field(default=3.2e-6, ge=0.0,
228 description="Channel wall roughness [m] for Churchill friction (machined, Ra ~0.8 um).")
229 back_grooves: List[BackGrooveConfig] = Field(default_factory=list,
230 description="Grooves cut in the back face (face-seal glands), as drawn. The layout draws "
231 "them, keeps them in the plate section (bending) and flags one that runs into "
232 "a channel, the centre port or another groove.")
233 rim_gland: Optional[RimGlandConfig] = Field(default=None,
234 description="Groove round the plug's rim (radial seal gland), as drawn.")
235 port_thread: Optional[Literal["1/8 NPT", "1/4 NPT", "3/8 NPT", "1/2 NPT", "3/4 NPT"]] = Field(
236 default=None,
237 description="Feed ports in the cover plate above the channels, channel_inlets per ring. "
238 "Drawn in the port-plate view; each port's bore is checked against the "
239 "back-face grooves it lands over.")
240 port_bore: Optional[float] = Field(default=None, gt=0.0,
241 description="Diameter the port opens with onto the injector's back face [m]. None => the "
242 "thread's tap drill.")
243 port_radius_O: Optional[float] = Field(default=None, gt=0.0,
244 description="Radius of the LOX ports [m]. None => the middle of the LOX channel.")
245 port_radius_F: Optional[float] = Field(default=None, gt=0.0,
246 description="Radius of the fuel ports [m]. None => the middle of the fuel channel.")
247 port_clock_F_deg: Optional[float] = Field(default=None,
248 description="Angle from the first LOX port to the first fuel port [deg]. None => half the "
249 "port pitch.")
250 profile_dxf_mode: Literal["geometry", "check"] = Field(default="geometry",
251 description="geometry: the drawing IS the plate (its section replaces the model's). check: "
252 "the plate is built from the fields above and compared with the drawing, line "
253 "by line; where they differ the layout says so.")
254 profile_dxf: Optional[str] = Field(default=None,
255 description="The plate's revolved half-section as drawn (a DXF of the revolve sketch, "
256 "units set; path absolute or from EngineDesign/). When set it is the geometry: "
257 "plate thickness, face groove, the channels' sections (flow area, hydraulic "
258 "diameter, centroid radius) and each passage's exit, length and entry are read "
259 "from it (engine/core/injectors/plate_dxf.py), and where the config's rings, L/d "
260 "or igniter disagree with it the layout says so. channel_width and "
261 "channel_flow_height_* are then not used.")
262
263
264class ImpingingInjectorConfig(InjectorBaseConfig):
265 """Impinging-element injector configuration"""
266 type: Literal["impinging"] = "impinging"
267 geometry: ImpingingInjectorGeometry
268 igniter: Optional[IgniterPortConfig] = Field(default=None,
269 description="Igniter port through the plate centre. None => no port modelled.")
270 plate: Optional[InjectorPlateConfig] = Field(default=None,
271 description="How the plug's face and back are machined, and its stress-check material. "
272 "None => drawn as a contoured face with a channel back (the defaults); Layer 1 applies the plug's constraints only when declared.")
273
274
275#: Named feed-line sizes -> flow bore [m]. A line size is a NAME, not a diameter: "3/8 NPT"
276#: names a *thread*, and its flow area is set by the fitting's through-bore (~0.380"), not by
277#: the 0.375" thread nominal. Those two differ by only 1.3% in diameter but the pair is exactly
278#: the kind of near-miss that looks right forever -- so the mapping lives here, once, by name.
279FEED_LINE_SIZES: Dict[str, float] = {
280 # NPT fittings — keyed on the thread callout the user actually buys, valued at the bore.
281 "1/4_NPT": 0.00635, # 0.250" through-bore
282 "3/8_NPT": 0.0096520, # 0.380" through-bore (NOT 0.375" — that is the thread nominal)
283 "1/2_NPT": 0.0127000, # 0.500" through-bore
284 "3/4_NPT": 0.0190500, # 0.750" through-bore
285 # Tube ODs quote a wall, so the bore depends on it; these assume 0.035" wall.
286 # Both values encoded a 0.0325" wall, not the 0.035" their names and comments claim
287 # (and the 1/2" comment's own arithmetic was wrong: 0.500 - 2(0.035) = 0.430, not 0.435).
288 # Corrected to the stated wall. The old values overstated flow area 2.3-3.3%, which is
289 # 4.7-6.7% of understated dp -- small, but this table exists to be the one place the
290 # near-miss cannot hide.
291 "3/8_TUBE_035": 0.0077470, # 0.375" OD - 2(0.035") = 0.305"
292 "1/2_TUBE_035": 0.0109220, # 0.500" OD - 2(0.035") = 0.430"
293}
294
295
296class FeedFitting(BaseModel):
297 """One itemised loss in a feed run: a valve, bend, tee, filter or fitting.
298
299 ``K`` is in velocity heads of the LINE (A_hydraulic), so a component in a different bore
300 must be referred first: K_line = K_local (A_line / A_local)^2 (scripts/feed_line_K.py).
301 ``source`` is required -- a loss coefficient with no provenance is the thing this list
302 exists to replace.
303 """
304 name: str = Field(min_length=1, description="What the component is, e.g. '1/2 in ball valve'.")
305 K: float = Field(ge=0, description="Loss coefficient on the line velocity head [-].")
306 source: str = Field(min_length=1, description="Where K came from (Crane TP-410 A-27, datasheet Cv, water-flow test, ...).")
307
308
309class FeedSystemConfig(BaseModel):
310 """Feed system configuration for one branch (O or F).
311
312 The feed system is the PLUMBING between tank and injector — line bore plus lumped loss
313 coefficients. It sets how much tank pressure is spent getting propellant to the injector
314 face, so it directly moves the required tank pressure. It is vehicle hardware and is
315 deliberately independent of which propellant flows through it.
316
317 Give ``line_size`` (preferred — a name from FEED_LINE_SIZES) and the bore and area are
318 derived for you. ``d_inlet``/``A_hydraulic`` remain available for a non-standard passage,
319 and an explicitly-given area always wins over the derived one.
320 """
321 line_size: Optional[str] = Field(
322 default=None,
323 description=(
324 "Named feed-line size, e.g. '3/8_NPT'. Sets d_inlet (and hence A_hydraulic) from "
325 f"the standard bore table. Valid: {', '.join(sorted(FEED_LINE_SIZES))}."
326 ),
327 )
328 d_inlet: float = Field(
329 gt=0,
330 description=(
331 "Inlet flow-path diameter [m] — the actual bore, not a thread size. Normally derived "
332 "from line_size; set directly only for a non-standard passage."
333 ),
334 )
335 A_hydraulic: float = Field(
336 gt=0,
337 description="Flow area of the feed line [m²]. Derived as πd²/4 from d_inlet when omitted.",
338 )
339 K0: float = Field(ge=0, description="Base loss coefficient")
340 supply_K: float = Field(
341 default=0.0, ge=0,
342 description=(
343 "The part of K0 that is the pressurant supply sagging under flow (regulator droop and "
344 "supply effect), not the line: it lowers the injector inlet exactly as K0 does, but at "
345 "chug frequencies it is the regulator's compliance (modelled separately in stability), so "
346 "the chug loop's feed resistance leaves it out. Written by Layer X's feed fit. 0: K0 is "
347 "all line, as before."
348 ),
349 )
350 K1: float = Field(ge=0, description="Pressure dependence coefficient")
351 phi_type: Literal["none", "sqrtP", "logP"] = Field(
352 default="none",
353 description="Pressure function type"
354 )
355 length: Optional[float] = Field(
356 default=None,
357 gt=0,
358 description=(
359 "Feed-line length from tank outlet to injector manifold [m]. Sets the line inertance "
360 "(length / area) in the chug model. Leave unset and the stability model records a "
361 "0.305 m assumption instead of using it silently."
362 ),
363 )
364 K_exit: float = Field(
365 default=1.0,
366 ge=0,
367 description=(
368 "Loss where the line discharges into the injector manifold, in velocity heads of the "
369 "exit bore (d_exit). 1.0: a plenum takes the whole velocity head (Borda-Carnot, "
370 "(1 - A_exit/A_manifold)^2 -> 1; Crane TP-410 pipe exit K = 1.0). Set 0 only when K0 "
371 "already counts it."
372 ),
373 )
374 d_exit: Optional[float] = Field(
375 default=None,
376 gt=0,
377 description=(
378 "Bore that discharges into the manifold [m], e.g. the 1/2 NPT fitting after a 1/2 in "
379 "tube run. None => the line itself (A_hydraulic)."
380 ),
381 )
382 fittings: List[FeedFitting] = Field(
383 default_factory=list,
384 description=(
385 "Itemised valves, bends, tees, filters and fittings, each {name, K, source} with K on "
386 "the line velocity head. Summed into K_eff on both paths (K0 and roughness_m). Empty "
387 "=> no change."
388 ),
389 )
390 roughness_m: Optional[float] = Field(
391 default=None,
392 ge=0,
393 description=(
394 "Absolute wall roughness [m] (drawn stainless tube 1.5e-6, Crane TP-410 A-23). When "
395 "set, K_eff = K_entrance + f L/d_inlet + sum(fittings) with Darcy f from Colebrook-"
396 "White at the actual Re = rho v d_inlet / mu of each call, and K0 is NOT used. None "
397 "=> K0 is the loss (the measured-override path), exactly as before. Assumes one "
398 "circular passage of bore d_inlet; requires length."
399 ),
400 )
401 K_entrance: float = Field(
402 default=0.5,
403 ge=0,
404 description=(
405 "Tank-to-line entrance loss, line velocity heads; used only when roughness_m is set. "
406 "0.5 = sharp-edged entrance (Crane TP-410 A-29)."
407 ),
408 )
409 derived_from: Optional[Dict[str, Any]] = Field(
410 default=None,
411 description=(
412 "Where K0 came from when it was fitted rather than typed: Layer X writes the feed-twin "
413 "drawing it burned, the run, the fitted line and supply terms, the flow they hold at, and "
414 "the K0 it replaced (engine/layerx/feedfit.py). Read by people, not by the physics."
415 ),
416 )
417
418 @model_validator(mode="after")
419 def _roughness_needs_length(self):
420 if self.roughness_m is not None and self.length is None:
421 raise ValueError(
422 "feed_system: roughness_m is set but length is not -- the friction path needs "
423 "f L/D. Give the line length, or drop roughness_m to use K0."
424 )
425 return self
426
427 @model_validator(mode="before")
428 @classmethod
429 def _resolve_line_size(cls, data):
430 """Fill d_inlet from line_size, and A_hydraulic from d_inlet, before validation.
431
432 d_inlet and A_hydraulic describe ONE passage two ways, and both used to be required and
433 independently editable, so nothing stopped them from disagreeing -- a stale area silently
434 outlived the diameter next to it. Deriving here means the pair cannot drift, while an
435 explicit A_hydraulic still wins for a genuinely non-circular passage.
436 """
437 if not isinstance(data, dict):
438 return data
439 d = dict(data)
440
441 size = d.get("line_size")
442 if size is not None:
443 key = str(size).strip().replace(" ", "_").replace("-", "_").upper()
444 bore = FEED_LINE_SIZES.get(key)
445 if bore is None:
446 raise ValueError(
447 f"Unknown feed line_size {size!r}. Valid sizes: {', '.join(sorted(FEED_LINE_SIZES))}. "
448 f"For a non-standard passage give d_inlet directly instead."
449 )
450 d["line_size"] = key
451 if d.get("d_inlet") is None:
452 d["d_inlet"] = bore
453 elif abs(float(d["d_inlet"]) - bore) > 1e-9:
454 raise ValueError(
455 f"feed line_size={key} implies d_inlet={bore:.7f} m but d_inlet="
456 f"{float(d['d_inlet']):.7f} m was also given. Drop one -- a named size and a "
457 f"contradicting bore is exactly the drift this field exists to prevent."
458 )
459
460 di = d.get("d_inlet")
461 if di is not None and d.get("A_hydraulic") is None:
462 d["A_hydraulic"] = float(np.pi) / 4.0 * float(di) ** 2
463 elif di is None and d.get("A_hydraulic") is not None:
464 # Area alone (twin line, non-circular passage): the equal-area bore.
465 d["d_inlet"] = float(np.sqrt(4.0 * float(d["A_hydraulic"]) / np.pi))
466 return d
467
468 @model_validator(mode="after")
469 def _warn_area_diameter_mismatch(self):
470 """Flag an A_hydraulic that does not match its own d_inlet (>1% off)."""
471 implied = float(np.pi) / 4.0 * self.d_inlet ** 2
472 if implied > 0 and abs(self.A_hydraulic - implied) / implied > 0.01:
473 import logging
474 logging.getLogger(__name__).warning(
475 "feed_system: A_hydraulic=%.6e m2 disagrees with pi/4*d_inlet^2=%.6e m2 "
476 "(d_inlet=%.6f m, %.1f%% off). Intentional only for a non-circular passage; "
477 "otherwise omit A_hydraulic and let it derive.",
478 self.A_hydraulic, implied, self.d_inlet,
479 100.0 * (self.A_hydraulic - implied) / implied,
480 )
481 return self
482
483
484class RegenCoolingConfig(BaseModel):
485 """Regenerative cooling channel configuration"""
486 enabled: bool = Field(default=False, description="Enable regen cooling model")
487 d_inlet: float = Field(gt=0, description="Inlet pipe diameter [m] (e.g., 3/8\" = 0.009525 m)")
488 L_inlet: float = Field(gt=0, description="Inlet pipe length [m]")
489 n_channels: int = Field(gt=0, description="Number of parallel cooling channels")
490 channel_width: float = Field(gt=0, description="Channel width [m]")
491 channel_height: float = Field(gt=0, description="Channel height [m]")
492 channel_length: float = Field(gt=0, description="Channel length [m] (typically chamber length)")
493 d_outlet: Optional[float] = Field(default=None, description="Outlet pipe diameter [m] (default: same as inlet)")
494 L_outlet: float = Field(gt=0, description="Outlet pipe length [m] (from merge to injector)")
495 roughness: float = Field(default=0.0, ge=0, description="Surface roughness [m] (0 = smooth)")
496 K_manifold_split: float = Field(default=0.5, ge=0, description="Manifold split loss coefficient")
497 K_manifold_merge: float = Field(default=0.3, ge=0, description="Manifold merge loss coefficient")
498 # Dynamic discharge coefficient configuration (similar to injector Cd)
499 Cd_entrance_inf: float = Field(default=0.8, gt=0, le=1, description="Asymptotic Cd at high Re for channel entrance")
500 a_Re_entrance: float = Field(default=0.1, ge=0, description="Reynolds correction parameter for entrance")
501 Cd_entrance_min: float = Field(default=0.6, ge=0, le=1, description="Minimum Cd for entrance")
502 Cd_exit_inf: float = Field(default=0.9, gt=0, le=1, description="Asymptotic Cd at high Re for channel exit")
503 a_Re_exit: float = Field(default=0.1, ge=0, description="Reynolds correction parameter for exit")
504 Cd_exit_min: float = Field(default=0.7, ge=0, le=1, description="Minimum Cd for exit")
505 # Heat-transfer coupling (Phase 2)
506 use_heat_transfer: bool = Field(default=False, description="Enable coupled heat-transfer calculations for regen cooling")
507 wall_thickness: float = Field(default=0.002, gt=0, description="Hot-wall thickness between gas and coolant [m]")
508 wall_thermal_conductivity: float = Field(default=300.0, gt=0, description="Wall material thermal conductivity [W/(m·K)]")
509 chamber_inner_diameter: Optional[float] = Field(default=None, gt=0, description="Chamber inner diameter for hot-side area [m]")
510 hot_gas_prandtl: float = Field(default=0.7, gt=0, description="Assumed hot-gas Prandtl number")
511 hot_gas_viscosity: float = Field(default=4.0e-5, gt=0, description="Effective hot-gas viscosity [Pa·s]")
512 hot_gas_thermal_conductivity: float = Field(default=0.1, gt=0, description="Effective hot-gas thermal conductivity [W/(m·K)]")
513 radiation_emissivity_hot: float = Field(default=0.8, ge=0, le=1, description="Effective hot-side emissivity for radiation")
514 radiation_view_factor: float = Field(default=1.0, ge=0, le=1, description="Radiation view factor to coolant surface")
515 n_segments: int = Field(default=20, gt=0, description="Number of axial segments for heat-transfer integration")
516 gas_turbulence_intensity: float = Field(default=0.1, ge=0, description="Estimated turbulence intensity of hot gas (0-1)")
517 coolant_turbulence_intensity: float = Field(default=0.05, ge=0, description="Estimated turbulence intensity of coolant (0-1)")
518 recovery_factor: Optional[float] = Field(default=None, gt=0, le=1, description="Turbulent boundary layer recovery factor for adiabatic wall temperature (Taw = Tc × recovery_factor). Typical range: 0.90-0.98. If None, uses default from constants.")
519
520
521class FilmCoolingConfig(BaseModel):
522 """Film cooling configuration"""
523 enabled: bool = Field(default=False, description="Enable film cooling model")
524 mass_fraction: float = Field(default=0.05, ge=0, le=0.5, description="Fraction of total mass flow used for film injection")
525 injection_temperature: Optional[float] = Field(default=None, gt=0, description="Film injection temperature [K] (defaults to fuel temperature)")
526 effectiveness_ref: float = Field(default=0.4, ge=0, le=1, description="Reference film effectiveness at injection location")
527 decay_length: float = Field(default=0.1, gt=0, description="Characteristic decay length for film effectiveness [m]")
528 apply_to_fraction_of_length: float = Field(default=1.0, gt=0, description="Portion of chamber length covered by film cooling")
529 slot_height: float = Field(default=3.0e-4, gt=0, description="Annular slot height for film injection [m]")
530 reference_blowing_ratio: float = Field(default=0.5, gt=0, description="Reference blowing ratio for effectiveness correlation")
531 blowing_exponent: float = Field(default=0.6, gt=0, description="Exponent on blowing ratio for effectiveness correlation")
532 turbulence_reference_intensity: float = Field(default=0.08, gt=0, description="Reference turbulence intensity for film erosion")
533 turbulence_sensitivity: float = Field(default=1.0, ge=0, description="Sensitivity of film effectiveness to turbulence intensity")
534 turbulence_exponent: float = Field(default=1.0, gt=0, description="Exponent governing turbulence erosion scaling")
535 turbulence_min_multiplier: float = Field(default=0.4, ge=0, le=1, description="Minimum multiplier applied to effectiveness due to turbulence erosion")
536 reference_wall_temperature: float = Field(default=1100.0, gt=0, description="Reference hot wall temperature used for heat-flux estimation [K]")
537 density_override: Optional[float] = Field(default=None, gt=0, description="Override density for film coolant if different from bulk fuel [kg/m³]")
538 cp_override: Optional[float] = Field(default=None, gt=0, description="Override specific heat for film coolant if different from bulk fuel [J/(kg·K)]")
539
540
541class SurfaceReactionConfig(BaseModel):
542 """Heterogeneous carbon-oxidiser rate A T^b exp(-E/RT) p^n [kg C/(m^2 s)], p in atm."""
543 A: float = Field(gt=0, description="Pre-exponential factor [kg/(m^2 s atm^n K^b)]")
544 E: float = Field(ge=0, description="Activation energy [J/mol]")
545 n: float = Field(ge=0, description="Pressure exponent on the oxidiser partial pressure [-]")
546 T_exponent: float = Field(default=0.0, description="Temperature exponent b [-]")
547
548
549class GraphiteInsertConfig(BaseModel):
550 """Graphite throat insert configuration (separate from chamber ablator)"""
551 enabled: bool = Field(default=False, description="Enable graphite throat insert")
552 material_density: float = Field(default=1800.0, gt=0, description="Graphite BULK density [kg/m³], from the grade's datasheet (isomolded 1750-1900). Not the 2260 of a single crystal: recession is carbon mass flux over this density")
553 heat_of_ablation: float = Field(default=8.0e6, gt=0, description="Effective heat of ablation [J/kg] (graphite: ~8-12 MJ/kg)")
554 thermal_conductivity: float = Field(default=100.0, gt=0, description="Graphite thermal conductivity [W/(m·K)] (typical: 50-150)")
555 specific_heat: float = Field(default=710.0, gt=0, description="Graphite specific heat [J/(kg·K)]; the insert's heat capacity when specific_heat_model is 'constant'")
556 specific_heat_model: Literal["constant", "butland_maddison_1973"] = Field(
557 default="constant",
558 description=(
559 "How the insert's specific heat follows temperature (engine/pipeline/thermal/graphite_properties.py). "
560 "'constant' uses specific_heat at every temperature; 'butland_maddison_1973' is graphite's published "
561 "cp(T), 0.71 kJ/(kg K) at 300 K rising to 2.0 at 2000 K, the same for every polygranular grade."
562 ),
563 )
564 material_source: Optional[str] = Field(
565 default=None,
566 description="Where the insert's material numbers came from: grade, manufacturer, datasheet.",
567 )
568 initial_thickness: float = Field(default=0.005, gt=0, description="Initial graphite insert thickness [m]")
569 surface_temperature_limit: float = Field(default=2500.0, gt=0, description="Maximum surface temperature before failure [K]")
570 oxidation_temperature: float = Field(default=800.0, gt=0, description="Onset temperature for oxidation [K]")
571 oxidation_rate: float = Field(default=1e-6, ge=0, description="Unused by the species model (kept so old configs load)")
572 activation_energy: Optional[float] = Field(default=180e3, gt=0, description="Activation energy for Arrhenius oxidation rate [J/mol]. Typical: 150-200 kJ/mol")
573 oxidation_reference_temperature: float = Field(default=1500.0, gt=0, description="Reference temperature where oxidation_rate is defined [K]. Typical: 1500-1800 K")
574 oxidation_reference_pressure: float = Field(default=1.0e6, gt=0, description="Reference pressure where oxidation_rate is defined [Pa]. Typical: 1 MPa")
575 recession_multiplier: Optional[float] = Field(default=None, gt=0, description="Recession multiplier vs chamber (if None, calculated from flow conditions). Typically 1.3-2.5")
576 sizing_only_mode: bool = Field(default=False, description="If True, suppress recession for sizing iterations. Graphite does recede in reality; use only for design phase.")
577 simplified_graphite_oxidation: bool = Field(default=False, description="If True, use the constant `simplified_oxidation_rate` instead of instead of the physics-based model.")
578 simplified_oxidation_rate: float = Field(
579 default=1.0e-5,
580 ge=0.0,
581 description=(
582 "Radial recession rate used when `simplified_graphite_oxidation` is true [m/s]. "
583 "Default 1e-5 m/s = 0.01 mm/s, which was hardcoded. Measure your own stock and set "
584 "it here rather than inheriting a number from someone else's graphite."
585 ),
586 )
587 sizing_recession_rate: float = Field(
588 default=1.0e-8,
589 ge=0.0,
590 description=(
591 "Recession rate assumed while SIZING the chamber [m/s]. Default 1e-8 is effectively "
592 "zero -- the sizing pass has always treated graphite as non-eroding. Raise it if your "
593 "insert measurably recedes over a burn and you want the bore sized for it."
594 ),
595 )
596 axial_half_length_ratio: float = Field(
597 default=0.75,
598 gt=0.0,
599 description=(
600 "Graphite insert axial half-length as a multiple of throat DIAMETER, used when "
601 "`axial_half_length` is unset. 0.75 was hardcoded in three geometry modules."
602 ),
603 )
604 axial_half_length: Optional[float] = Field(
605 default=None,
606 gt=0.0,
607 description="Explicit graphite insert axial half-length [m]. Overrides axial_half_length_ratio.",
608 )
609 char_layer_conductivity: float = Field(default=5.0, gt=0, description="Thermal conductivity of protective layer [W/(m·K)]")
610 char_layer_thickness: float = Field(default=0.0005, gt=0, description="Thickness of protective layer [m]")
611 coverage_fraction: float = Field(default=1.0, gt=0, le=1.0, description="Fraction of throat/nozzle with graphite insert")
612 emissivity: Optional[float] = Field(default=None, ge=0, le=1, description="Surface emissivity for radiation (default: 0.8)")
613 ambient_temperature: Optional[float] = Field(default=None, gt=0, description="Ambient temperature for radiation [K] (default: 300 K)")
614 feedback_fraction_min: Optional[float] = Field(default=None, ge=0, le=1, description="Minimum oxidation heat feedback fraction (default: 0.0)")
615 feedback_fraction_max: Optional[float] = Field(default=None, ge=0, le=1, description="Maximum oxidation heat feedback fraction (default: 0.2)")
616 oxidation_enthalpy: Optional[float] = Field(default=None, gt=0, description="Unused: reaction enthalpies are per species (graphite_cooling.CARBON_OXIDISERS)")
617 ablation_surface_temperature: Optional[float] = Field(default=None, gt=0, description="Surface temperature at which thermal ablation pins T_s [K] (default: 3000 K). Above this, T_s is fixed and m_dot_th balances energy.")
618 ablation_transition_width: float = Field(default=200.0, gt=0, description="Temperature width [K] for smooth transition to thermal ablation regime.")
619 oxidation_pressure_exponent: Optional[float] = Field(default=None, ge=0, description="Pressure exponent for oxidation kinetics (default: 0.5)")
620 oxidation_pre_exponential: Optional[float] = Field(default=None, gt=0, description="Pre-exponential factor for Arrhenius oxidation (default: calculated from oxidation_rate)")
621 mixture_mw: Optional[float] = Field(default=None, gt=0, description="Average molecular weight of combustion products [kg/mol] (default: 0.024)")
622 oxidation_stoichiometry_ratio: Optional[float] = Field(default=None, gt=0, description="Moles of C per mole of O2 (1.0 for CO2, 2.0 for CO) (default: 1.0)")
623 oxygen_mass_fraction: Optional[float] = Field(
624 default=None,
625 ge=0,
626 le=1,
627 description="Unused: the throat composition comes from CEA.",
628 )
629 oxygen_mole_fraction: Optional[float] = Field(
630 default=None,
631 ge=0,
632 le=1,
633 description="Unused: the throat composition comes from CEA.",
634 )
635 friction_coefficient_override: Optional[float] = Field(default=None, gt=0, description="Override skin friction coefficient Cf for blowing parameter calculation.")
636 reference_diffusivity: Optional[float] = Field(default=None, gt=0, description="Reference O2 diffusivity [m²/s] at reference temperature and pressure.")
637 reference_diffusivity_temperature: float = Field(default=1500.0, gt=0, description="Reference temperature [K] for oxygen diffusivity scaling.")
638 reference_diffusivity_pressure: float = Field(default=1.0e6, gt=0, description="Reference pressure [Pa] for oxygen diffusivity scaling.")
639 # Carbon oxidation by the throat gas (H2O, CO2, OH kinetic; O2, O diffusion-limited). Rates
640 # of Bradley et al. 1984, as tabulated by Thakre & Yang, J. Propulsion Power 24(4), 2008.
641 oxidation_H2O: SurfaceReactionConfig = Field(
642 default_factory=lambda: SurfaceReactionConfig(A=4.8e5, E=288.0e3, n=0.5),
643 description="C + H2O -> CO + H2 surface rate (Bradley 1984 via Thakre & Yang 2008)")
644 oxidation_CO2: SurfaceReactionConfig = Field(
645 default_factory=lambda: SurfaceReactionConfig(A=9.0e3, E=285.0e3, n=0.5),
646 description="C + CO2 -> 2 CO surface rate (Bradley 1984 via Thakre & Yang 2008)")
647 oxidation_OH: SurfaceReactionConfig = Field(
648 default_factory=lambda: SurfaceReactionConfig(A=361.0, E=0.0, n=1.0, T_exponent=-0.5),
649 description="C + OH -> CO + H surface rate (Bradley 1984 via Thakre & Yang 2008)")
650
651
652class StainlessSteelCaseConfig(BaseModel):
653 """Stainless steel case configuration (structural wall behind ablative/graphite)"""
654 enabled: bool = Field(default=True, description="Enable stainless steel case")
655 thickness: float = Field(default=0.003, gt=0, description="Stainless steel wall thickness [m]")
656 thermal_conductivity: float = Field(default=15.0, gt=0, description="Thermal conductivity [W/(m·K)]")
657 density: float = Field(default=8000.0, gt=0, description="Material density [kg/m³]")
658 specific_heat: float = Field(default=500.0, gt=0, description="Specific heat [J/(kg·K)]")
659 max_temperature: float = Field(default=1000.0, gt=0, description="Maximum allowable temperature [K] (melting point ~1700K, but limit lower for structural integrity)")
660 emissivity: float = Field(default=0.3, ge=0, le=1, description="Surface emissivity")
661
662
663class AblativeCoolingConfig(BaseModel):
664 """Ablative cooling configuration for chamber liner (phenolic)"""
665 enabled: bool = Field(default=False, description="Enable ablative cooling model")
666 material_density: float = Field(default=1600.0, gt=0, description="Ablator (phenolic) density [kg/m³]")
667 heat_of_ablation: float = Field(default=2.5e6, gt=0, description="Heat of ablation [J/kg]: energy per kg consumed beyond the sensible heat from ambient_temperature to ablation_surface_temperature (quasi-steady Landau balance)")
668 thermal_conductivity: float = Field(default=0.35, gt=0, description="Ablator (phenolic) thermal conductivity [W/(m·K)]")
669 specific_heat: float = Field(default=1500.0, gt=0, description="Ablator (phenolic) specific heat [J/(kg·K)]")
670 initial_thickness: float = Field(default=0.01, gt=0, description="Initial ablative (phenolic) thickness [m]")
671 surface_temperature_limit: float = Field(default=1200.0, gt=0, description="Allowable surface temperature [K]. A check only; the thermal model runs at ablation_surface_temperature")
672 ablation_surface_temperature: float = Field(default=1986.0, gt=0, description="Char surface temperature while the liner ablates [K]. Default 1986 K is the SiO2 melting point (CRC Handbook), the bound on a silica-phenolic char surface; set it for the reinforcement actually used")
673 coverage_fraction: float = Field(default=1.0, gt=0, le=1.0, description="Fraction of chamber surface protected by ablative liner")
674 pyrolysis_temperature: float = Field(default=900.0, gt=0, description="Characteristic pyrolysis temperature of ablator [K]")
675 blowing_efficiency: float = Field(default=0.8, ge=0, le=1, description="Effectiveness of ablative gases in blocking convective heat flux (legacy constant factor, used if use_physics_based_blowing=False)")
676 use_physics_based_blowing: bool = Field(default=True, description="If True, use physics-based blowing parameter B = m_dot_pyrolysis/m_dot_external. If False, use constant blowing_efficiency factor.")
677 blowing_coefficient: float = Field(default=0.5, gt=0, description="Blowing coefficient c in empirical function f(B) = 1/(1 + c*B). Typical range: 0.3-0.8. Higher values = stronger blowing effect.")
678 blowing_min_reduction_factor: float = Field(default=0.1, ge=0, le=1, description="Minimum convective reduction factor (maximum blowing effectiveness). Prevents blowing from reducing convective heat transfer below this fraction. Default 0.1 means maximum 90% reduction. Lower values allow stronger blowing effect.")
679 turbulence_reference_intensity: float = Field(default=0.08, gt=0, description="Unused: Bartz already carries the chamber's turbulence (kept so old configs load)")
680 turbulence_sensitivity: float = Field(default=1.5, ge=0, description="Unused: Bartz already carries the chamber's turbulence (kept so old configs load)")
681 turbulence_exponent: float = Field(default=1.0, gt=0, description="Unused: Bartz already carries the chamber's turbulence (kept so old configs load)")
682 turbulence_max_multiplier: float = Field(default=3.0, gt=0, description="Unused: Bartz already carries the chamber's turbulence (kept so old configs load)")
683 throat_recession_multiplier: Optional[float] = Field(default=None, gt=0, description="Throat recession multiplier vs chamber (if None, calculated from flow conditions). Typically 1.2-2.0")
684 char_layer_conductivity: float = Field(default=0.2, gt=0, description="Thermal conductivity of char layer [W/(m·K)]")
685 char_layer_thickness: float = Field(default=0.001, gt=0, description="Thickness of protective char layer [m]")
686 surface_emissivity: float = Field(default=0.85, ge=0, le=1, description="Surface emissivity for radiative heat transfer (0-1, typical 0.8-0.9 for charred ablators)")
687 ambient_temperature: float = Field(default=300.0, gt=0, description="Liner temperature at ignition [K] (the wall starts at ambient)")
688 radiative_sink_minimum_threshold: float = Field(default=400.0, gt=0, description="Unused: the hot face radiates only to the gas (kept so old configs load)")
689 radiative_sink_fallback_temperature: float = Field(default=600.0, gt=0, description="Unused: the hot face radiates only to the gas (kept so old configs load)")
690 track_geometry_evolution: bool = Field(default=True, description="Enable time-varying geometry tracking (L* evolution)")
691 nozzle_ablative: bool = Field(default=False, description="If True, nozzle exit also recedes (A_exit grows). If False, only throat recedes (expansion ratio decreases)")
692
693
694class DischargeConfig(BaseModel):
695 """Discharge coefficient configuration"""
696 Cd_inf: float = Field(
697 gt=0,
698 le=1,
699 description=(
700 "Baseline Cd at infinite Re for the reference orifice diameter d_ref_m "
701 "(thin-plate sharp hole, typically 0.60 for machined impinging jets)."
702 ),
703 )
704 a_Re: float = Field(ge=0, description="Reynolds number correction parameter")
705 Cd_min: float = Field(default=0.2, ge=0, le=1, description="Minimum Cd")
706 use_geometry_cd: bool = Field(
707 default=True,
708 description=(
709 "When True, Cd_inf is scaled from jet/orifice diameter d_hyd via cd_inf_from_orifice_diameter "
710 "before the Re correction (impinging jets; pintle orifice OD / fuel gap hydraulic diameter)."
711 ),
712 )
713 d_ref_m: float = Field(
714 default=0.002,
715 gt=0,
716 description="Reference hole diameter [m] where Cd_inf equals the Cd_inf baseline (default 2 mm).",
717 )
718 cd_small_hole_exponent: float = Field(
719 default=0.0,
720 ge=0.0,
721 le=1.0,
722 description=(
723 "For d < d_ref: Cd_inf scales as Cd_inf * (d/d_ref)^exponent. 0 (default): the thin-plate "
724 "asymptote holds below d_ref; ISO 5167 and Sutton & Biblarz Table 8-2 have small sharp "
725 "holes flowing slightly more, not less, so the old 0.2 penalty had the wrong sign."
726 ),
727 )
728 cd_large_hole_log_gain: float = Field(
729 default=0.015,
730 ge=0.0,
731 description="For d > d_ref: Cd_inf += gain * ln(d/d_ref), capped at cd_inf_max.",
732 )
733 cd_inf_max: float = Field(
734 default=0.62,
735 gt=0,
736 le=1,
737 description="Upper cap on geometry-based Cd_inf (slightly radiused / large-hole asymptote).",
738 )
739 cd_inf_min_geom: float = Field(
740 default=0.48,
741 gt=0,
742 le=1,
743 description="Lower floor on geometry-based Cd_inf for very small holes.",
744 )
745 # --- Orifice inlet geometry: the real, per-orifice Cd knob -------------------------
746 # Cd is set by what the INLET EDGE looks like and by L/d, not by hole diameter. Because
747 # discharge.oxidizer and discharge.fuel are separate blocks, filleting only ONE side's
748 # inlet raises that propellant's Cd and moves the momentum ratio / O/F without touching
749 # hole size, element count or angles. One extra machining op, one tuning knob.
750 # Both default to None => the existing diameter-based Cd_inf path is unchanged.
751 inlet_geometry: Optional[str] = Field(
752 default=None,
753 description=(
754 "Orifice inlet treatment: sharp (0.80) | chamfered (0.84) | conical (0.86) | "
755 "rounded_light (0.85) | rounded (0.88) | bellmouth (0.95). Short-tube values at "
756 "Re > 1e4 and L/d ~ 2-5 (engine.core.discharge.INLET_GEOMETRY_CD); the thin-plate "
757 "0.61 is recovered by orifice_l_over_d -> 0. Overrides the "
758 "diameter-scaled Cd_inf when set. Use inlet_radius_ratio instead for a "
759 "continuous r/d."
760 ),
761 )
762 inlet_radius_ratio: Optional[float] = Field(
763 default=None,
764 ge=0.0,
765 le=1.0,
766 description=(
767 "Inlet fillet radius / orifice diameter (r/d). Continuous alternative to "
768 "inlet_geometry: 0 = sharp (Cd 0.80 short tube), 0.125 = Cd 0.88, >= 0.2 = Cd 0.95 and "
769 "saturating. Nurick (1976): cavitation inception margin also rises with inlet "
770 "roundness, so this buys flow AND cavitation headroom."
771 ),
772 )
773 orifice_l_over_d: Optional[float] = Field(
774 default=None,
775 gt=0.0,
776 description=(
777 "Orifice length / diameter. Lichtarowicz et al. (1965): Cd peaks near L/d ~ 2 "
778 "where expansion past the vena contracta recovers dynamic pressure, falls below "
779 "~1 (thin-plate, no recovery) and above ~10 (wall friction). Only applied when "
780 "an inlet geometry is set. None => no length correction. With "
781 "l_over_d_source='plate' this is the LAND the counterbore leaves, and the L/d the "
782 "Cd model uses is what the plate actually gives."
783 ),
784 )
785 l_over_d_source: Literal["declared", "plate"] = Field(
786 default="declared",
787 description=(
788 "Where the orifice L/d comes from. 'declared' (default, previous behaviour): "
789 "orifice_l_over_d as typed. 'plate': derived from the injector plate -- "
790 "layer1_injector_plate_thickness_m and layer1_injector_counterbore_dia_m -- for the "
791 "current hole: with a counterbore the land is min(orifice_l_over_d * d, t/cos theta); "
792 "without one the small drill runs the whole passage, L/d = t / (d cos theta). Also "
793 "applies the counterbore's velocity of approach and entrance loss (approach_beta)."
794 ),
795 )
796 length_model: Literal["piecewise", "lichtarowicz"] = Field(
797 default="lichtarowicz",
798 description=(
799 "Cd vs L/d. 'lichtarowicz' (default): the published sharp-inlet fit Cd_u = 0.827 - "
800 "0.0085 L/d (Lichtarowicz, Duggins & Markland 1965, valid 2 <= L/d <= 10), "
801 "normalised so the inlet table's short-tube value sits at L/d 3.18. 'piecewise' "
802 "(legacy, unsourced): flat over L/d 2-5, linear roll-off above."
803 ),
804 )
805 approach_beta: Optional[float] = Field(
806 default=None, gt=0.0, lt=1.0,
807 description=(
808 "Orifice / feed-passage diameter ratio d/D when the orifice is fed from a "
809 "counterbore, not a plenum. Applies the velocity of approach and a sharp entrance "
810 "(K = 0.5, Idelchik) into the counterbore: Cd_eff = 1/sqrt((1-b^4)/Cd^2 + "
811 "b^4 (1+K)). Set by l_over_d_source='plate'; None => fed from a plenum."
812 ),
813 )
814 d_min_m: float = Field(
815 default=0.0004,
816 gt=0,
817 description="Minimum diameter used in Cd_geom [m] (below this, clamp to d_min_m).",
818 )
819 # Pressure and temperature dependence (optional)
820 use_pressure_correction: bool = Field(default=False, description="Enable pressure-dependent Cd (compressibility effects)")
821 P_ref: float = Field(default=5.0e6, gt=0, description="Reference pressure for pressure correction [Pa]")
822 a_P: float = Field(default=0.0, description="Pressure correction coefficient")
823 use_temperature_correction: bool = Field(default=False, description="Enable temperature-dependent Cd (viscosity effects)")
824 T_ref: float = Field(default=300.0, gt=0, description="Reference temperature for temperature correction [K]")
825 a_T: float = Field(default=0.0, description="Temperature correction coefficient")
826
827
828class SprayAngleConfig(BaseModel):
829 """Spray angle model configuration"""
830 model: Literal["J", "TMR"] = Field(default="TMR", description="Model type")
831 k: float = Field(default=0.5, gt=0, description="J model coefficient")
832 n: float = Field(default=0.5, gt=0, description="J model exponent")
833
834
835class SMDConfig(BaseModel):
836 """Sauter Mean Diameter configuration"""
837 model: Literal["lefebvre", "nukiyama_tanasawa", "ingebo"] = Field(
838 default="lefebvre",
839 description=(
840 "SMD model type. Impinging doublets always use NACA TN 4222 (Ingebo 1958) per stream; "
841 "'lefebvre' is the legacy We^-m·(1+Oh)^p form used by the pintle path."
842 ),
843 )
844 C: float = Field(default=0.5, gt=0, description="Lefebvre constant C")
845 m: float = Field(default=0.6, gt=0, description="Lefebvre exponent m")
846 p: float = Field(default=0.0, description="Lefebvre exponent p (viscous term (1+Oh)^p)")
847 C_ingebo: Optional[float] = Field(
848 default=None,
849 description="[DEPRECATED — no effect] Prefactor of the former SMD function. Impinging D32 is NACA TN 4222; calibrate with smd_scale.",
850 )
851 d32_measured_O: Optional[float] = Field(default=None, gt=0,
852 description="Measured oxidizer D32 [m]; set from measurements.d32_O_um. Held at this value.")
853 d32_measured_F: Optional[float] = Field(default=None, gt=0,
854 description="Measured fuel D32 [m]; set from measurements.d32_F_um. Held at this value.")
855 smd_scale: float = Field(
856 default=1.0,
857 gt=0,
858 description=(
859 "Multiplier on the impinging-jet D32 of NACA TN 4222 (Ingebo 1958). 1.0 = the "
860 "correlation; set it from a measured D32 (PDPA / laser diffraction cold flow)."
861 ),
862 )
863 smd_property_scaling: bool = Field(
864 default=True,
865 description=(
866 "Carry TN 4222 (n-heptane) to the actual liquid and gas with Ingebo's TN 4087 exponents: "
867 "D ~ (sigma mu_l / rho_l)^(1/4) rho_gas^(-1/4). Off = heptane in air as measured."
868 ),
869 )
870 smd_property_transfer: Literal["tn4087", "dombrowski_johns", "none"] = Field(
871 default="tn4087",
872 description=(
873 "How TN 4222's n-heptane/air D32 is carried to the real liquid and gas (used when "
874 "smd_property_scaling is on; spray.tn4222_property_factor). 'tn4087': Ingebo's crossflow "
875 "exponents, (sigma mu_l/rho_l)^(1/4) rho_g^(-1/4). 'dombrowski_johns': inviscid "
876 "sheet-breakup (Dombrowski & Johns 1963), (sigma^2/(rho_g rho_l))^(1/6). 'none': heptane "
877 "in air. An unverified transfer either way: on the 6.5 kN ethalox doublet the three "
878 "span eta_vap 0.961-0.989 (injector audit 2026-09-28). Measure D32 and set smd_scale."
879 ),
880 )
881 chamber_gas_R: float = Field(
882 default=360.0,
883 gt=0,
884 description=(
885 "Representative specific gas constant [J/(kg·K)] of the combustion gas, used only to "
886 "estimate the chamber gas density (rho_g = Pc/(R·T)) for the aerodynamic Weber number "
887 "in the Ingebo correlation. The injector solve runs before CEA chamber state is "
888 "available, so this is a configured representative value (LOX/CH4 ≈ 360)."
889 ),
890 )
891 chamber_gas_T: float = Field(
892 default=3500.0,
893 gt=0,
894 description=(
895 "Representative combustion-gas temperature [K] for the chamber gas density estimate "
896 "rho_g = Pc/(R·T) used in the Ingebo aerodynamic Weber number (LOX/CH4 ≈ 3500)."
897 ),
898 )
899 we_corr_max: Optional[float] = Field(
900 default=None,
901 gt=0.0,
902 description=(
903 "Optional cap applied to the liquid Weber number **only** when evaluating the "
904 "Lefebvre-style ``smd_lefebvre`` correlation (D32). Full We is still used for "
905 "``check_spray_constraints``. Jet-like impingement can produce enormous We with "
906 "liquid ρ and mm jets, which drives D32 to sub-micron values outside the correlation's "
907 "useful regime; capping We_corr is a pragmatic surrogate for secondary breakup / "
908 "turbulence limits. Default None = no cap."
909 ),
910 )
911
912
913class EvaporationConfig(BaseModel):
914 """Evaporation model configuration"""
915 model: Literal["derived", "constant"] = Field(
916 default="derived",
917 description=(
918 "'derived' computes the d²-law constant from propellant properties and chamber "
919 "state: k_evap = C·(8·ρ_g·D_v/ρ_l)·ln(1+B_M), so it responds to fuel volatility, "
920 "chamber temperature and pressure. 'constant' uses the legacy fixed K, which was "
921 "identical for ethanol, methane and RP-1 and did not move with Pc or Tc at all."
922 ),
923 )
924 C_evap: float = Field(
925 default=1.562,
926 gt=0,
927 description=(
928 "Calibration constant for the derived evaporation model. Anchored so ethanol at "
929 "3094 K / 450 psi reproduces k_evap = 3.33e-6 m²/s — the value the legacy fixed K "
930 "implied, and within 10% of a T² extrapolation of measured ethanol droplet data."
931 ),
932 )
933 cp_gas: float = Field(
934 default=2200.0,
935 gt=0,
936 description="Representative combustion-gas cp [J/(kg·K)] for the Spalding number B_M.",
937 )
938 apply_tau_res_correction: bool = Field(
939 default=False,
940 description=(
941 "[DEPRECATED — no effect on c*] The vaporization march (combustion_physics) follows "
942 "the drops from the face and integrates their evaporation, so taking the spray length "
943 "off the stay time would count it twice. Still read by the numba kernel until it is "
944 "mirrored."
945 ),
946 )
947 K: float = Field(
948 default=3e5,
949 gt=0,
950 description=(
951 "LEGACY fixed evaporation constant [s/m²] — note this is the RECIPROCAL of the "
952 "textbook d²-law constant (k_evap = 1/K, so 3e5 ⇒ 3.33e-6 m²/s). Used only when "
953 "model='constant', or as a fallback when a fluid lacks latent_heat/boiling_point."
954 ),
955 )
956 x_star_limit: float = Field(default=0.05, gt=0, description="Max evaporation length [m]")
957 use_constraint: bool = Field(default=True, description="Enable x* constraint")
958
959
960
961class PintleSprayConfig(BaseModel):
962 """Pintle-specific spray correlation parameters"""
963 C: float = Field(default=15.0, gt=0, description="Correlation coefficient C")
964 B: float = Field(default=2.0, ge=0, description="Ohnesorge multiplier B")
965 n: float = Field(default=0.5, gt=0, description="Weber exponent n")
966 p: float = Field(default=0.2, ge=0, description="Ohnesorge exponent p")
967
968
969class SprayConfig(BaseModel):
970 """Spray/mixing model configuration"""
971 momentum_flux_ratio: bool = Field(default=True, description="Enable J calculation")
972 spray_angle: SprayAngleConfig = Field(default_factory=SprayAngleConfig)
973 weber: dict = Field(default_factory=lambda: {"We_min": 15.0})
974 smd: SMDConfig = Field(default_factory=SMDConfig)
975 pintle: PintleSprayConfig = Field(default_factory=PintleSprayConfig)
976 evaporation: EvaporationConfig = Field(default_factory=EvaporationConfig)
977 use_turbulence_corrections: bool = Field(default=False, description="Enable turbulence-dependent spray corrections")
978 turbulence_breakup_gain: float = Field(default=1.0, ge=0, description="Gain applied to droplet breakup due to turbulence")
979 turbulence_penetration_gain: float = Field(default=0.5, ge=0, description="Gain applied to evaporation length reduction due to turbulence")
980
981
982class CEAConfig(BaseModel):
983 use_parallel_cea_build: bool = Field(default=True, description="Use parallel processing for CEA cache building")
984 cea_parallel_workers: Optional[int] = Field(default=None, description="Number of parallel workers (None = auto-detect, limited to 8)")
985 """CEA (Chemical Equilibrium Analysis) configuration"""
986 ox_name: str = Field(default="LOX", description="Oxidizer name")
987 fuel_name: str = Field(default="RP-1", description="Fuel name")
988 expansion_ratio: float = Field(gt=1, description="Nozzle expansion ratio (initial/default value)")
989 cache_file: str = Field(default="cea_cache_LOX_RP1.npz", description="Cache filename")
990 Pc_range: List[float] = Field(
991 default=[2.0e6, 9.0e6],
992 description="Chamber pressure range [Pa]"
993 )
994 MR_range: List[float] = Field(
995 default=[2.0, 2.8],
996 description="Mixture ratio range"
997 )
998 eps_range: Optional[List[float]] = Field(
999 default=None,
1000 description="Expansion ratio range for 3D cache [min, max]. If None, uses 2D cache with fixed expansion_ratio"
1001 )
1002 n_points: int = Field(default=34, gt=0, description="Number of grid points per dimension (34³ ≈ 39,300 points for 3D cache, similar to old 2D cache with ~40k points)")
1003
1004
1005class CombustionEfficiencyConfig(BaseModel):
1006 """c* efficiency: eta_c* = eta_vap * eta_mix * eta_HL (engine/pipeline/combustion_physics.py,
1007 combustion_eff.py). Chemical kinetics carry no chamber c* loss."""
1008 model: Literal["constant", "linear", "exponential"] = Field(
1009 default="exponential",
1010 description="Vaporization term. exponential (legacy name): the Priem-Heidmann spray march, "
1011 "eta_vap = fraction vaporized at the throat. constant: eta_vap = 1 - C. "
1012 "linear: eta_vap = 1 - C (1 - L*/1 m)."
1013 )
1014 C: float = Field(default=0.3, ge=0, le=1, description="Vaporization loss for the constant and linear models only.")
1015 K: float = Field(default=0.15, ge=0, description="[DEPRECATED — no effect] Read by no model.")
1016 use_spray_correction: bool = Field(default=False, description="[DEPRECATED — no effect] Read by no model.")
1017 spray_penalty_factor: float = Field(default=0.8, ge=0, le=1, description="[DEPRECATED — no effect] Read by no model.")
1018 use_mixture_coupling: bool = Field(
1019 default=False,
1020 description="[DIAGNOSTICS ONLY] Enable mixture diagnostics logging (does NOT affect efficiency calculation)"
1021 )
1022 use_cooling_coupling: bool = Field(
1023 default=True,
1024 description="Charge heat lost through the chamber wall to c*: eta_HL = sqrt(1 - Q/(mdot cp Tc)) "
1025 "(c* ~ sqrt(T0), Sutton eq. 3-32). Regenerative and film heat stay with the propellant."
1026 )
1027 use_turbulence_coupling: bool = Field(
1028 default=False,
1029 description="[DEPRECATED] Turbulence is folded into eta_mixing; the standalone eta_turbulence penalty was removed (non-physical/double-counted)."
1030 )
1031 # --- Mixing: eta_mix = sum_i w_i c*(O/F_i) / c*(O/F) over stream tubes whose O/F spread
1032 # reproduces Rupe's mixing factor E_m (combustion_physics.stream_tube_mixing_efficiency).
1033 # M = rho_O v_O^2 d_O / (rho_F v_F^2 d_F), Elverum & Morey (JPL Memo 30-5) eq. 1.
1034 Em_peak: float = Field(
1035 default=0.96, ge=0.5, le=1.0,
1036 description="[DEPRECATED — no effect] Was an assumed c* mixing efficiency; E_m is not a c* "
1037 "efficiency. Mixing is now rupe_Em_opt through the stream-tube c* integral."
1038 )
1039 mixing_sigma: float = Field(
1040 default=1.5, gt=0.0,
1041 description="[DEPRECATED — no effect] Width of the retired log-Gaussian mixing falloff; the "
1042 "falloff is Rupe's correlation (rupe_Em_curvature)."
1043 )
1044 rupe_Em_opt: float = Field(
1045 default=0.80, gt=0.0, le=1.0,
1046 description="Rupe mixing factor E_m (cold-flow mixture-ratio uniformity, 0-1; JPL TR 32-1546 "
1047 "eq. 1) of the element at its optimum M. Circular 1-on-1 unlike doublets with "
1048 "fully developed jets: 0.75-0.85 (Rupe, as summarized in Hoehn, Rupe & Sotter, "
1049 "JPL TR 32-1546, 1972, p. 1); Rupe's correlation peaks at 0.82 and JPL's "
1050 "noncavitating orifices at 0.815 (ibid. p. 10); short or cavitating orifices "
1051 "0.70 (Nurick & McHale, ibid.). 0.80 is the middle of the band: replace it with "
1052 "a cold-flow measurement of the element."
1053 )
1054 rupe_Em_curvature: float = Field(
1055 default=6.07, ge=0.0,
1056 description="Fall-off of E_m away from the optimum, E_m = rupe_Em_opt (1 - a (N_R - N_opt)^2), "
1057 "N_R = M/(1+M): a = 6.07 fitted to Rupe's mean line digitized from JPL TR 32-1546 "
1058 "Fig. 1 (N_R 0.26-0.78, within 2.4 points; data scatter +-5)."
1059 )
1060 mixing_distribution: Literal["gaussian", "two_tube"] = Field(
1061 default="gaussian",
1062 description="Shape of the O/F spread that reproduces E_m in the stream-tube c* integral: "
1063 "normal in oxidizer mass fraction (default) or two equal-mass tubes. The other "
1064 "shape's eta_mix is reported beside it; at E_m 0.8 on LOX/ethanol they differ "
1065 "by ~1.3 points (two tubes lose less)."
1066 )
1067 droplet_blowing_model: Literal["abramzon_sirignano", "none"] = Field(
1068 default="abramzon_sirignano",
1069 description="Stefan-flow corrections in the vaporization march while a drop evaporates: "
1070 "Nu* = 2 + (Nu0 - 2)/F(B) (Abramzon & Sirignano 1989) and C_D (1+B)^-0.2 "
1071 "(Yuen & Chen 1976). 'none' = uncorrected Ranz-Marshall and solid-sphere drag, "
1072 "which do not hold at the transfer numbers here (B_T 16-80)."
1073 )
1074 rupe_M_opt: float = Field(
1075 default=1.0, gt=0.0,
1076 description="Elverum-Morey mixing parameter at which mixing is best: 1.0 for a 1-on-1 unlike "
1077 "doublet (SP-8089 Table IV; 2-on-1 1.6, 3-on-1 3.5)."
1078 )
1079 R_opt: Optional[float] = Field(
1080 default=None,
1081 description="[DEPRECATED — no effect] The mixing optimum is rupe_M_opt; the resultant-tilt "
1082 "momentum ratio no longer enters c*."
1083 )
1084 spray_size_spread_q: float = Field(
1085 default=3.0, gt=1.0,
1086 description="ASSUMED Rosin-Rammler spread q of each stream's drop sizes (volume basis, "
1087 "X = D32 Gamma(1 - 1/q)) in the vaporization march. Lefebvre: 1.5-4 for most "
1088 "sprays; larger q is a narrower spray."
1089 )
1090 mixture_efficiency_floor: float = Field(default=0.25, ge=0, le=1, description="[DEPRECATED] No longer used")
1091 cooling_efficiency_floor: float = Field(
1092 default=0.25, ge=0, le=1,
1093 description="Floor on the chamber solver's legacy cooling_efficiency diagnostic only; the c* "
1094 "heat-loss factor has none."
1095 )
1096 turbulence_efficiency_floor: float = Field(default=0.3, ge=0, le=1, description="[DEPRECATED] No longer used")
1097 target_turbulence_intensity: Optional[float] = Field(
1098 default=None,
1099 description="[DEPRECATED] Design target only, not used in efficiency calculation"
1100 )
1101 turbulence_penalty_exponent: Optional[float] = Field(
1102 default=None,
1103 description="[DEPRECATED] No longer used"
1104 )
1105 target_smd_microns: Optional[float] = Field(
1106 default=None,
1107 description="[DEPRECATED] Design target only, not used in efficiency calculation"
1108 )
1109 xstar_limit_mm: Optional[float] = Field(
1110 default=None,
1111 description="[DEPRECATED] No longer used"
1112 )
1113 xstar_penalty_exponent: Optional[float] = Field(
1114 default=None,
1115 description="[DEPRECATED] No longer used"
1116 )
1117 we_reference: Optional[float] = Field(
1118 default=None,
1119 description="[DEPRECATED] Design target only, not used in efficiency calculation"
1120 )
1121 we_penalty_exponent: Optional[float] = Field(
1122 default=None,
1123 description="[DEPRECATED] No longer used"
1124 )
1125 smd_penalty_exponent: Optional[float] = Field(
1126 default=None,
1127 description="[DEPRECATED] No longer used"
1128 )
1129 use_advanced_model: bool = Field(
1130 default=False,
1131 description="Lets the numba accelerator run the chamber solve (engine/accel can_handle_chamber). "
1132 "The physics is the same either way."
1133 )
1134 Pc_gate: float = Field(
1135 default=1000000.0,
1136 ge=0,
1137 description="[DEPRECATED — no effect] Read by no model."
1138 )
1139 use_finite_rate_chemistry: bool = Field(
1140 default=True,
1141 description="[DEPRECATED — no effect] Chamber kinetics carry no c* loss (products relax in "
1142 "~1 us against a ~1 ms stay time); nozzle kinetics are bracketed by CEA's Cf."
1143 )
1144 use_shifting_equilibrium: bool = Field(
1145 default=True,
1146 description="[DEPRECATED — no effect] The iterative shifting-equilibrium nozzle was retired; "
1147 "exit-composition shift is captured exactly by CEA's Cf_vac table "
1148 "(RPA delivered thrust). Kept for config compatibility only."
1149 )
1150 # Retired RP-1 kinetics surrogate (tau_chem switched on O/F plateaus); read by no model.
1151 tau_ref: float = Field(default=1e-5, gt=0, description="[DEPRECATED — no effect] Retired kinetics surrogate [s].")
1152 tau_ref_P: float = Field(default=4.0e6, gt=0, description="[DEPRECATED — no effect] Retired kinetics surrogate [Pa].")
1153 tau_ref_T: float = Field(default=3500.0, gt=0, description="[DEPRECATED — no effect] Retired kinetics surrogate [K].")
1154 n_pressure: float = Field(default=0.8, ge=0, description="[DEPRECATED — no effect] Retired kinetics surrogate.")
1155 tau_Tc_floor_K: Optional[float] = Field(default=None, description="[DEPRECATED — no effect] Retired kinetics surrogate [K].")
1156 T_star_fuel_cap_K: float = Field(
1157 default=1000.0,
1158 gt=0,
1159 description="[DEPRECATED — no effect] Drops now evaporate at their saturation temperature at Pc "
1160 "(Clausius-Clapeyron from fluids.*.boiling_point) [K]."
1161 )
1162 # Arrhenius kinetics parameters (fuel-specific, can be overridden per fuel type)
1163 A0_hydrocarbon: float = Field(
1164 default=1e7,
1165 gt=0,
1166 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Pre-exponential factor for hydrocarbon fuels (RP-1, Kerosene) [1/s]. Default 1e7."
1167 )
1168 Ea_hydrocarbon: float = Field(
1169 default=80000.0,
1170 gt=0,
1171 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Activation energy for hydrocarbon fuels (RP-1, Kerosene) [J/mol]. Default 80 kJ/mol."
1172 )
1173 n_pre_hydrocarbon: float = Field(
1174 default=0.3,
1175 ge=0,
1176 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Pre-exponential pressure exponent for hydrocarbons. Default 0.3."
1177 )
1178 A0_ethanol: float = Field(
1179 default=5e7,
1180 gt=0,
1181 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Pre-exponential factor for ethanol [1/s]. Default 5e7 (faster than RP-1)."
1182 )
1183 Ea_ethanol: float = Field(
1184 default=140000.0,
1185 gt=0,
1186 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Activation energy for ethanol [J/mol]. Default 140 kJ/mol (higher than RP-1)."
1187 )
1188 n_pre_ethanol: float = Field(
1189 default=0.25,
1190 ge=0,
1191 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Pre-exponential pressure exponent for ethanol. Default 0.25."
1192 )
1193 A0_hydrogen: float = Field(
1194 default=1e9,
1195 gt=0,
1196 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Pre-exponential factor for hydrogen [1/s]. Default 1e9 (much faster than hydrocarbons)."
1197 )
1198 Ea_hydrogen: float = Field(
1199 default=40000.0,
1200 gt=0,
1201 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Activation energy for hydrogen [J/mol]. Default 40 kJ/mol (lower than hydrocarbons)."
1202 )
1203 n_pre_hydrogen: float = Field(
1204 default=0.2,
1205 ge=0,
1206 description="[Progress diagnostic only: reaction_chemistry, time-varying solver; no effect on c*] Pre-exponential pressure exponent for hydrogen. Default 0.2."
1207 )
1208
1209
1210class CombustionConfig(BaseModel):
1211 """Combustion configuration"""
1212 cea: CEAConfig = Field(default_factory=CEAConfig)
1213 efficiency: CombustionEfficiencyConfig = Field(default_factory=CombustionEfficiencyConfig)
1214
1215
1216class StabilityConfig(BaseModel):
1217 """Inputs to the combustion / feed stability model that belong to neither the propellant
1218 (``fluids``) nor the plumbing (``feed_system``): the combustion-response calibration, the
1219 nozzle-entrance Mach the acoustic damping uses, the acoustic damping coefficients, and the
1220 dome-regulator dynamics. A ``None`` here means "derive it" and the derivation is recorded in
1221 the assumptions registry (rich report -> assumptions.fallbacks_used), never substituted silently.
1222 """
1223 n_interaction: float = Field(
1224 default=0.5, gt=0,
1225 description="Crocco interaction index n (calibration range 0.3-0.6). The forward-mode slider overrides it per run.",
1226 )
1227 chi_acoustic: float = Field(
1228 default=0.15, gt=0, le=1,
1229 description="Sensitive-lag fraction chi: tau_sens = chi * tau_vap for the acoustic n-tau driving.",
1230 )
1231 mach_nozzle_entrance: Optional[float] = Field(
1232 default=None, gt=0, lt=1,
1233 description="Mean Mach at the nozzle entrance (sets nozzle damping). None = solve it from the contraction ratio (isentropic, subsonic).",
1234 )
1235 damping_injector_frac: float = Field(
1236 default=0.02, ge=0,
1237 description="Injector-face acoustic damping as a fraction of pi*f [-]. ASSUMED, uncalibrated (no source); calibrate against a cold ring-down test (T7).",
1238 )
1239 damping_twophase_frac: float = Field(
1240 default=0.03, ge=0,
1241 description="Two-phase (droplet) acoustic damping as a fraction of pi*f*droplet_loading [-]. ASSUMED, uncalibrated (no source).",
1242 )
1243 droplet_loading: float = Field(
1244 default=1.0, ge=0,
1245 description="Relative liquid loading near the injector face for the two-phase damping term [-].",
1246 )
1247 acoustic_gate_alpha_offset: float = Field(
1248 default=350.0, ge=0,
1249 description=(
1250 "UNUSED. Was an allowance that let a mode growing at up to this rate [1/s] pass the old "
1251 "tanh-remapped acoustic gate. The acoustic margin is now damping/driving (1 = neutral) and "
1252 "stability.acoustic_gate decides whether it gates. Kept so older configs still load."
1253 ),
1254 )
1255 acoustic_gate: Literal["report_only", "nominal_phase", "worst_phase"] = Field(
1256 default="report_only",
1257 description=(
1258 "Which acoustic (HF) margin reaches the stability gate. 'report_only': none -- the modes, "
1259 "damping budget and margins are reported but gate nothing, because the injector-face and "
1260 "two-phase damping are uncalibrated and omega*tau_sens spans many periods of 2*pi (Harrje & "
1261 "Reardon SP-194 rate HF stability by test). 'nominal_phase': damping/driving at the model's "
1262 "tau_sens. 'worst_phase': damping/driving at omega*tau = pi (Crocco's n_min/n)."
1263 ),
1264 )
1265 time_lag_model: Literal["leonardi_dtl", "d2_law"] = Field(
1266 default="leonardi_dtl",
1267 description=(
1268 "Named model for the injection->heat-release conversion lag that drives the chug loop. "
1269 "'leonardi_dtl' is the double-time-lag decomposition tau_atom + tau_vap + tau_mix of "
1270 "Leonardi et al., Acta Astronautica 139 (2017); 'd2_law' is STAR's historical quiescent "
1271 "droplet lifetime, kept for reproducing older results. See "
1272 "scripts/chug_timelag_benchmark.py for the measurement that chose the default."
1273 ),
1274 )
1275 convection_model: Literal["none", "leonardi_eq8", "ranz_marshall"] = Field(
1276 default="none",
1277 description=(
1278 "Convective speed-up applied to the droplet lifetime. Default 'none': the Leonardi eq. 9 "
1279 "evaporation constant is already calibrated against real chamber data, and applying "
1280 "eq. 8 on top of it triples the chug-frequency error against the reference experiment."
1281 ),
1282 )
1283 mixing_lag_fraction: float = Field(
1284 default=0.5, ge=0.0, le=3.0,
1285 description=(
1286 "Mixing lag as a fraction of the rate-limiting vaporization lag, shared by every stream "
1287 "(a gaseous propellant carries this and nothing else). Default 0.5 is Leonardi's own "
1288 "calibration for the validation engine: tau_mix = 2.2 ms at tau_vap = 4.4 ms. Set 0 to "
1289 "drop the mixing lag entirely."
1290 ),
1291 )
1292 regulator_enabled: bool = Field(default=True, description="Model the dome regulator upstream of each tank in the chug loop.")
1293 regulator_corner_hz: float = Field(default=3.0, gt=0, description="Regulator response corner frequency [Hz].")
1294 chug_band_mixing_lag_fraction_min: Optional[float] = Field(
1295 default=0.0, ge=0.0, le=3.0,
1296 description=(
1297 "Low end of the mixing-lag fraction band the chug gate must hold over. The fraction's only "
1298 "source is Leonardi 2017's GH2/LOX coaxial rig (0.5); nothing measures it for this "
1299 "propellant or injector, so the gate takes the lowest gain margin over [min, max]. Set both "
1300 "ends null to gate on mixing_lag_fraction alone once it is measured. leonardi_dtl only."
1301 ),
1302 )
1303 chug_band_mixing_lag_fraction_max: Optional[float] = Field(
1304 default=1.0, ge=0.0, le=3.0,
1305 description="High end of the chug gate's mixing-lag fraction band (see the _min field).",
1306 )
1307 regulator_Z_hf: float = Field(
1308 default=0.0, ge=0,
1309 description=(
1310 "Regulator high-frequency series impedance [Pa*s/kg]. 0 = regulator NOT MODELLED: the feed "
1311 "sees an ideal pressure source, and the report says so instead of printing a with/without "
1312 "pair. Set it only from a measured step response (T6)."
1313 ),
1314 )
1315 regulator_max_excursion_psi: float = Field(
1316 default=0.0, ge=0,
1317 description="Regulator outlet pressure excursion bound [psi]. Reporting only; not a pole-shifter.",
1318 )
1319
1320
1321class ChamberGeometryConfig(BaseModel):
1322 """
1323 Unified chamber geometry configuration for solve_chamber_geometry_with_cea.
1324 Groups all design inputs (chamber + nozzle) in one place.
1325 """
1326 # Design requirements
1327 design_pressure: float = Field(gt=0, description="Target chamber pressure Pc [Pa]")
1328 design_thrust: float = Field(gt=0, description="Target thrust F [N]")
1329 design_MR: float = Field(gt=0, description="Design mixture ratio O/F")
1330
1331 # Chamber dimensions
1332 chamber_diameter: float = Field(gt=0, description="Inner chamber diameter [m]")
1333 Lstar: float = Field(gt=0, description="Characteristic length [m] (0.95-1.27 for LOX/RP-1)")
1334
1335 # Nozzle dimensions
1336 exit_diameter: float = Field(gt=0, description="Nozzle exit diameter [m]")
1337 expansion_ratio: float = Field(gt=1, description="Area ratio A_exit/A_throat")
1338 nozzle_efficiency: float = Field(default=0.95, ge=0, le=1, description="Nozzle efficiency (0.94-0.98)")
1339
1340 # Solver outputs (populated after running solver)
1341 A_throat: Optional[float] = Field(default=None, gt=0, description="Throat area [m²] - SOLVED")
1342 A_exit: Optional[float] = Field(default=None, gt=0, description="Exit area [m²] - SOLVED")
1343 volume: Optional[float] = Field(default=None, gt=0, description="Chamber volume [m³] - SOLVED")
1344 length: Optional[float] = Field(default=None, gt=0, description="Total chamber length [m] - SOLVED")
1345 length_cylindrical: Optional[float] = Field(default=None, gt=0, description="Cylindrical section length [m] - SOLVED")
1346 length_contraction: Optional[float] = Field(default=None, gt=0, description="Contraction section length [m] - SOLVED")
1347 Cf: Optional[float] = Field(default=None, gt=0, description="Thrust coefficient - SOLVED")
1348
1349
1350class ChamberConfig(BaseModel):
1351 """Chamber geometry configuration"""
1352 volume: float = Field(gt=0, description="Chamber volume [m³]")
1353 A_throat: float = Field(gt=0, description="Throat area [m²]")
1354 length: Optional[float] = Field(default=None, gt=0, description="Total chamber length [m]")
1355 length_cylindrical: Optional[float] = Field(default=None, gt=0, description="Cylindrical section length [m]")
1356 length_contraction: Optional[float] = Field(default=None, gt=0, description="Contraction section length [m]")
1357 Lstar: Optional[float] = Field(
1358 default=None,
1359 gt=0,
1360 description="Characteristic length [m] = V_chamber / A_throat. If not specified, calculated from volume and A_throat."
1361 )
1362 chamber_inner_diameter: Optional[float] = Field(default=None, gt=0, description="Chamber inner diameter [m]")
1363 exit_diameter: Optional[float] = Field(default=None, gt=0, description="Nozzle exit diameter [m] for geometry solver")
1364
1365 # Design parameters for solve_chamber_geometry_with_cea
1366 design_pressure: Optional[float] = Field(default=None, gt=0, description="Design chamber pressure [Pa] (Pc_design)")
1367 design_thrust: Optional[float] = Field(default=None, gt=0, description="Design thrust [N] (F_design)")
1368 design_MR: Optional[float] = Field(default=None, gt=0, description="Design mixture ratio O/F for CEA solver")
1369 design_force_coefficient: Optional[float] = Field(default=None, gt=0, description="Solved thrust coefficient Cf (output from solver)")
1370
1371
1372class NozzleConfig(BaseModel):
1373 """Nozzle configuration"""
1374 A_throat: float = Field(gt=0, description="Throat area [m²]")
1375 A_exit: float = Field(gt=0, description="Exit area [m²]")
1376 expansion_ratio: float = Field(gt=1, description="Expansion ratio (A_exit/A_throat)")
1377 exit_diameter: Optional[float] = Field(default=None, gt=0, description="Nozzle exit diameter [m]")
1378 efficiency: float = Field(default=0.98, ge=0, le=1, description="Nozzle efficiency")
1379
1380
1381class ClosureConfig(BaseModel):
1382 """Closure iteration configuration"""
1383 max_iterations: int = Field(default=6, gt=0, description="Max closure iterations")
1384 Cd_reduction_factor: float = Field(
1385 default=1.0,
1386 ge=0,
1387 le=1,
1388 description=(
1389 "Multiplier applied to the injector Cd each time the spray constraints are "
1390 "violated. 1.0 = OFF, which is the default and the physical answer. "
1391 "WAS 0.95, and that is not a discharge coefficient -- a drilled orifice does "
1392 "not flow less because its spray is long. Measured on the 8 kN ethalox point: "
1393 "the loop shrank Cd from 0.6017 to 0.4658 (0.6055 * 0.95^5, exactly), a 23% "
1394 "error that propagates straight into orifice sizing, and at a realistic Cd the "
1395 "same hardware then makes +11.9% thrust with dP/Pc falling to 0.164, under the "
1396 "0.20 chug floor. Worse, the feedback has the WRONG SIGN: lower Cd means lower "
1397 "jet velocity, larger SMD and a LONGER evaporation length, so x* went from "
1398 "0.1256 m to 0.1338 m while the limit it was chasing is 0.05 m. It never "
1399 "converged -- it just exhausted max_iterations. An x* violation is an "
1400 "infeasibility to report (it is in diagnostics as x_star), not something to "
1401 "fudge the discharge coefficient for. Set below 1.0 only to reproduce a legacy run."
1402 ),
1403 )
1404 tolerance: float = Field(default=1e-4, gt=0, description="Convergence tolerance")
1405
1406
1407class SolverConfig(BaseModel):
1408 """Solver configuration"""
1409 method: Literal["brentq", "secant", "newton"] = Field(
1410 default="brentq",
1411 description="Root finding method"
1412 )
1413 Pc_bounds: List[float] = Field(
1414 default=[100000.0, 8000000.0],
1415 description="Chamber pressure bounds [Pa]"
1416 )
1417 tolerance: float = Field(default=1e-6, gt=0, description="Root finding tolerance")
1418 max_iterations: int = Field(default=100, gt=0, description="Max iterations")
1419 closure: ClosureConfig = Field(default_factory=ClosureConfig)
1420
1421
1422InjectorConfig = Union[PintleInjectorConfig, CoaxialInjectorConfig, ImpingingInjectorConfig]
1423
1424
1425# Flight simulation configuration classes
1426class LOXTankConfig(BaseModel):
1427 """LOX tank geometry configuration for flight simulation"""
1428 lox_h: float = Field(gt=0, description="LOX tank height (internal cylindrical length, not including end caps) [m]")
1429 lox_radius: float = Field(gt=0, description="LOX tank internal radius [m]")
1430 ox_tank_pos: float = Field(description="LOX tank center position relative to nozzle exit (positive = above nozzle) [m]")
1431 mass: Optional[float] = Field(default=None, gt=0, description="Initial LOX PROPELLANT mass [kg] (liquid only, not tank structure). Depletes during burn.")
1432 initial_pressure_psi: Optional[float] = Field(default=None, gt=0, description="Initial LOX tank pressure [psi]")
1433 tank_volume_m3: Optional[float] = Field(default=None, gt=0, description="LOX tank volume [m³]. If not provided, will be calculated from lox_h and lox_radius using π×r²×h")
1434 # The T-0 ullage: V_tank - m/rho_liquid of gas at initial_pressure_psi (absolute, as the engine
1435 # solver reads it) and this temperature, from CoolProp. Ground pre-pressurisation charges it, so
1436 # it is carried on top of press_tank.initial_gas_mass; the COPV then only refills what drains.
1437 ullage_gas: str = Field(default="Nitrogen", description="CoolProp name of the pressurant in the ullage (GN2 system)")
1438 ullage_gas_temperature_K: float = Field(default=293.15, gt=0, description="Ullage gas temperature [K] at T-0 and as the regulator refills it. Default: pressurant at ambient (bottle) temperature, not a measured ullage; gas chilled by LOX is denser.")
1439
1440
1441class FuelTankConfig(BaseModel):
1442 """Fuel tank geometry configuration for flight simulation. The rp1_* field names are legacy; the tank holds whichever fuel the config names."""
1443 rp1_h: float = Field(gt=0, description="Fuel tank height (internal cylindrical length, not including end caps) [m]")
1444 rp1_radius: float = Field(gt=0, description="Fuel tank internal radius [m]")
1445 fuel_tank_pos: float = Field(description="Fuel tank center position relative to nozzle exit (positive = above, negative = below nozzle) [m]")
1446 mass: Optional[float] = Field(default=None, gt=0, description="Initial fuel PROPELLANT mass [kg] (liquid only, not tank structure). Depletes during burn.")
1447 initial_pressure_psi: Optional[float] = Field(default=None, gt=0, description="Initial fuel tank pressure [psi]")
1448 tank_volume_m3: Optional[float] = Field(default=None, gt=0, description="Fuel tank volume [m³]. If not provided, will be calculated from rp1_h and rp1_radius using π×r²×h (field names are legacy)")
1449 # The T-0 ullage: V_tank - m/rho_liquid of gas at initial_pressure_psi (absolute, as the engine
1450 # solver reads it) and this temperature, from CoolProp. Ground pre-pressurisation charges it, so
1451 # it is carried on top of press_tank.initial_gas_mass; the COPV then only refills what drains.
1452 ullage_gas: str = Field(default="Nitrogen", description="CoolProp name of the pressurant in the ullage (GN2 system)")
1453 ullage_gas_temperature_K: float = Field(default=293.15, gt=0, description="Ullage gas temperature [K] at T-0 and as the regulator refills it. Default: pressurant at ambient (bottle) temperature, not a measured ullage; gas chilled by LOX is denser.")
1454
1455
1456class PressTankConfig(BaseModel):
1457 """Pressurant (COPV) tank configuration for flight simulation.
1458
1459 For gaseous nitrogen (GN2) pressurization system.
1460 - dry_mass: COPV tank structure mass (walls, fittings)
1461 - initial_gas_mass: Initial N2 gas mass from COPV sizing
1462 - free_volume_L: COPV free internal volume in liters
1463 """
1464 press_h: float = Field(gt=0, description="Pressurant tank height (internal cylindrical length) [m]")
1465 press_radius: float = Field(gt=0, description="Pressurant tank internal radius [m]")
1466 pres_tank_pos: float = Field(description="Pressurant tank center position relative to nozzle exit (positive = above nozzle) [m]")
1467 dry_mass: Optional[float] = Field(default=None, gt=0, description="COPV tank structure mass (tank walls only, no gas) [kg]")
1468 initial_gas_mass: Optional[float] = Field(default=None, gt=0, description="Initial N2 pressurant gas mass [kg] (from COPV sizing)")
1469 mass: Optional[float] = Field(default=None, gt=0, description="LEGACY: Use initial_gas_mass instead")
1470 free_volume_L: Optional[float] = Field(default=4.5, gt=0, description="COPV free internal volume [L]")
1471
1472
1473class FinsConfig(BaseModel):
1474 """Fins configuration for flight simulation"""
1475 no_fins: int = Field(gt=0, description="Number of fins (typically 3 or 4)")
1476 root_chord: float = Field(gt=0, description="Root chord length (fin edge attached to body) [m]")
1477 tip_chord: float = Field(gt=0, description="Tip chord length (outer fin edge) [m]")
1478 fin_span: float = Field(gt=0, description="Fin span (height from body to fin tip) [m]")
1479 fin_position: float = Field(description="Fin leading edge position from rocket tail (z=0) [m]")
1480
1481
1482class MotorConfig(BaseModel):
1483 """Motor configuration for flight simulation (LEGACY - use propulsion_dry_mass instead)"""
1484 dry_mass: float = Field(gt=0, description="Motor dry mass [kg]")
1485 inertia: List[float] = Field(description="Motor inertia [kg·m²]")
1486
1487
1488class RocketConfig(BaseModel):
1489 """Rocket configuration for flight simulation.
1490
1491 NEW Mass Model (recommended):
1492 - airframe_mass: Rocket body without propulsion (fuselage, fins, nosecone, avionics, payload)
1493 - engine_mass: Engine + ALL plumbing (chamber, nozzle, injector, valves, fittings, lines)
1494 - lox_tank_structure_mass: Empty LOX tank only (walls, no fittings)
1495 - fuel_tank_structure_mass: Empty fuel tank only (walls, no fittings)
1496 - copv_dry_mass: Empty COPV tank only (walls, no pressurant gas)
1497 - engine_cm_offset: Height of engine CM above nozzle exit
1498 - rocket_length: Total rocket length (tail to nose tip) for MoI estimation
1499
1500 propulsion_dry_mass and propulsion_cm_offset are COMPUTED from the above.
1501
1502 Total dry mass = airframe_mass + engine_mass + lox_tank_structure_mass + fuel_tank_structure_mass + copv_dry_mass
1503 Total wet mass = dry mass + lox_tank.mass + fuel_tank.mass (propellants) + press_tank.initial_gas_mass
1504
1505 LEGACY fields (mass, motor.dry_mass) still supported for backward compatibility.
1506 """
1507 # NEW detailed mass model
1508 airframe_mass: Optional[float] = Field(default=None, gt=0, description="Airframe mass (fuselage, fins, nosecone, avionics, payload) - NO propulsion [kg]")
1509 engine_mass: Optional[float] = Field(default=None, gt=0, description="Engine + plumbing mass (chamber, nozzle, injector, valves, ALL fittings & lines) [kg]")
1510 lox_tank_structure_mass: Optional[float] = Field(default=None, gt=0, description="Empty LOX tank only (walls, no fittings) [kg]")
1511 fuel_tank_structure_mass: Optional[float] = Field(default=None, gt=0, description="Empty fuel tank only (walls, no fittings) [kg]")
1512 engine_cm_offset: float = Field(default=0.15, ge=0, description="Height of engine+plumbing CM above nozzle exit [m]. Typical: 0.1-0.3m.")
1513
1514 # COMPUTED fields (calculated from detailed breakdown)
1515 propulsion_dry_mass: Optional[float] = Field(default=None, gt=0, description="COMPUTED: Total propulsion dry mass (engine + tanks) [kg]")
1516 propulsion_cm_offset: float = Field(default=0.3, description="COMPUTED: Propulsion system CM above nozzle exit [m]")
1517
1518 # COPV tank structure mass (for blowdown systems)
1519 copv_dry_mass: Optional[float] = Field(default=None, gt=0, description="COPV tank structure mass (tank walls only, no pressurant gas) [kg]")
1520
1521 # Common parameters
1522 inertia: List[float] = Field(description="AIRFRAME inertia only (without motor/propulsion), relative to airframe CM [Ixx, Iyy, Izz] [kg·m²]. Motor inertia is added separately by RocketPy from propulsion_dry_mass.")
1523 radius: float = Field(gt=0, description="Rocket body radius (outer diameter / 2) [m]")
1524 rocket_length: Optional[float] = Field(default=None, gt=0, description="Total rocket length from tail to nose tip [m]. Used for MoI estimation.")
1525 motor_position: float = Field(default=0.0, description="Nozzle exit position from rocket tail (z=0 at tail, positive toward nose) [m]")
1526 fins: Optional[FinsConfig] = Field(default=None, description="Fins configuration")
1527 # Nosecone: von Kármán (LD-Haack) is the minimum-drag transonic ogive. Length is derived from the
1528 # fineness ratio (nose length / body DIAMETER) — ~4.5 is a good low-drag transonic/supersonic value.
1529 # Set nose_length to override the derived value.
1530 nose_kind: str = Field(default="vonKarman", description="Nosecone profile (RocketPy): vonKarman | lvhaack | ogive | conical | …")
1531 nose_fineness_ratio: float = Field(default=4.5, gt=0, description="Nose length / body diameter (von Kármán ~4.5:1 is near-optimal transonic). Used when nose_length is unset.")
1532 nose_length: Optional[float] = Field(default=None, gt=0, description="Explicit nosecone length [m]. Overrides nose_fineness_ratio when set.")
1533 avionics_payload_length_m: float = Field(default=4.0, ge=0, description="Length of avionics/payload/recovery section ABOVE the propulsion stack, before the nosecone [m].")
1534
1535 # Drag. Either Cd(M) tables with their source (OpenRocket / RASAero export, wind tunnel, flight
1536 # data), or the component build-up of engine/pipeline/vehicle_drag.py on the vehicle the sim
1537 # assembles. There is no constant Cd. The three build-up inputs default to OpenRocket's own
1538 # component defaults, not to this vehicle's drawing; the flight report lists them.
1539 drag_curve_power_off: Optional[List[List[float]]] = Field(default=None, description="Axial Cd with the motor off, [[Mach, Cd], ...]. Replaces the build-up; needs drag_curve_power_on and drag_curve_source.")
1540 drag_curve_power_on: Optional[List[List[float]]] = Field(default=None, description="Axial Cd while thrusting, [[Mach, Cd], ...]. Give the power-off table again if the source has only one.")
1541 drag_curve_source: Optional[str] = Field(default=None, description="Where the drag tables came from: tool and version, file, test.")
1542 surface_roughness_m: float = Field(default=60e-6, ge=0, description="Skin roughness height for the drag build-up [m]. Default 60 um is OpenRocket's default finish, 'regular paint' (Niskanen 2013 table 3.2); 20 um smooth paint, 0 hydraulically smooth.")
1543 fin_thickness_m: float = Field(default=0.003, gt=0, description="Fin thickness for the drag build-up [m]. Default 3 mm is OpenRocket's default fin, not the drawing.")
1544 fin_profile: Literal["square", "rounded", "airfoil"] = Field(default="square", description="Fin edge profile for the drag build-up (Niskanen 2013 sec. 3.4.4). Default 'square' is OpenRocket's default, not the drawing.")
1545 rail_button_upper_pos_m: Optional[float] = Field(default=None, description="Forward rail button, distance from the tail [m]. With the lower button set, rail exit is when this one leaves the rail; unset, the full rail length is flown.")
1546 rail_button_lower_pos_m: Optional[float] = Field(default=None, description="Aft rail button, distance from the tail [m].")
1547
1548 @model_validator(mode="after")
1549 def _drag_tables_complete(self):
1550 tables = (self.drag_curve_power_off, self.drag_curve_power_on)
1551 if all(t is None for t in tables):
1552 return self
1553 if any(t is None for t in tables) or not (self.drag_curve_source or "").strip():
1554 raise ValueError("rocket.drag_curve_power_off, drag_curve_power_on and drag_curve_source go together")
1555 for name, table in zip(("drag_curve_power_off", "drag_curve_power_on"), tables):
1556 if len(table) < 2 or any(len(row) != 2 for row in table):
1557 raise ValueError(f"rocket.{name} must be at least two [Mach, Cd] rows")
1558 mach = [row[0] for row in table]
1559 if mach[0] < 0 or any(b <= a for a, b in zip(mach, mach[1:])) or any(row[1] <= 0 for row in table):
1560 raise ValueError(f"rocket.{name}: Mach must start >= 0 and increase, Cd must be > 0")
1561 return self
1562
1563 # LEGACY fields - kept for backward compatibility
1564 mass: Optional[float] = Field(default=None, gt=0, description="LEGACY: Airframe mass. Use airframe_mass instead.")
1565 cm_wo_motor: Optional[float] = Field(default=None, description="LEGACY: CM without motor. Now auto-calculated from airframe_mass and propulsion positions.")
1566 dry_mass: Optional[float] = Field(default=None, gt=0, description="LEGACY: Unused field.")
1567 motor_inertia: Optional[List[float]] = Field(default=None, description="LEGACY: Motor inertia. Now estimated from propulsion_dry_mass.")
1568 motor: Optional[MotorConfig] = Field(default=None, description="LEGACY: Motor config. Use propulsion_dry_mass instead.")
1569
1570
1571class EnvironmentConfig(BaseModel):
1572 """Environment configuration for flight simulation"""
1573 date: List[int] = Field(description="Launch date and time [year, month, day, hour (0-23 UTC)]")
1574 latitude: float = Field(ge=-90, le=90, description="Launch site latitude (positive = North, negative = South) [deg]")
1575 longitude: float = Field(ge=-180, le=180, description="Launch site longitude (positive = East, negative = West) [deg]")
1576 elevation: float = Field(description="Launch site elevation above sea level (ground level) [m]")
1577 # Atmosphere model: 'standard_atmosphere' (deterministic ISA 1976, no network — DEFAULT for a design
1578 # tool) or 'forecast' (live GFS weather for date/location — realistic but needs internet & a near date).
1579 atmosphere_model: Literal["standard_atmosphere", "forecast"] = Field(
1580 default="standard_atmosphere",
1581 description="Atmospheric model: 'standard_atmosphere' (ISA, offline, deterministic) or 'forecast' (live GFS)")
1582 # Launch. Defaults are the literals the flight sim flew before these fields existed, not the FAR rail.
1583 rail_length_m: float = Field(default=3.35, gt=0, description="Launch rail length [m]. Default 3.35 m is the old hardcoded value, not a measured rail.")
1584 launch_inclination_deg: float = Field(default=90.0, gt=0, le=90, description="Rail elevation from horizontal [deg]; 90 is vertical.")
1585 launch_heading_deg: float = Field(default=0.0, ge=0, lt=360, description="Rail azimuth [deg from north].")
1586
1587
1588class ThrustConfig(BaseModel):
1589 """Thrust configuration for flight simulation"""
1590 burn_time: float = Field(gt=0, description="Burn time [s]")
1591 reference_pressure_pa: Optional[float] = Field(default=None, gt=0, description="Ambient pressure the thrust curve was computed at [Pa]; the flight adds (p_ref - p(z))*A_exit. Unset: compute_ambient_pressure_from_elevation(environment.elevation), the engine solver's own reference.")
1592
1593
1594class FrozenParametersConfig(BaseModel):
1595 """Frozen parameter values for Layer 1 optimization.
1596
1597 When a parameter is set (not None), the optimizer will use that exact
1598 value instead of optimizing it. Values use user-friendly units.
1599 """
1600 # Chamber geometry
1601 A_throat_mm2: Optional[float] = Field(default=None, gt=0, description="Frozen throat area [mm²]")
1602 Lstar_mm: Optional[float] = Field(default=None, gt=0, description="Frozen characteristic length L* [mm]")
1603 expansion_ratio: Optional[float] = Field(default=None, gt=1, description="Frozen expansion ratio (A_exit/A_throat)")
1604 D_chamber_outer_mm: Optional[float] = Field(default=None, gt=0, description="Frozen chamber outer diameter [mm]")
1605
1606 # Injector geometry — PINTLE (applies when injector.type == 'pintle')
1607 d_pintle_tip_mm: Optional[float] = Field(default=None, gt=0, description="[pintle] Frozen pintle tip diameter [mm]")
1608 h_gap_mm: Optional[float] = Field(default=None, gt=0, description="[pintle] Frozen annular gap height [mm]")
1609 n_orifices: Optional[int] = Field(default=None, gt=0, description="[pintle] Frozen number of LOX orifices")
1610 d_orifice_mm: Optional[float] = Field(default=None, gt=0, description="[pintle] Frozen LOX orifice diameter [mm]")
1611 # Injector geometry — IMPINGING / doublet (applies when injector.type == 'impinging'). These were
1612 # already honored by the Layer-1 frozen-param mapping; the schema just needed to declare them so the
1613 # UI offers them and they validate (INJECTOR_PARITY_PLAN W1). Key names match the optimizer mapping.
1614 n_doublets: Optional[int] = Field(default=None, gt=0, description="[impinging] Frozen number of paired unlike doublets")
1615 d_jet_O_mm: Optional[float] = Field(default=None, gt=0, description="[impinging] Frozen LOX jet diameter [mm]")
1616 d_jet_F_mm: Optional[float] = Field(default=None, gt=0, description="[impinging] Frozen fuel jet diameter [mm]")
1617 impingement_angle_O_deg: Optional[float] = Field(default=None, gt=0, le=180, description="[impinging] Frozen LOX jet angle from the chamber axis [deg] (not the included angle)")
1618 impingement_angle_F_deg: Optional[float] = Field(default=None, gt=0, le=180, description="[impinging] Frozen fuel jet angle from the chamber axis [deg] (not the included angle)")
1619 spacing_O_mm: Optional[float] = Field(default=None, gt=0, description="[impinging] Frozen LOX element spacing [mm]")
1620 spacing_F_mm: Optional[float] = Field(default=None, gt=0, description="[impinging] Frozen fuel element spacing [mm]")
1621
1622 # Initial tank pressures
1623 P_O_start_psi: Optional[float] = Field(default=None, gt=0, description="Frozen initial LOX tank pressure [psi]")
1624 P_F_start_psi: Optional[float] = Field(default=None, gt=0, description="Frozen initial fuel tank pressure [psi]")
1625
1626
1627class DesignRequirementsConfig(BaseModel):
1628 """Design requirements for optimizer"""
1629 # Performance targets
1630 target_thrust: float = Field(default=7000.0, gt=0, description="Target peak thrust [N]")
1631 target_chamber_pressure_psi: Optional[float] = Field(
1632 default=None, gt=0,
1633 description="Optional target chamber pressure [psi]. Pc is emergent in a pressure-fed engine, so "
1634 "this is a soft objective target (not a hard constraint): when set, the Layer-1 optimizer "
1635 "drives tank pressure to land Pc near this value and sizes the throat for the thrust target, "
1636 "instead of pushing Pc higher to make thrust. Leave null to let Pc float freely.")
1637 target_apogee: Optional[float] = Field(default=3048.0, gt=0, description="Target apogee above ground level [m]")
1638 # Flight limits. Unset means reported, not checked: no waiver or range rule is assumed here.
1639 max_apogee_m: Optional[float] = Field(default=None, gt=0, description="Apogee ceiling (waiver) [m], in max_apogee_datum. Checked on a vertical, windless flight at the low-drag end of the finish.")
1640 max_apogee_datum: Optional[Literal["AGL", "MSL"]] = Field(default=None, description="Datum of max_apogee_m: 'AGL' above the pad or 'MSL' above sea level. Required with max_apogee_m.")
1641 min_rail_exit_velocity_m_s: Optional[float] = Field(default=None, gt=0, description="Minimum velocity leaving the rail [m/s] (e.g. 30.48 = 100 ft/s, Spaceport America Cup DTEG).")
1642 min_static_margin_cal: Optional[float] = Field(default=None, description="Minimum static margin [cal], at rail exit and at burnout.")
1643 max_static_margin_cal: Optional[float] = Field(default=None, description="Maximum static margin [cal]; above it the vehicle weathercocks hard into wind.")
1644
1645 @model_validator(mode="after")
1646 def _apogee_ceiling_has_datum(self):
1647 if self.max_apogee_m is not None and self.max_apogee_datum is None:
1648 raise ValueError("design_requirements.max_apogee_m needs max_apogee_datum ('AGL' or 'MSL')")
1649 lo, hi = self.min_static_margin_cal, self.max_static_margin_cal
1650 if lo is not None and hi is not None and hi <= lo:
1651 raise ValueError("max_static_margin_cal must exceed min_static_margin_cal")
1652 return self
1653
1654 optimal_of_ratio: float = Field(default=2.3, gt=0, description="Target oxidizer-to-fuel mixture ratio")
1655 target_burn_time: float = Field(default=10.0, gt=0, description="Target burn time [s]")
1656
1657 # Tank pressures
1658 max_lox_tank_pressure_psi: float = Field(default=700.0, gt=0, description="Maximum LOX tank pressure [psi]")
1659 max_fuel_tank_pressure_psi: float = Field(default=850.0, gt=0, description="Maximum fuel tank pressure [psi]")
1660 max_P_tank_O: Optional[float] = Field(default=None, gt=0, description="Maximum LOX tank pressure [Pa] (auto-converted from psi if None)")
1661 max_P_tank_F: Optional[float] = Field(default=None, gt=0, description="Maximum fuel tank pressure [Pa] (auto-converted from psi if None)")
1662
1663 # Geometry constraints
1664 max_engine_length: float = Field(default=0.5, gt=0, description="Maximum total engine length (chamber + nozzle) [m]")
1665 max_chamber_outer_diameter: float = Field(default=0.15, gt=0, description="Maximum chamber outer diameter [m]")
1666 metal_wall_thickness_per_side_m: float = Field(
1667 default=0.00635, gt=0,
1668 description="Chamber metal case thickness per side [m] (geometry envelope; default 0.25 in). "
1669 "Layer 1 derives the total OD -> gas-side wall as 2 * (this + ablative liner "
1670 "thickness when ablative cooling is enabled) — see optimizer.utils.total_wall_thickness_m. "
1671 "There is no separate lump wall field: the ablative share tracks "
1672 "ablative_cooling.initial_thickness automatically after Layer 3 sizes it. "
1673 "Distinct from stainless_steel_case.thickness (thermal-model conduction wall, often null).")
1674 max_nozzle_exit_diameter: float = Field(default=0.101, gt=0, description="Maximum nozzle exit diameter [m]")
1675
1676 # L* constraints
1677 min_Lstar: float = Field(default=0.95, gt=0, description="Minimum characteristic length [m]")
1678 max_Lstar: float = Field(default=1.27, gt=0, description="Maximum characteristic length [m]")
1679
1680 # Stability requirements (new comprehensive analysis)
1681 min_stability_score: float = Field(default=0.75, ge=0, le=1, description="Minimum stability score (0-1)")
1682 require_stable_state: bool = Field(default=True, description="Require 'stable' state (not just 'marginal')")
1683 stability_margin_handicap: float = Field(default=0.0, ge=0, le=1, description="Stability requirement relaxation factor (0=strict, 1=any)")
1684
1685 # Stability requirements (legacy margins)
1686 min_stability_margin: float = Field(
1687 default=1.2, gt=0,
1688 description=(
1689 "Minimum combustion-stability gain margin [-], 1 = neutral: the chug loop's Nyquist gain "
1690 "margin (low end of the mixing-lag band), and the acoustic damping/driving ratio when "
1691 "stability.acoustic_gate gates it. 2.0 = 6 dB. Not the flight static margin."
1692 ),
1693 )
1694 chugging_margin_min: float = Field(default=0.2, ge=0, description="Minimum chugging stability margin")
1695 acoustic_margin_min: float = Field(default=0.1, ge=0, description="Minimum acoustic stability margin")
1696 feed_stability_min: float = Field(default=0.15, ge=0, description="Minimum feed system stability margin")
1697
1698 # Tank capacities (for optimizer bounds and flight-sim mass caps)
1699 lox_tank_capacity_kg: Optional[float] = Field(default=None, gt=0, description="LOX tank capacity [kg]")
1700 fuel_tank_capacity_kg: Optional[float] = Field(default=None, gt=0, description="Fuel tank capacity [kg]")
1701 propellant_tank_fill_factor: float = Field(
1702 default=0.90,
1703 gt=0.0,
1704 le=1.0,
1705 description="Max liquid fill fraction of tank internal volume for flight simulation (e.g. 0.90 = 90% ullage margin)",
1706 )
1707
1708 # COPV
1709 copv_free_volume_L: Optional[float] = Field(default=4.5, gt=0, description="COPV free internal volume [L]")
1710 copv_free_volume_m3: Optional[float] = Field(default=None, gt=0, description="COPV free volume [m³] (auto-converted from L if None)")
1711
1712 # Injector ΔP_inj / Pc soft penalty bands (Layer 1); quadratic hinge outside each interval
1713 injector_dp_ratio_O_min: float = Field(
1714 default=0.20,
1715 ge=0.0,
1716 description="Preferred lower edge for oxidizer ΔP_inj/Pc (soft hinge)",
1717 )
1718 injector_dp_ratio_O_max: float = Field(
1719 default=0.40,
1720 gt=0.0,
1721 description="Preferred upper edge for oxidizer ΔP_inj/Pc (soft hinge)",
1722 )
1723 injector_dp_ratio_F_min: float = Field(
1724 default=0.20,
1725 ge=0.0,
1726 description="Preferred lower edge for fuel ΔP_inj/Pc (soft hinge)",
1727 )
1728 injector_dp_ratio_F_max: float = Field(
1729 default=0.40,
1730 gt=0.0,
1731 description="Preferred upper edge for fuel ΔP_inj/Pc (soft hinge)",
1732 )
1733 feed_pressure_model: str = Field(
1734 default="dome_regulated",
1735 description="Tank pressure time model: blowdown (decaying segments) or dome_regulated (eq. 6.2)",
1736 )
1737 regulator_supply_pressure_effect: Optional[float] = Field(
1738 default=None, ge=0.0,
1739 description=(
1740 "Dome regulator supply-pressure effect: outlet rise per unit inlet drop [psi/psi]. A property "
1741 "of the regulator: Aqua 1092 datasheet 0.010, TB 1031 ~0.017. Unset = 0.010 (Aqua 1092), "
1742 "recorded as an assumption until the regulator is confirmed."
1743 ),
1744 )
1745 regulator_supply_pressure_effect_source: Optional[str] = Field(
1746 default=None,
1747 description="Where regulator_supply_pressure_effect came from (datasheet, flow test).",
1748 )
1749 regulator_min_differential_psi: Optional[float] = Field(
1750 default=None, ge=0.0,
1751 description=(
1752 "Inlet-minus-outlet pressure below which the regulator can no longer hold its outlet [psi]; "
1753 "the dome-regulated curve raises instead of drawing through it. Unset = 0 (outlet <= inlet)."
1754 ),
1755 )
1756 W_geom_ao_af_momentum: float = Field(
1757 default=0.0,
1758 ge=0.0,
1759 description=(
1760 "Layer 1 impinging-only: weight × (relative error)² steering A_geom_O/A_geom_F toward "
1761 "MR/√(ρ_O/ρ_F) (consistent with R≈1 for bulk velocities). 0 disables."
1762 ),
1763 )
1764 # Layer 1 impinging-only (optional overrides; omit or null to use optimizer code defaults).
1765 W_MOM: Optional[float] = Field(
1766 default=None,
1767 ge=0.0,
1768 description=(
1769 "Weight on momentum_ratio_R quadratic hinge relative to bands below "
1770 "(default in layer1_static_optimization.py is 75 when unset)."
1771 ),
1772 )
1773 impinging_momentum_R_min: Optional[float] = Field(
1774 default=None,
1775 gt=0.0,
1776 description="Preferred lower edge for momentum_ratio_R hinge R (impinging-only). None disables.",
1777 )
1778 impinging_momentum_R_max: Optional[float] = Field(
1779 default=None,
1780 gt=0.0,
1781 description="Preferred upper edge for momentum_ratio_R validation gate (impinging-only).",
1782 )
1783 layer1_momentum_log_deadband_rel: Optional[float] = Field(
1784 default=None,
1785 ge=0.0,
1786 description=(
1787 "Multiplicative deadband for Layer-1 log-space momentum pull toward R=1 "
1788 "(optimizer soft term). Default when unset: 0.0 "
1789 "(layer1_static_optimization._LAYER1_DEFAULT_MOMENTUM_LOG_DEADBAND_REL)."
1790 ),
1791 )
1792 layer1_impinging_angle_deg_min: Optional[float] = Field(
1793 default=None,
1794 gt=0.0,
1795 lt=180.0,
1796 description="Preferred lower edge for effective impingement angle hinge [deg] (impinging-only).",
1797 )
1798 layer1_impinging_jet_angle_min_deg: Optional[float] = Field(
1799 default=None,
1800 gt=0.0,
1801 lt=90.0,
1802 description=(
1803 "HARD lower bound on a SINGLE jet's inclination from the chamber axis [deg]. "
1804 "Distinct from layer1_impinging_angle_deg_min, which bounds the INCLUDED angle "
1805 "(the sum of the two jets). The per-jet search box is widened by the permitted "
1806 "asymmetry and can fall to 10 deg or below, and the optimiser will use it -- the "
1807 "8 kN ethalox point returned theta_O = 13 deg, which is not drillable on a flat "
1808 "face without a spot-face or jig. Manufacturing, not physics, so nothing in the "
1809 "objective knows about it. None => no floor beyond the derived box. 20 deg is a "
1810 "reasonable default for conventional drilling."
1811 ),
1812 )
1813 layer1_impinging_angle_deg_max: Optional[float] = Field(
1814 default=None,
1815 gt=0.0,
1816 lt=180.0,
1817 description="Preferred upper edge for effective impingement angle hinge [deg] (impinging-only).",
1818 )
1819 W_IMPINGING_ANGLE: Optional[float] = Field(
1820 default=None,
1821 ge=0.0,
1822 description=(
1823 "Layer 1 impinging-only weight on effective impingement angle hinge term "
1824 "(uses layer1_impinging_angle_deg_min/max when both are set)."
1825 ),
1826 )
1827 # Paired-doublet realism: constrain how far oxidizer vs fuel jet inclinations may diverge.
1828 W_IMPINGING_JET_ASYM: Optional[float] = Field(
1829 default=None,
1830 ge=0.0,
1831 description=(
1832 "Impinging paired jets only: weight on normalized excess [|θ_O−θ_F| − "
1833 "`layer1_impinging_jet_angle_max_asym_deg`]₊ squared (paired-doublet coherence preference)."
1834 ),
1835 )
1836 layer1_impinging_jet_angle_max_asym_deg: Optional[float] = Field(
1837 default=None,
1838 gt=0.0,
1839 lt=180.0,
1840 description=(
1841 "Impinging paired jets only: allowable |θ_O−θ_F| before asymmetry hinge activates [deg]. "
1842 "Unset ⇒ optimizer defaults (see layer1_static_optimization)."
1843 ),
1844 )
1845 # Layer 1 impinging-only: SMD (atomization) objective. Penalizes the mass-flux-weighted
1846 # effective Sauter mean diameter (taken directly from the injector-physics Ingebo D32, which
1847 # already captures aerodynamic Weber, liquid Reynolds/viscosity, and impingement angle via the
1848 # law-of-cosines relative velocity) when it falls outside a *two-sided* band around the target.
1849 W_SMD: Optional[float] = Field(
1850 default=None,
1851 ge=0.0,
1852 description=(
1853 "Layer 1 impinging-only weight on the SMD range penalty. 0/None disables. The penalty is "
1854 "a two-sided deadband: zero inside [target·(1−tol), target·(1+tol)], squared outside."
1855 ),
1856 )
1857 target_smd_microns: Optional[float] = Field(
1858 default=None,
1859 gt=0.0,
1860 description="Target effective Sauter mean diameter [µm] for the SMD objective (e.g. 50).",
1861 )
1862 layer1_smd_rel_tol: Optional[float] = Field(
1863 default=None,
1864 ge=0.0,
1865 description=(
1866 "Half-width of the SMD deadband as a fraction of target (e.g. 0.20 ⇒ band is "
1867 "target·[0.8, 1.2]). Unset ⇒ optimizer default (0.20)."
1868 ),
1869 )
1870 # Layer 1 impinging-only: equal feed/tank pressure objective. Drives the LOX and fuel tank
1871 # stagnation pressures together. This is a SEPARATE objective from R≈1 — equal tank pressure does
1872 # NOT imply momentum matching because the two sides have different densities, feed-line losses,
1873 # and injector ΔP (different hole sizes/angles).
1874 W_TANK_EQUAL: Optional[float] = Field(
1875 default=None,
1876 ge=0.0,
1877 description=(
1878 "Layer 1 impinging-only weight on ((P_tank_O − P_tank_F)/scale)². 0/None disables."
1879 ),
1880 )
1881 layer1_tank_equal_scale_psi: Optional[float] = Field(
1882 default=None,
1883 gt=0.0,
1884 description="Normalizing scale [psi] for the equal-tank-pressure penalty. Unset ⇒ 100 psi.",
1885 )
1886 layer1_chamber_od_increment_in: Optional[float] = Field(
1887 default=None,
1888 ge=0.0,
1889 description=(
1890 "Snap the chamber OUTER diameter to this increment in INCHES (e.g. 0.5 for "
1891 "half-inch stock). Ablative sleeve, chamber tube and case are bought in fixed "
1892 "sizes, so a continuous optimum like 4.2 in is not purchasable — you build 4.0 or "
1893 "4.5 and the engine you build is not the one that was optimised. Snapping inside "
1894 "the evaluation means every candidate scored is one you can order, instead of "
1895 "rounding afterwards and silently moving contraction ratio, L* and wall thickness "
1896 "off the optimum. Unset/0 ⇒ continuous search."
1897 ),
1898 )
1899 layer1_lock_tank_pressures: Optional[bool] = Field(
1900 default=None,
1901 description=(
1902 "Treat the two tank pressures as ONE optimizer variable — the fuel tank follows "
1903 "the LOX tank exactly. 'Match tank pressures' says they are one quantity, so "
1904 "searching them separately and penalizing the gap makes the optimizer pay forever "
1905 "for a degree of freedom it was told not to use. Unset ⇒ follows the match-tanks "
1906 "checkbox (W_TANK_EQUAL > 0)."
1907 ),
1908 )
1909 layer1_thrust_deadband_rel: Optional[float] = Field(
1910 default=None, ge=0.0,
1911 description=(
1912 "Relative thrust deadband in the objective. Unset ⇒ 0.1% when the throat is solved "
1913 "from thrust (the derivation lands on target; a wide band only lets edge-sitting "
1914 "designs through 2-3% off), 2% when the throat is searched."
1915 ),
1916 )
1917 layer1_derive_tank_from_dp_ratio: Optional[bool] = Field(
1918 default=None,
1919 description=(
1920 "With tanks locked, solve the single tank pressure from the injector ΔP/Pc target "
1921 "(P_tank = Pc·(1+r) + ΔP_feed) instead of searching it. Makes the ΔP/Pc band exact "
1922 "but removes the pressure's Isp anchor, so Pc and Isp fall. Unset ⇒ OFF."
1923 ),
1924 )
1925 layer1_dp_ratio_target: Optional[float] = Field(
1926 default=None, gt=0.0,
1927 description="ΔP/Pc target for the tank-pressure derivation. Unset ⇒ mid-band.",
1928 )
1929 layer1_derive_fuel_jet_from_of: Optional[bool] = Field(
1930 default=None,
1931 description=(
1932 "Solve the fuel jet diameter from the O/F target (equal-ΔP area ratio). "
1933 "Experimental — the one-step secant overshoots when Pc moves with it. Unset ⇒ OFF."
1934 ),
1935 )
1936 layer1_tank_equal_inband_frac: Optional[float] = Field(
1937 default=None,
1938 ge=0.0,
1939 description=(
1940 "Weak always-on pull toward equal tank pressures INSIDE the match tolerance, as a "
1941 "fraction of the out-of-band weight. A pure deadband is flat inside the band, so "
1942 "nothing separates 1 psi from 9.9 psi and the optimizer parks on the edge — which "
1943 "is how a 10 psi requirement comes back at 10.1 psi. Unset ⇒ 0.05; 0 restores the "
1944 "flat deadband."
1945 ),
1946 )
1947 layer1_chamber_od_snap_target: Optional[str] = Field(
1948 default=None,
1949 description=(
1950 "Which diameter the stock increment applies to: 'outer' (the tube you buy) or "
1951 "'bore' (the gas-side diameter). Only one can land on the increment, because "
1952 "bore = OD - wall and the wall is rarely a whole increment — a 1.13 in wall "
1953 "turns a 6.00 in OD into a 4.87 in bore. Unset ⇒ 'outer'."
1954 ),
1955 )
1956 layer1_Lstar_from_smd: Optional[bool] = Field(
1957 default=None,
1958 description=(
1959 "Derive the L* target from the spray SMD instead of using a fixed number. L* buys "
1960 "residence time and the residence time you NEED is set by how long the droplets "
1961 "take to evaporate (d²-law: τ_vap ∝ SMD²), so L*_target ∝ SMD². Treating them as "
1962 "independent knobs is what makes a good design look like a failed one. Unset ⇒ ON."
1963 ),
1964 )
1965 layer1_Lstar_smd_ref_um: Optional[float] = Field(
1966 default=None, gt=0.0,
1967 description="SMD anchor for the L* correlation [µm]. Unset ⇒ 99.",
1968 )
1969 layer1_Lstar_ref_m: Optional[float] = Field(
1970 default=None, gt=0.0,
1971 description="L* target at the SMD anchor [m]. Unset ⇒ 1.10 (i.e. 99 µm ⇒ 1.05–1.15).",
1972 )
1973 layer1_Lstar_smd_exponent: Optional[float] = Field(
1974 default=None,
1975 description=(
1976 "Exponent in L*_target ∝ SMD^n. Unset ⇒ 2.0, which is the d²-law, not a fit — "
1977 "change it only to model a different evaporation regime."
1978 ),
1979 )
1980 layer1_Lstar_deadband_m: Optional[float] = Field(
1981 default=None, ge=0.0,
1982 description=(
1983 "Total width of the free window around the L* target [m]. Unset ⇒ 0.1 (±0.05), "
1984 "which is finer than the correlation's own accuracy."
1985 ),
1986 )
1987 layer1_impingement_Ld_target: Optional[float] = Field(
1988 default=None, gt=0.0,
1989 description=(
1990 "Where the jets of a doublet should meet, in JET DIAMETERS from the injector face. "
1991 "Too close and the collision erodes the injector plate; too far and the sheet "
1992 "spreads before it breaks up. Published practice for unlike doublets is 3–7. "
1993 "Unset ⇒ 4."
1994 ),
1995 )
1996 layer1_resultant_tilt_max_deg: Optional[float] = Field(
1997 default=None,
1998 description=(
1999 "Largest OUTWARD tilt of the doublet's spray resultant, in degrees from the "
2000 "chamber axis, before the design is treated as infeasible. This is the real "
2001 "ablative guard: the fan follows the vector sum of the two streams, and a fan "
2002 "aimed at the liner erodes it. Positive is toward the wall. Unset ⇒ 0 (never "
2003 "outward). The momentum-flux ratio R is NOT this quantity — p_O/p_F = R²·A_O/A_F, "
2004 "so R = 1 is not balance."
2005 ),
2006 )
2007 layer1_resultant_tilt_gate_tol_deg: Optional[float] = Field(
2008 default=None, ge=0.0,
2009 description=(
2010 "Tolerance added to the outward-tilt limit for the VALIDATION gate only, so "
2011 "whole-degree jet angles cannot fail a design by rounding. Unset ⇒ 1°."
2012 ),
2013 )
2014 layer1_resultant_tilt_scale_deg: Optional[float] = Field(
2015 default=None, gt=0.0,
2016 description="Normalizing scale [deg] for the resultant-tilt violation. Unset ⇒ 2.",
2017 )
2018 layer1_W_TILT: Optional[float] = Field(
2019 default=None, ge=0.0,
2020 description=(
2021 "Weight of the resultant-spray-tilt preference: W (tilt/scale)^2 around 0 deg, with the "
2022 "outward (wall) side multiplied by layer1_resultant_tilt_outward_multiplier. Inward lean "
2023 "is allowed at a mild cost; the hard wall guard (layer1_resultant_tilt_max_deg / reach) "
2024 "still applies. Unset or 0 = off."
2025 ),
2026 )
2027 layer1_resultant_tilt_outward_multiplier: Optional[float] = Field(
2028 default=None, ge=1.0,
2029 description="Outward-side multiplier on the tilt preference (layer1_W_TILT). Unset ⇒ 25.",
2030 )
2031 layer1_momentum_wall_side_multiplier: Optional[float] = Field(
2032 default=None, ge=1.0,
2033 description=(
2034 "How much more a momentum-ratio miss costs when it throws the spray toward the "
2035 "chamber WALL than toward the core. With LOX inboard, R > 1 means the LOX stream "
2036 "wins the collision and the fan deflects outward onto the ablative; R < 1 sends "
2037 "it into the core, which only costs mixing. Unset ⇒ 10."
2038 ),
2039 )
2040 layer1_momentum_scale: Optional[float] = Field(
2041 default=None, gt=0.0,
2042 description="Normalizing scale for the momentum-ratio deviation (in log R). Unset ⇒ 0.10.",
2043 )
2044 layer1_momentum_gate_safe_slack: Optional[float] = Field(
2045 default=None, ge=1.0,
2046 description=(
2047 "Widens the momentum validation band symmetrically about 1, as a multiple of the "
2048 "configured half-width. Unset ⇒ 1 (the gate is the configured band)."
2049 ),
2050 )
2051 layer1_derive_impingement_spacing: Optional[bool] = Field(
2052 default=None,
2053 description=(
2054 "Solve the fuel-ring hole pitch so the doublet meets at exactly "
2055 "layer1_impingement_Ld_target jet diameters, instead of searching it. One "
2056 "equation, one unknown: |s_F − s_O| = 2π·k·d_avg·(tanθ_O + tanθ_F)/n. Removes an "
2057 "optimizer dimension and makes the standoff exact rather than a penalty the "
2058 "search negotiates with. Turn OFF to search the fuel pitch freely. Unset ⇒ ON."
2059 ),
2060 )
2061 layer1_impingement_Ld_tol: Optional[float] = Field(
2062 default=None, ge=0.0,
2063 description=(
2064 "Free window around the impingement target, in jet diameters. Only meaningful "
2065 "when the spacing derivation is OFF — when it is on the target is hit exactly. "
2066 "Unset ⇒ 0 with the derivation on, 1.0 with it off."
2067 ),
2068 )
2069 layer1_ring_order_fuel_outboard: Optional[bool] = Field(
2070 default=None,
2071 description=(
2072 "Put the FUEL ring outboard of the LOX ring on the injector face. Whatever passes "
2073 "the impingement point on the outer ring is what reaches the chamber wall, and a "
2074 "fuel-rich wall film is what an ablative liner wants — an oxidizer-rich one attacks "
2075 "it. The impingement physics uses |ΔD_pitch| and is indifferent to the order, so "
2076 "this costs nothing. Unset ⇒ ON."
2077 ),
2078 )
2079 layer1_integer_jet_angles: Optional[bool] = Field(
2080 default=None,
2081 description=(
2082 "Snap impinging-doublet jet angles to whole degrees. A drill jig is indexed in "
2083 "whole degrees, so a 43.7° optimum is not machinable, and rounding it afterwards "
2084 "moves impingement distance, momentum ratio and SMD off the scored point. "
2085 "Impinging injectors only (pintle has no angle DOF). Unset ⇒ ON."
2086 ),
2087 )
2088 layer1_derive_expansion_ratio: Optional[bool] = Field(
2089 default=None,
2090 description=(
2091 "Solve the expansion ratio for a perfectly expanded exit (Pe = ambient) instead "
2092 "of searching it. Closed-form isentropic inversion of Pc/Pe, so the exit plane "
2093 "lands on ambient exactly and one optimizer dimension disappears. Turn OFF only "
2094 "to size deliberately over/under-expanded nozzles, or to explore a range of "
2095 "expansion ratios against fixed hardware. Unset ⇒ ON."
2096 ),
2097 )
2098 layer1_derive_throat_from_thrust: Optional[bool] = Field(
2099 default=None,
2100 description=(
2101 "Solve the throat area from the thrust requirement instead of searching it. "
2102 "Thrust is monotonic in throat area, so a short root-find lands the target "
2103 "exactly and removes an optimizer dimension. Turn OFF when the throat is fixed "
2104 "hardware (an existing graphite insert) and thrust is an output rather than a "
2105 "requirement. Unset ⇒ ON."
2106 ),
2107 )
2108 layer1_derive_max_iters: Optional[float] = Field(
2109 default=None,
2110 ge=1.0,
2111 description=(
2112 "Cap on root-find iterations per candidate when solving the derived DOFs; the solve "
2113 "exits once thrust is within layer1_derive_thrust_tol_rel and eps has stopped moving. "
2114 "Unset ⇒ 8. (2 fixed steps left a 0.4-0.9 % thrust miss that depended on the start.)"
2115 ),
2116 )
2117 layer1_derive_thrust_tol_rel: Optional[float] = Field(
2118 default=None,
2119 gt=0.0,
2120 description=(
2121 "Relative thrust tolerance for the derived-throat solve; iteration stops inside "
2122 "this band. Unset ⇒ 1e-3 (0.1%)."
2123 ),
2124 )
2125 layer1_tank_equal_tol_psi: Optional[float] = Field(
2126 default=None,
2127 ge=0.0,
2128 description=(
2129 "Tank-pressure delta [psi] that costs nothing — set it to your PT margin of error. "
2130 "Without it any nonzero delta is squared, so matching to 1.93 psi (a success against "
2131 "a 10 psi spec) still carried 11 objective points and read as non-convergence. "
2132 "Unset ⇒ 0 (no deadband, historical behaviour)."
2133 ),
2134 )
2135 layer1_of_deadband_rel: Optional[float] = Field(
2136 default=None,
2137 ge=0.0,
2138 description=(
2139 "Relative O/F error that costs nothing, e.g. 0.02 for ±2%. Without it, landing at "
2140 "1.6646 against a 1.65 target (+0.9%, far inside the 15% validation gate) still "
2141 "carried 4.7 objective points. Unset ⇒ 0."
2142 ),
2143 )
2144 layer1_exit_pressure_deadband_rel: Optional[float] = Field(
2145 default=None,
2146 gt=0.0,
2147 description=(
2148 "Relative deadband around ambient inside which exit-pressure error is free. Was "
2149 "hardcoded at 0.05 — for a 13.64 psi target that made 12.96–14.33 psi cost nothing, "
2150 "so runs settled at 14.6 psi and still reported converged. Tighten (e.g. 0.005) when "
2151 "exit pressure is a real requirement. Unset ⇒ 0.05."
2152 ),
2153 )
2154 layer1_W_LSTAR: Optional[float] = Field(
2155 default=None,
2156 ge=0.0,
2157 description=(
2158 "Layer 1 weight on L* ABOVE layer1_Lstar_target_m: ((L*-target)/target)², one-sided. "
2159 "L* is the only design variable with no opposing force in the objective — more "
2160 "residence time is pure reward — so it pinned to its max bound in every run. A chamber "
2161 "mass penalty does NOT substitute: wall mass scales as D·L while volume scales as D²·L, "
2162 "so charging for mass buys a fatter chamber at the same L*. Unset/0 ⇒ disabled."
2163 ),
2164 )
2165 layer1_Lstar_target_m: Optional[float] = Field(
2166 default=None,
2167 gt=0.0,
2168 description=(
2169 "L* [m] above which layer1_W_LSTAR starts charging. At or below it is free. "
2170 "Unset ⇒ 1.0 m."
2171 ),
2172 )
2173 layer1_W_MASS: Optional[float] = Field(
2174 default=None,
2175 ge=0.0,
2176 description=(
2177 "Layer 1 weight on chamber dry mass, (m/layer1_chamber_mass_ref_kg)², the "
2178 "counterweight to layer1_W_ISP. Unset ⇒ priced like propellant: at the reference mass "
2179 "1 kg of chamber costs what 1 kg of propellant does at fixed total impulse, "
2180 "W_MASS = W_ISP·m_ref/(2·m_prop_ideal). 0 ⇒ disabled."
2181 ),
2182 )
2183 layer1_W_ISP: Optional[float] = Field(
2184 default=None,
2185 ge=0.0,
2186 description=(
2187 "Layer 1 figure of merit: weight on the propellant the delivered Isp costs beyond an "
2188 "ideal engine for the required total impulse, W_ISP·(Isp_ideal/Isp − 1) (Sutton & "
2189 "Biblarz ch. 2: m_prop = F·t_b/(g0·Isp)). O/F and a Pc target are held at their "
2190 "requirement inside it, so it cannot pull them off target. Reported as isp_penalty "
2191 "(1000 ⇒ tenths of a percent of the ideal propellant). Unset ⇒ 1000; 0 ⇒ off."
2192 ),
2193 )
2194 layer1_contraction_half_angle_deg: Optional[float] = Field(
2195 default=None,
2196 gt=0,
2197 lt=90,
2198 description=(
2199 "Convergent half-angle [deg] for the chamber contraction. Was hardcoded at 45 deg. "
2200 "45 deg is the LENGTH-optimal angle; it is not the MASS-optimal one, because a "
2201 "shallower cone is longer but moves volume out of the full-diameter barrel (which "
2202 "carries the whole wall stack) into the tapering cone shell. 30 deg measured lighter "
2203 "at equal L* on the 8 kN ethalox point. Conventional band 25-45 deg. None = 45."
2204 ),
2205 )
2206 layer1_min_Lcyl_over_D: Optional[float] = Field(
2207 default=None,
2208 gt=0,
2209 description=(
2210 "Minimum CYLINDRICAL-length-to-bore ratio, enforced as infeasibility (not a penalty). "
2211 "layer1_chamber_ld_ratio_min gates TOTAL chamber length, which lets the convergent "
2212 "cone masquerade as mixing length; the constant-area section is where impinging "
2213 "sprays actually mix. Pair with spray.evaporation.x_star_limit. None = not enforced."
2214 ),
2215 )
2216 layer1_max_element_pitch_m: Optional[float] = Field(
2217 default=None,
2218 gt=0,
2219 description=(
2220 "Maximum injector element pitch sqrt(A_chamber/n_elements) [m], enforced as "
2221 "infeasibility. eta_mixing (Rupe) sees only momentum ratio, so nothing otherwise "
2222 "charges for spreading a fixed element count over a larger face -- the optimiser can "
2223 "buy chamber diameter for free. None = not enforced."
2224 ),
2225 )
2226 layer1_chamber_wall_density_kg_m3: Optional[float] = Field(
2227 default=None,
2228 gt=0.0,
2229 description=(
2230 "Effective density of the chamber wall stack (metal + ablative + graphite) for the "
2231 "layer1_W_MASS proxy. Unset ⇒ 2000 kg/m³."
2232 ),
2233 )
2234 layer1_chamber_mass_ref_kg: Optional[float] = Field(
2235 default=None,
2236 gt=0.0,
2237 description=(
2238 "Normalising chamber mass [kg] for the layer1_W_MASS penalty — the mass at which the "
2239 "term equals 1.0. Unset ⇒ 5 kg."
2240 ),
2241 )
2242 layer1_W_EXIT: Optional[float] = Field(
2243 default=None,
2244 ge=0.0,
2245 description=(
2246 "Layer 1 weight on the nozzle exit-pressure term (P_exit vs ambient). Was hardcoded "
2247 "at 2e2 in two places, i.e. ~300x weaker than layer1_W_THRUST (6e4), so exit pressure "
2248 "never bound. Unset ⇒ optimizer default (2e2)."
2249 ),
2250 )
2251 # Layer 1 impinging-only: injector geometry / vaporization-fit objective. Couples the impingement
2252 # standoff (where the streams meet) and the droplet vaporization length to the available chamber
2253 # length, and penalizes ring overflow / element overlap. Gives the ``spacing`` design variable a
2254 # real physical effect (impingement distance + pitch-circle diameter).
2255 W_IMP_GEOM: Optional[float] = Field(
2256 default=None,
2257 ge=0.0,
2258 description=(
2259 "Layer 1 impinging-only weight on the geometry/vaporization-fit penalty (normalized "
2260 "squared violations of: L_imp+x* within the evaporation budget, ring pitch diameter "
2261 "within the chamber bore, and element non-overlap). 0/None disables."
2262 ),
2263 )
2264 layer1_exit_pressure_inside_quad_scale: Optional[float] = Field(
2265 default=None,
2266 ge=0.0,
2267 description=(
2268 "Inside the nominal plus-or-minus 5 percent exit-pressure deadband, multiply W_EXIT by this "
2269 "coefficient times (ΔP_exit/P_tgt) squared to steer toward atmospheric-matched nozzle exit pressure."
2270 ),
2271 )
2272 layer1_impinging_n_doublets_max: Optional[int] = Field(
2273 default=None,
2274 ge=5,
2275 description=(
2276 "Layer 1 impinging: max paired LOX/fuel element count (n_doublets). "
2277 "The bore-derived ceiling from ``impinging_n_elements_hi_int`` is clipped to this value when set."
2278 ),
2279 )
2280 layer1_random_seed: Optional[int] = Field(
2281 default=None,
2282 description=(
2283 "Layer 1 integer seed for NumPy restart perturbations and CMA-ES ``seed`` "
2284 "(each restart uses seed + restart_index × 1_000_003). Omit or null → base 42."
2285 ),
2286 )
2287 layer1_cma_warmstart_trials: Optional[int] = Field(
2288 default=None,
2289 ge=0,
2290 description=(
2291 "Before legacy CMA-ES, evaluate this many clipped/snapped candidates around ``x0`` "
2292 "(same worker objective as CMA). 0 disables. Default 16 in optimizer when unset."
2293 ),
2294 )
2295 layer1_cma_warmstart_sigma_frac: Optional[float] = Field(
2296 default=None,
2297 ge=0.0,
2298 le=0.5,
2299 description=(
2300 "Per-dimension Gaussian scale for warm-start perturbations: ``sigma_frac × (upper−lower)``. "
2301 "Default 0.04 when unset."
2302 ),
2303 )
2304 layer1_cma_restart0_sigma_scale: Optional[float] = Field(
2305 default=None,
2306 ge=0.0,
2307 le=1.0,
2308 description=(
2309 "After warm-start, multiply legacy CMA-ES initial step size (restart 0 only) by this factor "
2310 "so the first population stays near the feasible mean (default 0.48 in code when unset)."
2311 ),
2312 )
2313 layer1_lbfgs_gtol: Optional[float] = Field(
2314 default=None,
2315 gt=0.0,
2316 description=(
2317 "Gradient norm tolerance for the L-BFGS-B local refinement after CMA-ES (default 1e-9 in code). "
2318 "Tighter values can reduce the weighted objective; they do not target a specific absolute objective magnitude."
2319 ),
2320 )
2321 layer1_lbfgs_second_pass: Optional[bool] = Field(
2322 default=None,
2323 description=(
2324 "If true, run a second L-BFGS-B from the first local optimum with a tighter ``gtol`` (default true in code)."
2325 ),
2326 )
2327 W_DP: Optional[float] = Field(default=None, ge=0.0, description="Layer 1 fallback injector ΔP weight.")
2328 W_DP_O: Optional[float] = Field(default=None, ge=0.0, description="Layer 1 oxidizer stream ΔP hinge weight.")
2329 W_DP_F: Optional[float] = Field(default=None, ge=0.0, description="Layer 1 fuel stream ΔP hinge weight.")
2330 W_DP_HIGH: Optional[float] = Field(
2331 default=None,
2332 ge=0.0,
2333 description=(
2334 "Layer 1 weight on injector ΔP hinge term when both ΔP/Pc streams exceed their bands "
2335 "(used together with W_DP_O/W_DP_F in weighted injector ΔP penalty)."
2336 ),
2337 )
2338 # ------------------------------------------------------------------------------------
2339 # Keys Layer 1 ALREADY READS that were never declared here. A pydantic model silently
2340 # DROPS unknown keys, so `PUT /api/config` with any of these returned 200 and discarded
2341 # them -- and four of them have a labelled control in the Configuration editor that
2342 # therefore did nothing at all. Declared here as Optional[...] = None so behaviour is
2343 # unchanged (None => the optimizer's own default, quoted in each description), but they
2344 # are now settable. Found by diffing _requirement_*/requirements.get() call sites in
2345 # layer1_static_optimization.py against this class.
2346 W_DP_CENTER: Optional[float] = Field(default=None, ge=0.0,
2347 description="Weight pulling injector dP/Pc toward the centre of its band. None => 500.0")
2348 W_DP_O_FLOOR: Optional[float] = Field(default=None, ge=0.0,
2349 description="Weight on the oxidiser dP/Pc soft floor. None => 0.0 (disabled)")
2350 injector_dp_ratio_O_soft_floor: Optional[float] = Field(default=None, gt=0.0, lt=1.0,
2351 description="Soft floor on dP_O/Pc, penalised below this. None => not enforced")
2352 layer1_A_throat_mm2_min: Optional[float] = Field(default=None, gt=0.0,
2353 description="Lower bound on the throat-area search variable [mm^2]. None => derived")
2354 layer1_A_throat_mm2_max: Optional[float] = Field(default=None, gt=0.0,
2355 description="Upper bound on the throat-area search variable [mm^2]. None => derived")
2356 layer1_cf_upper_bound_for_throat_floor: Optional[float] = Field(default=None, gt=0.0,
2357 description="Cf ceiling used when deriving the throat-area floor. None => 1.8")
2358 layer1_pc_fraction_for_throat_floor: Optional[float] = Field(default=None, gt=0.0, le=1.0,
2359 description="Fraction of target Pc used when deriving the throat floor. None => 0.75")
2360 layer1_enforce_ring_geometry: Optional[bool] = Field(default=None,
2361 description="Enforce doublet ring fit / element gap / impingement standoff from the "
2362 "DESIGN VARIABLES. None => True. Note the older diagnostics-driven term "
2363 "(_impinging_geometry_fit_squared) is inert because the numba accelerator "
2364 "emits none of the keys it reads -- this flag governs the live one.")
2365 # ---- INJECTOR FACE REAL ESTATE -------------------------------------------------------
2366 # Where the two rings SIT radially was an exactly flat direction in the objective: the
2367 # standoff derivation fixes the ring GAP (dr = Ld*d_avg*(tan th_O + tan th_F)) but nothing
2368 # fixed the pair's radius, so s_O drifted onto its own lower bound (0.003 m) and parked the
2369 # whole element ring against the axis. Measured on ethalox_8kN_FINAL: D_pitch_O 26.90 mm,
2370 # every doublet impinging on a 41.79 mm circle inside a 127.00 mm bore -- 10.8 % of the
2371 # chamber area, and NARROWER THAN THE 49.57 mm THROAT. These three keys give that direction
2372 # an opinion. All are measured against the orifice's ELLIPTICAL trace on the face: a hole of
2373 # diameter d inclined th from the chamber axis cuts the face as an ellipse whose RADIAL
2374 # (major) axis is d/cos(th), which at th = 69 deg is 2.8x the drill diameter.
2375 layer1_injector_spray_radius_frac: Optional[float] = Field(default=None, gt=0.0, le=1.0,
2376 description="Target impingement radius as a fraction of the chamber bore RADIUS. Every "
2377 "doublet on one ring collides on the same circle r_imp = r_inner + L_imp * "
2378 "tan(theta_inner), and that circle is where the propellant is actually put "
2379 "into the chamber. Nothing constrained it: the ring pair is free to slide "
2380 "radially (the standoff derivation fixes the ring GAP, not its radius), so "
2381 "it parks on whatever bound it meets. Measured: r_imp/r_wall 0.33, feeding "
2382 "10.8 % of the chamber area through a circle NARROWER THAN THE THROAT; and "
2383 "again at 0.44 once only a lower bound existed. The natural target is the "
2384 "EQUAL-AREA radius 1/sqrt(2) = 0.7071, which splits the chamber cross-"
2385 "section in half. None => inert.")
2386 layer1_injector_spray_radius_tol: Optional[float] = Field(default=None, gt=0.0,
2387 description="Half-width of the free band around layer1_injector_spray_radius_frac, in "
2388 "the same fraction-of-radius units. None => 0.08.")
2389 layer1_injector_plate_thickness_m: Optional[float] = Field(default=None, gt=0.0,
2390 description="Injector face plate thickness [m]. An orifice inclined theta from the "
2391 "chamber axis runs t/cos(theta) through it -- 2.79x the plate at 69 deg. "
2392 "Used for the drilled-passage and face-incidence machining checks. "
2393 "None => the checks are inert.")
2394 layer1_injector_min_face_incidence_deg: Optional[float] = Field(default=None, ge=0.0, le=90.0,
2395 description="Minimum angle between an orifice axis and the face PLANE [deg], i.e. "
2396 "90 - theta. A drill entering a flat at shallow incidence walks; below this "
2397 "the entry needs a spot-face milled normal to the hole axis. None => 0 "
2398 "(inert). NOTE this is a per-jet ceiling on theta, complementary to "
2399 "layer1_impinging_jet_angle_min_deg which is the floor.")
2400 layer1_injector_counterbore_dia_m: Optional[float] = Field(default=None, gt=0.0,
2401 description="Feed-passage (counterbore) diameter behind each orifice [m]. The orifice "
2402 "itself is only a short LAND of discharge.orifice_l_over_d diameters at the "
2403 "face end; the rest of the passage is this larger bore. Without it the "
2404 "drilled depth reads as if the orifice diameter ran the whole plate, which "
2405 "reported L/d 13.5 on a design whose small drill only goes 6.6 mm. "
2406 "None => the passage check uses the orifice diameter (conservative).")
2407 layer1_injector_center_clear_dia_m: Optional[float] = Field(default=None, gt=0.0,
2408 description="Clear circle that must remain unobstructed at the centre of the injector "
2409 "face [m] -- igniter boss, centre-body or instrumentation port. The inner "
2410 "edge of the innermost orifice ring must stay outside it. None => 0.0 "
2411 "(no centre reservation). A 3/8-18 NPT spark igniter is 17.15 mm across the "
2412 "thread crest, so ~0.028 m of boss plus clearance is a realistic entry.")
2413 layer1_injector_min_back_web_m: Optional[float] = Field(default=None, ge=0.0,
2414 description="Minimum land between neighbouring passage entries on the BACK (manifold) "
2415 "face of the plate [m]. An inclined hole walks t*tan(theta) radially through "
2416 "the plate and the inner ring's entries crowd toward the axis: on "
2417 "ethalox_8kN_SHIP the LOX counterbores leave 1.61 mm on the back against a "
2418 "6.40 mm face web. Needs layer1_injector_plate_thickness_m. None => inert "
2419 "(the drawing still warns against the face web floor).")
2420 layer1_injector_min_web_m: Optional[float] = Field(default=None, ge=0.0,
2421 description="Minimum land (web) between adjacent orifices on the SAME ring [m]. The only "
2422 "prior guard was spacing >= d_jet, i.e. a web of exactly zero. None => 0.0.")
2423 layer1_injector_wall_clearance_m: Optional[float] = Field(default=None, ge=0.0,
2424 description="Minimum radial gap from the outermost orifice's face trace to the chamber "
2425 "bore [m] -- manifold land, and the room a fuel barrier row would need. "
2426 "None => 0.0.")
2427 layer1_resultant_tilt_from_reach: Optional[bool] = Field(default=None,
2428 description="Derive the permitted outward spray tilt from the design's own geometry "
2429 "instead of taking layer1_resultant_tilt_max_deg as a constant. An outward "
2430 "fan is only a hazard if it REACHES the liner while the spray is still "
2431 "liquid, and the angle at which it does depends on the impingement radius -- "
2432 "which the optimizer is free to move underneath a fixed allowance. When true "
2433 "the limit is atan((r_wall - r_imp) / (margin * L_chamber)). None => False "
2434 "(the constant is used, previous behaviour exactly).")
2435 layer1_resultant_tilt_reach_margin: Optional[float] = Field(default=None, gt=0.0,
2436 description="Safety factor on chamber length for layer1_resultant_tilt_from_reach. "
2437 "1.5 means the fan may not reach the liner inside 1.5 chamber lengths. "
2438 "None => 1.5.")
2439 layer1_impingement_Ld_min: Optional[float] = Field(default=None, gt=0.0,
2440 description="Min impingement standoff in orifice diameters. None => Ld_target - Ld_tol")
2441 layer1_impingement_Ld_max: Optional[float] = Field(default=None, gt=0.0,
2442 description="Max impingement standoff in orifice diameters. None => Ld_target + Ld_tol")
2443 layer1_momentum_band_width: Optional[float] = Field(default=None, gt=0.0,
2444 description="Half-width of the log-symmetric momentum-ratio preference band. None => 0.05")
2445 layer1_momentum_low_side_multiplier: Optional[float] = Field(default=None, ge=0.0,
2446 description="Extra weight on momentum-ratio misses below the band. None => 10.0")
2447 layer1_generations_per_restart: Optional[float] = Field(default=None, gt=0.0,
2448 description="CMA-ES generations per restart. None => 50.0")
2449 max_chamber_length_m: Optional[float] = Field(default=None, gt=0.0,
2450 description="Hard cap on chamber length (injector face to throat) [m]. None => 0.50")
2451 objective_cache_rel: Optional[float] = Field(default=None, gt=0.0,
2452 description="Relative tolerance for the objective memo cache. None => 1e-5")
2453 report_every_n: Optional[int] = Field(default=None, gt=0,
2454 description="Progress reporting stride in evaluations. None => 1")
2455 # ------------------------------------------------------------------------------------
2456 layer1_infeasibility_gate_eps: Optional[float] = Field(
2457 default=None,
2458 ge=0.0,
2459 description="Treat infeasibility_score ≤ eps as feasible for thrust/MR/ΔP objective blending (code default 0.002).",
2460 )
2461 layer1_W_THRUST: Optional[float] = Field(
2462 default=None,
2463 ge=0.0,
2464 description="Layer 1 weight on thrust penalty term (code default 1e4 when unset).",
2465 )
2466 layer1_W_PC: Optional[float] = Field(
2467 default=None,
2468 ge=0.0,
2469 description="Layer 1 weight on the chamber-pressure target penalty — an exact (L1) penalty on "
2470 "|Pc - target|, so Pc lands ON the target (only active when target_chamber_pressure_psi "
2471 "is set; code default 1e4 when unset). Raise to hold Pc tighter, lower to let thrust win ties.",
2472 )
2473 layer1_W_OF: Optional[float] = Field(
2474 default=None,
2475 ge=0.0,
2476 description="Layer 1 weight on relative MR error squared (code default 1e4 when unset).",
2477 )
2478 layer1_W_OF_low_MR_scale: Optional[float] = Field(
2479 default=None,
2480 ge=0.0,
2481 description=(
2482 "When MR is below optimal_of_ratio, multiply the O/F penalty by max(1, this scale) "
2483 "(default 1: no extra weight on fuel-rich side)."
2484 ),
2485 )
2486 layer1_W_OF_high_MR_scale: Optional[float] = Field(
2487 default=None,
2488 ge=0.0,
2489 description=(
2490 "When MR exceeds optimal_of_ratio, multiply the O/F penalty by max(1, this scale) "
2491 "(default 1: symmetric with low‑MR tuning optional)."
2492 ),
2493 )
2494 layer1_of_validation_tol: Optional[float] = Field(
2495 default=None,
2496 gt=0.0,
2497 description=(
2498 "Relative MR error cap used for Layer‑1 bookkeeping and for ``pressure_candidate_valid`` "
2499 "O/F gate (default 0.15)."
2500 ),
2501 )
2502 layer1_thrust_validation_rel_tol: Optional[float] = Field(
2503 default=None,
2504 gt=0.0,
2505 description=(
2506 "Relative thrust error gate for ``pressure_candidate_valid``. "
2507 "If unset, uses ``tolerances['thrust']`` passed into ``run_layer1_optimization`` (default 10%)."
2508 ),
2509 )
2510 W_CHAMBER_SHAPE: Optional[float] = Field(
2511 default=None,
2512 ge=0.0,
2513 description="Layer 1 weight on chamber-shape regularization (D_chamber/D_throat and L_chamber/D_chamber).",
2514 )
2515 layer1_chamber_dt_ratio_min: Optional[float] = Field(
2516 default=None,
2517 gt=0.0,
2518 description="Preferred lower bound for chamber-to-throat diameter ratio D_chamber_inner/D_throat.",
2519 )
2520 layer1_chamber_dt_ratio_max: Optional[float] = Field(
2521 default=None,
2522 gt=0.0,
2523 description="Preferred upper bound for chamber-to-throat diameter ratio D_chamber_inner/D_throat.",
2524 )
2525 layer1_chamber_ld_ratio_min: Optional[float] = Field(
2526 default=None,
2527 gt=0.0,
2528 description="Preferred lower bound for chamber length ratio L_chamber/D_chamber_inner.",
2529 )
2530 layer1_chamber_ld_ratio_max: Optional[float] = Field(
2531 default=None,
2532 gt=0.0,
2533 description="Preferred upper bound for chamber length ratio L_chamber/D_chamber_inner.",
2534 )
2535
2536 layer1_stagnation_pressure_frac_min: Optional[float] = Field(
2537 default=None,
2538 ge=0.0,
2539 le=1.0,
2540 description=(
2541 "Layer 1 search-box lower edge as a fraction of each tank cap: "
2542 "P_O ∈ [max_lox×f_min, max_lox×f_max], P_F similarly (unless overridden below). "
2543 "Default when unset: 0.35 (the cap is the limit; the old 0.65 left the bottom of "
2544 "the band unsearched)."
2545 ),
2546 )
2547 layer1_stagnation_pressure_frac_max: Optional[float] = Field(
2548 default=None,
2549 ge=0.0,
2550 le=1.0,
2551 description="Companion upper fraction for stagnation-pressure box. Default when unset: 1.0 "
2552 "(the search reaches the cap itself).",
2553 )
2554 layer1_expansion_ratio_min: Optional[float] = Field(
2555 default=None,
2556 gt=0.0,
2557 description="Optional lower bound for Layer 1 expansion-ratio search box (default when unset: 4.0).",
2558 )
2559 layer1_expansion_ratio_max: Optional[float] = Field(
2560 default=None,
2561 gt=0.0,
2562 description="Optional upper bound for Layer 1 expansion-ratio search box (default when unset: 12.0).",
2563 )
2564 layer1_P_O_start_psi_min: Optional[float] = Field(
2565 default=None,
2566 gt=0.0,
2567 description="Optional absolute LOX stagnation pressure lower bound [psi] for Layer 1 (overrides frac box).",
2568 )
2569 layer1_P_O_start_psi_max: Optional[float] = Field(
2570 default=None,
2571 gt=0.0,
2572 description="Optional absolute LOX stagnation upper bound [psi].",
2573 )
2574 layer1_P_F_start_psi_min: Optional[float] = Field(
2575 default=None,
2576 gt=0.0,
2577 description="Optional fuel stagnation lower bound [psi].",
2578 )
2579 layer1_P_F_start_psi_max: Optional[float] = Field(
2580 default=None,
2581 gt=0.0,
2582 description="Optional fuel stagnation upper bound [psi].",
2583 )
2584
2585 # Frozen parameters (optional - for locking specific values during optimization)
2586 frozen_parameters: Optional[FrozenParametersConfig] = Field(
2587 default=None,
2588 description="Optional frozen parameter values for Layer 1 optimization. When set, these values are used instead of being optimized."
2589 )
2590
2591 @model_validator(mode="after")
2592 def _injector_dp_bands_ordered(self):
2593 # Normalize the legacy ΔP/Pc band [0.15, 0.35] -> [0.20, 0.40] (M6 recalibration) on the
2594 # CONFIG object itself, so the optimizer and the UI validation card agree. Previously only
2595 # the optimizer migrated this (injector_dp_bands_from_requirements), while the frontend read
2596 # the raw [0.15, 0.35] from the config -> a design that passed at 0.2-0.4 showed an X against
2597 # the stale 0.15-0.35 display.
2598 # DEF-10: this rewrites a band a user can type on purpose. The shipped configs are migrated
2599 # (default.yaml carries 0.20/0.40 as used); removing the rewrite needs the optimizer's copy
2600 # (injector_dp_penalty.injector_dp_bands_from_requirements) removed in the same change, or
2601 # the UI and the optimizer disagree again. Until then it is at least not silent.
2602 import logging
2603 _LEGACY = (0.15, 0.35)
2604 _NEW = (0.20, 0.40)
2605 if (abs(self.injector_dp_ratio_O_min - _LEGACY[0]) < 1e-9
2606 and abs(self.injector_dp_ratio_O_max - _LEGACY[1]) < 1e-9):
2607 self.injector_dp_ratio_O_min, self.injector_dp_ratio_O_max = _NEW
2608 logging.getLogger(__name__).warning(
2609 "injector_dp_ratio_O band 0.15-0.35 read as the legacy default and replaced by "
2610 "0.20-0.40; the optimizer applies the same rewrite")
2611 if (abs(self.injector_dp_ratio_F_min - _LEGACY[0]) < 1e-9
2612 and abs(self.injector_dp_ratio_F_max - _LEGACY[1]) < 1e-9):
2613 self.injector_dp_ratio_F_min, self.injector_dp_ratio_F_max = _NEW
2614 logging.getLogger(__name__).warning(
2615 "injector_dp_ratio_F band 0.15-0.35 read as the legacy default and replaced by "
2616 "0.20-0.40; the optimizer applies the same rewrite")
2617 if self.injector_dp_ratio_O_max <= self.injector_dp_ratio_O_min:
2618 raise ValueError("injector_dp_ratio_O_max must exceed injector_dp_ratio_O_min")
2619 if self.injector_dp_ratio_F_max <= self.injector_dp_ratio_F_min:
2620 raise ValueError("injector_dp_ratio_F_max must exceed injector_dp_ratio_F_min")
2621 rlo, rhi = self.impinging_momentum_R_min, self.impinging_momentum_R_max
2622 if (rlo is not None) ^ (rhi is not None):
2623 raise ValueError("Set both impinging_momentum_R_min and impinging_momentum_R_max or neither.")
2624 if rlo is not None and rhi is not None and float(rhi) <= float(rlo):
2625 raise ValueError("impinging_momentum_R_max must exceed impinging_momentum_R_min.")
2626 alo, ahi = self.layer1_impinging_angle_deg_min, self.layer1_impinging_angle_deg_max
2627 if (alo is not None) ^ (ahi is not None):
2628 raise ValueError("Set both layer1_impinging_angle_deg_min and layer1_impinging_angle_deg_max or neither.")
2629 if alo is not None and ahi is not None and float(ahi) <= float(alo):
2630 raise ValueError("layer1_impinging_angle_deg_max must exceed layer1_impinging_angle_deg_min.")
2631 fmn, fmx = self.layer1_stagnation_pressure_frac_min, self.layer1_stagnation_pressure_frac_max
2632 if fmn is not None and fmx is not None and float(fmx) <= float(fmn):
2633 raise ValueError("layer1_stagnation_pressure_frac_max must exceed layer1_stagnation_pressure_frac_min.")
2634 ermn, ermx = self.layer1_expansion_ratio_min, self.layer1_expansion_ratio_max
2635 if ermn is not None and ermx is not None and float(ermx) <= float(ermn):
2636 raise ValueError("layer1_expansion_ratio_max must exceed layer1_expansion_ratio_min.")
2637 dtr_lo, dtr_hi = self.layer1_chamber_dt_ratio_min, self.layer1_chamber_dt_ratio_max
2638 if (dtr_lo is not None) ^ (dtr_hi is not None):
2639 raise ValueError("Set both layer1_chamber_dt_ratio_min and layer1_chamber_dt_ratio_max or neither.")
2640 if dtr_lo is not None and dtr_hi is not None and float(dtr_hi) <= float(dtr_lo):
2641 raise ValueError("layer1_chamber_dt_ratio_max must exceed layer1_chamber_dt_ratio_min.")
2642 ldr_lo, ldr_hi = self.layer1_chamber_ld_ratio_min, self.layer1_chamber_ld_ratio_max
2643 if (ldr_lo is not None) ^ (ldr_hi is not None):
2644 raise ValueError("Set both layer1_chamber_ld_ratio_min and layer1_chamber_ld_ratio_max or neither.")
2645 if ldr_lo is not None and ldr_hi is not None and float(ldr_hi) <= float(ldr_lo):
2646 raise ValueError("layer1_chamber_ld_ratio_max must exceed layer1_chamber_ld_ratio_min.")
2647 return self
2648
2649
2650class HybridOptimizerConfig(BaseModel):
2651 """Configuration for Hybrid CMA + Block Re-optimization"""
2652 elite_k: int = Field(default=50, gt=0, description="Size of elite pool for block building")
2653
2654 block_method: Literal["random", "corr_greedy"] = Field(
2655 default="random",
2656 description="Method to build variable blocks: 'random' or 'corr_greedy' (correlation-based)"
2657 )
2658 num_blocks: int = Field(default=3, gt=0, description="Number of blocks")
2659 overlap_fraction: float = Field(default=0.0, ge=0.0, le=0.5, description="Fraction of block indices that overlap with the previous block")
2660
2661 cycles: int = Field(default=3, gt=0, description="Number of re-optimization cycles")
2662
2663 # Soft freezing / Penalty parameters.
2664 #
2665 # NOT WIRED. ``run_hybrid_optimization`` computes ``base_lambda`` and ``f_scale`` from
2666 # these every cycle and then never applies them: the block objective stitches the block's
2667 # coordinates into the incumbent and evaluates the plain objective, with no penalty on
2668 # leaving the incumbent. Blocks are therefore hard-frozen, and changing any of these four
2669 # fields changes nothing. Kept so shipped configs still validate; do not tune them.
2670 lambda0: float = Field(default=1e-3, gt=0, description="Initial penalty weight base (currently unused -- see note above)")
2671 lambda_mult: float = Field(default=10.0, gt=1.0, description="Multiplier for lambda per cycle")
2672 lambda_max: float = Field(default=1.0, gt=0, description="Maximum lambda (relative to f-scale)")
2673 lambda_normalize: bool = Field(default=True, description="Normalize lambda using objective function scale magnitude")
2674
2675 # Budgeting
2676 per_block_budget_fraction: float = Field(default=0.5, gt=0.0, le=0.9, description="Fraction of TOTAL budget allocated to block optimization cycles")
2677
2678 # Global Refresh
2679 refresh_every_pass: bool = Field(default=True, description="Run a global refresh after every full pass of blocks")
2680 refresh_budget_fraction: float = Field(default=0.1, gt=0.0, le=1.0, description="Fraction of initial global budget for each refresh")
2681 refresh_sigma_scale: float = Field(default=0.2, gt=0.0, le=1.0, description="Scale of sigma for refresh relative to initial sigma")
2682
2683 # Multi-track
2684 num_tracks: int = Field(default=1, gt=0, description="Number of independent optimization tracks")
2685
2686
2687class OptimizerConfig(BaseModel):
2688 """Main Optimizer Configuration"""
2689 mode: Literal["cma", "hybrid_cma_blocks"] = Field(
2690 default="cma",
2691 description="Optimization mode: 'cma' (baseline) or 'hybrid_cma_blocks'"
2692 )
2693 hybrid: Optional[HybridOptimizerConfig] = Field(default=None, description="Configuration for hybrid mode")
2694
2695
2696class PressureSegmentConfig(BaseModel):
2697 """Single segment of a pressure curve"""
2698 length_ratio: float = Field(gt=0, le=1, description="Fraction of total burn time for this segment (0-1)")
2699 type: Literal["linear", "blowdown"] = Field(description="Segment type: 'linear' or 'blowdown'")
2700 start_pressure_pa: float = Field(gt=0, description="Pressure at segment start [Pa]")
2701 end_pressure_pa: float = Field(gt=0, description="Pressure at segment end [Pa]")
2702 k: Optional[float] = Field(default=None, gt=0, description="Blowdown parameter k (only used for blowdown type, typically 0.1-2.0)")
2703
2704
2705class PressureCurvesConfig(BaseModel):
2706 """Optimized pressure curves from Layer 2 optimization"""
2707 n_points: int = Field(default=200, gt=0, description="Number of points in the generated pressure curve arrays")
2708 target_burn_time_s: float = Field(gt=0, description="Target burn time [s] used for optimization")
2709 initial_lox_pressure_pa: float = Field(gt=0, description="Initial LOX tank pressure [Pa]")
2710 initial_fuel_pressure_pa: float = Field(gt=0, description="Initial fuel tank pressure [Pa]")
2711 lox_segments: List[PressureSegmentConfig] = Field(description="LOX tank pressure curve segments")
2712 fuel_segments: List[PressureSegmentConfig] = Field(description="Fuel tank pressure curve segments")
2713
2714
2715class MeasuredValue(BaseModel):
2716 """A number measured on hardware, with where it came from. There is no default source."""
2717 value: float = Field(description="The measured value, in the unit its field names.")
2718 uncertainty: Optional[float] = Field(default=None, ge=0.0, description="± one standard uncertainty, same unit.")
2719 source: str = Field(min_length=1, description="Test and how: 'cold flow, water, element 3, 2026-10-02'.")
2720 date: Optional[str] = Field(default=None, description="When it was measured (ISO date).")
2721
2722
2723class MeasurementsConfig(BaseModel):
2724 """Measured values that replace the model's assumptions wherever they enter
2725 (engine/pipeline/measurements.py). Each one turns its ◇ in forward mode into ●."""
2726 cd_O: Optional[MeasuredValue] = Field(default=None, description="Oxidizer orifice Cd (cold flow), replaces the discharge model.")
2727 cd_F: Optional[MeasuredValue] = Field(default=None, description="Fuel orifice Cd (cold flow), replaces the discharge model.")
2728 em: Optional[MeasuredValue] = Field(default=None, description="Element mixing factor E_m (cold-flow patternation), used as measured in place of Rupe's curve.")
2729 d32_O_um: Optional[MeasuredValue] = Field(default=None, description="Oxidizer-stream D32 [µm] at the design point, held at that value.")
2730 d32_F_um: Optional[MeasuredValue] = Field(default=None, description="Fuel-stream D32 [µm] at the design point, held at that value.")
2731 nozzle_efficiency: Optional[MeasuredValue] = Field(default=None, description="Nozzle efficiency ζ_n from measured thrust, replaces the assumed factor.")
2732 chug_frequency_hz: Optional[MeasuredValue] = Field(default=None, description="Chug frequency seen in hot fire [Hz]: shown beside the model's, not fed in.")
2733
2734
2735class PintleEngineConfig(BaseModel):
2736 """Complete pintle engine configuration"""
2737 # Propellant preset name (configs/propellants/<name>.yaml). Resolved by io.load_config BEFORE
2738 # validation: preset supplies fluids/CEA baseline, explicit YAML fields override. Plain str (not
2739 # Literal) on purpose — adding a new propellant must require zero code changes (UNIFICATION P7).
2740 propellant_preset: Optional[str] = Field(default=None, description="Propellant preset to merge (e.g. 'methalox', 'ethalox', 'kerolox'); explicit fields win over preset")
2741 fluids: Dict[str, FluidConfig]
2742 injector: InjectorConfig
2743 feed_system: Dict[str, FeedSystemConfig] # "oxidizer" and "fuel"
2744 regen_cooling: Optional[RegenCoolingConfig] = Field(default=None, description="Regenerative cooling configuration (fuel only)")
2745 film_cooling: Optional[FilmCoolingConfig] = Field(default=None, description="Film cooling configuration")
2746 ablative_cooling: Optional[AblativeCoolingConfig] = Field(default=None, description="Ablative cooling configuration for chamber liner")
2747 graphite_insert: Optional[GraphiteInsertConfig] = Field(default=None, description="Graphite throat insert configuration (separate from chamber ablator)")
2748 stainless_steel_case: Optional[StainlessSteelCaseConfig] = Field(default=None, description="Stainless steel case configuration (structural wall behind ablative/graphite)")
2749 discharge: Dict[str, DischargeConfig] # "oxidizer" and "fuel"
2750 spray: SprayConfig = Field(default_factory=SprayConfig)
2751 combustion: CombustionConfig = Field(default_factory=CombustionConfig)
2752 # Chamber geometry - unified section for solve_chamber_geometry_with_cea
2753 chamber_geometry: Optional[ChamberGeometryConfig] = Field(default=None, description="Unified chamber geometry config (design inputs + solver outputs)")
2754 # Legacy chamber/nozzle sections (for backward compatibility - optional if chamber_geometry is provided)
2755 chamber: Optional[ChamberConfig] = Field(default=None, description="Legacy chamber config (use chamber_geometry instead)")
2756 nozzle: Optional[NozzleConfig] = Field(default=None, description="Legacy nozzle config (use chamber_geometry instead)")
2757 solver: SolverConfig = Field(default_factory=SolverConfig)
2758 stability: StabilityConfig = Field(default_factory=StabilityConfig, description="Combustion / feed stability model inputs (calibration, regulator, acoustic damping)")
2759 measurements: Optional[MeasurementsConfig] = Field(default=None, description="Measured values that replace the model's assumptions (cold flow, hot fire).")
2760 optimizer: Optional[OptimizerConfig] = Field(default=None, description="Optimizer configuration")
2761 # Flight simulation fields (optional)
2762 lox_tank: Optional[LOXTankConfig] = Field(default=None, description="LOX tank configuration for flight simulation")
2763 fuel_tank: Optional[FuelTankConfig] = Field(default=None, description="Fuel tank configuration for flight simulation")
2764 press_tank: Optional[PressTankConfig] = Field(default=None, description="Pressurant tank configuration for flight simulation")
2765 rocket: Optional[RocketConfig] = Field(default=None, description="Rocket configuration for flight simulation")
2766 environment: Optional[EnvironmentConfig] = Field(default=None, description="Environment configuration for flight simulation")
2767 thrust: Optional[ThrustConfig] = Field(default=None, description="Thrust configuration for flight simulation")
2768 design_requirements: Optional[DesignRequirementsConfig] = Field(default=None, description="Design requirements for optimizer")
2769 pressure_curves: Optional[PressureCurvesConfig] = Field(default=None, description="Optimized pressure curves from Layer 2 optimization")
2770 # Provenance stamp: which {injector, propellant} the current chamber was last solved/seeded for.
2771 # Forward mode warns ("needs re-optimization") when the live injector/propellant no longer match —
2772 # see engine.pipeline.config_switch.design_staleness. Set on canonical load & propellant overlay.
2773 design_valid_for: Optional[Dict[str, Optional[str]]] = Field(default=None, description="{'injector','propellant'} the chamber geometry was solved for (forward-mode staleness flag)")
2774
2775 @field_validator("feed_system", "discharge")
2776 @classmethod
2777 def validate_branches(cls, v):
2778 """Ensure both oxidizer and fuel branches are present"""
2779 if "oxidizer" not in v or "fuel" not in v:
2780 raise ValueError("Must specify both 'oxidizer' and 'fuel' branches")
2781 return v
2782
2783 @model_validator(mode="after")
2784 def injector_aware_geometry_cd_default(self):
2785 """FINDING F1 fix (UNIFICATION P3): ``use_geometry_cd`` schema-defaults to True (drilled
2786 impinging orifices). For PINTLE the geometry-Cd correlation doesn't apply (annulus/orifice
2787 Cd is fixed Cd_inf; the solver never calls it) — so when the field was NOT explicitly given,
2788 derive False for pintle. Explicit YAML values are respected (model_fields_set check)."""
2789 if getattr(getattr(self, "injector", None), "type", None) == "pintle":
2790 for side in ("oxidizer", "fuel"):
2791 d = self.discharge.get(side) if isinstance(self.discharge, dict) else None
2792 if d is not None and "use_geometry_cd" not in d.model_fields_set:
2793 d.use_geometry_cd = False
2794 return self
2795
2796 @model_validator(mode="after")
2797 def align_expansion_ratio_with_exit_throat_areas(self):
2798 """Nozzle thrust uses eps == A_exit/A_throat (strict); YAML often labels eps rounded."""
2799 cg = self.chamber_geometry
2800 if cg is None:
2801 return self
2802 at = cg.A_throat
2803 ae = cg.A_exit
2804 if at is None or ae is None:
2805 return self
2806 try:
2807 at_f = float(at)
2808 ae_f = float(ae)
2809 except (TypeError, ValueError):
2810 return self
2811 if not np.isfinite(at_f) or not np.isfinite(ae_f) or at_f <= 0 or ae_f <= 0:
2812 return self
2813 eps_geo = ae_f / at_f
2814 if eps_geo <= 1.0:
2815 return self
2816 cg.expansion_ratio = float(eps_geo)
2817 try:
2818 self.combustion.cea.expansion_ratio = float(eps_geo)
2819 except Exception:
2820 pass
2821 nz = self.nozzle
2822 if nz is not None:
2823 try:
2824 nz.expansion_ratio = float(eps_geo)
2825 except Exception:
2826 pass
2827 return self
2828
2829 @model_validator(mode="after")
2830 def sync_burn_time_fields(self):
2831 """Keep thrust.burn_time and pressure_curves.target_burn_time_s aligned with design_requirements."""
2832 from engine.pipeline.burn_time_sync import sync_burn_time_fields
2833
2834 sync_burn_time_fields(self)
2835 return self
2836
2837 model_config = ConfigDict(extra="allow")
2838
2839
2840def ensure_chamber_geometry(config: PintleEngineConfig) -> ChamberGeometryConfig:
2841 """
2842 Ensure chamber_geometry exists, creating from legacy sections if needed.
2843
2844 This helper function provides backward compatibility by creating chamber_geometry
2845 from legacy chamber/nozzle sections if chamber_geometry doesn't exist.
2846
2847 Parameters:
2848 -----------
2849 config : PintleEngineConfig
2850 Configuration object
2851
2852 Returns:
2853 --------
2854 ChamberGeometryConfig
2855 The chamber_geometry config (created if needed)
2856
2857 Raises:
2858 -------
2859 ValueError
2860 If neither chamber_geometry nor legacy sections exist
2861 """
2862 if config.chamber_geometry is not None:
2863 return config.chamber_geometry
2864
2865 # Create from legacy sections if they exist
2866 if config.chamber is None or config.nozzle is None:
2867 raise ValueError(
2868 "Must provide either 'chamber_geometry' section or both 'chamber' and 'nozzle' sections"
2869 )
2870
2871 # Create chamber_geometry from legacy sections
2872 chamber = config.chamber
2873 nozzle = config.nozzle
2874
2875 # Get design parameters (use defaults if not in legacy sections)
2876 design_pressure = getattr(chamber, 'design_pressure', 2.0e6)
2877 design_thrust = getattr(chamber, 'design_thrust', 5000.0)
2878 design_MR = getattr(chamber, 'design_MR', 2.55)
2879
2880 # Get dimensions
2881 chamber_diameter = getattr(chamber, 'chamber_inner_diameter', 0.08)
2882 Lstar = getattr(chamber, 'Lstar', 1.0)
2883 exit_diameter = getattr(chamber, 'exit_diameter', None) or getattr(nozzle, 'exit_diameter', 0.1)
2884 expansion_ratio = getattr(nozzle, 'expansion_ratio', 8.0)
2885 nozzle_efficiency = getattr(nozzle, 'efficiency', 0.95)
2886
2887 # Get solved outputs (if available)
2888 A_throat = getattr(chamber, 'A_throat', None) or getattr(nozzle, 'A_throat', None)
2889 A_exit = getattr(nozzle, 'A_exit', None)
2890 volume = getattr(chamber, 'volume', None)
2891 length = getattr(chamber, 'length', None)
2892 length_cylindrical = getattr(chamber, 'length_cylindrical', None)
2893 length_contraction = getattr(chamber, 'length_contraction', None)
2894 Cf = getattr(chamber, 'design_force_coefficient', None)
2895
2896 # Create and assign chamber_geometry
2897 config.chamber_geometry = ChamberGeometryConfig(
2898 design_pressure=design_pressure,
2899 design_thrust=design_thrust,
2900 design_MR=design_MR,
2901 chamber_diameter=chamber_diameter,
2902 Lstar=Lstar,
2903 exit_diameter=exit_diameter,
2904 expansion_ratio=expansion_ratio,
2905 nozzle_efficiency=nozzle_efficiency,
2906 A_throat=A_throat,
2907 A_exit=A_exit,
2908 volume=volume,
2909 length=length,
2910 length_cylindrical=length_cylindrical,
2911 length_contraction=length_contraction,
2912 Cf=Cf,
2913 )
2914
2915 return config.chamber_geometry
2916
2917
2918# Injector-agnostic alias (UNIFICATION P3 naming): the config models any bipropellant engine
2919# (pintle / impinging / coaxial), not just pintle. New code should prefer `EngineConfig`; the
2920# `PintleEngineConfig` name is retained for backward compatibility with the 57 existing references.
2921EngineConfig = PintleEngineConfig