STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
spec.py
Go to the documentation of this file.
1"""What a component *is*, declared as data.
2
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
6read:
7
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.
15
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
18YAML by hand.
19
20Fidelity models
21---------------
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.
27"""
28
29from __future__ import annotations
30
31import tomllib
32from dataclasses import dataclass, field
33from pathlib import Path
34from typing import Any, Protocol
35
36from feedtwin.model.curve import Curve, CurveSpec
37from feedtwin.model.param import Param, Provenance
38from feedtwin.model.units import check_dimension, si_unit_of
39
40
41class SpecError(ValueError):
42 """A component's configuration does not match its declaration."""
43
44
45class _ModelScoped(Protocol):
46 """The shape parameters, curves and options share.
47
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.
51 """
52
53 @property
54 def name(self) -> str: ...
55
56 @property
57 def models(self) -> tuple[str, ...]: ...
58
59
60@dataclass(frozen=True, slots=True)
62 """One parameter a component accepts.
63
64 Args:
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
74 usually a mistake.
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.
79 """
80
81 name: str
82 dimension: str
83 description: str
84 required: bool = True
85 default: Param | None = None
86 minimum: float | None = None
87 maximum: float | None = None
88 models: tuple[str, ...] = ()
89
90 def applies_to(self, model: str) -> bool:
91 return not self.models or model in self.models
92
93 @property
94 def si_unit(self) -> str:
95 return si_unit_of(self.dimension)
96
97
98@dataclass(frozen=True, slots=True)
100 """A categorical choice a component accepts.
101
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
105 of it a float.
106
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.
111 """
112
113 name: str
114 choices: tuple[str, ...]
115 default: str
116 description: str
117 models: tuple[str, ...] = ()
118
119 def __post_init__(self) -> None:
120 if not self.choices:
121 raise SpecError(f"option {self.name!r} declares no choices")
122 if self.default not in self.choices:
123 raise SpecError(
124 f"option {self.name!r}: default {self.default!r} is not among "
125 f"its choices {list(self.choices)}"
126 )
127
128 def applies_to(self, model: str) -> bool:
129 return not self.models or model in self.models
130
131
132@dataclass(frozen=True, slots=True)
134 """A connection point.
135
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.
139 """
140
141 name: str
142 description: str
143 kind: str = "fluid"
144
145
146@dataclass(frozen=True, slots=True)
148 """An internal dynamic state the component owns.
149
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.
154 """
155
156 name: str
157 dimension: str
158 description: str
159 initial: Param | None = None
160
161
162@dataclass(frozen=True, slots=True)
164 """The complete declaration of one kind of component."""
165
166 type: str
167 description: str
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)
175
176 def __post_init__(self) -> None:
177 if not self.models:
178 raise SpecError(f"{self.type}: needs at least one fidelity model")
179
180 duplicates = _duplicates([p.name for p in self.params])
181 if duplicates:
182 raise SpecError(f"{self.type}: duplicate parameters {duplicates}")
183
184 scoped: tuple[_ModelScoped, ...] = (*self.params, *self.curves, *self.options)
185 for spec in scoped:
186 unknown = set(spec.models) - set(self.models)
187 if unknown:
188 raise SpecError(
189 f"{self.type}.{spec.name}: refers to unknown model(s) "
190 f"{sorted(unknown)}; declared models are {list(self.models)}"
191 )
192 # Cache the lookup once; a frozen dataclass has to go through
193 # object.__setattr__ to do it.
194 object.__setattr__(self, "_by_name", {p.name: p for p in self.params})
195
196 def param(self, name: str) -> ParamSpec:
197 try:
198 return self._by_name[name]
199 except KeyError:
200 raise SpecError(
201 f"{self.type} has no parameter {name!r}; it accepts: "
202 f"{', '.join(sorted(self._by_name))}"
203 ) from None
204
205 def params_for(self, model: str) -> tuple[ParamSpec, ...]:
206 """Parameters that apply to one fidelity model."""
207 return tuple(p for p in self.params if p.applies_to(model))
208
209 def validate(self, given: dict[str, Param], model: str = "") -> dict[str, Param]:
210 """Check a configuration and return it with defaults filled in.
211
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.
215
216 Raises:
217 SpecError: unknown or missing parameters, wrong dimensions, or
218 values outside declared bounds.
219 """
220 chosen = model or self.models[0]
221 if chosen not in self.models:
222 raise SpecError(
223 f"{self.type}: unknown model {chosen!r}; available: "
224 f"{', '.join(self.models)}"
225 )
226
227 problems: list[str] = []
228 resolved: dict[str, Param] = {}
229
230 for name in sorted(set(given) - set(self._by_name)):
231 problems.append(
232 f" {name!r} is not a parameter of {self.type} "
233 f"(accepts: {', '.join(sorted(self._by_name))})"
234 )
235
236 for spec in self.params:
237 if not spec.applies_to(chosen):
238 # Supplying it is harmless -- switching models should not force
239 # deleting values -- but it is not resolved, so a model can
240 # never silently read a parameter another model owns.
241 continue
242
243 param = given.get(spec.name)
244 if param is None:
245 if spec.default is not None:
246 resolved[spec.name] = spec.default
247 elif spec.required:
248 problems.append(
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}"
252 )
253 continue
254
255 try:
256 check_dimension(
257 param.unit, spec.dimension, f" {self.type}.{spec.name}"
258 )
259 except ValueError as exc:
260 problems.append(str(exc))
261 continue
262
263 problem = _bounds_problem(spec, param, self.type)
264 if problem:
265 problems.append(problem)
266 continue
267
268 resolved[spec.name] = param
269
270 if problems:
271 raise SpecError(
272 f"{self.type} is not configured correctly:\n" + "\n".join(problems)
273 )
274 return resolved
275
277 self, given: dict[str, Curve], model: str = ""
278 ) -> dict[str, Curve]:
279 """Check tabulated data the same way scalars are checked.
280
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.
285 """
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] = {}
290
291 for name in sorted(set(given) - set(by_name)):
292 accepted = ", ".join(sorted(by_name)) or "(none)"
293 problems.append(
294 f" {name!r} is not a curve of {self.type} (accepts: {accepted})"
295 )
296
297 for spec in self.curves:
298 if not spec.applies_to(chosen):
299 continue
300 curve = given.get(spec.name)
301 if curve is None:
302 if spec.required:
303 problems.append(
304 f" curve {spec.name!r} is required by {self.type} "
305 f"(model {chosen}) -- {spec.description}"
306 )
307 continue
308 try:
309 curve.check_dimensions(
310 spec.x_dimension, spec.y_dimension, f" {self.type}.{spec.name}"
311 )
312 except ValueError as exc:
313 problems.append(str(exc))
314 continue
315 resolved[spec.name] = curve
316
317 if problems:
318 raise SpecError(
319 f"{self.type} curves are not configured correctly:\n"
320 + "\n".join(problems)
321 )
322 return resolved
323
325 self, given: dict[str, str], model: str = ""
326 ) -> dict[str, 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] = {}
332
333 for name in sorted(set(given) - set(by_name)):
334 accepted = ", ".join(sorted(by_name)) or "(none)"
335 problems.append(
336 f" {name!r} is not an option of {self.type} (accepts: {accepted})"
337 )
338
339 for spec in self.options:
340 if not spec.applies_to(chosen):
341 continue
342 value = given.get(spec.name, spec.default)
343 if value not in spec.choices:
344 problems.append(
345 f" {self.type}.{spec.name} = {value!r} is not valid; "
346 f"choose one of: {', '.join(spec.choices)}"
347 )
348 continue
349 resolved[spec.name] = value
350
351 if problems:
352 raise SpecError(
353 f"{self.type} options are not configured correctly:\n"
354 + "\n".join(problems)
355 )
356 return resolved
357
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]
361 return {
362 spec.name: spec.default
363 for spec in self.params_for(chosen)
364 if spec.default is not None
365 }
366
367 def to_dict(self) -> dict[str, Any]:
368 """A plain-data description -- what the Phase 10 inspector consumes."""
369 return {
370 "type": self.type,
371 "description": self.description,
372 "models": list(self.models),
373 "ports": [
374 {"name": p.name, "kind": p.kind, "description": p.description}
375 for p in self.ports
376 ],
377 "states": [
378 {"name": s.name, "dimension": s.dimension, "description": s.description}
379 for s in self.states
380 ],
381 "options": [
382 {
383 "name": o.name,
384 "choices": list(o.choices),
385 "default": o.default,
386 "description": o.description,
387 "models": list(o.models),
388 }
389 for o in self.options
390 ],
391 "curves": [
392 {
393 "name": c.name,
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),
399 }
400 for c in self.curves
401 ],
402 "params": [
403 {
404 "name": p.name,
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,
413 }
414 for p in self.params
415 ],
416 }
417
418
419def _bounds_problem(spec: ParamSpec, param: Param, type_name: str) -> str | None:
420 value = param.si
421 unit = spec.si_unit
422 if spec.minimum is not None and value < spec.minimum:
423 return (
424 f" {type_name}.{spec.name} = {value:g} {unit} is below the minimum "
425 f"{spec.minimum:g} {unit}"
426 )
427 if spec.maximum is not None and value > spec.maximum:
428 return (
429 f" {type_name}.{spec.name} = {value:g} {unit} is above the maximum "
430 f"{spec.maximum:g} {unit}"
431 )
432 return None
433
434
435def _duplicates(names: list[str]) -> list[str]:
436 seen: set[str] = set()
437 dupes: set[str] = set()
438 for name in names:
439 (dupes if name in seen else seen).add(name)
440 return sorted(dupes)
441
442
443_SPECS: dict[str, ComponentSpec] = {}
444
445
446def register_component_spec(spec: ComponentSpec) -> ComponentSpec:
447 """Register a component type. Re-registering a name replaces it.
448
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.
452 """
453 _SPECS[spec.type] = spec
454 return spec
455
456
457def get_component_spec(type_name: str) -> ComponentSpec:
459 try:
460 return _SPECS[type_name]
461 except KeyError:
462 raise SpecError(
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()."
466 ) from None
467
468
469def registered_component_types() -> list[str]:
471 return sorted(_SPECS)
472
473
474_SPECS_FILE = Path(__file__).with_name("components.toml")
475_LOADED = False
476
477
478def _ensure_loaded() -> None:
479 """Read the shipped component declarations, once.
480
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.
485 """
486 global _LOADED
487 if _LOADED:
488 return
489 _LOADED = True
490 load_component_specs(_SPECS_FILE)
491
492
493def load_component_specs(path: Path) -> list[ComponentSpec]:
494 """Load component declarations from a TOML file and register them.
495
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
500 from it.
501
502 Public, so a project can declare its own components without editing this
503 package; later definitions replace earlier ones by type name.
504 """
505 with path.open("rb") as handle:
506 raw = tomllib.load(handle)
507
508 loaded: list[ComponentSpec] = []
509 for type_name, body in raw.items():
510 if not isinstance(body, dict):
511 raise SpecError(
512 f"{path}: [{type_name}] must be a table, got {type(body).__name__}"
513 )
514 loaded.append(register_component_spec(_spec_from_toml(type_name, body)))
515 return loaded
516
517
518def _spec_from_toml(type_name: str, body: dict[str, Any]) -> ComponentSpec:
519 models = tuple(str(m) for m in body.get("models", ["default"]))
520
521 ports = tuple(
522 PortSpec(
523 name=name,
524 description=str(
525 _table(type_name, "ports", name, spec).get("description", "")
526 ),
527 kind=str(_table(type_name, "ports", name, spec).get("kind", "fluid")),
528 )
529 for name, spec in dict(body.get("ports", {})).items()
530 )
531
532 states = tuple(
533 StateSpec(
534 name=name,
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}"),
538 )
539 for name, spec in dict(body.get("states", {})).items()
540 )
541
542 params = tuple(
543 ParamSpec(
544 name=name,
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}"),
549 minimum=_maybe_float(spec.get("minimum")),
550 maximum=_maybe_float(spec.get("maximum")),
551 models=tuple(str(m) for m in spec.get("models", [])),
552 )
553 for name, spec in dict(body.get("params", {})).items()
554 )
555
556 curves = tuple(
557 CurveSpec(
558 name=name,
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", [])),
564 )
565 for name, spec in dict(body.get("curves", {})).items()
566 )
567
568 options = tuple(
570 name=name,
571 choices=tuple(
572 str(c) for c in _require(type_name, "options", name, spec, "choices")
573 ),
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", [])),
577 )
578 for name, spec in dict(body.get("options", {})).items()
579 )
580
581 return ComponentSpec(
582 type=type_name,
583 description=str(body.get("description", "")),
584 params=params,
585 ports=ports,
586 states=states,
587 curves=curves,
588 options=options,
589 models=models,
590 )
591
592
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")
596 return value
597
598
599def _require(type_name: str, block: str, name: str, value: Any, key: str) -> Any:
600 table = _table(type_name, block, name, value)
601 if key not in table:
602 raise SpecError(f"{type_name}.{block}.{name} is missing {key!r}")
603 return table[key]
604
605
606def _maybe_param(value: Any, where: str) -> Param | None:
607 return None if value is None else Param.from_dict(dict(value), where=where)
608
609
610def _maybe_float(value: Any) -> float | None:
611 return None if value is None else float(value)
612
613
614def default_param(value: float, unit: str, reference: str) -> Param:
615 """A library-supplied default, tagged as such.
616
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.
620 """
621 return Param(value=value, unit=unit, source=Provenance.DEFAULT, reference=reference)
dict[str, Param] validate(self, dict[str, Param] given, str model="")
Definition spec.py:209
dict[str, Curve] validate_curves(self, dict[str, Curve] given, str model="")
Definition spec.py:278
dict[str, Param] defaults(self, str model="")
Definition spec.py:358
dict[str, Any] to_dict(self)
Definition spec.py:367
dict[str, str] validate_options(self, dict[str, str] given, str model="")
Definition spec.py:326
tuple[ParamSpec,...] params_for(self, str model)
Definition spec.py:205
bool applies_to(self, str model)
Definition spec.py:128
bool applies_to(self, str model)
Definition spec.py:90
tuple[str,...] models(self)
Definition spec.py:57
Param|None _maybe_param(Any value, str where)
Definition spec.py:606
dict[str, Any] _table(str type_name, str block, str name, Any value)
Definition spec.py:593
None _ensure_loaded()
Definition spec.py:478
float|None _maybe_float(Any value)
Definition spec.py:610
list[ComponentSpec] load_component_specs(Path path)
Definition spec.py:493
ComponentSpec register_component_spec(ComponentSpec spec)
Definition spec.py:446
ComponentSpec get_component_spec(str type_name)
Definition spec.py:457
Any _require(str type_name, str block, str name, Any value, str key)
Definition spec.py:599
list[str] registered_component_types()
Definition spec.py:469
Param default_param(float value, str unit, str reference)
Definition spec.py:614
str|None _bounds_problem(ParamSpec spec, Param param, str type_name)
Definition spec.py:419
list[str] _duplicates(list[str] names)
Definition spec.py:435
ComponentSpec _spec_from_toml(str type_name, dict[str, Any] body)
Definition spec.py:518