Validating Oneway and Access Tags for Routing Jump to heading

A single reversed oneway tag can make a city block unreachable, and nothing about the data looks wrong: the geometry is valid, the tags parse, and the router simply refuses to go there.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

Directional and access tagging is validated against a profile, not in the abstract. oneway=yes restricts cars and, unless oneway:bicycle=no says otherwise, often bicycles too; it means nothing to a pedestrian. A check that reports “this way is oneway” has found nothing. A check that reports “under the car profile, this way’s direction makes eleven addresses unreachable” has found something.

Three classes of problem are worth separating.

Syntactic problems are values the profile cannot interpret: oneway=1, oneway=true, access=allowed, a maxspeed in a form nothing parses. These are cheap to find, unambiguous, and usually a small fixed list of variants once you look.

Structural problems are consistent tagging that produces an impossible graph. A reversed oneway on a short link creates a pocket that can be entered and not left, or reached only by an absurd detour. These do not show up as invalid anything; they show up as connectivity.

Semantic problems are tags that are individually valid and collectively contradictory: access=no on a way with bicycle=yes and foot=no, or a oneway on a roundabout that opposes the others in the same loop. These need the profile’s precedence rules to detect, which is why the check has to share those rules with the router rather than reimplement them.

The structural class is where the real damage is, and finding it needs a graph rather than a tag scan: strongly connected components under the profile’s directional rules. A healthy road network for cars is very nearly one giant strongly connected component, and everything outside it is either a genuine dead end or a bug.

Three classes of oneway and access problem, and what finds each Three panels. Syntactic problems are unparseable values such as oneway equals one or access equals allowed, which a tag scan finds instantly and which usually reduce to a small fixed list of variants. Structural problems are consistently tagged directions that produce an impossible graph, such as a reversed oneway creating a pocket that can be entered but never left, which only a strongly connected component analysis finds. Semantic problems are individually valid tags that contradict each other under the profile's precedence rules, such as access equals no alongside bicycle equals yes, which need the router's own rules to detect. Syntactic, structural, semantic Syntactic oneway=1, access=allowed A tag scan finds it A short list of variants Unambiguous to fix Structural Reversed oneway Pocket you cannot leave Needs a graph analysis Where the damage is Semantic access=no, bicycle=yes Individually valid Needs profile precedence Share rules with the router Only the first class is found by looking at tags alone, and it is the least consequential of the three.
A validator that scans tags covers the cheapest third of the problem.

Runnable solution Jump to heading

python
from __future__ import annotations

import logging
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field

import networkx as nx

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.routing.access")

# Values the specification recognises. Anything else is a finding, not a guess.
ONEWAY_FORWARD = {"yes", "true", "1"}
ONEWAY_REVERSE = {"-1", "reverse"}
ONEWAY_NONE = {"no", "false", "0"}
ACCESS_DENY = {"no", "private", "agricultural", "forestry", "delivery",
               "customers", "permit"}
ACCESS_ALLOW = {"yes", "designated", "permissive", "destination", "official"}


@dataclass(frozen=True)
class Profile:
    """Which keys the router reads, in precedence order. Must match the router."""
    name: str
    access_keys: tuple[str, ...]          # most specific LAST
    honours_oneway: bool = True
    oneway_exception_key: str | None = None   # e.g. "oneway:bicycle"


CAR = Profile("car", ("access", "vehicle", "motor_vehicle", "motorcar"))
BIKE = Profile("bike", ("access", "vehicle", "bicycle"),
               oneway_exception_key="oneway:bicycle")
FOOT = Profile("foot", ("access", "foot"), honours_oneway=False)


@dataclass
class Findings:
    unparseable: list[tuple[int, str, str]] = field(default_factory=list)
    contradictory: list[tuple[int, str]] = field(default_factory=list)
    unreachable: list[int] = field(default_factory=list)
    inescapable: list[int] = field(default_factory=list)


def direction(tags: Mapping[str, str], profile: Profile
              ) -> tuple[bool, bool, str | None]:
    """Return (forward_allowed, backward_allowed, problem)."""
    if not profile.honours_oneway:
        return True, True, None

    value = tags.get("oneway", "no").strip().lower()
    if profile.oneway_exception_key:
        value = tags.get(profile.oneway_exception_key, value).strip().lower()

    if value in ONEWAY_NONE:
        return True, True, None
    if value in ONEWAY_FORWARD:
        return True, False, None
    if value in ONEWAY_REVERSE:
        return False, True, None
    # Never guess: an unrecognised value is a finding, and treating it as
    # bidirectional invents a route while treating it as oneway removes one.
    return True, True, f"unparseable oneway value {value!r}"


