STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_comps_modularity.py
Go to the documentation of this file.
1"""The component library's seams, exercised from outside.
2
3Same purpose as the property layer's modularity tests: "nothing hardcoded"
4decays silently, because adding a special case is always the cheap move and
5nothing fails when a seam stops working -- the code still runs, it just can no
6longer be extended without editing it.
7
8Three seams here, and one principle:
9
10* ``register_fitting`` -- a loss correlation the package never shipped.
11* ``register_builder`` -- the physics behind a (type, model) pair, which is how
12 a component type declared in ``components.toml`` acquires behaviour.
13* ``options`` -- categorical configuration, of which the friction correlation is
14 the one this phase cared about most.
15
16And the principle: **measured data supersedes the correlation**, for every
17component type, from the first phase that has components at all.
18"""
19
20from __future__ import annotations
21
22import pytest
23
24from feedtwin.comps import (
25 FlowConditions,
26 FittingContext,
27 HydraulicComponent,
28 build_component,
29 fitting_K,
30 register_builder,
31 register_fitting,
32 registered_builders,
33 registered_fittings,
34)
35from feedtwin.model import (
36 ComponentInstance,
37 Param,
38 Provenance,
39 SpecError,
40 registered_component_types,
41)
42from feedtwin.model.curve import Curve
43
44M = Provenance.MANUFACTURER
45WATER = FlowConditions(rho=999.0, mu=1.138e-3, p_upstream=1.0e6)
46
47
48def _measured_curve(reference: str = "CF-2026-03") -> Curve:
49 """A deliberately steep measured curve, so 'did the data win?' is obvious."""
50 return Curve(
51 x=(0.0, 1.0, 2.0),
52 y=(0.0, 5.0, 20.0),
53 x_unit="kg/s",
54 y_unit="bar",
55 source=Provenance.MEASURED,
56 reference=reference,
57 )
58
59
60# ------------------------------------------------------------------- fittings
61
62
64 """A loss coefficient this package never shipped, selected by config."""
65 register_fitting("unobtainium_swirler", lambda ctx: 42.0)
66 assert "unobtainium_swirler" in registered_fittings()
67
68 ctx = FittingContext(bore=0.01, Re=1e5, roughness=1.5e-6, fd=0.018)
69 assert fitting_K("unobtainium_swirler", ctx) == 42.0
70
71
73 ctx = FittingContext(bore=0.01, Re=1e5, roughness=1.5e-6, fd=0.018)
74 with pytest.raises(ValueError, match="registered"):
75 fitting_K("teleporter", ctx)
76
77
79 """Every registered adapter is actually callable with the uniform context.
80
81 The registry's whole point is that correlations with very different
82 ``fluids`` signatures are reachable through one shape. An adapter that
83 unpacks the context wrongly fails here rather than the first time somebody
84 selects that fitting in a config.
85 """
86 ctx = FittingContext(bore=0.01, Re=1.0e5, roughness=1.5e-6, fd=0.018)
87 for name in registered_fittings():
88 K = fitting_K(name, ctx)
89 assert K == pytest.approx(K), f"{name} produced NaN"
90 assert K >= 0.0, f"{name} produced a negative resistance"
91
92
93# -------------------------------------------------------------------- builders
94
95
97 """How a component declared in components.toml acquires behaviour."""
98
99 class ConstantDrop(HydraulicComponent):
100 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
101 return 12345.0
102
103 register_builder("pipe", "constant_for_test", ConstantDrop)
104 assert ("pipe", "constant_for_test") in registered_builders()
105
106 # The schema does not declare that model, so the instance cannot be built
107 # through it -- which is the schema doing its job, not a gap.
108 with pytest.raises(SpecError, match="unknown model"):
109 ComponentInstance.build(
110 "FL-01",
111 "pipe",
112 {"length": Param(1.0, "m", M, ""), "bore": Param(10.0, "mm", M, "")},
113 model="constant_for_test",
114 )
115
116
118 """The honest failure when a schema is ahead of the implementation.
119
120 ``components.toml`` can declare a model before Phase 03 implements it. That
121 should say so, naming what *is* implemented, rather than failing somewhere
122 inside a solve.
123 """
124 from feedtwin.model import ComponentSpec, ParamSpec, register_component_spec
125
126 register_component_spec(
127 ComponentSpec(
128 type="test_unimplemented",
129 description="Declared but not built.",
130 models=("someday",),
131 params=(ParamSpec("bore", "length", "Bore."),),
132 )
133 )
134 instance = ComponentInstance.build(
135 "X-01", "test_unimplemented", {"bore": Param(10.0, "mm", M, "")}
136 )
137 with pytest.raises(SpecError, match="no implementation"):
138 build_component(instance)
139
140
142 """The reverse gap: a schema model with nothing behind it.
143
144 Catches the declaration and the implementation drifting apart, which is the
145 predictable cost of keeping schemas in data and physics in code.
146 """
147 from feedtwin.model import get_component_spec
148
149 implemented = set(registered_builders())
150 missing = [
151 (type_name, model)
152 for type_name in registered_component_types()
153 for model in get_component_spec(type_name).models
154 if (type_name, model) not in implemented
155 and not type_name.startswith(("test_", "burst_disc"))
156 ]
157 assert not missing, f"declared but not implemented: {missing}"
158
159
160# -------------------------------------------------------------------- measured
161
162
163@pytest.mark.parametrize("type_name", ["pipe", "orifice", "valve", "check_valve"])
165 type_name: str,
166) -> None:
167 """The Phase 12 principle, available from the phase that has components.
168
169 A measured curve replaces the correlation outright. Note the contrast with
170 the property layer, where measured data falls *through* to an equation of
171 state outside its range: a property has a defensible fallback and a
172 component does not.
173 """
174 instance = ComponentInstance.build(
175 "X-01",
176 type_name,
177 {},
178 curves={"dp_mdot": _measured_curve()},
179 model="measured",
180 )
181 component = build_component(instance)
182
183 # Exactly the measured value, not something near it.
184 assert component.pressure_drop(1.0, WATER) == pytest.approx(5.0e5)
185 assert component.pressure_drop(1.5, WATER) == pytest.approx(12.5e5) # interpolated
186 assert instance.curves["dp_mdot"].source is Provenance.MEASURED
187
188
190 """Off its range it does not switch to a correlation.
191
192 Silently switching to a correlation halfway up a flow sweep would put a kink
193 in the result that nobody asked for and nobody would see. It is continued
194 in its own measured shape, ``dp ~ mdot^2`` from the last point, and the
195 diagnostics say the point was not measured (see test_measured_off_curve:
196 raising here made a sweep that did not start at zero unsolvable).
197 """
198 component = build_component(
199 ComponentInstance.build(
200 "X-01", "pipe", {}, curves={"dp_mdot": _measured_curve()}, model="measured"
201 )
202 )
203 top = _measured_curve()(2.0)
204 assert component.pressure_drop(5.0, WATER) == pytest.approx(top * 2.5**2)
205 assert component.diagnostics(5.0, WATER)["extrapolated"] == 1.0
206 assert component.diagnostics(1.5, WATER)["extrapolated"] == 0.0
207
208
210 with pytest.raises(SpecError, match="dp_mdot"):
211 ComponentInstance.build("X-01", "pipe", {}, model="measured")
212
213
215 """Same rules as a scalar: where it came from, and what it measures."""
216 with pytest.raises(TypeError, match="where it came from"):
217 Curve((0.0, 1.0), (0.0, 1.0), "kg/s", "bar", "measured") # type: ignore[arg-type]
218
219 wrong_axis = Curve(
220 x=(0.0, 1.0),
221 y=(0.0, 1.0),
222 x_unit="m", # a length, not a mass flow
223 y_unit="bar",
224 source=Provenance.MEASURED,
225 )
226 with pytest.raises(SpecError, match="expected a mass_flow"):
227 ComponentInstance.build(
228 "X-01", "pipe", {}, curves={"dp_mdot": wrong_axis}, model="measured"
229 )
230
231
233 """Cv against travel, as a manufacturer's chart or a bench test gives it."""
234 curve = Curve(
235 x=(0.0, 0.25, 0.5, 0.75, 1.0),
236 y=(0.0, 0.4, 1.1, 2.0, 3.0),
237 x_unit="-",
238 y_unit="Cv",
239 source=Provenance.MANUFACTURER,
240 reference="vendor chart, figure 4",
241 )
242 valve = build_component(
243 ComponentInstance.build(
244 "SOL-01",
245 "valve",
246 {"Cv": Param(3.0, "Cv", M, "full open"), "bore": Param(10.0, "mm", M, "")},
247 curves={"cv_position": curve},
248 options={"characteristic": "tabulated"},
249 )
250 )
251 assert valve.effective_cv(0.5) == pytest.approx(1.1) # type: ignore[attr-defined]
252 assert valve.effective_cv(1.0) == pytest.approx(3.0) # type: ignore[attr-defined]
253
254
256 valve = build_component(
257 ComponentInstance.build(
258 "SOL-01",
259 "valve",
260 {"Cv": Param(3.0, "Cv", M, ""), "bore": Param(10.0, "mm", M, "")},
261 options={"characteristic": "tabulated"},
262 )
263 )
264 with pytest.raises(SpecError, match="cv_position"):
265 valve.effective_cv(0.5) # type: ignore[attr-defined]
266
267
268# --------------------------------------------------------- the solver interface
269
270
272 """The provisional protocol, checked against the physics it wraps.
273
274 ``residuals`` is expected to move when Phase 04 assembles a real Jacobian.
275 What must not move is this: whatever shape the solver interface takes, it
276 has to agree with ``pressure_drop``, which is the form validated against
277 published results. This test is the bridge, and it is the one to re-run when
278 that interface changes.
279 """
280 from feedtwin.model.component import EvalContext, PortState
281
282 line = build_component(
283 ComponentInstance.build(
284 "FL-01",
285 "pipe",
286 {"length": Param(2.0, "m", M, ""), "bore": Param(10.0, "mm", M, "")},
287 )
288 )
289 line.conditions_provider = lambda port, signals: WATER
290
291 mdot, p_in = 1.0, 1.0e6
292 expected_drop = line.pressure_drop(mdot, WATER)
293
294 ctx = EvalContext(
295 ports=(PortState(p=p_in, h=0.0), PortState(p=p_in - expected_drop, h=0.0)),
296 flows=(mdot,),
297 )
298 assert line.residuals(ctx)[0] == pytest.approx(0.0, abs=1e-6)
299
300 # Reverse flow: the loss opposes the flow, so the sign follows it.
301 reversed_ctx = EvalContext(
302 ports=(PortState(p=p_in, h=0.0), PortState(p=p_in + expected_drop, h=0.0)),
303 flows=(-mdot,),
304 )
305 assert line.residuals(reversed_ctx)[0] == pytest.approx(0.0, abs=1e-6)
306
307
309 """Phase 04 supplies it; until then the failure should be legible."""
310 from feedtwin.model.component import EvalContext, PortState
311
312 line = build_component(
313 ComponentInstance.build(
314 "FL-01",
315 "pipe",
316 {"length": Param(1.0, "m", M, ""), "bore": Param(10.0, "mm", M, "")},
317 )
318 )
319 ctx = EvalContext(
320 ports=(PortState(p=1e6, h=0.0), PortState(p=9e5, h=0.0)), flows=(1.0,)
321 )
322 with pytest.raises(SpecError, match="conditions provider"):
323 line.residuals(ctx)
None test_shipped_fittings_all_produce_a_finite_coefficient()
None test_evaluating_residuals_without_a_conditions_provider_says_so()
Curve _measured_curve(str reference="CF-2026-03")
None test_the_residual_form_agrees_with_the_causal_one()
None test_a_tabulated_valve_characteristic_comes_from_a_curve()
None test_a_fitting_correlation_can_be_added_from_outside()
None test_a_declared_model_with_no_physics_fails_clearly()
None test_measured_data_supersedes_the_correlation_for_every_type(str type_name)
None test_measured_data_never_falls_back_to_a_correlation()
None test_a_curve_must_declare_its_provenance_and_dimensions()
None test_physics_can_be_registered_for_a_new_type_model_pair()
None test_a_tabulated_characteristic_without_its_curve_fails_clearly()