Models¶
A model is used to encapsulate the logic for simulating the system under test (SUT) and producing
a timed set of state values called a Trace. Models can be implemented for just about any system,
regardless of if the system involves solely software, physical hardware, or a distributed set of
services. In general, you would choose to use a model when you already have a way to analyze the
output of a system (like a specification) and just need a way to produce
those outputs.
Traces¶
A Trace is a time-annotated set of system states produced by either
directly executing or simulating a system. In this respect, you can imagine a trace as a key-value
mapping between times and system states. You can create a Trace either by providing the mapping
directly or by providing a set of times and a set of states of equal length.
from staliro.models import Trace
t1 = Trace(times=[1, 2, 3], states=["red", "green", "blue"])
t2 = Trace({1: "red", 2: "green", 3: "blue"})
Note
It is recommended to use keyword arguments when constructing a Trace from times and states for readability. If you are using a type-checker, keyword arguments will be enforced.
Warning
If you are uncertain about the shape of your data, you should use the len() function
to ensure that the length of the trace is what you expect.
Results¶
Models are expected to return an instance of staliro.Result containing a Trace
value, which can result in longer type annotations and more function calls.
from staliro import Result
from staliro.models import Trace
r: Result[Trace[list[float]], None]
r = Result(Trace(times=[], states=[]), None)
To simplify both type annotation and call-sites, you can construct a
models.Result, which handles the construction of the inner
Trace while still allowing for annotation data to be provided.
from staliro.models import Result
r: Result[list[float], None]
r = Result(times=[], states=[], extra=None)
r = Result({}, None)
Base Class¶
To implement a model, you must inherit from the Model base class. The
only required method is the simulate() method which must accept a
Sample value and return a Result value. A Model is
parameterized by the type variables S and E which represent the type of the state values
produced by the model and the type of the annotation data.
from dataclasses import dataclass
from random import Random
from staliro import Sample, models
@dataclass()
class State:
"""The state of the system under test."""
class Foo(models.Model[State, int]):
def __init__(self):
self.rng = Random()
def simulate(self, sample: Sample) -> models.Result[State, int]:
return models.Result(
times=[0, 1, 2],
states=[State(), State(), State()],
extra=self.rng.randint(0, 100),
)
Decorator¶
For systems that only depend on their input, you can also implement a Model by decorating a
function with model(). As with extending the Model class, decorated
functions must accept a Sample as a parameter and return a staliro.Result value.
from staliro import Sample, models
@models.model()
def trace(sample: Sample) -> models.Trace[float]:
return models.Trace(times=[1.0, 2.0, 3.0], states=[-4.1, 3.2, 100.7])
@models.model
def result(sample: Sample) -> models.Result[list[float], str]:
return models.Result({1.0: [0.1, 4.8, 2.9], 2.0: [-0.5, 3.2, 2.1]}, extra="foo")
Blackbox¶
A Blackbox model is similar to the base Model but instead of receiving the input signals
as a set of continuous functions, it receives each signal evaluated at a uniformly spaced set of
times. This is most similar to how systems are simulated using Simulink.
Inputs¶
As inputs, a Blackbox model receives a
Blackbox.Inputs value, which contains 2 attributes:
static and
times. The static attribute returns the mapping of
names and values for each of the static inputs declared in the options. The
times attribute returns a mapping which contains the evaluation times as keys and a name-value
mapping containing the signal evaluations for the time. The times for the evaluation are uniformly
spaced between the start and end of the tspan of the options object. If no signals are provided,
then the times attribute will be empty.
from staliro import SignalInput, TestOptions, models
@models.blackbox()
def model(inputs: models.Blackbox.Inputs) -> models.Trace[list[float]]:
x = inputs.static["alpha"] * inputs.static["beta"]
states = {}
for time in inputs.times:
signals = inputs.time[time]
states[time] = x + signals["rho"] / signals["phi"]
return models.Trace(states)
options = TestOptions(
tspan=(0, 100),
static_inputs={
"alpha": (100, 200),
"beta": (0.0, 1.0),
},
signals={
"rho": SignalInput(control_points=[(0, 10), (10, 20), (20, 30)]),
"phi": SignalInput(control_points=[(20, 30), (20, 10), (10, 20)]),
}
)
Decorator¶
You can construct a Blackbox model by annotating a Python function with the
blackbox() decorator. The blackbox() decorator accepts an optional
step_size parameter which specifies the duration between each evaluation time (and by
consequence the number of evaluation times).
import staliro.models as models
@models.blackbox()
def no_step(inputs: models.Blackbox.Inputs) -> models.Result[object, None]:
...
@models.blackbox(step_size=0.5)
def with_step(inputs: models.Blackbox.Inputs) -> models.Trace[object]:
...
ODE¶
Some systems can be defined in terms of ordinary differential equations (ODE), which are
functions that return the derivative of the system given the current state of the system. In general
these systems are simulated using integration techniques that for a given initial state will
iteratively evaluate the derivative returned by the ODE to produce a new state that is used in the
next integration step. The implementation of this model relies on the solve_ivp() method from
the SciPy integrate module.
Inputs¶
ODE models are expected accept a Ode.Inputs value that
has the attributes time,
state, and
signals. time is the current time of the
simulation as decided by the integrator. state is a mapping of names to values that represents
the current state of the simulation. The names in the state mapping are the names of the static
inputs in the options. Finally, signals is a name-value mapping that contains
the value of each signal for the current time. The names in the signals map are the same as the
names of the signals in the options.
from staliro import SignalInput, TestOptions, models
@models.ode()
def model(inputs: models.Ode.Inputs) -> dict[str, float]:
alpha_dot = inputs.static["alpha"] * inputs.static["beta"] + inputs.signals["rho"]
beta_dot = inputs.static["beta"] / inputs.signals["phi"]
return {"alpha": alpha_dot, "beta": beta_dot}
options = TestOptions(
tspan=(0, 100),
static_inputs={
"alpha": (100, 200),
"beta": (0.0, 1.0),
},
signals={
"rho": SignalInput(control_points=[(0, 10), (10, 20), (20, 30)]),
"phi": SignalInput(control_points=[(20, 30), (20, 10), (10, 20)]),
}
)
Decorator¶
You can construct an Ode model by annotating a Python function with the
ode() decorator. The ode() decorator accepts an optional
method parameter which specifies the integration method to use for simulation.
import staliro.models as models
@models.ode()
def no_method(inputs: models.Ode.Inputs) -> dict[str, float]:
...
@models.ode(method="Radau")
def with_method(inputs: models.Ode.Inputs) -> dict[str, float]:
...