STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
curve.py
Go to the documentation of this file.
1"""Tabulated data: the parameters that are not single numbers.
2
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.
7
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.
12
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.
17"""
18
19from __future__ import annotations
20
21from dataclasses import dataclass
22from typing import Any, Sequence
23
24import numpy as np
25
26from feedtwin.model.param import Provenance
27from feedtwin.model.units import check_dimension, dimension_of, si_unit_of, to_si
28
29
30class CurveError(ValueError):
31 """A curve is malformed, or was asked for a point it does not cover."""
32
33
34@dataclass(frozen=True, slots=True)
35class Curve:
36 """One tabulated relationship, stored as authored and evaluated in SI.
37
38 Args:
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%.
47 """
48
49 x: tuple[float, ...]
50 y: tuple[float, ...]
51 x_unit: str
52 y_unit: str
53 source: Provenance
54 reference: str = ""
55 extrapolate: bool = False
56
57 def __post_init__(self) -> None:
58 if len(self.x) != len(self.y):
59 raise CurveError(
60 f"x has {len(self.x)} points and y has {len(self.y)}; they must match"
61 )
62 if len(self.x) < 2:
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")
68 dimension_of(self.x_unit)
69 dimension_of(self.y_unit)
70
71 @property
72 def x_si(self) -> tuple[float, ...]:
73 return tuple(to_si(v, self.x_unit) for v in self.x)
74
75 @property
76 def y_si(self) -> tuple[float, ...]:
77 return tuple(to_si(v, self.y_unit) for v in self.y)
78
79 @property
80 def x_range_si(self) -> tuple[float, float]:
81 xs = self.x_si
82 return xs[0], xs[-1]
83
84 def __call__(self, x_si: float) -> float:
85 """Interpolate at a point given in canonical units.
86
87 Linear between points. Higher-order interpolation of sparse measured
88 data invents structure that was never measured, which matters more here
89 than smoothness does.
90 """
91 low, high = self.x_range_si
92 if not low <= x_si <= high:
93 if not self.extrapolate:
94 raise CurveError(
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."
98 )
99 x_si = min(max(x_si, low), high)
100 return float(np.interp(x_si, self.x_si, self.y_si))
101
102 def check_dimensions(self, x_dimension: str, y_dimension: str, where: str) -> None:
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)")
106
107 def to_dict(self) -> dict[str, Any]:
108 data: dict[str, Any] = {
109 "x": list(self.x),
110 "y": list(self.y),
111 "x_unit": self.x_unit,
112 "y_unit": self.y_unit,
113 "source": self.source.value,
114 }
115 if self.reference:
116 data["reference"] = self.reference
117 if self.extrapolate:
118 data["extrapolate"] = True
119 return data
120
121 @classmethod
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]
124 if missing:
125 raise CurveError(f"{where}: missing {', '.join(missing)}")
126 try:
127 source = Provenance(str(data["source"]))
128 except ValueError:
129 valid = ", ".join(p.value for p in Provenance)
130 raise CurveError(
131 f"{where}: {data['source']!r} is not a valid source; use one of {valid}"
132 ) from None
133 return cls(
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"]),
138 source=source,
139 reference=str(data.get("reference", "")),
140 extrapolate=bool(data.get("extrapolate", False)),
141 )
142
143
144@dataclass(frozen=True, slots=True)
146 """A curve a component accepts, declared alongside its parameters."""
147
148 name: str
149 x_dimension: str
150 y_dimension: str
151 description: str
152 required: bool = False
153 models: tuple[str, ...] = ()
154
155 def applies_to(self, model: str) -> bool:
156 return not self.models or model in self.models
157
158
160 x: Sequence[float],
161 y: Sequence[float],
162 *,
163 x_unit: str,
164 y_unit: str,
165 reference: str,
166 source: Provenance = Provenance.MEASURED,
167) -> Curve:
168 """Build a measured curve. What a test-data reader produces."""
169 return Curve(
170 x=tuple(float(v) for v in x),
171 y=tuple(float(v) for v in y),
172 x_unit=x_unit,
173 y_unit=y_unit,
174 source=source,
175 reference=reference,
176 )
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
bool applies_to(self, str model)
Definition curve.py:155
dict[str, Any] to_dict(self)
Definition curve.py:107
float __call__(self, float x_si)
Definition curve.py:84
None __post_init__(self)
Definition curve.py:57
None check_dimensions(self, str x_dimension, str y_dimension, str where)
Definition curve.py:102
Curve from_dict(cls, dict[str, Any] data, str where="curve")
Definition curve.py:122
tuple[float, float] x_range_si(self)
Definition curve.py:80
Curve curve_from_points(Sequence[float] x, Sequence[float] y, *, str x_unit, str y_unit, str reference, Provenance source=Provenance.MEASURED)
Definition curve.py:167