7  Location

Before counting or tracking devices, we must know where each device is. This chapter shows how to assign positions to WiFi detections, a foundational step that feeds into all downstream analyses.

WiFi sensors report detections, not coordinates: “device X was seen at time T with signal strength S.” When coverage areas overlap, the same device appears in multiple sensor logs simultaneously. Localization consolidates these scattered detections into a single position estimate per time window.

This requires two decisions:

7.1 Setup

Prepare data

Download our sample dataset to follow along, or use your own WiFi detection data: sample_loc.zip. The ZIP contains three files: wifi.parquet (WiFi detections for one device), gps.csv (GPS ground truth), and sensors.gpkg (sensor locations).

Period: October 21 – November 14, 2019. A single device with paired GPS ground truth.

Location: UNIST campus, Ulsan, South Korea. 26 sensors in projected coordinates (KGD2002 Central Belt 2010).

Data structure:

File Rows Columns
wifi.parquet ~86,000 timestamp, source_address, sensor_name, rssi
gps.csv ~46,000 source_address, timestamp, x, y
sensors.gpkg 26 sensor_name, geom

How we prepared this sample (see scripts/0-4-unist19-location.R):

  1. Selected the device with most detections from the full dataset
  2. Hashed MAC address (SHA-256, first 12 characters) for privacy
  3. Exported sensor coordinates in projected CRS

This sample uses a single device with paired GPS ground truth, separate from the multi-device dataset used in later chapters (Count, Track, Revisits).

Load packages and data

Load required packages using pacman::p_load(), which installs any missing packages automatically:

pacman::p_load(tidyverse, lubridate, arrow, sf)

Load the data files:

wifi_raw <- read_parquet("../workflow/unist19_loc/data/wifi.parquet")
gps <- read_csv("../workflow/unist19_loc/data/gps.csv", show_col_types = FALSE) |>
  mutate(timestamp = ymd_hms(timestamp))
sensors <- st_read("../workflow/unist19_loc/data/sensors.gpkg", quiet = TRUE)

Extract sensor coordinates for localization:

sensor_locations <- sensors |>
  mutate(
    x_sensor = st_coordinates(sensors)[, 1],
    y_sensor = st_coordinates(sensors)[, 2]
  ) |>
  st_drop_geometry()

WiFi data (probe request detections with signal strength):

head(wifi_raw, 3)
           timestamp source_address sensor_name rssi
1 2019-10-21 00:01:35 a95f0d81c914 comm_center  -75
2 2019-10-21 00:01:36 a95f0d81c914 comm_center  -75
3 2019-10-21 00:01:51 a95f0d81c914 comm_center  -75
  • timestamp: Detection time (second precision)
  • source_address: SHA-256 hashed device identifier (first 12 characters)
  • sensor_name: Which sensor detected this device
  • rssi: Signal strength in dBm (closer to zero = stronger signal)

Sensors (point geometries in projected coordinates):

head(sensor_locations, 3)
      sensor_name x_sensor  y_sensor
1     bus_station 398694.9  332932.1
2 108_front_outs~ 398418.3  332757.7
3       206_front 398305.3  332761.0

7.2 Pipeline

The localization pipeline has three steps: create time windows, aggregate detections by sensor, and assign each window to a location. We use 20-second windows with Proximity (strongest signal wins); see Section 7.4 for the rationale.

Time windows

Group detections into fixed intervals. All detections within the same 20-second window will be aggregated together:

sampling_window <- 20  # seconds

wifi_windowed <- wifi_raw |>
  mutate(
    time_window = floor_date(timestamp, paste(sampling_window, "seconds"))
  )

Aggregate

Within each time window, sum RSSI values per sensor. Summing rather than averaging rewards sensors that detected the device more frequently, a proxy for sustained proximity:

wifi_aggregated <- wifi_windowed |>
  group_by(source_address, time_window, sensor_name) |>
  summarise(
    rssi_sum = sum(rssi),
    weight = sum(100 + rssi),
    n_detections = n(),
    .groups = "drop"
  ) |>
  left_join(sensor_locations, by = "sensor_name")

The weight column converts negative RSSI to positive values (adding 100) for weighted centroid calculations.

Assign location

Pick the sensor with strongest cumulative signal (Proximity method):

wifi_located <- wifi_aggregated |>
  group_by(source_address, time_window) |>
  slice_max(rssi_sum, n = 1, with_ties = FALSE) |>
  ungroup() |>
  select(source_address, time_window, sensor_name, x_sensor, y_sensor)

