Source code for irsim.msg.messages

"""ROS-style, dependency-free messages for simulation snapshots. Used for exchanging data with ROS and other simulators, and for logging and playback.

The classes in this module are plain dataclasses. They intentionally do not
depend on ROS, which keeps them usable in regular Python, learning, and batch
simulation workflows while providing familiar topic names and message shapes.
"""

from __future__ import annotations

from dataclasses import dataclass, field, fields, is_dataclass
from math import atan, atan2, cos, hypot, isfinite, sin, tan
from typing import Any, ClassVar

import numpy as np

_EPS = float(np.finfo(float).eps)

# For each kinematics model, the body-frame twist component held by each
# velocity row, as an index into ``(linear.x, linear.y, angular.z)``. Ackermann
# steering is an angle rather than a twist component, so its second velocity
# row is converted separately.
_TWIST_LAYOUT: dict[str, tuple[int, ...]] = {
    "diff": (0, 2),
    "omni": (0, 1),
    "omni_angular": (0, 1, 2),
    "acker": (0,),
}


def _copy_array(value: Any) -> np.ndarray | None:
    """Return an independent numpy copy, preserving ``None``."""
    if value is None:
        return None
    return np.array(value, copy=True)


def _float32_array(value: Any) -> np.ndarray:
    """Return an independent ROS ``float32[]``-compatible array."""
    if value is None:
        return np.array([], dtype=np.float32)
    return np.array(value, dtype=np.float32, copy=True)


