Logical Scenarios from Routes¶
A concrete scenario names lanelets: the ego spawns on lanelet 242, an NPC follows vertices in one map's coordinates. It runs on that map only.
A logical scenario describes the ego's drive as a route pattern -- "30 to 60 m of road with an oncoming lane, a signalised left turn with traffic coming from the opposite approach, then up to 40 m out of it" -- and places every other road user relative to that route. Any map with such a road can run it: the route search finds every matching route of the map, and each match is one concrete scenario.
This is the shape a recorded drive (a T4 scene) is transpiled into by
scene_to_scenario_transpiler: the ego's drive becomes the route pattern,
each NPC becomes a hidden entity that appears when the ego has progressed
to the point it was first seen, and then drives in FOLLOW mode along
waypoints that advance on the ego's progress.
flowchart LR
doc["document.yaml<br/>route: segments..."] --> search["route search<br/>(on the run's map)"]
search -->|match 0..N| exp["scenario-expand<br/>one case per match"]
exp --> run["DeclarativeScenario<br/>ego on the match,<br/>route poses + route_progress"]
The document¶
Everything is in the Scenario IR (ScenarioDocument).
A full example, using every feature this page describes:
version: 1
id: logical_left_turn
title: Left turn with an oncoming car and a pedestrian
timeout_seconds: 60
# --- The ego's drive, as a pattern of road ------------------------------------
route:
segments: # in driving order
- kind: lane
length: {min: 30, max: 60} # m along the route; either bound optional
lanes_left: {max: 0} # same-direction lanes to the left (count range)
lanes_right: {} # {} = any
opposite_lane: "yes" # any | yes | no (bare YAML yes/no accepted)
shape: straight # any | straight | curved_left | curved_right
stop_line: any # at the segment's end (its last lanelet)
traffic_light_stop_line: any
- kind: junction
turn: left # any | left | right | straight
traffic_light: "yes"
crossing_from_left: any
crossing_from_right: any
crossing_from_opposite: "yes"
crosswalk_entry: any
crosswalk_exit: "yes"
- kind: lane
length: {max: 40}
ego_spawn_s: 5.0 # ego spawns 5 m along the route
ego_goal: true # ego's goal = route end ...
ego_goal_margin: 2.0 # ... less 2 m
match_index: 0 # which match an unexpanded run takes
max_matches: 64 # 1..1000
seed: null # null: sorted order; int: shuffled with it
entities:
- {id: ego, kind: ego, driven_by: autoware} # no spawn/goal needed: the route gives them
- id: oncoming
kind: vehicle
vehicle_type: vehicle.tesla.model3
spawn: {hidden: true} # parked out of the world; no lanelet needed
- id: walker
kind: pedestrian
spawn: {hidden: true}
actions:
# The oncoming car: appears when the ego is 40 m short of the junction,
# 30 m before the junction on the opposite approach, at 8 m/s; drives FOLLOW
# mode and waits at the junction entry until the ego is 5 m from it.
- id: oncoming_drive
type: follow_trajectory
actor: oncoming
trigger:
type: route_progress
params: {value: -40.0, anchor: "junction:0:entry"} # entity: ego (default)
params:
path_source: route
following_mode: follow
time_domain: none
speed_ms: 8.0 # initial speed on appearing, and pace
appear_on_start: true
route_vertices:
- {kind: crossing, junction: 0, approach: opposite, turn: straight, distance: -30.0}
- {kind: crossing, junction: 0, approach: opposite, turn: straight, distance: 0.0}
- {kind: crossing, junction: 0, approach: opposite, turn: straight, distance: 25.0}
advance_conditions:
- vertex: 2 # counted from 1
condition:
type: route_progress
params: {value: -5.0, anchor: "junction:0:entry", rule: greater_than_or_equal}
# A pedestrian: appears on the pavement at the exit crosswalk's right end
# when the ego enters the junction, and walks across.
- id: walker_cross
type: follow_trajectory
actor: walker
trigger:
type: route_progress
params: {value: 0.0, anchor: "junction:0:entry"}
params:
path_source: route
following_mode: follow
time_domain: none
speed_ms: 1.3
appear_on_start: true
route_vertices: | # the editor's text form is accepted too
crosswalk junction=0 leg=exit side=right along=-1.5
crosswalk junction=0 leg=exit side=right along=6
# The other route vertex kinds, for reference (an entity "lead" not shown):
# - {kind: lane, ds: 15.0, d_lane: -1, offset: 0.0, yaw: null, anchor: null}
# - {kind: opposite, ds: 10.0, lane: 1, offset: 0.0, yaw: null, anchor: "segment:0:start"}
# - {kind: roadside, ds: 20.0, side: left, kerb_distance: 1.0, yaw: null, anchor: start}
assertions:
pass:
- type: route_progress
params: {value: -2.0, anchor: end}
fail:
- type: timeout
params: {timeout_seconds: 60}
route -- the route search¶
| Key | Type | Default | Meaning |
|---|---|---|---|
segments |
list | -- (required, >= 1) | The pattern, in driving order. Each item is a lane or a junction segment, told apart by kind. |
ego_spawn_s |
float >= 0 | 0.0 |
The ego spawns this far along the route (route s). Must lie on the route. |
ego_goal |
bool | true |
The ego's goal is the route's end (less ego_goal_margin). With true the ego must not name a goal of its own (validation error); set false to keep the ego's own goal. |
ego_goal_margin |
float >= 0 | 0.0 |
How far before the route's end the goal is. The goal must be ahead of the spawn. |
match_index |
int >= 0 | 0 |
Which match a run that was not expanded takes. Must be below max_matches (validation error). |
max_matches |
int 1..1000 | 64 |
How many matches the search returns at most. |
seed |
int or null | null |
null keeps matches sorted (by lanelet ids, then start); an int shuffles the full list with random.Random(seed) before max_matches cuts it -- a deterministic sample spread over the map. |
Lane segment (kind: lane) -- a stretch of road between junctions (no
lanelet with a turn_direction tag):
| Key | Values | Meaning |
|---|---|---|
length |
{min, max} metres |
Its length along the route. No max: grown to at most 300 m. |
lanes_left, lanes_right |
{min, max} counts |
How many same-direction lanes lie on that side (routing-graph neighbours, lane-changeable or not, counted outwards), on every lanelet of the segment. {min: 1} reads "at least one". |
opposite_lane |
any / yes / no |
Whether an opposite-direction lane runs beside the road, on every lanelet of the segment (yes) or on none (no). |
shape |
any / straight / curved_left / curved_right |
By the net heading change from the segment's start point to its end point: within +-15 deg is straight, above +15 deg (anticlockwise) curved_left, below -15 deg curved_right. An S-bend with no net change reads straight. |
stop_line |
any / yes / no |
Whether its last lanelet owns a stop line (any of the three patterns has_stop_line uses). |
traffic_light_stop_line |
any / yes / no |
Whether its last lanelet's stop line is a traffic light's. |
Junction segment (kind: junction) -- the ego's way through a junction:
one to four consecutive lanelets carrying turn_direction:
| Key | Values | Meaning |
|---|---|---|
turn |
any / left / right / straight |
The lanelets' turn_direction: straight when all are straight, else the one non-straight tag (lanelets turning both ways match only any). |
traffic_light |
any / yes / no |
A traffic-light regulatory element on one of its lanelets or on the lanelet entering it. |
crossing_from_left / _right / _opposite |
any / yes / no |
Whether a lanelet of the same junction, entering it from that approach, conflicts with the ego's way (see Junctions below). |
crosswalk_entry / crosswalk_exit |
any / yes / no |
Whether a crosswalk crosses the ego's path on the way in / out (see Crosswalks below). |
Unknown keys are refused (extra="forbid"), so a typo is an error, not a
silently ignored constraint. A junction segment does not take lane keys and
vice versa.
Route vertices (path_source: route)¶
A Follow Trajectory card with path_source: route lists its vertices in
route_vertices: a list of mappings, each with a kind, or the editor's text
form, one vertex per line, kind key=value ... (commas allowed between pairs;
blank lines and # comments skipped and not counted). Unstated keys take
their defaults. At least two vertices.
kind |
Keys (default) | Python class |
|---|---|---|
lane |
ds (0), offset (0), d_lane (0), yaw (null), anchor (null) |
RouteLanePose |
opposite |
ds (0), lane (1), offset (0), yaw (null), anchor (null) |
RouteOppositePose |
crossing |
junction (0), approach (left), distance (0), turn (null), offset (0), yaw (null) |
RouteCrossingPose |
crosswalk |
junction (0), leg (entry), side (right), along (0), yaw (null) |
RouteCrosswalkPose |
roadside |
ds (0), side (left), kerb_distance (0.5), yaw (null), anchor (null) |
RouteRoadsidePose |
All are resolved when the action starts (and again at every new run of
it), against the scenario's route match; an unanchored ds counts from the
ego's position projected onto the route at that moment. yaw (radians,
anticlockwise positive) is relative to the direction named in each row; null
faces along the trajectory. junction counts the route's junction segments
from 0.
lane-- route sbase + ds, thend_lanesame-direction lanes across (+1left,-1right, through routing-graph neighbours, lane-changeable or not; the lane change is made abreast, not at equal s),offsetmetres from that lane's centreline (positive left). Before the route's start or past its end the lane is followed on (the straightest predecessor / successor).opposite-- the lane running the other way abreast of route sbase + ds, on whichever side the opposite road is (left- and right-hand traffic alike);lanecounts from the centre line outwards (1 = next to it). The pose is on that lane's lanelet abreast of the route point, at the point's projection onto it -- also where the two directions are split into lanelets at different places. Faces that lane's own direction.crossing-- a lanelet of junctionjunctionentering fromapproach(left,rightoropposite, seen from the ego), whose path through the junction turnsturnif given; of several, one whose path conflicts with the ego's way wins, then the lowest id.distanceis metres along that path from where it enters the junction (its first junction lanelet's start): negative is back on its approach road (straightest predecessors), positive through the junction and beyond.crosswalk-- the crosswalk across theleg(entry/exit) of junctionjunction(of several, the one nearest the junction).sidepicks its end on the ego's left or right;alongis metres from that end towards the other (negative: behind the end, on the pavement). Faces across.roadside-- abreast of route sbase + ds, beyond the road's edge onside-- past every same-direction lane and, when the opposite road is on that side, past it too, taking each lane's lanelet abreast of the route point --kerb_distancemetres out (negative: onto the road). Faces along the route.
A pose the map cannot give -- no opposite lane there, no crosswalk on that
leg, no lanelet from that approach, a ds running off the map -- raises when
the action starts, naming the vertex and what is missing. The validator checks
what it can without the map: the rows' shape, at least two vertices, and that
every anchor and junction index exists in the pattern.
Anchors¶
An anchor names a point of the route by its role, so a distance means the same thing on every map:
| Anchor | Route s |
|---|---|
start |
0 |
end |
the route's length |
segment:K:start, segment:K:end |
where segment K (from 0, all segments) starts / ends |
junction:K:entry, junction:K:exit |
where junction segment K (from 0, junction segments only) starts / ends |
route_progress condition¶
type: route_progress
params:
entity: null # null: the ego
rule: greater_than_or_equal # less_than | less_than_or_equal | greater_than | greater_than_or_equal | equal_to
value: -15.0 # metres past the anchor; negative is before it
anchor: "junction:0:entry" # default start
Holds when the entity's route s -- its position projected onto the route's
reference line (the route lanelets' centrelines end to end) -- compares with
anchor_s + value by rule. Projection makes it robust to lane changes onto
parallel lanes: a vehicle on the lane beside the route is at the route s it is
abreast of. Successive checks look within 40 m of the last answer, so a route
that passes the same place twice is followed pass by pass; when the entity is
more than 12 m from the route within that window (it was teleported -- an
appearing entity --, or the condition was not checked for a while) the whole
route is searched instead and the nearer answer kept. The result is clamped
to [0, length]. An entity 50 m or more below the route where it projects
(spawned hidden, parked under the map) or not in the world has no
progress: the condition does not hold, and progress is None. Usable anywhere a condition is: a trigger, an
assertion, inside all/any/not, and as a waypoint condition
(advance_conditions) -- a vertex departs when the ego has progressed to the
recorded point.
appear_on_start¶
A Follow Trajectory parameter (bool, default false): when a run of the
action starts, the entity is put on the first vertex (on the point
initial_distance_offset names), facing along the path, physics on, moving at
speed_ms (standing if unset) -- then follows the trajectory as usual, in
either mode, vehicle or pedestrian. It is what brings in an entity spawned
hidden. It cannot be combined with hidden_outside_trajectory
(validation error). An appearing entity that is not spawned hidden draws a
warning (it would stand at its spawn until then).
Entities in a logical scenario¶
- The ego needs no spawn lanelet and, with
ego_goal: true, no goal: the route gives both. An Autoware ego is valid without its own goal then. - A hidden entity needs no spawn lanelet either: it is parked out of the world -- 500 m under the ego's spawn, 10 m apart -- until an action brings it in.
- A visible entity on a fixed lanelet draws a warning: that lanelet exists on one map only.
- A route search and a lanelet constraint search cannot both drive the expansion (validation error).
Matching semantics¶
A match is a sequence of lanelets, each following the one before in the routing graph (vehicle participant, German traffic rules -- the same graph the constraint sweeper builds; no lane changes, no lanelet twice), cut into the pattern's segments in order:
- lane segments cover non-junction lanelets, junction segments junction
lanelets (
turn_directiontagged), so a lane segment always ends where the next junction begins; - segments meet at lanelet boundaries, except that a first lane segment may start part-way into its first lanelet and a last lane segment may end part-way into its last -- which is how their length is kept within range.
The part of the road a match covers is fixed, so one road is found once:
- a first lane segment is grown backwards from where it ends to its maximum length and cut there; if the road runs out first (a junction, or nothing precedes), it starts where the road does;
- a last lane segment is grown forwards to its maximum, and ends earlier only at a junction or where the road ends;
- a lone lane segment (the whole pattern) starts where its road starts;
- every interior lane segment covers whole lanelets; its length is their sum and must lie in its range;
- a first junction segment starts where the junction does (its first lanelet has no junction predecessor); a last one ends where it does.
At a fork every branch is tried, and where two adjacent lane segments may meet
at more than one lanelet boundary, each split is its own match. Matches are
deduplicated by (lanelet ids, start, end, lanelets per segment) and sorted by
the same; RouteMatch.index is the position in the returned list.
Route s runs along the route lanelets' centrelines, 0 at the match's
start (start_s metres into its first lanelet) to its length (end_s metres
into its last).
Junctions¶
The ego's entry heading is its way's heading where it enters (the start of
its first junction lanelet). The junction's members are the junction
lanelets reachable from the ego's through the routing graph's conflicting
relation (transitively), starting within 60 m of the centroid of the
ego's way. Each is walked back through junction predecessors (the straightest
one, at most 4) to the first junction lanelet of its path: a member is that
path, named by its first lanelet, with the path's turn (straight, or its one
non-straight turn_direction); paths are deduplicated by (first lanelet,
turn). A member's approach is the angle from the ego's entry heading to
its first lanelet's heading where it enters:
| Angle | Approach |
|---|---|
| within +-45 deg | the ego's own (ignored) |
| -45 to -135 deg (clockwise: it drives towards the ego's right) | left |
| +45 to +135 deg | right |
| beyond +-135 deg | opposite |
crossing_from_X: yes needs a member from approach X with a lanelet that
directly conflicts with one of the ego's junction lanelets. A crossing pose takes
any member from that approach (conflicting ones first).
Crosswalks¶
A crosswalk is a lanelet of subtype crosswalk. Along the ego's local path --
the last 10 m of the lanelet it enters on, its junction lanelets, the first
10 m of the lanelet it leaves on -- with u = 0 at the junction entry and J
the length of its way through, a crosswalk whose centreline crosses the path
at u is on the entry leg for -10 m <= u <= J/2 and on the exit leg
for J/2 < u <= J + 10 m. For a junction at the end of the route, the lanelet
it leaves on is the straightest successor (and for one at the start, the
straightest predecessor is the one it enters on).
Opposite lanes¶
The opposite lane abreast of a point is a road lanelet whose heading differs
by at least 150 deg, beside the outermost same-direction lane on either
side there: one sharing that lane's outer bound, else one within 4 m
beyond it (probed 1 m past the bound's nearest point). Both lanes are followed
along (forwards and backwards through the routing graph, not across a
junction) to the lanelet whose centreline is nearest the point, so it is the
lanelet actually abreast. Both sides are looked at, so a left-hand-traffic map
(opposite road on the right) answers the same way as a right-hand one. For
the search's opposite_lane, a lanelet "has" an opposite lane when one is
abreast of its middle.
Caps and performance¶
| Cap | Value |
|---|---|
Lane segment without length.max |
grown to 300 m |
| Lanelets per lane segment | 50 |
| Lanelets per junction segment | 4 |
| DFS steps per search | 200 000 (then the matches found so far are returned and a warning is logged) |
max_matches |
default 64, at most 1000 |
The search is a depth-first walk from every lanelet that may start the first
segment, in id order. Unshuffled (seed: null) it stops once it has
max_matches matches, since later starting lanelets sort later. Measured on
the nishishinjuku fixture map (884 road lanelets): a lane / junction / lane
pattern with the default max_matches: 64 takes about 0.2 s; one that has to
walk the whole map -- max_matches: 200, or a five-segment pattern with no
match -- takes about 3.5-4 s. Properties are cached per lanelet.
Running it¶
Expansion. scenario-expand (and the lanelet_constraint Hydra sweeper)
read the document's sweep.route and emit, per match:
ego.spawn_lanelet_id=101 ego.spawn_s=45.0
ego.goal_lanelet_id=301 ego.goal_s=38.0 # with ego_goal
scenario.route.index=0
scenario.route.lanelet_ids=[101,102,602,301]
scenario.route.start_s=40.0 scenario.route.end_s=40.0
scenario.route.segment_kinds=[lane,junction,lane]
scenario.route.segment_ends=[60.0,78.5,118.5] # route s where each segment ends
scenario.route.segment_lanelet_counts=[2,1,1]
scenario.route.junction_turns=[left]
A match the ego cannot be placed on (spawn past the end, goal not ahead of the
spawn) is skipped and logged. The exported Hydra config declares every
scenario.route.* key, empty, so struct mode accepts them.
A run. DeclarativeScenario takes the match it was given; with none (an
unexpanded run) it searches the loaded map and takes match_index, spawning
the ego ego_spawn_s along it and sending it to the end. Either way the match
becomes the scenario's route (autoware_carla_scenario.route.set_scenario_route),
and the ego is sent along it:
- an Autoware ego gets the route's lanelets between its spawn and goal lanelets as waypoints (unless waypoints were configured), so it plans the matched route rather than the shortest one;
- an ego the TrafficManager drives gets a
TurnActionper junction segment, in the junction's actualturn_direction, triggered when it is 100 m short of the junction (or past the previous one).
Python API¶
from autoware_carla_scenario import (
RouteLanePose, RouteOppositePose, RouteCrossingPose, RouteCrosswalkPose,
RouteRoadsidePose, RouteProgressCondition, FollowTrajectoryAction,
)
from autoware_carla_scenario.route import (
parse_route_search, RouteMatch, set_scenario_route, scenario_route,
)
from autoware_carla_scenario.route.search import find_route_matches
from autoware_carla_scenario.route.frame import RouteFrame, ego_placement
from autoware_carla_scenario.route.positions import resolve_route_pose
spec = parse_route_search({"segments": [...]})
matches = find_route_matches(spec, lanelet_map, routing_graph)
set_scenario_route(matches[0]) # laid on the loaded map when first used
Every name imported from the top-level package is mirrored in the Codon model, so a hand-written scenario using them passes the static check.
Limits¶
- One route per scenario, the ego's. Route poses are always measured on it. The route is process-wide; the runner clears it before every scenario's setup, and a scenario with a route search sets it there.
- Lane properties hold on every lanelet of a segment; a road whose lane count changes mid-way needs two lane segments (whose boundary is then a lanelet boundary).
shapeis the net heading change; it does not see an S-bend.- Junction membership relies on the map's lanelets overlapping (
conflicting); a junction whose crossing lanelets never overlap the ego's has no members. - A crosswalk is recognised only as a lanelet of subtype
crosswalkcrossing the ego's local path. - Without a time reference a
FOLLOWentity drives every segment atspeed_ms; recorded speed changes between vertices are expressed by waypoint conditions only. appear_on_startdoes not take the entity out again at the end; combine with anuntilcondition or leave it standing.