feat(controls): Wheel slip detector (Issue #262) #266

Merged
sl-jetson merged 2 commits from sl-controls/issue-262-wheel-slip into main 2026-03-02 17:30:53 -05:00
10 changed files with 93 additions and 73 deletions

View File

@ -5,21 +5,10 @@ from launch.actions import DeclareLaunchArgument
import os import os
from ament_index_python.packages import get_package_share_directory from ament_index_python.packages import get_package_share_directory
def generate_launch_description(): def generate_launch_description():
pkg_dir = get_package_share_directory("saltybot_wheel_slip_detector") pkg_dir = get_package_share_directory("saltybot_wheel_slip_detector")
config_file = os.path.join(pkg_dir, "config", "wheel_slip_config.yaml") config_file = os.path.join(pkg_dir, "config", "wheel_slip_config.yaml")
return LaunchDescription([ return LaunchDescription([
DeclareLaunchArgument( DeclareLaunchArgument("config_file", default_value=config_file, description="Path to configuration YAML file"),
"config_file", Node(package="saltybot_wheel_slip_detector", executable="wheel_slip_detector_node", name="wheel_slip_detector", output="screen", parameters=[LaunchConfiguration("config_file")]),
default_value=config_file,
description="Path to configuration YAML file",
),
Node(
package="saltybot_wheel_slip_detector",
executable="wheel_slip_detector_node",
name="wheel_slip_detector",
output="screen",
parameters=[LaunchConfiguration("config_file")],
),
]) ])

View File

@ -0,0 +1,18 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>saltybot_wheel_slip_detector</name>
<version>0.1.0</version>
<description>Wheel slip detection by comparing commanded vs actual velocity.</description>
<maintainer email="seb@vayrette.com">Seb</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<depend>geometry_msgs</depend>
<depend>std_msgs</depend>
<depend>nav_msgs</depend>
<test_depend>pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>

View File

@ -1,13 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Wheel slip detector for SaltyBot.
Detects wheel slip by comparing commanded velocity vs actual encoder velocity.
Publishes Bool when slip is detected for >0.5s, enabling speed reduction response.
"""
from typing import Optional from typing import Optional
import math import math
import rclpy import rclpy
from rclpy.node import Node from rclpy.node import Node
from rclpy.timer import Timer from rclpy.timer import Timer
@ -15,82 +8,60 @@ from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry from nav_msgs.msg import Odometry
from std_msgs.msg import Bool from std_msgs.msg import Bool
class WheelSlipDetectorNode(Node): class WheelSlipDetectorNode(Node):
"""ROS2 node for wheel slip detection."""
def __init__(self): def __init__(self):
super().__init__("wheel_slip_detector") super().__init__("wheel_slip_detector")
self.declare_parameter("frequency", 10) self.declare_parameter("frequency", 10)
frequency = self.get_parameter("frequency").value frequency = self.get_parameter("frequency").value
self.declare_parameter("slip_threshold", 0.1) self.declare_parameter("slip_threshold", 0.1)
self.declare_parameter("slip_timeout", 0.5) self.declare_parameter("slip_timeout", 0.5)
self.slip_threshold = self.get_parameter("slip_threshold").value self.slip_threshold = self.get_parameter("slip_threshold").value
self.slip_timeout = self.get_parameter("slip_timeout").value self.slip_timeout = self.get_parameter("slip_timeout").value
self.period = 1.0 / frequency self.period = 1.0 / frequency
self.cmd_vel: Optional[Twist] = None self.cmd_vel: Optional[Twist] = None
self.actual_vel: Optional[Twist] = None self.actual_vel: Optional[Twist] = None
self.slip_duration = 0.0 self.slip_duration = 0.0
self.slip_detected = False self.slip_detected = False
self.create_subscription(Twist, "/cmd_vel", self._on_cmd_vel, 10) self.create_subscription(Twist, "/cmd_vel", self._on_cmd_vel, 10)
self.create_subscription(Odometry, "/odom", self._on_odom, 10) self.create_subscription(Odometry, "/odom", self._on_odom, 10)
self.pub_slip = self.create_publisher(Bool, "/saltybot/wheel_slip_detected", 10) self.pub_slip = self.create_publisher(Bool, "/saltybot/wheel_slip_detected", 10)
self.timer: Timer = self.create_timer(self.period, self._timer_callback) self.timer: Timer = self.create_timer(self.period, self._timer_callback)
self.get_logger().info(f"Wheel slip detector initialized at {frequency}Hz. Threshold: {self.slip_threshold} m/s, Timeout: {self.slip_timeout}s")
self.get_logger().info(
f"Wheel slip detector initialized at {frequency}Hz. "
f"Threshold: {self.slip_threshold} m/s, Timeout: {self.slip_timeout}s"
)
def _on_cmd_vel(self, msg: Twist) -> None: def _on_cmd_vel(self, msg: Twist) -> None:
"""Update commanded velocity from subscription."""
self.cmd_vel = msg self.cmd_vel = msg
def _on_odom(self, msg: Odometry) -> None: def _on_odom(self, msg: Odometry) -> None:
"""Update actual velocity from odometry subscription."""
self.actual_vel = msg.twist.twist self.actual_vel = msg.twist.twist
def _timer_callback(self) -> None: def _timer_callback(self) -> None:
"""Detect wheel slip and publish detection flag."""
if self.cmd_vel is None or self.actual_vel is None: if self.cmd_vel is None or self.actual_vel is None:
slip_detected = False slip_detected = False
else: else:
slip_detected = self._check_slip() slip_detected = self._check_slip()
if slip_detected: if slip_detected:
self.slip_duration += self.period self.slip_duration += self.period
else: else:
self.slip_duration = 0.0 self.slip_duration = 0.0
is_slip = self.slip_duration > self.slip_timeout is_slip = self.slip_duration > self.slip_timeout
if is_slip != self.slip_detected: if is_slip != self.slip_detected:
self.slip_detected = is_slip self.slip_detected = is_slip
if self.slip_detected: if self.slip_detected:
self.get_logger().warn(f"WHEEL SLIP DETECTED: {self.slip_duration:.2f}s") self.get_logger().warn(f"WHEEL SLIP DETECTED: {self.slip_duration:.2f}s")
else: else:
self.get_logger().info("Wheel slip cleared") self.get_logger().info("Wheel slip cleared")
slip_msg = Bool() slip_msg = Bool()
slip_msg.data = is_slip slip_msg.data = is_slip
self.pub_slip.publish(slip_msg) self.pub_slip.publish(slip_msg)
def _check_slip(self) -> bool: def _check_slip(self) -> bool:
"""Check if velocity difference indicates slip."""
cmd_speed = math.sqrt(self.cmd_vel.linear.x**2 + self.cmd_vel.linear.y**2) cmd_speed = math.sqrt(self.cmd_vel.linear.x**2 + self.cmd_vel.linear.y**2)
actual_speed = math.sqrt(self.actual_vel.linear.x**2 + self.actual_vel.linear.y**2) actual_speed = math.sqrt(self.actual_vel.linear.x**2 + self.actual_vel.linear.y**2)
vel_diff = abs(cmd_speed - actual_speed) vel_diff = abs(cmd_speed - actual_speed)
if cmd_speed < 0.05 and actual_speed < 0.05: if cmd_speed < 0.05 and actual_speed < 0.05:
return False return False
return vel_diff > self.slip_threshold return vel_diff > self.slip_threshold
def main(args=None): def main(args=None):
rclpy.init(args=args) rclpy.init(args=args)
node = WheelSlipDetectorNode() node = WheelSlipDetectorNode()
@ -102,6 +73,5 @@ def main(args=None):
node.destroy_node() node.destroy_node()
rclpy.shutdown() rclpy.shutdown()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -0,0 +1,4 @@
[develop]
script-dir=$base/lib/saltybot_wheel_slip_detector
[install]
install-scripts=$base/lib/saltybot_wheel_slip_detector

View File

@ -1,7 +1,5 @@
from setuptools import find_packages, setup from setuptools import find_packages, setup
package_name = "saltybot_wheel_slip_detector" package_name = "saltybot_wheel_slip_detector"
setup( setup(
name=package_name, name=package_name,
version="0.1.0", version="0.1.0",
@ -19,9 +17,5 @@ setup(
description="Wheel slip detection from velocity command/actual mismatch", description="Wheel slip detection from velocity command/actual mismatch",
license="Apache-2.0", license="Apache-2.0",
tests_require=["pytest"], tests_require=["pytest"],
entry_points={ entry_points={"console_scripts": ["wheel_slip_detector_node = saltybot_wheel_slip_detector.wheel_slip_detector_node:main"]},
"console_scripts": [ )
"wheel_slip_detector_node = saltybot_wheel_slip_detector.wheel_slip_detector_node:main",
],
},
)

View File

@ -61,9 +61,6 @@ import { NetworkPanel } from './components/NetworkPanel.jsx';
// Waypoint editor (issue #261) // Waypoint editor (issue #261)
import { WaypointEditor } from './components/WaypointEditor.jsx'; import { WaypointEditor } from './components/WaypointEditor.jsx';
// Status header (issue #269)
import { StatusHeader } from './components/StatusHeader.jsx';
const TAB_GROUPS = [ const TAB_GROUPS = [
{ {
label: 'SOCIAL', label: 'SOCIAL',

View File

@ -10,11 +10,13 @@
* - Execute waypoint sequence with automatic progression * - Execute waypoint sequence with automatic progression
* - Clear all waypoints button * - Clear all waypoints button
* - Visual feedback for active waypoint (executing) * - Visual feedback for active waypoint (executing)
* - Imports map display from MapViewer for coordinate system
*/ */
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
function WaypointEditor({ subscribe, publish, callService }) { function WaypointEditor({ subscribe, publish, callService }) {
// Waypoint storage
const [waypoints, setWaypoints] = useState([]); const [waypoints, setWaypoints] = useState([]);
const [selectedWaypoint, setSelectedWaypoint] = useState(null); const [selectedWaypoint, setSelectedWaypoint] = useState(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@ -22,16 +24,20 @@ function WaypointEditor({ subscribe, publish, callService }) {
const [activeWaypoint, setActiveWaypoint] = useState(null); const [activeWaypoint, setActiveWaypoint] = useState(null);
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
// Map context
const [mapData, setMapData] = useState(null); const [mapData, setMapData] = useState(null);
const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 }); const [robotPose, setRobotPose] = useState({ x: 0, y: 0, theta: 0 });
// Canvas reference
const canvasRef = useRef(null);
const containerRef = useRef(null); const containerRef = useRef(null);
// Refs for ROS integration
const mapDataRef = useRef(null); const mapDataRef = useRef(null);
const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 }); const robotPoseRef = useRef({ x: 0, y: 0, theta: 0 });
const waypointsRef = useRef([]); const waypointsRef = useRef([]);
// Subscribe to map data // Subscribe to map data (for coordinate reference)
useEffect(() => { useEffect(() => {
const unsubMap = subscribe( const unsubMap = subscribe(
'/map', '/map',
@ -54,7 +60,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
return unsubMap; return unsubMap;
}, [subscribe]); }, [subscribe]);
// Subscribe to robot odometry // Subscribe to robot odometry (for current position reference)
useEffect(() => { useEffect(() => {
const unsubOdom = subscribe( const unsubOdom = subscribe(
'/odom', '/odom',
@ -79,22 +85,29 @@ function WaypointEditor({ subscribe, publish, callService }) {
return unsubOdom; return unsubOdom;
}, [subscribe]); }, [subscribe]);
// Canvas event handlers
const handleCanvasClick = (e) => { const handleCanvasClick = (e) => {
if (!mapDataRef.current || !containerRef.current) return; if (!mapDataRef.current || !canvasRef.current) return;
const rect = containerRef.current.getBoundingClientRect(); const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left; const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top; const clickY = e.clientY - rect.top;
// Convert canvas coordinates to world coordinates
// This assumes the map is centered on the robot
const map = mapDataRef.current;
const robot = robotPoseRef.current; const robot = robotPoseRef.current;
const zoom = 1; const zoom = 1; // Would need to track zoom if map has zoom controls
const centerX = containerRef.current.clientWidth / 2; // Inverse of map rendering calculation
const centerY = containerRef.current.clientHeight / 2; const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const worldX = robot.x + (clickX - centerX) / zoom; const worldX = robot.x + (clickX - centerX) / zoom;
const worldY = robot.y - (clickY - centerY) / zoom; const worldY = robot.y - (clickY - centerY) / zoom;
// Create new waypoint
const newWaypoint = { const newWaypoint = {
id: Date.now(), id: Date.now(),
x: parseFloat(worldX.toFixed(2)), x: parseFloat(worldX.toFixed(2)),
@ -106,6 +119,12 @@ function WaypointEditor({ subscribe, publish, callService }) {
waypointsRef.current = [...waypointsRef.current, newWaypoint]; waypointsRef.current = [...waypointsRef.current, newWaypoint];
}; };
const handleCanvasContextMenu = (e) => {
e.preventDefault();
// Right-click handled by waypoint list
};
// Waypoint list handlers
const handleDeleteWaypoint = (id) => { const handleDeleteWaypoint = (id) => {
setWaypoints((prev) => prev.filter((wp) => wp.id !== id)); setWaypoints((prev) => prev.filter((wp) => wp.id !== id));
waypointsRef.current = waypointsRef.current.filter((wp) => wp.id !== id); waypointsRef.current = waypointsRef.current.filter((wp) => wp.id !== id);
@ -139,12 +158,18 @@ function WaypointEditor({ subscribe, publish, callService }) {
setDragIndex(null); setDragIndex(null);
}; };
// Execute waypoints
const sendNavGoal = async (waypoint) => { const sendNavGoal = async (waypoint) => {
if (!callService) return; if (!callService) return;
try { try {
// Create quaternion from heading (default to 0 if no heading)
const heading = waypoint.theta || 0; const heading = waypoint.theta || 0;
const halfHeading = heading / 2; const halfHeading = heading / 2;
const qx = 0;
const qy = 0;
const qz = Math.sin(halfHeading);
const qw = Math.cos(halfHeading);
const goal = { const goal = {
pose: { pose: {
@ -154,14 +179,15 @@ function WaypointEditor({ subscribe, publish, callService }) {
z: 0, z: 0,
}, },
orientation: { orientation: {
x: 0, x: qx,
y: 0, y: qy,
z: Math.sin(halfHeading), z: qz,
w: Math.cos(halfHeading), w: qw,
}, },
}, },
}; };
// Send to Nav2 navigate_to_pose action
await callService( await callService(
'/navigate_to_pose', '/navigate_to_pose',
'nav2_msgs/NavigateToPose', 'nav2_msgs/NavigateToPose',
@ -182,7 +208,11 @@ function WaypointEditor({ subscribe, publish, callService }) {
setExecuting(true); setExecuting(true);
for (const waypoint of waypoints) { for (const waypoint of waypoints) {
const success = await sendNavGoal(waypoint); const success = await sendNavGoal(waypoint);
if (!success) break; if (!success) {
console.error('Failed to send goal for waypoint:', waypoint);
break;
}
// Wait a bit before sending next goal
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
} }
setExecuting(false); setExecuting(false);
@ -207,16 +237,21 @@ function WaypointEditor({ subscribe, publish, callService }) {
return ( return (
<div className="flex h-full gap-3"> <div className="flex h-full gap-3">
{/* Map area */} {/* Map area with click handlers */}
<div className="flex-1 flex flex-col space-y-3"> <div className="flex-1 flex flex-col space-y-3">
<div className="flex-1 bg-gray-900 rounded-lg border border-cyan-950 overflow-hidden relative cursor-crosshair"> <div className="flex-1 bg-gray-900 rounded-lg border border-cyan-950 overflow-hidden relative cursor-crosshair">
<div <div
ref={containerRef} ref={containerRef}
className="w-full h-full" className="w-full h-full"
onClick={handleCanvasClick} onClick={handleCanvasClick}
onContextMenu={(e) => e.preventDefault()} onContextMenu={handleCanvasContextMenu}
> >
<svg className="absolute inset-0 w-full h-full pointer-events-none" id="waypoint-overlay"> {/* Virtual map display - waypoints overlaid */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
id="waypoint-overlay"
>
{/* Waypoint markers */}
{waypoints.map((wp, idx) => { {waypoints.map((wp, idx) => {
if (!mapDataRef.current) return null; if (!mapDataRef.current) return null;
@ -233,6 +268,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
return ( return (
<g key={wp.id}> <g key={wp.id}>
{/* Waypoint circle */}
<circle <circle
cx={canvasX} cx={canvasX}
cy={canvasY} cy={canvasY}
@ -240,6 +276,7 @@ function WaypointEditor({ subscribe, publish, callService }) {
fill={isActive ? '#ef4444' : isSelected ? '#fbbf24' : '#06b6d4'} fill={isActive ? '#ef4444' : isSelected ? '#fbbf24' : '#06b6d4'}
opacity="0.8" opacity="0.8"
/> />
{/* Waypoint number */}
<text <text
x={canvasX} x={canvasX}
y={canvasY} y={canvasY}
@ -252,12 +289,19 @@ function WaypointEditor({ subscribe, publish, callService }) {
> >
{idx + 1} {idx + 1}
</text> </text>
{/* Line to next waypoint */}
{idx < waypoints.length - 1 && ( {idx < waypoints.length - 1 && (
<line <line
x1={canvasX} x1={canvasX}
y1={canvasY} y1={canvasY}
x2={centerX + (waypoints[idx + 1].x - robot.x) * zoom} x2={
y2={centerY - (waypoints[idx + 1].y - robot.y) * zoom} centerX +
(waypoints[idx + 1].x - robot.x) * zoom
}
y2={
centerY -
(waypoints[idx + 1].y - robot.y) * zoom
}
stroke="#10b981" stroke="#10b981"
strokeWidth="2" strokeWidth="2"
opacity="0.6" opacity="0.6"
@ -266,6 +310,8 @@ function WaypointEditor({ subscribe, publish, callService }) {
</g> </g>
); );
})} })}
{/* Robot position marker */}
<circle <circle
cx={containerRef.current?.clientWidth / 2 || 400} cx={containerRef.current?.clientWidth / 2 || 400}
cy={containerRef.current?.clientHeight / 2 || 300} cy={containerRef.current?.clientHeight / 2 || 300}
@ -311,7 +357,9 @@ function WaypointEditor({ subscribe, publish, callService }) {
{/* Waypoint list */} {/* Waypoint list */}
<div className="flex-1 overflow-y-auto space-y-1"> <div className="flex-1 overflow-y-auto space-y-1">
{waypoints.length === 0 ? ( {waypoints.length === 0 ? (
<div className="text-center text-gray-700 text-xs py-4">Click map to add waypoints</div> <div className="text-center text-gray-700 text-xs py-4">
Click map to add waypoints
</div>
) : ( ) : (
waypoints.map((wp, idx) => ( waypoints.map((wp, idx) => (
<div <div