Skip to content

Commit 1ecb605

Browse files
Jepson2kclaude
andcommitted
Round-5: escape new-pairs parity, tool geom vocabulary, MoveIt-style invalidation
- collision_blocked and both ik-worker escape gates now also block when the step introduces a colliding pair absent at the current config, matching guard_joint_path: the global min-distance trend alone cannot distinguish an improving start-collision from a new shallower one. pairs_now is hoisted once per gate pass; allocations stay confined to the rare in-collision branch. - Attached tool geometry is named tool:{key}:{role} (per-role dedup suffix) instead of leaking raw STL basenames into error text and collision_pairs. - SegmentPlayer re-guards committed motion against the current world: a shapes_version bump re-validates the streaming trajectory's remaining rows (zero-alloc int compare on the unchanged path), and every TrajectorySegment is guarded at activation — closing the planner-FIFO race where a segment planned against the old world arrives after SET_SHAPES applied. Violations halt exactly like an ErrorSegment via guard_joint_path's escape-aware, display-vocabulary error. Inline segments are not trajectory playback and are not re-guarded. Red-first e2e coverage: mid-flight set_shapes halts the streaming move, rejects a queued move at activation, and leaves off-path motion undisturbed; unit coverage for the new-pair gate greying and tool-role pair vocabulary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
1 parent 7560ba8 commit 1ecb605

8 files changed

Lines changed: 389 additions & 22 deletions

File tree

