This commit is contained in:
legop3
2026-07-17 15:13:25 -04:00
parent c6b2843150
commit b7d421c489
2 changed files with 58 additions and 16 deletions
@@ -11,9 +11,10 @@ const DEFAULT_CONFIG = Object.freeze({
encoderNoiseFloorMmPerSec: 15, encoderNoiseFloorMmPerSec: 15,
fullStallProgressRatio: 0.1, fullStallProgressRatio: 0.1,
movingProgressRatio: 0.6, movingProgressRatio: 0.6,
movingOrUnknownRatePerSec: 0.25, movingOrUnknownRatePerSec: 0.2,
fullStallRatePerSec: 1, fullStallRatePerSec: 0.6,
wheelRecoveryRatePerSec: 0.75, wheelRecoveryDelaySec: 0.75,
wheelRecoveryRatePerSec: 0.4,
brushOvercurrentRatePerSec: 1, brushOvercurrentRatePerSec: 1,
brushRecoveryRatePerSec: 0.75, brushRecoveryRatePerSec: 0.75,
clearBeforeUnlockSec: 0.75, clearBeforeUnlockSec: 0.75,
@@ -44,6 +45,7 @@ function createMotorState() {
classification: 'unknown', classification: 'unknown',
progressSamples: [], progressSamples: [],
windowCommandSign: 0, windowCommandSign: 0,
clearSec: 0,
stress: 0, stress: 0,
cap: 1, cap: 1,
}; };
@@ -325,6 +327,7 @@ function createOvercurrentProtectionService(options = {}) {
motor.commandedSpeed = commandNumber; motor.commandedSpeed = commandNumber;
motor.measuredSpeed = measuredValid ? measuredNumber : null; motor.measuredSpeed = measuredValid ? measuredNumber : null;
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null; motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
motor.clearSec = motor.overcurrent ? 0 : motor.clearSec + deltaSec;
if (!motor.overcurrent) { if (!motor.overcurrent) {
resetWheelProgressWindow(motor, commandSign); resetWheelProgressWindow(motor, commandSign);
@@ -354,9 +357,14 @@ function createOvercurrentProtectionService(options = {}) {
const riseRate = config.movingOrUnknownRatePerSec const riseRate = config.movingOrUnknownRatePerSec
+ (config.fullStallRatePerSec - config.movingOrUnknownRatePerSec) * motor.stallFactor; + (config.fullStallRatePerSec - config.movingOrUnknownRatePerSec) * motor.stallFactor;
const recoveryAllowed = !motor.overcurrent && motor.clearSec >= config.wheelRecoveryDelaySec;
motor.stress = clampUnit( motor.stress = clampUnit(
motor.stress motor.stress
+ (motor.overcurrent ? riseRate * deltaSec : -config.wheelRecoveryRatePerSec * deltaSec), + (motor.overcurrent
? riseRate * deltaSec
: recoveryAllowed
? -config.wheelRecoveryRatePerSec * deltaSec
: 0),
); );
motor.cap = calculateCap(motor.stress); motor.cap = calculateCap(motor.stress);
} }
@@ -524,7 +532,11 @@ function createOvercurrentProtectionService(options = {}) {
// still inside the transient grace region. Reporting it separately keeps // still inside the transient grace region. Reporting it separately keeps
// HUD visibility immediate without falsely claiming output is being scaled. // HUD visibility immediate without falsely claiming output is being scaled.
if (anyOvercurrent) return 'overcurrent'; 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'; return 'idle';
} }
@@ -48,7 +48,9 @@ test('a short stalled-wheel spike remains inside the grace region', () => {
wheelSpeedsMmPerSecond: { left: 0 }, wheelSpeedsMmPerSecond: { left: 0 },
}), start + 100); }), 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); assert.equal(snapshot.motors.leftWheel.cap, 1);
// The hardware event is visible immediately even though the grace region // The hardware event is visible immediately even though the grace region
// correctly leaves the command cap at full output. // 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 }, 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({ service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true }, wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0, right: 200 }, 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 The first 400 ms establish that the wheel is making no net progress. The
classified, the faster stalled rate should approach—but not yet cross—the tuned stalled rate should then approach—but not yet cross—the hard-stop
hard-stop boundary at 1.2 seconds. boundary at 1.8 seconds.
*/ */
assert.equal(service.getPublicState('rover').drive.blocked, false); assert.equal(service.getPublicState('rover').drive.blocked, false);
service.processTelemetry('rover', makeSensors({ service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true }, wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0, right: 200 }, wheelSpeedsMmPerSecond: { left: 0, right: 200 },
}), start + 1300); }), start + 1900);
const snapshot = service.getPublicState('rover'); const snapshot = service.getPublicState('rover');
assert.equal(snapshot.status, 'stopped'); 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 }, 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; const wobbleSpeed = step % 2 === 0 ? 10 : -9;
service.processTelemetry('rover', makeSensors({ service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true }, wheelOvercurrents: { leftWheel: true },
@@ -222,7 +224,7 @@ test('wheel comparison follows scaled output and resets after reversal', () => {
driveDirect: { left: 300, right: 300 }, 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({ service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true }, wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 }, wheelSpeedsMmPerSecond: { left: 0 },
@@ -237,7 +239,7 @@ test('wheel comparison follows scaled output and resets after reversal', () => {
const reversed = service.processTelemetry('rover', makeSensors({ const reversed = service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true }, wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: -250 }, wheelSpeedsMmPerSecond: { left: -250 },
}), start + 700); }), start + 900);
assert.equal(reversed.motors.leftWheel.classification, 'unknown'); assert.equal(reversed.motors.leftWheel.classification, 'unknown');
assert.equal(reversed.motors.leftWheel.progressRatio, null); 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', { service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 }, 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({ service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true }, wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 }, wheelSpeedsMmPerSecond: { left: 0 },
}), start + step * 100); }), 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); service.processTelemetry('rover', makeSensors(), start + step * 100);
} }
assert.equal(service.getPublicState('rover').drive.blocked, true); 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 }); 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', () => { test('brush stress limits only the brush that reports overcurrent', () => {
const { service } = createHarness(); const { service } = createHarness();
const start = Date.now(); const start = Date.now();