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,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
|
+
}
|
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
|