STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
manifold.py
Go to the documentation of this file.
1"""Manifolds: one inlet, several outlets, and a right-angle turn at each.
2
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.
7
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.
15
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
19designation.
20
21Three kinds of port, and the difference matters
22-----------------------------------------------
23``flow``
24 Carries mass. Turn loss plus fitting loss, solved normally.
25
26``instrument``
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
31 "fixing" it later.
32
33``conditional``
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.
37
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.
41"""
42
43from __future__ import annotations
44
45import math
46from dataclasses import dataclass
47from enum import Enum
48
49import fluids.fittings as ft
50
51from feedtwin.comps.base import (
52 FlowConditions,
53 HydraulicComponent,
54 Violation,
55 register_builder,
56)
57from feedtwin.comps.correlations import reynolds, velocity
58from feedtwin.model.component import ComponentInstance
59from feedtwin.model.spec import SpecError
60
61
62class PortKind(Enum):
63 """What a manifold port is for. See the module docstring."""
64
65 FLOW = "flow"
66 INSTRUMENT = "instrument"
67 CONDITIONAL = "conditional"
68
69 @property
70 def carries_flow(self) -> bool:
71 return self is PortKind.FLOW
72
73
74#: Loss coefficient for turning out of a plenum into a side port, referenced to
75#: the *port* bore. A branch tee taken at ninety degrees is about 1.0 (Crane
76#: TP-410 gives K = 60 f_T, roughly 1.0-1.4 in small sizes); a plenum is a
77#: little worse than a tee because the approach velocity is lower and the
78#: turn sharper. Overridable per port, and a prime candidate for Phase 12
79#: fitting -- it is exactly the kind of number a cold-flow test pins down.
80DEFAULT_TURN_K = 1.3
81
82
83@dataclass(frozen=True, slots=True)
85 """One tapping in a manifold body.
86
87 Args:
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.
100 """
101
102 id: str
103 bore: float
104 kind: PortKind = PortKind.FLOW
105 fitting_K: float = 0.0
106 turn_K: float = DEFAULT_TURN_K
107 elevation: float = 0.0
108
109 @property
110 def total_K(self) -> float:
111 return self.turn_K + self.fitting_K
112
113 @property
114 def area(self) -> float:
115 return math.pi * self.bore * self.bore / 4.0
116
117
119 """The path from a manifold plenum out through one port.
120
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.
124 """
125
126 def __init__(self, instance: ComponentInstance, port: ManifoldPort) -> None:
127 super().__init__(instance)
128 self.port = port
129
130 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
131 if not self.port.kind.carries_flow or mdot == 0.0:
132 return 0.0
133 v = velocity(mdot, self.port.bore, flow.rho)
134 return self.port.total_K * 0.5 * flow.rho * v * v
135
136 def static_head(self, flow: FlowConditions) -> float:
137 return -flow.rho * flow.gravity * self.port.elevation
138
139 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
140 return {
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),
146 "dp": self.pressure_drop(mdot, flow),
147 }
148
149
151 """A manifold body: a plenum with ports tapped into it.
152
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.
158
159 Args:
160 instance: The configured component. Reads ``bore`` (the plenum's own
161 flow diameter) and ``volume`` (used by transient work, not by a
162 steady solve).
163 ports: The tappings.
164 """
165
167 self, instance: ComponentInstance, ports: list[ManifoldPort] | None = None
168 ) -> None:
169 super().__init__(instance)
170 self.ports = list(ports or [])
171 seen: set[str] = set()
172 for port in self.ports:
173 if port.id in seen:
174 raise SpecError(f"{self.id}: duplicate port {port.id!r}")
175 seen.add(port.id)
176
177 @property
178 def flow_ports(self) -> list[ManifoldPort]:
179 return [p for p in self.ports if p.kind.carries_flow]
180
181 @property
182 def dead_ports(self) -> list[ManifoldPort]:
183 return [p for p in self.ports if not p.kind.carries_flow]
184
185 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
186 raise SpecError(
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 "
190 "branches."
191 )
192
193 def check(self) -> list[Violation]:
194 out: list[Violation] = []
195 if len(self.flow_ports) < 2:
196 out.append(
197 Violation(
198 self.id,
199 "ports",
200 f"{len(self.flow_ports)} flow port(s). A manifold with "
201 "fewer than two is a fitting; model it as one.",
202 severity="warning",
203 )
204 )
205 plenum = self.p.get("bore", 0.0)
206 for port in self.ports:
207 if plenum > 0.0 and port.bore > plenum:
208 out.append(
209 Violation(
210 self.id,
211 "port_bore",
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",
215 severity="warning",
216 )
217 )
218 return out
219
220 def __repr__(self) -> str:
221 kinds = ", ".join(f"{p.id}:{p.kind.value}" for p in self.ports)
222 return f"Manifold({self.id!r}, [{kinds}])"
223
224
225def turn_K_from_geometry(port_bore: float, plenum_bore: float) -> float:
226 """A turn loss derived from the two bores, for when nobody measured one.
227
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.
232
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.
235 """
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
242
243
245 network: object,
246 manifold: Manifold,
247 plenum_node: str,
248 port_nodes: dict[str, str],
249) -> list[str]:
250 """Add a manifold's branches to a network. Returns the branch ids.
251
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.
255
256 Args:
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.
263 """
264 missing = {p.id for p in manifold.ports} - set(port_nodes)
265 if missing:
266 raise SpecError(
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."
271 )
272 extra = set(port_nodes) - {p.id for p in manifold.ports}
273 if extra:
274 raise SpecError(
275 f"{manifold.id}: node(s) given for unknown port(s) "
276 f"{', '.join(sorted(extra))}"
277 )
278
279 ids: list[str] = []
280 for port in manifold.ports:
281 branch_id = f"{manifold.id}.{port.id}"
282 component = ManifoldBranch(manifold.instance, port)
283 network.add_branch( # type: ignore[attr-defined]
284 branch_id, component, plenum_node, port_nodes[port.id]
285 )
286 ids.append(branch_id)
287 return ids
288
289
290# A manifold declared with no ports yet: the assembly layer attaches them. It
291# is registered so that a config naming `manifold` loads and validates like any
292# other type, and so `check()` runs on it.
293register_builder("manifold", "plenum", Manifold)
float pressure_drop(self, float mdot, FlowConditions flow)
Definition base.py:190
None __init__(self, ComponentInstance instance, ManifoldPort port)
Definition manifold.py:126
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition manifold.py:139
float static_head(self, FlowConditions flow)
Definition manifold.py:136
float pressure_drop(self, float mdot, FlowConditions flow)
Definition manifold.py:130
float pressure_drop(self, float mdot, FlowConditions flow)
Definition manifold.py:185
list[Violation] check(self)
Definition manifold.py:193
None __init__(self, ComponentInstance instance, list[ManifoldPort]|None ports=None)
Definition manifold.py:168
list[ManifoldPort] dead_ports(self)
Definition manifold.py:182
float turn_K_from_geometry(float port_bore, float plenum_bore)
Definition manifold.py:225
list[str] expand_manifold(object network, Manifold manifold, str plenum_node, dict[str, str] port_nodes)
Definition manifold.py:249