How to overlay linear event tables in Python

On this page

Problem statement

Three tables describe the same stretch of highway: traffic volumes between count stations, a functional classification published against a different measure system, and the route's own prefix segments. Answering "what is the volume on the principal-arterial parts of this route?" means overlaying them.

The overlay itself is twenty lines. Everything around it β€” translating one table's measures, keeping the route key, deciding what a piece covered by only two of the three tables means β€” is where the work is.

Quick answer

One function: collect breakpoints, cut, label each piece from every table by midpoint.

import numpy as np, pandas as pd

def overlay(tables, start, end, tol=1e-6):
    """tables: {name: [(from_m, to_m, value), …]}. Returns homogeneous pieces."""
    cuts = {round(start, 6), round(end, 6)}
    for rows in tables.values():
        for a, b, _ in rows:
            if end > a and b > start:
                cuts.add(round(max(a, start), 6))
                cuts.add(round(min(b, end), 6))
    edges = sorted(cuts)

    pieces = []
    for lo, hi in zip(edges[:-1], edges[1:]):
        if hi - lo <= tol:
            continue
        mid = (lo + hi) / 2
        row = {"from_m": lo, "to_m": hi, "length": hi - lo}
        for name, rows in tables.items():
            row[name] = next((v for a, b, v in rows if a <= mid < b), None)
        pieces.append(row)
    return pd.DataFrame(pieces)

demo = overlay({"speed": [(0, 5, 55), (5, 12, 65)],
                "surface": [(0, 3, "asphalt"), (3, 12, "concrete")]}, 0, 12)
print(demo.to_string(index=False))
 from_m  to_m  length  speed  surface
      0     3       3     55  asphalt
      3     5       2     55 concrete
      5    12       7     65 concrete

Two tables with one breakpoint each produce three pieces. That is the whole idea; the rest of this guide is about making it survive real data.

Two event tables with their breakpoints unioned and each piece labelled by midpoint lookup. breakpoints in, pieces out5565asphaltconcretespeedsurface55 Β· asphalt55 Β· concrete65 Β· concretepieceseach piece is labelled by looking up its midpoint in every table spatialworkflow.io
Breakpoints in, pieces out; the midpoint is what does the labelling.

One measure system, clipped spans, rounded breakpoints, midpoints

1. Get every table onto one measure system

Tables published against different measures have to be translated before the breakpoints mean anything. A crosswalk from the route table is the safe way.

2. Clip to the route span

An event table may cover more than the route you are analysing. Clipping at the start makes the accounting at the end interpretable.

3. Union the breakpoints, with rounding

Measures are published to a fixed precision. Round the cuts to that precision or floating point will produce pieces a micron long.

4. Label by midpoint

a <= mid < b on the midpoint of each piece. No boundary case, no half-open-interval argument.

5. Leave missing values missing

A piece no table covers gets None, not a filled-forward value. The count of those is the quality measure.

6. Aggregate with length weights

Once the pieces exist, every summary is a groupby with length as the weight.

A second event table translated from odometer to postmile before the overlay. translate first, then overlay2,285 classpiecesodometer keyscrosswalkroute table164 translatedothers areelsewhereoverlay+ 47 volume events226 pieces69.322 miCounting what survived the translation keeps the overlay’s coverage numbers meaningful spatialworkflow.io
The translation is a separate step, and it is where events get lost.

Three real tables on one route

The data used on this page

Caltrans's state highway network and its event layers β€” traffic counts (Traffic_AADT), bridges (State_Highway_Bridges) and functional classification β€” from the Caltrans ArcGIS feature services. lrs.py packages the download code from the reading guide as a module: query() pages through any layer, routes() returns centrelines with their postmiles, projected by the server to California Albers (EPSG:3310), and point_at_measure() interpolates a position along one line.

%%writefile lrs.py
"""Caltrans linear referencing: the reading guide's fetch and to_frame, as a module."""
import hashlib
import urllib.parse

import geopandas as gpd
import numpy as np
import requests
from shapely.geometry import LineString

SERVICE = "https://caltrans-gis.dot.ca.gov/arcgis/rest/services/CHhighway/{layer}/FeatureServer/0/query"
# Every page this guide requests, as Caltrans served it on 9 October 2026 (the layers are revised as
# counts are updated): FROZEN holds one file per request, named by a SHA-1 of the live URL.
FROZEN = "https://data.spatialworkflow.io/caltrans-2026-10-09/{key}.json"


def query(layer, where, out_sr=4326, want_m=False):
    """Every feature of a Caltrans layer that matches where, following the service's pages."""
    features, offset = [], 0
    while True:
        params = {"where": where, "outFields": "*", "f": "json", "outSR": out_sr,
                  "resultOffset": offset, "resultRecordCount": 2000}
        if want_m:
            params["returnM"] = "true"                   # without this there is no measure
        url = SERVICE.format(layer=layer) + "?" + urllib.parse.urlencode(params)
        key = hashlib.sha1(url.encode()).hexdigest()[:16]   # the live request: requests.get(url)
        page = requests.get(FROZEN.format(key=key), timeout=180).json()
        features += page["features"]
        if not page.get("exceededTransferLimit"):         # the service says when there is more
            return features
        offset += len(page["features"])


