Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.

OpenScenarioEngine Documentation

Architecture Documentation

Overview

Figure 1 shows, how the engine is orchestrated:

Dependency Graph

Figure 1: Dependency Graph

For reading and validation of an OpenSCENARIO description, the engine uses the OpenScenarioLib, publicly hosted by RA-Consulting GmbH. The library interfaces over the OpenScenario API and is available for all current OpenSCENARIO 1.X standards.

For interfacing against an environment simulator (e.g. openPASS::opSimulation), the engine uses the scenario agnostic interface definition openPASS::MantleAPI. In one direction, the engine implements mantle_api::IScenarioEngine, which allows to control the execution (e.g. Init() or Step()). In the other direction it needs access to the mantle_api::IEnvironment (e.g. to setup and control entities), which needs to be implemented by the environment simulator.

As the MantleAPI relies on the nholthaus/units library for physically correct representation of its properties, this dependency is also injected and used by the OpenScenarioEngine.

The heart of the engine is a behavior tree, based on the openPASS project openPASS::yase. Its responsibility is to handle the interaction between conditions and actions interact according to the standard, i.e. what should be triggered when.

The actual actions and conditions reside in the leaves of the tree.

The following shows an (beautified) excerpt of the such an actual behavior tree, taken from the demo example:

[RUNNING] - ParallelUntil[AllChildrenSucceed][OpenScenarioEngine]
|  [RUNNING] - Storyboard
|    [RUNNING] - StopTrigger
|    |  [RUNNING] - ConditionGroups
|    |    [RUNNING] - Conditions
|    |      [RUNNING] - SimulationTimeCondition
|    [RUNNING] - Child
|      [RUNNING] - ExecuteOnce
|      |  [RUNNING] - ParallelUntil[AllChildrenSucceed][InitActions]
|      |    [RUNNING] - ParallelUntil[AllChildrenSucceed][PrivateActions]
|      |      [SUCCESS] - TeleportAction
|      |      [RUNNING] - SpeedAction
...

Design Principles

The "ground truth" of ASAM OpenSCENARIO is an UML model, used to generate the XML specification, documentation, the OpenScenarioLib, and OpenSCENARIO API. When reading an OpenSCENARIO description with the parser component of the OpenScenarioLib, it generates objects implementing the OpenSCENARIO API. As such the (validated) information of the scenario file is available, but (a) not their logical relation and (b) no business logic.

Major parts of the engine are generated using a python code generator, which uses the same UML model to generate a full set of concrete business logic objects that inherently know about their relationship.
See also Generator Documentation

For example, a Condition object holds either a ByEntityCondition or a ByValueCondition (logical relation). Based on the current openSCENARIO description, the engine translates from pure OpenSCENARIO API objects into actual nodes of the internal behavior tree. Tree generation is described in the following.

Generation of the Behavior Tree

The heart of the engine is a behavior tree, based on the openPASS project Yase. Its responsibility is to handle the interaction between conditions and actions according to the standard.

Example

A condition group triggers when all its conditions are satisfied, which means that the corresponding action needs to be executed after a given delay and potentially receive all triggering entities.

Thereby the behavior of the tree is parameterized by the scenario description. In the example the user can decide whether any or all triggering entities lead to satisfaction of the corresponding condition.

:writing_hand: Principle

The engine strives to separate the interaction logic from the final conditions and actions.

The OpenScenarioLib is used to interpret each individual XML element of the scenario definition. This scenario description is transformed into elements of the behavior tree. The following example shows, how the hierarchical and parameterized structure is transformed into the final tree.

Example

Suppose that somewhere in the scenario definition, the user specifies a DistanceCondition:

<ConditionGroup>
  <Condition name="distance_reached" conditionEdge="rising" delay="0">
    <ByEntityCondition>
      <TriggeringEntities triggeringEntitiesRule="all">
        <EntityRef name="Ego">
      </TriggeringEntities>
      <DistanceCondition freespace="True" rule="lessThan" value="5">
        <Position>
          <WorldPosition x="100" y="200">
        </Position>
      </DistanceCondition>
    </ByEntityCondition>
  </Condition>
<ConditionGroup>

When the conversion reaches the ConditionGroup, the function parse(condition) is called, which shall act as entry point of this example (see src/Conversion/OscToNode/ParseCondition.cpp):

yase::BehaviorNode::Ptr parse(std::shared_ptr<NET_ASAM_OPENSCENARIO::v1_2::ICondition> condition)
{
  return std::make_shared<yase::ConditionNode>(
      condition->GetName(),
      condition->GetDelay(),
      OPENSCENARIO::ConvertScenarioConditionEdge(condition->GetConditionEdge()),
      detail::resolveChildCondition(condition));
}

According to the standard, the condition needs to interpret the condition edge and a delay. As such, its functionality is bundled in a specialized yase::ConditionNode.

