WiFi RSSI Fingerprinting & Radio Maps
WiFi fingerprinting turns a building’s existing access-point noise into a positioning system without a single new beacon: you record the received signal strength (RSSI) of every audible access point at known points on a grid, store those vectors as a radio map, and later match a live scan against that map to estimate where a device is standing. This technique is the signal-space entry point of the Indoor Positioning & Fingerprinting reference — it produces the raw (x, y, level) estimate that everything downstream refines, snaps, and routes on. Done carelessly it produces a position that jitters across a corridor and confuses two similar rooms; done with discipline it is the cheapest metre-grade indoor fix available, because the infrastructure is already bolted to the ceiling.
The Problem: A Noisy, Non-Stationary Signal Space
RSSI is a hostile feature. The same phone, held still at the same desk, reports values that swing five to eight dBm second to second as people walk past, doors open, and the 2.4 GHz band contends with microwaves and Bluetooth. Fingerprinting has to extract a stable position from that churn, and three specific failures dominate.
First, RSSI noise and multipath. A single scan is a snapshot of a fluctuating channel. Match one raw scan directly against the map and the estimate hops between neighbouring reference points on every update, so the blue dot vibrates even when the user is motionless.
Second, missing and appearing access points. No two scans see the same set of BSSIDs. An access point that was strong during the survey may be absent in a live scan (asleep, roamed, or simply below the scan floor), and an AP installed after the survey appears in live scans but has no column in the radio map. A naive distance metric that assumes aligned feature vectors silently miscomputes the moment the BSSID sets diverge.
Third, device RSSI offset. Every radio front-end reports signal strength on its own scale. A survey taken with one phone can read six to ten dBm higher than a different model standing in the exact same spot, so a map surveyed on device A systematically mislocates device B. Fingerprinting that ignores this offset degrades the instant it meets a handset that was not used to build the map.
The job of a radio map is to make all three tractable: aggregate away per-scan noise into a stable per-point signature, align feature vectors across an ever-changing BSSID set, and match in a metric that tolerates offset and missing data.
Prerequisites & Dependencies
Before surveying a single reference point, the following must be in place:
- A floor plan in the building’s local frame. Every reference point is stamped with an
(x, y, level)coordinate, and those coordinates must live in the same bounded Cartesian frame as the routable map — the Indoor Coordinate Reference System whose origin sits on a survey control point and whose Z encodes the floor level. If the survey grid and the routing graph disagree on the origin, every position estimate is offset by that disagreement. - A survey grid. A regular lattice of reference points, typically spaced one to two metres apart in circulation space and denser at decision points (junctions, doorways, lift lobbies). The grid spacing sets the floor on achievable accuracy — you cannot resolve finer than roughly half the point spacing.
- Access points with stable BSSIDs. Fingerprinting keys on the BSSID (the radio MAC of each AP), so the infrastructure must not randomise or rotate them. Mesh nodes that share a BSSID across bands, or guest SSIDs on rotating MACs, have to be filtered out or pinned.
- A Python numerical stack.
numpyfor the radio-map matrix,scipy(specificallyscipy.spatial.cKDTree) for fast neighbour search in signal space,scikit-learnfor a drop-inKNeighborsRegressorbaseline, andpandasfor the survey table keyed by BSSID columns.
How It Works: An Offline Survey and an Online Match
Fingerprinting splits cleanly into two phases that never run at the same time. The offline survey phase is a one-off (or periodic) data-collection campaign: a surveyor stands at each grid point, dwells for a fixed window, and records many WiFi scans, which are aggregated into one mean RSSI vector per point and written to the radio-map database. The online phase runs on every position update at query time: the device captures one live scan, that scan is aligned to the map’s BSSID columns, and a k-nearest-neighbour search in signal space returns the reference points whose recorded signatures most resemble the live vector — their coordinates are combined into the final estimate.
The pivot between the two phases is the radio map itself: an N x M matrix of N reference points by M BSSID columns, filled with mean RSSI and a floor value (for example −100 dBm) wherever a point never heard an AP. Everything in the offline phase exists to build that matrix cleanly; everything in the online phase exists to query it fast and robustly.
Step-by-Step Implementation
The three steps below build and query a radio map end to end: model a fingerprint and assemble the matrix, align a live scan against an ever-changing BSSID set, then estimate a position by k-nearest-neighbour search. The offline steps are covered in depth in Building a WiFi RSSI Radio Map in Python, and the matching trade-offs in kNN vs. Weighted kNN for RSSI Positioning.
Step 1: Model a fingerprint record and build the radio-map matrix
A fingerprint is a labelled point: a coordinate plus the mean RSSI it observed for each known BSSID. The radio map is the stack of those fingerprints, sharing one ordered BSSID column index so every row is comparable. Fix that column order once, at build time, and treat missing observations as the floor value rather than zero — zero dBm is an impossibly strong signal and would pull the metric the wrong way.
import logging
from dataclasses import dataclass, field
import numpy as np
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
RSSI_FLOOR: float = -100.0 # dBm assigned to an access point a point never heard
@dataclass
class Fingerprint:
x: float
y: float
level: int
rssi_by_bssid: dict[str, float] = field(default_factory=dict)
def build_radio_map(
fingerprints: list[Fingerprint],
floor: float = RSSI_FLOOR,
) -> tuple[np.ndarray, np.ndarray, list[str]]:
"""Assemble fingerprints into an (N, M) RSSI matrix, an (N, 3) coordinate array,
and the ordered BSSID column index shared by every row."""
if not fingerprints:
raise ValueError("cannot build a radio map from zero fingerprints")
bssids = sorted({b for fp in fingerprints for b in fp.rssi_by_bssid})
col = {b: i for i, b in enumerate(bssids)}
radio = np.full((len(fingerprints), len(bssids)), floor, dtype=np.float32)
coords = np.empty((len(fingerprints), 3), dtype=np.float32)
for r, fp in enumerate(fingerprints):
coords[r] = (fp.x, fp.y, fp.level)
for bssid, rssi in fp.rssi_by_bssid.items():
radio[r, col[bssid]] = rssi
logger.info("radio map built: %d points x %d BSSIDs", radio.shape[0], radio.shape[1])
return radio, coords, bssids
Step 2: Align a live scan to the map’s BSSID columns
A live scan arrives as its own {bssid: rssi} dict, and its key set almost never matches the map’s. Project it into the map’s fixed column order: known BSSIDs land in their column, BSSIDs the map never surveyed are dropped (they carry no positioning information), and every column the scan did not see is filled with the same floor value used at build time. This alignment is the single most common source of silent error — mismatched vectors compare garbage to garbage.
import logging
import numpy as np
logger = logging.getLogger(__name__)
def align_scan(
scan: dict[str, float],
bssids: list[str],
floor: float = RSSI_FLOOR,
) -> np.ndarray:
"""Project a live scan onto the radio map's ordered BSSID columns.
Unknown BSSIDs are ignored; unseen known BSSIDs get the floor value."""
if not scan:
raise ValueError("empty scan: no audible access points to position with")
col = {b: i for i, b in enumerate(bssids)}
vector = np.full(len(bssids), floor, dtype=np.float32)
matched = 0
for bssid, rssi in scan.items():
idx = col.get(bssid)
if idx is None:
continue # AP installed after the survey — no column to compare against
vector[idx] = rssi
matched += 1
if matched == 0:
raise ValueError("no scan BSSID overlaps the radio map — wrong floor or stale map?")
logger.info("scan aligned: %d/%d BSSIDs matched the map", matched, len(scan))
return vector
Step 3: Estimate a position by k-nearest-neighbour search
With the map as an (N, M) matrix and the live scan as a length-M vector, position estimation is a nearest-neighbour search in signal space: find the k reference rows whose RSSI vectors are closest (Euclidean distance in dBm) to the live vector, then take the centroid of their coordinates. A scipy KD-tree makes the search sublinear, and clamping k to the map size keeps small maps from raising. This plain centroid is the baseline the companion comparison improves on with distance weighting.
import logging
import numpy as np
from scipy.spatial import cKDTree
logger = logging.getLogger(__name__)
def estimate_position(
tree: cKDTree,
coords: np.ndarray,
scan_vector: np.ndarray,
k: int = 4,
) -> np.ndarray:
"""Return an (x, y, level) estimate as the centroid of the k nearest
reference fingerprints in RSSI signal space."""
k = min(k, coords.shape[0])
try:
distances, idx = tree.query(scan_vector, k=k)
except ValueError as exc:
logger.error("kNN query failed (dimension mismatch with the map?): %s", exc)
raise
idx = np.atleast_1d(idx)
estimate = coords[idx].mean(axis=0)
logger.info(
"position estimate=(%.2f, %.2f) level=%d from k=%d (nearest d=%.1f dBm)",
estimate[0], estimate[1], int(round(estimate[2])), k, float(np.atleast_1d(distances)[0]),
)
return estimate
Edge Cases & Gotchas
| Symptom | Root cause | Diagnostic | Remediation |
|---|---|---|---|
| Position offset a constant few metres for one phone model | Device RSSI offset — the map was surveyed on a different radio | Compare mean scan RSSI of the new device against the survey device at a known point | Estimate and subtract a per-device offset, or match on RSSI differences between APs rather than absolute levels |
| A newly installed AP never helps positioning | The AP has no column in the radio map, so align_scan drops it |
Log unmatched BSSIDs; a persistent unknown high-signal BSSID is an un-surveyed AP | Re-survey the affected zone, or incrementally add the column with fresh samples |
estimate_position raises or returns nonsense |
The live scan aligned to an all-floor vector (empty overlap) | Check the matched count from align_scan |
Reject the update when overlap is below a threshold and coast on the previous fix |
| Estimate flips between two similar rooms | Aliasing — mirror-image rooms have near-identical AP geometry, so their signatures collide | Inspect the k neighbours: split across two distant clusters signals aliasing | Add a discriminating AP, densify the grid at the boundary, or fuse motion to break the tie |
| Estimate jitters while the user stands still | Single-scan noise matched raw against the map | Watch the estimate variance over consecutive static scans | Smooth the input (median of a short scan window) or feed the estimate to a filter |
The unifying rule: the radio map is only as trustworthy as its BSSID alignment and its offset handling — the metric assumes comparable vectors, so anything that breaks comparability corrupts the fix long before the maths is wrong.
Validation Output
Validate a radio map the way you validate any spatial estimator: assert that a scan taken at a known point resolves back to within tolerance of that point’s ground-truth coordinate. Hold out one surveyed point, query with a fresh scan from it, and check the error against the grid spacing.
import numpy as np
from scipy.spatial import cKDTree
radio, coords, bssids = build_radio_map(fingerprints)
tree = cKDTree(radio)
# A held-out scan captured at ground-truth reference point index 12.
ground_truth = coords[12]
live_scan = {"a4:aa:01": -58.0, "a4:aa:02": -71.0, "a4:aa:07": -66.0}
vector = align_scan(live_scan, bssids)
estimate = estimate_position(tree, coords, vector, k=4)
planar_error = float(np.hypot(*(estimate[:2] - ground_truth[:2])))
assert estimate[2] == ground_truth[2], "level misclassified — check per-level survey separation"
assert planar_error <= 3.0, f"planar error {planar_error:.2f} m exceeds tolerance"
The failure to watch for is a green assertion on the training points and a large error on held-out ones — that is the map memorising its own survey rather than generalising, and it means the grid is too sparse or the samples per point too few to average out noise.
Performance & Scale Notes
- Build a KD-tree once, query many. A
scipy.spatial.cKDTreeover the(N, M)map turns each fix from anO(N·M)brute-force scan into a roughly logarithmic query. Rebuild the tree only when the map changes, not per position update. - Memory is
N × Mfloats per floor. A floor with 400 reference points and 60 audible BSSIDs is a 96 KBfloat32matrix — trivial in RAM. Whole campuses stay in memory; the cost is survey labour, not storage. - Partition by level. Never search one flat tree across floors — the Z separation is not encoded in RSSI. Keep one KD-tree per level and route the query to the level the device most likely occupies, which is exactly the fix that feeds Positioning to Routing Graph.
- Re-survey on a cadence, not a whim. Radio maps drift as APs are added, moved, or retuned. Track live-scan-to-map overlap as a health metric; when the median matched-BSSID ratio falls below roughly 60 percent, schedule a re-survey of the affected zone rather than the whole building.
- Fingerprinting is a first estimate, not the last word. A raw kNN fix jitters; it becomes stable only once smoothed and fused with motion in Sensor Fusion & Particle Filters, which treats each fix as a noisy observation rather than truth.
Frequently Asked Questions
How many reference points and samples per point do I actually need?
Space reference points one to two metres apart in circulation areas and densify at junctions and doorways — your achievable accuracy floors out at roughly half the grid spacing. At each point, collect enough scans to average out the second-to-second RSSI swing: 20 to 30 samples over a 10 to 15 second dwell is a common baseline, more in high-traffic areas where people and doors add variance. Too few samples and the mean is unstable, so the map memorises noise instead of the environment.
Why does the same location read differently on different phones?
Because RSSI is reported per radio front-end, not on an absolute physical scale. Two handsets standing in the same spot can differ by six to ten dBm, so a map surveyed on one device systematically mislocates another. Mitigate it by estimating a per-device offset against a known point, or by matching on the differences between access-point levels rather than their absolute values — differencing cancels a constant offset entirely.
What happens when the live scan and the radio map share almost no access points?
That is the signal the query cannot be trusted: either the device is on a different floor than the map, or the map is stale relative to the current AP layout. Track the matched-BSSID ratio in align_scan, reject any fix below a minimum overlap, and coast on the previous position or hand off to motion-based dead reckoning until overlap recovers. Positioning on a one- or two-AP overlap produces confident nonsense.
Can I fingerprint with BLE beacons instead of WiFi?
Yes — the offline-survey and online-match structure is identical; only the feature source changes. BLE gives you control over transmit power and beacon placement, which the companion BLE Beacon Positioning section covers, whereas WiFi reuses infrastructure you already own. Many production systems fingerprint on both bands at once, stacking WiFi and BLE columns into a single feature vector.
Related
- Building a WiFi RSSI Radio Map in Python — the reference-point survey and fingerprint store that feeds this pipeline.
- kNN vs. Weighted kNN for RSSI Positioning — how to choose the matching metric that turns neighbours into a position.
- Sensor Fusion & Particle Filters — smoothing and fusing the raw fingerprint fix with motion.
- Positioning to Routing Graph — snapping the estimate onto the routable network.
- Indoor Coordinate Reference Systems — the local frame every reference point and estimate lives in.
This guide is part of the Indoor Positioning & Fingerprinting reference.