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 emitsPoseEstimates. 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:core— pure Kotlin, no Android deps. TheObservationmodel, 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 intoObservations.:positioning:replay— JVM. 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 piece | Reused 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 / ValueKind | unchanged |
| iPerf engine | optional spot test at a point |
Google Maps / FullScreenMapScreen | survey map + breadcrumb |
| ”Upload to Web” / share | export 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:corevalue types + interfaces (Observation, sources, payloads, quality). - Build adapter sources over existing managers:
PdrTrajectorySource(← SensorDataManager),GpsFixSource(← LocationDataManager),ManualAnchorSource(← UI taps). - Hook into
tickFlowso Observations carrytickIndex+ monotonic time, joinable toradio_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)
PositionEstimatorv1 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; bumpschema_versionto 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:replayharness: 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.