Building Routing Graphs from OpenDRIVE Junctions
The graph produced in road network graph construction routes cleanly along roads and stops at junctions unless the junction interior is expanded into it. The tempting shortcut is one edge per movement, straight from the incoming lane to the outgoing lane, treating the junction as a hop. That shortcut discards the interior geometry, so the route's length is wrong, the turn's curvature is unavailable, and there is nowhere to record that two movements cross.
This task expands junctions properly: a node per connecting-road lane, two edges per movement, conflict annotations on the interior pairs, and a reachability gate.
The two expansions, and what the collapsed one cannot express:
Prerequisites #
- Python 3.10+, networkx 3.x, shapely 2.0+.
- Input: the movement list from modelling OpenDRIVE junction connections in Python and the synthesised turn paths.
- Upstream stage: the road-level graph, with nodes for ordinary lanes already present.
- Output: the same graph with junction interiors routable and conflicts annotated.
Step-by-Step #
1. Create a node per connecting-road lane #
def add_connecting_nodes(G, movements, paths) -> int:
added = 0
for mv in movements:
node = ("conn", mv.connecting_road, mv.connecting_lane)
if node in G:
continue
path = paths[mv.id]
G.add_node(node, kind="junction_interior",
length=path_length(path),
curvature=peak_curvature(path),
junction=mv.junction)
added += 1
return added
Several movements can share a connecting-road lane — a two-lane connecting road serves two movements from each of two incoming lanes — so the node is created once and keyed on the road and lane rather than on the movement.
2. Add the two edges per movement #
def add_movement_edges(G, movements) -> None:
for mv in movements:
entry = ("lane", mv.incoming_road, mv.incoming_lane)
interior = ("conn", mv.connecting_road, mv.connecting_lane)
exit_ = ("lane", mv.outgoing_road, mv.outgoing_lane)
G.add_edge(entry, interior, kind="junction_entry", movement=mv.id)
G.add_edge(interior, exit_, kind="junction_exit", movement=mv.id)
Both edges carry the movement identifier, which is what lets a route be translated back into a list of movements the planner can reason about — and what makes a conflict annotation on the interior resolvable to the two movements involved.
3. Annotate conflicts rather than adding edges #
from shapely.geometry import LineString
def annotate_conflicts(G, movements, paths, tol_m: float = 0.5) -> int:
found = 0
for i, a in enumerate(movements):
la = LineString(paths[a.id])
for b in movements[i + 1:]:
if a.junction != b.junction or a.incoming_lane == b.incoming_lane:
continue
lb = LineString(paths[b.id])
hit = la.intersection(lb)
if hit.is_empty:
continue
pt = hit if hit.geom_type == "Point" else hit.centroid
G.nodes[("conn", a.connecting_road, a.connecting_lane)] \
.setdefault("conflicts", []).append(
{"with": b.id, "s_self": la.project(pt), "s_other": lb.project(pt)})
found += 1
return found
Movements sharing an incoming lane diverge rather than conflict, so they are excluded — otherwise every junction reports its own fan-out as a conflict and the annotation becomes noise.
4. Verify reachability across the junction #
import networkx as nx
def unreachable_pairs(G, junction_id, movements) -> list[tuple]:
entries = {("lane", m.incoming_road, m.incoming_lane)
for m in movements if m.junction == junction_id}
exits = {("lane", m.outgoing_road, m.outgoing_lane)
for m in movements if m.junction == junction_id}
legal = {(("lane", m.incoming_road, m.incoming_lane),
("lane", m.outgoing_road, m.outgoing_lane))
for m in movements if m.junction == junction_id}
return [(u, v) for u, v in legal if not nx.has_path(G, u, v)]
Every legal movement must survive the expansion as a path. A non-empty result means an edge was dropped somewhere between the connection record and the graph, which no local check will find.
What reachability catches that node and edge counts do not:
Verification & Acceptance Criteria #
def assert_junction_graph(G, movements) -> None:
for j in {m.junction for m in movements}:
bad = unreachable_pairs(G, j, movements)
assert not bad, f"junction {j}: {len(bad)} unreachable legal movement(s)"
for n, d in G.nodes(data=True):
if d.get("kind") != "junction_interior":
continue
assert d["length"] > 0, f"{n}: interior node with zero length"
assert G.in_degree(n) and G.out_degree(n), f"{n}: interior node is a dead end"
for _, d in G.nodes(data=True):
for c in d.get("conflicts", []):
assert 0 <= c["s_self"] <= d["length"], "conflict station outside the path"
Acceptance gate: zero unreachable legal movements at every junction; every interior node with positive length and both an in-edge and an out-edge; and every conflict station inside its path's extent, which is the cheap proof the projection used the right geometry.
What each junction representation can and cannot answer:
Common Errors & Fixes #
Routes through junctions are shorter than reality. The junction was collapsed to a single edge, so the interior length is missing. Expand as in step 1.
Every junction reports dozens of conflicts. Movements sharing an incoming lane are being compared. Exclude them; a fan-out is not a conflict.
A legal movement is unroutable. Usually a mis-signed connecting lane, so the exit edge names a lane that does not exist. Check the contact-point sign before checking the graph code.
Conflict stations are all zero. The projection is being taken against the wrong path — project was called on the other movement's geometry. Project each station against its own path.
Interior nodes accumulate across releases. Nodes are keyed on movement rather than on connecting road and lane, so a re-run with reordered movements creates duplicates. Key on the road and lane.
FAQ #
Why put nodes inside the junction rather than one edge across it? #
Because the junction interior has geometry, cost and conflicts of its own, and a single edge across it has nowhere to carry them. With a node per connecting-road lane, the interior path is a first-class part of the route: its length contributes to travel cost, its curvature is checkable, and a conflict can be located at a station along it. Collapsing the junction to one edge makes every one of those questions unanswerable at planning time.
What does the reachability check actually catch? #
Junctions that became one-way by accident. A missing laneLink row, a mis-signed connecting lane or a dropped connection record all produce a graph that is structurally valid and in which some approach can no longer reach some exit. Reachability is the only check that sees this, because every local check passes: the edges that exist are all correct, and the fault is in the edges that do not.
Should conflicts be edges or annotations? #
Annotations. A conflict is not a movement — nobody drives it — so making it an edge corrupts every routing query with paths that cross from one turn onto another. Recording it as an attribute on the pair of edges, with the station along each, gives the planner what it needs without making the graph claim something false about connectivity.
Related #
- Weighting Road Graphs for AV Route Planning — costing the edges this expansion creates.
- Modelling OpenDRIVE Junction Connections in Python — the movement list this consumes.
- Validating Turn Restrictions in Road Graphs — the legality layer applied to the expanded graph.
Up one level: Road Network Graph Construction — the graph this expansion completes.