def to_frame(features, crs):
    """One row per path, the measures kept as an array per row."""
    rows, geoms, measures = [], [], []
    for f in features:
        for path in f["geometry"]["paths"]:
            a = np.asarray(path, dtype="float64")
            rows.append(f["attributes"])
            geoms.append(LineString(a[:, :2]))
            measures.append(a[:, 2])
    frame = gpd.GeoDataFrame(rows, geometry=geoms, crs=crs)
    frame["m"] = measures
    return frame


def routes(where, out_sr=3310):
    """State highway centrelines with postmiles, projected by the server (California Albers)."""
    frame = to_frame(query("SHN_Lines", where, out_sr=out_sr, want_m=True), out_sr)
    frame["pm_route"] = [f"{r.County}-{r.Route}{r.PMPrefix or ''}{r.PMSuffix or ''}-{r.AlignCode}"
                         for r in frame.itertuples()]         # the full route key
    return frame


def point_at_measure(line, m, target):
    """The position at a measure along one line, or None if the line does not carry it."""
    xy, m = np.asarray(line.coords), np.asarray(m)
    if not m.min() - 1e-9 <= target <= m.max() + 1e-9:
        return None
    distance = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
    order = np.argsort(m)                                # np.interp needs increasing x
    return line.interpolate(float(np.interp(target, m[order], distance[order])))
Writing lrs.py

Example 1 β€” three real tables on one route

from lrs import query, routes

shn = routes("County='SLO' AND Route=101 AND AlignCode='Right'").sort_values("bPM")
cross = shn[["PMPrefix", "bPM", "ePM", "bOdometer", "eOdometer"]].copy()
cross["PMPrefix"] = cross.PMPrefix.fillna("")

def odometer_to_pm(odo):
    for r in cross.itertuples():
        if r.bOdometer <= odo <= r.eOdometer and r.eOdometer > r.bOdometer:
            frac = (odo - r.bOdometer) / (r.eOdometer - r.bOdometer)
            return r.bPM + frac * (r.ePM - r.bPM)
    return None

# volumes, keyed on postmile already
aadt = pd.DataFrame([f["attributes"] for f in
                     query("Traffic_AADT", "CNTY='SLO' AND RTE='101'")])
aadt["PM"] = pd.to_numeric(aadt.PM, errors="coerce")
aadt["AHEAD_AADT"] = pd.to_numeric(aadt.AHEAD_AADT, errors="coerce")
st = (aadt.drop_duplicates(subset=["PM_PFX", "PM", "DESCRIPTION"])
      .sort_values("PM").dropna(subset=["PM"]).reset_index(drop=True))
volume = [(a.PM, b.PM, a.AHEAD_AADT)
          for a, b in zip(st.itertuples(), st.shift(-1).itertuples())
          if not pd.isna(b.PM) and b.PM > a.PM and not pd.isna(a.AHEAD_AADT)]

# functional class, keyed on the odometer: translate it
fclass_raw = []
for f in query("CRS_Functional_Classification", "RouteID='SHS_101._P'", want_m=True):
    for path in f["geometry"]["paths"]:
        fclass_raw.append((path[0][2], path[-1][2], f["attributes"]["F_System"]))
fclass = []
for a, b, v in fclass_raw:
    pa, pb = odometer_to_pm(min(a, b)), odometer_to_pm(max(a, b))
    if pa is not None and pb is not None and pb > pa:
        fclass.append((pa, pb, v))

prefix = [(s.bPM, s.ePM, (s.PMPrefix or "(none)")) for s in shn.itertuples()]

print(f"volume events {len(volume)}, functional-class pieces "
      f"{len(fclass)} of {len(fclass_raw)} translated, route segments {len(prefix)}")
pieces = overlay({"aadt": volume, "fclass": fclass, "prefix": prefix},
                 shn.bPM.min(), shn.ePM.max())
print(f"-> {len(pieces)} homogeneous pieces covering "
      f"{pieces.length.sum():.3f} miles")
print(pieces.head(6).to_string(index=False))
volume events 47, functional-class pieces 164 of 2285 translated, route segments 9
-> 226 homogeneous pieces covering 69.322 miles
  from_m     to_m   length    aadt  fclass prefix
0.000000 0.537963 0.537963 74000.0     NaN (none)
0.537963 0.767961 0.229998 74000.0     2.0 (none)
0.767961 0.813000 0.045039 74000.0     2.0 (none)
0.813000 1.014958 0.201958 65000.0     2.0 (none)
1.014958 4.637377 3.622419 65000.0     2.0 (none)
4.637377 4.807410 0.170033 65000.0     2.0 (none)

164 of the 2,285 functional-class pieces translate: the rest belong to the other thirteen counties on this route, whose odometer values fall outside the crosswalk built from San Luis Obispo's segments. Translating first and printing the survivors is what makes that a number rather than a surprise.

Example 2 β€” what the overlay tells you that the tables do not

