One-Sided Corridors for Divided Highways
On an undivided street, both sides of the road are equally reachable and a symmetric corridor is correct. On a motorway, a dual carriageway or a road with a central reservation, the two sides are different places — a service area two hundred metres to the left may require driving twelve kilometres to the next interchange and back. A corridor that ignores which side a candidate is on will keep offering those, and the routing-based ranking will keep rejecting them after paying for two shortest-path queries each. Filtering by side first is a few floating-point operations per candidate and removes roughly half the set before any routing happens. This page derives the side test, handles the cases where it does not apply, and shows where it belongs in the pipeline.
Prerequisites & Versions
Pure client-side geometry over the corridor’s candidate set; no server-side dependency beyond the corridor query itself.
| Requirement | Minimum version | Install |
|---|---|---|
| Python | 3.11 | — |
| neo4j (async driver) | 5.20 | pip install "neo4j>=5.20" |
| Neo4j Server | 5.15 | native point |
Implementation
The side of a point relative to a directed segment is the sign of a two-dimensional cross product. The only subtlety is doing it in a local metric frame so that a degree of longitude and a degree of latitude are comparable.
import math
from dataclasses import dataclass
from typing import Literal
EARTH_R = 6_371_008.8
Side = Literal["left", "right", "on"]
@dataclass(frozen=True)
class Segment:
a_lat: float
a_lon: float
b_lat: float
b_lon: float
divided: bool # is there a barrier between the carriageways?
drive_side: Side # "left" in the UK and Japan, "right" in most of Europe
def _to_local_m(lat: float, lon: float, ref_lat: float, ref_lon: float
) -> tuple[float, float]:
"""Metres east and north of a reference point.
Working in degrees would make the cross product meaningless away from the
equator, because a degree of longitude is shorter than a degree of latitude
everywhere except there — the sign would still usually be right, and would
flip in exactly the near-parallel cases this test exists to resolve.
"""
east = math.radians(lon - ref_lon) * EARTH_R * math.cos(math.radians(ref_lat))
north = math.radians(lat - ref_lat) * EARTH_R
return east, north
def side_of(segment: Segment, lat: float, lon: float,
tolerance_m: float = 3.0) -> Side:
"""Which side of the directed segment a point lies on.
The cross product of the segment vector with the vector to the point is
positive on the left and negative on the right, in a right-handed frame with
east as x and north as y. Points within `tolerance_m` of the centreline are
reported as "on", because a signed test on a point that is essentially on the
line is noise rather than information.
"""
ax, ay = _to_local_m(segment.a_lat, segment.a_lon, segment.a_lat, segment.a_lon)
bx, by = _to_local_m(segment.b_lat, segment.b_lon, segment.a_lat, segment.a_lon)
px, py = _to_local_m(lat, lon, segment.a_lat, segment.a_lon)
seg_x, seg_y = bx - ax, by - ay
seg_len = math.hypot(seg_x, seg_y)
if seg_len < 1e-6:
return "on"
cross = seg_x * (py - ay) - seg_y * (px - ax)
perpendicular_m = cross / seg_len # signed distance, in metres
if abs(perpendicular_m) <= tolerance_m:
return "on"
return "left" if perpendicular_m > 0 else "right"
def reachable_without_uturn(segment: Segment, lat: float, lon: float) -> bool:
"""A candidate is directly reachable when no barrier separates it.
On an undivided road either side is fine. On a divided one, only the side the
vehicle is driving on can be entered directly; the other requires an
interchange, which is a routing cost rather than a geometric one.
"""
if not segment.divided:
return True
side = side_of(segment, lat, lon)
return side in ("on", segment.drive_side)
How It Works
Three pieces make the test correct rather than merely plausible.
The local metric frame is not optional. In raw degrees the cross product mixes two units — a degree of longitude is about 111 km at the equator and 55 km at 60° north — so the sign is computed on a distorted plane. For a candidate well off to one side that distortion does not flip the answer; for one nearly in line with the segment, which is precisely the ambiguous case, it can. Converting to metres east and north around the segment’s own start point removes the distortion at negligible cost.
The tolerance band is what stops noise becoming a decision. A candidate whose recorded coordinate sits a metre from the centreline is not meaningfully on either side, and the sign of its cross product is a property of GPS error rather than of geography. Reporting those as "on" and treating them as reachable avoids a filter that silently drops legitimate stops because a survey put them on the wrong pixel.
Side is not the same as reachability, and the divided flag is what connects them. On an ordinary street, both sides are reachable and the test should not be applied at all — running it there would halve the candidate set for no reason. The flag comes from the source data: OSM’s dual_carriageway, a physical barrier tag, or the presence of two parallel one-way ways with the same name. Getting that flag right at ingestion matters more than the geometry does, because the geometry is exact and the flag is a judgement.
Common Failure Patterns
1. Applying the side filter to undivided roads. Half the candidates disappear for no reason, and the ones that disappear are the ones on the other side of an ordinary street — which any driver can reach by turning. The divided flag has to gate the test, and a graph that does not carry it should not be running this filter at all.
2. Taking the side from the wrong segment. A candidate near a junction is close to several segments with different bearings, and the side is only meaningful relative to the one the vehicle is actually on. Use the segment the candidate’s perpendicular projection falls within — the same clamped projection the corridor’s exact clip already computes, so it is available for free.
3. Hard-coding the driving side. A service that ships to both the UK and continental Europe with drive_side="left" compiled in offers the wrong carriageway in half its markets. It is a property of the region, and it belongs alongside the region’s other routing configuration rather than in the geometry code.
# WRONG: correct in Tokyo and London, wrong in Paris and Berlin.
if side_of(segment, lat, lon) == "left":
keep(candidate)
# RIGHT: the side comes from the road, which got it from the region.
if reachable_without_uturn(segment, lat, lon):
keep(candidate)
Performance Notes
The filter costs a handful of arithmetic operations per candidate and removes, on a divided road, close to half of them:
$$N_{\text{routed}} \approx N_{\text{corridor}} \cdot \big(1 - f_{\text{divided}} \cdot 0.5\big)$$
where $f_{\text{divided}}$ is the fraction of the route on divided carriageway. On a long-distance trip that fraction is high and the saving is close to the full half; on an urban route it is small and so is the benefit. Either way the filter is essentially free, because it runs on data already in memory — the corridor query has just returned these candidates and their coordinates.
The ordering within the pipeline matters more than the filter’s own cost. Run it after the envelope seek and the exact perpendicular clip, and before the detour-cost ranking. That placement is what makes it worthwhile: the routing step is thousands of times more expensive per candidate than the geometry, so removing a candidate before it is routed saves real work, while removing one before it is clipped saves almost nothing.
There is one case where the filter should be relaxed rather than applied: a long trip with a mandatory stop, where a driver would accept an interchange detour because the alternative is running out of range. Treating “wrong side” as a ranking penalty rather than an exclusion handles that gracefully — the far-side candidates fall to the bottom of the list instead of vanishing from it, and the detour ranking then prices them honestly.
A note on where the divided flag should live. It is tempting to derive it at query time from the road classification — motorways are divided, residential streets are not — but that rule is wrong often enough to matter: plenty of trunk roads are single carriageway, and plenty of urban avenues have a central reservation with no crossing for half a kilometre. Deriving it once at ingestion from the actual tags, and storing it on the segment, means the query is reading a fact rather than re-guessing it, and a correction to one road fixes every corridor that touches it rather than every code path that asks.
Related
- Corridor and Buffer Queries Along a Route — the pipeline this filter sits inside.
- Ranking Detour Cost for Corridor Candidates — the expensive stage this one protects.
- Snapping GPS Telemetry to Road Segments — the clamped projection that picks the right segment to measure against.
- Modeling Turn Restrictions as an Edge-Based Graph — where “you cannot get there from here” becomes a graph property rather than a geometric one.
This guide is part of Corridor and Buffer Queries Along a Route, within Cypher Spatial Queries & Pathfinding Patterns.