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.”

A survey lattice of reference points over one floor, with the access points that serve it A floor containing two rooms and a corridor. Thirty-two teal reference points are laid out on a regular four-by-three-metre lattice covering both rooms and the corridor; each is surveyed with thirty scans at a consistent phone height of 1.1 metres. Two amber access points are marked between the rooms and the corridor. Every reference point stores the vector of signal strengths it measured from every access point it could hear. A radio map is a labelled lattice, not a heat map Office 2.01 Lab 2.02 Corridor AP a4:2b:..:e2 AP 9c:1c:..:5d 32 reference points on a 4 m x 3 m lattice, 30 scans each, phone held at 1.1 m reference point access point

The lattice is the resolution ceiling. A kNN estimator can never be more precise than the spacing of the points it interpolates between — which is why the corridor, where users actually walk, is surveyed at the same density as the rooms.

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
Survey parameters, the physical effect each controls, and what breaks without it A table of six survey parameters. Twenty to thirty scans per point average out fast fading; too few and the fix jitters between scans. Three to four metre spacing bounds interpolation error; wider spacing gives blurry positions. A device height of 1.1 to 1.4 metres matches how phones are actually held, otherwise every reading carries a systematic offset. Four orientations are needed because the surveyor's body blocks the radio; skipping them roughly doubles the error when a user faces away from an access point. A dwell of three to five seconds per scan lets the radio settle, without which half the access points go unheard. Re-surveying on access-point change is required because radio maps age and otherwise drift silently. Six parameters, all of them about physics rather than code Survey parameter Value Why If skipped scans per point 20 - 30 averages fast fading △ fix jitters between scans spacing 3 - 4 m bounds interpolation error △ coarse, blurry positions device height 1.1 - 1.4 m matches how phones are held △ systematic dBm offset orientations 4 (N/E/S/W) body blocks the radio △ error doubles facing away dwell per scan 3 - 5 s lets the radio settle △ half the APs go unheard re-survey on AP change radio maps age △ drift, silently Orientation is the one most often skipped and the one with the largest single effect.

Body attenuation is the forgotten one. A surveyor standing between the phone and an access point costs 4-8 dB; survey in one orientation and every user facing the other way is positioned against a map that never saw their case.

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:

Median positioning error against survey spacing, at two survey depths Two curves of median positioning error against reference-point spacing. With thirty scans per point, error rises from 1.9 metres at 1.5-metre spacing to 8.1 metres at 10-metre spacing. With only five scans per point the whole curve shifts up by roughly 1.2 metres at fine spacing and converges towards the deeper survey at coarse spacing, because at 10 metres the error is dominated by interpolation rather than by scan noise. Two knobs, and only one of them keeps paying 2 4 6 8 10 2 4 6 8 10 reference-point spacing (m) median positioning error (m) 30 scans per point 5 scans per point 4 m spacing: the usual survey-effort compromise

Depth and density buy different things. More scans per point suppress measurement noise and stop helping past about 20; tighter spacing reduces interpolation error and keeps paying — but survey time scales with the square of the density.

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.

This page sits within the WiFi RSSI Fingerprinting & Radio Maps guide, part of the Indoor Positioning & Fingerprinting reference.