User Story #26 » 0001-fix-engine-antenna-delay-calibration-refreshes-inste.patch
| common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt | ||
|---|---|---|
|
* b_a + b_b un-distorts the frame. Empty (identity) until the surveyed layout is entered. */
|
||
|
private var antennaDelays: Map<DeviceId, Double> = 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()
|
||
| ... | ... | |
|
/** 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
|
||
|
}
|
||
|
}
|
||
|
// ═════════════════════════════════════════════════════════════════════
|
||
| common/src/commonMain/kotlin/com/aether/mofe/model/ConfigTypes.kt | ||
|---|---|---|
|
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,
|
||
| common/src/commonTest/kotlin/com/aether/mofe/engine/AntennaDelayRecalibrationTest.kt | ||
|---|---|---|
|
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()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
- « Previous
- 1
- 2
- Next »