STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_solve_steady.py
Go to the documentation of this file.
1"""The steady network solve: does it get the right answer, and know when it hasn't?
2
3Phase 04's exit criteria live here. The one that matters most is not accuracy
4but *honesty about* accuracy: a Newton solve will happily converge on a system
5that does not conserve mass, and that failure looks exactly like success. Mass
6residuals are therefore checked on every solve, not sampled.
7
8The physics is not re-validated here -- Phase 03 did that against closed forms
9and definitions. What is checked is that the solver reproduces those component
10answers when it assembles them into a network, which is a different claim and
11the one that can break.
12"""
13
14from __future__ import annotations
15
16import math
17
18import pytest
19
20from feedtwin.comps import build_component
21from feedtwin.model import ComponentInstance, Param, Provenance
22from feedtwin.solve import (
23 ConvergenceError,
24 Network,
25 NetworkError,
26 pressure_ladder,
27 solve_steady,
28)
29
30M = Provenance.MANUFACTURER
31
32
33def _pipe(cid: str, length: float, bore_mm: float = 7.75, **extra: Param) -> object:
34 params: dict[str, Param] = {
35 "length": Param(length, "m", M, "drawing"),
36 "bore": Param(bore_mm, "mm", M, "3/8 x 0.035 tube"),
37 }
38 params.update(extra)
39 return build_component(ComponentInstance.build(cid, "pipe", params))
40
41
42def _valve(cid: str, cv: float) -> object:
43 return build_component(
44 ComponentInstance.build(
45 cid,
46 "valve",
47 {"Cv": Param(cv, "Cv", M, "datasheet"), "bore": Param(0.375, "in", M, "")},
48 )
49 )
50
51
52def _series_network() -> Network:
53 """Tank to injector through two lines and a valve."""
54 net = Network()
55 net.add_node("tank", "LOX", 90.0, pressure=30.0e5)
56 net.add_node("n1", "LOX", 90.0)
57 net.add_node("n2", "LOX", 90.0)
58 net.add_node("inj", "LOX", 90.0, pressure=20.0e5)
59 net.add_branch("FL-01", _pipe("FL-01", 1.5), "tank", "n1") # type: ignore[arg-type]
60 net.add_branch("SOL-01", _valve("SOL-01", 4.0), "n1", "n2") # type: ignore[arg-type]
61 net.add_branch("FL-02", _pipe("FL-02", 0.8), "n2", "inj") # type: ignore[arg-type]
62 return net
63
64
65# ------------------------------------------------------------------ the answer
66
67
69 net = _series_network()
70 result = solve_steady(net)
71
72 assert result.converged
73 assert result.max_mass_residual < 1e-9, "mass must balance at every node"
74
75 # One path, so every branch carries the same flow. Not a tautology: the
76 # solver has three independent flow unknowns and only mass balance ties
77 # them together.
78 flows = list(result.flows.values())
79 assert all(f == pytest.approx(flows[0], rel=1e-9) for f in flows)
80 assert flows[0] > 0.0
81
82
84 """The whole point of a pressure ladder: nothing goes missing.
85
86 Tank minus injector is fixed by the boundary conditions, so the components
87 between them must account for exactly that much and no more.
88 """
89 net = _series_network()
90 result = solve_steady(net)
91
92 available = result.pressures["tank"] - result.pressures["inj"]
93 accounted = sum(result.dp(bid, net) for bid in net.branches)
94 assert accounted == pytest.approx(available, rel=1e-9)
95
96
97def test_each_branch_drop_matches_its_component(monkeypatch: object) -> None:
98 """The network's answer agrees with the component evaluated alone.
99
100 This is the bridge between Phase 03's validation and Phase 04's assembly:
101 the solve is only as good as the components, and this checks the solver is
102 not quietly transforming them on the way through.
103 """
104 net = _series_network()
105 result = solve_steady(net)
106
107 for branch_id, branch in net.branches.items():
108 conditions = net.conditions(branch.upstream, result.pressures[branch.upstream])
109 standalone = branch.component.pressure_drop(result.flows[branch_id], conditions)
110 assert result.dp(branch_id, net) == pytest.approx(standalone, rel=1e-6)
111
112
114 """Two paths between the same nodes: the easier one takes more.
115
116 Mass balance at the split is what makes this non-trivial -- the solver has
117 to find the division rather than being told it.
118 """
119 net = Network()
120 net.add_node("up", "LOX", 90.0, pressure=30.0e5)
121 net.add_node("dn", "LOX", 90.0, pressure=28.0e5)
122 net.add_branch("BIG", _pipe("BIG", 1.0, bore_mm=12.0), "up", "dn") # type: ignore[arg-type]
123 net.add_branch("SMALL", _pipe("SMALL", 1.0, bore_mm=6.0), "up", "dn") # type: ignore[arg-type]
124
125 result = solve_steady(net)
126 assert result.converged
127 assert result.flows["BIG"] > result.flows["SMALL"], "the wider line flows more"
128
129 # Both see the same pressure difference -- they share both end nodes.
130 assert result.dp("BIG", net) == pytest.approx(result.dp("SMALL", net))
131
132
134 """How an engine's appetite enters a feed-system solve."""
135 net = Network()
136 net.add_node("tank", "LOX", 90.0, pressure=30.0e5)
137 net.add_node("face", "LOX", 90.0, demand=0.8)
138 net.add_branch("FL-01", _pipe("FL-01", 2.0), "tank", "face") # type: ignore[arg-type]
139
140 result = solve_steady(net)
141 assert result.converged
142 assert result.flows["FL-01"] == pytest.approx(0.8, rel=1e-9)
143 assert result.pressures["face"] < result.pressures["tank"]
144
145
147 """Upstream and downstream are labels, not constraints."""
148 net = Network()
149 net.add_node("a", "LOX", 90.0, pressure=20.0e5)
150 net.add_node("b", "LOX", 90.0, pressure=30.0e5)
151 net.add_branch("FL-01", _pipe("FL-01", 1.0), "a", "b") # type: ignore[arg-type]
152
153 result = solve_steady(net)
154 assert result.converged
155 assert result.flows["FL-01"] < 0.0, "flow runs b to a, reported as negative"
156
157
158# ------------------------------------------------------------- exit criteria
159
160
162 """Phase 04's parity criterion, against the code this replaces.
163
164 EngineDesign's ``feed_loss.delta_p_feed`` is ``K_eff . rho v^2 / 2`` over a
165 single lumped coefficient. A zero-length pipe carrying only ``K_minor`` is
166 exactly that, so the two must agree to machine precision -- and when the
167 steady solver lands in EngineDesign, this is the test that says the
168 replacement is faithful.
169 """
170 K_eff, bore, mdot = 2.0, 0.009525, 1.2
171 rho = 1141.0
172
173 area = math.pi * bore * bore / 4.0
174 velocity = mdot / (rho * area)
175 expected = K_eff * (rho / 2.0) * velocity**2
176
177 line = build_component(
178 ComponentInstance.build(
179 "FEED",
180 "pipe",
181 {
182 "length": Param(0.0, "m", M, "lumped model has no length"),
183 "bore": Param(bore, "m", M, "3/8 NPT bore"),
184 "K_minor": Param(K_eff, "-", M, "EngineDesign feed_system.K0"),
185 },
186 )
187 )
188 from feedtwin.comps import FlowConditions
189
190 conditions = FlowConditions(rho=rho, mu=2.0e-4, p_upstream=30.0e5)
191 assert line.pressure_drop(mdot, conditions) == pytest.approx(expected, rel=1e-12)
192
193
194def _ladder_network(stages: int) -> Network:
195 """A dual-branch ladder: two parallel runs cross-linked at every stage."""
196 net = Network()
197 net.add_node("src", "LOX", 90.0, pressure=40.0e5)
198 net.add_node("sink", "LOX", 90.0, pressure=20.0e5)
199
200 for side in ("a", "b"):
201 previous = "src"
202 for i in range(stages):
203 node = f"{side}{i}"
204 net.add_node(node, "LOX", 90.0)
205 net.add_branch(
206 f"{side}L{i}", _pipe(f"{side}L{i}", 0.4), previous, node # type: ignore[arg-type]
207 )
208 previous = node
209 net.add_branch(f"{side}L{stages}", _pipe(f"{side}L{stages}", 0.4), previous, "sink") # type: ignore[arg-type]
210
211 for i in range(0, stages, 2):
212 net.add_branch(f"X{i}", _pipe(f"X{i}", 0.2, bore_mm=5.0), f"a{i}", f"b{i}") # type: ignore[arg-type]
213 return net
214
215
217 """Phase 04's performance criterion.
218
219 Forty-odd nodes, two parallel runs with cross-links, in well under 100 ms.
220 The budget matters because Phase 07 marches this solve thousands of times
221 and Phase 15's optimizer will call it far more than that.
222 """
223 net = _ladder_network(stages=19)
224 assert len(net.nodes) >= 40, f"only {len(net.nodes)} nodes"
225
226 solve_steady(net) # warm any table build out of the measurement
227 result = solve_steady(_ladder_network(stages=19))
228
229 assert result.converged
230 assert result.max_mass_residual < 1e-9
231 assert result.elapsed < 0.100, f"took {result.elapsed * 1e3:.1f} ms"
232
233
235 """Not sampled, not optional. A converged solve that does not conserve
236 mass has found the wrong answer confidently, and that is the failure that
237 looks like success."""
238 result = solve_steady(_series_network())
239 assert set(result.mass_residuals) == set(_series_network().free_nodes)
240 assert result.max_mass_residual >= 0.0
241
242
244 net = _series_network()
245 text = pressure_ladder(net, solve_steady(net))
246
247 assert "converged" in text
248 for branch_id in net.branches:
249 assert branch_id in text
250 assert "mass conservation" in text
251 assert "library defaults" in text, "assumed parameters must be visible"
252
253
255 """Correlations and equations of state are not frozen between releases."""
256 stack = solve_steady(_series_network()).stack
257 assert stack["feedtwin"]
258 assert stack["CoolProp"] and stack["fluids"]
259
260
261# ------------------------------------------------------------ failure modes
262
263
265 """No reference pressure means every pressure is arbitrary."""
266 net = Network()
267 net.add_node("a", "LOX", 90.0)
268 net.add_node("b", "LOX", 90.0)
269 net.add_branch("FL-01", _pipe("FL-01", 1.0), "a", "b") # type: ignore[arg-type]
270
271 with pytest.raises(NetworkError, match="no reference"):
272 solve_steady(net)
273
274
276 """A tank feeding one capped line and nothing else.
277
278 Everything prunes to stubs, so there is nothing to iterate on -- but that is
279 not the same as there being no answer. Nothing flows, and the capped line
280 sits at the tank's pressure. This used to raise, and that was wrong for the
281 case that matters most: a stand with its whole panel shut prunes to exactly
282 this shape, and it is where a stand spends most of its life. Refusing to
283 describe it made the most ordinary state on the pad an error.
284 """
285 net = Network()
286 net.add_node("tank", "LOX", 90.0, pressure=30.0e5)
287 net.add_node("stub", "LOX", 90.0)
288 net.add_branch("FL-01", _pipe("FL-01", 1.0), "tank", "stub") # type: ignore[arg-type]
289
290 result = solve_steady(net)
291 assert result.converged
292 assert result.flows["FL-01"] == 0.0
293 # Zero flow, so the only difference across the line is its static head.
294 assert result.pressures["stub"] == pytest.approx(30.0e5, rel=1e-3)
295
296
298 """The genuinely empty case stays an error -- there is no system here."""
299 net = Network()
300 net.add_node("tank", "LOX", 90.0, pressure=30.0e5)
301
302 with pytest.raises(NetworkError):
303 solve_steady(net)
304
305
307 net = Network()
308 net.add_node("lonely", "LOX", 90.0)
309 net.add_node("a", "LOX", 90.0)
310 net.add_node("b", "LOX", 90.0)
311 net.add_branch("FL-01", _pipe("FL-01", 1.0), "a", "b") # type: ignore[arg-type]
312
313 with pytest.raises(NetworkError) as excinfo:
314 net.validate()
315 message = str(excinfo.value)
316 assert "lonely" in message and "no reference" in message
317
318
320 net = Network()
321 net.add_node("a", "LOX", 90.0, pressure=1e6)
322 with pytest.raises(NetworkError, match="unknown node"):
323 net.add_branch("FL-01", _pipe("FL-01", 1.0), "a", "nowhere") # type: ignore[arg-type]
324
325
327 """What a parameter sweep wants: keep going, record the failure."""
328 net = _series_network()
329 result = solve_steady(net, max_iterations=1, raise_on_failure=False)
330 assert not result.converged
331 assert result.iterations == 1
332
333 with pytest.raises(ConvergenceError) as excinfo:
334 solve_steady(net, max_iterations=1)
335 assert excinfo.value.result.iterations == 1
336
337
339 """A bend inside its limit does not stop the solve, and is still reported."""
340 net = Network()
341 net.add_node("tank", "LOX", 90.0, pressure=30.0e5)
342 net.add_node("inj", "LOX", 90.0, pressure=25.0e5)
343 bend = build_component(
344 ComponentInstance.build(
345 "BD-01",
346 "bend",
347 {
348 "bore": Param(7.75, "mm", M, ""),
349 "bend_radius": Param(10.0, "mm", M, "as built"),
350 "min_bend_radius": Param(28.6, "mm", M, "3x OD"),
351 },
352 )
353 )
354 net.add_branch("BD-01", bend, "tank", "inj")
355
356 result = solve_steady(net)
357 assert result.converged
358 assert [v.limit for v in result.violations] == ["min_bend_radius"]
359 assert "min_bend_radius" in pressure_ladder(net, result)
360
361
363 """The derivative floor is a named regularisation, and it says when it acted.
364
365 A quadratic loss has zero slope at zero flow, so a shut branch would make
366 the Jacobian singular. The floor keeps the solve possible; reporting it
367 keeps it from ever being silently load-bearing.
368 """
369 net = Network()
370 net.add_node("tank", "LOX", 90.0, pressure=30.0e5)
371 net.add_node("mid", "LOX", 90.0)
372 net.add_node("inj", "LOX", 90.0, pressure=29.9e5)
373 net.add_branch("FL-01", _pipe("FL-01", 0.5), "tank", "mid") # type: ignore[arg-type]
374 net.add_branch("FL-02", _pipe("FL-02", 0.5), "mid", "inj") # type: ignore[arg-type]
375
376 result = solve_steady(net)
377 assert result.converged
378 # Nothing is shut here, so nothing should be sitting on the floor.
379 assert result.regularised_branches == []
380
381
382# ------------------------------------------------------------- the live path
383
384
385def _teed_network() -> Network:
386 """The series network with a tee: a second line to a second injector, and
387 a capped stub off the middle node."""
388 net = _series_network()
389 net.add_node("inj2", "LOX", 90.0, pressure=22.0e5)
390 net.add_node("cap", "LOX", 90.0)
391 net.add_branch("FL-03", _pipe("FL-03", 0.6), "n2", "inj2") # type: ignore[arg-type]
392 net.add_branch("FL-04", _pipe("FL-04", 0.3), "n1", "cap") # type: ignore[arg-type]
393 return net
394
395
397 """A live session asks for no per-branch report (``report=False``): the
398 pressures, flows and mass residuals are the same numbers, and the residuals
399 are each node's net inflow."""
400 net = _teed_network()
401 full = solve_steady(net)
402 bare = solve_steady(net, report=False)
403 assert full.converged and bare.converged
404 assert bare.pressures == full.pressures
405 assert bare.flows == full.flows
406 assert bare.mass_residuals == full.mass_residuals
407 assert bare.iterations == full.iterations
408 assert full.diagnostics and not bare.diagnostics
409 for node_id, residual in bare.mass_residuals.items():
410 total = -net.nodes[node_id].demand
411 for branch, sign in net.branches_at(node_id):
412 total += sign * bare.flows[branch.id]
413 assert residual == total, node_id
414
415
417 """Dead ends are remembered per shut set, but a branch moved to another node
418 is a different network."""
419 net = _teed_network()
420 assert {d.branch for d in net.dead_ends()} == {"FL-04"}
421 net.branches["FL-04"].downstream = "inj2"
422 assert net.dead_ends() == []
423 assert {d.branch for d in net.dead_ends(exclude={"FL-03"})} == set()
424 net.branches["FL-04"].downstream = "cap"
425 assert {d.branch for d in net.dead_ends()} == {"FL-04"}
426
427
428# ------------------------------------------------- the report is of this solve
429
430PSI = 6894.757293168361
431
432
433def _regulated_vent() -> Network:
434 """Bottle -> dome-loaded regulator -> part-open valve -> atmosphere."""
435 reg = build_component(
436 ComponentInstance.build(
437 "REG",
438 "regulator",
439 {
440 "setpoint": Param(400.0, "psia", M, ""),
441 "dome_bias": Param(50.0, "psi", M, ""),
442 "Cv": Param(0.8, "Cv", M, ""),
443 "bore": Param(7.75, "mm", M, ""),
444 "flow_droop": Param(20.0, "psi", M, ""),
445 "rated_flow": Param(0.05, "kg/s", M, ""),
446 },
447 model="droop",
448 )
449 )
450 net = Network()
451 net.add_node("COPV", "nitrogen", 293.0, pressure=3000.0 * PSI)
452 net.add_node("R", "nitrogen", 293.0)
453 net.add_node("atm", "nitrogen", 293.0, pressure=14.7 * PSI)
454 for node in net.nodes.values():
455 node.phase = "gas"
456 net.add_branch("REG", reg, "COPV", "R") # type: ignore[arg-type]
457 net.add_branch("V", _valve("V", 1.0), "R", "atm") # type: ignore[arg-type]
458 return net
459
460
462 """Diagnostics re-evaluate each branch at the answer. They used to do it
463 with no signals: a dome-loaded regulator reported its drawn 400 psia
464 setpoint and a zero dome while it held 530 psia, and a valve commanded to
465 30 % reported itself fully open with a 14 psi drop against an actual 516."""
466 net = _regulated_vent()
467 signals = {"REG.dome": 500.0 * PSI, "V.command": 0.3}
468 result = solve_steady(net, signals=signals)
469 assert result.converged
470 regulator = result.diagnostics["REG"]
471 assert regulator["dome_pressure"] == pytest.approx(500.0 * PSI)
472 assert regulator["outlet_target"] / PSI > 500.0
473 valve = result.diagnostics["V"]
474 component = net.branches["V"].component
475 assert valve["position"] == pytest.approx(0.3)
476 assert valve["Cv"] == pytest.approx(component.effective_cv(0.3)) # type: ignore[attr-defined]
477 # 530 psia to atmosphere through a part-open valve is sonic, and the
478 # classification is taken with the same command.
479 assert result.choked_branches == ["V"]
480
481
483 """``choked_branches`` was built and never handed to the result."""
484 net = Network()
485 net.add_node("tank", "nitrogen", 293.0, pressure=500.0 * PSI)
486 net.add_node("atm", "nitrogen", 293.0, pressure=14.7 * PSI)
487 for node in net.nodes.values():
488 node.phase = "gas"
489 net.add_branch("V", _valve("V", 1.0), "tank", "atm") # type: ignore[arg-type]
490 result = solve_steady(net)
491 assert result.converged
492 valve = net.branches["V"].component
493 ceiling = valve.flow_ceiling(net.conditions("tank", 500.0 * PSI))
494 assert result.flows["V"] == pytest.approx(ceiling, rel=1e-6)
495 assert result.choked_branches == ["V"]
None test_a_series_network_solves_and_conserves_mass()
None test_mass_residuals_are_reported_on_every_run()
None test_the_report_describes_the_signals_the_solve_ran_with()
None test_the_report_shows_where_the_pressure_went()
None test_design_violations_survive_into_the_result()
None test_structural_problems_are_reported_together()
object _pipe(str cid, float length, float bore_mm=7.75, **Param extra)
Network _ladder_network(int stages)
None test_a_forty_node_dual_branch_network_solves_quickly()
None test_the_stub_memo_follows_the_wiring()
None test_the_result_records_the_stack_that_produced_it()
None test_a_parallel_split_divides_flow_by_resistance()
None test_a_choked_branch_is_listed_as_choked()
None test_a_solve_without_its_report_is_the_same_solve()
None test_a_network_that_is_only_a_stub_still_has_an_answer()
None test_a_branch_to_a_missing_node_is_refused_at_construction()
None test_it_reproduces_engine_designs_lumped_k_feed_loss()
None test_a_failed_solve_can_be_inspected_instead_of_raised()
None test_a_shut_valve_is_reported_as_regularised_not_hidden()
None test_each_branch_drop_matches_its_component(object monkeypatch)
None test_the_pressure_drops_sum_to_what_was_available()
None test_a_network_with_no_branches_at_all_is_still_refused()
None test_a_network_with_no_fixed_pressure_is_refused()
None test_flow_reverses_when_the_pressure_gradient_does()
object _valve(str cid, float cv)
None test_a_demand_is_met_from_the_branches_feeding_it()