Source code for irsim.lib.behavior.group_behavior_methods

from math import cos, sin
from typing import Any

import numpy as np

from irsim.lib.algorithm.social_force_model import SocialForceModelBatch
from irsim.lib.behavior.behavior_registry import register_group_behavior_class
from irsim.util.util import relative_position, vel_world2diff, vel_world2omni
from irsim.world.object_base import ObjectBase


[docs] @register_group_behavior_class("omni", "orca") def beh_omni_orca(members: list[ObjectBase], **kwargs: Any): """ Registered initializer returning a class-based handler for ORCA. """ return OrcaGroupBehavior(members, **kwargs)
[docs] @register_group_behavior_class("diff", "orca") def beh_diff_orca(members: list[ObjectBase], **kwargs: Any): """ Registered initializer returning a class-based ORCA handler for differential-drive robots. """ return OrcaGroupBehavior(members, **kwargs)
[docs] class OrcaGroupBehavior: """ Class-based ORCA group behavior with one-time initialization. ORCA plans a collision-free holonomic velocity ``(vx, vy)`` in the world frame for every member. ``omni`` members have it rotated into their body frame; ``diff`` members map it to a ``(linear, angular)`` command via :func:`vel_world2diff`. """ def __init__( self, members: list[ObjectBase], neighborDist: float = 15.0, maxNeighbors: int = 10, timeHorizon: float = 20.0, timeHorizonObst: float = 10.0, safe_radius: float = 0.1, maxSpeed: float | None = None, **kwargs: Any, ) -> None: self._neighborDist = neighborDist self._maxNeighbors = maxNeighbors self._timeHorizon = timeHorizon self._timeHorizonObst = timeHorizonObst self._safe_radius = safe_radius self._maxSpeed = maxSpeed self._kinematics = members[0].kinematics if members else None self._sim = self._build_sim(members, **kwargs) def _ensure_pyrvo(self): try: import pyrvo # type: ignore return pyrvo except ImportError as e: raise ImportError( "pyrvo is not installed. Please install it using `pip install pyrvo`." ) from e def _build_sim(self, members: list[ObjectBase], **kwargs: Any): """ Add members to the simulator. Args: members: the members of the group kwargs: the keyword arguments Returns: pyrvo.RVOSimulator: the simulator """ pyrvo = self._ensure_pyrvo() sim = pyrvo.RVOSimulator() # Get step_time from first member's world_param step_time = members[0]._world_param.step_time if members else 0.1 sim.set_time_step(step_time) for member in members: agent_max_speed = self._agent_speed(member) sim.add_agent( member.state[:2, 0].tolist(), self._neighborDist, self._maxNeighbors, self._timeHorizon, self._timeHorizonObst, float(member.radius + self._safe_radius), agent_max_speed, ) return sim @staticmethod def _realizable_speed(member: ObjectBase) -> float: """Fastest speed ``member`` can hold in every direction. ``max_speed`` of an ``omni`` member is the norm of its component limits, which is reachable only on the diagonal; a plan above the smaller component would be clipped per component on execution and leave ORCA's collision-free direction. ``diff`` members are bounded by their linear limit, which ``max_speed`` already is. """ if member.kinematics in ("omni", "omni_angular"): return float(np.min(np.asarray(member.vel_max, dtype=float)[:2, 0])) return float(member.max_speed) def _agent_speed(self, member: ObjectBase) -> float: """Speed cap of one agent: its realizable speed, lowered to ``maxSpeed``.""" realizable = self._realizable_speed(member) if self._maxSpeed is not None: return min(float(self._maxSpeed), realizable) return realizable def _pref_velocity(self, member: ObjectBase) -> list[float]: """ORCA preferred velocity for one member as world-frame ``[vx, vy]``. The direction is the goal bearing and the magnitude the agent's speed cap (:meth:`_agent_speed`), the same cap the simulator enforces, so members head for their goals at full speed when nothing blocks them and the plan stays within what the member can execute. ``diff`` members take the bearing from the goal directly; ``omni`` members take it from the desired omni velocity, whose magnitude is discarded (it used to be clipped to 1 m/s, which held omni members at that speed). """ speed = self._agent_speed(member) if self._kinematics == "diff": if member.goal is None: return [0.0, 0.0] _, radian = relative_position(member.state, member.goal) return [speed * cos(radian), speed * sin(radian)] vx, vy = member.get_desired_omni_vel().flatten()[:2] norm = float(np.hypot(vx, vy)) if norm < 1e-9: return [0.0, 0.0] return [speed * vx / norm, speed * vy / norm] def _to_action(self, member: ObjectBase, vel_xy: tuple[float, float]) -> np.ndarray: """Convert an ORCA world-frame velocity into a member control input. ``diff`` members get the holonomic velocity mapped to ``(linear, angular)``; every other model converts it through its own kinematics, which for ``omni`` means rotating it into the body frame the model is commanded in. """ if self._kinematics == "diff": return vel_world2diff( member.state[2, 0], [vel_xy[0], vel_xy[1]], w_max=float(member.vel_max[1, 0]), guarantee_time=member._world_param.step_time, ) if self._kinematics == "omni": return vel_world2omni(member.state[2, 0], [vel_xy[0], vel_xy[1]]) return np.c_[list(vel_xy)] def __call__(self, members: list[ObjectBase], **kwargs: Any) -> list[np.ndarray]: """ Generate the velocity for the group. Args: members: the members of the group kwargs: the keyword arguments Returns: list[np.ndarray]: the velocities of the members """ # Keep the kinematics mapping in sync with the call-time members, in # case the group was rebuilt or its members changed since __init__. if members: self._kinematics = members[0].kinematics # If agent count mismatches, rebuild try: if self._sim.get_num_agents() != len(members): self._sim = self._build_sim(members, **kwargs) except Exception: self._sim = self._build_sim(members, **kwargs) for i, member in enumerate(members): self._sim.set_agent_pref_velocity(i, self._pref_velocity(member)) self._sim.set_agent_position(i, member.state[:2, 0].tolist()) self._sim.do_step() return [ self._to_action(members[i], self._sim.get_agent_velocity(i).to_tuple()) for i in range(self._sim.get_num_agents()) ]
[docs] @register_group_behavior_class("omni", "sfm") def beh_omni_sfm_group(members: list[ObjectBase], **kwargs: Any): """ Registered initializer returning a class-based SFM handler for omnidirectional robots. """ return SfmGroupBehavior(members, **kwargs)
[docs] @register_group_behavior_class("diff", "sfm") def beh_diff_sfm_group(members: list[ObjectBase], **kwargs: Any): """ Registered initializer returning a class-based SFM handler for differential-drive robots. """ return SfmGroupBehavior(members, **kwargs)
[docs] class SfmGroupBehavior: """ Class-based Social Force Model group behavior. Same physics and parameter names as the per-object ``sfm`` behavior, evaluated for every member at once by :class:`~irsim.lib.algorithm.social_force_model.SocialForceModelBatch`. All members read one state snapshot, so the crowd does not depend on the order objects are stepped in, and the pairwise social force is NumPy array math instead of a Python loop per member. Objects outside the group still take part: linestring obstacles enter as walls, every other object as a circular neighbour that pushes members but is not moved by them. ``target_roles`` filters those outside objects exactly as it does for the per-object behavior. ``social_groups`` optionally partitions the members into pedestrians walking together (Moussaid et al. 2010): those receive coherence, intra-group repulsion and gaze forces on top of the three base forces. Indices refer to the member order of the YAML group. A member without a goal has zero desired velocity: it stands still but still yields to neighbours. ``omni`` members receive the planned world velocity rotated into their body frame; ``diff`` members get it mapped to ``(linear, angular)`` via :func:`vel_world2diff`. """ def __init__( self, members: list[ObjectBase], vmax: float = 1.5, neighbor_threshold: float = 10.0, relaxation_time: float = 0.5, force_factor_desired: float = 1.0, force_factor_social: float = 2.1, force_factor_obstacle: float = 10.0, sigma_obstacle: float = 0.8, lambda_importance: float = 2.0, gamma: float = 0.35, n_angular: float = 2.0, n_velocity: float = 3.0, safety_radius: float = 0.0, social_groups: list[list[int]] | None = None, force_factor_coherence: float = 2.0, force_factor_group_repulsion: float = 1.0, group_repulsion_threshold: float | None = None, force_factor_gaze: float = 3.0, gaze_vision_angle: float = 90.0, target_roles: str = "all", **kwargs: Any, ) -> None: self._vmax = float(vmax) self._target_roles = target_roles self._kinematics = members[0].kinematics if members else None self._step_time = members[0]._world_param.step_time if members else 0.1 self._sfm = SocialForceModelBatch( states=np.zeros((len(members), 8)), social_groups=social_groups, vmax=self._vmax, step_time=self._step_time, relaxation_time=relaxation_time, force_factor_desired=force_factor_desired, force_factor_social=force_factor_social, force_factor_obstacle=force_factor_obstacle, sigma_obstacle=sigma_obstacle, lambda_importance=lambda_importance, gamma=gamma, n_angular=n_angular, n_velocity=n_velocity, neighbor_range=neighbor_threshold, safety_radius=safety_radius, force_factor_coherence=force_factor_coherence, force_factor_group_repulsion=force_factor_group_repulsion, group_repulsion_threshold=group_repulsion_threshold, force_factor_gaze=force_factor_gaze, gaze_vision_angle=gaze_vision_angle, ) @property def sfm(self) -> SocialForceModelBatch: """The batch solver holding the tuned parameters.""" return self._sfm def _collect_inputs( self, members: list[ObjectBase] ) -> tuple[np.ndarray, list[list[float]], list[list[float]]]: """Snapshot member states and the outside objects they react to. Returns: tuple: ``(states, circles, segments)`` where ``states`` is the ``(N, 8)`` member array with the desired velocity renormalised to ``vmax`` along the goal bearing (``rvo_state`` scales it by the kinematics-specific ``vel_max``), ``circles`` the neighbour states of non-member objects and ``segments`` the line obstacles. """ states = np.array([m.rvo_state for m in members], dtype=float).reshape(-1, 8) v_des = states[:, 5:7] norm = np.hypot(v_des[:, 0], v_des[:, 1]) moving = norm > 1e-9 states[moving, 5:7] = self._vmax * v_des[moving] / norm[moving, None] circles: list[list[float]] = [] segments: list[list[float]] = [] if not members: return states, circles, segments member_ids = {id(m) for m in members} externals = [ obj for obj in members[0].external_objects if id(obj) not in member_ids ] if self._target_roles in ("robot", "obstacle"): externals = [obj for obj in externals if obj.role == self._target_roles] for obj in externals: segs = obj.rvo_line_segments if segs: segments.extend(segs) else: circles.append(obj.rvo_neighbor_state) return states, circles, segments def _to_action(self, member: ObjectBase, vel_xy: np.ndarray) -> np.ndarray: """Convert a world-frame SFM velocity into a member control input.""" vx, vy = float(vel_xy[0]), float(vel_xy[1]) if self._kinematics == "diff": # Track the holonomic velocity tightly so the small sideways # component of the anisotropic forces survives the conversion. _, vmax_pair = member.get_vel_range() return vel_world2diff( member.state[2, 0], [vx, vy], w_max=float(vmax_pair[1, 0]), guarantee_time=self._step_time, tolerance=0.02, ) if self._kinematics == "omni": return vel_world2omni(member.state[2, 0], np.array([[vx], [vy]])) return np.array([[vx], [vy]]) def __call__(self, members: list[ObjectBase], **kwargs: Any) -> list[np.ndarray]: """ Generate the velocity for every member from one shared snapshot. Args: members: the members of the group kwargs: unused; the parameters are fixed at construction Returns: list[np.ndarray]: one control input per member, in member order """ if members: self._kinematics = members[0].kinematics states, circles, segments = self._collect_inputs(members) self._sfm.update(states, circles, segments) vel = self._sfm.cal_vel() return [self._to_action(m, vel[i]) for i, m in enumerate(members)]