9  Track

While Count answers “how many devices?”, Track answers “where did they go?” This chapter shows how to build origin-destination (OD) matrices and flow maps from WiFi detection data, revealing movement corridors across your study area.

The core concept is the trip: a sequence of detections for one device, bounded by gaps of inactivity. We define a 30-minute threshold: if a device isn’t detected for 30+ minutes, subsequent detections start a new trip. Each trip has an origin (first sensor) and destination (last sensor). Aggregating thousands of these OD pairs reveals which routes dominate and how they shift by time of day or day of week.

9.1 Setup

Prepare data

Download our sample dataset to follow along, or use your own WiFi detection data: sample_main.zip. The ZIP contains three files: wifi.parquet (WiFi detections), sensors.gpkg (sensor locations), and poi.gpkg (campus landmarks).

This chapter uses the same UNIST campus dataset introduced in Count: see that chapter for the period, sensor coverage, schema, and preparation steps.

Load packages and data

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

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

Load the data files:

wifi_raw <- read_parquet("../workflow/unist19_main/data/wifi.parquet")
sensors <- st_read("../workflow/unist19_main/data/sensors.gpkg", quiet = TRUE)
poi <- st_read("../workflow/unist19_main/data/poi.gpkg", quiet = TRUE)

Configure basemap

Flow maps overlay movement data on a geographic basemap. Choose one of these options:

For satellite imagery, register a Google Maps API key:

register_google(key = "YOUR_API_KEY")

bbox <- st_bbox(sensors)
base_map <- get_map(
  location = c(lon = mean(bbox[c(1,3)]), lat = mean(bbox[c(2,4)])),
  zoom = 16, maptype = "satellite", source = "google"
)

For a free alternative (no API key required):

bbox <- st_bbox(sensors)
base_map <- get_stadiamap(
  bbox = c(left = bbox["xmin"], bottom = bbox["ymin"],
           right = bbox["xmax"], top = bbox["ymax"]),
  zoom = 16, maptype = "stamen_toner_lite"
)

Extract sensor coordinates for plotting:

sensor_coords <- sensors |>
  st_coordinates() |>
  as_tibble() |>
  bind_cols(sensors |> st_drop_geometry() |> select(sensor_name))

WiFi data (20-second aggregated detections):

head(wifi_raw, 3)
            timestamp source_address       sensor_name rssi_median rssi_sum   n detections
1 2019-10-28 00:00:00   014ca1637458     PC_room_front       -76.5    -1371  61         18
2 2019-10-28 00:00:00   02f35e3b75cf football_field_front    -65.0     -461 132          7
3 2019-10-28 00:00:00   03cac8c223f6          202_back       -74.0      -74   2          1
  • timestamp: Start of 20-second aggregation window
  • source_address: SHA-256 hashed device identifier (first 12 characters)
  • sensor_name: Which sensor detected this device
  • rssi_median: Median signal strength in dBm within the window
  • rssi_sum: Cumulative signal strength (used for localization)
  • n: Total raw packets captured
  • detections: Number of 1-second slots with at least one detection

Sensors (point geometries with location coordinates):

head(sensors, 5)
Simple feature collection with 5 features and 1 field
Geometry type: POINT
Geodetic CRS:  WGS 84
      sensor_name                      geom
1     bus_station POINT (129.1918 35.57348)
2 108_front_outside POINT (129.1887 35.57197)
3        112_side  POINT (129.187 35.57127)
4        104_back  POINT (129.191 35.57212)
5        108_back  POINT (129.189 35.57104)

POI (reference labels for the map):

poi
Simple feature collection with 6 features and 1 field
Geometry type: POINT
Geodetic CRS:  WGS 84
               name                      geom
1         Dormitory POINT (129.1878 35.57708)
2           Library  POINT (129.1879 35.5738)
3       Bus Station POINT (129.1919 35.57353)
4 Engineering Bldg. POINT (129.1897 35.57187)
5  Off-campus shops  POINT (129.191 35.57672)
6              Lake POINT (129.1884 35.57273)

9.2 Pipeline

Localize

Each 20-second window may contain detections from multiple sensors. Before building trips, assign each device to its strongest sensor at each timestamp:

