1"""Tank geometry: how much liquid is in there, and how much surface it shows.
3Three numbers come out of a tank's shape and all three matter:
5**Volume below a level.** A propellant load is a mass, and where the surface
6sits follows from the shape. Treat a tank as a plain cylinder and you misplace
7the surface by the whole height of the end caps -- 5 cm on a 6 in. tank with
82:1 ellipsoidal heads, which is 15% of the volume of a short tank.
10**Cross-section at the surface.** This is the area across which warm pressurant
11talks to cold liquid, and it is what
12:mod:`~feedtwin.vessels.collapse` multiplies its heat flux by. In the barrel it
13is constant; in a head it is not, and a nearly-empty tank has a much smaller
14interface than a half-full one.
16**Wetted wall area.** What the liquid can chill, and what an insulation model
17needs. Carried now because Phase 14 wants it and because it is free once the
20Shapes are registered by name, so a tank with a geometry nobody anticipated is
21a registration rather than an edit to this module -- and the escape hatch below
22that is :class:`TabulatedGeometry`, which takes a *V(h)* curve straight off a
23CAD model and asks no questions about the shape at all.
26from __future__
import annotations
29from dataclasses
import dataclass
30from typing
import Callable, Protocol, Sequence, runtime_checkable
32from scipy.optimize
import brentq
37 """A tank shape, described by what a fluid model needs to ask of it."""
41 """Internal volume [m^3]."""
46 """Inside height from the lowest point to the highest [m]."""
50 """Liquid volume [m^3] with the surface at ``level`` above the bottom."""
54 """Horizontal area [m^2] at ``level`` -- the liquid surface area."""
58 """Wall area [m^2] below ``level``."""
63 """Invert ``volume_below``: where the surface sits for a given fill [m].
65 Numerically rather than analytically. ``volume_below`` is monotonic for any
66 physical tank, so a bracketed root always exists and always converges; the
67 alternative is a closed-form inverse per shape, which is three times the
68 code and wrong the moment somebody registers a fourth shape.
70 total = geometry.total_volume
74 return geometry.height
80 last = _LAST_LEVEL.get(id(geometry))
81 if last
is not None and last[0]
is geometry
and last[1] == volume:
84 brentq(
lambda h: geometry.volume_below(h) - volume, 0.0, geometry.height)
86 if len(_LAST_LEVEL) >= _LAST_LEVEL_SHAPES:
90 _LAST_LEVEL[id(geometry)] = (geometry, volume, level)
95_LAST_LEVEL: dict[int, tuple[TankGeometry, float, float]] = {}
97_LAST_LEVEL_SHAPES = 64
103@dataclass(frozen=True, slots=True)
105 """One end cap, described by how far it bulges past the barrel.
107 ``ratio`` is the classical head ratio: the barrel radius divided by the
108 head's depth. 2.0 is a 2:1 ellipsoidal head, the common pressure-vessel
109 shape; 1.0 is hemispherical; a flat head is depth zero, spelled here as
110 ``Head.flat()`` rather than an infinite ratio.
118 return Head(radius=0.0, ratio=math.inf)
122 """How far the head extends beyond the barrel [m]."""
123 if not math.isfinite(self.
ratio)
or self.
ratio <= 0.0:
125 return self.radius / self.
ratio
128 def volume(self) -> float:
129 """Volume of the whole head [m^3]: half an ellipsoid."""
130 return (2.0 / 3.0) * math.pi * self.radius * self.radius * self.
depth
133 """Volume [m^3] of the head filled to ``depth_filled`` from its pole.
135 Measured from the pole -- the tip of the dome -- because that is where
136 a bottom head starts filling and where a top head finishes.
138 The cap of a semi-ellipsoid of semi-axes ``(r, r, c)`` filled to depth
139 ``d`` from the pole is ``pi r^2 d^2 (3c - d) / (3 c^2)``, which reduces
140 to the spherical-cap formula when ``c == r``.
145 d =
min(
max(depth_filled, 0.0), c)
147 math.pi * self.radius * self.radius * d * d * (3.0 * c - d) / (3.0 * c * c)
151 """Horizontal area [m^2] at ``depth_filled`` from the pole."""
154 return math.pi * self.radius * self.radius
155 d =
min(
max(depth_filled, 0.0), c)
157 frac = 1.0 - ((c - d) / c) ** 2
158 return math.pi * self.radius * self.radius *
max(frac, 0.0)
161 """Wall area [m^2] of the head below ``depth_filled`` from the pole.
163 Archimedes' hat-box result, ``A = 2 pi r d``: the lateral area of a
164 spherical zone depends only on the slice thickness. Exact for a
165 hemispherical head, and a few percent low for a 2:1 ellipsoidal one --
166 comfortably inside the uncertainty of any heat-transfer coefficient
167 that will multiply it.
172 d =
min(
max(depth_filled, 0.0), c)
173 return 2.0 * math.pi * self.radius * d
176@dataclass(frozen=True, slots=True)
178 """A cylindrical barrel with a head at each end. The usual tank.
181 diameter: Inside diameter of the barrel [m].
182 barrel_length: Straight length between the head tangent lines [m].
183 bottom: Lower head. Defaults to 2:1 ellipsoidal.
184 top: Upper head. Defaults to the same as the bottom.
189 bottom: Head |
None =
None
190 top: Head |
None =
None
193 if self.diameter <= 0.0:
194 raise ValueError(f
"diameter must be positive, got {self.diameter}")
195 if self.barrel_length < 0.0:
196 raise ValueError(f
"barrel_length must be >= 0, got {self.barrel_length}")
197 r = self.diameter / 2.0
198 object.__setattr__(self,
"bottom", self.
bottom or Head(radius=r))
199 object.__setattr__(self,
"top", self.
top or Head(radius=r))
200 for name
in (
"bottom",
"top"):
201 head: Head = getattr(self, name)
202 if head.radius
not in (0.0, r):
204 f
"{name} head radius {head.radius} does not match the "
205 f
"barrel radius {r}; a head is a cap on this barrel"
211 assert self.
bottom is not None
216 assert self.
top is not None
221 return math.pi * self.diameter * self.diameter / 4.0
233 return self.
_bottom.depth + self.barrel_length + self.
_top.depth
239 return b.volume_filled(h)
240 if h <= b.depth + self.barrel_length:
243 empty_from_pole = self.
height - h
244 return self.
total_volume - t.volume_filled(empty_from_pole)
250 return b.cross_section(h)
251 if h <= b.depth + self.barrel_length:
253 return t.cross_section(self.
height - h)
259 return b.area_filled(h)
260 barrel = math.pi * self.diameter *
min(h - b.depth, self.barrel_length)
261 if h <= b.depth + self.barrel_length:
262 return b.area_filled(b.depth) + barrel
264 b.area_filled(b.depth)
266 + t.area_filled(t.depth)
267 - t.area_filled(self.
height - h)
272 f
"CylindricalTank({self.diameter * 1e3:.0f} mm x "
273 f
"{self.barrel_length * 1e3:.0f} mm, {self.total_volume * 1e3:.2f} L)"
277@dataclass(frozen=True, slots=True)
279 """A tank described by a measured or CAD-derived *V(h)* table.
281 The escape hatch, and for a real flight tank usually the right answer: a
282 weld land, a sump, a baffle ring and an anti-vortex plate are all volume
283 that no analytic shape has, and CAD already knows about every one of them.
286 levels: Heights above the lowest point [m], strictly increasing, the
287 first at or below zero and the last the full height.
288 volumes: Cumulative volume below each level [m^3], non-decreasing.
289 areas: Optional horizontal cross-section at each level [m^2]. Derived
290 by differencing the volume table when omitted, which is exactly
291 ``dV/dh`` and needs no extra data.
292 wall_areas: Optional cumulative wetted wall area [m^2].
295 levels: Sequence[float]
296 volumes: Sequence[float]
297 areas: Sequence[float] |
None =
None
298 wall_areas: Sequence[float] |
None =
None
302 raise ValueError(
"a V(h) table needs at least two points")
305 f
"levels and volumes must be the same length, got "
306 f
"{len(self.levels)} and {len(self.volumes)}"
308 if any(b <= a
for a, b
in zip(self.
levels, self.
levels[1:])):
309 raise ValueError(
"levels must be strictly increasing")
312 "volumes must be non-decreasing -- a tank cannot hold less "
313 "liquid when the surface goes up"
315 for name
in (
"areas",
"wall_areas"):
316 table = getattr(self, name)
317 if table
is not None and len(table) != len(self.
levels):
318 raise ValueError(f
"{name} must be the same length as levels")
326 return float(self.
levels[-1])
328 def _interp(self, table: Sequence[float], level: float) -> float:
330 for i
in range(len(self.
levels) - 1):
333 f = (h - h0) / (h1 - h0)
334 return float(table[i] + f * (table[i + 1] - table[i]))
335 return float(table[-1])
341 if self.
areas is not None:
344 for i
in range(len(self.
levels) - 1):
358GeometryFactory = Callable[..., TankGeometry]
360_GEOMETRIES: dict[str, GeometryFactory] = {}
364 _GEOMETRIES[name] = factory
368 if name
not in _GEOMETRIES:
370 f
"unknown tank geometry {name!r}; registered: "
371 f
"{', '.join(sorted(_GEOMETRIES))}"
373 return _GEOMETRIES[name](**kwargs)
377 return sorted(_GEOMETRIES)
382 barrel_length: float,
383 head_ratio: float = 2.0,
384 bottom_head_ratio: float |
None =
None,
385 top_head_ratio: float |
None =
None,
389 def head(ratio: float |
None) -> Head:
390 chosen = head_ratio
if ratio
is None else ratio
392 Head.flat()
if not math.isfinite(chosen)
else Head(radius=r, ratio=chosen)
397 barrel_length=barrel_length,
398 bottom=
head(bottom_head_ratio),
399 top=
head(top_head_ratio),
404 """A barrel of the right volume at a stated diameter, 2:1 heads.
406 What a drawing gives you: a volume and a diameter, not a barrel length.
407 The heads are fixed at the pressure-vessel default and the barrel takes
408 the remainder; a volume smaller than two heads gets a zero barrel rather
411 radius = diameter / 2.0
412 head_volume = 2.0 * (2.0 / 3.0) * math.pi * radius**2 * (radius / 2.0)
413 barrel =
max((volume - head_volume) / (math.pi * radius**2), 0.0)
static volatile uint32_t head
float wetted_area(self, float level)
float cross_section(self, float level)
float volume_below(self, float level)
float volume_filled(self, float depth_filled)
float area_filled(self, float depth_filled)
float cross_section(self, float depth_filled)
float wetted_area(self, float level)
float _interp(self, Sequence[float] table, float level)
float volume_below(self, float level)
float cross_section(self, float level)
float wetted_area(self, float level)
float cross_section(self, float level)
float volume_below(self, float level)
CylindricalTank cylindrical_from_volume(float volume, float diameter)
TankGeometry _cylindrical(float diameter, float barrel_length, float head_ratio=2.0, float|None bottom_head_ratio=None, float|None top_head_ratio=None)
float level_of_volume(TankGeometry geometry, float volume)
None register_geometry(str name, GeometryFactory factory)
TankGeometry build_geometry(str name, **object kwargs)
list[str] registered_geometries()