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/buildsim.py
ADDED
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build-process simulation: the residual stress and distortion a part picks
|
|
3
|
+
up while it is printed, and what to do about it.
|
|
4
|
+
|
|
5
|
+
One mechanism covers the three processes this handles. Each layer goes
|
|
6
|
+
down hot and stress-free, bonds to the (already distorted) part below, and
|
|
7
|
+
then shrinks as it cools; what it is bonded to stops part of that shrink,
|
|
8
|
+
so the layer is left in tension, the part below it bends, and when the part
|
|
9
|
+
comes off the plate or bed it springs into a new shape. The solver adds the
|
|
10
|
+
part a layer at a time on the voxel grid fem.py uses, gives each new layer
|
|
11
|
+
an *inherent strain* (the shrink it would take if it were free), solves for
|
|
12
|
+
the displacement increment with only the cells printed so far, and carries
|
|
13
|
+
every cell's stress forward. A new layer is stress-free in the shape it is
|
|
14
|
+
laid into: its bottom nodes sit where the layer below has moved to, its top
|
|
15
|
+
is the nominal plane (the recoater or nozzle levels it). Then the part is
|
|
16
|
+
released (cut off the plate, or peeled off the bed) and springs back.
|
|
17
|
+
|
|
18
|
+
What sets the inherent strain:
|
|
19
|
+
|
|
20
|
+
lpbf Each melt track shrinks from the solidus and is held by the solid
|
|
21
|
+
metal around it, so it yields; what it keeps is roughly the yield
|
|
22
|
+
strain. eps = -k * yield / E in the layer plane (and `kz` times that
|
|
23
|
+
along the build), with k fitted to a published distortion benchmark
|
|
24
|
+
(validated in bench/buildsim_validate.py and docs/BUILDSIM.md). The
|
|
25
|
+
commercial tools (Ansys Additive's assumed-uniform-strain mode,
|
|
26
|
+
Netfabb, Simufact) do the same with a strain scaling factor they
|
|
27
|
+
calibrate per machine and material.
|
|
28
|
+
fdm A road freezes at its glass transition and cools to the temperature
|
|
29
|
+
the part sits at during the print, set by the bed and the chamber:
|
|
30
|
+
T(z) = T_chamber + (T_bed - T_chamber) exp(-z / lambda). Polymer
|
|
31
|
+
relaxes while warm, so only `relax` of that shrink is kept. After
|
|
32
|
+
the print the whole part cools on the bed to room temperature
|
|
33
|
+
(stage two), then comes off. The bed holds the first layer until
|
|
34
|
+
the peel traction beats the adhesion, so the report gives the peel
|
|
35
|
+
stress at the bed: that, not the final warp, is what lifts corners.
|
|
36
|
+
sls The part sinters in a hot powder cake and cools as a whole, so there
|
|
37
|
+
is no layer stepping. The bulk shrink (3-4%) is taken out by the
|
|
38
|
+
printer's scale factor; what warps a part is the difference between
|
|
39
|
+
thin walls, which cool fast, and thick sections, which crystallize
|
|
40
|
+
more and shrink more. eps = -ds * min(depth / depth_sat, 1),
|
|
41
|
+
depth = distance to the nearest surface. That model is a heuristic
|
|
42
|
+
(stated in the report), sized to the 0.2-0.5% differential shrink
|
|
43
|
+
nylon sintering reports, not fitted to a measured part.
|
|
44
|
+
|
|
45
|
+
Layers are lumped: one analysis layer is `layer` mm (default one voxel) of
|
|
46
|
+
real layers, which is how every inherent-strain code runs a part in finite
|
|
47
|
+
time. Lumping is not free: a thick slab laid at once on a compliant part
|
|
48
|
+
bends it less than the same slab laid in thin layers (on the free strip of
|
|
49
|
+
tests/test_buildsim.py, 0.5 mm lumps give 72% of the thin-layer curvature,
|
|
50
|
+
progressive_beam). On a part held by a thick plate the effect is smaller;
|
|
51
|
+
the LPBF factor was fitted at the lumping the validation runs, and
|
|
52
|
+
docs/BUILDSIM.md gives the pitch study.
|
|
53
|
+
|
|
54
|
+
What it gives back (BuildResult): the displacement field before and after
|
|
55
|
+
release (best-fit rigid motion removed after a full release, as a CMM
|
|
56
|
+
reading of a free part would), the residual stress, the peak von Mises
|
|
57
|
+
against the material's yield, the upward lift at the build plane during
|
|
58
|
+
the build (an LPBF recoater-contact indicator), the FDM bed peel stress,
|
|
59
|
+
and a sampler of the displacement anywhere, which `compensate` uses to
|
|
60
|
+
pre-distort exported geometry so the print comes out nominal.
|
|
61
|
+
|
|
62
|
+
`optimize_warp` puts the as-built distortion into topology optimization:
|
|
63
|
+
weighted compliance plus distortion, with the gradient of the layer-by-
|
|
64
|
+
layer solve by adjoint (two solves per analysis layer and iteration on a
|
|
65
|
+
factorization shared between them).
|
|
66
|
+
"""
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import time
|
|
70
|
+
from dataclasses import dataclass, field
|
|
71
|
+
|
|
72
|
+
import numpy as np
|
|
73
|
+
import scipy.sparse as sp
|
|
74
|
+
|
|
75
|
+
from .fem import HexMesh, Stiffness, _b_matrix, amg_solver, elastic_c, hex8_b_center, rigid_body_modes, \
|
|
76
|
+
von_mises
|
|
77
|
+
|
|
78
|
+
__all__ = ["BUILD", "BuildSetup", "BuildResult", "inherent_strain", "simulate_build", "simulate_part",
|
|
79
|
+
"compensate", "compensate_mesh", "to_build_frame", "progressive_beam", "optimize_warp",
|
|
80
|
+
"WarpOptions", "WarpResult"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Process parameters by material. Every number carries its source; the
|
|
84
|
+
# fitted ones say what they were fitted to. Values a datasheet does not
|
|
85
|
+
# give are marked "estimate" with why.
|
|
86
|
+
BUILD = {
|
|
87
|
+
# LPBF: k is the share of the yield strain a layer keeps (fit: see
|
|
88
|
+
# LPBF_K below); kz the build-direction strain relative to in-plane.
|
|
89
|
+
"lpbf": {"k": None, "kz": 1.0},
|
|
90
|
+
# FDM: tg is where an amorphous road stops flowing (DSC Tg); tc, for a
|
|
91
|
+
# semi-crystalline one (PP, nylon), where it crystallizes on cooling
|
|
92
|
+
# and turns solid, far above its Tg. relax is the share of the thermal
|
|
93
|
+
# shrink below that the polymer keeps as stress (the rest creeps out
|
|
94
|
+
# while the part is warm). bed/chamber: typical settings.
|
|
95
|
+
"fdm": {
|
|
96
|
+
"abs": {"tg": (101.1, "polylite-abs DSC"), "bed": 100.0, "chamber": 35.0},
|
|
97
|
+
"asa": {"tg": (97.8, "polylite-asa DSC"), "bed": 100.0, "chamber": 35.0},
|
|
98
|
+
"petg": {"tg": (80.0, "estimate: PETG copolyester Tg 78-81 C (Eastman Eastar 6763, DSC)"),
|
|
99
|
+
"bed": 80.0, "chamber": 30.0},
|
|
100
|
+
"pla": {"tg": (60.0, "estimate: PLA Tg 55-65 C"), "bed": 60.0, "chamber": 30.0},
|
|
101
|
+
"pa-cf": {"tg": (70.0, "bambu-paht-cf DSC"), "bed": 90.0, "chamber": 40.0},
|
|
102
|
+
"pp": {"tc": (115.0, "estimate: isotactic PP crystallizes at 110-120 C on cooling (DSC, 10 K/min)"),
|
|
103
|
+
"bed": 100.0, "chamber": 25.0},
|
|
104
|
+
"pa12": {"tc": (150.0, "estimate: PA12 crystallizes at 145-155 C on cooling (DSC, 10 K/min)"),
|
|
105
|
+
"bed": 90.0, "chamber": 40.0},
|
|
106
|
+
},
|
|
107
|
+
# SLS: ds the extra linear shrink of a thick section over a thin wall;
|
|
108
|
+
# depth_sat the depth (mm) past which a section counts as thick.
|
|
109
|
+
"sls": {"ds": 0.003, "depth_sat": 4.0},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# LPBF strain factor k (eps = -k * yield / E), fitted to the NIST AM-Bench
|
|
113
|
+
# 2018 bridges at 0.5 mm voxels, one analysis layer per voxel, the 12.7 mm
|
|
114
|
+
# plate reduced to 2 voxel layers (plate_cells; on the whole plate the fit
|
|
115
|
+
# was 0.909) (bench/buildsim_validate.py; docs/BUILDSIM.md). The yield-strain
|
|
116
|
+
# scaling does not carry across alloy classes: with IN625's k, 15-5 PH
|
|
117
|
+
# came out 60% high (its martensite forms on cooling and expands, which
|
|
118
|
+
# undoes part of the shrink), so 15-5 PH has its own fit. Alloys without a
|
|
119
|
+
# fit use IN625's and the report says so.
|
|
120
|
+
LPBF_K = 0.839
|
|
121
|
+
LPBF_K_FIT = {"in625": 0.839, "15-5ph": 0.522}
|
|
122
|
+
# FDM retained share of the sub-Tg shrink, and the bed's hold (N/mm^3 of
|
|
123
|
+
# elastic foundation): fitted together on the nine 20 mm-wide ABS bars of
|
|
124
|
+
# Armillotta et al. 2018 (bench/buildsim_fdm.py; rms 0.16 mm, mean 24%;
|
|
125
|
+
# the bed pinned only in plane, so corners can lift). The decay length
|
|
126
|
+
# (mm) of the bed's heat up the part is an estimate. FDM_ADHESION: MPa of
|
|
127
|
+
# bond tension before the first layer lets go; off by default, since
|
|
128
|
+
# letting go made the Armillotta fit worse (it flattens the tall bars and
|
|
129
|
+
# leaves the thin ones), see docs/BUILDSIM.md.
|
|
130
|
+
FDM_RELAX = 0.895
|
|
131
|
+
FDM_BED = 4.0
|
|
132
|
+
FDM_DECAY = 6.0
|
|
133
|
+
FDM_ADHESION = float("inf")
|
|
134
|
+
# FDM: the printed layer (mm) builds are extrapolated to when their analysis
|
|
135
|
+
# layers are thicker (layer_extrapolation); 0.2 mm is the common setting.
|
|
136
|
+
FDM_PRINT_LAYER = 0.2
|
|
137
|
+
ROOM = 23.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class BuildSetup:
|
|
142
|
+
"""How the part is made. Lengths mm, temperatures C, strains signed
|
|
143
|
+
(negative = shrink). Everything not given comes from BUILD and the
|
|
144
|
+
material library."""
|
|
145
|
+
process: str = "lpbf"
|
|
146
|
+
material: str = "ti64"
|
|
147
|
+
layer: float | None = None # mm per analysis layer (default: one voxel)
|
|
148
|
+
plate: float = 10.0 # mm of build plate under the part (lpbf); 0 = rigid plate
|
|
149
|
+
plate_margin: float = 2.0 # mm the plate extends past the part's footprint
|
|
150
|
+
plate_fixed: bool = True # plate bolted down along its underside; False: free (3-2-1)
|
|
151
|
+
# at most this many cells of plate; a thinner model plate is made as
|
|
152
|
+
# compliant as the real one through the thickness (modulus scaled by
|
|
153
|
+
# the thickness ratio), for a plate held along its underside only.
|
|
154
|
+
# None = model the whole plate. On AM-Bench at 0.5 mm voxels, 2 cells
|
|
155
|
+
# ran in 65 s against 181 s and rose 8% more; LPBF_K is fitted with 2.
|
|
156
|
+
plate_cells: int | None = 2
|
|
157
|
+
strain: tuple | None = None # inherent strain override (exx, eyy, ezz), build frame
|
|
158
|
+
scale: float = 1.0 # multiplies the inherent strain (a calibration)
|
|
159
|
+
k: float | None = None # lpbf: share of the yield strain kept (default LPBF_K)
|
|
160
|
+
kz: float | None = None # lpbf: build-direction strain / in-plane
|
|
161
|
+
bed: float | None = None # fdm: bed temperature
|
|
162
|
+
chamber: float | None = None # fdm: chamber / ambient air during the print
|
|
163
|
+
room: float = ROOM
|
|
164
|
+
relax: float | None = None # fdm: share of the sub-Tg shrink kept
|
|
165
|
+
decay: float | None = None # fdm: mm the bed's heat reaches up the part
|
|
166
|
+
ds: float | None = None # sls: differential shrink, thick vs thin
|
|
167
|
+
depth_sat: float | None = None # sls: mm
|
|
168
|
+
release: str = "full" # "full" (off the plate, free), "none" (stay on), "cut"
|
|
169
|
+
cut: tuple | None = None # release="cut": ((x0,y0,z0),(x1,y1,z1)) box of cells removed
|
|
170
|
+
E: float | None = None # MPa override
|
|
171
|
+
nu: float | None = None
|
|
172
|
+
yield_strength: float | None = None # MPa override (lpbf strain, stress check)
|
|
173
|
+
alpha: float | None = None # 1/K override (fdm)
|
|
174
|
+
# fdm: how the bed holds the first layer, N/mm^3 (stiffness per unit
|
|
175
|
+
# area of an elastic foundation in z and in plane); None = FDM_BED
|
|
176
|
+
# (fitted), inf = rigid (glued), 0 = not at all (curls freely)
|
|
177
|
+
bed_stiffness: float | None = None
|
|
178
|
+
# fdm: peel strength of the bed bond, MPa: where the bed's pull on the
|
|
179
|
+
# first layer passes it, that spot lets go for good (corner lift).
|
|
180
|
+
# None = FDM_ADHESION, inf = never
|
|
181
|
+
bed_adhesion: float | None = None
|
|
182
|
+
recoater_gap: float = 0.05 # mm: lift at the build plane that can touch the recoater
|
|
183
|
+
# fdm: the printed layer, mm (None: FDM_PRINT_LAYER; 0: no extrapolation)
|
|
184
|
+
print_layer: float | None = None
|
|
185
|
+
# fdm: brim width, mm (0: none): more bed hold under the footprint's edge
|
|
186
|
+
brim: float = 0.0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class BuildResult:
|
|
191
|
+
mesh: HexMesh
|
|
192
|
+
part: np.ndarray # bool per element: the printed part (not plate)
|
|
193
|
+
layer_of: np.ndarray # analysis layer per element (-1 = plate)
|
|
194
|
+
u_built: np.ndarray # (n_nodes, 3) mm, at the end of the build, still on the plate/bed
|
|
195
|
+
u: np.ndarray # (n_nodes, 3) mm, after release (rigid motion removed if free)
|
|
196
|
+
stress_built: np.ndarray # (ne, 6) MPa
|
|
197
|
+
stress: np.ndarray # (ne, 6) MPa after release
|
|
198
|
+
strain: np.ndarray # (ne, 6) inherent strain applied
|
|
199
|
+
report: dict = field(default_factory=dict)
|
|
200
|
+
node_active: np.ndarray | None = None # bool per node: belongs to the released part
|
|
201
|
+
|
|
202
|
+
def sampler(self, released=True):
|
|
203
|
+
return _NodeField(self.mesh, self.u if released else self.u_built,
|
|
204
|
+
self.node_active if released else None)
|
|
205
|
+
|
|
206
|
+
def displacement_at(self, points, released=True):
|
|
207
|
+
"""Displacement (n, 3) at world points, trilinear in the cell the
|
|
208
|
+
point is in; points outside the part take the nearest node's."""
|
|
209
|
+
return self.sampler(released)(points)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ------------------------------------------------------------ the strain
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _material(name):
|
|
216
|
+
from .materials import material_from_spec
|
|
217
|
+
return material_from_spec(name)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _lib(name, key, default=None):
|
|
221
|
+
from .material_library import value
|
|
222
|
+
return value(name, key, default)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def inherent_strain(setup: BuildSetup, z=None, depth=None) -> tuple[np.ndarray, dict]:
|
|
226
|
+
"""Per-cell inherent strain (n, 6) for heights `z` (mm above the plate,
|
|
227
|
+
fdm) or depths below the surface (mm, sls), and a dict of what set it.
|
|
228
|
+
lpbf is uniform: one row broadcast."""
|
|
229
|
+
proc = setup.process.lower()
|
|
230
|
+
mat = _material(setup.material)
|
|
231
|
+
e = float(setup.E or mat.E)
|
|
232
|
+
n = 1 if z is None and depth is None else len(z if z is not None else depth)
|
|
233
|
+
info = {"process": proc, "material": setup.material, "E": e}
|
|
234
|
+
if setup.strain is not None:
|
|
235
|
+
s = np.asarray(setup.strain, float) * setup.scale
|
|
236
|
+
out = np.zeros((n, 6))
|
|
237
|
+
out[:, :3] = s
|
|
238
|
+
info.update({"model": "given", "strain": s.tolist()})
|
|
239
|
+
return out, info
|
|
240
|
+
if proc == "lpbf":
|
|
241
|
+
sy = float(setup.yield_strength or mat.strength)
|
|
242
|
+
name = str(setup.material).lower()
|
|
243
|
+
k = LPBF_K_FIT.get(name, LPBF_K) if setup.k is None else float(setup.k)
|
|
244
|
+
info["kSource"] = ("given" if setup.k is not None else
|
|
245
|
+
"fitted to AM-Bench 2018 (this alloy)" if name in LPBF_K_FIT else
|
|
246
|
+
"AM-Bench 2018 IN625 fit, not calibrated for this alloy: could be off 2x (calibrate with a "
|
|
247
|
+
"cantilever cut test and set buildSim.scale)")
|
|
248
|
+
kz = BUILD["lpbf"]["kz"] if setup.kz is None else float(setup.kz)
|
|
249
|
+
ep = -k * sy / e * setup.scale
|
|
250
|
+
out = np.zeros((n, 6))
|
|
251
|
+
out[:, :2] = ep
|
|
252
|
+
out[:, 2] = kz * ep
|
|
253
|
+
info.update({"model": "yield-strain", "k": k, "kz": kz, "yield": sy, "strain": [ep, ep, kz * ep]})
|
|
254
|
+
return out, info
|
|
255
|
+
if proc == "fdm":
|
|
256
|
+
p = _fdm_params(setup)
|
|
257
|
+
tz = _fdm_temperature(p, np.zeros(n) if z is None else np.asarray(z, float))
|
|
258
|
+
ep = -p["relax"] * p["alpha"] * np.maximum(p["tg"] - tz, 0.0) * setup.scale
|
|
259
|
+
out = np.zeros((n, 6))
|
|
260
|
+
out[:, :3] = ep[:, None]
|
|
261
|
+
info.update({"model": "solidification shrink", **{k: v for k, v in p.items() if k != "tgSource"},
|
|
262
|
+
"tgSource": p["tgSource"]})
|
|
263
|
+
return out, info
|
|
264
|
+
if proc in ("sls", "mjf"):
|
|
265
|
+
ds = BUILD["sls"]["ds"] if setup.ds is None else float(setup.ds)
|
|
266
|
+
dsat = BUILD["sls"]["depth_sat"] if setup.depth_sat is None else float(setup.depth_sat)
|
|
267
|
+
d = np.zeros(n) if depth is None else np.asarray(depth, float)
|
|
268
|
+
ep = -ds * np.minimum(d / dsat, 1.0) * setup.scale
|
|
269
|
+
out = np.zeros((n, 6))
|
|
270
|
+
out[:, :3] = ep[:, None]
|
|
271
|
+
info.update({"model": "differential cooling shrink (heuristic)", "ds": ds, "depthSat": dsat})
|
|
272
|
+
return out, info
|
|
273
|
+
raise ValueError(f"build simulation: process must be lpbf, fdm or sls, got {setup.process!r}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _fdm_params(setup):
|
|
277
|
+
"""The FDM shrink model's numbers. 'tg' is the temperature the road
|
|
278
|
+
turns solid at: Tg for an amorphous polymer, the crystallization
|
|
279
|
+
temperature for a semi-crystalline one (the BUILD row's 'tc')."""
|
|
280
|
+
from .thermal import expansion
|
|
281
|
+
name = str(setup.material).lower()
|
|
282
|
+
row = BUILD["fdm"].get(name)
|
|
283
|
+
if row is not None and "tc" in row:
|
|
284
|
+
tg, src = row["tc"]
|
|
285
|
+
kind = "semi-crystalline: solid below crystallization"
|
|
286
|
+
else:
|
|
287
|
+
kind = "amorphous: solid below Tg"
|
|
288
|
+
tg = _lib(name, "tg")
|
|
289
|
+
src = "material library DSC"
|
|
290
|
+
if row is not None and tg is None:
|
|
291
|
+
tg, src = row["tg"]
|
|
292
|
+
if tg is None:
|
|
293
|
+
raise ValueError(f"fdm build simulation: no glass transition or crystallization temperature for {name!r}; "
|
|
294
|
+
"give BuildSetup.strain")
|
|
295
|
+
alpha = float(setup.alpha) if setup.alpha is not None else float(expansion(name))
|
|
296
|
+
bed = setup.bed if setup.bed is not None else (row or {}).get("bed", 60.0)
|
|
297
|
+
ch = setup.chamber if setup.chamber is not None else (row or {}).get("chamber", 30.0)
|
|
298
|
+
return {"tg": float(tg), "tgSource": src, "solidifies": kind, "alpha": alpha, "bed": float(bed),
|
|
299
|
+
"chamber": float(ch), "room": float(setup.room),
|
|
300
|
+
"relax": FDM_RELAX if setup.relax is None else float(setup.relax),
|
|
301
|
+
"decay": FDM_DECAY if setup.decay is None else float(setup.decay)}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _fdm_temperature(p, z):
|
|
305
|
+
"""Where each height sits during the print: bed temperature at the bed,
|
|
306
|
+
the chamber's far up, never above Tg (a layer below Tg is solid)."""
|
|
307
|
+
t = p["chamber"] + (p["bed"] - p["chamber"]) * np.exp(-np.maximum(z, 0.0) / p["decay"])
|
|
308
|
+
return np.minimum(t, p["tg"])
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ------------------------------------------------------------ element kernels
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _eig_unit(nu) -> np.ndarray:
|
|
315
|
+
"""(24, 6): integral of B^T C over the unit cube at E = 1. An element's
|
|
316
|
+
load from an inherent strain eps (6,) is E h^2 this @ eps."""
|
|
317
|
+
c = elastic_c(1.0, nu)
|
|
318
|
+
g = 1.0 / np.sqrt(3.0)
|
|
319
|
+
out = np.zeros((24, 6))
|
|
320
|
+
for xi in (-g, g):
|
|
321
|
+
for eta in (-g, g):
|
|
322
|
+
for zeta in (-g, g):
|
|
323
|
+
out += _b_matrix(xi, eta, zeta).T @ c * 0.125
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class _Kernel:
|
|
328
|
+
def __init__(self, mesh: HexMesh, nu, fixed, springs=None):
|
|
329
|
+
self.mesh = mesh
|
|
330
|
+
self.springs = springs if springs is not None and np.any(springs) else None
|
|
331
|
+
self.nu = nu
|
|
332
|
+
self.st = Stiffness(mesh, nu, fixed)
|
|
333
|
+
self.g = _eig_unit(nu) * mesh.pitch ** 2
|
|
334
|
+
self.bc = hex8_b_center() / mesh.pitch
|
|
335
|
+
self.c1 = elastic_c(1.0, nu)
|
|
336
|
+
|
|
337
|
+
def k(self, scale):
|
|
338
|
+
"""Stiffness at per-element moduli, plus the bed's springs."""
|
|
339
|
+
k = self.st.assemble(scale)
|
|
340
|
+
return k if self.springs is None else k + sp.diags(self.springs * ~self.st.fixed)
|
|
341
|
+
|
|
342
|
+
def load(self, e, eps, elems):
|
|
343
|
+
"""Global load of inherent strains `eps` (len(elems), 6) at moduli e."""
|
|
344
|
+
fe = (e[elems, None] * eps) @ self.g.T
|
|
345
|
+
return np.bincount(self.mesh.edof[elems].ravel(), weights=fe.ravel(), minlength=self.mesh.n_dof)
|
|
346
|
+
|
|
347
|
+
def internal(self, e, ue, eps, elems):
|
|
348
|
+
"""Global internal force of elements with displacement-since-
|
|
349
|
+
activation ue (len, 24) and inherent strain eps."""
|
|
350
|
+
fe = e[elems, None] * (ue @ self.st.ke.T - eps @ self.g.T)
|
|
351
|
+
return np.bincount(self.mesh.edof[elems].ravel(), weights=fe.ravel(), minlength=self.mesh.n_dof)
|
|
352
|
+
|
|
353
|
+
def stress(self, e, ue, eps):
|
|
354
|
+
return ((ue @ self.bc.T - eps) @ self.c1.T) * e[:, None]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
_DIRECT = 90_000 # dofs: a direct factorization up to here
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _solve(k, f, xyz, d=None, direct=_DIRECT, rtol=1e-7):
|
|
361
|
+
"""Sparse SPD solve: a direct factorization for small systems, CG on
|
|
362
|
+
smoothed-aggregation AMG (rigid-body near null space of the nodes
|
|
363
|
+
`xyz`, restricted to the dofs `d` the system keeps) beyond."""
|
|
364
|
+
from scipy.sparse.linalg import cg, splu
|
|
365
|
+
n = k.shape[0]
|
|
366
|
+
if n <= direct:
|
|
367
|
+
return splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A").solve(f)
|
|
368
|
+
b = rigid_body_modes(xyz)
|
|
369
|
+
if d is not None:
|
|
370
|
+
b = b[d]
|
|
371
|
+
ml = amg_solver(k.tocsr(), B=b, symmetry="hermitian", strength="symmetric", max_coarse=400,
|
|
372
|
+
coarse_solver="splu")
|
|
373
|
+
u, info = cg(k, f, rtol=rtol, maxiter=2000, M=ml.aspreconditioner(cycle="V"))
|
|
374
|
+
if info != 0:
|
|
375
|
+
raise RuntimeError("build simulation: linear solve did not converge (is every piece attached?)")
|
|
376
|
+
return u
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _dofs(nodes, fixed):
|
|
380
|
+
d = (3 * np.flatnonzero(nodes)[:, None] + np.arange(3)).ravel()
|
|
381
|
+
return d[~fixed[d]]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _three_two_one(mesh, nodes):
|
|
385
|
+
"""Statically determinate support of a free part on its own nodes:
|
|
386
|
+
one node held in x, y, z, a second (far along x) in y and z, a third
|
|
387
|
+
(far along y) in z. A self-equilibrated load leaves no reaction."""
|
|
388
|
+
idx = np.flatnonzero(nodes)
|
|
389
|
+
xyz = mesh.node_xyz[idx]
|
|
390
|
+
lo = xyz.min(axis=0)
|
|
391
|
+
a = idx[np.argmin(np.linalg.norm(xyz - lo, axis=1))]
|
|
392
|
+
pa = mesh.node_xyz[a]
|
|
393
|
+
b = idx[np.argmax(xyz[:, 0] - 1e-3 * np.abs(xyz[:, 1] - pa[1]) - 1e-3 * np.abs(xyz[:, 2] - pa[2]))]
|
|
394
|
+
pb = mesh.node_xyz[b]
|
|
395
|
+
ab = (pb - pa) / max(np.linalg.norm(pb - pa), 1e-12)
|
|
396
|
+
off = np.linalg.norm(np.cross(xyz - pa, ab), axis=1) - 1e-3 * np.abs(xyz[:, 2] - pa[2])
|
|
397
|
+
c = idx[np.argmax(off)]
|
|
398
|
+
fixed = np.zeros(mesh.n_dof, bool)
|
|
399
|
+
fixed[3 * a:3 * a + 3] = True
|
|
400
|
+
fixed[3 * b + 1:3 * b + 3] = True
|
|
401
|
+
fixed[3 * c + 2] = True
|
|
402
|
+
return fixed
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _in_plane_pins(mesh, nodes):
|
|
406
|
+
"""Pins for a part on an elastic bed: the springs carry z, so only the
|
|
407
|
+
in-plane drift is held, at the node nearest the footprint's centre
|
|
408
|
+
(x, y) and one far from it along x (y). Pinning z at corners, as a
|
|
409
|
+
3-2-1 does, would hold down the corners that lift."""
|
|
410
|
+
idx = np.flatnonzero(nodes)
|
|
411
|
+
xyz = mesh.node_xyz[idx]
|
|
412
|
+
a = idx[np.argmin(np.linalg.norm(xyz[:, :2] - xyz[:, :2].mean(axis=0), axis=1))]
|
|
413
|
+
pa = mesh.node_xyz[a]
|
|
414
|
+
b = idx[np.argmax(np.abs(xyz[:, 0] - pa[0]) - 1e-3 * np.abs(xyz[:, 1] - pa[1]))]
|
|
415
|
+
fixed = np.zeros(mesh.n_dof, bool)
|
|
416
|
+
fixed[3 * a:3 * a + 2] = True
|
|
417
|
+
fixed[3 * b + 1] = True
|
|
418
|
+
return fixed
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _remove_rigid(xyz, u):
|
|
422
|
+
"""u minus its least-squares rigid motion (a best-fit alignment)."""
|
|
423
|
+
b = rigid_body_modes(xyz)
|
|
424
|
+
coef, *_ = np.linalg.lstsq(b, u.ravel(), rcond=None)
|
|
425
|
+
return (u.ravel() - b @ coef).reshape(-1, 3)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------ the solve
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _grid_mesh(solid, pitch, origin, setup):
|
|
432
|
+
"""HexMesh of the part's cells plus (lpbf, plate > 0) a plate under its
|
|
433
|
+
footprint. Returns mesh, part mask, layer per element, fixed dofs and
|
|
434
|
+
each element's height above the plate."""
|
|
435
|
+
solid = np.asarray(solid, bool)
|
|
436
|
+
ijk = np.argwhere(solid)
|
|
437
|
+
if len(ijk) == 0:
|
|
438
|
+
raise ValueError("build simulation: the part is empty")
|
|
439
|
+
k0 = ijk[:, 2].min()
|
|
440
|
+
plate_cells = 0
|
|
441
|
+
if setup.process.lower() == "lpbf" and setup.plate > 0:
|
|
442
|
+
plate_cells = max(1, int(round(setup.plate / pitch)))
|
|
443
|
+
if setup.plate_cells is not None and setup.plate_fixed:
|
|
444
|
+
# the scaled modulus keeps a held plate's through-thickness
|
|
445
|
+
# compliance (E / h); a free plate bends (E h^3), so it stays whole
|
|
446
|
+
plate_cells = min(plate_cells, max(1, int(setup.plate_cells)))
|
|
447
|
+
margin = int(round(setup.plate_margin / pitch)) if plate_cells else 0
|
|
448
|
+
shift = np.array([margin, margin, plate_cells - k0])
|
|
449
|
+
part_ijk = ijk + shift
|
|
450
|
+
if plate_cells:
|
|
451
|
+
lo = ijk[:, :2].min(axis=0)
|
|
452
|
+
hi = ijk[:, :2].max(axis=0) + 2 * margin
|
|
453
|
+
pi, pj, pk = np.meshgrid(np.arange(lo[0], hi[0] + 1), np.arange(lo[1], hi[1] + 1), np.arange(plate_cells),
|
|
454
|
+
indexing="ij")
|
|
455
|
+
plate_ijk = np.stack([pi.ravel(), pj.ravel(), pk.ravel()], axis=1)
|
|
456
|
+
all_ijk = np.concatenate([plate_ijk, part_ijk])
|
|
457
|
+
part = np.concatenate([np.zeros(len(plate_ijk), bool), np.ones(len(part_ijk), bool)])
|
|
458
|
+
else:
|
|
459
|
+
all_ijk = part_ijk
|
|
460
|
+
part = np.ones(len(part_ijk), bool)
|
|
461
|
+
org = np.asarray(origin, float) - shift * pitch
|
|
462
|
+
mesh = HexMesh(all_ijk, pitch, org)
|
|
463
|
+
lump = max(1, int(round((setup.layer or pitch) / pitch)))
|
|
464
|
+
layer_of = np.where(part, (mesh.elem_ijk[:, 2] - plate_cells) // lump, -1)
|
|
465
|
+
# the plate (or, without one, the bed) is held at its underside
|
|
466
|
+
bottom = mesh.node_ijk[:, 2] == 0
|
|
467
|
+
fixed = np.repeat(bottom, 3)
|
|
468
|
+
if plate_cells and not setup.plate_fixed:
|
|
469
|
+
on = np.zeros(mesh.n_nodes, bool)
|
|
470
|
+
on[mesh.enode[~part].ravel()] = True
|
|
471
|
+
fixed = _three_two_one(mesh, on)
|
|
472
|
+
springs = np.zeros(mesh.n_dof)
|
|
473
|
+
kb = setup.bed_stiffness
|
|
474
|
+
if kb is None and setup.process.lower() == "fdm":
|
|
475
|
+
kb = FDM_BED
|
|
476
|
+
if not plate_cells and kb is not None and np.isfinite(kb):
|
|
477
|
+
# an elastic bed: springs on the underside's nodes (a quarter cell
|
|
478
|
+
# of area per touching cell), 3-2-1 against rigid motion
|
|
479
|
+
fixed = _in_plane_pins(mesh, bottom)
|
|
480
|
+
area = np.zeros(mesh.n_nodes)
|
|
481
|
+
under = mesh.elem_ijk[:, 2] == 0
|
|
482
|
+
touch = np.zeros(mesh.n_nodes)
|
|
483
|
+
for c in range(4):
|
|
484
|
+
np.add.at(area, mesh.enode[under, c], 0.25 * pitch ** 2)
|
|
485
|
+
np.add.at(touch, mesh.enode[under, c], 1.0)
|
|
486
|
+
if setup.brim:
|
|
487
|
+
# a brim: a bonded skirt brim mm wide around the footprint,
|
|
488
|
+
# counted as more bed under the edge nodes (an estimate: a thin
|
|
489
|
+
# skirt holds less than the bed under the part)
|
|
490
|
+
edge = (touch > 0) & (touch < 4)
|
|
491
|
+
area[edge] += float(setup.brim) * pitch * (4 - touch[edge]) / 2
|
|
492
|
+
springs = np.repeat(area * float(kb), 3)
|
|
493
|
+
z_above = (mesh.elem_ijk[:, 2] - plate_cells + 0.5) * pitch
|
|
494
|
+
return mesh, part, layer_of, fixed, z_above, plate_cells, springs
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def layer_extrapolation(t, setup) -> float:
|
|
498
|
+
"""k for extrapolating an FDM build at analysis layers t mm to the
|
|
499
|
+
printed layer: u = (1 + k) u(t) - k u(2t), k = log2(t / printed).
|
|
500
|
+
|
|
501
|
+
FDM warp grows as the analysis layers thin and does not settle: a
|
|
502
|
+
60 x 4 x 3 mm nylon bar on the bed lifts 0.30, 0.46, 0.62, 0.80 mm at
|
|
503
|
+
1, 0.5, 0.25, 0.125 mm layers, whatever the voxel size (0.125-0.5 mm
|
|
504
|
+
voxels agree within 1%), about +0.16 mm per halving. Each layer goes
|
|
505
|
+
down stress-free on a part already curled by the ones below, so thin
|
|
506
|
+
layers keep adding curl. The model was fitted at the printed layers
|
|
507
|
+
(Armillotta, 0.254 mm), so that is where it must be read: the gain per
|
|
508
|
+
halving between t and 2t, carried log-linearly down to the printed
|
|
509
|
+
layer, lands within 4% of the direct thin-layer runs (0.61 against
|
|
510
|
+
0.62, and 0.92 against 0.96 on a 5.5 mm bar)."""
|
|
511
|
+
if setup is None or setup.process.lower() != "fdm":
|
|
512
|
+
return 0.0
|
|
513
|
+
tp = FDM_PRINT_LAYER if setup.print_layer is None else float(setup.print_layer)
|
|
514
|
+
if tp <= 0 or t <= 1.05 * tp:
|
|
515
|
+
return 0.0
|
|
516
|
+
return float(np.log2(t / tp))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def simulate_build(solid, pitch, origin=(0.0, 0.0, 0.0), setup: BuildSetup | None = None, on_layer=None):
|
|
520
|
+
"""Simulate printing a voxel part (bool grid, build direction +z, the
|
|
521
|
+
lowest cells sit on the plate or bed) and releasing it.
|
|
522
|
+
|
|
523
|
+
solid: bool (nx, ny, nz); pitch mm; origin: world position of the grid's
|
|
524
|
+
min corner. Returns a BuildResult; its mesh is the part's cells (plus a
|
|
525
|
+
plate, lpbf), in the same world frame. FDM analysis layers thicker than
|
|
526
|
+
the printed layer are extrapolated to it (layer_extrapolation): the
|
|
527
|
+
build runs twice, at its layers and at twice them."""
|
|
528
|
+
setup = setup or BuildSetup()
|
|
529
|
+
t = float(setup.layer or pitch)
|
|
530
|
+
kx = layer_extrapolation(t, setup)
|
|
531
|
+
r1 = _simulate_build(solid, pitch, origin, setup, on_layer)
|
|
532
|
+
if not kx:
|
|
533
|
+
return r1
|
|
534
|
+
from dataclasses import replace
|
|
535
|
+
r2 = _simulate_build(solid, pitch, origin, replace(setup, layer=2 * t))
|
|
536
|
+
lin = (lambda a, b: (1.0 + kx) * a - kx * b)
|
|
537
|
+
res = replace(r1, u_built=lin(r1.u_built, r2.u_built), u=lin(r1.u, r2.u),
|
|
538
|
+
stress_built=lin(r1.stress_built, r2.stress_built), stress=lin(r1.stress, r2.stress))
|
|
539
|
+
rep = res.report
|
|
540
|
+
m, part = res.mesh, res.part
|
|
541
|
+
pn = np.flatnonzero(res.node_active)
|
|
542
|
+
dmag = np.linalg.norm(res.u[pn], axis=1)
|
|
543
|
+
rep["distortion"] = {"max": float(dmag.max()), "rms": float(np.sqrt((dmag ** 2).mean())),
|
|
544
|
+
"maxZ": float(np.abs(res.u[pn, 2]).max()),
|
|
545
|
+
"at": [float(c) for c in m.node_xyz[pn[np.argmax(dmag)]]]}
|
|
546
|
+
rep["builtDistortionMax"] = float(np.linalg.norm(res.u_built[m.enode[part].ravel()], axis=1).max())
|
|
547
|
+
keep = np.abs(res.stress).sum(axis=1) > 0
|
|
548
|
+
vm_b, vm = von_mises(res.stress_built[part]), von_mises(res.stress[keep & part] if (keep & part).any()
|
|
549
|
+
else res.stress[part])
|
|
550
|
+
rs = rep["residualStress"]
|
|
551
|
+
rs.update(vmMaxBuilt=float(vm_b.max()), vmP95Built=float(np.percentile(vm_b, 95)), vmMax=float(vm.max()),
|
|
552
|
+
vmP95=float(np.percentile(vm, 95)), overYieldShare=float((vm > rs["yield"]).mean()))
|
|
553
|
+
rep["layerExtrapolation"] = {"analysisLayer": t, "printedLayer": t / 2 ** kx, "k": kx,
|
|
554
|
+
"distortionMaxUnextrapolated": r1.report["distortion"]["max"],
|
|
555
|
+
"note": "linear in log(layer) from builds at the analysis layer and twice it"}
|
|
556
|
+
rep["warnings"] = _warnings(rep, setup)
|
|
557
|
+
return res
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _simulate_build(solid, pitch, origin=(0.0, 0.0, 0.0), setup: BuildSetup | None = None, on_layer=None):
|
|
561
|
+
"""Simulate printing a voxel part (bool grid, build direction +z, the
|
|
562
|
+
lowest cells sit on the plate or bed) and releasing it.
|
|
563
|
+
|
|
564
|
+
solid: bool (nx, ny, nz); pitch mm; origin: world position of the grid's
|
|
565
|
+
min corner. Returns a BuildResult; its mesh is the part's cells (plus a
|
|
566
|
+
plate, lpbf), in the same world frame."""
|
|
567
|
+
setup = setup or BuildSetup()
|
|
568
|
+
t0 = time.time()
|
|
569
|
+
proc = setup.process.lower()
|
|
570
|
+
mat = _material(setup.material)
|
|
571
|
+
e0 = float(setup.E or mat.E)
|
|
572
|
+
nu = float(setup.nu if setup.nu is not None else mat.nu)
|
|
573
|
+
solid, floating = _grounded(solid, proc)
|
|
574
|
+
mesh, part, layer_of, fixed, z_above, plate_cells, springs = _grid_mesh(solid, pitch, origin, setup)
|
|
575
|
+
ne = mesh.n_elem
|
|
576
|
+
kern = _Kernel(mesh, nu, fixed, springs)
|
|
577
|
+
e = np.full(ne, e0)
|
|
578
|
+
if plate_cells and plate_cells * pitch < setup.plate - 1e-9:
|
|
579
|
+
e[~part] *= plate_cells * pitch / setup.plate
|
|
580
|
+
depth = None
|
|
581
|
+
if proc in ("sls", "mjf"):
|
|
582
|
+
from scipy import ndimage
|
|
583
|
+
g = np.zeros(tuple(mesh.elem_ijk.max(axis=0) + 3), bool)
|
|
584
|
+
g[tuple((mesh.elem_ijk + 1).T)] = True
|
|
585
|
+
depth = ndimage.distance_transform_edt(g)[tuple((mesh.elem_ijk + 1).T)] * pitch - 0.5 * pitch
|
|
586
|
+
eps, info = inherent_strain(setup, z=z_above if proc == "fdm" else None, depth=depth)
|
|
587
|
+
eps = np.broadcast_to(eps, (ne, 6)).copy()
|
|
588
|
+
eps[~part] = 0.0
|
|
589
|
+
u = np.zeros(mesh.n_dof)
|
|
590
|
+
ue = np.zeros((ne, 24)) # each element's displacement since it was laid
|
|
591
|
+
lift = []
|
|
592
|
+
xyz = mesh.node_xyz
|
|
593
|
+
layered = proc in ("lpbf", "fdm")
|
|
594
|
+
if layered:
|
|
595
|
+
layer_of = _joined_layers(mesh, part, layer_of)
|
|
596
|
+
n_layers = int(layer_of.max()) + 1
|
|
597
|
+
active = ~part
|
|
598
|
+
node_on = np.zeros(mesh.n_nodes, bool)
|
|
599
|
+
if active.any():
|
|
600
|
+
node_on[mesh.enode[active].ravel()] = True
|
|
601
|
+
# Big parts solve on mg.py's matrix-free multigrid (cells not yet
|
|
602
|
+
# printed get modulus 0, which drops them; its setup per layer is a
|
|
603
|
+
# reshape, where pyamg's was 60% of the time on AM-Bench); small
|
|
604
|
+
# ones factor each layer exactly.
|
|
605
|
+
big = mesh.n_dof > _DIRECT
|
|
606
|
+
detached = 0
|
|
607
|
+
solver = None
|
|
608
|
+
if big:
|
|
609
|
+
from .mg import GridElasticity
|
|
610
|
+
solver = GridElasticity(mesh, nu, fixed, springs=sp.diags(springs) if np.any(springs) else None,
|
|
611
|
+
rtol=1e-7, maxiter=3000, device="cpu")
|
|
612
|
+
for li in range(n_layers):
|
|
613
|
+
new = part & (layer_of == li)
|
|
614
|
+
if not new.any():
|
|
615
|
+
continue
|
|
616
|
+
active = active | new
|
|
617
|
+
node_on[mesh.enode[new].ravel()] = True
|
|
618
|
+
f = kern.load(e, eps[new], np.flatnonzero(new))
|
|
619
|
+
du = np.zeros(mesh.n_dof)
|
|
620
|
+
if big:
|
|
621
|
+
f[fixed] = 0.0
|
|
622
|
+
solver.set_moduli(e * active)
|
|
623
|
+
du = solver.solve_many([f])[0]
|
|
624
|
+
du[~np.repeat(node_on, 3)] = 0.0 # unprinted nodes go down at nominal
|
|
625
|
+
else:
|
|
626
|
+
k = kern.k(e * active)
|
|
627
|
+
d = _dofs(node_on, fixed)
|
|
628
|
+
du[d] = _solve(k[d][:, d], f[d], xyz, d)
|
|
629
|
+
u += du
|
|
630
|
+
act = np.flatnonzero(active)
|
|
631
|
+
ue[act] += du[mesh.edof[act]]
|
|
632
|
+
lost = _detach(kern, setup, u, ue, act, lambda g: _resolve(kern, e * active, node_on, fixed, xyz, g))
|
|
633
|
+
if lost and big:
|
|
634
|
+
solver = GridElasticity(mesh, nu, fixed, springs=sp.diags(kern.springs), rtol=1e-7, maxiter=3000,
|
|
635
|
+
device="cpu")
|
|
636
|
+
detached += lost
|
|
637
|
+
# lift at the build plane (the top of the cells just laid)
|
|
638
|
+
top_z = mesh.elem_ijk[new, 2].max() + 1
|
|
639
|
+
top = node_on & (mesh.node_ijk[:, 2] == top_z)
|
|
640
|
+
lz = u[3 * np.flatnonzero(top) + 2]
|
|
641
|
+
lift.append(float(lz.max()) if len(lz) else 0.0)
|
|
642
|
+
if on_layer is not None:
|
|
643
|
+
on_layer(li, n_layers)
|
|
644
|
+
else:
|
|
645
|
+
# sls/mjf: the part cools as a whole, free in the powder
|
|
646
|
+
node_on = np.zeros(mesh.n_nodes, bool)
|
|
647
|
+
node_on[mesh.enode.ravel()] = True
|
|
648
|
+
n_layers = 0
|
|
649
|
+
report = {"process": proc, "material": setup.material, "strain": info, "pitch": float(pitch),
|
|
650
|
+
"cells": int(part.sum()), "analysisLayers": n_layers,
|
|
651
|
+
"layerMm": float((setup.layer or pitch)) if layered else None}
|
|
652
|
+
|
|
653
|
+
if proc == "fdm":
|
|
654
|
+
# stage two: the finished part cools on the bed to room temperature
|
|
655
|
+
p = _fdm_params(setup)
|
|
656
|
+
tz = _fdm_temperature(p, z_above)
|
|
657
|
+
eps2 = np.zeros((ne, 6))
|
|
658
|
+
eps2[:, :3] = (-p["alpha"] * (tz - p["room"]) * setup.scale)[:, None]
|
|
659
|
+
report["peel"] = _bed_peel(mesh, kern, e, ue, eps, part, fixed, u)
|
|
660
|
+
k = kern.k(e)
|
|
661
|
+
d = _dofs(node_on, fixed)
|
|
662
|
+
f = kern.load(e, eps2, np.arange(ne))
|
|
663
|
+
du = np.zeros(mesh.n_dof)
|
|
664
|
+
du[d] = _solve(k[d][:, d], f[d], xyz, d)
|
|
665
|
+
u += du
|
|
666
|
+
ue += du[mesh.edof]
|
|
667
|
+
detached += _detach(kern, setup, u, ue, np.arange(ne), lambda g: _resolve(kern, e, node_on, fixed, xyz, g))
|
|
668
|
+
eps = eps + eps2
|
|
669
|
+
report["detachedArea"] = float(detached)
|
|
670
|
+
report["bedFailed"] = bool(getattr(kern, "bed_failed", False))
|
|
671
|
+
report["peelCooled"] = _bed_peel(mesh, kern, e, ue, eps, part, fixed, u)
|
|
672
|
+
|
|
673
|
+
u_built = u.reshape(-1, 3).copy()
|
|
674
|
+
stress_built = kern.stress(e, ue, eps)
|
|
675
|
+
if proc in ("sls", "mjf"):
|
|
676
|
+
release = "full"
|
|
677
|
+
else:
|
|
678
|
+
release = setup.release
|
|
679
|
+
keep = part.copy() if release in ("full", "none") else part & ~_in_box(mesh.elem_centers(), setup.cut)
|
|
680
|
+
if release == "none":
|
|
681
|
+
u_rel, stress = u_built.copy(), stress_built.copy()
|
|
682
|
+
node_act = node_on.copy()
|
|
683
|
+
else:
|
|
684
|
+
if release == "cut" and setup.cut is None:
|
|
685
|
+
raise ValueError("release='cut' needs BuildSetup.cut, the box of cells the cut removes")
|
|
686
|
+
if release == "cut" and plate_cells:
|
|
687
|
+
keep = keep | ~part # the plate stays, the cut slab goes
|
|
688
|
+
node_act = np.zeros(mesh.n_nodes, bool)
|
|
689
|
+
node_act[mesh.enode[keep].ravel()] = True
|
|
690
|
+
comps = _components(mesh, keep & part) if release == "full" else []
|
|
691
|
+
if release == "full":
|
|
692
|
+
# each piece the release frees gets its own 3-2-1
|
|
693
|
+
fx = np.zeros(mesh.n_dof, bool)
|
|
694
|
+
for c in comps:
|
|
695
|
+
fx |= _three_two_one(mesh, c)
|
|
696
|
+
else:
|
|
697
|
+
fx = fixed
|
|
698
|
+
kel = np.flatnonzero(keep)
|
|
699
|
+
r = kern.internal(e, ue[kel], eps[kel], kel)
|
|
700
|
+
# assembled without Dirichlet rows: the 3-2-1 set is not `fixed`,
|
|
701
|
+
# and the submatrix over the free dofs is all the solve needs
|
|
702
|
+
k = _assemble_free(kern, e * keep)
|
|
703
|
+
d = _dofs(node_act, fx)
|
|
704
|
+
du = np.zeros(mesh.n_dof)
|
|
705
|
+
du[d] = _solve(k[d][:, d], -r[d], xyz, d)
|
|
706
|
+
u_tot = u + du
|
|
707
|
+
ue[kel] += du[mesh.edof[kel]]
|
|
708
|
+
stress = kern.stress(e, ue, eps)
|
|
709
|
+
stress[~keep] = 0.0
|
|
710
|
+
u_rel = u_tot.reshape(-1, 3).copy()
|
|
711
|
+
u_rel[~node_act] = 0.0
|
|
712
|
+
for c in comps:
|
|
713
|
+
ids = np.flatnonzero(c)
|
|
714
|
+
u_rel[ids] = _remove_rigid(xyz[ids], u_rel[ids])
|
|
715
|
+
vm_b = von_mises(stress_built[part])
|
|
716
|
+
vm = von_mises(stress[keep])
|
|
717
|
+
sy = float(setup.yield_strength or mat.strength)
|
|
718
|
+
pn = np.flatnonzero(node_act)
|
|
719
|
+
dmag = np.linalg.norm(u_rel[pn], axis=1)
|
|
720
|
+
report.update({
|
|
721
|
+
"release": release,
|
|
722
|
+
"floatingCells": floating,
|
|
723
|
+
"distortion": {"max": float(dmag.max()), "rms": float(np.sqrt((dmag ** 2).mean())),
|
|
724
|
+
"maxZ": float(np.abs(u_rel[pn, 2]).max()),
|
|
725
|
+
"at": [float(c) for c in xyz[pn[np.argmax(dmag)]]]},
|
|
726
|
+
"builtDistortionMax": float(np.linalg.norm(u_built[mesh.enode[part].ravel()], axis=1).max()),
|
|
727
|
+
"residualStress": {"vmMaxBuilt": float(vm_b.max()), "vmP95Built": float(np.percentile(vm_b, 95)),
|
|
728
|
+
"vmMax": float(vm.max()), "vmP95": float(np.percentile(vm, 95)),
|
|
729
|
+
"yield": sy, "overYieldShare": float((vm > sy).mean())},
|
|
730
|
+
"seconds": round(time.time() - t0, 2),
|
|
731
|
+
})
|
|
732
|
+
if proc == "lpbf" and lift:
|
|
733
|
+
report["recoater"] = {"maxLift": float(max(lift)), "gap": setup.recoater_gap,
|
|
734
|
+
"layer": int(np.argmax(lift)), "risk": bool(max(lift) > setup.recoater_gap)}
|
|
735
|
+
report["warnings"] = _warnings(report, setup)
|
|
736
|
+
res = BuildResult(mesh=mesh, part=part, layer_of=layer_of, u_built=u_built, u=u_rel,
|
|
737
|
+
stress_built=stress_built, stress=stress, strain=eps, report=report, node_active=node_act)
|
|
738
|
+
return res
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _joined_layers(mesh, part, layer_of):
|
|
742
|
+
"""The analysis layer at which each part cell joins the plate or bed
|
|
743
|
+
through faces. A cell laid in mid-air (the low tip of a drooping,
|
|
744
|
+
pre-deformed arm; anything printed on supports) has nothing to hold it
|
|
745
|
+
in the solve until the layer that bonds it to the part: it goes in then,
|
|
746
|
+
stress-free in its place, as if its supports had held it."""
|
|
747
|
+
from scipy import ndimage
|
|
748
|
+
ijk = mesh.elem_ijk
|
|
749
|
+
g = np.full(tuple(ijk.max(axis=0) + 1), np.iinfo(np.int64).max, np.int64)
|
|
750
|
+
g[tuple(ijk.T)] = layer_of # the plate is -1
|
|
751
|
+
out = layer_of.copy()
|
|
752
|
+
pending = part.copy()
|
|
753
|
+
for li in range(int(layer_of.max()) + 1):
|
|
754
|
+
lbl, _ = ndimage.label(g <= li)
|
|
755
|
+
roots = np.unique(lbl[:, :, 0])
|
|
756
|
+
on = np.isin(lbl[tuple(ijk.T)], roots[roots > 0])
|
|
757
|
+
out[pending & on] = li
|
|
758
|
+
pending &= ~on
|
|
759
|
+
if not pending.any():
|
|
760
|
+
break
|
|
761
|
+
return out
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _grounded(solid, proc):
|
|
765
|
+
"""The part's cells that reach the plate or bed through faces (lpbf,
|
|
766
|
+
fdm), and how many were dropped: a piece printed in mid-air has
|
|
767
|
+
nothing to hold it, in the machine or in the solve. Powder-bed polymer
|
|
768
|
+
keeps every piece (the cake holds them)."""
|
|
769
|
+
from scipy import ndimage
|
|
770
|
+
solid = np.asarray(solid, bool)
|
|
771
|
+
if proc in ("sls", "mjf") or not solid.any():
|
|
772
|
+
return solid, 0
|
|
773
|
+
lbl, _ = ndimage.label(solid)
|
|
774
|
+
k0 = np.argwhere(solid)[:, 2].min()
|
|
775
|
+
keep = np.isin(lbl, np.unique(lbl[:, :, k0][solid[:, :, k0]]))
|
|
776
|
+
return solid & keep, int((solid & ~keep).sum())
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _components(mesh, cells):
|
|
780
|
+
"""Node masks of the face-connected pieces of `cells` (bool per element)."""
|
|
781
|
+
from scipy import ndimage
|
|
782
|
+
g = np.zeros(tuple(mesh.elem_ijk.max(axis=0) + 1), int)
|
|
783
|
+
idx = np.flatnonzero(cells)
|
|
784
|
+
g[tuple(mesh.elem_ijk[idx].T)] = 1
|
|
785
|
+
lbl, n = ndimage.label(g)
|
|
786
|
+
el = lbl[tuple(mesh.elem_ijk[idx].T)]
|
|
787
|
+
out = []
|
|
788
|
+
for i in range(1, n + 1):
|
|
789
|
+
on = np.zeros(mesh.n_nodes, bool)
|
|
790
|
+
on[mesh.enode[idx[el == i]].ravel()] = True
|
|
791
|
+
out.append(on)
|
|
792
|
+
return out
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _detach(kern, setup, u, ue, act, solve):
|
|
796
|
+
"""FDM bed bond failure: every bonded bed node whose bond pulls harder
|
|
797
|
+
than the adhesion (tension kb * u_z, MPa) lets go, its springs removed,
|
|
798
|
+
and the part re-balances; repeat until none fails. `solve(load)` solves
|
|
799
|
+
the current stiffness (it sees the springs as they are now). Updates u
|
|
800
|
+
and ue in place; returns the bed area let go (mm^2)."""
|
|
801
|
+
if kern.springs is None or setup.process.lower() != "fdm":
|
|
802
|
+
return 0
|
|
803
|
+
adh = FDM_ADHESION if setup.bed_adhesion is None else float(setup.bed_adhesion)
|
|
804
|
+
if not np.isfinite(adh):
|
|
805
|
+
return 0
|
|
806
|
+
kb = float(setup.bed_stiffness if setup.bed_stiffness is not None else FDM_BED)
|
|
807
|
+
mesh = kern.mesh
|
|
808
|
+
bed = np.flatnonzero(mesh.node_ijk[:, 2] == 0)
|
|
809
|
+
lost = 0.0
|
|
810
|
+
for _ in range(60):
|
|
811
|
+
bonded = kern.springs[3 * bed + 2] > 0
|
|
812
|
+
fail = bed[bonded & (kb * u[3 * bed + 2] > adh)]
|
|
813
|
+
if len(fail) == 0:
|
|
814
|
+
break
|
|
815
|
+
if bonded.sum() - len(fail) < max(4, 0.02 * len(bed)):
|
|
816
|
+
# the whole footprint lets go: the part pops off the bed (a
|
|
817
|
+
# failed print); keep the last bonds so the solve stays posed
|
|
818
|
+
kern.bed_failed = True
|
|
819
|
+
break
|
|
820
|
+
lost += float(kern.springs[3 * fail + 2].sum()) / kb # their bed area, mm^2
|
|
821
|
+
dof = (3 * fail[:, None] + np.arange(3)).ravel()
|
|
822
|
+
load = np.zeros(mesh.n_dof)
|
|
823
|
+
load[dof] = kern.springs[dof] * u[dof] # what the bond held, now let go
|
|
824
|
+
kern.springs[dof] = 0.0
|
|
825
|
+
du = solve(load)
|
|
826
|
+
u += du
|
|
827
|
+
ue[act] += du[mesh.edof[act]]
|
|
828
|
+
return lost
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _resolve(kern, scale, node_on, fixed, xyz, load):
|
|
832
|
+
"""One solve of the current stiffness (springs as they stand) on the
|
|
833
|
+
printed nodes: direct when small, multigrid when big."""
|
|
834
|
+
mesh = kern.mesh
|
|
835
|
+
du = np.zeros(mesh.n_dof)
|
|
836
|
+
if mesh.n_dof > _DIRECT:
|
|
837
|
+
from .mg import GridElasticity
|
|
838
|
+
sv = GridElasticity(mesh, kern.nu, fixed, springs=sp.diags(kern.springs) if kern.springs is not None else None,
|
|
839
|
+
rtol=1e-7, maxiter=3000, device="cpu")
|
|
840
|
+
sv.set_moduli(scale)
|
|
841
|
+
g = load.copy()
|
|
842
|
+
g[fixed] = 0.0
|
|
843
|
+
du = sv.solve_many([g])[0]
|
|
844
|
+
du[~np.repeat(node_on, 3)] = 0.0
|
|
845
|
+
return du
|
|
846
|
+
k = kern.k(scale)
|
|
847
|
+
d = _dofs(node_on, fixed)
|
|
848
|
+
du[d] = _solve(k[d][:, d], load[d], xyz, d)
|
|
849
|
+
return du
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _assemble_free(kern, scale):
|
|
853
|
+
"""Stiffness without Dirichlet rows (a free part's)."""
|
|
854
|
+
st = kern.st
|
|
855
|
+
data = np.zeros(st.nnz)
|
|
856
|
+
s = np.asarray(scale, float)[:, None]
|
|
857
|
+
for slots, blk in st._pairs:
|
|
858
|
+
data[slots] += s * blk
|
|
859
|
+
return sp.csr_matrix((data, st.indices, st.indptr), shape=(kern.mesh.n_dof, kern.mesh.n_dof))
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _in_box(pts, box):
|
|
863
|
+
if box is None:
|
|
864
|
+
return np.zeros(len(pts), bool)
|
|
865
|
+
lo, hi = (np.asarray(b, float) for b in box)
|
|
866
|
+
return np.all((pts >= lo) & (pts <= hi), axis=1)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _bed_peel(mesh, kern, e, ue, eps, part, fixed, u_now=None):
|
|
870
|
+
"""FDM: the bed's pull on the part (MPa, positive = the part trying to
|
|
871
|
+
lift off) per bed node, from the reactions of the fixed underside."""
|
|
872
|
+
el = np.flatnonzero(part)
|
|
873
|
+
r = kern.internal(e, ue[el], eps[el], el)
|
|
874
|
+
bed = np.flatnonzero(mesh.node_ijk[:, 2] == 0)
|
|
875
|
+
fz = r[3 * bed + 2] # internal force; the bed's reaction is -fz
|
|
876
|
+
if kern.springs is not None:
|
|
877
|
+
# an elastic bed: its pull is the spring's stretch (the 3-2-1
|
|
878
|
+
# nodes that hold the rigid motion carry no bed)
|
|
879
|
+
fz = -kern.springs[3 * bed + 2] * u_now[3 * bed + 2]
|
|
880
|
+
# tributary area: a quarter cell per touching cell
|
|
881
|
+
touch = np.zeros(mesh.n_nodes)
|
|
882
|
+
bottom = el[mesh.elem_ijk[el, 2] == 0]
|
|
883
|
+
for c in range(4): # the four bottom corners
|
|
884
|
+
np.add.at(touch, mesh.enode[bottom, c], 0.25)
|
|
885
|
+
area = np.maximum(touch[bed], 1e-9) * mesh.pitch ** 2
|
|
886
|
+
peel = -fz / area # reaction pulling down (on a part that wants up) -> positive
|
|
887
|
+
pos = peel > 0
|
|
888
|
+
lift = float((peel[pos] * area[pos]).sum())
|
|
889
|
+
# the max sits at an edge, where a bonded joint's peel stress is
|
|
890
|
+
# singular (it grows as the voxels shrink); the mean over the area in
|
|
891
|
+
# tension is the steadier number
|
|
892
|
+
return {"maxPeelStress": float(peel.max()), "meanPeelStress": lift / max(float(area[pos].sum()), 1e-12),
|
|
893
|
+
"peelArea": float(area[pos].sum()),
|
|
894
|
+
"at": [float(c) for c in mesh.node_xyz[bed[np.argmax(peel)]]],
|
|
895
|
+
"netLiftForce": lift}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _warnings(rep, setup):
|
|
899
|
+
w = []
|
|
900
|
+
d = rep["distortion"]
|
|
901
|
+
if rep.get("recoater", {}).get("risk"):
|
|
902
|
+
w.append(f"build: the build plane lifts {rep['recoater']['maxLift']:.3f} mm "
|
|
903
|
+
f"(> {setup.recoater_gap} mm) at analysis layer {rep['recoater']['layer']}: recoater contact "
|
|
904
|
+
"risk; support the overhang or change the orientation")
|
|
905
|
+
rs = rep["residualStress"]
|
|
906
|
+
if rs["vmMaxBuilt"] > rs["yield"]:
|
|
907
|
+
w.append(f"build: residual stress on the plate reaches {rs['vmMaxBuilt']:.0f} MPa "
|
|
908
|
+
f"(yield {rs['yield']:.0f}); expect local yielding and cracking risk at sharp corners "
|
|
909
|
+
"(a linear model overstates the peak where it passes yield)")
|
|
910
|
+
if rep["process"] == "fdm":
|
|
911
|
+
pk = rep.get("peel", {}).get("meanPeelStress", 0.0)
|
|
912
|
+
if pk > 0.5:
|
|
913
|
+
w.append(f"build: the part pulls on the bed at {pk:.2f} MPa during the print: corners may lift "
|
|
914
|
+
"(PEI/glue hold roughly 0.3-1 MPa); add brim/mouse ears or a hotter chamber")
|
|
915
|
+
if rep.get("floatingCells"):
|
|
916
|
+
w.append(f"build: {rep['floatingCells']} cells are not connected to the plate or bed and were left out")
|
|
917
|
+
if d["max"] > 0.2:
|
|
918
|
+
w.append(f"build: distortion after release up to {d['max']:.2f} mm; consider compensate()")
|
|
919
|
+
return w
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
# --------------------------------------------------------- mesh entry points
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def to_build_frame(points, build):
|
|
926
|
+
"""Rotation (3x3) taking a build direction ('+z', (axis, sign) or a
|
|
927
|
+
vector) to +z, and the points rotated by it."""
|
|
928
|
+
from .am import build_vector, parse_build_direction
|
|
929
|
+
v = build_vector(parse_build_direction(build) if isinstance(build, str) else build)
|
|
930
|
+
z = np.array([0.0, 0.0, 1.0])
|
|
931
|
+
c = float(v @ z)
|
|
932
|
+
if c > 1 - 1e-12:
|
|
933
|
+
r = np.eye(3)
|
|
934
|
+
elif c < -1 + 1e-12:
|
|
935
|
+
r = np.diag([1.0, -1.0, -1.0])
|
|
936
|
+
else:
|
|
937
|
+
ax = np.cross(v, z)
|
|
938
|
+
s = np.linalg.norm(ax)
|
|
939
|
+
ax /= s
|
|
940
|
+
kx = np.array([[0, -ax[2], ax[1]], [ax[2], 0, -ax[0]], [-ax[1], ax[0], 0]])
|
|
941
|
+
r = np.eye(3) + s * kx + (1 - c) * kx @ kx
|
|
942
|
+
pts = None if points is None else np.asarray(points, float) @ r.T
|
|
943
|
+
return r, pts
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def simulate_part(mesh, setup: BuildSetup | None = None, pitch=None, build="+z", cells=60_000):
|
|
947
|
+
"""Simulate a triangle mesh (trimesh) printed along `build`. Voxelizes
|
|
948
|
+
at `pitch` (default: about `cells` cells in the part). The BuildResult
|
|
949
|
+
is in the build frame; result.report["rotation"] is the world->build
|
|
950
|
+
rotation."""
|
|
951
|
+
import trimesh
|
|
952
|
+
setup = setup or BuildSetup()
|
|
953
|
+
from .voxels import mesh_solid
|
|
954
|
+
r, v = to_build_frame(mesh.vertices, build)
|
|
955
|
+
m = trimesh.Trimesh(v, mesh.faces, process=False)
|
|
956
|
+
if pitch is None:
|
|
957
|
+
pitch = float((abs(m.volume) / cells) ** (1 / 3))
|
|
958
|
+
solid, origin, _ = mesh_solid(m, pitch, pad=1)
|
|
959
|
+
res = simulate_build(solid, pitch, origin, setup)
|
|
960
|
+
res.report["rotation"] = r.tolist()
|
|
961
|
+
return res
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
class _NodeField:
|
|
965
|
+
"""Trilinear sampler of a nodal field on the voxel grid; nodes outside
|
|
966
|
+
the part (or not in `active`) are filled from their nearest part node."""
|
|
967
|
+
|
|
968
|
+
def __init__(self, mesh: HexMesh, u, active=None):
|
|
969
|
+
from scipy import ndimage
|
|
970
|
+
dims = tuple(mesh.node_dims)
|
|
971
|
+
on = np.zeros(dims, bool)
|
|
972
|
+
ok = np.ones(mesh.n_nodes, bool) if active is None else np.asarray(active, bool)
|
|
973
|
+
ijk = mesh.node_ijk[ok]
|
|
974
|
+
on[tuple(ijk.T)] = True
|
|
975
|
+
grid = np.zeros(dims + (3,))
|
|
976
|
+
grid[tuple(ijk.T)] = np.asarray(u)[ok]
|
|
977
|
+
_, ind = ndimage.distance_transform_edt(~on, return_indices=True)
|
|
978
|
+
self.grid = grid[tuple(ind)]
|
|
979
|
+
self.origin = mesh.origin
|
|
980
|
+
self.pitch = mesh.pitch
|
|
981
|
+
|
|
982
|
+
def __call__(self, pts):
|
|
983
|
+
from scipy.ndimage import map_coordinates
|
|
984
|
+
q = (np.asarray(pts, float) - self.origin) / self.pitch
|
|
985
|
+
return np.stack([map_coordinates(self.grid[..., c], q.T, order=1, mode="nearest") for c in range(3)],
|
|
986
|
+
axis=1)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# --------------------------------------------------------- compensation
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def compensate(points, sample, factor=1.0):
|
|
993
|
+
"""Pre-distort `points` against a displacement sampler: the inverse
|
|
994
|
+
of x -> x + d(x), by fixed point (y = x - factor * d(y), a few rounds),
|
|
995
|
+
so that printing y lands on x."""
|
|
996
|
+
x = np.asarray(points, float)
|
|
997
|
+
y = x.copy()
|
|
998
|
+
for _ in range(4):
|
|
999
|
+
y = x - factor * sample(y)
|
|
1000
|
+
return y
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def refine_mesh(mesh, size):
|
|
1004
|
+
"""The mesh with every edge longer than `size` mm split, so a
|
|
1005
|
+
displacement sampled at its vertices can bend it (a CAD export's long
|
|
1006
|
+
triangles move only at their corners)."""
|
|
1007
|
+
import trimesh
|
|
1008
|
+
v, f = trimesh.remesh.subdivide_to_size(mesh.vertices, mesh.faces, max_edge=float(size), max_iter=20)
|
|
1009
|
+
return trimesh.Trimesh(v, f, process=True)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def printed_shape(mesh, res: BuildResult, build="+z"):
|
|
1013
|
+
"""The mesh as printed: every vertex moved by the simulated
|
|
1014
|
+
displacement (released), in the mesh's own frame."""
|
|
1015
|
+
import trimesh
|
|
1016
|
+
r = np.asarray(res.report.get("rotation", np.eye(3)), float)
|
|
1017
|
+
v = np.asarray(mesh.vertices, float) @ r.T
|
|
1018
|
+
return trimesh.Trimesh((v + res.displacement_at(v)) @ r, mesh.faces, process=False)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def deviation(printed, nominal):
|
|
1022
|
+
"""How far a printed shape is from the design, as a scan comparison
|
|
1023
|
+
reads it: `printed` best-fit onto `nominal` (rigid ICP on the
|
|
1024
|
+
surface), then the distance between the two surfaces both ways.
|
|
1025
|
+
Returns ({"max", "rms", "p95"} mm, the aligned printed mesh)."""
|
|
1026
|
+
import trimesh
|
|
1027
|
+
m, _, _ = trimesh.registration.icp(np.asarray(printed.vertices), nominal, max_iterations=40, scale=False)
|
|
1028
|
+
aligned = printed.copy()
|
|
1029
|
+
aligned.apply_transform(m)
|
|
1030
|
+
_, d1, _ = trimesh.proximity.closest_point(nominal, aligned.vertices)
|
|
1031
|
+
_, d2, _ = trimesh.proximity.closest_point(aligned, nominal.vertices)
|
|
1032
|
+
d = np.concatenate([d1, d2])
|
|
1033
|
+
return {"max": float(d.max()), "rms": float(np.sqrt((d ** 2).mean())),
|
|
1034
|
+
"p95": float(np.percentile(d, 95))}, aligned
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def compensate_mesh(mesh, setup: BuildSetup, pitch=None, build="+z", iterations=2, cells=60_000, factor=1.0,
|
|
1038
|
+
refine=True, first: BuildResult | None = None):
|
|
1039
|
+
"""Iterative pre-distortion of a triangle mesh: simulate, move every
|
|
1040
|
+
vertex against its predicted displacement, simulate the compensated
|
|
1041
|
+
shape, correct by what is left, and so on. Returns (compensated
|
|
1042
|
+
trimesh, report with the residual distortion after each round; round 0
|
|
1043
|
+
is the uncompensated part). `refine` first splits edges longer than
|
|
1044
|
+
the voxel pitch, so long flat triangles can take the shape's curl.
|
|
1045
|
+
`first`: simulate_part's result for this mesh at this pitch and build,
|
|
1046
|
+
if already run (round 0 reuses it)."""
|
|
1047
|
+
import trimesh
|
|
1048
|
+
if pitch is None:
|
|
1049
|
+
pitch = float((abs(mesh.volume) / cells) ** (1 / 3))
|
|
1050
|
+
if refine:
|
|
1051
|
+
mesh = refine_mesh(mesh, pitch)
|
|
1052
|
+
r, v_nom = to_build_frame(mesh.vertices, build)
|
|
1053
|
+
nominal = trimesh.Trimesh(v_nom, mesh.faces, process=False)
|
|
1054
|
+
from .voxels import mesh_solid
|
|
1055
|
+
cur = v_nom.copy()
|
|
1056
|
+
rounds = []
|
|
1057
|
+
for it in range(iterations + 1):
|
|
1058
|
+
if it == 0 and first is not None:
|
|
1059
|
+
res = first
|
|
1060
|
+
else:
|
|
1061
|
+
m = trimesh.Trimesh(cur, mesh.faces, process=False)
|
|
1062
|
+
solid, origin, _ = mesh_solid(m, pitch, pad=1)
|
|
1063
|
+
res = simulate_build(solid, pitch, origin, setup)
|
|
1064
|
+
printed = cur + res.displacement_at(cur)
|
|
1065
|
+
if res.report["release"] == "full":
|
|
1066
|
+
printed = _align(printed, v_nom)
|
|
1067
|
+
err = np.linalg.norm(printed - v_nom, axis=1)
|
|
1068
|
+
rounds.append({"round": it, "maxDeviation": float(err.max()), "rmsDeviation": float(np.sqrt((err ** 2).mean())),
|
|
1069
|
+
"simDistortionMax": res.report["distortion"]["max"]})
|
|
1070
|
+
if it == iterations:
|
|
1071
|
+
break
|
|
1072
|
+
cur = cur - factor * (printed - v_nom)
|
|
1073
|
+
out = trimesh.Trimesh(cur @ r, mesh.faces, process=False) # back to the world frame
|
|
1074
|
+
return out, {"rounds": rounds, "pitch": pitch, "process": setup.process, "material": setup.material}
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _align(p, q):
|
|
1078
|
+
"""p rigidly moved onto q (Kabsch, least squares)."""
|
|
1079
|
+
pc, qc = p.mean(axis=0), q.mean(axis=0)
|
|
1080
|
+
h = (p - pc).T @ (q - qc)
|
|
1081
|
+
uu, _, vt = np.linalg.svd(h)
|
|
1082
|
+
d = np.sign(np.linalg.det(vt.T @ uu.T))
|
|
1083
|
+
rot = vt.T @ np.diag([1, 1, d]) @ uu.T
|
|
1084
|
+
return (p - pc) @ rot.T + qc
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
# --------------------------------------------------------- analytic check
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def progressive_beam(e, h_sub, layers, eps):
|
|
1091
|
+
"""Curvature of a free beam (unit width, modulus e, uniaxial) after
|
|
1092
|
+
depositing `layers` (thicknesses) on a substrate of thickness h_sub,
|
|
1093
|
+
each layer stress-free when laid and then given misfit strain `eps`
|
|
1094
|
+
(Tsui & Clyne 1997, progressive deposition, one material). Returns
|
|
1095
|
+
(curvature 1/mm, positive = concave up, per-layer stress list)."""
|
|
1096
|
+
zs = [0.0, h_sub]
|
|
1097
|
+
kappa = 0.0
|
|
1098
|
+
for t in layers:
|
|
1099
|
+
z0, z1 = zs[-1], zs[-1] + t
|
|
1100
|
+
a = z1 - 0.0 # current composite spans [0, z1]
|
|
1101
|
+
sz = 0.5 * z1 ** 2
|
|
1102
|
+
szz = z1 ** 3 / 3.0
|
|
1103
|
+
# new layer's misfit force and moment
|
|
1104
|
+
fn = e * eps * t
|
|
1105
|
+
mn = e * eps * 0.5 * (z1 ** 2 - z0 ** 2)
|
|
1106
|
+
m = np.array([[a, sz], [sz, szz]]) * e
|
|
1107
|
+
d0, dk = np.linalg.solve(m, [fn, mn])
|
|
1108
|
+
kappa += dk
|
|
1109
|
+
zs.append(z1)
|
|
1110
|
+
return kappa
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
# --------------------------------------------------------- optimization
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
@dataclass
|
|
1117
|
+
class WarpOptions:
|
|
1118
|
+
volume_fraction: float
|
|
1119
|
+
filter_radius: float
|
|
1120
|
+
process: BuildSetup = field(default_factory=BuildSetup)
|
|
1121
|
+
warp_weight: float = 1.0 # weight of normalized distortion against normalized compliance
|
|
1122
|
+
layer: float | None = None # mm per analysis layer (default 4 cells)
|
|
1123
|
+
max_iterations: int = 80
|
|
1124
|
+
beta_max: float = 16.0
|
|
1125
|
+
beta_every: int = 12
|
|
1126
|
+
eta: float = 0.5
|
|
1127
|
+
erode: float = 0.15
|
|
1128
|
+
move: float = 0.1
|
|
1129
|
+
tol: float = 0.01
|
|
1130
|
+
x0: np.ndarray | None = None
|
|
1131
|
+
extrusion: int | None = None
|
|
1132
|
+
target: np.ndarray | None = None # bool per node whose distortion counts (default every node)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@dataclass
|
|
1136
|
+
class WarpResult:
|
|
1137
|
+
x: np.ndarray
|
|
1138
|
+
xphys: np.ndarray
|
|
1139
|
+
compliance: float
|
|
1140
|
+
distortion: float # mean squared as-built displacement over the target, mm^2
|
|
1141
|
+
volume_fraction: float
|
|
1142
|
+
iterations: int
|
|
1143
|
+
converged: bool
|
|
1144
|
+
seconds: float
|
|
1145
|
+
history: list = field(default_factory=list)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _warp_problem(grid, supports, force, opts: WarpOptions):
|
|
1149
|
+
"""evaluate(xe) -> (objective, gradient, record) of optimize_warp, on
|
|
1150
|
+
the eroded density xe."""
|
|
1151
|
+
from .simp import E_MIN
|
|
1152
|
+
|
|
1153
|
+
mesh = grid.mesh
|
|
1154
|
+
setup = opts.process
|
|
1155
|
+
mat = _material(setup.material)
|
|
1156
|
+
e0 = float(setup.E or mat.E)
|
|
1157
|
+
nu = float(setup.nu if setup.nu is not None else mat.nu)
|
|
1158
|
+
p = 3.0
|
|
1159
|
+
warp = WarpTerm(mesh, setup, build=(2, 1), layer=opts.layer, target=opts.target, nu=nu)
|
|
1160
|
+
st_s = Stiffness(mesh, nu, np.asarray(supports, bool))
|
|
1161
|
+
f_s = np.asarray(force, float)
|
|
1162
|
+
ref = {}
|
|
1163
|
+
|
|
1164
|
+
def evaluate(xe):
|
|
1165
|
+
e = e0 * (E_MIN + (1 - E_MIN) * xe ** p)
|
|
1166
|
+
de = e0 * (1 - E_MIN) * p * xe ** (p - 1)
|
|
1167
|
+
# service compliance
|
|
1168
|
+
us = _solve(st_s.assemble(e), f_s, mesh.node_xyz)
|
|
1169
|
+
comp = float(f_s @ us)
|
|
1170
|
+
dc = -de * st_s.strain_energy(us)
|
|
1171
|
+
dist, dd = warp.evaluate(e, de)
|
|
1172
|
+
if not ref:
|
|
1173
|
+
ref["c"], ref["d"] = comp, max(dist, 1e-30)
|
|
1174
|
+
obj = comp / ref["c"] + opts.warp_weight * dist / ref["d"]
|
|
1175
|
+
grad = dc / ref["c"] + opts.warp_weight * dd / ref["d"]
|
|
1176
|
+
return obj, grad, {"compliance": comp, "distortion": dist}
|
|
1177
|
+
|
|
1178
|
+
return evaluate
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def optimize_warp(grid, supports, force, opts: WarpOptions, on_iter=None, should_stop=None):
|
|
1182
|
+
"""Topology optimization against service compliance and build
|
|
1183
|
+
distortion. `grid`: a thermal.ThermalGrid (the design cells, build
|
|
1184
|
+
direction +z, the lowest layer on the plate, held at its nodes there);
|
|
1185
|
+
`supports`: bool per dof fixed in service; `force`: service load
|
|
1186
|
+
(n_dof). Objective: c / c0 + warp_weight * D / D0, D the mean squared
|
|
1187
|
+
displacement of the target nodes at the end of the layer-by-layer
|
|
1188
|
+
build (inherent strain from opts.process, lumped to opts.layer), c0
|
|
1189
|
+
and D0 the first iterate's. warp_weight 0 is plain compliance."""
|
|
1190
|
+
from types import SimpleNamespace
|
|
1191
|
+
|
|
1192
|
+
from .thermal import _mma_loop
|
|
1193
|
+
|
|
1194
|
+
evaluate = _warp_problem(grid, supports, force, opts)
|
|
1195
|
+
o = SimpleNamespace(volume_fraction=opts.volume_fraction, filter_radius=opts.filter_radius,
|
|
1196
|
+
max_iterations=opts.max_iterations, beta_max=opts.beta_max, beta_every=opts.beta_every,
|
|
1197
|
+
eta=opts.eta, erode=opts.erode, move=opts.move, tol=opts.tol, x0=opts.x0,
|
|
1198
|
+
extrusion=opts.extrusion)
|
|
1199
|
+
pres = grid.preserve.copy()
|
|
1200
|
+
x, xp, it, conv, hist, secs = _mma_loop(grid, o, pres, evaluate, on_iter, should_stop)
|
|
1201
|
+
last = hist[-1] if hist else {}
|
|
1202
|
+
return WarpResult(x=x, xphys=xp, compliance=float(last.get("compliance", np.nan)),
|
|
1203
|
+
distortion=float(last.get("distortion", np.nan)),
|
|
1204
|
+
volume_fraction=float(xp[~pres].mean()), iterations=it, converged=conv, seconds=secs,
|
|
1205
|
+
history=hist)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
# --------------------------------------------------------- the warp term
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
class WarpTerm:
|
|
1212
|
+
"""The as-built distortion of a density design and its gradient: the
|
|
1213
|
+
layer-by-layer build of buildsim on a fixed voxel mesh, every cell
|
|
1214
|
+
present at modulus e (SIMP), the lowest layer's underside held by the
|
|
1215
|
+
plate. D = mean over the target nodes of |u|^2, u the displacement at
|
|
1216
|
+
the end of the build (still on the plate). Shared by optimize_warp and
|
|
1217
|
+
the study optimizer (simp, buildSim.warpWeight).
|
|
1218
|
+
|
|
1219
|
+
build: (axis, sign), the direction layers stack (a grid axis; a tilted
|
|
1220
|
+
build has no layers on this grid); layer: mm per analysis layer
|
|
1221
|
+
(default 4 cells)."""
|
|
1222
|
+
|
|
1223
|
+
DIRECT = 90_000 # dofs: factor each layer once for the forward and adjoint solves
|
|
1224
|
+
|
|
1225
|
+
def __init__(self, mesh: HexMesh, setup: BuildSetup, build=(2, 1), layer=None, target=None, nu=None):
|
|
1226
|
+
if build is None or len(build) != 2:
|
|
1227
|
+
raise ValueError("the warp term needs a build direction along a grid axis ('+z', '-y', ...); "
|
|
1228
|
+
"a tilted build direction has no layers on the voxel grid")
|
|
1229
|
+
axis, sign = int(build[0]), int(build[1])
|
|
1230
|
+
self.mesh = mesh
|
|
1231
|
+
self.axis = axis
|
|
1232
|
+
mat = _material(setup.material)
|
|
1233
|
+
nu = float(nu if nu is not None else (setup.nu if setup.nu is not None else mat.nu))
|
|
1234
|
+
hgt = sign * mesh.elem_ijk[:, axis]
|
|
1235
|
+
hgt = hgt - hgt.min()
|
|
1236
|
+
hn = sign * mesh.node_ijk[:, axis]
|
|
1237
|
+
self.sign = sign
|
|
1238
|
+
self.hgt = hgt
|
|
1239
|
+
self.hn = hn - hn.min()
|
|
1240
|
+
self.fdm = setup.process.lower() == "fdm"
|
|
1241
|
+
inplane = [a for a in range(3) if a != axis]
|
|
1242
|
+
springs = None
|
|
1243
|
+
if self.fdm:
|
|
1244
|
+
# FDM: the elastic bed of simulate_build (springs on the first
|
|
1245
|
+
# layer's underside, a quarter cell of area per touching cell),
|
|
1246
|
+
# pinned only against drift in its plane, so corners can lift
|
|
1247
|
+
kb = float(setup.bed_stiffness if setup.bed_stiffness is not None else FDM_BED)
|
|
1248
|
+
area = np.zeros(mesh.n_nodes)
|
|
1249
|
+
under = hgt == 0
|
|
1250
|
+
for c in range(8):
|
|
1251
|
+
nd = mesh.enode[under, c]
|
|
1252
|
+
on = self.hn[nd] == 0
|
|
1253
|
+
np.add.at(area, nd[on], 0.25 * mesh.pitch ** 2)
|
|
1254
|
+
springs = np.repeat(area * kb, 3)
|
|
1255
|
+
bottom = np.flatnonzero(self.hn == 0)
|
|
1256
|
+
pc = mesh.node_xyz[bottom][:, inplane]
|
|
1257
|
+
a = bottom[np.argmin(np.linalg.norm(pc - pc.mean(axis=0), axis=1))]
|
|
1258
|
+
b = bottom[np.argmax(np.abs(pc[:, 0] - mesh.node_xyz[a, inplane[0]]))]
|
|
1259
|
+
self.fixed = np.zeros(mesh.n_dof, bool)
|
|
1260
|
+
self.fixed[3 * a + inplane[0]] = self.fixed[3 * a + inplane[1]] = True
|
|
1261
|
+
self.fixed[3 * b + inplane[1]] = True
|
|
1262
|
+
else:
|
|
1263
|
+
self.fixed = np.repeat(hn == hn.min(), 3)
|
|
1264
|
+
self.kern = _Kernel(mesh, nu, self.fixed, springs)
|
|
1265
|
+
# the distortion measure: |u| for a plate-held metal build; along
|
|
1266
|
+
# the build only for FDM, where the in-plane shrink is uniform (the
|
|
1267
|
+
# slicer scales it out) and the warp is the lift off the bed
|
|
1268
|
+
self.mask = np.ones(3)
|
|
1269
|
+
if self.fdm:
|
|
1270
|
+
self.mask = np.zeros(3)
|
|
1271
|
+
self.mask[axis] = 1.0
|
|
1272
|
+
self.cool = None
|
|
1273
|
+
if self.fdm:
|
|
1274
|
+
p = _fdm_params(setup)
|
|
1275
|
+
tz = _fdm_temperature(p, (hgt + 0.5) * mesh.pitch)
|
|
1276
|
+
self.cool = np.zeros((mesh.n_elem, 6))
|
|
1277
|
+
self.cool[:, :3] = (-p["alpha"] * (tz - p["room"]) * setup.scale)[:, None]
|
|
1278
|
+
eps, self.info = inherent_strain(setup, z=(hgt + 0.5) * mesh.pitch if setup.process == "fdm" else None)
|
|
1279
|
+
eps = np.broadcast_to(eps, (mesh.n_elem, 6))
|
|
1280
|
+
# build frame (in plane, in plane, along the build) -> grid axes
|
|
1281
|
+
world = np.zeros((mesh.n_elem, 6))
|
|
1282
|
+
world[:, inplane[0]] = eps[:, 0]
|
|
1283
|
+
world[:, inplane[1]] = eps[:, 1]
|
|
1284
|
+
world[:, axis] = eps[:, 2]
|
|
1285
|
+
self.eps = world
|
|
1286
|
+
# default lumps: 4 cells for metal; 1 for FDM, whose warp builds up
|
|
1287
|
+
# layer by layer on a bed that gives (4-cell lumps read it 2-3x low)
|
|
1288
|
+
lump = max(1, int(round((layer or (1 if setup.process.lower() == "fdm" else 4) * mesh.pitch) / mesh.pitch)))
|
|
1289
|
+
self.layer_of = hgt // lump
|
|
1290
|
+
self.n_layers = int(self.layer_of.max()) + 1
|
|
1291
|
+
# FDM: lumps thicker than the printed layer read the warp low (it
|
|
1292
|
+
# grows as the layers thin); extrapolate (layer_extrapolation)
|
|
1293
|
+
self.extrapolate = 0.0
|
|
1294
|
+
if self.fdm:
|
|
1295
|
+
self.extrapolate = layer_extrapolation(lump * mesh.pitch, setup)
|
|
1296
|
+
self.layer_of2 = hgt // (2 * lump)
|
|
1297
|
+
target = np.ones(mesh.n_nodes, bool) if target is None else np.asarray(target, bool)
|
|
1298
|
+
self.wdof = np.repeat(target, 3).astype(float) / max(int(target.sum()), 1)
|
|
1299
|
+
self.last_u = None
|
|
1300
|
+
|
|
1301
|
+
def evaluate(self, e, de=None):
|
|
1302
|
+
"""(D, dD/dx) for moduli e (per cell) and their derivative de with
|
|
1303
|
+
respect to the design density (None: D only)."""
|
|
1304
|
+
vals, grad = self.measures(e, de, lambda v: {"mean": 1.0})
|
|
1305
|
+
return vals["mean"], grad
|
|
1306
|
+
|
|
1307
|
+
def _forward(self, e, layer_of, keep):
|
|
1308
|
+
"""One build at the given layer lumps: the end displacement, each
|
|
1309
|
+
step's (dofs, solve, cells, du, active, eps) when keep, and the
|
|
1310
|
+
build-plane rises (step, dofs, rise)."""
|
|
1311
|
+
from scipy.sparse.linalg import splu
|
|
1312
|
+
mesh, kern = self.mesh, self.kern
|
|
1313
|
+
ne = mesh.n_elem
|
|
1314
|
+
u = np.zeros(mesh.n_dof)
|
|
1315
|
+
steps, lifts = [], []
|
|
1316
|
+
node_on = np.zeros(mesh.n_nodes, bool)
|
|
1317
|
+
active = np.zeros(ne, bool)
|
|
1318
|
+
|
|
1319
|
+
def step(scale, f):
|
|
1320
|
+
d = _dofs(node_on, self.fixed)
|
|
1321
|
+
k = kern.k(scale)[d][:, d]
|
|
1322
|
+
du = np.zeros(mesh.n_dof)
|
|
1323
|
+
if len(d) <= self.DIRECT:
|
|
1324
|
+
lu = splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A")
|
|
1325
|
+
du[d] = lu.solve(f[d])
|
|
1326
|
+
return d, lu.solve, du
|
|
1327
|
+
du[d] = _solve(k, f[d], mesh.node_xyz, d)
|
|
1328
|
+
return d, (lambda kk, dd: (lambda r: _solve(kk, r, mesh.node_xyz, dd)))(k, d), du
|
|
1329
|
+
|
|
1330
|
+
for li in range(int(layer_of.max()) + 1):
|
|
1331
|
+
new = layer_of == li
|
|
1332
|
+
if not new.any():
|
|
1333
|
+
continue
|
|
1334
|
+
active |= new
|
|
1335
|
+
node_on[mesh.enode[new].ravel()] = True
|
|
1336
|
+
el = np.flatnonzero(new)
|
|
1337
|
+
d, solve, du = step(e * active, kern.load(e, self.eps[el], el))
|
|
1338
|
+
u += du
|
|
1339
|
+
plane = np.flatnonzero(node_on & (self.hn == self.hgt[el].max() + 1))
|
|
1340
|
+
ldof = 3 * plane + self.axis
|
|
1341
|
+
lifts.append((len(steps), ldof, self.sign * u[ldof]))
|
|
1342
|
+
steps.append((d, solve, el, du, np.flatnonzero(active), self.eps) if keep else None)
|
|
1343
|
+
if self.cool is not None:
|
|
1344
|
+
# FDM: the finished part cools on the bed to room temperature
|
|
1345
|
+
el = np.arange(ne)
|
|
1346
|
+
d, solve, du = step(e, kern.load(e, self.cool, el))
|
|
1347
|
+
u += du
|
|
1348
|
+
steps.append((d, solve, el, du, el, self.cool) if keep else None)
|
|
1349
|
+
return u, steps, lifts
|
|
1350
|
+
|
|
1351
|
+
def measures(self, e, de=None, weights=None):
|
|
1352
|
+
"""The as-built measures of moduli e: "mean" (D), "max" (the largest
|
|
1353
|
+
|u| at the end of the build, mm; along the build only for FDM) and
|
|
1354
|
+
"lift" (the largest rise along the build at the build plane, over
|
|
1355
|
+
the layers as each goes down: the recoater's clearance, mm). With
|
|
1356
|
+
de, also the gradient with respect to the design density of
|
|
1357
|
+
sum(w[m] * measure m), w = weights(vals) (a callable, so weights can
|
|
1358
|
+
depend on this design's values). The maxima are steered through
|
|
1359
|
+
16-norms rescaled to the true maximum at this design (adaptive
|
|
1360
|
+
normalization), so the gradient is that of the maximum to first
|
|
1361
|
+
order. FDM builds at lumps thicker than the printed layer are
|
|
1362
|
+
extrapolated to it from a second build at twice the lumps (see
|
|
1363
|
+
layer_extrapolation)."""
|
|
1364
|
+
mesh, kern = self.mesh, self.kern
|
|
1365
|
+
ne = mesh.n_elem
|
|
1366
|
+
keep = de is not None
|
|
1367
|
+
u1, steps, lifts = self._forward(e, self.layer_of, keep)
|
|
1368
|
+
kx = self.extrapolate
|
|
1369
|
+
if kx:
|
|
1370
|
+
u2, steps2, _ = self._forward(e, self.layer_of2, keep)
|
|
1371
|
+
u = (1.0 + kx) * u1 - kx * u2
|
|
1372
|
+
else:
|
|
1373
|
+
u = u1
|
|
1374
|
+
self.last_u = u
|
|
1375
|
+
uu = u.reshape(-1, 3) * self.mask
|
|
1376
|
+
un = np.linalg.norm(uu, axis=1)
|
|
1377
|
+
allrise = np.concatenate([r for _, _, r in lifts]) if lifts else np.zeros(1)
|
|
1378
|
+
vals = {"mean": float((self.wdof * u * u).sum()), "max": float(un.max()),
|
|
1379
|
+
"lift": float(max(allrise.max(), 0.0))}
|
|
1380
|
+
# the 16-norms the gradient steers (checks)
|
|
1381
|
+
vals["_maxP"] = float(np.sum(un ** 16) ** (1 / 16))
|
|
1382
|
+
vals["_liftP"] = float(np.sum(np.maximum(allrise, 0.0) ** 16) ** (1 / 16))
|
|
1383
|
+
if de is None:
|
|
1384
|
+
return vals, None
|
|
1385
|
+
w = weights(vals) if callable(weights) else (weights or {"mean": 1.0})
|
|
1386
|
+
q = 16.0
|
|
1387
|
+
# dJ/du at the end of the build, and per step (the lift)
|
|
1388
|
+
gu = 2.0 * w.get("mean", 0.0) * self.wdof * u
|
|
1389
|
+
if w.get("max") and vals["max"] > 0:
|
|
1390
|
+
r = un / vals["max"]
|
|
1391
|
+
c = r ** (q - 1) / (r ** q).sum()
|
|
1392
|
+
gu += w["max"] * ((c / np.maximum(un, 1e-30))[:, None] * uu).ravel()
|
|
1393
|
+
gstep = {}
|
|
1394
|
+
if w.get("lift") and vals["lift"] > 0:
|
|
1395
|
+
tot = float(np.sum((np.maximum(allrise, 0.0) / vals["lift"]) ** q))
|
|
1396
|
+
for si, ldof, rs in lifts:
|
|
1397
|
+
g = np.zeros(mesh.n_dof)
|
|
1398
|
+
g[ldof] = w["lift"] * self.sign * (np.maximum(rs, 0.0) / vals["lift"]) ** (q - 1) / tot
|
|
1399
|
+
gstep[si] = g
|
|
1400
|
+
# adjoint: J depends on the final u = sum of the layer increments
|
|
1401
|
+
# and on each step's u_k (the lift), so dJ/d du_j = gu + the sum of
|
|
1402
|
+
# the step terms from j on; K_j lam_j = that, and
|
|
1403
|
+
# dJ/de = sum_j lam_j^T (df_j/de - dK_j/de du_j). Extrapolated, u is
|
|
1404
|
+
# (1 + k) u1 - k u2, so each build's adjoint takes its share.
|
|
1405
|
+
dd = np.zeros(ne)
|
|
1406
|
+
ke, g = kern.st.ke, kern.g
|
|
1407
|
+
|
|
1408
|
+
def adjoint(steps, scale, with_lift):
|
|
1409
|
+
acc = np.zeros(mesh.n_dof)
|
|
1410
|
+
for si in range(len(steps) - 1, -1, -1):
|
|
1411
|
+
d, solve, el, du, ia, eps = steps[si]
|
|
1412
|
+
if with_lift and si in gstep:
|
|
1413
|
+
acc = acc + gstep[si]
|
|
1414
|
+
rhs = scale * gu + acc
|
|
1415
|
+
lam = np.zeros(mesh.n_dof)
|
|
1416
|
+
lam[d] = solve(rhs[d])
|
|
1417
|
+
dd[el] += np.einsum("ij,ij->i", lam[mesh.edof[el]], eps[el] @ g.T)
|
|
1418
|
+
dd[ia] -= np.einsum("ij,jk,ik->i", lam[mesh.edof[ia]], ke, du[mesh.edof[ia]])
|
|
1419
|
+
|
|
1420
|
+
adjoint(steps, 1.0 + kx, True)
|
|
1421
|
+
if kx:
|
|
1422
|
+
adjoint(steps2, -kx, False)
|
|
1423
|
+
return vals, dd * de
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
class CoarseWarp:
|
|
1427
|
+
"""A WarpTerm on a grid `factor` times coarser than the optimizer's:
|
|
1428
|
+
each coarse cell's modulus is the mean of its fine cells' (absent ones
|
|
1429
|
+
count as void), and the gradient goes back to each fine cell as its
|
|
1430
|
+
share. factor 2 has 8x fewer cells and half the layers, so the build
|
|
1431
|
+
solves in the optimizer loop cost a small fraction of the fine ones."""
|
|
1432
|
+
|
|
1433
|
+
def __init__(self, mesh: HexMesh, setup: BuildSetup, factor=2, build=(2, 1), layer=None, nu=None):
|
|
1434
|
+
f = int(factor)
|
|
1435
|
+
cijk = mesh.elem_ijk // f
|
|
1436
|
+
uniq, self.parent = np.unique(cijk, axis=0, return_inverse=True)
|
|
1437
|
+
self.parent = self.parent.ravel()
|
|
1438
|
+
self.n = len(uniq)
|
|
1439
|
+
self.share = 1.0 / f ** 3
|
|
1440
|
+
self.mesh = HexMesh(uniq, mesh.pitch * f, mesh.origin)
|
|
1441
|
+
self.fine_mesh = mesh
|
|
1442
|
+
self._fine = None
|
|
1443
|
+
self.ratio = {"mean": 1.0, "max": 1.0, "lift": 1.0}
|
|
1444
|
+
self.layer = layer or (f if setup.process.lower() == "fdm" else 4) * mesh.pitch
|
|
1445
|
+
self.setup, self.build = setup, build
|
|
1446
|
+
self.term = WarpTerm(self.mesh, setup, build=build, layer=self.layer, nu=nu)
|
|
1447
|
+
self.factor = f
|
|
1448
|
+
|
|
1449
|
+
def _coarse(self, e):
|
|
1450
|
+
return np.bincount(self.parent, weights=e, minlength=self.n) * self.share
|
|
1451
|
+
|
|
1452
|
+
def fine(self):
|
|
1453
|
+
"""The same build model on the full grid."""
|
|
1454
|
+
if self._fine is None:
|
|
1455
|
+
self._fine = WarpTerm(self.fine_mesh, self.setup, build=self.build,
|
|
1456
|
+
layer=self.layer,
|
|
1457
|
+
nu=self.term.kern.nu)
|
|
1458
|
+
return self._fine
|
|
1459
|
+
|
|
1460
|
+
def measures(self, e, de=None, weights=None, refresh=False):
|
|
1461
|
+
"""As WarpTerm.measures, on the coarse grid, with each measure scaled
|
|
1462
|
+
by its full-grid / coarse-grid ratio (refresh: re-measure that ratio
|
|
1463
|
+
on this design, one forward build on the full grid). Averaging the
|
|
1464
|
+
moduli makes thin members softer and stiffer by turns than they are,
|
|
1465
|
+
so the coarse grid alone misreads a design far from where the ratio
|
|
1466
|
+
was taken."""
|
|
1467
|
+
ec = np.maximum(self._coarse(e), 1e-9)
|
|
1468
|
+
if refresh:
|
|
1469
|
+
vf, _ = self.fine().measures(e)
|
|
1470
|
+
vc, _ = self.term.measures(ec)
|
|
1471
|
+
self.ratio = {k: (vf[k] / vc[k] if vc[k] > 1e-12 else 1.0) for k in ("mean", "max", "lift")}
|
|
1472
|
+
r = self.ratio
|
|
1473
|
+
|
|
1474
|
+
def scaled(v):
|
|
1475
|
+
return {**v, **{k: v[k] * r[k] for k in r}}
|
|
1476
|
+
|
|
1477
|
+
if de is None:
|
|
1478
|
+
v, _ = self.term.measures(ec)
|
|
1479
|
+
return scaled(v), None
|
|
1480
|
+
|
|
1481
|
+
def wc(v):
|
|
1482
|
+
w = weights(scaled(v)) if callable(weights) else (weights or {"mean": 1.0})
|
|
1483
|
+
return {k: w[k] * r.get(k, 1.0) for k in w}
|
|
1484
|
+
|
|
1485
|
+
vals, gc = self.term.measures(ec, np.ones(self.n), wc)
|
|
1486
|
+
return scaled(vals), gc[self.parent] * self.share * de
|
|
1487
|
+
|
|
1488
|
+
def evaluate(self, e, de=None):
|
|
1489
|
+
vals, g = self.measures(e, de, lambda v: {"mean": 1.0})
|
|
1490
|
+
return vals["mean"], g
|