1"""A parameter is a record, not a float.
3Every number that describes hardware carries where it came from::
9 reference: "Swagelok SS-8BK-V51 datasheet rev C, table 2"
10 uncertainty: {kind: relative, value: 0.10}
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.
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.
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
28from __future__
import annotations
30from dataclasses
import dataclass
38 """Where a value came from, ordered by how much weight it carries."""
41 """Measured on our own hardware. Cite the test in ``reference``."""
43 MANUFACTURER =
"manufacturer"
44 """From a datasheet. Cite the document and revision."""
46 ESTIMATED =
"estimated"
47 """Engineering judgement, a correlation, or a similar part. Say which."""
50 """Supplied by this library because nobody specified one. Always suspect."""
54 """Higher is more authoritative. Drives catalog override precedence."""
59 """True for values nobody has actually established."""
60 return self
in (Provenance.ESTIMATED, Provenance.DEFAULT)
63_RANKS: dict[Provenance, int] = {
64 Provenance.MEASURED: 3,
65 Provenance.MANUFACTURER: 2,
66 Provenance.ESTIMATED: 1,
67 Provenance.DEFAULT: 0,
71@dataclass(frozen=True, slots=True)
73 """How well a value is known.
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.
85 if self.
kind not in (
"relative",
"absolute"):
87 f
"uncertainty kind must be 'relative' or 'absolute', got {self.kind!r}"
90 raise ValueError(f
"uncertainty must be non-negative, got {self.value}")
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
98 return {
"kind": self.
kind,
"value": self.value}
101 def from_dict(cls, data: dict[str, Any]) -> Uncertainty:
102 return cls(kind=str(data[
"kind"]), value=float(data[
"value"]))
105@dataclass(frozen=True, slots=True)
107 """One authored number, with its unit and its provenance.
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.
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.
128 uncertainty: Uncertainty |
None =
None
132 if not isinstance(self.
source, Provenance):
134 f
"source must be a Provenance, got {type(self.source).__name__}. "
135 "Every parameter has to say where it came from."
139 def si(self) -> float:
140 """The value in this package's canonical unit for its dimension."""
145 return get_unit(self.
unit).dimension
148 """Uncertainty bounds in canonical units, or ``None`` if not stated."""
154 """A copy with fields changed -- values are immutable by design."""
155 from dataclasses
import replace
as _replace
157 return _replace(self, **changes)
162 """A plain-data form that round-trips exactly.
164 Empty optional fields are omitted rather than written as nulls, so a
165 file stays as terse as it was authored.
167 data: dict[str, Any] = {
170 "source": self.
source.value,
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]
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."
190 valid =
", ".join(p.value
for p
in Provenance)
192 f
"{where}: {data['source']!r} is not a valid source; use one of {valid}"
195 uncertainty = data.get(
"uncertainty")
197 value=float(data[
"value"]),
198 unit=str(data[
"unit"]),
200 reference=str(data.get(
"reference",
"")),
202 Uncertainty.from_dict(uncertainty)
if uncertainty
is not None else None
207 tail = f
" ({self.reference})" if self.
reference else ""
208 return f
"{self.value:g} {self.unit} [{self.source.value}]{tail}"
212 """Count parameters by provenance -- the header of a run report.
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.
219 counts = {p.value: 0
for p
in Provenance}
220 for param
in params.values():
221 counts[param.source.value] += 1
226 """Names of every parameter nobody has actually established, sorted.
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.
232 return sorted(name
for name, p
in params.items()
if p.source.is_assumed)
Param replace(self, **Any changes)
tuple[float, float]|None si_bounds(self)
dict[str, Any] to_dict(self)
Param from_dict(cls, dict[str, Any] data, str where="parameter")
dict[str, Any] to_dict(self)
tuple[float, float] bounds(self, float si_value)
Uncertainty from_dict(cls, dict[str, Any] data)
dict[str, int] provenance_summary(dict[str, Param] params)
list[str] assumed_params(dict[str, Param] params)