This commit is contained in:
legop3
2026-07-17 15:04:31 -04:00
parent 955f6f213d
commit b451849c02
12 changed files with 254 additions and 4380 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
-87
View File
@@ -1,87 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" />
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
<script>
/*
Build-time analytics adapter for the rover UI.
React only calls window.roverAnalytics.track/identify. Keeping the Umami
adapter here means analytics can still be removed, replaced, or configured
by changing this injected file instead of rebuilding app logic around a
specific analytics provider.
*/
(function () {
var pendingCalls = [];
var flushTimer = null;
function callUmami(method, args) {
if (!window.umami || typeof window.umami[method] !== 'function') return false;
window.umami[method].apply(window.umami, args);
return true;
}
function flushPendingCalls() {
if (!pendingCalls.length) return;
if (!window.umami) return;
pendingCalls = pendingCalls.filter(function (call) {
return !callUmami(call.method, call.args);
});
if (!pendingCalls.length && flushTimer) {
window.clearInterval(flushTimer);
flushTimer = null;
}
}
function enqueue(method, args) {
if (callUmami(method, args)) return;
pendingCalls.push({ method: method, args: args });
/*
The React app may fire route/session events before Umami's deferred
script has executed. Queueing preserves those early events while still
letting the whole adapter no-op harmlessly if the script is blocked.
*/
if (!flushTimer) {
flushTimer = window.setInterval(flushPendingCalls, 500);
}
}
window.roverAnalytics = {
track: function (name, data) {
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
},
identify: function (data) {
enqueue('identify', [data || {}]);
},
};
window.addEventListener('load', flushPendingCalls);
})();
</script>
<!-- otterlytics testing for blocking local -->
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-3ekGrBk9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DzqCFmyF.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -6,15 +6,14 @@ const logger = require('../../globals/logger').child('overcurrentProtectionServi
const DEFAULT_CONFIG = Object.freeze({
minimumUsefulWheelIntent: 75,
// A fully stalled wheel now accumulates 0.25 stress per second, producing a
// roughly four-second hard-stop window. The smaller base rate also prevents
// a wheel that is still making progress from being treated like a hard jam.
stressGrace: 0.25,
baseWheelOvercurrentRatePerSec: 0.08,
stalledWheelAdditionalRatePerSec: 0.17,
// Clear telemetry removes stress faster than even a complete stall adds it,
// so short threshold climbs and direction-change spikes do not linger.
wheelRecoveryRatePerSec: 0.5,
wheelProgressWindowSec: 0.4,
encoderNoiseFloorMmPerSec: 15,
fullStallProgressRatio: 0.1,
movingProgressRatio: 0.6,
movingOrUnknownRatePerSec: 0.25,
fullStallRatePerSec: 1,
wheelRecoveryRatePerSec: 0.75,
brushOvercurrentRatePerSec: 1,
brushRecoveryRatePerSec: 0.75,
clearBeforeUnlockSec: 0.75,
@@ -41,6 +40,10 @@ function createMotorState() {
measuredSpeed: null,
currentMa: null,
stallFactor: 0,
progressRatio: null,
classification: 'unknown',
progressSamples: [],
windowCommandSign: 0,
stress: 0,
cap: 1,
};
@@ -240,23 +243,117 @@ function createOvercurrentProtectionService(options = {}) {
return payload;
}
function updateWheelMotor(motor, { overcurrent, command, measured, currentMa, deltaSec }) {
const commandMagnitude = Math.abs(finiteNumber(command));
const measuredNumber = Number(measured);
const measuredMagnitude = Number.isFinite(measuredNumber) ? Math.abs(measuredNumber) : null;
const usefulIntent = commandMagnitude >= config.minimumUsefulWheelIntent;
const motionRatio = usefulIntent && measuredMagnitude != null
? clampUnit(measuredMagnitude / Math.max(commandMagnitude, config.minimumUsefulWheelIntent))
: 1;
const stallFactor = usefulIntent ? 1 - motionRatio : 0;
const riseRate = config.baseWheelOvercurrentRatePerSec
+ config.stalledWheelAdditionalRatePerSec * stallFactor;
function resetWheelProgressWindow(motor, commandSign = 0) {
motor.progressSamples = [];
motor.windowCommandSign = commandSign;
motor.progressRatio = null;
motor.classification = 'unknown';
motor.stallFactor = 0;
}
function appendWheelProgressSample(motor, sample) {
motor.progressSamples.push(sample);
let windowSec = motor.progressSamples.reduce((total, entry) => total + entry.deltaSec, 0);
/*
Keep an actual rolling time window rather than periodically clearing a
bucket. If the oldest sample crosses the boundary, retain only its
proportional tail so classification does not depend on sensor cadence.
*/
while (motor.progressSamples.length && windowSec > config.wheelProgressWindowSec) {
const excessSec = windowSec - config.wheelProgressWindowSec;
const oldest = motor.progressSamples[0];
if (oldest.deltaSec <= excessSec) {
motor.progressSamples.shift();
windowSec -= oldest.deltaSec;
continue;
}
const retainedFraction = (oldest.deltaSec - excessSec) / oldest.deltaSec;
oldest.deltaSec -= excessSec;
oldest.expectedDistance *= retainedFraction;
oldest.alignedDistance *= retainedFraction;
windowSec = config.wheelProgressWindowSec;
}
return windowSec;
}
function classifyWheelProgress(motor, windowSec) {
if (windowSec < config.wheelProgressWindowSec * 0.9) return;
const totals = motor.progressSamples.reduce(
(result, sample) => ({
expectedDistance: result.expectedDistance + sample.expectedDistance,
alignedDistance: result.alignedDistance + sample.alignedDistance,
}),
{ expectedDistance: 0, alignedDistance: 0 },
);
if (totals.expectedDistance <= 0) return;
/*
Signed, command-aligned distance lets forward/backward encoder wobble
cancel over the window. The fixed noise floor then removes small residual
bias from gearbox lash or a wheel module rocking without real travel.
*/
const averageExpectedSpeed = totals.expectedDistance / windowSec;
const averageAlignedSpeed = totals.alignedDistance / windowSec;
const usefulProgressSpeed = Math.max(0, averageAlignedSpeed - config.encoderNoiseFloorMmPerSec);
const progressRatio = clampUnit(usefulProgressSpeed / averageExpectedSpeed);
const ratioRange = Math.max(0.0001, config.movingProgressRatio - config.fullStallProgressRatio);
const stallFactor = clampUnit((config.movingProgressRatio - progressRatio) / ratioRange);
motor.progressRatio = progressRatio;
motor.stallFactor = stallFactor;
motor.classification = progressRatio <= config.fullStallProgressRatio
? 'stalled'
: progressRatio >= config.movingProgressRatio
? 'moving'
: 'partial';
}
function updateWheelMotor(motor, { overcurrent, command, intent, measured, currentMa, deltaSec }) {
const commandNumber = finiteNumber(command);
const commandMagnitude = Math.abs(commandNumber);
const commandSign = Math.sign(commandNumber);
const intentMagnitude = Math.abs(finiteNumber(intent));
// Null is intentionally checked before Number conversion because
// Number(null) is zero, which would falsely turn missing telemetry into a
// perfectly stalled wheel.
const measuredNumber = measured == null ? null : Number(measured);
const measuredValid = Number.isFinite(measuredNumber);
const usefulIntent = intentMagnitude >= config.minimumUsefulWheelIntent;
motor.overcurrent = Boolean(overcurrent);
motor.commandedSpeed = finiteNumber(command);
motor.measuredSpeed = measuredMagnitude;
motor.commandedSpeed = commandNumber;
motor.measuredSpeed = measuredValid ? measuredNumber : null;
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
motor.stallFactor = stallFactor;
if (!motor.overcurrent) {
resetWheelProgressWindow(motor, commandSign);
} else if (!usefulIntent || commandMagnitude <= 0 || !measuredValid) {
// Low commands and missing telemetry are real overcurrent events, but
// neither supplies enough evidence to label the wheel mechanically stalled.
// Raw operator intent decides whether the classifier remains meaningful;
// the progressively smaller applied output must not erase an already
// confirmed stall merely because protection itself reduced it below the
// normal command threshold.
resetWheelProgressWindow(motor, commandSign);
} else {
if (motor.windowCommandSign !== commandSign) {
// Samples from opposite requested directions cannot share a progress
// window because their aligned distances describe different motion.
resetWheelProgressWindow(motor, commandSign);
}
if (deltaSec > 0) {
const windowSec = appendWheelProgressSample(motor, {
deltaSec,
expectedDistance: commandMagnitude * deltaSec,
alignedDistance: measuredNumber * commandSign * deltaSec,
});
classifyWheelProgress(motor, windowSec);
}
}
const riseRate = config.movingOrUnknownRatePerSec
+ (config.fullStallRatePerSec - config.movingOrUnknownRatePerSec) * motor.stallFactor;
motor.stress = clampUnit(
motor.stress
+ (motor.overcurrent ? riseRate * deltaSec : -config.wheelRecoveryRatePerSec * deltaSec),
@@ -373,14 +470,16 @@ function createOvercurrentProtectionService(options = {}) {
updateWheelMotor(state.motors.leftWheel, {
overcurrent: flags.leftWheel,
command: state.driveIntent.left,
command: state.lastDriveOutput.left,
intent: state.driveIntent.left,
measured: speeds.left,
currentMa: sensors?.wheelLeftCurrentMa,
deltaSec,
});
updateWheelMotor(state.motors.rightWheel, {
overcurrent: flags.rightWheel,
command: state.driveIntent.right,
command: state.lastDriveOutput.right,
intent: state.driveIntent.right,
measured: speeds.right,
currentMa: sensors?.wheelRightCurrentMa,
deltaSec,
@@ -432,7 +531,11 @@ function createOvercurrentProtectionService(options = {}) {
function getPublicState(roverId) {
const state = getState(roverId);
const motors = MOTOR_KEYS.reduce((result, key) => {
result[key] = { ...state.motors[key] };
// Rolling samples are internal evidence, not UI state. Excluding them
// keeps every sensor frame compact while exposing the resulting ratio and
// classification needed to explain the service's decision.
const { progressSamples: _progressSamples, windowCommandSign: _windowCommandSign, ...motor } = state.motors[key];
result[key] = motor;
return result;
}, {});
return {
@@ -62,7 +62,7 @@ test('persistent stalled-wheel overcurrent scales both wheels and then stops dri
driveDirect: { left: 300, right: 200 },
});
for (let step = 0; step <= 39; step += 1) {
for (let step = 0; step <= 12; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0, right: 200 },
@@ -70,15 +70,15 @@ test('persistent stalled-wheel overcurrent scales both wheels and then stops dri
}
/*
Thirty-nine accumulated 100 ms intervals represent 3.9 seconds at the
maximum 0.25/s rate. The drive must still be available immediately before
the intended four-second hard-stop boundary.
The first 400 ms establish that the wheel is making no net progress. Once
classified, the faster stalled rate should approach—but not yet cross—the
hard-stop boundary at 1.2 seconds.
*/
assert.equal(service.getPublicState('rover').drive.blocked, false);
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0, right: 200 },
}), start + 4000);
}), start + 1300);
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.status, 'stopped');
@@ -133,20 +133,129 @@ test('administrator commands and telemetry bypass all enforcement', () => {
assert.equal(issued.length, 0);
});
test('encoder wobble around zero is classified as a full stall', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 13; step += 1) {
const wobbleSpeed = step % 2 === 0 ? 10 : -9;
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: wobbleSpeed },
}), start + step * 100);
}
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.motors.leftWheel.classification, 'stalled');
assert.equal(snapshot.motors.leftWheel.stallFactor, 1);
assert.equal(snapshot.drive.blocked, true);
});
test('overcurrent while a wheel keeps moving accumulates at the slower rate', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 20; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 240 },
}), start + step * 100);
}
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.motors.leftWheel.classification, 'moving');
assert.equal(snapshot.motors.leftWheel.stallFactor, 0);
assert.equal(snapshot.drive.blocked, false);
assert.ok(snapshot.motors.leftWheel.stress < 0.6);
});
test('partial wheel progress produces an intermediate stall factor', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 5; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 105 },
}), start + step * 100);
}
const motor = service.getPublicState('rover').motors.leftWheel;
assert.equal(motor.classification, 'partial');
assert.ok(motor.stallFactor > 0 && motor.stallFactor < 1);
});
test('missing wheel speed remains unknown instead of becoming a full stall', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 20; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: null },
}), start + step * 100);
}
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.motors.leftWheel.measuredSpeed, null);
assert.equal(snapshot.motors.leftWheel.classification, 'unknown');
assert.equal(snapshot.motors.leftWheel.stallFactor, 0);
assert.equal(snapshot.drive.blocked, false);
});
test('wheel comparison follows scaled output and resets after reversal', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 6; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start + step * 100);
}
const scaled = service.getPublicState('rover').motors.leftWheel.commandedSpeed;
assert.ok(scaled < 300);
service.protectCommand('rover', 'drive', {
driveDirect: { left: -300, right: -300 },
});
const reversed = service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: -250 },
}), start + 700);
assert.equal(reversed.motors.leftWheel.classification, 'unknown');
assert.equal(reversed.motors.leftWheel.progressRatio, null);
});
test('a stopped drive stays blocked until both clear time and neutral are observed', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 40; step += 1) {
for (let step = 0; step <= 13; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start + step * 100);
}
for (let step = 41; step <= 61; step += 1) {
for (let step = 14; step <= 28; step += 1) {
service.processTelemetry('rover', makeSensors(), start + step * 100);
}
assert.equal(service.getPublicState('rover').drive.blocked, true);
@@ -22,6 +22,13 @@ function formatSpeed(value) {
return `${Math.round(value)} mm/s`;
}
function formatClassification(value) {
if (value === 'stalled') return 'Stalled';
if (value === 'partial') return 'Partial';
if (value === 'moving') return 'Moving';
return 'Unknown';
}
function ProgressBar({ value, color = 'bg-emerald-500' }) {
const width = `${Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 100)}%`;
return (
@@ -75,10 +82,11 @@ export default function OvercurrentLimiterPanel() {
color={motor.overcurrent ? 'bg-red-500' : 'bg-amber-500'}
/>
{wheel ? (
<div className="grid grid-cols-3 gap-1 text-[0.65rem] text-slate-500">
<div className="grid grid-cols-2 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>
<span>{`Progress ${formatPct(motor.progressRatio)}`}</span>
<span>{`${formatClassification(motor.classification)} · stall ${formatPct(motor.stallFactor)}`}</span>
</div>
) : null}
</div>