How to Aggregate Movement into Flows Between Zones

Problem statement

Individual trajectories are not publishable and rarely answer the question. What people want is flows: how many journeys from here to there, and how did that change.

Aggregating trips into an origin-destination matrix looks like a groupby. Three things make it harder:

  • The zones decide the answer. Change the boundaries and the flows change, without any movement changing. This is the modifiable areal unit problem, and it is unavoidable rather than fixable.
  • Origin and destination are inferred, not observed. They come from your stop-detection thresholds.
  • Small flows are disclosive. A single trip between two rural zones can identify a person.

Quick answer

import geopandas as gpd
import pandas as pd


def od_matrix(trips, zones, zone_id="zone_id", min_count=5):
    """Origin-destination counts, with small cells suppressed."""
    origins = gpd.sjoin(
        gpd.GeoDataFrame(trips, geometry=gpd.points_from_xy(
            trips.start_x, trips.start_y), crs=zones.crs),
        zones[[zone_id, "geometry"]], how="left", predicate="within"
    )[zone_id].rename("origin")

    destinations = gpd.sjoin(
        gpd.GeoDataFrame(trips, geometry=gpd.points_from_xy(
            trips.end_x, trips.end_y), crs=zones.crs),
        zones[[zone_id, "geometry"]], how="left", predicate="within"
    )[zone_id].rename("destination")

    od = pd.concat([origins, destinations], axis=1).dropna()
    counts = od.value_counts().rename("trips").reset_index()

    suppressed = counts[counts["trips"] < min_count]
    print(f"  {len(counts):,} OD pairs, {len(suppressed):,} below {min_count} "
          f"({suppressed['trips'].sum():,} trips) suppressed")
    return counts[counts["trips"] >= min_count]
Trips reduced to origin and destination points, joined to zones, and counted into an origin-destination matrix.
Every step discards information. The matrix is a summary of a summary of an inference.

Step-by-step solution

1. Get trips before you get flows

A flow needs an origin and a destination, which means the trajectory must already be segmented into trips. Those come from stop detection, and stop detection is threshold-driven β€” a radius and a duration.

Change the stop duration from 90 seconds to 15 minutes and journeys that were three trips become one. The flow matrix changes accordingly, with no change to the movement. See How to split a track into trips and stops in Python.

2. Choose zones deliberately, and expect them to drive the result

Zones are the second modelling choice. Fine zones give a sparse matrix with many small, disclosive cells; coarse zones give a dense matrix where most journeys are internal and invisible.

The share of trips that are intra-zonal β€” origin and destination in the same zone β€” is the number to watch. If it is high, the zones are too coarse for the question, because those journeys have disappeared from the matrix entirely.

3. Decide how to handle boundary cases

A point exactly on a boundary, or in no zone at all, needs a rule:

joined = gpd.sjoin(points, zones, how="left", predicate="within")
unmatched = joined["zone_id"].isna().mean()
print(f"{unmatched:.2%} of endpoints fell outside every zone")

within drops points on the boundary line; intersects can match two zones and duplicate the trip. Use within and report the unmatched share; if it is large, the zone layer does not cover the study area.

4. Suppress small cells before publishing

A flow of one or two trips between small zones can identify an individual. Standard practice is to suppress cells below a threshold β€” commonly 5 or 10 β€” and to report how many trips that removes.

Suppression is not just deletion: if you publish row totals and suppressed cells, the missing values can be reconstructed by subtraction. Suppress secondary cells too, or publish the totals only over the retained cells.

5. Report what the matrix excludes

An honest flow matrix comes with four numbers: the trips that were intra-zonal, those with an endpoint outside the zone layer, those in suppressed cells, and the segmentation parameters that produced the trips at all.

The same journeys aggregated into coarse and fine zones, with a large share becoming intra-zonal and invisible under the coarse scheme.
Coarser zones make journeys disappear into their own cell rather than into a flow.

Code examples

Example 1 β€” trips to a flow matrix, with everything reported

import geopandas as gpd
import pandas as pd


def trips_to_flows(trips, zones, zone_id="zone_id", min_count=5,
                   include_internal=False):
    """OD matrix with intra-zonal, unmatched and suppressed all counted."""
    def assign(x_col, y_col, name):
        pts = gpd.GeoDataFrame(
            trips[[x_col, y_col]],
            geometry=gpd.points_from_xy(trips[x_col], trips[y_col]),
            crs=zones.crs)
        return gpd.sjoin(pts, zones[[zone_id, "geometry"]], how="left",
                         predicate="within")[zone_id].rename(name)

    od = pd.concat([assign("start_x", "start_y", "origin"),
                    assign("end_x", "end_y", "destination")], axis=1)

    unmatched = od.isna().any(axis=1)
    od = od[~unmatched]
    internal = od["origin"] == od["destination"]

    print(f"  {len(trips):,} trips")
    print(f"    {int(unmatched.sum()):,} ({unmatched.mean():.1%}) had an "
          f"endpoint outside every zone")
    print(f"    {int(internal.sum()):,} ({internal.mean():.1%}) were intra-zonal")

    if not include_internal:
        od = od[~internal]

    counts = (od.groupby(["origin", "destination"]).size()
                .rename("trips").reset_index())
    small = counts["trips"] < min_count
    print(f"    {len(counts):,} OD pairs, {int(small.sum()):,} suppressed "
          f"below {min_count} ({int(counts.loc[small, 'trips'].sum()):,} trips)")

    return counts[~small].sort_values("trips", ascending=False)

Example 2 β€” flows as drawable geometry

import geopandas as gpd
from shapely.geometry import LineString


