STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_transients.py
Go to the documentation of this file.
1"""Layer X transient diagnostics against closed forms (engine/layerx/diag/{start,shutdown,waterhammer,outflow}.py).
2
3Every test here checks the code against a hand calculation or a closed-form solution, never against
4the code itself. The LE4 cases at the bottom need a prepared Layer X burn and are skipped unless
5``LAYERX_GOLDEN=1``.
6"""
7
8from __future__ import annotations
9
10import math
11import os
12
13import pytest
14from scipy.optimize import brentq
15
16from engine.layerx.diag import start as S
17
18PSI = 6894.757293168361
19
20
21# ------------------------------------------------------------------ start: the column
22
23
25 """I dm/dt = dp with no loss: m = dp t / I, exactly, at any step."""
26 I, dp, dt = 1495.0, 3.9e6, 1e-4
27 m = 0.0
28 for _ in range(100):
29 m = S.column_step(m, dp, I, 0.0, dt)
30 assert m == pytest.approx(dp * 100 * dt / I, rel=1e-12)
31
32
34 """I dm/dt = dp - R m^2 from rest: m = m_inf tanh(t/tau), m_inf = sqrt(dp/R), tau = I/sqrt(R dp)."""
35 I, R, dp, dt = 10297.0, 1.4e6, 3.9e6, 1e-6
36 m_inf, tau = math.sqrt(dp / R), I / math.sqrt(R * dp)
37 m, t = 0.0, 0.0
38 checks = {round(k * tau / 2, 9) for k in (1, 2, 4, 8)}
39 seen = 0
40 for n in range(1, int(4.0 * tau / dt) + 2):
41 m = S.column_step(m, dp, I, R, dt)
42 t = n * dt
43 for tc in checks:
44 if abs(t - tc) < dt / 2:
45 assert m == pytest.approx(m_inf * math.tanh(t / tau), rel=2e-3)
46 seen += 1
47 assert seen == len(checks)
48
49
51 assert S.column_step(0.3, 4.0e6, 0.0, 1.0e6, 1e-3) == pytest.approx(2.0)
52 assert S.column_step(0.3, -4.0e6, 0.0, 1.0e6, 1e-3) == pytest.approx(-2.0)
53
54
56 """dPc/dt = (RT/V)(m - Pc At/c*), RT = (Gamma c*)^2: a first-order lag with tau = V/(Gamma^2 c* At)."""
57 V, At, cs, gam, m, pa = 2.44e-3, 1.795e-3, 1575.0, 1.135, 3.07, 94000.0
58 G = S.gamma_function(gam)
59 # Gamma by hand for gamma = 1.135 (the ideal-rocket mass-flow function): sqrt(1.135) * (2/2.135)^(2.135/0.27)
60 assert G == pytest.approx(math.sqrt(1.135) * (2 / 2.135) ** (2.135 / 0.27), rel=1e-12)
61 rt_v = (G * cs) ** 2 / V
62 tau = V / (G * G * cs * At)
63 pc_ss = m * cs / At
64 pc, dt = pa, tau / 2000
65 for _ in range(2000): # one tau
66 pc = S.chamber_step(pc, m, rt_v, At / cs, dt)
67 assert pc == pytest.approx(pc_ss + (pa - pc_ss) * math.exp(-1.0), rel=2e-3)
68 # LE4's chamber constant, AUDIT 9.5 §1: ~2.1 ms
69 assert 1.5e-3 < tau < 2.5e-3
70
71
72# ------------------------------------------------------------------ start: the whole model
73
74
75def _const(v):
76 return lambda mr, m: v
77
78
79def _chamber(**kw):
80 base = dict(volume=2.44e-3, throat_area=1.795e-3, exit_area=8.66e-3, ambient_pa=94069.7, gamma=1.135,
81 cstar=_const(1570.0), vvac=_const(2320.0), mr_design=1.52)
82 base.update(kw)
83 return S.StartChamber(**base)
84
85
86def _line(key, **kw):
87 base = dict(key=key, rho=1150.0 if key == "ox" else 792.8, p_tank=578.0 * PSI, I_up=747.0, R_up=2.9e4,
88 R_valve_open=2213.0, travel_s=0.05, t_open=0.0, I_dn=747.0, V_dn_line=6.6e-6, R_dn=1.0e4,
89 V_manifold=1.8e-5, A_exit=9.37e-5, I_inj=163.0, phi=1.73e-3)
90 base.update(kw)
91 return S.StartLine(**base)
92
93
95 ox = _line("ox", V_dn_line=0.0, V_manifold=0.0)
96 fu = _line("fuel", V_dn_line=0.0, V_manifold=0.0, t_open=-0.02)
97 run = S.run_start(ox, fu, _chamber(), dt=2e-5, horizon_s=0.05)
98 assert run["t_prime"][0] == pytest.approx(0.0, abs=1e-12)
99 assert run["t_prime"][1] == pytest.approx(-0.02, abs=1e-12)
100 assert run["t_ignition"] == pytest.approx(0.0, abs=1e-12)
101
102
104 """No loss, no valve travel, no dump: m = dp t/I, so the volume V fills at t = sqrt(2 I rho V / dp),
105 and the column keeps ramping at dp/I once it is primed (no injector loss, no ignition)."""
106 I, rho, V, dp = 1495.0, 1150.0, 2.4e-5, 578.0 * PSI - 94069.7
107 ox = _line("ox", I_up=I, R_up=0.0, R_valve_open=0.0, travel_s=0.0, I_dn=0.0, V_dn_line=0.0, R_dn=0.0,
108 V_manifold=V, K_exit_prime=0.0, I_inj=0.0, phi=math.inf)
109 fu = _line("fuel", t_open=math.inf)
110 run = S.run_start(ox, fu, _chamber(), dt=1e-5, horizon_s=0.02)
111 assert not run["ignited"]
112 assert run["t_prime"][0] == pytest.approx(math.sqrt(2 * I * rho * V / dp), rel=1e-3)
113 t_last, _, m_inj, _ = run["trace"][-1]
114 assert run["line_end"][0] == pytest.approx(dp * t_last / I, rel=1e-6)
115 assert m_inj == pytest.approx(dp * t_last / I, rel=1e-6)
116
117
119 """No inertance upstream, no loss but the K velocity heads at the front: the growing column starts
120 at its terminal velocity v = sqrt(2 dp / (rho K)) (Liou & Hunt's rigid-column equation with
121 l = 0) and fills a tube of length L at t = L / v."""
122 rho, A, L = 1150.0, math.pi * 10.92e-3 ** 2 / 4, 0.5
123 dp = 578.0 * PSI - 94069.7
124 for K in (1.0, 2.0):
125 ox = _line("ox", rho=rho, I_up=0.0, R_up=0.0, R_valve_open=0.0, travel_s=0.0, I_dn=L / A, V_dn_line=A * L,
126 R_dn=0.0, V_manifold=0.0, A_exit=A, K_exit_prime=K, I_inj=0.0, phi=math.inf)
127 run = S.run_start(ox, _line("fuel", t_open=math.inf), _chamber(), dt=1e-6, horizon_s=0.02)
128 assert run["t_prime"][0] == pytest.approx(L / math.sqrt(2 * dp / (rho * K)), rel=1e-3)
129 assert run["arrival_mdot"][0] == pytest.approx(rho * A * math.sqrt(2 * dp / (rho * K)), rel=1e-6)
130
131
133 """No loss, a column of fixed length L_u filling a tube of the same bore: rho (L_u + x) dv/dt = dp,
134 so v^2 = (2 dp / rho) ln((L_u + x)/L_u) and the tube of length L_d fills at
135 t = L_u sqrt(rho / (2 dp)) sqrt(pi) erfi(sqrt(ln((L_u + L_d)/L_u)))."""
136 from scipy.special import erfi
137
138 rho, A, Lu, Ld = 1150.0, math.pi * 10.92e-3 ** 2 / 4, 0.14, 0.5
139 dp = 578.0 * PSI - 94069.7
140 ox = _line("ox", rho=rho, I_up=Lu / A, R_up=0.0, R_valve_open=0.0, travel_s=0.0, I_dn=Ld / A, V_dn_line=A * Ld,
141 R_dn=0.0, V_manifold=0.0, A_exit=A, K_exit_prime=0.0, I_inj=0.0, phi=math.inf)
142 run = S.run_start(ox, _line("fuel", t_open=math.inf), _chamber(), dt=1e-6, horizon_s=0.02)
143 t_fill = Lu * math.sqrt(rho / (2 * dp)) * math.sqrt(math.pi) * erfi(math.sqrt(math.log((Lu + Ld) / Lu)))
144 assert run["t_prime"][0] == pytest.approx(t_fill, rel=2e-3)
145 v_end = math.sqrt(2 * dp / rho * math.log((Lu + Ld) / Lu))
146 assert run["arrival_mdot"][0] == pytest.approx(rho * A * v_end, rel=2e-3)
147
148
150 """Primed at Fire, no loss: the whole column, tube and orifice passages, ramps at dp / (I_up + I_dn + I_inj)."""
151 dp = 578.0 * PSI - 94069.7
152 ox = _line("ox", R_up=0.0, R_valve_open=0.0, travel_s=0.0, R_dn=0.0, V_dn_line=0.0, V_manifold=0.0,
153 I_up=747.0, I_dn=747.0, I_inj=163.0, phi=math.inf)
154 run = S.run_start(ox, _line("fuel", t_open=math.inf), _chamber(), dt=1e-5, horizon_s=0.01)
155 t_last = run["trace"][-1][0]
156 assert run["line_end"][0] == pytest.approx(dp * t_last / (747.0 + 747.0 + 163.0), rel=1e-9)
157
158
160 """With the valves full open, the start settles on m_s = sqrt((p_T - Pc)/R_s) and Pc At/c* = m_o + m_f."""
161 ox, fu = _line("ox"), _line("fuel", I_up=9700.0, phi=1.19e-3)
162 ch = _chamber()
163 run = S.run_start(ox, fu, ch, dt=1e-5, horizon_s=0.15)
164
165 def r_tot(ln):
166 return ln.R_up + ln.R_valve_open + ln.R_dn + 1.0 / ln.phi ** 2
167
168 def g(pc):
169 mo = math.sqrt((ox.p_tank - pc) / r_tot(ox))
170 mf = math.sqrt((fu.p_tank - pc) / r_tot(fu))
171 return pc * ch.throat_area / 1570.0 - (mo + mf)
172
173 pc = brentq(g, 1e5, 578.0 * PSI - 1.0)
174 assert run["pc_end"] == pytest.approx(pc, rel=1e-4)
175 assert run["mdot_end"][0] == pytest.approx(math.sqrt((ox.p_tank - pc) / r_tot(ox)), rel=1e-4)
176 assert run["F_end"] == pytest.approx(2320.0 * pc * ch.throat_area / 1570.0 - ch.ambient_pa * ch.exit_area,
177 rel=1e-4)
178 assert run["t_settle"] is not None and run["t_settle"] < 0.1
179
180
182 """Both primed at Fire (no gas volume), ignition delayed by tau: the pair injected before ignition,
183 burned at once in the chamber volume, gives a spike m (Gamma c*)^2 / V."""
184 tau = 4e-3
185 ox = _line("ox", V_dn_line=0.0, V_manifold=0.0, travel_s=0.0)
186 fu = _line("fuel", V_dn_line=0.0, V_manifold=0.0, travel_s=0.0, I_up=9700.0, phi=1.19e-3)
187 ch = _chamber(ignition_delay_s=tau)
188 run = S.run_start(ox, fu, ch, dt=1e-6, horizon_s=0.02)
189 mo, mf = run["pre_ignition_kg"]
190 pair = min(mo, 1.52 * mf) * (1 + 1 / 1.52)
191 assert run["pair_kg"] == pytest.approx(pair, rel=1e-9)
192 G = S.gamma_function(1.135)
193 assert run["spike_pa"] == pytest.approx(pair * (G * 1570.0) ** 2 / 2.44e-3, rel=1e-9)
194 assert run["t_ignition"] == pytest.approx(tau, abs=1e-12)
195 # the pre-ignition masses are the flows integrated over tau (ox: quasi-steady at full open)
196 m_ox_q = math.sqrt((ox.p_tank - ch.ambient_pa) / (ox.R_up + ox.R_valve_open + ox.R_dn + 1 / ox.phi ** 2))
197 assert mo < m_ox_q * tau and mo > 0.3 * m_ox_q * tau
198
199
201 F, mo, mf = 6750.0, 1.85, 1.22
202 c = F / (mo + mf)
203 mr = mo / mf
204 dt = 1e-4
205
206 def trace(delay, lead=0.0, q_lead=0.0):
207 out = []
208 t = -lead
209 while t <= 0.2 + 1e-12:
210 if t < 0.0:
211 out.append((t, 0.0, 0.0, q_lead))
212 elif t < delay:
213 out.append((t, 0.0, 0.0, 0.0))
214 else:
215 out.append((t, F, mo, mf))
216 t += dt
217 return out
218
219 kw = dict(F_settled=F, mdot_settled=(mo, mf), c_end=c, mr_end=mr)
220 # A start that is already the main burn costs nothing.
221 a = S.fixed_load_deficit(trace=trace(0.0), t_window=0.1, residual_extra=(0.0, 0.05), **kw)
222 assert a["deficit_Ns"] == pytest.approx(0.0, abs=1e-6)
223 # A pure delay costs F*delay over the window, all of it recovered at the end of a fixed load.
224 b = S.fixed_load_deficit(trace=trace(0.02), t_window=0.1, residual_extra=(0.0, 0.05), **kw)
225 assert b["window_deficit_Ns"] == pytest.approx(F * 0.02, abs=F * dt) # trapezoid over the step edge
226 assert b["deficit_Ns"] == pytest.approx(0.0, abs=0.5)
227 # A fuel lead of q*L kg with no thrust: free while the LOX-limited burn strands more fuel than that ...
228 c1 = S.fixed_load_deficit(trace=trace(0.0, lead=0.02, q_lead=2.0), t_window=0.1, residual_extra=(0.0, 0.05), **kw)
229 assert c1["deficit_Ns"] == pytest.approx(0.0, abs=0.5)
230 # ... and (1 + O/F) c (qL - r) once it eats past the stranded fuel.
231 c2 = S.fixed_load_deficit(trace=trace(0.0, lead=0.2, q_lead=2.0), t_window=0.1, residual_extra=(0.0, 0.05), **kw)
232 assert c2["deficit_Ns"] == pytest.approx(c * (1 + mr) * (2.0 * 0.2 - 0.05), rel=2e-3)
233
234
235# ------------------------------------------------------------------ shutdown
236
237
238from engine.layerx.diag import shutdown as SD # noqa: E402
239
240
241def _tail(key, **kw):
242 base = dict(key=key, rho=1150.0 if key == "ox" else 792.8, mdot0=1.9 if key == "ox" else 1.3,
243 V_up=7.6e-6, V_dn=2.4e-5, travel_s=0.05, R_valve_open=2213.0, R_rest=3.6e5)
244 base.update(kw)
245 return SD.TailSide(**base)
246
247
249 dt = 1e-5
250 a = _tail("ox", travel_s=0.0)
251 b = _tail("fuel", travel_s=0.0, V_dn=4.7e-5)
252 tail = SD.tail_off(a, b, t0=1.0, t_cmd=1.0, dt=dt)
253 for s in (a, b):
254 side = tail["sides"][s.key]
255 assert side["delivered_kg"] == pytest.approx(s.rho * s.V_dn, rel=1e-9)
256 assert side["t_end"] == pytest.approx(1.0 + s.rho * s.V_dn / s.mdot0, abs=2 * dt)
257
258
260 dt = 1e-5
261 a = _tail("ox", wet=False)
262 tail = SD.tail_off(a, _tail("fuel"), t0=0.0, t_cmd=10.0, dt=dt, horizon_s=0.0)
263 side = tail["sides"]["ox"]
264 stock = a.rho * (a.V_up + a.V_dn)
265 assert side["delivered_kg"] == pytest.approx(stock, rel=1e-9)
266 assert side["t_end"] == pytest.approx(stock / a.mdot0, abs=2 * dt)
267
268
270 """Valve the only loss, linear Cv: the quasi-steady flow falls linearly with travel, so the
271 closure passes m0 * travel / 2, then the downstream liquid drains."""
272 dt = 1e-6
273 s = _tail("fuel", R_rest=0.0)
274 assert s.closing_fraction(0.3) == pytest.approx(0.3, rel=1e-12)
275 tail = SD.tail_off(s, _tail("ox"), t0=0.0, t_cmd=0.0, dt=dt)
276 assert tail["sides"]["fuel"]["delivered_kg"] == pytest.approx(s.mdot0 * s.travel_s / 2 + s.rho * s.V_dn,
277 rel=1e-4)
278 # with the rest of the line in series the valve takes over only near the seat
279 s2 = _tail("fuel")
280 x = 0.1
281 assert s2.closing_fraction(x) == pytest.approx(
282 math.sqrt((s2.R_rest + s2.R_valve_open) / (s2.R_rest + s2.R_valve_open / x ** 2)), rel=1e-12)
283
284
286 """LOX runs dry with no downstream volume and a late command D: LOX ends at rho V_up / m_ox, fuel
287 flows until D then drains rho_f V_dn_f. Fuel alone = m_f (D - t_ox) + rho_f V_dn_f."""
288 dt, D = 1e-5, 0.08
289 ox = _tail("ox", wet=False, V_dn=0.0, travel_s=0.0)
290 fu = _tail("fuel", travel_s=0.0, V_dn=4.7e-5)
291 tail = SD.tail_off(ox, fu, t0=0.0, t_cmd=D, dt=dt)
292 cls = SD.classify(tail)
293 t_ox = ox.rho * ox.V_up / ox.mdot0
294 assert cls["mode"] == "fuel-rich" and cls["lox_alone_kg"] == 0.0
295 assert cls["end_ox_s"] == pytest.approx(t_ox, abs=2 * dt)
296 assert cls["alone_kg"] == pytest.approx(fu.mdot0 * (D - t_ox) + fu.rho * fu.V_dn, rel=2e-3)
297 assert cls["tail_mr_max"] == pytest.approx(ox.rho * ox.V_up / (fu.mdot0 * D + fu.rho * fu.V_dn), rel=2e-3)
298 # The other way round: fuel dry first and a late command, so LOX flows alone (LOX-rich).
299 ox2 = _tail("ox", travel_s=0.0, V_dn=2.4e-5)
300 fu2 = _tail("fuel", wet=False, travel_s=0.0, V_up=1e-5, V_dn=0.0)
301 cls2 = SD.classify(SD.tail_off(ox2, fu2, t0=0.0, t_cmd=D, dt=dt))
302 t_fu = fu2.rho * fu2.V_up / fu2.mdot0
303 assert cls2["mode"] == "LOX-rich"
304 assert cls2["lox_alone_kg"] == pytest.approx(ox2.mdot0 * (D - t_fu) + ox2.rho * ox2.V_dn, rel=2e-3)
305 assert SD._note("LOX-rich", cls2)["grade"] == "warn"
306
307
308# ------------------------------------------------------------------ water hammer
309
310
311from engine.layerx.diag import waterhammer as WH # noqa: E402
312
313# LOX at 3.985 MPa, 90 K (CoolProp 7.2.0 through feedtwin Fluid('oxygen')): rho, sound speed.
314LOX_RHO, LOX_A = 1150.5066, 924.2572
315
316
318 """AUDIT 9.5 §5 by hand: LOX in 1/2 x 0.035 in 316 tube, a = 897 m/s."""
319 a = WH.wave_speed(LOX_A, LOX_RHO, 10.92e-3, 0.889e-3, 193e9, 1.0)
320 assert a == pytest.approx(897.0, abs=1.5)
321 assert WH.wave_speed(LOX_A, LOX_RHO, 10.92e-3, 0.889e-3, math.inf) == LOX_A
322 # Joukowsky at the audit's v = 17.4 m/s: 2607 psi
323 assert WH.joukowsky(LOX_RHO, 897.0, 17.4) / PSI == pytest.approx(2607.0, rel=2e-3)
324
325
326def _pipe(**kw):
327 base = dict(rho=LOX_RHO, a=897.0, bore=10.92e-3, length=0.9, p_tank=578.0 * PSI, mdot0=1.9)
328 base.update(kw)
329 return WH.Pipeline(**base)
330
331
333 pipe = _pipe(R_entry=2.0e4, R_friction=1.0e4)
334 sim = WH.moc(pipe, t_end=0.01, valve_R=lambda t: 3.6e5)
335 assert sim["p_max"] - sim["p_min"] < 1e-6 * sim["p0_valve"]
336
337
339 pipe = _pipe()
340 v0 = pipe.mdot0 / (pipe.rho * pipe.area)
341 sim = WH.moc(pipe, t_end=8 * pipe.length / pipe.a, valve_R=lambda t: math.inf if t > 0 else 3.6e5,
342 R_down0=3.6e5)
343 assert sim["p_max"] - sim["p0_valve"] == pytest.approx(pipe.rho * pipe.a * v0, rel=1e-9)
344 # and the line rings as a square wave of period 4L/a: +J for 0 < t < 2L/a, -J for 2L/a < t < 4L/a
345 J, T = pipe.rho * pipe.a * v0, 4 * pipe.length / pipe.a
346 for t, p in zip(sim["t"], sim["p_valve"]):
347 if 0.05 * T < t < 0.45 * T:
348 assert p - sim["p0_valve"] == pytest.approx(J, rel=1e-9)
349 elif 0.55 * T < t < 0.95 * T:
350 assert p - sim["p0_valve"] == pytest.approx(-J, rel=1e-9)
351
352
354 """Frictionless, the velocity at the valve brought down linearly over t_c > 2L/a: peak 2 rho L v0 / t_c."""
355 pipe = _pipe()
356 tc = 10 * 2 * pipe.length / pipe.a
357 sim = WH.moc(pipe, t_end=2 * tc, n_reaches=32,
358 valve_flow=lambda t: pipe.mdot0 * max(1.0 - t / tc, 0.0))
359 v0 = pipe.mdot0 / (pipe.rho * pipe.area)
360 assert sim["p_max"] - sim["p0_valve"] == pytest.approx(2 * pipe.rho * pipe.length * v0 / tc, rel=1e-2)
361 assert WH.michaud(pipe.rho, pipe.length, v0, tc, pipe.a) == pytest.approx(2 * pipe.rho * pipe.length * v0 / tc)
362 assert WH.michaud(pipe.rho, pipe.length, v0, 0.5 * pipe.length / pipe.a, pipe.a) == pytest.approx(
363 pipe.rho * pipe.a * v0)
364 # inside the round trip 2L/a (not just L/a) the closure is "rapid": Joukowsky
365 assert WH.michaud(pipe.rho, pipe.length, v0, 1.5 * pipe.length / pipe.a, pipe.a) == pytest.approx(
366 pipe.rho * pipe.a * v0)
367
368
370 B, m_arr, pa = 9.6e6, 4.9, 94000.0
371 assert WH.opening_surge(p_front=pa, pc=pa, mdot_arrival=m_arr, phi=math.inf, B=B)["p_peak"] == pytest.approx(pa)
372 assert WH.opening_surge(p_front=pa, pc=pa, mdot_arrival=m_arr, phi=1e-12, B=B)["p_peak"] == pytest.approx(
373 pa + B * m_arr, rel=1e-6)
374 phi = 1.73e-3
375 o = WH.opening_surge(p_front=pa, pc=pa, mdot_arrival=m_arr, phi=phi, B=B)
376 # by hand: m^2/phi^2 + B m = B m_arr, the orifice drop equal to the Joukowsky rise
377 m = (-B + math.sqrt(B * B + 4 * B * m_arr / phi ** 2)) / (2 / phi ** 2)
378 assert o["mdot_after"] == pytest.approx(m, rel=1e-9)
379 assert o["p_peak"] - pa == pytest.approx((m / phi) ** 2, rel=1e-9)
380
381
382# ------------------------------------------------------------------ outflow (gas ingestion)
383
384
385from engine.layerx.diag import outflow as OF # noqa: E402
386
387
389 """h(z) = z + Q^2/(8 pi^2 g z^4) over a hemispherical sink: its minimum is h_c; the constant
390 0.69 of Lubin & Springer is that minimum over (Q^2/g)^(1/5)."""
391 from scipy.optimize import minimize_scalar
392
393 Q, g = 1.73e-3, 9.80665
394 res = minimize_scalar(lambda z: z + Q * Q / (8 * math.pi ** 2 * g * z ** 4), bounds=(1e-4, 1.0), method="bounded",
395 options={"xatol": 1e-12})
396 scale = (Q * Q / g) ** 0.2
397 assert res.fun / scale == pytest.approx(OF.point_sink_constant(), rel=1e-6)
398 assert OF.point_sink_constant() == pytest.approx(OF.LS_CONSTANT, rel=3e-3)
399 assert OF.critical_height(Q, g, 1150.0) == pytest.approx(0.69 * scale, rel=1e-12)
400
401
403 Q, g = 1.73e-3, 9.80665
404 h = OF.critical_height(Q, g, 1150.0)
405 assert OF.critical_height(2 * Q, g, 1150.0) == pytest.approx(h * 2 ** 0.4, rel=1e-12)
406 assert OF.critical_height(Q, 9 * g, 1150.0) == pytest.approx(h * 9 ** -0.2, rel=1e-12)
407 # gas half as dense as the liquid halves the buoyant gravity: h_c up by 2^(1/5)
408 assert OF.critical_height(Q, g, 1150.0, 575.0) == pytest.approx(h * 2 ** 0.2, rel=1e-12)
409
410
412 R = 0.08
413 assert OF.volume_below(R, R, R) == pytest.approx(2 / 3 * math.pi * R ** 3, rel=1e-12) # hemisphere
414 assert OF.volume_below(R / 2, R, R / 2) == pytest.approx(2 / 3 * math.pi * R * R * R / 2, rel=1e-12) # 2:1 head
415 assert OF.volume_below(0.03, R, 0.0) == pytest.approx(math.pi * R * R * 0.03, rel=1e-12)
416 # a spherical cap: pi h^2 (R - h/3)
417 assert OF.volume_below(0.03, R, R) == pytest.approx(math.pi * 0.03 ** 2 * (R - 0.01), rel=1e-12)
418 for b in (0.0, R / 2, R):
419 for h in (0.004, 0.03, 0.2):
420 assert OF.liquid_height(OF.volume_below(h, R, b), R, b) == pytest.approx(h, rel=1e-9)
421
422
424 """Constant flow from a flat-bottomed tank: the level falls linearly and h_c is constant, so the
425 dip forms at t = (M - rho pi R^2 h_c) / mdot with rho pi R^2 h_c left."""
426 rho, R, md, M, g = 1150.0, 0.08, 1.9, 6.6, 9.80665
427 t = [0.05 * k for k in range(1, 70)]
428 liq = [M - md * ti for ti in t]
429 on = OF.onset(t, liq, [md] * len(t), [g] * len(t), rho_liquid=[rho] * len(t), rho_gas=[0.0] * len(t),
430 radius=R, head_depth=0.0)
431 hc = 0.69 * ((md / rho) ** 2 / g) ** 0.2
432 m_c = rho * math.pi * R * R * hc
433 assert on["residual_kg"] == pytest.approx(m_c, rel=1e-6)
434 assert on["t"] == pytest.approx((M - m_c) / md, rel=1e-6)
435 # never reached when the burn ends first
436 off = OF.onset(t[:10], liq[:10], [md] * 10, [g] * 10, rho_liquid=[rho] * 10, rho_gas=[0.0] * 10,
437 radius=R, head_depth=0.0)
438 assert off["t"] is None and off["residual_kg"] is None
439
440
441# ------------------------------------------------------------------ LE4 (slow: LAYERX_GOLDEN=1)
442
443
444GOLDEN = os.environ.get("LAYERX_GOLDEN") == "1"
445slow = pytest.mark.skipif(not GOLDEN, reason="LE4 prepare + he_pad burn (~1-2 min); set LAYERX_GOLDEN=1")
446
447
448@pytest.fixture(scope="module")
449def le4():
450 import copy
451 import importlib.util
452 from pathlib import Path
453
454 from engine.core.runner import PintleEngineRunner
455 from engine.layerx import LayerXSettings, prepare, run_prepared
456
457 root = Path(__file__).resolve().parents[1]
458 spec = importlib.util.spec_from_file_location("layerx_baseline", root / "scripts" / "layerx_baseline.py")
459 mod = importlib.util.module_from_spec(spec)
460 spec.loader.exec_module(mod)
461 cfg, _ = mod.load_engine_config(root / "docs" / "layerx" / "baseline-2026-10-02.json")
462 drawing = mod.find_drawing("copv_study_he")
463 burned = copy.deepcopy(cfg)
464 runner = PintleEngineRunner(burned)
465 prep = prepare(burned, runner, drawing, LayerXSettings(drawing_id=drawing.id), [])
466 result = run_prepared(prep, runner=runner, replay=prep.settings.replay, config=burned,
467 progress=lambda stage, frac: None)
468 return prep, result, burned
469
470
471@slow
473 """AUDIT 9.5 §1, by hand from the drawing: line inertance sum L/A = 1495 (LOX, 0.14 m) and
474 10297 1/m (fuel, 0.964 m) of 10.92 mm bore; orifice passages 163 / 200 1/m (24 x 8.16 mm)."""
475 prep, result, cfg = le4
476 sp = S.settled_point(result)
477 ox = S.feed_line(prep, "oxidiser", mdot_ref=sp["line_ox"], p_ref=578 * PSI, T_ref=90.0)
478 fu = S.feed_line(prep, "fuel", mdot_ref=sp["line_fu"], p_ref=578 * PSI, T_ref=293.15)
479 assert ox.sum("up", "inertance") + ox.sum("dn", "inertance") == pytest.approx(1495.0, abs=1.0)
480 assert fu.sum("up", "inertance") + fu.sum("dn", "inertance") == pytest.approx(10297.0, abs=5.0)
481 assert ox.valve.id == "MVO" and fu.valve.id == "MVF" and ox.valve.travel_s == pytest.approx(0.05)
482 man = S.manifold_volumes(cfg)
483 assert man["ox"]["I_inj"] == pytest.approx(163.0, abs=1.0)
484 assert man["fuel"]["I_inj"] == pytest.approx(200.0, abs=1.0)
485 # the drawn tank-exit K 0.5 and the line friction: the audit's 14.69 psi LOX line loss
486 # (AUDIT 1, hand) is the drawn loss at ~1.88 kg/s; the twin's own element losses give it
487 dp_line = (ox.sum("up", "R") + ox.valve.R + ox.sum("dn", "R")) * 1.879 ** 2 / PSI
488 assert 5.0 < dp_line < 20.0
489
490
491@slow
493 import copy
494
495 from engine.layerx.diag import outflow as OFm, shutdown as SDm, waterhammer as WHm
496
497 prep, result, cfg = le4
498 before = copy.deepcopy(result)
499 st = S.start_from_run(prep, result, cfg)
500 assert st["available"], st.get("error")
501 assert 0.0 < st["prime_ox_s"] < st["prime_fuel_s"] < 0.05
502 assert st["ignition_s"] == pytest.approx(st["prime_fuel_s"])
503 assert 0.0 < st["impulse_deficit_pct"] < 1.5
504 assert abs(st["steady_check"]["thrust_rel"]) < 0.01
505 assert len(st["t"]) == len(st["pc_psia"]) == len(st["mdot_ox"]) == len(st["mr"])
506 of = OFm.outflow_from_run(prep, result)
507 assert all(r["available"] for r in of)
508 sd = SDm.shutdown_from_run(prep, result, cfg, outflow=of)
509 assert sd["available"] and sd["first_dry"] in ("ox", "fuel") and sd["mode"] in ("LOX-rich", "fuel-rich",
510 "simultaneous")
511 wh = WHm.waterhammer_from_run(prep, result, cfg, start=st)
512 assert [r["side"] for r in wh] == ["ox", "fuel"] and all(r["available"] for r in wh)
513 # Joukowsky by hand at the burn's largest LOX flow: rho a v with a = 897 m/s
514 r_ox = wh[0]
515 v = r_ox["v0_m_s"]
516 assert r_ox["joukowsky_psi"] == pytest.approx(1150.5 * 897.0 * v / PSI, rel=0.01)
517 # Each line against the absolute pressure its tank trips the stand at (prepare.vessel_trip), as it
518 # is: the standard atmosphere was added on top of it, a rating one atmosphere over the trip.
519 for r, side in zip(wh, ("oxidiser", "fuel")):
520 assert r["rating_psia"] == pytest.approx(prep.derived["tank_mawp_psi"][prep.roles[side]], abs=1e-9)
521 import json
522
523 for block in [st, sd, *of, *wh]:
524 assert {"name", "source", "assumptions", "inputs"} <= set(block["model"])
525 json.dumps(block, allow_nan=False) # the run record is strict JSON
526 assert result == before
527
528
530 """LE4's LOX side has no drawn line (tank outlet, OM-R, injector): the start took the manifold's
531 entry area from a line that is not there, indexed an empty list, and the whole start went
532 missing from the run ("Start not computed"). With no line it is the main valve's own bore; with
533 a line, the line's, exactly as before; with neither, it says so rather than guess."""
534 pipe = S.Element(id="L1", kind="pipe", R=0.0, length=0.3, bore=0.010922)
535 valve = S.Element(id="MV", kind="valve", R=0.0, bore=0.0095, travel_s=0.05)
536 bare = S.Element(id="MV", kind="valve", R=0.0, travel_s=0.05)
537
538 def feed(up, v, dn):
539 return S.FeedLine("oxidiser", "ox", "T", "oxygen", 1140.0, 2e-4, 900.0, 1e5, tuple(up), v, tuple(dn))
540
541 assert S.exit_area(feed([], valve, [pipe])) == (pipe.area, False)
542 assert S.exit_area(feed([pipe], valve, [])) == (pipe.area, False) # as before: the line above it
543 area, through = S.exit_area(feed([], valve, []))
544 assert through and area == pytest.approx(math.pi * 0.0095 ** 2 / 4.0)
545 with pytest.raises(ValueError, match="no line and no main-valve bore"):
546 S.exit_area(feed([], bare, []))
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
test_the_start_enters_the_manifold_through_the_valve_when_no_line_is_drawn()
test_wet_side_with_an_instant_shut_drains_exactly_its_downstream_liquid()
test_le4_diagnostics_run_on_the_golden_burn_and_leave_it_alone(le4)
static ArmGate g(kMe)
static ArmAction feed(const Frame &f, uint32_t t)
Definition test_main.cpp:48