sl-controls 4ffa36370d feat: Add Issue #213 - PID auto-tuner (Astrom-Hagglund relay oscillation)
Implements PID auto-tuning ROS2 node using relay feedback (Astrom-Hagglund) method.
Service-triggered relay oscillation measures ultimate gain (Ku) and ultimate period
(Tu), then computes Ziegler-Nichols PD gains. Safety abort on >25deg tilt.

Features:
  - Service /saltybot/autotune_pid (std_srvs/Trigger) starts tuning
  - Relay oscillation method for accurate gain measurement
  - Measures Ku (ultimate gain) and Tu (ultimate period)
  - Computes Z-N PD gains: Kp=0.6*Ku, Kd=0.075*Ku*Tu
  - Real-time safety abort >25° tilt angle
  - JSON telemetry on /saltybot/autotune_info
  - Relay commands on /saltybot/autotune_cmd_vel

Tuning Process:
  1. Settle phase: zero command, allow oscillations to die
  2. Relay oscillation: apply +/-relay_magnitude commands
  3. Measure peaks: detect zero crossings, record extrema
  4. Analysis: calculate Ku from peak amplitude, Tu from period
  5. Gain computation: Ziegler-Nichols formulas
  6. Publish results: Ku, Tu, Kp, Kd

Safety Features:
  - IMU tilt monitoring (abort >25°)
  - Max tuning duration timeout
  - Configurable settle time and oscillation cycles

Published Topics:
  - /saltybot/autotune_info (std_msgs/String) - JSON with Ku, Tu, Kp, Kd
  - /saltybot/autotune_cmd_vel (geometry_msgs/Twist) - Relay control

Subscribed Topics:
  - /imu/data (sensor_msgs/Imu) - IMU tilt safety check
  - /saltybot/balance_feedback (std_msgs/Float32) - Balance feedback

Package: saltybot_pid_autotune
Entry point: pid_autotune_node
Service: /saltybot/autotune_pid

Tests: 20+ unit tests covering:
  - IMU tilt extraction
  - Relay oscillation analysis
  - Ku/Tu measurement
  - Ziegler-Nichols gain computation
  - Peak detection and averaging
  - Safety limits (tilt, timeout)
  - State machine transitions
  - JSON telemetry format

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 11:46:59 -05:00

340 lines
10 KiB
JavaScript

