Skip to Content
Indoor Survey — implementation plan (architecture)

Indoor Survey — implementation plan (architecture)

Companion to: indoor-survey-design-brief.md (the what and why). This is the how — the code architecture and a phased build plan to come back to. Status: Parked design reference. Not started.

Guiding principle: the architecture is the product here. The value (compare sources, swap algorithms, learn iteratively) only exists if the structure supports it from day one.


0. The one idea everything hangs on

Every source produces an Observation. The “brain” consumes a time-ordered stream of Observations and emits PoseEstimates. Live and replay are the same code path — only the source of the stream differs.

That single decision buys comparison and iterative learning for free: a recorded log of Observations can be re-run through any version of the brain, off-device.


1. Module / package structure

Split the engine out of the Android/UI code so the maths is testable and replayable on the JVM:

  • :positioning:corepure Kotlin, no Android deps. The Observation model, source interfaces, the estimator interface + implementations (fusion maths), the confidence/calibration model. JVM-testable, replayable in CI.
  • :positioning:sources-android — Android-coupled source implementations (IMU/PDR, ARCore, GPS, Wi-Fi RTT). Each adapts an existing app manager or OS API into Observations.
  • :positioning:replayJVM. The offline evaluation harness + metrics (calibration, sharpness, RMSE) + A/B comparison.
  • :app — survey UI (Compose) + ViewModel + wiring.

Keeping :positioning:core Android-free is the key enabler — it’s what makes the iterative-learning loop not need a phone.


2. Key interfaces (sketch — lives in :positioning:core)

