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
@@ -0,0 +1,885 @@
1
+ """
2
+ Physical calibration: coupons to print, a measurement format, and a fit that
3
+ turns measured numbers into a calibrated material card.
4
+
5
+ Every number loadpath reports rests on a datasheet: Young's modulus,
6
+ strength, density, and the process's across-layer factors (spec.PROCESSES).
7
+ A printer, a filament and a slicer profile move all of them, so this closes
8
+ the loop with a handful of coupons that need no lab: a kitchen scale,
9
+ weights you can hang (water bottles), a ruler or a dial indicator, and a
10
+ luggage scale for the break tests.
11
+
12
+ kit = coupon_kit("petg") # meshes + predictions per coupon
13
+ write_kit(kit, "calibration/") # STLs, predictions.json, measurements template
14
+ card = fit(json.load(open("measurements.json"))) # calibrated material card
15
+
16
+ The coupons (each STL sits on the bed in its print orientation):
17
+
18
+ F-XY cantilever printed flat: bending stiffness along the layers -> E
19
+ F-Z the same printed standing: bending across the layers -> E_z / E
20
+ T-XY dog-bone printed flat, pulled to break -> tensile strength
21
+ T-Z the same printed standing -> strength across layers / in-plane
22
+ (the process's `interlayer`)
23
+ L-GY cantilever with a gyroid sheet core -> how a printed lattice compares
24
+ with the homogenized model lattice.py uses (SHEETS)
25
+ X-20, X-30 ... (with crush=True) gyroid crush blocks at two densities,
26
+ squashed under growing weight -> the crush plateau impact.py
27
+ predicts (CRUSH), its density exponent, and the densification
28
+ strain. PA12 needs a 10:1 lever (a board and a bucket of water);
29
+ TPU takes weights on a plate.
30
+
31
+ Every coupon's mass gives the as-printed density. A part test (a finished
32
+ part on a fixture with a known weight) compares measured deflection with
33
+ verify's prediction at the calibrated modulus: the model's own error
34
+ (voxel stiffness, bonded joints, slicer fill) left after the material is
35
+ right. Cards saved in loadpath/calibrations/ are usable by name as a
36
+ study's material (materials.material_from_spec), and carry their
37
+ process factors into the study (spec.study_from_dict).
38
+
39
+ The fit uses closed-form beam theory, not the voxel model, so the material
40
+ card is measured independently of the solver it then feeds.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ from dataclasses import dataclass, field
47
+
48
+ import numpy as np
49
+
50
+ from .materials import Material, material_from_spec
51
+
52
+ __all__ = ["Coupon", "coupon_kit", "crush_coupons", "creep_coupons", "fit_creep", "write_kit", "fit", "fit_crush", "measurement_template",
53
+ "part_prediction", "rigid_motion", "CALIBRATION_DIR", "load_card", "list_cards", "crush_factor"]
54
+
55
+ G = 9.81 # N per kg
56
+ CALIBRATION_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "calibrations")
57
+
58
+ # cantilever: clamp block, then the beam; the load hangs from a hole near the
59
+ # tip, `span` mm from the block's face (the root)
60
+ _CANT = {"block": (30.0, 12.0, 10.0), "width": 12.0, "thickness": 5.0, "span": 80.0, "tip": 6.0, "hole": 4.5}
61
+ # dog-bone: tabs with an 8 mm pin hole, a smooth taper, a 2 x 3 mm gauge
62
+ _BONE = {"tab": (20.0, 18.0, 8.0), "taper": 16.0, "gauge": (2.0, 3.0, 24.0), "hole": 8.0}
63
+ # lattice cantilever: gyroid sheet core between a solid clamp block and a
64
+ # solid end that carries the hook hole
65
+ _LAT = {"block": 30.0, "width": 20.0, "height": 12.0, "core": 80.0, "end": 12.0, "cell": 10.0, "wall": 1.0,
66
+ "hole": 4.5, "kind": "gyroid"}
67
+ _LOADS_KG = (0.2, 0.5, 1.0)
68
+ # crush blocks: n x n cells in plan, m cells tall, between solid plates;
69
+ # the cell is set so the thinnest wall (at the lower density) is the
70
+ # process minimum
71
+ _CRUSH = {"densities": (0.2, 0.3), "plan": 2, "tall": 2, "plate": 2.0, "kind": "gyroid", "lever": 15.0}
72
+ _CRUSH_ELASTIC = {"densities": (0.25, 0.4), "plan": 2, "tall": 2, "plate": 1.5, "kind": "gyroid", "lever": 1.0}
73
+ # elastomer creep block: the low-density crush block, 3 cells tall, held at
74
+ # half its buckling load for a week (viscoelastic.py's creep law)
75
+ _CREEP = {"rho": 0.25, "tall": 3, "loadShare": 0.5, "readMinutes": (1, 10, 60, 600, 1440, 4320, 10080)}
76
+
77
+
78
+ @dataclass
79
+ class Coupon:
80
+ id: str
81
+ title: str
82
+ mesh: object # trimesh, on the bed (z >= 0) in print orientation
83
+ test: str # what to do with it, one paragraph
84
+ measure: dict # template of what to write down
85
+ predicted: dict # at the starting material card
86
+ geometry: dict = field(default_factory=dict)
87
+
88
+ def to_dict(self):
89
+ return {"id": self.id, "title": self.title, "test": self.test, "measure": self.measure,
90
+ "predicted": self.predicted, "geometry": self.geometry}
91
+
92
+
93
+ # ---------------------------------------------------------------- geometry
94
+
95
+ def _box(lo, hi):
96
+ import manifold3d as m3d
97
+ lo, hi = np.asarray(lo, float), np.asarray(hi, float)
98
+ return m3d.Manifold.cube(list(hi - lo)).translate(list(lo))
99
+
100
+
101
+ def _cyl_z(x, y, z0, z1, r, n=48):
102
+ import manifold3d as m3d
103
+ return m3d.Manifold.cylinder(z1 - z0, r, r, n).translate([x, y, z0])
104
+
105
+
106
+ def _to_trimesh(man):
107
+ from .shapes import trimesh_from_manifold
108
+ m = trimesh_from_manifold(man)
109
+ m.merge_vertices()
110
+ return m
111
+
112
+
113
+ def _rotate_upright(man):
114
+ """Stand a part drawn along +x on its x = 0 end: x -> z."""
115
+ # rotation about y by -90 degrees takes +x to +z and +z to -x
116
+ man = man.rotate([0.0, -90.0, 0.0])
117
+ lo = np.asarray(man.bounding_box()[:3])
118
+ return man.translate(list(-lo))
119
+
120
+
121
+ def _cantilever_manifold(c=_CANT):
122
+ bx, by, bz = c["block"]
123
+ end = bx + c["span"] + c["tip"]
124
+ body = _box([0, 0, 0], [bx, by, bz]) + _box([bx, 0, 0], [end, c["width"], c["thickness"]])
125
+ return body - _cyl_z(bx + c["span"], c["width"] / 2, -1, c["thickness"] + 1, c["hole"] / 2)
126
+
127
+
128
+ def _bone_outline(b=_BONE, n=24):
129
+ """Half-outline (u along the pull, v across) of the dog-bone, as a closed
130
+ polygon, centred on v = 0, from u = 0."""
131
+ tw, tl, _ = b["tab"]
132
+ gw, _, gl = b["gauge"]
133
+ tp = b["taper"]
134
+ u_pts, v_pts = [0.0, tl], [tw / 2, tw / 2]
135
+ s = np.linspace(0, 1, n)[1:]
136
+ # cosine taper: zero slope at both ends, no sharp corner at the gauge
137
+ for si in s:
138
+ u_pts.append(tl + tp * si)
139
+ v_pts.append(gw / 2 + (tw - gw) / 2 * 0.5 * (1 + np.cos(np.pi * si)))
140
+ u_pts.append(tl + tp + gl)
141
+ v_pts.append(gw / 2)
142
+ for si in s:
143
+ u_pts.append(tl + tp + gl + tp * si)
144
+ v_pts.append(gw / 2 + (tw - gw) / 2 * 0.5 * (1 - np.cos(np.pi * si)))
145
+ total = 2 * tl + 2 * tp + gl
146
+ u_pts += [total]
147
+ v_pts += [tw / 2]
148
+ top = list(zip(u_pts, v_pts))
149
+ bottom = [(u, -v) for u, v in reversed(top)]
150
+ # counter-clockwise, as manifold3d wants a filled outline
151
+ return list(reversed(top + bottom)), total
152
+
153
+
154
+ def _teardrop(r, n=40):
155
+ """A hole that prints without support with its axis horizontal: a circle
156
+ with a 45 degree roof pointing to +u (up, once the coupon stands)."""
157
+ a = np.linspace(0.25 * np.pi, 1.75 * np.pi, n)
158
+ pts = [(r * np.cos(t), r * np.sin(t)) for t in a]
159
+ return [(r * np.sqrt(2.0), 0.0)] + pts
160
+
161
+
162
+ def _bone_manifold(b=_BONE):
163
+ import manifold3d as m3d
164
+ outline, total = _bone_outline(b)
165
+ tw, tl, tt = b["tab"]
166
+ gt = b["gauge"][1]
167
+ plate = m3d.CrossSection([outline]).extrude(gt).translate([0, 0, (tt - gt) / 2])
168
+ tabs = _box([0, -tw / 2, 0], [tl, tw / 2, tt]) + _box([total - tl, -tw / 2, 0], [total, tw / 2, tt])
169
+ body = plate + tabs
170
+ r = b["hole"] / 2
171
+ for u0 in (tl / 2, total - tl / 2):
172
+ drop = [(u0 + u, v) for u, v in _teardrop(r)]
173
+ body = body - m3d.CrossSection([drop]).extrude(tt + 2).translate([0, 0, -1])
174
+ lo = np.asarray(body.bounding_box()[:3])
175
+ return body.translate(list(-lo)), total
176
+
177
+
178
+ def _lattice_sdf(c=_LAT):
179
+ from .lattice import tpms
180
+
181
+ L = c["block"] + c["core"] + c["end"]
182
+ w, h = c["width"], c["height"]
183
+
184
+ def box_sdf(p, lo, hi):
185
+ lo, hi = np.asarray(lo), np.asarray(hi)
186
+ ctr, half = (lo + hi) / 2, (hi - lo) / 2
187
+ q = np.abs(p - ctr) - half
188
+ return np.linalg.norm(np.maximum(q, 0), axis=-1) + np.minimum(q.max(axis=-1), 0)
189
+
190
+ x0, x1 = c["block"], c["block"] + c["core"]
191
+ hole = (x1 + c["end"] / 2, w / 2)
192
+
193
+ def sdf(p):
194
+ solid = np.minimum(box_sdf(p, [0, 0, 0], [x0 + 0.5, w, h]), box_sdf(p, [x1 - 0.5, 0, 0], [L, w, h]))
195
+ r = np.hypot(p[:, 0] - hole[0], p[:, 1] - hole[1]) - c["hole"] / 2
196
+ solid = np.maximum(solid, -r)
197
+ sheet = np.abs(tpms(c["kind"], p, c["cell"])) - c["wall"] / 2
198
+ core = np.maximum(sheet, box_sdf(p, [x0, 0, 0], [x1, w, h]))
199
+ return np.minimum(solid, core)
200
+
201
+ return sdf, (L, w, h), hole
202
+
203
+
204
+ def _lattice_mesh(c=_LAT, pitch=0.25):
205
+ import trimesh
206
+ from skimage.measure import marching_cubes
207
+
208
+ sdf, (L, w, h), _ = _lattice_sdf(c)
209
+ # grid offset off the faces: a sample exactly on a flat face (sdf = 0)
210
+ # makes marching cubes leave a non-manifold seam
211
+ pad = 2.137 * pitch
212
+ xs = np.arange(-pad, L + pad + pitch / 2, pitch)
213
+ ys = np.arange(-pad, w + pad + pitch / 2, pitch)
214
+ zs = np.arange(-pad, h + pad + pitch / 2, pitch)
215
+ vol = np.empty((len(xs), len(ys), len(zs)), np.float32)
216
+ Y, Z = np.meshgrid(ys, zs, indexing="ij")
217
+ for i, x in enumerate(xs):
218
+ p = np.column_stack([np.full(Y.size, x), Y.ravel(), Z.ravel()])
219
+ vol[i] = sdf(p).reshape(Y.shape)
220
+ verts, faces, _, _ = marching_cubes(vol, 0.0, spacing=(pitch,) * 3)
221
+ verts += [xs[0], ys[0], zs[0]]
222
+ m = trimesh.Trimesh(verts, faces, process=False)
223
+ m.merge_vertices()
224
+ if m.volume < 0:
225
+ m.invert()
226
+ # keep the one body (marching cubes can leave a crumb at a clipped cell)
227
+ parts = m.split(only_watertight=True)
228
+ if len(parts) > 1:
229
+ m = max(parts, key=lambda q: q.volume)
230
+ m.apply_translation(-m.bounds[0])
231
+ return m
232
+
233
+
234
+ # ---------------------------------------------------------------- beam theory
235
+
236
+ def _cantilever_compliance(E, I, a, c=0.0, G_mod=None, area=None, kappa=5 / 6):
237
+ """Tip deflection per newton (mm/N) at distance a + c from the root of a
238
+ cantilever whose first `a` mm bend (E, I) and last `c` mm are rigid,
239
+ plus Timoshenko shear over `a` when G and the area are given."""
240
+ v = a ** 3 / 3 + c * a ** 2 / 2
241
+ th = a ** 2 / 2 + c * a
242
+ d = (v + th * c) / (E * I)
243
+ if G_mod and area:
244
+ d += a / (kappa * G_mod * area)
245
+ return d
246
+
247
+
248
+ def _bone_area(b=_BONE):
249
+ return b["gauge"][0] * b["gauge"][1]
250
+
251
+
252
+ def _lattice_props(mat, c=_LAT, rho=None, factor=1.0):
253
+ from .lattice import relative_density, sheet_modulus
254
+ if rho is None:
255
+ rho = float(relative_density(c["kind"], c["wall"], c["cell"]))
256
+ e = float(sheet_modulus(c["kind"], rho)) * mat.E * factor
257
+ return rho, e
258
+
259
+
260
+ def _lattice_compliance(E_star, c=_LAT, nu_star=0.35):
261
+ I = c["width"] * c["height"] ** 3 / 12
262
+ return _cantilever_compliance(E_star, I, c["core"], c["end"] / 2, E_star / (2 * (1 + nu_star)),
263
+ c["width"] * c["height"])
264
+
265
+
266
+ # ---------------------------------------------------------------- the kit
267
+
268
+ def coupon_kit(material="petg", process="fdm", loads_kg=_LOADS_KG, interlayer=None,
269
+ stiffness_across=None, crush=False) -> list:
270
+ """The coupons for one material, each with what to measure and what the
271
+ starting card predicts. FDM by default; for powder or resin the same
272
+ coupons read the (small) anisotropy of those processes."""
273
+ from .am import _LAYER_STIFFNESS
274
+ from .spec import PROCESSES
275
+
276
+ mat = material_from_spec(material)
277
+ inter = interlayer if interlayer is not None else PROCESSES[process]["interlayer"]
278
+ ez = stiffness_across if stiffness_across is not None else _LAYER_STIFFNESS.get(process, (1.0, 1.0))[0]
279
+ c = _CANT
280
+ I = c["width"] * c["thickness"] ** 3 / 12
281
+ sigma_per_kg = 6 * G * c["span"] / (c["width"] * c["thickness"] ** 2)
282
+ kit = []
283
+
284
+ cant = _cantilever_manifold()
285
+ for cid, title, man, E, where in (
286
+ ("F-XY", "cantilever, printed flat", cant, mat.E, "along the layers"),
287
+ ("F-Z", "cantilever, printed standing", _rotate_upright(cant), mat.E * ez, "across the layers")):
288
+ m = _to_trimesh(man)
289
+ d = _cantilever_compliance(E, I, c["span"])
290
+ kit.append(Coupon(
291
+ cid, title, m,
292
+ test=(f"Clamp the {c['block'][0]:.0f} mm block flat on a table, its end face flush with the table's "
293
+ f"edge, beam sticking out level. Hang each weight from the hole ({c['span']:.0f} mm from the "
294
+ "block) and read how far the hole drops, about 10 s after it settles (PETG creeps). Take the "
295
+ "weight off between readings and note the zero."),
296
+ measure={"massG": None, "width": None, "thickness": None, "points": [[kg, None] for kg in loads_kg]},
297
+ predicted={"massG": round(float(m.volume) * mat.density / 1000, 2),
298
+ "mmPerKg": round(d * G, 3),
299
+ "points": [[kg, round(d * G * kg, 2)] for kg in loads_kg],
300
+ "rootStressMPaPerKg": round(sigma_per_kg, 2), "E": round(E), "stressing": where},
301
+ geometry={"span": c["span"], "width": c["width"], "thickness": c["thickness"], "I": I}))
302
+
303
+ bone, total = _bone_manifold()
304
+ A = _bone_area()
305
+ for cid, title, man, s, where in (
306
+ ("T-XY", "dog-bone, printed flat", bone, mat.strength, "along the layers"),
307
+ ("T-Z", "dog-bone, printed standing", _rotate_upright(bone), mat.strength * inter,
308
+ "across the layers")):
309
+ m = _to_trimesh(man)
310
+ kit.append(Coupon(
311
+ cid, title, m,
312
+ test=("Print two. Loop a strap or carabiner through each end. Anchor one end (a door handle, a "
313
+ "foot through the loop) and pull the other with a luggage scale on max-hold, slowly (about "
314
+ "5 s to break). Write down each peak, and where it broke."),
315
+ measure={"massG": None, "width": None, "thickness": None, "breakKg": [None, None], "breakAt": None},
316
+ predicted={"massG": round(float(m.volume) * mat.density / 1000, 2),
317
+ "breakKg": round(s * A / G, 1), "strength": round(s, 1), "stressing": where},
318
+ geometry={"gaugeWidth": _BONE["gauge"][0], "gaugeThickness": _BONE["gauge"][1], "area": A,
319
+ "length": total}))
320
+
321
+ lat = _lattice_mesh()
322
+ rho, e_star = _lattice_props(mat)
323
+ d = _lattice_compliance(e_star)
324
+ lc = _LAT
325
+ kit.append(Coupon(
326
+ "L-GY", f"cantilever, {lc['kind']} core, printed flat", lat,
327
+ test=("As F-XY: clamp the solid block flush with the table edge, hang the weights from the hole in "
328
+ f"the solid end ({lc['core'] + lc['end'] / 2:.0f} mm from the block) and read the drop."),
329
+ measure={"massG": None, "points": [[kg, None] for kg in loads_kg]},
330
+ predicted={"massG": round(float(lat.volume) * mat.density / 1000, 2), "mmPerKg": round(d * G, 3),
331
+ "points": [[kg, round(d * G * kg, 2)] for kg in loads_kg],
332
+ "relativeDensity": round(rho, 3), "Estar": round(e_star, 1)},
333
+ geometry={k: lc[k] for k in ("block", "width", "height", "core", "end", "cell", "wall", "kind")}
334
+ | {"solidVolume": _lattice_solid_volume(lc), "coreVolume": lc["core"] * lc["width"] * lc["height"]}))
335
+ if crush:
336
+ kit.extend(crush_coupons(material, process))
337
+ kit.extend(creep_coupons(material, process))
338
+ return kit
339
+
340
+
341
+ def _crush_spec(mat, process):
342
+ from .impact import ELASTOMERS
343
+ from .lattice import wall_for_density
344
+ from .spec import PROCESSES
345
+
346
+ c = dict(_CRUSH_ELASTIC if mat.name in ELASTOMERS else _CRUSH)
347
+ wall = float(PROCESSES[process]["minFeature"])
348
+ c["cell"] = round(wall / float(wall_for_density(c["kind"], min(c["densities"]), 1.0)), 1)
349
+ c["side"] = c["plan"] * c["cell"]
350
+ c["core"] = c["tall"] * c["cell"]
351
+ return c
352
+
353
+
354
+ def _crush_mesh(c, rho, pitch=None):
355
+ from .fields import FieldGraph
356
+
357
+ s, hcore, t = c["side"], c["core"], c["plate"]
358
+ doc = {"nodes": {
359
+ "core": {"op": "lattice", "type": c["kind"], "cell": c["cell"], "density": rho},
360
+ "box": {"op": "shape", "shape": {"type": "box", "min": [0, 0, t], "max": [s, s, t + hcore]}},
361
+ "plates": {"op": "union", "inputs": [
362
+ {"op": "shape", "shape": {"type": "box", "min": [0, 0, 0], "max": [s, s, t + 0.3]}},
363
+ {"op": "shape", "shape": {"type": "box", "min": [0, 0, t + hcore - 0.3], "max": [s, s, 2 * t + hcore]}}]},
364
+ "part": {"op": "union", "inputs": ["plates", {"op": "intersect", "inputs": ["core", "box"]}]}},
365
+ "output": "part"}
366
+ return FieldGraph(doc).mesh(pitch=pitch or c["cell"] / 40)
367
+
368
+
369
+ def crush_coupons(material="pa12", process="sls") -> list:
370
+ """Gyroid crush blocks at two densities for calibrating impact.py's crush
371
+ law on a real printer: each block's predicted plateau force (and, for
372
+ the lever test, the bucket load that starts it), its densification, and
373
+ what to write down."""
374
+ from .impact import crush_law, densification_strain, plateau_stress
375
+
376
+ mat = material_from_spec(material)
377
+ c = _crush_spec(mat, process)
378
+ law, ref = crush_law(c["kind"], mat, calibrated=False)
379
+ area = c["side"] ** 2
380
+ lever = c["lever"]
381
+ out = []
382
+ for rho in c["densities"]:
383
+ m = _crush_mesh(c, rho)
384
+ f = float(plateau_stress(law, rho, ref)) * area
385
+ cid = f"X-{round(rho * 100):d}"
386
+ if lever > 1:
387
+ test = (f"Lever test, {lever:.0f}:1. Use a stiff board about 1 m long (a 2x4). Hold one end down "
388
+ "under something that cannot lift (a strap round a heavy bench leg, the underside of a "
389
+ "bolted shelf). Stand the coupon on the floor under the board, its centre 1/"
390
+ f"{lever:.0f} of the way from that end to where the bucket hangs (6.7 cm for a 1 m "
391
+ "lever at 15:1). Hang a bucket at the far end and pour water in a litre at a time. After each "
392
+ "litre, weigh or note the bucket's total kg and measure how far the board's end has "
393
+ "dropped from where it started (a ruler against a wall mark). The block holds, then "
394
+ "squashes in one go when the bucket reaches the plateau load; keep adding until it "
395
+ "stops sinking. Write down the board's mass once (half of it counts at the bucket).")
396
+ else:
397
+ test = ("Weights on a plate. Stand the coupon on a table, lay a small flat board or a hardcover "
398
+ "book on it, and add weights (water bottles weighed on a kitchen scale) one at a time. "
399
+ "After each, note the total kg on top (board included) and the coupon's height "
400
+ "with calipers or a ruler. It squashes suddenly at the plateau and stops when the walls "
401
+ "touch; take it a bit past that. It springs back afterwards: measure that too.")
402
+ out.append(Coupon(
403
+ cid, f"{c['kind']} crush block, relative density {rho:.2f}", m, test=test,
404
+ measure={"massG": None, "height": None, "leverRatio": lever, "boardKg": None if lever > 1 else 0.0,
405
+ "points": [[None, None]]},
406
+ predicted={"massG": round(float(m.volume) * mat.density / 1000, 2),
407
+ "plateauN": round(f, 1), "plateauKgAtLoad": round(f / G / lever, 1),
408
+ "densificationStrain": round(float(densification_strain(rho)), 2),
409
+ "law": "elastic buckling" if law.endswith(":elastic") else "plastic collapse"},
410
+ geometry={"rho": rho, "cell": c["cell"], "side": c["side"], "core": c["core"], "plate": c["plate"],
411
+ "kind": c["kind"], "lever": lever}))
412
+ return out
413
+
414
+
415
+ def creep_coupons(material="tpu", process="fdm") -> list:
416
+ """For materials viscoelastic.py models (TPU): one gyroid block held at
417
+ half its buckling load for a week, its height read with calipers at
418
+ growing intervals. The log-time slope of the deflection is the creep
419
+ rate m that sets an isolator's long-term sag and its stiffening under
420
+ vibration and impact. Empty for other materials."""
421
+ from .impact import crush_law, plateau_stress
422
+ from .lattice import sheet_modulus
423
+ from .viscoelastic import creep_factor, creep_params
424
+
425
+ mat = material_from_spec(material)
426
+ if creep_params(mat) is None:
427
+ return []
428
+ c = dict(_crush_spec(mat, process))
429
+ k = _CREEP
430
+ c["tall"] = k["tall"]
431
+ c["core"] = k["tall"] * c["cell"]
432
+ rho = k["rho"]
433
+ law, ref = crush_law(c["kind"], mat, calibrated=False)
434
+ area = c["side"] ** 2
435
+ f = k["loadShare"] * float(plateau_stress(law, rho, ref)) * area
436
+ strain = f / area / (float(mat.E) * float(sheet_modulus(c["kind"], rho)))
437
+ pred = {f"{m} min": round(float(c["core"] * strain * creep_factor(mat, 60.0 * m)), 3) for m in k["readMinutes"]}
438
+ m = _crush_mesh(c, rho)
439
+ test = (f"Creep under a dead weight. Measure the coupon's height with calipers first (unloaded). Then stand "
440
+ f"it on a table, lay a small flat board on it and load it to {f / G:.2f} kg in all, board "
441
+ "included (water bottles weighed on a kitchen scale), gently, in one go; start a timer. Read the "
442
+ "height at the corners of the board (average two opposite corners) at about "
443
+ + ", ".join(f"{x} min" for x in k["readMinutes"][:4]) + ", 1 day, 3 days and a week. Keep it "
444
+ "somewhere the temperature stays steady (TPU creeps faster when warm) and note the room "
445
+ "temperature. Write each reading as [minutes since loading, height mm].")
446
+ return [Coupon(
447
+ f"K-{round(rho * 100):d}", f"{c['kind']} creep block, relative density {rho:.2f}", m, test=test,
448
+ measure={"massG": None, "heightUnloaded": None, "loadKg": round(f / G, 2), "temperatureC": None,
449
+ "points": [[None, None]]},
450
+ predicted={"massG": round(float(m.volume) * mat.density / 1000, 2), "loadKg": round(f / G, 2),
451
+ "deflectionMm": pred, "creepRate": creep_params(mat)[0]},
452
+ geometry={"rho": rho, "cell": c["cell"], "side": c["side"], "core": c["core"], "plate": c["plate"],
453
+ "kind": c["kind"]})]
454
+
455
+
456
+ def fit_creep(coupons: dict, mat, process: str = "fdm") -> dict | None:
457
+ """The creep block from a filled-in K-* coupon: deflection against the
458
+ log of time is a straight line d = a + b ln t (viscoelastic.py's law),
459
+ so m = b / (a + b ln t_ref): the creep rate per e-fold of time relative
460
+ to the deflection at the card's time scale. Also the modulus the
461
+ coupon shows at t_ref, over the card's."""
462
+ from .lattice import sheet_modulus
463
+ from .viscoelastic import creep_params
464
+
465
+ p = creep_params(mat)
466
+ t_ref = p[1] if p else 10.0
467
+ for cid, e in sorted(coupons.items()):
468
+ if not cid.startswith("K-") or not e.get("heightUnloaded"):
469
+ continue
470
+ pts = [(float(t), float(h)) for t, h in (e.get("points") or []) if t is not None and h is not None]
471
+ if len(pts) < 3:
472
+ continue
473
+ t = np.array([x for x, _ in pts]) * 60.0
474
+ d = float(e["heightUnloaded"]) - np.array([h for _, h in pts])
475
+ b, a = np.polyfit(np.log(t), d, 1)
476
+ d_ref = a + b * np.log(t_ref)
477
+ out = {"m": round(float(b / d_ref), 4), "tRef": t_ref,
478
+ "coupon": {"id": cid, "points": len(pts), "deflectionAtTRef": round(float(d_ref), 3),
479
+ "temperatureC": e.get("temperatureC")}}
480
+ geo = _crush_spec(mat, process)
481
+ if e.get("loadKg") and d_ref > 0:
482
+ core = _CREEP["tall"] * geo["cell"]
483
+ e_meas = float(e["loadKg"]) * G / geo["side"] ** 2 / (d_ref / core)
484
+ out["coupon"]["modulusFactor"] = round(e_meas / (float(mat.E) * float(sheet_modulus(geo["kind"],
485
+ _CREEP["rho"]))), 3)
486
+ return out
487
+ return None
488
+
489
+
490
+ def _lattice_solid_volume(c=_LAT):
491
+ hole = np.pi * (c["hole"] / 2) ** 2 * c["height"]
492
+ return (c["block"] + c["end"]) * c["width"] * c["height"] - hole
493
+
494
+
495
+ def measurement_template(kit, material="petg", process="fdm", parts=None) -> dict:
496
+ """What to fill in after testing: one entry per coupon (and part test).
497
+ Masses in grams, lengths in mm, loads in kg (what a kitchen scale reads).
498
+ Points are [kg hung, mm the load point dropped]."""
499
+ return {
500
+ "material": material if isinstance(material, str) else material_from_spec(material).name,
501
+ "process": process,
502
+ "printer": None, "filament": None,
503
+ "slicer": {"layer": None, "walls": None, "infill": "100%", "nozzleTemp": None},
504
+ "notes": None,
505
+ "coupons": {c.id: dict(c.measure) for c in kit},
506
+ # a part test: drop at the hook in mm, or set laserDistanceM and give
507
+ # the laser spot's travel on the wall in mm
508
+ "parts": {k: {"massG": None, "laserDistanceM": None, "points": [[kg, None] for kg in v.get("loadsKg", [])]}
509
+ for k, v in (parts or {}).items()},
510
+ }
511
+
512
+
513
+ def write_kit(kit, out_dir, material="petg", process="fdm", parts=None) -> dict:
514
+ """Write each coupon's STL, predictions.json and measurements.json (the
515
+ template to fill in). Returns the paths."""
516
+ os.makedirs(out_dir, exist_ok=True)
517
+ mat = material_from_spec(material)
518
+ paths = {}
519
+ for c in kit:
520
+ p = os.path.join(out_dir, f"coupon-{c.id}.stl")
521
+ c.mesh.export(p)
522
+ paths[c.id] = p
523
+ pred = {"material": mat.to_dict(), "process": process,
524
+ "coupons": {c.id: c.to_dict() for c in kit}, "parts": parts or {}}
525
+ with open(os.path.join(out_dir, "predictions.json"), "w") as fh:
526
+ json.dump(pred, fh, indent=2)
527
+ tmpl = measurement_template(kit, mat.name, process, parts)
528
+ with open(os.path.join(out_dir, "measurements.json"), "w") as fh:
529
+ json.dump(tmpl, fh, indent=2)
530
+ paths["predictions"] = os.path.join(out_dir, "predictions.json")
531
+ paths["measurements"] = os.path.join(out_dir, "measurements.json")
532
+ return paths
533
+
534
+
535
+ # ---------------------------------------------------------------- part tests
536
+
537
+ def part_prediction(study, mesh, hook, rigid=None, probes=None, max_cells=120_000) -> dict:
538
+ """Deflection per kg at a hook point for a finished part under a hanging
539
+ weight. `study` holds the part as in the test (its supports are the
540
+ fixture, its one load case the weight, 1 kg as `force` [N]); `hook` is
541
+ the point whose drop is read. With `rigid` (a shapes.Shape: a stiff
542
+ block the weight hangs from, such as a dummy servo) the block's rigid
543
+ motion is fitted to its nodes and read at the hook, and its rotation is
544
+ reported too (for an optical lever: a laser on the block moves its spot
545
+ by rotation x distance). `probes` ({name: Shape}) adds each region's
546
+ fitted rotation, to see how much of the motion a fixture adds. Uses
547
+ verify's grid and the study's material."""
548
+ from .verify import verify_part
549
+
550
+ rep = verify_part(study, mesh, max_cells=max_cells, refine=0, fields=True)
551
+ f = rep["fields"]
552
+ nodes = np.asarray(f["nodes"], float)
553
+ u = np.asarray(f["cases"][0]["displacement"], float).reshape(-1, 3)
554
+ load = study.load_cases[0].loads[0]
555
+ kg = float(np.linalg.norm(load.force)) / G
556
+ dirn = load.force / np.linalg.norm(load.force)
557
+ hook = np.asarray(hook, float)
558
+ out = {}
559
+ if rigid is not None:
560
+ sel = rigid.sdf(nodes) <= 0
561
+ if sel.sum() < 4:
562
+ raise ValueError("the rigid block holds fewer than 4 analysis nodes")
563
+ t, w = rigid_motion(nodes[sel], u[sel])
564
+ uh = t + np.cross(w, hook - nodes[sel].mean(axis=0))
565
+ out["rotationMradPerKg"] = (w / kg * 1000).tolist()
566
+ out["rigidNodes"] = int(sel.sum())
567
+ else:
568
+ d = np.linalg.norm(nodes - hook, axis=1)
569
+ near = np.argsort(d)[:8]
570
+ uh = u[near].mean(axis=0)
571
+ out["hookNodeDistance"] = float(d[near].max())
572
+ for name, shape in (probes or {}).items():
573
+ sel = shape.sdf(nodes) <= 0
574
+ if sel.sum() >= 4:
575
+ out.setdefault("probeRotationMradPerKg", {})[name] = (rigid_motion(nodes[sel], u[sel])[1]
576
+ / kg * 1000).tolist()
577
+ c0 = rep["cases"][0]
578
+ out.update({"mmPerKg": float(uh @ dirn) / kg, "E": study.material.E,
579
+ "peakStressMPaPerKg": c0["peakStress"] / kg, "peakAt": c0["peakAt"],
580
+ "massG": rep["mass"], "analysisPitch": rep["analysisPitch"]})
581
+ return out
582
+
583
+
584
+ def rigid_motion(x, u):
585
+ """Least-squares rigid motion (translation at the centroid, small
586
+ rotation vector) of points x displaced by u."""
587
+ c = x.mean(axis=0)
588
+ r = x - c
589
+ t = u.mean(axis=0)
590
+ # u - t = w x r -> A w = b with A the cross-product matrices
591
+ A = np.zeros((3 * len(r), 3))
592
+ A[0::3, 1], A[0::3, 2] = r[:, 2], -r[:, 1]
593
+ A[1::3, 0], A[1::3, 2] = -r[:, 2], r[:, 0]
594
+ A[2::3, 0], A[2::3, 1] = r[:, 1], -r[:, 0]
595
+ w = np.linalg.lstsq(A, (u - t).ravel(), rcond=None)[0]
596
+ return t, w
597
+
598
+
599
+ # ---------------------------------------------------------------- the fit
600
+
601
+ def _slope(points):
602
+ """mm per kg through [kg, mm] points: least squares with an offset when
603
+ there are three or more (clamp slack, a misread zero), else through 0."""
604
+ pts = np.asarray([p for p in points or [] if p and p[0] is not None and p[1] is not None], float)
605
+ if len(pts) == 0:
606
+ return None, None
607
+ kg, mm = pts[:, 0], pts[:, 1]
608
+ if len(pts) >= 3:
609
+ A = np.column_stack([kg, np.ones_like(kg)])
610
+ (s, b), *_ = np.linalg.lstsq(A, mm, rcond=None)
611
+ resid = mm - (s * kg + b)
612
+ r2 = 1 - (resid ** 2).sum() / max(((mm - mm.mean()) ** 2).sum(), 1e-12)
613
+ return float(s), {"offsetMm": round(float(b), 3), "r2": round(float(r2), 4), "n": len(pts)}
614
+ s = float((kg * mm).sum() / (kg * kg).sum())
615
+ return s, {"n": len(pts)}
616
+
617
+
618
+ def _geom(entry, key, default):
619
+ v = entry.get(key)
620
+ return float(v) if v else float(default)
621
+
622
+
623
+ def fit(measurements: dict, predictions: dict | None = None, name: str | None = None) -> dict:
624
+ """A calibrated material card from filled-in measurements (the
625
+ write_kit template). Missing coupons leave the starting card's values
626
+ in place and say so. `predictions` (predictions.json) supplies the part
627
+ tests' predicted deflections, so the part's model factor can be
628
+ reported. Returns the card: material (usable as a study's material),
629
+ process factors, lattice and model factors, and the evidence."""
630
+ from .am import _LAYER_STIFFNESS
631
+ from .spec import PROCESSES
632
+
633
+ base = material_from_spec(measurements.get("material", "petg"))
634
+ proc = measurements.get("process", "fdm")
635
+ co = measurements.get("coupons") or {}
636
+ ev, kept = {}, []
637
+
638
+ # modulus, along and across the layers
639
+ c = _CANT
640
+ E = {}
641
+ for cid in ("F-XY", "F-Z"):
642
+ e = co.get(cid) or {}
643
+ s, info = _slope(e.get("points"))
644
+ if s is None or s <= 0:
645
+ continue
646
+ b, h = _geom(e, "width", c["width"]), _geom(e, "thickness", c["thickness"])
647
+ I = b * h ** 3 / 12
648
+ E[cid] = _cantilever_compliance(1.0, I, c["span"]) * G / s
649
+ ev[cid] = {"mmPerKg": round(s, 4), "E": round(E[cid]), **(info or {}), "I": round(I, 2)}
650
+ E_xy = E.get("F-XY")
651
+ if E_xy is None:
652
+ E_xy = base.E
653
+ kept.append("E (no F-XY points)")
654
+ ez = None
655
+ if "F-Z" in E and "F-XY" in E:
656
+ ez = E["F-Z"] / E["F-XY"]
657
+
658
+ # strength, along and across the layers
659
+ S = {}
660
+ for cid in ("T-XY", "T-Z"):
661
+ e = co.get(cid) or {}
662
+ if not any(k for k in np.atleast_1d(e.get("breakKg") or [])):
663
+ continue
664
+ w, t = _geom(e, "width", _BONE["gauge"][0]), _geom(e, "thickness", _BONE["gauge"][1])
665
+ # one break, or a list from several copies (the mean is used)
666
+ kgs = [float(k) for k in np.atleast_1d(e["breakKg"]) if k]
667
+ S[cid] = float(np.mean(kgs)) * G / (w * t)
668
+ ev[cid] = {"breakKg": e["breakKg"], "strength": round(S[cid], 1), "area": round(w * t, 2)}
669
+ if len(kgs) > 1:
670
+ ev[cid]["scatter"] = round(float(np.std(kgs, ddof=1) / np.mean(kgs)), 3)
671
+ where = str(e.get("breakAt") or "")
672
+ if where and not any(k in where.lower() for k in ("gauge", "middle", "centre", "center")):
673
+ ev[cid]["warning"] = (f"broke at {where!r}, not in the gauge: the strength reads low; "
674
+ "a stress raiser (seam, taper, hole) failed first")
675
+ s_xy = S.get("T-XY")
676
+ if s_xy is None:
677
+ s_xy = base.strength
678
+ kept.append("strength (no T-XY break)")
679
+ inter = S["T-Z"] / s_xy if "T-Z" in S else None
680
+
681
+ # as-printed density from the solid coupons' masses
682
+ kit_vol = _coupon_volumes()
683
+ dens = []
684
+ for cid in ("F-XY", "F-Z", "T-XY", "T-Z"):
685
+ mg = (co.get(cid) or {}).get("massG")
686
+ if mg:
687
+ dens.append(float(mg) / kit_vol[cid] * 1000)
688
+ ev.setdefault(cid, {})["density"] = round(dens[-1], 3)
689
+ rho_mat = float(np.median(dens)) if dens else base.density
690
+ if not dens:
691
+ kept.append("density (no coupon masses)")
692
+
693
+ mat = Material(name or f"{base.name}-calibrated", round(float(E_xy), 0), base.nu,
694
+ round(float(s_xy), 1), round(rho_mat, 3),
695
+ f"calibrated from printed coupons ({measurements.get('printer') or 'printer not given'}, "
696
+ f"{measurements.get('filament') or base.name})", base.processes)
697
+
698
+ # lattice: measured core modulus against the homogenized sheet model
699
+ lat = None
700
+ e = co.get("L-GY") or {}
701
+ s, info = _slope(e.get("points"))
702
+ if s and s > 0:
703
+ lc = _LAT
704
+ unit = _lattice_compliance(1.0) # mm/N at E* = 1 MPa
705
+ e_meas = unit * G / s
706
+ rho_nom, e_nom = _lattice_props(mat)
707
+ rho_built = rho_nom
708
+ if e.get("massG"):
709
+ core_mass = float(e["massG"]) - _lattice_solid_volume(lc) * rho_mat / 1000
710
+ core_vol = lc["core"] * lc["width"] * lc["height"]
711
+ rho_built = max(core_mass * 1000 / (rho_mat * core_vol), 1e-3)
712
+ _, e_built = _lattice_props(mat, rho=rho_built)
713
+ lat = {"kind": lc["kind"], "relativeDensityDrawn": round(rho_nom, 3),
714
+ "relativeDensityPrinted": round(rho_built, 3),
715
+ "Estar": round(e_meas, 1), "EstarModel": round(e_nom, 1),
716
+ # model at the density that printed: wall-width error taken
717
+ # out, what is left is the sheet model's own error
718
+ "stiffnessFactor": round(e_meas / e_built, 3), **(info or {})}
719
+ ev["L-GY"] = {"mmPerKg": round(s, 4), **lat}
720
+
721
+ # part tests: measured against verify's prediction at the fitted modulus
722
+ parts = {}
723
+ pred_parts = (predictions or {}).get("parts") or {}
724
+ for pid, e in (measurements.get("parts") or {}).items():
725
+ s, info = _slope(e.get("points"))
726
+ if not s or s <= 0:
727
+ continue
728
+ out = {"mmPerKg": round(s, 4), **(info or {})}
729
+ pp = pred_parts.get(pid) or {}
730
+ # read with a dial at the hook (mm), or a laser spot on a wall
731
+ # laserDistanceM away (mm of spot travel)
732
+ laser = e.get("laserDistanceM")
733
+ per_kg = pp.get("laserSpotMmPerKgPerM") * float(laser) if laser and pp.get("laserSpotMmPerKgPerM") \
734
+ else (None if laser else pp.get("mmPerKg"))
735
+ if per_kg and pp.get("E"):
736
+ pred = per_kg * pp["E"] / mat.E # linear: deflection goes as 1/E
737
+ out.update(predictedMmPerKg=round(pred, 4), stiffnessFactor=round(pred / s, 3))
738
+ if laser:
739
+ out["read"] = f"laser spot at {laser} m"
740
+ if e.get("massG") and pp.get("massG"):
741
+ # predicted at the starting card's density; rescale to the printed one
742
+ pred_g = pp["massG"] * mat.density / float(pp.get("density") or base.density)
743
+ out.update(predictedMassG=round(pred_g, 2), massRatio=round(float(e["massG"]) / pred_g, 3))
744
+ parts[pid] = out
745
+
746
+ process = {"type": proc}
747
+ if inter is not None:
748
+ process["interlayer"] = round(float(min(inter, 1.0)), 3)
749
+ else:
750
+ kept.append(f"interlayer (no T-Z break; process default {PROCESSES[proc]['interlayer']})")
751
+ if ez is not None:
752
+ process["stiffnessAcrossLayers"] = round(float(min(ez, 1.0)), 3)
753
+ else:
754
+ kept.append(f"stiffnessAcrossLayers (no F-Z points; default {_LAYER_STIFFNESS.get(proc, (1.0,))[0]})")
755
+ crush = fit_crush(co, mat, base, proc)
756
+ creep = fit_creep(co, base, proc)
757
+ if creep is None and any(k.startswith("K-") for k in co):
758
+ kept.append("creep rate (no K- readings)")
759
+ if crush is None and any(k.startswith("X-") for k in co):
760
+ kept.append("crush plateau (no X- crush points)")
761
+ sf = [p["stiffnessFactor"] for p in parts.values() if "stiffnessFactor" in p]
762
+ card = {
763
+ "name": mat.name,
764
+ "basedOn": base.name,
765
+ # pasteable as a study's custom material
766
+ "material": {k: v for k, v in mat.to_dict().items() if k != "processes"},
767
+ "process": process,
768
+ "change": {"E": round(mat.E / base.E, 3), "strength": round(mat.strength / base.strength, 3),
769
+ "density": round(mat.density / base.density, 3)},
770
+ "lattice": lat,
771
+ "crush": crush,
772
+ "creep": creep,
773
+ "parts": parts,
774
+ # a part's measured stiffness over verify's, both at the calibrated
775
+ # modulus: what the voxel model, bonded joints and slicer fill miss
776
+ "modelFactor": {"stiffness": round(float(np.exp(np.mean(np.log(sf)))), 3)} if sf else None,
777
+ "evidence": ev,
778
+ "kept": kept,
779
+ "measured": {k: measurements.get(k) for k in ("printer", "filament", "slicer", "notes")},
780
+ }
781
+ return card
782
+
783
+
784
+ def _coupon_volumes():
785
+ """Volumes (mm^3) of the solid coupons, computed once."""
786
+ global _VOLS
787
+ try:
788
+ return _VOLS
789
+ except NameError:
790
+ pass
791
+ cant = _cantilever_manifold().volume()
792
+ bone = _bone_manifold()[0].volume()
793
+ _VOLS = {"F-XY": cant, "F-Z": cant, "T-XY": bone, "T-Z": bone}
794
+ return _VOLS
795
+
796
+
797
+ def fit_crush(coupons: dict, mat, base, process: str) -> dict | None:
798
+ """The crush block from filled-in X-* coupons: per coupon the plateau
799
+ (the load at which it ran away, between the last load it held and the
800
+ one that squashed it), the strain it ran to (densification) and the
801
+ density it printed at; overall the factor on impact.py's plateau (the
802
+ geometric mean of measured over predicted at the printed density) and,
803
+ with two densities, the measured exponent."""
804
+ from .impact import crush_law, plateau_stress
805
+
806
+ spec = _crush_spec(base, process)
807
+ law, _ = crush_law(spec["kind"], base, calibrated=False) # the law goes with the base material
808
+ ref = float(mat.E) if law.endswith(":elastic") else float(mat.strength)
809
+ area, hcore, t = spec["side"] ** 2, spec["core"], spec["plate"]
810
+ ev = {}
811
+ for cid, e in sorted(coupons.items()):
812
+ if not cid.startswith("X-"):
813
+ continue
814
+ pts = sorted((float(k), float(d)) for k, d in (e.get("points") or []) if k is not None and d is not None)
815
+ if not pts:
816
+ continue
817
+ ratio = float(e.get("leverRatio") or 1.0)
818
+ board = float(e.get("boardKg") or 0.0)
819
+ force = np.array([(k + board / 2) * G * ratio for k, _ in pts])
820
+ strain = np.array([d / ratio / hcore for _, d in pts])
821
+ rho_draw = int(cid[2:]) / 100
822
+ rho = rho_draw
823
+ if e.get("massG"):
824
+ core_g = float(e["massG"]) - 2 * t * area * mat.density / 1000
825
+ rho = max(core_g * 1000 / (mat.density * area * hcore), 1e-3)
826
+ out = {"relativeDensityDrawn": rho_draw, "relativeDensityPrinted": round(rho, 3)}
827
+ jump = np.flatnonzero(strain > 0.08)
828
+ if len(jump) == 0:
829
+ out.update(plateauAtLeastN=round(float(force.max()), 1),
830
+ note="never squashed: the plateau is above the largest load")
831
+ ev[cid] = out
832
+ continue
833
+ i = int(jump[0])
834
+ pl = 0.5 * (force[i - 1] + force[i]) if i > 0 else force[i]
835
+ model = float(plateau_stress(law, rho, ref)) * area
836
+ out.update(plateauN=round(float(pl), 1), bracketN=[round(float(force[i - 1]), 1) if i > 0 else 0.0,
837
+ round(float(force[i]), 1)],
838
+ densificationStrain=round(float(strain[i:].max()), 3), predictedN=round(model, 1),
839
+ factor=round(float(pl / model), 3), plateauMPa=round(float(pl / area), 3))
840
+ ev[cid] = out
841
+ got = [v for v in ev.values() if "factor" in v]
842
+ if not got:
843
+ return {"coupons": ev} if ev else None
844
+ crush = {"law": "elastic" if law.endswith(":elastic") else "plastic", "kind": spec["kind"],
845
+ "factor": round(float(np.exp(np.mean(np.log([v["factor"] for v in got])))), 3),
846
+ "densificationAlpha": round(float(np.mean([(1 - v["densificationStrain"]) / v["relativeDensityPrinted"]
847
+ for v in got])), 2),
848
+ "coupons": ev}
849
+ if len(got) >= 2:
850
+ a, b = sorted(got, key=lambda v: v["relativeDensityPrinted"])[:: len(got) - 1]
851
+ s1, s2 = a["plateauMPa"] / ref, b["plateauMPa"] / ref
852
+ p = float(np.log(s2 / s1) / np.log(b["relativeDensityPrinted"] / a["relativeDensityPrinted"]))
853
+ crush.update(p=round(p, 3), D=round(float(s1 / a["relativeDensityPrinted"] ** p), 4))
854
+ return crush
855
+
856
+
857
+ def crush_factor(name: str, law: str) -> float:
858
+ """The measured factor on impact.py's crush plateau from a saved card
859
+ of this name (1 when there is none, or it was fitted for the other
860
+ law)."""
861
+ try:
862
+ card = load_card(name) if name else None
863
+ except Exception: # noqa: BLE001
864
+ card = None
865
+ cr = (card or {}).get("crush") or {}
866
+ return float(cr["factor"]) if cr.get("factor") and cr.get("law") == law else 1.0
867
+
868
+
869
+ # ---------------------------------------------------------------- cards
870
+
871
+ def list_cards() -> list:
872
+ if not os.path.isdir(CALIBRATION_DIR):
873
+ return []
874
+ return sorted(f[:-5] for f in os.listdir(CALIBRATION_DIR) if f.endswith(".json"))
875
+
876
+
877
+ def load_card(name: str) -> dict | None:
878
+ """A saved calibration card by name (loadpath/calibrations/<name>.json),
879
+ or None."""
880
+ key = name.strip().lower()
881
+ path = os.path.join(CALIBRATION_DIR, f"{key}.json")
882
+ if not os.path.isfile(path):
883
+ return None
884
+ with open(path) as fh:
885
+ return json.load(fh)