Validating Intersection Drivable Area

The turn paths synthesised for intersection and junction modeling are checked for continuity and curvature by construction. What no amount of curve fitting establishes is whether a vehicle following them stays on the road, and that question is not about the path — it is about the area the vehicle body sweeps along it.

This task builds that area correctly, including the rear-axle cut-in that a centreline buffer misses, and reports how deeply each movement leaves the drivable surface.

Where the vehicle actually is, against where a centreline buffer says it is:

Rear-Axle Cut-In and the Corridor a Centreline Buffer Misses A turn showing the front-axle path, the inward-tracking rear-axle path, a centreline-only buffer and the true union corridor overlapping a kerb. front-axle path rear-axle path — tracks up to 0.9 m inside kerb 0.35 m incursion a buffer around the front-axle path alone reports full containment on this movement

Prerequisites #

  • Python 3.10+, NumPy 1.24+, shapely 2.0+ with an STRtree index.
  • Input: the synthesised turn paths, the surveyed carriageway surfaces, and the island and kerb polygons.
  • Upstream stage: turn-path generation; this validates its output.
  • Output: a per-movement incursion depth and the geometry of the worst excursion.

Step-by-Step #

1. Assemble the drivable polygon #

python
from shapely.ops import unary_union

def drivable_area(surfaces, exclusions):
    """Carriageway surfaces minus islands, refuges and kerb bodies."""
    area = unary_union(list(surfaces))
    if exclusions:
        area = area.difference(unary_union(list(exclusions)))
    if area.is_empty:
        raise ValueError("drivable area is empty — check the surface inputs")
    return area

Raising on an empty result is not defensive noise: Polygon().contains(anything) is False, so an empty drivable area makes every movement fail, and an empty exclusion-only area would make every movement pass. Both are silent without the check.

2. Derive the rear-axle track #

python
import numpy as np

def rear_track(front: np.ndarray, wheelbase_m: float = 2.9) -> np.ndarray:
    """Rear-axle positions for a vehicle whose front axle follows `front`."""
    d = np.gradient(front, axis=0)
    hdg = np.arctan2(d[:, 1], d[:, 0])
    return front - wheelbase_m * np.column_stack((np.cos(hdg), np.sin(hdg)))

This is the kinematic approximation — the rear axle lies one wheelbase behind along the current heading — and it is accurate to a few centimetres at junction speeds. The exact rear track is the solution of a differential equation, and the difference does not repay the complexity here.

3. Build the corridor as a union of both tracks #

python
from shapely.geometry import LineString
from shapely.ops import unary_union

def swept_corridor(front: np.ndarray, width_m: float = 2.0,
                   wheelbase_m: float = 2.9, margin_m: float = 0.0):
    half = width_m / 2.0 + margin_m
    rear = rear_track(front, wheelbase_m)
    return unary_union([
        LineString(front).buffer(half, cap_style=2, join_style=2),
        LineString(rear).buffer(half, cap_style=2, join_style=2),
    ])

Key parameters: margin_m is the safety clearance the operator requires beyond the body, applied here rather than by widening the vehicle, so the two can be reported separately. join_style=2 (mitre) keeps the corridor's outer edge from being rounded away at the tight part of the turn, which is where containment is decided.

4. Report the incursion depth, not a boolean #

python
def incursion(corridor, area) -> tuple[float, "BaseGeometry"]:
    """Deepest excursion outside the drivable area, and its geometry."""
    outside = corridor.difference(area)
    if outside.is_empty:
        return 0.0, outside
    parts = list(getattr(outside, "geoms", [outside]))
    worst = max(parts, key=lambda g: g.area)
    depth = max(area.exterior.distance(
        __import__("shapely").geometry.Point(c)) for c in worst.exterior.coords)
    return float(depth), outside

Returning the geometry alongside the depth is what makes the finding actionable — a surveyor needs to see where, and a screenshot of the excursion polygon over the junction is the fastest way to communicate it.

The three incursion classes a junction produces, and what each one means:

