mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
slopcurrent
This commit is contained in:
@@ -1,63 +1,72 @@
|
||||
// Overcurrent Overlay
|
||||
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
// Purpose: Shows server-authoritative motor limiting, stop, recovery, and administrator-bypass status.
|
||||
// Scope: Renders protection state only; it never calculates stress or changes motor commands.
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../../../context/telemetryViews.js';
|
||||
import { useOvercurrentLimiter } from '../../../controls/index.js';
|
||||
import { OVERCURRENT_LABELS } from './constants.js';
|
||||
|
||||
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
|
||||
function OvercurrentOverlay({ roverId = null, overcurrentLimiter = null, compact = false }) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const selectedOvercurrents = useVisualTelemetrySelector(effectiveRoverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
||||
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
|
||||
const resolvedOvercurrents = sensors?.wheelOvercurrents ?? selectedOvercurrents;
|
||||
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
|
||||
const overcurrentMotors = useMemo(
|
||||
() =>
|
||||
resolvedOvercurrents == null
|
||||
? []
|
||||
: Object.entries(resolvedOvercurrents)
|
||||
.filter(([, active]) => Boolean(active))
|
||||
.map(([key]) => key),
|
||||
[resolvedOvercurrents],
|
||||
const protection = overcurrentLimiter ?? internalLimiter;
|
||||
const status = protection?.status || 'idle';
|
||||
const motors = protection?.motors || {};
|
||||
const activeMotors = useMemo(
|
||||
() => Object.entries(motors)
|
||||
.filter(([, motor]) => Boolean(motor?.overcurrent) || Number(motor?.stress) > 0)
|
||||
.map(([key]) => key),
|
||||
[motors],
|
||||
);
|
||||
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
|
||||
const limiterFill = useMemo(() => {
|
||||
if (!limiterCaps) return null;
|
||||
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
|
||||
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
|
||||
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
|
||||
}, [limiterCaps]);
|
||||
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
|
||||
const motors = useMemo(
|
||||
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
|
||||
[overcurrentMotors, limiterActive],
|
||||
);
|
||||
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
||||
|
||||
if (!motors?.length) return null;
|
||||
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
||||
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
||||
const textClass = compact ? 'text-lg' : 'text-4xl';
|
||||
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
||||
const safeFill = Math.max(0, Math.min(1, fill));
|
||||
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
||||
if (status === 'idle') return null;
|
||||
|
||||
const stopReason = protection?.drive?.stopReason;
|
||||
const displayMotors = stopReason ? [stopReason] : activeMotors;
|
||||
const labels = displayMotors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||
const highestStress = displayMotors.reduce(
|
||||
(highest, name) => Math.max(highest, Number(motors?.[name]?.stress) || 0),
|
||||
0,
|
||||
);
|
||||
const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
|
||||
const fillWidth = `${Math.round(Math.max(0, Math.min(1, highestStress)) * 100)}%`;
|
||||
const bypassed = status === 'bypassed';
|
||||
const stopped = status === 'stopped';
|
||||
const title = bypassed
|
||||
? 'Overcurrent detected'
|
||||
: stopped
|
||||
? 'Drive stopped'
|
||||
: status === 'recovering'
|
||||
? 'Protection recovering'
|
||||
: 'Overcurrent limiting';
|
||||
const detail = bypassed
|
||||
? 'Admin bypass'
|
||||
: stopped && protection?.drive?.requiresNeutral
|
||||
? `${labels.join(', ') || 'Wheel stall'} · release controls to resume`
|
||||
: status === 'limiting'
|
||||
? `${labels.join(', ')} · output ${Math.round(driveCap * 100)}%`
|
||||
: labels.join(', ');
|
||||
const containerClass = bypassed
|
||||
? 'h-[3.5rem] w-[14rem]'
|
||||
: compact
|
||||
? 'h-[3.5rem] w-[14rem]'
|
||||
: 'h-[7rem] w-[22rem]';
|
||||
const titleClass = compact || bypassed ? 'text-base' : 'text-3xl';
|
||||
const detailClass = compact || bypassed ? 'text-xs' : 'text-base';
|
||||
const backgroundClass = bypassed ? 'bg-amber-950/75' : 'bg-red-950/70';
|
||||
const fillClass = bypassed ? 'bg-amber-700/50' : 'bg-red-700/60';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
||||
className={`pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center ${backgroundClass} ${containerClass}`}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
||||
</div>
|
||||
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
||||
<div>OVERCURRENT</div>
|
||||
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<div className={`absolute inset-y-0 left-0 ${fillClass}`} style={{ width: fillWidth }} />
|
||||
<div className="relative z-10 flex h-full flex-col items-center justify-center px-2 text-center font-semibold text-white">
|
||||
<div className={titleClass}>{title}</div>
|
||||
<div className={`font-medium ${detailClass}`}>{detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Overcurrent Limiter Panel
|
||||
// Purpose: Defines the Overcurrent Limiter Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo } from 'react';
|
||||
// Overcurrent Protection Panel
|
||||
// Purpose: Presents detailed server-calculated motor stress and command-tracking diagnostics.
|
||||
// Scope: Read-only status surface for the assigned rover; protection and recovery remain server-owned.
|
||||
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { OVERCURRENT_GROUPS } from '../../controls/overcurrentLimiter.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
const GROUP_LABELS = {
|
||||
drive: 'Drive wheels',
|
||||
aux: 'Aux motors',
|
||||
const MOTOR_LABELS = {
|
||||
leftWheel: 'Left wheel',
|
||||
rightWheel: 'Right wheel',
|
||||
mainBrush: 'Main brush',
|
||||
sideBrush: 'Side brush',
|
||||
};
|
||||
|
||||
function formatPct(value) {
|
||||
@@ -16,8 +17,13 @@ function formatPct(value) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatSpeed(value) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
return `${Math.round(value)} mm/s`;
|
||||
}
|
||||
|
||||
function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
||||
const width = `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
||||
const width = `${Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 100)}%`;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
|
||||
<div className={`h-full ${color}`} style={{ width }} />
|
||||
@@ -25,51 +31,72 @@ function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(protection) {
|
||||
if (protection?.adminImmune) return 'Admin bypass';
|
||||
if (protection?.status === 'stopped') return 'Drive stopped';
|
||||
if (protection?.status === 'limiting') return 'Limiting';
|
||||
if (protection?.status === 'recovering') return 'Recovering';
|
||||
return 'Ready';
|
||||
}
|
||||
|
||||
export default function OvercurrentLimiterPanel() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const overcurrentLimiter = useControlSelector((control) => control.overcurrentLimiter);
|
||||
const groups = useMemo(() => OVERCURRENT_GROUPS.map((group) => group.key), []);
|
||||
const protection = useControlSelector((control) => control.overcurrentLimiter);
|
||||
const motors = protection?.motors || {};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Overcurrent limiter"
|
||||
meta={overcurrentLimiter?.adminImmune ? 'Admin immune' : 'Active'}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
title="Overcurrent protection"
|
||||
meta={statusLabel(protection)}
|
||||
bodyClassName="space-y-1 text-sm"
|
||||
>
|
||||
{!roverId ? (
|
||||
<p className="text-xs text-slate-500">Assign a rover to view limiter status.</p>
|
||||
<p className="text-xs text-slate-500">Assign a rover to view protection status.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{groups.map((key) => {
|
||||
const cap = overcurrentLimiter?.caps?.[key]?.cap ?? 0;
|
||||
const over = overcurrentLimiter?.overcurrent?.groups?.[key] ?? false;
|
||||
const scale = overcurrentLimiter?.scales?.perGroup?.[key] ?? 1;
|
||||
<div className="space-y-1">
|
||||
{Object.entries(MOTOR_LABELS).map(([key, label]) => {
|
||||
const motor = motors[key] || {};
|
||||
const wheel = key === 'leftWheel' || key === 'rightWheel';
|
||||
return (
|
||||
<div key={key} className="space-y-0.5">
|
||||
<div key={key} className="space-y-0.5 border-b border-slate-800 pb-1 last:border-0">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-200">{GROUP_LABELS[key] || key}</span>
|
||||
<span className={over ? 'text-red-300' : 'text-slate-400'}>
|
||||
{over ? 'overcurrent' : 'ok'}
|
||||
<span className="text-slate-200">{label}</span>
|
||||
<span className={motor.overcurrent ? 'text-red-300' : 'text-slate-400'}>
|
||||
{motor.overcurrent ? 'Overcurrent' : 'Clear'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Cap</span>
|
||||
<span>{formatPct(cap)}</span>
|
||||
</div>
|
||||
<ProgressBar value={cap} color="bg-amber-500" />
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Scale</span>
|
||||
<span>{formatPct(scale)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Stress {formatPct(motor.stress)}</span>
|
||||
<span>Output {formatPct(motor.cap)}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={motor.stress}
|
||||
color={motor.overcurrent ? 'bg-red-500' : 'bg-amber-500'}
|
||||
/>
|
||||
{wheel ? (
|
||||
<div className="grid grid-cols-3 gap-1 text-[0.65rem] text-slate-500">
|
||||
<span>{`Command ${formatSpeed(Math.abs(Number(motor.commandedSpeed)))}`}</span>
|
||||
<span>{`Measured ${formatSpeed(motor.measuredSpeed)}`}</span>
|
||||
<span>{`Stall ${formatPct(motor.stallFactor)}`}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{protection?.drive?.blocked ? (
|
||||
<p className="text-xs text-red-300">
|
||||
{protection.drive.requiresNeutral
|
||||
? 'Drive is stopped. Release controls to neutral before resuming.'
|
||||
: 'Drive is stopped while the wheel condition clears.'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="text-[0.7rem] text-slate-400">
|
||||
<div>{`Down rate ${overcurrentLimiter?.config?.downRatePerSec}/s · Up rate ${overcurrentLimiter?.config?.upRatePerSec}/s`}</div>
|
||||
<div>{`Release delay ${overcurrentLimiter?.config?.releaseDelaySec}s`}</div>
|
||||
<div>{`Output rate ${overcurrentLimiter?.config?.outputRateMs}ms`}</div>
|
||||
<div>{`Drive output ${formatPct(protection?.drive?.cap)}`}</div>
|
||||
<div>
|
||||
{protection?.adminImmune
|
||||
? 'This session bypasses all overcurrent enforcement.'
|
||||
: 'Status and output limits are calculated by the server.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user