Project

General

Profile

User Story #24 » 0009-feat-engine-inter-anchor-link-health-report-STRONG-W.patch

knight8241, 08/07/2026 18:31

View differences:

androidApp/src/main/java/com/aether/mofe/platform/MofeEngineHost.kt
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
......
val anchorHealth: StateFlow<Map<String, AnchorHealth>> = _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<InterAnchorLinkReport> = _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 {
......
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)
}
}
}
common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt
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
......
* pair. Feeds the rigid-body [AnchorConstellationSolver] on the maintenance
* cadence to correct the AoA-placed constellation (see refineAnchorConstellation). */
private val interAnchorDistances = mutableMapOf<Pair<DeviceId, DeviceId>, 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<Pair<DeviceId, DeviceId>, LinkStat>()
private val interAnchorLinkThresholds = InterAnchorLinkThresholds()
/** Maintenance-tick counter that paces constellation refinement. */
private var maintenanceTicks = 0
......
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) {
......
* 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<Pair<DeviceId, DeviceId>, Double> {
if (antennaDelays.isEmpty()) return interAnchorDistances
return interAnchorDistances.mapValues { (pair, d) ->
common/src/commonMain/kotlin/com/aether/mofe/model/InterAnchorLink.kt
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<InterAnchorLink>,
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<DeviceId>,
samples: Map<Pair<DeviceId, DeviceId>, InterAnchorLinkSample>,
nowMicros: Long,
thresholds: InterAnchorLinkThresholds = InterAnchorLinkThresholds(),
): InterAnchorLinkReport {
if (anchorIds.size < 2) return EMPTY
val ids = anchorIds.distinct()
val links = ArrayList<InterAnchorLink>(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)
}
}
}
common/src/commonTest/kotlin/com/aether/mofe/model/InterAnchorLinkReportTest.kt
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))
}
}
    (1-1/1)