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.

Measure-based transfer through a crosswalk compared with geometric transfer by snapping. two ways to move an eventby measure✓exact wherever the crosswalk applies✓fails loudly where it does not✓the failures name a real discontinuity✓needs a shared numbering or a crosswalkby geometry✕always returns an answer✕error equals the gap between centrelines✕silently wrong where the lines diverge✕needs nothing, which is the problem48 of 48 volume events transferred by measure; snapping shifted them a median of 14 ft spatialworkflow.io
One fails loudly and one never fails; that difference is the whole choice.

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.

The outcomes of a transfer run and what each one means. what a transfer run returnsoutcomecountwhat it meanstranslated by measure48exact; keep both referencesno target segment0the measure has no counterpartnot on the source route0the event was never on this routesnapped instead48geometric fallback, offset recordedboth methods agree to14 ft median112 ft at the worst pointa transfer that reports 100% success is the one most worth checking by eyeUS 101, right carriageway to left, San Luis Obispo county spatialworkflow.io
The output of a transfer is a table of outcomes, not just a table of events.

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.

Two carriageways separating at an interchange, with an event snapped to the wrong position. where the error livesparallel: snapping is fine0 ft alongdiverging: it drifts112 ft alongThe three worst transfers on this route are all at junctions spatialworkflow.io
Where the carriageways separate, the nearest line stops being the matching one.

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.

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.