wifi <- wifi_raw |>
  group_by(source_address, timestamp) |>
  slice_max(rssi_sum, n = 1) |>
  ungroup() |>
  select(source_address, timestamp, sensor_name)

This reduces the dataset from ~4.2M rows to ~2.9M by keeping only the strongest detection per device per time window.

Define trips

Segment the localized detections into trips. For each device, calculate the time gap between consecutive detections. When the gap exceeds 30 minutes, start a new trip:

gap_threshold <- 30  # minutes

trips <- wifi |>
  arrange(source_address, timestamp) |>
  group_by(source_address) |>
  mutate(
    time_gap = as.numeric(difftime(timestamp, lag(timestamp), units = "mins")),
    new_trip = is.na(time_gap) | time_gap > gap_threshold,
    trip_id = cumsum(new_trip)
  ) |>
  ungroup()

Extract OD pairs

For each trip, extract the origin (first sensor) and destination (last sensor). Filter out stationary trips (origin = destination) and single-detection trips:

od_pairs <- trips |>
  group_by(source_address, trip_id) |>
  summarise(
    origin = first(sensor_name),
    destination = last(sensor_name),
    trip_start = min(timestamp),
    trip_end = max(timestamp),
    n_detections = n(),
    .groups = "drop"
  ) |>
  filter(
    origin != destination,
    n_detections >= 2
  )

Build OD matrix

Count trips between each sensor pair and compute transition probabilities. This creates an OD matrix showing how traffic flows between locations:

od_counts <- od_pairs |>
  count(origin, destination, name = "n_trips")

od_probs <- od_counts |>
  group_by(origin) |>
  mutate(
    total_from = sum(n_trips),
    prob = n_trips / total_from
  ) |>
  ungroup()

od_probs |>
  filter(origin == "104_back") |>
  arrange(desc(n_trips)) |>
  head(3)
# A tibble: 3 x 5
  origin   destination          n_trips total_from   prob
  <chr>    <chr>                  <int>      <int>  <dbl>
1 104_back bus_station             1523       4976  0.306
2 104_back btw_201_102              465       4976  0.093
3 104_back bridge_main_engineer     433       4976  0.087

From the 104_back sensor (near dormitories), 31% of outbound trips go to the bus station, the main campus exit. Another 9% head toward the btw_201_102 area (between academic buildings).

9.3 Flow map

Flow maps display OD pairs on a geographic basemap, with arrows showing direction and line thickness encoding volume. This visualization reveals which corridors carry the most traffic.

Select the top OD pairs and join with sensor coordinates:

top_n <- 5

edges_top <- od_probs |>
  slice_max(n_trips, n = top_n) |>
  left_join(sensor_coords, by = c("origin" = "sensor_name")) |>
  rename(x_from = X, y_from = Y) |>
  left_join(sensor_coords, by = c("destination" = "sensor_name")) |>
  rename(x_to = X, y_to = Y)

edges_top |> select(origin, destination, n_trips)
# A tibble: 5 x 3
  origin              destination       n_trips
  <chr>               <chr>               <int>
1 btw_201_102         bus_station          2317
2 104_back            bus_station          1523
3 bus_station         btw_201_102          1400
4 bus_station         104_back             1229
5 lake                108_front_outside    1204

Plot the flow map with curved arrows:

# Get labels for sensors in top flows
od_sensors <- unique(c(edges_top$origin, edges_top$destination))
sensor_labels <- sensor_coords |>
  filter(sensor_name %in% od_sensors)

ggmap(base_map, darken = c(0.3, "white")) +
  geom_curve(data = edges_top,
             aes(x = x_from, y = y_from, xend = x_to, yend = y_to),
             linewidth = edges_top$n_trips / max(edges_top$n_trips) * 3,
             color = "#2c7bb6", curvature = 0.3, alpha = 0.85,
             arrow = arrow(length = unit(0.2, "cm"), type = "closed")) +
  geom_point(data = sensor_labels, aes(x = X, y = Y),
             size = 2.5, color = "#d7191c") +
  geom_text_repel(data = sensor_labels, aes(x = X, y = Y, label = sensor_name),
                  size = 2.5, fontface = "bold.italic") +
  theme_void()

