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/calibration.py
ADDED
|
@@ -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)
|