How to build measures onto a line that has none
On this page
Problem statement
Most road data has no linear referencing system. OpenStreetMap, a council's asset layer, a utility's as-built drawings: lines with attributes, no routes and no measures. Building an LRS from them means assembling the pieces of each route in order, deciding its direction, and assigning a measure to every vertex.
None of those steps is automatic. On one Portland street, the 213 edges OSM holds under a single name become 120 once the two directions are deduplicated, and those merge into seven separate pieces rather than one route.
Quick answer
Deduplicate, merge, take the pieces you get, and measure each one:
import numpy as np, osmnx as ox
from shapely.ops import linemerge
edges = ox.graph_to_gdfs(ox.load_graphml("pdx_drive.graphml"), nodes=False).to_crs(3857)
street = edges[edges.name.astype(str) == "Southeast Division Street"]
print(f"{len(street)} directed edges, {street.length.sum() / 1000:.3f} km of geometry")
seen, unique = set(), []
for g in street.geometry: # a two-way street appears twice, reversed
key = tuple(sorted([tuple(np.round(g.coords[0], 2)),
tuple(np.round(g.coords[-1], 2))]))
if key not in seen:
seen.add(key)
unique.append(g)
print(f"{len(unique)} after removing reversed duplicates, "
f"{sum(g.length for g in unique) / 1000:.3f} km")
merged = linemerge(unique)
parts = list(merged.geoms) if merged.geom_type == "MultiLineString" else [merged]
parts.sort(key=lambda g: -g.length)
print(f"linemerge gives {len(parts)} piece(s); longest {parts[0].length / 1000:.3f} km, "
f"rest {sum(g.length for g in parts[1:]) / 1000:.3f} km")
213 directed edges, 22.238 km of geometry
120 after removing reversed duplicates, 12.752 km
linemerge gives 7 piece(s); longest 8.514 km, rest 4.238 km
The street is not a route until someone decides it is. Seven pieces and a name is what the data actually contains.
Deduplicate, merge, choose the route and its direction
1. Deduplicate the directions
A two-way street is two directed edges with the same geometry reversed. Summing their lengths doubles the route.
2. Merge what touches
linemerge joins lines that share endpoints exactly. Anything left over did not touch β a gap, a rounding difference, or a genuinely separate stretch.
3. Decide what the route is
The longest merged piece, everything above a length threshold, or a hand-picked set. This is a modelling decision and it belongs in the metadata.
4. Fix the direction deliberately
Increasing measures should run a defensible way β south to north, downstream, away from a datum point. Do not let the merge order decide it.
5. Assign measures from cumulative length
Distance along the merged line, in whatever unit the register will use. Round to the precision you intend to publish.
6. Freeze it, and version it
The moment events are recorded against these measures, re-running this code with a newer OSM extract must not change them. Store the route geometry and its measures as a versioned artefact.
Measuring a route built from OpenStreetMap
The data used on this page
The street network is the OpenStreetMap driving network for inner Portland, Oregon, built with OSMnx and saved as GraphML. overpass-api.de refuses connections from here, so the query went to the maps.mail.ru mirror of the same API, and the setup reads that answer as saved on 9 October 2026: OpenStreetMap is edited continuously, and a live query moves the edge counts a little.
import io
import os
import requests
import zipfile
import osmnx as ox
# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-measures-not-monotonic-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
PDX = (-122.75, 45.46, -122.56, 45.57) # west, south, east, north: inner Portland
if not os.path.exists("pdx_drive.graphml"):
ox.save_graphml(ox.graph_from_bbox(PDX, network_type="drive"), "pdx_drive.graphml")
G = ox.load_graphml("pdx_drive.graphml")
print(f"drive network: {len(G):,} nodes, {G.number_of_edges():,} edges")
drive network: 13,757 nodes, 39,806 edges
Example 1 β measuring the route you chose
route = parts[0]
xy = np.asarray(route.coords)
step = np.hypot(*np.diff(xy, axis=0).T)
measures = np.concatenate([[0.0], np.cumsum(step)]) / 1000 # kilometres
if xy[0][0] > xy[-1][0]: # make measures run west to east
route = route.reverse()
measures = measures[-1] - measures[::-1]
print(f"route: {len(xy)} vertices, {measures[-1]:.3f} km")
print(f"first measures: {measures[:4].round(4).tolist()}")
print(f"vertex spacing: median {np.median(step):.1f} m, "
f"90th {np.percentile(step, 90):.1f} m, max {step.max():.1f} m")
print(f"measures increase monotonically: {bool(np.all(np.diff(measures) >= 0))}")
route: 294 vertices, 8.514 km
first measures: [0.0, 0.0412, 0.0647, 0.0853]
vertex spacing: median 18.8 m, 90th 66.1 m, max 114.7 m
measures increase monotonically: True
The vertex spacing sets the resolution of the calibration: an event can only be placed as precisely as the line is digitised between the vertices it falls between.
Example 2 β what the fragments cost
import pandas as pd
frag = pd.DataFrame({"km": [round(g.length / 1000, 3) for g in parts]})
frag["share"] = (frag.km / frag.km.sum()).round(3)
frag["gap_to_longest_m"] = [round(g.distance(parts[0])) for g in parts]
frag["farthest_from_2nd_m"] = [round(g.hausdorff_distance(parts[1])) for g in parts]
print(frag.to_string(index=False))
print(f"\nkeeping only the longest piece: {frag.km.iloc[0]:.3f} of "
f"{frag.km.sum():.3f} km ({frag.share.iloc[0]:.0%})")
print(f"pieces under 100 m: {(frag.km < 0.1).sum()}, totalling "
f"{frag.loc[frag.km < 0.1, 'km'].sum():.3f} km")
km share gap_to_longest_m farthest_from_2nd_m
8.514 0.668 0 8463
1.580 0.124 0 0
1.577 0.124 0 16
0.493 0.039 53 10071
0.479 0.038 1570 1570
0.054 0.004 0 10029
0.054 0.004 0 10029
keeping only the longest piece: 8.514 of 12.751 km (67%)
pieces under 100 m: 2, totalling 0.108 km
Just over four kilometres of this street β a third of it β are not in the route. East of the longest piece, Division Street splits into a divided stretch: its two carriageways are the 1.580 and 1.577 km pieces, drawn in opposite directions about 16 m apart, so the reversed-duplicate test cannot pair them and linemerge cannot choose between them. The 0.479 km piece carries on east beyond that split, cut off from the main piece by it. At the west end, the 0.493 km piece stops 53 m short of the main piece, and the two 54 m stubs are one short split carriageway drawn both ways. Each needs a decision, and the decision has to be recorded β otherwise the next person re-runs the merge, gets a different longest piece, and every event measured against the old one moves.
Example 3 β placing events on the route you built
from shapely.geometry import Point
def measure_of(point):
d = route.project(point)
return float(np.interp(d, np.concatenate([[0.0], np.cumsum(
np.hypot(*np.diff(np.asarray(route.coords), axis=0).T))]), measures))
division = edges[edges.name.astype(str) == "Southeast Division Street"].geometry
probes = [Point(g.coords[0]) for g in division][:200] # the street's own edge starts
located = [(measure_of(p), route.distance(p)) for p in probes]
on_route = [(m, off) for m, off in located if off < 15]
print(f"{len(on_route)} of {len(probes)} probe points within 15 m of the route")
km = np.array([m for m, _ in on_route])
print(f"measures span {km.min():.3f}β{km.max():.3f} km")
print(f"offsets: median {np.median([o for _, o in on_route]):.1f} m")
print(f"points rejected as off-route: {len(probes) - len(on_route)}")
164 of 200 probe points within 15 m of the route
measures span 0.000β8.514 km
offsets: median 0.0 m
points rejected as off-route: 36
The rejected points are on the fragments that did not make it into the route β which is the honest outcome, and exactly why the fragments needed a decision rather than a default.
Why merging is not enough, and a built LRS must be frozen
Why merging is not enough
linemerge joins lines whose endpoints are identical. Real data has endpoints that differ by a centimetre, lines that overlap rather than touch, and one-way pairs that are parallel rather than collinear. The merge result is a lower bound on how connected the street is, and the gaps it leaves are a list of things to look at, not errors to snap away blindly.
Why the direction has to be chosen
Measures that increase in the direction the merge happened to produce are stable only until the input changes. Fixing them to something external β a compass direction, a start point, a datum β means a re-run produces the same numbers, which is the whole point of a register.
Why a built LRS must be frozen
An LRS derived from geometry is only useful if its measures are stable. Re-running the derivation against a newer extract will shift every measure slightly, and every event recorded against the old measures now points somewhere else. Publish the derived route as data, version it, and treat later derivations as a new system that needs a crosswalk.
Name changes, route relations and rounding
- Name changes along a street break the selection before they break the geometry; select by
refor a relation where one exists. - OSM route relations already group the members of a numbered route, and are a better starting point than a name.
- Snapping tolerance: closing a one-centimetre gap is reasonable, closing a fifty-metre one is inventing road.
- Projected CRS: measures from a geographic CRS are in degrees, which is never the intent.
- Round the measures to the precision you will publish, and store the rounding rule.
- Both directions: if events are direction-specific, build two routes rather than one.
Internal links
- Linear referencing explained: routes, measures and events β what you are building
- Route calibration explained β why a derived LRS is proportional and a real one is not
- How to read a linear referencing system in Python β what a published one looks like
- How to clean a line network: dangles, overshoots and disconnects β fixing the geometry first
- How to download OpenStreetMap data in Python with OSMnx β getting the edges
- M values explained: geometry that carries a measure β storing the result
FAQ
How do I create measures for a road with none?
Deduplicate directions, merge the touching pieces, choose which pieces are the route, fix a direction, and take the cumulative length.
Why does my street merge into several pieces?
Because the endpoints do not match exactly, or the street genuinely has gaps, couplets or name changes.
Which direction should measures increase?
Any defensible one, chosen explicitly β compass direction, from a datum point, downstream β so a re-run reproduces it.
Can I regenerate the measures later?
Only if no events have been recorded against them. Otherwise freeze the route and version it.
What precision should the measures have?
Whatever you will publish and defend β a metre for a city street, a hundredth of a mile for a highway.
Is a derived LRS calibrated?
No, it is proportional to length by construction, which is a fine starting point and not the same as a surveyed register.