User Story #24 » 0009-feat-engine-inter-anchor-link-health-report-STRONG-W.patch
| 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))
|
||
|
}
|
||
|
}
|
||