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/durability.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Durability: fatigue under repeated loads, creep and stress relaxation under
|
|
3
|
+
sustained ones, for printed parts (mostly plastics).
|
|
4
|
+
|
|
5
|
+
The static check (verify.py) asks whether a part survives its load once. A
|
|
6
|
+
robot arm's printed links see the same servo load hundreds of thousands of
|
|
7
|
+
times, and its bolted bosses hold a clamp load for years, sometimes warm.
|
|
8
|
+
Plastics are poor at both: fatigue strength at a million cycles is a small
|
|
9
|
+
fraction of the tensile strength, and a polymer under a steady load keeps
|
|
10
|
+
straining (creep) or, held at fixed strain by a bolt, loses its stress
|
|
11
|
+
(relaxation). This module turns the verified stresses into those readings.
|
|
12
|
+
|
|
13
|
+
A load case opts in with `cycles` (how many times it is applied over the
|
|
14
|
+
part's life: fatigue) and/or `hours` (how long it is held: creep), and
|
|
15
|
+
`temperature` (C) if it differs from the study's `durability.temperature`.
|
|
16
|
+
Fasteners' preload cases are held for `durability.preloadHours`.
|
|
17
|
+
|
|
18
|
+
Fatigue. Ezeh and Susmel (2018, 2019) re-analysed every published fatigue
|
|
19
|
+
dataset on FDM PLA with their own tests across raster angles and load
|
|
20
|
+
ratios R = -1..0.3 and found that (a) fatigue strength scales with the
|
|
21
|
+
static tensile strength, (b) the raster angle matters little for parts
|
|
22
|
+
printed flat, and (c) Goodman's mean-stress correction is unconservative
|
|
23
|
+
for printed PLA, while the maximum stress in the cycle captures the mean
|
|
24
|
+
stress effect on its own. Their design curve (survival probability > 90%,
|
|
25
|
+
100% infill) is
|
|
26
|
+
|
|
27
|
+
sigma_max(N) = 0.10 * UTS * (N / 2e6) ** (-1 / 5.5)
|
|
28
|
+
|
|
29
|
+
so a load case's life is N = 2e6 * (0.10 UTS / sigma_max) ** 5.5, and
|
|
30
|
+
several cyclic cases add up by Miner's rule. The fatigue safety factor is
|
|
31
|
+
the factor on every cyclic load that brings the Miner sum to 1: D ** (-1/k).
|
|
32
|
+
(The peaks of different cases can sit in different places; summing them is
|
|
33
|
+
conservative.) Across layers the same curve is scaled by the process's
|
|
34
|
+
bond ratio (process.interlayer) and read against the tension normal to the
|
|
35
|
+
layers, as the static bond check does.
|
|
36
|
+
|
|
37
|
+
No comparable design curve was found for printed PETG, ABS, ASA or TPU, so
|
|
38
|
+
those use the PLA curve normalized by their own strength (the
|
|
39
|
+
strength-proportional result above), flagged "assumed". SLS/MJF PA12/PA11
|
|
40
|
+
use Van Hooreweder and Kruth (CIRP Annals 2014): every specimen survived
|
|
41
|
+
18 MPa amplitude at 1e6 cycles, R = -1 (UTS ~48 MPa); taken as 0.30 UTS at
|
|
42
|
+
2e6 cycles with a slope of 10 after a scatter allowance. Metals get rough
|
|
43
|
+
as-built-surface values (machining or HIP raises them 2-3x).
|
|
44
|
+
|
|
45
|
+
Creep. Strain under a held stress grows as a power of time (Findley):
|
|
46
|
+
eps(t) = eps0 * (1 + phi(t)), phi(t) = phi_1000 * (t / 1000 h) ** n, with
|
|
47
|
+
phi_1000 the creep-to-elastic strain ratio after 1000 h at 23 C, read off
|
|
48
|
+
typical design-data creep moduli (E_creep(1000 h) / E ~ 0.65-0.75 for
|
|
49
|
+
amorphous FDM plastics, ~0.5 for nylons and resins). Warmer, a polymer
|
|
50
|
+
creeps faster as it softens: phi is divided by the modulus retention r(T)
|
|
51
|
+
(thermal.MODULUS_RETENTION) and the elastic strain too, so a held
|
|
52
|
+
deflection grows by (1 + phi / r) / r. That is a coarse stand-in for
|
|
53
|
+
time-temperature superposition and is least trustworthy near the glass
|
|
54
|
+
transition, where both readings run away together, as real parts do.
|
|
55
|
+
|
|
56
|
+
Creep rupture: a held stress breaks a polymer eventually. Its long-term
|
|
57
|
+
strength falls roughly linearly in log time from the short test (~6 min):
|
|
58
|
+
S_r(t) = UTS * r_s(T) * max(0.25, 1 - 0.10 * log10(t / 0.1 h)), which is
|
|
59
|
+
~60% of UTS at 1000 h and ~40% at ten years, typical of amorphous
|
|
60
|
+
thermoplastics. Strength retention with temperature is taken as the
|
|
61
|
+
modulus retention: conservative below Tg (strength falls less than
|
|
62
|
+
modulus there), right at it.
|
|
63
|
+
|
|
64
|
+
Bolted joints: a screw clamping a plastic part holds it at nearly fixed
|
|
65
|
+
strain, so the clamp load relaxes with the relaxation modulus: preload
|
|
66
|
+
retention ~ r(T) / (1 + phi(t, T)). Below about half, the joint will need
|
|
67
|
+
a compression limiter (a metal sleeve the screw clamps on), a heat-set
|
|
68
|
+
insert clamping metal to metal, a spring washer, or retightening.
|
|
69
|
+
|
|
70
|
+
Thermal cycling: a study whose thermal block has a duty cycle (thermal.
|
|
71
|
+
Cycle, with `cycles`) heats up and cools down that many times. verify_part
|
|
72
|
+
solves the cycle's hot and cold moments and, per node, takes the larger
|
|
73
|
+
von Mises stress over strength retention (so it reads against room-
|
|
74
|
+
temperature strength) as the cycle's maximum; where the stress barely moves
|
|
75
|
+
over the cycle (R above ~0.3, where the max-stress reading stops holding
|
|
76
|
+
and the load is closer to creep), the range over 0.7 counts instead. The
|
|
77
|
+
worst load case's peak of that, times `cycles`, joins the in-plane Miner
|
|
78
|
+
sum. An estimate on top of an estimate: no printed-polymer thermal-fatigue
|
|
79
|
+
data were found, so it is the mechanical design curve at the thermal
|
|
80
|
+
stress.
|
|
81
|
+
|
|
82
|
+
Everything here is an estimate to compare designs and catch parts that will
|
|
83
|
+
not last, not an allowable: printed fatigue scatters by a factor of 3 in
|
|
84
|
+
life even within one study, creep data for printed parts is sparse, and
|
|
85
|
+
both depend on infill, moisture (nylons), frequency (self-heating above
|
|
86
|
+
~5 Hz) and print settings. Test the part that matters.
|
|
87
|
+
"""
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
from dataclasses import dataclass, field
|
|
91
|
+
|
|
92
|
+
import numpy as np
|
|
93
|
+
|
|
94
|
+
__all__ = ["DurabilityData", "DURABILITY", "Durability", "durability_from_spec", "durability_data",
|
|
95
|
+
"strength_retention", "fatigue_strength", "cycles_to_failure", "creep_ratio", "rupture_strength",
|
|
96
|
+
"assess"]
|
|
97
|
+
|
|
98
|
+
N_REF = 2e6 # cycles at which fatigue strength is quoted
|
|
99
|
+
T_REF = 23.0 # C
|
|
100
|
+
RUPTURE_T0 = 0.1 # h: the duration of a short-term tensile test
|
|
101
|
+
RUPTURE_FLOOR = 0.25 # long-term strength never read below this share of UTS
|
|
102
|
+
|
|
103
|
+
_EZEH = "Ezeh & Susmel, Int J Fatigue 2019 (FDM PLA design curve, Ps > 90%)"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class DurabilityData:
|
|
108
|
+
fatigue_ratio: float # max stress / strength survived for N_REF cycles
|
|
109
|
+
fatigue_slope: float # k: life goes as stress ** -k
|
|
110
|
+
creep_ratio: float # creep / elastic strain after 1000 h at 23 C (phi_1000)
|
|
111
|
+
creep_exponent: float # n in phi ~ t ** n
|
|
112
|
+
rupture_per_decade: float # long-term strength lost per decade of time, share of UTS
|
|
113
|
+
source: str
|
|
114
|
+
quality: str # "measured", "assumed" or "rough"
|
|
115
|
+
|
|
116
|
+
def to_dict(self):
|
|
117
|
+
return {"fatigueStrength": self.fatigue_ratio, "fatigueSlope": self.fatigue_slope,
|
|
118
|
+
"creepRatio": self.creep_ratio, "creepExponent": self.creep_exponent,
|
|
119
|
+
"rupturePerDecade": self.rupture_per_decade, "source": self.source, "quality": self.quality}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_PLA_CURVE = (0.10, 5.5)
|
|
123
|
+
DURABILITY = {
|
|
124
|
+
"pla": DurabilityData(*_PLA_CURVE, 0.30, 0.20, 0.10, _EZEH, "measured"),
|
|
125
|
+
"petg": DurabilityData(*_PLA_CURVE, 0.40, 0.20, 0.10,
|
|
126
|
+
"PLA design curve scaled by strength (no printed PETG design curve found); "
|
|
127
|
+
"creep from copolyester creep-modulus data", "assumed"),
|
|
128
|
+
"abs": DurabilityData(*_PLA_CURVE, 0.45, 0.20, 0.10,
|
|
129
|
+
"PLA design curve scaled by strength; creep from ABS creep-modulus data", "assumed"),
|
|
130
|
+
"asa": DurabilityData(*_PLA_CURVE, 0.45, 0.20, 0.10,
|
|
131
|
+
"PLA design curve scaled by strength; creep as ABS", "assumed"),
|
|
132
|
+
"tpu": DurabilityData(0.15, 8.0, 1.00, 0.15, 0.10,
|
|
133
|
+
"engineering estimate: elastomer fatigue is strain-governed and no printed-TPU "
|
|
134
|
+
"S-N data was found; verify with nonlinear analysis and a test", "rough"),
|
|
135
|
+
"pa-cf": DurabilityData(0.15, 8.0, 0.50, 0.20, 0.10,
|
|
136
|
+
"estimate between FDM PLA and filled nylon; moisture lowers both", "rough"),
|
|
137
|
+
"pp": DurabilityData(0.20, 8.0, 1.00, 0.20, 0.10,
|
|
138
|
+
"rough estimate: PP fatigues well but creeps fast at room temperature", "rough"),
|
|
139
|
+
"pa12": DurabilityData(0.30, 10.0, 0.80, 0.20, 0.10,
|
|
140
|
+
"Van Hooreweder & Kruth, CIRP Annals 2014 (SLS PA12, 18 MPa at 1e6, R=-1); "
|
|
141
|
+
"creep from PA12 creep-modulus data, dry", "measured"),
|
|
142
|
+
"pa11": DurabilityData(0.30, 10.0, 0.80, 0.20, 0.10, "as PA12", "assumed"),
|
|
143
|
+
"resin": DurabilityData(0.08, 5.0, 1.00, 0.25, 0.12,
|
|
144
|
+
"estimate: standard SLA resins are brittle and creep at room temperature", "rough"),
|
|
145
|
+
"resin-tough": DurabilityData(0.10, 6.0, 1.20, 0.25, 0.12, "estimate, as standard resin", "rough"),
|
|
146
|
+
# metals, against `strength` (yield): as-built surfaces, no creep at these temperatures
|
|
147
|
+
"alsi10mg": DurabilityData(0.30, 6.0, 0.0, 0.2, 0.0,
|
|
148
|
+
"rough: LPBF AlSi10Mg as built, ~60-70 MPa at 1e7 R=-1", "rough"),
|
|
149
|
+
"ti64": DurabilityData(0.28, 6.0, 0.0, 0.2, 0.0, "rough: LPBF Ti-6Al-4V as built", "rough"),
|
|
150
|
+
"316l": DurabilityData(0.40, 8.0, 0.0, 0.2, 0.0, "rough: LPBF 316L as built", "rough"),
|
|
151
|
+
}
|
|
152
|
+
_GENERIC = DurabilityData(*_PLA_CURVE, 0.50, 0.20, 0.10,
|
|
153
|
+
"no data for this material: generic FDM thermoplastic (PLA curve, ABS-like creep)",
|
|
154
|
+
"assumed")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Durability:
|
|
159
|
+
temperature: float = T_REF
|
|
160
|
+
safety_factor: float = 1.5
|
|
161
|
+
# True: the safety-factor search sizes the part for fatigue and creep too
|
|
162
|
+
constrain: bool = False
|
|
163
|
+
preload_hours: float = 8760.0
|
|
164
|
+
overrides: dict = field(default_factory=dict)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_OVERRIDES = {"fatigueStrength": "fatigue_ratio", "fatigueSlope": "fatigue_slope",
|
|
168
|
+
"creepRatio": "creep_ratio", "creepExponent": "creep_exponent"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def durability_from_spec(spec) -> Durability | None:
|
|
172
|
+
if spec is None:
|
|
173
|
+
return None
|
|
174
|
+
if not isinstance(spec, dict):
|
|
175
|
+
raise ValueError("durability must be an object: {temperature, safetyFactor, constrain, preloadHours}")
|
|
176
|
+
t = float(spec.get("temperature", T_REF))
|
|
177
|
+
if not -60.0 <= t <= 400.0:
|
|
178
|
+
raise ValueError("durability.temperature is the service temperature in C")
|
|
179
|
+
sf = float(spec.get("safetyFactor", 1.5))
|
|
180
|
+
if sf <= 0:
|
|
181
|
+
raise ValueError("durability.safetyFactor must be positive")
|
|
182
|
+
ph = float(spec.get("preloadHours", 8760.0))
|
|
183
|
+
if ph <= 0:
|
|
184
|
+
raise ValueError("durability.preloadHours must be positive (h)")
|
|
185
|
+
over = {}
|
|
186
|
+
for key, attr in _OVERRIDES.items():
|
|
187
|
+
if spec.get(key) is not None:
|
|
188
|
+
v = float(spec[key])
|
|
189
|
+
if v < 0 or (key in ("fatigueStrength", "fatigueSlope", "creepExponent") and v <= 0):
|
|
190
|
+
raise ValueError(f"durability.{key} must be positive")
|
|
191
|
+
over[attr] = v
|
|
192
|
+
return Durability(t, sf, bool(spec.get("constrain", False)), ph, over)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _key(material) -> str:
|
|
196
|
+
from .thermal import _material_key
|
|
197
|
+
|
|
198
|
+
return _material_key(material)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def durability_data(material, overrides: dict | None = None) -> DurabilityData:
|
|
202
|
+
import dataclasses
|
|
203
|
+
|
|
204
|
+
key = _key(material)
|
|
205
|
+
base = DURABILITY.get(key)
|
|
206
|
+
if base is None and key.split("-")[0] in DURABILITY:
|
|
207
|
+
# a filled grade (pa12-gf, pa12-gb) without its own curve: the base
|
|
208
|
+
# polymer's (fillers stiffen it and shift its fatigue a little either
|
|
209
|
+
# way; far closer than the generic FDM curve)
|
|
210
|
+
root = key.split("-")[0]
|
|
211
|
+
base = dataclasses.replace(DURABILITY[root], source=f"as {root} (no curve for the filled grade {key}): "
|
|
212
|
+
+ DURABILITY[root].source, quality="assumed")
|
|
213
|
+
base = base or _GENERIC
|
|
214
|
+
if overrides:
|
|
215
|
+
base = dataclasses.replace(base, **overrides, source=base.source + " (overridden in the study)",
|
|
216
|
+
quality="study")
|
|
217
|
+
return base
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def strength_retention(material, t: float) -> float | None:
|
|
221
|
+
"""Share of room-temperature modulus (and, as a proxy, strength) kept at
|
|
222
|
+
t (C), or None when the material has no table."""
|
|
223
|
+
from .thermal import MODULUS_RETENTION, retention
|
|
224
|
+
|
|
225
|
+
if _key(material) not in MODULUS_RETENTION:
|
|
226
|
+
return None
|
|
227
|
+
return float(retention(material, np.array([t]))[0][0])
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def fatigue_strength(data: DurabilityData, strength: float, cycles: float) -> float:
|
|
231
|
+
"""Max stress survived for `cycles` (MPa), never above `strength`."""
|
|
232
|
+
s = data.fatigue_ratio * strength * (max(cycles, 1.0) / N_REF) ** (-1.0 / data.fatigue_slope)
|
|
233
|
+
return float(min(s, strength))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cycles_to_failure(data: DurabilityData, strength: float, sigma_max: float) -> float:
|
|
237
|
+
if sigma_max <= 0:
|
|
238
|
+
return float("inf")
|
|
239
|
+
if sigma_max >= strength:
|
|
240
|
+
return 1.0
|
|
241
|
+
return float(N_REF * (data.fatigue_ratio * strength / sigma_max) ** data.fatigue_slope)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def creep_ratio(data: DurabilityData, hours: float, retention: float = 1.0) -> float:
|
|
245
|
+
"""phi: creep strain over elastic strain after `hours`, at a temperature
|
|
246
|
+
where the modulus is `retention` of its room value."""
|
|
247
|
+
if data.creep_ratio <= 0 or hours <= 0:
|
|
248
|
+
return 0.0
|
|
249
|
+
return float(data.creep_ratio * (hours / 1000.0) ** data.creep_exponent / max(retention, 0.02))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def rupture_strength(data: DurabilityData, strength: float, hours: float) -> float:
|
|
253
|
+
"""Stress held for `hours` before creep rupture, MPa (strength already at temperature)."""
|
|
254
|
+
if hours <= RUPTURE_T0 or data.rupture_per_decade <= 0:
|
|
255
|
+
return float(strength)
|
|
256
|
+
f = max(RUPTURE_FLOOR, 1.0 - data.rupture_per_decade * np.log10(hours / RUPTURE_T0))
|
|
257
|
+
return float(strength * f)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _thermal_cycles(study):
|
|
261
|
+
th = getattr(study, "thermal", None)
|
|
262
|
+
return th.cycle.cycles if th is not None and th.cycle is not None and th.cycle.cycles else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def active(study) -> bool:
|
|
266
|
+
return study.durability is not None or any(c.cycles or c.hours for c in study.load_cases) \
|
|
267
|
+
or _thermal_cycles(study) is not None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _hdt(mat):
|
|
271
|
+
"""The library card's heat deflection temperature at 0.45 MPa, if any."""
|
|
272
|
+
from .material_library import value
|
|
273
|
+
return value(_key(mat), "hdt")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def assess(study, cases: list, allowance: float = 1.0) -> dict:
|
|
277
|
+
"""The durability block of a verification report. `cases` are the
|
|
278
|
+
report's per-case dicts (peakStress, interlayerStress, deflection) in
|
|
279
|
+
load-case order; stresses are multiplied by `allowance` (the same
|
|
280
|
+
discretization allowance the static safety factor uses)."""
|
|
281
|
+
dur = study.durability or Durability()
|
|
282
|
+
mat = study.material
|
|
283
|
+
data = durability_data(mat, dur.overrides)
|
|
284
|
+
proc = study.process
|
|
285
|
+
inter = proc.interlayer if proc is not None and proc.build not in (None, "auto") else None
|
|
286
|
+
out = {"temperature": dur.temperature, "target": dur.safety_factor, "constrain": dur.constrain,
|
|
287
|
+
"material": mat.name, "data": data.to_dict(), "cases": [], "warnings": []}
|
|
288
|
+
|
|
289
|
+
def ret(t):
|
|
290
|
+
r = strength_retention(mat, t)
|
|
291
|
+
if r is None:
|
|
292
|
+
if abs(t - T_REF) > 5:
|
|
293
|
+
out["warnings"].append(f"no temperature data for {mat.name}: read at room-temperature properties")
|
|
294
|
+
return 1.0
|
|
295
|
+
return r
|
|
296
|
+
|
|
297
|
+
damage = {"inPlane": 0.0, "acrossLayers": 0.0}
|
|
298
|
+
# a load of unknown in-plane direction expands into one case per
|
|
299
|
+
# direction (name@angle, spec._expand_planar), each carrying the case's
|
|
300
|
+
# cycles: the cycles happen once, in some direction, so the group adds
|
|
301
|
+
# its worst direction's damage, not the sum (16 directions read PETG's
|
|
302
|
+
# fatigue SF 1.66x low)
|
|
303
|
+
group = {}
|
|
304
|
+
creep_sf = np.inf
|
|
305
|
+
for lc, c in zip(study.load_cases, cases):
|
|
306
|
+
hours = lc.hours
|
|
307
|
+
if hours is None and lc.name == "preload" and study.joints and study.durability is not None:
|
|
308
|
+
hours = dur.preload_hours
|
|
309
|
+
if not lc.cycles and not hours:
|
|
310
|
+
continue
|
|
311
|
+
t = lc.temperature if lc.temperature is not None else dur.temperature
|
|
312
|
+
# a study with heat has solved the temperature at the peak itself
|
|
313
|
+
r = c["strengthRetention"] if c.get("strengthRetention") is not None else ret(t)
|
|
314
|
+
if c.get("peakTemperature") is not None:
|
|
315
|
+
t = c["peakTemperature"]
|
|
316
|
+
hdt = _hdt(mat)
|
|
317
|
+
if hdt is not None and t >= hdt:
|
|
318
|
+
msg = (f"{lc.name}: {t:g} C is at or above {mat.name}'s heat deflection temperature ({hdt:g} C at "
|
|
319
|
+
"0.45 MPa, material_library.py): the part will sag; use a material rated hotter")
|
|
320
|
+
if msg not in out["warnings"]:
|
|
321
|
+
out["warnings"].append(msg)
|
|
322
|
+
s_t = mat.strength * r
|
|
323
|
+
peak = c["peakStress"] * allowance
|
|
324
|
+
tens = (c.get("interlayerStress") or 0.0) * allowance if inter is not None else 0.0
|
|
325
|
+
row = {"name": lc.name, "temperature": t, "strengthAtTemperature": s_t}
|
|
326
|
+
if lc.cycles:
|
|
327
|
+
life = cycles_to_failure(data, s_t, peak)
|
|
328
|
+
row.update(cycles=lc.cycles, fatigueStrength=fatigue_strength(data, s_t, lc.cycles), life=life,
|
|
329
|
+
damage=lc.cycles / life)
|
|
330
|
+
g = group.setdefault(str(lc.name).split("@")[0], {"inPlane": 0.0, "acrossLayers": 0.0})
|
|
331
|
+
g["inPlane"] = max(g["inPlane"], lc.cycles / life)
|
|
332
|
+
if tens > 0:
|
|
333
|
+
life_i = cycles_to_failure(data, inter * s_t, tens)
|
|
334
|
+
row.update(interlayerLife=life_i, interlayerDamage=lc.cycles / life_i)
|
|
335
|
+
g["acrossLayers"] = max(g["acrossLayers"], lc.cycles / life_i)
|
|
336
|
+
if hours:
|
|
337
|
+
phi = creep_ratio(data, hours, r)
|
|
338
|
+
s_r = rupture_strength(data, s_t, hours)
|
|
339
|
+
sf = s_r / peak if peak > 0 else np.inf
|
|
340
|
+
if tens > 0:
|
|
341
|
+
sf = min(sf, inter * s_r / tens)
|
|
342
|
+
creep_sf = min(creep_sf, sf)
|
|
343
|
+
row.update(hours=hours, creepRatio=phi, creepDeflection=c["deflection"] * (1.0 + phi) / r,
|
|
344
|
+
ruptureStrength=s_r, creepSafetyFactor=sf)
|
|
345
|
+
if sf < 2.0 and data.creep_ratio > 0:
|
|
346
|
+
out["warnings"].append(
|
|
347
|
+
f"{lc.name}: held at {1 / sf:.0%} of its {hours:g} h rupture strength; creep there is "
|
|
348
|
+
"nonlinear and faster than the estimate")
|
|
349
|
+
out["cases"].append(row)
|
|
350
|
+
|
|
351
|
+
for g in group.values():
|
|
352
|
+
for where in damage:
|
|
353
|
+
damage[where] += g[where]
|
|
354
|
+
n_th = _thermal_cycles(study)
|
|
355
|
+
if n_th is not None and any("cycleStress" in c for c in cases):
|
|
356
|
+
# heating up and cooling down (thermal.Cycle): the worst load case's
|
|
357
|
+
# equivalent cycle stress (verify_part, over strength retention, so
|
|
358
|
+
# read against room-temperature strength), n_th times
|
|
359
|
+
worst = max((c for c in cases if "cycleStress" in c), key=lambda c: c["cycleStress"])
|
|
360
|
+
sig = worst["cycleStress"] * allowance
|
|
361
|
+
life = cycles_to_failure(data, mat.strength, sig)
|
|
362
|
+
damage["inPlane"] += n_th / life
|
|
363
|
+
out["thermalCycling"] = {"cycles": n_th, "case": worst["name"], "stress": sig,
|
|
364
|
+
"stressRange": worst["cycleStressRange"] * allowance, "life": life,
|
|
365
|
+
"damage": n_th / life,
|
|
366
|
+
"fatigueStrength": fatigue_strength(data, mat.strength, n_th)}
|
|
367
|
+
|
|
368
|
+
k = data.fatigue_slope
|
|
369
|
+
fat_sf = np.inf
|
|
370
|
+
for where, d in damage.items():
|
|
371
|
+
if d > 0:
|
|
372
|
+
fat_sf = min(fat_sf, d ** (-1.0 / k))
|
|
373
|
+
out["fatigueDamage"] = max(damage.values())
|
|
374
|
+
out["fatigueSafetyFactor"] = fat_sf if np.isfinite(fat_sf) else None
|
|
375
|
+
out["creepSafetyFactor"] = creep_sf if np.isfinite(creep_sf) else None
|
|
376
|
+
sfs = [v for v in (fat_sf, creep_sf) if np.isfinite(v)]
|
|
377
|
+
out["safetyFactor"] = min(sfs) if sfs else None
|
|
378
|
+
if damage["acrossLayers"] > damage["inPlane"] and damage["acrossLayers"] > 0:
|
|
379
|
+
out["fatigueLimitedBy"] = "layer bond"
|
|
380
|
+
elif damage["inPlane"] > 0:
|
|
381
|
+
out["fatigueLimitedBy"] = "in-plane"
|
|
382
|
+
|
|
383
|
+
if study.joints and study.durability is not None:
|
|
384
|
+
t = dur.temperature
|
|
385
|
+
r = ret(t)
|
|
386
|
+
phi = creep_ratio(data, dur.preload_hours, r)
|
|
387
|
+
s_r = rupture_strength(data, mat.strength * r, dur.preload_hours)
|
|
388
|
+
joints, relaxed = [], []
|
|
389
|
+
for j in study.joints:
|
|
390
|
+
bearing = j["preload"] / j["area"] if j["area"] > 0 else 0.0
|
|
391
|
+
keep = r / (1.0 + phi)
|
|
392
|
+
row = {"id": j["id"], "preload": j["preload"], "bearingStress": bearing,
|
|
393
|
+
"preloadRetention": keep, "bearingSafetyFactor": s_r / bearing if bearing > 0 else None,
|
|
394
|
+
"hours": dur.preload_hours, "temperature": t}
|
|
395
|
+
joints.append(row)
|
|
396
|
+
if keep < 0.5 and data.creep_ratio > 0:
|
|
397
|
+
relaxed.append(j["id"])
|
|
398
|
+
if bearing > 0 and s_r / bearing < 1.0:
|
|
399
|
+
out["warnings"].append(f"fastener {j['id']}: the head's bearing stress {bearing:.1f} MPa is above "
|
|
400
|
+
f"the {dur.preload_hours:g} h creep strength {s_r:.1f} MPa; use a washer "
|
|
401
|
+
"or lower the preload")
|
|
402
|
+
if relaxed:
|
|
403
|
+
# the same for every screw in one material: one line, not one per screw
|
|
404
|
+
keep = r / (1.0 + phi)
|
|
405
|
+
who = f"fastener {relaxed[0]}" if len(relaxed) == 1 else f"fasteners {', '.join(relaxed)}"
|
|
406
|
+
out["warnings"].append(
|
|
407
|
+
f"{who}: about {keep:.0%} of the clamp load is left after {dur.preload_hours:g} h "
|
|
408
|
+
f"at {t:g} C; clamp on a metal compression limiter or insert, or add a spring washer")
|
|
409
|
+
out["joints"] = joints
|
|
410
|
+
|
|
411
|
+
if data.quality in ("assumed", "rough"):
|
|
412
|
+
out["warnings"].append(f"{mat.name} fatigue/creep data is {data.quality}: {data.source}")
|
|
413
|
+
return out
|