Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2e103fe
Report pending planned commands and clear finished execution indices
Jepson2k Sep 8, 2026
309eb5f
test: use controller state for queue query fixtures
Jepson2k Sep 8, 2026
ccd3acf
Merge branch 'feat/held-object-geometry' into feat/supervised-restart
Jepson2k Sep 8, 2026
20df556
Merge precise command completion into restart support
Jepson2k Sep 8, 2026
9effcea
Merge broadcast session checks into supervised restart
Jepson2k Sep 8, 2026
ca62e2f
Merge controller query cancellation handling
Jepson2k Sep 8, 2026
9125207
Drop segments planned before the last cancel
Jepson2k Sep 11, 2026
9b28c5c
Merge commit '87bc694394237a2c6dca7363ac0a55c0b8798ee2' into feat/sup…
Jepson2k Sep 11, 2026
c3ba304
Merge commit '1109e6aaac9342b0946afb0381e45a5722a2f3b6' into feat/sup…
Jepson2k Sep 11, 2026
4dbe216
Merge commit 'ba9f62fadc62ebb61f91a867cce07ffcb52f73e6' into feat/sup…
Jepson2k Sep 11, 2026
2bdc5bb
Test the queue readback the PR body claimed, against the controller
Jepson2k Sep 11, 2026
ab8029b
Merge commit '7ac94325eb8fdaf4cfc6d431c11ebc45b47e730d' into feat/sup…
Jepson2k Sep 11, 2026
4cb1555
Merge branch 'feat/held-object-geometry' into feat/supervised-restart
claude Sep 17, 2026
3e94387
Merge branch 'feat/held-object-geometry' into feat/supervised-restart
claude Sep 17, 2026
338cf87
Merge branch 'feat/held-object-geometry' into feat/supervised-restart
claude Sep 17, 2026
4bb94f5
Merge branch 'feat/held-object-geometry' into feat/supervised-restart
claude Sep 18, 2026
40e4399
Merge branch 'feat/held-object-geometry' into feat/supervised-restart
claude Sep 18, 2026
8ce7414
Keep failed command indices and list only unstarted work
claude Sep 18, 2026
5e2d494
Merge branch 'feat/held-object-geometry' into feat/supervised-restart
claude Sep 18, 2026
1c24a4a
Merge main into feat/supervised-restart
Jepson2k Sep 19, 2026
ee67055
Generate trapezoidal and quintic profiles directly
Jepson2k Sep 19, 2026
8c255a6
Let a caller's deadline cancel a status wait
Jepson2k Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions parol6/client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1586,11 +1586,11 @@ async def wait_status(
if remaining <= 0:
return False
try:
await asyncio.wait_for(
self._status_event.wait(),
timeout=min(remaining, 0.5),
)
except asyncio.TimeoutError:
# asyncio.timeout, not wait_for: on 3.11 wait_for can swallow an
# outer cancellation when its child wakes in the same turn.
async with asyncio.timeout(min(remaining, 0.5)):
await self._status_event.wait()
except (asyncio.TimeoutError, TimeoutError):
continue

if self._closed:
Expand Down Expand Up @@ -1689,8 +1689,12 @@ async def _await_completion_hint(self, command_index: int, timeout: float) -> No
if remaining <= 0:
return
try:
await asyncio.wait_for(self._status_event.wait(), remaining)
except asyncio.TimeoutError:
# asyncio.timeout, not wait_for: on 3.11 wait_for can swallow an
# outer cancellation when its child wakes in the same turn, and
# the caller's deadline rides on that cancellation.
async with asyncio.timeout(remaining):
await self._status_event.wait()
except (asyncio.TimeoutError, TimeoutError):
return

# --------------- Move commands (queued, pre-computed trajectory) ---------------
Expand Down
7 changes: 6 additions & 1 deletion parol6/commands/query_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,12 @@ class QueueCommand(QueryCommand[QueueCmd]):

def compute(self, state: "ControllerState") -> Response:
return QueueResultStruct(
queue=state.queue_nonstreamable,
queue=state.queue_nonstreamable
+ [
name
for index, name in state.pending_planned
if index != state.executing_command_index
],
executing_index=state.executing_command_index,
completed_index=state.completed_command_index,
last_checkpoint=state.last_checkpoint,
Expand Down
137 changes: 64 additions & 73 deletions parol6/motion/trajectory.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,54 @@ def _rad_to_steps_alloc(rad: NDArray) -> NDArray[np.int32]:
return out


def _trapezoid_duration(distance: float, v_max: float, a_max: float) -> float:
"""Duration of a trapezoidal profile over ``distance``, starting and ending at rest."""
distance = abs(distance)
if distance < 1e-12:
return 0.0
if distance * a_max >= v_max * v_max:
return v_max / a_max + distance / v_max
return 2.0 * float(np.sqrt(distance / a_max))


def _trapezoid_samples(
times: NDArray[np.float64], q0: float, q1: float, v_max: float, a_max: float
) -> NDArray[np.float64]:
"""Sample a trapezoidal profile from ``q0`` to ``q1``, starting and ending at rest."""
distance = abs(q1 - q0)
duration = _trapezoid_duration(distance, v_max, a_max)
if duration <= 0.0:
return np.full(times.shape, q0, dtype=np.float64)

if distance * a_max >= v_max * v_max:
t_accel = v_max / a_max
v_peak = v_max
else:
t_accel = duration / 2.0
v_peak = a_max * t_accel

t = np.clip(times, 0.0, duration)
t_decel = duration - t_accel
travelled = np.where(
t < t_accel,
0.5 * a_max * t * t,
np.where(
t < t_decel,
0.5 * v_peak * t_accel + v_peak * (t - t_accel),
distance - 0.5 * a_max * (duration - t) ** 2,
),
)
return q0 + np.sign(q1 - q0) * travelled


def _quintic_samples(
times: NDArray[np.float64], q0: float, q1: float, duration: float
) -> NDArray[np.float64]:
"""Sample a quintic profile from ``q0`` to ``q1``, at rest and unaccelerated at both ends."""
s = np.clip(times / duration, 0.0, 1.0)
return q0 + (q1 - q0) * s * s * s * (10.0 - 15.0 * s + 6.0 * s * s)


class _LinearPath:
"""Piecewise linear path wrapper for TOPPRA compatibility.

Expand Down Expand Up @@ -742,15 +790,10 @@ def _compute_joint_duration_trapezoid(self) -> float:
"""
Compute duration for joint paths using trapezoidal profile.

For each joint, uses InterpolatePy to compute the minimum duration
for its displacement given its velocity/acceleration limits.
For each joint, computes the minimum duration for its displacement
given its velocity/acceleration limits.
Returns the maximum (slowest joint determines overall duration).
"""
from interpolatepy.trapezoidal import (
TrajectoryParams as TrapParams,
TrapezoidalTrajectory,
)

positions = self.joint_path.positions
if len(positions) < 2:
return self.dt * 2
Expand All @@ -763,15 +806,7 @@ def _compute_joint_duration_trapezoid(self) -> float:
if delta < 1e-6:
continue

params = TrapParams(
q0=0.0,
q1=delta,
v0=0.0,
v1=0.0,
vmax=self.v_max[j],
amax=self.a_max[j],
)
_, duration = TrapezoidalTrajectory.generate_trajectory(params)
duration = _trapezoid_duration(delta, self.v_max[j], self.a_max[j])
max_duration = max(max_duration, duration)

return max(max_duration, self.dt * 2)
Expand Down Expand Up @@ -879,8 +914,6 @@ def _build_quintic_trajectory_joint(self) -> Trajectory:
Each joint independently follows a quintic polynomial profile,
synchronized to finish at the same time.
"""
from interpolatepy import BoundaryCondition, PolynomialTrajectory, TimeInterval

start_pos = self.joint_path.positions[0]
end_pos = self.joint_path.positions[-1]

Expand All @@ -899,17 +932,9 @@ def _build_quintic_trajectory_joint(self) -> Trajectory:
trajectory_rad[:, j] = start_pos[j]
continue

bc_start = BoundaryCondition(
position=start_pos[j], velocity=0.0, acceleration=0.0
)
bc_end = BoundaryCondition(
position=end_pos[j], velocity=0.0, acceleration=0.0
trajectory_rad[:, j] = _quintic_samples(
times, start_pos[j], end_pos[j], duration
)
interval = TimeInterval(start=0.0, end=duration)
traj = PolynomialTrajectory.order_5_trajectory(bc_start, bc_end, interval)

for i, t in enumerate(times):
trajectory_rad[i, j] = traj(t)[0]

trajectory_rad, duration = self._enforce_segment_limits(
trajectory_rad, duration
Expand All @@ -926,26 +951,17 @@ def _build_quintic_trajectory_cartesian(self) -> Trajectory:
TCP follows quintic polynomial profile along the path, with local
slowdown where velocity limits would be exceeded.
"""
from interpolatepy import BoundaryCondition, PolynomialTrajectory, TimeInterval

if self.duration:
duration = self.duration
else:
# Use per-segment analysis to handle singularities and wrist flips
duration = self._compute_cartesian_duration_from_path()

# Quintic profile for the path parameter s, from s=0 to s=1
bc_start = BoundaryCondition(position=0.0, velocity=0.0, acceleration=0.0)
bc_end = BoundaryCondition(position=1.0, velocity=0.0, acceleration=0.0)
interval = TimeInterval(start=0.0, end=duration)
traj = PolynomialTrajectory.order_5_trajectory(bc_start, bc_end, interval)

n_output = max(2, int(np.ceil(duration / self.dt)))
times = np.linspace(0.0, duration, n_output)

profile_s = np.empty(n_output, dtype=np.float64)
for i in range(n_output):
profile_s[i] = traj(float(times[i]))[0]
profile_s = _quintic_samples(times, 0.0, 1.0, duration)

trajectory_rad = self.joint_path.sample_many(profile_s)

Expand Down Expand Up @@ -976,11 +992,6 @@ def _build_trapezoid_trajectory_joint(self) -> Trajectory:
Each joint independently follows a trapezoidal velocity profile,
synchronized to finish at the same time.
"""
from interpolatepy.trapezoidal import (
TrajectoryParams as TrapParams,
TrapezoidalTrajectory,
)

start_pos = self.joint_path.positions[0]
end_pos = self.joint_path.positions[-1]

Expand All @@ -999,23 +1010,18 @@ def _build_trapezoid_trajectory_joint(self) -> Trajectory:
trajectory_rad[:, j] = start_pos[j]
continue

params = TrapParams(
q0=start_pos[j],
q1=end_pos[j],
v0=0.0,
v1=0.0,
vmax=self.v_max[j],
amax=self.a_max[j],
)
traj_fn, profile_duration = TrapezoidalTrajectory.generate_trajectory(
params
)
profile_duration = _trapezoid_duration(delta, self.v_max[j], self.a_max[j])

# Scale this joint's own profile time onto the synchronized duration
time_scale = profile_duration / duration if duration > 0 else 1.0

for i, t in enumerate(times):
trajectory_rad[i, j] = traj_fn(t * time_scale)[0]
trajectory_rad[:, j] = _trapezoid_samples(
times * time_scale,
start_pos[j],
end_pos[j],
self.v_max[j],
self.a_max[j],
)

trajectory_rad, duration = self._enforce_segment_limits(
trajectory_rad, duration
Expand All @@ -1032,11 +1038,6 @@ def _build_trapezoid_trajectory_cartesian(self) -> Trajectory:
TCP follows trapezoidal velocity profile along the path, with local
slowdown where velocity limits would be exceeded.
"""
from interpolatepy.trapezoidal import (
TrajectoryParams as TrapParams,
TrapezoidalTrajectory,
)

if self.duration:
duration = self.duration
else:
Expand All @@ -1046,15 +1047,7 @@ def _build_trapezoid_trajectory_cartesian(self) -> Trajectory:
vmax_s, amax_s, _ = self._compute_s_profile_limits()

# Trapezoidal profile for the path parameter s, from s=0 to s=1
params = TrapParams(
q0=0.0,
q1=1.0,
v0=0.0,
v1=0.0,
vmax=vmax_s,
amax=amax_s,
)
traj_fn, profile_duration = TrapezoidalTrajectory.generate_trajectory(params)
profile_duration = _trapezoid_duration(1.0, vmax_s, amax_s)

# If user specified longer duration, scale to match
if self.duration and self.duration > profile_duration:
Expand All @@ -1067,9 +1060,7 @@ def _build_trapezoid_trajectory_cartesian(self) -> Trajectory:
n_output = max(2, int(np.ceil(duration / self.dt)))
times = np.linspace(0.0, duration, n_output)

profile_s = np.array(
[traj_fn(t * time_scale)[0] for t in times], dtype=np.float64
)
profile_s = _trapezoid_samples(times * time_scale, 0.0, 1.0, vmax_s, amax_s)

trajectory_rad = self.joint_path.sample_many(profile_s)

Expand Down
4 changes: 4 additions & 0 deletions parol6/server/command_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def execute_active_command(self) -> None:
except Exception as e:
logger.error("Command execution error: %s", e)
state.action_current = ""
state.executing_command_index = -1
state.action_params = ""
state.action_state = ActionState.IDLE
self._update_queue_state(state)
Expand Down Expand Up @@ -272,6 +273,7 @@ def _process_tick_result(
)

state.action_current = ""
state.executing_command_index = -1
state.action_params = ""
state.action_state = ActionState.IDLE
state.record_completion(ac.command_index)
Expand Down Expand Up @@ -320,6 +322,7 @@ def cancel_active_command(self, reason: str = "Cancelled by user") -> None:

state = self._state_manager.get_state()
state.action_current = ""
state.executing_command_index = -1
state.action_params = ""
state.action_state = ActionState.IDLE

Expand All @@ -335,6 +338,7 @@ def cancel_active_streamable(self) -> bool:
if ac and isinstance(ac.command, MotionCommand) and ac.command.streamable:
state = self._state_manager.get_state()
state.action_current = ""
state.executing_command_index = -1
state.action_params = ""
state.action_state = ActionState.IDLE
self.active_command = None
Expand Down
3 changes: 2 additions & 1 deletion parol6/server/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ def _handle_motion_command(
# segments are active/queued (e.g. homing), the planner's internal
# tracking is correct: Position_in may reflect a mid-motion position
# and the planner has already predicted a queued HOME's homed flags.
segment_idle = not self._segment_player.active
segment_idle = not self._segment_player.active and not state.pending_planned
pos_snapshot = state.Position_in.copy() if segment_idle else None
homed_snapshot: bool | None = None
if segment_idle:
Expand All @@ -876,6 +876,7 @@ def _handle_motion_command(
homed=homed_snapshot,
)
)
state.pending_planned.append((cmd_index, cmd_name))
state.plan_submitted_index = cmd_index
if cmd_type and self._ack_policy.requires_ack(cmd_type):
self._reply_ok_index(req_id, addr, cmd_index)
Expand Down
1 change: 1 addition & 0 deletions parol6/server/motion_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ def motion_planner_main(
ErrorSegment(
command_index=msg.command_index,
error=robot_error,
generation=msg.generation,
)
)
worker.cancel()
Expand Down
17 changes: 17 additions & 0 deletions parol6/server/segment_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,15 @@ def _activate_next(self, state: ControllerState) -> None:
self._inline_activated = False
state.executing_command_index = self._active.command_index
state.action_state = ActionState.EXECUTING
# The chain this segment plays is no longer owed: QUEUE lists only
# commands not yet started.
started = seg.command_index
if isinstance(seg, TrajectorySegment):
for idx in seg.blend_consumed_indices:
if idx > started:
started = idx
while state.pending_planned and state.pending_planned[0][0] <= started:
state.pending_planned.popleft()
# Populate action info for trajectory segments (inline segments set these later)
if isinstance(self._active, TrajectorySegment):
self._position_rad[:] = self._active.trajectory_rad[0]
Expand Down Expand Up @@ -422,14 +431,20 @@ def _tick_inline(self, seg: InlineSegment, state: ControllerState) -> bool | Non

def _complete_segment(self, seg: Segment, state: ControllerState) -> None:
"""Mark segment as completed and update tracking indices."""
final_idx = seg.command_index
if isinstance(seg, TrajectorySegment):
for idx in seg.blend_consumed_indices:
if idx != seg.command_index:
state.record_completion(idx)
if idx > final_idx:
final_idx = idx
state.queued_duration -= seg.duration
state.queued_segments -= 1
state.record_completion(seg.command_index)
while state.pending_planned and state.pending_planned[0][0] <= final_idx:
state.pending_planned.popleft()
state.action_current = ""
state.executing_command_index = -1
state.action_params = ""
state.action_state = ActionState.IDLE
self._active = None
Expand Down Expand Up @@ -498,6 +513,7 @@ def cancel(self, state: ControllerState) -> None:
# Planned trajectories live here rather than in CommandExecutor.
# Cancelling its command cannot clear this player's activity.
state.action_current = ""
state.executing_command_index = -1
state.action_params = ""
state.action_state = ActionState.IDLE
self._active = None
Expand All @@ -514,6 +530,7 @@ def _drain_planner_queue(self, state: ControllerState) -> None:
"""Drain any remaining segments from the planner's output queue."""
while self._planner.poll_segment() is not None:
pass
state.pending_planned.clear()
state.queued_segments = 0
state.queued_duration = 0.0
state.plan_received_index = state.plan_submitted_index
Loading
Loading