From 1adfa2783cc6de9fa8da5a711865702008305fe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:43:56 +0000 Subject: [PATCH 01/10] fix(engine): antenna-delay calibration refreshes instead of latching at the first solve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-diagnostic recovery (P2): maybeCalibrateAntennaDelays returned early forever once any delay was solved (`if (antennaDelays.isNotEmpty()) return`), so a per-device UWB range bias was frozen at its first estimate — a bias that later drifts (temperature, a re-mount) or a graph that later completes was never re-fitted, quietly skewing the constellation (an uncalibrated antenna pushes an anchor ~0.4 m out of place). Now it keeps the one-shot fast path but REFRESHES: re-solve on any inter-anchor edge-set change (a fuller/altered graph sharpens it; also covers an anchor added or reaped) and on a gentle periodic cadence (antennaDelayRecalibrateEveryNthMaintenance, default 60 ≈ 5 min; 0 disables). calibrateAntennaDelays() already solves from the raw ranges, so re-running only sharpens it. 2 jvmTests (tracks a drifting bias; honors the disable) with the real field survey as fixture; full :common:jvmTest green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../mofe/engine/MultiObserverFusionEngine.kt | 24 ++++- .../com/aether/mofe/model/ConfigTypes.kt | 6 ++ .../engine/AntennaDelayRecalibrationTest.kt | 97 +++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/AntennaDelayRecalibrationTest.kt diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt index f5026e9..5955917 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt @@ -271,6 +271,10 @@ class MultiObserverFusionEngine( * b_a + b_b un-distorts the frame. Empty (identity) until the surveyed layout is entered. */ private var antennaDelays: Map = emptyMap() private var _antennaDelayCalibration: AntennaDelayCalibrator.Result? = null + /** Edge count + maintenance tick at the last successful antenna-delay solve — drive the refresh + * triggers in [maybeCalibrateAntennaDelays] (re-solve on an edge-set change or periodic cadence). */ + private var antennaCalibrationEdgeCount = 0 + private var antennaCalibrationTick = 0 /** Single mesh-wide event detector — predicates apply to every target. */ private val eventDetector = EventDetector() @@ -1638,10 +1642,24 @@ class MultiObserverFusionEngine( /** Auto-calibrate once the surveyed layout is present and enough edges have accumulated; retries * each maintenance tick until it succeeds, then holds (re-run [calibrateAntennaDelays] to redo). */ private fun maybeCalibrateAntennaDelays() { - if (antennaDelays.isNotEmpty()) return if (MeshGroundTruth.isEmpty()) return - if (interAnchorDistances.size < config.anchorConstellationMinEdges) return - calibrateAntennaDelays() + val edges = interAnchorDistances.size + if (edges < config.anchorConstellationMinEdges) return + // One-shot fast path, then REFRESH (previously latched forever after the first solve): re-solve + // when the inter-anchor edge SET changes (a fuller/altered graph sharpens it — anchor added or + // reaped), or on a gentle periodic cadence so a slowly-drifting per-device bias is tracked + // rather than frozen at the first estimate. calibrateAntennaDelays() always solves from the RAW + // ranges, so re-running only sharpens it. + val firstTime = antennaDelays.isEmpty() + val edgeSetChanged = edges != antennaCalibrationEdgeCount + val cadence = config.antennaDelayRecalibrateEveryNthMaintenance + val cadenceDue = cadence > 0 && !firstTime && maintenanceTicks - antennaCalibrationTick >= cadence + if (!firstTime && !edgeSetChanged && !cadenceDue) return + val result = calibrateAntennaDelays() + if (result.ok) { + antennaCalibrationEdgeCount = edges + antennaCalibrationTick = maintenanceTicks + } } // ═════════════════════════════════════════════════════════════════════ diff --git a/common/src/commonMain/kotlin/com/aether/mofe/model/ConfigTypes.kt b/common/src/commonMain/kotlin/com/aether/mofe/model/ConfigTypes.kt index 0ba5a01..d6e3fba 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/model/ConfigTypes.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/model/ConfigTypes.kt @@ -398,6 +398,12 @@ data class MofeConfig( val anchorConstellationRefineEveryNthMaintenance: Int = 3, /** Minimum distinct inter-anchor edges required before attempting a refine. */ val anchorConstellationMinEdges: Int = 3, + /** After the first antenna-delay solve, re-solve at least every Nth performMaintenance() call so a + * slowly-changing per-device range bias (temperature, a re-mount) is tracked instead of frozen at + * the first estimate. The solve also re-runs immediately whenever the inter-anchor edge SET changes + * (a fuller/altered graph sharpens it). 0 disables the periodic refresh (keeps one-shot + edge + * change). ~60 ≈ 5 min at a 5 s maintenance cadence. */ + val antennaDelayRecalibrateEveryNthMaintenance: Int = 60, /** Only push a corrected anchor whose shift from its current position exceeds * this (metres) — avoids churning the frame version for sub-noise nudges. */ val anchorConstellationMinCorrectionMeters: Double = 0.005, diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/AntennaDelayRecalibrationTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/AntennaDelayRecalibrationTest.kt new file mode 100644 index 0000000..36336fd --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/AntennaDelayRecalibrationTest.kt @@ -0,0 +1,97 @@ +package com.aether.mofe.engine + +import com.aether.mofe.integration.MofeTestHarness +import com.aether.mofe.model.DeviceId +import com.aether.mofe.model.MofeConfig +import com.aether.mofe.model.Vector3D +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Antenna-delay calibration must REFRESH, not latch at the first solve. A per-device UWB range bias + * drifts (temperature, a re-mount); once solved, the old code returned early forever + * (`if (antennaDelays.isNotEmpty()) return`), so the frame stayed corrected against a stale estimate. + * The refresh re-solves on a periodic cadence (and on any inter-anchor edge-set change), tracking the + * drift. Fixture = the real field survey. + */ +class AntennaDelayRecalibrationTest { + + private val a1 = DeviceId("A1") + private val a2 = DeviceId("A2") + private val a3 = DeviceId("A3") + private val a4 = DeviceId("A4") + private val truth = mapOf( + a1 to Vector3D(0.0, 0.0, 1.2318), + a2 to Vector3D(2.286, 0.0, 1.3843), + a3 to Vector3D(0.0, 3.6957, 1.4478), + a4 to Vector3D(2.286, 2.921, 0.889), + ) + private val ids = listOf(a1, a2, a3, a4) + + private fun MofeTestHarness.feed(a4bias: Double, reps: Int) { + val bias = mapOf(a1 to 0.10, a2 to -0.02, a3 to 0.05, a4 to a4bias) + repeat(reps) { + for (i in ids.indices) for (j in i + 1 until ids.size) { + engine.processInterAnchorRanging( + ids[i], ids[j], + truth.getValue(ids[i]).distanceTo(truth.getValue(ids[j])) + + bias.getValue(ids[i]) + bias.getValue(ids[j]), + ) + } + } + } + + @Test + fun antenna_delay_recalibrates_instead_of_latching_at_the_first_solve() { + MeshGroundTruth.clear() + try { + for ((id, p) in truth) MeshGroundTruth.set(id.value, MeshGroundTruth.Pose(p)) + val h = MofeTestHarness(MofeConfig(antennaDelayRecalibrateEveryNthMaintenance = 1)).build() + h.engine.initializeAsRoot(a1) + + // First solve: A4's antenna bias is +0.39 m (the field-observed worst case). + h.feed(a4bias = 0.39, reps = 12) + h.engine.performMaintenance() + assertTrue( + abs(h.engine.antennaDelays().getValue(a4) - 0.39) < 0.05, + "first calibration recovers A4 = +0.39; got ${h.engine.antennaDelays()[a4]}", + ) + + // A4's antenna 'drifts' to +0.10. Before the fix the estimate was frozen; now the + // calibration re-runs on the cadence and tracks the new bias. + h.feed(a4bias = 0.10, reps = 40) + h.engine.performMaintenance() + assertTrue( + abs(h.engine.antennaDelays().getValue(a4) - 0.10) < 0.06, + "recalibration tracks A4's drift to +0.10 (would stay ~0.39 if latched); " + + "got ${h.engine.antennaDelays()[a4]}", + ) + } finally { + MeshGroundTruth.clear() + } + } + + @Test + fun recalibration_can_be_disabled_by_config() { + MeshGroundTruth.clear() + try { + for ((id, p) in truth) MeshGroundTruth.set(id.value, MeshGroundTruth.Pose(p)) + // 0 disables the periodic refresh; with a fixed edge set the first estimate is kept. + val h = MofeTestHarness(MofeConfig(antennaDelayRecalibrateEveryNthMaintenance = 0)).build() + h.engine.initializeAsRoot(a1) + h.feed(a4bias = 0.39, reps = 12) + h.engine.performMaintenance() + val first = h.engine.antennaDelays().getValue(a4) + + h.feed(a4bias = 0.10, reps = 40) + h.engine.performMaintenance() // no cadence, same 6 edges → no re-solve + assertTrue( + abs(h.engine.antennaDelays().getValue(a4) - first) < 1e-9, + "with cadence disabled and a fixed edge set the calibration is not refreshed", + ) + } finally { + MeshGroundTruth.clear() + } + } +} -- 2.43.0