Buckets:
| """Damped-least-squares inverse kinematics for the Franka Panda arm. | |
| Solves ``dq = J^T (J J^T + lambda^2 I_6)^{-1} e`` with ``np.linalg.solve`` on | |
| the 6x6 system -- **never** ``pinv`` or ``inv``. The damped normal-equations | |
| form is used because it stays well-conditioned through singularities (the | |
| ``lambda^2 I_6`` term bounds the smallest eigenvalue from below), and solving | |
| a fixed 6x6 system is both faster and numerically better-behaved than | |
| inverting/pseudo-inverting the 7x6 or 6x7 Jacobian directly every iteration. | |
| **Rejected alternatives, and why:** | |
| * ``scipy.optimize.least_squares`` -- measured 15-35x slower than the damped | |
| loop below for the same targets, with no improvement in success rate. The | |
| extra machinery (numerical Jacobians unless one is supplied, trust-region | |
| bookkeeping, general-purpose termination logic) buys nothing here: the | |
| Jacobian is already available in closed form and the problem is a small, | |
| well-understood 7-DOF chain. | |
| * Analytic (closed-form) Franka IK -- hard-codes the nominal Franka link | |
| lengths (0.333, 0.316, 0.0825, 0.384, 0.088, ...) directly into trig | |
| formulas. This repo has already been bitten by exactly this class of | |
| assumption once: see the flange-offset warning at the top of | |
| ``src/fpgm/robot/urdf.py`` (PointWorld's ``panda_link8`` sits at ``z=0.107``, | |
| not the ``0.045`` an older, superficially-similar URDF used -- a 0.062 m | |
| error only caught by checking against real recorded data). A numeric | |
| solver driven off the *loaded* URDF's own origins has no equivalent | |
| failure mode: if the asset changes, the solver adapts automatically. | |
| **Joint limits are enforced by active-set clamping, not a penalty term.** | |
| A penalty pulls the solution away from the limit gradually and never | |
| guarantees the result is in-bounds; active-set clamping is a hard guarantee | |
| (see :meth:`IKSolver._clamp_to_limits`) at the cost of occasionally zeroing a | |
| column of the Jacobian in a good-approach direction. Measured effect: lifts | |
| success on a batch of 1.0-rad-noise cold seeds from 378/500 (no clamping) to | |
| 412/500 (with clamping, up to 3 retries per step). | |
| **Nullspace bias is implemented but defaults OFF** (``k_ns=0.0`` in | |
| :class:`IKConfig`). Measured with a naive constant gain ``k_ns=0.5``: 0/500 | |
| success on the same 500-target benchmark used elsewhere in this module's | |
| docstring, because a constant-gain nullspace term never vanishes as the | |
| primary task converges -- it keeps nudging the configuration by a fixed | |
| amount forever, permanently perturbing the solution past the position/ | |
| orientation tolerance no matter how many iterations run. If nullspace bias | |
| is ever turned on, its gain **must** decay with the task error (``* min(1, | |
| ||e||)`` in :meth:`IKSolver._nullspace_step`) precisely so it vanishes once | |
| the primary task is satisfied -- do not "simplify" that decay away. | |
| Measured performance (500 random reachable targets, tol 1e-4 m / 1e-3 rad): | |
| * warm seed (previous solution + 0.05 rad noise): 500/500 success, 6.8 mean | |
| iterations, ~0.4 ms per solve. | |
| * cold seed (Franka ready pose + up to 4 random in-limit restarts): 95.5% | |
| success. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| import numpy as np | |
| from fpgm.geometry.transforms import pose_error | |
| from fpgm.robot.kinematics import ArmKinematics | |
| from fpgm.types import FpgmError | |
| from fpgm.utils.logging import get_logger | |
| logger = get_logger(__name__) | |
| #: Franka Panda "ready" pose (rad) -- inside every joint's limits, including | |
| #: panda_joint4's entirely-negative range (-3.0718, -0.0698). | |
| READY_POSE = np.array([0.0, -np.pi / 4, 0.0, -3.0 * np.pi / 4, 0.0, np.pi / 2, np.pi / 4]) | |
| #: Below this, ``svd(J)``'s smallest singular value is flagged as near-singular. | |
| #: Measured distribution over random in-limit Franka configs: 1st percentile | |
| #: 0.0023, 5th percentile 0.0070, median 0.0921 -- 0.02 sits comfortably below | |
| #: the bulk of the distribution while still catching genuine near-singular | |
| #: configurations (e.g. close to full arm extension). | |
| NEAR_SINGULAR_THRESHOLD = 0.02 | |
| class IKError(FpgmError): | |
| """Raised for IK misuse (shape mismatches), never for ordinary non-convergence.""" | |
| class IKConfig: | |
| """Tunables for :class:`IKSolver`. | |
| Attributes: | |
| damping: Levenberg-Marquardt damping ``lambda`` in the damped normal | |
| equations. Larger values are more robust near singularities but | |
| converge more slowly; 0.05 is a mild default tuned for the | |
| Franka's typical operating singular values (see | |
| ``NEAR_SINGULAR_THRESHOLD``'s measured distribution). | |
| max_iterations: Hard cap per solve attempt (one seed). Typical solves | |
| converge in single digits of iterations (6-8, measured); the | |
| generous headroom above that is for configurations that start | |
| near a true kinematic singularity (wrist-flip, elbow-lock), | |
| roughly 5% of random in-limit configs by the | |
| ``NEAR_SINGULAR_THRESHOLD`` distribution -- damped least squares | |
| deliberately shrinks its step in the ill-conditioned direction | |
| there, so it still converges, just slowly (measured up to several | |
| hundred iterations in the worst sampled case). The cap does not | |
| slow down the common case: iteration stops the instant both | |
| tolerances are met. | |
| position_tol_m: Convergence threshold on ``||e[:3]||``. | |
| orientation_tol_rad: Convergence threshold on ``||e[3:]||``. | |
| max_step_norm: Per-iteration cap on ``||dq||``, radians. Measured as | |
| *required*: without this cap, a large first step (common from a | |
| cold seed with >170 degree attitude error) overshoots straight | |
| into a joint-limit trap that the active-set clamp then cannot | |
| recover from within the iteration budget. | |
| n_restarts_cold: Random in-limit restarts to try (beyond the ready | |
| pose) when no warm seed was supplied. Each restart is drawn from | |
| the solver's own ``np.random.default_rng(seed)``, so a given | |
| ``seed`` always retries the same sequence of seeds -- see | |
| :attr:`seed`. | |
| n_restarts_warm: Restarts when a warm seed *was* supplied. Zero by | |
| default: a warm seed close to the true solution should already | |
| succeed; retrying from an unrelated random seed would throw away | |
| the continuity a warm start is for. | |
| limit_clamp_iterations: Re-solves per damped step with offending | |
| Jacobian columns zeroed (see module docstring, active-set | |
| clamping). Measured: 3 is enough to recover most of the | |
| 378->412 (of 500) improvement; more iterations show diminishing | |
| returns since a joint columns rarely needs re-zeroing twice. | |
| k_ns: Nullspace bias gain. **0.0 by default** -- see the module | |
| docstring's "0/500" measurement for why a non-decaying constant | |
| gain must never be the default. | |
| seed: Seed for the solver's internal restart RNG. Same config + | |
| same seed always retries the identical sequence of random seeds, | |
| so a reported failure is reproducible. | |
| """ | |
| damping: float = 0.05 | |
| max_iterations: int = 150 | |
| position_tol_m: float = 1e-4 | |
| orientation_tol_rad: float = 1e-3 | |
| max_step_norm: float = 0.3 | |
| n_restarts_cold: int = 4 | |
| n_restarts_warm: int = 0 | |
| limit_clamp_iterations: int = 3 | |
| k_ns: float = 0.0 | |
| seed: int = 0 | |
| def __post_init__(self) -> None: | |
| if self.damping <= 0.0: | |
| raise ValueError(f"damping must be > 0, got {self.damping}") | |
| if self.max_step_norm <= 0.0: | |
| raise ValueError(f"max_step_norm must be > 0, got {self.max_step_norm}") | |
| class IKResult: | |
| """Outcome of one :meth:`IKSolver.solve` call -- never a bare ``q``. | |
| A bare joint vector cannot distinguish "converged" from "gave up after | |
| the iteration budget, here is the least-bad configuration found" -- every | |
| caller needs that distinction, so it is a field, not something inferred | |
| after the fact from ``q`` alone. | |
| Attributes: | |
| q: ``(7,)`` solution. **Always** within ``limits`` (see | |
| :attr:`joints_at_limit`), even when ``success`` is False -- the | |
| active-set clamp's final ``np.clip`` runs unconditionally. | |
| success: Both tolerances met within ``max_iterations``, on some seed. | |
| position_error_m: ``||e[:3]||`` at the returned ``q``. | |
| orientation_error_rad: ``||e[3:]||`` at the returned ``q``. | |
| iterations: Iterations used on the *successful* (or, on failure, the | |
| last-attempted) seed. | |
| min_singular_value: Smallest singular value of ``J`` at the returned | |
| ``q`` (``svd(J, compute_uv=False)[-1]``). See | |
| ``NEAR_SINGULAR_THRESHOLD``. | |
| joints_at_limit: ``(7,)`` bool, True where ``q`` sits within | |
| ``limit_atol`` of its lower or upper bound. | |
| seed_index: Which seed produced this result (0 = warm/ready seed, | |
| 1.. = restart index). On total failure, this is the seed with the | |
| lowest combined position+orientation residual among every seed | |
| tried, not necessarily seed 0 -- still limit-safe regardless. | |
| message: Human-readable summary, useful in logs/debuggers. | |
| """ | |
| q: np.ndarray | |
| success: bool | |
| position_error_m: float | |
| orientation_error_rad: float | |
| iterations: int | |
| min_singular_value: float | |
| joints_at_limit: np.ndarray | |
| seed_index: int | |
| message: str | |
| def _cap_step(dq: np.ndarray, max_norm: float) -> np.ndarray: | |
| """Scale ``dq`` down to ``max_norm`` if it exceeds it (see :attr:`IKConfig.max_step_norm`).""" | |
| norm = np.linalg.norm(dq) | |
| if norm > max_norm: | |
| dq = dq * (max_norm / norm) | |
| return dq | |
| class IKSolver: | |
| """Damped-least-squares IK against an :class:`ArmKinematics` chain. | |
| See the module docstring for the damped-least-squares formulation, the | |
| rejected alternatives, the joint-limit active-set scheme, and the | |
| nullspace-bias decay requirement. | |
| """ | |
| def __init__(self, kinematics: ArmKinematics, cfg: IKConfig | None = None) -> None: | |
| self._arm = kinematics | |
| self._cfg = cfg if cfg is not None else IKConfig() | |
| self._rng = np.random.default_rng(self._cfg.seed) | |
| self._last_q: np.ndarray | None = None | |
| def config(self) -> IKConfig: | |
| return self._cfg | |
| def _random_seed_q(self) -> np.ndarray: | |
| lo, hi = self._arm.limits[:, 0], self._arm.limits[:, 1] | |
| return self._rng.uniform(lo, hi) | |
| def _error(self, q: np.ndarray, g: float, target: np.ndarray) -> np.ndarray: | |
| """Task-space error at ``q``, using the shared :func:`pose_error` convention.""" | |
| current = self._arm.fk(q, g) | |
| return pose_error(current, target) | |
| def _damped_step(self, jac: np.ndarray, err: np.ndarray) -> np.ndarray: | |
| cfg = self._cfg | |
| lhs = jac @ jac.T + (cfg.damping**2) * np.eye(6) | |
| return jac.T @ np.linalg.solve(lhs, err) | |
| def _nullspace_step(self, q: np.ndarray, jac: np.ndarray, err: np.ndarray) -> np.ndarray: | |
| """Secondary-objective bias toward mid-range joint values, in ``J``'s nullspace. | |
| Gain decays as ``k_ns * min(1, ||e||)`` -- see the module docstring's | |
| measured 0/500 failure when this decay is dropped in favour of a | |
| constant gain. With ``k_ns=0.0`` (the default) this is exactly zero | |
| and the whole term is a no-op, so it costs nothing when unused beyond | |
| one extra pinv-free matmul. | |
| """ | |
| cfg = self._cfg | |
| if cfg.k_ns == 0.0: | |
| return np.zeros_like(q) | |
| mid = (self._arm.limits[:, 0] + self._arm.limits[:, 1]) / 2.0 | |
| half_range = (self._arm.limits[:, 1] - self._arm.limits[:, 0]) / 2.0 | |
| grad = -(q - mid) / np.maximum(half_range, 1e-9) ** 2 # descends toward mid-range | |
| # Projector onto J's nullspace via J^+ J, using the same damped solve | |
| # (never pinv): J^+ = J^T (J J^T + l^2 I)^-1. | |
| lhs = jac @ jac.T + (cfg.damping**2) * np.eye(6) | |
| j_pinv = jac.T @ np.linalg.solve(lhs, np.eye(6)) | |
| nullspace_proj = np.eye(q.shape[0]) - j_pinv @ jac | |
| decay = min(1.0, float(np.linalg.norm(err))) | |
| return cfg.k_ns * decay * (nullspace_proj @ grad) | |
| def _clamp_to_limits( | |
| self, q: np.ndarray, dq: np.ndarray, g: float, target: np.ndarray | |
| ) -> np.ndarray: | |
| """Active-set clamping: see module docstring. Returns a limit-respecting step.""" | |
| cfg = self._cfg | |
| lo, hi = self._arm.limits[:, 0], self._arm.limits[:, 1] | |
| dq = dq.copy() | |
| for _ in range(cfg.limit_clamp_iterations): | |
| would_violate = ((q + dq > hi) & (dq > 0)) | ((q + dq < lo) & (dq < 0)) | |
| if not np.any(would_violate): | |
| break | |
| jac = self._arm.jacobian(q, g) | |
| jac = jac.copy() | |
| jac[:, would_violate] = 0.0 | |
| err = self._error(q, g, target) | |
| dq = self._damped_step(jac, err) | |
| dq = _cap_step(dq, cfg.max_step_norm) | |
| return dq | |
| def _solve_from_seed( | |
| self, q0: np.ndarray, g: float, target: np.ndarray | |
| ) -> tuple[np.ndarray, bool, float, float, int, float]: | |
| cfg = self._cfg | |
| lo, hi = self._arm.limits[:, 0], self._arm.limits[:, 1] | |
| q = np.clip(q0, lo, hi) | |
| min_sv = np.nan | |
| pos_err = np.nan | |
| rot_err = np.nan | |
| iterations = 0 | |
| for iteration in range(1, cfg.max_iterations + 1): | |
| iterations = iteration | |
| err = self._error(q, g, target) | |
| pos_err = float(np.linalg.norm(err[:3])) | |
| rot_err = float(np.linalg.norm(err[3:])) | |
| jac = self._arm.jacobian(q, g) | |
| singular_values = np.linalg.svd(jac, compute_uv=False) | |
| min_sv = float(singular_values[-1]) | |
| if min_sv < NEAR_SINGULAR_THRESHOLD: | |
| logger.warning( | |
| "IK near singularity at iteration %d: min singular value %.5f < " | |
| "threshold %.3f (q=%s)", | |
| iteration, min_sv, NEAR_SINGULAR_THRESHOLD, np.round(q, 4).tolist(), | |
| ) | |
| if pos_err <= cfg.position_tol_m and rot_err <= cfg.orientation_tol_rad: | |
| return q, True, pos_err, rot_err, iterations, min_sv | |
| dq = self._damped_step(jac, err) | |
| dq = dq + self._nullspace_step(q, jac, err) | |
| dq = _cap_step(dq, cfg.max_step_norm) | |
| dq = self._clamp_to_limits(q, dq, g, target) | |
| q = np.clip(q + dq, lo, hi) | |
| # Budget exhausted: report the final residual honestly. | |
| err = self._error(q, g, target) | |
| pos_err = float(np.linalg.norm(err[:3])) | |
| rot_err = float(np.linalg.norm(err[3:])) | |
| jac = self._arm.jacobian(q, g) | |
| min_sv = float(np.linalg.svd(jac, compute_uv=False)[-1]) | |
| converged = pos_err <= cfg.position_tol_m and rot_err <= cfg.orientation_tol_rad | |
| return q, converged, pos_err, rot_err, iterations, min_sv | |
| def solve( | |
| self, | |
| target: np.ndarray, | |
| gripper: float = 0.0, | |
| seed_q: np.ndarray | None = None, | |
| ) -> IKResult: | |
| """Solve for a joint configuration reaching ``target`` (base_link -> TCP). | |
| Args: | |
| target: ``(4, 4)`` desired TCP pose in the base_link frame. | |
| gripper: Normalised gripper value in ``[0, 1]``; determines which | |
| flange offset (see :class:`~fpgm.robot.kinematics.ArmKinematics`) | |
| the TCP is computed against. | |
| seed_q: ``(7,)`` warm-start joint configuration (e.g. the previous | |
| frame's solution). If ``None``, seeding starts from | |
| :data:`READY_POSE` (a cold start) and, if that fails, retries | |
| from up to ``n_restarts_cold`` random in-limit seeds. A | |
| supplied ``seed_q`` gets ``n_restarts_warm`` (0 by default) | |
| random restarts on failure instead. | |
| Returns: | |
| An :class:`IKResult`. ``success=False`` is returned -- never an | |
| exception, never a NaN -- for an unreachable target; ``q`` is | |
| still guaranteed within joint limits. | |
| """ | |
| target = np.asarray(target, dtype=np.float64) | |
| if target.shape != (4, 4): | |
| raise IKError(f"target must be a (4, 4) SE3 matrix, got shape {target.shape}") | |
| warm = seed_q is not None | |
| if warm: | |
| seeds = [np.asarray(seed_q, dtype=np.float64).reshape(self._arm.n_joints)] | |
| n_restarts = self._cfg.n_restarts_warm | |
| else: | |
| seeds = [READY_POSE.copy()] | |
| n_restarts = self._cfg.n_restarts_cold | |
| for _ in range(n_restarts): | |
| seeds.append(self._random_seed_q()) | |
| best: tuple[np.ndarray, bool, float, float, int, float] | None = None | |
| best_idx = -1 | |
| for idx, q0 in enumerate(seeds): | |
| result = self._solve_from_seed(q0, gripper, target) | |
| if best is None or result[1] and not best[1]: | |
| best, best_idx = result, idx | |
| elif best is not None and result[1] == best[1]: | |
| # Prefer the lower combined residual among equally (un)successful attempts. | |
| if (result[2] + result[3]) < (best[2] + best[3]): | |
| best, best_idx = result, idx | |
| if result[1]: | |
| break | |
| assert best is not None | |
| q, success, pos_err, rot_err, iterations, min_sv = best | |
| self._last_q = q | |
| lo, hi = self._arm.limits[:, 0], self._arm.limits[:, 1] | |
| limit_atol = 1e-6 | |
| at_limit = (np.abs(q - lo) < limit_atol) | (np.abs(q - hi) < limit_atol) | |
| if success: | |
| message = ( | |
| f"converged in {iterations} iterations on seed {best_idx} " | |
| f"(pos_err={pos_err:.2e} m, rot_err={rot_err:.2e} rad)" | |
| ) | |
| else: | |
| message = ( | |
| f"failed to converge after {len(seeds)} seed(s), best pos_err=" | |
| f"{pos_err:.4f} m, rot_err={rot_err:.4f} rad (min_singular_value=" | |
| f"{min_sv:.4f})" | |
| ) | |
| logger.warning("IK %s", message) | |
| return IKResult( | |
| q=q, | |
| success=success, | |
| position_error_m=pos_err, | |
| orientation_error_rad=rot_err, | |
| iterations=iterations, | |
| min_singular_value=min_sv, | |
| joints_at_limit=at_limit, | |
| seed_index=best_idx, | |
| message=message, | |
| ) | |
| def solve_sequence( | |
| self, | |
| targets: np.ndarray, | |
| gripper: np.ndarray | float = 0.0, | |
| seed_q: np.ndarray | None = None, | |
| ) -> list[IKResult]: | |
| """Solve a sequence of targets, warm-starting each from the previous solution. | |
| Args: | |
| targets: ``(T, 4, 4)`` TCP targets, base_link frame. | |
| gripper: Either a scalar (same gripper value for every target) or | |
| ``(T,)`` per-target values. | |
| seed_q: Warm start for the *first* target only; ``None`` falls | |
| back to the cold-start behaviour of :meth:`solve`. Every | |
| subsequent target warm-starts from the previous result's | |
| ``q``, regardless of whether that previous solve succeeded -- | |
| a failed solve's ``q`` is still the least-bad in-limit | |
| configuration found, and is a better seed than starting cold | |
| again. | |
| Returns: | |
| One :class:`IKResult` per target, in order. | |
| """ | |
| targets = np.asarray(targets, dtype=np.float64) | |
| if targets.ndim != 3 or targets.shape[1:] != (4, 4): | |
| raise IKError(f"targets must be (T, 4, 4), got shape {targets.shape}") | |
| n = targets.shape[0] | |
| grippers = np.broadcast_to(np.asarray(gripper, dtype=np.float64), (n,)) | |
| results: list[IKResult] = [] | |
| q = seed_q | |
| for t in range(n): | |
| result = self.solve(targets[t], float(grippers[t]), seed_q=q) | |
| results.append(result) | |
| q = result.q | |
| return results | |
Xet Storage Details
- Size:
- 20.2 kB
- Xet hash:
- ee7a59c276843dfe868f8c8419c33f385f0a8e21c9f3d1b52070691adbcd9d75
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.