1"""Do the components reproduce results that exist outside this codebase?
3Phase 03's exit criterion. Every check here is against something independent --
4a closed-form solution, or the definition of a unit, or a coefficient whose
5value is fixed by a standard. None of them checks this package against itself.
7The strongest ones are the *definitional* checks. A valve with Cv = 1 passing
8one US gallon per minute of water must drop exactly one psi, because that is
9what Cv means. A fitting with K = 1 must drop exactly one dynamic head, because
10that is what K means. Those cannot drift without something being genuinely
11wrong, and they exercise the whole path -- unit conversion, parameter
12resolution, correlation adapter -- rather than one function.
14Where a correlation has no closed form, the check is that ``fluids`` and this
15package agree, which tests the adapter and not the physics. That distinction is
16kept explicit in each docstring, because a test that looks like validation and
17is really a tautology is worse than no test.
20from __future__
import annotations
29 conditions_from_fluid,
34M = Provenance.MANUFACTURER
35PSI = 6894.757293168361
36GPM = 3.785411784e-3 / 60.0
42def _pipe(**overrides: Param) -> ComponentInstance:
43 params: dict[str, Param] = {
44 "length":
Param(1.0,
"m", M,
"test"),
45 "bore":
Param(10.0,
"mm", M,
"test"),
47 params.update(overrides)
48 return ComponentInstance.build(
"FL-01",
"pipe", params)
55 """The one pipe case with an exact analytic solution.
57 ``dp = 128 mu L Q / (pi D^4)``, equivalently f = 64/Re. Exact, no
58 correlation involved, so any disagreement is an error in the friction
59 factor, the Reynolds number, the dynamic head, or the unit conversion --
60 which between them is most of the pipe model.
63 mu, rho = 1.0e-3, 1000.0
66 line = build_component(
72 expected = 128.0 * mu * L * Q / (math.pi * D**4)
74 assert line.diagnostics(mdot, flow)[
"Re"] < 2040.0,
"must be laminar"
75 assert line.pressure_drop(mdot, flow) == pytest.approx(expected, rel=1e-9)
79 """Static head, with no flow, is not a correlation -- it is arithmetic.
81 Reported through ``static_head`` rather than ``pressure_drop`` because it
82 does not reverse with the flow; see test_audit_regressions for the bug that
83 distinction fixed. Ten metres of LOX is about 1.6 bar, so omitting it in a
84 tall vehicle is a real error, not a refinement.
87 line = build_component(
_pipe(elevation_change=
Param(climb,
"m", M,
"drawing")))
89 expected = 1141.0 * 9.80665 * climb
91 assert line.static_head(flow) == pytest.approx(expected)
93 assert line.total_dp(0.0, flow) == pytest.approx(expected)
94 assert line.pressure_drop(0.0, flow) == pytest.approx(0.0)
98 """K is defined as ``dp / (rho v^2 / 2)``. A sharp exit has K = 1 exactly.
100 So the drop through it must equal one dynamic head to machine precision --
101 a definitional check on the fitting adapter, the velocity calculation and
102 the dynamic head together.
104 elbow = build_component(
105 ComponentInstance.build(
108 {
"bore":
Param(10.0,
"mm", M,
"test")},
109 options={
"kind":
"exit"},
113 v = mdot / (WATER.rho * math.pi * 0.01**2 / 4.0)
114 expected = 1.0 * WATER.rho * v * v / 2.0
116 assert elbow.pressure_drop(mdot, WATER) == pytest.approx(expected, rel=1e-12)
117 assert elbow.diagnostics(mdot, WATER)[
"K"] == pytest.approx(1.0)
121 """Cv = 1 passing 1 US gpm of water drops 1 psi. That is what Cv *means*.
123 The keystone check of the valve model: it goes through the flow-coefficient
124 unit, the Cv-to-K conversion and the dynamic head, and its expected value
125 comes from the definition of the unit rather than from any library.
127 ``dp = SG (Q/Cv)^2``, so at SG = 0.999 the answer is 0.999 psi, not 1.000.
129 valve = build_component(
130 ComponentInstance.build(
134 "Cv":
Param(1.0,
"Cv", M,
"definition"),
135 "bore":
Param(10.0,
"mm", M,
"arbitrary"),
139 mdot = 1.0 * GPM * WATER.rho
140 dp = valve.pressure_drop(mdot, WATER)
142 specific_gravity = WATER.rho / 1000.0
143 assert dp / PSI == pytest.approx(specific_gravity, rel=1e-3)
146@pytest.mark.parametrize("bore_mm", [5.0, 10.0, 25.0, 50.0])
148 """Cv already accounts for the valve's geometry.
150 K does depend on bore, and Cv-to-K converts between them -- so if the two
151 were inconsistent, the drop would move with a diameter that Cv has already
152 priced in. A silent factor-of-several error, and this is what catches it.
154 valve = build_component(
155 ComponentInstance.build(
159 "Cv":
Param(2.5,
"Cv", M,
"test"),
160 "bore":
Param(bore_mm,
"mm", M,
"varied"),
165 Q_gpm = (mdot / WATER.rho) / GPM
166 expected = (WATER.rho / 1000.0) * (Q_gpm / 2.5) ** 2
167 assert valve.pressure_drop(mdot, WATER) / PSI == pytest.approx(expected, rel=1e-3)
171 """``m = Cd A sqrt(2 rho dp)``, solved for dp and checked forward."""
173 orifice = build_component(
174 ComponentInstance.build(
178 "bore":
Param(d,
"m", M,
"throat"),
179 "pipe_bore":
Param(8.0,
"mm", M,
"line"),
180 "Cd":
Param(Cd,
"-", M,
"sharp edged"),
186 dp = orifice.pressure_drop(mdot, WATER)
188 area = math.pi * d * d / 4.0
189 recovered = Cd * area * math.sqrt(2.0 * WATER.rho * dp)
190 assert recovered == pytest.approx(mdot, rel=1e-12)
194 """Agreement with ``fluids``, which implements ISO 5167 -- an adapter check.
196 This asserts that the arguments are passed correctly and the sign of the
197 result is right, not that Reader-Harris/Gallagher is correct. Stated plainly
198 because the difference matters: a passing test here is evidence about this
199 package, not about the standard.
201 from fluids.flow_meter
import differential_pressure_meter_solver
203 D, d, mdot = 0.05, 0.025, 3.0
206 upstream =
FlowConditions(rho=WATER.rho, mu=WATER.mu, p_upstream=10.0e5)
207 orifice = build_component(
208 ComponentInstance.build(
212 "bore":
Param(d,
"m", M,
"throat"),
213 "pipe_bore":
Param(D,
"m", M,
"line"),
218 dp = orifice.pressure_drop(mdot, upstream)
221 recovered = differential_pressure_meter_solver(
226 P1=upstream.p_upstream,
227 P2=upstream.p_upstream - dp,
228 meter_type=
"ISO 5167 orifice",
230 epsilon_specified=1.0,
232 assert float(recovered) == pytest.approx(mdot, rel=1e-6)
239 """Different correlations, same order of magnitude, different answers.
241 Which explicit approximation to Colebrook a project uses is a modelling
242 decision. This asserts both halves of that: the option genuinely changes the
243 result, and the alternatives agree to within a couple of percent, so the
244 choice is a refinement rather than a coin flip.
247 for method
in (
"Clamond",
"Churchill_1977",
"Haaland",
"Serghides_1"):
248 line = build_component(
249 ComponentInstance.build(
253 "length":
Param(2.0,
"m", M,
"test"),
254 "bore":
Param(10.0,
"mm", M,
"test"),
256 Param(45.0,
"um" if False else "mm", M,
"rough")
258 else Param(0.045,
"mm", M,
"commercial steel")
261 options={
"friction": method},
264 results[method] = line.pressure_drop(2.0, WATER)
266 values = list(results.values())
267 assert len(set(values)) > 1,
"the option changed nothing"
268 assert max(values) /
min(values) < 1.02, results
274 with pytest.raises(SpecError, match=
"not valid"):
275 ComponentInstance.build(
279 "length":
Param(1.0,
"m", M,
""),
280 "bore":
Param(10.0,
"mm", M,
""),
282 options={
"friction":
"vibes"},
286@pytest.mark.parametrize(
287 "characteristic,at_half",
288 [(
"linear", 0.5), (
"equal_percentage", 0.1636), (
"quick_opening", 0.9025)],
291 characteristic: str, at_half: float
293 """Half travel gives very different capacity depending on the trim."""
294 valve = build_component(
295 ComponentInstance.build(
298 {
"Cv":
Param(10.0,
"Cv", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
299 options={
"characteristic": characteristic},
302 assert valve.effective_cv(0.5) == pytest.approx(10.0 * at_half, rel=1e-3)
303 assert valve.effective_cv(1.0) == pytest.approx(10.0)
307 """Exactly zero capacity is a singular network, not a closed valve."""
308 valve = build_component(
309 ComponentInstance.build(
312 {
"Cv":
Param(1.2,
"Cv", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
315 assert valve.effective_cv(0.0) == pytest.approx(1e-6)
316 assert valve.effective_cv(0.0) > 0.0
320 """Forward it is a valve with a cracking pressure; backward it is shut.
322 The first component whose behaviour is not a smooth function of flow, and
323 the reason the solver interface has to be residual-based rather than
324 ``dp(mdot)``. Worth re-checking when Phase 04 settles that interface.
326 check = build_component(
327 ComponentInstance.build(
330 {
"Cv":
Param(2.0,
"Cv", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
333 forward = check.pressure_drop(0.5, WATER)
334 reverse = check.pressure_drop(-0.5, WATER)
336 assert reverse > 1e4 * forward,
"reverse flow must be effectively blocked"
337 assert check.diagnostics(0.5, WATER)[
"open"] == 1.0
338 assert check.diagnostics(-0.5, WATER)[
"open"] == 0.0
342 """At vanishing flow the drop tends to the cracking pressure, not to zero."""
343 check = build_component(
344 ComponentInstance.build(
348 "Cv":
Param(2.0,
"Cv", M,
""),
349 "bore":
Param(10.0,
"mm", M,
""),
350 "cracking_pressure":
Param(5.0,
"psi", M,
"datasheet"),
354 assert check.pressure_drop(1e-9, WATER) == pytest.approx(5.0 * PSI, rel=1e-6)
361 """An end-to-end sanity check on real propellant, not a pinned number.
363 Phase 12 is what validates against measured hardware. This asserts only
364 that the pieces connect and the result sits in a defensible range -- which
365 is the honest claim available before there is any test data.
367 lox = conditions_from_fluid(
Fluid(
"LOX"), p=3.0e6, T=90.0)
368 assert lox.rho == pytest.approx(1148.0, rel=1e-2)
369 assert 0.5e5 < lox.p_sat < 1.5e5,
"LOX at 90 K boils near one atmosphere"
370 assert lox.p_crit == pytest.approx(5.043e6, rel=1e-2)
372 line = build_component(
373 ComponentInstance.build(
377 "length":
Param(2.0,
"m", M,
"drawing"),
378 "bore":
Param(7.75,
"mm", M,
"3/8 x 0.035 tube"),
382 diagnostics = line.diagnostics(0.5, lox)
383 assert 5.0 < diagnostics[
"velocity"] < 15.0,
"sane feed-line velocity"
384 assert diagnostics[
"Re"] > 1e5,
"turbulent, as a feed line should be"
385 assert 0.5e5 < line.pressure_drop(0.5, lox) < 5.0e5
389 """IEC 60534 choked flow needs a saturation and a critical pressure.
391 Where they are unknown the limit is reported as absent rather than
392 fabricated -- a valve on an unspecified fluid should not claim to know
395 valve = build_component(
396 ComponentInstance.build(
399 {
"Cv":
Param(1.2,
"Cv", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
402 lox = conditions_from_fluid(
Fluid(
"LOX"), p=3.0e6, T=90.0)
403 assert valve.choked_dp(lox)
is not None
404 assert "cavitation_index" in valve.diagnostics(0.5, lox)
407 assert valve.choked_dp(unknown)
is None
411 """A flow that cannot pass should say so, not fail inside a root find.
413 ``fluids`` reports this as a bracketing failure several frames down, which
414 tells a solver author nothing. Phase 04 will want to catch this and back off
415 a step, and it can only do that if the failure is identifiable.
419 orifice = build_component(
420 ComponentInstance.build(
424 "bore":
Param(0.025,
"m", M,
"throat"),
425 "pipe_bore":
Param(0.05,
"m", M,
"line"),
430 starved =
FlowConditions(rho=999.0, mu=1.138e-3, p_upstream=0.34e5)
431 with pytest.raises(InfeasibleOperatingPoint, match=
"cannot pass"):
432 orifice.pressure_drop(3.0, starved)
436 """Two valves must not move together because they share a signal name.
438 An unqualified ``"command"`` reaching every valve converges perfectly and
439 models a system nobody built, so the component looks for its own id first.
441 valve = build_component(
442 ComponentInstance.build(
446 "Cv":
Param(6.0,
"Cv", M,
"x"),
447 "bore":
Param(12.7,
"mm", M,
"x"),
448 "leak_closed":
Param(0.001,
"Cv", Provenance.ESTIMATED,
"x"),
453 def dp(signals: dict[str, float]) -> float:
457 FlowConditions(rho=1140.0, mu=2e-4, p_upstream=3.4e6, signals=signals),
462 assert dp({
"MV-01.command": 0.0}) > 1e6 * wide_open
464 assert dp({
"MV-02.command": 0.0}) == pytest.approx(wide_open)
466 assert dp({
"command": 0.0}) > 1e6 * wide_open
None test_valve_characteristics_follow_iec_shapes(str characteristic, float at_half)
None test_the_friction_correlation_is_a_configuration_choice()
None test_a_shut_valve_keeps_a_seat_leak_rather_than_zero_capacity()
None test_a_valve_reproduces_the_definition_of_cv()
None test_a_valve_command_is_component_scoped()
None test_cracking_pressure_must_be_overcome()
None test_orifice_cd_inverts_the_bernoulli_relation()
None test_elevation_is_exactly_rho_g_h()
None test_lox_through_a_feed_line_is_physically_sensible()
None test_an_unknown_friction_method_is_rejected_at_load()
ComponentInstance _pipe(**Param overrides)
None test_choking_is_reported_when_the_fluid_state_supports_it()
None test_a_fitting_drops_exactly_K_dynamic_heads()
None test_a_check_valve_is_asymmetric()
None test_an_impossible_operating_point_says_what_is_wrong()
None test_laminar_pipe_matches_hagen_poiseuille()
None test_valve_pressure_drop_does_not_depend_on_bore(float bore_mm)
None test_iso5167_orifice_round_trips_through_the_standard()