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,260 @@
1
+ """
2
+ Dual-network isolation verifier for exchanger-class parts (heat exchangers,
3
+ manifolds, anything carrying two fluids). Watertightness is not the interesting
4
+ invariant for these — NETWORK TOPOLOGY is: each fluid domain must connect its
5
+ own inlet/outlet pair, with zero cross-leak paths.
6
+
7
+ `check_networks(part, ports)` voxelizes the part, labels the void space with
8
+ 26-connectivity, and maps each declared port to the void component it lands
9
+ in. Port naming convention: ports of the same circuit share the prefix before
10
+ the first underscore ("a_in" / "a_out" are one pair).
11
+
12
+ The part is a trimesh or, for a part built from a field, the implicit field
13
+ itself (f(P) < 0 inside, with bounds=(lo, hi)): sampling the field at the
14
+ voxel centres skips meshing and voxelizing the STL, which is most of the
15
+ time on a multi-million-triangle part. Meshes go through the scanline
16
+ rasterizer in voxels.py, which never touches trimesh's ray tree.
17
+
18
+ Resolution honesty: isolation verified at voxel pitch p means "no leak path
19
+ wider than ~p" — a pinhole narrower than a voxel can slip through the
20
+ occupancy grid. The report always carries the pitch used, and the thinnest
21
+ wall between circuits in mm with its +/- pitch; state exactly that bound, not
22
+ "leak-free".
23
+ """
24
+ import os
25
+
26
+ import numpy as np
27
+ from scipy import ndimage
28
+
29
+ from ..voxels import solid_grid, tiled_edt_min
30
+
31
+ __all__ = ["check_networks"]
32
+
33
+ # Voxel-count ceiling for the occupancy grid (bool grid + int32 labels, about
34
+ # 5 bytes per cell). The pitch chooser coarsens to fit under this rather than
35
+ # allocating unboundedly on a huge part.
36
+ _VOXEL_BUDGET = int(os.environ.get("LOADPATH_NETWORKS_VOXEL_BUDGET",
37
+ os.environ.get("CAD_NETWORKS_VOXEL_BUDGET", str(128_000_000))))
38
+
39
+ # 3x3x3 ones = 26-connectivity. Deliberately the LEAKIEST connectivity for the
40
+ # void: a diagonal voxel chain counts as a connected fluid path, so we err
41
+ # toward reporting leaks rather than hiding them.
42
+ _CONN = np.ones((3, 3, 3), bool)
43
+
44
+ _DILATION_CAP = 20 # min-wall search cutoff, in voxels
45
+
46
+
47
+ def _gap_cells(mask_a, mask_b):
48
+ """Solid separation between two void components: the Euclidean distance,
49
+ in cells, between their nearest voxel centres (None beyond the cap). The
50
+ nearest pair is the one whose centres sit closest to their surfaces
51
+ (about half a cell off), and the grid counts every voxel the surface
52
+ touches as solid (about another half), so the wall is about d - 1 cells,
53
+ +/- one."""
54
+ small, big = (mask_a, mask_b) if mask_a.sum() <= mask_b.sum() else (mask_b, mask_a)
55
+ return tiled_edt_min(small, big, _DILATION_CAP + 1)
56
+
57
+
58
+ def _refill_plugs(solid, origin, pitch, plugs):
59
+ """Virtually refill port openings before flood-fill. A real exchanger's
60
+ ports open to the outside world, which connects BOTH plenums to the same
61
+ air component and would flag every part as a leak. Two plug shapes:
62
+ - capsule {"a", "b", "r"} — a port bore, refilled; with "ends": "flat"
63
+ a flat-ended cylinder instead, from the inner end `a` (cut square,
64
+ so a wide plug never bulges into the cavity past its probes) to `b`
65
+ (the outer end, carried 1.5 voxels further out);
66
+ - box {"c": center, "h": half-extents} — a full-face manifold window
67
+ (hood lofts open the whole rectangular face; no capsule can cap
68
+ that without bulging into the plenum and swallowing the probes).
69
+ Both are rasterized with a 1.5-voxel dilation so grid aliasing can't
70
+ leave a void thread along the wall. Plugs only ADD solid: they can mask
71
+ a genuine leak only if the leak runs through the plugged volume itself,
72
+ which the opening's subtraction already made void by construction."""
73
+ shape = np.asarray(solid.shape)
74
+ for plug in plugs:
75
+ if "c" in plug and "h" in plug:
76
+ c = np.asarray(plug["c"], float)
77
+ h = np.asarray(plug["h"], float) + 1.5 * pitch
78
+ lo = np.maximum(
79
+ np.floor((c - h - origin) / pitch).astype(int), 0)
80
+ hi = np.minimum(
81
+ np.ceil((c + h - origin) / pitch).astype(int) + 1, shape)
82
+ if np.any(lo >= hi):
83
+ continue # box entirely off-grid
84
+ solid[lo[0]:hi[0], lo[1]:hi[1], lo[2]:hi[2]] = True
85
+ continue
86
+ a = np.asarray(plug["a"], float)
87
+ b = np.asarray(plug["b"], float)
88
+ r = float(plug["r"]) + 1.5 * pitch
89
+ flat = plug.get("ends", "round") == "flat"
90
+ if flat and np.linalg.norm(b - a) > 0.0:
91
+ b = b + (b - a) / np.linalg.norm(b - a) * 1.5 * pitch
92
+ lo = np.maximum(
93
+ np.floor((np.minimum(a, b) - r - origin) / pitch).astype(int), 0)
94
+ hi = np.minimum(
95
+ np.ceil((np.maximum(a, b) + r - origin) / pitch).astype(int) + 1,
96
+ shape)
97
+ if np.any(lo >= hi):
98
+ continue # plug entirely off-grid
99
+ idx = np.moveaxis(np.indices(tuple(hi - lo)), 0, -1) + lo
100
+ centers = origin + idx * pitch
101
+ ab = b - a
102
+ denom = float(ab @ ab)
103
+ if denom <= 0.0:
104
+ t_raw = np.zeros(centers.shape[:-1])
105
+ else:
106
+ t_raw = ((centers - a) @ ab) / denom
107
+ t = np.clip(t_raw, 0.0, 1.0)
108
+ dist = np.linalg.norm(centers - (a + t[..., None] * ab), axis=-1)
109
+ inside = dist <= r
110
+ if flat:
111
+ inside &= (t_raw >= 0.0) & (t_raw <= 1.0)
112
+ sub = solid[lo[0]:hi[0], lo[1]:hi[1], lo[2]:hi[2]]
113
+ sub[inside] = True
114
+
115
+
116
+ def check_networks(mesh, ports, pitch=None, plugs=None, min_feature=None, bounds=None):
117
+ """Verify fluid-network isolation of `mesh` against declared `ports`.
118
+
119
+ mesh: a trimesh, or an implicit field f(P) (negative inside the solid, P
120
+ an (N, 3) array in mm) together with bounds=(lo, hi) — the fast path for
121
+ a part built from a field.
122
+
123
+ ports: list of {"name": str, "point": [x, y, z] mm, "r": mm} spheres —
124
+ inlet/outlet locations in the void space, named `<circuit>_<role>`.
125
+ plugs: optional list of {"a", "b", "r"} capsules (add "ends": "flat" for a
126
+ square-cut inner end) or {"c", "h"} boxes to virtually refill
127
+ before labeling — pass each port bore here whenever the ports open to the
128
+ outside, or the shared outside air reads as a cross-circuit leak.
129
+ min_feature: the smallest structure (mm) the verdict must resolve —
130
+ typically min(wall thickness, channel width). Declaring it makes the
131
+ pitch chooser fine enough to resolve it (pitch <= min_feature/2), and
132
+ makes the check refuse to render a verdict it can't stand behind: a
133
+ probe coarser than the walls reads aliasing as leaks (measured: a
134
+ sound 1.2mm-wall exchanger "leaked" at a 2.66mm probe).
135
+
136
+ Returns {
137
+ "pitch": voxel pitch used (mm) — the honesty bound on leak width,
138
+ "components": void components touching any port,
139
+ "ports": {name: component id or None (port found no void)},
140
+ "isolated": every port hit exactly one component, same-prefix ports
141
+ share a component, and no component carries two prefixes,
142
+ "minWallMm": thinnest solid separation between port-bearing
143
+ components, in mm, None if single/too far,
144
+ "minWallToleranceMm": the +/- on it (one pitch),
145
+ "minWallVoxels": the same in voxels (kept for older callers),
146
+ "plugs": number of plug capsules refilled,
147
+ }
148
+ OR, when the part is so large that a min_feature-resolving pitch would
149
+ blow the voxel budget, an INCONCLUSIVE report {"pitch", "inconclusive":
150
+ True, "reason", "plugs"} — no isolated verdict at all, because a verdict
151
+ at that resolution would be noise. Callers must treat inconclusive as
152
+ "not verified", never as "leaked".
153
+
154
+ Default pitch (no min_feature) = bounding-box max extent / 64, floored
155
+ at 0.4mm — coarse on purpose: cheap enough to always run. Declare
156
+ min_feature (or pass an explicit pitch) when the geometry has walls or
157
+ channels finer than that.
158
+ """
159
+ is_field = callable(mesh) and not hasattr(mesh, "faces")
160
+ if is_field:
161
+ if bounds is None:
162
+ raise ValueError("check_networks on a field needs bounds=(lo, hi) in mm")
163
+ bounds = tuple(np.asarray(b, float) for b in bounds)
164
+ extents = bounds[1] - bounds[0]
165
+ else:
166
+ extents = np.asarray(mesh.extents, float)
167
+ if pitch is None:
168
+ pitch = max(float(np.max(extents)) / 64.0, 0.4)
169
+ if min_feature is not None:
170
+ # Resolve the declared finest feature with >=2 voxels across it.
171
+ pitch = min(pitch, max(float(min_feature) / 2.0, 0.2))
172
+ pitch = float(pitch)
173
+
174
+ # Coarsen (never refine) to fit the voxel budget on huge parts.
175
+ def _cells(p):
176
+ return float(np.prod(np.ceil(extents / p) + 4))
177
+
178
+ while _cells(pitch) > _VOXEL_BUDGET:
179
+ pitch = round(pitch * 1.25, 4)
180
+
181
+ if min_feature is not None and pitch > float(min_feature) / 2.0 + 1e-9:
182
+ return {
183
+ "pitch": pitch,
184
+ "inconclusive": True,
185
+ "reason": (
186
+ f"probe pitch {pitch:g}mm cannot resolve the declared "
187
+ f"{float(min_feature):g}mm minimum feature within the voxel "
188
+ "budget — isolation is UNVERIFIED at this scale (a verdict "
189
+ "here would alias walls into phantom leaks)"
190
+ ),
191
+ "plugs": len(plugs) if plugs else 0,
192
+ }
193
+
194
+ solid, origin, _info = solid_grid(mesh, pitch, pad=2, bounds=bounds, touch=True)
195
+ if plugs:
196
+ _refill_plugs(solid, origin, pitch, plugs)
197
+ labels, _ = ndimage.label(~solid, structure=_CONN)
198
+ shape = np.asarray(labels.shape)
199
+
200
+ assignment = {}
201
+ ambiguous = False
202
+ for port in ports:
203
+ pt = np.asarray(port["point"], float)
204
+ r = float(port["r"])
205
+ lo = np.maximum(np.floor((pt - r - origin) / pitch).astype(int), 0)
206
+ hi = np.minimum(np.ceil((pt + r - origin) / pitch).astype(int) + 1, shape)
207
+ if np.any(lo >= hi):
208
+ assignment[port["name"]] = None # sphere entirely off-grid
209
+ continue
210
+ sub = labels[lo[0]:hi[0], lo[1]:hi[1], lo[2]:hi[2]]
211
+ idx = np.moveaxis(np.indices(sub.shape), 0, -1) + lo
212
+ centers = origin + idx * pitch
213
+ hits = sub[np.linalg.norm(centers - pt, axis=-1) <= r]
214
+ hits = hits[hits > 0]
215
+ if hits.size == 0:
216
+ assignment[port["name"]] = None # sphere touched only solid
217
+ continue
218
+ ids, counts = np.unique(hits, return_counts=True)
219
+ if ids.size > 1:
220
+ ambiguous = True # straddles two voids — cannot claim isolation
221
+ assignment[port["name"]] = int(ids[np.argmax(counts)])
222
+
223
+ port_comps = sorted({c for c in assignment.values() if c is not None})
224
+
225
+ # isolation: every port resolved cleanly, each circuit prefix lives in
226
+ # exactly one component, and no component hosts two circuits.
227
+ isolated = not ambiguous and all(c is not None for c in assignment.values())
228
+ if isolated and assignment:
229
+ prefix_of = {} # component -> circuit prefix
230
+ comp_of = {} # circuit prefix -> component
231
+ for name, comp in assignment.items():
232
+ prefix = name.split("_")[0]
233
+ if prefix_of.setdefault(comp, prefix) != prefix:
234
+ isolated = False # two circuits share a void — a leak
235
+ break
236
+ if comp_of.setdefault(prefix, comp) != comp:
237
+ isolated = False # one circuit split across voids — a blockage
238
+ break
239
+
240
+ gap = None
241
+ if len(port_comps) >= 2:
242
+ gaps = [
243
+ _gap_cells(labels == a, labels == b)
244
+ for i, a in enumerate(port_comps)
245
+ for b in port_comps[i + 1:]
246
+ ]
247
+ gaps = [g for g in gaps if g is not None]
248
+ gap = min(gaps) if gaps else None
249
+
250
+ return {
251
+ "pitch": pitch,
252
+ "components": len(port_comps),
253
+ "ports": assignment,
254
+ "isolated": bool(isolated),
255
+ "minWallMm": None if gap is None else round(max(gap - 1.0, 0.0) * pitch, 3),
256
+ "minWallToleranceMm": round(pitch, 4),
257
+ "minWallVoxels": None if gap is None else int(round(max(gap - 1.0, 0.0))),
258
+ "plugs": len(plugs) if plugs else 0,
259
+ "source": "field" if is_field else "mesh",
260
+ }
@@ -0,0 +1,67 @@
1
+ """
2
+ Does the part OPEN from above? The "can you put things in it" probe.
3
+
4
+ A container read as a container only when its cavities break through the top.
5
+ Concept blockouts kept coming back as closed pills with the compartments
6
+ inside them, or with cavities that only nicked the rounded ends, and neither
7
+ the fill ratio (a sealed hollow is mostly empty) nor the trapped-void probe (a
8
+ nicked cavity drains) can tell those apart from a real organizer.
9
+
10
+ Looking straight down can. Sample the surface densely, keep the HIGHEST point
11
+ in each grid cell (the first thing you'd hit from above), and measure how much
12
+ of the footprint's interior sits deep below the top. An open pocket shows its
13
+ floor; a lidded hollow or a solid shows its top. The footprint is eroded first
14
+ so a soft rounded rim, which is low near the edge, doesn't count as open.
15
+ """
16
+ import numpy as np
17
+ from scipy import ndimage
18
+ import trimesh
19
+
20
+ # How far below the top a cell must sit to count as open, as a share of the
21
+ # part's height.
22
+ _DEPTH_FRACTION = 0.3
23
+ # Footprint erosion, as a share of the smaller plan dimension.
24
+ _ERODE_FRACTION = 0.12
25
+
26
+
27
+ def check_opening(mesh, spec=None):
28
+ spec = spec or {}
29
+ lo, hi = mesh.bounds
30
+ size = hi - lo
31
+ height = float(size[2])
32
+ plan = float(min(size[0], size[1]))
33
+ if height <= 0 or plan <= 0:
34
+ return {"error": "degenerate bounds"}
35
+
36
+ cell = float(spec.get("cellMm") or max(plan / 60.0, 0.5))
37
+ nx = int(np.ceil(size[0] / cell)) + 1
38
+ ny = int(np.ceil(size[1] / cell)) + 1
39
+
40
+ count = int(min(max(mesh.area / (cell * cell) * 6, 20_000), 400_000))
41
+ pts, _ = trimesh.sample.sample_surface(mesh, count, seed=7)
42
+ ix = np.clip(((pts[:, 0] - lo[0]) / cell).astype(int), 0, nx - 1)
43
+ iy = np.clip(((pts[:, 1] - lo[1]) / cell).astype(int), 0, ny - 1)
44
+
45
+ top = np.full((nx, ny), -np.inf)
46
+ np.maximum.at(top, (ix, iy), pts[:, 2])
47
+ covered = np.isfinite(top)
48
+ # Cells a pocket's vertical walls cover only sparsely can miss samples;
49
+ # close small holes so the footprint is the part's silhouette.
50
+ footprint = ndimage.binary_closing(covered, iterations=2)
51
+ footprint = ndimage.binary_fill_holes(footprint)
52
+ erode = max(1, int(round(plan * _ERODE_FRACTION / cell)))
53
+ interior = ndimage.binary_erosion(footprint, iterations=erode)
54
+ n_interior = int(interior.sum())
55
+ if n_interior == 0:
56
+ return {"error": "footprint too small to probe", "cellMm": round(cell, 3)}
57
+
58
+ deep = float(hi[2]) - _DEPTH_FRACTION * height
59
+ # An empty interior cell (no surface at all beneath it) is a through-hole:
60
+ # open by definition.
61
+ open_cells = interior & (~covered | (top < deep))
62
+ frac = float(open_cells.sum()) / n_interior
63
+ return {
64
+ "openFraction": round(frac, 4),
65
+ "cellMm": round(cell, 3),
66
+ "interiorCells": n_interior,
67
+ }
@@ -0,0 +1,7 @@
1
+ """Moved to `loadpath.sdf_kit` (it generates geometry; it is not a check). This
2
+ alias keeps `loadpath.checks.sdf_kit` imports working."""
3
+ import sys
4
+
5
+ from .. import sdf_kit as _moved
6
+
7
+ sys.modules[__name__] = _moved
loadpath/conjugate.py ADDED
@@ -0,0 +1,267 @@
1
+ """
2
+ Conjugate heat transfer on a voxel grid, for heat sinks and cold plates in a
3
+ duct: the flow from flow.py's lattice Boltzmann run, the heat by finite
4
+ volumes, solid and fluid in one linear system.
5
+
6
+ Topology optimization of heat sinks usually rates a design with a fixed
7
+ convection coefficient h on its surface (thermal.py does). That is fine for
8
+ choosing where metal goes in conduction, but it cannot say what a design
9
+ costs in pressure drop, or that the fluid warms as it passes the first fins,
10
+ or that a fine lattice chokes its own flow. To compare a solid topology
11
+ against a lattice fairly, both are rated here the same way:
12
+
13
+ - the flow through the design (`duct_flow`): flow.network_flow on the fluid
14
+ voxels, from a plug at the duct's inlet face to a fixed-pressure outlet,
15
+ time-averaged; the flow rate is searched until the pressure drop matches a
16
+ target (`at_pressure_drop`), since a fair comparison of sinks holds the fan
17
+ fixed, not the flow;
18
+ - the heat (`solve_heat`): steady conduction in the solid and the fluid,
19
+ advection in the fluid (first-order upwind on the voxel faces, from the
20
+ averaged velocity), the heat entering through given faces of the solid
21
+ (a die under the base), the fluid entering at the inlet temperature.
22
+ Walls not named are adiabatic. One sparse solve.
23
+
24
+ The result is the thermal resistance (the hottest point of the heated face
25
+ over the inlet temperature, per watt) and the mean one, with the energy
26
+ balance as a check (what the fluid carries out over what went in).
27
+
28
+ Units: mm for geometry, SI for properties, W, K.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import numpy as np
33
+ import scipy.sparse as sp
34
+
35
+ __all__ = ["solve_heat", "duct_flow", "at_pressure_drop"]
36
+
37
+
38
+ def solve_heat(solid: np.ndarray, fluid: np.ndarray, velocity, h: float, k_solid: float, fluid_props,
39
+ heat_faces: np.ndarray, power: float, flow_axis: int = 0, outlet=None, inlet=None,
40
+ project: bool = False) -> dict:
41
+ """Steady temperature rise over the inlet (K) in every solid and fluid voxel.
42
+
43
+ solid, fluid: bool voxel masks (disjoint; everything else is an
44
+ adiabatic wall). velocity: (3, *shape) m/s on the voxels (zero in the
45
+ solid). heat_faces: bool mask of solid voxels whose face on the low side
46
+ of axis 2 takes the heat (spread evenly by area), or a float field of
47
+ weights (several dies at different powers), `power` W in all. The fluid
48
+ enters through the voxels on the grid's low face along `flow_axis` at
49
+ temperature 0 and leaves through the high one; or, with `inlet` and
50
+ `outlet` = (mask, axis, sign), enters and leaves through the masked
51
+ fluid voxels, inward (inlet) or outward (outlet) along `sign` x `axis`.
52
+ project: first make the face fluxes conserve volume (`_project`); for a
53
+ lattice Boltzmann flow through a fine network, without it the heat
54
+ balance can be far off.
55
+
56
+ Returns {"T": field (nan outside), "maxRise", "meanRise" (over the heated
57
+ faces), "resistance" K/W (max), "meanResistance", "balance" (heat carried
58
+ out over power)}."""
59
+ shape = solid.shape
60
+ hm = h * 1e-3
61
+ live = solid | fluid
62
+ idx = -np.ones(shape, np.int64)
63
+ n = int(live.sum())
64
+ idx[live] = np.arange(n)
65
+ k = np.where(solid, k_solid, fluid_props.k)
66
+ rc = fluid_props.rho * fluid_props.cp
67
+ U = np.asarray(velocity, float)
68
+ rows, cols, vals = [], [], []
69
+ rhs = np.zeros(n)
70
+ # the faces: conduction across every live pair, volume flux (m^3/s,
71
+ # a to b positive) across fluid pairs from the averaged cell velocities
72
+ fa, fb, flux = [], [], []
73
+ for ax in range(3):
74
+ a = [slice(None)] * 3
75
+ b = [slice(None)] * 3
76
+ a[ax] = slice(0, -1)
77
+ b[ax] = slice(1, None)
78
+ a, b = tuple(a), tuple(b)
79
+ both = live[a] & live[b]
80
+ ia, ib = idx[a][both], idx[b][both]
81
+ ka, kb = k[a][both], k[b][both]
82
+ g = 2.0 * ka * kb / (ka + kb) * hm # W/K across the face
83
+ rows += [ia, ib, ia, ib]
84
+ cols += [ib, ia, ia, ib]
85
+ vals += [-g, -g, g, g]
86
+ ff = fluid[a] & fluid[b]
87
+ fa.append(idx[a][ff])
88
+ fb.append(idx[b][ff])
89
+ flux.append(0.5 * (U[ax][a][ff] + U[ax][b][ff]) * hm * hm)
90
+ fa, fb, flux = np.concatenate(fa), np.concatenate(fb), np.concatenate(flux)
91
+ # inlet and outlet: (cells, volume flux in / out)
92
+ if inlet is None:
93
+ lo = [slice(None)] * 3
94
+ lo[flow_axis] = 0
95
+ lo = tuple(lo)
96
+ ii = idx[lo][fluid[lo]]
97
+ qi = np.maximum(U[flow_axis][lo][fluid[lo]] * hm * hm, 0.0)
98
+ else:
99
+ im, iax, isign = inlet
100
+ im = np.asarray(im, bool) & fluid
101
+ ii, qi = idx[im], np.maximum(isign * U[iax][im] * hm * hm, 0.0)
102
+ if outlet is None:
103
+ hi = [slice(None)] * 3
104
+ hi[flow_axis] = -1
105
+ hi = tuple(hi)
106
+ io = idx[hi][fluid[hi]]
107
+ qo = np.maximum(U[flow_axis][hi][fluid[hi]] * hm * hm, 0.0)
108
+ else:
109
+ om, oax, osign = outlet
110
+ om = np.asarray(om, bool) & fluid
111
+ io, qo = idx[om], np.maximum(osign * U[oax][om] * hm * hm, 0.0)
112
+ if project:
113
+ flux, qo = _project(n, fa, fb, flux, ii, qi, io, qo)
114
+ F = rc * flux # W/K
115
+ fp, fm = np.maximum(F, 0.0), np.maximum(-F, 0.0)
116
+ Fo = rc * qo
117
+ # upwind advection, conservative: each cell gives out at its own T and
118
+ # takes in F T_upwind. A cell's diagonal is its outflow; where (the LBM's
119
+ # averaged velocity is weakly compressible) a cell's inflow beats it, the
120
+ # matrix loses diagonal dominance and AIR can diverge, so the diagonal
121
+ # is max(outflow, inflow): exact for a divergence-free flow, and with
122
+ # `project` the flow is made divergence-free first.
123
+ outs = np.zeros(n)
124
+ ins = np.zeros(n)
125
+ np.add.at(outs, fa, fp)
126
+ np.add.at(outs, fb, fm)
127
+ np.add.at(ins, fb, fp)
128
+ np.add.at(ins, fa, fm)
129
+ np.add.at(ins, ii, rc * qi) # inflow at temperature 0
130
+ np.add.at(outs, io, Fo)
131
+ rows += [fb, fa]
132
+ cols += [fa, fb]
133
+ vals += [-fp, -fm]
134
+ diag = np.maximum(outs, ins)
135
+ # heat in
136
+ wq = np.where(solid, np.asarray(heat_faces, float), 0.0)
137
+ q = wq > 0
138
+ if not q.any():
139
+ raise ValueError("no heated face on the solid")
140
+ np.add.at(rhs, idx[q], power * wq[q] / wq[q].sum())
141
+ A = sp.coo_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n)).tocsr()
142
+ A = A + sp.diags(diag)
143
+ t = _solve(A, rhs)
144
+ T = np.full(shape, np.nan)
145
+ T[live] = t
146
+ carried = float((Fo * t[io]).sum())
147
+ tq = T[q]
148
+ return {"T": T, "maxRise": float(tq.max()), "meanRise": float(tq.mean()),
149
+ "resistance": float(tq.max() / power), "meanResistance": float(tq.mean() / power),
150
+ "balance": carried / power, "inletCells": int(ii.size)}
151
+
152
+
153
+ def _project(n, fa, fb, flux, ii, qi, io, qo):
154
+ """The nearest face fluxes to `flux` that conserve volume in every cell
155
+ (least squares, the inlet fluxes held, the outlet ones free): flux -
156
+ grad phi with L phi = div, L the fluid cells' graph Laplacian (the
157
+ outlets tied to phi = 0). The averaged LBM velocity, interpolated to
158
+ faces, gains and loses volume cell by cell; advected heat with it does
159
+ the same, and on a water cold plate that lost 99% of the heat."""
160
+ import pyamg
161
+
162
+ div = np.zeros(n)
163
+ np.add.at(div, fa, flux)
164
+ np.add.at(div, fb, -flux)
165
+ np.add.at(div, io, qo)
166
+ np.add.at(div, ii, -qi)
167
+ used = np.zeros(n, bool)
168
+ used[fa] = used[fb] = used[io] = used[ii] = True
169
+ m = np.flatnonzero(used)
170
+ loc = -np.ones(n, np.int64)
171
+ loc[m] = np.arange(m.size)
172
+ a, b = loc[fa], loc[fb]
173
+ d = np.zeros(m.size)
174
+ np.add.at(d, a, 1.0)
175
+ np.add.at(d, b, 1.0)
176
+ np.add.at(d, loc[io], 1.0)
177
+ d += 1e-9 # pockets cut off from the outlet
178
+ Lm = sp.coo_matrix((np.concatenate([-np.ones(a.size), -np.ones(a.size)]),
179
+ (np.concatenate([a, b]), np.concatenate([b, a]))), shape=(m.size, m.size)).tocsr()
180
+ Lm = Lm + sp.diags(d)
181
+ rhs = -div[m]
182
+ ml = pyamg.smoothed_aggregation_solver(Lm)
183
+ phi = ml.solve(rhs, tol=1e-12, accel="cg", maxiter=500)
184
+ flux = flux - (phi[b] - phi[a])
185
+ qo = qo + phi[loc[io]]
186
+ return flux, qo
187
+
188
+
189
+ def _solve(A, b):
190
+ """Advection-diffusion is nonsymmetric and, on a 0.5 mm air sink of ~300k
191
+ cells, more than SuperLU's fill-in can take (not done in 20 minutes),
192
+ while GMRES on the symmetric part's multigrid stalls (metal and air
193
+ conductances 10^4 apart). Rows scaled by the diagonal, then pyamg's AIR
194
+ (built for upwinded advection) under GMRES: ~20 s to 1e-9."""
195
+ import pyamg
196
+
197
+ A = A.tocsr()
198
+ d = 1.0 / A.diagonal()
199
+ As = (sp.diags(d) @ A).tocsr()
200
+ bs = d * b
201
+ for make in (lambda: pyamg.air_solver(As),
202
+ lambda: pyamg.smoothed_aggregation_solver(As, symmetry="nonsymmetric")):
203
+ x = make().solve(bs, tol=1e-10, accel="gmres", maxiter=300)
204
+ if np.linalg.norm(b - A @ x) <= 1e-7 * np.linalg.norm(b):
205
+ return x
206
+ raise RuntimeError("conjugate heat solve did not converge")
207
+
208
+
209
+ def duct_flow(fluid: np.ndarray, h: float, flow_lpm: float, medium="air", flow_axis: int = 0, **kw) -> dict:
210
+ """flow.network_flow through a duct whose inlet is the grid's low face
211
+ along `flow_axis` and outlet the high one (the fluid voxels there).
212
+ Returns the network_flow report with "velocity" (3, *shape) m/s on the
213
+ unpadded grid and "dp" Pa (inlet to outlet)."""
214
+ from .flow import network_flow
215
+
216
+ shape = np.asarray(fluid.shape)
217
+ o = [a for a in range(3) if a != flow_axis]
218
+ sel_in = np.take(fluid, 0, axis=flow_axis)
219
+ ij = np.argwhere(sel_in)
220
+ c = 0.5 * (ij.min(0) + ij.max(0)) * h
221
+ rad = 0.5 * np.linalg.norm((ij.max(0) - ij.min(0) + 1) * h) + h
222
+
223
+ def port(end):
224
+ p = np.zeros(3)
225
+ p[flow_axis] = 0.0 if end == 0 else (shape[flow_axis] - 1) * h
226
+ p[o[0]], p[o[1]] = c
227
+ ax = np.zeros(3)
228
+ ax[flow_axis] = -1.0 if end == 0 else 1.0
229
+ return {"point": p.tolist(), "axis": ax.tolist(), "radius": float(rad)}
230
+
231
+ rep = network_flow(fluid, h, port(0), port(1), medium=medium, flow_lpm=flow_lpm, fields=True, **kw)
232
+ fld = rep.pop("fields")
233
+ rep["velocity"] = fld["velocity"][:, 1:-1, 1:-1, 1:-1]
234
+ rep["pressure"] = fld["pressure"][1:-1, 1:-1, 1:-1]
235
+ rep["dp"] = float(rep["totalPressureDrop"]) if "totalPressureDrop" in rep else _dp(rep, fluid, flow_axis)
236
+ return rep
237
+
238
+
239
+ def _dp(rep, fluid, ax):
240
+ p = rep["pressure"]
241
+ a = np.take(p, 0, axis=ax)[np.take(fluid, 0, axis=ax)].mean()
242
+ b = np.take(p, -1, axis=ax)[np.take(fluid, -1, axis=ax)].mean()
243
+ return float(a - b)
244
+
245
+
246
+ def at_pressure_drop(fluid: np.ndarray, h: float, dp: float, guess_lpm: float, medium="air", flow_axis: int = 0,
247
+ rtol: float = 0.05, tries: int = 5, **kw) -> dict:
248
+ """duct_flow at the flow rate whose pressure drop is `dp` Pa (within
249
+ rtol): secant on log(flow) against log(dp) from `guess_lpm`."""
250
+ q = float(guess_lpm)
251
+ seen = []
252
+ rep = None
253
+ for _ in range(tries):
254
+ rep = duct_flow(fluid, h, q, medium, flow_axis, **kw)
255
+ rep["flowLpm"] = q
256
+ seen.append((np.log(q), np.log(max(rep["dp"], 1e-9))))
257
+ if abs(rep["dp"] / dp - 1.0) <= rtol:
258
+ break
259
+ if len(seen) == 1:
260
+ slope = 1.6 # dp ~ Q^1.6 between laminar and turbulent
261
+ else:
262
+ (x0, y0), (x1, y1) = seen[-2], seen[-1]
263
+ slope = (y1 - y0) / (x1 - x0) if abs(x1 - x0) > 1e-9 else 1.6
264
+ slope = float(np.clip(slope, 1.0, 2.0))
265
+ q = float(np.exp(seen[-1][0] + (np.log(dp) - seen[-1][1]) / slope))
266
+ rep["searched"] = [(float(np.exp(a)), float(np.exp(b))) for a, b in seen]
267
+ return rep