Skip to content

Using the C++ interface

Introduction

OSC2 is a declarative language. To enable the use of procedural logic, you can either:

  • Declare native methods and define them using the supported OSC2 native method constructs.
  • Declare external methods and bind them to code written in C++.

There are use cases where you might prefer to use C++ methods, for example:

  • Code that might be difficult to implement efficiently in OSC2, such as code that calculates the attribute of a collision.
  • Code that integrates with other C/C++ libraries.
  • Complex or extensive code that is already implemented and tested in C++.

C++ interface features

Foretify's C++ interface lets you create C++ methods that can access and modify OSC2 objects, invoke other OSC2 methods, and emit OSC2 events.

OSC2 is an object-oriented language that consists of objects (instances) of compound types (struct, actor and scenario). Each compound type can contain member definitions, such as fields, external methods, constraints, and events. Scenarios can contain additional members, including cover definitions, scenario invocations and scenario modifier invocations.

To represent the structure of user-defined objects and to allow you to interact with the objects managed by Foretify during runtime, the OSC2 compiler generates a set of C++ files that you include in your C++ project. The generated C++ files contain the definitions of enumerated types, physical types, and -- for each of the user-defined or extended compound types -- a proxy class containing an interface that lets you access and modify the type's members.

Work flow

The typical work flow for the C++ interface is as follows:

  1. Write the implementation of the external C++ method.
  2. Declare an external C++ method in OSC2.
  3. Generate C++ header files using create_osc_headers.
  4. Compile a shared object from the generated files and the implementation files.
  5. Create a test that calls the C++ method.
  6. Execute the test.

C++ interface limitations

  • All methods are executed in zero simulation time. Methods that consume simulation time are not supported.
  • The C++ interface is based on the C++11 standard version. In the current release, user code must use C++11 only.

Writing the C++ implementation

When you call an external C++ method from OSC2, you can access the following objects:

  • The object (struct, actor or scenario) that contains the external method declaration. This object is represented by the C++ pointer this.
  • Each of the method's parameters.
  • The global actor top.

For example, if you define an external C++ method in sut that passes a parameter of type car, you can access any members of top, sut, and sut.car including sut.car.state, sut.car.physical, and sut.car.policy. The pointers for these objects are valid only within the current OSC2 method call. Using these pointers after the method call ends may yield undefined behavior.

For each predefined type and each user-defined type, a corresponding class is defined in the generated C++ header files. In addition, for each field in a compound type, a getter and a setter function is defined so that you can retrieve and set the field values.

To prevent naming conflicts between generated C++ code and user code, all generated types are placed in the namespace foretify.

C++ naming conventions for OSC2 types

Scenarios are represented as a class under the related actor's namespace. For example, scenarios declared under the sut actor are represented under sut_scenarios.

For other OSC2 types, a C++ class is generated with a _t suffix.

Examples

  • The actor vehicle has a corresponding C++ class named vehicle_t.
  • The scenario sut.merge_car_group_on_ramp is represented as sut_scenarios::merge_car_group_on_ramp_t.
  • The predefined OSC2 types list, speed, int have corresponding C++ classes list_t, speed_t, int_t, and so on.

The name for a getter function matches exactly the name of OSC2 field it represents. However, if the field name uses a reserved keyword in C++, it is appended with an underscore.

The name for a setter function is the OSC2 field name prepended with set_.

Examples

OSC2 code: actor definition
# OSC2 compound type definition:
actor my_vehicle:
    max_speed: speed
    for: int
C++ code: generated for actor my_vehicle
// Generated C++ code:
class my_vehicle_t {
    ...
    speed_t max_speed();                // getter function
    void set_max_speed(speed_t value);  // setter function

    int_t for_();               // getter function; "for" is a reserved word in C++
    void set_for_(int_t value)  // setter function
    ...
};

Declaring a C++ interface function

Here are the guidelines for declaring a C++ function that accesses OSC2 entities:

  • Include the generated header file (the file with the .h file extension). Do not include the generated C++ file (the file with the .cpp file extension).
  • Declare the foretify namespace with using.
  • Declare all parameters and fields using the generated C++ class names (the _t names).

Example

C++ code: access OSC2 entities
#include "check_min_dist.h"
#include  <cmath>

using namespace foretify;

bool_t sut_t::check_minimum_distance_to_other_car(vehicle_t other_car) {
    bool_t found_new_minimum_distance = false;
    length_t length;
    ...
    return found_new_minimum_distance;
}

Accessing OSC2 entities

The following sections describe how to access each OSC2 type, with examples.

Primitive types

The following table describes the OSC2 primitive types and their corresponding C++ class.