have = pieces.notna()
for col in ("aadt", "fclass", "prefix"):
    covered = pieces.loc[have[col], "length"].sum()
    print(f"{col:8} covers {covered:7.3f} mi of {pieces.length.sum():.3f} "
          f"({covered / pieces.length.sum():5.1%}) in {int(have[col].sum()):4} pieces")

complete = pieces[have[["aadt", "fclass", "prefix"]].all(axis=1)]
print(f"\npieces with all three: {len(complete)}, {complete.length.sum():.3f} mi")
partial = pieces[~have[["aadt", "fclass", "prefix"]].all(axis=1)]
print(f"pieces missing something: {len(partial)}, {partial.length.sum():.3f} mi")
print(partial.head(5).to_string(index=False))
aadt     covers  69.322 mi of 69.322 (100.0%) in  226 pieces
fclass   covers  68.753 mi of 69.322 (99.2%) in  224 pieces
prefix   covers  69.155 mi of 69.322 (99.8%) in  220 pieces

pieces with all three: 218, 68.586 mi
pieces missing something: 8, 0.736 mi
 from_m      to_m   length    aadt  fclass prefix
  0.000  0.537963 0.537963 74000.0     NaN (none)
  8.108  8.198000 0.090000 64000.0     2.0    NaN
 19.538 19.548000 0.010000 71000.0     2.0    NaN
 25.817 25.825000 0.008000 69000.0     2.0    NaN
 34.567 34.616000 0.049000 48000.0     2.0    NaN

Example 3 β€” the question the overlay was for

by_class = (complete.assign(vmt=complete.aadt * complete.length)
            .groupby("fclass")
            .agg(miles=("length", "sum"), vmt=("vmt", "sum"),
                 pieces=("length", "size")))
by_class["mean_aadt"] = by_class.vmt / by_class.miles
print(by_class.round(1).to_string())

by_prefix = (complete.assign(vmt=complete.aadt * complete.length)
             .groupby("prefix").agg(miles=("length", "sum"), vmt=("vmt", "sum")))
by_prefix["mean_aadt"] = (by_prefix.vmt / by_prefix.miles).round(0)
print("\n" + by_prefix.round(1).to_string())
        miles        vmt  pieces  mean_aadt
fclass                                     
2.0      68.6  3711037.5     218    54107.9

        miles        vmt  mean_aadt
prefix                             
(none)   58.9  3154631.7    53567.0
R         9.7   556405.8    57392.0

Length-weighted means, computed from pieces that are homogeneous in all three attributes. No piece contributes to two classes, and no class borrows a volume from a stretch that has a different one.

The functional-class table turns out to have a single value along this whole route in this county β€” every piece is class 2, principal arterial β€” so that summary has one row. That is a result, not a wasted overlay: the alternative was assuming it, and the prefix split underneath shows the realigned nine miles carrying slightly more traffic than the rest.

Translate first, and leave gaps empty

Why the translation belongs outside the overlay

A table in another measure system is not an overlay problem, it is a conflation problem, and mixing the two hides where events were lost. Translating first, counting what survived, then overlaying means the overlay's coverage numbers describe the overlay rather than the crosswalk.

Why None is better than a fill

Filling a missing attribute forward is a modelling decision dressed as a data-cleaning step. On a route where the classification stops at a county line, a forward fill extends it silently into a county that classified it differently. Leaving the gap means the summary explicitly excludes it, and the excluded mileage is printed.

Why this scales

The overlay is O(pieces Γ— tables) with a linear scan per lookup, which is fine for a route and slow for a state. For a large run, sort each table once and walk the breakpoints with an index per table, or push the whole thing into a database with range joins β€” the logic is the same and the midpoint rule still does the labelling.

Route mileage described by each event table and by all of them together. how much of the route each table describesvolume events100.0% in 226 piecesfunctional class99.2% in 224 piecesroute segments99.8% in 220 piecesall three present218 pieces035 mi70 mi8 pieces incompleteThe overlay’s real output is this chart, not the pieces spatialworkflow.io
The overlay's real output: how much of the route each table actually describes.

Duplicates, overlaps and dated rows

  • Duplicate rows in an event table produce duplicate breakpoints, which the rounding-plus-set handles.
  • Overlapping rows within one table make the midpoint lookup return the first match; detect them first.
  • Direction-specific tables need the alignment in the key, or they will overlay onto both carriageways.
  • Dates: filter temporal events to one point in time before overlaying, or a work zone from 2019 will cut the route.
  • Very many tables make the piece count multiply; overlay only the tables the question needs.
  • SQL does this with range joins; BETWEEN on the midpoint is the same rule.

FAQ

How do I combine two linear event tables?

Union their breakpoints, cut the route at each one, and label every piece by looking up its midpoint in each table.

What if the tables use different measures?

Translate one into the other's system first, and count how many events survived the translation.

Why use the midpoint?

It is unambiguously inside exactly one interval of each table, so there is no boundary case.

What should a piece with no value contain?

None. Filling it in invents data, and the uncovered mileage is the number worth reporting.

How do I summarise the result?

Group by the attributes you care about and weight by piece length.

Does this work for point events?

Point events do not cut the route; aggregate them onto the pieces afterwards.