STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layer1_injector_terms.py
Go to the documentation of this file.
1"""Layer 1 injector terms: ring co-location, vaporization budget, tilt sign, igniter thread,
2closure-plate support, manifold velocity head.
3
4Each expectation is hand arithmetic or an outside number (ASME B1.20.1 L2, Roark Table 11.2,
5NASA TN D-5467 via layout.channel_velocity_head), not a replay of the optimizer.
6"""
7from __future__ import annotations
8
9import math
10from pathlib import Path
11from types import SimpleNamespace
12
13import pytest
14import yaml
15
17from engine.pipeline.io import load_config
18
19ROOT = Path(__file__).resolve().parents[1]
20# The committed config: output/ is gitignored, so a path there exists only on
21# the machine that wrote it and the test failed everywhere else, CI included.
22Y6500 = ROOT / "configs/ethalox_6500N_doublet_2026-09-26.yaml"
23PSI = 6894.757
24IN = 0.0254
25
26
27# ---------------------------------------------------------------------------------------------
28# 1. _impinging_geometry_fit_squared: no ring co-location, no vaporization budget
29# ---------------------------------------------------------------------------------------------
30
32 """The co-location term charged (|D_pitch_O - D_pitch_F| / bore)^2, driving the ring gap --
33 and the impingement standoff dr / (tan th_O + tan th_F) -- to zero."""
34 bore = 0.127
35 kw = dict(D_chamber_inner_m=bore, L_chamber_m=0.15)
36 apart = L1._impinging_geometry_fit_squared({"D_pitch_O": 0.060, "D_pitch_F": 0.090}, **kw)
37 together = L1._impinging_geometry_fit_squared({"D_pitch_O": 0.075, "D_pitch_F": 0.075}, **kw)
38 assert apart == together == 0.0
39 # Not vacuous: the ring-fit term it keeps still charges a ring outside the bore.
40 out = L1._impinging_geometry_fit_squared({"D_pitch_O": 0.060, "D_pitch_F": 0.1397}, **kw)
41 assert out == pytest.approx((0.1397 / bore - 1.0) ** 2, rel=1e-12)
42
43
45 """x* came from a second, cruder evaporation model and double-counted eta_vap (~142 points
46 on the 6500N). A spray three chamber lengths long now costs nothing here."""
47 diag = {"vaporization_length_total": 0.45, "L_imp": 0.006, "x_star": 0.44}
48 assert L1._impinging_geometry_fit_squared(diag, D_chamber_inner_m=0.127, L_chamber_m=0.15) == 0.0
49 assert L1._impinging_geometry_fit_squared(
50 {"L_imp": 0.006, "x_star": 0.44}, D_chamber_inner_m=0.127, L_chamber_m=0.15) == 0.0
51
52
53# ---------------------------------------------------------------------------------------------
54# Tilt sign from the pitch circles, the same rule as the break-even
55# ---------------------------------------------------------------------------------------------
56
58 # LOX-heavy streams at equal angles: the resultant leans toward whichever way LOX travels.
59 return dict(mdot_O=1.80, mdot_F=1.13, rho_O=1141.0, rho_F=789.0, n_elements=24,
60 d_jet_O_m=0.00163, d_jet_F_m=0.00147, angle_O_deg=40.0, angle_F_deg=40.0)
61
62
64 """LOX outboard (s_O > s_F) travels INWARD to the collision, so a LOX-heavy pair leans
65 inward -- whatever layer1_ring_order_fuel_outboard requested."""
66 kw = _tilt_kw()
67 lox_in = L1._impinging_resultant_tilt_deg(**kw, lox_inboard=True)
68 assert lox_in > 0.0
69 lox_out_geom = L1._impinging_resultant_tilt_deg(
70 **kw, lox_inboard=True, spacing_O_m=0.0118, spacing_F_m=0.0091)
71 assert lox_out_geom == pytest.approx(-lox_in, rel=1e-12)
72 lox_in_geom = L1._impinging_resultant_tilt_deg(
73 **kw, lox_inboard=False, spacing_O_m=0.0091, spacing_F_m=0.0118)
74 assert lox_in_geom == pytest.approx(lox_in, rel=1e-12)
75
76
78 """Break-even takes theta of the inner ring (smaller D_pitch); the tilt's sign must use the
79 same ring. Swapping the spacings swaps both."""
80 n = 24
81 for sO, sF in ((0.0091, 0.0118), (0.0118, 0.0091)):
82 inner_is_O = n * sO / math.pi <= n * sF / math.pi
83 assert L1._lox_ring_inboard(sO, sF) is inner_is_O
84 be_O = L1._resultant_tilt_breakeven_deg(
85 n_elements=n, spacing_O_m=sO, spacing_F_m=sF, angle_O_deg=30.0, angle_F_deg=50.0,
86 D_chamber_inner_m=0.127, L_chamber_m=0.15)
87 dr = 0.5 * abs(n * (sO - sF) / math.pi)
88 L_imp = dr / (math.tan(math.radians(30.0)) + math.tan(math.radians(50.0)))
89 th_in = 30.0 if inner_is_O else 50.0
90 r_imp = 0.5 * n * min(sO, sF) / math.pi + L_imp * math.tan(math.radians(th_in))
91 assert be_O == pytest.approx(math.degrees(math.atan2(0.0635 - r_imp, 0.15)), rel=1e-12)
92 t = L1._impinging_resultant_tilt_deg(**_tilt_kw(), spacing_O_m=sO, spacing_F_m=sF)
93 assert (t > 0) is inner_is_O
94
95
96# ---------------------------------------------------------------------------------------------
97# 3. Closure plate: support condition and plug radius
98# ---------------------------------------------------------------------------------------------
99
101 """Roark Table 11.2, uniform load: simply supported k = 3(3+nu)/8 (centre), fixed k = 3/4
102 (edge), sigma_max = k p a^2 / t^2."""
103 assert L1._closure_plate_stress_coeff("simply_supported", 0.3) == pytest.approx(1.2375, rel=1e-12)
104 assert L1._closure_plate_stress_coeff("simply_supported", 0.25) == pytest.approx(3 * 3.25 / 8)
105 assert L1._closure_plate_stress_coeff("clamped") == 0.75
106 assert L1._closure_plate_stress_coeff("fixed") == 0.75
107 with pytest.raises(ValueError):
108 L1._closure_plate_stress_coeff("bolted")
109
110
112 a_bore, a_plug, p, sig, rho, wall = 0.0635, 0.0762, 2.2e6, 205e6, 3400.0, 0.0381
113 A_c = math.pi * a_bore ** 2
114 barrel = L1._layer1_chamber_mass_kg(A_c, 0.1, wall, rho, Pc_pa=0.0)
115 m = L1._layer1_chamber_mass_kg(A_c, 0.1, wall, rho, Pc_pa=p,
116 plate_support="simply_supported", r_plate_m=a_plug)
117 t = a_plug * math.sqrt(3.0 * 3.3 / 8.0 * p / sig)
118 assert m - barrel == pytest.approx(math.pi * a_plug ** 2 * t * rho, rel=1e-12)
119 # Previous behaviour, exactly, when neither is passed: fixed edge at the bore.
120 m0 = L1._layer1_chamber_mass_kg(A_c, 0.1, wall, rho, Pc_pa=p)
121 t0 = a_bore * math.sqrt(0.75 * p / sig)
122 assert m0 - barrel == pytest.approx(math.pi * a_bore ** 2 * t0 * rho, rel=1e-12)
123
124
126 req = {}
127 bare = SimpleNamespace(injector=SimpleNamespace(plate=SimpleNamespace(
128 model_dump=lambda: {"back": "channels"})), discharge={})
129 assert L1._layer1_injector_plate_constants(bare, req)["layer1_injector_plate_support"] == "simply_supported"
130 declared = SimpleNamespace(injector=SimpleNamespace(plate=SimpleNamespace(
131 support="clamped", model_dump=lambda: {"back": "channels", "support": "clamped"})), discharge={})
132 assert L1._layer1_injector_plate_constants(declared, req)["layer1_injector_plate_support"] == "clamped"
133
134
135# ---------------------------------------------------------------------------------------------
136# 2 and 4. Sign-off gates on the 6500N doublet: igniter thread, manifold velocity head
137# ---------------------------------------------------------------------------------------------
138
139@pytest.fixture(scope="module")
141 from engine import accel
142 real = accel.enabled, accel.require
143 accel.enabled = lambda: False
144 accel.require = lambda: False
145 try:
146 yield
147 finally:
148 accel.enabled, accel.require = real
149
150
151def _load(tmp_path: Path, edit=None):
152 d = yaml.safe_load(Y6500.read_text())
153 if edit:
154 edit(d)
155 p = tmp_path / "cfg.yaml"
156 p.write_text(yaml.safe_dump(d))
157 cfg = load_config(str(p))
158 return cfg, cfg.design_requirements.model_dump()
159
160
161@pytest.fixture(scope="module")
162def solved(_python_physics):
163 from engine.core.runner import PintleEngineRunner
164 cfg = load_config(str(Y6500))
165 P = 529.0414683968598 * PSI
166 return PintleEngineRunner(cfg).evaluate(P_tank_O=P, P_tank_F=P)
167
168
170 """1/2 NPT: L2 = 0.5337 in = 13.56 mm (ASME B1.20.1) against a 0.500 in (12.70 mm) plate."""
171 cfg, req = _load(tmp_path)
172 assert req["layer1_injector_plate_thickness_m"] == pytest.approx(0.5 * IN)
173 ok, msg = L1._layer1_declared_limit_gates(cfg, req, {})["igniter_engagement"]
174 assert not ok and "13.56 mm" in msg and "12.70 mm" in msg
175
176 def hub(d):
177 d["injector"]["igniter"]["hub_thickness"] = 0.014
178 cfg, req = _load(tmp_path, hub)
179 ok, _ = L1._layer1_declared_limit_gates(cfg, req, {})["igniter_engagement"]
180 assert ok
181
182
183def _q_hand(area, mdot, rho, dp, inlets):
184 v = mdot / (2.0 * inlets) / (rho * area)
185 return 0.5 * rho * v * v / dp
186
187
189 from engine.core.injectors.layout import MANIFOLD_Q_FRAC, layout_from_config
190 cfg, req = _load(tmp_path)
191 gates = L1._layer1_declared_limit_gates(cfg, req, {}, performance=solved)
192 ok_O, msg_O = gates["manifold_q_O"]
193 assert not ok_O and "1 feed port" in msg_O
194 dg = solved["diagnostics"]
195 flows = {"mdot_O": solved["mdot_O"], "mdot_F": solved["mdot_F"],
196 "dp_O": dg["delta_p_injector_O"], "dp_F": dg["delta_p_injector_F"]}
197 lay = layout_from_config(yaml.safe_load(Y6500.read_text()), drawings=False, flows=flows)
198 A_O = lay["passages"]["O"]["channel"]["flow_area"]
199 assert A_O * 1e6 == pytest.approx(19.3, abs=0.5) # the drawn LOX channel
200 assert f"channel {A_O * 1e6:.1f} mm^2" in msg_O # Layer 1 sizes the same channel
201 rho_O = cfg.fluids["oxidizer"].density
202 q = _q_hand(A_O, solved["mdot_O"], rho_O, dg["delta_p_injector_O"], 1)
203 assert q > MANIFOLD_Q_FRAC
204 assert lay["passages"]["O"]["channel"]["q_over_dp"] == pytest.approx(q, rel=1e-9)
205
206 def four(d):
207 d["injector"]["plate"]["channel_inlets"] = 4
208 cfg4, req4 = _load(tmp_path, four)
209 gates4 = L1._layer1_declared_limit_gates(cfg4, req4, {}, performance=solved)
210 assert gates4["manifold_q_O"][0] and gates4["manifold_q_F"][0]
211 assert _q_hand(A_O, solved["mdot_O"], rho_O, dg["delta_p_injector_O"], 4) == pytest.approx(q / 16)
212
213
215 cfg, req = _load(tmp_path)
216 c = L1._layer1_injector_plate_constants(cfg, req)
217 g = cfg.injector.geometry
218 dg = solved["diagnostics"]
219 kw = dict(n_elements=g.oxidizer.n_elements, spacing_O_m=g.oxidizer.spacing,
220 spacing_F_m=g.fuel.spacing, d_jet_O_m=g.oxidizer.d_jet, d_jet_F_m=g.fuel.d_jet,
221 angle_O_deg=g.oxidizer.impingement_angle, angle_F_deg=g.fuel.impingement_angle,
222 plate_thickness_m=c["layer1_injector_plate_thickness_m"],
223 face_contoured=c["layer1_injector_face_contoured"],
224 exit_land=c["layer1_injector_exit_land"],
225 passage_ld_O=c["layer1_injector_land_ld_O"], passage_ld_F=c["layer1_injector_land_ld_F"],
226 channel_width_m=c["layer1_injector_channel_width_m"],
227 channel_floor=c["layer1_injector_channel_floor"],
228 mdot_O=solved["mdot_O"], mdot_F=solved["mdot_F"],
229 dp_O=dg["delta_p_injector_O"], dp_F=dg["delta_p_injector_F"],
230 rho_O=cfg.fluids["oxidizer"].density, rho_F=cfg.fluids["fuel"].density)
231 q1 = L1._impinging_manifold_q(**kw, channel_inlets=1)
232 v1 = L1._impinging_manifold_violation(q1)
233 hand = sum(max(0.0, h["area_needed"] / h["flow_area"] - 1.0) ** 2 for h in q1.values())
234 assert v1 == pytest.approx(hand, rel=1e-12) and v1 > 0.0
235 assert L1._impinging_manifold_violation(L1._impinging_manifold_q(**kw, channel_inlets=4)) == 0.0
236 # No flows (a failed solve) => nothing to charge.
237 assert L1._impinging_manifold_q(**dict(kw, mdot_O=None, mdot_F=None), channel_inlets=1) == {}
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
_q_hand(area, mdot, rho, dp, inlets)
test_manifold_gate_fails_one_port_and_passes_four(tmp_path, solved)
test_manifold_search_term_is_the_area_shortfall(tmp_path, solved)