Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions assets/maps/demo/lht/01_two_lane_oneway.xodr
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Left-hand traffic one-way corridor with two driving lanes.
Per OpenDRIVE LHT, positive lane IDs travel in +s.
Lane +1 is nearer the median; lane +2 is nearer the left curb (slower). -->
<OpenDRIVE>
<header revMajor="1" revMinor="6" name="lht_two_lane_oneway" version="1.0" date="2026-07-19"/>
<road name="LHT Two Lane Oneway" length="40.0" id="1" junction="-1" rule="LHT">
<type s="0" type="town">
<speed max="50" unit="km/h"/>
</type>
<planView>
<geometry s="0" x="0" y="0" hdg="0" length="40.0">
<line/>
</geometry>
</planView>
<lanes>
<laneOffset s="0" a="0" b="0" c="0" d="0"/>
<laneSection s="0">
<left>
<lane id="2" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
</lane>
<lane id="1" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
</lane>
</left>
<center>
<lane id="0" type="none" level="false"/>
</center>
</laneSection>
</lanes>
</road>
</OpenDRIVE>
38 changes: 38 additions & 0 deletions assets/maps/demo/lht/02_three_lane_speeds.xodr
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Left-hand traffic one-way with three lanes and explicit per-lane speeds.
+1 (median / faster) 80 km/h, +2 (middle) 60 km/h, +3 (curb / slower) 40 km/h. -->
<OpenDRIVE>
<header revMajor="1" revMinor="6" name="lht_three_lane_speeds" version="1.0" date="2026-07-19"/>
<road name="LHT Three Lane Speeds" length="50.0" id="2" junction="-1" rule="LHT">
<type s="0" type="town">
<speed max="60" unit="km/h"/>
</type>
<planView>
<geometry s="0" x="0" y="0" hdg="0" length="50.0">
<line/>
</geometry>
</planView>
<lanes>
<laneOffset s="0" a="0" b="0" c="0" d="0"/>
<laneSection s="0">
<left>
<lane id="3" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
<speed sOffset="0" max="40" unit="km/h"/>
</lane>
<lane id="2" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
<speed sOffset="0" max="60" unit="km/h"/>
</lane>
<lane id="1" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
<speed sOffset="0" max="80" unit="km/h"/>
</lane>
</left>
<center>
<lane id="0" type="none" level="false"/>
</center>
</laneSection>
</lanes>
</road>
</OpenDRIVE>
41 changes: 41 additions & 0 deletions assets/maps/demo/lht/03_two_way.xodr
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Left-hand traffic two-way road.
+s travel on left (positive IDs); -s travel on right (negative IDs).
Each direction has a faster median lane and a slower curb lane. -->
<OpenDRIVE>
<header revMajor="1" revMinor="6" name="lht_two_way" version="1.0" date="2026-07-19"/>
<road name="LHT Two Way" length="60.0" id="3" junction="-1" rule="LHT">
<type s="0" type="town">
<speed max="50" unit="km/h"/>
</type>
<planView>
<geometry s="0" x="0" y="0" hdg="0" length="60.0">
<line/>
</geometry>
</planView>
<lanes>
<laneOffset s="0" a="0" b="0" c="0" d="0"/>
<laneSection s="0">
<left>
<lane id="2" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
</lane>
<lane id="1" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
</lane>
</left>
<center>
<lane id="0" type="none" level="false"/>
</center>
<right>
<lane id="-1" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
</lane>
<lane id="-2" type="driving" level="false">
<width sOffset="0" a="3.5" b="0" c="0" d="0"/>
</lane>
</right>
</laneSection>
</lanes>
</road>
</OpenDRIVE>
104 changes: 104 additions & 0 deletions tests/domains/driving/test_lht_driving.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Driving-domain tests on left-hand traffic demo maps."""

import inspect
from pathlib import Path

import pytest

from tests.utils import compileScenic, sampleScene

pytestmark = pytest.mark.filterwarnings(
"ignore::scenic.formats.opendrive.OpenDriveWarning"
)

LHT = Path(__file__).resolve().parents[3] / "assets" / "maps" / "demo" / "lht"

ALL_MAPS = (
"01_two_lane_oneway.xodr",
"02_three_lane_speeds.xodr",
"03_two_way.xodr",
)

template = inspect.cleandoc(
"""
param map = r'{map}'
param map_options = dict(useCache=False)
model scenic.domains.driving.model
"""
)

basicScenario = inspect.cleandoc(
"""
lane = Uniform(*network.lanes)
ego = new Car in lane
follower = new Car on visible lane.centerline
"""
)

fasterLaneScenario = inspect.cleandoc(
"""
candidates = [lane for lane in network.lanes if lane.sections[0]._fasterLane]
require len(candidates) > 0
lane = Uniform(*candidates)
ego = new Car in lane
other = new Car in lane.sections[0].fasterLane.lane
"""
)

slowerLaneScenario = inspect.cleandoc(
"""
candidates = [lane for lane in network.lanes if lane.sections[0]._slowerLane]
require len(candidates) > 0
lane = Uniform(*candidates)
ego = new Car in lane
other = new Car in lane.sections[0].slowerLane.lane
"""
)


def _compile(path, code):
preamble = template.format(map=path)
return compileScenic(preamble + "\n" + code, mode2D=True)


def _sample_two_car_scene(name, scenario_code):
scenario = _compile(LHT / name, scenario_code)
scene = sampleScene(scenario, maxIterations=1000)
assert len(scene.objects) == 2
ego, other = scene.objects
assert ego is scene.egoObject
return ego, other


@pytest.mark.parametrize("name", ALL_MAPS)
def test_lht_basic_car_placement(name):
"""Ego and a second car can be placed on the same sampled lane."""
ego, follower = _sample_two_car_scene(name, basicScenario)

assert ego.lane is follower.lane
assert ego.laneSection.lane is follower.laneSection.lane
assert follower.lane.containsPoint(follower.position)


@pytest.mark.parametrize("name", ALL_MAPS)
def test_lht_faster_lane_placement(name):
"""Ego sits on a slower lane; other is placed on its fasterLane neighbor."""
ego, other = _sample_two_car_scene(name, fasterLaneScenario)

ego_sec = ego.laneSection
assert ego_sec._fasterLane is not None
assert other.lane is ego_sec.fasterLane.lane
assert other.lane is not ego.lane
assert other.laneSection.lane is ego_sec.fasterLane.lane


@pytest.mark.parametrize("name", ALL_MAPS)
def test_lht_slower_lane_placement(name):
"""Ego sits on a faster lane; other is placed on its slowerLane neighbor."""
ego, other = _sample_two_car_scene(name, slowerLaneScenario)

ego_sec = ego.laneSection
assert ego_sec._slowerLane is not None
assert other.lane is ego_sec.slowerLane.lane
assert other.lane is not ego.lane
assert other.laneSection.lane is ego_sec.slowerLane.lane
163 changes: 163 additions & 0 deletions tests/formats/opendrive/test_lht.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Left-hand traffic OpenDRIVE maps — centerlines and faster/slower lanes."""

