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."""
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
57 start = vessel.initial_state(pressure=p0, temperature=T0)
60 return orifice.choked_flow(conditions_from_fluid(fl, vessel.pressure(state), vessel.temperature(state)))
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
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))
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))
79@pytest.mark.parametrize(
"gas,isothermal,duration", [
80 (
"nitrogen",
True, 110.0),
81 (
"nitrogen",
False, 110.0),
82 (
"helium",
False, 15.0),
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."""
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)"
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"))
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")
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)
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")}))
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")
140 return solve_steady(net), rho
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."""
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)))
181 mid = 0.5 * (lo + hi)
182 lo, hi = (mid, hi)
if area_ratio(mid) > contraction
else (lo, mid)
184 return (1.0 + g * m * m) / (1.0 + 0.5 * (g - 1.0) * m * m) ** (g / (g - 1.0))
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")
194 config = PintleEngineConfig(**json.loads(BASELINE.read_text())[
"inputs"][
"config"])
195 ambient = 94069.72225005485
196 sampler = EngineSampler(config, ambient)
197 p_O, p_F = 560.0 * PSI, 545.0 * PSI
198 res = sampler.runner.evaluate(p_O, p_F, silent=
True, P_ambient=ambient)
199 return sampler, res, (p_O, p_F), ambient
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
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)
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."""
222 sampler, res, (p_O, p_F), ambient = le4_point
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)
_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)