1"""Conservation checks on a Layer X burn (engine/layerx/diag/vv.py).
3What these check, and against what:
5* **Propellant mass** on a hand-built burn: the balance closes when the numbers do, and a leak shows
6 as exactly its own size. The trapped liquid is checked by hand: pi/4 d^2 L of the drawing's
7 tank-to-engine lines at CoolProp's saturated density, the fill and vent lines left out.
8* **Pressurant mass and gas-side energy** on a pressurisation integrated here, independently of the
9 twin: a bottle feeds an ullage that grows as the liquid leaves, explicit Euler, every state from
10 CoolProp's (rho, u). Mass is conserved by construction and the first law holds step by step, so the
11 checks must close to the quadrature error of the boundary work (~1e-4 of it). Leaving the work
12 out, or the ullage volume wrong, must not close.
13* **The 2026-10-02 reference run** (read only, skipped when absent): the twin's own bookkeeping
14 closes, the pressurant to round-off.
17from __future__
import annotations
21from pathlib
import Path
25CP = pytest.importorskip(
"CoolProp.CoolProp")
30PSI = 6894.757293168361
31RUNS = Path(__file__).resolve().parents[1] /
".userdata" /
"local" /
"engine" /
"layerx" /
"runs"
32REFERENCE = RUNS /
"20261002-231658-7e47d1.json"
35def _p(value, unit, source="estimated"):
36 return {
"value": value,
"unit": unit,
"source": source,
"reference":
"test"}
43 {
"id":
"OXT",
"data": {
"componentType":
"TANK",
"fluid":
"oxygen",
"params": {}}},
44 {
"id":
"FILL",
"data": {
"componentType":
"SOL",
"fluid":
"oxygen",
"params": {}}},
45 {
"id":
"VENT",
"data": {
"componentType":
"SOL",
"fluid":
"helium",
"params": {}}},
46 {
"id":
"MVO",
"data": {
"componentType":
"SOL",
"fluid":
"oxygen",
"params": {}}},
47 {
"id":
"ENG",
"data": {
"componentType":
"ENGINE",
"params": {}}},
50 {
"id":
"l_fill",
"source":
"FILL",
"target":
"OXT",
"data": {
"params": {
"length":
_p(0.4,
"m"),
"bore":
_p(12.7,
"mm")}}},
51 {
"id":
"l_vent",
"source":
"OXT",
"target":
"VENT",
"data": {
"params": {
"length":
_p(0.4,
"m"),
"bore":
_p(9.525,
"mm")}}},
52 {
"id":
"l_ox1",
"source":
"OXT",
"target":
"MVO",
"data": {
"params": {
"length":
_p(2.0,
"in"),
"bore":
_p(10.92,
"mm")}}},
53 {
"id":
"l_ox2",
"source":
"MVO",
"target":
"ENG",
"data": {
"params": {
"length":
_p(0.07,
"m"),
"bore":
_p(10.92,
"mm")}}},
59 lines = feed_lines(DOC,
"OXT")
60 assert [ln[
"id"]
for ln
in lines] == [
"l_ox1",
"l_ox2"]
61 assert lines[0][
"length_m"] == pytest.approx(0.0508)
62 assert lines[1][
"volume_m3"] == pytest.approx(math.pi / 4 * 0.01092 ** 2 * 0.07, rel=1e-12)
65def _burn(n=20, dt=0.05, mdot=(1.9, 1.25), loaded=(6.0, 4.0)):
66 t = [-0.05, 0.0] + [dt * (k + 1)
for k
in range(n)]
67 firing = [
False,
False] + [
True] * n
68 side =
lambda m: {
"mdot": [0.0, 0.0] + [m] * n,
"liquid_K": [90.0] * len(t)}
69 burned = [m * dt * n
for m
in mdot]
71 "series": {
"t": t,
"dt": [dt] * len(t),
"firing": firing,
"ox": side(mdot[0]),
"fuel": side(mdot[1])},
72 "summary": {
"ox": {
"loaded_kg": loaded[0],
"residual_kg": loaded[0] - burned[0]},
73 "fuel": {
"loaded_kg": loaded[1],
"residual_kg": loaded[1] - burned[1]}},
74 "provenance": {
"derived": {
"roles": {
"oxidiser":
"OXT",
"fuel":
"FUT"},
75 "species": {
"oxidiser":
"oxygen",
"fuel":
"ethanol"}}},
81 m = mass_balance(r, document=DOC)
83 rho = CP.PropsSI(
"D",
"T", 90.0,
"Q", 0,
"Oxygen")
84 vol = math.pi / 4 * 0.01092 ** 2 * (0.0508 + 0.07)
85 assert ox[
"trapped_kg"] == pytest.approx(rho * vol, rel=1e-9)
86 assert ox[
"burned_kg"] == pytest.approx(1.9 * 0.05 * 20, rel=1e-12)
87 assert ox[
"loaded_kg"] == pytest.approx(6.0 + rho * vol, rel=1e-12)
88 assert abs(ox[
"error_pct"]) < 1e-10
90 assert m[
"fuel"][
"trapped_kg"]
is None and abs(m[
"fuel"][
"error_pct"]) < 1e-10
91 assert "Lines not counted" in m[
"fuel"][
"basis"]
96 r[
"summary"][
"ox"][
"residual_kg"] -= 0.05
97 ox = mass_balance(r)[
"ox"]
98 assert ox[
"error_kg"] == pytest.approx(0.05, rel=1e-9)
99 assert ox[
"error_pct"] == pytest.approx(100 * 0.05 / 6.0, rel=1e-9)
103 """Each recorded flow is the end-of-step sample of its own step: mdot[i] * dt[i]. The flow
104 climbs here (as it does when the tanks rise) so that pairing a flow with any other step's
105 length -- the neighbour's, or a constant -- changes the sum; with a constant flow it would not."""
107 n = len(r[
"series"][
"t"])
108 r[
"series"][
"ox"][
"mdot"] = [0.0, 0.0] + [1.80 + 0.01 * k
for k
in range(n - 2)]
109 r[
"series"][
"dt"][-1] = 0.02
110 burned = sum(1.80 + 0.01 * k
for k
in range(n - 3)) * 0.05 + (1.80 + 0.01 * (n - 3)) * 0.02
111 r[
"summary"][
"ox"][
"residual_kg"] = r[
"summary"][
"ox"][
"loaded_kg"] - burned
112 ox = mass_balance(r)[
"ox"]
113 assert ox[
"burned_kg"] == pytest.approx(burned, rel=1e-12)
114 assert abs(ox[
"error_pct"]) < 1e-10
120def _pressurise(n=300, gas="Nitrogen", bottle_L=4.6871, p_b0=300e5, tank_L=15.1, fill0=0.95,
121 p_u0=40e5, T0=293.15, burn_s=3.5):
122 """A bottle feeding one ullage that grows as the liquid leaves; adiabatic everywhere. The flow
123 holds the ullage's density (what a regulator nearly does). Explicit Euler on (m, U) of each
124 volume, h out of the bottle at the step's start, work p dV at the step's start."""
127 q_liq = Vt * (fill0 - 0.05) / burn_s
129 rho_b = CP.PropsSI(
"D",
"P", p_b0,
"T", T0, gas)
130 mb, Ub = rho_b * Vb, rho_b * Vb * CP.PropsSI(
"U",
"P", p_b0,
"T", T0, gas)
131 Vu = Vt * (1 - fill0)
132 rho_u = CP.PropsSI(
"D",
"P", p_u0,
"T", T0, gas)
133 mu, Uu = rho_u * Vu, rho_u * Vu * CP.PropsSI(
"U",
"P", p_u0,
"T", T0, gas)
134 rec = {
"t": [],
"copv_psia": [],
"copv_mass_kg": [],
"p": [],
"T": [],
"fill": []}
138 rec[
"t"].append(k * dt)
139 rec[
"copv_psia"].append(CP.PropsSI(
"P",
"D", mb / Vb,
"U", Ub / mb, gas) / PSI)
140 rec[
"copv_mass_kg"].append(mb)
141 rec[
"p"].append(CP.PropsSI(
"P",
"D", mu / Vu,
"U", Uu / mu, gas) / PSI)
142 rec[
"T"].append(CP.PropsSI(
"T",
"D", mu / Vu,
"U", Uu / mu, gas))
143 rec[
"fill"].append(1.0 - Vu / Vt)
147 h_b = CP.PropsSI(
"H",
"D", mb / Vb,
"U", Ub / mb, gas)
148 p_u = CP.PropsSI(
"P",
"D", mu / Vu,
"U", Uu / mu, gas)
150 mdot = (mu / Vu) * q_liq
151 mb, Ub = mb - mdot * dt, Ub - h_b * mdot * dt
152 mu, Uu = mu + mdot * dt, Uu + h_b * mdot * dt - p_u * dV
157 flat =
lambda v: [v] * m
159 "series": {
"t": rec[
"t"],
"dt": [dt] * m,
"firing": [
True] * m,
"copv_psia": rec[
"copv_psia"],
160 "copv_mass_kg": rec[
"copv_mass_kg"],
"copv_wall_K": flat(T0),
161 "ox": {
"tank_psia": rec[
"p"],
"ullage_K": rec[
"T"],
"fill_fraction": rec[
"fill"]},
162 "fuel": {
"tank_psia": flat(p_u0 / PSI),
"ullage_K": flat(T0),
"fill_fraction": flat(0.95)}},
163 "provenance": {
"derived": {
"roles": {
"oxidiser":
"OXT",
"fuel":
"FUT"},
164 "tank_volumes_L": {
"OXT": tank_L,
"FUT": 8.67},
"copv_volume_L": bottle_L,
165 "pressurant_gas": gas.lower()},
166 "settings": {
"ullage_vapour":
False}},
171@pytest.fixture(scope="module")
178 p = pressurant_balance(r)
179 assert p[
"available"]
180 assert p[
"bottle_out_kg"] > 0.1
181 assert abs(p[
"error_pct"]) < 1e-6
182 assert p[
"vented_kg"]
is None and "vents taken as shut" in p[
"basis"]
187 r = json.loads(json.dumps(r))
188 r[
"provenance"][
"derived"][
"tank_volumes_L"][
"OXT"] *= 1.01
189 assert abs(pressurant_balance(r)[
"error_pct"]) > 0.1
194 e = energy_balance(r)
195 assert e[
"available"]
196 assert e[
"boundary_work_J"] == pytest.approx(W, rel=2e-3)
199 s = r[
"series"][
"ox"]
200 p = [v * PSI
for v
in s[
"tank_psia"]]
201 V = [15.1e-3 * (1 - f)
for f
in s[
"fill_fraction"]]
202 quad = -0.5 * sum((p[k + 1] - p[k]) * (V[k + 1] - V[k])
for k
in range(len(p) - 1))
203 assert e[
"unclosed_J"] == pytest.approx(quad, rel=1e-5, abs=1e-6)
204 assert abs(e[
"error_pct"]) < 0.1, e
206 assert e[
"bottle_wall_heat_J"]
is None
210 """Warm the ullage gas at the end by 2 K at fixed density: that energy was never in the
211 balance, so R falls by it (R is minus the heat the walls gave the gas)."""
213 r = json.loads(json.dumps(r))
214 s = r[
"series"][
"ox"]
215 p, T = s[
"tank_psia"][-1] * PSI, s[
"ullage_K"][-1]
216 rho = CP.PropsSI(
"D",
"P", p,
"T", T,
"Nitrogen")
217 vol = 15.1e-3 * (1 - s[
"fill_fraction"][-1])
218 u0 = CP.PropsSI(
"U",
"P", p,
"T", T,
"Nitrogen")
219 s[
"ullage_K"][-1] = T + 2.0
220 s[
"tank_psia"][-1] = CP.PropsSI(
"P",
"D", rho,
"T", T + 2.0,
"Nitrogen") / PSI
221 q = rho * vol * (CP.PropsSI(
"U",
"D", rho,
"T", T + 2.0,
"Nitrogen") - u0)
222 before = energy_balance(
_pressurise()[0])[
"unclosed_J"]
223 after = energy_balance(r)
225 dp = s[
"tank_psia"][-1] * PSI - p
226 dV = 15.1e-3 * (r[
"series"][
"ox"][
"fill_fraction"][-2] - r[
"series"][
"ox"][
"fill_fraction"][-1])
227 assert after[
"unclosed_J"] - before == pytest.approx(-q - 0.5 * dp * dV, rel=1e-6)
232 r = json.loads(json.dumps(r))
233 r[
"provenance"][
"settings"][
"ullage_vapour"] =
True
234 assert not pressurant_balance(r)[
"available"]
and "vapour" in pressurant_balance(r)[
"error"]
235 assert not energy_balance(r)[
"available"]
238 assert mass_balance(b)[
"ox"][
"includes_evaporation"]
is False
239 b[
"provenance"][
"settings"] = {
"ullage_vapour":
True}
240 m = mass_balance(b)[
"ox"]
241 assert m[
"includes_evaporation"]
is True and "evaporated" in m[
"basis"]
245 out =
check({
"series": {
"t": [0.0, 1.0]},
"summary": {
"ox":
"nonsense"}})
246 for key
in (
"mass",
"pressurant",
"energy"):
248 assert out[
"pressurant"][
"available"]
is False
249 assert out[
"convergence"] == []
250 assert out[
"model"][
"name"] ==
"layerx_vv_balances"
257 r = {
"passes": [{
"pass": 1,
"schedule_change": 0.04}, {
"pass": 2,
"schedule_change": 5e-5,
"accel_change": 1e-3,
258 "agreement": {
"worst": {
"pc": 0.001,
"mdot_O": -0.002}}}]}
259 c =
check(r)[
"convergence"]
260 assert [p[
"pass"]
for p
in c] == [1, 2]
261 assert c[1][
"throat_residual"] == 5e-5
and c[1][
"throat_tolerance"] == THROAT_TOLERANCE
262 assert c[1][
"accel_tolerance"] == ACCEL_TOLERANCE
and c[0][
"accel_tolerance"]
is None
263 assert c[1][
"agreement_worst"] == 0.002
268 r = json.loads(json.dumps(r))
269 r[
"summary"] = {
"ox": {
"loaded_kg": 6.0,
"residual_kg": 6.0,
"t0_psia": 580.0},
"fuel": {
"loaded_kg": 4.0,
"residual_kg": 4.0,
"t0_psia": 580.0}}
270 r[
"series"][
"ox"][
"mdot"] = [0.0] * len(r[
"series"][
"t"])
271 r[
"series"][
"fuel"][
"mdot"] = [0.0] * len(r[
"series"][
"t"])
272 r[
"diagnostics"] = {
"vv":
check(r)}
273 g = {e[
"key"]: e
for e
in grade(r)}
274 assert g[
"conservation_pressurant"][
"grade"] ==
"ok"
275 assert g[
"conservation_mass_ox"][
"grade"] ==
"ok"
276 assert g[
"conservation_energy"][
"grade"] ==
"info"
279@pytest.mark.skipif(not REFERENCE.is_file(), reason=
"the 2026-10-02 reference run 7e47d1 is not in .userdata")
281 r = json.loads(REFERENCE.read_text())[
"result"]
283 for side, lines
in ((
"ox", [
"l_ox1",
"l_ox2"]), (
"fuel", [
"l_fu1",
"l_fu2"])):
286 assert abs(m[
"error_pct"]) < 0.01
287 if m[
"trapped_kg"]
is not None:
288 assert [ln[
"id"]
for ln
in m[
"lines"]] == lines
289 assert abs(v[
"pressurant"][
"error_pct"]) < 1e-6
290 assert v[
"pressurant"][
"bottle_out_kg"] == pytest.approx(r[
"series"][
"copv_mass_kg"][0] - r[
"series"][
"copv_mass_kg"][-1])
291 last = v[
"convergence"][-1]
292 assert last[
"throat_residual"] < last[
"throat_tolerance"]
and last[
"accel_residual"] < last[
"accel_tolerance"]
test_heat_into_the_ullage_shows_as_the_unclosed_part(pressurised)
test_the_reference_run_conserves_what_it_must()
test_a_leak_shows_as_exactly_its_own_size()
test_the_feed_walk_takes_the_lines_to_the_engine_and_nothing_else()
_burn(n=20, dt=0.05, mdot=(1.9, 1.25), loaded=(6.0, 4.0))
test_limits_grade_the_balances(pressurised)
test_a_consistent_burn_closes_and_counts_its_lines_by_hand()
test_convergence_lists_every_pass_against_its_tolerance()
test_a_wrong_ullage_volume_does_not_close(pressurised)
test_check_never_raises_and_says_what_it_could_not_do()
test_vapour_in_the_ullage_is_refused_rather_than_booked_as_error(pressurised)
test_the_pressurant_balance_closes_on_an_independent_integration(pressurised)
_p(value, unit, source="estimated")
test_the_burned_mass_counts_each_step_by_its_own_length()
test_the_energy_balance_closes_to_the_quadrature_of_the_work(pressurised)
_pressurise(n=300, gas="Nitrogen", bottle_L=4.6871, p_b0=300e5, tank_L=15.1, fill0=0.95, p_u0=40e5, T0=293.15, burn_s=3.5)
static void check(bool ok, const std::string &what)
static void record(void *ctx, uint8_t type, const void *data, uint8_t len)