overcurrent limiter testing

This commit is contained in:
legop3
2026-01-21 02:12:54 -05:00
parent b9a007bd48
commit 59c19f1da9
10 changed files with 430 additions and 128 deletions
+45 -1
View File
@@ -6,6 +6,11 @@ import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.j
import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
import {
applyAuxOvercurrentScale,
applyDriveOvercurrentScale,
useOvercurrentLimiter,
} from './overcurrentLimiter.js';
const ControlSystemContext = createContext(null);
@@ -23,7 +28,6 @@ function clampServoAngle(config, value) {
}
export function ControlSystemProvider({ children }) {
const pipeline = useCommandPipeline();
const [state, dispatch] = useReducer(controlReducer, initialControlState);
const prevModeRef = useRef(null);
const pendingLightsRef = useRef(false);
@@ -33,6 +37,17 @@ export function ControlSystemProvider({ children }) {
save: saveControlSettings,
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
const { session, homeAssistantSetState } = useSession();
const roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback(
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
);
const auxTransform = useCallback(
(values) => applyAuxOvercurrentScale(values, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
);
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const turnOnAllLights = useCallback(() => {
const entities = session?.homeAssistant?.entities || [];
@@ -124,6 +139,33 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/record-intent' });
}, []);
const driveSpeedsRef = useRef(state.drive.speeds);
const auxValuesRef = useRef(state.aux);
const limiterScaleToken = useMemo(() => JSON.stringify(overcurrentLimiter.scales), [overcurrentLimiter.scales]);
useEffect(() => {
driveSpeedsRef.current = state.drive.speeds;
}, [state.drive.speeds]);
useEffect(() => {
auxValuesRef.current = state.aux;
}, [state.aux]);
useEffect(() => {
if (!pipeline.roverId || overcurrentLimiter.adminImmune) return;
const drive = driveSpeedsRef.current || { left: 0, right: 0 };
const aux = auxValuesRef.current || { main: 0, side: 0, vacuum: 0 };
const driveActive = Boolean(drive.left || drive.right);
const auxActive = Boolean(aux.main || aux.side || aux.vacuum);
if (!driveActive && !auxActive) return;
if (driveActive) {
pipeline.sendDriveDirect(drive);
}
if (auxActive) {
pipeline.sendAuxMotors(aux);
}
}, [limiterScaleToken, overcurrentLimiter.adminImmune, pipeline]);
const setDriveVector = useCallback(
(vector, meta = {}) => {
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
@@ -294,6 +336,7 @@ export function ControlSystemProvider({ children }) {
state,
dispatch,
pipeline,
overcurrentLimiter,
actions: {
setMode,
setDriveVector,
@@ -316,6 +359,7 @@ export function ControlSystemProvider({ children }) {
[
state,
pipeline,
overcurrentLimiter,
setMode,
setDriveVector,
setAuxMotors,
+17 -5
View File
@@ -11,7 +11,8 @@ import {
} from './constants.js';
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline() {
export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options;
const socket = useSocket();
const { session } = useSession();
const roverId = session?.assignment?.roverId;
@@ -50,34 +51,45 @@ export function useCommandPipeline() {
const sendDriveDirect = useCallback(
(speeds) => {
if (!roverId) return null;
const payload = {
const rawPayload = {
left: clampRange(speeds?.left ?? 0, [-500, 500]),
right: clampRange(speeds?.right ?? 0, [-500, 500]),
};
const transformed = driveTransform ? driveTransform(rawPayload) : rawPayload;
const payload = {
left: clampRange(transformed?.left ?? 0, [-500, 500]),
right: clampRange(transformed?.right ?? 0, [-500, 500]),
};
emitCommand({
type: 'drive',
data: { driveDirect: payload },
});
return payload;
},
[emitCommand, roverId],
[driveTransform, emitCommand, roverId],
);
const sendAuxMotors = useCallback(
({ main = 0, side = 0, vacuum = 0 } = {}) => {
if (!roverId) return null;
const payload = {
const rawPayload = {
main: clampRange(main, AUX_LIMITS.main),
side: clampRange(side, AUX_LIMITS.side),
vacuum: clampRange(vacuum, AUX_LIMITS.vacuum),
};
const transformed = auxTransform ? auxTransform(rawPayload) : rawPayload;
const payload = {
main: clampRange(transformed?.main ?? 0, AUX_LIMITS.main),
side: clampRange(transformed?.side ?? 0, AUX_LIMITS.side),
vacuum: clampRange(transformed?.vacuum ?? 0, AUX_LIMITS.vacuum),
};
emitCommand({
type: 'motors',
data: { motorPwm: payload },
});
return payload;
},
[emitCommand, roverId],
[auxTransform, emitCommand, roverId],
);
const sendServoAngle = useCallback(
+1
View File
@@ -1,3 +1,4 @@
export { ControlSystemProvider, useControlSystem } from './ControlContext.jsx';
export { default as KeyboardInputManager } from './inputs/KeyboardInputManager.jsx';
export { default as GamepadInputManager } from './inputs/GamepadInputManager.jsx';
export { useOvercurrentLimiter } from './overcurrentLimiter.js';
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
export const OVERCURRENT_MOTORS = ['leftWheel', 'rightWheel', 'mainBrush', 'sideBrush'];
export const DEFAULT_OVERCURRENT_LIMITS = {
meterAChargeSec: 9,
meterADecaySec: 9,
meterBChargeSec: 5,
meterBDecaySec: 5,
};
function createInitialMeters() {
return OVERCURRENT_MOTORS.reduce((acc, key) => {
acc[key] = { a: 0, b: 0 };
return acc;
}, {});
}
function clampUnit(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(1, value));
}
function stepValue(current, delta, seconds, direction) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return direction === 'up' ? 1 : 0;
}
const amount = delta / seconds;
return direction === 'up' ? current + amount : current - amount;
}
export function useOvercurrentLimiter(roverId, options = {}) {
const { session } = useSession();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const overcurrentFlags = sensors?.wheelOvercurrents || {};
const config = useMemo(
() => ({ ...DEFAULT_OVERCURRENT_LIMITS, ...(options.config || {}) }),
[options.config],
);
const [meters, setMeters] = useState(() => createInitialMeters());
const lastTickRef = useRef(typeof performance !== 'undefined' ? performance.now() : Date.now());
const flagsRef = useRef(overcurrentFlags);
useEffect(() => {
flagsRef.current = overcurrentFlags || {};
}, [overcurrentFlags]);
useEffect(() => {
setMeters(createInitialMeters());
}, [roverId]);
useEffect(() => {
const interval = setInterval(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const deltaMs = Math.max(0, now - lastTickRef.current);
lastTickRef.current = now;
const deltaSec = deltaMs / 1000;
if (deltaSec <= 0) return;
setMeters((prev) => {
let changed = false;
const next = {};
OVERCURRENT_MOTORS.forEach((key) => {
const prevEntry = prev[key] || { a: 0, b: 0 };
const over = Boolean(flagsRef.current?.[key]);
const nextA = clampUnit(
stepValue(
prevEntry.a,
deltaSec,
over ? config.meterAChargeSec : config.meterADecaySec,
over ? 'up' : 'down',
),
);
const shouldFillB = over && nextA >= 1;
const nextB = clampUnit(
stepValue(
prevEntry.b,
deltaSec,
shouldFillB ? config.meterBChargeSec : config.meterBDecaySec,
shouldFillB ? 'up' : 'down',
),
);
if (Math.abs(nextA - prevEntry.a) > 0.0001 || Math.abs(nextB - prevEntry.b) > 0.0001) {
changed = true;
}
next[key] = { a: nextA, b: nextB };
});
return changed ? next : prev;
});
}, 100);
return () => clearInterval(interval);
}, [config.meterAChargeSec, config.meterADecaySec, config.meterBChargeSec, config.meterBDecaySec]);
const scales = useMemo(() => {
const perMotor = OVERCURRENT_MOTORS.reduce((acc, key) => {
acc[key] = clampUnit(1 - (meters?.[key]?.b ?? 0));
return acc;
}, {});
return {
perMotor,
drive: {
left: perMotor.leftWheel ?? 1,
right: perMotor.rightWheel ?? 1,
},
aux: {
main: perMotor.mainBrush ?? 1,
side: perMotor.sideBrush ?? 1,
vacuum: 1,
},
};
}, [meters]);
const overcurrent = useMemo(
() =>
OVERCURRENT_MOTORS.reduce((acc, key) => {
acc[key] = Boolean(overcurrentFlags?.[key]);
return acc;
}, {}),
[overcurrentFlags],
);
const adminImmune =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
return useMemo(
() => ({
meters,
overcurrent,
scales,
config,
adminImmune,
}),
[meters, overcurrent, scales, config, adminImmune],
);
}
export function applyDriveOvercurrentScale(speeds = {}, scales, adminImmune = false) {
if (adminImmune || !scales?.drive) return speeds;
const leftScale = typeof scales.drive.left === 'number' ? scales.drive.left : 1;
const rightScale = typeof scales.drive.right === 'number' ? scales.drive.right : 1;
return {
left: Math.round((speeds.left ?? 0) * leftScale),
right: Math.round((speeds.right ?? 0) * rightScale),
};
}
export function applyAuxOvercurrentScale(values = {}, scales, adminImmune = false) {
if (adminImmune || !scales?.aux) return values;
const mainScale = typeof scales.aux.main === 'number' ? scales.aux.main : 1;
const sideScale = typeof scales.aux.side === 'number' ? scales.aux.side : 1;
const vacuumScale = typeof scales.aux.vacuum === 'number' ? scales.aux.vacuum : 1;
return {
main: Math.round((values.main ?? 0) * mainScale),
side: Math.round((values.side ?? 0) * sideScale),
vacuum: Math.round((values.vacuum ?? 0) * vacuumScale),
};
}