An event's measure falls outside the route

On this page

Problem statement

An event table and a route table disagree. A structure is recorded at postmile 64.62 and the route's unprefixed segments do not reach it — only its realigned ones do. A count station names a route the centreline layer does not carry in that county. A measure lands in the tenth of a mile that an equation skips.

The temptation is to clamp: snap the measure to the nearest valid one and carry on. That turns every out-of-range event into a plausible in-range event, piles them at segment boundaries, and hides the disagreement that produced them. The fix is to classify the failures, because each class has a different cause and a different answer.

Quick answer

Four outcomes, not two:

import numpy as np, pandas as pd
from lrs import query, routes

shn = routes("District=5 AND AlignCode='Right'")
shn["pfx"] = shn.PMPrefix.fillna("")
spans = {}
for s in shn.itertuples():
    spans.setdefault((s.County, int(s.Route)), []).append(
        (s.pfx, min(s.bPM, s.ePM), max(s.bPM, s.ePM)))

structures = pd.DataFrame([f["attributes"] for f in
                           query("State_Highway_Bridges", "DIST='05'")])
structures["PM"] = pd.to_numeric(structures.PM, errors="coerce")
structures["route"] = pd.to_numeric(structures.RTE, errors="coerce")
structures = structures.dropna(subset=["PM", "route"])

def classify(county, route, pm, tol=0.005):
    key = (county, int(route))
    if key not in spans:
        return "route not in the LRS"
    rows = spans[key]
    if any(p == "" and lo - tol <= pm <= hi + tol for p, lo, hi in rows):
        return "in range"
    if any(lo - tol <= pm <= hi + tol for p, lo, hi in rows):
        return "only under a prefix"
    return "beyond the end" if pm > max(hi for _, _, hi in rows) else "inside a gap"

structures["outcome"] = [classify(r.CO, r.route, r.PM) for r in structures.itertuples()]
print(f"{len(structures)} structures in District 5")
print(structures.outcome.value_counts().to_string())
495 structures in District 5
outcome
in range                489
route not in the LRS      3
only under a prefix       3

Six failures in 495 records, in two distinct classes. Clamping would have produced 495 apparently good locations and no way to tell which six were invented.

The outcomes when an event measure does not match the route, and the right response to each. four failures, four answersWhat the check foundWhat to dothe measure is in range,unprefixedLocate it; this is the only casethat yields a coordinateit is carried only under a prefixList the candidate prefixes; useone only if it is aloneit falls inside a numbering gapReturn null and report; thenumbering skips that groundthe route is not in the routetableTwo datasets disagree about whatexists — escalate it spatialworkflow.io
Four outcomes; the only one that produces a coordinate is the first.

Check the key, try prefixes, separate gaps from overruns

1. Check the route key before the measure

"Route not in the LRS" is a different problem from "measure out of range", and it is usually a county, a route type or a vintage mismatch.

2. Try the prefixes before giving up

A measure that fails unprefixed and succeeds under R is not an error; the event table simply does not carry the prefix column.

3. Separate gaps from overruns

Inside a gap means the numbering skips it. Beyond the end means the event is off the route, or the route table is clipped to a jurisdiction.

4. Use a tolerance that matches the register

Postmiles rounded to a hundredth of a mile need about that much slack at the ends, and no more.

5. Never clamp silently

If a business rule says to clamp, clamp and record that you did, in a column that survives to the output.

6. Report the failures back

Six records in 495 is a bug report for whoever maintains one of the two tables, not a rounding problem.

An event measure just past the end of a route segment, with the tolerance band drawn. how much slack is rounding, and how much is a different segmentsegment: 0.000 – 8.108±0.005outsidea hundredth of a mile is the register’s own rounding — about eight metresa tenth of a mile is a different segment, and widening the tolerance to reach itconverts a data problem into a silent one spatialworkflow.io
A hundredth of a mile of tolerance is rounding; a tenth is a different segment.

What the failures actually are

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 — what the failures actually are

bad = structures[structures.outcome != "in range"]
print(bad[["CO", "RTE", "PM", "NAME", "outcome"]].to_string(index=False))

for co, rte in sorted({(r.CO, r.RTE) for r in bad.itertuples()}):   # a set's order varies between runs
    key = (co, int(float(rte)))
    if key in spans:
        rows = sorted(spans[key], key=lambda t: t[1])
        print(f"\n{co}-{rte} route segments: "
              + ", ".join(f"{p or '(none)'}{lo:.3f}–{hi:.3f}" for p, lo, hi in rows[:6]))
    else:
        print(f"\n{co}-{rte}: no segments at all in this route table")
 CO   RTE    PM                             NAME              outcome
MON 00000  0.00       CRAZY HORSE CANYON VIADUCT route not in the LRS
SLO 00001 64.62           ARROYO DE LOS PLAYANOS  only under a prefix
SLO 00001 64.86                ARROYO DEL CORRAL  only under a prefix
SLO 00001 65.65                   ARROYO DEL OSO  only under a prefix
 SB 00225  0.01 LAS POSITAS (225/101) SEPARATION route not in the LRS
 SB 00225  0.02                   LAS POSITAS OH route not in the LRS

MON-00000: no segments at all in this route table

SB-00225: no segments at all in this route table

SLO-00001 route segments: (none)0.000–4.841, R4.841–5.201, (none)5.299–6.534, R6.584–7.531, (none)7.531–10.213, (none)10.283–10.394

Example 2 — retrying with the prefixes

def locate_with_prefixes(county, route, pm, tol=0.005):
    """Return every (prefix, segment) that could carry this measure."""
    hits = []
    for p, lo, hi in spans.get((county, int(route)), []):
        if lo - tol <= pm <= hi + tol:
            hits.append(p or "(none)")
    return hits