Two design principles can be derived from this example:

  1. :writing_hand: Principle

    Separation of Interaction

    The implementation is almost completely independent of the underlying condition. All it needs to know is the state of the condition (in terms of yase success or running). Hence, the underlying condition is again parsed (resolveChildCondition will be discussed below), which is also the term used within the engine for any translation from the context of an OpenScenario API type* to a YASE Node. Corresponding functions can be found under src/Conversion/OscToNode.

  2. :writing_hand: Principle

    Separation of Values

    The implementation should only know only what it needs to do its job. Passing in values of type NET_ASAM_OPENSCENARIO::* would violate this principle, as the OpenScenario API type generally offers access to more functionality than necessary and would introduce an unnecessary dependency.

    Internal values are either MantelAPI types or C++17 types, if no other option is available. This also makes testing lot easier.

    The functions for conversion from OpenScenario API type* to MantleAPI or C++17 types are always named ConvertScenarioTypeToBeConverted(), here ConvertScenarioConditionEdge() and can be found under src/Conversion/OscToMantle.

The function resolveChildCondition (same source file) shows how the engine makes use of semantic information of the description:

namespace detail
{
yase::BehaviorNode::Ptr resolveChildCondition(const std::shared_ptr<NET_ASAM_OPENSCENARIO::v1_2::ICondition>& condition)
{
  if (auto element = condition->GetByEntityCondition(); element)
  {
    return parse(element);
  }
  if (auto element = condition->GetByValueCondition(); element)
  {
    return parse(element);
  }
  throw std::runtime_error("Corrupted openSCENARIO file: No choice made within std::shared_ptr<NET_ASAM_OPENSCENARIO::v1_2::ICondition>");
}
}  // namespace detail

Here, the standard states that a condition is either a ByValueCondition or a ByEntityCondition, which is why the function simply checks one after the other. Depending on the outcome of the checks, parsing simply continues with the next layer. Note that specifying no condition is not allowed and that the engine uses the OpenScenarioLib to validate the scenario before building the tree. So reaching the end of the function is literally an exception:

:writing_hand: Principle

The engine assumes that the description is complete. If not, exceptions are allowed.

Handling of Union and List Types

Very often, the standard allow xor (union) or sequence (list) relations within elements the scenario description. In the following, some special rules for these types are described:

Union Types

Suppose that the user specified a ByValueCondition, namely a SimulationTimeCondition, as Condition. As ByValueConditions are just a union type without additional information, no intermediate node is generated. Instead the result of the next parse is directly returned (see ParseByValueCondition.cpp). In other words SimulationTimeCondition will become the direct child ConditionNode.

:writing_hand: Principle

Skip tree nodes for union types, if they do not contain additional logic.

List Types

For list types, such as Actions in the Event element, parsing unwraps the individual elements into children of an yase::ParallelNode (see gen/Conversion/OscToNode/ParseActions.cpp):

yase::BehaviorNode::Ptr parse(std::vector<std::shared_ptr<NET_ASAM_OPENSCENARIO::v1_2::IAction>> actions)
{
auto node = std::make_shared<yase::ParallelNode>("Actions");
for (const auto& action : actions)
{
    node->addChild(parse(action));
}
return node;
}

:writing_hand: Principle

Use ParallelNodes for list types, if they do not contain additional logic.


Figure 2 shows both principles at work:

Tree Structure

Figure 2: Init Path

The list of InitActions is transformed into individual children of the ParallelNode InitActions. Yet, no Action node exist as intermediate layer. Instead each child is directly specialized to the lowest possible occurrence (union type). This could be a concrete action, such as an EnvironmentAction, or another intermediate node, such as an EntityAction.

Conditions and Actions

Concrete conditions and actions are the leafs of the tree. As conditions precede actions, they will be described first.

Conditions

When the parsing process reaches a concrete condition, a leaf is instantiated.

In general, ByEntityConditions and ByValueConditions are supported, with ByEntityConditions being an extension of ByValueConditions, also including the management of triggering entities.

Note that interaction ("who is triggering what") is responsibility of the tree. This means that for ByEntityConditions, the parsing stage generates an intermediate layer (AllTriggeringEntitiesNode or AnyTriggeringEntitiesNode), separating the individual triggering entities from each other.

In other words:

:writing_hand: Principle

Conditions do not know about each other and therefore a ByEntityCondition only refers to a single triggering entity.

The basic structure of a fictitious ByEntityCondition is shown in Figure 3 as an example. For a ByValueCondition, entity-specific parts can simply be left out.

Tree Structure

Figure 3: Composition of an Condition leaf

Note that this structure is autogenerated by a code generator and only the implementation files should be customized to implement the actual behavior of a condition.

