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:
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 #
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 #
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.
3. Expand every laneLink row into a movement #
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 #
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:
Verification & Acceptance Criteria #
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:
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.
Related #
- Generating Turn Paths Through Signalised Junctions — the consumer of this movement list.
- Building Lane Successor Graphs from OpenDRIVE — the same contact-point rule applied to plain road links.
- Validating Intersection Drivable Area — the geometric validation that follows path synthesis.
Up one level: Intersection & Junction Modeling — the stage this movement list feeds.