STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_new_design_defaults.py
Go to the documentation of this file.
1"""What a user gets from a NEW design: session start, Doublet, Ethalox (DEF-01..12 defaults).
2
3The UI builds a new design through the backend's own sequence: a fresh session loads
4configs/default.yaml, each dropdown is POST /api/config/switch (config_to_dict -> switch_config ->
5PintleEngineConfig), and the Layer 1 "Run" on a pintle design switches to impinging the same way.
6These tests build the design exactly so, and check it against numbers that do not come from this
7codebase: CEA (rocketcea) and CoolProp, quoted with the conditions they were read at.
8"""
9from __future__ import annotations
10
11import copy
12from pathlib import Path
13
14import pytest
15import yaml
16
17from engine.pipeline.config_schemas import DesignRequirementsConfig, PintleEngineConfig
19 design_staleness,
20 infer_preset_from_fluids,
21 load_canonical_config,
22 switch_config,
23)
24from engine.pipeline.io import load_config
25
26REPO = Path(__file__).resolve().parents[1]
27
28# CEA (rocketcea 1.2, equilibrium), LOX/Ethanol, O/F 1.5, Pc 430 psia: Tc 3225.45 K, M 22.25.
29CEA_ETHALOX_OF15_430_TC = 3225.45
30CEA_ETHALOX_OF15_430_R = 373.71
31# CoolProp Oxygen at 90 K: rho*a^2 = 0.938e9 Pa at 0.15 MPa and 0.983e9 at 4 MPa;
32# cp 1698.6 J/(kg K); viscosity 1.957e-4 (0.15 MPa) .. 2.034e-4 (4 MPa) Pa s.
33COOLPROP_LOX_K = (0.938e9, 0.983e9)
34COOLPROP_LOX_CP = 1698.6
35COOLPROP_LOX_MU = (1.957e-4, 2.034e-4)
36
37
38def _dump(path) -> dict:
39 return load_config(str(path)).model_dump(mode="json")
40
41
42def _ui_switch(cfg: dict, **body) -> dict:
43 """POST /api/config/switch as the router runs it."""
44 return PintleEngineConfig(**switch_config(cfg, **body)).model_dump(mode="json")
45
46
48 d = _dump(REPO / "configs" / "default.yaml") # fresh session
49 d = _ui_switch(d, injector_type="impinging") # Injector: Doublet
50 return _ui_switch(d, propellant_preset="ethalox") # Propellant: Ethalox
51
52
53# ---------------------------------------------------------------- propellant switch (DEF-06, DEF-11)
54
57 assert d["propellant_preset"] == "ethalox"
58 assert d["fluids"]["fuel"]["name"] == "Ethanol"
59 assert d["combustion"]["cea"]["fuel_name"] == "Ethanol"
60 assert d["combustion"]["cea"]["MR_range"] == [1.0, 2.5]
61 # methalox's 2.8 is past ethanol's stoichiometric 96/46.07 = 2.08
62 assert d["design_requirements"]["optimal_of_ratio"] == pytest.approx(1.5)
63 assert d["chamber_geometry"]["design_MR"] == pytest.approx(1.5)
64
65
68 smd = d["spray"]["smd"]
69 assert smd["chamber_gas_T"] == pytest.approx(CEA_ETHALOX_OF15_430_TC, rel=0.01)
70 assert smd["chamber_gas_R"] == pytest.approx(CEA_ETHALOX_OF15_430_R, rel=0.01)
71
72
73@pytest.mark.parametrize("preset", ["ethalox", "methalox", "kerolox"])
75 p = yaml.safe_load((REPO / "configs" / "propellants" / f"{preset}.yaml").read_text())
76 lox = p["fluids"]["oxidizer"]
77 lo, hi = COOLPROP_LOX_K
78 assert 0.95 * lo <= lox["bulk_modulus_pa"] <= 1.05 * hi
79 assert lox["specific_heat"] == pytest.approx(COOLPROP_LOX_CP, rel=0.01)
80 assert COOLPROP_LOX_MU[0] * 0.98 <= lox["viscosity"] <= COOLPROP_LOX_MU[1] * 1.02
81
82
84 lox = _new_ethalox_doublet()["fluids"]["oxidizer"]
85 assert lox["bulk_modulus_pa"] == pytest.approx(0.94e9)
86 assert lox["specific_heat"] == pytest.approx(1700.0)
87 assert lox["viscosity"] == pytest.approx(2.0e-4)
88
89
91 d = _dump(REPO / "configs" / "default.yaml")
92 assert d["propellant_preset"] == "methalox"
93 s = switch_config(d, propellant_preset="ethalox")
94 assert "methalox -> ethalox" in (design_staleness(s) or "")
95
96
98 d = _dump(REPO / "configs" / "default.yaml")
99 d["propellant_preset"] = None
100 assert infer_preset_from_fluids(d) == "methalox"
101 s = switch_config(d, propellant_preset="ethalox")
102 assert design_staleness(s) is not None
103 assert s["design_requirements"]["optimal_of_ratio"] == pytest.approx(1.5)
104
105
108 d["design_requirements"]["optimal_of_ratio"] = 1.35
109 s = switch_config(d, propellant_preset="ethalox")
110 assert s["design_requirements"]["optimal_of_ratio"] == pytest.approx(1.35)
111
112
113# ---------------------------------------------------------------- injector switch (DEF-02, DEF-01)
114
116 start = load_canonical_config("pintle") # ethalox, 7000 N, O/F 1.4
117 out = _ui_switch(start, injector_type="impinging")
118 assert out["injector"]["type"] == "impinging"
119 assert out["propellant_preset"] == "ethalox"
120 assert out["fluids"]["fuel"]["name"] == "Ethanol"
121 assert out["combustion"]["cea"]["fuel_name"] == "Ethanol"
122 req = out["design_requirements"]
123 assert req["optimal_of_ratio"] == pytest.approx(start["design_requirements"]["optimal_of_ratio"])
124 assert req["target_thrust"] == pytest.approx(start["design_requirements"]["target_thrust"])
125 assert req["target_burn_time"] == pytest.approx(start["design_requirements"]["target_burn_time"])
126 # the impinging seed was solved for methalox: say so
127 assert "methalox -> ethalox" in (design_staleness(out) or "")
128 # and the doublet gets drilled-hole Cd, not the pintle's 0.40 / 0.65
129 assert out["discharge"]["oxidizer"]["use_geometry_cd"] is True
130 assert out["spray"]["smd"]["model"] == "ingebo"
131
132
134 d = _dump(REPO / "configs" / "ethalox_6500N.yaml")
135 d = _ui_switch(d, injector_type="pintle")
136 d = _ui_switch(d, injector_type="impinging")
137 assert d["propellant_preset"] == "ethalox"
138 assert d["fluids"]["fuel"]["name"] == "Ethanol"
139 assert d["design_requirements"]["target_thrust"] == pytest.approx(6500.0)
140
141
143 d = _dump(REPO / "configs" / "ethalox_6500N.yaml")
144 d["propellant_preset"] = "custom"
145 d["fluids"]["fuel"]["density"] = 801.0 # the user's own ethanol blend
146 out = _ui_switch(d, injector_type="pintle")
147 assert out["propellant_preset"] == "custom"
148 assert out["fluids"]["fuel"]["density"] == pytest.approx(801.0)
149
150
151# ---------------------------------------------------------------- measured Cd survives (DEF-04)
152
154 d = _dump(REPO / "configs" / "ethalox_6500N.yaml")
155 for side, cd in (("oxidizer", 0.72), ("fuel", 0.68)):
156 d["discharge"][side].update(Cd_inf=cd, a_Re=0.05, Cd_min=0.5, use_geometry_cd=False,
157 inlet_geometry=None)
158 d = _ui_switch(d, propellant_preset="kerolox")
159 d = _ui_switch(d, propellant_preset="ethalox")
160 for side, cd in (("oxidizer", 0.72), ("fuel", 0.68)):
161 assert d["discharge"][side]["Cd_inf"] == pytest.approx(cd)
162 assert d["discharge"][side]["a_Re"] == pytest.approx(0.05)
163 assert d["discharge"][side]["Cd_min"] == pytest.approx(0.5)
164
165
166# ---------------------------------------------------------------- one set of defaults (DEF-05, DEF-08)
167
168GUARDS = (
169 "layer1_injector_spray_radius_frac", "layer1_injector_spray_radius_tol",
170 "layer1_injector_plate_thickness_m", "layer1_injector_min_web_m",
171 "layer1_injector_wall_clearance_m", "layer1_injector_min_face_incidence_deg",
172 "layer1_injector_counterbore_dia_m", "layer1_impinging_jet_angle_min_deg",
173 "layer1_max_element_pitch_m",
174)
175
176
178 return _ui_switch(load_canonical_config("impinging"), propellant_preset="ethalox")
179
180
181@pytest.mark.parametrize("build", [_new_ethalox_doublet, _blank_via_canonical])
183 from engine.core.injectors.layout import PLATE_THICKNESS_DEFAULT
184 d = build()
185 req = d["design_requirements"]
186 for k in GUARDS:
187 assert req[k] is not None and req[k] > 0, k
188 assert req["layer1_resultant_tilt_from_reach"] is True
189 # the plate the optimizer checks is the plate the drawing draws
190 assert req["layer1_injector_plate_thickness_m"] == pytest.approx(PLATE_THICKNESS_DEFAULT)
191 assert d["injector"]["plate"]["back"] == "channels"
192 assert d["injector"]["igniter"]["thread"] == "1/2 NPT"
193 # equal-area radius of the bore
194 assert req["layer1_injector_spray_radius_frac"] == pytest.approx(2 ** -0.5, abs=1e-4)
195 # the 6500 N design needs 24 elements
196 assert req["layer1_impinging_n_doublets_max"] >= 24
197
198
200 for f in ("configs/default.yaml", "configs/canonical/impinging.yaml"):
201 req = yaml.safe_load((REPO / f).read_text())["design_requirements"]
202 assert req["W_MOM"] == pytest.approx(75.0), f
203
204
206 a = yaml.safe_load((REPO / "configs/default.yaml").read_text())["design_requirements"]
207 b = yaml.safe_load((REPO / "configs/canonical/impinging.yaml").read_text())["design_requirements"]
208 for k in ("W_MOM", "W_SMD", "layer1_W_OF", "layer1_W_THRUST", "impinging_momentum_R_min",
209 "impinging_momentum_R_max", "layer1_stagnation_pressure_frac_min",
210 "layer1_stagnation_pressure_frac_max", "layer1_impinging_angle_deg_max",
211 "layer1_impinging_n_doublets_max", "injector_dp_ratio_O_min", "injector_dp_ratio_O_max",
212 "injector_dp_ratio_F_min", "injector_dp_ratio_F_max") + GUARDS:
213 assert a[k] == b[k], k
214
215
217 from engine.core.injectors.layout import FACE_HEATING_INCLUDED_DEG
218 for f in ("configs/default.yaml", "configs/canonical/impinging.yaml"):
219 req = yaml.safe_load((REPO / f).read_text())["design_requirements"]
220 assert req["layer1_impinging_angle_deg_max"] <= FACE_HEATING_INCLUDED_DEG, f
221
222
224 """DEF-10: the loader no longer has to rewrite default.yaml's band to make it the one used."""
225 raw = yaml.safe_load((REPO / "configs/default.yaml").read_text())["design_requirements"]
226 cfg = load_config(str(REPO / "configs/default.yaml")).design_requirements
227 for k in ("injector_dp_ratio_O_min", "injector_dp_ratio_O_max",
228 "injector_dp_ratio_F_min", "injector_dp_ratio_F_max"):
229 assert getattr(cfg, k) == pytest.approx(raw[k]), k
230
231
232# ---------------------------------------------------------------- the schema says what the code does
233
234@pytest.mark.parametrize("field, code_default", [
235 ("layer1_stagnation_pressure_frac_min", "0.35"),
236 ("layer1_stagnation_pressure_frac_max", "1.0"),
237 ("layer1_momentum_log_deadband_rel", "0.0"),
238 ("W_MOM", "75"),
239])
241 desc = DesignRequirementsConfig.model_fields[field].description
242 assert f"unset: {code_default}" in desc or f"is {code_default} when unset" in desc, desc
243
244
246 """Fields added tonight by the flight and Layer 1 owners must say what they do."""
247 for k in ("layer1_W_ISP", "layer1_W_MASS", "max_apogee_m", "max_apogee_datum",
248 "min_rail_exit_velocity_m_s", "min_static_margin_cal", "max_static_margin_cal",
249 "regulator_supply_pressure_effect", "regulator_supply_pressure_effect_source",
250 "regulator_min_differential_psi", "layer1_injector_min_back_web_m"):
251 desc = DesignRequirementsConfig.model_fields[k].description or ""
252 assert len(desc) > 40, k
253
254
256 """DEF-10 (partial): until the schema and optimizer rewrites are removed together, the rewrite
257 of a typed 0.15-0.35 band must at least be reported."""
258 import logging
259 with caplog.at_level(logging.WARNING, logger="engine.pipeline.config_schemas"):
260 DesignRequirementsConfig(injector_dp_ratio_O_min=0.15, injector_dp_ratio_O_max=0.35)
261 assert any("0.15-0.35" in r.getMessage() for r in caplog.records)
262
263
265 """CEACache deletes and rebuilds a table whose grid differs from the request; a dropdown
266 toggle must not trigger that (minutes of CEA, and the shipped table gone)."""
267 import engine.pipeline.cea_cache as cc
268 from engine.pipeline.config_switch import derive_chamber_gas
269 opened = []
270
271 class _Spy:
272 def __init__(self, *a, **k):
273 opened.append(a)
274 raise RuntimeError("would have opened the table")
275
277 monkeypatch.setattr(cc, "CEACache", _Spy)
278 d["combustion"]["cea"]["n_points"] = 20 # not the grid on disk (34)
279 before = dict(d["spray"]["smd"])
280 out = derive_chamber_gas(d)
281 assert opened == []
282 assert out["spray"]["smd"]["chamber_gas_T"] == before["chamber_gas_T"]
283 d["combustion"]["cea"]["n_points"] = 34 # the table on disk: it is read
284 derive_chamber_gas(d)
285 assert len(opened) == 1
dict _ui_switch(dict cfg, **body)
test_chamber_gas_derivation_never_opens_a_table_it_would_rebuild(monkeypatch)
test_session_start_config_names_its_propellant_and_the_switch_flags_the_seed()
test_schema_description_states_the_code_fallback(field, code_default)