pacman::p_load(tidyverse, lubridate, arrow, sf, ggmap, ggrepel)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:
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 windowsource_address: SHA-256 hashed device identifier (first 12 characters)sensor_name: Which sensor detected this devicerssi_median: Median signal strength in dBm within the windowrssi_sum: Cumulative signal strength (used for localization)n: Total raw packets captureddetections: 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):
poiSimple 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()
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

- 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_dormandparking_dorm → dorm_frontappear in top weekend routes; residents switch to personal vehicles - Recreational shift:
108_front_outside → lakerises 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

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) andbus_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.