Add an Algorithm¶
The framework owns the pipeline — observation extraction, reward shaping, route legality masks, episode lifecycle, checkpointing, metrics. An algorithm owns only the decision. This page shows how to plug one in.
You never edit a file under opencda_marl/ to add an algorithm. If you find
yourself doing so, that is a bug in the seam — please report it.
Two integration levels¶
| Level | Who owns the training loop | Use when |
|---|---|---|
Environment port — OpenCDAMARLParallelEnv |
You | You want to drive the benchmark with any RL code (SB3, RLlib, CleanRL, your own loop) against a standard obs-in / speed-out interface |
Policy port — Policy + register_algorithm |
The framework | You want the framework's training infrastructure: matrix launcher, checkpoints, the metrics store and monitor, route-intent heads |
The built-in PCMA algorithms are level-2 citizens; both levels see identical observations, rewards, and execution, which is what keeps results comparable.
Level 1: the ParallelEnv port¶
A PettingZoo-style parallel environment: observations out, target speeds in,
framework-computed rewards back — with the per-component breakdown, objective
vectors, and preference vectors in infos so you can re-shape rewards in
your own loop without forking the pipeline.
The engine installs as a library — the wheel bundles the sumo-marl
config family, the lite traffic replay and the intersection SUMO network,
so this runs without a repository checkout; torch is installed as a
dependency but this path never imports it:
pixi run wheel --public # builds dist/public/opencda_marl-*.whl, without the UCLA-licensed paths
pip install dist/public/opencda_marl-*.whl # SUMO runs out of the box
The full build, which also carries the vendored OpenCDA core, installs from Git
(pip install git+https://github.com/radar-lab/OpenCDA-MARL.git@v2.2.0); it is never uploaded to
a package index, because UCLA's licence does not allow the core to be transferred to others.
from opencda_marl.envs.parallel_env import OpenCDAMARLParallelEnv
env = OpenCDAMARLParallelEnv.from_scenario(
"sumo-marl/intersection/route_speed_only_pcmappo",
overrides={"MARL": {"external": {"features": {
"rel_x": 1, "rel_y": 1, "heading_angle": 1, "dist_to_intersection": 1}}}})
observations, infos = env.reset()
while not env.episode_over: # env.agents is empty before the first spawn
actions = {agent: my_policy(observations[agent]) for agent in env.agents}
observations, rewards, terminations, truncations, infos = env.step(actions)
env.close()
Contract notes:
- Actions are target speeds in km/h (the planner and controller stay
fixed — that is the matched protocol that makes comparisons fair). An
agent you stop sending actions for is handed back to SUMO's own
car-following model (under the scenario's
world.sumo_rl_runtime_mode); an agent you have not commanded yet keeps the departure speed it spawned with. To control every agent, send an action for every agent inenv.agents. - Loop on
env.episode_over, not onenv.agents: vehicles spawn over time, so the live set is empty at the start of a replay and between waves of traffic. - Agents join and leave mid-episode as vehicles spawn and terminate; a
terminating agent appears one final time in the step returns
(
infos[agent]["event"]issuccess/collision/removed). - The scenario declares the observation vector (
MARL.external.features, in the profile or throughoverridesas above) — the benchmark defines the observation space, not the algorithm. The observation width follows those features (plus the preference entries when the scenario conditions on preferences), not the profile'sMARL.state_dim, which belongs to the profile's own learner. The reference resolves exactly as on the command line (aconfigs/root under the working directory,OPENCDA_MARL_CONFIG_ROOTS, then the bundled configs), and a profile'sinclude:list is composed. - Path intent stays framework-side for this port (the port's actions are
speeds):
MARL.action_space.path.selectorpicks rule-based / random-legal / keep-current / a learned head, andMARL.action_space.path.providernames a registered path action of your own (Architecture has the seam map). Path actions are not exposed over the external port yet. pettingzoo/gymnasiumare soft dependencies (installed in the pixi environment; the wheel's[ports]extra adds them): without them the same API works by duck typing andobservation_space()raises an actionable error; with them the class is a genuinepettingzoo.ParallelEnvwith real Gymnasium spaces.from_scenariosupports the SUMO backend today; CARLA lands with the parallel-training work.
Level 2: the Policy port¶
The rest of this page covers level 2.
The contract¶
Two protocols in opencda_marl/api/policy.py:
Policy— the speed controller. Required.IntentPolicy— optional one-shot route-intent capability, needed only foraction_space.mode: path_speedwith a learned route selector.
A Policy implements seven methods and exposes two attributes:
| Member | Meaning |
|---|---|
select_action(multi_agent_obs, ego_agent_id, training) |
Target speed in km/h for one agent, or None (warmup) to leave the vehicle to the backend's own driver for that step: the vanilla driver on CARLA, SUMO's car-following model on SUMO (a vehicle never commanded keeps its departure speed) |
store_transition(obs, ego_id, action, reward, next_obs, done) |
Record one transition |
update() |
One learning step; returns a metrics dict |
reset_episode() |
Clear per-episode state |
get_training_info() |
Diagnostics for the monitor and metric sinks |
save(path) / load(path) |
Checkpoint round-trip |
max_action |
Upper speed clamp, km/h |
supports_batched_actions |
True to receive one batched select_actions call per step instead of N per-agent calls |
Observations arrive as {agent_id: state_vector} — every live agent, so a
centralized critic can see the whole scene. The ego agent's own vector is
multi_agent_obs[ego_agent_id].
A minimal algorithm¶
# my_project/constant_policy.py
from opencda_marl.api import register_algorithm
class ConstantSpeedPolicy:
supports_batched_actions = False
def __init__(self, config, state_dim, action_dim):
self.max_action = float(config.get("max_action", 45.0))
self.speed = float(config.get("constant_speed", 25.0))
def select_action(self, multi_agent_obs, ego_agent_id, training=True):
return self.speed
def store_transition(self, multi_agent_obs, ego_agent_id, action, reward,
next_multi_agent_obs, done):
pass
def update(self):
return {}
def reset_episode(self):
pass
def get_training_info(self):
return {"algorithm_type": "constant"}
def save(self, filepath):
pass
def load(self, filepath):
pass
@register_algorithm("constant", label="Constant")
def build_constant(config, *, state_dim, action_dim):
return ConstantSpeedPolicy(config, state_dim, action_dim)
Making it discoverable¶
The registration runs when the module is imported. Four ways to get it
imported -- none of them touch opencda_marl/:
| route | when to use |
|---|---|
| your own package with an entry point | a research repository that pip installs the framework — the standard workflow |
drop the module into opencda_marl/builtin/algorithms/contrib/ |
you cloned this repository; contrib modules are auto-imported, zero configuration |
MARL.plugins: [my_project.constant_policy] in the scenario YAML |
pin the plugin to the experiment; loaded at initialize for every run, before either backend resolves a component, so it works with the matrix launcher, the monitor and evaluation |
OPENCDA_MARL_PLUGINS=my_project.constant_policy (or a .py path; os.pathsep-separated) |
ad-hoc, no config edit |
Declare the contract version your package targets as a module attribute —
OPENCDA_MARL_POLICY_API = 1 — next to your registrations. The engine
(opencda_marl.plugins.POLICY_API_VERSION) rolls back and reports a
plugin built against a different contract version instead of letting it
misbehave at run time; omitting the attribute skips the check.
The built-in learners are themselves a plugin (opencda_marl/builtin/algorithms/,
loaded through the entry point the wheel declares on itself), so a plugin
is a peer, not a special case — the engine imports no learners at all. python -m opencda_marl.plugins lists everything that
is registered and where it came from; a plugin that fails to import is
reported there and in the error you get when its name is requested.
Your package can ship its own scenario profile under its own config root and inherit the framework's family default and scenario base:
pixi run python opencda.py -t sumo-marl/intersection/my_profile --marl \
--marl-config-root path/to/my_project/configs
The smallest complete learner -- one file, one profile -- is
opencda_marl/builtin/algorithms/contrib/random_speed.py with
opencda_marl/_bundled/configs/sumo-marl/intersection/random_speed.yaml; run it with
pixi run python opencda.py -t sumo-marl/intersection/random_speed --marl.
What the framework does for a plain policy¶
A policy that does not subclass BaseAlgorithm still gets: checkpoints
(save/load are called with the per-episode / best / latest paths), world
reloads on CARLA (the snapshot reads only what the policy has), and a
metrics store for the monitor -- the manager records every update()
result (every outputs.metrics_store.log_frequency calls) and every episode
summary under metrics/<algorithm>/<run>/, where <algorithm> is the key
you registered (a BaseAlgorithm subclass's store lands there too).
What a checkpoint should hold¶
A checkpoint should not carry the run's configuration. The config your
factory receives is your MARL.<section> block plus training_mode (and
reward_dim when vector_rewards injects it) and the tensorboard and
metrics_store blocks the manager merges in from outputs.* (directories
under outputs.root_dir, the run name). Its top-level values arrive
resolved, but its list and dict values are OmegaConf nodes, each of which
keeps a reference to the whole run config: pickled as they are, they ship
every section. The built-in learners store self.checkpoint_config(),
which every BaseAlgorithm inherits from TrainingLogger, instead of
self.config. It is a plain copy without those two blocks, with any value
that is an absolute filesystem path replaced by <absolute path removed>;
every hyperparameter is kept. Interpolations inside nested blocks are kept
as written. A top-level value that resolved to an absolute path is
replaced, but a path inside a longer string (run@${outputs.root_dir}) is
saved as it resolved, so keep such strings out of your section's top
level. A BaseAlgorithm subclass that writes its config in its own save
should store the same. Any list or dict it reads from its config and saves
separately should go in as portable_config(...) from
opencda_marl.builtin.algorithms.training_logger, the function behind
checkpoint_config(). It takes an OmegaConf node or a plain dict or list,
returns plain dicts and lists, and replaces absolute paths the same way.
OmegaConf.to_container(...) raises on a plain dict, for example in a
learner built from plain dicts, and keeps absolute paths. A list of plain
numbers can also go in as list(...). A policy that does not subclass
BaseAlgorithm can store portable_config(config), which also leaves
those two blocks out.
Configuring it¶
The shared extractor's position_x and position_y features are absolute
map coordinates in the common CARLA frame. They read the explicit normalized
position fields; location is a fallback because legacy CARLA observations
use that dictionary for junction-relative coordinates. Use rel_x and
rel_y for the relative position. The SUMO position features have always
read this way.
For replay vehicles with assigned junction geometry, rel_x/rel_y are the
vehicle's map position minus that junction's center. dist_to_intersection
and the approach decision-zone radius use the planar distance to the same
center on both backends. This replaces CARLA's former nearest-waypoint vector
and entry-distance trigger. Legacy planners without junction geometry retain
their adapter-provided distances; the legacy location alias is unchanged.
The config section is the algorithm name by default. Features declared here drive the shared observation extractor — this is how your algorithm gets a state vector without writing any extraction code:
MARL:
algorithm: constant
state_dim: 3
action_space:
mode: speed_only
constant:
constant_speed: 27.5
max_action: 45.0
features:
rel_x: 1
rel_y: 1
speed: 1
state_dim must equal the summed feature widths plus the preference
dimension when preference conditioning is on. A mismatch is logged as a
warning at construction — the configured value still wins, because it is
baked into saved checkpoints.
Declaring capabilities¶
register_algorithm accepts:
| Argument | Default | Effect |
|---|---|---|
label |
uppercased key | Display name in tables, logs and the monitor |
config_section |
the key | Which MARL.<section> block reaches your factory |
multi_agent |
True |
False routes single-agent observations instead: select_action(state, training) and store_transition(state, action, reward, next_state, done) on the agent's own state |
reward_aggregation |
"per_agent" |
"team_mean" gives every agent the team-mean reward |
supports_intent |
False |
True declares the IntentPolicy capability |
vector_rewards |
False |
True injects reward_dim for objective-vector runs |
override |
False |
Required to replace an existing registration |
The framework dispatches on these declarations. There is no isinstance
check and no name list to join.
A single-agent policy (multi_agent=False) returns the target speed from
select_action too, unless its class declares returns_action_index =
True: then select_action returns an index into the policy's
speed_actions list, the framework drives that list entry, and the index
is what store_transition receives back as action. The declaration is
read, never inferred — owning a speed_actions table does not make a
policy index-returning (the bundled DQN returns speeds; tabular Q-learning
returns indices).
Adding route-intent support¶
To support learned route selection, implement IntentPolicy — or inherit
the standard implementation from RouteHeadMixin, which is what the
built-in families do:
from opencda_marl.builtin.algorithms.route_head import RouteHeadMixin
class MyIntentPolicy(RouteHeadMixin, ConstantSpeedPolicy):
route_head_cls = "HybridRouteSACHead" # or HybridRoutePPOHead
route_head_shares_optimizer = False # True for PPO-family heads
@register_algorithm("my_intent", supports_intent=True)
def build(config, *, state_dim, action_dim):
return MyIntentPolicy(config, state_dim, action_dim)
The mixin requires self.device (and self.optimizer when
route_head_shares_optimizer = True).
Route intent is one-shot: the policy chooses once per vehicle at spawn
from the legal set the framework supplies, and the reward arrives at that
vehicle's terminal event — hence store_pending / finalize_terminal
rather than a per-step transition. Illegal actions are masked before
sampling; never select a masked-out index.
Registering with supports_intent=True without implementing the methods
fails at manager construction with a message naming what is missing, rather
than misbehaving at run time.
Beyond algorithms: components and profiles¶
Research rarely stops at the learner. The same plugin door (entry point,
MARL.plugins, OPENCDA_MARL_PLUGINS, contrib/) carries every other
registration your package makes at import time — planners, agent types,
intent providers, observation extractors, reward calculators, the profile
tables, and axes of your own. Replace a Component
is the mechanism, with one worked page per kind; overriding an existing
key — built-ins included — always requires override=True, so a typo
cannot shadow one silently. The per-vehicle local planner still lives
inside the vendored OpenCDA behavior stack and is not yet an axis.
Verifying your algorithm¶
That file registers a policy defined entirely inside the test and drives it
through MARLManager — copy its shape to smoke-test your own before
launching a training run.
Known rough edges¶
Honest notes, so you do not discover these the hard way:
- Actions are km/h, not a normalized action space. Conversion belongs in an env-side action space; until that lands it is part of the contract.
Nonedoubles as the "leave this vehicle to the backend's own driver" signal during warmup.- Preference vectors are appended to the end of the state vector; policies
that need them slice the last
pref_dimentries.