retry = bad[bad.outcome == "only under a prefix"]
for r in retry.itertuples():
    print(f"{r.CO}-{r.RTE} postmile {r.PM}: carried by prefix "
          f"{locate_with_prefixes(r.CO, r.route, r.PM)} — {r.NAME}")

ambiguous = [r for r in structures.itertuples()
             if len(locate_with_prefixes(r.CO, r.route, r.PM)) > 1]
print(f"\nstructures whose postmile is carried by more than one prefix: "
      f"{len(ambiguous)} of {len(structures)}")
SLO-00001 postmile 64.62: carried by prefix ['R'] — ARROYO DE LOS PLAYANOS
SLO-00001 postmile 64.86: carried by prefix ['R'] — ARROYO DEL CORRAL
SLO-00001 postmile 65.65: carried by prefix ['R'] — ARROYO DEL OSO

structures whose postmile is carried by more than one prefix: 50 of 495

Fifty of the 495 structures have a postmile that more than one prefix could carry. Those fifty are located correctly today only because the unprefixed segment is tried first and usually happens to be right; they are ambiguous in the data and nothing in the output says so. Counting them is the cheapest way to find out how much of an inventory depends on that convention.

Those three are on the realigned stretch of Route 1 north of Cambria. The structure table records the postmile without its prefix, so it is unambiguous on the ground and ambiguous in the data — which is exactly the case to report rather than to guess.

Example 3 — clamping, if you must, visibly

def clamp(county, route, pm, tol=0.005):
    rows = spans.get((county, int(route)), [])
    if not rows:
        return None, "no route"
    unprefixed = [(lo, hi) for p, lo, hi in rows if p == ""]
    for lo, hi in unprefixed:
        if lo - tol <= pm <= hi + tol:
            return min(max(pm, lo), hi), "in range"
    if not unprefixed:
        return None, "no unprefixed segment"
    nearest = min(unprefixed, key=lambda t: min(abs(pm - t[0]), abs(pm - t[1])))
    target = min(max(pm, nearest[0]), nearest[1])
    return target, f"clamped by {abs(target - pm):.3f} mi"

out = [clamp(r.CO, r.route, r.PM) for r in structures.itertuples()]
structures["clamped_pm"] = [o[0] for o in out]
structures["clamp_note"] = [o[1] for o in out]
print(structures.clamp_note.value_counts().to_string())
moved = structures[structures.clamp_note.str.startswith("clamped")]
print(f"\n{len(moved)} records moved; distances: "
      f"{sorted(moved.clamp_note.str.extract(r'by (.+) mi')[0].astype(float).round(3))}")
clamp_note
in range               489
no route                 3
clamped by 0.481 mi      1
clamped by 0.721 mi      1
clamped by 1.511 mi      1

3 records moved; distances: [0.481, 0.721, 1.511]

The three clamped records move by 0.481, 0.721 and 1.511 miles — between half a mile and a mile and a half from where the register says they are. A clamp of that size is not a rounding fix, it is a relocation, and the note column is the point: A downstream reader can filter it, a map can symbolise it, and an audit can count it — none of which is possible when the clamp is a silent min(max(...)) buried in a helper.

Why clamping is so tempting and so bad

Why clamping is so tempting and so bad

Clamping produces a complete dataset, and completeness is what gets asked for. The cost is that the six wrong records are now indistinguishable from the 489 right ones, and they sit exactly at segment boundaries — where an analysis looking for clusters will find them, since every clamped event in a gap lands on the same point.

Why "route not in the LRS" is the most informative failure

It means the two tables disagree about what exists, which is a bigger problem than a measure. Here it is a structure with route number 00000 — a crossing over a non-state road — and two structures on a route the centreline layer does not carry in that county. Neither is fixable in code; both are worth an email.

Why prefixes cause most of the rest

Event tables routinely record a postmile and drop the prefix, because the prefix is invisible on the sign and obvious to the crew. The route table cannot resolve it, so the honest output is a list of candidate prefixes rather than a coordinate. Where a single candidate exists, using it is safe and should be recorded as an inference.

Outcomes of locating 495 structures against the route table. what a location run actually returnsin range, unprefixed489 of 495only under a prefix3 — Route 1 north of Cambriaroute not in the LRS3 — including one numbered 00000ambiguous across prefixes50 located by convention alone0250500Caltrans District 5 structures; the last row is the one nobody counts spatialworkflow.io
The output of a location run is this chart, and then the coordinates.

Neighbouring counties, vintages and tolerance creep

  • Off-by-a-county: a measure valid in the neighbouring county is the most common "beyond the end" cause.
  • Vintage: an event recorded before a realignment may cite numbering that no longer exists.
  • Route type: state route 1 and US 101 can both be "1" and "101" in a table without a type column.
  • Negative measures occur where a route was extended backwards; they are valid in some registers.
  • Tolerance creep: widening the tolerance until everything matches converts a data problem into a silent one.
  • Keep the raw value alongside anything derived, always.

FAQ

Why is my event's milepost outside the route?

The prefix is missing, the measure is in a numbering gap, the route key is wrong, or the event is genuinely off the route.

Should I clamp it to the nearest valid measure?

Only with a note in the output saying you did, and how far it moved.

What tolerance should I use?

About the precision of the published measures — a hundredth of a mile for postmiles.

What if the route is not in the route table at all?

That is a disagreement between two datasets; report it rather than working around it.

How do I handle missing prefixes?

List the prefixes that could carry the measure; use one only when it is the only candidate, and record that you inferred it.

How many failures are normal?

A percent or so in a well-maintained pair of tables — but the number matters less than knowing which records they are.