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
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thermal and hydraulic rating of a sheet TPMS heat exchanger, and sizing its
|
|
3
|
+
cell and wall: effectiveness-NTU, with the heat transfer coefficient and
|
|
4
|
+
the pressure drop from published correlations for gyroid channels.
|
|
5
|
+
|
|
6
|
+
Each fluid fills one side of the sheet. Seen from one fluid, its side is
|
|
7
|
+
the void of a network (solid) gyroid of the same porosity, so pressure
|
|
8
|
+
drop uses a Darcy-Forchheimer fit for network gyroids:
|
|
9
|
+
|
|
10
|
+
dp/dx = mu U_s / K + rho C_F U_s^2 / sqrt(K), K = 0.0157 phi^2.72 a^2,
|
|
11
|
+
C_F = 0.0784 phi^-2.04
|
|
12
|
+
(Savoldi, Cammi, Gajetti, Marocco 2026, Int. J. Heat Fluid Flow 121:110631;
|
|
13
|
+
gyroid, 20 <= Re <= 100, 0.3 <= phi <= 0.7, +-15%. Against the 1KZ
|
|
14
|
+
cooler's LBM cells, gyroid sheets, it holds to 6-9% median (0.79-1.20)
|
|
15
|
+
out to Re 1,500 and down to phi 0.17, so it is flagged only past those)
|
|
16
|
+
|
|
17
|
+
where U_s is the superficial velocity (flow over the core's frontal area),
|
|
18
|
+
phi the fluid's share of the core volume and a the cell. Heat transfer:
|
|
19
|
+
|
|
20
|
+
Nu = 0.49 Re^0.62 Pr^0.4
|
|
21
|
+
(Reynolds, Fee, Morison, Holland 2023, IJHMT 212:124264; printed polymer
|
|
22
|
+
sheet gyroid, water and air, 100 < Re < 2500; Re and Nu on
|
|
23
|
+
Dh = 4 V_f / A_wet and the interstitial velocity U_s / phi)
|
|
24
|
+
|
|
25
|
+
which the rating designs on. Two more fits bound it:
|
|
26
|
+
|
|
27
|
+
Nu = 1.66 Re^0.464 Pr^0.217
|
|
28
|
+
(LBM, periodic gyroid-sheet cells, fully developed, isothermal wall with
|
|
29
|
+
a uniform heat source in the fluid; 39 points, 8-16 mm cells at 1.0-1.5 mm
|
|
30
|
+
walls, air Re 190-1470 and water Re 11-72; within 7% of every point.
|
|
31
|
+
From the 1KZ charge-cooler project, connorwhite-online/intercooler.
|
|
32
|
+
Same Dh and velocity as above; it runs 1.27x Reynolds 2023 on air, 1.53x
|
|
33
|
+
on water)
|
|
34
|
+
|
|
35
|
+
Nu = 0.148 Re^0.704 Pr^0.395
|
|
36
|
+
(Cordisco et al. 2025, SAE 2025-24-0015, CHT RANS of whole exchangers;
|
|
37
|
+
velocity 2 U_s / phi = U_s / phi_i, Dh = 4 V_f / A_wall)
|
|
38
|
+
|
|
39
|
+
Cordisco's h is not a local coefficient: it is the exchanger's heat over
|
|
40
|
+
its wall area times (mean wall temperature - the arithmetic mean of inlet
|
|
41
|
+
and outlet bulk temperatures). When one side's own NTU is large, as it is in
|
|
42
|
+
a TPMS core, the arithmetic mean overstates the driving difference (at an
|
|
43
|
+
NTU of 4 by about 2x against the log mean), so it reads 2-4x below the
|
|
44
|
+
local fits, more so at low Re. It is the low end of the reported band
|
|
45
|
+
(nuSpread), not a design value. Diamond uses Cordisco's 0.255 Re^0.640
|
|
46
|
+
Pr^0.328 (the only diamond fit, so conservative); Schwarz P falls back to
|
|
47
|
+
the gyroid's (a rough stand-in, flagged).
|
|
48
|
+
|
|
49
|
+
The two networks need not share the void equally: `split` (fluid A's share
|
|
50
|
+
of the void, default 0.5) moves the sheet off the mid-surface toward B. An
|
|
51
|
+
air side wants the bigger share against a liquid. The geometry is then
|
|
52
|
+
measured on one periodic cell (phi, wetted area and Dh per side).
|
|
53
|
+
|
|
54
|
+
The wall is a plane conductor (it is thin against the channels), so
|
|
55
|
+
1/U = 1/h_A + t/k_wall + 1/h_B on the wetted area. Effectiveness is the
|
|
56
|
+
counterflow formula for the "counter" layout and the unmixed-unmixed cross
|
|
57
|
+
flow formula otherwise. Port losses add 1.5 velocity heads per port pair;
|
|
58
|
+
the channels' mouths add a contraction in (less with rounded mouths, after
|
|
59
|
+
Idelchik's rounded inlets) and a Borda-Carnot expansion out.
|
|
60
|
+
|
|
61
|
+
Units: SI inside, mm for geometry at the edges (cells, walls, lengths).
|
|
62
|
+
"""
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
|
|
67
|
+
import numpy as np
|
|
68
|
+
|
|
69
|
+
__all__ = ["Fluid", "FLUIDS", "WALL_CONDUCTIVITY", "tpms_geometry", "rate", "rate_design", "size_core"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Fluid:
|
|
74
|
+
name: str
|
|
75
|
+
rho: float # kg/m^3
|
|
76
|
+
cp: float # J/kg K
|
|
77
|
+
k: float # W/m K
|
|
78
|
+
mu: float # Pa s
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def pr(self):
|
|
82
|
+
return self.cp * self.mu / self.k
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# near room temperature; pass a Fluid for anything else
|
|
86
|
+
FLUIDS = {
|
|
87
|
+
"water": Fluid("water", 997.0, 4180.0, 0.60, 0.89e-3),
|
|
88
|
+
"water60": Fluid("water60", 983.0, 4185.0, 0.65, 0.47e-3),
|
|
89
|
+
"air": Fluid("air", 1.18, 1007.0, 0.026, 1.85e-5),
|
|
90
|
+
"glycol50": Fluid("glycol50", 1065.0, 3300.0, 0.38, 3.5e-3),
|
|
91
|
+
"oil": Fluid("oil", 870.0, 1900.0, 0.14, 0.03),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# W/m K, as printed (dense). Polymers: Tychanicz-Kwiecien et al. 2025 for PLA
|
|
95
|
+
# and PETG; PA, PP and PA-CF from datasheets. Metals: LPBF as built.
|
|
96
|
+
WALL_CONDUCTIVITY = {"pla": 0.19, "petg": 0.20, "pa": 0.27, "pp": 0.18, "pa-cf": 0.4, "resin": 0.18,
|
|
97
|
+
"alsi10mg": 125.0, "316l": 13.0}
|
|
98
|
+
|
|
99
|
+
# mid-surface area per period cube, in a^2
|
|
100
|
+
_SURFACE = {"gyroid": 3.09, "diamond": 3.84, "schwarz-p": 2.35}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# fit ranges: Nu (Reynolds 2023 100-2500, the LBM cells down to 11), pressure drop
|
|
104
|
+
# (Savoldi, extended by the LBM cells to Re 1,500 and phi 0.17)
|
|
105
|
+
_NU_RE = (10.0, 2500.0)
|
|
106
|
+
_DP_RE = (1.0, 1500.0)
|
|
107
|
+
_DP_PHI = (0.15, 0.7)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tpms_geometry(kind: str, cell: float, wall: float, split: float = 0.5) -> dict:
|
|
111
|
+
"""One fluid's side of a sheet TPMS: its share of the volume, wetted
|
|
112
|
+
area per volume (1/mm) and hydraulic diameter (mm). With split 0.5,
|
|
113
|
+
analytic and the same for both sides; otherwise measured on a periodic
|
|
114
|
+
cell, and "sides" holds fluid A's and B's (A has `split` of the void)."""
|
|
115
|
+
s = _SURFACE[kind]
|
|
116
|
+
phi_sheet = 1.0 - s * wall / cell
|
|
117
|
+
if phi_sheet <= 0.2:
|
|
118
|
+
raise ValueError(f"a {wall:g} mm wall closes a {cell:g} mm {kind}")
|
|
119
|
+
if abs(split - 0.5) < 1e-9:
|
|
120
|
+
phi = 0.5 * phi_sheet
|
|
121
|
+
beta = s / cell
|
|
122
|
+
g = {"phi": phi, "phiSheet": phi_sheet, "areaDensity": beta, "dh": 4.0 * phi / beta}
|
|
123
|
+
return {**g, "sides": [dict(g), dict(g)], "offset": 0.0}
|
|
124
|
+
cellg = _cell_geometry(kind, round(wall / cell, 4), round(float(split), 4))
|
|
125
|
+
sides = [{"phi": p, "phiSheet": cellg["phiSheet"], "areaDensity": a / cell, "dh": 4.0 * p / (a / cell)}
|
|
126
|
+
for p, a in zip(cellg["phi"], cellg["area"])]
|
|
127
|
+
return {**sides[0], "sides": sides, "offset": cellg["offset"] * cell}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_CELL_CACHE = {}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cell_field(kind):
|
|
134
|
+
from .lattice import tpms
|
|
135
|
+
|
|
136
|
+
return lambda pts: tpms(kind, pts, 1.0)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def sheet_offset(field, wall: float, split: float, n: int = 48) -> float:
|
|
140
|
+
"""Level (in the field's units, cell = 1) to move a sheet of thickness
|
|
141
|
+
`wall` (a fraction of the cell) to, so that the side where field > level
|
|
142
|
+
gets `split` of the void. `field(pts)` is a signed distance-like TPMS
|
|
143
|
+
field over a unit cell."""
|
|
144
|
+
if abs(split - 0.5) < 1e-9:
|
|
145
|
+
return 0.0
|
|
146
|
+
ax = (np.arange(n) + 0.5) / n
|
|
147
|
+
pts = np.stack(np.meshgrid(ax, ax, ax, indexing="ij"), -1).reshape(-1, 3)
|
|
148
|
+
d = np.asarray(field(pts), float)
|
|
149
|
+
h = 0.5 * wall
|
|
150
|
+
|
|
151
|
+
def share(c):
|
|
152
|
+
a = np.count_nonzero(d > c + h)
|
|
153
|
+
b = np.count_nonzero(d < c - h)
|
|
154
|
+
return a / max(a + b, 1)
|
|
155
|
+
|
|
156
|
+
lo, hi = float(d.min()), float(d.max())
|
|
157
|
+
if not share(lo) >= split >= share(hi):
|
|
158
|
+
raise ValueError(f"no sheet of {wall:g} cells gives fluid A {split:g} of the void")
|
|
159
|
+
for _ in range(50):
|
|
160
|
+
mid = 0.5 * (lo + hi)
|
|
161
|
+
if share(mid) > split:
|
|
162
|
+
lo = mid
|
|
163
|
+
else:
|
|
164
|
+
hi = mid
|
|
165
|
+
return 0.5 * (lo + hi)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _cell_geometry(kind, wall, split, n=48):
|
|
169
|
+
"""phi and wetted area (per unit cell, cell = 1) of each side of a sheet
|
|
170
|
+
`wall` thick (cells) moved so that A has `split` of the void."""
|
|
171
|
+
key = (kind, wall, split)
|
|
172
|
+
if key in _CELL_CACHE:
|
|
173
|
+
return _CELL_CACHE[key]
|
|
174
|
+
from skimage.measure import marching_cubes
|
|
175
|
+
|
|
176
|
+
field = _cell_field(kind)
|
|
177
|
+
c = sheet_offset(field, wall, split, n)
|
|
178
|
+
ax = (np.arange(n + 1)) / n # closed grid, so the periodic faces close
|
|
179
|
+
pts = np.stack(np.meshgrid(ax, ax, ax, indexing="ij"), -1).reshape(-1, 3)
|
|
180
|
+
d = np.asarray(field(pts), float).reshape(n + 1, n + 1, n + 1)
|
|
181
|
+
h = 0.5 * wall
|
|
182
|
+
inner = d[:-1, :-1, :-1]
|
|
183
|
+
phi = [float(np.mean(inner > c + h)), float(np.mean(inner < c - h))]
|
|
184
|
+
area = []
|
|
185
|
+
for lvl in (c + h, c - h):
|
|
186
|
+
v, f, _, _ = marching_cubes(d, lvl, spacing=(1.0 / n,) * 3)
|
|
187
|
+
tri = v[f]
|
|
188
|
+
area.append(float(0.5 * np.linalg.norm(np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]), axis=1).sum()))
|
|
189
|
+
out = {"phi": phi, "area": area, "phiSheet": phi[0] + phi[1], "offset": c}
|
|
190
|
+
_CELL_CACHE[key] = out
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _flow(spec, fluid):
|
|
195
|
+
"""kg/s from {"kgPerS"} or {"lPerMin"}."""
|
|
196
|
+
if "kgPerS" in spec:
|
|
197
|
+
return float(spec["kgPerS"])
|
|
198
|
+
if "lPerMin" in spec:
|
|
199
|
+
return float(spec["lPerMin"]) / 60000.0 * fluid.rho
|
|
200
|
+
raise ValueError("each fluid's duty needs a flow: \"kgPerS\" or \"lPerMin\"")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _fluid(v):
|
|
204
|
+
if isinstance(v, Fluid):
|
|
205
|
+
return v
|
|
206
|
+
if v not in FLUIDS:
|
|
207
|
+
raise ValueError(f"fluid must be one of {sorted(FLUIDS)} or a Fluid, got {v!r}")
|
|
208
|
+
return FLUIDS[v]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# loss coefficient of an inlet from a large space by its edge radius over Dh (Idelchik,
|
|
212
|
+
# rounded inlet flush with a wall): sharp 0.5 down to 0.04 once r/Dh reaches 0.15
|
|
213
|
+
_ENTRY = (np.array([0.0, 0.02, 0.04, 0.06, 0.10, 0.15]), np.array([0.5, 0.28, 0.24, 0.15, 0.09, 0.04]))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _mouths(fluid, us, phi, dh, mouth_radius):
|
|
217
|
+
"""Pressure lost getting from the plenum into the channels and back out:
|
|
218
|
+
a contraction to the open share `phi` of the face, its loss cut by
|
|
219
|
+
rounded mouths (r/Dh), and a Borda-Carnot expansion out, which rounding
|
|
220
|
+
barely helps. Both on the channel velocity U_s / phi."""
|
|
221
|
+
q = 0.5 * fluid.rho * (us / phi) ** 2
|
|
222
|
+
k_in = float(np.interp(mouth_radius / dh, *_ENTRY)) * (1.0 - phi)
|
|
223
|
+
k_out = (1.0 - phi) ** 2
|
|
224
|
+
return k_in * q, k_out * q, k_in
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _side(kind, fluid, m, phi, phi_sheet, dh, cell, length, frontal, port_area, mouth_radius=0.0):
|
|
228
|
+
"""One fluid's channel: velocities, Re, h (both correlations), pressure drop."""
|
|
229
|
+
us = m / (fluid.rho * frontal) # superficial, m/s
|
|
230
|
+
dh_m = dh * 1e-3
|
|
231
|
+
re = fluid.rho * (us / phi) * dh_m / fluid.mu
|
|
232
|
+
pr = fluid.pr
|
|
233
|
+
flags = []
|
|
234
|
+
if kind == "diamond":
|
|
235
|
+
nu = 0.255 * re ** 0.640 * pr ** 0.328
|
|
236
|
+
fits = {"cordisco2025": nu}
|
|
237
|
+
else:
|
|
238
|
+
nu = 0.49 * re ** 0.62 * pr ** 0.4
|
|
239
|
+
fits = {"reynolds2023": nu, "lbmCells": 1.66 * re ** 0.464 * pr ** 0.217,
|
|
240
|
+
"cordisco2025": 0.148 * re ** 0.704 * pr ** 0.395}
|
|
241
|
+
if kind != "gyroid":
|
|
242
|
+
flags.append(f"{kind}: gyroid correlations used")
|
|
243
|
+
if not _NU_RE[0] <= re <= _NU_RE[1]:
|
|
244
|
+
flags.append(f"Re {re:.0f} is outside the Nu fits ({_NU_RE[0]:g}-{_NU_RE[1]:g})")
|
|
245
|
+
h = nu * fluid.k / dh_m
|
|
246
|
+
a = cell * 1e-3
|
|
247
|
+
k_perm = 0.0157 * phi ** 2.72 * a ** 2
|
|
248
|
+
cf = 0.0784 * phi ** -2.04
|
|
249
|
+
dpdx = fluid.mu * us / k_perm + fluid.rho * cf * us ** 2 / np.sqrt(k_perm)
|
|
250
|
+
dp_core = dpdx * length * 1e-3
|
|
251
|
+
v_port = m / (fluid.rho * port_area)
|
|
252
|
+
dp_ports = 1.5 * 0.5 * fluid.rho * v_port ** 2
|
|
253
|
+
dp_in, dp_out, k_in = _mouths(fluid, us, phi, dh, mouth_radius)
|
|
254
|
+
if not _DP_RE[0] <= re <= _DP_RE[1] or not _DP_PHI[0] <= phi <= _DP_PHI[1]:
|
|
255
|
+
flags.append(f"pressure drop extrapolated past its fit (Re up to {_DP_RE[1]:g}, "
|
|
256
|
+
f"phi {_DP_PHI[0]:g}-{_DP_PHI[1]:g})")
|
|
257
|
+
return {"superficialVelocity": us, "re": re, "pr": pr, "nu": nu, "nuFits": fits,
|
|
258
|
+
"nuSpread": [min(fits.values()), max(fits.values())],
|
|
259
|
+
"hSpread": [min(fits.values()) * fluid.k / dh_m, max(fits.values()) * fluid.k / dh_m],
|
|
260
|
+
"h": h, "pressureDrop": dp_core + dp_ports + dp_in + dp_out,
|
|
261
|
+
"pressureDropCore": dp_core, "pressureDropPorts": dp_ports, "pressureDropMouths": dp_in + dp_out,
|
|
262
|
+
"mouthEntryK": k_in, "flags": flags}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _effectiveness(ntu, cr, arrangement):
|
|
266
|
+
if arrangement == "counter":
|
|
267
|
+
if abs(1 - cr) < 1e-6:
|
|
268
|
+
return ntu / (1 + ntu)
|
|
269
|
+
e = np.exp(-ntu * (1 - cr))
|
|
270
|
+
return (1 - e) / (1 - cr * e)
|
|
271
|
+
# cross flow, both fluids unmixed
|
|
272
|
+
if cr < 1e-9:
|
|
273
|
+
return 1 - np.exp(-ntu)
|
|
274
|
+
return 1 - np.exp(ntu ** 0.22 / cr * (np.exp(-cr * ntu ** 0.78) - 1))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def rate(kind, cell, wall, core_volume, lengths, duty, arrangement="cross", port_diameter=8.0, geometry=None,
|
|
278
|
+
mouth_radius=0.0, split=0.5):
|
|
279
|
+
"""Rate one design. `core_volume` mm^3; `lengths` (mm) each fluid's flow
|
|
280
|
+
path through the core; `duty` = {"A": {"fluid": "water", "lPerMin": 2,
|
|
281
|
+
"tIn": 80}, "B": {...}, "wall": "petg" | W/mK}. `geometry` overrides the
|
|
282
|
+
analytic one per fluid (from a design report: phi, areaDensity, dh).
|
|
283
|
+
`lengths` and `geometry` follow the order the fluids are named in `duty`.
|
|
284
|
+
`mouth_radius` (mm) rounds the channels' mouths (a design's blend).
|
|
285
|
+
`split`: the first fluid's share of the void (the sheet moved toward the
|
|
286
|
+
second). The result's "range" rates the design again at the low and
|
|
287
|
+
high ends of the Nu band (effectivenessRange, tOutRange)."""
|
|
288
|
+
kw = duty.get("wall", "petg")
|
|
289
|
+
k_wall = float(WALL_CONDUCTIVITY[kw] if isinstance(kw, str) else kw)
|
|
290
|
+
names = [n for n in duty if n != "wall"]
|
|
291
|
+
if len(names) != 2:
|
|
292
|
+
raise ValueError("duty names two fluids, e.g. {\"A\": {...}, \"B\": {...}}")
|
|
293
|
+
base = tpms_geometry(kind, cell, wall, split)
|
|
294
|
+
sides, caps, areas = {}, [], []
|
|
295
|
+
for i, n in enumerate(names):
|
|
296
|
+
geo = dict(base["sides"][i])
|
|
297
|
+
if geometry is not None:
|
|
298
|
+
geo.update(geometry[i])
|
|
299
|
+
fl = _fluid(duty[n].get("fluid", "water"))
|
|
300
|
+
m = _flow(duty[n], fl)
|
|
301
|
+
frontal = core_volume / lengths[i] * 1e-6 # m^2
|
|
302
|
+
pd = port_diameter[i] if isinstance(port_diameter, (tuple, list)) else port_diameter
|
|
303
|
+
port_area = np.pi * (0.5 * pd * 1e-3) ** 2
|
|
304
|
+
s = _side(kind, fl, m, geo["phi"], geo.get("phiSheet", 2 * geo["phi"]), geo["dh"], cell, lengths[i],
|
|
305
|
+
frontal, port_area, mouth_radius)
|
|
306
|
+
s["massFlow"] = m
|
|
307
|
+
sides[n] = s
|
|
308
|
+
caps.append(m * fl.cp)
|
|
309
|
+
areas.append(geo["areaDensity"] * core_volume * 1e-6) # 1/mm * mm^3 = mm^2 -> m^2
|
|
310
|
+
area = float(np.mean(areas))
|
|
311
|
+
r_wall = wall * 1e-3 / k_wall
|
|
312
|
+
cmin, cmax = min(caps), max(caps)
|
|
313
|
+
cr = cmin / cmax
|
|
314
|
+
t = [duty[n].get("tIn") for n in names]
|
|
315
|
+
|
|
316
|
+
def solve(ha, hb):
|
|
317
|
+
u = 1.0 / (1.0 / ha + r_wall + 1.0 / hb)
|
|
318
|
+
ntu = u * area / cmin
|
|
319
|
+
return u, ntu, float(_effectiveness(ntu, cr, arrangement))
|
|
320
|
+
|
|
321
|
+
u, ntu, eff = solve(sides[names[0]]["h"], sides[names[1]]["h"])
|
|
322
|
+
out = {"U": u, "UA": u * area, "area": area, "NTU": ntu, "Cr": cr, "effectiveness": eff,
|
|
323
|
+
"wallResistanceShare": r_wall / (1.0 / u), "arrangement": arrangement, "fluids": sides}
|
|
324
|
+
lo_hi = [solve(sides[names[0]]["hSpread"][j], sides[names[1]]["hSpread"][j])[2] for j in (0, 1)]
|
|
325
|
+
out["effectivenessRange"] = lo_hi
|
|
326
|
+
if None not in t:
|
|
327
|
+
q = eff * cmin * (t[0] - t[1])
|
|
328
|
+
# the heat moved, whichever fluid is listed first; dutySign is +1 when the first
|
|
329
|
+
# fluid is the hot one (it gives the heat), -1 when it takes it
|
|
330
|
+
out["duty"] = abs(q)
|
|
331
|
+
out["dutySign"] = 1 if q >= 0 else -1
|
|
332
|
+
out["hotFluid"] = names[0] if t[0] >= t[1] else names[1]
|
|
333
|
+
out["tOut"] = {names[0]: t[0] - q / caps[0], names[1]: t[1] + q / caps[1]}
|
|
334
|
+
out["tOutRange"] = {n: sorted(tin - (1 if i == 0 else -1) * e * cmin * (t[0] - t[1]) / caps[i]
|
|
335
|
+
for e in lo_hi) for i, (n, tin) in enumerate(zip(names, t))}
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _lengths(hx, report):
|
|
340
|
+
"""Each fluid's path through the core: from its inlet zone's centre to
|
|
341
|
+
its outlet's, on the core's surface (from the design report)."""
|
|
342
|
+
out = []
|
|
343
|
+
for n in hx.names:
|
|
344
|
+
a = np.asarray(report["zones"][f"{n}-in"]["center"])
|
|
345
|
+
b = np.asarray(report["zones"][f"{n}-out"]["center"])
|
|
346
|
+
out.append(float(np.linalg.norm(b - a)))
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def rate_design(hx, report, duty):
|
|
351
|
+
"""Rate a designed exchanger from its report: the measured channel
|
|
352
|
+
geometry (share of volume, wetted area, hydraulic diameter) and the
|
|
353
|
+
zones' flow paths."""
|
|
354
|
+
v = report["coreVolume"]
|
|
355
|
+
geo = []
|
|
356
|
+
for n in hx.names:
|
|
357
|
+
r = report[n]
|
|
358
|
+
if not r.get("hydraulicDiameter"):
|
|
359
|
+
raise ValueError(f"fluid {n} reaches none of the core; fix the design (its report) before rating it")
|
|
360
|
+
geo.append({"phi": r["coreVolume"] / v, "areaDensity": r["coreWettedArea"] / v, "dh": r["hydraulicDiameter"],
|
|
361
|
+
"phiSheet": (report[hx.names[0]]["coreVolume"] + report[hx.names[1]]["coreVolume"]) / v})
|
|
362
|
+
duty = {**{k: v_ for k, v_ in duty.items() if k == "wall"},
|
|
363
|
+
**{n: duty[n] for n in hx.names}}
|
|
364
|
+
return rate(hx.kind, hx.cell, hx.wall, v, _lengths(hx, report), duty,
|
|
365
|
+
"counter" if hx.layout == "counter" else "cross", hx.port_diameter, geo,
|
|
366
|
+
1.2 * float(report.get("blend", 0.0))) # a smooth union of radius k rounds about 1.2 k
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# g/cm^3, for the core's mass in sizing (as printed, dense)
|
|
370
|
+
WALL_DENSITY = {"pla": 1.24, "petg": 1.27, "pa": 1.01, "pp": 0.90, "pa-cf": 1.15, "resin": 1.18,
|
|
371
|
+
"alsi10mg": 2.67, "316l": 7.98}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def size_core(kind, core_volume, lengths, duty, arrangement="cross", port_diameter=8.0, min_wall=0.8,
|
|
375
|
+
max_pressure_drop=None, cells=None, walls=None, min_channel=1.5, split=0.5,
|
|
376
|
+
objective="effectiveness", min_effectiveness=None):
|
|
377
|
+
"""Sweep cell and wall for a core and duty. Keeps the candidates that
|
|
378
|
+
meet the pressure-drop limits ({"A": Pa, "B": Pa}) and print (the wall
|
|
379
|
+
at least `min_wall`, channels at least `min_channel` mm across) and, if
|
|
380
|
+
given, `min_effectiveness`. Each carries its core's solid volume (mm^3)
|
|
381
|
+
and mass (g, from the duty's wall material). Ranked by `objective`:
|
|
382
|
+
"effectiveness" (best first) or "metal" (least solid first: the
|
|
383
|
+
lightest core that meets the limits; thin the wall and keep the
|
|
384
|
+
hydraulic diameter, rather than grow the cell; "mass" is the same), or
|
|
385
|
+
"pressure" (least of the worst fluid's share of its limit). `split` is
|
|
386
|
+
the first fluid's share of the void, or a list of shares to sweep
|
|
387
|
+
too. "solidVolume" and "coreMass" are the core's sheet only."""
|
|
388
|
+
objective = "mass" if objective == "metal" else objective
|
|
389
|
+
if objective not in ("effectiveness", "mass", "pressure"):
|
|
390
|
+
raise ValueError('objective is "mass" (or "metal"), "effectiveness" or "pressure"')
|
|
391
|
+
cells = cells if cells is not None else np.arange(4.0, 20.01, 1.0)
|
|
392
|
+
walls = walls if walls is not None else np.round(np.arange(min_wall, 2.01, 0.2), 2)
|
|
393
|
+
splits = [float(x) for x in np.atleast_1d(split)]
|
|
394
|
+
names = [n for n in duty if n != "wall"]
|
|
395
|
+
wall_mat = duty.get("wall", "petg")
|
|
396
|
+
density = WALL_DENSITY.get(wall_mat) if isinstance(wall_mat, str) else None
|
|
397
|
+
ok, rejected = [], []
|
|
398
|
+
for a in cells:
|
|
399
|
+
for t in walls:
|
|
400
|
+
if t < min_wall or a < 3 * t:
|
|
401
|
+
continue
|
|
402
|
+
for sp in splits:
|
|
403
|
+
try:
|
|
404
|
+
geo = tpms_geometry(kind, a, t, sp)
|
|
405
|
+
except ValueError:
|
|
406
|
+
continue
|
|
407
|
+
if min(g["dh"] for g in geo["sides"]) < min_channel:
|
|
408
|
+
continue
|
|
409
|
+
r = rate(kind, a, t, core_volume, lengths, duty, arrangement, port_diameter, split=sp)
|
|
410
|
+
solid = core_volume * (1.0 - geo["phiSheet"])
|
|
411
|
+
row = {"cell": float(a), "wall": float(t), "split": sp, "effectiveness": r["effectiveness"],
|
|
412
|
+
"effectivenessRange": r["effectivenessRange"], "UA": r["UA"],
|
|
413
|
+
"dh": {n: g["dh"] for n, g in zip(names, geo["sides"])},
|
|
414
|
+
"pressureDrop": {n: r["fluids"][n]["pressureDrop"] for n in names},
|
|
415
|
+
"duty": r.get("duty"), "solidVolume": solid,
|
|
416
|
+
"coreMass": solid * density / 1000.0 if density else None}
|
|
417
|
+
lim = max_pressure_drop or {}
|
|
418
|
+
if not all(row["pressureDrop"][n] <= lim.get(n, np.inf) for n in names):
|
|
419
|
+
rejected.append(row)
|
|
420
|
+
elif min_effectiveness is not None and row["effectiveness"] < min_effectiveness:
|
|
421
|
+
rejected.append(row)
|
|
422
|
+
else:
|
|
423
|
+
ok.append(row)
|
|
424
|
+
if objective == "mass":
|
|
425
|
+
ok.sort(key=lambda x: (x["solidVolume"], -x["effectiveness"]))
|
|
426
|
+
elif objective == "pressure":
|
|
427
|
+
lim = max_pressure_drop or {}
|
|
428
|
+
ok.sort(key=lambda x: max(x["pressureDrop"][n] / lim.get(n, 1.0) for n in names))
|
|
429
|
+
else:
|
|
430
|
+
ok.sort(key=lambda x: -x["effectiveness"])
|
|
431
|
+
return {"best": ok[0] if ok else None, "candidates": ok[:10], "objective": objective,
|
|
432
|
+
"rejected": len(rejected)}
|