OSC2 type Name in C++ interface Underlying C++ type
int int_t std::int64_t
uint uint_t std::uint64_t
float float_t double
bool bool_t std::int64_t
external_data external_t void*

Physical types

Physical types are used to characterize the physical movement in space and time, including speed, length, angle, time, and so on. The generated C++ header file includes a predefined class for each of the physical types in OSC2: speed_t, length_t, angle_t, time_t, and so on. In a C++ expression, you must append the OSC2 unit for a physical type to the number with an underscore, for example:

C++ code: access OSC2 physical types
2_km
5_mile
10_ms

The following examples show how to specify physical units in C++:

C++ code: access OSC2 physical types
// The variable calc_distance is of type length with value of 10250 meters:
auto calc_distance = 5 * 2_km + 500_m / 2;

// Compare distance to values in any unit type - automatic conversion
if (calc_distance < 5_mile)
     printf("Distance is smaller than 5 miles\n");

// ERROR! Returns a compilation error because these units represents different types
auto value = 5_ms + 1_degree;

The header file also contains predefined methods for converting between C++ double and OSC2 physical types. These methods are required when performing calculations that operate only on double values.

For example when you need to send or receive a vehicle's speed to or from the simulator.

When casting between C++ doubles and OSC2 physical types, you must specify the unit. In Foretify, for each physical type there is a set of predefined units. For example, for length you can use: mm, millimeter, cm, centimeter, and so on. (See Physical type literals for a list of physical types and their predefined units.)

For each predefined unit of each physical type, there is a set of predefined methods in the header file for casting between C++ and the OSC2 physical type. For example, since both millimeter and mm are predefined units for length, the C++ header file has two corresponding sets of methods. The first method in each set casts an OSC2 millimeter value to C++ value. The second casts a C++ value to an OSC2 millimeter value.

C++ code: casting methods for millimeter and mm
float_t millimeter()               // OSC2 -> C++
length_t millimeter(float_t val) // C++ -> OSC2

float_t mm()                       // OSC2 -> C++
length_t mm(float_t val)         // C++ -> OSC2

For each defined unit of a physical type, you can extract the nearest integer value using rounding methods.
For example:
int64_t distance_t::round_to_millimeter()      // OSC2 -> C++ (rounded to nearest integer)
int64_t distance_t::round_to_mm()              // OSC2 -> C++ (rounded to nearest integer)

In addition to unit-based casting, the following methods are available for all physical types:

C++ code: SI value access and utility methods
// SI value access
constexpr float_t value_in_si() const noexcept;              // Get value in SI units
static constexpr distance_t from_si(float_t val) noexcept;   // Create from SI value

// Max/min values
static constexpr distance_t max_value() noexcept;  // Maximum representable value
static constexpr distance_t min_value() noexcept;   // Minimum representable value

// Tolerance-based equality
inline const bool nearly_eq(const distance_t& other, const double rel_tol,
                            const distance_t abs_tol = distance_t(0.0)) const;

Here are some examples of how to use these casting methods:

C++ code: using casting methods (examples)
length_t dist_from_car = length_t::meter(0.5);   // C++ -> OSC2
double dist_in_feet = dist_from_car.feet();          // OSC2 -> C++

// Print distance after conversion to millimeters:
printf("Distance from vehicle in mm: %f\n", dist_from_car.mm());  // prints 500.000000

Enumerated types

Enumerated types are cast to and from C++ using static methods through the generated C++ getters and setters.

The from_string() static method retrieves an enum value by its name, as shown in the example below:

Example

OSC2 code: enumerated type
# OSC2 enum definition:
enum wheel_base: [two=2, three=3, four=4, ten=10, sixteen=16]

extend vehicle:
    wheel_number: wheel_base

    def check_wheelbase(car1: vehicle, car2: vehicle) is external cpp("check_wheelbase", "wheelbase.so")
C++ code: generated enum and method definitions
// Generated C++ method definition:
void foretify::vehicle_t::check_wheelbase(foretify::vehicle_t, foretify::vehicle_t)

// Generated C++ enum definition:
class wheel_base_t {
    public:
        ...

        // Get an enum value by its name
        static wheel_base_t from_string(const std::string& val);

        // Values are not hard-coded in the generated header since values may
        // change according to the loaded OSC2 files.
        // Value will be evaluated on runtime
        static wheel_base_t two();
        static wheel_base_t three();
        static wheel_base_t four();
        static wheel_base_t ten();
        static wheel_base_t sixteen();
        ...

};
C++ code: method implementation
// Using OSC2 type in C++ for the "wheel_number" field of actors car1 and car2:
#include "wheelbase_types.h"
#include <cmath>

