STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_model_spec.py
Go to the documentation of this file.
1"""Component declarations: validation, fidelity models, and round-tripping.
2
3Two exit criteria of Phase 02 live here -- a configuration round-trips
4identically, and a component's schema is data rather than code. The rest is
5validation: every check that can be made without solving anything, made at load
6time, with all failures reported together.
7"""
8
9from __future__ import annotations
10
11import json
12import textwrap
13from pathlib import Path
14
15import pytest
16
17from feedtwin.model import (
18 ComponentInstance,
19 ComponentSpec,
20 Param,
21 ParamSpec,
22 PortSpec,
23 Provenance,
24 SpecError,
25 default_param,
26 get_component_spec,
27 load_component_specs,
28 register_component_spec,
29 registered_component_types,
30)
31
32MANUF = Provenance.MANUFACTURER
33
34
35def _pipe(**overrides: Param) -> dict[str, Param]:
36 params: dict[str, Param] = {
37 "length": Param(0.5, "m", MANUF, "drawing"),
38 "bore": Param(7.75, "mm", MANUF, "3/8 in. x 0.035 wall"),
39 }
40 params.update(overrides)
41 return params
42
43
44# --------------------------------------------------------------- shipped specs
45
46
48 """The shipped declarations come from components.toml, not from Python.
49
50 The same guard the species registry has: someone adds a component type by
51 writing it into code because that felt quicker, and the data path quietly
52 becomes decorative.
53 """
54 import tomllib
55
56 from feedtwin.model import spec as spec_module
57
58 table = Path(spec_module.__file__).with_name("components.toml")
59 with table.open("rb") as handle:
60 declared = set(tomllib.load(handle))
61
62 assert {"pipe", "orifice", "valve"} <= declared
63 assert declared <= set(registered_component_types())
64
65
67 """Enough for a UI to render an editor without knowing what a pipe is."""
68 described = get_component_spec("pipe").to_dict()
69
70 assert described["type"] == "pipe"
71 assert described["description"]
72 assert [p["name"] for p in described["ports"]] == ["inlet", "outlet"]
73
74 bore = next(p for p in described["params"] if p["name"] == "bore")
75 assert bore["dimension"] == "length"
76 assert bore["si_unit"] == "m"
77 assert bore["required"] is True
78 assert bore["description"]
79
80
81# ------------------------------------------------------------------ validation
82
83
85 line = ComponentInstance.build("FL-01", "pipe", _pipe())
86 assert line.params["roughness"].source is Provenance.DEFAULT
87 assert "roughness" in line.assumptions()
88
89
91 with pytest.raises(SpecError, match="'bore' is required"):
92 ComponentInstance.build(
93 "FL-01", "pipe", {"length": Param(0.5, "m", MANUF, "drawing")}
94 )
95
96
98 """Six errors at a time beats six edit-run cycles."""
99 with pytest.raises(SpecError) as excinfo:
100 ComponentInstance.build(
101 "FL-01",
102 "pipe",
103 {
104 "bore": Param(500.0, "psi", MANUF, "wrong dimension"),
105 "nonsense": Param(1.0, "m", MANUF, "not a pipe parameter"),
106 },
107 )
108 message = str(excinfo.value)
109 assert "length" in message # missing
110 assert "nonsense" in message # unknown
111 assert "pressure" in message # wrong dimension
112
113
115 with pytest.raises(SpecError, match="expected a length but 'psi' is a pressure"):
116 ComponentInstance.build(
117 "FL-01", "pipe", _pipe(bore=Param(500.0, "psi", MANUF, "oops"))
118 )
119
120
122 """A negative bore fails at load, not inside a Reynolds number."""
123 with pytest.raises(SpecError, match="below the minimum"):
124 ComponentInstance.build(
125 "FL-01", "pipe", _pipe(bore=Param(-3.0, "mm", MANUF, "typo"))
126 )
127
128 with pytest.raises(SpecError, match="above the maximum"):
129 ComponentInstance.build(
130 "OR-01",
131 "orifice",
132 {
133 "bore": Param(1.0, "mm", MANUF, "d"),
134 "pipe_bore": Param(8.0, "mm", MANUF, "D"),
135 "Cd": Param(1.4, "-", MANUF, "impossible"),
136 },
137 )
138
139
141 with pytest.raises(SpecError, match="registered"):
142 ComponentInstance.build("X-01", "flux_capacitor", {})
143
144
145# -------------------------------------------------------------- fidelity models
146
147
149 """An orifice using ISO 5167 is not asked for a discharge coefficient.
150
151 Cd belongs to the 'cd' model; the 'iso5167' model computes it from geometry
152 and Reynolds number. Asking for it anyway would force a value that is then
153 silently ignored -- the worst kind of parameter.
154 """
155 geometry = {
156 "bore": Param(2.0, "mm", MANUF, "throat"),
157 "pipe_bore": Param(8.0, "mm", MANUF, "line"),
158 }
159 iso = ComponentInstance.build("OR-01", "orifice", geometry, model="iso5167")
160 assert "Cd" not in iso.params
161
162 cd = ComponentInstance.build("OR-02", "orifice", geometry, model="cd")
163 assert cd.params["Cd"].source is Provenance.DEFAULT
164
165
167 with pytest.raises(SpecError, match="unknown model"):
168 ComponentInstance.build("FL-01", "pipe", _pipe(), model="handwaving")
169
170
172 with pytest.raises(SpecError, match="unknown model"):
174 type="broken",
175 description="",
176 models=("a",),
177 params=(ParamSpec("x", "length", "", models=("b",)),),
178 )
179
180
182 with pytest.raises(SpecError, match="duplicate"):
184 type="broken",
185 description="",
186 params=(ParamSpec("x", "length", ""), ParamSpec("x", "mass", "")),
187 )
188
189
190# ---------------------------------------------------------------- round-tripping
191
192
194 """Phase 02's round-trip criterion.
195
196 Through JSON specifically -- an in-memory copy would not catch a field that
197 serialises but does not deserialise.
198 """
199 original = ComponentInstance.build(
200 "SOL-01",
201 "valve",
202 {
203 "Cv": Param(1.2, "Cv", MANUF, "SS-8BK-V51 rev C"),
204 "bore": Param(0.375, "in", MANUF, "datasheet"),
205 },
206 model="cv",
207 connections={"inlet": "n3", "outlet": "n4"},
208 )
209
210 restored = ComponentInstance.from_dict(json.loads(json.dumps(original.to_dict())))
211
212 assert restored.to_dict() == original.to_dict()
213 assert restored.params == original.params
214 assert restored.connections == original.connections
215 assert restored.model == original.model
216
217
219 """A file written in inches comes back in inches, not in converted floats."""
220 line = ComponentInstance.build(
221 "FL-01", "pipe", _pipe(length=Param(18.0, "in", MANUF, "tape"))
222 )
223 restored = ComponentInstance.from_dict(line.to_dict())
224 assert restored.params["length"].unit == "in"
225 assert restored.params["length"].value == 18.0
226 assert restored.si("length") == pytest.approx(0.4572)
227
228
230 """Provenance lost on the first save is provenance that does not exist."""
231 line = ComponentInstance.build(
232 "FL-01",
233 "pipe",
234 _pipe(bore=Param(7.75, "mm", Provenance.MEASURED, "calipers, 2026-09-08")),
235 )
236 restored = ComponentInstance.from_dict(line.to_dict())
237 assert restored.params["bore"].source is Provenance.MEASURED
238 assert restored.params["bore"].reference == "calipers, 2026-09-08"
239
240
241# ------------------------------------------------------------------- extension
242
243
245 """A project adds a component type without editing this package."""
246 table = tmp_path / "extra_components.toml"
247 table.write_text(
248 textwrap.dedent(
249 """
250 [burst_disc]
251 description = "A one-shot pressure relief."
252 models = ["ideal"]
253
254 [burst_disc.ports.inlet]
255 description = "Upstream."
256
257 [burst_disc.params.burst_pressure]
258 dimension = "pressure"
259 description = "Differential pressure at which it opens."
260 minimum = 0.0
261 """
262 )
263 )
264
265 loaded = load_component_specs(table)
266 assert [s.type for s in loaded] == ["burst_disc"]
267
268 disc = ComponentInstance.build(
269 "BD-01",
270 "burst_disc",
271 {"burst_pressure": Param(1200.0, "psi", MANUF, "datasheet")},
272 )
273 assert disc.si("burst_pressure") == pytest.approx(8273708.75, rel=1e-6)
274
275
277 register_component_spec(
279 type="test_filter",
280 description="A filter element.",
281 models=("clean",),
282 ports=(PortSpec("inlet", "Up"), PortSpec("outlet", "Down")),
283 params=(
284 ParamSpec(
285 "clean_dp",
286 "pressure",
287 "Pressure drop when clean at rated flow.",
288 default=default_param(2.0, "psi", "illustrative"),
289 ),
290 ),
291 )
292 )
293 element = ComponentInstance.build("FIL-01", "test_filter", {})
294 assert element.params["clean_dp"].source is Provenance.DEFAULT
295
296
298 """A dead end is a singular system; naming it beats a linear-algebra error."""
299 line = ComponentInstance.build(
300 "FL-01", "pipe", _pipe(), connections={"inlet": "n1"}
301 )
302 assert line.unconnected_ports() == ["outlet"]
303
304
306 """A valve's command port is not a leak."""
307 valve = ComponentInstance.build(
308 "SOL-01",
309 "valve",
310 {"Cv": Param(1.2, "Cv", MANUF, "d"), "bore": Param(0.375, "in", MANUF, "d")},
311 connections={"inlet": "n1", "outlet": "n2"},
312 )
313 assert valve.unconnected_ports() == []
None test_signal_ports_are_not_expected_to_be_plumbed()
None test_authored_units_survive_the_round_trip()
dict[str, Param] _pipe(**Param overrides)
None test_an_unknown_model_is_rejected()
None test_bounds_are_enforced_in_canonical_units()
None test_a_wrong_dimension_is_caught_before_any_physics()
None test_a_component_type_can_be_registered_in_code()
None test_a_model_only_needs_its_own_parameters()
None test_a_missing_required_parameter_is_named()
None test_round_trip_preserves_provenance_and_references()
None test_an_instance_round_trips_through_json_identically()
None test_unconnected_fluid_ports_are_reported()
None test_all_problems_are_reported_at_once()
None test_a_shipped_spec_describes_itself_completely()
None test_duplicate_parameters_are_rejected()
None test_component_schemas_are_data()
None test_defaults_are_filled_and_tagged_as_defaults()
None test_a_component_type_can_be_declared_from_outside(Path tmp_path)
None test_a_spec_cannot_reference_a_model_it_does_not_declare()
None test_an_unknown_component_type_lists_what_exists()