STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
base.py
Go to the documentation of this file.
1"""What every hydraulic component is, and how one gets built.
2
3Each component exposes its physics **twice**, and the split is deliberate.
4
5``pressure_drop(mdot, flow)`` is the causal form: given a mass flow and the
6fluid's state, what is the loss. Most liquid plumbing genuinely is this -- a
7pipe's Δp is a function of ṁ -- and it is the form that can be checked against a
8worked example in a book without a solver in the way. Every validation test in
9this phase calls it.
10
11``residuals(ctx)`` is the acausal form the network solver will consume, and it
12is derived from the first for simple components. Components whose behaviour is
13*not* a function -- a check valve with a discrete open state, a regulator whose
14poppet has its own dynamics -- override it.
15
16The protocol is still provisional (see :mod:`feedtwin.model.component`). Phase
1704 assembles a real Jacobian against it and is expected to reshape it. Keeping
18the physics in ``pressure_drop`` is what makes that cheap: when the solver
19interface moves, this module's ``residuals`` adapters move and no correlation
20is touched.
21"""
22
23from __future__ import annotations
24
25from dataclasses import dataclass, field
26from typing import Callable, Mapping, Sequence
27
28from feedtwin.model.component import ComponentInstance, EvalContext, PortState
29from feedtwin.model.pressure import say
30from feedtwin.model.spec import SpecError
31
32R_UNIVERSAL = 8.31446261815324
33"""Universal gas constant [J/(mol.K)], CODATA 2018.
34
35Lives here rather than in :mod:`feedtwin.comps.gas`, where it was, because
36:mod:`feedtwin.comps.elements` needs it and ``gas`` imports ``elements`` -- so
37reaching it from there meant a function-level import, executed on **every**
38call to ``conditions_from_fluid``. That is 1.6 million import statements in a
39two-second burn, which Python resolves quickly but not freely. ``base`` is
40below both of them and imports neither, so from here it is a plain module-level
41name for everybody. Re-exported from ``gas`` for anyone who imported it there.
42"""
43
44
45class InfeasibleOperatingPoint(ValueError):
46 """The requested flow cannot pass at the given upstream pressure.
47
48 A real physical answer, not a numerical accident: a correlation that has to
49 solve for a downstream pressure will fail to bracket when the only solution
50 is negative. Left untranslated it surfaces from inside ``fluids`` as a
51 bracketing error several frames down, which tells a solver author nothing.
52 Phase 04 will want to catch this and back off a step rather than abort.
53 """
54
55 def __init__(self, component: str, mdot: float, p_upstream: float) -> None:
56 super().__init__(
57 f"{component}: {mdot:.6g} kg/s cannot pass with only "
58 f"{say(p_upstream, 1)} upstream -- the required downstream "
59 "pressure is below zero. Either the flow is too high, the "
60 "restriction too small, or the upstream pressure too low."
61 )
62 self.mdot = mdot
63 self.p_upstream = p_upstream
64
65
66@dataclass(frozen=True, slots=True)
68 """The fluid state a component needs to evaluate its loss.
69
70 Passed in rather than looked up: a component should not own a
71 :class:`~feedtwin.props.Fluid`, because the same length of tube can carry
72 LOX on one run and water on the next, and because it keeps the physics
73 testable with two floats instead of an equation of state.
74 """
75
76 rho: float
77 """Density [kg/m^3]."""
78
79 mu: float
80 """Dynamic viscosity [Pa.s]."""
81
82 p_upstream: float
83 """Static pressure at the inlet [Pa]. Needed wherever choking or cavitation
84 depends on absolute level, not just on the drop."""
85
86 p_sat: float = 0.0
87 """Saturation pressure at the inlet temperature [Pa]. Zero disables the
88 cavitation and choking checks, which is right for a gas and wrong for a
89 cryogen -- pass the real value."""
90
91 p_crit: float = 0.0
92 """Thermodynamic critical pressure [Pa]. Required by the IEC 60534 choked
93 liquid flow correlation."""
94
95 temperature: float = 0.0
96 """Static temperature at the inlet [K]. Zero means unknown, which the gas
97 components fall back from; the liquid ones never look."""
98
99 gamma: float = 0.0
100 """Heat capacity ratio cp/cv. Needed for choking; zero means unknown."""
101
102 r_specific: float = 0.0
103 """Specific gas constant R/M [J/(kg.K)]. Needed for choked mass flow."""
104
105 signals: Mapping[str, float] = field(default_factory=dict)
106 """Control inputs -- ``{"command": 0.4}`` for a part-open valve."""
107
108 gravity: float = 9.80665
109 """Acceleration the liquid column feels along the line's elevation axis
110 [m/s^2]: standard gravity on a stand, the vehicle's proper acceleration
111 (thrust less drag, over mass) in flight. Static head is ``rho * this * dz``.
112 The default is :data:`feedtwin.vessels.volume.GRAVITY` exactly, so nothing
113 that does not set it changes."""
114
115 gamma_ideal: float = 0.0
116 """Ideal-gas ratio of specific heats, cp/cv at 1 kPa and :attr:`temperature`.
117
118 What IEC 60534-2-1's ``F_gamma = gamma / 1.40`` is built from (its gamma is
119 a tabulated ideal-gas property: nitrogen 1.40, helium 1.66), as opposed to
120 :attr:`gamma`, the real-gas value at the inlet. Filled by
121 :func:`~feedtwin.comps.elements.conditions_from_fluid` for gas-priced nodes
122 only; zero for a liquid or when unknown. Read only by the components that
123 opt into :mod:`feedtwin.comps.iec_gas`."""
124
125 def signal(self, name: str, default: float = 1.0) -> float:
126 return float(self.signals.get(name, default))
127
128
129@dataclass(frozen=True, slots=True)
131 """A design limit this component's configuration breaks.
132
133 Reported rather than raised. A bend tighter than the tube's minimum radius
134 is buildable -- somebody will build it -- and the model should still solve
135 so you can see what it costs, while saying clearly that the hardware is out
136 of spec. Raising would force a choice between modelling reality and
137 modelling the rules.
138
139 Phase 15's constraint API generalises this: MEOP, NPSH margin, injector
140 stiffness and structural limits are the same shape of statement.
141 """
142
143 component: str
144 limit: str
145 detail: str
146 severity: str = "error"
147 """``error`` for a violated hardware limit, ``warning`` for a margin worth
148 knowing about."""
149
150 def __str__(self) -> str:
151 return f"[{self.severity}] {self.component} ({self.limit}): {self.detail}"
152
153
155 """Base for a component whose loss is a function of flow.
156
157 Subclasses implement :meth:`pressure_drop` and, optionally,
158 :meth:`diagnostics`. Everything else -- the residual adapter, parameter
159 resolution, reporting -- comes from here.
160 """
161
162 #: Ports, in the order residuals index them.
163 inlet_port = "inlet"
164 outlet_port = "outlet"
165
166 #: Whether the law is the same whichever way the fluid goes, so the solver
167 #: may price reverse flow at the node it actually comes from (the higher
168 #: pressure end) and choke it there. False keeps the drawn direction: a
169 #: check valve, a regulator, a relief, an injector are one-way laws, and
170 #: anything not declared symmetric keeps the behaviour it always had.
171 symmetric = False
172
173 def __init__(self, instance: ComponentInstance) -> None:
174 self.instance = instance
175 # Resolved once, in canonical units. Nothing in an inner loop should be
176 # converting a unit or looking one up.
177 self.p = instance.si_params()
178 self.opt = dict(instance.options)
179
180 @property
181 def id(self) -> str:
182 return self.instance.id
183
184 @property
185 def type(self) -> str:
186 return self.instance.type
187
188 # ------------------------------------------------------------ the physics
189
190 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
191 """Flow-dependent pressure loss [Pa]. Always non-negative.
192
193 A *loss*: it opposes the flow, so it lowers the downstream pressure
194 whichever way the fluid is going. Reverse flow is each component's own
195 business -- symmetric for a pipe, very much not for a check valve.
196
197 Anything that does **not** depend on the direction of flow belongs in
198 :meth:`static_head`, not here. See that method for why the distinction
199 is load-bearing rather than tidy.
200 """
201 raise NotImplementedError(f"{type(self).__name__} has no pressure_drop")
202
203 def static_head(self, flow: FlowConditions) -> float:
204 """Flow-independent pressure change [Pa], signed. Zero for most things.
205
206 Elevation is the case that matters: ``rho g dz`` is set by where the
207 ends of a pipe are, and does not care which way the fluid is going. A
208 loss reverses sign with the flow; a static head does not.
209
210 Conflating the two is silently wrong exactly when it is hardest to
211 notice -- a network where a branch reverses, which is any recirculation
212 loop, any vent path, and most transients. Getting it wrong puts the
213 answer out by ``2 rho g dz`` on that branch, with everything still
214 converging and conserving mass.
215 """
216 return 0.0
217
218 def total_dp(self, mdot: float, flow: FlowConditions) -> float:
219 """The pressure change a solver needs: ``p_upstream - p_downstream``.
220
221 Loss with the sign of the flow, plus static head with its own sign.
222 This is what network assembly consumes; :meth:`pressure_drop` is what
223 the validation tests check against published results.
224 """
225 loss = self.pressure_drop(mdot, flow)
226 signed = loss if mdot >= 0.0 else -loss
227 return signed + self.static_head(flow)
228
229 def signal(self, flow: FlowConditions, name: str, default: float = 1.0) -> float:
230 """One control input for *this* component.
231
232 Looks for ``"<this component's id>.<name>"`` first and falls back to a
233 bare ``"<name>"``. The qualified form is what a scenario with two main
234 valves needs -- reading an unqualified ``"command"`` means every valve
235 in the network moves together, which converges perfectly and models a
236 system nobody built. The bare fallback is kept so a single-actuator
237 test can stay terse.
238 """
239 qualified = flow.signals.get(f"{self.id}.{name}")
240 if qualified is not None:
241 return float(qualified)
242 return flow.signal(name, default)
243
244 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
245 """Derived quantities worth reporting: velocity, Reynolds number, K.
246
247 Never consumed by a solver. This is what a frame and a report show, and
248 what makes a result inspectable rather than just a number.
249 """
250 return {}
251
252 # ------------------------------------------------------ isolation
253
254 def isolates(self, signals: Mapping[str, float] | None = None) -> bool:
255 """Whether this component is commanded fully shut.
256
257 Load-bearing for the solver, not a report. A shut valve used to be
258 modelled as a very small ``Cv`` on the reasoning that a real seat leaks
259 and that an exactly-zero capacity makes the network singular. Both
260 halves of that were wrong in the way that matters.
261
262 A branch with a nearly-zero capacity has a nearly-zero derivative, so
263 Newton cannot move its row: the solve stalls with the residual parked
264 just above tolerance, and the pressures either side of a shut valve come
265 back somewhere in between rather than held. On a stand where most valves
266 are shut most of the time -- which is every stand, most of the time --
267 that is most of the network.
268
269 Reported shut, the branch is *removed* from the unknowns and its ends
270 are peeled as ordinary stubs, which is both what the hardware does and
271 what the numerics want. Seat leakage is a real effect and belongs in a
272 leak-rate model that runs when somebody asks about it, not in the
273 conditioning of every solve.
274 """
275 return False
276
277 # ------------------------------------------------------- choking
278
279 def flow_ceiling(self, flow: FlowConditions) -> float | None:
280 """Largest mass flow this component can pass [kg/s], or ``None``.
281
282 ``None`` means "no ceiling", which is true of every liquid component
283 here and of any gas element below its critical pressure ratio.
284
285 A ceiling changes what equation the network solver writes for this
286 branch, which is why it lives on the base class rather than inside the
287 one component that has one. See :meth:`is_choked`.
288 """
289 return None
290
291 def pinned_flow(self, dp_available: float, flow: FlowConditions) -> float | None:
292 """A flow this component pins at this drop, whatever downstream does.
293
294 ``None`` -- every component here but an opt-in one -- leaves the branch to
295 :meth:`flow_ceiling` and :meth:`is_choked`. A component whose choked
296 flow depends on *which* way it is past its limit returns the flow here:
297 the compressible regulator seat (:mod:`feedtwin.comps.regulator`) is
298 both a regulator that shuts (pinned at zero, by the ceiling) and a seat
299 that chokes wide open (pinned at its capacity, by this). The solver asks
300 this first and writes ``mdot = pinned`` when it answers.
301 """
302 return None
303
304 def is_choked(self, dp_available: float, flow: FlowConditions) -> bool:
305 """Whether ``dp_available`` puts this component past its choking point.
306
307 Load-bearing for the solver, not just a report. A branch's usual
308 equation is ``(p_up - p_dn) - dp(mdot) = 0``, which presumes the drop
309 is a function of the flow. Past choking it is not: mass flow stops
310 depending on downstream pressure entirely, and the drop can be anything
311 at or above the critical value. The relation is no longer invertible
312 and Newton, asked to invert it anyway, runs away -- which is exactly
313 what a relief valve venting to atmosphere does to a naive solver.
314
315 When this returns true the solver writes ``mdot = flow_ceiling``
316 instead, and reports the branch as choked.
317 """
318 return False
319
320 def check(self) -> list[Violation]:
321 """Design limits this component's configuration breaks.
322
323 Configuration only -- nothing here depends on the operating point, so it
324 can be run the moment a network is loaded and long before it is solved.
325 Bend radius is the case that motivated it: a line bent tighter than its
326 tube allows is a fabrication problem, and finding out at the bender is
327 expensive.
328 """
329 return []
330
331 # ------------------------------------------------- the provisional protocol
332
333 @property
334 def n_residuals(self) -> int:
335 return 1
336
337 def residuals(self, ctx: EvalContext) -> Sequence[float]:
338 """``(p_in - p_out) - dp(mdot) = 0``.
339
340 Requires ``conditions`` to have been provided, because the fluid state
341 is not in the context yet -- Phase 04 decides how a solver supplies it.
342 """
343 if len(ctx.ports) < 2 or not ctx.flows:
344 raise SpecError(
345 f"{self.id}: residuals need two ports and one flow, got "
346 f"{len(ctx.ports)} and {len(ctx.flows)}"
347 )
348 flow = self.conditions(ctx.ports[0], ctx.signals)
349 mdot = ctx.flows[0]
350 return [(ctx.ports[0].p - ctx.ports[1].p) - self.total_dp(mdot, flow)]
351
352 def outputs(self, ctx: EvalContext) -> Mapping[str, float]:
353 flow = self.conditions(ctx.ports[0], ctx.signals)
354 return self.diagnostics(ctx.flows[0] if ctx.flows else 0.0, flow)
355
356 #: Turns a port state into fluid conditions. Injected at build time; Phase
357 #: 04 supplies the real one from the network's fluid assignment.
358 conditions_provider: (
359 Callable[[PortState, Mapping[str, float]], FlowConditions] | None
360 ) = None
361
363 self, port: PortState, signals: Mapping[str, float]
364 ) -> FlowConditions:
365 if self.conditions_provider is None:
366 raise SpecError(
367 f"{self.id}: no conditions provider. A component evaluated through "
368 "residuals() needs one to turn a port state into fluid properties; "
369 "call pressure_drop() directly to evaluate it standalone."
370 )
371 return self.conditions_provider(port, signals)
372
373 def __repr__(self) -> str:
374 return f"{type(self).__name__}({self.id!r})"
375
376
377#: Builds a component from its configuration.
378Builder = Callable[[ComponentInstance], HydraulicComponent]
379
380_BUILDERS: dict[tuple[str, str], Builder] = {}
381
382
383def register_builder(type_name: str, model: str, builder: Builder) -> None:
384 """Register the physics for one (component type, fidelity model) pair.
385
386 Keyed on both because that pair is exactly what a fidelity model *is*: an
387 orifice evaluated with a fixed discharge coefficient and the same orifice
388 evaluated through ISO 5167 are different physics behind one declaration.
389 """
390 _BUILDERS[(type_name, model)] = builder
391
392
393def build_component(instance: ComponentInstance) -> HydraulicComponent:
394 """Build the physics for a configured component.
395
396 Raises:
397 SpecError: no implementation is registered for that type and model --
398 which is the honest failure when a schema declares a model that
399 Phase 03 has not implemented yet.
400 """
401 key = (instance.type, instance.model)
402 builder = _BUILDERS.get(key)
403 if builder is None:
404 available = sorted(m for t, m in _BUILDERS if t == instance.type)
405 raise SpecError(
406 f"{instance.id}: no implementation for {instance.type!r} model "
407 f"{instance.model!r}. Implemented models for that type: "
408 f"{', '.join(available) or '(none)'}"
409 )
410 return builder(instance)
411
412
413def registered_builders() -> list[tuple[str, str]]:
414 return sorted(_BUILDERS)
float signal(self, str name, float default=1.0)
Definition base.py:125
float static_head(self, FlowConditions flow)
Definition base.py:203
None __init__(self, ComponentInstance instance)
Definition base.py:173
float|None flow_ceiling(self, FlowConditions flow)
Definition base.py:279
list[Violation] check(self)
Definition base.py:320
float pressure_drop(self, float mdot, FlowConditions flow)
Definition base.py:190
bool isolates(self, Mapping[str, float]|None signals=None)
Definition base.py:254
float total_dp(self, float mdot, FlowConditions flow)
Definition base.py:218
bool is_choked(self, float dp_available, FlowConditions flow)
Definition base.py:304
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition base.py:244
Sequence[float] residuals(self, EvalContext ctx)
Definition base.py:337
FlowConditions conditions(self, PortState port, Mapping[str, float] signals)
Definition base.py:364
float signal(self, FlowConditions flow, str name, float default=1.0)
Definition base.py:229
float|None pinned_flow(self, float dp_available, FlowConditions flow)
Definition base.py:291
Mapping[str, float] outputs(self, EvalContext ctx)
Definition base.py:352
None __init__(self, str component, float mdot, float p_upstream)
Definition base.py:55
list[tuple[str, str]] registered_builders()
Definition base.py:413
HydraulicComponent build_component(ComponentInstance instance)
Definition base.py:393
None register_builder(str type_name, str model, Builder builder)
Definition base.py:383