MovingPandas TrajectoryCollection patterns
A movingpandas.TrajectoryCollection is the natural container when you move from a single track to a fleet: it wraps a GeoDataFrame of timestamped points, groups them into one Trajectory per identifier, and exposes vectorized operations — split, add speed, generalize, aggregate — across every track at once. Getting it right hinges on two preconditions that the library will not enforce for you: the geometry must be in a projected metric CRS before any speed or length is computed, and the frame must carry a clean datetime index plus a trajectory-id column. This guide covers construction from a GeoDataFrame, the core operations, iteration, conversion back to GeoPandas, and the degenerate cases (empty collection, single-point track) that break naive code.
Why the structure matters
MovingPandas builds directly on the row-per-observation layout described in how to structure trajectory data in GeoPandas: each point is a row, geometry is a Point, and time is the index. A TrajectoryCollection adds one requirement on top — an identifier column so it knows where one track ends and the next begins. If that column is missing or unsorted, the collection either fails to construct or silently stitches two vehicles’ points into one impossible track.
The projected-CRS requirement is the quieter trap. add_speed and add_distance measure geometry in its native units. In a geographic CRS such as EPSG:4326 those units are degrees, so a “speed” comes out in degrees per second — meaningless and latitude-dependent. This is the same discipline the wider trajectory object design patterns reference insists on: project to a UTM zone first, then enrich. Everything downstream, including trajectory segmentation, assumes metric speeds and distances.
Core construction and operation pipeline
- Prepare the GeoDataFrame. Point geometry, a sorted datetime index, a
traj_idcolumn, and a projected metric CRS. Drop identifiers with fewer than two points. - Construct the collection. Pass the
traj_idcolumn so rows group into oneTrajectoryeach; optionally set a minimum length to exclude degenerate tracks. - Enrich and split. Add speed (in m/s, thanks to the projected CRS) and split by observation gaps so each track becomes clean per-trip sub-trajectories.
- Convert back. Iterate the collection for per-track logic, or flatten to a point or line GeoDataFrame for standard GeoPandas analysis.
Production-ready Python implementation
The function below builds a TrajectoryCollection defensively and returns both the enriched collection and a flattened GeoDataFrame. It guards every degenerate case the library punishes at runtime: missing columns, empty input, and single-point tracks.
from __future__ import annotations
import geopandas as gpd
import movingpandas as mpd
import pandas as pd
from movingpandas import ObservationGapSplitter
def build_trajectory_collection(
gdf: gpd.GeoDataFrame,
traj_id_col: str = "traj_id",
time_col: str = "timestamp",
metric_epsg: int = 32633,
gap_split_minutes: float = 5.0,
min_points: int = 2,
) -> tuple[mpd.TrajectoryCollection, gpd.GeoDataFrame]:
"""
Build an enriched TrajectoryCollection from a point GeoDataFrame.
Parameters
----------
gdf : GeoDataFrame
Point geometry, one row per observation. Must contain traj_id_col
and time_col. Any CRS is accepted on input; it is reprojected to a
metric CRS internally because speed/length require metres.
traj_id_col : column that groups rows into individual trajectories.
time_col : timestamp column used as the temporal index.
metric_epsg : EPSG of the projected metric CRS. NEVER compute speed in
EPSG:4326 — degrees per second is not a physical speed.
gap_split_minutes : split a track wherever consecutive points are
farther apart in time than this.
min_points : minimum observations for a usable trajectory (>= 2).
Returns
-------
(TrajectoryCollection, GeoDataFrame)
The split, speed-enriched collection and a flattened point frame.
Raises
------
ValueError
On missing columns, empty input, or no viable trajectory.
"""
required = {traj_id_col, time_col}
missing = required - set(gdf.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if gdf.empty:
raise ValueError("Input GeoDataFrame is empty")
if gdf.geometry.isna().any():
raise ValueError("Null geometries present; clean before building")
work = gdf.copy()
# Project to a metric CRS so add_speed yields m/s, not degrees/s.
if work.crs is None:
raise ValueError("Input GeoDataFrame has no CRS set")
work = work.to_crs(epsg=metric_epsg)
# Drop single-point tracks: a trajectory needs >= 2 points to have a
# direction, length, or speed.
counts = work.groupby(traj_id_col)[time_col].transform("size")
work = work.loc[counts >= min_points].copy()
if work.empty:
raise ValueError(
f"No trajectory has >= {min_points} points after filtering"
)
# MovingPandas indexes on time; ensure a sorted DatetimeIndex.
work[time_col] = pd.to_datetime(work[time_col], utc=True)
work = work.sort_values([traj_id_col, time_col]).set_index(time_col)
collection = mpd.TrajectoryCollection(
work, traj_id_col=traj_id_col, min_length=0
)
if len(collection) == 0:
raise ValueError("TrajectoryCollection is empty after construction")
# Split each track at observation gaps -> clean per-trip segments.
gap = pd.Timedelta(minutes=gap_split_minutes)
collection = ObservationGapSplitter(collection).split(gap=gap)
# Enrich: speed in m/s (valid because the CRS is metric).
collection.add_speed(overwrite=True)
# Flatten back to a point GeoDataFrame for downstream GeoPandas work.
point_gdf = collection.to_point_gdf()
return collection, point_gdf
def summarize_collection(
collection: mpd.TrajectoryCollection,
) -> pd.DataFrame:
"""
Per-trajectory summary: id, point count, length (m), duration, mean
speed (m/s). Iterates the collection; handles an empty collection.
"""
if len(collection) == 0:
return pd.DataFrame(
columns=["traj_id", "n_points", "length_m",
"duration_s", "mean_speed_ms"]
)
rows = []
for traj in collection.trajectories:
df = traj.df
rows.append({
"traj_id": traj.id,
"n_points": len(df),
"length_m": round(traj.get_length(), 1), # metres (metric CRS)
"duration_s": traj.get_duration().total_seconds(),
"mean_speed_ms": round(float(df["speed"].mean()), 2)
if "speed" in df else float("nan"),
})
return pd.DataFrame(rows)
Validation block
Confirm the collection is well-formed and that enrichment produced physical values before handing it downstream.
def validate_collection(
collection: "mpd.TrajectoryCollection",
metric_epsg: int = 32633,
) -> None:
"""
Sanity-check a built TrajectoryCollection.
Raises AssertionError on failure; prints a summary on success.
"""
assert len(collection) > 0, "Collection is empty"
for traj in collection.trajectories:
df = traj.df
# Every trajectory must have at least two points.
assert len(df) >= 2, f"Trajectory {traj.id} has < 2 points"
# CRS must be projected/metric, not geographic degrees.
assert not traj.crs.is_geographic, (
f"Trajectory {traj.id} is in a geographic CRS; "
f"speeds would be degrees/second"
)
if "speed" in df:
# Speeds are m/s and must be finite and non-negative.
s = df["speed"].dropna()
assert (s >= 0).all(), f"Negative speed in {traj.id}"
assert s.max() < 200, (
f"Implausible speed {s.max():.1f} m/s in {traj.id}"
)
print(
f"Validation passed: {len(collection)} trajectories, "
f"metric CRS EPSG:{metric_epsg}."
)
Common mistakes and gotchas
-
Building the collection in a geographic CRS.
add_speedandget_lengththen return degree-based values that silently corrupt every downstream metric. Reproject to a UTM zone (or other metric CRS) before construction, and assertnot crs.is_geographicas the validation block does. -
Leaving single-point tracks in the input. A one-row group has no direction, length, or speed and will either be dropped without warning or raise during enrichment. Filter groups with fewer than two points up front so the failure is explicit and controlled.
-
An unsorted or non-unique time index. MovingPandas relies on a monotonic temporal index per track. Duplicate or out-of-order timestamps produce backward segments and negative speeds. Sort by
traj_idthen time, and de-duplicate beforeset_index. -
Forgetting to split by gaps. Without a gap split, a device that logged two separate trips days apart becomes one trajectory with a straight teleport line between them, inflating length and distorting speed. Apply
ObservationGapSplitterbefore enrichment or aggregation, and chain a stop split when you need trips broken at activity locations for trajectory segmentation. -
Assuming an empty collection is impossible. After aggressive filtering or splitting, a collection can legitimately end up empty. Guard
len(collection) == 0before iterating or exporting, and return an empty typed GeoDataFrame rather than letting a later call raise.
Related
- Trajectory Object Design Patterns — parent reference on trajectory data models and when to reach for an object wrapper.
- How to Structure Trajectory Data in GeoPandas — the row-per-observation GeoDataFrame layout a TrajectoryCollection is built from.
- Trajectory Segmentation — downstream splitting of enriched trajectories into meaningful movement units.
- Spatiotemporal Data Foundations & Structures — the wider foundations section this topic sits within.