1"""The component library's seams, exercised from outside.
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.
8Three seams here, and one principle:
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.
16And the principle: **measured data supersedes the correlation**, for every
17component type, from the first phase that has components at all.
20from __future__
import annotations
40 registered_component_types,
44M = Provenance.MANUFACTURER
49 """A deliberately steep measured curve, so 'did the data win?' is obvious."""
55 source=Provenance.MEASURED,
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()
68 ctx =
FittingContext(bore=0.01, Re=1e5, roughness=1.5e-6, fd=0.018)
69 assert fitting_K(
"unobtainium_swirler", ctx) == 42.0
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)
79 """Every registered adapter is actually callable with the uniform context.
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.
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"
97 """How a component declared in components.toml acquires behaviour."""
100 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
103 register_builder(
"pipe",
"constant_for_test", ConstantDrop)
104 assert (
"pipe",
"constant_for_test")
in registered_builders()
108 with pytest.raises(SpecError, match=
"unknown model"):
109 ComponentInstance.build(
112 {
"length":
Param(1.0,
"m", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
113 model=
"constant_for_test",
118 """The honest failure when a schema is ahead of the implementation.
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
124 from feedtwin.model import ComponentSpec, ParamSpec, register_component_spec
126 register_component_spec(
128 type=
"test_unimplemented",
129 description=
"Declared but not built.",
131 params=(ParamSpec(
"bore",
"length",
"Bore."),),
134 instance = ComponentInstance.build(
135 "X-01",
"test_unimplemented", {
"bore":
Param(10.0,
"mm", M,
"")}
137 with pytest.raises(SpecError, match=
"no implementation"):
138 build_component(instance)
142 """The reverse gap: a schema model with nothing behind it.
144 Catches the declaration and the implementation drifting apart, which is the
145 predictable cost of keeping schemas in data and physics in code.
149 implemented = set(registered_builders())
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"))
157 assert not missing, f
"declared but not implemented: {missing}"
163@pytest.mark.parametrize("type_name", ["pipe", "orifice", "valve", "check_valve"])
167 """The Phase 12 principle, available from the phase that has components.
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
174 instance = ComponentInstance.build(
181 component = build_component(instance)
184 assert component.pressure_drop(1.0, WATER) == pytest.approx(5.0e5)
185 assert component.pressure_drop(1.5, WATER) == pytest.approx(12.5e5)
186 assert instance.curves[
"dp_mdot"].source
is Provenance.MEASURED
190 """Off its range it does not switch to a correlation.
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).
198 component = build_component(
199 ComponentInstance.build(
200 "X-01",
"pipe", {}, curves={
"dp_mdot":
_measured_curve()}, model=
"measured"
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
210 with pytest.raises(SpecError, match=
"dp_mdot"):
211 ComponentInstance.build(
"X-01",
"pipe", {}, model=
"measured")
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")
224 source=Provenance.MEASURED,
226 with pytest.raises(SpecError, match=
"expected a mass_flow"):
227 ComponentInstance.build(
228 "X-01",
"pipe", {}, curves={
"dp_mdot": wrong_axis}, model=
"measured"
233 """Cv against travel, as a manufacturer's chart or a bench test gives it."""
235 x=(0.0, 0.25, 0.5, 0.75, 1.0),
236 y=(0.0, 0.4, 1.1, 2.0, 3.0),
239 source=Provenance.MANUFACTURER,
240 reference=
"vendor chart, figure 4",
242 valve = build_component(
243 ComponentInstance.build(
246 {
"Cv":
Param(3.0,
"Cv", M,
"full open"),
"bore":
Param(10.0,
"mm", M,
"")},
247 curves={
"cv_position": curve},
248 options={
"characteristic":
"tabulated"},
251 assert valve.effective_cv(0.5) == pytest.approx(1.1)
252 assert valve.effective_cv(1.0) == pytest.approx(3.0)
256 valve = build_component(
257 ComponentInstance.build(
260 {
"Cv":
Param(3.0,
"Cv", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
261 options={
"characteristic":
"tabulated"},
264 with pytest.raises(SpecError, match=
"cv_position"):
265 valve.effective_cv(0.5)
272 """The provisional protocol, checked against the physics it wraps.
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.
282 line = build_component(
283 ComponentInstance.build(
286 {
"length":
Param(2.0,
"m", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
289 line.conditions_provider =
lambda port, signals: WATER
291 mdot, p_in = 1.0, 1.0e6
292 expected_drop = line.pressure_drop(mdot, WATER)
295 ports=(PortState(p=p_in, h=0.0), PortState(p=p_in - expected_drop, h=0.0)),
298 assert line.residuals(ctx)[0] == pytest.approx(0.0, abs=1e-6)
301 reversed_ctx = EvalContext(
302 ports=(PortState(p=p_in, h=0.0), PortState(p=p_in + expected_drop, h=0.0)),
305 assert line.residuals(reversed_ctx)[0] == pytest.approx(0.0, abs=1e-6)
309 """Phase 04 supplies it; until then the failure should be legible."""
312 line = build_component(
313 ComponentInstance.build(
316 {
"length":
Param(1.0,
"m", M,
""),
"bore":
Param(10.0,
"mm", M,
"")},
320 ports=(PortState(p=1e6, h=0.0), PortState(p=9e5, h=0.0)), flows=(1.0,)
322 with pytest.raises(SpecError, match=
"conditions provider"):
None test_shipped_fittings_all_produce_a_finite_coefficient()
None test_evaluating_residuals_without_a_conditions_provider_says_so()
None test_an_unknown_fitting_lists_what_exists()
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_every_shipped_type_and_model_has_physics()
None test_a_curve_must_declare_its_provenance_and_dimensions()
None test_physics_can_be_registered_for_a_new_type_model_pair()
None test_the_measured_model_says_what_it_needs()
None test_a_tabulated_characteristic_without_its_curve_fails_clearly()