Enforcing Lane Width and Overlap Invariants

The checks in topological validation rules answer questions about connectivity: does this lane have a successor, does the graph close, is the heading continuous. A lane can pass all of them and still be undrivable, because none of them looks at the space the lane occupies.

This task adds the three invariants that do: width inside the band its road class allows, no intersection between the bodies of adjacent lanes, and no lane whose body intersects itself.

The three defects, and why the topology checks are blind to all of them:

Three Geometry Defects Invisible to Topology Checks Three panels showing a local narrowing, an adjacent-lane body intersection and a self-intersecting folded lane, each with a note that topology passes. 2.1 m 3.4 m average bodies intersect · 12 m body folds through itself local narrowing adjacent overlap self-intersection all three: one start, one end, valid successors — topology passes

Prerequisites #

  • Python 3.10+, NumPy 1.24+, shapely 2.0+ with an STRtree.
  • Input: lane centrelines with per-station width, road class per lane, and the junction extents.
  • Upstream stage: centerline generation and smoothing; these invariants check their output.
  • Output: findings with a lane identifier, a station and a measured value.

Step-by-Step #

1. Sample width per station #

python
import numpy as np

def width_profile(lane, spacing_m: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
    """Width sampled at fixed stations along the lane."""
    s = np.arange(0.0, lane.length, spacing_m)
    w = np.array([lane.width_at(x) for x in s])
    return s, w

Sampling at a metre keeps an eight-metre narrowing from falling between samples while staying cheap: a national map is a few hundred million samples, which is one pass over a columnar array.

2. Band by road class #

python
WIDTH_BAND_M = {
    "motorway": (3.25, 3.90),
    "primary": (2.90, 3.75),
    "residential": (2.60, 3.50),
    "service": (2.30, 3.40),
}

def width_findings(lane, spacing_m: float = 1.0) -> list[dict]:
    lo, hi = WIDTH_BAND_M[lane.road_class]
    s, w = width_profile(lane, spacing_m)
    bad = np.flatnonzero((w < lo) | (w > hi))
    return [{"lane": lane.id, "s": float(s[i]), "width": float(w[i]),
             "band": (lo, hi)} for i in bad]

Bands per class rather than one global range: a 2.7 m residential lane is normal and a 2.7 m motorway lane is a defect, and a single band either accepts both or rejects both.

3. Test adjacent lane bodies for intersection #

python
from shapely.geometry import LineString
from shapely.strtree import STRtree

def overlap_findings(lanes, junction_extents) -> list[dict]:
    bodies = {l.id: LineString(l.centreline).buffer(l.mean_width / 2.0,
                                                   cap_style=2) for l in lanes}
    tree = STRtree(list(bodies.values()))
    ids = list(bodies)
    out = []
    for i, lid in enumerate(ids):
        for j in tree.query(bodies[lid]):
            other = ids[j]
            if other <= lid:
                continue
            inter = bodies[lid].intersection(bodies[other])
            if inter.is_empty or any(e.contains(inter) for e in junction_extents):
                continue
            out.append({"lanes": (lid, other), "area_m2": inter.area})
    return out

The junction-extent exclusion is essential: turn paths are meant to cross, and applying the open-road rule inside a junction reports every junction as broken.

4. Detect self-overlap #

python
def self_overlap_findings(lanes) -> list[dict]:
    out = []
    for l in lanes:
        line = LineString(l.centreline)
        if not line.is_simple:
            out.append({"lane": l.id, "reason": "centreline self-intersects"})
            continue
        body = line.buffer(l.mean_width / 2.0, cap_style=2)
        if body.geom_type == "MultiPolygon" or not body.is_valid:
            out.append({"lane": l.id, "reason": "body is not a simple polygon"})
    return out

is_simple on the centreline catches the outright fold; the buffered-body check catches the near-fold, where the centreline does not quite cross itself but the lane body does. Both come from the same cause — smoothing on a hairpin with a deviation budget comparable to the radius.

Where the width band sits relative to real road classes, and why one band cannot serve all of them:

Per-Class Width Bands against One Global Band Four class bands drawn on a shared width axis, with the union band shown beneath and the two defects it would accept marked. 2.02.53.0 3.54.0 m lane width motorway primary residential service one global band the global band accepts a 2.4 m motorway lane and a 3.85 m service road — both defects, neither reported

Verification & Acceptance Criteria #

python
def assert_geometry_invariants(lanes, junction_extents) -> list[dict]:
    findings = []
    for l in lanes:
        findings += width_findings(l)
    findings += overlap_findings(lanes, junction_extents)
    findings += self_overlap_findings(lanes)
    return findings

Acceptance gate: zero self-overlaps, which are never acceptable; zero adjacent-lane body intersections outside junction extents; and width findings only where a survey note records a genuine narrowing, with every unexplained finding blocking the release. The width check runs at 1 m stations, and the gate records the spacing so a later run at a coarser spacing cannot silently pass more.

Which stage each geometric defect comes from, which is where the fix belongs:

Origin of Each Geometric Defect Three rows pairing a geometric defect with the pipeline stage it originates in and where the fix belongs. three defects, three different upstream stages local narrowing 2.1 m over eight metres boundary extraction lost one side fix the extractor adjacent bodies overlap 12 m of intersection midpoint averaging without correspondence fix the method choice body self-intersects a fold at a hairpin smoothing budget ≈ local radius fix the budget, per segment a validator that repairs rather than reports moves each of these defects somewhere harder to find

Common Errors & Fixes #

Every junction reports overlapping lanes. Junction extents are not being excluded. Exclude them; turn paths are expected to cross.

A narrowing is reported that a surveyor confirms is real. Correct behaviour — record the exemption against the lane rather than widening the band, so the next unexplained narrowing is still caught.

Self-overlap appears only after smoothing. The deviation budget is comparable to the local radius at a hairpin. Reduce the budget on high-curvature segments rather than disabling the check.

Width findings vanish when the pipeline is re-run. The station spacing was coarsened, so the narrow stretch now falls between samples. Pin the spacing and record it with the result.

The overlap test is slow on a dense tile. Pairwise comparison without an index. Build the STRtree once and query it, as in step 3.

FAQ #

Why check width per station rather than per lane? #

Because a lane that is 3.4 metres wide on average can be 2.1 metres wide for eight metres in the middle, and the average hides exactly the stretch a vehicle cannot fit through. Per-station sampling turns the check into a profile with a location, which is what a surveyor needs, and lets the gate distinguish a genuine narrowing — a construction taper — from an extraction error that pinches one station.

Should adjacent lanes ever overlap? #

Their bodies, no; their swept corridors at a junction, yes and necessarily. That is why the overlap test runs on lane bodies derived from centreline and width on open road, and is suspended inside junction extents where turn paths are expected to cross. Applying the open-road rule inside a junction reports every junction as broken, which is the fastest way to have the check disabled.

What causes a lane to overlap itself? #

A smoothing or resampling step that moved vertices far enough for the centreline to fold back on itself, usually on a hairpin where the deviation budget is comparable to the radius. The result is a lane whose body self-intersects, which every topology check passes — the lane has one start, one end and valid successors — and which no vehicle can drive. A self-intersection test on the buffered body is the only cheap way to find it.

Up one level: Topological Validation Rules — the validation stage these invariants extend.