From 7f0fc2d7fa04fdf233b117a496ad3babaca6e0d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 18:26:07 +0000 Subject: [PATCH] feat(engine): inter-anchor link health report (STRONG/WEAK/MISSING) + host flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks the UI/UX agent's inter-anchor health visualizer (request 1). The engine ingested raw inter-anchor ranges but produced no link-health output; this adds it. - model/InterAnchorLink.kt: InterAnchorLinkStatus {STRONG, WEAK, MISSING}, InterAnchorLink, and InterAnchorLinkReport with a PURE, unit-tested build() classifier + a hasMissingLink roll-up. A MISSING link (an expected anchor pair that never/long-stale ranges — the NLOS "dark link") is the classic reason the distance graph is not rigid and the constellation won't fully solve. - MultiObserverFusionEngine: a parallel per-link stats store kept ALONGSIDE interAnchorDistances (so the solve / antenna-cal consumers of that map are untouched), accumulated in processInterAnchorRanging using the already-computed expectedDistance as the |measured - solved| quality residual, and a public interAnchorLinkReport() that classifies every pair of the current constellation. - MofeEngineHost.anchorLinks: StateFlow, refreshed on the 2 s maintenance tick — the reactive observable the UI layer asked for. Classification: MISSING = never/stale; WEAK = ranging but few samples or large residual; STRONG = fresh + consistent. New InterAnchorLinkReportTest covers each. :common:jvmTest green (5/5 new, no regressions). MofeEngineHost is androidApp (not compilable in this env) — a plain StateFlow bridge for the UI agent to build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../aether/mofe/platform/MofeEngineHost.kt | 9 ++ .../mofe/engine/MultiObserverFusionEngine.kt | 49 +++++++ .../com/aether/mofe/model/InterAnchorLink.kt | 131 ++++++++++++++++++ .../mofe/model/InterAnchorLinkReportTest.kt | 86 ++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 common/src/commonMain/kotlin/com/aether/mofe/model/InterAnchorLink.kt create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/model/InterAnchorLinkReportTest.kt diff --git a/androidApp/src/main/java/com/aether/mofe/platform/MofeEngineHost.kt b/androidApp/src/main/java/com/aether/mofe/platform/MofeEngineHost.kt index 309a633..d7f15af 100644 --- a/androidApp/src/main/java/com/aether/mofe/platform/MofeEngineHost.kt +++ b/androidApp/src/main/java/com/aether/mofe/platform/MofeEngineHost.kt @@ -9,6 +9,7 @@ import com.aether.mofe.engine.MultilaterationSolver import com.aether.mofe.model.AnchorHealth import com.aether.mofe.model.DeviceId import com.aether.mofe.model.FusedState +import com.aether.mofe.model.InterAnchorLinkReport import com.aether.mofe.model.MeshOperatingMode import com.aether.mofe.model.MofeError import com.aether.mofe.model.QuorumStatus @@ -110,6 +111,11 @@ class MofeEngineHost( val anchorHealth: StateFlow> = _anchorHealth.asStateFlow() + private val _anchorLinks = MutableStateFlow(InterAnchorLinkReport.EMPTY) + /** Inter-anchor link health (STRONG/WEAK/MISSING per anchor pair) for the mesh-health UI, + * refreshed on the maintenance tick. A MISSING link is why the constellation won't fully solve. */ + val anchorLinks: StateFlow = _anchorLinks.asStateFlow() + /** Instrumentation + state capture — the engine's multi-observer fusion output, * surfaced to logcat AND to the flows above for the HUD/health pane to consume. */ private val logListener = object : MofeListener { @@ -213,6 +219,9 @@ class MofeEngineHost( while (true) { kotlinx.coroutines.delay(2_000.milliseconds) runCatching { eng.performMaintenance() } + // Refresh inter-anchor link health for the mesh-health UI (slow-changing). + _anchorLinks.value = runCatching { eng.interAnchorLinkReport() } + .getOrDefault(InterAnchorLinkReport.EMPTY) } } } 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 00c06be..fcb05ac 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt @@ -15,6 +15,9 @@ import com.aether.mofe.model.FusedState import com.aether.mofe.model.GdopQuality import com.aether.mofe.model.GeometricPredicate import com.aether.mofe.model.mesh.ImuSample +import com.aether.mofe.model.InterAnchorLinkReport +import com.aether.mofe.model.InterAnchorLinkSample +import com.aether.mofe.model.InterAnchorLinkThresholds import com.aether.mofe.model.InterAnchorRanging import com.aether.mofe.model.MeshEvent import com.aether.mofe.model.MeshOperatingMode @@ -248,6 +251,15 @@ class MultiObserverFusionEngine( * pair. Feeds the rigid-body [AnchorConstellationSolver] on the maintenance * cadence to correct the AoA-placed constellation (see refineAnchorConstellation). */ private val interAnchorDistances = mutableMapOf, Double>() + + /** Per-link health stats for [interAnchorLinkReport], kept ALONGSIDE [interAnchorDistances] + * so the solve / antenna-cal consumers of that map are untouched. */ + private class LinkStat( + var emaDistance: Double, var emaAbsResidual: Double, + var residualSamples: Int, var lastMicros: Long, var count: Int, + ) + private val interAnchorLinkStats = mutableMapOf, LinkStat>() + private val interAnchorLinkThresholds = InterAnchorLinkThresholds() /** Maintenance-tick counter that paces constellation refinement. */ private var maintenanceTicks = 0 @@ -1025,6 +1037,24 @@ class MultiObserverFusionEngine( interAnchorDistances[key] = if (prev == null) distance else prev + a * (distance - prev) } + // Parallel per-link health accounting for the inter-anchor link report (UI diagnostic). + // Ungated (independent of the refinement toggle) and cheap; the report reads only this. + if (anchor1 != anchor2 && distance > 0.0 && distance.isFinite()) { + val lkey = if (anchor1.value <= anchor2.value) anchor1 to anchor2 else anchor2 to anchor1 + val alpha = config.interAnchorDistanceEmaAlpha + val resid = if (ranging.expectedDistance > 0.0) kotlin.math.abs(distance - ranging.expectedDistance) else null + val st = interAnchorLinkStats[lkey] + if (st == null) { + interAnchorLinkStats[lkey] = + LinkStat(distance, resid ?: 0.0, if (resid != null) 1 else 0, ranging.timestamp.microseconds, 1) + } else { + st.emaDistance += alpha * (distance - st.emaDistance) + if (resid != null) { st.emaAbsResidual += alpha * (resid - st.emaAbsResidual); st.residualSamples++ } + st.lastMicros = ranging.timestamp.microseconds + st.count++ + } + } + val healthChange = anchorDriftDetector.processRanging(ranging) ?: return listener.onAnchorHealthChange(healthChange.anchorId, healthChange) if (healthChange.status == AnchorStatus.OFFLINE) { @@ -1339,6 +1369,25 @@ class MultiObserverFusionEngine( * per-device antenna bias does not distort the frame; drift detection still sees the raw ranges * (it tracks CHANGE, not absolute bias, so a constant delay is harmless there). */ + /** + * Inter-anchor link health for the calibration / mesh-health UI: classify every pair of the + * current anchor constellation STRONG / WEAK / MISSING from the accumulated ranging stats. A + * MISSING link (a pair that never ranges — an obstruction / NLOS "dark link") is the classic + * reason the distance graph is not rigid and the frame won't fully solve. Pure read. + */ + fun interAnchorLinkReport(nowMicros: Long = clock.now().microseconds): InterAnchorLinkReport { + val ids = frameManager.getAllReferencePoints().map { it.id } + val samples = interAnchorLinkStats.mapValues { (_, st) -> + InterAnchorLinkSample( + distanceMeters = st.emaDistance, + residualMeters = if (st.residualSamples > 0) st.emaAbsResidual else null, + lastMicros = st.lastMicros, + sampleCount = st.count, + ) + } + return InterAnchorLinkReport.build(ids, samples, nowMicros, interAnchorLinkThresholds) + } + private fun correctedInterAnchorDistances(): Map, Double> { if (antennaDelays.isEmpty()) return interAnchorDistances return interAnchorDistances.mapValues { (pair, d) -> diff --git a/common/src/commonMain/kotlin/com/aether/mofe/model/InterAnchorLink.kt b/common/src/commonMain/kotlin/com/aether/mofe/model/InterAnchorLink.kt new file mode 100644 index 0000000..8557d0e --- /dev/null +++ b/common/src/commonMain/kotlin/com/aether/mofe/model/InterAnchorLink.kt @@ -0,0 +1,131 @@ +package com.aether.mofe.model + +/** + * Health of one anchor↔anchor UWB link, for the calibration / mesh-health UI. + * + * The anchor constellation only solves into a rigid frame when its distance graph is rigid — + * i.e. enough anchor pairs actually range each other. A pair that never ranges (an obstruction / + * NLOS "dark link", the classic field failure) leaves the graph one edge short and the frame + * cannot fully solve. This surfaces each expected link so the UI can show *which* pair is the + * problem, rather than a bare "not solved". + */ +enum class InterAnchorLinkStatus { + /** Ranging regularly and close to the solved geometry — a trustworthy edge. */ + STRONG, + + /** Ranging, but marginal: too few samples yet, stale-ish, or a large distance↔solve residual. */ + WEAK, + + /** Expected pair with no (or long-stale) ranging — the graph is missing this edge. */ + MISSING, +} + +/** Accumulated ranging stats for one anchor pair — the raw input to link classification. */ +data class InterAnchorLinkSample( + /** EMA of the measured inter-anchor distance (m). */ + val distanceMeters: Double, + /** EMA of |measured − solved| (m) — the link-quality residual. null until a solve exists. */ + val residualMeters: Double?, + /** Timestamp (µs) of the most recent measurement. */ + val lastMicros: Long, + /** Number of measurements folded in. */ + val sampleCount: Int, +) + +/** Tunable thresholds for [InterAnchorLinkReport.build]. */ +data class InterAnchorLinkThresholds( + /** No measurement within this age (µs) ⇒ the link is treated as MISSING. */ + val staleMicros: Long = 5_000_000L, + /** A link needs at least this many samples to be eligible for STRONG. */ + val strongMinSamples: Int = 3, + /** |measured − solved| at/below this (m) is a STRONG link; above it is WEAK. */ + val strongResidualMeters: Double = 0.20, +) + +/** One classified anchor↔anchor link. [a] < [b] by device id, so a pair appears once. */ +data class InterAnchorLink( + val a: DeviceId, + val b: DeviceId, + val status: InterAnchorLinkStatus, + /** EMA measured distance (m); null when MISSING/never ranged. */ + val distanceMeters: Double?, + /** |measured − solved| EMA (m); the quality residual, null when unavailable. */ + val residualMeters: Double?, + /** Age of the most recent measurement (µs); null when never ranged. */ + val ageMicros: Long?, + /** Measurements folded into this link. */ + val sampleCount: Int, +) + +/** + * The full inter-anchor link picture: one [InterAnchorLink] per expected pair of the current + * anchor constellation, plus roll-ups. [hasMissingLink] is the "why won't it solve" flag. + */ +data class InterAnchorLinkReport( + val links: List, + val anchorCount: Int, + val expectedLinks: Int, + val strong: Int, + val weak: Int, + val missing: Int, +) { + /** A distance graph with a MISSING expected link is not rigid — the solve is blocked/degraded. */ + val hasMissingLink: Boolean get() = missing > 0 + + companion object { + val EMPTY = InterAnchorLinkReport(emptyList(), 0, 0, 0, 0, 0) + + /** + * Pure classifier: for every unordered pair of [anchorIds], look up its accumulated + * [samples] and classify STRONG / WEAK / MISSING at [nowMicros]. Order-independent + * (pair key is normalized by device id). No engine or IO — unit-tested directly. + */ + fun build( + anchorIds: List, + samples: Map, InterAnchorLinkSample>, + nowMicros: Long, + thresholds: InterAnchorLinkThresholds = InterAnchorLinkThresholds(), + ): InterAnchorLinkReport { + if (anchorIds.size < 2) return EMPTY + val ids = anchorIds.distinct() + val links = ArrayList(ids.size * (ids.size - 1) / 2) + var strong = 0 + var weak = 0 + var missing = 0 + for (i in ids.indices) { + for (j in i + 1 until ids.size) { + val a = ids[i] + val b = ids[j] + val key = if (a.value <= b.value) a to b else b to a + val s = samples[key] + val age = s?.let { nowMicros - it.lastMicros } + val status = when { + s == null -> InterAnchorLinkStatus.MISSING + age != null && age > thresholds.staleMicros -> InterAnchorLinkStatus.MISSING + s.sampleCount >= thresholds.strongMinSamples && + (s.residualMeters == null || s.residualMeters <= thresholds.strongResidualMeters) -> + InterAnchorLinkStatus.STRONG + else -> InterAnchorLinkStatus.WEAK + } + when (status) { + InterAnchorLinkStatus.STRONG -> strong++ + InterAnchorLinkStatus.WEAK -> weak++ + InterAnchorLinkStatus.MISSING -> missing++ + } + links.add( + InterAnchorLink( + a = key.first, + b = key.second, + status = status, + distanceMeters = s?.distanceMeters, + residualMeters = s?.residualMeters, + ageMicros = age, + sampleCount = s?.sampleCount ?: 0, + ), + ) + } + } + return InterAnchorLinkReport(links, ids.size, ids.size * (ids.size - 1) / 2, strong, weak, missing) + } + } +} diff --git a/common/src/commonTest/kotlin/com/aether/mofe/model/InterAnchorLinkReportTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/model/InterAnchorLinkReportTest.kt new file mode 100644 index 0000000..414799c --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/model/InterAnchorLinkReportTest.kt @@ -0,0 +1,86 @@ +package com.aether.mofe.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Unit tests for the pure link classifier [InterAnchorLinkReport.build] — the STRONG / WEAK / + * MISSING logic the mesh-health UI renders. Engine-free: the engine only accumulates the samples. + */ +class InterAnchorLinkReportTest { + + private val A = DeviceId("A") + private val B = DeviceId("B") + private val C = DeviceId("C") + private val D = DeviceId("D") + private val now = 10_000_000L + + private fun key(a: DeviceId, b: DeviceId) = if (a.value <= b.value) a to b else b to a + private fun sample(dist: Double, resid: Double?, ageMicros: Long, count: Int) = + InterAnchorLinkSample(dist, resid, now - ageMicros, count) + private fun statusOf(r: InterAnchorLinkReport, a: DeviceId, b: DeviceId) = + r.links.first { it.a == key(a, b).first && it.b == key(a, b).second }.status + + @Test + fun everyPairFreshLowResidualIsStrong() { + val ids = listOf(A, B, C, D) + val samples = buildMap { + for (i in ids.indices) for (j in i + 1 until ids.size) { + put(key(ids[i], ids[j]), sample(2.0, 0.05, ageMicros = 100_000, count = 10)) + } + } + val r = InterAnchorLinkReport.build(ids, samples, now) + assertEquals(6, r.expectedLinks) // 4 anchors → 6 pairs + assertEquals(6, r.strong) + assertEquals(0, r.weak) + assertEquals(0, r.missing) + assertFalse(r.hasMissingLink) + } + + @Test + fun anUnrangedPairIsMissingAndFlagsTheGraph() { + val ids = listOf(A, B, C, D) + val samples = buildMap { + for (i in ids.indices) for (j in i + 1 until ids.size) { + if (key(ids[i], ids[j]) == key(B, C)) continue // B↔C never ranges — the "dark link" + put(key(ids[i], ids[j]), sample(2.0, 0.05, 100_000, 10)) + } + } + val r = InterAnchorLinkReport.build(ids, samples, now) + assertEquals(1, r.missing) + assertEquals(5, r.strong) + assertTrue(r.hasMissingLink) + assertEquals(InterAnchorLinkStatus.MISSING, statusOf(r, B, C)) + } + + @Test + fun staleFewSampleAndHighResidualDegradeToMissingOrWeak() { + val ids = listOf(A, B, C) + val samples = mapOf( + key(A, B) to sample(2.0, 0.05, ageMicros = 9_000_000, count = 10), // stale (>5 s) → MISSING + key(A, C) to sample(2.0, 0.05, ageMicros = 100_000, count = 1), // too few samples → WEAK + key(B, C) to sample(2.0, 0.9, ageMicros = 100_000, count = 10), // large residual → WEAK + ) + val r = InterAnchorLinkReport.build(ids, samples, now) + assertEquals(InterAnchorLinkStatus.MISSING, statusOf(r, A, B)) + assertEquals(InterAnchorLinkStatus.WEAK, statusOf(r, A, C)) + assertEquals(InterAnchorLinkStatus.WEAK, statusOf(r, B, C)) + assertEquals(1, r.missing) + assertEquals(2, r.weak) + assertEquals(0, r.strong) + } + + @Test + fun aLinkWithNoSolveResidualYetCanStillBeStrongOnSamplesAlone() { + val ids = listOf(A, B) + val r = InterAnchorLinkReport.build(ids, mapOf(key(A, B) to sample(2.0, null, 100_000, 5)), now) + assertEquals(InterAnchorLinkStatus.STRONG, statusOf(r, A, B)) + } + + @Test + fun fewerThanTwoAnchorsIsEmpty() { + assertEquals(InterAnchorLinkReport.EMPTY, InterAnchorLinkReport.build(listOf(A), emptyMap(), now)) + } +} -- 2.43.0