Skip to content

v2.1.0 — first 2.x release

This is the first 2.x release. Breaking and Migrating from 1.x compare with 1.0.0; the other sections, Fixed in particular, also record changes made during 2.x development, some of them in features that are new since 1.0.0.

Theme: a framework other people's algorithms can plug into, with a lifecycle that has been verified end to end on both backends.

Breaking

  • Configs are package data under opencda_marl/_bundled/configs/<family>/ with three-layer resolution (default.yaml → <scenario>/base.yaml → profile). Scenario references are <family>/<scenario>/<profile>; extra roots via --marl-config-root / OPENCDA_MARL_CONFIG_ROOTS may hold only a profile and inherit the other layers.
  • Algorithms are registered, not listed. Built-ins and external packages go through the same register_algorithm(...) with declared PolicyCapabilities; the hard-coded name table and every if algorithm == … dispatch are gone. Plugins are discovered through the opencda_marl.plugins entry-point group, OPENCDA_MARL_PLUGINS, or MARL.plugins; python -m opencda_marl.plugins lists what is registered and from where.
  • The engine imports no learners. The Policy contract lives in opencda_marl.api.policy; the eight first-party learners live in opencda_marl/builtin/algorithms/, which the wheel loads through its own opencda_marl.plugins entry point — the same door any external package uses (OPENCDA_MARL_PLUGINS / MARL.plugins work too, and a contrib/ drop-in folder auto-registers modules for cloned checkouts). Direct imports change from opencda_marl.core.marl.algorithms.sac to opencda_marl.builtin.algorithms.sac, and the 1.x keys sac / td3 are now masac / matd3 (see Migrating from 1.x); the checkpoint format is unchanged. The wheel is the framework: it bundles the SUMO benchmark (configs, lite replay, network) and installs an opencda-marl CLI (run / list), so installing it gives a runnable SUMO environment with no repository checkout. There is no PyPI release yet: pixi run wheel builds the wheel.
  • Sealed kernel, one plugin system. The package is four zones, and a test enforces their boundaries: _kernel/ owns the complete lifecycle (coordinator, both backend envs, world/scenario management, adapters, traffic, sensing, policy machinery) and never imports a concrete plugin; api/ is the public surface — the data contracts, the Policy contract and every register_* door (from opencda_marl.api import … is the one import external packages need); plugins/ is the single plugin system — entry-point group opencda_marl.plugins, $OPENCDA_MARL_PLUGINS, MARL.plugins, the contrib drop-in — with cross-kind API-version rollback and a startup freeze (plugins load once; no hot reload; a run's behaviour set is fixed at initialize); builtin/ is the framework's own plugin set, loaded through the same doors. Every part of the loop is a registrable kind: algorithm, planner, agent type, reward calculator, observation extractor, intent provider, context encoder, and the reward/objective/preference profiles — python -m opencda_marl.plugins lists all of them with sources. The 1.x import paths (opencda_marl.core.*, opencda_marl.coordinator) are gone.
  • Config composes. A profile may include: fragment YAMLs (merged in order under the profile's own keys), and a component may register a validate hook that checks its own config section at resolution — a misconfigured plugin fails at initialize with its own message.
  • Multi-objective is the reward axis, not a mode. Objectives and preference profiles live in opencda_marl/api/ (objectives.py, preferences.py); the preference sampler and regimes in opencda_marl/_kernel/traffic/; the intent reward accumulator and the policy-intent bridge in opencda_marl/_kernel/policy/; the spawn observation in opencda_marl/api/intent_observation.py; the route-action trace writer in opencda_marl/telemetry/trace.py; and the dependency-free schema env in opencda_marl/envs/. The momarl/ config family name stays — it labels the path + speed example family.
  • Evaluation is store-first. The legacy per-step .npz archives, the matplotlib plotter and the cross-agent PNG reporter are gone; evaluation mode full maps to summary with a warning. The manager mirrors its per-episode statistics and final summary into the metrics store, so evaluation numbers appear in the monitor next to training runs. In a learner run they go into the learner's own store (Eval/* tags, one evaluation_episode event per episode, final_summary in the run's metadata), so the run is one monitor entry. Only a run without a store of its own (a baseline agent, or a learner with outputs.metrics_store disabled) gets a separate evaluation/... run, labelled with the learner key or the agent type and the mode train or eval.
  • The Qt desktop dashboard and --gui are retired; the web monitor (pixi run monitor, started by every run) is the framework's one GUI.
  • The SUMO intersection profiles are named route_* (one per learner and action space).
  • The bundled rule-based baseline drives differently. RuleBasedAgent (agents.agent_type: rule_based, marl/intersection/rule_based) changed in the ways listed here, so rule-based numbers from 1.x do not reproduce on this release; use 1.x for them.

    • Follower law. The leader gap is bumper to bumper (the bounding boxes, 2.4 m per half-length without one), minimum_distance_buffer is that physical gap (3.0 m default, was a 5 m centre-to-centre distance), and the target speed is the leader's speed plus a braking curve (closing_decel, 1 m/s²) that reaches zero at the safe distance: a follower closes on a queue, stops at the buffer and moves off with its leader. following_speed_buffer is retired; a profile that still sets it gets one warning that the key is no longer read.
    • Stop on conflict. With junction_stop_on_conflict (on by default) a vehicle that must yield at a predicted trajectory conflict brakes on a junction_stop_decel curve to junction_stop_buffer metres before the entry and holds until the conflict clears; inside the junction the slow-down rule still applies, so the box is never blocked.
    • Following cap in junction management. Stage 1 also applies the car-following speed, taking the lower cap, so yielding to crossing traffic never drives into a stopped leader.
    • Abreast guard (abreast_guard, on by default). A same-direction vehicle within abreast_longitudinal_m whose lateral distance is below abreast_lateral_m makes the vehicle that is behind (ties by actor id) cap its speed at the neighbour's less abreast_drop_kmh until the pair is no longer alongside. Inside abreast_hard_m a neighbour always binds; beyond it, only while it closes laterally by abreast_closing_m per step and moves. In a pair at rest inside the hard zone the rear vehicle creeps ahead at abreast_creep_kmh and ends the pair.
    • Lane hold (lane_hold, on by default). The vehicle keeps its current lane, and drops back, while the planner's next lane is occupied alongside, the way SUMO's lane-change model waits for a gap.
    • Merge order (lane_hold_geometry_priority, on by default). In a standing merge the vehicle ahead goes first, along the pair's shared heading; actor ids break ties only within 0.5 m. false orders by actor id alone, which can hold a merging vehicle with the larger id and the standing vehicle behind it in place for good.
    • Forward corridor. Another vehicle is a leader only when its projected body overlaps the ego's forward driving corridor, so a queue in an adjacent lane no longer stops a clear lane.
    • Junction distance resolved in 2 m steps instead of 5 m.
    • Branch choice at forks. The conflict predictor follows the branch whose heading is closest to the other vehicle's heading extrapolated by its yaw rate, instead of always the first branch of waypoint.next().

    The bundled profile states lane_hold: true and lane_hold_geometry_priority: true explicitly.

  • Some observation features changed meaning, so checkpoints that use them see different inputs. On CARLA, position_x / position_y are absolute map coordinates in the common CARLA frame (they repeated rel_x / rel_y before); SUMO's are unchanged. For replay vehicles with assigned junction geometry, rel_x / rel_y are the map position minus that junction's centre and dist_to_intersection is the planar distance to it, on both backends (CARLA used a nearest-waypoint vector). SUMO spawn-time path decisions now see their neighbours in the CARLA frame (see Fixed), so SUMO path-head checkpoints trained earlier saw different distance and TTC features.

  • CARLA baseline agents run max_episodes. A behavior, vanilla or rule_based run on CARLA was forced to one episode; it now runs scenario.simulation.max_episodes, as on SUMO. Every bundled family base sets 500, so a baseline profile of your own that sets no count now runs 500 episodes: add max_episodes: 1 to keep one, as the bundled marl/intersection baseline profiles do.

Migrating from 1.x

1.x 2.1.0
python opencda.py -t rule_based --marl (flat configs/marl/<name>.yaml) -t marl/intersection/rule_based --marl (<family>/<scenario>/<profile>)
configs/ at the repository root package data under opencda_marl/_bundled/configs/; your own profiles go under a --marl-config-root
MARL.algorithm: sac / td3 with sac: / td3: sections masac / matd3 with masac: / matd3: sections; the old keys are rejected
opencda_marl.core.marl.algorithms.*, opencda_marl.core.agents.* opencda_marl.builtin.algorithms.*, opencda_marl.builtin.agents.*; register through opencda_marl.api
checkpoints/<run>/, recordings/ at the root load_checkpoint: ral/<run> (bundled in opencda_marl/_bundled/checkpoints/ral/) and opencda_marl/_bundled/recordings/; run outputs land under artifacts/
--gui, pixi run marl-quick-test-gui removed; the web monitor starts with every run
pixi run marl-eval-agents, pixi run fix-tensorboard removed (evaluation is a run with training_mode: false)
pixi run tensorboard / tb on runs/, event files under runs/<class>/<timestamp>/ (sac, td3, qlearning, mappo, dqn) on artifacts/, which holds every family's event files under <outputs.tensorboard.dir>/<key>/<run>/, named by the registered key: sac becomes masac, td3 becomes matd3 and qlearning becomes q_learning; mappo and dqn keep their names, and the new keys pcmasac, pcmatd3 and pcmappo get their own directories. Event files written by 1.x stay under runs/
pixi run cda-platoon-town06 runs platoon_joining_town06_carla
a CARLA baseline profile ran one episode whatever it set it runs max_episodes (500 from a bundled family base); set max_episodes: 1 for one

Lifecycle

  • One episode loop for CARLA and SUMO; the coordinator closes evaluation episodes explicitly on both backends (the evaluation manager previously never fired on SUMO and fired a step early on CARLA).
  • CARLA world reloads carry the full learner state for every family — networks, optimizers, encoders, replay / rollout buffers, normalizers, route-intent providers.
  • Resumed runs keep their episode numbering and checkpoint history (training_metadata.json); a configured checkpoint that fails to load aborts instead of scoring random weights.
  • SUMO: first episode finishes, every episode saves like CARLA, empty steps are counted, one consistent SUMO install (libsumo pinned to the environment's SUMO), torch / numpy / random seeded from world.seed.
  • MARL.plugins is loaded by the coordinator at initialize, before either backend resolves a planner, an intent provider or an agent type, and for runs without a learner too; each plugin is imported once per process.
  • Metrics archives flush at close; record-mode traffic writes the replay file once.
  • OpenDRIVE worlds skip pedestrian navigation generation by default (world.pedestrian_navigation: true restores it): rebuilding the walker navmesh on every world reload left navigation-build jobs running beside training. Road geometry and vehicle physics are unchanged.

Learners

  • MAPPO / PCMAPPO: per-trajectory GAE, n_steps rollouts, critic bootstrap, exact PPO ratio, opt-in value normalization, loaded checkpoints skip the random warmup, training_step advances per update.
  • SAC family: one packed LSTM encode and one actor forward per update, one device readback, fused Polyak; opt-in CUDA-graph update (--cuda-graph-update from the launcher) that fails fast on capture errors.
  • TD3: batched context storage, warmup that ends on CARLA, per-piece checkpoint loading.
  • All families share one neighbour ordering and one encode path; acting and learning contexts are identical by construction. The neighbour-context encoder is a registrable kind (MARL.<algo>.context_encoder, default lstm), replaceable without touching a learner.
  • Single-agent learners (multi_agent=False) declare what select_action returns through the class attribute returns_action_index: False (the BaseAlgorithm default, and DQN) means a target speed in km/h, True (tabular Q-learning) an index into its own speed_actions table.

Infrastructure

  • Metrics store + monitor: every run writes a per-run SQLite store (artifacts/metrics/<algo>/<run>/metrics.sqlite, where <algo> is the registered algorithm key); starting a simulation starts the monitor — a detached web service that outlives the run and is reused by later runs (outputs.monitor, a mapping or a bare bool), serving a dependency-free dashboard (static frontend: no build step) that overlays runs, smooths, lists episode summaries and draws the live traffic view. One monitor serves one artifacts root: a run reuses only a monitor of its own root (GET /api/root reports the served directory and the service pid), and when the port serves another root it takes the next one, up to port + 7. The liveness probe is a plain keep-alive request and the port is bound exclusively, so two monitors can no longer share a port on Windows. To stop the service, end the pid it reports; on Windows stop it before deleting or renaming its root, which it holds as its working directory and monitor.log. TensorBoard event files are still written by default and are optional.
  • The monitor answers only loopback names when bound to loopback. A monitor on 127.0.0.1 (the default) serves only requests whose Host is a loopback name (127.0.0.1, localhost, ::1, *.localhost), which blocks DNS-rebinding reads from a web page. ssh -L 8765:127.0.0.1:8765 is unaffected. A proxy that forwards under its own host name (Jupyter server-proxy, code-server /proxy, Open OnDemand, dev tunnels) gets a 403 naming the fix: start the monitor with python -m opencda_marl.telemetry.monitor --root <root> --allow-host NAME (repeatable); later runs on the same root reuse it. opencda_marl.telemetry.monitor.ensure_service(root, host, port, allow_hosts=()) passes the names to a service it starts.
  • Strict JSON everywhere. The store skips a non-finite scalar and counts it in the run's meta (skipped_nonfinite) instead of failing the whole flush batch; episode, event and meta payloads and every monitor response are strict JSON (a non-finite value becomes null).
  • Experiment matrix: --parallel N slots with one CARLA server each, --cuda-visible-devices / --carla-gpus spreading, host-binary or Docker server restarts, --resume, process-tree timeouts, non-zero exit on any failed run, an evaluation launcher, a checkpoint-eval route and analyzers that no longer confuse evaluation slugs with training runs. The launcher is wired to the bundled momarl/intersection family — one learner (PCMAPPO) under every built-in path provider, eight profiles in all with the SUMO route_* set — and the evaluation slug pattern is derived from its condition table, so the two cannot drift apart. Profiles outside the bundled family run directly through opencda.py --marl-config-root, not through this launcher. The default --carla-restart-mode process starts and restarts CARLA itself, so outside --dry-run it stops before the first run unless CARLA_ROOT / CARLA_BIN or --carla-root / --carla-bin is set (no install path is guessed); servers managed elsewhere take --carla-restart-mode none or docker. --clean-planned-slugs also removes each slug's monitor store, so a relaunch no longer appends to the old run. A cell slug is <prefix><map>_<condition>_<regime>_seed<N> with --slug-prefix defaulting to matrix_; a run launched under another prefix, an earlier default included, keeps it (the first part of its directory names under artifacts/checkpoints/matrix/), so pass it with --slug-prefix to resume or evaluate it. Continuation cells (continue_*) load their source from the same prefix unless --continue-source-prefix names another run. The analyzers (analyze_matrix_results.py, analyze_matrix_eval.py, inspect_route_diversity.py) and run_matrix_eval.py read cells under any prefix, keep the prefix in every row and comparison group, warn when one root holds several runs, and take --slug-prefix to read one. scripts/run_carla_checkpoint_eval.py writes to artifacts/eval_runs/ by default.
  • Schematic traffic view: headless runs publish lane geometry and live vehicle poses (throttled, atomic JSON next to the metrics store; CARLA poses come from the step's existing world snapshot — no extra server round trips), and the monitor draws a SUMO-GUI-style live view — lanes as lines, RL agents as oriented triangles — replacing the need for CARLA rendering when watching a run. The geometry is written before the first state and retried until it succeeds; role: agent marks a live vehicle of the learning population (background vehicles are traffic); outputs.traffic_view accepts a bare bool.
  • Coordinate frames come from the map: netOffset is read from the SUMO network header instead of three hardcoded constants, and every SUMO-side position goes through one CoordinateBridge. runtime.coordinate_landmarks (or a <net>_landmarks.json beside the network) decides the convention from junction landmarks, which matters for CARLA town exports (they mirror CARLA's y axis); a map that ships no landmarks keeps the default. This fixes a real defect — SUMO's distance-to-junction subtracted a CARLA-frame centre from a SUMO-frame position, leaving the whole offset (~140 m on the bundled map) in the result. Junction centres are per active junction, and crossing order is reported per junction.
  • Maps on both backends: scripts/convert_opendrive_maps.py derives SUMO networks from OpenDRIVE (opencda_marl/assets/maps/), keeping CARLA's junction ids as SUMO node ids; a scenario pointed at an .xodr builds a building-free CARLA world on the same roads. SUMO takes its network from the .sumocfg at runtime.sumo_cfg, not from scenario.map, and reads a derived town network in CARLA's frame only with the junction landmarks of the entry above; no .sumocfg names the derived town networks in opencda_marl/assets/maps/, and no landmarks ship for any town (see Run Experiments). The converter records repository- or $CARLA_ROOT-relative paths in its outputs.
  • The step trace runs on either backend, keys on the environment's own counters, and records population / learning per vehicle, so offline metrics can separate the learner's vehicles from background traffic. CARLA collision events carry the other actor's id and type, the event frame and the impulse in metadata. An agent may expose a safety-filter state through shield_state(); its fields (applied_speed, shield_active, shield_stage, lane_hold, merge_priority_*) reach the trace. The hook is read on the CARLA vehicle path only, so on SUMO these fields are null: SUMO builds no driver agent. The bundled RuleBasedAgent implements it on CARLA: lane_hold, merge_priority_release and merge_priority_override record each step's lane hold and merge order, merge_priority_enabled the configured lane_hold_geometry_priority switch, and applied_speed the target its rules gave the controller; shield_active and shield_stage stay null, as it has no safety filter. The other bundled agents (marl, vanilla, behavior) do not implement it, so their vehicles, the learner's marl vehicles included, record null in all seven fields.
  • PettingZoo ParallelEnv port of the scenarios for external RL stacks. from_scenario resolves a reference exactly as the command line does (a configs/ root under the working directory, OPENCDA_MARL_CONFIG_ROOTS, then the bundled configs) and composes include:; MARL.external.features can come from the profile or through overrides, and the observation width follows those features, not the profile's MARL.state_dim. Loop on the new env.episode_over, not on env.agents, which is empty before the first spawn. An agent you stop sending actions for is handed back to SUMO's car-following model; one never commanded keeps its departure speed.
  • The wheel declares what it imports, and is built clean. The base dependencies carry everything the SUMO backend needs: eclipse-sumo bundles the simulator and its traci / sumolib clients, which the engine imports from the package's own sumo/tools (its SUMO resolver puts that first on sys.path). libsumo is an optional accelerator; without it the engine falls back to traci with one warning, which names the matching pip install libsumo==<SUMO version>, and a SUMO step runs about 13× slower. The vendored OpenCDA core ships in the wheel, with networkx, so every engine module imports. [carla] adds the libraries for CARLA scenarios run from a repository checkout (python opencda.py -t <ref> --marl, which reads the maps from the working tree); the wheel's own CLI and env port drive SUMO only, and carla itself comes with your simulator. [ports] adds PettingZoo / Gymnasium. pixi run wheel builds from a clean build/ and refuses a wheel with a member that has no source file; test_wheel_contents.py walks the engine's imports and fails on any that is declared nowhere.
  • pixi run verify runs the verification battery. It now checks that traci / sumolib import from the eclipse-sumo package alone (with SUMO_HOME unset and the separate distributions hidden). Neither smoke run starts a detached monitor: the SUMO run turns it off, and the CARLA run uses the rule-based baseline from its own profile, which writes under the verify root.
  • pixi: tensorboard / tb serve --logdir=artifacts. On win-64, activation sets SUMO_HOME to the environment's own eclipse-sumo package, and CARLA_ROOT / CARLA_HOME / CARLA_BIN default to the example D:\Applications\CARLA_0.9.16 location; activation overrides shell values, so edit those lines in pixi.toml when CARLA lives elsewhere. The alternate manifests in dependencies/pixi/ drop marl-quick-test-gui, marl-eval-agents and pyside6; their marl-quick-test runs marl/intersection/rule_based. The Python 3.10 backup manifest no longer sets SUMO_HOME; set it yourself there.
  • Linux: solvable linux-64 lock, CARLA servers survive the pixi activation environment, measured multi-GPU throughput documented.

Mixed autonomy and path decisions

  • Per-vehicle population (agents.population: {mix, seed}): each spawned vehicle draws its agent type from a seeded pattern, so the shares are exact over every window of resolution (default 100) vehicles and reproducible per seed; a shorter prefix only approximates them, and live flows draw flow by flow. The learner commands — speed and path actions alike, at spawn and at the approach decision zone — and learns from its own population (agents.agent_type) only; background vehicles keep their demand routes and stay visible in the traffic context. A mix must name that learning population: on CARLA a mix without it, or with a positive share for a name that is not a registered agent type (matched exactly, case-sensitive), is rejected at construction, and a zero learner share warns. Every built-in agent type reads its own section over vehicle.behavior, so a mix naming rule_based or vanilla works on a profile without an agents.rule_based / agents.vanilla section. On SUMO only the learner's population gets the RL runtime mode and a commanded speed; every other name is background traffic that keeps SUMO's default safeguards and car-following model, with any per-vehicle driver parameters from the recording (metadata.driver) applied.
  • The agent kind is resolved on CARLA only, one agent per spawned vehicle; on SUMO a population key is a label and a registered agent factory never runs. BASELINE_AGENT_TYPES (behavior, vanilla, rule_based) is closed: a registered agent type is not treated as a no-learner baseline.
  • What one path action means is a registered provider (MARL.action_space.path.provider); the built-in semantic action (left / straight / right / keep) is the example, and a provider declares its own alternatives, mask and head width through opencda_marl.api.path_action. A registered provider runs on both backends: the pipeline wires the provider the configured mode names (rebuilt through the registry on a CARLA world reload), and no kernel apply or log site reaches into the built-in decision type.
  • A decision zone (MARL.action_space.path.decision_zone: {trigger: spawn | approach, distance_m}) takes the one-shot path decision at spawn or on approaching the junction.
  • A committed-exit-lane observation channel (MARL.<algo>.features.committed_exit_lanes: <width>, off by default) gives each agent, per exit lane, the number of other vehicles at the same junction that have already committed to it; the state width changes with it.
  • Spawn metadata reaches the agent factory as config["spawn_metadata"], so a plugin agent can be per-vehicle and reproducible from a recording. An agent may also expose a safety-filter state through shield_state() (the speed it applied, whether the filter intervened); on CARLA the vehicle adapter merges those fields into the vehicle's observation and the trace records them. The hook is read on the CARLA vehicle path only; the bundled RuleBasedAgent implements it for its lane hold and merge order.
  • The safety API hands agents the step's TTC and kinematics. constant_velocity_ttc is the world snapshot's quadratic as a public function, min_ttc_to_others folds it over the kinematics of every other vehicle, and published_kinematics returns what the agent manager published for the current step, so an agent that acts on time to collision uses the number the learner and the metrics see without a second round of simulator queries.
  • Competition metrics follow the objective, not a built-in profile name. Any run with an objective spec (or a reward profile whose name says competitive) records the per-agent competition metadata and the terminal records behind the route-intent and preference summaries, so an objective registered through the plugin door keeps them.

Performance

  • CARLA: one world snapshot per step replaces O(N²) server round trips; the MARL collision sensor is installed at construction; the duplicate safety IMU, the disabled-V2X neighbour search and several per-step costs with no consumer are gone.
  • Shared extractor caches, once-per-spec reward metadata, once-per-tick vehicle updates.
  • State extraction reads no config at step time and runs once per observation dict. Profiled on one SUMO episode (2,400 steps, ~14 vehicles, libsumo): the simulator was 6% of the step, while the feature extractor walked the configured mapping -- an OmegaConf DictConfig -- for every feature of every agent (31% of the step in the config resolver) and ran three times per step on identical inputs, because the manager's identity memo keyed on a learning view that a mixed population rebuilt on every call and the SUMO step handed the learner a copy of the read it had just extracted from. The feature list is now resolved once into a plan over plain values, the learning view is stable across the calls of a step, and the SUMO step reuses its post-step read (outside readers still get a copy); neighbour settings and exit-lane rows are built once per step rather than per vehicle. The same episode reproduces bit for bit at 27 ms/step under the profiler instead of 62.
  • Neighbour blocks in one pass per snapshot. The per-ego neighbour search, sort and TTC solves were the next 10% of a SUMO step (34k calls per episode). The world snapshot now computes every vehicle's k-slot block and minimum TTC at once -- same selection (k nearest within the radius, ascending distance, ties by row), same normalisation, same constant-velocity TTC -- and caches it per parameter set, so the per-ego calls of both backends index into it; the scalar implementation stays as the reference the batched pass is tested against on random scenes. Row lookup by actor id is a dict, not an array scan. The same episode reproduces bit for bit at 17 ms/step under the profiler instead of 27.
  • A SUMO vehicle's population is resolved once per episode. The observation builder re-derived it on every step for every vehicle -- a config read through the resolver plus a spawn-event lookup, 34k calls and 1.9 s per 2,400-step episode -- for an answer the spawn event fixes for the vehicle's whole life. The episode state now remembers it (0.1 s), a new episode forgets it, and a vehicle seen before its spawn event is registered is asked again rather than remembered with the default.
  • The learner crosses the device boundary less often. Its per-agent work was already batched (one context sort, one LSTM encode, one actor and one critic pass per step); what remained at the boundary were two host-to-device copies of the same table rows, three or four device-to-host syncs for the step's outputs, and a rollout re-stacked and re-uploaded for every PPO epoch. The ego rows are now read from the sequence tensor already on the device, the outputs come back in one transfer, and the rollout's device tensors are built once per fill. Numerics are unchanged (the same episode reproduces bit for bit); the gain is modest -- transfers halved, the PPO update about 20% faster -- because at a dozen agents the GPU is launch-bound: a full acting step measures 0.74 ms on CPU against 1.34 ms on CUDA with the real network shapes. Acting on a CPU replica is the remaining lever there, and a design decision rather than a fix.
  • Learners act on a CPU replica and train on the GPU. With acting_device: auto (the default) a learner training on CUDA acts on CPU copies of its actor, critic and context encoder, refreshed lazily after every optimiser step or checkpoint load through one pinned flat copy, with the value normalisers mirrored; PCMAPPO, MAPPO, PCMASAC and TD3 opt in. The replica caps torch's intra-op pool (acting_threads, default at most 8) because a tiny op pays to wake every thread. Back-to-back on the same machine state, one SUMO PCMAPPO episode under cProfile: 47–49 s acting on the GPU, 41 s on the replica, the acting path itself 11.5 s → 6.6 s. Runs are no longer bit-identical to GPU acting; acting_device: train restores it.

Fixed

  • Platooning scenarios ran the plain behavior agent. VehicleManager tested for 'platoon' while the platooning scenarios pass ['platooning'], so no platoon member or joining vehicle got the platooning agent. It tests 'platooning' again, as stock OpenCDA does.
  • The bundled DQN baseline drove every vehicle at the top speed. The manager read DQN's returned speed as an index into DQN's own speed_actions table, so 15, 30, 45 and 60 km/h all clamped to the last entry. The manager now follows the policy's returns_action_index declaration (see Learners). The single-agent speed clamp also accepts a speed_actions table held as a numpy array, which failed every step before.
  • DQN and tabular Q-learning train again. The shared training strategy indexed their per-agent states as multi-agent dicts and called a six-argument store_transition; single-agent learners, and any registered policy with multi_agent=False, now get their own flow.
  • MARL.plugins reached too few runs. It loaded only when a learner was configured, and on CARLA only after the scenario had resolved its planner and intent provider, so a component registered only there was an unknown key. It now loads at initialize for every run (see Lifecycle), and loading the same plugin again is a no-op instead of reporting a working .py plugin as failed.
  • SUMO ignored MARL.components.reward_calculator. It read a top-level components block and silently ran the default calculator; it now selects from the MARL block, as CARLA does.
  • The SUMO evaluation miscounted its vehicles. A finished vehicle still counted as active, and a vehicle still in SUMO's insertion queue at the end of an episode dropped out of the evaluation's total. A queued vehicle is now an unfinished route (a timeout) in Eval/* and episodes_summary.csv, matching the training summary and CARLA's never-entered spawns. SumoMARLEnv.pending_spawn_count() counts only spawn events not yet due (CARLA's pending_spawns), and SumoMARLEnv.queued_insertions() lists the queued vehicles.
  • A SUMO learner that stopped receiving commands kept its last speed. TraCI's setSpeed persists, so a vehicle the policy stopped commanding (a warmup or no-action step, an external loop that sent nothing for it) held its last commanded speed. It is now handed back to SUMO's car-following model on the commanded → un-commanded transition; a vehicle never commanded keeps the departure speed the spawner set.
  • SUMO spawn-time path decisions mixed coordinate frames. The spawning ego is built in the CARLA frame, but its neighbours were in SUMO's, so their distance and TTC features carried the network offset (and the mirror on a y-flipped map). Every neighbour now goes through the coordinate bridge, position and velocity heading alike.
  • A SUMO vehicle queued for insertion survived the episode reset. SUMO holds a vehicle whose departure lane has no room in an insertion queue that traci.vehicle.getIDList does not list, so the reset left it there and it entered the next episode with the old route, blocking its own id ("vehicle ... to add already exists"). A short approach edge admits one vehicle at a time, so under demand this carried a sizeable share of an episode's spawns into the next. The reset now reads the queue (SUMO's pending list, completed from the spawner's registry), counts those vehicles as unfinished routes of the episode that spawned them, and removes them.
  • A SUMO trip ended at the end of an edge, not at its destination. The waypoint shim converted a stored position back with the bundled intersection's constant offset (not the published bridge) and clamped next() at the lane's end, so on a calibrated map the planner's walk past a junction exit stopped at the first edge end and a route's destination sat wherever that edge ended. The spawner ranked destination edges by distance to their vertices, so a point halfway along a long edge lost to any short edge nearby; and SUMO's own arrival, at the end of the target edge, decided success, so a trip's length depended on how the network splits its edges. next() now measures progress through the bridge and continues onto the linked lane, previous() mirrors it, get_left_lane() / get_right_lane() return the adjacent lane at the same fraction of its length, candidate edges rank by distance to the lane, and the environment ends a trip within world.arrival_radius_m (5 m) of its destination point, as CARLA does, with SUMO's arrival as the fallback. The SUMO twin's route table reproduces the CARLA planner's spawn and destination points, and a replayed event keeps the recording's destination when a route decision keeps its demand route.
  • WorldSnapshot.capture skips an unreadable actor instead of ending the run. Its error path referred to a logger the module never imported, so a CARLA server that stopped answering for one call raised NameError instead of costing one actor for one step.
  • The abreast guard told no neighbours apart. It read .id, which the perception's obstacle vehicles do not have, so every neighbour was id 0: the tie-break always yielded and all neighbours shared one closing memory. It reads carla_id now.
  • The branch choice at forks never saw a turning obstacle. Perception obstacles carry no angular velocity, so the yaw rate read as zero and every vehicle was predicted straight on. The rate is now estimated from each obstacle's heading change between steps over the world's fixed delta; an unmatched detection (carla_id -1), or an id two objects carry in one step, gets no rate and is predicted straight on.
  • A population mix naming rule_based or vanilla crashed at the first such spawn on any profile without that agent's own section; both now read their section over vehicle.behavior.
  • A resumed external policy restarted its monitor counters. A non-BaseAlgorithm policy's metrics store now continues its episode and update-step counters when a resumed run reopens the same store.
  • Store discovery missed some run directories. Read-only opens work for run directories containing #, % or spaces, for stores under a mapped network drive (no longer resolved to UNC) and for UNC roots; such runs were missing from discovery and the monitor. An unreadable store is logged once at WARNING.
  • The monitor page missed tags that appear mid-run. Loss/* after the warmup and Eval/* now show up without reselecting the run, and the default selection extends to them; tags are re-listed at most every 10 s, and only when a selected run wrote scalars. A failed refresh shows refresh failed: ... in the status line and polling continues.
  • outputs.monitor: false started the monitor anyway and outputs.monitor: true stopped initialize; both sections now take a bare bool.
  • A learner run appeared twice in the monitor. The evaluation manager opened a store of its own next to the learner's, with no mode in its metadata (? in the run list). A learner's evaluation numbers now go into the learner's store (see Evaluation is store-first under Breaking), and a separate evaluation run is labelled with the learner key or the agent type and train or eval.
  • MASAC and PCMASAC runs shared one sac/ directory. The metrics store directory, the run's algorithm label and the TensorBoard event directory came from the learner class, which the two keys share. Both writers now name the run by the registered algorithm key, for every learner: <metrics log_dir>/<key>/<run>/ and <tensorboard log_dir>/<key>/<run>/ (the TensorBoard names that change from 1.x are listed in Migrating from 1.x). Only a learner built without MARLManager falls back to the class-derived name. A bare tensorboard: true|false or metrics_store: true|false, in outputs or in an algorithm block, is the enabled switch and keeps the other settings (the TensorBoard one wrote to ./runs before), and an empty block reads as absent instead of failing the learner.
  • Built-in learners logged no timeouts to the store. Their episode summaries carried the success and collision rates but not timeout_rate, successes, collisions or timeouts, which external policies logged, so the monitor's columns depended on the learner's base class. Every learner logs all of them now.
  • The episode table cut its most useful columns. It kept the first 12 extra columns in alphabetical order, which intent_* diagnostics could fill. The outcome, throughput, TTC and near-miss columns now come first, then the reward_component_* columns, then the rest alphabetically, up to 24 extra columns.
  • Counts in the episode table printed as decimals. A count of 0 printed 0.00, and a count from 1 to 999 kept three decimals (2.000). A column whose shown values are all whole numbers (counts, episode and step numbers) now prints integers. Any other column keeps the page's usual precision on every row, so a reward of 3 shown next to 2.537 reads 3.000. The table makes this choice again on every refresh, over the rows it shows. A missing or non-finite value prints a dash.
  • A finished run's traffic view looked live. The last frame stayed on screen with only an age label. A frame with no newer one for max(3 s, 3 × interval) is now dimmed and labelled stale, and a frame from a run whose store was closed after it is labelled run ended. traffic_state.json gains the publish interval, and a store's metadata gains closed_at when its writer closes it (removed when a resumed run reopens the store).
  • MASAC logged preference tags that were always zero. The SAC/pref_shift_* tags are logged only under an internal preference mode (internalpref, internalpref_sep) and SAC/pref_sep_* only under internalpref_sep; elsewhere (plain MASAC, pref_mode: direct) they were constant zeros.
  • CARLA baseline agents ignored max_episodes. A behavior, vanilla or rule_based run was forced to one episode. It now honours scenario.simulation.max_episodes, as SUMO did (see Breaking for a baseline profile that sets no count).
  • SUMO runs carried no scenario label. The -t scenario reference now labels SUMO runs in the store metadata (scenario), as it did CARLA runs.
  • Another SUMO install could still supply the bindings. With the environment's eclipse-sumo present, the SUMO resolver removed only $SUMO_HOME/tools from sys.path, so a PYTHONPATH entry for a system SUMO's tools stayed behind the package's. The resolver now removes every other SUMO tools directory: $SUMO_HOME/tools, and any directory named tools that ships both traci/ and sumolib/. A project's own tools/ without those bindings is kept.
  • A CARLA world build that timed out ended the run. On a loaded machine, generating an OpenDRIVE world or loading a town could outlast the client timeout. The build is now retried once with twice world.timeout, and the client timeout is restored afterwards. A second time-out stops the run with a message that says to check the server or raise world.timeout; any other error is raised at once, as before. A town load that timed out is no longer reported as a missing map.
  • The evaluation summary printed ± as mojibake on a console that is not UTF-8; it prints +/-.
  • The RAL evaluation profiles load the bundled kit. The marl/intersection evaluation profiles name load_checkpoint: ral/<run>, matching checkpoints/ral/manifest.json; mappo_h128, td3_43d and td3_lstm256 load their own entries instead of copies of other runs' paths, and td3_44d is a training profile (no bundled weights).
  • sumo-marl/intersection/base.yaml is headless (world.sumo_gui: false); sac.yaml still opens the SUMO window.
  • A section left empty in a profile erased the family's. outputs: with nothing under it parses as null, and the merge replaced the lower layer's whole block with it, at any depth and in include: fragments too; the run then crashed on its first read of the section. An empty key now keeps the lower layer's mapping. Any other scalar, a bare true / false included, still replaces it; a mapping still merges into it, and a list over it is still an error; null over a scalar or a list still sets it. A top-level section (scenario, world, runtime, agents, outputs, MARL) that is still null after the merge, because no layer defines it as a mapping, reads as an empty mapping: the coordinator, the opencda-marl CLI and the PettingZoo port's from_scenario normalise it before anything reads it. The matrix launcher's check for whether a cell needs the perception model (--apply_ml) now merges the layers the same way, a cell's include: fragments included, so neither an empty section nor a fragment hides whether perception is on.
  • Checkpoints carried the run configuration of the machine that trained them. Every built-in learner pickled its whole config into each checkpoint: the tensorboard and metrics_store blocks (log directories under outputs.root_dir, the run name) and, because a nested OmegaConf node keeps a reference to the configuration it came from, the entire run configuration, every section with outputs.root_dir among them. DQN also stored its speed list as such a node. The built-in learners now save checkpoint_config(), which every BaseAlgorithm inherits from TrainingLogger: plain values, without the run-output blocks, and with any value that is an absolute path replaced by <absolute path removed>. Interpolations inside nested blocks are kept as written. The top-level values of the learner's section reach it already resolved, so one that resolves to an absolute path is replaced, but a path inside a longer string (run@${outputs.root_dir}) is saved as it resolved. Hyperparameters are kept, and DQN saves its speed list as a plain list. Nothing reads the saved config back, so checkpoints written earlier, which still carry all of it, load and resume unchanged. The bundled baseline checkpoints are unchanged. A learner of your own should save the same (Add an Algorithm).
  • Matrix cells launched with a capitalised map name were invisible downstream. The launchers accepted --maps Custom (validation lowercased it) but wrote matrix_Custom_* cells, which run_matrix_eval.py and the analyzers never matched. launch_matrix.py, launch_eval_matrix.py and launch_distributed_matrix.py now normalise --maps, and --continue-source-map / --checkpoint-map where they take one, to the map profile key (matrix_common.canonical_map / canonical_maps), and plan each map once however it is spelt or repeated. An unknown --continue-source-map or --checkpoint-map now stops the launcher up front. Cells an earlier run already wrote as matrix_Custom_* keep that name: rename the directories named after their slugs to matrix_custom_* (or re-run the cells) to bring them into run_matrix_eval.py and the analyzers and, on a case-sensitive filesystem, back into the evaluation launcher, continuation and --resume.

Known issues

  • SUMO spawn-time path decisions see the ego heading +x. A spawn transform generated on SUMO carries yaw 0, so the ego of a spawn-time path decision moves along +x whatever its lane, and its TTC and closing features against its neighbours are wrong. This covers SUMO runs with scenario.traffic.mode: live or record, and replays of recordings made on SUMO — including lite_2min_SUMO.json, which the bundled sumo-marl profiles replay — because a recording carries no backend tag. Decisions taken at the approach zone read the live vehicle, and replays of recordings made on CARLA carry each vehicle's own yaw; both are unaffected. make_spawn_event_proxy(..., heading_rad=) and spawn_lane_heading_rad in opencda_marl/_kernel/traffic/sumo_route_intent.py are the seam for the fix. SUMO path-head checkpoints trained in live or record mode are not comparable with ones trained after it.
  • OpenCDAMARLParallelEnv.from_scenario drives the SUMO backend only.
  • A long CARLA evaluation can die at an automatic world reset. The world manager reloads the world when a step gets 1.5x slower than the episode's baseline, and the reload uses the fixed world.timeout without a retry. On a windowed server a reload takes 10 to 35 s, so a run of a few tens of episodes can end with a time-out at the reload, followed by a time-out in the next world.tick() or a native Assertion failed: _episode != nullptr from the CARLA client. A failed reload does not stop the run cleanly. Raise world.timeout in the profile, or run a headless server (-RenderOffScreen), until this is fixed. Smoke runs and pixi run verify --full are too short to reach it.
  • The first build of the bundled intersection world can exceed the default time-out. generate_opendrive_world took 23 to 37 s on a windowed server; the defaults are 20 s (MARL profiles) and 10 s (OpenCDA scenarios) with one retry, and the retry waits behind the still-running first request, so it fails whenever one build takes more than about 30 s. Set world: {timeout: 90.0} in the profile.
  • Sensors are not destroyed at an episode reset or a world reload. The CARLA client logs sensor object went out of the scope but the sensor is still alive in the simulation for the GNSS, IMU and collision sensors, and they accumulate on the server during long runs.
  • --apply_ml fails on Windows in the standard OpenCDA path. The ML manager imports torch after cv2, open3d or matplotlib have been loaded, and import torch then fails with WinError 127 (shm.dll). Import torch first, for example python -c "import torch, sys, runpy; sys.argv = ['opencda.py', '-t', '<scenario>', '--apply_ml']; runpy.run_path('opencda.py', run_name='__main__')".
  • platoon_joining_2lanefree_carla does not exercise the joining branches. The platoon members and the merging vehicle now run the platooning agent (the fix under Fixed), but the merging vehicle reaches the main road 80 to 125 m behind the platoon and about 8 km/h slower, so it never comes within the 35 m communication range and the frontal, cut-in and back joining code is not reached. The scenario file is the one the upstream repository ships.
  • Console noise from the OpenCDA scenarios. On single_2lanefree_carla the safety manager prints Safety Warning from the safety manager: {... 'offroad': True ...} at every tick of the whole route, and OpenCV or Open3D windows can print qt.qpa.screen: Unable to open monitor interface and QThreadStorage: entry N destroyed before end of thread on a multi-monitor Windows setup. The run itself completes.

Docs

  • The portal is Getting Started (Installation, Quick Start, CARLA Version Support), Guide (Architecture, Run Experiments, Training Throughput, Customize — one worked page per replaceable kind) and Reference (PCMA, the OpenCDA patch ledger, Contributing, this changelog). The API reference was retired; the code and its tests are the reference.
  • The Agent Workflow page and the Code Index are what coding agents read before acting; the PR template is the review contract.