STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
param.py
Go to the documentation of this file.
1"""A parameter is a record, not a float.
2
3Every number that describes hardware carries where it came from::
4
5 Cv:
6 value: 1.2
7 unit: Cv
8 source: manufacturer
9 reference: "Swagelok SS-8BK-V51 datasheet rev C, table 2"
10 uncertainty: {kind: relative, value: 0.10}
11
12The value is the least interesting field. What makes a result trustworthy is
13knowing which of its inputs were measured on the bench, which came off a
14datasheet, and which somebody guessed on a Tuesday -- and today the answer to
15that lives in people's heads and evaporates when they graduate.
16
17There is no default for ``source``. Omitting it is a validation error, not a
18shrug, because "we don't know where this came from" is exactly the state this
19type exists to make impossible. ``DEFAULT`` is available and means something
20specific: the library supplied it, and nobody has looked.
21
22Provenance survives into results. A run report can say *this answer rests on
23nine measured parameters, four datasheet values and two guesses* -- which is the
24difference between a number you can defend in a design review and one you
25cannot.
26"""
27
28from __future__ import annotations
29
30from dataclasses import dataclass
31from enum import Enum
32from typing import Any
33
34from feedtwin.model.units import get_unit, to_si
35
36
37class Provenance(Enum):
38 """Where a value came from, ordered by how much weight it carries."""
39
40 MEASURED = "measured"
41 """Measured on our own hardware. Cite the test in ``reference``."""
42
43 MANUFACTURER = "manufacturer"
44 """From a datasheet. Cite the document and revision."""
45
46 ESTIMATED = "estimated"
47 """Engineering judgement, a correlation, or a similar part. Say which."""
48
49 DEFAULT = "default"
50 """Supplied by this library because nobody specified one. Always suspect."""
51
52 @property
53 def rank(self) -> int:
54 """Higher is more authoritative. Drives catalog override precedence."""
55 return _RANKS[self]
56
57 @property
58 def is_assumed(self) -> bool:
59 """True for values nobody has actually established."""
60 return self in (Provenance.ESTIMATED, Provenance.DEFAULT)
61
62
63_RANKS: dict[Provenance, int] = {
64 Provenance.MEASURED: 3,
65 Provenance.MANUFACTURER: 2,
66 Provenance.ESTIMATED: 1,
67 Provenance.DEFAULT: 0,
68}
69
70
71@dataclass(frozen=True, slots=True)
73 """How well a value is known.
74
75 Carried but not yet propagated -- Phase 12 fits parameters against measured
76 data and needs a prior, and a sensitivity study needs a range. Recording it
77 at authoring time costs nothing; reconstructing it two years later from a
78 datasheet nobody kept costs a great deal.
79 """
80
81 kind: str # "relative" | "absolute"
82 value: float
83
84 def __post_init__(self) -> None:
85 if self.kind not in ("relative", "absolute"):
86 raise ValueError(
87 f"uncertainty kind must be 'relative' or 'absolute', got {self.kind!r}"
88 )
89 if self.value < 0.0:
90 raise ValueError(f"uncertainty must be non-negative, got {self.value}")
91
92 def bounds(self, si_value: float) -> tuple[float, float]:
93 """Low and high bounds around a value already in canonical units."""
94 delta = abs(si_value) * self.value if self.kind == "relative" else self.value
95 return si_value - delta, si_value + delta
96
97 def to_dict(self) -> dict[str, Any]:
98 return {"kind": self.kind, "value": self.value}
99
100 @classmethod
101 def from_dict(cls, data: dict[str, Any]) -> Uncertainty:
102 return cls(kind=str(data["kind"]), value=float(data["value"]))
103
104
105@dataclass(frozen=True, slots=True)
106class Param:
107 """One authored number, with its unit and its provenance.
108
109 The value is stored **as authored**, in the unit it was written in, and
110 converted on demand through :attr:`si`. Keeping the authored form is what
111 makes a config round-trip byte-identically: a file written in psi comes back
112 in psi rather than as a converted float with a trail of decimals.
113
114 Args:
115 value: The number, in ``unit``.
116 unit: A registered unit name -- see :mod:`feedtwin.model.units`.
117 source: Where it came from. Required.
118 reference: The document, test or reasoning behind it. Strongly
119 encouraged; a ``MEASURED`` value with no reference is not traceable
120 to anything and is only nominally better than a guess.
121 uncertainty: How well it is known, if that is established.
122 """
123
124 value: float
125 unit: str
126 source: Provenance
127 reference: str = ""
128 uncertainty: Uncertainty | None = None
129
130 def __post_init__(self) -> None:
131 get_unit(self.unit) # raises UnknownUnit if the spelling is not registered
132 if not isinstance(self.source, Provenance):
133 raise TypeError(
134 f"source must be a Provenance, got {type(self.source).__name__}. "
135 "Every parameter has to say where it came from."
136 )
137
138 @property
139 def si(self) -> float:
140 """The value in this package's canonical unit for its dimension."""
141 return to_si(self.value, self.unit)
142
143 @property
144 def dimension(self) -> str:
145 return get_unit(self.unit).dimension
146
147 def si_bounds(self) -> tuple[float, float] | None:
148 """Uncertainty bounds in canonical units, or ``None`` if not stated."""
149 if self.uncertainty is None:
150 return None
151 return self.uncertainty.bounds(self.si)
152
153 def replace(self, **changes: Any) -> Param:
154 """A copy with fields changed -- values are immutable by design."""
155 from dataclasses import replace as _replace
156
157 return _replace(self, **changes)
158
159 # ------------------------------------------------------------ serialisation
160
161 def to_dict(self) -> dict[str, Any]:
162 """A plain-data form that round-trips exactly.
163
164 Empty optional fields are omitted rather than written as nulls, so a
165 file stays as terse as it was authored.
166 """
167 data: dict[str, Any] = {
168 "value": self.value,
169 "unit": self.unit,
170 "source": self.source.value,
171 }
172 if self.reference:
173 data["reference"] = self.reference
174 if self.uncertainty is not None:
175 data["uncertainty"] = self.uncertainty.to_dict()
176 return data
177
178 @classmethod
179 def from_dict(cls, data: dict[str, Any], where: str = "parameter") -> Param:
180 missing = [k for k in ("value", "unit", "source") if k not in data]
181 if missing:
182 raise ValueError(
183 f"{where}: missing required field(s) {', '.join(missing)}. "
184 "Every parameter needs a value, a unit and a source -- see "
185 "feedtwin.model.param.Provenance for what a source may be."
186 )
187 try:
188 source = Provenance(str(data["source"]))
189 except ValueError:
190 valid = ", ".join(p.value for p in Provenance)
191 raise ValueError(
192 f"{where}: {data['source']!r} is not a valid source; use one of {valid}"
193 ) from None
194
195 uncertainty = data.get("uncertainty")
196 return cls(
197 value=float(data["value"]),
198 unit=str(data["unit"]),
199 source=source,
200 reference=str(data.get("reference", "")),
201 uncertainty=(
202 Uncertainty.from_dict(uncertainty) if uncertainty is not None else None
203 ),
204 )
205
206 def __str__(self) -> str:
207 tail = f" ({self.reference})" if self.reference else ""
208 return f"{self.value:g} {self.unit} [{self.source.value}]{tail}"
209
210
211def provenance_summary(params: dict[str, Param]) -> dict[str, int]:
212 """Count parameters by provenance -- the header of a run report.
213
214 Returns a mapping keyed by provenance value, always containing every key so
215 a report can render a stable table. ``{"measured": 9, "manufacturer": 4,
216 "estimated": 2, "default": 0}`` says more about how much to trust a result
217 than any single number in it.
218 """
219 counts = {p.value: 0 for p in Provenance}
220 for param in params.values():
221 counts[param.source.value] += 1
222 return counts
223
224
225def assumed_params(params: dict[str, Param]) -> list[str]:
226 """Names of every parameter nobody has actually established, sorted.
227
228 What a report should list explicitly. A result standing on twelve guesses is
229 not wrong, but it is a different kind of claim from one standing on twelve
230 measurements, and the difference should never be invisible.
231 """
232 return sorted(name for name, p in params.items() if p.source.is_assumed)
Param replace(self, **Any changes)
Definition param.py:153
Uncertainty uncertainty
Definition param.py:128
tuple[float, float]|None si_bounds(self)
Definition param.py:147
dict[str, Any] to_dict(self)
Definition param.py:161
Param from_dict(cls, dict[str, Any] data, str where="parameter")
Definition param.py:179
None __post_init__(self)
Definition param.py:130
dict[str, Any] to_dict(self)
Definition param.py:97
tuple[float, float] bounds(self, float si_value)
Definition param.py:92
Uncertainty from_dict(cls, dict[str, Any] data)
Definition param.py:101
dict[str, int] provenance_summary(dict[str, Param] params)
Definition param.py:211
list[str] assumed_params(dict[str, Param] params)
Definition param.py:225