Using STR-packed R-trees for fast trajectory lookups
For a candidate set that is built once and queried many times — a day’s trips, a zone layer, a road network extract — a Sort-Tile-Recursive packed R-tree is the right structure: it builds in a single sort-based pass, produces tighter node rectangles than incremental insertion, and answers a bounding-box query in microseconds. Shapely 2’s STRtree is exactly this, and the whole technique is knowing when it applies and when it does not.
Why this happens
An R-tree built by inserting one geometry at a time has to decide, for each insertion, which subtree to descend and when to split a full node. Those decisions are made without knowing what comes next, so the resulting node rectangles overlap more than necessary, and overlapping rectangles mean a query descends several branches where one would do.
Bulk loading removes the guesswork. Sort-Tile-Recursive sorts the geometries by one axis, slices them into vertical tiles, sorts each tile by the other axis, and packs leaves in that order. Because it sees the whole set, the leaves are compact and the overlap between sibling rectangles is close to minimal. The cost is that the tree is immutable: adding a geometry means rebuilding. For trajectory work that is usually fine, because the candidate set for a query is a static extract — which is the same observation that makes the pruning funnel in spatiotemporal query indexing work.
Core pipeline
- Assemble the static candidate set — one geometry per trip, zone or road link, with a stable index back to the source rows.
- Build the tree once with
shapely.STRtree, which packs on construction. - Query with the coarse predicate to get candidate indices, then apply the exact predicate only to those.
- Rebuild rather than update when the set changes, and measure the rebuild so you know the refresh cost.
Production-ready Python implementation
import numpy as np
import geopandas as gpd
from shapely import STRtree, box
from shapely.geometry.base import BaseGeometry
class TripIndex:
"""
A packed R-tree over trip envelopes, with the exact-predicate step.
The tree answers "which trips MIGHT intersect this?" in microseconds.
The exact test then runs on tens of candidates rather than millions of
rows — that two-stage shape is the entire point.
"""
def __init__(self, trips: gpd.GeoDataFrame, geom_col: str = "geometry"):
if trips.empty:
raise ValueError("Cannot index an empty trip set.")
if trips.crs is None or trips.crs.is_geographic:
raise ValueError(
f"trips must be in a PROJECTED metric CRS; got {trips.crs}. "
"A tree built in degrees returns candidates whose distances "
"are latitude-dependent and cannot be compared to metres."
)
if trips[geom_col].isna().any():
raise ValueError("Null geometries cannot be indexed; drop them first.")
self.trips = trips.reset_index(drop=False) # keep the original ids
self.crs = trips.crs
# STRtree packs at construction — there is no incremental insert.
self.tree = STRtree(self.trips[geom_col].values)
def candidates(self, query: BaseGeometry, buffer_m: float = 0.0) -> np.ndarray:
"""Positional indices whose ENVELOPE intersects the query envelope."""
g = query.buffer(buffer_m) if buffer_m > 0 else query
return self.tree.query(g)
def intersects(self, query: BaseGeometry, buffer_m: float = 0.0) -> gpd.GeoDataFrame:
"""Rows that actually intersect — coarse filter, then exact predicate."""
idx = self.candidates(query, buffer_m)
if idx.size == 0:
return self.trips.iloc[[]]
cand = self.trips.iloc[idx]
g = query.buffer(buffer_m) if buffer_m > 0 else query
# The exact test is the expensive one; it now runs on len(idx) rows.
return cand[cand.geometry.intersects(g)]
def in_window(self, bbox: tuple, t0, t1,
start_col: str = "t_start", end_col: str = "t_end") -> gpd.GeoDataFrame:
"""
Space AND time. The tree only knows geometry, so the temporal
predicate is applied to the candidate set — which is exactly the
right order, because the spatial filter is the selective one here.
"""
for c in (start_col, end_col):
if c not in self.trips.columns:
raise ValueError(f"Missing temporal column: {c}")
hits = self.intersects(box(*bbox))
overlaps = (hits[start_col] < t1) & (hits[end_col] > t0)
return hits[overlaps]
def build_and_time(trips: gpd.GeoDataFrame) -> dict:
"""Report build cost and tree quality so a rebuild schedule can be set."""
import time
t0 = time.perf_counter()
idx = TripIndex(trips)
build_s = time.perf_counter() - t0
# Mean envelope area relative to the total extent is a proxy for how
# selective the tree can be: long diagonal trips have huge envelopes and
# a tree over them prunes poorly no matter how well it is packed.
env = trips.geometry.envelope.area
total = trips.total_bounds
extent = (total[2] - total[0]) * (total[3] - total[1])
return {
"n": len(trips),
"build_s": round(build_s, 3),
"mean_envelope_frac": float((env / max(extent, 1e-9)).mean()),
"index": idx,
}
Validation block
def validate_tree(stats: dict, sample_query, expected_max_candidates: int = 500) -> None:
"""Three checks that separate a useful tree from an expensive one."""
idx = stats["index"]
# 1. Envelope bloat. If the average trip envelope covers a large share of
# the extent, every query hits most of the tree and the index cannot
# help — split long trips into sub-trips before indexing.
assert stats["mean_envelope_frac"] < 0.05, (
f"mean envelope is {stats['mean_envelope_frac']:.1%} of the extent — "
"long diagonal trips defeat any R-tree; index sub-trips instead"
)
# 2. Candidate count. A coarse filter that returns thousands has not
# filtered; the exact predicate then dominates the runtime.
n_cand = len(idx.candidates(sample_query))
assert n_cand <= expected_max_candidates, (
f"{n_cand} candidates for one query — check the envelope bloat above"
)
# 3. Precision of the coarse filter: how many candidates survive the
# exact test. Below ~10% means the envelopes are a poor proxy.
n_exact = len(idx.intersects(sample_query))
precision = n_exact / max(n_cand, 1)
print(f"OK — build {stats['build_s']}s, {n_cand} candidates, "
f"{precision:.0%} survive the exact test")
Common mistakes and gotchas
-
Building the tree in degrees. Candidates come back, distances do not mean metres, and a buffered query is an ellipse whose size varies with latitude. Assert a projected CRS at construction.
-
Indexing whole long trips. A cross-city trip has an envelope covering the city, so every query matches it. Split into sub-trips of a few kilometres before indexing; the extra rows cost far less than the false candidates.
-
Skipping the exact predicate.
STRtree.queryreturns envelope intersections, not geometric ones. Treating the result as the answer over-reports, sometimes by a factor of three. -
Rebuilding per query. Construction is a full sort; doing it inside a loop turns an index into an overhead. Build once, reuse, and cache by extract version.
-
Expecting incremental updates. A packed tree is immutable by design. If the candidate set changes constantly, a database index — see indexing trajectories in PostGIS — is the better tool.
-
Ignoring the temporal dimension. The tree knows nothing about time. Filter the candidate set on time afterwards, or partition the trees by day so the temporal filter happens at extract level.
FAQ
How large a set is worth indexing?
Above roughly a thousand geometries the tree pays for itself immediately; below a few hundred a vectorised bounding-box comparison in NumPy is usually faster because it avoids the tree traversal overhead. The crossover moves with geometry complexity — for complex polygons the tree wins much earlier, because it avoids evaluating the exact predicate.
Does node capacity matter?
Less than people expect. Shapely’s default is well chosen; larger nodes build slightly faster and query slightly slower, and the effect is a few per cent either way. Envelope bloat, by contrast, changes candidate counts by orders of magnitude — spend the effort there.
Can I use it for nearest-neighbour queries?
Yes, STRtree.nearest is supported and is the right tool for snapping fixes to the nearest road link. Be aware it returns exactly one neighbour by default; for map matching you usually want all links within a radius, which is a buffered query followed by an exact distance filter.
Related
- Spatiotemporal Query Indexing — the parent reference and where this structure fits in the funnel.
- Indexing Trajectories in PostGIS for Space-Time Range Queries — the persistent, updatable alternative.
- Optimizing Spatial Joins for Trajectory-to-Zone Matching — the same two-stage pattern applied to a join.
- Map-Matching to Road Networks — the nearest-link query this index makes cheap.
- Building Origin-Destination Matrices from Trajectory Data — a consumer whose runtime is dominated by point-in-zone lookups.