Condition leaves have two responsibilities:

  1. Interfacing w.r.t the behavior tree

    The entry point of the behavior tree is the executeTick() method of the ActionNode base.
    :warning:YASE context: Do not to confuse with an openSCENARIO Action.

    The condition node (here FooCondition) wraps the result in the tick() method, required by the base class.

    The actual implementation (impl_) of the condition only returns true or false and is unaware of the behavior tree, which corresponds to the Separation of Interaction principle (see above). To achieve this, the implementation of a condition obeys the following principle:

    :writing_hand: Principle

    If an condition is satisfied, its implementation returns true.

  2. Instantiation of a concrete implementation

    Again, Separation of Values (see according Principle above) is used to make the conditions independent of OpenScenario API datatypes. Each parameter, requested by the standard is transformed into its corresponding Mantle API (or C++) type into a structure called Values.

    The conditions base class (here FooConditionBase) makes all parameters available to its specialization through a struct named value. Thereby, the name of each field follows the name as defined in the openSCENARIO standard, e.g. freespace in a RelativeDistanceCondition and is accessed within the implementation as follows:

    // evaluation of param "freespace" done at initialization
    if (values.freespace) { /*...*/ }

    Whenever possible, conversion is already executed at the instantiation of the implementation. In some cases, as for evaluation of the Position in the very moment, a parameterless lambda expression is constructed, which can be used as follows:

    // evaluation of param "position" done during runtime, via GetPosition()
    std::optional<mantle_api::Pose> position = values.GetPosition();
    
    // or simply
    auto position = values.GetPosition();

    :writing_hand: Principle

    Within an condition, available parameters are accessed via the member values.

    The conditions base class (here FooConditionBase) also bundles all necessary interfaces w.r.t. the environment simulator in a struct Interfaces made available by the member mantle:

    auto& repository = mantle.environment->GetEntityRepository();

    :writing_hand: Principle

    Within an condition, available mantle interfaces are accessed via the member mantle.

    To make a ByEntityCondition aware of the triggeringEntity an additional member is added to the Values struct, typically used as follows:

    const auto& triggeringEntity = EntityUtils::GetEntityByName(mantle.environment, values.triggeringEntity);

    :writing_hand: Principle

    Within an ByEntityCondition, the triggering entity is accessed via the field values.triggeringEntity.

Actions

In general actions are very similar to conditions. To explain the differences, a fictitious action shall be used, that follows the fundamental leaf structure shown in Figure 4:

Tree Structure

Figure 4: Composition of an Action leaf

Note that this structure is autogenerated by a code generator and only the implementation files should be customized to implement the actual behavior of a condition.

Action leaves have two responsibilities:

  1. Interfacing w.r.t the behavior tree
    As conditions, actions are and unaware of the behavior tree. The main difference is that actions use the method Step() to report their state and it has to be distinguished whether they are executed immediately or continuously:

    :writing_hand: Principle

    If an action is done, the implementation returns true.
    Continuous actions always return false.

  2. Instantiation of a concrete implementation
    Separation of Values (see according Principle above) also applies to actions and the following principles can be adopted:

    :writing_hand: Principles

    Within an action, available parameters are accessed via the member values.

    Within an action, available mantle interfaces are accessed via the member mantle.

    The main difference is, that if an action has to be applied to a triggering entity, an additional vector entities is added to the Values struct, typically used as follows:

    for (const auto& entity_name : values.entities)
    {
      const auto& entity = EntityUtils::GetEntityByName(mantle.environment, entity_name);
    }

    :writing_hand: Principle

    Within actions interacting with triggering entities, these entities are accessed via the field values.entities.

Logging and error handling

The OpenScenarioEngine uses the log framework provided by the MantleAPI: https://gitlab.eclipse.org/eclipse/openpass/mantle-api/-/blob/master/include/MantleAPI/Common/i_logger.h

It consists of an ILogger interface class, which is passed to the OpenScenarioEngine on construction and therefore can be implemented outside of the OpenScenarioEngine. The OpenScenarioEngine forwards all log messages to the implementation of ILogger. The implementation of the ILogger interface class can then forward log messages of the OpenScenarioEngine to own logging implementations of a simulator or test framework.

The different log levels defined in the MantleAPI are used by the OpenScenarioEngine in the following way:

  • the logs of the OpenScenario API while reading / parsing and validating the scenario are forwarded with the same log levels (e.g. warning when deprecated elements are used, error when scenario does not validate against schema) together with a summary of the found warnings and errors
  • for the use-case of scenario execution the OpenScenarioEngine additionally stop execution by throwing a runtime error, if there were errors during the parsing and validation of the scenario
  • for the use-case of only scenario validation no runtime error is thrown and it's left to the caller of the validation method how to proceed
  • if features are used in a scenario which are not yet supported by the OpenScenarioEngine, then an error is logged and the respective feature is not executed
  • if scenarios contain semantically wrong information the OpenScenarioEngine can currently only detect this upon execution of the feature and therefore also stops the execution by throwing a runtime error. In the future semantic checks could also partially be implemented in the OpenScenario API (parser & validator), so semantic errors in scenarios could also be detected in the "validation only" use-case