from pathlib import Path

import pytest

from scenic.core.distributions import RejectionException
from scenic.domains.driving.roads import Network

pytestmark = pytest.mark.filterwarnings(
"ignore::scenic.formats.opendrive.OpenDriveWarning"
)

DEMO = Path(__file__).resolve().parents[3] / "assets" / "maps" / "demo" / "lht"

ALL_MAPS = (
"01_two_lane_oneway.xodr",
"02_three_lane_speeds.xodr",
"03_two_way.xodr",
)


def load(name):
return Network.fromFile(DEMO / name, useCache=False)


def section_by_id(network, od_id):
for road in network.roads:
for sec in road.sections:
if od_id in sec.lanesByOpenDriveID:
return sec.lanesByOpenDriveID[od_id]
raise AssertionError(f"no lane section with OpenDRIVE id {od_id}")


def centerline_delta(lane):
pts = lane.centerline.points
return pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1]


def carriageway_groups(road):
if road.forwardLanes:
yield road.forwardLanes
if road.backwardLanes:
yield road.backwardLanes


def assert_faster_slower(network, sec, *, faster_id, slower_id):
faster = section_by_id(network, faster_id) if faster_id else None
slower = section_by_id(network, slower_id) if slower_id else None
assert sec._fasterLane is faster
assert sec._slowerLane is slower
if faster_id is None:
with pytest.raises(RejectionException):
_ = sec.fasterLane
else:
assert sec.fasterLane is faster
if slower_id is None:
with pytest.raises(RejectionException):
_ = sec.slowerLane
else:
assert sec.slowerLane is slower


