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.
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.
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.
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_LocateBetweenfor the geometry side of this, once the measures are settled.
Internal links
- Linear referencing explained: routes, measures and events β the model
- Postmile and milepost systems explained β why breakpoints do not line up
- How to overlay linear event tables in Python β the reusable implementation
- How to aggregate events to route segments β counting per mile
- Event lengths do not add up to the route length β gaps and overlaps
- How to dissolve polygons in Python (GeoPandas) β the polygon equivalent of this idea
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.