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.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. 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