loadpath 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/homogenized.py
ADDED
|
@@ -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)
|