From 726be2bcaea8c5768c1e4d6846a4d9d90dc361d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:26:55 +0000 Subject: [PATCH 1/7] Declare the MSG gripper's built-in camera mount The MSG carries an integrated camera mount, so its ToolConfig now ships a default CameraSpec. The video device stays per-machine: the spec's no-camera sentinel is resolved through the tool's runtime camera override, and frontends can use the declaration to surface the mount (e.g. hand-eye calibration guidance). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015HkPS4EgPZg7tvgCxBYqTT --- parol6/tools.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/parol6/tools.py b/parol6/tools.py index c24f9fc..6f94c26 100644 --- a/parol6/tools.py +++ b/parol6/tools.py @@ -677,6 +677,9 @@ def _make_tcp_transform( transform=_make_tcp_transform(x=-0.029, z=-0.103), meshes=_MSG_100_MESHES, motions=_MSG_100_JAW_MOTION, + # The MSG carries a built-in camera mount; the video device is + # per-machine, supplied at runtime via the tool's camera override. + camera_spec=CameraSpec(), variants=( ToolVariant( key="100mm", From 7d8869c2b1505a5a17e26f310d2636317b1bfc88 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:40:32 -0400 Subject: [PATCH 2/7] Gate trajectory settling on progress, not elapsed ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settle phase completed a segment after a fixed 20-tick cap even with the firmware still closing on the target, and the mock transport freezes all residual motion once the command stream goes idle — on a starved host the simulated plant falls behind the waypoint stream, the cap fires, and the robot is reported complete while stranded short of the target (waldo-commander's hand-eye CI observed home "complete" with J1 29 deg from standby). Settling now resets the tick counter whenever the position error shrinks, so the cap only fires after 20 ticks without progress — preserving the anti-hang escape for real hardware's steady-state residual — and an unconverged completion logs the residual instead of passing silently. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EdLG1uiKKZJ6aAeiojeRwd --- parol6/server/segment_player.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/parol6/server/segment_player.py b/parol6/server/segment_player.py index 3eb9ef5..080e2a2 100644 --- a/parol6/server/segment_player.py +++ b/parol6/server/segment_player.py @@ -17,7 +17,6 @@ from typing import TYPE_CHECKING import numpy as np -from pinokin import arrays_equal_n from parol6.commands._collision_guard import guard_joint_path from parol6.commands.base import CommandBase, ExecutionStatusCode @@ -60,6 +59,7 @@ class SegmentPlayer: "_inline_activated", "_settling", "_settle_ticks", + "_settle_err", "_last_shapes_version", ) @@ -72,6 +72,7 @@ def __init__(self, planner: MotionPlanner) -> None: self._inline_activated: bool = False self._settling: bool = False self._settle_ticks: int = 0 + self._settle_err: int = -1 self._last_shapes_version: int = 0 @property @@ -127,16 +128,36 @@ def tick(self, state: ControllerState) -> bool: self._step += 1 self._settling = False return True - # All waypoints sent — hold MOVE at target until Position_in converges + # All waypoints sent — hold MOVE at target until Position_in + # converges. The tick cap gates on stall, not elapsed time: + # while the firmware is still closing on the target (e.g. it + # fell behind the waypoint stream under CPU starvation) the + # segment stays active, so completion is never reported with + # the robot still in motion. target = active.trajectory_steps[-1] if not self._settling: self._settling = True self._settle_ticks = 0 + self._settle_err = -1 + err = 0 + for i in range(6): + d = int(state.Position_in[i]) - int(target[i]) + if d < 0: + d = -d + if d > err: + err = d + if self._settle_err < 0 or err < self._settle_err: + self._settle_err = err + self._settle_ticks = 0 self._settle_ticks += 1 - if ( - arrays_equal_n(state.Position_in[:6], target[:6]) - or self._settle_ticks > SETTLE_MAX_TICKS - ): + if err == 0 or self._settle_ticks > SETTLE_MAX_TICKS: + if err != 0: + logger.warning( + "Segment completed %d steps short of target " + "(no settle progress for %d ticks)", + err, + SETTLE_MAX_TICKS, + ) self._settling = False self._complete_segment(active, state) continue From dbeadbf0aba61c3552d5ca0b0850db445ff47a00 Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:52:37 -0400 Subject: [PATCH 3/7] Annotate the clamped-spline bc assignment so ty keeps it Any scipy-stubs 1.18 types bc_type derivative values as array-likes only, while scipy requires scalars for 1-D y. ty narrows a bare-declared Any on assignment, so the tuple branch failed overload resolution; an annotated assignment keeps bc at its declared Any. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LkVPPMQg33tFuGCVLkyrJQ --- parol6/motion/geometry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/parol6/motion/geometry.py b/parol6/motion/geometry.py index e36cb0b..b581a79 100644 --- a/parol6/motion/geometry.py +++ b/parol6/motion/geometry.py @@ -192,9 +192,10 @@ def generate_spline( pos_splines = [] for i in range(3): - bc: Any + # Annotated assignment keeps bc as Any: scipy-stubs' bc_type rejects + # the scalar derivative values scipy requires for 1-D y if velocity_start is not None and velocity_end is not None: - bc = ((1, float(velocity_start[i])), (1, float(velocity_end[i]))) + bc: Any = ((1, float(velocity_start[i])), (1, float(velocity_end[i]))) else: bc = "not-a-knot" spline = CubicSpline(timestamps_arr, waypoints_arr[:, i], bc_type=bc) From 804b898775b6142d18087c7a45f475c5bdb7046e Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:30:21 -0400 Subject: [PATCH 4/7] Drop a stale ty ignore code and a redundant memoryview cast ty 0.0.69 resolves ndarray.tolist() without the ty-specific suppression and infers the frame memoryview union directly; it now warns on both leftovers, and warnings fail the lint hook. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LkVPPMQg33tFuGCVLkyrJQ --- parol6/protocol/wire.py | 2 +- parol6/server/transports/serial_transport.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/parol6/protocol/wire.py b/parol6/protocol/wire.py index 231d7e0..204f90f 100644 --- a/parol6/protocol/wire.py +++ b/parol6/protocol/wire.py @@ -43,7 +43,7 @@ def _enc_hook(obj: object) -> object: """Custom encoder hook for numpy types.""" if isinstance(obj, np.ndarray): - return obj.tolist() # type: ignore[no-matching-overload, ty:no-matching-overload] + return obj.tolist() # type: ignore[no-matching-overload] if isinstance(obj, (np.integer, np.floating)): return obj.item() raise NotImplementedError(f"Cannot encode {type(obj)}") diff --git a/parol6/server/transports/serial_transport.py b/parol6/server/transports/serial_transport.py index ace4b03..a26e822 100644 --- a/parol6/server/transports/serial_transport.py +++ b/parol6/server/transports/serial_transport.py @@ -8,7 +8,6 @@ import logging import os import time -from typing import cast import numba import numpy as np @@ -415,9 +414,7 @@ def get_latest_frame_view(self) -> tuple[memoryview | None, int, float]: Return a tuple of (memoryview|None, version:int, timestamp:float). The memoryview points to a stable 52-byte buffer which is updated by the reader. """ - mv = cast( - "memoryview | None", self._frame_mv if self._frame_version > 0 else None - ) + mv = self._frame_mv if self._frame_version > 0 else None return (mv, self._frame_version, self._frame_ts) def _update_hz_tracking(self) -> None: From adcd5b88144ddde5583b0460d51e34572aafb7fb Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:47:53 -0400 Subject: [PATCH 5/7] Pin scipy-stubs in the dev extra Unpinned stub releases have broken the ty lint hook twice; ty itself stays unpinned deliberately while it is still in beta. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LkVPPMQg33tFuGCVLkyrJQ --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4a08877..b3e14fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ dev = [ "trimesh", "fast-simplification", "rtree", - "scipy-stubs", + "scipy-stubs==1.18.0.1", "types-pyserial", ] From bd65722a418afe85b9db369fbb1257d9819c735a Mon Sep 17 00:00:00 2001 From: jepson2k <55201008+Jepson2k@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:50:04 -0400 Subject: [PATCH 6/7] Split the scipy-stubs pin by Python version scipy-stubs 1.18.x requires Python >=3.12, so a single exact pin is uninstallable on the 3.11 CI jobs; 3.11 pins the last 1.17 release. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LkVPPMQg33tFuGCVLkyrJQ --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b3e14fa..78015cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,8 @@ dev = [ "trimesh", "fast-simplification", "rtree", - "scipy-stubs==1.18.0.1", + "scipy-stubs==1.17.1.5; python_version < '3.12'", + "scipy-stubs==1.18.0.1; python_version >= '3.12'", "types-pyserial", ] From d4af98d2899a48fead74cb47a88ac18b8408805e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 20:40:10 +0000 Subject: [PATCH 7/7] Seat the MSG gripper on the flange, not inside the wrist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MSG STLs were exported 6.5 mm proud of their mounting face: every body mesh ends at z = +6.500 in the flange frame, where the SSG-48 body ends at exactly 0.000. Left uncorrected the gripper is seated 6.5 mm into the wrist — it interpenetrates L5 (invisible to the checker because tool geometry attaches to L6 and neighbouring pairs are dropped as adjacent) and the residual 2.1 mm gap to L4 sits inside the 5 mm clearance buffer, so every reachable pose reads as in-collision and the guard refuses to return the arm to standby. Give the nine MSG mesh specs a -6.5 mm z origin so the mounting face lands on the flange plane like every other tool. Standby clearance becomes 8.53 mm, matching the SSG-48, with no pair exclusions needed. The TCP carried the same export offset: z = -103 mm sat on the jaw tips only while the meshes sat 6.5 mm too deep. With the mount corrected the tips are at z = -109.5 mm, so the TCP moves there too, keeping the TCP-at-fingertip convention the SSG-48 uses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015HkPS4EgPZg7tvgCxBYqTT --- parol6/tools.py | 72 +++++++++++++++++++----- tests/unit/test_collision_integration.py | 36 ++++++++++++ 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/parol6/tools.py b/parol6/tools.py index 6f94c26..28f1521 100644 --- a/parol6/tools.py +++ b/parol6/tools.py @@ -651,22 +651,68 @@ def _make_tcp_transform( ), ) +# The MSG STLs were exported 6.5 mm proud of their mounting face: every body +# mesh ends at z = +6.500 in the flange frame, where the SSG-48 body ends at +# exactly 0.000. Left uncorrected the gripper is seated 6.5 mm into the wrist +# — it interpenetrates L5, which no pair check catches because tool geometry +# is attached to L6 and the neighbouring pairs are dropped as adjacent — and +# what clearance remains to L4 is an artifact of that. Shifting the whole +# assembly back puts the mounting face on the flange plane, matching every +# other tool. +_MSG_MOUNT_ORIGIN = (0.0, 0.0, -0.0065) + _MSG_100_MESHES = ( - MeshSpec(file="msg_ai_100_body_simplified.stl", role=MeshRole.BODY), - MeshSpec(file="msg_ai_100_right_jaw_simplified.stl", role=MeshRole.JAW), - MeshSpec(file="msg_ai_100_left_jaw_simplified.stl", role=MeshRole.JAW), + MeshSpec( + file="msg_ai_100_body_simplified.stl", + role=MeshRole.BODY, + origin=_MSG_MOUNT_ORIGIN, + ), + MeshSpec( + file="msg_ai_100_right_jaw_simplified.stl", + role=MeshRole.JAW, + origin=_MSG_MOUNT_ORIGIN, + ), + MeshSpec( + file="msg_ai_100_left_jaw_simplified.stl", + role=MeshRole.JAW, + origin=_MSG_MOUNT_ORIGIN, + ), ) _MSG_150_MESHES = ( - MeshSpec(file="msg_ai_150_body_simplified.stl", role=MeshRole.BODY), - MeshSpec(file="msg_ai_150_right_jaw_simplified.stl", role=MeshRole.JAW), - MeshSpec(file="msg_ai_150_left_jaw_simplified.stl", role=MeshRole.JAW), + MeshSpec( + file="msg_ai_150_body_simplified.stl", + role=MeshRole.BODY, + origin=_MSG_MOUNT_ORIGIN, + ), + MeshSpec( + file="msg_ai_150_right_jaw_simplified.stl", + role=MeshRole.JAW, + origin=_MSG_MOUNT_ORIGIN, + ), + MeshSpec( + file="msg_ai_150_left_jaw_simplified.stl", + role=MeshRole.JAW, + origin=_MSG_MOUNT_ORIGIN, + ), ) _MSG_200_MESHES = ( - MeshSpec(file="msg_ai_200_body_simplified.stl", role=MeshRole.BODY), - MeshSpec(file="msg_ai_200_right_jaw_simplified.stl", role=MeshRole.JAW), - MeshSpec(file="msg_ai_200_left_jaw_simplified.stl", role=MeshRole.JAW), + MeshSpec( + file="msg_ai_200_body_simplified.stl", + role=MeshRole.BODY, + origin=_MSG_MOUNT_ORIGIN, + ), + MeshSpec( + file="msg_ai_200_right_jaw_simplified.stl", + role=MeshRole.JAW, + origin=_MSG_MOUNT_ORIGIN, + ), + MeshSpec( + file="msg_ai_200_left_jaw_simplified.stl", + role=MeshRole.JAW, + origin=_MSG_MOUNT_ORIGIN, + ), ) register_tool( @@ -674,7 +720,7 @@ def _make_tcp_transform( ElectricGripperConfig( name="MSG AI Stepper Gripper", description="MSG compliant AI stepper gripper (StepFOC)", - transform=_make_tcp_transform(x=-0.029, z=-0.103), + transform=_make_tcp_transform(x=-0.029, z=-0.1095), meshes=_MSG_100_MESHES, motions=_MSG_100_JAW_MOTION, # The MSG carries a built-in camera mount; the video device is @@ -686,7 +732,7 @@ def _make_tcp_transform( display_name="100mm Rail", meshes=_MSG_100_MESHES, motions=_MSG_100_JAW_MOTION, - tcp_origin=(-0.029, 0.0, -0.103), + tcp_origin=(-0.029, 0.0, -0.1095), tcp_rpy=_TCP_RPY, ), ToolVariant( @@ -694,7 +740,7 @@ def _make_tcp_transform( display_name="150mm Rail", meshes=_MSG_150_MESHES, motions=_MSG_150_JAW_MOTION, - tcp_origin=(-0.029, 0.0, -0.103), + tcp_origin=(-0.029, 0.0, -0.1095), tcp_rpy=_TCP_RPY, ), ToolVariant( @@ -702,7 +748,7 @@ def _make_tcp_transform( display_name="200mm Rail", meshes=_MSG_200_MESHES, motions=_MSG_200_JAW_MOTION, - tcp_origin=(-0.029, 0.0, -0.103), + tcp_origin=(-0.029, 0.0, -0.1095), tcp_rpy=_TCP_RPY, ), ), diff --git a/tests/unit/test_collision_integration.py b/tests/unit/test_collision_integration.py index 59269cd..37e41bc 100644 --- a/tests/unit/test_collision_integration.py +++ b/tests/unit/test_collision_integration.py @@ -9,8 +9,11 @@ from __future__ import annotations +from pathlib import Path + import numpy as np import pytest +import trimesh import parol6.PAROL6_ROBOT as PAROL6_ROBOT import parol6.config # noqa: F401 - imports trigger collision-checker init @@ -485,3 +488,36 @@ def test_dry_run_script_set_shapes_applies_and_replays(): assert PAROL6_ROBOT._active_shape_names == ["shape:bar2"] finally: PAROL6_ROBOT.apply_shapes([]) + + +def test_msg_mounts_on_the_flange_not_inside_the_wrist(): + """The MSG STLs are exported 6.5 mm proud of their mounting face, so the + assembly needs a matching origin offset to seat on the flange. Without it + the gripper is sunk into L5 — a pair no check covers, tool geometry being + attached to L6 — and what little clearance is left to L4 falls inside the + buffer, so every pose reads as colliding and the arm can never plan its + way back to standby. + """ + standby = np.radians(PAROL6_ROBOT.joint.standby_deg) + checker = PAROL6_ROBOT.collision + mesh_dir = Path(PAROL6_ROBOT._mesh_dir) / "meshes" + try: + PAROL6_ROBOT.apply_tool("MSG") + checker.update_placements(standby) + + # Seated on the flange: no part of the body lies inside the wrist link. + body = trimesh.load_mesh(str(mesh_dir / "msg_ai_100_body.stl")) + body.apply_transform(checker.geometry_world_pose("tool:MSG:body")) + l5 = trimesh.load_mesh(str(mesh_dir / "L5.STL")) + l5.apply_transform(checker.geometry_world_pose("L5_0")) + assert not l5.contains(body.vertices).any(), "gripper seated inside L5" + + # With the tool clear of the wrist, standby is outside the buffer and + # the return path the controller plans for HOME is accepted. + assert checker.in_collision(standby) is False + away = np.radians([90.0, -95.0, 187.0, 0.0, 6.0, 165.0]) + guard_joint_path( + np.vstack([np.linspace(a, s, 25) for a, s in zip(away, standby)]).T + ) + finally: + PAROL6_ROBOT.apply_tool("NONE")