pacman::p_load(tidyverse, lubridate, arrow, sf, ggmap, ggrepel, data.table)11 Activities
While Revisits classifies who visits, Activities reveals what they do: whether they stay or pass through. This chapter detects stationary activities using anchor-based spatial clustering: after localizing each detection to its strongest sensor and grouping into trajectories, we cluster consecutive detections that remain within a distance threshold of an anchor point. Clusters lasting 5+ minutes are classified as “stays.”
The core insight is that spatial stability signals activity. A device that lingers near the same sensors is likely engaged in stationary behavior (studying, dining, socializing). One that moves rapidly across distant sensors is passing through. By segmenting trajectories into stays and pass-throughs, we can map where activities concentrate and how they differ between visitor types.
11.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") |>
mutate(date = as_date(timestamp), hour = hour(timestamp))
sensors <- st_read("../workflow/unist19_main/data/sensors.gpkg", quiet = TRUE)Precompute distance matrix
For anchor clustering, we need pairwise distances between all sensors. We transform to a projected CRS (EPSG:5179, Korea TM Central Belt) and compute a 24×24 distance matrix:
sensor_sf <- sensors |> st_transform(5179)
dist_mat <- as.numeric(st_distance(sensor_sf))
dist_mat <- matrix(dist_mat, nrow = nrow(sensor_sf),
dimnames = list(sensors$sensor_name, sensors$sensor_name))
sensor_idx <- setNames(seq_len(nrow(sensors)), sensors$sensor_name)This gives O(1) distance lookups during the clustering loop, which is critical for performance over ~3 million rows.
11.2 Stay Detection
WiFi detections are processed through a five-step pipeline: localize each detection to its strongest sensor, group into trajectories, filter out low-quality trajectories, cluster spatially stable segments using anchor-based clustering, and classify by duration.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8f4f8', 'primaryTextColor': '#1a1a1a', 'primaryBorderColor': '#5c9ead', 'lineColor': '#5c9ead', 'secondaryColor': '#f0f7e6', 'tertiaryColor': '#fff5e6'}}}%%
flowchart LR
A[WiFi<br/>Detections] --> B[Localize]
B --> C[Trajectories]
C --> D[Quality<br/>Filter]
D --> E[Anchor<br/>Clustering]
E --> F{Duration<br/>>= 5 min?}
F -->|Yes| G[Stay]
F -->|No| H[Pass-through]
style A fill:#e8f4f8,stroke:#5c9ead
style G fill:#f0f7e6,stroke:#7cb342
style H fill:#fff5e6,stroke:#f9a825
Parameters
gap_threshold <- 30 # minutes: trajectory splitting
dist_threshold <- 150 # meters: anchor clustering distance
time_threshold <- 5 # minutes: minimum stay duration
min_windows <- 3 # minimum detections per trajectory
max_duration <- 7200 # seconds (2 hours): trajectory duration capThese values suit campus-scale deployments with ~100m sensor spacing:
| Parameter | Default | Range | Notes |
|---|---|---|---|
| Distance threshold | 150m | 50–200m | ~sensor detection range |
| Time threshold | 5 min | 3–15 min | Public life study convention |
| Min detections | 3 | 2–5 | Removes unreliable trajectories |
| Max duration | 2 hours | 1–4 hours | Removes overnight device traces |
The anchor clustering algorithm follows Li et al. (2008), who used similar spatial clustering for GPS stay point detection. Kang et al. (2005) used comparable distance thresholds (50–200m) and time thresholds (10–30 min) for WiFi-based place detection.
Step 1: Localize
Each 20-second window may detect the same device at multiple sensors simultaneously. We select the strongest sensor per (device, timestamp) using rssi_sum, the total signal energy received, as the localization signal:
wifi <- wifi_raw |>
arrange(source_address, timestamp, desc(rssi_sum)) |>
distinct(source_address, timestamp, .keep_all = TRUE)Raw: 4,231,921 -> Localized: 2,747,285 (64.9%)
Localization reduces the data by 35%, removing duplicate detections where a device was picked up by multiple sensors in the same window. Here we keep exactly one row per device-window; the tie-keeping variant in Track retains slightly more rows.
Steps 2–3: Trajectories and quality filter
We split each device’s detections into trajectories using a 30-minute gap threshold (same as Track), then apply a quality filter:
dt <- as.data.table(wifi)
setorder(dt, source_address, timestamp)
# Trajectory splitting
dt[, time_gap := as.numeric(difftime(timestamp, shift(timestamp), units = "mins")),
by = source_address]
dt[, new_traj := is.na(time_gap) | time_gap > gap_threshold]
dt[, traj_id := cumsum(new_traj), by = source_address]
# Quality filter: remove sparse or excessively long trajectories
traj_stats <- dt[, .(
duration_sec = as.numeric(difftime(max(timestamp), min(timestamp), units = "secs")),
n_windows = .N
), by = .(source_address, traj_id)]
valid_traj <- traj_stats[n_windows >= min_windows & duration_sec <= max_duration]
dt <- dt[valid_traj[, .(source_address, traj_id)], on = .(source_address, traj_id)]Without a duration cap, overnight dormitory devices create trajectories spanning 6–8 hours. These devices are stationary (sleeping residents), but WiFi signal fluctuation causes the “strongest” sensor to change every few minutes. After localization, the device appears to bounce between 10+ sensors despite never moving.
The 2-hour maximum removes these extended traces. The 3-detection minimum removes very sparse trajectories that produce unreliable clustering results.
Step 4: Anchor clustering
Unlike consecutive-distance segmentation (which compares each detection only to its predecessor), anchor-based clustering (Li et al., 2008) compares each detection to the cluster’s anchor sensor: the sensor where the cluster began. A new cluster starts only when the device moves beyond the distance threshold from the anchor:
addr_vec <- dt$source_address
traj_vec <- dt$traj_id
sidx_vec <- sensor_idx[dt$sensor_name]
n <- nrow(dt)
cluster_ids <- integer(n)
cluster_ids[1] <- 1L
anchor <- sidx_vec[1]
cid <- 1L
for (i in 2:n) {
if (addr_vec[i] != addr_vec[i-1] || traj_vec[i] != traj_vec[i-1]) {
anchor <- sidx_vec[i]
cid <- 1L
} else if (dist_mat[anchor, sidx_vec[i]] > dist_threshold) {
cid <- cid + 1L
anchor <- sidx_vec[i]
}
cluster_ids[i] <- cid
}
dt[, cluster_id := cluster_ids]Consider a device detected at sensors A, B, and C:
Detection 1: Sensor A → anchor = A, cluster 1
Detection 2: Sensor B (80m from A) → within 150m of anchor A → cluster 1
Detection 3: Sensor A (0m from A) → within 150m of anchor A → cluster 1
Detection 4: Sensor C (200m from A)→ exceeds 150m from anchor → cluster 2, anchor = C
Detection 5: Sensor C (0m from C) → within 150m of anchor C → cluster 2
The key advantage: a device alternating between sensors A and B (both within 150m of anchor A) stays in one cluster. With consecutive-distance segmentation, the A→B→A pattern could create unnecessary segment breaks if B→A exceeds the threshold, a common problem with WiFi signal fluctuation near sensor boundaries.
Step 5: Classify
For each cluster, calculate duration and assign the primary sensor (anchor). Clusters with duration >= 5 minutes are classified as stays, further subdivided by duration:
stays <- dt[, .(
start_time = min(timestamp),
end_time = max(timestamp),
duration_mins = as.numeric(difftime(max(timestamp), min(timestamp), units = "mins")),
n_detections = .N,
primary_sensor = sensor_name[1],
n_sensors = uniqueN(sensor_name),
date = date[1],
hour = hour[1]
), by = .(source_address, traj_id, cluster_id)]
stays[, `:=`(
is_stay = duration_mins >= time_threshold,
activity_type = factor(
fcase(
duration_mins < time_threshold, "Pass-through",
duration_mins < 15, "Short stay",
duration_mins < 60, "Medium stay",
default = "Long stay"
),
levels = c("Pass-through", "Short stay", "Medium stay", "Long stay")
)
)]
stays <- as_tibble(stays)Stay detection results:
Total clusters: 343,815
Stays (>= 5 min): 79,016 (23.0%)
Median stay duration: 21 mins
Of roughly 344,000 clusters, about 23% qualify as stays: periods where devices remained spatially stable for at least 5 minutes. The quality filter and anchor clustering produce a cleaner signal than simple consecutive-distance segmentation.
11.3 Behavioral Patterns
Activity type distribution
Most clusters (77%) are pass-throughs: brief movements across the sensor network. Among stays, medium stays (15–60 min) are most common at 11.9%, followed by short stays (8.5%) and long stays (2.6%).