head(wifi_located)
  source_address       time_window  sensor_name x_sensor  y_sensor
1   a95f0d81c914 2019-10-21 00:01:20  comm_center 398347.1  332870.2
2   a95f0d81c914 2019-10-21 00:01:40  comm_center 398347.1  332870.2
3   a95f0d81c914 2019-10-21 09:07:20     104_back 398627.3  332779.6

Each row is now one device at one location for one time window, ready for counting or tracking.

Proximity assigns devices to discrete sensor locations. If you need continuous coordinates for mapping or distance calculations, compute a weighted average of all detecting sensors:

wifi_centroid <- wifi_aggregated |>
  group_by(source_address, time_window) |>
  summarise(
    x_est = sum(x_sensor * weight, na.rm = TRUE) / sum(weight, na.rm = TRUE),
    y_est = sum(y_sensor * weight, na.rm = TRUE) / sum(weight, na.rm = TRUE),
    .groups = "drop"
  )

\[ x_{est} = \frac{\sum_{i} w_i \cdot x_i}{\sum_{i} w_i} \]

where \(w_i = 100 + RSSI_i\) (adding 100 converts negative RSSI to positive weights).

7.3 Sensor spacing

Sensor spacing matters more than localization method. When sensors are too far apart, devices pass through gaps undetected, and no algorithm can compensate for missing data.

We tested this by progressively removing sensors from our campus deployment:

Sensor density scenarios: from 25 sensors (~50m spacing) to 4 sensors (~320m spacing).

Detection and accuracy both degrade beyond 100m spacing:

Effect of sensor density on localization accuracy.
Spacing Sensors Detection Rate Median Error
~50m 25 100% ~20m
~100m 12 96% ~25m
~150m 6 78% ~40m
~320m 4 67% ~60m

The jump from 100m to 150m is where coverage breaks down. At ~50m spacing, we detected every device with ~20m median error. Doubling to ~100m barely affected performance. But beyond that, accuracy degraded rapidly: at ~150m spacing, we missed 22% of devices and error doubled.

Recommendation: Keep sensors within 100m of each other. If budget forces wider spacing, accept that you’ll miss significant pedestrian traffic, and counts will underestimate accordingly.

7.4 Method selection

Two parameters require decisions: time window length and localization method. Our validation uses GPS ground truth from multiple participants to compare approaches.

Time window

Window length trades off temporal resolution against estimation stability. Short windows (1–10s) capture fine-grained movement but produce noisy estimates; long windows (1–3min) yield stable estimates but blur movement patterns.

We tested windows from 1 to 120 seconds against GPS ground truth:

Localization error by sampling time. Shaded region (10–30 seconds) marks the practical sweet spot.

Weighted Centroid performs best across all window lengths, with ~34m median error at 20 seconds. Centroid follows at ~40m, while Proximity starts at ~46m (1s) and degrades to ~93m at longer windows.

The degradation of Proximity at longer windows reflects a known limitation: when accumulating RSSI over time, a nearby sensor with many weak detections can outweigh a closer sensor with fewer strong detections.

We default to 20 seconds as a reasonable balance:

  • Short enough to capture movement between locations
  • Long enough to aggregate multiple detections for stable estimates
  • Aligns well with typical pedestrian pace (~1.4 m/s = 28m in 20s)

Localization method

Despite lower positional accuracy, we use Proximity (assign to strongest sensor) for the pipeline in subsequent chapters:

  • Output maps directly to a single sensor, essential for discrete analyses like counting at specific locations and building OD matrices between sensors
  • Centroid and Weighted Centroid produce continuous coordinates that fall between sensors, which cannot be used for sensor-level counting or tracking
  • At sensor-level resolution (~50m spacing), the accuracy difference is less consequential than the analytical convenience

For applications requiring precise continuous positioning (e.g., indoor navigation), Weighted Centroid is the better choice.

Li et al. (2021) categorize IoT localization methods by complexity and accuracy:

Localization algorithms (Li et al., 2021).

For low-cost outdoor deployments with sparse sensor networks, only Proximity and Centroid are practical. Methods requiring precise timing (ToA, TDoA) or dense arrays (fingerprinting) demand hardware beyond typical WiFi sensing setups.

Reference: Li, You, et al. Toward location-enabled IoT (LE-IoT). IEEE Internet of Things Journal, 2020, 8.6: 4035-4062.