STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_props_modularity.py
Go to the documentation of this file.
1"""The extension points, exercised from outside the package.
2
3"Nothing hardcoded" is a claim that decays silently. Adding a special case is
4always the cheap move in the moment, and nothing fails when the seam quietly
5stops working -- the code still runs, it just can no longer be extended without
6editing it. These tests are what makes that failure loud.
7
8Each one adds something *from the outside*, the way a project using this library
9would, and asserts it took effect without touching the package:
10
11* a species that is not in ``species.toml``
12* a whole species file layered on top of the shipped one
13* a backend that is not CoolProp at all
14* measured data placed in front of an equation of state
15* a property this package never heard of
16
17If any of these needs a change inside ``feedtwin/props`` to pass, the seam it
18covers is gone.
19"""
20
21from __future__ import annotations
22
23import textwrap
24from pathlib import Path
25
26import numpy as np
27import pytest
28
29from feedtwin.props import (
30 PROPERTIES,
31 Fluid,
32 OutOfRange,
33 Phase,
34 SpeciesSpec,
35 StatePair,
36 TabulatedProperties,
37 load_species_file,
38 register_backend,
39 register_property,
40 register_species,
41 registered_backends,
42 registered_species,
43)
44
45
47 """Argon is not in species.toml. It works anyway, with no code change."""
48 register_species(SpeciesSpec(name="argon", backend_fluid="Argon", aliases=("ar",)))
49
50 assert "argon" in registered_species()
51 argon = Fluid("ar")
52 assert argon.name == "argon"
53 # Argon at 1 atm, 300 K: about 1.62 kg/m3.
54 assert argon.get("rho", p=101325.0, T=300.0) == pytest.approx(1.62, rel=1e-2)
55
56
58 """A project can ship its own fluid table; later definitions win."""
59 table = tmp_path / "extra_species.toml"
60 table.write_text(
61 textwrap.dedent(
62 """
63 [krypton]
64 backend_fluid = "Krypton"
65 aliases = ["kr"]
66 roles = ["pressurant"]
67 chain = ["heos"]
68 """
69 )
70 )
71
72 loaded = load_species_file(table)
73 assert [s.name for s in loaded] == ["krypton"]
74
75 krypton = Fluid("kr")
76 assert krypton.chain == ("heos",)
77 assert krypton.species.roles == ("pressurant",)
78 assert krypton.get("rho", p=101325.0, T=300.0) > 0.0
79
80
82 """The seam is the protocol, not the library behind it.
83
84 A deliberately absurd backend -- constant density, nothing else -- proves
85 the chain talks to the protocol and never to CoolProp specifically. This is
86 what a REFPROP or Modelica media backend would slot into.
87 """
88
89 class ConstantDensity:
90 """Answers 'rho' with one number and declines everything else."""
91
92 name = "constant"
93
94 def __init__(self, value: float) -> None:
95 self._value = value
96
97 def supports(self, prop: str) -> bool:
98 return prop == "rho"
99
100 def update(self, pair: StatePair, v1: float, v2: float) -> None:
101 return None
102
103 def value(self, prop: str) -> float:
104 if prop != "rho":
105 raise KeyError(prop)
106 return self._value
107
108 def phase(self) -> Phase:
109 return Phase.UNKNOWN
110
111 def quality(self) -> float | None:
112 return None
113
114 register_backend("constant_999", lambda species: ConstantDensity(999.0))
115 assert "constant_999" in registered_backends()
116
117 n2 = Fluid("nitrogen", chain=["constant_999", "heos"])
118 # rho comes from the stub, which is first and supports it...
119 assert n2.get("rho", p=1.0e7, T=300.0) == 999.0
120 # ...while viscosity skips it, because it does not claim to compute it.
121 assert n2.get("mu", p=1.0e7, T=300.0) == pytest.approx(1.9e-5, rel=0.2)
122
123
125 """The Phase 12 principle, working in Phase 01.
126
127 A measured grid in front of the equation of state overrides it inside the
128 measured region and falls through outside it -- and nobody writes the "do
129 we have data here?" branch, because the chain is that branch.
130 """
131 p_grid = np.array([1.0e6, 2.0e6, 3.0e6])
132 T_grid = np.array([280.0, 300.0, 320.0])
133 # Deliberately wrong by a factor of two, so "did the measurement win?" is
134 # unambiguous rather than a question about interpolation error.
135 truth = np.array(
136 [[Fluid("nitrogen").get("rho", p=p, T=T) for T in T_grid] for p in p_grid]
137 )
138 measured = TabulatedProperties(
139 p_grid, T_grid, {"rho": truth * 2.0}, source="CF-2026-03"
140 )
141
142 n2 = Fluid("nitrogen", chain=[measured, "bicubic", "heos"])
143 assert n2.chain == ("measured", "bicubic", "heos")
144
145 inside = n2.get("rho", p=2.0e6, T=300.0)
146 assert inside == pytest.approx(truth[1][1] * 2.0, rel=1e-9)
147
148 # Outside the measured box the equation of state answers, unmodified.
149 outside = n2.get("rho", p=1.0e7, T=300.0)
150 assert outside == pytest.approx(Fluid("nitrogen").get("rho", p=1.0e7, T=300.0))
151
152
154 """An extrapolated measurement has a measurement's authority and a guess's
155 accuracy, so the backend must decline instead."""
157 [1.0e6, 2.0e6], [280.0, 300.0], {"rho": np.ones((2, 2))}, source="unit-test"
158 )
159 with pytest.raises(OutOfRange, match="outside the measured grid"):
160 tab.update(StatePair.PT, 9.0e6, 290.0)
161
162
164 """A table of numbers with no declared unit is not data, it is a guess."""
165 with pytest.raises(ValueError, match="not registered properties"):
167 [1.0, 2.0], [1.0, 2.0], {"squiggliness": np.ones((2, 2))}, source="x"
168 )
169
170
172 """Quantities this package never shipped can still flow through it."""
173 spec = register_property("fouling_factor", "m^2.K/W", "Heat exchanger fouling")
174 assert PROPERTIES["fouling_factor"] is spec
175
177 [1.0e5, 1.0e6],
178 [280.0, 320.0],
179 {"fouling_factor": np.full((2, 2), 1.7e-4)},
180 source="unit-test",
181 )
182 fluid = Fluid("nitrogen", chain=[tab, "heos"])
183 assert fluid.get("fouling_factor", p=5.0e5, T=300.0) == pytest.approx(1.7e-4)
184
185 with pytest.raises(ValueError, match="already registered"):
186 register_property("fouling_factor", "-", "duplicate")
187
188
190 """species.toml is the registry; the package holds no fluid list.
191
192 A guard against the obvious regression: someone adds a fluid by writing it
193 into Python because that felt quicker, and the data path quietly becomes
194 decorative.
195 """
196 from feedtwin.props import species as species_module
197
198 table = Path(species_module.__file__).with_name("species.toml")
199 declared = set(tomllib_keys(table))
200
201 assert {"oxygen", "ethanol", "nitrogen", "helium"} <= declared
202 for name in declared:
203 assert Fluid(name).species.backend_fluid
204
205
206def tomllib_keys(path: Path) -> list[str]:
207 import tomllib
208
209 with path.open("rb") as handle:
210 return list(tomllib.load(handle))
uint8_t value
None test_shipped_species_are_declared_as_data_not_code()
None test_measured_data_refuses_outside_its_grid_rather_than_extrapolating()
list[str] tomllib_keys(Path path)
None test_a_backend_that_is_not_coolprop_can_join_a_chain()
None test_a_species_file_can_be_layered_on_top(Path tmp_path)
None test_measured_data_rejects_unregistered_properties()