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/impact.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Crush and impact: what a lattice is for when stiffness per gram is not the goal.
|
|
3
|
+
|
|
4
|
+
A solid part stops a falling or swinging mass like a spring: the force rises
|
|
5
|
+
with the deflection until the part has stored the whole kinetic energy, and
|
|
6
|
+
a stiff part (which is what topology optimization makes) has to push back
|
|
7
|
+
very hard to do that in a fraction of a millimetre. Then it hands the energy
|
|
8
|
+
back as a rebound. A cellular core crushes at a nearly constant stress (the
|
|
9
|
+
plateau) over most of its height, so it absorbs the same energy at a force
|
|
10
|
+
the designer picks, and keeps it (the walls yield or buckle). That is the
|
|
11
|
+
job of a crash box, a hard stop, a bumper or a drop pad.
|
|
12
|
+
|
|
13
|
+
This module predicts that behaviour for TPMS sheet lattices graded by a
|
|
14
|
+
field, and grades one for a given impact:
|
|
15
|
+
|
|
16
|
+
- `collapse_strength`: plastic collapse stress of one periodic unit cell
|
|
17
|
+
under uniaxial compression, from the elastic compensation method
|
|
18
|
+
(Mackenzie & Boyle 1993): repeated linear solves in which every cell wall
|
|
19
|
+
above the yield stress is softened in proportion, so the load spreads
|
|
20
|
+
the way a plastic mechanism spreads it. The lower bound P sigma_y /
|
|
21
|
+
max sigma it gives rises with each pass to near the limit load. The fits
|
|
22
|
+
in CRUSH (sigma_pl / sigma_y = D rho^p) come from it.
|
|
23
|
+
- `crush_curve`: force against crush distance for a part along one axis,
|
|
24
|
+
as a stack of thin layers in series. Each layer's crush force is the sum
|
|
25
|
+
of its cells' plateau stress times their area (cells in parallel), and
|
|
26
|
+
the weakest layer crushes first, through its densification strain
|
|
27
|
+
(Gibson & Ashby: eps_D = 1 - 1.4 rho), before the next one starts. This
|
|
28
|
+
is the standard model for graded foams; it ignores lateral load spreading
|
|
29
|
+
between layers, which `limit_load` checks in 3D.
|
|
30
|
+
- `impact_response`: where on that curve a mass m at speed v stops: crush
|
|
31
|
+
distance, peak force, peak deceleration, and whether the core bottoms out.
|
|
32
|
+
- `grade_for_impact`: the density per layer that crushes every layer at the
|
|
33
|
+
same force (sigma_pl(rho(z)) A(z) = F), the lowest such force that stops
|
|
34
|
+
the mass within the core, which is the flattest, gentlest absorber that
|
|
35
|
+
fits the envelope. A tapered envelope makes a uniform core's force rise
|
|
36
|
+
as the crush front reaches wider layers; grading takes that out.
|
|
37
|
+
- `solid_impact`: the same impact on a solid part from its linear stiffness
|
|
38
|
+
and peak stress (what a topology-optimized part does: elastic, so the
|
|
39
|
+
peak force is sqrt(2 E k), and it rebounds).
|
|
40
|
+
- `limit_load`: the elastic compensation method on a 3D voxel model with
|
|
41
|
+
per-cell modulus and strength (a homogenized graded lattice), for the
|
|
42
|
+
first collapse load without the layer model's assumptions.
|
|
43
|
+
|
|
44
|
+
Assumptions worth knowing. The plateau is taken equal to the plastic
|
|
45
|
+
collapse stress (right for ductile polymers such as PA12, PA11, PETG; PLA
|
|
46
|
+
crushes by fracture and is ragged; TPU buckles elastically and recovers,
|
|
47
|
+
which this does not model). Densification uses Gibson & Ashby's foam
|
|
48
|
+
constant 1.4, not a TPMS measurement. Rate effects are ignored (polymers
|
|
49
|
+
are 10-30% stronger at impact rates, which raises the force a little and
|
|
50
|
+
is on the safe side for stroke, not for force). Treat predictions as
|
|
51
|
+
+-25% until a printed crush coupon calibrates D (see CRUSH).
|
|
52
|
+
|
|
53
|
+
Units: mm, N, MPa; energy in N mm (= mJ), mass in kg, speed in m/s.
|
|
54
|
+
"""
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
|
|
59
|
+
import numpy as np
|
|
60
|
+
|
|
61
|
+
from .lattice import SHEETS, relative_density, sheet_modulus, tpms
|
|
62
|
+
|
|
63
|
+
__all__ = ["CRUSH", "CRUSH_DATA", "crush_law", "ELASTOMERS", "HYSTERESIS", "collapse_strength", "plateau_stress", "density_for_plateau", "densification_strain",
|
|
64
|
+
"CrushCurve", "crush_curve", "layer_profile", "impact_response", "grade_for_impact", "solid_impact",
|
|
65
|
+
"limit_load", "foam_stress", "face_load", "grade_3d", "save_density_grid", "impact_report", "sample_core", "DENSIFICATION_ALPHA"]
|
|
66
|
+
|
|
67
|
+
DENSIFICATION_ALPHA = 1.4 # Gibson & Ashby, open-cell foams: eps_D = 1 - 1.4 rho
|
|
68
|
+
|
|
69
|
+
# Plastic collapse under uniaxial compression, sigma_pl / sigma_y = D rho^p,
|
|
70
|
+
# from `collapse_strength` (elastic compensation lower bound, 32^3 voxels
|
|
71
|
+
# per cell, nu 0.39, 15 passes) at rho = 0.11 .. 0.51, fit residuals under
|
|
72
|
+
# 6%. The collapse stress is 1.4-2x the first-yield stress (the walls share
|
|
73
|
+
# load once the hottest ones yield). Exponents near 1.1-1.4 say the sheets
|
|
74
|
+
# carry load mostly by stretching, which is why they beat strut lattices
|
|
75
|
+
# (bending, p ~ 1.5-2) per gram. Per gram, diamond is the strongest.
|
|
76
|
+
# Perfect cells: no buckling, no print defects, quasi-static; a crush
|
|
77
|
+
# coupon is what pins D down for a real machine (docs/IMPACT.md).
|
|
78
|
+
CRUSH = {
|
|
79
|
+
"gyroid": {"D": 0.633, "p": 1.191},
|
|
80
|
+
"diamond": {"D": 0.675, "p": 1.122},
|
|
81
|
+
"schwarz-p": {"D": 0.795, "p": 1.380},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Elastomers (TPU) do not yield: their sheets buckle elastically, the core
|
|
85
|
+
# crushes at the buckling stress and springs back. Plateau sigma_el / E =
|
|
86
|
+
# D rho^p, Gibson & Ashby's elastic-collapse law for elastomeric foams
|
|
87
|
+
# (0.05 rho^2). Our own check, linear buckling of a 2x2x2 gyroid block
|
|
88
|
+
# between glued plates (nonlinear.linear_buckling), gives 0.0043 at rho 0.3
|
|
89
|
+
# with 24 voxels per cell against the law's 0.0045, but is not converged
|
|
90
|
+
# (0.0144 at 16 voxels: thin walls lock), so the law stands until a
|
|
91
|
+
# coupon says otherwise. Same law for every sheet type (no per-type data).
|
|
92
|
+
for _k in ("gyroid", "diamond", "schwarz-p"):
|
|
93
|
+
CRUSH[f"{_k}:elastic"] = {"D": 0.05, "p": 2.0}
|
|
94
|
+
ELASTOMERS = {"tpu"}
|
|
95
|
+
# fraction of the absorbed energy an elastomer core dissipates (the rest
|
|
96
|
+
# comes back as rebound): large-strain compression hysteresis of TPU
|
|
97
|
+
# foams and lattices, roughly 0.2-0.4; rough
|
|
98
|
+
HYSTERESIS = {"tpu": 0.3}
|
|
99
|
+
|
|
100
|
+
# (rho, sigma_pl / sigma_y) behind the fits, for tests and re-fitting
|
|
101
|
+
CRUSH_DATA = {
|
|
102
|
+
"gyroid": [(0.123, 0.0542), (0.206, 0.0927), (0.303, 0.1479), (0.408, 0.2121), (0.509, 0.2987)],
|
|
103
|
+
"diamond": [(0.111, 0.0580), (0.199, 0.1098), (0.289, 0.1656), (0.412, 0.2509), (0.512, 0.3202)],
|
|
104
|
+
"schwarz-p": [(0.113, 0.0397), (0.198, 0.0826), (0.307, 0.1568), (0.399, 0.2267), (0.498, 0.3024)],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- unit cell ---------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def collapse_strength(kind: str, wall_over_cell: float, n: int = 32, nu: float = 0.39, iters: int = 15,
|
|
111
|
+
axis: int = 2) -> dict:
|
|
112
|
+
"""Plastic collapse stress (over the solid's yield stress) of a TPMS sheet
|
|
113
|
+
unit cell under uniaxial macro compression along `axis`, lateral faces
|
|
114
|
+
free on average, by the elastic compensation method on an n^3 periodic
|
|
115
|
+
voxel cell.
|
|
116
|
+
|
|
117
|
+
Each pass solves the cell for a unit macro stress, takes the lower bound
|
|
118
|
+
1 / max(von Mises) (the macro stress at which the most loaded wall
|
|
119
|
+
yields, with the moduli of this pass) and softens every element by
|
|
120
|
+
sigma_ref / sigma_e, sigma_ref the mean wall stress, so overloaded walls
|
|
121
|
+
shed load to idle ones as they would after yielding. Returns rho, the
|
|
122
|
+
first-yield stress (pass 1, the elastic answer), the collapse estimate
|
|
123
|
+
(best lower bound over the passes) and the history."""
|
|
124
|
+
import pyamg
|
|
125
|
+
import scipy.sparse as sp
|
|
126
|
+
from scipy.sparse.linalg import cg
|
|
127
|
+
|
|
128
|
+
from .fem import CORNERS, _b_matrix, elastic_c, hex8_ke, von_mises
|
|
129
|
+
|
|
130
|
+
h = 1.0 / n
|
|
131
|
+
c = (np.arange(n) + 0.5) * h
|
|
132
|
+
g = np.stack(np.meshgrid(c, c, c, indexing="ij"), -1).reshape(-1, 3)
|
|
133
|
+
solid = np.abs(tpms(kind, g, 1.0)) < 0.5 * wall_over_cell
|
|
134
|
+
eijk = np.argwhere(solid.reshape(n, n, n))
|
|
135
|
+
ne = len(eijk)
|
|
136
|
+
corner = (eijk[:, None, :] + CORNERS[None]) % n
|
|
137
|
+
_, inv = np.unique((corner[..., 0] * n + corner[..., 1]) * n + corner[..., 2], return_inverse=True)
|
|
138
|
+
enode = inv.reshape(ne, 8)
|
|
139
|
+
nn = int(enode.max()) + 1
|
|
140
|
+
edof = (3 * enode[:, :, None] + np.arange(3)).reshape(ne, 24)
|
|
141
|
+
ke = hex8_ke(nu) * h
|
|
142
|
+
rows = np.repeat(edof, 24, axis=1).ravel()
|
|
143
|
+
cols = np.tile(edof, (1, 24)).ravel()
|
|
144
|
+
free = np.ones(3 * nn, bool)
|
|
145
|
+
free[:3] = False
|
|
146
|
+
xc = (eijk[:, None, :] + CORNERS[None]) * h
|
|
147
|
+
c0 = elastic_c(1.0, nu)
|
|
148
|
+
bc = _b_matrix(0, 0, 0) / h
|
|
149
|
+
scale = np.ones(ne)
|
|
150
|
+
|
|
151
|
+
def solve(eps_list):
|
|
152
|
+
k = sp.csr_matrix((np.repeat(scale, 576) * np.tile(ke.ravel(), ne), (rows, cols)), shape=(3 * nn, 3 * nn))
|
|
153
|
+
kf = k[free][:, free]
|
|
154
|
+
ml = pyamg.smoothed_aggregation_solver(kf, symmetry="hermitian", max_coarse=400, coarse_solver="splu")
|
|
155
|
+
out = []
|
|
156
|
+
for e in eps_list:
|
|
157
|
+
eps = np.array([[e[0], e[3] / 2, e[5] / 2], [e[3] / 2, e[1], e[4] / 2], [e[5] / 2, e[4] / 2, e[2]]])
|
|
158
|
+
u0 = (xc @ eps.T).reshape(ne, 24)
|
|
159
|
+
f = np.zeros(3 * nn)
|
|
160
|
+
np.add.at(f, edof, -scale[:, None] * (u0 @ ke.T))
|
|
161
|
+
uf, info = cg(kf, f[free], rtol=1e-8, maxiter=3000, M=ml.aspreconditioner())
|
|
162
|
+
if info != 0:
|
|
163
|
+
raise RuntimeError("unit cell solve did not converge")
|
|
164
|
+
u = np.zeros(3 * nn)
|
|
165
|
+
u[free] = uf
|
|
166
|
+
sig = ((u[edof] + u0) @ bc.T) @ c0.T * scale[:, None] # element-center stress
|
|
167
|
+
out.append((sig.sum(axis=0) / n ** 3, sig))
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
unit = np.eye(6)
|
|
171
|
+
hist = []
|
|
172
|
+
best = 0.0
|
|
173
|
+
first = None
|
|
174
|
+
for _ in range(iters):
|
|
175
|
+
res = solve([unit[0], unit[1], unit[2]])
|
|
176
|
+
cm = np.stack([r[0][:3] for r in res], axis=1) # 3x3 normal block of the macro stiffness
|
|
177
|
+
# macro strains giving unit uniaxial stress along `axis`
|
|
178
|
+
target = np.zeros(3)
|
|
179
|
+
target[axis] = 1.0
|
|
180
|
+
e = np.linalg.solve(cm, target)
|
|
181
|
+
sig = sum(e[i] * res[i][1] for i in range(3))
|
|
182
|
+
vm = von_mises(sig)
|
|
183
|
+
lb = 1.0 / float(vm.max())
|
|
184
|
+
if first is None:
|
|
185
|
+
first = lb
|
|
186
|
+
best = max(best, lb)
|
|
187
|
+
hist.append(lb)
|
|
188
|
+
ref = float(vm.mean())
|
|
189
|
+
scale = scale * np.clip(ref / np.maximum(vm, 1e-12), 0.05, 20.0)
|
|
190
|
+
scale = np.maximum(scale, 1e-6)
|
|
191
|
+
return {"kind": kind, "rho": float(solid.mean()), "firstYield": float(first), "collapse": float(best),
|
|
192
|
+
"history": [float(x) for x in hist]}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---- homogenized crush laws ------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def _crush_fit(kind):
|
|
198
|
+
fit = CRUSH.get(kind)
|
|
199
|
+
if fit is None:
|
|
200
|
+
raise ValueError(f"lattice type must be one of {sorted(CRUSH)}, got {kind!r}")
|
|
201
|
+
return fit
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _base(kind: str) -> str:
|
|
205
|
+
return kind.split(":")[0]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def crush_law(kind: str, material, calibrated: bool = True) -> tuple:
|
|
209
|
+
"""(law key, reference stress) for a lattice of `material`: plastic
|
|
210
|
+
collapse over the yield stress for polymers and metals that yield, and
|
|
211
|
+
elastic buckling over the modulus ("<kind>:elastic") for elastomers,
|
|
212
|
+
which buckle, crush and spring back. A calibration card of that name
|
|
213
|
+
with a crush block (calibration.fit on crush coupons) scales the
|
|
214
|
+
reference by its measured factor."""
|
|
215
|
+
name = getattr(material, "name", "")
|
|
216
|
+
elastic = name in ELASTOMERS or str(getattr(material, "note", "")).startswith("elastomer")
|
|
217
|
+
if not elastic:
|
|
218
|
+
try:
|
|
219
|
+
from .calibration import load_card
|
|
220
|
+
|
|
221
|
+
card = load_card(name) or {}
|
|
222
|
+
elastic = card.get("basedOn") in ELASTOMERS
|
|
223
|
+
except Exception: # noqa: BLE001 - a missing or broken card is no card
|
|
224
|
+
card = {}
|
|
225
|
+
law, ref = (f"{kind}:elastic", float(material.E)) if elastic else (kind, float(material.strength))
|
|
226
|
+
if calibrated:
|
|
227
|
+
from .calibration import crush_factor
|
|
228
|
+
|
|
229
|
+
ref *= crush_factor(name, "elastic" if elastic else "plastic")
|
|
230
|
+
return law, ref
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def plateau_stress(kind: str, rho, sigma_y: float):
|
|
234
|
+
"""Crush plateau stress (MPa) of a `kind` sheet at relative density rho;
|
|
235
|
+
solid (rho >= 1) is sigma_y. For an elastic law ("gyroid:elastic")
|
|
236
|
+
sigma_y is the solid's modulus (see crush_law)."""
|
|
237
|
+
f = _crush_fit(kind)
|
|
238
|
+
rho = np.asarray(rho, float)
|
|
239
|
+
return np.where(rho >= 1.0, sigma_y, sigma_y * f["D"] * np.clip(rho, 1e-3, 1.0) ** f["p"])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def density_for_plateau(kind: str, stress, sigma_y: float):
|
|
243
|
+
"""The relative density whose plateau is `stress` (MPa)."""
|
|
244
|
+
f = _crush_fit(kind)
|
|
245
|
+
return (np.asarray(stress, float) / (sigma_y * f["D"])) ** (1.0 / f["p"])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def densification_strain(rho, alpha: float = DENSIFICATION_ALPHA):
|
|
249
|
+
"""Nominal strain at which the crushed walls lock up (Gibson & Ashby)."""
|
|
250
|
+
return np.clip(1.0 - alpha * np.asarray(rho, float), 0.0, 1.0)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---- the layer model ----------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class CrushCurve:
|
|
257
|
+
"""Force (N) against crush distance (mm), piecewise linear, plus the
|
|
258
|
+
layers it came from. `stroke` is the distance at which every layer has
|
|
259
|
+
densified (the core bottoms out); `energy(d)` the work absorbed to d."""
|
|
260
|
+
d: np.ndarray
|
|
261
|
+
f: np.ndarray
|
|
262
|
+
stroke: float
|
|
263
|
+
layer_force: np.ndarray
|
|
264
|
+
layer_area: np.ndarray
|
|
265
|
+
layer_rho: np.ndarray
|
|
266
|
+
layer_stroke: np.ndarray
|
|
267
|
+
|
|
268
|
+
def energy(self, d=None):
|
|
269
|
+
e = np.concatenate([[0.0], np.cumsum(0.5 * (self.f[1:] + self.f[:-1]) * np.diff(self.d))])
|
|
270
|
+
return e if d is None else np.interp(d, self.d, e)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def capacity(self):
|
|
274
|
+
return float(self.energy()[-1])
|
|
275
|
+
|
|
276
|
+
def to_dict(self, points=60):
|
|
277
|
+
dd = np.linspace(0.0, float(self.d[-1]), points)
|
|
278
|
+
return {"stroke": round(self.stroke, 3), "capacity": round(self.capacity, 1),
|
|
279
|
+
"distance": [round(float(x), 3) for x in dd],
|
|
280
|
+
"force": [round(float(x), 1) for x in np.interp(dd, self.d, self.f)]}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def layer_profile(inside, rho, pitch: float, axis: int = 2):
|
|
284
|
+
"""Per layer along `axis` of a voxel grid: area (mm^2) and area-weighted
|
|
285
|
+
mean relative density of the cells inside the part. inside: bool grid;
|
|
286
|
+
rho: relative density per voxel (same shape, or a scalar)."""
|
|
287
|
+
inside = np.asarray(inside, bool)
|
|
288
|
+
rho = np.broadcast_to(np.asarray(rho, float), inside.shape)
|
|
289
|
+
other = tuple(i for i in range(3) if i != axis)
|
|
290
|
+
n = inside.sum(axis=other)
|
|
291
|
+
area = n * pitch ** 2
|
|
292
|
+
mean = np.where(n > 0, (rho * inside).sum(axis=other) / np.maximum(n, 1), 0.0)
|
|
293
|
+
return area, mean
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def crush_curve(kind: str, area, rho, h: float, sigma_y: float, modulus: float,
|
|
297
|
+
alpha: float = DENSIFICATION_ALPHA, layer_force=None) -> CrushCurve:
|
|
298
|
+
"""Force-crush curve of a stack of layers of thickness h (mm) with the
|
|
299
|
+
given area and density (one entry per layer; rho may be a scalar). The
|
|
300
|
+
layers act in series: all carry the same force, the elastic part is the
|
|
301
|
+
series compliance, and the layer with the lowest crush force collapses
|
|
302
|
+
first and runs through its densification strain before the force rises
|
|
303
|
+
to the next. `layer_force` (N per layer) overrides sigma_pl(rho) A when
|
|
304
|
+
a layer's cells are not uniform (pass the sum over its cells)."""
|
|
305
|
+
area = np.asarray(area, float)
|
|
306
|
+
rho = np.broadcast_to(np.asarray(rho, float), area.shape).copy()
|
|
307
|
+
keep = area > 0
|
|
308
|
+
area, rho = area[keep], rho[keep]
|
|
309
|
+
fl = plateau_stress(kind, rho, sigma_y) * area if layer_force is None else np.asarray(layer_force, float)[keep]
|
|
310
|
+
stroke = densification_strain(rho, alpha) * h
|
|
311
|
+
k_layer = modulus * sheet_modulus(_base(kind), rho) * area / h
|
|
312
|
+
comp = float(np.sum(1.0 / np.maximum(k_layer, 1e-12))) # series compliance, mm/N
|
|
313
|
+
order = np.argsort(fl, kind="stable")
|
|
314
|
+
d, f = [0.0], [0.0]
|
|
315
|
+
crushed = 0.0
|
|
316
|
+
for i in order:
|
|
317
|
+
# elastic ramp to this layer's force (the uncrushed layers still spring)
|
|
318
|
+
d.append(crushed + comp * fl[i])
|
|
319
|
+
f.append(fl[i])
|
|
320
|
+
crushed += stroke[i]
|
|
321
|
+
d.append(crushed + comp * fl[i])
|
|
322
|
+
f.append(fl[i])
|
|
323
|
+
return CrushCurve(np.array(d), np.array(f), float(crushed + comp * fl.max()), fl, area, rho, stroke)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def impact_response(curve: CrushCurve, energy: float, mass: float | None = None) -> dict:
|
|
327
|
+
"""Where a mass carrying `energy` (N mm) stops on the curve: crush
|
|
328
|
+
distance, peak force and (with `mass`, kg) peak deceleration in g. If
|
|
329
|
+
the core cannot take it all before densifying, `bottomedOut` is true and
|
|
330
|
+
`residualEnergy` is what is left for the densified core and whatever
|
|
331
|
+
is behind it (the force then is far above the plateau)."""
|
|
332
|
+
e = curve.energy()
|
|
333
|
+
out = {"energy": round(float(energy), 1), "capacity": round(float(e[-1]), 1)}
|
|
334
|
+
if energy > e[-1]:
|
|
335
|
+
out.update(bottomedOut=True, residualEnergy=round(float(energy - e[-1]), 1),
|
|
336
|
+
crush=round(float(curve.d[-1]), 3), peakForce=None, usedStroke=1.0)
|
|
337
|
+
return out
|
|
338
|
+
# f is piecewise linear in d, so within a segment the energy is
|
|
339
|
+
# quadratic in d: solve it there (a straight line between the corners
|
|
340
|
+
# reads a stiff core that stops on its first ramp far too low)
|
|
341
|
+
k = int(np.clip(np.searchsorted(e, energy, side="right") - 1, 0, len(e) - 2))
|
|
342
|
+
d0, d1 = float(curve.d[k]), float(curve.d[k + 1])
|
|
343
|
+
f0, f1 = float(curve.f[k]), float(curve.f[k + 1])
|
|
344
|
+
rest = max(float(energy - e[k]), 0.0)
|
|
345
|
+
s = (f1 - f0) / max(d1 - d0, 1e-12)
|
|
346
|
+
if abs(s) < 1e-12:
|
|
347
|
+
x = rest / max(f0, 1e-12)
|
|
348
|
+
else:
|
|
349
|
+
x = (-f0 + np.sqrt(max(f0 * f0 + 2 * s * rest, 0.0))) / s
|
|
350
|
+
d = float(min(d0 + max(x, 0.0), d1))
|
|
351
|
+
j = int(np.searchsorted(curve.d, d, side="right"))
|
|
352
|
+
fpk = float(max(curve.f[:j].max(initial=0.0), np.interp(d, curve.d, curve.f)))
|
|
353
|
+
out.update(bottomedOut=False, crush=round(d, 3), peakForce=round(fpk, 1),
|
|
354
|
+
usedStroke=round(d / max(curve.stroke, 1e-9), 3),
|
|
355
|
+
# energy over (peak force x crush): 1 is an ideal square pulse
|
|
356
|
+
efficiency=round(float(energy / max(fpk * d, 1e-12)), 3))
|
|
357
|
+
if mass:
|
|
358
|
+
out["peakG"] = round(fpk / (mass * 9.81), 1)
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def grade_for_impact(kind: str, area, h: float, sigma_y: float, modulus: float, energy: float,
|
|
363
|
+
force_limit: float | None = None, rho_min: float = 0.12, rho_max: float = 0.5,
|
|
364
|
+
reserve: float = 1.25, alpha: float = DENSIFICATION_ALPHA) -> dict:
|
|
365
|
+
"""Density per layer for the gentlest stop of `energy` (N mm) in this
|
|
366
|
+
envelope: every layer crushes at the same force F (rho_i from
|
|
367
|
+
sigma_pl(rho_i) A_i = F, clipped to the printable range), and F is the
|
|
368
|
+
lowest for which the core can absorb `reserve` x the energy before it
|
|
369
|
+
densifies. Returns the force, the densities, and the curve, with
|
|
370
|
+
`ok` false if no F up to `force_limit` does it."""
|
|
371
|
+
area = np.asarray(area, float)
|
|
372
|
+
a = area[area > 0]
|
|
373
|
+
lo = float(plateau_stress(kind, rho_min, sigma_y) * a.min())
|
|
374
|
+
hi = float(plateau_stress(kind, rho_max, sigma_y) * a.max())
|
|
375
|
+
|
|
376
|
+
def at(force):
|
|
377
|
+
rho = np.clip(density_for_plateau(kind, force / a, sigma_y), rho_min, rho_max)
|
|
378
|
+
return rho, crush_curve(kind, a, rho, h, sigma_y, modulus, alpha)
|
|
379
|
+
|
|
380
|
+
# the target force with the lowest peak (layers clipped at rho_min crush
|
|
381
|
+
# above it, so the lowest target is not always the gentlest) among those
|
|
382
|
+
# that keep `reserve` x the energy in hand before densifying
|
|
383
|
+
best, peak = None, np.inf
|
|
384
|
+
for force in np.geomspace(lo, hi, 300):
|
|
385
|
+
rho, cv = at(force)
|
|
386
|
+
if cv.capacity < reserve * energy:
|
|
387
|
+
continue
|
|
388
|
+
p = impact_response(cv, energy)["peakForce"]
|
|
389
|
+
if p < peak:
|
|
390
|
+
best, peak = (force, rho, cv), p
|
|
391
|
+
ok = best is not None
|
|
392
|
+
if best is None: # take the most capacity there is
|
|
393
|
+
best = max(((f, *at(f)) for f in np.geomspace(lo, hi, 200)), key=lambda t: t[2].capacity)
|
|
394
|
+
force, rho, cv = best
|
|
395
|
+
resp = impact_response(cv, energy)
|
|
396
|
+
if force_limit is not None and (resp["peakForce"] is None or resp["peakForce"] > force_limit):
|
|
397
|
+
ok = False
|
|
398
|
+
full = np.zeros_like(area)
|
|
399
|
+
full[area > 0] = rho
|
|
400
|
+
return {"ok": ok, "force": float(force), "rho": full, "curve": cv, "response": resp,
|
|
401
|
+
"meanRho": float(np.sum(rho * a) / np.sum(a))}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def solid_impact(stiffness: float, stress_per_newton: float, sigma_y: float, energy: float,
|
|
405
|
+
force_limit: float | None = None, mass: float | None = None) -> dict:
|
|
406
|
+
"""The same impact on a solid (elastic) part with stiffness k (N/mm) at the
|
|
407
|
+
impact point and peak von Mises stress s (MPa per N of load): it stops
|
|
408
|
+
the mass at F = sqrt(2 E k) after F/k mm, stores (does not dissipate) the
|
|
409
|
+
energy, and yields if s F > sigma_y. Also the most energy it can take
|
|
410
|
+
under the force limit, F_lim^2 / 2k."""
|
|
411
|
+
fpk = float(np.sqrt(2.0 * energy * stiffness))
|
|
412
|
+
out = {"stiffness": round(float(stiffness), 1), "peakForce": round(fpk, 1),
|
|
413
|
+
"crush": round(fpk / stiffness, 4), "peakStress": round(stress_per_newton * fpk, 1),
|
|
414
|
+
"yields": bool(stress_per_newton * fpk > sigma_y),
|
|
415
|
+
"energyAtYield": round(float((sigma_y / stress_per_newton) ** 2 / (2 * stiffness)), 1),
|
|
416
|
+
"rebound": "elastic: the stored energy comes back"}
|
|
417
|
+
if force_limit is not None:
|
|
418
|
+
out["energyAtForceLimit"] = round(float(force_limit ** 2 / (2 * stiffness)), 2)
|
|
419
|
+
if mass:
|
|
420
|
+
out["peakG"] = round(fpk / (mass * 9.81), 1)
|
|
421
|
+
return out
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---- 3D check -------------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def face_load(mesh, axis: int, total: float, top: bool = True) -> np.ndarray:
|
|
427
|
+
"""A force `total` (N) along `axis` spread evenly over the mesh's
|
|
428
|
+
highest (or lowest) face normal to that axis, each node taking its
|
|
429
|
+
share of the face cells around it (a uniform pressure, consistently)."""
|
|
430
|
+
from .fem import CORNERS
|
|
431
|
+
|
|
432
|
+
k = mesh.elem_ijk[:, axis]
|
|
433
|
+
edge = k.max() if top else k.min()
|
|
434
|
+
corners = np.flatnonzero(CORNERS[:, axis] == (1 if top else 0))
|
|
435
|
+
elems = np.flatnonzero(k == edge)
|
|
436
|
+
f = np.zeros(mesh.n_dof)
|
|
437
|
+
np.add.at(f, 3 * mesh.enode[np.ix_(elems, corners)].ravel() + axis, total / (4.0 * len(elems)))
|
|
438
|
+
return f
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
FOAM_ALPHA = 2.12 # Deshpande & Fleck (2000): crushable foam with zero plastic Poisson ratio
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def foam_stress(stress, alpha: float = FOAM_ALPHA):
|
|
445
|
+
"""Deshpande-Fleck effective stress of a crushable cellular solid:
|
|
446
|
+
sqrt((vm^2 + alpha^2 sm^2) / (1 + (alpha/3)^2)), sm the mean stress.
|
|
447
|
+
Equals the stress in uniaxial compression; unlike von Mises it counts
|
|
448
|
+
pressure, so a laterally confined lattice still crushes (alpha 0 is
|
|
449
|
+
von Mises, a solid)."""
|
|
450
|
+
from .fem import von_mises
|
|
451
|
+
|
|
452
|
+
vm = von_mises(stress)
|
|
453
|
+
sm = stress[:, :3].mean(axis=1)
|
|
454
|
+
return np.sqrt((vm ** 2 + alpha ** 2 * sm ** 2) / (1 + (alpha / 3) ** 2))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def limit_load(mesh, modulus, strength, nu: float, fixed, force, iters: int = 15,
|
|
458
|
+
alpha: float = FOAM_ALPHA) -> dict:
|
|
459
|
+
"""Collapse load factor of a voxel model (per-element modulus and
|
|
460
|
+
strength, MPa; for a homogenized lattice the cell's plateau stress) under
|
|
461
|
+
`force` (N per dof), by the elastic compensation method: lower bound
|
|
462
|
+
min over elements of strength / effective stress per pass, elements
|
|
463
|
+
softened by sigma_ref / sigma_e between passes. The effective stress is
|
|
464
|
+
`foam_stress` (alpha 0 for a solid). lambda < 1: it collapses below the
|
|
465
|
+
applied load. Also returns the elastic stiffness along the load
|
|
466
|
+
(|f|^2 / f.u) of the first pass."""
|
|
467
|
+
from scipy.sparse.linalg import cg
|
|
468
|
+
|
|
469
|
+
from .fem import Stiffness, amg_solver
|
|
470
|
+
|
|
471
|
+
mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,)).copy()
|
|
472
|
+
strength = np.broadcast_to(np.asarray(strength, float), (mesh.n_elem,))
|
|
473
|
+
st = Stiffness(mesh, nu, np.asarray(fixed, bool))
|
|
474
|
+
f = np.where(st.fixed, 0.0, np.asarray(force, float))
|
|
475
|
+
scale = mod.copy()
|
|
476
|
+
hist, best, k0, upper = [], 0.0, None, np.inf
|
|
477
|
+
u = None
|
|
478
|
+
for _ in range(iters):
|
|
479
|
+
k = st.assemble(scale)
|
|
480
|
+
ml = amg_solver(k, symmetry="hermitian", max_coarse=500)
|
|
481
|
+
u, info = cg(k, f, x0=u, rtol=1e-7, maxiter=1500, M=ml.aspreconditioner())
|
|
482
|
+
if k0 is None:
|
|
483
|
+
k0 = float(f @ f / max(f @ u, 1e-30))
|
|
484
|
+
se = foam_stress(st.stress(u, scale), alpha)
|
|
485
|
+
util = se / strength
|
|
486
|
+
lam = 1.0 / float(util.max())
|
|
487
|
+
best = max(best, lam)
|
|
488
|
+
hist.append(lam)
|
|
489
|
+
# Mackenzie & Boyle's upper bound from the same field (kinematic: the
|
|
490
|
+
# strain field is compatible), sum(s sigma / E) / sum(sigma^2 / E)
|
|
491
|
+
ub = float(np.sum(strength * se / scale) / max(np.sum(se ** 2 / scale), 1e-300))
|
|
492
|
+
upper = min(upper, ub)
|
|
493
|
+
ref = float(np.mean(util))
|
|
494
|
+
# softening only, at most 1000x, which keeps the solves well conditioned
|
|
495
|
+
scale = np.clip(scale * np.clip(ref / np.maximum(util, 1e-12), 0.05, 20.0), 1e-3 * mod, mod)
|
|
496
|
+
return {"loadFactor": best, "upperBound": upper, "firstYieldFactor": hist[0], "history": hist,
|
|
497
|
+
"stiffness": k0}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ---- field graphs -------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
def sample_core(graph, envelope: str, density=None, pitch: float = 0.5, bounds=None):
|
|
503
|
+
"""Voxel samples of a field graph's lattice core: (inside, rho, lo, pitch)
|
|
504
|
+
with `inside` the cells whose centres are inside the `envelope` solid
|
|
505
|
+
and rho the `density` field there (a number, a node name, or None for
|
|
506
|
+
solid)."""
|
|
507
|
+
b = graph.bounds(envelope) if bounds is None else bounds
|
|
508
|
+
if b is None:
|
|
509
|
+
raise ValueError(f"node {envelope!r} has no bounds; give the analysis 'bounds'")
|
|
510
|
+
lo, hi = (np.asarray(x, float) for x in b)
|
|
511
|
+
dims = np.maximum(np.ceil((hi - lo) / pitch).astype(int), 1)
|
|
512
|
+
axes = [lo[i] + (np.arange(dims[i]) + 0.5) * pitch for i in range(3)]
|
|
513
|
+
P = np.stack(np.meshgrid(*axes, indexing="ij"), -1).reshape(-1, 3)
|
|
514
|
+
inside = (graph.evaluate(P, envelope) < 0).reshape(tuple(dims))
|
|
515
|
+
if density is None:
|
|
516
|
+
rho = np.ones(inside.shape)
|
|
517
|
+
elif isinstance(density, (int, float)):
|
|
518
|
+
rho = np.full(inside.shape, float(density))
|
|
519
|
+
else:
|
|
520
|
+
rho = np.broadcast_to(np.asarray(graph.evaluate(P, density), float), (len(P),)).reshape(tuple(dims))
|
|
521
|
+
return inside, np.clip(rho, 0.0, 1.0), lo, pitch
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _energy(spec):
|
|
525
|
+
if spec.get("energy") is not None:
|
|
526
|
+
return 1000.0 * float(spec["energy"]) # J -> N mm
|
|
527
|
+
return 1000.0 * 0.5 * float(spec["mass"]) * float(spec["velocity"]) ** 2
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def impact_report(graph, spec: dict, material) -> dict:
|
|
531
|
+
"""The field graph's `impact` block: crush the `envelope` filled with the
|
|
532
|
+
`lattice` at `density` along `axis`, stop the impact on it, and (with
|
|
533
|
+
`grade`) work out the flattest graded core for the same envelope as a
|
|
534
|
+
ready remap table for the density node."""
|
|
535
|
+
kind = spec.get("lattice", "gyroid")
|
|
536
|
+
axis = "xyz".index(spec.get("axis", "z"))
|
|
537
|
+
pitch = float(spec.get("pitch", 0.5))
|
|
538
|
+
inside, rho, lo, h = sample_core(graph, spec["envelope"], spec.get("density"), pitch)
|
|
539
|
+
law, sy = crush_law(kind, material)
|
|
540
|
+
E = float(material.E)
|
|
541
|
+
other = tuple(i for i in range(3) if i != axis)
|
|
542
|
+
area, mean = layer_profile(inside, rho, h, axis)
|
|
543
|
+
lf = (plateau_stress(law, rho, sy) * inside).sum(axis=other) * h ** 2
|
|
544
|
+
energy = _energy(spec)
|
|
545
|
+
mass = spec.get("mass")
|
|
546
|
+
flim = spec.get("forceLimit")
|
|
547
|
+
cv = crush_curve(law, area, mean, h, sy, E, layer_force=lf)
|
|
548
|
+
resp = impact_response(cv, energy, mass)
|
|
549
|
+
rate = None
|
|
550
|
+
if law.endswith(":elastic") and mass and spec.get("rate", True):
|
|
551
|
+
# an elastomer is stiffer in a fast knock than in the card's test: the
|
|
552
|
+
# crush time (constant-force estimate 2 d / v) sets the factor
|
|
553
|
+
from .viscoelastic import creep_params, event_factor
|
|
554
|
+
|
|
555
|
+
if creep_params(material) is not None:
|
|
556
|
+
v = np.sqrt(2.0 * energy * 1e-3 / float(mass)) # m/s
|
|
557
|
+
fac = 1.0
|
|
558
|
+
for _ in range(4):
|
|
559
|
+
t = 2.0 * float(resp["crush"]) * 1e-3 / v
|
|
560
|
+
fac = float(event_factor(material, t))
|
|
561
|
+
cv = crush_curve(law, area, mean, h, sy * fac, E * fac, layer_force=lf * fac)
|
|
562
|
+
resp = impact_response(cv, energy, mass)
|
|
563
|
+
sy, E = sy * fac, E * fac
|
|
564
|
+
rate = {"eventSeconds": round(t, 4), "stiffening": round(fac, 3)}
|
|
565
|
+
core_mass = float((rho * inside).sum() * h ** 3 * material.density / 1000.0)
|
|
566
|
+
out = {"lattice": kind, "law": "elastic buckling" if law.endswith(":elastic") else "plastic collapse",
|
|
567
|
+
"axis": "xyz"[axis], "energy": round(energy, 1), "coreMass": round(core_mass, 2),
|
|
568
|
+
"meanDensity": round(float((rho * inside).sum() / max(inside.sum(), 1)), 3),
|
|
569
|
+
"response": resp, "curve": cv.to_dict()}
|
|
570
|
+
if rate:
|
|
571
|
+
out["rate"] = rate
|
|
572
|
+
if law.endswith(":elastic"):
|
|
573
|
+
# an elastomer core springs back: only its hysteresis is dissipated
|
|
574
|
+
hy = HYSTERESIS.get(material.name, 0.3)
|
|
575
|
+
out["dissipated"] = hy
|
|
576
|
+
out["reboundEnergy"] = round((1 - hy) * energy, 1)
|
|
577
|
+
if flim is not None:
|
|
578
|
+
out["forceLimit"] = float(flim)
|
|
579
|
+
out["ok"] = bool(not resp["bottomedOut"] and resp["peakForce"] <= flim)
|
|
580
|
+
if spec.get("grade"):
|
|
581
|
+
rho_min = float(spec.get("rhoMin", 0.12))
|
|
582
|
+
if spec.get("cell") is not None:
|
|
583
|
+
# the thinnest printable wall sets the lowest density at this cell
|
|
584
|
+
rho_min = max(rho_min, float(relative_density(kind, float(spec.get("minWall", 0.8)),
|
|
585
|
+
float(spec["cell"]))))
|
|
586
|
+
g = grade_for_impact(law, area, h, sy, E, energy, flim, rho_min, float(spec.get("rhoMax", 0.5)))
|
|
587
|
+
z = lo[axis] + (np.arange(len(area)) + 0.5) * h
|
|
588
|
+
keep = area > 0
|
|
589
|
+
zs, rs = z[keep], g["rho"][keep]
|
|
590
|
+
# thin the table to every ~1 mm (the field is linear in between)
|
|
591
|
+
step = max(1, int(round(1.0 / h)))
|
|
592
|
+
idx = np.unique(np.r_[np.arange(0, len(zs), step), len(zs) - 1])
|
|
593
|
+
gm = float((g["rho"] * area).sum() * h * material.density / 1000.0)
|
|
594
|
+
# the fair comparison: a uniform core of the same mass
|
|
595
|
+
cu = crush_curve(law, area, g["meanRho"], h, sy, E)
|
|
596
|
+
out["uniformSameMass"] = {"density": round(g["meanRho"], 4), "response": impact_response(cu, energy, mass),
|
|
597
|
+
"curve": cu.to_dict()}
|
|
598
|
+
out["graded"] = {"ok": g["ok"], "plateauForce": round(g["force"], 1), "coreMass": round(gm, 2),
|
|
599
|
+
"rhoMin": round(rho_min, 4),
|
|
600
|
+
"response": impact_response(g["curve"], energy, mass), "curve": g["curve"].to_dict(),
|
|
601
|
+
"density": {"op": "remap", "input": {"op": "coordinate", "axis": "xyz"[axis]},
|
|
602
|
+
"from": [round(float(x), 3) for x in zs[idx]],
|
|
603
|
+
"to": [round(float(x), 4) for x in rs[idx]]}}
|
|
604
|
+
return out
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def grade_3d(inside, lo, h: float, kind: str, material, force: float, rho0=None, rho_min: float = 0.12,
|
|
608
|
+
rho_max: float = 0.5, axis: int = 2, iters: int = 16, alpha: float = FOAM_ALPHA,
|
|
609
|
+
keep_layers: bool = True, voids: bool = True, void_below: float = 0.5) -> dict:
|
|
610
|
+
"""Density per cell, not just per layer, for a core that crushes at
|
|
611
|
+
`force` (N, pressed evenly on the top face along `axis`, foot held).
|
|
612
|
+
|
|
613
|
+
A layer-only grading (grade_for_impact) gives every layer the right
|
|
614
|
+
total strength, but in a taper the load from a narrow layer stays in a
|
|
615
|
+
column and reaches the middle of the wider layers below before their
|
|
616
|
+
rims: the middle crushes early and the rims idle. Here each pass solves
|
|
617
|
+
the homogenized core elastically and, within each layer, shares the
|
|
618
|
+
layer's crush force out in proportion to where the (crushable-foam)
|
|
619
|
+
stress goes, so each layer still crushes at `force` (`keep_layers`).
|
|
620
|
+
Cells that would need less than `void_below` x rho_min become empty
|
|
621
|
+
(`voids`): the idle rim is removed and its material moves to the loaded
|
|
622
|
+
middle, which is why the result is lighter as well. Damped, as the load
|
|
623
|
+
path moves with the densities.
|
|
624
|
+
|
|
625
|
+
inside: bool voxel grid (cell centres at lo + (ijk + 0.5) h); rho0: the
|
|
626
|
+
starting density per voxel (default rho_min). Returns the density grid
|
|
627
|
+
(0 = empty; outside cells carry the nearest inside value so a field
|
|
628
|
+
graph can sample it anywhere), the worst utilization per pass and the
|
|
629
|
+
core mass. In a graph, a grid node of it drives the lattice density and
|
|
630
|
+
an expr `0.1 - density` intersected with the envelope cuts the empty
|
|
631
|
+
cells (see examples/impact/end_stop.py)."""
|
|
632
|
+
from scipy import ndimage
|
|
633
|
+
from scipy.sparse.linalg import cg
|
|
634
|
+
|
|
635
|
+
from .fem import HexMesh, Stiffness, amg_solver
|
|
636
|
+
from .lattice import sheet_modulus
|
|
637
|
+
|
|
638
|
+
kind, sy = crush_law(_base(kind), material)
|
|
639
|
+
E = float(material.E)
|
|
640
|
+
ijk = np.argwhere(inside)
|
|
641
|
+
mesh = HexMesh(ijk, h, lo)
|
|
642
|
+
rho = (np.full(len(ijk), rho_min) if rho0 is None
|
|
643
|
+
else np.clip(np.broadcast_to(rho0, inside.shape)[inside], rho_min, rho_max)).astype(float)
|
|
644
|
+
x = mesh.node_xyz[:, axis]
|
|
645
|
+
fixed = np.zeros(mesh.n_dof, bool)
|
|
646
|
+
foot = np.flatnonzero(x < x.min() + 1e-6)
|
|
647
|
+
fixed[3 * foot + axis] = True
|
|
648
|
+
others = [a for a in range(3) if a != axis]
|
|
649
|
+
fixed[3 * foot[0] + others[0]] = fixed[3 * foot[0] + others[1]] = True # hold it still sideways
|
|
650
|
+
fixed[3 * foot[-1] + others[1]] = True
|
|
651
|
+
f = face_load(mesh, axis, -force)
|
|
652
|
+
st = Stiffness(mesh, float(material.nu), fixed)
|
|
653
|
+
f = np.where(st.fixed, 0.0, f)
|
|
654
|
+
u, hist = None, []
|
|
655
|
+
for _ in range(iters):
|
|
656
|
+
mod = np.where(rho > 0, E * sheet_modulus(_base(kind), np.maximum(rho, 1e-3)), 1e-6 * E)
|
|
657
|
+
k = st.assemble(mod)
|
|
658
|
+
u, _info = cg(k, f, x0=u, rtol=1e-7, maxiter=1500,
|
|
659
|
+
M=amg_solver(k, symmetry="hermitian", max_coarse=500).aspreconditioner())
|
|
660
|
+
se = foam_stress(st.stress(u, mod), alpha)
|
|
661
|
+
util = np.where(rho > 0, se / plateau_stress(kind, np.maximum(rho, 1e-3), sy), 0.0)
|
|
662
|
+
hist.append(float(util.max()))
|
|
663
|
+
if keep_layers:
|
|
664
|
+
# within each layer, share the layer's crush force out in proportion
|
|
665
|
+
# to where the stress goes: scale c per layer so sum sigma_pl A = force
|
|
666
|
+
lay = ijk[:, axis]
|
|
667
|
+
nl = lay.max() + 1
|
|
668
|
+
lo_c, hi_c = np.full(nl, 1e-3), np.full(nl, 1e3)
|
|
669
|
+
for _b in range(40):
|
|
670
|
+
c = np.sqrt(lo_c * hi_c)
|
|
671
|
+
t = np.clip(density_for_plateau(kind, c[lay] * se, sy), rho_min, rho_max)
|
|
672
|
+
if voids:
|
|
673
|
+
t = np.where(density_for_plateau(kind, c[lay] * se, sy) < void_below * rho_min, 0.0, t)
|
|
674
|
+
tot = np.bincount(lay, plateau_stress(kind, t, sy) * h ** 2, nl)
|
|
675
|
+
over = tot > force
|
|
676
|
+
hi_c = np.where(over, c, hi_c)
|
|
677
|
+
lo_c = np.where(over, lo_c, c)
|
|
678
|
+
target = t
|
|
679
|
+
else:
|
|
680
|
+
target = np.clip(density_for_plateau(kind, se, sy), rho_min, rho_max)
|
|
681
|
+
rho = np.where(target == 0.0, 0.0, np.sqrt(np.maximum(rho, rho_min) * np.maximum(target, rho_min)))
|
|
682
|
+
grid = np.zeros(inside.shape)
|
|
683
|
+
grid[inside] = rho
|
|
684
|
+
idx = ndimage.distance_transform_edt(~inside, return_distances=False, return_indices=True)
|
|
685
|
+
return {"rho": grid[tuple(idx)], "inside": inside, "history": hist, "utilization": util,
|
|
686
|
+
"mass": float(rho.sum() * h ** 3 * material.density / 1000.0)}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def save_density_grid(path: str, rho, lo, h: float) -> None:
|
|
690
|
+
"""A density grid for a field graph's `grid` node: values at the voxel
|
|
691
|
+
centres lo + (ijk + 0.5) h."""
|
|
692
|
+
np.savez_compressed(path, values=np.asarray(rho, np.float32), origin=np.asarray(lo, float) + 0.5 * h,
|
|
693
|
+
pitch=float(h))
|