Joining OSM Roads to a Speed Limit Reference Jump to heading
Road attributes do not transfer between networks the way point attributes do: the two datasets cut their roads at different places, so a single OSM way may overlap four reference segments carrying three different values.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Linear conflation differs from point conflation in one structural way: the unit of the match is a stretch of road, not an object. Three consequences follow.
Segmentation never agrees. OSM splits ways at junctions, at tagging changes and at the whim of whoever drew them; a reference network splits at its own administrative boundaries. One OSM way commonly spans several reference segments and vice versa.
Proximity is not enough. Two roads running parallel twenty metres apart — a carriageway and its service road, a road and a parallel cycleway — are near each other everywhere along their length. Distance alone matches them confidently and wrongly. Bearing agreement is the signal that separates them, and it is cheap.
The answer may be partial. If sixty percent of an OSM way overlaps reference segments saying 50 and forty percent says 30, there is no single correct value. The honest output is the dominant value plus the coverage and the agreement, so a consumer can decide whether to use it.
Runnable solution Jump to heading
from __future__ import annotations
import logging
import math
from collections import defaultdict
from dataclasses import dataclass
import geopandas as gpd
from shapely.geometry import LineString
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.enrich.linear")
BUFFER_M = 12.0 # half a carriageway plus positional error
BEARING_TOLERANCE = 25.0 # degrees; roads are directional, parallel ones are not
MIN_COVERAGE = 0.6 # below this, report but do not assign
@dataclass(frozen=True)
class Transfer:
osm_key: str
value: str | None
coverage: float # fraction of the way length covered by agreeing refs
agreement: float # fraction of covered length carrying the chosen value
sources: int
def bearing(line: LineString) -> float:
"""Overall direction of a line in degrees, folded to 0..180.
Folding removes direction-of-travel: a way drawn the other way round is
the same road, and we are matching geometry rather than heading.
"""
(x1, y1), (x2, y2) = line.coords[0], line.coords[-1]
angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
return angle % 180.0
def bearing_delta(a: float, b: float) -> float:
diff = abs(a - b) % 180.0
return min(diff, 180.0 - diff)
def transfer_attribute(osm: gpd.GeoDataFrame, reference: gpd.GeoDataFrame,
epsg: int, value_column: str = "speed_kph",
osm_id: str = "osm_key") -> list[Transfer]:
"""Move a linear attribute from a reference network onto OSM ways."""
left = osm.to_crs(epsg=epsg)
right = reference.to_crs(epsg=epsg)
probe = left.copy()
probe["geometry"] = probe.geometry.buffer(BUFFER_M, cap_style=2)
pairs = gpd.sjoin(probe[[osm_id, "geometry"]],
right[[value_column, "geometry"]],
how="inner", predicate="intersects")
logger.info("%d OSM way(s) produced %d overlap candidate(s)",
len(left), len(pairs))
geom_by_id = left.set_index(osm_id).geometry
ref_geom = right.geometry
lengths: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
covered: dict[str, float] = defaultdict(float)
for _, row in pairs.iterrows():
way = geom_by_id.loc[row[osm_id]]
ref = ref_geom.loc[row["index_right"]]
# Direction filter: a parallel cycleway is close but not aligned.
if bearing_delta(bearing(way), bearing(ref)) > BEARING_TOLERANCE:
continue
# Overlap length: intersect the reference with the way's buffer.
piece = ref.intersection(way.buffer(BUFFER_M, cap_style=2))
if piece.is_empty:
continue
length = piece.length
lengths[row[osm_id]][str(row[value_column])] += length
covered[row[osm_id]] += length
results: list[Transfer] = []
for key, geom in geom_by_id.items():
by_value = lengths.get(key, {})
if not by_value or geom.length == 0:
results.append(Transfer(key, None, 0.0, 0.0, 0))
continue
best_value, best_length = max(by_value.items(), key=lambda kv: kv[1])
coverage = min(1.0, covered[key] / geom.length)
agreement = best_length / covered[key]
# Below the coverage floor the value is reported but NOT assigned:
# a fifth of a way agreeing is not evidence about the whole way.
assigned = best_value if coverage >= MIN_COVERAGE else None
results.append(Transfer(key, assigned, coverage, agreement, len(by_value)))
assigned = sum(1 for r in results if r.value is not None)
logger.info("assigned a value to %d of %d way(s); median coverage %.2f",
assigned, len(results),
sorted(r.coverage for r in results)[len(results) // 2]
if results else 0.0)
return results
if __name__ == "__main__":
logger.info("store value, coverage and agreement — never the value alone")
Step-by-step walkthrough Jump to heading
- Work in metres. A buffer and a length are meaningless in degrees, and a road network spans enough latitude for the error to be substantial.
- Buffer with flat caps. A flat cap stops the buffer extending past the end of a way and collecting overlaps from the road continuing beyond a junction.
- Fold bearings to 180 degrees. A way drawn in the opposite direction is the same road; folding removes direction of travel while keeping alignment.
- Filter by bearing before measuring. The bearing test is arithmetic and the intersection is geometry, so testing first discards most wrong pairs cheaply.
- Accumulate length per value, not per segment. Several reference segments may carry the same value; what matters is the total length each value covers.
- Report coverage separately from agreement. Coverage says how much of the way had any reference at all; agreement says how much of that agreed on the winning value. A way can have full coverage and poor agreement, which is a very different situation from partial coverage with perfect agreement.
- Refuse to assign below the coverage floor. A value derived from a fifth of a way is not evidence about the way, and silently assigning it is how a reference value ends up on a road it never described.
- Return a null value, not a missing row. Every OSM way appears in the output, with an explicit null where nothing could be assigned, so downstream counts are complete.
Verification Jump to heading
- Parallel features are not matched. Find a road with a parallel cycleway and confirm the cycleway did not receive the road’s value.
- Coverage is high on trunk roads. A major road should be almost entirely covered by a national reference; low coverage there means the buffer or bearing tolerance is wrong.
- Low-agreement ways are genuinely mixed. Sample a few and confirm the reference really does change value along them.
- Every way appears in the output. Including those with no value; a shorter output than input means rows were dropped rather than nulled.
- Values are in your units. The reference’s units are its own; convert explicitly rather than assuming they match the OSM convention.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Cycleways carry road speed limits | No bearing filter | Discard overlaps whose bearing differs beyond a tolerance |
| Values bleed past junctions | Round buffer caps | Use flat caps so the buffer stops at the way’s end |
| Coverage always tiny | Buffer narrower than the positional offset | Widen the buffer to half a carriageway plus error |
| Buffers in the thousandths | Work done in a geographic CRS | Reproject both layers to a metric CRS |
| One value assigned to a mixed road | Agreement not reported | Store agreement alongside the value and review low ones |
| Output shorter than input | Unmatched ways dropped | Emit an explicit null row for every way |
| Speeds off by a factor | Reference in different units | Convert units explicitly at the boundary |
Specification reference Jump to heading
Linear referencing transfers attributes between networks whose segmentation differs by measuring the overlap between source and target geometry and assigning by length. Shapely provides the buffer, intersection and length operations used here; the bearing comparison is ordinary trigonometry over the endpoints. See the Shapely documentation for
buffercap styles andintersectionsemantics on linear geometry.
Frequently Asked Questions Jump to heading
Why is a bearing filter necessary?
Because proximity alone cannot distinguish a road from the things that run alongside it. A carriageway, its service road, a parallel cycleway and a footpath are all within a few metres of each other for their entire length, and a buffer-based overlap matches all of them equally well. Comparing overall direction, folded so that a way drawn backwards is still aligned, separates them almost perfectly and costs nothing.
What should happen when a way overlaps segments with different values?
Report it rather than resolve it. High coverage with low agreement is not a matching failure; it is the reference telling you that the attribute genuinely changes along that OSM way, which usually means the way should be split. Assigning the dominant value silently puts a single speed limit on a road that really has two, and nothing downstream will ever reveal that.
How wide should the buffer be?
Wide enough to cover half a carriageway plus the positional difference between the two networks, which is typically ten to fifteen metres for a road network. Too narrow and dual carriageways mapped as two ways match nothing; too wide and the buffer starts collecting genuinely different roads, at which point the bearing filter is doing all the work and doing it under more strain than it should.
Should a way with partial coverage get the value anyway?
Not automatically. A value derived from a fifth of a way’s length is not evidence about the whole way, and assigning it anyway produces data that looks complete and is not. Report the value and the coverage together, set a floor below which nothing is assigned, and let a consumer who genuinely wants a low-coverage estimate opt into it explicitly.
Related Jump to heading
- Attribute Enrichment from Authoritative Sources — the parent topic and the namespace discipline this output follows.
- Normalizing OSM Speed Limits and Units — reconciling the two sources’ units.
- Nearest-Neighbour Matching with GeoPandas sjoin_nearest — the point-based equivalent of this join.
- Routing Graph Topology QA — where a transferred speed limit is usually consumed.
- Validating Oneway and Access Tags for Routing — checking the attributes this produces.
Up one level: Attribute Enrichment from Authoritative Sources.