@pytest.mark.parametrize(
"name, n_lanes, oneway",
[
("01_two_lane_oneway.xodr", 2, True),
("02_three_lane_speeds.xodr", 3, True),
("03_two_way.xodr", 4, False),
],
)
def test_lht_network_structure(name, n_lanes, oneway):
network = load(name)
road = network.roads[0]

assert len(network.roads) == 1
assert len(network.lanes) == n_lanes
assert road.is1Way is oneway

for lane in network.lanes:
assert not lane.polygon.is_empty
assert lane.polygon.is_valid
for sec in lane.sections:
assert sec.containsRegion(sec.centerline, tolerance=0.5)
assert sec.containsRegion(sec.leftEdge, tolerance=0.5)
assert sec.containsRegion(sec.rightEdge, tolerance=0.5)


@pytest.mark.parametrize("name", ALL_MAPS)
def test_centerlines_follow_traffic_flow(name):
"""Centerlines point with traffic; orientation matches flow at lane midpoints."""
road = load(name).roads[0]

for group in carriageway_groups(road):
deltas = [centerline_delta(lane) for lane in group.lanes]
signs = [1 if dx > 0 else -1 if dx < 0 else 0 for dx, _ in deltas]
assert 0 not in signs, f"{name}: zero-length centerline"
assert len(set(signs)) == 1, f"{name}: mixed centerline directions {deltas}"

for lane in group.lanes:
pt = lane.centerline.pointAlongBy(0.5, normalized=True)
dirs = road.network.nominalDirectionsAt(pt)
assert pytest.approx(lane.orientation[pt]) in dirs
assert lane.containsPoint(pt)

if not road.is1Way:
fwd = centerline_delta(road.forwardLanes.lanes[0])[0]
bwd = centerline_delta(road.backwardLanes.lanes[0])[0]
assert fwd * bwd < 0


def test_two_lane_faster_slower_toward_median():
network = load("01_two_lane_oneway.xodr")
assert_faster_slower(network, section_by_id(network, 2), faster_id=1, slower_id=None)
assert_faster_slower(network, section_by_id(network, 1), faster_id=None, slower_id=2)


def test_three_lane_faster_slower_chain():
network = load("02_three_lane_speeds.xodr")
assert_faster_slower(network, section_by_id(network, 3), faster_id=2, slower_id=None)
assert_faster_slower(network, section_by_id(network, 2), faster_id=1, slower_id=3)
assert_faster_slower(network, section_by_id(network, 1), faster_id=None, slower_id=2)


def test_two_way_faster_slower_each_carriageway():
network = load("03_two_way.xodr")
assert_faster_slower(network, section_by_id(network, 2), faster_id=1, slower_id=None)
assert_faster_slower(network, section_by_id(network, 1), faster_id=None, slower_id=2)
assert_faster_slower(
network, section_by_id(network, -2), faster_id=-1, slower_id=None
)
assert_faster_slower(
network, section_by_id(network, -1), faster_id=None, slower_id=-2
)


@pytest.mark.parametrize("name", ALL_MAPS)
def test_lane_adjacency_helpers(name):
"""shiftedBy, faster/slower neighbors, and same-direction constraints."""
network = load(name)
for road in network.roads:
for sec in road.sections:
for lane_sec in sec.lanes:
neighbors = {lane_sec._laneToLeft, lane_sec._laneToRight} - {None}
if lane_sec._laneToLeft:
assert lane_sec.shiftedBy(1) is lane_sec._laneToLeft
if lane_sec._laneToLeft.isForward == lane_sec.isForward:
assert lane_sec._laneToLeft.shiftedBy(-1) is lane_sec
if lane_sec._laneToRight:
assert lane_sec.shiftedBy(-1) is lane_sec._laneToRight
if lane_sec._laneToRight.isForward == lane_sec.isForward:
assert lane_sec._laneToRight.shiftedBy(1) is lane_sec
if lane_sec._fasterLane:
assert lane_sec._fasterLane in neighbors
assert lane_sec._fasterLane.isForward == lane_sec.isForward
if lane_sec._slowerLane:
assert lane_sec._slowerLane in neighbors
assert lane_sec._slowerLane.isForward == lane_sec.isForward

if name == "03_two_way.xodr":
road = network.roads[0]
assert road.forwardLanes._opposite is road.backwardLanes
assert road.backwardLanes.opposite is road.forwardLanes