loadpath 0.1.1__py3-none-any.whl

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