/**
* JoystickTeleop.jsx — Virtual joystick for robot teleop
*
* Features:
* - Virtual thumbstick UI with visual feedback
* - Touch & mouse support
* - Publishes geometry_msgs/Twist to /cmd_vel
* - 10% deadzone for both axes
* - Max linear velocity: 0.5 m/s
* - Max angular velocity: 1.0 rad/s
* - Real-time velocity display
*/
import { useEffect, useRef, useState } from 'react';
const DEADZONE = 0.1; // 10%
const MAX_LINEAR_VEL = 0.5; // m/s
const MAX_ANGULAR_VEL = 1.0; // rad/s
const RADIUS = 80; // pixels
const STICK_RADIUS = 25; // pixels
function JoystickTeleop({ publish }) {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const [joystickPos, setJoystickPos] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [linearVel, setLinearVel] = useState(0);
const [angularVel, setAngularVel] = useState(0);
const velocityTimerRef = useRef(null);
// Draw joystick on canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Clear canvas
ctx.fillStyle = '#1f2937';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw outer circle (background)
ctx.strokeStyle = '#374151';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(centerX, centerY, RADIUS, 0, Math.PI * 2);
ctx.stroke();
// Draw crosshair
ctx.strokeStyle = '#4b5563';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(centerX - 30, centerY);
ctx.lineTo(centerX + 30, centerY);
ctx.moveTo(centerX, centerY - 30);
ctx.lineTo(centerX, centerY + 30);
ctx.stroke();
// Draw deadzone indicator
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.arc(centerX, centerY, RADIUS * DEADZONE, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Draw stick
const stickX = centerX + joystickPos.x * RADIUS;
const stickY = centerY + joystickPos.y * RADIUS;
ctx.fillStyle = isDragging ? '#06b6d4' : '#0891b2';
ctx.beginPath();
ctx.arc(stickX, stickY, STICK_RADIUS, 0, Math.PI * 2);
ctx.fill();
// Draw direction indicator (arrow)
if (Math.abs(joystickPos.x) > 0.01 || Math.abs(joystickPos.y) > 0.01) {
const angle = Math.atan2(-joystickPos.y, joystickPos.x);
const arrowLen = 40;
const arrowX = centerX + Math.cos(angle) * arrowLen;
const arrowY = centerY - Math.sin(angle) * arrowLen;
ctx.strokeStyle = '#06b6d4';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.lineTo(arrowX, arrowY);
ctx.stroke();
}
}, [joystickPos, isDragging]);
// Handle joystick input
const handleInput = (clientX, clientY) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// Calculate relative position
let x = (clientX - rect.left - centerX) / RADIUS;
let y = (clientY - rect.top - centerY) / RADIUS;
// Normalize to unit circle
const distance = Math.sqrt(x * x + y * y);
if (distance > 1) {
x /= distance;
y /= distance;
}
// Apply deadzone
const deadzoneMagnitude = Math.sqrt(x * x + y * y);
let adjustedX = x;
let adjustedY = y;
if (deadzoneMagnitude < DEADZONE) {
adjustedX = 0;
adjustedY = 0;
} else {
// Scale values above deadzone
const scale = (deadzoneMagnitude - DEADZONE) / (1 - DEADZONE);
adjustedX = (x / deadzoneMagnitude) * scale;
adjustedY = (y / deadzoneMagnitude) * scale;
}
setJoystickPos({ x: adjustedX, y: adjustedY });
// Calculate velocities
// X-axis (left-right) = angular velocity
// Y-axis (up-down) = linear velocity (negative because canvas Y is inverted)
const linear = -adjustedY * MAX_LINEAR_VEL;
const angular = adjustedX * MAX_ANGULAR_VEL;
setLinearVel(linear);
setAngularVel(angular);
// Publish Twist message
if (publish) {
const twist = {
linear: { x: linear, y: 0, z: 0 },
angular: { x: 0, y: 0, z: angular }
};
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
}
};
const handleMouseDown = (e) => {
setIsDragging(true);
handleInput(e.clientX, e.clientY);
};
const handleMouseMove = (e) => {
if (isDragging) {
handleInput(e.clientX, e.clientY);
}
};
const handleMouseUp = () => {
setIsDragging(false);
setJoystickPos({ x: 0, y: 0 });
setLinearVel(0);
setAngularVel(0);
if (publish) {
const twist = {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 }
};
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
}
};
// Touch support
const handleTouchStart = (e) => {
setIsDragging(true);
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
};
const handleTouchMove = (e) => {
if (isDragging) {
e.preventDefault();
const touch = e.touches[0];
handleInput(touch.clientX, touch.clientY);
}
};
const handleTouchEnd = () => {
setIsDragging(false);
setJoystickPos({ x: 0, y: 0 });
setLinearVel(0);
setAngularVel(0);
if (publish) {
const twist = {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 }
};
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
}
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('touchstart', handleTouchStart);
canvas.addEventListener('touchmove', handleTouchMove);
canvas.addEventListener('touchend', handleTouchEnd);
return () => {
canvas.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('touchstart', handleTouchStart);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);
};
}, [isDragging]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (velocityTimerRef.current) {
clearTimeout(velocityTimerRef.current);
}
// Send stop command on unmount
if (publish) {
const twist = {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 }
};
publish('/cmd_vel', 'geometry_msgs/Twist', twist);
}
};
}, [publish]);
return (
<div className="flex flex-col h-full space-y-4">
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-3">
<div className="text-cyan-700 text-xs font-bold tracking-widest">
JOYSTICK TELEOP
</div>
<div className="flex gap-6">
{/* Joystick canvas */}
<div
ref={containerRef}
className="relative bg-gray-900 rounded border border-gray-800 p-2"
>
<canvas
ref={canvasRef}
width={320}
height={320}
className="rounded cursor-grab active:cursor-grabbing touch-none"
style={{ userSelect: 'none' }}
/>
</div>
{/* Controls and info */}
<div className="flex flex-col justify-center space-y-4 flex-1 min-w-64">
{/* Velocity displays */}
<div className="space-y-2">
<div className="text-sm">
<div className="text-gray-500 text-xs mb-1">LINEAR VELOCITY</div>
<div className="flex items-end gap-2">
<span className="text-xl font-mono text-cyan-400">
{linearVel.toFixed(3)}
</span>
<span className="text-xs text-gray-600 mb-0.5">m/s</span>
</div>
<div className="text-xs text-gray-700 mt-1">
Range: -0.500 to +0.500 m/s
</div>
</div>
</div>
<div className="w-full h-px bg-gray-800" />
<div className="space-y-2">
<div className="text-sm">
<div className="text-gray-500 text-xs mb-1">ANGULAR VELOCITY</div>
<div className="flex items-end gap-2">
<span className="text-xl font-mono text-yellow-400">
{angularVel.toFixed(3)}
</span>
<span className="text-xs text-gray-600 mb-0.5">rad/s</span>
</div>
<div className="text-xs text-gray-700 mt-1">
Range: -1.000 to +1.000 rad/s
</div>
</div>
</div>
<div className="w-full h-px bg-gray-800" />
{/* Instructions */}
<div className="space-y-1 text-xs text-gray-600">
<div className="font-bold text-gray-500">CONTROLS:</div>
<div> Forward / Backward</div>
<div> Rotate Left / Rotate Right</div>
<div className="text-gray-700 mt-2">Deadzone: 10%</div>
</div>
{/* Status indicators */}
<div className="flex gap-2">
<div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
isDragging
? 'bg-cyan-950 border border-cyan-700 text-cyan-400'
: 'bg-gray-900 border border-gray-800 text-gray-600'
}`}>
<div className={`w-2 h-2 rounded-full ${isDragging ? 'bg-cyan-400' : 'bg-gray-700'}`} />
{isDragging ? 'ACTIVE' : 'READY'}
</div>
</div>
</div>
</div>
</div>
{/* Topic info */}
<div className="bg-gray-950 rounded border border-gray-800 p-3 text-xs text-gray-600">
<div className="flex justify-between">
<span>Publishing to: <code className="text-gray-500">/cmd_vel</code></span>
<span className="text-gray-700">geometry_msgs/Twist</span>
</div>
</div>
</div>
);
}
export default JoystickTeleop;