Modelling OpenDRIVE Junction Connections in Python

A junction record is the densest part of an OpenDRIVE file and the part most often read incorrectly, because three levels of indirection stack up: the junction names connections, each connection names an incoming road and a connecting road with a contact point, and each connection carries a table mapping incoming lane ids to connecting lane ids. Getting any level wrong produces a movement list that looks plausible and routes traffic into oncoming lanes.

This task resolves all three into the explicit movement list that intersection and junction modeling synthesises paths for, and validates the result against the geometry the roads actually have.

The three levels, and what each contributes to one movement:

Assembling One Movement from Three Levels of Junction Records A three-level structure from junction to connection to laneLink row, converging into a single explicit movement record. <junction id="9"> <connection incomingRoad="12" connectingRoad="30" contactPoint="start"> <laneLink from="−2" to="−1"/> one movement entry: road 12, last section, lane −2 exit: road 30, section 0, lane −1 enters road 30 at s = 0, sign preserved the contact point contributes the last line, and it is the line every incorrect reader omits

Prerequisites #

  • Python 3.10+, lxml 5.x, NumPy 1.24+.
  • Input: a validated OpenDRIVE file with its roads already parsed and its geometry evaluable.
  • Upstream stage: road indexing and reference-line evaluation, as in extracting road geometry from OpenDRIVE records.
  • Output: an explicit movement list with entry pose, exit pose and provenance back to the connection row.

Step-by-Step #

1. Index the roads before touching junctions #

python
def index_roads(root) -> dict[str, dict]:
    """road id -> its lane sections, length and endpoint poses."""
    out = {}
    for road in root.iter("road"):
        rid = road.get("id")
        sections = [s for s in road.iter("laneSection")]
        length = float(road.get("length"))
        out[rid] = {"sections": sections, "length": length,
                    "start": pose_at(road, 0.0), "end": pose_at(road, length)}
    return out

Indexing first means a connection referencing a missing road is a KeyError at the point of use rather than a movement with a None in it. Expected output: a dict keyed by road id, complete before any junction is read.

2. Resolve the contact point #

python
def connecting_entry(road: dict, contact_point: str) -> tuple:
    """The pose and lane-id sign for traffic entering the connecting road."""
    if contact_point == "start":
        return road["start"], +1, 0                     # section index 0
    if contact_point == "end":
        return road["end"], -1, len(road["sections"]) - 1
    raise ValueError(f"unknown contactPoint {contact_point!r}")

The sign is the part that matters: entering at the far end means travelling against the reference line, so a connecting lane id of −1 in the table refers to what is +1 in the road's own numbering. This is the same rule applied to plain road links in building lane successor graphs from OpenDRIVE.

python
from dataclasses import dataclass

@dataclass(frozen=True)
class Movement:
    junction: str
    incoming_road: str
    incoming_lane: int
    connecting_road: str
    connecting_lane: int
    entry_section: int

def expand(junction, roads) -> list[Movement]:
    out = []
    for conn in junction.iter("connection"):
        cr = roads[conn.get("connectingRoad")]
        _, sign, sec = connecting_entry(cr, conn.get("contactPoint"))
        for link in conn.iter("laneLink"):
            out.append(Movement(
                junction=junction.get("id"),
                incoming_road=conn.get("incomingRoad"),
                incoming_lane=int(link.get("from")),
                connecting_road=conn.get("connectingRoad"),
                connecting_lane=sign * int(link.get("to")),
                entry_section=sec))
    return sorted(out, key=lambda m: (m.incoming_road, m.incoming_lane,
                                      m.connecting_road, m.connecting_lane))

Sorting makes the movement list a pure function of the file, which matters because downstream path synthesis is keyed on movement order and a reordered list produces a different — though equivalent — set of identifiers.

4. Validate structurally, then geometrically #

