Integrate Your Own Vehicle Dynamics Model
Overview
Our architecture is specifically made to easily switch between different kinematic and dynamic models for planning, control and simulation. Each model requires a set of constituting parts, which have an interface (base class) enforcing an implementation compliant with the rest of the toolbox: The constituting parts of each model are:
| Component | Interface | Description |
|---|---|---|
| Vehicle model | VehicleModelInterface |
Provides the vehicle dynamics funciton and the functionality for computing the (normalized) combined acceleration. |
| State dataclass | StateInterface |
Dataclass for storing the state. |
| Control input dataclass | InputInterface |
Dataclass for storing control inputs. |
| Disturbance dataclass | DisturbanceInterface |
Dataclass for storing disturbances. |
| Full state noise dataclass | FullStateNoiseInterface |
Dataclass for storing sensor noise of the full state feedback sensor (see the sensor model documentation). |
| Trajectory dataclass | TrajectoryInterface |
Dataclass for storing state/input/distubrance trajectories |
| SIDT factory | StateInputDisturbanceTrajectoryFactoryInterface |
Creates states, inputs, disturbances and trajectories for the model. |
All constituting parts inherit from their respective interface. Respecting these interfaces allows users to easily incorporate their own work.
For more details, check the documentation of the vehicle model interface.
Example
Let us integrate the KinematicBicycleModel) as an example for you to follow for your own vehicle model.
1. Setup the state dataclass:
We start with the implementation of the state dataclass, whose attributes are the state variables:
@dataclass
class KBState(StateInterface):
"""
Dataclass storing the states of the kinematic bicycle model.
"""
position_x: float = None
position_y: float = None
velocity: float = None
heading: float = None
steering_angle: float = None
@property
def dim(self) -> int:
"""
:return: state dimension
"""
return KBStateIndices.dim
def convert_to_array(self) -> np.ndarray:
"""
Converts instance of class to numpy array.
:return: np.ndarray of dimension (self.dim,)
"""
x_np = np.zeros((self.dim,))
x_np[KBStateIndices.position_x] = self.position_x
x_np[KBStateIndices.position_y] = self.position_y
x_np[KBStateIndices.velocity] = self.velocity
x_np[KBStateIndices.heading] = self.heading
x_np[KBStateIndices.steering_angle] = self.steering_angle
return x_np
The convert_to_array() method returns a numpy array storing the state variables in its entries.
Since we frequently have to represent the state as an array (e.g., when evaluating the vehicle dynamics function), we implemented another dataclass providing the indices of the entries storing the respective state variable for safe access and conversion.
@dataclass(frozen=True)
class KBStateIndices(StateInterfaceIndex):
"""
Indices of the states of the kinematic bicycle model.
"""
dim: int = 5
position_x: int = 0
position_y: int = 1
velocity: int = 2
heading: int = 3
steering_angle: int = 4
2. Setup the control input, disturbance, and full state noise dataclass:
The other point dataclasses follow the same pattern as the state dataclass. Therefore, we paste the definition of the respective point and point index dataclasses below without further explanation.
@dataclass(frozen=True)
class KBInputIndices(InputInterfaceIndex):
"""
Indices of the control inputs of the kinematic bicycle model.
"""
dim: int = 2
acceleration: int = 0
steering_angle_velocity: int = 1
@dataclass()
class KBInput(InputInterface):
"""
Dataclass storing the control input of the kinematic bicycle model.
"""
acceleration: float = None
steering_angle_velocity: float = None
@property
def dim(self) -> int:
"""
:return: control input dimension
"""
return KBInputIndices.dim
def convert_to_array(self) -> np.ndarray:
"""
Converts instance of class to numpy array.
:return: np.ndarray of dimension (self.dim,)
"""
u_np = np.zeros((self.dim,))
u_np[KBInputIndices.acceleration] = self.acceleration
u_np[KBInputIndices.steering_angle_velocity] = self.steering_angle_velocity
return u_np
@dataclass(frozen=True)
class KBDisturbanceIndices(DisturbanceInterfaceIndex):
"""
Indices of the disturbances of the kinematic bicycle model.
"""
dim: int = 5
position_x: int = 0
position_y: int = 1
velocity: int = 2
heading: int = 3
steering_angle: int = 4
@dataclass
class KBDisturbance(DisturbanceInterface):
"""
Dataclass storing the disturbances of the kinematic bicycle model.
"""
position_x: float = 0.0
position_y: float = 0.0
velocity: float = 0.0
heading: float = 0.0
steering_angle: float = 0.0
@property
def dim(self) -> int:
"""
:return: disturbance dimension
"""
return KBDisturbanceIndices.dim
def convert_to_array(self) -> np.ndarray:
"""
Converts instance of class to numpy array.
:return: np.ndarray of dimension (self.dim,)
"""
w_np = np.zeros((self.dim,))
w_np[KBDisturbanceIndices.position_x] = self.position_x
w_np[KBDisturbanceIndices.position_y] = self.position_y
w_np[KBDisturbanceIndices.velocity] = self.velocity
w_np[KBDisturbanceIndices.heading] = self.heading
w_np[KBDisturbanceIndices.steering_angle] = self.steering_angle
return w_np
@dataclass(frozen=True)
class KBNoiseIndices(FullStateNoiseInterfaceIndex):
"""
Indices of the noise variables.
"""
dim: int = 5
position_x: int = 0
position_y: int = 1
velocity: int = 2
heading: int = 3
steering_angle: int = 4
@dataclass
class KBNoise(FullStateNoiseInterface):
"""
Full state noise of the kinematic bicycle model - required for the full state feedback sensor model.
"""
position_x: float = 0.0
position_y: float = 0.0
velocity: float = 0.0
heading: float = 0.0
steering_angle: float = 0.0
@property
def dim(self) -> int:
"""
:return: noise dimension
"""
return KBNoiseIndices.dim
def convert_to_array(self) -> np.ndarray:
"""
Converts instance of class to numpy array.
:return: np.ndarray of dimension (self.dim,)
"""
w_np = np.zeros((self.dim,))
w_np[KBNoiseIndices.position_x] = self.position_x
w_np[KBNoiseIndices.position_y] = self.position_y
w_np[KBNoiseIndices.velocity] = self.velocity
w_np[KBNoiseIndices.heading] = self.heading
w_np[KBNoiseIndices.steering_angle] = self.steering_angle
return w_np
3. Setup the trajectory dataclass:
Most of the functionality of the trajectory data class—such as conversion to an array or querying a trajectory point at a discrete time step—is already implemented in the TrajectoryInterface.
For plotting, it is recommended to write a conversion to the CommonRoad Obstacle object (see e.g. KB Trajectory).
4. Setup the SIDT factory
To create instances of point dataclasses from a given array (e.g. when converting the output of the simulation to a state dataclass instance), we require a model-specific implementation of the StateInputDisturbanceTrajectoryFactoryInterface:
class KBSIDTFactory(StateInputDisturbanceTrajectoryFactoryInterface):
"""
Factory for creating kinematic bicycle model State, Input, Disturbance, and Trajectory.
"""
state_dimension: int = KBStateIndices.dim
input_dimension: int = KBInputIndices.dim
disturbance_dimension: int = KBDisturbanceIndices.dim
@classmethod
def state_from_args(
cls,
position_x: float,
position_y: float,
velocity: float,
heading: float,
steering_angle: float,
) -> Union["KBState"]:
"""
Create State from args
:param position_x: position x of center of gravity (Cartesian coordinates)
:param position_y: position y of center of gravity (Cartesian coordinates)
:param velocity: velocity
:param heading: heading
:param steering_angle: steering angle
:return: KBState
"""
return KBState(
position_x=position_x,
position_y=position_y,
velocity=velocity,
heading=heading,
steering_angle=steering_angle,
)
@classmethod
def input_from_args(
cls,
acceleration: float,
steering_angle_velocity,
) -> Union["KBInput"]:
"""
Create Input from args
:param acceleration: longitudinal acceleration
:param steering_angle_velocity: steering angle velocity
:return: KBInput
"""
return KBInput(
acceleration=acceleration, steering_angle_velocity=steering_angle_velocity
)
@staticmethod
def disturbance_from_args(
position_x: float = 0.0,
position_y: float = 0.0,
velocity: float = 0.0,
heading: float = 0.0,
steering_angle: float = 0.0,
) -> Union["KBDisturbance"]:
"""
Create Disturbance from args - the default value of all variables is zero.
:param position_x: position x of center of gravity
:param position_y: position y of center of gravity
:param velocity: velocity
:param heading: heading
:param steering_angle: steering angle
:return: KBDisturbance
"""
return KBDisturbance(
position_x=position_x,
position_y=position_y,
velocity=velocity,
heading=heading,
steering_angle=steering_angle,
)
@classmethod
def state_from_numpy_array(
cls,
x_np: np.ndarray[tuple[float], np.dtype[np.float64]],
) -> Union["KBState"]:
"""
Create State from numpy array
:param x_np: state vector - array of dimension (cls.state_dimension,)
:return: KBState
"""
if x_np.ndim > 1 or x_np.shape[0] != cls.state_dimension:
logger.error(
f"Size of np_array should be ({cls.state_dimension},) but is {x_np.ndim}"
)
raise ValueError(
f"Size of np_array should be ({cls.state_dimension},) but is {x_np.ndim}"
)
return KBState(
position_x=x_np[KBStateIndices.position_x],
position_y=x_np[KBStateIndices.position_y],
velocity=x_np[KBStateIndices.velocity],
heading=x_np[KBStateIndices.heading],
steering_angle=x_np[KBStateIndices.steering_angle],
)
@classmethod
def input_from_numpy_array(
cls, u_np: np.ndarray[tuple[float], np.dtype[np.float64]]
) -> Union["KBInput"]:
"""
Create Input from numpy array
:param u_np: control input - array of dimension (cls.input_dimension,)
:return: KBInput
"""
if u_np.ndim > 1 or u_np.shape[0] != cls.input_dimension:
logger.error(
f"Size of np_array should be ({cls.input_dimension},) but is {u_np.ndim}"
)
raise ValueError(
f"Size of np_array should be ({cls.input_dimension},) but is {u_np.ndim}"
)
return KBInput(
acceleration=u_np[KBInputIndices.acceleration],
steering_angle_velocity=u_np[KBInputIndices.steering_angle_velocity],
)
@classmethod
def disturbance_from_numpy_array(
cls, w_np: np.ndarray[tuple[float], np.dtype[np.float64]]
) -> Union["KBDisturbance"]:
"""
Create Disturbance from numpy array
:param w_np: disturbance - array of dimension (cls.disturbance_dimension,)
:return: KBDisturbance
"""
if w_np.ndim > 1 or w_np.shape[0] != cls.disturbance_dimension:
logger.error(
f"Size of np_array should be ({cls.disturbance_dimension},) but is {w_np.shape}"
)
raise ValueError(
f"Size of np_array should be ({cls.disturbance_dimension},) but is {w_np.shape}"
)
return KBDisturbance(
position_x=w_np[KBDisturbanceIndices.position_x],
position_y=w_np[KBDisturbanceIndices.position_y],
velocity=w_np[KBDisturbanceIndices.velocity],
heading=w_np[KBDisturbanceIndices.heading],
steering_angle=w_np[KBDisturbanceIndices.steering_angle],
)
@classmethod
def trajectory_from_points(
cls,
trajectory_dict: Union[Dict[int, KBState], Dict[int, KBInput]],
mode: TrajectoryMode,
t_0: float,
delta_t: float,
) -> "KBTrajectory":
"""
Create State, Input, or Disturbance Trajectory from list of KB points.
:param trajectory_dict: dict of time steps to kb points
:param mode: type of points (State, Input, or Disturbance)
:param t_0: initial time - float
:param delta_t: sampling time - float
:return: KBTrajectory
"""
return KBTrajectory(points=trajectory_dict, mode=mode, t_0=t_0, delta_t=delta_t)
@classmethod
def trajectory_from_numpy_array(
cls,
traj_np: np.ndarray[tuple[float, float], np.dtype[np.float64]],
mode: TrajectoryMode,
time_steps: List[int],
t_0: float,
delta_t: float,
) -> "KBTrajectory":
"""
Create State, Input, or Disturbance Trajectory from numpy array.
:param traj_np: numpy array storing the values of the point variables
:param mode: type of points (State, Input, or Disturbance)
:param time_steps: time steps of the points in the columns of traj_np
:param t_0: initial time - float
:param delta_t: sampling time - float
:return: KBTrajectory
"""
# convert trajectory points to State/Input/DisturbanceInterface
points_val = []
for kk in range(len(time_steps)):
if mode == TrajectoryMode.State:
points_val.append(cls.state_from_numpy_array(traj_np[:, kk]))
elif mode == TrajectoryMode.Input:
points_val.append(cls.input_from_numpy_array(traj_np[:, kk]))
elif mode == TrajectoryMode.Disturbance:
points_val.append(cls.disturbance_from_numpy_array(traj_np[:, kk]))
return KBTrajectory(
points=dict(zip(time_steps, points_val)),
mode=mode,
delta_t=delta_t,
t_0=t_0,
)
5. Setup the vehicle model
As a last component, the vehicle model itself has to be implemented.
Functionality that is shared among vehicle models, such as the time-discretization for MPC or the linearization of the dynamics, is implemented in the VehicleModelInterface.
All that is left to be implemented are:
class KinematicBicycle(VehicleModelInterface):
"""
Kinematic bicycle model.
Reference point for the vehicle dynamics: center of gravity.
"""
@classmethod
def factory_method(
cls, params: VehicleParameters, delta_t: float
) -> "KinematicBicycle":
"""
Factory method to generate class
:param params: CommonRoad-Control vehicle parameters
:param delta_t: sampling time
:return: instance
"""
return KinematicBicycle(params=params, delta_t=delta_t)
def __init__(self, params: VehicleParameters, delta_t: float):
# set vehicle parameters
self._l_wb = params.l_wb
self._l_r = params.l_r
self._a_long_max = params.a_long_max
self._a_lat_max = params.a_lat_max
# init base class
super().__init__(
params=params,
nx=KBStateIndices.dim,
nu=KBInputIndices.dim,
nw=KBDisturbanceIndices.dim,
delta_t=delta_t,
)
... the extraction of the input bounds from the vehicle parameters.
def _set_input_bounds(self, params: VehicleParameters) -> Tuple[KBInput, KBInput]:
"""
Extract input bounds from vehicle parameters and returns them as instances of the Input class.
:param params: vehicle parameters
:return: lower and upper bound on the inputs - KBInputs
"""
# lower bound
u_lb = KBInput(
acceleration=-params.a_long_max,
steering_angle_velocity=-params.steering_angle_velocity_max,
)
# upper bound
u_ub = KBInput(
acceleration=params.a_long_max,
steering_angle_velocity=params.steering_angle_velocity_max,
)
return u_lb, u_ub
... the actual dynamics function.
To ensure modularity, the dynamics function has to be implemented in the method _dynamics_cas.
The extraction of the single states and control inputs from the respective vectors demonstrates the efficacy of providing the KBStateIndices and KBInputIndices dataclasses.
def _dynamics_cas(
self,
x: Union[cas.SX.sym, np.ndarray[tuple[float], np.dtype[np.float64]]],
u: Union[cas.SX.sym, np.ndarray[tuple[float], np.dtype[np.float64]]],
w: Union[cas.SX.sym, np.ndarray[tuple[float], np.dtype[np.float64]]],
) -> Union[cas.SX.sym, np.array]:
"""
Continuous-time dynamics function of the vehicle model for simulation and symbolic operations using CasADi.
:param x: state - CasADi symbolic/ array of dimension (self._nx,)
:param u: control input - CasADi symbolic/ array of dimension (self._nu,)
:param w: disturbance - CasADi symbolic/ array of dimension (self._nw,)
:return: dynamics function evaluated at (x,u,w) - CasADi symbolic/ array of dimension (self._nx,)
"""
# extract state
v = x[KBStateIndices.velocity]
psi = x[KBStateIndices.heading]
delta = x[KBStateIndices.steering_angle]
# extract control input
a = u[KBInputIndices.acceleration]
delta_dot = u[KBInputIndices.steering_angle_velocity]
# compute slip angle
beta = cas.atan(cas.tan(delta) * self._l_r / self._l_wb)
# dynamics
position_x_dot = v * cas.cos(psi + beta)
position_y_dot = v * cas.sin(psi + beta)
velocity_dot = a
heading_dot = v * cas.sin(beta) / self._l_r
steering_angle_dot = delta_dot
f = cas.vertcat(
position_x_dot,
position_y_dot,
velocity_dot,
heading_dot,
steering_angle_dot,
)
# add disturbances
f = f + np.reshape(w, shape=(w.size, 1))
return f
... the method compute_normalized_acceleration, which computes the normalized longitudinal and lateral acceleration from a given state-control input pair.
def compute_normalized_acceleration(
self,
x: Union[KBState, cas.SX.sym, np.array],
u: Union[KBInput, cas.SX.sym, np.array],
) -> Tuple[Union[float, cas.SX.sym], Union[float, cas.SX.sym]]:
"""
Computes the normalized longitudinal and lateral acceleration (w.r.t. the maximum acceleration).
:param x: state - StateInterface/ CasADi symbolic/ array of dimension (self._nx,)
:param u: control input - InputInterface/ CasADi symbolic/ array of dimension (self._nu,)
:return: normalized longitudinal and lateral acceleration - float/ CasADi symbolic
"""
# extract state
if isinstance(x, KBState):
x = x.convert_to_array()
v = x[KBStateIndices.velocity]
delta = x[KBStateIndices.steering_angle]
# compute slip angle
beta = cas.atan(cas.tan(delta) * self._l_r / self._l_wb)
# compute yaw rate
heading_dot = v * cas.sin(beta) / self._l_r
# extract control input
if isinstance(u, KBInput):
u = u.convert_to_array()
a = u[KBInputIndices.acceleration]
# normalized acceleration
a_long_norm = a / self._a_long_max
a_lat_norm = (v * heading_dot) / self._a_lat_max
return a_long_norm, a_lat_norm