Scenario Editor¶
The Scenario Editor is a web UI for authoring scenarios declaratively and exporting them as self-contained wheelhouses. It runs as its own application:
It is deliberately separate from the Scenario Result Viewer
(uv run viewer). Neither mounts the other, and running one does not start the
other; integrating the two is a later phase.
What it edits¶
The editor never edits Python. Its canonical representation is the Scenario
IR -- a ScenarioDocument stored as YAML -- which the framework compiles into
the existing runtime primitives:
There is no editor-only runtime. Every action a document names is built by
autoware_carla_scenario.actions, every condition by
autoware_carla_scenario.conditions, and every spawn constraint is evaluated by
the same sweeper.constraints engine a --multirun sweep uses.
Which map: the Map library¶
A scenario's map is named by a source URI pointing at a git repository, and the editor fetches and caches it. Map library in the header browses a repository -- HuggingFace, GitHub, your own -- and lists every map in it; the Scenario inspector shows, per scenario, whether the map is downloaded and whether its revision is pinned.
See HD Maps from a Git Repository. Two things are worth knowing here: a map Autoware's own setup already downloaded is used as it sits, and OpenDRIVE is not needed to author a scenario -- lanelets are.
Where it happens: the places panel¶
One map carries every lanelet the scenario names -- each entity's spawn, the ego's goal, and the lanelet every condition watches. The canvas says what happens and in what order; it has no way to say where, and until this panel existed the answer was a scatter of ids across lane heads and condition cards that only meant something with the map open in another window.
It sits at the top of the right-hand rail, over the inspector:
+------------------------------------------+---------------------------+
| Arrangement (the timeline, full width) | Places < 1/133 > |
| ACTOR | Init | 1 | 2 | 3 | 4 | 5 | (o) NPC1 spawn BOUND |
| Ego | | [] | | | | | (o) Ego goal |
| NPC1 | | [] | [] | | | | (o) Ego spawn |
| Env | | | [] | | | | [Ego spawn][Ego goal][..] |
| PASS | ... +---------------------------+
| FAIL | ... | Inspector |
+------------------------------------------+---------------------------+
Three things had to be true at once -- the map, the timeline and the inspector all on screen without scrolling -- and this is the arrangement that gets there without taking width from the thing that needs it. The timeline's axis is width: a card further right is later in the story, so narrowing it costs steps you can see. The map is roughly square and the inspector is a column, so both fit the rail; and "where" then sits directly over "what", which is what makes clicking a place and reading its object one movement of the eye.
The whole editor is one CSS grid (.ed-stage) for that reason. #editor-body
stays the single htmx swap target and simply does not draw a box -- display:
contents lifts the timeline and the inspector into that grid -- so the map can
sit beside either of them while every edit still re-renders one element. Moving
the panel is a change to one block of editor.css, not to a template.
Which glyph a place gets is the field's own declaration, not a guess from
whatever holds it: FieldSpec.place is spawn, goal or watched, so the
Set Goal action's lanelet draws as a destination exactly as the ego's own goal
does. A primitive that registers a new lanelet field is drawn without the map
learning anything about it.
Each place is a pin in its actor's track colour -- the same hue that actor's lane head and every condition naming it already wear -- with a glyph for what the place is: a target for a spawn, a flag for a goal, an eye for a lanelet something watches. The viewer has one highlight channel, so the outline says "this scenario touches here" and the pins say which of them is which.
Under the map the same places are chips, in document order. Clicking one opens the object that named it in the inspector and points the map at it; clicking a place on the map does the same, because the overview draws places the document already names and the only thing a click there can mean is "show me what named this". Nothing is edited here -- a lanelet is still set where it is written, in the picker its own field opens.
A label reads away from its dot, except on the right half of the map, where it reads back towards the middle: that edge is also where the viewer keeps its own zoom buttons, and a label running under them is a label nobody can read.
An abstract scenario draws one bound pattern¶
A scenario whose lanelet is a constraint search does not name a place: it names
a set, and the sweeper runs it once per member. Outlining all 133 matches
draws the search, and none of those 133 runs looks like that picture. So one
match is bound -- through the same swept_slot the exported config sweeps
and the same constraint engine the sweep evaluates -- and the panel says which
of how many is on screen. The arrows step through the runs a --multirun sweep
would perform, one concrete scenario at a time.
Everything else keeps the id stored beside it, because that is exactly what a run that does not sweep uses: the sweeper enumerates a single target key, so at most one lanelet in a document is ever bound.
Binding means parsing the map on the server, which is measured in seconds, so it is a button (Bind a pattern) rather than something every page load pays for. Once the map is cached every later render binds without being asked, including the panel's own refresh after an edit.
Where a pin goes¶
The labels are the server's -- rendered with the panel, beside the list that
repeats them -- and only the coordinates are the viewer's. editor.js asks it
through its public API: focusOn(id) centres the view on a primitive, so
focusing one and reading getView() back reports that lanelet's centre in the
map's own coordinates, and the view is then put back where it was. The
alternative was projecting the .osm a second time in JavaScript, which is a
second answer to "where is this" that can disagree with the drawing it is laid
over. Each lanelet is measured once and kept on the frame, which is the frame
reuseMap() parks across a re-render, so an edit re-places the pins without
asking the map anything.
The panel frames itself on the places rather than on the whole city when the map
loads, and a pan or zoom after that is the person's: a re-render carries it
across, exactly as the picker's does. Tilted into the viewer's 3D view the pins
hide themselves, because getView() then reports drawing coordinates and a
point on a hill and the ground behind it are drawn in the same place.
This is a second parse of the same .osm while a picker is open, which is the
cost the inspector's own map was removed to avoid. It is a different drawing
rather than the same one twice: the picker answers "which lanelet is this
field", and the overview answers "where is this scenario" -- a question nothing
else on the page answers at all.
The canvas¶
The main view is a swimlane DAG drawn as a DAW arrangement: one track per actor, with the horizontal axis reading as scenario progression, not time. Under the actors sits the Environment track, for actions no vehicle performs.
STEP > | 1 | 2 | 3
------------+------------+-------------------+------------------
Ego | Drive | |
------------+------------+-------------------+------------------
NPC1 | Follow | | # Lane Change Left
| | | ^
| | | +-----+-----+
| | | | ALL |
| | | NPC1->Ego NPC1->Ego
| | | Distance TTC
| | | < 20 m < 4 s
------------+------------+-------------------+------------------
Environment | | Set Traffic Signal|
------------+------------+-------------------+------------------
PASS | NPC1 enters the ego lane
FAIL | Collision | Timeout 30 s
Which track an action belongs to¶
The action type decides, never the author. ActionSpec.scope is either
actor or environment, and an environment action -- setting traffic lights,
for instance -- is drawn in the Environment track whatever its actor field
says. Nothing reads that field when such an action is built, so honouring it
put the card in some vehicle's lane and claimed a relationship the run does not
have. The editor no longer offers the field for one, refuses it on the way in,
and drops a stale value on the next save; each track's + action menu offers
only the actions that belong in it.
The Environment track is drawn even when it is empty, because it is where you go to add the first one. An action that merely has no actor yet is drawn there too: it is invalid, and a card nothing draws cannot be corrected.
The sequencer furniture is what makes the direction readable: track headers down the left, a numbered ruler across the top, a bar line before every slot and every other slot shaded. The ruler counts steps and not seconds, which is the whole reason it is a step ruler -- see the first rule below.
What a step is¶
Nothing in Python has a "step". The runtime arms every action from the first tick and ticks them all every frame:
An action fires when its own trigger says so, not when its column comes up, and
an action with no trigger gets AlwaysTrueCondition -- so it fires on tick 1
however far right it is drawn. column_hint is read by nothing outside this
editor: compiler.py, declarative.py and the exporters contain no reference
to ui at all.
So a step is not a point in time. It is a set of actions nothing orders, and the canvas draws it that way:
- Several actions can share one step, stacked in the same column. That is the honest picture of two actions that are both armed and neither waiting on the other -- spreading them across two columns would draw a sequence the runtime does not have.
- An action may not share a step with anything its trigger waits on. Within
a step nothing is ordered, so a dependency has to be visibly earlier. Adding
an
action_statetrigger pushes the dependent right on the spot, moving a dependency right carries its dependents with it, and a move that would break the rule is repaired rather than refused -- the card lands as close to where it was aimed as its dependencies allow.validator.pyreports a hand-editeduiblock that says otherwise.
That makes the step axis mean exactly one thing: left of is "already finished, and something here is waiting on it". It still says nothing about how much earlier.
A step number means the same column on every lane, so there is no leading column an actor track has and the Environment or verdict lanes do not. A spawn is not a step -- it is the state a track starts in -- so it is stated in the lane head rather than given a slot on the ruler.
An action with no trigger fires on the first tick, wherever it is drawn.
BaseAction defaults to AlwaysTrueCondition, so a card in step 5 with an
empty trigger runs immediately and its position is simply wrong about it. The
canvas writes "fires immediately" under the card, and the validator warns --
never errors, because a position is presentation.
Three rules make it readable:
- The distance between cards means nothing. Column position is
ui.column_hint, which is presentation only and never reaches the runtime. A clip in step 3 may only start after one in step 2 if something says so; how much later is not on screen because the document does not know. - There is no separate event lane. A condition is a trigger, drawn under the action it fires and joined to it by a solid line, so cause and effect are next to each other instead of being correlated across the screen. A Follow Trajectory card can also hold conditions on its vertices -- waypoint conditions, edited in its inspector with the same condition controls.
- One actor reacting to another is a reference, not a coincidence.
"Swerve once NPC1 has cut in" is a
Lane Changeclip on the ego track whose trigger is an Action state condition naming NPC1's cut-in and the statecompleteState. That reference lives in the document (params: {action: a_..., state: completeState}), so it is a fact the scenario contains rather than something the canvas infers.
The canvas draws that reference as a dashed line in the causing actor's track colour, running from the action to the condition waiting on it. The cut-in example reads as one chain across two tracks:
NPC1 ... [Cut in] ......
: <- dashed: Cut in -> the condition waiting on it
Ego ..............:... [Evade right]
^ Cut in · NPC1 | Action | completeState
solid: fires this action
Both line types are named in the legend above the canvas, because a dash
pattern is not self-explanatory. Crucially the dashed line is drawn only
from data-caused-by, the document's own reference -- never from where two
cards happen to sit. Moving a clip can therefore neither invent a causal link
nor erase one, and a condition that names no action (a distance check, say) is
never drawn as caused by anything.
Action states¶
The states are ASAM OpenSCENARIO 1.2's StoryboardElementState, applied to a
single action:
| State | Meaning |
|---|---|
standbyState |
Instantiated, waiting for its start trigger |
startTransition |
The trigger fired and execute() ran -- held for one tick |
runningState |
The work is under way |
endTransition |
The completion criteria were met -- held for one tick |
completeState |
Finished (a repeating action returns to standbyState) |
completeState is the one that makes "after the cut-in" verifiable. A forced
lane change stays in runningState until the vehicle has actually settled
onto the next lane -- a different lane id, within
LANE_CHANGE_CENTER_TOLERANCE_M of its centre and
LANE_CHANGE_HEADING_TOLERANCE_DEG of its heading. A lane id change alone is
not enough: a car whose centre has just crossed the boundary is still diagonal,
and a reaction triggered on that would fire mid-manoeuvre.
There is deliberately no failure state and no action-level timeout, because
OpenSCENARIO has neither. A manoeuvre that never happens stays in
runningState forever, so completeState cannot be reached by a lane change
that did not occur; ending such a run is the scenario timeout's job, which is
already a FAIL condition. The two transition states are each held for exactly
one tick, so a condition watching startTransition sees it.
Which is also why a card can be moved into an empty step rather than only swapped with its neighbour: a reaction has to be placeable to the right of the cause on another track, and its own track is usually empty in between.
Every condition reads as subject -> target | metric | rule value, e.g.
NPC1 -> Ego | Distance | < 20 m or Ego -> Lanelet 183 | Position | inside.
A target that is not an actor is named for the coordinate system it is in.
This framework speaks both Lanelet2 and OpenDRIVE and their ids are written the
same way, so Ego -> 183 would say nothing about which 183 is meant.
Position: pick a frame, not a mixture¶
There are two position conditions, one per coordinate system, because the two need different fields:
| Address | Reads as | |
|---|---|---|
| Position (Lanelet2) | a lanelet | NPC1 -> Lanelet 183 \| Position \| inside |
| Position (OpenDRIVE) | a road, optionally a lane | NPC1 -> Road 80 \| Position \| lane 2 \| inside |
Either can be narrowed to a stretch of that lane with s from / s to and
t from / t to. The runtime has always accepted these comparisons; the editor
used to discard them. They are the abstract way to say "in the last 20 m before
the junction": unlike an absolute region, s and t are measured along and
across the lane, so the condition keeps its meaning wherever a constraint sweep
puts the entity.
A lanelet already names one lane, so the Lanelet2 condition resolves it to
an OpenDRIVE road and lane and there is nothing further to pin. An
OpenDRIVE road does not: a road carries several lanes, so road and lane
together are what address a place uniquely, and a road left without a lane says
so on the card (any lane) rather than looking like a precise address.
Lanelet2 ids are picked, never typed¶
Every field naming a Lanelet2 lanelet is edited only on the map. The number beside it is a
readout, not an input: nobody knows lanelet ids by heart, so typing one is
guesswork the map answers exactly, and making the map the single editor leaves
one path a value can arrive by instead of two to keep in step. Edit opens
the viewer over the whole window -- the inspector column is 384px wide, and a
city map that size cannot be picked from -- and clicking a lanelet fills the
field and closes it. This comes from the field's declared kind (lanelet), so
any field naming a lanelet gets the picker without touching a template.
There is no "type it instead" fallback for a viewer that will not load, for the same reason the SVG map fallback was removed: the page fetches htmx from a CDN too, so an editor that cannot reach the network has no working controls at all.
Three fields still take typed ids, each because their value is not only a
lanelet id, and a test in test_authoring_registry.py fails if a new one
appears without being listed there with its reason:
| Field | Why it is not picked |
|---|---|
equals.value (spawn constraint) |
any matches every lanelet -- a sentinel the sweeper parses, not an id |
in_set.values (spawn constraint) |
accepts ${map.no_3d_model_lanelet_ids}, a reference resolved at sweep time |
traffic_signal regulatory element ids |
the viewer's regulatory layer reports the id of the linestring that draws a sign, not of the regulatory element -- a picker there would save a confidently wrong number |
Two implementation notes, both of which cost an afternoon to find:
- The picker is moved to
<body>before it is shown. The inspector sits inside aposition: stickywrapper, and sticky creates a stacking context whatever its ownz-indexis -- so az-indexleft in place ranks only against its siblings, and the canvas lanes (z-index: 2) paint straight over a modal asking for 80. - A set of lanelets works the same way, toggling on each click, and saves when the picker is closed -- sending the form on every toggle would re-render the inspector and tear the map down mid-selection.
- Which viewer layers a click may land on is part of the field's declaration
(
FieldSpec.picks). The layers overlap: a click on a road usually lands on the direction arrow, and a hair to the side lands on abound, which reports the id of a linestring, not of either lanelet it separates. Only the layers whose id is the lanelet's own are accepted. /staticis served withCache-Control: no-cache.StaticFilessends anETagbut noCache-Control, which lets a browser invent a freshness lifetime and keep running an oldeditor.jswithout asking -- an editor visibly missing a feature the server is already serving. TheETagstill makes the check a 304.
They are separate conditions rather than one with a coordinate-system switch
because offering a lanelet and a lane on the same condition is what let the two
contradict each other: on the nishishinjuku map lanelets 183 and 184 are lanes
2 and 1 of road 80, so pinning lanelet 183 and lane 1 asked for a place that
does not exist.
Compose them with ALL, ANY, NOT, Sticky and Persistent, which map onto
AndCondition, OrCondition, NotCondition, StickyCondition and
PersistentCondition.
Fixed or searched: every lanelet, one question¶
Edit on any lanelet field opens the map, and the panel beside it asks the same thing wherever you opened it from — Fixed or Constraint search:
| Where a lanelet is named | Slot | Hydra key a sweep writes |
|---|---|---|
| An entity's spawn | ego.spawn, npc1.spawn |
ego.spawn_lanelet_id, scenario.spawn_overrides.npc1.lanelet_id |
| The ego's goal | ego.goal |
ego.goal_lanelet_id |
A lanelet parameter of an action or a condition |
<node id>.<field> |
scenario.param_overrides.<node id>.<field> |
A constraint is a statement about the map, so it is written with the map it searches in view, and the matches are outlined on that same map as they are counted. That is why the choice is in the picker and not in the 384px inspector column, which only states which mode is in force and offers Edit.
A set of lanelets — stop_lanelets, the map's exclusion list — is picked by
hand and gets no such panel: a search names one lanelet per run, because that
is what the sweeper enumerates, so offering the choice there would promise that
a search fills the whole set.
Fixed pins the lanelet the field shows. For a spawn that is a lanelet and an offset:
| Field | Value | Where |
|---|---|---|
| Lanelet ID | 183 | the picker, by clicking the map |
| Offset | 12.5 m | the picker, under the same heading |
Constraint search hands the choice to the existing lanelet-constraint
sweeper. The tree is edited in the picker, each node with its own parameters —
the inspector column is behind the map while it is open — with the match count
beside it outlining what it found on that same map, and is serialised straight
into sweep.constraints under whichever key addresses the slot:
sweep:
constraints:
ego.spawn_lanelet_id:
- type: and
constraints:
- type: has_adjacent
value: left
- type: lanelet_length
rule: greater_than_or_equal
value: 10.0
- type: not
constraint: {type: is_junction}
- type: not
constraint:
type: in_set
values: ${map.no_3d_model_lanelet_ids}
The id the field shows stays in the document under a search: it is the default a run that does not sweep falls back to, and the value the exported config declares so that Hydra's struct mode accepts the override at all.
One search per run. The sweeper enumerates a single target key, so a document may search for one lanelet; a second search is a validation warning rather than an error — the scenario still runs, with everything else pinned to its default — and names which slot is being driven.
Preview matches evaluates that tree against the real Lanelet2 map and
reports, for example, 133 matched of 979 lanelets. It is counted when the
picker is opened, not on every render: each lanelet field on the inspector has a
picker in the page at once, and counting means parsing a city. Loading a map is
opt-in and cached; if the map files are missing the count is unavailable but the
constraint editor keeps working.
The map¶
The drawing comes from
simple_lanelet2's wasm map
viewer — the same project that provides the lanelet2 Python API this framework
runs on. The editor serves the scenario's .osm at
/draft/<id>/map.osm, the viewer parses and renders it in the browser, and the
editor drives it through two calls: setHighlight() with the matched IDs, and a
select listener that turns clicking a lanelet into setting whichever lanelet
field the picker was opened from.
Constraint evaluation stays on the server, in the framework's own sweeper — the viewer only draws. That split is deliberate: a second constraint engine in JavaScript could disagree with the sweep the scenario will actually run.
The module is loaded from the project's GitHub Pages build, because the wasm is
built rather than committed. Point SCENARIO_EDITOR_MAP_VIEWER at the output of
simple_lanelet2's tools/build_web.sh to serve it yourself. When it cannot be
fetched the panel says so; the match count and the matched-ID list come from the
server and are unaffected.
The map is parsed once¶
htmx replaces the whole inspector on every edit, so the server sends a
brand-new, empty map frame each time. Mounting that frame refetched the .osm
and parsed it again in wasm — one fetch per edit, for a map that had not
changed.
The frame that already holds the parsed scene carries a data-viewer-key, and
reuseMap() in editor.js swaps it back in over the fresh one, copying across
only what actually differs: which lanelets are outlined. The whole frame moves
rather than the canvas inside it — the viewer keeps a reference to the element
it was constructed with and observes it for resizes, so lifting the canvas out
would leave it measuring a node that is no longer on the page. Panning and
zooming survive an edit as a consequence, which re-mounting had been silently
throwing away.
That is what makes the picker editable. An edit in its side panel — the fixed or
searched choice, the constraints, a spawn's offset along the lanelet — posts like
every other control and swaps the whole editor body, which builds a fresh copy
of the modal while the open one hangs off <body>. The fresh copy is swapped in
behind the person using it, and the key carries the parsed map across, pan and
zoom included.
It is the only renderer. A server-rendered SVG used to sit behind it as an
offline fallback, but the page loads htmx from a CDN and every control here is an
hx- attribute, so an editor that cannot reach the network does not work at all
— the fallback bought no offline capability while costing a second drawing of the
same map on screen at once.
The viewer has a single highlight channel — one outline colour, no second class — so what is outlined follows the mode: a constraint search outlines its matches, a pinned lanelet outlines itself. The caption under the map names which of the two it is, rather than showing colour swatches the viewer does not use.
Derived offsets¶
The longitudinal offset can be Fixed or Derived from the map. A derived
offset is a sweep.bindings entry -- currently StopLineOffsetBinding:
| Field | Value |
|---|---|
| Position on matched lanelet | Derived |
| Derived from | Before stop line |
| Distance | 15 m |
The sweeper enumerates a single target key per run, so one entity's spawn can be searched per scenario; the editor warns when a document asks for more.
The ego reaches the runner through the framework's own ego.spawn_lanelet_id /
ego.spawn_s keys. Other entities get a declared
scenario.spawn_overrides.<entity> sub-tree so they are addressable by exactly
the same plain key=value overrides.
A logical scenario: the route search¶
A document may describe the ego's drive as a pattern of road instead of
lanelets -- a logical scenario. The scenario inspector's
Route search section lists the pattern one segment per line ("lane 30-60 m,
opposite lane: yes", "junction, turn left, traffic light: yes") with where the
ego spawns and is sent, and edits it as the YAML the document stores (an empty
box removes it; a malformed one is refused with the reason). A Follow
Trajectory card's Path then offers Along the scenario's route, whose
Route vertices are edited as text, one kind key=value ... per line, and
the Route progress condition is offered for triggers, assertions and
waypoint conditions. Appear at the first vertex when the action starts
(appear_on_start) brings a hidden entity in.
Ego: who drives, and where to¶
The ego's inspector opens with Driven by — the stack that drives it, exported
as ego.entity:
| Driven by | ego.entity |
Goal |
|---|---|---|
| TrafficManager (CARLA autopilot) | autopilot |
optional — it reads none |
| Autoware | autoware |
required — it plans its route to the goal and will not move without one |
(The framework's third value, carla_driver, needs a driver config group the
editor does not author; a run can still select it from the command line.)
The Goal section sits beside the spawn, because the two are the ends of the
same thing: where the run starts, and where the ego is meant to get to. A goal is
picked from the map like a spawn is, and may be searched for like one — the
sweep then writes ego.goal_lanelet_id. An Autoware ego without one is a validation
error; an ego the TrafficManager drives may be given none — a cut-in or a
red-light run is about what happens on the way — and Clear goal puts it back
to that.
A goal is not a card. A card happens during a run, and a goal is what the ego
needs before one can start: Autoware localizes at the spawn, plans a route to
the goal, and only then engages. The framework asks the same of a hand-written
scenario — BaseScenario.require_goal(), asked by register_route_to_goal and
by ScenarioRunner once setup() returns — so the editor stores the goal on the
ego and exports it as the framework's own keys:
The goal keys are written only when a goal is set, leaving the ego group's own
null in place otherwise.
The Set Goal card still exists, for changing a destination mid-run or for a vehicle that is not the ego. One owned by the ego and left in the initialization phase is warned about: the ego's own goal is already delivered there, so the card would send a second destination in the same phase. Only a vehicle that plans its own route reads a goal at all, so a goal stored on any other entity is a validation error rather than something quietly dropped at export.
Metadata-driven GUI¶
Actions, conditions, constraints and bindings keep growing, so the templates
render metadata, never a type switch. Adding a primitive is a Python-side
change in autoware_carla_scenario.authoring.registry:
register_action_spec(
ActionSpec(
type_id="lane_change",
title="Lane Change",
category="Vehicle / Motion",
builder="build_lane_change_action",
visual_kind="instant",
fields=(FieldSpec("direction", "Direction", "select", "left", _DIRECTIONS),),
)
)
plus a matching factory in authoring.builders that returns the framework's own
class. The inspector, the canvas, validation and the compiler all pick it up
with no template edit. test_authoring_registry.py fails if a spec names a
builder that does not exist, or a visual that names a field the primitive lacks.
The map path is a document field¶
/draft/<id>/map.osm serves the file the document names, and that name is
typed by whoever is using the editor -- which binds 0.0.0.0 by default. Handed
straight to a FileResponse it was an arbitrary local file read for anyone who
could reach the port. A path is accepted only when it resolves inside
SCENARIO_EDITOR_MAP_ROOTS (the working directory unless set) and names a
.osm, so what the route can serve is bounded by where the editor was started
rather than by what the process can read.
That is a bound, not authentication: the editor still has none, so it belongs on a network you trust.
Save Draft vs Export Wheelhouse¶
Save Draft writes the working document to scenario_drafts/<id>.yaml.
Export Wheelhouse produces a .zip the browser downloads, holding every
wheel the scenario needs:
cut_in-wheelhouse.zip
`-- cut_in_scenario_wheelhouse/
|-- cut_in_scenario-0.1.0-py3-none-any.whl # the scenario itself
|-- autoware_carla_scenario-*.whl # the framework, at the pinned commit
|-- typesafe_carla-0.2.*.whl # the CARLA client, CPython package prebuilt
|-- typesafe_carla_toolchain-*.whl # the Codon compiler it pins
|-- ... every transitive dependency, ~94 wheels
|-- requirements.txt # the whole set, pinned
`-- README.md # how to install it
Install it with pip and nothing else -- no uv, no git, no network, no
resolution:
unzip cut_in-wheelhouse.zip
python3 -m venv .venv
.venv/bin/pip install --no-index --find-links cut_in_scenario_wheelhouse cut-in-scenario
.venv/bin/scenario scenario=cut_in map=nishishinjuku
The CARLA client is typesafe_carla,
and the typesafe-carla wheel in the wheelhouse is the released one from
PyPI, which carries its CPython package (typesafe_carla.carla) prebuilt --
one build for every Python 3.10+. So pip is the whole installation, here as
everywhere: nothing is compiled on the target, and an offline vehicle needs
neither cc nor a build step. Only if that prebuilt package does not match
the installation -- a typesafe-carla-toolchain other than the one the wheel
was built with, say -- does the first import typesafe_carla.carla, the first
scenario run, build it with the Codon compiler from the toolchain wheel (15 to
30 minutes, about 14 GB of RAM, cc, but no network) into
~/.cache/typesafe_carla/pycarla (TYPESAFE_CARLA_PYCARLA_DIR moves it);
typesafe-codon pycarla does that ahead of time, and reports that there is
nothing to build when the prebuilt package applies.
That is the point of the format. Where scenarios run is often a venv built
from python3-venv and python3-pip -- the two things rosdep can resolve --
with neither uv nor, on a vehicle, a network route. A uv project needs all three; a wheelhouse needs none.
The scenario's document and Hydra config travel inside its wheel, so an installed scenario is self-contained: there is no directory that has to be kept beside it.
What the wheelhouse is built from¶
The exporter still generates the uv project it always did -- pyproject.toml
with the framework pinned to an exact commit, uv.lock, the document, the Hydra
config, the package's own tests -- and still runs uv sync --locked and those
tests against it. That project is now a build input: the wheels are resolved
from its lockfile and the project itself does not leave the server.
The CARLA client needs no special handling: typesafe-carla is a plain
dependency of the framework, published on PyPI, so the package requests no
extra, vendors no wheel, and the lock resolves the client like any other
dependency.
There is no destination field. The editor is routinely used from another machine on the LAN, where a path typed into it would name a directory on the host running the server -- not one the person exporting can reach. Everything is built in a temporary directory, the wheelhouse is zipped, and the build tree is removed, so the only thing that outlives the request is the archive, and re-exporting never has to overwrite a half-written one.
The response is still the report -- warnings, the tool log, whether the package's own tests passed -- with the download link in it. Making the response the file itself would throw away the very things an export is checked for.
One platform, every supported interpreter¶
A wheelhouse is resolved for a platform: the wheels are the ones the exporting machine selected, so installing them on a different platform fails on the first wheel with no matching tag -- export from a matching machine instead.
The interpreter is not that kind of constraint. Resolution is done once per
interpreter the package supports, all into the same directory. Which
interpreters those are is the CI-tested range -- 3.10 through 3.14 -- narrowed
to what the package's requires-python admits; the package inherits the
framework's, so normally all five. The pure-Python wheels are shared between
the passes and the compiled ones sit side by side with their own tag, so pip
installs whichever matches. That is what lets one export serve ROS 2 Humble,
whose Python is 3.10, and Jazzy, whose Python is 3.12: the alternative is a
python3.12 that Ubuntu 22.04 does not package at all.
It is large -- the CARLA client and its Codon toolchain, OpenCV and the lanelet2 bindings, the compiled ones once per interpreter. That is the cost of not needing a network at install time, and of not needing to know which Python the target runs before exporting.
Reproducibility¶
| What | Pinned by |
|---|---|
autoware-carla-scenario |
exact version, or an exact commit SHA |
typesafe-carla |
the framework's own requirement (>=0.3.0,<0.4), locked in uv.lock like everything else |
| Python | .python-version, exact patch version |
| uv | [tool.uv] required-version, when uv's version could be read |
| Everything else | uv.lock, and then the wheels built from it |
A branch is never emitted -- main, master and HEAD all move, so a
package pinned to one stops being the package that was tested as soon as
somebody pushes. When no immutable pin can be determined the export fails rather
than guessing. A local-path dependency is only produced by an explicit
development export, which says so in its README and manifest.
Set SCENARIO_EXPORT_FRAMEWORK_VERSION to pin a published release instead of
the current checkout's commit.
Export is atomic¶
The exporter builds into a temporary directory and runs uv lock, then
uv sync --locked, then the generated package's own tests, then the wheelhouse.
If locking, verification or the wheelhouse fails, nothing is written -- a
package whose dependencies never resolved, or that no environment can install,
is not a successful export.
The manifest records only values that were actually observed:
format_version: 3
scenario: {id: cut_in, title: Cut in, document_version: 1, package: cut-in-scenario}
runtime: {python: 3.12.10, uv: 0.12.0, requires_python: '>=3.10,<3.15'}
wheelhouse:
directory: cut_in_scenario_wheelhouse
install: pip install --no-index --find-links cut_in_scenario_wheelhouse cut-in-scenario
wheels: 104
python: ['3.10', '3.11', '3.12', '3.13', '3.14']
autoware_carla_scenario:
source: git
repository: https://github.com/hakuturu583/autoware_carla_scenario
commit: 0123456789abcdef0123456789abcdef01234567
subdirectory: autoware_carla_scenario
files:
document: src/cut_in_scenario/document.yaml
hydra_config: src/cut_in_scenario/conf/scenario/cut_in.yaml
notes: []
Anything that could not be determined is null with a note, never a plausible
guess.
Configuration¶
| Variable | Default | Meaning |
|---|---|---|
SCENARIO_EDITOR_DRAFTS |
./scenario_drafts |
Where drafts are stored |
SCENARIO_EDITOR_EXPORT_DIR |
./scenario_packages |
Where an export's wheelhouse .zip is staged until the browser fetches it |
SCENARIO_EDITOR_MAP_ROOTS |
the working directory | Directories a Lanelet2 map may be read from, :-separated |
SCENARIO_EDITOR_HOST |
0.0.0.0 |
Bind address |
SCENARIO_EDITOR_PORT |
9100 |
Bind port (the result viewer uses 9000) |
SCENARIO_EDITOR_MAP_VIEWER |
GitHub Pages build | URL of simple_lanelet2's viewer.js |
Running an authored scenario¶
An exported scenario plugs in through the same entry point any scenario package
uses (see Architecture) -- installed from its wheelhouse, the
scenario command is on the venv's PATH:
.venv/bin/scenario scenario=cut_in map=nishishinjuku
# Sweep every lanelet the spawn constraints match:
.venv/bin/scenario --multirun scenario=cut_in map=nishishinjuku \
hydra/sweeper=lanelet_constraint
The CARLA client needs no separate install: typesafe-carla is a dependency of
the framework, so it is in the lock and in the wheelhouse alongside everything
else, with its CPython package prebuilt, so nothing has to be compiled on the
machine that runs the scenario (see
Save Draft vs Export Wheelhouse).
Offline¶
Only two things in the editor come from the network, and neither is load-bearing for editing:
| htmx | every interaction; the editor needs it |
simple_lanelet2's map viewer |
the map drawing only — the panel says so when it cannot be fetched |
The stylesheet is served by the app itself rather than by a CDN, so a blocked egress rule cannot take the layout with it.