STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
tabulated.py
Go to the documentation of this file.
1"""Measured data as a property source.
2
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::
8
9 tab = TabulatedProperties(
10 p=[...], T=[...], values={"rho": rho_grid}, source="CF-2026-03"
11 )
12 n2 = Fluid("nitrogen", chain=[tab, "bicubic", "heos"])
13
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.
19
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.
22"""
23
24from __future__ import annotations
25
26from typing import TYPE_CHECKING, Any, Mapping, Sequence
27
28import threading
29
30import numpy as np
31from scipy.interpolate import RegularGridInterpolator
32
33from feedtwin.props.backend import StatePair
34from feedtwin.props.errors import OutOfRange, UnsupportedProperty
35from feedtwin.props.state import PROPERTIES, Phase
36
37if TYPE_CHECKING: # pragma: no cover - types only
38 from feedtwin.props.species import SpeciesSpec
39
40
42 """Properties read from a measured grid over pressure and temperature.
43
44 Args:
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"``.
53
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.
57 """
58
60 self,
61 p: Sequence[float],
62 T: Sequence[float],
63 values: Mapping[str, Any],
64 *,
65 source: str,
66 name: str = "measured",
67 ) -> None:
68 self._p = np.asarray(p, dtype=float)
69 self._T = np.asarray(T, dtype=float)
70 self._name = name
71 self.source = source
72
73 _require_increasing(self._p, "p")
74 _require_increasing(self._T, "T")
75
76 unknown = sorted(set(values) - set(PROPERTIES))
77 if unknown:
78 raise ValueError(
79 f"{unknown} are not registered properties. Register them with "
80 "feedtwin.props.register_property() first, so their units are "
81 "declared somewhere."
82 )
83
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:
89 raise ValueError(
90 f"{prop!r} has shape {array.shape}, expected {shape} "
91 "= (len(p), len(T))"
92 )
93 # bounds_error: outside the measured box we must *refuse*, not
94 # extrapolate. Extrapolated measurements are the worst of both --
95 # they carry a measurement's authority and a guess's accuracy.
96 self._interp[prop] = RegularGridInterpolator(
97 (self._p, self._T), array, bounds_error=True
98 )
99
100 # Thread-local cursor. Unlike the CoolProp backends, one instance of
101 # this is shared across threads -- a Fluid cannot rebuild it per thread
102 # because it is parameterised by megabytes of measured data rather than
103 # by a species name. The grids are read-only after construction, so
104 # confining just the cursor is enough to make sharing safe.
105 self._cursor = threading.local()
106
107 @property
108 def name(self) -> str:
109 return self._name
110
111 def supports(self, prop: str) -> bool:
112 return prop in self._interp
113
114 def update(self, pair: StatePair, v1: float, v2: float) -> None:
115 if pair is not StatePair.PT:
116 raise OutOfRange(
117 self._name,
118 "tabulated",
119 f"only PT is addressable, got {pair.value}",
120 )
121 if not (self._p[0] <= v1 <= self._p[-1] and self._T[0] <= v2 <= self._T[-1]):
122 raise OutOfRange(
123 self._name,
124 "tabulated",
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)",
128 )
129 self._cursor.point = (v1, v2)
130
131 def value(self, prop: str) -> float:
132 point: tuple[float, float] | None = getattr(self._cursor, "point", None)
133 if point is None:
134 raise RuntimeError("update() must be called before value() in this thread")
135 interp = self._interp.get(prop)
136 if interp is None:
137 raise UnsupportedProperty(self._name, prop)
138 if prop == "p":
139 return point[0]
140 if prop == "T":
141 return point[1]
142 return float(interp(np.asarray([point]))[0])
143
144 def phase(self) -> Phase:
145 """Unknown: a measured grid does not carry phase information.
146
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
149 for.
150 """
151 return Phase.UNKNOWN
152
153 def quality(self) -> float | None:
154 return None
155
156
157def _require_increasing(values: Any, label: str) -> None:
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")
162
163
164def tabulated_from_spec(species: SpeciesSpec) -> TabulatedProperties:
165 """Build a tabulated backend from ``[species.options.measured]`` in TOML.
166
167 Lets a species declare its measured data in the species table rather than
168 only in code::
169
170 [nitrogen.options.measured]
171 path = "data/n2_coldflow.npz"
172 source = "CF-2026-03"
173
174 The ``.npz`` must hold ``p`` and ``T`` arrays plus one array per property.
175 """
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"
180 )
181
182 with np.load(str(options["path"])) as data:
183 arrays = {k: data[k] for k in data.files}
184
185 p = arrays.pop("p")
186 T = arrays.pop("T")
187 return TabulatedProperties(
188 p, T, arrays, source=str(options.get("source", options["path"]))
189 )
None __init__(self, Sequence[float] p, Sequence[float] T, Mapping[str, Any] values, *, str source, str name="measured")
Definition tabulated.py:67
None update(self, StatePair pair, float v1, float v2)
Definition tabulated.py:114
uint8_t value
None _require_increasing(Any values, str label)
Definition tabulated.py:157
TabulatedProperties tabulated_from_spec(SpeciesSpec species)
Definition tabulated.py:164