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/coopt.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Co-optimizing the links of a mechanism together.
|
|
3
|
+
|
|
4
|
+
Each link of an arm carries everything past it, so its loads depend on the
|
|
5
|
+
masses of the links beyond it, and its own mass loads every link and joint
|
|
6
|
+
before it. Sizing the links one at a time against the masses written in
|
|
7
|
+
the URDF (the parts as they were) leaves the chain inconsistent: a lighter
|
|
8
|
+
wrist is still carried as the old heavy one by the forearm, the upper arm
|
|
9
|
+
and the J2 and J3 drives. This module closes the loop:
|
|
10
|
+
|
|
11
|
+
arm = ArmModel(robot, hardware_from={"forearm": shipped_forearm_mesh, ...})
|
|
12
|
+
arm.set_part("forearm", new_mesh) # printed part swapped, hardware kept
|
|
13
|
+
demand = joint_demand(arm.robot, payload) # per-joint torque needed, payload capacity
|
|
14
|
+
history = co_optimize(arm, order, derive, solve)
|
|
15
|
+
|
|
16
|
+
**Mass model.** A URDF body is the printed part plus the hardware fixed to
|
|
17
|
+
it (motors, drives, bearings, bolts). `ArmModel` subtracts the part as it
|
|
18
|
+
was (mesh and density, full mass properties: mass, centre of mass and
|
|
19
|
+
inertia by the parallel-axis theorem) to get the hardware alone, then adds
|
|
20
|
+
whichever part is current, so a new link changes the body's mass, its
|
|
21
|
+
centre of mass and its inertia together.
|
|
22
|
+
|
|
23
|
+
**Joint demand.** `joint_demand` samples poses over the joint limits (the
|
|
24
|
+
corners and random poses), still and moving (each joint near its speed
|
|
25
|
+
limit, bang-bang accelerations reached in `ramp` seconds), and for each
|
|
26
|
+
state the torque every actuated joint needs with and without the payload.
|
|
27
|
+
Torque is affine in the payload mass, so the payload each joint can carry
|
|
28
|
+
in each state follows exactly, and the arm's capacity is the least over
|
|
29
|
+
joints and states. A counterbalanced joint (a spring) is credited with the
|
|
30
|
+
arm's own static weight, not the payload's.
|
|
31
|
+
|
|
32
|
+
**The loop.** `co_optimize` walks the links from the tip of the chain
|
|
33
|
+
toward the base (Gauss-Seidel): a link's loads come from the masses beyond
|
|
34
|
+
it, which are already this pass's, so one pass gets most of the way and a
|
|
35
|
+
second pass re-derives every link's loads and regrows only those whose
|
|
36
|
+
loads moved by more than `tol`. The derive and solve steps are callbacks,
|
|
37
|
+
so the study, the sweep settings and the solver stay the caller's.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import copy
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import time
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
from .assembly import G, Payload, Robot, _State
|
|
50
|
+
|
|
51
|
+
__all__ = ["MassProps", "mass_props", "ArmModel", "joint_demand", "load_change", "co_optimize"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------- mass properties
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class MassProps:
|
|
58
|
+
"""kg; centre of mass (m) and inertia about it (kg m²), in one frame."""
|
|
59
|
+
mass: float = 0.0
|
|
60
|
+
com: np.ndarray = field(default_factory=lambda: np.zeros(3))
|
|
61
|
+
inertia: np.ndarray = field(default_factory=lambda: np.zeros((3, 3)))
|
|
62
|
+
|
|
63
|
+
def __add__(self, other):
|
|
64
|
+
return _combine(self, other, 1.0)
|
|
65
|
+
|
|
66
|
+
def __sub__(self, other):
|
|
67
|
+
return _combine(self, other, -1.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _shift(I, m, d):
|
|
71
|
+
"""Inertia about a point displaced by d from the centre of mass."""
|
|
72
|
+
d = np.asarray(d, float)
|
|
73
|
+
return I + m * ((d @ d) * np.eye(3) - np.outer(d, d))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _combine(a: MassProps, b: MassProps, sign: float) -> MassProps:
|
|
77
|
+
m = a.mass + sign * b.mass
|
|
78
|
+
if m <= 1e-9:
|
|
79
|
+
return MassProps()
|
|
80
|
+
com = (a.mass * a.com + sign * b.mass * b.com) / m
|
|
81
|
+
I = _shift(a.inertia, a.mass, a.com - com) + sign * _shift(b.inertia, b.mass, b.com - com)
|
|
82
|
+
# what is left after taking a part away can come out slightly non-physical
|
|
83
|
+
# when the URDF's inertia was itself an estimate: keep it positive
|
|
84
|
+
I = 0.5 * (I + I.T)
|
|
85
|
+
w, v = np.linalg.eigh(I)
|
|
86
|
+
I = (v * np.maximum(w, 1e-9)) @ v.T
|
|
87
|
+
return MassProps(m, com, I)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def mass_props(mesh, density: float, to_frame=None) -> MassProps:
|
|
91
|
+
"""Mass properties of a closed mesh in mm at `density` g/cm³, in the
|
|
92
|
+
frame `to_frame` maps it into (4x4, mm in -> metres out; default: the
|
|
93
|
+
mesh's own frame in metres)."""
|
|
94
|
+
m = mesh.copy()
|
|
95
|
+
if to_frame is None:
|
|
96
|
+
to_frame = np.diag([1e-3, 1e-3, 1e-3, 1.0])
|
|
97
|
+
m.apply_transform(np.asarray(to_frame, float))
|
|
98
|
+
vol = abs(float(m.volume))
|
|
99
|
+
if vol <= 0:
|
|
100
|
+
return MassProps()
|
|
101
|
+
kg = vol * density * 1000.0
|
|
102
|
+
# trimesh's moment_inertia is for unit density about the centre of mass
|
|
103
|
+
I = np.asarray(m.moment_inertia, float) * (density * 1000.0)
|
|
104
|
+
if m.volume < 0:
|
|
105
|
+
I = -I
|
|
106
|
+
return MassProps(kg, np.asarray(m.center_mass, float), I)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _body_props(body) -> MassProps:
|
|
110
|
+
return MassProps(float(body.mass), np.asarray(body.com, float).copy(), np.asarray(body.inertia, float).copy())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ArmModel:
|
|
114
|
+
"""A robot whose printed parts can be swapped while the hardware stays.
|
|
115
|
+
|
|
116
|
+
robot: from Robot.from_urdf, with any hardware changes already applied
|
|
117
|
+
to its bodies (a motor swapped for a lighter one). parts: {link: [(mesh
|
|
118
|
+
mm, density g/cm³, 4x4 mesh frame -> the arm's world at home, mm)]}, the
|
|
119
|
+
printed parts the URDF's inertials include, so they can be taken out.
|
|
120
|
+
A link's parts may be several meshes (a hand: palm, fingers)."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, robot: Robot, parts: dict):
|
|
123
|
+
self.robot = robot
|
|
124
|
+
self.T0 = robot.fk({})
|
|
125
|
+
self.hardware = {}
|
|
126
|
+
self.parts = {}
|
|
127
|
+
for name, b in robot.bodies.items():
|
|
128
|
+
hw = _body_props(b)
|
|
129
|
+
for spec in parts.get(name, []):
|
|
130
|
+
hw = hw - self._props(name, *spec)
|
|
131
|
+
self.hardware[name] = hw
|
|
132
|
+
self.parts[name] = list(parts.get(name, []))
|
|
133
|
+
|
|
134
|
+
def _props(self, link, mesh, density, frame=None):
|
|
135
|
+
frame = np.eye(4) if frame is None else np.asarray(frame, float)
|
|
136
|
+
to_link = np.linalg.inv(self.T0[link]) @ np.diag([1e-3, 1e-3, 1e-3, 1.0]) @ frame
|
|
137
|
+
return mass_props(mesh, density, to_link)
|
|
138
|
+
|
|
139
|
+
def set_parts(self, link: str, parts: list):
|
|
140
|
+
"""Replace `link`'s printed parts: [(mesh, density, frame)]."""
|
|
141
|
+
self.parts[link] = list(parts)
|
|
142
|
+
p = self.hardware[link]
|
|
143
|
+
for spec in parts:
|
|
144
|
+
p = p + self._props(link, *spec)
|
|
145
|
+
b = self.robot.bodies[link]
|
|
146
|
+
b.mass, b.com, b.inertia = p.mass, p.com, p.inertia
|
|
147
|
+
|
|
148
|
+
def set_part(self, link: str, mesh, density: float, frame=None):
|
|
149
|
+
self.set_parts(link, [(mesh, density, frame)])
|
|
150
|
+
|
|
151
|
+
def printed_kg(self, link: str | None = None) -> float:
|
|
152
|
+
links = [link] if link else list(self.parts)
|
|
153
|
+
return float(sum(self._props(n, *s).mass for n in links for s in self.parts[n]))
|
|
154
|
+
|
|
155
|
+
def masses(self) -> dict:
|
|
156
|
+
"""{link: {kg, printedKg, hardwareKg, comM}}"""
|
|
157
|
+
out = {}
|
|
158
|
+
for n, b in self.robot.bodies.items():
|
|
159
|
+
if b.mass <= 0 and not self.parts.get(n):
|
|
160
|
+
continue
|
|
161
|
+
out[n] = {"kg": round(float(b.mass), 4), "printedKg": round(self.printed_kg(n), 4),
|
|
162
|
+
"hardwareKg": round(float(self.hardware[n].mass), 4),
|
|
163
|
+
"comM": [round(float(v), 4) for v in b.com]}
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
def snapshot(self) -> dict:
|
|
167
|
+
return {n: _body_props(b) for n, b in self.robot.bodies.items()}
|
|
168
|
+
|
|
169
|
+
def restore(self, snap: dict):
|
|
170
|
+
for n, p in snap.items():
|
|
171
|
+
b = self.robot.bodies[n]
|
|
172
|
+
b.mass, b.com, b.inertia = p.mass, p.com.copy(), p.inertia.copy()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --------------------------------------------------------------------------- joint demand
|
|
176
|
+
|
|
177
|
+
def joint_demand(robot: Robot, payload: Payload, *, joints: list | None = None, samples: int = 2000,
|
|
178
|
+
corners: bool = True, ramp: float | None = 0.5, speed: float = 1.0, balanced: tuple = (),
|
|
179
|
+
gravity=(0.0, 0.0, -G), seed: int = 0) -> dict:
|
|
180
|
+
"""Torque each actuated joint needs, its margin at `payload.kg`, and the
|
|
181
|
+
payload the arm can carry, holding still and moving.
|
|
182
|
+
|
|
183
|
+
For every sampled pose: held still, and moving (each joint at speed x
|
|
184
|
+
its velocity limit, signs at random, accelerating at velocity / ramp,
|
|
185
|
+
bang-bang). Torques are taken with the payload (at payload.at, its
|
|
186
|
+
nominal mass) and without; torque is affine in payload mass, so each
|
|
187
|
+
joint's capacity in each state is exact. balanced joints have their
|
|
188
|
+
static gravity torque without the payload carried by a counterbalance
|
|
189
|
+
(a spring tuned to the arm). Returns per joint {effort, still, moving}
|
|
190
|
+
with peak torque (N·m) at the rated payload, margin (effort / peak) and
|
|
191
|
+
capacityKg, plus the arm's capacity (the least)."""
|
|
192
|
+
rng = np.random.default_rng(seed)
|
|
193
|
+
jt = robot.joints
|
|
194
|
+
names = robot.movable
|
|
195
|
+
lim = {n: (jt[n].lower, jt[n].upper) for n in names}
|
|
196
|
+
acts = joints or [n for n in names if jt[n].type in ("revolute", "continuous") and jt[n].effort > 0]
|
|
197
|
+
g = np.asarray(gravity, float)
|
|
198
|
+
reach = robot.path_joints(payload.link)
|
|
199
|
+
relevant = [n for n in names if n in reach]
|
|
200
|
+
poses = []
|
|
201
|
+
if corners:
|
|
202
|
+
levels = [(lo, 0.5 * (lo + hi), hi) if lo < hi else (lo,) for lo, hi in (lim[n] for n in relevant)]
|
|
203
|
+
if int(np.prod([len(v) for v in levels])) <= 4096:
|
|
204
|
+
for idx in np.ndindex(*[len(v) for v in levels]):
|
|
205
|
+
poses.append({n: levels[i][idx[i]] for i, n in enumerate(relevant)})
|
|
206
|
+
for _ in range(samples):
|
|
207
|
+
poses.append({n: rng.uniform(*lim[n]) for n in relevant})
|
|
208
|
+
pay = (1.0, np.asarray(payload.at, float), payload.link)
|
|
209
|
+
effort = np.array([jt[n].effort for n in acts])
|
|
210
|
+
rows = {"still": ([], []), "moving": ([], [])}
|
|
211
|
+
for q in poses:
|
|
212
|
+
s_arm = _State(robot, q, None, None, g)
|
|
213
|
+
s_pay = _State(robot, q, None, None, g, pay)
|
|
214
|
+
spring = np.array([s_arm.torque(n) if n in balanced else 0.0 for n in acts])
|
|
215
|
+
t0 = np.array([s_arm.torque(n) for n in acts]) - spring
|
|
216
|
+
t1 = np.array([s_pay.torque(n) for n in acts]) - spring
|
|
217
|
+
rows["still"][0].append(t0)
|
|
218
|
+
rows["still"][1].append(t1 - t0)
|
|
219
|
+
if ramp:
|
|
220
|
+
qd = {n: rng.choice([-1.0, 1.0]) * speed * jt[n].velocity for n in relevant}
|
|
221
|
+
qdd = {n: rng.choice([-1.0, 1.0]) * jt[n].velocity / ramp for n in relevant}
|
|
222
|
+
d_arm = _State(robot, q, qd, qdd, g)
|
|
223
|
+
d_pay = _State(robot, q, qd, qdd, g, pay)
|
|
224
|
+
t0 = np.array([d_arm.torque(n) for n in acts]) - spring
|
|
225
|
+
t1 = np.array([d_pay.torque(n) for n in acts]) - spring
|
|
226
|
+
rows["moving"][0].append(t0)
|
|
227
|
+
rows["moving"][1].append(t1 - t0)
|
|
228
|
+
out = {"payloadKg": payload.kg, "ramp": ramp, "samples": len(poses), "balanced": list(balanced), "joints": {}}
|
|
229
|
+
cap_arm = {}
|
|
230
|
+
for mode, (a, b) in rows.items():
|
|
231
|
+
if not a:
|
|
232
|
+
continue
|
|
233
|
+
a, b = np.array(a), np.array(b) # (states, joints): arm alone, per kg of payload
|
|
234
|
+
need = np.abs(a + payload.kg * b)
|
|
235
|
+
# largest p >= 0 with |a + p b| <= e in every state
|
|
236
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
237
|
+
cap = np.where(np.abs(b) > 1e-12, (effort - np.sign(b) * a) / np.abs(b), np.inf)
|
|
238
|
+
cap = np.where(np.abs(a) > effort, -np.inf, cap) # the arm alone is already over
|
|
239
|
+
per = cap.min(axis=0)
|
|
240
|
+
for i, n in enumerate(acts):
|
|
241
|
+
k = int(np.argmax(need[:, i]))
|
|
242
|
+
out["joints"].setdefault(n, {"effort": float(effort[i])})[mode] = {
|
|
243
|
+
"peakNm": round(float(need[k, i]), 3), "armOnlyNm": round(float(np.abs(a[:, i]).max()), 3),
|
|
244
|
+
"margin": round(float(effort[i] / need[k, i]), 3) if need[k, i] > 0 else None,
|
|
245
|
+
"capacityKg": round(float(max(per[i], 0.0)), 3) if np.isfinite(per[i]) else None}
|
|
246
|
+
j = int(np.argmin(per))
|
|
247
|
+
cap_arm[mode] = {"capacityKg": round(float(max(per[j], 0.0)), 3), "limitedBy": acts[j]}
|
|
248
|
+
out["capacity"] = cap_arm
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --------------------------------------------------------------------------- the loop
|
|
253
|
+
|
|
254
|
+
def load_change(old_cases: list, new_cases: list, region: str | None = None) -> float:
|
|
255
|
+
"""Relative change between two sets of governing cases: of the largest
|
|
256
|
+
force and the largest moment at `region` (the larger of the two)."""
|
|
257
|
+
def env(cases):
|
|
258
|
+
f = m = 0.0
|
|
259
|
+
for c in cases:
|
|
260
|
+
for ld in c["loads"]:
|
|
261
|
+
if region and ld.get("region") != region:
|
|
262
|
+
continue
|
|
263
|
+
f = max(f, float(np.linalg.norm(ld.get("force", [0, 0, 0]))))
|
|
264
|
+
m = max(m, float(np.linalg.norm(ld.get("moment", [0, 0, 0]))))
|
|
265
|
+
return f, m
|
|
266
|
+
(f0, m0), (f1, m1) = env(old_cases), env(new_cases)
|
|
267
|
+
return max(abs(f1 - f0) / max(f0, 1e-9), abs(m1 - m0) / max(m0, 1e-9))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def co_optimize(arm: ArmModel, order: list, derive, solve, *, verify=None, passes: int = 3, tol: float = 0.02,
|
|
271
|
+
state: dict | None = None, save=None, log=print) -> dict:
|
|
272
|
+
"""Regrow `order` (links, tip of the chain first) until their loads stop
|
|
273
|
+
moving.
|
|
274
|
+
|
|
275
|
+
derive(link) -> (load cases, info): the link's governing cases for the
|
|
276
|
+
arm's current masses (arm.robot). solve(link, cases) -> (parts for
|
|
277
|
+
ArmModel.set_parts, report dict with "g") or None when nothing meets the
|
|
278
|
+
goal. verify(link, cases) -> report dict with "g" and "passes": the
|
|
279
|
+
link's current part checked under the new cases. A link is regrown when
|
|
280
|
+
its derived loads moved by more than `tol` (load_change) since the loads
|
|
281
|
+
its part was grown or checked for, or when its part no longer passes.
|
|
282
|
+
The regrown part replaces the current one only when it is lighter or the
|
|
283
|
+
current one fails: an optimizer's outcome moves by a few percent from
|
|
284
|
+
run to run, more than a few percent of load moves the optimum, so a
|
|
285
|
+
passing part is not traded for a heavier one. state: a previous return
|
|
286
|
+
value, to resume; save(state) is called after every step. Returns
|
|
287
|
+
{"history": [...], "links": {link: {cases, report, pass}}, "converged"}."""
|
|
288
|
+
state = state or {"history": [], "links": {}, "converged": False}
|
|
289
|
+
for p in range(passes):
|
|
290
|
+
if state.get("pass", -1) > p:
|
|
291
|
+
continue
|
|
292
|
+
state["pass"] = p
|
|
293
|
+
changed = state.setdefault("changed", {}).setdefault(str(p), 0)
|
|
294
|
+
for link in order:
|
|
295
|
+
done = state.get("done", {}).get(str(p), [])
|
|
296
|
+
if link in done:
|
|
297
|
+
continue
|
|
298
|
+
t = time.time()
|
|
299
|
+
cases, info = derive(link)
|
|
300
|
+
prev = state["links"].get(link)
|
|
301
|
+
change = None if prev is None else load_change(prev["cases"], cases, info.get("region"))
|
|
302
|
+
row = {"pass": p, "link": link, "loadChange": None if change is None else round(change, 4),
|
|
303
|
+
"deriveSeconds": round(time.time() - t, 1), "massesBefore": arm.masses()}
|
|
304
|
+
cur = verify(link, cases) if (verify and prev is not None) else None
|
|
305
|
+
if cur is not None:
|
|
306
|
+
row["current"] = cur
|
|
307
|
+
ok = cur is None or cur.get("passes", True)
|
|
308
|
+
if prev is not None and change <= tol and ok:
|
|
309
|
+
row["result"] = "loads within tolerance; kept"
|
|
310
|
+
else:
|
|
311
|
+
t = time.time()
|
|
312
|
+
res = solve(link, cases)
|
|
313
|
+
row["solveSeconds"] = round(time.time() - t, 1)
|
|
314
|
+
if res is not None:
|
|
315
|
+
row["report"] = res[1]
|
|
316
|
+
if res is None:
|
|
317
|
+
row["result"] = "no outcome met the goal; part kept"
|
|
318
|
+
elif cur is not None and ok and cur["g"] <= res[1]["g"]:
|
|
319
|
+
# the part it has carries the new loads and is lighter
|
|
320
|
+
state["links"][link] = {**prev, "cases": cases, "report": {**prev["report"], **cur}, "info": info}
|
|
321
|
+
row["result"] = f"regrown at {res[1]['g']} g, heavier than the current part; kept"
|
|
322
|
+
else:
|
|
323
|
+
arm.set_parts(link, res[0])
|
|
324
|
+
state["links"][link] = {"cases": cases, "report": res[1], "pass": p, "info": info}
|
|
325
|
+
row["result"] = "regrown"
|
|
326
|
+
changed += 1
|
|
327
|
+
state["changed"][str(p)] = changed
|
|
328
|
+
row["printedKg"] = round(arm.printed_kg(link), 4)
|
|
329
|
+
state["history"].append(row)
|
|
330
|
+
state.setdefault("done", {}).setdefault(str(p), []).append(link)
|
|
331
|
+
log(json.dumps({k: v for k, v in row.items() if k not in ("massesBefore", "report", "current")}
|
|
332
|
+
| {"g": (row.get("report") or {}).get("g"), "currentG": (cur or {}).get("g"),
|
|
333
|
+
"currentSf": (cur or {}).get("sf")}))
|
|
334
|
+
if save:
|
|
335
|
+
save(state)
|
|
336
|
+
if changed == 0:
|
|
337
|
+
state["converged"] = True
|
|
338
|
+
state["pass"] = passes
|
|
339
|
+
if save:
|
|
340
|
+
save(state)
|
|
341
|
+
break
|
|
342
|
+
return state
|
loadpath/cores.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pick a part's core for the process it will be printed by.
|
|
3
|
+
|
|
4
|
+
A stiffness-bound part like the arm link can be built several ways inside the
|
|
5
|
+
same envelope: a plain hollow skin, a box section with ribs, a skin over a
|
|
6
|
+
lattice core. Which is lightest depends on the analysis; which can be built
|
|
7
|
+
depends on the process. A hollow with a 26 mm flat roof over a sealed cavity
|
|
8
|
+
is the lightest arm link on paper and prints on SLS or MJF (nothing needs
|
|
9
|
+
support there, the powder comes out through escape holes), but on FDM the
|
|
10
|
+
roof has nothing under it and support printed inside can never come out.
|
|
11
|
+
|
|
12
|
+
`choose_core` tunes every option (tune.tune_field, each confirmed on its
|
|
13
|
+
fine grids), meshes each tuned part, and reads it the way each process
|
|
14
|
+
would build it (printcheck.check_print along `up`):
|
|
15
|
+
|
|
16
|
+
fdm - support trapped inside a sealed cavity (`trappedSupportArea`)
|
|
17
|
+
must stay under `trappedLimit` mm^2 (default 50: a few
|
|
18
|
+
stray cells of a stencil that flags every 45-degree patch).
|
|
19
|
+
sls, mjf - nothing needs support; a sealed void needs escape holes
|
|
20
|
+
for the powder (`drains`), reported, not refused.
|
|
21
|
+
|
|
22
|
+
The lightest option that met its loads (on the confirming grid) and the
|
|
23
|
+
process wins, by its meshed mass. A tuned option is reused from a previous
|
|
24
|
+
run in the same folder when its graph has not changed.
|
|
25
|
+
|
|
26
|
+
spec = {"options": [{"name": "hollow", "graph": "arm-link-hollow-tune.json"},
|
|
27
|
+
{"name": "box", "graph": "arm-link-box-tune.json"},
|
|
28
|
+
{"name": "sandwich", "graph": "arm-link-sandwich-tune.json"}],
|
|
29
|
+
"processes": ["fdm", "sls"], "up": "+z"}
|
|
30
|
+
rep = choose_core(spec, base_dir="examples/fields", out_dir="cores-out")
|
|
31
|
+
rep["winners"]["fdm"] # {"name": ..., "mass": ..., ...}
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
|
|
39
|
+
__all__ = ["choose_core", "PROCESSES", "TRAPPED_LIMIT"]
|
|
40
|
+
|
|
41
|
+
PROCESSES = ("fdm", "sls", "mjf")
|
|
42
|
+
TRAPPED_LIMIT = 50.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _digest(doc) -> str:
|
|
46
|
+
return hashlib.sha256(json.dumps(doc, sort_keys=True).encode()).hexdigest()[:16]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _up(up):
|
|
50
|
+
"""'+z' -> (2, 1), the build direction check_print takes."""
|
|
51
|
+
if isinstance(up, (list, tuple)):
|
|
52
|
+
return up
|
|
53
|
+
s = str(up).strip()
|
|
54
|
+
sign = -1.0 if s.startswith("-") else 1.0
|
|
55
|
+
return ({"x": 0, "y": 1, "z": 2}[s.lstrip("+-").lower()], sign)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def choose_core(spec: dict, base_dir=None, out_dir=None, progress=None, pitch: float = 0.3) -> dict:
|
|
59
|
+
"""Tune each of spec["options"], read each tuned part for each of
|
|
60
|
+
spec["processes"] and return {"options": [...], "winners": {process:
|
|
61
|
+
option or None}}. Each option: name, graph (path or graph doc with a
|
|
62
|
+
`tune` block); spec: processes (default ["fdm"]), up (default "+z"),
|
|
63
|
+
trappedLimit, evaluations (overrides each option's)."""
|
|
64
|
+
from .fields import FieldGraph
|
|
65
|
+
from .materials import material_from_spec
|
|
66
|
+
from .printcheck import check_print
|
|
67
|
+
from .tune import _study, tune_field
|
|
68
|
+
|
|
69
|
+
options = spec.get("options") or []
|
|
70
|
+
if len(options) < 2:
|
|
71
|
+
raise ValueError("cores: give at least two options {name, graph}")
|
|
72
|
+
processes = [p.lower() for p in spec.get("processes") or ["fdm"]]
|
|
73
|
+
bad = [p for p in processes if p not in PROCESSES]
|
|
74
|
+
if bad:
|
|
75
|
+
raise ValueError(f"cores: unknown process {bad} (have {', '.join(PROCESSES)})")
|
|
76
|
+
up = _up(spec.get("up", "+z"))
|
|
77
|
+
trapped_limit = float(spec.get("trappedLimit", TRAPPED_LIMIT))
|
|
78
|
+
rows = []
|
|
79
|
+
for opt in options:
|
|
80
|
+
name = opt["name"]
|
|
81
|
+
doc = opt["graph"]
|
|
82
|
+
gdir = base_dir
|
|
83
|
+
if isinstance(doc, str):
|
|
84
|
+
path = doc if os.path.isabs(doc) or base_dir is None else os.path.join(base_dir, doc)
|
|
85
|
+
with open(path) as fh:
|
|
86
|
+
doc = json.load(fh)
|
|
87
|
+
odir = os.path.join(out_dir, name) if out_dir else None
|
|
88
|
+
key = _digest({"doc": doc, "evaluations": spec.get("evaluations")})
|
|
89
|
+
cached = None
|
|
90
|
+
if odir and os.path.exists(os.path.join(odir, "core.json")):
|
|
91
|
+
with open(os.path.join(odir, "core.json")) as fh:
|
|
92
|
+
c = json.load(fh)
|
|
93
|
+
if c.get("key") == key:
|
|
94
|
+
cached = c
|
|
95
|
+
if progress:
|
|
96
|
+
progress({"event": "core-option", "name": name, "reused": cached is not None})
|
|
97
|
+
if cached is None:
|
|
98
|
+
out = tune_field(doc, gdir, odir, progress=progress, evaluations=spec.get("evaluations"))
|
|
99
|
+
best = out["best"]
|
|
100
|
+
row = {"name": name, "key": key, "tuned": out["doc"],
|
|
101
|
+
"best": None if best is None else {k: best.get(k) for k in
|
|
102
|
+
("values", "mass", "safetyFactor", "deflection", "ok",
|
|
103
|
+
"unconfirmed", "check")}}
|
|
104
|
+
else:
|
|
105
|
+
row = cached
|
|
106
|
+
best = row["best"]
|
|
107
|
+
row["meetsLoads"] = bool(best and best.get("ok"))
|
|
108
|
+
if row["meetsLoads"] and "print" not in row:
|
|
109
|
+
tuned = {k: v for k, v in row["tuned"].items() if k != "tune"}
|
|
110
|
+
mesh = FieldGraph(tuned, base_dir=gdir).mesh(pitch=pitch)
|
|
111
|
+
dens = (material_from_spec(tuned["material"]).density if "material" in tuned
|
|
112
|
+
else _study(doc["tune"], gdir).material.density)
|
|
113
|
+
row["meshedMass"] = float(abs(mesh.volume) * dens / 1000.0)
|
|
114
|
+
pr = check_print(mesh, up, "fdm", pitch=0.4)
|
|
115
|
+
row["print"] = {k: pr.get(k) for k in ("supportArea", "trappedSupportArea", "sealedVoidVolume",
|
|
116
|
+
"islands", "maxBridgeSpan", "bridges")}
|
|
117
|
+
if odir:
|
|
118
|
+
os.makedirs(odir, exist_ok=True)
|
|
119
|
+
mesh.export(os.path.join(odir, f"{name}.stl"))
|
|
120
|
+
if odir:
|
|
121
|
+
os.makedirs(odir, exist_ok=True)
|
|
122
|
+
with open(os.path.join(odir, "core.json"), "w") as fh:
|
|
123
|
+
json.dump(row, fh, indent=1)
|
|
124
|
+
row["processes"] = {}
|
|
125
|
+
for proc in processes:
|
|
126
|
+
if not row["meetsLoads"]:
|
|
127
|
+
row["processes"][proc] = {"ok": False, "why": "no tuned candidate met the loads on the "
|
|
128
|
+
"confirming grid"}
|
|
129
|
+
continue
|
|
130
|
+
pr = row["print"]
|
|
131
|
+
if proc == "fdm":
|
|
132
|
+
ok = pr["trappedSupportArea"] <= trapped_limit
|
|
133
|
+
why = None if ok else (f"{pr['trappedSupportArea']:.0f} mm^2 of support trapped inside a sealed "
|
|
134
|
+
f"cavity (limit {trapped_limit:g})")
|
|
135
|
+
row["processes"][proc] = {"ok": ok, "why": why}
|
|
136
|
+
else:
|
|
137
|
+
note = "sealed void: add escape holes (drains)" if (pr.get("sealedVoidVolume") or 0) > 1.0 else None
|
|
138
|
+
row["processes"][proc] = {"ok": True, "why": None, "note": note}
|
|
139
|
+
rows.append(row)
|
|
140
|
+
if progress:
|
|
141
|
+
progress({"event": "core-read", "name": name, "meetsLoads": row["meetsLoads"],
|
|
142
|
+
"meshedMass": row.get("meshedMass"), **{p: v["ok"] for p, v in row["processes"].items()}})
|
|
143
|
+
winners = {}
|
|
144
|
+
for proc in processes:
|
|
145
|
+
ok = [r for r in rows if r["processes"][proc]["ok"]]
|
|
146
|
+
w = min(ok, key=lambda r: r["meshedMass"]) if ok else None
|
|
147
|
+
winners[proc] = None if w is None else {"name": w["name"], "meshedMass": w["meshedMass"],
|
|
148
|
+
"deflection": w["best"]["check"]["deflection"]
|
|
149
|
+
if w["best"].get("check") else w["best"]["deflection"],
|
|
150
|
+
"safetyFactor": w["best"]["safetyFactor"]}
|
|
151
|
+
rep = {"processes": processes, "up": spec.get("up", "+z"), "trappedLimit": trapped_limit,
|
|
152
|
+
"options": [{k: v for k, v in r.items() if k not in ("tuned", "key")} for r in rows],
|
|
153
|
+
"winners": winners}
|
|
154
|
+
if out_dir:
|
|
155
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
156
|
+
with open(os.path.join(out_dir, "cores.json"), "w") as fh:
|
|
157
|
+
json.dump(rep, fh, indent=1)
|
|
158
|
+
for proc, w in winners.items():
|
|
159
|
+
if w is not None:
|
|
160
|
+
tuned = next(r["tuned"] for r in rows if r["name"] == w["name"])
|
|
161
|
+
with open(os.path.join(out_dir, f"{proc}.json"), "w") as fh:
|
|
162
|
+
json.dump(tuned, fh, indent=1)
|
|
163
|
+
return rep
|