‎parol6/PAROL6_ROBOT.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ def _refresh_collision_tool_geometry(
214214
meshes = v.meshes
215215
break
216216
mesh_root = Path(_mesh_dir) / "meshes"
217+
role_counts: dict[str, int] = {}
217218
try:
218219
for spec in meshes:
219220
path = mesh_root / spec.file
@@ -222,10 +223,14 @@ def _refresh_collision_tool_geometry(
222223
# rotation branch here when a non-identity rpy appears.
223224
T = np.eye(4, dtype=np.float64)
224225
T[:3, 3] = spec.origin
225-
# tool: namespace so colliding-pair reports speak the defined
226-
# vocabulary (link names / shape: / install: / tool:) instead
227-
# of raw mesh filenames.
228-
geom_name = f"tool:{tool_key}:{spec.file}"
226+
# tool: namespace with the mesh's semantic role so colliding-
227+
# pair reports speak the defined vocabulary (link names /
228+
# shape: / install: / tool:), never raw mesh filenames.
229+
# Repeated roles (two jaws) get a positional suffix for the
230+
# attach/detach bookkeeping's name uniqueness.
231+
role = spec.role.name.lower()
232+
n = role_counts[role] = role_counts.get(role, 0) + 1
233+
geom_name = f"tool:{tool_key}:{role}" + (f"_{n}" if n > 1 else "")
229234
collision.attach_mesh_to_frame(
230235
geom_name,
231236
str(path),

‎parol6/commands/_collision_guard.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,18 @@ def collision_blocked(checker, current_q, target_q) -> bool:
2929
"""Whether streaming toward ``target_q`` must stop.
3030
3131
Approaching: blocked when ``target_q`` collides. Already colliding (a
32-
keep-out placed over the arm, or a tool-attach overlap): blocked only when
33-
the motion goes *deeper* — escaping is allowed, mirroring
34-
:func:`guard_joint_path`'s start-in-collision rule.
32+
keep-out placed over the arm, or a tool-attach overlap): blocked when the
33+
motion contacts anything *new* or goes *deeper* — escaping is allowed,
34+
mirroring :func:`guard_joint_path`'s start-in-collision rule (a global
35+
min-distance trend alone can't tell an improving start-collision from a
36+
new shallower one, so pairs are checked too). The pair sets allocate, but
37+
only in this already-colliding branch — never in the steady-state path.
3538
"""
3639
if checker.in_collision(current_q):
37-
return bool(
40+
new_pairs = set(checker.colliding_pairs(target_q)) - set(
41+
checker.colliding_pairs(current_q)
42+
)
43+
return bool(new_pairs) or bool(
3844
checker.min_distance(target_q)
3945
< checker.min_distance(current_q) - _ESCAPE_TOL
4046
)

‎parol6/commands/shape_commands.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ class SetShapesCommand(SystemCommand[SetShapesCmd]):
2323
dropped with) motion. The controller mirrors the applied shapes to the
2424
planner subprocess via ``sync_shapes`` after this executes.
2525
26+
A world change also invalidates committed motion that now violates it
27+
(MoveIt-style): the segment player re-guards the streaming trajectory's
28+
remaining waypoints on the version bump, and every trajectory again at
29+
activation, halting with the collision error instead of driving into the
30+
new keep-out.
31+
2632
This is the codec boundary: the wire form is rebuilt into waldoctl
2733
``Shape`` objects here (running their construction-time validation), and
2834
everything downstream — state, checkers, subprocess syncs, readback —

‎parol6/server/ik_worker.py‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -294,18 +294,20 @@ def gate_joint_enable_collision(checker, q_rad, joint_en, q_step, qlim=None) ->
294294
collides — self, tool, or keep-out shape. Proximity-gated (skip the
295295
per-direction checks when the arm is farther than ``_ENABLE_NEAR_M`` from
296296
collision) so it stays cheap. When the arm is ALREADY colliding (a keep-out
297-
placed over it), escaping directions stay enabled — grey only those that go
298-
deeper — mirroring the jog/planner escape semantics; otherwise every button
299-
would grey with no way out. ``qlim`` is optional ``(low, high)`` rows: the
300-
probe is clamped so a pose past the mechanical stop (which the jog itself
301-
can never reach) can't grey the button. Not njit — it calls the C++ checker.
297+
placed over it), escaping directions stay enabled — grey those that go
298+
deeper or contact anything new — mirroring the jog/planner escape
299+
semantics; otherwise every button would grey with no way out. ``qlim`` is
300+
optional ``(low, high)`` rows: the probe is clamped so a pose past the
301+
mechanical stop (which the jog itself can never reach) can't grey the
302+
button. Not njit — it calls the C++ checker.
302303
"""
303304
if checker is None:
304305
return
305306
md_now = checker.min_distance(q_rad)
306307
if md_now >= _ENABLE_NEAR_M:
307308
return
308309
inside = checker.in_collision(q_rad)
310+
pairs_now = set(checker.colliding_pairs(q_rad)) if inside else None
309311
for j in range(6):
310312
for slot, sign in ((2 * j, 1.0), (2 * j + 1, -1.0)):
311313
if joint_en[slot]:
@@ -316,8 +318,10 @@ def gate_joint_enable_collision(checker, q_rad, joint_en, q_step, qlim=None) ->
316318
q_step[j] = qlim[0][j]
317319
elif q_step[j] > qlim[1][j]:
318320
q_step[j] = qlim[1][j]
319-
if inside:
320-
if checker.min_distance(q_step) < md_now - _ESCAPE_TOL:
321+
if pairs_now is not None:
322+
if set(checker.colliding_pairs(q_step)) - pairs_now or (
323+
checker.min_distance(q_step) < md_now - _ESCAPE_TOL
324+
):
321325
joint_en[slot] = 0
322326
elif checker.in_collision(q_step):
323327
joint_en[slot] = 0
@@ -420,15 +424,18 @@ def _compute_cart_enable(
420424
near = md_now < _ENABLE_NEAR_M
421425
# Already colliding: keep escaping directions enabled (see the joint gate).
422426
inside = near and checker is not None and checker.in_collision(q_rad)
427+
pairs_now = set(checker.colliding_pairs(q_rad)) if inside else None
423428

424429
# Check IK (and, when near collision, the solved config) for each target
425430
for i in range(12):
426431
try:
427432
ik = solve_ik(robot, targets[i], q_rad, quiet_logging=True)
428433
ok = bool(ik.success)
429434
if ok and near and checker is not None:
430-
if inside:
431-
ok = checker.min_distance(ik.q) >= md_now - _ESCAPE_TOL
435+
if pairs_now is not None:
436+
ok = checker.min_distance(ik.q) >= md_now - _ESCAPE_TOL and not (
437+
set(checker.colliding_pairs(ik.q)) - pairs_now
438+
)
432439
elif checker.in_collision(ik.q):
433440
ok = False
434441
out[i] = 1 if ok else 0

‎parol6/server/segment_player.py‎

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
from collections import deque
1717
from typing import TYPE_CHECKING
1818

19+
import numpy as np
1920
from pinokin import arrays_equal_n
2021

22+
from parol6.commands._collision_guard import guard_joint_path
2123
from parol6.commands.base import CommandBase, ExecutionStatusCode
22-
from parol6.config import SETTLE_MAX_TICKS
24+
from parol6.config import COLLISION_PATH_SAMPLES, SETTLE_MAX_TICKS, steps_to_rad
2325
from parol6.protocol.wire import CommandCode
2426
from parol6.server.command_executor import _format_cmd_params
2527
from parol6.server.command_registry import create_command_from_struct
@@ -32,6 +34,7 @@
3234
)
3335
from parol6.utils.error_catalog import RobotError, make_error
3436
from parol6.utils.error_codes import ErrorCode
37+
from parol6.utils.errors import TrajectoryPlanningError
3538
from waldoctl import ActionState
3639

3740
if TYPE_CHECKING:
@@ -57,6 +60,7 @@ class SegmentPlayer:
5760
"_inline_activated",
5861
"_settling",
5962
"_settle_ticks",
63+
"_last_shapes_version",
6064
)
6165

6266
def __init__(self, planner: MotionPlanner) -> None:
@@ -68,6 +72,7 @@ def __init__(self, planner: MotionPlanner) -> None:
6872
self._inline_activated: bool = False
6973
self._settling: bool = False
7074
self._settle_ticks: int = 0
75+
self._last_shapes_version: int = 0
7176

7277
@property
7378
def active(self) -> bool:
@@ -89,6 +94,18 @@ def tick(self, state: ControllerState) -> bool:
8994
state.queued_duration += seg.duration
9095
seg = self._planner.poll_segment()
9196

97+
# MoveIt-style invalidation: a world change (SET_SHAPES bumps
98+
# shapes_version) re-guards the streaming trajectory's remaining
99+
# waypoints. The unchanged path pays only this int compare.
100+
if state.shapes_version != self._last_shapes_version:
101+
self._last_shapes_version = state.shapes_version
102+
if isinstance(self._active, TrajectorySegment):
103+
start = self._step - 1 if self._step > 0 else 0
104+
if not self._world_guard(
105+
self._active, self._active.trajectory_steps[start:], state
106+
):
107+
return False
108+
92109
# Process active segment or activate next
93110
max_immediate = 8 # prevent infinite recursion on back-to-back instant commands
94111
for _ in range(max_immediate):
@@ -97,6 +114,8 @@ def tick(self, state: ControllerState) -> bool:
97114
if not self._buffer:
98115
return False
99116
self._activate_next(state)
117+
if self._active is None:
118+
continue # activation-time world guard rejected the segment
100119

101120
active = self._active
102121

@@ -162,8 +181,19 @@ def tick(self, state: ControllerState) -> bool:
162181
return self._active is not None
163182

164183
def _activate_next(self, state: ControllerState) -> None:
165-
"""Promote next buffered segment to active."""
166-
self._active = self._buffer.popleft()
184+
"""Promote next buffered segment to active.
185+
186+
Trajectory segments are re-guarded against the *current* world first:
187+
their plan-time guard may predate a world change — the planner FIFO
188+
orders plans and shape syncs by submission, so a segment planned
189+
against the old world can arrive here after SET_SHAPES applied.
190+
"""
191+
seg = self._buffer.popleft()
192+
if isinstance(seg, TrajectorySegment) and not self._world_guard(
193+
seg, seg.trajectory_steps, state
194+
):
195+
return
196+
self._active = seg
167197
self._step = 0
168198
self._inline_cmd = None
169199
self._inline_activated = False
@@ -245,6 +275,51 @@ def _on_failure(
245275
self._planner.cancel()
246276
self._drain_planner_queue(state)
247277

278+
def _world_guard(
279+
self, seg: Segment, steps: np.ndarray, state: ControllerState
280+
) -> bool:
281+
"""Validate trajectory waypoints (motor steps) against the current
282+
collision world; on violation, halt playback like an ErrorSegment.
283+
284+
Runs only at segment activation and on a world change, never per-tick;
285+
rows are subsampled *before* the steps→rad conversion so the cost stays
286+
~COLLISION_PATH_SAMPLES checker calls regardless of trajectory length.
287+
``guard_joint_path`` keeps the escape semantics — a keep-out dropped
288+
onto the arm still permits the escaping remainder. Inline segments
289+
(Home/Gripper) are not trajectory playback and are not re-guarded.
290+
"""
291+
n = len(steps)
292+
if n == 0:
293+
return True
294+
target = max(2, COLLISION_PATH_SAMPLES + 2)
295+
if n > target:
296+
idx = np.unique(np.linspace(0, n - 1, target).round().astype(int))
297+
steps = steps[idx]
298+
q = np.empty((len(steps), 6), dtype=np.float64)
299+
for i in range(len(steps)):
300+
steps_to_rad(steps[i], q[i])
301+
try:
302+
guard_joint_path(q)
303+
except TrajectoryPlanningError as exc:
304+
logger.error(
305+
"Command %d invalidated by world change: %s",
306+
seg.command_index,
307+
exc.robot_error,
308+
)
309+
state.error = exc.robot_error
310+
pairs = exc.colliding_pairs
311+
state.collision_active = bool(pairs)
312+
state.collision_pairs = tuple(pairs) if pairs else ()
313+
state.action_state = ActionState.ERROR
314+
state.action_current = ""
315+
state.action_params = ""
316+
self._active = None
317+
self._buffer.clear()
318+
self._planner.cancel()
319+
self._drain_planner_queue(state)
320+
return False
321+
return True
322+
248323
def cancel(self, state: ControllerState) -> None:
249324
"""Clear buffer, drain stale segments, and stop playback."""
250325
self._active = None

0 commit comments

Comments
 (0)