Top 5 OD flows across UNIST campus. Arrow thickness indicates trip volume.

The dominant corridors reveal the bus station as the campus’s primary transit hub:

  • btw_201_102 → bus_station (2,317 trips): Academic area to transit, the highest-volume route
  • 104_back → bus_station (1,523 trips): Dormitories to transit exit
  • bus_station ↔︎ btw_201_102 / 104_back: Strong bidirectional flows (1,400 / 1,229 return trips)

The bus station anchors the campus movement network, with bidirectional flows to residential and academic zones. The lake → 108_front_outside corridor (1,204 trips) connects recreational and academic areas.

9.4 Temporal patterns

Movement patterns vary by time: weekdays vs weekends, morning vs evening. Segmenting OD pairs by temporal context reveals how space usage shifts with daily and weekly rhythms.

Weekday vs Weekend

Split OD pairs by day type to compare routine weekday flows against sparser weekend patterns:

od_by_daytype <- od_pairs |>
  mutate(day_type = if_else(wday(trip_start) %in% c(1, 7), "Weekend", "Weekday")) |>
  count(origin, destination, day_type, name = "n_trips")

# Top 3 routes per day type
od_by_daytype |>
  group_by(day_type) |>
  slice_max(n_trips, n = 3)
# A tibble: 6 x 4
  day_type origin              destination       n_trips
  <chr>    <chr>               <chr>               <int>
1 Weekday  btw_201_102         bus_station          1854
2 Weekday  104_back            bus_station          1259
3 Weekday  bus_station         btw_201_102          1189
4 Weekend  btw_201_102         bus_station           463
5 Weekend  108_front_outside   lake                  277
6 Weekend  bridge_main_engineer bus_station          267

Top 15 OD flows by day type. Weekday (left) vs Weekend (right).
  • Volume: Weekday top route (1,854 trips) is 4.0× higher than weekend top (463)
  • Bus station dominance: Top routes on weekdays end at bus_station; transit is the primary destination on working days
  • Parking rise: dorm_front → parking_dorm and parking_dorm → dorm_front appear in top weekend routes; residents switch to personal vehicles
  • Recreational shift: 108_front_outside → lake rises to #2 on weekends; recreational movement increases when classes are out

Morning vs Evening

Comparing morning (7-10am) and evening (5-8pm) flows on weekdays reveals commute directionality:

od_time_period <- od_pairs |>
  filter(wday(trip_start) %in% 2:6) |>  # weekdays only
  mutate(
    hour = hour(trip_start),
    time_period = case_when(
      hour >= 7 & hour < 10 ~ "Morning (7-10)",
      hour >= 17 & hour < 20 ~ "Evening (17-20)",
      TRUE ~ NA_character_
    )
  ) |>
  filter(!is.na(time_period)) |>
  count(origin, destination, time_period, name = "n_trips")

# Top 3 routes per time period
od_time_period |>
  group_by(time_period) |>
  slice_max(n_trips, n = 3)
# A tibble: 6 x 4
  time_period     origin              destination       n_trips
  <chr>           <chr>               <chr>               <int>
1 Evening (17-20) 104_back            bus_station           408
2 Evening (17-20) btw_201_102         bus_station           382
3 Evening (17-20) bus_station         btw_201_102           205
4 Morning (7-10)  btw_201_102         bus_station           260
5 Morning (7-10)  bus_station         104_back              236
6 Morning (7-10)  bus_station         btw_201_102           228

Top 15 weekday OD flows by time period. Morning 7-10am (left) vs Evening 5-8pm (right).

The data reveals directional asymmetry:

  • Bus station: Evening outbound volume (408 + 382 = 790 departures) far exceeds morning outbound (260), more departures than arrivals by bus
  • Morning inbound: bus_station → 104_back (236 trips) and bus_station → btw_201_102 (228 trips) show morning arrivals dispersing into campus
  • Evening convergence: Multiple origins (dormitories, academic buildings) funnel toward bus_station

Many students arrive by multiple means (bus, car, walking) but converge on the bus station for evening departure. The campus functions as a daytime destination with a transit-dependent evening exodus.