Building a WiFi RSSI Radio Map in Python
This page is the offline half of WiFi RSSI Fingerprinting & Radio Maps: the concrete Python for turning a survey of a floor into a queryable radio map, before any live positioning happens. Everything here runs once per building (and again on a re-survey cadence), and its only output is a clean, aligned fingerprint store the online matcher can read.
What a Radio Map and a Fingerprint Are
A fingerprint is one labelled observation of the radio environment: a physical coordinate (x, y, level) paired with the received signal strength that location observed for each nearby access point, indexed by BSSID. A radio map is the collection of every fingerprint on a floor, arranged so that all rows share one BSSID column order and are therefore directly comparable.
The subtlety that makes a radio map more than a spreadsheet is aggregation. RSSI at a fixed point is not a number; it is a distribution that swings several dBm as people move and channels contend. A fingerprint is therefore not a single scan but the mean (or median) of many scans collected during a dwell at the point — that aggregation is what converts a noisy live feed into a stable signature. The second subtlety is completeness: no point hears every access point, so the map is inherently sparse, and the missing cells must be filled with a physically meaningful floor value rather than left null or zeroed. Zero dBm implies an impossibly strong signal; a floor such as −100 dBm correctly encodes “effectively inaudible here.”
Minimal Working Example
The example below collects samples per reference point, aggregates each point to a mean RSSI vector, and stores the whole survey as a pandas frame keyed by BSSID columns with missing access points filled at the floor value. The frame is the radio map: one row per (x, y, level), one column per BSSID, ready to hand to a nearest-neighbour matcher.
import logging
from dataclasses import dataclass
import numpy as np
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
RSSI_FLOOR: float = -100.0 # dBm for an access point a reference point never heard
@dataclass(frozen=True)
class RefPoint:
x: float
y: float
level: int
def aggregate_point(samples: list[dict[str, float]]) -> dict[str, float]:
"""Reduce many raw scans at one reference point to a mean RSSI per BSSID."""
if len(samples) < 5:
logger.warning("only %d samples: mean RSSI will be unstable", len(samples))
acc: dict[str, list[float]] = {}
for scan in samples:
for bssid, rssi in scan.items():
acc.setdefault(bssid, []).append(rssi)
return {bssid: float(np.mean(vals)) for bssid, vals in acc.items()}
def build_radio_map(survey: dict[RefPoint, list[dict[str, float]]]) -> pd.DataFrame:
"""Aggregate a survey into a radio map: rows are reference points, columns are
BSSIDs, missing access points filled with the RSSI floor."""
if not survey:
raise ValueError("empty survey: no reference points collected")
rows, index = [], []
for point, samples in survey.items():
rows.append(aggregate_point(samples))
index.append((point.x, point.y, point.level))
frame = pd.DataFrame(rows, index=pd.MultiIndex.from_tuples(index, names=["x", "y", "level"]))
frame = frame.fillna(RSSI_FLOOR).astype(np.float32)
logger.info("radio map: %d points x %d BSSIDs", frame.shape[0], frame.shape[1])
return frame
The resulting frame stores geometry in the same local Cartesian coordinates as the routable map, matching the site’s GeoJSON envelope so a fingerprint and a routing node can share one frame:
{
"type": "FeatureCollection",
"features": [
{ "type": "Feature",
"geometry": { "type": "Point", "coordinates": [12.4, 7.8] },
"properties": { "feature_id": "L2-fp-0042", "space_class": "corridor", "level": 2,
"is_routable": true, "accessibility_rating": "step_free", "topology_hash": "9f3c2a7e",
"rssi": { "a4:aa:01": -58.0, "a4:aa:02": -71.0 } } }
]
}
Survey & Radio-Map Parameter Reference
| Parameter | Typical value | Notes |
|---|---|---|
| Samples per reference point | 20–30 scans | Averages out second-to-second RSSI swing; fewer than ~5 gives an unstable mean |
| Dwell time per point | 10–15 s | Long enough to capture channel variation; hold the device at natural carry height |
| Grid spacing | 1–2 m | Accuracy floors at roughly half the spacing; densify at junctions and doorways |
| Missing-AP fill | −100 dBm | Encodes “inaudible here”; never use 0, which reads as an impossibly strong signal |
| Valid RSSI range | −30 to −95 dBm | Clamp or discard outliers outside this band as spurious reads |
| Survey device policy | one model, logged | Record the survey handset so a per-device offset can be corrected online |
| Re-survey trigger | overlap < ~60% | When live-scan-to-map BSSID overlap drops, re-survey the affected zone |
Common Errors & Fixes
ValueError: Length mismatch / misaligned columns when stacking surveys. Two survey sessions saw different BSSID sets, so their frames have different columns and a naive pd.concat or matrix stack fails or silently misaligns. The fix is to take the union of columns and reindex both frames onto it before combining, filling the gaps at the floor:
all_bssids = frame_a.columns.union(frame_b.columns)
frame_a = frame_a.reindex(columns=all_bssids, fill_value=RSSI_FLOOR)
frame_b = frame_b.reindex(columns=all_bssids, fill_value=RSSI_FLOOR)
radio_map = pd.concat([frame_a, frame_b]).astype("float32")
Estimates are unstable even on surveyed points. The mean RSSI per point is noisy because too few samples were collected during the dwell. aggregate_point warns below five samples; raise the sample count to 20–30 and confirm the dwell actually captured that many scans (many phones throttle WiFi scans to one every few seconds, so a 15-second dwell may yield fewer scans than expected — pace the survey accordingly).
A point reads systematically weak or strong versus its neighbours. The survey mixed device models, so one point was recorded on a handset with a different RSSI offset. Standardise on a single survey device and log it; if a mixed survey is unavoidable, calibrate each device against a shared reference point and subtract the measured offset before aggregation.
Integration Point
This radio map is the artifact the online matcher reads. Once built, the frame’s column order becomes the fixed BSSID index that a live scan is aligned against, and its rows become the reference points a nearest-neighbour search returns — the full query path is described in the parent WiFi RSSI Fingerprinting & Radio Maps guide. The immediate next decision is how to weight those neighbours into a single position, which is exactly the trade-off in kNN vs. Weighted kNN for RSSI Positioning. Keep the map’s coordinates in the same frame as the routing graph so the resulting fix needs no reprojection before it is used.
Frequently Asked Questions
Should I aggregate fingerprints with the mean or the median?
Use the mean as a default — it is cheap and, over 20 or more samples, stable. Switch to the median when a point sits near a lift, a busy doorway, or reflective glazing, where transient obstructions produce a skewed, heavy-tailed distribution that a mean chases. The median resists those outliers at the cost of discarding a little information. Whichever you pick, apply it consistently across the whole survey so every row is comparable.
Why fill missing access points with -100 dBm instead of leaving them blank?
Because the matcher computes a distance across the whole vector, and a blank cell has no defined distance. Zero is worse than blank — zero dBm is a physically impossible near-transmitter signal that would pull the metric hard toward any point that also happens to lack that AP. A floor value such as −100 dBm encodes the true meaning, “effectively inaudible at this point,” so absence itself becomes a weak but correct positioning feature.
How do I keep the radio map fresh as access points change?
Treat the map as perishable. Monitor the fraction of a live scan’s BSSIDs that match the map; when the median overlap falls below roughly 60 percent, an AP has been added, moved, or retired and the affected zone needs re-surveying. You rarely re-survey a whole building at once — re-collect the drifted zone, reindex its columns onto the existing map, and replace those rows.
Related
- WiFi RSSI Fingerprinting & Radio Maps — the full offline-survey and online-match pipeline this map plugs into.
- kNN vs. Weighted kNN for RSSI Positioning — how to turn the map’s nearest neighbours into a single position.
This page sits within the WiFi RSSI Fingerprinting & Radio Maps guide, part of the Indoor Positioning & Fingerprinting reference.