Marauder

Live indoor positioning from noisy radio signals

Why I built it

I wanted to see how far indoor positioning from radio signal strength can go with a clean streaming pipeline: fixed receivers hear opt-in tags, a staff badge or an asset tag, and the backend turns that noisy stream into dots moving live on a floor plan.

Five receivers and six tags walking at 1.4 m/s. Every receiver hears every tag ten times a second with 3 dB of noise, and the scene runs a port of the API's estimator: RSSI smoothed per receiver, a log-distance fit, a light moving average, 150 ms batches. The ring under each dot is where the tag really is, so the gap is the error, about 1.5 m on average here.

Decisions

Measure before tuning

For a long time I judged the positions by watching the map. Now a seeded replay of the simulator compares every update with where the tag really was, and every variant sees the same signals. The first run was humbling: the linearised trilateration I started with lost to a plain weighted centroid from 3 dB of noise up and put a quarter of its answers outside the building at 6 dB. Each receiver's RSSI is now smoothed in dB and the position is fitted in log-distance space, where the noise actually is, clamped to the floor plan.

  • Median position error at 6 dB of noise went from 3.59 m to 2.04 m, measured against the simulator's truth.measured · README.md
  • At the simulator's default noise of 1.2 dB, the median error is 1.03 m and the 95th percentile 1.62 m, down from 1.95 m.measured · README.md
  • Each receiver's signal strength is smoothed in dB, the position is fitted in log-distance space when three or more receivers hear a tag, and a light moving average keeps markers from jumping.design · README.md

Fit the error nobody calibrates

Nobody knows their building's path-loss exponent exactly. With four or more receivers the fit also estimates one scale on the distances, which is what a wrong exponent does to them. Smoothing the readings without it hurt that case, 3.03 m to 5.20 m median, because the fit settled confidently on the biased answer. That scenario is the one the scale models, so the benchmark also has one it doesn't: a fixed offset per receiver, where the new estimator beats the old one but the scale term barely helps.

  • When the building's path-loss exponent is 3.0 and the solver assumes 2.5, the 95th percentile error is 2.4 m, where the old solver's was 20.1 m with a third of its answers outside the building.measured · README.md

Recognize a tag exactly once

Several receivers can hear a new tag in the same instant, so several messages race to create it. A message that finds no entity for the fingerprint tries to insert one, and the insert does nothing if another message got there first, so the unique constraint alone decides. Only the message whose insert went in announces the new tag, and one entity comes out no matter how many arrive together.

  • The first sighting of a tag creates its entity exactly once, because the insert skips a fingerprint that already exists, so the unique constraint alone decides.design · apps/api/src/entities/entity.store.ts

Keep a day, back up what matters

Position history is simulator traffic, so the nightly backup leaves it out and the table keeps one day in hourly chunks. I added the retention after finding a 3.65 GB table on the server and a history view that never finished loading. It now loads the last 10 minutes or hour.

  • Position history keeps one day in hourly chunks. Without retention it had grown to a 3.65 GB table in under four days and the history view never loaded.measured · README.md
  • Backups leave the position history out on purpose, because it is simulator traffic that refills in minutes and keeping it made a 3 GB volume out of a few kilobytes of real data.measured · README.md

How I know it works

A seeded benchmark replays the simulator's world on a virtual clock and compares every 150 ms update with the true position, across five noise and calibration scenarios, and tests hold the live estimator to it. Unit tests cover the fit and the race between two first sightings, and an end-to-end suite runs against a real TimescaleDB and Redis. The receivers are interchangeable: the simulator, an ESP32 sketch and a Python BLE scanner all publish the same MQTT message.

What I would do next

Per-receiver calibration, since a fixed offset per receiver still costs about 3 m over the clean case. A constant-velocity filter, since walking lag is now most of the error at low noise. And scaling out for real: tracks live in process memory, so two API instances would each compute everything.