using namespace foretify;

void vehicle_t::check_wheelbase(vehicle_t car1, vehicle_t car2) {

    wheel_base_t wheel_no1 = car1.wheel_number();
    wheel_base_t wheel_no2 = car2.wheel_number();

    // Print numerical value
    printf("First vehicle wheel base is: %d\n", wheel_no1.value());
    printf("Second vehicle wheel base is: %d\n", wheel_no2.value());

    // Print string value
    printf("First vehicle wheel base is: %s\n", wheel_no1.to_string().c_str());
    printf("Second vehicle wheel base is: %s\n", wheel_no2.to_string().c_str());

    car2.set_wheel_number(wheel_base_t::ten());

    // Get enum value by name
    wheel_base_t wb = wheel_base_t::from_string("sixteen");
}

String types

You can access OSC2 fields of type string using getter and setter functions, in the same way that you access fields of primitive types. Values are received in C++ as const char*.

However, unlike primitives, the memory management of strings is handled by Foretify's garbage collector: - When you set a string-based field, Foretify copies the string value into Foretify-managed memory. - When you receive a string value, the memory of the field is valid only within scope of the current OSC2 method call. To save a string value for later use, you should copy the string into a data structure that is accessible from other C++ entities.

Example

OSC2 code: string
extend sut
    !model: string
C++ code: accessing string type
#include "chk_model.h"
#include <cmath>

using namespace foretify;

void sut_scenarios::check_model(vehicle_t other_car) {

    auto model1 = other_car.model();

    printf("Other vehicle model is: %s\n", model1);

    printf("Current SUT model is: %s\n",  this->vehicle().model());

    // Setting new string
    string_t new_string = string_t::create("toyota");
    this->vehicle().set_model(new_string);

    printf("New SUT model is: %s\n",  this->vehicle().model());
}

Range types

The C++ class that represents ranges of OSC2 is range_t<>. It is a template class that can represent any int or physical type range. Ranges are immutable objects; upon creation, you can define their value and access their lower and upper bounds but you cannot set them.

An OSC2 range in C++ is defined as:

C++ code: range type definition syntax
range_t<<osc_type>>

For example, a range of int and a range of speed are defined in C++ as:

C++ code: range type definition examples
range_t<int_t>
range_t<speed_t>

Create ranges by using the static range<T>::create(T lowerBound, T upperBound) function. The create function throws an exception if the lowerBound is larger than the upperBound.

C++ code: range of speed creation example
range_t<speed_t>::create(40_kph, 50_kph)

You can use the following functions to access a range:

lowerBound()

Syntax Parameters Return value Description
T lowerBound() const None The lower bound of the range Returns the lower bound of the range

upperBound()

Syntax Parameters Return value Description
T upperBound() const None The upper bound of the range Returns the upper bound of the range

Comparing ranges (using the == and != operators) is done by comparing the values of the lower and upper bounds and not by pointer equality.

Full example of C++ range handling

Assume the following OSC2 code:

C++ code: speed_holder struct
struct speed_holder:

     var speeds: range of speed

The following example shows a C++ function that accepts a speed_holder and modifies the speed's range to be 10kph higher:

C++ code: range type example
void make_it_higher(speed_holder_t holder) {

    range_t<speed_t> range = holder.speeds()

    auto new_range = range_t<speed_t>::create(range.lowerBound()+10_kph, range.upperBound()+10_kph)

    holder.set_speeds(new_range)

}

List types

A list is a way to describe an ordered collection of similar values in OSC2. A OSC2 list in C++ is defined as:

C++ code: list type definition syntax
list_t<<osc_type>>

For example, a list of point and a list of vehicle are defined in C++ as:

C++ code: list type definition examples
list_t<point_t>
list_t<vehicle_t>

You can use the predefined C++ interface list methods described in the following sections to manipulate OSC2 lists in various ways, including:

  • size()
  • is_empty()
  • clear()
  • append()
  • pop()
  • get()
  • create()

In addition, OSC2 lists supports the C++ iterator interface, allowing iteration using for-each loops: - for()

size()

Syntax Parameters Return value Description
size_t size() None size_t Returns the number of items in the list

is_empty()

Syntax Parameters Return value Description
bool is_empty() None bool Returns true when number of items in the list equals zero

clear()

Syntax Parameters Return value Description
void clear() None void Clears all list items and resizes it to 0

append()

Syntax Parameters Return value Description
void append(<osc_type> item) item - A single item from the type of the items on this list void Adds a single item to the end of the list

pop()

