Skip to content

Commit 80a7d80

Browse files
committed
add tool variants and concurrent tool commands, tune motion limits, clean up executor threading
1 parent dd256b6 commit 80a7d80

38 files changed

Lines changed: 1083 additions & 632 deletions

‎.gitignore‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,5 @@ cython_debug/
124124
# VS Code
125125
.vscode/
126126

127-
serial_port.txt
127+
serial_port.txt
128+
test-results.xml

‎CLAUDE.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ For streamable commands (`streamable = True`), `do_setup()` also runs at high fr
132132
## Code Style
133133

134134
- **Comments**: Describe the final code state, not what changed. Avoid "changed X to Y" or "added this because..." comments.
135-
- **Git commits/PRs**: Keep messages concise and factual. No emoji, no "Generated by..." footers, no co-author boilerplate.
135+
- **Git commits/PRs**: No emoji, no "Generated by..." footers, no co-author boilerplate.
136136
- **Type annotations**: Fix type errors properly instead of using `# type: ignore`. Prefer:
137137
- `@overload` decorators for functions with different return types based on input
138138
- `assert` statements to narrow types after None checks
@@ -145,5 +145,6 @@ For streamable commands (`streamable = True`), `do_setup()` also runs at high fr
145145
**When CI tests fail, fix them.** Don't waste time analyzing whether failures are "related to your changes" — just fix all failing tests. The goal is a green CI, not attribution.
146146

147147
Prefer fewer, comprehensive integration tests that mimic manual testing over a large number of unit tests. We have no code coverage requirements—the goal is working features, not metrics.
148-
- **NEVER truncate test output.** Do not pipe pytest through `tail`, `head`, `grep`, or any other filter. Always let the full output come through so failures and tracebacks are visible. This applies to both foreground and background test runs.
148+
- **Test results are in `test-results.xml`.** Pytest writes JUnit XML to `test-results.xml` automatically. When diagnosing failures, read this file — it contains test names, durations, failure messages, and captured output. This is more reliable than parsing console output.
149149
- **NEVER run parol6 and web commander test suites in parallel** — no proper isolation, they share resources and have timing issues when resource-constrained. Always run sequentially.
150+
- **NEVER allow subagents to run tests.** Many tests are timing-sensitive and the system doesn't have enough resources for agents and tests to run simultaneously. Only the main conversation should run tests, and only after all agents have completed.

‎parol6/PAROL6_ROBOT.py‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,23 +61,26 @@
6161
robot: Robot = Robot(_urdf_path)
6262

6363

64-
def apply_tool(tool_name: str) -> None:
64+
def apply_tool(tool_name: str, variant_key: str = "") -> None:
6565
"""
6666
Apply tool transform to the robot model.
6767
6868
Parameters
6969
----------
7070
tool_name : str
7171
Name of the tool from the tool registry
72+
variant_key : str
73+
Optional variant key for the tool
7274
"""
73-
T_tool = get_tool_transform(tool_name)
75+
T_tool = get_tool_transform(tool_name, variant_key=variant_key or None)
7476

77+
label = f"'{tool_name}:{variant_key}'" if variant_key else f"'{tool_name}'"
7578
if tool_name != "NONE" and not np.allclose(T_tool, np.eye(4)):
7679
robot.set_tool_transform(T_tool)
77-
logger.info(f"Applied tool '{tool_name}' to robot model")
80+
logger.info(f"Applied tool {label} to robot model")
7881
else:
7982
robot.clear_tool_transform()
80-
logger.info(f"Applied tool '{tool_name}' (identity)")
83+
logger.info(f"Applied tool {label} (identity)")
8184

8285

8386
# Initialize with no tool
@@ -98,14 +101,14 @@ def _cleanup_robot() -> None:
98101
[6.4, 20.0, 20.0 * (38.0 / 42.0), 4.0, 4.0, 10.0], dtype=np.float64
99102
)
100103

