1"""The parts database: real hardware, with the datasheet and what we measured.
3A catalog entry is a part number and the parameters that describe it. Two sets
4of them, deliberately kept apart::
8 manufacturer = "Swagelok"
10 [swagelok-ss-8bk-v51.params.Cv]
13 source = "manufacturer"
14 reference = "SS-8BK-V51 datasheet rev C, table 2"
16 [swagelok-ss-8bk-v51.measured.Cv]
20 reference = "CF-2026-03, 12 points, R^2 0.998"
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.
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.
32from __future__
import annotations
35from dataclasses
import dataclass, field
36from pathlib
import Path
37from typing
import Any, Iterable, Mapping
45 """A part is missing, or a catalog file is malformed."""
48@dataclass(frozen=
True, slots=
True)
50 """One catalogued piece of hardware."""
54 manufacturer: 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.
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."""
69 """Datasheet values with measurements layered over them."""
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).
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.
81 out: dict[str, tuple[Param, Param]] = {}
82 for name, measured
in self.
measured.items():
83 claimed = self.
params.get(name)
86 denominator = abs(claimed.si)
or 1.0
87 if abs(measured.si - claimed.si) / denominator > tolerance:
88 out[name] = (claimed, measured)
92 data: dict[str, Any] = {
"type": self.type}
98 data[
"params"] = {k: v.to_dict()
for k, v
in sorted(self.
params.items())}
101 k: v.to_dict()
for k, v
in sorted(self.
measured.items())
104 data[
"curves"] = {k: v.to_dict()
for k, v
in sorted(self.
curves.items())}
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")
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", {})),
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")
126 name: Curve.from_dict(dict(body), where=f
"{part_id}.curves.{name}")
127 for name, body
in raw.items()
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")
135 name: Param.from_dict(dict(body), where=f
"{part_id}.{block}.{name}")
136 for name, body
in raw.items()
141 """A collection of parts, loadable from one or more TOML files.
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.
148 def __init__(self, parts: Iterable[Part] = ()) ->
None:
149 self.
_parts: dict[str, Part] = {p.id: p
for p
in parts}
155 return part_id
in self.
_parts
157 def add(self, part: Part) -> Part:
158 self.
_parts[part.id] = part
161 def get(self, part_id: str) -> Part:
163 return self.
_parts[part_id]
165 known =
", ".join(sorted(self.
_parts)[:12])
or "(catalog is empty)"
167 f
"no part {part_id!r} in the catalog. Known parts include: {known}"
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))
174 def load(self, path: Path | str) -> list[Part]:
175 """Load a TOML catalog file, replacing any parts it redefines."""
177 with path.open(
"rb")
as handle:
178 raw = tomllib.load(handle)
180 loaded: list[Part] = []
181 for part_id, body
in raw.items():
182 if not isinstance(body, dict):
184 f
"{path}: [{part_id}] must be a table, got {type(body).__name__}"
186 loaded.append(self.
add(Part.from_dict(part_id, body)))
200 overrides: Mapping[str, Param] |
None =
None,
201 curves: Mapping[str, Curve] |
None =
None,
203 connections: Mapping[str, str] |
None =
None,
204 ) -> ComponentInstance:
205 """Turn a part number into a configured component.
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.
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.
217 part = self.
get(part_id)
218 params = part.resolve()
220 params = {**params, **dict(overrides)}
222 resolved_curves = {**part.curves, **dict(curves
or {})}
224 return ComponentInstance.build(
228 curves=resolved_curves,
230 connections=dict(connections
or {}),
235 return {pid: part.to_dict()
for pid, part
in sorted(self.
_parts.items())}
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."""
243 source=Provenance.MEASURED,
245 uncertainty=uncertainty,
bool __contains__(self, object part_id)
Catalog from_file(cls, Path|str path)
None __init__(self, Iterable[Part] parts=())
dict[str, Any] to_dict(self)
Part get(self, str part_id)
Part add(self, Part part)
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)
list[str] parts(self, str|None type_name=None)
list[Part] load(self, Path|str path)
dict[str, Param] resolve(self)
dict[str, Any] to_dict(self)
dict[str, tuple[Param, Param]] disagreements(self, float tolerance=0.0)
Part from_dict(cls, str part_id, Mapping[str, Any] data)
Param measured(float value, str unit, str reference, Any uncertainty=None)
dict[str, Curve] _curves(str part_id, Any raw)
dict[str, Param] _params(str part_id, Any raw, str block)