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,558 @@
1
+ """
2
+ Anisotropic lattice homogenization: the full elasticity tensor of a unit
3
+ cell and a directional wall-stress surface, and both rotated into the frame
4
+ the cell sits in.
5
+
6
+ lattice.SHEETS / STRUTS read every cell as an isotropic material, and for a
7
+ strut cell the conservative one: the modulus is the lesser of its [100] and
8
+ [111] moduli, the wall stress the worst of a [100] pull, a [111] pull and a
9
+ shear. An octet is 1.5x stiffer along its struts than that, a simple cubic
10
+ cell 10x and more on its axes, a BCCz 2-3x along z; a part whose lattice is
11
+ loaded the way its cells are strong carries mass it does not need. The
12
+ scalar reading also scales the wall stress by the macro von Mises stress,
13
+ which is zero under a hydrostatic load the walls feel in full.
14
+
15
+ Here each cell is solved six times on a periodic voxel grid (a unit macro
16
+ strain per Voigt component, the nodes wrapping across the cell's faces):
17
+
18
+ - the mean stresses give the 6x6 effective stiffness C* (over the solid's
19
+ E, engineering shear strains), symmetrized over the cell's own symmetry;
20
+ - the six nodal stress fields superpose to the walls' stress under any
21
+ macro stress sigma: sigma -> C*^-1 sigma -> sum of the fields. Sampled
22
+ over a few hundred directions (axes, diagonals, shears, hydrostatic and
23
+ random), the walls' peak (99.5th percentile of nodal von Mises, as the
24
+ scalar fits read it) is fitted by a quadratic form, peak^2 = sigma' P
25
+ sigma, an anisotropic yield surface in the spirit of Hill's, then scaled
26
+ up until it lies on or above every sample, so it never under-reads one.
27
+
28
+ Tables over relative density per cell type live in data/lattice_tensors.json
29
+ (bench/lattice_tensors.py writes them); tensors_at reads them at any density
30
+ (log-log on the leading entry, the shape interpolated in log rho, up to the
31
+ solid at rho = 1).
32
+
33
+ Frames: a cell's tensors are in its own axes; homogenized.py rotates them by
34
+ the lattice coordinates' local Jacobian (polar factor), so a cylindrical,
35
+ conformal or rotated lattice is read the way it actually lies.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import itertools
40
+ import json
41
+ from functools import lru_cache
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+
46
+ __all__ = ["unit_cell_tensors", "tensors_at", "rotate_stiffness", "rotate_stress_form", "stress_rotation",
47
+ "strain_rotation", "has_tensors", "directional_modulus", "stress_surface", "VM_FORM", "TABLE_PATH"]
48
+
49
+ TABLE_PATH = Path(__file__).resolve().parent / "data" / "lattice_tensors.json"
50
+ _VOIGT = ((0, 0), (1, 1), (2, 2), (0, 1), (1, 2), (2, 0))
51
+
52
+ # von Mises squared as a quadratic form of Voigt stress (tensor shears)
53
+ VM_FORM = np.array([[1.0, -0.5, -0.5, 0, 0, 0],
54
+ [-0.5, 1.0, -0.5, 0, 0, 0],
55
+ [-0.5, -0.5, 1.0, 0, 0, 0],
56
+ [0, 0, 0, 3.0, 0, 0],
57
+ [0, 0, 0, 0, 3.0, 0],
58
+ [0, 0, 0, 0, 0, 3.0]])
59
+
60
+
61
+ # ---- rotations ----------------------------------------------------------------------
62
+
63
+ def _basis(shear_half: bool) -> np.ndarray:
64
+ """(6, 3, 3): the tensor of each Voigt unit vector; engineering shear
65
+ strain puts 1/2 on each off-diagonal entry, stress 1."""
66
+ e = np.zeros((6, 3, 3))
67
+ for a, (i, j) in enumerate(_VOIGT):
68
+ v = 0.5 if (shear_half and i != j) else 1.0
69
+ e[a, i, j] = v
70
+ e[a, j, i] = v
71
+ return e
72
+
73
+
74
+ def _rotation_matrix(rot, shear_half: bool):
75
+ """(..., 6, 6) T with v_local = T v_global for Voigt vectors, where
76
+ `rot` (..., 3, 3) has the local axes as its columns (in global
77
+ components): tensor_local = rot' tensor rot."""
78
+ rot = np.asarray(rot, float)
79
+ e = _basis(shear_half)
80
+ loc = np.einsum("...pi,apq,...qj->...aij", rot, e, rot)
81
+ ii = np.array([i for i, _ in _VOIGT])
82
+ jj = np.array([j for _, j in _VOIGT])
83
+ t = loc[..., ii, jj] # (..., a, b): component b of rotated unit a
84
+ if shear_half:
85
+ t = t * np.array([1, 1, 1, 2, 2, 2], float) # engineering shear read back
86
+ return np.swapaxes(t, -1, -2)
87
+
88
+
89
+ def stress_rotation(rot):
90
+ return _rotation_matrix(rot, False)
91
+
92
+
93
+ def strain_rotation(rot):
94
+ return _rotation_matrix(rot, True)
95
+
96
+
97
+ def rotate_stiffness(c_local, rot):
98
+ """C in global axes for a cell whose local axes are the columns of
99
+ `rot`: C_g = T_e' C_l T_e (strain energy is frame free)."""
100
+ t = strain_rotation(rot)
101
+ return np.einsum("...ab,...bc,...cd->...ad", np.swapaxes(t, -1, -2), c_local, t)
102
+
103
+
104
+ def rotate_stress_form(p_local, rot):
105
+ """A quadratic form of stress (P) in global axes: P_g = T_s' P_l T_s."""
106
+ t = stress_rotation(rot)
107
+ return np.einsum("...ab,...bc,...cd->...ad", np.swapaxes(t, -1, -2), p_local, t)
108
+
109
+
110
+ @lru_cache(maxsize=None)
111
+ def _group(kind_sym: str) -> np.ndarray:
112
+ """Proper rotations of the cube (24) or those keeping z (8)."""
113
+ out = []
114
+ for perm in itertools.permutations(range(3)):
115
+ for signs in itertools.product((-1, 1), repeat=3):
116
+ r = np.zeros((3, 3))
117
+ for i, p in enumerate(perm):
118
+ r[p, i] = signs[i]
119
+ if np.linalg.det(r) < 0:
120
+ continue
121
+ if kind_sym == "tetragonal" and abs(r[2, 2]) != 1:
122
+ continue
123
+ if kind_sym == "orthorhombic" and not np.allclose(np.abs(r), np.eye(3)):
124
+ continue
125
+ out.append(r)
126
+ return np.array(out)
127
+
128
+
129
+ def symmetry_of(kind: str) -> str:
130
+ """cubic, tetragonal (z stands apart) or none (a random foam draw)."""
131
+ from .struts import canonical
132
+
133
+ k = canonical(kind)
134
+ if k in ("bccz", "fccz"):
135
+ return "tetragonal"
136
+ if k == "voronoi":
137
+ return "none"
138
+ return "cubic"
139
+
140
+
141
+ def _symmetrize(m, sym: str, rotate):
142
+ m = 0.5 * (m + m.T)
143
+ if sym == "none":
144
+ return m
145
+ g = _group(sym)
146
+ return np.mean(rotate(np.broadcast_to(m, (len(g), 6, 6)), g), axis=0)
147
+
148
+
149
+ # ---- unit cell ----------------------------------------------------------------------
150
+
151
+ def _directions(seed=0, n_random=600):
152
+ """Unit macro stresses (Voigt, Frobenius norm 1): axes, face and cube
153
+ diagonals in tension and compression, pure shears, hydrostatic, random."""
154
+ out = []
155
+ dirs = [np.array(v, float) for v in itertools.product((-1, 0, 1), repeat=3) if any(v)]
156
+ for d in dirs:
157
+ d = d / np.linalg.norm(d)
158
+ s = np.outer(d, d)
159
+ out.append(np.array([s[i, j] for i, j in _VOIGT]))
160
+ for a in range(3, 6):
161
+ v = np.zeros(6)
162
+ v[a] = 1.0
163
+ out += [v, -v]
164
+ for sgn in (1, -1):
165
+ out.append(sgn * np.array([1, 1, 1, 0, 0, 0], float))
166
+ rng = np.random.default_rng(seed)
167
+ out += list(rng.normal(size=(n_random, 6)))
168
+ out = np.array(out)
169
+ frob = np.sqrt((out[:, :3] ** 2).sum(1) + 2 * (out[:, 3:] ** 2).sum(1))
170
+ return out / frob[:, None]
171
+
172
+
173
+ def unit_cell_tensors(kind: str, wall_over_cell: float, n: int = 40, nu: float = 0.35, blend: float = 0.0,
174
+ percentile: float = 99.5, aspect=None, _keep: bool = False) -> dict:
175
+ """Full anisotropic homogenization of one unit cell (module docstring)
176
+ on a periodic voxel grid, n voxels along its shortest side. `wall_over_cell`:
177
+ sheet wall or strut diameter over the cell; `blend` the strut node
178
+ fillet over the cell; `aspect` (per axis) a stretched cell, its box
179
+ `aspect` cells along each axis, as the field graph's `aspect` key lays
180
+ it out. Returns rho, C (6x6 over E), the wall-stress surface (P, Q4,
181
+ floor), held-out checks of it, the sampled directions' peaks, and the
182
+ scalar readings (E100, E111, G) for comparison with the isotropic
183
+ tables."""
184
+ import pyamg
185
+ import scipy.sparse as sp
186
+ from scipy.sparse.linalg import cg
187
+
188
+ from .fem import CORNERS, _b_matrix, elastic_c, hex8_b, hex8_ke, von_mises
189
+ from .lattice import tpms
190
+ from .struts import is_strut, unit_cell_distance
191
+
192
+ a = np.ones(3) if aspect is None else np.asarray(aspect, float)
193
+ stretched = not np.allclose(a, 1.0)
194
+ h = float(a.min()) / n
195
+ dims = np.maximum(np.round(a / h).astype(int), 1)
196
+ a = dims * h # the box the grid holds exactly
197
+ axes = [(np.arange(d) + 0.5) * h for d in dims]
198
+ g = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
199
+ U = g / a
200
+ if is_strut(kind):
201
+ solid = unit_cell_distance(kind, U, blend, aspect=tuple(a) if stretched else None) < 0.5 * wall_over_cell
202
+ else:
203
+ solid = np.abs(tpms(kind, U, 1.0) * float(a.min())) < 0.5 * wall_over_cell
204
+ eijk = np.argwhere(solid.reshape(tuple(dims)))
205
+ ne = len(eijk)
206
+ corner = (eijk[:, None, :] + CORNERS[None]) % dims
207
+ used, inv = np.unique((corner[..., 0] * dims[1] + corner[..., 1]) * dims[2] + corner[..., 2],
208
+ return_inverse=True)
209
+ enode = inv.reshape(ne, 8)
210
+ nn = len(used)
211
+ edof = (3 * enode[:, :, None] + np.arange(3)).reshape(ne, 24)
212
+ ke = hex8_ke(nu) * h
213
+ k = sp.csr_matrix((np.tile(ke.ravel(), ne), (np.repeat(edof, 24, axis=1).ravel(),
214
+ np.tile(edof, (1, 24)).ravel())), shape=(3 * nn, 3 * nn))
215
+ free = np.ones(3 * nn, bool)
216
+ free[:3] = False
217
+ kf = k[free][:, free].tocsr()
218
+ ml = pyamg.smoothed_aggregation_solver(kf, symmetry="hermitian", max_coarse=400, coarse_solver="splu")
219
+ xc = (eijk[:, None, :] + CORNERS[None]) * h
220
+ c0 = elastic_c(1.0, nu)
221
+ bc = _b_matrix(0, 0, 0) / h
222
+ bcorner = [hex8_b(x, y, z, nu) / h for (x, y, z) in 2.0 * CORNERS - 1.0]
223
+ count = np.bincount(enode.ravel(), minlength=nn)[:, None]
224
+ C = np.zeros((6, 6))
225
+ L = np.zeros((nn, 6, 6), np.float32) # nodal stress per unit macro strain
226
+ for comp in range(6):
227
+ e = np.zeros(6)
228
+ e[comp] = 1.0
229
+ 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]]])
230
+ u0 = (xc @ eps.T).reshape(ne, 24)
231
+ f = np.zeros(3 * nn)
232
+ np.add.at(f, edof, -(u0 @ ke.T))
233
+ uf, info = cg(kf, f[free], rtol=1e-9, maxiter=3000, M=ml.aspreconditioner())
234
+ if info != 0:
235
+ raise RuntimeError("unit cell solve did not converge")
236
+ u = np.zeros(3 * nn)
237
+ u[free] = uf
238
+ ue = u[edof] + u0
239
+ C[:, comp] = ((ue @ bc.T) @ c0.T).sum(axis=0) * h ** 3 / float(np.prod(a))
240
+ acc = np.zeros((nn, 6))
241
+ for kk in range(8):
242
+ np.add.at(acc, enode[:, kk], (ue @ bcorner[kk].T) @ c0.T)
243
+ L[:, :, comp] = acc / count
244
+ sym = symmetry_of(kind)
245
+ if stretched and sym != "none":
246
+ # a cell stretched along its axes keeps only the half turns about them
247
+ sym = "orthorhombic"
248
+ C = _symmetrize(C, sym, rotate_stiffness)
249
+ S = np.linalg.inv(C)
250
+
251
+ def peaks_on(Ls, dirs, rank_shift=0):
252
+ out = np.empty(len(dirs))
253
+ q = percentile / 100.0 * (nn - 1) - rank_shift # the order statistic, numpy's "linear"
254
+ for i in range(0, len(dirs), 16):
255
+ eps = dirs[i:i + 16] @ S.T # (k, 6) macro strains
256
+ st = np.einsum("nab,kb->kna", Ls, eps.astype(np.float32))
257
+ for k, s_ in enumerate(st):
258
+ v = von_mises(s_)
259
+ lo = int(np.floor(q))
260
+ part = np.partition(v, [lo, min(lo + 1, len(v) - 1)])
261
+ out[i + k] = part[lo] + (q - lo) * (part[min(lo + 1, len(v) - 1)] - part[lo])
262
+ return out
263
+
264
+ # The peak is an order statistic near the top: only nodes whose stress
265
+ # could reach it matter. A node's von Mises per unit macro stress is at
266
+ # most the root of the largest eigenvalue of M' V M (M its stress per
267
+ # macro stress), so nodes whose bound is under a threshold every
268
+ # direction's peak clears can be dropped, the rank shifted by as many.
269
+ M = np.einsum("nab,bc->nac", L, S.astype(np.float32))
270
+ bound = np.sqrt(np.linalg.eigvalsh(np.einsum("nba,bc,ncd->nad", M, VM_FORM.astype(np.float32), M))[:, -1])
271
+ ref = _directions(n_random=0)
272
+ thr = 0.5 * float(peaks_on(L, ref).min())
273
+ hot = bound >= thr
274
+ Lh = L[hot]
275
+ shift = int(nn - hot.sum())
276
+
277
+ def peaks_of(dirs):
278
+ out = peaks_on(Lh, dirs, shift)
279
+ bad = out < thr
280
+ if bad.any():
281
+ out[bad] = peaks_on(L, dirs[bad])
282
+ return out
283
+
284
+ base = _directions()
285
+ peaks = peaks_of(base)
286
+ # the cell's symmetry images of every sample read the same peak
287
+ if sym == "none":
288
+ dirs, pk = base, peaks
289
+ else:
290
+ t = stress_rotation(_group(sym)) # (g, 6, 6)
291
+ dirs = np.einsum("gab,kb->gka", t, base).reshape(-1, 6)
292
+ pk = np.tile(peaks, len(t))
293
+ P, quad_worst = _fit_quadratic(dirs, pk, sym)
294
+ Q4, q4_scale = _fit_quartic(dirs, pk)
295
+ floor = 1.0 / quad_worst
296
+ # held-out directions, solved directly: the model against the cell
297
+ hold = _directions(seed=11, n_random=200)[-200:]
298
+ ph = peaks_of(hold)
299
+ ratio = stress_surface(hold, P, Q4, floor) / ph
300
+ ratio_quad = np.sqrt(np.einsum("ka,ab,kb->k", hold, P, hold)) / ph
301
+ e100 = 1.0 / S[0, 0]
302
+ d111 = np.full(3, 1 / np.sqrt(3.0))
303
+ return {"kind": kind, "d": float(wall_over_cell), "n": n, "nu": nu, "rho": float(solid.mean()),
304
+ "aspect": a.tolist() if stretched else None,
305
+ "C": C, "P": P, "Q4": Q4, "floor": floor, "symmetry": sym,
306
+ "holdout": {"min": float(ratio.min()), "mean": float(ratio.mean()), "max": float(ratio.max())},
307
+ "holdoutQuadratic": {"min": float(ratio_quad.min()), "mean": float(ratio_quad.mean()),
308
+ "max": float(ratio_quad.max())},
309
+ "E100": float(e100), "E111": float(directional_modulus(C, d111)),
310
+ "G": float(C[3, 3]), "directions": base, "peaks": peaks, **({"L": L, "S": S} if _keep else {})}
311
+
312
+
313
+ def _fit_quadratic(dirs, pk, sym):
314
+ """peak^2 = s' P s by least squares on the relative error (a cell's
315
+ strong directions count as much as its weak ones), positive definite,
316
+ scaled to lie on or above every sample. Returns (P, worst over-read)."""
317
+ iu = np.triu_indices(6)
318
+ w = np.where(iu[0] == iu[1], 1.0, 2.0)
319
+ A = dirs[:, iu[0]] * dirs[:, iu[1]] * w
320
+ wt = 1.0 / pk ** 2
321
+ p, *_ = np.linalg.lstsq(A * wt[:, None], pk ** 2 * wt, rcond=None)
322
+ P = np.zeros((6, 6))
323
+ P[iu] = p
324
+ P = P + np.triu(P, 1).T
325
+ P = _symmetrize(P, sym, rotate_stress_form)
326
+ wv, vv = np.linalg.eigh(P)
327
+ P = (vv * np.maximum(wv, 1e-6 * wv.max())) @ vv.T
328
+ fit = np.sqrt(np.einsum("ka,ab,kb->k", dirs, P, dirs))
329
+ scale = float(np.max(pk / fit))
330
+ return P * scale ** 2, float(np.max(fit * scale / pk))
331
+
332
+
333
+ MONOMIALS4 = np.array(list(itertools.combinations_with_replacement(range(6), 4))) # (126, 4)
334
+
335
+
336
+ def quartic_features(s):
337
+ """(k, 126) the degree-4 monomials of Voigt stresses s (k, 6)."""
338
+ s = np.asarray(s, float)
339
+ return s[:, MONOMIALS4[:, 0]] * s[:, MONOMIALS4[:, 1]] * s[:, MONOMIALS4[:, 2]] * s[:, MONOMIALS4[:, 3]]
340
+
341
+
342
+ def square_of_quadratic(P) -> np.ndarray:
343
+ """The quartic coefficients (126,) of (s' P s)^2."""
344
+ coef = {}
345
+ for a in range(6):
346
+ for b in range(6):
347
+ for c in range(6):
348
+ for d in range(6):
349
+ key = tuple(sorted((a, b, c, d)))
350
+ coef[key] = coef.get(key, 0.0) + P[a, b] * P[c, d]
351
+ return np.array([coef[tuple(m)] for m in MONOMIALS4])
352
+
353
+
354
+ def _fit_quartic(dirs, pk):
355
+ """peak^4 = a quartic polynomial of the macro stress (126 terms), least
356
+ squares on the relative error, scaled onto or above every sample."""
357
+ A = quartic_features(dirs)
358
+ wt = 1.0 / pk ** 4
359
+ q, *_ = np.linalg.lstsq(A * wt[:, None], pk ** 4 * wt, rcond=None)
360
+ fit = np.maximum(A @ q, 1e-30) ** 0.25
361
+ scale = float(np.max(pk / fit))
362
+ return q * scale ** 4, scale
363
+
364
+
365
+ def stress_surface(s, P, Q4, floor):
366
+ """Peak wall stress for macro stresses s (k, 6) in the cell's axes: the
367
+ quartic surface, never under `floor` times the quadratic one (positive
368
+ definite everywhere, so a direction the quartic dips in between samples
369
+ still reads). P, Q4, floor may be per row ((k, 6, 6), (k, 126), (k,))."""
370
+ s = np.asarray(s, float)
371
+ quad = np.sqrt(np.maximum(np.einsum("ka,ab,kb->k", s, P, s) if np.ndim(P) == 2
372
+ else np.einsum("ka,kab,kb->k", s, P, s), 0.0))
373
+ f = quartic_features(s)
374
+ q4 = (f @ Q4) if np.ndim(Q4) == 1 else np.einsum("km,km->k", f, Q4)
375
+ return np.maximum(np.maximum(q4, 0.0) ** 0.25, np.asarray(floor) * quad)
376
+
377
+
378
+ def directional_modulus(C, d) -> float:
379
+ """Young's modulus along unit direction d of stiffness C."""
380
+ d = np.asarray(d, float)
381
+ d = d / np.linalg.norm(d)
382
+ s = np.outer(d, d)
383
+ sig = np.array([s[i, j] for i, j in _VOIGT])
384
+ return float(1.0 / (sig @ np.linalg.solve(C, sig)))
385
+
386
+
387
+ # ---- tables -------------------------------------------------------------------------
388
+
389
+ @lru_cache(maxsize=1)
390
+ def _tables() -> dict:
391
+ import os
392
+
393
+ path = Path(os.environ.get("LOADPATH_LATTICE_TABLES") or TABLE_PATH)
394
+ if not path.exists():
395
+ return {}
396
+ return json.loads(path.read_text())
397
+
398
+
399
+ def _key(kind: str) -> str:
400
+ from .struts import canonical, is_strut
401
+
402
+ return canonical(kind) if is_strut(kind) else kind
403
+
404
+
405
+ def _aspect_key(aspect):
406
+ if aspect is None:
407
+ return None
408
+ a = tuple(round(float(x), 3) for x in aspect)
409
+ return None if np.allclose(a, 1.0) else a
410
+
411
+
412
+ def has_tensors(kind: str, aspect=None) -> bool:
413
+ """Tables exist (cubic cells), or can be built (a stretched strut or
414
+ sheet cell; not a Voronoi foam)."""
415
+ from .struts import canonical
416
+
417
+ if _aspect_key(aspect) is not None:
418
+ return canonical(kind) != "voronoi" and _key(kind) in _tables().get("kinds", {})
419
+ return _key(kind) in _tables().get("kinds", {})
420
+
421
+
422
+ # stretched cells: a short ladder solved on first use (about a minute and a
423
+ # half a cell type and aspect on 4 cores), cached on disk
424
+ ASPECT_RHO = (0.06, 0.12, 0.22, 0.36, 0.55, 0.8)
425
+ ASPECT_N = 32
426
+
427
+
428
+ def _cache_dir() -> Path:
429
+ import os
430
+
431
+ return Path(os.environ.get("LOADPATH_CACHE") or Path.home() / ".cache" / "loadpath") / "lattice-tensors"
432
+
433
+
434
+ @lru_cache(maxsize=None)
435
+ def aspect_table(kind: str, aspect: tuple) -> list:
436
+ """Rows (rho, d, C, P, Q4, floor) of a stretched cell, from the disk
437
+ cache or solved now (unit_cell_tensors with `aspect`). `d` is the wall
438
+ or strut diameter over the cell: a stretched cell's density at a given
439
+ wall is not the cubic cell's, so it is read from these rows too."""
440
+ from .lattice import SHEETS, wall_for_density
441
+ from .struts import strut_diameter_for_density
442
+
443
+ k = _key(kind)
444
+ path = _cache_dir() / f"{k}-{aspect[0]:g}x{aspect[1]:g}x{aspect[2]:g}.json"
445
+ if path.exists():
446
+ try:
447
+ return json.loads(path.read_text())
448
+ except (OSError, ValueError):
449
+ pass
450
+ rows = []
451
+ for rho in ASPECT_RHO:
452
+ d = float(wall_for_density(k, rho, 1.0)) if k in SHEETS else float(strut_diameter_for_density(k, rho, 1.0))
453
+ r = unit_cell_tensors(k, d, n=ASPECT_N, aspect=aspect)
454
+ rows.append({"rho": r["rho"], "d": d, "C": r["C"].tolist(), "P": r["P"].tolist(),
455
+ "Q4": r["Q4"].tolist(), "floor": r["floor"], "holdout": r["holdout"]})
456
+ rows.sort(key=lambda x: x["rho"])
457
+ try:
458
+ path.parent.mkdir(parents=True, exist_ok=True)
459
+ path.write_text(json.dumps(rows))
460
+ except OSError:
461
+ pass
462
+ return rows
463
+
464
+
465
+ def aspect_density(kind: str, aspect, d_over_cell):
466
+ """Relative density of a stretched cell at wall (or strut diameter)
467
+ over cell `d_over_cell`, from its rows (log-log, to the solid)."""
468
+ rows = aspect_table(kind, _aspect_key(aspect))
469
+ d = np.array([r["d"] for r in rows])
470
+ rho = np.array([r["rho"] for r in rows])
471
+ x = np.log(np.clip(np.asarray(d_over_cell, float), 1e-4, None))
472
+ lx, ly = np.log(d), np.log(rho)
473
+ slope = (ly[1] - ly[0]) / (lx[1] - lx[0])
474
+ hi = (ly[-1] - ly[-2]) / (lx[-1] - lx[-2])
475
+ out = np.where(x < lx[0], ly[0] + slope * (x - lx[0]),
476
+ np.where(x > lx[-1], ly[-1] + hi * (x - lx[-1]), np.interp(x, lx, ly)))
477
+ return np.clip(np.exp(out), 1e-3, 1.0)
478
+
479
+
480
+ @lru_cache(maxsize=None)
481
+ def _rows(kind: str, aspect=None):
482
+ t = aspect_table(kind, aspect) if aspect is not None else _tables()["kinds"][_key(kind)]
483
+ nu = float(_tables().get("nu", 0.35))
484
+ from .fem import elastic_c
485
+
486
+ rho = np.array([r["rho"] for r in t] + [1.0])
487
+ C = np.array([np.array(r["C"]) for r in t] + [elastic_c(1.0, nu)])
488
+ P = np.array([np.array(r["P"]) for r in t] + [VM_FORM])
489
+ Q = np.array([np.array(r["Q4"]) for r in t] + [square_of_quadratic(VM_FORM)])
490
+ F = np.array([r["floor"] for r in t] + [1.0])
491
+ return rho, C, P, Q, F
492
+
493
+
494
+ def _interp_at(rho, xs):
495
+ """(log-log leading-entry interpolant, row j, weight t) at rho: under
496
+ the first row the leading entry runs on with the first two rows' slope
497
+ and the shape is held; between rows the shape is linear in log rho."""
498
+ lx = np.log(xs)
499
+ lr = np.log(np.clip(rho, 1e-3, 1.0))
500
+ j = np.clip(np.searchsorted(lx, lr) - 1, 0, len(lx) - 2)
501
+ t = np.clip((lr - lx[j]) / (lx[j + 1] - lx[j]), 0.0, 1.0)
502
+
503
+ def lead(v):
504
+ lv = np.log(v)
505
+ slope = (lv[1] - lv[0]) / (lx[1] - lx[0])
506
+ return np.exp(np.where(lr < lx[0], lv[0] + slope * (lr - lx[0]), np.interp(lr, lx, lv)))
507
+
508
+ return lead, j, t
509
+
510
+
511
+ def _interp(rho, xs, M, norm):
512
+ lead, j, t = _interp_at(rho, xs)
513
+ shape = M / norm.reshape((-1,) + (1,) * (M.ndim - 1))
514
+ tt = t.reshape((-1,) + (1,) * (M.ndim - 1))
515
+ return lead(norm).reshape((-1,) + (1,) * (M.ndim - 1)) * (shape[j] * (1 - tt) + shape[j + 1] * tt)
516
+
517
+
518
+ def tensors_at(kind: str, rho, aspect=None):
519
+ """(C, P, Q4, floor) of a cell type at relative densities rho, in the
520
+ cell's own axes: C (k, 6, 6) over the solid's E (engineering shears);
521
+ the wall-stress surface (stress_surface) as its quadratic form P (k, 6,
522
+ 6), quartic Q4 (k, 126) and floor (k,), for the macro stress the lattice
523
+ carries (Voigt, tensor shears). `aspect`: a stretched cell (its own
524
+ tables, aspect_table)."""
525
+ if not has_tensors(kind, aspect):
526
+ raise ValueError(f"no anisotropic tensors for lattice {kind!r}; run bench/lattice_tensors.py")
527
+ rho = np.atleast_1d(np.asarray(rho, float))
528
+ xs, C, P, Q, F = _rows(kind, _aspect_key(aspect))
529
+ _, j, t = _interp_at(rho, xs)
530
+ c, pp, q = _interp(rho, xs, C, C[:, 0, 0]), _interp(rho, xs, P, P[:, 0, 0]), _interp(rho, xs, Q, P[:, 0, 0] ** 2)
531
+ low = rho < xs[0]
532
+ if low.any():
533
+ # under the table each eigen-mode runs on with its own power law
534
+ # (matrix log linear in log rho, through the first two rows): a strut
535
+ # cell's bending modes (shear for the cubic) fall about as rho^2.3
536
+ # while its stretching modes fall as rho, so holding the shape of the
537
+ # first row read the shear 3x too stiff at rho 0.02
538
+ u = (np.log(np.clip(rho[low], 1e-3, 1.0)) - np.log(xs[0])) / (np.log(xs[1]) - np.log(xs[0]))
539
+ c_low, p_low = _spd_powerlaw(C[0], C[1], u), _spd_powerlaw(P[0], P[1], u)
540
+ q[low] = q[low] * (p_low[:, 0, 0] / pp[low][:, 0, 0])[:, None] ** 2
541
+ c[low], pp[low] = c_low, p_low
542
+ return c, pp, q, F[j] * (1 - t) + F[j + 1] * t
543
+
544
+
545
+ def _spd_logm(A):
546
+ w, V = np.linalg.eigh(0.5 * (A + A.T))
547
+ return (V * np.log(np.maximum(w, 1e-300 + 1e-12 * w.max()))) @ V.T
548
+
549
+
550
+ def _spd_powerlaw(A0, A1, u):
551
+ """expm(log A0 + u (log A1 - log A0)) for each u: symmetric positive
552
+ definite, each eigen-direction on its own power law."""
553
+ L0, dL = _spd_logm(A0), _spd_logm(A1) - _spd_logm(A0)
554
+ out = np.empty((len(u),) + A0.shape)
555
+ for i, ui in enumerate(np.atleast_1d(u)):
556
+ w, V = np.linalg.eigh(L0 + ui * dL)
557
+ out[i] = (V * np.exp(w)) @ V.T
558
+ return out