How to transfer events between two route systems
On this page
Problem statement
Events recorded against one route have to be shown against another: the southbound carriageway's records on the northbound line, a postmile inventory against an odometer-keyed reporting layer, last year's crashes against this year's centreline.
There are two ways to do it and they fail differently. Measure translation uses a crosswalk and fails loudly when a measure has no counterpart. Geometric transfer snaps to the nearest line and always succeeds, including where it should not. This guide does both, on the same events, and compares them.
Quick answer
Translate by measure when both routes share a numbering, and check the result geometrically:
import numpy as np, pandas as pd
from lrs import routes, query
right = routes("County='SLO' AND Route=101 AND AlignCode='Right'").sort_values("bPM")
left = routes("County='SLO' AND Route=101 AND AlignCode='Left'").sort_values("bPM")
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["prefix"] = aadt.PM_PFX.fillna("")
events = (aadt.drop_duplicates(subset=["prefix", "PM", "DESCRIPTION"])
.dropna(subset=["PM"]).sort_values("PM").reset_index(drop=True))
def carries(frame, pm, prefix):
for s in frame.itertuples():
m = np.asarray(s.m)
if (s.PMPrefix or "") == prefix and min(m[0], m[-1]) <= pm <= max(m[0], m[-1]):
return s
return None
hits = [carries(left, pm, p) is not None for pm, p in zip(events.PM, events.prefix)]
print(f"{len(events)} events recorded on the Right alignment")
print(f"{sum(hits)} of them have a Left-alignment segment carrying the same postmile")
print(f"Right covers {right.geometry.length.sum() / 1609.344:.3f} miles, "
f"Left covers {left.geometry.length.sum() / 1609.344:.3f}")
48 events recorded on the Right alignment
48 of them have a Left-alignment segment carrying the same postmile
Right covers 69.171 miles, Left covers 69.162
Where the two carriageways share a numbering, the transfer is free: the same postmile means the same place on both. Where they do not, no amount of geometry will invent the correspondence.
What the routes share, translate measures, snap the rest
1. Establish what the two routes have in common
A shared numbering, a published crosswalk, or nothing but geometry. The answer decides the method.
2. Translate measures where you can
Piecewise per segment, carrying the prefix. Anything that does not translate is reported, not guessed.
3. Snap only what is left
Geometric transfer for the remainder, with a maximum offset and the offset stored on every result.
4. Compare the two answers where both exist
The difference between the translated and the snapped measure is the best available estimate of the geometric method's error.
5. Keep both references
The original route and measure, and the new ones. Without the original, the transfer cannot be redone when the network changes.
6. Report the breakdown
How many translated, how many snapped, how many failed, and the offset distribution for the snapped ones.
Transfer by measure, checked by geometry
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 — transfer by measure, then check by geometry
def along(line):
xy = np.asarray(line.coords)
return np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
def place(seg, pm):
m, d = np.asarray(seg.m), along(seg.geometry)
order = np.argsort(m)
return seg.geometry.interpolate(float(np.interp(pm, m[order], d[order])))
rows = []
for e in events.itertuples():
src = carries(right, e.PM, e.prefix)
dst = carries(left, e.PM, e.prefix)
if src is None:
rows.append({"pm": e.PM, "outcome": "not on the source route"})
continue
p_src = place(src, e.PM)
if dst is None:
rows.append({"pm": e.PM, "outcome": "no target segment", "offset_m": np.nan})
continue
p_dst = place(dst, e.PM)
rows.append({"pm": e.PM, "outcome": "translated",
"offset_m": p_src.distance(p_dst)})
out = pd.DataFrame(rows)
print(out.outcome.value_counts().to_string())
gap = out.offset_m.dropna()
print(f"\ndistance between the two carriageways at the same postmile: "
f"median {gap.median():.1f} m, 90th {gap.quantile(0.9):.1f} m, max {gap.max():.1f} m")
outcome
translated 48
distance between the two carriageways at the same postmile: median 22.1 m, 90th 36.1 m, max 58.7 m
That distance is not an error — it is the width of the divided highway. A transfer by measure puts the event at the same postmile on the other carriageway, which is where it belongs; the tens of metres between them are the road.
Example 2 — the geometric alternative, and what it costs
from shapely import STRtree
tree = STRtree(left.geometry.values)
def snap(point, max_offset=200.0):
idx = tree.query_nearest(point, max_distance=max_offset)
if len(idx) == 0:
return None, np.nan
seg = left.iloc[int(idx[0])]
m, d = np.asarray(seg.m), along(seg.geometry)
return float(np.interp(seg.geometry.project(point), d, m)), seg.geometry.distance(point)
compare = []
for e in events.itertuples():
src = carries(right, e.PM, e.prefix)
if src is None:
continue
snapped_pm, offset = snap(place(src, e.PM))
compare.append({"pm": e.PM, "snapped_pm": snapped_pm, "offset_m": offset})
compare = pd.DataFrame(compare).dropna(subset=["snapped_pm"])
compare["shift_mi"] = (compare.snapped_pm - compare.pm).abs()
print(f"{len(compare)} events snapped to the Left alignment")
print(f"postmile shift introduced by snapping: median {compare.shift_mi.median():.4f} mi, "
f"90th {compare.shift_mi.quantile(0.9):.4f} mi, max {compare.shift_mi.max():.4f} mi")
print(f"in feet: median {compare.shift_mi.median() * 5280:.0f}, "
f"max {compare.shift_mi.max() * 5280:.0f}")
print(compare.nlargest(3, "shift_mi").round(4).to_string(index=False))
48 events snapped to the Left alignment
postmile shift introduced by snapping: median 0.0026 mi, 90th 0.0122 mi, max 0.0213 mi
in feet: median 14, max 112
pm snapped_pm offset_m shift_mi
58.762 58.7833 20.2472 0.0213
13.173 13.1567 23.5195 0.0163
67.712 67.6962 18.9405 0.0158
Snapping moves the median event 14 feet and the worst one 112 — small, because these two carriageways run parallel for sixty-nine miles. The three worst are all where the lines separate: an interchange at 58.8, a junction in Arroyo Grande at 13.2, and the approach to Camp Roberts at 67.7. The method's error is not spread evenly along the route; it is concentrated exactly where the geometry stops being parallel.
Example 3 — the transfer report
report = pd.DataFrame({
"events": [len(events)],
"translated by measure": [(out.outcome == "translated").sum()],
"no target segment": [(out.outcome == "no target segment").sum()],
"not on source route": [(out.outcome == "not on the source route").sum()],
"snapped instead": [len(compare)],
}).T.rename(columns={0: "count"})
print(report.to_string())
both = out.merge(compare[["pm", "snapped_pm", "shift_mi"]], on="pm", how="inner")
print(f"\nevents with both answers: {len(both)}")
print(f"agreement: median {both.shift_mi.median() * 5280:.0f} ft, "
f"worst {both.shift_mi.max() * 5280:.0f} ft")
print("\nkeep both references on every transferred row:")
print(both.head(3)[["pm", "snapped_pm", "shift_mi"]].round(4).to_string(index=False))
count
events 48
translated by measure 48
no target segment 0
not on source route 0
snapped instead 48
events with both answers: 48
agreement: median 14 ft, worst 112 ft
keep both references on every transferred row:
pm snapped_pm shift_mi
0.000 0.0000 0.0000
0.813 0.8130 0.0000
4.851 4.8508 0.0002
Why translation beats snapping
Why measure translation is preferred
It is exact where it applies, it fails where it does not apply, and its failures are informative: a measure that does not translate names a real discontinuity between the two systems. Geometric transfer converts all of those into small position errors that nothing downstream can distinguish from correct answers.
Why snapping between carriageways is particularly risky
The two lines of a divided highway are parallel and close, so a snap almost always lands on a plausible measure. Where they diverge — around an interchange, through a town where one direction takes a one-way street — the snapped measure drifts, and at the extreme the event moves to the other side of a junction. The distribution above is the size of that drift on a well-behaved rural route; in a city it is larger.
Why both references must be stored
A transferred event has two identities and the second one is derived. Keeping only the new reference means the next centreline update has to transfer a transfer, compounding the error, and there is no way to check the work against the original record.
One-to-many splits, reversal and tolerances
- One-to-many: a route split into several in the target system needs the target route chosen before the measure.
- Direction reversal between systems flips the measure order; check the endpoints rather than assuming.
- Ramps and connectors exist in one network and not the other more often than mainline does.
- Batch transfers should be reproducible: store the source network version alongside the result.
- Tolerances should reflect the network, not the machine: 200 m on rural highway, 20 m in a city.
- Check a sample by eye — a transfer that reports 100% success is the one most worth looking at.
Internal links
- LRS conflation explained — the concepts behind this
- How to find the measure of a point on a route — the snapping half
- How to find the point at a given measure on a route — the placing half
- A point snaps to the wrong carriageway — the failure this guards against
- Postmile and milepost systems explained — why a measure may not exist on the target
- How to snap points to a street network in Python — the general snapping problem
FAQ
How do I move events from one route system to another?
Translate the measures through a crosswalk where one exists, and snap geometrically only for what is left.
Why not just snap everything?
Because snapping always returns an answer, including where the two networks describe different roads.
What do I do with events that do not translate?
Report them. They mark a real difference between the two systems.
How accurate is a geometric transfer?
As accurate as the two centrelines agree — here a few tens of feet on a rural highway, more where the carriageways diverge.
Should I keep the original reference?
Always. A transferred event without its source cannot be re-transferred or checked.
How do I know the transfer worked?
Compare the two methods where both apply, and look at the distribution of the differences rather than the mean.