roboapi 0.4.2__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 (94) hide show
  1. roboapi/__init__.py +2 -0
  2. roboapi/calibration/__init__.py +6 -0
  3. roboapi/calibration/profile.py +139 -0
  4. roboapi/calibration/session.py +350 -0
  5. roboapi/cli.py +215 -0
  6. roboapi/config/__init__.py +22 -0
  7. roboapi/config/loader.py +119 -0
  8. roboapi/config/models.py +191 -0
  9. roboapi/embodiment.py +251 -0
  10. roboapi/errors.py +37 -0
  11. roboapi/geometry.py +337 -0
  12. roboapi/hardware/__init__.py +5 -0
  13. roboapi/hardware/base.py +113 -0
  14. roboapi/hardware/camera.py +167 -0
  15. roboapi/hardware/franka/__init__.py +5 -0
  16. roboapi/hardware/franka/arm.py +476 -0
  17. roboapi/hardware/franka/desk.py +555 -0
  18. roboapi/hardware/franka/feedback.py +196 -0
  19. roboapi/hardware/franka/guiding.py +104 -0
  20. roboapi/hardware/franka/ik.py +543 -0
  21. roboapi/hardware/franka/limits.py +110 -0
  22. roboapi/hardware/franka/settings.py +116 -0
  23. roboapi/hardware/robotiq.py +350 -0
  24. roboapi/hardware/ur/__init__.py +16 -0
  25. roboapi/hardware/ur/build.py +92 -0
  26. roboapi/hardware/ur/compliant.py +128 -0
  27. roboapi/hardware/ur/dashboard.py +103 -0
  28. roboapi/hardware/ur/feedback.py +159 -0
  29. roboapi/hardware/ur/ipc.py +206 -0
  30. roboapi/hardware/ur/native/CMakeLists.txt +37 -0
  31. roboapi/hardware/ur/native/dependencies.lock.json +13 -0
  32. roboapi/hardware/ur/native/include/ur_cpp/cartesian_speedl.hpp +170 -0
  33. roboapi/hardware/ur/native/include/ur_cpp/control_loop.hpp +126 -0
  34. roboapi/hardware/ur/native/include/ur_cpp/geom.hpp +72 -0
  35. roboapi/hardware/ur/native/include/ur_cpp/motion_primitives.hpp +350 -0
  36. roboapi/hardware/ur/native/include/ur_cpp/robot_io.hpp +26 -0
  37. roboapi/hardware/ur/native/include/ur_cpp/streaming_pose.hpp +369 -0
  38. roboapi/hardware/ur/native/src/control_loop.cpp +512 -0
  39. roboapi/hardware/ur/native/src/robot_io_urcl.cpp +143 -0
  40. roboapi/hardware/ur/native/tests/cartesian_speedl_unit.cpp +113 -0
  41. roboapi/hardware/ur/native/tests/control_modes_unit.cpp +137 -0
  42. roboapi/hardware/ur/native/tests/streaming_pose_unit.cpp +348 -0
  43. roboapi/hardware/ur/native/tests/velocity_governor_unit.cpp +65 -0
  44. roboapi/hardware/ur/native/worker.cpp +147 -0
  45. roboapi/hardware/ur/position.py +253 -0
  46. roboapi/hardware/ur/settings.py +95 -0
  47. roboapi/images.py +16 -0
  48. roboapi/initialize.py +137 -0
  49. roboapi/mcp.py +497 -0
  50. roboapi/motion.py +125 -0
  51. roboapi/naming.py +7 -0
  52. roboapi/operations.py +45 -0
  53. roboapi/policy/__init__.py +7 -0
  54. roboapi/policy/client.py +277 -0
  55. roboapi/policy/protocol.py +166 -0
  56. roboapi/policy/settings.py +79 -0
  57. roboapi/product-names.json +12 -0
  58. roboapi/records.py +149 -0
  59. roboapi/runtime.py +911 -0
  60. roboapi/semantics.py +6 -0
  61. roboapi/tasks.py +337 -0
  62. roboapi/web/__init__.py +5 -0
  63. roboapi/web/presets.py +44 -0
  64. roboapi/web/server.py +419 -0
  65. roboapi/web/static/assets/Bundler-LICENSE.txt +48 -0
  66. roboapi/web/static/assets/CalibrationScene-BO_spRoL.js +4220 -0
  67. roboapi/web/static/assets/Inter-OFL.txt +93 -0
  68. roboapi/web/static/assets/Inter-Variable-c8O0ljhh.ttf +0 -0
  69. roboapi/web/static/assets/Lucide-LICENSE.txt +43 -0
  70. roboapi/web/static/assets/Meshoptimizer-LICENSE.txt +23 -0
  71. roboapi/web/static/assets/MiSans-Regular-ES4DrbaF.woff2 +0 -0
  72. roboapi/web/static/assets/MiSans-Semibold-B34HIk9C.woff2 +0 -0
  73. roboapi/web/static/assets/PokeBrand-NOTICE.txt +20 -0
  74. roboapi/web/static/assets/React-LICENSE.txt +23 -0
  75. roboapi/web/static/assets/Three-LICENSE.txt +23 -0
  76. roboapi/web/static/assets/home-reference-CTpf8-oV.png +0 -0
  77. roboapi/web/static/assets/index-CGLhpUm0.js +28 -0
  78. roboapi/web/static/assets/index-QXqV-fDC.css +1 -0
  79. roboapi/web/static/assets/robotiq-ace-pro-2-EJuoOXTy.glb +0 -0
  80. roboapi/web/static/index.html +15 -0
  81. roboapi-0.4.2.dist-info/METADATA +205 -0
  82. roboapi-0.4.2.dist-info/RECORD +94 -0
  83. roboapi-0.4.2.dist-info/WHEEL +5 -0
  84. roboapi-0.4.2.dist-info/entry_points.txt +2 -0
  85. roboapi-0.4.2.dist-info/licenses/LICENSE +202 -0
  86. roboapi-0.4.2.dist-info/licenses/NOTICE +16 -0
  87. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Bundler-LICENSE.txt +48 -0
  88. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Inter-OFL.txt +93 -0
  89. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Lucide-LICENSE.txt +43 -0
  90. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Meshoptimizer-LICENSE.txt +23 -0
  91. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/PokeBrand-NOTICE.txt +20 -0
  92. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/React-LICENSE.txt +23 -0
  93. roboapi-0.4.2.dist-info/licenses/frontend/public/assets/Three-LICENSE.txt +23 -0
  94. roboapi-0.4.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,543 @@
