1"""Manifolds: one inlet, several outlets, and a right-angle turn at each.
3A manifold is a block with a bore through it and ports tapped into the side.
4The team uses them everywhere -- a COPV manifold typically carries a pressure
5transducer, a relief valve, a fill quick-disconnect and the regulator feed off
6one inlet -- and modelling one as a bare node is the mistake worth avoiding.
8What a manifold actually is
9---------------------------
10A **plenum** at one pressure, plus one **branch per port**. Flow entering the
11plenum and leaving through a side port turns through ninety degrees into a bore
12that is usually smaller than the plenum's; that turn is a real loss and it is
13not the same as the fitting screwed into the port. So each port branch carries
14two things in series: the turn out of the plenum, and whatever is threaded in.
16The ports themselves carry no information. Every port on the team's manifolds is
17female, so the port is just a hole -- **the fitting in it is the whole story**,
18which is why :class:`ManifoldPort` takes a bore and a K and not a thread
21Three kinds of port, and the difference matters
22-----------------------------------------------
24 Carries mass. Turn loss plus fitting loss, solved normally.
27 A transducer port. It is a dead end -- no flow, ever -- so it reads plenum
28 pressure exactly and contributes nothing but a node. Phase 04's dead-end
29 peeling already handles this correctly; declaring the port as an instrument
30 is what tells a reader *why* the stub is there, and stops someone
34 A relief valve or a burst disc: shut now, a flow path the moment it opens.
35 Modelled as a dead end while shut, which is exactly what it is, and named so
36 that a scenario can open it without restructuring the network.
38The last case is why port type is declared rather than inferred from
39connectivity. A shut relief and a capped port are topologically identical and
40physically nothing alike, and only one of them belongs in a burst analysis.
43from __future__
import annotations
46from dataclasses
import dataclass
49import fluids.fittings
as ft
63 """What a manifold port is for. See the module docstring."""
66 INSTRUMENT =
"instrument"
67 CONDITIONAL =
"conditional"
71 return self
is PortKind.FLOW
83@dataclass(frozen=True, slots=True)
85 """One tapping in a manifold body.
88 id: Port tag, unique within the manifold.
89 bore: Flow diameter of the port itself [m]. Not the thread size --
90 see the note on ``bore`` throughout this library.
91 kind: What the port is for.
92 fitting_K: Loss of whatever is screwed into the port, referenced to
93 ``bore``. Zero means nothing but the tapping.
94 turn_K: Loss of turning out of the plenum into this port. Defaults to
95 :data:`DEFAULT_TURN_K`; set it to zero for an in-line port -- a
96 manifold whose outlet is straight through does not turn.
97 elevation: Height of this port above the manifold's reference
98 point [m]. Usually millimetres, and usually negligible; carried
99 because a vertical manifold on a cryogenic line is not.
104 kind: PortKind = PortKind.FLOW
105 fitting_K: float = 0.0
106 turn_K: float = DEFAULT_TURN_K
107 elevation: float = 0.0
115 return math.pi * self.bore * self.bore / 4.0
119 """The path from a manifold plenum out through one port.
121 Built by :func:`expand_manifold` rather than declared directly, because a
122 manifold is one symbol on a drawing and several branches in a solve. That
123 asymmetry is the whole point of an assembly.
126 def __init__(self, instance: ComponentInstance, port: ManifoldPort) ->
None:
131 if not self.
port.kind.carries_flow
or mdot == 0.0:
133 v = velocity(mdot, self.
port.bore, flow.rho)
134 return self.
port.total_K * 0.5 * flow.rho * v * v
137 return -flow.rho * flow.gravity * self.
port.elevation
139 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
141 "K_turn": self.
port.turn_K,
142 "K_fitting": self.
port.fitting_K,
143 "K_total": self.
port.total_K,
144 "velocity": velocity(mdot, self.
port.bore, flow.rho),
145 "Re": reynolds(mdot, self.
port.bore, flow.rho, flow.mu),
151 """A manifold body: a plenum with ports tapped into it.
153 Not itself a branch. It has no single inlet-to-outlet pressure drop, which
154 is why :meth:`pressure_drop` refuses rather than returning zero -- a
155 manifold that silently costs nothing is a manifold whose port losses have
156 gone missing. Use :func:`expand_manifold` to turn one into the node and
157 branches a solver consumes.
160 instance: The configured component. Reads ``bore`` (the plenum's own
161 flow diameter) and ``volume`` (used by transient work, not by a
167 self, instance: ComponentInstance, ports: list[ManifoldPort] |
None =
None
171 seen: set[str] = set()
172 for port
in self.
ports:
174 raise SpecError(f
"{self.id}: duplicate port {port.id!r}")
179 return [p
for p
in self.
ports if p.kind.carries_flow]
183 return [p
for p
in self.
ports if not p.kind.carries_flow]
187 f
"{self.id}: a manifold is not a single branch -- it is a plenum "
188 f
"with {len(self.ports)} ports, each with its own loss. Expand it "
189 "with feedtwin.comps.manifold.expand_manifold() and solve the "
194 out: list[Violation] = []
200 f
"{len(self.flow_ports)} flow port(s). A manifold with "
201 "fewer than two is a fitting; model it as one.",
205 plenum = self.
p.get(
"bore", 0.0)
206 for port
in self.
ports:
207 if plenum > 0.0
and port.bore > plenum:
212 f
"port {port.id!r} bore {port.bore * 1e3:.2f} mm exceeds "
213 f
"the plenum bore {plenum * 1e3:.2f} mm; the port is not "
214 "the restriction it is being modelled as",
221 kinds =
", ".join(f
"{p.id}:{p.kind.value}" for p
in self.
ports)
222 return f
"Manifold({self.id!r}, [{kinds}])"
226 """A turn loss derived from the two bores, for when nobody measured one.
228 The port sees the plenum as a large volume, so the entry is a contraction
229 from the plenum bore into the port bore plus the ninety-degree turn. Both
230 come from ``fluids``: the contraction from the area ratio, the turn from
231 the branch-tee correlation.
233 A derived number, and it says so. Rung 6 of the K ladder -- fine for a first
234 pass, worth replacing with a fitted value the moment a flow test exists.
236 if plenum_bore <= 0.0
or port_bore <= 0.0:
237 return DEFAULT_TURN_K
238 if port_bore >= plenum_bore:
239 return float(ft.entrance_sharp())
240 contraction = float(ft.contraction_sharp(Di1=plenum_bore, Di2=port_bore))
241 return contraction + DEFAULT_TURN_K
248 port_nodes: dict[str, str],
250 """Add a manifold's branches to a network. Returns the branch ids.
252 The plenum node must already exist and each port must be mapped to a node,
253 including the instrument and conditional ones -- those become dead-end
254 stubs, which Phase 04 peels and back-fills exactly.
257 network: The :class:`~feedtwin.solve.network.Network` to add to. Typed
258 loosely to keep this module free of a solver import; the solver
259 already imports components and the reverse would be a cycle.
260 manifold: The expanded body.
261 plenum_node: Node id for the plenum interior.
262 port_nodes: ``{port id: node id}`` for every port.
264 missing = {p.id
for p
in manifold.ports} - set(port_nodes)
267 f
"{manifold.id}: no node given for port(s) "
268 f
"{', '.join(sorted(missing))}. Every port needs one, including "
269 "instrument and conditional ports -- they become dead-end stubs, "
270 "which is how a transducer tap reads plenum pressure exactly."
272 extra = set(port_nodes) - {p.id
for p
in manifold.ports}
275 f
"{manifold.id}: node(s) given for unknown port(s) "
276 f
"{', '.join(sorted(extra))}"
280 for port
in manifold.ports:
281 branch_id = f
"{manifold.id}.{port.id}"
284 branch_id, component, plenum_node, port_nodes[port.id]
286 ids.append(branch_id)
293register_builder(
"manifold",
"plenum", Manifold)
float pressure_drop(self, float mdot, FlowConditions flow)
None __init__(self, ComponentInstance instance, ManifoldPort port)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float static_head(self, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
list[Violation] check(self)
None __init__(self, ComponentInstance instance, list[ManifoldPort]|None ports=None)
list[ManifoldPort] dead_ports(self)
float turn_K_from_geometry(float port_bore, float plenum_bore)
list[str] expand_manifold(object network, Manifold manifold, str plenum_node, dict[str, str] port_nodes)