1"""Regressions from the Stage A audit.
3Four defects found by probing Phases 00-04 after they were each "done". Three
4of them had passing tests either side of the seam they broke, which is the
5pattern worth noting: every one lived in the *join* between two phases, where
6each side was tested alone and the pair was not.
8* A static head that reversed sign with the flow (Phase 03 physics, exposed
9 only through Phase 04 assembly).
10* Substance constants asked of one backend instead of the chain, so putting
11 measured data first -- the point of the feature -- broke every component
12 evaluation (Phase 01 x Phase 03).
13* A catalog that could not hold a curve, making the ``measured`` model
14 unreachable from the parts database (Phase 02 x Phase 03).
15* A canonical unit resolved by dictionary order rather than declaration.
18from __future__
import annotations
21from pathlib
import Path
26from feedtwin.comps import FlowConditions, build_component, conditions_from_fluid
27from feedtwin.model import Catalog, ComponentInstance, Param, Provenance
33M = Provenance.MANUFACTURER
38 return build_component(
39 ComponentInstance.build(
43 "length":
Param(1.0,
"m", M,
"test"),
44 "bore":
Param(10.0,
"mm", M,
"test"),
45 "elevation_change":
Param(rise,
"m", M,
"climbs"),
55 """The bug: elevation was signed along with friction.
57 ``rho g dz`` is set by where the ends of the pipe are. Friction opposes the
58 flow and reverses with it; elevation does not. Conflating them put a
59 reversing branch out by exactly ``2 rho g dz`` -- while still converging and
60 still conserving mass, which is why nothing noticed.
63 head = 1000.0 * 9.80665 * 10.0
65 assert line.static_head(WATER) == pytest.approx(head)
67 friction = line.pressure_drop(1.0, WATER)
68 assert line.pressure_drop(-1.0, WATER) == pytest.approx(friction), (
69 "the loss itself is symmetric -- only its sign in the balance changes"
72 assert line.total_dp(1.0, WATER) == pytest.approx(friction + head)
73 assert line.total_dp(-1.0, WATER) == pytest.approx(-friction + head)
77 """Stated as the solver sees it, which is where the bug actually bit."""
79 line.conditions_provider =
lambda port, signals: WATER
81 friction = line.pressure_drop(1.0, WATER)
82 head = line.static_head(WATER)
85 truth = -friction + head
90 assert line.residuals(ctx)[0] == pytest.approx(0.0, abs=1e-6)
94 """End to end: a branch running backwards up a climb still balances."""
96 net.add_node(
"low",
"LOX", 90.0, pressure=20.0e5)
97 net.add_node(
"high",
"LOX", 90.0, pressure=30.0e5)
100 result = solve_steady(net)
101 assert result.converged
102 assert result.flows[
"FL-01"] < 0.0,
"flow runs high to low"
104 branch = net.branches[
"FL-01"]
105 conditions = net.conditions(
"low", result.pressures[
"low"])
106 assert result.dp(
"FL-01", net) == pytest.approx(
107 branch.component.total_dp(result.flows[
"FL-01"], conditions), rel=1e-6
112 """The default is zero, so nothing else acquired a spurious head."""
113 for type_name, params
in [
114 (
"valve", {
"Cv":
Param(1.0,
"Cv", M,
""),
"bore":
Param(10.0,
"mm", M,
"")}),
118 "bore":
Param(2.0,
"mm", M,
""),
119 "pipe_bore":
Param(8.0,
"mm", M,
""),
124 {
"bore":
Param(9.5,
"mm", M,
""),
"length":
Param(0.5,
"m", M,
"")},
127 component = build_component(ComponentInstance.build(
"X", type_name, params))
128 assert component.static_head(WATER) == 0.0, type_name
135 p = np.array([1.0e6, 2.0e6, 3.0e6])
136 T = np.array([85.0, 90.0, 95.0])
137 rho = np.array([[
Fluid(
"LOX").get(
"rho", p=pi, T=Ti)
for Ti
in T]
for pi
in p])
139 return Fluid(
"LOX", chain=[table,
"bicubic",
"heos"])
143 """The bug: only the head of the chain was asked.
145 A measured table has no critical point to report, so putting one in front --
146 which is the entire point of the feature -- made ``critical_pressure`` raise,
147 which made ``conditions_from_fluid`` raise, which broke every component
148 evaluation downstream of it.
151 assert lox.chain[0] ==
"measured"
152 assert lox.critical_pressure == pytest.approx(5.043e6, rel=1e-2)
153 assert lox.critical_temperature == pytest.approx(154.6, rel=1e-2)
157 """The downstream consequence, checked where it actually mattered."""
158 conditions = conditions_from_fluid(
_measured_lox(), p=2.0e6, T=90.0)
159 assert conditions.rho > 0.0
160 assert conditions.p_crit == pytest.approx(5.043e6, rel=1e-2)
164 """With guidance, not a bare failure."""
165 p = np.array([1.0e6, 2.0e6])
166 T = np.array([85.0, 90.0])
168 with pytest.raises(Exception, match=
"equation-of-state backend"):
169 Fluid(
"LOX", chain=[table]).critical_pressure
175CATALOG = textwrap.dedent(
179 manufacturer = "Aeroquip"
181 ["hose-8an-600".params.bore]
184 source = "manufacturer"
185 reference = "datasheet"
187 ["hose-8an-600".params.length]
190 source = "manufacturer"
191 reference = "cut length"
193 ["hose-8an-600".curves.dp_mdot]
199 reference = "CF-2026-03, 9 points"
205 """The bug: the catalog held scalars only.
207 So a part with measured flow data could not be catalogued, the ``measured``
208 model was reachable from code but not from the parts database, and Phase 12
209 had nowhere to write a fitted curve back to.
211 path = tmp_path /
"parts.toml"
212 path.write_text(CATALOG)
213 catalog = Catalog.from_file(path)
215 hose = catalog.instantiate(
"FH-01",
"hose-8an-600", model=
"measured")
216 assert hose.curves[
"dp_mdot"].source
is Provenance.MEASURED
217 assert "CF-2026-03" in hose.curves[
"dp_mdot"].reference
219 component = build_component(hose)
220 assert component.pressure_drop(0.5, WATER) == pytest.approx(1.2e5)
226 path = tmp_path /
"parts.toml"
227 path.write_text(CATALOG)
228 catalog = Catalog.from_file(path)
230 data = catalog.to_dict()
231 rebuilt =
Catalog(Part.from_dict(pid, body)
for pid, body
in data.items())
232 assert rebuilt.to_dict() == data
233 assert "dp_mdot" in rebuilt.get(
"hose-8an-600").curves
237 """A retest of this particular hose beats the part's stored curve."""
240 path = tmp_path /
"parts.toml"
241 path.write_text(CATALOG)
242 catalog = Catalog.from_file(path)
249 source=Provenance.MEASURED,
250 reference=
"CF-2026-11, after service",
252 hose = catalog.instantiate(
253 "FH-01",
"hose-8an-600", curves={
"dp_mdot": retest}, model=
"measured"
255 assert build_component(hose).pressure_drop(0.5, WATER) == pytest.approx(2.0e5)
262 """K and degC differences are the same size, so factor 1.0 cannot decide.
264 Before, the answer came from dictionary insertion order -- stable in
265 practice, invisible in the source, and not something a reader could check.
267 assert si_unit_of(
"temperature_difference") ==
"K_diff"
268 assert to_si(20.0,
"degC_diff") == pytest.approx(20.0)
269 assert to_si(20.0,
"K_diff") == pytest.approx(20.0)
273 register_unit(
"widget",
"widgetry", 1.0, canonical=
True)
274 assert si_unit_of(
"widgetry") ==
"widget"
278 """Fifty units listed is what you read when you do not know what exists.
279 A typo is the commoner case and deserves a pointer."""
280 with pytest.raises(UnknownUnit, match=
"Did you mean"):
285 with pytest.raises(UnknownFluid, match=
"'nitrogen'"):
293 """A COPV feeding a manifold: PT-less plenum with a shut relief and a
294 capped fill quick-disconnect hanging off it, and a regulator downstream.
296 The shape of most real feed systems, and the one the Phase 04 validator
297 used to refuse outright.
301 def pipe(cid: str, length: float, bore: float = 7.75) -> object:
302 return build_component(
303 ComponentInstance.build(
306 {
"length": P(length,
"m", M,
""),
"bore": P(bore,
"mm", M,
"")},
311 net.add_node(
"copv",
"nitrogen", 293.0, pressure=310.0e5)
312 net.add_node(
"MAN-01",
"nitrogen", 293.0)
313 net.add_node(
"reg_in",
"nitrogen", 293.0, pressure=250.0e5)
314 net.add_node(
"relief",
"nitrogen", 293.0)
315 net.add_node(
"qd",
"nitrogen", 293.0)
317 net.add_branch(
"p_in", pipe(
"p_in", 0.30),
"copv",
"MAN-01")
318 net.add_branch(
"p_reg", pipe(
"p_reg", 0.20),
"MAN-01",
"reg_in")
319 net.add_branch(
"p_rv", pipe(
"p_rv", 0.05, 6.0),
"MAN-01",
"relief")
320 net.add_branch(
"p_qd", pipe(
"p_qd", 0.05, 6.0),
"MAN-01",
"qd")
325 """The bug: a transducer port, a capped QD and a shut relief are dead ends,
326 and the validator treated every one as a malformed network.
328 They are correct hardware. Most ports on a real manifold carry no flow.
331 assert result.converged
332 assert result.max_mass_residual < 1e-9
336 """Zero flow means no friction, so a level stub reads the plenum exactly."""
338 result = solve_steady(net)
340 plenum = result.pressures[
"MAN-01"]
341 assert result.pressures[
"qd"] == pytest.approx(plenum)
342 assert result.pressures[
"relief"] == pytest.approx(plenum)
343 assert result.flows[
"p_qd"] == 0.0
344 assert result.flows[
"p_rv"] == 0.0
348 """They would otherwise sit on the derivative floor and be reported as
349 anomalies on every run of a perfectly ordinary system."""
351 assert {d.branch
for d
in net.dead_ends()} == {
"p_qd",
"p_rv"}
353 result = solve_steady(net)
354 assert set(result.dead_ends) == {
"p_qd",
"p_rv"}
355 assert result.regularised_branches == [],
"the floor should never be reached"
359 """A capped run two branches deep is still a stub.
361 Removing the outer branch is what makes the inner one a stub in turn, so
362 the peel has to iterate rather than scan once.
366 def pipe(cid: str) -> object:
367 return build_component(
368 ComponentInstance.build(
371 {
"length": P(0.2,
"m", M,
""),
"bore": P(6.0,
"mm", M,
"")},
376 net.add_node(
"qd_cap",
"nitrogen", 293.0)
377 net.add_branch(
"p_cap", pipe(
"p_cap"),
"qd",
"qd_cap")
379 assert {d.branch
for d
in net.dead_ends()} == {
"p_qd",
"p_rv",
"p_cap"}
380 result = solve_steady(net)
381 assert result.converged
382 assert result.pressures[
"qd_cap"] == pytest.approx(result.pressures[
"MAN-01"])
386 """A check valve behind its cracking pressure has no defined stub pressure.
388 Nothing is flowing, so nothing holds that drop; the honest report is the
389 live end's pressure as an upper bound, plus a note that it is one.
394 net.add_node(
"cv_out",
"nitrogen", 293.0)
395 check = build_component(
396 ComponentInstance.build(
400 "Cv": P(1.0,
"Cv", M,
""),
401 "bore": P(6.0,
"mm", M,
""),
402 "cracking_pressure": P(5.0,
"psi", M,
"datasheet"),
406 net.add_branch(
"CV-01", check,
"MAN-01",
"cv_out")
408 result = solve_steady(net)
409 assert result.converged
410 assert "CV-01" in result.indeterminate_dead_ends
414 """A capped leg behind a shut valve holds what was trapped in it.
416 The check above asks the component for a drop at zero flow, and a shut ball
417 valve has none: its ``pressure_drop(0)`` is identically zero. So the leg
418 was back-filled from the manifold across the seat and reported as a
419 reading -- a transducer there followed the plenum it is shut off from
420 (docs/PHYSICS-BENCHMARK.md 3.2). Open, the same leg is an ordinary stub on
421 the manifold and reads it.
426 net.add_node(
"sv_out",
"nitrogen", 293.0)
427 net.add_node(
"cap",
"nitrogen", 293.0)
428 valve = build_component(
429 ComponentInstance.build(
432 {
"Cv": P(1.0,
"Cv", M,
""),
"bore": P(6.0,
"mm", M,
"")},
435 net.add_branch(
"SV-01", valve,
"MAN-01",
"sv_out")
436 leg = build_component(
437 ComponentInstance.build(
440 {
"length": P(0.3,
"m", M,
""),
"bore": P(6.0,
"mm", M,
"")},
443 net.add_branch(
"p_leg", leg,
"sv_out",
"cap")
445 shut = solve_steady(net, signals={
"SV-01.command": 0.0})
446 assert shut.converged
447 assert {
"sv_out",
"cap"} <= set(shut.indeterminate_nodes)
449 opened = solve_steady(net, signals={
"SV-01.command": 1.0})
450 assert opened.converged
451 assert not {
"sv_out",
"cap"} & set(opened.indeterminate_nodes)
452 assert opened.pressures[
"cap"] == pytest.approx(opened.pressures[
"MAN-01"])
None test_catalogued_curves_survive_a_round_trip(Path tmp_path)
None test_a_mistyped_fluid_suggests_the_right_one()
None test_a_part_can_carry_measured_flow_data(Path tmp_path)
None test_static_head_does_not_reverse_with_the_flow()
None test_a_measured_fluid_still_drives_components()
None test_a_new_dimension_needs_its_canonical_unit_declared()
None test_a_mistyped_unit_suggests_the_right_one()
None test_a_leg_behind_a_shut_valve_is_flagged()
None test_the_residual_is_zero_at_the_correct_reverse_state()
None test_the_canonical_unit_is_declared_not_inferred()
None test_dead_ended_ports_no_longer_refuse_to_solve()
None test_a_network_with_elevation_and_reverse_flow_is_consistent()
None test_substance_constants_are_sought_along_the_whole_chain()
None test_components_without_elevation_report_no_static_head()
None test_a_passed_curve_overrides_the_catalogued_one(Path tmp_path)
None test_a_stub_sits_at_its_plenum_pressure()
None test_an_indeterminate_stub_is_flagged()
object _climbing_pipe(float rise=10.0)
None test_a_chain_that_truly_cannot_report_constants_says_so()
None test_stubs_are_peeled_transitively()
None test_stubs_are_pruned_rather_than_iterated()