activity_dist <- stays |>
count(activity_type) |>
mutate(pct = n / sum(n) * 100)
ggplot(activity_dist, aes(activity_type, pct, fill = activity_type)) +
geom_col(width = 0.7) +
geom_text(aes(label = paste0(round(pct, 1), "%")),
vjust = -0.5, fontface = "bold", size = 3.5) +
scale_fill_manual(values = c("Pass-through" = "gray55",
"Short stay" = "#fdae61",
"Medium stay" = "#f46d43",
"Long stay" = "#d73027")) +
scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
labs(title = "Activity Type Distribution",
x = NULL, y = "%") +
theme_bw() +
theme(legend.position = "none")Stay duration distribution
Among stays, the distribution peaks at 15–30 minutes (28.5%), followed by 5–10 minutes (23.8%) and 30–60 minutes (23.1%). The 2-hour trajectory cap means virtually no stays exceed 120 minutes (0.1%), eliminating the artificial long-duration tail that overnight dormitory traces would otherwise produce. About 11% of stays fall in the 60–120 minute range, representing extended activities like classes or study sessions.

stays_only <- stays |> filter(is_stay)
duration_bins <- stays_only |>
mutate(duration_bin = cut(duration_mins,
breaks = c(5, 10, 15, 30, 60, 120, Inf),
labels = c("5-10", "10-15", "15-30", "30-60", "60-120", "120+"),
right = FALSE)) |>
count(duration_bin) |>
mutate(pct = n / sum(n) * 100)
ggplot(duration_bins, aes(duration_bin, pct)) +
geom_col(fill = "gray45", width = 0.7) +
geom_text(aes(label = paste0(round(pct, 1), "%")),
vjust = -0.5, size = 3.2) +
scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
labs(title = "Stay Duration Distribution",
x = "Duration (minutes)", y = "%") +
theme_bw()Temporal pattern
Stay counts follow a clear daytime pattern, peaking during midday (11 AM–1 PM) at around 6,000–7,000 stays per hour. Nighttime stays drop to ~1,000 during 3–5 AM, reflecting the 6:1 daytime-to-nighttime ratio expected on a university campus. The quality filter removed extended overnight dormitory traces that would otherwise inflate nighttime stay counts.

