STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_audit_regressions.py
Go to the documentation of this file.
1"""Regressions from the Stage A audit.
2
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.
7
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.
16"""
17
18from __future__ import annotations
19
20import textwrap
21from pathlib import Path
22
23import numpy as np
24import pytest
25
26from feedtwin.comps import FlowConditions, build_component, conditions_from_fluid
27from feedtwin.model import Catalog, ComponentInstance, Param, Provenance
28from feedtwin.model.component import EvalContext, PortState
29from feedtwin.model.units import UnknownUnit, register_unit, si_unit_of, to_si
30from feedtwin.props import Fluid, TabulatedProperties, UnknownFluid
31from feedtwin.solve import Network, solve_steady
32
33M = Provenance.MANUFACTURER
34WATER = FlowConditions(rho=1000.0, mu=1.0e-3, p_upstream=10.0e5)
35
36
37def _climbing_pipe(rise: float = 10.0) -> object:
38 return build_component(
39 ComponentInstance.build(
40 "FL-01",
41 "pipe",
42 {
43 "length": Param(1.0, "m", M, "test"),
44 "bore": Param(10.0, "mm", M, "test"),
45 "elevation_change": Param(rise, "m", M, "climbs"),
46 },
47 )
48 )
49
50
51# ----------------------------------------------------- static head vs. loss
52
53
55 """The bug: elevation was signed along with friction.
56
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.
61 """
62 line = _climbing_pipe()
63 head = 1000.0 * 9.80665 * 10.0
64
65 assert line.static_head(WATER) == pytest.approx(head) # type: ignore[attr-defined]
66
67 friction = line.pressure_drop(1.0, WATER) # type: ignore[attr-defined]
68 assert line.pressure_drop(-1.0, WATER) == pytest.approx(friction), ( # type: ignore[attr-defined]
69 "the loss itself is symmetric -- only its sign in the balance changes"
70 )
71
72 assert line.total_dp(1.0, WATER) == pytest.approx(friction + head) # type: ignore[attr-defined]
73 assert line.total_dp(-1.0, WATER) == pytest.approx(-friction + head) # type: ignore[attr-defined]
74
75
77 """Stated as the solver sees it, which is where the bug actually bit."""
78 line = _climbing_pipe()
79 line.conditions_provider = lambda port, signals: WATER # type: ignore[attr-defined]
80
81 friction = line.pressure_drop(1.0, WATER) # type: ignore[attr-defined]
82 head = line.static_head(WATER) # type: ignore[attr-defined]
83
84 p_in = 10.0e5
85 truth = -friction + head # p_up - p_dn, flowing backwards up a climb
86 ctx = EvalContext(
87 ports=(PortState(p=p_in, h=0.0), PortState(p=p_in - truth, h=0.0)),
88 flows=(-1.0,),
89 )
90 assert line.residuals(ctx)[0] == pytest.approx(0.0, abs=1e-6) # type: ignore[attr-defined]
91
92
94 """End to end: a branch running backwards up a climb still balances."""
95 net = Network()
96 net.add_node("low", "LOX", 90.0, pressure=20.0e5)
97 net.add_node("high", "LOX", 90.0, pressure=30.0e5)
98 net.add_branch("FL-01", _climbing_pipe(5.0), "low", "high") # type: ignore[arg-type]
99
100 result = solve_steady(net)
101 assert result.converged
102 assert result.flows["FL-01"] < 0.0, "flow runs high to low"
103
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
108 )
109
110
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, "")}),
115 (
116 "orifice",
117 {
118 "bore": Param(2.0, "mm", M, ""),
119 "pipe_bore": Param(8.0, "mm", M, ""),
120 },
121 ),
122 (
123 "flex_hose",
124 {"bore": Param(9.5, "mm", M, ""), "length": Param(0.5, "m", M, "")},
125 ),
126 ]:
127 component = build_component(ComponentInstance.build("X", type_name, params))
128 assert component.static_head(WATER) == 0.0, type_name
129
130
131# --------------------------------------------- constants along the chain
132
133
134def _measured_lox() -> Fluid:
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])
138 table = TabulatedProperties(p, T, {"rho": rho}, source="CF-2026-03")
139 return Fluid("LOX", chain=[table, "bicubic", "heos"])
140
141
143 """The bug: only the head of the chain was asked.
144
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.
149 """
150 lox = _measured_lox()
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)
154
155
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)
161
162
164 """With guidance, not a bare failure."""
165 p = np.array([1.0e6, 2.0e6])
166 T = np.array([85.0, 90.0])
167 table = TabulatedProperties(p, T, {"rho": np.ones((2, 2))}, source="unit-test")
168 with pytest.raises(Exception, match="equation-of-state backend"):
169 Fluid("LOX", chain=[table]).critical_pressure
170
171
172# --------------------------------------------------- catalogued curves
173
174
175CATALOG = textwrap.dedent(
176 """
177 ["hose-8an-600"]
178 type = "flex_hose"
179 manufacturer = "Aeroquip"
180
181 ["hose-8an-600".params.bore]
182 value = 9.5
183 unit = "mm"
184 source = "manufacturer"
185 reference = "datasheet"
186
187 ["hose-8an-600".params.length]
188 value = 0.6
189 unit = "m"
190 source = "manufacturer"
191 reference = "cut length"
192
193 ["hose-8an-600".curves.dp_mdot]
194 x = [0.0, 0.5, 1.0]
195 y = [0.0, 1.2, 4.6]
196 x_unit = "kg/s"
197 y_unit = "bar"
198 source = "measured"
199 reference = "CF-2026-03, 9 points"
200 """
201)
202
203
205 """The bug: the catalog held scalars only.
206
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.
210 """
211 path = tmp_path / "parts.toml"
212 path.write_text(CATALOG)
213 catalog = Catalog.from_file(path)
214
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
218
219 component = build_component(hose)
220 assert component.pressure_drop(0.5, WATER) == pytest.approx(1.2e5)
221
222
224 from feedtwin.model.catalog import Part
225
226 path = tmp_path / "parts.toml"
227 path.write_text(CATALOG)
228 catalog = Catalog.from_file(path)
229
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
234
235
237 """A retest of this particular hose beats the part's stored curve."""
238 from feedtwin.model.curve import Curve
239
240 path = tmp_path / "parts.toml"
241 path.write_text(CATALOG)
242 catalog = Catalog.from_file(path)
243
244 retest = Curve(
245 x=(0.0, 0.5, 1.0),
246 y=(0.0, 2.0, 8.0),
247 x_unit="kg/s",
248 y_unit="bar",
249 source=Provenance.MEASURED,
250 reference="CF-2026-11, after service",
251 )
252 hose = catalog.instantiate(
253 "FH-01", "hose-8an-600", curves={"dp_mdot": retest}, model="measured"
254 )
255 assert build_component(hose).pressure_drop(0.5, WATER) == pytest.approx(2.0e5)
256
257
258# ------------------------------------------------------------ unit lookup
259
260
262 """K and degC differences are the same size, so factor 1.0 cannot decide.
263
264 Before, the answer came from dictionary insertion order -- stable in
265 practice, invisible in the source, and not something a reader could check.
266 """
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)
270
271
273 register_unit("widget", "widgetry", 1.0, canonical=True)
274 assert si_unit_of("widgetry") == "widget"
275
276
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"):
281 to_si(1.0, "inches")
282
283
285 with pytest.raises(UnknownFluid, match="'nitrogen'"):
286 Fluid("nitrogn")
287
288
289# ------------------------------------------------- dead-ended manifold ports
290
291
292def _stub_network() -> Network:
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.
295
296 The shape of most real feed systems, and the one the Phase 04 validator
297 used to refuse outright.
298 """
299 from feedtwin.model import Param as P
300
301 def pipe(cid: str, length: float, bore: float = 7.75) -> object:
302 return build_component(
303 ComponentInstance.build(
304 cid,
305 "pipe",
306 {"length": P(length, "m", M, ""), "bore": P(bore, "mm", M, "")},
307 )
308 )
309
310 net = Network()
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)
316
317 net.add_branch("p_in", pipe("p_in", 0.30), "copv", "MAN-01") # type: ignore[arg-type]
318 net.add_branch("p_reg", pipe("p_reg", 0.20), "MAN-01", "reg_in") # type: ignore[arg-type]
319 net.add_branch("p_rv", pipe("p_rv", 0.05, 6.0), "MAN-01", "relief") # type: ignore[arg-type]
320 net.add_branch("p_qd", pipe("p_qd", 0.05, 6.0), "MAN-01", "qd") # type: ignore[arg-type]
321 return net
322
323
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.
327
328 They are correct hardware. Most ports on a real manifold carry no flow.
329 """
330 result = solve_steady(_stub_network())
331 assert result.converged
332 assert result.max_mass_residual < 1e-9
333
334
336 """Zero flow means no friction, so a level stub reads the plenum exactly."""
337 net = _stub_network()
338 result = solve_steady(net)
339
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
345
346
348 """They would otherwise sit on the derivative floor and be reported as
349 anomalies on every run of a perfectly ordinary system."""
350 net = _stub_network()
351 assert {d.branch for d in net.dead_ends()} == {"p_qd", "p_rv"}
352
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"
356
357
359 """A capped run two branches deep is still a stub.
360
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.
363 """
364 from feedtwin.model import Param as P
365
366 def pipe(cid: str) -> object:
367 return build_component(
368 ComponentInstance.build(
369 cid,
370 "pipe",
371 {"length": P(0.2, "m", M, ""), "bore": P(6.0, "mm", M, "")},
372 )
373 )
374
375 net = _stub_network()
376 net.add_node("qd_cap", "nitrogen", 293.0)
377 net.add_branch("p_cap", pipe("p_cap"), "qd", "qd_cap") # type: ignore[arg-type]
378
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"])
383
384
386 """A check valve behind its cracking pressure has no defined stub pressure.
387
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.
390 """
391 from feedtwin.model import Param as P
392
393 net = _stub_network()
394 net.add_node("cv_out", "nitrogen", 293.0)
395 check = build_component(
396 ComponentInstance.build(
397 "CV-01",
398 "check_valve",
399 {
400 "Cv": P(1.0, "Cv", M, ""),
401 "bore": P(6.0, "mm", M, ""),
402 "cracking_pressure": P(5.0, "psi", M, "datasheet"),
403 },
404 )
405 )
406 net.add_branch("CV-01", check, "MAN-01", "cv_out")
407
408 result = solve_steady(net)
409 assert result.converged
410 assert "CV-01" in result.indeterminate_dead_ends
411
412
414 """A capped leg behind a shut valve holds what was trapped in it.
415
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.
422 """
423 from feedtwin.model import Param as P
424
425 net = _stub_network()
426 net.add_node("sv_out", "nitrogen", 293.0)
427 net.add_node("cap", "nitrogen", 293.0)
428 valve = build_component(
429 ComponentInstance.build(
430 "SV-01",
431 "valve",
432 {"Cv": P(1.0, "Cv", M, ""), "bore": P(6.0, "mm", M, "")},
433 )
434 )
435 net.add_branch("SV-01", valve, "MAN-01", "sv_out")
436 leg = build_component(
437 ComponentInstance.build(
438 "p_leg",
439 "pipe",
440 {"length": P(0.3, "m", M, ""), "bore": P(6.0, "mm", M, "")},
441 )
442 )
443 net.add_branch("p_leg", leg, "sv_out", "cap") # type: ignore[arg-type]
444
445 shut = solve_steady(net, signals={"SV-01.command": 0.0})
446 assert shut.converged
447 assert {"sv_out", "cap"} <= set(shut.indeterminate_nodes)
448
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_part_can_carry_measured_flow_data(Path tmp_path)
None test_a_new_dimension_needs_its_canonical_unit_declared()
None test_the_residual_is_zero_at_the_correct_reverse_state()
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)
object _climbing_pipe(float rise=10.0)
None test_a_chain_that_truly_cannot_report_constants_says_so()