from abc import ABC, abstractmethod
from math import atan2, cos, sin
from typing import Any, ClassVar
import numpy as np
from irsim.config import palette_param
from irsim.lib.algorithm.kinematics import (
ackermann_kinematics,
differential_kinematics,
omni_angular_kinematics,
omni_kinematics,
)
from irsim.util.util import (
check_number,
vel_diff2world,
vel_omni2world,
vel_world2omni,
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_kinematics_registry: dict[str, type["KinematicsHandler"]] = {}
# Ground friction of a body decelerates it by ``friction * gravity``; the
# defaults of both, and of restitution and the drive lag, are world
# parameters (``irsim.config.world_param``), set under ``world`` in YAML.
[docs]
def register_kinematics(name: str):
"""Decorator to register a KinematicsHandler subclass.
Any extra key under ``kinematics:`` in YAML is passed to the subclass's
``__init__`` (after ``name``, ``noise``, ``alpha``), so a registered model
can take its own parameters, e.g. ``kinematics: {name: lag, tau: 0.5}``.
Args:
name (str): Name used in YAML configs (e.g. ``"diff"``, ``"omni"``).
Returns:
Callable: Class decorator that registers and returns the class unchanged.
"""
# Normalize registry key to ensure consistency with lookup, which
# lowercases names (see KinematicsFactory usage).
normalized_name = name.lower()
def decorator(cls):
# Prevent accidental overrides when the same (normalized) name is
# registered for multiple different handler classes.
existing = _kinematics_registry.get(normalized_name)
if existing is not None and existing is not cls:
raise ValueError(
f"Kinematics handler '{normalized_name}' is already registered "
f"for class {existing.__name__}"
)
_kinematics_registry[normalized_name] = cls
return cls
return decorator
# ---------------------------------------------------------------------------
# Base class
# ---------------------------------------------------------------------------
def _heading(state: np.ndarray) -> float:
"""Read the heading from a state that may not carry one."""
return state[2, 0] if state.shape[0] > 2 else 0.0
[docs]
class KinematicsHandler(ABC):
"""
Abstract base class for handling robot kinematics.
Subclasses should set the class-attribute metadata described below and
implement :meth:`step`, :meth:`velocity_to_xy`, :meth:`compute_max_speed`,
and :meth:`compute_heading`; :meth:`velocity_from_xy` may be overridden
when a world-frame push maps to the command frame in another way. A
subclass may add keyword parameters to ``__init__``; any extra key under
``kinematics:`` in YAML is passed to it.
"""
# -- Metadata (override in subclasses) --
action_dim: int = 2
min_state_dim: int = 3
state_dim: int = 3
vel_max: ClassVar[list[float]] = [1, 1]
vel_min: ClassVar[list[float]] = [-1, -1]
acce: ClassVar[list[float]] = [float("inf"), float("inf")]
color: str | None = (
None # explicit robot color; None uses palette_param.<color_key>
)
obstacle_color: str | None = (
None # explicit obstacle color; None uses palette_param.obstacle
)
color_key: str = "robot" # palette entry robots with this model default to
description: str | None = None
show_arrow: bool = True
# A passive model does not drive the object: its state only changes when a
# contact pushes it, so the object is static unless its mass is finite.
passive: bool = False
default_mass: float = 1.0 # mass of a non-static object given none
# Row of the velocity vector that is a yaw rate, so a contact that turns
# the object can be folded into its velocity; ``None`` when there is none.
yaw_rate_row: int | None = None
# Rows of the velocity vector that move the body, whose change per step
# the wheels' grip on the ground caps in ``collision_mode: contact``.
translation_rows: tuple[int, ...] = (0,)
# Drive lag set under ``kinematics: {tau: ...}``; ``None`` means the
# world's ``drive_tau``. It acts in ``collision_mode: contact`` only: the
# object's velocity then follows its command as a first-order response
# with this constant, as the PD-driven wheels of a base do.
tau: float | None = None
def __init__(self, name, noise: bool = False, alpha: list | None = None):
"""
Initialize the KinematicsHandler class.
Args:
name (str): Kinematics model name.
noise (bool): Boolean indicating whether to add noise to the velocity (default False).
alpha (list): List of noise parameters for the velocity model (default [0.03, 0, 0, 0.03]).
"""
self.name = name
self.noise = noise
self.alpha = alpha or [0.03, 0, 0, 0.03]
[docs]
@classmethod
def default_color(cls, role: str = "robot", pushable: bool = False) -> str:
"""Default color of an object with this model.
The explicit ``color`` / ``obstacle_color`` class attribute wins when
set; otherwise the value comes from :mod:`irsim.config.palette_param`
(``obstacle`` for obstacles, the model's ``color_key`` for robots), so
a palette changed at runtime applies to objects created afterwards.
Args:
role: ``"robot"`` or ``"obstacle"``.
pushable: Whether contacts can move the object. A driven model
keeps its role color either way; the passive model turns
orange (``palette_param.pushable``).
Returns:
str: A Matplotlib color.
"""
if role == "obstacle":
if cls.obstacle_color is not None:
return cls.obstacle_color
return palette_param.obstacle
if cls.color is not None:
return cls.color
return getattr(palette_param, cls.color_key)
[docs]
@abstractmethod
def step(
self, state: np.ndarray, velocity: np.ndarray, step_time: float
) -> np.ndarray:
"""
Calculate the next state using the kinematics model.
Args:
state (np.ndarray): Current state.
velocity (np.ndarray): Velocity vector.
step_time (float): Time step for simulation.
Returns:
np.ndarray: Next state.
"""
[docs]
def velocity_to_xy(self, state: np.ndarray, velocity: np.ndarray) -> np.ndarray:
"""Convert velocity to [vx, vy] in world frame.
The default implementation follows differential-drive conventions:
``velocity[0]`` is the linear speed projected through the heading
angle ``state[2]``, which is what :func:`~irsim.util.util.vel_diff2world`
does. Subclasses with different velocity semantics (e.g.
omnidirectional) should override this.
Args:
state (np.ndarray): Current state vector.
velocity (np.ndarray): Velocity vector in kinematics frame.
Returns:
np.ndarray: (2, 1) array of [vx, vy].
"""
if len(velocity.shape) == 0:
return np.zeros((2, 1))
return vel_diff2world(state[2, 0], velocity)
[docs]
def velocity_from_xy(
self, state: np.ndarray, velocity_xy: np.ndarray
) -> np.ndarray:
"""Express a world-frame ``[vx, vy]`` motion in this model's command frame.
The inverse of :meth:`velocity_to_xy` as far as the model allows; the
``contact`` collision mode uses it to fold a push into the velocity.
The default keeps only the forward component along the heading
``state[2]``, which is all a differential or car-like model can
express: a sideways push has no command counterpart.
Args:
state (np.ndarray): Current state vector.
velocity_xy (np.ndarray): World-frame velocity ``[vx, vy]`` (2x1).
Returns:
np.ndarray: ``(action_dim, 1)`` velocity in the command frame.
"""
theta = float(state[2, 0]) if state.shape[0] > 2 else 0.0
out = np.zeros((self.action_dim, 1))
out[0, 0] = float(velocity_xy[0, 0]) * cos(theta) + float(
velocity_xy[1, 0]
) * sin(theta)
return out
[docs]
def coast(
self,
velocity: np.ndarray,
step_time: float,
friction: float,
gyration: float = 0.0,
gravity: float = 9.81,
) -> np.ndarray:
"""Velocity the object keeps for one step when nothing commands it.
A driven model stops: without a command or behavior it stays put. A
passive body keeps its momentum and is slowed by ground friction.
Args:
velocity (np.ndarray): Velocity at the end of the previous step.
step_time (float): Time step.
friction (float): The object's friction coefficient.
gyration (float): Radius of gyration, the lever at which a
spinning body's rim slides; ``0`` when it cannot spin.
gravity (float): Gravitational acceleration, the world's.
Returns:
np.ndarray: Velocity to integrate this step, same shape as
``velocity``.
"""
return np.zeros_like(velocity, dtype=float)
[docs]
def compute_max_speed(self, vel_max: np.ndarray) -> float:
"""Compute the scalar maximum speed from the vel_max vector.
The default implementation follows differential-drive conventions:
the first component ``vel_max[0, 0]`` is the translational speed
limit. Subclasses where max speed is derived differently (e.g.
omnidirectional using the L2 norm) should override this.
Args:
vel_max (np.ndarray): Maximum velocity vector.
Returns:
float: Scalar maximum speed.
"""
return float(vel_max[0, 0])
[docs]
def compute_heading(self, state: np.ndarray, velocity: np.ndarray) -> float:
"""Compute the heading angle.
The default implementation follows differential-drive conventions:
heading is ``state[2]`` (the orientation component). Returns 0.0
if the state has fewer than 3 rows.
Args:
state (np.ndarray): Current state vector.
velocity (np.ndarray): Current velocity vector.
Returns:
float: Heading in radians.
"""
return float(state[2, 0]) if state.shape[0] > 2 else 0.0
# ---------------------------------------------------------------------------
# Concrete subclasses
# ---------------------------------------------------------------------------
[docs]
@register_kinematics("omni")
class OmniKinematics(KinematicsHandler):
"""Omnidirectional model with body-frame translational velocity.
Velocity is ``[forward, lateral]`` in the robot body frame. The state is
``[x, y, theta]`` and ``theta`` is preserved by :meth:`step`.
"""
action_dim = 2
translation_rows = (0, 1)
min_state_dim = 3
state_dim = 3
vel_max: ClassVar[list[float]] = [1, 1]
vel_min: ClassVar[list[float]] = [-1, -1]
acce: ClassVar[list[float]] = [float("inf"), float("inf")]
description = None
show_arrow = False
def __init__(self, name, noise, alpha):
super().__init__(name, noise, alpha)
if alpha is None:
self.alpha = [0.03, 0.03]
[docs]
def step(
self, state: np.ndarray, velocity: np.ndarray, step_time: float
) -> np.ndarray:
"""Advance omnidirectional state one step.
Args:
state (np.ndarray): Current state [x, y, theta].
velocity (np.ndarray): Velocity [forward, lateral] in body frame.
step_time (float): Time step.
Returns:
np.ndarray: New state [x, y, theta] (theta preserved).
"""
return omni_kinematics(state[0:3], velocity, step_time, self.noise, self.alpha)
[docs]
def velocity_to_xy(self, state: np.ndarray, velocity: np.ndarray) -> np.ndarray:
"""Convert body-frame translation to world-frame ``[vx, vy]``.
Args:
state (np.ndarray): Current state ``[x, y, theta]``.
velocity (np.ndarray): Body-frame ``[forward, lateral]`` velocity.
Returns:
np.ndarray: ``(2, 1)`` world-frame velocity.
"""
return vel_omni2world(_heading(state), velocity[0:2])
[docs]
def velocity_from_xy(
self, state: np.ndarray, velocity_xy: np.ndarray
) -> np.ndarray:
"""Rotate a world-frame ``[vx, vy]`` into the body frame."""
return vel_world2omni(_heading(state), velocity_xy)
[docs]
def compute_max_speed(self, vel_max: np.ndarray) -> float:
"""Compute translational speed limit from forward/lateral limits.
Args:
vel_max (np.ndarray): Maximum ``[forward, lateral]`` velocity.
Returns:
float: Euclidean norm of the translational velocity bound.
"""
return float(np.linalg.norm(vel_max))
[docs]
def compute_heading(self, state: np.ndarray, velocity: np.ndarray) -> float:
"""Compute travel-direction heading for omnidirectional motion.
Args:
state (np.ndarray): Current state ``[x, y, theta]``.
velocity (np.ndarray): Body-frame ``[forward, lateral]`` velocity.
Returns:
float: World-frame velocity direction in radians.
"""
theta = state[2, 0] if state.shape[0] > 2 else 0.0
return float(atan2(velocity[1, 0], velocity[0, 0])) + theta
[docs]
@register_kinematics("omni_angular")
class OmniAngularKinematics(KinematicsHandler):
"""Omnidirectional kinematics with angular velocity control.
Velocity is ``[forward, lateral, yaw_rate]`` in body frame.
The kinematics function converts to world-frame internally.
Note: ``compute_heading`` is intentionally inherited from the base class
(returns ``state[2]``), because this robot has independent yaw control
and its heading IS the orientation angle, unlike :class:`OmniKinematics`
which derives heading from velocity direction.
"""
action_dim = 3
translation_rows = (0, 1)
yaw_rate_row = 2
min_state_dim = 3
state_dim = 3
vel_max: ClassVar[list[float]] = [1, 1, 1]
vel_min: ClassVar[list[float]] = [-1, -1, -1]
acce: ClassVar[list[float]] = [float("inf"), float("inf"), float("inf")]
description = None
show_arrow = True
def __init__(self, name, noise, alpha):
super().__init__(name, noise, alpha)
if alpha is None:
self.alpha = [0.03, 0.03, 0.03]
[docs]
def step(
self, state: np.ndarray, velocity: np.ndarray, step_time: float
) -> np.ndarray:
"""Advance omnidirectional-angular state one step.
Args:
state (np.ndarray): Current state [x, y, theta].
velocity (np.ndarray): Velocity [forward, lateral, yaw_rate] in body frame.
step_time (float): Time step.
Returns:
np.ndarray: New state [x, y, theta].
"""
return omni_angular_kinematics(
state[0:3], velocity, step_time, self.noise, self.alpha
)
[docs]
def velocity_to_xy(self, state: np.ndarray, velocity: np.ndarray) -> np.ndarray:
"""Convert body-frame translation to world-frame ``[vx, vy]``.
The yaw-rate component is ignored for this projection.
Args:
state (np.ndarray): Current state ``[x, y, theta]``.
velocity (np.ndarray): Body-frame ``[forward, lateral, yaw_rate]``.
Returns:
np.ndarray: ``(2, 1)`` world-frame velocity.
"""
return vel_omni2world(_heading(state), velocity[0:2])
[docs]
def velocity_from_xy(
self, state: np.ndarray, velocity_xy: np.ndarray
) -> np.ndarray:
"""Rotate a world-frame ``[vx, vy]`` into the body frame; a push adds no yaw rate."""
out = np.zeros((3, 1))
out[:2] = vel_world2omni(_heading(state), velocity_xy)
return out
[docs]
def compute_max_speed(self, vel_max: np.ndarray) -> float:
"""Compute translational speed limit from the first two components.
Args:
vel_max (np.ndarray): Maximum ``[forward, lateral, yaw_rate]`` velocity.
Returns:
float: Euclidean norm of forward/lateral velocity bounds.
"""
return float(np.linalg.norm(vel_max[0:2]))
[docs]
@register_kinematics("diff")
class DifferentialKinematics(KinematicsHandler):
"""Differential-drive model with ``[linear, angular]`` velocity."""
action_dim = 2
yaw_rate_row = 1
min_state_dim = 3
state_dim = 3
vel_max: ClassVar[list[float]] = [1, 1]
vel_min: ClassVar[list[float]] = [-1, -1]
acce: ClassVar[list[float]] = [float("inf"), float("inf")]
description = None
show_arrow = True
def __init__(self, name, noise, alpha):
super().__init__(name, noise, alpha)
[docs]
def step(
self, state: np.ndarray, velocity: np.ndarray, step_time: float
) -> np.ndarray:
"""Advance differential-drive state one step.
Args:
state (np.ndarray): Current state [x, y, theta].
velocity (np.ndarray): [linear, angular].
step_time (float): Time step.
Returns:
np.ndarray: Next state.
"""
return differential_kinematics(
state, velocity, step_time, self.noise, self.alpha
)
[docs]
@register_kinematics("acker")
class AckermannKinematics(KinematicsHandler):
"""Ackermann car-like model with steering or angular-rate control.
The state is ``[x, y, theta, steer]``. In ``mode="steer"``, velocity is
interpreted as ``[linear, steer]``; other modes are handled by
:func:`irsim.lib.algorithm.kinematics.ackermann_kinematics`.
"""
action_dim = 2
min_state_dim = 4
state_dim = 4
vel_max: ClassVar[list[float]] = [1, 1]
vel_min: ClassVar[list[float]] = [-1, -1]
acce: ClassVar[list[float]] = [float("inf"), float("inf")]
color_key = "robot_acker"
description = "car_green.png"
show_arrow = True
def __init__(
self,
name,
noise: bool = False,
alpha: list | None = None,
mode: str = "steer",
wheelbase: float = 1.0,
):
super().__init__(name, noise, alpha)
self.mode = mode
self.wheelbase = wheelbase
[docs]
def step(
self, state: np.ndarray, velocity: np.ndarray, step_time: float
) -> np.ndarray:
"""Advance Ackermann-steered state one step.
Args:
state (np.ndarray): Current state [x, y, theta, steer].
velocity (np.ndarray): Depending on mode: [linear, steer] or [linear, angular].
step_time (float): Time step.
Returns:
np.ndarray: Next state.
"""
return ackermann_kinematics(
state,
velocity,
step_time,
self.noise,
self.alpha,
self.mode,
self.wheelbase,
)
[docs]
class PassiveKinematics(KinematicsHandler):
"""Kinematics of an object that does not drive itself.
Every object configured without ``kinematics`` gets this model, and so
does the ``static`` name. Without a finite mass the object is static. In
``collision_mode: contact`` a finite ``mass`` lets contacts push the
body: its velocity is the world-frame ``[vx, vy, yaw_rate]`` a push gave
it, it keeps that momentum once released, and ground friction slows it by
``friction * gravity`` (the world's) per second until it stops, as a box sliding on a
floor does in a physics engine; a spin slows at the same rate taken at
the body's radius of gyration.
"""
action_dim = 3
min_state_dim = 3
state_dim = 3
vel_max: ClassVar[list[float]] = [1, 1, 1]
vel_min: ClassVar[list[float]] = [-1, -1, -1]
acce: ClassVar[list[float]] = [float("inf"), float("inf"), float("inf")]
show_arrow = False
passive = True
default_mass = float("inf")
yaw_rate_row = 2
def __init__(
self, name: str | None = None, noise: bool = False, alpha: list | None = None
):
super().__init__(name, noise, alpha)
[docs]
@classmethod
def default_color(cls, role: str = "robot", pushable: bool = False) -> str:
"""The palette's pushable color when contacts can move the body, else the obstacle color."""
return palette_param.pushable if pushable else palette_param.obstacle
[docs]
def step(
self, state: np.ndarray, velocity: np.ndarray, step_time: float
) -> np.ndarray:
"""Integrate the world-frame velocity and yaw rate the body coasts with."""
new_state = state.astype(float)
rows = min(3, velocity.shape[0], state.shape[0]) if velocity.ndim == 2 else 0
new_state[:rows] += velocity[:rows] * step_time
return new_state
[docs]
def coast(
self,
velocity: np.ndarray,
step_time: float,
friction: float,
gyration: float = 0.0,
gravity: float = 9.81,
) -> np.ndarray:
"""Keep the momentum of the last push, slowed by ground friction."""
out = np.zeros_like(velocity, dtype=float)
if velocity.ndim != 2 or velocity.shape[0] < 2:
return out
brake = friction * gravity * step_time
speed = float(np.hypot(velocity[0, 0], velocity[1, 0]))
if speed > 0.0:
out[:2] = velocity[:2] * (max(0.0, speed - brake) / speed)
if velocity.shape[0] > 2 and gyration > 0.0:
rate = float(velocity[2, 0])
out[2, 0] = np.sign(rate) * max(0.0, abs(rate) - brake / gyration)
return out
[docs]
def velocity_to_xy(self, state: np.ndarray, velocity: np.ndarray) -> np.ndarray:
"""The velocity already is the world-frame ``[vx, vy]`` of the last push."""
out = np.zeros((2, 1))
if velocity.ndim == 2:
rows = min(2, velocity.shape[0])
out[:rows] = velocity[:rows]
return out
[docs]
def velocity_from_xy(
self, state: np.ndarray, velocity_xy: np.ndarray
) -> np.ndarray:
"""A push is stored as the world-frame velocity itself; it adds no spin."""
out = np.zeros((3, 1))
out[:2] = np.asarray(velocity_xy, dtype=float).reshape(2, 1)
return out
[docs]
def compute_max_speed(self, vel_max: np.ndarray) -> float:
"""A passive body has no drive of its own."""
return 0.0
[docs]
class KinematicsFactory:
"""
Factory class to create kinematics handlers.
"""
[docs]
@staticmethod
def create_kinematics(
name: str | None = None,
noise: bool = False,
alpha: list | None = None,
mode: str | None = None,
wheelbase: float | None = None,
role: str = "robot",
tau: float | None = None,
*,
shape_wheelbase: float | None = None,
**kwargs: Any,
) -> KinematicsHandler:
"""Create a kinematics handler from a YAML ``kinematics`` block.
Args:
name: Registered kinematics name: ``diff``, ``omni``, ``omni_angular``,
``acker``, or a custom name registered with
:func:`register_kinematics`. ``None`` defaults to ``diff``;
``static`` gives the :class:`PassiveKinematics` of an object
that never drives itself.
noise: Whether to apply motion noise.
alpha: Noise parameters passed to the handler.
mode: Steering mode of ``acker`` handlers; forwarded when given.
wheelbase: Wheelbase set under ``kinematics``; forwarded when given
and takes precedence over ``shape_wheelbase``.
role: Object role, retained for API compatibility.
tau: Drive lag in seconds for ``collision_mode: contact``; see
:attr:`KinematicsHandler.tau`. ``None`` uses the world's
``drive_tau``.
shape_wheelbase: Wheelbase taken from a car-like shape; the fallback
for ``acker`` handlers (default ``1.0``) when ``wheelbase`` is
not given.
**kwargs: Any other ``kinematics`` key, forwarded to the handler's
``__init__`` (a custom handler's own parameters).
Returns:
KinematicsHandler: Handler instance for ``name``.
Raises:
NotImplementedError: If ``name`` is not registered.
TypeError: If the handler does not accept a forwarded parameter.
"""
if tau is not None:
tau = check_number(tau, "kinematics tau", low=0.0)
if name is None:
handler: KinematicsHandler = DifferentialKinematics("diff", noise, alpha)
elif name.lower() == "static":
handler = PassiveKinematics("static", noise, alpha)
else:
name = name.lower()
handler_cls = _kinematics_registry.get(name)
if handler_cls is None:
raise NotImplementedError(f"Kinematics {name!r} is not registered")
if mode is not None:
kwargs["mode"] = mode
if wheelbase is not None:
kwargs["wheelbase"] = wheelbase
if issubclass(handler_cls, AckermannKinematics):
kwargs.setdefault("wheelbase", shape_wheelbase or 1.0)
handler = handler_cls(name, noise, alpha, **kwargs)
handler.tau = tau
return handler
[docs]
@staticmethod
def get_handler_class(name: str) -> type[KinematicsHandler] | None:
"""Look up a registered handler class by name without instantiation.
Args:
name (str): Kinematics name (e.g. ``"diff"``, ``"omni"``).
Returns:
type[KinematicsHandler] | None: The class, or ``None`` if not found.
"""
return _kinematics_registry.get(name.lower() if name else "")