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/porous.py ADDED
@@ -0,0 +1,994 @@
1
+ """
2
+ Whole heat exchangers, simulated cheaply: the TPMS core as a porous
3
+ medium, the plenums and ports as open flow, both fluids and the wall
4
+ between them as three temperature fields on one coarse grid.
5
+
6
+ A resolved simulation (LBM, flow.py) needs voxels a fraction of the wall
7
+ thick, so a whole core is millions of cells and hours. Averaged over a cell
8
+ of the lattice, though, a sheet gyroid's channels behave like a porous
9
+ medium whose laws are known from cell-level runs:
10
+
11
+ -grad p = (mu / K + rho C_F |u| / sqrt K) u (Darcy-Forchheimer)
12
+ K = 0.0157 phi^2.72 a^2, C_F = 0.0784 phi^-2.04 (Savoldi et al. 2026)
13
+
14
+ with u the superficial velocity, phi the fluid's share of the core and a
15
+ the cell (the same law heatx_thermal rates on, checked there against the
16
+ 1KZ cooler's LBM cells), and each fluid trades heat with the wall through
17
+
18
+ q = h a_w (T_fluid - T_wall), Nu = 0.49 Re^0.62 Pr^0.4 (Reynolds 2023)
19
+
20
+ on the local Re (closures "lbm": the LBM cells' 1.66 Re^0.464 Pr^0.217,
21
+ and K and C_F refitted to the same cells, lbm_medium), so a slow
22
+ corner of the core gets the weak h it deserves. That is cheap on voxels
23
+ a few mm across (a core is 10-100 thousand cells, not millions) and gives
24
+ what the one-number epsilon-NTU rating cannot:
25
+
26
+ - how the flow shares out over the core (a port jetting into a shallow
27
+ plenum starves the far corners), as a uniformity index and a map;
28
+ - the pressure field port to port, core and headers apart;
29
+ - the temperature of both fluids and the wall everywhere (hot spots, the
30
+ wall's own conduction along the flow, which caps a metal counterflow
31
+ core), and the outlet temperatures that follow;
32
+ - so header and port shapes can be compared and tuned (`sweep`).
33
+
34
+ The core is not uniform: outside its own zones each fluid is sealed off
35
+ within `cap` of the surface, and with a build axis its channels close under
36
+ surfaces that look up. exchanger_domain measures each fluid's local share
37
+ of the core from the design's own grid (averaged over half a cell); where
38
+ it is under half the bulk's, those partial channels carry nothing (_PERC).
39
+ Against the full LBM of the 30 mm crossflow cube (flow.py) this reproduces
40
+ the starved edges: mid-plane cv 0.52 vs 0.56, worst bin 0.05 vs 0.08; on
41
+ the tuned cube 0.47 vs 0.52 and 0.24 vs 0.24. The core's pressure drop then
42
+ reads 169 Pa on the tuned cube against the LBM's 159 raw (about 110 after
43
+ its LES correction); on the original cube 225 against 364 raw (250): its
44
+ 6 mm ports jet into a 4 mm plenum and lose more at the core face than a
45
+ porous medium can show. Size ports so they don't jet (sizing.py does).
46
+
47
+ Flow, one fluid at a time (they only meet through heat): pseudo-transient
48
+ pressure correction on a staggered (MAC) grid. Open cells (plenums,
49
+ ports) carry momentum (upwind advection and viscosity, incompressible),
50
+ core cells Darcy-Forchheimer drag with the fluid's inertia rho / phi; a
51
+ face between the two takes half the core's drag. Inlets are volume
52
+ sources in the port's end cells, outlets hold p = 0. Each step solves one
53
+ pressure correction with algebraic multigrid; the drag is implicit, so the
54
+ core relaxes in a few hundred steps and the steady state does not depend
55
+ on the step.
56
+
57
+ Heat, once the flow is steady: steady upwind advection-conduction for each
58
+ fluid (its own cells), conduction for the wall (the core's cells, at the
59
+ sheet's effective conductivity, (1 - phi_sheet) k / 1.5), and the exchange
60
+ between them, h' a V with h' = 1 / (1/h + t / 2k) so fluid-wall-fluid is the
61
+ rating's 1/U = 1/h_A + t/k + 1/h_B. Headers and the outer shell are
62
+ adiabatic. One sparse solve (ILU-preconditioned GMRES).
63
+
64
+ Not captured: turbulence in the headers beyond the coarse grid's own
65
+ mixing (a port jet spreads faster than in life, so maldistribution is if
66
+ anything understated), entry effects in the channels (the Nu law is fully
67
+ developed), heat through the plenum walls, and the minor losses of a
68
+ port's own mouth (`portLoss` adds the rating's 1.5 velocity heads per port
69
+ pair so totals compare).
70
+
71
+ Units: SI inside; mm for positions and the grid at the edges.
72
+ """
73
+ from __future__ import annotations
74
+
75
+ from dataclasses import dataclass, field
76
+
77
+ import numpy as np
78
+ from scipy import ndimage, sparse
79
+
80
+ from .heatx_thermal import WALL_CONDUCTIVITY, _flow, _fluid
81
+
82
+ __all__ = ["PorousDomain", "Medium", "exchanger_domain", "box_domain", "simulate_domain", "simulate_box", "solve_flow", "solve_heat", "simulate_exchanger",
83
+ "simulate_design", "sweep", "savoldi_medium", "lbm_medium", "summarize", "plot_fields"]
84
+
85
+
86
+ @dataclass
87
+ class Medium:
88
+ """One fluid's side of the core, homogenized: share of the core volume,
89
+ wetted area per volume (1/m), hydraulic diameter (m), permeability K
90
+ (m^2) and Forchheimer coefficient C_F."""
91
+ phi: float
92
+ area_density: float
93
+ dh: float
94
+ K: float
95
+ CF: float
96
+ nK: float = 2.72 # K ~ phi^nK and C_F ~ phi^nC, for a local porosity
97
+ nC: float = -2.04
98
+
99
+
100
+ def savoldi_medium(phi, area_density, dh, cell):
101
+ """The Savoldi 2026 network-gyroid law (as heatx_thermal): `cell` in m."""
102
+ return Medium(float(phi), float(area_density), float(dh), 0.0157 * phi ** 2.72 * cell ** 2,
103
+ 0.0784 * phi ** -2.04)
104
+
105
+
106
+ def lbm_medium(phi, area_density, dh, cell):
107
+ """The same form refitted to periodic gyroid-sheet cells run with LBM
108
+ (/mnt/project-files/flow/gyroid-cells.json: the 1KZ cooler's 39 BGK
109
+ points, loadpath.flow's TRT re-runs and its creeping-flow limit, f Re
110
+ 183 on the a10 air cell): K = 0.00932 phi^2.257 a^2, C_F = 0.0569
111
+ phi^-2.283. Median ratio 0.99, 90% of points within 13% (the rest is the
112
+ ~20% scatter between flow states past Re ~200), against Savoldi's 1.08
113
+ and 18%; the gain is at phi 0.17-0.26, below Savoldi's range, where
114
+ Savoldi reads 10-29% high."""
115
+ return Medium(float(phi), float(area_density), float(dh), 0.00932 * phi ** 2.257 * cell ** 2,
116
+ 0.0569 * phi ** -2.283, 2.257, -2.283)
117
+
118
+
119
+ # dead edge band beyond the seal, in cells (box_domain, sizing.py): where the
120
+ # calibrated local porosity puts the dead partial channels on designed cores.
121
+ # Checked with box_domain at 1.5 mm on the 30 mm crossflow cubes (water side):
122
+ # band original: eff core Pa cv tuned: eff total/core Pa cv
123
+ # 0 0.341 100 0.29 0.379 198 / 79 0.27
124
+ # 0.1 0.304 123 0.43 0.341 216 / 97 0.30
125
+ # 0.2 0.269 155 0.48 0.304 241 / 120 0.43
126
+ # designed porous 0.336 / 225 / 0.52 and 0.377 / 267 / 183 / 0.47; LBM tuned
127
+ # 197 static, core ~110 corrected, cv 0.52. A quarter cell gets the tuned core
128
+ # dp and flow sharing near LBM; it reads effectiveness ~20% under the designed
129
+ # model on a core only 3.75 cells across (conservative), and the band is a small
130
+ # share of a big core, where box_domain is meant to be used.
131
+ _BAND_CELLS = 0.25
132
+
133
+ # local porosity: averaging window (cells), and a threshold (share of the bulk's
134
+ # open space) below which a band of partial channels carries nothing while the
135
+ # rest flows as bulk core ("step"). Fitted to the flow thread's LBM of the 30 mm
136
+ # crossflow cube, original and tuned: mid-plane cv 0.52 / 0.47 (LBM 0.56 / 0.52),
137
+ # worst bin 0.05 / 0.24 (LBM 0.08 / 0.24). Scaling K and C_F continuously with
138
+ # the local share ("step": False, exponent x "band") cannot match both: it
139
+ # either evens the flow or adds resistance the LBM doesn't see.
140
+ _PERC = {"window": 0.5, "rc": 0.5, "step": True, "band": 1.0}
141
+
142
+ # closures: (pressure-drop law, Nu law)
143
+ _CLOSURES = {"design": (savoldi_medium, "design"), "lbm": (lbm_medium, "lbm")}
144
+
145
+
146
+ @dataclass
147
+ class PorousDomain:
148
+ """A coarse grid (cell `h` m, corner `lo` mm) with the core and, per
149
+ fluid, its cells (core plus its open plenums and ports), its inlet and
150
+ outlet cells, and its homogenized medium in the core. Arrays are padded
151
+ so the outer layer is never a fluid cell."""
152
+ h: float
153
+ lo: np.ndarray
154
+ core: np.ndarray
155
+ cells: list
156
+ inlet: list
157
+ outlet: list
158
+ media: list
159
+ names: tuple = ("A", "B")
160
+ wall: float = 0.8e-3 # m, the sheet
161
+ phi_sheet: float = 0.8 # the core's open share (both fluids)
162
+ extra: dict = field(default_factory=dict)
163
+
164
+ @property
165
+ def dims(self):
166
+ return self.core.shape
167
+
168
+ def centers(self, mask):
169
+ """Cell centres (mm) of a mask."""
170
+ return self.lo + (np.argwhere(mask) + 0.5) * self.h * 1e3
171
+
172
+
173
+ # ---------------------------------------------------------------- the grid
174
+
175
+ def _shift(a, d, ax):
176
+ """b[c] = a[c + d e_ax], zero past the edge (the domain is padded)."""
177
+ out = np.zeros_like(a)
178
+ n = a.shape[ax]
179
+ src = [slice(None)] * 3
180
+ dst = [slice(None)] * 3
181
+ if d > 0:
182
+ src[ax], dst[ax] = slice(d, n), slice(0, n - d)
183
+ else:
184
+ src[ax], dst[ax] = slice(0, n + d), slice(-d, n)
185
+ out[tuple(dst)] = a[tuple(src)]
186
+ return out
187
+
188
+
189
+ def _faces(m):
190
+ """Face masks: face ax at cell c joins c and c + e_ax."""
191
+ return [m & _shift(m, 1, ax) for ax in range(3)]
192
+
193
+
194
+ def _cell_speed(U, active):
195
+ """|u| at cell centres from the face velocities."""
196
+ s = np.zeros(active.shape)
197
+ for ax in range(3):
198
+ s += (0.5 * (U[ax] + _shift(U[ax], -1, ax))) ** 2
199
+ return np.sqrt(s) * active
200
+
201
+
202
+ # ---------------------------------------------------------------- flow
203
+
204
+ def _local(dom, f):
205
+ """Per cell: this fluid's porosity (1 in open cells), permeability and
206
+ Forchheimer coefficient, and its share relative to the medium's (which
207
+ scales the wetted area). Where the design seals or closes channels near
208
+ the core's surface (dom.extra["phi"]), the local share is lower."""
209
+ med = dom.media[f]
210
+ core = dom.core & dom.cells[f]
211
+ loc = (dom.extra.get("phi") or [None, None])[f]
212
+ r = np.where(core, 1.0 if loc is None else np.maximum(loc / med.phi, 0.05), 1.0)
213
+ phi = np.where(core, med.phi * r, 1.0)
214
+ rc = _PERC["rc"]
215
+ if _PERC.get("step"):
216
+ # partial channels below the threshold carry nothing; the rest flow as the bulk core
217
+ rk = np.where(r >= rc, 1.0, 0.02)
218
+ K = med.K * rk ** 2
219
+ CF = med.CF * rk ** -1
220
+ else:
221
+ rk = np.maximum((r - rc) / (1.0 - rc), 0.02) if rc > 0 else r
222
+ K = med.K * rk ** (med.nK * _PERC["band"])
223
+ CF = med.CF * rk ** (med.nC * _PERC["band"])
224
+ return phi, K, CF, r
225
+
226
+
227
+ def _face_props(dom, f, fluid):
228
+ """Per face: inertia (rho / phi, averaged over the two cells), whether
229
+ it's a core face (drag), and the share of its length in the core."""
230
+ m = dom.cells[f]
231
+ core = dom.core & m
232
+ phi = _local(dom, f)[0]
233
+ inert = fluid.rho / phi
234
+ out = []
235
+ for ax, fa in enumerate(_faces(m)):
236
+ share = 0.5 * (core.astype(float) + _shift(core, 1, ax).astype(float)) * fa # 0 open, 0.5 interface, 1 core
237
+ rho_eff = np.where(fa, 0.5 * (inert + _shift(inert, 1, ax)), fluid.rho)
238
+ out.append((fa, share, rho_eff))
239
+ return out
240
+
241
+
242
+ def solve_flow(dom: PorousDomain, f: int, fluid, mass_flow: float, max_steps: int = 4000, tol: float = 1e-4,
243
+ cfl: float = 0.4, local_dt: float = 20.0, progress=None):
244
+ """Steady flow of one fluid through its cells: inflow `mass_flow` kg/s
245
+ spread over its inlet cells, out at p = 0 through its outlet cells.
246
+ Returns face velocities (superficial, m/s), cell pressures (Pa) and
247
+ convergence info."""
248
+ import pyamg
249
+
250
+ fluid = _fluid(fluid)
251
+ m = dom.cells[f]
252
+ h = dom.h
253
+ med = dom.media[f]
254
+ rho, mu = fluid.rho, fluid.mu
255
+ nu = mu / rho
256
+ q = mass_flow / rho # m^3/s
257
+ inlet, outlet = dom.inlet[f] & m, dom.outlet[f] & m
258
+ if not inlet.any() or not outlet.any():
259
+ raise ValueError(f"fluid {dom.names[f]} has no inlet or outlet cells on the grid")
260
+ src = np.zeros(m.shape)
261
+ src[inlet] = q / inlet.sum()
262
+ props = _face_props(dom, f, fluid)
263
+ fa = [p[0] for p in props]
264
+ share = [p[1] for p in props]
265
+ rho_eff = [p[2] for p in props]
266
+ open_face = [a & (s == 0) for a, s in zip(fa, share)]
267
+ # a reference speed for the step: the port's mean speed
268
+ n_port = max(inlet.sum() ** (2.0 / 3.0), 1.0)
269
+ u_ref = max(q / (n_port * h * h), 1e-6)
270
+ dt_visc = 0.15 * h * h / nu
271
+ dt = min(cfl * h / (2.0 * u_ref), dt_visc)
272
+
273
+ # numbering of the unknowns (outlet cells hold p = 0)
274
+ free = m & ~outlet
275
+ idx = -np.ones(m.shape, dtype=np.int64)
276
+ idx[free] = np.arange(int(free.sum()))
277
+ n = int(free.sum())
278
+
279
+ _, K_c, CF_c, _ = _local(dom, f)
280
+ in_core = dom.core & m
281
+
282
+ def drag(U):
283
+ """Face drag R (Pa s / m^2): Darcy-Forchheimer, half of each cell's
284
+ on either side of the face (open cells none)."""
285
+ speed = _cell_speed(U, m)
286
+ rc = np.where(in_core, mu / K_c + rho * CF_c * speed / np.sqrt(K_c), 0.0)
287
+ return [0.5 * (rc + _shift(rc, 1, ax)) * fa[ax] for ax in range(3)]
288
+
289
+ def assemble(a):
290
+ """The pressure-correction matrix for face coefficients a (Pa s / m^2 per
291
+ unit speed): flux = -h^2 (dp / h) / a."""
292
+ rows, cols, vals = [], [], []
293
+ diag = np.zeros(n)
294
+ for ax in range(3):
295
+ g = np.where(fa[ax], h / np.where(fa[ax], a[ax], 1.0), 0.0)
296
+ i0 = idx
297
+ i1 = _shift(idx + 1, 1, ax) - 1 # index of c + e_ax (-1 where none)
298
+ sel = fa[ax]
299
+ gi = g[sel]
300
+ a0, a1 = i0[sel], i1[sel]
301
+ for p_, q_ in ((a0, a1), (a1, a0)):
302
+ ok = p_ >= 0
303
+ np.add.at(diag, p_[ok], gi[ok])
304
+ both = ok & (q_ >= 0)
305
+ rows.append(p_[both])
306
+ cols.append(q_[both])
307
+ vals.append(-gi[both])
308
+ rows.append(np.arange(n))
309
+ cols.append(np.arange(n))
310
+ vals.append(diag)
311
+ L = sparse.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n))
312
+ return L
313
+
314
+ # initial guess: Darcy everywhere (open faces with an inertial resistance at the port speed)
315
+ U = [np.zeros(m.shape) for _ in range(3)]
316
+ R = drag(U)
317
+ a_init = [np.where(share[ax] > 0, R[ax] + rho * u_ref / h * (1 - share[ax]), rho * u_ref / h) for ax in range(3)]
318
+ L = assemble(a_init)
319
+ ml = pyamg.smoothed_aggregation_solver(L, symmetry="symmetric", max_coarse=500)
320
+ p = np.zeros(m.shape)
321
+ p[free] = ml.solve(src[free], tol=1e-10, accel="cg", maxiter=200)
322
+ for ax in range(3):
323
+ U[ax] = np.where(fa[ax], -(_shift(p, 1, ax) - p) / h / np.where(fa[ax], a_init[ax], 1.0), 0.0)
324
+
325
+ hist = []
326
+ a = None
327
+ ml = None
328
+ converged = False
329
+ step = 0
330
+ for step in range(max_steps):
331
+ R = drag(U)
332
+ if step % 25 == 0 or a is None:
333
+ # a_f = rho' / dt + R: implicit drag; refresh the correction's matrix now and then
334
+ # local pseudo-time steps (only the steady state counts): open faces by their own
335
+ # speed's CFL, up to 20x the port's, core faces long enough for the drag to settle
336
+ speed = _cell_speed(U, m)
337
+ dt_f = []
338
+ for ax in range(3):
339
+ sp = np.maximum(speed, _shift(speed, 1, ax))
340
+ d_open = np.clip(cfl * h / (2.0 * np.maximum(sp, 1e-12)), dt, min(local_dt * dt, dt_visc))
341
+ dt_f.append(np.where(share[ax] > 0, np.maximum(d_open, 2.0 * rho_eff[ax] / np.maximum(R[ax], 1e-30)),
342
+ d_open))
343
+ a = [rho_eff[ax] / dt_f[ax] + R[ax] for ax in range(3)]
344
+ ml = pyamg.smoothed_aggregation_solver(assemble(a), symmetry="symmetric", max_coarse=500)
345
+ inert = [rho_eff[ax] / dt_f[ax] for ax in range(3)]
346
+ # explicit momentum in open faces: upwind advection, viscosity (no-slip walls)
347
+ Ustar = []
348
+ for ax in range(3):
349
+ u = U[ax]
350
+ rhs = inert[ax] * u - (_shift(p, 1, ax) - p) / h * fa[ax]
351
+ of = open_face[ax]
352
+ if of.any():
353
+ adv = np.zeros(m.shape)
354
+ lap = np.zeros(m.shape)
355
+ for d in range(3):
356
+ if d == ax:
357
+ vd = u
358
+ else:
359
+ V = U[d]
360
+ vd = 0.25 * (V + _shift(V, 1, ax) + _shift(V, -1, d) + _shift(_shift(V, 1, ax), -1, d))
361
+ up = _shift(u, 1, d)
362
+ dn = _shift(u, -1, d)
363
+ adv += np.where(vd > 0, vd * (u - dn), vd * (up - u)) / h
364
+ fup = _shift(fa[ax], 1, d)
365
+ fdn = _shift(fa[ax], -1, d)
366
+ if d == ax:
367
+ lap += (up + dn - 2 * u) / (h * h)
368
+ else:
369
+ # a missing neighbour face across a wall: no-slip ghost -u
370
+ lap += (np.where(fup, up, -u) + np.where(fdn, dn, -u) - 2 * u) / (h * h)
371
+ rhs = rhs + np.where(of, -rho * adv + mu * lap, 0.0)
372
+ Ustar.append(np.where(fa[ax], rhs / np.where(fa[ax], a[ax], 1.0), 0.0))
373
+ # pressure correction: div(u) = source
374
+ div = src.copy()
375
+ for ax in range(3):
376
+ div -= h * h * (Ustar[ax] - _shift(Ustar[ax], -1, ax))
377
+ dp = np.zeros(m.shape)
378
+ dp[free] = ml.solve(div[free], tol=1e-8, accel="cg", maxiter=100)
379
+ change = 0.0
380
+ for ax in range(3):
381
+ un = np.where(fa[ax], Ustar[ax] - (_shift(dp, 1, ax) - dp) / h / np.where(fa[ax], a[ax], 1.0), 0.0)
382
+ change = max(change, float(np.abs(un - U[ax]).max()))
383
+ U[ax] = un
384
+ p = p + dp
385
+ rel = change / u_ref
386
+ if not np.isfinite(rel) or rel > 50.0:
387
+ # the explicit header momentum outran its local step: start again, more carefully
388
+ if local_dt <= 1.0 and cfl < 0.1:
389
+ raise ValueError(f"fluid {dom.names[f]}'s flow solve diverged")
390
+ return solve_flow(dom, f, fluid, mass_flow, max_steps, tol, cfl * (0.7 if local_dt <= 1.0 else 1.0),
391
+ max(1.0, local_dt / 4.0), progress)
392
+ hist.append(rel)
393
+ if progress is not None and step % 100 == 0:
394
+ progress(step=step, change=rel)
395
+ if step > 20 and rel < tol:
396
+ converged = True
397
+ break
398
+ # mass balance check: what leaves through the outlet cells
399
+ div = np.zeros(m.shape)
400
+ for ax in range(3):
401
+ div += h * h * (U[ax] - _shift(U[ax], -1, ax))
402
+ out_flow = -float(div[outlet].sum())
403
+ return {"U": U, "p": p, "steps": step + 1, "converged": converged, "change": hist[-1] if hist else 0.0,
404
+ "outflow": out_flow * rho, "massFlow": mass_flow, "dt": dt, "uRef": u_ref}
405
+
406
+
407
+ # ---------------------------------------------------------------- heat
408
+
409
+ _NU = {"design": (0.49, 0.62, 0.4), "lbm": (1.66, 0.464, 0.217)}
410
+ _NU_MIN = 3.0 # a floor for near-stagnant channels (conduction across a channel, roughly)
411
+
412
+
413
+ def _pairs(m, ax, idx):
414
+ """Index pairs (c, c + e_ax) of the faces inside mask m."""
415
+ sel = m & _shift(m, 1, ax)
416
+ return sel, idx[sel], (_shift(idx + 1, 1, ax) - 1)[sel]
417
+
418
+
419
+ def _laplacian(n, pairs_g):
420
+ """Sparse conduction matrix from (i, j, conductance) lists."""
421
+ rows, cols, vals = [], [], []
422
+ diag = np.zeros(n)
423
+ for i, j, g in pairs_g:
424
+ np.add.at(diag, i, g)
425
+ np.add.at(diag, j, g)
426
+ rows += [i, j]
427
+ cols += [j, i]
428
+ vals += [-g, -g]
429
+ rows.append(np.arange(n))
430
+ cols.append(np.arange(n))
431
+ vals.append(diag)
432
+ return sparse.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n))
433
+
434
+
435
+ def solve_heat(dom: PorousDomain, fluids, flows, t_in, k_wall, nu="design"):
436
+ """Steady temperatures of both fluids and the wall, given each fluid's
437
+ steady flow (solve_flow's results) and inlet temperature (deg C).
438
+ `k_wall` W/m K, the sheet's material.
439
+
440
+ Advection is upwind (each cell's outflow leaves at the cell's
441
+ temperature), but the heat a cell trades with the wall is driven by the
442
+ mean temperature along its stream, between what flows in and what flows
443
+ out, weighted as in a single stream exchanging at fixed wall temperature
444
+ (w = (1 - (1 - e^-N) / N) / (1 - e^-N), N the cell's own NTU): plain
445
+ upwind would use the outflow's and run 15% low on a crossflow core 10
446
+ cells across."""
447
+ from scipy.sparse.linalg import LinearOperator, gmres, spilu
448
+
449
+ h = dom.h
450
+ vol = h ** 3
451
+ c0, e_re, e_pr = _NU[nu] if isinstance(nu, str) else nu
452
+ t_half = 0.5 * dom.wall / k_wall
453
+ core = dom.core
454
+ wcells = core & dom.cells[0] & dom.cells[1]
455
+ frac = dom.extra.get("coreFraction", 1.0)
456
+ n_w = int(wcells.sum())
457
+ iw = -np.ones(core.shape, dtype=np.int64)
458
+ iw[wcells] = np.arange(n_w)
459
+ blocks, rhs, couple, h_maps = [], [], [], []
460
+ g_sum = np.zeros(n_w)
461
+ wall_rhs = np.zeros(n_w)
462
+ for f in (0, 1):
463
+ fl = _fluid(fluids[f])
464
+ m = dom.cells[f]
465
+ n = int(m.sum())
466
+ i = -np.ones(core.shape, dtype=np.int64)
467
+ i[m] = np.arange(n)
468
+ med = dom.media[f]
469
+ phi_c, _, _, r_c = _local(dom, f)
470
+ U = flows[f]["U"]
471
+ rc = fl.rho * fl.cp
472
+ # inflow matrix P[i, j] = capacity flowing from j into i (W/K); conduction
473
+ pr, pc, pv, cond = [], [], [], []
474
+ for ax in range(3):
475
+ sel, a0, a1 = _pairs(m, ax, i)
476
+ F = U[ax][sel] * h * h * rc
477
+ pos = F > 0
478
+ pr += [a1[pos], a0[~pos]]
479
+ pc += [a0[pos], a1[~pos]]
480
+ pv += [F[pos], -F[~pos]]
481
+ cond.append((a0, a1, fl.k * 0.5 * (phi_c + _shift(phi_c, 1, ax))[sel] * h))
482
+ P = sparse.csr_matrix((np.concatenate(pv), (np.concatenate(pr), np.concatenate(pc))), shape=(n, n))
483
+ inl = (dom.inlet[f] & m)[m]
484
+ s = np.where(inl, flows[f]["massFlow"] * fl.cp / inl.sum(), 0.0)
485
+ fin = np.asarray(P.sum(axis=1)).ravel() + s
486
+ A = sparse.diags(fin) - P + _laplacian(n, cond)
487
+ b = s * t_in[f]
488
+ # exchange with the wall, on the local Re
489
+ speed = _cell_speed(U, m)
490
+ re = fl.rho * (speed / phi_c) * med.dh / fl.mu
491
+ hc = np.maximum(c0 * re ** e_re * fl.pr ** e_pr, _NU_MIN) * fl.k / med.dh
492
+ h_maps.append(np.where(wcells, hc, 0.0))
493
+ G = (med.area_density * r_c * vol * frac / (1.0 / hc + t_half))[wcells]
494
+ fi = i[wcells]
495
+ N = G / np.maximum(fin[fi], 1e-30)
496
+ en = np.exp(-np.minimum(N, 50.0))
497
+ w = np.where(N > 1e-6, (1.0 - (1.0 - en) / np.maximum(N, 1e-30)) / np.maximum(1.0 - en, 1e-30), 0.5)
498
+ w = np.where(fin[fi] > 0, w, 1.0)
499
+ # the driving temperature at wall cell k: w T_i + (1 - w) (P T + s t_in)_i / fin_i
500
+ E = sparse.csr_matrix((np.ones(n_w), (np.arange(n_w), fi)), shape=(n_w, n))
501
+ dinv = np.where(fin[fi] > 0, (1.0 - w) / np.maximum(fin[fi], 1e-30), 0.0)
502
+ Mf = sparse.diags(w) @ E + sparse.diags(dinv) @ (E @ P)
503
+ m0 = dinv * s[fi] * t_in[f]
504
+ GM = sparse.diags(G) @ Mf
505
+ A = A + E.T @ GM
506
+ b = b - E.T @ (G * m0)
507
+ blocks.append((A, -(E.T @ sparse.diags(G)), -GM))
508
+ rhs.append(b)
509
+ g_sum += G
510
+ wall_rhs += G * m0
511
+ couple.append((i, m))
512
+ kw = (1.0 - dom.phi_sheet) * k_wall / 1.5
513
+ W = _laplacian(n_w, [_pairs(wcells, ax, iw)[1:] + (np.full(int(_pairs(wcells, ax, iw)[0].sum()), kw * h),)
514
+ for ax in range(3)]) + sparse.diags(g_sum)
515
+ (A0, C0, R0), (A1, C1, R1) = blocks
516
+ K = sparse.bmat([[A0, None, C0], [None, A1, C1], [R0, R1, W]], format="csc")
517
+ b = np.concatenate([rhs[0], rhs[1], wall_rhs])
518
+ n_tot = K.shape[0]
519
+ # cells nothing reaches (no flow, no exchange, e.g. a dead band with no wall): pin them
520
+ dead = np.flatnonzero(abs(K).sum(axis=1).A1 == 0)
521
+ if dead.size:
522
+ K = (K + sparse.csc_matrix((np.ones(dead.size), (dead, dead)), shape=K.shape)).tocsc()
523
+ b[dead] = float(np.mean(t_in))
524
+ if n_tot < 200_000:
525
+ x = sparse.linalg.spsolve(K, b)
526
+ else:
527
+ try:
528
+ ilu = spilu(K, drop_tol=1e-5, fill_factor=20)
529
+ except RuntimeError:
530
+ # an exactly singular pivot (dead cells with no flow and no exchange): factor
531
+ # with a tiny diagonal shift and let GMRES correct against K itself
532
+ d = abs(K.diagonal())
533
+ ilu = spilu((K + sparse.diags(1e-8 * max(float(d.max()), 1.0) + 1e-6 * d)).tocsc(),
534
+ drop_tol=1e-5, fill_factor=20)
535
+ x, _ = gmres(K, b, M=LinearOperator(K.shape, ilu.solve), rtol=1e-10, restart=50, maxiter=200,
536
+ x0=np.full(n_tot, float(np.mean(t_in))))
537
+ T, off = [], 0
538
+ for i, m in couple:
539
+ t = np.full(core.shape, np.nan)
540
+ t[m] = x[off:off + int(m.sum())]
541
+ off += int(m.sum())
542
+ T.append(t)
543
+ tw = np.full(core.shape, np.nan)
544
+ tw[wcells] = x[off:]
545
+ return {"T": T, "Twall": tw, "h": h_maps, "wallCells": wcells}
546
+
547
+
548
+ # ---------------------------------------------------------------- results
549
+
550
+ def _outlet_temperature(dom, f, flow, T):
551
+ """Mixed-cup temperature leaving through the outlet cells: what flows
552
+ into them, at their temperature."""
553
+ h = dom.h
554
+ out = dom.outlet[f] & dom.cells[f]
555
+ U = flow["U"]
556
+ # net inflow into each outlet cell = -div
557
+ div = np.zeros(out.shape)
558
+ for ax in range(3):
559
+ div += h * h * (U[ax] - _shift(U[ax], -1, ax))
560
+ q = np.maximum(-div, 0.0) * out
561
+ return float((q * np.nan_to_num(T)).sum() / max(q.sum(), 1e-30))
562
+
563
+
564
+ def _core_entry(dom, f, flow):
565
+ """Volume flux (m^3/s) into the core through each face between this
566
+ fluid's open cells and the core (positive in)."""
567
+ m = dom.cells[f]
568
+ core = dom.core & m
569
+ opn = m & ~dom.core
570
+ h = dom.h
571
+ out = []
572
+ for ax in range(3):
573
+ U = flow["U"][ax]
574
+ # open at c, core at c + e: flux in = +U; core at c, open at c + e: in = -U
575
+ a = opn & _shift(core, 1, ax)
576
+ b = core & _shift(opn, 1, ax)
577
+ out.append(U[a] * h * h)
578
+ out.append(-U[b] * h * h)
579
+ q = np.concatenate(out)
580
+ return q
581
+
582
+
583
+ def _midplane(dom, f, flow, cell_mm):
584
+ """Flow sharing across the core's middle, as flow.py's coreFlow
585
+ measures it: the slab at the core's mid-plane normal to this fluid's
586
+ mean core flow, binned on cell-sized squares from the world origin;
587
+ bins under half the median cell count dropped. Returns cv, min and max
588
+ over the mean, and the bins (flow over mean, rows and columns along
589
+ the other two axes ascending)."""
590
+ m = dom.cells[f] & dom.core
591
+ U = flow["U"]
592
+ mean = [float((0.5 * (U[ax] + _shift(U[ax], -1, ax)))[m].sum()) for ax in range(3)]
593
+ ax = int(np.argmax(np.abs(mean)))
594
+ sgn = np.sign(mean[ax])
595
+ idx = np.argwhere(m)
596
+ k = int(round(0.5 * (idx[:, ax].min() + idx[:, ax].max())))
597
+ sl = [slice(None)] * 3
598
+ sl[ax] = k
599
+ sl = tuple(sl)
600
+ u = (sgn * 0.5 * (U[ax] + _shift(U[ax], -1, ax)))[sl]
601
+ mm = m[sl]
602
+ other = [a for a in range(3) if a != ax]
603
+ ij = np.argwhere(mm)
604
+ pos = dom.lo[other] + (ij + 0.5) * dom.h * 1e3
605
+ b = np.floor(pos / cell_mm).astype(int)
606
+ b -= b.min(axis=0)
607
+ shape = tuple(b.max(axis=0) + 1)
608
+ q = np.zeros(shape)
609
+ n = np.zeros(shape)
610
+ np.add.at(q, (b[:, 0], b[:, 1]), u[mm])
611
+ np.add.at(n, (b[:, 0], b[:, 1]), 1)
612
+ ok = n >= 0.5 * np.median(n[n > 0])
613
+ qm = q[ok].mean()
614
+ return {"axis": "xyz"[ax], "cv": float(q[ok].std() / qm), "minOverMean": float(q[ok].min() / qm),
615
+ "maxOverMean": float(q[ok].max() / qm), "bins": np.round(q / qm, 3).tolist()}
616
+
617
+
618
+ def _uniformity(q):
619
+ """Flow uniformity over the faces where the fluid enters the core
620
+ (Weltens' index, 1 = perfectly even), its spread, and the share of
621
+ faces taking less than half the mean."""
622
+ q = q[q > 0]
623
+ if len(q) == 0:
624
+ return {}
625
+ mean = float(q.mean())
626
+ return {"uniformity": float(1.0 - np.abs(q - mean).sum() / (2.0 * len(q) * mean)),
627
+ "cov": float(q.std() / mean), "minOverMean": float(q.min() / mean), "maxOverMean": float(q.max() / mean),
628
+ "starvedShare": float((q < 0.5 * mean).mean()), "entryFaces": int(len(q))}
629
+
630
+
631
+ def _mean_p(dom, f, flow, mask):
632
+ p = flow["p"]
633
+ sel = mask & dom.cells[f]
634
+ return float(p[sel].mean()) if sel.any() else float("nan")
635
+
636
+
637
+ def _plenum_faces(dom, f, which):
638
+ """Core cells next to this fluid's open cells that connect (through the
639
+ open space) to its inlet or its outlet."""
640
+ m = dom.cells[f]
641
+ opn = m & ~dom.core
642
+ lbl, _ = ndimage.label(opn)
643
+ seeds = dom.inlet[f] if which == "in" else dom.outlet[f]
644
+ keep = np.isin(lbl, np.unique(lbl[seeds & opn])) & (lbl > 0)
645
+ near = ndimage.binary_dilation(keep) & dom.core & m
646
+ return near
647
+
648
+
649
+ def summarize(dom, fluids, flows, heat, t_in):
650
+ out = {"grid": {"voxel": dom.h * 1e3, "dims": list(dom.dims),
651
+ "coreCells": int(dom.core.sum()),
652
+ "cells": {n: int(c.sum()) for n, c in zip(dom.names, dom.cells)}},
653
+ "fluids": {}}
654
+ caps = []
655
+ for f, name in enumerate(dom.names):
656
+ fl = _fluid(fluids[f])
657
+ flow = flows[f]
658
+ p = flow["p"]
659
+ m = dom.cells[f]
660
+ p_in = _mean_p(dom, f, flow, dom.inlet[f])
661
+ core_in = _plenum_faces(dom, f, "in")
662
+ core_out = _plenum_faces(dom, f, "out")
663
+ dp_core = _mean_p(dom, f, flow, core_in) - _mean_p(dom, f, flow, core_out)
664
+ speed = _cell_speed(flow["U"], m)
665
+ cs = speed[dom.core & m]
666
+ med = dom.media[f]
667
+ cap = flow["massFlow"] * fl.cp
668
+ caps.append(cap)
669
+ row = {"massFlow": flow["massFlow"], "pressureDrop": p_in, "pressureDropCore": dp_core,
670
+ "pressureDropHeaders": p_in - dp_core,
671
+ "converged": flow["converged"], "steps": flow["steps"],
672
+ "massBalance": flow["outflow"] / flow["massFlow"],
673
+ "coreVelocity": {"mean": float(cs.mean()), "p05": float(np.percentile(cs, 5)),
674
+ "p95": float(np.percentile(cs, 95))},
675
+ "coreRe": float(fl.rho * cs.mean() / med.phi * med.dh / fl.mu),
676
+ "deadShare": float((cs < 0.1 * cs.mean()).mean()),
677
+ "entry": _uniformity(_core_entry(dom, f, flow)),
678
+ "midplane": _midplane(dom, f, flow, dom.extra.get("cell", 8.0))}
679
+ if heat is not None:
680
+ row["tIn"] = t_in[f]
681
+ row["tOut"] = _outlet_temperature(dom, f, flow, heat["T"][f])
682
+ hm = heat["h"][f][heat["wallCells"]]
683
+ row["h"] = {"mean": float(hm.mean()), "p05": float(np.percentile(hm, 5)),
684
+ "p95": float(np.percentile(hm, 95))}
685
+ out["fluids"][name] = row
686
+ if heat is not None:
687
+ qa = caps[0] * (t_in[0] - out["fluids"][dom.names[0]]["tOut"])
688
+ qb = caps[1] * (out["fluids"][dom.names[1]]["tOut"] - t_in[1])
689
+ qmax = min(caps) * (t_in[0] - t_in[1])
690
+ tw = heat["Twall"][heat["wallCells"]]
691
+ wc = dom.centers(heat["wallCells"])
692
+ hot = int(np.argmax(tw))
693
+ cold = int(np.argmin(tw))
694
+ # the heat moved (W), whichever fluid is listed first (dutySign -1: the first takes it)
695
+ out.update(duty=abs(0.5 * (qa + qb)), dutySign=1 if qa + qb >= 0 else -1,
696
+ effectiveness=0.5 * (qa + qb) / qmax if qmax else None,
697
+ energyBalance=(qa - qb) / max(abs(qa), abs(qb), 1e-30),
698
+ wall={"max": float(tw.max()), "maxAt": np.round(wc[hot], 1).tolist(),
699
+ "min": float(tw.min()), "minAt": np.round(wc[cold], 1).tolist()})
700
+ return out
701
+
702
+
703
+ # ---------------------------------------------------------------- heat exchangers
704
+
705
+ def _block_mean(a, r):
706
+ nx, ny, nz = (-(-s // r) for s in a.shape)
707
+ a = np.pad(a.astype(np.float32), [(0, n * r - s) for n, s in zip((nx, ny, nz), a.shape)])
708
+ return a.reshape(nx, r, ny, r, nz, r).mean(axis=(1, 3, 5))
709
+
710
+
711
+ def exchanger_domain(hx, report, grids, voxel=None, open_fraction=0.3, closures="design", local_porosity=True):
712
+ """The coarse porous grid of a designed exchanger (design_heat_exchanger
713
+ with grids=True): core cells from the core's shape, each fluid's
714
+ plenums and ports from its space on the design grid, inlets and outlets
715
+ at the ports' ends."""
716
+ step = float(grids["step"])
717
+ if voxel is None:
718
+ from .heatx import port_diameter
719
+ voxel = min(hx.cell / 2.0, min(port_diameter(hx, 0), port_diameter(hx, 1)) / 4.0, hx.header / 3.0)
720
+ r = max(1, int(round(voxel / step)))
721
+ h_mm = r * step
722
+ fr = [_block_mean(m.reshape(grids["dims"]), r) for m in grids["masks"]]
723
+ dims = fr[0].shape
724
+ lo = np.asarray(grids["lo"], float)
725
+ # the core's share of each cell (4^3 samples), so the core's volume (and so its
726
+ # wetted area) is right however its faces fall on the grid
727
+ sub = (np.indices((4, 4, 4)).reshape(3, -1).T + 0.5) / 4.0
728
+ base = lo + np.indices(dims).reshape(3, -1).T * h_mm
729
+ frac = np.zeros(int(np.prod(dims)))
730
+ for o in sub:
731
+ frac += hx.core.sdf(base + o * h_mm) < 0
732
+ frac = (frac / len(sub)).reshape(dims)
733
+ core = frac >= 0.5
734
+ opn = [(fr[f] >= open_fraction) & ~core for f in (0, 1)]
735
+ both = opn[0] & opn[1]
736
+ opn[0] &= ~(both & (fr[1] > fr[0]))
737
+ opn[1] &= ~(both & (fr[0] >= fr[1]))
738
+ # each fluid's local share of the core, over about a cell: lower where the design
739
+ # seals it off the surface (outside its zones) or closes its channels (closure);
740
+ # measured on cells wholly inside the core, spread to the rest
741
+ inner = (frac > 0.95).astype(np.float32)
742
+ win = max(3, int(round(_PERC["window"] * hx.cell / h_mm)) | 1)
743
+ wsum = ndimage.uniform_filter(inner, win, mode="constant")
744
+ phi_loc = []
745
+ for f in (0, 1):
746
+ pl = ndimage.uniform_filter(fr[f] * inner, win, mode="constant") / np.maximum(wsum, 1e-6)
747
+ deep = ndimage.binary_erosion(core, iterations=win) & (inner > 0)
748
+ ref = float(np.median(pl[deep])) if deep.sum() > 8 else float(np.median(pl[core]))
749
+ phi_loc.append(pl / max(ref, 1e-6)) # relative; scaled by the medium's phi below
750
+ pad = lambda a: np.pad(a, 1)
751
+ # cells in the core carry all of its volume between them
752
+ frac = pad(np.where(core, frac * frac.sum() / max(frac[core].sum(), 1e-9), 0.0))
753
+ core = pad(core)
754
+ opn = [pad(o) for o in opn]
755
+ lo = lo - h_mm
756
+ dims = core.shape
757
+ pts = lo + (np.indices(dims).reshape(3, -1).T + 0.5) * h_mm
758
+ v = report["coreVolume"]
759
+ cells, inlet, outlet, media = [], [], [], []
760
+ for f, name in enumerate(hx.names):
761
+ rep = report[name]
762
+ phi = rep["coreVolume"] / v
763
+ med = _CLOSURES[closures][0](phi, rep["coreWettedArea"] / v * 1e3, rep["hydraulicDiameter"] * 1e-3, hx.cell * 1e-3)
764
+ m = core | opn[f]
765
+ ends = []
766
+ for end in ("in", "out"):
767
+ z = report["zones"][f"{name}-{end}"]
768
+ p1, ax = np.asarray(z["port"][0], float), np.asarray(z["port"][1], float)
769
+ rel = pts - p1
770
+ t = rel @ ax
771
+ rad = np.linalg.norm(rel - np.outer(t, ax), axis=1)
772
+ sel = ((t <= 0) & (t >= -max(0.5 * hx.port_length, 1.5 * h_mm)) &
773
+ (rad <= 0.5 * z.get("portDiameter", hx.port_diameter) + 0.5 * h_mm)).reshape(dims) & opn[f]
774
+ if not sel.any():
775
+ d = np.linalg.norm(rel, axis=1).reshape(dims)
776
+ d[~opn[f]] = np.inf
777
+ sel = d <= d.min() + 0.5 * h_mm
778
+ ends.append(sel)
779
+ # keep what the inlet reaches
780
+ lbl, _ = ndimage.label(m)
781
+ keep = np.isin(lbl, np.unique(lbl[ends[0]])) & (lbl > 0)
782
+ if not (keep & ends[1]).any():
783
+ raise ValueError(f"fluid {name}'s inlet does not reach its outlet on a {h_mm:.2f} mm grid; "
784
+ "use a finer voxel")
785
+ phi_loc[f] = pad(phi_loc[f]) * phi
786
+ cells.append(keep)
787
+ inlet.append(ends[0] & keep)
788
+ outlet.append(ends[1] & keep)
789
+ media.append(med)
790
+ phi_sheet = (report[hx.names[0]]["coreVolume"] + report[hx.names[1]]["coreVolume"]) / v
791
+ return PorousDomain(h=h_mm * 1e-3, lo=lo, core=core, cells=cells, inlet=inlet, outlet=outlet, media=media,
792
+ names=tuple(hx.names), wall=hx.wall * 1e-3, phi_sheet=phi_sheet,
793
+ extra={"cell": hx.cell, "coreFraction": frac, "phi": phi_loc if local_porosity else None})
794
+
795
+
796
+ def simulate_design(hx, report, grids, duty, voxel=None, closures="design", progress=None, fields=False):
797
+ """Simulate a designed exchanger (design_heat_exchanger(..., grids=True))
798
+ under `duty` (the rating's: {"A": {"fluid", "lPerMin" | "kgPerS",
799
+ "tIn"}, "B": {...}, "wall": "petg" | W/m K})."""
800
+ import time
801
+
802
+ t0 = time.time()
803
+ if closures not in _CLOSURES:
804
+ raise ValueError(f"closures is one of {sorted(_CLOSURES)}")
805
+ dom = exchanger_domain(hx, report, grids, voxel, closures=closures)
806
+ return simulate_domain(dom, duty, closures, progress, fields, t0)
807
+
808
+
809
+ def simulate_domain(dom, duty, closures="design", progress=None, fields=False, t0=None):
810
+ """Flow of both fluids, then heat, on a built domain."""
811
+ import time
812
+
813
+ t0 = t0 if t0 is not None else time.time()
814
+
815
+ class hx: # noqa: N801 (names only)
816
+ names = dom.names
817
+ kw = duty.get("wall", "petg")
818
+ k_wall = float(WALL_CONDUCTIVITY[kw] if isinstance(kw, str) else kw)
819
+ fluids = [_fluid(duty[n].get("fluid", "water")) for n in hx.names]
820
+ flows_kg = [_flow(duty[n], fl) for n, fl in zip(hx.names, fluids)]
821
+ flows = []
822
+ for f in (0, 1):
823
+ flows.append(solve_flow(dom, f, fluids[f], flows_kg[f],
824
+ progress=(lambda **k: progress(fluid=hx.names[f], **k)) if progress else None))
825
+ t_in = [duty[n].get("tIn") for n in hx.names]
826
+ heat = None
827
+ if None not in t_in:
828
+ heat = solve_heat(dom, fluids, flows, [float(t) for t in t_in], k_wall, _CLOSURES[closures][1])
829
+ out = summarize(dom, fluids, flows, heat, t_in)
830
+ out["seconds"] = round(time.time() - t0, 1)
831
+ out["closures"] = closures
832
+ if fields:
833
+ out["_fields"] = {"domain": dom, "flows": flows, "heat": heat}
834
+ return out
835
+
836
+
837
+ def simulate_exchanger(hx, duty, voxel=None, closures="design", progress=None, fields=False):
838
+ """Design `hx` and simulate it whole under `duty`; with the epsilon-NTU
839
+ rating alongside for comparison."""
840
+ from .heatx import design_heat_exchanger
841
+ from .heatx_thermal import rate_design
842
+
843
+ mesh, report, grids = design_heat_exchanger(hx, grids=True)
844
+ sim = simulate_design(hx, report, grids, duty, voxel, closures, progress, fields)
845
+ try:
846
+ sim["rating"] = rate_design(hx, report, duty)
847
+ except ValueError as exc:
848
+ sim["ratingError"] = str(exc)
849
+ return mesh, report, sim
850
+
851
+
852
+ def sweep(hx, duty, variants, voxel=None, closures="design", progress=None):
853
+ """Compare header and port shapes: each variant is a dict of
854
+ HeatExchangerSpec fields ({"header": 4}, {"port_diameter": 10}, ...)
855
+ applied to a copy of `hx`. Rows: each variant's pressure drops, flow
856
+ uniformity and effectiveness, best effectiveness first."""
857
+ import copy
858
+
859
+ rows = []
860
+ for v in variants:
861
+ h2 = copy.deepcopy(hx)
862
+ for k, val in v.items():
863
+ setattr(h2, k, val)
864
+ _, rep, sim = simulate_exchanger(h2, duty, voxel, closures, progress)
865
+ rows.append({"variant": v, "effectiveness": sim.get("effectiveness"),
866
+ "fluids": {n: {"pressureDrop": r["pressureDrop"], "pressureDropCore": r["pressureDropCore"],
867
+ "uniformity": r["entry"].get("uniformity"), "deadShare": r["deadShare"]}
868
+ for n, r in sim["fluids"].items()},
869
+ "rating": (sim.get("rating") or {}).get("effectiveness"),
870
+ "solidVolume": rep["solidVolume"]})
871
+ rows.sort(key=lambda r: -(r["effectiveness"] or 0))
872
+ return rows
873
+
874
+
875
+ def plot_fields(sim_fields, path, axis=2, at=None, title=None):
876
+ """A section through the exchanger (normal `axis`, at `at` mm, default
877
+ the core's middle): each fluid's speed and temperature and the wall's
878
+ temperature, as a PNG."""
879
+ import matplotlib
880
+ matplotlib.use("Agg")
881
+ import matplotlib.pyplot as plt
882
+
883
+ dom, flows, heat = sim_fields["domain"], sim_fields["flows"], sim_fields["heat"]
884
+ hmm = dom.h * 1e3
885
+ core_idx = np.argwhere(dom.core)
886
+ k = int(round((at - dom.lo[axis]) / hmm - 0.5)) if at is not None else \
887
+ int(round(0.5 * (core_idx[:, axis].min() + core_idx[:, axis].max())))
888
+ sl = [slice(None)] * 3
889
+ sl[axis] = k
890
+ sl = tuple(sl)
891
+ ij = [a for a in range(3) if a != axis]
892
+ ext = [dom.lo[ij[0]], dom.lo[ij[0]] + dom.dims[ij[0]] * hmm, dom.lo[ij[1]], dom.lo[ij[1]] + dom.dims[ij[1]] * hmm]
893
+ panels = []
894
+ for f, n in enumerate(dom.names):
895
+ sp = _cell_speed(flows[f]["U"], dom.cells[f])
896
+ # scaled to the core: the ports run far faster
897
+ top = 2.0 * float(np.percentile(sp[dom.core & dom.cells[f]], 95))
898
+ panels.append((f"{n}: speed (m/s, superficial)", np.where(dom.cells[f], np.minimum(sp, top), np.nan)[sl],
899
+ "viridis"))
900
+ if heat is not None:
901
+ for f, n in enumerate(dom.names):
902
+ panels.append((f"{n}: temperature (C)", heat["T"][f][sl], "inferno"))
903
+ panels.append(("wall temperature (C)", heat["Twall"][sl], "inferno"))
904
+ fig, axs = plt.subplots(1, len(panels), figsize=(3.6 * len(panels), 3.6), dpi=150)
905
+ for ax_, (name, img, cmap) in zip(np.atleast_1d(axs), panels):
906
+ im = ax_.imshow(img.T, origin="lower", extent=ext, cmap=cmap, interpolation="bilinear")
907
+ ax_.set_title(name, fontsize=8)
908
+ ax_.set_xlabel("xyz"[ij[0]] + " (mm)", fontsize=7)
909
+ ax_.set_ylabel("xyz"[ij[1]] + " (mm)", fontsize=7)
910
+ ax_.tick_params(labelsize=6)
911
+ fig.colorbar(im, ax=ax_, shrink=0.75).ax.tick_params(labelsize=6)
912
+ if title:
913
+ fig.suptitle(title, fontsize=9)
914
+ fig.tight_layout()
915
+ fig.savefig(path)
916
+ plt.close(fig)
917
+ return path
918
+
919
+
920
+ def box_domain(hx, voxel=None, closures="design"):
921
+ """The porous grid of a box-cored crossflow exchanger built straight
922
+ from its spec, without designing it: for cores too big to design on a
923
+ wall-resolving grid (an intercooler at 0.5 mm walls is hundreds of
924
+ millions of points). Plenums are slabs `header` deep over each zone
925
+ face, inset `cap` from its edges; ports are round, on the face's
926
+ centre, `port_length` long; each fluid's core is dead within the edge
927
+ band (cap + a quarter cell) of every face it is sealed under, as the
928
+ calibrated local porosity finds on designed cores. The medium is the
929
+ analytic sheet geometry (tpms_geometry). Ignores print shaping (roofs,
930
+ closure), which the band stands in for."""
931
+ from .heatx import port_diameter, zones_for
932
+ from .heatx_thermal import tpms_geometry
933
+
934
+ if hx.layout != "cross" or hx.zones is not None:
935
+ raise ValueError("box_domain builds the cross layout; design other layouts with heatx")
936
+ clo, chi = (np.asarray(b, float) for b in hx.core.bounds())
937
+ ext = chi - clo
938
+ a0, a1 = (int(a) if not isinstance(a, str) else "xyz".index(a) for a in hx.axes)
939
+ a2 = 3 - a0 - a1
940
+ cap = hx.cap if hx.cap is not None else hx.wall + 1.0
941
+ pd = [port_diameter(hx, 0), port_diameter(hx, 1)]
942
+ if voxel is None:
943
+ voxel = min(hx.cell / 2.0, min(pd) / 4.0, hx.header / 3.0, float(ext.min()) / 12.0)
944
+ h_mm = float(voxel)
945
+ reach = hx.header + hx.port_length + 2 * h_mm
946
+ lo = clo - reach
947
+ dims = tuple(int(np.ceil((ext[i] + 2 * reach) / h_mm)) + 2 for i in range(3))
948
+ lo = lo - h_mm
949
+ pts = lo + (np.indices(dims).reshape(3, -1).T + 0.5) * h_mm
950
+ inside = np.all((pts >= clo) & (pts <= chi), axis=1)
951
+ core = inside.reshape(dims)
952
+ geo = tpms_geometry(hx.kind, hx.cell, hx.wall, hx.split)
953
+ law = _CLOSURES[closures][0]
954
+ band = cap + _BAND_CELLS * hx.cell
955
+ cells, inlet, outlet, media, phi_loc = [], [], [], [], []
956
+ for f, ax in ((0, a0), (1, a1)):
957
+ side = geo["sides"][f]
958
+ med = law(side["phi"], side["areaDensity"] * 1e3, side["dh"] * 1e-3, hx.cell * 1e-3)
959
+ others = [o for o in range(3) if o != ax]
960
+ # dead within the band of every face this fluid is sealed under
961
+ d_edge = np.min([np.minimum(pts[:, o] - clo[o], chi[o] - pts[:, o]) for o in others], axis=0)
962
+ live = (inside & (d_edge >= band)).reshape(dims)
963
+ pl = np.where(live, side["phi"], 0.0)
964
+ opn = np.zeros(int(np.prod(dims)), bool)
965
+ ends = []
966
+ for sgn, face in ((-1, clo[ax]), (1, chi[ax])):
967
+ out = sgn * (pts[:, ax] - face) # distance out of the face
968
+ lat = np.all([(pts[:, o] >= clo[o] + cap) & (pts[:, o] <= chi[o] - cap) for o in others], axis=0)
969
+ plen = (out > 0) & (out <= hx.header) & lat
970
+ c = 0.5 * (clo + chi)
971
+ rad = np.linalg.norm((pts - c)[:, others], axis=1)
972
+ port = (out > hx.header - h_mm) & (out <= hx.header + hx.port_length) & (rad <= 0.5 * pd[f])
973
+ opn |= plen | port
974
+ ends.append((port & (out > hx.header + hx.port_length - max(1.5 * h_mm, 0.5 * hx.port_length)))
975
+ .reshape(dims))
976
+ m = core | opn.reshape(dims)
977
+ cells.append(m)
978
+ inlet.append(ends[0] & m)
979
+ outlet.append(ends[1] & m)
980
+ media.append(med)
981
+ phi_loc.append(pl)
982
+ return PorousDomain(h=h_mm * 1e-3, lo=lo, core=core, cells=cells, inlet=inlet, outlet=outlet, media=media,
983
+ names=tuple(hx.names), wall=hx.wall * 1e-3, phi_sheet=geo["phiSheet"],
984
+ extra={"cell": hx.cell, "coreFraction": core.astype(float), "phi": phi_loc})
985
+
986
+
987
+ def simulate_box(hx, duty, voxel=None, closures="design", progress=None, fields=False):
988
+ """box_domain + simulate_domain: a whole crossflow box exchanger in
989
+ seconds to minutes, without designing it."""
990
+ import time
991
+
992
+ t0 = time.time()
993
+ dom = box_domain(hx, voxel, closures)
994
+ return simulate_domain(dom, duty, closures, progress, fields, t0)