def _to_builtin(value: Any) -> Any:
    """Recursively convert message data to JSON-compatible Python values."""
    if is_dataclass(value) and not isinstance(value, type):
        return {
            item.name: _to_builtin(getattr(value, item.name)) for item in fields(value)
        }
    if isinstance(value, np.ndarray):
        return value.tolist()
    if isinstance(value, np.generic):
        return value.item()
    if isinstance(value, dict):
        return {key: _to_builtin(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [_to_builtin(item) for item in value]
    return value


[docs] class Message: """Base class that provides JSON-compatible message serialization.""" __slots__ = ()
[docs] def to_dict(self) -> dict[str, Any]: """Return the complete message as JSON-compatible built-in values.""" return _to_builtin(self)
[docs] @dataclass(slots=True) class Point(Message): """ROS ``geometry_msgs/Point``-shaped Cartesian position.""" ros_type: ClassVar[str] = "geometry_msgs/Point" x: float = 0.0 y: float = 0.0 z: float = 0.0
[docs] @dataclass(slots=True) class Vector3(Message): """ROS ``geometry_msgs/Vector3``-shaped Cartesian vector.""" ros_type: ClassVar[str] = "geometry_msgs/Vector3" x: float = 0.0 y: float = 0.0 z: float = 0.0
[docs] @dataclass(slots=True) class Quaternion(Message): """ROS ``geometry_msgs/Quaternion``-shaped orientation.""" ros_type: ClassVar[str] = "geometry_msgs/Quaternion" x: float = 0.0 y: float = 0.0 z: float = 0.0 w: float = 1.0
[docs] @classmethod def from_yaw(cls, yaw: float) -> Quaternion: """Create a planar quaternion from a yaw angle in radians.""" half_yaw = float(yaw) / 2.0 return cls(z=sin(half_yaw), w=cos(half_yaw))
[docs] def to_yaw(self) -> float: """Return the planar yaw angle in radians, from a normalized copy. Raises: ValueError: If the quaternion is zero and defines no rotation. """ norm = hypot(hypot(self.x, self.y), hypot(self.z, self.w)) if norm <= _EPS: raise ValueError("Quaternion orientation must be non-zero to define a yaw.") x, y, z, w = (value / norm for value in (self.x, self.y, self.z, self.w)) return atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z))
[docs] @dataclass(slots=True) class Pose(Message): """ROS ``geometry_msgs/Pose``-shaped position and orientation.""" ros_type: ClassVar[str] = "geometry_msgs/Pose" position: Point = field(default_factory=Point) orientation: Quaternion = field(default_factory=Quaternion)
[docs] @dataclass(slots=True) class PoseWithCovariance(Message): """ROS ``geometry_msgs/PoseWithCovariance``-shaped pose.""" ros_type: ClassVar[str] = "geometry_msgs/PoseWithCovariance" pose: Pose = field(default_factory=Pose) covariance: np.ndarray = field(default_factory=lambda: np.zeros(36))
[docs] @dataclass(slots=True) class Twist(Message): """ROS ``geometry_msgs/Twist``-shaped linear and angular velocity.""" ros_type: ClassVar[str] = "geometry_msgs/Twist" linear: Vector3 = field(default_factory=Vector3) angular: Vector3 = field(default_factory=Vector3)
[docs] @dataclass(slots=True) class TwistWithCovariance(Message): """ROS ``geometry_msgs/TwistWithCovariance``-shaped velocity.""" ros_type: ClassVar[str] = "geometry_msgs/TwistWithCovariance" twist: Twist = field(default_factory=Twist) covariance: np.ndarray = field(default_factory=lambda: np.zeros(36))
[docs] @dataclass(slots=True) class Odometry(Message): """ROS ``nav_msgs/Odometry``-shaped object pose and velocity.""" ros_type: ClassVar[str] = "nav_msgs/Odometry" header: Header = field(default_factory=Header) child_frame_id: str = "" pose: PoseWithCovariance = field(default_factory=PoseWithCovariance) twist: TwistWithCovariance = field(default_factory=TwistWithCovariance)
[docs] @classmethod def from_object( cls, obj: Any, *, stamp: float = 0.0, seq: int = 0, frame_id: str = "world", ) -> Odometry: """Capture an object's planar pose and body-frame velocity.""" state = np.asarray(obj.state).reshape(-1) velocity = np.asarray(obj.velocity).reshape(-1) yaw = float(state[2]) if state.size > 2 else 0.0 kinematics = obj.kinematics # Body-frame (linear.x, linear.y, angular.z); an unknown model is only # trusted for its leading forward-velocity row. twist_values = [0.0, 0.0, 0.0] for row, component in enumerate(_TWIST_LAYOUT.get(kinematics, (0,))): if row < velocity.size: twist_values[component] = float(velocity[row]) if kinematics == "acker" and state.size > 3 and velocity.size: twist_values[2] = float(velocity[0]) * tan(float(state[3])) / obj.wheelbase if kinematics is None and velocity.size >= 2: # a passive body stores the world-frame velocity a push gave it c, s = cos(yaw), sin(yaw) twist_values[0] = c * float(velocity[0]) + s * float(velocity[1]) twist_values[1] = -s * float(velocity[0]) + c * float(velocity[1]) if velocity.size > 2: twist_values[2] = float(velocity[2]) return cls( header=Header(seq=int(seq), stamp=float(stamp), frame_id=frame_id), child_frame_id=f"{obj.name}/base_link", pose=PoseWithCovariance( pose=Pose( position=Point(x=float(state[0]), y=float(state[1])), orientation=Quaternion.from_yaw(yaw), ) ), twist=TwistWithCovariance( twist=Twist( linear=Vector3(x=twist_values[0], y=twist_values[1]), angular=Vector3(z=twist_values[2]), ) ), )
[docs] @classmethod def from_msg(cls, msg: Any) -> Odometry: """Adopt any ROS-compatible ``nav_msgs/Odometry``-shaped message. Only the planar fields IR-SIM uses are read, so native ROS messages and partial stand-ins are both accepted. Header metadata is left out, since its layout is ROS-version specific and belongs to the receiver. Args: msg: An object exposing ``pose.pose`` and ``twist.twist``. Returns: Odometry: An independent, validated IR-SIM odometry message. Raises: TypeError: If ``msg`` does not expose ROS odometry pose and twist. ValueError: If any planar value is not finite. """ try: position = msg.pose.pose.position orientation = msg.pose.pose.orientation twist = msg.twist.twist values = [ float(position.x), float(position.y), float(orientation.x), float(orientation.y), float(orientation.z), float(orientation.w), float(twist.linear.x), float(twist.linear.y), float(twist.angular.z), ] except (AttributeError, TypeError, ValueError) as exc: raise TypeError( "Expected a ROS-compatible nav_msgs/Odometry message, got " f"{type(msg).__name__}." ) from exc if not all(isfinite(value) for value in values): raise ValueError("Odometry values must all be finite.") x, y, qx, qy, qz, qw, linear_x, linear_y, angular_z = values return cls( pose=PoseWithCovariance( pose=Pose( position=Point(x=x, y=y), orientation=Quaternion(x=qx, y=qy, z=qz, w=qw), ) ), twist=TwistWithCovariance( twist=Twist( linear=Vector3(x=linear_x, y=linear_y), angular=Vector3(z=angular_z), ) ), )
[docs] def to_state_velocity(self, obj: Any) -> tuple[np.ndarray, np.ndarray]: """Convert this odometry into state and velocity arrays for ``obj``. The inverse of :meth:`from_object`: the planar pose replaces the first three state values, and the body-frame twist is mapped onto the velocity layout of the object's kinematics. ``obj`` is only read, so a caller can validate several updates before applying any of them. Ackermann steering is carried as a yaw rate, which is zero whenever the car is not moving. A stopped car therefore conveys no steering angle, and ``obj`` keeps the one it already had. Args: obj: The simulation object whose layout the arrays must match. Returns: tuple: The new ``(state, velocity)`` arrays for ``obj``. Raises: ValueError: If ``obj`` has fewer than three state values, an Ackermann object has no steering value, or the orientation defines no rotation. """ state = np.array(obj.state, dtype=float, copy=True) if state.shape[0] < 3: raise ValueError( f"Object '{obj.name}' needs at least three state values " "to receive planar odometry." ) state[0, 0] = self.pose.pose.position.x state[1, 0] = self.pose.pose.position.y state[2, 0] = self.pose.pose.orientation.to_yaw() twist = self.twist.twist velocity = np.zeros(obj.vel_shape, dtype=float) kinematics = obj.kinematics if kinematics == "acker": if state.shape[0] < 4: raise ValueError( f"Object '{obj.name}' needs a steering state value to " "receive Ackermann odometry." ) # A car reports a yaw rate; recover the steering angle it implies. steering = float(state[3, 0]) if abs(twist.linear.x) > _EPS: steering = atan(twist.angular.z * obj.wheelbase / twist.linear.x) state[3, 0] = steering velocity[0, 0] = twist.linear.x velocity[1, 0] = ( steering if getattr(obj.kf, "mode", "steer") == "steer" else twist.angular.z ) elif kinematics is not None: # An unknown model receives as many planar components as it has rows. twist_values = (twist.linear.x, twist.linear.y, twist.angular.z) layout = _TWIST_LAYOUT.get(kinematics, (0, 1, 2)) for row, component in enumerate(layout[: obj.vel_shape[0]]): velocity[row, 0] = twist_values[component] return state, velocity
[docs] @dataclass(slots=True) class LaserScan(Message): """Shared ``sensor_msgs/LaserScan`` fields for a LiDAR snapshot.""" ros_type: ClassVar[str] = "sensor_msgs/LaserScan" header: Header angle_min: float angle_max: float angle_increment: float time_increment: float scan_time: float range_min: float range_max: float ranges: np.ndarray intensities: np.ndarray
[docs] @classmethod def from_sensor( cls, sensor: Any, *, stamp: float = 0.0, seq: int = 0, frame_id: str | None = None, use_inf: bool = False, ) -> LaserScan: """Capture a LiDAR sensor without sharing its mutable arrays. By default ``ranges`` are the sensor's own finite values, ``range_max`` for a miss, so snapshots used for learning are unchanged. With ``use_inf=True`` a beam without a usable return becomes ``+inf`` when nothing came back within ``range_max`` and ``-inf`` when the return was too close to measure, as Gazebo publishes it under REP 117, so ROS consumers read the scan as they would a real driver's. """ scan = sensor.get_scan() ranges = _float32_array(scan["ranges"]) valid = scan.get("valid") if use_inf and valid is not None and np.size(valid) == ranges.size: invalid = ~np.asarray(valid, dtype=bool).reshape(-1) lost = invalid & (ranges >= np.float32(scan["range_max"])) ranges[lost] = np.inf ranges[invalid & ~lost] = -np.inf return cls( header=Header( seq=int(seq), stamp=float(stamp), frame_id=frame_id or str(sensor.sensor_type), ), angle_min=float(scan["angle_min"]), angle_max=float(scan["angle_max"]), angle_increment=float(scan["angle_increment"]), time_increment=float(scan["time_increment"]), scan_time=float(scan["scan_time"]), range_min=float(scan["range_min"]), range_max=float(scan["range_max"]), ranges=ranges, intensities=_float32_array(scan.get("intensities")), )
[docs] @dataclass(slots=True) class ContactState(Message): """One contact of an object in the last step (``collision_mode: contact``). ``normal`` points from the partner toward the object, ``point`` is the world-frame contact point, ``depth`` the overlap that was pushed apart (zero or negative for a resting contact) and ``force`` the contact force in newtons along the normal. """ ros_type: ClassVar[str] = "irsim_msgs/ContactState" other_id: int = -1 other: str = "" point: Point = field(default_factory=Point) normal: Vector3 = field(default_factory=Vector3) depth: float = 0.0 force: float = 0.0
[docs] @classmethod def from_report(cls, report: Any) -> ContactState: """Serialize an object's :class:`~irsim.lib.algorithm.contact.ContactReport`.""" return cls( other_id=int(report.other.id), other=str(report.other.name), point=Point(x=float(report.point[0]), y=float(report.point[1])), normal=Vector3(x=float(report.normal[0]), y=float(report.normal[1])), depth=float(report.depth), force=float(report.force), )
[docs] @dataclass(slots=True) class ObjectState(Message): """Topic-shaped messages and simulator metadata for one object. In ``collision_mode: contact`` the state also carries what a contact sensor reports: the net ``contact_force``, one ``ContactState`` per contact, and how long the object has been touching (``contact_time``) or free (``air_time``). """ header: Header id: int name: str role: str kinematics: str | None shape: str odom: Odometry scan: LaserScan | None goal: np.ndarray | None static: bool arrive: bool collision: bool collision_ids: list[int] = field(default_factory=list) scans: list[LaserScan] = field(default_factory=list) contact_force: Vector3 = field(default_factory=Vector3) contact_time: float = 0.0 air_time: float = 0.0 contacts: list[ContactState] = field(default_factory=list)
[docs] @classmethod def from_object( cls, obj: Any, *, stamp: float = 0.0, seq: int = 0, frame_id: str = "world", use_inf: bool = False, ) -> ObjectState: """Capture an object as conventional ``odom`` and ``scan`` topics. ``use_inf`` is passed to :meth:`LaserScan.from_sensor`. """ lidar_sensors = [ sensor for sensor in obj.sensors if sensor.sensor_type in {"lidar2d", "fmcw_lidar2d"} ] scans = [ LaserScan.from_sensor( sensor, stamp=stamp, seq=seq, frame_id=( f"{obj.name}/laser" if index == 0 else f"{obj.name}/laser_{index}" ), use_inf=use_inf, ) for index, sensor in enumerate(lidar_sensors) ] return cls( header=Header(seq=int(seq), stamp=float(stamp), frame_id=frame_id), id=int(obj.id), name=str(obj.name), role=str(obj.role), kinematics=obj.kinematics, shape=str(obj.shape), odom=Odometry.from_object( obj, stamp=stamp, seq=seq, frame_id=frame_id, ), scan=scans[0] if scans else None, goal=_copy_array(obj.goal), static=bool(obj.static), arrive=bool(obj.arrive), collision=bool(obj.collision), collision_ids=[int(other.id) for other in obj.collision_obj], scans=scans, contact_force=Vector3( x=float(obj.contact.force[0, 0]), y=float(obj.contact.force[1, 0]) ), contact_time=float(obj.contact.contact_time), air_time=float(obj.contact.air_time), contacts=[ContactState.from_report(r) for r in obj.contact.reports], )
@property def sensors(self) -> list[LaserScan]: """Return all LiDAR messages as a compatibility alias for ``scans``.""" return list(self.scans)
[docs] @dataclass(slots=True) class WorldState(Message): """Complete point-in-time state of an IR-SIM environment.""" header: Header name: str status: str step_time: float objects: list[ObjectState] = field(default_factory=list)
[docs] @classmethod def from_env( cls, env: Any, frame_id: str = "world", use_inf: bool = False ) -> WorldState: """Capture the current state and sensor data from an environment. ``use_inf`` is passed to every :meth:`LaserScan.from_sensor`. """ seq = int(env.world_param.count) stamp = float(env.time) return cls( header=Header(seq=seq, stamp=stamp, frame_id=frame_id), name=str(env._world.name), status=str(env.status), step_time=float(env.step_time), objects=[ ObjectState.from_object( obj, stamp=stamp, seq=seq, frame_id=frame_id, use_inf=use_inf, ) for obj in env.objects ], )
@property def robots(self) -> list[ObjectState]: """Return the robot messages in this snapshot.""" return [obj for obj in self.objects if obj.role == "robot"] @property def obstacles(self) -> list[ObjectState]: """Return the obstacle messages in this snapshot.""" return [obj for obj in self.objects if obj.role == "obstacle"]