1"""Measured data as a property source.
3The point of this backend is that it sits *in front of* an equation of state and
4covers only the region you actually measured. Inside that region your numbers
5win; outside it, it raises :class:`OutOfRange` and the fluid's chain falls
6through to the equation of state. Nobody writes the "do we have data here?"
7branch, because the chain already is that branch::
9 tab = TabulatedProperties(
10 p=[...], T=[...], values={"rho": rho_grid}, source="CF-2026-03"
12 n2 = Fluid("nitrogen", chain=[tab, "bicubic", "heos"])
14This is the property-layer half of a principle that runs through the whole
15project (Decision 02 in the plan): anything modelled can be replaced by
16something measured, with the provenance recorded. It is here in Phase 01 rather
17than bolted on at Phase 12 because a seam added after the fact is a seam every
18existing call site has to be audited against.
20A blended fluid -- a real ullage of pressurant over propellant vapour -- is a
21mixture, not a table, and belongs to a mixture backend rather than to this one.
24from __future__
import annotations
26from typing
import TYPE_CHECKING, Any, Mapping, Sequence
31from scipy.interpolate
import RegularGridInterpolator
42 """Properties read from a measured grid over pressure and temperature.
45 p: Strictly increasing pressures [Pa].
46 T: Strictly increasing temperatures [K].
47 values: Property name to a ``(len(p), len(T))`` array of values, in the
48 SI units :data:`feedtwin.props.state.PROPERTIES` declares.
49 source: Where the numbers came from -- a test campaign, a datasheet
50 revision. Carried into every :class:`ThermoState` this produces, so
51 a result can always be traced to the measurement behind it.
52 name: Backend identifier; defaults to ``"measured"``.
54 Only ``StatePair.PT`` is addressable. Any other pair raises
55 :class:`OutOfRange`, which the chain answers by trying the next backend --
56 the same path as a state point outside the grid.
63 values: Mapping[str, Any],
66 name: str =
"measured",
68 self.
_p = np.asarray(p, dtype=float)
69 self.
_T = np.asarray(T, dtype=float)
76 unknown = sorted(set(values) - set(PROPERTIES))
79 f
"{unknown} are not registered properties. Register them with "
80 "feedtwin.props.register_property() first, so their units are "
84 shape = (self.
_p.size, self.
_T.size)
85 self.
_interp: dict[str, RegularGridInterpolator] = {}
86 for prop, grid
in values.items():
87 array = np.asarray(grid, dtype=float)
88 if array.shape != shape:
90 f
"{prop!r} has shape {array.shape}, expected {shape} "
96 self.
_interp[prop] = RegularGridInterpolator(
97 (self.
_p, self.
_T), array, bounds_error=
True
114 def update(self, pair: StatePair, v1: float, v2: float) ->
None:
115 if pair
is not StatePair.PT:
119 f
"only PT is addressable, got {pair.value}",
121 if not (self.
_p[0] <= v1 <= self.
_p[-1]
and self.
_T[0] <= v2 <= self.
_T[-1]):
125 f
"p={v1:.6g} Pa, T={v2:.6g} K outside the measured grid "
126 f
"({self._p[0]:.4g}-{self._p[-1]:.4g} Pa, "
127 f
"{self._T[0]:.4g}-{self._T[-1]:.4g} K)",
131 def value(self, prop: str) -> float:
132 point: tuple[float, float] |
None = getattr(self.
_cursor,
"point",
None)
134 raise RuntimeError(
"update() must be called before value() in this thread")
135 interp = self.
_interp.get(prop)
142 return float(interp(np.asarray([point]))[0])
145 """Unknown: a measured grid does not carry phase information.
147 Reported honestly rather than guessed. A caller that needs the phase
148 gets it from an equation-of-state backend, which is what the chain is
158 if values.ndim != 1
or values.size < 2:
159 raise ValueError(f
"{label} must be a 1-D array of at least two values")
160 if not bool(np.all(np.diff(values) > 0)):
161 raise ValueError(f
"{label} must be strictly increasing")
165 """Build a tabulated backend from ``[species.options.measured]`` in TOML.
167 Lets a species declare its measured data in the species table rather than
170 [nitrogen.options.measured]
171 path = "data/n2_coldflow.npz"
172 source = "CF-2026-03"
174 The ``.npz`` must hold ``p`` and ``T`` arrays plus one array per property.
176 options = species.options.get(
"measured")
177 if not isinstance(options, dict)
or "path" not in options:
179 "measured", f
"species {species.name!r} declares no measured data"
182 with np.load(str(options[
"path"]))
as data:
183 arrays = {k: data[k]
for k
in data.files}
188 p, T, arrays, source=str(options.get(
"source", options[
"path"]))
None __init__(self, Sequence[float] p, Sequence[float] T, Mapping[str, Any] values, *, str source, str name="measured")
None update(self, StatePair pair, float v1, float v2)
bool supports(self, str prop)
None _require_increasing(Any values, str label)
TabulatedProperties tabulated_from_spec(SpeciesSpec species)