Buckets:
| """Plans an :class:`~fpgm.motion.types.ActionSpec` into a joint trajectory. | |
| Realises each waypoint with :class:`fpgm.robot.ik.IKSolver`, one 15 Hz grid | |
| tick at a time, producing a :class:`~fpgm.motion.types.JointTrajectory`. | |
| :class:`TrajectoryPlanner` walks each step of an | |
| :class:`~fpgm.motion.types.ActionSpec` and, for a | |
| :class:`~fpgm.motion.types.CartesianWaypoint`, drives | |
| :class:`fpgm.robot.ik.IKSolver` along a | |
| :func:`fpgm.motion.profile.trapezoidal_profile` built on the shared SE3 | |
| interpolation parameter ``s``. Two design choices matter enough to call out | |
| here rather than leave implicit in the code: | |
| **The segment's start pose is the FK of the actual previous ``q``, never the | |
| previous segment's *commanded* target.** IK never converges to exact-zero | |
| residual (``position_tol_m=1e-4`` by default) -- if segment N+1 interpolated | |
| from segment N's *commanded* pose, that last few tenths of a millimetre of | |
| residual would be silently dropped on the floor every segment, and over a | |
| long plan those drops either accumulate into a visible discontinuity or (worse) | |
| mask a real, growing tracking problem. Starting from ``arm.fk(q_prev, g_prev)`` | |
| instead means whatever residual segment N left behind becomes part of segment | |
| N+1's actual path -- it gets *absorbed and corrected*, not hidden. | |
| **IK is warm-started from the previous sample throughout**, including across | |
| gripper/dwell steps (where ``q`` does not change, so the warm start is exact). | |
| :mod:`fpgm.robot.ik`'s own measurements show this is the regime where damped | |
| least squares converges in single-digit iterations at ~100% success -- a cold | |
| restart is only needed if warm-starting itself is failing, which would be a | |
| sign something upstream is wrong (e.g. a target outside the reachable | |
| workspace), not something to route around by feeding IK a fresh ready-pose | |
| guess every segment. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| import numpy as np | |
| from fpgm.geometry.transforms import matrix_to_rotvec, rotvec_to_matrix | |
| from fpgm.motion.profile import ProfileConfig, ScalarProfile, trapezoidal_profile | |
| from fpgm.motion.types import ( | |
| ActionSpec, | |
| CartesianWaypoint, | |
| Dwell, | |
| GripperAction, | |
| IssueKind, | |
| JointTrajectory, | |
| PlanningError, | |
| TrajectoryIssue, | |
| ) | |
| from fpgm.robot.ik import IKSolver | |
| from fpgm.robot.kinematics import ArmKinematics | |
| from fpgm.robot.urdf import RobotModel | |
| from fpgm.utils.logging import get_logger | |
| logger = get_logger(__name__) | |
| #: Labels used in segment_index bookkeeping / summarize_plan, one per ActionStep type. | |
| _LABEL_CARTESIAN = "cartesian" | |
| _LABEL_GRIPPER = "gripper" | |
| _LABEL_DWELL = "dwell" | |
| class PlannerConfig: | |
| """Tunables for :class:`TrajectoryPlanner`. | |
| Attributes: | |
| profile: Acceleration/rate limits and grid rate for | |
| :func:`~fpgm.motion.profile.trapezoidal_profile`. | |
| on_failure: ``"raise"`` (default, matching this repo's eager-validation | |
| posture elsewhere -- see :mod:`fpgm.robot.ik`'s own docstring) | |
| raises :class:`~fpgm.motion.types.PlanningError` the moment a | |
| step cannot be realised. ``"best_effort"`` instead records a | |
| :class:`~fpgm.motion.types.TrajectoryIssue`, keeps the | |
| closest-achieved ``q``, sets | |
| :attr:`~fpgm.motion.types.JointTrajectory.success` to False, and | |
| keeps planning the remaining steps (warm-started from that | |
| closest ``q`` -- still the best available seed, exactly as | |
| :meth:`fpgm.robot.ik.IKSolver.solve_sequence` already treats a | |
| failed solve's ``q``). | |
| max_retime_iterations: How many times a | |
| :class:`~fpgm.motion.types.CartesianWaypoint` segment is re-timed | |
| (slowed down, more grid ticks) before its peak per-joint velocity | |
| ratio is accepted as unfixable and reported as a | |
| :attr:`~fpgm.motion.types.IssueKind.SINGULARITY` instead. | |
| joint_limit_atol: Distance (radians) from a joint's bound within which | |
| :attr:`~fpgm.motion.types.JointTrajectory.joints_at_limit` reports True. | |
| velocity_clamp_tol: Slack on the ``ratio <= 1.0`` velocity-clamp check, | |
| purely to absorb float noise in the ratio computation itself -- | |
| not a physical margin. | |
| """ | |
| profile: ProfileConfig = field(default_factory=ProfileConfig) | |
| on_failure: str = "raise" | |
| max_retime_iterations: int = 3 | |
| joint_limit_atol: float = 1e-6 | |
| velocity_clamp_tol: float = 1e-6 | |
| def __post_init__(self) -> None: | |
| if self.on_failure not in ("raise", "best_effort"): | |
| raise ValueError( | |
| f'on_failure must be "raise" or "best_effort", got {self.on_failure!r}' | |
| ) | |
| class TrajectoryPlanner: | |
| """Realises an :class:`~fpgm.motion.types.ActionSpec` as a joint trajectory. | |
| Needs all three of the robot stack's pieces: ``arm`` (fast FK/Jacobian), | |
| ``ik`` (built against that same ``arm``), and ``robot`` (only consulted | |
| for the gripper drive joint's velocity limit and travel range -- see | |
| :meth:`_plan_gripper_segment`). | |
| """ | |
| def __init__( | |
| self, | |
| arm: ArmKinematics, | |
| ik: IKSolver, | |
| robot: RobotModel, | |
| cfg: PlannerConfig | None = None, | |
| ) -> None: | |
| self._arm = arm | |
| self._ik = ik | |
| self._robot = robot | |
| self._cfg = cfg if cfg is not None else PlannerConfig() | |
| def config(self) -> PlannerConfig: | |
| return self._cfg | |
| # ------------------------------------------------------------------ # | |
| # Public entry point | |
| # ------------------------------------------------------------------ # | |
| def plan(self, spec: ActionSpec, q_start: np.ndarray, g_start: float) -> JointTrajectory: | |
| """Plan a full joint trajectory for ``spec``, starting at ``(q_start, g_start)``. | |
| Args: | |
| spec: Ordered Cartesian/gripper/dwell steps. | |
| q_start: ``(7,)`` starting joint configuration. | |
| g_start: Starting normalised gripper value in ``[0, 1]``. | |
| Returns: | |
| A :class:`~fpgm.motion.types.JointTrajectory` beginning with a | |
| single ``t=0`` row for ``(q_start, g_start)``, followed by every | |
| 15 Hz-grid sample produced by ``spec``'s steps in order. | |
| Raises: | |
| PlanningError: If ``on_failure="raise"`` (the default) and any | |
| step cannot be realised -- an unreachable Cartesian target, or | |
| a per-joint velocity demand that re-timing could not fix. | |
| """ | |
| arm, cfg = self._arm, self._cfg | |
| q_start = np.asarray(q_start, dtype=np.float64).reshape(arm.n_joints) | |
| g_start = float(np.clip(g_start, 0.0, 1.0)) | |
| dt = 1.0 / cfg.profile.rate_hz | |
| times: list[float] = [0.0] | |
| qs: list[np.ndarray] = [q_start.copy()] | |
| gs: list[float] = [g_start] | |
| tcps: list[np.ndarray] = [arm.fk(q_start, g_start)] | |
| pos_errs: list[float] = [0.0] | |
| ori_errs: list[float] = [0.0] | |
| min_svs: list[float] = [_min_singular_value(arm, q_start, g_start)] | |
| seg_idx: list[int] = [-1] | |
| labels: dict[int, str] = {} | |
| issues: list[TrajectoryIssue] = [] | |
| any_hard_failure = False | |
| q_cur, g_cur = q_start.copy(), g_start | |
| pose_cur = tcps[0].copy() | |
| t_cur = 0.0 | |
| for step_i, step in enumerate(spec): | |
| if isinstance(step, CartesianWaypoint): | |
| labels[step_i] = _LABEL_CARTESIAN | |
| q_seq, tcp_seq, pe, oe, msv, step_issues, hard = self._plan_cartesian_segment( | |
| step_i, step, q_cur, g_cur, pose_cur | |
| ) | |
| n = q_seq.shape[0] | |
| for k in range(n): | |
| t_cur += dt | |
| times.append(t_cur) | |
| qs.append(q_seq[k]) | |
| gs.append(g_cur) | |
| tcps.append(tcp_seq[k]) | |
| pos_errs.append(float(pe[k])) | |
| ori_errs.append(float(oe[k])) | |
| min_svs.append(float(msv[k])) | |
| seg_idx.append(step_i) | |
| if n: | |
| q_cur = q_seq[-1] | |
| pose_cur = arm.fk(q_cur, g_cur) # real q's FK, not the commanded target (see above) | |
| elif isinstance(step, GripperAction): | |
| labels[step_i] = _LABEL_GRIPPER | |
| g_seq, tcp_seq, msv, g_target = self._plan_gripper_segment(step, q_cur, g_cur) | |
| n = g_seq.shape[0] | |
| for k in range(n): | |
| t_cur += dt | |
| times.append(t_cur) | |
| qs.append(q_cur.copy()) | |
| gs.append(float(g_seq[k])) | |
| tcps.append(tcp_seq[k]) | |
| pos_errs.append(0.0) | |
| ori_errs.append(0.0) | |
| min_svs.append(float(msv[k])) | |
| seg_idx.append(step_i) | |
| g_cur = g_target | |
| pose_cur = arm.fk(q_cur, g_cur) # gripper travel moves the TCP with q held fixed | |
| step_issues, hard = [], False | |
| elif isinstance(step, Dwell): | |
| labels[step_i] = _LABEL_DWELL | |
| n_ticks = max(1, int(np.ceil(step.seconds * cfg.profile.rate_hz - 1e-9))) | |
| tcp_hold = arm.fk(q_cur, g_cur) | |
| msv_hold = _min_singular_value(arm, q_cur, g_cur) | |
| for _ in range(n_ticks): | |
| t_cur += dt | |
| times.append(t_cur) | |
| qs.append(q_cur.copy()) | |
| gs.append(g_cur) | |
| tcps.append(tcp_hold) | |
| pos_errs.append(0.0) | |
| ori_errs.append(0.0) | |
| min_svs.append(msv_hold) | |
| seg_idx.append(step_i) | |
| step_issues, hard = [], False | |
| else: # pragma: no cover - ActionStep is a closed union | |
| raise PlanningError(f"unknown ActionSpec step type: {type(step)!r}") | |
| issues.extend(step_issues) | |
| if hard: | |
| any_hard_failure = True | |
| if cfg.on_failure == "raise": | |
| raise PlanningError( | |
| f"trajectory planning failed at step {step_i} " | |
| f"({type(step).__name__}): " | |
| + "; ".join(i.message for i in step_issues) | |
| ) | |
| timestamps = np.array(times, dtype=np.float64) | |
| joint_positions = np.stack(qs, axis=0) | |
| gripper = np.array(gs, dtype=np.float64) | |
| tcp_poses = np.stack(tcps, axis=0) | |
| position_error_m = np.array(pos_errs, dtype=np.float64) | |
| orientation_error_rad = np.array(ori_errs, dtype=np.float64) | |
| min_singular_value = np.array(min_svs, dtype=np.float64) | |
| segment_index = np.array(seg_idx, dtype=np.int32) | |
| lo, hi = arm.limits[:, 0], arm.limits[:, 1] | |
| joints_at_limit = (np.abs(joint_positions - lo) < cfg.joint_limit_atol) | ( | |
| np.abs(joint_positions - hi) < cfg.joint_limit_atol | |
| ) | |
| for i in np.flatnonzero(joints_at_limit.any(axis=1)): | |
| seg = int(segment_index[i]) | |
| if seg >= 0: | |
| issues.append( | |
| TrajectoryIssue( | |
| IssueKind.JOINT_LIMIT, seg, int(i), | |
| f"joint(s) {np.flatnonzero(joints_at_limit[i]).tolist()} at limit " | |
| f"on sample {i} (segment {seg})", | |
| ) | |
| ) | |
| joint_speed_ratio = np.zeros(timestamps.shape[0], dtype=np.float64) | |
| if timestamps.shape[0] > 1: | |
| dq = np.diff(joint_positions, axis=0) | |
| joint_speed_ratio[1:] = np.max(np.abs(dq) / (dt * arm.vel_lim), axis=1) | |
| traj = JointTrajectory( | |
| timestamps=timestamps, | |
| joint_positions=joint_positions, | |
| gripper=gripper, | |
| tcp_poses=tcp_poses, | |
| position_error_m=position_error_m, | |
| orientation_error_rad=orientation_error_rad, | |
| min_singular_value=min_singular_value, | |
| joints_at_limit=joints_at_limit, | |
| segment_index=segment_index, | |
| issues=issues, | |
| success=not any_hard_failure, | |
| joint_speed_ratio=joint_speed_ratio, | |
| segment_labels=labels, | |
| ) | |
| logger.info( | |
| "planned %d samples, %.3f s:\n%s", len(traj), timestamps[-1], summarize_plan(traj) | |
| ) | |
| return traj | |
| # ------------------------------------------------------------------ # | |
| # Cartesian segments | |
| # ------------------------------------------------------------------ # | |
| def _solve_along_profile( | |
| self, | |
| profile: ScalarProfile, | |
| p0: np.ndarray, | |
| r0: np.ndarray, | |
| p1: np.ndarray, | |
| r1: np.ndarray, | |
| g: float, | |
| q_seed: np.ndarray, | |
| ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: | |
| """IK-solve every new sample of ``profile`` along the shared-``s`` SE3 path. | |
| The relative rotation ``R0^T R1`` is computed once and scaled by each | |
| sample's ``s`` -- exact SLERP built from | |
| :func:`~fpgm.geometry.transforms.rotvec_to_matrix`/ | |
| :func:`~fpgm.geometry.transforms.matrix_to_rotvec`, the two primitives | |
| already pinned to one rotation convention elsewhere in this repo (see | |
| :mod:`fpgm.motion.profile`'s module docstring). | |
| """ | |
| arm, ik = self._arm, self._ik | |
| relative_rotvec = matrix_to_rotvec(r0.T @ r1) | |
| n = profile.n_ticks | |
| q_seq = np.empty((n, arm.n_joints), dtype=np.float64) | |
| tcp_seq = np.empty((n, 4, 4), dtype=np.float64) | |
| pos_err = np.empty(n, dtype=np.float64) | |
| ori_err = np.empty(n, dtype=np.float64) | |
| min_sv = np.empty(n, dtype=np.float64) | |
| ok = np.empty(n, dtype=bool) | |
| q_prev = q_seed | |
| for i in range(n): | |
| s = float(profile.s[i + 1]) # skip index 0 (t=0, already the segment's start pose) | |
| target = np.eye(4, dtype=np.float64) | |
| target[:3, :3] = r0 @ rotvec_to_matrix(s * relative_rotvec) | |
| target[:3, 3] = p0 + s * (p1 - p0) | |
| result = ik.solve(target, gripper=g, seed_q=q_prev) | |
| q_seq[i] = result.q | |
| tcp_seq[i] = arm.fk(result.q, g) | |
| pos_err[i] = result.position_error_m | |
| ori_err[i] = result.orientation_error_rad | |
| min_sv[i] = result.min_singular_value | |
| ok[i] = result.success | |
| q_prev = result.q | |
| return q_seq, tcp_seq, pos_err, ori_err, min_sv, ok | |
| def _plan_cartesian_segment( | |
| self, | |
| step_i: int, | |
| step: CartesianWaypoint, | |
| q_cur: np.ndarray, | |
| g_cur: float, | |
| pose_cur: np.ndarray, | |
| ) -> tuple[ | |
| np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, list[TrajectoryIssue], bool | |
| ]: | |
| """Plan one CartesianWaypoint, re-timing the segment if it is too fast for the joints.""" | |
| arm, cfg = self._arm, self._cfg | |
| dt = 1.0 / cfg.profile.rate_hz | |
| target = step.matrix() | |
| p0, r0 = pose_cur[:3, 3], pose_cur[:3, :3] | |
| p1, r1 = target[:3, 3], target[:3, :3] | |
| length_m = float(np.linalg.norm(p1 - p0)) | |
| angle_rad = float(np.linalg.norm(matrix_to_rotvec(r0.T @ r1))) | |
| issues: list[TrajectoryIssue] = [] | |
| n_ticks = 1 | |
| q_seq = tcp_seq = pos_err = ori_err = min_sv = ok = None # noqa: F841 - assigned in loop | |
| for attempt in range(cfg.max_retime_iterations + 1): | |
| profile = trapezoidal_profile( | |
| length_m, angle_rad, step.speed_mps, cfg.profile, min_ticks=n_ticks | |
| ) | |
| q_seq, tcp_seq, pos_err, ori_err, min_sv, ok = self._solve_along_profile( | |
| profile, p0, r0, p1, r1, g_cur, q_cur | |
| ) | |
| q_chain = np.vstack([q_cur[None, :], q_seq]) | |
| dq = np.diff(q_chain, axis=0) | |
| ratio = float(np.max(np.abs(dq) / (dt * arm.vel_lim))) if dq.size else 0.0 | |
| if ratio <= 1.0 + cfg.velocity_clamp_tol: | |
| if attempt > 0: | |
| issues.append( | |
| TrajectoryIssue( | |
| IssueKind.SPEED_CLAMPED, step_i, -1, | |
| f"segment {step_i} re-timed {attempt}x to {profile.n_ticks} ticks " | |
| f"({profile.duration:.3f} s) to bring peak per-joint velocity " | |
| "ratio back under 1.0", | |
| ) | |
| ) | |
| break | |
| if attempt == cfg.max_retime_iterations: | |
| issues.append( | |
| TrajectoryIssue( | |
| IssueKind.SINGULARITY, step_i, -1, | |
| f"segment {step_i} still demands {ratio:.2f}x a joint's velocity " | |
| f"limit after {cfg.max_retime_iterations} retime attempts -- this is " | |
| "a near-singular direction (unbounded joint rate for bounded " | |
| "Cartesian rate), not something slowing down can fix", | |
| ) | |
| ) | |
| break | |
| n_ticks = max(n_ticks + 1, int(np.ceil(ratio * profile.n_ticks))) | |
| assert q_seq is not None | |
| if not np.all(ok): | |
| n_failed = int(ok.size - np.sum(ok)) | |
| issues.append( | |
| TrajectoryIssue( | |
| IssueKind.UNREACHABLE, step_i, -1, | |
| f"{n_failed}/{ok.size} IK solve(s) on segment {step_i} failed to converge", | |
| ) | |
| ) | |
| hard = bool(not np.all(ok)) or any(i.kind is IssueKind.SINGULARITY for i in issues) | |
| return q_seq, tcp_seq, pos_err, ori_err, min_sv, issues, hard | |
| # ------------------------------------------------------------------ # | |
| # Gripper segments | |
| # ------------------------------------------------------------------ # | |
| def _plan_gripper_segment( | |
| self, step: GripperAction, q_cur: np.ndarray, g_cur: float | |
| ) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]: | |
| """Ramp the gripper under the drive joint's own URDF velocity limit. | |
| The arm holds ``q_cur`` throughout -- but the TCP still physically | |
| moves (see :mod:`fpgm.robot.kinematics`'s module docstring: up to | |
| 11.3 mm between fully open and fully closed, because the Robotiq's | |
| inner fingers are mimic-driven off ``finger_joint`` and the TCP is | |
| their midpoint). That is why the caller records | |
| ``position_error_m = 0.0`` for these samples rather than comparing | |
| the drifting TCP to some fixed target: there never was a Cartesian | |
| target here to have "tracking error" against, only ``fk(q, g)`` at a | |
| deliberately-ramping ``g``. | |
| The minimum duration is derived from | |
| ``robot.gripper_drive_velocity_limit`` and ``robot.gripper_limits`` | |
| (the drive joint's own ``<limit>``), never a hard-coded sample count | |
| -- e.g. the Robotiq 2F-85's ``finger_joint`` (2.0 rad/s over a 0.725 | |
| rad range) works out to 0.363 s / 6 samples at 15 Hz for a full | |
| open<->close, but that number is a *consequence* of the URDF, not | |
| assumed here. | |
| """ | |
| arm, robot, cfg = self._arm, self._robot, self._cfg | |
| g_target = float(np.clip(step.resolve(arm), 0.0, 1.0)) | |
| lower, upper = robot.gripper_limits | |
| delta_rad = abs(g_target - g_cur) * (upper - lower) | |
| vel_lim = robot.gripper_drive_velocity_limit | |
| min_duration = delta_rad / vel_lim if vel_lim > 0 else 0.0 | |
| n = max(1, int(np.ceil(min_duration * cfg.profile.rate_hz - 1e-9))) | |
| g_seq = g_cur + (g_target - g_cur) * (np.arange(1, n + 1, dtype=np.float64) / n) | |
| tcp_seq = np.stack([arm.fk(q_cur, float(g)) for g in g_seq], axis=0) | |
| min_sv = np.array([_min_singular_value(arm, q_cur, float(g)) for g in g_seq]) | |
| return g_seq, tcp_seq, min_sv, g_target | |
| def _min_singular_value(arm: ArmKinematics, q: np.ndarray, g: float) -> float: | |
| return float(np.linalg.svd(arm.jacobian(q, g), compute_uv=False)[-1]) | |
| def summarize_plan(traj: JointTrajectory) -> str: | |
| """Render one row per :class:`~fpgm.motion.types.ActionSpec` step -- the | |
| primary debugging artefact for this module, mirroring | |
| :func:`fpgm.objects.interaction.summarize`. Eyeballing hundreds of ``(4, | |
| 4)`` TCP matrices is not a debugging strategy; a handful of (type, | |
| samples, duration, length, peak speed, peak joint-speed ratio, IK | |
| residual, min sigma) rows is. | |
| Args: | |
| traj: Output of :meth:`TrajectoryPlanner.plan`. | |
| Returns: | |
| A multi-line human-readable table, one row per originating step | |
| (skipping the single initial ``t=0`` row, ``segment_index == -1``, | |
| which precedes any step and is not itself a segment). | |
| """ | |
| if len(traj) <= 1: | |
| return "(empty trajectory)" | |
| dt = float(traj.timestamps[1] - traj.timestamps[0]) | |
| positions = traj.tcp_poses[:, :3, 3] | |
| header = ( | |
| f"{'segment':>7} {'type':<10} {'samples':>7} {'seconds':>8} " | |
| f"{'length_m':>9} {'peak_mps':>9} {'peak_qratio':>11} {'max_res_m':>10} {'min_sigma':>9}" | |
| ) | |
| lines = [header] | |
| seg_ids = sorted({int(s) for s in traj.segment_index if s >= 0}) | |
| for seg in seg_ids: | |
| idx = np.flatnonzero(traj.segment_index == seg) | |
| if idx.size == 0: | |
| continue | |
| span = np.concatenate([[idx[0] - 1], idx]) if idx[0] > 0 else idx | |
| deltas = np.linalg.norm(np.diff(positions[span], axis=0), axis=1) | |
| length_m = float(np.sum(deltas)) | |
| peak_mps = float(np.max(deltas) / dt) if deltas.size else 0.0 | |
| has_ratio = traj.joint_speed_ratio.size > 0 | |
| peak_qratio = float(np.max(traj.joint_speed_ratio[idx])) if has_ratio else float("nan") | |
| max_res = float(np.max(traj.position_error_m[idx])) | |
| min_sigma = float(np.min(traj.min_singular_value[idx])) | |
| label = traj.segment_labels.get(seg, str(seg)) | |
| lines.append( | |
| f"{seg:>7d} {label:<10} {idx.size:>7d} {idx.size * dt:>8.3f} " | |
| f"{length_m:>9.4f} {peak_mps:>9.4f} {peak_qratio:>11.3f} " | |
| f"{max_res:>10.2e} {min_sigma:>9.4f}" | |
| ) | |
| return "\n".join(lines) | |
Xet Storage Details
- Size:
- 22.3 kB
- Xet hash:
- 2a72abde7c798bac6692cb9fa44377aadd48fcb373ec34fb30f45f7c26de27ec
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.