def accessible(tags: Mapping[str, str], profile: Profile
               ) -> tuple[bool, str | None]:
    """Apply the profile's keys in order; the most specific one present wins."""
    allowed: bool | None = None
    seen: list[tuple[str, str]] = []
    for key in profile.access_keys:
        value = tags.get(key)
        if value is None:
            continue
        value = value.strip().lower()
        seen.append((key, value))
        if value in ACCESS_DENY:
            allowed = False
        elif value in ACCESS_ALLOW:
            allowed = True
        else:
            return True, f"unparseable {key} value {value!r}"

    if len(seen) > 1 and len({v for _, v in seen}) > 1:
        # Not an error by itself — specific overrides general is the point —
        # but worth surfacing when the general key denies and no specific
        # key re-permits for THIS profile.
        general = dict(seen).get("access")
        if general in ACCESS_DENY and allowed is False:
            return False, None
    return (True if allowed is None else allowed), None


def build(ways: Iterable[tuple[int, list[int], Mapping[str, str]]],
          profile: Profile, findings: Findings) -> nx.DiGraph:
    graph = nx.DiGraph()
    for way_id, nodes, tags in ways:
        ok, problem = accessible(tags, profile)
        if problem:
            findings.unparseable.append((way_id, "access", problem))
        if not ok:
            continue
        forward, backward, problem = direction(tags, profile)
        if problem:
            findings.unparseable.append((way_id, "oneway", problem))
        for a, b in zip(nodes, nodes[1:]):
            if forward:
                graph.add_edge(a, b, way=way_id)
            if backward:
                graph.add_edge(b, a, way=way_id)
    return graph


def connectivity(graph: nx.DiGraph, findings: Findings,
                 min_component: int = 20) -> None:
    """A healthy car network is nearly one giant strongly connected component.

    Everything outside it is a genuine dead end or a directional bug, and the
    two are distinguished by whether the pocket has an undirected connection
    to the giant component that the directions have closed off.
    """
    components = sorted(nx.strongly_connected_components(graph),
                        key=len, reverse=True)
    if not components:
        return
    giant = components[0]
    logger.info("giant component holds %.2f%% of nodes",
                100 * len(giant) / graph.number_of_nodes())

    undirected = graph.to_undirected(as_view=True)
    for component in components[1:]:
        if len(component) < min_component:
            continue
        touches_giant = any(neighbour in giant
                            for node in component
                            for neighbour in undirected.neighbors(node))
        if not touches_giant:
            continue
        # Physically connected, directionally isolated: a oneway bug.
        into = any(graph.has_edge(n, m) for n in giant for m in component
                   if graph.has_edge(n, m))
        (findings.inescapable if into else findings.unreachable).extend(
            sorted(component)[:5])


if __name__ == "__main__":
    logger.info("validate against the profile the router actually uses")

Step-by-step walkthrough Jump to heading

  1. Take the profile as a parameter. The same data is correct for one profile and broken for another, so a check without a profile is answering an undefined question.
  2. Never guess an unparseable value. Defaulting oneway=1 to bidirectional invents a route; defaulting it to oneway removes one. Report it and leave the graph unchanged.
  3. Apply access keys in precedence order. motorcar overrides motor_vehicle overrides vehicle overrides access, and a check that ignores that reports a private-access finding on every service road.
  4. Build the directed graph the router would build. The whole point is to find what the router will do, and any divergence in the graph construction makes the findings advisory rather than real.
  5. Compute strongly connected components. For a car profile the giant component should hold almost everything, and a component of twenty nodes sitting outside it is a strong signal.
  6. Distinguish unreachable from inescapable. A pocket you can enter but not leave and one you can leave but not enter are different bugs with the same connectivity signature, and naming which is which halves the diagnosis.
  7. Filter tiny components. Genuine cul-de-sacs, service yards and driveways produce small components legitimately, and a floor of around twenty nodes removes most of that noise.
  8. Check physical adjacency before reporting. A component with no undirected connection to the giant one is simply a disconnected area, not a directional bug.
