Skip to content

Open in Colab Launch Binder

Build the trajectory hierarchy: stops, trips and tours

Question. From raw GPS fixes, how do I get to the units researchers actually reason about: stops, trips and tours, and which stops are home or work?

You will get five linked tables: staypoints (stops), user locations (places), triplegs (movement between stops), trips and tours, plus a transport-mode split, and the explicit constructors behind each step.

Prerequisites. Clean noisy GPS data and find stops.

Data. GeoLife: the first 25 trips of users 000, 001 and 002 (about 4 MB read from the 313 MB archive, about 105,000 fixes), read with fastmob.data.load_geolife. Timestamps are UTC. The notebook stops if the download fails.

%pip install -q 'fastmob>=0.3.4' pandas matplotlib

Load and clean the data

The hierarchy starts from cleaned fixes. We use the same 200 km/h speed filter as in the previous notebook.

import fastmob
from fastmob.data import load_geolife

fixes = load_geolife(users=["000", "001", "002"], max_trips_per_user=25).to_pandas()

fixes = fastmob.filter(fixes, max_speed_kmh=200)
print(len(fixes), "fixes after filtering")
105287 fixes after filtering

Positionfixes to staypoints

Positionfixes is the typed table of raw fixes. generate_staypoints detects stops with the same parameters as stay_locations: here at least 20 minutes within 200 m, and a 30-minute silence limit (a stay in progress when the data go quiet for longer is dropped). The result is a Staypoints table with a started_at and finished_at per stop.

positionfixes = fastmob.Positionfixes(fixes)
staypoints = positionfixes.generate_staypoints(
    minutes_for_a_stop=20, spatial_radius_km=0.2, no_data_for_minutes=30
)

staypoints.to_pandas().head()
staypoint_id lat lng started_at uid finished_at
0 0 40.008994 116.321445 2008-10-23 09:44:50 000 2008-10-23 10:09:16
1 1 40.007759 116.319107 2008-10-23 10:09:16 000 2008-10-23 10:31:25
2 2 40.008899 116.321856 2008-10-23 10:44:31 000 2008-10-23 11:10:42
3 3 40.011588 116.296897 2008-10-28 00:38:26 000 2008-10-28 01:12:06
4 4 40.011368 116.296882 2008-11-04 00:07:03 000 2008-11-04 01:12:53

Each row is one stop: where (lat, lng), when it started and finished, and which user. In the rows shown the stops last from about 20 minutes to about an hour. Stays of this kind cannot be told apart from a device left switched on at home, which is why the silence limit matters.

Which stops are activities?

create_activity_flag marks a staypoint as an activity when it lasts at least time_threshold_min minutes. Shorter stops, such as waiting for a bus, stay in the table but are flagged False, so later steps can ignore them.

staypoints = staypoints.create_activity_flag(
    method="time_threshold", time_threshold_min=30
)

staypoints.to_pandas()["activity"].value_counts()
activity
False    43
True     29
Name: count, dtype: int64

About 40% of the stops (29 of 72) last at least 30 minutes and are flagged as activities. The rest are shorter pauses. Changing time_threshold_min moves this share directly, so choose it from what counts as an activity in your study.

Staypoints to places

generate_user_locations groups each person's stops into recurring locations (an H3 grid of roughly epsilon_km; location ids are per person). It returns the location table and the staypoints with a location_id. identify then labels each person's locations home, work or other with a dwell-time rule: the place where the person spends the most time between 22:00 and 06:00 is home, and the other place with the most time on weekdays between 08:00 and 18:00 is work. The hours are UTC here, so for GeoLife in Beijing (UTC+8) the night window is 06:00 to 14:00 local time and the work window is 16:00 to 02:00 local time.

locations, staypoints = staypoints.generate_user_locations(epsilon_km=0.2)
locations = locations.identify(staypoints)

locations.to_pandas().head(8)
uid location_id center_lat center_lng n_staypoints purpose
0 000 0 40.008884 116.320810 4 work
1 000 1 40.011469 116.296872 3 home
2 000 2 40.006735 116.297058 1 other
3 000 3 39.958511 116.316202 1 other
4 000 4 39.994547 116.326321 1 other
5 001 0 39.980998 116.311191 4 home
6 001 5 40.001843 116.170976 1 other
7 001 6 40.001869 116.166960 1 other

Each row is a place of one user: its centre, how many stops fall in it and its label. User 000 gets one work and one home place and several visited once (other); user 001 has a home place with four stops. The label of a place that was visited once or twice is weak evidence. Location ids restart for each user, so location_id 0 of user 000 is not the same place as 0 of user 001.

Movement between stops: triplegs and transport mode

A tripleg is one continuous movement between two stops. generate_triplegs derives them from the fixes and the stops, splitting also at recording gaps longer than gap_threshold_min. predict_transport_mode assigns a coarse mode from each tripleg's mean speed (slow_mobility below 15 km/h, motorized_mobility from 15 to 100 km/h, fast_mobility above), and calculate_modal_split counts them.

