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,886 @@
1
+ """
2
+ FE of a field graph's part with its lattice homogenized.
3
+
4
+ A lattice's walls are a fraction of a millimetre, so a voxel model of the
5
+ real geometry needs cells a few tenths of a millimetre across and millions
6
+ of them; tuning a lattice by meshing and analyzing every candidate that way
7
+ is out of reach. Instead each analysis cell (a millimetre or so) is the
8
+ material it holds on average, as lattice.py does for its own infill:
9
+
10
+ - the graph is evaluated twice per sample point, once with the lattice
11
+ node filled solid and once with it empty, so each cell knows how much of
12
+ it is solid skin or boss (`fs`) and how much is lattice (`fc`);
13
+ - the lattice's own fields (wall or density, cell) at the cell's centre give
14
+ its relative density rho (lattice.relative_density);
15
+ - by default (lattice_tensors.py) the lattice is its unit cell's full 6x6
16
+ stiffness at rho, rotated into the frame the cells lie in, and its wall
17
+ stress comes from the cell's directional stress surface under the macro
18
+ stress the lattice carries; the cell's elasticity is E (fs C_solid +
19
+ fc C_lattice), each element its own;
20
+ - with anisotropic=False, the scalar fits: modulus E C rho^n, peak wall
21
+ stress A rho^-m times the stress it carries on average (lattice.SHEETS),
22
+ the cell's modulus E (fs + fc C rho^n);
23
+ - lattice cut open by a face with no skin over it keeps only part of its
24
+ stiffness within a cell of that face, by how the face cuts the cells
25
+ (FACE_CUT: little cut square, much more on the bias for bending cells);
26
+ free_surface= gives one knockdown everywhere (FREE_SURFACE_KNOCKDOWN, the
27
+ old model).
28
+
29
+ Safety factor and deflection are read as verify_part reads them: peaks
30
+ outside a contact band round supports and loads, the stress allowance
31
+ included, deflection the mean displacement of each case's loaded nodes.
32
+ Sheet lattices (lattice.SHEETS) and strut cells (lattice.STRUTS); a
33
+ stretched `aspect` cell is read from its own tensors, solved on first use
34
+ and cached (lattice_tensors.aspect_table). Validation against
35
+ the resolved lattice: bench/lattice_aniso_validate.py, docs/fields.md.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import numpy as np
40
+ from scipy import ndimage
41
+
42
+ __all__ = ["homogenized_check", "lattice_nodes"]
43
+
44
+ _SOLID, _EMPTY = -1.0e3, 1.0e3
45
+
46
+
47
+ def lattice_nodes(graph, node=None):
48
+ """Names of the lattice nodes a FieldGraph's `node` (default its
49
+ output) depends on, inline nodes included."""
50
+ todo, seen, out = [node or graph.output], set(), []
51
+ while todo:
52
+ n = todo.pop()
53
+ if n in seen or n not in graph.nodes:
54
+ continue
55
+ seen.add(n)
56
+ if graph.nodes[n].op == "lattice":
57
+ out.append(n)
58
+ todo += list(graph.deps.get(n, ()))
59
+ return out
60
+
61
+
62
+ def _variant(graph, doc, base_dir, lattice, value):
63
+ """The same graph with `lattice` a constant: solid (negative) or empty.
64
+ It shares the original's built resources (meshes, analyses), so a
65
+ `result` node behind the lattice's wall field is not run again."""
66
+ from .fields import FieldGraph
67
+
68
+ g = FieldGraph(doc, base_dir=base_dir, sources=graph.sources)
69
+ g._built, g.analyses = graph._built, graph.analyses
70
+ nd = g.nodes[lattice]
71
+ o = nd.spec.get("orient")
72
+ if value == _EMPTY and isinstance(o, dict) and o.get("zones") and o.get("seam"):
73
+ # the lattice taken out leaves its zones' seam plates: solid, like a skin
74
+ nd.spec = {**nd.spec, "_seamOnly": True}
75
+ return g
76
+ nd.op, nd.spec, nd.refs = "constant", {"op": "constant", "value": value}, {}
77
+ g.deps[lattice] = []
78
+ return g
79
+
80
+
81
+ # the least share of a cell the part must fill for the cell to be analyzed
82
+ FILL_MIN = 0.1
83
+
84
+ # lattice stiffness lost at a face the lattice is cut open by (no skin):
85
+ # the cell right at the face keeps 1 - this of its unit-cell stiffness,
86
+ # rising linearly to all of it one lattice cell in (free_surface_exposure)
87
+ FREE_SURFACE_KNOCKDOWN = 0.5
88
+
89
+ # printed walls and struts are wavy, porous and stair-stepped, and buckle
90
+ # below a perfect lattice's eigenvalue. Not fitted: none of the 33 published
91
+ # compression tests (docs/VALIDATION.md) reached its walls' perfect buckling
92
+ # stress before first yield, so no test bounds it. Half is a conventional
93
+ # margin for imperfect columns near where buckling and yield meet (Eurocode 3
94
+ # buckling curves put real columns at 0.5-0.7 of Euler there); thin shells
95
+ # lose more, but TPMS sheets at rho 0.15-0.3 buckle at 1.5-8x the solid's
96
+ # yield strain, past first yield
97
+ BUCKLING_KNOCKDOWN = 0.5
98
+
99
+ # how much stiffness a lattice keeps where a bare face (no skin) cuts it
100
+ # depends on how the face cuts the cells. Cut square (the face on a cell
101
+ # plane), struts and sheets run on along the face and carry load: the bars of
102
+ # bench/results/lattice-face-cut.json, cut square, matched their resolved
103
+ # compliance at knockdowns 0-0.3 (0.5, the old single value, read them up to
104
+ # 2x too soft). Cut on the bias, every strut ends in a stub; how much that
105
+ # costs depends on how the cell is braced: bending cells (cubic, BCCz)
106
+ # lose nearly all of it, an octet's triangulated struts very little.
107
+ # knockdown = square + (bias - square) * s, s = 0 cut square to 1 on the body
108
+ # diagonal (face_alignment). Families with no validation take the worst.
109
+ # per family (square, bias, stress): the knockdown cut square and on the
110
+ # bias, and how much more the wall stress of a bias-cut cell at the face
111
+ # reads (the cut struts' stubs: a turned BCCz bar's resolved peak sat on
112
+ # them, 2.3x the homogenized reading). Families with no validation take
113
+ # the bending cells' values (the most conservative).
114
+ FACE_CUT = {"bending": (0.2, 1.4, 1.0), "stretch": (0.1, 0.5, 0.0), "sheet": (0.3, 0.5, 0.0)}
115
+ _FACE_FAMILY = {"cubic": "bending", "bccz": "bending", "bcc": "bending", "kelvin": "bending",
116
+ "voronoi": "bending", "diamond-strut": "bending", "fcc": "stretch", "fccz": "stretch",
117
+ "octet": "stretch", "gyroid": "sheet", "diamond": "sheet", "schwarz-p": "sheet"}
118
+ # cells whose face-cut reading holds turned against as laid at full
119
+ # resolution: the orientation optimizer's turn of an octet bar in a pull
120
+ # (-5% mass) was confirmed (resolved turned/as-laid peak 0.91). Bending
121
+ # cells were not: re-optimized on this model, a BCCz L-bracket still read
122
+ # -40% and came out 3.4x weaker (bench/results/lattice-orient-confirm-
123
+ # facecut.json). lattice_orient.optimize_orientation lifts its bare-face
124
+ # guard only for these
125
+ FACE_CUT_VALIDATED = ("octet",)
126
+
127
+
128
+ # alignment read as cut square: central differences of the envelope that
129
+ # straddle an edge or a blend mix two faces' normals
130
+ SQUARE_ALIGN = 0.95
131
+
132
+
133
+ def _bias_share(align):
134
+ """0 for a face cut square to the cells, 1 on the body diagonal."""
135
+ return np.clip((SQUARE_ALIGN - np.asarray(align, float)) / (SQUARE_ALIGN - 1.0 / np.sqrt(3.0)), 0.0, 1.0)
136
+
137
+
138
+ def face_cut_params(kind, face_cut=None):
139
+ p = tuple(face_cut) if face_cut is not None else FACE_CUT[_FACE_FAMILY.get(kind, "bending")]
140
+ return p + (0.0,) * (3 - len(p))
141
+
142
+
143
+ def face_cut_knockdown(kind, align, face_cut=None):
144
+ """Per cell, the stiffness knockdown at a bare face for cells cut with
145
+ face_alignment `align`. `face_cut`: (square, bias[, stress]) to override."""
146
+ sq, bias, _ = face_cut_params(kind, face_cut)
147
+ return sq + (bias - sq) * _bias_share(align)
148
+
149
+
150
+ # where a lattice roots into a skin or solid, each strut or sheet pulls on
151
+ # the skin over a small patch, and the resolved skin peaks there above the
152
+ # smooth stress the homogenized skin carries: a skinned tall-BCC beam read
153
+ # 0.87-0.90x of the resolved peak, 1.01-1.05x with this (bench/lattice_aniso_validate.py,
154
+ # --band 12). Analysis cells holding
155
+ # both skin and lattice read this many times their stress
156
+ SKIN_JUNCTION = 1.25
157
+
158
+ # the contact band is measured to analysis cell centres, a resolved check's
159
+ # to its nodes: a cell straddling the band's edge was left out whole, up to
160
+ # half a cell more than the resolved check leaves out, and right there (where
161
+ # a load spreads from the block it comes in through) the stress climbs. The
162
+ # band shrinks by this many cells so straddling cells count: a tall-BCC
163
+ # beam's pull read 0.88x of the resolved peak at the band's edge, 0.99x with
164
+ # it; the other skinned beams move up 1-5%
165
+ BAND_EDGE = 0.5
166
+
167
+
168
+ def homogenized_check(doc, study, node=None, base_dir=None, cells: int = 30_000, samples: int = 3,
169
+ fields: bool = False, graph=None, solid_ok: bool = False,
170
+ contact_band: float | None = None, anisotropic: bool | None = None,
171
+ free_surface: float | None = None, skin_junction: float | None = None,
172
+ sources: dict | None = None, face_cut=None, as_printed=None, buckling: bool | None = None) -> dict:
173
+ """mass, safety factor (worst case) and deflection of the graph's part
174
+ under `study` with its lattice homogenized (module docstring). Returns
175
+ a verify_part-like dict: mass, volume, safetyFactor, cases (name,
176
+ safetyFactor, deflection, peakStress, peakAt, inLattice), bodies,
177
+ analysisPitch, analysisCells, latticeFraction. `graph` is a built
178
+ FieldGraph of `doc` to evaluate (and reuse the resources of).
179
+ `contact_band` (mm): stress this close to a support or load is left out
180
+ of the peak; default the study's optimization cell (domain.choose_pitch),
181
+ as the study runner's own check does, and never under 1.5 analysis cells.
182
+ A band that shrinks with the grid lets a bolt-hole singularity govern a
183
+ fine confirm: a GRIP forearm read SF 4.7 on 60k cells and 1.3 on 2M,
184
+ 1 mm from a J3 bolt the part is clamped by.
185
+
186
+ `anisotropic` (default: on wherever lattice_tensors has the cell's
187
+ tables; LOADPATH_LATTICE_ANISO=0 turns it off): each lattice cell is its
188
+ unit cell's full 6x6 stiffness, rotated into the frame the lattice lies
189
+ in, and its wall stress is read from the directional stress surface
190
+ (lattice_tensors) instead of the scalar fits' worst direction. False
191
+ gives the isotropic reading. The report says which (`latticeModel`).
192
+ `skin_junction` (default SKIN_JUNCTION) scales the stress in cells
193
+ where the lattice meets a skin or solid.
194
+ `as_printed` (default: on; LOADPATH_LATTICE_AS_PRINTED=0 turns it off):
195
+ the lattice's stiffness times the as-printed factor
196
+ (lattice_print.print_factor for the study's process, or its
197
+ process.latticeStiffness / calibrated card); a number sets it, False
198
+ reads the designed geometry. Sheet wall stress is read from the strain
199
+ the softer lattice takes, strut stress from the stress it carries
200
+ (lattice_print.STRAIN_SHARE). The report's `latticePrint` says which.
201
+ `buckling` (default on where data/lattice_buckling.json has the cell):
202
+ each lattice cell's largest compressive principal macro stress against
203
+ its walls' buckling stress (lattice_buckling, times BUCKLING_KNOCKDOWN
204
+ and the as-printed factor); a case's safetyFactor is the lesser of
205
+ yield and buckling, `governs` says which. `sources`: in-memory fields
206
+ for the graph's `source` nodes (a sized density grid, lattice_grade)."""
207
+ import time
208
+
209
+ from scipy.spatial import cKDTree
210
+
211
+ from .domain import boundary_conditions
212
+ from .fem import HexMesh, Solver, Stiffness, von_mises
213
+ from .simp import _use_mg
214
+ from .fields import FieldGraph, expand_groups
215
+ from .lattice import lattice_fit, relative_density, sheet_modulus, sheet_stress_factor
216
+ from .struts import is_strut
217
+ from .verify import STRESS_ALLOWANCE
218
+
219
+ t0 = time.time()
220
+ if anisotropic is None:
221
+ import os
222
+
223
+ anisotropic = os.environ.get("LOADPATH_LATTICE_ANISO", "1").strip() not in ("0", "false", "off")
224
+ from .lattice_tensors import aspect_density, has_tensors
225
+
226
+ aspect = None
227
+ full = expand_groups(doc)
228
+ name = node or full.get("output") or list(full["nodes"])[-1]
229
+ full = {**full, "output": name}
230
+ g = graph or FieldGraph(full, base_dir=base_dir, sources=sources)
231
+ lats = lattice_nodes(g, name)
232
+ if not lats and not solid_ok:
233
+ raise ValueError(f"{name!r} has no lattice in it: analyze it as a solid (verify_part), "
234
+ "or pass solid_ok to read it on the same fill-weighted grid")
235
+ if not lats:
236
+ # no lattice: the same grid, every cell solid by its fill. A thin
237
+ # skin reads here without a mesh fine enough to resolve it, and
238
+ # against a lattice part on equal terms
239
+ lat, kind, lrefs, g_solid, g_empty = None, None, {}, g, g
240
+ elif len(lats) > 1:
241
+ raise ValueError(f"homogenized check handles one lattice node, {name!r} uses {', '.join(lats)}")
242
+ else:
243
+ lat = lats[0]
244
+ lnode = g.nodes[lat]
245
+ lspec, lrefs = lnode.spec, lnode.refs
246
+ kind = lspec.get("type", "gyroid")
247
+ if not is_strut(kind) and lspec.get("form", "sheet") != "sheet":
248
+ raise ValueError(f"lattice {lat!r}: the homogenized fits are for sheet lattices, not network")
249
+ lattice_fit(kind) # refuse a cell with no fit before any work
250
+ if "aspect" in lspec and not np.allclose(lspec["aspect"], 1.0):
251
+ aspect = tuple(float(x) for x in lspec["aspect"])
252
+ if not (anisotropic and has_tensors(kind, aspect)):
253
+ raise ValueError(f"lattice {lat!r}: a stretched 'aspect' cell is read from its own tensors "
254
+ "(anisotropic on); the scalar fits are for cubic cells")
255
+ g_solid = _variant(g, full, base_dir, lat, _SOLID)
256
+ g_empty = _variant(g, full, base_dir, lat, _EMPTY)
257
+ b = g_solid.bounds(name)
258
+ if b is None:
259
+ raise ValueError(f"{name!r} is unbounded: intersect its lattice with a solid")
260
+ lo, hi = b
261
+ mat = study.material
262
+
263
+ # analysis grid: the envelope's box at about `cells` cells of the envelope
264
+ span = hi - lo
265
+ pitch = float((np.prod(span) * 0.6 / cells) ** (1.0 / 3.0))
266
+ dims = np.maximum(np.ceil(span / pitch).astype(int), 1)
267
+ origin = lo - 0.5 * (dims * pitch - span)
268
+ ijk = np.stack(np.meshgrid(*[np.arange(n) for n in dims], indexing="ij"), -1).reshape(-1, 3)
269
+ sub = (np.arange(samples) + 0.5) / samples
270
+ offs = np.stack(np.meshgrid(sub, sub, sub, indexing="ij"), -1).reshape(-1, 3)
271
+
272
+ f_full = occupancy_at(g_solid, name, origin, ijk, offs, pitch)
273
+ # every cell the part fills a tenth of or more, each as stiff and as
274
+ # heavy as its share: dropping the half-filled cells at a face (as a
275
+ # 0.5 threshold did) peeled up to half a cell off every skin, a quarter
276
+ # of a 1 mm skin on a 0.5 mm grid, and which half depended on how the
277
+ # grid fell
278
+ active = f_full >= FILL_MIN
279
+ f_skin = np.zeros(len(ijk))
280
+ f_skin[active] = (f_full[active] if lat is None
281
+ else occupancy_at(g_empty, name, origin, ijk[active], offs, pitch))
282
+ grid = np.zeros(tuple(dims), bool)
283
+ grid[tuple(ijk[active].T)] = True
284
+ lbl, _ = ndimage.label(grid)
285
+ e_ijk = ijk[active]
286
+ fs = np.clip(f_skin[active] / np.maximum(f_full[active], 1e-9), 0.0, 1.0)
287
+ fc = 1.0 - fs
288
+ fill = np.clip(f_full[active], 0.0, 1.0)
289
+ centers = origin + (e_ijk + 0.5) * pitch
290
+ # the lattice's density where it is
291
+ if lat is None:
292
+ rho = np.ones(len(centers))
293
+ elif aspect is not None:
294
+ # a stretched cell's density at a wall is its own (aspect_table)
295
+ from .lattice import wall_for_density
296
+ from .struts import strut_diameter_for_density
297
+
298
+ cell = np.broadcast_to(_field(g, lrefs.get("cell", 8.0), centers), (len(centers),))
299
+ if "density" in lrefs:
300
+ dens = np.broadcast_to(_field(g, lrefs["density"], centers), (len(centers),))
301
+ dens = np.clip(dens, 0.02 if is_strut(kind) else 0.05, 0.95)
302
+ wall = (strut_diameter_for_density(kind, dens, cell) if is_strut(kind)
303
+ else wall_for_density(kind, dens, cell))
304
+ else:
305
+ wall = np.broadcast_to(_field(g, lrefs.get("thickness", 1.0), centers), (len(centers),))
306
+ rho = aspect_density(kind, aspect, wall / cell)
307
+ elif "density" in lrefs:
308
+ rho = _field(g, lrefs["density"], centers)
309
+ else:
310
+ wall = np.broadcast_to(_field(g, lrefs.get("thickness", 1.0), centers), (len(centers),))
311
+ cell = np.broadcast_to(_field(g, lrefs.get("cell", 8.0), centers), (len(centers),))
312
+ blend = lrefs.get("blend", 0.0)
313
+ blend = float(np.mean(_field(g, blend, centers))) if not isinstance(blend, float) else blend
314
+ rho = relative_density(kind, wall / cell, 1.0, blend=blend / float(np.mean(cell)))
315
+ rho = np.clip(np.broadcast_to(np.asarray(rho, float), (len(centers),)), 1e-3, 1.0)
316
+
317
+ hm = HexMesh(e_ijk, pitch, origin)
318
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
319
+ sup = np.unique(lbl[tuple(e_ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
320
+ keep = np.isin(lbl[tuple(e_ijk.T)], sup)
321
+ bodies = int(len(np.unique(lbl[grid])))
322
+ if not keep.all():
323
+ e_ijk, fs, fc, rho, centers, fill = e_ijk[keep], fs[keep], fc[keep], rho[keep], centers[keep], fill[keep]
324
+ hm = HexMesh(e_ijk, pitch, origin)
325
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * pitch)
326
+ # lattice cut open by the part's face with no skin over it: within a
327
+ # cell of that face its struts and sheets end free, and carry less than
328
+ # the periodic unit cell says
329
+ if lat is None:
330
+ exposed = np.zeros(len(e_ijk))
331
+ else:
332
+ cell_mm = np.broadcast_to(np.asarray(_field(g, lrefs.get("cell", 8.0), centers), float), (len(centers),))
333
+ exposed = free_surface_exposure(e_ijk, dims, fs, fc, pitch, cell_mm)
334
+ # how square the bare face cuts the cells: 1 where its normal lies on a
335
+ # cell axis (struts and sheets run on along the face), down to 1/sqrt(3)
336
+ # on the bias (body diagonal: every strut ends in a stub)
337
+ rot = None
338
+ align = np.ones(len(e_ijk))
339
+ if lat is not None and (exposed > 0).any():
340
+ rot = lattice_frames(g, lat, centers, pitch, start=name) if has_tensors(kind, aspect) else None
341
+ align = face_alignment(e_ijk, dims, exposed, rot, field=lambda P: g_solid.evaluate(P, name),
342
+ centers=centers, h=0.25 * pitch)
343
+ ec = np.ones(len(rho)) if lat is None else sheet_modulus(kind, rho)
344
+ # stiffness only: the stress read below takes the cell's full tensor
345
+ # times the (larger) strain the softer cell takes, so it errs high
346
+ if free_surface is not None or lat is None:
347
+ # one knockdown wherever a bare face cuts (the old model, and for fits)
348
+ k_cut = FREE_SURFACE_KNOCKDOWN if free_surface is None else float(free_surface)
349
+ face_model = "uniform"
350
+ else:
351
+ k_cut = face_cut_knockdown(kind, align, face_cut)
352
+ face_model = "cut-angle"
353
+ stub = face_cut_params(kind, face_cut)[2] if face_model == "cut-angle" else 0.0
354
+ stub_k = 1.0 + stub * _bias_share(align) * exposed if stub > 0 else None
355
+ knock = np.clip(1.0 - k_cut * exposed, 0.05, 1.0)
356
+ # the print: softer than the designed walls (lattice_print). Sheets as
357
+ # the free-surface knockdown, stiffness only (wall stress from the strain
358
+ # the softer lattice takes); struts read from the stress they carry
359
+ kp, print_info = _print_stiffness(study, kind, rho, as_printed) if lat is not None else (None, None)
360
+ wall_scale = None
361
+ if kp is not None:
362
+ knock = knock * kp
363
+ # sheets: wall stress from the softer lattice's strain (scale 1);
364
+ # struts: from the stress it carries (scale k). lattice_print.STRAIN_SHARE
365
+ from .lattice_print import strain_share
366
+
367
+ share = strain_share(kind)
368
+ wall_scale = None if share >= 1.0 else kp ** (1.0 - share)
369
+ e_cell = mat.E * fill * (fs + fc * ec * knock)
370
+ rhs = [bc.loads[ci] for ci in range(len(study.load_cases))]
371
+ nu = study.elastic(mat)
372
+ aniso = bool(anisotropic) and lat is not None and has_tensors(kind, aspect)
373
+ if aniso:
374
+ from .fem import elastic_c
375
+ from .lattice_tensors import rotate_stiffness, stress_rotation, stress_surface, tensors_at
376
+
377
+ c_lat, p_lat, q_lat, f_lat = tensors_at(kind, rho, aspect)
378
+ if rot is None:
379
+ rot = lattice_frames(g, lat, centers, pitch, start=name)
380
+ # stiffness into the part's axes; stresses go into the cell's
381
+ # (stress_rotation) for its wall-stress surface
382
+ t_rot = None
383
+ if rot is not None:
384
+ c_lat, t_rot = rotate_stiffness(c_lat, rot), stress_rotation(rot)
385
+ c_cell = (mat.E * fill)[:, None, None] * (fs[:, None, None] * elastic_c(1.0, nu)[None]
386
+ + (fc * knock)[:, None, None] * c_lat)
387
+ us = solve_anisotropic(hm, nu, c_cell, bc, mat.E, rhs, proxy=e_cell)
388
+ elif _use_mg(hm.n_elem):
389
+ # a fine grid (a thin skin's re-check runs to a million cells and
390
+ # more): matrix-free multigrid, as verify_part, a fraction of the
391
+ # assembled matrix's memory (13 GB at 1M cells)
392
+ from .mg import GridElasticity
393
+
394
+ stiff = GridElasticity(hm, study.elastic(mat), bc.fixed,
395
+ springs=mat.E * bc.springs if bc.springs is not None else None,
396
+ rtol=1e-6, maxiter=3000)
397
+ stiff.set_moduli(np.asarray(e_cell, float))
398
+ us = stiff.solve_many(rhs)
399
+ else:
400
+ stiff = Stiffness(hm, study.elastic(mat), bc.fixed)
401
+ k = stiff.assemble(e_cell)
402
+ if bc.springs is not None:
403
+ k = (k + mat.E * bc.springs).tocsr()
404
+ us = Solver(hm, rtol=1e-6, maxiter=3000).solve_many(k, rhs)
405
+ if contact_band is None:
406
+ from .domain import choose_pitch
407
+ contact_band = choose_pitch(study)
408
+ band = max(1.5 * pitch, float(contact_band) - BAND_EDGE * pitch)
409
+ wall_k = ec if lat is None else ec * sheet_stress_factor(kind, rho)
410
+ vol_cell = pitch ** 3
411
+ volume = float((fill * (fs + fc * rho) * vol_cell).sum())
412
+ cases, worst = [], np.inf
413
+ macro, bodies_mask = [], []
414
+ junction_k = float(SKIN_JUNCTION if skin_junction is None else skin_junction)
415
+ junction = (fs > 0.05) & (fc > 0.05)
416
+ buck_cap = None
417
+ if buckling is not False and aniso and aspect is None:
418
+ from .lattice_buckling import buckling_stress, has_buckling
419
+
420
+ if has_buckling(kind):
421
+ # the walls' buckling macro stress as printed: perfect-lattice
422
+ # table x knockdown x the cell's stiffness share (print, free face)
423
+ buck_cap = BUCKLING_KNOCKDOWN * buckling_stress(kind, rho) * mat.E * knock
424
+ for ci, case in enumerate(study.load_cases):
425
+ u = us[ci]
426
+ if aniso:
427
+ strain = _center_strain(hm, u)
428
+ se = von_mises(strain @ (mat.E * elastic_c(1.0, nu)).T)
429
+ sig_lat = mat.E * np.einsum("eab,eb->ea", c_lat, strain)
430
+ if fields:
431
+ macro.append(sig_lat) # global axes
432
+ if t_rot is not None:
433
+ sig_lat = np.einsum("eab,eb->ea", t_rot, sig_lat)
434
+ s_lat = np.empty(len(sig_lat))
435
+ for a in range(0, len(sig_lat), 100_000):
436
+ sl = slice(a, a + 100_000)
437
+ s_lat[sl] = stress_surface(sig_lat[sl], p_lat[sl], q_lat[sl], f_lat[sl])
438
+ else:
439
+ se = von_mises(_center_stress(hm, nu, u, mat.E))
440
+ s_lat = se * wall_k
441
+ s_solid = np.where(fs > 0.05, se, 0.0)
442
+ if wall_scale is not None:
443
+ s_lat = s_lat * wall_scale
444
+ if stub_k is not None:
445
+ s_lat = s_lat * stub_k
446
+ s_wall = np.where(fc > 0.05, s_lat, 0.0)
447
+ s = np.maximum(s_solid, s_wall)
448
+ if lat is not None:
449
+ s = np.where(junction, s * junction_k, s)
450
+ contact = bc.support_nodes | bc.case_load_nodes[ci]
451
+ near = cKDTree(hm.node_xyz[contact]).query(centers, k=1, distance_upper_bound=band)[0] < np.inf
452
+ body = ~near if (~near).any() else np.ones(len(s), bool)
453
+ if fields:
454
+ bodies_mask.append(body)
455
+ i = int(np.argmax(np.where(body, s, -1.0)))
456
+ peak = float(s[i])
457
+ sf = mat.strength / (peak * STRESS_ALLOWANCE) if peak > 0 else np.inf
458
+ sf_yield, buck = sf, None
459
+ if buck_cap is not None:
460
+ # the macro stress the lattice carries (its stiffness share), its
461
+ # most compressive principal value
462
+ carried = mat.E * np.einsum("eab,eb->ea", c_lat, strain) * knock[:, None]
463
+ comp = np.maximum(-_min_principal(carried), 0.0)
464
+ use = np.where(body & (fc > 0.05), comp / np.maximum(buck_cap, 1e-30), 0.0)
465
+ j = int(np.argmax(use))
466
+ bf = float(1.0 / use[j]) if use[j] > 0 else np.inf
467
+ buck = {"bucklingFactor": bf, "bucklingAt": centers[j].tolist(),
468
+ "compressiveStress": float(comp[j]), "bucklingStress": float(buck_cap[j])}
469
+ sf = min(sf, bf)
470
+ disp = u.reshape(-1, 3)
471
+ ln = bc.case_load_nodes[ci]
472
+ defl = float(np.linalg.norm(disp[ln].mean(axis=0))) if ln.any() else 0.0
473
+ worst = min(worst, sf)
474
+ extra = {} if buck is None else {**buck, "yieldSafetyFactor": sf_yield,
475
+ "governs": "buckling" if buck["bucklingFactor"] < sf_yield else "yield"}
476
+ cases.append({**extra, "name": case.name, "safetyFactor": sf, "deflection": defl, "peakStress": peak,
477
+ "compliance": float(rhs[ci] @ u),
478
+ "peakAt": centers[i].tolist(), "inLattice": bool(s_wall[i] >= s_solid[i]),
479
+ "maxDisplacement": float(np.linalg.norm(disp, axis=1).max())})
480
+ rep = {"mass": volume * mat.density / 1000.0, "volume": volume, "safetyFactor": worst, "cases": cases,
481
+ "bodies": bodies, "analysisPitch": pitch, "analysisCells": int(hm.n_elem),
482
+ "latticeFraction": float((fc * vol_cell).sum() / max(len(fc) * vol_cell, 1e-12)),
483
+ "latticeDensity": [float(rho[fc > 0.05].min()), float(rho[fc > 0.05].max())] if (fc > 0.05).any()
484
+ else None, "lattice": lat, "stressAllowance": STRESS_ALLOWANCE, "seconds": time.time() - t0,
485
+ "latticeModel": None if lat is None else ("anisotropic" if aniso else "isotropic"),
486
+ "latticePrint": print_info,
487
+ "latticeBuckling": None if lat is None else (
488
+ {"knockdown": BUCKLING_KNOCKDOWN, "table": "data/lattice_buckling.json"} if buck_cap is not None
489
+ else "not checked (" + ("off" if buckling is False else "isotropic model" if not aniso
490
+ else "stretched cell" if aspect is not None else f"no table for {kind}") + ")"),
491
+ # share of the lattice's volume within half a cell of a face it is
492
+ # cut open by (no skin): homogenization reads it too stiff there
493
+ "latticeExposed": float((fc * fill * (exposed > 0.5)).sum() / max((fc * fill).sum(), 1e-12))
494
+ if lat is not None else None,
495
+ "faceCutModel": face_model if lat is not None else None,
496
+ # exposure-weighted mean of face_alignment (1: cut square)
497
+ "faceAlignment": float((fc * fill * exposed * align).sum() / max((fc * fill * exposed).sum(), 1e-12))
498
+ if lat is not None and (exposed > 0).any() else None}
499
+ turned_exposed = None if lat is None else _turned_exposed(rot if aniso else None, fc * fill, exposed)
500
+ rep["latticeTurnedExposed"] = turned_exposed
501
+ zo = (lspec.get("orient") if lat is not None else None)
502
+ zoned = isinstance(zo, dict) and len(zo.get("zones") or []) > 1
503
+ if zoned and not zo.get("seam"):
504
+ rep.setdefault("warnings", []).append(
505
+ "zoned lattice without seam plates (orient.seam): where thin cells of two directions meet the "
506
+ "check reads the seam too stiff (a skinless graded zoned beam: peak 2.4x higher, 1.4x softer at full "
507
+ "resolution, the peak on the seam). Add orient.seam (1 mm) or confirm with a resolved check.")
508
+ elif zoned:
509
+ rep.setdefault("warnings", []).append(
510
+ "zoned lattice with seam plates: the peak reads on the safe side but the stiffness about 20-25% "
511
+ "high (a skinless graded zoned beam at full resolution); leave margin on deflection.")
512
+ elif turned_exposed is not None and turned_exposed > TURNED_EXPOSED_WARN and not (
513
+ face_model == "cut-angle" and kind in FACE_CUT_VALIDATED and aspect is None):
514
+ rep.setdefault("warnings", []).append(
515
+ f"{100 * turned_exposed:.0f}% of the lattice is turned cells cut open at a free face (no skin): "
516
+ "the knockdown there follows the angle the face cuts the cells at (FACE_CUT), but for bending cells "
517
+ "(cubic, BCC, BCCz...) cut on the bias it still reads 0.5-2x off at full resolution: a BCCz "
518
+ "L-bracket turned on this model came out 3.4x weaker than the check said "
519
+ "(bench/results/lattice-orient-confirm-facecut.json). Add a skin or confirm with "
520
+ "resolved.resolved_check (`loadpath resolved-check`).")
521
+ if print_info and print_info.get("note") and getattr(study, "process", None) is not None:
522
+ rep.setdefault("warnings", []).append(print_info["note"])
523
+ if fields:
524
+ rep["fields"] = {"centers": centers, "rho": rho, "fs": fs, "fc": fc, "fill": fill,
525
+ "knock": knock if lat is not None else None,
526
+ # anisotropic: per case the lattice's macro stress (global
527
+ # axes) and the cells the peak is read over; the cell frames
528
+ "latticeStress": macro or None, "body": bodies_mask,
529
+ "frames": rot if aniso else None, "aspect": aspect, "kind": kind}
530
+ return rep
531
+
532
+
533
+ def _print_stiffness(study, kind, rho, as_printed):
534
+ """(per-cell as-printed stiffness factor or None, report entry)."""
535
+ import os
536
+
537
+ from .lattice_print import SOURCES, print_factor, process_group
538
+
539
+ proc = getattr(study, "process", None)
540
+ n = len(rho)
541
+
542
+ def const(k, basis):
543
+ return np.full(n, float(k)), {"factor": [float(k), float(k)], "basis": basis}
544
+
545
+ if as_printed is False or os.environ.get("LOADPATH_LATTICE_AS_PRINTED", "1").strip() in ("0", "false", "off"):
546
+ return None, {"factor": None, "basis": "designed geometry (as-printed factor off)"}
547
+ if as_printed is not None and as_printed is not True:
548
+ return const(as_printed, "given")
549
+ val = getattr(proc, "lattice_stiffness", None) if proc is not None else None
550
+ if val == "designed":
551
+ return None, {"factor": None, "basis": "designed geometry (process.latticeStiffness)"}
552
+ if val is not None:
553
+ return const(val, "process.latticeStiffness or the material's calibrated lattice coupon")
554
+ if proc is None:
555
+ return None, {"factor": None, "basis": "designed geometry (the study names no process)",
556
+ "note": "no process given: the lattice is read as designed; printed lattices tested 1.0-4.1x "
557
+ "softer (docs/VALIDATION.md). Name the process for the as-printed reading."}
558
+ k = print_factor(proc.kind, kind, rho)
559
+ if k is None:
560
+ return None, {"factor": None, "basis": f"designed geometry (no printed-lattice tests for {proc.kind})",
561
+ "note": f"no published lattice tests for {proc.kind}: the lattice is read as designed, and "
562
+ "polymer prints tested 1.0-4.1x softer than designed. Calibrate on a printed coupon."}
563
+ g = process_group(proc.kind)
564
+ return k, {"factor": [float(k.min()), float(k.max())], "group": g, "sources": SOURCES[g],
565
+ "basis": f"published compression tests ({g}{'' if g == proc.kind else ' data, for ' + proc.kind}; "
566
+ "lattice_print.py)"}
567
+
568
+
569
+ def _min_principal(v):
570
+ """Smallest principal value of Voigt stresses (tensor shears), (n, 6)."""
571
+ t = np.empty((len(v), 3, 3))
572
+ t[:, 0, 0], t[:, 1, 1], t[:, 2, 2] = v[:, 0], v[:, 1], v[:, 2]
573
+ t[:, 0, 1] = t[:, 1, 0] = v[:, 3]
574
+ t[:, 1, 2] = t[:, 2, 1] = v[:, 4]
575
+ t[:, 0, 2] = t[:, 2, 0] = v[:, 5]
576
+ return np.linalg.eigvalsh(t)[:, 0]
577
+
578
+
579
+ def free_surface_exposure(e_ijk, dims, fs, fc, pitch, cell_mm):
580
+ """Per analysis cell, 1 - (distance to the part's outside) / (lattice
581
+ cell), clipped to [0, 1], where the outside is nearer than any solid
582
+ (skin) cell; 0 elsewhere. A lattice under a skin is not exposed."""
583
+ from scipy import ndimage
584
+
585
+ pad = 1
586
+ shape = tuple(int(d) + 2 * pad for d in dims)
587
+ inside = np.zeros(shape, bool)
588
+ idx = tuple((e_ijk + pad).T)
589
+ inside[idx] = True
590
+ skin = np.zeros(shape, bool)
591
+ skin[idx] = fs > 0.5
592
+ d_out = ndimage.distance_transform_edt(inside)[idx] * pitch - 0.5 * pitch
593
+ d_skin = ndimage.distance_transform_edt(~skin)[idx] * pitch if skin.any() else np.full(len(e_ijk), np.inf)
594
+ e = np.clip(1.0 - d_out / np.maximum(cell_mm, 1e-9), 0.0, 1.0)
595
+ return np.where((fc > 0.05) & (d_out < d_skin), e, 0.0)
596
+
597
+
598
+ def face_alignment(e_ijk, dims, exposed, rot=None, field=None, centers=None, h=None):
599
+ """Per analysis cell, max_i |n . a_i|: n the outward normal of the
600
+ nearest outside face, a_i the lattice cell's axes there (rot's columns;
601
+ the part's axes if None). 1 for a face cut square to the cells, 1/sqrt(2)
602
+ on a face diagonal, 1/sqrt(3) on the body diagonal. Cells with no
603
+ exposure read 1. n is the gradient of `field` (the part's envelope, its
604
+ lattice filled: inside a box it is the nearest face's normal, square at
605
+ an edge) by central differences at `centers`; without it, of the grid's
606
+ distance to the outside (diagonal along edges)."""
607
+ from scipy import ndimage
608
+
609
+ hit = np.asarray(exposed) > 0
610
+ if field is not None:
611
+ # four points about the centre, the best aligned of them: a centre on
612
+ # an edge's bisector (a symmetric grid puts them there) mixes the two
613
+ # faces' normals, one step off it sees one face
614
+ P0 = np.asarray(centers, float)[hit]
615
+ best = np.zeros(len(P0))
616
+ for off in ((1.0, 0.6, 0.3), (-0.3, 1.0, -0.6), (0.6, -0.3, -1.0), (-1.0, -0.6, 0.3)):
617
+ P = P0 + 1.2 * h * np.asarray(off, float)
618
+ g = np.stack([np.asarray(field(P + h * e), float) - np.asarray(field(P - h * e), float)
619
+ for e in np.eye(3)], 1)
620
+ g /= np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-12)
621
+ if rot is None:
622
+ a = np.abs(g).max(axis=1)
623
+ else:
624
+ R = np.asarray(rot, float)
625
+ a = np.abs(np.einsum("ei,eij->ej", g, R[hit]) if R.ndim == 3 else g @ R).max(axis=1)
626
+ best = np.maximum(best, a)
627
+ out = np.ones(len(e_ijk))
628
+ out[hit] = np.where(best > 0, best, 1.0)
629
+ return out
630
+ n = np.zeros((len(e_ijk), 3))
631
+ if True:
632
+ pad = 2
633
+ shape = tuple(int(v) + 2 * pad for v in dims)
634
+ inside = np.zeros(shape, bool)
635
+ idx = tuple((e_ijk + pad).T)
636
+ inside[idx] = True
637
+ dist = ndimage.gaussian_filter(ndimage.distance_transform_edt(inside), 1.0)
638
+ n = -np.stack([gr[idx] for gr in np.gradient(dist)], 1)
639
+ n /= np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-12)
640
+ if rot is None:
641
+ a = np.abs(n).max(axis=1)
642
+ else:
643
+ R = np.asarray(rot, float)
644
+ a = np.abs(np.einsum("ei,eij->ej", n, R) if R.ndim == 3 else n @ R).max(axis=1)
645
+ return np.where(hit & (np.linalg.norm(n, axis=1) > 0.5), a, 1.0)
646
+
647
+
648
+ def _center_strain(hm, u):
649
+ """Element-center strain (ne, 6), engineering shears (the bubbles'
650
+ strain is zero there)."""
651
+ from .fem import hex8_b_center
652
+
653
+ return u[hm.edof] @ (hex8_b_center() / hm.pitch).T
654
+
655
+
656
+ # share of the lattice (by volume) that is turned more than 5 degrees off
657
+ # its laid axes AND within half a cell of a free face it is cut open by.
658
+ # Above this the result carries a warning: the free-surface knockdown was
659
+ # fitted on cells cut square, and turned cells cut on the bias at a bare
660
+ # face read far too stiff (bench: skinless graded deep beam, resolved check)
661
+ TURNED_EXPOSED_WARN = 0.1
662
+
663
+
664
+ def _turned_exposed(rot, weight, exposed):
665
+ if rot is None:
666
+ return 0.0
667
+ R = np.asarray(rot, float)
668
+ if R.ndim == 2:
669
+ R = np.broadcast_to(R, (len(weight), 3, 3))
670
+ ang = np.degrees(np.arccos(np.clip((np.trace(R, axis1=1, axis2=2) - 1.0) / 2.0, -1.0, 1.0)))
671
+ w = np.asarray(weight, float)
672
+ hit = (ang > 5.0) & (np.asarray(exposed) > 0.5)
673
+ return float((w * hit).sum() / max(w.sum(), 1e-12))
674
+
675
+
676
+ def lattice_frames(g, lat, centers, pitch, start=None):
677
+ """(n, 3, 3) the lattice's local cell axes at `centers` (columns, in
678
+ global components): the polar factor of the lattice coordinates'
679
+ Jacobian, by central differences. None where the cell axes are the
680
+ global ones (a cartesian, ungraded lattice, or a Voronoi foam, whose
681
+ tables are its own periodic draw in global axes)."""
682
+ nd = g.nodes[lat]
683
+ s = nd.spec
684
+ from .struts import canonical
685
+
686
+ moves = _path_transforms(g, start or g.output, lat)
687
+ rotated = any("rotate" in g.nodes[t].spec for t in moves)
688
+ if isinstance(s.get("orient"), dict) and "zones" in s["orient"]:
689
+ # turned zone by zone: the polar factor of the zones' rotations
690
+ # weighted where the cell sits (a transform above moves the points)
691
+ from .fields import orient_weights, zone_rotations
692
+
693
+ P = centers
694
+ for t in moves:
695
+ P = g.transform_points(g.nodes[t], P)
696
+ if rotated:
697
+ raise ValueError(f"lattice {lat!r}: a zoned orient under a rotating transform node is not supported; "
698
+ "fold the rotation into the zones")
699
+ m = np.einsum("nk,kij->nij", orient_weights(nd, P), zone_rotations(nd))
700
+ w, _, vt = np.linalg.svd(m)
701
+ return w @ vt
702
+ frame = (s.get("frame") or {}).get("type", "cartesian")
703
+ graded = isinstance(nd.refs.get("cell"), str) and s.get("grading", "warp") == "warp"
704
+ if not rotated and (canonical(s.get("type", "gyroid")) == "voronoi"
705
+ or (frame == "cartesian" and not graded and "orient" not in s)):
706
+ return None
707
+
708
+ def coords(P):
709
+ for t in moves:
710
+ P = g.transform_points(g.nodes[t], P)
711
+ if canonical(s.get("type", "gyroid")) == "voronoi":
712
+ return P
713
+ return g.lattice_coords(nd, P)[0]
714
+
715
+ h = 1e-3 * pitch
716
+ jac = np.empty((len(centers), 3, 3))
717
+ for i in range(3):
718
+ d = np.zeros(3)
719
+ d[i] = h
720
+ up = coords(centers + d)
721
+ um = coords(centers - d)
722
+ du = up - um
723
+ du -= np.round(du) # a frame's seam jumps by whole cells
724
+ jac[:, :, i] = du / (2 * h)
725
+ w, _, vt = np.linalg.svd(jac)
726
+ q = w @ vt # rows: the cell's axes
727
+ return np.swapaxes(q, 1, 2)
728
+
729
+
730
+ def _path_transforms(g, start, target):
731
+ """Transform nodes on a path from `start` down to `target`, outermost
732
+ first (each moves the points the lattice is sampled at)."""
733
+ def walk(n, seen):
734
+ if n == target:
735
+ return []
736
+ if n in seen or n not in g.nodes:
737
+ return None
738
+ seen.add(n)
739
+ for d in g.deps.get(n, ()):
740
+ rest = walk(d, seen)
741
+ if rest is not None:
742
+ return ([n] if g.nodes[n].op == "transform" else []) + rest
743
+ return None
744
+
745
+ return walk(start, set()) or []
746
+
747
+
748
+ def solve_anisotropic(hm, nu, c_cell, bc, modulus, rhs, proxy, rtol=1e-6, maxiter=3000):
749
+ """Displacements for per-element elasticity matrices `c_cell` (ne, 6, 6,
750
+ moduli included). Below simp.MG_MIN_CELLS: the assembled matrix, each
751
+ element its own condensed hex8 (fem.hex8_ke_batch), CG + AMG. From
752
+ there: the operator matrix-free (each element's C and bubble
753
+ condensation kept, in single precision) inside CG preconditioned by the
754
+ geometric multigrid of the isotropic stand-in with moduli `proxy`."""
755
+ from .fem import Solver, Stiffness, hex8_ke_batch
756
+ from .simp import _use_mg
757
+
758
+ springs = modulus * bc.springs if bc.springs is not None else None
759
+ ne = len(c_cell)
760
+ chunk = 20_000
761
+ if not _use_mg(ne):
762
+ stiff = Stiffness(hm, nu, bc.fixed)
763
+ data = np.zeros(stiff.nnz)
764
+ for a in range(0, ne, chunk):
765
+ idx = np.arange(a, min(a + chunk, ne))
766
+ ke, _ = hex8_ke_batch(c_cell[idx])
767
+ stiff.add_elements(data, ke * hm.pitch, idx)
768
+ k = stiff.finish(data)
769
+ if springs is not None:
770
+ k = (k + springs).tocsr()
771
+ return Solver(hm, rtol=rtol, maxiter=maxiter).solve_many(k, rhs)
772
+ return _AnisoMG(hm, nu, c_cell, bc, springs, proxy, rtol, maxiter).solve_many(rhs)
773
+
774
+
775
+ class _AnisoMG:
776
+ """Matrix-free anisotropic operator + isotropic multigrid preconditioner."""
777
+
778
+ CHUNK = 40_000
779
+
780
+ def __init__(self, hm, nu, c_cell, bc, springs, proxy, rtol, maxiter):
781
+ from .fem import _gauss_b, hex8_ke_batch
782
+ from .mg import GridElasticity
783
+
784
+ self.hm, self.rtol, self.maxiter = hm, rtol, maxiter
785
+ self.free = ~np.asarray(bc.fixed, bool)
786
+ self.springs = springs.tocsr() if springs is not None else None
787
+ b, ba = _gauss_b()
788
+ self.b = (b.reshape(48, 24) / hm.pitch).astype(np.float32)
789
+ self.ba = np.zeros((48, 9), np.float32)
790
+ for gi in range(8):
791
+ self.ba[6 * gi:6 * gi + 6] = ba[gi] / hm.pitch
792
+ self.w = hm.pitch ** 3 / 8.0
793
+ ne = len(c_cell)
794
+ self.c = c_cell.astype(np.float32)
795
+ self.r = np.empty((ne, 9, 24), np.float32)
796
+ for a in range(0, ne, 20_000):
797
+ _, r = hex8_ke_batch(c_cell[a:a + 20_000])
798
+ self.r[a:a + 20_000] = 0.0 if r is None else r
799
+ self.mg = GridElasticity(hm, nu, bc.fixed, springs=springs, rtol=rtol, maxiter=maxiter)
800
+ self.mg.set_moduli(np.asarray(proxy, float))
801
+ self.last_iters = 0
802
+
803
+ def apply(self, u):
804
+ hm = self.hm
805
+ out = np.zeros(hm.n_dof)
806
+ u = np.where(self.free, u, 0.0)
807
+ for a in range(0, hm.n_elem, self.CHUNK):
808
+ sl = slice(a, a + self.CHUNK)
809
+ ed = hm.edof[sl]
810
+ ue = u[ed]
811
+ alpha = np.einsum("eij,ej->ei", self.r[sl], ue)
812
+ eps = (ue @ self.b.T + alpha @ self.ba.T).reshape(-1, 8, 6)
813
+ sig = np.einsum("eij,egj->egi", self.c[sl], eps).reshape(-1, 48)
814
+ fe = (sig @ self.b) * self.w
815
+ out += np.bincount(ed.ravel(), weights=fe.astype(np.float64).ravel(), minlength=hm.n_dof)
816
+ if self.springs is not None:
817
+ out += self.springs @ u
818
+ return np.where(self.free, out, 0.0)
819
+
820
+ def precondition(self, r):
821
+ g = self.mg.to_grid(r) * self.mg.levels[0].mask
822
+ return np.where(self.free, self.mg.from_grid(self.mg.precondition(g) * self.mg.levels[0].mask), 0.0)
823
+
824
+ def solve_many(self, fs):
825
+ out = []
826
+ for f in fs:
827
+ b = np.where(self.free, f, 0.0)
828
+ x = np.zeros_like(b)
829
+ bn = float(np.linalg.norm(b))
830
+ if bn == 0:
831
+ out.append(x)
832
+ continue
833
+ r = b.copy()
834
+ z = self.precondition(r)
835
+ p = z.copy()
836
+ rz = float(r @ z)
837
+ ok = False
838
+ for it in range(1, self.maxiter + 1):
839
+ ap = self.apply(p)
840
+ alpha = rz / float(p @ ap)
841
+ x += alpha * p
842
+ r -= alpha * ap
843
+ if np.linalg.norm(r) <= self.rtol * bn:
844
+ ok = True
845
+ break
846
+ z = self.precondition(r)
847
+ rz_new = float(r @ z)
848
+ p = z + (rz_new / rz) * p
849
+ rz = rz_new
850
+ self.last_iters = it
851
+ if not ok:
852
+ raise RuntimeError(f"anisotropic lattice solve did not converge ({it} iterations)")
853
+ out.append(x)
854
+ return out
855
+
856
+
857
+ def _center_stress(hm, nu, u, modulus):
858
+ """Element-center stress (Stiffness.stress without the matrix layout)."""
859
+ from .fem import elastic_c, hex8_b_center
860
+
861
+ b = hex8_b_center() / hm.pitch
862
+ strain = u[hm.edof] @ b.T
863
+ return (strain @ elastic_c(1.0, nu).T) * modulus
864
+
865
+
866
+ def occupancy_at(graph, name, origin, ijk, offs, pitch):
867
+ """Solid share of each cell from its sub-samples, each counted by how
868
+ deep it sits (the signed distance over the sample spacing, a linear ramp
869
+ across it) rather than in or out: a flat face that lands on a row of
870
+ samples would otherwise drop them all, and a thin wall's reading jumped
871
+ by a third from one grid to the next (a 1 mm skin on a 0.5 mm grid lost
872
+ 13% of its mass)."""
873
+ occ = np.empty(len(ijk))
874
+ n = round(len(offs) ** (1.0 / 3.0))
875
+ h = pitch / max(n, 1)
876
+ step = max(1, 400_000 // len(offs))
877
+ for i in range(0, len(ijk), step):
878
+ P = (origin + (ijk[i:i + step, None, :] + offs[None]) * pitch).reshape(-1, 3)
879
+ d = np.asarray(graph.evaluate(P, name), float)
880
+ occ[i:i + step] = np.clip(0.5 - d / h, 0.0, 1.0).reshape(-1, len(offs)).mean(axis=1)
881
+ return occ
882
+
883
+
884
+ def _field(g, ref, P):
885
+ """A lattice param at points: a number or the node it names."""
886
+ return float(ref) if isinstance(ref, (int, float)) else np.asarray(g.evaluate(P, ref), float)