# Controller Configuration
How to pick a controller and pass its shared parameters
(`ControllerParams`) and impl-specific options to an
[`Agent`](../api/index).
The same `controller` argument is accepted at every layer of the API
(YAML → `AgentSpawnParams` → `Agent.from_params` / `from_urdf` /
`from_mesh` → `Agent.__init__`). All parsing happens in **one place**
(`Agent.__init__`); the higher layers just forward the value verbatim.
## TL;DR — Five ways to specify a controller
```python
from pybullet_fleet.controller import DifferentialController
from pybullet_fleet.controller_params import ControllerParams
# 1) Implicit — framework default (OmniController) with default params.
# motion_mode is a legacy hint; prefer the explicit forms below.
Agent.from_urdf("robot.urdf")
```
For **batch controllers** (vectorised, shared across many agents) use
`AgentManager` with `batch_controller=`. See § *Batch Controllers* below for the
full API.
```python
from pybullet_fleet.agent_manager import AgentManager
mgr = AgentManager(sim_core=sim, batch_controller="batch_differential")
agents = mgr.spawn_agents_grid(100, grid_params, spawn_params)
bc = mgr.batch_controller # BatchDifferentialController
```
```python
# 2) String shortcut — registry name (recommended for built-ins)
Agent.from_urdf("robot.urdf", controller="differential")
# 3) Full config dict — ★ recommended for YAML / config-driven workflows
Agent.from_urdf("robot.urdf", controller={
"type": "differential",
"max_linear_vel": 1.5, # → ControllerParams.max_linear_vel
"max_angular_vel": 2.0, # → ControllerParams.max_angular_vel
"navigation_2d": True, # → ControllerParams.navigation_2d (behaviour flag)
"wheel_separation": 0.3, # → DifferentialController.__init__ (impl extra)
})
# 4) Controller instance — ★ recommended for Python workflows that need
# custom controllers, complex construction, or type-safe injection.
# Installed verbatim; motion_mode is ignored for selection.
Agent.from_urdf(
"robot.urdf",
controller=DifferentialController(
params=ControllerParams(max_linear_vel=1.5, max_angular_vel=2.0),
wheel_separation=0.3,
),
)
# 5) ControllerParams instance — params-only override; motion_mode still
# picks the Omni/Diff class. Use only when keeping the default class.
Agent.from_urdf("robot.urdf", controller=ControllerParams(max_linear_vel=2.0))
```
YAML equivalent (consumed by `AgentSpawnParams.from_dict`):
```yaml
agent:
urdf_path: robot.urdf
controller: # single source of truth
type: differential
max_linear_vel: 1.5
max_angular_vel: 2.0
wheel_separation: 0.3
```
```{note}
**YAML can express forms 1–3 and 5 only.** Form 4 (a pre-built
`Controller` instance) is Python-only — it is the escape hatch for
custom controller subclasses or constructions that don't fit cleanly in
a config dict.
```
```{note}
`motion_mode` (`omnidirectional` / `differential`) is still accepted at
both the Python and YAML layers but is **deprecated** as a controller
selector — use `controller=` instead. See § *motion_mode vs
controller.type* below for the interaction rules and the one corner where
`motion_mode` is still meaningful (a coarse "2D mobile base shape" hint
for batched controllers).
```
## Recommended pattern — pick by use case
| Use case | Recommended form | Why |
|----------|------------------|-----|
| YAML / config-file driven simulation | **(3) dict with `type`** | Single source of truth; survives a round-trip through `AgentSpawnParams.from_dict`. |
| Quick built-in with defaults | **(2) string** | Shortest path; no boilerplate. |
| Custom `Controller` subclass, or anything needing non-trivial construction | **(4) `Controller` instance** | Build it however you like in Python and inject the exact instance — no dict serialization required. |
| Tweak shared params only, keep default Omni/Diff class chosen by `motion_mode` | **(5) `ControllerParams` instance** | Avoids re-specifying `type`. |
| Don't care, prototyping | **(1) `None`** | Falls back to defaults. |
## The five accepted forms of `controller`
| Form | Example | What happens |
|------|---------|--------------|
| `None` | `controller=None` (default) | Fallback path: `motion_mode` picks `OmniController` / `DifferentialController` with framework-default `ControllerParams`. |
| `str` | `controller="patrol"` | Equivalent to `{"type": "patrol"}` — registry lookup, no `ControllerParams` overrides. |
| `dict` | `controller={"type": "...", "max_linear_vel": 1.5, ...}` | **Recommended for config-driven flows.** `type` selects the controller class; the remaining keys are split between `ControllerParams` (see § *ControllerParams* below) and the controller's `__init__` (impl extras). |
| `Controller` instance | `controller=DifferentialController(params=..., wheel_separation=0.3)` | **Recommended for Python flows with custom controllers.** Installed as-is via `set_controller`; `controller_params` is taken from `controller.params`. `motion_mode` is ignored for selection. |
| `ControllerParams` instance | `controller=ControllerParams(max_linear_vel=2.0)` | Python-only escape hatch. The instance is used as-is; `motion_mode` still picks the controller class (no `type` override). |
## Two-step build inside `Agent.__init__`
The controller is created **exactly once** during `__init__` (via
`self.set_controller(...)`). No intermediate Omni/Diff instance is
built and discarded when an explicit `type` is given.
```{mermaid}
flowchart TD
A[controller arg] --> B{kind?}
B -- Controller instance --> P[install verbatim
controller_params = controller.params]
B -- ControllerParams --> C[self.controller_params = instance
ctrl_type = None]
B -- str / dict / None --> D[parse_config →
ctrl_type, ctrl_cfg]
D --> E[ControllerParams.from_dict(ctrl_cfg)
→ self.controller_params]
P --> J[self.set_controller(...)]
C --> G{ctrl_type set?}
E --> G
G -- no --> H[_make_default_controller()
→ Omni/Diff from motion_mode]
G -- yes --> I[create_controller(ctrl_type, ctrl_cfg)
warn if motion_mode mismatches]
H --> J
I --> J
```
### Step 1 — Parse `controller`
```python
if isinstance(controller, Controller):
_preset_controller = controller
self.controller_params = getattr(controller, "params", None) or ControllerParams()
ctrl_type, ctrl_cfg = None, {}
elif isinstance(controller, ControllerParams):
self.controller_params = controller
ctrl_type, ctrl_cfg = None, {}
else:
ctrl_type, ctrl_cfg = ControllerParams.parse_config(controller)
self.controller_params = ControllerParams.from_dict(ctrl_cfg)
```
* `ControllerParams.parse_config` normalises `None` / `str` / `dict`
to `(ctrl_type, raw_dict)`.
* `ControllerParams.from_dict` pulls only the keys that match its
`@dataclass` fields and **silently drops unknown keys** so that
impl extras (e.g. `wheel_separation`) can coexist in one dict.
* The full `ctrl_cfg` (including the dropped keys) is kept around for
step 3; impl extras will be routed by `Controller.from_config`
using `inspect.signature` on the subclass `__init__`.
### Step 2 — Build the controller and install via `set_controller`
`Agent.__init__` decides in one place:
```python
if _preset_controller is not None:
# form (4): a Controller instance was passed — install it as-is.
self.set_controller(_preset_controller)
elif ctrl_type:
# form (2) / (3): explicit registry lookup; warn on motion_mode mismatch.
self.set_controller(create_controller(ctrl_type, ctrl_cfg))
else:
# form (1) / (5): fall back to motion_mode → Omni/Diff with self.controller_params.
self.set_controller(self._make_default_controller())
```
`set_controller` is the **single** entry point that installs a
controller on the agent. Use it at runtime to swap controllers:
```python
from pybullet_fleet.controller import create_controller
agent.set_controller(create_controller("patrol", {"max_linear_vel": 1.5}))
```
`create_controller(name, config)` → `Controller.from_config(config)`
routes the dict into:
* `params=` → `ControllerParams.from_dict(config)` (keys that match
`ControllerParams` fields)
* `**impl_kwargs` → controller subclass `__init__` (e.g.
`wheel_separation=0.3` for `DifferentialController`)
### `set_motion_mode` (legacy)
```python
agent.set_motion_mode(MotionMode.DIFFERENTIAL)
```
Replaces the active controller with the matching default Omni / Diff
(via the same `_make_default_controller` helper). **Deprecated** for
new code — prefer `agent.set_controller(create_controller(...))`.
## `motion_mode` vs `controller.type`
`motion_mode` is **deprecated as a controller selector** — prefer
`controller="omni"` / `controller="differential"` or a full
`controller={"type": ..., ...}` dict. It is still accepted (and kept
in sync with the chosen controller) because batched controllers and a
few utilities read it as a coarse "2D mobile base shape" hint.
When both are given, the rule is:
| Given | Selected controller | Warning? |
|-------|--------------------|----------|
| `motion_mode=OMNI`, `controller=None` | `OmniController` | no |
| `motion_mode=DIFF`, `controller=None` | `DifferentialController` | no |
| `motion_mode=OMNI`, `controller={"type": "omni", ...}` | `OmniController` (built with cfg) | no |
| `motion_mode=OMNI`, `controller={"type": "differential", ...}` | `DifferentialController` (override) | **yes** — logged at WARNING |
| `motion_mode=OMNI`, `controller={"type": "patrol", ...}` | `PatrolController` (override) | no (non-omni/diff) |
> **Recommendation:** set `controller.type` explicitly and **omit**
> `motion_mode` (or set it to match) to avoid the warning.
## `ControllerParams` — shared parameters for every controller
`ControllerParams` is the dataclass of parameters that every
`KinematicController` subclass shares. It bundles both **kinematic
limits** (velocity / acceleration caps) and **behaviour flags**
(2D navigation, default movement direction, command-velocity watchdog).
Impl-specific knobs (e.g. `wheel_separation` for differential drive)
live on the controller subclass itself — not here.
`Agent.controller_params` holds the agent's instance. The legacy
`Agent.max_linear_vel` / `max_angular_vel` / `max_linear_accel` /
`max_angular_accel` are read-only properties that delegate to it.
```python
agent.controller_params.max_linear_vel = [2.0, 1.0, 0.0] # write here
agent.max_linear_vel # → np.array([2.0, 1.0, 0.0]) (read-only)
```
Fields:
| Field | Type | Notes |
|-------|------|-------|
| `max_linear_vel` (m/s) | scalar or `[vx, vy, vz]` | Scalar = Euclidean magnitude clamp. 3-element = per-axis cap in body frame. |
| `max_angular_vel` (rad/s) | scalar or `[wx, wy, wz]` | ROS/PyBullet order; differential drive reads `wz` (idx 2). |
| `max_linear_accel` (m/s²) | scalar or per-axis | Used by TPI. |
| `max_angular_accel` (rad/s²) | scalar or per-axis | Used by TPI. |
| `cmd_vel_timeout` (s) | float | Velocity-command watchdog; `0.0` disables. |
| `navigation_2d` | bool | Preserve current `z` during pose-mode trajectories. |
| `default_direction` | `MovementDirection` | Default direction for `set_path` when none is given; differential-drive only. `from_dict` accepts the string form. |
The batched controllers (`BatchOmniController`,
`BatchDifferentialController`) read the same instance when an agent is
registered (via `AgentManager.add_object`) and pack the fields into
`(N,)` arrays — no duplication between per-agent and batch paths.
## Passing the same `controller` value at every layer
Each layer just forwards `controller=` to the next — only
`Agent.__init__` parses it.
```text
YAML (controller: {...})
└── AgentSpawnParams.from_dict ← stores raw value in spawn_params.controller
└── Agent.from_params ← extra_kwargs={"controller": spawn_params.controller}
└── Agent.from_urdf / from_mesh ← controller=controller (factory kwarg)
└── Agent.__init__ ← ★ parses & applies
```
Direct Python calls skip the upper layers but use the same kwarg:
```python
agent = Agent.from_urdf("robot.urdf", controller={"type": "omni", "max_linear_vel": 2.5})
agent = Agent.from_mesh(visual_shape=..., controller="differential")
agent = Agent.from_urdf("robot.urdf", controller=ControllerParams(max_linear_vel=3.0))
agent = Agent.from_urdf(
"robot.urdf",
controller=DifferentialController(params=ControllerParams(max_linear_vel=1.5), wheel_separation=0.3),
)
```
## Batch Controllers
Batch controllers replace per-agent Python dispatch with a single vectorised
`batch_advance()` call that drives **all registered agents at once** using
NumPy arrays. The public API (`agent.set_path()`, `MoveAction`, `agent.stop()`)
is identical to the per-agent path — the controller switch is transparent to
application code.
### When to use
| Scenario | Recommendation |
|---|---|
| < 50 agents | per-agent is fine; overhead is negligible |
| 50–500 agents | batch gives a measurable controller-loop speedup (~1.3–4×) |
| 500+ agents | batch is strongly recommended |
The speedup is concentrated in the controller-loop phase (`agent_update` /
`phase1_update` in profiling output). Collision detection, pose flush, and
AABB refresh are unaffected.
### Available batch types
| Registry name | Per-agent equivalent | Motion mode |
|---|---|---|
| `"batch_omni"` | `omni` | `OMNIDIRECTIONAL` |
| `"batch_differential"` | `differential` | `DIFFERENTIAL` |
### Python API
The primary way to use batch controllers is through `AgentManager`. Pass
`batch_controller=` at construction time, or call `enable_batch()` after the fact.
#### Basic usage
```python
from pybullet_fleet.agent_manager import AgentManager
mgr = AgentManager(sim_core=sim, batch_controller="batch_differential")
agents = mgr.spawn_agents_grid(100, grid_params, spawn_params)
bc = mgr.batch_controller # BatchDifferentialController instance
for a in agents:
bc.set_path(a, [goal_pose])
```
#### Adding agents manually
When you spawn agents outside `AgentManager` (e.g. via `Agent.from_params`
directly), register them with `mgr.add_object()`. The batch controller is
applied automatically:
```python
mgr = AgentManager(sim_core=sim, batch_controller="batch_differential")
agents = _spawn_grid(sim, n, MotionMode.DIFFERENTIAL) # your own helper
for a in agents:
mgr.add_object(a)
mgr.batch_controller.set_path(a, wp_fn(a.get_pose()))
```
#### Enable batch after spawning
You can spawn agents first and enable the batch controller later. All
existing agents in the manager are registered at that point:
```python
mgr = AgentManager(sim_core=sim)
agents = mgr.spawn_agents_grid(...)
mgr.enable_batch("batch_omni") # existing agents are auto-registered
bc = mgr.batch_controller
for a in agents:
bc.set_path(a, goal)
```
#### Disable batch
```python
mgr.disable_batch() # agents revert to their per-agent controllers
```
### Config API
The YAML config loader also supports batch controllers through entity
declarations. Two styles are available.
#### Option B — named manager
Declare a named manager in `managers:` with `batch_controller:`, then reference
it by name from each entity group:
```yaml
managers:
- name: delivery_fleet
batch_controller: batch_omni
controller: # ControllerParams defaults — per-agent kinematics
max_linear_vel: 1.5
max_linear_accel: 2.0
navigation_2d: true
entities:
- urdf_path: robots/simple_cube.urdf
motion_mode: omnidirectional
manager: delivery_fleet
grid:
count: 50
spacing: [2, 2]
```
`controller:` defaults are applied at spawn time to any agent that has no explicit
`controller:` block. Individual entities can still override:
```yaml
managers:
- name: mixed_fleet
batch_controller: batch_differential
controller:
max_linear_vel: 1.0 # default for all entities below
entities:
- urdf_path: robots/husky.urdf
manager: mixed_fleet
# inherits max_linear_vel: 1.0 from fleet defaults
- urdf_path: robots/fast_husky.urdf
manager: mixed_fleet
controller:
max_linear_vel: 3.0 # overrides fleet default for this entity only
```
Retrieve the manager after loading:
```python
sim = MultiRobotSimulationCore.from_yaml("config.yaml")
mgr = sim.get_manager("delivery_fleet")
bc = mgr.batch_controller
```
Multiple fleets with different batch types:
```yaml
managers:
- name: omni_fleet
batch_controller: batch_omni
- name: diff_fleet
batch_controller: batch_differential
entities:
- urdf_path: robots/simple_cube.urdf
motion_mode: omnidirectional
manager: omni_fleet
grid:
count: 50
spacing: [2, 2]
- urdf_path: robots/husky.urdf
motion_mode: differential
manager: diff_fleet
grid:
count: 30
spacing: [3, 3]
```
#### Option A — shorthand (anonymous manager)
Set `batch_controller:` directly on an entity group. An anonymous
`AgentManager` is created automatically:
```yaml
entities:
- urdf_path: robots/simple_cube.urdf
motion_mode: differential
batch_controller: batch_differential
grid:
count: 100
spacing: [2, 2]
```
Use `sim.get_manager()` (no argument, or by index) to retrieve the
auto-created manager.
### Fleet-wide kinematic defaults
Use the `controller:` key on a named manager (or `fleet_controller=` in Python) to set
kinematic defaults for all agents in the manager:
```python
from pybullet_fleet.agent_manager import AgentManager
mgr = AgentManager(
sim_core=sim,
batch_controller="batch_differential",
fleet_controller={"max_linear_vel": 1.0, "navigation_2d": True},
)
# Agents spawned without an explicit controller: block inherit these defaults.
```
Fleet defaults are applied at spawn time. If an agent already has non-default
`ControllerParams` (i.e. an explicit `controller:` block was provided), the fleet
defaults are **not** applied — per-agent config always takes precedence.
#### `navigation_2d`
`ControllerParams.navigation_2d` is `None` by default (not explicitly set), which both
per-agent and batch controllers treat as `False`:
| Setting | Behaviour |
|---|---|
| `None` (default) | follows goal Z (3D navigation) |
| `True` | preserves current Z (2D / flat-floor navigation) |
| `False` | follows goal Z |
Set it fleet-wide via `controller:` on the manager, or per-entity via the entity's own
`controller:` block.
### Kinematic params — per-agent, not per-controller
The shared batch controller has **no velocity params of its own**. It reads
`agent.controller_params` at `set_path()` time, so each agent can have
different speed / acceleration limits:
```python
fast_params = AgentSpawnParams(
controller={"max_linear_vel": 5.0, "max_linear_accel": 4.0, ...},
motion_mode=MotionMode.DIFFERENTIAL,
)
slow_params = AgentSpawnParams(
controller={"max_linear_vel": 1.0, "max_linear_accel": 1.0, ...},
motion_mode=MotionMode.DIFFERENTIAL,
)
# Both share one BatchDifferentialController; TPI params differ per agent.
mgr = AgentManager(sim_core=sim, batch_controller="batch_differential")
fast_agents = mgr.spawn_agents_grid(50, grid_a, fast_params)
slow_agents = mgr.spawn_agents_grid(50, grid_b, slow_params)
```
### Features — full parity with per-agent
#### Direction (differential only)
```python
agent.set_path(path, direction=MovementDirection.FORWARD) # default
agent.set_path(path, direction=MovementDirection.BACKWARD) # robot moves backward
agent.set_path(path, direction=MovementDirection.AUTO) # auto-select per waypoint
# (yaw-delta > 90° → BACKWARD)
```
#### Final-orientation alignment
After the last waypoint is reached, an in-place rotation aligns the robot
to `path[-1].orientation` (default `True` for both omni and diff):
```python
agent.set_path(path, final_orientation_align=True) # default — rotate at end
agent.set_path(path, final_orientation_align=False) # arrive and stop, no rotation
```
#### Action system
`MoveAction`, `PickAction`, and other actions in the action queue work
identically in batch and per-agent modes. `agent.update()` continues to
run the action queue; only `controller.compute()` is skipped (the batch
controller handles movement via `batch_advance()`).
```python
# Works transparently regardless of batch or per-agent
agent.add_action(MoveAction(path=Path(waypoints=[...]), direction=MovementDirection.BACKWARD))
agent.add_action(PickAction(target=pallet))
agent.add_action(MoveAction(path=Path(waypoints=[...])))
agent.add_action(DropAction(target=drop_zone))
```
### Performance reference
Measured on 100 differential agents running a cube-patrol path (collision
detection enabled, `physics=False`):
```
batch total=1.68 ms/step agent_update=0.59 ms
per_agent total=1.81 ms/step agent_update=0.70 ms
Speedup total: 1.09×
Speedup controller loop: 1.26×
```
The controller-loop speedup increases with agent count: ~3–5× at 500 agents
with collision disabled (see `examples/scale/batch_controller_500_demo.py`).
Run `python3 examples/scale/100robots_cube_patrol_demo.py --benchmark` for a
live batch vs per-agent comparison on your hardware.
---
## Adding a custom controller
1. Subclass [`Controller`](../api/index) (or `KinematicController`) and set
a registry name:
```python
class PatrolController(KinematicController):
_registry_name = "patrol"
def __init__(self, params: ControllerParams, *, waypoints: list):
super().__init__(params)
self.waypoints = waypoints
```
2. Import the module once (e.g. in your sim entrypoint) so the
`__init_subclass__` hook registers it in `CONTROLLER_REGISTRY`.
3. Use it from YAML or Python by name:
```yaml
controller:
type: patrol
max_linear_vel: 1.0 # → ControllerParams (kinematic limit)
waypoints: [...] # → PatrolController.__init__ (impl extra)
```
`Controller.from_config` inspects the subclass signature and routes
unknown-to-`ControllerParams` keys (e.g. `waypoints`) to the subclass
`__init__` automatically.