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.

One road carrying a gapped postmile run and a continuous odometer, with the mapping between them. one road, two registers0.000–8.108173.6–181.78.198–19.538181.7–193.119.548–25.817193.1–199.3gappostmileodometerthe odometer is continuous; the postmiles skip 0.090 of a mile at the equationso the translation is exact one way and not invertible at the gap spatialworkflow.io
One road, two registers: the gaps are in one of them and not the other.

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.

The four outcomes of translating an event between two measure systems. what a translation returnsWhat the crosswalk saysWhat to do with itthe measure falls inside onesegmentTranslate it; the answer is exactit falls on a segment boundaryTwo answers: choose by the event’sown prefixit falls in a numbering gapNo counterpart exists — report, donot clampthe route key is absent on thetargetConflate the keys first; geometrycannot fix this spatialworkflow.io
Four outcomes, and only one of them is a number.

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.

Two centrelines for the same road diverging at a curve, and the measure error snapping introduces. the geometric fallback and its errorwhere the lines agreesnap error ≈ 0 ftat an interchangesnapped 112 ft alongThe error equals the distance between the two centrelines, wherever they disagree spatialworkflow.io
The error in a geometric conflation is the distance between the two centrelines, wherever they disagree.

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.

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.