Syntax Parameters Return value Description
<osc_type> pop() None A single item from the type of the items on this list Returns the last item and removes it from the list

get()

Syntax Parameters Return value Description
<osc_type> get(size_t index) index - The index from which to retrieve the item A single item from the type of the items on this list Returns an item in the specified index (can be accessed with [ ] as well). If the index is bigger than the list size, behavior is undefined.

create()

Syntax Parameters Return value Description
list_t<osc_type> create() None A new list instance A static method which creates a new empty list

for()

OSC2 lists support C++ iterator interface, which allows iteration using for-each loops.

Syntax Parameters Return value Description
for (<variable> : <array>)... <variable>: Has the form <element-type> | auto <variable-name>
<array>: The name of the array whose elements you want to iterate through.
Iterate through each element in an array, assigning the value of the current array element to the variable.

Example

C++ code: use of for()
map_scenarios::create_lane_path_from_roads(list_t<road_t> road_list) {
    auto lane_list = list_t<lane_t>::create();

    // Iterate through the road list
    for (auto road : road_list) {
        auto selected_lane = road.lanes()[0]; //Create path of first lane on each road
        lane_list.append(selected_lane);
    }
    printf("Total number of lanes: %lu\n", lane_list.size());

    return lane_list;
}

Compound types

A C++ class is generated for each OSC2 compound type (actor, scenario, and struct). Each class is created with a set of getter and setter methods so you can access and modify the object's fields.

In addition, a method in the form of invoke_<OSC2-method-name> is created for each of the object's methods. When called, Foretify invokes the method according to its definition in OSC2.

Each compound type has a static create() method that returns a new instance of that type.

External data types External data types (external_data) hold a pointer to an external entity, such as a C++ class. Fields of this type cannot be generated, so they must have the do-not-generator operator !.

Example 1

This example shows how to set and retrieve values for various types of external data.

C++ code: set and retrieve external data
#include "generated_external_data.h"
#include "test_utils.h"
#include<string>

using namespace foretify;

struct my_struct {
    int size;
};

class class1 {
    public:
      int i = 0;
      int* ptr;
      int arr[5];
      char str[10] = "";
      my_struct s1;
};

class1* class_ptr = new class1;
external_data_t e_array = new external_data_t;
int* int_ptr = new int;
my_struct* struct_ptr = new my_struct;

//Set external_data to point to class, pointer, array, cpp struct
void car1_scenarios::set_ext_data(external_data_t e_class) {
    external_data_t e_ptr, e_str, e_struct;

    //Assign values to the external_data variables
    char abc[10] ="hello";
    struct_ptr->size = 50;
    e_struct = struct_ptr;

    //Assign values to the external_data variables through methods
    e_ptr = set_int_ptr();
    set_array(e_array);

    //set the external_data using setter methods
    set_external_int_ptr(e_ptr);
    set_external_array(e_array);
    set_external_string(&abc);
    set_external_struct(e_struct);
    set_class_members(e_class);
}

void car1_scenarios::set_class_members(external_data_t e_class) {
    int j = 0;
    //Assign values to class members through external_data using getter methods
    class_ptr->i = 10;
    class_ptr->ptr = reinterpret_cast<int*>(external_int_ptr());
    for(j=0; j<5; j++) {
        class_ptr->str[j] = reinterpret_cast<char*>(external_string())[j];
    }
    class_ptr->str[j] = '\0';
    for(j=0; j<5; j++) {
        class_ptr->arr[j] = reinterpret_cast<int*>(external_array())[j];
    }
    class_ptr->s1 = *reinterpret_cast<my_struct*>(external_struct());

    e_class = class_ptr;
    set_external_class(e_class);
}

external_data_t car1_scenarios::set_int_ptr() {
//Set external_data variable through method which returns external_data
    external_data_t ee;
    *int_ptr = 20;
    ee = int_ptr;
    return ee;
}

void car1_scenarios::set_array(external_data_t e_arr) {
//Set external_data variable through method which takes external_data as param
    for(int i=0; i <5; i++) {
        reinterpret_cast<int*>(e_arr)[i] = i*100;
    }
}

