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:
- Write the implementation of the external C++ method.
- Declare an external C++ method in OSC2.
- Generate C++ header files using create_osc_headers.
- Compile a shared object from the generated files and the implementation files.
- Create a test that calls the C++ method.
- 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.
- 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_.
# OSC2 compound type definition:
actor my_vehicle:
max_speed: speed
for: int
// 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).
#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.
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:
2_km
5_mile
10_ms
The following examples show how to specify physical units in C++:
// 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.
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:
// 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:
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:
# 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")
// 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();
...
};
// 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.
extend sut
!model: string
#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:
range_t<<osc_type>>
For example, a range of int and a range of speed are defined in C++ as:
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.
range_t<speed_t>::create(40_kph, 50_kph)
You can use the following functions to access a range:
| Syntax | Parameters | Return value | Description |
|---|---|---|---|
T lowerBound() const |
None | The lower bound of the range | Returns the lower bound of the range |
| 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.
Assume the following OSC2 code:
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:
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:
list_t<<osc_type>>
For example, a list of point and a list of vehicle are defined in C++ as:
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()
| Syntax | Parameters | Return value | Description |
|---|---|---|---|
| size_t size() | None | size_t | Returns the number of items in the list |
| Syntax | Parameters | Return value | Description |
|---|---|---|---|
| bool is_empty() | None | bool | Returns true when number of items in the list equals zero |
| Syntax | Parameters | Return value | Description |
|---|---|---|---|
| void clear() | None | void | Clears all list items and resizes it to 0 |
| 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 |
| 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 |
| 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. |
| Syntax | Parameters | Return value | Description |
|---|---|---|---|
| list_t<osc_type> create() | None | A new list instance | A static method which creates a new empty list |
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. |
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.
This example shows how to set and retrieve values for various types of 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));
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.
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++
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.
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():
...
// 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 { ... }
}
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.
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.
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
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:
will generate the following C++ interface:def my_method(a: int = 7, b: int, c: int = 4) is: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.
# File1.osc
struct road:
length: length
# File2.osc
import file1.osc
extend road:
width: length
// 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<
# 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++:
{
...
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:
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:
extend sut:
def check_sensor(sensor: int) -> bool is undefined
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.
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:
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:
extend sut:
def break_camera is first external.cpp("check_sensor(sensor: int)", "sensors.so")
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:
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.
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:
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:
- 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.
- Use <cpp-header>.osc to generate the header files.
- 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.
- 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.
# 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)
# 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")
# 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.
$ 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 containsvehicle_t.hthat has the C++ interface forvehicle.
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:
# 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.
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
$ 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:
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:
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
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:
extend top:
do serial:
if (check_sensor(1))
scenario1()
else:
scenario2()
Example 3: 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))")
# 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:
- Make sure that the shared library is accessible to Foretify.
- 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:
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:<path>
Example
For example, if the shared library is in /project/libs, do the following:
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/project/libs
$ foretify --load test_top.osc --run