STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
geometry.py
Go to the documentation of this file.
1"""Tank geometry: how much liquid is in there, and how much surface it shows.
2
3Three numbers come out of a tank's shape and all three matter:
4
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.
9
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.
15
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
18shape is described.
19
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.
24"""
25
26from __future__ import annotations
27
28import math
29from dataclasses import dataclass
30from typing import Callable, Protocol, Sequence, runtime_checkable
31
32from scipy.optimize import brentq
33
34
35@runtime_checkable
36class TankGeometry(Protocol):
37 """A tank shape, described by what a fluid model needs to ask of it."""
38
39 @property
40 def total_volume(self) -> float:
41 """Internal volume [m^3]."""
42 ...
43
44 @property
45 def height(self) -> float:
46 """Inside height from the lowest point to the highest [m]."""
47 ...
48
49 def volume_below(self, level: float) -> float:
50 """Liquid volume [m^3] with the surface at ``level`` above the bottom."""
51 ...
52
53 def cross_section(self, level: float) -> float:
54 """Horizontal area [m^2] at ``level`` -- the liquid surface area."""
55 ...
56
57 def wetted_area(self, level: float) -> float:
58 """Wall area [m^2] below ``level``."""
59 ...
60
61
62def level_of_volume(geometry: TankGeometry, volume: float) -> float:
63 """Invert ``volume_below``: where the surface sits for a given fill [m].
64
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.
69 """
70 total = geometry.total_volume
71 if volume <= 0.0:
72 return 0.0
73 if volume >= total:
74 return geometry.height
75 # The last answer for this shape, kept. A tank asks where its surface is
76 # several times at one fill -- its level, its interface area, its ullage
77 # -- before the fill moves, and each was a root find: ~43,000 of them in
78 # two seconds of burn, a fifth of the console's time. Exact: the same
79 # shape and the same volume, compared by identity and by value.
80 last = _LAST_LEVEL.get(id(geometry))
81 if last is not None and last[0] is geometry and last[1] == volume:
82 return last[2]
83 level = float(
84 brentq(lambda h: geometry.volume_below(h) - volume, 0.0, geometry.height)
85 )
86 if len(_LAST_LEVEL) >= _LAST_LEVEL_SHAPES:
87 _LAST_LEVEL.clear()
88 # The shape is held as well as its id, so an id reused by a later shape
89 # can never be mistaken for this one.
90 _LAST_LEVEL[id(geometry)] = (geometry, volume, level)
91 return level
92
93
94#: The last level worked out for each shape: (shape, volume, level).
95_LAST_LEVEL: dict[int, tuple[TankGeometry, float, float]] = {}
96#: More shapes than any stand has tanks; past it the memo starts again.
97_LAST_LEVEL_SHAPES = 64
98
99
100# ------------------------------------------------------------------- the heads
101
102
103@dataclass(frozen=True, slots=True)
104class Head:
105 """One end cap, described by how far it bulges past the barrel.
106
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.
111 """
112
113 radius: float
114 ratio: float = 2.0
115
116 @staticmethod
117 def flat() -> Head:
118 return Head(radius=0.0, ratio=math.inf)
119
120 @property
121 def depth(self) -> float:
122 """How far the head extends beyond the barrel [m]."""
123 if not math.isfinite(self.ratio) or self.ratio <= 0.0:
124 return 0.0
125 return self.radius / self.ratio
126
127 @property
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
131
132 def volume_filled(self, depth_filled: float) -> float:
133 """Volume [m^3] of the head filled to ``depth_filled`` from its pole.
134
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.
137
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``.
141 """
142 c = self.depth
143 if c <= 0.0:
144 return 0.0
145 d = min(max(depth_filled, 0.0), c)
146 return (
147 math.pi * self.radius * self.radius * d * d * (3.0 * c - d) / (3.0 * c * c)
148 )
149
150 def cross_section(self, depth_filled: float) -> float:
151 """Horizontal area [m^2] at ``depth_filled`` from the pole."""
152 c = self.depth
153 if c <= 0.0:
154 return math.pi * self.radius * self.radius
155 d = min(max(depth_filled, 0.0), c)
156 # Ellipsoid: (x/r)^2 + ((c - d)/c)^2 = 1 at the free surface.
157 frac = 1.0 - ((c - d) / c) ** 2
158 return math.pi * self.radius * self.radius * max(frac, 0.0)
159
160 def area_filled(self, depth_filled: float) -> float:
161 """Wall area [m^2] of the head below ``depth_filled`` from the pole.
162
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.
168 """
169 c = self.depth
170 if c <= 0.0:
171 return 0.0
172 d = min(max(depth_filled, 0.0), c)
173 return 2.0 * math.pi * self.radius * d
174
175
176@dataclass(frozen=True, slots=True)
178 """A cylindrical barrel with a head at each end. The usual tank.
179
180 Args:
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.
185 """
186
187 diameter: float
188 barrel_length: float
189 bottom: Head | None = None
190 top: Head | None = None
191
192 def __post_init__(self) -> 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):
203 raise ValueError(
204 f"{name} head radius {head.radius} does not match the "
205 f"barrel radius {r}; a head is a cap on this barrel"
206 )
207
208 # Narrowing for type checkers: __post_init__ guarantees these are set.
209 @property
210 def _bottom(self) -> Head:
211 assert self.bottom is not None
212 return self.bottom
213
214 @property
215 def _top(self) -> Head:
216 assert self.top is not None
217 return self.top
218
219 @property
220 def barrel_area(self) -> float:
221 return math.pi * self.diameter * self.diameter / 4.0
222
223 @property
224 def total_volume(self) -> float:
225 return (
226 self._bottom.volume
227 + self.barrel_area * self.barrel_length
228 + self._top.volume
229 )
230
231 @property
232 def height(self) -> float:
233 return self._bottom.depth + self.barrel_length + self._top.depth
234
235 def volume_below(self, level: float) -> float:
236 b, t = self._bottom, self._top
237 h = min(max(level, 0.0), self.height)
238 if h <= b.depth:
239 return b.volume_filled(h)
240 if h <= b.depth + self.barrel_length:
241 return b.volume + self.barrel_area * (h - b.depth)
242 # In the top head: total minus the empty cap above the surface.
243 empty_from_pole = self.height - h
244 return self.total_volume - t.volume_filled(empty_from_pole)
245
246 def cross_section(self, level: float) -> float:
247 b, t = self._bottom, self._top
248 h = min(max(level, 0.0), self.height)
249 if h <= b.depth:
250 return b.cross_section(h)
251 if h <= b.depth + self.barrel_length:
252 return self.barrel_area
253 return t.cross_section(self.height - h)
254
255 def wetted_area(self, level: float) -> float:
256 b, t = self._bottom, self._top
257 h = min(max(level, 0.0), self.height)
258 if h <= b.depth:
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
263 return (
264 b.area_filled(b.depth)
265 + barrel
266 + t.area_filled(t.depth)
267 - t.area_filled(self.height - h)
268 )
269
270 def __repr__(self) -> str:
271 return (
272 f"CylindricalTank({self.diameter * 1e3:.0f} mm x "
273 f"{self.barrel_length * 1e3:.0f} mm, {self.total_volume * 1e3:.2f} L)"
274 )
275
276
277@dataclass(frozen=True, slots=True)
279 """A tank described by a measured or CAD-derived *V(h)* table.
280
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.
284
285 Args:
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].
293 """
294
295 levels: Sequence[float]
296 volumes: Sequence[float]
297 areas: Sequence[float] | None = None
298 wall_areas: Sequence[float] | None = None
299
300 def __post_init__(self) -> None:
301 if len(self.levels) < 2:
302 raise ValueError("a V(h) table needs at least two points")
303 if len(self.levels) != len(self.volumes):
304 raise ValueError(
305 f"levels and volumes must be the same length, got "
306 f"{len(self.levels)} and {len(self.volumes)}"
307 )
308 if any(b <= a for a, b in zip(self.levels, self.levels[1:])):
309 raise ValueError("levels must be strictly increasing")
310 if any(b < a for a, b in zip(self.volumes, self.volumes[1:])):
311 raise ValueError(
312 "volumes must be non-decreasing -- a tank cannot hold less "
313 "liquid when the surface goes up"
314 )
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")
319
320 @property
321 def total_volume(self) -> float:
322 return float(self.volumes[-1])
323
324 @property
325 def height(self) -> float:
326 return float(self.levels[-1])
327
328 def _interp(self, table: Sequence[float], level: float) -> float:
329 h = min(max(level, self.levels[0]), self.levels[-1])
330 for i in range(len(self.levels) - 1):
331 h0, h1 = self.levels[i], self.levels[i + 1]
332 if h <= h1:
333 f = (h - h0) / (h1 - h0)
334 return float(table[i] + f * (table[i + 1] - table[i]))
335 return float(table[-1])
336
337 def volume_below(self, level: float) -> float:
338 return self._interp(self.volumes, level)
339
340 def cross_section(self, level: float) -> float:
341 if self.areas is not None:
342 return self._interp(self.areas, level)
343 h = min(max(level, self.levels[0]), self.levels[-1])
344 for i in range(len(self.levels) - 1):
345 h0, h1 = self.levels[i], self.levels[i + 1]
346 if h <= h1:
347 return float((self.volumes[i + 1] - self.volumes[i]) / (h1 - h0))
348 return 0.0
349
350 def wetted_area(self, level: float) -> float:
351 if self.wall_areas is None:
352 return 0.0
353 return self._interp(self.wall_areas, level)
354
355
356#: Builds a geometry from keyword parameters. Registered by name so a shape
357#: nobody anticipated is a registration, not an edit to this module.
358GeometryFactory = Callable[..., TankGeometry]
359
360_GEOMETRIES: dict[str, GeometryFactory] = {}
361
362
363def register_geometry(name: str, factory: GeometryFactory) -> None:
364 _GEOMETRIES[name] = factory
365
366
367def build_geometry(name: str, **kwargs: object) -> TankGeometry:
368 if name not in _GEOMETRIES:
369 raise KeyError(
370 f"unknown tank geometry {name!r}; registered: "
371 f"{', '.join(sorted(_GEOMETRIES))}"
372 )
373 return _GEOMETRIES[name](**kwargs)
374
375
376def registered_geometries() -> list[str]:
377 return sorted(_GEOMETRIES)
378
379
381 diameter: float,
382 barrel_length: float,
383 head_ratio: float = 2.0,
384 bottom_head_ratio: float | None = None,
385 top_head_ratio: float | None = None,
386) -> TankGeometry:
387 r = diameter / 2.0
388
389 def head(ratio: float | None) -> Head:
390 chosen = head_ratio if ratio is None else ratio
391 return (
392 Head.flat() if not math.isfinite(chosen) else Head(radius=r, ratio=chosen)
393 )
394
395 return CylindricalTank(
396 diameter=diameter,
397 barrel_length=barrel_length,
398 bottom=head(bottom_head_ratio),
399 top=head(top_head_ratio),
400 )
401
402
403def cylindrical_from_volume(volume: float, diameter: float) -> CylindricalTank:
404 """A barrel of the right volume at a stated diameter, 2:1 heads.
405
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
409 than a negative one.
410 """
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)
414 return CylindricalTank(diameter=diameter, barrel_length=barrel)
415
416
417register_geometry("cylindrical", _cylindrical)
418register_geometry("tabulated", TabulatedGeometry)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
static volatile uint32_t head
Definition board_uart9.c:11
float wetted_area(self, float level)
Definition geometry.py:255
float cross_section(self, float level)
Definition geometry.py:246
float volume_below(self, float level)
Definition geometry.py:235
float volume_filled(self, float depth_filled)
Definition geometry.py:132
float area_filled(self, float depth_filled)
Definition geometry.py:160
float cross_section(self, float depth_filled)
Definition geometry.py:150
float wetted_area(self, float level)
Definition geometry.py:350
float _interp(self, Sequence[float] table, float level)
Definition geometry.py:328
float volume_below(self, float level)
Definition geometry.py:337
float cross_section(self, float level)
Definition geometry.py:340
float wetted_area(self, float level)
Definition geometry.py:57
float cross_section(self, float level)
Definition geometry.py:53
float volume_below(self, float level)
Definition geometry.py:49
CylindricalTank cylindrical_from_volume(float volume, float diameter)
Definition geometry.py:403
TankGeometry _cylindrical(float diameter, float barrel_length, float head_ratio=2.0, float|None bottom_head_ratio=None, float|None top_head_ratio=None)
Definition geometry.py:386
float level_of_volume(TankGeometry geometry, float volume)
Definition geometry.py:62
None register_geometry(str name, GeometryFactory factory)
Definition geometry.py:363
TankGeometry build_geometry(str name, **object kwargs)
Definition geometry.py:367
list[str] registered_geometries()
Definition geometry.py:376