1
+ """Feedback-leashed inverse kinematics for Franka Cartesian control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from dataclasses import dataclass
7
+ from typing import Sequence
8
+
9
+ import numpy as np
10
+ from scipy.optimize import Bounds, LinearConstraint, minimize
11
+
12
+ from .settings import IKConfig
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class IKResult:
17
+ qd: np.ndarray
18
+ dq: np.ndarray
19
+ pose_error: np.ndarray
20
+ minimum_joint_margin: float
21
+ task_scale: float
22
+ joint_velocity_limited: bool
23
+ posture_limited: bool
24
+
25
+
26
+ def _vector(value: Sequence[float], size: int, name: str) -> np.ndarray:
27
+ result = np.asarray(value, dtype=np.float64)
28
+ if result.shape != (size,) or not np.all(np.isfinite(result)):
29
+ raise ValueError(f"{name} must contain {size} finite values")
30
+ return result
31
+
32
+
33
+ def normalize_quaternion(value: Sequence[float]) -> np.ndarray:
34
+ quaternion = _vector(value, 4, "quaternion")
35
+ norm = float(np.linalg.norm(quaternion))
36
+ if norm < 1e-12:
37
+ raise ValueError("quaternion norm is zero")
38
+ return quaternion / norm
39
+
40
+
41
+ def quaternion_error_rotvec(target_xyzw: Sequence[float], current_xyzw: Sequence[float]) -> np.ndarray:
42
+ """Shortest-path world-frame rotation vector from current to target."""
43
+ target = normalize_quaternion(target_xyzw)
44
+ current = normalize_quaternion(current_xyzw)
45
+ if float(np.dot(target, current)) < 0.0:
46
+ target = -target
47
+ tx, ty, tz, tw = target
48
+ cx, cy, cz, cw = current
49
+ # target * inverse(current)
50
+ vector = np.array(
51
+ [tw * -cx + tx * cw + ty * -cz - tz * -cy,
52
+ tw * -cy - tx * -cz + ty * cw + tz * -cx,
53
+ tw * -cz + tx * -cy - ty * -cx + tz * cw],
54
+ dtype=np.float64,
55
+ )
56
+ scalar = tw * cw + tx * cx + ty * cy + tz * cz
57
+ vector_norm = float(np.linalg.norm(vector))
58
+ if vector_norm < 1e-12:
59
+ return np.zeros(3, dtype=np.float64)
60
+ angle = 2.0 * np.arctan2(vector_norm, max(0.0, float(scalar)))
61
+ return vector * (angle / vector_norm)
62
+
63
+
64
+ def _clip_norm(vector: np.ndarray, limit: float) -> np.ndarray:
65
+ norm = float(np.linalg.norm(vector))
66
+ if norm <= limit or norm < 1e-12:
67
+ return vector.copy()
68
+ return vector * (limit / norm)
69
+
70
+
71
+ def _adaptive_regularization(
72
+ min_singular_value: float, config: IKConfig
73
+ ) -> float:
74
+ """Keep normal-space response while damping only the singular region."""
75
+ if config.singularity_hard_threshold >= config.singularity_soft_threshold:
76
+ raise ValueError(
77
+ "singularity_hard_threshold must be less than "
78
+ "singularity_soft_threshold"
79
+ )
80
+ if config.singularity_max_regularization < config.regularization:
81
+ raise ValueError(
82
+ "singularity_max_regularization must be at least regularization"
83
+ )
84
+ if min_singular_value >= config.singularity_soft_threshold:
85
+ return float(config.regularization)
86
+ if min_singular_value <= config.singularity_hard_threshold:
87
+ return float(config.singularity_max_regularization)
88
+ ratio = (
89
+ config.singularity_soft_threshold - min_singular_value
90
+ ) / (
91
+ config.singularity_soft_threshold - config.singularity_hard_threshold
92
+ )
93
+ # Smoothstep avoids a damping derivative discontinuity at either threshold.
94
+ blend = ratio * ratio * (3.0 - 2.0 * ratio)
95
+ return float(
96
+ config.regularization
97
+ + blend * (config.singularity_max_regularization - config.regularization)
98
+ )
99
+
100
+
101
+ def _uniform_task_scale(
102
+ velocity: np.ndarray, lower: np.ndarray, upper: np.ndarray
103
+ ) -> float:
104
+ """Largest [0, 1] scale that preserves the differential-IK direction."""
105
+ scale = 1.0
106
+ for value, low, high in zip(velocity, lower, upper):
107
+ if value > high:
108
+ scale = min(scale, float(high / value))
109
+ elif value < low:
110
+ scale = min(scale, float(low / value))
111
+ return float(np.clip(scale, 0.0, 1.0))
112
+
113
+
114
+ def _solve_bounded_qp(
115
+ hessian: np.ndarray,
116
+ rhs: np.ndarray,
117
+ lower: np.ndarray,
118
+ upper: np.ndarray,
119
+ *,
120
+ projection_direction: np.ndarray | None = None,
121
+ max_projection: float | None = None,
122
+ ) -> np.ndarray:
123
+ """Solve the seven-dimensional IK QP with box and nullspace bounds."""
124
+ if np.any(lower > upper):
125
+ raise ValueError("joint velocity constraints are infeasible")
126
+ constraints = ()
127
+ if projection_direction is not None:
128
+ if (
129
+ max_projection is None
130
+ or not np.isfinite(max_projection)
131
+ or max_projection <= 0
132
+ ):
133
+ raise ValueError("max_projection must be positive")
134
+ direction = _vector(projection_direction, rhs.size, "projection_direction")
135
+ minimum = float(
136
+ np.sum(np.where(direction >= 0.0, direction * lower, direction * upper))
137
+ )
138
+ maximum = float(
139
+ np.sum(np.where(direction >= 0.0, direction * upper, direction * lower))
140
+ )
141
+ if minimum > max_projection or maximum < -max_projection:
142
+ raise np.linalg.LinAlgError(
143
+ "nullspace velocity cap is incompatible with the joint velocity box"
144
+ )
145
+ constraints = (
146
+ LinearConstraint(direction, -max_projection, max_projection),
147
+ )
148
+
149
+ def objective(value: np.ndarray) -> float:
150
+ return float(0.5 * value @ hessian @ value - rhs @ value)
151
+
152
+ with warnings.catch_warnings():
153
+ warnings.filterwarnings(
154
+ "ignore",
155
+ message="Values in x were outside bounds during a minimize step",
156
+ category=RuntimeWarning,
157
+ module="scipy.optimize._optimize",
158
+ )
159
+ result = minimize(
160
+ objective,
161
+ np.clip(np.linalg.solve(hessian, rhs), lower, upper),
162
+ jac=lambda value: hessian @ value - rhs,
163
+ bounds=Bounds(lower, upper),
164
+ constraints=constraints,
165
+ method="SLSQP",
166
+ options={"ftol": 1e-12, "maxiter": 100},
167
+ )
168
+ solution = np.clip(np.asarray(result.x, dtype=np.float64), lower, upper)
169
+ finite = bool(np.all(np.isfinite(solution)))
170
+ projection_ok = projection_direction is None or (
171
+ abs(float(direction @ solution)) <= float(max_projection) + 1e-7
172
+ )
173
+ if not result.success or not finite or not projection_ok:
174
+ raise np.linalg.LinAlgError(f"bounded IK QP failed: {result.message}")
175
+ return solution
176
+
177
+
178
+ def _strict_nullspace_posture(
179
+ *,
180
+ primary_dq: np.ndarray,
181
+ direction: np.ndarray,
182
+ preferred_dq: np.ndarray,
183
+ lower: np.ndarray,
184
+ upper: np.ndarray,
185
+ max_velocity: float,
186
+ ) -> tuple[np.ndarray, bool]:
187
+ """Set the final feasible posture velocity in the exact 7-DoF nullspace.
188
+
189
+ A full-row-rank 6x7 Jacobian always has one structural null direction. By
190
+ parameterising the correction as ``z * alpha`` with ``J z == 0``, the
191
+ secondary posture objective cannot alter the Cartesian velocity already
192
+ chosen by the bounded primary QP. Crucially, ``max_velocity`` limits the
193
+ *final* structural-nullspace component, not the correction added this tick.
194
+ Otherwise a primary solution constrained around the previous command can
195
+ carry old nullspace velocity which is then added again on every cycle.
196
+
197
+ The scalar correction interval is the intersection of all seven joint
198
+ velocity boxes, so acceleration and joint limits remain authoritative
199
+ without another optimisation dependency.
200
+ """
201
+ if not np.isfinite(max_velocity) or max_velocity <= 0.0:
202
+ raise ValueError("posture_max_velocity must be positive")
203
+ if np.any(primary_dq < lower - 1e-9) or np.any(primary_dq > upper + 1e-9):
204
+ raise ValueError("primary IK velocity is outside posture bounds")
205
+
206
+ primary_alpha = float(np.dot(direction, primary_dq))
207
+ desired_alpha = float(np.dot(direction, preferred_dq))
208
+ target_alpha = float(np.clip(desired_alpha, -max_velocity, max_velocity))
209
+ desired_correction_alpha = target_alpha - primary_alpha
210
+
211
+ alpha_lower = -np.inf
212
+ alpha_upper = np.inf
213
+ for value, low, high, component in zip(
214
+ primary_dq, lower, upper, direction
215
+ ):
216
+ if abs(component) < 1e-12:
217
+ continue
218
+ first = float((low - value) / component)
219
+ second = float((high - value) / component)
220
+ alpha_lower = max(alpha_lower, min(first, second))
221
+ alpha_upper = min(alpha_upper, max(first, second))
222
+ if alpha_lower > alpha_upper + 1e-10:
223
+ raise np.linalg.LinAlgError(
224
+ "nullspace posture constraints are infeasible"
225
+ )
226
+ correction_alpha = float(
227
+ np.clip(desired_correction_alpha, alpha_lower, alpha_upper)
228
+ )
229
+ limited = bool(
230
+ abs(target_alpha - desired_alpha) > 1e-9
231
+ or abs(correction_alpha - desired_correction_alpha) > 1e-9
232
+ )
233
+ return primary_dq + direction * correction_alpha, limited
234
+
235
+
236
+ def _posture_motion_activity(
237
+ *,
238
+ translation_speed: float,
239
+ rotation_speed: float,
240
+ translation_threshold: float,
241
+ rotation_threshold: float,
242
+ ) -> float:
243
+ """Smoothly enable posture motion from 1x to 2x each task threshold."""
244
+
245
+ def ramp(speed: float, threshold: float) -> float:
246
+ if threshold == 0.0:
247
+ return float(speed > 0.0)
248
+ ratio = float(np.clip((speed - threshold) / threshold, 0.0, 1.0))
249
+ return ratio * ratio * (3.0 - 2.0 * ratio)
250
+
251
+ return max(
252
+ ramp(translation_speed, translation_threshold),
253
+ ramp(rotation_speed, rotation_threshold),
254
+ )
255
+
256
+
257
+ def solve_feedback_leashed_ik(
258
+ *,
259
+ q: Sequence[float],
260
+ current_pose7: Sequence[float],
261
+ target_pose7: Sequence[float],
262
+ target_twist: Sequence[float] | None = None,
263
+ jacobian: Sequence[Sequence[float]],
264
+ lower_limits: Sequence[float],
265
+ upper_limits: Sequence[float],
266
+ dt: float,
267
+ config: IKConfig,
268
+ previous_dq: Sequence[float] | None = None,
269
+ joint_velocity_bounds: tuple[np.ndarray, np.ndarray] | None = None,
270
+ ) -> IKResult:
271
+ """Compute one bounded q target based only on the latest measured q."""
272
+ qv = _vector(q, 7, "q")
273
+ current = _vector(current_pose7, 7, "current_pose7")
274
+ target = _vector(target_pose7, 7, "target_pose7")
275
+ lower = _vector(lower_limits, 7, "lower_limits")
276
+ upper = _vector(upper_limits, 7, "upper_limits")
277
+ jac = np.asarray(jacobian, dtype=np.float64)
278
+ if jac.shape != (6, 7) or not np.all(np.isfinite(jac)):
279
+ raise ValueError("jacobian must be a finite 6x7 matrix")
280
+ if not np.all(lower < upper):
281
+ raise ValueError("joint limits are invalid")
282
+ if np.any(qv < lower) or np.any(qv > upper):
283
+ raise ValueError("measured joints are outside model limits")
284
+ if not np.isfinite(config.joint_limit_margin) or config.joint_limit_margin <= 0.0:
285
+ raise ValueError("joint_limit_margin must be positive")
286
+ if not np.isfinite(config.nullspace_gain) or config.nullspace_gain < 0.0:
287
+ raise ValueError("nullspace_gain must be non-negative")
288
+ if not np.isfinite(config.nominal_dt) or config.nominal_dt <= 0.0:
289
+ raise ValueError("nominal_dt must be positive")
290
+ if not np.isfinite(config.target_lead_time) or config.target_lead_time <= 0.0:
291
+ raise ValueError("target_lead_time must be positive")
292
+ if (
293
+ not np.isfinite(config.translation_feedback_gain)
294
+ or config.translation_feedback_gain < 0.0
295
+ ):
296
+ raise ValueError("translation_feedback_gain must be non-negative")
297
+ if (
298
+ not np.isfinite(config.rotation_feedback_gain)
299
+ or config.rotation_feedback_gain < 0.0
300
+ ):
301
+ raise ValueError("rotation_feedback_gain must be non-negative")
302
+ posture_reference = _vector(
303
+ config.posture_reference, 7, "posture_reference"
304
+ )
305
+ posture_weights = _vector(config.posture_weights, 7, "posture_weights")
306
+ if (
307
+ config.posture_gain > 0.0
308
+ and (
309
+ np.any(posture_reference < lower)
310
+ or np.any(posture_reference > upper)
311
+ )
312
+ ):
313
+ raise ValueError("posture_reference is outside model limits")
314
+ if np.any(posture_weights < 0.0):
315
+ raise ValueError("posture_weights must be non-negative")
316
+ if not np.isfinite(config.posture_gain) or config.posture_gain < 0.0:
317
+ raise ValueError("posture_gain must be non-negative")
318
+ if (
319
+ not np.isfinite(config.posture_task_velocity_threshold)
320
+ or config.posture_task_velocity_threshold < 0.0
321
+ ):
322
+ raise ValueError("posture_task_velocity_threshold must be non-negative")
323
+ if (
324
+ not np.isfinite(config.posture_rotation_velocity_threshold)
325
+ or config.posture_rotation_velocity_threshold < 0.0
326
+ ):
327
+ raise ValueError(
328
+ "posture_rotation_velocity_threshold must be non-negative"
329
+ )
330
+ max_joint_velocity = _vector(
331
+ config.max_joint_velocity, 7, "max_joint_velocity"
332
+ )
333
+ max_joint_acceleration = _vector(
334
+ config.max_joint_acceleration, 7, "max_joint_acceleration"
335
+ )
336
+ if np.any(max_joint_velocity <= 0.0):
337
+ raise ValueError("max_joint_velocity must contain positive values")
338
+ if np.any(max_joint_acceleration <= 0.0):
339
+ raise ValueError("max_joint_acceleration must contain positive values")
340
+ previous_velocity = (
341
+ np.zeros(7, dtype=np.float64)
342
+ if previous_dq is None
343
+ else _vector(previous_dq, 7, "previous_dq")
344
+ )
345
+
346
+ budget_dt = min(max(float(dt), 0.0), config.nominal_dt)
347
+ if not np.isfinite(budget_dt) or budget_dt <= 0.0:
348
+ budget_dt = config.nominal_dt
349
+ error = np.concatenate(
350
+ [target[:3] - current[:3], quaternion_error_rotvec(target[3:], current[3:])]
351
+ )
352
+ twist = (
353
+ np.zeros(6, dtype=np.float64)
354
+ if target_twist is None
355
+ else _vector(target_twist, 6, "target_twist")
356
+ )
357
+ max_translation_velocity = float(config.max_translation_velocity)
358
+ max_rotation_velocity = float(config.max_rotation_velocity)
359
+ if (not np.isfinite(max_translation_velocity) or max_translation_velocity <= 0.0
360
+ or not np.isfinite(max_rotation_velocity) or max_rotation_velocity <= 0.0):
361
+ raise ValueError("Cartesian velocity limits must be positive and finite")
362
+ if (not np.isfinite(config.max_translation_step) or config.max_translation_step <= 0.0
363
+ or not np.isfinite(config.max_rotation_step) or config.max_rotation_step <= 0.0):
364
+ raise ValueError("Cartesian step limits must be positive and finite")
365
+ max_translation_velocity = min(
366
+ max_translation_velocity, config.max_translation_step / config.nominal_dt,
367
+ )
368
+ max_rotation_velocity = min(
369
+ max_rotation_velocity, config.max_rotation_step / config.nominal_dt,
370
+ )
371
+ feedback_velocity = np.concatenate(
372
+ [
373
+ _clip_norm(
374
+ config.translation_feedback_gain * error[:3],
375
+ max_translation_velocity,
376
+ ),
377
+ _clip_norm(
378
+ config.rotation_feedback_gain * error[3:],
379
+ max_rotation_velocity,
380
+ ),
381
+ ]
382
+ )
383
+ feedforward_velocity = np.concatenate(
384
+ [
385
+ _clip_norm(twist[:3], max_translation_velocity),
386
+ _clip_norm(twist[3:], max_rotation_velocity),
387
+ ]
388
+ )
389
+ cartesian_velocity = np.concatenate(
390
+ [
391
+ _clip_norm(
392
+ feedback_velocity[:3] + feedforward_velocity[:3],
393
+ max_translation_velocity,
394
+ ),
395
+ _clip_norm(
396
+ feedback_velocity[3:] + feedforward_velocity[3:],
397
+ max_rotation_velocity,
398
+ ),
399
+ ]
400
+ )
401
+
402
+ singular_values = np.linalg.svd(jac, compute_uv=False)
403
+ min_singular_value = float(singular_values[-1])
404
+ effective_regularization = _adaptive_regularization(
405
+ min_singular_value, config
406
+ )
407
+ damping2 = effective_regularization**2
408
+ hessian = jac.T @ jac + damping2 * np.eye(7)
409
+ task_rhs = jac.T @ cartesian_velocity
410
+ unconstrained_task_dq = np.linalg.solve(hessian, task_rhs)
411
+
412
+ lower_distance = qv - lower
413
+ upper_distance = upper - qv
414
+ centre = 0.5 * (lower + upper)
415
+ proximity = np.clip(
416
+ 1.0 - np.minimum(lower_distance, upper_distance) / config.joint_limit_margin,
417
+ 0.0, 1.0,
418
+ )
419
+ # Smoothly bias the redundant joint direction away from a nearby limit.
420
+ proximity = proximity * proximity * (3.0 - 2.0 * proximity)
421
+ limit_bias = config.nullspace_gain * proximity * np.sign(centre - qv)
422
+
423
+ posture_error = (
424
+ posture_reference - qv
425
+ if config.posture_gain > 0.0
426
+ else np.zeros(7, dtype=np.float64)
427
+ )
428
+ # Feed-forward twist is the trajectory's motion intent. A smooth activity
429
+ # ramp keeps coordinated Hold exactly still and avoids a posture-velocity
430
+ # step as a segment starts or settles. Translation and rotation use their
431
+ # own physical-unit thresholds; either kind of task motion can enable the
432
+ # secondary objective.
433
+ posture_activity = _posture_motion_activity(
434
+ # Motion intent is the complete Cartesian request. Teleop deliberately
435
+ # suppresses trajectory twist, but its pose-feedback motion must still
436
+ # keep posture control active. A settled Hold has neither component.
437
+ translation_speed=float(np.linalg.norm(cartesian_velocity[:3])),
438
+ rotation_speed=float(np.linalg.norm(cartesian_velocity[3:])),
439
+ translation_threshold=config.posture_task_velocity_threshold,
440
+ rotation_threshold=config.posture_rotation_velocity_threshold,
441
+ )
442
+ posture_preference = (
443
+ posture_activity
444
+ * config.posture_gain
445
+ * posture_weights
446
+ * posture_error
447
+ )
448
+ posture_preference += limit_bias
449
+
450
+ # Apply step budgets and directional slowdown to a symmetric velocity box.
451
+ # Intersect the official box afterwards: it may require inward motion and
452
+ # must never be relaxed by multiplying one of its bounds toward zero.
453
+ max_step = _vector(config.max_joint_step, 7, "max_joint_step")
454
+ if np.any(max_step <= 0.0):
455
+ raise ValueError("max_joint_step must contain positive values")
456
+ step_velocity = max_step * (budget_dt / config.nominal_dt) / config.nominal_dt
457
+ velocity_limit = np.minimum(max_joint_velocity, step_velocity)
458
+ base_lower = -velocity_limit * np.clip(lower_distance / config.joint_limit_margin, 0.0, 1.0)
459
+ base_upper = velocity_limit * np.clip(upper_distance / config.joint_limit_margin, 0.0, 1.0)
460
+ if joint_velocity_bounds is not None:
461
+ velocity_lower = _vector(joint_velocity_bounds[0], 7, "joint_velocity_bounds lower")
462
+ velocity_upper = _vector(joint_velocity_bounds[1], 7, "joint_velocity_bounds upper")
463
+ base_lower = np.maximum(base_lower, velocity_lower)
464
+ base_upper = np.minimum(base_upper, velocity_upper)
465
+ position_horizon = max(config.nominal_dt, config.target_lead_time)
466
+ base_lower = np.maximum(base_lower, (lower - qv) / position_horizon)
467
+ base_upper = np.minimum(base_upper, (upper - qv) / position_horizon)
468
+
469
+ acceleration_delta = max_joint_acceleration * budget_dt
470
+ lower_velocity = np.maximum(
471
+ base_lower, previous_velocity - acceleration_delta
472
+ )
473
+ upper_velocity = np.minimum(
474
+ base_upper, previous_velocity + acceleration_delta
475
+ )
476
+ if np.any(lower_velocity > upper_velocity):
477
+ raise ValueError("joint position, velocity and acceleration constraints are infeasible")
478
+
479
+ # First scale the complete Cartesian task uniformly. This preserves its
480
+ # direction when a velocity, acceleration, or position constraint becomes
481
+ # active instead of letting independent joint clips bend the requested
482
+ # path. During a reversal zero may be outside the acceleration window; in
483
+ # that transient the QP performs the required bounded deceleration.
484
+ zero_is_feasible = np.all(lower_velocity <= 0.0) and np.all(
485
+ upper_velocity >= 0.0
486
+ )
487
+ base_contains_zero = np.all(base_lower <= 0.0) and np.all(base_upper >= 0.0)
488
+ task_scale = 1.0
489
+ if base_contains_zero:
490
+ task_scale = _uniform_task_scale(
491
+ unconstrained_task_dq,
492
+ lower_velocity if zero_is_feasible else base_lower,
493
+ upper_velocity if zero_is_feasible else base_upper,
494
+ )
495
+ # A position-dependent official box can require inward motion and exclude
496
+ # zero. Scaling from zero is invalid there; the QP handles that box directly.
497
+ scaled_cartesian_velocity = task_scale * cartesian_velocity
498
+
499
+ # First solve the bounded Cartesian task with no posture trade-off. Then
500
+ # use only its exact structural nullspace for the continuous secondary
501
+ # posture target. This is a strict-priority hierarchy, not a weighted
502
+ # objective that can smooth or bend the requested end-effector velocity.
503
+ rhs = jac.T @ scaled_cartesian_velocity
504
+ _, _, vh = np.linalg.svd(jac, full_matrices=True)
505
+ null_direction = np.asarray(vh[-1], dtype=np.float64)
506
+ null_direction /= max(float(np.linalg.norm(null_direction)), 1e-12)
507
+ primary_dq = _solve_bounded_qp(
508
+ hessian,
509
+ rhs,
510
+ lower_velocity,
511
+ upper_velocity,
512
+ projection_direction=null_direction,
513
+ max_projection=config.posture_max_velocity,
514
+ )
515
+ dq, posture_limited = _strict_nullspace_posture(
516
+ primary_dq=primary_dq,
517
+ direction=null_direction,
518
+ preferred_dq=posture_preference,
519
+ lower=lower_velocity,
520
+ upper=upper_velocity,
521
+ max_velocity=config.posture_max_velocity,
522
+ )
523
+ if abs(float(null_direction @ dq)) > config.posture_max_velocity + 1e-7:
524
+ raise np.linalg.LinAlgError("final structural-nullspace velocity exceeded its hard cap")
525
+ # qd is a bounded local position lead, rebuilt from measured q every tick.
526
+ # The same constrained dq is also the damping reference, so the position
527
+ # and velocity terms agree and no target debt accumulates.
528
+ lead_time = float(config.target_lead_time)
529
+ qd = qv + dq * lead_time
530
+ joint_velocity_limited = bool(
531
+ task_scale < 1.0 - 1e-6
532
+ or np.any(np.isclose(dq, lower_velocity, atol=1e-6))
533
+ or np.any(np.isclose(dq, upper_velocity, atol=1e-6))
534
+ )
535
+ return IKResult(
536
+ qd=qd,
537
+ dq=dq,
538
+ pose_error=error,
539
+ minimum_joint_margin=float(np.min(np.minimum(lower_distance, upper_distance))),
540
+ task_scale=task_scale,
541
+ joint_velocity_limited=joint_velocity_limited,
542
+ posture_limited=posture_limited,
543
+ )
@@ -0,0 +1,110 @@
1
+ """Official model limits and FR3 position-dependent velocity bounds.
2
+
3
+ FR3 5.9+ supplies its parameters in the robot's URDF. Earlier FR3 systems use
4
+ the libfranka 0.13.3 formula; Panda uses fixed nominal limits.
5
+
6
+ Sources:
7
+ https://frankarobotics.github.io/docs/robot_specifications.html
8
+ https://github.com/frankarobotics/libfranka/blob/0.21.2/src/joint_velocity_limits.cpp
9
+ https://github.com/frankarobotics/libfranka/blob/0.13.3/include/franka/rate_limiting.h
10
+ https://github.com/frankarobotics/docs/blob/73f863bb76c6fc1f9ecdb3c6838569e552f3bad0/source/control-parameters-joint-fr3.csv
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ import xml.etree.ElementTree as ET
17
+
18
+ import numpy as np
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class FrankaLimits:
23
+ joint_lower: tuple[float, ...]
24
+ joint_upper: tuple[float, ...]
25
+ joint_velocity: tuple[float, ...]
26
+ joint_acceleration: tuple[float, ...]
27
+ cartesian_velocity: tuple[float, float]
28
+ _velocity_lower: tuple[float, ...] | None = field(default=None, repr=False)
29
+ _velocity_upper: tuple[float, ...] | None = field(default=None, repr=False)
30
+ _velocity_offset: tuple[float, ...] | None = field(default=None, repr=False)
31
+ _deceleration: tuple[float, ...] | None = field(default=None, repr=False)
32
+
33
+ def velocity_bounds(self, q) -> tuple[np.ndarray, np.ndarray]:
34
+ positions = np.asarray(q, dtype=np.float64)
35
+ if positions.shape != (7,) or not np.all(np.isfinite(positions)):
36
+ raise ValueError("joint positions must contain seven finite values")
37
+ maximum = np.asarray(self.joint_velocity)
38
+ if self._velocity_offset is None:
39
+ return -maximum, maximum.copy()
40
+ offset = np.asarray(self._velocity_offset)
41
+ deceleration = np.asarray(self._deceleration)
42
+ upper = np.minimum(maximum, np.maximum(
43
+ 0.0, -offset + np.sqrt(np.maximum(
44
+ 0.0, 2.0 * deceleration * (np.asarray(self._velocity_upper) - positions),
45
+ )),
46
+ ))
47
+ lower = np.maximum(-maximum, np.minimum(
48
+ 0.0, offset - np.sqrt(np.maximum(
49
+ 0.0, 2.0 * deceleration * (positions - np.asarray(self._velocity_lower)),
50
+ )),
51
+ ))
52
+ # libfranka's kJointVelocityLimitsTolerance is 0.001 for both FR3 versions.
53
+ return lower + 0.001, upper - 0.001
54
+
55
+
56
+ _FR3_VELOCITY = (2.62, 2.62, 2.62, 2.62, 5.26, 4.18, 5.26)
57
+ _FR3_ACCELERATION = (10.0,) * 7
58
+ _FR3_56 = FrankaLimits(
59
+ joint_lower=(-2.7437, -1.7837, -2.9007, -3.0421, -2.8065, 0.5445, -3.0159),
60
+ joint_upper=(2.7437, 1.7837, 2.9007, -0.1518, 2.8065, 4.5169, 3.0159),
61
+ joint_velocity=_FR3_VELOCITY,
62
+ joint_acceleration=_FR3_ACCELERATION,
63
+ cartesian_velocity=(3.0, 2.5),
64
+ # These curve endpoints differ from the older system's joint-position limits.
65
+ _velocity_lower=(-2.7501, -1.7918, -2.9065, -3.0481, -2.8101, 0.54092, -3.0196),
66
+ _velocity_upper=(2.7501, 1.7918, 2.9065, -0.1458, 2.8101, 4.5205, 3.0196),
67
+ _velocity_offset=(0.30, 0.20, 0.20, 0.30, 0.35, 0.35, 0.35),
68
+ _deceleration=(6.0, 2.585, 3.5, 4.0, 17.0, 5.5, 17.0),
69
+ )
70
+ _PANDA = FrankaLimits(
71
+ joint_lower=(-2.8973, -1.7628, -2.8973, -3.0718, -2.8973, -0.0175, -2.8973),
72
+ joint_upper=(2.8973, 1.7628, 2.8973, -0.0698, 2.8973, 3.7525, 2.8973),
73
+ joint_velocity=(2.175, 2.175, 2.175, 2.175, 2.61, 2.61, 2.61),
74
+ joint_acceleration=(15.0, 7.5, 10.0, 12.5, 15.0, 20.0, 20.0),
75
+ cartesian_velocity=(1.7, 2.5),
76
+ )
77
+
78
+
79
+ def load_limits(model: str, model_urdf: str | None) -> FrankaLimits:
80
+ nominal = {"fr3": _FR3_56, "panda": _PANDA}[model]
81
+ if model_urdf is None or model == "panda":
82
+ return nominal
83
+ root = ET.fromstring(model_urdf)
84
+
85
+ joints = {}
86
+ names = {name: index for index in range(7)
87
+ for name in (f"joint{index + 1}", f"fr3_joint{index + 1}")}
88
+ for joint in root.findall("joint"):
89
+ name = joint.get("name", "")
90
+ index = names.get(name)
91
+ if index is None:
92
+ continue
93
+ limit = joint.find("limit")
94
+ curve = joint.find("position_based_velocity_limits")
95
+ lower, upper, velocity = (float(limit.attrib[key]) for key in ("lower", "upper", "velocity"))
96
+ offset = float(curve.attrib["velocity_offset"])
97
+ deceleration = float(curve.attrib["deceleration_limit"])
98
+ joints[index] = lower, upper, velocity, offset, deceleration
99
+ lower, upper, velocity, offset, deceleration = zip(*(joints[i] for i in range(7)))
100
+ return FrankaLimits(
101
+ joint_lower=lower,
102
+ joint_upper=upper,
103
+ joint_velocity=velocity,
104
+ joint_acceleration=_FR3_ACCELERATION,
105
+ cartesian_velocity=(3.0, 2.5),
106
+ _velocity_lower=lower,
107
+ _velocity_upper=upper,
108
+ _velocity_offset=offset,
109
+ _deceleration=deceleration,
110
+ )