STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_comps_validation.py
Go to the documentation of this file.
1"""Do the components reproduce results that exist outside this codebase?
2
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.
6
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.
13
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.
18"""
19
20from __future__ import annotations
21
22import math
23
24import pytest
25
26from feedtwin.comps import (
27 FlowConditions,
28 build_component,
29 conditions_from_fluid,
30)
31from feedtwin.model import ComponentInstance, Param, Provenance
32from feedtwin.props import Fluid
33
34M = Provenance.MANUFACTURER
35PSI = 6894.757293168361
36GPM = 3.785411784e-3 / 60.0
37
38#: Water near 60 F, the reference condition Cv is defined at.
39WATER = FlowConditions(rho=999.0, mu=1.138e-3, p_upstream=5.0 * PSI)
40
41
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"),
46 }
47 params.update(overrides)
48 return ComponentInstance.build("FL-01", "pipe", params)
49
50
51# ---------------------------------------------------------------- closed form
52
53
55 """The one pipe case with an exact analytic solution.
56
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.
61 """
62 L, D = 1.0, 0.01
63 mu, rho = 1.0e-3, 1000.0
64 mdot = 0.005 # gives Re ~ 640, comfortably laminar
65
66 line = build_component(
67 _pipe(length=Param(L, "m", M, ""), bore=Param(D, "m", M, ""))
68 )
69 flow = FlowConditions(rho=rho, mu=mu, p_upstream=1.0e6)
70
71 Q = mdot / rho
72 expected = 128.0 * mu * L * Q / (math.pi * D**4)
73
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)
76
77
79 """Static head, with no flow, is not a correlation -- it is arithmetic.
80
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.
85 """
86 climb = 10.0
87 line = build_component(_pipe(elevation_change=Param(climb, "m", M, "drawing")))
88 flow = FlowConditions(rho=1141.0, mu=2.0e-4, p_upstream=1.0e6)
89 expected = 1141.0 * 9.80665 * climb
90
91 assert line.static_head(flow) == pytest.approx(expected)
92 # No flow, so the whole pressure change is the head.
93 assert line.total_dp(0.0, flow) == pytest.approx(expected)
94 assert line.pressure_drop(0.0, flow) == pytest.approx(0.0)
95
96
98 """K is defined as ``dp / (rho v^2 / 2)``. A sharp exit has K = 1 exactly.
99
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.
103 """
104 elbow = build_component(
105 ComponentInstance.build(
106 "FIT-01",
107 "fitting",
108 {"bore": Param(10.0, "mm", M, "test")},
109 options={"kind": "exit"},
110 )
111 )
112 mdot = 0.5
113 v = mdot / (WATER.rho * math.pi * 0.01**2 / 4.0)
114 expected = 1.0 * WATER.rho * v * v / 2.0
115
116 assert elbow.pressure_drop(mdot, WATER) == pytest.approx(expected, rel=1e-12)
117 assert elbow.diagnostics(mdot, WATER)["K"] == pytest.approx(1.0)
118
119
121 """Cv = 1 passing 1 US gpm of water drops 1 psi. That is what Cv *means*.
122
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.
126
127 ``dp = SG (Q/Cv)^2``, so at SG = 0.999 the answer is 0.999 psi, not 1.000.
128 """
129 valve = build_component(
130 ComponentInstance.build(
131 "SOL-01",
132 "valve",
133 {
134 "Cv": Param(1.0, "Cv", M, "definition"),
135 "bore": Param(10.0, "mm", M, "arbitrary"),
136 },
137 )
138 )
139 mdot = 1.0 * GPM * WATER.rho # one gallon per minute, as mass flow
140 dp = valve.pressure_drop(mdot, WATER)
141
142 specific_gravity = WATER.rho / 1000.0
143 assert dp / PSI == pytest.approx(specific_gravity, rel=1e-3)
144
145
146@pytest.mark.parametrize("bore_mm", [5.0, 10.0, 25.0, 50.0])
148 """Cv already accounts for the valve's geometry.
149
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.
153 """
154 valve = build_component(
155 ComponentInstance.build(
156 "SOL-01",
157 "valve",
158 {
159 "Cv": Param(2.5, "Cv", M, "test"),
160 "bore": Param(bore_mm, "mm", M, "varied"),
161 },
162 )
163 )
164 mdot = 0.5
165 Q_gpm = (mdot / WATER.rho) / GPM
166 expected = (WATER.rho / 1000.0) * (Q_gpm / 2.5) ** 2 # psi, from the definition
167 assert valve.pressure_drop(mdot, WATER) / PSI == pytest.approx(expected, rel=1e-3)
168
169
171 """``m = Cd A sqrt(2 rho dp)``, solved for dp and checked forward."""
172 d, Cd = 2.0e-3, 0.61
173 orifice = build_component(
174 ComponentInstance.build(
175 "OR-01",
176 "orifice",
177 {
178 "bore": Param(d, "m", M, "throat"),
179 "pipe_bore": Param(8.0, "mm", M, "line"),
180 "Cd": Param(Cd, "-", M, "sharp edged"),
181 },
182 model="cd",
183 )
184 )
185 mdot = 0.4
186 dp = orifice.pressure_drop(mdot, WATER)
187
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)
191
192
194 """Agreement with ``fluids``, which implements ISO 5167 -- an adapter check.
195
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.
200 """
201 from fluids.flow_meter import differential_pressure_meter_solver
202
203 D, d, mdot = 0.05, 0.025, 3.0
204 # A realistic upstream pressure. At the 5 psi of the WATER fixture this flow
205 # simply cannot pass, which is a physical fact and is asserted separately.
206 upstream = FlowConditions(rho=WATER.rho, mu=WATER.mu, p_upstream=10.0e5)
207 orifice = build_component(
208 ComponentInstance.build(
209 "OR-01",
210 "orifice",
211 {
212 "bore": Param(d, "m", M, "throat"),
213 "pipe_bore": Param(D, "m", M, "line"),
214 },
215 model="iso5167",
216 )
217 )
218 dp = orifice.pressure_drop(mdot, upstream)
219 assert dp > 0.0
220
221 recovered = differential_pressure_meter_solver(
222 D=D,
223 D2=d,
224 rho=upstream.rho,
225 mu=upstream.mu,
226 P1=upstream.p_upstream,
227 P2=upstream.p_upstream - dp,
228 meter_type="ISO 5167 orifice",
229 taps="D",
230 epsilon_specified=1.0, # incompressible, as the component assumes
231 )
232 assert float(recovered) == pytest.approx(mdot, rel=1e-6)
233
234
235# ------------------------------------------------------------------- behaviour
236
237
239 """Different correlations, same order of magnitude, different answers.
240
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.
245 """
246 results = {}
247 for method in ("Clamond", "Churchill_1977", "Haaland", "Serghides_1"):
248 line = build_component(
249 ComponentInstance.build(
250 "FL-01",
251 "pipe",
252 {
253 "length": Param(2.0, "m", M, "test"),
254 "bore": Param(10.0, "mm", M, "test"),
255 "roughness": (
256 Param(45.0, "um" if False else "mm", M, "rough")
257 if False
258 else Param(0.045, "mm", M, "commercial steel")
259 ),
260 },
261 options={"friction": method},
262 )
263 )
264 results[method] = line.pressure_drop(2.0, WATER)
265
266 values = list(results.values())
267 assert len(set(values)) > 1, "the option changed nothing"
268 assert max(values) / min(values) < 1.02, results
269
270
272 from feedtwin.model import SpecError
273
274 with pytest.raises(SpecError, match="not valid"):
275 ComponentInstance.build(
276 "FL-01",
277 "pipe",
278 {
279 "length": Param(1.0, "m", M, ""),
280 "bore": Param(10.0, "mm", M, ""),
281 },
282 options={"friction": "vibes"},
283 )
284
285
286@pytest.mark.parametrize(
287 "characteristic,at_half",
288 [("linear", 0.5), ("equal_percentage", 0.1636), ("quick_opening", 0.9025)],
289)
291 characteristic: str, at_half: float
292) -> None:
293 """Half travel gives very different capacity depending on the trim."""
294 valve = build_component(
295 ComponentInstance.build(
296 "SOL-01",
297 "valve",
298 {"Cv": Param(10.0, "Cv", M, ""), "bore": Param(10.0, "mm", M, "")},
299 options={"characteristic": characteristic},
300 )
301 )
302 assert valve.effective_cv(0.5) == pytest.approx(10.0 * at_half, rel=1e-3) # type: ignore[attr-defined]
303 assert valve.effective_cv(1.0) == pytest.approx(10.0) # type: ignore[attr-defined]
304
305
307 """Exactly zero capacity is a singular network, not a closed valve."""
308 valve = build_component(
309 ComponentInstance.build(
310 "SOL-01",
311 "valve",
312 {"Cv": Param(1.2, "Cv", M, ""), "bore": Param(10.0, "mm", M, "")},
313 )
314 )
315 assert valve.effective_cv(0.0) == pytest.approx(1e-6) # type: ignore[attr-defined]
316 assert valve.effective_cv(0.0) > 0.0 # type: ignore[attr-defined]
317
318
320 """Forward it is a valve with a cracking pressure; backward it is shut.
321
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.
325 """
326 check = build_component(
327 ComponentInstance.build(
328 "CV-01",
329 "check_valve",
330 {"Cv": Param(2.0, "Cv", M, ""), "bore": Param(10.0, "mm", M, "")},
331 )
332 )
333 forward = check.pressure_drop(0.5, WATER)
334 reverse = check.pressure_drop(-0.5, WATER)
335
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
339
340
342 """At vanishing flow the drop tends to the cracking pressure, not to zero."""
343 check = build_component(
344 ComponentInstance.build(
345 "CV-01",
346 "check_valve",
347 {
348 "Cv": Param(2.0, "Cv", M, ""),
349 "bore": Param(10.0, "mm", M, ""),
350 "cracking_pressure": Param(5.0, "psi", M, "datasheet"),
351 },
352 )
353 )
354 assert check.pressure_drop(1e-9, WATER) == pytest.approx(5.0 * PSI, rel=1e-6)
355
356
357# ------------------------------------------------------------ real propellants
358
359
361 """An end-to-end sanity check on real propellant, not a pinned number.
362
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.
366 """
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)
371
372 line = build_component(
373 ComponentInstance.build(
374 "FL-01",
375 "pipe",
376 {
377 "length": Param(2.0, "m", M, "drawing"),
378 "bore": Param(7.75, "mm", M, "3/8 x 0.035 tube"),
379 },
380 )
381 )
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
386
387
389 """IEC 60534 choked flow needs a saturation and a critical pressure.
390
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
393 where it chokes.
394 """
395 valve = build_component(
396 ComponentInstance.build(
397 "SOL-01",
398 "valve",
399 {"Cv": Param(1.2, "Cv", M, ""), "bore": Param(10.0, "mm", M, "")},
400 )
401 )
402 lox = conditions_from_fluid(Fluid("LOX"), p=3.0e6, T=90.0)
403 assert valve.choked_dp(lox) is not None # type: ignore[attr-defined]
404 assert "cavitation_index" in valve.diagnostics(0.5, lox)
405
406 unknown = FlowConditions(rho=1000.0, mu=1e-3, p_upstream=1e6)
407 assert valve.choked_dp(unknown) is None # type: ignore[attr-defined]
408
409
411 """A flow that cannot pass should say so, not fail inside a root find.
412
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.
416 """
417 from feedtwin.comps import InfeasibleOperatingPoint
418
419 orifice = build_component(
420 ComponentInstance.build(
421 "OR-01",
422 "orifice",
423 {
424 "bore": Param(0.025, "m", M, "throat"),
425 "pipe_bore": Param(0.05, "m", M, "line"),
426 },
427 model="iso5167",
428 )
429 )
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)
433
434
436 """Two valves must not move together because they share a signal name.
437
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.
440 """
441 valve = build_component(
442 ComponentInstance.build(
443 "MV-01",
444 "valve",
445 {
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"),
449 },
450 )
451 )
452
453 def dp(signals: dict[str, float]) -> float:
454 return float(
455 valve.pressure_drop(
456 0.7,
457 FlowConditions(rho=1140.0, mu=2e-4, p_upstream=3.4e6, signals=signals),
458 )
459 )
460
461 wide_open = dp({})
462 assert dp({"MV-01.command": 0.0}) > 1e6 * wide_open
463 # Another valve's command must not touch this one.
464 assert dp({"MV-02.command": 0.0}) == pytest.approx(wide_open)
465 # The bare fallback still works for a single-actuator test.
466 assert dp({"command": 0.0}) > 1e6 * wide_open
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
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_orifice_cd_inverts_the_bernoulli_relation()
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_an_impossible_operating_point_says_what_is_wrong()
None test_valve_pressure_drop_does_not_depend_on_bore(float bore_mm)
None test_iso5167_orifice_round_trips_through_the_standard()