101-
# Joint speeds (steps/s) - hardware limits
104+
# Joint speeds (steps/s)
102105
_joint_max_speed_hw: Vec6i = np.array(
103-
[9750, 27000, 30000, 30000, 33000, 33000], dtype=np.int32
106+
[16000, 27000, 32000, 10000, 10000, 32000], dtype=np.int32
104107
)
105108
_joint_min_speed: Vec6i = np.array([100, 100, 100, 100, 100, 100], dtype=np.int32)
106109

107110
# Effective max speeds with scaling applied
108-
_joint_max_speed: Vec6i = _joint_max_speed_hw.astype(np.int32)
111+
_joint_max_speed: Vec6i = _joint_max_speed_hw.copy()
109112

110113
# Jog speeds (steps/s) - 80% of scaled max for safety margin during jogging
111114
_joint_max_jog_speed: Vec6i = (_joint_max_speed * 0.8).astype(np.int32)
@@ -137,11 +140,11 @@ def _cleanup_robot() -> None:
137140
# Linear units: mm/s, mm/s^2, mm/s^3
138141
# Angular units: deg/s, deg/s^2, deg/s^3
139142
_cart_linear_velocity_max: float = 200
140-
_cart_angular_velocity_max: float = 280
143+
_cart_angular_velocity_max: float = 100
141144
_cart_linear_acc_max: float = 550
142-
_cart_angular_acc_max: float = 835
145+
_cart_angular_acc_max: float = 275
143146
_cart_linear_jerk_max: float = 5500
144-
_cart_angular_jerk_max: float = 8350
147+
_cart_angular_jerk_max: float = 2750
145148

146149
# Min values as 1% of max
147150
_cart_linear_velocity_min: float = _cart_linear_velocity_max * 0.01
@@ -323,32 +326,55 @@ def split_2_bitfield(var_in: int) -> list[int]:
323326
def _compute_tcp_velocity_at_config(
324327
q: NDArray, direction: int, v_max_joint: NDArray
325328
) -> float | None:
326-
"""Max TCP velocity in one direction while maintaining orientation."""
329+
"""Max TCP velocity in one Cartesian direction.
330+
331+
For linear directions (0-2), rejects samples that cause orientation change.
332+
For angular directions (3-5), rejects samples that cause linear translation.
333+
"""
327334
try:
328335
J = robot.jacob0(q)
329336
if np.linalg.cond(J) > 1e6:
330337
return None
331338
desired = np.zeros(6)
332339
desired[direction] = 1.0
333340
q_dot = np.linalg.pinv(J) @ desired
334-
if np.linalg.norm(J[3:, :] @ q_dot) > 0.01:
335-
return None
341+
if direction < 3:
342+
if np.linalg.norm(J[3:, :] @ q_dot) > 0.01:
343+
return None
344+
else:
345+
if np.linalg.norm(J[:3, :] @ q_dot) > 0.01:
346+
return None
336347
return float(np.min(v_max_joint / (np.abs(q_dot) + 1e-10)))
337348
except (np.linalg.LinAlgError, ValueError):
338349
return None
339350

340-
def _sample_limit(n_samples: int, seed: int, v_max: NDArray) -> tuple[float, float]:
341-
"""Sample workspace and return (median_linear_m, mean_angular_rad)."""
351+
_home_rad = np.deg2rad(_standby_deg)
352+
353+
def _sample_limit(
354+
n_samples: int, seed: int, v_max: NDArray, spread_deg: float = 30.0
355+
) -> tuple[float, float]:
356+
"""Sample around home position and return (median_linear_m, median_angular_rad).
357+
358+
Samples joint configurations from a Gaussian centered on home with
359+
std dev of ``spread_deg`` degrees, clamped to joint limits.
360+
"""
342361
rng = np.random.default_rng(seed)
343-
results = []
362+
spread_rad = np.deg2rad(spread_deg)
363+
lin_results = []
364+
ang_results = []
344365
for _ in range(n_samples):
345-
q = np.array([rng.uniform(lo, hi) for lo, hi in _joint_limits_radian])
366+
q = _home_rad + rng.normal(0, spread_rad, size=6)
367+
q = np.clip(q, _joint_limits_radian[:, 0], _joint_limits_radian[:, 1])
346368
for d in range(3):
347369
v = _compute_tcp_velocity_at_config(q, d, v_max)
348370
if v is not None and v > 0.001:
349-
results.append(v)
350-
linear = float(np.median(results)) if results else 0.1
351-
angular = float(np.mean(v_max[3:6]))
371+
lin_results.append(v)
372+
for d in range(3, 6):
373+
v = _compute_tcp_velocity_at_config(q, d, v_max)
374+
if v is not None and v > 0.001:
375+
ang_results.append(v)
376+
linear = float(np.median(lin_results)) if lin_results else 0.1
377+
angular = float(np.median(ang_results)) if ang_results else 0.1
352378
return linear, angular
353379

