1"""A configured component, and the shape an evaluable one takes.
3Two things here, at different levels of settledness.
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.
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.
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.
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.
30from __future__
import annotations
32from dataclasses
import dataclass, field
33from typing
import Any, Mapping, Protocol, Sequence, runtime_checkable
41@dataclass(frozen=True, slots=True)
43 """Thermodynamic state at a connection point.
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
55 """Specific enthalpy [J/kg]."""
58@dataclass(frozen=True, slots=True)
60 """Everything a component needs to evaluate its residuals once.
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
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."""
72 states: tuple[float, ...] = ()
73 """Internal dynamic states, in the order the spec declares them."""
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."""
79 signals: Mapping[str, float] = field(default_factory=dict)
80 """Control inputs -- a commanded valve position, for instance."""
83 """Time [s]. Zero and meaningless in a steady solve."""
88 """An evaluable component. **Provisional** -- see the module docstring.
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.
97 """The configuration this was built from."""
102 """How many equations this contributes to the system."""
105 def residuals(self, ctx: EvalContext) -> Sequence[float]:
106 """Residuals, zero when the component's equations are satisfied."""
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
116@dataclass(frozen=True, slots=True)
118 """One configured piece of hardware in a network.
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
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)
141 segments: tuple[LineSegment, ...] = ()
142 """The run itemised, for a line whose drawing broke it into segments.
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.
157 params: dict[str, Param],
159 curves: dict[str, Curve] |
None =
None,
160 options: dict[str, str] |
None =
None,
162 connections: dict[str, str] |
None =
None,
164 segments: Sequence[LineSegment] |
None =
None,
165 ) -> ComponentInstance:
166 """Validate a configuration and produce an instance from it.
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.
172 spec = get_component_spec(type_name)
173 chosen = model
or spec.models[0]
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 {}),
183 segments=tuple(segments
or ()),
188 return self.spec.type
191 """One categorical choice, defaults already resolved."""
195 f
"{self.id} ({self.type}) has no option {name!r}. It holds: "
196 f
"{', '.join(sorted(self.options)) or '(none)'}"
200 def si(self, name: str) -> float:
201 """One parameter as a plain float in canonical units."""
202 param = self.
params.get(name)
205 f
"{self.id} ({self.type}) has no value for {name!r}. It holds: "
206 f
"{', '.join(sorted(self.params)) or '(none)'}"
211 """Every parameter as plain floats -- what a component resolves once."""
212 return {name: param.si
for name, param
in self.
params.items()}
215 """How many of this component's parameters came from where."""
216 return provenance_summary(self.
params)
219 """Parameters here that nobody has established. Worth reporting."""
220 return assumed_params(self.
params)
223 """Fluid ports with nothing attached.
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.
231 for port
in self.spec.ports
232 if port.kind ==
"fluid" and port.name
not in self.
connections
238 data: dict[str, Any] = {
242 "params": {name: p.to_dict()
for name, p
in sorted(self.
params.items())},
245 data[
"options"] = dict(sorted(self.
options.items()))
248 name: c.to_dict()
for name, c
in sorted(self.
curves.items())
251 data[
"connections"] = dict(sorted(self.
connections.items()))
253 data[
"part"] = self.
part
257 def from_dict(cls, data: Mapping[str, Any]) -> ComponentInstance:
258 missing = [k
for k
in (
"id",
"type")
if k
not in data]
260 raise SpecError(f
"component is missing {', '.join(missing)}: {dict(data)}")
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")
268 name: Param.from_dict(dict(body), where=f
"{identifier}.{name}")
269 for name, body
in raw.items()
271 raw_curves = data.get(
"curves", {})
272 if not isinstance(raw_curves, Mapping):
273 raise SpecError(f
"{identifier}: 'curves' must be a mapping")
275 name: Curve.from_dict(dict(body), where=f
"{identifier}.{name}")
276 for name, body
in raw_curves.items()
281 type_name=str(data[
"type"]),
284 options={k: str(v)
for k, v
in dict(data.get(
"options", {})).items()},
285 model=str(data.get(
"model",
"")),
287 k: str(v)
for k, v
in dict(data.get(
"connections", {})).items()
289 part=str(data.get(
"part",
"")),
293 return f
"{self.id} ({self.type}/{self.model})"
dict[str, Any] to_dict(self)
dict[str, int] provenance(self)
list[str] unconnected_ports(self)
str option(self, str name)
list[str] assumptions(self)
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)
dict[str, float] si_params(self)
ComponentInstance from_dict(cls, Mapping[str, Any] data)
ComponentInstance instance(self)
Sequence[float] residuals(self, EvalContext ctx)
Mapping[str, float] outputs(self, EvalContext ctx)