def flows_to_lines(counts, zones, zone_id="zone_id", min_trips=None):
    """Desire lines between zone centroids, widths proportional to volume."""
    centroids = zones.set_index(zone_id).geometry.representative_point()

    rows = []
    for r in counts.itertuples():
        if min_trips and r.trips < min_trips:
            continue
        a, b = centroids.get(r.origin), centroids.get(r.destination)
        if a is None or b is None:
            continue
        rows.append({"origin": r.origin, "destination": r.destination,
                     "trips": r.trips,
                     "geometry": LineString([a, b])})

    lines = gpd.GeoDataFrame(rows, crs=zones.crs)
    lines["width"] = 0.5 + 4 * (lines["trips"] / lines["trips"].max()) ** 0.5
    print(f"  {len(lines):,} desire lines, "
          f"{lines['trips'].min()}–{lines['trips'].max()} trips")
    return lines

representative_point() rather than centroid matters for awkward zones β€” a crescent-shaped or multipart zone can have a centroid outside itself, and a desire line starting in the sea is a recurring embarrassment.

Note that a desire line is a straight line between centroids. It is not a route, and it should not be drawn in a style that suggests one.

Example 3 β€” checking the zone scheme before committing

import numpy as np
import geopandas as gpd


def zone_diagnostics(trips, zone_schemes, zone_id="zone_id"):
    """Compare candidate zone layers on what each one hides."""
    for name, zones in zone_schemes.items():
        counts = trips_to_flows(trips, zones, zone_id=zone_id,
                                include_internal=True)
        internal = counts[counts["origin"] == counts["destination"]]["trips"].sum()
        total = counts["trips"].sum()
        pairs = len(counts)
        small = (counts["trips"] < 5).mean()

        print(f"  {name:20} {len(zones):5,} zones  "
              f"{pairs:6,} OD pairs  "
              f"intra-zonal {internal / total:5.1%}  "
              f"cells under 5: {small:5.1%}")

The trade-off is visible in one table. Fine zones push the intra-zonal share down and the disclosive-cell share up; coarse zones do the reverse. Pick the scheme where both are tolerable, and say why.

Explanation

Why the zones are the analysis

The modifiable areal unit problem says results computed over areal units depend on those units, in two ways: the scale (how big) and the zoning (where the lines fall). Both apply here.

Two flow matrices over the same trips with different zone layers are not comparable, and neither is more correct. This is not a technical limitation to be engineered around; it is a property of aggregating continuous movement into discrete containers.

The practical consequence is that the zone layer must be published with the matrix, and that comparisons across studies with different zones are invalid unless the trips are re-aggregated.

Why intra-zonal trips are the hidden variable

A trip that starts and ends in the same zone contributes nothing to any flow. With coarse zones, that can be most of the trips β€” short local journeys, which are the majority of journeys in most datasets.

A matrix built from coarse zones therefore describes a minority of the movement while looking like a description of all of it. Reporting the intra-zonal share is the fix, and it is rarely done.

Why origin and destination are inferences

An "origin" is where a trip started, and a trip started where a stop ended. Stops come from a radius and a duration you chose.

So the flow matrix inherits every segmentation parameter. A shorter stop duration splits journeys at traffic lights and produces many short trips within one zone; a longer one merges a shopping trip and the drive home into one journey with the wrong destination.

Publishing the segmentation parameters alongside the matrix is not optional metadata. It is part of the definition of what was counted.

Why suppression needs care

Suppressing cells below a threshold is necessary and insufficient. If a row total is published alongside four visible cells and one suppressed one, the suppressed value is the total minus the four.

The standard remedy is secondary suppression: hide a second cell in every row and column that contains a suppressed cell, chosen so the arithmetic no longer determines the hidden values. Simpler and safer for most purposes is to publish totals computed only over retained cells, and to say so.

A suppressed flow cell recovered by subtracting visible cells from a published row total, and secondary suppression preventing it.
Hiding one cell and publishing the row total hides nothing at all.

Edge cases or notes

  • Segment trips before aggregating. Flows inherit the stop-detection thresholds.
  • Use within, not intersects, or a boundary point duplicates the trip.
  • Report the unmatched share. A large one means the zone layer does not cover the data.
  • Report the intra-zonal share. Those journeys are invisible in the matrix.
  • Suppress small cells, and consider secondary suppression so totals do not reveal them.
  • Use representative_point() for desire lines, not centroid.
  • A desire line is not a route. Do not style it like one.
  • Matrices with different zones are not comparable. Re-aggregate from trips instead.

FAQ

How do I build an origin-destination matrix in Python?

Segment trajectories into trips, spatially join the start and end points to zones, and count the resulting pairs. Then suppress small cells before publishing.

Why do my flows change when I change the zones?

Because aggregating continuous movement into discrete zones is scale- and boundary-dependent β€” the modifiable areal unit problem. Neither result is more correct; publish the zone layer with the matrix.

What are intra-zonal trips?

Journeys that start and end in the same zone. They contribute to no flow, and with coarse zones they can be most of the trips β€” always report their share.

Should I use within or intersects for the join?

within. intersects can match a boundary point to two zones and duplicate the trip.

What is a safe suppression threshold?

Commonly 5 or 10 trips per cell. Also consider secondary suppression, since a published row total can reveal a single suppressed cell by subtraction.

Is a desire line a route?

No. It is a straight line between zone representative points, showing volume rather than path. Styling it like a route misleads readers.

How do I compare two flow matrices?

Only if they use the same zones and the same segmentation parameters. Otherwise re-aggregate both from the underlying trips.