triplegs = positionfixes.generate_triplegs(staypoints, gap_threshold_min=15)
triplegs = triplegs.predict_transport_mode(method="simple-coarse")
split = triplegs.calculate_modal_split()

triplegs.to_pandas().head()
uid tripleg_id started_at finished_at length_km duration_s mean_speed_kmh mode
0 000 0 2008-10-23 02:53:04 2008-10-23 09:44:50 11.125883 24706.0 1.621192 slow_mobility
1 000 2 2008-10-23 10:31:25 2008-10-23 10:44:31 0.235488 786.0 1.078571 slow_mobility
2 000 4 2008-10-23 11:10:42 2008-10-28 00:38:26 43.853683 394064.0 0.400628 slow_mobility
3 000 6 2008-10-28 01:12:06 2008-11-04 00:07:03 24.460027 600897.0 0.146541 slow_mobility
4 000 8 2008-11-04 01:12:53 2008-11-04 02:04:33 2.272649 3100.0 2.639205 slow_mobility

Each row is one movement segment with its start, end, length, duration, mean speed and mode. Read the durations before trusting the modes. Several of the first triplegs last hours or days (the third lasts about four and a half days) with mean speeds under 3 km/h: the 25 trips per user are separate recordings, and the movement between two stops can include long gaps with no data. A mean speed over such a tripleg is meaningless. The next cell counts them.

split
mode value
0 slow_mobility 61
1 motorized_mobility 4
2 fast_mobility 1

Almost all triplegs (61 of 66) fall in the slowest class, which is a symptom of the gap problem above rather than a finding about how these people travel. Treat the modal split as a demonstration of the interface until triplegs are restricted to continuous recordings.

tl = triplegs.to_pandas()
long_ones = tl["duration_s"] > 6 * 3600
print(
    f"{long_ones.sum()} of {len(tl)} triplegs last more than 6 hours (longest {tl['duration_s'].max() / 86400:.1f} days)"
)
print(
    f"median mean speed of the others: {tl.loc[~long_ones, 'mean_speed_kmh'].median():.1f} km/h"
)
37 of 66 triplegs last more than 6 hours (longest 7.0 days)
median mean speed of the others: 5.2 km/h

More than half of the triplegs (37 of 66) last over six hours and the longest lasts seven days, so they contain gaps between separate recordings. The remaining ones have a median mean speed of about 5 km/h, a walking pace. For a real analysis, drop or split long triplegs (or use continuous data) before classifying modes.

Trips and tours

A trip joins consecutive triplegs that are separated only by non-activity stops. A tour is a sequence of trips that leaves a location and returns to it (for example home to work to shop and back home). generate_trips needs the staypoints; generate_tours additionally needs staypoints that carry location ids.

trips = triplegs.generate_trips(staypoints, gap_threshold_min=15)
tours = trips.generate_tours(staypoints)

print(
    len(staypoints),
    "staypoints,",
    len(triplegs),
    "triplegs,",
    len(trips),
    "trips,",
    len(tours),
    "tours",
)
tours.to_pandas().head()
72 staypoints, 66 triplegs, 29 trips, 9 tours
uid tour_id started_at finished_at location_id journey
0 000 0 2008-10-28 01:12:06 2008-11-04 00:07:03 1 [1]
1 000 1 2008-11-04 01:12:53 2008-11-11 00:17:04 1 [2]
2 001 2 2008-10-24 06:25:12 2008-10-30 05:31:56 0 [5, 6, 7]
3 001 3 2008-11-01 05:43:48 2008-11-05 18:26:22 1 [9, 10, 11, 12]
4 002 4 2008-10-23 16:23:22 2008-10-24 14:11:21 0 [15, 16]

Each tour starts and ends at the same location of one user and lists the trips (journey) in between. Here nine tours come from 29 trips. The tours span days because the stops they connect are days apart, again a consequence of recording in separate sessions rather than continuously.

The same pipeline, step by step

Every generate_* method has a class-level equivalent that you can call on its own, which is useful when you already have some of the tables: Triplegs.from_positionfixes, Trips.from_triplegs and Tours.from_trips. The results are identical to the chained calls above.

triplegs_b = fastmob.Triplegs.from_positionfixes(
    positionfixes, staypoints, gap_threshold_min=15
)
trips_b = fastmob.Trips.from_triplegs(triplegs_b, staypoints, gap_threshold_min=15)
tours_b = fastmob.Tours.from_trips(trips_b, staypoints)

print(len(triplegs_b), len(trips_b), len(tours_b))
66 29 9

The counts match the chained calls: 66 triplegs, 29 trips and 9 tours.

Functional forms

The labelling and classification steps are also plain functions that take any of the tables: identify_locations, create_activity_flag, predict_transport_mode and calculate_modal_split. They are the right choice when your data are ordinary dataframes and you do not want the typed classes.

