1"""Component declarations: validation, fidelity models, and round-tripping.
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.
9from __future__
import annotations
13from pathlib
import Path
28 register_component_spec,
29 registered_component_types,
32MANUF = Provenance.MANUFACTURER
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"),
40 params.update(overrides)
48 """The shipped declarations come from components.toml, not from Python.
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
58 table = Path(spec_module.__file__).with_name(
"components.toml")
59 with table.open(
"rb")
as handle:
60 declared = set(tomllib.load(handle))
62 assert {
"pipe",
"orifice",
"valve"} <= declared
63 assert declared <= set(registered_component_types())
67 """Enough for a UI to render an editor without knowing what a pipe is."""
68 described = get_component_spec(
"pipe").to_dict()
70 assert described[
"type"] ==
"pipe"
71 assert described[
"description"]
72 assert [p[
"name"]
for p
in described[
"ports"]] == [
"inlet",
"outlet"]
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"]
85 line = ComponentInstance.build(
"FL-01",
"pipe",
_pipe())
86 assert line.params[
"roughness"].source
is Provenance.DEFAULT
87 assert "roughness" in line.assumptions()
91 with pytest.raises(SpecError, match=
"'bore' is required"):
92 ComponentInstance.build(
93 "FL-01",
"pipe", {
"length":
Param(0.5,
"m", MANUF,
"drawing")}
98 """Six errors at a time beats six edit-run cycles."""
99 with pytest.raises(SpecError)
as excinfo:
100 ComponentInstance.build(
104 "bore":
Param(500.0,
"psi", MANUF,
"wrong dimension"),
105 "nonsense":
Param(1.0,
"m", MANUF,
"not a pipe parameter"),
108 message = str(excinfo.value)
109 assert "length" in message
110 assert "nonsense" in message
111 assert "pressure" in message
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"))
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"))
128 with pytest.raises(SpecError, match=
"above the maximum"):
129 ComponentInstance.build(
133 "bore":
Param(1.0,
"mm", MANUF,
"d"),
134 "pipe_bore":
Param(8.0,
"mm", MANUF,
"D"),
135 "Cd":
Param(1.4,
"-", MANUF,
"impossible"),
141 with pytest.raises(SpecError, match=
"registered"):
142 ComponentInstance.build(
"X-01",
"flux_capacitor", {})
149 """An orifice using ISO 5167 is not asked for a discharge coefficient.
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.
156 "bore":
Param(2.0,
"mm", MANUF,
"throat"),
157 "pipe_bore":
Param(8.0,
"mm", MANUF,
"line"),
159 iso = ComponentInstance.build(
"OR-01",
"orifice", geometry, model=
"iso5167")
160 assert "Cd" not in iso.params
162 cd = ComponentInstance.build(
"OR-02",
"orifice", geometry, model=
"cd")
163 assert cd.params[
"Cd"].source
is Provenance.DEFAULT
167 with pytest.raises(SpecError, match=
"unknown model"):
168 ComponentInstance.build(
"FL-01",
"pipe",
_pipe(), model=
"handwaving")
172 with pytest.raises(SpecError, match=
"unknown model"):
177 params=(
ParamSpec(
"x",
"length",
"", models=(
"b",)),),
182 with pytest.raises(SpecError, match=
"duplicate"):
194 """Phase 02's round-trip criterion.
196 Through JSON specifically -- an in-memory copy would not catch a field that
197 serialises but does not deserialise.
199 original = ComponentInstance.build(
203 "Cv":
Param(1.2,
"Cv", MANUF,
"SS-8BK-V51 rev C"),
204 "bore":
Param(0.375,
"in", MANUF,
"datasheet"),
207 connections={
"inlet":
"n3",
"outlet":
"n4"},
210 restored = ComponentInstance.from_dict(json.loads(json.dumps(original.to_dict())))
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
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"))
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)
230 """Provenance lost on the first save is provenance that does not exist."""
231 line = ComponentInstance.build(
234 _pipe(bore=
Param(7.75,
"mm", Provenance.MEASURED,
"calipers, 2026-09-08")),
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"
245 """A project adds a component type without editing this package."""
246 table = tmp_path /
"extra_components.toml"
251 description = "A one-shot pressure relief."
254 [burst_disc.ports.inlet]
255 description = "Upstream."
257 [burst_disc.params.burst_pressure]
258 dimension = "pressure"
259 description = "Differential pressure at which it opens."
265 loaded = load_component_specs(table)
266 assert [s.type
for s
in loaded] == [
"burst_disc"]
268 disc = ComponentInstance.build(
271 {
"burst_pressure":
Param(1200.0,
"psi", MANUF,
"datasheet")},
273 assert disc.si(
"burst_pressure") == pytest.approx(8273708.75, rel=1e-6)
277 register_component_spec(
280 description=
"A filter element.",
287 "Pressure drop when clean at rated flow.",
288 default=default_param(2.0,
"psi",
"illustrative"),
293 element = ComponentInstance.build(
"FIL-01",
"test_filter", {})
294 assert element.params[
"clean_dp"].source
is Provenance.DEFAULT
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"}
302 assert line.unconnected_ports() == [
"outlet"]
306 """A valve's command port is not a leak."""
307 valve = ComponentInstance.build(
310 {
"Cv":
Param(1.2,
"Cv", MANUF,
"d"),
"bore":
Param(0.375,
"in", MANUF,
"d")},
311 connections={
"inlet":
"n1",
"outlet":
"n2"},
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()