-
Andreas Rauschert authoredAndreas Rauschert authored
OpenScenarioEngine Documentation
- User Guide
- Architecture Documentation (this document)
- Generator Documentation
Architecture Documentation
Overview
Figure 1 shows, how the engine is orchestrated:
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.
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:
-
PrincipleSeparation 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
orrunning
). 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 undersrc/Conversion/OscToNode
. -
PrincipleSeparation 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()
, hereConvertScenarioConditionEdge()
and can be found undersrc/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:
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 ByValueCondition
s 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
.
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;
}
Use
ParallelNode
s for list types, if they do not contain additional logic.
Figure 2 shows both principles at work:
Figure 2: Init Path
The list of InitAction
s 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, ByEntityCondition
s and ByValueCondition
s are supported, with ByEntityCondition
s being an extension of ByValueCondition
s, also including the management of triggering entities.
Note that interaction ("who is triggering what") is responsibility of the tree.
This means that for ByEntityCondition
s, the parsing stage generates an intermediate layer (AllTriggeringEntitiesNode
or AnyTriggeringEntitiesNode
), separating the individual triggering entities from each other.
In other words:
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.
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:
-
Interfacing w.r.t the behavior tree
The entry point of the behavior tree is the
executeTick()
method of theActionNode
base.
YASE context: Do not to confuse with an openSCENARIO Action.The condition node (here
FooCondition
) wraps the result in thetick()
method, required by the base class.The actual implementation (
impl_
) of the condition only returnstrue
orfalse
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: PrincipleIf an condition is satisfied, its implementation returns
true
. -
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 namedvalue
. Thereby, the name of each field follows the name as defined in the openSCENARIO standard, e.g.freespace
in aRelativeDistanceCondition
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();
PrincipleWithin 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 structInterfaces
made available by the membermantle
:auto& repository = mantle.environment->GetEntityRepository();
PrincipleWithin an condition, available mantle interfaces are accessed via the member
mantle
.To make a
ByEntityCondition
aware of thetriggeringEntity
an additional member is added to theValues
struct, typically used as follows:const auto& triggeringEntity = EntityUtils::GetEntityByName(mantle.environment, values.triggeringEntity);
PrincipleWithin an
ByEntityCondition
, the triggering entity is accessed via the fieldvalues.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:
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:
-
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 methodStep()
to report their state and it has to be distinguished whether they are executed immediately or continuously: PrincipleIf an action is done, the implementation returns
true
.
Continuous actions always returnfalse
. -
Instantiation of a concrete implementation
Separation of Values (see according Principle above) also applies to actions and the following principles can be adopted: PrinciplesWithin 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 theValues
struct, typically used as follows:for (const auto& entity_name : values.entities) { const auto& entity = EntityUtils::GetEntityByName(mantle.environment, entity_name); }
PrincipleWithin 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