loadpath 0.1.1__py3-none-any.whl

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