hourly_stays <- stays |>
group_by(hour) |>
summarise(
n_total = n(),
n_stays = sum(is_stay),
stay_rate = mean(is_stay) * 100,
.groups = "drop"
)
ggplot(hourly_stays, aes(hour, n_stays)) +
geom_col(fill = "gray45", width = 0.8) +
scale_x_continuous(breaks = c(0, 4, 8, 12, 16, 20, 23),
labels = c("0AM", "4", "8", "12PM", "16", "20", "23")) +
scale_y_continuous(labels = scales::comma) +
labs(title = "Hourly Stay Count",
subtitle = "Stays concentrate during daytime activity hours",
x = NULL, y = "Number of stays") +
theme_bw()While stay count peaks at midday, stay rate (stays / total clusters) is highest at night: ~30–40% during 1–5 AM vs ~20–25% during daytime hours. After the quality filter removes extended overnight traces, the surviving nighttime clusters are predominantly short trajectories near dormitories, and these naturally show high stay rates because devices near a single sensor tend to remain spatially stable. During the day, the flood of cross-campus movement creates many pass-through clusters that lower the overall rate.
Spatial pattern
Stay rates differ sharply by location, revealing the functional character of each sensor’s surroundings. Academic buildings show the highest stay rates (34–40%): places where people work and study. Transit points and corridors show the lowest rates (5–17%): places people pass through.

