From 5f5532d71f12ea071b5df4c99b7aceb30d292dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:41:25 +0000 Subject: [PATCH 2/6] feat(engine): client self-seed + render-readiness model (F4 client resilience) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seedSelfFromExternalPosition(position): feed the constellation's broadcast solve of THIS client into its own self-EKF — the client analogue of maintainSelfReferenceAnchor. A mobile observer's self-EKF otherwise never initializes (its own UWB only ranges peers; it starves on the >=4 concurrent peer-supplied self-observations the client solve needs), so getState(self) stays null and it renders raw/garbage. Guard: no-op if self is a reference point (an anchor self-seeds the other way) or the position is non-finite. 3 jvmTests: unseeded->null, seed->init+tracks, non-finite->ignored. MeshRenderReadiness.evaluate(engineStarted, meshSolved, referenceCount, selfLocalized): the pure gate deciding whether a client may draw the spatial scene (READY) or must HOLD with a status — the safeguard against drawing invalid visualizations before the engine + mesh + this device's localization are all ready. 6 jvmTests incl. the precedence + gate-only-on-READY. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../mofe/engine/MultiObserverFusionEngine.kt | 46 ++++++++++++ .../mofe/engine/render/MeshRenderReadiness.kt | 74 +++++++++++++++++++ .../aether/mofe/engine/ClientSelfSeedTest.kt | 57 ++++++++++++++ .../engine/render/MeshRenderReadinessTest.kt | 72 ++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshRenderReadiness.kt create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/ClientSelfSeedTest.kt create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshRenderReadinessTest.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 bfe827a..4fc922c 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/MultiObserverFusionEngine.kt @@ -232,6 +232,13 @@ class MultiObserverFusionEngine( * not zero (its placement carries the seed/calibration error). σ ≈ 5 cm. */ private val REFERENCE_ANCHOR_VARIANCE = 0.0025 + /** Measurement variance (m²) for a CLIENT self-seed from the constellation's broadcast solve + * ([seedSelfFromExternalPosition]). The position is band-relayed and a fresh multilateration + * result, so it is less certain than a surveyed anchor coordinate — trust it less. σ ≈ 20 cm; + * still tight enough to initialize and anchor the self-EKF, while the IMU predict smooths between + * the ~band-rate updates. */ + private val EXTERNAL_SELF_SEED_VARIANCE = 0.04 + /** Aggregates pre-filtered measurements per target across all anchors. * Honours the configured retention window — previously it silently used the * default (500 ms), so a wider real-device window (set to survive intermittent @@ -1344,6 +1351,45 @@ class MultiObserverFusionEngine( ) } + /** + * Seed the SELF target's EKF from an EXTERNAL position solution — the constellation's broadcast + * solve of THIS client — for a mobile OBSERVER that is NOT itself a reference point. This is the + * client analogue of [maintainSelfReferenceAnchor]: that path only initializes the self-EKF when + * self IS a reference point (an anchor), reading its known position from the frame. A client is + * never a reference point, and its own UWB only ranges peers (every local range is targetId=peer), + * so it depends entirely on the client-multilateration branch — which needs ≥ + * [MofeConfig.minAnchorsForFusion] concurrent peer-supplied `targetId=self` observations in one + * window. A late/observer client rarely has that many fresh at once, so [ImuFusionEKF] never + * initializes and [getState]/[selfAttitude] stay null: the device can neither place itself nor + * anything relative to it, and the view falls back to raw/garbage. + * + * Feeding the constellation's already-computed position for this client (the very value the anchors + * broadcast and the HUD already trusts) as a [PositionSolution] initializes the EKF on the first + * call and keeps it anchored thereafter; the IMU predict smooths between updates. Call it on a + * cadence (e.g. each maintenance tick) with the freshest broadcast position. + * + * No-op when there is no self target, when [position] is non-finite, or when self IS already a + * reference point (then [maintainSelfReferenceAnchor] owns the seed — don't double-feed). Uses + * [varianceMeters2] ≥ [REFERENCE_ANCHOR_VARIANCE] since a band-relayed solve is less certain than a + * surveyed anchor coordinate. + */ + fun seedSelfFromExternalPosition( + position: Vector3D, + varianceMeters2: Double = EXTERNAL_SELF_SEED_VARIANCE, + ) { + val self = selfTargetId ?: return + if (!position.x.isFinite() || !position.y.isFinite() || !position.z.isFinite()) return + if (frameManager.getAllReferencePoints().any { it.id == self }) return // an anchor self-seeds instead + val ctx = targets[self] ?: return + val v = varianceMeters2 + ctx.ekf.update( + PositionSolution( + targetId = self, timestamp = clock.now(), position = position, + covariance = Matrix.diagonal(v, v, v), gdop = 1.0, converged = true, + ), + ) + } + /** * Periodic maintenance — should be called on a timer (e.g. every 5s). * Detects anchors that have stopped reporting and removes them. diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshRenderReadiness.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshRenderReadiness.kt new file mode 100644 index 0000000..739d0ff --- /dev/null +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/render/MeshRenderReadiness.kt @@ -0,0 +1,74 @@ +package com.aether.mofe.engine.render + +/** + * Whether a CLIENT may render the spatial mesh scene (3-D / FPV nodes + camera), or must instead HOLD + * with a status message. This is the safeguard that keeps a device from drawing invalid or garbage + * visualizations before its MOFE engine and the mesh are actually ready — the failure the field showed + * (a mobile observer with no self pose drew raw, jittery, spatially-wrong nodes and an un-tracking + * camera, indistinguishable to the user from a working view). + * + * The rule: a client may place objects in space only when (1) its engine has started, (2) the mesh has + * a trustworthy solved frame (quorum + enough reference anchors), AND (3) THIS device has localized + * itself (a finite self pose / initialized self-EKF) — because every node is drawn RELATIVE to self, so + * a missing self pose makes the whole scene meaningless. Any earlier state renders a labelled holding + * screen instead, and reports the reason for the dev. + * + * Pure and platform-agnostic: the host supplies the four booleans/counts from its own StateFlows; this + * decides the state + user label + the single `canRenderSpatial` gate the UI checks. + */ +enum class RenderReadinessState { + /** The MOFE runtime hasn't started (or produced anything) yet. */ + INITIALIZING, + /** No trustworthy solved frame yet — the constellation hasn't reached solvable quorum. */ + ACQUIRING_MESH, + /** The mesh is solved, but THIS device has no self position yet (self-EKF not initialized). The + * scene is self-relative, so it cannot be placed until self localizes. */ + ACQUIRING_SELF, + /** Everything needed to place objects accurately is present — render the spatial scene. */ + READY, +} + +data class MeshRenderReadiness( + val state: RenderReadinessState, + /** Short, user-facing label for the holding overlay (empty when [canRenderSpatial]). */ + val label: String, + /** One-line reason for the dev diagnostic (empty when [READY]). */ + val detail: String, +) { + /** The single gate the scene checks: may it render node positions + drive the FPV camera? */ + val canRenderSpatial: Boolean get() = state == RenderReadinessState.READY + + companion object { + /** Reference anchors the frame needs before a client should trust placement (matches the + * engine's min-reference-points-for-solving; a client renders relative to the solved set). */ + const val DEFAULT_MIN_REFERENCES: Int = 4 + + /** + * @param engineStarted the MOFE runtime has started for this device + * @param meshSolved the constellation reached solvable quorum (QUORUM / canSolvePositions) + * @param referenceCount solved reference anchors currently backing the frame + * @param selfLocalized this device has a finite self pose (its self-EKF has initialized) + */ + fun evaluate( + engineStarted: Boolean, + meshSolved: Boolean, + referenceCount: Int, + selfLocalized: Boolean, + minReferences: Int = DEFAULT_MIN_REFERENCES, + ): MeshRenderReadiness = when { + !engineStarted -> MeshRenderReadiness( + RenderReadinessState.INITIALIZING, "Starting…", + "engine not started", + ) + !meshSolved || referenceCount < minReferences -> MeshRenderReadiness( + RenderReadinessState.ACQUIRING_MESH, "Acquiring mesh…", + "no solved quorum (references $referenceCount/$minReferences, solved=$meshSolved)", + ) + !selfLocalized -> MeshRenderReadiness( + RenderReadinessState.ACQUIRING_SELF, "Locating this device…", + "mesh solved but self not localized (no self pose)", + ) + else -> MeshRenderReadiness(RenderReadinessState.READY, "", "") + } + } +} diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/ClientSelfSeedTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/ClientSelfSeedTest.kt new file mode 100644 index 0000000..e62b6dd --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/ClientSelfSeedTest.kt @@ -0,0 +1,57 @@ +package com.aether.mofe.engine + +import com.aether.mofe.integration.MofeTestHarness +import com.aether.mofe.model.DeviceId +import com.aether.mofe.model.Vector3D +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * F4 · client self-seed. A mobile OBSERVER is never a reference point and its own UWB only ranges peers, + * so its self-EKF starves on the ≥4 concurrent peer-supplied self-observations the client-multilateration + * branch needs — it never initializes, `getState(self)` stays null, and the device renders raw/garbage + * (the field "no self pose"). [MultiObserverFusionEngine.seedSelfFromExternalPosition] feeds the + * constellation's broadcast solve of this client into the self-EKF so it initializes + tracks. + */ +class ClientSelfSeedTest { + + private val self = DeviceId("observer") + private lateinit var harness: MofeTestHarness + private lateinit var engine: MultiObserverFusionEngine + + @BeforeTest + fun setup() { + harness = MofeTestHarness().build() + harness.bootstrapMesh() + engine = harness.engine + engine.registerTarget(self) // a mobile observer: a tracked target… + engine.setSelfId(self) // …that is THIS device, but never a reference anchor + } + + @Test + fun unseeded_client_self_ekf_has_no_state() { + // No self ranging fed → the EKF never initialized → the field bug (null self pose). + assertNull(engine.getState(self), "an unseeded client self-EKF returns null") + } + + @Test + fun seed_initializes_and_tracks_the_broadcast_position() { + val broadcast = Vector3D(1.5, 2.0, 0.8) + repeat(5) { engine.seedSelfFromExternalPosition(broadcast) } + val state = engine.getState(self) + assertNotNull(state, "self-seed initializes the client self-EKF") + assertTrue( + state.position.distanceTo(broadcast) < 0.15, + "the self pose tracks the broadcast solve; got ${state.position}", + ) + } + + @Test + fun non_finite_broadcast_is_ignored() { + engine.seedSelfFromExternalPosition(Vector3D(Double.NaN, 0.0, 0.0)) + assertNull(engine.getState(self), "a NaN broadcast never initializes the EKF") + } +} diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshRenderReadinessTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshRenderReadinessTest.kt new file mode 100644 index 0000000..be88e3e --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/render/MeshRenderReadinessTest.kt @@ -0,0 +1,72 @@ +package com.aether.mofe.engine.render + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The client render-readiness safeguard. Proves the gate only opens when the engine, the mesh frame, + * AND this device's own localization are all present — so a client can never draw spatial nodes/camera + * from missing or garbage state (the field failure: a mobile observer with no self pose drew raw, + * spatially-wrong nodes that looked like a working view). + */ +class MeshRenderReadinessTest { + + private fun eval(started: Boolean, solved: Boolean, refs: Int, self: Boolean) = + MeshRenderReadiness.evaluate(started, solved, refs, self) + + @Test + fun ready_only_when_everything_present() { + val r = eval(started = true, solved = true, refs = 4, self = true) + assertEquals(RenderReadinessState.READY, r.state) + assertTrue(r.canRenderSpatial) + assertEquals("", r.label) + } + + @Test + fun engine_not_started_initializing() { + val r = eval(started = false, solved = true, refs = 4, self = true) + assertEquals(RenderReadinessState.INITIALIZING, r.state) + assertFalse(r.canRenderSpatial) + assertTrue(r.label.isNotEmpty() && r.detail.isNotEmpty()) + } + + @Test + fun no_quorum_acquires_mesh() { + assertEquals(RenderReadinessState.ACQUIRING_MESH, eval(true, false, 4, true).state) + // Solved flag true but too few reference anchors is still "acquiring mesh". + assertEquals(RenderReadinessState.ACQUIRING_MESH, eval(true, true, 3, true).state) + assertFalse(eval(true, false, 4, true).canRenderSpatial) + } + + @Test + fun mesh_solved_but_no_self_pose_acquires_self() { + // THE Pixel-8 field case: mesh is fine, this device just isn't localized → HOLD, don't render. + val r = eval(started = true, solved = true, refs = 4, self = false) + assertEquals(RenderReadinessState.ACQUIRING_SELF, r.state) + assertFalse(r.canRenderSpatial) + assertTrue(r.detail.contains("self"), "dev reason names the self-pose gap: ${r.detail}") + } + + @Test + fun precedence_engine_then_mesh_then_self() { + // Worst-present condition wins, so the label always names the FIRST thing to fix. + assertEquals(RenderReadinessState.INITIALIZING, eval(false, false, 0, false).state) + assertEquals(RenderReadinessState.ACQUIRING_MESH, eval(true, false, 0, false).state) + assertEquals(RenderReadinessState.ACQUIRING_SELF, eval(true, true, 4, false).state) + assertEquals(RenderReadinessState.READY, eval(true, true, 4, true).state) + } + + @Test + fun only_ready_state_opens_the_spatial_gate() { + for (started in listOf(false, true)) for (solved in listOf(false, true)) + for (refs in listOf(0, 4)) for (self in listOf(false, true)) { + val r = eval(started, solved, refs, self) + assertEquals( + r.state == RenderReadinessState.READY, r.canRenderSpatial, + "canRenderSpatial must equal (state==READY) for $started/$solved/$refs/$self", + ) + } + } +} -- 2.43.0