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.
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.
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.
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.
Internal links
- How to find the point at a given measure on a route — the operation that fails
- Postmile and milepost systems explained — prefixes, gaps and equations
- LRS conflation explained — when the two tables come from different systems
- Event lengths do not add up to the route length — the line-event version of this
- How to read a linear referencing system in Python — the route table this checks against
- Exposure counts are double-counted or missed — the same "report the residual" argument
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.