sensor_activity <- stays |>
group_by(primary_sensor) |>
summarise(
n_clusters = n(),
n_stays = sum(is_stay),
stay_rate = mean(is_stay) * 100,
avg_duration = mean(duration_mins[is_stay], na.rm = TRUE),
.groups = "drop"
) |>
left_join(sensor_coords, by = c("primary_sensor" = "sensor_name"))
ggmap(base_map, darken = c(0.3, "white")) +
geom_point(data = sensor_coords, aes(x = X, y = Y),
size = 1.2, color = "grey60", inherit.aes = FALSE) +
geom_point(data = sensor_activity,
aes(x = X, y = Y, size = n_stays, color = stay_rate),
alpha = 0.8, inherit.aes = FALSE) +
scale_size_continuous(range = c(1.5, 10), name = "Stay\ncount") +
scale_color_gradient(low = "#fee08b", high = "#d73027", name = "Stay\nrate (%)") +
labs(title = "Spatial Distribution of Stays") +
theme_bw()Stay rates reveal location function. Academic buildings lead with 34–40%: 108_back (40%, avg 37 min) and 108_front_outside (34%, avg 35 min) are places where people work and study for extended periods. Dormitories and public-facing buildings follow at 32–33%: dorm_front, 202_front, and 203_front attract stays but shorter ones (avg 27–30 min). Recreation areas register 26–27% (gym_front, football_field_back). Transit sensors show the lowest rates (5–17%): 202_back_further (5%), football_field_front (11%), and bridge_main_engineer (17%, avg 24 min) are places people pass through rather than linger.
By visitor type
Linking stays to the Revisits classification reveals that one-time and returning visitors show similar stay rates (~23%), but the character of their stays differs. Returning visitors’ median stay is 47% longer (22 min vs 15 min), and they are 2.3× more likely to engage in long stays (2.8% vs 1.2%).

# Classify devices by visit frequency (same as Revisits chapter)
regular_threshold <- 5
device_days <- wifi |>
group_by(source_address) |>
summarise(n_days = n_distinct(date), .groups = "drop") |>
mutate(freq_type = case_when(
n_days == 1 ~ "One-time",
n_days >= regular_threshold ~ "Regular",
TRUE ~ "Occasional"
))
# Join frequency type to stays
stays_with_freq <- stays |>
left_join(device_days |> select(source_address, freq_type), by = "source_address") |>
filter(freq_type %in% c("One-time", "Regular"))
# Activity distribution by frequency type
activity_by_freq <- stays_with_freq |>
count(freq_type, activity_type) |>
group_by(freq_type) |>
mutate(pct = n / sum(n) * 100) |>
ungroup()
ggplot(activity_by_freq, aes(activity_type, pct, fill = freq_type)) +
geom_col(position = "dodge", width = 0.7) +
scale_fill_manual(values = c("One-time" = "#d7191c", "Regular" = "#2c7bb6"),
name = NULL) +
labs(title = "Activity Types by Visitor Frequency",
x = NULL, y = "%") +
theme_bw() +
theme(legend.position = "top")Both groups have nearly identical pass-through rates (~77%), but their stay profiles diverge. One-time visitors skew toward short stays (11.8%): brief stops consistent with wayfinding or quick errands. Returning visitors skew toward medium and long stays (14.9% combined): extended activities like classes, lab work, and recreation. The 2.3× long-stay ratio is the clearest behavioral marker distinguishing campus members from visitors.
This pattern complements rather than duplicates the Revisits analysis: frequency classification tells us who someone is, while stay detection tells us what they do at each location.