from fastmob.preprocessing import (
    calculate_modal_split,
    create_activity_flag,
    identify_locations,
    predict_transport_mode,
)

flagged = create_activity_flag(
    staypoints, method="time_threshold", time_threshold_min=30
)
labelled = identify_locations(locations, staypoints, method="freq")
moded = predict_transport_mode(triplegs_b, method="simple-coarse")
print(
    flagged.to_pandas()["activity"].sum(),
    "of",
    len(flagged.to_pandas()),
    "stops are activities at 30 minutes",
)
print(labelled.to_pandas()["purpose"].value_counts().to_dict())
split_b = calculate_modal_split(moded, metric="count", normalize=True)

split_b
29 of 72 stops are activities at 30 minutes
{'other': 28, 'work': 3, 'home': 3}
mode value
0 slow_mobility 0.924242
1 motorized_mobility 0.060606
2 fast_mobility 0.015152

The functional forms give the same kind of result as the methods. The modal split is normalised here, so the shares sum to 1: 92% slow, 6% motorised and 2% fast, matching the counts above (61, 4 and 1 of 66). At the 30-minute threshold 29 of the 72 stops are activities, and identify_locations labels 3 locations home, 3 work and 28 other across the three people.

City-wide places instead of per-person places

Per-person location ids cannot be compared across people. generate_global_locations instead assigns every staypoint to a cell of an H3 grid shared by everyone (h3_resolution=9 is a cell about 350 m across, with an edge of about 170 m). associate_global_locations attaches such a catalogue to staypoints, and require_global and model_input check that a catalogue is global before it is used as input to a model.

global_locations, global_stays = staypoints.generate_global_locations(h3_resolution=9)
attached = global_stays.associate_global_locations(global_locations)
model_ready = global_locations.model_input()

print(
    len(global_locations),
    "shared places against",
    len(locations),
    "per-user places; stops use",
    attached.to_pandas()["location_id"].nunique(),
    "of them;",
    type(model_ready).__name__,
)
global_locations.require_global().head()
43 shared places against 34 per-user places; stops use 43 of them; ModelLocations





┌───────────────────────────────────────────────────────────┐
|                    Narwhals DataFrame                     |
|-----------------------------------------------------------|
|          location_id  center_lat  center_lng  n_staypoints|
|0  617866870557245439   40.008772  116.322243             2|
|1  617866870562226175   40.008805  116.318983             1|
|2  617866869937537023   40.011452  116.298205             3|
|3  617866869940420607   40.006579  116.297369             1|
|4  617866870340976639   39.957603  116.315075             1|
└───────────────────────────────────────────────────────────┘

The shared grid has 43 places for 72 stops, against 34 per-user places above: with a shared grid two users who stop in the same cell share one place, and a stop near a cell edge can split from a stop 50 m away. require_global returned the catalogue, which shows it is a valid global one, and model_input wrapped it for use with the generation models.

Checking inputs

Staypoints.validate, Staypoints.coerce, Staypoints.resolve_dataframe and Locations.validate_staypoint_assignments are the checks the methods above run internally. Call them directly to get a clear error before a long pipeline, for example when your staypoints come from another tool.

fastmob.Staypoints.validate(staypoints.to_pandas(), "started_at", "finished_at")
coerced = fastmob.Staypoints.coerce(staypoints)
resolved = fastmob.Staypoints.resolve_dataframe(staypoints)
locations.validate_staypoint_assignments(
    staypoints.to_native(), user_id_col="uid", location_id_col="location_id"
)

print(type(coerced).__name__, "- resolved columns:", resolved[1:])
Staypoints - resolved columns: ('uid', 'started_at', 'lat', 'lng')

None of the checks raised, so the staypoints and the location assignments are consistent. The validators return nothing on success and raise a ValueError naming the missing or inconsistent column otherwise.

Interpretation and limits

  • Every level is defined by thresholds: stop duration and radius, the silence limit, the activity threshold, the cluster size and the gap between triplegs. Report them. Changing any one changes the levels above it.
  • Home and work come from a dwell-time rule on UTC hours. They are hypotheses, not ground truth, and wrong whenever the time zone is not UTC: for Beijing the work window falls in the evening and night. Convert times to local first (see Fill gaps, smooth, compare and convert trajectories).
  • The transport mode is a speed class (slow_mobility, motorized_mobility, fast_mobility), not a detected mode of travel. A tripleg's mean speed includes any slow stretches within it.
  • This is 3 of 182 GeoLife users with 25 trips each; the counts describe the subset.

Next steps

generate_staypoints · generate_triplegs · create_activity_flag · generate_user_locations · generate_global_locations · associate_global_locations · validate · coerce · resolve_dataframe · identify · require_global · model_input · validate_staypoint_assignments · from_positionfixes · predict_transport_mode · calculate_modal_split · generate_trips · from_triplegs · generate_tours · from_trips · identify_locations · create_activity_flag · predict_transport_mode · calculate_modal_split