Dynamic Segmentation Explained

On this page

Problem statement

A road has traffic volumes from 48 count stations, a pavement type that changes every few miles, a truck designation that covers whole counties, and a speed limit that changes at town boundaries. None of those tables shares a segmentation with any other, and none of them should: each one changes where its own thing changes.

Dynamic segmentation is the operation that combines them without pre-cutting anything. Take every measure at which any attribute changes, cut the route there, and every resulting piece is homogeneous in all of them. The segments are produced on demand from the events, which is where the "dynamic" comes from β€” and it is why the event tables can be edited independently forever.

Quick answer

Collect the breakpoints, cut once, label each piece:

import numpy as np, pandas as pd
from lrs import query, routes

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")
stations = (aadt.drop_duplicates(subset=["PM_PFX", "PM", "DESCRIPTION"])
            .sort_values("PM").reset_index(drop=True))

# Point stations describe the road ahead of them: that is a line event.
volume = pd.DataFrame({"from_pm": stations.PM[:-1].values,
                       "to_pm": stations.PM[1:].values,
                       "aadt": stations.AHEAD_AADT[:-1].values,
                       "at": stations.DESCRIPTION[:-1].values})
volume["len_mi"] = volume.to_pm - volume.from_pm
volume = volume[volume.len_mi > 0].dropna(subset=["aadt"])

print(f"{len(stations)} count stations -> {len(volume)} volume events "
      f"covering {volume.len_mi.sum():.3f} miles")
print(volume.nlargest(3, "aadt")[["from_pm", "to_pm", "aadt", "len_mi", "at"]]
      .to_string(index=False))
48 count stations -> 47 volume events covering 69.322 miles
 from_pm  to_pm    aadt  len_mi                                        at
  15.579 16.398 84000.0   0.819                   PISMO BEACH, PISMO OAKS
  14.613 15.579 75000.0   0.966                PISMO BEACH, OAK PARK ROAD
   0.000  0.813 74000.0   0.813 SANTA BARBARA/SAN LUIS OBISPO COUNTY LINE

The count stations are points; the quantity they describe is a line event. Getting that conversion right is most of the work, and the BACK_/AHEAD_ column pair exists precisely because the publisher knew it.

Three event tables over one route, with every breakpoint projected down into homogeneous segments. three tables, one route, the segments that fall outvolumeclasssurfacesegments7 pieces, each homogeneous in all three tables spatialworkflow.io
Three tables, one route, and the segments that fall out of overlaying them.

One route key, line events, breakpoints, midpoint labels

1. Get every event onto the same route key

County, route, prefix and alignment. Events from tables that key differently have to be reconciled first, or the overlay silently drops them.

2. Turn point events into line events where that is what they mean

A count station, a change of speed limit and a pavement transition all describe the road from that measure. A bridge or a crash does not.

3. Collect the breakpoints

The union of every from-measure and to-measure in every table, sorted. That list is the segmentation.

4. Label each piece by lookup, not by join

For each piece, take its midpoint measure and ask each event table what it says there. Midpoints avoid every boundary ambiguity.

5. Check for pieces no table covers

A piece with no volume, no class and no designation is either a gap in the events or a gap in the route numbering. Both are worth knowing about.

6. Aggregate with length weights

Segments are different lengths, so an unweighted mean across them is not the mean along the road.

Unweighted and length-weighted mean AADT across 47 volume segments, and the vehicle-miles. averaging 47 segments of different lengthsunweighted mean54,617 β€” an average of averageslength-weighted mean54,261 along the roadlongest segment (7.5 mi)48,000 over 7.503 mishortest segment (0.2 mi)42,000 over 0.218 mi030k60k3.72 m vehicle-miles/dayUS 101 in San Luis Obispo county; publish the vehicle-miles, not the average spatialworkflow.io
The same 47 segments, averaged two ways, and the quantity that actually means something.

An overlay of Caltrans event tables

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 β€” the overlay

shn = routes("County='SLO' AND Route=101 AND AlignCode='Right'").sort_values("bPM")
spans = [(s.bPM, s.ePM, s.PMPrefix or "") for s in shn.itertuples()]

breaks = sorted(set([b for s in spans for b in s[:2]] +
                    list(volume.from_pm) + list(volume.to_pm)))

pieces = []
for lo, hi in zip(breaks[:-1], breaks[1:]):
    if hi - lo < 1e-6:
        continue
    mid = (lo + hi) / 2
    prefix = next((p for b, e, p in spans if b <= mid <= e), None)
    vol = next((v.aadt for v in volume.itertuples() if v.from_pm <= mid < v.to_pm), None)
    pieces.append({"from": round(lo, 3), "to": round(hi, 3), "mi": round(hi - lo, 3),
                   "prefix": "(none)" if prefix == "" else prefix, "aadt": vol})
seg = pd.DataFrame(pieces)

print(f"{len(volume)} volume events + {len(spans)} route segments "
      f"-> {len(seg)} homogeneous pieces")
print(seg.head(8).to_string(index=False))
47 volume events + 9 route segments -> 61 homogeneous pieces
  from     to    mi prefix    aadt
 0.000  0.813 0.813 (none) 74000.0
 0.813  4.851 4.038 (none) 65000.0
 4.851  6.406 1.555 (none) 65000.0
 6.406  7.851 1.445 (none) 65000.0
 7.851  8.108 0.257 (none) 64000.0
 8.108  8.198 0.090    NaN 64000.0
 8.198 12.521 4.323 (none) 64000.0
