Agents and dynamics models
An agent is described by one AgentConfig (swarp/dynamics/base.py): its motion model, how its action vector is interpreted, its collision footprint, and its actuation limits.
A world takes a list of them, so a fleet can be heterogeneous — the models are branches of one unified integrate kernel, not separate code paths.
from swarp import AgentConfig, ControlMode, DynamicsModel
configs = [
AgentConfig(model=DynamicsModel.HOLONOMIC, radius=0.05, max_speed=1.0),
AgentConfig(model=DynamicsModel.DIFF_DRIVE, ctrl_mode=ControlMode.ACCELERATION),
AgentConfig(model=DynamicsModel.KINEMATIC_BICYCLE, l_f=0.12, l_r=0.12),
]
The four models
|
state it moves |
action slots |
notes |
|---|---|---|---|
|
|
2 |
a point that can move in any direction; |
|
|
2 |
unicycle: forward speed and yaw rate |
|
|
2 |
slip-angle β formulation with front/rear axle offsets |
|
full 6-DOF: |
4 |
|
All four are differentiable and integrate with either semi-implicit Euler or classical RK4 (WorldConfig.integrator).
Control modes
ControlMode decides how the action vector is read:
model |
|
|
|---|---|---|
|
|
|
|
|
|
|
always |
|
|
always four per-rotor thrusts — the flag is ignored |
Every slot is clamped inside the kernel against that agent’s limits (max_speed, max_accel, max_ang_vel, max_ang_accel, max_steer, thrust_max).
Per-slot action bounds
Those limits are the action space — actions are physical and nothing rescales them:
model / mode |
slot 0 |
slot 1 |
slots 2–3 |
|---|---|---|---|
|
|
|
— |
|
|
|
— |
|
|
|
— |
|
|
|
— |
|
|
|
— |
|
|
|
|
action_bounds(cfg) returns this row for one AgentConfig, and env.action_bounds returns the whole [n_agents, act_dim] box — see Actions for why an RL wrapper should be given it rather than a [-1, 1] default.
The holonomic rows are the box circumscribing the true feasible set: that model clamps the action’s norm (clamp_norm(a, max_speed)), so its reachable region is the inscribed disc. The box edges are still exact along each axis, which is all a per-slot bound can say.
The quadrotor
The drone is a first-class model, not a bolt-on: its integration is the TAG_DRONE branch of the same kernel, and its extra state lives in the same WorldState.
Total thrust acts along body +z, roll and pitch torques come from opposing-rotor thrust differences over the arm, yaw torque from the rotor reaction sum; attitude follows quaternion kinematics and body rates follow Euler’s rigid-body equation with diagonal inertia.
A convenience factory fills the rotor parameters:
from swarp.dynamics.drone import drone_config
cfg = drone_config(mass=1.0, thrust_max=10.0, arm_length=0.15, inertia_zz=0.02)
radius is the horizontal footprint the shared 2D neighbor and contact machinery uses.
Note
The attitude loop is much stiffer than the 2D models’.
With inertia ~1e-2 kg·m² a 10 Hz control step diverges — substep the physics (the viewer demo integrates the quadrotor at 100 Hz behind a dt=0.1 control step with substeps=10).
Configuration fields
AgentConfig validates in __post_init__, so a non-positive mass or a zero wheelbase fails at construction rather than in a kernel.
field |
default |
used by |
|---|---|---|
|
|
all |
|
0.05, 1.0 |
all (contacts, and the drone’s rigid body) |
|
1.0, 1.0 |
all 2D models |
|
π, 2π |
diff-drive |
|
0.1, 0.1, π/4 |
kinematic bicycle |
|
see |
drone |
Heterogeneous fleets
Because act_dim is the max arity over the models present, a world mixing 2D vehicles and drones takes one rectangular [n_envs, n_agents, 4] action tensor and the 2D agents simply ignore slots 2 and 3.
Nothing else in the stack changes: observations come from the same unified state, and contacts use each agent’s own radius.
Per-env parameter randomization
By default the parameter matrix is shared across the batch — [n_agents, NUM_PARAMS], the zero-overhead fast path.
Domain randomization is opt-in: hand the stepper a full [n_envs, n_agents, NUM_PARAMS] tensor and it switches to the kernel variants that index params[e, a].
import numpy as np
from swarp import P_MASS, P_MAX_SPEED, per_env_float_template
floats = per_env_float_template(world.agent_configs, n_envs) # [E, A, NUM_PARAMS]
rng = np.random.default_rng(0)
floats[..., P_MASS] *= rng.uniform(0.8, 1.2, size=floats.shape[:2])
floats[..., P_MAX_SPEED] *= rng.uniform(0.9, 1.1, size=floats.shape[:2])
world.stepper.set_agent_params_per_env(floats)
Columns follow AgentConfig.to_row() order and are addressed by the exported P_* index constants (P_RADIUS, P_MASS, P_MAX_SPEED, P_MAX_ACCEL, P_MAX_ANG_VEL, P_MAX_ANG_ACCEL, P_LF, P_LR, P_MAX_STEER, P_THRUST_MAX, P_ARM, P_IXX, P_IYY, P_IZZ, P_KAPPA, P_GRAVITY).
Parameters are static per episode, so call set_agent_params_per_env again at reset to re-randomize.
Randomizing P_RADIUS upward is checked against neighbor_radius: if the widened contact reach would exceed it, the call raises rather than silently missing contacts.
Model tags and control modes stay per-agent — they are structural and are never randomized.
The recurrence itself is shared between the shared and per-env kernels, so the two cannot drift; the measured hot-path overhead is about 2% in the mid band and within noise elsewhere.