1"""The steady network solve: does it get the right answer, and know when it hasn't?
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.
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.
14from __future__
import annotations
30M = Provenance.MANUFACTURER
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"),
39 return build_component(ComponentInstance.build(cid,
"pipe", params))
42def _valve(cid: str, cv: float) -> object:
43 return build_component(
44 ComponentInstance.build(
47 {
"Cv":
Param(cv,
"Cv", M,
"datasheet"),
"bore":
Param(0.375,
"in", M,
"")},
53 """Tank to injector through two lines and a valve."""
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")
60 net.add_branch(
"SOL-01",
_valve(
"SOL-01", 4.0),
"n1",
"n2")
61 net.add_branch(
"FL-02",
_pipe(
"FL-02", 0.8),
"n2",
"inj")
70 result = solve_steady(net)
72 assert result.converged
73 assert result.max_mass_residual < 1e-9,
"mass must balance at every node"
78 flows = list(result.flows.values())
79 assert all(f == pytest.approx(flows[0], rel=1e-9)
for f
in flows)
84 """The whole point of a pressure ladder: nothing goes missing.
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.
90 result = solve_steady(net)
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)
98 """The network's answer agrees with the component evaluated alone.
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.
105 result = solve_steady(net)
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)
114 """Two paths between the same nodes: the easier one takes more.
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.
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")
123 net.add_branch(
"SMALL",
_pipe(
"SMALL", 1.0, bore_mm=6.0),
"up",
"dn")
125 result = solve_steady(net)
126 assert result.converged
127 assert result.flows[
"BIG"] > result.flows[
"SMALL"],
"the wider line flows more"
130 assert result.dp(
"BIG", net) == pytest.approx(result.dp(
"SMALL", net))
134 """How an engine's appetite enters a feed-system solve."""
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")
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"]
147 """Upstream and downstream are labels, not constraints."""
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")
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"
162 """Phase 04's parity criterion, against the code this replaces.
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.
170 K_eff, bore, mdot = 2.0, 0.009525, 1.2
173 area = math.pi * bore * bore / 4.0
174 velocity = mdot / (rho * area)
175 expected = K_eff * (rho / 2.0) * velocity**2
177 line = build_component(
178 ComponentInstance.build(
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"),
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)
195 """A dual-branch ladder: two parallel runs cross-linked at every stage."""
197 net.add_node(
"src",
"LOX", 90.0, pressure=40.0e5)
198 net.add_node(
"sink",
"LOX", 90.0, pressure=20.0e5)
200 for side
in (
"a",
"b"):
202 for i
in range(stages):
204 net.add_node(node,
"LOX", 90.0)
206 f
"{side}L{i}",
_pipe(f
"{side}L{i}", 0.4), previous, node
209 net.add_branch(f
"{side}L{stages}",
_pipe(f
"{side}L{stages}", 0.4), previous,
"sink")
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}")
217 """Phase 04's performance criterion.
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.
224 assert len(net.nodes) >= 40, f
"only {len(net.nodes)} nodes"
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"
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."""
240 assert result.max_mass_residual >= 0.0
245 text = pressure_ladder(net, solve_steady(net))
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"
255 """Correlations and equations of state are not frozen between releases."""
257 assert stack[
"feedtwin"]
258 assert stack[
"CoolProp"]
and stack[
"fluids"]
265 """No reference pressure means every pressure is arbitrary."""
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")
271 with pytest.raises(NetworkError, match=
"no reference"):
276 """A tank feeding one capped line and nothing else.
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.
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")
290 result = solve_steady(net)
291 assert result.converged
292 assert result.flows[
"FL-01"] == 0.0
294 assert result.pressures[
"stub"] == pytest.approx(30.0e5, rel=1e-3)
298 """The genuinely empty case stays an error -- there is no system here."""
300 net.add_node(
"tank",
"LOX", 90.0, pressure=30.0e5)
302 with pytest.raises(NetworkError):
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")
313 with pytest.raises(NetworkError)
as excinfo:
315 message = str(excinfo.value)
316 assert "lonely" in message
and "no reference" in message
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")
327 """What a parameter sweep wants: keep going, record the failure."""
329 result = solve_steady(net, max_iterations=1, raise_on_failure=
False)
330 assert not result.converged
331 assert result.iterations == 1
333 with pytest.raises(ConvergenceError)
as excinfo:
334 solve_steady(net, max_iterations=1)
335 assert excinfo.value.result.iterations == 1
339 """A bend inside its limit does not stop the solve, and is still reported."""
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(
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"),
354 net.add_branch(
"BD-01", bend,
"tank",
"inj")
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)
363 """The derivative floor is a named regularisation, and it says when it acted.
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.
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")
374 net.add_branch(
"FL-02",
_pipe(
"FL-02", 0.5),
"mid",
"inj")
376 result = solve_steady(net)
377 assert result.converged
379 assert result.regularised_branches == []
386 """The series network with a tee: a second line to a second injector, and
387 a capped stub off the middle node."""
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")
392 net.add_branch(
"FL-04",
_pipe(
"FL-04", 0.3),
"n1",
"cap")
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."""
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
417 """Dead ends are remembered per shut set, but a branch moved to another node
418 is a different 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"}
430PSI = 6894.757293168361
434 """Bottle -> dome-loaded regulator -> part-open valve -> atmosphere."""
435 reg = build_component(
436 ComponentInstance.build(
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,
""),
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():
456 net.add_branch(
"REG", reg,
"COPV",
"R")
457 net.add_branch(
"V",
_valve(
"V", 1.0),
"R",
"atm")
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."""
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))
479 assert result.choked_branches == [
"V"]
483 """``choked_branches`` was built and never handed to the result."""
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():
489 net.add_branch(
"V",
_valve(
"V", 1.0),
"tank",
"atm")
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()
Network _series_network()
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()
Network _regulated_vent()
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()