From 5289ad90f57b392f41d682fb75522c4637ab881a Mon Sep 17 00:00:00 2001 From: Cam Mazzacane Date: Thu, 15 Oct 2026 18:28:52 -0400 Subject: [PATCH 1/2] Add a GNSS outage duration setting An outage started by faults.outageProbability lasted to the end of the run. That allowed no test of filter recovery after GNSS returns. faults.outageDurationSec ends each outage after that many seconds. After it ends, the outage probability applies again. A missing or zero value keeps the old behavior, so existing runs do not change. Co-Authored-By: Claude Opus 5.5 --- app/autonomy/LocalizationMeasurements.js | 17 ++++++++++---- app/simulation/sensors/SensorTypeRegistry.js | 1 + docs/run-manifests.md | 2 +- tests/localization-sensors.test.js | 24 ++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/autonomy/LocalizationMeasurements.js b/app/autonomy/LocalizationMeasurements.js index d5156419..d6c29fd2 100644 --- a/app/autonomy/LocalizationMeasurements.js +++ b/app/autonomy/LocalizationMeasurements.js @@ -296,8 +296,17 @@ export function buildGnssMeasurement(snapshot, sensorConfig, rng, state = {}) { } const outageProbability = Number(faults.outageProbability ?? 0); - const inOutage = state.inOutage || (outageProbability > 0 && rng.next() < outageProbability); - if (inOutage) { + // A missing or zero duration means the outage lasts to the end of the run. + const outageDurationNs = Math.max(0, Number(faults.outageDurationSec || 0)) * 1e9; + const outageEnded = state.inOutage + && state.outageEndNs != null + && snapshot.captureTimeNs >= state.outageEndNs; + const continuing = state.inOutage && !outageEnded; + const starting = !continuing && outageProbability > 0 && rng.next() < outageProbability; + if (continuing || starting) { + const outageEndNs = starting + ? (outageDurationNs > 0 ? snapshot.captureTimeNs + outageDurationNs : null) + : state.outageEndNs ?? null; return { measurement: { status: -1, @@ -309,7 +318,7 @@ export function buildGnssMeasurement(snapshot, sensorConfig, rng, state = {}) { positionCovarianceType: 0, noFix: true, }, - nextState: { ...state, multipath: nextMultipath, inOutage: true, lastCaptureTimeNs: snapshot.captureTimeNs }, + nextState: { ...state, multipath: nextMultipath, inOutage: true, outageEndNs, lastCaptureTimeNs: snapshot.captureTimeNs }, }; } @@ -338,7 +347,7 @@ export function buildGnssMeasurement(snapshot, sensorConfig, rng, state = {}) { positionCovarianceType: 2, noFix: false, }, - nextState: { ...state, multipath: nextMultipath, inOutage: false, lastCaptureTimeNs: snapshot.captureTimeNs, dropped: false }, + nextState: { ...state, multipath: nextMultipath, inOutage: false, outageEndNs: null, lastCaptureTimeNs: snapshot.captureTimeNs, dropped: false }, }; } diff --git a/app/simulation/sensors/SensorTypeRegistry.js b/app/simulation/sensors/SensorTypeRegistry.js index 9df88d2d..10162f06 100644 --- a/app/simulation/sensors/SensorTypeRegistry.js +++ b/app/simulation/sensors/SensorTypeRegistry.js @@ -174,6 +174,7 @@ const gnssRunFields = [ { label: "Position noise N (m)", path: ["calibration", "positionNoiseEnu", "y"], control: "number", min: 0, step: 0.001, advanced: true }, { label: "Dropout probability", path: ["calibration", "faults", "dropoutProbability"], control: "number", min: 0, max: 1, step: 0.001, advanced: true }, { label: "Outage probability", path: ["calibration", "faults", "outageProbability"], control: "number", min: 0, max: 1, step: 0.001, advanced: true }, + { label: "Outage duration (s, 0 = rest of run)", path: ["calibration", "faults", "outageDurationSec"], control: "number", min: 0, step: 0.1, advanced: true }, ]; const wheelOdometryRunFields = [ diff --git a/docs/run-manifests.md b/docs/run-manifests.md index d134a41c..664a97cb 100644 --- a/docs/run-manifests.md +++ b/docs/run-manifests.md @@ -342,7 +342,7 @@ appearance/analytic readback. | Sensor | Rate | Key calibration | Units | | --- | --- | --- | --- | | IMU | 100 Hz | `gravity`, per-axis `angularVelocityStdDev`, `linearAccelerationStdDev`, drift τ, saturation | rad/s, m/s² | -| GNSS | 10 Hz | `datum` (lat/lng/alt), `positionNoiseEnu`, `faults.dropoutProbability`, `faults.outageProbability`, multipath τ | deg, m | +| GNSS | 10 Hz | `datum` (lat/lng/alt), `positionNoiseEnu`, `faults.dropoutProbability`, `faults.outageProbability`, `faults.outageDurationSec` (0 or absent: outage lasts to the end of the run), multipath τ | deg, m | | Wheel odometry | 50 Hz | `wheelRadius`, `ticksPerRevolution`, `trackWidth`, `slipFactor`, pose/twist noise | m, ticks/rev | GNSS treats map/odom position as ENU offset from the manifest datum. Wheel odometry integrates an independent measured `odom → base_link` estimate; oracle truth on `/oracle/vehicle/odometry` remains isolated for scoring. diff --git a/tests/localization-sensors.test.js b/tests/localization-sensors.test.js index fe0a249b..5b916464 100644 --- a/tests/localization-sensors.test.js +++ b/tests/localization-sensors.test.js @@ -131,6 +131,30 @@ test("GNSS dropout returns no sample and outage emits no-fix status", () => { assert.equal(outage.measurement.noFix, true); }); +test("GNSS outage ends after outageDurationSec and is permanent without it", () => { + const run = (faults) => { + const start = normalizeRunSensor({ type: "gnss", calibration: { faults: { ...faults, outageProbability: 1 } } }); + const after = normalizeRunSensor({ type: "gnss", calibration: { faults: { ...faults, outageProbability: 0 } } }); + const rng = new SeededRNG("gnss-outage-duration"); + let state = createMeasurementSeedState(start, rng); + const noFix = []; + // 10 Hz fixes. The outage starts on the first fix only. + for (let index = 1; index <= 20; index += 1) { + const snapshot = captureVehicleSnapshot(mockVehicle(), index * 100_000_000); + const result = buildGnssMeasurement(snapshot, index === 1 ? start : after, rng, state); + state = { ...state, ...result.nextState }; + noFix.push(result.measurement.noFix); + } + return noFix; + }; + // Starts at 0.1 s and ends at 0.6 s: fixes at 0.1 through 0.5 s have no fix. + const timed = run({ outageDurationSec: 0.5 }); + assert.deepEqual(timed.slice(0, 5), [true, true, true, true, true]); + assert.ok(timed.slice(5).every((value) => value === false)); + assert.ok(run({}).every((value) => value === true)); + assert.ok(run({ outageDurationSec: 0 }).every((value) => value === true)); +}); + test("encoder quantization is deterministic and slip reduces travel", () => { const q1 = quantizeTravel(0.123, 0.15, 1024); const q2 = quantizeTravel(0.123, 0.15, 1024); From bc33105626d2ddb6a90b7ecb8f5cd99d9058ace8 Mon Sep 17 00:00:00 2001 From: Cam Mazzacane Date: Thu, 15 Oct 2026 18:40:23 -0400 Subject: [PATCH 2/2] Move the outage duration test away from other new GNSS tests PR #8 adds a test at the same place in the file. Moving this one up lets the two PRs merge in either order. Co-Authored-By: Claude Opus 5.5 --- tests/localization-sensors.test.js | 36 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/localization-sensors.test.js b/tests/localization-sensors.test.js index 5b916464..d444c41b 100644 --- a/tests/localization-sensors.test.js +++ b/tests/localization-sensors.test.js @@ -113,24 +113,6 @@ test("WGS84 conversion returns datum at zero ENU offset", () => { assert.ok(Math.abs(converted.lng - datum.lng) < 1e-4); }); -test("GNSS dropout returns no sample and outage emits no-fix status", () => { - const config = normalizeRunSensor({ - type: "gnss", - calibration: { faults: { dropoutProbability: 1, outageProbability: 0 } }, - }); - const rng = new SeededRNG("gnss-dropout"); - const snapshot = captureVehicleSnapshot(mockVehicle(), 16_666_667); - const dropped = buildGnssMeasurement(snapshot, config, rng, createMeasurementSeedState(config, rng)); - assert.equal(dropped.measurement, null); - - const outageConfig = normalizeRunSensor({ - type: "gnss", - calibration: { faults: { dropoutProbability: 0, outageProbability: 1 } }, - }); - const outage = buildGnssMeasurement(snapshot, outageConfig, new SeededRNG("gnss-outage"), createMeasurementSeedState(outageConfig, rng)); - assert.equal(outage.measurement.noFix, true); -}); - test("GNSS outage ends after outageDurationSec and is permanent without it", () => { const run = (faults) => { const start = normalizeRunSensor({ type: "gnss", calibration: { faults: { ...faults, outageProbability: 1 } } }); @@ -155,6 +137,24 @@ test("GNSS outage ends after outageDurationSec and is permanent without it", () assert.ok(run({ outageDurationSec: 0 }).every((value) => value === true)); }); +test("GNSS dropout returns no sample and outage emits no-fix status", () => { + const config = normalizeRunSensor({ + type: "gnss", + calibration: { faults: { dropoutProbability: 1, outageProbability: 0 } }, + }); + const rng = new SeededRNG("gnss-dropout"); + const snapshot = captureVehicleSnapshot(mockVehicle(), 16_666_667); + const dropped = buildGnssMeasurement(snapshot, config, rng, createMeasurementSeedState(config, rng)); + assert.equal(dropped.measurement, null); + + const outageConfig = normalizeRunSensor({ + type: "gnss", + calibration: { faults: { dropoutProbability: 0, outageProbability: 1 } }, + }); + const outage = buildGnssMeasurement(snapshot, outageConfig, new SeededRNG("gnss-outage"), createMeasurementSeedState(outageConfig, rng)); + assert.equal(outage.measurement.noFix, true); +}); + test("encoder quantization is deterministic and slip reduces travel", () => { const q1 = quantizeTravel(0.123, 0.15, 1024); const q2 = quantizeTravel(0.123, 0.15, 1024);