STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_analytic.py
Go to the documentation of this file.
1"""Closed forms Layer X's physics must reproduce, through the components it actually burns with.
2
3Each test sets up a case with a textbook answer, runs it through lib/feedtwin (or EngineDesign's
4chamber solve and the Layer X engine card), and compares with the formula written out here. Each
5was shown to fail when its formula is broken in the code (see the mutation notes in each docstring).
6
7What lib/feedtwin already checks, and is not repeated here:
8
9* ``tests/test_vessels.py::test_adiabatic_blowdown_matches_the_closed_form``: a vessel emptied at a
10 *prescribed* flow follows T/T0 = (m/m0)^(g-1). Here the flow is the choked orifice's own, so the
11 time history checks the orifice and the vessel together.
12* ``tests/test_comps_validation.py::test_orifice_cd_inverts_the_bernoulli_relation``: the orifice
13 component alone. Here it is solved inside a network between two pressures.
14* ``tests/test_body_acceleration.py``: a pipe's static head and a session's tank head at 7 g. Here the
15 network solve itself must balance a column at a non-standard specific force.
16* ``tests/test_engine_card.py::test_card_chamber_pressure_and_thrust_follow_the_tables``: a constant
17 card's Pc. Here the card is fitted to the LE4 engine and checked against EngineDesign's closure,
18 whose Rayleigh stagnation loss is worked out by hand.
19"""
20
21from __future__ import annotations
22
23import json
24import math
25from pathlib import Path
26
27import pytest
28
29feedtwin = pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed (pip install -e ../lib/feedtwin)")
30
31from feedtwin.comps import build_component, conditions_from_fluid # noqa: E402
32from feedtwin.model import ComponentInstance, Param, Provenance # noqa: E402
33from feedtwin.props import Fluid # noqa: E402
34from feedtwin.solve import Network, solve_steady # noqa: E402
35from feedtwin.vessels import GasVolume # noqa: E402
36
37M = Provenance.MANUFACTURER
38G0 = 9.80665
39PSI = 6894.757293168361
40BASELINE = Path(__file__).resolve().parents[1] / "docs" / "layerx" / "baseline-2026-10-02.json"
41
42
43# ---------------------------------------------------------------- choked blowdown
44
45
46def _choked_blowdown(gas: str, isothermal: bool, duration: float, steps: int = 4000, *, V: float = 0.05,
47 p0: float = 2.0e5, T0: float = 300.0, bore: float = 2.0e-3, cd: float = 0.85):
48 """A vessel emptied through a choked gas orifice into vacuum: feedtwin's ``gas_orifice`` sets the
49 flow from the vessel's state at every step. ``isothermal``: the vessel wears a wall of effectively
50 infinite heat capacity, coupled tightly (500 W/K against ~80 J/K of gas), which holds the gas at
51 its temperature; otherwise the vessel has no wall and the expansion is adiabatic."""
52 fl = Fluid(gas)
53 orifice = build_component(ComponentInstance.build(
54 "GO-01", "gas_orifice", {"bore": Param(bore * 1e3, "mm", M, "test"), "Cd": Param(cd, "-", M, "test")}))
55 vessel = (GasVolume(fl, volume=V, wall_mass=1.0e7, wall_capacity=900.0, wall_conductance=500.0) if isothermal
56 else GasVolume(fl, volume=V))
57 start = vessel.initial_state(pressure=p0, temperature=T0)
58
59 def mdot(state, t): # noqa: ARG001 - the blowdown's callable signature
60 return orifice.choked_flow(conditions_from_fluid(fl, vessel.pressure(state), vessel.temperature(state))) # type: ignore[attr-defined]
61
62 history = vessel.blowdown(start, mdot, duration=duration, steps=steps)
63 c0 = conditions_from_fluid(fl, p0, T0)
64 g, R = c0.gamma, c0.r_specific
65 # mdot = Cd A p sqrt(g/(R T)) (2/(g+1))^((g+1)/(2(g-1))) (Sutton & Biblarz, Rocket Propulsion
66 # Elements, 9th ed., eq. 3-24); with m = pV/(RT):
67 # isothermal: p/p0 = exp(-t/tau);
68 # adiabatic: p/p0 = (1 + (g-1)/2 t/tau)^(-2g/(g-1)),
69 # tau = V / (Cd A (2/(g+1))^((g+1)/(2(g-1))) sqrt(g R T0)), integrating dp/dt with T = T0 (p/p0)^((g-1)/g).
70 area = math.pi * bore * bore / 4.0
71 tau = V / (cd * area * (2.0 / (g + 1.0)) ** ((g + 1.0) / (2.0 * (g - 1.0))) * math.sqrt(g * R * T0))
72 out = []
73 for t, state in history[:: max(len(history) // 25, 1)]:
74 closed = math.exp(-t / tau) if isothermal else (1.0 + 0.5 * (g - 1.0) * t / tau) ** (-2.0 * g / (g - 1.0))
75 out.append((t, vessel.pressure(state) / p0, closed))
76 return out, tau
77
78
79@pytest.mark.parametrize("gas,isothermal,duration", [
80 ("nitrogen", True, 110.0),
81 ("nitrogen", False, 110.0),
82 ("helium", False, 15.0),
83])
85 """At two bar both gases are ideal to ~0.05 % (the reference's premise); the residual is that and
86 the change of the real gamma with temperature, ~0.3 % at most by p/p0 = 0.2. Mutation: scaling
87 ``feedtwin.comps.gas.choked_mass_flow`` by 1.02 (or dropping its sqrt(gamma)) fails this."""
88 rows, tau = _choked_blowdown(gas, isothermal, duration)
89 assert rows[-1][1] < 0.6, "the blowdown must get well away from its start to test anything"
90 for t, model, closed in rows:
91 assert model == pytest.approx(closed, rel=4e-3), f"{gas} at t={t:.2f} s (tau {tau:.1f} s)"
92
93
95 """Physical ordering, not a closed form: a wall that exchanges heat keeps the gas warmer than
96 adiabatic and cooler than isothermal, so its pressure lies between the two closed forms."""
97 rows_a, _ = _choked_blowdown("nitrogen", False, 60.0, steps=2000)
98 rows_i, _ = _choked_blowdown("nitrogen", True, 60.0, steps=2000)
99 assert rows_a[-1][1] < rows_i[-1][1]
100 assert rows_a[-1][2] < rows_i[-1][2]
101
102
103# ---------------------------------------------------------------- orifice in a network
104
105
106@pytest.mark.parametrize("dp_bar,cd,bore_mm", [(4.0, 0.61, 2.0), (25.0, 0.80, 1.5)])
108 """``mdot = Cd A sqrt(2 rho dp)`` for an incompressible liquid through a sharp orifice between two
109 held pressures (Sutton & Biblarz eq. 8-1). Mutation: dropping the 2 in ``OrificeCd`` fails this."""
110 orifice = build_component(ComponentInstance.build(
111 "OR-01", "orifice", {"bore": Param(bore_mm, "mm", M, "test"), "pipe_bore": Param(10.92, "mm", M, "line"),
112 "Cd": Param(cd, "-", M, "test")}, model="cd"))
113 net = Network()
114 p_up = 5.0e5 + dp_bar * 1e5
115 net.add_node("up", "water", 293.15, pressure=p_up)
116 net.add_node("dn", "water", 293.15, pressure=5.0e5)
117 net.add_branch("OR-01", orifice, "up", "dn") # type: ignore[arg-type]
118 result = solve_steady(net)
119 assert result.converged
120 rho = net.conditions("up", p_up).rho
121 area = math.pi * (bore_mm * 1e-3) ** 2 / 4.0
122 assert result.flows["OR-01"] == pytest.approx(cd * area * math.sqrt(2.0 * rho * dp_bar * 1e5), rel=1e-7)
123
124
125# ---------------------------------------------------------------- hydrostatic head
126
127
128def _column(gravity: float, dp: float, climb: float = 1.3716):
129 """Ethanol in a vertical 1/2 in x 0.035 in line, ``climb`` metres high (the team's 4.5 ft fuel
130 run), held at the bottom and ``dp`` lower at the top."""
131 pipe = build_component(ComponentInstance.build("FL-01", "pipe", {
132 "length": Param(climb, "m", M, "test"), "bore": Param(10.92, "mm", M, "1/2 x 0.035 in tube"),
133 "elevation_change": Param(climb, "m", M, "test")}))
134 net = Network()
135 net.gravity = gravity
136 net.add_node("bottom", "ethanol", 293.15, pressure=40.0e5)
137 rho = net.conditions("bottom", 40.0e5).rho
138 net.add_node("top", "ethanol", 293.15, pressure=40.0e5 - dp(rho) if callable(dp) else 40.0e5 - dp)
139 net.add_branch("FL-01", pipe, "bottom", "top") # type: ignore[arg-type]
140 return solve_steady(net), rho
141
142
143LIFTOFF = 8.25 * G0
144"""The specific force at LE4's liftoff, 8.25 g0 (AUDIT 5.1: T/m at the first flown step)."""
145
146
148 """``dp = rho a h`` (hydrostatics in the vehicle frame, a the specific force along the axis). Held
149 at exactly that difference, the column does not move; held at the pad's rho g0 h under the same
150 specific force, it falls. Mutation: a pipe whose static head uses standard gravity fails this."""
151 h = 1.3716
152 still, rho = _column(LIFTOFF, lambda r: r * LIFTOFF * h)
153 assert still.converged
154 assert abs(still.flows["FL-01"]) < 1e-6
155 pad, _ = _column(LIFTOFF, lambda r: r * G0 * h)
156 assert pad.converged and pad.flows["FL-01"] < -0.1, "under 8.25 g the pad's head cannot hold the column up"
157 # The density the head is priced at: the twin takes liquid on the saturation line at its
158 # temperature (CoolProp: 789.34 kg/m^3 for ethanol at 293.15 K), not compressed to the 40 bar it is
159 # held at (792.81 kg/m^3, +0.44 %). So the extra head at liftoff is 789.34 * 7.25 g0 * 1.3716 m =
160 # 11.164 psi, not 11.213. This pins that choice; the balance above is the physics check.
161 CP = pytest.importorskip("CoolProp.CoolProp")
162 assert rho == pytest.approx(CP.PropsSI("D", "T", 293.15, "Q", 0, "Ethanol"), rel=1e-4)
163 assert rho * (LIFTOFF - G0) * h / PSI == pytest.approx(11.164, abs=0.002)
164
165
166# ---------------------------------------------------------------- Pc = mdot c* / A_t
167
168
169def _rayleigh_kappa(contraction: float, gamma: float) -> float:
170 """``P_injector_end / P0_nozzle`` of a constant-area combustor (heat added along a Rayleigh line):
171 ``(1 + g M^2) / (1 + (g-1)/2 M^2)^(g/(g-1))`` with ``M`` the chamber-end Mach number on the
172 subsonic branch of ``A/A* = (1/M) [(2/(g+1)) (1 + (g-1)/2 M^2)]^((g+1)/(2(g-1)))`` (Sutton &
173 Biblarz, 9th ed., sec. 3.3 and Table 3-2; Huzel & Huang, sec. 2). Solved here by bisection."""
174 g = gamma
175
176 def area_ratio(m: float) -> float:
177 return (1.0 / m) * ((2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * m * m)) ** ((g + 1.0) / (2.0 * (g - 1.0)))
178
179 lo, hi = 1e-9, 1.0
180 for _ in range(200):
181 mid = 0.5 * (lo + hi)
182 lo, hi = (mid, hi) if area_ratio(mid) > contraction else (lo, mid)
183 m = 0.5 * (lo + hi)
184 return (1.0 + g * m * m) / (1.0 + 0.5 * (g - 1.0) * m * m) ** (g / (g - 1.0))
185
186
187@pytest.fixture(scope="module")
189 if not BASELINE.is_file():
190 pytest.skip("the LE4 baseline (docs/layerx/baseline-2026-10-02.json) is not in this checkout")
191 from engine.layerx.card import EngineSampler
192 from engine.pipeline.config_schemas import PintleEngineConfig
193
194 config = PintleEngineConfig(**json.loads(BASELINE.read_text())["inputs"]["config"])
195 ambient = 94069.72225005485 # the site's atmosphere at 626.67 m (derived.ambient_pa)
196 sampler = EngineSampler(config, ambient)
197 p_O, p_F = 560.0 * PSI, 545.0 * PSI # line-exit pressures inside LE4's burn (AUDIT 9.9 B)
198 res = sampler.runner.evaluate(p_O, p_F, silent=True, P_ambient=ambient)
199 return sampler, res, (p_O, p_F), ambient
200
201
203 """``Pc = kappa mdot c*/A_t``: c* = eta_c* c*_ideal is referred to the nozzle's stagnation
204 pressure, and Pc is the injector end, kappa = 1.004 above it on LE4 (contraction 7.06). Mutation:
205 EngineDesign's ``nozzle_stagnation_loss`` returning 1.0 fails this by 0.4 %."""
206 sampler, res, _, _ = le4_point
207 cg = sampler.config.chamber_geometry
208 contraction = math.pi * cg.chamber_diameter ** 2 / 4.0 / cg.A_throat
209 kappa = _rayleigh_kappa(contraction, float(res["gamma"]))
210 mdot = res["mdot_O"] + res["mdot_F"]
211 assert 1.002 < kappa < 1.01
212 assert res["Pc"] == pytest.approx(kappa * mdot * res["cstar_actual"] / cg.A_throat, rel=1e-8)
213 assert res["cstar_actual"] == pytest.approx(res["eta_cstar"] * res["cstar_ideal"], rel=1e-12)
214
215
217 """The Layer X card stores c*_card = Pc A_t / mdot (the injector-end c*), so its chamber returns
218 ``Pc = mdot c*_card / A_t`` = EngineDesign's Pc at a point it was fitted through, to the table's
219 interpolation error. Mutation: the card chamber's Pc without the 1/A_t, or c* scaled, fails this."""
220 from engine.layerx.card import fit_card
221
222 sampler, res, (p_O, p_F), ambient = le4_point
223 samples = []
224 for u in (0.95, 1.0, 1.05):
225 for v in (0.95, 1.0, 1.05):
226 point = sampler(p_O * u, p_O * u * v * (p_F / p_O))
227 if point is not None:
228 samples.append({**point, "p_O": p_O * u, "p_F": p_O * u * v * (p_F / p_O)})
229 assert len(samples) == 9
230 cg = sampler.config.chamber_geometry
231 card = fit_card(samples, throat_area=float(cg.A_throat), exit_area=float(cg.A_exit), ambient_pa=ambient,
232 name="LE4 fixed point")
233 out = card.chamber_model(ambient_pressure=ambient).evaluate(res["mdot_O"], res["mdot_F"])
234 mdot = res["mdot_O"] + res["mdot_F"]
235 cg_kappa = _rayleigh_kappa(math.pi * cg.chamber_diameter ** 2 / 4.0 / cg.A_throat, float(res["gamma"]))
236 assert out.pressure == pytest.approx(res["Pc"], rel=1e-5)
237 assert out.pressure == pytest.approx(cg_kappa * mdot * res["cstar_actual"] / cg.A_throat, rel=1e-5)
#define max(a, b)
Definition Arduino.h:43
_choked_blowdown(str gas, bool isothermal, float duration, int steps=4000, *, float V=0.05, float p0=2.0e5, float T0=300.0, float bore=2.0e-3, float cd=0.85)
test_the_isothermal_and_adiabatic_bounds_bracket_a_real_wall()
float _rayleigh_kappa(float contraction, float gamma)
test_the_engine_card_returns_enginedesigns_pc_at_a_fixed_point(le4_point)
_column(float gravity, float dp, float climb=1.3716)
test_a_network_solves_an_orifice_to_cd_a_sqrt_2_rho_dp(dp_bar, cd, bore_mm)
test_enginedesign_closes_its_chamber_on_mdot_cstar_over_At_with_the_rayleigh_loss(le4_point)
test_a_choked_blowdown_follows_its_closed_form(gas, isothermal, duration)
test_a_column_at_a_known_specific_force_holds_exactly_rho_a_h()