loadpath 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
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