1"""What a component *is*, declared as data.
3A :class:`ComponentSpec` says what a kind of hardware has: which parameters,
4which ports, which internal states, and which fidelity models it can be
5evaluated at. It is the single description that three very different things
8* **validation** -- is this configuration complete, dimensionally sound, and
9 within bounds, before a solver touches it;
10* **the property inspector** (Phase 10) -- the panel that appears when someone
11 clicks a valve is generated from this, so a new component type arrives with a
12 working editor and nobody writes a form;
13* **documentation** -- the parameter table for a component is generated, so it
14 cannot drift from what the code actually accepts.
16Writing those three by hand is how "nothing hardcoded" quietly dies: the physics
17grows a parameter, the UI does not, and the only way to set it becomes editing
22One component, several depths. A regulator can be an ideal setpoint, a
23tabulated droop curve, a dome force balance, or pure measured data -- the same
24hardware at four levels of detail, chosen per instance. Parameters that only one
25model needs declare that in :attr:`ParamSpec.models`, so an ``ideal`` regulator
26is not asked for a poppet mass it will never use.
29from __future__
import annotations
32from dataclasses
import dataclass, field
33from pathlib
import Path
34from typing
import Any, Protocol
42 """A component's configuration does not match its declaration."""
46 """The shape parameters, curves and options share.
48 They are separate types on purpose -- each carries different fields -- but
49 all three are named and all three may be restricted to particular fidelity
50 models, which is the only thing the cross-checks below need.
57 def models(self) -> tuple[str, ...]: ...
60@dataclass(frozen=True, slots=True)
62 """One parameter a component accepts.
65 name: Identifier used in configs and code.
66 dimension: What it measures -- ``"pressure"``, ``"length"``,
67 ``"flow_coefficient"``. Enforced: a value in the wrong kind of unit
68 is rejected at load with both dimensions named.
69 description: What it is, in a sentence. Shown in the inspector, so it is
70 user-facing text and not an implementation note.
71 required: Whether a configuration must supply it.
72 default: Used when it is absent. A spec that is neither required nor
73 defaulted describes an optional parameter with no value, which is
75 minimum, maximum: Bounds in canonical units. A bore of -3 mm should fail
76 at load rather than inside a Reynolds number.
77 models: Fidelity models this parameter applies to. Empty means all of
78 them, which is the common case.
85 default: Param |
None =
None
86 minimum: float |
None =
None
87 maximum: float |
None =
None
88 models: tuple[str, ...] = ()
98@dataclass(frozen=True, slots=True)
100 """A categorical choice a component accepts.
102 Parameters are numbers; some configuration is not. Which friction
103 correlation to use, which Crane fitting a bend is, whether a valve's
104 characteristic is linear or equal-percentage -- all real configuration, none
107 Choices are enumerated, so an invalid one fails at load with the valid set
108 listed, and a UI can render a dropdown from the declaration. This is what
109 makes "the choice of correlation is a parameter like anything else" true
110 rather than aspirational.
114 choices: tuple[str, ...]
117 models: tuple[str, ...] = ()
121 raise SpecError(f
"option {self.name!r} declares no choices")
122 if self.default
not in self.
choices:
124 f
"option {self.name!r}: default {self.default!r} is not among "
125 f
"its choices {list(self.choices)}"
132@dataclass(frozen=True, slots=True)
134 """A connection point.
136 ``kind`` separates a fluid connection from a control signal: a solenoid's
137 two fluid ports carry mass, its command port carries a number from a
138 timeline, and the network solver must not confuse them.
146@dataclass(frozen=True, slots=True)
148 """An internal dynamic state the component owns.
150 Empty for most components -- a length of pipe has no memory. A regulator's
151 poppet position and a tank's gas mass are states, and declaring them here is
152 what lets the transient solver in Phase 07 assemble its state vector from
153 the network rather than from a hardcoded list.
159 initial: Param |
None =
None
162@dataclass(frozen=True, slots=True)
164 """The complete declaration of one kind of component."""
168 params: tuple[ParamSpec, ...] = ()
169 ports: tuple[PortSpec, ...] = ()
170 states: tuple[StateSpec, ...] = ()
171 curves: tuple[CurveSpec, ...] = ()
172 options: tuple[OptionSpec, ...] = ()
173 models: tuple[str, ...] = (
"default",)
174 _by_name: dict[str, ParamSpec] = field(default_factory=dict, repr=
False)
178 raise SpecError(f
"{self.type}: needs at least one fidelity model")
182 raise SpecError(f
"{self.type}: duplicate parameters {duplicates}")
186 unknown = set(spec.models) - set(self.
models)
189 f
"{self.type}.{spec.name}: refers to unknown model(s) "
190 f
"{sorted(unknown)}; declared models are {list(self.models)}"
194 object.__setattr__(self,
"_by_name", {p.name: p
for p
in self.
params})
196 def param(self, name: str) -> ParamSpec:
201 f
"{self.type} has no parameter {name!r}; it accepts: "
202 f
"{', '.join(sorted(self._by_name))}"
206 """Parameters that apply to one fidelity model."""
207 return tuple(p
for p
in self.
params if p.applies_to(model))
209 def validate(self, given: dict[str, Param], model: str =
"") -> dict[str, Param]:
210 """Check a configuration and return it with defaults filled in.
212 Every check that can be made without solving anything is made here, and
213 all failures are reported together rather than one per run -- fixing a
214 config six errors at a time beats six edit-run cycles.
217 SpecError: unknown or missing parameters, wrong dimensions, or
218 values outside declared bounds.
220 chosen = model
or self.
models[0]
221 if chosen
not in self.
models:
223 f
"{self.type}: unknown model {chosen!r}; available: "
224 f
"{', '.join(self.models)}"
227 problems: list[str] = []
228 resolved: dict[str, Param] = {}
230 for name
in sorted(set(given) - set(self.
_by_name)):
232 f
" {name!r} is not a parameter of {self.type} "
233 f
"(accepts: {', '.join(sorted(self._by_name))})"
237 if not spec.applies_to(chosen):
243 param = given.get(spec.name)
245 if spec.default
is not None:
246 resolved[spec.name] = spec.default
249 f
" {spec.name!r} is required by {self.type}"
250 f
"{f' (model {chosen})' if spec.models else ''}"
251 f
" and was not given -- {spec.description}"
257 param.unit, spec.dimension, f
" {self.type}.{spec.name}"
259 except ValueError
as exc:
260 problems.append(str(exc))
265 problems.append(problem)
268 resolved[spec.name] = param
272 f
"{self.type} is not configured correctly:\n" +
"\n".join(problems)
277 self, given: dict[str, Curve], model: str =
""
278 ) -> dict[str, Curve]:
279 """Check tabulated data the same way scalars are checked.
281 Same three failures as :meth:`validate`: unknown name, missing when
282 required, wrong dimension on either axis. A Cv-versus-position curve
283 whose x axis is a pressure is exactly as wrong as a scalar bore in psi,
284 and should fail in the same place.
286 chosen = model
or self.
models[0]
287 by_name = {c.name: c
for c
in self.
curves}
288 problems: list[str] = []
289 resolved: dict[str, Curve] = {}
291 for name
in sorted(set(given) - set(by_name)):
292 accepted =
", ".join(sorted(by_name))
or "(none)"
294 f
" {name!r} is not a curve of {self.type} (accepts: {accepted})"
298 if not spec.applies_to(chosen):
300 curve = given.get(spec.name)
304 f
" curve {spec.name!r} is required by {self.type} "
305 f
"(model {chosen}) -- {spec.description}"
309 curve.check_dimensions(
310 spec.x_dimension, spec.y_dimension, f
" {self.type}.{spec.name}"
312 except ValueError
as exc:
313 problems.append(str(exc))
315 resolved[spec.name] = curve
319 f
"{self.type} curves are not configured correctly:\n"
320 +
"\n".join(problems)
325 self, given: dict[str, str], model: str =
""
327 """Check categorical choices and fill in defaults."""
328 chosen = model
or self.
models[0]
329 by_name = {o.name: o
for o
in self.
options}
330 problems: list[str] = []
331 resolved: dict[str, str] = {}
333 for name
in sorted(set(given) - set(by_name)):
334 accepted =
", ".join(sorted(by_name))
or "(none)"
336 f
" {name!r} is not an option of {self.type} (accepts: {accepted})"
340 if not spec.applies_to(chosen):
342 value = given.get(spec.name, spec.default)
343 if value
not in spec.choices:
345 f
" {self.type}.{spec.name} = {value!r} is not valid; "
346 f
"choose one of: {', '.join(spec.choices)}"
349 resolved[spec.name] = value
353 f
"{self.type} options are not configured correctly:\n"
354 +
"\n".join(problems)
358 def defaults(self, model: str =
"") -> dict[str, Param]:
359 """Every defaulted parameter for a model. What a new instance starts as."""
360 chosen = model
or self.
models[0]
362 spec.name: spec.default
364 if spec.default
is not None
368 """A plain-data description -- what the Phase 10 inspector consumes."""
372 "models": list(self.
models),
374 {
"name": p.name,
"kind": p.kind,
"description": p.description}
378 {
"name": s.name,
"dimension": s.dimension,
"description": s.description}
384 "choices": list(o.choices),
385 "default": o.default,
386 "description": o.description,
387 "models": list(o.models),
394 "x_dimension": c.x_dimension,
395 "y_dimension": c.y_dimension,
396 "description": c.description,
397 "required": c.required,
398 "models": list(c.models),
405 "dimension": p.dimension,
406 "si_unit": p.si_unit,
407 "description": p.description,
408 "required": p.required,
409 "models": list(p.models),
410 "minimum": p.minimum,
411 "maximum": p.maximum,
412 "default": p.default.to_dict()
if p.default
else None,
422 if spec.minimum
is not None and value < spec.minimum:
424 f
" {type_name}.{spec.name} = {value:g} {unit} is below the minimum "
425 f
"{spec.minimum:g} {unit}"
427 if spec.maximum
is not None and value > spec.maximum:
429 f
" {type_name}.{spec.name} = {value:g} {unit} is above the maximum "
430 f
"{spec.maximum:g} {unit}"
436 seen: set[str] = set()
437 dupes: set[str] = set()
439 (dupes
if name
in seen
else seen).add(name)
443_SPECS: dict[str, ComponentSpec] = {}
447 """Register a component type. Re-registering a name replaces it.
449 Phase 03 is expected to re-register the shipped declarations as it
450 implements their physics and learns which parameters each model really
451 needs; replacing by name is how that happens without a migration.
453 _SPECS[spec.type] = spec
460 return _SPECS[type_name]
463 f
"unknown component type {type_name!r}; registered: "
464 f
"{', '.join(sorted(_SPECS)) or '(none)'}. Add one with "
465 "feedtwin.model.register_component_spec()."
471 return sorted(_SPECS)
474_SPECS_FILE = Path(__file__).with_name(
"components.toml")
479 """Read the shipped component declarations, once.
481 An explicit flag rather than "is the registry empty", for the reason
482 :mod:`feedtwin.props.species` learned the hard way: a program whose first
483 call registers a component would make the registry non-empty, and every
484 shipped declaration would then be silently missing for the rest of the run.
494 """Load component declarations from a TOML file and register them.
496 A component's *schema* is data -- what parameters it takes, what they mean,
497 what units they are in, what its ports are. Only its *physics* is code. So
498 adding a component type is a data edit, and Phase 03 supplies behaviour
499 keyed by type name rather than a parallel set of declarations that can drift
502 Public, so a project can declare its own components without editing this
503 package; later definitions replace earlier ones by type name.
505 with path.open(
"rb")
as handle:
506 raw = tomllib.load(handle)
508 loaded: list[ComponentSpec] = []
509 for type_name, body
in raw.items():
510 if not isinstance(body, dict):
512 f
"{path}: [{type_name}] must be a table, got {type(body).__name__}"
519 models = tuple(str(m)
for m
in body.get(
"models", [
"default"]))
525 _table(type_name,
"ports", name, spec).get(
"description",
"")
527 kind=str(
_table(type_name,
"ports", name, spec).get(
"kind",
"fluid")),
529 for name, spec
in dict(body.get(
"ports", {})).items()
535 dimension=str(
_require(type_name,
"states", name, spec,
"dimension")),
536 description=str(spec.get(
"description",
"")),
537 initial=
_maybe_param(spec.get(
"initial"), f
"{type_name}.states.{name}"),
539 for name, spec
in dict(body.get(
"states", {})).items()
545 dimension=str(
_require(type_name,
"params", name, spec,
"dimension")),
546 description=str(spec.get(
"description",
"")),
547 required=bool(spec.get(
"required",
True)),
548 default=
_maybe_param(spec.get(
"default"), f
"{type_name}.params.{name}"),
551 models=tuple(str(m)
for m
in spec.get(
"models", [])),
553 for name, spec
in dict(body.get(
"params", {})).items()
559 x_dimension=str(
_require(type_name,
"curves", name, spec,
"x_dimension")),
560 y_dimension=str(
_require(type_name,
"curves", name, spec,
"y_dimension")),
561 description=str(spec.get(
"description",
"")),
562 required=bool(spec.get(
"required",
False)),
563 models=tuple(str(m)
for m
in spec.get(
"models", [])),
565 for name, spec
in dict(body.get(
"curves", {})).items()
572 str(c)
for c
in _require(type_name,
"options", name, spec,
"choices")
574 default=str(
_require(type_name,
"options", name, spec,
"default")),
575 description=str(spec.get(
"description",
"")),
576 models=tuple(str(m)
for m
in spec.get(
"models", [])),
578 for name, spec
in dict(body.get(
"options", {})).items()
583 description=str(body.get(
"description",
"")),
593def _table(type_name: str, block: str, name: str, value: Any) -> dict[str, Any]:
594 if not isinstance(value, dict):
595 raise SpecError(f
"{type_name}.{block}.{name} must be a table")
599def _require(type_name: str, block: str, name: str, value: Any, key: str) -> Any:
600 table =
_table(type_name, block, name, value)
602 raise SpecError(f
"{type_name}.{block}.{name} is missing {key!r}")
607 return None if value
is None else Param.from_dict(dict(value), where=where)
611 return None if value
is None else float(value)
615 """A library-supplied default, tagged as such.
617 A shorthand worth having: every default in a component declaration should
618 carry :attr:`Provenance.DEFAULT` and say where the number came from, and
619 making that a one-liner is what stops it being skipped.
621 return Param(value=value, unit=unit, source=Provenance.DEFAULT, reference=reference)
dict[str, Param] validate(self, dict[str, Param] given, str model="")
dict[str, Curve] validate_curves(self, dict[str, Curve] given, str model="")
dict[str, Param] defaults(self, str model="")
dict[str, Any] to_dict(self)
dict[str, str] validate_options(self, dict[str, str] given, str model="")
tuple[ParamSpec,...] params_for(self, str model)
bool applies_to(self, str model)
bool applies_to(self, str model)
tuple[str,...] models(self)
Param|None _maybe_param(Any value, str where)
dict[str, Any] _table(str type_name, str block, str name, Any value)
float|None _maybe_float(Any value)
list[ComponentSpec] load_component_specs(Path path)
ComponentSpec register_component_spec(ComponentSpec spec)
ComponentSpec get_component_spec(str type_name)
Any _require(str type_name, str block, str name, Any value, str key)
list[str] registered_component_types()
Param default_param(float value, str unit, str reference)
str|None _bounds_problem(ParamSpec spec, Param param, str type_name)
list[str] _duplicates(list[str] names)
ComponentSpec _spec_from_toml(str type_name, dict[str, Any] body)