python
def validate_movements(movements, roads, max_gap_m: float = 2.0) -> list[str]:
    findings, seen = [], set()
    for m in movements:
        key = (m.incoming_road, m.incoming_lane, m.connecting_road)
        if key in seen:
            findings.append(f"duplicate movement from {key}")
        seen.add(key)

        if m.connecting_road not in roads:
            findings.append(f"{m.connecting_road}: referenced but absent")
            continue
        gap = endpoint_gap(roads, m)
        if gap > max_gap_m:
            findings.append(f"{m.incoming_road}->{m.connecting_road}: "
                            f"{gap:.1f} m between the lane ends")
    return findings

The max_gap_m check is what separates a structurally valid junction from a usable one: a connection whose incoming and connecting lane ends are eighty metres apart is describing a movement the geometry cannot support, and no path synthesis will make it drivable.

What the geometric check catches that the structural one cannot:

A Structurally Valid, Geometrically Impossible Connection Two connections drawn to scale, one with a small junction gap and one with a large gap caused by a mistyped road reference. 1.2 m normal junction gap 78 m both roads exist · both lanes exist · every structural check passes connectingRoad was mistyped — 30 for 3

Verification & Acceptance Criteria #

python
def assert_junction_model(junction, movements, roads) -> None:
    rows = sum(1 for c in junction.iter("connection") for _ in c.iter("laneLink"))
    assert len(movements) == rows, f"{rows} laneLink rows produced {len(movements)} movements"
    assert not validate_movements(movements, roads), "junction has findings"
    assert movements == sorted(movements, key=lambda m: (m.incoming_road, m.incoming_lane,
                                                         m.connecting_road, m.connecting_lane))

Acceptance gate: movement count equal to the laneLink row count — no row dropped and none duplicated; zero structural or geometric findings; and a deterministically ordered list, so re-parsing the same file reproduces the same movement identifiers.

What each level of the junction record contributes, and what goes wrong when each is misread:

Failure Mode per Level of the Junction Record Three rows pairing a junction-record level with the specific routing failure a misreading produces. three levels, three distinct failures <junction> names its connections a dropped connection an approach with no movements <connection> roads plus a contact point an ignored contact point traffic into oncoming lanes <laneLink> the many-to-many table deduplicated rows vehicles in the wrong exit lane all three produce a movement list that looks plausible, which is why the geometric endpoint-gap check is worth its cost

Common Errors & Fixes #

Traffic is routed into an oncoming lane. The contact point was ignored, so the connecting lane sign was not flipped. Apply the sign from connecting_entry.

Movement count is lower than the row count. Rows are being deduplicated by (incoming, connecting) road pair, collapsing the many-to-many table. Expand per row.

A movement references a road that is not in the index. Either the file is broken or the roads were indexed lazily. Index everything first, then expand.

Path synthesis produces an eighty-metre turn. A structurally valid connection with a mistyped road id. Add the endpoint-gap check.

Movement identifiers change between runs on an unchanged file. The list is not sorted, so identifiers depend on XML iteration order. Sort as in step 3.

FAQ #

What is a connecting road? #

A short road that exists only inside the junction and carries one turn movement, or a small set of them. It is a full road in the schema — with its own reference line, lane sections and geometry — rather than a special junction primitive, which is why junction geometry can be authored with exactly the same machinery as ordinary road geometry. A four-way junction typically has one connecting road per approach pair.

Why does the connection record need a contact point? #

Because a connecting road can be authored in either direction, and the junction has to say which of its two ends the incoming traffic enters. contactPoint of start means traffic enters at s equals zero and travels with the reference line; contactPoint of end means it enters at the far end and travels against it, which also flips the sign of every connecting lane id. Ignoring it links traffic into lanes running the other way.

Can a junction be validated without geometry? #

Only partially. Structural checks — every referenced road exists, every laneLink names lanes that exist, no duplicate rows — need no geometry and catch most authoring mistakes. What they cannot catch is a connection that is structurally valid and geometrically impossible, such as a movement whose incoming and connecting lane ends are eighty metres apart. That needs the road geometry, which is why validation runs after the roads are indexed rather than during the parse.

Up one level: Intersection & Junction Modeling — the stage this movement list feeds.