loadpath 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/flow.py
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flow and heat transfer on voxel grids, by the lattice Boltzmann method.
|
|
3
|
+
|
|
4
|
+
The rating in heatx_thermal.py takes its heat transfer coefficient and
|
|
5
|
+
pressure drop from fits to published or simulated cells, each with its own
|
|
6
|
+
range. This module computes them directly, for any channel geometry that
|
|
7
|
+
can be voxelized:
|
|
8
|
+
|
|
9
|
+
- `cell_flow` runs one periodic cell (a TPMS side, or any fluid mask that
|
|
10
|
+
tiles) driven by a body force along an axis, to fully developed flow, and
|
|
11
|
+
returns Re, the Darcy friction factor and Nu on the hydraulic diameter.
|
|
12
|
+
Heat transfer is the fully developed problem with walls held at T = 0 and
|
|
13
|
+
uniform heating in the fluid (the convention of the 1KZ charge-cooler cells
|
|
14
|
+
the rating's LBM fit came from), or conjugate, with the wall conducting
|
|
15
|
+
and the far side of the wall held at T = 0.
|
|
16
|
+
- `network_flow` runs a whole channel network, ports and plenums included,
|
|
17
|
+
from a velocity inlet to a pressure outlet, and reports the pressure drop,
|
|
18
|
+
how it splits along the path, and how evenly the core's channels share the
|
|
19
|
+
flow.
|
|
20
|
+
|
|
21
|
+
Method: D3Q19 lattice Boltzmann with two-relaxation-time (TRT) collisions
|
|
22
|
+
and a Smagorinsky subgrid viscosity (Hou et al. 1996 effective relaxation
|
|
23
|
+
time, C_s 0.1), so it stays stable well into turbulent Re on cells of 48-96
|
|
24
|
+
voxels. TRT with Lambda = 3/16 puts halfway bounce-back walls exactly
|
|
25
|
+
halfway whatever the viscosity, so a cell's permeability does not move with
|
|
26
|
+
the lattice viscosity as it does under BGK (on a 64-voxel gyroid cell, BGK's
|
|
27
|
+
f Re in creeping flow runs 1% high at tau 0.8 and 4% at tau 0.6, and more
|
|
28
|
+
toward tau 0.5, where high-Re runs live);
|
|
29
|
+
Guo body forcing; halfway bounce-back walls; equilibrium velocity inlets and
|
|
30
|
+
fixed-density outlets. Temperature is a passive scalar on D3Q7 with
|
|
31
|
+
two-relaxation-time collisions (Lambda 1/4) and anti-bounce-back isothermal
|
|
32
|
+
walls. Both run as numba kernels in parallel over x (`pip install numba`,
|
|
33
|
+
the `flow` extra); there is no pure-numpy path, it would be ~100x slower.
|
|
34
|
+
|
|
35
|
+
Lattice units throughout the kernels; Re, f and Nu are dimensionless and the
|
|
36
|
+
physical results (Pa, W/m^2K) come from them in `cell_flow`'s callers.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import time
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
__all__ = ["cell_flow", "tpms_cell", "wetted_area", "network_flow", "exchanger_flow", "available"]
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
import numba as nb
|
|
48
|
+
|
|
49
|
+
_HAVE_NUMBA = True
|
|
50
|
+
except ImportError: # pragma: no cover
|
|
51
|
+
nb = None
|
|
52
|
+
_HAVE_NUMBA = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def available() -> bool:
|
|
56
|
+
return _HAVE_NUMBA
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _need_numba():
|
|
60
|
+
if not _HAVE_NUMBA:
|
|
61
|
+
raise ImportError("loadpath.flow needs numba: pip install numba (or loadpath[flow])")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# D3Q19 velocities, weights and opposites
|
|
65
|
+
C19 = np.array([[0, 0, 0],
|
|
66
|
+
[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1],
|
|
67
|
+
[1, 1, 0], [-1, -1, 0], [1, -1, 0], [-1, 1, 0],
|
|
68
|
+
[1, 0, 1], [-1, 0, -1], [1, 0, -1], [-1, 0, 1],
|
|
69
|
+
[0, 1, 1], [0, -1, -1], [0, 1, -1], [0, -1, 1]], np.int64)
|
|
70
|
+
W19 = np.array([1 / 3] + [1 / 18] * 6 + [1 / 36] * 12)
|
|
71
|
+
OPP19 = np.array([0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15, 18, 17], np.int64)
|
|
72
|
+
C7 = C19[:7].copy()
|
|
73
|
+
W7 = np.array([1 / 4] + [1 / 8] * 6)
|
|
74
|
+
OPP7 = OPP19[:7].copy()
|
|
75
|
+
|
|
76
|
+
# node types
|
|
77
|
+
FLUID, SOLID, INLET, OUTLET = 0, 1, 2, 3
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _kernels():
|
|
81
|
+
"""Compile the kernels on first use (numba import is optional)."""
|
|
82
|
+
if "step" in _K:
|
|
83
|
+
return _K
|
|
84
|
+
_need_numba()
|
|
85
|
+
C, W, OPP = C19, W19, OPP19
|
|
86
|
+
Ct, Wt, OPPt = C7, W7, OPP7
|
|
87
|
+
|
|
88
|
+
@nb.njit(parallel=True, fastmath=True, cache=True)
|
|
89
|
+
def step(f, fn, ntype, tau0, cs2, gx, gy, gz, u_in, rho_out, periodic, rho, ux, uy, uz, lam, nut):
|
|
90
|
+
"""One collide-and-stream step (pull). f holds post-collision populations."""
|
|
91
|
+
nx, ny, nz = ntype.shape
|
|
92
|
+
for x in nb.prange(nx):
|
|
93
|
+
fin = np.empty(19)
|
|
94
|
+
feq = np.empty(19)
|
|
95
|
+
for y in range(ny):
|
|
96
|
+
for z in range(nz):
|
|
97
|
+
t = ntype[x, y, z]
|
|
98
|
+
if t == 1:
|
|
99
|
+
continue
|
|
100
|
+
for i in range(19):
|
|
101
|
+
sx = x - C[i, 0]
|
|
102
|
+
sy = y - C[i, 1]
|
|
103
|
+
sz = z - C[i, 2]
|
|
104
|
+
if periodic[0]:
|
|
105
|
+
sx %= nx
|
|
106
|
+
if periodic[1]:
|
|
107
|
+
sy %= ny
|
|
108
|
+
if periodic[2]:
|
|
109
|
+
sz %= nz
|
|
110
|
+
if sx < 0 or sx >= nx or sy < 0 or sy >= ny or sz < 0 or sz >= nz:
|
|
111
|
+
fin[i] = f[OPP[i], x, y, z]
|
|
112
|
+
elif ntype[sx, sy, sz] == 1:
|
|
113
|
+
fin[i] = f[OPP[i], x, y, z]
|
|
114
|
+
else:
|
|
115
|
+
fin[i] = f[i, sx, sy, sz]
|
|
116
|
+
r = 0.0
|
|
117
|
+
jx = 0.0
|
|
118
|
+
jy = 0.0
|
|
119
|
+
jz = 0.0
|
|
120
|
+
for i in range(19):
|
|
121
|
+
r += fin[i]
|
|
122
|
+
jx += fin[i] * C[i, 0]
|
|
123
|
+
jy += fin[i] * C[i, 1]
|
|
124
|
+
jz += fin[i] * C[i, 2]
|
|
125
|
+
if t == 2 or t == 3:
|
|
126
|
+
# inlet: prescribed velocity, density from the neighbour inward;
|
|
127
|
+
# outlet: fixed density, velocity from the neighbour inward.
|
|
128
|
+
# u_in holds the inlet velocity, or the outlet's inward normal.
|
|
129
|
+
if t == 2:
|
|
130
|
+
vx = u_in[0, x, y, z]
|
|
131
|
+
vy = u_in[1, x, y, z]
|
|
132
|
+
vz = u_in[2, x, y, z]
|
|
133
|
+
ax = abs(vx)
|
|
134
|
+
ay = abs(vy)
|
|
135
|
+
az = abs(vz)
|
|
136
|
+
ox = 0
|
|
137
|
+
oy = 0
|
|
138
|
+
oz = 0
|
|
139
|
+
if ax >= ay and ax >= az:
|
|
140
|
+
ox = 1 if vx > 0 else -1
|
|
141
|
+
elif ay >= az:
|
|
142
|
+
oy = 1 if vy > 0 else -1
|
|
143
|
+
else:
|
|
144
|
+
oz = 1 if vz > 0 else -1
|
|
145
|
+
else:
|
|
146
|
+
ox = int(u_in[0, x, y, z])
|
|
147
|
+
oy = int(u_in[1, x, y, z])
|
|
148
|
+
oz = int(u_in[2, x, y, z])
|
|
149
|
+
vx = jx / r
|
|
150
|
+
vy = jy / r
|
|
151
|
+
vz = jz / r
|
|
152
|
+
qx = x + ox
|
|
153
|
+
qy = y + oy
|
|
154
|
+
qz = z + oz
|
|
155
|
+
if 0 <= qx < nx and 0 <= qy < ny and 0 <= qz < nz and ntype[qx, qy, qz] == 0:
|
|
156
|
+
if t == 2:
|
|
157
|
+
r = rho[qx, qy, qz]
|
|
158
|
+
else:
|
|
159
|
+
vx = ux[qx, qy, qz]
|
|
160
|
+
vy = uy[qx, qy, qz]
|
|
161
|
+
vz = uz[qx, qy, qz]
|
|
162
|
+
if t == 3:
|
|
163
|
+
r = rho_out
|
|
164
|
+
if r <= 0.0:
|
|
165
|
+
r = 1.0
|
|
166
|
+
usq = vx * vx + vy * vy + vz * vz
|
|
167
|
+
for i in range(19):
|
|
168
|
+
cu = C[i, 0] * vx + C[i, 1] * vy + C[i, 2] * vz
|
|
169
|
+
fn[i, x, y, z] = W[i] * r * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq)
|
|
170
|
+
rho[x, y, z] = r
|
|
171
|
+
ux[x, y, z] = vx
|
|
172
|
+
uy[x, y, z] = vy
|
|
173
|
+
uz[x, y, z] = vz
|
|
174
|
+
continue
|
|
175
|
+
Fx = gx * r
|
|
176
|
+
Fy = gy * r
|
|
177
|
+
Fz = gz * r
|
|
178
|
+
vx = (jx + 0.5 * Fx) / r
|
|
179
|
+
vy = (jy + 0.5 * Fy) / r
|
|
180
|
+
vz = (jz + 0.5 * Fz) / r
|
|
181
|
+
usq = vx * vx + vy * vy + vz * vz
|
|
182
|
+
pxx = 0.0
|
|
183
|
+
pyy = 0.0
|
|
184
|
+
pzz = 0.0
|
|
185
|
+
pxy = 0.0
|
|
186
|
+
pxz = 0.0
|
|
187
|
+
pyz = 0.0
|
|
188
|
+
for i in range(19):
|
|
189
|
+
cu = C[i, 0] * vx + C[i, 1] * vy + C[i, 2] * vz
|
|
190
|
+
feq[i] = W[i] * r * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq)
|
|
191
|
+
d = fin[i] - feq[i]
|
|
192
|
+
pxx += d * C[i, 0] * C[i, 0]
|
|
193
|
+
pyy += d * C[i, 1] * C[i, 1]
|
|
194
|
+
pzz += d * C[i, 2] * C[i, 2]
|
|
195
|
+
pxy += d * C[i, 0] * C[i, 1]
|
|
196
|
+
pxz += d * C[i, 0] * C[i, 2]
|
|
197
|
+
pyz += d * C[i, 1] * C[i, 2]
|
|
198
|
+
# the forcing's share of the second moment (Guo)
|
|
199
|
+
pxx += vx * Fx
|
|
200
|
+
pyy += vy * Fy
|
|
201
|
+
pzz += vz * Fz
|
|
202
|
+
pxy += 0.5 * (vx * Fy + vy * Fx)
|
|
203
|
+
pxz += 0.5 * (vx * Fz + vz * Fx)
|
|
204
|
+
pyz += 0.5 * (vy * Fz + vz * Fy)
|
|
205
|
+
Q = np.sqrt(pxx * pxx + pyy * pyy + pzz * pzz + 2.0 * (pxy * pxy + pxz * pxz + pyz * pyz))
|
|
206
|
+
tau = 0.5 * (tau0 + np.sqrt(tau0 * tau0 + 18.0 * 1.41421356 * cs2 * Q / r))
|
|
207
|
+
# TRT: even moments relax at the (subgrid-augmented) viscous rate, odd
|
|
208
|
+
# ones at the rate that puts halfway bounce-back walls exactly halfway
|
|
209
|
+
# (Lambda 3/16), whatever the viscosity
|
|
210
|
+
nut[x, y, z] = (tau - tau0) / 3.0 # subgrid viscosity, for the heat
|
|
211
|
+
op = 1.0 / tau
|
|
212
|
+
om = 1.0 / (0.5 + lam / (tau - 0.5))
|
|
213
|
+
pp = 1.0 - 0.5 * op
|
|
214
|
+
pm = 1.0 - 0.5 * om
|
|
215
|
+
uF = vx * Fx + vy * Fy + vz * Fz
|
|
216
|
+
fn[0, x, y, z] = fin[0] - op * (fin[0] - feq[0]) - pp * W[0] * 3.0 * uF
|
|
217
|
+
for i in range(1, 19, 2):
|
|
218
|
+
j = i + 1
|
|
219
|
+
cu = C[i, 0] * vx + C[i, 1] * vy + C[i, 2] * vz
|
|
220
|
+
cF = C[i, 0] * Fx + C[i, 1] * Fy + C[i, 2] * Fz
|
|
221
|
+
dp = op * (0.5 * (fin[i] + fin[j]) - 0.5 * (feq[i] + feq[j]))
|
|
222
|
+
dm = om * (0.5 * (fin[i] - fin[j]) - 0.5 * (feq[i] - feq[j]))
|
|
223
|
+
sp = pp * W[i] * (9.0 * cu * cF - 3.0 * uF)
|
|
224
|
+
sm = pm * W[i] * 3.0 * cF
|
|
225
|
+
fn[i, x, y, z] = fin[i] - dp - dm + sp + sm
|
|
226
|
+
fn[j, x, y, z] = fin[j] - dp + dm + sp - sm
|
|
227
|
+
rho[x, y, z] = r
|
|
228
|
+
ux[x, y, z] = vx
|
|
229
|
+
uy[x, y, z] = vy
|
|
230
|
+
uz[x, y, z] = vz
|
|
231
|
+
|
|
232
|
+
@nb.njit(parallel=True, fastmath=True, cache=True)
|
|
233
|
+
def step_t(g, gn, tnode, ux, uy, uz, alpha, tfix, qsrc, qvel, periodic, T, nut, prt_inv):
|
|
234
|
+
"""D3Q7 TRT advection-diffusion. tnode: 0 active (fluid or conducting
|
|
235
|
+
solid), 1 adiabatic wall, 2 isothermal wall at tfix (anti-bounce-back,
|
|
236
|
+
halfway), 3 fixed-temperature node (inlet), 4 zero-gradient outlet.
|
|
237
|
+
The source is qsrc + qvel . u per node (qvel: the flow-weighted
|
|
238
|
+
heating of a fully developed mean axial gradient)."""
|
|
239
|
+
nx, ny, nz = tnode.shape
|
|
240
|
+
for x in nb.prange(nx):
|
|
241
|
+
gin = np.empty(7)
|
|
242
|
+
for y in range(ny):
|
|
243
|
+
for z in range(nz):
|
|
244
|
+
t = tnode[x, y, z]
|
|
245
|
+
if t == 1 or t == 2:
|
|
246
|
+
continue
|
|
247
|
+
for i in range(7):
|
|
248
|
+
sx = x - Ct[i, 0]
|
|
249
|
+
sy = y - Ct[i, 1]
|
|
250
|
+
sz = z - Ct[i, 2]
|
|
251
|
+
if periodic[0]:
|
|
252
|
+
sx %= nx
|
|
253
|
+
if periodic[1]:
|
|
254
|
+
sy %= ny
|
|
255
|
+
if periodic[2]:
|
|
256
|
+
sz %= nz
|
|
257
|
+
if sx < 0 or sx >= nx or sy < 0 or sy >= ny or sz < 0 or sz >= nz:
|
|
258
|
+
gin[i] = g[OPPt[i], x, y, z]
|
|
259
|
+
else:
|
|
260
|
+
s = tnode[sx, sy, sz]
|
|
261
|
+
if s == 1:
|
|
262
|
+
gin[i] = g[OPPt[i], x, y, z]
|
|
263
|
+
elif s == 2:
|
|
264
|
+
gin[i] = -g[OPPt[i], x, y, z] + 2.0 * Wt[i] * tfix[sx, sy, sz]
|
|
265
|
+
else:
|
|
266
|
+
gin[i] = g[i, sx, sy, sz]
|
|
267
|
+
Tl = 0.0
|
|
268
|
+
for i in range(7):
|
|
269
|
+
Tl += gin[i]
|
|
270
|
+
if t == 3:
|
|
271
|
+
Tl = tfix[x, y, z]
|
|
272
|
+
vx = ux[x, y, z]
|
|
273
|
+
vy = uy[x, y, z]
|
|
274
|
+
vz = uz[x, y, z]
|
|
275
|
+
if t == 3 or t == 4:
|
|
276
|
+
for i in range(7):
|
|
277
|
+
cu = Ct[i, 0] * vx + Ct[i, 1] * vy + Ct[i, 2] * vz
|
|
278
|
+
gn[i, x, y, z] = Wt[i] * Tl * (1.0 + 4.0 * cu)
|
|
279
|
+
T[x, y, z] = Tl
|
|
280
|
+
continue
|
|
281
|
+
# odd moments set the diffusivity: molecular plus the flow's subgrid
|
|
282
|
+
# viscosity over a turbulent Prandtl number
|
|
283
|
+
tm = 0.5 + 4.0 * (alpha[x, y, z] + nut[x, y, z] * prt_inv)
|
|
284
|
+
tp = 0.5 + 0.25 / (tm - 0.5) # Lambda = 1/4
|
|
285
|
+
om = 1.0 / tm
|
|
286
|
+
op = 1.0 / tp
|
|
287
|
+
S = qsrc[x, y, z] + qvel[0] * vx + qvel[1] * vy + qvel[2] * vz
|
|
288
|
+
e0 = Wt[0] * Tl
|
|
289
|
+
gn[0, x, y, z] = gin[0] - op * (gin[0] - e0) + Wt[0] * S
|
|
290
|
+
for i in range(1, 7, 2):
|
|
291
|
+
j = i + 1
|
|
292
|
+
cu = Ct[i, 0] * vx + Ct[i, 1] * vy + Ct[i, 2] * vz
|
|
293
|
+
ep = Wt[i] * Tl
|
|
294
|
+
em = Wt[i] * Tl * 4.0 * cu
|
|
295
|
+
gp = 0.5 * (gin[i] + gin[j])
|
|
296
|
+
gm = 0.5 * (gin[i] - gin[j])
|
|
297
|
+
dp = op * (gp - ep)
|
|
298
|
+
dm = om * (gm - em)
|
|
299
|
+
gn[i, x, y, z] = gin[i] - dp - dm + Wt[i] * S
|
|
300
|
+
gn[j, x, y, z] = gin[j] - dp + dm + Wt[j] * S
|
|
301
|
+
T[x, y, z] = Tl
|
|
302
|
+
|
|
303
|
+
_K["step"] = step
|
|
304
|
+
_K["step_t"] = step_t
|
|
305
|
+
return _K
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
_K = {}
|
|
309
|
+
LAMBDA = 3.0 / 16.0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _equilibrium(rho, ux, uy, uz):
|
|
313
|
+
f = np.empty((19,) + rho.shape, np.float32)
|
|
314
|
+
usq = ux * ux + uy * uy + uz * uz
|
|
315
|
+
for i in range(19):
|
|
316
|
+
cu = C19[i, 0] * ux + C19[i, 1] * uy + C19[i, 2] * uz
|
|
317
|
+
f[i] = W19[i] * rho * (1 + 3 * cu + 4.5 * cu * cu - 1.5 * usq)
|
|
318
|
+
return f
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _equilibrium_t(T, ux, uy, uz):
|
|
322
|
+
g = np.empty((7,) + T.shape, np.float32)
|
|
323
|
+
for i in range(7):
|
|
324
|
+
cu = C7[i, 0] * ux + C7[i, 1] * uy + C7[i, 2] * uz
|
|
325
|
+
g[i] = W7[i] * T * (1 + 4 * cu)
|
|
326
|
+
return g
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------- geometry
|
|
330
|
+
|
|
331
|
+
def wetted_area(field: np.ndarray, h: float, periodic: bool = True) -> float:
|
|
332
|
+
"""Area (h units squared) of the zero level of `field` (positive in the
|
|
333
|
+
fluid) over the box, by marching cubes; periodic wraps the box so the
|
|
334
|
+
faces close."""
|
|
335
|
+
from skimage.measure import marching_cubes
|
|
336
|
+
|
|
337
|
+
d = np.pad(field, 1, mode="wrap" if periodic else "edge").astype(np.float32)
|
|
338
|
+
v, fc, _, _ = marching_cubes(d, 0.0, spacing=(h, h, h))
|
|
339
|
+
a, b, c = v[fc[:, 0]], v[fc[:, 1]], v[fc[:, 2]]
|
|
340
|
+
cen = (a + b + c) / 3.0
|
|
341
|
+
n = np.array(field.shape) * h
|
|
342
|
+
inside = np.all((cen >= h) & (cen < h + n), axis=1)
|
|
343
|
+
return float(0.5 * np.linalg.norm(np.cross(b - a, c - a), axis=1)[inside].sum())
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def tpms_cell(kind: str = "gyroid", cell: float = 10.0, wall: float = 1.0, split: float = 0.5,
|
|
347
|
+
side: str = "a", n: int = 64):
|
|
348
|
+
"""One fluid's side of a sheet TPMS over one periodic cell, on n^3 voxels:
|
|
349
|
+
(fluid mask, voxel size mm, signed distance into the fluid in mm, wall
|
|
350
|
+
signed distance (positive in the wall)). The sheet sits where heatx puts
|
|
351
|
+
it: moved off the mid-surface so A has `split` of the void."""
|
|
352
|
+
from .heatx_thermal import sheet_offset
|
|
353
|
+
from .lattice import tpms
|
|
354
|
+
|
|
355
|
+
c = sheet_offset(lambda p: tpms(kind, p, 1.0), wall / cell, split) * cell
|
|
356
|
+
h = cell / n
|
|
357
|
+
ax = (np.arange(n) + 0.5) * h
|
|
358
|
+
pts = np.stack(np.meshgrid(ax, ax, ax, indexing="ij"), -1).reshape(-1, 3)
|
|
359
|
+
d = tpms(kind, pts, cell).reshape(n, n, n) - c
|
|
360
|
+
if side == "a":
|
|
361
|
+
sdf = d - 0.5 * wall
|
|
362
|
+
elif side == "b":
|
|
363
|
+
sdf = -d - 0.5 * wall
|
|
364
|
+
else:
|
|
365
|
+
raise ValueError("side is 'a' or 'b'")
|
|
366
|
+
wall_sd = 0.5 * wall - np.abs(d)
|
|
367
|
+
return sdf > 0, h, sdf, wall_sd
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------- periodic cell
|
|
371
|
+
|
|
372
|
+
def _window(u, length, chunk, passes):
|
|
373
|
+
"""Chunks in an averaging window: `passes` flow-throughs of the cell at
|
|
374
|
+
interstitial velocity u, from 4 to 200 chunks (a creeping flow's
|
|
375
|
+
flow-through is far longer than it takes to settle)."""
|
|
376
|
+
return int(np.clip(np.ceil(passes * length / max(abs(u), 1e-5) / chunk), 4, 200))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _settled(hist, w, tol):
|
|
380
|
+
"""The last window's mean within tol of the one before (a flow that
|
|
381
|
+
settles to unsteady but statistically steady state passes too)."""
|
|
382
|
+
if len(hist) < 2 * w:
|
|
383
|
+
return False
|
|
384
|
+
a, b = np.mean(hist[-w:]), np.mean(hist[-2 * w:-w])
|
|
385
|
+
return abs(a - b) < tol * abs(a)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _estimate_force(re, nu, dh):
|
|
389
|
+
"""Body force (lattice) that drives a gyroid side to about `re`: Darcy
|
|
390
|
+
friction from the LBM cells' trend, f_D ~ 2.4 + 64/Re (laminar onset)."""
|
|
391
|
+
u = re * nu / dh
|
|
392
|
+
fd = 2.3 + 75.0 / max(re, 1.0)
|
|
393
|
+
return fd * u * u / (2.0 * dh), u
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def cell_flow(fluid: np.ndarray, h: float = 1.0, axis: int = 0, re=(100.0,), *, pr: float = 0.71,
|
|
397
|
+
sdf: np.ndarray | None = None, wall: np.ndarray | None = None, k_ratio: float = 0.0,
|
|
398
|
+
u_max: float = 0.06, nu_min: float = 0.0008, nu_max: float = 0.05, cs: float = 0.1,
|
|
399
|
+
heat: bool = True, heating: str = "uniform", prt: float | None = None, tol: float = 5e-3,
|
|
400
|
+
passes: float = 4.0, develop: float = 15.0, max_steps: int = 300000, chunk: int = 250,
|
|
401
|
+
forces=None, nu=None, verbose: bool = False) -> dict:
|
|
402
|
+
"""Fully developed flow and heat transfer in one periodic cell.
|
|
403
|
+
|
|
404
|
+
fluid: boolean mask (True = fluid) that tiles along all three axes;
|
|
405
|
+
h: voxel size (any unit; results are dimensionless); axis: flow
|
|
406
|
+
direction; re: target Reynolds numbers on Dh and the interstitial
|
|
407
|
+
velocity (each run lands near its target: the body force comes from a
|
|
408
|
+
friction estimate, and the achieved Re is what is reported). Pass
|
|
409
|
+
`forces` (lattice) and `nu` instead to set the drive exactly.
|
|
410
|
+
|
|
411
|
+
Heat: walls at T = 0 and the fluid heated, either uniformly
|
|
412
|
+
(heating "uniform", the convention of the LBM cells the rating's fit
|
|
413
|
+
came from) or in proportion to its axial velocity ("axial": the fully
|
|
414
|
+
developed state under a mean temperature gradient along the flow, the
|
|
415
|
+
textbook convention, with wall temperature uniform around each section);
|
|
416
|
+
Nu on the velocity-weighted bulk temperature. For a conjugate wall, give `wall`
|
|
417
|
+
(positive inside the wall, e.g. tpms_cell's 4th item) and k_ratio =
|
|
418
|
+
k_wall / k_fluid: the wall conducts (its diffusivity is k_ratio times
|
|
419
|
+
the fluid's: right at steady state, where heat capacity drops out), and
|
|
420
|
+
the far side of the wall (where `wall` < 0 outside this fluid) stays at
|
|
421
|
+
T = 0. Nu then is the overall coefficient to the far face, wall included.
|
|
422
|
+
prt: turbulent Prandtl number for a subgrid heat diffusivity (the LES
|
|
423
|
+
eddy viscosity over prt, e.g. 0.85, added to the fluid's; default off).
|
|
424
|
+
Past Re ~2,000 the heat lattice is unstable without it, but at 64 voxels
|
|
425
|
+
a cell it dominates: on a 10 mm air cell at Re ~1,300 it lifts Nu from
|
|
426
|
+
38 to 64. Turbulent Nu needs finer cells (and a wall-adapting subgrid
|
|
427
|
+
model) before it is a number to design on.
|
|
428
|
+
|
|
429
|
+
Converged when, after `develop` flow-throughs of the cell, the mean over
|
|
430
|
+
the last `passes` flow-throughs (and Tb's, with heat) is within `tol` of
|
|
431
|
+
the window before. Flows past Re ~200 in a gyroid do not settle: on a
|
|
432
|
+
10 mm air cell at Re ~250 the flow swings a few percent about one state
|
|
433
|
+
for ~13 flow-throughs, then may break down into a more chaotic one with
|
|
434
|
+
~25% more friction (and back: one cell has more than one long-lived
|
|
435
|
+
state, so f scatters ~20% between runs from Re ~200 to ~1,500), and a
|
|
436
|
+
short stop window catches either. "unsteadiness" is the rms of the flow over the last
|
|
437
|
+
window, over its mean. Successive drives start from the previous one's
|
|
438
|
+
flow.
|
|
439
|
+
|
|
440
|
+
The lattice viscosity is chosen so the interstitial velocity stays
|
|
441
|
+
under u_max (Mach), never below nu_min (the LES carries the rest).
|
|
442
|
+
Returns {"phi", "areaDensity" (1/h), "dh" (h), "points": [{re, f, nu,
|
|
443
|
+
j (Colburn), ...}]}."""
|
|
444
|
+
K = _kernels()
|
|
445
|
+
fluid = np.ascontiguousarray(fluid, bool)
|
|
446
|
+
shape = fluid.shape
|
|
447
|
+
phi = float(fluid.mean())
|
|
448
|
+
area = wetted_area(sdf, h) if sdf is not None else wetted_area(fluid.astype(np.float32) - 0.5, h)
|
|
449
|
+
beta = area / (np.prod(shape) * h ** 3)
|
|
450
|
+
dh = 4.0 * phi / beta
|
|
451
|
+
dh_lu = dh / h
|
|
452
|
+
ntype = np.where(fluid, FLUID, SOLID).astype(np.int8)
|
|
453
|
+
per = np.array([1, 1, 1], np.int64)
|
|
454
|
+
uin = np.zeros((3,) + shape, np.float32)
|
|
455
|
+
nfl = int(fluid.sum())
|
|
456
|
+
nut = np.zeros(shape, np.float32)
|
|
457
|
+
prt_inv = 1.0 / prt if prt else 0.0
|
|
458
|
+
|
|
459
|
+
if heat:
|
|
460
|
+
tnode = np.where(fluid, 0, 2).astype(np.int8)
|
|
461
|
+
if wall is not None and k_ratio > 0:
|
|
462
|
+
tnode[(~fluid) & (wall > 0)] = 0
|
|
463
|
+
tfix = np.zeros(shape, np.float32)
|
|
464
|
+
runs = []
|
|
465
|
+
if forces is not None:
|
|
466
|
+
if nu is None:
|
|
467
|
+
raise ValueError("forces need nu")
|
|
468
|
+
runs = [(float(g), float(nu), None) for g in forces]
|
|
469
|
+
else:
|
|
470
|
+
for r in np.atleast_1d(re):
|
|
471
|
+
# the fastest flow Mach allows, unless that needs a viscosity under nu_min (then the
|
|
472
|
+
# flow runs faster) or over nu_max (then slower)
|
|
473
|
+
nu_lu = float(np.clip(u_max * dh_lu / float(r), nu_min, nu_max))
|
|
474
|
+
g, _ = _estimate_force(float(r), nu_lu, dh_lu)
|
|
475
|
+
runs.append((g, nu_lu, float(r)))
|
|
476
|
+
|
|
477
|
+
out = []
|
|
478
|
+
prev = None
|
|
479
|
+
for g, nu_lu, r_target in runs:
|
|
480
|
+
t0 = time.time()
|
|
481
|
+
tau0 = 0.5 + 3.0 * nu_lu
|
|
482
|
+
alpha_f = nu_lu / pr
|
|
483
|
+
g3 = [0.0, 0.0, 0.0]
|
|
484
|
+
g3[axis] = g
|
|
485
|
+
if prev is None:
|
|
486
|
+
rho = np.ones(shape, np.float32)
|
|
487
|
+
u = [np.zeros(shape, np.float32) for _ in range(3)]
|
|
488
|
+
f = _equilibrium(rho, *u)
|
|
489
|
+
fn = f.copy()
|
|
490
|
+
else: # the next drive starts from the last one's flow
|
|
491
|
+
f, fn, rho, u = prev
|
|
492
|
+
|
|
493
|
+
def run(n_steps, f, fn):
|
|
494
|
+
for _ in range(n_steps):
|
|
495
|
+
K["step"](f, fn, ntype, tau0, cs * cs, g3[0], g3[1], g3[2], uin, 1.0, per, rho, u[0], u[1], u[2], LAMBDA, nut)
|
|
496
|
+
f, fn = fn, f
|
|
497
|
+
return f, fn
|
|
498
|
+
|
|
499
|
+
hist = []
|
|
500
|
+
it = 0
|
|
501
|
+
flow_ok = False
|
|
502
|
+
while it < max_steps:
|
|
503
|
+
f, fn = run(chunk, f, fn)
|
|
504
|
+
it += chunk
|
|
505
|
+
m = float(u[axis].mean())
|
|
506
|
+
if not np.isfinite(m):
|
|
507
|
+
break
|
|
508
|
+
hist.append(m)
|
|
509
|
+
through = shape[axis] / max(abs(m / phi), 1e-5) # steps per flow-through
|
|
510
|
+
w = _window(m / phi, shape[axis], chunk, passes)
|
|
511
|
+
# a flow that has gone steady needs no long development; an unsteady one may yet
|
|
512
|
+
# change state, so it runs `develop` flow-throughs first
|
|
513
|
+
steady = len(hist) >= w and np.std(hist[-w:]) < 1e-4 * abs(m)
|
|
514
|
+
if (steady or it >= develop * through) and _settled(hist, w, tol):
|
|
515
|
+
flow_ok = True
|
|
516
|
+
break
|
|
517
|
+
rec = {"force": g, "nuLattice": nu_lu, "reTarget": r_target}
|
|
518
|
+
if not np.isfinite(hist[-1] if hist else np.nan):
|
|
519
|
+
rec.update({"re": float("nan"), "f": float("nan"), "nu": float("nan"), "ok": False,
|
|
520
|
+
"error": "the lattice went unstable"})
|
|
521
|
+
out.append(rec)
|
|
522
|
+
prev = None
|
|
523
|
+
continue
|
|
524
|
+
nu_v = float("nan")
|
|
525
|
+
if heat:
|
|
526
|
+
alpha = np.where(fluid, alpha_f, alpha_f * k_ratio).astype(np.float32)
|
|
527
|
+
q = 1e-5
|
|
528
|
+
qvel = np.zeros(3)
|
|
529
|
+
if heating == "uniform":
|
|
530
|
+
qsrc = np.where(fluid, q, 0.0).astype(np.float32)
|
|
531
|
+
elif heating == "axial":
|
|
532
|
+
# a mean temperature gradient along the flow: each fluid node heats in
|
|
533
|
+
# proportion to its axial velocity (net q per fluid node, as above)
|
|
534
|
+
qsrc = np.zeros(shape, np.float32)
|
|
535
|
+
qvel[axis] = q * phi / float(u[axis].mean())
|
|
536
|
+
else:
|
|
537
|
+
raise ValueError("heating is 'uniform' or 'axial'")
|
|
538
|
+
T = np.zeros(shape, np.float32)
|
|
539
|
+
gt = _equilibrium_t(T, *u)
|
|
540
|
+
gtn = gt.copy()
|
|
541
|
+
tb_hist, u_hist = [], []
|
|
542
|
+
heat_ok = False
|
|
543
|
+
n_t = 0
|
|
544
|
+
while n_t < max_steps:
|
|
545
|
+
for _ in range(chunk):
|
|
546
|
+
K["step"](f, fn, ntype, tau0, cs * cs, g3[0], g3[1], g3[2], uin, 1.0, per, rho, u[0], u[1], u[2], LAMBDA, nut)
|
|
547
|
+
f, fn = fn, f
|
|
548
|
+
K["step_t"](gt, gtn, tnode, u[0], u[1], u[2], alpha, tfix, qsrc, qvel, per, T, nut, prt_inv)
|
|
549
|
+
gt, gtn = gtn, gt
|
|
550
|
+
n_t += chunk
|
|
551
|
+
ua = u[axis]
|
|
552
|
+
tb_hist.append(float((T * ua)[fluid].sum() / ua[fluid].sum()))
|
|
553
|
+
u_hist.append(float(ua.mean()))
|
|
554
|
+
if not (np.isfinite(tb_hist[-1]) and np.isfinite(u_hist[-1])):
|
|
555
|
+
break
|
|
556
|
+
w = _window(u_hist[-1] / phi, shape[axis], chunk, passes)
|
|
557
|
+
if _settled(tb_hist, w, tol) and _settled(u_hist, w, tol):
|
|
558
|
+
heat_ok = True
|
|
559
|
+
break
|
|
560
|
+
it += n_t
|
|
561
|
+
if not (np.isfinite(tb_hist[-1]) and np.isfinite(u_hist[-1])):
|
|
562
|
+
rec.update({"re": float("nan"), "f": float("nan"), "nu": float("nan"), "ok": False,
|
|
563
|
+
"error": "the lattice went unstable with heat on"})
|
|
564
|
+
out.append(rec)
|
|
565
|
+
prev = None
|
|
566
|
+
continue
|
|
567
|
+
tb = float(np.mean(tb_hist[-w:]))
|
|
568
|
+
hc = q * nfl / (beta * h * np.prod(shape) * tb) # per wetted area, lattice units
|
|
569
|
+
nu_v = hc * dh_lu / alpha_f
|
|
570
|
+
hist = u_hist
|
|
571
|
+
else:
|
|
572
|
+
heat_ok = True
|
|
573
|
+
w = _window(hist[-1] / phi, shape[axis], chunk, passes)
|
|
574
|
+
last = np.asarray(hist[-w:])
|
|
575
|
+
U = float(last.mean())
|
|
576
|
+
rec["unsteadiness"] = float(last.std() / abs(U))
|
|
577
|
+
prev = (f, fn, rho, u)
|
|
578
|
+
ui = U / phi
|
|
579
|
+
re_v = ui * dh_lu / nu_lu
|
|
580
|
+
fd = 2.0 * g * dh_lu / ui ** 2
|
|
581
|
+
rec.update({"re": float(re_v), "f": float(fd), "nu": float(nu_v),
|
|
582
|
+
"j": float(nu_v / (re_v * pr ** (1.0 / 3.0))) if heat else None,
|
|
583
|
+
"mach": float(ui * np.sqrt(3.0)), "uMax": float(np.abs(u[axis]).max() * np.sqrt(3.0)),
|
|
584
|
+
"steps": it, "seconds": round(time.time() - t0, 1), "ok": bool(flow_ok and heat_ok)})
|
|
585
|
+
if verbose:
|
|
586
|
+
print(f" Re {re_v:8.1f} f_D {fd:7.4f} Nu {nu_v:7.2f} ({it} steps, {rec['seconds']:.0f} s)", flush=True)
|
|
587
|
+
out.append(rec)
|
|
588
|
+
return {"phi": phi, "areaDensity": float(beta), "dh": float(dh), "n": list(shape), "pr": pr,
|
|
589
|
+
"kRatio": k_ratio, "heating": heating if heat else None, "points": out}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ---------------------------------------------------------------- whole networks
|
|
593
|
+
|
|
594
|
+
def _port_slice(fluid, h, origin, point, axis, radius):
|
|
595
|
+
"""The fluid voxels at a port's open end: within `radius` of the port's
|
|
596
|
+
axis, in the last fluid layer along `axis` (pointing out of the part)
|
|
597
|
+
within two voxels of `point`. Returns (mask, integer inward normal)."""
|
|
598
|
+
axis = np.asarray(axis, float)
|
|
599
|
+
axis /= np.linalg.norm(axis)
|
|
600
|
+
k = int(np.argmax(np.abs(axis)))
|
|
601
|
+
inward = np.zeros(3, int)
|
|
602
|
+
inward[k] = -int(np.sign(axis[k]))
|
|
603
|
+
idx = np.argwhere(fluid)
|
|
604
|
+
P = origin + idx * h
|
|
605
|
+
q = P - np.asarray(point, float)
|
|
606
|
+
along = q @ axis
|
|
607
|
+
radial = np.linalg.norm(q - along[:, None] * axis, axis=1)
|
|
608
|
+
near = (radial <= radius + 0.5 * h) & (np.abs(along) <= 3.0 * h + radius)
|
|
609
|
+
if not near.any():
|
|
610
|
+
raise ValueError(f"no fluid within {radius:g} mm of the port at {np.round(point, 2).tolist()}")
|
|
611
|
+
cand = idx[near]
|
|
612
|
+
# the outermost voxel of each column along the port axis
|
|
613
|
+
out = np.zeros(fluid.shape, bool)
|
|
614
|
+
o = [a for a in range(3) if a != k]
|
|
615
|
+
key = cand[:, o[0]] * fluid.shape[o[1]] + cand[:, o[1]]
|
|
616
|
+
order = np.argsort(cand[:, k] * inward[k]) # outermost first
|
|
617
|
+
seen = set()
|
|
618
|
+
for i in order:
|
|
619
|
+
if key[i] in seen:
|
|
620
|
+
continue
|
|
621
|
+
seen.add(key[i])
|
|
622
|
+
out[tuple(cand[i])] = True
|
|
623
|
+
return out, inward
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _boundary_flux_mean(mask_a, mask_b, value, weight):
|
|
627
|
+
"""Weighted mean of `value` over voxels of A that touch B (6-neighbour)."""
|
|
628
|
+
from scipy import ndimage
|
|
629
|
+
|
|
630
|
+
touch = mask_a & ndimage.binary_dilation(mask_b)
|
|
631
|
+
w = weight[touch]
|
|
632
|
+
if not touch.any() or w.sum() <= 0:
|
|
633
|
+
return float("nan"), 0
|
|
634
|
+
return float((value[touch] * w).sum() / w.sum()), int(touch.sum())
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def network_flow(fluid: np.ndarray, h: float, inlet: dict, outlet: dict, *, medium="water", flow_lpm=None,
|
|
638
|
+
mass_flow=None, regions: np.ndarray | None = None, region_names=None, core: np.ndarray | None = None,
|
|
639
|
+
core_axis=None, cell: float | None = None, heat: bool = False, prt: float | None = None, u_port: float = 0.05,
|
|
640
|
+
nu_min: float = 0.0003, cs: float = 0.1, tol: float = 1e-2, max_steps: int = 300000,
|
|
641
|
+
chunk: int = 500, ramp_steps: int = 4000, develop: float = 3.0, origin=(0.0, 0.0, 0.0),
|
|
642
|
+
fields: bool = False, verbose: bool = False) -> dict:
|
|
643
|
+
"""Steady flow through one channel network, port to port.
|
|
644
|
+
|
|
645
|
+
fluid: boolean voxel mask of the network (True = fluid), voxel size h
|
|
646
|
+
(mm), the centre of voxel (0,0,0) at `origin` (mm). inlet / outlet:
|
|
647
|
+
{"point": the port's open end (mm), "axis": pointing out of the part,
|
|
648
|
+
"radius": mm}. The flow (`flow_lpm` L/min or `mass_flow` kg/s of
|
|
649
|
+
`medium`, a heatx_thermal fluid name or Fluid) enters as a plug at the
|
|
650
|
+
inlet and leaves at fixed pressure.
|
|
651
|
+
|
|
652
|
+
The lattice runs at the physical Reynolds number when it can: the
|
|
653
|
+
viscosity is set from the port velocity u_port (lattice, Mach ~0.09);
|
|
654
|
+
where that would need a viscosity under nu_min the port runs faster
|
|
655
|
+
(reported as "mach") and, past Mach 0.3, at a lower Re ("reScale").
|
|
656
|
+
|
|
657
|
+
regions: optional int labels (one per voxel, e.g. port in, plenum in,
|
|
658
|
+
core, plenum out, port out, in path order) with `region_names`; the
|
|
659
|
+
report then splits the pressure drop along the path, from the
|
|
660
|
+
flow-weighted total pressure where each region meets the next.
|
|
661
|
+
core: optional mask of the core's voxels, and core_axis its mean flow
|
|
662
|
+
direction (default: the mean velocity there), for the flow-sharing
|
|
663
|
+
measure: the flow through the core's mid-section on cell-sized bins.
|
|
664
|
+
|
|
665
|
+
Runs `develop` residence times (the network's volume over the inflow)
|
|
666
|
+
and on until the inlet pressure's mean over one residence time is within
|
|
667
|
+
`tol` of the one before; what it reports comes from fields averaged over
|
|
668
|
+
the last residence times, since a port's jet into a plenum never goes
|
|
669
|
+
steady. fields: also return those averaged fields (m/s, Pa).
|
|
670
|
+
|
|
671
|
+
heat: also run temperature, with the core's walls held at 0 and the
|
|
672
|
+
inlet at 1 (every other wall adiabatic): the effectiveness of this side
|
|
673
|
+
alone against a wall at a fixed temperature, so NTU = -ln(1 - eff) and
|
|
674
|
+
the core's mean h = NTU * m cp / A_wet."""
|
|
675
|
+
from .heatx_thermal import FLUIDS, Fluid
|
|
676
|
+
|
|
677
|
+
K = _kernels()
|
|
678
|
+
fl = medium if isinstance(medium, Fluid) else FLUIDS[medium]
|
|
679
|
+
fluid = np.pad(np.asarray(fluid, bool), 1)
|
|
680
|
+
origin = np.asarray(origin, float) - h
|
|
681
|
+
pad = (lambda a: None if a is None else np.pad(a, 1))
|
|
682
|
+
regions, core = pad(regions), pad(core)
|
|
683
|
+
shape = fluid.shape
|
|
684
|
+
if mass_flow is None:
|
|
685
|
+
if flow_lpm is None:
|
|
686
|
+
raise ValueError("give flow_lpm or mass_flow")
|
|
687
|
+
mass_flow = flow_lpm / 60000.0 * fl.rho
|
|
688
|
+
qv = mass_flow / fl.rho # m^3/s
|
|
689
|
+
|
|
690
|
+
m_in, n_in = _port_slice(fluid, h, origin, inlet["point"], inlet["axis"], inlet["radius"])
|
|
691
|
+
m_out, n_out = _port_slice(fluid, h, origin, outlet["point"], outlet["axis"], outlet["radius"])
|
|
692
|
+
a_in = int(m_in.sum())
|
|
693
|
+
u_phys = qv / (a_in * (h * 1e-3) ** 2) # plug velocity over the inlet voxels
|
|
694
|
+
nu_phys = fl.mu / fl.rho
|
|
695
|
+
re_h = u_phys * h * 1e-3 / nu_phys # Re on one voxel
|
|
696
|
+
u_lu = u_port
|
|
697
|
+
nu_lu = u_lu / re_h
|
|
698
|
+
if nu_lu < nu_min:
|
|
699
|
+
nu_lu = nu_min
|
|
700
|
+
u_lu = nu_lu * re_h
|
|
701
|
+
re_scale = 1.0
|
|
702
|
+
if u_lu > 0.17: # Mach 0.3
|
|
703
|
+
re_scale = 0.17 / u_lu
|
|
704
|
+
u_lu = 0.17
|
|
705
|
+
dt = nu_lu * (h * 1e-3) ** 2 / (nu_phys / re_scale) # s per step at the simulated Re
|
|
706
|
+
vel = (h * 1e-3) / dt # m/s per lattice velocity
|
|
707
|
+
|
|
708
|
+
ntype = np.where(fluid, FLUID, SOLID).astype(np.int8)
|
|
709
|
+
ntype[m_in] = INLET
|
|
710
|
+
ntype[m_out] = OUTLET
|
|
711
|
+
uin = np.zeros((3,) + shape, np.float32)
|
|
712
|
+
for k in range(3):
|
|
713
|
+
uin[k][m_in] = n_in[k] * u_lu
|
|
714
|
+
uin[k][m_out] = n_out[k]
|
|
715
|
+
per = np.zeros(3, np.int64)
|
|
716
|
+
tau0 = 0.5 + 3.0 * nu_lu
|
|
717
|
+
nut = np.zeros(shape, np.float32)
|
|
718
|
+
prt_inv = 1.0 / prt if prt else 0.0
|
|
719
|
+
rho = np.ones(shape, np.float32)
|
|
720
|
+
u = [np.zeros(shape, np.float32) for _ in range(3)]
|
|
721
|
+
f = _equilibrium(rho, *u)
|
|
722
|
+
fn = f.copy()
|
|
723
|
+
|
|
724
|
+
def run(n, f, fn):
|
|
725
|
+
for _ in range(n):
|
|
726
|
+
K["step"](f, fn, ntype, tau0, cs * cs, 0.0, 0.0, 0.0, uin, 1.0, per, rho, u[0], u[1], u[2], LAMBDA, nut)
|
|
727
|
+
f, fn = fn, f
|
|
728
|
+
return f, fn
|
|
729
|
+
|
|
730
|
+
t0 = time.time()
|
|
731
|
+
hist = []
|
|
732
|
+
it = 0
|
|
733
|
+
ok = False
|
|
734
|
+
ramp = max(int(ramp_steps), 1)
|
|
735
|
+
residence = float(fluid.sum()) / (u_lu * a_in) # steps for the inflow to fill the network
|
|
736
|
+
acc_r = np.zeros(shape)
|
|
737
|
+
acc_u = np.zeros((3,) + shape)
|
|
738
|
+
n_acc = 0
|
|
739
|
+
while it < max_steps:
|
|
740
|
+
if it < ramp:
|
|
741
|
+
# start gently: an impulsive plug into still fluid near tau 0.5 throws a pressure
|
|
742
|
+
# wave that blows the lattice up
|
|
743
|
+
fac = min(1.0, (it + 100) / ramp)
|
|
744
|
+
for k in range(3):
|
|
745
|
+
uin[k][m_in] = n_in[k] * u_lu * fac
|
|
746
|
+
f, fn = run(100, f, fn)
|
|
747
|
+
it += 100
|
|
748
|
+
continue
|
|
749
|
+
f, fn = run(chunk, f, fn)
|
|
750
|
+
it += chunk
|
|
751
|
+
dp = float(rho[m_in].mean() - 1.0) / 3.0
|
|
752
|
+
if not np.isfinite(dp):
|
|
753
|
+
break
|
|
754
|
+
hist.append(dp)
|
|
755
|
+
if verbose and len(hist) % 20 == 0:
|
|
756
|
+
print(f" {it} steps dp {dp:.3e}", flush=True)
|
|
757
|
+
if it >= (develop - 1.0) * residence: # time-average the fields for the report
|
|
758
|
+
acc_r += rho
|
|
759
|
+
for k in range(3):
|
|
760
|
+
acc_u[k] += u[k]
|
|
761
|
+
n_acc += 1
|
|
762
|
+
# windows of one residence time (the network's volume over the inflow), after
|
|
763
|
+
# `develop` of them: a plenum's jet takes several to settle into the core
|
|
764
|
+
w = max(8, int(np.ceil(residence / chunk)))
|
|
765
|
+
if it >= develop * residence and _settled(hist, w, tol):
|
|
766
|
+
ok = True
|
|
767
|
+
break
|
|
768
|
+
p_unit = fl.rho * vel ** 2 # Pa per lattice pressure
|
|
769
|
+
if n_acc:
|
|
770
|
+
R, U = acc_r / n_acc, acc_u / n_acc
|
|
771
|
+
else:
|
|
772
|
+
R, U = rho, np.stack(u)
|
|
773
|
+
del acc_r, acc_u
|
|
774
|
+
speed = np.sqrt((U ** 2).sum(0))
|
|
775
|
+
p = (R - 1.0) / 3.0
|
|
776
|
+
pt = p + 0.5 * R * speed ** 2
|
|
777
|
+
dp = float(np.mean(hist[-w:])) if hist else float("nan")
|
|
778
|
+
out = {"pressureDrop": dp * p_unit,
|
|
779
|
+
"steps": it, "residenceSteps": residence, "seconds": round(time.time() - t0, 1), "converged": ok,
|
|
780
|
+
"voxel": h, "voxels": int(fluid.sum()), "inletVoxels": a_in, "portVelocity": u_phys,
|
|
781
|
+
"reVoxel": re_h, "reScale": re_scale, "mach": float(u_lu * np.sqrt(3.0)),
|
|
782
|
+
"maxMach": float(speed[fluid].max() * np.sqrt(3.0))}
|
|
783
|
+
# (a run at a lower Re than the physical one keeps the physical velocity, with a more
|
|
784
|
+
# viscous fluid: dt carries re_scale, so pressures are already at the physical velocity)
|
|
785
|
+
to_pa = p_unit
|
|
786
|
+
|
|
787
|
+
if regions is not None:
|
|
788
|
+
names = list(region_names or range(int(regions.max()) + 1))
|
|
789
|
+
segs = {}
|
|
790
|
+
# flow-weighted throughout, so a profile's kinetic energy counts the same at every cut
|
|
791
|
+
p_in = float((pt[m_in] * speed[m_in]).sum() / speed[m_in].sum())
|
|
792
|
+
bounds = []
|
|
793
|
+
for i in range(len(names) - 1):
|
|
794
|
+
v, n = _boundary_flux_mean(fluid & (regions == i), fluid & (regions == i + 1), pt, speed)
|
|
795
|
+
bounds.append(v)
|
|
796
|
+
p_out = float((pt[m_out] * speed[m_out]).sum() / speed[m_out].sum())
|
|
797
|
+
pts = [p_in] + bounds + [p_out]
|
|
798
|
+
for i, nme in enumerate(names):
|
|
799
|
+
segs[str(nme)] = (pts[i] - pts[i + 1]) * to_pa
|
|
800
|
+
out["pressureDropByRegion"] = segs
|
|
801
|
+
out["totalPressureDrop"] = (p_in - p_out) * to_pa
|
|
802
|
+
|
|
803
|
+
if core is not None:
|
|
804
|
+
c = fluid & core
|
|
805
|
+
idx = np.argwhere(c)
|
|
806
|
+
if core_axis is None:
|
|
807
|
+
mv = np.array([U[k][c].mean() for k in range(3)])
|
|
808
|
+
core_axis = mv / np.linalg.norm(mv)
|
|
809
|
+
ax = np.asarray(core_axis, float)
|
|
810
|
+
ax /= np.linalg.norm(ax)
|
|
811
|
+
P = origin + idx * h
|
|
812
|
+
along = P @ ax
|
|
813
|
+
mid = 0.5 * (along.min() + along.max())
|
|
814
|
+
slab = np.abs(along - mid) <= 0.5 * h * 1.0001
|
|
815
|
+
un = (U[:, idx[:, 0], idx[:, 1], idx[:, 2]].T @ ax)
|
|
816
|
+
bin_ = cell or 4.0 * h
|
|
817
|
+
e1 = np.cross(ax, [1.0, 0, 0] if abs(ax[0]) < 0.9 else [0, 1.0, 0])
|
|
818
|
+
e1 /= np.linalg.norm(e1)
|
|
819
|
+
e2 = np.cross(ax, e1)
|
|
820
|
+
b = np.stack([np.floor(P[slab] @ e1 / bin_), np.floor(P[slab] @ e2 / bin_)], 1).astype(int)
|
|
821
|
+
keys, inv = np.unique(b, axis=0, return_inverse=True)
|
|
822
|
+
inv = inv.ravel()
|
|
823
|
+
q = np.bincount(inv, weights=un[slab])
|
|
824
|
+
nvox = np.bincount(inv)
|
|
825
|
+
full = nvox >= 0.5 * np.median(nvox)
|
|
826
|
+
qf = q[full]
|
|
827
|
+
mean_u = float(un.mean())
|
|
828
|
+
out["coreFlow"] = {
|
|
829
|
+
"bins": int(full.sum()), "binSize": bin_,
|
|
830
|
+
"cv": float(qf.std() / qf.mean()) if qf.size else float("nan"),
|
|
831
|
+
"minOverMean": float(qf.min() / qf.mean()) if qf.size else float("nan"),
|
|
832
|
+
"maxOverMean": float(qf.max() / qf.mean()) if qf.size else float("nan"),
|
|
833
|
+
"stagnantShare": float(np.mean(np.abs(un) < 0.1 * abs(mean_u))),
|
|
834
|
+
"reverseShare": float(np.mean(un * np.sign(mean_u) < 0)),
|
|
835
|
+
"interstitialVelocity": mean_u * vel}
|
|
836
|
+
out["_coreBins"] = {"keys": keys[full].tolist(), "flow": (qf / qf.mean()).tolist()}
|
|
837
|
+
|
|
838
|
+
if heat:
|
|
839
|
+
alpha = np.full(shape, nu_lu / fl.pr, np.float32)
|
|
840
|
+
tnode = np.where(fluid, 0, 1).astype(np.int8)
|
|
841
|
+
if core is not None:
|
|
842
|
+
tnode[(~fluid) & core] = 2
|
|
843
|
+
else:
|
|
844
|
+
tnode[~fluid] = 2
|
|
845
|
+
tnode[m_in] = 3
|
|
846
|
+
tnode[m_out] = 4
|
|
847
|
+
tfix = np.zeros(shape, np.float32)
|
|
848
|
+
tfix[m_in] = 1.0
|
|
849
|
+
T = np.zeros(shape, np.float32)
|
|
850
|
+
T[m_in] = 1.0
|
|
851
|
+
gt = _equilibrium_t(T, *u)
|
|
852
|
+
gtn = gt.copy()
|
|
853
|
+
zero = np.zeros(shape, np.float32)
|
|
854
|
+
qv0 = np.zeros(3)
|
|
855
|
+
th = []
|
|
856
|
+
un_out = np.abs(U[:, m_out].T @ n_out.astype(float))
|
|
857
|
+
hok = False
|
|
858
|
+
n_t = 0
|
|
859
|
+
while n_t < max_steps:
|
|
860
|
+
for _ in range(chunk):
|
|
861
|
+
K["step"](f, fn, ntype, tau0, cs * cs, 0.0, 0.0, 0.0, uin, 1.0, per, rho, u[0], u[1], u[2], LAMBDA, nut)
|
|
862
|
+
f, fn = fn, f
|
|
863
|
+
K["step_t"](gt, gtn, tnode, u[0], u[1], u[2], alpha, tfix, zero, qv0, per, T, nut, prt_inv)
|
|
864
|
+
gt, gtn = gtn, gt
|
|
865
|
+
n_t += chunk
|
|
866
|
+
th.append(float((T[m_out] * un_out).sum() / un_out.sum()))
|
|
867
|
+
if len(th) >= 16:
|
|
868
|
+
a, b = np.mean(th[-8:]), np.mean(th[-16:-8])
|
|
869
|
+
if abs(a - b) < tol * max(abs(1 - a), 1e-3):
|
|
870
|
+
hok = True
|
|
871
|
+
break
|
|
872
|
+
t_out = float(np.mean(th[-8:]))
|
|
873
|
+
eff = 1.0 - t_out
|
|
874
|
+
ntu = -np.log(max(1.0 - eff, 1e-9))
|
|
875
|
+
out["heat"] = {"effectiveness": eff, "ntu": float(ntu), "steps": n_t, "converged": hok}
|
|
876
|
+
if core is not None:
|
|
877
|
+
from skimage.measure import marching_cubes
|
|
878
|
+
|
|
879
|
+
# the core's wetted area: the fluid surface inside the core
|
|
880
|
+
fld = np.where(fluid, 1.0, -1.0).astype(np.float32)
|
|
881
|
+
v, fc, _, _ = marching_cubes(fld, 0.0, spacing=(h, h, h))
|
|
882
|
+
cen = (v[fc[:, 0]] + v[fc[:, 1]] + v[fc[:, 2]]) / 3.0
|
|
883
|
+
ci = np.clip(np.round(cen / h).astype(int), 0, np.array(shape) - 1)
|
|
884
|
+
inside = core[ci[:, 0], ci[:, 1], ci[:, 2]]
|
|
885
|
+
tri = v[fc[inside]]
|
|
886
|
+
area = float(0.5 * np.linalg.norm(np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]), axis=1).sum())
|
|
887
|
+
hc = ntu * mass_flow * fl.cp / (area * 1e-6)
|
|
888
|
+
out["heat"].update({"coreWettedArea": area, "h": float(hc)})
|
|
889
|
+
out["simulated"] = {"nuLattice": nu_lu, "uPortLattice": u_lu, "dt": dt}
|
|
890
|
+
if fields:
|
|
891
|
+
# time-averaged fields (padded grid: voxel (0,0,0) is at `origin`), for renders
|
|
892
|
+
out["fields"] = {"velocity": (U * vel).astype(np.float32), "pressure": (p * p_unit).astype(np.float32),
|
|
893
|
+
"fluid": fluid, "origin": origin, "h": h}
|
|
894
|
+
return out
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def exchanger_flow(hx, fluid: str, duty: dict | None = None, *, pitch: float | None = None, heat: bool = False,
|
|
898
|
+
design=None, verbose: bool = False, **kw) -> dict:
|
|
899
|
+
"""Simulate one fluid's network of a heatx design, port to port, and set
|
|
900
|
+
it beside the rating's prediction for the same side.
|
|
901
|
+
|
|
902
|
+
hx: a HeatExchangerSpec (or a heatExchanger spec dict); fluid: its name
|
|
903
|
+
(hx.names); duty: the rating's duty ({name: {"fluid", "lPerMin" |
|
|
904
|
+
"kgPerS"}, "wall"}), by default the spec's own. pitch: voxel size (mm,
|
|
905
|
+
default wall / 2.5, at least two voxels across each sheet's gaps).
|
|
906
|
+
design: (mesh, report, spaces) from design_heat_exchanger(...,
|
|
907
|
+
fluid_meshes=True), to skip rebuilding it. The pressure drop is split
|
|
908
|
+
into inlet port, inlet plenum, core (mouths included), outlet plenum and
|
|
909
|
+
outlet port, and the core's flow sharing is measured on cell-sized
|
|
910
|
+
bins. Extra keywords go to network_flow."""
|
|
911
|
+
from scipy import ndimage
|
|
912
|
+
|
|
913
|
+
from .heatx import design_heat_exchanger, heat_exchanger_from_spec
|
|
914
|
+
from .heatx_thermal import _fluid, _flow, rate_design
|
|
915
|
+
from .voxels import mesh_solid
|
|
916
|
+
|
|
917
|
+
spec_duty = None
|
|
918
|
+
if isinstance(hx, dict):
|
|
919
|
+
spec_duty = hx.get("duty")
|
|
920
|
+
hx = heat_exchanger_from_spec(hx)
|
|
921
|
+
duty = duty or spec_duty
|
|
922
|
+
if duty is None:
|
|
923
|
+
raise ValueError("give a duty: {name: {\"fluid\": \"water\", \"lPerMin\": 1}, ...}")
|
|
924
|
+
if fluid not in hx.names:
|
|
925
|
+
raise ValueError(f"fluid is one of {list(hx.names)}")
|
|
926
|
+
if design is None:
|
|
927
|
+
design = design_heat_exchanger(hx, fluid_meshes=True)
|
|
928
|
+
mesh, report, spaces = design
|
|
929
|
+
pitch = pitch or hx.wall / 2.5
|
|
930
|
+
solid, origin, _ = mesh_solid(spaces[fluid], pitch, pad=1)
|
|
931
|
+
med = _fluid(duty[fluid].get("fluid", "water"))
|
|
932
|
+
m = _flow(duty[fluid], med)
|
|
933
|
+
|
|
934
|
+
idx = np.indices(solid.shape).reshape(3, -1).T
|
|
935
|
+
P = origin + idx * pitch
|
|
936
|
+
in_core = (hx.core.sdf(P) < 0).reshape(solid.shape)
|
|
937
|
+
zi, zo = report["zones"][f"{fluid}-in"], report["zones"][f"{fluid}-out"]
|
|
938
|
+
from .heatx import port_diameter
|
|
939
|
+
r_port = 0.5 * port_diameter(hx, hx.names.index(fluid))
|
|
940
|
+
|
|
941
|
+
def port_region(z):
|
|
942
|
+
p1, ax = np.asarray(z["port"][0], float), np.asarray(z["port"][1], float)
|
|
943
|
+
q = P - p1
|
|
944
|
+
a = q @ ax
|
|
945
|
+
rad = np.linalg.norm(q - a[:, None] * ax, axis=1)
|
|
946
|
+
return ((rad <= r_port + pitch) & (a >= -hx.port_length) & (a <= 3.0)).reshape(solid.shape), p1 + 2.0 * ax, ax
|
|
947
|
+
|
|
948
|
+
port_in, end_in, ax_in = port_region(zi)
|
|
949
|
+
port_out, end_out, ax_out = port_region(zo)
|
|
950
|
+
port_in &= solid
|
|
951
|
+
port_out &= solid & ~port_in
|
|
952
|
+
rest = solid & ~in_core & ~port_in & ~port_out
|
|
953
|
+
lab, n = ndimage.label(rest)
|
|
954
|
+
near_in = ndimage.binary_dilation(port_in) & rest
|
|
955
|
+
ids_in = set(np.unique(lab[near_in]).tolist()) - {0}
|
|
956
|
+
regions = np.full(solid.shape, -1, int)
|
|
957
|
+
regions[port_in] = 0
|
|
958
|
+
regions[rest & np.isin(lab, list(ids_in))] = 1
|
|
959
|
+
regions[solid & in_core] = 2
|
|
960
|
+
regions[rest & ~np.isin(lab, list(ids_in))] = 3
|
|
961
|
+
regions[port_out] = 4
|
|
962
|
+
core_axis = None
|
|
963
|
+
if hx.layout == "cross":
|
|
964
|
+
k = hx.axes[hx.names.index(fluid)]
|
|
965
|
+
core_axis = np.eye(3)[k]
|
|
966
|
+
elif fluid == hx.names[0]:
|
|
967
|
+
core_axis = np.eye(3)[hx.axes[0]]
|
|
968
|
+
res = network_flow(solid, pitch, {"point": end_in, "axis": ax_in, "radius": r_port},
|
|
969
|
+
{"point": end_out, "axis": ax_out, "radius": r_port}, medium=med, mass_flow=m,
|
|
970
|
+
regions=regions, region_names=["portIn", "plenumIn", "core", "plenumOut", "portOut"],
|
|
971
|
+
core=in_core, core_axis=core_axis, cell=hx.cell, heat=heat, origin=origin,
|
|
972
|
+
verbose=verbose, **kw)
|
|
973
|
+
try:
|
|
974
|
+
rd = dict(duty)
|
|
975
|
+
if "wall" not in rd:
|
|
976
|
+
rd["wall"] = "petg"
|
|
977
|
+
rating = rate_design(hx, report, rd)["fluids"][fluid]
|
|
978
|
+
res["rating"] = {"pressureDrop": rating["pressureDrop"], "core": rating["pressureDropCore"],
|
|
979
|
+
"mouths": rating["pressureDropMouths"], "ports": rating["pressureDropPorts"],
|
|
980
|
+
"re": rating["re"], "h": rating["h"], "hSpread": rating["hSpread"]}
|
|
981
|
+
except (KeyError, ValueError) as e: # the other fluid's duty missing: no rating to compare
|
|
982
|
+
res["rating"] = {"error": str(e)}
|
|
983
|
+
res["fluid"] = fluid
|
|
984
|
+
return res
|