Normalizing OSM Speed Limits and Units Jump to heading
Turn maxspeed into a number your routing engine can use, without inventing values for the fifth of the data that is not a plain number.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
maxspeed looks like a number and is a small union type.
Three consequences follow. A schema with one integer column cannot hold the answer, because none and walk are not numbers and nulling them conflates them with missing data. Implicit values need a country lookup, so the country has to be known before normalisation runs. And the unit is not optional to handle.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Normalise OSM maxspeed values to km/h, keeping what cannot be a number."""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from enum import Enum
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
MPH_TO_KMH = 1.609344
KNOTS_TO_KMH = 1.852
class SpeedKind(str, Enum):
EXPLICIT = "explicit" # a number was stated
IMPLICIT = "implicit" # resolved from a country default
CATEGORY = "category" # none / walk / signals — no honest number
UNKNOWN = "unknown" # absent
UNPARSEABLE = "unparseable" # present, not understood
@dataclass(frozen=True)
class Speed:
kmh: float | None
kind: SpeedKind
category: str | None = None
raw: str | None = None
#: Values that deliberately are not numbers. Mapping them to one loses information.
CATEGORIES = {
"none": "no_legal_limit", # German autobahn — advisory 130, not a limit
"walk": "walking_pace",
"signals": "variable_signed",
"variable": "variable_signed",
}
#: A small extract of the implicit-value table. The real one is maintained upstream
#: in the OSM wiki and should be loaded as data, not embedded in code.
IMPLICIT: dict[str, float] = {
"DE:urban": 50, "DE:rural": 100, "DE:living_street": 7, "DE:motorway": 130,
"GB:nsl_single": 60 * MPH_TO_KMH, "GB:nsl_dual": 70 * MPH_TO_KMH,
"GB:motorway": 70 * MPH_TO_KMH,
"FR:urban": 50, "FR:rural": 80, "FR:motorway": 130,
"RO:urban": 50, "RO:rural": 90, "RO:motorway": 130,
"AT:urban": 50, "AT:rural": 100, "AT:motorway": 130,
}
_NUMERIC = re.compile(r"""
^\s*
(?P<value>\d+(?:\.\d+)?) # the number
\s*
(?P<unit>km/h|kmh|kph|mph|knots|kn)? # optional unit; absent means km/h
\s*$
""", re.IGNORECASE | re.VERBOSE)
_UNIT_FACTOR = {
None: 1.0, "": 1.0, "km/h": 1.0, "kmh": 1.0, "kph": 1.0,
"mph": MPH_TO_KMH, "knots": KNOTS_TO_KMH, "kn": KNOTS_TO_KMH,
}
#: Above this, the value is a data error rather than a fast road.
MAX_PLAUSIBLE_KMH = 300.0
def normalise(raw: str | None, country: str | None = None) -> Speed:
"""Parse one maxspeed value. Never guesses; never returns a number it invented."""
if raw is None or not raw.strip():
return Speed(None, SpeedKind.UNKNOWN, raw=raw)
value = raw.strip()
lowered = value.lower()
if lowered in CATEGORIES:
return Speed(None, SpeedKind.CATEGORY, category=CATEGORIES[lowered], raw=raw)
match = _NUMERIC.match(value)
if match:
unit = (match.group("unit") or "").lower()
kmh = float(match.group("value")) * _UNIT_FACTOR[unit or None]
if not 0 < kmh <= MAX_PLAUSIBLE_KMH:
return Speed(None, SpeedKind.UNPARSEABLE, raw=raw)
return Speed(round(kmh, 1), SpeedKind.EXPLICIT, raw=raw)
# Implicit form: either a full "CC:type" token, or a bare type with a known country.
key = value if ":" in value else (f"{country}:{value}" if country else None)
if key and key in IMPLICIT:
return Speed(round(IMPLICIT[key], 1), SpeedKind.IMPLICIT, raw=raw)
return Speed(None, SpeedKind.UNPARSEABLE, raw=raw)
def normalise_many(values: list[tuple[str | None, str | None]]) -> list[Speed]:
results = [normalise(raw, country) for raw, country in values]
counts: dict[SpeedKind, int] = {kind: 0 for kind in SpeedKind}
for speed in results:
counts[speed.kind] += 1
total = len(results) or 1
for kind, n in counts.items():
logger.info("%-12s %8d %5.1f%%", kind.value, n, 100 * n / total)
return results
Step-by-step walkthrough Jump to heading
CATEGORIES is checked before the numeric pattern, and its values become a category rather than a number. maxspeed=none on a German autobahn means there is no legal limit; encoding it as 130 asserts a limit that does not exist, and encoding it as null makes it indistinguishable from an unsurveyed road. A separate categorical column is the only representation that is true.
The numeric regex makes the unit optional and defaults it to km/h, which is what the specification says. The important part is that mph is converted rather than stripped — the thirteen percent slice that a naive parser silently makes 38 percent too slow.
MAX_PLAUSIBLE_KMH rejects rather than clamps. OSM contains maxspeed=999 and maxspeed=1000, which are data errors, and clamping them to 300 asserts a limit nobody surveyed. Returning UNPARSEABLE keeps them visible, following the same discipline as the coercion bounds in Mapping OSM Tags to a Fixed Schema with YAML.
The implicit lookup accepts both a full CC:type token and a bare type combined with a known country, because both appear. Without a country it does not guess — a bare urban with no country is genuinely ambiguous, and 50 km/h is right in much of Europe and wrong in the United States.
The kind field is what makes the output usable. A consumer computing average speeds needs to exclude implicit values or weight them differently; one computing coverage needs to count them as present. One number cannot serve both.
Verification Jump to heading
Test each family, and specifically test that the unit is converted:
def test_mph_is_converted_not_stripped():
assert normalise("30 mph").kmh == 48.3
assert normalise("70 mph").kmh == 112.7
def test_bare_number_is_kmh():
assert normalise("50").kmh == 50.0
def test_none_is_a_category_not_a_number():
speed = normalise("none")
assert speed.kmh is None and speed.category == "no_legal_limit"
def test_implicit_needs_a_country():
assert normalise("urban").kind is SpeedKind.UNPARSEABLE
assert normalise("urban", country="DE").kmh == 50.0
def test_absurd_values_are_rejected_not_clamped():
assert normalise("999").kind is SpeedKind.UNPARSEABLE
Then run over a real extract and compare the distribution against the chart above. A parseable rate far below 98 percent usually means the country join has not run, so every implicit value is failing.
Finally, sanity-check the resulting distribution against reality:
speeds = [s.kmh for s in results if s.kmh is not None]
import statistics
logger.info("median %.0f km/h, p95 %.0f km/h",
statistics.median(speeds), sorted(speeds)[int(0.95 * len(speeds))])
A median around 50 and a 95th percentile around 120–130 is what a European road network looks like. A median in the thirties means mph values are being stripped rather than converted — the number is plausible, which is exactly why the distribution check is worth running.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Routing ETAs consistently optimistic | mph stripped, not converted |
Multiply by 1.609344 |
| German autobahns capped at 130 | none mapped to a number |
Keep it as a category |
| Every implicit value unparseable | No country available at normalisation time | Join the country before normalising |
| A road at 999 km/h | Value accepted without bounds | Reject above a plausible ceiling |
none and unsurveyed look identical |
Both stored as null | Add a kind column |
| Median speed near 35 km/h | mph slice mishandled | Check the distribution, not just the parse rate |
Frequently Asked Questions Jump to heading
Should implicit values be resolved at all?
Resolve them, and mark them. An implicit value is a real legal limit and excluding it loses five percent of the network’s speed data; but it is a limit derived from a national rule rather than a surveyed sign, so a consumer comparing surveyed coverage between countries needs to be able to exclude them. The kind column costs one byte and makes both uses possible.
Where does the implicit-value table come from?
The OSM wiki maintains it per country, and it changes when national speed laws change. Load it as data rather than embedding it in code, version it alongside your mapping, and treat an update as a reviewable change — a table edit that silently reclassifies every rural road in a country is exactly the kind of change that should show up in a diff.
What about maxspeed:conditional?
It uses the value @ condition form and shares its time-selector grammar with opening_hours, so the parser in Parsing OSM Opening Hours Values does most of the work. Resolve the base maxspeed first and treat the conditional as an override applying over a time window; storing only the conditional loses the default.
Should I fall back to a default when maxspeed is absent?
Only with a provenance stamp. A highway-class default is a reasonable estimate and a terrible fact: a routing engine benefits from it, and a completeness metric computed over defaulted values measures your defaults rather than the map. Fill it, mark it as defaulted, and let each consumer decide — the fallback-chain pattern from Batch Attribute Mapping Strategies.
Specification reference Jump to heading
maxspeedvalues are numeric with an optional unit suffix, where an absent unit means km/h and recognised suffixes aremphandknots. Implicit values take the formCC:type, resolved against a country-specific table. The valuesnone,walk,signalsandvariableare defined categorical states rather than numbers.
Related Jump to heading
- Value Standardization & Regex Cleaning — the topic this normalisation belongs to.
- Mapping OSM Tags to a Fixed Schema with YAML — where the bounds and coercion rules live.
- Parsing OSM Opening Hours Values — the conditional grammar this shares.
- Handling Missing Tags in OSM Data Pipelines — why absent and unknown must stay distinct.
- Best Practices for OSM Tag Standardization Across Regions — the regional variation behind the implicit table.
Up one level: Value Standardization & Regex Cleaning.