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,245 @@
1
+ """
2
+ Elastic buckling of a lattice's walls and struts, from a periodic unit-cell
3
+ eigenvalue solve.
4
+
5
+ The homogenized check reads a lattice's first yield from its walls' linear
6
+ stress. A thin wall or a slender strut can instead buckle first, under a
7
+ compressive macro stress well below the one that yields it, and the linear
8
+ check does not see that. Here a block of `cells`^3 unit cells on a periodic
9
+ voxel grid (the nodes wrap across the block's faces, as lattice_tensors'
10
+ solves do) is put under a unit macro stress, and the linear eigenvalue
11
+ problem
12
+
13
+ K phi = lambda (-K_sigma) phi
14
+
15
+ gives the smallest load factor lambda at which the walls buckle: the macro
16
+ stress, in units of the solid's modulus, at which a perfect infinite lattice
17
+ buckles. A one-cell block finds the modes that repeat every cell, a two-cell
18
+ block also the ones that alternate between neighbours (often lower); the
19
+ long-wave (whole-part, Euler) modes are not in it, a part-level check
20
+ (nonlinear.linear_buckling) covers those.
21
+
22
+ K uses the element in force (the incompatible-mode hex8, which bends without
23
+ locking at a few voxels through a wall); K_sigma is the trilinear geometric
24
+ stiffness of the linear stress at the Gauss points.
25
+
26
+ Tables over relative density per cell type and load direction live in
27
+ data/lattice_buckling.json (bench/lattice_buckling.py writes them);
28
+ `buckling_stress` reads them. Real walls are not perfect: printed walls are
29
+ wavy, porous and stair-stepped, and shells lose much of their perfect
30
+ buckling load to that. How the check in homogenized.py knocks the perfect
31
+ value down (with the as-printed stiffness, lattice_print.py) is documented
32
+ there and in docs/VALIDATION.md.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ from functools import lru_cache
38
+ from pathlib import Path
39
+
40
+ import numpy as np
41
+
42
+ __all__ = ["unit_cell_buckling", "buckling_stress", "has_buckling", "TABLE_PATH", "DIRECTIONS"]
43
+
44
+ TABLE_PATH = Path(__file__).resolve().parent / "data" / "lattice_buckling.json"
45
+
46
+ # macro uniaxial compression directions the tables hold, in the cell's axes
47
+ DIRECTIONS = {"100": (0.0, 0.0, 1.0), "111": (1.0, 1.0, 1.0)}
48
+
49
+
50
+ def _grid(kind, wall_over_cell, n, cells):
51
+ from .lattice import tpms
52
+ from .struts import is_strut, unit_cell_distance
53
+
54
+ m = n * cells
55
+ h = 1.0 / n
56
+ axes = (np.arange(m) + 0.5) * h
57
+ g = np.stack(np.meshgrid(axes, axes, axes, indexing="ij"), -1).reshape(-1, 3)
58
+ if is_strut(kind):
59
+ u = g % 1.0
60
+ solid = unit_cell_distance(kind, u, 0.0) < 0.5 * wall_over_cell
61
+ else:
62
+ solid = np.abs(tpms(kind, g, 1.0)) < 0.5 * wall_over_cell
63
+ return np.argwhere(solid.reshape(m, m, m)), m, h
64
+
65
+
66
+ def unit_cell_buckling(kind: str, wall_over_cell: float, direction=(0.0, 0.0, 1.0), n: int | None = None,
67
+ cells: int = 1, nu: float = 0.35, n_modes: int = 2, direct_max_dof: int = 60_000) -> dict:
68
+ """Perfect-lattice elastic buckling under uniaxial macro compression
69
+ along `direction` (cell axes): the critical macro stress over the
70
+ solid's modulus (`sigmaOverE`), the relative density of the voxel cell,
71
+ and its modulus along the direction (over E), for a block of
72
+ `cells`^3 periodic unit cells, `n` voxels per cell (default: five
73
+ voxels through the wall or strut, 24-72)."""
74
+ import time
75
+
76
+ import scipy.sparse as sp
77
+ from scipy.sparse.linalg import LinearOperator, eigsh
78
+
79
+ from .fem import CORNERS, elastic_c, hex8_b, hex8_ke
80
+
81
+ t0 = time.time()
82
+ if n is None:
83
+ n = int(np.clip(np.ceil(5.0 / float(wall_over_cell)), 24, 72))
84
+ eijk, m, h = _grid(kind, float(wall_over_cell), n, cells)
85
+ corner = (eijk[:, None, :] + CORNERS[None]) % m
86
+ eijk, corner = _connected(eijk, corner, m)
87
+ ne = len(eijk)
88
+ rho = ne / float(m ** 3)
89
+ used, inv = np.unique((corner[..., 0] * m + corner[..., 1]) * m + corner[..., 2], return_inverse=True)
90
+ enode = inv.reshape(ne, 8)
91
+ nn = len(used)
92
+ edof = (3 * enode[:, :, None] + np.arange(3)).reshape(ne, 24)
93
+ rows = np.repeat(edof, 24, axis=1).ravel()
94
+ cols = np.tile(edof, (1, 24)).ravel()
95
+ ke = hex8_ke(nu) * h
96
+ k = sp.csr_matrix((np.tile(ke.ravel(), ne), (rows, cols)), shape=(3 * nn, 3 * nn))
97
+ # keep the block from floating: hold one node; the rest is periodic
98
+ free = np.ones(3 * nn, bool)
99
+ free[:3] = False
100
+ kf = k[free][:, free].tocsc()
101
+ nf = int(free.sum())
102
+ kinv = _factorized(kf, nf <= direct_max_dof)
103
+ xc = (eijk[:, None, :] + CORNERS[None]) * h
104
+ c0 = elastic_c(1.0, nu)
105
+ vol = float(cells ** 3)
106
+ bc = hex8_b(0.0, 0.0, 0.0, nu) / h
107
+
108
+ def macro_solve(e):
109
+ eps = np.array([[e[0], e[3] / 2, e[5] / 2], [e[3] / 2, e[1], e[4] / 2], [e[5] / 2, e[4] / 2, e[2]]])
110
+ u0 = (xc @ eps.T).reshape(ne, 24)
111
+ f = np.zeros(3 * nn)
112
+ np.add.at(f, edof, -(u0 @ ke.T))
113
+ u = np.zeros(3 * nn)
114
+ u[free] = kinv(f[free])
115
+ ue = u[edof] + u0
116
+ return ue, ((ue @ bc.T) @ c0.T).sum(axis=0) * h ** 3 / vol
117
+
118
+ # effective stiffness, then the macro strain of a unit uniaxial compression
119
+ C = np.zeros((6, 6))
120
+ fields = []
121
+ for comp in range(6):
122
+ e = np.zeros(6)
123
+ e[comp] = 1.0
124
+ ue, s = macro_solve(e)
125
+ C[:, comp] = s
126
+ fields.append(ue)
127
+ d = np.asarray(direction, float)
128
+ d = d / np.linalg.norm(d)
129
+ sig = -np.array([d[0] ** 2, d[1] ** 2, d[2] ** 2, d[0] * d[1], d[1] * d[2], d[2] * d[0]])
130
+ eps = np.linalg.solve(C, sig)
131
+ ue = sum(eps[c] * fields[c] for c in range(6))
132
+ e_dir = 1.0 / float(-sig @ np.linalg.solve(C, -sig)) # modulus along d, over E
133
+ # geometric stiffness of the linear stress at the 2x2x2 Gauss points
134
+ g = 1.0 / np.sqrt(3.0)
135
+ s = 2.0 * CORNERS - 1.0
136
+ w = h ** 3 / 8.0
137
+ kq = np.zeros((ne, 8, 8))
138
+ for xi in (-g, g):
139
+ for eta in (-g, g):
140
+ for zeta in (-g, g):
141
+ dn = np.empty((8, 3))
142
+ dn[:, 0] = 0.125 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2]) * 2 / h
143
+ dn[:, 1] = 0.125 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2]) * 2 / h
144
+ dn[:, 2] = 0.125 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1]) * 2 / h
145
+ b = hex8_b(xi, eta, zeta, nu) / h
146
+ sv = (ue @ b.T) @ c0.T # (ne, 6) Voigt, tensor shears
147
+ st = np.empty((ne, 3, 3))
148
+ st[:, 0, 0], st[:, 1, 1], st[:, 2, 2] = sv[:, 0], sv[:, 1], sv[:, 2]
149
+ st[:, 0, 1] = st[:, 1, 0] = sv[:, 3]
150
+ st[:, 1, 2] = st[:, 2, 1] = sv[:, 4]
151
+ st[:, 0, 2] = st[:, 2, 0] = sv[:, 5]
152
+ kq += w * np.einsum("ai,eij,bj->eab", dn, st, dn)
153
+ kg_e = np.einsum("eab,ij->eaibj", kq, np.eye(3)).reshape(ne, 576)
154
+ kg = sp.csr_matrix((kg_e.ravel(), (rows, cols)), shape=(3 * nn, 3 * nn))
155
+ kgf = kg[free][:, free].tocsr()
156
+ del kg_e, kq
157
+ op = LinearOperator((nf, nf), matvec=kinv, dtype=float)
158
+ from scipy.sparse.linalg import ArpackNoConvergence
159
+
160
+ try:
161
+ mu, _ = eigsh(-kgf, k=n_modes, M=kf.tocsr(), Minv=op, which="LA", tol=1e-4, ncv=max(20, 4 * n_modes),
162
+ maxiter=600)
163
+ except ArpackNoConvergence as e:
164
+ # clustered modes (a cell whose struts buckle in many equal ways):
165
+ # the converged ones, else the iteration's best estimate
166
+ mu = e.eigenvalues if len(e.eigenvalues) else np.array([0.0])
167
+ mu = np.sort(np.atleast_1d(mu))[::-1]
168
+ lam = float(1.0 / mu[0]) if mu[0] > 1e-14 else float("inf")
169
+ return {"kind": kind, "wallOverCell": float(wall_over_cell), "direction": d.tolist(), "cells": cells, "n": n,
170
+ "rho": rho, "elements": ne, "dof": nf, "sigmaOverE": lam, "modulusOverE": e_dir,
171
+ "strainAtBuckling": lam / e_dir, "seconds": time.time() - t0}
172
+
173
+
174
+ def _connected(eijk, corner, m):
175
+ """The voxels of the largest piece joined through shared nodes (a thin
176
+ strut can voxelize into islands that would float)."""
177
+ import scipy.sparse as sp
178
+ from scipy.sparse.csgraph import connected_components
179
+
180
+ ne = len(eijk)
181
+ key = ((corner[..., 0] * m + corner[..., 1]) * m + corner[..., 2]).ravel()
182
+ _, node = np.unique(key, return_inverse=True)
183
+ inc = sp.csr_matrix((np.ones(ne * 8), (np.repeat(np.arange(ne), 8), node)), shape=(ne, node.max() + 1))
184
+ _, lab = connected_components(inc @ inc.T, directed=False)
185
+ big = lab == np.argmax(np.bincount(lab))
186
+ return eijk[big], corner[big]
187
+
188
+
189
+ def _factorized(kf, direct):
190
+ """x = K^-1 r: PARDISO (pypardiso, nested-dissection ordering, threaded)
191
+ when installed, else SuperLU for small systems and AMG-preconditioned
192
+ CG above `direct_max_dof` (SuperLU's orderings fill in badly on 3D
193
+ grids: a 170k-dof sheet cell took over 10 minutes and 8 GB)."""
194
+ import scipy.sparse as sp
195
+
196
+ try:
197
+ import pypardiso
198
+
199
+ a = sp.csr_matrix(kf)
200
+ solver = pypardiso.PyPardisoSolver()
201
+ solver.factorize(a)
202
+ return lambda r: solver.solve(a, np.ascontiguousarray(r, float))
203
+ except ImportError:
204
+ pass
205
+ if direct:
206
+ from scipy.sparse.linalg import splu
207
+
208
+ return splu(kf.tocsc(), permc_spec="MMD_AT_PLUS_A").solve
209
+ import pyamg
210
+ from scipy.sparse.linalg import cg
211
+
212
+ ml = pyamg.smoothed_aggregation_solver(sp.csr_matrix(kf), symmetry="hermitian", max_coarse=400,
213
+ coarse_solver="splu")
214
+ pre = ml.aspreconditioner()
215
+ return lambda r: cg(kf, r, rtol=1e-10, maxiter=4000, M=pre)[0]
216
+
217
+
218
+ @lru_cache(maxsize=1)
219
+ def _table() -> dict:
220
+ if not TABLE_PATH.exists():
221
+ return {}
222
+ return json.loads(TABLE_PATH.read_text())
223
+
224
+
225
+ def has_buckling(kind: str) -> bool:
226
+ return kind in _table().get("cells", {})
227
+
228
+
229
+ def buckling_stress(kind: str, rho, direction: str | None = None) -> np.ndarray:
230
+ """Perfect-lattice buckling stress over the solid's modulus at relative
231
+ densities `rho`: the table's least over its directions (or the one
232
+ named), log-log in rho between rows, the end rows' power law beyond."""
233
+ row = _table()["cells"][kind]
234
+ xs, keep = np.unique(np.asarray(row["rho"], float), return_index=True)
235
+ dirs = [direction] if direction else list(row["sigmaOverE"])
236
+ ys = np.min([np.asarray(row["sigmaOverE"][dd], float)[keep] for dd in dirs], axis=0)
237
+ lr, ly = np.log(xs), np.log(ys)
238
+ x = np.log(np.clip(np.atleast_1d(np.asarray(rho, float)), 1e-4, 1.0))
239
+ out = np.interp(x, lr, ly)
240
+ if len(xs) > 1:
241
+ lo = (ly[1] - ly[0]) / (lr[1] - lr[0])
242
+ hi = (ly[-1] - ly[-2]) / (lr[-1] - lr[-2])
243
+ out = np.where(x < lr[0], ly[0] + lo * (x - lr[0]), out)
244
+ out = np.where(x > lr[-1], ly[-1] + hi * (x - lr[-1]), out)
245
+ return np.exp(out)
@@ -0,0 +1,325 @@
1
+ """
2
+ Lattice walls graded to the stress, for field-graph lattices, together
3
+ with the cells' direction.
4
+
5
+ `grade_lattice` replaces a lattice node's constant `thickness` with a
6
+ `density` field (a grid node) sized by fully stressed iteration on the
7
+ homogenized check (homogenized.py, anisotropic tensors):
8
+
9
+ 1. analyze the part with the current density; every lattice cell's macro
10
+ stress per load case comes back in global axes;
11
+ 2. holding that stress, each cell takes the least density (of 28
12
+ candidates between the wall limits, log-interpolated) whose wall stress
13
+ surface, in the cell's own frame, stays under the allowable in every
14
+ case; cells where lattice meets a skin carry the skin-junction factor,
15
+ as the check does;
16
+ 3. the requirement spreads over a quarter cell (a peak spreads over a cell
17
+ before the lattice can follow it), moves at most halving or doubling a
18
+ pass, and the allowable is tightened or eased by how far the analysis
19
+ lands from the target, as lattice.lattice_infill's sizing does.
20
+
21
+ With `orient` the cells are turned too: "uniform" one direction for the
22
+ whole lattice, an integer k for k zones (stress_zones). The direction is
23
+ chosen for the least material the sized lattice needs, not for the peak:
24
+ holding each cell's macro stress, every candidate rotation's per-cell need
25
+ is a few matrix products away (the quartic features of the turned stress
26
+ dotted with every candidate density's surface), so hundreds of rotations
27
+ per zone are scored by the mass they would leave. Sizing and turning
28
+ alternate for `rounds` rounds.
29
+
30
+ The density is a grid (`op: grid`, `source` while sizing; written to an
31
+ .npz the result's graph points at), so `loadpath field` meshes the graded
32
+ lattice as sized. Stretched (`aspect`) cells and graded (warp) cells are not
33
+ graded here yet.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import copy
38
+ import time
39
+
40
+ import numpy as np
41
+
42
+ __all__ = ["grade_lattice", "need_density"]
43
+
44
+ SOURCE = "lattice-density"
45
+ N_CAND = 28
46
+
47
+
48
+ def _cand_tensors(kind, rho_min, rho_max, aspect=None):
49
+ from .lattice_tensors import tensors_at
50
+
51
+ cand = np.geomspace(rho_min, rho_max, N_CAND)
52
+ C, P, Q, F = tensors_at(kind, cand, aspect)
53
+ return cand, P, Q, F
54
+
55
+
56
+ def _surfaces(sc, P, Q, F):
57
+ """Wall stress surface of stresses sc (n, 6), in the cells' axes, for
58
+ every candidate density's (P, Q, F): (n, J)."""
59
+ from .lattice_tensors import quartic_features
60
+
61
+ f = quartic_features(sc)
62
+ q4 = f @ Q.T
63
+ quad = np.sqrt(np.maximum(np.einsum("na,jab,nb->nj", sc, P, sc), 0.0))
64
+ return np.maximum(np.maximum(q4, 0.0) ** 0.25, F[None, :] * quad)
65
+
66
+
67
+ def need_density(worst, cand, allow, over):
68
+ """Least candidate density with worst (n, J) <= allow, log-interpolated
69
+ between candidates; `over` where none is."""
70
+ ok = worst <= allow
71
+ first = np.where(ok.any(axis=1), ok.argmax(axis=1), len(cand))
72
+ need = np.full(len(worst), float(over))
73
+ at = first < len(cand)
74
+ need[at] = cand[first[at]]
75
+ mid = at & (first > 0)
76
+ j = first[mid]
77
+ w0 = np.maximum(worst[mid, j - 1], 1e-30)
78
+ w1 = np.maximum(worst[mid, j], 1e-30)
79
+ t = np.clip((np.log(w0) - np.log(allow)) / np.maximum(np.log(w0) - np.log(w1), 1e-12), 0.0, 1.0)
80
+ need[mid] = np.exp(np.log(cand[j - 1]) + t * (np.log(cand[j]) - np.log(cand[j - 1])))
81
+ return need
82
+
83
+
84
+ def _cell_stress(fl, mask, frames=None):
85
+ """Per case the lattice macro stress of the masked cells in their own
86
+ axes: frames (n, 3, 3) columns the cell axes, or the check's own."""
87
+ from .lattice_tensors import stress_rotation
88
+
89
+ fr = fl["frames"] if frames is None else frames
90
+ out = []
91
+ for sig in fl["latticeStress"]:
92
+ s = sig[mask]
93
+ if fr is not None:
94
+ s = np.einsum("eab,eb->ea", stress_rotation(fr[mask] if fr.ndim == 3 else fr), s)
95
+ out.append(s)
96
+ return out
97
+
98
+
99
+ def _grid_of(centers, values, origin, pitch):
100
+ """A dense grid (origin at the first cell centre) of per-cell values,
101
+ cells with none filled from their nearest neighbour."""
102
+ from scipy import ndimage
103
+
104
+ ijk = np.round((centers - origin) / pitch).astype(int)
105
+ dims = ijk.max(axis=0) + 1
106
+ grid = np.zeros(tuple(dims))
107
+ have = np.zeros(tuple(dims), bool)
108
+ grid[tuple(ijk.T)] = values
109
+ have[tuple(ijk.T)] = True
110
+ if not have.all():
111
+ idx = ndimage.distance_transform_edt(~have, return_distances=False, return_indices=True)
112
+ grid = grid[tuple(idx)]
113
+ return grid
114
+
115
+
116
+ def grade_lattice(doc, study, target_sf=None, wall_min=None, wall_max=None, cells: int = 15_000,
117
+ iterations: int = 12, orient=None, rounds: int = 2, base_dir=None, node=None,
118
+ progress=None, n_rot: int = 800, seam: float = 1.0, confirm: str = "auto") -> dict:
119
+ """Grade the graph's lattice wall to the stress (module docstring).
120
+ Returns {"doc" (density as a grid node reading the in-memory source),
121
+ "density": {"origin", "pitch", "values"}, "best" (the lightest analyzed
122
+ design that met the target), "history", "orient", "seconds"}. `seam`:
123
+ zones only, mm of solid plate between zones (orient.seam; 0 for none).
124
+ `confirm`: with "auto" (default) a lattice cut open at bare faces
125
+ (latticeExposed over lattice_orient.EXPOSED_LIMIT) is graded but not
126
+ turned, and "guard" says why (lattice_orient.optimize_orientation);
127
+ "none" turns it on the homogenized reading. Check a turned graded
128
+ design yourself with resolved.resolved_check."""
129
+ from scipy import ndimage
130
+
131
+ from .fields import FieldGraph, expand_groups
132
+ from .homogenized import SKIN_JUNCTION, homogenized_check, lattice_nodes
133
+ from .lattice import relative_density
134
+ from .lattice_orient import (_cell_group, _rotvec_deg, _smallest, candidate_rotations,
135
+ dominant_directions, stress_zones)
136
+ from .lattice_tensors import has_tensors, stress_rotation, tensors_at
137
+ from .struts import is_strut
138
+ from .verify import STRESS_ALLOWANCE
139
+
140
+ t0 = time.time()
141
+ say = progress or (lambda *_: None)
142
+ full = expand_groups(doc)
143
+ name = node or full.get("output") or list(full["nodes"])[-1]
144
+ g = FieldGraph({**full, "output": name}, base_dir=base_dir)
145
+ lats = lattice_nodes(g, name)
146
+ if len(lats) != 1:
147
+ raise ValueError(f"grading needs one lattice node in {name!r} (found {len(lats)})")
148
+ lat = lats[0]
149
+ spec = full["nodes"][lat]
150
+ kind = spec.get("type", "gyroid")
151
+ if "aspect" in spec:
152
+ raise ValueError(f"lattice {lat!r}: grading a stretched (aspect) cell is not supported yet")
153
+ if not isinstance(spec.get("cell", 8.0), (int, float)):
154
+ raise ValueError(f"lattice {lat!r}: grading needs a constant cell")
155
+ if not has_tensors(kind):
156
+ raise ValueError(f"lattice {lat!r} ({kind}) has no cell tensors to grade by")
157
+ cell = float(spec.get("cell", 8.0))
158
+ target = float(target_sf or (study.goal.safety_factor if getattr(study, "goal", None) else 2.0) or 2.0)
159
+ proc = getattr(study, "process", None)
160
+ wmin = float(wall_min if wall_min is not None else max(getattr(proc, "min_feature", 0.0) or 0.0, 0.6))
161
+ lo_lim = 0.02 if is_strut(kind) else 0.05
162
+ rho_min = float(np.clip(relative_density(kind, wmin, cell), lo_lim, 0.95))
163
+ rho_max = float(np.clip(relative_density(kind, wall_max, cell), rho_min, 0.95)) if wall_max else 0.6
164
+ cand, Pc, Qc, Fc = _cand_tensors(kind, rho_min, rho_max)
165
+ allow = study.material.strength / (target * STRESS_ALLOWANCE)
166
+ group = _cell_group(kind)
167
+
168
+ work = copy.deepcopy(full)
169
+ ls = work["nodes"][lat]
170
+ rho0 = float(np.clip(relative_density(kind, float(ls.get("thickness", 1.0)), cell), rho_min, rho_max)) \
171
+ if not isinstance(ls.get("thickness"), str) else 0.5 * (rho_min + rho_max)
172
+ ls.pop("thickness", None)
173
+ dens = f"{lat}__density"
174
+ ls["density"] = dens
175
+ work["nodes"][dens] = {"op": "grid", "source": SOURCE}
176
+ if isinstance(orient, (int, np.integer)) and orient > 1 or orient == "uniform":
177
+ ls.pop("orient", None)
178
+
179
+ def analyze(values, origin, pitch, fields=True):
180
+ src = {SOURCE: {"origin": origin, "pitch": pitch, "values": values}}
181
+ return homogenized_check(copy.deepcopy(work), study, node=name, base_dir=base_dir, cells=cells,
182
+ fields=fields, sources=src)
183
+
184
+ # the grid: the check's own, from one analysis at the uniform start
185
+ probe = {SOURCE: {"origin": np.zeros(3), "pitch": 1e3, "values": np.full((2, 2, 2), rho0)}}
186
+ rep = homogenized_check(copy.deepcopy(work), study, node=name, base_dir=base_dir, cells=cells, fields=True,
187
+ sources=probe)
188
+ fl = rep["fields"]
189
+ pitch = float(rep["analysisPitch"])
190
+ origin = fl["centers"].min(axis=0)
191
+ dims = np.round((fl["centers"].max(axis=0) - origin) / pitch).astype(int) + 1
192
+ G = np.full(tuple(dims), rho0) # the density grid (cell centres)
193
+ history, best = [], None
194
+ grow = max(1, int(round(0.25 * cell / pitch)))
195
+
196
+ def ijk_of(c):
197
+ return np.clip(np.round((c - origin) / pitch).astype(int), 0, dims - 1)
198
+
199
+ def size(G, fl, allow):
200
+ """The grid's next density from the analysis `fl` of it."""
201
+ c = fl["centers"]
202
+ ijk = ijk_of(c)
203
+ rho = G[tuple(ijk.T)]
204
+ latc = fl["fc"] > 0.05
205
+ junction = (fl["fs"] > 0.05) & latc
206
+ worst = np.zeros((len(c), len(cand)))
207
+ for sc in _cell_stress(fl, np.ones(len(c), bool)):
208
+ worst = np.maximum(worst, _surfaces(sc, Pc, Qc, Fc))
209
+ worst = np.where(junction[:, None], worst * SKIN_JUNCTION, worst)
210
+ need = np.where(latc, need_density(worst, cand, allow, rho_max), 0.0)
211
+ g3 = np.zeros(tuple(dims))
212
+ g3[tuple(ijk.T)] = need
213
+ g3 = ndimage.maximum_filter(g3, size=2 * grow + 1)
214
+ need = np.where(latc, g3[tuple(ijk.T)], rho)
215
+ new = G.copy()
216
+ new[tuple(ijk.T)] = np.clip(np.clip(need, 0.5 * rho, 2.0 * rho), rho_min, rho_max)
217
+ return new
218
+
219
+ def run_sizing(G, fl, allow, tag):
220
+ nonlocal best
221
+ for it in range(iterations):
222
+ new = size(G, fl, allow)
223
+ rep = analyze(new, origin, pitch)
224
+ fl = rep["fields"]
225
+ sf = float(rep["safetyFactor"])
226
+ latc = fl["fc"] > 0.05
227
+ r_now = new[tuple(ijk_of(fl["centers"][latc]).T)]
228
+ row = {"stage": tag, "iteration": it, "mass": rep["mass"], "safetyFactor": sf,
229
+ "densityRange": [float(r_now.min()), float(r_now.max())] if len(r_now) else None,
230
+ "latticeTurnedExposed": rep.get("latticeTurnedExposed")}
231
+ if rep.get("warnings"):
232
+ row["warnings"] = rep["warnings"]
233
+ history.append(row)
234
+ say({"stage": "lattice-grade", **row})
235
+ if sf >= target * 0.995 and (best is None or rep["mass"] < best["mass"]):
236
+ best = {**row, "values": new.copy(), "orient": copy.deepcopy(work["nodes"][lat].get("orient"))}
237
+ short = sf / target
238
+ if it >= 1:
239
+ allow *= float(np.clip(short, 0.85, 1.15)) ** 0.5
240
+ converged = np.allclose(new, G, rtol=0.02, atol=0.003) and abs(short - 1.0) < 0.03
241
+ G = new
242
+ if converged:
243
+ break
244
+ return G, fl, allow
245
+
246
+ G, fl, allow = run_sizing(G, fl, allow, "size")
247
+ guard = None
248
+ if orient:
249
+ from .lattice_orient import _guard
250
+
251
+ if confirm not in ("auto", "none"):
252
+ raise ValueError("grade_lattice confirm is 'auto' or 'none' (check a turned design with resolved_check)")
253
+ exposed = float(rep.get("latticeExposed") or 0.0)
254
+ guard = _guard(confirm, exposed, True, None, None, study, "safetyFactor", name, base_dir)
255
+ if guard is not None:
256
+ say({"stage": "lattice-grade-guard", "reason": guard["reason"]})
257
+ orient = None
258
+ if orient:
259
+ # zones start from the best single direction: one uniform round first
260
+ k_z = 1 if orient == "uniform" else int(orient)
261
+ plan = [1] * rounds if k_z == 1 else [1] + [k_z] * rounds
262
+ current = [np.eye(3)]
263
+ at = None
264
+ for rnd, k in enumerate(plan):
265
+ centers = fl["centers"]
266
+ latc = fl["fc"] > 0.05
267
+ w = fl["fc"] * fl["fill"] * pitch ** 3
268
+ rho = G[tuple(ijk_of(centers).T)]
269
+ if k > 1:
270
+ from .fields import zone_weights
271
+
272
+ if at is None:
273
+ _, at = stress_zones(centers[latc], [s[latc] for s in fl["latticeStress"]], w[latc], k,
274
+ spatial=2.0)
275
+ current = [current[0]] * k
276
+ own = np.argmax(zone_weights(at, cell, centers), axis=1)
277
+ else:
278
+ own = np.zeros(len(centers), int)
279
+ rho_mid = float(np.median(rho[latc]))
280
+ C = tensors_at(kind, [rho_mid])[0][0]
281
+ rots = []
282
+ for i in range(k):
283
+ m = (own == i) & latc
284
+ dirs, _ = dominant_directions([s[m] for s in fl["latticeStress"]], w[m])
285
+ # the zone's current turn first (cost[0]): it is kept unless beaten
286
+ R = np.concatenate([[current[i]], candidate_rotations(C, dirs, n_random=n_rot, seed=17 * rnd + i)])
287
+ sig = [s[m] for s in fl["latticeStress"]]
288
+ junction = (fl["fs"][m] > 0.05)
289
+ vol = w[m]
290
+ cost = np.empty(len(R))
291
+ for j, r in enumerate(R):
292
+ t = stress_rotation(r)
293
+ worst = np.zeros((m.sum(), len(cand)))
294
+ for s in sig:
295
+ worst = np.maximum(worst, _surfaces(s @ t.T, Pc, Qc, Fc))
296
+ worst = np.where(junction[:, None], worst * SKIN_JUNCTION, worst)
297
+ need = need_density(worst, cand, allow, 2.0 * rho_max)
298
+ cost[j] = float((vol * need).sum())
299
+ rots.append(_smallest(R[int(np.argmin(cost))], group))
300
+ say({"stage": "lattice-grade-orient", "zone": i, "screened": len(R),
301
+ "massNeeded": float(cost.min()), "asLaid": float(cost[0])})
302
+ current = rots
303
+ if k > 1:
304
+ work["nodes"][lat]["orient"] = {"zones": [{"at": [round(float(v), 3) for v in a],
305
+ "rotate": [round(float(v), 3) for v in _rotvec_deg(r)]}
306
+ for a, r in zip(at, rots)], "blend": cell}
307
+ if seam:
308
+ work["nodes"][lat]["orient"]["seam"] = float(seam)
309
+ else:
310
+ work["nodes"][lat]["orient"] = [round(float(v), 3) for v in _rotvec_deg(rots[0])]
311
+ # re-read the stresses in the turned lattice before sizing on
312
+ rep = analyze(G, origin, pitch)
313
+ fl = rep["fields"]
314
+ G, fl, allow = run_sizing(G, fl, allow, f"size-after-orient-{rnd}")
315
+ out = copy.deepcopy(work)
316
+ if best is not None and best.get("orient") is not None:
317
+ out["nodes"][lat]["orient"] = best["orient"]
318
+ elif best is not None:
319
+ out["nodes"][lat].pop("orient", None)
320
+ return {"doc": out, "lattice": lat, "densityNode": dens, "source": SOURCE,
321
+ "density": None if best is None else {"origin": origin.tolist(), "pitch": pitch,
322
+ "values": best["values"]},
323
+ "best": None if best is None else {k: v for k, v in best.items() if k != "values"},
324
+ "target": target, "densityLimits": [rho_min, rho_max], "history": history, "guard": guard,
325
+ "seconds": time.time() - t0}