1"""What every hydraulic component is, and how one gets built.
3Each component exposes its physics **twice**, and the split is deliberate.
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
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.
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
23from __future__
import annotations
25from dataclasses
import dataclass, field
26from typing
import Callable, Mapping, Sequence
32R_UNIVERSAL = 8.31446261815324
33"""Universal gas constant [J/(mol.K)], CODATA 2018.
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.
46 """The requested flow cannot pass at the given upstream pressure.
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.
55 def __init__(self, component: str, mdot: float, p_upstream: float) ->
None:
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."
66@dataclass(frozen=True, slots=True)
68 """The fluid state a component needs to evaluate its loss.
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.
77 """Density [kg/m^3]."""
80 """Dynamic viscosity [Pa.s]."""
83 """Static pressure at the inlet [Pa]. Needed wherever choking or cavitation
84 depends on absolute level, not just on the drop."""
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."""
92 """Thermodynamic critical pressure [Pa]. Required by the IEC 60534 choked
93 liquid flow correlation."""
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."""
100 """Heat capacity ratio cp/cv. Needed for choking; zero means unknown."""
102 r_specific: float = 0.0
103 """Specific gas constant R/M [J/(kg.K)]. Needed for choked mass flow."""
105 signals: Mapping[str, float] = field(default_factory=dict)
106 """Control inputs -- ``{"command": 0.4}`` for a part-open valve."""
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."""
115 gamma_ideal: float = 0.0
116 """Ideal-gas ratio of specific heats, cp/cv at 1 kPa and :attr:`temperature`.
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`."""
125 def signal(self, name: str, default: float = 1.0) -> float:
126 return float(self.
signals.get(name, default))
129@dataclass(frozen=True, slots=True)
131 """A design limit this component's configuration breaks.
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
139 Phase 15's constraint API generalises this: MEOP, NPSH margin, injector
140 stiffness and structural limits are the same shape of statement.
146 severity: str =
"error"
147 """``error`` for a violated hardware limit, ``warning`` for a margin worth
151 return f
"[{self.severity}] {self.component} ({self.limit}): {self.detail}"
155 """Base for a component whose loss is a function of flow.
157 Subclasses implement :meth:`pressure_drop` and, optionally,
158 :meth:`diagnostics`. Everything else -- the residual adapter, parameter
159 resolution, reporting -- comes from here.
164 outlet_port =
"outlet"
173 def __init__(self, instance: ComponentInstance) ->
None:
177 self.
p = instance.si_params()
178 self.
opt = dict(instance.options)
191 """Flow-dependent pressure loss [Pa]. Always non-negative.
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.
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.
201 raise NotImplementedError(f
"{type(self).__name__} has no pressure_drop")
204 """Flow-independent pressure change [Pa], signed. Zero for most things.
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.
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.
218 def total_dp(self, mdot: float, flow: FlowConditions) -> float:
219 """The pressure change a solver needs: ``p_upstream - p_downstream``.
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.
226 signed = loss
if mdot >= 0.0
else -loss
229 def signal(self, flow: FlowConditions, name: str, default: float = 1.0) -> float:
230 """One control input for *this* component.
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
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)
244 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
245 """Derived quantities worth reporting: velocity, Reynolds number, K.
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.
254 def isolates(self, signals: Mapping[str, float] |
None =
None) -> bool:
255 """Whether this component is commanded fully shut.
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.
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.
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.
280 """Largest mass flow this component can pass [kg/s], or ``None``.
282 ``None`` means "no ceiling", which is true of every liquid component
283 here and of any gas element below its critical pressure ratio.
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`.
291 def pinned_flow(self, dp_available: float, flow: FlowConditions) -> float |
None:
292 """A flow this component pins at this drop, whatever downstream does.
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.
304 def is_choked(self, dp_available: float, flow: FlowConditions) -> bool:
305 """Whether ``dp_available`` puts this component past its choking point.
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.
315 When this returns true the solver writes ``mdot = flow_ceiling``
316 instead, and reports the branch as choked.
321 """Design limits this component's configuration breaks.
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
337 def residuals(self, ctx: EvalContext) -> Sequence[float]:
338 """``(p_in - p_out) - dp(mdot) = 0``.
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.
343 if len(ctx.ports) < 2
or not ctx.flows:
345 f
"{self.id}: residuals need two ports and one flow, got "
346 f
"{len(ctx.ports)} and {len(ctx.flows)}"
348 flow = self.
conditions(ctx.ports[0], ctx.signals)
350 return [(ctx.ports[0].p - ctx.ports[1].p) - self.
total_dp(mdot, flow)]
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)
358 conditions_provider: (
359 Callable[[PortState, Mapping[str, float]], FlowConditions] |
None
363 self, port: PortState, signals: Mapping[str, float]
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."
374 return f
"{type(self).__name__}({self.id!r})"
378Builder = Callable[[ComponentInstance], HydraulicComponent]
380_BUILDERS: dict[tuple[str, str], Builder] = {}
384 """Register the physics for one (component type, fidelity model) pair.
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.
390 _BUILDERS[(type_name, model)] = builder
394 """Build the physics for a configured component.
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.
401 key = (instance.type, instance.model)
402 builder = _BUILDERS.get(key)
404 available = sorted(m
for t, m
in _BUILDERS
if t == instance.type)
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)'}"
410 return builder(instance)
414 return sorted(_BUILDERS)
float signal(self, str name, float default=1.0)
float static_head(self, FlowConditions flow)
None __init__(self, ComponentInstance instance)
float|None flow_ceiling(self, FlowConditions flow)
list[Violation] check(self)
float pressure_drop(self, float mdot, FlowConditions flow)
bool isolates(self, Mapping[str, float]|None signals=None)
float total_dp(self, float mdot, FlowConditions flow)
bool is_choked(self, float dp_available, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Sequence[float] residuals(self, EvalContext ctx)
FlowConditions conditions(self, PortState port, Mapping[str, float] signals)
float signal(self, FlowConditions flow, str name, float default=1.0)
float|None pinned_flow(self, float dp_available, FlowConditions flow)
Mapping[str, float] outputs(self, EvalContext ctx)
None __init__(self, str component, float mdot, float p_upstream)
list[tuple[str, str]] registered_builders()
HydraulicComponent build_component(ComponentInstance instance)
None register_builder(str type_name, str model, Builder builder)