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.

How a linear attribute moves from one network to another Four steps. The buffer step grows each OSM way slightly and intersects it with the reference network to find segments running alongside it. The bearing step discards overlaps whose direction differs beyond a tolerance, which is what separates a road from the parallel cycleway beside it. The measure step computes the overlapping length for each surviving reference segment. The assign step takes the value covering the greatest length, and reports both the coverage fraction and whether the overlapping segments agreed. Overlap, filter by bearing, weight by length buffer grow and intersect finds parallel too bearing discard wrong direction removes the cycleway measure overlap length each the weighting assign dominant value plus coverage Skipping the bearing filter is how a motorway ends up carrying the speed limit of the footpath running beside it.
The last step deliberately returns three things, because a value without its coverage is not interpretable.

Runnable solution Jump to heading

python
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

  1. 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.
  2. 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.
  3. Fold bearings to 180 degrees. A way drawn in the opposite direction is the same road; folding removes direction of travel while keeping alignment.
  4. Filter by bearing before measuring. The bearing test is arithmetic and the intersection is geometry, so testing first discards most wrong pairs cheaply.
  5. Accumulate length per value, not per segment. Several reference segments may carry the same value; what matters is the total length each value covers.
  6. 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.
  7. 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.
  8. 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.
How coverage and agreement combine into four interpretations A grid of two coverage levels against two agreement levels. High coverage with high agreement is a clean transfer that can be used directly. High coverage with low agreement means the reference genuinely changes value along the way, so the OSM way probably needs splitting rather than a single value. Low coverage with high agreement means only part of the way has reference data, and the value applies to that part rather than the whole. Low coverage with low agreement means there is no usable evidence and nothing should be assigned. Two numbers, four very different situations High agreement Low agreement High coverage clean: use it value changes: split the way Low coverage partial: applies to part no usable evidence What to store the value the conflict What to review nothing the segmentation The top-right cell is the interesting one: it is not a matching failure, it is the reference telling you the OSM way is too long.
Collapsing these four into one number loses the distinction between a bad match and a real change along the road.
What each filter removes from the candidate overlaps on one urban road network Five counts through the filtering pipeline for a city road layer. The raw buffer intersection produces a large number of candidate overlaps. Removing overlaps whose bearing disagrees beyond the tolerance discards roughly half of them, almost entirely parallel paths and service roads. Removing empty intersections discards a small further share. Accumulating by value rather than by segment collapses the remainder substantially, because several reference segments commonly share one value. The final assignments are fewer still, because ways below the coverage floor receive nothing. Where the candidate overlaps go Raw buffer overlaps baseline After the bearing filter about half remain After empty intersections slightly fewer Collapsed by value segments merge Assigned above the floor final assignments The bearing filter removes the most and is the cheapest to compute, which is why it runs before any geometric intersection.
Half the raw overlaps are paths and service roads running alongside, which no distance-based rule can separate.

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 buffer cap styles and intersection semantics 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.

Up one level: Attribute Enrichment from Authoritative Sources.