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.

From named edges to a route with measures. five decisions, not one function336 directededges22.884 kmdeduplicate13.075 kmlinemerge7 pieceschoose theroute8.514 kmmeasure andfreezecumulative lengthSoutheast Division Street, Portland, from an OSM drive graph spatialworkflow.io
Five decisions between a pile of edges and a route.

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.

The merged pieces of one named street. what one street name actually containslongest piece8.514 km β€” 67% of the namedivided, eastbound1.580 kmdivided, westbound1.577 kmwest end, 53 m gap0.493 kmeast of the split0.479 kmtwo stubs0.054 km each04.5 km9 km4.238 km outside the routeA divided stretch drawn twice, a gap, a piece cut off beyond it, and two stubs spatialworkflow.io
Seven pieces from one street name; the route is the decision about which of them count.

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.

A derived route frozen as a versioned artefact, with later derivations treated as a separate system. the derivation is a build step; the register is the output1derive the route from this extractmerge, choose, measure2freeze the geometry and its measuresstore as versioned data3record events against those measuresthe register is now in use4a newer extract arrivesvertices move, lengths change5derive again as a new systemand crosswalk between themRe-deriving in place moves every event that was recorded against the old measures spatialworkflow.io
The derivation is a build step; its output is the register, and the register is what gets stored.

Name changes, route relations and rounding

  • Name changes along a street break the selection before they break the geometry; select by ref or 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.

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.