Controller Configuration
How to pick a controller and pass its shared parameters
(ControllerParams) and impl-specific options to an
Agent.
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
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.
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
# 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):
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 |
Single source of truth; survives a round-trip through |
Quick built-in with defaults |
(2) string |
Shortest path; no boilerplate. |
Custom |
(4) |
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 |
(5) |
Avoids re-specifying |
Don’t care, prototyping |
(1) |
Falls back to defaults. |
The five accepted forms of controller
Form |
Example |
What happens |
|---|---|---|
|
|
Fallback path: |
|
|
Equivalent to |
|
|
Recommended for config-driven flows. |
|
|
Recommended for Python flows with custom controllers. Installed as-is via |
|
|
Python-only escape hatch. The instance is used as-is; |
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.
flowchart TD
A[controller arg] --> B{kind?}
B -- Controller instance --> P[install verbatim<br/>controller_params = controller.params]
B -- ControllerParams --> C[self.controller_params = instance<br/>ctrl_type = None]
B -- str / dict / None --> D[parse_config →<br/>ctrl_type, ctrl_cfg]
D --> E[ControllerParams.from_dict(ctrl_cfg)<br/>→ self.controller_params]
P --> J[self.set_controller(...)]
C --> G{ctrl_type set?}
E --> G
G -- no --> H[_make_default_controller()<br/>→ Omni/Diff from motion_mode]
G -- yes --> I[create_controller(ctrl_type, ctrl_cfg)<br/>warn if motion_mode mismatches]
H --> J
I --> J
Step 1 — Parse controller
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_confignormalisesNone/str/dictto(ctrl_type, raw_dict).ControllerParams.from_dictpulls only the keys that match its@dataclassfields 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 byController.from_configusinginspect.signatureon the subclass__init__.
Step 2 — Build the controller and install via set_controller
Agent.__init__ decides in one place:
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:
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 matchControllerParamsfields)**impl_kwargs→ controller subclass__init__(e.g.wheel_separation=0.3forDifferentialController)
set_motion_mode (legacy)
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? |
|---|---|---|
|
|
no |
|
|
no |
|
|
no |
|
|
yes — logged at WARNING |
|
|
no (non-omni/diff) |
Recommendation: set
controller.typeexplicitly and omitmotion_mode(or set it to match) to avoid the warning.
Passing the same controller value at every layer
Each layer just forwards controller= to the next — only
Agent.__init__ parses it.
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:
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 |
|---|---|---|
|
|
|
|
|
|
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
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:
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:
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
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:
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:
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:
sim = MultiRobotSimulationCore.from_yaml("config.yaml")
mgr = sim.get_manager("delivery_fleet")
bc = mgr.batch_controller
Multiple fleets with different batch types:
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:
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:
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.
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:
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)
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):
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()).
# 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
Subclass
Controller(orKinematicController) and set a registry name:class PatrolController(KinematicController): _registry_name = "patrol" def __init__(self, params: ControllerParams, *, waypoints: list): super().__init__(params) self.waypoints = waypoints
Import the module once (e.g. in your sim entrypoint) so the
__init_subclass__hook registers it inCONTROLLER_REGISTRY.Use it from YAML or Python by name:
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.