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,
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';
}
@@ -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();