STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
catalog.py
Go to the documentation of this file.
1"""The parts database: real hardware, with the datasheet and what we measured.
2
3A catalog entry is a part number and the parameters that describe it. Two sets
4of them, deliberately kept apart::
5
6 [swagelok-ss-8bk-v51]
7 type = "valve"
8 manufacturer = "Swagelok"
9
10 [swagelok-ss-8bk-v51.params.Cv]
11 value = 1.2
12 unit = "Cv"
13 source = "manufacturer"
14 reference = "SS-8BK-V51 datasheet rev C, table 2"
15
16 [swagelok-ss-8bk-v51.measured.Cv]
17 value = 1.14
18 unit = "Cv"
19 source = "measured"
20 reference = "CF-2026-03, 12 points, R^2 0.998"
21
22:meth:`Part.resolve` merges them with the measurement winning. The datasheet
23value is *not* overwritten -- it stays visible, so "the valve flows 5% under
24its rating" is a question anyone can ask later. Overwriting would destroy the
25only record that the discrepancy exists.
26
27This is where Phase 12 writes back: fitting parameters against a cold-flow
28produces ``measured`` blocks, with the test that produced them in ``reference``.
29Building the shape now means that phase adds data, not plumbing.
30"""
31
32from __future__ import annotations
33
34import tomllib
35from dataclasses import dataclass, field
36from pathlib import Path
37from typing import Any, Iterable, Mapping
38
39from feedtwin.model.component import ComponentInstance
40from feedtwin.model.curve import Curve
41from feedtwin.model.param import Param, Provenance
42
43
44class CatalogError(KeyError):
45 """A part is missing, or a catalog file is malformed."""
46
47
48@dataclass(frozen=True, slots=True)
49class Part:
50 """One catalogued piece of hardware."""
51
52 id: str
53 type: str
54 manufacturer: str = ""
55 description: str = ""
56 params: dict[str, Param] = field(default_factory=dict)
57 measured: dict[str, Param] = field(default_factory=dict)
58 curves: dict[str, Curve] = field(default_factory=dict)
59 """Tabulated data belonging to this part -- a manufacturer's Cv-versus-travel
60 chart, or a measured pressure drop against flow.
61
62 Kept alongside the scalars because a part is not only scalars, and because
63 without it a part with measured flow data cannot be catalogued at all: the
64 ``measured`` model needs a ``dp_mdot`` curve, so the flagship feature was
65 reachable in code and not from the parts database. It is also where Phase 12
66 writes a fitted curve back to."""
67
68 def resolve(self) -> dict[str, Param]:
69 """Datasheet values with measurements layered over them."""
70 return {**self.params, **self.measured}
71
72 def disagreements(self, tolerance: float = 0.0) -> dict[str, tuple[Param, Param]]:
73 """Parameters where measurement and datasheet differ by more than
74 ``tolerance`` (relative).
75
76 Worth surfacing rather than burying. A part that consistently measures
77 below its rating is either mis-specified, installed wrong, or the
78 datasheet is optimistic -- all three are findings, and all three are
79 invisible if the override silently replaces the original.
80 """
81 out: dict[str, tuple[Param, Param]] = {}
82 for name, measured in self.measured.items():
83 claimed = self.params.get(name)
84 if claimed is None:
85 continue
86 denominator = abs(claimed.si) or 1.0
87 if abs(measured.si - claimed.si) / denominator > tolerance:
88 out[name] = (claimed, measured)
89 return out
90
91 def to_dict(self) -> dict[str, Any]:
92 data: dict[str, Any] = {"type": self.type}
93 if self.manufacturer:
94 data["manufacturer"] = self.manufacturer
95 if self.description:
96 data["description"] = self.description
97 if self.params:
98 data["params"] = {k: v.to_dict() for k, v in sorted(self.params.items())}
99 if self.measured:
100 data["measured"] = {
101 k: v.to_dict() for k, v in sorted(self.measured.items())
102 }
103 if self.curves:
104 data["curves"] = {k: v.to_dict() for k, v in sorted(self.curves.items())}
105 return data
106
107 @classmethod
108 def from_dict(cls, part_id: str, data: Mapping[str, Any]) -> Part:
109 if "type" not in data:
110 raise CatalogError(f"part {part_id!r} does not say what type it is")
111 return cls(
112 id=part_id,
113 type=str(data["type"]),
114 manufacturer=str(data.get("manufacturer", "")),
115 description=str(data.get("description", "")),
116 params=_params(part_id, data.get("params", {}), "params"),
117 measured=_params(part_id, data.get("measured", {}), "measured"),
118 curves=_curves(part_id, data.get("curves", {})),
119 )
120
121
122def _curves(part_id: str, raw: Any) -> dict[str, Curve]:
123 if not isinstance(raw, Mapping):
124 raise CatalogError(f"part {part_id!r}: [curves] must be a table")
125 return {
126 name: Curve.from_dict(dict(body), where=f"{part_id}.curves.{name}")
127 for name, body in raw.items()
128 }
129
130
131def _params(part_id: str, raw: Any, block: str) -> dict[str, Param]:
132 if not isinstance(raw, Mapping):
133 raise CatalogError(f"part {part_id!r}: [{block}] must be a table")
134 return {
135 name: Param.from_dict(dict(body), where=f"{part_id}.{block}.{name}")
136 for name, body in raw.items()
137 }
138
139
141 """A collection of parts, loadable from one or more TOML files.
142
143 Layering is by design: a shipped catalog, a team catalog and a
144 campaign-specific one can all be loaded in order, with later definitions
145 replacing earlier ones by part number.
146 """
147
148 def __init__(self, parts: Iterable[Part] = ()) -> None:
149 self._parts: dict[str, Part] = {p.id: p for p in parts}
150
151 def __len__(self) -> int:
152 return len(self._parts)
153
154 def __contains__(self, part_id: object) -> bool:
155 return part_id in self._parts
156
157 def add(self, part: Part) -> Part:
158 self._parts[part.id] = part
159 return part
160
161 def get(self, part_id: str) -> Part:
162 try:
163 return self._parts[part_id]
164 except KeyError:
165 known = ", ".join(sorted(self._parts)[:12]) or "(catalog is empty)"
166 raise CatalogError(
167 f"no part {part_id!r} in the catalog. Known parts include: {known}"
168 ) from None
169
170 def parts(self, type_name: str | None = None) -> list[str]:
171 """Part numbers, optionally filtered by component type."""
172 return sorted(p.id for p in self._parts.values() if type_name in (None, p.type))
173
174 def load(self, path: Path | str) -> list[Part]:
175 """Load a TOML catalog file, replacing any parts it redefines."""
176 path = Path(path)
177 with path.open("rb") as handle:
178 raw = tomllib.load(handle)
179
180 loaded: list[Part] = []
181 for part_id, body in raw.items():
182 if not isinstance(body, dict):
183 raise CatalogError(
184 f"{path}: [{part_id}] must be a table, got {type(body).__name__}"
185 )
186 loaded.append(self.add(Part.from_dict(part_id, body)))
187 return loaded
188
189 @classmethod
190 def from_file(cls, path: Path | str) -> Catalog:
191 catalog = cls()
192 catalog.load(path)
193 return catalog
194
196 self,
197 instance_id: str,
198 part_id: str,
199 *,
200 overrides: Mapping[str, Param] | None = None,
201 curves: Mapping[str, Curve] | None = None,
202 model: str = "",
203 connections: Mapping[str, str] | None = None,
204 ) -> ComponentInstance:
205 """Turn a part number into a configured component.
206
207 Precedence, lowest to highest: datasheet values, then measurements, then
208 anything given in ``overrides``. Curves layer the same way, the part's
209 beneath anything passed in. An override is how a single instance
210 differs from its catalogue entry -- this particular valve was trimmed,
211 this run of tube is longer -- without inventing a new part number.
212
213 The result is validated against the component's spec, so a catalog entry
214 missing a required parameter fails here with the parameter named, rather
215 than at solve time as a missing key.
216 """
217 part = self.get(part_id)
218 params = part.resolve()
219 if overrides:
220 params = {**params, **dict(overrides)}
221
222 resolved_curves = {**part.curves, **dict(curves or {})}
223
224 return ComponentInstance.build(
225 id=instance_id,
226 type_name=part.type,
227 params=params,
228 curves=resolved_curves,
229 model=model,
230 connections=dict(connections or {}),
231 part=part.id,
232 )
233
234 def to_dict(self) -> dict[str, Any]:
235 return {pid: part.to_dict() for pid, part in sorted(self._parts.items())}
236
237
238def measured(value: float, unit: str, reference: str, uncertainty: Any = None) -> Param:
239 """A measured value, tagged as such. Shorthand for Phase 12 write-back."""
240 return Param(
241 value=value,
242 unit=unit,
243 source=Provenance.MEASURED,
244 reference=reference,
245 uncertainty=uncertainty,
246 )
bool __contains__(self, object part_id)
Definition catalog.py:154
Catalog from_file(cls, Path|str path)
Definition catalog.py:190
None __init__(self, Iterable[Part] parts=())
Definition catalog.py:148
dict[str, Any] to_dict(self)
Definition catalog.py:234
Part get(self, str part_id)
Definition catalog.py:161
Part add(self, Part part)
Definition catalog.py:157
ComponentInstance instantiate(self, str instance_id, str part_id, *, Mapping[str, Param]|None overrides=None, Mapping[str, Curve]|None curves=None, str model="", Mapping[str, str]|None connections=None)
Definition catalog.py:204
list[str] parts(self, str|None type_name=None)
Definition catalog.py:170
list[Part] load(self, Path|str path)
Definition catalog.py:174
dict[str, Param] resolve(self)
Definition catalog.py:68
dict[str, Any] to_dict(self)
Definition catalog.py:91
dict[str, tuple[Param, Param]] disagreements(self, float tolerance=0.0)
Definition catalog.py:72
Part from_dict(cls, str part_id, Mapping[str, Any] data)
Definition catalog.py:108
Param measured(float value, str unit, str reference, Any uncertainty=None)
Definition catalog.py:238
dict[str, Curve] _curves(str part_id, Any raw)
Definition catalog.py:122
dict[str, Param] _params(str part_id, Any raw, str block)
Definition catalog.py:131