LRS Conflation Explained: Moving Events Between Networks
On this page
Problem statement
Sooner or later two linear referencing systems have to be reconciled. The same highway carries postmiles that restart per county and an odometer that runs the length of the route; a federal reporting layer keys on its own route identifiers; a new centreline replaces the one every event table was written against.
Conflation is moving events from one system to the other without losing them. It is mostly not a geometric problem: where both systems are published on the same geometry, the translation is a lookup table, and the interesting part is what cannot be translated — measures that fall in a gap, measures that are ambiguous, and events whose route key does not exist on the other side.
Quick answer
Where one dataset publishes both measure systems, build the translation from it:
import numpy as np, pandas as pd
from lrs import routes
shn = routes("County='SLO' AND Route=101 AND AlignCode='Right'").sort_values("bPM")
table = shn[["PMPrefix", "bPM", "ePM", "bOdometer", "eOdometer"]].copy()
table["PMPrefix"] = table.PMPrefix.fillna("")
print(table.to_string(index=False))
def pm_to_odometer(pm, prefix=""):
for r in table.itertuples():
if r.PMPrefix == prefix and r.bPM - 1e-9 <= pm <= r.ePM + 1e-9:
frac = (pm - r.bPM) / (r.ePM - r.bPM) if r.ePM > r.bPM else 0.0
return r.bOdometer + frac * (r.eOdometer - r.bOdometer)
return None
for pm, prefix in [(0.0, ""), (49.64, ""), (20.0, "R"), (20.0, ""), (8.15, "")]:
odo = pm_to_odometer(pm, prefix)
label = f"{prefix}{pm}"
print(f"postmile {label:>7} -> odometer "
f"{'no match' if odo is None else format(odo, '.3f')}")
PMPrefix bPM ePM bOdometer eOdometer
0.000 8.108 173.617 181.725
8.198 19.538 181.725 193.065
R 19.548 25.817 193.065 199.334
25.825 32.976 199.334 206.485
R 32.976 34.567 206.485 208.076
34.616 49.063 208.076 222.523
49.067 49.471 222.523 222.927
49.477 67.456 222.927 240.906
R 67.456 69.322 240.906 242.772
postmile 0.0 -> odometer 173.617
postmile 49.64 -> odometer 223.090
postmile R20.0 -> odometer 193.517
postmile 20.0 -> odometer no match
postmile 8.15 -> odometer no match
Two of those five translations fail, and both failures are correct: postmile 20.0 without a prefix does not exist on this route, and postmile 8.15 falls in the gap left by an equation.
Find a crosswalk, translate piecewise, carry the whole key
1. Establish whether a published crosswalk exists
A segment table carrying both measures is a crosswalk. So is a published equation table. Use it before reaching for geometry.
2. Translate piecewise, never globally
Each segment has its own linear relation between the two measures. A single scale factor across the route is wrong wherever an equation falls.
3. Carry the whole key through the translation
Prefix and alignment travel with the measure. Dropping the prefix makes the translation ambiguous in exactly the places where the route was realigned.
4. Round-trip every event and measure the error
Translate there and back. Anything that does not return to within the register's precision is at a discontinuity and needs a rule, not an interpolation.
5. Report what did not translate
Events in gaps, events with unknown prefixes, events beyond the route's range. A conflation that silently drops 2% of an inventory is worse than one that fails.
6. Fall back to geometry only when you must
Snapping one network's vertices to the other and reading the measure works, and it introduces its own error — so it goes last, with the error quantified.
Round-tripping a route and conflating events
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), point_at_measure() interpolates a position along one line, and measure_at() reads the measure back at the nearest point on a 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])))
def measure_at(line, m, point):
"""The measure at the point on one line nearest to point, read from distance along it."""
xy = np.asarray(line.coords)
along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))])
return float(np.interp(line.project(point), along, m))
Writing lrs.py
Example 1 — round-tripping the whole route
def odometer_to_pm(odo):
hits = []
for r in table.itertuples():
if r.bOdometer - 1e-9 <= odo <= r.eOdometer + 1e-9:
frac = ((odo - r.bOdometer) / (r.eOdometer - r.bOdometer)
if r.eOdometer > r.bOdometer else 0.0)
hits.append((r.PMPrefix, r.bPM + frac * (r.ePM - r.bPM)))
return hits
errors, ambiguous = [], 0
for r in table.itertuples():
for pm in np.linspace(r.bPM, r.ePM, 25):
back = odometer_to_pm(pm_to_odometer(pm, r.PMPrefix))
if len(back) > 1:
ambiguous += 1
same = [v for p, v in back if p == r.PMPrefix]
if same:
errors.append(abs(same[0] - pm))
errors = np.array(errors)
print(f"{len(errors)} probes round-tripped")
print(f" largest error: {errors.max():.4f} miles")
print(f" probes whose odometer maps back to more than one postmile: {ambiguous}")
print(f" postmile gap at the same place: "
f"{table.bPM.iloc[1] - table.ePM.iloc[0]:.3f} miles")
225 probes round-tripped
largest error: 0.0900 miles
probes whose odometer maps back to more than one postmile: 16
postmile gap at the same place: 0.090 miles
The largest round-trip error is exactly the size of the equation gap, and it happens only at segment boundaries: an odometer value at a boundary belongs to two postmile segments, and coming back you get whichever the loop found first. That is a rule to write down, not a bug to fix — at a boundary, prefer the segment the event's own prefix names.
Example 2 — conflating an event table
from lrs import query
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("")
stations = aadt.drop_duplicates(subset=["prefix", "PM", "DESCRIPTION"]).dropna(subset=["PM"])
stations = stations.assign(odometer=[pm_to_odometer(pm, p)
for pm, p in zip(stations.PM, stations.prefix)])
ok = stations.odometer.notna()
print(f"{len(stations)} count stations: {ok.sum()} translated, {(~ok).sum()} did not")
if (~ok).any():
print(stations.loc[~ok, ["prefix", "PM", "DESCRIPTION"]].to_string(index=False))
print(f"odometer range of the translated stations: "
f"{stations.odometer.min():.3f} to {stations.odometer.max():.3f}")
48 count stations: 48 translated, 0 did not
odometer range of the translated stations: 173.617 to 242.772
All 48 translate, because the published table carries PM_PFX on every row — the two stations above postmile 67.4 are prefixed R, and without that column they would have looked like the failures in the quick answer.
Example 3 — the geometric fallback, with its error
from shapely.geometry import Point
from lrs import point_at_measure, measure_at
target = shn.iloc[0]
probe_pms = np.linspace(target.bPM + 0.1, target.ePM - 0.1, 40)
there = [point_at_measure(target.geometry, target.m, pm) for pm in probe_pms]
other = shn.iloc[0] # pretend this is a second, independent centreline
back = [measure_at(other.geometry, other.m, p) for p in there]
err = np.abs(np.array(back) - probe_pms)
print(f"snap-and-read on the same geometry: max error {err.max() * 5280:.1f} ft")
offset = [Point(p.x + 12.0, p.y + 12.0) for p in there] # 17 m of centreline difference
back2 = [measure_at(other.geometry, other.m, p) for p in offset]
err2 = np.abs(np.array(back2) - probe_pms)
print(f"with a 17 m centreline offset: median {np.median(err2) * 5280:.1f} ft, "
f"max {err2.max() * 5280:.1f} ft")
snap-and-read on the same geometry: max error 0.0 ft
with a 17 m centreline offset: median 8.7 ft, max 35.0 ft
Snapping to the same geometry is exact, which is the point: the error in a geometric conflation is entirely the difference between the two centrelines, and it appears wherever they diverge — at curves, at interchanges and wherever one network was digitised at a different scale.
Conflation is mostly a key problem
Why conflation is mostly a key problem
Two LRSs that describe the same roads usually differ in what a route is — one route per county, per direction, per jurisdiction — before they differ in where the measures fall. Matching the keys is the substantive work; once two segments are known to be the same road, the measure mapping is arithmetic.
Why the gaps do not translate
An equation is a declaration that the numbering skips a stretch. There is no postmile for ground inside the gap, so there is nothing to translate to. The continuous measure covers it, which means the translation is not invertible in that direction — and a workflow that assumes invertibility will quietly reuse the boundary value.
Why geometric conflation is a last resort
Snapping is seductive because it always returns an answer. It returns one even where the two centrelines describe different alignments, where one has a service road the other does not, and where a divided highway is one line in one dataset and two in the other. Each of those produces a confident measure on the wrong route.
Versioned systems, divided highways and ramps
- Versioned LRSs publish an "as of" date; conflating across versions needs the dates as well as the keys.
- Divided highways conflate badly when one system has a single route and the other has two.
- Ramps often exist in one network and not the other; decide whether to drop or to keep them unreferenced.
- Federal reporting layers (HPMS in the US) have their own route identifiers and their own measures.
- Round-tripping is the cheapest test of any crosswalk; run it before trusting a translation.
- Keep the original measure and key on every translated event, so the conflation can be redone.
Internal links
- Linear referencing explained: routes, measures and events — the model
- Postmile and milepost systems explained — why the gaps exist
- How to transfer events between two route systems — the implementation
- An event's measure falls outside the route — handling what does not translate
- Route calibration explained — the other reason measures do not line up
- How to snap and align geometries to fix slivers and gaps in Python — the geometric side
FAQ
What is LRS conflation?
Moving events from one linear referencing system to another — a different measure, a different route scheme, or a newer centreline.
Do I need geometry for it?
Not if both systems are published on the same segments; a crosswalk table is exact and much safer.
Why did some events fail to translate?
They fall in a numbering gap, carry a prefix the other system does not have, or lie outside the route's range.
How do I check a crosswalk?
Round-trip a dense set of measures and look at the maximum error and at how many map back ambiguously.
Can I just snap the events to the new network?
As a last resort, and report the error: it equals the distance between the two centrelines.
What should I store after conflating?
Both references — the original key and measure, and the new one — so the work can be repeated when the network changes again.