12.521 13.173 0.652 (none) 60000.0

Look at the 0.090-mile piece at 8.108. It has a volume and no route segment: the count table covers ground that the postmile numbering skips over at an equation. Dynamic segmentation surfaced a data disagreement that neither table shows on its own.

Example 2 β€” what each attribute contributes to the cut

sets = {"volume events": set(np.round(list(volume.from_pm) + list(volume.to_pm), 6)),
        "route segments": set(np.round([b for s in spans for b in s[:2]], 6))}
for name, brk in sets.items():
    print(f"{name:16} {len(brk):3} breakpoints")
both = sets["volume events"] & sets["route segments"]
print(f"shared          {len(both):3}")
print(f"union           {len(set().union(*sets.values())):3} -> {len(seg)} segments")

covered = seg[seg.aadt.notna()]
print(f"\npieces with a volume: {len(covered)} covering {covered.mi.sum():.3f} miles")
print(f"pieces without one:   {len(seg) - len(covered)} covering "
      f"{seg[seg.aadt.isna()].mi.sum():.3f} miles")
volume events     48 breakpoints
route segments    16 breakpoints
shared            2
union            62 -> 61 segments

pieces with a volume: 61 covering 69.322 miles
pieces without one:   0 covering 0.000 miles

Example 3 β€” aggregating over segments of different lengths

plain = volume.aadt.mean()
weighted = np.average(volume.aadt, weights=volume.len_mi)
vmt = (volume.aadt * volume.len_mi).sum()

print(f"unweighted mean AADT across segments : {plain:9,.0f}")
print(f"length-weighted mean along the road  : {weighted:9,.0f}")
print(f"daily vehicle-miles on this route    : {vmt:9,.0f}")
print(f"longest segment: {volume.len_mi.max():.3f} mi at "
      f"{volume.loc[volume.len_mi.idxmax(), 'aadt']:,.0f} AADT; "
      f"shortest: {volume.len_mi.min():.3f} mi at "
      f"{volume.loc[volume.len_mi.idxmin(), 'aadt']:,.0f}")
unweighted mean AADT across segments :    54,617
length-weighted mean along the road  :    54,261
daily vehicle-miles on this route    : 3,761,487
longest segment: 7.503 mi at 48,000 AADT; shortest: 0.218 mi at 42,000

The two means differ by only a few hundred vehicles here, because the busy segments happen to be short and the quiet ones long in roughly equal measure. That is luck, not a rule β€” and the vehicle-miles figure is the one to publish, because it is a quantity rather than an average of averages.

Why segmentation is computed, not stored

Why the segmentation is not stored

Store the segments and every attribute edit forces a re-cut, every re-cut changes the segment identifiers, and every downstream table keyed on them breaks. Store the events against measures and the segmentation is derived whenever it is needed, from whichever tables that particular question involves.

Why midpoints are the right lookup

At a breakpoint, two events are both arguably in effect, and half-open intervals get argued about in every implementation. Evaluating at the midpoint of each piece sidesteps it: the midpoint is unambiguously inside exactly one interval of every table.

Why overlaps and gaps in the events matter more than in the geometry

Two volume events covering the same measure range mean the table contradicts itself, and the overlay will pick one arbitrarily. A gap means a stretch of road with no value, which is different from a value of zero. Both need to be reported rather than smoothed over, which is why the piece count and the covered mileage are printed above.

The checks that make a dynamic segmentation trustworthy. before trusting the segmentsevery table on the same route keycounty, route, prefix, alignmentpoint events converted where they mean a linebreakpoints unioned and rounded to the registerpieces labelled at their midpointuncovered mileage counted and reportedaverages weighted by piece lengththe segmentation stored for later reusederive it again insteadThe last two change the published number spatialworkflow.io
Six checks, and the last two are the ones that change the published number.

Zero-length pieces, overlaps and temporal events

  • Zero-length pieces appear wherever two tables share a breakpoint; drop them with a tolerance, not with ==.
  • Floating point measures rarely compare equal; round to the precision the register uses (a hundredth of a mile here).
  • Overlapping events within one table are a data error; find them before the overlay, not after.
  • Direction-specific events need the alignment in the key or they will double up.
  • Temporal events β€” a work zone, a closure β€” need a date filter before the overlay.
  • PostGIS has ST_LocateBetween for the geometry side of this, once the measures are settled.

FAQ

What is dynamic segmentation?

Deriving homogeneous route segments on demand by overlaying event tables, instead of storing a fixed segmentation.

Why not just store the segments?

Because every attribute edit would force a re-cut and break anything keyed on the old segment identifiers.

How do I combine two event tables?

Take the union of their breakpoints, cut the route there, and label each piece by looking up its midpoint.

Should point events become line events?

If they describe the road onward from that point β€” a count station, a speed limit change β€” yes. A bridge or a crash stays a point.

How do I average an attribute over a route?

Weight by segment length. An unweighted mean treats a 0.1-mile piece and a 7-mile piece alike.

What if the events overlap?

That is a data error in the table. Find and resolve it before overlaying.