1"""Tabulated data: the parameters that are not single numbers.
3A :class:`Param` covers a scalar. Real hardware also comes with curves --
4a valve's Cv against stem position, a pump's head against flow, and above all a
5measured pressure drop against mass flow, which is what a cold-flow test
6produces and what supersedes every correlation in this package.
8Same rules as a scalar: units are declared and converted once, provenance is
9mandatory, and interpolation **refuses outside the measured range** rather than
10extrapolating. An extrapolated measurement carries a measurement's authority and
11a guess's accuracy, which is the worst of both.
13Discovered in Phase 03 rather than designed in Phase 02: the need only became
14concrete once components had to accept a measured Δp table. It is a small,
15additive extension -- :class:`ComponentInstance` grew a ``curves`` field
16alongside ``params`` -- and nothing that existed had to change.
19from __future__
import annotations
21from dataclasses
import dataclass
22from typing
import Any, Sequence
31 """A curve is malformed, or was asked for a point it does not cover."""
34@dataclass(frozen=
True, slots=
True)
36 """One tabulated relationship, stored as authored and evaluated in SI.
39 x: Independent values, strictly increasing, in ``x_unit``.
40 y: Dependent values, same length, in ``y_unit``.
41 x_unit, y_unit: Registered unit names.
42 source: Where the curve came from. Required, like any parameter.
43 reference: The test or document behind it.
44 extrapolate: Whether to hold the end values outside the range instead of
45 raising. Off by default and rarely right; a valve curve that stops at
46 90% travel genuinely does not say what happens at 100%.
55 extrapolate: bool =
False
58 if len(self.
x) != len(self.
y):
60 f
"x has {len(self.x)} points and y has {len(self.y)}; they must match"
63 raise CurveError(
"a curve needs at least two points")
64 if not all(b > a
for a, b
in zip(self.
x, self.
x[1:])):
65 raise CurveError(
"x must be strictly increasing")
66 if not isinstance(self.
source, Provenance):
67 raise TypeError(
"a curve must say where it came from, like any parameter")
72 def x_si(self) -> tuple[float, ...]:
73 return tuple(to_si(v, self.
x_unit)
for v
in self.
x)
76 def y_si(self) -> tuple[float, ...]:
77 return tuple(to_si(v, self.
y_unit)
for v
in self.
y)
85 """Interpolate at a point given in canonical units.
87 Linear between points. Higher-order interpolation of sparse measured
88 data invents structure that was never measured, which matters more here
92 if not low <= x_si <= high:
95 f
"{x_si:.6g} {si_unit_of(dimension_of(self.x_unit))} is outside "
96 f
"the curve's range ({low:.6g} to {high:.6g}). It was measured "
97 "over that interval and says nothing beyond it."
99 x_si =
min(
max(x_si, low), high)
100 return float(np.interp(x_si, self.
x_si, self.
y_si))
103 """Raise unless the axes measure what a component expects."""
104 check_dimension(self.
x_unit, x_dimension, f
"{where} (x axis)")
105 check_dimension(self.
y_unit, y_dimension, f
"{where} (y axis)")
108 data: dict[str, Any] = {
113 "source": self.
source.value,
118 data[
"extrapolate"] =
True
122 def from_dict(cls, data: dict[str, Any], where: str =
"curve") -> Curve:
123 missing = [k
for k
in (
"x",
"y",
"x_unit",
"y_unit",
"source")
if k
not in data]
125 raise CurveError(f
"{where}: missing {', '.join(missing)}")
129 valid =
", ".join(p.value
for p
in Provenance)
131 f
"{where}: {data['source']!r} is not a valid source; use one of {valid}"
134 x=tuple(float(v)
for v
in data[
"x"]),
135 y=tuple(float(v)
for v
in data[
"y"]),
136 x_unit=str(data[
"x_unit"]),
137 y_unit=str(data[
"y_unit"]),
139 reference=str(data.get(
"reference",
"")),
140 extrapolate=bool(data.get(
"extrapolate",
False)),
144@dataclass(frozen=True, slots=True)
146 """A curve a component accepts, declared alongside its parameters."""
152 required: bool =
False
153 models: tuple[str, ...] = ()
166 source: Provenance = Provenance.MEASURED,
168 """Build a measured curve. What a test-data reader produces."""
170 x=tuple(float(v)
for v
in x),
171 y=tuple(float(v)
for v
in y),
bool applies_to(self, str model)
dict[str, Any] to_dict(self)
float __call__(self, float x_si)
None check_dimensions(self, str x_dimension, str y_dimension, str where)
Curve from_dict(cls, dict[str, Any] data, str where="curve")
tuple[float, float] x_range_si(self)
Curve curve_from_points(Sequence[float] x, Sequence[float] y, *, str x_unit, str y_unit, str reference, Provenance source=Provenance.MEASURED)