Architecture
Overview of Scenario Execution
Scenario execution is built as a Python library on top of two open-source components: the generic scenario description language OpenSCENARIO DSL and PyTrees. In general, the user defines a scenario in the OpenSCENARIO DSL language, scenario execution parses the scenario, translates it to a behavior tree, executes it and finally gathers the test results.
Architecture of Scenario Execution
Our implementation is highly modular separating the core components from simulation- and/or middleware-specific modules realized through a plugin-based approach. In principle, any additional feature that is required by a specific scenario and that can be implemented in Python could be realized as additional library. A library typically provides an OpenSCENARIO DSL file with additional definitions and may provide code implementing additional functionality such as conditions or actions.
Currently, the following sub-packages and libraries are available:
Design for Modularity
Scenario execution is designed to be easily extensible through libraries. An example is available here: Create Scenario Library.
The entry points are defined like this:
entry_points={
'scenario_execution.actions': [
'custom_action = example_library.custom_action:CustomAction',
],
'scenario_execution.osc_libraries': [
'example = example_library.get_osc_library:get_example_library',
]
}
Scenario Parsing
Architecture of Scenario Parsing
The Internal Model Builder, implemented as a Model Listener does an initial check of the model by checking for supported language features. The Internal Model Resolver, implemented as a Model Visitor is used for type/variable resolving and does an in depth consistency check of the model.
Modules
scenario_execution: The base package for scenario execution. It provides the parsing of OpenSCENARIO DSL files and the conversion to py-trees. It's middleware agnostic and can therefore be used as a basis for more specific implementations (e.g. ROS or step-based simulation). It also provides basic OpenSCENARIO DSL libraries and actions.scenario_execution_ros: This package usesscenario_executionas a basis and implements a ROS2 version of scenario execution. It provides a OpenSCENARIO DSL library with basic ROS2-related actions like publishing on a topic or calling a service.scenario_execution_control: Provides code to control scenario execution (in ROS2) from another application such as RViz.scenario_execution_coverage: Provides tools to generate concrete scenarios from abstract OpenSCENARIO DSL scenario definition and execute them.scenario_execution_gazebo: Provides a Gazebo-specific OpenSCENARIO DSL library with actions.scenario_execution_interfaces: Provides ROS2 interfaces, more specifically, messages and services, which are used to interface ROS2 with thescenario_execution_controlpackage.scenario_execution_rviz: Contains several rviz plugins for visualizing and controlling scenarios when working with ROS2.simulation/gazebo_tf_publisher: Publish ground truth transforms from simulation within TF.simulation/tb4_sim_scenario: Run Turtlebot4 within simulation, controlled by scenario execution.tools/message_modification: ROS2 nodes to modify messages.tools/scenario_status: Publish the current scenario status on a topic (e.g. to be capture within a ROS bag).
Action Lifecycle
Every action is a py-trees behavior (BaseAction), so the tree owns when it runs, when it stops,
and what it is allowed to assume in between.
A tick is shared. update() is called once per tick for every action currently running, in
every branch, on one thread. Blocking there stalls the whole scenario, so a long operation is
started and then polled across ticks rather than waited for. Actions that must clean up
asynchronously hand a future to ShutdownHandler instead of blocking on it.
Stopping is not the same as finishing. py-trees calls terminate(new_status) when a behavior
stops, and INVALID means the branch was abandoned rather than completed -- a losing one_of
child, or one the timeout modifier gave up on. An action whose effect outlives the tick, such as
a goal a robot is still driving or a process still running, has to end that effect there; otherwise
the branch ends while the effect continues to the end of the scenario. BaseAction.terminate()
does this centrally by calling request_cancel(), which an action overrides to stop whatever it
started, and which reports False when it cannot.
Actions do not reference each other. There is no way for one action to reach another and act on
it, by name or otherwise. Coordination between branches goes through the blackboard, which at the
DSL surface is event / emit / wait @: one branch decides when to signal, another decides
what the signal ends, and neither depends on the other's structure or on the order the branches were
written in. This is what keeps composition predictable -- an action's behavior follows from its own
subtree, not from what a sibling elsewhere chose to do to it.
Step-based Simulation
scenario_execution supports step-based simulators (e.g. MuJoCo, PyBullet, custom hardware-in-the-loop setups) through the SimulationInterface abstraction. This allows scenario authors to run scenarios while retaining full use of the OpenSCENARIO DSL, including time-based directives such as wait elapsed() and timeout().
Clock abstraction
In normal (wall-clock) mode the framework uses time.sleep() between ticks. In step-based mode there is no sleeping: the loop runs as fast as the simulator allows. Time is tracked by a SimulationClock that advances by exactly dt seconds per tick, so wait elapsed(1s) maps to exactly 1 / dt simulation steps regardless of the system clock.
The WallClock is used as fallback when no simulation is configured, preserving backward compatibility.
┌─────────────────────────────────────────────────────┐
│ ScenarioExecution │
│ │
│ run_with_simulation(sim) │
│ sim.setup() │
│ sim.reset() ← once before the scenario │
│ while running: │
│ sim.step() ← advance the simulator │
│ clock.advance() ← advance SimulationClock │
│ tree.tick() ← advance the behavior tree │
│ sim.shutdown() │
└─────────────────────────────────────────────────────┘
Who owns the loop: base runner vs. ROS runner
The loop above belongs to the base runner, where the simulation drives everything and there is no
rclpy — which also means no ROS behavior can run in it. The ROS runner cannot adopt that shape,
because the executor owns its loop. ROSScenarioExecution therefore steps the simulation from
inside the spin loop instead, so the simulation advances alongside the ROS behaviors that drive it
and a scenario can bring up a ROS stack against a step-based simulator. Stepping is paced to real
time; a simulation that publishes /clock on step() becomes the time source and other nodes
run use_sim_time.
The lifecycle differs accordingly. The base runner sets the simulation up and shuts it down once per
run, whereas the ROS runner does it per scenario — each scenario already runs on its own node and
executor, because py_trees_ros adopts the node it is given and destroys it on shutdown(). A
simulation whose setup() or reset() raises fails that one scenario and lets the remaining
scenarios run, rather than aborting the file.
API alignment with ros-simulation/simulation_interfaces
The SimulationInterface is conceptually aligned with the ros-simulation/simulation_interfaces standard, making it straightforward to implement adapters for compliant simulators.
See Step-based simulation for usage instructions and a complete example.
Behavior Tree Status Log
--bt-log writes behaviors.jsonl, described for users under Behavior tree status log. This section covers why it is built the way it is; scenario_execution/utils/bt_logger.py holds the implementation.
One writer for both runners
The writer lives in ScenarioExecution, so ROSScenarioExecution inherits it rather than reimplementing it, and bt_logger.py imports nothing but the standard library and py_trees. Before this existed, behavior-tree state could only be captured through py_trees_ros' snapshot stream, which meant recording a ROS topic — so mode: base scenarios had no way to record it at all, and everyone else paid for a rosbag to get it.
A post-tick handler, not a visitor
add_post_tick_handler hands over the whole BehaviourTree, so the writer walks root.iterate() — the same traversal py_trees_ros uses. A visitor only sees the nodes a tick actually traversed, which would miss a node invalidated out of the visited path and could not notice a subtree inserted or pruned at runtime. Walking every node each tick is O(nodes) and cheap at realistic tree sizes, so there is no changed gate to get subtly wrong.
A SnapshotVisitor is still attached, but only to fill is_active: a node can hold SUCCESS from an earlier tick without being on the current path, so its status alone cannot answer whether this tick touched it.
Content follows py_trees_ros, cadence does not
The per-record fields mirror py_trees_ros_interfaces/Behaviour so a reader familiar with the ROS snapshots finds the same information. py_trees_ros republishes the entire tree on every snapshot, which is right for a transport whose subscribers may attach at any moment but would make a file grow by the tree size per tick. Instead the whole tree is written once at timestamp 0 and only status changes after that; the initial snapshot is what keeps never-executed branches in the file, so the tree can still be fully reconstructed.
Two of their fields are dropped and one is kept for a specific reason:
child_idsis replaced bychild_index, one integer instead of a list of UUIDs per record. It is what restores sibling order, whichparent_idalone does not give.current_child_idis dropped becausetip_idalready carries what it was needed for.tip_idis kept even though it looks derivable. Recomputing py_trees'tip()needs each composite'scurrent_child, which is not logged, and statuses alone do not determine it for amemory=Truesequence or a parallel.
Time source
The writer takes a Clock and calls now(), so timestamp is simulated time whenever one exists and monotonic time otherwise — zero-based either way, with the metadata record naming which applied.
ScenarioExecution.setup() resolves it as kwargs.get('sim_clock') or kwargs.get('clock'). The ROS runner passes sim_clock=RosClock(node) rather than clock=: clock is what ClockTimer/ClockTimeout read, so passing it there would retarget every scenario's timeouts from wall time to /clock. That may well be the correct semantics under use_sim_time, but it changes when timeouts fire and is a separate decision from recording a log.
Source locations
Records carry osc_file/osc_line/osc_column so a behavior can be traced back to the scenario that declared it. Model elements have always known this (ModelElement.set_ctx stores it from the ANTLR context, and ActionError reports it), but only plugin actions kept a reference to their model — composites, decorators and the built-in behaviors are plain py_trees objects. ModelToPyTree.BehaviorInit.stamp_source therefore stamps every behavior it creates with osc_source.
For a modifier the stamp deliberately uses the invocation rather than the ModifierDeclaration: built-in modifiers are declared in an imported library, so the declaration would point every timeout() in every scenario at the same line of helpers.osc. The file is stored per behavior rather than once per run for the same reason — set_ctx records the file being parsed, so an imported .osc keeps its own name.
Tick and Action Timing
--tick-log writes tick_timing.csv and action_timing.csv, described for users under Tick and action timing. This section covers why it is built the way it is; scenario_execution/utils/tick_recorder.py holds the implementation and scenario_execution/tick_report.py reads the result back.
Why not the behavior tree status log
The status log already records what the tree did, so it is a fair question why timing is not simply added to it. Two reasons, and both are fatal to that idea. Its records are written only when a status changes, so a scenario sitting in one RUNNING action writes nothing at all — which is exactly the window a stall occupies. And its timestamp is simulated time whenever a clock exists, which is the one timeline on which a scheduling delay is invisible by construction.
The two features are therefore separate files and separate flags, either usable alone. They are made to fit together rather than to overlap: behavior_id, behavior_name, class_name and status are produced by the same calls the status log uses, and timestamp comes from the same clock with the same semantics, so the records join on behavior_id without either being translated into the other's terms. Identity is repeated per row rather than referenced, because --tick-log may be the only one enabled and a file whose ids resolve to nothing is not a record.
Timing only
Nothing here reads process CPU time, psutil or a cgroup file. Resource accounting is a separate concern, and a file that mixed the two would put two kinds of claim behind one flag.
The distinction that matters is still available, from where the time went rather than from what consumed it. Time spent inside a tick shows up as a large duration_s with action rows summing to most of it, and those rows name the behavior. Time that passed between ticks shows up as a large interval_s while the previous tick was short and no action row accounts for the gap. What timing alone cannot separate is time lost between ticks to another callback on the same callback group, which looks exactly like not being scheduled; telling those apart needs a resource signal from outside these files, which can be joined to them on wall_ts.
One row per tick, and one per call
Aggregating per second was rejected twice over. At the default 10 Hz a one-second bucket holds ten samples, which is too few for the percentiles such a summary would report. Worse, a sampler driven by the tick loop emits fewer rows exactly during the seconds it is supposed to describe, so a stall would erase its own evidence. Per tick, a five-second gap is one row whose interval_s is 5.0.
The same argument decides the action file. Cumulative counters would make "how long did this call take" a subtraction between two rows and locate the worst call only to the nearest second, which is useless for the debugging the file exists to support. One row per call reads directly, and summing it over any window needs no delta arithmetic.
Nothing is installed unless it is asked for
ScenarioExecution._setup_tick_recorder returns immediately when the flag is not set: no recorder, no handlers, and no behavior touched. This rules out the two obvious implementations. Wrapping update at class-definition time would put a wrapper on every behavior in every run, and a per-call if inside the tick loop or the behaviors would cost every run something for a feature it did not ask for. Instead the recorder wraps the methods on the instances of the tree it was given, and only then.
Every leaf is wrapped, not only BaseAction subclasses. A scenario's wait elapsed() is a ClockTimer and its emit a TopicPublish; covering only the action libraries would leave most of a typical tree unmeasured, and attribution that cannot see a whole class of node does not merely miss time, it blames the wrong node for it. Composites are skipped because they route ticks rather than do work, and their children are ticked from Composite.tick() rather than from update(), so nothing is double-counted by leaving them out.
For the same reason the recorder does not walk the tree per tick to find newly inserted nodes. py_trees calls tree_update_handler from insert_subtree, replace_subtree and prune_subtree and nowhere else, so a walk happens only when the tree actually changed shape. It is a single callable slot rather than a handler list, so an existing handler is chained rather than replaced.
Cost
The call path does no formatting and no I/O: a tick costs two time.monotonic() reads, and a timed call two more plus one list.append, with the identity resolved once at install time and captured in the wrapper. Rows are serialized in a flush that runs at most once per wall second.
Reading it back
The summary logged at the end of a run is produced by reading the finished files, not by counting while ticking. That keeps the arithmetic in one place — shared with python -m scenario_execution.tick_report — and keeps the tick loop free of bookkeeping that exists only for a log line.
The tick period had to be fixed first
interval_s / period_s is only meaningful if period_s is the period that was actually asked for. Under ROS it was not: --step-duration was parsed and then dropped, because ROSScenarioExecution never forwarded tick_period to its base class and no ROS parameter mirrored the flag. The period was therefore always the 0.1 s default, silently. That is fixed alongside this, which also changes what tick_period every action receives in setup().
Log Line Format
Both loggers available through kwargs['logger'] emit the same line shape:
[LEVEL] [epoch] [name]: message
Logger (used by the base runner) and RosLogger (which delegates to rclpy) are two
implementations of one base class, so a scenario's own output used to be formatted differently
depending on which middleware happened to be in use — the base logger printed [name] [LEVEL] msg,
with no timestamp at all. Placing a scenario's output in time therefore depended on the backend, and
a log aggregator needed one grammar per runner instead of one.
The ANSI color for warnings and errors wraps the message rather than the whole line, so the level marker stays at the start of the line where a parser anchored there can still find it.
Note
This changes the output of the base runner. Anything parsing scenario_execution's stdout
(as opposed to scenario_execution_ros', which already had this shape) needs updating.