enum class ObsKind { RELATIVE_DELTA, ABSOLUTE_FIX, CONSTRAINT } enum class Quality { STRONG, MODERATE, WEAK, UNVERIFIED } data class Observation( val tMonoNanos: Long, // monotonic (elapsed_realtime_nanos), NOT wall-clock val tickIndex: Long?, // join key to radio_sample val source: SourceId, // pdr | arcore | gps | rtt | manual_anchor | envelope ... val kind: ObsKind, val payload: ObsPayload, // delta+cov | fix(pos+cov) | constraint val quality: Quality, val qualityReason: String, val version: String ) interface LocationSource { val id: SourceId val isAvailable: Boolean fun observations(): Flow<Observation> } interface PositionEstimator { // THE swappable "brain" val version: String fun reset(initial: InitialState) fun update(obs: Observation): PoseEstimate // recursive (live) // replay = feed the recorded stream through the same update() } data class PoseEstimate( val tMonoNanos: Long, val x: Double, val y: Double, val floorIndex: Int, val covariance: Mat2, // uncertainty val confidence: Confidence, // plain-language state + radius + reason val sourceContributions: Map<SourceId, Double>, // agreement / who moved it val estimatorVersion: String, val confidenceModelVersion: String )

Three source families behind LocationSource: Trajectory (PDR/VIO/NN → relative deltas), Fix (manual anchor/GPS/RTT → absolute), Constraint (envelope/floor). Adding ARCore later = one new Trajectory source; nothing else changes.

The brain is conceptually a recursive Bayesian estimator: predict with trajectory deltas (grow covariance), update with fixes/constraints (shrink it). The innovation at each fix (predicted vs measured) is the source-agreement / trust signal. v1 = anchored dead-reckoning + rubber-sheet; v2 = filter or factor-graph backend (poses=nodes, PDR/VIO=relative edges, anchors/GPS/RTT=absolute edges, return-to-start=loop-closure edge; re-solving = the rubber-sheet).


3. Reuse map (be heavily considerate of the existing pipeline)

Do not duplicate data collection — adapt what already exists:

Existing pieceReused as
SensorDataManager (accel/gyro/rotation)feeds PdrTrajectorySource
LocationDataManager (GPS/route)feeds GpsFixSource
TelemetryProvider.tickFlow (1 Hz tick loop)drives per-tick pose; stamps tickIndex
UnifiedDataRegistry (RF + radio state)RF stays as-is; pose joins by tickIndex
DataLoggerManager (v3 JSONL writer)writes the new positioning records; radio_sample untouched
MetricRegistry / ValueKindunchanged
iPerf engineoptional spot test at a point
Google Maps / FullScreenMapScreensurvey map + breadcrumb
”Upload to Web” / shareexport unchanged

Positioning runs alongside the existing pipeline and joins to it by tickIndex / tMonoNanos.


4. Build phases

Phase 1 — Data collection & preparation (reuse-first)

  • Define :positioning:core value types + interfaces (Observation, sources, payloads, quality).
  • Build adapter sources over existing managers: PdrTrajectorySource (← SensorDataManager), GpsFixSource (← LocationDataManager), ManualAnchorSource (← UI taps).
  • Hook into tickFlow so Observations carry tickIndex + monotonic time, joinable to radio_sample.
  • No estimation yet — just emit and record a clean, replayable Observation stream beside the existing v3 log.
  • Exit: a recorded session has a faithful, replayable Observation stream joined to RF, with zero disruption to existing logging.

Phase 2 — UI (phased to the feature plan)

  • Survey scaffolding: setup sheet → live survey (reference layers + breadcrumb) → mark-my-location flow → review/verdict screen.
  • Wire to a stub estimator first so the UX can be built and tested before the brain exists.
  • v1 layers = satellite/grid; floor plan deferred.
  • Exit: full survey UX walkthrough runs on stub/replayed data.

Phase 3 — Initial end-to-end model (the brain, v1)

  • PositionEstimator v1 in :positioning:core: anchored dead-reckoning + rubber-sheet reconstruction + a first confidence model (distance/turns since last good anchor + anchor quality + innovation/agreement).
  • Live path: sources → estimator → PoseEstimate → UI breadcrumb + per-sample confidence. Deterministic + versioned.
  • Exit: walk a real route → live trace + honest confidence; the same log replays to an identical trace off-device (parity).

Phase 4 — Additional inputs & logging

  • New log records via DataLoggerManager: indoor_survey_start, pose, indoor_anchor, indoor_correction (audit), indoor_survey_end; bump schema_version to 4.
  • More sources behind the same interface: EnvelopeConstraintSource, RttFixSource (where FTM APs exist), ArcoreTrajectorySource (opt-in).
  • Audit + version fields; explicit raw-PDR / anchor-reconstructed / presentation-only distinction in the log.
  • Exit: multi-source fusion with full auditable logging; the platform can reconstruct the same trace.

Phase 5 — Test pipeline & process (iterative learning)

  • :positioning:replay harness: load logs → run any estimator/confidence version → score vs ground truth (calibration + sharpness + RMSE) → comparison report.
  • Ground-truth capture process (tape-measured walks); a growing corpus of recorded walks; CI integration.
  • A/B framework: compare estimator/confidence versions across the corpus, attributable by version.
  • Exit: a reproducible loop where a fusion change is evaluated across recorded walks without re-walking, and calibration is tracked across versions.

5. Cross-cutting requirements

  • Determinism: given the same Observation log + versions, the estimator output is identical (seed any randomness). This is what makes app-vs-platform parity and A/B comparison valid.
  • Versioning: every source, estimator and confidence model carries a version, logged on every record — so results are attributable and reproducible.
  • Calibration is the headline metric, not average error (a 90% region must contain truth ~90% of the time), traded against sharpness.
  • No Android types in :positioning:core — enforce via module boundaries so the brain stays JVM-testable.

6. Suggested first slice (when this un-parks)

Phase 0 spike → Phase 1 (Observation stream over existing managers) → Phase 3 v1 brain on replayed data in :positioning:replay, before much UI. That proves the accuracy floor and the architecture end-to-end on the JVM, cheaply, and de-risks everything downstream.

Last updated on