loadpath 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/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)
|