STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_sealed_tank_drain.py
Go to the documentation of this file.
1"""A sealed tank with a drain open: it pulls a vacuum and stops.
2
3The operator stopped a fuel fill (the vent shuts in Armed) with a line still
4open below the liquid, and the fuel tank emptied slowly while the solver tab's
5clamp energy climbed. Two faults, each enough on its own:
6
7* The ullage's floor (``TankSim._settled``) held the pressurant at
8 atmosphere's worth *in the ullage's present volume*, so as the liquid left,
9 the floor put air in to keep it there. A tank cannot be refilled with air
10 through a shut vent: it goes below atmosphere until the head can no longer
11 push liquid out. A vented tank keeps the floor -- on a LOX load it stands in
12 for the boil-off the built-in chill vents off the books.
13* The network solve judged every branch's pressure residual against the
14 highest pressure anywhere on the stand. With a 6,000 psi K-bottle drawn, a
15 branch could sit half a psi off its relation and report converged, so a
16 drain driven by 0.45 psi of head never left the solve's 1 g/s starting
17 guess.
18"""
19
20from __future__ import annotations
21
22from dataclasses import replace
23
24import json
25from pathlib import Path
26
27import pytest
28
29from feedtwin.comps import build_component
30from feedtwin.model import ComponentInstance, Param, Provenance
31from feedtwin.pid import read_diagram
32from feedtwin.props import Fluid
33from feedtwin.session import assemble_model, load_machine
34from feedtwin.session.burn import open_session
35from feedtwin.session.core import AMBIENT, TankSim
36from feedtwin.session.gauge import from_psig
37from feedtwin.solve import Network, solve_steady
38from feedtwin.vessels import CylindricalTank, Tank
39
40M = Provenance.MANUFACTURER
41HERE = Path(__file__).resolve().parent
42DRAWING = HERE / "fixtures" / "le4_rocket_and_gse.json"
43TABLES = HERE.parents[2] / "feed-twin" / "backend" / "statemachines"
44
45
46def _full_tank_at_atmosphere() -> TankSim:
47 tank = Tank(
48 Fluid("ethanol"),
49 Fluid("nitrogen"),
50 CylindricalTank(diameter=0.152, barrel_length=0.40),
51 )
52 state = tank.initial_state(
53 pressure=from_psig(0.0),
54 liquid_mass=5.5,
55 liquid_temperature=293.15,
56 gas_temperature=293.15,
57 )
58 return TankSim(
59 id="T", label="T", tank=tank, state=state, ullage_node="u", outlet_node="o"
60 )
61
62
65 sim.sealed = True
66 air = sim.state.ullage.mass
67 for _ in range(50):
68 sim.advance(
69 0.02,
70 mdot_liquid_out=0.05,
71 mdot_gas_in=0.0,
72 mdot_gas_out=0.0,
73 enthalpy_gas_in=sim.tank.gas_enthalpy(sim.state),
74 )
75 assert sim.state.ullage.mass == pytest.approx(air, rel=1e-12), "no air in"
76 assert sim.pressure < AMBIENT - 0.05 * 6894.76, "a vacuum, not atmosphere"
77 assert sim.fixed_kg == pytest.approx(0.0, abs=1e-12)
78 assert sim.fixed_J == pytest.approx(0.0, abs=1e-6)
79
80
82 """The floor's own job: a vent asked for more gas than there is above
83 atmosphere leaves the tank *at* atmosphere -- at the gas's own (expanded)
84 temperature, not the wall's -- and what that takes is air the vent let in,
85 booked as inflow, not as a clamp."""
87 sim.state = replace(
88 sim.state, ullage=replace(sim.state.ullage, mass=sim.state.ullage.mass * 3.0)
89 )
90 sim.advance(
91 0.02,
92 mdot_liquid_out=0.0,
93 mdot_gas_in=0.0,
94 mdot_gas_out=sim.state.ullage.mass / 0.02,
95 enthalpy_gas_in=sim.tank.gas_enthalpy(sim.state),
96 vent_fraction=1.0,
97 )
98 assert sim.pressure == pytest.approx(AMBIENT, rel=1e-4)
99 assert sim.vent_drawn_kg > 0.0
100 assert sim.fixed_kg == pytest.approx(0.0, abs=1e-9), "not a clamp"
101
102
103def _drain_network(with_bottle: bool) -> Network:
104 """A drain valve between a tank outlet 0.45 psi above atmosphere and the
105 air -- and, beside it, a 6,000 psi K-bottle line that has nothing to do
106 with it."""
107 valve = build_component(
108 ComponentInstance.build(
109 "drain",
110 "valve",
111 {"Cv": Param(0.8, "Cv", M, "datasheet"), "bore": Param(0.25, "in", M, "")},
112 )
113 )
114 net = Network()
115 head = 0.45 * 6894.76
116 net.add_node("outlet", "ethanol", 293.15, pressure=AMBIENT + head)
117 net.add_node("air", "ethanol", 293.15, pressure=AMBIENT)
118 net.add_branch("drain", valve, "outlet", "air") # type: ignore[arg-type]
119 if with_bottle:
120 line = build_component(
121 ComponentInstance.build(
122 "kline",
123 "valve",
124 {
125 "Cv": Param(0.1, "Cv", M, "datasheet"),
126 "bore": Param(0.25, "in", M, ""),
127 },
128 )
129 )
130 net.add_node("bottle", "nitrogen", 293.15, pressure=from_psig(6000.0))
131 net.add_node("reg", "nitrogen", 293.15, pressure=from_psig(5990.0))
132 net.add_branch("kline", line, "bottle", "reg") # type: ignore[arg-type]
133 return net
134
135
137 alone = solve_steady(_drain_network(False), tol=1e-10).flows["drain"]
138 assert alone > 0.01, "0.45 psi through a Cv 0.8 valve is grams a second"
139 beside = solve_steady(_drain_network(True), tol=1e-4)
140 assert beside.converged
141 # Held to a percent of its own pressure: within a fifth of the flow, where
142 # the stand-wide scale left it at the 1 g/s it started from.
143 assert beside.flows["drain"] == pytest.approx(alone, rel=0.2)
144
145
146@pytest.mark.skipif(
147 not (DRAWING.exists() and TABLES.is_dir()), reason="LE4 fixture or tables absent"
148)
150 """The operator's case on LE4 (6): fill the fuel tank, stop the fill (Armed
151 shuts the vent), and a drain teed into the tank's bottom port is open. The
152 tank pulls its head's worth of vacuum and keeps its fuel; the old floor
153 let it drain at 129 g/s and booked the air it made as clamp energy."""
154 payload = json.loads(DRAWING.read_text())
155 ids = {n["data"]["label"]: n["id"] for n in payload["nodes"]}
156 tank, qd = ids["Eth-Tank"], ids["QD-FFB"]
157 edge = next(
158 e
159 for e in payload["edges"]
160 if e["source"] == tank and e.get("sourceHandle") == "b2"
161 )
162 assert edge["target"] == qd
163 edge.update(target="drain_tee", targetHandle="t")
164 payload["nodes"] += [
165 {
166 "id": "drain_tee",
167 "type": "JUNCTION",
168 "position": {"x": 0, "y": 0},
169 "data": {"componentType": "JUNCTION", "label": "drain_tee"},
170 },
171 {
172 "id": "drain",
173 "type": "MAN",
174 "position": {"x": 40, "y": 0},
175 "data": {"componentType": "MAN", "label": "FD-MAN", "options": {}},
176 },
177 ]
178 payload["edges"] += [
179 {"id": "t-qd", "source": "drain_tee", "sourceHandle": "b", "target": qd},
180 {"id": "t-dr", "source": "drain_tee", "sourceHandle": "r", "target": "drain"},
181 ]
182 for edge in payload["edges"][-2:]:
183 edge.update(targetHandle="l", type="smoothstep", data={})
184 model = assemble_model(read_diagram(payload, name="drain"), diagram_id="drain")
185 session = open_session(model, load_machine(tables=TABLES))
186 fuel = session.tanks[tank]
187 for state, seconds in (("Armed", 1.0), ("Fuel Fill", 12.0), ("Armed", 1.0)):
188 session.command_state(state)
189 for _ in range(int(seconds / 0.5)):
190 session.step(0.5)
191 full = fuel.state.liquid_mass
192 assert full > 5.0, "the fuel tank was filled"
193 assert fuel.pressure == pytest.approx(AMBIENT, abs=0.5 * 6894.76)
194 air, fixed = fuel.state.ullage.mass, fuel.fixed_J
195
196 session.set_valve("drain", True)
197 for _ in range(20):
198 session.step(0.5)
199 assert fuel.sealed
200 assert full - fuel.state.liquid_mass < 0.05, "it holds, bar a few grams"
201 assert fuel.state.ullage.mass == pytest.approx(air, rel=1e-6), "no air made"
202 assert fuel.fixed_J - fixed == pytest.approx(0.0, abs=1.0)
203 head = AMBIENT - fuel.pressure
204 assert 0.2 * 6894.76 < head < 0.8 * 6894.76, "a column's worth of vacuum"
205 # And at rest, not sloshing: the shut-in outlet is closed implicitly
206 # (Session._close_outlet). Held at its start-of-step pressure it swung the
207 # drain ~30 g/s each way and the tank refused the half that came back.
208 assert fuel.refused_kg < 1e-4
209 assert [f.to_dict() for f in session.watcher.flags] == []
210
211
212@pytest.mark.skipif(
213 not (DRAWING.exists() and TABLES.is_dir()), reason="LE4 fixture or tables absent"
214)
216 """LE4 (6)'s unsized tee joins the fill line and the dump into the cart
217 tank's outlet node. With both hand valves held open the transfer tank
218 pushes into the fuel tank, then dumps itself dry -- and the fuel tank's
219 pressure must then go out through the same tee. Cutting every branch on the
220 dry tank's outlet froze it at 45 psig with the dump open."""
221 payload = json.loads(DRAWING.read_text())
222 ids = {n["data"]["label"]: n["id"] for n in payload["nodes"]}
223 model = assemble_model(read_diagram(payload, name="dump"), diagram_id="dump")
224 session = open_session(model, load_machine(tables=TABLES))
225 fuel, cart = (
226 session.tanks[ids["Eth-Tank"]],
227 session.tanks[ids["Fuel Transfer Tank"]],
228 )
229 for state, seconds in (("Armed", 1.0), ("Fuel Fill", 10.0), ("Armed", 2.0)):
230 session.command_state(state)
231 for _ in range(int(seconds / 0.5)):
232 session.step(0.5)
233 session.set_valve(ids["FF-MAN-Output"], True)
234 session.set_valve(ids["FF-MAN-Dump"], True)
235 for _ in range(60):
236 session.step(0.5)
237 assert cart.state.liquid_mass < 1e-3, "the dump emptied the cart tank"
238 assert fuel.pressure < AMBIENT + 0.5 * 6894.76, "and then the fuel tank"
239 assert fuel.state.liquid_mass > 5.0, "down to its head, not drained"
240 assert not [f for f in session.watcher.flags if f.check == "held_open"]
None test_a_low_pressure_branch_is_solved_beside_a_high_pressure_one()
None test_a_dry_transfer_tank_is_a_tee_the_fill_line_drains_through()
Network _drain_network(bool with_bottle)
None test_a_vent_still_cannot_pull_a_tank_below_atmosphere()
None test_a_sealed_tank_draining_goes_below_atmosphere_without_gaining_gas()
None test_a_full_fuel_tank_with_the_vent_shut_holds_against_an_open_drain()