Incursion Depth and the Cause It Usually Indicates Three depth bands with the typical cause and the appropriate response for each. < 0.05 m grazing, short stretch usually the kerb polygon is coarsely surveyed accept, and log it against the survey queue 0.05 – 0.3 m consistent along the turn the path swings wider than the junction allows lower the design speed and re-solve the tension > 0.3 m crosses an island, or a surface is missing not a parameter problem surveyor review block the release a boolean gate collapses all three into "fail", and the response to each is different

Verification & Acceptance Criteria #

python
def assert_drivable(movements, paths, area, margin_m=0.15) -> list[str]:
    assert not area.is_empty, "drivable area is empty"
    findings = []
    for mv, path in paths.items():
        depth, geom = incursion(swept_corridor(path, margin_m=margin_m), area)
        if depth > 0.30:
            findings.append(f"{mv}: {depth:.2f} m incursion — surveyor review")
        elif depth > 0.05:
            findings.append(f"{mv}: {depth:.2f} m — lower the design speed")
    return findings

Acceptance gate: zero incursions deeper than 0.30 m; incursions between 0.05 m and 0.30 m allowed only with a recorded justification; the drivable area non-empty; and the corridor built from both axle tracks, which is verified by asserting the corridor area exceeds a front-only buffer's on at least one turning movement.

How far the rear axle cuts in, which is the whole reason the corridor is a union of two tracks:

Rear-Axle Cut-In by Turn Radius Four rows pairing a turn radius with the rear-axle cut-in it produces and whether it exceeds a typical safety margin. cut-in for a 2.9 m wheelbase, by turn radius radius 30 m · a sweeping turn motorway slip road ~0.14 m cut-in just inside the margin radius 15 m · a normal junction the common urban case ~0.28 m cut-in exceeds the margin radius 8 m · a tight left residential junction ~0.51 m cut-in well over radius 5 m · a hairpin service road or car park ~0.79 m cut-in a metre of unmodelled body a corridor built from the front path alone under-reports every row but the first, and reports full containment on all four

Common Errors & Fixes #

Every movement passes, including obviously wrong ones. The exclusions were unioned into the area instead of subtracted, or the area is empty. Check area.area is plausible for the junction footprint.

Only left turns fail. Rear-axle cut-in is present and correct — this is the check working. Left turns are tighter, so they fail first.

A movement fails by exactly the safety margin. The margin is being applied twice, once in the corridor and once in the gate. Apply it in one place; the corridor is the better one because the reported depth then means what it says.

The corridor's outer edge is rounded and containment passes. join_style defaulted to round, cutting the mitre off the outside of the turn. Set it to mitre.

Findings change between runs. The path was regenerated with a re-solved tension. Store the solved tension with the path so validation and synthesis agree.

FAQ #

Why is buffering the centreline not enough? #

Because a vehicle is not a point that widens. On a turn the rear axle tracks inside the path the front axle follows, by up to about a metre on a tight junction turn with a normal wheelbase, and that metre is exactly where the kerb is. A symmetric buffer around the centreline puts the inside edge of the corridor where the front axle passes, which is the one place the vehicle definitely does not reach — and misses where it does.

What belongs in the drivable polygon? #

The carriageway surface a vehicle may occupy, minus everything inside it that it may not: traffic islands, pedestrian refuges, raised kerbs, and any hatched area a regulation excludes. Building it as a union of surfaces and then subtracting the exclusions keeps the two decisions separate, which matters because the surfaces come from the survey and the exclusions come from regulation, and they change on different cycles.

Why report a distance rather than a pass or fail? #

Because a boolean cannot be triaged. A movement whose corridor clips 0.02 metres of kerb over a two-metre stretch is a different problem from one that crosses an island by 1.4 metres, and a review queue that ranks by depth puts the second first. The gate still turns the number into a decision, but the number is what a surveyor acts on and what a trend across releases is measured in.

Up one level: Intersection & Junction Modeling — the stage this validation closes.