How a single reversed oneway becomes an unreachable block Four steps. One short link in a grid of streets is tagged oneway in the wrong direction, which is a single character difference from correct and passes every syntactic check. Because the surrounding streets are themselves oneway in an alternating pattern, that link was the only inbound route into a block. The block remains physically connected to the network in the undirected sense, so no geometry or topology check notices anything. Under the directed graph the router builds, the block becomes its own strongly connected component that can be left but never entered, and every address inside it is unroutable as a destination. One tag, one unreachable block reversed link one character wrong passes tag checks only inbound route neighbours alternate no other way in still connected undirected view fine geometry checks pass own component can leave, cannot enter addresses unroutable Nothing in the third step is wrong, which is why every check that ignores direction reports a clean result here.
The signature is a small strongly connected component adjacent to the giant one.
How three routing profiles read the same tags differently A grid of four tag combinations against how the car, bicycle and foot profiles each interpret them. A way tagged oneway equals yes is directional for cars and bicycles but irrelevant to pedestrians. A way tagged oneway equals yes with oneway colon bicycle equals no is directional for cars and bidirectional for bicycles and pedestrians. A way tagged access equals no with bicycle equals yes is closed to cars and open to bicycles, with pedestrians closed unless a foot key permits them. A way tagged highway equals footway is closed to cars, usually closed to bicycles unless permitted, and open to pedestrians. One set of tags, three answers Car Bicycle Foot oneway=yes directional directional irrelevant + oneway:bicycle=no directional both ways irrelevant access=no, bicycle=yes closed open closed highway=footway closed usually closed open A validator with no profile parameter has to pick one column and will report the other two as findings on correct data.
Every row is correctly tagged; only the profile decides what the data means.

Verification Jump to heading

  • A synthetic reversal is caught. Flip one oneway in a test grid and confirm the component analysis finds the pocket.
  • The giant component dominates. For a car profile on a city extract, it should hold well over ninety-nine percent of nodes.
  • Profiles differ. Run car and foot profiles over the same data and confirm the foot graph has far fewer components.
  • Unparseable values are reported, not absorbed. Introduce oneway=true and confirm it appears as a finding.
  • Precedence works. Tag a way access=no with motorcar=yes and confirm the car profile treats it as open.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Router refuses plausible destinations Reversed oneway creating a pocket Report small components adjacent to the giant one
Every service road flagged as private Access keys applied without precedence Evaluate general to specific, most specific wins
Findings disagree with the router Check builds a different graph Share the profile rules with the router
Unusual oneway values silently ignored Unrecognised value defaulted Report unparseable values rather than guessing
Thousands of tiny components reported No minimum component size Filter below about twenty nodes
Disconnected islands reported as bugs Physical adjacency never checked Require an undirected link to the giant component
Cyclists routed the wrong way oneway:bicycle not consulted Let the profile name its own exception key

Specification reference Jump to heading

The oneway key takes yes, no or -1, where -1 indicates the restriction applies against the way’s drawn direction; true, false and 1 are documented as deprecated equivalents. Transport-mode access keys form a hierarchy in which a more specific key overrides a more general one for the modes it covers, so motorcar overrides motor_vehicle, which overrides vehicle, which overrides access. Mode-specific oneway exceptions take the form oneway:<mode>. See the OpenStreetMap wiki pages for oneway, conditional restrictions and access.

Frequently Asked Questions Jump to heading

How do you tell a genuine dead end from a directional bug?

By whether the component is physically adjacent to the rest of the network in the undirected graph. A cul-de-sac is its own small component only if it is oneway inward or outward, which is unusual; normally it is part of the giant component because you can drive both ways along it. A component that touches the giant one undirectedly but not directionally is almost always a tagging error, and that adjacency test removes nearly all the false positives.

Should the validator fix what it finds?

No, and especially not by reversing tags automatically. Some of these are genuine: a street really can be oneway in a way that makes a block awkward to reach, and mappers survey the ground. The validator’s output belongs in a review queue where somebody can check imagery or local knowledge, and an automated fix pushed upstream is how a corrective edit becomes a mapping dispute.

What about conditional restrictions?

oneway:conditional and time-based access are real and widely used, and a validator that ignores them will report findings on correctly tagged data. The pragmatic approach is to evaluate them for a representative time — a weekday midday — and to mark findings on conditionally restricted ways as lower confidence, since the check has picked one moment out of a schedule. Treating them as unconditional is the alternative, and it produces confident nonsense.

Does this need the whole network, or can it run on an extract?

The connectivity analysis needs enough of the network that the giant component is genuinely giant, which an extract clipped to a city boundary does not give you — every road crossing the boundary becomes a false dead end. Running it on an extract with a generous buffer, and ignoring findings within that buffer, is the usual compromise. The clipping strategies in the extract material apply directly.

Up one level: Routing Graph Topology QA.