Real-time motor current visualization with: - Subscribes to /saltybot/motor_currents for dual-motor current data - Rolling 60-second history window with automatic data culling - Dual-axis line chart for left (cyan) and right (amber) motor amps - Canvas-based rendering for performance - Thermal warning threshold line (25A, configurable) - Real-time statistics: * Current draw for left and right motors * Peak current tracking over 60-second window * Average current calculation * Thermal status indicator with warning badge - Color-coded thermal alerts: * Red background when threshold exceeded * Warning indicator and message - Grid overlay, axis labels, time labels, legend - Takes absolute value of currents (handles reverse direction) Integrated into TELEMETRY tab group as 'Motor Current' tab. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
399 lines
13 KiB
JavaScript
399 lines
13 KiB
JavaScript
/**
|
|
* MotorCurrentGraph.jsx — Live motor current visualization
|
|
*
|
|
* Features:
|
|
* - Subscribes to /saltybot/motor_currents for real-time motor current data
|
|
* - Maintains rolling 60-second history of readings
|
|
* - Dual-axis line chart: left motor (cyan) and right motor (amber)
|
|
* - Canvas-based rendering for performance
|
|
* - Real-time peak current tracking
|
|
* - Average current calculation
|
|
* - Thermal warning threshold line (configurable)
|
|
* - Current stats and alerts
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
const MAX_HISTORY_SECONDS = 60;
|
|
const SAMPLE_RATE = 10; // Hz
|
|
const MAX_DATA_POINTS = MAX_HISTORY_SECONDS * SAMPLE_RATE;
|
|
const THERMAL_WARNING_THRESHOLD = 25; // Amps
|
|
|
|
function calculateStats(data, field) {
|
|
if (data.length === 0) return { current: 0, peak: 0, average: 0 };
|
|
|
|
const values = data.map((d) => d[field]);
|
|
const current = values[values.length - 1];
|
|
const peak = Math.max(...values);
|
|
const average = values.reduce((a, b) => a + b, 0) / values.length;
|
|
|
|
return { current, peak, average };
|
|
}
|
|
|
|
export function MotorCurrentGraph({ subscribe }) {
|
|
const canvasRef = useRef(null);
|
|
const [data, setData] = useState([]);
|
|
const dataRef = useRef([]);
|
|
const [stats, setStats] = useState({
|
|
left: { current: 0, peak: 0, average: 0 },
|
|
right: { current: 0, peak: 0, average: 0 },
|
|
});
|
|
const [alerts, setAlerts] = useState({
|
|
leftThermal: false,
|
|
rightThermal: false,
|
|
});
|
|
|
|
// Subscribe to motor currents
|
|
useEffect(() => {
|
|
const unsubscribe = subscribe(
|
|
'/saltybot/motor_currents',
|
|
'std_msgs/Float32MultiArray',
|
|
(msg) => {
|
|
try {
|
|
let leftAmps = 0;
|
|
let rightAmps = 0;
|
|
|
|
if (msg.data && msg.data.length >= 2) {
|
|
leftAmps = Math.abs(msg.data[0]);
|
|
rightAmps = Math.abs(msg.data[1]);
|
|
}
|
|
|
|
const timestamp = Date.now();
|
|
const newPoint = { timestamp, leftAmps, rightAmps };
|
|
|
|
setData((prev) => {
|
|
const updated = [...prev, newPoint];
|
|
|
|
// Keep only last 60 seconds of data
|
|
const sixtySecondsAgo = timestamp - MAX_HISTORY_SECONDS * 1000;
|
|
const filtered = updated.filter((p) => p.timestamp >= sixtySecondsAgo);
|
|
|
|
dataRef.current = filtered;
|
|
|
|
// Calculate stats
|
|
if (filtered.length > 0) {
|
|
const leftStats = calculateStats(filtered, 'leftAmps');
|
|
const rightStats = calculateStats(filtered, 'rightAmps');
|
|
|
|
setStats({
|
|
left: leftStats,
|
|
right: rightStats,
|
|
});
|
|
|
|
// Check thermal warnings
|
|
setAlerts({
|
|
leftThermal: leftStats.current > THERMAL_WARNING_THRESHOLD,
|
|
rightThermal: rightStats.current > THERMAL_WARNING_THRESHOLD,
|
|
});
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
} catch (e) {
|
|
console.error('Error parsing motor currents:', e);
|
|
}
|
|
}
|
|
);
|
|
|
|
return unsubscribe;
|
|
}, [subscribe]);
|
|
|
|
// Canvas rendering
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || dataRef.current.length === 0) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = '#1f2937';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
const data = dataRef.current;
|
|
const padding = { top: 30, right: 60, bottom: 40, left: 60 };
|
|
const chartWidth = width - padding.left - padding.right;
|
|
const chartHeight = height - padding.top - padding.bottom;
|
|
|
|
// Find min/max values for scaling
|
|
let maxCurrent = 0;
|
|
data.forEach((point) => {
|
|
maxCurrent = Math.max(maxCurrent, point.leftAmps, point.rightAmps);
|
|
});
|
|
|
|
maxCurrent = maxCurrent * 1.1;
|
|
const minCurrent = 0;
|
|
|
|
const startTime = data[0].timestamp;
|
|
const endTime = data[data.length - 1].timestamp;
|
|
const timeRange = endTime - startTime || 1;
|
|
|
|
// Grid
|
|
ctx.strokeStyle = '#374151';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.globalAlpha = 0.3;
|
|
|
|
for (let i = 0; i <= 5; i++) {
|
|
const y = padding.top + (i * chartHeight) / 5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(padding.left, y);
|
|
ctx.lineTo(width - padding.right, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let i = 0; i <= 4; i++) {
|
|
const x = padding.left + (i * chartWidth) / 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, padding.top);
|
|
ctx.lineTo(x, height - padding.bottom);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
// Draw axes
|
|
ctx.strokeStyle = '#6b7280';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(padding.left, padding.top);
|
|
ctx.lineTo(padding.left, height - padding.bottom);
|
|
ctx.lineTo(width - padding.right, height - padding.bottom);
|
|
ctx.stroke();
|
|
|
|
// Draw thermal warning threshold line
|
|
const thresholdY =
|
|
padding.top +
|
|
chartHeight -
|
|
((THERMAL_WARNING_THRESHOLD - minCurrent) / (maxCurrent - minCurrent)) * chartHeight;
|
|
|
|
ctx.strokeStyle = '#ef4444';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.globalAlpha = 0.6;
|
|
ctx.beginPath();
|
|
ctx.moveTo(padding.left, thresholdY);
|
|
ctx.lineTo(width - padding.right, thresholdY);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
// Left motor line (cyan)
|
|
ctx.strokeStyle = '#06b6d4';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.globalAlpha = 0.85;
|
|
ctx.beginPath();
|
|
|
|
let firstPoint = true;
|
|
data.forEach((point) => {
|
|
const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth;
|
|
const y =
|
|
padding.top +
|
|
chartHeight -
|
|
((point.leftAmps - minCurrent) / (maxCurrent - minCurrent)) * chartHeight;
|
|
|
|
if (firstPoint) {
|
|
ctx.moveTo(x, y);
|
|
firstPoint = false;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
});
|
|
ctx.stroke();
|
|
|
|
// Right motor line (amber)
|
|
ctx.strokeStyle = '#f59e0b';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.globalAlpha = 0.85;
|
|
ctx.beginPath();
|
|
|
|
firstPoint = true;
|
|
data.forEach((point) => {
|
|
const x = padding.left + ((point.timestamp - startTime) / timeRange) * chartWidth;
|
|
const y =
|
|
padding.top +
|
|
chartHeight -
|
|
((point.rightAmps - minCurrent) / (maxCurrent - minCurrent)) * chartHeight;
|
|
|
|
if (firstPoint) {
|
|
ctx.moveTo(x, y);
|
|
firstPoint = false;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
});
|
|
ctx.stroke();
|
|
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
// Y-axis labels (current in amps)
|
|
ctx.fillStyle = '#9ca3af';
|
|
ctx.font = 'bold 11px monospace';
|
|
ctx.textAlign = 'right';
|
|
for (let i = 0; i <= 5; i++) {
|
|
const value = minCurrent + (i * (maxCurrent - minCurrent)) / 5;
|
|
const y = padding.top + (5 - i) * (chartHeight / 5);
|
|
ctx.fillText(value.toFixed(1), padding.left - 10, y + 4);
|
|
}
|
|
|
|
// X-axis time labels
|
|
ctx.fillStyle = '#9ca3af';
|
|
ctx.font = '10px monospace';
|
|
ctx.textAlign = 'center';
|
|
for (let i = 0; i <= 4; i++) {
|
|
const secondsAgo = MAX_HISTORY_SECONDS - (i * MAX_HISTORY_SECONDS) / 4;
|
|
const label = secondsAgo === 0 ? 'now' : `${Math.floor(secondsAgo)}s ago`;
|
|
const x = padding.left + (i * chartWidth) / 4;
|
|
ctx.fillText(label, x, height - padding.bottom + 20);
|
|
}
|
|
|
|
// Legend
|
|
const legendY = 10;
|
|
ctx.fillStyle = '#06b6d4';
|
|
ctx.fillRect(width - 200, legendY, 10, 10);
|
|
ctx.fillStyle = '#06b6d4';
|
|
ctx.font = '11px monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText('Left Motor', width - 185, legendY + 10);
|
|
|
|
ctx.fillStyle = '#f59e0b';
|
|
ctx.fillRect(width - 200, legendY + 15, 10, 10);
|
|
ctx.fillStyle = '#f59e0b';
|
|
ctx.fillText('Right Motor', width - 185, legendY + 25);
|
|
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.strokeStyle = '#ef4444';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(width - 200, legendY + 37);
|
|
ctx.lineTo(width - 190, legendY + 37);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.font = '11px monospace';
|
|
ctx.fillText('Thermal Limit', width - 185, legendY + 42);
|
|
}, []);
|
|
|
|
const leftThermalStatus = alerts.leftThermal ? 'THERMAL WARNING' : 'Normal';
|
|
const rightThermalStatus = alerts.rightThermal ? 'THERMAL WARNING' : 'Normal';
|
|
|
|
return (
|
|
<div className="flex flex-col h-full space-y-3">
|
|
{/* Controls */}
|
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-3">
|
|
<div className="flex justify-between items-center flex-wrap gap-2">
|
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
|
MOTOR CURRENT (60 SEC)
|
|
</div>
|
|
<div className="text-gray-600 text-xs">
|
|
{data.length} samples
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current stats */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Left motor */}
|
|
<div
|
|
className={`rounded border p-2 space-y-1 ${
|
|
alerts.leftThermal
|
|
? 'bg-red-950 border-red-800'
|
|
: 'bg-gray-900 border-gray-800'
|
|
}`}
|
|
>
|
|
<div className={`text-xs font-bold ${
|
|
alerts.leftThermal ? 'text-red-400' : 'text-gray-700'
|
|
}`}>
|
|
LEFT MOTOR
|
|
</div>
|
|
<div className="flex items-end gap-1">
|
|
<span className={`text-lg font-mono ${
|
|
alerts.leftThermal ? 'text-red-400' : 'text-cyan-400'
|
|
}`}>
|
|
{stats.left.current.toFixed(2)}
|
|
</span>
|
|
<span className="text-xs text-gray-600 mb-0.5">A</span>
|
|
</div>
|
|
<div className="text-xs space-y-0.5">
|
|
<div className="flex justify-between text-gray-500">
|
|
<span>Peak:</span>
|
|
<span className="text-cyan-300">{stats.left.peak.toFixed(2)}A</span>
|
|
</div>
|
|
<div className="flex justify-between text-gray-500">
|
|
<span>Avg:</span>
|
|
<span className="text-cyan-300">{stats.left.average.toFixed(2)}A</span>
|
|
</div>
|
|
</div>
|
|
{alerts.leftThermal && (
|
|
<div className="text-xs text-red-400 font-bold mt-1">⚠ {leftThermalStatus}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right motor */}
|
|
<div
|
|
className={`rounded border p-2 space-y-1 ${
|
|
alerts.rightThermal
|
|
? 'bg-red-950 border-red-800'
|
|
: 'bg-gray-900 border-gray-800'
|
|
}`}
|
|
>
|
|
<div className={`text-xs font-bold ${
|
|
alerts.rightThermal ? 'text-red-400' : 'text-gray-700'
|
|
}`}>
|
|
RIGHT MOTOR
|
|
</div>
|
|
<div className="flex items-end gap-1">
|
|
<span className={`text-lg font-mono ${
|
|
alerts.rightThermal ? 'text-red-400' : 'text-amber-400'
|
|
}`}>
|
|
{stats.right.current.toFixed(2)}
|
|
</span>
|
|
<span className="text-xs text-gray-600 mb-0.5">A</span>
|
|
</div>
|
|
<div className="text-xs space-y-0.5">
|
|
<div className="flex justify-between text-gray-500">
|
|
<span>Peak:</span>
|
|
<span className="text-amber-300">{stats.right.peak.toFixed(2)}A</span>
|
|
</div>
|
|
<div className="flex justify-between text-gray-500">
|
|
<span>Avg:</span>
|
|
<span className="text-amber-300">{stats.right.average.toFixed(2)}A</span>
|
|
</div>
|
|
</div>
|
|
{alerts.rightThermal && (
|
|
<div className="text-xs text-red-400 font-bold mt-1">⚠ {rightThermalStatus}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart canvas */}
|
|
<div className="flex-1 bg-gray-950 rounded-lg border border-cyan-950 overflow-hidden">
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={800}
|
|
height={400}
|
|
className="w-full h-full"
|
|
style={{ userSelect: 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info panel */}
|
|
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Topic:</span>
|
|
<span className="text-gray-500">/saltybot/motor_currents</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>History:</span>
|
|
<span className="text-gray-500">{MAX_HISTORY_SECONDS} seconds rolling window</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Thermal Threshold:</span>
|
|
<span className="text-gray-500">{THERMAL_WARNING_THRESHOLD}A (warning only)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|