From 4ee75de984a90792f0691583968b23b6c72a1ecd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:00:25 +0000 Subject: [PATCH 1/3] test(ekf): bracket the ZARU rest gate with an above-threshold boundary case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zaruStopsYawDriftWhenStationaryFlat covers a gyro bias BELOW zuptGyroThreshold (ZARU arrests the resting heading drift). This adds the complementary boundary: a standing gyro bias ABOVE the threshold makes the raw rate itself read as motion, so the device is classified MOVING, ZARU never engages, and yaw free-runs — the regime that only an absolute AoA reference can recover. Maps to the on-device self-diagnostic's "motionMode != STATIONARY" branch used to localize orientation drift. Verified green via :common:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../aether/mofe/engine/ImuFusionEKFTest.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/ImuFusionEKFTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/ImuFusionEKFTest.kt index 81f01b5..cfa8249 100644 --- a/common/src/commonTest/kotlin/com/aether/mofe/engine/ImuFusionEKFTest.kt +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/ImuFusionEKFTest.kt @@ -724,6 +724,39 @@ class ImuFusionEKFTest { "ZARU should drive the gyro-bias estimate to the resting reading (~0.05). Got: $bg") } + @Test + fun gyroBiasAboveRestThresholdReadsMovingSoZaruCannotAnchorYaw() { + // Rest-gate BOUNDARY — the on-device diagnostic tree's "motionMode ≠ STATIONARY" + // branch. ZARU only fires while the RAW-gyro rest gate reads the device as still. A + // standing gyro bias that EXCEEDS zuptGyroThreshold makes the raw rate itself look + // like motion, so the device reads MOVING, ZARU never engages, and the bias integrates + // into an ever-growing heading — the regime where only an absolute AoA reference can + // recover yaw. Complements zaruStopsYawDriftWhenStationaryFlat (a bias BELOW the gate, + // which ZARU arrests); together they bracket the gate. Bias is derived from the config + // so the test tracks the threshold rather than hard-coding it. + val cfg = EkfConfig() // zaruEnabled = true (default) + val biasZ = cfg.zuptGyroThreshold + 0.08 // rad/s, safely ABOVE the rest gate + val e = ImuFusionEKF(TARGET_1, cfg) + e.update(PositionSolution(TARGET_1, Timestamp(0), Vector3D(0.0, 0.0, 0.0), + Matrix.diagonal(0.001, 0.001, 0.001), 2.0, true)) + var t = 0L + var facingAt150: Vector3D? = null + var facingLast = Vector3D(0.0, 1.0, 0.0) + repeat(250) { i -> + t += 10_000 // 100 Hz + e.predict(ImuSample(Timestamp(t), Vector3D(0.0, 0.0, 9.81), Vector3D(0.0, 0.0, biasZ))) + if (i == 149) facingAt150 = facingOf(e) + if (i >= 149) facingLast = facingOf(e) + } + // Raw rate exceeds the gate → never classified at rest → ZARU disarmed. + assertEquals(MotionMode.MOVING, e.motionMode, + "a gyro bias above zuptGyroThreshold must read MOVING, holding ZARU off") + // With ZARU disarmed the standing bias integrates → the heading keeps walking. + val drift = angle(facingAt150!!, facingLast) + assertTrue(drift > 0.03, + "with the rest gate holding ZARU off, a standing gyro bias must walk the heading (got $drift rad)") + } + // ── Motion mode + trust-gated ZUPT (mesh gear-shift) ──────────────────────── @Test -- 2.43.0