Route Calibration Explained: Why Measures Are Not Lengths
On this page
Problem statement
It is tempting to treat a measure as a proportion of the line: postmile 5 on a 10-mile route must be halfway along. It is also wrong, and wrong by more than rounding. On the 238 state highway segments in Caltrans District 5, the stored measures depart from a straight-line-in-distance model by a median of 0.012 miles and by as much as 0.44 miles β and only one segment in 238 is exactly proportional.
A route is calibrated: measures are pinned to the geometry at known points, and between them they follow the survey rather than the digitised centreline. Ignoring that turns every event location into an estimate with a sixty-foot median error and a half-mile tail.
Quick answer
Never interpolate a measure from proportion. Interpolate against the measures the data carries:
import numpy as np
from lrs import routes
shn = routes("County='SLO' AND Route=101 AND AlignCode='Right'")
def along_miles(line):
xy = np.asarray(line.coords)
return np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))]) / 1609.344
worst, gap = None, 0.0
for r in shn.itertuples():
d, m = along_miles(r.geometry), np.asarray(r.m)
mid = (m[0] + m[-1]) / 2
diff = abs(np.interp(mid, m, d) - np.interp(mid, [m[0], m[-1]], [0.0, d[-1]]))
if diff > gap:
worst, gap = r, diff
d, m = along_miles(worst.geometry), np.asarray(worst.m)
mid = (m[0] + m[-1]) / 2
print(f"segment {worst.bPM:.3f}β{worst.ePM:.3f}: {d[-1]:.3f} miles of geometry, "
f"{m[-1] - m[0]:.3f} miles of measure")
print(f"postmile {mid:.3f} is at mile {np.interp(mid, m, d):.4f} along the line")
print(f" assuming proportion puts it at "
f"{np.interp(mid, [m[0], m[-1]], [0.0, d[-1]]):.4f} "
f"β {gap * 5280:,.0f} ft away")
segment 49.477β67.456: 17.988 miles of geometry, 17.979 miles of measure
postmile 58.467 is at mile 9.0093 along the line
assuming proportion puts it at 8.9940 β 81 ft away
The two answers differ because the measures are not evenly spaced along the geometry β which is the definition of a calibrated route.
Check calibration, interpolate stored measures, compare spans
1. Find out whether the route is calibrated at all
Compare the stored measures with a straight-line model. If they match to floating point, the route is uncalibrated and proportion is exact β which is rare in a maintained LRS.
2. Interpolate against the stored measures
np.interp(target, measures, distances) is the whole operation. It is piecewise linear between vertices, which is what the calibration claims.
3. Expect the measure span and the geometry length to differ
They are different quantities. On these segments the ratio is within 1% two thirds of the time and within 5% nine times in ten β and the tails are real.
4. Never mix measures between paths
A multipart feature carries one set of attributes and several geometries; the feature's begin and end postmiles bound all its parts, not each one.
5. Re-derive nothing you were given
If the source publishes a measure, use it. Recomputing measures from geometry produces a second, incompatible LRS that will disagree with every event table keyed on the first.
6. Check monotonicity before interpolating
np.interp requires increasing x. Measures that decrease along the geometry need reversing first, and measures that are not monotonic at all need fixing rather than interpolating.
How calibrated is the state highway network
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 β how calibrated is this network?
district = routes("District=5 AND AlignCode='Right'")
district["path_mi"] = [abs(mm[-1] - mm[0]) for mm in district.m]
district["geom_mi"] = district.geometry.length / 1609.344
deviation = []
for r in district[district.path_mi > 0.05].itertuples():
pts = np.asarray(r.geometry.coords)
d = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(pts, axis=0).T))]) / 1609.344
mm = np.asarray(r.m)
straight = mm[0] + (mm[-1] - mm[0]) * d / d[-1]
deviation.append(np.abs(mm - straight).max())
deviation = np.array(deviation)
print(f"{len(district)} paths in the district")
print(f"max departure from a proportional model, per path (miles): "
f"median {np.median(deviation):.4f}, 95th {np.percentile(deviation, 95):.4f}, "
f"worst {deviation.max():.3f}")
print(f"paths that are exactly proportional: "
f"{(deviation < 1e-6).mean():.1%}")
print(f"which in feet is a median of {np.median(deviation) * 5280:,.0f} ft "
f"and a worst case of {deviation.max() * 5280:,.0f} ft")
238 paths in the district
max departure from a proportional model, per path (miles): median 0.0116, 95th 0.1334, worst 0.441
paths that are exactly proportional: 0.4%
which in feet is a median of 61 ft and a worst case of 2,330 ft
Sixty feet is the difference between the right bridge and the one before it. Two thousand three hundred feet is a different interchange.
Example 2 β measure span against geometric length
ok = district[district.path_mi > 0.05].copy()
ok["ratio"] = ok.geom_mi / ok.path_mi
print(f"geometry Γ· measure: median {ok.ratio.median():.4f}, "
f"5th {ok.ratio.quantile(0.05):.4f}, 95th {ok.ratio.quantile(0.95):.4f}")
print(f"within 1%: {((ok.ratio - 1).abs() < 0.01).mean():.1%} "
f"within 5%: {((ok.ratio - 1).abs() < 0.05).mean():.1%}")
print(ok.nsmallest(3, "ratio")[["County", "Route", "PMPrefix", "path_mi", "geom_mi", "ratio"]]
.to_string(index=False))
geometry Γ· measure: median 0.9998, 5th 0.9648, 95th 1.0647
within 1%: 66.2% within 5%: 89.9%
County Route PMPrefix path_mi geom_mi ratio
SCR 9 0.396 0.250254 0.631953
SLO 166 R 0.211 0.167866 0.795575
SCR 9 R 0.107 0.091164 0.852002
The worst ratios are all very short segments, where a few metres of digitising difference is a large fraction. That is the usual shape: calibration error is roughly constant in absolute terms, so it looks catastrophic on short pieces and negligible on long ones.
Example 3 β the multipart trap
district["attr_mi"] = (district.ePM - district.bPM).abs()
mismatch = district[(district.attr_mi - district.path_mi).abs() > 0.002]
print(f"{len(mismatch)} of {len(district)} paths have their own measure span "
f"different from the feature's bPMβePM")
print(mismatch[["County", "Route", "PMPrefix", "bPM", "ePM", "path_mi", "geom_mi"]]
.to_string(index=False))
4 of 238 paths have their own measure span different from the feature's bPMβePM
County Route PMPrefix bPM ePM path_mi geom_mi
SCR 1 8.058 37.451 8.962 8.977622
SCR 1 8.058 37.451 20.431 20.578390
MON 183 0.000 1.821 0.710 0.706998
MON 183 0.000 1.821 1.111 1.132416
Those are multipart features: one row in the service, several lines on the ground, one pair of begin and end postmiles covering the lot. Reading bPM and ePM per geometry rather than per feature gives a route span that is too long for every part but one.
Why routes are calibrated, and recomputing is a trap
Why a route is calibrated rather than measured
The measures come from the field: signed mileposts, survey stations, historic chainage. The geometry comes from a map. The two were produced decades apart by different processes, and the calibration is the reconciliation between them β a set of points where the register and the map are declared to agree. Between those points the geometry can wander without the numbering following.
Why recomputing measures is a trap
If a route's measures look untidy, it is tempting to reassign them from cumulative length. The result is a clean, self-consistent LRS that no longer matches the sign on the road, the bridge inventory, the crash records or the maintenance contracts β all of which were written against the old numbers. The untidiness is the interface to the real world.
Why this is the same problem as a stream's river kilometres
Every linear register has it: rail chainage from the 1840s, pipeline stationing from construction, river kilometres measured up a channel that has since migrated. In all of them the measure is the authoritative one-dimensional coordinate and the geometry is a later approximation of the thing it runs along.
Vertex density, calibration points and two systems
- Vertex density limits calibration: interpolation is piecewise linear between vertices, so a sparse line cannot express a tight calibration.
- Calibration points are published separately in some systems, as a table of measure-and-coordinate pairs.
- Re-digitising the centreline requires re-calibrating; the measures are not supposed to move.
- Two measure systems on one route β postmile and odometer here β are each calibrated separately.
- Reverse-direction routes may store decreasing measures; check before interpolating.
- PostGIS
ST_LocateAlongandST_InterpolatePointrespect the M ordinate, so they are calibrated by construction.
Internal links
- Linear referencing explained: routes, measures and events β the model
- M values explained: geometry that carries a measure β where the calibration is stored
- Postmile and milepost systems explained β why the numbers are not tidy
- How to find the point at a given measure on a route β interpolating correctly
- Event lengths do not add up to the route length β the arithmetic that surfaces it
- How to measure distance accurately in Python β the geometric side
FAQ
What is route calibration?
Pinning measures to positions along the geometry at known points, so a measure can be converted to a coordinate.
Can I compute a postmile from the proportion along the line?
Only on an uncalibrated route. On a real one it is wrong by a median of sixty feet and sometimes much more.
Why do measure span and geometry length differ?
They are different quantities produced by different processes, and realignments are recorded in the numbering rather than corrected.
Should I recalculate the measures to make them tidy?
No. Every existing event table is keyed on the published numbers.
How do I interpolate correctly?
np.interp(measure, stored_measures, distances_along) β piecewise linear against the vertices.
What if the measures decrease along the line?
Reverse both arrays before interpolating, or the interpolation silently returns the endpoint.