STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
component.py
Go to the documentation of this file.
1"""A configured component, and the shape an evaluable one takes.
2
3Two things here, at different levels of settledness.
4
5:class:`ComponentInstance` is **settled**: one piece of hardware in a network,
6with its type, its chosen fidelity model, its validated parameters and what its
7ports connect to. It is what a config file deserialises into, what the inspector
8edits, and what Phase 03's components are built from. It carries no physics.
9
10:class:`Component` is the protocol those built objects satisfy, and it is
11**provisional**. Designing a solver interface before there is a solver is
12speculation; it is written down now so Phase 03's components share one shape
13rather than three, and it should be expected to move once Phase 04 assembles a
14real Jacobian against it. That is said here rather than discovered later.
15
16Why residuals rather than pressure drops
17----------------------------------------
18The obvious interface is ``dp(mdot) -> float``. It handles a pipe and then
19fails, in order, at: a check valve (which has a discrete open/closed state), a
20regulator (which has its own poppet dynamics, so its behaviour is an ODE and not
21a function), a cavitating venturi (where the relation inverts once choked), and
22anything with two flow paths.
23
24Contributing *residuals* to a system -- ``f(port values, flows, states,
25derivatives) = 0`` -- covers all of those uniformly, and is the same acausal
26formulation Modelica uses for the same reason. The cost is that a component
27cannot be evaluated alone, only solved; for a network that was always true.
28"""
29
30from __future__ import annotations
31
32from dataclasses import dataclass, field
33from typing import Any, Mapping, Protocol, Sequence, runtime_checkable
34
35from feedtwin.model.curve import Curve
36from feedtwin.model.param import Param, assumed_params, provenance_summary
37from feedtwin.model.segments import LineSegment
38from feedtwin.model.spec import ComponentSpec, SpecError, get_component_spec
39
40
41@dataclass(frozen=True, slots=True)
43 """Thermodynamic state at a connection point.
44
45 Pressure and specific enthalpy, because that pair fixes the state on both
46 sides of the saturation dome -- pressure and temperature do not, and a feed
47 system that never goes two-phase is a feed system that has not been started
48 from cold.
49 """
50
51 p: float
52 """Pressure [Pa]."""
53
54 h: float
55 """Specific enthalpy [J/kg]."""
56
57
58@dataclass(frozen=True, slots=True)
60 """Everything a component needs to evaluate its residuals once.
61
62 Ports and flows are in the order the component's spec declares them, so a
63 component indexes its own ports positionally without looking up names in a
64 hot loop.
65 """
66
67 ports: tuple[PortState, ...]
68 flows: tuple[float, ...]
69 """Mass flow through each of the component's flow paths [kg/s], signed
70 positive from the first port toward the second."""
71
72 states: tuple[float, ...] = ()
73 """Internal dynamic states, in the order the spec declares them."""
74
75 dstates: tuple[float, ...] = ()
76 """Time derivatives of ``states``. All zero in a steady solve, which is how
77 one component definition serves both steady and transient."""
78
79 signals: Mapping[str, float] = field(default_factory=dict)
80 """Control inputs -- a commanded valve position, for instance."""
81
82 t: float = 0.0
83 """Time [s]. Zero and meaningless in a steady solve."""
84
85
86@runtime_checkable
87class Component(Protocol):
88 """An evaluable component. **Provisional** -- see the module docstring.
89
90 Implementations are built from a :class:`ComponentInstance` and hold their
91 parameters as plain floats in canonical units, resolved once at
92 construction. Nothing in a residual evaluation should be looking up a unit.
93 """
94
95 @property
96 def instance(self) -> ComponentInstance:
97 """The configuration this was built from."""
98 ...
99
100 @property
101 def n_residuals(self) -> int:
102 """How many equations this contributes to the system."""
103 ...
104
105 def residuals(self, ctx: EvalContext) -> Sequence[float]:
106 """Residuals, zero when the component's equations are satisfied."""
107 ...
108
109 def outputs(self, ctx: EvalContext) -> Mapping[str, float]:
110 """Derived quantities worth reporting -- velocity, Reynolds number,
111 cavitation index. Never used by the solver; consumed by frames and
112 reports."""
113 ...
114
115
116@dataclass(frozen=True, slots=True)
118 """One configured piece of hardware in a network.
119
120 Args:
121 id: Unique within a network -- ``"SOL-01"``, ``"PR-01"``. These are the
122 tags on the P&ID, deliberately, so a component in a simulation and
123 the same component on the drawing have the same name.
124 spec: Its declaration.
125 model: Which fidelity model to evaluate it at.
126 params: Validated parameters, defaults already filled in.
127 connections: Port name to network node id.
128 part: Catalog part number this was resolved from, if any. Kept for
129 traceability: it is how a report can say which physical valve a
130 result assumed.
131 """
132
133 id: str
134 spec: ComponentSpec
135 model: str
136 params: dict[str, Param]
137 curves: dict[str, Curve] = field(default_factory=dict)
138 options: dict[str, str] = field(default_factory=dict)
139 connections: dict[str, str] = field(default_factory=dict)
140 part: str = ""
141 segments: tuple[LineSegment, ...] = ()
142 """The run itemised, for a line whose drawing broke it into segments.
143
144 Its own field rather than more entries in ``params`` because a segment list
145 is a *sequence of heterogeneous records* -- three elbows in a 10 mm bore
146 then a reducer then 400 mm of 6 mm -- and flattening that into
147 ``length_1``, ``bore_1``, ``fitting_1_kind`` would lose the ordering that
148 makes a bore change a reducer. Empty for everything that is not a segmented
149 line, which is every component the library shipped with.
150 """
151
152 @classmethod
153 def build(
154 cls,
155 id: str,
156 type_name: str,
157 params: dict[str, Param],
158 *,
159 curves: dict[str, Curve] | None = None,
160 options: dict[str, str] | None = None,
161 model: str = "",
162 connections: dict[str, str] | None = None,
163 part: str = "",
164 segments: Sequence[LineSegment] | None = None,
165 ) -> ComponentInstance:
166 """Validate a configuration and produce an instance from it.
167
168 The only supported way to make one: it runs the spec's validation, so an
169 instance that exists is one whose parameters are complete, dimensionally
170 sound and within bounds.
171 """
172 spec = get_component_spec(type_name)
173 chosen = model or spec.models[0]
174 return cls(
175 id=id,
176 spec=spec,
177 model=chosen,
178 params=spec.validate(params, chosen),
179 curves=spec.validate_curves(dict(curves or {}), chosen),
180 options=spec.validate_options(dict(options or {}), chosen),
181 connections=dict(connections or {}),
182 part=part,
183 segments=tuple(segments or ()),
184 )
185
186 @property
187 def type(self) -> str:
188 return self.spec.type
189
190 def option(self, name: str) -> str:
191 """One categorical choice, defaults already resolved."""
192 value = self.options.get(name)
193 if value is None:
194 raise SpecError(
195 f"{self.id} ({self.type}) has no option {name!r}. It holds: "
196 f"{', '.join(sorted(self.options)) or '(none)'}"
197 )
198 return value
199
200 def si(self, name: str) -> float:
201 """One parameter as a plain float in canonical units."""
202 param = self.params.get(name)
203 if param is None:
204 raise SpecError(
205 f"{self.id} ({self.type}) has no value for {name!r}. It holds: "
206 f"{', '.join(sorted(self.params)) or '(none)'}"
207 )
208 return param.si
209
210 def si_params(self) -> dict[str, float]:
211 """Every parameter as plain floats -- what a component resolves once."""
212 return {name: param.si for name, param in self.params.items()}
213
214 def provenance(self) -> dict[str, int]:
215 """How many of this component's parameters came from where."""
216 return provenance_summary(self.params)
217
218 def assumptions(self) -> list[str]:
219 """Parameters here that nobody has established. Worth reporting."""
220 return assumed_params(self.params)
221
222 def unconnected_ports(self) -> list[str]:
223 """Fluid ports with nothing attached.
224
225 Not an error on its own -- a network under construction has plenty --
226 but a dead end at solve time is a singular system, and naming it here is
227 far kinder than a linear-algebra failure later.
228 """
229 return sorted(
230 port.name
231 for port in self.spec.ports
232 if port.kind == "fluid" and port.name not in self.connections
233 )
234
235 # ------------------------------------------------------------ serialisation
236
237 def to_dict(self) -> dict[str, Any]:
238 data: dict[str, Any] = {
239 "id": self.id,
240 "type": self.type,
241 "model": self.model,
242 "params": {name: p.to_dict() for name, p in sorted(self.params.items())},
243 }
244 if self.options:
245 data["options"] = dict(sorted(self.options.items()))
246 if self.curves:
247 data["curves"] = {
248 name: c.to_dict() for name, c in sorted(self.curves.items())
249 }
250 if self.connections:
251 data["connections"] = dict(sorted(self.connections.items()))
252 if self.part:
253 data["part"] = self.part
254 return data
255
256 @classmethod
257 def from_dict(cls, data: Mapping[str, Any]) -> ComponentInstance:
258 missing = [k for k in ("id", "type") if k not in data]
259 if missing:
260 raise SpecError(f"component is missing {', '.join(missing)}: {dict(data)}")
261
262 identifier = str(data["id"])
263 raw = data.get("params", {})
264 if not isinstance(raw, Mapping):
265 raise SpecError(f"{identifier}: 'params' must be a mapping")
266
267 params = {
268 name: Param.from_dict(dict(body), where=f"{identifier}.{name}")
269 for name, body in raw.items()
270 }
271 raw_curves = data.get("curves", {})
272 if not isinstance(raw_curves, Mapping):
273 raise SpecError(f"{identifier}: 'curves' must be a mapping")
274 curves = {
275 name: Curve.from_dict(dict(body), where=f"{identifier}.{name}")
276 for name, body in raw_curves.items()
277 }
278
279 return cls.build(
280 id=identifier,
281 type_name=str(data["type"]),
282 params=params,
283 curves=curves,
284 options={k: str(v) for k, v in dict(data.get("options", {})).items()},
285 model=str(data.get("model", "")),
286 connections={
287 k: str(v) for k, v in dict(data.get("connections", {})).items()
288 },
289 part=str(data.get("part", "")),
290 )
291
292 def __str__(self) -> str:
293 return f"{self.id} ({self.type}/{self.model})"
ComponentInstance build(cls, str id, str type_name, dict[str, Param] params, *, dict[str, Curve]|None curves=None, dict[str, str]|None options=None, str model="", dict[str, str]|None connections=None, str part="", Sequence[LineSegment]|None segments=None)
Definition component.py:165
ComponentInstance from_dict(cls, Mapping[str, Any] data)
Definition component.py:257
ComponentInstance instance(self)
Definition component.py:96
Sequence[float] residuals(self, EvalContext ctx)
Definition component.py:105
Mapping[str, float] outputs(self, EvalContext ctx)
Definition component.py:109