//Check the value assigned to the class members
//i.e. Check values assigned by setters and getters of external_data
void car1_scenarios::check_ext_data(external_data_t e) {
    assertTrue(reinterpret_cast<class1*>(e)->i == 10, "external_data: i");
    assertTrue(strcmp(reinterpret_cast<class1*>(e)->str, "hello") == 0, "external_data: str");
    assertTrue(*reinterpret_cast<int*>(external_int_ptr()) == 20, "external_data: ptr");
    assertTrue(reinterpret_cast<class1*>(e)->s1.size == 50, "external_data: struct");
    assertTrue(reinterpret_cast<class1*>(e)->arr[0] == 0, "external_data: arr[0]");
    assertTrue(reinterpret_cast<class1*>(e)->arr[1] == 100, "external_data: arr[1]");
    assertTrue(reinterpret_cast<class1*>(e)->arr[2] == 200, "external_data: arr[2]");
    assertTrue(reinterpret_cast<class1*>(e)->arr[3] == 300, "external_data: arr[3]");
    assertTrue(reinterpret_cast<class1*>(e)->arr[4] == 400, "external_data: arr[4]");

    //free allocated memory
    delete(class_ptr);
    delete(int_ptr);
    delete(struct_ptr);
    delete(reinterpret_cast<int*>(e_array));

Example 2

A good use case is that a C++ library needs to save data between multiple C++ methods without defining structs in OSC2, either because the required data type is not available in OSC2 (a hash table, for example) or just to save time.

OSC2 code: retrieve external data
extend vehicle:
    !states_on_lane_change: external_data

    def set_states_on_lane_change(states: external_data) -> external_data is \
       external.cpp("set_states_on_lane_change", "car_states.so")
C++ code: send external data
// C++

class car_state {
    speed_t speed;
    ...
};

void vehicle_scenarios::on_create() {
    auto states = new std::vector<car_state>();
    this->set_states_on_lane_change(states);
}

void vehicle_scenarios::on_lane_change() {
    auto states = static_cast<std::vector<car_state>*>(this->set_states_on_lane_change());

    states.push_back({this->speed, ... });
}

Scenarios

Scenarios are represented as a class under the related actor's namespace. For example, scenarios declared under the sut actor are represented under sut_scenarios. This allows scenarios with the same name to be defined in different actors.

Example

OSC2 code: scenario implementation
scenario sut.cut_in_and_slow:
     car1: vehicle

     do serial():
         c: cut_in(car1: car1)
         s: slow(car1: car1)

scenario sut.cut_in:
     car1: vehicle
     overtake_side: side

     do serial():
         ...
C++ code: generated scenario
// Generated C++ code:
namespace sut_scenarios {
    //...
    class cut_in_and_slow_t {
        ...
        cut_in_t c();
        slow_t s();
        ...
    }
    class cut_in_t { ... }
    class slow_t { ... }
}

Sub-scenarios access

As for actors and structs, a C++ class is generated for each scenario type, with a set of getter/setter methods. Scenarios can also contain sub-scenario invocations.

In the C++ interface, you can access sub-scenarios using their named label. A getter method is generated for each sub-scenario. It returns a new proxy class that can be used to access the sub-scenario instance.

The global agent top

OSC2 defines a unique actor named top. This actor, and all of its fields, are accessible in the global namespace when writing code in OSC2 and in C++.

To access the actor and its fields, you can write top() from any method implemented in C++ code that is linked with Foretify.

Example

C++ code: access top()
auto sim_time = top().sim_time;

Invoking OSC2 methods from C++

For each OSC2 method, a corresponding C++ method with the name invoke_<method_name> is generated. You can invoke OSC2 methods at any time from your C++ code. Example: From the following OSC2 method:

extend truck:
    def get_truck_length(including_trailer: bool = true) -> length is:
        if including_trailer:
             return compound_object.bbox.length
        else: 
             return bbox.length
This CPP interface is generated:

C++ code: invoke OSC2 method
 foretify::distance_t invoke_get_truck_length(foretify::bool_t including_trailer = true);
Parameter default values

When a method is defined in an OSC2 file with defaults specified for some of its parameters, the generated C++ will have these defaults. For example, in the previous example, the default true for the including_trailer parameter is generated in C++ Limitations:

  • Only defaults for parameters of type int, float, bool, enum and any physical type such as distance are reflected in the generated C++ header (for other types of parameters, a proper C++ representation is generated, but the default is not specified - the calling C++ code must specify a value).

  • If an OSC2 parameter of a method is specified without a default value, the generated interface will not have the defaults for parameters preceding it. For example:

    def my_method(a: int = 7, b: int, c: int = 4) is:
    
    will generate the following C++ interface:
    void foretify::invoke_my_method(foretify::int_t a, foretify::int_t b, foretify::int_t c = 4);
    

Events entity

For each event defined in a type, the following C++ methods are created:

  • void emit_<event_name>(<params>)

  • uint_t <event_name>_event_occurrences()

  • bool_t <event_name>_event_occurred()

  • <event_data_struct> <event_name>_event_data()

    Note

    The event_data_struct type name ends with _t.

Object inheritance

OSC2 has four inheritance mechanisms, and each has different implications on the generated C++ code:

Extensions

The C++ files are typically generated from a set of multiple OSC2 files: the single OSC2 file that is specified as the top file, and the additional OSC2 files that are imported by it.

Each of the OSC2 files loaded can contain multiple extensions for each of the objects. However, at the time of the C++ code generation, the C++ interface for each object is defined by the collection of all of its extensions.

This means that the resulting C++ class represents the composite snapshot of each OSC2 object.

Example

OSC2 code: object definition
# File1.osc
struct road:
    length: length
# File2.osc
import file1.osc
extend road:
    width: length

C++ code: generated object
// Generated C++ code:
class road_t {
    ...
    length_t length();
    length_t width();
    ...
};

Unconditional inheritance

OSC2 implements single inheritance between extensible classes. This works like inheritance in most object-oriented programming languages.

In the C++ interface, unconditional inheritance is expressed using the regular C++ inheritance. The proxy class of each inherited type inherits from the proxy class of its OSC2 parent type.

For example, if the actor truck inherits from vehicle, then its representation in C++, truck_t inherits from vehicle_t. This enables access to all of the fields of the parent type from the inheriting type.

Conditional inheritance

Conditional inheritance enables the creation of sub-types that depend upon a value of a Boolean or enumerated field.

This definition means that conditional inheritance evaluation can only occur during runtime. The proxy class of an OSC2 object inherits its parent object as in unconditional inheritance. However, casting between conditional types works differently.

Two methods are defined to enable runtime checking and casting: - is<>() - Checks whether a cast is legal. (The conditions for inheritance are met.) - as<>() - Performs a cast to the specified conditional type.

Example

OSC2 code: conditional inheritance
# OSC2:
actor my_vehicle:
    is_truck: bool
    is_emergency: bool
    ...

actor truck: my_vehicle(is_truck: true):
    has_trailer: bool
    ...

actor emergency: my_vehicle(is_emergency: true):
    em_type: emergency_vehicle_type
    ...
C++ code: generated code for conditionally inherited objects
// C++:
{
    ...
    if (my_vehicle.is<truck_t>() && my_vehicle.is<emergency_t>()) {
        truck_t truck = my_vehicle.as<truck_t>();
        emergency_t emergency = my_vehicle.as<emergency_t>();

        printf("truck has trailer: %d\n", truck.has_trailer());
        printf("emergency type: %s\n", emergency.em_type());
    }
}

Printing messages to the Foretify logs

To print messages to the Foretify logs, use the following macros:

OSC2 code: printing to Foretify logs
FTX_LOG_TRACE(<message>)
FTX_LOG_DEBUG(<message>)
FTX_LOG_INFO(<message>)

Messages are printed to the log depending on the level set by set verbosity or other verbosity configuration options.

Raising errors

To raise an error from user code you should raise an exception. Only exceptions inheriting from foretify::FtxUserError should be used. You can either use the FtxUserError class directly, or write a new class that inherits it.

If any other error is raised, the error message is: "Unhandled exception caught".

When an exception is raised during method invocation, the test run stops immediately, and an error message is displayed, including the OSC2 methods stack dump. All exceptions are reported as issues. See Analyzing and resolving issues in the Foretify & FRun for more details about issues.

in modifier

TBD

Declaring an external method

You can declare an external method within a struct, scenario or actor using OSC2's def construct. If the method will be used exclusively with a particular struct or scenario, it might make sense to declare the method there. Declaring a method within an actor such as sut, vehicle or even top makes the method more broadly available.

Declaring a placeholder for a method

You can declare a method without specifying the name or location of the C++ implementation, if -- for example -- the implementation is not yet complete. To do this, use the term undefined in the method declaration:

OSC2 code: undefined methods
extend sut:
    def check_sensor(sensor: int) -> bool is undefined
Methods declared as undefined will cause a runtime error if called.

Defining a method

To define a method, you must specify a name for the method, the kind of method it is and the shared object that contains the method. In the following example, break_camera is the OSC2 name for the method, the kind of method is external C++ (external.cpp), and the shared object is dut_failures.so.

OSC2 code: define a method
extend sut:
    def break_camera(failure_type: int) is external.cpp("dut_failures.so")

Note

The name you specify for the method must be unique in the context where it is called.

If the actual name of the C++ method is not the same as the OSC2 name, you must also specify the actual name. For example, if the actual name of the C++ method is malfunction_1, you should declare the method like this:

OSC2 code: specify C++ method name
extend sut:
    def break_camera(failure_type: int) is external.cpp("malfunction_1", "dut_failures.so")

Note

Do not specify the full pathname of the shared object; specify only the filename. Foretify looks for the shared object following your operating system's conventions. In Linux, for example, Foretify looks for the shared object in the paths defined in the LD_LIBRARY_PATH environment variable.

Extending a method

For the purposes of a particular test, you might need to modify the logic of the method. For example, you might need to:

  • Check for a particular condition before executing the method.
  • Execute the method and then check for a particular condition.
  • Replace the method entirely with a temporary implementation.

You can use the keywords is first, is also or is only to modify the method.

For example, let's assume that you want to check a particular sensor before executing the break_camera method. In the test file, you extend the sut actor where break_camera is defined as follows:

OSC2 code: extend a method (is first)
extend sut:
    def break_camera is first external.cpp("check_sensor(sensor: int)", "sensors.so")
If you want to execute the break_camera method first and then check the sensors, add this to the test file:

OSC2 code: extend a method (is also)
extend sut:
    def break_camera is also external.cpp("check_sensor(sensor: int)", "sensors.so")

If you want to try out a new implementation of break_camera, add this to the test file:

OSC2 code: extend a method (is only)
extend sut:
    def break_camera is only external.cpp("break_camera_2", "dut_failures.so")

Note

When you use is first and is also, the generated C++ files have a separate method declaration for each specified method that is invoked. For example, if you define a method to call break_camera and then extend the definition to first call print_sensor_status, there will be a separate method declaration for break_camera and print_sensor_status in the generated C++ files.

Example

The following example defines a method with the OSC2 name of check_minimum_distance_to_other_car. Since the C++ name of the method is not specified, it is assumed to be the same as the OSC2 name.

OSC2 code: define a method
extend sut:
    def check_minimum_distance_to_other_car(other_car: vehicle)-> bool is
        external.cpp("check_minimum_distance_to_other_car", "chk_min_dist.so")

This external method declaration in OSC2 results in the following declaration in the generated C++ header file:

C++ code: generated method declaration
class sut_scenarios : public foretify::any_agent_t {
    public:
        ...
        foretify::bool_t check_minimum_distance_to_other_car(foretify::vehicle_t other_car);
        ...
}

Of course, you must supply the C++ implementation for the external methods that you declare.

Generating C++ header files

This section uses the term OSC2 source files for the C++ interface to refer to the set of OSC2 files in a project that:

  • Declare external C++ methods.
  • Define new types, fields, events or objects accessed by those methods.
  • Extend predefined types accessed by those methods.

Note

This term does not include OSC2 files that call external C++ methods.

Workflow for generating and using header files

Here is one way to generate and use header files:

  1. Create a <cpp-header>.osc file that imports all the OSC2 source files for the C++ interface. <cpp-header> is the name applied by default to the generated header files.
  2. Use <cpp-header>.osc to generate the header files.
  3. Create a <test_top>.osc file that imports all the OSC2 files in the test. Import <cpp-header>.osc from the test file - <test_top>.osc.
  4. Use <test_top>.osc to compile the test.

Example

Assume that the following three files comprise the OSC2 source files for the C++ interface that is required for a particular test.

OSC2 code: user-defined_types example
# user_defined_types.osc
# extends vehicle with a field required for the external method
extend vehicle:
    minimum_car_distance: length with:
        keep(soft it == 3m)
OSC2 code: check_min_dist example
# check_min_dist.osc
# declares the external method
extend sut:
  def check_minimum_distance_to_other_car(other_car: vehicle)-> bool is
        external.cpp("check_minimum_distance_to_other_car", "check_min_dist.so")
OSC2 code: check_min_dist_h example
# check_min_dist_h.osc
# imports all files required by the external method
import "user_defined_types.osc"
import "check_min_dist.osc"

The create_osc_headers utility is used to create the header files.

Shell command: invoke create_osc_headers
$ create_osc_headers --load check_min_dist_h.osc --out_file check_min_dist

Note

You can include more than one --load option when you invoke create_osc_headers.

The create_osc_headers utility creates the following files and folders:

  • check_min_dist.cpp: include this file on the g++ compiler command line when you create the shared library containing the C++ implementation of the external method check_minimum_distance_to_other_car().
  • check_min_dist.h: include this header file in the file containing the C++ implementation of the method check_minimum_distance_to_other_car().
  • check_min_dist/: This folder contains multiple files and subfolders, and is used by check_min_dist.h. For example, it contains vehicle_t.h that has the C++ interface for vehicle.

Note

Use create_osc_headers --help to see all the options of this utility.

You can then import check_min_dist_h.osc in the test file, for example:

OSC2 code: import generated header files
# test_top.osc
# imports the files required by the external method and then the test itself
import "check_min_dist_h.osc"
import "test_check_min_dist.osc"

Recreating the header files after changes in OSC2 code

Although you can load different OSC2 code from that used to create the C++ header, some code differences lead to a runtime error:

  • Writing or reading a field whose declaration was not loaded.
  • Invoking an external method whose declaration was not loaded.
  • Invoking an external method whose signature (return value or arguments) has changed.
  • Using an enumerated type value whose declaration was not loaded.
  • Creating an instance of a compound object whose declaration was not loaded.
  • Emitting an event whose declaration was not loaded.

You can recreate the C++ header files automatically with a makefile by adding a dependency between the header files and all of the OSC2 files that are used to create it.

Recreating the header files with each new Foretify version

You must also recreate the header files when you switch versions of Foretify. Foretify checks to ensure that the Foretify version used to create the header files is the same as the Foretify version used to run the simulation.

Compiling a shared object

Use the g++ compiler to compile the generated header files and the user-defined implementation of the C++ method into a shared object.

Shell command: compile C++ files with g++ (syntax)
g++ -std=c++11 -fPIC -shared -isystem $FTX/includes -I<dir> <generated-cpp-file> <user-cpp-files> -o <shared-object-name>
Flag Meaning
-std=c++11 Specifies the C++ standard for the user-defined implementation. C++11 is required.
-fPIC Generates position-independent code.
-shared Creates a shared library.
-isystem Points to the FTX directory that has additional required header files.
-I<dir> Search the specified directory for the header files generated by create_osc_headers
<generated-cpp-file> The name of the .cpp file created by create_osc_headers.
<user-cpp-files> The name of one or more files containing the user-defined implementation of C++ methods that you want to include in the shared object.
-o <shared-object-name> Specifies the filename of the shared object.

Note

The folder $FTX/includes contain C++ header files with definitions needed by the generated files.

Example

Shell command: compile C++ files with g++ (example)
$ g++ -std=c++11 -fPIC -shared \
 -isystem $FTX/includes \
 -I/project/headers \
 /project/headers/check_min_dist.cpp \
 /project/cpp_source/check_min_dist.cpp \
 -o /project/libs/dut_ext.so

Creating a test that calls the external method

Example 1: no return value

Some external methods do not return a value. For example, the break_camera method disables the camera with a specified failure type:

OSC2 code: external method (no return)
extend sut:
    def break_camera(failure_type: int) is external.cpp("malfunction_1", "dut_failures.so")

To invoke an external method that does not return a value, use the call scenario. In the following example, the method is called at the start event of the slow phase of the scenario cut_in_and_slow:

OSC2 code: invoke external method (no return)
extend test_config:
    set map = "$FTX_PACKAGES/hooder.xodr" # specify map to use in this test

extend top.main:
    on @c.slow.start:
        call sut.break_camera(3)

    do c: cut_in_and_slow()

Example 2: return value

Some methods return a value, for example

OSC2 code: external method (with return)
extend sut:
    def check_sensor(sensor: int) -> bool is external.cpp("sensors.so")

If a method returns a value, you can invoke it in any place where an expression of that type can be used, such as in Boolean expressions, constraint expressions, qualified event expressions, coverage computations and so on. For example:

OSC2 code:invoke external method (with return)
extend top:
    do serial:
        if (check_sensor(1))
            scenario1()
        else:
            scenario2()

Example 3: print return value

OSC2 code: external method (print return value)
# test_check_min_dist.osc
extend top.main:
  car1: vehicle

  do s1: serial:
    p1: parallel(duration: [10..15]second):
      sut.car.drive()
      car1.drive() with:
        speed(speed: [50..200]kph)
    log_info("[MIN DIST CHK] Minimum distance is observed: " + "$(sut.check_minimum_distance(car1))")
OSC2 code: test_top
# test_top.osc
# import the OSC2 source files for the C++ interface
import "cpp_top_h.osc"
# import the actual test
import "test_check_min_dist.osc"

Executing the test

Here is the work flow for executing the test:

  1. Make sure that the shared library is accessible to Foretify.
  2. Load the test into Foretify and start the run as usual.

Make the shared library accessible

To make the shared library accessible to Foretify, you need to include the pathname of the directory containing the library in the LD_LIBRARY_PATH environment variable. Since this variable might be set already, it is best to append the directory to the current definition using the syntax:

Shell command: extend LD_LIBRARY_PATH
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:<path>

Example

For example, if the shared library is in /project/libs, do the following:

Shell command: load OSC2 code with external method
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/project/libs
$ foretify --load test_top.osc --run