From b7d421c489635f3f38a7b5a308bfd25225e00d37 Mon Sep 17 00:00:00 2001 From: legop3 Date: Fri, 17 Jul 2026 15:13:25 -0400 Subject: [PATCH] slop --- .../overcurrentProtectionService/index.js | 22 ++++++-- .../index.test.js | 52 +++++++++++++++---- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/server/src/services/overcurrentProtectionService/index.js b/server/src/services/overcurrentProtectionService/index.js index 0823c111..2b30489a 100644 --- a/server/src/services/overcurrentProtectionService/index.js +++ b/server/src/services/overcurrentProtectionService/index.js @@ -11,9 +11,10 @@ const DEFAULT_CONFIG = Object.freeze({ encoderNoiseFloorMmPerSec: 15, fullStallProgressRatio: 0.1, movingProgressRatio: 0.6, - movingOrUnknownRatePerSec: 0.25, - fullStallRatePerSec: 1, - wheelRecoveryRatePerSec: 0.75, + movingOrUnknownRatePerSec: 0.2, + fullStallRatePerSec: 0.6, + wheelRecoveryDelaySec: 0.75, + wheelRecoveryRatePerSec: 0.4, brushOvercurrentRatePerSec: 1, brushRecoveryRatePerSec: 0.75, clearBeforeUnlockSec: 0.75, @@ -44,6 +45,7 @@ function createMotorState() { classification: 'unknown', progressSamples: [], windowCommandSign: 0, + clearSec: 0, stress: 0, cap: 1, }; @@ -325,6 +327,7 @@ function createOvercurrentProtectionService(options = {}) { motor.commandedSpeed = commandNumber; motor.measuredSpeed = measuredValid ? measuredNumber : null; motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null; + motor.clearSec = motor.overcurrent ? 0 : motor.clearSec + deltaSec; if (!motor.overcurrent) { resetWheelProgressWindow(motor, commandSign); @@ -354,9 +357,14 @@ function createOvercurrentProtectionService(options = {}) { const riseRate = config.movingOrUnknownRatePerSec + (config.fullStallRatePerSec - config.movingOrUnknownRatePerSec) * motor.stallFactor; + const recoveryAllowed = !motor.overcurrent && motor.clearSec >= config.wheelRecoveryDelaySec; motor.stress = clampUnit( motor.stress - + (motor.overcurrent ? riseRate * deltaSec : -config.wheelRecoveryRatePerSec * deltaSec), + + (motor.overcurrent + ? riseRate * deltaSec + : recoveryAllowed + ? -config.wheelRecoveryRatePerSec * deltaSec + : 0), ); motor.cap = calculateCap(motor.stress); } @@ -524,7 +532,11 @@ function createOvercurrentProtectionService(options = {}) { // still inside the transient grace region. Reporting it separately keeps // HUD visibility immediate without falsely claiming output is being scaled. if (anyOvercurrent) return 'overcurrent'; - if (anyLimited) return 'recovering'; + // Stress below the command-limiting grace threshold still represents a + // recent hardware event. Keeping recovery visible until it reaches zero + // prevents the HUD from vanishing the instant the raw flag clears. + const anyStress = MOTOR_KEYS.some((key) => state.motors[key].stress > 0); + if (anyStress) return 'recovering'; return 'idle'; } diff --git a/server/src/services/overcurrentProtectionService/index.test.js b/server/src/services/overcurrentProtectionService/index.test.js index f689b731..c203f55f 100644 --- a/server/src/services/overcurrentProtectionService/index.test.js +++ b/server/src/services/overcurrentProtectionService/index.test.js @@ -48,7 +48,9 @@ test('a short stalled-wheel spike remains inside the grace region', () => { wheelSpeedsMmPerSecond: { left: 0 }, }), start + 100); - assert.equal(snapshot.motors.leftWheel.stress, 0.025); + // Stress is accumulated with floating-point time arithmetic, so compare + // within a tiny tolerance instead of depending on an exact binary decimal. + assert.ok(Math.abs(snapshot.motors.leftWheel.stress - 0.02) < 1e-9); assert.equal(snapshot.motors.leftWheel.cap, 1); // The hardware event is visible immediately even though the grace region // correctly leaves the command cap at full output. @@ -62,7 +64,7 @@ test('persistent stalled-wheel overcurrent scales both wheels and then stops dri driveDirect: { left: 300, right: 200 }, }); - for (let step = 0; step <= 12; step += 1) { + for (let step = 0; step <= 18; step += 1) { service.processTelemetry('rover', makeSensors({ wheelOvercurrents: { leftWheel: true }, wheelSpeedsMmPerSecond: { left: 0, right: 200 }, @@ -70,15 +72,15 @@ test('persistent stalled-wheel overcurrent scales both wheels and then stops dri } /* - 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. + The first 400 ms establish that the wheel is making no net progress. The + tuned stalled rate should then approach—but not yet cross—the hard-stop + boundary at 1.8 seconds. */ assert.equal(service.getPublicState('rover').drive.blocked, false); service.processTelemetry('rover', makeSensors({ wheelOvercurrents: { leftWheel: true }, wheelSpeedsMmPerSecond: { left: 0, right: 200 }, - }), start + 1300); + }), start + 1900); const snapshot = service.getPublicState('rover'); assert.equal(snapshot.status, 'stopped'); @@ -140,7 +142,7 @@ test('encoder wobble around zero is classified as a full stall', () => { driveDirect: { left: 300, right: 300 }, }); - for (let step = 0; step <= 13; step += 1) { + for (let step = 0; step <= 19; step += 1) { const wobbleSpeed = step % 2 === 0 ? 10 : -9; service.processTelemetry('rover', makeSensors({ wheelOvercurrents: { leftWheel: true }, @@ -222,7 +224,7 @@ test('wheel comparison follows scaled output and resets after reversal', () => { driveDirect: { left: 300, right: 300 }, }); - for (let step = 0; step <= 6; step += 1) { + for (let step = 0; step <= 8; step += 1) { service.processTelemetry('rover', makeSensors({ wheelOvercurrents: { leftWheel: true }, wheelSpeedsMmPerSecond: { left: 0 }, @@ -237,7 +239,7 @@ test('wheel comparison follows scaled output and resets after reversal', () => { const reversed = service.processTelemetry('rover', makeSensors({ wheelOvercurrents: { leftWheel: true }, wheelSpeedsMmPerSecond: { left: -250 }, - }), start + 700); + }), start + 900); assert.equal(reversed.motors.leftWheel.classification, 'unknown'); assert.equal(reversed.motors.leftWheel.progressRatio, null); }); @@ -248,14 +250,14 @@ test('a stopped drive stays blocked until both clear time and neutral are observ service.protectCommand('rover', 'drive', { driveDirect: { left: 300, right: 300 }, }); - for (let step = 0; step <= 13; step += 1) { + for (let step = 0; step <= 19; step += 1) { service.processTelemetry('rover', makeSensors({ wheelOvercurrents: { leftWheel: true }, wheelSpeedsMmPerSecond: { left: 0 }, }), start + step * 100); } - for (let step = 14; step <= 28; step += 1) { + for (let step = 20; step <= 52; step += 1) { service.processTelemetry('rover', makeSensors(), start + step * 100); } assert.equal(service.getPublicState('rover').drive.blocked, true); @@ -276,6 +278,34 @@ test('a stopped drive stays blocked until both clear time and neutral are observ assert.deepEqual(resumed.driveDirect, { left: 300, right: 300 }); }); +test('cleared wheel stress remains visible through the hold and drains to idle', () => { + const { service } = createHarness(); + const start = Date.now(); + service.protectCommand('rover', 'drive', { + driveDirect: { left: 300, right: 300 }, + }); + service.processTelemetry('rover', makeSensors({ + wheelOvercurrents: { leftWheel: true }, + wheelSpeedsMmPerSecond: { left: 0 }, + }), start); + service.processTelemetry('rover', makeSensors({ + wheelOvercurrents: { leftWheel: true }, + wheelSpeedsMmPerSecond: { left: 0 }, + }), start + 100); + + const justCleared = service.processTelemetry('rover', makeSensors(), start + 200); + assert.equal(justCleared.status, 'recovering'); + assert.ok(justCleared.motors.leftWheel.stress > 0); + + const held = service.processTelemetry('rover', makeSensors(), start + 800); + assert.equal(held.status, 'recovering'); + assert.ok(held.motors.leftWheel.stress > 0); + + const recovered = service.processTelemetry('rover', makeSensors(), start + 1000); + assert.equal(recovered.status, 'idle'); + assert.equal(recovered.motors.leftWheel.stress, 0); +}); + test('brush stress limits only the brush that reports overcurrent', () => { const { service } = createHarness(); const start = Date.now();