354380
vel_lin, vel_ang = _sample_limit(500, 42, _joint_speed_rad)

‎parol6/ack_policy.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
CmdType.SERVOL,
4040
CmdType.JOGJ,
4141
CmdType.JOGL,
42+
CmdType.TELEPORT,
4243
CmdType.RESET_LOOP_STATS,
4344
}
4445

‎parol6/client/async_client.py‎

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
SetProfileCmd,
7878
SetToolCmd,
7979
SimulatorCmd,
80+
TeleportCmd,
8081
SpeedsResultStruct,
8182
StatusBuffer,
8283
StatusResultStruct,
@@ -274,6 +275,7 @@ def __init__(
274275

275276
# Active tool key (set by set_tool)
276277
self._active_tool_key: str | None = None
278+
self._active_variant_key: str = ""
277279

278280
# Bound tool specs (populated by Robot.create_async_client)
279281
self._bound_tools: dict[str, ToolSpec] = {}
@@ -500,23 +502,23 @@ async def _send(self, cmd: msgspec.Struct) -> int:
500502
501503
Returns:
502504
int (command index ≥ 0) for ACK'd queued commands, -1 on failure,
503-
bool for system/fire-and-forget/query commands.
505+
1 for system/fire-and-forget/query success, 0 on failure.
504506
"""
505507
await self._ensure_endpoint()
506508
assert self._transport is not None
507509

508510
# Get command type from struct's tag
509511
cmd_type = STRUCT_TO_CMDTYPE.get(type(cmd))
510512
if cmd_type is None:
511-
return False
513+
return 0
512514

513515
# System commands: need stable bytes across await
514516
if cmd_type in SYSTEM_CMD_TYPES:
515517
try:
516518
await self._request_ok_raw(encode_command(cmd), self.timeout)
517-
return True
519+
return 1
518520
except TimeoutError:
519-
return False
521+
return 0
520522

521523
# Motion and other non-query commands
522524
if cmd_type not in QUERY_CMD_TYPES:
@@ -530,12 +532,12 @@ async def _send(self, cmd: msgspec.Struct) -> int:
530532
# Fire-and-forget: reuse pre-allocated buffer (sendto copies)
531533
encode_command_into(cmd, self._tx_buf)
532534
self._transport.sendto(self._tx_buf)
533-
return True
535+
return 1
534536

535537
# Queries via _send: fire-and-forget
536538
encode_command_into(cmd, self._tx_buf)
537539
self._transport.sendto(self._tx_buf)
538-
return True
540+
return 1
539541

540542
async def _request(self, cmd: msgspec.Struct) -> Response | None:
541543
"""Send a query command and wait for a typed response.
@@ -628,7 +630,7 @@ async def _request_ok_raw(self, data: bytes, timeout: float) -> OkMsg:
628630
# --------------- Motion / Control ---------------
629631

630632
async def home(
631-
self, wait: bool = False, timeout: float = 10.0, **wait_kwargs: Any
633+
self, wait: bool = False, timeout: float = 60.0, **wait_kwargs: Any
632634
) -> int:
633635
"""Home the robot to its home position.
634636
@@ -646,7 +648,9 @@ async def home(
646648
index = await self._send(HomeCmd())
647649
assert isinstance(index, int)
648650
if wait and index >= 0:
649-
await self.wait_command_complete(index, timeout=timeout)
651+
ok = await self.wait_command_complete(index, timeout=timeout)
652+
if not ok:
653+
raise TimeoutError(f"home() timed out after {timeout}s")
650654
return index
651655

652656
async def resume(self) -> int:
@@ -658,7 +662,7 @@ async def resume(self) -> int:
658662
rbt.resume()
659663
660664
Returns:
661-
True if the command was acknowledged successfully.
665+
1 if acknowledged, 0 on failure.
662666
"""
663667
return await self._send(ResumeCmd())
664668

@@ -671,7 +675,7 @@ async def halt(self) -> int:
671675
rbt.halt()
672676
673677
Returns:
674-
True if the command was acknowledged successfully.
678+
1 if acknowledged, 0 on failure.
675679
"""
676680
return await self._send(HaltCmd())
677681

@@ -687,7 +691,7 @@ async def simulator_on(self) -> int:
687691
rbt.simulator_on()
688692
689693
Returns:
690-
True if the command was acknowledged successfully.
694+
1 if acknowledged, 0 on failure.
691695
"""
692696
return await self._send(SimulatorCmd(on=True))
693697

@@ -700,10 +704,27 @@ async def simulator_off(self) -> int:
700704
rbt.simulator_off()
701705
702706
Returns:
703-
True if the command was acknowledged successfully.
707+
1 if acknowledged, 0 on failure.
704708
"""
705709
return await self._send(SimulatorCmd(on=False))
706710

711+
async def teleport(
712+
self,
713+
angles_deg: list[float],
714+
tool_positions: list[float] | None = None,
715+
) -> int:
716+
"""Instantly set joint angles and optional tool positions (simulator only).
717+
718+
Category: Control
719+
720+
Example:
721+
rbt.teleport([0, -90, 0, 0, 0, 0])
722+
rbt.teleport([0, -90, 0, 0, 0, 0], tool_positions=[1.0])
723+
"""
724+
return await self._send(
725+
TeleportCmd(angles=angles_deg, tool_positions=tool_positions)
726+
)
727+
707728
async def set_serial_port(self, port_str: str) -> int:
708729
"""Set the serial port for robot hardware communication.
709730
@@ -716,7 +737,7 @@ async def set_serial_port(self, port_str: str) -> int:
716737
port_str: Serial port path (e.g., '/dev/ttyUSB0' or 'COM3').
717738
718739
Returns:
719-
True if the command was acknowledged successfully.
740+
1 if acknowledged, 0 on failure.
720741
721742
Raises:
722743
ValueError: If port_str is empty.
@@ -828,7 +849,7 @@ async def reset_loop_stats(self) -> int:
828849
"""
829850
return await self._send(ResetLoopStatsCmd())
830851

831-
async def set_tool(self, tool_name: str) -> int:
852+
async def set_tool(self, tool_name: str, variant_key: str = "") -> int:
832853
"""Set the current end-effector tool configuration.
833854
834855
Category: Configuration
@@ -838,12 +859,16 @@ async def set_tool(self, tool_name: str) -> int:
838859
839860
Args:
840861
tool_name: Name of the tool ('NONE', 'PNEUMATIC', 'SSG-48', 'MSG', 'VACUUM')
862+
variant_key: Optional variant within the tool type.
841863
842864
Returns:
843-
True if successful
865+
Command index (>= 0) if queued, 0 on failure.
844866
"""
845867
self._active_tool_key = tool_name.upper()
846-
return await self._send(SetToolCmd(tool_name=self._active_tool_key))
868+
self._active_variant_key = variant_key
869+
return await self._send(
870+
SetToolCmd(tool_name=self._active_tool_key, variant_key=variant_key)
871+
)
847872

848873
async def set_profile(self, profile: str) -> int:
849874
"""Set the motion profile for all moves.

0 commit comments

Comments
 (0)