From cd0a64a9660d7e3c99cc884766a41651f85fb1ee Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 5 Oct 2026 15:31:10 +0200 Subject: [PATCH 001/123] Reapply "k_scaled_0045 reduce speed limit penalty by 30x" This reverts commit 74b44c2f31865a0f19d8aeca7513073ea3fe88ea. --- README.md | 2 +- pufferlib/config/evaluation/benchmark.yaml | 2 +- pufferlib/config/puffer_drive.yaml | 2 +- pufferlib/ocean/cosim/arch.py | 2 +- pufferlib/ocean/drive/drive.h | 6 +++--- pufferlib/ocean/drive/drive.py | 2 +- scripts/kesai/3_train_64GPU_multinode_slurm.sh | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 41fcf3f614..926df70402 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ To add a metric to the report: add its key to `TREND_METRICS` / | `reward_vel_align` | `1.0` | Speed matching road limit | | `reward_lane_center` | `0.0038` | Lane centering | | `reward_stop_line` | `1.0` | Stop line penalty | -| `reward_overspeed` | `0.05` | Speeding penalty | +| `reward_overspeed` | `0.005` | Speeding penalty | ### `train` — PPO diff --git a/pufferlib/config/evaluation/benchmark.yaml b/pufferlib/config/evaluation/benchmark.yaml index 6925cd7971..154f72bf91 100644 --- a/pufferlib/config/evaluation/benchmark.yaml +++ b/pufferlib/config/evaluation/benchmark.yaml @@ -12,7 +12,7 @@ env: reward_stop_line: 1.0 reward_ade: 0.0 reward_goal: 1.0 - reward_overspeed: 0.05 + reward_overspeed: 0.005 reward_comfort: 0.05 reward_velocity: 0.0025 reward_lane_align: 0.025 diff --git a/pufferlib/config/puffer_drive.yaml b/pufferlib/config/puffer_drive.yaml index 8c8cc5d4bd..10ce35d6bd 100644 --- a/pufferlib/config/puffer_drive.yaml +++ b/pufferlib/config/puffer_drive.yaml @@ -179,7 +179,7 @@ env: reward_velocity: 0.0025 reward_reverse: 0.005 reward_timestep: 2.5e-05 - reward_overspeed: 0.05 + reward_overspeed: 0.005 # m/s above the lane limit before the overspeed penalty fires overspeed_tolerance_mps: 0.0 reward_ade: 0.0 diff --git a/pufferlib/ocean/cosim/arch.py b/pufferlib/ocean/cosim/arch.py index 9e21009bc9..24d50cdea4 100644 --- a/pufferlib/ocean/cosim/arch.py +++ b/pufferlib/ocean/cosim/arch.py @@ -35,7 +35,7 @@ "reward_stop_line": 1.0, "reward_ade": 0.0, "reward_goal": 1.0, - "reward_overspeed": 0.05, + "reward_overspeed": 0.005, "reward_comfort": 0.05, "reward_velocity": 0.0025, "reward_lane_align": 0.025, diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index 88b83bc039..a70d3d0ce5 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -354,7 +354,7 @@ static const RewardBound REWARD_BOUNDS[NUM_REWARD_COEFS] = { {2.5e-4f, 7.5e-3f, 0}, // REWARD_COEF_REVERSE α_reverse ~ U(2.5e-4, 7.5e-3) {0.0f, 1.0f, 0}, // REWARD_COEF_STOP_LINE α_stop-line ~ U(0, 1) {0.0f, 5e-5f, 0}, // REWARD_COEF_TIMESTEP α_timestep ~ U(0, 5e-5f) - {0.0f, 1.0f, 0}, // REWARD_COEF_OVERSPEED α_overspeed ~ U(0, 1) + {0.0f, 0.1f, 0}, // REWARD_COEF_OVERSPEED α_overspeed ~ U(0, 0.1) {0.8f, 1.25f, 0}, // REWARD_COEF_THROTTLE C_throttle {0.8f, 1.25f, 0}, // REWARD_COEF_STEER C_steer {0.666f, 1.5f, 0}, // REWARD_COEF_ACC C_acc @@ -376,7 +376,7 @@ static const RewardBound REWARD_BOUNDS_LOG[NUM_REWARD_COEFS] = { {2.5e-4f, 7.5e-3f, 1}, // REWARD_COEF_REVERSE α_reverse ~ logU(2.5e-4, 7.5e-3) {0.0f, 1.0f, 0}, // REWARD_COEF_STOP_LINE α_stop-line ~ U(0, 1) {0.0f, 5e-5f, 0}, // REWARD_COEF_TIMESTEP α_timestep ~ U(0, 5e-5f) - {0.0f, 1.0f, 0}, // REWARD_COEF_OVERSPEED α_overspeed ~ U(0, 1) + {0.0f, 0.1f, 0}, // REWARD_COEF_OVERSPEED α_overspeed ~ U(0, 0.1) {0.8f, 1.25f, 0}, // REWARD_COEF_THROTTLE C_throttle {0.8f, 1.25f, 0}, // REWARD_COEF_STEER C_steer {0.666f, 1.5f, 0}, // REWARD_COEF_ACC C_acc @@ -4595,7 +4595,7 @@ static void compute_rewards(Drive *env, int i) { } // Speed limit reward - float speed_reward = -agent->reward_coefs[REWARD_COEF_OVERSPEED] * agent->metrics_array[SPEED_LIMIT_IDX]; + float speed_reward = -agent->reward_coefs[REWARD_COEF_OVERSPEED] * env->dt * agent->metrics_array[SPEED_LIMIT_IDX]; env->rewards[i] += speed_reward; agent_log->reward_overspeed += speed_reward; diff --git a/pufferlib/ocean/drive/drive.py b/pufferlib/ocean/drive/drive.py index a0783b0670..7593e1aa78 100644 --- a/pufferlib/ocean/drive/drive.py +++ b/pufferlib/ocean/drive/drive.py @@ -33,7 +33,7 @@ def __init__( reward_reverse=0.005, reward_stop_line=1.0, reward_timestep=0.000025, - reward_overspeed=0.05, + reward_overspeed=0.005, overspeed_tolerance_mps=0.0, reward_ade=0.0, min_goal_spacing=20.0, diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 39334d9503..8c4596f6c9 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -22,7 +22,7 @@ start=$(date +%s) export SEED=1000 -export RUN_NAME=k_scaled_0044_${SEED} +export RUN_NAME=k_scaled_0045_${SEED} echo ${RUN_NAME} export DATA_DIR=/home/bjaeger/PufferDrive/experiments/${RUN_NAME} From 53692a910913d846e5f1a2cf022fa30caf507dec Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 5 Oct 2026 15:59:44 +0200 Subject: [PATCH 002/123] update default values in config to latest training config. Add S3 upload for the maps. --- data_utils/datasets.yaml | 12 ++++++++ pufferlib/config/puffer_drive.yaml | 30 +++++++++---------- .../kesai/3_train_64GPU_multinode_slurm.sh | 18 ----------- tests/unit_tests/test_drive_config.py | 2 +- 4 files changed, 28 insertions(+), 34 deletions(-) diff --git a/data_utils/datasets.yaml b/data_utils/datasets.yaml index 724de0ea1f..c839a13496 100644 --- a/data_utils/datasets.yaml +++ b/data_utils/datasets.yaml @@ -62,3 +62,15 @@ nuplan_val: CC BY-NC-SA 4.0 (nuPlan, Motional) — non-commercial use only, attribution required, share-alike. Redistribute only under this same license and keep this notice with any copy. + +carla_128_affine_zones_stop: + s3_uri: s3://pufferdrive-bins/carla/0.3.2/128_affine_zones_stop/ + size: ~106 MB, 128 bins + public: true + description: >- + CARLA Towns 01-07/10HD as PufferDrive bins with 16 affine augmentations + each (flips, shears, scales), per-lane speed zones, stop signs and the + 0.1 polyline tolerance (123Drive mapforge at v0.3.2-19). The training set + behind the k_scaled_004x runs. + license: >- + CARLA assets are CC BY 4.0 — attribution required. diff --git a/pufferlib/config/puffer_drive.yaml b/pufferlib/config/puffer_drive.yaml index 10ce35d6bd..d7532bd9f9 100644 --- a/pufferlib/config/puffer_drive.yaml +++ b/pufferlib/config/puffer_drive.yaml @@ -57,19 +57,19 @@ env: # Speed cap when reward_randomization is off (eval); null = base_max_speed_mps. Sets C_vel = max_speed_mps / base_max_speed_mps (training range [1/a, a] below) max_speed_mps: null # Training C_vel ~ X(a) = 0.5 U(1/a, 1) + 0.5 U(1, a): speed cap = base_max_speed_mps * [1/a, a]; 1.5 = paper (13.3-30 m/s), 2.0 = 10-40 m/s - conditioning_speed_scale: 1.5 + conditioning_speed_scale: 2.0 # Optional nonzero launch speed for gigaflow random spawns spawn_initial_speed: 0.0 # Gigaflow spawn pose noise: lateral offset as a fraction of half the lane width, heading offset in degrees spawn_lateral_offset_max_frac: 1.0 spawn_heading_max_deg: 30.0 # Gigaflow dynamics randomization: per-step brownian pose buffeting (std in meters / degrees); 0 disables - pose_noise_xy_m: 0.0 - pose_noise_yaw_deg: 0.0 + pose_noise_xy_m: 0.025 + pose_noise_yaw_deg: 0.25 # Speed-limit randomization (needs SPDZONE1 maps): per episode, with this probability every speed zone shifts its # posted limit by one uniform offset in +-delta, clipped to [min, max]; junction lanes inherit the min over entries. # m/s: 9.72 = 35 km/h, 1.39 = 5 km/h, 36.11 = 130 km/h. 0 disables. - speed_limit_random_prob: 0.0 + speed_limit_random_prob: 1.0 speed_limit_random_delta_mps: 9.72 speed_limit_random_min_mps: 1.39 speed_limit_random_max_mps: 36.11 @@ -83,7 +83,7 @@ env: stop_sign_behavior: stop # Traffic controls observed and enforced; stop-sign runs are detected as in CaRL's RunStopSign2 (reward_stop_line) traffic_lights_enabled: true - stop_signs_enabled: false + stop_signs_enabled: true yield_signs_enabled: false # Skip red light violation detection (metrics, reward, behavior) disable_red_light_infractions: false @@ -100,7 +100,7 @@ env: # Number of steps before reset; keep it off multiples of train.bptt_horizon so episode ends don't all land on the unused last buffer slot scenario_length: 2500 # Frequency of resampling scenario (in steps), 0 to disable - resample_frequency: 256000 + resample_frequency: 0 # End the episode early when too many agents are inactive; otherwise use scenario_length termination_mode: true # Ratio of inactive agents (stopped/removed) that triggers early reset @@ -152,9 +152,9 @@ env: # Maximum speed at final waypoint to count goal reward goal_speed: 3.0 # true: goal-speed coef ~ U(0, 20) per agent under reward_randomization; false: pinned to goal_speed (GIGAFLOW v_goal = 3) - goal_speed_randomization: true + goal_speed_randomization: false # true: the final goal is only consumed below goal speed (GIGAFLOW R_goal); false: consumed on entry, reward gated - goal_reach_requires_speed: false + goal_reach_requires_speed: true # Number of target waypoints in the observation (including goal) num_goals: 3 # Waypoint spacing range in meters when building path @@ -195,11 +195,11 @@ env: obs_slots_boundary_n: 50 obs_slots_partners_n: 20 # Append the partner's velocity relative to ego (ego frame, 2 floats) to every partner row (GIGAFLOW observes velocity) - obs_partner_relative_velocity: false + obs_partner_relative_velocity: true # Ego lane-angle slot: true = signed heading error theta_f / pi (GIGAFLOW B.1), false = cos(theta_f) - obs_lane_heading_signed: false + obs_lane_heading_signed: true # Append each lane segment's speed limit (/ obs_norm_speed_mps) to its lane row so limit changes ahead are visible - obs_lane_speed_limit: false + obs_lane_speed_limit: true obs_slots_traffic_controls_n: 4 # Fraction of segment observation slots to drop (reduces obs size) obs_dropout_lane: 0.5 @@ -255,7 +255,7 @@ policy: # Encoder activation - options: "relu", "tanh", "gelu" encoder_activation: relu encoder_layer_norm: true - mask_padded_features: false + mask_padded_features: true # Shared backbone layer backbone_hidden_size: 1024 backbone_num_layers: 3 @@ -271,7 +271,7 @@ policy: critic_num_layers: 1 critic_head_layer_norm: false # Run the actor/critic heads in float32 under bf16 autocast (backbone stays bf16) - fp32_heads: false + fp32_heads: true # Dual or shared actor-critic backbone shared_network: false action_type: discrete @@ -297,7 +297,7 @@ train: anneal_lr: true precision: bfloat16 # false: pure float32 matmuls/convs (no TensorFloat-32); requires precision float32 - tf32: true + tf32: false rollout_dtype: float32 total_timesteps: 500000000000 learning_rate: 0.0005 @@ -333,7 +333,7 @@ train: # Accumulate gradients above this size max_minibatch_size: 65536 bptt_horizon: 128 - compile: false + compile: true compile_mode: default compile_fullgraph: false vtrace_rho_clip: 1 diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 8c4596f6c9..6c5a3ea967 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -64,26 +64,8 @@ srun torchrun \ run_name=${RUN_NAME} \ train.total_timesteps=1000000000000 \ vec.num_envs=16 \ - train.compile=True \ train.max_minibatch_size=131072 \ train.minibatch_size=131072 \ - train.precision=bfloat16 \ - policy.fp32_heads=true \ - train.tf32=false \ - env.goal_speed_randomization=false \ - env.goal_reach_requires_speed=true \ - env.obs_partner_relative_velocity=true \ - env.obs_lane_heading_signed=true \ - env.obs_lane_speed_limit=true \ - env.stop_signs_enabled=true \ - env.pose_noise_xy_m=0.025 \ - env.pose_noise_yaw_deg=0.25 \ - env.speed_limit_random_prob=1.0 \ - env.stagger_first_episode=true \ - env.resample_frequency=0 \ - env.conditioning_speed_scale=2.0 \ - policy.mask_padded_features=true \ - train.evaluation_benchmarks=carla_fast \ train.final_model_name=${FINAL_MODEL_NAME} \ train.seed=${SEED} \ tb=True diff --git a/tests/unit_tests/test_drive_config.py b/tests/unit_tests/test_drive_config.py index 3e9928ae12..364da1755a 100644 --- a/tests/unit_tests/test_drive_config.py +++ b/tests/unit_tests/test_drive_config.py @@ -96,7 +96,7 @@ def test_cli_override(self): @patch("sys.argv", ["pufferl.py"]) def test_training_performance_defaults(self): args = load_config("puffer_drive") - self.assertFalse(args["train"]["compile"]) + self.assertTrue(args["train"]["compile"]) self.assertEqual(args["train"]["precision"], "bfloat16") @patch("sys.argv", ["pufferl.py", "--train.learning-rate=0.5"]) From 1ed2d308aa7a1720a5aedf3a2304c4bdf62d5955 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 5 Oct 2026 17:49:19 +0200 Subject: [PATCH 003/123] Added Carla leaderboard 1.0 cosim. Speed up training with better communication allowing higher env count per CPU. --- pufferlib/ocean/cosim/carla/README.md | 6 +- pufferlib/ocean/cosim/carla/controller.py | 69 +- pufferlib/ocean/cosim/carla/lb1/README.md | 97 +++ pufferlib/ocean/cosim/carla/lb1/__init__.py | 0 .../ocean/cosim/carla/lb1/ground_truth.py | 178 ++++ .../ocean/cosim/carla/lb1/lb1_protocol.py | 57 ++ .../cosim/carla/lb1/leaderboard_agent.py | 540 ++++++++++++ .../ocean/cosim/carla/lb1/policy_server.py | 99 +++ .../ocean/cosim/carla/lb1/run_evaluator.py | 90 ++ .../ocean/cosim/carla/lb1/run_leaderboard.sh | 117 +++ .../ocean/cosim/carla/leaderboard_agent.py | 775 ++--------------- pufferlib/ocean/cosim/carla/shadow_ego.py | 820 ++++++++++++++++++ pufferlib/ocean/cosim/carla_bridge.py | 264 ++++-- pufferlib/ocean/cosim/carla_cosim.py | 4 +- pufferlib/vector.py | 119 +-- scripts/kesai/14_carla_longest6_v1.sh | 256 ++++++ .../test_carla_offset_calibration.py | 2 +- tests/unit_tests/test_lb1_ground_truth.py | 90 ++ tests/unit_tests/test_lb1_protocol.py | 79 ++ tests/unit_tests/test_vector_driver.py | 67 ++ 20 files changed, 2859 insertions(+), 870 deletions(-) create mode 100644 pufferlib/ocean/cosim/carla/lb1/README.md create mode 100644 pufferlib/ocean/cosim/carla/lb1/__init__.py create mode 100644 pufferlib/ocean/cosim/carla/lb1/ground_truth.py create mode 100644 pufferlib/ocean/cosim/carla/lb1/lb1_protocol.py create mode 100644 pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py create mode 100644 pufferlib/ocean/cosim/carla/lb1/policy_server.py create mode 100644 pufferlib/ocean/cosim/carla/lb1/run_evaluator.py create mode 100644 pufferlib/ocean/cosim/carla/lb1/run_leaderboard.sh create mode 100644 pufferlib/ocean/cosim/carla/shadow_ego.py create mode 100644 scripts/kesai/14_carla_longest6_v1.sh create mode 100644 tests/unit_tests/test_lb1_ground_truth.py create mode 100644 tests/unit_tests/test_lb1_protocol.py diff --git a/pufferlib/ocean/cosim/carla/README.md b/pufferlib/ocean/cosim/carla/README.md index d80b7137df..0a6f489248 100644 --- a/pufferlib/ocean/cosim/carla/README.md +++ b/pufferlib/ocean/cosim/carla/README.md @@ -8,10 +8,12 @@ Evaluate a PufferDrive-trained policy with **CaRL's unmodified `original_leaderb Zero changes to CaRL. The leaderboard imports any agent file via `--agent`, so the whole integration is this package: -- `leaderboard_agent.py` — `AutonomousAgent` subclass (entry point `PufferAgent`). - Loads a PufferDrive checkpoint, builds the shadow `Drive` env from the checkpoint's config, and every CARLA tick (the shadow env runs at the 0.05 s tick, no action repeat): overwrites ALL shadow agents (ego included) from CARLA ground truth (ego pose/velocity, nearest vehicles+walkers with true bounding boxes, traffic-light states, route goals from the leaderboard's dense global plan), recomputes observations, runs the policy, integrates one dt to produce a target (speed, yaw), and returns a `carla.VehicleControl` every tick. Get `sensor.camera.rgb` chase cam via `sensors()` — the leaderboard's own standard sensor mechanism, so this doesn't touch the read-only-w.r.t.-CARLA contract; frames stream straight to an mp4 writer. `COSIM_OBS_HTML` records the interactive observation replay (exact policy input/outputs). +- `shadow_ego.py` — `ShadowEgo`, the carla-free core shared with the Leaderboard 1.0 integration (`lb1/`). + Loads a PufferDrive checkpoint, builds the shadow `Drive` env from the checkpoint's config, calibrates the bin frame, maps lights/stop signs and route goals, and every CARLA tick (the shadow env runs at the 0.05 s tick, no action repeat): overwrites ALL shadow agents (ego included) from the CARLA-frame snapshot (ego pose/velocity, nearest vehicles+walkers with true bounding boxes, traffic-light states, route goals from the leaderboard's target points), recomputes observations, runs the policy and integrates one dt into the ego's next pose (or a target (speed, yaw) for the controller). All per-route logs (telemetry, world log, obs replay, obs dump) are written here. +- `leaderboard_agent.py` — `AutonomousAgent` subclass (entry point `PufferAgent`), the CARLA side: reads the ground truth into the snapshot, teleports the ego to the returned pose (resting it on the road mesh), runs CaRL's criteria for the `carla_*` telemetry columns, and returns a `carla.VehicleControl` every tick. Get `sensor.camera.rgb` chase cam via `sensors()` — the leaderboard's own standard sensor mechanism, so this doesn't touch the read-only-w.r.t.-CARLA contract; frames stream straight to an mp4 writer. `COSIM_OBS_HTML` records the interactive observation replay (exact policy input/outputs). - `controller.py` — `TrackingController`: convert that shadow-env kinematic target into actual throttle/brake/steer at CARLA's native tick rate +- `lb1/` — the same policy under carla_garage's Leaderboard 1.0 (CARLA 0.9.10.1, longest6 v1): a Python 3.7 client agent plus `policy_server.py` running `ShadowEgo` in the PufferDrive venv; see `lb1/README.md`. All shadow-env assumptions about the model (observation layout, dynamics model, dt, goal count/radius, reward conditioning) are adopted from the checkpoint's sibling `config.yaml` diff --git a/pufferlib/ocean/cosim/carla/controller.py b/pufferlib/ocean/cosim/carla/controller.py index d3b8ee7d7e..2bc8a20da2 100644 --- a/pufferlib/ocean/cosim/carla/controller.py +++ b/pufferlib/ocean/cosim/carla/controller.py @@ -1,9 +1,11 @@ -"""Kinematic target -> carla.VehicleControl tracking controller. +"""Kinematic target -> (steer, throttle, brake) tracking controller. The policy's jerk action is integrated by the shadow PufferDrive env into a target (speed, yaw) one policy-dt ahead; this controller makes CARLA's physics chase that target. CARLA (not the shadow env) moves the ego, so all leaderboard -collision/infraction machinery sees a normally-driven vehicle. +collision/infraction machinery sees a normally-driven vehicle. The simulator +side wraps the returned values in carla.VehicleControl; this module never +imports carla so it also runs in the PufferDrive venv behind lb1/policy_server.py. Longitudinal: PI on speed error -> throttle/brake. Lateral: desired yaw rate over the policy horizon -> inverse kinematic bicycle @@ -15,41 +17,54 @@ import math -import carla import numpy as np from pufferlib.ocean.cosim.carla_bridge import wrap_deg_180 +WHEEL_ROW_COLUMNS = 4 # max_steer_deg, position x/y/z (cm, CARLA's physics control units) +WHEEL_POSITION_CM_PER_M = 100.0 +DEFAULT_WHEELBASE_M = 2.85 # Lincoln MKZ-ish fallbacks for implausible physics controls +DEFAULT_MAX_STEER_DEG = 70.0 +WHEELBASE_RANGE_M = (1.5, 5.0) +MAX_STEER_RANGE_DEG = (10.0, 90.0) + + +def wheel_rows(vehicle): + """[[max_steer_deg, x_cm, y_cm, z_cm], ...] of a live CARLA vehicle's wheels (read-only physics control).""" + return [[w.max_steer_angle, w.position.x, w.position.y, w.position.z] for w in vehicle.get_physics_control().wheels] + + +def vehicle_geometry_from_wheels(rows): + """(wheelbase_m, max_steer_rad) from wheel_rows; defaults for anything implausible.""" + rows = np.asarray(rows, dtype=np.float64).reshape(-1, WHEEL_ROW_COLUMNS) + if len(rows) == 0: + return DEFAULT_WHEELBASE_M, math.radians(DEFAULT_MAX_STEER_DEG) + max_steer_deg = float(rows[:, 0].max()) + steering, fixed = rows[rows[:, 0] > 0.0], rows[rows[:, 0] <= 0.0] + wheelbase = DEFAULT_WHEELBASE_M + if len(steering) and len(fixed): + axle_offset_cm = steering[:, 1:].mean(axis=0) - fixed[:, 1:].mean(axis=0) + wheelbase = float(np.linalg.norm(axle_offset_cm)) / WHEEL_POSITION_CM_PER_M + if not (WHEELBASE_RANGE_M[0] <= wheelbase <= WHEELBASE_RANGE_M[1]): + wheelbase = DEFAULT_WHEELBASE_M + if not (MAX_STEER_RANGE_DEG[0] <= max_steer_deg <= MAX_STEER_RANGE_DEG[1]): + max_steer_deg = DEFAULT_MAX_STEER_DEG + return wheelbase, math.radians(max_steer_deg) + def read_vehicle_geometry(vehicle): - """(wheelbase_m, max_steer_rad) from the vehicle's physics parameters - (read-only). Falls back to Lincoln MKZ-ish defaults on any surprise.""" + """(wheelbase_m, max_steer_rad) of a live CARLA vehicle; defaults on any surprise.""" try: - phys = vehicle.get_physics_control() - wheels = phys.wheels - max_steer_deg = max(w.max_steer_angle for w in wheels) - steering = [w for w in wheels if w.max_steer_angle > 0.0] - fixed = [w for w in wheels if w.max_steer_angle <= 0.0] - if steering and fixed: - front = np.mean([[w.position.x, w.position.y, w.position.z] for w in steering], axis=0) - rear = np.mean([[w.position.x, w.position.y, w.position.z] for w in fixed], axis=0) - wheelbase = float(np.linalg.norm(front - rear)) / 100.0 # positions are in cm - else: - wheelbase = 2.85 - if not (1.5 <= wheelbase <= 5.0): - wheelbase = 2.85 - if not (10.0 <= max_steer_deg <= 90.0): - max_steer_deg = 70.0 - return wheelbase, math.radians(max_steer_deg) + return vehicle_geometry_from_wheels(wheel_rows(vehicle)) except Exception: - return 2.85, math.radians(70.0) + return DEFAULT_WHEELBASE_M, math.radians(DEFAULT_MAX_STEER_DEG) class TrackingController: def __init__( self, - wheelbase_m=2.85, - max_steer_rad=math.radians(70.0), + wheelbase_m=DEFAULT_WHEELBASE_M, + max_steer_rad=math.radians(DEFAULT_MAX_STEER_DEG), horizon_s=0.1, kp_speed=0.7, ki_speed=0.15, @@ -77,8 +92,8 @@ def reset(self): self._yaw_errors = [] def step(self, current_speed, current_yaw_deg, target_speed, target_yaw_deg, tick_dt): - """One control tick. current_* from CARLA ground truth, target_* from the - shadow env's integrated policy action (held between policy steps).""" + """One control tick -> (steer, throttle, brake). current_* from CARLA ground truth, target_* from + the shadow env's integrated policy action (held between policy steps).""" # --- longitudinal --- err = target_speed - current_speed self._speed_errors.append(err) @@ -103,7 +118,7 @@ def step(self, current_speed, current_yaw_deg, target_speed, target_yaw_deg, tic steer_angle = math.atan2(self.wheelbase * desired_yaw_rate, max(current_speed, 1.0)) steer = float(np.clip(steer_angle / self.max_steer, -1.0, 1.0)) - return carla.VehicleControl(steer=steer, throttle=throttle, brake=brake) + return steer, throttle, brake def stats(self): """Per-route tracking fidelity: mean/max absolute speed error (m/s) and diff --git a/pufferlib/ocean/cosim/carla/lb1/README.md b/pufferlib/ocean/cosim/carla/lb1/README.md new file mode 100644 index 0000000000..6c506c745b --- /dev/null +++ b/pufferlib/ocean/cosim/carla/lb1/README.md @@ -0,0 +1,97 @@ +# PufferDrive policy under carla_garage's CARLA Leaderboard 1.0 (longest6 v1) + +## Goal + +Evaluate a PufferDrive-trained policy with **carla_garage's unmodified Leaderboard 1.0** pipeline +(`leaderboard_evaluator_local.py`, CARLA 0.9.10.1, `longest6.xml` + `eval_scenarios.json`, +`BENCHMARK=longest6`) — the evaluator, scenarios, scoring and `tools/result_parser.py` that produced +the TransFuser++ longest6 numbers. The Leaderboard 2.0 counterpart (CaRL, CARLA 0.9.15) is +`../leaderboard_agent.py`; both share the same shadow env core. + +## Design + +Zero changes to carla_garage. The leaderboard imports any agent file via `--agent`; the difference to +the Leaderboard 2.0 agent is the interpreter: the `garage` conda env is **Python 3.7** (the only Python +the CARLA 0.9.10 egg ships for) and PufferDrive needs 3.9+, so one process cannot hold both. The +integration is therefore a client/server pair around the shared core: + +- `../shadow_ego.py` — `ShadowEgo`, the carla-free core used by both leaderboards: loads the checkpoint + and its `config.yaml`, builds the shadow `Drive` env (clean-eval profile, `cosim/arch.py`), calibrates + the bin frame against CARLA's driving waypoints, maps CARLA lights/stop signs onto the bin, turns the + leaderboard's target points into route goals, and per tick overwrites ALL shadow agents from the + snapshot, runs the policy and integrates one dt. All inputs/outputs are CARLA-frame plain data. +- `leaderboard_agent.py` (this directory) — the `AutonomousAgent` for the garage evaluator (entry point + `PufferAgentLB1`), Python 3.7, imports nothing from `pufferlib`. On the first tick it reads the town, the + ego's box/wheels, the route (`set_global_plan` sparse target points + the dense 1 m route), the driving + waypoints, every traffic light's stop waypoints (from the trigger volume: 0.9.10 has no + `get_stop_waypoints`) and every `traffic.stop` trigger volume, and sends them to the server. Every tick + it ships ego/partner states and light states, and teleports the ego to the returned pose + (`COSIM_DYNAMICS_SOURCE=pufferdrive`) or applies the returned throttle/brake/steer (`carla`). Chase-cam + mp4 (`COSIM_DEBUG_CARLA_VIEW`) and infraction clips (`COSIM_RECORD_INFRACTIONS`) are written here. +- `policy_server.py` — started by the agent in the PufferDrive venv (`COSIM_SERVER_PYTHON`, default + `/.venv/bin/python`), one process per route, `ShadowEgo` behind `lb1_protocol.py` + (length-prefixed JSON over an inherited socket). Telemetry, world log, obs replay and obs dump come out + of this process (same `COSIM_*` variables and file names as the Leaderboard 2.0 agent). +- `ground_truth.py` — the Leaderboard 1.0 criteria (red light, stop sign, outside lanes, collision via a + collision sensor on the client) recomputed from the client's observations, for the `carla_*` telemetry + columns and infraction clips. The leaderboard's own `result.json` is the score. + +Verified 2026-10-05 locally with `k_scaled_0040_1000` on a CARLA 0.9.10.1 offscreen-OpenGL server, policy +on the CPU at the 20 Hz tick: Town01 route 0 scenario-free completed with DS 100 / RC 100 and no +infraction, 655 s of game time in 22 min wall-clock (0.5x real time, 256 background cars), telemetry, +world log, obs replay (13096 frames) and chase-cam mp4 written, and both `analyze_carla_cosim.py` and +`render_carla_obs_html.py` accept the run. + +## How to run + +Prerequisites (this machine): CARLA 0.9.10.1 at `~/ordnung/internal/CARLA_0.9.10`, carla_garage at +`~/ordnung/internal/carla_garage_1`, the `garage` conda env (`environment.yml`, Python 3.7), the repo's +`.venv` for the server. The 0.9.10 egg links `libtiff.so.5`, which newer distros no longer ship (Ubuntu +24.04: `libtiff.so.6` only). The launcher scripts test `import carla` in the garage env and, when it fails, +first put the env's own lib dir on `LD_LIBRARY_PATH` (`conda install -n garage "libtiff<4.5"` installs a +`.so.5` there; the egg does not find it without the path) and otherwise link the system `libtiff.so.6` +under that name next to the results (`LIBTIFF5_COMPAT=0` disables the link). On a distro that still ships +`libtiff.so.5`, nothing happens. + +One route, own CARLA server (the script starts and stops it): + +```bash +CKPT=experiments/k_scaled_0040_1000/final_model.pt \ +ROUTES=~/ordnung/internal/carla_garage_1/leaderboard/data/longest6_split/longest_weathers_0.xml \ +SCENARIOS=~/ordnung/internal/carla_garage_1/leaderboard/data/scenarios/no_scenarios.json \ +bash pufferlib/ocean/cosim/carla/lb1/run_leaderboard.sh +``` + +Against a server you started yourself (`DISPLAY= ./CarlaUE4.sh -opengl -carla-rpc-port=2000`), from VS Code: +launch config "Cosim: CARLA leaderboard 1.0 agent (garage)" runs `run_evaluator.py --model-dir +--routes ...` in the garage env with outputs under `/eval/carla_leaderboard_v1/`. It points +`LD_LIBRARY_PATH` at `experiments/compat_lib` (gitignored), which holds the `libtiff.so.5` link: +`mkdir -p experiments/compat_lib && ln -s /usr/lib/x86_64-linux-gnu/libtiff.so.6 experiments/compat_lib/libtiff.so.5`. + +All 36 longest6 routes on the cluster: `scripts/kesai/14_carla_longest6_v1.sh` (one CARLA 0.9.10 server ++ evaluator per GPU over the `longest6_split` files, retries, garage's `result_parser.py` aggregation, +the nuPlan-style HTML report and the obs replay gallery, like `11_carla_longest6.sh`). + +The evaluator reads `BENCHMARK=longest6` (background traffic on every spawn point, stop-sign penalty +1.0) and `ROUTES` (names the route records) from the environment; `run_leaderboard.sh`, +`run_evaluator.py` and script 14 export them. + +## Differences to the Leaderboard 2.0 co-sim + +- Target points every **50 m** (`downsample_route(route, 50)`) instead of 200 m, so the sliding goal + window is denser; junction entries/exits and lane changes are target points in both. +- Scoring is Leaderboard 1.0's: penalties 0.50/0.60/0.65/0.70 (pedestrian/vehicle/layout/red light), + stop sign 1.0 under `BENCHMARK=longest6`, no min-speed or scenario-timeout infractions, blocked after + 180 s (`BLOCKED_THRESHOLD`), route timeout 0.8 s/m. `result.json` records carry nine infraction keys. +- The ego is `vehicle.lincoln.mkz2017`; routes carry weather. +- CARLA 0.9.10: no `World.cast_ray`, so the teleported ego rests on the lane waypoint's height and road + plane (the 0.9.15 agent additionally samples the road mesh); stop waypoints from the trigger volume. +- The `carla_*` telemetry columns come from `ground_truth.py` (straight-ahead probes in place of lane + waypoints for the stop sign, no same-lane check for the red light), not from CaRL's criteria. + +## Outputs + +As for the Leaderboard 2.0 agent: `result.json` (the leaderboard's), `telemetry/.csv`, +`world_log/.npz`, `obs_html/.replay.zlib` (rendered by +`scripts/eval/render_carla_obs_html.py`), `carla_view/.mp4`. `scripts/eval/analyze_carla_cosim.py` +builds the HTML report from a run folder holding `result.json`, `world_log/`, `telemetry/`, `carla_view/`. diff --git a/pufferlib/ocean/cosim/carla/lb1/__init__.py b/pufferlib/ocean/cosim/carla/lb1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pufferlib/ocean/cosim/carla/lb1/ground_truth.py b/pufferlib/ocean/cosim/carla/lb1/ground_truth.py new file mode 100644 index 0000000000..73da099f39 --- /dev/null +++ b/pufferlib/ocean/cosim/carla/lb1/ground_truth.py @@ -0,0 +1,178 @@ +"""CARLA-side ground truth of the Leaderboard 1.0 criteria, recomputed in the policy server from what the +Python 3.7 client observes (it cannot run CaRL's criteria the Leaderboard 2.0 agent uses). Feeds the +carla_* telemetry columns and the infraction clips; the leaderboard's own result.json stays the score. + +Per-tick observation sent by lb1/leaderboard_agent.py (CARLA frame): + {"collision": [other actor type ids since the last tick], + "lane": [driving_distance_m, driving_lane_width_m, parking_distance_m (-1: no parking lane), + parking_lane_width_m, driving_lane_yaw_deg, driving_is_junction]} +plus the snapshot's ego state. Ports of scenario_runner's RunningRedLightTest (tail line crossing the +0.4-lane-width stop line of a red light within 15 m, once per light), RunningStopTest (entered a stop +trigger volume, left its 20 m influence without a standstill) and OutsideRouteLanesTest (farther than +half a lane plus 1.3 m from both the driving and the parking lane, or facing more than 120 deg away from +a non-junction lane). +""" + +import math + +from pufferlib.ocean.cosim.carla.shadow_ego import STATE_X, STATE_Y, STATE_YAW_DEG, ego_speed_from_state + + +RED_LIGHT_DISTANCE_M = 15.0 # RunningRedLightTest.DISTANCE_LIGHT +STOP_LINE_HALF_WIDTH_FRACTION = 0.4 # of the lane width, RunningRedLightTest +TAIL_CLOSE_FRACTION = 0.8 # tail line from 0.8 * extent to extent + 1 m behind the ego centre +TAIL_FAR_MARGIN_M = 1.0 +STOP_SIGN_SPEED_THRESHOLD_MPS = 0.1 # RunningStopTest.SPEED_THRESHOLD +STOP_SIGN_PROXIMITY_M = 50.0 # RunningStopTest.PROXIMITY_THRESHOLD +STOP_SIGN_LOOKAHEAD_STEPS = 20 # RunningStopTest: 20 waypoints, WAYPOINT_STEP 1 m +STOP_SIGN_LOOKAHEAD_STEP_M = 1.0 +OUTSIDE_LANE_ALLOWED_DISTANCE_M = 1.3 # OutsideRouteLanesTest.ALLOWED_OUT_DISTANCE +WRONG_LANE_MAX_ANGLE_DEG = 120.0 # OutsideRouteLanesTest.MAX_ALLOWED_VEHICLE_ANGLE +( + LANE_DRIVING_DISTANCE, + LANE_DRIVING_WIDTH, + LANE_PARKING_DISTANCE, + LANE_PARKING_WIDTH, + LANE_YAW_DEG, + LANE_IS_JUNCTION, +) = range(6) + + +def segments_intersect(a0, a1, b0, b1): + """Proper or touching intersection of segments a0-a1 and b0-b1 (2-D).""" + + def orientation(p, q, r): + return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) + + def on_segment(p, q, r): + return min(p[0], q[0]) <= r[0] <= max(p[0], q[0]) and min(p[1], q[1]) <= r[1] <= max(p[1], q[1]) + + o1, o2 = orientation(a0, a1, b0), orientation(a0, a1, b1) + o3, o4 = orientation(b0, b1, a0), orientation(b0, b1, a1) + if (o1 > 0) != (o2 > 0) and (o3 > 0) != (o4 > 0) and o1 != 0 and o2 != 0 and o3 != 0 and o4 != 0: + return True + if o1 == 0 and on_segment(a0, a1, b0): + return True + if o2 == 0 and on_segment(a0, a1, b1): + return True + if o3 == 0 and on_segment(b0, b1, a0): + return True + return o4 == 0 and on_segment(b0, b1, a1) + + +def point_inside_box(point, center, extent): + """RunningStopTest.point_inside_boundingbox: axis-aligned test, the trigger volume's rotation ignored.""" + return abs(point[0] - center[0]) < extent[0] and abs(point[1] - center[1]) < extent[1] + + +class LeaderboardOneGroundTruth: + def __init__(self, lights, stop_signs, ego_extent_x): + """lights / stop_signs: the init_route geometry dicts (carla_bridge.light_geometry_from_carla / + stop_sign_geometry_from_carla layout), ego_extent_x: half length of the ego's bounding box.""" + self.stop_lines_by_light = {} + self.trigger_by_light = {} + for light in lights: + lines = [] + for stop_wp in light["stop_waypoints"]: + yaw = math.radians(stop_wp["yaw_deg"]) + half_width = STOP_LINE_HALF_WIDTH_FRACTION * stop_wp["lane_width"] + across = (-math.sin(yaw) * half_width, math.cos(yaw) * half_width) + left = (stop_wp["x"] + across[0], stop_wp["y"] + across[1]) + right = (stop_wp["x"] - across[0], stop_wp["y"] - across[1]) + lines.append(((math.cos(yaw), math.sin(yaw)), left, right)) + self.stop_lines_by_light[int(light["id"])] = lines + self.trigger_by_light[int(light["id"])] = (light["trigger_x"], light["trigger_y"]) + self.stop_signs = [(sign["center"][:2], sign["extent"][:2]) for sign in stop_signs] + self.ego_extent_x = float(ego_extent_x) + self.last_red_light_id = None + self.target_stop_sign = None + self.stop_completed = False + self.affected_by_stop = False + + def update(self, snapshot, observation): + """-> {'collision', 'offroad', 'red_light', 'stop_sign'} flags (1.0 fired this tick) from one snapshot.""" + ego = [float(v) for v in snapshot["ego"]] + light_states = {int(light_id): state_name for light_id, state_name in snapshot["light_states"]} + return { + "collision": float(bool(observation.get("collision"))), + "offroad": float(self._outside_lanes(ego, observation["lane"])), + "red_light": float(self._ran_red_light(ego, light_states)), + "stop_sign": float(self._ran_stop_sign(ego, observation["lane"])), + } + + def _ran_red_light(self, ego, light_states): + yaw = math.radians(ego[STATE_YAW_DEG]) + forward = (math.cos(yaw), math.sin(yaw)) + x, y = ego[STATE_X], ego[STATE_Y] + tail_close = ( + x - TAIL_CLOSE_FRACTION * self.ego_extent_x * forward[0], + y - TAIL_CLOSE_FRACTION * self.ego_extent_x * forward[1], + ) + tail_far_m = self.ego_extent_x + TAIL_FAR_MARGIN_M + tail_far = (x - tail_far_m * forward[0], y - tail_far_m * forward[1]) + for light_id, lines in self.stop_lines_by_light.items(): + if light_states.get(light_id) != "Red" or light_id == self.last_red_light_id: + continue + trigger_x, trigger_y = self.trigger_by_light[light_id] + if math.hypot(trigger_x - x, trigger_y - y) > RED_LIGHT_DISTANCE_M: + continue + for lane_forward, left, right in lines: + if forward[0] * lane_forward[0] + forward[1] * lane_forward[1] <= 0.0: + continue # this stop line does not face our lane + if segments_intersect(tail_close, tail_far, left, right): + self.last_red_light_id = light_id + return True + return False + + def _affected_by_stop_sign(self, ego, sign): + """RunningStopTest.is_actor_affected_by_stop with straight-ahead probes in place of lane waypoints.""" + center, extent = sign + x, y = ego[STATE_X], ego[STATE_Y] + if math.hypot(center[0] - x, center[1] - y) > STOP_SIGN_PROXIMITY_M: + return False + yaw = math.radians(ego[STATE_YAW_DEG]) + for k in range(STOP_SIGN_LOOKAHEAD_STEPS + 1): + probe = ( + x + k * STOP_SIGN_LOOKAHEAD_STEP_M * math.cos(yaw), + y + k * STOP_SIGN_LOOKAHEAD_STEP_M * math.sin(yaw), + ) + if point_inside_box(probe, center, extent): + return True + return False + + def _ran_stop_sign(self, ego, lane): + if self.target_stop_sign is None: + lane_yaw = math.radians(lane[LANE_YAW_DEG]) + yaw = math.radians(ego[STATE_YAW_DEG]) + if math.cos(yaw - lane_yaw) <= 0.0: + return False # ignore all when going in a wrong lane + for sign in self.stop_signs: + if self._affected_by_stop_sign(ego, sign): + self.target_stop_sign = sign + break + return False + if not self.stop_completed and ego_speed_from_state(ego) < STOP_SIGN_SPEED_THRESHOLD_MPS: + self.stop_completed = True + center, extent = self.target_stop_sign + if not self.affected_by_stop and point_inside_box((ego[STATE_X], ego[STATE_Y]), center, extent): + self.affected_by_stop = True + if self._affected_by_stop_sign(ego, self.target_stop_sign): + return False + ran = self.affected_by_stop and not self.stop_completed + self.target_stop_sign = None + self.stop_completed = False + self.affected_by_stop = False + return ran + + def _outside_lanes(self, ego, lane): + driving_distance, parking_distance = lane[LANE_DRIVING_DISTANCE], lane[LANE_PARKING_DISTANCE] + if parking_distance >= 0.0 and driving_distance >= parking_distance: + distance, lane_width = parking_distance, lane[LANE_PARKING_WIDTH] + else: + distance, lane_width = driving_distance, lane[LANE_DRIVING_WIDTH] + outside = distance > lane_width / 2.0 + OUTSIDE_LANE_ALLOWED_DISTANCE_M + if lane[LANE_IS_JUNCTION] > 0.0: + return outside # lanes and roads are too chaotic at junctions + angle = (lane[LANE_YAW_DEG] - ego[STATE_YAW_DEG]) % 360.0 + wrong_lane = WRONG_LANE_MAX_ANGLE_DEG <= angle <= 360.0 - WRONG_LANE_MAX_ANGLE_DEG + return outside or wrong_lane diff --git a/pufferlib/ocean/cosim/carla/lb1/lb1_protocol.py b/pufferlib/ocean/cosim/carla/lb1/lb1_protocol.py new file mode 100644 index 0000000000..184d9379ff --- /dev/null +++ b/pufferlib/ocean/cosim/carla/lb1/lb1_protocol.py @@ -0,0 +1,57 @@ +"""Length-prefixed JSON messages between lb1/leaderboard_agent.py (CARLA 0.9.10, Python 3.7) and +lb1/policy_server.py (PufferDrive venv). Standard library only: the agent imports this file as a +sibling module (the leaderboard puts the agent's directory on sys.path), the server as a package module. + +Handshake, then one request/reply pair per CARLA tick: + server -> client {"type": "config", "partner_slots", "partner_max_abs_dz_m", "light_probe_steps_m", + "dynamics_source", "infraction_flags"} + client -> server {"type": "init", "route": shadow_ego.init_route dict} -> {"type": "ready"} + client -> server {"type": "tick", "snapshot": shadow_ego.step dict, + "ground_truth": lb1/ground_truth.py observation or null} -> {"type": "control", + "motion": [...] | null, "control": [steer, throttle, brake] | null, + "pd_flags": {...} | null, "carla_flags": {...} | null} + client -> server {"type": "finish"} -> {"type": "done"} +""" + +import json +import struct + + +HEADER = struct.Struct("!I") +MAX_MESSAGE_BYTES = 256 * 1024 * 1024 +RECV_CHUNK_BYTES = 1 << 20 + + +def send_message(sock, message): + payload = json.dumps(message, separators=(",", ":")).encode("utf-8") + if len(payload) > MAX_MESSAGE_BYTES: + raise ValueError(f"message of {len(payload)} bytes exceeds MAX_MESSAGE_BYTES") + sock.sendall(HEADER.pack(len(payload)) + payload) + + +def recv_message(sock): + """The next message, or None once the peer closed the connection between messages.""" + header = _recv_exact(sock, HEADER.size) + if header is None: + return None + (length,) = HEADER.unpack(header) + if length > MAX_MESSAGE_BYTES: + raise ValueError(f"incoming message of {length} bytes exceeds MAX_MESSAGE_BYTES") + payload = _recv_exact(sock, length) + if payload is None: + raise ConnectionError("peer closed the connection inside a message") + return json.loads(payload.decode("utf-8")) + + +def _recv_exact(sock, byte_count): + chunks = [] + remaining = byte_count + while remaining > 0: # every pass consumes >= 1 byte or returns + chunk = sock.recv(min(remaining, RECV_CHUNK_BYTES)) + if not chunk: + if chunks: + raise ConnectionError("peer closed the connection inside a message") + return None + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) diff --git a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py new file mode 100644 index 0000000000..71d330daca --- /dev/null +++ b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -0,0 +1,540 @@ +"""PufferDrive policy as a CARLA Leaderboard 1.0 agent (carla_garage's leaderboard_evaluator_local.py, +CARLA 0.9.10.1, longest6 v1). + +The leaderboard runs in carla_garage's `garage` conda env: Python 3.7, the only Python the CARLA 0.9.10 +egg supports, and one that cannot import PufferDrive (Python >= 3.9). This file is therefore the CARLA +side only -- the counterpart of ../leaderboard_agent.py's CARLA code: it reads ground truth (ego, nearby +actors, lights, stop signs, the route), ships it over a socket to lb1/policy_server.py (started here in +the PufferDrive venv, one process per route, shadow_ego.ShadowEgo inside) and moves the ego the way the +reply says. Nothing from `pufferlib` is imported here; lb1_protocol.py is a sibling module. + +Usage (BENCHMARK=longest6 and ROUTES= exported, see run_leaderboard.sh / 14_carla_longest6_v1.sh): + + python $GARAGE_WORK_DIR/leaderboard/leaderboard/leaderboard_evaluator_local.py \ + --routes $GARAGE_WORK_DIR/leaderboard/data/longest6_split/longest_weathers_0.xml \ + --scenarios $GARAGE_WORK_DIR/leaderboard/data/scenarios/eval_scenarios.json \ + --agent /path/to/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py \ + --agent-config /path/to/experiments//final_model.pt \ + --checkpoint /path/to/results/result.json --track MAP --timeout 600 + +Environment variables (this process; the shadow env's COSIM_DEVICE ... COSIM_WORLD_LOG are read by +policy_server.py from the inherited environment, see shadow_ego.py): + SCENARIO_RUNNER_ROOT REQUIRED: carla_garage's scenario_runner checkout + COSIM_SERVER_PYTHON python of the PufferDrive venv that runs policy_server.py + (default: /.venv/bin/python) + COSIM_DEBUG_CARLA_VIEW=/dir write a CARLA chase-camera mp4 per route (native tick rate) + COSIM_RECORD_INFRACTIONS=/dir write a short chase-cam clip (last ~5 s) per ego infraction flagged by + the shadow env (pd_*) or by the Leaderboard 1.0 criteria recomputed in + the server (carla_*, lb1/ground_truth.py) + +CARLA 0.9.10 differences handled here: no World.cast_ray (the teleported ego rests on the lane waypoint's +height and road plane), no TrafficLight.get_stop_waypoints (stop waypoints come from the trigger volume, +RunningRedLightTest's own recipe). +""" + +import math +import os +import re +import socket +import subprocess +from collections import deque +from datetime import datetime +from pathlib import Path + +import carla +import lb1_protocol +import numpy as np +from leaderboard.autoagents import autonomous_agent +from srunner.scenariomanager.carla_data_provider import CarlaDataProvider + + +REPO_ROOT = Path(__file__).resolve().parents[5] +POLICY_SERVER = Path(__file__).resolve().with_name("policy_server.py") +DEFAULT_SERVER_PYTHON = REPO_ROOT / ".venv" / "bin" / "python" +SERVER_ENV_DROPPED = ("LD_LIBRARY_PATH", "PYTHONPATH", "PYTHONHOME") # the garage env's libs/paths break the venv +PROTOCOL_TIMEOUT_S = 600.0 # one reply per tick; the evaluator's own agent watchdog is --timeout (600 s) +SERVER_EXIT_TIMEOUT_S = 60.0 + +INFRACTION_CLIP_SECONDS = 5.0 +INFRACTION_MIN_SEPARATION_M = 10.0 +CARLA_VIEW_SENSOR_ID = "puffer_chase_cam" +CARLA_VIEW_WIDTH, CARLA_VIEW_HEIGHT, CARLA_VIEW_FOV = 960, 540, 90 +CARLA_VIEW_TRANSFORM = dict(x=-2.0, y=0.0, z=2.1, roll=0.0, pitch=-15.0, yaw=0.0) # within the 3 m sensor radius +MP4_FOURCC = "mp4v" + +OFFSET_CALIBRATION_SPACING_M = 10.0 # carla_bridge.driving_waypoint_samples +TRIGGER_SAMPLE_SPAN_FRACTION = 0.9 # RunningRedLightTest.get_traffic_light_waypoints: avoids adjacent lanes +TRIGGER_SAMPLE_STEP_M = 1.0 +JUNCTION_ADVANCE_STEP_M = 0.5 +MAX_JUNCTION_ADVANCE_STEPS = 400 # 200 m: a stop waypoint farther from its junction is a map error +NO_LIGHT_ID = -1 +NO_PARKING_LANE = -1.0 + + +def get_entry_point(): + return "PufferAgentLB1" + + +def wrap_deg_180(d): + return (d + 180.0) % 360.0 - 180.0 + + +def actor_state_row(actor): + """shadow_ego ACTOR_STATE of a live CARLA actor (CARLA frame).""" + tf = actor.get_transform() + v = actor.get_velocity() + av = actor.get_angular_velocity() + acc = actor.get_acceleration() + return [tf.location.x, tf.location.y, tf.location.z, tf.rotation.yaw, v.x, v.y, v.z, av.z, acc.x, acc.y] + + +def partner_row(actor): + """shadow_ego PARTNER_ROW of a live CARLA vehicle or walker.""" + box = actor.bounding_box + is_walker = 1.0 if "walker" in actor.type_id else 0.0 + return actor_state_row(actor) + [box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, actor.id] + + +def plan_xyz(plan): + """[(carla.Transform, RoadOption)] -> [[x, y, z], ...] CARLA-frame positions.""" + return [[t.location.x, t.location.y, t.location.z] for t, _ in plan] + + +def road_aligned_attitude(road_up, yaw_deg): + """(pitch_deg, roll_deg, unit forward) of a body heading yaw_deg that lies flat on the road plane with normal road_up.""" + normal = np.array([road_up.x, road_up.y, road_up.z], dtype=np.float64) + yaw_rad = math.radians(yaw_deg) + forward = np.array([math.cos(yaw_rad), math.sin(yaw_rad), 0.0]) + forward -= normal * float(forward @ normal) + forward /= np.linalg.norm(forward) + right = np.cross(normal, forward) # CARLA is left-handed: right = up x forward + pitch_deg = math.degrees(math.asin(forward[2])) + roll_deg = math.degrees(math.asin(-right[2])) # positive roll = right side down + return pitch_deg, roll_deg, forward + + +def driving_waypoint_samples(carla_map): + """[[x, y, z, right_x, right_y], ...] of the non-junction driving waypoints (carla_bridge.driving_waypoint_samples).""" + rows = [] + for wp in carla_map.generate_waypoints(OFFSET_CALIBRATION_SPACING_M): + if wp.is_junction or str(wp.lane_type) != "Driving": + continue + loc, right = wp.transform.location, wp.transform.get_right_vector() + rows.append([loc.x, loc.y, loc.z, right.x, right.y]) + return rows + + +def light_stop_waypoints(light, carla_map): + """Stop waypoints of a traffic light from its trigger volume: one waypoint per lane the volume spans, + advanced to the junction entry (RunningRedLightTest.get_traffic_light_waypoints; CARLA >= 0.9.11 + exposes the same list as TrafficLight.get_stop_waypoints).""" + base_transform = light.get_transform() + trigger = light.trigger_volume + lane_waypoints = [] + for x in np.arange( + -TRIGGER_SAMPLE_SPAN_FRACTION * trigger.extent.x, + TRIGGER_SAMPLE_SPAN_FRACTION * trigger.extent.x, + TRIGGER_SAMPLE_STEP_M, + ): + point = base_transform.transform(trigger.location + carla.Location(x=float(x))) + wp = carla_map.get_waypoint(point) + if wp is None: + continue + if not lane_waypoints or lane_waypoints[-1].road_id != wp.road_id or lane_waypoints[-1].lane_id != wp.lane_id: + lane_waypoints.append(wp) + stop_waypoints = [] + for wp in lane_waypoints: + for _ in range(MAX_JUNCTION_ADVANCE_STEPS): + if wp.is_junction: + break + ahead = wp.next(JUNCTION_ADVANCE_STEP_M) + if not ahead or ahead[0].is_junction: + break + wp = ahead[0] + stop_waypoints.append(wp) + return stop_waypoints + + +def light_geometry(lights, carla_map, probe_steps_m): + """carla_bridge.light_geometry_from_carla layout, built with the 0.9.10 API (probe steps from the server's config).""" + geometry = [] + for light in lights: + location = light.get_location() + trigger = light.get_transform().transform(light.trigger_volume.location) + stop_waypoints = [] + for wp in light_stop_waypoints(light, carla_map): + backward, forward = [], [] + for step_m in probe_steps_m: + probes = [wp] if step_m == 0.0 else wp.previous(step_m) + backward.append( + None if not probes else [probes[0].transform.location.x, probes[0].transform.location.y] + ) + for step_m in probe_steps_m[1:]: + probes = wp.next(step_m) or [] + forward.append( + None + if not probes + else [ + probes[0].transform.location.x, + probes[0].transform.location.y, + probes[0].transform.rotation.yaw, + ] + ) + stop_waypoints.append( + { + "x": wp.transform.location.x, + "y": wp.transform.location.y, + "yaw_deg": wp.transform.rotation.yaw, + "lane_width": wp.lane_width, + "backward": backward, + "forward": forward, + } + ) + geometry.append( + { + "id": light.id, + "x": location.x, + "y": location.y, + "trigger_x": trigger.x, + "trigger_y": trigger.y, + "stop_waypoints": stop_waypoints, + } + ) + return geometry + + +def stop_sign_geometry(world, carla_map): + """carla_bridge.stop_sign_geometry_from_carla layout, built with the 0.9.10 API.""" + geometry = [] + for actor in world.get_actors().filter("traffic.stop"): + actor_transform = actor.get_transform() + trigger = actor.trigger_volume + center = actor_transform.transform(trigger.location) + waypoint = carla_map.get_waypoint(center, project_to_road=True, lane_type=carla.LaneType.Driving) + if waypoint is None: + continue + forward = waypoint.transform.get_forward_vector() + lane_location = waypoint.transform.location + geometry.append( + { + "id": actor.id, + "center": [center.x, center.y, center.z], + "extent": [trigger.extent.x, trigger.extent.y, trigger.extent.z], + "yaw_deg": actor_transform.rotation.yaw, + "lane": [ + lane_location.x, + lane_location.y, + lane_location.z, + math.degrees(math.atan2(forward.y, forward.x)), + ], + } + ) + return geometry + + +class Mp4Writer: + """Streams RGB uint8 frames to an mp4 via OpenCV (carla_cosim.Mp4Writer, importable here).""" + + def __init__(self, out_path, fps): + self.out_path = str(out_path) + self.fps = float(fps) + self.writer = None + + def append_data(self, frame): + import cv2 + + frame = np.ascontiguousarray(frame) + if self.writer is None: + height, width = frame.shape[:2] + self.writer = cv2.VideoWriter(self.out_path, cv2.VideoWriter_fourcc(*MP4_FOURCC), self.fps, (width, height)) + if not self.writer.isOpened(): + raise RuntimeError(f"Mp4Writer({self.out_path}): OpenCV could not open the video writer") + self.writer.write(cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)) + + def close(self): + if self.writer is not None: + self.writer.release() + self.writer = None + + +def write_mp4(out_path, frames, fps): + writer = Mp4Writer(out_path, fps) + for frame in frames: + writer.append_data(frame) + writer.close() + + +class PolicyServerLink: + """policy_server.py as a child process in the PufferDrive venv, one request/reply per message.""" + + def __init__(self, checkpoint, route_tag): + python = os.environ.get("COSIM_SERVER_PYTHON", str(DEFAULT_SERVER_PYTHON)) + if not Path(python).is_file(): + raise FileNotFoundError(f"COSIM_SERVER_PYTHON={python} does not exist (the PufferDrive venv python)") + self.sock, child_sock = socket.socketpair() + self.sock.settimeout(PROTOCOL_TIMEOUT_S) + env = {k: v for k, v in os.environ.items() if k not in SERVER_ENV_DROPPED} + env["PYTHONPATH"] = str(REPO_ROOT) + env["PYTHONUNBUFFERED"] = "1" # the server shares the evaluator's log file + command = [ + python, + str(POLICY_SERVER), + "--checkpoint", + checkpoint, + "--socket-fd", + str(child_sock.fileno()), + "--route-tag", + route_tag, + ] + self.process = subprocess.Popen(command, cwd=str(REPO_ROOT), env=env, pass_fds=(child_sock.fileno(),)) + child_sock.close() + self.config = self._receive() + if self.config.get("type") != "config": + raise RuntimeError(f"policy_server.py handshake failed: {self.config!r}") + + def _receive(self): + try: + message = lb1_protocol.recv_message(self.sock) + except (ConnectionError, socket.timeout) as error: # noqa: UP041 (alias of TimeoutError only from 3.10) + raise RuntimeError(f"policy_server.py connection lost ({error}, exit code {self.process.poll()!r})") + if message is None: + raise RuntimeError(f"policy_server.py exited (code {self.process.poll()!r}) without replying") + return message + + def request(self, message): + lb1_protocol.send_message(self.sock, message) + return self._receive() + + def close(self): + self.sock.close() + try: + self.process.wait(timeout=SERVER_EXIT_TIMEOUT_S) + except subprocess.TimeoutExpired: + self.process.kill() + print("[puffer_agent_lb1] policy_server.py did not exit; killed", flush=True) + + +class PufferAgentLB1(autonomous_agent.AutonomousAgent): + def setup(self, path_to_conf_file, route_index=None): + if "SCENARIO_RUNNER_ROOT" not in os.environ: + raise RuntimeError("SCENARIO_RUNNER_ROOT is not set; export carla_garage's scenario_runner checkout") + self.track = autonomous_agent.Track.MAP + route_index = re.sub(r"[^\w.-]", "_", str(route_index)) if route_index else "route" + self.route_tag = f"{route_index}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" + self.debug_carla_view_dir = os.environ.get("COSIM_DEBUG_CARLA_VIEW", None) + self.record_infractions_dir = os.environ.get("COSIM_RECORD_INFRACTIONS", None) + self.ground_truth = bool(self.record_infractions_dir or os.environ.get("COSIM_TELEMETRY")) + self.server = PolicyServerLink(str(Path(path_to_conf_file).resolve()), self.route_tag) + self.partner_slots = int(self.server.config["partner_slots"]) + self.partner_max_abs_dz_m = float(self.server.config["partner_max_abs_dz_m"]) + self.light_probe_steps_m = [float(v) for v in self.server.config["light_probe_steps_m"]] + self.dense_global_plan_world_coord = None + self.step = -1 + self.initialized = False + self.carla_view_writer = None + self._collision_sensor = None + self._collision_events = [] + + def sensors(self): + if not self.debug_carla_view_dir and not self.record_infractions_dir: + return [] + sensor = { + "type": "sensor.camera.rgb", + "id": CARLA_VIEW_SENSOR_ID, + "width": CARLA_VIEW_WIDTH, + "height": CARLA_VIEW_HEIGHT, + "fov": CARLA_VIEW_FOV, + } + sensor.update(CARLA_VIEW_TRANSFORM) + return [sensor] + + def set_global_plan(self, global_plan_gps, global_plan_world_coord): + """The base class keeps only the 50 m target points; the dense 1 m route gives the goals their direction.""" + super().set_global_plan(global_plan_gps, global_plan_world_coord) + self.dense_global_plan_world_coord = global_plan_world_coord + + def _init_on_first_step(self): + """Deferred init (the ego and world only exist once the route runs); read-only with respect to CARLA.""" + self.vehicle = CarlaDataProvider.get_hero_actor() + self.world = self.vehicle.get_world() + self.cmap = self.world.get_map() + town = CarlaDataProvider.get_map().name.split("/")[-1] + self.tick_dt = float(self.world.get_settings().fixed_delta_seconds) + self.lights = list(self.world.get_actors().filter("traffic.traffic_light")) + body = self.vehicle.bounding_box + wheels = [ + [w.max_steer_angle, w.position.x, w.position.y, w.position.z] + for w in self.vehicle.get_physics_control().wheels + ] + route = { + "town": town, + "tick_dt": self.tick_dt, + "ego": { + "extent": [body.extent.x, body.extent.y, body.extent.z], + "box_z": body.location.z, + "wheels": wheels, + "state": actor_state_row(self.vehicle), + }, + "sparse_plan": plan_xyz(self._global_plan_world_coord), + "dense_plan": plan_xyz(self.dense_global_plan_world_coord), + "driving_waypoints": driving_waypoint_samples(self.cmap), + "lights": light_geometry(self.lights, self.cmap, self.light_probe_steps_m), + "stop_signs": stop_sign_geometry(self.world, self.cmap), + "infraction_flags": bool(self.record_infractions_dir), + "ground_truth": self.ground_truth, + } + reply = self.server.request({"type": "init", "route": route}) + if reply.get("type") != "ready": + raise RuntimeError(f"policy_server.py did not become ready: {reply!r}") + + if self.debug_carla_view_dir: + Path(self.debug_carla_view_dir).mkdir(parents=True, exist_ok=True) + out = str(Path(self.debug_carla_view_dir) / f"{self.route_tag}.mp4") + self.carla_view_writer = Mp4Writer(out, fps=round(1.0 / self.tick_dt)) + if self.record_infractions_dir: + Path(self.record_infractions_dir).mkdir(parents=True, exist_ok=True) + self.infraction_buffer = deque(maxlen=int(INFRACTION_CLIP_SECONDS / self.tick_dt)) + self.infraction_counter = 0 + self.last_infraction_location = self.vehicle.get_location() + if self.ground_truth: + blueprint = self.world.get_blueprint_library().find("sensor.other.collision") + self._collision_sensor = self.world.spawn_actor(blueprint, carla.Transform(), attach_to=self.vehicle) + self._collision_sensor.listen(self._on_collision) + print( + f"[puffer_agent_lb1] town={town} tick_dt={self.tick_dt:.3f} lights={len(self.lights)} " + f"stop_signs={len(route['stop_signs'])} target_points={len(route['sparse_plan'])} " + f"dense={len(route['dense_plan'])}", + flush=True, + ) + self.initialized = True + + def _on_collision(self, event): + self._collision_events.append(event.other_actor.type_id) + + # --- CARLA ground truth for the shadow env (read-only w.r.t. CARLA) ---- + + def _nearby_actors(self): + ego_loc = self.vehicle.get_location() + candidates = [] + for a in self.world.get_actors(): + if a.id == self.vehicle.id or not ("vehicle" in a.type_id or "walker.pedestrian" in a.type_id): + continue + loc = a.get_location() + # scenario_runner parks pending scenario actors hundreds of metres underground at the ego's waypoint + if abs(loc.z - ego_loc.z) > self.partner_max_abs_dz_m: + continue + candidates.append((loc.distance(ego_loc), a)) + candidates.sort(key=lambda item: item[0]) + return [a for _, a in candidates[: self.partner_slots]] + + def _snapshot(self): + ego_light = self.vehicle.get_traffic_light() + return { + "ego": actor_state_row(self.vehicle), + "partners": [partner_row(a) for a in self._nearby_actors()], + "light_states": [[lt.id, lt.get_state().name] for lt in self.lights], + "ego_light_id": NO_LIGHT_ID if ego_light is None else ego_light.id, + } + + def _ground_truth_observation(self): + """lb1/ground_truth.py's per-tick input: collisions since the last tick and the ego's lane situation.""" + collisions, self._collision_events = self._collision_events, [] + location = self.vehicle.get_location() + driving = self.cmap.get_waypoint(location, project_to_road=True, lane_type=carla.LaneType.Driving) + parking = self.cmap.get_waypoint(location, project_to_road=True, lane_type=carla.LaneType.Parking) + parking_distance = NO_PARKING_LANE if parking is None else location.distance(parking.transform.location) + return { + "collision": collisions, + "lane": [ + location.distance(driving.transform.location), + driving.lane_width, + parking_distance, + 0.0 if parking is None else parking.lane_width, + driving.transform.rotation.yaw, + 1.0 if driving.is_junction else 0.0, + ], + } + + def _teleport_carla_ego(self, motion): + """Place the CARLA ego at the shadow env's post-step pose (shadow_ego.StepResult.motion), resting on the + lane waypoint's height and road plane (0.9.10 has no road-mesh raycast).""" + _, _, yaw0_deg, _, x, y, yaw_deg, speed, carla_z = motion + yaw_delta_deg = wrap_deg_180(yaw_deg - yaw0_deg) + wp = self.cmap.get_waypoint(carla.Location(x=x, y=y, z=carla_z)) + z = wp.transform.location.z if wp is not None else carla_z + road_up = wp.transform.rotation.get_up_vector() if wp is not None else carla.Vector3D(x=0.0, y=0.0, z=1.0) + pitch_deg, roll_deg, forward = road_aligned_attitude(road_up, yaw_deg) + # Zero momentum before the teleport: CARLA's collision resolver reacts violently to a + # physics body carrying velocity into a new pose (carla issue #8076). + zero = carla.Vector3D(x=0.0, y=0.0, z=0.0) + self.vehicle.set_target_velocity(zero) + self.vehicle.set_target_angular_velocity(zero) + self.vehicle.set_transform( + carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=roll_deg)) + ) + self.vehicle.set_target_velocity( + carla.Vector3D(x=speed * float(forward[0]), y=speed * float(forward[1]), z=speed * float(forward[2])) + ) + self.vehicle.set_target_angular_velocity(carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.tick_dt)) + + def _maybe_save_infraction_clip(self, pd_flags, carla_flags): + """One mp4 of the rolling chase-cam buffer per flagged ego infraction, at most once per + INFRACTION_MIN_SEPARATION_M of ego travel (as ../leaderboard_agent.py).""" + fired = [f"pd_{name}" for name, value in (pd_flags or {}).items() if value > 0.0] + fired += [f"carla_{name}" for name, value in (carla_flags or {}).items() if value > 0.0] + if not fired or not self.infraction_buffer: + return + location = self.vehicle.get_location() + if location.distance(self.last_infraction_location) <= INFRACTION_MIN_SEPARATION_M: + return + out = str( + Path(self.record_infractions_dir) / f"{self.route_tag}_{'_'.join(fired)}_{self.infraction_counter:02d}.mp4" + ) + write_mp4(out, list(self.infraction_buffer), fps=round(1.0 / self.tick_dt)) + print(f"[puffer_agent_lb1] infraction {fired} -> {out}", flush=True) + self.infraction_counter += 1 + self.last_infraction_location = location + + def run_step(self, input_data, timestamp, sensors=None): + self.step += 1 + if not self.initialized: + self._init_on_first_step() + return carla.VehicleControl(steer=0.0, throttle=0.0, brake=1.0) + + if CARLA_VIEW_SENSOR_ID in input_data: + _, bgra = input_data[CARLA_VIEW_SENSOR_ID] # (H, W, 4) uint8, leaderboard's CallBack format + rgb = bgra[:, :, [2, 1, 0]] + if self.carla_view_writer is not None: + self.carla_view_writer.append_data(rgb) + if self.record_infractions_dir: + self.infraction_buffer.append(rgb.copy()) + + message = {"type": "tick", "snapshot": self._snapshot(), "ground_truth": None} + if self.ground_truth: + message["ground_truth"] = self._ground_truth_observation() + reply = self.server.request(message) + if reply.get("motion") is not None: + self._teleport_carla_ego(reply["motion"]) + if self.record_infractions_dir: + self._maybe_save_infraction_clip(reply.get("pd_flags"), reply.get("carla_flags")) + if reply.get("control") is None: + return carla.VehicleControl() + steer, throttle, brake = reply["control"] + return carla.VehicleControl(steer=steer, throttle=throttle, brake=brake) + + def destroy(self, results=None): + if not hasattr(self, "server"): + return + if self.initialized: + reply = self.server.request({"type": "finish"}) + if reply.get("type") != "done": + print(f"[puffer_agent_lb1] unexpected finish reply {reply!r}", flush=True) + self.server.close() + if self.carla_view_writer is not None: + self.carla_view_writer.close() + print(f"[puffer_agent_lb1] wrote CARLA chase-cam video for route {self.route_tag}", flush=True) + if self._collision_sensor is not None: + self._collision_sensor.stop() + self._collision_sensor.destroy() + self._collision_sensor = None + self.initialized = False diff --git a/pufferlib/ocean/cosim/carla/lb1/policy_server.py b/pufferlib/ocean/cosim/carla/lb1/policy_server.py new file mode 100644 index 0000000000..6aec7b741d --- /dev/null +++ b/pufferlib/ocean/cosim/carla/lb1/policy_server.py @@ -0,0 +1,99 @@ +"""Shadow env + policy process behind lb1/leaderboard_agent.py (CARLA Leaderboard 1.0, CARLA 0.9.10). + +Started by the agent in the PufferDrive venv, one process per route, talking lb1_protocol over an +inherited socket: `init` builds the ShadowEgo for the route, every `tick` runs one policy step and +answers with the ego command (teleport pose or control values) plus both infraction sources, +`finish` flushes the per-route outputs. Options come from the COSIM_* environment variables +(shadow_ego.py), inherited from the evaluator. + +usage: policy_server.py --checkpoint <.pt or run dir> --socket-fd --route-tag +""" + +import argparse +import socket +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[5] +sys.path.insert(0, str(REPO_ROOT)) + +from pufferlib.ocean.cosim import carla_bridge as cb +from pufferlib.ocean.cosim.carla.lb1 import lb1_protocol +from pufferlib.ocean.cosim.carla.lb1.ground_truth import LeaderboardOneGroundTruth +from pufferlib.ocean.cosim.carla.shadow_ego import MAX_POLICY_STEPS, PARTNER_MAX_ABS_DZ_M, ShadowEgo + + +MAX_MESSAGES = MAX_POLICY_STEPS + 16 # ticks plus the handshake; the shadow env caps the route before this + + +def reply_for_step(result, carla_flags): + return { + "type": "control", + "motion": None if result.motion is None else [float(v) for v in result.motion], + "control": None if result.control is None else [float(v) for v in result.control], + "pd_flags": result.pd_flags, + "carla_flags": carla_flags, + } + + +def serve(sock, shadow): + lb1_protocol.send_message( + sock, + { + "type": "config", + "partner_slots": shadow.partner_slots, + "partner_max_abs_dz_m": PARTNER_MAX_ABS_DZ_M, + "light_probe_steps_m": list(cb.LIGHT_PROBE_STEPS_M), + "dynamics_source": shadow.dynamics_source, + }, + ) + ground_truth = None + for _ in range(MAX_MESSAGES): + message = lb1_protocol.recv_message(sock) + if message is None: + print("[policy_server] client closed the connection", flush=True) + return + kind = message["type"] + if kind == "init": + route = message["route"] + shadow.init_route(route) + if route.get("ground_truth", False): + ground_truth = LeaderboardOneGroundTruth( + route["lights"], route["stop_signs"], route["ego"]["extent"][0] + ) + lb1_protocol.send_message(sock, {"type": "ready", "num_traffic": shadow.num_traffic}) + elif kind == "tick": + snapshot = message["snapshot"] + result = shadow.step(snapshot) + carla_flags = None + if ground_truth is not None and message.get("ground_truth") is not None: + carla_flags = ground_truth.update(snapshot, message["ground_truth"]) + shadow.telemetry(result, carla_flags) + lb1_protocol.send_message(sock, reply_for_step(result, carla_flags)) + elif kind == "finish": + shadow.finish() + lb1_protocol.send_message(sock, {"type": "done"}) + return + else: + raise ValueError(f"unknown message type {kind!r}") + raise RuntimeError(f"more than MAX_MESSAGES={MAX_MESSAGES} messages on one route") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--checkpoint", required=True, help="PufferDrive checkpoint .pt or run dir (config.yaml beside)" + ) + parser.add_argument("--socket-fd", type=int, required=True, help="inherited socket to the leaderboard agent") + parser.add_argument("--route-tag", required=True, help="per-route file stem for every output") + args = parser.parse_args() + sock = socket.socket(fileno=args.socket_fd) + try: + serve(sock, ShadowEgo(args.checkpoint, args.route_tag)) + finally: + sock.close() + + +if __name__ == "__main__": + main() diff --git a/pufferlib/ocean/cosim/carla/lb1/run_evaluator.py b/pufferlib/ocean/cosim/carla/lb1/run_evaluator.py new file mode 100644 index 0000000000..ec49b8ac12 --- /dev/null +++ b/pufferlib/ocean/cosim/carla/lb1/run_evaluator.py @@ -0,0 +1,90 @@ +"""Run carla_garage's leaderboard_evaluator_local.py (Leaderboard 1.0, CARLA 0.9.10) for one model directory, +with all outputs under its eval folder; the Leaderboard 1.0 twin of ../run_evaluator.py. Runs in the `garage` +conda env (Python 3.7), so nothing from pufferlib is imported here. + +usage: run_evaluator.py --model-dir [evaluator args...] + + holds final_model.pt + config.yaml (blank -> DEFAULT_MODEL_DIR). The launcher adds --agent-config +/final_model.pt, --checkpoint /eval/carla_leaderboard_v1/result.json (the evaluator opens the +result file before any agent code runs) and --scenarios eval_scenarios.json, points COSIM_TELEMETRY / +COSIM_WORLD_LOG / COSIM_OBS_HTML / COSIM_DEBUG_CARLA_VIEW into the eval folder unless already set, and +exports what the garage evaluator reads from the environment: ROUTES (the --routes file, it names the +route records after it) and BENCHMARK=longest6 (all spawn points of background traffic, stop-sign +penalty 1.0). The evaluator is located through GARAGE_WORK_DIR. +""" + +import os +import runpy +import sys +from pathlib import Path + + +DEFAULT_MODEL_DIR = "experiments/k_scaled_0040_1000" +EVAL_SUBDIR = "eval/carla_leaderboard_v1" +DEFAULT_BENCHMARK = "longest6" +DEFAULT_SCENARIOS = "leaderboard/data/scenarios/eval_scenarios.json" +OUTPUT_ENV_DIRS = { + "COSIM_TELEMETRY": "telemetry", + "COSIM_WORLD_LOG": "world_log", + "COSIM_OBS_HTML": "obs_html", + "COSIM_DEBUG_CARLA_VIEW": "carla_view", +} + + +def option_value(argv, name): + """Value of `--name value` or `--name=value` in argv, else None.""" + for k, arg in enumerate(argv): + if arg == name and k + 1 < len(argv): + return argv[k + 1] + if arg.startswith(name + "="): + return arg.split("=", 1)[1] + return None + + +def main(): + argv = sys.argv[1:] + model_dir = "" + if "--model-dir" in argv: + k = argv.index("--model-dir") + has_value = k + 1 < len(argv) and not argv[k + 1].startswith("--") + model_dir = argv[k + 1] if has_value else "" + del argv[k : k + 2 if has_value else k + 1] + if not model_dir: + model_dir = DEFAULT_MODEL_DIR + print(f"[run_evaluator] --model-dir blank, using {DEFAULT_MODEL_DIR}") + model_dir = Path(model_dir) + if not model_dir.is_absolute(): + model_dir = Path.cwd() / model_dir + checkpoint = model_dir / "final_model.pt" + if not checkpoint.is_file(): + raise SystemExit(f"run_evaluator.py: no checkpoint at {checkpoint}") + garage_work_dir = os.environ.get("GARAGE_WORK_DIR") + if not garage_work_dir: + raise SystemExit("run_evaluator.py: GARAGE_WORK_DIR is not set (the carla_garage checkout)") + evaluator = Path(garage_work_dir) / "leaderboard" / "leaderboard" / "leaderboard_evaluator_local.py" + if not evaluator.is_file(): + raise SystemExit(f"run_evaluator.py: evaluator not found at {evaluator}") + routes = option_value(argv, "--routes") + if routes is None: + raise SystemExit("run_evaluator.py: --routes is required") + eval_dir = model_dir / EVAL_SUBDIR + eval_dir.mkdir(parents=True, exist_ok=True) + if option_value(argv, "--agent-config") is None: + argv += ["--agent-config", str(checkpoint)] + if option_value(argv, "--checkpoint") is None: + argv += ["--checkpoint", str(eval_dir / "result.json")] + if option_value(argv, "--scenarios") is None: + argv += ["--scenarios", str(Path(garage_work_dir) / DEFAULT_SCENARIOS)] + for env_name, subdir in OUTPUT_ENV_DIRS.items(): + if env_name not in os.environ: + os.environ[env_name] = str(eval_dir / subdir) + os.environ["ROUTES"] = routes + os.environ.setdefault("BENCHMARK", DEFAULT_BENCHMARK) + os.environ.setdefault("SCENARIO_RUNNER_ROOT", str(Path(garage_work_dir) / "scenario_runner")) + print(f"[run_evaluator] model {model_dir} -> outputs {eval_dir} (BENCHMARK={os.environ['BENCHMARK']})") + sys.argv = [str(evaluator)] + argv + runpy.run_path(str(evaluator), run_name="__main__") + + +if __name__ == "__main__": + main() diff --git a/pufferlib/ocean/cosim/carla/lb1/run_leaderboard.sh b/pufferlib/ocean/cosim/carla/lb1/run_leaderboard.sh new file mode 100644 index 0000000000..b65fccb0f2 --- /dev/null +++ b/pufferlib/ocean/cosim/carla/lb1/run_leaderboard.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# Run lb1/leaderboard_agent.py under carla_garage's leaderboard_evaluator_local.py (CARLA Leaderboard 1.0, +# CARLA 0.9.10.1): start a CARLA server, evaluate one routes xml, stop the server. One machine, one GPU; +# scripts/kesai/14_carla_longest6_v1.sh is the cluster version over all 36 longest6 routes. +# +# Usage: +# CKPT=experiments//final_model.pt bash pufferlib/ocean/cosim/carla/lb1/run_leaderboard.sh +# +# Required env: +# CKPT PufferDrive checkpoint .pt (config.yaml beside it or one level up) or an experiment dir +# Optional env: +# ROUTES routes xml. Default: longest6_split/longest_weathers_0.xml (one Town01 route). +# SCENARIOS scenario json. Default: eval_scenarios.json (the benchmark); no_scenarios.json = empty. +# REPETITIONS default 1 +# OUT result json. Default: runs/cosim_leaderboard_v1_/result.json +# CARLA_PORT default 2000 (distinct per concurrent server); TM_PORT default CARLA_PORT+6000 +# CARLA_WINDOW 1: rendering window on $DISPLAY; default 0: offscreen OpenGL (DISPLAY unset, -opengl) +# CARLA_ROOT CARLA 0.9.10.1 install. Default: ~/ordnung/internal/CARLA_0.9.10 +# GARAGE_WORK_DIR carla_garage checkout. Default: ~/ordnung/internal/carla_garage_1 +# GARAGE_PY python of the `garage` conda env. Default: ~/miniconda3/envs/garage/bin/python +# COSIM_SERVER_PYTHON python of the PufferDrive venv for policy_server.py. Default: $PD/.venv/bin/python +# PD PufferDrive checkout. Default: the one holding this script. +# LIBTIFF5_COMPAT 1 (default): the 0.9.10 egg links libtiff.so.5. When `import carla` fails, the garage env's +# own lib dir is tried first (`conda install -n garage "libtiff<4.5"` puts one there), then +# the system libtiff.so.6 is linked under that name inside $OUT's folder (0 = never link). +# COSIM_* forwarded to the agent / policy server as-is (see lb1/leaderboard_agent.py, shadow_ego.py); +# COSIM_DEBUG_CARLA_VIEW defaults to "$(dirname "$OUT")/carla_view" (empty disables). +set -u + +: "${CKPT:?set CKPT=/path/to/final_model.pt}" +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PD=${PD:-$(cd "$SCRIPT_DIR/../../../../.." && pwd)} +CARLA_ROOT=${CARLA_ROOT:-$HOME/ordnung/internal/CARLA_0.9.10} +GARAGE_WORK_DIR=${GARAGE_WORK_DIR:-$HOME/ordnung/internal/carla_garage_1} +GARAGE_PY=${GARAGE_PY:-$HOME/miniconda3/envs/garage/bin/python} +export COSIM_SERVER_PYTHON=${COSIM_SERVER_PYTHON:-$PD/.venv/bin/python} +CARLA_PORT=${CARLA_PORT:-2000} +TM_PORT=${TM_PORT:-$((CARLA_PORT + 6000))} +CARLA_WINDOW=${CARLA_WINDOW:-0} +ROUTES=${ROUTES:-$GARAGE_WORK_DIR/leaderboard/data/longest6_split/longest_weathers_0.xml} +SCENARIOS=${SCENARIOS:-$GARAGE_WORK_DIR/leaderboard/data/scenarios/eval_scenarios.json} +REPETITIONS=${REPETITIONS:-1} +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +OUT=${OUT:-$PD/runs/cosim_leaderboard_v1_${TIMESTAMP}/result.json} +OUT_DIR=$(dirname "$OUT") +mkdir -p "$OUT_DIR" +export COSIM_DEBUG_CARLA_VIEW=${COSIM_DEBUG_CARLA_VIEW-"$OUT_DIR/carla_view"} +EVALUATOR=$GARAGE_WORK_DIR/leaderboard/leaderboard/leaderboard_evaluator_local.py +CARLA_EGG=$CARLA_ROOT/PythonAPI/carla/dist/carla-0.9.10-py3.7-linux-x86_64.egg +for path in "$CKPT" "$GARAGE_PY" "$COSIM_SERVER_PYTHON" "$CARLA_ROOT/CarlaUE4.sh" "$CARLA_EGG" "$EVALUATOR" "$ROUTES" "$SCENARIOS"; do + [ -e "$path" ] || { echo "missing $path"; exit 1; } +done + +carla_imports() { PYTHONPATH="$CARLA_EGG" "$GARAGE_PY" -c "import carla" >/dev/null 2>&1; } +# the 0.9.10 egg links libtiff.so.5, which newer distros no longer ship (Ubuntu 24.04: libtiff.so.6 only) +if ! carla_imports; then + GARAGE_LIB=$(cd "$(dirname "$GARAGE_PY")/../lib" 2>/dev/null && pwd) + if [ -n "$GARAGE_LIB" ] && [ -e "$GARAGE_LIB/libtiff.so.5" ]; then + export LD_LIBRARY_PATH="$GARAGE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + echo "[run_leaderboard_v1] libtiff.so.5 from the garage env: $GARAGE_LIB" + fi +fi +if ! carla_imports && [ "${LIBTIFF5_COMPAT:-1}" = "1" ]; then + SYSTEM_LIBTIFF=$( (ldconfig -p 2>/dev/null || /sbin/ldconfig -p 2>/dev/null) | awk '/libtiff.so.6 /{print $NF; exit}') + if [ -n "$SYSTEM_LIBTIFF" ]; then + mkdir -p "$OUT_DIR/compat_lib" && ln -sf "$SYSTEM_LIBTIFF" "$OUT_DIR/compat_lib/libtiff.so.5" + export LD_LIBRARY_PATH="$OUT_DIR/compat_lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + echo "[run_leaderboard_v1] libtiff.so.5 missing: using $SYSTEM_LIBTIFF via $OUT_DIR/compat_lib" + fi +fi +carla_imports || { echo "ERROR: $GARAGE_PY cannot import the CARLA egg:"; PYTHONPATH="$CARLA_EGG" "$GARAGE_PY" -c "import carla"; exit 1; } + +export CARLA_ROOT GARAGE_WORK_DIR +export SCENARIO_RUNNER_ROOT=$GARAGE_WORK_DIR/scenario_runner +export LEADERBOARD_ROOT=$GARAGE_WORK_DIR/leaderboard +export PYTHONPATH="$CARLA_ROOT/PythonAPI/carla:$CARLA_EGG:$SCENARIO_RUNNER_ROOT:$LEADERBOARD_ROOT:${PYTHONPATH:-}" +# the garage evaluator names the route records after $ROUTES; BENCHMARK=longest6 fills every spawn point +export ROUTES BENCHMARK=${BENCHMARK:-longest6} DATAGEN=0 + +if [ "$CARLA_WINDOW" = "1" ]; then + "$CARLA_ROOT/CarlaUE4.sh" -opengl -nosound -carla-rpc-port="$CARLA_PORT" -carla-streaming-port=$((CARLA_PORT + 1)) \ + > "$OUT_DIR/carla_server.log" 2>&1 & +else + DISPLAY= "$CARLA_ROOT/CarlaUE4.sh" -opengl -nosound -carla-rpc-port="$CARLA_PORT" -carla-streaming-port=$((CARLA_PORT + 1)) \ + > "$OUT_DIR/carla_server.log" 2>&1 & +fi +SERVER_PID=$! +up=1 +for _ in $(seq 1 60); do + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "CARLA server process died"; break + fi + "$GARAGE_PY" -c " +import carla, sys +try: + c = carla.Client('localhost', $CARLA_PORT); c.set_timeout(10.0) + c.get_world().get_map() +except Exception: + sys.exit(1)" 2>/dev/null && up=0 && break + sleep 5 +done +if [ $up -ne 0 ]; then + echo "FAIL: CARLA server not ready on port $CARLA_PORT (see $OUT_DIR/carla_server.log)" + pkill -9 -P $SERVER_PID 2>/dev/null; kill -9 $SERVER_PID 2>/dev/null + exit 1 +fi +echo "[run_leaderboard_v1] CARLA 0.9.10 server ready (pid $SERVER_PID, port $CARLA_PORT)" + +echo "[run_leaderboard_v1] routes=$ROUTES scenarios=$SCENARIOS ckpt=$CKPT out=$OUT" +"$GARAGE_PY" -u "$EVALUATOR" --routes "$ROUTES" --scenarios "$SCENARIOS" --repetitions "$REPETITIONS" \ + --agent "$SCRIPT_DIR/leaderboard_agent.py" --agent-config "$CKPT" --checkpoint "$OUT" --track MAP \ + --port "$CARLA_PORT" --trafficManagerPort "$TM_PORT" --timeout 600 +RC=$? +# children first: killing the wrapper shell first reparents the UE4 binary and leaves it running +pkill -9 -P $SERVER_PID 2>/dev/null; kill -9 $SERVER_PID 2>/dev/null +echo "[run_leaderboard_v1] evaluator exit $RC; result -> $OUT" +exit $RC diff --git a/pufferlib/ocean/cosim/carla/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/leaderboard_agent.py index fda865955c..31d76b5900 100644 --- a/pufferlib/ocean/cosim/carla/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/leaderboard_agent.py @@ -1,4 +1,4 @@ -"""PufferDrive policy as a CARLA leaderboard agent (CaRL original_leaderboard). +"""PufferDrive policy as a CARLA leaderboard agent (CaRL original_leaderboard, CARLA 0.9.15). Usage (CaRL env vars/paths as in CaRL/CARLA/README.md, plus PufferDrive repo root on PYTHONPATH for `pufferlib` and `data_utils`): @@ -9,66 +9,27 @@ --agent-config /path/to/experiments/puffer_drive_xxx/models/model_xxx.pt \ --checkpoint /path/to/results/result.json --track MAP -Everything about the shadow env (obs layout, dynamics, dt, goal count, agent -pool size, ...) comes from the checkpoint's sibling config.yaml, with the -clean-eval profile applied on top (cosim/arch.py CLEAN_EVAL_OVERRIDES); only -the structural co-sim keys (map, pool wiring) are set here. +This file is the CARLA side only: it reads ground truth (ego, nearby actors, lights, stop signs, the +route), hands it to shadow_ego.ShadowEgo (the shadow Drive env + policy, built from the checkpoint's +config.yaml) and moves the ego the way the result says. The Leaderboard 1.0 integration (lb1/) runs +the same core behind a socket. -Environment variables: +Environment variables (the shadow env's own, COSIM_DEVICE ... COSIM_WORLD_LOG, are listed in +shadow_ego.py): SCENARIO_RUNNER_ROOT REQUIRED: path to the scenario_runner checkout (route scenarios silently fail to load without it) CARL_WORK_DIR REQUIRED if COSIM_TELEMETRY or COSIM_RECORD_INFRACTIONS is set: path to the CaRL/CARLA checkout, to import its reward.criteria collision/offroad detectors - COSIM_DEVICE=cpu torch device for the policy - COSIM_DYNAMICS_SOURCE=pufferdrive - "pufferdrive" (default): PufferDrive's own - dynamics (the ones the policy trained on) move - the ego; the CARLA actor is teleported to match - every policy step. - "carla": TrackingController converts the shadow - env's target (speed, yaw) into throttle/brake/ - steer and CARLA's own vehicle physics moves the - ego (subject to dynamics-mismatch tracking lag). - COSIM_MAX_SPEED_MPS=20 ego speed cap; sets the shadow env's C_vel = - cap / base_max_speed_mps. Default: the - checkpoint's base_max_speed_mps (C_vel = 1). - Must lie in the trained range - base * [1/conditioning_speed_scale, scale] - (0036: 13.3-30 m/s, 0038+: 10-40 m/s). - COSIM_ZERO_PARTNER_STOPPED_TIME=0 - 1: every partner's seconds_stopped observation - feature is held at 0 (the ego's own counter is - untouched). Ablates the stopped-time cue behind - parked-car / red-light-queue avoidance. - COSIM_PEDESTRIAN_MIN_SIZE_M=0.0 - walker partner boxes below this on either axis - grow to it (nuPlan's pedestrian_min_size_m; the - training spawn floor is 0.8 m, CARLA walkers - are 0.4-0.5 m) COSIM_DEBUG_CARLA_VIEW=/dir write a CARLA chase-camera mp4 per route (native tick rate, streamed to disk frame-by-frame) COSIM_RECORD_INFRACTIONS=/dir write a short chase-cam clip (last ~5 s) per ego infraction (collision/offroad/red-light/stop-sign), from either the shadow pufferdrive env's own - model (_ego_infractions) or real CARLA ground - truth (_carla_infractions) - COSIM_TELEMETRY=/dir write a per-policy-step CSV per route, with - both infraction sources above as columns - COSIM_OBS_HTML=/dir write an interactive pufferlib.viz replay per - route (the exact obs + policy outputs the ego saw) - COSIM_OBS_HTML_RENDER=1 0: save only the compact .replay.zlib per route; - scripts/eval/render_carla_obs_html.py renders the - pages of a whole run into one gallery afterwards - COSIM_DUMP_OBS=/dir write the raw ego obs + action per policy step - per route (.npz: step, obs, action) - COSIM_WORLD_LOG=/dir write the bin-frame world state per policy step - per route (ego, partners, light states, route); - input of scripts/eval/analyze_carla_cosim.py + model (pd_*) or real CARLA ground truth (carla_*) """ -import json import math import os import re @@ -82,11 +43,9 @@ from leaderboard.autoagents import autonomous_agent from srunner.scenariomanager.carla_data_provider import CarlaDataProvider -from pufferlib.ocean.drive.drive import Drive -from pufferlib.ocean.cosim.goals import RouteGoalWindow, route_goals_from_target_points from pufferlib.ocean.cosim import carla_bridge as cb -from pufferlib.ocean.cosim.arch import checkpoint_config_path, shadow_env_kwargs -from pufferlib.ocean.cosim.carla.controller import TrackingController, read_vehicle_geometry +from pufferlib.ocean.cosim.carla.controller import vehicle_geometry_from_wheels, wheel_rows +from pufferlib.ocean.cosim.carla.shadow_ego import PARTNER_MAX_ABS_DZ_M, ShadowEgo # Rolling chase-cam window kept for COSIM_RECORD_INFRACTIONS clips, and the # minimum ego travel between two logged infractions (suppresses re-triggering @@ -94,18 +53,10 @@ INFRACTION_MIN_SEPARATION_M = 10.0 RED_LIGHT_CHECK_DISTANCE_M = 30.0 # matches CaRL RunRedLight's own distance_light default -FAR_AWAY = 1.0e6 # park unused shadow-env agent slots out of observation range -PARTNER_MAX_ABS_DZ_M = 20.0 # CARLA actors farther above/below the ego are hidden scenario props, not traffic -DEFAULT_BASE_MAX_SPEED_MPS = 20.0 # Drive() defaults, for checkpoints whose config predates the keys -DEFAULT_CONDITIONING_SPEED_SCALE = 1.5 ROAD_RAY_HALF_SPAN_M = ( 5.0 # vertical ray around the waypoint z; the ego roof and underpass roads are filtered by label/nearest ) ROAD_RAY_LABELS = (carla.CityObjectLabel.Roads, carla.CityObjectLabel.RoadLines, carla.CityObjectLabel.Bridge) -MAX_POLICY_STEPS = 100_000 # shadow-env episode cap (~1.4 h at the 0.05 s CARLA tick); sizes the light state buffers -SCENARIO_LENGTH_MARGIN_STEPS = 2 -# Shadow-env metrics_array indices (datatypes.h): collision/offroad/red-light flags. -EGO_INFRACTION_METRICS = {"collision": 0, "offroad": 1, "red_light": 2, "stop_sign": 3} CARLA_VIEW_SENSOR_ID = "puffer_chase_cam" CARLA_VIEW_WIDTH, CARLA_VIEW_HEIGHT, CARLA_VIEW_FOV = 960, 540, 90 @@ -119,35 +70,6 @@ def get_entry_point(): return "PufferAgent" -def clean_policy_state_dict(state_dict): - """Strip torch.compile / DDP prefixes (wandb, neptune, the _C kernel, ...) - from the leaderboard's evaluation environment.""" - - def clean(key): - while key.startswith(("module.", "_orig_mod.")): - key = key.split(".", 1)[1] - return key - - return {clean(k): v for k, v in state_dict.items()} - - -def resolve_checkpoint(path_to_conf_file): - """(checkpoint_path, config_dict) from a .pt file or an experiment dir.""" - import yaml - - p = Path(path_to_conf_file).resolve() - if p.is_file(): - ckpt, cfg_path = p, checkpoint_config_path(p) - else: - models = sorted((p / "models").glob("*.pt")) or sorted(p.glob("*.pt")) - if not models: - raise FileNotFoundError(f"no .pt checkpoint under {p}") - ckpt, cfg_path = models[-1], p / "config.yaml" - with open(cfg_path) as f: - cfg = yaml.safe_load(f) - return str(ckpt), cfg - - def road_mesh_z(world, x, y, reference_z): """Height of the road mesh at (x, y) from a vertical ray, the road hit nearest reference_z; None when none is hit.""" top = carla.Location(x=x, y=y, z=reference_z + ROAD_RAY_HALF_SPAN_M) @@ -171,29 +93,27 @@ def road_aligned_attitude(road_up, yaw_deg): return pitch_deg, roll_deg, forward -def cosim_max_speed_mps(env_cfg): - """COSIM_MAX_SPEED_MPS as a float inside the checkpoint's trained C_vel range, or None when unset.""" - raw = os.environ.get("COSIM_MAX_SPEED_MPS") - if raw is None: - return None - max_speed_mps = float(raw) - base_max_speed_mps = float(env_cfg.get("base_max_speed_mps") or DEFAULT_BASE_MAX_SPEED_MPS) - scale = float(env_cfg.get("conditioning_speed_scale") or DEFAULT_CONDITIONING_SPEED_SCALE) - low_mps, high_mps = base_max_speed_mps / scale, base_max_speed_mps * scale - if not (low_mps <= max_speed_mps <= high_mps): - raise ValueError( - f"COSIM_MAX_SPEED_MPS={max_speed_mps} is outside the trained C_vel range " - f"[{low_mps:.1f}, {high_mps:.1f}] m/s (base_max_speed_mps={base_max_speed_mps}, " - f"conditioning_speed_scale={scale})" - ) - return max_speed_mps - - def plan_xyz(plan): """[(carla.Transform, RoadOption)] -> (N, 3) CARLA-frame positions.""" return np.array([[t.location.x, t.location.y, t.location.z] for t, _ in plan], dtype=np.float64).reshape(-1, 3) +def actor_state_row(actor): + """shadow_ego ACTOR_STATE of a live CARLA actor (CARLA frame).""" + tf = actor.get_transform() + v = actor.get_velocity() + av = actor.get_angular_velocity() # deg/s, world frame (CARLA's rotation convention) + acc = actor.get_acceleration() # m/s^2, world frame + return [tf.location.x, tf.location.y, tf.location.z, tf.rotation.yaw, v.x, v.y, v.z, av.z, acc.x, acc.y] + + +def partner_row(actor): + """shadow_ego PARTNER_ROW of a live CARLA vehicle or walker.""" + box = actor.bounding_box + is_walker = 1.0 if "walker" in actor.type_id else 0.0 + return actor_state_row(actor) + [box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, actor.id] + + class PufferAgent(autonomous_agent.AutonomousAgent): def setup(self, path_to_conf_file, route_index=None): if "SCENARIO_RUNNER_ROOT" not in os.environ: @@ -206,64 +126,16 @@ def setup(self, path_to_conf_file, route_index=None): # CaRL's `route_index` is route_date_string = Path(ROUTES).stem, fixed # once per evaluator process unless --collect-dataset is passed. self.video_tag = f"{self.route_index}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" - self.checkpoint, self.cfg = resolve_checkpoint(path_to_conf_file) - - self.device = os.environ.get("COSIM_DEVICE", "cpu") - self.dynamics_source = os.environ.get("COSIM_DYNAMICS_SOURCE", "pufferdrive") - if self.dynamics_source not in ("carla", "pufferdrive"): - raise ValueError(f"COSIM_DYNAMICS_SOURCE must be 'carla' or 'pufferdrive', got {self.dynamics_source!r}") - env_cfg = self.cfg["env"] - self.max_speed_mps = cosim_max_speed_mps(env_cfg) - zero_stopped_raw = os.environ.get("COSIM_ZERO_PARTNER_STOPPED_TIME", "0") - if zero_stopped_raw not in ("0", "1"): - raise ValueError(f"COSIM_ZERO_PARTNER_STOPPED_TIME must be '0' or '1', got {zero_stopped_raw!r}") - self.zero_partner_stopped_time = zero_stopped_raw == "1" - self.pedestrian_min_size_m = float(os.environ.get("COSIM_PEDESTRIAN_MIN_SIZE_M", "0.0")) - if not (self.pedestrian_min_size_m >= 0.0): - raise ValueError(f"COSIM_PEDESTRIAN_MIN_SIZE_M must be >= 0, got {self.pedestrian_min_size_m}") - # Shadow agent pool == the training per-env cap - self.num_agents = int(env_cfg["max_agents_per_env"]) + self.shadow = ShadowEgo(path_to_conf_file, self.video_tag) self.debug_carla_view_dir = os.environ.get("COSIM_DEBUG_CARLA_VIEW", None) self.record_infractions_dir = os.environ.get("COSIM_RECORD_INFRACTIONS", None) - self.telemetry_dir = os.environ.get("COSIM_TELEMETRY", None) - self.telemetry_file = None - self.obs_html_dir = os.environ.get("COSIM_OBS_HTML", None) - self.obs_dump_dir = os.environ.get("COSIM_DUMP_OBS", None) # raw ego obs per policy step (.npz) - self._obs_dump = [] - self.world_log_dir = os.environ.get("COSIM_WORLD_LOG", None) # bin-frame world state per policy step (.npz) - self._world_log = {"ego": [], "partners": [], "lights": []} - self._last_partners = None - self._partner_stopped_s = {} # CARLA actor id -> seconds at standstill - from pufferlib.ocean.drive import binding - - self._partner_stopped_speed_threshold = float(binding.AGENT_STOPPED_SPEED_THRESHOLD) - self.obs_html_max_steps = int(os.environ.get("COSIM_OBS_HTML_MAX_STEPS", "12000")) - obs_html_render_raw = os.environ.get("COSIM_OBS_HTML_RENDER", "1") - if obs_html_render_raw not in ("0", "1"): - raise ValueError(f"COSIM_OBS_HTML_RENDER must be '0' or '1', got {obs_html_render_raw!r}") - self.obs_html_render = obs_html_render_raw == "1" - self._obs_html = None - # Per-policy-step CSV of the ego's current goal vs the lane - # find_goal_lane snapped it to (goal_lane_idx from env.get_state()): - # lane direction, lane-to-goal distance, dot vs the route direction - # used at snap time -- diagnoses whether the goal feature is guiding - # toward a real, correctly-directed lane. Off unless set. - self.debug_goal_lane_dir = os.environ.get("COSIM_DEBUG_GOAL_LANE", None) - self._goal_lane_debug_file = None - self._road_data = None self.step = -1 self.initialized = False - self.policy = None self.carla_view_writer = None - self.goal_window = None - self.target = (0.0, 0.0) # (target_speed, target_yaw_deg), held between policy steps - # (x, y, yaw_deg, speed) of the shadow ego before and after the current policy step, CARLA frame - self._motion = None self._carla_collision = None # set by _init_carla_infraction_detectors when needed self._carla_stop_sign = None - self._stop_sign_lines = np.zeros((0, 6), np.float32) # CARLA trigger volumes as bin-frame stop lines def sensors(self): if not self.debug_carla_view_dir and not self.record_infractions_dir: @@ -279,67 +151,6 @@ def sensors(self): } ] - def _load_policy_and_env(self, town_bin): - arch = shadow_env_kwargs( - self.cfg, - overrides=dict( - map_dir=town_bin, - num_maps=1, - # One policy agent (the ego); every other slot is a static partner streamed from CARLA. - num_agents=1, - min_agents_per_env=1, - cosim_partner_slots=self.num_agents - 1, - goal_source="external", - dt=self.dt, - scenario_length=MAX_POLICY_STEPS + SCENARIO_LENGTH_MARGIN_STEPS, - resample_frequency=0, - termination_mode=0, - # Enforcement off (detection flags still fire): in one endless - # episode a "stop" latch is permanent and fires only AFTER - # CARLA scored the infraction (route 5 froze 320 s -> DNF). - collision_behavior="ignore", - offroad_behavior="ignore", - traffic_light_behavior="ignore", - **({} if self.max_speed_mps is None else {"max_speed_mps": self.max_speed_mps}), - ), - ) - self.dynamics_model = arch.get("dynamics_model", "classic") - # jerk dynamics build accel across steps while sync() re-seeds accel - self.speed_intent_extension_s = 2.0 * self.dt if self.dynamics_model == "jerk" else 0.0 - - self.env = Drive(**arch) - self.env.reset() - - import torch - import pufferlib.ocean.torch as drive_torch - - policy_state_dict = clean_policy_state_dict( - torch.load(self.checkpoint, map_location=self.device, weights_only=False) - ) - env_for_policy = self.env - self._boundary_feat_ckpt = None - bw = policy_state_dict.get("actor_backbone.boundary_encoder.0.weight") - if bw is not None and bw.shape[1] < env_for_policy.boundary_features: - # Older checkpoints emit fewer boundary features than this drive.h - # (e.g. 7 vs 9 -- two zero-padded GPS columns were added later). - import copy - - self._boundary_feat_ckpt = int(bw.shape[1]) - env_for_policy = copy.copy(self.env) - env_for_policy.boundary_features = self._boundary_feat_ckpt - print( - f"[puffer_agent] checkpoint uses {self._boundary_feat_ckpt} boundary features " - f"(env emits {self.env.boundary_features}); stripping zero-padded columns" - ) - - policy_cls = getattr(drive_torch, self.cfg.get("policy_name", "Drive")) - # pre-3.0 checkpoints keep action_type only in the env section - self.cfg["policy"].setdefault("action_type", self.cfg["env"]["action_type"]) - self.policy = policy_cls(env_for_policy, **self.cfg["policy"]).to(self.device) - self.policy.load_state_dict(policy_state_dict) - self.policy.eval() - print(f"[puffer_agent] loaded policy from {self.checkpoint}") - def _init_on_first_step(self): """Deferred init (the ego and world only exist once the route runs) — same pattern as CaRL's eval_agent.agent_init. Everything here is @@ -349,89 +160,31 @@ def _init_on_first_step(self): self.cmap = self.world.get_map() town = CarlaDataProvider.get_map().name.split("/")[-1] self.tick_dt = float(self.world.get_settings().fixed_delta_seconds) # 0.05 @ 20 Hz - self.dt = self.tick_dt # the policy runs every CARLA tick, like the leaderboard's sensor agents - - self.town_bin = cb.bin_path_for_town(town) - self._load_policy_and_env(self.town_bin) - - self.transform = cb.CarlaTransform(town, offset=cb.town_offset(self.town_bin)) - offset, z_offset, residual_before, residual_after = cb.calibrate_town_offset( - self.cmap, self.transform, self.town_bin - ) - print( - f"[puffer_agent] bin offset calibrated against CARLA lanes: shift " - f"({offset[0] - self.transform.tx:+.2f}, {offset[1] - self.transform.ty:+.2f}) m, z {z_offset:+.2f} m, " - f"median lane residual {residual_before:.2f} -> {residual_after:.2f} m" - ) - self.transform = cb.CarlaTransform(town, offset=offset, z_offset=z_offset) - if self.dynamics_source == "pufferdrive": - self._sync_ego_from_carla(zero_velocity=True) - ego_loc = self.vehicle.get_location() - target_points = plan_xyz(self._global_plan_world_coord) - dense_points = plan_xyz(self.dense_global_plan_world_coord) - target_points[:, 2] = self.transform.z_to_bin(target_points[:, 2]) - dense_points[:, 2] = self.transform.z_to_bin(dense_points[:, 2]) - self.route_goals = route_goals_from_target_points( - target_points, - dense_points, - self.transform.loc_to_bin, - self.transform.loc_to_bin(ego_loc.x, ego_loc.y), - self.env.goal_radius, - ) - # the leaderboard's target points, next num_goals of them at all times (fewer only at the route end) - self.goal_window = RouteGoalWindow(self.env, self.route_goals, sliding=True) - - import data_utils.mirror_map_bin as mbin - - bin_data = mbin.read_bin(Path(self.town_bin)) - bin_traffic = bin_data["traffic"] - self.stop_line_centers = np.array( - [ - [0.5 * (t["stop_line"][0] + t["stop_line"][3]), 0.5 * (t["stop_line"][1] + t["stop_line"][4])] - for t in bin_traffic - ] - ).reshape(-1, 2) - - if self.debug_goal_lane_dir: - # goal_lane_idx comes from road_elements[] in the C file; bin_data["roads"] - # is the same bin read in the same order, so it's usable as-is. - self._road_data = bin_data["roads"] - Path(self.debug_goal_lane_dir).mkdir(parents=True, exist_ok=True) - self._goal_lane_debug_file = open(Path(self.debug_goal_lane_dir) / f"{self.video_tag}.csv", "w") - self._goal_lane_debug_file.write( - "step,ego_x,ego_y,goal_x,goal_y,gdir_x,gdir_y,goal_lane_idx,lane_dir_dot_route,lane_dist_to_goal_m\n" - ) - self.lights = list(self.world.get_actors().filter("traffic.traffic_light")) - light_map, bin_num_traffic = cb.map_lights_to_bin(self.lights, self.transform, self.town_bin) - # the shadow env runs on CARLA's trigger volumes: the bin's exported stop lines sit up to 9 m off and miss a few - self._stop_sign_lines, stop_sign_headings = cb.stop_signs_from_carla(self.world, self.cmap, self.transform) - self.num_traffic = self.env.set_stop_signs(self._stop_sign_lines, stop_sign_headings) - print( - f"[puffer_agent] stop signs: {len(stop_sign_headings)} CARLA trigger volumes replace the bin's " - f"(traffic elements {bin_num_traffic} -> {self.num_traffic})" - ) - # One id -> bin-indices table, reused by both passes in - # _read_light_states, so they can't disagree on which bin element a - # given light maps to (a prior independent geometric lookup for the - # ego-governance pass picked the wrong element ~5% of the time). - self.light_bin_indices_by_id = {lt.id: light_map[li] for li, lt in enumerate(self.lights)} - self.last_light_states = np.zeros(self.num_traffic, np.int32) - wheelbase, max_steer = read_vehicle_geometry(self.vehicle) - self.wheelbase_m = wheelbase body = self.vehicle.bounding_box self.body_front_m = body.location.x + body.extent.x self.body_rear_m = body.location.x - body.extent.x - self.controller = TrackingController(wheelbase_m=wheelbase, max_steer_rad=max_steer, horizon_s=self.dt) - - if self.obs_html_dir: - from pufferlib.ocean.cosim.obs_replay import ObsReplayCapture - - Path(self.obs_html_dir).mkdir(parents=True, exist_ok=True) - self._obs_html = ObsReplayCapture( - self.env, self.policy, Path(self.obs_html_dir) / self.video_tag, max_steps=self.obs_html_max_steps - ) + wheels = wheel_rows(self.vehicle) + self.wheelbase_m, _ = vehicle_geometry_from_wheels(wheels) + self.shadow.init_route( + { + "town": town, + "tick_dt": self.tick_dt, + "ego": { + "extent": [body.extent.x, body.extent.y, body.extent.z], + "box_z": body.location.z, + "wheels": wheels, + "state": actor_state_row(self.vehicle), + }, + "sparse_plan": plan_xyz(self._global_plan_world_coord), + "dense_plan": plan_xyz(self.dense_global_plan_world_coord), + "driving_waypoints": cb.driving_waypoint_samples(self.cmap), + "lights": cb.light_geometry_from_carla(self.lights), + "stop_signs": cb.stop_sign_geometry_from_carla(self.world, self.cmap), + "infraction_flags": bool(self.record_infractions_dir), + } + ) if self.debug_carla_view_dir: from pufferlib.ocean.cosim.carla_cosim import Mp4Writer @@ -440,16 +193,6 @@ def _init_on_first_step(self): out = str(Path(self.debug_carla_view_dir) / f"{self.video_tag}.mp4") self.carla_view_writer = Mp4Writer(out, fps=round(1.0 / self.tick_dt)) - if self.telemetry_dir: - Path(self.telemetry_dir).mkdir(parents=True, exist_ok=True) - self.telemetry_file = open(Path(self.telemetry_dir) / f"{self.video_tag}.csv", "w") - self.telemetry_file.write( - "step,current_speed,target_speed,ego_action,goal_cursor,goal_dist_m," - "near_light_dist_m,near_light_state," - "pd_infr_collision,pd_infr_offroad,pd_infr_red,pd_infr_stop," - "carla_infr_collision,carla_infr_offroad,carla_infr_red,carla_infr_stop\n" - ) - if self.record_infractions_dir: from collections import deque @@ -459,18 +202,11 @@ def _init_on_first_step(self): self.infraction_counter = 0 self.last_infraction_location = self.vehicle.get_location() - if self.telemetry_dir or self.record_infractions_dir: + if self.shadow.telemetry_dir or self.record_infractions_dir: self._init_carla_infraction_detectors() - - print( - f"[puffer_agent] town={town} tick_dt={self.tick_dt} dt={self.dt} route_goals={len(self.route_goals)} " - f"max_speed_mps={self.env.max_speed_mps:.1f} (C_vel={self.env.max_speed_mps / self.env.base_max_speed_mps:.2f}) " - f"zero_partner_stopped_time={int(self.zero_partner_stopped_time)} " - f"pedestrian_min_size_m={self.pedestrian_min_size_m:g}" - ) self.initialized = True - # --- shadow-env sync (read-only w.r.t. CARLA) -------------------------- + # --- CARLA ground truth for the shadow env (read-only w.r.t. CARLA) ---- def _nearby_actors(self): ego_loc = self.vehicle.get_location() @@ -484,196 +220,25 @@ def _nearby_actors(self): continue candidates.append((loc.distance(ego_loc), a)) candidates.sort(key=lambda item: item[0]) - return [a for _, a in candidates[: self.num_agents - 1]] - - def _read_states(self, actors): - idx, x, y, z, h, vx, vy, yaw_rate, accel_long, stopped_s = [], [], [], [], [], [], [], [], [], [] - stopped_by_id = {} - for j, a in enumerate(actors): - idx.append(1 + j) # agent 0 = ego; others fill 1..M - bx, by, bz, bh, bvx, bvy, byr, bal = self.transform.actor_state_to_bin(a) - x.append(bx) - y.append(by) - z.append(bz) - h.append(bh) - vx.append(bvx) - vy.append(bvy) - yaw_rate.append(byr) - accel_long.append(bal) - # stopped time follows the CARLA actor, not the shadow slot (slots reshuffle by distance every step) - is_stopped = math.hypot(bvx, bvy) <= self._partner_stopped_speed_threshold - stopped_by_id[a.id] = self._partner_stopped_s.get(a.id, 0.0) + self.dt if is_stopped else 0.0 - stopped_s.append(stopped_by_id[a.id]) - self._partner_stopped_s = stopped_by_id - if self.zero_partner_stopped_time: - stopped_s = [0.0] * len(actors) - return ( - np.array(idx, np.int32), - np.array(x, np.float32), - np.array(y, np.float32), - np.array(z, np.float32), - np.array(h, np.float32), - np.array(vx, np.float32), - np.array(vy, np.float32), - np.array(yaw_rate, np.float32), - np.array(accel_long, np.float32), - np.array(stopped_s, np.float32), - ) + return [a for _, a in candidates[: self.shadow.partner_slots]] - def _read_sizes(self, actors): - idx, length, width = [], [], [] - for j, a in enumerate(actors): - idx.append(1 + j) - ext = a.bounding_box.extent - walker_floor_m = self.pedestrian_min_size_m if "walker" in a.type_id else 0.0 - length.append(max(2.0 * ext.x, 0.1, walker_floor_m)) - width.append(max(2.0 * ext.y, 0.1, walker_floor_m)) - return (np.array(idx, np.int32), np.array(length, np.float32), np.array(width, np.float32)) - - def _read_light_states(self): - """Ground truth for every mapped bin traffic element, via the - precomputed light_bin_indices_by_id table (see _init_on_first_step).""" - # unmapped light elements read OFF (a training state), never UNKNOWN - states = np.full(self.num_traffic, cb.TRAFFIC_LIGHT_STATE_OFF, np.int32) - for lt in self.lights: - state = cb.carla_light_to_puffer(lt.get_state()) - for j in self.light_bin_indices_by_id[lt.id]: - if 0 <= j < self.num_traffic: - states[j] = state + def _snapshot(self): ego_light = self.vehicle.get_traffic_light() - if ego_light is not None and ego_light.id in self.light_bin_indices_by_id: - state = cb.carla_light_to_puffer(ego_light.get_state()) - for j in self.light_bin_indices_by_id[ego_light.id]: - if 0 <= j < self.num_traffic: - states[j] = state - return states - - def _sync_ego_from_carla(self, zero_velocity=False): - """Overwrite agent 0 (ego) from CARLA's ground-truth pose/size.""" - ego_state = self.transform.actor_state_to_bin(self.vehicle) - if zero_velocity: - x, y, z, heading, *_ = ego_state - ego_state = (x, y, z, heading, 0.0, 0.0, 0.0, 0.0) - self.env.set_agent_states(np.array([0], np.int32), *[np.array([v], np.float32) for v in ego_state]) - ego_ext = self.vehicle.bounding_box.extent - self.env.set_agent_sizes( - np.array([0], np.int32), np.array([2.0 * ego_ext.x], np.float32), np.array([2.0 * ego_ext.y], np.float32) - ) - - def _sync_carla(self): - """Overwrite the shadow env's background agents/lights (+ ego, only in - dynamics_source='carla' mode) from CARLA ground truth and return the - recomputed observation array (num_agents, obs_dim). Row 0 = ego.""" - if self.dynamics_source == "carla": - self._sync_ego_from_carla() - ego = self.env.get_global_agent_state() - ebx, eby = float(ego["x"][0]), float(ego["y"][0]) - - actors = self._nearby_actors() - if actors: - states = self._read_states(actors) - sizes = self._read_sizes(actors) - self.env.set_agent_states(*states) - self.env.set_agent_sizes(*sizes) - is_walker = np.array(["walker" in a.type_id for a in actors], np.float32) - self._last_partners = (states[1], states[2], states[4], states[5], states[6], sizes[1], sizes[2], is_walker) - else: - self._last_partners = None - n_used = 1 + len(actors) - if n_used < self.num_agents: - sp = np.arange(n_used, self.num_agents, dtype=np.int32) - zf = np.full(len(sp), FAR_AWAY, np.float32) - zz = np.zeros_like(zf) - self.env.set_agent_states(sp, zf, zf, zf, zz, zz, zz, zz, zz) - - self.last_light_states = self._read_light_states() - self.env.set_traffic_light_states(self.last_light_states) - - self.goal_window.sync(ebx, eby, float(ego["heading"][0])) - if self._goal_lane_debug_file is not None: - self._write_goal_lane_debug_row(ebx, eby, self.route_goals[self.goal_window.current_index]) - return np.asarray(self.env.recompute_observations()) - - def _write_goal_lane_debug_row(self, ebx, eby, current_goal): - """One CSV row: the CURRENT goal (window slot 0, current_goal_idx after - the reset in c_set_agent_goals) vs the lane find_goal_lane snapped it - to -- that lane's direction (nearest segment to the goal, mirroring - find_goal_lane's own point-to-segment search) dotted against the route - direction used at snap time, and the snap distance. dot < 0 means the - snapped lane runs opposite the route (oncoming lane); a large snap - distance means no nearby lane passed find_goal_lane's gates at all.""" - gx, gy, _, gdir_x, gdir_y = current_goal - state = self.env.get_state() - scenario = state[0] if isinstance(state, list) else state - ego_agent = (scenario.get("agents") or [{}])[0] - lane_idx = int(ego_agent.get("goal_lane_idx", -1)) - dot, dist = float("nan"), float("nan") - if lane_idx >= 0 and self._road_data is not None and lane_idx < len(self._road_data): - road = self._road_data[lane_idx] - xs, ys = np.asarray(road["x"], dtype=np.float64), np.asarray(road["y"], dtype=np.float64) - if len(xs) >= 2: - seg_dx, seg_dy = xs[1:] - xs[:-1], ys[1:] - ys[:-1] - seg_len = np.hypot(seg_dx, seg_dy) - seg_len_safe = np.where(seg_len > 1e-6, seg_len, 1.0) - t = np.clip(((gx - xs[:-1]) * seg_dx + (gy - ys[:-1]) * seg_dy) / (seg_len_safe**2), 0.0, 1.0) - px, py = xs[:-1] + t * seg_dx, ys[:-1] + t * seg_dy - d = np.hypot(gx - px, gy - py) - k = int(np.argmin(d)) - dist = float(d[k]) - route_norm = math.hypot(gdir_x, gdir_y) - if seg_len[k] > 1e-6 and route_norm > 1e-6: - dot = float((gdir_x * seg_dx[k] + gdir_y * seg_dy[k]) / (seg_len[k] * route_norm)) - self._goal_lane_debug_file.write( - f"{self.step},{ebx:.2f},{eby:.2f},{gx:.2f},{gy:.2f},{gdir_x:.3f},{gdir_y:.3f}," - f"{lane_idx},{dot:.3f},{dist:.2f}\n" - ) - self._goal_lane_debug_file.flush() - - def _carla_integrate(self, actions): - """Step the shadow env ONCE (one policy step == one shadow tick). - - dynamics_source='carla': return the ego's target kinematic state for - the tracking controller - - dynamics_source='pufferdrive': PufferDrive's own jerk/classic dynamics - (the ones the policy trained on) ARE the ego's motion -- no target to - chase. The CARLA ego is teleported to the post-step pose (see - _teleport_carla_ego), where the next tick's observation reads it. - """ - if self.step >= MAX_POLICY_STEPS: - raise RuntimeError(f"route exceeded MAX_POLICY_STEPS={MAX_POLICY_STEPS} policy steps") - before = self.env.get_global_agent_state() - speed_before = self._ego_speed() - self.env.step(actions) - after = self.env.get_global_agent_state() - target_yaw_deg = self.transform.bin_heading_to_yaw(float(after["heading"][0])) - speed_after = self._ego_speed() - accel_after = float(np.asarray(self.env.observations)[0][4]) * self._accel_long_norm() - - if self.dynamics_source == "pufferdrive": - x0, y0 = self.transform.bin_to_loc(float(before["x"][0]), float(before["y"][0])) - x1, y1 = self.transform.bin_to_loc(float(after["x"][0]), float(after["y"][0])) - yaw0_deg = self.transform.bin_heading_to_yaw(float(before["heading"][0])) - self._motion = (x0, y0, yaw0_deg, speed_before, x1, y1, target_yaw_deg, speed_after, float(after["z"][0])) - self._teleport_carla_ego(1.0) - return speed_after, target_yaw_deg # logged, not chased (see run_step) - - target_speed = speed_after + max(accel_after, 0.0) * self.speed_intent_extension_s - return target_speed, target_yaw_deg + return { + "ego": actor_state_row(self.vehicle), + "partners": [partner_row(a) for a in self._nearby_actors()], + "light_states": [[lt.id, lt.get_state().name] for lt in self.lights], + "ego_light_id": -1 if ego_light is None else ego_light.id, + } - def _teleport_carla_ego(self, fraction): - """Place the CARLA ego at `fraction` (0 = pre-step, 1 = post-step) of the current policy step's motion.""" - x0, y0, yaw0_deg, speed0, x1, y1, yaw1_deg, speed1, sim_z = self._motion - yaw_delta_deg = cb.wrap_deg_180(yaw1_deg - yaw0_deg) - x = x0 + fraction * (x1 - x0) - y = y0 + fraction * (y1 - y0) - yaw_deg = yaw0_deg + fraction * yaw_delta_deg - speed = speed0 + fraction * (speed1 - speed0) + def _teleport_carla_ego(self, motion): + """Place the CARLA ego at the shadow env's post-step pose (StepResult.motion).""" + _, _, yaw0_deg, _, x, y, yaw_deg, speed, carla_z = motion + yaw_delta_deg = cb.wrap_deg_180(yaw_deg - yaw0_deg) # CARLA's live road-mesh height, not the shadow env's lane-averaged sim_z: on graded # multi-level roads the average can land the body mid-structure (measured: 20-100+ # road collisions per Town03/04 route). sim_z only off the drivable network. # get_waypoint is nearest-in-3D: without sim_z the z=0 default snaps an overpass ego to the road beneath - carla_z = self.transform.z_to_carla(sim_z) wp = self.cmap.get_waypoint(carla.Location(x=x, y=y, z=carla_z)) z = wp.transform.location.z if wp is not None else carla_z road_up = wp.transform.rotation.get_up_vector() if wp is not None else carla.Vector3D(x=0.0, y=0.0, z=1.0) @@ -701,7 +266,7 @@ def _teleport_carla_ego(self, fraction): self.vehicle.set_target_velocity( carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2]) ) - self.vehicle.set_target_angular_velocity(carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.dt)) + self.vehicle.set_target_angular_velocity(carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.shadow.dt)) def _road_surface(self, x, y, reference_z, yaw_deg): """(z, pitch_deg) resting the body on the road mesh: bumper-to-bumper chord, lifted until no sample is below the mesh; None off the mesh.""" @@ -716,32 +281,7 @@ def _road_surface(self, x, y, reference_z, yaw_deg): z = max(z_i - slope * s_i for z_i, s_i in zip(sample_z, sample_offsets_m)) return z, math.degrees(math.atan(slope)) - def _ego_speed(self): - return float(np.asarray(self.env.observations)[0][0]) * self._max_speed() - - def _max_speed(self): - return self.env.obs_norm_speed_mps - - def _accel_long_norm(self): - from pufferlib.ocean.drive import binding - - return binding.ACCEL_LONG_NORM - - def _ego_infractions(self): - """{'collision': f, 'offroad': f, 'red_light': f, 'stop_sign': f} from the shadow - pufferdrive env's own model of the ego (compute_metrics, refreshed by - the last integrate()) -- what the policy would have caused according - to PufferDrive's own dynamics, not necessarily what the real CARLA - vehicle did (see _carla_infractions).""" - state = self.env.get_state() - scenario = state[0] if isinstance(state, list) else state - agents = scenario.get("agents") or [] - if not agents: - return {name: 0.0 for name in EGO_INFRACTION_METRICS} - metrics = agents[0].get("metrics_array") or [] - return { - name: float(metrics[idx]) if idx < len(metrics) else 0.0 for name, idx in EGO_INFRACTION_METRICS.items() - } + # --- CARLA ground-truth infraction detectors (CaRL's criteria) ----------- def _init_carla_infraction_detectors(self): """Real CARLA ground truth for collision/offroad/red-light/stop-sign, reusing @@ -820,7 +360,7 @@ def _poll_carla_stop_sign(self): def _carla_infractions(self): """{'collision': f, 'offroad': f, 'red_light': f, 'stop_sign': f} from real CARLA ground truth (_init_carla_infraction_detectors) -- contrast with - _ego_infractions, which is the shadow env's model of the ego.""" + StepResult.pd_flags, which is the shadow env's model of the ego.""" collision, self._carla_collision_pending = self._carla_collision_pending, False red_light, self._ran_red_light_pending = self._ran_red_light_pending, False stop_sign, self._ran_stop_sign_pending = self._ran_stop_sign_pending, False @@ -833,137 +373,6 @@ def _carla_infractions(self): "stop_sign": float(stop_sign), } - # --- policy + capture --------------------------------------------------- - - def _adapt_obs_for_policy(self, obs): - """Strip the trailing zero-padded boundary columns when the checkpoint - predates them (see _load_policy_and_env). No-op otherwise.""" - if self._boundary_feat_ckpt is None: - return obs - env = self.env - n_slots = env.obs_slots_boundary_kept - feat_env, feat_ckpt = env.boundary_features, self._boundary_feat_ckpt - b0 = ( - env.ego_features - + env.num_reward_coefs - + env.goal_dim - + env.obs_slots_partners_n * env.partner_features - + env.obs_slots_lane_kept * env.lane_features - ) - b1 = b0 + n_slots * feat_env - obs = np.asarray(obs) - boundary = obs[:, b0:b1].reshape(obs.shape[0], n_slots, feat_env)[:, :, :feat_ckpt] - return np.concatenate([obs[:, :b0], boundary.reshape(obs.shape[0], -1), obs[:, b1:]], axis=1) - - def _policy_actions(self, obs): - """-> (actions (num_agents, act_dim) int32, aux dict). aux carries - per-agent value/entropy/action-probs/pool for the obs_html capture""" - import torch - import pufferlib.pytorch - - import pufferlib.spaces - - obs = self._adapt_obs_for_policy(obs) - # A discrete policy head on a continuous env needs the bin->continuous - # mapping (pufferl.py feeds cont_action to the env, never the bin indices). - env_continuous = isinstance(self.env.single_action_space, pufferlib.spaces.Box) - action_selection = ( - pufferlib.pytorch.ACTION_SELECT_MEAN - if env_continuous and not self.policy.is_continuous - else pufferlib.pytorch.ACTION_SELECT_MODE - ) - with torch.no_grad(): - logits, value = self.policy.forward_eval(torch.as_tensor(obs).to(self.device)) - action, _, entropy, cont_action = pufferlib.pytorch.sample_logits( - logits, action_selection=action_selection, env_continuous=env_continuous, policy=self.policy - ) - env_action = cont_action if env_continuous and cont_action is not None else action - actions = env_action.cpu().numpy().reshape(1, -1) - if not env_continuous: - actions = actions.astype(np.int32) - aux = {} - if self._obs_html is not None: - aux = self._obs_html.policy_outputs(torch.as_tensor(obs).to(self.device), logits, value, entropy) - aux["action_index"] = action.cpu().numpy().reshape(-1) if not self.policy.is_continuous else None - return actions, aux - - def _record_world_state(self, obs): - """Bin-frame snapshot at sync time (pre-step ego, partners as streamed, light states).""" - ego = self.env.get_global_agent_state() - accel_long = float(obs[0][4]) * self._accel_long_norm() - step_idx = len(self._world_log["ego"]) - self._world_log["ego"].append( - ( - self.step, - float(ego["x"][0]), - float(ego["y"][0]), - float(ego["heading"][0]), - self._ego_speed(), - accel_long, - float(self.goal_window.current_index), - ) - ) - if self._last_partners is not None: - x, y, h, vx, vy, length, width, is_walker = self._last_partners - speed = np.hypot(vx, vy) - rows = np.column_stack([np.full(len(x), step_idx, np.float32), x, y, h, length, width, speed, is_walker]) - self._world_log["partners"].append(rows.astype(np.float32)) - self._world_log["lights"].append(self.last_light_states.astype(np.int8)) - - def _write_world_log(self): - Path(self.world_log_dir).mkdir(parents=True, exist_ok=True) - dense = np.array( - [self.transform.loc_to_bin(t.location.x, t.location.y) for t, _ in self.dense_global_plan_world_coord], - np.float32, - ) - partners = self._world_log["partners"] - np.savez_compressed( - Path(self.world_log_dir) / f"{self.video_tag}.npz", - ego=np.array(self._world_log["ego"], np.float32), - partners=np.concatenate(partners) if partners else np.zeros((0, 8), np.float32), - lights=np.stack(self._world_log["lights"]) if self._world_log["lights"] else np.zeros((0, 0), np.int8), - route_goals=self.route_goals, - stop_signs=self._stop_sign_lines, - dense_route=dense, - meta=json.dumps( - { - "town": Path(self.town_bin).stem.split("__")[-1], - "town_bin": self.town_bin, - "dt": self.dt, - "tick_dt": self.tick_dt, - "offset": [self.transform.tx, self.transform.ty], - "z_offset": self.transform.tz, - } - ), - ) - - def _write_telemetry_row(self, ego_action, pd_flags, carla_flags): - """One CSV row per policy step: what the loop commanded vs achieved, - plus the nearest mapped light's state and both infraction sources - (pd_* = shadow pufferdrive env, carla_* = real CARLA ground truth).""" - ego = self.env.get_global_agent_state() - ex, ey = float(ego["x"][0]), float(ego["y"][0]) - cur = min(self.goal_window.current_index, len(self.route_goals) - 1) - goal_dist = float(np.hypot(self.route_goals[cur, 0] - ex, self.route_goals[cur, 1] - ey)) - near_dist, near_state = -1.0, -1 - if len(self.stop_line_centers): - d2 = (self.stop_line_centers[:, 0] - ex) ** 2 + (self.stop_line_centers[:, 1] - ey) ** 2 - j = int(d2.argmin()) - near_dist, near_state = float(np.sqrt(d2[j])), int(self.last_light_states[j]) - current_speed = ( - float(np.asarray(self.env.observations)[0, 0]) * self._max_speed() - if self.dynamics_source == "pufferdrive" - else self.vehicle.get_velocity().length() - ) - self.telemetry_file.write( - f"{self.step},{current_speed:.3f},{self.target[0]:.3f}," - f"{ego_action},{self.goal_window.current_index},{goal_dist:.1f},{near_dist:.1f},{near_state}," - f"{pd_flags['collision']:.0f},{pd_flags['offroad']:.0f},{pd_flags['red_light']:.0f}," - f"{pd_flags['stop_sign']:.0f}," - f"{carla_flags['collision']:.0f},{carla_flags['offroad']:.0f},{carla_flags['red_light']:.0f}," - f"{carla_flags['stop_sign']:.0f}\n" - ) - def _maybe_save_infraction_clip(self, pd_flags, carla_flags): """Dump the rolling chase-cam buffer as one mp4 when either infraction source flags an ego infraction (collision/offroad/red-light/stop-sign), at most @@ -1005,61 +414,27 @@ def run_step(self, input_data, timestamp, sensors=None): self._poll_carla_red_light() # filters single-tick get_traffic_light() noise self._poll_carla_stop_sign() - obs = self._sync_carla() # shadow env <- CARLA ground truth - actions, aux = self._policy_actions(obs) - if self.obs_dump_dir: - self._obs_dump.append((self.step, obs[0].astype(np.float32), actions[0].astype(np.float32))) - if self.world_log_dir: - self._record_world_state(obs) - if self._obs_html is not None: - self._obs_html.capture(obs, actions, aux, aux.get("action_index")) - self.target = self._carla_integrate(actions) # policy intent, one dt ahead - if self.telemetry_file is not None or self.record_infractions_dir: - # _init_carla_infraction_detectors ran for either dir being set - pd_flags, carla_flags = self._ego_infractions(), self._carla_infractions() - if self.telemetry_file is not None: - self._write_telemetry_row(float(actions[0, 0]), pd_flags, carla_flags) + result = self.shadow.step(self._snapshot()) # shadow env <- CARLA ground truth, policy, one dt + if result.motion is not None: + self._teleport_carla_ego(result.motion) + if self._carla_collision is not None: + carla_flags = self._carla_infractions() + self.shadow.telemetry(result, carla_flags) if self.record_infractions_dir: - self._maybe_save_infraction_clip(pd_flags, carla_flags) + self._maybe_save_infraction_clip(result.pd_flags, carla_flags) - if self.dynamics_source == "pufferdrive": + if result.control is None: return carla.VehicleControl() - - # Controller runs every tick against the latest CARLA state, chasing the - # target held from the last policy step. - current_speed = self.vehicle.get_velocity().length() - current_yaw = self.vehicle.get_transform().rotation.yaw - target_speed, target_yaw = self.target - return self.controller.step(current_speed, current_yaw, target_speed, target_yaw, self.tick_dt) + steer, throttle, brake = result.control + return carla.VehicleControl(steer=steer, throttle=throttle, brake=brake) def destroy(self, results=None): if not self.initialized: return - print( - f"[puffer_agent] route done: goals {self.goal_window.current_index + 1}/{len(self.route_goals)}, " - f"tracking {self.controller.stats()}" - ) + self.shadow.finish() if self.carla_view_writer is not None: self.carla_view_writer.close() print(f"[puffer_agent] wrote CARLA chase-cam video for route {self.video_tag}") - if self.telemetry_file is not None: - self.telemetry_file.close() if self._carla_collision is not None: self._carla_collision.clean() # stop+destroy the spawned collision sensor actor - if self._goal_lane_debug_file is not None: - self._goal_lane_debug_file.close() - if self._obs_html is not None: - written = self._obs_html.write(render_html=self.obs_html_render) - print(f"[puffer_agent] wrote obs replay ({len(self._obs_html)} frames) -> {written}") - if self.world_log_dir and self._world_log["ego"]: - self._write_world_log() - if self.obs_dump_dir and self._obs_dump: - Path(self.obs_dump_dir).mkdir(parents=True, exist_ok=True) - np.savez_compressed( - Path(self.obs_dump_dir) / f"{self.video_tag}.npz", - step=np.array([s for s, _, _ in self._obs_dump], np.int32), - obs=np.stack([o for _, o, _ in self._obs_dump]), - action=np.stack([a for _, _, a in self._obs_dump]), - ) - self.env.close() self.initialized = False diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py new file mode 100644 index 0000000000..ba80e5c091 --- /dev/null +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -0,0 +1,820 @@ +"""PufferDrive shadow env + policy driving one ego from another simulator's ground truth. + +Shared by the CARLA leaderboard integrations: CaRL's Leaderboard 2.0 agent (leaderboard_agent.py, +CARLA 0.9.15, in-process) and the Leaderboard 1.0 client/server pair (lb1/, CARLA 0.9.10, whose +Python 3.7 cannot import PufferDrive). The simulator side only reads CARLA and moves the ego; +everything the policy needs happens here and never imports carla. Inputs and outputs are in the +CARLA frame (metres, degrees, left-handed y) as plain lists/dicts, so they also travel as JSON. + +Everything about the shadow env (obs layout, dynamics, dt, goal count, agent pool size, ...) comes +from the checkpoint's sibling config.yaml with the clean-eval profile applied on top (cosim/arch.py +CLEAN_EVAL_OVERRIDES); only the structural co-sim keys (map, pool wiring) are set here. + +Actor state rows (CARLA frame, physics quantities from CARLA's own physics): + ACTOR_STATE [x, y, z, yaw_deg, vx, vy, vz, angular_velocity_z_deg_s, accel_x, accel_y] + PARTNER_ROW ACTOR_STATE + [box_z, extent_x, extent_y, extent_z, is_walker, actor_id] + +init_route(route) once the ego exists, route = { + town, tick_dt, + ego: {extent: [x, y, z], box_z, wheels: controller.wheel_rows, state: ACTOR_STATE}, + sparse_plan: (N, 3) leaderboard target points, dense_plan: (M, 3) the dense route, + driving_waypoints: carla_bridge.driving_waypoint_samples rows, + lights: carla_bridge.light_geometry_from_carla dicts, stop_signs: carla_bridge.stop_sign_geometry_from_carla dicts, + infraction_flags: bool, also compute the shadow env's own infraction flags when no telemetry is written } +step(snapshot) every simulator tick, snapshot = { + ego: ACTOR_STATE, partners: PARTNER_ROW list (nearest first, at most partner_slots, |dz| <= PARTNER_MAX_ABS_DZ_M), + light_states: [[light_id, carla.TrafficLightState name], ...], ego_light_id: id reported for the ego or -1 } + +Environment variables: + COSIM_DEVICE=cpu torch device for the policy + COSIM_DYNAMICS_SOURCE=pufferdrive + "pufferdrive" (default): PufferDrive's own dynamics (the ones the policy + trained on) move the ego; StepResult.motion is the pose the simulator + teleports the ego to every step. + "carla": TrackingController converts the shadow env's target (speed, yaw) + into StepResult.control = (steer, throttle, brake) and CARLA's own vehicle + physics moves the ego (subject to dynamics-mismatch tracking lag). + COSIM_MAX_SPEED_MPS=20 ego speed cap; sets the shadow env's C_vel = cap / base_max_speed_mps. + Default: the checkpoint's base_max_speed_mps (C_vel = 1). Must lie in the + trained range base * [1/conditioning_speed_scale, scale] + (0036: 13.3-30 m/s, 0038+: 10-40 m/s). + COSIM_ZERO_PARTNER_STOPPED_TIME=0 + 1: every partner's seconds_stopped observation feature is held at 0 (the + ego's own counter is untouched). Ablates the stopped-time cue behind + parked-car / red-light-queue avoidance. + COSIM_PEDESTRIAN_MIN_SIZE_M=0.0 + walker partner boxes below this on either axis grow to it (nuPlan's + pedestrian_min_size_m; the training spawn floor is 0.8 m, CARLA walkers + are 0.4-0.5 m) + COSIM_TELEMETRY=/dir write a per-policy-step CSV per route, with the shadow env's and the + simulator's infraction flags as columns + COSIM_OBS_HTML=/dir write an interactive pufferlib.viz replay per route (the exact obs + + policy outputs the ego saw) + COSIM_OBS_HTML_RENDER=1 0: save only the compact .replay.zlib per route; + scripts/eval/render_carla_obs_html.py renders the pages of a whole run + into one gallery afterwards + COSIM_OBS_HTML_MAX_STEPS=12000 frames kept per route replay + COSIM_DUMP_OBS=/dir write the raw ego obs + action per policy step per route (.npz) + COSIM_WORLD_LOG=/dir write the bin-frame world state per policy step per route (ego, + partners, light states, route); input of scripts/eval/analyze_carla_cosim.py + COSIM_DEBUG_GOAL_LANE=/dir per-step CSV of the current goal vs the lane find_goal_lane snapped it to +""" + +import json +import math +import os +from pathlib import Path + +import numpy as np + +from pufferlib.ocean.cosim import carla_bridge as cb +from pufferlib.ocean.cosim.arch import checkpoint_config_path, shadow_env_kwargs +from pufferlib.ocean.cosim.carla.controller import TrackingController, vehicle_geometry_from_wheels +from pufferlib.ocean.cosim.goals import RouteGoalWindow, route_goals_from_target_points +from pufferlib.ocean.drive.drive import Drive + + +ACTOR_STATE_COLUMNS = 10 +PARTNER_ROW_COLUMNS = 16 +( + STATE_X, + STATE_Y, + STATE_Z, + STATE_YAW_DEG, + STATE_VX, + STATE_VY, + STATE_VZ, + STATE_ANGULAR_VELOCITY_Z, + STATE_ACCEL_X, + STATE_ACCEL_Y, +) = range(ACTOR_STATE_COLUMNS) +( + PARTNER_BOX_Z, + PARTNER_EXTENT_X, + PARTNER_EXTENT_Y, + PARTNER_EXTENT_Z, + PARTNER_IS_WALKER, + PARTNER_ACTOR_ID, +) = range(ACTOR_STATE_COLUMNS, PARTNER_ROW_COLUMNS) + +FAR_AWAY = 1.0e6 # park unused shadow-env agent slots out of observation range +PARTNER_MAX_ABS_DZ_M = 20.0 # CARLA actors farther above/below the ego are hidden scenario props, not traffic +DEFAULT_BASE_MAX_SPEED_MPS = 20.0 # Drive() defaults, for checkpoints whose config predates the keys +DEFAULT_CONDITIONING_SPEED_SCALE = 1.5 +MAX_POLICY_STEPS = 100_000 # shadow-env episode cap (~1.4 h at the 0.05 s CARLA tick); sizes the light state buffers +SCENARIO_LENGTH_MARGIN_STEPS = 2 +MIN_PARTNER_SIZE_M = 0.1 +# Shadow-env metrics_array indices (datatypes.h): collision/offroad/red-light/stop-sign flags. +EGO_INFRACTION_METRICS = {"collision": 0, "offroad": 1, "red_light": 2, "stop_sign": 3} +DYNAMICS_SOURCES = ("carla", "pufferdrive") +TELEMETRY_HEADER = ( + "step,current_speed,target_speed,ego_action,goal_cursor,goal_dist_m," + "near_light_dist_m,near_light_state," + "pd_infr_collision,pd_infr_offroad,pd_infr_red,pd_infr_stop," + "carla_infr_collision,carla_infr_offroad,carla_infr_red,carla_infr_stop\n" +) +GOAL_LANE_DEBUG_HEADER = ( + "step,ego_x,ego_y,goal_x,goal_y,gdir_x,gdir_y,goal_lane_idx,lane_dir_dot_route,lane_dist_to_goal_m\n" +) + + +def clean_policy_state_dict(state_dict): + """Strip torch.compile / DDP prefixes (wandb, neptune, the _C kernel, ...) + from the leaderboard's evaluation environment.""" + + def clean(key): + while key.startswith(("module.", "_orig_mod.")): + key = key.split(".", 1)[1] + return key + + return {clean(k): v for k, v in state_dict.items()} + + +def resolve_checkpoint(path_to_conf_file): + """(checkpoint_path, config_dict) from a .pt file or an experiment dir.""" + import yaml + + p = Path(path_to_conf_file).resolve() + if p.is_file(): + ckpt, cfg_path = p, checkpoint_config_path(p) + else: + models = sorted((p / "models").glob("*.pt")) or sorted(p.glob("*.pt")) + if not models: + raise FileNotFoundError(f"no .pt checkpoint under {p}") + ckpt, cfg_path = models[-1], p / "config.yaml" + with open(cfg_path) as f: + cfg = yaml.safe_load(f) + return str(ckpt), cfg + + +def cosim_max_speed_mps(env_cfg): + """COSIM_MAX_SPEED_MPS as a float inside the checkpoint's trained C_vel range, or None when unset.""" + raw = os.environ.get("COSIM_MAX_SPEED_MPS") + if raw is None: + return None + max_speed_mps = float(raw) + base_max_speed_mps = float(env_cfg.get("base_max_speed_mps") or DEFAULT_BASE_MAX_SPEED_MPS) + scale = float(env_cfg.get("conditioning_speed_scale") or DEFAULT_CONDITIONING_SPEED_SCALE) + low_mps, high_mps = base_max_speed_mps / scale, base_max_speed_mps * scale + if not (low_mps <= max_speed_mps <= high_mps): + raise ValueError( + f"COSIM_MAX_SPEED_MPS={max_speed_mps} is outside the trained C_vel range " + f"[{low_mps:.1f}, {high_mps:.1f}] m/s (base_max_speed_mps={base_max_speed_mps}, " + f"conditioning_speed_scale={scale})" + ) + return max_speed_mps + + +def env_flag(name, default="0"): + raw = os.environ.get(name, default) + if raw not in ("0", "1"): + raise ValueError(f"{name} must be '0' or '1', got {raw!r}") + return raw == "1" + + +def ego_speed_from_state(state): + """CARLA's Vector3D.length() of the ego velocity (3-D, as the leaderboard criteria read it).""" + return math.sqrt(state[STATE_VX] ** 2 + state[STATE_VY] ** 2 + state[STATE_VZ] ** 2) + + +class StepResult: + """One policy step: `actions` (num_agents, act_dim), the shadow env's infraction flags (None unless + requested), and the ego command -- `motion` = [x0, y0, yaw0_deg, speed0, x1, y1, yaw1_deg, speed1, z1] + (CARLA frame, pre- and post-step pose to teleport along) in pufferdrive mode, `control` = (steer, + throttle, brake) in carla mode; `target` = (speed_mps, yaw_deg) the policy intends one dt ahead.""" + + def __init__(self, actions, target, motion=None, control=None, pd_flags=None): + self.actions = actions + self.target = target + self.motion = motion + self.control = control + self.pd_flags = pd_flags + + +class ShadowEgo: + def __init__(self, path_to_conf_file, route_tag): + self.route_tag = route_tag + self.checkpoint, self.cfg = resolve_checkpoint(path_to_conf_file) + self.device = os.environ.get("COSIM_DEVICE", "cpu") + self.dynamics_source = os.environ.get("COSIM_DYNAMICS_SOURCE", "pufferdrive") + if self.dynamics_source not in DYNAMICS_SOURCES: + raise ValueError(f"COSIM_DYNAMICS_SOURCE must be one of {DYNAMICS_SOURCES}, got {self.dynamics_source!r}") + env_cfg = self.cfg["env"] + self.max_speed_mps = cosim_max_speed_mps(env_cfg) + self.zero_partner_stopped_time = env_flag("COSIM_ZERO_PARTNER_STOPPED_TIME") + self.pedestrian_min_size_m = float(os.environ.get("COSIM_PEDESTRIAN_MIN_SIZE_M", "0.0")) + if not (self.pedestrian_min_size_m >= 0.0): + raise ValueError(f"COSIM_PEDESTRIAN_MIN_SIZE_M must be >= 0, got {self.pedestrian_min_size_m}") + # Shadow agent pool == the training per-env cap + self.num_agents = int(env_cfg["max_agents_per_env"]) + + self.telemetry_dir = os.environ.get("COSIM_TELEMETRY", None) + self.telemetry_file = None + self.obs_html_dir = os.environ.get("COSIM_OBS_HTML", None) + self.obs_dump_dir = os.environ.get("COSIM_DUMP_OBS", None) + self._obs_dump = [] + self.world_log_dir = os.environ.get("COSIM_WORLD_LOG", None) + self._world_log = {"ego": [], "partners": [], "lights": []} + self._last_partners = None + self._partner_stopped_s = {} # CARLA actor id -> seconds at standstill + from pufferlib.ocean.drive import binding + + self._partner_stopped_speed_threshold = float(binding.AGENT_STOPPED_SPEED_THRESHOLD) + self.obs_html_max_steps = int(os.environ.get("COSIM_OBS_HTML_MAX_STEPS", "12000")) + self.obs_html_render = env_flag("COSIM_OBS_HTML_RENDER", "1") + self._obs_html = None + self.debug_goal_lane_dir = os.environ.get("COSIM_DEBUG_GOAL_LANE", None) + self._goal_lane_debug_file = None + self._road_data = None + + self.step_count = 0 + self.initialized = False + self.infraction_flags = False + self.policy = None + self.env = None + self.goal_window = None + self.target = (0.0, 0.0) # (target_speed, target_yaw_deg), held between policy steps + self._ego_speed_carla = 0.0 # CARLA's own ego speed at the last sync (carla dynamics telemetry) + self._stop_sign_lines = np.zeros((0, 6), np.float32) # CARLA trigger volumes as bin-frame stop lines + + @property + def partner_slots(self): + return self.num_agents - 1 + + def _load_policy_and_env(self, town_bin): + arch = shadow_env_kwargs( + self.cfg, + overrides=dict( + map_dir=town_bin, + num_maps=1, + # One policy agent (the ego); every other slot is a static partner streamed from CARLA. + num_agents=1, + min_agents_per_env=1, + cosim_partner_slots=self.partner_slots, + goal_source="external", + dt=self.dt, + scenario_length=MAX_POLICY_STEPS + SCENARIO_LENGTH_MARGIN_STEPS, + resample_frequency=0, + termination_mode=0, + # Enforcement off (detection flags still fire): in one endless + # episode a "stop" latch is permanent and fires only AFTER + # CARLA scored the infraction (route 5 froze 320 s -> DNF). + collision_behavior="ignore", + offroad_behavior="ignore", + traffic_light_behavior="ignore", + **({} if self.max_speed_mps is None else {"max_speed_mps": self.max_speed_mps}), + ), + ) + self.dynamics_model = arch.get("dynamics_model", "classic") + # jerk dynamics build accel across steps while sync() re-seeds accel + self.speed_intent_extension_s = 2.0 * self.dt if self.dynamics_model == "jerk" else 0.0 + + self.env = Drive(**arch) + self.env.reset() + + import torch + + import pufferlib.ocean.torch as drive_torch + + policy_state_dict = clean_policy_state_dict( + torch.load(self.checkpoint, map_location=self.device, weights_only=False) + ) + env_for_policy = self.env + self._boundary_feat_ckpt = None + bw = policy_state_dict.get("actor_backbone.boundary_encoder.0.weight") + if bw is not None and bw.shape[1] < env_for_policy.boundary_features: + # Older checkpoints emit fewer boundary features than this drive.h + # (e.g. 7 vs 9 -- two zero-padded GPS columns were added later). + import copy + + self._boundary_feat_ckpt = int(bw.shape[1]) + env_for_policy = copy.copy(self.env) + env_for_policy.boundary_features = self._boundary_feat_ckpt + print( + f"[puffer_agent] checkpoint uses {self._boundary_feat_ckpt} boundary features " + f"(env emits {self.env.boundary_features}); stripping zero-padded columns" + ) + + policy_cls = getattr(drive_torch, self.cfg.get("policy_name", "Drive")) + # pre-3.0 checkpoints keep action_type only in the env section + self.cfg["policy"].setdefault("action_type", self.cfg["env"]["action_type"]) + self.policy = policy_cls(env_for_policy, **self.cfg["policy"]).to(self.device) + self.policy.load_state_dict(policy_state_dict) + self.policy.eval() + print(f"[puffer_agent] loaded policy from {self.checkpoint}") + + def init_route(self, route): + """Build the shadow env for this route's town and seed it from the simulator's ground truth (see + the module docstring for the `route` dict). Read-only with respect to the simulator.""" + town = route["town"] + self.tick_dt = float(route["tick_dt"]) + self.dt = self.tick_dt # the policy runs every CARLA tick, like the leaderboard's sensor agents + self.infraction_flags = bool(route.get("infraction_flags", False)) + ego = route["ego"] + self.ego_extent = [float(v) for v in ego["extent"]] + self.ego_box_z = float(ego["box_z"]) + + self.town_bin = cb.bin_path_for_town(town) + self._load_policy_and_env(self.town_bin) + + self.transform = cb.CarlaTransform(town, offset=cb.town_offset(self.town_bin)) + offset, z_offset, residual_before, residual_after = cb.calibrate_town_offset( + route["driving_waypoints"], self.transform, self.town_bin + ) + print( + f"[puffer_agent] bin offset calibrated against CARLA lanes: shift " + f"({offset[0] - self.transform.tx:+.2f}, {offset[1] - self.transform.ty:+.2f}) m, z {z_offset:+.2f} m, " + f"median lane residual {residual_before:.2f} -> {residual_after:.2f} m" + ) + self.transform = cb.CarlaTransform(town, offset=offset, z_offset=z_offset) + ego_state = [float(v) for v in ego["state"]] + if self.dynamics_source == "pufferdrive": + self._sync_ego(ego_state, zero_velocity=True) + target_points = np.asarray(route["sparse_plan"], dtype=np.float64).reshape(-1, 3).copy() + dense_points = np.asarray(route["dense_plan"], dtype=np.float64).reshape(-1, 3).copy() + self.dense_plan_xy = dense_points[:, :2].copy() # CARLA frame, for the world log + target_points[:, 2] = self.transform.z_to_bin(target_points[:, 2]) + dense_points[:, 2] = self.transform.z_to_bin(dense_points[:, 2]) + self.route_goals = route_goals_from_target_points( + target_points, + dense_points, + self.transform.loc_to_bin, + self.transform.loc_to_bin(ego_state[STATE_X], ego_state[STATE_Y]), + self.env.goal_radius, + ) + # the leaderboard's target points, next num_goals of them at all times (fewer only at the route end) + self.goal_window = RouteGoalWindow(self.env, self.route_goals, sliding=True) + + import data_utils.mirror_map_bin as mbin + + bin_data = mbin.read_bin(Path(self.town_bin)) + bin_traffic = bin_data["traffic"] + self.stop_line_centers = np.array( + [ + [0.5 * (t["stop_line"][0] + t["stop_line"][3]), 0.5 * (t["stop_line"][1] + t["stop_line"][4])] + for t in bin_traffic + ] + ).reshape(-1, 2) + + if self.debug_goal_lane_dir: + # goal_lane_idx comes from road_elements[] in the C file; bin_data["roads"] + # is the same bin read in the same order, so it's usable as-is. + self._road_data = bin_data["roads"] + Path(self.debug_goal_lane_dir).mkdir(parents=True, exist_ok=True) + self._goal_lane_debug_file = open(Path(self.debug_goal_lane_dir) / f"{self.route_tag}.csv", "w") + self._goal_lane_debug_file.write(GOAL_LANE_DEBUG_HEADER) + + lights = route["lights"] + light_map, bin_num_traffic = cb.map_lights_to_bin(lights, self.transform, self.town_bin) + # the shadow env runs on CARLA's trigger volumes: the bin's exported stop lines sit up to 9 m off and miss a few + self._stop_sign_lines, stop_sign_headings = cb.stop_sign_lines(route["stop_signs"], self.transform) + self.num_traffic = self.env.set_stop_signs(self._stop_sign_lines, stop_sign_headings) + print( + f"[puffer_agent] stop signs: {len(stop_sign_headings)} CARLA trigger volumes replace the bin's " + f"(traffic elements {bin_num_traffic} -> {self.num_traffic})" + ) + # One id -> bin-indices table, reused by both passes in + # _read_light_states, so they can't disagree on which bin element a + # given light maps to (a prior independent geometric lookup for the + # ego-governance pass picked the wrong element ~5% of the time). + self.light_bin_indices_by_id = {int(light["id"]): light_map[i] for i, light in enumerate(lights)} + self.last_light_states = np.zeros(self.num_traffic, np.int32) + + wheelbase, max_steer = vehicle_geometry_from_wheels(ego["wheels"]) + self.controller = TrackingController(wheelbase_m=wheelbase, max_steer_rad=max_steer, horizon_s=self.dt) + + if self.obs_html_dir: + from pufferlib.ocean.cosim.obs_replay import ObsReplayCapture + + Path(self.obs_html_dir).mkdir(parents=True, exist_ok=True) + self._obs_html = ObsReplayCapture( + self.env, self.policy, Path(self.obs_html_dir) / self.route_tag, max_steps=self.obs_html_max_steps + ) + + if self.telemetry_dir: + Path(self.telemetry_dir).mkdir(parents=True, exist_ok=True) + self.telemetry_file = open(Path(self.telemetry_dir) / f"{self.route_tag}.csv", "w") + self.telemetry_file.write(TELEMETRY_HEADER) + + print( + f"[puffer_agent] town={town} tick_dt={self.tick_dt} dt={self.dt} route_goals={len(self.route_goals)} " + f"max_speed_mps={self.env.max_speed_mps:.1f} (C_vel={self.env.max_speed_mps / self.env.base_max_speed_mps:.2f}) " + f"zero_partner_stopped_time={int(self.zero_partner_stopped_time)} " + f"pedestrian_min_size_m={self.pedestrian_min_size_m:g}" + ) + self.initialized = True + + # --- shadow-env sync (read-only w.r.t. the simulator) ------------------- + + def _read_states(self, partners): + idx, x, y, z, h, vx, vy, yaw_rate, accel_long, stopped_s = [], [], [], [], [], [], [], [], [], [] + stopped_by_id = {} + for j, row in enumerate(partners): + idx.append(1 + j) # agent 0 = ego; others fill 1..M + bx, by, bz, bh, bvx, bvy, byr, bal = self.transform.state_to_bin( + row[STATE_X], + row[STATE_Y], + row[STATE_Z], + row[STATE_YAW_DEG], + row[STATE_VX], + row[STATE_VY], + row[STATE_ANGULAR_VELOCITY_Z], + row[STATE_ACCEL_X], + row[STATE_ACCEL_Y], + row[PARTNER_BOX_Z], + row[PARTNER_EXTENT_Z], + ) + x.append(bx) + y.append(by) + z.append(bz) + h.append(bh) + vx.append(bvx) + vy.append(bvy) + yaw_rate.append(byr) + accel_long.append(bal) + # stopped time follows the CARLA actor, not the shadow slot (slots reshuffle by distance every step) + actor_id = int(row[PARTNER_ACTOR_ID]) + is_stopped = math.hypot(bvx, bvy) <= self._partner_stopped_speed_threshold + stopped_by_id[actor_id] = self._partner_stopped_s.get(actor_id, 0.0) + self.dt if is_stopped else 0.0 + stopped_s.append(stopped_by_id[actor_id]) + self._partner_stopped_s = stopped_by_id + if self.zero_partner_stopped_time: + stopped_s = [0.0] * len(partners) + return ( + np.array(idx, np.int32), + np.array(x, np.float32), + np.array(y, np.float32), + np.array(z, np.float32), + np.array(h, np.float32), + np.array(vx, np.float32), + np.array(vy, np.float32), + np.array(yaw_rate, np.float32), + np.array(accel_long, np.float32), + np.array(stopped_s, np.float32), + ) + + def _read_sizes(self, partners): + idx, length, width = [], [], [] + for j, row in enumerate(partners): + idx.append(1 + j) + walker_floor_m = self.pedestrian_min_size_m if row[PARTNER_IS_WALKER] > 0.0 else 0.0 + length.append(max(2.0 * row[PARTNER_EXTENT_X], MIN_PARTNER_SIZE_M, walker_floor_m)) + width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M, walker_floor_m)) + return (np.array(idx, np.int32), np.array(length, np.float32), np.array(width, np.float32)) + + def _read_light_states(self, light_states, ego_light_id): + """Ground truth for every mapped bin traffic element, via the + precomputed light_bin_indices_by_id table (see init_route).""" + # unmapped light elements read OFF (a training state), never UNKNOWN + states = np.full(self.num_traffic, cb.TRAFFIC_LIGHT_STATE_OFF, np.int32) + state_by_id = {} + for light_id, state_name in light_states: + state = cb.light_state_from_name(state_name) + state_by_id[int(light_id)] = state + for j in self.light_bin_indices_by_id[int(light_id)]: + if 0 <= j < self.num_traffic: + states[j] = state + ego_light_id = int(ego_light_id) + if ego_light_id in self.light_bin_indices_by_id: + state = state_by_id[ego_light_id] + for j in self.light_bin_indices_by_id[ego_light_id]: + if 0 <= j < self.num_traffic: + states[j] = state + return states + + def _sync_ego(self, ego_state, zero_velocity=False): + """Overwrite agent 0 (ego) from the simulator's ground-truth pose/size.""" + bin_state = self.transform.state_to_bin( + ego_state[STATE_X], + ego_state[STATE_Y], + ego_state[STATE_Z], + ego_state[STATE_YAW_DEG], + ego_state[STATE_VX], + ego_state[STATE_VY], + ego_state[STATE_ANGULAR_VELOCITY_Z], + ego_state[STATE_ACCEL_X], + ego_state[STATE_ACCEL_Y], + self.ego_box_z, + self.ego_extent[2], + ) + if zero_velocity: + x, y, z, heading, *_ = bin_state + bin_state = (x, y, z, heading, 0.0, 0.0, 0.0, 0.0) + self.env.set_agent_states(np.array([0], np.int32), *[np.array([v], np.float32) for v in bin_state]) + self.env.set_agent_sizes( + np.array([0], np.int32), + np.array([2.0 * self.ego_extent[0]], np.float32), + np.array([2.0 * self.ego_extent[1]], np.float32), + ) + + def _sync(self, snapshot): + """Overwrite the shadow env's background agents/lights (+ ego, only in + dynamics_source='carla' mode) from the simulator's ground truth and return the + recomputed observation array (num_agents, obs_dim). Row 0 = ego.""" + ego_state = [float(v) for v in snapshot["ego"]] + self._ego_speed_carla = ego_speed_from_state(ego_state) + if self.dynamics_source == "carla": + self._sync_ego(ego_state) + ego = self.env.get_global_agent_state() + ebx, eby = float(ego["x"][0]), float(ego["y"][0]) + + partners = np.asarray(snapshot["partners"], dtype=np.float64).reshape(-1, PARTNER_ROW_COLUMNS) + if len(partners): + states = self._read_states(partners) + sizes = self._read_sizes(partners) + self.env.set_agent_states(*states) + self.env.set_agent_sizes(*sizes) + is_walker = (partners[:, PARTNER_IS_WALKER] > 0.0).astype(np.float32) + self._last_partners = (states[1], states[2], states[4], states[5], states[6], sizes[1], sizes[2], is_walker) + else: + self._last_partners = None + n_used = 1 + len(partners) + if n_used < self.num_agents: + sp = np.arange(n_used, self.num_agents, dtype=np.int32) + zf = np.full(len(sp), FAR_AWAY, np.float32) + zz = np.zeros_like(zf) + self.env.set_agent_states(sp, zf, zf, zf, zz, zz, zz, zz, zz) + + self.last_light_states = self._read_light_states(snapshot["light_states"], snapshot["ego_light_id"]) + self.env.set_traffic_light_states(self.last_light_states) + + self.goal_window.sync(ebx, eby, float(ego["heading"][0])) + if self._goal_lane_debug_file is not None: + self._write_goal_lane_debug_row(ebx, eby, self.route_goals[self.goal_window.current_index]) + return np.asarray(self.env.recompute_observations()) + + def _write_goal_lane_debug_row(self, ebx, eby, current_goal): + """One CSV row: the CURRENT goal (window slot 0, current_goal_idx after + the reset in c_set_agent_goals) vs the lane find_goal_lane snapped it + to -- that lane's direction (nearest segment to the goal, mirroring + find_goal_lane's own point-to-segment search) dotted against the route + direction used at snap time, and the snap distance. dot < 0 means the + snapped lane runs opposite the route (oncoming lane); a large snap + distance means no nearby lane passed find_goal_lane's gates at all.""" + gx, gy, _, gdir_x, gdir_y = current_goal + state = self.env.get_state() + scenario = state[0] if isinstance(state, list) else state + ego_agent = (scenario.get("agents") or [{}])[0] + lane_idx = int(ego_agent.get("goal_lane_idx", -1)) + dot, dist = float("nan"), float("nan") + if lane_idx >= 0 and self._road_data is not None and lane_idx < len(self._road_data): + road = self._road_data[lane_idx] + xs, ys = np.asarray(road["x"], dtype=np.float64), np.asarray(road["y"], dtype=np.float64) + if len(xs) >= 2: + seg_dx, seg_dy = xs[1:] - xs[:-1], ys[1:] - ys[:-1] + seg_len = np.hypot(seg_dx, seg_dy) + seg_len_safe = np.where(seg_len > 1e-6, seg_len, 1.0) + t = np.clip(((gx - xs[:-1]) * seg_dx + (gy - ys[:-1]) * seg_dy) / (seg_len_safe**2), 0.0, 1.0) + px, py = xs[:-1] + t * seg_dx, ys[:-1] + t * seg_dy + d = np.hypot(gx - px, gy - py) + k = int(np.argmin(d)) + dist = float(d[k]) + route_norm = math.hypot(gdir_x, gdir_y) + if seg_len[k] > 1e-6 and route_norm > 1e-6: + dot = float((gdir_x * seg_dx[k] + gdir_y * seg_dy[k]) / (seg_len[k] * route_norm)) + self._goal_lane_debug_file.write( + f"{self.step_count},{ebx:.2f},{eby:.2f},{gx:.2f},{gy:.2f},{gdir_x:.3f},{gdir_y:.3f}," + f"{lane_idx},{dot:.3f},{dist:.2f}\n" + ) + self._goal_lane_debug_file.flush() + + def _integrate(self, actions, ego_state): + """Step the shadow env ONCE (one policy step == one shadow tick). + + dynamics_source='carla': the tracking controller chases the ego's target kinematic state + with CARLA's physics -> StepResult.control. + + dynamics_source='pufferdrive': PufferDrive's own jerk/classic dynamics (the ones the policy + trained on) ARE the ego's motion -- no target to chase. StepResult.motion is the pre/post-step + pose the simulator teleports the ego along, where the next tick's observation reads it. + """ + if self.step_count >= MAX_POLICY_STEPS: + raise RuntimeError(f"route exceeded MAX_POLICY_STEPS={MAX_POLICY_STEPS} policy steps") + before = self.env.get_global_agent_state() + speed_before = self._ego_speed() + self.env.step(actions) + after = self.env.get_global_agent_state() + target_yaw_deg = self.transform.bin_heading_to_yaw(float(after["heading"][0])) + speed_after = self._ego_speed() + accel_after = float(np.asarray(self.env.observations)[0][4]) * self._accel_long_norm() + + if self.dynamics_source == "pufferdrive": + x0, y0 = self.transform.bin_to_loc(float(before["x"][0]), float(before["y"][0])) + x1, y1 = self.transform.bin_to_loc(float(after["x"][0]), float(after["y"][0])) + yaw0_deg = self.transform.bin_heading_to_yaw(float(before["heading"][0])) + z1 = self.transform.z_to_carla(float(after["z"][0])) + self.target = (speed_after, target_yaw_deg) # logged, not chased + return StepResult( + actions, self.target, motion=[x0, y0, yaw0_deg, speed_before, x1, y1, target_yaw_deg, speed_after, z1] + ) + + target_speed = speed_after + max(accel_after, 0.0) * self.speed_intent_extension_s + self.target = (target_speed, target_yaw_deg) + # Controller runs every tick against the latest CARLA state, chasing the target of this policy step. + control = self.controller.step( + self._ego_speed_carla, ego_state[STATE_YAW_DEG], target_speed, target_yaw_deg, self.tick_dt + ) + return StepResult(actions, self.target, control=control) + + def step(self, snapshot): + """Shadow env <- simulator ground truth, policy, one dt of dynamics -> StepResult.""" + self.step_count += 1 + obs = self._sync(snapshot) + actions, aux = self._policy_actions(obs) + if self.obs_dump_dir: + self._obs_dump.append((self.step_count, obs[0].astype(np.float32), actions[0].astype(np.float32))) + if self.world_log_dir: + self._record_world_state(obs) + if self._obs_html is not None: + self._obs_html.capture(obs, actions, aux, aux.get("action_index")) + result = self._integrate(actions, [float(v) for v in snapshot["ego"]]) + if self.telemetry_file is not None or self.infraction_flags: + result.pd_flags = self._ego_infractions() + return result + + def _ego_speed(self): + return float(np.asarray(self.env.observations)[0][0]) * self._max_speed() + + def _max_speed(self): + return self.env.obs_norm_speed_mps + + def _accel_long_norm(self): + from pufferlib.ocean.drive import binding + + return binding.ACCEL_LONG_NORM + + def _ego_infractions(self): + """{'collision': f, 'offroad': f, 'red_light': f, 'stop_sign': f} from the shadow + pufferdrive env's own model of the ego (compute_metrics, refreshed by + the last integrate()) -- what the policy would have caused according + to PufferDrive's own dynamics, not necessarily what the real CARLA + vehicle did (the simulator side's own detectors tell that).""" + state = self.env.get_state() + scenario = state[0] if isinstance(state, list) else state + agents = scenario.get("agents") or [] + if not agents: + return {name: 0.0 for name in EGO_INFRACTION_METRICS} + metrics = agents[0].get("metrics_array") or [] + return { + name: float(metrics[idx]) if idx < len(metrics) else 0.0 for name, idx in EGO_INFRACTION_METRICS.items() + } + + # --- policy + capture --------------------------------------------------- + + def _adapt_obs_for_policy(self, obs): + """Strip the trailing zero-padded boundary columns when the checkpoint + predates them (see _load_policy_and_env). No-op otherwise.""" + if self._boundary_feat_ckpt is None: + return obs + env = self.env + n_slots = env.obs_slots_boundary_kept + feat_env, feat_ckpt = env.boundary_features, self._boundary_feat_ckpt + b0 = ( + env.ego_features + + env.num_reward_coefs + + env.goal_dim + + env.obs_slots_partners_n * env.partner_features + + env.obs_slots_lane_kept * env.lane_features + ) + b1 = b0 + n_slots * feat_env + obs = np.asarray(obs) + boundary = obs[:, b0:b1].reshape(obs.shape[0], n_slots, feat_env)[:, :, :feat_ckpt] + return np.concatenate([obs[:, :b0], boundary.reshape(obs.shape[0], -1), obs[:, b1:]], axis=1) + + def _policy_actions(self, obs): + """-> (actions (num_agents, act_dim) int32, aux dict). aux carries + per-agent value/entropy/action-probs/pool for the obs_html capture""" + import torch + + import pufferlib.pytorch + import pufferlib.spaces + + obs = self._adapt_obs_for_policy(obs) + # A discrete policy head on a continuous env needs the bin->continuous + # mapping (pufferl.py feeds cont_action to the env, never the bin indices). + env_continuous = isinstance(self.env.single_action_space, pufferlib.spaces.Box) + action_selection = ( + pufferlib.pytorch.ACTION_SELECT_MEAN + if env_continuous and not self.policy.is_continuous + else pufferlib.pytorch.ACTION_SELECT_MODE + ) + with torch.no_grad(): + logits, value = self.policy.forward_eval(torch.as_tensor(obs).to(self.device)) + action, _, entropy, cont_action = pufferlib.pytorch.sample_logits( + logits, action_selection=action_selection, env_continuous=env_continuous, policy=self.policy + ) + env_action = cont_action if env_continuous and cont_action is not None else action + actions = env_action.cpu().numpy().reshape(1, -1) + if not env_continuous: + actions = actions.astype(np.int32) + aux = {} + if self._obs_html is not None: + aux = self._obs_html.policy_outputs(torch.as_tensor(obs).to(self.device), logits, value, entropy) + aux["action_index"] = action.cpu().numpy().reshape(-1) if not self.policy.is_continuous else None + return actions, aux + + def _record_world_state(self, obs): + """Bin-frame snapshot at sync time (pre-step ego, partners as streamed, light states).""" + ego = self.env.get_global_agent_state() + accel_long = float(obs[0][4]) * self._accel_long_norm() + step_idx = len(self._world_log["ego"]) + self._world_log["ego"].append( + ( + self.step_count, + float(ego["x"][0]), + float(ego["y"][0]), + float(ego["heading"][0]), + self._ego_speed(), + accel_long, + float(self.goal_window.current_index), + ) + ) + if self._last_partners is not None: + x, y, h, vx, vy, length, width, is_walker = self._last_partners + speed = np.hypot(vx, vy) + rows = np.column_stack([np.full(len(x), step_idx, np.float32), x, y, h, length, width, speed, is_walker]) + self._world_log["partners"].append(rows.astype(np.float32)) + self._world_log["lights"].append(self.last_light_states.astype(np.int8)) + + def _write_world_log(self): + Path(self.world_log_dir).mkdir(parents=True, exist_ok=True) + dense = np.array([self.transform.loc_to_bin(x, y) for x, y in self.dense_plan_xy], np.float32).reshape(-1, 2) + partners = self._world_log["partners"] + np.savez_compressed( + Path(self.world_log_dir) / f"{self.route_tag}.npz", + ego=np.array(self._world_log["ego"], np.float32), + partners=np.concatenate(partners) if partners else np.zeros((0, 8), np.float32), + lights=np.stack(self._world_log["lights"]) if self._world_log["lights"] else np.zeros((0, 0), np.int8), + route_goals=self.route_goals, + stop_signs=self._stop_sign_lines, + dense_route=dense, + meta=json.dumps( + { + "town": Path(self.town_bin).stem.split("__")[-1], + "town_bin": self.town_bin, + "dt": self.dt, + "tick_dt": self.tick_dt, + "offset": [self.transform.tx, self.transform.ty], + "z_offset": self.transform.tz, + } + ), + ) + + def telemetry(self, result, carla_flags): + """One CSV row per policy step (when COSIM_TELEMETRY is set): what the loop commanded vs + achieved, the nearest mapped light's state and both infraction sources (pd_* = shadow pufferdrive + env, carla_* = the simulator side's own ground truth, None -> all 0).""" + if self.telemetry_file is None: + return + pd_flags = result.pd_flags + carla_flags = carla_flags or {name: 0.0 for name in EGO_INFRACTION_METRICS} + ego = self.env.get_global_agent_state() + ex, ey = float(ego["x"][0]), float(ego["y"][0]) + cur = min(self.goal_window.current_index, len(self.route_goals) - 1) + goal_dist = float(np.hypot(self.route_goals[cur, 0] - ex, self.route_goals[cur, 1] - ey)) + near_dist, near_state = -1.0, -1 + if len(self.stop_line_centers): + d2 = (self.stop_line_centers[:, 0] - ex) ** 2 + (self.stop_line_centers[:, 1] - ey) ** 2 + j = int(d2.argmin()) + near_dist, near_state = float(np.sqrt(d2[j])), int(self.last_light_states[j]) + current_speed = ( + float(np.asarray(self.env.observations)[0, 0]) * self._max_speed() + if self.dynamics_source == "pufferdrive" + else self._ego_speed_carla + ) + self.telemetry_file.write( + f"{self.step_count},{current_speed:.3f},{self.target[0]:.3f}," + f"{float(result.actions[0, 0])},{self.goal_window.current_index},{goal_dist:.1f},{near_dist:.1f},{near_state}," + f"{pd_flags['collision']:.0f},{pd_flags['offroad']:.0f},{pd_flags['red_light']:.0f}," + f"{pd_flags['stop_sign']:.0f}," + f"{carla_flags['collision']:.0f},{carla_flags['offroad']:.0f},{carla_flags['red_light']:.0f}," + f"{carla_flags['stop_sign']:.0f}\n" + ) + + def finish(self): + """Route done: flush every per-route output and release the shadow env.""" + if not self.initialized: + return + print( + f"[puffer_agent] route done: goals {self.goal_window.current_index + 1}/{len(self.route_goals)}, " + f"tracking {self.controller.stats()}" + ) + if self.telemetry_file is not None: + self.telemetry_file.close() + if self._goal_lane_debug_file is not None: + self._goal_lane_debug_file.close() + if self._obs_html is not None: + written = self._obs_html.write(render_html=self.obs_html_render) + print(f"[puffer_agent] wrote obs replay ({len(self._obs_html)} frames) -> {written}") + if self.world_log_dir and self._world_log["ego"]: + self._write_world_log() + if self.obs_dump_dir and self._obs_dump: + Path(self.obs_dump_dir).mkdir(parents=True, exist_ok=True) + np.savez_compressed( + Path(self.obs_dump_dir) / f"{self.route_tag}.npz", + step=np.array([s for s, _, _ in self._obs_dump], np.int32), + obs=np.stack([o for _, o, _ in self._obs_dump]), + action=np.stack([a for _, _, a in self._obs_dump]), + ) + self.env.close() + self.initialized = False diff --git a/pufferlib/ocean/cosim/carla_bridge.py b/pufferlib/ocean/cosim/carla_bridge.py index 8931f3dae2..3b4828a622 100644 --- a/pufferlib/ocean/cosim/carla_bridge.py +++ b/pufferlib/ocean/cosim/carla_bridge.py @@ -93,17 +93,33 @@ def town_offset(bin_path: str): OFFSET_CALIBRATION_MAX_Z_OFFSET_M = 5.0 # exported bins sit 0-1.7 m below CARLA's ground; more means a frame mix-up -def calibrate_town_offset(carla_map, transform, town_bin): +DRIVING_WAYPOINT_COLUMNS = 5 # x, y, z, right_x, right_y in the CARLA frame + + +def driving_waypoint_samples(carla_map): + """(N, 5) [x, y, z, right_x, right_y] of CARLA's non-junction driving waypoints, calibrate_town_offset's input.""" + rows = [] + for wp in carla_map.generate_waypoints(OFFSET_CALIBRATION_SPACING_M): + if wp.is_junction or str(wp.lane_type) != "Driving": + continue + loc, right = wp.transform.location, wp.transform.get_right_vector() + rows.append((loc.x, loc.y, loc.z, right.x, right.y)) + return np.array(rows, dtype=np.float64).reshape(-1, DRIVING_WAYPOINT_COLUMNS) + + +def calibrate_town_offset(driving_waypoints, transform, town_bin): """(tx, ty) refined so the bin's lane polylines coincide with CARLA's lane centres. - The exported bins sit a constant fraction of a metre off the CARLA frame (measured 2026-09-23 with - the stored offsets: Town01 0.27 m, Town02 0.6 m), and the ego plus every streamed actor inherited it, - driving 0.44 m right of CARLA's lane centre on Town02 and clipping curbs the shadow env could not see. - Least squares of the translation over the lane-normal residuals of CARLA's driving waypoints against - the nearest bin lane segment, two passes (re-matched after the first shift), mismatches beyond - OFFSET_CALIBRATION_MATCH_MAX_M dropped. The bins also sit a constant per-town height below CARLA's - ground (Town03 0.77 m, Town04 1.24 m, Town05 1.64 m, Town06 0), so the same matches give the z offset as - the median of bin lane z minus waypoint z. Returns (offset, z_offset_m, residual_before_m, residual_after_m).""" + driving_waypoints: (N, 5) CARLA-frame [x, y, z, right_x, right_y] samples of the driving lanes + (driving_waypoint_samples). The exported bins sit a constant fraction of a metre off the CARLA frame + (measured 2026-09-23 with the stored offsets: Town01 0.27 m, Town02 0.6 m), and the ego plus every + streamed actor inherited it, driving 0.44 m right of CARLA's lane centre on Town02 and clipping curbs + the shadow env could not see. Least squares of the translation over the lane-normal residuals of the + waypoints against the nearest bin lane segment, two passes (re-matched after the first shift), + mismatches beyond OFFSET_CALIBRATION_MATCH_MAX_M dropped. The bins also sit a constant per-town height + below CARLA's ground (Town03 0.77 m, Town04 1.24 m, Town05 1.64 m, Town06 0), so the same matches give + the z offset as the median of bin lane z minus waypoint z. + Returns (offset, z_offset_m, residual_before_m, residual_after_m).""" data = _mbin.read_bin(Path(town_bin)) seg_start, seg_end, seg_z_start, seg_z_end = [], [], [], [] for road in data["roads"]: @@ -121,18 +137,11 @@ def calibrate_town_offset(carla_map, transform, town_bin): seg_z_end = np.concatenate(seg_z_end) seg_dir = seg_end - seg_start seg_len_sq = np.maximum((seg_dir**2).sum(1), 1e-9) - points, normals, waypoint_z = [], [], [] - for wp in carla_map.generate_waypoints(OFFSET_CALIBRATION_SPACING_M): - if wp.is_junction or str(wp.lane_type) != "Driving": - continue - loc, right = wp.transform.location, wp.transform.get_right_vector() - points.append(transform.loc_to_bin(loc.x, loc.y)) - normals.append((right.x, -right.y)) # y flips into the bin frame - waypoint_z.append(loc.z) - points = np.array(points, dtype=np.float64).reshape(-1, 2) - normals = np.array(normals, dtype=np.float64).reshape(-1, 2) + samples = np.asarray(driving_waypoints, dtype=np.float64).reshape(-1, DRIVING_WAYPOINT_COLUMNS) + points = np.array([transform.loc_to_bin(x, y) for x, y in samples[:, :2]], dtype=np.float64).reshape(-1, 2) + normals = np.column_stack([samples[:, 3], -samples[:, 4]]) # y flips into the bin frame normals /= np.maximum(np.hypot(normals[:, 0], normals[:, 1]), 1e-9)[:, None] - waypoint_z = np.array(waypoint_z, dtype=np.float64) + waypoint_z = samples[:, 2] def normal_residuals(shift): residuals = np.empty(len(points)) @@ -204,24 +213,41 @@ def yaw_to_bin(self, yaw_deg): def vel_to_bin(self, vx, vy): return vx, -vy + def state_to_bin(self, x, y, z, yaw_deg, vx, vy, angular_velocity_z_deg_s, accel_x, accel_y, box_z, box_extent_z): + """(x, y, z, heading, vx, vy, yaw_rate, accel_long) in the bin frame from a CARLA-frame actor state. + Angular velocity (deg/s) and acceleration (m/s^2, world frame) must come from CARLA's own physics, not + finite-differenced from a previous PufferDrive state: env.step() may have already overwritten this + actor's previous state with a throwaway dummy-action rollout. box_z/box_extent_z: bounding-box pivot + offset and half height (vehicles pivot at the ground, walkers at the capsule centre); bin z = ground.""" + bx, by = self.loc_to_bin(x, y) + heading = self.yaw_to_bin(yaw_deg) + yaw_rate = -math.radians( + angular_velocity_z_deg_s + ) # mirrored y flips rotation sense, like yaw_to_bin's negation + accel_long = accel_x * math.cos(heading) + (-accel_y) * math.sin(heading) # mirror y, like vel_to_bin + ground_z = z + box_z - box_extent_z + return (bx, by, self.z_to_bin(ground_z), heading, vx, -vy, yaw_rate, accel_long) + def actor_state_to_bin(self, actor): - """Return (x, y, z, heading, vx, vy, yaw_rate, accel_long) in the bin frame for a CARLA actor. - yaw_rate/accel_long come from CARLA's own physics (get_angular_velocity/get_acceleration), not - finite-differenced from a previous PufferDrive state: env.step() may have already overwritten - this actor's previous state with a throwaway dummy-action rollout by the time PufferDrive would - otherwise read "previous" state itself.""" + """state_to_bin of a live CARLA actor.""" tf = actor.get_transform() v = actor.get_velocity() - av = actor.get_angular_velocity() # deg/s, world frame (CARLA's rotation convention) - acc = actor.get_acceleration() # m/s^2, world frame - bx, by = self.loc_to_bin(tf.location.x, tf.location.y) - heading = self.yaw_to_bin(tf.rotation.yaw) - yaw_rate = -math.radians(av.z) # mirrored y flips rotation sense, like yaw_to_bin's negation - accel_x, accel_y = acc.x, -acc.y # mirror y, like vel_to_bin - accel_long = accel_x * math.cos(heading) + accel_y * math.sin(heading) - box = actor.bounding_box # pivot differs per asset (vehicles: ground, walkers: capsule centre); bin z = ground - ground_z = tf.location.z + box.location.z - box.extent.z - return (bx, by, self.z_to_bin(ground_z), heading, v.x, -v.y, yaw_rate, accel_long) + av = actor.get_angular_velocity() + acc = actor.get_acceleration() + box = actor.bounding_box + return self.state_to_bin( + tf.location.x, + tf.location.y, + tf.location.z, + tf.rotation.yaw, + v.x, + v.y, + av.z, + acc.x, + acc.y, + box.location.z, + box.extent.z, + ) # --- bin frame -> CARLA (to teleport the ego back into CARLA) --- def bin_to_loc(self, bx, by): @@ -234,23 +260,22 @@ def bin_heading_to_yaw(self, heading_rad): return -math.degrees(heading_rad) -# CARLA traffic-light state -> PufferDrive enum (datatypes.h:61-67) +# CARLA traffic-light state name -> PufferDrive enum (datatypes.h:61-67) # UNKNOWN=0 RED=1 YELLOW=2 GREEN=3 OFF=4 TRAFFIC_LIGHT_STATE_OFF = 4 +LIGHT_STATE_BY_NAME = {"Red": 1, "Yellow": 2, "Green": 3, "Off": 4, "Unknown": 0} -def carla_light_to_puffer(state) -> int: - import carla +def light_state_from_name(state_name) -> int: + """carla.TrafficLightState. (as sent over a co-sim protocol) -> PufferDrive enum.""" + return LIGHT_STATE_BY_NAME.get(state_name, 0) + - return { - carla.TrafficLightState.Red: 1, - carla.TrafficLightState.Yellow: 2, - carla.TrafficLightState.Green: 3, - carla.TrafficLightState.Off: 4, - carla.TrafficLightState.Unknown: 0, - }.get(state, 0) +def carla_light_to_puffer(state) -> int: + return light_state_from_name(state.name) +LIGHT_PROBE_STEPS_M = (0.0, 3.0, 6.0, 10.0, 15.0) # walk-back (all) / forward (all but 0) probes of map_lights_to_bin LIGHT_LANE_MATCH_MAX_DIST_M = 12.0 # measured Town01 controlled-lane stub distance (see below) LIGHT_LANE_FORWARD_MATCH_MAX_DIST_M = ( 3.0 # forward fallback lands ON the connector; a lane width away is the neighbour's @@ -288,7 +313,7 @@ def find(k): return clusters -def _drop_junction_outliers(mapping, lights, transform, stop_centers): +def _drop_junction_outliers(mapping, light_geometry, transform, stop_centers): """(light_idx, element_idx) pairs to remove from `mapping`: elements whose assigned light sits far outside where its junction cluster-mates' lights actually are -- see map_lights_to_bin's docstring.""" @@ -309,8 +334,8 @@ def _drop_junction_outliers(mapping, lights, transform, stop_centers): continue light_pos = {} for j in elements: - loc = lights[element_light[j]].get_location() - light_pos[j] = np.array(transform.loc_to_bin(loc.x, loc.y)) + light = light_geometry[element_light[j]] + light_pos[j] = np.array(transform.loc_to_bin(light["x"], light["y"])) for j in elements: others = np.array([light_pos[j2] for j2 in elements if j2 != j]) centroid = others.mean(axis=0) @@ -324,8 +349,62 @@ def _drop_junction_outliers(mapping, lights, transform, stop_centers): return to_drop -def map_lights_to_bin(lights, transform, town_bin): - """mapping[i] = list of bin traffic-element indices controlled by lights[i]. +def light_geometry_from_carla(lights): + """Plain-data geometry of live CARLA traffic lights for map_lights_to_bin (CARLA frame). + + One dict per light: id, x/y of the light actor, trigger_x/trigger_y of its trigger volume and one + entry per stop waypoint (get_stop_waypoints, CARLA >= 0.9.11) with x, y, yaw_deg, lane_width, + `backward` = [x, y] of the first waypoint LIGHT_PROBE_STEPS_M behind it (None where the lane ends) + and `forward` = [x, y, yaw_deg] of the first waypoint each non-zero step ahead of it.""" + geometry = [] + for light in lights: + location = light.get_location() + trigger = light.get_transform().transform(light.trigger_volume.location) + stop_waypoints = [] + for wp in light.get_stop_waypoints(): + backward, forward = [], [] + for step_m in LIGHT_PROBE_STEPS_M: + probes = [wp] if step_m == 0.0 else wp.previous(step_m) + backward.append( + None if not probes else [probes[0].transform.location.x, probes[0].transform.location.y] + ) + for step_m in LIGHT_PROBE_STEPS_M[1:]: + probes = wp.next(step_m) or [] + forward.append( + None + if not probes + else [ + probes[0].transform.location.x, + probes[0].transform.location.y, + probes[0].transform.rotation.yaw, + ] + ) + stop_waypoints.append( + { + "x": wp.transform.location.x, + "y": wp.transform.location.y, + "yaw_deg": wp.transform.rotation.yaw, + "lane_width": wp.lane_width, + "backward": backward, + "forward": forward, + } + ) + geometry.append( + { + "id": light.id, + "x": location.x, + "y": location.y, + "trigger_x": trigger.x, + "trigger_y": trigger.y, + "stop_waypoints": stop_waypoints, + } + ) + return geometry + + +def map_lights_to_bin(light_geometry, transform, town_bin): + """mapping[i] = list of bin traffic-element indices controlled by light_geometry[i] + (light_geometry_from_carla, or the same dicts built by a co-sim client without get_stop_waypoints). Semantic matching: each of the light's STOP WAYPOINTS is snapped to its bin LANE (nearest drivable-lane segment -- stop lines lie ON lane segments, so @@ -406,26 +485,26 @@ def controlling_elements_near(px, py, max_dist_m, heading=None): return controlling return [] - probe_steps_m = (0.0, 3.0, 6.0, 10.0, 15.0) mapping = [] - for lt in lights: + for light in light_geometry: element_indices = [] - for wp in lt.get_stop_waypoints(): + for stop_waypoint in light["stop_waypoints"]: if not len(seg_lane): continue controlling = [] - for step_m in probe_steps_m: - probes = [wp] if step_m == 0.0 else wp.previous(step_m) - for probe in probes[:1]: - bx, by = transform.loc_to_bin(probe.transform.location.x, probe.transform.location.y) - controlling = controlling_elements_near(bx, by, LIGHT_LANE_MATCH_MAX_DIST_M) + for probe in stop_waypoint["backward"]: + if probe is None: + continue + bx, by = transform.loc_to_bin(probe[0], probe[1]) + controlling = controlling_elements_near(bx, by, LIGHT_LANE_MATCH_MAX_DIST_M) if controlling: break - for step_m in probe_steps_m[1:] if not controlling else (): # fallback: forward into the junction - for probe in (wp.next(step_m) or [])[:1]: - bx, by = transform.loc_to_bin(probe.transform.location.x, probe.transform.location.y) - heading = transform.yaw_to_bin(probe.transform.rotation.yaw) - controlling = controlling_elements_near(bx, by, LIGHT_LANE_FORWARD_MATCH_MAX_DIST_M, heading) + for probe in stop_waypoint["forward"] if not controlling else (): # fallback: forward into the junction + if probe is None: + continue + bx, by = transform.loc_to_bin(probe[0], probe[1]) + heading = transform.yaw_to_bin(probe[2]) + controlling = controlling_elements_near(bx, by, LIGHT_LANE_FORWARD_MATCH_MAX_DIST_M, heading) if controlling: break element_indices.extend(controlling) @@ -437,22 +516,19 @@ def controlling_elements_near(px, py, max_dist_m, heading=None): for t in data["traffic"] ] ).reshape(-1, 2) - for light_idx, element_idx in _drop_junction_outliers(mapping, lights, transform, stop_centers): + for light_idx, element_idx in _drop_junction_outliers(mapping, light_geometry, transform, stop_centers): mapping[light_idx].remove(element_idx) return mapping, len(data["traffic"]) -def stop_signs_from_carla(world, carla_map, transform): - """(lines (K, 6), headings (K,)) in the bin frame, one per CARLA `traffic.stop` actor: the trigger - volume's centre projected onto its driving lane, spanning the trigger's width across that lane, with - the lane's travel direction as heading and the lane waypoint's height (a few trigger volumes sit up - to 1.5 m above or below their road). Feed to Drive.set_stop_signs so the shadow env runs on the - volumes the leaderboard scores against instead of the bin's exported stop lines (which sit up to - 9 m away on a few Town03/05 approaches and miss four signs).""" +def stop_sign_geometry_from_carla(world, carla_map): + """Plain-data geometry of every CARLA `traffic.stop` actor for stop_sign_lines (CARLA frame): id, the + trigger volume's centre/extent/yaw_deg and `lane` = [x, y, z, yaw_deg] of the driving-lane waypoint + under the centre. Signs whose centre projects onto no driving lane are left out.""" import carla - lines, headings = [], [] + geometry = [] for actor in world.get_actors().filter("traffic.stop"): actor_transform = actor.get_transform() trigger = actor.trigger_volume @@ -461,14 +537,44 @@ def stop_signs_from_carla(world, carla_map, transform): if waypoint is None: continue forward = waypoint.transform.get_forward_vector() - lane_yaw = math.atan2(forward.y, forward.x) - box_yaw = math.radians(actor_transform.rotation.yaw) + lane_location = waypoint.transform.location + geometry.append( + { + "id": actor.id, + "center": [center.x, center.y, center.z], + "extent": [trigger.extent.x, trigger.extent.y, trigger.extent.z], + "yaw_deg": actor_transform.rotation.yaw, + "lane": [ + lane_location.x, + lane_location.y, + lane_location.z, + math.degrees(math.atan2(forward.y, forward.x)), + ], + } + ) + return geometry + + +def stop_sign_lines(stop_sign_geometry, transform): + """(lines (K, 6), headings (K,)) in the bin frame, one per stop sign of stop_sign_geometry_from_carla: the + trigger volume's centre projected onto its driving lane, spanning the trigger's width across that lane, + with the lane's travel direction as heading and the lane waypoint's height (a few trigger volumes sit up + to 1.5 m above or below their road). Feed to Drive.set_stop_signs so the shadow env runs on the volumes + the leaderboard scores against instead of the bin's exported stop lines (which sit up to 9 m away on a + few Town03/05 approaches and miss four signs).""" + lines, headings = [], [] + for sign in stop_sign_geometry: + center_x, center_y, _ = sign["center"] + extent_x, extent_y, _ = sign["extent"] + _, _, lane_z, lane_yaw_deg = sign["lane"] + lane_yaw = math.radians(lane_yaw_deg) + box_yaw = math.radians(sign["yaw_deg"]) box_x_along_lane = abs(math.cos(box_yaw - lane_yaw)) >= math.cos(math.pi / 4.0) - half_width = trigger.extent.y if box_x_along_lane else trigger.extent.x + half_width = extent_y if box_x_along_lane else extent_x across_x, across_y = -math.sin(lane_yaw), math.cos(lane_yaw) - left = transform.loc_to_bin(center.x - half_width * across_x, center.y - half_width * across_y) - right = transform.loc_to_bin(center.x + half_width * across_x, center.y + half_width * across_y) - line_z = transform.z_to_bin(waypoint.transform.location.z) + left = transform.loc_to_bin(center_x - half_width * across_x, center_y - half_width * across_y) + right = transform.loc_to_bin(center_x + half_width * across_x, center_y + half_width * across_y) + line_z = transform.z_to_bin(lane_z) lines.append([left[0], left[1], line_z, right[0], right[1], line_z]) - headings.append(transform.yaw_to_bin(math.degrees(lane_yaw))) + headings.append(transform.yaw_to_bin(lane_yaw_deg)) return np.array(lines, np.float32).reshape(-1, 6), np.array(headings, np.float32) diff --git a/pufferlib/ocean/cosim/carla_cosim.py b/pufferlib/ocean/cosim/carla_cosim.py index 1909f0b9a2..81792e0344 100644 --- a/pufferlib/ocean/cosim/carla_cosim.py +++ b/pufferlib/ocean/cosim/carla_cosim.py @@ -414,13 +414,13 @@ def main(): world, tm, ego, bg, lights = build_carla(client, town, route_wps, args.num_background, 0.1) cmap = world.get_map() transform = cb.CarlaTransform(town, offset=cb.town_offset(town_bin)) - offset, z_offset, _, _ = cb.calibrate_town_offset(cmap, transform, town_bin) + offset, z_offset, _, _ = cb.calibrate_town_offset(cb.driving_waypoint_samples(cmap), transform, town_bin) transform = cb.CarlaTransform(town, offset=offset, z_offset=z_offset) _bin_lanes = cb._bin_lane_points(town_bin) # bin lane points (== global frame) for diagnostics dense_route = densify_route(route_wps) # fine-sampled route for lane-centered goal placement route_goals = build_route_goals(dense_route, transform, cmap) # fixed 20-m lane-centered goal sequence goal_window = RouteGoalWindow(env, route_goals) - light_map, num_traffic = cb.map_lights_to_bin(lights, transform, town_bin) + light_map, num_traffic = cb.map_lights_to_bin(cb.light_geometry_from_carla(lights), transform, town_bin) print( f"[cosim] carla: ego + {len(bg)} background + {len(lights)} lights; " f"offset={transform.tx:.1f},{transform.ty:.1f} z={transform.tz:+.2f}" diff --git a/pufferlib/vector.py b/pufferlib/vector.py index 2d9254fa57..a40397adf3 100644 --- a/pufferlib/vector.py +++ b/pufferlib/vector.py @@ -6,6 +6,7 @@ import time import psutil from multiprocessing import get_all_start_methods, get_context +from multiprocessing.connection import wait from pufferlib import PufferEnv, set_buffers import pufferlib.spaces @@ -15,8 +16,7 @@ SEND = 2 RECV = 3 CLOSE = 4 -MAIN = 5 -INFO = 6 +WORKER_POLL_TIMEOUT_SECONDS = 5.0 def recv_precheck(vecenv): @@ -253,36 +253,24 @@ def _worker_process( else: envs = Serial(env_creators, env_args, env_kwargs, num_envs, buf=buf, seed=seed) - semaphores = np.ndarray(num_workers, dtype=np.uint8, buffer=shm["semaphores"]) notify = np.ndarray(num_workers, dtype=bool, buffer=shm["notify"]) - start = time.time() while True: + command = recv_pipe.recv() if notify[worker_idx]: envs.notify() notify[worker_idx] = False - sem = semaphores[worker_idx] - if sem >= MAIN: - if time.time() - start > 0.5: - time.sleep(0.01) - continue - - start = time.time() - if sem == RESET: + if command == RESET: seed = recv_pipe.recv() _, infos = envs.reset(seed=seed) - elif sem == STEP: + elif command == STEP: _, _, _, _, infos = envs.step(atn_arr) - elif sem == CLOSE: + elif command == CLOSE: envs.close() send_pipe.send(None) break - if infos: - semaphores[worker_idx] = INFO - send_pipe.send(infos) - else: - semaphores[worker_idx] = MAIN + send_pipe.send(infos) class Multiprocessing: @@ -319,14 +307,14 @@ def __init__( import psutil - cpu_cores = psutil.cpu_count(logical=False) - if num_workers > cpu_cores and not overwork: + cpu_threads = psutil.cpu_count(logical=True) + if num_workers > cpu_threads and not overwork: raise pufferlib.APIUsageError( " ".join( [ - f"num_workers ({num_workers}) > hardware cores ({cpu_cores}) is disallowed by default.", - "PufferLib multiprocessing is heavily optimized for 1 process per hardware core.", - "If you really want to do this, set overwork=True (--vec-overwork in our demo.py).", + f"num_workers ({num_workers}) > hardware threads ({cpu_threads}) is disallowed by default.", + "Idle workers block instead of spinning, so one process per hardware thread is the limit.", + "If you really want to do this, set overwork=True.", ] ) ) @@ -391,7 +379,6 @@ def __init__( terminals=process_context.RawArray("b", num_agents), truncateds=process_context.RawArray("b", num_agents), masks=process_context.RawArray("b", num_agents), - semaphores=process_context.RawArray("c", num_workers), notify=process_context.RawArray("b", num_workers), ) shape = (num_workers, agents_per_worker) @@ -404,10 +391,8 @@ def __init__( terminals=np.ndarray(shape, dtype=bool, buffer=self.shm["terminals"]), truncations=np.ndarray(shape, dtype=bool, buffer=self.shm["truncateds"]), masks=np.ndarray(shape, dtype=bool, buffer=self.shm["masks"]), - semaphores=np.ndarray(num_workers, dtype=np.uint8, buffer=self.shm["semaphores"]), notify=np.ndarray(num_workers, dtype=bool, buffer=self.shm["notify"]), ) - self.buf["semaphores"][:] = MAIN self.send_pipes, w_recv_pipes = zip(*[process_context.Pipe() for _ in range(num_workers)]) w_send_pipes, self.recv_pipes = zip(*[process_context.Pipe() for _ in range(num_workers)]) @@ -452,28 +437,49 @@ def __init__( self.ready_workers = [] self.waiting_workers = [] + self.batch_workers = [] + self.pending_result = np.zeros(num_workers, dtype=bool) + + def _check_worker_alive(self, worker): + process = self.processes[worker] + if not process.is_alive(): + raise RuntimeError(f"Vectorization worker {worker} exited with code {process.exitcode}") + + def _recv_worker_result(self, worker): + pipe = self.recv_pipes[worker] + while not pipe.poll(WORKER_POLL_TIMEOUT_SECONDS): + self._check_worker_alive(worker) + + self.pending_result[worker] = False + infos = pipe.recv() + if infos: + self.infos[worker] = infos + + def _collect_ready_workers(self): + # sync_traj consumes workers in send order so batches stay aligned with the experience buffer + if self.sync_traj: + worker = self.waiting_workers.pop(0) + self._recv_worker_result(worker) + self.ready_workers.append(worker) + return + + pipes = [self.recv_pipes[worker] for worker in self.waiting_workers] + ready_pipes = wait(pipes, WORKER_POLL_TIMEOUT_SECONDS) + if not ready_pipes: + for worker in self.waiting_workers: + self._check_worker_alive(worker) + return + + for pipe in ready_pipes: + worker = self.recv_pipe_dict[pipe] + self._recv_worker_result(worker) + self.waiting_workers.remove(worker) + self.ready_workers.append(worker) def recv(self): recv_precheck(self) while True: - # Bandaid patch for new experience buffer desync - if self.sync_traj: - worker = self.waiting_workers[0] - sem = self.buf["semaphores"][worker] - if sem >= MAIN: - self.waiting_workers.pop(0) - self.ready_workers.append(worker) - else: - worker = self.waiting_workers.pop(0) - sem = self.buf["semaphores"][worker] - if sem >= MAIN: - self.ready_workers.append(worker) - else: - self.waiting_workers.append(worker) - - if sem == INFO: - self.infos[worker] = self.recv_pipes[worker].recv() - + self._collect_ready_workers() if not self.ready_workers: continue @@ -523,6 +529,7 @@ def recv(self): break self.w_slice = w_slice + self.batch_workers = s_range buf = self.buf o = buf["observations"][w_slice].reshape(self.obs_batch_shape) @@ -546,21 +553,14 @@ def send(self, actions): actions = send_precheck(self, actions).reshape(self.atn_batch_shape) # TODO: What shape? - idxs = self.w_slice - self.actions[idxs] = actions - self.buf["semaphores"][idxs] = STEP + self.actions[self.w_slice] = actions + for worker in self.batch_workers: + self.send_pipes[worker].send(STEP) + self.pending_result[self.batch_workers] = True def async_reset(self, seed=0): - # Flush any waiting workers - while self.waiting_workers: - worker = self.waiting_workers.pop(0) - sem = self.buf["semaphores"][worker] - if sem >= MAIN: - self.ready_workers.append(worker) - if sem == INFO: - self.recv_pipes[worker].recv() - else: - self.waiting_workers.append(worker) + for worker in np.flatnonzero(self.pending_result): + self._recv_worker_result(worker) self.flag = RECV self.prev_env_id = [] @@ -571,13 +571,14 @@ def async_reset(self, seed=0): self.waiting_workers = list(range(self.num_workers)) self.infos = [[] for _ in range(self.num_workers)] - self.buf["semaphores"][:] = RESET worker_ss = ( np.random.SeedSequence(seed).spawn(self.num_workers) if seed is not None else [None] * self.num_workers ) for i in range(self.num_workers): s = int(worker_ss[i].generate_state(1)[0]) & 0x7FFFFFFF if worker_ss[i] is not None else None + self.send_pipes[i].send(RESET) self.send_pipes[i].send(s) + self.pending_result[:] = True def notify(self): self.buf["notify"][:] = True diff --git a/scripts/kesai/14_carla_longest6_v1.sh b/scripts/kesai/14_carla_longest6_v1.sh new file mode 100644 index 0000000000..a01d1dfbd9 --- /dev/null +++ b/scripts/kesai/14_carla_longest6_v1.sh @@ -0,0 +1,256 @@ +#!/bin/bash +#SBATCH --job-name eval_longest6_v1 +#SBATCH --ntasks 1 +#SBATCH --nodes 1 +#SBATCH --time 1-00:00 +#SBATCH --gres gpu:8 +#SBATCH --mem=1007G +#SBATCH --cpus-per-task 144 +#SBATCH --output /home/bjaeger/PufferDrive/experiments/logs/log_%j.out +#SBATCH --error /home/bjaeger/PufferDrive/experiments/logs/log_%j.err +#SBATCH --partition dev +# CARLA longest6 v1 evaluation of a PufferDrive checkpoint through carla_garage's unmodified Leaderboard 1.0 +# (CARLA 0.9.10.1, pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py), the Leaderboard 1.0 counterpart of +# 11_carla_longest6.sh. The garage evaluator runs in the `garage` conda env (Python 3.7, the CARLA 0.9.10 egg); +# the agent starts pufferlib/ocean/cosim/carla/lb1/policy_server.py in a PufferDrive python ($SERVER_PY) per route. +# +# One CARLA server + one evaluator per GPU (1-8: whatever the allocation holds, e.g. `sbatch --gres gpu:2`); +# the 36 routes (one split xml each) are dealt round-robin over the GPUs, every route gets its own server +# (restarted per route), crashed routes are retried, and the per-route result jsons are aggregated at the end +# with garage's tools/result_parser.py (results.csv) plus the nuPlan-style HTML report ($OUT/report/index.html) +# and the obs replay gallery of all routes ($OUT/obs_html/index.html, as in 11_carla_longest6.sh). +# +# Overridable env: RUN_DIR, ROUTE_DIR (the split xmls), ROUTES_XML (the combined longest6.xml, for the parser +# and the gallery), SCENARIOS (1 = eval_scenarios.json, 0 = no_scenarios.json), REPETITIONS, ROUTE_SUBSET +# ("0 5 17": only these route ids), NUM_GPUS, PORT_BASE (CARLA rpc port of gpu 0; gpu w uses PORT_BASE+50w, +# TM PORT_BASE+6000+50w), LOGGING (1, default: telemetry, world log and the HTML report per route; 0: scores +# only), CARLA_VIEW (0, default: no chase-cam; 1: chase-cam mp4 per route, needs LOGGING=1), OBS_HTML (1 = also +# the interactive obs replay per route, rendered into $OUT/obs_html at the end, large; needs LOGGING=1), REPORT +# (0 = skip the HTML report), MAX_ATTEMPTS, CARLA_ROOT, GARAGE_WORK_DIR, GARAGE_PY, SERVER_PY, PD, +# COSIM_MAX_SPEED_MPS (ego speed cap, default 30), COSIM_ZERO_PARTNER_STOPPED_TIME (default 1), +# COSIM_PEDESTRIAN_MIN_SIZE_M (default 0), LIBTIFF5_COMPAT (1: when `import carla` fails for the egg's missing +# libtiff.so.5, try the garage env's lib dir (`conda install -n garage "libtiff<4.5"`), then link the system +# libtiff.so.6 under that name; 0: never link). +set -u + +export PD=${PD:-/home/bjaeger/PufferDrive} +export GARAGE_PY=${GARAGE_PY:-/home/bjaeger/miniconda3/envs/garage/bin/python} # cp37: the CARLA 0.9.10 egg +export SERVER_PY=${SERVER_PY:-/home/bjaeger/miniconda3/envs/carl/bin/python} # any PufferDrive-capable python +export CARLA_ROOT=${CARLA_ROOT:-/home/bjaeger/CARLA_0.9.10} +export GARAGE_WORK_DIR=${GARAGE_WORK_DIR:-/home/bjaeger/carla_garage_1} +RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0044_1000} +# the agent finds config.yaml next to final_model.pt (or one level above a models/*.pt) +export CKPT=$RUN_DIR/final_model.pt +ROUTE_DIR=${ROUTE_DIR:-$GARAGE_WORK_DIR/leaderboard/data/longest6_split} +ROUTES_XML=${ROUTES_XML:-$GARAGE_WORK_DIR/leaderboard/data/longest6.xml} +SCENARIOS=${SCENARIOS:-1} +if [ "$SCENARIOS" = "1" ]; then + SCENARIO_FILE=$GARAGE_WORK_DIR/leaderboard/data/scenarios/eval_scenarios.json +else + SCENARIO_FILE=$GARAGE_WORK_DIR/leaderboard/data/scenarios/no_scenarios.json +fi +REPETITIONS=${REPETITIONS:-1} +ROUTE_SUBSET=${ROUTE_SUBSET:-} +PORT_BASE=${PORT_BASE:-2000} +MAX_ATTEMPTS=${MAX_ATTEMPTS:-3} +LOGGING=${LOGGING:-1} +CARLA_VIEW=${CARLA_VIEW:-0} +[ "$CARLA_VIEW" = "1" ] && [ "$LOGGING" != "1" ] && { echo "CARLA_VIEW=1 needs LOGGING=1"; exit 1; } +OBS_HTML=${OBS_HTML:-1} +REPORT=${REPORT:-$LOGGING} +EVALUATOR=$GARAGE_WORK_DIR/leaderboard/leaderboard/leaderboard_evaluator_local.py +CARLA_EGG=$CARLA_ROOT/PythonAPI/carla/dist/carla-0.9.10-py3.7-linux-x86_64.egg +AGENT=$PD/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py +for path in "$CKPT" "$GARAGE_PY" "$SERVER_PY" "$CARLA_ROOT/CarlaUE4.sh" "$CARLA_EGG" "$EVALUATOR" "$ROUTE_DIR" "$ROUTES_XML" \ + "$SCENARIO_FILE" "$AGENT" "$GARAGE_WORK_DIR/tools/result_parser.py"; do + [ -e "$path" ] || { echo "missing $path"; exit 1; } +done +for path in "$GARAGE_PY" "$SERVER_PY" "$CARLA_ROOT/CarlaUE4.sh"; do + [ -x "$path" ] || { echo "not executable: $path (chmod +x, or the filesystem is mounted noexec)"; exit 1; } +done +echo "Evaluating checkpoint: $CKPT" + +# GPUs of this allocation; one CARLA server + one evaluator per GPU +NUM_GPUS=${NUM_GPUS:-$(nvidia-smi -L 2>/dev/null | grep -c "^GPU")} +if [ "$NUM_GPUS" -lt 1 ] || [ "$NUM_GPUS" -gt 8 ]; then echo "NUM_GPUS=$NUM_GPUS must be 1..8"; exit 1; fi + +TAG=longest6_v1$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") +# results live in the model's own eval folder, next to the PufferDrive benchmark evals +OUT=$RUN_DIR/eval/carla_${TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} +mkdir -p "$OUT/routes" "$OUT/aggregate" +{ + echo "checkpoint $CKPT"; echo "routes $ROUTE_DIR ($ROUTES_XML)"; echo "scenarios $SCENARIO_FILE" + echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS" + echo "git $(git -C "$PD" rev-parse --short HEAD 2>/dev/null) $(git -C "$PD" diff --quiet 2>/dev/null || echo dirty)" +} > "$OUT/run_info.txt" +echo "Results -> $OUT" + +carla_imports() { PYTHONPATH="$CARLA_EGG" "$GARAGE_PY" -c "import carla" >/dev/null 2>&1; } +# the 0.9.10 egg links libtiff.so.5, which newer distros no longer ship +if ! carla_imports; then + GARAGE_LIB=$(cd "$(dirname "$GARAGE_PY")/../lib" 2>/dev/null && pwd) + if [ -n "$GARAGE_LIB" ] && [ -e "$GARAGE_LIB/libtiff.so.5" ]; then + export LD_LIBRARY_PATH="$GARAGE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + echo "libtiff.so.5 from the garage env: $GARAGE_LIB" + fi +fi +if ! carla_imports && [ "${LIBTIFF5_COMPAT:-1}" = "1" ]; then + SYSTEM_LIBTIFF=$( (ldconfig -p 2>/dev/null || /sbin/ldconfig -p 2>/dev/null) | awk '/libtiff.so.6 /{print $NF; exit}') + if [ -n "$SYSTEM_LIBTIFF" ]; then + mkdir -p "$OUT/compat_lib" && ln -sf "$SYSTEM_LIBTIFF" "$OUT/compat_lib/libtiff.so.5" + export LD_LIBRARY_PATH="$OUT/compat_lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + echo "libtiff.so.5 missing: using $SYSTEM_LIBTIFF via $OUT/compat_lib" + fi +fi +carla_imports || { echo "ERROR: $GARAGE_PY cannot import the CARLA egg:"; PYTHONPATH="$CARLA_EGG" "$GARAGE_PY" -c "import carla"; exit 1; } + +# the garage evaluator and the CARLA egg only; the policy server gets PYTHONPATH=$PD from the agent +export PYTHONPATH="$CARLA_ROOT/PythonAPI/carla:$CARLA_EGG:$GARAGE_WORK_DIR/scenario_runner:$GARAGE_WORK_DIR/leaderboard" +export SCENARIO_RUNNER_ROOT=$GARAGE_WORK_DIR/scenario_runner +export LEADERBOARD_ROOT=$GARAGE_WORK_DIR/leaderboard +export BENCHMARK=longest6 DATAGEN=0 +cd "$PD" || exit 1 +export OMP_NUM_THREADS=4 MKL_NUM_THREADS=4 OPENBLAS_NUM_THREADS=4 NUMEXPR_NUM_THREADS=4 +export COSIM_SERVER_PYTHON=$SERVER_PY +export COSIM_DEVICE=${COSIM_DEVICE:-cpu} +export COSIM_DYNAMICS_SOURCE=pufferdrive +export COSIM_MAX_SPEED_MPS=${COSIM_MAX_SPEED_MPS:-30} +export COSIM_ZERO_PARTNER_STOPPED_TIME=${COSIM_ZERO_PARTNER_STOPPED_TIME:-1} +export COSIM_PEDESTRIAN_MIN_SIZE_M=${COSIM_PEDESTRIAN_MIN_SIZE_M:-0} +export COSIM_OBS_HTML_MAX_STEPS=${COSIM_OBS_HTML_MAX_STEPS:-20000} +export COSIM_OBS_HTML_RENDER=0 # routes save the compact replay only; render_carla_obs_html.py renders all pages into $OUT/obs_html + +mkdir -p "$PD/experiments/logs" # the build helper keeps its source hash there +PATH="$(dirname "$SERVER_PY"):$PATH" bash "$PD/scripts/kesai/build_ext_if_changed.sh" "$PD" || exit 1 +IMPORTED_CORE=$(env -u PYTHONPATH PYTHONPATH="$PD" "$SERVER_PY" -c "import pufferlib.ocean.cosim.carla.shadow_ego as s; print(s.__file__)") \ + || { echo "ERROR: shadow env import failed in $SERVER_PY (see traceback above)"; exit 1; } +case "$IMPORTED_CORE" in + "$PD"/*) echo "shadow env: $IMPORTED_CORE" ;; + *) echo "ERROR: shadow env imported from '$IMPORTED_CORE', expected $PD"; exit 1 ;; +esac +"$GARAGE_PY" -c "import carla, leaderboard.leaderboard_evaluator_local" || { echo "ERROR: the garage python cannot import the evaluator (see traceback above)"; exit 1; } + +# route ids = the split files longest_weathers_.xml, dealt round-robin: gpu w runs ids w, w+N, w+2N, ... +mapfile -t ROUTE_IDS < <(ls "$ROUTE_DIR"/longest_weathers_*.xml | sed 's/.*longest_weathers_\([0-9]*\)\.xml/\1/' | sort -n) +[ -n "$ROUTE_SUBSET" ] && read -r -a ROUTE_IDS <<< "$ROUTE_SUBSET" +echo "${#ROUTE_IDS[@]} routes over $NUM_GPUS GPUs" + +route_done() { # 0 when the route json holds a record that is not one of the crash states garage resubmits + "$SERVER_PY" - "$1" <<'EOF' +import json, sys +try: + records = json.load(open(sys.argv[1]))["_checkpoint"]["records"] +except Exception: + sys.exit(1) +crashed = ("Failed - Agent couldn't be set up", "Failed - Simulation crashed", "Failed - Agent crashed") +sys.exit(0 if records and all(r["status"] not in crashed for r in records) else 1) +EOF +} + +run_route() { # $1 gpu, $2 route id, $3 attempt: one CARLA server + one evaluator run, into $OUT/routes/route_ + local gpu=$1 route_id=$2 attempt=$3 + local route_dir=$OUT/routes/route_$(printf "%02d" "$route_id") + local routes_xml=$ROUTE_DIR/longest_weathers_$route_id.xml + local port=$((PORT_BASE + gpu * 50)) tm_port=$((PORT_BASE + 6000 + gpu * 50)) + local server_log=$route_dir/carla_server_attempt$attempt.log evaluator_log=$route_dir/evaluator_attempt$attempt.log + mkdir -p "$route_dir" + rm -f "$route_dir/result.json" + # offscreen OpenGL on the worker's GPU (UE 4.24 has no -RenderOffScreen/-nullrhi/-graphicsadapter) + DISPLAY= SDL_VIDEODRIVER=offscreen SDL_HINT_CUDA_DEVICE="$gpu" "$CARLA_ROOT/CarlaUE4.sh" -opengl -nosound \ + -carla-rpc-port="$port" -carla-streaming-port=$((port + 1)) > "$server_log" 2>&1 & + local server_pid=$! up=1 + echo "$server_pid" >> "$OUT/server_pids" + for _ in $(seq 1 60); do + kill -0 "$server_pid" 2>/dev/null || { echo "[gpu$gpu route$route_id] CARLA server died"; break; } + "$GARAGE_PY" -c " +import carla, sys +try: + c = carla.Client('localhost', $port); c.set_timeout(10.0); c.get_world().get_map() +except Exception: + sys.exit(1)" 2>/dev/null && up=0 && break + sleep 5 + done + if [ $up -ne 0 ]; then + pkill -9 -P "$server_pid" 2>/dev/null; kill -9 "$server_pid" 2>/dev/null + echo "[gpu$gpu route$route_id] last lines of $server_log:"; tail -n 20 "$server_log" + return 1 + fi + local log_env=(ROUTES="$routes_xml") + if [ "$LOGGING" = "1" ]; then + log_env+=(COSIM_TELEMETRY="$route_dir/telemetry" COSIM_WORLD_LOG="$route_dir/world_log") + [ "$CARLA_VIEW" = "1" ] && log_env+=(COSIM_DEBUG_CARLA_VIEW="$route_dir/carla_view") + [ "$OBS_HTML" = "1" ] && log_env+=(COSIM_OBS_HTML="$route_dir/obs_html") + fi + env CUDA_VISIBLE_DEVICES="$gpu" "${log_env[@]}" \ + "$GARAGE_PY" -u "$EVALUATOR" --routes "$routes_xml" --scenarios "$SCENARIO_FILE" --repetitions "$REPETITIONS" \ + --agent "$AGENT" --agent-config "$CKPT" --checkpoint "$route_dir/result.json" --track MAP \ + --port "$port" --trafficManagerPort "$tm_port" --timeout 600 \ + > "$evaluator_log" 2>&1 + # children first: killing the wrapper shell first reparents the UE4 binary and leaves it running + pkill -9 -P "$server_pid" 2>/dev/null; kill -9 "$server_pid" 2>/dev/null; wait "$server_pid" 2>/dev/null + route_done "$route_dir/result.json" && return 0 + echo "[gpu$gpu route$route_id] no valid route record; last lines of $evaluator_log:"; tail -n 20 "$evaluator_log" + return 1 +} + +run_worker() { # $1 gpu: its share of the routes, sequentially, each retried up to MAX_ATTEMPTS + local gpu=$1 + for ((k = gpu; k < ${#ROUTE_IDS[@]}; k += NUM_GPUS)); do + local route_id=${ROUTE_IDS[k]} attempt + for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + echo "[gpu$gpu] route $route_id attempt $attempt $(date +%H:%M:%S)" + run_route "$gpu" "$route_id" "$attempt" && break + echo "[gpu$gpu] route $route_id attempt $attempt failed" + done + done +} + +# only the servers this job started (children first: the UE4 binary outlives its wrapper shell otherwise) +trap 'for p in $(cat "$OUT/server_pids" 2>/dev/null); do pkill -9 -P "$p" 2>/dev/null; kill -9 "$p" 2>/dev/null; done' EXIT +WORKER_PIDS=() +for ((gpu = 0; gpu < NUM_GPUS; gpu++)); do + run_worker "$gpu" > "$OUT/worker_gpu$gpu.log" 2>&1 & + WORKER_PIDS+=($!) +done +wait "${WORKER_PIDS[@]}" +cat "$OUT"/worker_gpu*.log + +# aggregate: garage's parser over the per-route jsons -> $OUT/aggregate/results.csv, plus a per-route table +for f in "$OUT"/routes/route_*/result.json; do + route_id=$(basename "$(dirname "$f")" | sed 's/route_0*//; s/^$/0/') + cp "$f" "$OUT/aggregate/longest_weathers_$route_id.json" +done +# garage's parser imports torch/pygame; the garage env's torch may not load everywhere, so the server python is the fallback +for parser_py in "$GARAGE_PY" "$SERVER_PY"; do + env -u PYTHONPATH "$parser_py" "$GARAGE_WORK_DIR/tools/result_parser.py" --xml "$ROUTES_XML" --results "$OUT/aggregate" --log_dir "$OUT/aggregate" \ + && break + echo "result_parser.py failed with $parser_py (per-route jsons in $OUT/aggregate are unaffected)" +done +"$SERVER_PY" - "$OUT/aggregate" "${#ROUTE_IDS[@]}" <<'EOF' +import glob, json, sys +rows = [] +for f in sorted(glob.glob(sys.argv[1] + "/longest_weathers_*.json"), key=lambda p: int(p.rsplit("_", 1)[1][:-5])): + for r in json.load(open(f))["_checkpoint"]["records"]: + rows.append((r["route_id"], r["status"], r["scores"]["score_composed"], r["scores"]["score_route"], r["scores"]["score_penalty"])) +print(f"\n{len(rows)} route records of {sys.argv[2]} routes") +for route_id, status, ds, rc, ip in rows: + print(f" {route_id:22s} {status:34s} DS {ds:6.2f} RC {rc:6.2f} IP {ip:5.3f}") +if rows: + n = len(rows) + print(f"mean DS {sum(r[2] for r in rows)/n:.2f} RC {sum(r[3] for r in rows)/n:.2f} IP {sum(r[4] for r in rows)/n:.3f}") +EOF + +REPORT_ARGS=() +[ "$OBS_HTML" = "1" ] && REPORT_ARGS+=(--obs-html-dir "$OUT/obs_html") +if [ "$REPORT" = "1" ] && [ "$LOGGING" = "1" ]; then + env -u PYTHONPATH "$SERVER_PY" "$PD/scripts/eval/analyze_carla_cosim.py" "$OUT"/routes/route_* "$OUT/report" "${REPORT_ARGS[@]}" \ + || echo "HTML report failed (results above are unaffected)" +fi +# after the report: the gallery links to it when it exists +if [ "$OBS_HTML" = "1" ] && [ "$LOGGING" = "1" ]; then + env -u PYTHONPATH "$SERVER_PY" "$PD/scripts/eval/render_carla_obs_html.py" "$OUT" --routes "$ROUTES_XML" \ + || echo "obs replay gallery failed (results above are unaffected)" +fi +[ -f "$OUT/report/index.html" ] && echo "report -> $OUT/report/index.html" +[ -f "$OUT/obs_html/index.html" ] && echo "obs replays -> $OUT/obs_html/index.html" +echo "Done -> $OUT" diff --git a/tests/unit_tests/test_carla_offset_calibration.py b/tests/unit_tests/test_carla_offset_calibration.py index 3102986b52..e80c718446 100644 --- a/tests/unit_tests/test_carla_offset_calibration.py +++ b/tests/unit_tests/test_carla_offset_calibration.py @@ -65,7 +65,7 @@ def generate_waypoints(self, spacing_m): def test_calibration_recovers_translation_and_height(): transform = cb.CarlaTransform("Town01", offset=cb.town_offset(str(TOWN_BIN))) offset, z_offset, residual_before, residual_after = cb.calibrate_town_offset( - _Map(transform), transform, str(TOWN_BIN) + cb.driving_waypoint_samples(_Map(transform)), transform, str(TOWN_BIN) ) assert residual_before > 0.2 assert residual_after < 0.02 diff --git a/tests/unit_tests/test_lb1_ground_truth.py b/tests/unit_tests/test_lb1_ground_truth.py new file mode 100644 index 0000000000..1a5e7d67ae --- /dev/null +++ b/tests/unit_tests/test_lb1_ground_truth.py @@ -0,0 +1,90 @@ +"""lb1/ground_truth.py: the Leaderboard 1.0 criteria recomputed from plain CARLA-frame geometry.""" + +import os +import sys + + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from pufferlib.ocean.cosim.carla.lb1.ground_truth import LeaderboardOneGroundTruth, segments_intersect + + +EGO_EXTENT_X = 2.4 +LANE_WIDTH = 3.5 +# one light whose stop line crosses the +x lane at x = 50 (ego drives along +x, y = 0) +LIGHT = { + "id": 11, + "x": 55.0, + "y": 6.0, + "trigger_x": 50.0, + "trigger_y": 0.0, + "stop_waypoints": [{"x": 50.0, "y": 0.0, "yaw_deg": 0.0, "lane_width": LANE_WIDTH, "backward": [], "forward": []}], +} +STOP_SIGN = { + "id": 21, + "center": [120.0, 0.0, 0.0], + "extent": [2.0, 1.5, 1.0], + "yaw_deg": 0.0, + "lane": [120.0, 0.0, 0.0, 0.0], +} +IN_LANE = [0.0, LANE_WIDTH, -1.0, 0.0, 0.0, 0.0] # on the driving lane centre, no parking lane, lane yaw 0, no junction + + +def ego(x, speed=5.0, yaw_deg=0.0): + return [x, 0.0, 0.0, yaw_deg, speed, 0.0, 0.0, 0.0, 0.0, 0.0] + + +def snapshot(x, light_state="Red", speed=5.0, yaw_deg=0.0): + return {"ego": ego(x, speed, yaw_deg), "partners": [], "light_states": [[11, light_state]], "ego_light_id": -1} + + +def test_segments_intersect(): + assert segments_intersect((0, 0), (2, 0), (1, -1), (1, 1)) + assert not segments_intersect((0, 0), (2, 0), (3, -1), (3, 1)) + assert segments_intersect((0, 0), (2, 0), (2, 0), (2, 1)) # touching end point + + +def test_red_light_fires_once_when_the_tail_crosses_the_stop_line(): + truth = LeaderboardOneGroundTruth([LIGHT], [], EGO_EXTENT_X) + observation = {"collision": [], "lane": IN_LANE} + assert truth.update(snapshot(45.0), observation)["red_light"] == 0.0 # nose over, tail not yet + assert truth.update(snapshot(52.5), observation)["red_light"] == 1.0 # tail line 50.1..49.6 straddles x = 50 + assert truth.update(snapshot(52.6), observation)["red_light"] == 0.0 # same light never fires twice + + +def test_green_light_and_oncoming_lane_do_not_fire(): + truth = LeaderboardOneGroundTruth([LIGHT], [], EGO_EXTENT_X) + observation = {"collision": [], "lane": IN_LANE} + assert truth.update(snapshot(52.5, light_state="Green"), observation)["red_light"] == 0.0 + oncoming = snapshot(47.5, yaw_deg=180.0) # driving -x: the stop line faces the other lane + oncoming["ego"][4] = -5.0 + assert truth.update(oncoming, {"collision": [], "lane": IN_LANE})["red_light"] == 0.0 + + +def test_stop_sign_run_vs_stopped(): + observation = {"collision": [], "lane": IN_LANE} + run = LeaderboardOneGroundTruth([], [STOP_SIGN], EGO_EXTENT_X) + flags = [run.update(snapshot(x, speed=5.0), observation)["stop_sign"] for x in (100.0, 110.0, 119.5, 123.0, 150.0)] + assert flags == [0.0, 0.0, 0.0, 1.0, 0.0] # targeted at 100 m (20 m lookahead), inside at 119.5, fires once past it + + stopped = LeaderboardOneGroundTruth([], [STOP_SIGN], EGO_EXTENT_X) + for x, speed in ((100.0, 5.0), (119.5, 0.05), (123.0, 3.0), (150.0, 5.0)): + assert stopped.update(snapshot(x, speed=speed), observation)["stop_sign"] == 0.0 + + +def test_outside_lane_and_wrong_lane(): + truth = LeaderboardOneGroundTruth([], [], EGO_EXTENT_X) + far = [LANE_WIDTH / 2.0 + 1.4, LANE_WIDTH, -1.0, 0.0, 0.0, 0.0] + assert truth.update(snapshot(10.0), {"collision": [], "lane": far})["offroad"] == 1.0 + on_parking = [LANE_WIDTH / 2.0 + 1.4, LANE_WIDTH, 0.5, 2.5, 0.0, 0.0] # closer to a parking lane centre + assert truth.update(snapshot(10.0), {"collision": [], "lane": on_parking})["offroad"] == 0.0 + wrong_way = snapshot(10.0, yaw_deg=170.0) + assert truth.update(wrong_way, {"collision": [], "lane": IN_LANE})["offroad"] == 1.0 + junction = [0.0, LANE_WIDTH, -1.0, 0.0, 0.0, 1.0] + assert truth.update(wrong_way, {"collision": [], "lane": junction})["offroad"] == 0.0 + + +def test_collision_flag_follows_the_sensor_events(): + truth = LeaderboardOneGroundTruth([], [], EGO_EXTENT_X) + assert truth.update(snapshot(10.0), {"collision": ["vehicle.audi.a2"], "lane": IN_LANE})["collision"] == 1.0 + assert truth.update(snapshot(11.0), {"collision": [], "lane": IN_LANE})["collision"] == 0.0 diff --git a/tests/unit_tests/test_lb1_protocol.py b/tests/unit_tests/test_lb1_protocol.py new file mode 100644 index 0000000000..5ce03a306e --- /dev/null +++ b/tests/unit_tests/test_lb1_protocol.py @@ -0,0 +1,79 @@ +"""Leaderboard 1.0 co-sim client: wire protocol round trips, and the client stays Python 3.7 / pufferlib-free +(it runs inside carla_garage's `garage` conda env, the only Python the CARLA 0.9.10 egg supports).""" + +import ast +import os +import socket +import subprocess +import sys +from pathlib import Path + +import pytest + + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from pufferlib.ocean.cosim.carla.lb1 import lb1_protocol + + +LB1_DIR = Path(__file__).resolve().parents[2] / "pufferlib" / "ocean" / "cosim" / "carla" / "lb1" +CLIENT_SIDE_FILES = ("leaderboard_agent.py", "lb1_protocol.py", "run_evaluator.py") +CLIENT_PYTHON_FEATURE_VERSION = (3, 7) +GARAGE_PYTHON = Path.home() / "miniconda3" / "envs" / "garage" / "bin" / "python" +LARGE_PAYLOAD_FLOATS = 300_000 # a 5-route-km dense plan plus every Town04 driving waypoint + + +def test_round_trip_preserves_nested_message(): + client, server = socket.socketpair() + message = { + "type": "tick", + "snapshot": {"ego": [1.5, -2.0, 0.0], "partners": [], "light_states": [[7, "Red"]]}, + "x": None, + } + lb1_protocol.send_message(client, message) + assert lb1_protocol.recv_message(server) == message + client.close() + server.close() + + +def test_large_message_arrives_whole_over_chunked_reads(): + client, server = socket.socketpair() + payload = { + "type": "init", + "route": {"dense_plan": [[float(k), 0.5 * k, 0.0] for k in range(LARGE_PAYLOAD_FLOATS // 3)]}, + } + import threading + + sender = threading.Thread(target=lb1_protocol.send_message, args=(client, payload)) + sender.start() + received = lb1_protocol.recv_message(server) + sender.join() + assert received == payload + client.close() + server.close() + + +def test_closed_peer_reads_as_none(): + client, server = socket.socketpair() + client.close() + assert lb1_protocol.recv_message(server) is None + server.close() + + +@pytest.mark.parametrize("filename", CLIENT_SIDE_FILES) +def test_client_side_files_parse_as_python_37_without_pufferlib(filename): + source = (LB1_DIR / filename).read_text() + tree = ast.parse(source, filename=filename, feature_version=CLIENT_PYTHON_FEATURE_VERSION) + imported = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported += [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + assert not [name for name in imported if name.split(".")[0] in ("pufferlib", "data_utils", "torch")], imported + + +@pytest.mark.skipif(not GARAGE_PYTHON.is_file(), reason="garage conda env not installed on this machine") +@pytest.mark.parametrize("filename", CLIENT_SIDE_FILES) +def test_client_side_files_compile_with_garage_python(filename): + subprocess.run([str(GARAGE_PYTHON), "-m", "py_compile", str(LB1_DIR / filename)], check=True) diff --git a/tests/unit_tests/test_vector_driver.py b/tests/unit_tests/test_vector_driver.py index 8a2f808961..8b82eedf78 100644 --- a/tests/unit_tests/test_vector_driver.py +++ b/tests/unit_tests/test_vector_driver.py @@ -5,9 +5,11 @@ """ import multiprocessing +import time import gymnasium import numpy as np +import pytest import pufferlib.vector from pufferlib import PufferEnv @@ -115,3 +117,68 @@ def test_preloaded_resources_are_inherited_when_default_is_forkserver(): assert process.exitcode == 0 assert queue.get() == ("forkserver", 1, True) + + +def _make_multiprocessing(worker_count, batch_size): + return pufferlib.vector.make( + [_make_minimal_env] * worker_count, + env_args=[[]] * worker_count, + env_kwargs=[{}] * worker_count, + backend="Multiprocessing", + num_envs=worker_count, + num_workers=worker_count, + batch_size=batch_size, + ) + + +def test_reset_drains_pending_workers_and_steps_again(): + """A reset issued right after recv() finds half the workers idle and half + mid-step; both halves must be reset without hanging and step afterwards.""" + worker_count = 4 + vecenv = _make_multiprocessing(worker_count, batch_size=2) + try: + obs, _ = vecenv.reset(seed=1) + assert obs.shape == (2, OBSERVATION_SIZE) + actions = np.zeros(vecenv.action_space.shape, dtype=np.int32) + vecenv.send(actions) + vecenv.recv() + obs, _ = vecenv.reset(seed=2) + assert obs.shape == (2, OBSERVATION_SIZE) + assert vecenv.pending_result.sum() == 2 + for _ in range(worker_count): + obs, *_ = vecenv.step(actions) + assert obs.shape == (2, OBSERVATION_SIZE) + finally: + vecenv.close() + + +class SlowStepEnv(MinimalEnv): + def step(self, actions): + time.sleep(30) + return super().step(actions) + + +def _make_slow_step_env(**kwargs): + return SlowStepEnv(**kwargs) + + +def test_dead_worker_raises_instead_of_hanging(monkeypatch): + monkeypatch.setattr(pufferlib.vector, "WORKER_POLL_TIMEOUT_SECONDS", 0.2) + vecenv = pufferlib.vector.make( + [_make_minimal_env, _make_slow_step_env], + env_args=[[]] * 2, + env_kwargs=[{}] * 2, + backend="Multiprocessing", + num_envs=2, + num_workers=2, + batch_size=2, + ) + try: + vecenv.reset(seed=1) + vecenv.send(np.zeros(vecenv.action_space.shape, dtype=np.int32)) + vecenv.processes[1].kill() + vecenv.processes[1].join() + with pytest.raises((RuntimeError, EOFError)): + vecenv.recv() + finally: + vecenv.close() From 1567f6aa34e8608865c752d2e289340a1140d538 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 5 Oct 2026 18:03:55 +0200 Subject: [PATCH 004/123] use double the envs on th elcuster --- scripts/kesai/3_train_64GPU_multinode_slurm.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 6c5a3ea967..0cab92f509 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -44,6 +44,7 @@ export OMP_NUM_THREADS=1 source .venv/bin/activate bash scripts/kesai/build_ext_if_changed.sh /home/bjaeger/PufferDrive || exit 1 +# Blocking env workers allow 2 workers per hardware thread (18 threads per rank); forked workers share the preloaded map cache # Execute torchrun across all nodes using srun srun torchrun \ --nnodes=${SLURM_JOB_NUM_NODES} \ @@ -63,7 +64,8 @@ srun torchrun \ train.name=${RUN_NAME} \ run_name=${RUN_NAME} \ train.total_timesteps=1000000000000 \ - vec.num_envs=16 \ + vec.num_envs=32 \ + env.preload_map_cache=True \ train.max_minibatch_size=131072 \ train.minibatch_size=131072 \ train.final_model_name=${FINAL_MODEL_NAME} \ From 82d0b2ce0a6124dc76e75a1c5a04ba9d45116e58 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Tue, 6 Oct 2026 11:57:02 +0200 Subject: [PATCH 005/123] increase eval envs --- scripts/kesai/3_train_64GPU_multinode_slurm.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 0cab92f509..cbcc9ef02d 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -77,14 +77,14 @@ if [ ! -f ${MODEL_PATH} ]; then exit 1 fi -# parallel_eval places one shard per allocated node via srun, so each shard's 64 +# parallel_eval places one shard per allocated node via srun, so each shard's 128 # env workers get a full node's cores instead of sharing the batch host. echo "Training done, evaluating ${MODEL_PATH}" .venv/bin/python scripts/parallel_eval.py carla \ --total-scenarios 40000 \ --num-nodes 4 \ env.map_dir=/home/bjaeger/PufferDrive/pufferlib/resources/drive/binaries/carla \ - vec.num_envs=64 \ + vec.num_envs=128 \ eval.reward_comfort=0.0 \ eval.reward_lane_center=0.0075 \ env.eval_perceived_size_margin_m=0.2 \ From 164891ccb88095cb4da64a954289cb9388ecf36f Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Tue, 6 Oct 2026 13:58:39 +0200 Subject: [PATCH 006/123] Add fused network. and tuning scripts. --- pufferlib/config/puffer_drive.yaml | 2 + pufferlib/config_schema.py | 1 + pufferlib/ocean/fused_slot_encoder.py | 583 ++++++++++++++++++ pufferlib/ocean/torch.py | 17 + .../kesai/15_tune_fused_slot_encoder_slurm.sh | 37 ++ .../kesai/3_train_64GPU_multinode_slurm.sh | 1 + scripts/profile_policy_step.py | 210 +++++++ scripts/tune_fused_slot_encoder.py | 206 +++++++ tests/unit_tests/test_fused_slot_encoder.py | 140 +++++ 9 files changed, 1197 insertions(+) create mode 100644 pufferlib/ocean/fused_slot_encoder.py create mode 100755 scripts/kesai/15_tune_fused_slot_encoder_slurm.sh create mode 100644 scripts/profile_policy_step.py create mode 100644 scripts/tune_fused_slot_encoder.py create mode 100644 tests/unit_tests/test_fused_slot_encoder.py diff --git a/pufferlib/config/puffer_drive.yaml b/pufferlib/config/puffer_drive.yaml index d7532bd9f9..a7be3297d1 100644 --- a/pufferlib/config/puffer_drive.yaml +++ b/pufferlib/config/puffer_drive.yaml @@ -272,6 +272,8 @@ policy: critic_head_layer_norm: false # Run the actor/critic heads in float32 under bf16 autocast (backbone stays bf16) fp32_heads: true + # Fused Triton kernels for the per-slot encoders and their max-pool (same bf16 math, fewer activation bytes) + fused_slot_encoder: false # Dual or shared actor-critic backbone shared_network: false action_type: discrete diff --git a/pufferlib/config_schema.py b/pufferlib/config_schema.py index 72a2c13e11..302fd0d673 100644 --- a/pufferlib/config_schema.py +++ b/pufferlib/config_schema.py @@ -386,6 +386,7 @@ class DrivePolicyConfig: critic_num_layers: int = _constrained_field(NONNEGATIVE_INT_CONSTRAINT) critic_head_layer_norm: bool = MISSING fp32_heads: bool = MISSING + fused_slot_encoder: bool = False shared_network: bool = MISSING action_type: ActionType = MISSING diff --git a/pufferlib/ocean/fused_slot_encoder.py b/pufferlib/ocean/fused_slot_encoder.py new file mode 100644 index 0000000000..c12d26dfd2 --- /dev/null +++ b/pufferlib/ocean/fused_slot_encoder.py @@ -0,0 +1,583 @@ +"""Fused per-slot encoder: Linear -> LayerNorm -> ReLU -> Linear -> masked max-pool over slots, in two Triton kernels. + +Reproduces the bf16-autocast numerics of DriveBackbone._encode_and_pool (bf16 matmul inputs and outputs, fp32 +accumulation, fp32 LayerNorm, gradient shared evenly between tied maxima) without materializing the 256-wide +per-slot activations between ops. +""" + +from dataclasses import dataclass + +import torch +import triton +import triton.language as tl + + +@dataclass(frozen=True) +class KernelConfig: + fwd_block_batch: int + fwd_block_slots: int + fwd_block_k: int + fwd_num_warps: int + fwd_num_stages: int + bwd_block_rows: int + bwd_block_k: int + bwd_num_warps: int + bwd_num_stages: int + pool_block_rows: int + pool_num_warps: int + + +# Timed on an RTX 5090 (GB202, compute capability 12.0); the RTX PRO 6000 Blackwell is the same chip. +GB202_CONFIG = KernelConfig(8, 4, 32, 4, 2, 64, 64, 8, 2, 32, 4) +# Untimed placeholders: replace with the KernelConfig printed by scripts/tune_fused_slot_encoder.py on that GPU. +H100_CONFIG = GB202_CONFIG +B200_CONFIG = GB202_CONFIG +B300_CONFIG = B200_CONFIG # Blackwell Ultra shares the B200 SM design +CONFIGS_BY_CAPABILITY = {(12, 0): GB202_CONFIG, (9, 0): H100_CONFIG, (10, 0): B200_CONFIG, (10, 3): B300_CONFIG} +CONFIG_OVERRIDE = None +ROWS_PER_PARTIAL_SUM = 256 +MIN_FEATURE_PAD = 16 +MAX_FEATURE_PAD = 64 +LAYER_NORM_EPS_VALUE = 1e-5 +LAYER_NORM_EPS = tl.constexpr(LAYER_NORM_EPS_VALUE) + + +def kernel_config(device): + if CONFIG_OVERRIDE is not None: + return CONFIG_OVERRIDE + return CONFIGS_BY_CAPABILITY.get(torch.cuda.get_device_capability(device), GB202_CONFIG) + + +@triton.jit +def _bf16_round(x): + return x.to(tl.bfloat16).to(tl.float32) + + +@triton.jit +def _encode_slot_chunk( + x_bf16, + row_mask, + flat_rows, + w1_ptr, + b1_ptr, + gamma_ptr, + beta_ptr, + w2t_ptr, + b2, + normed_relu_ptr, + D: tl.constexpr, + F_PAD: tl.constexpr, + BLOCK_K: tl.constexpr, + STORE_ACTS: tl.constexpr, +): + # full-row stats first, then columns recomputed per chunk: register tensors cannot be sliced + d_offs = tl.arange(0, D) + f_offs = tl.arange(0, F_PAD) + w1t = tl.load(w1_ptr + d_offs[None, :] * F_PAD + f_offs[:, None]) + b1 = tl.load(b1_ptr + d_offs) + pre_norm = _bf16_round(tl.dot(x_bf16, w1t) + b1[None, :]) + mean = tl.sum(pre_norm, axis=1) / D + centered = pre_norm - mean[:, None] + rstd = tl.rsqrt(tl.sum(centered * centered, axis=1) / D + LAYER_NORM_EPS) + encoded = tl.zeros((x_bf16.shape[0], D), tl.float32) + for k0 in range(0, D, BLOCK_K): + k_offs = k0 + tl.arange(0, BLOCK_K) + w1t_chunk = tl.load(w1_ptr + k_offs[None, :] * F_PAD + f_offs[:, None]) + b1_chunk = tl.load(b1_ptr + k_offs) + gamma_chunk = tl.load(gamma_ptr + k_offs) + beta_chunk = tl.load(beta_ptr + k_offs) + pre_norm_chunk = _bf16_round(tl.dot(x_bf16, w1t_chunk) + b1_chunk[None, :]) + normed_chunk = (pre_norm_chunk - mean[:, None]) * rstd[:, None] * gamma_chunk[None, :] + beta_chunk[None, :] + normed_relu_chunk = tl.maximum(normed_chunk, 0.0).to(tl.bfloat16) + if STORE_ACTS: + tl.store( + normed_relu_ptr + flat_rows[:, None] * D + k_offs[None, :], normed_relu_chunk, mask=row_mask[:, None] + ) + w2t_chunk = tl.load(w2t_ptr + k_offs[:, None] * D + d_offs[None, :]) + encoded = tl.dot(normed_relu_chunk, w2t_chunk, encoded) + return _bf16_round(encoded + b2[None, :]) + + +@triton.jit +def fused_slot_encoder_fwd_kernel( + x_ptr, + counts_ptr, + w1_ptr, + b1_ptr, + gamma_ptr, + beta_ptr, + w2t_ptr, + b2_ptr, + normed_relu_ptr, + encoded_ptr, + pooled_ptr, + tie_count_ptr, + B, + S, + F, + stride_xb, + stride_xs, + stride_xf, + BLOCK_B: tl.constexpr, + BLOCK_S: tl.constexpr, + D: tl.constexpr, + F_PAD: tl.constexpr, + BLOCK_K: tl.constexpr, + STORE_ACTS: tl.constexpr, +): + ROWS: tl.constexpr = BLOCK_B * BLOCK_S + pid = tl.program_id(0) + batch_offs = pid * BLOCK_B + tl.arange(0, BLOCK_B) + batch_mask = batch_offs < B + counts = tl.load(counts_ptr + batch_offs, mask=batch_mask, other=0) + d_offs = tl.arange(0, D) + f_offs = tl.arange(0, F_PAD) + b2 = tl.load(b2_ptr + d_offs) + row_offs = tl.arange(0, ROWS) + row_slot = row_offs % BLOCK_S + row_batch = pid * BLOCK_B + row_offs // BLOCK_S + row_counts = tl.load(counts_ptr + row_batch, mask=row_batch < B, other=0) + running_max = tl.full((BLOCK_B, D), float("-inf"), tl.float32) + tie_count = tl.zeros((BLOCK_B, D), tl.int32) + for slot0 in range(0, S, BLOCK_S): + slots = slot0 + row_slot + row_mask = (row_batch < B) & (slots < S) + x = tl.load( + x_ptr + row_batch[:, None] * stride_xb + slots[:, None] * stride_xs + f_offs[None, :] * stride_xf, + mask=row_mask[:, None] & (f_offs[None, :] < F), + other=0.0, + ) + flat_rows = row_batch * S + slots + encoded = _encode_slot_chunk( + x.to(tl.bfloat16), + row_mask, + flat_rows, + w1_ptr, + b1_ptr, + gamma_ptr, + beta_ptr, + w2t_ptr, + b2, + normed_relu_ptr, + D, + F_PAD, + BLOCK_K, + STORE_ACTS, + ) + if STORE_ACTS: + tl.store( + encoded_ptr + flat_rows[:, None] * D + d_offs[None, :], encoded.to(tl.bfloat16), mask=row_mask[:, None] + ) + valid = row_mask & (slots < row_counts) + masked = tl.reshape(tl.where(valid[:, None], encoded, float("-inf")), (BLOCK_B, BLOCK_S, D)) + valid3 = tl.reshape(valid, (BLOCK_B, BLOCK_S)) + chunk_max = tl.max(masked, axis=1) + chunk_ties = tl.sum(((masked == chunk_max[:, None, :]) & valid3[:, :, None]).to(tl.int32), axis=1) + greater = chunk_max > running_max + equal = chunk_max == running_max + tie_count = tl.where(greater, chunk_ties, tl.where(equal, tie_count + chunk_ties, tie_count)) + running_max = tl.where(greater, chunk_max, running_max) + pooled = tl.where((counts > 0)[:, None], running_max, 0.0) + out_offs = batch_offs[:, None] * D + d_offs[None, :] + tl.store(pooled_ptr + out_offs, pooled.to(tl.bfloat16), mask=batch_mask[:, None]) + tl.store(tie_count_ptr + out_offs, tie_count.to(tl.uint8), mask=batch_mask[:, None]) + + +@triton.jit +def fused_slot_pool_bwd_kernel( + encoded_ptr, + counts_ptr, + pooled_ptr, + tie_count_ptr, + grad_pooled_ptr, + grad_encoded_ptr, + grad_b2_partial_ptr, + B, + S, + stride_grad_row, + BLOCK_R: tl.constexpr, + ITERS: tl.constexpr, + D: tl.constexpr, +): + pid = tl.program_id(0) + d_offs = tl.arange(0, D) + grad_b2 = tl.zeros((D,), tl.float32) + for it in range(ITERS): + rows = (pid * ITERS + it) * BLOCK_R + tl.arange(0, BLOCK_R) + row_mask = rows < B * S + batch = rows // S + slots = rows % S + counts = tl.load(counts_ptr + batch, mask=row_mask, other=0) + valid = row_mask & (slots < counts) + row_offs = rows[:, None] * D + d_offs[None, :] + batch_offs = batch[:, None] * D + d_offs[None, :] + grad_offs = batch[:, None] * stride_grad_row + d_offs[None, :] + encoded = tl.load(encoded_ptr + row_offs, mask=row_mask[:, None], other=0.0).to(tl.float32) + pooled = tl.load(pooled_ptr + batch_offs, mask=row_mask[:, None], other=0.0).to(tl.float32) + grad_pooled = tl.load(grad_pooled_ptr + grad_offs, mask=row_mask[:, None], other=0.0).to(tl.float32) + tie_count = tl.load(tie_count_ptr + batch_offs, mask=row_mask[:, None], other=1).to(tl.float32) + shared_grad = _bf16_round(grad_pooled / tl.maximum(tie_count, 1.0)) + grad_encoded = tl.where(valid[:, None] & (encoded == pooled), shared_grad, 0.0) + tl.store(grad_encoded_ptr + row_offs, grad_encoded.to(tl.bfloat16), mask=row_mask[:, None]) + grad_b2 += tl.sum(grad_encoded, axis=0) + tl.store(grad_b2_partial_ptr + pid * D + d_offs, grad_b2) + + +@triton.jit +def fused_slot_encoder_bwd_kernel( + x_ptr, + grad_encoded_ptr, + w1_ptr, + b1_ptr, + gamma_ptr, + beta_ptr, + w2_ptr, + grad_pre_norm_ptr, + grad_gamma_partial_ptr, + grad_beta_partial_ptr, + grad_b1_partial_ptr, + B, + S, + F, + stride_xb, + stride_xs, + stride_xf, + BLOCK_R: tl.constexpr, + ITERS: tl.constexpr, + D: tl.constexpr, + F_PAD: tl.constexpr, + BLOCK_K: tl.constexpr, +): + pid = tl.program_id(0) + d_offs = tl.arange(0, D) + f_offs = tl.arange(0, F_PAD) + w1t = tl.load(w1_ptr + d_offs[None, :] * F_PAD + f_offs[:, None]) + b1 = tl.load(b1_ptr + d_offs) + gamma = tl.load(gamma_ptr + d_offs) + beta = tl.load(beta_ptr + d_offs) + grad_gamma = tl.zeros((D,), tl.float32) + grad_beta = tl.zeros((D,), tl.float32) + grad_b1 = tl.zeros((D,), tl.float32) + for it in range(ITERS): + rows = (pid * ITERS + it) * BLOCK_R + tl.arange(0, BLOCK_R) + row_mask = rows < B * S + batch = rows // S + slots = rows % S + grad_normed_relu = tl.zeros((BLOCK_R, D), tl.float32) + for k0 in range(0, D, BLOCK_K): + k_offs = k0 + tl.arange(0, BLOCK_K) + grad_chunk = tl.load( + grad_encoded_ptr + rows[:, None] * D + k_offs[None, :], mask=row_mask[:, None], other=0.0 + ) + w2_chunk = tl.load(w2_ptr + k_offs[:, None] * D + d_offs[None, :]) + grad_normed_relu = tl.dot(grad_chunk, w2_chunk, grad_normed_relu) + grad_normed_relu = _bf16_round(grad_normed_relu) + x = tl.load( + x_ptr + batch[:, None] * stride_xb + slots[:, None] * stride_xs + f_offs[None, :] * stride_xf, + mask=row_mask[:, None] & (f_offs[None, :] < F), + other=0.0, + ) + pre_norm = _bf16_round(tl.dot(x.to(tl.bfloat16), w1t) + b1[None, :]) + mean = tl.sum(pre_norm, axis=1) / D + centered = pre_norm - mean[:, None] + rstd = tl.rsqrt(tl.sum(centered * centered, axis=1) / D + LAYER_NORM_EPS) + normalized = centered * rstd[:, None] + normed = normalized * gamma[None, :] + beta[None, :] + grad_normed = tl.where(normed > 0.0, grad_normed_relu, 0.0) + grad_scaled = grad_normed * gamma[None, :] + mean_grad = tl.sum(grad_scaled, axis=1) / D + mean_grad_normalized = tl.sum(grad_scaled * normalized, axis=1) / D + grad_pre_norm = _bf16_round( + rstd[:, None] * (grad_scaled - mean_grad[:, None] - normalized * mean_grad_normalized[:, None]) + ) + tl.store( + grad_pre_norm_ptr + rows[:, None] * D + d_offs[None, :], + grad_pre_norm.to(tl.bfloat16), + mask=row_mask[:, None], + ) + grad_gamma += tl.sum(grad_normed * normalized, axis=0) + grad_beta += tl.sum(grad_normed, axis=0) + grad_b1 += tl.sum(grad_pre_norm, axis=0) + tl.store(grad_gamma_partial_ptr + pid * D + d_offs, grad_gamma) + tl.store(grad_beta_partial_ptr + pid * D + d_offs, grad_beta) + tl.store(grad_b1_partial_ptr + pid * D + d_offs, grad_b1) + + +def _feature_pad(in_features): + return max(MIN_FEATURE_PAD, triton.next_power_of_2(in_features)) + + +def _slot_block(num_slots, block_slots): + return min(block_slots, triton.next_power_of_2(num_slots)) + + +def pad_weight(w1_bf16, feature_pad): + out_features, in_features = w1_bf16.shape + padded = torch.zeros(out_features, feature_pad, dtype=torch.bfloat16, device=w1_bf16.device) + padded[:, :in_features] = w1_bf16 + return padded + + +@torch.library.custom_op("pufferdrive::fused_slot_encoder_fwd", mutates_args=()) +def fused_slot_encoder_fwd( + x: torch.Tensor, + counts: torch.Tensor, + w1_padded: torch.Tensor, + b1: torch.Tensor, + gamma: torch.Tensor, + beta: torch.Tensor, + w2t: torch.Tensor, + b2: torch.Tensor, + store_acts: bool, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + B, S, F = x.shape + D, feature_pad = w1_padded.shape + config = kernel_config(x.device) + rows = B * S if store_acts else 0 + normed_relu = torch.empty(rows, D, dtype=torch.bfloat16, device=x.device) + encoded = torch.empty(rows, D, dtype=torch.bfloat16, device=x.device) + pooled = torch.empty(B, D, dtype=torch.bfloat16, device=x.device) + tie_count = torch.empty(B, D, dtype=torch.uint8, device=x.device) + grid = (triton.cdiv(B, config.fwd_block_batch),) + fused_slot_encoder_fwd_kernel[grid]( + x, + counts, + w1_padded, + b1, + gamma, + beta, + w2t, + b2, + normed_relu, + encoded, + pooled, + tie_count, + B, + S, + F, + x.stride(0), + x.stride(1), + x.stride(2), + BLOCK_B=config.fwd_block_batch, + BLOCK_S=_slot_block(S, config.fwd_block_slots), + D=D, + F_PAD=feature_pad, + BLOCK_K=config.fwd_block_k, + STORE_ACTS=store_acts, + num_warps=config.fwd_num_warps, + num_stages=config.fwd_num_stages, + ) + return pooled, tie_count, normed_relu, encoded + + +@fused_slot_encoder_fwd.register_fake +def _(x, counts, w1_padded, b1, gamma, beta, w2t, b2, store_acts): + B, S, _ = x.shape + D = w1_padded.shape[0] + rows = B * S if store_acts else 0 + return ( + torch.empty(B, D, dtype=torch.bfloat16, device=x.device), + torch.empty(B, D, dtype=torch.uint8, device=x.device), + torch.empty(rows, D, dtype=torch.bfloat16, device=x.device), + torch.empty(rows, D, dtype=torch.bfloat16, device=x.device), + ) + + +@torch.library.custom_op("pufferdrive::fused_slot_encoder_bwd", mutates_args=()) +def fused_slot_encoder_bwd( + x: torch.Tensor, + counts: torch.Tensor, + w1_padded: torch.Tensor, + b1: torch.Tensor, + gamma: torch.Tensor, + beta: torch.Tensor, + w2: torch.Tensor, + normed_relu: torch.Tensor, + encoded: torch.Tensor, + pooled: torch.Tensor, + tie_count: torch.Tensor, + grad_pooled: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + B, S, F = x.shape + D, feature_pad = w1_padded.shape + config = kernel_config(x.device) + rows = B * S + partial_count = triton.cdiv(rows, ROWS_PER_PARTIAL_SUM) + # The cat backward hands over a column slice of the feature gradient; only its row stride is non-trivial. + if grad_pooled.stride(1) != 1: + grad_pooled = grad_pooled.contiguous() + grad_encoded = torch.empty(rows, D, dtype=torch.bfloat16, device=x.device) + grad_b2_partial = torch.empty(partial_count, D, dtype=torch.float32, device=x.device) + fused_slot_pool_bwd_kernel[(partial_count,)]( + encoded, + counts, + pooled, + tie_count, + grad_pooled, + grad_encoded, + grad_b2_partial, + B, + S, + grad_pooled.stride(0), + BLOCK_R=config.pool_block_rows, + ITERS=ROWS_PER_PARTIAL_SUM // config.pool_block_rows, + D=D, + num_warps=config.pool_num_warps, + ) + grad_pre_norm = torch.empty(rows, D, dtype=torch.bfloat16, device=x.device) + grad_gamma_partial = torch.empty(partial_count, D, dtype=torch.float32, device=x.device) + grad_beta_partial = torch.empty(partial_count, D, dtype=torch.float32, device=x.device) + grad_b1_partial = torch.empty(partial_count, D, dtype=torch.float32, device=x.device) + fused_slot_encoder_bwd_kernel[(partial_count,)]( + x, + grad_encoded, + w1_padded, + b1, + gamma, + beta, + w2, + grad_pre_norm, + grad_gamma_partial, + grad_beta_partial, + grad_b1_partial, + B, + S, + F, + x.stride(0), + x.stride(1), + x.stride(2), + BLOCK_R=config.bwd_block_rows, + ITERS=ROWS_PER_PARTIAL_SUM // config.bwd_block_rows, + D=D, + F_PAD=feature_pad, + BLOCK_K=config.bwd_block_k, + num_warps=config.bwd_num_warps, + num_stages=config.bwd_num_stages, + ) + # Weight gradients stay bf16 cuBLAS GEMMs with fp32 accumulation, as in the autocast reference. + grad_w2 = (grad_encoded.t() @ normed_relu).float() + grad_w1 = (grad_pre_norm.t() @ x.reshape(rows, F).to(torch.bfloat16)).float() + grad_b2 = grad_b2_partial.sum(0).to(torch.bfloat16).float() + grad_b1 = grad_b1_partial.sum(0).to(torch.bfloat16).float() + return grad_w1, grad_b1, grad_gamma_partial.sum(0), grad_beta_partial.sum(0), grad_w2, grad_b2 + + +@fused_slot_encoder_bwd.register_fake +def _(x, counts, w1_padded, b1, gamma, beta, w2, normed_relu, encoded, pooled, tie_count, grad_pooled): + F = x.shape[2] + D = w1_padded.shape[0] + f32 = {"dtype": torch.float32, "device": x.device} + return ( + torch.empty(D, F, **f32), + torch.empty(D, **f32), + torch.empty(D, **f32), + torch.empty(D, **f32), + torch.empty(D, D, **f32), + torch.empty(D, **f32), + ) + + +@torch.library.custom_op("pufferdrive::fused_slot_encoder", mutates_args=()) +def fused_slot_encoder( + x: torch.Tensor, + counts: torch.Tensor, + w1: torch.Tensor, + b1: torch.Tensor, + gamma: torch.Tensor, + beta: torch.Tensor, + w2: torch.Tensor, + b2: torch.Tensor, + store_acts: bool, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + w1_padded = pad_weight(w1.to(torch.bfloat16), _feature_pad(w1.shape[1])) + b1_bf16_values = b1.to(torch.bfloat16).float() + w2_bf16 = w2.to(torch.bfloat16).contiguous() + b2_bf16_values = b2.to(torch.bfloat16).float() + pooled, tie_count, normed_relu, encoded = fused_slot_encoder_fwd( + x, counts, w1_padded, b1_bf16_values, gamma, beta, w2_bf16.t().contiguous(), b2_bf16_values, store_acts + ) + return pooled, tie_count, normed_relu, encoded, w1_padded, b1_bf16_values, w2_bf16 + + +@fused_slot_encoder.register_fake +def _(x, counts, w1, b1, gamma, beta, w2, b2, store_acts): + B, S, F = x.shape + D = w1.shape[0] + rows = B * S if store_acts else 0 + bf16 = {"dtype": torch.bfloat16, "device": x.device} + return ( + torch.empty(B, D, **bf16), + torch.empty(B, D, dtype=torch.uint8, device=x.device), + torch.empty(rows, D, **bf16), + torch.empty(rows, D, **bf16), + torch.empty(D, _feature_pad(F), **bf16), + torch.empty(D, dtype=torch.float32, device=x.device), + torch.empty(D, D, **bf16), + ) + + +def _setup_context(ctx, inputs, output): + x, counts, _, _, gamma, beta, _, _, _ = inputs + pooled, tie_count, normed_relu, encoded, w1_padded, b1_bf16_values, w2_bf16 = output + ctx.save_for_backward( + x, counts, w1_padded, b1_bf16_values, gamma, beta, w2_bf16, normed_relu, encoded, pooled, tie_count + ) + + +def _backward( + ctx, grad_pooled, grad_tie_count, grad_normed_relu, grad_encoded, grad_w1_padded, grad_b1_values, grad_w2_bf16 +): + x, counts, w1_padded, b1_bf16_values, gamma, beta, w2_bf16, normed_relu, encoded, pooled, tie_count = ( + ctx.saved_tensors + ) + grad_w1, grad_b1, grad_gamma, grad_beta, grad_w2, grad_b2 = fused_slot_encoder_bwd( + x, + counts, + w1_padded, + b1_bf16_values, + gamma, + beta, + w2_bf16, + normed_relu, + encoded, + pooled, + tie_count, + grad_pooled.to(torch.bfloat16), + ) + return None, None, grad_w1, grad_b1, grad_gamma, grad_beta, grad_w2, grad_b2, None + + +fused_slot_encoder.register_autograd(_backward, setup_context=_setup_context) + + +def validate_fused_encoder(encoder): + linear_in, norm, activation, linear_out = encoder + if not (isinstance(norm, torch.nn.LayerNorm) and isinstance(activation, torch.nn.ReLU)): + raise TypeError("fused_slot_encoder requires encoder_layer_norm=true and encoder_activation=relu") + width = linear_in.out_features + if width != triton.next_power_of_2(width) or width % 64 != 0 or width > 1024: + raise ValueError(f"fused_slot_encoder needs a power-of-two encoder width that is a multiple of 64, got {width}") + if linear_in.in_features > MAX_FEATURE_PAD: + raise ValueError(f"fused_slot_encoder supports at most {MAX_FEATURE_PAD} input features per slot") + if norm.eps != LAYER_NORM_EPS_VALUE or linear_out.in_features != width or linear_out.out_features != width: + raise ValueError("fused_slot_encoder expects LayerNorm eps 1e-5 and a square second linear layer") + + +def fused_encoder_active(objects): + return objects.is_cuda and torch.is_autocast_enabled("cuda") and torch.get_autocast_dtype("cuda") == torch.bfloat16 + + +def fused_encode_and_pool(objects, valid_counts, encoder): + linear_in, norm, _, linear_out = encoder + store_acts = torch.is_grad_enabled() and linear_in.weight.requires_grad + return fused_slot_encoder( + objects, + valid_counts.contiguous(), + linear_in.weight, + linear_in.bias, + norm.weight, + norm.bias, + linear_out.weight, + linear_out.bias, + store_acts, + )[0] diff --git a/pufferlib/ocean/torch.py b/pufferlib/ocean/torch.py index bf23ae2cc2..815e70c19a 100644 --- a/pufferlib/ocean/torch.py +++ b/pufferlib/ocean/torch.py @@ -7,6 +7,7 @@ import pufferlib import pufferlib.models from pufferlib.ocean.drive import binding +from pufferlib.ocean.fused_slot_encoder import fused_encode_and_pool, fused_encoder_active, validate_fused_encoder from pufferlib.models import Default as Policy # noqa: F401 from pufferlib.models import Convolutional as Conv # noqa: F401 @@ -23,6 +24,8 @@ class DriveBackbone(nn.Module): - Split Actor/Critic (configurable) """ + fused_slot_encoder = False # class default: tests build instances without __init__ + def _create_encoder(self, in_features, out_size): layers = [pufferlib.pytorch.layer_init(nn.Linear(in_features, out_size))] if self.encoder_layer_norm: @@ -35,6 +38,9 @@ def _encode_and_pool(self, objects, valid_counts, encoder): if not self.mask_padded_features: return encoder(objects).max(dim=1).values + if self.fused_slot_encoder and fused_encoder_active(objects): + return fused_encode_and_pool(objects, valid_counts, encoder) + # Dense encode + masked fill keeps shapes static for torch.compile; padded rows are rare. valid_mask = torch.arange(objects.shape[1], device=objects.device) < valid_counts.unsqueeze(1) encoded_objects = encoder(objects) @@ -59,6 +65,7 @@ def __init__( backbone_activation, backbone_layer_norm, mask_padded_features, + fused_slot_encoder, ): super().__init__() self.encoder_act_cls = ACTIVATIONS[encoder_activation] @@ -115,6 +122,14 @@ def __init__( self.context_encoder = self._create_encoder(self.context_dim, context_input_size) encoders_out += context_input_size + self.fused_slot_encoder = fused_slot_encoder + if fused_slot_encoder and not mask_padded_features: + raise ValueError("fused_slot_encoder requires mask_padded_features=true") + slot_encoder_names = ("lane_encoder", "boundary_encoder", "partner_encoder", "traffic_control_encoder") + for encoder_name in slot_encoder_names: + if fused_slot_encoder and hasattr(self, encoder_name): + validate_fused_encoder(getattr(self, encoder_name)) + # 2. Main Backbone MLP backbone_act_cls = ACTIVATIONS[backbone_activation] backbone_layers = [] @@ -321,6 +336,7 @@ def __init__( actor_head_layer_norm: bool = False, critic_head_layer_norm: bool = False, fp32_heads: bool = False, + fused_slot_encoder: bool = False, ): super().__init__() @@ -378,6 +394,7 @@ def __init__( "backbone_activation": backbone_activation, "backbone_layer_norm": backbone_layer_norm, "mask_padded_features": mask_padded_features, + "fused_slot_encoder": fused_slot_encoder, } # Instantiate backbones diff --git a/scripts/kesai/15_tune_fused_slot_encoder_slurm.sh b/scripts/kesai/15_tune_fused_slot_encoder_slurm.sh new file mode 100755 index 0000000000..aaea65c0ab --- /dev/null +++ b/scripts/kesai/15_tune_fused_slot_encoder_slurm.sh @@ -0,0 +1,37 @@ +#!/bin/bash +#SBATCH --job-name tune_fused_encoder +#SBATCH --ntasks 1 +#SBATCH --nodes 1 +#SBATCH --time 3:00:00 +#SBATCH --gres gpu:1 +#SBATCH --mem=125G +#SBATCH --cpus-per-task 18 +#SBATCH --output /home/bjaeger/PufferDrive/experiments/logs/tune_fused_%j.out +#SBATCH --error /home/bjaeger/PufferDrive/experiments/logs/tune_fused_%j.err +#SBATCH --partition dev + +# Times the fused slot-encoder kernels on this node's GPU and prints the KernelConfig to paste into +# CONFIGS_BY_CAPABILITY in pufferlib/ocean/fused_slot_encoder.py. Submit once per GPU type (H100, B200, ...); +# pick the node type with --constraint or --gres gpu::1 if the partition mixes GPUs. + +echo "START TIME: $(date)" +start=$(date +%s) +cd /home/bjaeger/PufferDrive || exit 1 +nvidia-smi --query-gpu=name,compute_cap,memory.total --format=csv + +export OMP_NUM_THREADS=1 +source .venv/bin/activate +bash scripts/kesai/build_ext_if_changed.sh /home/bjaeger/PufferDrive || exit 1 + +RESULT_FILE=/home/bjaeger/PufferDrive/experiments/logs/tune_fused_${SLURM_JOB_ID}_result.txt +python scripts/tune_fused_slot_encoder.py --batch 131072 --shapes all 2>&1 | tee ${RESULT_FILE}.full +# The pasteable block is the tail of the full log. +sed -n '/=== fused_slot_encoder tuning result/,/=== end of tuning result ===/p' ${RESULT_FILE}.full > ${RESULT_FILE} + +echo +echo "Result block (also in ${RESULT_FILE}):" +cat ${RESULT_FILE} + +end=$(date +%s) +echo "END TIME: $(date)" +echo "Runtime: $((end-start)) s" diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index cbcc9ef02d..878b367901 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -68,6 +68,7 @@ srun torchrun \ env.preload_map_cache=True \ train.max_minibatch_size=131072 \ train.minibatch_size=131072 \ + policy.fused_slot_encoder=true \ train.final_model_name=${FINAL_MODEL_NAME} \ train.seed=${SEED} \ tb=True diff --git a/scripts/profile_policy_step.py b/scripts/profile_policy_step.py new file mode 100644 index 0000000000..cc3385e3ac --- /dev/null +++ b/scripts/profile_policy_step.py @@ -0,0 +1,210 @@ +"""Run one PPO update minibatch and one rollout forward of the real Drive policy inside NVTX ranges for Nsight Compute. + +Usage: + ncu --nvtx --nvtx-include "update/" --nvtx-include "rollout/" --nvtx-include "calibrate/" \\ + --metrics gpu__time_duration.sum,sm__ops_path_tensor_src_bf16_dst_fp32.sum,sm__ops_path_tensor_src_fp16_dst_fp32.sum,smsp__sass_thread_inst_executed_op_ffma_pred_on.sum,dram__bytes.sum \\ + --csv --page raw python scripts/profile_policy_step.py --rows 65536 [--fused] > ncu_out.csv + python scripts/profile_policy_step.py --parse ncu_out.csv --rows 65536 +The calibrate range runs a matmul of known size so the tensor-op metric is converted to FLOPs without guessing. +""" + +import argparse +import copy +import csv +import os +import sys + + +os.environ.setdefault("OMP_NUM_THREADS", "1") +import numpy as np +import torch + + +TRAIN_FLOP_PER_TRANSITION = 112145920.0 +ROLLOUT_FLOP_PER_TRANSITION = 37654113.0 +CALIBRATE_N = 8192 +CALIBRATE_FLOPS = 2.0 * CALIBRATE_N**3 +PEAK_BF16_TFLOPS = 209.5 +ROLLOUT_ROWS = 16384 +ENV_AGENTS = 512 +ROLLOUT_STEPS = 40 +TRAIN_OVERRIDES = [ + "vec.backend=Serial", + "vec.num_envs=1", + "vec.num_workers=1", + "vec.batch_size=1", + f"env.num_agents={ENV_AGENTS}", + "train.minibatch_size=8192", + "train.max_minibatch_size=8192", + "wandb=False", + "tb=False", + "neptune=False", +] + + +def build_policy_and_obs(rows, fused): + sys.argv = ["profile_policy_step"] + TRAIN_OVERRIDES + import pufferlib.pufferl as P + + args = P.load_config("puffer_drive") + args = P.normalize_puffer_drive_config(args, "training") + P.validate_puffer_drive_config(args, "training") + args = copy.deepcopy(args) + args["policy"]["fused_slot_encoder"] = fused + vecenv = P.load_env("puffer_drive", args, seed=3) + torch.manual_seed(0) + policy = P.load_policy(args, vecenv, "puffer_drive") + vecenv.async_reset(3) + obs_rows = [] + for _ in range(ROLLOUT_STEPS): + o, *_ = vecenv.recv() + obs_rows.append(np.array(o, copy=True)) + vecenv.send(np.random.uniform(-1, 1, size=vecenv.action_space.shape).astype(vecenv.action_space.dtype)) + vecenv.close() + obs = torch.as_tensor(np.concatenate(obs_rows)).cuda() + repeats = -(-max(rows, ROLLOUT_ROWS) // obs.shape[0]) + return policy, obs.repeat(repeats, 1) + + +def run_phases(rows, fused): + import pufferlib.pytorch + + torch.set_float32_matmul_precision("highest") + torch.backends.cuda.matmul.allow_tf32 = False + policy, obs = build_policy_and_obs(rows, fused) + compiled = torch.compile(policy) + compiled_eval = torch.compile(policy.forward_eval) + sample_logits = torch.compile(pufferlib.pytorch.sample_logits) + optimizer = torch.optim.AdamW(policy.parameters(), lr=1e-4, weight_decay=0.01) + update_obs = obs[:rows] + actions = torch.randint(0, policy.action_dim, (rows,), device="cuda", dtype=torch.int32) + rollout_obs = obs[:ROLLOUT_ROWS] + amp = torch.autocast("cuda", dtype=torch.bfloat16) + + def update_step(): + optimizer.zero_grad(set_to_none=True) + with amp: + logits, value = compiled(update_obs, {"action": actions, "lstm_h": None, "lstm_c": None}) + logits = logits.float() + _, logprob, entropy, _ = sample_logits(logits, action=actions) + loss = -logprob.float().mean() + 0.5 * value.float().square().mean() - 0.01 * entropy.mean() + loss.backward() + torch.nn.utils.clip_grad_norm_(policy.parameters(), 0.5) + optimizer.step() + + def rollout_step(): + with torch.no_grad(), amp: + logits, _ = compiled_eval(rollout_obs, {}) + sample_logits(logits.float(), env_continuous=True, policy=policy) + + for _ in range(2): + update_step() + rollout_step() + wall_ms = {} + for name, step in (("update", update_step), ("rollout", rollout_step)): + start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True) + start.record() + step() + end.record() + torch.cuda.synchronize() + wall_ms[name] = start.elapsed_time(end) + print(f"unprofiled wall time: update {wall_ms['update']:.2f} ms, rollout {wall_ms['rollout']:.2f} ms") + a = torch.randn(CALIBRATE_N, CALIBRATE_N, device="cuda", dtype=torch.bfloat16) + b = torch.randn_like(a) + a @ b + torch.cuda.synchronize() + # Start/end ranges are process-wide and also cover the autograd thread's kernels; push/pop ranges are per thread. + for name, step in (("calibrate", lambda: a @ b), ("update", update_step), ("rollout", rollout_step)): + range_id = torch.cuda.nvtx.range_start(name) + step() + torch.cuda.nvtx.range_end(range_id) + torch.cuda.synchronize() + print( + f"analytic model FLOPs: update {rows * TRAIN_FLOP_PER_TRANSITION:.4e}, rollout {ROLLOUT_ROWS * ROLLOUT_FLOP_PER_TRANSITION:.4e}, calibrate {CALIBRATE_FLOPS:.4e}" + ) + + +METRIC_COLUMNS = { + "time": "gpu__time_duration.sum", + "bf16": "sm__ops_path_tensor_op_hmma_src_bf16_dst_fp32.sum", + "fp16": "sm__ops_path_tensor_op_hmma_src_fp16_dst_fp32.sum", + "tf32": "sm__ops_path_tensor_op_hmma_src_tf32_dst_fp32.sum", + "ffma": "sm__sass_thread_inst_executed_op_ffma_pred_on.sum", + "fadd": "sm__sass_thread_inst_executed_op_fadd_pred_on.sum", + "fmul": "sm__sass_thread_inst_executed_op_fmul_pred_on.sum", + "dram": "dram__bytes.sum", + "local_ld": "sm__sass_inst_executed_op_local_ld.sum", + "local_st": "sm__sass_inst_executed_op_local_st.sum", +} +HEAD_FLOP_PER_TRANSITION = 3166208.0 +ROLLOUT_HEAD_FLOP_PER_TRANSITION = 1055232.0 + + +RANGE_NAMES = ("calibrate", "update", "rollout") + + +def _phase_of(record, kernel_col): + kernel = record[kernel_col] + if "/" in kernel: + return kernel.split("/")[0] + text = " ".join(record) + for name in RANGE_NAMES: + if f":{name}:" in text: + return name + return "unlabeled" + + +def parse(path, rows): + with open(path) as handle: + table = list(csv.reader(line for line in handle if line.startswith('"'))) + header, data_rows = table[0], table[2:] + kernel_col = header.index("Kernel Name") + columns = {key: header.index(name) for key, name in METRIC_COLUMNS.items() if name in header} + sums = {} + for record in data_rows: + phase = _phase_of(record, kernel_col) + phase_sums = sums.setdefault(phase, {key: 0.0 for key in columns}) + phase_sums["kernels"] = phase_sums.get("kernels", 0) + 1 + for key, col in columns.items(): + phase_sums[key] += float(record[col].replace(",", "") or 0.0) + calibrate = sums.get("calibrate") + if calibrate: + print(f"calibration: counter {calibrate['bf16']:.6e} FLOP vs exact {CALIBRATE_FLOPS:.6e} for the 8192^3 matmul") + model_tensor = { + "update": rows * (TRAIN_FLOP_PER_TRANSITION - HEAD_FLOP_PER_TRANSITION), + "rollout": ROLLOUT_ROWS * (ROLLOUT_FLOP_PER_TRANSITION - ROLLOUT_HEAD_FLOP_PER_TRANSITION), + "calibrate": CALIBRATE_FLOPS, + } + model_fp32 = {"update": rows * HEAD_FLOP_PER_TRANSITION, "rollout": ROLLOUT_ROWS * ROLLOUT_HEAD_FLOP_PER_TRANSITION} + peak = PEAK_BF16_TFLOPS * 1e12 + for phase, m in sums.items(): + seconds = m["time"] * 1e-9 + tensor = m["bf16"] + m.get("fp16", 0.0) + m.get("tf32", 0.0) + fp32 = 2.0 * m.get("ffma", 0.0) + m.get("fadd", 0.0) + m.get("fmul", 0.0) + print(f"== {phase}: {m['kernels']} kernels, summed kernel time {seconds * 1e3:.2f} ms") + print( + f" tensor FLOP counted {tensor:.4e} (bf16 {m['bf16']:.3e}, tf32 {m.get('tf32', 0.0):.2e}) vs model GEMM FLOP {model_tensor.get(phase, 0.0):.4e}" + ) + print(f" fp32 CUDA-core FLOP counted {fp32:.4e} vs model fp32 head FLOP {model_fp32.get(phase, 0.0):.3e}") + print( + f" tensor-FLOP rate over kernel time {tensor / seconds / 1e12:.1f} TFLOP/s = {100 * tensor / seconds / peak:.1f}% of bf16 peak; model-FLOP rate {model_tensor.get(phase, 0.0) / seconds / 1e12:.1f} TFLOP/s = {100 * model_tensor.get(phase, 0.0) / seconds / peak:.1f}%" + ) + print( + f" DRAM {m['dram'] / 1e9:.2f} GB -> {m['dram'] / seconds / 1e9:.0f} GB/s; local (spill) ld/st instructions {m.get('local_ld', 0.0):.3e} / {m.get('local_st', 0.0):.3e}" + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--rows", type=int, default=65536, help="update minibatch rows") + parser.add_argument("--fused", action="store_true", help="enable policy.fused_slot_encoder") + parser.add_argument("--parse", help="ncu raw-page CSV to summarize instead of running") + args = parser.parse_args() + if args.parse: + parse(args.parse, args.rows) + else: + run_phases(args.rows, args.fused) + + +if __name__ == "__main__": + main() diff --git a/scripts/tune_fused_slot_encoder.py b/scripts/tune_fused_slot_encoder.py new file mode 100644 index 0000000000..da34b7875d --- /dev/null +++ b/scripts/tune_fused_slot_encoder.py @@ -0,0 +1,206 @@ +"""Time fused_slot_encoder kernel configurations on the current GPU and print one KernelConfig for its GPU class. + +Usage (needs CUDA + Triton; one process per GPU type): + python scripts/tune_fused_slot_encoder.py # all encoder shapes, 131072 transitions + python scripts/tune_fused_slot_encoder.py --batch 65536 --quick +Paste the printed KernelConfig into CONFIGS_BY_CAPABILITY in pufferlib/ocean/fused_slot_encoder.py. +""" + +import argparse +import itertools +import time + +import torch +import triton +from torch import nn + +import pufferlib.ocean.fused_slot_encoder as fse +import pufferlib.pytorch + + +ENCODER_SHAPES = {"lane": (35, 10, 256), "boundary": (30, 6, 256), "partner": (20, 11, 256), "traffic": (4, 14, 128)} +OBS_PREFIX = 100 +TIMING_ITERS = 15 +WARMUP_ITERS = 3 +POOLED_TOLERANCE = 2.0**-6 +GRAD_TOLERANCE = 1e-3 +FWD_GRID = { + "block_batch": (4, 8, 16), + "block_slots": (4, 8), + "block_k": (32, 64), + "num_warps": (4, 8), + "num_stages": (1, 2, 3), +} +BWD_GRID = {"block_rows": (32, 64, 128), "block_k": (32, 64), "num_warps": (4, 8), "num_stages": (1, 2, 3)} +POOL_GRID = {"block_rows": (16, 32, 64), "num_warps": (4, 8)} +QUICK_FWD_GRID = {"block_batch": (8,), "block_slots": (4,), "block_k": (32, 64), "num_warps": (4,), "num_stages": (2,)} +QUICK_BWD_GRID = {"block_rows": (64,), "block_k": (64,), "num_warps": (8,), "num_stages": (1, 2)} +QUICK_POOL_GRID = {"block_rows": (32,), "num_warps": (4,)} +LAUNCH_ERRORS = (triton.runtime.errors.OutOfResources, triton.compiler.errors.CompilationError, RuntimeError) +PARTS = ("fwd", "bwd", "pool") + + +def make_case(slots, features, width, batch, seed): + generator = torch.Generator(device="cuda").manual_seed(seed) + encoder = nn.Sequential( + pufferlib.pytorch.layer_init(nn.Linear(features, width)), + nn.LayerNorm(width), + nn.ReLU(), + pufferlib.pytorch.layer_init(nn.Linear(width, width)), + ).cuda() + obs = torch.zeros(batch, OBS_PREFIX + slots * features, device="cuda") + counts = torch.randint(0, slots + 1, (batch,), device="cuda", generator=generator) + objects = obs[:, OBS_PREFIX:].view(batch, slots, features) + valid = torch.arange(slots, device="cuda")[None, :] < counts[:, None] + objects.copy_( + (torch.rand(batch, slots, features, device="cuda", generator=generator) * 1.6 - 0.8) * valid[:, :, None] + ) + grad_pooled = (torch.randn(batch, width, device="cuda", generator=generator) * 0.1).to(torch.bfloat16) + return encoder, objects, counts.long(), grad_pooled + + +def time_call(fn): + for _ in range(WARMUP_ITERS): + fn() + torch.cuda.synchronize() + start = time.perf_counter() + for _ in range(TIMING_ITERS): + fn() + torch.cuda.synchronize() + return (time.perf_counter() - start) / TIMING_ITERS * 1e3 + + +def with_part(base, part, params): + fields = {name: getattr(base, name) for name in base.__dataclass_fields__} + fields.update({f"{part}_{key}": value for key, value in params.items()}) + return fse.KernelConfig(**fields) + + +class ShapeCase: + def __init__(self, name, batch): + slots, features, width = ENCODER_SHAPES[name] + self.name = name + self.encoder, self.objects, self.counts, self.grad_pooled = make_case(slots, features, width, batch, seed=slots) + fse.CONFIG_OVERRIDE = fse.GB202_CONFIG + self.reference_fwd = self.forward() + self.reference_bwd = self.backward(self.reference_fwd) + fse.CONFIG_OVERRIDE = None + + def forward(self): + linear_in, norm, _, linear_out = self.encoder + return fse.fused_slot_encoder( + self.objects, + self.counts, + linear_in.weight, + linear_in.bias, + norm.weight, + norm.bias, + linear_out.weight, + linear_out.bias, + True, + ) + + def backward(self, fwd): + pooled, tie_count, normed_relu, encoded, w1_padded, b1_values, w2_bf16 = fwd + _, norm, _, _ = self.encoder + return fse.fused_slot_encoder_bwd( + self.objects, + self.counts, + w1_padded, + b1_values, + norm.weight, + norm.bias, + w2_bf16, + normed_relu, + encoded, + pooled, + tie_count, + self.grad_pooled, + ) + + def measure(self, part, config): + """Milliseconds for one part under `config`, or None when its numerics differ from the default config.""" + fse.CONFIG_OVERRIDE = config + try: + if part == "fwd": + pooled = self.forward()[0] + if (pooled.float() - self.reference_fwd[0].float()).abs().max().item() > POOLED_TOLERANCE: + return None + return time_call(self.forward) + grads = self.backward(self.reference_fwd) + for candidate, reference in zip(grads, self.reference_bwd): + if ((candidate - reference).norm() / reference.norm().clamp_min(1e-12)).item() > GRAD_TOLERANCE: + return None + return time_call(lambda: self.backward(self.reference_fwd)) + finally: + fse.CONFIG_OVERRIDE = None + + +def sweep(cases, part, grid, base): + totals = {} + per_shape_best = {case.name: (float("inf"), None) for case in cases} + for values in itertools.product(*grid.values()): + params = dict(zip(grid.keys(), values)) + config = with_part(base, part, params) + total = 0.0 + for case in cases: + try: + ms = case.measure(part, config) + except LAUNCH_ERRORS as exc: + ms = None + print(f" {part} {params} on {case.name}: skipped ({str(exc).splitlines()[0][:70]})", flush=True) + if ms is None: + total = None + break + total += ms + if ms < per_shape_best[case.name][0]: + per_shape_best[case.name] = (ms, params) + if total is not None: + totals[tuple(params.items())] = total + print(f" {part} {params}: {total:7.3f} ms over all shapes", flush=True) + best_key = min(totals, key=totals.get) + return dict(best_key), totals[best_key], per_shape_best + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--shapes", default="all", help="comma-separated encoder names or 'all'") + parser.add_argument("--batch", type=int, default=131072, help="transitions per minibatch") + parser.add_argument("--quick", action="store_true", help="tiny grids, for checking that the script runs") + args = parser.parse_args() + names = list(ENCODER_SHAPES) if args.shapes == "all" else args.shapes.split(",") + grids = {"fwd": FWD_GRID, "bwd": BWD_GRID, "pool": POOL_GRID} + if args.quick: + grids = {"fwd": QUICK_FWD_GRID, "bwd": QUICK_BWD_GRID, "pool": QUICK_POOL_GRID} + properties = torch.cuda.get_device_properties(0) + capability = torch.cuda.get_device_capability(0) + device_line = ( + f"{properties.name}, capability {capability}, {properties.multi_processor_count} SMs, " + f"{properties.shared_memory_per_block_optin // 1024} KB shared memory per block, " + f"torch {torch.__version__}, triton {triton.__version__}" + ) + print(device_line, flush=True) + cases = [ShapeCase(name, args.batch) for name in names] + base = fse.GB202_CONFIG + recommended = base + report_lines = [] + for part in PARTS: + best_params, best_total, per_shape = sweep(cases, part, grids[part], base) + baseline_total = sum(case.measure(part, base) for case in cases) + recommended = with_part(recommended, part, best_params) + report_lines.append(f"{part}: GB202_CONFIG {baseline_total:.3f} ms -> best {best_total:.3f} ms {best_params}") + for name, (ms, params) in per_shape.items(): + report_lines.append(f" {part} best for {name}: {ms:.3f} ms {params}") + combined_total = sum(case.measure(part, recommended) for case in cases for part in PARTS) + baseline_combined = sum(case.measure(part, base) for case in cases for part in PARTS) + print("\n=== fused_slot_encoder tuning result (paste this block back) ===") + print(device_line) + print(f"batch {args.batch}, shapes {','.join(names)}") + print("\n".join(report_lines)) + print(f"all parts, all shapes: GB202_CONFIG {baseline_combined:.3f} ms -> recommended {combined_total:.3f} ms") + print(f"RECOMMENDED for capability {capability}: {recommended}") + print("=== end of tuning result ===") + + +if __name__ == "__main__": + main() diff --git a/tests/unit_tests/test_fused_slot_encoder.py b/tests/unit_tests/test_fused_slot_encoder.py new file mode 100644 index 0000000000..1e2aaaec65 --- /dev/null +++ b/tests/unit_tests/test_fused_slot_encoder.py @@ -0,0 +1,140 @@ +"""The fused Triton slot encoder must reproduce DriveBackbone._encode_and_pool under bf16 autocast. + +Agreement is checked at bf16 rounding level against the eager autocast path and against an fp32 ground truth: +the fused path may not be further from the truth than the eager bf16 path it replaces. +""" + +import pytest +import torch +from torch import nn + +import pufferlib.pytorch + + +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="fused slot encoder needs CUDA + Triton") + +ENCODER_SHAPES = [(35, 10, 256), (30, 6, 256), (20, 11, 256), (4, 14, 128)] +BATCH = 1024 +BF16_ULP_AT_FOUR = 2.0**-6 + + +def make_encoder(in_features, width): + layers = [ + pufferlib.pytorch.layer_init(nn.Linear(in_features, width)), + nn.LayerNorm(width), + nn.ReLU(), + pufferlib.pytorch.layer_init(nn.Linear(width, width)), + ] + return nn.Sequential(*layers).cuda() + + +def reference_encode_and_pool(objects, valid_counts, encoder): + valid_mask = torch.arange(objects.shape[1], device=objects.device) < valid_counts.unsqueeze(1) + encoded = encoder(objects) + masked = encoded.masked_fill(~valid_mask.unsqueeze(2), torch.finfo(encoded.dtype).min) + pooled = masked.amax(dim=1) + return torch.where(valid_counts.unsqueeze(1) == 0, pooled.new_zeros(()), pooled) + + +def make_inputs(slots, features, width, seed): + generator = torch.Generator(device="cuda").manual_seed(seed) + obs_width = 100 + slots * features + obs = torch.zeros(BATCH, obs_width, device="cuda") + counts = torch.randint(0, slots + 1, (BATCH,), device="cuda", generator=generator) + counts[: BATCH // 8] = 0 + counts[BATCH // 8 : BATCH // 4] = slots + objects = obs[:, 100:].view(BATCH, slots, features) + data = torch.rand(BATCH, slots, features, device="cuda", generator=generator) * 1.6 - 0.8 + valid = torch.arange(slots, device="cuda")[None, :] < counts[:, None] + objects.copy_(data * valid[:, :, None]) + duplicated = counts >= 2 + objects[duplicated, 1] = objects[duplicated, 0] + grad_pooled = (torch.randn(BATCH, width, device="cuda", generator=generator) * 0.1).to(torch.bfloat16) + return objects, counts.long(), grad_pooled + + +def run(encoder, objects, counts, grad_pooled, path): + from pufferlib.ocean.fused_slot_encoder import fused_encode_and_pool + + for parameter in encoder.parameters(): + parameter.grad = None + if path == "fp32": + pooled = reference_encode_and_pool(objects, counts, encoder) + pooled.backward(grad_pooled.float()) + else: + with torch.autocast("cuda", dtype=torch.bfloat16): + if path == "eager": + pooled = reference_encode_and_pool(objects, counts, encoder) + else: + pooled = fused_encode_and_pool(objects, counts, encoder) + pooled.backward(grad_pooled) + grads = {name: parameter.grad.detach().clone() for name, parameter in encoder.named_parameters()} + return pooled.detach().float(), grads + + +def relative_error(a, b): + return ((a - b).norm() / b.norm().clamp_min(1e-12)).item() + + +@pytest.mark.parametrize("slots,features,width", ENCODER_SHAPES) +def test_fused_matches_autocast_reference(slots, features, width): + torch.manual_seed(0) + encoder = make_encoder(features, width) + objects, counts, grad_pooled = make_inputs(slots, features, width, seed=slots) + truth_pooled, truth_grads = run(encoder, objects, counts, grad_pooled, "fp32") + eager_pooled, eager_grads = run(encoder, objects, counts, grad_pooled, "eager") + fused_pooled, fused_grads = run(encoder, objects, counts, grad_pooled, "fused") + + assert fused_pooled.dtype == torch.float32 and fused_pooled.shape == eager_pooled.shape + pooled_diff = (fused_pooled - eager_pooled).abs() + assert pooled_diff.max().item() <= BF16_ULP_AT_FOUR + assert (pooled_diff > 0).float().mean().item() < 5e-3 + assert (fused_pooled - truth_pooled).abs().max().item() <= 1.05 * ( + eager_pooled - truth_pooled + ).abs().max().item() + 1e-3 + for name in eager_grads: + assert relative_error(fused_grads[name], eager_grads[name]) < 2e-2, name + fused_truth_error = relative_error(fused_grads[name], truth_grads[name]) + eager_truth_error = relative_error(eager_grads[name], truth_grads[name]) + assert fused_truth_error <= 1.05 * eager_truth_error + 2e-3, name + + +@pytest.mark.parametrize("slots,features,width", ENCODER_SHAPES[:1]) +def test_fused_inference_matches_training_forward(slots, features, width): + from pufferlib.ocean.fused_slot_encoder import fused_encode_and_pool + + torch.manual_seed(0) + encoder = make_encoder(features, width) + objects, counts, _ = make_inputs(slots, features, width, seed=7) + with torch.autocast("cuda", dtype=torch.bfloat16): + pooled_train = fused_encode_and_pool(objects, counts, encoder) + with torch.no_grad(): + pooled_eval = fused_encode_and_pool(objects, counts, encoder) + assert torch.equal(pooled_train.detach(), pooled_eval) + assert pooled_eval.dtype == torch.bfloat16 + + +def test_fused_handles_strided_upstream_gradient(): + """The cat backward hands the encoder a strided slice of the feature gradient.""" + from pufferlib.ocean.fused_slot_encoder import fused_encode_and_pool + + torch.manual_seed(0) + slots, features, width = ENCODER_SHAPES[0] + encoder = make_encoder(features, width) + head = pufferlib.pytorch.layer_init(nn.Linear(2 * width, 8)).cuda() + objects, counts, _ = make_inputs(slots, features, width, seed=11) + other_features = torch.randn(BATCH, width, device="cuda") + grads = {} + for path in ("eager", "fused"): + for parameter in encoder.parameters(): + parameter.grad = None + with torch.autocast("cuda", dtype=torch.bfloat16): + if path == "eager": + pooled = reference_encode_and_pool(objects, counts, encoder) + else: + pooled = fused_encode_and_pool(objects, counts, encoder) + output = head(torch.cat([other_features.to(pooled.dtype), pooled], dim=1)) + output.float().square().mean().backward() + grads[path] = {name: parameter.grad.detach().clone() for name, parameter in encoder.named_parameters()} + for name in grads["eager"]: + assert relative_error(grads["fused"][name], grads["eager"][name]) < 2e-2, name From 2fe6586dd0dbfbdd4b20d82370e1b9e36e069e47 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Tue, 6 Oct 2026 15:56:51 +0200 Subject: [PATCH 007/123] Add Damianos changes for Alpasim cosim. --- pufferlib/ocean/drive/drive.h | 20 ++++++--- pufferlib/ocean/drive/drive.py | 5 ++- pufferlib/ocean/env_binding.h | 22 ++++++---- pufferlib/ocean/fused_slot_encoder.py | 5 ++- .../kesai/15_tune_fused_slot_encoder_slurm.sh | 12 +++++- scripts/profile_policy_step.py | 34 ++++++++++++++- scripts/tune_fused_slot_encoder.py | 6 +++ tests/drive/test_drive_cosim_setters.c | 41 +++++++++++++++++-- 8 files changed, 123 insertions(+), 22 deletions(-) diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index a70d3d0ce5..f0f5db7948 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -4034,7 +4034,8 @@ int c_set_agent_states( const float *vy, const float *yaw_rate, const float *accel_long, - const float *seconds_stopped) { + const float *seconds_stopped, + const float *steering) { for (int k = 0; k < count; k++) { int agent_idx = idx[k]; if (agent_idx < 0 || agent_idx >= env->num_total_agents) { @@ -4046,6 +4047,9 @@ int c_set_agent_states( if (seconds_stopped && !(seconds_stopped[k] >= 0.0f)) { return -1; } + if (steering && !isfinite(steering[k])) { + return -1; + } Agent *agent = &env->agents[agent_idx]; agent->sim_x = x[k] - env->world_mean_x; agent->sim_y = y[k] - env->world_mean_y; @@ -4072,11 +4076,17 @@ int c_set_agent_states( // called, so "the agent's previous state" at this point is a throwaway dummy-action rollout, // not the true previous external state. agent->yaw_rate = yaw_rate[k]; - float speed_for_steering = fmaxf(fabsf(agent->sim_speed_signed), 1.0f); - float steering = atanf(agent->yaw_rate * agent->wheelbase / speed_for_steering); - agent->steering_angle = clip(steering, -STEERING_LIMIT, STEERING_LIMIT); + if (steering) { + agent->steering_angle = clip(steering[k], -STEERING_LIMIT, STEERING_LIMIT); + agent->accel_lat + = agent->sim_speed_signed * agent->sim_speed_signed * tanf(agent->steering_angle) / agent->wheelbase; + } else { + float speed_for_steering = fmaxf(fabsf(agent->sim_speed_signed), 1.0f); + float derived_steering = atanf(agent->yaw_rate * agent->wheelbase / speed_for_steering); + agent->steering_angle = clip(derived_steering, -STEERING_LIMIT, STEERING_LIMIT); + agent->accel_lat = agent->sim_speed_signed * agent->yaw_rate; + } agent->accel_long = accel_long[k]; - agent->accel_lat = agent->sim_speed_signed * agent->yaw_rate; refresh_lane_association(env, agent); // current_lane_idx / lane-dist / lane-angle for the new pose // NULL keeps c_step's own accumulation (co-sim default); an array injects stopped-time as state. diff --git a/pufferlib/ocean/drive/drive.py b/pufferlib/ocean/drive/drive.py index 7593e1aa78..4540c865a8 100644 --- a/pufferlib/ocean/drive/drive.py +++ b/pufferlib/ocean/drive/drive.py @@ -832,7 +832,9 @@ def get_global_agent_state(self, include_static=False): return states - def set_agent_states(self, idx, x, y, z, heading, vx, vy, yaw_rate, accel_long, seconds_stopped=None): + def set_agent_states( + self, idx, x, y, z, heading, vx, vy, yaw_rate, accel_long, seconds_stopped=None, steering=None + ): """Co-sim: overwrite the sim state of agents at global indices `idx` (e.g. CARLA background) with world-frame pose/velocity. The C side subtracts world_mean, recaches heading trig and recomputes speed. @@ -858,6 +860,7 @@ def set_agent_states(self, idx, x, y, z, heading, vx, vy, yaw_rate, accel_long, np.ascontiguousarray(yaw_rate, dtype=np.float32), np.ascontiguousarray(accel_long, dtype=np.float32), None if seconds_stopped is None else np.ascontiguousarray(seconds_stopped, dtype=np.float32), + None if steering is None else np.ascontiguousarray(steering, dtype=np.float32), ) # ── Co-simulation external-state setters ───────────────────────────────────── diff --git a/pufferlib/ocean/env_binding.h b/pufferlib/ocean/env_binding.h index cedb4c1215..e740b69f29 100644 --- a/pufferlib/ocean/env_binding.h +++ b/pufferlib/ocean/env_binding.h @@ -987,8 +987,8 @@ static PyObject *vec_get_global_agent_state(PyObject *self, PyObject *args) { // ── Co-simulation external-state setters (mirror vec_get_global_agent_state) ── // Co-sim runs a single env (num_envs == 1); these operate on vec->envs[0]. static PyObject *vec_set_agent_states(PyObject *self, PyObject *args) { - if (PyTuple_Size(args) != 11) { - PyErr_SetString(PyExc_TypeError, "vec_set_agent_states requires 11 arguments"); + if (PyTuple_Size(args) != 12) { + PyErr_SetString(PyExc_TypeError, "vec_set_agent_states requires 12 arguments"); return NULL; } VecEnv *vec = unpack_vecenv(args); @@ -1005,6 +1005,7 @@ static PyObject *vec_set_agent_states(PyObject *self, PyObject *args) { PyObject *yaw_rate_arr = PyTuple_GetItem(args, 8); PyObject *accel_long_arr = PyTuple_GetItem(args, 9); PyObject *seconds_stopped_arr = PyTuple_GetItem(args, 10); // None keeps c_step's accumulation + PyObject *steering_arr = PyTuple_GetItem(args, 11); // None derives steering from yaw_rate if (!PyArray_Check(idx_arr) || !PyArray_Check(x_arr) || !PyArray_Check(y_arr) || !PyArray_Check(z_arr) || !PyArray_Check(heading_arr) || !PyArray_Check(vx_arr) || !PyArray_Check(vy_arr) || !PyArray_Check(yaw_rate_arr) || !PyArray_Check(accel_long_arr)) { @@ -1015,6 +1016,10 @@ static PyObject *vec_set_agent_states(PyObject *self, PyObject *args) { PyErr_SetString(PyExc_TypeError, "seconds_stopped must be a NumPy array or None"); return NULL; } + if (steering_arr != Py_None && !PyArray_Check(steering_arr)) { + PyErr_SetString(PyExc_TypeError, "steering must be a NumPy array or None"); + return NULL; + } int *idx = (int *) PyArray_DATA((PyArrayObject *) idx_arr); float *x = (float *) PyArray_DATA((PyArrayObject *) x_arr); float *y = (float *) PyArray_DATA((PyArrayObject *) y_arr); @@ -1026,10 +1031,11 @@ static PyObject *vec_set_agent_states(PyObject *self, PyObject *args) { float *accel_long = (float *) PyArray_DATA((PyArrayObject *) accel_long_arr); float *seconds_stopped = seconds_stopped_arr == Py_None ? NULL : (float *) PyArray_DATA((PyArrayObject *) seconds_stopped_arr); + float *steering = steering_arr == Py_None ? NULL : (float *) PyArray_DATA((PyArrayObject *) steering_arr); int count = (int) PyArray_SIZE((PyArrayObject *) idx_arr); - PyObject *value_arrs[9] - = {x_arr, y_arr, z_arr, heading_arr, vx_arr, vy_arr, yaw_rate_arr, accel_long_arr, seconds_stopped_arr}; - for (int k = 0; k < 9; k++) { + PyObject *value_arrs[10] = { + x_arr, y_arr, z_arr, heading_arr, vx_arr, vy_arr, yaw_rate_arr, accel_long_arr, seconds_stopped_arr, steering_arr}; + for (int k = 0; k < 10; k++) { if (value_arrs[k] == Py_None) { continue; } @@ -1050,11 +1056,13 @@ static PyObject *vec_set_agent_states(PyObject *self, PyObject *args) { vy, yaw_rate, accel_long, - seconds_stopped) + seconds_stopped, + steering) != 0) { PyErr_SetString( PyExc_ValueError, - "vec_set_agent_states: agent index out of range, non-finite state or negative seconds_stopped"); + "vec_set_agent_states: agent index out of range, non-finite state, negative seconds_stopped or " + "non-finite steering"); return NULL; } Py_RETURN_NONE; diff --git a/pufferlib/ocean/fused_slot_encoder.py b/pufferlib/ocean/fused_slot_encoder.py index c12d26dfd2..755e1aea30 100644 --- a/pufferlib/ocean/fused_slot_encoder.py +++ b/pufferlib/ocean/fused_slot_encoder.py @@ -29,8 +29,9 @@ class KernelConfig: # Timed on an RTX 5090 (GB202, compute capability 12.0); the RTX PRO 6000 Blackwell is the same chip. GB202_CONFIG = KernelConfig(8, 4, 32, 4, 2, 64, 64, 8, 2, 32, 4) -# Untimed placeholders: replace with the KernelConfig printed by scripts/tune_fused_slot_encoder.py on that GPU. -H100_CONFIG = GB202_CONFIG +# Timed on an H100 80GB HBM3 at 131072 rows (scripts/tune_fused_slot_encoder.py): 106 -> 79 ms over all encoders. +H100_CONFIG = KernelConfig(8, 4, 64, 4, 2, 32, 64, 4, 3, 32, 4) +# Untimed placeholder: replace with the KernelConfig printed by scripts/tune_fused_slot_encoder.py on that GPU. B200_CONFIG = GB202_CONFIG B300_CONFIG = B200_CONFIG # Blackwell Ultra shares the B200 SM design CONFIGS_BY_CAPABILITY = {(12, 0): GB202_CONFIG, (9, 0): H100_CONFIG, (10, 0): B200_CONFIG, (10, 3): B300_CONFIG} diff --git a/scripts/kesai/15_tune_fused_slot_encoder_slurm.sh b/scripts/kesai/15_tune_fused_slot_encoder_slurm.sh index aaea65c0ab..1f17515e91 100755 --- a/scripts/kesai/15_tune_fused_slot_encoder_slurm.sh +++ b/scripts/kesai/15_tune_fused_slot_encoder_slurm.sh @@ -24,10 +24,20 @@ source .venv/bin/activate bash scripts/kesai/build_ext_if_changed.sh /home/bjaeger/PufferDrive || exit 1 RESULT_FILE=/home/bjaeger/PufferDrive/experiments/logs/tune_fused_${SLURM_JOB_ID}_result.txt -python scripts/tune_fused_slot_encoder.py --batch 131072 --shapes all 2>&1 | tee ${RESULT_FILE}.full +CONFIG_JSON=/home/bjaeger/PufferDrive/experiments/logs/tune_fused_${SLURM_JOB_ID}_config.json +python scripts/tune_fused_slot_encoder.py --batch 131072 --shapes all --write-config ${CONFIG_JSON} 2>&1 | tee ${RESULT_FILE}.full # The pasteable block is the tail of the full log. sed -n '/=== fused_slot_encoder tuning result/,/=== end of tuning result ===/p' ${RESULT_FILE}.full > ${RESULT_FILE} +# Real policy update + rollout step on this GPU: compiled reference vs fused kernels with the config just tuned. +echo "=== step timing (paste this block back too) ===" | tee -a ${RESULT_FILE} +python scripts/profile_policy_step.py --rows 131072 2>&1 | grep "^RESULT" | tee -a ${RESULT_FILE} +python scripts/profile_policy_step.py --rows 131072 --fused --kernel-config ${CONFIG_JSON} 2>&1 | grep "^RESULT" | tee -a ${RESULT_FILE} +awk '/^RESULT fused=0/ {for (i=1;i<=NF;i++) if ($i ~ /^update_ms=/) {split($i,a,"="); base=a[2]}} + /^RESULT fused=1/ {for (i=1;i<=NF;i++) if ($i ~ /^update_ms=/) {split($i,a,"="); fused=a[2]}} + END {if (base && fused) printf "update speedup fused vs reference: %.2fx\n", base / fused}' ${RESULT_FILE} | tee -a ${RESULT_FILE} +echo "=== end of step timing ===" | tee -a ${RESULT_FILE} + echo echo "Result block (also in ${RESULT_FILE}):" cat ${RESULT_FILE} diff --git a/scripts/profile_policy_step.py b/scripts/profile_policy_step.py index cc3385e3ac..a8c32f42c8 100644 --- a/scripts/profile_policy_step.py +++ b/scripts/profile_policy_step.py @@ -11,6 +11,7 @@ import argparse import copy import csv +import json import os import sys @@ -25,6 +26,8 @@ CALIBRATE_N = 8192 CALIBRATE_FLOPS = 2.0 * CALIBRATE_N**3 PEAK_BF16_TFLOPS = 209.5 +# dense bf16 tensor peak with fp32 accumulation, by compute capability +PEAK_BF16_TFLOPS_BY_CAPABILITY = {(12, 0): 209.5, (9, 0): 989.0, (10, 0): 2250.0, (10, 3): 2250.0} ROLLOUT_ROWS = 16384 ENV_AGENTS = 512 ROLLOUT_STEPS = 40 @@ -66,6 +69,22 @@ def build_policy_and_obs(rows, fused): return policy, obs.repeat(repeats, 1) +def fse_config_name(): + import pufferlib.ocean.fused_slot_encoder as fse + + if fse.CONFIG_OVERRIDE is not None: + return "override" + return "table" + + +def apply_kernel_config(path): + import pufferlib.ocean.fused_slot_encoder as fse + + with open(path) as handle: + fse.CONFIG_OVERRIDE = fse.KernelConfig(**json.load(handle)) + print(f"kernel config override: {fse.CONFIG_OVERRIDE}") + + def run_phases(rows, fused): import pufferlib.pytorch @@ -109,6 +128,14 @@ def rollout_step(): torch.cuda.synchronize() wall_ms[name] = start.elapsed_time(end) print(f"unprofiled wall time: update {wall_ms['update']:.2f} ms, rollout {wall_ms['rollout']:.2f} ms") + peak = PEAK_BF16_TFLOPS_BY_CAPABILITY.get(torch.cuda.get_device_capability(0)) + update_rate = rows * TRAIN_FLOP_PER_TRANSITION / (wall_ms["update"] * 1e-3) / 1e12 + rollout_rate = ROLLOUT_ROWS * ROLLOUT_FLOP_PER_TRANSITION / (wall_ms["rollout"] * 1e-3) / 1e12 + mfu = f" update_mfu={100 * update_rate / peak:.1f}% rollout_mfu={100 * rollout_rate / peak:.1f}%" if peak else "" + print( + f"RESULT fused={int(fused)} rows={rows} update_ms={wall_ms['update']:.2f} rollout_ms={wall_ms['rollout']:.2f}" + f" update_tflops={update_rate:.1f} rollout_tflops={rollout_rate:.1f}{mfu} config={fse_config_name()}" + ) a = torch.randn(CALIBRATE_N, CALIBRATE_N, device="cuda", dtype=torch.bfloat16) b = torch.randn_like(a) a @ b @@ -199,11 +226,14 @@ def main(): parser.add_argument("--rows", type=int, default=65536, help="update minibatch rows") parser.add_argument("--fused", action="store_true", help="enable policy.fused_slot_encoder") parser.add_argument("--parse", help="ncu raw-page CSV to summarize instead of running") + parser.add_argument("--kernel-config", help="JSON KernelConfig (from tune_fused_slot_encoder.py --write-config)") args = parser.parse_args() if args.parse: parse(args.parse, args.rows) - else: - run_phases(args.rows, args.fused) + return + if args.kernel_config: + apply_kernel_config(args.kernel_config) + run_phases(args.rows, args.fused) if __name__ == "__main__": diff --git a/scripts/tune_fused_slot_encoder.py b/scripts/tune_fused_slot_encoder.py index da34b7875d..a4130d955c 100644 --- a/scripts/tune_fused_slot_encoder.py +++ b/scripts/tune_fused_slot_encoder.py @@ -7,7 +7,9 @@ """ import argparse +import dataclasses import itertools +import json import time import torch @@ -167,6 +169,7 @@ def main(): parser.add_argument("--shapes", default="all", help="comma-separated encoder names or 'all'") parser.add_argument("--batch", type=int, default=131072, help="transitions per minibatch") parser.add_argument("--quick", action="store_true", help="tiny grids, for checking that the script runs") + parser.add_argument("--write-config", help="write the recommended KernelConfig as JSON to this path") args = parser.parse_args() names = list(ENCODER_SHAPES) if args.shapes == "all" else args.shapes.split(",") grids = {"fwd": FWD_GRID, "bwd": BWD_GRID, "pool": POOL_GRID} @@ -200,6 +203,9 @@ def main(): print(f"all parts, all shapes: GB202_CONFIG {baseline_combined:.3f} ms -> recommended {combined_total:.3f} ms") print(f"RECOMMENDED for capability {capability}: {recommended}") print("=== end of tuning result ===") + if args.write_config: + with open(args.write_config, "w") as handle: + json.dump(dataclasses.asdict(recommended), handle) if __name__ == "__main__": diff --git a/tests/drive/test_drive_cosim_setters.c b/tests/drive/test_drive_cosim_setters.c index 16986abce0..cd64cfa8a2 100644 --- a/tests/drive/test_drive_cosim_setters.c +++ b/tests/drive/test_drive_cosim_setters.c @@ -141,7 +141,7 @@ static int test_set_agent_states_teleport_resets_prev_pose(void) { int idx[1] = {0}; float x[1] = {10.0f}, y[1] = {20.0f}, z[1] = {0.0f}, h[1] = {0.5f}; float vx[1] = {1.0f}, vy[1] = {0.0f}, yr[1] = {0.0f}, al[1] = {0.0f}; - EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, NULL), 0); + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, NULL, NULL), 0); EXPECT_NEAR(agent.prev_x, agent.sim_x, 1e-6f); EXPECT_NEAR(agent.prev_y, agent.sim_y, 1e-6f); @@ -163,19 +163,51 @@ static int test_set_agent_states_seconds_stopped_injects_or_preserves(void) { float x[1] = {1.0f}, y[1] = {2.0f}, z[1] = {0.0f}, h[1] = {0.0f}; float vx[1] = {0.0f}, vy[1] = {0.0f}, yr[1] = {0.0f}, al[1] = {0.0f}; - EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, NULL), 0); + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, NULL, NULL), 0); EXPECT_NEAR(agent.seconds_stopped, 12.5f, 1e-6f); float seconds_stopped[1] = {3.0f}; - EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, seconds_stopped), 0); + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, seconds_stopped, NULL), 0); EXPECT_NEAR(agent.seconds_stopped, 3.0f, 1e-6f); float negative_seconds_stopped[1] = {-1.0f}; - EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, negative_seconds_stopped), -1); + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, vx, vy, yr, al, negative_seconds_stopped, NULL), -1); EXPECT_NEAR(agent.seconds_stopped, 3.0f, 1e-6f); return 0; } +static int test_set_agent_states_steering_is_state_below_1mps(void) { + // A co-sim that tracks steering as state can hand it over exactly: the angle is kept and the + // lateral acceleration follows from it as in c_step (v^2 tan(steering) / wheelbase). Without it, + // steering is derived from yaw_rate with the speed floored at 1 m/s, so a stopped car that still + // holds its wheels turned reads 0 -- the case the steering input exists for. + Drive env = {0}; + Agent agent = drive_test_agent(0.0f, 0.0f, 0.0f); + env.agents = &agent; + env.num_total_agents = 1; + + int idx[1] = {0}; + float x[1] = {1.0f}, y[1] = {2.0f}, z[1] = {0.0f}, h[1] = {0.0f}; + float vy[1] = {0.0f}, yr[1] = {0.0f}, al[1] = {0.0f}; + + float stopped[1] = {0.0f}, steering[1] = {0.14f}; + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, stopped, vy, yr, al, NULL, steering), 0); + EXPECT_NEAR(agent.steering_angle, 0.14f, 1e-6f); + EXPECT_NEAR(agent.accel_lat, 0.0f, 1e-6f); + + float crawling[1] = {0.5f}; + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, crawling, vy, yr, al, NULL, steering), 0); + EXPECT_NEAR(agent.steering_angle, 0.14f, 1e-6f); + EXPECT_NEAR(agent.accel_lat, 0.25f * tanf(0.14f) / agent.wheelbase, 1e-6f); + + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, stopped, vy, yr, al, NULL, NULL), 0); + EXPECT_NEAR(agent.steering_angle, 0.0f, 1e-6f); + + float not_finite[1] = {NAN}; + EXPECT_EQ_INT(c_set_agent_states(&env, 1, idx, x, y, z, h, stopped, vy, yr, al, NULL, not_finite), -1); + return 0; +} + // --------------------------------------------------------------------------- // c_set_agent_goals // --------------------------------------------------------------------------- @@ -337,6 +369,7 @@ int main(void) { RUN_TEST(test_set_stop_signs_rejects_bad_input_untouched); RUN_TEST(test_set_agent_states_teleport_resets_prev_pose); RUN_TEST(test_set_agent_states_seconds_stopped_injects_or_preserves); + RUN_TEST(test_set_agent_states_steering_is_state_below_1mps); RUN_TEST(test_set_agent_goals_sets_positions_lane_and_count); RUN_TEST(test_set_agent_goals_rejects_more_than_max_goals); RUN_TEST(test_set_agent_goals_rejects_out_of_range_agent_idx); From 76579bd4b00f83c828ce92f6f5cb6051785bcf59 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 14:06:59 +0200 Subject: [PATCH 008/123] rerun nuPlan --- pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh | 2 ++ scripts/kesai/9_nuPlan.sh | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh index bc21e10f92..ea56fa2d24 100755 --- a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh +++ b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh @@ -41,6 +41,7 @@ # policy's observation (training never spawns below 0.8 m); unset/0 = off. # OBS_SLOTS_PARTNERS_N shadow-env partner obs slots (unset = planner yaml, 40). # EVAL_PERCEIVED_SIZE_MARGIN_M [m per side] ego box inflation in the policy's obs (unset = arch.py, 0.2). +# REWARD_OVERSPEED overspeed conditioning alpha fed to the policy (unset = arch.py, 0.005). # COSIM_OBS_HTML=all|failures|infractions|0 interactive observation replay (exact policy # input/outputs per step, pufferlib.viz HTML) -> $GROUP/obs_html. all: every # scenario. failures (default): only scenarios scoring below @@ -88,6 +89,7 @@ EXTRA_ARGS+=("planner.pufferdrive_planner.city_bin_dir=$CITY_BIN_DIR") [ -n "${PEDESTRIAN_MIN_SIZE_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_min_size_m=$PEDESTRIAN_MIN_SIZE_M") [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.obs_slots_partners_n=$OBS_SLOTS_PARTNERS_N") [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.eval_perceived_size_margin_m=$EVAL_PERCEIVED_SIZE_MARGIN_M") +[ -n "${REWARD_OVERSPEED:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.reward_overspeed=$REWARD_OVERSPEED") [ -n "${WORKER:-}" ] && EXTRA_ARGS+=("worker=$WORKER") [ -n "${THREADS_PER_NODE:-}" ] && EXTRA_ARGS+=("worker.threads_per_node=$THREADS_PER_NODE") [ -n "${LIMIT_TOTAL_SCENARIOS:-}" ] && EXTRA_ARGS+=("scenario_filter.limit_total_scenarios=$LIMIT_TOTAL_SCENARIOS") diff --git a/scripts/kesai/9_nuPlan.sh b/scripts/kesai/9_nuPlan.sh index 53cc904afd..a72145586f 100644 --- a/scripts/kesai/9_nuPlan.sh +++ b/scripts/kesai/9_nuPlan.sh @@ -14,7 +14,7 @@ set -u export PD=/home/bjaeger/PufferDrive export PY=$(conda info --base)/envs/carl_nuplan/bin/python # Overridable env: RUN_DIR (3_train_64GPU_multinode_slurm.sh submits this job with RUN_DIR=), CHALLENGES -RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0041_1000} +RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} # the planner finds config.yaml next to final_model.pt (or one level above a models/*.pt) export CKPT=$RUN_DIR/final_model.pt [ -f "$CKPT" ] || { echo "missing $CKPT"; exit 1; } @@ -56,6 +56,8 @@ export PEDESTRIAN_MIN_SIZE_M=0.8 # reads as 2.7 x 5.6 m). Unset = planner yaml / arch.py defaults (40 / 0.2). export OBS_SLOTS_PARTNERS_N=40 export EVAL_PERCEIVED_SIZE_MARGIN_M=0.2 +# overspeed conditioning alpha (unset = arch.py 0.005, the cheapest 5% of the U(0, 0.1) training range; 0.05 keeps the ego at the limit) +export REWARD_OVERSPEED=0.05 ABLATION_TAG="" [ "$SLIDING_GOAL_WINDOW" = "true" ] && ABLATION_TAG="${ABLATION_TAG}_slide" [ "$STARTUP_ACCEL_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jacc${STARTUP_ACCEL_JERK_CAP}" @@ -63,6 +65,7 @@ ABLATION_TAG="" [ "$PEDESTRIAN_MIN_SIZE_M" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_ped${PEDESTRIAN_MIN_SIZE_M}m" [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && ABLATION_TAG="${ABLATION_TAG}_slots${OBS_SLOTS_PARTNERS_N}" [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && ABLATION_TAG="${ABLATION_TAG}_margin${EVAL_PERCEIVED_SIZE_MARGIN_M}" +[ -n "${REWARD_OVERSPEED:-}" ] && ABLATION_TAG="${ABLATION_TAG}_overspeed${REWARD_OVERSPEED}" # results live in the model's own eval folder, next to the PufferDrive benchmark evals export GROUP=$RUN_DIR/eval/nuplan_val14${CHALLENGE_TAG}_${GOAL_SOURCE}${ABLATION_TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} From f2dbb2dffb30460177e9eb47395259ed65ac5597 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 14:12:56 +0200 Subject: [PATCH 009/123] Update H100 kernel tuning --- pufferlib/ocean/fused_slot_encoder.py | 253 +++++++++++++------- scripts/tune_fused_slot_encoder.py | 14 +- tests/unit_tests/test_fused_slot_encoder.py | 14 +- 3 files changed, 182 insertions(+), 99 deletions(-) diff --git a/pufferlib/ocean/fused_slot_encoder.py b/pufferlib/ocean/fused_slot_encoder.py index 755e1aea30..b5edeed0e0 100644 --- a/pufferlib/ocean/fused_slot_encoder.py +++ b/pufferlib/ocean/fused_slot_encoder.py @@ -2,7 +2,8 @@ Reproduces the bf16-autocast numerics of DriveBackbone._encode_and_pool (bf16 matmul inputs and outputs, fp32 accumulation, fp32 LayerNorm, gradient shared evenly between tied maxima) without materializing the 256-wide -per-slot activations between ops. +per-slot activations between ops. The forward walks the slots one at a time for a block of batch rows, so the +max-pool is an elementwise running max with no padded slots. """ from dataclasses import dataclass @@ -15,7 +16,6 @@ @dataclass(frozen=True) class KernelConfig: fwd_block_batch: int - fwd_block_slots: int fwd_block_k: int fwd_num_warps: int fwd_num_stages: int @@ -28,9 +28,10 @@ class KernelConfig: # Timed on an RTX 5090 (GB202, compute capability 12.0); the RTX PRO 6000 Blackwell is the same chip. -GB202_CONFIG = KernelConfig(8, 4, 32, 4, 2, 64, 64, 8, 2, 32, 4) -# Timed on an H100 80GB HBM3 at 131072 rows (scripts/tune_fused_slot_encoder.py): 106 -> 79 ms over all encoders. -H100_CONFIG = KernelConfig(8, 4, 64, 4, 2, 32, 64, 4, 3, 32, 4) +GB202_CONFIG = KernelConfig(32, 32, 4, 2, 64, 64, 8, 2, 32, 4) +# Backward timed on an H100 80GB HBM3 at 131072 rows; forward carried over from the slot-chunk kernel, retune with +# scripts/kesai/15_tune_fused_slot_encoder_slurm.sh. +H100_CONFIG = KernelConfig(32, 64, 4, 2, 32, 64, 4, 3, 32, 4) # Untimed placeholder: replace with the KernelConfig printed by scripts/tune_fused_slot_encoder.py on that GPU. B200_CONFIG = GB202_CONFIG B300_CONFIG = B200_CONFIG # Blackwell Ultra shares the B200 SM design @@ -55,47 +56,64 @@ def _bf16_round(x): @triton.jit -def _encode_slot_chunk( +def _pre_norm_chunk(x_bf16, k_offs, w1_ptr, b1_ptr, F_PAD: tl.constexpr): + f_offs = tl.arange(0, F_PAD) + w1t_chunk = tl.load(w1_ptr + k_offs[None, :] * F_PAD + f_offs[:, None]) + b1_chunk = tl.load(b1_ptr + k_offs) + return _bf16_round(tl.dot(x_bf16, w1t_chunk) + b1_chunk[None, :]) + + +@triton.jit +def _row_stats_full(x_bf16, w1_ptr, b1_ptr, D: tl.constexpr, F_PAD: tl.constexpr): + pre_norm = _pre_norm_chunk(x_bf16, tl.arange(0, D), w1_ptr, b1_ptr, F_PAD) + mean = tl.sum(pre_norm, axis=1) / D + centered = pre_norm - mean[:, None] + rstd = tl.rsqrt(tl.sum(centered * centered, axis=1) / D + LAYER_NORM_EPS) + return mean, rstd + + +@triton.jit +def _normed_relu_chunk(x_bf16, mean, rstd, k_offs, w1_ptr, b1_ptr, gamma_ptr, beta_ptr, F_PAD: tl.constexpr): + gamma_chunk = tl.load(gamma_ptr + k_offs) + beta_chunk = tl.load(beta_ptr + k_offs) + pre_norm_chunk = _pre_norm_chunk(x_bf16, k_offs, w1_ptr, b1_ptr, F_PAD) + normed_chunk = (pre_norm_chunk - mean[:, None]) * rstd[:, None] * gamma_chunk[None, :] + beta_chunk[None, :] + return tl.maximum(normed_chunk, 0.0).to(tl.bfloat16) + + +@triton.jit +def _encode_columns( x_bf16, + mean, + rstd, row_mask, flat_rows, + n_offs, w1_ptr, b1_ptr, gamma_ptr, beta_ptr, w2t_ptr, - b2, + b2_ptr, normed_relu_ptr, D: tl.constexpr, F_PAD: tl.constexpr, BLOCK_K: tl.constexpr, - STORE_ACTS: tl.constexpr, + BLOCK_N: tl.constexpr, + STORE_NORMED: tl.constexpr, ): - # full-row stats first, then columns recomputed per chunk: register tensors cannot be sliced - d_offs = tl.arange(0, D) - f_offs = tl.arange(0, F_PAD) - w1t = tl.load(w1_ptr + d_offs[None, :] * F_PAD + f_offs[:, None]) - b1 = tl.load(b1_ptr + d_offs) - pre_norm = _bf16_round(tl.dot(x_bf16, w1t) + b1[None, :]) - mean = tl.sum(pre_norm, axis=1) / D - centered = pre_norm - mean[:, None] - rstd = tl.rsqrt(tl.sum(centered * centered, axis=1) / D + LAYER_NORM_EPS) - encoded = tl.zeros((x_bf16.shape[0], D), tl.float32) + # columns recomputed per K chunk: register tensors cannot be sliced + encoded = tl.zeros((x_bf16.shape[0], BLOCK_N), tl.float32) for k0 in range(0, D, BLOCK_K): k_offs = k0 + tl.arange(0, BLOCK_K) - w1t_chunk = tl.load(w1_ptr + k_offs[None, :] * F_PAD + f_offs[:, None]) - b1_chunk = tl.load(b1_ptr + k_offs) - gamma_chunk = tl.load(gamma_ptr + k_offs) - beta_chunk = tl.load(beta_ptr + k_offs) - pre_norm_chunk = _bf16_round(tl.dot(x_bf16, w1t_chunk) + b1_chunk[None, :]) - normed_chunk = (pre_norm_chunk - mean[:, None]) * rstd[:, None] * gamma_chunk[None, :] + beta_chunk[None, :] - normed_relu_chunk = tl.maximum(normed_chunk, 0.0).to(tl.bfloat16) - if STORE_ACTS: + normed_relu_chunk = _normed_relu_chunk(x_bf16, mean, rstd, k_offs, w1_ptr, b1_ptr, gamma_ptr, beta_ptr, F_PAD) + if STORE_NORMED: tl.store( normed_relu_ptr + flat_rows[:, None] * D + k_offs[None, :], normed_relu_chunk, mask=row_mask[:, None] ) - w2t_chunk = tl.load(w2t_ptr + k_offs[:, None] * D + d_offs[None, :]) + w2t_chunk = tl.load(w2t_ptr + k_offs[:, None] * D + n_offs[None, :]) encoded = tl.dot(normed_relu_chunk, w2t_chunk, encoded) + b2 = tl.load(b2_ptr + n_offs) return _bf16_round(encoded + b2[None, :]) @@ -120,64 +138,59 @@ def fused_slot_encoder_fwd_kernel( stride_xs, stride_xf, BLOCK_B: tl.constexpr, - BLOCK_S: tl.constexpr, D: tl.constexpr, F_PAD: tl.constexpr, BLOCK_K: tl.constexpr, STORE_ACTS: tl.constexpr, ): - ROWS: tl.constexpr = BLOCK_B * BLOCK_S pid = tl.program_id(0) batch_offs = pid * BLOCK_B + tl.arange(0, BLOCK_B) batch_mask = batch_offs < B counts = tl.load(counts_ptr + batch_offs, mask=batch_mask, other=0) d_offs = tl.arange(0, D) f_offs = tl.arange(0, F_PAD) - b2 = tl.load(b2_ptr + d_offs) - row_offs = tl.arange(0, ROWS) - row_slot = row_offs % BLOCK_S - row_batch = pid * BLOCK_B + row_offs // BLOCK_S - row_counts = tl.load(counts_ptr + row_batch, mask=row_batch < B, other=0) running_max = tl.full((BLOCK_B, D), float("-inf"), tl.float32) tie_count = tl.zeros((BLOCK_B, D), tl.int32) - for slot0 in range(0, S, BLOCK_S): - slots = slot0 + row_slot - row_mask = (row_batch < B) & (slots < S) + for slot in range(S): x = tl.load( - x_ptr + row_batch[:, None] * stride_xb + slots[:, None] * stride_xs + f_offs[None, :] * stride_xf, - mask=row_mask[:, None] & (f_offs[None, :] < F), + x_ptr + batch_offs[:, None] * stride_xb + slot * stride_xs + f_offs[None, :] * stride_xf, + mask=batch_mask[:, None] & (f_offs[None, :] < F), other=0.0, ) - flat_rows = row_batch * S + slots - encoded = _encode_slot_chunk( - x.to(tl.bfloat16), - row_mask, + x_bf16 = x.to(tl.bfloat16) + flat_rows = batch_offs * S + slot + mean, rstd = _row_stats_full(x_bf16, w1_ptr, b1_ptr, D, F_PAD) + encoded = _encode_columns( + x_bf16, + mean, + rstd, + batch_mask, flat_rows, + d_offs, w1_ptr, b1_ptr, gamma_ptr, beta_ptr, w2t_ptr, - b2, + b2_ptr, normed_relu_ptr, D, F_PAD, BLOCK_K, + D, STORE_ACTS, ) if STORE_ACTS: tl.store( - encoded_ptr + flat_rows[:, None] * D + d_offs[None, :], encoded.to(tl.bfloat16), mask=row_mask[:, None] + encoded_ptr + flat_rows[:, None] * D + d_offs[None, :], + encoded.to(tl.bfloat16), + mask=batch_mask[:, None], ) - valid = row_mask & (slots < row_counts) - masked = tl.reshape(tl.where(valid[:, None], encoded, float("-inf")), (BLOCK_B, BLOCK_S, D)) - valid3 = tl.reshape(valid, (BLOCK_B, BLOCK_S)) - chunk_max = tl.max(masked, axis=1) - chunk_ties = tl.sum(((masked == chunk_max[:, None, :]) & valid3[:, :, None]).to(tl.int32), axis=1) - greater = chunk_max > running_max - equal = chunk_max == running_max - tie_count = tl.where(greater, chunk_ties, tl.where(equal, tie_count + chunk_ties, tie_count)) - running_max = tl.where(greater, chunk_max, running_max) + valid = (batch_mask & (slot < counts))[:, None] + greater = valid & (encoded > running_max) + equal = valid & (encoded == running_max) + tie_count = tl.where(greater, 1, tl.where(equal, tie_count + 1, tie_count)) + running_max = tl.where(greater, encoded, running_max) pooled = tl.where((counts > 0)[:, None], running_max, 0.0) out_offs = batch_offs[:, None] * D + d_offs[None, :] tl.store(pooled_ptr + out_offs, pooled.to(tl.bfloat16), mask=batch_mask[:, None]) @@ -224,6 +237,87 @@ def fused_slot_pool_bwd_kernel( tl.store(grad_b2_partial_ptr + pid * D + d_offs, grad_b2) +@triton.jit +def _grad_normed_relu_columns( + grad_encoded_ptr, + rows, + row_mask, + n_offs, + w2_ptr, + D: tl.constexpr, + BLOCK_R: tl.constexpr, + BLOCK_K: tl.constexpr, + BLOCK_N: tl.constexpr, +): + grad_normed_relu = tl.zeros((BLOCK_R, BLOCK_N), tl.float32) + for k0 in range(0, D, BLOCK_K): + k_offs = k0 + tl.arange(0, BLOCK_K) + grad_chunk = tl.load(grad_encoded_ptr + rows[:, None] * D + k_offs[None, :], mask=row_mask[:, None], other=0.0) + w2_chunk = tl.load(w2_ptr + k_offs[:, None] * D + n_offs[None, :]) + grad_normed_relu = tl.dot(grad_chunk, w2_chunk, grad_normed_relu) + return _bf16_round(grad_normed_relu) + + +@triton.jit +def _layer_norm_grad_columns( + x_bf16, mean, rstd, grad_normed_relu, n_offs, w1_ptr, b1_ptr, gamma_ptr, beta_ptr, F_PAD: tl.constexpr +): + gamma_chunk = tl.load(gamma_ptr + n_offs) + beta_chunk = tl.load(beta_ptr + n_offs) + pre_norm = _pre_norm_chunk(x_bf16, n_offs, w1_ptr, b1_ptr, F_PAD) + normalized = (pre_norm - mean[:, None]) * rstd[:, None] + normed = normalized * gamma_chunk[None, :] + beta_chunk[None, :] + grad_normed = tl.where(normed > 0.0, grad_normed_relu, 0.0) + return normalized, grad_normed, grad_normed * gamma_chunk[None, :] + + +@triton.jit +def _backward_rows_full( + x_bf16, + rows, + row_mask, + grad_encoded_ptr, + w1t, + b1, + gamma, + beta, + w2_ptr, + grad_pre_norm_ptr, + grad_gamma, + grad_beta, + grad_b1, + D: tl.constexpr, + BLOCK_R: tl.constexpr, + BLOCK_K: tl.constexpr, +): + d_offs = tl.arange(0, D) + grad_normed_relu = _grad_normed_relu_columns( + grad_encoded_ptr, rows, row_mask, d_offs, w2_ptr, D, BLOCK_R, BLOCK_K, D + ) + pre_norm = _bf16_round(tl.dot(x_bf16, w1t) + b1[None, :]) + mean = tl.sum(pre_norm, axis=1) / D + centered = pre_norm - mean[:, None] + rstd = tl.rsqrt(tl.sum(centered * centered, axis=1) / D + LAYER_NORM_EPS) + normalized = centered * rstd[:, None] + normed = normalized * gamma[None, :] + beta[None, :] + grad_normed = tl.where(normed > 0.0, grad_normed_relu, 0.0) + grad_scaled = grad_normed * gamma[None, :] + mean_grad = tl.sum(grad_scaled, axis=1) / D + mean_grad_normalized = tl.sum(grad_scaled * normalized, axis=1) / D + grad_pre_norm = _bf16_round( + rstd[:, None] * (grad_scaled - mean_grad[:, None] - normalized * mean_grad_normalized[:, None]) + ) + tl.store( + grad_pre_norm_ptr + rows[:, None] * D + d_offs[None, :], + grad_pre_norm.to(tl.bfloat16), + mask=row_mask[:, None], + ) + grad_gamma += tl.sum(grad_normed * normalized, axis=0) + grad_beta += tl.sum(grad_normed, axis=0) + grad_b1 += tl.sum(grad_pre_norm, axis=0) + return grad_gamma, grad_beta, grad_b1 + + @triton.jit def fused_slot_encoder_bwd_kernel( x_ptr, @@ -264,41 +358,29 @@ def fused_slot_encoder_bwd_kernel( row_mask = rows < B * S batch = rows // S slots = rows % S - grad_normed_relu = tl.zeros((BLOCK_R, D), tl.float32) - for k0 in range(0, D, BLOCK_K): - k_offs = k0 + tl.arange(0, BLOCK_K) - grad_chunk = tl.load( - grad_encoded_ptr + rows[:, None] * D + k_offs[None, :], mask=row_mask[:, None], other=0.0 - ) - w2_chunk = tl.load(w2_ptr + k_offs[:, None] * D + d_offs[None, :]) - grad_normed_relu = tl.dot(grad_chunk, w2_chunk, grad_normed_relu) - grad_normed_relu = _bf16_round(grad_normed_relu) x = tl.load( x_ptr + batch[:, None] * stride_xb + slots[:, None] * stride_xs + f_offs[None, :] * stride_xf, mask=row_mask[:, None] & (f_offs[None, :] < F), other=0.0, ) - pre_norm = _bf16_round(tl.dot(x.to(tl.bfloat16), w1t) + b1[None, :]) - mean = tl.sum(pre_norm, axis=1) / D - centered = pre_norm - mean[:, None] - rstd = tl.rsqrt(tl.sum(centered * centered, axis=1) / D + LAYER_NORM_EPS) - normalized = centered * rstd[:, None] - normed = normalized * gamma[None, :] + beta[None, :] - grad_normed = tl.where(normed > 0.0, grad_normed_relu, 0.0) - grad_scaled = grad_normed * gamma[None, :] - mean_grad = tl.sum(grad_scaled, axis=1) / D - mean_grad_normalized = tl.sum(grad_scaled * normalized, axis=1) / D - grad_pre_norm = _bf16_round( - rstd[:, None] * (grad_scaled - mean_grad[:, None] - normalized * mean_grad_normalized[:, None]) - ) - tl.store( - grad_pre_norm_ptr + rows[:, None] * D + d_offs[None, :], - grad_pre_norm.to(tl.bfloat16), - mask=row_mask[:, None], + grad_gamma, grad_beta, grad_b1 = _backward_rows_full( + x.to(tl.bfloat16), + rows, + row_mask, + grad_encoded_ptr, + w1t, + b1, + gamma, + beta, + w2_ptr, + grad_pre_norm_ptr, + grad_gamma, + grad_beta, + grad_b1, + D, + BLOCK_R, + BLOCK_K, ) - grad_gamma += tl.sum(grad_normed * normalized, axis=0) - grad_beta += tl.sum(grad_normed, axis=0) - grad_b1 += tl.sum(grad_pre_norm, axis=0) tl.store(grad_gamma_partial_ptr + pid * D + d_offs, grad_gamma) tl.store(grad_beta_partial_ptr + pid * D + d_offs, grad_beta) tl.store(grad_b1_partial_ptr + pid * D + d_offs, grad_b1) @@ -308,10 +390,6 @@ def _feature_pad(in_features): return max(MIN_FEATURE_PAD, triton.next_power_of_2(in_features)) -def _slot_block(num_slots, block_slots): - return min(block_slots, triton.next_power_of_2(num_slots)) - - def pad_weight(w1_bf16, feature_pad): out_features, in_features = w1_bf16.shape padded = torch.zeros(out_features, feature_pad, dtype=torch.bfloat16, device=w1_bf16.device) @@ -360,7 +438,6 @@ def fused_slot_encoder_fwd( x.stride(1), x.stride(2), BLOCK_B=config.fwd_block_batch, - BLOCK_S=_slot_block(S, config.fwd_block_slots), D=D, F_PAD=feature_pad, BLOCK_K=config.fwd_block_k, diff --git a/scripts/tune_fused_slot_encoder.py b/scripts/tune_fused_slot_encoder.py index a4130d955c..237f03b49c 100644 --- a/scripts/tune_fused_slot_encoder.py +++ b/scripts/tune_fused_slot_encoder.py @@ -26,17 +26,11 @@ WARMUP_ITERS = 3 POOLED_TOLERANCE = 2.0**-6 GRAD_TOLERANCE = 1e-3 -FWD_GRID = { - "block_batch": (4, 8, 16), - "block_slots": (4, 8), - "block_k": (32, 64), - "num_warps": (4, 8), - "num_stages": (1, 2, 3), -} -BWD_GRID = {"block_rows": (32, 64, 128), "block_k": (32, 64), "num_warps": (4, 8), "num_stages": (1, 2, 3)} +FWD_GRID = {"block_batch": (16, 32, 64), "block_k": (32, 64), "num_warps": (4, 8), "num_stages": (2, 3)} +BWD_GRID = {"block_rows": (32, 64, 128), "block_k": (32, 64), "num_warps": (4, 8), "num_stages": (2, 3)} POOL_GRID = {"block_rows": (16, 32, 64), "num_warps": (4, 8)} -QUICK_FWD_GRID = {"block_batch": (8,), "block_slots": (4,), "block_k": (32, 64), "num_warps": (4,), "num_stages": (2,)} -QUICK_BWD_GRID = {"block_rows": (64,), "block_k": (64,), "num_warps": (8,), "num_stages": (1, 2)} +QUICK_FWD_GRID = {"block_batch": (32,), "block_k": (32, 64), "num_warps": (4,), "num_stages": (2,)} +QUICK_BWD_GRID = {"block_rows": (64,), "block_k": (64,), "num_warps": (8,), "num_stages": (2,)} QUICK_POOL_GRID = {"block_rows": (32,), "num_warps": (4,)} LAUNCH_ERRORS = (triton.runtime.errors.OutOfResources, triton.compiler.errors.CompilationError, RuntimeError) PARTS = ("fwd", "bwd", "pool") diff --git a/tests/unit_tests/test_fused_slot_encoder.py b/tests/unit_tests/test_fused_slot_encoder.py index 1e2aaaec65..1ae60beab3 100644 --- a/tests/unit_tests/test_fused_slot_encoder.py +++ b/tests/unit_tests/test_fused_slot_encoder.py @@ -14,6 +14,8 @@ pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="fused slot encoder needs CUDA + Triton") ENCODER_SHAPES = [(35, 10, 256), (30, 6, 256), (20, 11, 256), (4, 14, 128)] +# None = the table config of this GPU; the second config exercises 64-row tiles with 8 warps +KERNEL_CONFIGS = {"table": None, "wide": (64, 64, 8, 2, 128, 64, 8, 2, 64, 4)} BATCH = 1024 BF16_ULP_AT_FOUR = 2.0**-6 @@ -76,8 +78,18 @@ def relative_error(a, b): return ((a - b).norm() / b.norm().clamp_min(1e-12)).item() +@pytest.fixture(params=list(KERNEL_CONFIGS), ids=list(KERNEL_CONFIGS)) +def kernel_config(request): + import pufferlib.ocean.fused_slot_encoder as fse + + fields = KERNEL_CONFIGS[request.param] + fse.CONFIG_OVERRIDE = fse.KernelConfig(*fields) if fields else None + yield request.param + fse.CONFIG_OVERRIDE = None + + @pytest.mark.parametrize("slots,features,width", ENCODER_SHAPES) -def test_fused_matches_autocast_reference(slots, features, width): +def test_fused_matches_autocast_reference(slots, features, width, kernel_config): torch.manual_seed(0) encoder = make_encoder(features, width) objects, counts, grad_pooled = make_inputs(slots, features, width, seed=slots) From 364d738ca2a56f5b8cd3baba0198c19f35ed069f Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 14:18:35 +0200 Subject: [PATCH 010/123] measure mfu on cluster --- scripts/kesai/16_mfu_bench_slurm.sh | 57 +++++++++ scripts/mfu_bench.py | 186 ++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100755 scripts/kesai/16_mfu_bench_slurm.sh create mode 100644 scripts/mfu_bench.py diff --git a/scripts/kesai/16_mfu_bench_slurm.sh b/scripts/kesai/16_mfu_bench_slurm.sh new file mode 100755 index 0000000000..852ad7fff3 --- /dev/null +++ b/scripts/kesai/16_mfu_bench_slurm.sh @@ -0,0 +1,57 @@ +#!/bin/bash +#SBATCH --job-name mfu_bench +#SBATCH --ntasks 1 +#SBATCH --nodes 1 +#SBATCH --time 1:00:00 +#SBATCH --gres gpu:1 +#SBATCH --mem=125G +#SBATCH --cpus-per-task 18 +#SBATCH --output /home/bjaeger/PufferDrive/experiments/logs/mfu_bench_%j.out +#SBATCH --error /home/bjaeger/PufferDrive/experiments/logs/mfu_bench_%j.err +#SBATCH --partition dev + +# Measures MFU of the real PPO loop (rollout, update, whole epoch) and SPS on this node's GPU, with and without +# policy.fused_slot_encoder, and adds hardware-counter utilization of one step if Nsight Compute is available. +# 32 envs on 18 threads, like the multinode training script (blocking workers allow 2 per hardware thread). + +echo "START TIME: $(date)" +start=$(date +%s) +cd /home/bjaeger/PufferDrive || exit 1 +nvidia-smi --query-gpu=name,compute_cap,memory.total --format=csv + +export OMP_NUM_THREADS=1 +source .venv/bin/activate +bash scripts/kesai/build_ext_if_changed.sh /home/bjaeger/PufferDrive || exit 1 + +RESULT_FILE=/home/bjaeger/PufferDrive/experiments/logs/mfu_bench_${SLURM_JOB_ID}_result.txt +NUM_ENVS=32 +MINIBATCH=131072 +echo "=== MFU result (paste this block back) ===" | tee ${RESULT_FILE} +python scripts/mfu_bench.py --num-envs ${NUM_ENVS} --minibatch ${MINIBATCH} 2>&1 | grep -v "^MFU_RESULT" | grep "reference\|capability" | tee -a ${RESULT_FILE} +python scripts/mfu_bench.py --num-envs ${NUM_ENVS} --minibatch ${MINIBATCH} --fused 2>&1 | grep -v "^MFU_RESULT" | grep "fused" | tee -a ${RESULT_FILE} + +NCU=$(command -v ncu || ls /opt/nvidia/nsight-compute/*/ncu 2>/dev/null | tail -1) +if [ -n "${NCU}" ]; then + echo "--- hardware counters (ncu range replay, one update minibatch of ${MINIBATCH} rows + one rollout forward) ---" | tee -a ${RESULT_FILE} + METRICS=gpu__time_duration.sum,sm__ops_path_tensor_op_hmma_src_bf16_dst_fp32.sum,dram__bytes.sum + for variant in reference fused; do + FLAG=""; [ ${variant} = fused ] && FLAG="--fused" + CSV=/home/bjaeger/PufferDrive/experiments/logs/mfu_bench_${SLURM_JOB_ID}_${variant}.csv + timeout 600 ${NCU} --replay-mode range --target-processes application-only --clock-control none --nvtx \ + --nvtx-include "update" --nvtx-include "rollout" --nvtx-include "calibrate" --print-nvtx-rename kernel \ + --metrics ${METRICS} --csv --page raw --print-units base \ + python scripts/profile_policy_step.py --rows ${MINIBATCH} ${FLAG} > ${CSV} 2>&1 \ + && { echo "${variant}:" | tee -a ${RESULT_FILE}; python scripts/profile_policy_step.py --parse ${CSV} --rows ${MINIBATCH} | tee -a ${RESULT_FILE}; } \ + || echo "${variant}: ncu failed (counters restricted or unsupported), see ${CSV}" | tee -a ${RESULT_FILE} + done +else + echo "Nsight Compute not found on this node; hardware-counter utilization skipped." | tee -a ${RESULT_FILE} +fi +echo "=== end of MFU result ===" | tee -a ${RESULT_FILE} + +echo +echo "Result block (also in ${RESULT_FILE}):" +cat ${RESULT_FILE} +end=$(date +%s) +echo "END TIME: $(date)" +echo "Runtime: $((end-start)) s" diff --git a/scripts/mfu_bench.py b/scripts/mfu_bench.py new file mode 100644 index 0000000000..15c43df72b --- /dev/null +++ b/scripts/mfu_bench.py @@ -0,0 +1,186 @@ +"""Model FLOP utilization of the real PPO loop: rollout, update and whole epoch, plus agent steps per second. + +Builds the trainer exactly as `puffer train puffer_drive` does, runs warm-up epochs (torch.compile), then measures +steady-state epochs. Model FLOPs are counted analytically from the policy's linear layers, so no profiler touches +the compiled policy (a TorchDispatchMode around it makes dynamo skip the frame for the rest of the process). + +Usage: + python scripts/mfu_bench.py --num-envs 16 --minibatch 131072 [--fused] [-- train.seed=3 ...] +""" + +import argparse +import json +import os +import sys +import tempfile +import time +from collections import defaultdict + + +os.environ.setdefault("OMP_NUM_THREADS", "1") +import torch +import torch._dynamo +from profile_policy_step import PEAK_BF16_TFLOPS_BY_CAPABILITY +from torch import nn + + +ENV_NAME = "puffer_drive" +RECOMPILE_EPOCH_FACTOR = 1.25 +SLOT_ENCODER_COUNTS = { + "lane_encoder": "obs_slots_lane_kept", + "boundary_encoder": "obs_slots_boundary_kept", + "partner_encoder": "obs_slots_partners_n", + "traffic_control_encoder": "obs_slots_traffic_controls_n", +} + + +def model_flops_per_transition(policy): + """Forward and forward+backward FLOPs of one transition through the policy (matmuls only, the MFU convention).""" + forward = 0.0 + backward = 0.0 + for name, module in policy.named_modules(): + if not isinstance(module, nn.Linear): + continue + rows = 1 + backbone_name = name.split(".")[0] + backbone = getattr(policy, backbone_name, None) + for encoder_name, count_attribute in SLOT_ENCODER_COUNTS.items(): + if encoder_name in name and backbone is not None: + rows = getattr(backbone, count_attribute) + layer_flops = 2.0 * rows * module.in_features * module.out_features + forward += layer_flops + # the first layer of every encoder reads observations, which need no input gradient + first_encoder_layer = "encoder" in name and name.endswith(".0") + backward += layer_flops if first_encoder_layer else 2.0 * layer_flops + return forward, forward + backward + + +def timed(fn): + torch.cuda.synchronize() + start = time.perf_counter() + result = fn() + torch.cuda.synchronize() + return time.perf_counter() - start, result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--num-envs", type=int, default=16) + parser.add_argument("--minibatch", type=int, default=131072) + parser.add_argument("--warmup", type=int, default=2, help="epochs before measuring (compile, recompiles)") + parser.add_argument("--epochs", type=int, default=3, help="measured epochs") + parser.add_argument("--fused", action="store_true", help="policy.fused_slot_encoder=true") + parser.add_argument("overrides", nargs="*", help="extra hydra overrides, e.g. train.seed=3") + args = parser.parse_args() + + data_dir = tempfile.mkdtemp(prefix="mfu_bench_") + sys.argv = [ + sys.argv[0], + f"vec.num_envs={args.num_envs}", + "env.preload_map_cache=True", + f"train.minibatch_size={args.minibatch}", + f"train.max_minibatch_size={args.minibatch}", + f"train.data_dir={data_dir}", + f"policy.fused_slot_encoder={str(args.fused).lower()}", + "wandb=False", + "tb=False", + "neptune=False", + *args.overrides, + ] + import pufferlib.pufferl as P + + config = P.load_config(ENV_NAME) + config = P.normalize_puffer_drive_config(config, "training") + P.validate_puffer_drive_config(config, "training") + P.validate_puffer_drive_resources(config, "training") + torch_seed, env_seed = P.derive_rank_seeds(config["vec"]["seed"], config["train"]["seed"], 1, 0) + torch.manual_seed(torch_seed) + vecenv = P.load_env(ENV_NAME, config, seed=env_seed) + policy = P.load_policy(config, vecenv, ENV_NAME) + train_config = dict(**config["train"], env=ENV_NAME, eval=config.get("eval", {}), run_name=config["run_name"]) + pufferl = P.PuffeRL(train_config, vecenv, policy) + pufferl.profile.frequency = 10**9 + + forward_flops, train_flops = model_flops_per_transition(pufferl.uncompiled_policy) + counters = defaultdict(int) + original_ppo_loss = pufferl._ppo_loss + + def counting_ppo_loss(mb_obs, *rest, **kwargs): + counters["samples"] += mb_obs.shape[0] + counters["minibatches"] += 1 + return original_ppo_loss(mb_obs, *rest, **kwargs) + + pufferl._ppo_loss = counting_ppo_loss + properties = torch.cuda.get_device_properties(0) + capability = torch.cuda.get_device_capability(0) + peak_tflops = PEAK_BF16_TFLOPS_BY_CAPABILITY.get(capability) + agent_steps = train_config["batch_size"] + print( + f"{properties.name} capability {capability}, {args.num_envs} envs, {vecenv.num_agents} agents, " + f"minibatch {pufferl.minibatch_size}, fused_slot_encoder={args.fused}, " + f"model FLOP per transition: forward {forward_flops / 1e6:.2f} MFLOP, update {train_flops / 1e6:.2f} MFLOP", + flush=True, + ) + + try: + for epoch in range(args.warmup): + rollout_seconds, _ = timed(pufferl.evaluate) + update_seconds, _ = timed(pufferl.train) + print(f"warmup epoch {epoch}: rollout {rollout_seconds:.2f} s, update {update_seconds:.2f} s", flush=True) + frames = dict(torch._dynamo.utils.counters["frames"]) + epochs = [] + for epoch in range(args.epochs): + counters.clear() + rollout_seconds, _ = timed(pufferl.evaluate) + update_seconds, _ = timed(pufferl.train) + epochs.append((rollout_seconds, update_seconds, counters["samples"])) + print( + f"epoch {epoch}: rollout {rollout_seconds:.2f} s, update {update_seconds:.2f} s, " + f"kept transitions {counters['samples']}", + flush=True, + ) + finally: + pufferl.utilization.stop() + vecenv.close() + + fastest_update = min(update for _, update, _ in epochs) + steady = [e for e in epochs if e[1] < RECOMPILE_EPOCH_FACTOR * fastest_update] + rollout = sum(e[0] for e in steady) / len(steady) + update = sum(e[1] for e in steady) / len(steady) + kept = sum(e[2] for e in steady) / len(steady) + phases = { + "update": (kept * train_flops, update), + "rollout": (agent_steps * forward_flops, rollout), + "full_loop": (kept * train_flops + agent_steps * forward_flops, update + rollout), + } + result = { + "gpu": properties.name, + "capability": list(capability), + "fused": args.fused, + "num_envs": args.num_envs, + "minibatch": pufferl.minibatch_size, + "steady_epochs": len(steady), + "kept_transitions_per_epoch": kept, + "agent_steps_per_epoch": agent_steps, + "sps": agent_steps / (update + rollout), + "peak_tflops": peak_tflops, + "dynamo_frames": frames, + "max_memory_gb": torch.cuda.max_memory_allocated() / 2**30, + } + for phase, (flops, seconds) in phases.items(): + result[f"{phase}_seconds"] = seconds + result[f"{phase}_tflops"] = flops / seconds / 1e12 + if peak_tflops: + result[f"{phase}_mfu"] = 100.0 * flops / seconds / (peak_tflops * 1e12) + print("MFU_RESULT " + json.dumps(result)) + label = "fused" if args.fused else "reference" + for phase in phases: + mfu = f", MFU {result[f'{phase}_mfu']:.1f}%" if peak_tflops else "" + print( + f"{label} {phase:9s}: {result[f'{phase}_seconds']:6.2f} s/epoch, {result[f'{phase}_tflops']:6.1f} TFLOP/s{mfu}" + ) + print(f"{label} SPS {result['sps'] / 1e3:.0f}k agent-steps/s, peak GPU memory {result['max_memory_gb']:.1f} GB") + + +if __name__ == "__main__": + main() From bd95191aaef91e0523165507f74670feaac7ea1f Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 14:29:20 +0200 Subject: [PATCH 011/123] update kernel tuning H100 --- pufferlib/ocean/fused_slot_encoder.py | 5 ++--- scripts/profile_policy_step.py | 23 +++++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pufferlib/ocean/fused_slot_encoder.py b/pufferlib/ocean/fused_slot_encoder.py index b5edeed0e0..e8a769d126 100644 --- a/pufferlib/ocean/fused_slot_encoder.py +++ b/pufferlib/ocean/fused_slot_encoder.py @@ -29,9 +29,8 @@ class KernelConfig: # Timed on an RTX 5090 (GB202, compute capability 12.0); the RTX PRO 6000 Blackwell is the same chip. GB202_CONFIG = KernelConfig(32, 32, 4, 2, 64, 64, 8, 2, 32, 4) -# Backward timed on an H100 80GB HBM3 at 131072 rows; forward carried over from the slot-chunk kernel, retune with -# scripts/kesai/15_tune_fused_slot_encoder_slurm.sh. -H100_CONFIG = KernelConfig(32, 64, 4, 2, 32, 64, 4, 3, 32, 4) +# Timed on an H100 80GB HBM3 at 131072 rows (scripts/kesai/15_tune_fused_slot_encoder_slurm.sh, job 30133). +H100_CONFIG = KernelConfig(32, 32, 4, 3, 32, 64, 4, 3, 32, 4) # Untimed placeholder: replace with the KernelConfig printed by scripts/tune_fused_slot_encoder.py on that GPU. B200_CONFIG = GB202_CONFIG B300_CONFIG = B200_CONFIG # Blackwell Ultra shares the B200 SM design diff --git a/scripts/profile_policy_step.py b/scripts/profile_policy_step.py index a8c32f42c8..76d7d3033a 100644 --- a/scripts/profile_policy_step.py +++ b/scripts/profile_policy_step.py @@ -29,6 +29,8 @@ # dense bf16 tensor peak with fp32 accumulation, by compute capability PEAK_BF16_TFLOPS_BY_CAPABILITY = {(12, 0): 209.5, (9, 0): 989.0, (10, 0): 2250.0, (10, 3): 2250.0} ROLLOUT_ROWS = 16384 +WARMUP_STEPS = 4 +TIMED_STEPS = 5 ENV_AGENTS = 512 ROLLOUT_STEPS = 40 TRAIN_OVERRIDES = [ @@ -116,18 +118,23 @@ def rollout_step(): logits, _ = compiled_eval(rollout_obs, {}) sample_logits(logits.float(), env_continuous=True, policy=policy) - for _ in range(2): + for _ in range(WARMUP_STEPS): update_step() rollout_step() wall_ms = {} for name, step in (("update", update_step), ("rollout", rollout_step)): - start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True) - start.record() - step() - end.record() - torch.cuda.synchronize() - wall_ms[name] = start.elapsed_time(end) - print(f"unprofiled wall time: update {wall_ms['update']:.2f} ms, rollout {wall_ms['rollout']:.2f} ms") + samples = [] + for _ in range(TIMED_STEPS): + start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True) + start.record() + step() + end.record() + torch.cuda.synchronize() + samples.append(start.elapsed_time(end)) + wall_ms[name] = sorted(samples)[len(samples) // 2] + print( + f"unprofiled wall time (median of {TIMED_STEPS}): update {wall_ms['update']:.2f} ms, rollout {wall_ms['rollout']:.2f} ms" + ) peak = PEAK_BF16_TFLOPS_BY_CAPABILITY.get(torch.cuda.get_device_capability(0)) update_rate = rows * TRAIN_FLOP_PER_TRANSITION / (wall_ms["update"] * 1e-3) / 1e12 rollout_rate = ROLLOUT_ROWS * ROLLOUT_FLOP_PER_TRANSITION / (wall_ms["rollout"] * 1e-3) / 1e12 From e48a16d254bb186a9047eb048df70d5c07260f2a Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 15:02:35 +0200 Subject: [PATCH 012/123] update mfu bench --- scripts/kesai/16_mfu_bench_slurm.sh | 4 ++-- scripts/mfu_bench.py | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/kesai/16_mfu_bench_slurm.sh b/scripts/kesai/16_mfu_bench_slurm.sh index 852ad7fff3..5e010c2902 100755 --- a/scripts/kesai/16_mfu_bench_slurm.sh +++ b/scripts/kesai/16_mfu_bench_slurm.sh @@ -24,8 +24,8 @@ source .venv/bin/activate bash scripts/kesai/build_ext_if_changed.sh /home/bjaeger/PufferDrive || exit 1 RESULT_FILE=/home/bjaeger/PufferDrive/experiments/logs/mfu_bench_${SLURM_JOB_ID}_result.txt -NUM_ENVS=32 -MINIBATCH=131072 +NUM_ENVS=${NUM_ENVS:-32} # override: NUM_ENVS=48 sbatch scripts/kesai/16_mfu_bench_slurm.sh +MINIBATCH=${MINIBATCH:-131072} echo "=== MFU result (paste this block back) ===" | tee ${RESULT_FILE} python scripts/mfu_bench.py --num-envs ${NUM_ENVS} --minibatch ${MINIBATCH} 2>&1 | grep -v "^MFU_RESULT" | grep "reference\|capability" | tee -a ${RESULT_FILE} python scripts/mfu_bench.py --num-envs ${NUM_ENVS} --minibatch ${MINIBATCH} --fused 2>&1 | grep -v "^MFU_RESULT" | grep "fused" | tee -a ${RESULT_FILE} diff --git a/scripts/mfu_bench.py b/scripts/mfu_bench.py index 15c43df72b..0722be5f5f 100644 --- a/scripts/mfu_bench.py +++ b/scripts/mfu_bench.py @@ -70,6 +70,7 @@ def main(): parser.add_argument("--warmup", type=int, default=2, help="epochs before measuring (compile, recompiles)") parser.add_argument("--epochs", type=int, default=3, help="measured epochs") parser.add_argument("--fused", action="store_true", help="policy.fused_slot_encoder=true") + parser.add_argument("--rollout-only", action="store_true", help="time only the rollout (data collection)") parser.add_argument("overrides", nargs="*", help="extra hydra overrides, e.g. train.seed=3") args = parser.parse_args() @@ -123,16 +124,23 @@ def counting_ppo_loss(mb_obs, *rest, **kwargs): ) try: + + def update_or_skip(): + if args.rollout_only: + pufferl.epoch += 1 # keep the trainer's epoch bookkeeping moving without an update + return 0.0, None + return timed(pufferl.train) + for epoch in range(args.warmup): rollout_seconds, _ = timed(pufferl.evaluate) - update_seconds, _ = timed(pufferl.train) + update_seconds, _ = update_or_skip() print(f"warmup epoch {epoch}: rollout {rollout_seconds:.2f} s, update {update_seconds:.2f} s", flush=True) frames = dict(torch._dynamo.utils.counters["frames"]) epochs = [] for epoch in range(args.epochs): counters.clear() rollout_seconds, _ = timed(pufferl.evaluate) - update_seconds, _ = timed(pufferl.train) + update_seconds, _ = update_or_skip() epochs.append((rollout_seconds, update_seconds, counters["samples"])) print( f"epoch {epoch}: rollout {rollout_seconds:.2f} s, update {update_seconds:.2f} s, " @@ -143,6 +151,12 @@ def counting_ppo_loss(mb_obs, *rest, **kwargs): pufferl.utilization.stop() vecenv.close() + if args.rollout_only: + rollout = sum(e[0] for e in epochs) / len(epochs) + print( + f"ROLLOUT_RESULT envs={args.num_envs} agents={vecenv.num_agents} rollout_s={rollout:.3f} agent_steps_per_s={agent_steps / rollout:.0f}" + ) + return fastest_update = min(update for _, update, _ in epochs) steady = [e for e in epochs if e[1] < RECOMPILE_EPOCH_FACTOR * fastest_update] rollout = sum(e[0] for e in steady) / len(steady) From aa9ab0b4dc94c0ee130c964a7d881641b1e2680d Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 15:32:49 +0200 Subject: [PATCH 013/123] fix longest6 eval --- pufferlib/ocean/cosim/carla/lb1/README.md | 6 +- .../cosim/carla/lb1/leaderboard_agent.py | 132 ++++++++++++++---- scripts/kesai/11_carla_longest6.sh | 6 +- scripts/kesai/14_carla_longest6_v1.sh | 2 +- 4 files changed, 116 insertions(+), 30 deletions(-) diff --git a/pufferlib/ocean/cosim/carla/lb1/README.md b/pufferlib/ocean/cosim/carla/lb1/README.md index 6c506c745b..1371c42712 100644 --- a/pufferlib/ocean/cosim/carla/lb1/README.md +++ b/pufferlib/ocean/cosim/carla/lb1/README.md @@ -84,8 +84,10 @@ The evaluator reads `BENCHMARK=longest6` (background traffic on every spawn poin stop sign 1.0 under `BENCHMARK=longest6`, no min-speed or scenario-timeout infractions, blocked after 180 s (`BLOCKED_THRESHOLD`), route timeout 0.8 s/m. `result.json` records carry nine infraction keys. - The ego is `vehicle.lincoln.mkz2017`; routes carry weather. -- CARLA 0.9.10: no `World.cast_ray`, so the teleported ego rests on the lane waypoint's height and road - plane (the 0.9.15 agent additionally samples the road mesh); stop waypoints from the trigger volume. +- CARLA 0.9.10: no `World.cast_ray`, so the ego's body chord rests on the lane's OpenDRIVE height profile + and a downward semantic lidar probes the road mesh's offset from it (Town02's mesh sits 0.22 m above its + OpenDRIVE; the egg's `Rotation.get_up_vector()` points down, so the pitch comes from the profile too); + stop waypoints from the trigger volume. - The `carla_*` telemetry columns come from `ground_truth.py` (straight-ahead probes in place of lane waypoints for the stop sign, no same-lane check for the red light), not from CaRL's criteria. diff --git a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py index 71d330daca..d7327e76ce 100644 --- a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -27,9 +27,10 @@ the shadow env (pd_*) or by the Leaderboard 1.0 criteria recomputed in the server (carla_*, lb1/ground_truth.py) -CARLA 0.9.10 differences handled here: no World.cast_ray (the teleported ego rests on the lane waypoint's -height and road plane), no TrafficLight.get_stop_waypoints (stop waypoints come from the trigger volume, -RunningRedLightTest's own recipe). +CARLA 0.9.10 differences handled here: no World.cast_ray (the ego's body chord rests on the lane's OpenDRIVE +height profile, lifted by the road mesh's offset from it as probed by a downward semantic lidar; the egg's +Rotation.get_up_vector() points down, so the pitch comes from the profile too), no +TrafficLight.get_stop_waypoints (stop waypoints come from the trigger volume, RunningRedLightTest's own recipe). """ import math @@ -70,6 +71,16 @@ NO_LIGHT_ID = -1 NO_PARKING_LANE = -1.0 +WHEEL_POSITION_CM_PER_M = 100.0 +ROAD_PROBE_MOUNT_Z_M = 0.5 # inside the hull, above any plausible penetration +ROAD_PROBE_RANGE_M = 20.0 +ROAD_PROBE_POINTS_PER_SECOND = 2000 +ROAD_SURFACE_TAGS = (6, 7, 14, 15, 16, 22) # CityObjectLabel RoadLines, Roads, Ground, Bridge, RailTrack, Terrain +REST_ORIGIN_ABOVE_ROAD_M = 0.033 # mkz2017 origin height at rest on a flat road (measured, 0.9.10) +MESH_LIFT_MARGIN_M = 0.05 +MESH_OFFSET_DEADBAND_M = 0.02 +MESH_OFFSET_MAX_STEP_M = 0.3 + def get_entry_point(): return "PufferAgentLB1" @@ -100,17 +111,22 @@ def plan_xyz(plan): return [[t.location.x, t.location.y, t.location.z] for t, _ in plan] -def road_aligned_attitude(road_up, yaw_deg): - """(pitch_deg, roll_deg, unit forward) of a body heading yaw_deg that lies flat on the road plane with normal road_up.""" - normal = np.array([road_up.x, road_up.y, road_up.z], dtype=np.float64) - yaw_rad = math.radians(yaw_deg) - forward = np.array([math.cos(yaw_rad), math.sin(yaw_rad), 0.0]) - forward -= normal * float(forward @ normal) - forward /= np.linalg.norm(forward) - right = np.cross(normal, forward) # CARLA is left-handed: right = up x forward - pitch_deg = math.degrees(math.asin(forward[2])) - roll_deg = math.degrees(math.asin(-right[2])) # positive roll = right side down - return pitch_deg, roll_deg, forward +def lane_z_at(wp, offset_m): + """Lane height offset_m along the lane from wp (negative = behind); None past the lane network.""" + if offset_m == 0.0: + return wp.transform.location.z + found = wp.next(offset_m) if offset_m > 0.0 else wp.previous(-offset_m) + return found[0].transform.location.z if found else None + + +def lane_chord_pose(wp, sample_offsets_m): + """(origin z, pitch_deg) of a rigid body chord resting on the lane's height profile around wp.""" + sample_z = [lane_z_at(wp, s) for s in sample_offsets_m] + if any(z is None for z in sample_z): + return wp.transform.location.z, wrap_deg_180(wp.transform.rotation.pitch) + slope = (sample_z[-1] - sample_z[0]) / (sample_offsets_m[-1] - sample_offsets_m[0]) + z = max(z_i - slope * s_i for z_i, s_i in zip(sample_z, sample_offsets_m)) + return z, math.degrees(math.atan(slope)) def driving_waypoint_samples(carla_map): @@ -264,6 +280,44 @@ def write_mp4(out_path, frames, fps): writer.close() +class RoadProbe: + """Semantic lidar firing straight down from inside the hull: 0.9.10's substitute for World.cast_ray.""" + + def __init__(self, world, vehicle, tick_dt): + blueprint = world.get_blueprint_library().find("sensor.lidar.ray_cast_semantic") + for name, value in ( + ("channels", "1"), + ("range", str(ROAD_PROBE_RANGE_M)), + ("points_per_second", str(ROAD_PROBE_POINTS_PER_SECOND)), + ("rotation_frequency", str(1.0 / tick_dt)), + ("upper_fov", "-90"), + ("lower_fov", "-90"), + ): + blueprint.set_attribute(name, value) + mount = carla.Transform(carla.Location(x=0.0, y=0.0, z=ROAD_PROBE_MOUNT_Z_M)) + self.sensor = world.spawn_actor(blueprint, mount, attach_to=vehicle) + self.sensor.listen(self._on_data) + self._frame = None + self._distance_m = None + self._consumed_frame = None + + def _on_data(self, data): + distances = sorted(-d.point.z for d in data if d.object_tag in ROAD_SURFACE_TAGS) + if distances: + self._frame, self._distance_m = data.frame, distances[len(distances) // 2] + + def fresh_distance_m(self): + """Median road distance along the body's down axis from the newest unread scan; None when nothing new.""" + if self._frame is None or self._frame == self._consumed_frame: + return None + self._consumed_frame = self._frame + return self._distance_m + + def destroy(self): + self.sensor.stop() + self.sensor.destroy() + + class PolicyServerLink: """policy_server.py as a child process in the PufferDrive venv, one request/reply per message.""" @@ -334,6 +388,12 @@ def setup(self, path_to_conf_file, route_index=None): self.carla_view_writer = None self._collision_sensor = None self._collision_events = [] + self.road_probe = None + self.body_chord_offsets_m = None + self.mesh_offset_m = 0.0 + self._previous_body_z = None + self._previous_lane_center_z = None + self._previous_pitch_rad = 0.0 def sensors(self): if not self.debug_carla_view_dir and not self.record_infractions_dir: @@ -354,7 +414,7 @@ def set_global_plan(self, global_plan_gps, global_plan_world_coord): self.dense_global_plan_world_coord = global_plan_world_coord def _init_on_first_step(self): - """Deferred init (the ego and world only exist once the route runs); read-only with respect to CARLA.""" + """Deferred init (the ego and world only exist once the route runs).""" self.vehicle = CarlaDataProvider.get_hero_actor() self.world = self.vehicle.get_world() self.cmap = self.world.get_map() @@ -366,6 +426,12 @@ def _init_on_first_step(self): [w.max_steer_angle, w.position.x, w.position.y, w.position.z] for w in self.vehicle.get_physics_control().wheels ] + front_left, rear_left = wheels[0][1:], wheels[2][1:] + half_wheelbase_m = 0.5 * math.sqrt(sum((a - b) ** 2 for a, b in zip(front_left, rear_left))) / WHEEL_POSITION_CM_PER_M + body_rear_m, body_front_m = body.location.x - body.extent.x, body.location.x + body.extent.x + assert body_rear_m < -half_wheelbase_m < half_wheelbase_m < body_front_m + self.body_chord_offsets_m = (body_rear_m, -half_wheelbase_m, 0.0, half_wheelbase_m, body_front_m) + self.road_probe = RoadProbe(self.world, self.vehicle, self.tick_dt) route = { "town": town, "tick_dt": self.tick_dt, @@ -456,27 +522,42 @@ def _ground_truth_observation(self): } def _teleport_carla_ego(self, motion): - """Place the CARLA ego at the shadow env's post-step pose (shadow_ego.StepResult.motion), resting on the - lane waypoint's height and road plane (0.9.10 has no road-mesh raycast).""" + """Place the CARLA ego at the shadow env's post-step pose (shadow_ego.StepResult.motion): the body chord on + the lane's height profile, lifted by the probed road-mesh offset (0.9.10 has no road-mesh raycast).""" _, _, yaw0_deg, _, x, y, yaw_deg, speed, carla_z = motion yaw_delta_deg = wrap_deg_180(yaw_deg - yaw0_deg) wp = self.cmap.get_waypoint(carla.Location(x=x, y=y, z=carla_z)) - z = wp.transform.location.z if wp is not None else carla_z - road_up = wp.transform.rotation.get_up_vector() if wp is not None else carla.Vector3D(x=0.0, y=0.0, z=1.0) - pitch_deg, roll_deg, forward = road_aligned_attitude(road_up, yaw_deg) + chord_z, pitch_deg = (carla_z, 0.0) if wp is None else lane_chord_pose(wp, self.body_chord_offsets_m) + self._update_mesh_offset() + z = chord_z + self.mesh_offset_m + REST_ORIGIN_ABOVE_ROAD_M + MESH_LIFT_MARGIN_M + pitch_rad, yaw_rad = math.radians(pitch_deg), math.radians(yaw_deg) + self._previous_body_z = z + self._previous_lane_center_z = carla_z if wp is None else wp.transform.location.z + self._previous_pitch_rad = pitch_rad + forward = (math.cos(yaw_rad) * math.cos(pitch_rad), math.sin(yaw_rad) * math.cos(pitch_rad), math.sin(pitch_rad)) # Zero momentum before the teleport: CARLA's collision resolver reacts violently to a # physics body carrying velocity into a new pose (carla issue #8076). zero = carla.Vector3D(x=0.0, y=0.0, z=0.0) self.vehicle.set_target_velocity(zero) self.vehicle.set_target_angular_velocity(zero) self.vehicle.set_transform( - carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=roll_deg)) - ) - self.vehicle.set_target_velocity( - carla.Vector3D(x=speed * float(forward[0]), y=speed * float(forward[1]), z=speed * float(forward[2])) + carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=0.0)) ) + self.vehicle.set_target_velocity(carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2])) self.vehicle.set_target_angular_velocity(carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.tick_dt)) + def _update_mesh_offset(self): + """Road mesh height above the lane's height profile under the body, from the probe's newest scan at the previous pose.""" + distance_m = self.road_probe.fresh_distance_m() + if distance_m is None or self._previous_body_z is None: + return + # the ray follows the pitched body's down axis; the mount offset is along that axis too + vertical_clearance_m = (distance_m - ROAD_PROBE_MOUNT_Z_M) * math.cos(self._previous_pitch_rad) + measured_m = self._previous_body_z - vertical_clearance_m - self._previous_lane_center_z + step_m = measured_m - self.mesh_offset_m + if abs(step_m) > MESH_OFFSET_DEADBAND_M: + self.mesh_offset_m += max(-MESH_OFFSET_MAX_STEP_M, min(MESH_OFFSET_MAX_STEP_M, step_m)) + def _maybe_save_infraction_clip(self, pd_flags, carla_flags): """One mp4 of the rolling chase-cam buffer per flagged ego infraction, at most once per INFRACTION_MIN_SEPARATION_M of ego travel (as ../leaderboard_agent.py).""" @@ -533,6 +614,9 @@ def destroy(self, results=None): if self.carla_view_writer is not None: self.carla_view_writer.close() print(f"[puffer_agent_lb1] wrote CARLA chase-cam video for route {self.route_tag}", flush=True) + if self.road_probe is not None: + self.road_probe.destroy() + self.road_probe = None if self._collision_sensor is not None: self._collision_sensor.stop() self._collision_sensor.destroy() diff --git a/scripts/kesai/11_carla_longest6.sh b/scripts/kesai/11_carla_longest6.sh index 3f589db93e..d5a208f66b 100644 --- a/scripts/kesai/11_carla_longest6.sh +++ b/scripts/kesai/11_carla_longest6.sh @@ -1,5 +1,5 @@ #!/bin/bash -#SBATCH --job-name eval_longest6 +#SBATCH --job-name eval_longest6_v2 #SBATCH --ntasks 1 #SBATCH --nodes 1 #SBATCH --time 1-00:00 @@ -34,7 +34,7 @@ export PD=${PD:-/home/bjaeger/PufferDrive} export PY=${PY:-/home/bjaeger/miniconda3/envs/carl/bin/python} # cp310: CARLA 0.9.15 ships no newer wheel export CARLA_ROOT=${CARLA_ROOT:-/home/bjaeger/CARLA_0.9.15} export CARL_WORK_DIR=${CARL_WORK_DIR:-/home/bjaeger/CaRL/CARLA} -RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0041_1000} +RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} # the agent finds config.yaml next to final_model.pt (or one level above a models/*.pt) export CKPT=$RUN_DIR/final_model.pt SCENARIOS=${SCENARIOS:-1} @@ -79,7 +79,7 @@ export COSIM_PEDESTRIAN_MIN_SIZE_M=${COSIM_PEDESTRIAN_MIN_SIZE_M:-0} export COSIM_OBS_HTML_MAX_STEPS=${COSIM_OBS_HTML_MAX_STEPS:-20000} export COSIM_OBS_HTML_RENDER=0 # routes save the compact replay only; render_carla_obs_html.py renders all pages into $OUT/obs_html -TAG=longest6$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") +TAG=longest6_v2$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") # results live in the model's own eval folder, next to the PufferDrive benchmark evals OUT=$RUN_DIR/eval/carla_${TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT/routes" "$OUT/aggregate" diff --git a/scripts/kesai/14_carla_longest6_v1.sh b/scripts/kesai/14_carla_longest6_v1.sh index a01d1dfbd9..f36941705d 100644 --- a/scripts/kesai/14_carla_longest6_v1.sh +++ b/scripts/kesai/14_carla_longest6_v1.sh @@ -38,7 +38,7 @@ export GARAGE_PY=${GARAGE_PY:-/home/bjaeger/miniconda3/envs/garage/bin/python} export SERVER_PY=${SERVER_PY:-/home/bjaeger/miniconda3/envs/carl/bin/python} # any PufferDrive-capable python export CARLA_ROOT=${CARLA_ROOT:-/home/bjaeger/CARLA_0.9.10} export GARAGE_WORK_DIR=${GARAGE_WORK_DIR:-/home/bjaeger/carla_garage_1} -RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0044_1000} +RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} # the agent finds config.yaml next to final_model.pt (or one level above a models/*.pt) export CKPT=$RUN_DIR/final_model.pt ROUTE_DIR=${ROUTE_DIR:-$GARAGE_WORK_DIR/leaderboard/data/longest6_split} From 110726c44881f298e9f61c2ee8fb973d4e8f524c Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 17:11:27 +0200 Subject: [PATCH 014/123] update mfu bench --- scripts/kesai/16_mfu_bench_slurm.sh | 2 ++ scripts/mfu_bench.py | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/kesai/16_mfu_bench_slurm.sh b/scripts/kesai/16_mfu_bench_slurm.sh index 5e010c2902..ea6e898023 100755 --- a/scripts/kesai/16_mfu_bench_slurm.sh +++ b/scripts/kesai/16_mfu_bench_slurm.sh @@ -29,6 +29,8 @@ MINIBATCH=${MINIBATCH:-131072} echo "=== MFU result (paste this block back) ===" | tee ${RESULT_FILE} python scripts/mfu_bench.py --num-envs ${NUM_ENVS} --minibatch ${MINIBATCH} 2>&1 | grep -v "^MFU_RESULT" | grep "reference\|capability" | tee -a ${RESULT_FILE} python scripts/mfu_bench.py --num-envs ${NUM_ENVS} --minibatch ${MINIBATCH} --fused 2>&1 | grep -v "^MFU_RESULT" | grep "fused" | tee -a ${RESULT_FILE} +echo "--- rollout split (env = waiting for the simulation workers, the rest is the trainer process) ---" | tee -a ${RESULT_FILE} +python scripts/mfu_bench.py --num-envs ${NUM_ENVS} --minibatch ${MINIBATCH} --fused --rollout-only --profile --warmup 1 --epochs 2 2>&1 | grep -A5 "section split" | tee -a ${RESULT_FILE} NCU=$(command -v ncu || ls /opt/nvidia/nsight-compute/*/ncu 2>/dev/null | tail -1) if [ -n "${NCU}" ]; then diff --git a/scripts/mfu_bench.py b/scripts/mfu_bench.py index 0722be5f5f..90828f05f6 100644 --- a/scripts/mfu_bench.py +++ b/scripts/mfu_bench.py @@ -5,7 +5,7 @@ the compiled policy (a TorchDispatchMode around it makes dynamo skip the frame for the rest of the process). Usage: - python scripts/mfu_bench.py --num-envs 16 --minibatch 131072 [--fused] [-- train.seed=3 ...] + python scripts/mfu_bench.py --num-envs 16 --minibatch 131072 [--fused] [--profile] [-- train.seed=3 ...] """ import argparse @@ -26,6 +26,7 @@ ENV_NAME = "puffer_drive" RECOMPILE_EPOCH_FACTOR = 1.25 +PROFILE_SECTIONS = ("env", "eval_copy", "eval_forward", "eval_misc", "train_copy", "train_forward", "learn", "train_misc") SLOT_ENCODER_COUNTS = { "lane_encoder": "obs_slots_lane_kept", "boundary_encoder": "obs_slots_boundary_kept", @@ -71,6 +72,10 @@ def main(): parser.add_argument("--epochs", type=int, default=3, help="measured epochs") parser.add_argument("--fused", action="store_true", help="policy.fused_slot_encoder=true") parser.add_argument("--rollout-only", action="store_true", help="time only the rollout (data collection)") + parser.add_argument( + "--profile", action="store_true", help="print the trainer's section split (env wait, copy, forward, learn)" + ) + parser.add_argument("--load-model", help="policy checkpoint (.pt) so the rollout shows trained behavior") parser.add_argument("overrides", nargs="*", help="extra hydra overrides, e.g. train.seed=3") args = parser.parse_args() @@ -100,7 +105,11 @@ def main(): policy = P.load_policy(config, vecenv, ENV_NAME) train_config = dict(**config["train"], env=ENV_NAME, eval=config.get("eval", {}), run_name=config["run_name"]) pufferl = P.PuffeRL(train_config, vecenv, policy) - pufferl.profile.frequency = 10**9 + pufferl.profile.frequency = 1 if args.profile else 10**9 + if args.load_model: + state = torch.load(args.load_model, map_location=config["train"]["device"], weights_only=False) + state = state.get("policy_state_dict", state) + pufferl.uncompiled_policy.load_state_dict(P.clean_policy_state_dict(state)) forward_flops, train_flops = model_flops_per_transition(pufferl.uncompiled_policy) counters = defaultdict(int) @@ -136,6 +145,7 @@ def update_or_skip(): update_seconds, _ = update_or_skip() print(f"warmup epoch {epoch}: rollout {rollout_seconds:.2f} s, update {update_seconds:.2f} s", flush=True) frames = dict(torch._dynamo.utils.counters["frames"]) + profile_baseline = {name: pufferl.profile.profiles[name]["elapsed"] for name in PROFILE_SECTIONS} epochs = [] for epoch in range(args.epochs): counters.clear() @@ -151,6 +161,12 @@ def update_or_skip(): pufferl.utilization.stop() vecenv.close() + if args.profile: + print("section split per epoch (trainer profiler, CUDA-synchronized at every boundary):") + for name in PROFILE_SECTIONS: + seconds = (pufferl.profile.profiles[name]["elapsed"] - profile_baseline[name]) / len(epochs) + if seconds > 0: + print(f" {name:14s} {seconds:6.2f} s") if args.rollout_only: rollout = sum(e[0] for e in epochs) / len(epochs) print( From ac785ab7f5902844d9d1db8320746d08cb0e2e47 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 17:41:07 +0200 Subject: [PATCH 015/123] speedup simulation code. set speed limit reward condition to max at eval time. --- pufferlib/config/evaluation/benchmark.yaml | 2 +- pufferlib/ocean/cosim/arch.py | 2 +- .../ocean/cosim/nuplan/run_nuplan_planner.sh | 2 +- pufferlib/ocean/drive/drive.h | 58 ++++++++++++------- scripts/kesai/9_nuPlan.sh | 4 +- tests/drive/test_drive_observations_rewards.c | 8 ++- 6 files changed, 47 insertions(+), 29 deletions(-) diff --git a/pufferlib/config/evaluation/benchmark.yaml b/pufferlib/config/evaluation/benchmark.yaml index 154f72bf91..9eab354b98 100644 --- a/pufferlib/config/evaluation/benchmark.yaml +++ b/pufferlib/config/evaluation/benchmark.yaml @@ -12,7 +12,7 @@ env: reward_stop_line: 1.0 reward_ade: 0.0 reward_goal: 1.0 - reward_overspeed: 0.005 + reward_overspeed: 0.1 # training-range max (REWARD_BOUNDS) reward_comfort: 0.05 reward_velocity: 0.0025 reward_lane_align: 0.025 diff --git a/pufferlib/ocean/cosim/arch.py b/pufferlib/ocean/cosim/arch.py index 24d50cdea4..86580e58e3 100644 --- a/pufferlib/ocean/cosim/arch.py +++ b/pufferlib/ocean/cosim/arch.py @@ -35,7 +35,7 @@ "reward_stop_line": 1.0, "reward_ade": 0.0, "reward_goal": 1.0, - "reward_overspeed": 0.005, + "reward_overspeed": 0.1, # training-range max (REWARD_BOUNDS) "reward_comfort": 0.05, "reward_velocity": 0.0025, "reward_lane_align": 0.025, diff --git a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh index ea56fa2d24..e8d45484ac 100755 --- a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh +++ b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh @@ -41,7 +41,7 @@ # policy's observation (training never spawns below 0.8 m); unset/0 = off. # OBS_SLOTS_PARTNERS_N shadow-env partner obs slots (unset = planner yaml, 40). # EVAL_PERCEIVED_SIZE_MARGIN_M [m per side] ego box inflation in the policy's obs (unset = arch.py, 0.2). -# REWARD_OVERSPEED overspeed conditioning alpha fed to the policy (unset = arch.py, 0.005). +# REWARD_OVERSPEED overspeed conditioning alpha fed to the policy (unset = arch.py, 0.1). # COSIM_OBS_HTML=all|failures|infractions|0 interactive observation replay (exact policy # input/outputs per step, pufferlib.viz HTML) -> $GROUP/obs_html. all: every # scenario. failures (default): only scenarios scoring below diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index f0f5db7948..9039476d77 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -4744,7 +4744,21 @@ static int write_reward_target_obs(Drive *env, Agent *ego, float *obs, int obs_i return obs_idx; } -static int write_partner_obs(Drive *env, Agent *ego, int agent_idx, float *obs, int obs_idx, int *partner_count) { +typedef struct { + float x; + float y; + float z; + int agent_idx; +} PartnerScanEntry; + +static int write_partner_obs( + Drive *env, + Agent *ego, + int ego_agent_idx, + const PartnerScanEntry *partner_scan, + float *obs, + int obs_idx, + int *partner_count) { // Partner blindness: zero partner obs for the configured duration once triggered if (ego->partner_blindness_counter > 0) { ego->partner_blindness_counter--; @@ -4767,36 +4781,27 @@ static int write_partner_obs(Drive *env, Agent *ego, int agent_idx, float *obs, } AgentDistance; AgentDistance nearby_agents[env->num_agents]; int nearby_count = 0; + float range_sq = env->obs_range_partner_m * env->obs_range_partner_m; for (int j = 0; j < env->num_agents; j++) { - int index = -1; - if (j < env->active_agent_count) { - index = env->active_agent_indices[j]; - } else if (j < env->num_agents) { - index = env->static_agent_indices[j - env->active_agent_count]; - } - if (index == env->active_agent_indices[agent_idx]) { - continue; // Skip self, but don't increment obs_idx - } - Agent *other = &env->agents[index]; - float dx = other->sim_x - ego->sim_x; - float dy = other->sim_y - ego->sim_y; - float dz = other->sim_z - ego->sim_z; + float dx = partner_scan[j].x - ego->sim_x; + float dy = partner_scan[j].y - ego->sim_y; + float dz = partner_scan[j].z - ego->sim_z; float dist_sq = dx * dx + dy * dy + dz * dz; - if (dist_sq > env->obs_range_partner_m * env->obs_range_partner_m) { - continue; - } - nearby_agents[nearby_count].index = index; + nearby_agents[nearby_count].index = partner_scan[j].agent_idx; nearby_agents[nearby_count].dist_sq = dist_sq; nearby_agents[nearby_count].dz = dz; - nearby_count++; + // branchless append: self and out-of-range candidates are overwritten by the next one + nearby_count += (partner_scan[j].agent_idx != ego_agent_idx) & !(dist_sq > range_sq); } int partners_written = 0; int partners_to_write = (nearby_count < env->obs_slots_partners_n) ? nearby_count : env->obs_slots_partners_n; for (int k = 0; k < partners_to_write; k++) { int nearest_idx = k; + float nearest_dist_sq = nearby_agents[k].dist_sq; for (int j = k + 1; j < nearby_count; j++) { - if (nearby_agents[j].dist_sq < nearby_agents[nearest_idx].dist_sq) { + if (nearby_agents[j].dist_sq < nearest_dist_sq) { + nearest_dist_sq = nearby_agents[j].dist_sq; nearest_idx = j; } } @@ -5051,8 +5056,10 @@ static int write_traffic_control_obs(Drive *env, Agent *ego, float *obs, int obs = (visible_count < env->obs_slots_traffic_controls_n) ? visible_count : env->obs_slots_traffic_controls_n; for (int k = 0; k < controls_to_observe; k++) { int nearest_idx = k; + float nearest_dist_sq = visible_controls[k].dist_sq; for (int j = k + 1; j < visible_count; j++) { - if (visible_controls[j].dist_sq < visible_controls[nearest_idx].dist_sq) { + if (visible_controls[j].dist_sq < nearest_dist_sq) { + nearest_dist_sq = visible_controls[j].dist_sq; nearest_idx = j; } } @@ -5098,6 +5105,13 @@ static void compute_observations(Drive *env) { int obs_per_agent = compute_observation_size(env); memset(env->observations, 0, obs_per_agent * env->active_agent_count * sizeof(float)); float (*obs_matrix)[obs_per_agent] = (float (*)[obs_per_agent]) env->observations; + PartnerScanEntry partner_scan[env->num_agents]; + for (int j = 0; j < env->num_agents; j++) { + int index = (j < env->active_agent_count) ? env->active_agent_indices[j] + : env->static_agent_indices[j - env->active_agent_count]; + Agent *agent = &env->agents[index]; + partner_scan[j] = (PartnerScanEntry) {agent->sim_x, agent->sim_y, agent->sim_z, index}; + } for (int i = 0; i < env->active_agent_count; i++) { float *obs = &obs_matrix[i][0]; int agent_idx = env->active_agent_indices[i]; @@ -5110,7 +5124,7 @@ static void compute_observations(Drive *env) { obs_idx = write_ego_obs(env, ego, obs, obs_idx); obs_idx = write_reward_target_obs(env, ego, obs, obs_idx); - obs_idx = write_partner_obs(env, ego, i, obs, obs_idx, &partner_count); + obs_idx = write_partner_obs(env, ego, agent_idx, partner_scan, obs, obs_idx, &partner_count); obs_idx = write_road_obs(env, ego, obs, obs_idx, &lane_count, &boundary_count); obs_idx = write_traffic_control_obs(env, ego, obs, obs_idx, &traffic_control_count); obs[obs_idx++] = (float) lane_count; diff --git a/scripts/kesai/9_nuPlan.sh b/scripts/kesai/9_nuPlan.sh index a72145586f..a823d24f70 100644 --- a/scripts/kesai/9_nuPlan.sh +++ b/scripts/kesai/9_nuPlan.sh @@ -56,8 +56,8 @@ export PEDESTRIAN_MIN_SIZE_M=0.8 # reads as 2.7 x 5.6 m). Unset = planner yaml / arch.py defaults (40 / 0.2). export OBS_SLOTS_PARTNERS_N=40 export EVAL_PERCEIVED_SIZE_MARGIN_M=0.2 -# overspeed conditioning alpha (unset = arch.py 0.005, the cheapest 5% of the U(0, 0.1) training range; 0.05 keeps the ego at the limit) -export REWARD_OVERSPEED=0.05 +# overspeed conditioning alpha (unset = arch.py 0.1 = max of the U(0, 0.1) training range; 0.05 kept the ego at the limit, 0.005 let it speed) +export REWARD_OVERSPEED=0.1 ABLATION_TAG="" [ "$SLIDING_GOAL_WINDOW" = "true" ] && ABLATION_TAG="${ABLATION_TAG}_slide" [ "$STARTUP_ACCEL_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jacc${STARTUP_ACCEL_JERK_CAP}" diff --git a/tests/drive/test_drive_observations_rewards.c b/tests/drive/test_drive_observations_rewards.c index 2ca5f6e099..09a7c7711f 100644 --- a/tests/drive/test_drive_observations_rewards.c +++ b/tests/drive/test_drive_observations_rewards.c @@ -276,19 +276,23 @@ static int test_partner_obs_relative_velocity(void) { update_agent_speed(&agents[0]); update_agent_speed(&agents[1]); env.agents = agents; + PartnerScanEntry partner_scan[2] = { + {agents[0].sim_x, agents[0].sim_y, agents[0].sim_z, 0}, + {agents[1].sim_x, agents[1].sim_y, agents[1].sim_z, 1}, + }; float obs[2 * (PARTNER_FEATURES + PARTNER_RELATIVE_VELOCITY_FEATURES)]; int partner_count = 0; env.obs_partner_relative_velocity = 0; memset(obs, 0, sizeof(obs)); - int end_idx = write_partner_obs(&env, &agents[0], 0, obs, 0, &partner_count); + int end_idx = write_partner_obs(&env, &agents[0], 0, partner_scan, obs, 0, &partner_count); EXPECT_EQ_INT(partner_count, 1); EXPECT_EQ_INT(end_idx, 2 * PARTNER_FEATURES); EXPECT_NEAR(obs[7], 0.5f, 1e-5f); env.obs_partner_relative_velocity = 1; memset(obs, 0, sizeof(obs)); - end_idx = write_partner_obs(&env, &agents[0], 0, obs, 0, &partner_count); + end_idx = write_partner_obs(&env, &agents[0], 0, partner_scan, obs, 0, &partner_count); EXPECT_EQ_INT(end_idx, 2 * (PARTNER_FEATURES + PARTNER_RELATIVE_VELOCITY_FEATURES)); EXPECT_NEAR(obs[7], 0.5f, 1e-5f); EXPECT_NEAR(obs[PARTNER_FEATURES], -1.0f, 1e-5f); From 5cf63db44e719a24204a165a50056dd6a041330c Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 17:44:44 +0200 Subject: [PATCH 016/123] fix alpasim eval --- scripts/kesai/12_alpasim.sh | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index dcc2882388..2e56324609 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -14,14 +14,14 @@ # scenes converted to PufferDrive bins; AlpaSim renders, simulates the ego and replays the logged traffic. # # One-time setup: alpasim-training at ALPAGYM_ROOT (`uv sync --all-packages`), the AlpaSim fork at ALPASIM_ROOT, the -# NuRec scenes and converted bins under NUREC_ROOT (alpasim-training README steps 3-4; all-usdzs/ must hold real files), +# NuRec scenes and bins with 130 km/h lane limits under NUREC_ROOT (alpasim-training README steps 3-4; all-usdzs/ must hold real files), # and the AlpaSim base image archived to ALPASIM_IMAGE_TAR (REPO=$ALPASIM_ROOT OUT_DIR= sbatch # --output= --error= $ALPAGYM_ROOT/scripts/build_alpasim_image.sbatch). # # Overridable env: RUN_DIR, CKPT (default RUN_DIR/final_model.pt, else the newest RUN_DIR/models/model_*.pt), PD, # ALPAGYM_ROOT, ALPASIM_ROOT, NUREC_ROOT, BINS, USDZ_DIR, ALPASIM_IMAGE_TAR, TOPOLOGY (8gpu_64rollouts = whole node; # 1gpu for a quick check), PARALLEL (scenes in flight: 32 on 8gpu, at most 4 on 1gpu), N_SCENES (0 = every scene that -# passed the conversion checks), SCENE_LIST (file of scene ids), GOAL_MODE (gt | route), CAM_W/CAM_H (16:9 render size; +# passed the conversion checks), SCENE_LIST (file of scene ids), GOAL_MODE (gt_time: | gt | route), CAM_W/CAM_H (16:9 render size; # the teacher never sees the cameras), RENDER_VIDEO (1 = one mp4 per scene), SIM_TIMEOUT_S (per batch of PARALLEL scenes). set -u @@ -29,9 +29,9 @@ export PD=${PD:-/home/bjaeger/PufferDrive} ALPAGYM_ROOT=${ALPAGYM_ROOT:-/home/bjaeger/alpasim-training} export ALPASIM_ROOT=${ALPASIM_ROOT:-/home/bjaeger/alpasim} NUREC_ROOT=${NUREC_ROOT:-/home/shared/data/nurec} -BINS=${BINS:-$NUREC_ROOT/bins} +BINS=${BINS:-$NUREC_ROOT/bins_130kmh} USDZ_DIR=${USDZ_DIR:-$NUREC_ROOT/all-usdzs} -RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0043_1000} +RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} TOPOLOGY=${TOPOLOGY:-8gpu_64rollouts} case "$TOPOLOGY" in 8gpu*) PARALLEL=${PARALLEL:-32} ;; @@ -39,7 +39,7 @@ case "$TOPOLOGY" in esac N_SCENES=${N_SCENES:-0} SCENE_LIST=${SCENE_LIST:-} -GOAL_MODE=${GOAL_MODE:-gt} +GOAL_MODE=${GOAL_MODE:-gt_time:5} CAM_W=${CAM_W:-640} CAM_H=${CAM_H:-360} RENDER_VIDEO=${RENDER_VIDEO:-1} @@ -106,7 +106,7 @@ EOF [ ${#SCENES[@]} -gt 0 ] || { echo "no scenes to evaluate"; exit 1; } # results live in the model's own eval folder, next to the PufferDrive benchmark evals -OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE}_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} +OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT" { echo "checkpoint $CKPT"; echo "scenes ${#SCENES[@]}"; echo "topology $TOPOLOGY parallel $PARALLEL goal_mode $GOAL_MODE" @@ -147,7 +147,7 @@ fi # the teacher is an external driver, but the 8gpu topologies re-assert driver.skip=false and abort at startup OVERRIDES="wizard.baseport=$BASEPORT runtime.endpoints.driver.skip=true" -[ "$RENDER_VIDEO" = "1" ] || OVERRIDES="$OVERRIDES eval.video.render_video=false" +[ "$RENDER_VIDEO" = "1" ] && RENDER_VIDEO_FLAG=true || RENDER_VIDEO_FLAG=false echo "${#SCENES[@]} scenes, $TOPOLOGY with $PARALLEL in flight; full log: $OUT/evaluate.log" cd "$ALPAGYM_ROOT" || exit 1 "$ALPAGYM_PY" -m alpagym_host.evaluate \ @@ -155,6 +155,7 @@ cd "$ALPAGYM_ROOT" || exit 1 --teacher_checkpoint "$CKPT" \ --teacher_bins_dir "$BINS" \ --teacher_goal_mode "$GOAL_MODE" \ + --render_video "$RENDER_VIDEO_FLAG" \ --local_scene_dir "$USDZ_DIR" \ --scene_ids "${SCENES[@]}" \ --topology "$TOPOLOGY" \ From 0d6fd7e1abf8d0c55aa8025f0ef1955e9d8cdaff Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 17:54:13 +0200 Subject: [PATCH 017/123] update alpasim --- scripts/kesai/12_alpasim.sh | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 2e56324609..102e098943 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -14,30 +14,32 @@ # scenes converted to PufferDrive bins; AlpaSim renders, simulates the ego and replays the logged traffic. # # One-time setup: alpasim-training at ALPAGYM_ROOT (`uv sync --all-packages`), the AlpaSim fork at ALPASIM_ROOT, the -# NuRec scenes and bins with 130 km/h lane limits under NUREC_ROOT (alpasim-training README steps 3-4; all-usdzs/ must hold real files), -# and the AlpaSim base image archived to ALPASIM_IMAGE_TAR (REPO=$ALPASIM_ROOT OUT_DIR= sbatch +# NuRec scenes under NUREC_ROOT (all-usdzs/ must hold real files), bins with 130 km/h lane limits at BINS (alpasim-training +# README step 4; default: Damiano's conversion), and the AlpaSim base image archived to ALPASIM_IMAGE_TAR (default: his too; +# REPO=$ALPASIM_ROOT OUT_DIR= sbatch # --output= --error= $ALPAGYM_ROOT/scripts/build_alpasim_image.sbatch). # # Overridable env: RUN_DIR, CKPT (default RUN_DIR/final_model.pt, else the newest RUN_DIR/models/model_*.pt), PD, -# ALPAGYM_ROOT, ALPASIM_ROOT, NUREC_ROOT, BINS, USDZ_DIR, ALPASIM_IMAGE_TAR, TOPOLOGY (8gpu_64rollouts = whole node; -# 1gpu for a quick check), PARALLEL (scenes in flight: 32 on 8gpu, at most 4 on 1gpu), N_SCENES (0 = every scene that -# passed the conversion checks), SCENE_LIST (file of scene ids), GOAL_MODE (gt_time: | gt | route), CAM_W/CAM_H (16:9 render size; -# the teacher never sees the cameras), RENDER_VIDEO (1 = one mp4 per scene), SIM_TIMEOUT_S (per batch of PARALLEL scenes). +# ALPAGYM_ROOT, ALPASIM_ROOT, NUREC_ROOT, BINS, USDZ_DIR, ALPASIM_IMAGE_TAR, PARALLEL (scenes in flight: 32 on 8gpu, at most +# 4 on 1gpu), SCENE_LIST (file of scene ids), GOAL_MODE (gt_time: | gt | route), CAM_W/CAM_H (16:9 render size; the teacher +# never sees the cameras), RENDER_VIDEO (1 = one mp4 per scene), SIM_TIMEOUT_S (per batch of PARALLEL scenes). set -u +# quick check: TOPOLOGY=1gpu N_SCENES=2; full run: TOPOLOGY=8gpu_64rollouts (whole node) N_SCENES=0 (every scene that passed the checks) +TOPOLOGY=1gpu +N_SCENES=2 + export PD=${PD:-/home/bjaeger/PufferDrive} ALPAGYM_ROOT=${ALPAGYM_ROOT:-/home/bjaeger/alpasim-training} export ALPASIM_ROOT=${ALPASIM_ROOT:-/home/bjaeger/alpasim} NUREC_ROOT=${NUREC_ROOT:-/home/shared/data/nurec} -BINS=${BINS:-$NUREC_ROOT/bins_130kmh} +BINS=${BINS:-/home/ddacol/code/alpasim-training/data/distill/bins_130kmh} USDZ_DIR=${USDZ_DIR:-$NUREC_ROOT/all-usdzs} RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} -TOPOLOGY=${TOPOLOGY:-8gpu_64rollouts} case "$TOPOLOGY" in 8gpu*) PARALLEL=${PARALLEL:-32} ;; *) PARALLEL=${PARALLEL:-4} ;; esac -N_SCENES=${N_SCENES:-0} SCENE_LIST=${SCENE_LIST:-} GOAL_MODE=${GOAL_MODE:-gt_time:5} CAM_W=${CAM_W:-640} @@ -71,7 +73,7 @@ case "$TOPOLOGY" in esac ALPASIM_VERSION=$(grep -m1 '^version' "$ALPASIM_ROOT/pyproject.toml" | sed -E 's/.*"(.*)".*/\1/') BASE_TAG=alpasim-base:$ALPASIM_VERSION -ALPASIM_IMAGE_TAR=${ALPASIM_IMAGE_TAR:-/home/bjaeger/docker_images/alpasim-base-$ALPASIM_VERSION.tar.zst} +ALPASIM_IMAGE_TAR=${ALPASIM_IMAGE_TAR:-/home/ddacol/code/alpasim-training/data/images/alpasim-base-$ALPASIM_VERSION.tar.zst} NRE_TAG=$(grep -m1 -oE 'nvcr.io/nvidia/nre/[^[:space:]"]+' "$ALPASIM_ROOT/src/wizard/configs/base_config.yaml") [ -n "$NRE_TAG" ] || { echo "no NRE renderer image in $ALPASIM_ROOT/src/wizard/configs/base_config.yaml"; exit 1; } echo "Evaluating checkpoint: $CKPT" From 4954e2daa2c13c4e84a00f83cbf037248c4dab7c Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 18:00:38 +0200 Subject: [PATCH 018/123] reeval model with speed limit compliance. --- scripts/kesai/7_eval_8node_slurm.sh | 63 ++++++++++++++--------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/scripts/kesai/7_eval_8node_slurm.sh b/scripts/kesai/7_eval_8node_slurm.sh index 58a71cf93d..15d7f69b98 100644 --- a/scripts/kesai/7_eval_8node_slurm.sh +++ b/scripts/kesai/7_eval_8node_slurm.sh @@ -1,6 +1,6 @@ #!/bin/bash -#SBATCH --job-name eval_puffer_8node -#SBATCH --nodes 8 +#SBATCH --job-name eval_puffer_2node +#SBATCH --nodes 2 #SBATCH --ntasks-per-node 1 #SBATCH --gres gpu:8 #SBATCH --cpus-per-task 144 @@ -10,13 +10,21 @@ #SBATCH --error /home/bjaeger/PufferDrive/experiments/logs/eval_%a_%A.err #SBATCH --partition dev +# Eval-only twin of the post-training eval in 3_train_64GPU_multinode_slurm.sh: same CARLA +# self-play benchmark and follow-up co-sim jobs, on 2 nodes (16 GPUs) instead of 4. echo "START TIME: $(date)" start=$(date +%s) -export RUN_NAME=k_scaled_0038_1000 +export SEED=1000 + +export RUN_NAME=k_scaled_0045_${SEED} echo ${RUN_NAME} -export MODEL_PATH=/home/bjaeger/PufferDrive/experiments/${RUN_NAME}/final_model.pt +export DATA_DIR=/home/bjaeger/PufferDrive/experiments/${RUN_NAME} +echo ${DATA_DIR} + +export FINAL_MODEL_NAME=final_model.pt +export MODEL_PATH=${DATA_DIR}/${FINAL_MODEL_NAME} echo ${MODEL_PATH} # Thread limit limits CPU thrashing across worker environments @@ -34,15 +42,14 @@ if [ ! -f ${MODEL_PATH} ]; then exit 1 fi -# parallel_eval places one shard per allocated node via srun, so each shard's 64 +# parallel_eval places one shard per allocated node via srun, so each shard's 128 # env workers get a full node's cores instead of sharing the batch host. +echo "Evaluating ${MODEL_PATH}" .venv/bin/python scripts/parallel_eval.py carla \ - --total-scenarios 5000 \ - --num-nodes 8 \ + --total-scenarios 40000 \ + --num-nodes 2 \ env.map_dir=/home/bjaeger/PufferDrive/pufferlib/resources/drive/binaries/carla \ - vec.num_envs=64 \ - eval.render_filter=all_infractions \ - eval.capture_observations=true \ + vec.num_envs=128 \ eval.reward_comfort=0.0 \ eval.reward_lane_center=0.0075 \ env.eval_perceived_size_margin_m=0.2 \ @@ -52,33 +59,25 @@ fi env.disable_stop_sign_infractions=1 \ env.traffic_light_junction_phases=0 \ env.eval_standstill_jerk_deadband_mps3=1.5 \ - eval.output_name=${RUN_NAME}_random_tl2 \ - load_model_path=${MODEL_PATH} \ - wandb=True - -# eval.obs_slots_partners_n=40 \ -# eval.goal_regen_mode=rolling \ -# env.max_speed_mps=13.33 \ - -# No srun: single-node eval, run here on the batch host. -python -m pufferlib.pufferl eval puffer_drive nuplan_multi \ - eval.map_dir=/home/shared/data/nuplan/PufferDrive_broken_noconn \ - vec.num_envs=64 \ - eval.max_sdc_replay_workers=64 \ - eval.num_agents=300 \ - eval.reward_comfort=0.0 \ - eval.reward_lane_center=0.0075 \ - env.eval_perceived_size_margin_m=0.0 \ - eval.disable_red_light_infractions=1 \ - eval.disable_stop_sign_infractions=1 \ - env.eval_standstill_jerk_deadband_mps3=1.5 \ eval.render_filter=all_infractions \ eval.capture_observations=true \ - eval.output_name=${RUN_NAME}_random_tl2 \ + eval.output_name=${RUN_NAME} \ load_model_path=${MODEL_PATH} \ wandb=True -# env.max_speed_mps=13.33 \ +# nuPlan (reactive and non-reactive), longest6 and AlpaSim evals need one 8-GPU node each: submit them as their own jobs so this 2-node allocation ends now. +echo "CARLA eval done, submitting nuPlan reactive eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/9_nuPlan.sh \ + || echo "nuPlan eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/9_nuPlan.sh" +echo "Submitting nuPlan non-reactive eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh \ + || echo "nuPlan non-reactive eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh" +echo "Submitting longest6 eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6.sh \ + || echo "longest6 eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6.sh" +echo "Submitting AlpaSim eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh \ + || echo "AlpaSim eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh" end=$(date +%s) runtime=$((end-start)) From 51e861cfef69e3aa88ced55c956c78644d7555fb Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Wed, 7 Oct 2026 18:29:46 +0200 Subject: [PATCH 019/123] k_scaled_0046 500B + optimizations --- scripts/kesai/3_train_64GPU_multinode_slurm.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 878b367901..5aaff4037a 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -22,7 +22,7 @@ start=$(date +%s) export SEED=1000 -export RUN_NAME=k_scaled_0045_${SEED} +export RUN_NAME=k_scaled_0046_${SEED} echo ${RUN_NAME} export DATA_DIR=/home/bjaeger/PufferDrive/experiments/${RUN_NAME} @@ -63,7 +63,7 @@ srun torchrun \ env.num_maps=128 \ train.name=${RUN_NAME} \ run_name=${RUN_NAME} \ - train.total_timesteps=1000000000000 \ + train.total_timesteps=500000000000 \ vec.num_envs=32 \ env.preload_map_cache=True \ train.max_minibatch_size=131072 \ From 243f05e79083233e8c4d98cf7b87890d9ad16a35 Mon Sep 17 00:00:00 2001 From: jaeger Date: Wed, 7 Oct 2026 23:26:16 +0200 Subject: [PATCH 020/123] fix leaderboard stop sign and red light bugs --- pufferlib/ocean/cosim/carla/shadow_ego.py | 12 +++ pufferlib/ocean/cosim/carla_bridge.py | 41 ++++++++ pufferlib/ocean/drive/drive.h | 46 ++++++++- pufferlib/ocean/drive/drive.py | 8 ++ pufferlib/ocean/env_binding.h | 37 +++++++ scripts/eval/analyze_carla_cosim.py | 9 +- tests/drive/test_drive_cosim_setters.c | 43 ++++++++ tests/drive/test_drive_stop_sign.c | 29 ++++++ .../unit_tests/test_carla_light_stop_lines.py | 99 +++++++++++++++++++ 9 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 tests/unit_tests/test_carla_light_stop_lines.py diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py index ba80e5c091..2e8e9d7ec1 100644 --- a/pufferlib/ocean/cosim/carla/shadow_ego.py +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -366,6 +366,18 @@ def init_route(self, route): lights = route["lights"] light_map, bin_num_traffic = cb.map_lights_to_bin(lights, self.transform, self.town_bin) + light_line_indices, light_lines = cb.light_stop_line_overrides( + lights, light_map, self.transform, self.town_bin + ) + if len(light_line_indices): + moved_centers = 0.5 * (light_lines[:, 0:2] + light_lines[:, 3:5]) + max_shift_m = float(np.hypot(*(moved_centers - self.stop_line_centers[light_line_indices]).T).max()) + self.env.set_traffic_light_lines(light_line_indices, light_lines) + self.stop_line_centers[light_line_indices] = moved_centers + print( + f"[puffer_agent] light stop lines: {len(light_line_indices)} moved onto CARLA's stop waypoints " + f"(max {max_shift_m:.1f} m)" + ) # the shadow env runs on CARLA's trigger volumes: the bin's exported stop lines sit up to 9 m off and miss a few self._stop_sign_lines, stop_sign_headings = cb.stop_sign_lines(route["stop_signs"], self.transform) self.num_traffic = self.env.set_stop_signs(self._stop_sign_lines, stop_sign_headings) diff --git a/pufferlib/ocean/cosim/carla_bridge.py b/pufferlib/ocean/cosim/carla_bridge.py index 3b4828a622..aaa5ddb7a7 100644 --- a/pufferlib/ocean/cosim/carla_bridge.py +++ b/pufferlib/ocean/cosim/carla_bridge.py @@ -283,6 +283,7 @@ def carla_light_to_puffer(state) -> int: LIGHT_LANE_ALIGN_COS_MIN = ( 0.5 # forward fallback: the lane must run along the waypoint's heading (no crossing connectors) ) +LIGHT_STOP_LINE_TOLERANCE_M = 1.0 # exported bin lines within this of CARLA's stop waypoint stay (median 0.5 m) JUNCTION_CLUSTER_M = 60.0 # stop lines this close are treated as one junction (outlier repair, see below) # An assigned light farther from its junction cluster-mates' lights than this # multiple of the cluster-mates' own spread is a wrong-junction match. @@ -522,6 +523,46 @@ def controlling_elements_near(px, py, max_dist_m, heading=None): return mapping, len(data["traffic"]) +def light_stop_line_overrides(light_geometry, mapping, transform, town_bin): + """(element indices (K,), lines (K, 6)) for the bin light elements whose exported stop line sits more than + LIGHT_STOP_LINE_TOLERANCE_M along the lane from CARLA's stop waypoints, the line RunningRedLightTest + scores against (Town04: four approaches whose bin line is 8 m past the waypoint, so an ego stopping at + it has already run the light). Each line is the bin's own translated along the element's travel + direction onto its most upstream waypoint (a line spanning several lanes then precedes every lane's + CARLA line); a light's waypoints go to the nearest of its elements.""" + import data_utils.mirror_map_bin as mbin + + bin_traffic = mbin.read_bin(Path(town_bin))["traffic"] + along_offsets_by_element = {} + for light, elements in zip(light_geometry, mapping): + if not elements: + continue + mids = { + j: ( + 0.5 * (bin_traffic[j]["stop_line"][0] + bin_traffic[j]["stop_line"][3]), + 0.5 * (bin_traffic[j]["stop_line"][1] + bin_traffic[j]["stop_line"][4]), + ) + for j in elements + } + for waypoint in light["stop_waypoints"]: + wx, wy = transform.loc_to_bin(waypoint["x"], waypoint["y"]) + j = min(elements, key=lambda e: math.hypot(mids[e][0] - wx, mids[e][1] - wy)) + heading = bin_traffic[j]["heading"] + along = (wx - mids[j][0]) * math.cos(heading) + (wy - mids[j][1]) * math.sin(heading) + along_offsets_by_element.setdefault(j, []).append(along) + indices, lines = [], [] + for j in sorted(along_offsets_by_element): + shift_m = min(along_offsets_by_element[j]) + if abs(shift_m) <= LIGHT_STOP_LINE_TOLERANCE_M: + continue + heading = bin_traffic[j]["heading"] + dx, dy = shift_m * math.cos(heading), shift_m * math.sin(heading) + x1, y1, z1, x2, y2, z2 = bin_traffic[j]["stop_line"] + indices.append(j) + lines.append([x1 + dx, y1 + dy, z1, x2 + dx, y2 + dy, z2]) + return np.array(indices, np.int32), np.array(lines, np.float32).reshape(-1, 6) + + def stop_sign_geometry_from_carla(world, carla_map): """Plain-data geometry of every CARLA `traffic.stop` actor for stop_sign_lines (CARLA frame): id, the trigger volume's centre/extent/yaw_deg and `lane` = [x, y, z, yaw_deg] of the driving-lane waypoint diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index 9039476d77..8cee3b5604 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -1954,14 +1954,17 @@ static bool update_stop_sign_state(Drive *env, int agent_idx) { agent->stop_sign_target_idx = -1; agent->stop_sign_stop_completed = 0; } - if (agent->stop_sign_target_idx < 0) { + // an uncompleted target keeps competing: a curved approach latches the neighbour lane's sign first + if (agent->stop_sign_target_idx < 0 || !agent->stop_sign_stop_completed) { int standstill_idx = find_stop_sign_standstill(env, agent); if (standstill_idx >= 0) { agent->stop_sign_standstill_idx = standstill_idx; } - agent->stop_sign_target_idx = find_stop_sign_target(env, agent); - agent->stop_sign_stop_completed - = agent->stop_sign_target_idx >= 0 && agent->stop_sign_target_idx == agent->stop_sign_standstill_idx; + int target_idx = find_stop_sign_target(env, agent); + if (target_idx >= 0 && target_idx != agent->stop_sign_target_idx) { + agent->stop_sign_target_idx = target_idx; + agent->stop_sign_stop_completed = target_idx == agent->stop_sign_standstill_idx; + } if (agent->stop_sign_target_idx < 0) { return false; } @@ -4194,6 +4197,41 @@ int c_set_stop_signs(Drive *env, int count, const float *lines, const float *hea return 0; } +// Co-sim: move traffic-light stop lines onto the external sim's own (the lines its red-light judge scores against), +// indices = element index per line, lines = count x [x1, y1, z1, x2, y2, z2] in world coordinates. +int c_set_traffic_light_lines(Drive *env, int count, const int *indices, const float *lines) { + if (count < 0 || (count > 0 && (indices == NULL || lines == NULL))) { + return -1; + } + for (int k = 0; k < count; k++) { + int idx = indices[k]; + if (idx < 0 || idx >= env->num_traffic_elements + || env->traffic_elements[idx].type != TRAFFIC_CONTROL_TYPE_TRAFFIC_LIGHT) { + return -1; + } + for (int v = 0; v < 6; v++) { + if (!isfinite(lines[k * 6 + v])) { + return -1; + } + } + float line_dx = lines[k * 6 + 3] - lines[k * 6]; + float line_dy = lines[k * 6 + 4] - lines[k * 6 + 1]; + if (line_dx * line_dx + line_dy * line_dy <= 0.0f) { + return -1; + } + } + for (int k = 0; k < count; k++) { + TrafficControlElement *tc = &env->traffic_elements[indices[k]]; + tc->stop_line[0] = lines[k * 6] - env->world_mean_x; + tc->stop_line[1] = lines[k * 6 + 1] - env->world_mean_y; + tc->stop_line[2] = lines[k * 6 + 2]; + tc->stop_line[3] = lines[k * 6 + 3] - env->world_mean_x; + tc->stop_line[4] = lines[k * 6 + 4] - env->world_mean_y; + tc->stop_line[5] = lines[k * 6 + 5]; + } + return 0; +} + void c_get_agent_goal_progress(Drive *env, int agent_idx, int *current_goal_idx_out, int *goal_count_out) { Agent *agent = &env->agents[agent_idx]; *current_goal_idx_out = agent->current_goal_idx; diff --git a/pufferlib/ocean/drive/drive.py b/pufferlib/ocean/drive/drive.py index 4540c865a8..e49c07b34c 100644 --- a/pufferlib/ocean/drive/drive.py +++ b/pufferlib/ocean/drive/drive.py @@ -896,6 +896,14 @@ def set_stop_signs(self, lines, headings): headings = np.ascontiguousarray(np.asarray(headings, dtype=np.float32).reshape(-1)) return int(binding.vec_set_stop_signs(self.c_envs, lines, headings)) + def set_traffic_light_lines(self, indices, lines): + """move traffic-light elements' stop lines onto the external sim's own + (co-sim): indices (K,) element indices, lines (K, 6) world-frame + endpoints [x1, y1, z1, x2, y2, z2].""" + indices = np.ascontiguousarray(np.asarray(indices, dtype=np.int32).reshape(-1)) + lines = np.ascontiguousarray(np.asarray(lines, dtype=np.float32).reshape(-1, 6)) + binding.vec_set_traffic_light_lines(self.c_envs, indices, lines) + def set_agent_goals(self, agent_idx, gx, gy, gz, gdir_x=None, gdir_y=None): """set an agent's goal waypoints (e.g. the ego's route) in world coords (C subtracts world_mean). Each waypoint is snapped to its diff --git a/pufferlib/ocean/env_binding.h b/pufferlib/ocean/env_binding.h index e740b69f29..98bc012670 100644 --- a/pufferlib/ocean/env_binding.h +++ b/pufferlib/ocean/env_binding.h @@ -1174,6 +1174,39 @@ static PyObject *vec_set_stop_signs(PyObject *self, PyObject *args) { return PyLong_FromLong(drive->num_traffic_elements); } +static PyObject *vec_set_traffic_light_lines(PyObject *self, PyObject *args) { + if (PyTuple_Size(args) != 3) { + PyErr_SetString( + PyExc_TypeError, "vec_set_traffic_light_lines requires 3 arguments (vec_env, indices, lines)"); + return NULL; + } + VecEnv *vec = unpack_vecenv(args); + if (!vec) { + return NULL; + } + PyObject *indices_arr = PyTuple_GetItem(args, 1); + PyObject *lines_arr = PyTuple_GetItem(args, 2); + if (!PyArray_Check(indices_arr) || !PyArray_Check(lines_arr)) { + PyErr_SetString(PyExc_TypeError, "vec_set_traffic_light_lines: indices and lines must be NumPy arrays"); + return NULL; + } + int count = (int) PyArray_SIZE((PyArrayObject *) indices_arr); + if ((int) PyArray_SIZE((PyArrayObject *) lines_arr) != count * 6) { + PyErr_SetString(PyExc_ValueError, "vec_set_traffic_light_lines: lines must hold 6 floats per index"); + return NULL; + } + Drive *drive = (Drive *) vec->envs[0]; + const int *indices = (const int *) PyArray_DATA((PyArrayObject *) indices_arr); + const float *lines = (const float *) PyArray_DATA((PyArrayObject *) lines_arr); + if (c_set_traffic_light_lines(drive, count, indices, lines) != 0) { + PyErr_SetString( + PyExc_ValueError, + "vec_set_traffic_light_lines: index is not a traffic light, non-finite value or zero-length line"); + return NULL; + } + Py_RETURN_NONE; +} + static PyObject *vec_get_agent_goal_progress(PyObject *self, PyObject *args) { if (PyTuple_Size(args) != 2) { PyErr_SetString(PyExc_TypeError, "vec_get_agent_goal_progress requires 2 arguments"); @@ -1521,6 +1554,10 @@ static PyMethodDef methods[] vec_set_stop_signs, METH_VARARGS, "Replace the stop signs with the external sim's own (co-sim)"}, + {"vec_set_traffic_light_lines", + vec_set_traffic_light_lines, + METH_VARARGS, + "Move traffic-light stop lines onto the external sim's own (co-sim)"}, {"vec_set_agent_goals", vec_set_agent_goals, METH_VARARGS, "Set an agent's goal waypoints (co-sim)"}, {"vec_get_agent_goal_progress", vec_get_agent_goal_progress, diff --git a/scripts/eval/analyze_carla_cosim.py b/scripts/eval/analyze_carla_cosim.py index 7e8a008c22..e45cf29719 100644 --- a/scripts/eval/analyze_carla_cosim.py +++ b/scripts/eval/analyze_carla_cosim.py @@ -1,9 +1,10 @@ """Failure analysis + visualizations for CARLA leaderboard runs of the PufferDrive co-sim agent (pufferlib/ocean/cosim/carla/leaderboard_agent.py run with COSIM_WORLD_LOG and COSIM_TELEMETRY set). Same report shape as analyze_nuplan_cosim.py: score table worst first, failure-category counts, -per-route diagnosis, six-frame top-down strip + speed plot + chase-cam / top-down video. +per-route diagnosis, six-frame top-down strip + speed plot + chase-cam video. The matplotlib top-down mp4 +is opt-in (--video): it costs about a minute per 1000 logged steps, the obs replay pages cover the same ground. -usage: python scripts/eval/analyze_carla_cosim.py [ ...] [--max-inline N] [--no-video] +usage: python scripts/eval/analyze_carla_cosim.py [ ...] [--max-inline N] [--video] [--obs-html-dir D] holds result.json (leaderboard_evaluator --checkpoint), world_log/*.npz, telemetry/*.csv and @@ -367,7 +368,7 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("paths", nargs="+", help="run dirs followed by the report dir") parser.add_argument("--max-inline", type=int, default=12) - parser.add_argument("--no-video", action="store_true") + parser.add_argument("--video", action="store_true", help="also render the matplotlib top-down mp4 per route (slow)") parser.add_argument("--obs-html-dir", help="the run's obs replay gallery dir, linked per route") args = parser.parse_args() run_dirs, report_dir = args.paths[:-1], Path(args.paths[-1]) @@ -377,7 +378,7 @@ def main(): rows = [] for run_dir in run_dirs: for record, log_path, chase_mp4, tag in collect_routes(run_dir): - rows.append(render_route(record, RouteLog(log_path), chase_mp4, report_dir, tag, not args.no_video)) + rows.append(render_route(record, RouteLog(log_path), chase_mp4, report_dir, tag, args.video)) print(f"{tag}: {rows[-1]['categories']} | {rows[-1]['diagnosis']}") df = pd.DataFrame(rows).sort_values("driving_score") df.to_csv(report_dir / "routes.csv", index=False) diff --git a/tests/drive/test_drive_cosim_setters.c b/tests/drive/test_drive_cosim_setters.c index cd64cfa8a2..97e757a236 100644 --- a/tests/drive/test_drive_cosim_setters.c +++ b/tests/drive/test_drive_cosim_setters.c @@ -359,6 +359,48 @@ static int test_set_stop_signs_rejects_bad_input_untouched(void) { return 0; } +// --------------------------------------------------------------------------- +// c_set_traffic_light_lines +// --------------------------------------------------------------------------- + +static int test_set_traffic_light_lines_moves_lights_only_and_rejects_bad_input(void) { + // Only the addressed light elements move (world_mean subtracted like every setter input), their state + // schedule stays; a stop-sign index, an out-of-range index, a non-finite coordinate or a zero-length + // line fails the whole call (-1) with every line untouched. + int light_states[2] = {1, 1}; + TrafficControlElement elements[2]; + elements[0] = make_traffic_element(TRAFFIC_CONTROL_TYPE_TRAFFIC_LIGHT, 2, light_states); + elements[1] = make_traffic_element(TRAFFIC_CONTROL_TYPE_STOP_SIGN, 0, NULL); + Drive env = {0}; + env.traffic_elements = elements; + env.num_traffic_elements = 2; + env.world_mean_x = 100.0f; + env.world_mean_y = -50.0f; + + int idx[1] = {0}; + float line[6] = {110.0f, -52.0f, 0.5f, 110.0f, -48.0f, 0.5f}; + EXPECT_EQ_INT(c_set_traffic_light_lines(&env, 1, idx, line), 0); + EXPECT_NEAR(elements[0].stop_line[0], 10.0f, 1e-6f); + EXPECT_NEAR(elements[0].stop_line[1], -2.0f, 1e-6f); + EXPECT_NEAR(elements[0].stop_line[2], 0.5f, 1e-6f); + EXPECT_NEAR(elements[0].stop_line[3], 10.0f, 1e-6f); + EXPECT_NEAR(elements[0].stop_line[4], 2.0f, 1e-6f); + EXPECT_EQ_INT(elements[0].states[0], 1); + + int stop_sign_idx[1] = {1}; + EXPECT_EQ_INT(c_set_traffic_light_lines(&env, 1, stop_sign_idx, line), -1); + EXPECT_NEAR(elements[1].stop_line[0], 0.0f, 1e-6f); + int bad_idx[1] = {7}; + EXPECT_EQ_INT(c_set_traffic_light_lines(&env, 1, bad_idx, line), -1); + float degenerate[6] = {1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f}; + EXPECT_EQ_INT(c_set_traffic_light_lines(&env, 1, idx, degenerate), -1); + float nan_line[6] = {0.0f, NAN, 0.0f, 0.0f, 4.0f, 0.0f}; + EXPECT_EQ_INT(c_set_traffic_light_lines(&env, 1, idx, nan_line), -1); + EXPECT_NEAR(elements[0].stop_line[0], 10.0f, 1e-6f); + EXPECT_EQ_INT(c_set_traffic_light_lines(&env, 0, NULL, NULL), 0); + return 0; +} + int main(void) { int failures = 0; RUN_TEST(test_set_agent_sizes_updates_dimensions_radius_and_wheelbase); @@ -367,6 +409,7 @@ int main(void) { RUN_TEST(test_set_traffic_light_states_rejects_out_of_range_timestep_and_state); RUN_TEST(test_set_stop_signs_retires_map_signs_and_appends_external_ones); RUN_TEST(test_set_stop_signs_rejects_bad_input_untouched); + RUN_TEST(test_set_traffic_light_lines_moves_lights_only_and_rejects_bad_input); RUN_TEST(test_set_agent_states_teleport_resets_prev_pose); RUN_TEST(test_set_agent_states_seconds_stopped_injects_or_preserves); RUN_TEST(test_set_agent_states_steering_is_state_below_1mps); diff --git a/tests/drive/test_drive_stop_sign.c b/tests/drive/test_drive_stop_sign.c index 472ccff4a2..4baff302e2 100644 --- a/tests/drive/test_drive_stop_sign.c +++ b/tests/drive/test_drive_stop_sign.c @@ -294,6 +294,34 @@ static int test_standstill_clears_beyond_proximity(void) { return 0; } +// Two lanes' signs 4 m apart: 19 m out on a curved approach the agent sits in the neighbour line's frame +// (|across| 0.1 m) and latches it; once it is in its own lane the own sign must take over and complete. +static int test_neighbour_lane_target_yields_to_own_lane_sign(void) { + TrafficControlElement tcs[2]; + tcs[0] = drive_test_stop_sign(); + tcs[0].stop_line[1] = 2.0f; + tcs[0].stop_line[4] = 6.0f; + tcs[1] = drive_test_stop_sign(); + Agent agent = drive_test_stop_agent(-20.0f, 3.9f, 0.0f); + Drive env = drive_test_stop_env(tcs, &agent); + env.num_traffic_elements = 2; + drive_test_step_to(&agent, -19.0f, 3.9f, 8.0f); + EXPECT_FALSE(update_stop_sign_state(&env, 0)); + EXPECT_EQ_INT(agent.stop_sign_target_idx, 0); + drive_test_step_to(&agent, -10.0f, 0.5f, 8.0f); + EXPECT_FALSE(update_stop_sign_state(&env, 0)); + EXPECT_EQ_INT(agent.stop_sign_target_idx, 1); + EXPECT_EQ_INT(agent.stop_sign_stop_completed, 0); + drive_test_step_to(&agent, -2.5f, 0.0f, 0.0f); + EXPECT_FALSE(update_stop_sign_state(&env, 0)); + EXPECT_EQ_INT(agent.stop_sign_target_idx, 1); + EXPECT_EQ_INT(agent.stop_sign_stop_completed, 1); + drive_test_step_to(&agent, 0.5f, 0.0f, 3.0f); + EXPECT_FALSE(update_stop_sign_state(&env, 0)); + EXPECT_EQ_INT(agent.stop_sign_last_failed_idx, -1); + return 0; +} + static int test_traffic_light_ignored(void) { TrafficControlElement tc = drive_test_stop_sign(); tc.type = TRAFFIC_CONTROL_TYPE_TRAFFIC_LIGHT; @@ -331,6 +359,7 @@ int main(void) { RUN_TEST(test_state_clears_beyond_proximity); RUN_TEST(test_spawn_inside_box_counts_as_stopped); RUN_TEST(test_standstill_before_targeting_counts); + RUN_TEST(test_neighbour_lane_target_yields_to_own_lane_sign); RUN_TEST(test_standstill_outside_box_before_targeting_does_not_count); RUN_TEST(test_standstill_clears_beyond_proximity); RUN_TEST(test_traffic_light_ignored); diff --git a/tests/unit_tests/test_carla_light_stop_lines.py b/tests/unit_tests/test_carla_light_stop_lines.py new file mode 100644 index 0000000000..ebbfc75edf --- /dev/null +++ b/tests/unit_tests/test_carla_light_stop_lines.py @@ -0,0 +1,99 @@ +"""light_stop_line_overrides moves a bin light stop line along its lane onto CARLA's stop waypoint and leaves +lines within tolerance as exported.""" + +import math +import os +import sys +from pathlib import Path + +import numpy as np + + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import data_utils.mirror_map_bin as mbin +from pufferlib.ocean.cosim import carla_bridge as cb + + +TOWN_BIN = Path(__file__).resolve().parents[2] / "pufferlib/resources/drive/binaries/carla/opendrive__Town01.bin" + + +def _line_mid(element): + line = element["stop_line"] + return 0.5 * (line[0] + line[3]), 0.5 * (line[1] + line[4]) + + +def _light_with_waypoints(transform, elements, along_offsets_m): + """One light whose stop waypoints sit along_offsets_m along the lane from each element's bin stop line.""" + waypoints = [] + for element, along_m in zip(elements, along_offsets_m): + heading = element["heading"] + mx, my = _line_mid(element) + cx, cy = transform.bin_to_loc(mx + along_m * math.cos(heading), my + along_m * math.sin(heading)) + waypoints.append( + { + "x": cx, + "y": cy, + "yaw_deg": transform.bin_heading_to_yaw(heading), + "lane_width": 3.5, + "backward": [], + "forward": [], + } + ) + return { + "id": 1, + "x": waypoints[0]["x"], + "y": waypoints[0]["y"], + "trigger_x": 0.0, + "trigger_y": 0.0, + "stop_waypoints": waypoints, + } + + +def _setup(): + traffic = mbin.read_bin(TOWN_BIN)["traffic"] + light_elements = [j for j, t in enumerate(traffic) if t["type"] == 1] + transform = cb.CarlaTransform("Town01", offset=cb.town_offset(TOWN_BIN)) + return traffic, light_elements, transform + + +def test_line_moves_onto_waypoint_eight_metres_upstream(): + traffic, light_elements, transform = _setup() + j = light_elements[0] + light = _light_with_waypoints(transform, [traffic[j]], [-8.0]) + + indices, lines = cb.light_stop_line_overrides([light], [[j]], transform, TOWN_BIN) + + assert indices.tolist() == [j] + heading = traffic[j]["heading"] + mx, my = _line_mid(traffic[j]) + moved_mid = 0.5 * (lines[0, 0:2] + lines[0, 3:5]) + assert np.allclose(moved_mid, [mx - 8.0 * math.cos(heading), my - 8.0 * math.sin(heading)], atol=1e-3) + x1, y1, z1, x2, y2, z2 = traffic[j]["stop_line"] + moved_length = float(np.hypot(lines[0, 3] - lines[0, 0], lines[0, 4] - lines[0, 1])) + assert math.isclose(moved_length, math.hypot(x2 - x1, y2 - y1), abs_tol=1e-3) + assert lines[0, 2] == np.float32(z1) and lines[0, 5] == np.float32(z2) + + +def test_line_within_tolerance_is_left_as_exported(): + traffic, light_elements, transform = _setup() + j = light_elements[1] + light = _light_with_waypoints(transform, [traffic[j]], [0.5]) + + indices, lines = cb.light_stop_line_overrides([light], [[j]], transform, TOWN_BIN) + + assert len(indices) == 0 and lines.shape == (0, 6) + + +def test_waypoints_go_to_the_nearest_of_the_lights_elements(): + traffic, light_elements, transform = _setup() + a, b = light_elements[2], light_elements[3] + light = _light_with_waypoints(transform, [traffic[a], traffic[b]], [-8.0, 0.3]) + + indices, lines = cb.light_stop_line_overrides([light], [[a, b]], transform, TOWN_BIN) + + assert indices.tolist() == [a] + heading = traffic[a]["heading"] + mx, my = _line_mid(traffic[a]) + moved_mid = 0.5 * (lines[0, 0:2] + lines[0, 3:5]) + assert np.allclose(moved_mid, [mx - 8.0 * math.cos(heading), my - 8.0 * math.sin(heading)], atol=1e-3) From 8a95cf42e70b35c2812f4a1f28ee0882d849adbe Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 13:11:34 +0200 Subject: [PATCH 021/123] change nurec folder --- scripts/kesai/12_alpasim.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 102e098943..48cdf69554 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -14,7 +14,8 @@ # scenes converted to PufferDrive bins; AlpaSim renders, simulates the ego and replays the logged traffic. # # One-time setup: alpasim-training at ALPAGYM_ROOT (`uv sync --all-packages`), the AlpaSim fork at ALPASIM_ROOT, the -# NuRec scenes under NUREC_ROOT (all-usdzs/ must hold real files), bins with 130 km/h lane limits at BINS (alpasim-training +# NuRec scenes as real files at USDZ_DIR (default: Damiano's copy; NUREC_ROOT/all-usdzs is a symlink farm), bins with 130 km/h +# lane limits at BINS (alpasim-training # README step 4; default: Damiano's conversion), and the AlpaSim base image archived to ALPASIM_IMAGE_TAR (default: his too; # REPO=$ALPASIM_ROOT OUT_DIR= sbatch # --output= --error= $ALPAGYM_ROOT/scripts/build_alpasim_image.sbatch). @@ -34,7 +35,7 @@ ALPAGYM_ROOT=${ALPAGYM_ROOT:-/home/bjaeger/alpasim-training} export ALPASIM_ROOT=${ALPASIM_ROOT:-/home/bjaeger/alpasim} NUREC_ROOT=${NUREC_ROOT:-/home/shared/data/nurec} BINS=${BINS:-/home/ddacol/code/alpasim-training/data/distill/bins_130kmh} -USDZ_DIR=${USDZ_DIR:-$NUREC_ROOT/all-usdzs} +USDZ_DIR=${USDZ_DIR:-/home/ddacol/code/alpasim-training/data/distill/usdz} RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} case "$TOPOLOGY" in 8gpu*) PARALLEL=${PARALLEL:-32} ;; From 9a161077357e9d439a5537597b1212fdffb657cd Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 13:20:48 +0200 Subject: [PATCH 022/123] add defaults --- scripts/kesai/12_alpasim.sh | 54 +++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 48cdf69554..456244ec60 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -13,48 +13,42 @@ # alpasim-training's teacher driver runs the policy in a shadow env of this checkout (PUFFERDRIVE_ROOT=$PD) on the # scenes converted to PufferDrive bins; AlpaSim renders, simulates the ego and replays the logged traffic. # -# One-time setup: alpasim-training at ALPAGYM_ROOT (`uv sync --all-packages`), the AlpaSim fork at ALPASIM_ROOT, the -# NuRec scenes as real files at USDZ_DIR (default: Damiano's copy; NUREC_ROOT/all-usdzs is a symlink farm), bins with 130 km/h -# lane limits at BINS (alpasim-training -# README step 4; default: Damiano's conversion), and the AlpaSim base image archived to ALPASIM_IMAGE_TAR (default: his too; -# REPO=$ALPASIM_ROOT OUT_DIR= sbatch +# One-time setup: alpasim-training at ALPAGYM_ROOT (`uv sync --all-packages`), the AlpaSim fork at ALPASIM_ROOT, the NuRec +# scenes as real files at USDZ_DIR, their bins with 130 km/h lane limits at BINS (alpasim-training README step 4) and the +# AlpaSim base image archived in ALPASIM_IMAGE_DIR (REPO=$ALPASIM_ROOT OUT_DIR=$ALPASIM_IMAGE_DIR sbatch # --output= --error= $ALPAGYM_ROOT/scripts/build_alpasim_image.sbatch). # -# Overridable env: RUN_DIR, CKPT (default RUN_DIR/final_model.pt, else the newest RUN_DIR/models/model_*.pt), PD, -# ALPAGYM_ROOT, ALPASIM_ROOT, NUREC_ROOT, BINS, USDZ_DIR, ALPASIM_IMAGE_TAR, PARALLEL (scenes in flight: 32 on 8gpu, at most -# 4 on 1gpu), SCENE_LIST (file of scene ids), GOAL_MODE (gt_time: | gt | route), CAM_W/CAM_H (16:9 render size; the teacher -# never sees the cameras), RENDER_VIDEO (1 = one mp4 per scene), SIM_TIMEOUT_S (per batch of PARALLEL scenes). +# Settings are the plain assignments below. Only RUN_DIR is also read from the environment: 3_train and 7_eval chain this +# eval after their own by exporting it. set -u # quick check: TOPOLOGY=1gpu N_SCENES=2; full run: TOPOLOGY=8gpu_64rollouts (whole node) N_SCENES=0 (every scene that passed the checks) TOPOLOGY=1gpu N_SCENES=2 - -export PD=${PD:-/home/bjaeger/PufferDrive} -ALPAGYM_ROOT=${ALPAGYM_ROOT:-/home/bjaeger/alpasim-training} -export ALPASIM_ROOT=${ALPASIM_ROOT:-/home/bjaeger/alpasim} -NUREC_ROOT=${NUREC_ROOT:-/home/shared/data/nurec} -BINS=${BINS:-/home/ddacol/code/alpasim-training/data/distill/bins_130kmh} -USDZ_DIR=${USDZ_DIR:-/home/ddacol/code/alpasim-training/data/distill/usdz} -RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} +RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0046_1000} +CKPT=$RUN_DIR/final_model.pt +[ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) +SCENE_LIST="" # file of scene ids; empty = every scene that passed the conversion checks +GOAL_MODE=gt_time:5 # gt_time: | gt | route +RENDER_VIDEO=1 # one mp4 per scene +CAM_W=640 # 16:9 render size; the teacher never sees the cameras +CAM_H=360 +SIM_TIMEOUT_S=3600 # per batch of PARALLEL scenes +export PD=/home/bjaeger/PufferDrive +ALPAGYM_ROOT=/home/bjaeger/alpasim-training +export ALPASIM_ROOT=/home/bjaeger/alpasim +# Damiano's conversion; /home/shared/data/nurec/all-usdzs is a symlink farm that dangles inside the containers +BINS=/home/ddacol/code/alpasim-training/data/distill/bins_130kmh +USDZ_DIR=/home/ddacol/code/alpasim-training/data/distill/usdz +ALPASIM_IMAGE_DIR=/home/ddacol/code/alpasim-training/data/images case "$TOPOLOGY" in - 8gpu*) PARALLEL=${PARALLEL:-32} ;; - *) PARALLEL=${PARALLEL:-4} ;; + 8gpu*) PARALLEL=32 ;; # scenes in flight + *) PARALLEL=4 ;; esac -SCENE_LIST=${SCENE_LIST:-} -GOAL_MODE=${GOAL_MODE:-gt_time:5} -CAM_W=${CAM_W:-640} -CAM_H=${CAM_H:-360} -RENDER_VIDEO=${RENDER_VIDEO:-1} -SIM_TIMEOUT_S=${SIM_TIMEOUT_S:-3600} ALPAGYM_PY=$ALPAGYM_ROOT/.venv/bin/python # uv: the host syncs the AlpaSim checkout's venv before it starts the Wizard export PATH="$HOME/.local/bin:$PATH" -if [ -z "${CKPT:-}" ]; then - CKPT=$RUN_DIR/final_model.pt - [ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) -fi [ -n "$CKPT" ] || { echo "no checkpoint in $RUN_DIR (final_model.pt or models/model_*.pt)"; exit 1; } for path in "$CKPT" "$ALPAGYM_PY" "$ALPASIM_ROOT/pyproject.toml" "$BINS/scene_manifest.jsonl" "$USDZ_DIR"; do [ -e "$path" ] || { echo "missing $path"; exit 1; } @@ -74,7 +68,7 @@ case "$TOPOLOGY" in esac ALPASIM_VERSION=$(grep -m1 '^version' "$ALPASIM_ROOT/pyproject.toml" | sed -E 's/.*"(.*)".*/\1/') BASE_TAG=alpasim-base:$ALPASIM_VERSION -ALPASIM_IMAGE_TAR=${ALPASIM_IMAGE_TAR:-/home/ddacol/code/alpasim-training/data/images/alpasim-base-$ALPASIM_VERSION.tar.zst} +ALPASIM_IMAGE_TAR=$ALPASIM_IMAGE_DIR/alpasim-base-$ALPASIM_VERSION.tar.zst NRE_TAG=$(grep -m1 -oE 'nvcr.io/nvidia/nre/[^[:space:]"]+' "$ALPASIM_ROOT/src/wizard/configs/base_config.yaml") [ -n "$NRE_TAG" ] || { echo "no NRE renderer image in $ALPASIM_ROOT/src/wizard/configs/base_config.yaml"; exit 1; } echo "Evaluating checkpoint: $CKPT" From 87d70c1da56b174785decc7dcee9e9f280feac96 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 14:05:52 +0200 Subject: [PATCH 023/123] fix leaderboard 2.0 red light lines --- .../cosim/carla/lb1/leaderboard_agent.py | 8 +-- .../ocean/cosim/carla/leaderboard_agent.py | 2 +- pufferlib/ocean/cosim/carla/shadow_ego.py | 2 +- pufferlib/ocean/cosim/carla_bridge.py | 54 +++++++++++++++---- pufferlib/ocean/cosim/carla_cosim.py | 2 +- .../unit_tests/test_carla_light_stop_lines.py | 4 +- 6 files changed, 53 insertions(+), 19 deletions(-) diff --git a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py index d7327e76ce..6a17a0bdfc 100644 --- a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -29,8 +29,8 @@ CARLA 0.9.10 differences handled here: no World.cast_ray (the ego's body chord rests on the lane's OpenDRIVE height profile, lifted by the road mesh's offset from it as probed by a downward semantic lidar; the egg's -Rotation.get_up_vector() points down, so the pitch comes from the profile too), no -TrafficLight.get_stop_waypoints (stop waypoints come from the trigger volume, RunningRedLightTest's own recipe). +Rotation.get_up_vector() points down, so the pitch comes from the profile too); stop waypoints come from the +trigger volume with RunningRedLightTest's own recipe on both sides (carla_bridge.light_stop_waypoints). """ import math @@ -142,8 +142,8 @@ def driving_waypoint_samples(carla_map): def light_stop_waypoints(light, carla_map): """Stop waypoints of a traffic light from its trigger volume: one waypoint per lane the volume spans, - advanced to the junction entry (RunningRedLightTest.get_traffic_light_waypoints; CARLA >= 0.9.11 - exposes the same list as TrafficLight.get_stop_waypoints).""" + advanced to the junction entry (RunningRedLightTest.get_traffic_light_waypoints): the py3.7 copy of + carla_bridge.light_stop_waypoints. TrafficLight.get_stop_waypoints is NOT that list in Town01/02.""" base_transform = light.get_transform() trigger = light.trigger_volume lane_waypoints = [] diff --git a/pufferlib/ocean/cosim/carla/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/leaderboard_agent.py index 31d76b5900..9f6ffb4bff 100644 --- a/pufferlib/ocean/cosim/carla/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/leaderboard_agent.py @@ -180,7 +180,7 @@ def _init_on_first_step(self): "sparse_plan": plan_xyz(self._global_plan_world_coord), "dense_plan": plan_xyz(self.dense_global_plan_world_coord), "driving_waypoints": cb.driving_waypoint_samples(self.cmap), - "lights": cb.light_geometry_from_carla(self.lights), + "lights": cb.light_geometry_from_carla(self.lights, self.cmap), "stop_signs": cb.stop_sign_geometry_from_carla(self.world, self.cmap), "infraction_flags": bool(self.record_infractions_dir), } diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py index 2e8e9d7ec1..547a7411da 100644 --- a/pufferlib/ocean/cosim/carla/shadow_ego.py +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -375,7 +375,7 @@ def init_route(self, route): self.env.set_traffic_light_lines(light_line_indices, light_lines) self.stop_line_centers[light_line_indices] = moved_centers print( - f"[puffer_agent] light stop lines: {len(light_line_indices)} moved onto CARLA's stop waypoints " + f"[puffer_agent] light stop lines: {len(light_line_indices)} moved onto the leaderboard's red-light lines " f"(max {max_shift_m:.1f} m)" ) # the shadow env runs on CARLA's trigger volumes: the bin's exported stop lines sit up to 9 m off and miss a few diff --git a/pufferlib/ocean/cosim/carla_bridge.py b/pufferlib/ocean/cosim/carla_bridge.py index aaa5ddb7a7..9d3db4919f 100644 --- a/pufferlib/ocean/cosim/carla_bridge.py +++ b/pufferlib/ocean/cosim/carla_bridge.py @@ -283,7 +283,11 @@ def carla_light_to_puffer(state) -> int: LIGHT_LANE_ALIGN_COS_MIN = ( 0.5 # forward fallback: the lane must run along the waypoint's heading (no crossing connectors) ) -LIGHT_STOP_LINE_TOLERANCE_M = 1.0 # exported bin lines within this of CARLA's stop waypoint stay (median 0.5 m) +LIGHT_STOP_LINE_TOLERANCE_M = 1.0 # exported bin lines within this of the light's stop waypoint stay (median 0.5 m) +TRIGGER_SAMPLE_SPAN_FRACTION = 0.9 # RunningRedLightTest.get_traffic_light_waypoints: avoids adjacent lanes +TRIGGER_SAMPLE_STEP_M = 1.0 +JUNCTION_ADVANCE_STEP_M = 0.5 +MAX_JUNCTION_ADVANCE_STEPS = 400 # 200 m: a trigger volume farther from its junction is a map error JUNCTION_CLUSTER_M = 60.0 # stop lines this close are treated as one junction (outlier repair, see below) # An assigned light farther from its junction cluster-mates' lights than this # multiple of the cluster-mates' own spread is a wrong-junction match. @@ -350,11 +354,41 @@ def _drop_junction_outliers(mapping, light_geometry, transform, stop_centers): return to_drop -def light_geometry_from_carla(lights): +def light_stop_waypoints(light, carla_map): + """Stop waypoints of a traffic light from its trigger volume: one waypoint per lane the volume spans, + advanced to the junction entry, the line RunningRedLightTest scores (its get_traffic_light_waypoints). + Not TrafficLight.get_stop_waypoints: in Town01/02 those sit 10-14 m before the junction entry.""" + import carla + + base_transform = light.get_transform() + trigger = light.trigger_volume + lane_waypoints = [] + for x in np.arange( + -TRIGGER_SAMPLE_SPAN_FRACTION * trigger.extent.x, + TRIGGER_SAMPLE_SPAN_FRACTION * trigger.extent.x, + TRIGGER_SAMPLE_STEP_M, + ): + wp = carla_map.get_waypoint(base_transform.transform(trigger.location + carla.Location(x=float(x)))) + if not lane_waypoints or lane_waypoints[-1].road_id != wp.road_id or lane_waypoints[-1].lane_id != wp.lane_id: + lane_waypoints.append(wp) + stop_waypoints = [] + for wp in lane_waypoints: + for _ in range(MAX_JUNCTION_ADVANCE_STEPS): + if wp.is_junction: + break + ahead = wp.next(JUNCTION_ADVANCE_STEP_M) + if not ahead or ahead[0].is_junction: + break + wp = ahead[0] + stop_waypoints.append(wp) + return stop_waypoints + + +def light_geometry_from_carla(lights, carla_map): """Plain-data geometry of live CARLA traffic lights for map_lights_to_bin (CARLA frame). One dict per light: id, x/y of the light actor, trigger_x/trigger_y of its trigger volume and one - entry per stop waypoint (get_stop_waypoints, CARLA >= 0.9.11) with x, y, yaw_deg, lane_width, + entry per stop waypoint (light_stop_waypoints) with x, y, yaw_deg, lane_width, `backward` = [x, y] of the first waypoint LIGHT_PROBE_STEPS_M behind it (None where the lane ends) and `forward` = [x, y, yaw_deg] of the first waypoint each non-zero step ahead of it.""" geometry = [] @@ -362,7 +396,7 @@ def light_geometry_from_carla(lights): location = light.get_location() trigger = light.get_transform().transform(light.trigger_volume.location) stop_waypoints = [] - for wp in light.get_stop_waypoints(): + for wp in light_stop_waypoints(light, carla_map): backward, forward = [], [] for step_m in LIGHT_PROBE_STEPS_M: probes = [wp] if step_m == 0.0 else wp.previous(step_m) @@ -405,7 +439,7 @@ def light_geometry_from_carla(lights): def map_lights_to_bin(light_geometry, transform, town_bin): """mapping[i] = list of bin traffic-element indices controlled by light_geometry[i] - (light_geometry_from_carla, or the same dicts built by a co-sim client without get_stop_waypoints). + (light_geometry_from_carla, or the same dicts built by a CARLA 0.9.10 co-sim client). Semantic matching: each of the light's STOP WAYPOINTS is snapped to its bin LANE (nearest drivable-lane segment -- stop lines lie ON lane segments, so @@ -525,11 +559,11 @@ def controlling_elements_near(px, py, max_dist_m, heading=None): def light_stop_line_overrides(light_geometry, mapping, transform, town_bin): """(element indices (K,), lines (K, 6)) for the bin light elements whose exported stop line sits more than - LIGHT_STOP_LINE_TOLERANCE_M along the lane from CARLA's stop waypoints, the line RunningRedLightTest - scores against (Town04: four approaches whose bin line is 8 m past the waypoint, so an ego stopping at - it has already run the light). Each line is the bin's own translated along the element's travel - direction onto its most upstream waypoint (a line spanning several lanes then precedes every lane's - CARLA line); a light's waypoints go to the nearest of its elements.""" + LIGHT_STOP_LINE_TOLERANCE_M along the lane from the light's stop waypoints (light_stop_waypoints: the + line RunningRedLightTest scores against; Town04: four approaches whose bin line is 8 m past it, so an + ego stopping at the bin line has already run the light). Each line is the bin's own translated along + the element's travel direction onto its most upstream waypoint (a line spanning several lanes then + precedes every lane's CARLA line); a light's waypoints go to the nearest of its elements.""" import data_utils.mirror_map_bin as mbin bin_traffic = mbin.read_bin(Path(town_bin))["traffic"] diff --git a/pufferlib/ocean/cosim/carla_cosim.py b/pufferlib/ocean/cosim/carla_cosim.py index 81792e0344..0fbb07fd4c 100644 --- a/pufferlib/ocean/cosim/carla_cosim.py +++ b/pufferlib/ocean/cosim/carla_cosim.py @@ -420,7 +420,7 @@ def main(): dense_route = densify_route(route_wps) # fine-sampled route for lane-centered goal placement route_goals = build_route_goals(dense_route, transform, cmap) # fixed 20-m lane-centered goal sequence goal_window = RouteGoalWindow(env, route_goals) - light_map, num_traffic = cb.map_lights_to_bin(cb.light_geometry_from_carla(lights), transform, town_bin) + light_map, num_traffic = cb.map_lights_to_bin(cb.light_geometry_from_carla(lights, cmap), transform, town_bin) print( f"[cosim] carla: ego + {len(bg)} background + {len(lights)} lights; " f"offset={transform.tx:.1f},{transform.ty:.1f} z={transform.tz:+.2f}" diff --git a/tests/unit_tests/test_carla_light_stop_lines.py b/tests/unit_tests/test_carla_light_stop_lines.py index ebbfc75edf..03bd456151 100644 --- a/tests/unit_tests/test_carla_light_stop_lines.py +++ b/tests/unit_tests/test_carla_light_stop_lines.py @@ -1,5 +1,5 @@ -"""light_stop_line_overrides moves a bin light stop line along its lane onto CARLA's stop waypoint and leaves -lines within tolerance as exported.""" +"""light_stop_line_overrides moves a bin light stop line along its lane onto the light's stop waypoint (the +junction entry RunningRedLightTest scores) and leaves lines within tolerance as exported.""" import math import os From c09b8799b60bc315a8f725a275d5d2465bbd169e Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 14:29:33 +0200 Subject: [PATCH 024/123] fix red light issue and shadow env bug --- pufferlib/ocean/cosim/carla/shadow_ego.py | 9 +++-- pufferlib/ocean/cosim/carla_bridge.py | 39 ++++++++++++++++++- pufferlib/ocean/drive/drive.h | 4 ++ tests/drive/test_drive_cosim_setters.c | 17 +++++--- .../unit_tests/test_carla_light_stop_lines.py | 18 +++++++++ 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py index 547a7411da..135a473f5d 100644 --- a/pufferlib/ocean/cosim/carla/shadow_ego.py +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -366,8 +366,11 @@ def init_route(self, route): lights = route["lights"] light_map, bin_num_traffic = cb.map_lights_to_bin(lights, self.transform, self.town_bin) + dense_route_bin_xy = np.array( + [self.transform.loc_to_bin(x, y) for x, y in self.dense_plan_xy], np.float64 + ).reshape(-1, 2) light_line_indices, light_lines = cb.light_stop_line_overrides( - lights, light_map, self.transform, self.town_bin + lights, light_map, self.transform, self.town_bin, route_xy=dense_route_bin_xy ) if len(light_line_indices): moved_centers = 0.5 * (light_lines[:, 0:2] + light_lines[:, 3:5]) @@ -375,8 +378,8 @@ def init_route(self, route): self.env.set_traffic_light_lines(light_line_indices, light_lines) self.stop_line_centers[light_line_indices] = moved_centers print( - f"[puffer_agent] light stop lines: {len(light_line_indices)} moved onto the leaderboard's red-light lines " - f"(max {max_shift_m:.1f} m)" + f"[puffer_agent] light stop lines: {len(light_line_indices)} on the route moved onto the leaderboard's " + f"red-light lines (max {max_shift_m:.1f} m)" ) # the shadow env runs on CARLA's trigger volumes: the bin's exported stop lines sit up to 9 m off and miss a few self._stop_sign_lines, stop_sign_headings = cb.stop_sign_lines(route["stop_signs"], self.transform) diff --git a/pufferlib/ocean/cosim/carla_bridge.py b/pufferlib/ocean/cosim/carla_bridge.py index 9d3db4919f..7ab3b37fc2 100644 --- a/pufferlib/ocean/cosim/carla_bridge.py +++ b/pufferlib/ocean/cosim/carla_bridge.py @@ -284,6 +284,8 @@ def carla_light_to_puffer(state) -> int: 0.5 # forward fallback: the lane must run along the waypoint's heading (no crossing connectors) ) LIGHT_STOP_LINE_TOLERANCE_M = 1.0 # exported bin lines within this of the light's stop waypoint stay (median 0.5 m) +ROUTE_STOP_LINE_MARGIN_M = 1.0 # a route crossing a stop line extended by this at both ends drives that line's lane +ROUTE_STOP_LINE_ALIGN_COS_MIN = 0.5 # ... in the element's travel direction (opposing traffic crosses the same line) TRIGGER_SAMPLE_SPAN_FRACTION = 0.9 # RunningRedLightTest.get_traffic_light_waypoints: avoids adjacent lanes TRIGGER_SAMPLE_STEP_M = 1.0 JUNCTION_ADVANCE_STEP_M = 0.5 @@ -557,13 +559,42 @@ def controlling_elements_near(px, py, max_dist_m, heading=None): return mapping, len(data["traffic"]) -def light_stop_line_overrides(light_geometry, mapping, transform, town_bin): +def route_crosses_stop_line(route_xy, stop_line, heading): + """True when the polyline route_xy (N, 2) crosses stop_line [x1, y1, z1, x2, y2, z2] (extended by + ROUTE_STOP_LINE_MARGIN_M at both ends) while running along `heading`, the element's travel direction.""" + route = np.asarray(route_xy, dtype=np.float64).reshape(-1, 2) + if len(route) < 2: + return False + x1, y1, _, x2, y2, _ = stop_line + line_len = math.hypot(x2 - x1, y2 - y1) + if line_len <= 0.0: + return False + ux, uy = (x2 - x1) / line_len, (y2 - y1) / line_len + ax, ay = x1 - ROUTE_STOP_LINE_MARGIN_M * ux, y1 - ROUTE_STOP_LINE_MARGIN_M * uy + bx, by = x2 + ROUTE_STOP_LINE_MARGIN_M * ux, y2 + ROUTE_STOP_LINE_MARGIN_M * uy + p, q = route[:-1], route[1:] + d = q - p + seg_len = np.maximum(np.hypot(d[:, 0], d[:, 1]), 1e-9) + along = (d[:, 0] * math.cos(heading) + d[:, 1] * math.sin(heading)) / seg_len + line_side_p = (bx - ax) * (p[:, 1] - ay) - (by - ay) * (p[:, 0] - ax) + line_side_q = (bx - ax) * (q[:, 1] - ay) - (by - ay) * (q[:, 0] - ax) + seg_side_a = d[:, 0] * (ay - p[:, 1]) - d[:, 1] * (ax - p[:, 0]) + seg_side_b = d[:, 0] * (by - p[:, 1]) - d[:, 1] * (bx - p[:, 0]) + crossing = ((line_side_p < 0.0) != (line_side_q < 0.0)) & ((seg_side_a < 0.0) != (seg_side_b < 0.0)) + return bool(np.any(crossing & (along >= ROUTE_STOP_LINE_ALIGN_COS_MIN))) + + +def light_stop_line_overrides(light_geometry, mapping, transform, town_bin, route_xy=None): """(element indices (K,), lines (K, 6)) for the bin light elements whose exported stop line sits more than LIGHT_STOP_LINE_TOLERANCE_M along the lane from the light's stop waypoints (light_stop_waypoints: the line RunningRedLightTest scores against; Town04: four approaches whose bin line is 8 m past it, so an ego stopping at the bin line has already run the light). Each line is the bin's own translated along the element's travel direction onto its most upstream waypoint (a line spanning several lanes then - precedes every lane's CARLA line); a light's waypoints go to the nearest of its elements.""" + precedes every lane's CARLA line); a light's waypoints go to the nearest of its elements. + route_xy (N, 2), bin frame: only lines the route crosses in their travel direction move (the ones the + leaderboard scores); the rest keep the exported position the policy trained with, since a moved + neighbouring line beside the ego's own red line is out of distribution (Town04 junctions 483/1249 sit + 3.8 m apart in CARLA's model and the policy ran that red). None moves every line.""" import data_utils.mirror_map_bin as mbin bin_traffic = mbin.read_bin(Path(town_bin))["traffic"] @@ -589,6 +620,10 @@ def light_stop_line_overrides(light_geometry, mapping, transform, town_bin): shift_m = min(along_offsets_by_element[j]) if abs(shift_m) <= LIGHT_STOP_LINE_TOLERANCE_M: continue + if route_xy is not None and not route_crosses_stop_line( + route_xy, bin_traffic[j]["stop_line"], bin_traffic[j]["heading"] + ): + continue heading = bin_traffic[j]["heading"] dx, dy = shift_m * math.cos(heading), shift_m * math.sin(heading) x1, y1, z1, x2, y2, z2 = bin_traffic[j]["stop_line"] diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index 8cee3b5604..8fe2bce157 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -4136,6 +4136,10 @@ int c_set_traffic_light_states(Drive *env, const int *states) { return -1; } traffic->states[ts] = states[i]; + // holds until the next sync: c_step's metrics and obs read timestep + 1 before that sync arrives + if (ts + 1 < traffic->state_size) { + traffic->states[ts + 1] = states[i]; + } } return 0; } diff --git a/tests/drive/test_drive_cosim_setters.c b/tests/drive/test_drive_cosim_setters.c index 97e757a236..f8b97415f6 100644 --- a/tests/drive/test_drive_cosim_setters.c +++ b/tests/drive/test_drive_cosim_setters.c @@ -65,9 +65,10 @@ static TrafficControlElement make_traffic_element(int type, int state_size, int return element; } -static int test_set_traffic_light_states_writes_current_timestep_for_lights_only(void) { - // Only TRAFFIC_CONTROL_TYPE_TRAFFIC_LIGHT elements are written, only at env->timestep, and every - // other timestep slot must be left alone. +static int test_set_traffic_light_states_writes_current_and_next_timestep_for_lights_only(void) { + // Only TRAFFIC_CONTROL_TYPE_TRAFFIC_LIGHT elements are written, at env->timestep and the step after + // it (c_step increments the timestep before its metrics and obs read the state, and the next sync + // only arrives after that step); every other timestep slot must be left alone. int light_states[5] = {99, 99, 99, 99, 99}; int stop_sign_states[5] = {99, 99, 99, 99, 99}; TrafficControlElement elements[2]; @@ -83,9 +84,9 @@ static int test_set_traffic_light_states_writes_current_timestep_for_lights_only EXPECT_EQ_INT(c_set_traffic_light_states(&env, new_states), 0); EXPECT_EQ_INT(light_states[2], 3); // written at the current timestep + EXPECT_EQ_INT(light_states[3], 3); // and at the one c_step reads next EXPECT_EQ_INT(light_states[0], 99); EXPECT_EQ_INT(light_states[1], 99); - EXPECT_EQ_INT(light_states[3], 99); EXPECT_EQ_INT(light_states[4], 99); for (int t = 0; t < 5; t++) { EXPECT_EQ_INT(stop_sign_states[t], 99); // non-light element untouched @@ -118,6 +119,12 @@ static int test_set_traffic_light_states_rejects_out_of_range_timestep_and_state EXPECT_EQ_INT(light_states[1], 99); EXPECT_EQ_INT(c_set_traffic_light_states(&env, new_states), 0); EXPECT_EQ_INT(light_states[1], 1); + EXPECT_EQ_INT(light_states[2], 1); + + env.timestep = 2; // last slot of the schedule: no next timestep to fill, still a success + int last_states[2] = {3, 3}; + EXPECT_EQ_INT(c_set_traffic_light_states(&env, last_states), 0); + EXPECT_EQ_INT(light_states[2], 3); return 0; } @@ -405,7 +412,7 @@ int main(void) { int failures = 0; RUN_TEST(test_set_agent_sizes_updates_dimensions_radius_and_wheelbase); RUN_TEST(test_set_agent_sizes_rejects_out_of_range_index_and_bad_size); - RUN_TEST(test_set_traffic_light_states_writes_current_timestep_for_lights_only); + RUN_TEST(test_set_traffic_light_states_writes_current_and_next_timestep_for_lights_only); RUN_TEST(test_set_traffic_light_states_rejects_out_of_range_timestep_and_state); RUN_TEST(test_set_stop_signs_retires_map_signs_and_appends_external_ones); RUN_TEST(test_set_stop_signs_rejects_bad_input_untouched); diff --git a/tests/unit_tests/test_carla_light_stop_lines.py b/tests/unit_tests/test_carla_light_stop_lines.py index 03bd456151..ff78146a5c 100644 --- a/tests/unit_tests/test_carla_light_stop_lines.py +++ b/tests/unit_tests/test_carla_light_stop_lines.py @@ -97,3 +97,21 @@ def test_waypoints_go_to_the_nearest_of_the_lights_elements(): mx, my = _line_mid(traffic[a]) moved_mid = 0.5 * (lines[0, 0:2] + lines[0, 3:5]) assert np.allclose(moved_mid, [mx - 8.0 * math.cos(heading), my - 8.0 * math.sin(heading)], atol=1e-3) + + +def test_only_lines_the_route_crosses_in_travel_direction_move(): + traffic, light_elements, transform = _setup() + a, b = light_elements[2], light_elements[3] + light = _light_with_waypoints(transform, [traffic[a], traffic[b]], [-8.0, -8.0]) + heading = traffic[a]["heading"] + mx, my = _line_mid(traffic[a]) + route = np.array([[mx + s * math.cos(heading), my + s * math.sin(heading)] for s in np.arange(-20.0, 20.0, 1.0)]) + + indices, lines = cb.light_stop_line_overrides([light], [[a, b]], transform, TOWN_BIN, route_xy=route) + assert indices.tolist() == [a] and lines.shape == (1, 6) + + indices, _ = cb.light_stop_line_overrides([light], [[a, b]], transform, TOWN_BIN, route_xy=route[::-1]) + assert len(indices) == 0 # opposing traffic crosses the same line against its travel direction + + indices, _ = cb.light_stop_line_overrides([light], [[a, b]], transform, TOWN_BIN) + assert indices.tolist() == [a, b] # no route: every off line moves From ff9de5029bb57c5dcf49a64ae43e21c65c501e3d Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 18:05:09 +0200 Subject: [PATCH 025/123] add pos of infraction to html renderer --- pufferlib/viz.py | 42 +++- scripts/eval/render_carla_obs_html.py | 65 +++++-- scripts/eval/render_obs_html.py | 107 +++++++++-- .../test_render_infraction_steps.py | 181 ++++++++++++++++++ tests/unit_tests/test_replay_ghost.py | 10 + 5 files changed, 370 insertions(+), 35 deletions(-) create mode 100644 tests/unit_tests/test_render_infraction_steps.py diff --git a/pufferlib/viz.py b/pufferlib/viz.py index 8461f02df5..4d6a30fd2c 100644 --- a/pufferlib/viz.py +++ b/pufferlib/viz.py @@ -79,6 +79,8 @@ PAYLOAD_CHUNK_SIZE = 4 * 1024 * 1024 OBS_QUANTIZE_FRAME_CHUNK = 64 REPLAY_COMPRESSION_LEVEL = 1 +REPLAY_HEADER_PROBE_BYTES = 1 << 16 +REPLAY_INFLATE_MAX_ROUNDS = 1 << 12 SIMULATOR_FIGSIZE = (20.0, 20.0) SIMULATOR_DPI = 100 SIMULATOR_GOAL_RADIUS_METERS = 2.0 @@ -1838,15 +1840,49 @@ def render_interactive_replay_zlib(replay_path, filename): _render_interactive_replay_payload(compressed_payload, filename) -def read_replay_zlib(replay_path): - """-> (header dict, chunk name -> ndarray view) of a saved .replay.zlib.""" +def replay_step_link(step, seconds=None): + """Link for a panel injected into an interactive replay page: clicking seeks the viewer to `step`.""" + label = f"step {int(step)}" + ("" if seconds is None else f" ({seconds:.1f} s)") + return ( + f'{label}' + ) + + +def _inflate_through(decompressor, raw, needed_bytes): + rounds = 0 + while len(raw) < needed_bytes and decompressor.unconsumed_tail and rounds < REPLAY_INFLATE_MAX_ROUNDS: + raw += decompressor.decompress(decompressor.unconsumed_tail, needed_bytes - len(raw)) + rounds += 1 + if len(raw) < needed_bytes: + raise ValueError(f"truncated replay: {len(raw)} of {needed_bytes} bytes") + return raw + + +def _inflate_replay_prefix(compressed, chunk_names): + """Inflate a .replay.zlib only through the last of chunk_names (a long route inflates to GBs).""" + decompressor = zlib.decompressobj() + raw = bytearray(decompressor.decompress(compressed, REPLAY_HEADER_PROBE_BYTES)) + header_end = 4 + struct.unpack(" (header dict, chunk name -> ndarray view) of a saved .replay.zlib; chunk_names limits the chunks + returned and the inflated prefix of the stream.""" with open(replay_path, "rb") as replay_file: - raw = zlib.decompress(replay_file.read()) + compressed = replay_file.read() + raw = zlib.decompress(compressed) if chunk_names is None else _inflate_replay_prefix(compressed, chunk_names) header_len = struct.unpack("___route.html so the gallery lists the worst first (flags: COL collision, RED red light, STOP stop sign, OFF outside route lanes, DEV route deviation, BLOCK blocked, TIMEOUT route/scenario timeout); each page carries the route's leaderboard scores and -infractions as a panel. --routes (the evaluated xml) names the town of every route, else the world log does. +infractions as a panel, every positioned infraction linking to the replay step where the ego was nearest. +--routes (the evaluated xml) names the town of every route, else the world log does. Retried routes leave the files of crashed attempts behind: the newest files pair with the records. """ @@ -28,6 +29,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) from pufferlib import viz +from pufferlib.ocean.cosim.carla_bridge import TOWN_OFFSETS COLLISION_KEYS = ("collisions_pedestrian", "collisions_vehicle", "collisions_layout") @@ -55,6 +57,8 @@ ("yield_emergency_vehicle_infractions", "emergency vehicles"), ) MESSAGE_MAX_CHARS = 120 +POSITION_PATTERN = re.compile(r"x=([-\d.]+), y=([-\d.]+)") +NEAREST_STEP_MAX_M = 5.0 ROUTE_ID_PATTERN = re.compile(r"RouteScenario_(\d+)(?:_rep(\d+))?") @@ -80,8 +84,8 @@ def page_name(record, town): return "_".join(parts) + ".html" -def score_panel(record, town): - """Fixed panel injected into the viewer: leaderboard scores, every infraction category, the messages.""" +def score_panel(record, town, steps): + """Fixed panel injected into the viewer: leaderboard scores, every infraction category, the messages + steps.""" scores, meta = record["scores"], record.get("meta", {}) route_idx, rep = route_index_rep(record) lines = [] @@ -94,8 +98,10 @@ def score_panel(record, town): f'{len(messages)}' ) for message in messages: + step_html = f" · {steps[message]}" if message in steps else "" lines.append( - f'
↳ {html.escape(message[:MESSAGE_MAX_CHARS])}
' + f'
↳ {html.escape(message[:MESSAGE_MAX_CHARS])}' + f"{step_html}
" ) if meta: lines.append( @@ -115,6 +121,31 @@ def score_panel(record, town): ) +def infraction_steps(record, replay_path, town, log_meta): + """message -> seek link of every positioned infraction: the replay step where the ego was nearest to it. + CARLA -> bin offset from the route's world log (calibrated), else the town default.""" + offset = log_meta.get("offset") or TOWN_OFFSETS.get(town) + if offset is None: + return {} + tick_dt = log_meta.get("tick_dt") + _, chunks = viz.read_replay_zlib(replay_path, chunk_names=("agent_f32",)) + ego_xy = chunks["agent_f32"][:, 0, :2] + messages = [message for key, _ in PANEL_INFRACTIONS for message in infraction_messages(record, key)] + steps = {} + for message in messages: + match = POSITION_PATTERN.search(message) + if match is None: + continue + bin_x, bin_y = float(match.group(1)) + offset[0], -float(match.group(2)) + offset[1] + distance_m = np.hypot(ego_xy[:, 0] - bin_x, ego_xy[:, 1] - bin_y) + step = int(np.argmin(distance_m)) + if distance_m[step] > NEAREST_STEP_MAX_M: + steps[message] = "beyond the replay" + continue + steps[message] = viz.replay_step_link(step, None if tick_dt is None else step * tick_dt) + return steps + + def render_page(zlib_path, html_path, panel): viz.render_interactive_replay_zlib(str(zlib_path), str(html_path)) page = Path(html_path).read_text() @@ -137,8 +168,8 @@ def route_towns(routes_xml): } -def world_log_town(npz_path): - return json.loads(str(np.load(npz_path)["meta"])).get("town", "") +def world_log_meta(npz_path): + return json.loads(str(np.load(npz_path)["meta"])) def newest_files(route_dir, subdir, suffix, count): @@ -148,7 +179,7 @@ def newest_files(route_dir, subdir, suffix, count): def collect_jobs(run_dir, towns): - """-> [(record, replay zlib path, town)] over routes/route_*, the newest replay per route record.""" + """-> [(record, replay zlib path, town, world log meta)] over routes/route_*, the newest replay per record.""" jobs = [] for route_dir in sorted(Path(run_dir, "routes").glob("route_*")): result_path = route_dir / "result.json" @@ -165,10 +196,9 @@ def collect_jobs(run_dir, towns): records = records[-len(replays) :] world_logs = newest_files(route_dir, "world_log", ".npz", len(replays)) for k, (record, replay) in enumerate(zip(records, replays)): - town = towns.get(route_index_rep(record)[0]) - if town is None and k < len(world_logs): - town = world_log_town(world_logs[k]) - jobs.append((record, replay, town or "")) + log_meta = world_log_meta(world_logs[k]) if k < len(world_logs) else {} + town = towns.get(route_index_rep(record)[0]) or log_meta.get("town", "") + jobs.append((record, replay, town, log_meta)) return jobs @@ -185,10 +215,17 @@ def main(): raise SystemExit(f"no routes/route_*/obs_html/*.replay.zlib with a result.json under {run_dir}") obs_dir = run_dir / "obs_html" obs_dir.mkdir(exist_ok=True) - pages = [(obs_dir / page_name(record, town), record, replay, town) for record, replay, town in jobs] + pages = [ + (obs_dir / page_name(record, town), record, replay, town, log_meta) for record, replay, town, log_meta in jobs + ] + + def render(page): + html_path, record, replay, town, log_meta = page + render_page(replay, html_path, score_panel(record, town, infraction_steps(record, replay, town, log_meta))) + with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool: - list(pool.map(lambda job: render_page(job[2], job[0], score_panel(job[1], job[3])), pages)) - file_metrics = {page.name: gallery_metrics(record) for page, record, _, _ in pages} + list(pool.map(render, pages)) + file_metrics = {page.name: gallery_metrics(record) for page, record, *_ in pages} report = run_dir / "report" / "index.html" links = [("Analysis report", os.path.relpath(report, obs_dir))] if report.is_file() else [] viz.build_gallery_index(str(obs_dir), file_metrics=file_metrics, links=links) diff --git a/scripts/eval/render_obs_html.py b/scripts/eval/render_obs_html.py index 7cad4b7b31..26130fb559 100644 --- a/scripts/eval/render_obs_html.py +++ b/scripts/eval/render_obs_html.py @@ -7,7 +7,8 @@ Selection: aggregated score < --max-score OR any --metric < 1 OR explicitly listed --tokens. Pages are named s___.html so the gallery lists the worst first -(flags: COL at-fault collision, OFF drivable area, DIR driving direction, STALL no progress). +(flags: COL at-fault collision, OFF drivable area, DIR driving direction, STALL no progress). Every violated +metric links to the replay step of its first violation (collisions: the shadow env's own flag). --prune deletes the .replay.zlib of every scenario that was not selected. """ @@ -18,6 +19,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path +import numpy as np import pandas as pd REPO_ROOT = Path(__file__).resolve().parents[2] @@ -50,14 +52,25 @@ "ego_yaw_acceleration", "ego_yaw_rate", ) -COMFORT_BOUNDS = { - "ego_lon_acceleration": "-4.05..2.40 m/s^2", - "ego_lon_jerk": "|j| <= 4.13 m/s^3", - "ego_jerk": "|j| <= 8.37 m/s^3", - "ego_lat_acceleration": "|a| <= 4.89 m/s^2", - "ego_yaw_acceleration": "|a| <= 1.93 rad/s^2", - "ego_yaw_rate": "|w| <= 0.95 rad/s", +COMFORT_BOUNDS = { # (lower, upper, label): nuPlan's within-bound thresholds + "ego_lon_acceleration": (-4.05, 2.40, "-4.05..2.40 m/s^2"), + "ego_lon_jerk": (-4.13, 4.13, "|j| <= 4.13 m/s^3"), + "ego_jerk": (-8.37, 8.37, "|j| <= 8.37 m/s^3"), + "ego_lat_acceleration": (-4.89, 4.89, "|a| <= 4.89 m/s^2"), + "ego_yaw_acceleration": (-1.93, 1.93, "|a| <= 1.93 rad/s^2"), + "ego_yaw_rate": (-0.95, 0.95, "|w| <= 0.95 rad/s"), } +NUPLAN_STEP_SECONDS = 0.1 +DRIVING_DIRECTION_COMPLIANCE_THRESHOLD_M = 2.0 +TTC_LEAST_MIN_S = 0.95 +COLLISION_METRIC = "no_ego_at_fault_collisions" +COLLISION_METRIC_IDX = viz.METRIC_LABELS.index("collision") +VIOLATION_SERIES = ( # scored metric, parquet with its per-step series, (lower, upper) of a compliant value + ("drivable_area_compliance", "corners_in_drivable_area", 1.0, None), + ("driving_direction_compliance", "driving_direction_compliance", -DRIVING_DIRECTION_COMPLIANCE_THRESHOLD_M, None), + ("time_to_collision_within_bound", "time_to_collision_within_bound", TTC_LEAST_MIN_S, None), + ("speed_limit_compliance", "speed_limit_compliance", None, 0.0), +) def scenario_rows(group_dir): @@ -71,6 +84,48 @@ def scenario_rows(group_dir): return rows +def first_out_of_bounds(values, lower=None, upper=None): + """-> first index with value < lower or value > upper, None when every value is within bounds.""" + values = np.asarray(values, dtype=np.float64) + outside = np.zeros(values.shape, dtype=bool) + if lower is not None: + outside |= values < lower + if upper is not None: + outside |= values > upper + hits = np.flatnonzero(outside) + return int(hits[0]) if hits.size else None + + +def step_link(step): + return viz.replay_step_link(step, step * NUPLAN_STEP_SECONDS) + + +def violation_steps(group_dir): + """token -> {scored metric: first violating step} from the per-step time series nuPlan writes per metric.""" + series_files = [ + (metric, Path(sim_dir) / "metrics" / f"{parquet_name}.parquet", lower, upper) + for sim_dir in glob.glob(f"{group_dir}/simulation/*/20*") + for metric, parquet_name, lower, upper in VIOLATION_SERIES + ] + out = {} + for metric, parquet_path, lower, upper in series_files: + if not parquet_path.exists(): + continue + for _, r in pd.read_parquet(parquet_path).iterrows(): + values = r["time_series_values"] + step = None if values is None else first_out_of_bounds(values, lower, upper) + if step is not None: + out.setdefault(str(r["scenario_name"]), {})[metric] = step + return out + + +def replay_collision_step(replay_path): + """-> first replay step where the shadow env flagged an ego collision (at fault or not), None when never.""" + _, chunks = viz.read_replay_zlib(replay_path, chunk_names=("metrics_f32",)) + hits = np.flatnonzero(chunks["metrics_f32"][:, 0, COLLISION_METRIC_IDX] > 0) + return int(hits[0]) if hits.size else None + + def comfort_failures(group_dir): """token -> ['ego_lon_jerk -2.7..5.0', ...]: nuPlan's own out-of-bound comfort sub-metrics.""" out = {} @@ -83,15 +138,20 @@ def comfort_failures(group_dir): flag = [c for c in df.columns if c.endswith("within_bounds_stat_value")] if not flag: continue + lower, upper, bound_label = COMFORT_BOUNDS[name] for _, r in df[~df[flag[0]].astype(bool)].iterrows(): + step = first_out_of_bounds(r["time_series_values"], lower, upper) + step_html = "" if step is None else f" · {step_link(step)}" out.setdefault(str(r["scenario_name"]), []).append( - f"{name} {float(r[f'min_{name}_stat_value']):.2f}..{float(r[f'max_{name}_stat_value']):.2f} (bound {COMFORT_BOUNDS[name]})" + f"{name} {float(r[f'min_{name}_stat_value']):.2f}..{float(r[f'max_{name}_stat_value']):.2f} " + f"(bound {bound_label}){step_html}" ) return out -def score_panel(token, row, comfort): - """Fixed panel injected into the viewer: nuPlan score, every scored sub-metric, violated ones highlighted.""" +def score_panel(token, row, comfort, steps): + """Fixed panel injected into the viewer: nuPlan score, every scored sub-metric, violated ones highlighted + with the replay step of their first violation.""" if row is None: return "" lines = [] @@ -99,9 +159,13 @@ def score_panel(token, row, comfort): value = float(row[metric]) color = "#ff6b6b" if value < 1.0 else "#7ed491" mark = "✗" if value < 1.0 else "✓" + step_html = "" + if value < 1.0 and metric in steps: + source = ' (shadow env flag)' if metric == COLLISION_METRIC else "" + step_html = f" · {step_link(steps[metric])}{source}" lines.append( f'
{mark} {label}' - f'{value:.2f} ({role})
' + f'{value:.2f} ({role}){step_html}' ) for detail in comfort: lines.append(f'
↳ {detail}
') @@ -164,19 +228,26 @@ def main(): raise SystemExit(f"no obs_html/*.replay.zlib under {args.group_dir}") rows = scenario_rows(args.group_dir) comfort = comfort_failures(args.group_dir) + steps = violation_steps(args.group_dir) wanted = selected_tokens(rows, args.max_score, args.metric) | set(args.tokens) jobs = [ (token, replays[token], obs_dir / page_name(token, rows.get(token))) for token in sorted(wanted) if token in replays ] + + def render(job): + token, replay, html_path = job + row = rows.get(token) + token_steps = dict(steps.get(token, {})) + collision_failed = row is not None and float(row[COLLISION_METRIC]) < 1.0 + collision_step = replay_collision_step(replay) if collision_failed else None + if collision_step is not None: + token_steps[COLLISION_METRIC] = collision_step + render_page(replay, html_path, score_panel(token, row, comfort.get(token, []), token_steps)) + with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool: - list( - pool.map( - lambda job: render_page(job[1], job[2], score_panel(job[0], rows.get(job[0]), comfort.get(job[0], []))), - jobs, - ) - ) + list(pool.map(render, jobs)) file_metrics = {html.name: gallery_metrics(rows.get(token)) for token, _, html in jobs} pruned = 0 if args.prune: diff --git a/tests/unit_tests/test_render_infraction_steps.py b/tests/unit_tests/test_render_infraction_steps.py new file mode 100644 index 0000000000..9a56b7b4e7 --- /dev/null +++ b/tests/unit_tests/test_render_infraction_steps.py @@ -0,0 +1,181 @@ +"""Infraction -> replay step links in the obs replay panels of the CARLA and nuPlan renderers.""" + +import os +import sys + +import numpy as np +import pandas as pd + +from pufferlib import viz + + +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "scripts", "eval") +) + +import render_carla_obs_html as carla_render +import render_obs_html as nuplan_render + + +FRAMES, AGENT_CAP = 3000, 3 +TICK_DT = 0.05 +OFFSET = (10.0, 20.0) +COLLISION_FRAME = 600 + + +def _replay(ego_xy, collision_frames=()): + replay = { + "env": { + "num_goals": 2, + "goal_radius": 2.0, + "reward_conditioning": False, + "obs_slots_partners_n": 4, + "obs_slots_lane_n": 4, + "obs_slots_boundary_n": 4, + "obs_slots_traffic_controls_n": 1, + }, + "agent_f32": np.zeros((FRAMES, AGENT_CAP, 13), np.float32), + "agent_i32": np.zeros((FRAMES, AGENT_CAP, 10), np.int32), + "metrics_f32": np.zeros((FRAMES, AGENT_CAP, len(viz.METRIC_LABELS)), np.float32), + "puffer_f32": np.zeros((FRAMES, AGENT_CAP, 4), np.float32), + "traffic_i16": np.zeros((FRAMES, 1, 3), np.int16), + "raw_action": np.ones((FRAMES, 1, 2), np.float32), + "clipped_action": np.ones((FRAMES, 1, 2), np.float32), + "value": np.zeros((FRAMES, 1), np.float32), + "entropy": np.zeros((FRAMES, 1), np.float32), + "obs": None, + } + replay["agent_f32"][:, 0, :2] = ego_xy + replay["metrics_f32"][list(collision_frames), 0, nuplan_render.COLLISION_METRIC_IDX] = 1.0 + return replay + + +def _straight_ego_xy(): + return np.column_stack([0.5 * np.arange(FRAMES), np.full(FRAMES, 100.0)]) + + +def _carla_record(): + # bin (300, 100) = ego at frame 600; CARLA x = bin_x - tx, CARLA y = -(bin_y - ty) + return { + "route_id": "RouteScenario_3_rep0", + "status": "Completed", + "scores": {"score_composed": 50.0, "score_route": 100.0, "score_penalty": 0.5}, + "infractions": { + "collisions_vehicle": [ + "Agent collided against object with type=vehicle and id=1 at (x=290.0, y=-80.0, z=0.0)" + ], + "route_dev": ["Agent deviated from the route at (x=5000.0, y=0.0, z=0.0)"], + "outside_route_lanes": [ + "Agent went outside its route lanes for about 18.0 meters (1.5% of the completed route)" + ], + }, + } + + +def test_read_replay_zlib_chunk_subset_matches_full_read(tmp_path): + path = tmp_path / "route.replay.zlib" + viz.save_interactive_replay_zlib({}, _replay(_straight_ego_xy(), collision_frames=(7,)), str(path)) + header_full, chunks_full = viz.read_replay_zlib(path) + header_part, chunks_part = viz.read_replay_zlib(path, chunk_names=("agent_f32", "metrics_f32")) + assert header_part == header_full + assert set(chunks_part) == {"agent_f32", "metrics_f32"} + for name, arr in chunks_part.items(): + assert np.array_equal(arr, chunks_full[name]) + assert chunks_full["agent_f32"].nbytes > viz.REPLAY_HEADER_PROBE_BYTES + + +def test_carla_infraction_steps_link_nearest_ego_frame(tmp_path): + path = tmp_path / "route.replay.zlib" + viz.save_interactive_replay_zlib({}, _replay(_straight_ego_xy()), str(path)) + record = _carla_record() + log_meta = {"offset": list(OFFSET), "tick_dt": TICK_DT} + + steps = carla_render.infraction_steps(record, str(path), "Town01", log_meta) + collision, deviation, outside = ( + record["infractions"][key][0] for key in ("collisions_vehicle", "route_dev", "outside_route_lanes") + ) + assert steps[collision] == viz.replay_step_link(COLLISION_FRAME, COLLISION_FRAME * TICK_DT) + assert steps[deviation] == "beyond the replay" + assert outside not in steps + assert carla_render.infraction_steps(record, str(path), "", {}) == {} + + panel = carla_render.score_panel(record, "Town01", steps) + assert f'onclick="step={COLLISION_FRAME};' in panel + assert "beyond the replay" in panel + + +def test_carla_infraction_steps_fall_back_to_town_offset(tmp_path): + path = tmp_path / "route.replay.zlib" + viz.save_interactive_replay_zlib({}, _replay(_straight_ego_xy()), str(path)) + tx, ty = carla_render.TOWN_OFFSETS["Town01"] + message = f"Agent ran a red light 12 at (x={300.0 - tx:.3f}, y={-(100.0 - ty):.3f}, z=0.0)" + record = {"infractions": {"red_light": [message]}} + steps = carla_render.infraction_steps(record, str(path), "Town01", {}) + assert steps[message] == viz.replay_step_link(COLLISION_FRAME) + + +def _write_series(metrics_dir, name, values, extra=None): + row = {"scenario_name": "tok", "time_series_values": None if values is None else np.asarray(values, np.float64)} + row.update(extra or {}) + pd.DataFrame([row]).to_parquet(metrics_dir / f"{name}.parquet") + + +def test_nuplan_violation_steps_from_metric_series(tmp_path): + metrics_dir = tmp_path / "simulation" / "closed_loop" / "2026.01.01.00.00.00" / "metrics" + metrics_dir.mkdir(parents=True) + _write_series(metrics_dir, "corners_in_drivable_area", [1.0, 1.0, 0.0, 0.0]) + _write_series(metrics_dir, "driving_direction_compliance", [0.0, -1.0, -3.0]) + _write_series(metrics_dir, "time_to_collision_within_bound", [np.inf, 2.0, 0.9]) + _write_series(metrics_dir, "speed_limit_compliance", [0.0, 0.0, 0.0, 0.4]) + assert nuplan_render.violation_steps(str(tmp_path)) == { + "tok": { + "drivable_area_compliance": 2, + "driving_direction_compliance": 2, + "time_to_collision_within_bound": 2, + "speed_limit_compliance": 3, + } + } + + +def test_nuplan_violation_steps_skip_compliant_and_missing_series(tmp_path): + metrics_dir = tmp_path / "simulation" / "closed_loop" / "2026.01.01.00.00.00" / "metrics" + metrics_dir.mkdir(parents=True) + _write_series(metrics_dir, "speed_limit_compliance", [0.0, 0.0]) + _write_series(metrics_dir, "corners_in_drivable_area", None) + assert nuplan_render.violation_steps(str(tmp_path)) == {} + + +def test_nuplan_comfort_failures_carry_first_out_of_bound_step(tmp_path): + metrics_dir = tmp_path / "simulation" / "closed_loop" / "2026.01.01.00.00.00" / "metrics" + metrics_dir.mkdir(parents=True) + stats = { + "abs_ego_lon_jerk_within_bounds_stat_value": False, + "min_ego_lon_jerk_stat_value": 1.0, + "max_ego_lon_jerk_stat_value": 5.0, + } + _write_series(metrics_dir, "ego_lon_jerk", [1.0, 5.0, 1.0], stats) + comfort = nuplan_render.comfort_failures(str(tmp_path)) + assert comfort == { + "tok": [f"ego_lon_jerk 1.00..5.00 (bound |j| <= 4.13 m/s^3) · {nuplan_render.step_link(1)}"] + } + + +def test_nuplan_replay_collision_step(tmp_path): + path = tmp_path / "tok.replay.zlib" + viz.save_interactive_replay_zlib({}, _replay(np.zeros((FRAMES, 2)), collision_frames=(40, 41)), str(path)) + assert nuplan_render.replay_collision_step(str(path)) == 40 + viz.save_interactive_replay_zlib({}, _replay(np.zeros((FRAMES, 2))), str(path)) + assert nuplan_render.replay_collision_step(str(path)) is None + + +def test_nuplan_score_panel_links_violated_metrics_only(): + row = pd.Series( + {metric: 1.0 for metric, _, _ in nuplan_render.SCORED_METRICS} | {"score": 0.4, "scenario_type": "t"} + ) + row["speed_limit_compliance"] = 0.8 + row[nuplan_render.COLLISION_METRIC] = 0.0 + steps = {"speed_limit_compliance": 12, nuplan_render.COLLISION_METRIC: 40, "drivable_area_compliance": 3} + panel = nuplan_render.score_panel("tok", row, [], steps) + assert 'onclick="step=12;' in panel + assert 'onclick="step=40;' in panel and "(shadow env flag)" in panel + assert 'onclick="step=3;' not in panel diff --git a/tests/unit_tests/test_replay_ghost.py b/tests/unit_tests/test_replay_ghost.py index c7fc571960..7e589bd0f1 100644 --- a/tests/unit_tests/test_replay_ghost.py +++ b/tests/unit_tests/test_replay_ghost.py @@ -117,3 +117,13 @@ def ego_state(i): assert np.allclose(boxes[:, 0], [0.0, 1.0, 2.0, 3.0]) assert np.allclose(boxes[:, 1], 0.0) assert np.allclose(boxes[:, 2:], [0.25, 5.1, 2.3]) + + +def test_read_replay_zlib_chunk_subset(tmp_path): + path = tmp_path / "e.replay.zlib" + viz.save_interactive_replay_zlib({}, _replay(_ghost_values()), str(path)) + header_full, chunks_full = viz.read_replay_zlib(path) + header_part, chunks_part = viz.read_replay_zlib(path, chunk_names=("ghost_f32",)) + assert header_part == header_full + assert list(chunks_part) == ["ghost_f32"] + assert np.array_equal(chunks_part["ghost_f32"], chunks_full["ghost_f32"]) From 2914067556269035b4f96e8e6d3b4845fbff91a3 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 18:16:05 +0200 Subject: [PATCH 026/123] fix alpasim script --- scripts/kesai/12_alpasim.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 456244ec60..1d6467b902 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -90,7 +90,13 @@ mapfile -t SCENES < <("$ALPAGYM_PY" - "$BINS/scene_manifest.jsonl" "$N_SCENES" " import json, sys manifest, n_scenes, scene_list = sys.argv[1], int(sys.argv[2]), sys.argv[3] rows = [json.loads(line) for line in open(manifest).read().splitlines()[1:]] -scenes = [r["alpasim_scene_id"] for r in rows if r["source_parity_pass"] and r["gt_replay_pass"]] +def passed_checks(row): + if "gt_replay_pass" in row: + return row["source_parity_pass"] and row["gt_replay_pass"] + if "offroad_verdict" in row: # manifests built before alpasim-training d29bdd9 (2026-08-31) + return row["source_parity_pass"] and row["offroad_verdict"] == "both_pass" + sys.exit(f"{manifest}: unknown scene row schema, keys: {sorted(row)}") +scenes = [r["alpasim_scene_id"] for r in rows if passed_checks(r)] print(f"{len(scenes)} of {len(rows)} converted scenes passed the conversion checks", file=sys.stderr) if scene_list: usable = set(scenes) From 8e89492b44e8f8e829e6ed3d49629a60dac22e59 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 18:21:11 +0200 Subject: [PATCH 027/123] new alpasim fix --- scripts/kesai/12_alpasim.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 1d6467b902..ecc91639ae 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -22,13 +22,12 @@ # eval after their own by exporting it. set -u -# quick check: TOPOLOGY=1gpu N_SCENES=2; full run: TOPOLOGY=8gpu_64rollouts (whole node) N_SCENES=0 (every scene that passed the checks) +# quick check: TOPOLOGY=1gpu N_SCENES=2; full run: TOPOLOGY=8gpu_64rollouts (whole node) N_SCENES=0 (the whole SCENE_LIST) TOPOLOGY=1gpu N_SCENES=2 RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0046_1000} CKPT=$RUN_DIR/final_model.pt [ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) -SCENE_LIST="" # file of scene ids; empty = every scene that passed the conversion checks GOAL_MODE=gt_time:5 # gt_time: | gt | route RENDER_VIDEO=1 # one mp4 per scene CAM_W=640 # 16:9 render size; the teacher never sees the cameras @@ -36,6 +35,8 @@ CAM_H=360 SIM_TIMEOUT_S=3600 # per batch of PARALLEL scenes export PD=/home/bjaeger/PufferDrive ALPAGYM_ROOT=/home/bjaeger/alpasim-training +# held-out split Damiano evaluates on; empty = every converted scene that passed the checks (2149 on the cluster, 1584 of them train) +SCENE_LIST=$ALPAGYM_ROOT/scene_splits/eval_405.txt export ALPASIM_ROOT=/home/bjaeger/alpasim # Damiano's conversion; /home/shared/data/nurec/all-usdzs is a symlink farm that dangles inside the containers BINS=/home/ddacol/code/alpasim-training/data/distill/bins_130kmh @@ -50,7 +51,7 @@ ALPAGYM_PY=$ALPAGYM_ROOT/.venv/bin/python export PATH="$HOME/.local/bin:$PATH" [ -n "$CKPT" ] || { echo "no checkpoint in $RUN_DIR (final_model.pt or models/model_*.pt)"; exit 1; } -for path in "$CKPT" "$ALPAGYM_PY" "$ALPASIM_ROOT/pyproject.toml" "$BINS/scene_manifest.jsonl" "$USDZ_DIR"; do +for path in "$CKPT" "$ALPAGYM_PY" "$ALPASIM_ROOT/pyproject.toml" "$BINS/scene_manifest.jsonl" "$USDZ_DIR" ${SCENE_LIST:+"$SCENE_LIST"}; do [ -e "$path" ] || { echo "missing $path"; exit 1; } done [ -f "$(dirname "$CKPT")/config.yaml" ] || [ -f "$(dirname "$(dirname "$CKPT")")/config.yaml" ] \ From a8d19f93e6fdb5e46476d9c9b9c8320a3fcea2f9 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 18:26:18 +0200 Subject: [PATCH 028/123] update alpasim script --- scripts/kesai/12_alpasim.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index ecc91639ae..4947e52ee5 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -15,8 +15,8 @@ # # One-time setup: alpasim-training at ALPAGYM_ROOT (`uv sync --all-packages`), the AlpaSim fork at ALPASIM_ROOT, the NuRec # scenes as real files at USDZ_DIR, their bins with 130 km/h lane limits at BINS (alpasim-training README step 4) and the -# AlpaSim base image archived in ALPASIM_IMAGE_DIR (REPO=$ALPASIM_ROOT OUT_DIR=$ALPASIM_IMAGE_DIR sbatch -# --output= --error= $ALPAGYM_ROOT/scripts/build_alpasim_image.sbatch). +# AlpaSim base image archived in ALPASIM_IMAGE_DIR (cd $ALPAGYM_ROOT && ALPASIM_ROOT=$ALPASIM_ROOT OUT_DIR=$ALPASIM_IMAGE_DIR +# sbatch scripts/build_alpasim_image.sbatch; ~3 h on a dev node, ~31 GB archive). The archive must be readable by this user. # # Settings are the plain assignments below. Only RUN_DIR is also read from the environment: 3_train and 7_eval chain this # eval after their own by exporting it. @@ -52,7 +52,7 @@ export PATH="$HOME/.local/bin:$PATH" [ -n "$CKPT" ] || { echo "no checkpoint in $RUN_DIR (final_model.pt or models/model_*.pt)"; exit 1; } for path in "$CKPT" "$ALPAGYM_PY" "$ALPASIM_ROOT/pyproject.toml" "$BINS/scene_manifest.jsonl" "$USDZ_DIR" ${SCENE_LIST:+"$SCENE_LIST"}; do - [ -e "$path" ] || { echo "missing $path"; exit 1; } + [ -r "$path" ] || { echo "missing or not readable: $path"; exit 1; } done [ -f "$(dirname "$CKPT")/config.yaml" ] || [ -f "$(dirname "$(dirname "$CKPT")")/config.yaml" ] \ || { echo "no config.yaml next to or one level above $CKPT"; exit 1; } @@ -72,6 +72,8 @@ BASE_TAG=alpasim-base:$ALPASIM_VERSION ALPASIM_IMAGE_TAR=$ALPASIM_IMAGE_DIR/alpasim-base-$ALPASIM_VERSION.tar.zst NRE_TAG=$(grep -m1 -oE 'nvcr.io/nvidia/nre/[^[:space:]"]+' "$ALPASIM_ROOT/src/wizard/configs/base_config.yaml") [ -n "$NRE_TAG" ] || { echo "no NRE renderer image in $ALPASIM_ROOT/src/wizard/configs/base_config.yaml"; exit 1; } +[ -r "$ALPASIM_IMAGE_TAR" ] || docker image inspect "$BASE_TAG" > /dev/null 2>&1 \ + || { echo "$ALPASIM_IMAGE_TAR is missing or not readable and $BASE_TAG is not on $(hostname)"; exit 1; } echo "Evaluating checkpoint: $CKPT" # drop inherited entries from other PufferDrive checkouts: the teacher would import their pufferlib instead of $PD's @@ -138,7 +140,7 @@ cleanup() { } trap cleanup EXIT if ! docker image inspect "$BASE_TAG" > /dev/null 2>&1; then - [ -f "$ALPASIM_IMAGE_TAR" ] || { echo "$BASE_TAG is not on $(hostname) and $ALPASIM_IMAGE_TAR is missing"; exit 1; } + [ -r "$ALPASIM_IMAGE_TAR" ] || { echo "$BASE_TAG is not on $(hostname) and $ALPASIM_IMAGE_TAR is missing or not readable"; exit 1; } echo "Loading $BASE_TAG from $ALPASIM_IMAGE_TAR" LOADED_BASE=1 zstd -dc "$ALPASIM_IMAGE_TAR" | docker load || exit 1 From e1e8a8050c1e8983ac3e5095241b1593a9502c89 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 18:58:20 +0200 Subject: [PATCH 029/123] remove obsolete comment --- pufferlib/ocean/drive/drive.h | 1 - 1 file changed, 1 deletion(-) diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index 8fe2bce157..293899d7a2 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -4876,7 +4876,6 @@ static int write_partner_obs( obs[obs_idx++] = rel_heading_x; obs[obs_idx++] = rel_heading_y; obs[obs_idx++] = other->sim_speed_signed / env->obs_norm_speed_mps; - // TODO(hack): partner seconds_stopped is a temporary feature; remove later. obs[obs_idx++] = fminf(1.0f, other->seconds_stopped / MAX_STOPPED_SECONDS); if (env->obs_partner_relative_velocity) { obs[obs_idx++] = rel_vx / env->obs_norm_speed_mps; From 8e9ca63ed310bbd6e876682bdb5ba2d2bafa90e9 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 19:54:24 +0200 Subject: [PATCH 030/123] alpasim model 45 --- scripts/kesai/12_alpasim.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 4947e52ee5..f14b08989d 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -25,7 +25,7 @@ set -u # quick check: TOPOLOGY=1gpu N_SCENES=2; full run: TOPOLOGY=8gpu_64rollouts (whole node) N_SCENES=0 (the whole SCENE_LIST) TOPOLOGY=1gpu N_SCENES=2 -RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0046_1000} +RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} CKPT=$RUN_DIR/final_model.pt [ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) GOAL_MODE=gt_time:5 # gt_time: | gt | route From f30d6c91cffa44da51309943c82186a436aa8a56 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 20:00:15 +0200 Subject: [PATCH 031/123] update train script. --- pufferlib/ocean/cosim/carla/lb1/README.md | 2 +- scripts/eval/render_carla_obs_html.py | 4 +- ...la_longest6.sh => 11_carla_longest6_v2.sh} | 0 scripts/kesai/14_carla_longest6_v1.sh | 4 +- .../kesai/3_train_64GPU_multinode_slurm.sh | 33 +- scripts/kesai/7_eval_8node_slurm.sh | 6 +- scripts/visualize_map_bins.py | 422 ++++++++++++++++++ 7 files changed, 448 insertions(+), 23 deletions(-) rename scripts/kesai/{11_carla_longest6.sh => 11_carla_longest6_v2.sh} (100%) create mode 100644 scripts/visualize_map_bins.py diff --git a/pufferlib/ocean/cosim/carla/lb1/README.md b/pufferlib/ocean/cosim/carla/lb1/README.md index 1371c42712..03e28e2059 100644 --- a/pufferlib/ocean/cosim/carla/lb1/README.md +++ b/pufferlib/ocean/cosim/carla/lb1/README.md @@ -70,7 +70,7 @@ launch config "Cosim: CARLA leaderboard 1.0 agent (garage)" runs `run_evaluator. All 36 longest6 routes on the cluster: `scripts/kesai/14_carla_longest6_v1.sh` (one CARLA 0.9.10 server + evaluator per GPU over the `longest6_split` files, retries, garage's `result_parser.py` aggregation, -the nuPlan-style HTML report and the obs replay gallery, like `11_carla_longest6.sh`). +the nuPlan-style HTML report and the obs replay gallery, like `11_carla_longest6_v2.sh`). The evaluator reads `BENCHMARK=longest6` (background traffic on every spawn point, stop-sign penalty 1.0) and `ROUTES` (names the route records) from the environment; `run_leaderboard.sh`, diff --git a/scripts/eval/render_carla_obs_html.py b/scripts/eval/render_carla_obs_html.py index 75b363b788..3a0e90079b 100644 --- a/scripts/eval/render_carla_obs_html.py +++ b/scripts/eval/render_carla_obs_html.py @@ -1,4 +1,4 @@ -"""Render the interactive observation replays of a CARLA longest6 run (scripts/kesai/11_carla_longest6.sh: +"""Render the interactive observation replays of a CARLA longest6 run (scripts/kesai/11_carla_longest6_v2.sh: /routes/route_XX/obs_html/*.replay.zlib) into /obs_html/, one page per route plus an index.html gallery (same navigator as the nuPlan and self-play eval replays). @@ -204,7 +204,7 @@ def collect_jobs(run_dir, towns): def main(): ap = argparse.ArgumentParser() - ap.add_argument("run_dir", help="output dir of 11_carla_longest6.sh (holds routes/route_*/)") + ap.add_argument("run_dir", help="output dir of 11_carla_longest6_v2.sh (holds routes/route_*/)") ap.add_argument("--routes", help="evaluated route xml, names the town of every route") ap.add_argument("--workers", type=int, default=min(16, os.cpu_count() or 1)) args = ap.parse_args() diff --git a/scripts/kesai/11_carla_longest6.sh b/scripts/kesai/11_carla_longest6_v2.sh similarity index 100% rename from scripts/kesai/11_carla_longest6.sh rename to scripts/kesai/11_carla_longest6_v2.sh diff --git a/scripts/kesai/14_carla_longest6_v1.sh b/scripts/kesai/14_carla_longest6_v1.sh index f36941705d..71378b8f41 100644 --- a/scripts/kesai/14_carla_longest6_v1.sh +++ b/scripts/kesai/14_carla_longest6_v1.sh @@ -11,14 +11,14 @@ #SBATCH --partition dev # CARLA longest6 v1 evaluation of a PufferDrive checkpoint through carla_garage's unmodified Leaderboard 1.0 # (CARLA 0.9.10.1, pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py), the Leaderboard 1.0 counterpart of -# 11_carla_longest6.sh. The garage evaluator runs in the `garage` conda env (Python 3.7, the CARLA 0.9.10 egg); +# 11_carla_longest6_v2.sh. The garage evaluator runs in the `garage` conda env (Python 3.7, the CARLA 0.9.10 egg); # the agent starts pufferlib/ocean/cosim/carla/lb1/policy_server.py in a PufferDrive python ($SERVER_PY) per route. # # One CARLA server + one evaluator per GPU (1-8: whatever the allocation holds, e.g. `sbatch --gres gpu:2`); # the 36 routes (one split xml each) are dealt round-robin over the GPUs, every route gets its own server # (restarted per route), crashed routes are retried, and the per-route result jsons are aggregated at the end # with garage's tools/result_parser.py (results.csv) plus the nuPlan-style HTML report ($OUT/report/index.html) -# and the obs replay gallery of all routes ($OUT/obs_html/index.html, as in 11_carla_longest6.sh). +# and the obs replay gallery of all routes ($OUT/obs_html/index.html, as in 11_carla_longest6_v2.sh). # # Overridable env: RUN_DIR, ROUTE_DIR (the split xmls), ROUTES_XML (the combined longest6.xml, for the parser # and the gallery), SCENARIOS (1 = eval_scenarios.json, 0 = no_scenarios.json), REPETITIONS, ROUTE_SUBSET diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 5aaff4037a..19dc86380d 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -78,9 +78,26 @@ if [ ! -f ${MODEL_PATH} ]; then exit 1 fi +# nuPlan (reactive and non-reactive), longest6 (v2 and v1) and AlpaSim evals need one 8-GPU node each: submit them as their own jobs before the long self-play eval below so they queue while it runs. +echo "Training done, submitting nuPlan reactive eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/9_nuPlan.sh \ + || echo "nuPlan eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/9_nuPlan.sh" +echo "Submitting nuPlan non-reactive eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh \ + || echo "nuPlan non-reactive eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh" +echo "Submitting longest6 v2 eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6_v2.sh \ + || echo "longest6 v2 eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6_v2.sh" +echo "Submitting longest6 v1 eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/14_carla_longest6_v1.sh \ + || echo "longest6 v1 eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/14_carla_longest6_v1.sh" +echo "Submitting AlpaSim eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh \ + || echo "AlpaSim eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh" + # parallel_eval places one shard per allocated node via srun, so each shard's 128 # env workers get a full node's cores instead of sharing the batch host. -echo "Training done, evaluating ${MODEL_PATH}" +echo "Follow-up evals submitted, evaluating ${MODEL_PATH}" .venv/bin/python scripts/parallel_eval.py carla \ --total-scenarios 40000 \ --num-nodes 4 \ @@ -101,20 +118,6 @@ echo "Training done, evaluating ${MODEL_PATH}" load_model_path=${MODEL_PATH} \ wandb=True -# nuPlan (reactive and non-reactive), longest6 and AlpaSim evals need one 8-GPU node each: submit them as their own jobs so this 4-node allocation ends now. -echo "CARLA eval done, submitting nuPlan reactive eval for ${MODEL_PATH}" -RUN_DIR=${DATA_DIR} sbatch scripts/kesai/9_nuPlan.sh \ - || echo "nuPlan eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/9_nuPlan.sh" -echo "Submitting nuPlan non-reactive eval for ${MODEL_PATH}" -RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh \ - || echo "nuPlan non-reactive eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh" -echo "Submitting longest6 eval for ${MODEL_PATH}" -RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6.sh \ - || echo "longest6 eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6.sh" -echo "Submitting AlpaSim eval for ${MODEL_PATH}" -RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh \ - || echo "AlpaSim eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh" - end=$(date +%s) runtime=$((end-start)) echo "END TIME: $(date)" diff --git a/scripts/kesai/7_eval_8node_slurm.sh b/scripts/kesai/7_eval_8node_slurm.sh index 15d7f69b98..8a404ea74e 100644 --- a/scripts/kesai/7_eval_8node_slurm.sh +++ b/scripts/kesai/7_eval_8node_slurm.sh @@ -72,9 +72,9 @@ RUN_DIR=${DATA_DIR} sbatch scripts/kesai/9_nuPlan.sh \ echo "Submitting nuPlan non-reactive eval for ${MODEL_PATH}" RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh \ || echo "nuPlan non-reactive eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/13_nuPlan_nonreactive.sh" -echo "Submitting longest6 eval for ${MODEL_PATH}" -RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6.sh \ - || echo "longest6 eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6.sh" +echo "Submitting longest6 v2 eval for ${MODEL_PATH}" +RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6_v2.sh \ + || echo "longest6 v2 eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/11_carla_longest6_v2.sh" echo "Submitting AlpaSim eval for ${MODEL_PATH}" RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh \ || echo "AlpaSim eval submission failed; run by hand: RUN_DIR=${DATA_DIR} sbatch scripts/kesai/12_alpasim.sh" diff --git a/scripts/visualize_map_bins.py b/scripts/visualize_map_bins.py new file mode 100644 index 0000000000..29d7e7b3a4 --- /dev/null +++ b/scripts/visualize_map_bins.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python +"""Render PufferDrive map .bin files into one self-contained HTML map inspector (lanes, road lines, road edges), +with click-to-mark: every click drops a numbered mark and appends a line with the bin coordinates and the nearest +road edge / lane to a report you can copy. Optionally overlays the road edges of a second bin directory for comparison. + + python scripts/visualize_map_bins.py --bin-dir ~/ordnung/data/CARLA/puffer_bins_nd_shoulders \\ + --compare-dir ~/ordnung/data/CARLA/puffer_bins_stops_tol01 --output /tmp/map_inspector.html + +Keys: 1/2/3 mark category, u undo, c copy report, f fit, Esc clear. Drag pans, wheel zooms. +""" + +import argparse +import json +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "data_utils")) +from mirror_map_bin import read_bin # noqa: E402 + +LANE_TYPE_MAX = 9 +ROAD_LINE_TYPES = range(10, 20) +ROAD_EDGE_TYPES = (20, 21, 22) + + +def _polyline(road): + return [[round(float(x), 2), round(float(y), 2)] for x, y in zip(road["x"], road["y"])] + + +def _edges(data): + return [{"id": int(r["id"]), "type": int(r["type"]), "pts": _polyline(r)} for r in data["roads"] if r["type"] in ROAD_EDGE_TYPES] + + +def collect_map(bin_file: pathlib.Path, compare_file: pathlib.Path | None) -> dict: + data = read_bin(bin_file) + lanes, lines = [], [] + for road in data["roads"]: + if 0 <= road["type"] <= LANE_TYPE_MAX: + lanes.append( + { + "id": int(road["id"]), + "junction": bool(data.get("has_zone_section")) and road.get("speed_zone_idx", 0) < 0, + "length": round(float(road["length"]), 1), + "pts": _polyline(road), + } + ) + elif road["type"] in ROAD_LINE_TYPES: + lines.append(_polyline(road)) + compare = _edges(read_bin(compare_file)) if compare_file is not None else [] + return { + "name": bin_file.stem.split("__")[-1], + "file": bin_file.name, + "lanes": lanes, + "lines": lines, + "edges": _edges(data), + "compare": compare, + } + + +STYLE = """ +:root { + --ground: #F2F3F1; --panel: #FAFAF8; --line: #D9DCD8; --text: #1C2126; --muted: #6E7882; + --accent: #C9552B; --accent-soft: rgba(201, 85, 43, 0.12); --lane: #B9BEB9; --junction: #D3D7D2; --roadline: #E3E6E2; + --edge: #C9552B; --median: #8C3D1E; --compare: #E6A23C; --row-hover: #ECEEEA; --tooltip: #1C2126; --tooltip-text: #F2F3F1; + --cat1: #C9552B; --cat2: #2F6FB3; --cat3: #B07C1A; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --ground: #141719; --panel: #1B1F22; --line: #2C3237; --text: #E4E8EA; --muted: #8B95A0; + --accent: #E2764B; --accent-soft: rgba(226, 118, 75, 0.18); --lane: #4A5159; --junction: #363C42; --roadline: #2A3035; + --edge: #E2764B; --median: #C45A33; --compare: #E6B45C; --row-hover: #23282C; --tooltip: #E4E8EA; --tooltip-text: #141719; + --cat1: #E2764B; --cat2: #6FA5E6; --cat3: #D9A43A; + color-scheme: dark; + } +} +:root[data-theme="dark"] { + --ground: #141719; --panel: #1B1F22; --line: #2C3237; --text: #E4E8EA; --muted: #8B95A0; + --accent: #E2764B; --accent-soft: rgba(226, 118, 75, 0.18); --lane: #4A5159; --junction: #363C42; --roadline: #2A3035; + --edge: #E2764B; --median: #C45A33; --compare: #E6B45C; --row-hover: #23282C; --tooltip: #E4E8EA; --tooltip-text: #141719; + --cat1: #E2764B; --cat2: #6FA5E6; --cat3: #D9A43A; + color-scheme: dark; +} +* { box-sizing: border-box; } +html, body { height: 100%; } +body { margin: 0; background: var(--ground); color: var(--text); font-family: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif; font-size: 13px; line-height: 1.4; } +.app { display: grid; grid-template-rows: auto 1fr; grid-template-columns: 1fr 360px; height: 100vh; min-height: 560px; } +header { grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; } +header h1 { font-size: 15px; font-weight: 600; margin: 0; white-space: nowrap; } +.tabs { display: flex; gap: 4px; flex-wrap: wrap; } +.tabs button, .seg button, .controls button { font: inherit; color: var(--text); background: transparent; border: 1px solid var(--line); border-radius: 4px; padding: 4px 10px; cursor: pointer; } +.tabs button[aria-pressed="true"], .seg button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); } +.seg { display: flex; } +.seg button:first-child { border-radius: 4px 0 0 4px; } +.seg button:last-child { border-radius: 0 4px 4px 0; } +.seg button:not(:first-child) { margin-left: -1px; } +.controls { display: flex; align-items: center; gap: 12px; margin-left: auto; color: var(--muted); flex-wrap: wrap; } +.controls label { display: flex; align-items: center; gap: 5px; cursor: pointer; } +.stage { position: relative; overflow: hidden; } +canvas { display: block; width: 100%; height: 100%; cursor: crosshair; } +.hint { position: absolute; left: 12px; bottom: 10px; color: var(--muted); font-size: 12px; pointer-events: none; } +.legend { position: absolute; right: 12px; top: 10px; display: flex; flex-direction: column; gap: 3px; font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 11px; pointer-events: none; } +.legend span { display: inline-flex; align-items: center; gap: 6px; } +.legend i { width: 18px; height: 3px; border-radius: 2px; display: inline-block; } +.tooltip { position: absolute; pointer-events: none; background: var(--tooltip); color: var(--tooltip-text); padding: 6px 9px; border-radius: 4px; font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 11.5px; white-space: nowrap; transform: translate(12px, 12px); line-height: 1.5; } +.tooltip[hidden] { display: none; } +aside { border-left: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; min-height: 0; } +.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); } +.stat .k { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; } +.stat .v { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 15px; font-variant-numeric: tabular-nums; } +.marks { overflow: auto; flex: 1; min-height: 0; padding: 6px 0; } +.mark-row { display: grid; grid-template-columns: 26px 1fr 22px; gap: 6px; align-items: start; padding: 5px 14px; font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 11.5px; cursor: pointer; } +.mark-row:hover { background: var(--row-hover); } +.mark-row .n { display: inline-flex; width: 20px; height: 20px; border-radius: 50%; color: #fff; align-items: center; justify-content: center; font-size: 11px; } +.mark-row .d { color: var(--muted); } +.mark-row button { font: inherit; border: 0; background: transparent; color: var(--muted); cursor: pointer; } +.report { border-top: 1px solid var(--line); padding: 10px 14px; display: flex; flex-direction: column; gap: 6px; } +.report textarea { width: 100%; height: 120px; font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 11px; background: var(--ground); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: 6px; resize: vertical; } +.report .row { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 11.5px; } +.note { padding: 8px 14px; color: var(--muted); font-size: 11.5px; border-top: 1px solid var(--line); } +@media (max-width: 860px) { .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr 360px; } aside { border-left: 0; border-top: 1px solid var(--line); } } +""" + +SCRIPT = r""" +const MAPS = window.__MAP_INSPECTOR__.maps; +const META = window.__MAP_INSPECTOR__.meta; +const CATEGORIES = { "1": ["not_drivable", "--cat1", "NOT drivable in CARLA but drivable in the map"], + "2": ["missing_drivable", "--cat2", "drivable in CARLA but blocked in the map"], + "3": ["other", "--cat3", "other issue"] }; +const state = { map: 0, category: "1", hover: null, view: null, compare: true, lines: true, lanes: true, marks: MAPS.map(() => []) }; +const canvas = document.getElementById("map"); +const ctx = canvas.getContext("2d"); +const tooltip = document.getElementById("tooltip"); +const css = () => getComputedStyle(document.documentElement); +const color = v => css().getPropertyValue(v).trim(); +function current() { return MAPS[state.map]; } + +function fitView() { + const m = current(); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const pts of [...m.lanes.map(l => l.pts), ...m.edges.map(e => e.pts)]) for (const [x, y] of pts) { + if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; + } + fitBox([minX, minY, maxX, maxY], 30); +} +function fitBox(box, pad) { + const w = canvas.clientWidth, h = canvas.clientHeight; + const scale = Math.min((w - 2 * pad) / Math.max(1, box[2] - box[0]), (h - 2 * pad) / Math.max(1, box[3] - box[1])); + state.view = { scale, tx: w / 2 - scale * (box[0] + box[2]) / 2, ty: h / 2 + scale * (box[1] + box[3]) / 2 }; +} +function toScreen(p) { const v = state.view; return [v.tx + p[0] * v.scale, v.ty - p[1] * v.scale]; } +function toWorld(sx, sy) { const v = state.view; return [(sx - v.tx) / v.scale, (v.ty - sy) / v.scale]; } + +function resize() { + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvas.clientWidth * dpr); + canvas.height = Math.round(canvas.clientHeight * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (!state.view) fitView(); + draw(); +} + +function strokePolyline(pts) { + ctx.beginPath(); + pts.forEach((p, i) => { const s = toScreen(p); if (i === 0) ctx.moveTo(s[0], s[1]); else ctx.lineTo(s[0], s[1]); }); + ctx.stroke(); +} + +function draw() { + const m = current(); + ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); + ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.globalAlpha = 1; + const base = Math.max(1.2, Math.min(4, 0.8 * state.view.scale)); + if (state.lines) { ctx.strokeStyle = color("--roadline"); ctx.lineWidth = Math.max(0.8, base * 0.5); ctx.setLineDash([]); m.lines.forEach(strokePolyline); } + if (state.lanes) { + for (const lane of m.lanes) { + ctx.setLineDash(lane.junction ? [4, 4] : []); + ctx.strokeStyle = lane.junction ? color("--junction") : color("--lane"); ctx.lineWidth = base * 0.8; + strokePolyline(lane.pts); + } + } + ctx.setLineDash([]); + if (state.compare && m.compare.length) { + ctx.strokeStyle = color("--compare"); ctx.lineWidth = base * 2.2; ctx.globalAlpha = 0.55; + m.compare.forEach(e => strokePolyline(e.pts)); ctx.globalAlpha = 1; + } + for (const e of m.edges) { ctx.strokeStyle = e.type === 22 ? color("--median") : color("--edge"); ctx.lineWidth = base * 1.1; strokePolyline(e.pts); } + if (state.hover) { ctx.lineWidth = base * 2.6; ctx.strokeStyle = color("--accent"); strokePolyline(state.hover.pts); } + for (const mk of state.marks[state.map]) { + const [sx, sy] = toScreen([mk.x, mk.y]); + ctx.beginPath(); ctx.arc(sx, sy, 8, 0, 2 * Math.PI); ctx.fillStyle = color(CATEGORIES[mk.cat][1]); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.font = "bold 10px IBM Plex Mono, ui-monospace, monospace"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; + ctx.fillText(String(mk.n), sx, sy); + } +} + +function distToSegment(p, a, b) { + const dx = b[0] - a[0], dy = b[1] - a[1]; + const len2 = dx * dx + dy * dy; + const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2)); + return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); +} +function polylineDistance(p, pts) { + let best = Infinity; + for (let i = 1; i < pts.length; i++) best = Math.min(best, distToSegment(p, pts[i - 1], pts[i])); + return pts.length === 1 ? Math.hypot(p[0] - pts[0][0], p[1] - pts[0][1]) : best; +} +function candidates() { + const m = current(); + const items = m.edges.map(e => ({ kind: "edge", id: e.id, type: e.type, pts: e.pts })); + if (state.compare) items.push(...m.compare.map(e => ({ kind: "old edge", id: e.id, type: e.type, pts: e.pts }))); + if (state.lanes) items.push(...m.lanes.map(l => ({ kind: l.junction ? "junction lane" : "lane", id: l.id, type: null, pts: l.pts, length: l.length }))); + return items; +} +function pick(sx, sy) { + const p = toWorld(sx, sy), tol = 8 / state.view.scale; + let best = null, bestD = tol; + for (const it of candidates()) { const d = polylineDistance(p, it.pts); if (d < bestD) { bestD = d; best = it; } } + return best; +} +function nearest(p, kind) { + let best = null, bestD = Infinity; + for (const it of candidates()) { + if (!it.kind.includes(kind)) continue; + const d = polylineDistance(p, it.pts); if (d < bestD) { bestD = d; best = it; } + } + return best ? { id: best.id, type: best.type, kind: best.kind, d: bestD } : null; +} +function showTooltip(item, x, y) { + if (!item) { tooltip.hidden = true; return; } + const len = item.pts.reduce((acc, p, i) => i ? acc + Math.hypot(p[0] - item.pts[i - 1][0], p[1] - item.pts[i - 1][1]) : 0, 0); + const text = item.kind.includes("edge") ? `${item.kind} ${item.id} · type ${item.type} · ${item.pts.length} pts · ${len.toFixed(0)} m` + : `${item.kind} ${item.id} · ${len.toFixed(0)} m`; + tooltip.textContent = text; tooltip.style.left = `${x}px`; tooltip.style.top = `${y}px`; tooltip.hidden = false; +} + +function markLine(mk) { + const parts = []; + if (mk.edge) parts.push(`${mk.edge.kind} ${mk.edge.id} type ${mk.edge.type} at ${mk.edge.d.toFixed(2)} m`); + if (mk.oldEdge) parts.push(`old edge ${mk.oldEdge.id} at ${mk.oldEdge.d.toFixed(2)} m`); + if (mk.lane) parts.push(`${mk.lane.kind} ${mk.lane.id} at ${mk.lane.d.toFixed(2)} m`); + return `#${mk.n} ${CATEGORIES[mk.cat][0]} at (${mk.x.toFixed(1)}, ${mk.y.toFixed(1)}) [${parts.join("; ")}]`; +} +function report() { + const m = current(), marks = state.marks[state.map]; + const head = `Map marks for ${m.name} (PufferDrive bin coordinates, ${META.bin_dir}${META.compare_dir ? ", old edges from " + META.compare_dir : ""}):`; + return [head, ...marks.map(mk => " " + markLine(mk))].join("\n"); +} +function renderPanel() { + const m = current(), marks = state.marks[state.map]; + const stats = [["lanes", m.lanes.length], ["road edges", m.edges.length], ["old edges", m.compare.length], ["marks", marks.length]]; + document.getElementById("stats").innerHTML = stats.map(([k, v]) => `
${k}
${v}
`).join(""); + document.getElementById("marks").innerHTML = marks.map((mk, i) => + `
${mk.n}${markLine(mk).replace(/^#\d+ /, "")}
${CATEGORIES[mk.cat][2]}
`).join(""); + document.querySelectorAll(".mark-row").forEach(row => row.addEventListener("click", e => { + const i = Number(row.dataset.i); + if (e.target.dataset.rm !== undefined) { removeMark(i); return; } + const mk = marks[i]; fitBox([mk.x - 40, mk.y - 40, mk.x + 40, mk.y + 40], 20); draw(); + })); + document.getElementById("report").value = report(); + document.querySelectorAll(".seg button").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === state.category))); +} +function addMark(x, y) { + const marks = state.marks[state.map], p = [x, y]; + const edge = nearest(p, "edge"), lane = nearest(p, "lane"); + const own = current().edges.map(e => ({ id: e.id, type: e.type, d: polylineDistance(p, e.pts) })).sort((a, b) => a.d - b.d)[0]; + const old = state.compare ? current().compare.map(e => ({ id: e.id, type: e.type, d: polylineDistance(p, e.pts) })).sort((a, b) => a.d - b.d)[0] : null; + marks.push({ n: marks.length + 1, cat: state.category, x, y, + edge: own && own.d < 5 ? { ...own, kind: "edge" } : null, + oldEdge: old && old.d < 5 ? old : null, + lane: lane && lane.d < 8 ? lane : null }); + renderPanel(); draw(); +} +function removeMark(i) { + const marks = state.marks[state.map]; marks.splice(i, 1); marks.forEach((mk, k) => mk.n = k + 1); renderPanel(); draw(); +} + +let drag = null, moved = false; +canvas.addEventListener("pointerdown", e => { drag = { x: e.clientX, y: e.clientY, tx: state.view.tx, ty: state.view.ty }; moved = false; canvas.setPointerCapture(e.pointerId); }); +canvas.addEventListener("pointermove", e => { + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left, sy = e.clientY - rect.top; + if (drag) { + const dx = e.clientX - drag.x, dy = e.clientY - drag.y; + if (Math.abs(dx) + Math.abs(dy) > 2) moved = true; + state.view.tx = drag.tx + dx; state.view.ty = drag.ty + dy; + tooltip.hidden = true; requestAnimationFrame(draw); return; + } + const item = pick(sx, sy); + if (item !== state.hover) { state.hover = item; requestAnimationFrame(draw); } + showTooltip(item, sx, sy); + const w = toWorld(sx, sy); document.getElementById("coords").textContent = `bin (${w[0].toFixed(1)}, ${w[1].toFixed(1)})`; +}); +canvas.addEventListener("pointerup", e => { + canvas.releasePointerCapture(e.pointerId); + if (!moved && e.button === 0) { const rect = canvas.getBoundingClientRect(); const w = toWorld(e.clientX - rect.left, e.clientY - rect.top); addMark(w[0], w[1]); } + drag = null; +}); +canvas.addEventListener("pointerleave", () => { state.hover = null; tooltip.hidden = true; draw(); }); +canvas.addEventListener("wheel", e => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left, sy = e.clientY - rect.top; + const factor = Math.exp(-e.deltaY * 0.0015); + const v = state.view; + v.tx = sx - (sx - v.tx) * factor; v.ty = sy - (sy - v.ty) * factor; v.scale *= factor; + requestAnimationFrame(draw); +}, { passive: false }); +window.addEventListener("keydown", e => { + if (e.target.tagName === "TEXTAREA") return; + if (CATEGORIES[e.key]) { state.category = e.key; renderPanel(); } + else if (e.key === "u") { const marks = state.marks[state.map]; if (marks.length) removeMark(marks.length - 1); } + else if (e.key === "f") { fitView(); draw(); } + else if (e.key === "c") { copyReport(); } + else if (e.key === "Escape") { state.hover = null; tooltip.hidden = true; draw(); } +}); +function copyReport() { + const ta = document.getElementById("report"); ta.select(); + const done = () => { document.getElementById("copied").textContent = "copied"; setTimeout(() => { document.getElementById("copied").textContent = ""; }, 1500); }; + if (navigator.clipboard) navigator.clipboard.writeText(ta.value).then(done, () => { document.execCommand("copy"); done(); }); else { document.execCommand("copy"); done(); } +} +function selectMap(i) { + state.map = i; state.hover = null; state.view = null; + document.querySelectorAll(".tabs button").forEach((b, j) => b.setAttribute("aria-pressed", String(i === j))); + fitView(); renderPanel(); draw(); +} +const tabs = document.querySelector(".tabs"); +MAPS.forEach((m, i) => { + const b = document.createElement("button"); b.textContent = m.name; b.type = "button"; + b.setAttribute("aria-pressed", String(i === 0)); b.addEventListener("click", () => selectMap(i)); tabs.appendChild(b); +}); +document.querySelectorAll(".seg button").forEach(b => b.addEventListener("click", () => { state.category = b.dataset.cat; renderPanel(); })); +document.getElementById("compare").addEventListener("change", e => { state.compare = e.target.checked; draw(); }); +document.getElementById("lines").addEventListener("change", e => { state.lines = e.target.checked; draw(); }); +document.getElementById("lanes").addEventListener("change", e => { state.lanes = e.target.checked; draw(); }); +document.getElementById("fit").addEventListener("click", () => { fitView(); draw(); }); +document.getElementById("copy").addEventListener("click", copyReport); +document.getElementById("clear").addEventListener("click", () => { state.marks[state.map] = []; renderPanel(); draw(); }); +document.getElementById("legend").innerHTML = `road edge (bin)median edgeold edge (compare)lane centerlinejunction lane`; +window.addEventListener("resize", resize); +window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { renderPanel(); draw(); }); +new MutationObserver(() => { renderPanel(); draw(); }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); +renderPanel(); +resize(); +""" + + +def build_html(maps: list[dict], meta: dict) -> str: + payload = json.dumps({"maps": maps, "meta": meta}, separators=(",", ":")) + head = ( + "PufferDrive Map Inspector\n" + '\n' + '\n' + f"\n" + ) + compare_note = f" · old edges: {meta['compare_dir']}" if meta.get("compare_dir") else "" + body = f""" +
+
+

PufferDrive Map Inspector

+
+
+ + + +
+
+ + + + + +
+
+
+ +
+ +
drag to pan · wheel to zoom · click to drop a mark (1/2/3 category) · u undo · c copy report · bins: {meta['bin_dir']}{compare_note}
+
+ +
+ + +""" + return f'\n\n\n\n\n{head}\n{body}\n\n' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--bin-dir", required=True, help="Directory of PufferDrive *.bin map files to inspect") + parser.add_argument("--compare-dir", default=None, help="Second bin directory whose road edges are overlaid in orange") + parser.add_argument("--towns", nargs="*", default=None, help="Subset of towns, e.g. Town04 Town06 (default: all bins)") + parser.add_argument("--output", default=None, help="Output .html (default: /map_inspector.html)") + args = parser.parse_args() + + bin_dir = pathlib.Path(args.bin_dir).expanduser() + compare_dir = pathlib.Path(args.compare_dir).expanduser() if args.compare_dir else None + bin_files = sorted(bin_dir.glob("*.bin")) + if args.towns: + bin_files = [f for f in bin_files if f.stem.split("__")[-1] in set(args.towns)] + if not bin_files: + sys.exit(f"no matching .bin files in {bin_dir}") + maps = [] + for bin_file in bin_files: + compare_file = compare_dir / bin_file.name if compare_dir and (compare_dir / bin_file.name).exists() else None + maps.append(collect_map(bin_file, compare_file)) + meta = {"bin_dir": str(bin_dir), "compare_dir": str(compare_dir) if compare_dir else None} + output = pathlib.Path(args.output) if args.output else bin_dir / "map_inspector.html" + output.write_text(build_html(maps, meta)) + print(f"wrote {output} ({output.stat().st_size / 1e6:.1f} MB, {len(maps)} maps)") + + +if __name__ == "__main__": + main() From 493c5114c5d722dd8d4cfbc446abbf3acce2f8cc Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 20:29:50 +0200 Subject: [PATCH 032/123] render eval for alpasim --- scripts/kesai/12_alpasim.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index f14b08989d..e3f0d1ceb1 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -23,13 +23,13 @@ set -u # quick check: TOPOLOGY=1gpu N_SCENES=2; full run: TOPOLOGY=8gpu_64rollouts (whole node) N_SCENES=0 (the whole SCENE_LIST) -TOPOLOGY=1gpu -N_SCENES=2 +TOPOLOGY=8gpu_64rollouts +N_SCENES=0 RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} CKPT=$RUN_DIR/final_model.pt [ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) GOAL_MODE=gt_time:5 # gt_time: | gt | route -RENDER_VIDEO=1 # one mp4 per scene +RENDER_VIDEO=0 # one mp4 per scene; 1 also streams the teacher BEV and makes every policy step ~5x slower CAM_W=640 # 16:9 render size; the teacher never sees the cameras CAM_H=360 SIM_TIMEOUT_S=3600 # per batch of PARALLEL scenes From 6d8c9be0b5ed85912d676184f0ccca53810d5d41 Mon Sep 17 00:00:00 2001 From: Valentin Charraut Date: Thu, 1 Oct 2026 20:49:13 +0200 Subject: [PATCH 033/123] Clarify partner blindness behavior in comments and update visibility logic in write_partner_obs function --- pufferlib/config/puffer_drive.yaml | 2 +- pufferlib/ocean/drive/datatypes.h | 4 ++-- pufferlib/ocean/drive/drive.h | 21 +++++++++++++-------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/pufferlib/config/puffer_drive.yaml b/pufferlib/config/puffer_drive.yaml index 11f5542d6b..8787536539 100644 --- a/pufferlib/config/puffer_drive.yaml +++ b/pufferlib/config/puffer_drive.yaml @@ -209,7 +209,7 @@ env: # Erratic agents are selected once per episode; masked out of the PPO rollout buffer. # Upper bound of the per-episode blind-agent rate; each episode draws its rate uniformly from [0, this] partner_blindness_prob: 0.05 - # Per-step probability that a flagged blind-partner agent zeros out its partner observations + # Per-step probability that a flagged blind-partner agent restricts its partner observations partner_blindness_trigger_prob: 0.03 # Duration in seconds of partner blindness once triggered partner_blindness_duration_seconds: 3.0 diff --git a/pufferlib/ocean/drive/datatypes.h b/pufferlib/ocean/drive/datatypes.h index 0067e222e8..380cba0ea9 100644 --- a/pufferlib/ocean/drive/datatypes.h +++ b/pufferlib/ocean/drive/datatypes.h @@ -129,8 +129,8 @@ struct Agent { float reward_coefs[NUM_REWARD_COEFS]; int phantom_braking_counter; // >0 means currently phantom braking - int partner_blindness_counter; // >0 means currently blind to partners - unsigned char is_blind_partner; // episode-level flag: agent sees no other agents + int partner_blindness_counter; // >0 means partner visibility is restricted to the forward centerline + unsigned char is_blind_partner; // episode-level flag: agent may restrict partner visibility unsigned char is_phantom_braker; // episode-level flag: agent may phantom-brake }; diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index d1120e3bf7..ea4499caab 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -4064,7 +4064,7 @@ static int write_reward_target_obs(Drive *env, Agent *ego, float *obs, int obs_i } static int write_partner_obs(Drive *env, Agent *ego, int agent_idx, float *obs, int obs_idx, int *partner_count) { - // Partner blindness: zero partner obs for the configured duration once triggered + // Partner blindness restricts visibility to the forward centerline while triggered. if (ego->partner_blindness_counter > 0) { ego->partner_blindness_counter--; } @@ -4072,13 +4072,6 @@ static int write_partner_obs(Drive *env, Agent *ego, int agent_idx, float *obs, && sample_uniform(&env->rng_state, 0.0f, 1.0f) < env->partner_blindness_trigger_prob) { ego->partner_blindness_counter = env->partner_blindness_duration; } - if (ego->partner_blindness_counter > 0) { - int partner_obs_stride = env->obs_slots_partners_n * partner_feature_count(env); - memset(&obs[obs_idx], 0, partner_obs_stride * sizeof(float)); - *partner_count = 0; - return obs_idx + partner_obs_stride; - } - typedef struct { int index; float dist_sq; @@ -4104,6 +4097,18 @@ static int write_partner_obs(Drive *env, Agent *ego, int agent_idx, float *obs, if (dist_sq > env->obs_range_partner_m * env->obs_range_partner_m) { continue; } + bool partner_visible = true; + if (ego->partner_blindness_counter > 0) { + float rel_x, rel_y, rel_heading_x, rel_heading_y; + project_point_to_ego_frame(ego, other->sim_x, other->sim_y, &rel_x, &rel_y); + project_vector_to_ego_frame(ego, other->cos_heading, other->sin_heading, &rel_heading_x, &rel_heading_y); + float lateral_half_extent_m + = 0.5f * (fabsf(rel_heading_y) * other->sim_length + fabsf(rel_heading_x) * other->sim_width); + partner_visible = rel_x > 0.0f && fabsf(rel_y) <= lateral_half_extent_m; + } + if (!partner_visible) { + continue; + } nearby_agents[nearby_count].index = index; nearby_agents[nearby_count].dist_sq = dist_sq; nearby_agents[nearby_count].dz = dz; From 21163639c9da2ec7848b4edefc8aaece13c2f47e Mon Sep 17 00:00:00 2001 From: Valentin Charraut Date: Fri, 2 Oct 2026 09:39:45 +0200 Subject: [PATCH 034/123] Update partner blindness comments and logic for visibility corridor --- pufferlib/ocean/drive/datatypes.h | 2 +- pufferlib/ocean/drive/drive.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pufferlib/ocean/drive/datatypes.h b/pufferlib/ocean/drive/datatypes.h index 380cba0ea9..b3df9f05ed 100644 --- a/pufferlib/ocean/drive/datatypes.h +++ b/pufferlib/ocean/drive/datatypes.h @@ -129,7 +129,7 @@ struct Agent { float reward_coefs[NUM_REWARD_COEFS]; int phantom_braking_counter; // >0 means currently phantom braking - int partner_blindness_counter; // >0 means partner visibility is restricted to the forward centerline + int partner_blindness_counter; // >0 means partner visibility is restricted to an ego-width forward corridor unsigned char is_blind_partner; // episode-level flag: agent may restrict partner visibility unsigned char is_phantom_braker; // episode-level flag: agent may phantom-brake }; diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index ea4499caab..b95a1c5ca3 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -4064,7 +4064,7 @@ static int write_reward_target_obs(Drive *env, Agent *ego, float *obs, int obs_i } static int write_partner_obs(Drive *env, Agent *ego, int agent_idx, float *obs, int obs_idx, int *partner_count) { - // Partner blindness restricts visibility to the forward centerline while triggered. + // Partner blindness restricts visibility to an ego-width forward corridor while triggered. if (ego->partner_blindness_counter > 0) { ego->partner_blindness_counter--; } @@ -4104,7 +4104,7 @@ static int write_partner_obs(Drive *env, Agent *ego, int agent_idx, float *obs, project_vector_to_ego_frame(ego, other->cos_heading, other->sin_heading, &rel_heading_x, &rel_heading_y); float lateral_half_extent_m = 0.5f * (fabsf(rel_heading_y) * other->sim_length + fabsf(rel_heading_x) * other->sim_width); - partner_visible = rel_x > 0.0f && fabsf(rel_y) <= lateral_half_extent_m; + partner_visible = rel_x > 0.0f && fabsf(rel_y) <= lateral_half_extent_m + 0.5f * ego->sim_width; } if (!partner_visible) { continue; From c800540dbbc796e9dcb37fc9818435cbc2b2aefd Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Thu, 8 Oct 2026 21:59:33 +0200 Subject: [PATCH 035/123] update maps to exclude shoulder lanes outside intersections. --- .../binaries/carla/opendrive__Town03.bin | Bin 967984 -> 983280 bytes .../binaries/carla/opendrive__Town04.bin | Bin 2193108 -> 2242228 bytes .../binaries/carla/opendrive__Town05.bin | Bin 1576604 -> 1607116 bytes .../binaries/carla/opendrive__Town06.bin | Bin 1076024 -> 1107640 bytes .../binaries/carla/opendrive__Town07.bin | Bin 561228 -> 567980 bytes .../binaries/carla/opendrive__Town10HD.bin | Bin 178376 -> 184600 bytes .../kesai/3_train_64GPU_multinode_slurm.sh | 6 +++--- 7 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town03.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town03.bin index 0612a6ca6601e9a94f684c0e3dfb4250e376475e..c7ba58f85e74714bfc140b9a6b9056e131fad226 100644 GIT binary patch delta 17615 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 hLKg&O1QCJ?f+~UXP!a{j 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 delta 150 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 delta 112 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 SF)tAF0WtsfE6xH>76Aan6Cc+A diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town07.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town07.bin index 2573c12d1543ae9b68c91b8316a3f9c1b06c7528..71a8e8330925818ece455edb42375bb850bc6880 100644 GIT binary patch delta 6050 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 delta 54 zcmZ2;Rq4zEB{l{i_|MqLwv~->S#Uc~9wQJl0WmWWvj8zG5VHX>`*xl@js-RV#IX;o diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town10HD.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town10HD.bin index bba155c7920b0c56719e8e0c88cc95dc48262e31..23e7172b3c16e741c853c6ba0bc36c31ae5a70e3 100644 GIT binary patch delta 6099 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 delta 1273 zcmXw#4NTQl7{+<;zaF)VcPF~q>rjga8pdlceA1jbPL7Lt^Z2#kWv=)HIAzdgV6Jm)<-|7WM`V} zo=ysuXttQ=cZKaImxROqzPQUj*Rp?z_xY#GStSiBFg9S1B8}Ypqzo(%bV5VG7WN*c zOiNlBn9I^B9plA;$#Paq_A`VHP0ZZ%0yl2(1(~{B>|^H)7c(!apY7y zH!M6gd>KW9|4yyA`ntAXDQRbZjMiF}Zr3V;qO|7y&;#2ePNzFW7#_J9=GSwB^`*6j zO;a8b=6y_*d91m=V_oy9T}%4@E2H|8A(N5SPh?RQoq6GA^`WkE3hwaoDm#F?ytcXk z;2saH$p+}>x~nRH0e-ty1-l2Sg_5KoSyc=f_g|hiCR~^{4(uH?emeGp(Q&BRIM`NY zEOBo$n&%C9tHd^;6orzN`0g+huE%Y}*mYa+@C$pfQ@&i7=hV*IN9J_gTPV@#_G{mQ`QNLka{Hh5?P**nyqr9k|YTc(c|JRC+Q;C)Y zcd??{RaARAi=G@uF?qs4WZd!={V{8WdA<~MO$6K2h}XR9MEL+0y}KL4#P|(S+6&1IZ~aCM@C#3ApqG5eC%&ZvkN9k3JisK6ZR!O072TS%VTut&3I4{I_H1~Ht?lvf4|yG;OYn9v^&?$}6Fp>jYC9=MIT22+V^@gmU^TL(1oJi?#`yl^XV9cB?DlF8 Date: Fri, 9 Oct 2026 16:56:09 +0200 Subject: [PATCH 036/123] update path --- scripts/kesai/12_alpasim.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index e3f0d1ceb1..3af9313981 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -38,8 +38,8 @@ ALPAGYM_ROOT=/home/bjaeger/alpasim-training # held-out split Damiano evaluates on; empty = every converted scene that passed the checks (2149 on the cluster, 1584 of them train) SCENE_LIST=$ALPAGYM_ROOT/scene_splits/eval_405.txt export ALPASIM_ROOT=/home/bjaeger/alpasim -# Damiano's conversion; /home/shared/data/nurec/all-usdzs is a symlink farm that dangles inside the containers -BINS=/home/ddacol/code/alpasim-training/data/distill/bins_130kmh +# shoulder lanes off-road (py123d 1_bernhard_dev); USDZ_DIR stays Damiano's, the shared all-usdzs symlinks dangle inside the containers +BINS=/home/shared/data/nurec/bins/eval405_130kmh_ndshoulders USDZ_DIR=/home/ddacol/code/alpasim-training/data/distill/usdz ALPASIM_IMAGE_DIR=/home/ddacol/code/alpasim-training/data/images case "$TOPOLOGY" in From 721b79dda0f5eb427628a8ad2a456bccf4d1a33e Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Fri, 9 Oct 2026 19:10:26 +0200 Subject: [PATCH 037/123] remove nuPlan floor and add an option to increase pedestrian size by 0.8. --- pufferlib/ocean/cosim/carla/README.md | 4 ++- pufferlib/ocean/cosim/carla/shadow_ego.py | 17 +++++++++--- pufferlib/ocean/cosim/nuplan/README.md | 5 +++- .../config/planner/pufferdrive_planner.yaml | 2 ++ pufferlib/ocean/cosim/nuplan/planner.py | 9 +++++++ .../ocean/cosim/nuplan/run_nuplan_planner.sh | 3 +++ pufferlib/ocean/cosim/nuplan_bridge.py | 9 +++++++ scripts/kesai/11_carla_longest6_v2.sh | 7 ++++- scripts/kesai/12_alpasim.sh | 5 +++- scripts/kesai/14_carla_longest6_v1.sh | 11 +++++--- scripts/kesai/9_nuPlan.sh | 6 ++--- .../test_nuplan_bridge_partner_sizes.py | 26 ++++++++++++++++--- 12 files changed, 86 insertions(+), 18 deletions(-) diff --git a/pufferlib/ocean/cosim/carla/README.md b/pufferlib/ocean/cosim/carla/README.md index 0a6f489248..71047e3247 100644 --- a/pufferlib/ocean/cosim/carla/README.md +++ b/pufferlib/ocean/cosim/carla/README.md @@ -43,7 +43,9 @@ Optional env vars (see `leaderboard_agent.py` docstring): `COSIM_DEVICE`, `COSIM_DYNAMICS_SOURCE`, `COSIM_NUM_AGENTS`, `COSIM_MAX_SPEED_MPS` (ego speed cap via C_vel; default = the checkpoint's `base_max_speed_mps`, must stay inside the trained range), `COSIM_ZERO_PARTNER_STOPPED_TIME` (1 = partners' `seconds_stopped` obs feature held at 0, ego -untouched; ablation for the parked-car / red-light-queue avoidance), `COSIM_OBS_HTML`, +untouched; ablation for the parked-car / red-light-queue avoidance), `COSIM_PEDESTRIAN_MIN_SIZE_M` +and `COSIM_PEDESTRIAN_SIZE_PAD_M` (walker boxes in the policy's obs: a floor on each axis, then an +additive pad; the longest6 scripts set the pad to 0.8 m), `COSIM_OBS_HTML`, `COSIM_DEBUG_CARLA_VIEW`, `COSIM_RECORD_INFRACTIONS`. ### Ego dynamics source diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py index 135a473f5d..17706933fb 100644 --- a/pufferlib/ocean/cosim/carla/shadow_ego.py +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -46,6 +46,10 @@ walker partner boxes below this on either axis grow to it (nuPlan's pedestrian_min_size_m; the training spawn floor is 0.8 m, CARLA walkers are 0.4-0.5 m) + COSIM_PEDESTRIAN_SIZE_PAD_M=0.0 + added to every walker partner box's length and width after the floor + (nuPlan's pedestrian_size_pad_m; 0.8 lifts CARLA walkers to 1.2-1.3 m, + strictly above the smallest training vehicle) COSIM_TELEMETRY=/dir write a per-policy-step CSV per route, with the shadow env's and the simulator's infraction flags as columns COSIM_OBS_HTML=/dir write an interactive pufferlib.viz replay per route (the exact obs + @@ -205,6 +209,9 @@ def __init__(self, path_to_conf_file, route_tag): self.pedestrian_min_size_m = float(os.environ.get("COSIM_PEDESTRIAN_MIN_SIZE_M", "0.0")) if not (self.pedestrian_min_size_m >= 0.0): raise ValueError(f"COSIM_PEDESTRIAN_MIN_SIZE_M must be >= 0, got {self.pedestrian_min_size_m}") + self.pedestrian_size_pad_m = float(os.environ.get("COSIM_PEDESTRIAN_SIZE_PAD_M", "0.0")) + if not (self.pedestrian_size_pad_m >= 0.0): + raise ValueError(f"COSIM_PEDESTRIAN_SIZE_PAD_M must be >= 0, got {self.pedestrian_size_pad_m}") # Shadow agent pool == the training per-env cap self.num_agents = int(env_cfg["max_agents_per_env"]) @@ -415,7 +422,7 @@ def init_route(self, route): f"[puffer_agent] town={town} tick_dt={self.tick_dt} dt={self.dt} route_goals={len(self.route_goals)} " f"max_speed_mps={self.env.max_speed_mps:.1f} (C_vel={self.env.max_speed_mps / self.env.base_max_speed_mps:.2f}) " f"zero_partner_stopped_time={int(self.zero_partner_stopped_time)} " - f"pedestrian_min_size_m={self.pedestrian_min_size_m:g}" + f"pedestrian_min_size_m={self.pedestrian_min_size_m:g} pedestrian_size_pad_m={self.pedestrian_size_pad_m:g}" ) self.initialized = True @@ -472,9 +479,11 @@ def _read_sizes(self, partners): idx, length, width = [], [], [] for j, row in enumerate(partners): idx.append(1 + j) - walker_floor_m = self.pedestrian_min_size_m if row[PARTNER_IS_WALKER] > 0.0 else 0.0 - length.append(max(2.0 * row[PARTNER_EXTENT_X], MIN_PARTNER_SIZE_M, walker_floor_m)) - width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M, walker_floor_m)) + is_walker = row[PARTNER_IS_WALKER] > 0.0 + walker_floor_m = self.pedestrian_min_size_m if is_walker else 0.0 + walker_pad_m = self.pedestrian_size_pad_m if is_walker else 0.0 + length.append(max(2.0 * row[PARTNER_EXTENT_X], MIN_PARTNER_SIZE_M, walker_floor_m) + walker_pad_m) + width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M, walker_floor_m) + walker_pad_m) return (np.array(idx, np.int32), np.array(length, np.float32), np.array(width, np.float32)) def _read_light_states(self, light_states, ego_light_id): diff --git a/pufferlib/ocean/cosim/nuplan/README.md b/pufferlib/ocean/cosim/nuplan/README.md index a035df5e5e..f9cd0ad04a 100644 --- a/pufferlib/ocean/cosim/nuplan/README.md +++ b/pufferlib/ocean/cosim/nuplan/README.md @@ -34,7 +34,10 @@ Zero changes to nuplan-devkit or CaRL's `carl_nuplan`. Hydra loads any planner v `PEDESTRIAN_MIN_SIZE_M`): pedestrian and bicycle partner boxes are grown to at least this size in the policy's observation (training spawns nothing below 0.8 x 0.8 m, nuPlan pedestrians are 0.4-0.8 m); nuPlan still scores the true boxes. Off by - default, script 9 sets 0.8 m. The shadow env consumes the goals of its + default and in script 9. `pedestrian_size_pad_m` (env `PEDESTRIAN_SIZE_PAD_M`) + instead adds this much to every pedestrian box's length and width (after the floor + when both are set), so pedestrians read strictly larger than the smallest training + vehicle; off by default, script 9 sets 0.8 m. The shadow env consumes the goals of its `num_goals` window itself exactly like training (`goal_regen_mode: finite`, consumed slots zeroed in the obs); the planner pushes the next window only when the current one is exhausted or its current goal is clearly behind the diff --git a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml index 6bef40984c..887c9c9a85 100644 --- a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml +++ b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml @@ -23,6 +23,8 @@ pufferdrive_planner: startup_brake_jerk_cap_mps3: 0.0 # eval hack: grow pedestrian/bicycle partner boxes to at least this size [m] in the policy's obs (training min 0.8); 0 = off pedestrian_min_size_m: 0.0 + # eval hack: grow pedestrian partner boxes by this much [m] in length and width, after the floor; 0 = off + pedestrian_size_pad_m: 0.0 env_overrides: obs_slots_partners_n: 40 # GIGAFLOW eval value (Table A4); slots are max-pooled, so any checkpoint transfers stop_signs_enabled: false # nuPlan city bins export TURN_STOP zones as stop signs; keep them out of the obs diff --git a/pufferlib/ocean/cosim/nuplan/planner.py b/pufferlib/ocean/cosim/nuplan/planner.py index 512f590d45..382e391bbf 100644 --- a/pufferlib/ocean/cosim/nuplan/planner.py +++ b/pufferlib/ocean/cosim/nuplan/planner.py @@ -94,6 +94,7 @@ def __init__( startup_accel_jerk_cap_mps3: float = 0.0, startup_brake_jerk_cap_mps3: float = 0.0, pedestrian_min_size_m: float = 0.0, + pedestrian_size_pad_m: float = 0.0, env_overrides: Optional[Dict] = None, obs_html_dir: Optional[str] = None, obs_html_max_steps: int = 800, @@ -128,6 +129,9 @@ def __init__( :param pedestrian_min_size_m: eval hack: pedestrian and bicycle partner boxes are grown to at least this length and width [m] before the policy sees them (training spawns nothing below 0.8 x 0.8 m, nuPlan pedestrians are 0.4-0.8 m); nuPlan keeps scoring the true boxes. 0 = off. + :param pedestrian_size_pad_m: eval hack: pedestrian partner boxes grow by this much in length and + width [m] after the floor, so they read strictly larger than the smallest training vehicle + (0.8 x 0.8 m); nuPlan keeps scoring the true boxes. 0 = off. :param env_overrides: Drive env kwargs overriding DEFAULT_ARCH. :param obs_html_dir: write the interactive pufferlib.viz observation replay per scenario (the exact obs the policy received, its outputs and encoder pool winners). @@ -160,6 +164,9 @@ def __init__( self._pedestrian_min_size_m = float(pedestrian_min_size_m) if self._pedestrian_min_size_m < 0: raise ValueError("pedestrian_min_size_m must be >= 0") + self._pedestrian_size_pad_m = float(pedestrian_size_pad_m) + if self._pedestrian_size_pad_m < 0: + raise ValueError("pedestrian_size_pad_m must be >= 0") self._startup_jerk_cap_steps = 0 # resolved in _build from the scenario dt self._startup_action_bounds = None # (lo, hi) on the continuous jerk action while the cap is live self._env_overrides = env_overrides or {} @@ -511,6 +518,8 @@ def _sync(self, ego_state: EgoState, detections: DetectionsTracks, traffic_light idx, x, y, z, hh, vx, vy, tp, ln, wd = nb.tracked_objects_to_arrays(objs, tf) if self._pedestrian_min_size_m > 0: ln, wd = nb.floor_vru_partner_sizes(tp, ln, wd, self._pedestrian_min_size_m) + if self._pedestrian_size_pad_m > 0: + ln, wd = nb.pad_pedestrian_partner_sizes(tp, ln, wd, self._pedestrian_size_pad_m) env.set_agent_states(idx, x, y, z, hh, vx, vy, np.zeros_like(vx), np.zeros_like(vx)) env.set_agent_sizes(idx, ln, wd) surplus = np.arange(1 + len(objs), self._num_agents, dtype=np.int32) diff --git a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh index e8d45484ac..740ae7d6f2 100755 --- a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh +++ b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh @@ -39,6 +39,8 @@ # STARTUP_JERK_CAP_SECONDS (default 1.5) of every scenario; unset/0 = off. # PEDESTRIAN_MIN_SIZE_M [m] grow pedestrian/bicycle partner boxes to at least this size in the # policy's observation (training never spawns below 0.8 m); unset/0 = off. +# PEDESTRIAN_SIZE_PAD_M [m] add this to pedestrian partner boxes' length and width in the policy's +# observation, after the floor (0.8 = strictly above the smallest training vehicle); unset/0 = off. # OBS_SLOTS_PARTNERS_N shadow-env partner obs slots (unset = planner yaml, 40). # EVAL_PERCEIVED_SIZE_MARGIN_M [m per side] ego box inflation in the policy's obs (unset = arch.py, 0.2). # REWARD_OVERSPEED overspeed conditioning alpha fed to the policy (unset = arch.py, 0.1). @@ -87,6 +89,7 @@ EXTRA_ARGS+=("planner.pufferdrive_planner.city_bin_dir=$CITY_BIN_DIR") [ -n "${STARTUP_ACCEL_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_accel_jerk_cap_mps3=$STARTUP_ACCEL_JERK_CAP") [ -n "${STARTUP_BRAKE_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_brake_jerk_cap_mps3=$STARTUP_BRAKE_JERK_CAP") [ -n "${PEDESTRIAN_MIN_SIZE_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_min_size_m=$PEDESTRIAN_MIN_SIZE_M") +[ -n "${PEDESTRIAN_SIZE_PAD_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_size_pad_m=$PEDESTRIAN_SIZE_PAD_M") [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.obs_slots_partners_n=$OBS_SLOTS_PARTNERS_N") [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.eval_perceived_size_margin_m=$EVAL_PERCEIVED_SIZE_MARGIN_M") [ -n "${REWARD_OVERSPEED:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.reward_overspeed=$REWARD_OVERSPEED") diff --git a/pufferlib/ocean/cosim/nuplan_bridge.py b/pufferlib/ocean/cosim/nuplan_bridge.py index 599ca2fdc7..3b301288cf 100644 --- a/pufferlib/ocean/cosim/nuplan_bridge.py +++ b/pufferlib/ocean/cosim/nuplan_bridge.py @@ -620,6 +620,15 @@ def floor_vru_partner_sizes(types, lengths, widths, min_size_m: float): return lengths, widths +def pad_pedestrian_partner_sizes(types, lengths, widths, pad_m: float): + """Pedestrian boxes grow by pad_m in length and width (0.8 puts every nuPlan pedestrian strictly + above the 0.8 x 0.8 m training minimum). Other types untouched.""" + pedestrian = types == _NUPLAN_AGENT_TYPE["PEDESTRIAN"] + lengths = np.where(pedestrian, lengths + pad_m, lengths).astype(np.float32) + widths = np.where(pedestrian, widths + pad_m, widths).astype(np.float32) + return lengths, widths + + def tracked_objects_to_arrays(tracked_objects, transform: NuPlanTransform, first_slot: int = 1): """nuPlan DetectionsTracks agents -> (idx, x, y, z, h, vx, vy, types, lengths, widths) arrays in the bin frame, filling PufferDrive slots first_slot..N.""" diff --git a/scripts/kesai/11_carla_longest6_v2.sh b/scripts/kesai/11_carla_longest6_v2.sh index d5a208f66b..04fa675f7c 100644 --- a/scripts/kesai/11_carla_longest6_v2.sh +++ b/scripts/kesai/11_carla_longest6_v2.sh @@ -27,7 +27,9 @@ # folder $OUT/obs_html at the end, large; needs LOGGING=1), REPORT (0 = skip the HTML report), # MAX_ATTEMPTS, CARLA_ROOT, CARL_WORK_DIR, PY, PD, COSIM_MAX_SPEED_MPS (ego speed cap, default 30), # COSIM_ZERO_PARTNER_STOPPED_TIME (default 1: partners' stopped-time obs held at 0; 0 = real stopped times), -# COSIM_PEDESTRIAN_MIN_SIZE_M (default 0: true CARLA walker boxes; 0.8 = the training spawn floor). +# COSIM_PEDESTRIAN_MIN_SIZE_M (default 0: true CARLA walker boxes; 0.8 = the training spawn floor), +# COSIM_PEDESTRIAN_SIZE_PAD_M (default 0.8: added to every walker box's length and width after the floor, +# so walkers read strictly larger than the smallest training vehicle; 0 = true boxes). set -u export PD=${PD:-/home/bjaeger/PufferDrive} @@ -76,15 +78,18 @@ export COSIM_DYNAMICS_SOURCE=pufferdrive export COSIM_MAX_SPEED_MPS=${COSIM_MAX_SPEED_MPS:-30} export COSIM_ZERO_PARTNER_STOPPED_TIME=${COSIM_ZERO_PARTNER_STOPPED_TIME:-1} export COSIM_PEDESTRIAN_MIN_SIZE_M=${COSIM_PEDESTRIAN_MIN_SIZE_M:-0} +export COSIM_PEDESTRIAN_SIZE_PAD_M=${COSIM_PEDESTRIAN_SIZE_PAD_M:-0.8} export COSIM_OBS_HTML_MAX_STEPS=${COSIM_OBS_HTML_MAX_STEPS:-20000} export COSIM_OBS_HTML_RENDER=0 # routes save the compact replay only; render_carla_obs_html.py renders all pages into $OUT/obs_html TAG=longest6_v2$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") +TAG=$TAG$([ "$COSIM_PEDESTRIAN_SIZE_PAD_M" = "0" ] || echo "_pedpad$COSIM_PEDESTRIAN_SIZE_PAD_M") # results live in the model's own eval folder, next to the PufferDrive benchmark evals OUT=$RUN_DIR/eval/carla_${TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT/routes" "$OUT/aggregate" { echo "checkpoint $CKPT"; echo "routes $ROUTES"; echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS" + echo "pedestrian_size_pad_m $COSIM_PEDESTRIAN_SIZE_PAD_M" echo "git $(git -C "$PD" rev-parse --short HEAD 2>/dev/null) $(git -C "$PD" diff --quiet 2>/dev/null || echo dirty)" } > "$OUT/run_info.txt" echo "Results -> $OUT" diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 3af9313981..c0ded2026e 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -29,6 +29,7 @@ RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} CKPT=$RUN_DIR/final_model.pt [ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) GOAL_MODE=gt_time:5 # gt_time: | gt | route +PEDESTRIAN_SIZE_PAD_M=0.8 # added to every pedestrian box's length and width the teacher sees; 0 = true boxes RENDER_VIDEO=0 # one mp4 per scene; 1 also streams the teacher BEV and makes every policy step ~5x slower CAM_W=640 # 16:9 render size; the teacher never sees the cameras CAM_H=360 @@ -112,10 +113,11 @@ EOF [ ${#SCENES[@]} -gt 0 ] || { echo "no scenes to evaluate"; exit 1; } # results live in the model's own eval folder, next to the PufferDrive benchmark evals -OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} +OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}$([ "$PEDESTRIAN_SIZE_PAD_M" = "0" ] || echo "_pedpad$PEDESTRIAN_SIZE_PAD_M")_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT" { echo "checkpoint $CKPT"; echo "scenes ${#SCENES[@]}"; echo "topology $TOPOLOGY parallel $PARALLEL goal_mode $GOAL_MODE" + echo "pedestrian_size_pad_m $PEDESTRIAN_SIZE_PAD_M" for repo in "$PD" "$ALPAGYM_ROOT" "$ALPASIM_ROOT"; do echo "git $repo $(git -C "$repo" rev-parse --short HEAD 2>/dev/null) $(git -C "$repo" diff --quiet 2>/dev/null || echo dirty)" done @@ -161,6 +163,7 @@ cd "$ALPAGYM_ROOT" || exit 1 --teacher_checkpoint "$CKPT" \ --teacher_bins_dir "$BINS" \ --teacher_goal_mode "$GOAL_MODE" \ + --teacher_pedestrian_size_pad_m "$PEDESTRIAN_SIZE_PAD_M" \ --render_video "$RENDER_VIDEO_FLAG" \ --local_scene_dir "$USDZ_DIR" \ --scene_ids "${SCENES[@]}" \ diff --git a/scripts/kesai/14_carla_longest6_v1.sh b/scripts/kesai/14_carla_longest6_v1.sh index 71378b8f41..5df2d74f09 100644 --- a/scripts/kesai/14_carla_longest6_v1.sh +++ b/scripts/kesai/14_carla_longest6_v1.sh @@ -28,9 +28,10 @@ # the interactive obs replay per route, rendered into $OUT/obs_html at the end, large; needs LOGGING=1), REPORT # (0 = skip the HTML report), MAX_ATTEMPTS, CARLA_ROOT, GARAGE_WORK_DIR, GARAGE_PY, SERVER_PY, PD, # COSIM_MAX_SPEED_MPS (ego speed cap, default 30), COSIM_ZERO_PARTNER_STOPPED_TIME (default 1), -# COSIM_PEDESTRIAN_MIN_SIZE_M (default 0), LIBTIFF5_COMPAT (1: when `import carla` fails for the egg's missing -# libtiff.so.5, try the garage env's lib dir (`conda install -n garage "libtiff<4.5"`), then link the system -# libtiff.so.6 under that name; 0: never link). +# COSIM_PEDESTRIAN_MIN_SIZE_M (default 0), COSIM_PEDESTRIAN_SIZE_PAD_M (default 0.8: added to every walker box's +# length and width after the floor, so walkers read strictly larger than the smallest training vehicle; 0 = true +# boxes), LIBTIFF5_COMPAT (1: when `import carla` fails for the egg's missing libtiff.so.5, try the garage env's +# lib dir (`conda install -n garage "libtiff<4.5"`), then link the system libtiff.so.6 under that name; 0: never link). set -u export PD=${PD:-/home/bjaeger/PufferDrive} @@ -58,6 +59,7 @@ CARLA_VIEW=${CARLA_VIEW:-0} [ "$CARLA_VIEW" = "1" ] && [ "$LOGGING" != "1" ] && { echo "CARLA_VIEW=1 needs LOGGING=1"; exit 1; } OBS_HTML=${OBS_HTML:-1} REPORT=${REPORT:-$LOGGING} +export COSIM_PEDESTRIAN_SIZE_PAD_M=${COSIM_PEDESTRIAN_SIZE_PAD_M:-0.8} EVALUATOR=$GARAGE_WORK_DIR/leaderboard/leaderboard/leaderboard_evaluator_local.py CARLA_EGG=$CARLA_ROOT/PythonAPI/carla/dist/carla-0.9.10-py3.7-linux-x86_64.egg AGENT=$PD/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -75,12 +77,13 @@ NUM_GPUS=${NUM_GPUS:-$(nvidia-smi -L 2>/dev/null | grep -c "^GPU")} if [ "$NUM_GPUS" -lt 1 ] || [ "$NUM_GPUS" -gt 8 ]; then echo "NUM_GPUS=$NUM_GPUS must be 1..8"; exit 1; fi TAG=longest6_v1$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") +TAG=$TAG$([ "$COSIM_PEDESTRIAN_SIZE_PAD_M" = "0" ] || echo "_pedpad$COSIM_PEDESTRIAN_SIZE_PAD_M") # results live in the model's own eval folder, next to the PufferDrive benchmark evals OUT=$RUN_DIR/eval/carla_${TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT/routes" "$OUT/aggregate" { echo "checkpoint $CKPT"; echo "routes $ROUTE_DIR ($ROUTES_XML)"; echo "scenarios $SCENARIO_FILE" - echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS" + echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS"; echo "pedestrian_size_pad_m $COSIM_PEDESTRIAN_SIZE_PAD_M" echo "git $(git -C "$PD" rev-parse --short HEAD 2>/dev/null) $(git -C "$PD" diff --quiet 2>/dev/null || echo dirty)" } > "$OUT/run_info.txt" echo "Results -> $OUT" diff --git a/scripts/kesai/9_nuPlan.sh b/scripts/kesai/9_nuPlan.sh index a823d24f70..170f91620c 100644 --- a/scripts/kesai/9_nuPlan.sh +++ b/scripts/kesai/9_nuPlan.sh @@ -49,8 +49,8 @@ export GOAL_SOURCE=roadblock export SLIDING_GOAL_WINDOW=true export STARTUP_ACCEL_JERK_CAP=2 export STARTUP_BRAKE_JERK_CAP=2 -# pedestrian/bicycle partner boxes grown to the training minimum agent size in the policy's obs (0 = off) -export PEDESTRIAN_MIN_SIZE_M=0.8 +# pedestrian partner boxes grow by this much in length and width in the policy's obs (0 = off): 0.8 puts them strictly above the smallest training vehicle +export PEDESTRIAN_SIZE_PAD_M=0.8 # Shadow-env obs overrides: partner slots (training 20, GIGAFLOW eval 40; 40 real partners max-pooled is OOD in # sidewalk crowds) and the ego perceived-size margin [m per side] (0.2 = native eval; nuPlan's 2.3 x 5.2 m ego # reads as 2.7 x 5.6 m). Unset = planner yaml / arch.py defaults (40 / 0.2). @@ -62,7 +62,7 @@ ABLATION_TAG="" [ "$SLIDING_GOAL_WINDOW" = "true" ] && ABLATION_TAG="${ABLATION_TAG}_slide" [ "$STARTUP_ACCEL_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jacc${STARTUP_ACCEL_JERK_CAP}" [ "$STARTUP_BRAKE_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jbrk${STARTUP_BRAKE_JERK_CAP}" -[ "$PEDESTRIAN_MIN_SIZE_M" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_ped${PEDESTRIAN_MIN_SIZE_M}m" +[ "$PEDESTRIAN_SIZE_PAD_M" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_pedpad${PEDESTRIAN_SIZE_PAD_M}m" [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && ABLATION_TAG="${ABLATION_TAG}_slots${OBS_SLOTS_PARTNERS_N}" [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && ABLATION_TAG="${ABLATION_TAG}_margin${EVAL_PERCEIVED_SIZE_MARGIN_M}" [ -n "${REWARD_OVERSPEED:-}" ] && ABLATION_TAG="${ABLATION_TAG}_overspeed${REWARD_OVERSPEED}" diff --git a/tests/unit_tests/test_nuplan_bridge_partner_sizes.py b/tests/unit_tests/test_nuplan_bridge_partner_sizes.py index 704edc5bea..ab0d62e5b8 100644 --- a/tests/unit_tests/test_nuplan_bridge_partner_sizes.py +++ b/tests/unit_tests/test_nuplan_bridge_partner_sizes.py @@ -1,4 +1,5 @@ -"""floor_vru_partner_sizes: pedestrian/bicycle boxes grow to the floor on each axis, vehicles keep their size.""" +"""Partner size eval hacks: the VRU floor grows pedestrian/bicycle boxes to the floor on each axis, the pad adds +to pedestrian boxes only; vehicles keep their size and the inputs are never modified.""" import os import sys @@ -8,8 +9,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -nuplan = pytest.importorskip("nuplan") - from pufferlib.ocean.cosim import nuplan_bridge as nb @@ -29,3 +28,24 @@ def test_floor_leaves_inputs_untouched(): widths = np.array([0.4], np.float32) nb.floor_vru_partner_sizes(types, lengths, widths, 0.8) assert lengths[0] == pytest.approx(0.4) and widths[0] == pytest.approx(0.4) + + +def test_pad_grows_only_pedestrian_boxes(): + types = np.array([1, 2, 3, 2], np.int32) # vehicle, pedestrian, bicycle, pedestrian + lengths = np.array([4.5, 0.5, 1.7, 0.9], np.float32) + widths = np.array([1.9, 0.6, 0.5, 0.7], np.float32) + ln, wd = nb.pad_pedestrian_partner_sizes(types, lengths, widths, 0.8) + np.testing.assert_allclose(ln, [4.5, 1.3, 1.7, 1.7], rtol=1e-6) + np.testing.assert_allclose(wd, [1.9, 1.4, 0.5, 1.5], rtol=1e-6) + assert ln.dtype == np.float32 and wd.dtype == np.float32 + assert lengths[1] == pytest.approx(0.5) and widths[1] == pytest.approx(0.6) + + +def test_pad_stacks_on_the_floor(): + types = np.array([2, 3], np.int32) # pedestrian, bicycle + lengths = np.array([0.4, 1.7], np.float32) + widths = np.array([0.4, 0.5], np.float32) + ln, wd = nb.floor_vru_partner_sizes(types, lengths, widths, 0.8) + ln, wd = nb.pad_pedestrian_partner_sizes(types, ln, wd, 0.8) + np.testing.assert_allclose(ln, [1.6, 1.7], rtol=1e-6) + np.testing.assert_allclose(wd, [1.6, 0.8], rtol=1e-6) From cd8941c9dd16ffcc1ed11e6cea240c32443a70bf Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Fri, 9 Oct 2026 19:17:38 +0200 Subject: [PATCH 038/123] remove pedestrian floor. --- pufferlib/ocean/cosim/carla/README.md | 6 +-- pufferlib/ocean/cosim/carla/shadow_ego.py | 24 ++++-------- pufferlib/ocean/cosim/nuplan/README.md | 14 +++---- .../config/planner/pufferdrive_planner.yaml | 4 +- pufferlib/ocean/cosim/nuplan/planner.py | 14 ++----- .../ocean/cosim/nuplan/run_nuplan_planner.sh | 6 +-- pufferlib/ocean/cosim/nuplan_bridge.py | 12 ------ scripts/kesai/11_carla_longest6_v2.sh | 6 +-- scripts/kesai/14_carla_longest6_v1.sh | 9 ++--- .../test_nuplan_bridge_partner_sizes.py | 37 ++++--------------- 10 files changed, 37 insertions(+), 95 deletions(-) diff --git a/pufferlib/ocean/cosim/carla/README.md b/pufferlib/ocean/cosim/carla/README.md index 71047e3247..cf678f9801 100644 --- a/pufferlib/ocean/cosim/carla/README.md +++ b/pufferlib/ocean/cosim/carla/README.md @@ -43,9 +43,9 @@ Optional env vars (see `leaderboard_agent.py` docstring): `COSIM_DEVICE`, `COSIM_DYNAMICS_SOURCE`, `COSIM_NUM_AGENTS`, `COSIM_MAX_SPEED_MPS` (ego speed cap via C_vel; default = the checkpoint's `base_max_speed_mps`, must stay inside the trained range), `COSIM_ZERO_PARTNER_STOPPED_TIME` (1 = partners' `seconds_stopped` obs feature held at 0, ego -untouched; ablation for the parked-car / red-light-queue avoidance), `COSIM_PEDESTRIAN_MIN_SIZE_M` -and `COSIM_PEDESTRIAN_SIZE_PAD_M` (walker boxes in the policy's obs: a floor on each axis, then an -additive pad; the longest6 scripts set the pad to 0.8 m), `COSIM_OBS_HTML`, +untouched; ablation for the parked-car / red-light-queue avoidance), `COSIM_PEDESTRIAN_SIZE_PAD_M` +(added to every walker box's length and width in the policy's obs; the longest6 scripts set +0.8 m), `COSIM_OBS_HTML`, `COSIM_DEBUG_CARLA_VIEW`, `COSIM_RECORD_INFRACTIONS`. ### Ego dynamics source diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py index 17706933fb..11124d9ba7 100644 --- a/pufferlib/ocean/cosim/carla/shadow_ego.py +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -42,14 +42,11 @@ 1: every partner's seconds_stopped observation feature is held at 0 (the ego's own counter is untouched). Ablates the stopped-time cue behind parked-car / red-light-queue avoidance. - COSIM_PEDESTRIAN_MIN_SIZE_M=0.0 - walker partner boxes below this on either axis grow to it (nuPlan's - pedestrian_min_size_m; the training spawn floor is 0.8 m, CARLA walkers - are 0.4-0.5 m) COSIM_PEDESTRIAN_SIZE_PAD_M=0.0 - added to every walker partner box's length and width after the floor - (nuPlan's pedestrian_size_pad_m; 0.8 lifts CARLA walkers to 1.2-1.3 m, - strictly above the smallest training vehicle) + added to every walker partner box's length and width (nuPlan's + pedestrian_size_pad_m; the training spawn floor is 0.8 m, CARLA walkers + are 0.4-0.5 m, so 0.8 lifts them to 1.2-1.3 m, strictly above the + smallest training vehicle) COSIM_TELEMETRY=/dir write a per-policy-step CSV per route, with the shadow env's and the simulator's infraction flags as columns COSIM_OBS_HTML=/dir write an interactive pufferlib.viz replay per route (the exact obs + @@ -206,9 +203,6 @@ def __init__(self, path_to_conf_file, route_tag): env_cfg = self.cfg["env"] self.max_speed_mps = cosim_max_speed_mps(env_cfg) self.zero_partner_stopped_time = env_flag("COSIM_ZERO_PARTNER_STOPPED_TIME") - self.pedestrian_min_size_m = float(os.environ.get("COSIM_PEDESTRIAN_MIN_SIZE_M", "0.0")) - if not (self.pedestrian_min_size_m >= 0.0): - raise ValueError(f"COSIM_PEDESTRIAN_MIN_SIZE_M must be >= 0, got {self.pedestrian_min_size_m}") self.pedestrian_size_pad_m = float(os.environ.get("COSIM_PEDESTRIAN_SIZE_PAD_M", "0.0")) if not (self.pedestrian_size_pad_m >= 0.0): raise ValueError(f"COSIM_PEDESTRIAN_SIZE_PAD_M must be >= 0, got {self.pedestrian_size_pad_m}") @@ -422,7 +416,7 @@ def init_route(self, route): f"[puffer_agent] town={town} tick_dt={self.tick_dt} dt={self.dt} route_goals={len(self.route_goals)} " f"max_speed_mps={self.env.max_speed_mps:.1f} (C_vel={self.env.max_speed_mps / self.env.base_max_speed_mps:.2f}) " f"zero_partner_stopped_time={int(self.zero_partner_stopped_time)} " - f"pedestrian_min_size_m={self.pedestrian_min_size_m:g} pedestrian_size_pad_m={self.pedestrian_size_pad_m:g}" + f"pedestrian_size_pad_m={self.pedestrian_size_pad_m:g}" ) self.initialized = True @@ -479,11 +473,9 @@ def _read_sizes(self, partners): idx, length, width = [], [], [] for j, row in enumerate(partners): idx.append(1 + j) - is_walker = row[PARTNER_IS_WALKER] > 0.0 - walker_floor_m = self.pedestrian_min_size_m if is_walker else 0.0 - walker_pad_m = self.pedestrian_size_pad_m if is_walker else 0.0 - length.append(max(2.0 * row[PARTNER_EXTENT_X], MIN_PARTNER_SIZE_M, walker_floor_m) + walker_pad_m) - width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M, walker_floor_m) + walker_pad_m) + walker_pad_m = self.pedestrian_size_pad_m if row[PARTNER_IS_WALKER] > 0.0 else 0.0 + length.append(max(2.0 * row[PARTNER_EXTENT_X], MIN_PARTNER_SIZE_M) + walker_pad_m) + width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M) + walker_pad_m) return (np.array(idx, np.int32), np.array(length, np.float32), np.array(width, np.float32)) def _read_light_states(self, light_states, ego_light_id): diff --git a/pufferlib/ocean/cosim/nuplan/README.md b/pufferlib/ocean/cosim/nuplan/README.md index f9cd0ad04a..3a25126391 100644 --- a/pufferlib/ocean/cosim/nuplan/README.md +++ b/pufferlib/ocean/cosim/nuplan/README.md @@ -30,14 +30,12 @@ Zero changes to nuplan-devkit or CaRL's `carl_nuplan`. Hydra loads any planner v change only where the lane graph forces one), so multi-lane roads stop pulling the ego toward the road centre. All come from the challenge's route roadblock ids only; the planner - never reads the logged ego. Eval hack `pedestrian_min_size_m` (env - `PEDESTRIAN_MIN_SIZE_M`): pedestrian and bicycle partner boxes are grown to at least - this size in the policy's observation (training spawns nothing below 0.8 x 0.8 m, - nuPlan pedestrians are 0.4-0.8 m); nuPlan still scores the true boxes. Off by - default and in script 9. `pedestrian_size_pad_m` (env `PEDESTRIAN_SIZE_PAD_M`) - instead adds this much to every pedestrian box's length and width (after the floor - when both are set), so pedestrians read strictly larger than the smallest training - vehicle; off by default, script 9 sets 0.8 m. The shadow env consumes the goals of its + never reads the logged ego. Eval hack `pedestrian_size_pad_m` (env + `PEDESTRIAN_SIZE_PAD_M`): this much is added to every pedestrian partner box's length + and width in the policy's observation (training spawns nothing below 0.8 x 0.8 m, + nuPlan pedestrians are 0.4-0.8 m), so pedestrians read strictly larger than the + smallest training vehicle; nuPlan still scores the true boxes. Off by default, + script 9 sets 0.8 m. The shadow env consumes the goals of its `num_goals` window itself exactly like training (`goal_regen_mode: finite`, consumed slots zeroed in the obs); the planner pushes the next window only when the current one is exhausted or its current goal is clearly behind the diff --git a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml index 887c9c9a85..4792874ad6 100644 --- a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml +++ b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml @@ -21,9 +21,7 @@ pufferdrive_planner: startup_jerk_cap_seconds: 1.5 startup_accel_jerk_cap_mps3: 0.0 startup_brake_jerk_cap_mps3: 0.0 - # eval hack: grow pedestrian/bicycle partner boxes to at least this size [m] in the policy's obs (training min 0.8); 0 = off - pedestrian_min_size_m: 0.0 - # eval hack: grow pedestrian partner boxes by this much [m] in length and width, after the floor; 0 = off + # eval hack: grow pedestrian partner boxes by this much [m] in length and width in the policy's obs (training min 0.8 x 0.8); 0 = off pedestrian_size_pad_m: 0.0 env_overrides: obs_slots_partners_n: 40 # GIGAFLOW eval value (Table A4); slots are max-pooled, so any checkpoint transfers diff --git a/pufferlib/ocean/cosim/nuplan/planner.py b/pufferlib/ocean/cosim/nuplan/planner.py index 382e391bbf..820391536c 100644 --- a/pufferlib/ocean/cosim/nuplan/planner.py +++ b/pufferlib/ocean/cosim/nuplan/planner.py @@ -93,7 +93,6 @@ def __init__( startup_jerk_cap_seconds: float = 1.5, startup_accel_jerk_cap_mps3: float = 0.0, startup_brake_jerk_cap_mps3: float = 0.0, - pedestrian_min_size_m: float = 0.0, pedestrian_size_pad_m: float = 0.0, env_overrides: Optional[Dict] = None, obs_html_dir: Optional[str] = None, @@ -126,12 +125,10 @@ def __init__( during the start window [m/s³]; 0 = uncapped. nuPlan's Savitzky-Golay jerk extrapolates at the trajectory edges, so a ramp that starts at t=0 reads ~2x its raw jerk. :param startup_brake_jerk_cap_mps3: same cap for braking jerk; 0 = uncapped. - :param pedestrian_min_size_m: eval hack: pedestrian and bicycle partner boxes are grown to at - least this length and width [m] before the policy sees them (training spawns nothing below - 0.8 x 0.8 m, nuPlan pedestrians are 0.4-0.8 m); nuPlan keeps scoring the true boxes. 0 = off. :param pedestrian_size_pad_m: eval hack: pedestrian partner boxes grow by this much in length and - width [m] after the floor, so they read strictly larger than the smallest training vehicle - (0.8 x 0.8 m); nuPlan keeps scoring the true boxes. 0 = off. + width [m] before the policy sees them, so they read strictly larger than the smallest training + vehicle (0.8 x 0.8 m; nuPlan pedestrians are 0.4-0.8 m); nuPlan keeps scoring the true boxes. + 0 = off. :param env_overrides: Drive env kwargs overriding DEFAULT_ARCH. :param obs_html_dir: write the interactive pufferlib.viz observation replay per scenario (the exact obs the policy received, its outputs and encoder pool winners). @@ -161,9 +158,6 @@ def __init__( < 0 ): raise ValueError("startup_jerk_cap_seconds and the startup jerk caps must be >= 0") - self._pedestrian_min_size_m = float(pedestrian_min_size_m) - if self._pedestrian_min_size_m < 0: - raise ValueError("pedestrian_min_size_m must be >= 0") self._pedestrian_size_pad_m = float(pedestrian_size_pad_m) if self._pedestrian_size_pad_m < 0: raise ValueError("pedestrian_size_pad_m must be >= 0") @@ -516,8 +510,6 @@ def _sync(self, ego_state: EgoState, detections: DetectionsTracks, traffic_light objs = objs[: self._num_agents - 1] if objs: idx, x, y, z, hh, vx, vy, tp, ln, wd = nb.tracked_objects_to_arrays(objs, tf) - if self._pedestrian_min_size_m > 0: - ln, wd = nb.floor_vru_partner_sizes(tp, ln, wd, self._pedestrian_min_size_m) if self._pedestrian_size_pad_m > 0: ln, wd = nb.pad_pedestrian_partner_sizes(tp, ln, wd, self._pedestrian_size_pad_m) env.set_agent_states(idx, x, y, z, hh, vx, vy, np.zeros_like(vx), np.zeros_like(vx)) diff --git a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh index 740ae7d6f2..707f26f280 100755 --- a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh +++ b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh @@ -37,10 +37,9 @@ # SLIDING_GOAL_WINDOW=true|false refill the goal window after every consumed goal (default false). # STARTUP_ACCEL_JERK_CAP / STARTUP_BRAKE_JERK_CAP [m/s^3] ego jerk caps for the first # STARTUP_JERK_CAP_SECONDS (default 1.5) of every scenario; unset/0 = off. -# PEDESTRIAN_MIN_SIZE_M [m] grow pedestrian/bicycle partner boxes to at least this size in the -# policy's observation (training never spawns below 0.8 m); unset/0 = off. # PEDESTRIAN_SIZE_PAD_M [m] add this to pedestrian partner boxes' length and width in the policy's -# observation, after the floor (0.8 = strictly above the smallest training vehicle); unset/0 = off. +# observation (training never spawns below 0.8 m; 0.8 = strictly above the smallest training +# vehicle); unset/0 = off. # OBS_SLOTS_PARTNERS_N shadow-env partner obs slots (unset = planner yaml, 40). # EVAL_PERCEIVED_SIZE_MARGIN_M [m per side] ego box inflation in the policy's obs (unset = arch.py, 0.2). # REWARD_OVERSPEED overspeed conditioning alpha fed to the policy (unset = arch.py, 0.1). @@ -88,7 +87,6 @@ EXTRA_ARGS+=("planner.pufferdrive_planner.city_bin_dir=$CITY_BIN_DIR") [ -n "${STARTUP_JERK_CAP_SECONDS:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_jerk_cap_seconds=$STARTUP_JERK_CAP_SECONDS") [ -n "${STARTUP_ACCEL_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_accel_jerk_cap_mps3=$STARTUP_ACCEL_JERK_CAP") [ -n "${STARTUP_BRAKE_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_brake_jerk_cap_mps3=$STARTUP_BRAKE_JERK_CAP") -[ -n "${PEDESTRIAN_MIN_SIZE_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_min_size_m=$PEDESTRIAN_MIN_SIZE_M") [ -n "${PEDESTRIAN_SIZE_PAD_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_size_pad_m=$PEDESTRIAN_SIZE_PAD_M") [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.obs_slots_partners_n=$OBS_SLOTS_PARTNERS_N") [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.eval_perceived_size_margin_m=$EVAL_PERCEIVED_SIZE_MARGIN_M") diff --git a/pufferlib/ocean/cosim/nuplan_bridge.py b/pufferlib/ocean/cosim/nuplan_bridge.py index 3b301288cf..e8c1f06b4a 100644 --- a/pufferlib/ocean/cosim/nuplan_bridge.py +++ b/pufferlib/ocean/cosim/nuplan_bridge.py @@ -608,18 +608,6 @@ def partner_tracked_objects(tracked_objects, map_api, static_on_lane: dict): return kept -VRU_PARTNER_TYPE_IDS = (_NUPLAN_AGENT_TYPE["PEDESTRIAN"], _NUPLAN_AGENT_TYPE["BICYCLE"]) - - -def floor_vru_partner_sizes(types, lengths, widths, min_size_m: float): - """Pedestrian/bicycle boxes below min_size_m on either axis grow to min_size_m (training never - spawns agents under 0.8 x 0.8 m; nuPlan pedestrians are mostly 0.4-0.8 m). Other types untouched.""" - vru = np.isin(types, VRU_PARTNER_TYPE_IDS) - lengths = np.where(vru, np.maximum(lengths, min_size_m), lengths).astype(np.float32) - widths = np.where(vru, np.maximum(widths, min_size_m), widths).astype(np.float32) - return lengths, widths - - def pad_pedestrian_partner_sizes(types, lengths, widths, pad_m: float): """Pedestrian boxes grow by pad_m in length and width (0.8 puts every nuPlan pedestrian strictly above the 0.8 x 0.8 m training minimum). Other types untouched.""" diff --git a/scripts/kesai/11_carla_longest6_v2.sh b/scripts/kesai/11_carla_longest6_v2.sh index 04fa675f7c..5a89e8bce3 100644 --- a/scripts/kesai/11_carla_longest6_v2.sh +++ b/scripts/kesai/11_carla_longest6_v2.sh @@ -27,9 +27,8 @@ # folder $OUT/obs_html at the end, large; needs LOGGING=1), REPORT (0 = skip the HTML report), # MAX_ATTEMPTS, CARLA_ROOT, CARL_WORK_DIR, PY, PD, COSIM_MAX_SPEED_MPS (ego speed cap, default 30), # COSIM_ZERO_PARTNER_STOPPED_TIME (default 1: partners' stopped-time obs held at 0; 0 = real stopped times), -# COSIM_PEDESTRIAN_MIN_SIZE_M (default 0: true CARLA walker boxes; 0.8 = the training spawn floor), -# COSIM_PEDESTRIAN_SIZE_PAD_M (default 0.8: added to every walker box's length and width after the floor, -# so walkers read strictly larger than the smallest training vehicle; 0 = true boxes). +# COSIM_PEDESTRIAN_SIZE_PAD_M (default 0.8: added to every walker box's length and width, so walkers read +# strictly larger than the smallest training vehicle; 0 = true CARLA walker boxes). set -u export PD=${PD:-/home/bjaeger/PufferDrive} @@ -77,7 +76,6 @@ export COSIM_DEVICE=${COSIM_DEVICE:-cpu} export COSIM_DYNAMICS_SOURCE=pufferdrive export COSIM_MAX_SPEED_MPS=${COSIM_MAX_SPEED_MPS:-30} export COSIM_ZERO_PARTNER_STOPPED_TIME=${COSIM_ZERO_PARTNER_STOPPED_TIME:-1} -export COSIM_PEDESTRIAN_MIN_SIZE_M=${COSIM_PEDESTRIAN_MIN_SIZE_M:-0} export COSIM_PEDESTRIAN_SIZE_PAD_M=${COSIM_PEDESTRIAN_SIZE_PAD_M:-0.8} export COSIM_OBS_HTML_MAX_STEPS=${COSIM_OBS_HTML_MAX_STEPS:-20000} export COSIM_OBS_HTML_RENDER=0 # routes save the compact replay only; render_carla_obs_html.py renders all pages into $OUT/obs_html diff --git a/scripts/kesai/14_carla_longest6_v1.sh b/scripts/kesai/14_carla_longest6_v1.sh index 5df2d74f09..dbbd638d9e 100644 --- a/scripts/kesai/14_carla_longest6_v1.sh +++ b/scripts/kesai/14_carla_longest6_v1.sh @@ -28,10 +28,10 @@ # the interactive obs replay per route, rendered into $OUT/obs_html at the end, large; needs LOGGING=1), REPORT # (0 = skip the HTML report), MAX_ATTEMPTS, CARLA_ROOT, GARAGE_WORK_DIR, GARAGE_PY, SERVER_PY, PD, # COSIM_MAX_SPEED_MPS (ego speed cap, default 30), COSIM_ZERO_PARTNER_STOPPED_TIME (default 1), -# COSIM_PEDESTRIAN_MIN_SIZE_M (default 0), COSIM_PEDESTRIAN_SIZE_PAD_M (default 0.8: added to every walker box's -# length and width after the floor, so walkers read strictly larger than the smallest training vehicle; 0 = true -# boxes), LIBTIFF5_COMPAT (1: when `import carla` fails for the egg's missing libtiff.so.5, try the garage env's -# lib dir (`conda install -n garage "libtiff<4.5"`), then link the system libtiff.so.6 under that name; 0: never link). +# COSIM_PEDESTRIAN_SIZE_PAD_M (default 0.8: added to every walker box's length and width, so walkers read strictly +# larger than the smallest training vehicle; 0 = true boxes), LIBTIFF5_COMPAT (1: when `import carla` fails for the +# egg's missing libtiff.so.5, try the garage env's lib dir (`conda install -n garage "libtiff<4.5"`), then link the +# system libtiff.so.6 under that name; 0: never link). set -u export PD=${PD:-/home/bjaeger/PufferDrive} @@ -119,7 +119,6 @@ export COSIM_DEVICE=${COSIM_DEVICE:-cpu} export COSIM_DYNAMICS_SOURCE=pufferdrive export COSIM_MAX_SPEED_MPS=${COSIM_MAX_SPEED_MPS:-30} export COSIM_ZERO_PARTNER_STOPPED_TIME=${COSIM_ZERO_PARTNER_STOPPED_TIME:-1} -export COSIM_PEDESTRIAN_MIN_SIZE_M=${COSIM_PEDESTRIAN_MIN_SIZE_M:-0} export COSIM_OBS_HTML_MAX_STEPS=${COSIM_OBS_HTML_MAX_STEPS:-20000} export COSIM_OBS_HTML_RENDER=0 # routes save the compact replay only; render_carla_obs_html.py renders all pages into $OUT/obs_html diff --git a/tests/unit_tests/test_nuplan_bridge_partner_sizes.py b/tests/unit_tests/test_nuplan_bridge_partner_sizes.py index ab0d62e5b8..0afa85767f 100644 --- a/tests/unit_tests/test_nuplan_bridge_partner_sizes.py +++ b/tests/unit_tests/test_nuplan_bridge_partner_sizes.py @@ -1,5 +1,5 @@ -"""Partner size eval hacks: the VRU floor grows pedestrian/bicycle boxes to the floor on each axis, the pad adds -to pedestrian boxes only; vehicles keep their size and the inputs are never modified.""" +"""pad_pedestrian_partner_sizes: pedestrian boxes grow by the pad on both axes, other types keep their size, and the +inputs are never modified.""" import os import sys @@ -12,24 +12,6 @@ from pufferlib.ocean.cosim import nuplan_bridge as nb -def test_floor_grows_only_vru_boxes_below_the_floor(): - types = np.array([1, 2, 3, 2], np.int32) # vehicle, pedestrian, bicycle, pedestrian - lengths = np.array([4.5, 0.5, 1.7, 0.9], np.float32) - widths = np.array([1.9, 0.6, 0.5, 0.7], np.float32) - ln, wd = nb.floor_vru_partner_sizes(types, lengths, widths, 0.8) - np.testing.assert_allclose(ln, [4.5, 0.8, 1.7, 0.9]) - np.testing.assert_allclose(wd, [1.9, 0.8, 0.8, 0.8]) - assert ln.dtype == np.float32 and wd.dtype == np.float32 - - -def test_floor_leaves_inputs_untouched(): - types = np.array([2], np.int32) - lengths = np.array([0.4], np.float32) - widths = np.array([0.4], np.float32) - nb.floor_vru_partner_sizes(types, lengths, widths, 0.8) - assert lengths[0] == pytest.approx(0.4) and widths[0] == pytest.approx(0.4) - - def test_pad_grows_only_pedestrian_boxes(): types = np.array([1, 2, 3, 2], np.int32) # vehicle, pedestrian, bicycle, pedestrian lengths = np.array([4.5, 0.5, 1.7, 0.9], np.float32) @@ -38,14 +20,11 @@ def test_pad_grows_only_pedestrian_boxes(): np.testing.assert_allclose(ln, [4.5, 1.3, 1.7, 1.7], rtol=1e-6) np.testing.assert_allclose(wd, [1.9, 1.4, 0.5, 1.5], rtol=1e-6) assert ln.dtype == np.float32 and wd.dtype == np.float32 - assert lengths[1] == pytest.approx(0.5) and widths[1] == pytest.approx(0.6) -def test_pad_stacks_on_the_floor(): - types = np.array([2, 3], np.int32) # pedestrian, bicycle - lengths = np.array([0.4, 1.7], np.float32) - widths = np.array([0.4, 0.5], np.float32) - ln, wd = nb.floor_vru_partner_sizes(types, lengths, widths, 0.8) - ln, wd = nb.pad_pedestrian_partner_sizes(types, ln, wd, 0.8) - np.testing.assert_allclose(ln, [1.6, 1.7], rtol=1e-6) - np.testing.assert_allclose(wd, [1.6, 0.8], rtol=1e-6) +def test_pad_leaves_inputs_untouched(): + types = np.array([2], np.int32) + lengths = np.array([0.4], np.float32) + widths = np.array([0.4], np.float32) + nb.pad_pedestrian_partner_sizes(types, lengths, widths, 0.8) + assert lengths[0] == pytest.approx(0.4) and widths[0] == pytest.approx(0.4) From 16b693a82ff846e5df79670bdfcb94985236e155 Mon Sep 17 00:00:00 2001 From: jaeger Date: Sat, 10 Oct 2026 16:47:00 +0200 Subject: [PATCH 039/123] rerun alpasim with goal radius 2 --- scripts/kesai/12_alpasim.sh | 47 +++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index c0ded2026e..0bb240806a 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -29,6 +29,7 @@ RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} CKPT=$RUN_DIR/final_model.pt [ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) GOAL_MODE=gt_time:5 # gt_time: | gt | route +GOAL_RADIUS_M=2 # goal arrival radius Damiano's reference evals use; the teacher's own gt_time default is 6 PEDESTRIAN_SIZE_PAD_M=0.8 # added to every pedestrian box's length and width the teacher sees; 0 = true boxes RENDER_VIDEO=0 # one mp4 per scene; 1 also streams the teacher BEV and makes every policy step ~5x slower CAM_W=640 # 16:9 render size; the teacher never sees the cameras @@ -113,10 +114,10 @@ EOF [ ${#SCENES[@]} -gt 0 ] || { echo "no scenes to evaluate"; exit 1; } # results live in the model's own eval folder, next to the PufferDrive benchmark evals -OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}$([ "$PEDESTRIAN_SIZE_PAD_M" = "0" ] || echo "_pedpad$PEDESTRIAN_SIZE_PAD_M")_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} +OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}_r${GOAL_RADIUS_M}$([ "$PEDESTRIAN_SIZE_PAD_M" = "0" ] || echo "_pedpad$PEDESTRIAN_SIZE_PAD_M")_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT" { - echo "checkpoint $CKPT"; echo "scenes ${#SCENES[@]}"; echo "topology $TOPOLOGY parallel $PARALLEL goal_mode $GOAL_MODE" + echo "checkpoint $CKPT"; echo "scenes ${#SCENES[@]}"; echo "topology $TOPOLOGY parallel $PARALLEL goal_mode $GOAL_MODE goal_radius_m $GOAL_RADIUS_M" echo "pedestrian_size_pad_m $PEDESTRIAN_SIZE_PAD_M" for repo in "$PD" "$ALPAGYM_ROOT" "$ALPASIM_ROOT"; do echo "git $repo $(git -C "$repo" rev-parse --short HEAD 2>/dev/null) $(git -C "$repo" diff --quiet 2>/dev/null || echo dirty)" @@ -163,6 +164,7 @@ cd "$ALPAGYM_ROOT" || exit 1 --teacher_checkpoint "$CKPT" \ --teacher_bins_dir "$BINS" \ --teacher_goal_mode "$GOAL_MODE" \ + --teacher_goal_radius "$GOAL_RADIUS_M" \ --teacher_pedestrian_size_pad_m "$PEDESTRIAN_SIZE_PAD_M" \ --render_video "$RENDER_VIDEO_FLAG" \ --local_scene_dir "$USDZ_DIR" \ @@ -176,24 +178,45 @@ cd "$ALPAGYM_ROOT" || exit 1 > "$OUT/evaluate.log" 2>&1 EVAL_STATUS=$? +# results.txt: the official score, AlpaSim's km between incidents (all and at-fault) and the mean of every per-scene metric if [ -f "$OUT/alpagym/evaluation.yaml" ]; then - "$ALPAGYM_PY" - "$OUT/alpagym" <<'EOF' -import sys + "$ALPAGYM_PY" - "$OUT/alpagym/evaluation.yaml" "$OUT/alpagym/alpasim/wizard_0/aggregate/results-summary.json" "$OUT/results.txt" <<'EOF' +import json, statistics, sys from pathlib import Path import yaml -run = Path(sys.argv[1]) -summary = yaml.safe_load((run / "evaluation.yaml").read_text()) -print(f"\n{len(summary['scenes'])} scenes evaluated, complete: {summary['complete']}") -aggregate = run / "metrics" / "aggregate.yaml" -if aggregate.exists(): - for name, stats in yaml.safe_load(aggregate.read_text())["metrics"].items(): - print(f" {name:34s} mean {stats['mean']:9.4f} median {stats['median']:9.4f} n {stats['num_finite']}") +summary = yaml.safe_load(Path(sys.argv[1]).read_text()) +aggregate_path = Path(sys.argv[2]) +scenes = summary["scenes"] +official = summary.get("official_score") +if official is None: + lines = [f"official_score n/a (incomplete: {len(scenes)} scenes finished, {len(summary['failed_scenes'])} failed)"] +else: + lines = [f"official_score {official['score']:.4f} scenes {official['num_scored']} failed {official['num_failed']}"] +rows = [] +runs = json.loads(aggregate_path.read_text())["metrics_results"] if aggregate_path.exists() else [] +for run in runs: + prefix = f"{run['run_name']} " if len(runs) > 1 else "" + for name in ("avg_dist_between_incidents", "avg_dist_between_incidents_at_fault"): + km_per_incident = run.get(name) + if km_per_incident is None: + rows.append((prefix + name, "n/a (no incidents)")) + continue + per_km = f"{1.0 / km_per_incident:.4f} incidents/km" if km_per_incident > 0 else "inf incidents/km" + rows.append((prefix + name, f"{km_per_incident:9.4f} km ({per_km})")) +for name in sorted({name for scene in scenes for name in scene["metrics"]}): + values = [float(scene["metrics"][name]) for scene in scenes if name in scene["metrics"]] + rows.append((name, f"mean {statistics.mean(values):9.4f} n {len(values)}")) +width = max((len(name) for name, _ in rows), default=0) +lines += [f"{name:{width}s} {value}" for name, value in rows] +text = "".join(line + "\n" for line in lines) +Path(sys.argv[3]).write_text(text) +sys.stdout.write("\n" + text) EOF fi if [ $EVAL_STATUS -ne 0 ]; then echo "AlpaSim evaluation failed (exit $EVAL_STATUS); last host lines of $OUT/evaluate.log:" grep -vE '^[a-z0-9_-]+-[0-9]+-[0-9]+ +\|' "$OUT/evaluate.log" | tail -n 30 fi -echo "per-scene metrics -> $OUT/alpagym/metrics/per_scene.csv; videos -> $OUT/alpagym/alpasim/wizard_0/rollouts" +echo "results -> $OUT/results.txt; per-scene metrics -> $OUT/alpagym/evaluation.yaml; videos -> $OUT/alpagym/alpasim/wizard_0/rollouts" echo "Done -> $OUT" exit $EVAL_STATUS From ea45fafab76e1dd16fc493eb3491ceca25fbff8a Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 14:05:17 +0200 Subject: [PATCH 040/123] fix longest6 bug, fix vizu bug, fix bug of stride 2 for center lines. --- pufferlib/config/puffer_drive.yaml | 2 + pufferlib/config_schema.py | 1 + pufferlib/ocean/cosim/carla/README.md | 6 +- .../cosim/carla/lb1/leaderboard_agent.py | 28 +++-- .../ocean/cosim/carla/leaderboard_agent.py | 30 +++-- pufferlib/ocean/cosim/carla/shadow_ego.py | 29 ++--- pufferlib/ocean/cosim/nuplan/README.md | 12 +- .../config/planner/pufferdrive_planner.yaml | 4 +- pufferlib/ocean/cosim/nuplan/planner.py | 20 ++-- .../ocean/cosim/nuplan/run_nuplan_planner.sh | 4 +- pufferlib/ocean/cosim/nuplan_bridge.py | 15 ++- pufferlib/ocean/drive/binding.c | 5 +- pufferlib/ocean/drive/datatypes.h | 1 + pufferlib/ocean/drive/drive.h | 2 + pufferlib/ocean/drive/drive.py | 4 + pufferlib/ocean/drive/map_data.h | 103 +++++++++++++----- pufferlib/pufferl.py | 1 + pufferlib/viz.py | 6 +- scripts/kesai/11_carla_longest6_v2.sh | 10 +- scripts/kesai/12_alpasim.sh | 13 ++- scripts/kesai/14_carla_longest6_v1.sh | 12 +- .../kesai/3_train_64GPU_multinode_slurm.sh | 1 + scripts/kesai/9_nuPlan.sh | 6 +- tests/drive/test_drive_map_cache.c | 41 ++++++- .../test_nuplan_bridge_partner_sizes.py | 14 +-- 25 files changed, 249 insertions(+), 121 deletions(-) diff --git a/pufferlib/config/puffer_drive.yaml b/pufferlib/config/puffer_drive.yaml index a7be3297d1..da275e1635 100644 --- a/pufferlib/config/puffer_drive.yaml +++ b/pufferlib/config/puffer_drive.yaml @@ -207,6 +207,8 @@ env: # Stride for lane and boundary segments; 1 means use every segment, 2 means use every other segment, etc. obs_lane_stride: 2 obs_boundary_stride: 1 + # true: strided-out segments merge into one chord so polylines stay connected; false = pre-2026-10-12 gaps + obs_stride_merge_segments: false # --- Observation normalization --- obs_norm_speed_mps: 60.0 # num_goals * max_goal_spacing; goals beyond this collapse to a unit direction vector diff --git a/pufferlib/config_schema.py b/pufferlib/config_schema.py index 302fd0d673..ce5df75459 100644 --- a/pufferlib/config_schema.py +++ b/pufferlib/config_schema.py @@ -336,6 +336,7 @@ class DriveEnvConfig: obs_dropout_boundary: float = _constrained_field(PROBABILITY_CONSTRAINT) obs_lane_stride: int = _constrained_field(POSITIVE_INT_CONSTRAINT) obs_boundary_stride: int = _constrained_field(POSITIVE_INT_CONSTRAINT) + obs_stride_merge_segments: bool = MISSING obs_norm_speed_mps: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) obs_norm_goal_offset_m: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) obs_norm_xy_offset_m: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) diff --git a/pufferlib/ocean/cosim/carla/README.md b/pufferlib/ocean/cosim/carla/README.md index cf678f9801..6e72985eb8 100644 --- a/pufferlib/ocean/cosim/carla/README.md +++ b/pufferlib/ocean/cosim/carla/README.md @@ -43,9 +43,9 @@ Optional env vars (see `leaderboard_agent.py` docstring): `COSIM_DEVICE`, `COSIM_DYNAMICS_SOURCE`, `COSIM_NUM_AGENTS`, `COSIM_MAX_SPEED_MPS` (ego speed cap via C_vel; default = the checkpoint's `base_max_speed_mps`, must stay inside the trained range), `COSIM_ZERO_PARTNER_STOPPED_TIME` (1 = partners' `seconds_stopped` obs feature held at 0, ego -untouched; ablation for the parked-car / red-light-queue avoidance), `COSIM_PEDESTRIAN_SIZE_PAD_M` -(added to every walker box's length and width in the policy's obs; the longest6 scripts set -0.8 m), `COSIM_OBS_HTML`, +untouched; ablation for the parked-car / red-light-queue avoidance), `COSIM_VRU_SIZE_PAD_M` +(added to the length and width of every walker and two-wheeler box in the policy's obs; the +longest6 scripts set 0.8 m), `COSIM_OBS_HTML`, `COSIM_DEBUG_CARLA_VIEW`, `COSIM_RECORD_INFRACTIONS`. ### Ego dynamics source diff --git a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py index 6a17a0bdfc..ae3d34959f 100644 --- a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -103,7 +103,10 @@ def partner_row(actor): """shadow_ego PARTNER_ROW of a live CARLA vehicle or walker.""" box = actor.bounding_box is_walker = 1.0 if "walker" in actor.type_id else 0.0 - return actor_state_row(actor) + [box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, actor.id] + is_two_wheeler = 1.0 if actor.attributes.get("number_of_wheels") == "2" else 0.0 + return actor_state_row(actor) + [ + box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, is_two_wheeler, actor.id + ] def plan_xyz(plan): @@ -537,14 +540,25 @@ def _teleport_carla_ego(self, motion): forward = (math.cos(yaw_rad) * math.cos(pitch_rad), math.sin(yaw_rad) * math.cos(pitch_rad), math.sin(pitch_rad)) # Zero momentum before the teleport: CARLA's collision resolver reacts violently to a # physics body carrying velocity into a new pose (carla issue #8076). + # One ordered batch: the server reorders async actor commands, and a late zero left the ego reading 0 m/s on 3 % of ticks zero = carla.Vector3D(x=0.0, y=0.0, z=0.0) - self.vehicle.set_target_velocity(zero) - self.vehicle.set_target_angular_velocity(zero) - self.vehicle.set_transform( - carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=0.0)) + CarlaDataProvider.get_client().apply_batch_sync( + [ + carla.command.ApplyTargetVelocity(self.vehicle.id, zero), + carla.command.ApplyTargetAngularVelocity(self.vehicle.id, zero), + carla.command.ApplyTransform( + self.vehicle.id, + carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=0.0)), + ), + carla.command.ApplyTargetVelocity( + self.vehicle.id, carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2]) + ), + carla.command.ApplyTargetAngularVelocity( + self.vehicle.id, carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.tick_dt) + ), + ], + False, ) - self.vehicle.set_target_velocity(carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2])) - self.vehicle.set_target_angular_velocity(carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.tick_dt)) def _update_mesh_offset(self): """Road mesh height above the lane's height profile under the body, from the probe's newest scan at the previous pose.""" diff --git a/pufferlib/ocean/cosim/carla/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/leaderboard_agent.py index 9f6ffb4bff..06f1df48cc 100644 --- a/pufferlib/ocean/cosim/carla/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/leaderboard_agent.py @@ -111,7 +111,10 @@ def partner_row(actor): """shadow_ego PARTNER_ROW of a live CARLA vehicle or walker.""" box = actor.bounding_box is_walker = 1.0 if "walker" in actor.type_id else 0.0 - return actor_state_row(actor) + [box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, actor.id] + is_two_wheeler = 1.0 if actor.attributes.get("number_of_wheels") == "2" else 0.0 + return actor_state_row(actor) + [ + box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, is_two_wheeler, actor.id + ] class PufferAgent(autonomous_agent.AutonomousAgent): @@ -257,16 +260,25 @@ def _teleport_carla_ego(self, motion): ) # Zero momentum before the teleport: CARLA's collision resolver reacts violently to a # physics body carrying velocity into a new pose (carla issue #8076). + # One ordered batch: the server reorders async actor commands, and a late zero left the ego reading 0 m/s on 3 % of ticks zero = carla.Vector3D(x=0.0, y=0.0, z=0.0) - self.vehicle.set_target_velocity(zero) - self.vehicle.set_target_angular_velocity(zero) - self.vehicle.set_transform( - carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=roll_deg)) + CarlaDataProvider.get_client().apply_batch_sync( + [ + carla.command.ApplyTargetVelocity(self.vehicle.id, zero), + carla.command.ApplyTargetAngularVelocity(self.vehicle.id, zero), + carla.command.ApplyTransform( + self.vehicle.id, + carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=roll_deg)), + ), + carla.command.ApplyTargetVelocity( + self.vehicle.id, carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2]) + ), + carla.command.ApplyTargetAngularVelocity( + self.vehicle.id, carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.shadow.dt) + ), + ], + False, ) - self.vehicle.set_target_velocity( - carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2]) - ) - self.vehicle.set_target_angular_velocity(carla.Vector3D(x=0.0, y=0.0, z=yaw_delta_deg / self.shadow.dt)) def _road_surface(self, x, y, reference_z, yaw_deg): """(z, pitch_deg) resting the body on the road mesh: bumper-to-bumper chord, lifted until no sample is below the mesh; None off the mesh.""" diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py index 11124d9ba7..ea4ae7f950 100644 --- a/pufferlib/ocean/cosim/carla/shadow_ego.py +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -12,7 +12,7 @@ Actor state rows (CARLA frame, physics quantities from CARLA's own physics): ACTOR_STATE [x, y, z, yaw_deg, vx, vy, vz, angular_velocity_z_deg_s, accel_x, accel_y] - PARTNER_ROW ACTOR_STATE + [box_z, extent_x, extent_y, extent_z, is_walker, actor_id] + PARTNER_ROW ACTOR_STATE + [box_z, extent_x, extent_y, extent_z, is_walker, is_two_wheeler, actor_id] init_route(route) once the ego exists, route = { town, tick_dt, @@ -42,11 +42,10 @@ 1: every partner's seconds_stopped observation feature is held at 0 (the ego's own counter is untouched). Ablates the stopped-time cue behind parked-car / red-light-queue avoidance. - COSIM_PEDESTRIAN_SIZE_PAD_M=0.0 - added to every walker partner box's length and width (nuPlan's - pedestrian_size_pad_m; the training spawn floor is 0.8 m, CARLA walkers - are 0.4-0.5 m, so 0.8 lifts them to 1.2-1.3 m, strictly above the - smallest training vehicle) + COSIM_VRU_SIZE_PAD_M=0.0 added to the length and width of every walker and two-wheeler partner box + (nuPlan's vru_size_pad_m; the training spawn floor is 0.8 m, CARLA walkers + are 0.4-0.5 m and bicycles 0.4 m wide, so 0.8 lifts them strictly above + the smallest training vehicle) COSIM_TELEMETRY=/dir write a per-policy-step CSV per route, with the shadow env's and the simulator's infraction flags as columns COSIM_OBS_HTML=/dir write an interactive pufferlib.viz replay per route (the exact obs + @@ -76,7 +75,7 @@ ACTOR_STATE_COLUMNS = 10 -PARTNER_ROW_COLUMNS = 16 +PARTNER_ROW_COLUMNS = 17 ( STATE_X, STATE_Y, @@ -95,6 +94,7 @@ PARTNER_EXTENT_Y, PARTNER_EXTENT_Z, PARTNER_IS_WALKER, + PARTNER_IS_TWO_WHEELER, PARTNER_ACTOR_ID, ) = range(ACTOR_STATE_COLUMNS, PARTNER_ROW_COLUMNS) @@ -203,9 +203,9 @@ def __init__(self, path_to_conf_file, route_tag): env_cfg = self.cfg["env"] self.max_speed_mps = cosim_max_speed_mps(env_cfg) self.zero_partner_stopped_time = env_flag("COSIM_ZERO_PARTNER_STOPPED_TIME") - self.pedestrian_size_pad_m = float(os.environ.get("COSIM_PEDESTRIAN_SIZE_PAD_M", "0.0")) - if not (self.pedestrian_size_pad_m >= 0.0): - raise ValueError(f"COSIM_PEDESTRIAN_SIZE_PAD_M must be >= 0, got {self.pedestrian_size_pad_m}") + self.vru_size_pad_m = float(os.environ.get("COSIM_VRU_SIZE_PAD_M", "0.0")) + if not (self.vru_size_pad_m >= 0.0): + raise ValueError(f"COSIM_VRU_SIZE_PAD_M must be >= 0, got {self.vru_size_pad_m}") # Shadow agent pool == the training per-env cap self.num_agents = int(env_cfg["max_agents_per_env"]) @@ -416,7 +416,7 @@ def init_route(self, route): f"[puffer_agent] town={town} tick_dt={self.tick_dt} dt={self.dt} route_goals={len(self.route_goals)} " f"max_speed_mps={self.env.max_speed_mps:.1f} (C_vel={self.env.max_speed_mps / self.env.base_max_speed_mps:.2f}) " f"zero_partner_stopped_time={int(self.zero_partner_stopped_time)} " - f"pedestrian_size_pad_m={self.pedestrian_size_pad_m:g}" + f"vru_size_pad_m={self.vru_size_pad_m:g}" ) self.initialized = True @@ -473,9 +473,10 @@ def _read_sizes(self, partners): idx, length, width = [], [], [] for j, row in enumerate(partners): idx.append(1 + j) - walker_pad_m = self.pedestrian_size_pad_m if row[PARTNER_IS_WALKER] > 0.0 else 0.0 - length.append(max(2.0 * row[PARTNER_EXTENT_X], MIN_PARTNER_SIZE_M) + walker_pad_m) - width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M) + walker_pad_m) + is_vru = row[PARTNER_IS_WALKER] > 0.0 or row[PARTNER_IS_TWO_WHEELER] > 0.0 + vru_pad_m = self.vru_size_pad_m if is_vru else 0.0 + length.append(max(2.0 * row[PARTNER_EXTENT_X], MIN_PARTNER_SIZE_M) + vru_pad_m) + width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M) + vru_pad_m) return (np.array(idx, np.int32), np.array(length, np.float32), np.array(width, np.float32)) def _read_light_states(self, light_states, ego_light_id): diff --git a/pufferlib/ocean/cosim/nuplan/README.md b/pufferlib/ocean/cosim/nuplan/README.md index 3a25126391..4384784678 100644 --- a/pufferlib/ocean/cosim/nuplan/README.md +++ b/pufferlib/ocean/cosim/nuplan/README.md @@ -30,12 +30,12 @@ Zero changes to nuplan-devkit or CaRL's `carl_nuplan`. Hydra loads any planner v change only where the lane graph forces one), so multi-lane roads stop pulling the ego toward the road centre. All come from the challenge's route roadblock ids only; the planner - never reads the logged ego. Eval hack `pedestrian_size_pad_m` (env - `PEDESTRIAN_SIZE_PAD_M`): this much is added to every pedestrian partner box's length - and width in the policy's observation (training spawns nothing below 0.8 x 0.8 m, - nuPlan pedestrians are 0.4-0.8 m), so pedestrians read strictly larger than the - smallest training vehicle; nuPlan still scores the true boxes. Off by default, - script 9 sets 0.8 m. The shadow env consumes the goals of its + never reads the logged ego. Eval hack `vru_size_pad_m` (env `VRU_SIZE_PAD_M`): this + much is added to every pedestrian and bicycle partner box's length and width in the + policy's observation (training spawns nothing below 0.8 x 0.8 m, nuPlan pedestrians + are 0.4-0.8 m, bicycles ~0.5 m wide), so they read strictly larger than the smallest + training vehicle; nuPlan still scores the true boxes. Off by default, script 9 sets + 0.8 m. The shadow env consumes the goals of its `num_goals` window itself exactly like training (`goal_regen_mode: finite`, consumed slots zeroed in the obs); the planner pushes the next window only when the current one is exhausted or its current goal is clearly behind the diff --git a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml index 4792874ad6..5e9dc697ab 100644 --- a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml +++ b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml @@ -21,8 +21,8 @@ pufferdrive_planner: startup_jerk_cap_seconds: 1.5 startup_accel_jerk_cap_mps3: 0.0 startup_brake_jerk_cap_mps3: 0.0 - # eval hack: grow pedestrian partner boxes by this much [m] in length and width in the policy's obs (training min 0.8 x 0.8); 0 = off - pedestrian_size_pad_m: 0.0 + # eval hack: grow pedestrian and bicycle partner boxes by this much [m] in length and width in the policy's obs (training min 0.8 x 0.8); 0 = off + vru_size_pad_m: 0.0 env_overrides: obs_slots_partners_n: 40 # GIGAFLOW eval value (Table A4); slots are max-pooled, so any checkpoint transfers stop_signs_enabled: false # nuPlan city bins export TURN_STOP zones as stop signs; keep them out of the obs diff --git a/pufferlib/ocean/cosim/nuplan/planner.py b/pufferlib/ocean/cosim/nuplan/planner.py index 820391536c..dd23c1a4d3 100644 --- a/pufferlib/ocean/cosim/nuplan/planner.py +++ b/pufferlib/ocean/cosim/nuplan/planner.py @@ -93,7 +93,7 @@ def __init__( startup_jerk_cap_seconds: float = 1.5, startup_accel_jerk_cap_mps3: float = 0.0, startup_brake_jerk_cap_mps3: float = 0.0, - pedestrian_size_pad_m: float = 0.0, + vru_size_pad_m: float = 0.0, env_overrides: Optional[Dict] = None, obs_html_dir: Optional[str] = None, obs_html_max_steps: int = 800, @@ -125,10 +125,10 @@ def __init__( during the start window [m/s³]; 0 = uncapped. nuPlan's Savitzky-Golay jerk extrapolates at the trajectory edges, so a ramp that starts at t=0 reads ~2x its raw jerk. :param startup_brake_jerk_cap_mps3: same cap for braking jerk; 0 = uncapped. - :param pedestrian_size_pad_m: eval hack: pedestrian partner boxes grow by this much in length and - width [m] before the policy sees them, so they read strictly larger than the smallest training - vehicle (0.8 x 0.8 m; nuPlan pedestrians are 0.4-0.8 m); nuPlan keeps scoring the true boxes. - 0 = off. + :param vru_size_pad_m: eval hack: pedestrian and bicycle partner boxes grow by this much in length + and width [m] before the policy sees them, so they read strictly larger than the smallest + training vehicle (0.8 x 0.8 m; nuPlan pedestrians are 0.4-0.8 m, bicycles ~0.5 m wide); nuPlan + keeps scoring the true boxes. 0 = off. :param env_overrides: Drive env kwargs overriding DEFAULT_ARCH. :param obs_html_dir: write the interactive pufferlib.viz observation replay per scenario (the exact obs the policy received, its outputs and encoder pool winners). @@ -158,9 +158,9 @@ def __init__( < 0 ): raise ValueError("startup_jerk_cap_seconds and the startup jerk caps must be >= 0") - self._pedestrian_size_pad_m = float(pedestrian_size_pad_m) - if self._pedestrian_size_pad_m < 0: - raise ValueError("pedestrian_size_pad_m must be >= 0") + self._vru_size_pad_m = float(vru_size_pad_m) + if self._vru_size_pad_m < 0: + raise ValueError("vru_size_pad_m must be >= 0") self._startup_jerk_cap_steps = 0 # resolved in _build from the scenario dt self._startup_action_bounds = None # (lo, hi) on the continuous jerk action while the cap is live self._env_overrides = env_overrides or {} @@ -510,8 +510,8 @@ def _sync(self, ego_state: EgoState, detections: DetectionsTracks, traffic_light objs = objs[: self._num_agents - 1] if objs: idx, x, y, z, hh, vx, vy, tp, ln, wd = nb.tracked_objects_to_arrays(objs, tf) - if self._pedestrian_size_pad_m > 0: - ln, wd = nb.pad_pedestrian_partner_sizes(tp, ln, wd, self._pedestrian_size_pad_m) + if self._vru_size_pad_m > 0: + ln, wd = nb.pad_vru_partner_sizes(tp, ln, wd, self._vru_size_pad_m) env.set_agent_states(idx, x, y, z, hh, vx, vy, np.zeros_like(vx), np.zeros_like(vx)) env.set_agent_sizes(idx, ln, wd) surplus = np.arange(1 + len(objs), self._num_agents, dtype=np.int32) diff --git a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh index 707f26f280..6656e6bce2 100755 --- a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh +++ b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh @@ -37,7 +37,7 @@ # SLIDING_GOAL_WINDOW=true|false refill the goal window after every consumed goal (default false). # STARTUP_ACCEL_JERK_CAP / STARTUP_BRAKE_JERK_CAP [m/s^3] ego jerk caps for the first # STARTUP_JERK_CAP_SECONDS (default 1.5) of every scenario; unset/0 = off. -# PEDESTRIAN_SIZE_PAD_M [m] add this to pedestrian partner boxes' length and width in the policy's +# VRU_SIZE_PAD_M [m] add this to pedestrian and bicycle partner boxes' length and width in the policy's # observation (training never spawns below 0.8 m; 0.8 = strictly above the smallest training # vehicle); unset/0 = off. # OBS_SLOTS_PARTNERS_N shadow-env partner obs slots (unset = planner yaml, 40). @@ -87,7 +87,7 @@ EXTRA_ARGS+=("planner.pufferdrive_planner.city_bin_dir=$CITY_BIN_DIR") [ -n "${STARTUP_JERK_CAP_SECONDS:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_jerk_cap_seconds=$STARTUP_JERK_CAP_SECONDS") [ -n "${STARTUP_ACCEL_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_accel_jerk_cap_mps3=$STARTUP_ACCEL_JERK_CAP") [ -n "${STARTUP_BRAKE_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_brake_jerk_cap_mps3=$STARTUP_BRAKE_JERK_CAP") -[ -n "${PEDESTRIAN_SIZE_PAD_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_size_pad_m=$PEDESTRIAN_SIZE_PAD_M") +[ -n "${VRU_SIZE_PAD_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.vru_size_pad_m=$VRU_SIZE_PAD_M") [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.obs_slots_partners_n=$OBS_SLOTS_PARTNERS_N") [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.eval_perceived_size_margin_m=$EVAL_PERCEIVED_SIZE_MARGIN_M") [ -n "${REWARD_OVERSPEED:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.reward_overspeed=$REWARD_OVERSPEED") diff --git a/pufferlib/ocean/cosim/nuplan_bridge.py b/pufferlib/ocean/cosim/nuplan_bridge.py index e8c1f06b4a..b07288adb9 100644 --- a/pufferlib/ocean/cosim/nuplan_bridge.py +++ b/pufferlib/ocean/cosim/nuplan_bridge.py @@ -608,12 +608,15 @@ def partner_tracked_objects(tracked_objects, map_api, static_on_lane: dict): return kept -def pad_pedestrian_partner_sizes(types, lengths, widths, pad_m: float): - """Pedestrian boxes grow by pad_m in length and width (0.8 puts every nuPlan pedestrian strictly - above the 0.8 x 0.8 m training minimum). Other types untouched.""" - pedestrian = types == _NUPLAN_AGENT_TYPE["PEDESTRIAN"] - lengths = np.where(pedestrian, lengths + pad_m, lengths).astype(np.float32) - widths = np.where(pedestrian, widths + pad_m, widths).astype(np.float32) +VRU_PARTNER_TYPE_IDS = (_NUPLAN_AGENT_TYPE["PEDESTRIAN"], _NUPLAN_AGENT_TYPE["BICYCLE"]) + + +def pad_vru_partner_sizes(types, lengths, widths, pad_m: float): + """Pedestrian and bicycle boxes grow by pad_m in length and width (0.8 puts every nuPlan pedestrian + and bicycle strictly above the 0.8 x 0.8 m training minimum). Other types untouched.""" + vru = np.isin(types, VRU_PARTNER_TYPE_IDS) + lengths = np.where(vru, lengths + pad_m, lengths).astype(np.float32) + widths = np.where(vru, widths + pad_m, widths).astype(np.float32) return lengths, widths diff --git a/pufferlib/ocean/drive/binding.c b/pufferlib/ocean/drive/binding.c index 0c73e69a79..6c56e7d4a2 100644 --- a/pufferlib/ocean/drive/binding.c +++ b/pufferlib/ocean/drive/binding.c @@ -1926,6 +1926,7 @@ static int unpack_map_cache_args(PyObject *kwargs, Drive *config, const char *** config->use_neighbor_cache = (int) unpack(kwargs, "use_neighbor_cache"); config->obs_lane_stride = (int) unpack(kwargs, "obs_lane_stride"); config->obs_boundary_stride = (int) unpack(kwargs, "obs_boundary_stride"); + config->obs_stride_merge_segments = (int) unpack(kwargs, "obs_stride_merge_segments"); config->obs_range_road_front_m = (float) unpack(kwargs, "obs_range_road_front_m"); config->obs_range_road_behind_m = (float) unpack(kwargs, "obs_range_road_behind_m"); config->obs_range_road_side_m = (float) unpack(kwargs, "obs_range_road_side_m"); @@ -1933,7 +1934,8 @@ static int unpack_map_cache_args(PyObject *kwargs, Drive *config, const char *** return -1; } if (config->use_neighbor_cache < 0 || config->use_neighbor_cache > 1 || config->obs_lane_stride < 1 - || config->obs_boundary_stride < 1 || !isfinite(config->obs_range_road_front_m) + || config->obs_boundary_stride < 1 || config->obs_stride_merge_segments < 0 + || config->obs_stride_merge_segments > 1 || !isfinite(config->obs_range_road_front_m) || !isfinite(config->obs_range_road_behind_m) || !isfinite(config->obs_range_road_side_m) || config->obs_range_road_front_m < 0.0f || config->obs_range_road_behind_m < 0.0f || config->obs_range_road_side_m < 0.0f) { @@ -2063,6 +2065,7 @@ static int my_init(Env *env, PyObject *args, PyObject *kwargs) { env->yield_signs_enabled = (bool) unpack(kwargs, "yield_signs_enabled"); env->obs_lane_stride = (int) unpack(kwargs, "obs_lane_stride"); env->obs_boundary_stride = (int) unpack(kwargs, "obs_boundary_stride"); + env->obs_stride_merge_segments = (int) unpack(kwargs, "obs_stride_merge_segments"); env->dt = (float) unpack(kwargs, "dt"); env->base_max_speed_mps = (float) unpack(kwargs, "base_max_speed_mps"); env->max_speed_mps = (float) unpack(kwargs, "max_speed_mps"); diff --git a/pufferlib/ocean/drive/datatypes.h b/pufferlib/ocean/drive/datatypes.h index 0daefdfd39..9f2de5aa32 100644 --- a/pufferlib/ocean/drive/datatypes.h +++ b/pufferlib/ocean/drive/datatypes.h @@ -147,6 +147,7 @@ typedef struct { float direction_x; float direction_y; float width_m; + int observed; } RoadObservationSegment; struct RoadMapElement { diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index 293899d7a2..d859f85af0 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -139,6 +139,7 @@ struct SharedMapData { struct LaneGraph lane_graph; int obs_lane_stride; int obs_boundary_stride; + int obs_stride_merge_segments; int ref_count; pid_t owner_pid; }; @@ -288,6 +289,7 @@ struct Drive { int yield_signs_enabled; int obs_lane_stride; int obs_boundary_stride; + int obs_stride_merge_segments; int obs_slots_lane_kept; int obs_slots_boundary_kept; int road_dropout_enabled; diff --git a/pufferlib/ocean/drive/drive.py b/pufferlib/ocean/drive/drive.py index e49c07b34c..366f39de88 100644 --- a/pufferlib/ocean/drive/drive.py +++ b/pufferlib/ocean/drive/drive.py @@ -118,6 +118,7 @@ def __init__( obs_slots_boundary_n=32, obs_lane_stride=1, obs_boundary_stride=1, + obs_stride_merge_segments=False, obs_slots_partners_n=16, obs_partner_relative_velocity=False, obs_lane_heading_signed=False, @@ -304,6 +305,7 @@ def __init__( self.obs_slots_boundary_n = obs_slots_boundary_n self.obs_lane_stride = obs_lane_stride self.obs_boundary_stride = obs_boundary_stride + self.obs_stride_merge_segments = int(bool(obs_stride_merge_segments)) self.obs_slots_partners_n = obs_slots_partners_n self.obs_partner_relative_velocity = int(bool(obs_partner_relative_velocity)) self.obs_lane_heading_signed = int(bool(obs_lane_heading_signed)) @@ -524,6 +526,7 @@ def preload_shared_resources(self): "use_neighbor_cache": self.use_neighbor_cache, "obs_lane_stride": self.obs_lane_stride, "obs_boundary_stride": self.obs_boundary_stride, + "obs_stride_merge_segments": self.obs_stride_merge_segments, "obs_range_road_front_m": self.obs_range_road_front_m, "obs_range_road_behind_m": self.obs_range_road_behind_m, "obs_range_road_side_m": self.obs_range_road_side_m, @@ -574,6 +577,7 @@ def _env_init_kwargs(self, map_file, max_agents): "obs_slots_boundary_n": self.obs_slots_boundary_n, "obs_lane_stride": self.obs_lane_stride, "obs_boundary_stride": self.obs_boundary_stride, + "obs_stride_merge_segments": self.obs_stride_merge_segments, "obs_slots_partners_n": self.obs_slots_partners_n, "obs_partner_relative_velocity": self.obs_partner_relative_velocity, "obs_lane_heading_signed": self.obs_lane_heading_signed, diff --git a/pufferlib/ocean/drive/map_data.h b/pufferlib/ocean/drive/map_data.h index 8254dc4fbb..f079e77935 100644 --- a/pufferlib/ocean/drive/map_data.h +++ b/pufferlib/ocean/drive/map_data.h @@ -56,6 +56,68 @@ static void add_entity_to_grid( cell_entities_insert_index[grid_index] = count + 1; } +static void set_observation_segment_geometry( + RoadMapElement *road, + int start_point_idx, + int end_point_idx, + int carrier_idx) { + RoadObservationSegment *segment = &road->observation_segments[carrier_idx]; + // Preserve the observation formulas and float rounding, including zero-length segments. + segment->mid_x_m = (road->x[start_point_idx] + road->x[end_point_idx]) / 2.0f; + segment->mid_y_m = (road->y[start_point_idx] + road->y[end_point_idx]) / 2.0f; + segment->mid_z_m = (road->z[start_point_idx] + road->z[end_point_idx]) / 2.0f; + float half_dx_m = road->x[end_point_idx] - segment->mid_x_m; + float half_dy_m = road->y[end_point_idx] - segment->mid_y_m; + segment->half_length_m = sqrtf(half_dx_m * half_dx_m + half_dy_m * half_dy_m); + segment->direction_x = segment->half_length_m > 0.0f ? half_dx_m / segment->half_length_m : half_dx_m; + segment->direction_y = segment->half_length_m > 0.0f ? half_dy_m / segment->half_length_m : half_dy_m; + segment->width_m + = is_road_lane(road->type) ? 0.5f * (road->widths[start_point_idx] + road->widths[end_point_idx]) : 0.0f; +} + +// Legacy stride: every obs_stride-th segment is observed with its own extent, so the skipped ones leave gaps. +static void mark_strided_observation_segments(RoadMapElement *road, int obs_stride) { + int last_kept_idx = 0; + for (int segment_idx = 0; segment_idx < road->segment_size - 1; segment_idx++) { + int observed = 1; + if (obs_stride > 1 && segment_idx > 0) { + float heading_dev_rad + = fabsf(normalize_heading(road->headings[segment_idx] - road->headings[last_kept_idx])); + observed = segment_idx - last_kept_idx >= obs_stride || heading_dev_rad > OBS_STRIDE_HEADING_THRESHOLD; + } + if (observed) { + last_kept_idx = segment_idx; + } + road->observation_segments[segment_idx].observed = observed; + } +} + +// One chord from the run's first point to its last point, carried by the run's middle segment. +static void close_observation_segment_run(RoadMapElement *road, int first_segment_idx, int last_segment_idx) { + int carrier_idx = (first_segment_idx + last_segment_idx) / 2; + set_observation_segment_geometry(road, first_segment_idx, last_segment_idx + 1, carrier_idx); + road->observation_segments[carrier_idx].observed = 1; +} + +// Runs of up to obs_stride near-collinear segments become one chord, so the polyline stays connected. +static void merge_strided_observation_segments(RoadMapElement *road, int obs_stride) { + int segment_count = road->segment_size - 1; + int run_start_idx = 0; + for (int segment_idx = 0; segment_idx < segment_count; segment_idx++) { + if (segment_idx > run_start_idx + && fabsf(normalize_heading(road->headings[segment_idx] - road->headings[run_start_idx])) + > OBS_STRIDE_HEADING_THRESHOLD) { + close_observation_segment_run(road, run_start_idx, segment_idx - 1); + run_start_idx = segment_idx; + } + if (segment_idx - run_start_idx + 1 < obs_stride && segment_idx < segment_count - 1) { + continue; + } + close_observation_segment_run(road, run_start_idx, segment_idx); + run_start_idx = segment_idx + 1; + } +} + // Build after loading lane widths and before sharing the immutable road elements. static void cache_road_observation_geometry(Drive *env) { for (int road_idx = 0; road_idx < env->num_road_elements; road_idx++) { @@ -77,20 +139,14 @@ static void cache_road_observation_geometry(Drive *env) { if (road->observation_segments == NULL) { raise_error_with_message(ERROR_MEMORY_ALLOCATION, "Failed to cache geometry for road %d", road_idx); } - int has_lane_width = is_road_lane(road->type); for (int segment_idx = 0; segment_idx < road->segment_size - 1; segment_idx++) { - RoadObservationSegment *segment = &road->observation_segments[segment_idx]; - // Preserve the observation formulas and float rounding, including zero-length segments. - segment->mid_x_m = (road->x[segment_idx] + road->x[segment_idx + 1]) / 2.0f; - segment->mid_y_m = (road->y[segment_idx] + road->y[segment_idx + 1]) / 2.0f; - segment->mid_z_m = (road->z[segment_idx] + road->z[segment_idx + 1]) / 2.0f; - float half_dx_m = road->x[segment_idx + 1] - segment->mid_x_m; - float half_dy_m = road->y[segment_idx + 1] - segment->mid_y_m; - segment->half_length_m = sqrtf(half_dx_m * half_dx_m + half_dy_m * half_dy_m); - segment->direction_x = segment->half_length_m > 0.0f ? half_dx_m / segment->half_length_m : half_dx_m; - segment->direction_y = segment->half_length_m > 0.0f ? half_dy_m / segment->half_length_m : half_dy_m; - segment->width_m - = has_lane_width ? 0.5f * (road->widths[segment_idx] + road->widths[segment_idx + 1]) : 0.0f; + set_observation_segment_geometry(road, segment_idx, segment_idx + 1, segment_idx); + } + int obs_stride = is_road_lane(road->type) ? env->obs_lane_stride : env->obs_boundary_stride; + if (env->obs_stride_merge_segments) { + merge_strided_observation_segments(road, obs_stride); + } else { + mark_strided_observation_segments(road, obs_stride); } } } @@ -209,24 +265,9 @@ static int init_grid_map(Drive *env) { } RoadMapElement *element = &env->road_elements[i]; int is_lane = is_road_lane(element->type); - int obs_stride = 1; - if (is_lane) { - obs_stride = env->obs_lane_stride; - } else if (is_road_edge(element->type)) { - obs_stride = env->obs_boundary_stride; - } - int last_kept_idx = 0; for (int j = 0; j < element->segment_size - 1; j++) { - // Keep a point every obs_stride points, plus wherever heading deviates enough - // since the last kept point (densifies curves/intersections) - int valid_for_obs = 1; - if (obs_stride > 1 && j > 0) { - float heading_dev = fabsf(normalize_heading(element->headings[j] - element->headings[last_kept_idx])); - valid_for_obs = j - last_kept_idx >= obs_stride || heading_dev > OBS_STRIDE_HEADING_THRESHOLD; - } int obs_kind = OBS_ENTITY_NONE; - if (valid_for_obs) { - last_kept_idx = j; + if (element->observation_segments[j].observed) { obs_kind = is_lane ? OBS_ENTITY_LANE : OBS_ENTITY_EDGE; } float x_center = (element->x[j] + element->x[j + 1]) / 2; @@ -1009,7 +1050,8 @@ static struct SharedMapData *map_cache_lookup(Drive *env) { for (int i = 0; i < g_map_cache_count; i++) { if (g_map_cache[i] != NULL && strcmp(g_map_cache[i]->map_name, env->map_name) == 0 && g_map_cache[i]->obs_lane_stride == env->obs_lane_stride - && g_map_cache[i]->obs_boundary_stride == env->obs_boundary_stride) { + && g_map_cache[i]->obs_boundary_stride == env->obs_boundary_stride + && g_map_cache[i]->obs_stride_merge_segments == env->obs_stride_merge_segments) { return g_map_cache[i]; } } @@ -1039,6 +1081,7 @@ static struct SharedMapData *map_cache_store(Drive *env) { entry->lane_graph = env->lane_graph; entry->obs_lane_stride = env->obs_lane_stride; entry->obs_boundary_stride = env->obs_boundary_stride; + entry->obs_stride_merge_segments = env->obs_stride_merge_segments; entry->ref_count = 1; entry->owner_pid = getpid(); map_cache_insert(entry); diff --git a/pufferlib/pufferl.py b/pufferlib/pufferl.py index 2704ab32aa..d2142fff12 100644 --- a/pufferlib/pufferl.py +++ b/pufferlib/pufferl.py @@ -1507,6 +1507,7 @@ def train(env_name, args=None, vecenv=None, policy=None, logger=None, early_stop "obs_slots_lane_n", "obs_boundary_stride", "obs_lane_stride", + "obs_stride_merge_segments", "obs_dropout_boundary", "obs_dropout_lane", "obs_slots_partners_n", diff --git a/pufferlib/viz.py b/pufferlib/viz.py index 4d6a30fd2c..c2dd7c34d4 100644 --- a/pufferlib/viz.py +++ b/pufferlib/viz.py @@ -733,7 +733,7 @@ def plot_observation( continue count_lane += 1 rel_x, rel_y = lane_obs[i][0], lane_obs[i][1] - length = lane_obs[i][3] * rl2p + length = 2 * lane_obs[i][3] * rl2p dir_cos, dir_sin = lane_obs[i][4], lane_obs[i][5] # idx 7 = goal_dist_abs (0 near goal lane -> 1 far/unreachable); green->red colormap color = plt.cm.RdYlGn_r(float(lane_obs[i][7])) if obs_goal_lane_distance else "lightgrey" @@ -759,7 +759,7 @@ def plot_observation( continue count_boundary += 1 rel_x, rel_y = boundary_obs[i][0], boundary_obs[i][1] - length = boundary_obs[i][3] * rl2p + length = 2 * boundary_obs[i][3] * rl2p dir_cos, dir_sin = boundary_obs[i][4], boundary_obs[i][5] color = "black" ax.scatter(rel_x, rel_y, color=color, s=10, zorder=1) @@ -1601,7 +1601,7 @@ def _render_interactive_replay_payload(compressed_payload, filename): const rot = (x,y) => [-y,x]; const zero = (off,n) => { for(let i=0;i { const out=[]; for(let i=0;i= 0 ? v(o+limitIdx)*speedScale : null]); } return out; }; + const roads = (start,count,poolName,feat) => { const out=[]; for(let i=0;i= 0 ? v(o+limitIdx)*speedScale : null]); } return out; }; const partners = []; for(let i=0;i/dev/null) $(git -C "$PD" diff --quiet 2>/dev/null || echo dirty)" } > "$OUT/run_info.txt" echo "Results -> $OUT" diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index c0ded2026e..42558c0e21 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -29,8 +29,9 @@ RUN_DIR=${RUN_DIR:-/home/bjaeger/PufferDrive/experiments/k_scaled_0045_1000} CKPT=$RUN_DIR/final_model.pt [ -f "$CKPT" ] || CKPT=$(ls "$RUN_DIR"/models/model_*.pt 2>/dev/null | sort | tail -n 1) GOAL_MODE=gt_time:5 # gt_time: | gt | route -PEDESTRIAN_SIZE_PAD_M=0.8 # added to every pedestrian box's length and width the teacher sees; 0 = true boxes +VRU_SIZE_PAD_M=0.8 # added to the length and width of every pedestrian and two-wheeler box the teacher sees; 0 = true boxes RENDER_VIDEO=0 # one mp4 per scene; 1 also streams the teacher BEV and makes every policy step ~5x slower +OBS_REPLAY=1 # PufferDrive observation replay html per scene -> $OUT/alpagym/obs_replays CAM_W=640 # 16:9 render size; the teacher never sees the cameras CAM_H=360 SIM_TIMEOUT_S=3600 # per batch of PARALLEL scenes @@ -113,11 +114,11 @@ EOF [ ${#SCENES[@]} -gt 0 ] || { echo "no scenes to evaluate"; exit 1; } # results live in the model's own eval folder, next to the PufferDrive benchmark evals -OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}$([ "$PEDESTRIAN_SIZE_PAD_M" = "0" ] || echo "_pedpad$PEDESTRIAN_SIZE_PAD_M")_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} +OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}$([ "$VRU_SIZE_PAD_M" = "0" ] || echo "_vrupad$VRU_SIZE_PAD_M")_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT" { echo "checkpoint $CKPT"; echo "scenes ${#SCENES[@]}"; echo "topology $TOPOLOGY parallel $PARALLEL goal_mode $GOAL_MODE" - echo "pedestrian_size_pad_m $PEDESTRIAN_SIZE_PAD_M" + echo "vru_size_pad_m $VRU_SIZE_PAD_M" for repo in "$PD" "$ALPAGYM_ROOT" "$ALPASIM_ROOT"; do echo "git $repo $(git -C "$repo" rev-parse --short HEAD 2>/dev/null) $(git -C "$repo" diff --quiet 2>/dev/null || echo dirty)" done @@ -156,6 +157,7 @@ fi # the teacher is an external driver, but the 8gpu topologies re-assert driver.skip=false and abort at startup OVERRIDES="wizard.baseport=$BASEPORT runtime.endpoints.driver.skip=true" [ "$RENDER_VIDEO" = "1" ] && RENDER_VIDEO_FLAG=true || RENDER_VIDEO_FLAG=false +[ "$OBS_REPLAY" = "1" ] && OBS_REPLAY_FLAG=true || OBS_REPLAY_FLAG=false echo "${#SCENES[@]} scenes, $TOPOLOGY with $PARALLEL in flight; full log: $OUT/evaluate.log" cd "$ALPAGYM_ROOT" || exit 1 "$ALPAGYM_PY" -m alpagym_host.evaluate \ @@ -163,8 +165,9 @@ cd "$ALPAGYM_ROOT" || exit 1 --teacher_checkpoint "$CKPT" \ --teacher_bins_dir "$BINS" \ --teacher_goal_mode "$GOAL_MODE" \ - --teacher_pedestrian_size_pad_m "$PEDESTRIAN_SIZE_PAD_M" \ + --teacher_vru_size_pad_m "$VRU_SIZE_PAD_M" \ --render_video "$RENDER_VIDEO_FLAG" \ + --teacher_obs_replay "$OBS_REPLAY_FLAG" \ --local_scene_dir "$USDZ_DIR" \ --scene_ids "${SCENES[@]}" \ --topology "$TOPOLOGY" \ @@ -194,6 +197,6 @@ if [ $EVAL_STATUS -ne 0 ]; then echo "AlpaSim evaluation failed (exit $EVAL_STATUS); last host lines of $OUT/evaluate.log:" grep -vE '^[a-z0-9_-]+-[0-9]+-[0-9]+ +\|' "$OUT/evaluate.log" | tail -n 30 fi -echo "per-scene metrics -> $OUT/alpagym/metrics/per_scene.csv; videos -> $OUT/alpagym/alpasim/wizard_0/rollouts" +echo "per-scene metrics -> $OUT/alpagym/metrics/per_scene.csv; videos -> $OUT/alpagym/alpasim/wizard_0/rollouts; obs replays -> $OUT/alpagym/obs_replays" echo "Done -> $OUT" exit $EVAL_STATUS diff --git a/scripts/kesai/14_carla_longest6_v1.sh b/scripts/kesai/14_carla_longest6_v1.sh index dbbd638d9e..715d814134 100644 --- a/scripts/kesai/14_carla_longest6_v1.sh +++ b/scripts/kesai/14_carla_longest6_v1.sh @@ -28,9 +28,9 @@ # the interactive obs replay per route, rendered into $OUT/obs_html at the end, large; needs LOGGING=1), REPORT # (0 = skip the HTML report), MAX_ATTEMPTS, CARLA_ROOT, GARAGE_WORK_DIR, GARAGE_PY, SERVER_PY, PD, # COSIM_MAX_SPEED_MPS (ego speed cap, default 30), COSIM_ZERO_PARTNER_STOPPED_TIME (default 1), -# COSIM_PEDESTRIAN_SIZE_PAD_M (default 0.8: added to every walker box's length and width, so walkers read strictly -# larger than the smallest training vehicle; 0 = true boxes), LIBTIFF5_COMPAT (1: when `import carla` fails for the -# egg's missing libtiff.so.5, try the garage env's lib dir (`conda install -n garage "libtiff<4.5"`), then link the +# COSIM_VRU_SIZE_PAD_M (default 0.8: added to the length and width of every walker and two-wheeler box, so they read +# strictly larger than the smallest training vehicle; 0 = true boxes), LIBTIFF5_COMPAT (1: when `import carla` fails for +# the egg's missing libtiff.so.5, try the garage env's lib dir (`conda install -n garage "libtiff<4.5"`), then link the # system libtiff.so.6 under that name; 0: never link). set -u @@ -59,7 +59,7 @@ CARLA_VIEW=${CARLA_VIEW:-0} [ "$CARLA_VIEW" = "1" ] && [ "$LOGGING" != "1" ] && { echo "CARLA_VIEW=1 needs LOGGING=1"; exit 1; } OBS_HTML=${OBS_HTML:-1} REPORT=${REPORT:-$LOGGING} -export COSIM_PEDESTRIAN_SIZE_PAD_M=${COSIM_PEDESTRIAN_SIZE_PAD_M:-0.8} +export COSIM_VRU_SIZE_PAD_M=${COSIM_VRU_SIZE_PAD_M:-0.8} EVALUATOR=$GARAGE_WORK_DIR/leaderboard/leaderboard/leaderboard_evaluator_local.py CARLA_EGG=$CARLA_ROOT/PythonAPI/carla/dist/carla-0.9.10-py3.7-linux-x86_64.egg AGENT=$PD/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -77,13 +77,13 @@ NUM_GPUS=${NUM_GPUS:-$(nvidia-smi -L 2>/dev/null | grep -c "^GPU")} if [ "$NUM_GPUS" -lt 1 ] || [ "$NUM_GPUS" -gt 8 ]; then echo "NUM_GPUS=$NUM_GPUS must be 1..8"; exit 1; fi TAG=longest6_v1$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") -TAG=$TAG$([ "$COSIM_PEDESTRIAN_SIZE_PAD_M" = "0" ] || echo "_pedpad$COSIM_PEDESTRIAN_SIZE_PAD_M") +TAG=$TAG$([ "$COSIM_VRU_SIZE_PAD_M" = "0" ] || echo "_vrupad$COSIM_VRU_SIZE_PAD_M") # results live in the model's own eval folder, next to the PufferDrive benchmark evals OUT=$RUN_DIR/eval/carla_${TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT/routes" "$OUT/aggregate" { echo "checkpoint $CKPT"; echo "routes $ROUTE_DIR ($ROUTES_XML)"; echo "scenarios $SCENARIO_FILE" - echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS"; echo "pedestrian_size_pad_m $COSIM_PEDESTRIAN_SIZE_PAD_M" + echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS"; echo "vru_size_pad_m $COSIM_VRU_SIZE_PAD_M" echo "git $(git -C "$PD" rev-parse --short HEAD 2>/dev/null) $(git -C "$PD" diff --quiet 2>/dev/null || echo dirty)" } > "$OUT/run_info.txt" echo "Results -> $OUT" diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index b0df489336..979362e1cb 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -61,6 +61,7 @@ srun torchrun \ train.data_dir=${DATA_DIR} \ env.map_dir=/home/bjaeger/PufferDrive/pufferlib/resources/drive/binaries/carla_128_affine_zones_stop_ndshoulders \ env.num_maps=128 \ + env.obs_stride_merge_segments=true \ train.name=${RUN_NAME} \ run_name=${RUN_NAME} \ train.total_timesteps=1000000000000 \ diff --git a/scripts/kesai/9_nuPlan.sh b/scripts/kesai/9_nuPlan.sh index 170f91620c..0163e15c15 100644 --- a/scripts/kesai/9_nuPlan.sh +++ b/scripts/kesai/9_nuPlan.sh @@ -49,8 +49,8 @@ export GOAL_SOURCE=roadblock export SLIDING_GOAL_WINDOW=true export STARTUP_ACCEL_JERK_CAP=2 export STARTUP_BRAKE_JERK_CAP=2 -# pedestrian partner boxes grow by this much in length and width in the policy's obs (0 = off): 0.8 puts them strictly above the smallest training vehicle -export PEDESTRIAN_SIZE_PAD_M=0.8 +# pedestrian and bicycle partner boxes grow by this much in length and width in the policy's obs (0 = off): 0.8 puts them strictly above the smallest training vehicle +export VRU_SIZE_PAD_M=0.8 # Shadow-env obs overrides: partner slots (training 20, GIGAFLOW eval 40; 40 real partners max-pooled is OOD in # sidewalk crowds) and the ego perceived-size margin [m per side] (0.2 = native eval; nuPlan's 2.3 x 5.2 m ego # reads as 2.7 x 5.6 m). Unset = planner yaml / arch.py defaults (40 / 0.2). @@ -62,7 +62,7 @@ ABLATION_TAG="" [ "$SLIDING_GOAL_WINDOW" = "true" ] && ABLATION_TAG="${ABLATION_TAG}_slide" [ "$STARTUP_ACCEL_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jacc${STARTUP_ACCEL_JERK_CAP}" [ "$STARTUP_BRAKE_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jbrk${STARTUP_BRAKE_JERK_CAP}" -[ "$PEDESTRIAN_SIZE_PAD_M" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_pedpad${PEDESTRIAN_SIZE_PAD_M}m" +[ "$VRU_SIZE_PAD_M" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_vrupad${VRU_SIZE_PAD_M}m" [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && ABLATION_TAG="${ABLATION_TAG}_slots${OBS_SLOTS_PARTNERS_N}" [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && ABLATION_TAG="${ABLATION_TAG}_margin${EVAL_PERCEIVED_SIZE_MARGIN_M}" [ -n "${REWARD_OVERSPEED:-}" ] && ABLATION_TAG="${ABLATION_TAG}_overspeed${REWARD_OVERSPEED}" diff --git a/tests/drive/test_drive_map_cache.c b/tests/drive/test_drive_map_cache.c index a1dd9ff6d8..a4ff0a936c 100644 --- a/tests/drive/test_drive_map_cache.c +++ b/tests/drive/test_drive_map_cache.c @@ -330,8 +330,8 @@ static int test_road_observation_cache_handles_widths_and_zero_length_segments(v Drive env = {.road_elements = roads, .num_road_elements = 3}; cache_road_observation_geometry(&env); const RoadObservationSegment expected_lane[] = { - {3.0f, 4.0f, 2.0f, 5.0f, 0.6f, 0.8f, 3.0f}, - {6.0f, 8.0f, 3.0f, 0.0f, 0.0f, 0.0f, 5.0f}, + {3.0f, 4.0f, 2.0f, 5.0f, 0.6f, 0.8f, 3.0f, 1}, + {6.0f, 8.0f, 3.0f, 0.0f, 0.0f, 0.0f, 5.0f, 1}, }; RoadObservationSegment expected_boundary[2]; memcpy(expected_boundary, expected_lane, sizeof(expected_boundary)); @@ -345,10 +345,47 @@ static int test_road_observation_cache_handles_widths_and_zero_length_segments(v return 0; } +// 5 straight 10 m segments then a 90 degree turn of two 10 m segments; stride 2. +static int test_stride_merge_chords_stay_connected_and_split_at_bends(void) { + float x[] = {0.0f, 10.0f, 20.0f, 30.0f, 40.0f, 50.0f, 50.0f, 50.0f}; + float y[] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 10.0f, 20.0f}; + float z[8] = {0.0f}; + float headings[] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.5708f, 1.5708f, 1.5708f}; + RoadMapElement roads[] = { + {.type = ROAD_EDGE_BOUNDARY, .segment_size = 8, .x = x, .y = y, .z = z, .headings = headings}, + }; + Drive legacy = {.road_elements = roads, .num_road_elements = 1, .obs_boundary_stride = 2}; + cache_road_observation_geometry(&legacy); + const int expected_legacy_observed[] = {1, 0, 1, 0, 1, 1, 0}; + for (int segment_idx = 0; segment_idx < 7; segment_idx++) { + EXPECT_EQ_INT(roads[0].observation_segments[segment_idx].observed, expected_legacy_observed[segment_idx]); + EXPECT_NEAR(roads[0].observation_segments[segment_idx].half_length_m, 5.0f, 1e-5f); + } + free(roads[0].observation_segments); + + Drive merged + = {.road_elements = roads, .num_road_elements = 1, .obs_boundary_stride = 2, .obs_stride_merge_segments = 1}; + cache_road_observation_geometry(&merged); + // runs [0,1] [2,3] [4] [5,6] -> carriers 0, 2, 4, 5 spanning x 0-20, 20-40, 40-50, then y 0-20 + const RoadObservationSegment expected[] = { + {10.0f, 0.0f, 0.0f, 10.0f, 1.0f, 0.0f, 0.0f, 1}, + {15.0f, 0.0f, 0.0f, 5.0f, 1.0f, 0.0f, 0.0f, 0}, + {30.0f, 0.0f, 0.0f, 10.0f, 1.0f, 0.0f, 0.0f, 1}, + {35.0f, 0.0f, 0.0f, 5.0f, 1.0f, 0.0f, 0.0f, 0}, + {45.0f, 0.0f, 0.0f, 5.0f, 1.0f, 0.0f, 0.0f, 1}, + {50.0f, 10.0f, 0.0f, 10.0f, 0.0f, 1.0f, 0.0f, 1}, + {50.0f, 15.0f, 0.0f, 5.0f, 0.0f, 1.0f, 0.0f, 0}, + }; + EXPECT_EQ_INT(memcmp(roads[0].observation_segments, expected, sizeof(expected)), 0); + free(roads[0].observation_segments); + return 0; +} + int main(void) { int failures = 0; RUN_TEST(test_road_observation_cache_follows_map_ownership); RUN_TEST(test_road_observation_cache_handles_widths_and_zero_length_segments); + RUN_TEST(test_stride_merge_chords_stay_connected_and_split_at_bends); RUN_TEST(test_all_cache_modes_produce_identical_step_outputs); RUN_TEST(test_cache_mode_matrix_has_expected_map_and_neighbor_allocations); RUN_TEST(test_mixed_neighbor_modes_share_map_and_populate_neighbor_cache); diff --git a/tests/unit_tests/test_nuplan_bridge_partner_sizes.py b/tests/unit_tests/test_nuplan_bridge_partner_sizes.py index 0afa85767f..19a31d22d0 100644 --- a/tests/unit_tests/test_nuplan_bridge_partner_sizes.py +++ b/tests/unit_tests/test_nuplan_bridge_partner_sizes.py @@ -1,5 +1,5 @@ -"""pad_pedestrian_partner_sizes: pedestrian boxes grow by the pad on both axes, other types keep their size, and the -inputs are never modified.""" +"""pad_vru_partner_sizes: pedestrian and bicycle boxes grow by the pad on both axes, vehicles keep their size, and +the inputs are never modified.""" import os import sys @@ -12,13 +12,13 @@ from pufferlib.ocean.cosim import nuplan_bridge as nb -def test_pad_grows_only_pedestrian_boxes(): +def test_pad_grows_pedestrian_and_bicycle_boxes_only(): types = np.array([1, 2, 3, 2], np.int32) # vehicle, pedestrian, bicycle, pedestrian lengths = np.array([4.5, 0.5, 1.7, 0.9], np.float32) widths = np.array([1.9, 0.6, 0.5, 0.7], np.float32) - ln, wd = nb.pad_pedestrian_partner_sizes(types, lengths, widths, 0.8) - np.testing.assert_allclose(ln, [4.5, 1.3, 1.7, 1.7], rtol=1e-6) - np.testing.assert_allclose(wd, [1.9, 1.4, 0.5, 1.5], rtol=1e-6) + ln, wd = nb.pad_vru_partner_sizes(types, lengths, widths, 0.8) + np.testing.assert_allclose(ln, [4.5, 1.3, 2.5, 1.7], rtol=1e-6) + np.testing.assert_allclose(wd, [1.9, 1.4, 1.3, 1.5], rtol=1e-6) assert ln.dtype == np.float32 and wd.dtype == np.float32 @@ -26,5 +26,5 @@ def test_pad_leaves_inputs_untouched(): types = np.array([2], np.int32) lengths = np.array([0.4], np.float32) widths = np.array([0.4], np.float32) - nb.pad_pedestrian_partner_sizes(types, lengths, widths, 0.8) + nb.pad_vru_partner_sizes(types, lengths, widths, 0.8) assert lengths[0] == pytest.approx(0.4) and widths[0] == pytest.approx(0.4) From 0dbc56925ae0508cba47cf08362297e9dc10d9ec Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 14:18:31 +0200 Subject: [PATCH 041/123] remove uneccesary alpasim logs --- scripts/kesai/12_alpasim.sh | 58 +++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index a5830a6a9d..0d59ef1e5a 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -32,10 +32,13 @@ GOAL_MODE=gt_time:5 # gt_time: | gt | route GOAL_RADIUS_M=2 # goal arrival radius Damiano's reference evals use; the teacher's own gt_time default is 6 VRU_SIZE_PAD_M=0.8 # added to the length and width of every pedestrian and two-wheeler box the teacher sees; 0 = true boxes RENDER_VIDEO=0 # one mp4 per scene; 1 also streams the teacher BEV and makes every policy step ~5x slower -OBS_REPLAY=1 # PufferDrive observation replay html per scene -> $OUT/alpagym/obs_replays +OBS_REPLAY=1 # PufferDrive observation replay html per scene -> $OUT/obs_replays CAM_W=640 # 16:9 render size; the teacher never sees the cameras CAM_H=360 SIM_TIMEOUT_S=3600 # per batch of PARALLEL scenes +ALPASIM_LOG_LEVEL=WARNING # AlpaSim services; INFO logs every sim event (~100 MB per 400 scenes in evaluate.log and the worker logs) +PROMETHEUS=0 # AlpaSim performance telemetry (~80 MB per run); only alpagym_runtime.perf reads it +KEEP_ROLLOUT_LOGS=imperfect # rollout.asl (~36 MB per scene, offline analysis only) kept after scoring: all | imperfect (score < 1) | failed | none export PD=/home/bjaeger/PufferDrive ALPAGYM_ROOT=/home/bjaeger/alpasim-training # held-out split Damiano evaluates on; empty = every converted scene that passed the checks (2149 on the cluster, 1584 of them train) @@ -54,6 +57,7 @@ ALPAGYM_PY=$ALPAGYM_ROOT/.venv/bin/python export PATH="$HOME/.local/bin:$PATH" [ -n "$CKPT" ] || { echo "no checkpoint in $RUN_DIR (final_model.pt or models/model_*.pt)"; exit 1; } +case "$KEEP_ROLLOUT_LOGS" in all|imperfect|failed|none) ;; *) echo "KEEP_ROLLOUT_LOGS must be all, imperfect, failed or none, got $KEEP_ROLLOUT_LOGS"; exit 1 ;; esac for path in "$CKPT" "$ALPAGYM_PY" "$ALPASIM_ROOT/pyproject.toml" "$BINS/scene_manifest.jsonl" "$USDZ_DIR" ${SCENE_LIST:+"$SCENE_LIST"}; do [ -r "$path" ] || { echo "missing or not readable: $path"; exit 1; } done @@ -133,8 +137,10 @@ BASEPORT=$((6000 + (${SLURM_JOB_ID:-$$} % 90) * 20)) LOADED_BASE=0 PULLED_NRE=0 cleanup() { - local compose_file=$OUT/alpagym/alpasim/wizard_0/docker-compose.yaml - [ -f "$compose_file" ] && docker compose -f "$compose_file" down --volumes --remove-orphans > /dev/null 2>&1 + local compose_file + for compose_file in "$OUT/alpagym/alpasim/wizard_0/docker-compose.yaml" "$OUT/alpasim/wizard_0/docker-compose.yaml"; do + [ -f "$compose_file" ] && docker compose -f "$compose_file" down --volumes --remove-orphans > /dev/null 2>&1 + done for container in $(docker ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT_NAME"); do docker rm -f "$container" > /dev/null 2>&1 done @@ -156,10 +162,11 @@ if ! docker image inspect "$NRE_TAG" > /dev/null 2>&1; then fi # the teacher is an external driver, but the 8gpu topologies re-assert driver.skip=false and abort at startup -OVERRIDES="wizard.baseport=$BASEPORT runtime.endpoints.driver.skip=true" +[ "$PROMETHEUS" = "1" ] && PROMETHEUS_FLAG=true || PROMETHEUS_FLAG=false +OVERRIDES="wizard.baseport=$BASEPORT runtime.endpoints.driver.skip=true wizard.log_level=$ALPASIM_LOG_LEVEL wizard.prometheus.start_prometheus=$PROMETHEUS_FLAG" [ "$RENDER_VIDEO" = "1" ] && RENDER_VIDEO_FLAG=true || RENDER_VIDEO_FLAG=false [ "$OBS_REPLAY" = "1" ] && OBS_REPLAY_FLAG=true || OBS_REPLAY_FLAG=false -echo "${#SCENES[@]} scenes, $TOPOLOGY with $PARALLEL in flight; full log: $OUT/evaluate.log" +echo "${#SCENES[@]} scenes, $TOPOLOGY with $PARALLEL in flight; full log: $OUT/evaluate.log (logs/ once the run is tidied)" cd "$ALPAGYM_ROOT" || exit 1 "$ALPAGYM_PY" -m alpagym_host.evaluate \ --model_kind teacher \ @@ -220,6 +227,45 @@ if [ $EVAL_STATUS -ne 0 ]; then echo "AlpaSim evaluation failed (exit $EVAL_STATUS); last host lines of $OUT/evaluate.log:" grep -vE '^[a-z0-9_-]+-[0-9]+-[0-9]+ +\|' "$OUT/evaluate.log" | tail -n 30 fi -echo "results -> $OUT/results.txt; per-scene metrics -> $OUT/alpagym/evaluation.yaml; videos -> $OUT/alpagym/alpasim/wizard_0/rollouts; obs replays -> $OUT/alpagym/obs_replays" +# tidy: results.txt, obs_replays/, logs/ and the AlpaSim wizard tree (alpasim/, which AlpaSim's own tooling expects as is) +# side by side; the alpagym run dir dissolves into them +mkdir -p "$OUT/logs" +[ -d "$OUT/alpagym/alpasim" ] && mv "$OUT/alpagym/alpasim" "$OUT/alpasim" +[ -d "$OUT/alpagym/obs_replays" ] && mv "$OUT/alpagym/obs_replays" "$OUT/obs_replays" +for f in "$OUT/evaluate.log" "$OUT/run_info.txt" "$OUT/alpagym/config.json" "$OUT/alpagym/evaluation.yaml" "$OUT/alpagym/logs/evaluation.log"; do + [ -f "$f" ] && mv "$f" "$OUT/logs/" +done +WIZARD=$OUT/alpasim/wizard_0 +[ -d "$WIZARD/txt-logs" ] && mv "$WIZARD"/txt-logs/* "$OUT/logs/" 2>/dev/null && rmdir "$WIZARD/txt-logs" +[ -d "$WIZARD/controller" ] && mv "$WIZARD/controller" "$OUT/logs/controller" +rmdir "$OUT/alpagym/logs" "$OUT/alpagym" 2>/dev/null + +# rollout.asl is only read offline (re-scoring, videos, obs replay rebuilds, failure analysis): keep it for the scenes worth a look +SUMMARY=$WIZARD/aggregate/results-summary.json +if [ "$KEEP_ROLLOUT_LOGS" = "all" ]; then + : +elif [ -f "$SUMMARY" ]; then + "$ALPAGYM_PY" - "$SUMMARY" "$WIZARD/rollouts" "$KEEP_ROLLOUT_LOGS" <<'EOF' +import json, sys +from pathlib import Path +summary, rollouts, keep = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3] +keep_rule = {"imperfect": lambda r: r["score"] < 1, "failed": lambda r: r["status"] == "fail", "none": lambda r: False}[keep] +removed = freed = kept = 0 +for row in json.loads(summary.read_text())["rollouts"]: + log = rollouts / row["clipgt_id"] / row["rollout_id"] / "rollout.asl" + if not log.exists(): + continue + if keep_rule(row): + kept += 1 + continue + freed += log.stat().st_size + log.unlink() + removed += 1 +print(f"rollout logs: kept {kept} ({keep}), removed {removed} ({freed / 1e9:.1f} GB)") +EOF +else + echo "no $SUMMARY: keeping every rollout log" +fi +echo "results -> $OUT/results.txt; obs replays -> $OUT/obs_replays; logs -> $OUT/logs; AlpaSim tree (per-scene metrics, aggregate, kept rollout logs) -> $OUT/alpasim" echo "Done -> $OUT" exit $EVAL_STATUS From 805fa565610029b37e1229e22c6304c7c5cd01db Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 15:31:04 +0200 Subject: [PATCH 042/123] visu fixes, and build carla eval hack to set speed limits to 20 km/h if a pedestrian is near --- pufferlib/config/puffer_drive.yaml | 4 + pufferlib/config_schema.py | 6 +- pufferlib/ocean/cosim/carla/README.md | 3 +- pufferlib/ocean/cosim/carla/shadow_ego.py | 27 +++- pufferlib/ocean/cosim/nuplan/README.md | 6 +- .../config/planner/pufferdrive_planner.yaml | 3 + pufferlib/ocean/cosim/nuplan/planner.py | 18 +++ .../ocean/cosim/nuplan/run_nuplan_planner.sh | 4 + pufferlib/ocean/cosim/nuplan_bridge.py | 3 +- pufferlib/ocean/drive/drive.h | 17 +++ pufferlib/ocean/drive/drive.py | 4 + pufferlib/ocean/env_binding.h | 24 +++ pufferlib/pufferl.py | 27 +++- pufferlib/viz.py | 38 ++++- scripts/attribute_training_infractions.py | 137 ++++++++++++++++++ scripts/kesai/11_carla_longest6_v2.sh | 9 +- scripts/kesai/14_carla_longest6_v1.sh | 9 +- scripts/kesai/9_nuPlan.sh | 4 + 18 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 scripts/attribute_training_infractions.py diff --git a/pufferlib/config/puffer_drive.yaml b/pufferlib/config/puffer_drive.yaml index da275e1635..467c85c892 100644 --- a/pufferlib/config/puffer_drive.yaml +++ b/pufferlib/config/puffer_drive.yaml @@ -353,6 +353,10 @@ train: # Rendering options render: false render_interval: 1000 + # Training maps per render pass, a seeded random subset; null renders every map + render_num_maps: null + # Stop after this many epochs in this process (debug/render runs); null runs to total_timesteps + stop_after_epochs: null sweep: method: Protein metric: score diff --git a/pufferlib/config_schema.py b/pufferlib/config_schema.py index ce5df75459..67e1a31222 100644 --- a/pufferlib/config_schema.py +++ b/pufferlib/config_schema.py @@ -416,7 +416,7 @@ class TrainingConfig: tf32: bool = MISSING rollout_dtype: RolloutDtype = MISSING total_timesteps: int = _constrained_field(POSITIVE_INT_CONSTRAINT) - learning_rate: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) + learning_rate: float = _constrained_field(NONNEGATIVE_NUMBER_CONSTRAINT) gamma: float = _constrained_field(PROBABILITY_CONSTRAINT) gae_lambda: float = _constrained_field(PROBABILITY_CONSTRAINT) update_epochs: int = _constrained_field(POSITIVE_INT_CONSTRAINT) @@ -456,6 +456,8 @@ class TrainingConfig: adv_filter_leak_fraction: float = _constrained_field(PROBABILITY_CONSTRAINT) render: bool = MISSING render_interval: int = _constrained_field(POSITIVE_INT_CONSTRAINT) + render_num_maps: int | None = _constrained_field(POSITIVE_INT_CONSTRAINT) + stop_after_epochs: int | None = _constrained_field(POSITIVE_INT_CONSTRAINT) # Derived by load_config from rnn_name and intentionally absent from YAML. use_rnn: bool = MISSING @@ -674,6 +676,8 @@ def _validate_cross_field_constraints(config, context): not isinstance(evaluation_benchmarks, str) or not evaluation_benchmarks.strip() ): _raise_config_error(context, "train.evaluation_benchmarks", "must be a non-empty string") + if train["render_num_maps"] is not None and train["render_num_maps"] > config["env"]["num_maps"]: + _raise_config_error(context, "train.render_num_maps", "must not exceed env.num_maps") if not context.startswith("evaluation") and context != "simulation profiling": for field_name in ("batch_size", "bptt_horizon"): if train[field_name] != "auto": diff --git a/pufferlib/ocean/cosim/carla/README.md b/pufferlib/ocean/cosim/carla/README.md index 6e72985eb8..78ecbf58ad 100644 --- a/pufferlib/ocean/cosim/carla/README.md +++ b/pufferlib/ocean/cosim/carla/README.md @@ -45,7 +45,8 @@ C_vel; default = the checkpoint's `base_max_speed_mps`, must stay inside the tra `COSIM_ZERO_PARTNER_STOPPED_TIME` (1 = partners' `seconds_stopped` obs feature held at 0, ego untouched; ablation for the parked-car / red-light-queue avoidance), `COSIM_VRU_SIZE_PAD_M` (added to the length and width of every walker and two-wheeler box in the policy's obs; the -longest6 scripts set 0.8 m), `COSIM_OBS_HTML`, +longest6 scripts set 0.8 m), `COSIM_PEDESTRIAN_SPEED_CAP_MPS` with `COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M` +(cap on every observed lane limit while a walker is ahead within the radius; off by default), `COSIM_OBS_HTML`, `COSIM_DEBUG_CARLA_VIEW`, `COSIM_RECORD_INFRACTIONS`. ### Ego dynamics source diff --git a/pufferlib/ocean/cosim/carla/shadow_ego.py b/pufferlib/ocean/cosim/carla/shadow_ego.py index ea4ae7f950..1ea82cb604 100644 --- a/pufferlib/ocean/cosim/carla/shadow_ego.py +++ b/pufferlib/ocean/cosim/carla/shadow_ego.py @@ -46,6 +46,12 @@ (nuPlan's vru_size_pad_m; the training spawn floor is 0.8 m, CARLA walkers are 0.4-0.5 m and bicycles 0.4 m wide, so 0.8 lifts them strictly above the smallest training vehicle) + COSIM_PEDESTRIAN_SPEED_CAP_MPS=0.0 + while a walker is ahead of the ego's front bumper within + COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M (default 30, the garage autopilot's + detection radius), every lane speed limit the policy observes is capped at + this value, so it slows down by itself (nuPlan's pedestrian_speed_cap_mps; + the autopilot crawls at 2 m/s in the same situation). 0 = off COSIM_TELEMETRY=/dir write a per-policy-step CSV per route, with the shadow env's and the simulator's infraction flags as columns COSIM_OBS_HTML=/dir write an interactive pufferlib.viz replay per route (the exact obs + @@ -206,6 +212,14 @@ def __init__(self, path_to_conf_file, route_tag): self.vru_size_pad_m = float(os.environ.get("COSIM_VRU_SIZE_PAD_M", "0.0")) if not (self.vru_size_pad_m >= 0.0): raise ValueError(f"COSIM_VRU_SIZE_PAD_M must be >= 0, got {self.vru_size_pad_m}") + self.pedestrian_speed_cap_mps = float(os.environ.get("COSIM_PEDESTRIAN_SPEED_CAP_MPS", "0.0")) + if not (self.pedestrian_speed_cap_mps >= 0.0): + raise ValueError(f"COSIM_PEDESTRIAN_SPEED_CAP_MPS must be >= 0, got {self.pedestrian_speed_cap_mps}") + self.pedestrian_speed_cap_radius_m = float(os.environ.get("COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M", "30.0")) + if not (self.pedestrian_speed_cap_radius_m > 0.0): + raise ValueError( + f"COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M must be > 0, got {self.pedestrian_speed_cap_radius_m}" + ) # Shadow agent pool == the training per-env cap self.num_agents = int(env_cfg["max_agents_per_env"]) @@ -416,7 +430,8 @@ def init_route(self, route): f"[puffer_agent] town={town} tick_dt={self.tick_dt} dt={self.dt} route_goals={len(self.route_goals)} " f"max_speed_mps={self.env.max_speed_mps:.1f} (C_vel={self.env.max_speed_mps / self.env.base_max_speed_mps:.2f}) " f"zero_partner_stopped_time={int(self.zero_partner_stopped_time)} " - f"vru_size_pad_m={self.vru_size_pad_m:g}" + f"vru_size_pad_m={self.vru_size_pad_m:g} pedestrian_speed_cap_mps={self.pedestrian_speed_cap_mps:g} " + f"(radius {self.pedestrian_speed_cap_radius_m:g} m)" ) self.initialized = True @@ -479,6 +494,12 @@ def _read_sizes(self, partners): width.append(max(2.0 * row[PARTNER_EXTENT_Y], MIN_PARTNER_SIZE_M) + vru_pad_m) return (np.array(idx, np.int32), np.array(length, np.float32), np.array(width, np.float32)) + def _walker_ahead(self, x, y, is_walker, ego_x, ego_y, ego_heading): + """A walker within pedestrian_speed_cap_radius_m and ahead of the ego's front bumper (bin frame).""" + dx, dy = x[is_walker] - ego_x, y[is_walker] - ego_y + ahead = dx * math.cos(ego_heading) + dy * math.sin(ego_heading) > self.ego_extent[0] + return bool(np.any(ahead & (np.hypot(dx, dy) <= self.pedestrian_speed_cap_radius_m))) + def _read_light_states(self, light_states, ego_light_id): """Ground truth for every mapped bin traffic element, via the precomputed light_bin_indices_by_id table (see init_route).""" @@ -536,6 +557,7 @@ def _sync(self, snapshot): ebx, eby = float(ego["x"][0]), float(ego["y"][0]) partners = np.asarray(snapshot["partners"], dtype=np.float64).reshape(-1, PARTNER_ROW_COLUMNS) + walker_ahead = False if len(partners): states = self._read_states(partners) sizes = self._read_sizes(partners) @@ -543,8 +565,11 @@ def _sync(self, snapshot): self.env.set_agent_sizes(*sizes) is_walker = (partners[:, PARTNER_IS_WALKER] > 0.0).astype(np.float32) self._last_partners = (states[1], states[2], states[4], states[5], states[6], sizes[1], sizes[2], is_walker) + walker_ahead = self._walker_ahead(states[1], states[2], is_walker > 0.0, ebx, eby, float(ego["heading"][0])) else: self._last_partners = None + if self.pedestrian_speed_cap_mps > 0.0: + self.env.set_lane_speed_limit_cap(self.pedestrian_speed_cap_mps if walker_ahead else 0.0) n_used = 1 + len(partners) if n_used < self.num_agents: sp = np.arange(n_used, self.num_agents, dtype=np.int32) diff --git a/pufferlib/ocean/cosim/nuplan/README.md b/pufferlib/ocean/cosim/nuplan/README.md index 4384784678..fdc201a4a3 100644 --- a/pufferlib/ocean/cosim/nuplan/README.md +++ b/pufferlib/ocean/cosim/nuplan/README.md @@ -35,7 +35,11 @@ Zero changes to nuplan-devkit or CaRL's `carl_nuplan`. Hydra loads any planner v policy's observation (training spawns nothing below 0.8 x 0.8 m, nuPlan pedestrians are 0.4-0.8 m, bicycles ~0.5 m wide), so they read strictly larger than the smallest training vehicle; nuPlan still scores the true boxes. Off by default, script 9 sets - 0.8 m. The shadow env consumes the goals of its + 0.8 m. `pedestrian_speed_cap_mps` (env `PEDESTRIAN_SPEED_CAP_MPS`, radius + `pedestrian_speed_cap_radius_m` / `PEDESTRIAN_SPEED_CAP_RADIUS_M`, 30 m): while a pedestrian + is ahead within the radius, every lane limit the policy observes is capped at this value, so + it slows down like the CARLA garage autopilot does near walkers. Off by default. The shadow + env consumes the goals of its `num_goals` window itself exactly like training (`goal_regen_mode: finite`, consumed slots zeroed in the obs); the planner pushes the next window only when the current one is exhausted or its current goal is clearly behind the diff --git a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml index 5e9dc697ab..9bafcc88cf 100644 --- a/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml +++ b/pufferlib/ocean/cosim/nuplan/config/planner/pufferdrive_planner.yaml @@ -23,6 +23,9 @@ pufferdrive_planner: startup_brake_jerk_cap_mps3: 0.0 # eval hack: grow pedestrian and bicycle partner boxes by this much [m] in length and width in the policy's obs (training min 0.8 x 0.8); 0 = off vru_size_pad_m: 0.0 + # eval hack: cap every observed lane limit [m/s] while a pedestrian is ahead within the radius [m]; 0 = off + pedestrian_speed_cap_mps: 0.0 + pedestrian_speed_cap_radius_m: 30.0 env_overrides: obs_slots_partners_n: 40 # GIGAFLOW eval value (Table A4); slots are max-pooled, so any checkpoint transfers stop_signs_enabled: false # nuPlan city bins export TURN_STOP zones as stop signs; keep them out of the obs diff --git a/pufferlib/ocean/cosim/nuplan/planner.py b/pufferlib/ocean/cosim/nuplan/planner.py index dd23c1a4d3..34266cb2a9 100644 --- a/pufferlib/ocean/cosim/nuplan/planner.py +++ b/pufferlib/ocean/cosim/nuplan/planner.py @@ -94,6 +94,8 @@ def __init__( startup_accel_jerk_cap_mps3: float = 0.0, startup_brake_jerk_cap_mps3: float = 0.0, vru_size_pad_m: float = 0.0, + pedestrian_speed_cap_mps: float = 0.0, + pedestrian_speed_cap_radius_m: float = 30.0, env_overrides: Optional[Dict] = None, obs_html_dir: Optional[str] = None, obs_html_max_steps: int = 800, @@ -129,6 +131,11 @@ def __init__( and width [m] before the policy sees them, so they read strictly larger than the smallest training vehicle (0.8 x 0.8 m; nuPlan pedestrians are 0.4-0.8 m, bicycles ~0.5 m wide); nuPlan keeps scoring the true boxes. 0 = off. + :param pedestrian_speed_cap_mps: eval hack: while a pedestrian is ahead of the ego's front bumper + within pedestrian_speed_cap_radius_m, every lane speed limit the policy observes is capped at + this value [m/s], so it slows down by itself (the CARLA garage autopilot crawls at 2 m/s within + its 30 m detection radius). 0 = off. + :param pedestrian_speed_cap_radius_m: radius [m] of the pedestrian check above. :param env_overrides: Drive env kwargs overriding DEFAULT_ARCH. :param obs_html_dir: write the interactive pufferlib.viz observation replay per scenario (the exact obs the policy received, its outputs and encoder pool winners). @@ -161,6 +168,10 @@ def __init__( self._vru_size_pad_m = float(vru_size_pad_m) if self._vru_size_pad_m < 0: raise ValueError("vru_size_pad_m must be >= 0") + self._pedestrian_speed_cap_mps = float(pedestrian_speed_cap_mps) + self._pedestrian_speed_cap_radius_m = float(pedestrian_speed_cap_radius_m) + if self._pedestrian_speed_cap_mps < 0 or self._pedestrian_speed_cap_radius_m <= 0: + raise ValueError("pedestrian_speed_cap_mps must be >= 0 and pedestrian_speed_cap_radius_m > 0") self._startup_jerk_cap_steps = 0 # resolved in _build from the scenario dt self._startup_action_bounds = None # (lo, hi) on the continuous jerk action while the cap is live self._env_overrides = env_overrides or {} @@ -508,12 +519,19 @@ def _sync(self, ego_state: EgoState, detections: DetectionsTracks, traffic_light ox, oy = ego_state.center.x, ego_state.center.y objs.sort(key=lambda o: (o.center.x - ox) ** 2 + (o.center.y - oy) ** 2) objs = objs[: self._num_agents - 1] + pedestrian_ahead = False if objs: idx, x, y, z, hh, vx, vy, tp, ln, wd = nb.tracked_objects_to_arrays(objs, tf) if self._vru_size_pad_m > 0: ln, wd = nb.pad_vru_partner_sizes(tp, ln, wd, self._vru_size_pad_m) env.set_agent_states(idx, x, y, z, hh, vx, vy, np.zeros_like(vx), np.zeros_like(vx)) env.set_agent_sizes(idx, ln, wd) + pedestrian = tp == nb.PEDESTRIAN_PARTNER_TYPE_ID + dx, dy = x[pedestrian] - ex, y[pedestrian] - ey + ahead = dx * math.cos(h) + dy * math.sin(h) > 0.5 * ego_state.car_footprint.length + pedestrian_ahead = bool(np.any(ahead & (np.hypot(dx, dy) <= self._pedestrian_speed_cap_radius_m))) + if self._pedestrian_speed_cap_mps > 0: + env.set_lane_speed_limit_cap(self._pedestrian_speed_cap_mps if pedestrian_ahead else 0.0) surplus = np.arange(1 + len(objs), self._num_agents, dtype=np.int32) if len(surplus): far = np.full(len(surplus), FAR_AWAY, np.float32) diff --git a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh index 6656e6bce2..837d3799d2 100755 --- a/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh +++ b/pufferlib/ocean/cosim/nuplan/run_nuplan_planner.sh @@ -40,6 +40,8 @@ # VRU_SIZE_PAD_M [m] add this to pedestrian and bicycle partner boxes' length and width in the policy's # observation (training never spawns below 0.8 m; 0.8 = strictly above the smallest training # vehicle); unset/0 = off. +# PEDESTRIAN_SPEED_CAP_MPS [m/s] cap every observed lane limit while a pedestrian is ahead within +# PEDESTRIAN_SPEED_CAP_RADIUS_M (default 30 m); unset/0 = off. # OBS_SLOTS_PARTNERS_N shadow-env partner obs slots (unset = planner yaml, 40). # EVAL_PERCEIVED_SIZE_MARGIN_M [m per side] ego box inflation in the policy's obs (unset = arch.py, 0.2). # REWARD_OVERSPEED overspeed conditioning alpha fed to the policy (unset = arch.py, 0.1). @@ -88,6 +90,8 @@ EXTRA_ARGS+=("planner.pufferdrive_planner.city_bin_dir=$CITY_BIN_DIR") [ -n "${STARTUP_ACCEL_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_accel_jerk_cap_mps3=$STARTUP_ACCEL_JERK_CAP") [ -n "${STARTUP_BRAKE_JERK_CAP:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.startup_brake_jerk_cap_mps3=$STARTUP_BRAKE_JERK_CAP") [ -n "${VRU_SIZE_PAD_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.vru_size_pad_m=$VRU_SIZE_PAD_M") +[ -n "${PEDESTRIAN_SPEED_CAP_MPS:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_speed_cap_mps=$PEDESTRIAN_SPEED_CAP_MPS") +[ -n "${PEDESTRIAN_SPEED_CAP_RADIUS_M:-}" ] && EXTRA_ARGS+=("planner.pufferdrive_planner.pedestrian_speed_cap_radius_m=$PEDESTRIAN_SPEED_CAP_RADIUS_M") [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.obs_slots_partners_n=$OBS_SLOTS_PARTNERS_N") [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.eval_perceived_size_margin_m=$EVAL_PERCEIVED_SIZE_MARGIN_M") [ -n "${REWARD_OVERSPEED:-}" ] && EXTRA_ARGS+=("++planner.pufferdrive_planner.env_overrides.reward_overspeed=$REWARD_OVERSPEED") diff --git a/pufferlib/ocean/cosim/nuplan_bridge.py b/pufferlib/ocean/cosim/nuplan_bridge.py index b07288adb9..2c4b5246a8 100644 --- a/pufferlib/ocean/cosim/nuplan_bridge.py +++ b/pufferlib/ocean/cosim/nuplan_bridge.py @@ -608,7 +608,8 @@ def partner_tracked_objects(tracked_objects, map_api, static_on_lane: dict): return kept -VRU_PARTNER_TYPE_IDS = (_NUPLAN_AGENT_TYPE["PEDESTRIAN"], _NUPLAN_AGENT_TYPE["BICYCLE"]) +PEDESTRIAN_PARTNER_TYPE_ID = _NUPLAN_AGENT_TYPE["PEDESTRIAN"] +VRU_PARTNER_TYPE_IDS = (PEDESTRIAN_PARTNER_TYPE_ID, _NUPLAN_AGENT_TYPE["BICYCLE"]) def pad_vru_partner_sizes(types, lengths, widths, pad_m: float): diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index d859f85af0..3b5a480061 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -219,6 +219,7 @@ struct Drive { float speed_limit_random_min_mps; float speed_limit_random_max_mps; float *lane_speed_limit_mps; // per-episode effective limit per road element (zone-randomized) + float *lane_speed_limit_base_mps; // the same before any co-sim cap (c_set_lane_speed_limit_cap) float *speed_zone_offset_mps; // num_speed_zones unsigned char *lane_limit_resolved; // scratch for junction-lane inheritance int dynamics_model; @@ -3654,11 +3655,13 @@ void init(Drive *env) { env->map_name); } env->lane_speed_limit_mps = (float *) malloc(env->num_road_elements * sizeof(float)); + env->lane_speed_limit_base_mps = (float *) malloc(env->num_road_elements * sizeof(float)); env->lane_limit_resolved = (unsigned char *) calloc(env->num_road_elements, sizeof(unsigned char)); env->speed_zone_offset_mps = (float *) malloc((env->num_speed_zones > 0 ? env->num_speed_zones : 1) * sizeof(float)); begin_episode_rng(env); sample_zone_speed_limits(env); + memcpy(env->lane_speed_limit_base_mps, env->lane_speed_limit_mps, env->num_road_elements * sizeof(float)); if (env->simulation_mode == SIMULATION_MODE_GIGAFLOW) { int steps = env->scenario_length; if (steps > 0) { @@ -3713,6 +3716,7 @@ void c_close(Drive *env) { free(env->active_agent_indices); free(env->logs); free(env->lane_speed_limit_mps); + free(env->lane_speed_limit_base_mps); free(env->speed_zone_offset_mps); free(env->lane_limit_resolved); free(env->obs_neighbor_scratch); @@ -4121,6 +4125,18 @@ int c_set_agent_sizes(Drive *env, int count, const int *idx, const float *length return 0; } +// Co-sim: cap every lane's effective speed limit at cap_mps for the coming ticks; 0 restores the episode limits +int c_set_lane_speed_limit_cap(Drive *env, float cap_mps) { + if (!(cap_mps >= 0.0f) || !isfinite(cap_mps)) { + return -1; + } + for (int i = 0; i < env->num_road_elements; i++) { + float base_mps = env->lane_speed_limit_base_mps[i]; + env->lane_speed_limit_mps[i] = (cap_mps > 0.0f && base_mps > cap_mps) ? cap_mps : base_mps; + } + return 0; +} + int c_set_traffic_light_states(Drive *env, const int *states) { int ts = env->timestep; for (int i = 0; i < env->num_traffic_elements; i++) { @@ -5484,6 +5500,7 @@ void c_reset(Drive *env) { begin_episode_rng(env); sample_zone_speed_limits(env); + memcpy(env->lane_speed_limit_base_mps, env->lane_speed_limit_mps, env->num_road_elements * sizeof(float)); if (env->simulation_mode == SIMULATION_MODE_GIGAFLOW) { generate_traffic_light_states(env); // stale poses must neither block nor escape the spawn overlap check diff --git a/pufferlib/ocean/drive/drive.py b/pufferlib/ocean/drive/drive.py index 366f39de88..039b861682 100644 --- a/pufferlib/ocean/drive/drive.py +++ b/pufferlib/ocean/drive/drive.py @@ -879,6 +879,10 @@ def set_agent_sizes(self, idx, length, width): np.ascontiguousarray(width, dtype=np.float32), ) + def set_lane_speed_limit_cap(self, cap_mps): + """co-sim: cap every lane's observed speed limit at cap_mps [m/s] from now on; 0 restores the map limits.""" + binding.vec_set_lane_speed_limit_cap(self.c_envs, float(cap_mps)) + def recompute_observations(self): """recompute observations from current state without stepping dynamics or advancing the timestep (call after set_agent_states).""" diff --git a/pufferlib/ocean/env_binding.h b/pufferlib/ocean/env_binding.h index 98bc012670..4f6be5168b 100644 --- a/pufferlib/ocean/env_binding.h +++ b/pufferlib/ocean/env_binding.h @@ -1111,6 +1111,26 @@ static PyObject *vec_recompute_observations(PyObject *self, PyObject *args) { Py_RETURN_NONE; } +static PyObject *vec_set_lane_speed_limit_cap(PyObject *self, PyObject *args) { + if (PyTuple_Size(args) != 2) { + PyErr_SetString(PyExc_TypeError, "vec_set_lane_speed_limit_cap requires 2 arguments"); + return NULL; + } + VecEnv *vec = unpack_vecenv(args); + if (!vec) { + return NULL; + } + double cap_mps = PyFloat_AsDouble(PyTuple_GetItem(args, 1)); + if (cap_mps == -1.0 && PyErr_Occurred()) { + return NULL; + } + if (c_set_lane_speed_limit_cap((Drive *) vec->envs[0], (float) cap_mps) != 0) { + PyErr_SetString(PyExc_ValueError, "vec_set_lane_speed_limit_cap: cap must be finite and >= 0"); + return NULL; + } + Py_RETURN_NONE; +} + static PyObject *vec_set_traffic_light_states(PyObject *self, PyObject *args) { if (PyTuple_Size(args) != 2) { PyErr_SetString(PyExc_TypeError, "vec_set_traffic_light_states requires 2 arguments"); @@ -1550,6 +1570,10 @@ static PyMethodDef methods[] vec_set_traffic_light_states, METH_VARARGS, "Override traffic light states (co-sim)"}, + {"vec_set_lane_speed_limit_cap", + vec_set_lane_speed_limit_cap, + METH_VARARGS, + "Cap every lane's observed speed limit, 0 restores the map limits (co-sim)"}, {"vec_set_stop_signs", vec_set_stop_signs, METH_VARARGS, diff --git a/pufferlib/pufferl.py b/pufferlib/pufferl.py index d2142fff12..70afeee312 100644 --- a/pufferlib/pufferl.py +++ b/pufferlib/pufferl.py @@ -1626,6 +1626,10 @@ def train(env_name, args=None, vecenv=None, policy=None, logger=None, early_stop # numpy scalars of the saved RNG state, not just tensors. tstate = torch.load(trainer_state_path, map_location=train_config["device"], weights_only=False) pufferl.optimizer.load_state_dict(tstate["optimizer_state_dict"]) + # The saved groups carry the old run's annealed lr; train.learning_rate is this run's base. + for param_group in pufferl.optimizer.param_groups: + param_group["lr"] = train_config["learning_rate"] + param_group["initial_lr"] = train_config["learning_rate"] pufferl.global_step = tstate.get("global_step", pufferl.global_step) pufferl.epoch = tstate.get("update", pufferl.epoch) # Fast-forward the LR scheduler to the resumed epoch so the cosine @@ -1654,8 +1658,10 @@ def train(env_name, args=None, vecenv=None, policy=None, logger=None, early_stop logging_threshold = min(0.20 * train_config["total_timesteps"], 100_000_000) all_logs = [] last_training_evaluation_epoch = None + stop_after_epochs = args["train"]["stop_after_epochs"] + stop_epoch = float("inf") if stop_after_epochs is None else pufferl.epoch + stop_after_epochs - while pufferl.global_step < train_config["total_timesteps"]: + while pufferl.global_step < train_config["total_timesteps"] and pufferl.epoch < stop_epoch: if is_cuda_device(train_config["device"]): torch.compiler.cudagraph_mark_step_begin() try: @@ -2497,10 +2503,9 @@ def _run_eval_rollout( def render_training_replays(env_name, args, policy, epoch, global_step, run_dir): - """Render one HTML replay per training map with the live policy, observations included.""" + """Render one HTML replay per selected training map with the live policy, observations included.""" run_args = copy.deepcopy(args) env_config = run_args["env"] - num_scenarios = env_config["num_maps"] # Fixed-length episodes: eval-mode summaries flush on the resample boundary. env_config["termination_mode"] = False env_config["compute_eval_metrics"] = True @@ -2511,9 +2516,19 @@ def render_training_replays(env_name, args, policy, epoch, global_step, run_dir) capture_observations = run_args["eval"]["capture_observations"] # Observation replays render at multi-GB RSS each; the obs writer count bounds that too. max_renderer_count = run_args["eval"]["observation_replay_writer_count"] if capture_observations else None + # Same maps and episode seeds at every render of a run, so renders across epochs compare like for like. + render_seed = run_args["train"]["seed"] if run_args["train"]["seed"] is not None else 0 + render_rng = np.random.default_rng(render_seed) + render_num_maps = run_args["train"]["render_num_maps"] + if render_num_maps is None: + map_indices = list(range(env_config["num_maps"])) + else: + map_indices = sorted(render_rng.choice(env_config["num_maps"], size=render_num_maps, replace=False).tolist()) + map_seed_pairs = [(map_idx, int(render_rng.integers(0, 2**63, dtype=np.int64))) for map_idx in map_indices] + num_scenarios = len(map_seed_pairs) num_workers = min(run_args["vec"]["num_envs"], num_scenarios) - worker_env_kwargs, total_steps = drive_benchmark._plan_benchmark_eval_workers( - run_args, num_scenarios, num_workers, env_config["scenario_length"], capture_replay=True + worker_env_kwargs, total_steps = drive_benchmark._plan_failure_replay_workers( + run_args, map_seed_pairs, num_workers, env_config["scenario_length"] ) output_dir = os.path.join(run_dir, "renders", f"epoch_{epoch:06d}_step_{global_step}") @@ -2528,7 +2543,7 @@ def render_training_replays(env_name, args, policy, epoch, global_step, run_dir) "Rendering training replays", num_scenarios, policy=policy, - replay_output_dir=os.path.join(output_dir, "replays"), + replay_output_dir=os.path.join(output_dir, drive_eval_replay.ZLIB_REPLAY_DIR_NAME), capture_observations=capture_observations, ) drive_eval_replay._render_eval_replays( diff --git a/pufferlib/viz.py b/pufferlib/viz.py index c2dd7c34d4..31bebe06ed 100644 --- a/pufferlib/viz.py +++ b/pufferlib/viz.py @@ -1102,6 +1102,7 @@ def _render_interactive_replay_payload(compressed_payload, filename): .perturbation-line.blindness { border-color:#6d28d9; border-top-style:dashed; } .perturbation-line.phantom-braking { border-color:#b45309; } .perturbation-line.human-log { border-color:#ff0000; } + .perturbation-line.collision-onset { border-color:currentColor; border-top-style:dashed; } /* Agent panel: dark instrument-cluster surface in both themes — scoped variable overrides restyle all children. */ #hud-telemetry { --border:#4a5468; --muted:#aab3c5; --field:rgba(255,255,255,.07); --accent:#7cbcff; position:absolute; top:14px; right:14px; width:372px; max-height:calc(100vh - 90px); padding:12px 14px; overflow-y:auto; display:none; background:rgba(54,62,77,.95); color:#eef1f6; } [data-theme="dark"] #hud-telemetry { background:rgba(48,56,70,.95); } @@ -1152,6 +1153,7 @@ def _render_interactive_replay_payload(compressed_payload, filename):
Active perturbations
Partner blindness
Phantom braking
+
Collision: sharp box = sim footprint, dashed = pose one step before the onset
@@ -1424,6 +1426,33 @@ def _render_interactive_replay_payload(compressed_payload, filename): ctx.stroke(); ctx.restore(); } + function infractionOnset(frame, idx, metricIdx) { + // First frame of the flag's current run ending at `frame`; -1 when the flag is clear. + const stride = H.agent_cap * F.mf; + let base = (frame * H.agent_cap + idx) * F.mf + metricIdx; + if (!(C.metrics_f32[base] > 0)) return -1; + let onset = frame; + while (onset > 0 && C.metrics_f32[base - stride] > 0) { onset--; base -= stride; } + return onset; + } + function drawCollisionFootprint(a, f, outline) { + // Sprites round their corners; the simulator collides on the sharp box drawn here. + const onset = infractionOnset(f, a.idx, 0); + if (onset < 0) return; + ctx.save(); + ctx.strokeStyle = outline; ctx.lineWidth = 1.5 / cam.z; + ctx.strokeRect(-a.l/2, -a.w/2, a.l, a.w); + ctx.restore(); + if (onset !== f || f === 0) return; + const prev = agentAt(f - 1, a.idx); + if (!prev) return; + // The swept test can flag a contact made between frames: show where the car was one step earlier. + ctx.save(); + ctx.rotate(-a.h); ctx.translate(prev.x - a.x, prev.y - a.y); ctx.rotate(prev.h); + ctx.strokeStyle = outline; ctx.lineWidth = 1.5 / cam.z; ctx.setLineDash([4 / cam.z, 3 / cam.z]); + ctx.strokeRect(-prev.l/2, -prev.w/2, prev.l, prev.w); + ctx.restore(); + } function drawPerturbationOutlines(a) { if (a.phantomBrakingActive) { drawPerturbationOutline(a, PHANTOM_BRAKING_OUTLINE_COLOR, [], 3); @@ -1768,7 +1797,12 @@ def _render_interactive_replay_payload(compressed_payload, filename): for (let i=0;i { + const onset = infractionOnset(f, agent.idx, metricIdx); + if (onset < 0) return; + warnings.push(onset < f ? `${name} since
step ${onset}` : name); + }); const warnKey = warnings.join('|'), warnRow = document.getElementById('warn-row'); if (warnKey !== lastWarnKey) { lastWarnKey = warnKey; warnRow.style.display = warnings.length ? 'flex' : 'none'; warnRow.innerHTML = warnings.map(w=>`${w}`).join(''); } const obs = decodeObs(f, agent.slot); if (obs) { obsBox.style.display='block'; drawObs(obs, {l:agent.l*H.scales.meters_to_position, w:agent.w*H.scales.meters_to_position}); } else obsBox.style.display='none'; @@ -1784,7 +1818,7 @@ def _render_interactive_replay_payload(compressed_payload, filename): ctx.lineCap='round'; ctx.strokeStyle=colors.road; ctx.lineWidth=.5; ctx.stroke(paths[0]); ctx.strokeStyle=colors.line; ctx.setLineDash([1,1]); ctx.stroke(paths[1]); ctx.setLineDash([]); ctx.strokeStyle=colors.edge; ctx.lineWidth=.8; ctx.stroke(paths[2]); drawCurrentLane(target, colors); drawGhosts(f); - for(const a of getFrameAgents(f)){ ctx.save(); ctx.translate(a.x,a.y); ctx.rotate(a.h); drawAgentBody(a, darkMode?'#fff':'#111'); drawPerturbationOutlines(a); ctx.restore(); ctx.save(); ctx.translate(a.x,a.y); if(isEgoCam && target) ctx.rotate(-Math.PI/2 + target.h); else ctx.scale(1,-1); ctx.fillStyle=colors.text; ctx.font='600 '+(14/cam.z)+'px system-ui'; ctx.textAlign='center'; ctx.fillText(a.id,0,(isEgoCam && target)?a.w/2+.5:-a.w/2-.5); ctx.restore(); if(a.id === followedId){ ctx.save(); ctx.translate(a.x,a.y); ctx.strokeStyle=colors.accent; ctx.lineWidth=3/cam.z; ctx.beginPath(); ctx.arc(0,0,Math.max(a.l,a.w)*1.2,0,7); ctx.stroke(); ctx.restore(); } } + for(const a of getFrameAgents(f)){ ctx.save(); ctx.translate(a.x,a.y); ctx.rotate(a.h); drawAgentBody(a, darkMode?'#fff':'#111'); drawPerturbationOutlines(a); drawCollisionFootprint(a, f, darkMode?'#fff':'#111'); ctx.restore(); ctx.save(); ctx.translate(a.x,a.y); if(isEgoCam && target) ctx.rotate(-Math.PI/2 + target.h); else ctx.scale(1,-1); ctx.fillStyle=colors.text; ctx.font='600 '+(14/cam.z)+'px system-ui'; ctx.textAlign='center'; ctx.fillText(a.id,0,(isEgoCam && target)?a.w/2+.5:-a.w/2-.5); ctx.restore(); if(a.id === followedId){ ctx.save(); ctx.translate(a.x,a.y); ctx.strokeStyle=colors.accent; ctx.lineWidth=3/cam.z; ctx.beginPath(); ctx.arc(0,0,Math.max(a.l,a.w)*1.2,0,7); ctx.stroke(); ctx.restore(); } } for(let i=0;i/renders//replays [dt] +(needs eval.keep_zlib_replays=true on the render) +""" +import glob +import os +import sys +from collections import Counter + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from pufferlib.viz import read_replay_zlib # noqa: E402 + +COLLISION_IDX, OFFROAD_IDX, RED_LIGHT_IDX, STOP_SIGN_IDX, LANE_DIST_IDX = 0, 1, 2, 3, 5 +I_TYPE, I_ACTIVE, I_STOPPED, I_REMOVED, I_SLOT, I_BLIND, I_PHANTOM = 1, 3, 4, 5, 7, 8, 9 +F_X, F_Y, F_HEADING, F_LENGTH, F_WIDTH, F_SPEED = 0, 1, 3, 4, 5, 6 +PERTURBATION_LOOKBACK_FRAMES = 10 # 3 s bouts at dt 0.3 +SPAWN_FRAMES = 10 + + +def first_frame(flags): + hits = np.flatnonzero(flags) + return int(hits[0]) if hits.size else None + + +def classify_collision(f32, i32, m, t, a): + pos = f32[t][:, [F_X, F_Y]] + valid = i32[t, :, 2] == 1 + valid[a] = False + dist = np.linalg.norm(pos - pos[a], axis=1) + dist[~valid] = np.inf + # Prefer the agent whose collision flag fires in the same frame; fall back to the nearest body. + co_flagged = valid & (m[t, :, COLLISION_IDX] == 1) + if co_flagged.any(): + dist_co = np.where(co_flagged, dist, np.inf) + p = int(np.argmin(dist_co)) + else: + p = int(np.argmin(dist)) + gap = dist[p] - 0.5 * (f32[t, a, F_LENGTH] + f32[t, p, F_LENGTH]) + if not np.isfinite(dist[p]) or gap > 2.0: + return "no partner found", None + t0 = max(0, t - PERTURBATION_LOOKBACK_FRAMES) + tp = t - 1 if t > 0 else t + tags = [] + if i32[t0 : t + 1, p, I_BLIND].any(): + tags.append("partner blind now") + if i32[t0 : t + 1, p, I_PHANTOM].any(): + tags.append("partner phantom-braking now") + if not tags and (i32[:, p, I_BLIND].any() or i32[:, p, I_PHANTOM].any()): + tags.append("partner is a designated perturber (bout elsewhere)") + if t > 0 and i32[t - 1, p, I_STOPPED] == 1: + tags.append("partner parked by earlier infraction") + elif f32[tp, p, F_SPEED] < 0.3: + tags.append("partner stationary") + if i32[t, p, I_ACTIVE] == 0: + tags.append("partner not policy-controlled") + if not tags: + if m[t, p, COLLISION_IDX] == 1 and first_frame(m[:, p, COLLISION_IDX]) == t: + tags.append("both moving, mutual first collision") + else: + tags.append("both moving, other") + dh = abs((f32[t, a, F_HEADING] - f32[t, p, F_HEADING] + np.pi) % (2 * np.pi) - np.pi) + rel = pos[p] - pos[a] + ahead = np.cos(f32[t, a, F_HEADING]) * rel[0] + np.sin(f32[t, a, F_HEADING]) * rel[1] > 0 + if dh < np.radians(30): + geometry = "rear-end, ego behind" if ahead else "rear-end, ego in front" + elif dh > np.radians(150): + geometry = "head-on" + else: + geometry = "crossing/side" + detail = f"ego {f32[tp, a, F_SPEED]:.1f} m/s, partner {p} {f32[tp, p, F_SPEED]:.1f} m/s, {geometry}" + return " + ".join(tags), detail + + +def analyze(path, dt): + _, c = read_replay_zlib(path, chunk_names=["agent_f32", "agent_i32", "metrics_f32"]) + f32, i32, m = c["agent_f32"], c["agent_i32"], c["metrics_f32"] + frames, agents = m.shape[:2] + rows = [] + logged = 0 + for a in range(agents): + if i32[0, a, I_ACTIVE] != 1: + continue + perturbed = bool(i32[:, a, I_BLIND].any() or i32[:, a, I_PHANTOM].any()) + if not perturbed: + logged += 1 + events = { + "collision": first_frame(m[:, a, COLLISION_IDX]), + "offroad": first_frame(m[:, a, OFFROAD_IDX]), + "red_light": first_frame(m[:, a, RED_LIGHT_IDX]), + "stop_sign": first_frame(m[:, a, STOP_SIGN_IDX]), + } + events = {k: v for k, v in events.items() if v is not None} + if not events: + continue + kind = min(events, key=events.get) + t = events[kind] + tp = max(t - 1, 0) + speed = f32[tp, a, F_SPEED] + if kind == "collision": + tag, detail = classify_collision(f32, i32, m, t, a) + elif kind == "offroad": + tag = "offroad within spawn frames" if t < SPAWN_FRAMES else ("offroad at creep speed (<1 m/s)" if speed < 1.0 else ("offroad slow (1-4 m/s)" if speed < 4.0 else "offroad fast (>4 m/s)")) + detail = f"{speed:.1f} m/s, lane dist {m[tp, a, LANE_DIST_IDX]:.1f} m" + else: + tag = kind + detail = f"{speed:.1f} m/s" + rows.append((kind, tag, detail, t * dt, a, perturbed)) + return frames, agents, logged, rows + + +def main(): + replays_dir = sys.argv[1] + dt = float(sys.argv[2]) if len(sys.argv) > 2 else 0.3 + paths = sorted(glob.glob(os.path.join(replays_dir, "*.replay.zlib"))) + totals = Counter() + logged_total = 0 + for path in paths: + frames, agents, logged, rows = analyze(path, dt) + logged_total += logged + print(f"\n== {os.path.basename(path)}: {frames} frames, {agents} agents ({logged} logged policy agents)") + for kind, tag, detail, t_s, a, perturbed in sorted(rows, key=lambda r: r[3]): + flag = " [ego perturbed, not logged]" if perturbed else "" + print(f" t={t_s:6.1f}s agent {a:3d} {kind:9s} {tag}: {detail}{flag}") + if not perturbed: + totals[(kind, tag)] += 1 + n_inf = sum(totals.values()) + print(f"\n== totals over {len(paths)} episodes, {logged_total} logged agent-episodes, {n_inf} first infractions" + f" ({n_inf / max(logged_total, 1):.3f} per agent-episode)") + for (kind, tag), n in sorted(totals.items(), key=lambda kv: -kv[1]): + print(f" {n:3d} {kind:9s} {tag}") + + +if __name__ == "__main__": + main() diff --git a/scripts/kesai/11_carla_longest6_v2.sh b/scripts/kesai/11_carla_longest6_v2.sh index 2111ab40f9..79ecf72fef 100644 --- a/scripts/kesai/11_carla_longest6_v2.sh +++ b/scripts/kesai/11_carla_longest6_v2.sh @@ -28,7 +28,9 @@ # MAX_ATTEMPTS, CARLA_ROOT, CARL_WORK_DIR, PY, PD, COSIM_MAX_SPEED_MPS (ego speed cap, default 30), # COSIM_ZERO_PARTNER_STOPPED_TIME (default 1: partners' stopped-time obs held at 0; 0 = real stopped times), # COSIM_VRU_SIZE_PAD_M (default 0.8: added to the length and width of every walker and two-wheeler box, so they -# read strictly larger than the smallest training vehicle; 0 = true CARLA boxes). +# read strictly larger than the smallest training vehicle; 0 = true CARLA boxes), COSIM_PEDESTRIAN_SPEED_CAP_MPS +# (default 5.56 = 20 km/h: while a walker is ahead within COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M (default 30), every +# lane limit the policy observes is capped there, the garage autopilot's slow-near-walkers rule; 0 = off). set -u export PD=${PD:-/home/bjaeger/PufferDrive} @@ -77,17 +79,20 @@ export COSIM_DYNAMICS_SOURCE=pufferdrive export COSIM_MAX_SPEED_MPS=${COSIM_MAX_SPEED_MPS:-30} export COSIM_ZERO_PARTNER_STOPPED_TIME=${COSIM_ZERO_PARTNER_STOPPED_TIME:-1} export COSIM_VRU_SIZE_PAD_M=${COSIM_VRU_SIZE_PAD_M:-0.8} +export COSIM_PEDESTRIAN_SPEED_CAP_MPS=${COSIM_PEDESTRIAN_SPEED_CAP_MPS:-5.56} +export COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M=${COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M:-30} export COSIM_OBS_HTML_MAX_STEPS=${COSIM_OBS_HTML_MAX_STEPS:-20000} export COSIM_OBS_HTML_RENDER=0 # routes save the compact replay only; render_carla_obs_html.py renders all pages into $OUT/obs_html TAG=longest6_v2$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") TAG=$TAG$([ "$COSIM_VRU_SIZE_PAD_M" = "0" ] || echo "_vrupad$COSIM_VRU_SIZE_PAD_M") +TAG=$TAG$([ "$COSIM_PEDESTRIAN_SPEED_CAP_MPS" = "0" ] || echo "_pedcap${COSIM_PEDESTRIAN_SPEED_CAP_MPS}mps${COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M}m") # results live in the model's own eval folder, next to the PufferDrive benchmark evals OUT=$RUN_DIR/eval/carla_${TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT/routes" "$OUT/aggregate" { echo "checkpoint $CKPT"; echo "routes $ROUTES"; echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS" - echo "vru_size_pad_m $COSIM_VRU_SIZE_PAD_M" + echo "vru_size_pad_m $COSIM_VRU_SIZE_PAD_M pedestrian_speed_cap_mps $COSIM_PEDESTRIAN_SPEED_CAP_MPS radius_m $COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M" echo "git $(git -C "$PD" rev-parse --short HEAD 2>/dev/null) $(git -C "$PD" diff --quiet 2>/dev/null || echo dirty)" } > "$OUT/run_info.txt" echo "Results -> $OUT" diff --git a/scripts/kesai/14_carla_longest6_v1.sh b/scripts/kesai/14_carla_longest6_v1.sh index 715d814134..d17b270633 100644 --- a/scripts/kesai/14_carla_longest6_v1.sh +++ b/scripts/kesai/14_carla_longest6_v1.sh @@ -29,7 +29,9 @@ # (0 = skip the HTML report), MAX_ATTEMPTS, CARLA_ROOT, GARAGE_WORK_DIR, GARAGE_PY, SERVER_PY, PD, # COSIM_MAX_SPEED_MPS (ego speed cap, default 30), COSIM_ZERO_PARTNER_STOPPED_TIME (default 1), # COSIM_VRU_SIZE_PAD_M (default 0.8: added to the length and width of every walker and two-wheeler box, so they read -# strictly larger than the smallest training vehicle; 0 = true boxes), LIBTIFF5_COMPAT (1: when `import carla` fails for +# strictly larger than the smallest training vehicle; 0 = true boxes), COSIM_PEDESTRIAN_SPEED_CAP_MPS (default 5.56 = +# 20 km/h: while a walker is ahead within COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M (default 30), every lane limit the policy +# observes is capped there, the garage autopilot's slow-near-walkers rule; 0 = off), LIBTIFF5_COMPAT (1: when `import carla` fails for # the egg's missing libtiff.so.5, try the garage env's lib dir (`conda install -n garage "libtiff<4.5"`), then link the # system libtiff.so.6 under that name; 0: never link). set -u @@ -60,6 +62,8 @@ CARLA_VIEW=${CARLA_VIEW:-0} OBS_HTML=${OBS_HTML:-1} REPORT=${REPORT:-$LOGGING} export COSIM_VRU_SIZE_PAD_M=${COSIM_VRU_SIZE_PAD_M:-0.8} +export COSIM_PEDESTRIAN_SPEED_CAP_MPS=${COSIM_PEDESTRIAN_SPEED_CAP_MPS:-5.56} +export COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M=${COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M:-30} EVALUATOR=$GARAGE_WORK_DIR/leaderboard/leaderboard/leaderboard_evaluator_local.py CARLA_EGG=$CARLA_ROOT/PythonAPI/carla/dist/carla-0.9.10-py3.7-linux-x86_64.egg AGENT=$PD/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -78,12 +82,13 @@ if [ "$NUM_GPUS" -lt 1 ] || [ "$NUM_GPUS" -gt 8 ]; then echo "NUM_GPUS=$NUM_GPUS TAG=longest6_v1$([ "$SCENARIOS" = "1" ] || echo "_noscen")$([ "$REPETITIONS" = "1" ] || echo "_rep$REPETITIONS") TAG=$TAG$([ "$COSIM_VRU_SIZE_PAD_M" = "0" ] || echo "_vrupad$COSIM_VRU_SIZE_PAD_M") +TAG=$TAG$([ "$COSIM_PEDESTRIAN_SPEED_CAP_MPS" = "0" ] || echo "_pedcap${COSIM_PEDESTRIAN_SPEED_CAP_MPS}mps${COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M}m") # results live in the model's own eval folder, next to the PufferDrive benchmark evals OUT=$RUN_DIR/eval/carla_${TAG}_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT/routes" "$OUT/aggregate" { echo "checkpoint $CKPT"; echo "routes $ROUTE_DIR ($ROUTES_XML)"; echo "scenarios $SCENARIO_FILE" - echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS"; echo "vru_size_pad_m $COSIM_VRU_SIZE_PAD_M" + echo "repetitions $REPETITIONS"; echo "gpus $NUM_GPUS"; echo "vru_size_pad_m $COSIM_VRU_SIZE_PAD_M pedestrian_speed_cap_mps $COSIM_PEDESTRIAN_SPEED_CAP_MPS radius_m $COSIM_PEDESTRIAN_SPEED_CAP_RADIUS_M" echo "git $(git -C "$PD" rev-parse --short HEAD 2>/dev/null) $(git -C "$PD" diff --quiet 2>/dev/null || echo dirty)" } > "$OUT/run_info.txt" echo "Results -> $OUT" diff --git a/scripts/kesai/9_nuPlan.sh b/scripts/kesai/9_nuPlan.sh index 0163e15c15..76fc928e6e 100644 --- a/scripts/kesai/9_nuPlan.sh +++ b/scripts/kesai/9_nuPlan.sh @@ -51,6 +51,9 @@ export STARTUP_ACCEL_JERK_CAP=2 export STARTUP_BRAKE_JERK_CAP=2 # pedestrian and bicycle partner boxes grow by this much in length and width in the policy's obs (0 = off): 0.8 puts them strictly above the smallest training vehicle export VRU_SIZE_PAD_M=0.8 +# cap every observed lane limit [m/s] while a pedestrian is ahead within PEDESTRIAN_SPEED_CAP_RADIUS_M (0 = off; 5.56 = 20 km/h) +export PEDESTRIAN_SPEED_CAP_MPS=0 +export PEDESTRIAN_SPEED_CAP_RADIUS_M=30 # Shadow-env obs overrides: partner slots (training 20, GIGAFLOW eval 40; 40 real partners max-pooled is OOD in # sidewalk crowds) and the ego perceived-size margin [m per side] (0.2 = native eval; nuPlan's 2.3 x 5.2 m ego # reads as 2.7 x 5.6 m). Unset = planner yaml / arch.py defaults (40 / 0.2). @@ -63,6 +66,7 @@ ABLATION_TAG="" [ "$STARTUP_ACCEL_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jacc${STARTUP_ACCEL_JERK_CAP}" [ "$STARTUP_BRAKE_JERK_CAP" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_jbrk${STARTUP_BRAKE_JERK_CAP}" [ "$VRU_SIZE_PAD_M" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_vrupad${VRU_SIZE_PAD_M}m" +[ "$PEDESTRIAN_SPEED_CAP_MPS" != "0" ] && ABLATION_TAG="${ABLATION_TAG}_pedcap${PEDESTRIAN_SPEED_CAP_MPS}mps${PEDESTRIAN_SPEED_CAP_RADIUS_M}m" [ -n "${OBS_SLOTS_PARTNERS_N:-}" ] && ABLATION_TAG="${ABLATION_TAG}_slots${OBS_SLOTS_PARTNERS_N}" [ -n "${EVAL_PERCEIVED_SIZE_MARGIN_M:-}" ] && ABLATION_TAG="${ABLATION_TAG}_margin${EVAL_PERCEIVED_SIZE_MARGIN_M}" [ -n "${REWARD_OVERSPEED:-}" ] && ABLATION_TAG="${ABLATION_TAG}_overspeed${REWARD_OVERSPEED}" From 96c200df505c91950e310028e93cd0c1f159b8a4 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 17:59:12 +0200 Subject: [PATCH 043/123] update map to fix shoulders in intersections. add blind agents corridor with margin --- pufferlib/ocean/drive/constants.h | 2 ++ pufferlib/ocean/drive/datatypes.h | 2 +- pufferlib/ocean/drive/drive.h | 5 +++-- .../binaries/carla/opendrive__Town01.bin | Bin 165736 -> 165736 bytes .../binaries/carla/opendrive__Town02.bin | Bin 95732 -> 95732 bytes .../binaries/carla/opendrive__Town03.bin | Bin 983280 -> 981432 bytes .../binaries/carla/opendrive__Town04.bin | Bin 2242228 -> 2237956 bytes .../binaries/carla/opendrive__Town05.bin | Bin 1607116 -> 1607508 bytes .../binaries/carla/opendrive__Town06.bin | Bin 1107640 -> 1105756 bytes .../binaries/carla/opendrive__Town07.bin | Bin 567980 -> 567872 bytes .../binaries/carla/opendrive__Town10HD.bin | Bin 184600 -> 185128 bytes .../kesai/3_train_64GPU_multinode_slurm.sh | 2 +- 12 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pufferlib/ocean/drive/constants.h b/pufferlib/ocean/drive/constants.h index 9977ff483d..27b30c82b0 100644 --- a/pufferlib/ocean/drive/constants.h +++ b/pufferlib/ocean/drive/constants.h @@ -274,6 +274,8 @@ static const int ROAD_OFFSETS[25][2] #define OBS_VALID_COUNT_FEATURES 4 // Heading deviation since last kept point that forces a keep when obs stride > 1 (~15 degrees). #define OBS_STRIDE_HEADING_THRESHOLD 0.2618f +// Lateral slack beyond the ego's half width on each side of a blind partner's forward corridor +#define PARTNER_BLINDNESS_CORRIDOR_MARGIN_M 1.0f // ===================================================================================== // 9. REWARDS diff --git a/pufferlib/ocean/drive/datatypes.h b/pufferlib/ocean/drive/datatypes.h index 4b547130d9..ee1f973afb 100644 --- a/pufferlib/ocean/drive/datatypes.h +++ b/pufferlib/ocean/drive/datatypes.h @@ -134,7 +134,7 @@ struct Agent { float reward_coefs[NUM_REWARD_COEFS]; int phantom_braking_counter; // >0 means currently phantom braking - int partner_blindness_counter; // >0 means partner visibility is restricted to an ego-width forward corridor + int partner_blindness_counter; // >0 means partner visibility is restricted to a forward corridor (ego width + margin) unsigned char is_blind_partner; // episode-level flag: agent may restrict partner visibility unsigned char is_phantom_braker; // episode-level flag: agent may phantom-brake }; diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index fd44ca515b..b70dfd00ad 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -4819,7 +4819,7 @@ static int write_partner_obs( float *obs, int obs_idx, int *partner_count) { - // Partner blindness restricts visibility to an ego-width forward corridor while triggered + // Partner blindness restricts visibility to a forward corridor of ego width plus margin while triggered if (ego->partner_blindness_counter > 0) { ego->partner_blindness_counter--; } @@ -4850,7 +4850,8 @@ static int write_partner_obs( project_vector_to_ego_frame(ego, other->cos_heading, other->sin_heading, &rel_heading_x, &rel_heading_y); float lateral_half_extent_m = 0.5f * (fabsf(rel_heading_y) * other->sim_length + fabsf(rel_heading_x) * other->sim_width); - partner_visible = rel_x > 0.0f && fabsf(rel_y) <= lateral_half_extent_m + 0.5f * ego->sim_width; + partner_visible = rel_x > 0.0f + && fabsf(rel_y) <= lateral_half_extent_m + 0.5f * ego->sim_width + PARTNER_BLINDNESS_CORRIDOR_MARGIN_M; } nearby_agents[nearby_count].index = partner_scan[j].agent_idx; nearby_agents[nearby_count].dist_sq = dist_sq; diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town01.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town01.bin index b0b3f0402f720e53174e368f2e838e9fa81f42c9..db510756179765dbacf0e78a2254b3486f0e4ef1 100644 GIT binary patch delta 377 zcmXZY&q_i;6b5kT-cFPlaUt8vtxe#zqD5wQgBCr5AygEZSZRNiB3xM9wCrS3P--pO z7H3fqty)L!eE~N;K)b%VwD{n64l{FRrc?HJ%KqoPq6MGNm*8Xd?kO%YBWcninoIIR zVP&m=x-^gtBrQMPIYk+dsh+k;*C2DupOm*i%b<0Xt5WK3chNq$E2#lfQC5@HV2H1R z3EDxmEx*Cv|mi@O$3R=~djliu)z6DC%;#R{2zYP((|@M#7{uFeb0$S5^#+ zBLk&TMt%p66lrE9HPkhU!CMZy6Y}oq7_@<5Qp%&<0onr(BsFHLK69+beSG7M(he4O z<0{?IGP9!S z8mviV)^AV_+?VoP)}$lwgeg$6wxJBtFC3#oZUyT7^}GawxZnnbK6x!)Iaqr#kR?6Yg+Bpo zMAqh&{HqyR1A$8XHb)8qnUO$d%w|sEXiio?Ak%kprcAM5I*Y3}hBIbEoE0RW zGkGs}y;%f)?^?n0mzb`?82YG zng!(LY+lK~nvvBH$PC;ZDF|f70GW}SIfbJ+S$%;_zsZ?0#ex|?Ryq)a?C=5Nz{ziA p>IFga0Z`F2Ae}k6R`xI}$PH`002e{G64Vp diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town03.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town03.bin index c7ba58f85e74714bfc140b9a6b9056e131fad226..d52445ebab270f203f7cfa1b13fbb4c7b94a5ecd 100644 GIT binary patch delta 5517 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 WU=7Ft8=yPz31C~j{iNN5G5-Nfe|;YS delta 6755 zcmZ9Q30zHE`^P(bw;NTOHPDLCJ6Sd6RoH$@es5MqA#ZWVv);M<(8#}V%dW$F4o_NAl>qKH5G&6r#KC!kZA=;VF zSiLkeZ>^e5-SCqKrVAeVVqb&PKfur!7d+~X@lkxXre#c5Y`Th{twR(MwwGi!z9a@i z3VMopXdwP;9jq8)e~D)PgU5#|oSbG*OoGPDbs1sEW8cY`6!DGMMbq!FH3^d{Lhl1k zq$Lg*Ck&~b_)fu06SI8|I8DXY_ZZ?&W}UH;j@T%8UoXnk5t}5?PLb|6k1{2*wiw*k zZm+CMu%*C|eICVHq-g9OFJn$%^SB~3U@65s$1@K^*sIL68e0{ZDlxnF0sA^^J;IO` zD>2tF*TwwaUW$}Ht`su^XTat4t6@la(?ve0mtuWjw2b)yKc;vs6z~SVK2ouQA_Rjuj+) zb|KRbTS3@z5vLF8&ye{L#lArbW2Of-yNVMNw|J*2CSz zt;166yW=Mx3^~bUfg@NCE3AiKGqe5|Kel1Wa10)f72iy#vG@^NvoZL;!60#1vWw!w z#0Z&n6mh^1=ebyCkts1{Qh$a7%oMjxddQIC=PEK&gpSMw>~=|voa>@^GN+bd$fu`P zae09Y%`C!B3&q9^z+_-+f#|W=OHugqCW;|vNhN#K{k;spqR*3aG%IF&}TK4?I4%P4)B!7P7t~V%Pvq(WH)FevIp4zj%6?4 ziR=UCiR=e&h#UZ&*J3FIgNYmjl|+ibuzW0s!0mi$i|jDCyben-C>N>mvLhg7J(i=O zay=d_0dNDBW1w^+4mb`h02T$vCvpOm6DbAuo3NY&JdrYxPUIALL*z90sQ}9vP)X!0 zXd`kC4BL#Q9Apwvf`ddPAlrgP1p6&hS2dX)zM*^cKcXi&5rKUdP=4$&$aP&=p}sB1Ks zwF5t2r@>~T)ik(3#&6J|?@s)DlLpB|Z_!}YPAZPNO@p4hu->7;{#`ilU7Tk(p1+63 ziQcC{(r!HdfCeYY^FzAKau4>W{-n!t_EF9h^@s*l2k^*aTyY`R8oYyp*z*algy>UT z!9hI!7Y%HRus*{T6j4P~Ee*^LQJbmfblHBaGOCU)iz=q#WK=y3)*Qh#H_)J+Xd?}# z9>tMOGR(<-#p^ zxvLo(r?5ivyL3ePPX+bLIzc`CMwEIZ;B~p=-xA)fAEa8L?7{^$-((4`+GOO`FvH2Z zHd|eNx{{6stPmlbx@I~wFDsJI^Q^>*5nF(#^K&!DIu z|FVgi5%83+@sAgbQ{#o+o8pCW*Wv}SH|3u4L9fDv^^;JmQ37(G5g~j`h!Auw-Fl<) z9HC9G!trgTuyFNz*uK^kxsQ)Tu|CO2?J!ixiXSROW(^hEP7D=JHVzd?fgEKQ*sd4* z+U+^C%y|mo54T~R;vzhkc^V!p4Hf?B8zzi&3m4MYg$n_j!Uge3j=fwxf?YeH1KTuw z>42NT2@!;kdYdFQe^{G{e%L=4Z5S4#somT=Lb#m}E@bx!`?d$&UJA|Dm)q9XD&qo& zN`y~(xq2;VK>x}cP(^M7+AKDpF-IsQ59(P7&wg1Cm+lUQf0*=?j8_IpgN-9K$0IcD zGON#Ly-m&ws6}TU)uM;>^{DC~PI$NhneE)d?v5&AKh1LEHvE#ujoNsbbM=UkE=I>l zPG<&5W6J*{Ep!|%tymSvsT16|RfqqIVLQ#dtl?j6awB{`NTt^^8qnO#29$Qa9!*|U zhhDC!)l~e;j54_4?QobiBoMB1aD?O5eNu_sl}^sbyIy7qpHCiVn_p~Uy+;13;R_9J z;IlJ?eALJ%&IIF{9Ds6W`=BoSJ0s`L4yd8U5*_Vff}{^Jw4|j4{yaO0|NY}={)2HM z|9xHz|5H&2-&pL+f7oopKYndIcIKT%}*%z=f|9mmG;=iNS>>rrQh`KiiUt_uJk}Z?&pm@ zT)9Jm`b=Idnl4%E=C>=8P4R zTusqpbvw))@ER_Y)x&?oYG6j|J@_c`7W{KeHC$tIUDM8J&oR7wSt7q=(?I@NS}1Qf z(4QZ5&w!8dyv5~=+0D(HI*vmo*V$N~Da!4}CaT4i*(%)T{_=YHgYuBD2};q$jUp!JAiIc!~d3OIDgwI65I(}z1?>Bx6*1 zQ)SC}dC)H2wCY>&a>m<|GaAfrLq4>#VMfgw%(S_%804-D5rVJx7jmmY1=GS%Az<+m zc+oiLys^a)hbL#u3xI^C$sogw9eO3&-bT4zi#yY%N2v6N^gfc zXDhuOo%D?tc1(^JR&0+K<~+cE;2eesU5{14_LJE#wDpGSbra9o&yFL4wlwb50pfF%eWr-cvgq4-a4l9`*YXwcbqfJKP=~*-WGFY5%Si&Tn2Fj zJH3U7%EF1cN$_>{JczFqSNR|D z)zXU>k0tBhnx%KlN6B)8p~}O{T!Vw&c9yJpuF1H3(L_zK?)>dLWUeAO>iM2`Xo19k zU+M@*{B^$5Z5$;+C#^5O<>dx-K_}tZg2Qmyg`Mz19})IVT?Xqb(qNapzroSTS@8Dk znb6XIBD}C<1Z=vV@GTihdH~<_w!Se^l6s)@*mOGUTM)$7`q8W~ch`Us?B9_DC%wX! z2bVyy-iFcnn*4b={MWCPQ&x512+mq(awnZD9V}dbGgx>Y5GS;y#R-=CKhQ-l@bCNSC^l{``hD&K^oMT-y1F_8ZNz`fJ~^v|UgXtC^us%!NVl|^*B>dZ|B+E-e`x^@rf_bv$LWsD{7*j;|jv#2~voS+On z36$ntZz=bO+e$wdL`aR{b2W6q^6x;Y zQs0}ZI>y^-aM#=RN4xLP@mC{JL|=yQmlVS5w*6;OC`<6r^G!ENLBxM@<13E%>wNcj zx8n$&|6OnZN(vs0VpU_&hkyx4K0gI5Zu(mB9RrEq8e76&i{H&pj@`hYl(ZE`4zHC; z^IN4wr7~6e8B>+Fg{=lJZDw=a3ex;J35$^R`+2Cba1LS*%tV{3CL`~GW6-DSMCABo zFj}%Y9JM+JYF@i6>x9%%cVX-L+03aX2njPCJDcyZ7jxbRFZJXqcePYpChq{#Tm*;2IgMrq6AJyKlg zF%5ot3wtjxMcW1Z?N4-$dbrLrO1JQw`tvNoU(femn-_Zd*aLN%{Wt%k!R>EHr@iK+ zMChjVC4YR3;*HSM_a<^g7C2S%m|y(VFp4aqTY%{O$M@Kbk5@Hx*9-18AOU^cnTRg* z7=fCGct>>_V-%%mulcqhi9Ot2B^9i{tD%QpktUszk&SsWa`6a39X;eM(k-}*yCe|; z^cGTXN=135lMo3qnrMN_0z5T=x)`D@Q%_67hpIL7)LY2U+yMP!#~??iHE^6E`1Zv_byOmS{|z z8S3X_jD8nr1QR~PDf3!k09DISDvimG+zp|JerQXdh7`dKZQ=h}m+Buh{hmF?IM{j5I%A z3t`iTVwf3t9B#UJ0+uUFHFx}kMZAlu(%e(^wuis!m}hU*tWF`S4WR>6jdYBLe%cku zEoymDg*?_mG}-DrdbZ&ba+9v2%YN0U`VgzO`ioU3hH~ofKC|lh9;{k%BSNjYMYl}x zS6wTQP^q?ZoNI13u4;&jhCpp%ECycX1t{seV$`8;DZ1l)4jqw2t0x#lt0P1~J^xgc z?pK#@Kf1&k59X?zqPWegLNx?wbCKhJr}P2>%~wz%F05b?Cs%h4X4Mc^qw6Kn&Y~}u z7V4%USQ|_Zz>6UbXh&!bdOr3x+FX4F`7KkUln&?6yS*i-#%CY8w7&qIYaOiqoH10L zpO&a@J~>=H_Q?qKuyZ5T8FNOdt?`dAMcMcV7`(<;#3o~B?_QSv5;eqBDzd$)9Cv1$ yvUJ-tWv9%K<>oV_vpQBxKS5z=j3I`>7-5VtCKywU8O9vb0b@}%{e)#=%>Mx$;2lW- diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town04.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town04.bin index fc29efd22ecf9688bc3f11345c6ece9dcd23e28f..b44a52db8460e7544f1c198795d79c6ca1c8a1d7 100644 GIT binary patch delta 12406 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 a`>24nQ2`E70qvp!+D8RA76v%U9{fMdi{lpn diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town05.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town05.bin index 4b15a4cef02832b1ab196bff7af3ab5cad43f33a..5892e0598b91b9910c450183c4856b8592c81ea4 100644 GIT binary patch delta 3886 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 delta 6885 zcmZu!2|QF?|99^-p^Qq3sEA|>MUpTx&ZT|dx2Gg3ZQ2(tBWXbtgHzfS<>^Uj(vW55 z-Xrl;O8e%KrAVu!@|1ednWp~pe%^nd&$sXIckcJMoHOT~dp#{;b8oN$9sF-)wqP~Y zSwQLN=wmP#1{ky~ni?h0!&W=lBPw6m9$SVOIvBe0HBVXKGyif}$9Fvn(3nRCZ;0xN7;$g4Xpw8Zswbi(9g zT)R+$71&%O59yE1b=X`f-!cH3Yq7aXp6oWvSx|w^+w$tUGDp*xWC#UR^~Q9L8oU#zA@6+D)_oV)Lkc#0Igkpa7fs^0=5BsIc|36GniiDylbFwYc~ z6`P_v{uapE%L1)*wm`0P%+V~&(=08}q|O#-9Akk-wU{GPWz)d!ESPG{8f@sm-a4Sm z#--@8rOP|8y2Fgwv;bqaVv;c%wA-9*F0f{!njG1#ecf5o&cmHA(=?D>6F9>au9TsP z%iZCsK^~=Ig7TA(^V`n(OJot{W-#{^l_c$m+q6rxKr^;mpx8AQs4&C=y_;%*>h7DO zpl7D2`lTsKtTacuji%`Azouwv2Q&1;!VHBcSs)VUdLj$l{-OkH$5sHdlQp2AwHdHF zA3;V<12DK!2`q0`f)8gaLCc|Q;B#yqXbnFF?(IAUNE~ieCJ*&Wyw%Av{lao@X=9m^ zV0YSZiTswlpz&*&QUe9^h8j=_1OKLjsm+R}ziA7K+5&~wX(wu{!s|M5rEuE~dJ`1^ z+iu{|ZP1|{QzV>Hjt93xhnuuF6$L|X;=vs-fp|M%>rFbA+6A+3(PyaLFuH<1NJT^2 z+gK6%9VVF+n2=sk{=W!H1Cv*(P)#H|r!-NJ*X)yUE9y|d%H{$SF_!6 zG8DeA=z}zMN+Ebl$I{ekSoj`?WJ0b9)1UCl2RxxOa9%Uk{srR*orQVLI6)Si_Yr&N zU>u?IFpp3+d`{>B?A(G$4t)t-grO~XIdfnIp-WKs2@fiu7ol93KqwEs{Dcc#hS{I7 zmk%pHW3K?Ff5BcMtoVZCilA33CM8_kiU(DS*0z|P;XR|0K+;K-{mj!-Eq`+=X8!ImF5!QaV;nN8F+7(g*+sOzvN%{b9F7&ww< z{-nzp_=#p5>6;9+W|$cI76Y>w>{l@GG4XFRaDguN?=Ub?msw2TW#D(>-(#So0P}qY zP8Hzs2Ml~m{D%xQZ^wkwj~K|e!~SDDPW&efT&#!vr;LKvV`frxC8JnpK#YHI#`gG0 z6{B$Mz_h36XAB%+QN$aP2el0RAjG-q7?^5=A3SGZi4pc+FmQQC?AJ4j z)Q*ffLpLz+k})25$tY@!$v`9Swi_OJ#lT3yukkDizrnL4{FZ_3OfkP>pqnY4^?L?3 z5^iE(ni(^a{(xs;hMza%*>%VMN4y%{nVED8_RTSW!a8$2{u%ogn7`oX7ECJL%0QPM zIPPBt&g_BxuZ+UFC$pHMzcKJ~FZ`g5QM~BIjHKxAjAD)zV@}aO7#QB0ainw<>Ae|z zqAB>snz=^NFwcf@q8Qk@5B79nWgqMbpocA{c5ogcJs3wwA3nEbo=^txO<(M_hu!U% ze5wPa?3pE$AzWsU2Zite@r>XA2PT&42wytj!A`KNBlbGOR!98I7$!Jj>H;15F|#NW zm`$iF^m1knQr#fsg2@z?xiH?885Fo;>JI%}aTarUz?B(AS-|IndMF0;$GuQJ6+$<> z>8M`NYap&+39k&q39R5e4=m~p&wAkG*3e)OCL0(+s1M`_*}@T?Skf1Ao;bN3>^>Ni zJq#e^09Ovi``Qt9_rm0);Jk1fR6j-A5bXLp>*7~5zSi|HtdGsd43 z*)MEG1g@G`8ppbgr2MDhX0m)TL5U>$cT5$mk%;ks;{FtNba6$Kyxh~T>!*@bzfOLRXwYG$hZ0O!EZ0D;^ zJe=ml|LX8b#9SW&H!W>d$j{{7Z-cp^Gdgp>w6j*I#02gdtv+rnzYB+o%nDI%a6U?i zyo@?#=OUlmmr%&7iHP+Trh!(ZyQ6sS9Pel0qo`jc89~FUz zmNvW;A*G=bRXpv;$2E8St-YGlJ)dCEPpz#EW}U7HIq25t%P3Y~iG~& zC+o4J_UW;Dv3jgbQfSu4OG`)uiCi)&e{}#60lQs~m2Ubeln&9V?obksj2mOom5#4L zKd0Bfw@&id?mh(G8f|-Az1%k$#U{zn#@C6+#w`)`UzmWJ_lK(pL>evm$|uQQMN84H6YEi*!Z5_$U#LzR zRA7ky$T39IvJBD0Q@DsqU;^J;QYN#-T8m>Bz8qBtuSD{_YtYmo>yY2Yji`9)7PL4v z0zF<6iH_affff#oMy8``_~ObP_+C0q{I-N<-gagSUpM&^FI)eK2dS9x z+DIeUSg4Xj)+*zvcB+;&{Zu;-`8M-3L`)TG-7fM9?k8KPF43q)*YPC zix3B7-Oe7R^sqynbnTGjRxq%f9}KJq1%ZH}bHKR9>0qGiM6l%cIQ2C+K6G4dLF5Ek zQ3sv&O6$7;%9;()%8c-fYOtCjZ#$cNk(pzYf3q9^7R=1$DnwAOzk?pYQ_Y9 z)h#DpcUU{#c@xLwkFe$~R$BAn`>pxMN^9P$-kLvPWW#^)u;HiuVFTYeQGPxFnuO%; ze7d?XdfUkk)!Nu2ixda6b+i*IdFz70Zy2&OJUg(JDg$<9ramh&)@PS~#>Y)1Fp~}# z{YAOHlTg*=oTX}6xw~q==P)&fY7&!!y}j5LMIj8{7X9Hmi|uAOmA%<8Ui*)cH0pDy z@)w3_V#)3QCon{`i6N2{j%N*b&ioy-?`(+}!&f|9YX;M;o`FSQZh(!q@7EM8_NRl7b-7^1{$gMqTn@tjk$`1U!vH;d3`hu^2r8FM2Xy&da3CiLWP7WBJuB(s zeO03C&Ag~^YKCZMUX*B4yq{=q!#Ce)pO2_9QfrP?Zr$;_zzod}T!XBhgrQ%X`f*5+ zm|&DfOV-lZ(+15RV}l%y+n~DBp=efM7#h)U_3s!GTS;G=q3maTSB=qHh2Y^s==k0J z=o$5KLPuYvgcJD(v zs}hiWY$DoZAwz+8Whi=m5<0Rq83_YY(24jIbnYfIp^3DL$sd7;2t`LBYEk!_fKGY}BrDeFJ zt`SIF^}QuB76)nZM8Tv`i^+djH_x) z(3XGWr{o7B(ZT&Wa z{9{~Dk5X3vBkRxj^wVqe5oP4-inYy?|>AqW66@Sc7HP*U|YTZ9QRaHI?s!6V% zYE0Ma+F4kPbV^Fl1e@>tWc%;@uhp3MVYrH5hDJ;7>8pGRDy-#^Tvv%QZS&Ewp$g<+ zB1HQ;36Y*qXn@uk2+?za5W)WSw7qnuRv997&6KO3CeXs)=km?1S3?M9X|x8ojOST7 z%3P3(PWfL(My3Vm@qCC zcq8E}{_yMtk zrZ^^@LV(!n{32H1ahY|<%3(iTPX^h4CxfukWZ+zq3}C-Yl-0mMO$6B$_B~FrZHm+E z?MY|Z)9fUblX*S zQfdgWJo0~s=9}LSFM=Q~?a*x{YOU4b|6e;_qa}N2hv8**Gn>z*pDtkY1{JaO4NA64 zF2_DS#XGad6N+)75;myT9rSD#gE8kv0$JlE@b|n~KwcXNdOZySXmAK1h17d!*tTG? z)XHBxDab}VszgVuZ+}^IG-JDHY*`PH@z|R_1i@OJ_Nv%{(l*DSCx#cewyNy^`MeL& zXvy+js9KCBcUp};Nj9PDx!Vwl_5AY!_bl=P7d`6&=Qi+yfFp_2>lZl9Wby*7dDd== zL0{8%BA2`n5tu)kjpxF0v0uk15{pEZ3LFZ2}i4mSq#1Nsc)x!bP%gl$f| zL}1S^Fz!ocYCpM43?(+Y^+ePXdr|asSe&RqI9k;EL~k`jH)?&GFYHnyRMQA1ouCoM z633HoRelGfxeaj-xIYi+vq>_2c59428@f#&fA{5Ejb5w?ARkQCZhvuZgAZ}BVS73D zR1{aAw1KM#UBmcbp@Q+dm*aFxYQ&>J3k1~lx88a{U}5qF>s+CvQEi8CNI?_BPZz5_XaNf zyB_Zz)|EfB$&o*)=f%H#J&d1oeGcDl{Br)uJ1O7nTgf}UujNBJHt=z)-tc-`-t*Uu zoB7SgpZJyGpLwwHi+X2G509}{g*~!Wfl^1+LxrnqVwSsVdd6VY+hkvrUZtNJ%QVHw zp}1X;&sMSuw&Cv!Z1aRH_WQ$3_JP!ZeGr8ajS-I_Ga%pECa}i_%0#+c6HTu0jRSJo zI|~%-!*7?^3vd4chDI;IQm*0m+nZeB@dv+))ARKt1Yug!ZVf-rdSqp@Mf)$XL)XdK zGvFd?^YtS8@K6q$-sciqkZ_5u`|}-GCwmK?_Id-hc)tQkuNn-&;~_7=clTN#uC9WX z_OwG_SW|G(f&{K8ChJGhq$O6$YLP_QAPiKBCq*dzAMRCSg;v)aaMw3V6zo2G1*^ne zKigdcy2rlwudZGEbtEM(^wik3!H2=n#Smb$!_dRf$1uQXkI@0c5JQMzgwav4!KYJQ G+J68G%h$L7 diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town07.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town07.bin index 71a8e8330925818ece455edb42375bb850bc6880..13dae8f4ba48886428dab8433895be37a4ecfc55 100644 GIT binary patch delta 2547 zcmZXU3s98T700=|+~^V(Szby+1g%=sH39KKPJAy?gKX9UGk0er*=@!{7E6>Yyhuc&}yibM&tmw6=H)=m!{_Uhsv2~E z$LMc}3OS3M;jOCY=^#ee_?znK+^AklujA+{|DF0v8cqECX7FbJLv#j4F_NC{^QSrt z1EsT?QWaf=VJ43aJWaDO%HZC>qx6Q)-K1^M4T-9V=(QA|FXNFxJLwXPlK8=(Pw3}3 ze~){oOy$3wa*8g*c|FewZh)-k$z)y>JdKY8*G2SV(g31H;xOe&B!TegkXihIu1+%_ zhw;c$To+PBKf|Q|NiT&+Dr&&VWWFRkml`ozAvK4W2KZK)&L7M??OS=ebRwokLvN$$ zjubopvWkvk_>J^%;Z=XC7lnCjLMXoz|CA;HM~jd|DKa6H&}kTkN%4!@2%U*xjO0!H zmrC;yx;;oI?_IW2BV*Kobn%?!ZN3vY#Ys|+Z~h|AUyufqGW~t)T*>F8^w3nSWa7;! zQ+c2vi&}7G=Ea5r-+UVX(6EcPq3t*~84qcWVdO$u`LOYDcmU>7MEpl0=Y8Za(m|72 zrR&D93%P_~hF?7=siC~xyj&yJ(1Ud1%$5kz&h%3Wu@8}d!H-9$Me}!4%cw}~khE!A z2wjF*Nzxw6enLe>HIGb}=oNh4&j->!@m)g~Kbi3g6(`p(nN}TE(_l;t;=|dc^l^-) zNExf$YWfvEe;{esH4y5?@Ql=w>mXEI%64fqZ?Qj}jJZ^bw|)KX9C3w4_S~26H{6%! zy05ZnbFZ_W_G{+^LH*|qf=Li-C3Z3yUb2z}L<6lt0$|WeGRfl*n~$SF$QGi79r+}g z1i?u$ngZT@ObG^k0giNFDme*jx1fL*IV3tErhee(09>KI)b$6NB7> z&b{eIK^Qzhr#@U^v4l3T(54sIZ~oCOyM`uOBA|FZ_M8e$LZ-nF?1UQF_3{45)0de z%!0Rt%!b}VEcXl0*oj>){^haFK60zfpWaiMA4c7fD+_MPm4%bsg8C!x`Xmaj`DHKr>#{6 z!Fp~2AGg?sF1ZB@;b0MNNe_eQB#VF)t}K&v`&IXEKi;Wmyl$*DP#rEQewBQRMd$+zuVMx zd$%d2E>Es~G_hxjBKP-cM?EP;M?Lpz3gvdMbwW=AY{2rw2xm*NnF;O+VQ{bv&%+FZ zWjIO&vH>Lx^g=9P6Y>n~5t0rkHehB3+!L}2;x^)BCRl}JLE}b}Y|B=xCabE?GmzEk ziTi$^vHa3LmX7T;rCp@;NXn7Pb!NIPg;EDx3$V4|vi)KksSntdU20YR114W6nHi zbfD7;E(gBR`4Cr*H?crb;Lg`X4j-X%d}>SwnH$sb>aT~-3Bq1B83pUXT8>T|44{(~ z!gr#8T~XlB$987;r=8tuS;Hct@>u0!{8Ay=trrY}B8aO%OR>Uo{2M#V`M;fMqv}}3 zAGc1hlzYi%LyKYt2gle}A5Pk!Bm8Wm7qi8U(WO(9iqQHT>B3aN(JD%{K_MY%tp zZfx(k(b)c~V;jr5Ik8*~WLJ?xq-?%e(|d8Y#)^WZJ}<#nZkY_ zwGi&ai}IYpvUV6))nf-(U+<9ocGJ(6(T_X^K^^2c@vW!_mlId}E4V3S3(#sj!mZF) fjiYVgLU|>ymLMubjrb!Jc?_BKL169VBP)Icy9g32 delta 2549 zcmZWq2~bp57Uk1Cuz?1eO&b+iLNwwAve;qi?%&cwBOu7qvV-iK!3vPYQYPahj7f;o zJk3qCR2;`K1~EEcV@QPV{>Bwq)ZoHIO~)`Ju}tYiGom#$b6*Fou8LL1bIyI|-?#Pu zn7;`*I2uA6@ZT#&Qcez|IAUykg&aWfccaxnj-fbC6S%83)k_tQZSv2t_9em*aRSjt z4>-O?zCkrgcS>ebwIqXFMt6m7ll)9(a}ExHw8Lo!2|_hnSV-P;BJL>VLZ7t6k*q+O zLWAAEB6?J*)at(1_VhKnS=J_JU9Te8T(LMFLlqwDi4s*b{n+Ccxrg<;)ap5%ZuR_v zEX4W(n(5W%ya-h|NAtY`=zXsy|BDzKL`V>kIJ6k?5=R5QL+D5HCg~Ei^AQnL?p;lO z!qAVx25-TU#9>iIqy4f-JSw%&;aB2Hl2NA82Xnr%sTAQzP`#9VhY#Hlg69uAlE)|~ zg+~i6JCO*CE~LScKJ>eYCDJH#Y+;dtBGQK=z9?r15lg!{+tJJstWmcerKj-euMrkH z7`tA264hyhNHdpplP^&9QcgJ_J&Wobs|%yb6;31pTd8PR;sDX0T24C>r&D)TD$%0T z(ISK#&c%LfrXbX>jLk6^+T!Oo}~@fqT%@UceXD8GP`t>Qca z6AtSrx7>%CwaaX)9l**tti0;aCf7~1U@K(%xZpqolLF}{%S#E{%zz+EdX=-?V65=A zZVN{wtRqz<3uFYvC3-RW7U@BCgdWqk+iHV?CS|vTcwo?rj;ECnPgHJ#KHVfC4>0*q zkgaOt2>UIX1Y=e?N6aYugvsosPK0f1nh=rqpQn(W!)0?Wa3F^Z=cd6ZBPnpX+zQSW z(sI$c!5fU+VP7s6$H~CToCnAZ81aNW13E8w+rZUxa(KX;H|STR^8xE>biN?VL-GS9 zBY&u5G#!kLW$w1^%*PpK!k&CA&VorsvmvYiDG)Ll&4CRC7zu*j0lpxKABBZ=eN9f=X;K2c&-zKe+=Y2|qp!veeP{r}eTI%yrhm5&tP9gKONT{m*Z z#IO*oY?eh(S&Fn6j7V^;7!UGnDHp^=z(gsoIudr4AuWZ;GE7B5*cvXpE!u9HvnTJz zZvmHt>vd*+!8$X4x9ijXiS3{ETf6tF7-Hsc+4kne zD%N|9n0eNl^<}tO6yvPM|J2JMa}6G(5;|~6+{@6*C=SLM#e=*YDFG4~B|>dECRMPH zks7S5r~z385)aA>ENWqp(Q=5c#9|VZRpO>~Fu~mKz^@9gKN*Zw==3nbTnbcHqgw$+ zMyW8tC=E0j={_0;k_)VKT_@Tk8Vzuv8QSrj4cFWUwGV6o1HmsSNjD714Jucv1Ulqq|4+ikESxtOQOcP&{(Zp9R1-=&F;=)?aJ(~^Q z+3#%|7|3OVxEsXQZ+fukni#64njtx`X7PV^ zwl!Gem-GBR-uPm3A4Bz2mfiREnHMb^6vLLB-DfRJ3;Qi`uN|=@7Vfo#oURwQSt>-g z=3?l>WZz@hM*QsC6vSwS6ps zp~h}u9t(D>PhNY=oPVN;Prd%z&W%u*3~-x2$>co_bF3DfRasJX8lXxN7Rsd^MlEKV3tYxXy^z z|8!WDp4O~N{D+@v&*sC4-j{DDFw{>qX1^NireVw9bi9FM;Wsl`n!|yF?)pISh zG;u~~gIzE?Ae6TT>iD&xwfy+TEb$L>v&1^zahdbW2patKF+1mT1r2xo-L@vg>cUGe`1 Da5y)K diff --git a/pufferlib/resources/drive/binaries/carla/opendrive__Town10HD.bin b/pufferlib/resources/drive/binaries/carla/opendrive__Town10HD.bin index 23e7172b3c16e741c853c6ba0bc36c31ae5a70e3..c6587b07bc17f0c65a8fc189c0bd87879fdc9e40 100644 GIT binary patch delta 2413 zcmZXS2~bl<7{`6kH&aNkQjEwTWt5?y5I|54Bakdqz^H&yB^;t6hXDjc1dIv_c05sM zD@QAqONvq|_^KAM!0up+b)pC!Ottl7fGQw}6nRp*)tN#*?q+2 za|+=NhE)-}lM_$PW!NHOk8zD3vvu1#xSL7$P6CV+lKEg|HE170y{%1sJd!iH+qpS(_QL>os<_eK4c3aop{> z&IW7^*{y^r^Q@u0V-Kq&t8I`K2eVnkr9lO8TZv1D6;5WbP3*}glU1f>HO(o{h)-=M z%HT?!Vh!2j#1=uuHHQ6~xUYyahdW%QYzbKvL*yD`!vBgdiTHtoG|&Crq?)$zaKBrO z=J*6|(5>5mO(cD5dLEoIsmKCe=Vj=`J_Bh%FC6?ZaAw3 zIb&TQ^Pvb$n>Rz3)ehtay&g*x3n*g6*bs>G8Ur?s=BVXwb3I}ql|BFQ7&xgO0%yys z(IZv0E~}ly9cDgu5PMKWSaCP-UU?l{HWUGy86xoh?4GcK5MC4^y*~!#77v4?N2Y_< z71Q;IMNs`%E|yS4eSH<$bEpalA|B$=>GyPhLe7#7UtWu$#S^*MnM_ttn^<|X4Qp2`^oppb~VW*i!S0jDInp~RI|k($0(caNlL! zD77U6t-SoX!CnVO@fn2(EAL=Y-#wfaB?K3?3xRf`1>u2GUQ4L#yo4m|pPz(nPIUwG zb=P&-IdQ+X@(SQA`UH$3kV4?hWL&=>8LQTv1F4qvdPoqJ&5Ayc^C~alePsghbF)B~ z)sC_h+V;!Ea*8;pI)T>soj}QjYLMOCr6b^_UwnBjgXx4=P7zUmlp*_?GSnXQ9q24< z)FW2FpZXfGG*1>kUO0g(S@ zho^>EV(m4)>h195wHn?e#2Sj2lUM+pl?A}Md=k|h>_zVr;m~SOC00;Gkc|m&vNi!l zc9yui(N5Q+&Fg9_uLxL5h)9YkbTI)LjwayzqF&@2JgMuUC8D5cC{i3v5n0|cyqI&x z_ZkAQ|LFi-k6THe5?*UzIw4{xq9}d>&0g7$p63PNupE729aIlFDArTNYM}={S|r0K z|0+h;*^>XKdKB2!Mzn#-PBh3c{?r4ft+GO<3$1in?L4vY4LN-yMN}-GM2=nqh=a3G zSmrD}B96+g`d|Qc%$!7RMa5|NRIx70Pr)l5+73I26DY#F#sg=&031?l1e(f?bnlOr zNQ9w;*hCR?JpV)`%ibYrj}fTt)F+g1=dgrHf=#3*!&juH!1*sE%x0KEYAQTUY8vb% zH66|#kuY0e^oT^9L3J4TLWyr3QQ~^x6`9v~olN^C^(n8m^4bbZ36V(=S?x+3t5)I{ z*jMT!nA-9_u?^lHQHirC;(MQJbl0&OMOoL$Zo0MWdffP-!{S|awuZZ#TXda z8-oZZXKBcpYkEDlQ`u`x)oAS7YP2h7dyDX`r&K$S_H-3gkII=H(Bh?Byp!Ta%<@6P n%zR+h{<>vO-CR9x7mRx;XL4a9sd><1JTkv?{9@-g6WH@FVvFPl delta 1695 zcmZuw4Nz276y`!#D@1YyD5eS-WfD5anXh=3vpn2M;)YA8sfmNs?} zgi;fK%D@#HMych!M-~&h2W16cJF3`b^hT7M5Mtp<1n}xH6KqDOpj?8mL5QcT_%-7+ zl#0tqAjt1JE<}}#Ac$GGB8Q({6X+t07V~ayQPwmIb*twFU84*84$o%c*ygi*c}Ir(g{U4>Dh zc#^O3$+DppDE9*KPp8$WzCh&+oBUN&e5HLL_@_ITqpCuPhGV4thyWGM#%Lw%2uPix4NYA8`=$K~vyt@KtHxW&DRTKOi&>H zg{b8Gme3K$_;YRR2=Rf$KNMs-bRhe}=6hbm4_c7@!TY|31i(?`K;G?vf{+<}?VlP# zg80NoUW5emzjxRZG84i(H6#SiBZq?bV+Bz_E^-(gMGl8{Tj~9u8M&xLS@AV=va1=QfUiK=8211@G zNE{R)$3rdhTa7>n}~ulFa~FNN5*I)$<}SN6a!f<=_(vHab7&cO5>*RSVJdo^*+ zOPe^zu$F;NDJxDHN)|8X*YPndasC1+DfqCS`${!<7`I+M**$92{X#b0jsSkyd5DcPP6cFBRYq( za$PIcDDiS(`(BfKt^R&PwB~W0h$R-eqK{?8t{gozN$UUGLl^e>Fqi-IjWKKsbLnxK zB_e$=H{5DC8tQg0VH=|gnCF!=X1)^2&R=zAeLFjZl1~o{*#WKWxSNsn9yGGR1|usp z8X4bjWZkk`45#V3sIt%F(zhNTH@J7V)c+q#ha3*WCUbjDT)WK770xhoG|9|Wt~Ya@ zm1fSp)>)We<9f;Imp9dkv|L?t0(_S`JV3Ke+^+2=Zrn)|cR+9A5;~)qZ|X!gvARcS*B%kz zZ02}uI3g^|!ns4uO-2nj*-Lu^>r)w+smQ<{o-nWtsScd%UXwc1u}uBZ?Z>Lq4?8ZO zc;qWc&VR@sU%bqs^Wu~BtUa%ub)KqYnfouYg1i9X#bmQCzT})Pb5E?!(!HoXo3AP_ zrk+v8?X8fo++qTEnr^b>Rf8s~5}K8D@9dNsTh{0}Vo18ID!(ov+rmidUl*9B^?PPl zUB|Z6)G=kZK^=Dek@~wy&(%NG+jEvhL_KG?m1^=o?Miudql6VuHjomSGgMLh^CdY1 zvr&CPtnqFTD{Ty7VSErf&{ru`>05-DW?Ocx!GlE{j}oto4C1EBO4|l5v<;Bx)_(!c Chef>r diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 979362e1cb..5830e77fd3 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -59,7 +59,7 @@ srun torchrun \ wandb_project=nightly-multi-long \ wandb_group=emerge_ \ train.data_dir=${DATA_DIR} \ - env.map_dir=/home/bjaeger/PufferDrive/pufferlib/resources/drive/binaries/carla_128_affine_zones_stop_ndshoulders \ + env.map_dir=/home/bjaeger/PufferDrive/pufferlib/resources/drive/binaries/carla_128_affine_zones_stop_ndshoulders_v17 \ env.num_maps=128 \ env.obs_stride_merge_segments=true \ train.name=${RUN_NAME} \ From 7e38f39d20d66c2d3befa7f1dd5c49b5881aa8d7 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 18:04:28 +0200 Subject: [PATCH 044/123] 0048 env.obs_stride_merge_segments=true Maps v17 fix shoulders in intersections Blind partners see a forward corridor some minor stuff --- scripts/kesai/3_train_64GPU_multinode_slurm.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/kesai/3_train_64GPU_multinode_slurm.sh b/scripts/kesai/3_train_64GPU_multinode_slurm.sh index 5830e77fd3..a571308f40 100644 --- a/scripts/kesai/3_train_64GPU_multinode_slurm.sh +++ b/scripts/kesai/3_train_64GPU_multinode_slurm.sh @@ -22,7 +22,7 @@ start=$(date +%s) export SEED=1000 -export RUN_NAME=k_scaled_0047_${SEED} +export RUN_NAME=k_scaled_0048_${SEED} echo ${RUN_NAME} export DATA_DIR=/home/bjaeger/PufferDrive/experiments/${RUN_NAME} From 7c22e96b72223543917b7710fb70900115800670 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 18:05:51 +0200 Subject: [PATCH 045/123] fix unit tests. --- notebooks/01_observations.py | 4 ++++ notebooks/05_inference.py | 18 +++++++++++++++++- tests/eval/test_eval.py | 2 ++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/notebooks/01_observations.py b/notebooks/01_observations.py index 92c8c99750..6420781e82 100644 --- a/notebooks/01_observations.py +++ b/notebooks/01_observations.py @@ -69,6 +69,8 @@ obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, ) print(f"ego: {ego.shape} = {ego}") print(f"target: {target.shape}") @@ -283,6 +285,8 @@ obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, obs_lane_stride=env.obs_lane_stride, obs_boundary_stride=env.obs_boundary_stride, obs_goal_lane_distance=env.obs_goal_lane_distance, diff --git a/notebooks/05_inference.py b/notebooks/05_inference.py index 17122e6712..25999cddc8 100644 --- a/notebooks/05_inference.py +++ b/notebooks/05_inference.py @@ -205,6 +205,8 @@ def run_rollout(env, policy, action_selection=ACTION_SELECT_SAMPLE, horizon=HORI obs_slots_boundary_n=env.obs_slots_boundary_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, obs_lane_stride=env.obs_lane_stride, obs_boundary_stride=env.obs_boundary_stride, obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, @@ -241,6 +243,8 @@ def run_rollout(env, policy, action_selection=ACTION_SELECT_SAMPLE, horizon=HORI obs_slots_boundary_n=env.obs_slots_boundary_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, ) ego_features_over_time.append(ego) @@ -290,6 +294,8 @@ def run_rollout(env, policy, action_selection=ACTION_SELECT_SAMPLE, horizon=HORI obs_slots_boundary_n=env.obs_slots_boundary_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, ) @@ -364,6 +370,8 @@ def layer_stats(name, arr): "sim_speed_signed", "seconds_stopped", ] +if env.obs_partner_relative_velocity: + partner_labels += ["rel_vx", "rel_vy"] for p in range(min(int(n_visible), 5)): vals = ", ".join(f"{partner_labels[j]}={partners[p, j]:.3f}" for j in range(env.partner_features)) print(f" [{p}] {vals}") @@ -477,6 +485,8 @@ def unpack_all_timesteps(bufs, agent_idx): obs_slots_boundary_n=env.obs_slots_boundary_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, ) egos.append(ego) @@ -554,6 +564,8 @@ def unpack_all_timesteps(bufs, agent_idx): obs_slots_boundary_n=env.obs_slots_boundary_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, ) dists = np.sqrt(part[:, 0] ** 2 + part[:, 1] ** 2) @@ -581,6 +593,8 @@ def unpack_all_timesteps(bufs, agent_idx): obs_slots_boundary_n=env.obs_slots_boundary_n, obs_dropout_lane=env.obs_dropout_lane, obs_dropout_boundary=env.obs_dropout_boundary, + obs_partner_relative_velocity=env.obs_partner_relative_velocity, + obs_lane_speed_limit=env.obs_lane_speed_limit, obs_slots_traffic_controls_n=env.obs_slots_traffic_controls_n, ) @@ -644,7 +658,7 @@ def unpack_all_timesteps(bufs, agent_idx): for i in range(partners.shape[0]): if np.allclose(partners[i], 0): continue - rx, ry, rz, length, width, hc, hs, speed, _ = partners[i] + rx, ry, rz, length, width, hc, hs, speed = partners[i][:8] heading = np.arctan2(hs, hc) rect = Rectangle( (-length / 2, -width / 2), length, width, facecolor="orange", edgecolor="black", alpha=0.6, zorder=9 @@ -791,6 +805,8 @@ def unpack_all_timesteps(bufs, agent_idx): "sim_speed_signed", "seconds_stopped", ] +if env.obs_partner_relative_velocity: + partner_labels += ["rel_vx", "rel_vy"] obs_slots_partners_n = env.obs_slots_partners_n pf = env.partner_features diff --git a/tests/eval/test_eval.py b/tests/eval/test_eval.py index bb19d2a3ff..a0e868a1e2 100644 --- a/tests/eval/test_eval.py +++ b/tests/eval/test_eval.py @@ -102,6 +102,7 @@ def _write_benchmark_config( "termination_mode": False, "obs_dropout_lane": 0.0, "obs_dropout_boundary": 0.0, + "speed_limit_random_prob": 0.0, }, "benchmarks": [ { @@ -381,6 +382,7 @@ def _replay_render_args(): "goal_source": "gt", "num_goals": 3, "compute_eval_metrics": True, + "speed_limit_random_prob": 0.0, } ) _set_small_observation_config(args) From c7c6c37ece6ba58b72273331cdd5430e9ec01671 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 18:11:32 +0200 Subject: [PATCH 046/123] index.html for alpasim --- scripts/eval/render_alpasim_obs_html.py | 262 ++++++++++++++++++++++++ scripts/kesai/12_alpasim.sh | 6 +- 2 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 scripts/eval/render_alpasim_obs_html.py diff --git a/scripts/eval/render_alpasim_obs_html.py b/scripts/eval/render_alpasim_obs_html.py new file mode 100644 index 0000000000..ba0ed4e084 --- /dev/null +++ b/scripts/eval/render_alpasim_obs_html.py @@ -0,0 +1,262 @@ +"""Render the interactive observation replays of an AlpaSim run (scripts/kesai/12_alpasim.sh: +/obs_replays/.replay.zlib) into one page per scene plus an index.html gallery (same +navigator as the nuPlan, CARLA and self-play eval replays). + +usage: python scripts/eval/render_alpasim_obs_html.py [--workers N] + +Pages are named s__.html so the gallery lists the worst first (flags: COL at-fault +collision, OFF offroad, CORR left the corridor laterally, PROG progress score < 1); each page carries the +scene's AlpaSim score criteria and per-metric values as a panel, every violated per-step metric linking to the +replay step of its first violation. The page the run wrote (.html) is replaced by the renamed one. +Runs before 2026-10-12 keep everything under /alpagym/; both layouts are read. +""" + +import argparse +import html +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import numpy as np +import pandas as pd +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) +from pufferlib import viz # noqa: E402 + +FLAG_METRICS = ( + ("COL", "collision_at_fault"), + ("OFF", "offroad"), + ("CORR", "left_corridor_laterally"), +) +PROGRESS_FLAG = "PROG" +SCORED_METRICS = ( + ("collision_at_fault", "at-fault collision"), + ("offroad", "offroad"), + ("left_corridor_laterally", "left corridor laterally"), +) +UNSCORED_SERIES = ( + ("collision_any", "collision (any)"), + ("collision_front", "collision front"), + ("collision_lateral", "collision lateral"), + ("collision_rear", "collision rear"), + ("wrong_lane", "wrong lane"), +) +PER_STEP_SERIES = ( + "collision_any", + "collision_front", + "collision_lateral", + "collision_rear", + "offroad", + "left_corridor_laterally", + "wrong_lane", + "eval_relevant", + "dist_to_gt_trajectory", +) +ALPASIM_STEP_SECONDS = 0.1 + + +def run_layout(run_dir): + """-> (obs replay dir, AlpaSim wizard dir) of the tidied layout, else of the pre-2026-10-12 alpagym/ nesting.""" + for base in (run_dir, run_dir / "alpagym"): + if (base / "obs_replays").is_dir(): + return base / "obs_replays", base / "alpasim" / "wizard_0" + raise SystemExit(f"no obs_replays/ under {run_dir} or {run_dir / 'alpagym'}") + + +def scene_rows(wizard_dir): + """clipgt -> results-summary rollout row (score, status, failure_reason, metrics, score_metrics).""" + summary_path = wizard_dir / "aggregate" / "results-summary.json" + if not summary_path.is_file(): + raise SystemExit(f"no {summary_path}: the run has not been scored") + summary = json.loads(summary_path.read_text()) + rows = {} + for row in summary["rollouts"]: + if row["clipgt_id"] in rows: + raise SystemExit(f"{row['clipgt_id']} has several rollouts; the scene's single replay cannot be paired") + rows[row["clipgt_id"]] = row + return rows, summary.get("score_criteria", {}) + + +def max_gt_dist_m(wizard_dir): + return float( + yaml.safe_load((wizard_dir / "eval-config.yaml").read_text())["aggregation_modifiers"][ + "max_dist_to_gt_trajectory" + ] + ) + + +def scoring_timeline(wizard_dir, row, frames, stop_gt_dist_m): + """AlpaSim scores from the first eval_relevant step through the first collision, offroad or stop_gt_dist_m + from the gt trajectory. -> {scored: series -> first replay step > 0 inside that window, after: first one past it, + stop_step, stop_reasons}. The replay ends with the per-step series, so index i is replay step i - (length - frames).""" + df = pd.read_parquet(wizard_dir / "rollouts" / row["clipgt_id"] / row["rollout_id"] / "metrics.parquet") + wide = ( + df[df["name"].isin(PER_STEP_SERIES)].pivot(index="timestamps_us", columns="name", values="values").sort_index() + ) + wide["collision_at_fault"] = ((wide["collision_front"] > 0) | (wide["collision_lateral"] > 0)).astype(np.float64) + offset = len(wide) - frames + relevant = np.flatnonzero(wide["eval_relevant"].to_numpy() > 0) + start = int(relevant[0]) if relevant.size else len(wide) + stop_events = { + "collision": wide["collision_any"].to_numpy() > 0, + "offroad": wide["offroad"].to_numpy() > 0, + f">= {stop_gt_dist_m:g} m from the gt trajectory": wide["dist_to_gt_trajectory"].to_numpy() >= stop_gt_dist_m, + } + stop_hits = np.flatnonzero(np.logical_or.reduce(list(stop_events.values())) & (np.arange(len(wide)) >= start)) + stop = int(stop_hits[0]) if stop_hits.size else len(wide) - 1 + timeline = {"scored": {}, "after": {}, "stop_step": None, "stop_reasons": []} + if stop_hits.size: + timeline["stop_step"] = stop - offset + timeline["stop_reasons"] = [reason for reason, hits in stop_events.items() if hits[stop]] + for name in [metric for metric, _ in SCORED_METRICS] + [series for series, _ in UNSCORED_SERIES]: + hits = np.flatnonzero(wide[name].to_numpy() > 0) + inside, later = hits[(hits >= start) & (hits <= stop)], hits[hits > stop] + if inside.size: + timeline["scored"][name] = int(inside[0]) - offset + if later.size: + timeline["after"][name] = int(later[0]) - offset + return timeline + + +def step_html(step): + if step < 0: + return f"{-step * ALPASIM_STEP_SECONDS:.1f} s before the handover" + return viz.replay_step_link(step, step * ALPASIM_STEP_SECONDS) + + +def metric_line(label, value, failed, name, timeline): + color = "#ff6b6b" if failed else "#7ed491" + mark = "✗" if failed else "✓" + detail = "" + if failed and name in timeline["scored"]: + detail = f" · {step_html(timeline['scored'][name])}" + elif not failed and name in timeline["after"]: + detail = ( + f' · {step_html(timeline["after"][name])}, after scoring stopped' + ) + return ( + f'
{mark} {label}' + f'{value}{detail}
' + ) + + +def score_panel(row, criteria, timeline): + """Fixed panel injected into the viewer: AlpaSim score, every score criterion, the unscored per-step metrics, + each violation linking to its first step in the scored window.""" + metrics, score_metrics = row["metrics"], row["score_metrics"] + lines = [] + for metric, label in SCORED_METRICS: + value = float(score_metrics[metric]) + criterion = html.escape(criteria.get(metric, "")) + lines.append( + metric_line( + label, f'{value:.0f} ({criterion})', value > 0, metric, timeline + ) + ) + progress_score = float(score_metrics["progress_score"]) + lines.append( + metric_line( + "progress score", + f'{progress_score:.2f} (clipped rel {float(score_metrics["progress_clipped_rel"]):.2f}, ' + f"full at 0.8)", + progress_score < 1.0, + "progress", + timeline, + ) + ) + if timeline["stop_step"] is not None: + lines.append( + f'
scoring stopped at {step_html(timeline["stop_step"])}: ' + f"{', '.join(timeline['stop_reasons'])}
" + ) + for series, label in UNSCORED_SERIES: + value = float(metrics[series]) + lines.append( + metric_line(f'{label}', f"{value:.0f}", value > 0, series, timeline) + ) + lines.append( + f'
driven {float(metrics["dist_traveled_m"]):.0f} m of gt {float(metrics["gt_dist_traveled_m"]):.0f} m · ' + f"gt trajectory dist {float(metrics['dist_to_gt_trajectory']):.1f} m (lateral {float(metrics['lateral_dist_to_gt_trajectory']):.1f} m)
" + ) + failure = f" · {html.escape(row['failure_reason'])}" if row["failure_reason"] else "" + return ( + '
' + f'AlpaSim score {float(row["score"]):.2f} · ' + f"{html.escape(row['status'])}{failure} · {html.escape(row['clipgt_id'])}" + + "".join(lines) + + "
" + ) + + +def page_name(clipgt, row): + if row is None: + return f"{clipgt}.html" + flags = [flag for flag, metric in FLAG_METRICS if float(row["score_metrics"][metric]) > 0] + if float(row["score_metrics"]["progress_score"]) < 1.0: + flags.append(PROGRESS_FLAG) + return f"s{float(row['score']):.2f}_" + "".join(f"{flag}_" for flag in flags) + f"{clipgt}.html" + + +def gallery_metrics(row): + """Failure flags in the names the self-play gallery filters on (rate > 0 = flagged).""" + if row is None: + return {} + return { + "score": float(row["score"]), + "collision_rate": float(row["metrics"]["collision_any"]), + "at_fault_collision_rate": float(row["score_metrics"]["collision_at_fault"]), + "offroad_rate": float(row["score_metrics"]["offroad"]), + } + + +def render_page(zlib_path, html_path, panel): + viz.render_interactive_replay_zlib(str(zlib_path), str(html_path)) + if panel: + page = Path(html_path).read_text() + Path(html_path).write_text(page.replace("", "" + panel, 1)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("run_dir", help="output dir of 12_alpasim.sh (holds obs_replays/ and alpasim/wizard_0/)") + ap.add_argument("--workers", type=int, default=min(16, os.cpu_count() or 1)) + args = ap.parse_args() + obs_dir, wizard_dir = run_layout(Path(args.run_dir)) + replays = {p.name[: -len(".replay.zlib")]: p for p in sorted(obs_dir.glob("*.replay.zlib"))} + if not replays: + raise SystemExit(f"no {obs_dir}/*.replay.zlib") + rows, criteria = scene_rows(wizard_dir) + stop_gt_dist_m = max_gt_dist_m(wizard_dir) + jobs = [(clipgt, replay, obs_dir / page_name(clipgt, rows.get(clipgt))) for clipgt, replay in replays.items()] + + def render(job): + clipgt, replay, html_path = job + row = rows.get(clipgt) + panel = "" + if row is not None: + frames = int(viz.read_replay_zlib(replay, chunk_names=("metrics_f32",))[0]["frames"]) + panel = score_panel(row, criteria, scoring_timeline(wizard_dir, row, frames, stop_gt_dist_m)) + render_page(replay, html_path, panel) + run_page = obs_dir / f"{clipgt}.html" + if run_page != html_path and run_page.exists(): + run_page.unlink() + + with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool: + list(pool.map(render, jobs)) + file_metrics = {html_path.name: gallery_metrics(rows.get(clipgt)) for clipgt, _, html_path in jobs} + viz.build_gallery_index(str(obs_dir), file_metrics=file_metrics) + unscored = sum(clipgt not in rows for clipgt in replays) + print( + f"[render_alpasim_obs_html] {len(jobs)} scene pages rendered" + f"{f', {unscored} without a score (no panel)' if unscored else ''} -> {obs_dir / 'index.html'}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 0d59ef1e5a..e3cdf684e3 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -266,6 +266,10 @@ EOF else echo "no $SUMMARY: keeping every rollout log" fi -echo "results -> $OUT/results.txt; obs replays -> $OUT/obs_replays; logs -> $OUT/logs; AlpaSim tree (per-scene metrics, aggregate, kept rollout logs) -> $OUT/alpasim" +# score-named pages with the AlpaSim score panel + index.html gallery, as 9_nuPlan.sh and 11_carla_longest6_v2.sh +if [ -d "$OUT/obs_replays" ] && [ -f "$SUMMARY" ]; then + "$ALPAGYM_PY" "$PD/scripts/eval/render_alpasim_obs_html.py" "$OUT" || echo "obs replay gallery failed" +fi +echo "results -> $OUT/results.txt; obs replays -> $OUT/obs_replays/index.html; logs -> $OUT/logs; AlpaSim tree (per-scene metrics, aggregate, kept rollout logs) -> $OUT/alpasim" echo "Done -> $OUT" exit $EVAL_STATUS From 46db9efb264f2828d49bb82d9729d7199517940a Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 18:27:59 +0200 Subject: [PATCH 047/123] tidy up alpasim eval after the run --- scripts/eval/render_alpasim_obs_html.py | 81 ++++++++----- scripts/eval/tidy_alpasim_run.py | 148 ++++++++++++++++++++++++ scripts/kesai/12_alpasim.sh | 63 +++------- 3 files changed, 216 insertions(+), 76 deletions(-) create mode 100644 scripts/eval/tidy_alpasim_run.py diff --git a/scripts/eval/render_alpasim_obs_html.py b/scripts/eval/render_alpasim_obs_html.py index ba0ed4e084..377803d051 100644 --- a/scripts/eval/render_alpasim_obs_html.py +++ b/scripts/eval/render_alpasim_obs_html.py @@ -2,13 +2,14 @@ /obs_replays/.replay.zlib) into one page per scene plus an index.html gallery (same navigator as the nuPlan, CARLA and self-play eval replays). -usage: python scripts/eval/render_alpasim_obs_html.py [--workers N] +usage: python scripts/eval/render_alpasim_obs_html.py [--prune] [--workers N] Pages are named s__.html so the gallery lists the worst first (flags: COL at-fault collision, OFF offroad, CORR left the corridor laterally, PROG progress score < 1); each page carries the scene's AlpaSim score criteria and per-metric values as a panel, every violated per-step metric linking to the replay step of its first violation. The page the run wrote (.html) is replaced by the renamed one. -Runs before 2026-10-12 keep everything under /alpagym/; both layouts are read. +--prune deletes each .replay.zlib once its page is written (the page embeds the same bytes). +Reads the tidy_alpasim_run.py layout (/alpasim/*) and the untidied AlpaSim trees of older runs. """ import argparse @@ -60,18 +61,32 @@ def run_layout(run_dir): - """-> (obs replay dir, AlpaSim wizard dir) of the tidied layout, else of the pre-2026-10-12 alpagym/ nesting.""" - for base in (run_dir, run_dir / "alpagym"): - if (base / "obs_replays").is_dir(): - return base / "obs_replays", base / "alpasim" / "wizard_0" - raise SystemExit(f"no obs_replays/ under {run_dir} or {run_dir / 'alpagym'}") + """-> (obs replay dir, results-summary.json, metrics_unprocessed.parquet, eval-config.yaml) of the tidied + layout, else of an AlpaSim wizard tree (2026-10-12 layout, then the older alpagym/ nesting).""" + obs_dirs = [base / "obs_replays" for base in (run_dir, run_dir / "alpagym") if (base / "obs_replays").is_dir()] + if not obs_dirs: + raise SystemExit(f"no obs_replays/ under {run_dir} or {run_dir / 'alpagym'}") + tidied = run_dir / "alpasim" + candidates = [ + (tidied / "results-summary.json", tidied / "metrics_unprocessed.parquet", tidied / "eval-config.yaml") + ] + for wizard_dir in (run_dir / "alpasim" / "wizard_0", run_dir / "alpagym" / "alpasim" / "wizard_0"): + aggregate = wizard_dir / "aggregate" + candidates.append( + ( + aggregate / "results-summary.json", + aggregate / "metrics_unprocessed.parquet", + wizard_dir / "eval-config.yaml", + ) + ) + for summary_path, metrics_path, eval_config_path in candidates: + if summary_path.is_file(): + return obs_dirs[0], summary_path, metrics_path, eval_config_path + raise SystemExit(f"no results-summary.json under {run_dir}: the run has not been scored") -def scene_rows(wizard_dir): +def scene_rows(summary_path): """clipgt -> results-summary rollout row (score, status, failure_reason, metrics, score_metrics).""" - summary_path = wizard_dir / "aggregate" / "results-summary.json" - if not summary_path.is_file(): - raise SystemExit(f"no {summary_path}: the run has not been scored") summary = json.loads(summary_path.read_text()) rows = {} for row in summary["rollouts"]: @@ -81,22 +96,26 @@ def scene_rows(wizard_dir): return rows, summary.get("score_criteria", {}) -def max_gt_dist_m(wizard_dir): - return float( - yaml.safe_load((wizard_dir / "eval-config.yaml").read_text())["aggregation_modifiers"][ - "max_dist_to_gt_trajectory" - ] - ) +def max_gt_dist_m(eval_config_path): + return float(yaml.safe_load(eval_config_path.read_text())["aggregation_modifiers"]["max_dist_to_gt_trajectory"]) -def scoring_timeline(wizard_dir, row, frames, stop_gt_dist_m): +def scene_series(metrics_path, rows): + """clipgt -> its rollout's per-step metrics (PER_STEP_SERIES rows of metrics_unprocessed.parquet).""" + df = pd.read_parquet(metrics_path, columns=["name", "timestamps_us", "values", "clipgt_id", "rollout_id"]) + df = df[df["name"].isin(PER_STEP_SERIES)] + series = {} + for (clipgt, rollout_id), scene_df in df.groupby(["clipgt_id", "rollout_id"]): + if clipgt in rows and rows[clipgt]["rollout_id"] == rollout_id: + series[clipgt] = scene_df + return series + + +def scoring_timeline(df, frames, stop_gt_dist_m): """AlpaSim scores from the first eval_relevant step through the first collision, offroad or stop_gt_dist_m from the gt trajectory. -> {scored: series -> first replay step > 0 inside that window, after: first one past it, stop_step, stop_reasons}. The replay ends with the per-step series, so index i is replay step i - (length - frames).""" - df = pd.read_parquet(wizard_dir / "rollouts" / row["clipgt_id"] / row["rollout_id"] / "metrics.parquet") - wide = ( - df[df["name"].isin(PER_STEP_SERIES)].pivot(index="timestamps_us", columns="name", values="values").sort_index() - ) + wide = df.pivot(index="timestamps_us", columns="name", values="values").sort_index() wide["collision_at_fault"] = ((wide["collision_front"] > 0) | (wide["collision_lateral"] > 0)).astype(np.float64) offset = len(wide) - frames relevant = np.flatnonzero(wide["eval_relevant"].to_numpy() > 0) @@ -224,15 +243,17 @@ def render_page(zlib_path, html_path, panel): def main(): ap = argparse.ArgumentParser() - ap.add_argument("run_dir", help="output dir of 12_alpasim.sh (holds obs_replays/ and alpasim/wizard_0/)") + ap.add_argument("run_dir", help="output dir of 12_alpasim.sh (holds obs_replays/ and alpasim/)") + ap.add_argument("--prune", action="store_true", help="delete each .replay.zlib once its page is written") ap.add_argument("--workers", type=int, default=min(16, os.cpu_count() or 1)) args = ap.parse_args() - obs_dir, wizard_dir = run_layout(Path(args.run_dir)) + obs_dir, summary_path, metrics_path, eval_config_path = run_layout(Path(args.run_dir)) replays = {p.name[: -len(".replay.zlib")]: p for p in sorted(obs_dir.glob("*.replay.zlib"))} if not replays: raise SystemExit(f"no {obs_dir}/*.replay.zlib") - rows, criteria = scene_rows(wizard_dir) - stop_gt_dist_m = max_gt_dist_m(wizard_dir) + rows, criteria = scene_rows(summary_path) + series = scene_series(metrics_path, rows) + stop_gt_dist_m = max_gt_dist_m(eval_config_path) jobs = [(clipgt, replay, obs_dir / page_name(clipgt, rows.get(clipgt))) for clipgt, replay in replays.items()] def render(job): @@ -241,7 +262,7 @@ def render(job): panel = "" if row is not None: frames = int(viz.read_replay_zlib(replay, chunk_names=("metrics_f32",))[0]["frames"]) - panel = score_panel(row, criteria, scoring_timeline(wizard_dir, row, frames, stop_gt_dist_m)) + panel = score_panel(row, criteria, scoring_timeline(series[clipgt], frames, stop_gt_dist_m)) render_page(replay, html_path, panel) run_page = obs_dir / f"{clipgt}.html" if run_page != html_path and run_page.exists(): @@ -251,10 +272,14 @@ def render(job): list(pool.map(render, jobs)) file_metrics = {html_path.name: gallery_metrics(rows.get(clipgt)) for clipgt, _, html_path in jobs} viz.build_gallery_index(str(obs_dir), file_metrics=file_metrics) + if args.prune: + for replay in replays.values(): + replay.unlink() unscored = sum(clipgt not in rows for clipgt in replays) print( f"[render_alpasim_obs_html] {len(jobs)} scene pages rendered" - f"{f', {unscored} without a score (no panel)' if unscored else ''} -> {obs_dir / 'index.html'}" + f"{f', {unscored} without a score (no panel)' if unscored else ''}{', replays pruned' if args.prune else ''}" + f" -> {obs_dir / 'index.html'}" ) diff --git a/scripts/eval/tidy_alpasim_run.py b/scripts/eval/tidy_alpasim_run.py new file mode 100644 index 0000000000..1553318abd --- /dev/null +++ b/scripts/eval/tidy_alpasim_run.py @@ -0,0 +1,148 @@ +"""Tidy the output dir of scripts/kesai/12_alpasim.sh down to what is read after the run: + + results.txt official score + per-metric means (written by 12_alpasim.sh) + obs_replays/ teacher observation replays (render_alpasim_obs_html.py turns them into the gallery) + logs/ evaluate.log, evaluation.log, evaluation.yaml, config.json, run_info.txt + alpasim/ results-summary.json, metrics_unprocessed.parquet (every rollout's per-step metrics), eval-config.yaml, + wizard-config.yaml + videos/ the in-run mp4s, only with RENDER_VIDEO=1 + +usage: python scripts/eval/tidy_alpasim_run.py [--keep_rollout_logs none] [--keep_container_info] + +Everything else AlpaSim writes is deleted: per-rollout metrics.parquet (concatenated in metrics_unprocessed.parquet) and +_complete markers, controller csvs, runtime worker logs (copies of evaluate.log's runtime lines), generated configs, +docker-compose.yaml, prometheus configs, aggregate plots. Kept rollout.asl stay at alpasim/wizard_0/rollouts///, +where AlpaSim's and alpasim-training's offline tools look. evaluate.log loses its container INFO lines unless +--keep_container_info: AlpaSim's runtime workers and the renderer log every sim event at INFO whatever wizard.log_level says. +A run without results-summary.json (never aggregated) keeps its AlpaSim tree. Runs in the 2026-10-12 layout +(alpasim/wizard_0/ and logs/ already in place) are tidied the same way. +""" + +import argparse +import json +import re +from pathlib import Path + +KEEP_RULES = { + "all": lambda row: True, + "imperfect": lambda row: row["score"] < 1, + "failed": lambda row: row["status"] == "fail", + "none": lambda row: False, +} +RUN_LOGS = ("evaluate.log", "run_info.txt") +ALPAGYM_LOGS = ("config.json", "evaluation.yaml", "logs/evaluation.log") +WIZARD_KEPT = ( + "aggregate/results-summary.json", + "aggregate/metrics_unprocessed.parquet", + "eval-config.yaml", + "wizard-config.yaml", +) +CONTAINER_INFO_LINE = re.compile(rb"^[a-z0-9_-]+-[0-9]+-[0-9]+ +\| .*( INFO:|\[INFO\])") + + +def move(src, dst_dir): + if not src.exists(): + return + dst = dst_dir / src.name + if dst.exists(): + raise SystemExit(f"{dst} already exists, not overwriting it with {src}") + dst_dir.mkdir(exist_ok=True) + src.rename(dst) + + +def drop_container_info(log_path): + """-> bytes removed from log_path by dropping the INFO lines of AlpaSim's containers.""" + size_before = log_path.stat().st_size + filtered_path = log_path.with_suffix(".filtered") + with open(log_path, "rb") as source, open(filtered_path, "wb") as target: + for line in source: + if CONTAINER_INFO_LINE.match(line) is None: + target.write(line) + filtered_path.replace(log_path) + return size_before - log_path.stat().st_size + + +def remove_empty_dirs(root): + for directory in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True): + if not any(directory.iterdir()): + directory.rmdir() + if root.is_dir() and not any(root.iterdir()): + root.rmdir() + + +def sweep_wizard(wizard_dir, rows, keep_rule): + """Delete every file of the AlpaSim tree except the rollout.asl of the summary rows keep_rule keeps. + -> (files removed, bytes removed, rollout logs kept).""" + kept_logs = { + wizard_dir / "rollouts" / row["clipgt_id"] / row["rollout_id"] / "rollout.asl" for row in rows if keep_rule(row) + } + removed_count = removed_bytes = 0 + for path in sorted(wizard_dir.rglob("*")): + if not path.is_file() or path in kept_logs: + continue + removed_bytes += path.stat().st_size + path.unlink() + removed_count += 1 + remove_empty_dirs(wizard_dir) + return removed_count, removed_bytes, sum(path.is_file() for path in kept_logs) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("run_dir", help="output dir of 12_alpasim.sh") + ap.add_argument("--keep_rollout_logs", choices=tuple(KEEP_RULES), default="none") + ap.add_argument( + "--keep_container_info", action="store_true", help="keep the containers' INFO lines in evaluate.log" + ) + args = ap.parse_args() + run_dir = Path(args.run_dir) + alpagym_dir, alpasim_dir, logs_dir = run_dir / "alpagym", run_dir / "alpasim", run_dir / "logs" + + move(alpagym_dir / "obs_replays", run_dir) + for name in RUN_LOGS: + move(run_dir / name, logs_dir) + for name in ALPAGYM_LOGS: + move(alpagym_dir / name, logs_dir) + if (alpagym_dir / "alpasim" / "wizard_0").is_dir(): + alpasim_dir.mkdir(exist_ok=True) + move(alpagym_dir / "alpasim" / "wizard_0", alpasim_dir) + remove_empty_dirs(alpagym_dir) + if alpagym_dir.exists(): + print( + f"[tidy_alpasim_run] left in place, unknown: {sorted(str(p) for p in alpagym_dir.rglob('*') if p.is_file())}" + ) + + removed_count = removed_bytes = 0 + for stale in sorted(logs_dir.glob("runtime_worker_*.log")) + sorted( + logs_dir.glob("controller/alpasim_controller_*.csv") + ): + removed_bytes += stale.stat().st_size + stale.unlink() + removed_count += 1 + remove_empty_dirs(logs_dir / "controller") + evaluate_log = logs_dir / "evaluate.log" + if evaluate_log.is_file() and not args.keep_container_info: + removed_bytes += drop_container_info(evaluate_log) + + wizard_dir = alpasim_dir / "wizard_0" + summary_path = wizard_dir / "aggregate" / "results-summary.json" + if not summary_path.is_file(): + tidied = (alpasim_dir / "results-summary.json").is_file() + print(f"[tidy_alpasim_run] {'already tidied' if tidied else f'no {summary_path}: AlpaSim tree kept as is'}") + return + rows = json.loads(summary_path.read_text())["rollouts"] + for mp4 in sorted(wizard_dir.glob("rollouts/*/*/*.mp4")): + move(mp4, run_dir / "videos") + for name in WIZARD_KEPT: + move(wizard_dir / name, alpasim_dir) + wizard_count, wizard_bytes, kept_logs = sweep_wizard(wizard_dir, rows, KEEP_RULES[args.keep_rollout_logs]) + removed_count += wizard_count + removed_bytes += wizard_bytes + print( + f"[tidy_alpasim_run] removed {removed_count} files ({removed_bytes / 1e9:.2f} GB), kept {kept_logs} rollout logs " + f"({args.keep_rollout_logs}) -> {run_dir}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index e3cdf684e3..6d7f5d662e 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -36,9 +36,9 @@ OBS_REPLAY=1 # PufferDrive observation replay html per scene -> $OUT/obs CAM_W=640 # 16:9 render size; the teacher never sees the cameras CAM_H=360 SIM_TIMEOUT_S=3600 # per batch of PARALLEL scenes -ALPASIM_LOG_LEVEL=WARNING # AlpaSim services; INFO logs every sim event (~100 MB per 400 scenes in evaluate.log and the worker logs) +ALPASIM_LOG_LEVEL=WARNING # AlpaSim services; INFO also keeps the per-event INFO lines runtime workers and renderer print anyway (~110 MB per 400 scenes) PROMETHEUS=0 # AlpaSim performance telemetry (~80 MB per run); only alpagym_runtime.perf reads it -KEEP_ROLLOUT_LOGS=imperfect # rollout.asl (~36 MB per scene, offline analysis only) kept after scoring: all | imperfect (score < 1) | failed | none +KEEP_ROLLOUT_LOGS=none # rollout.asl (~36 MB per scene, offline re-scoring/failure analysis only): all | imperfect (score < 1) | failed | none (never written) export PD=/home/bjaeger/PufferDrive ALPAGYM_ROOT=/home/bjaeger/alpasim-training # held-out split Damiano evaluates on; empty = every converted scene that passed the checks (2149 on the cluster, 1584 of them train) @@ -137,10 +137,8 @@ BASEPORT=$((6000 + (${SLURM_JOB_ID:-$$} % 90) * 20)) LOADED_BASE=0 PULLED_NRE=0 cleanup() { - local compose_file - for compose_file in "$OUT/alpagym/alpasim/wizard_0/docker-compose.yaml" "$OUT/alpasim/wizard_0/docker-compose.yaml"; do - [ -f "$compose_file" ] && docker compose -f "$compose_file" down --volumes --remove-orphans > /dev/null 2>&1 - done + local compose_file=$OUT/alpagym/alpasim/wizard_0/docker-compose.yaml + [ -f "$compose_file" ] && docker compose -f "$compose_file" down --volumes --remove-orphans > /dev/null 2>&1 for container in $(docker ps -aq --filter "label=com.docker.compose.project=$COMPOSE_PROJECT_NAME"); do docker rm -f "$container" > /dev/null 2>&1 done @@ -163,7 +161,9 @@ fi # the teacher is an external driver, but the 8gpu topologies re-assert driver.skip=false and abort at startup [ "$PROMETHEUS" = "1" ] && PROMETHEUS_FLAG=true || PROMETHEUS_FLAG=false +[ "$KEEP_ROLLOUT_LOGS" = "none" ] && SAVE_ROLLOUT_LOG_FLAG=false || SAVE_ROLLOUT_LOG_FLAG=true OVERRIDES="wizard.baseport=$BASEPORT runtime.endpoints.driver.skip=true wizard.log_level=$ALPASIM_LOG_LEVEL wizard.prometheus.start_prometheus=$PROMETHEUS_FLAG" +OVERRIDES="$OVERRIDES runtime.simulation_config.save_rollout_log=$SAVE_ROLLOUT_LOG_FLAG" [ "$RENDER_VIDEO" = "1" ] && RENDER_VIDEO_FLAG=true || RENDER_VIDEO_FLAG=false [ "$OBS_REPLAY" = "1" ] && OBS_REPLAY_FLAG=true || OBS_REPLAY_FLAG=false echo "${#SCENES[@]} scenes, $TOPOLOGY with $PARALLEL in flight; full log: $OUT/evaluate.log (logs/ once the run is tidied)" @@ -227,49 +227,16 @@ if [ $EVAL_STATUS -ne 0 ]; then echo "AlpaSim evaluation failed (exit $EVAL_STATUS); last host lines of $OUT/evaluate.log:" grep -vE '^[a-z0-9_-]+-[0-9]+-[0-9]+ +\|' "$OUT/evaluate.log" | tail -n 30 fi -# tidy: results.txt, obs_replays/, logs/ and the AlpaSim wizard tree (alpasim/, which AlpaSim's own tooling expects as is) -# side by side; the alpagym run dir dissolves into them -mkdir -p "$OUT/logs" -[ -d "$OUT/alpagym/alpasim" ] && mv "$OUT/alpagym/alpasim" "$OUT/alpasim" -[ -d "$OUT/alpagym/obs_replays" ] && mv "$OUT/alpagym/obs_replays" "$OUT/obs_replays" -for f in "$OUT/evaluate.log" "$OUT/run_info.txt" "$OUT/alpagym/config.json" "$OUT/alpagym/evaluation.yaml" "$OUT/alpagym/logs/evaluation.log"; do - [ -f "$f" ] && mv "$f" "$OUT/logs/" -done -WIZARD=$OUT/alpasim/wizard_0 -[ -d "$WIZARD/txt-logs" ] && mv "$WIZARD"/txt-logs/* "$OUT/logs/" 2>/dev/null && rmdir "$WIZARD/txt-logs" -[ -d "$WIZARD/controller" ] && mv "$WIZARD/controller" "$OUT/logs/controller" -rmdir "$OUT/alpagym/logs" "$OUT/alpagym" 2>/dev/null - -# rollout.asl is only read offline (re-scoring, videos, obs replay rebuilds, failure analysis): keep it for the scenes worth a look -SUMMARY=$WIZARD/aggregate/results-summary.json -if [ "$KEEP_ROLLOUT_LOGS" = "all" ]; then - : -elif [ -f "$SUMMARY" ]; then - "$ALPAGYM_PY" - "$SUMMARY" "$WIZARD/rollouts" "$KEEP_ROLLOUT_LOGS" <<'EOF' -import json, sys -from pathlib import Path -summary, rollouts, keep = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3] -keep_rule = {"imperfect": lambda r: r["score"] < 1, "failed": lambda r: r["status"] == "fail", "none": lambda r: False}[keep] -removed = freed = kept = 0 -for row in json.loads(summary.read_text())["rollouts"]: - log = rollouts / row["clipgt_id"] / row["rollout_id"] / "rollout.asl" - if not log.exists(): - continue - if keep_rule(row): - kept += 1 - continue - freed += log.stat().st_size - log.unlink() - removed += 1 -print(f"rollout logs: kept {kept} ({keep}), removed {removed} ({freed / 1e9:.1f} GB)") -EOF -else - echo "no $SUMMARY: keeping every rollout log" -fi +# the stack is done: take it down while docker-compose.yaml exists, the tidy deletes it +cleanup +# results.txt, obs_replays/, logs/, alpasim/ (+ videos/); container INFO lines stay in evaluate.log for debugging and failed runs +TIDY_FLAGS="" +{ [ "$ALPASIM_LOG_LEVEL" = "INFO" ] || [ $EVAL_STATUS -ne 0 ]; } && TIDY_FLAGS="--keep_container_info" +"$ALPAGYM_PY" "$PD/scripts/eval/tidy_alpasim_run.py" "$OUT" --keep_rollout_logs "$KEEP_ROLLOUT_LOGS" $TIDY_FLAGS || echo "tidy failed" # score-named pages with the AlpaSim score panel + index.html gallery, as 9_nuPlan.sh and 11_carla_longest6_v2.sh -if [ -d "$OUT/obs_replays" ] && [ -f "$SUMMARY" ]; then - "$ALPAGYM_PY" "$PD/scripts/eval/render_alpasim_obs_html.py" "$OUT" || echo "obs replay gallery failed" +if [ -d "$OUT/obs_replays" ] && [ -f "$OUT/alpasim/results-summary.json" ]; then + "$ALPAGYM_PY" "$PD/scripts/eval/render_alpasim_obs_html.py" "$OUT" --prune || echo "obs replay gallery failed" fi -echo "results -> $OUT/results.txt; obs replays -> $OUT/obs_replays/index.html; logs -> $OUT/logs; AlpaSim tree (per-scene metrics, aggregate, kept rollout logs) -> $OUT/alpasim" +echo "results -> $OUT/results.txt; obs replays -> $OUT/obs_replays/index.html; logs -> $OUT/logs; per-scene scores + per-step metrics -> $OUT/alpasim" echo "Done -> $OUT" exit $EVAL_STATUS From 7700804eae582341794e6cf702dc408049fa1a57 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Mon, 12 Oct 2026 19:31:46 +0200 Subject: [PATCH 048/123] change stop signs in alpasim maps --- pufferlib/config/puffer_drive.yaml | 2 + pufferlib/config_schema.py | 1 + pufferlib/ocean/drive/binding.c | 5 ++ pufferlib/ocean/drive/drive.h | 27 +++++-- pufferlib/ocean/drive/drive.py | 5 ++ scripts/kesai/12_alpasim.sh | 5 +- tests/drive/test_drive_observations_rewards.c | 70 +++++++++++++++++++ 7 files changed, 110 insertions(+), 5 deletions(-) diff --git a/pufferlib/config/puffer_drive.yaml b/pufferlib/config/puffer_drive.yaml index a6c28b1c08..d40451c32c 100644 --- a/pufferlib/config/puffer_drive.yaml +++ b/pufferlib/config/puffer_drive.yaml @@ -221,6 +221,8 @@ env: obs_norm_z_m: 10.0 # --- Eval-only --- eval_perceived_size_margin_m: 0.1 + # Eval-only: partners longer than this (m) are observed as ceil(length / this) touching pieces (training max 7.0). 0 disables. + obs_partner_split_length_m: 0.0 # Eval-only: while standing exactly still, |longitudinal jerk| below this (m/s^3) is zeroed # so leftover mean-action jerk cannot creep the car. 0 disables. eval_standstill_jerk_deadband_mps3: 0.0 diff --git a/pufferlib/config_schema.py b/pufferlib/config_schema.py index 67e1a31222..9dcd8da59a 100644 --- a/pufferlib/config_schema.py +++ b/pufferlib/config_schema.py @@ -346,6 +346,7 @@ class DriveEnvConfig: obs_norm_road_seg_width_m: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) obs_norm_z_m: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) eval_perceived_size_margin_m: float = _constrained_field(NONNEGATIVE_NUMBER_CONSTRAINT) + obs_partner_split_length_m: float = _constrained_field(NONNEGATIVE_NUMBER_CONSTRAINT) eval_standstill_jerk_deadband_mps3: float = _constrained_field(NONNEGATIVE_NUMBER_CONSTRAINT) obs_range_road_front_m: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) obs_range_road_behind_m: float = _constrained_field(POSITIVE_NUMBER_CONSTRAINT) diff --git a/pufferlib/ocean/drive/binding.c b/pufferlib/ocean/drive/binding.c index 6c56e7d4a2..6b84ca977a 100644 --- a/pufferlib/ocean/drive/binding.c +++ b/pufferlib/ocean/drive/binding.c @@ -2148,6 +2148,11 @@ static int my_init(Env *env, PyObject *args, PyObject *kwargs) { env->obs_norm_road_seg_width_m = (float) unpack(kwargs, "obs_norm_road_seg_width_m"); env->obs_norm_z_m = (float) unpack(kwargs, "obs_norm_z_m"); env->eval_perceived_size_margin_m = (float) unpack(kwargs, "eval_perceived_size_margin_m"); + env->obs_partner_split_length_m = (float) unpack(kwargs, "obs_partner_split_length_m"); + if (!(env->obs_partner_split_length_m >= 0.0f) || !isfinite(env->obs_partner_split_length_m)) { + PyErr_SetString(PyExc_ValueError, "obs_partner_split_length_m must be finite and >= 0"); + return -1; + } env->eval_standstill_jerk_deadband_mps3 = (float) unpack(kwargs, "eval_standstill_jerk_deadband_mps3"); env->obs_range_traffic_control_m = (float) unpack(kwargs, "obs_range_traffic_control_m"); env->obs_range_partner_m = (float) unpack(kwargs, "obs_range_partner_m"); diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index b70dfd00ad..937c452c37 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -303,6 +303,7 @@ struct Drive { float obs_norm_road_seg_width_m; float obs_norm_z_m; float eval_perceived_size_margin_m; + float obs_partner_split_length_m; // 0 disables; longer partners are observed as ceil(length / this) equal pieces int cosim_eval_semantics; // co-sim: apply the eval-only obs/action tweaks without native eval batching float eval_standstill_jerk_deadband_mps3; // 0 disables; eval-only standstill deadband on j_long float obs_range_traffic_control_m; @@ -4878,7 +4879,8 @@ static int write_partner_obs( } } - for (int j = 0; j < partners_to_write; j++) { + int row_features = partner_feature_count(env); + for (int j = 0; j < partners_to_write && partners_written < env->obs_slots_partners_n; j++) { Agent *other = &env->agents[nearby_agents[j].index]; float rel_x, rel_y, rel_heading_x, rel_heading_y, rel_vx, rel_vy; project_point_to_ego_frame(ego, other->sim_x, other->sim_y, &rel_x, &rel_y); @@ -4892,10 +4894,18 @@ static int write_partner_obs( &rel_vx, &rel_vy); project_vector_to_ego_frame(ego, other->cos_heading, other->sin_heading, &rel_heading_x, &rel_heading_y); - obs[obs_idx++] = rel_x / env->obs_norm_xy_offset_m; - obs[obs_idx++] = rel_y / env->obs_norm_xy_offset_m; + // float count: agent lengths are not range-checked at load, an int cast could overflow + float piece_count = 1.0f; + if (env->obs_partner_split_length_m > 0.0f && other->sim_length > env->obs_partner_split_length_m) { + piece_count = ceilf(other->sim_length / env->obs_partner_split_length_m); + } + float piece_length_m = other->sim_length / piece_count; + float rear_piece_offset_m = 0.5f * (piece_length_m - other->sim_length); + int row_start = obs_idx; + obs[obs_idx++] = (rel_x + rear_piece_offset_m * rel_heading_x) / env->obs_norm_xy_offset_m; + obs[obs_idx++] = (rel_y + rear_piece_offset_m * rel_heading_y) / env->obs_norm_xy_offset_m; obs[obs_idx++] = nearby_agents[j].dz / env->obs_norm_z_m; - obs[obs_idx++] = other->sim_length / env->obs_norm_veh_length_m; + obs[obs_idx++] = piece_length_m / env->obs_norm_veh_length_m; obs[obs_idx++] = other->sim_width / env->obs_norm_veh_width_m; obs[obs_idx++] = rel_heading_x; obs[obs_idx++] = rel_heading_y; @@ -4906,6 +4916,15 @@ static int write_partner_obs( obs[obs_idx++] = rel_vy / env->obs_norm_speed_mps; } partners_written++; + for (int piece_idx = 1; (float) piece_idx < piece_count && partners_written < env->obs_slots_partners_n; + piece_idx++) { + float piece_offset_m = rear_piece_offset_m + (float) piece_idx * piece_length_m; + memcpy(&obs[obs_idx], &obs[row_start], row_features * sizeof(float)); + obs[obs_idx] = (rel_x + piece_offset_m * rel_heading_x) / env->obs_norm_xy_offset_m; + obs[obs_idx + 1] = (rel_y + piece_offset_m * rel_heading_y) / env->obs_norm_xy_offset_m; + obs_idx += row_features; + partners_written++; + } } *partner_count = partners_written; diff --git a/pufferlib/ocean/drive/drive.py b/pufferlib/ocean/drive/drive.py index 039b861682..12aa08f8c1 100644 --- a/pufferlib/ocean/drive/drive.py +++ b/pufferlib/ocean/drive/drive.py @@ -137,6 +137,7 @@ def __init__( obs_norm_road_seg_width_m=5.0, obs_norm_z_m=10.0, eval_perceived_size_margin_m=0.1, + obs_partner_split_length_m=0.0, eval_standstill_jerk_deadband_mps3=0.0, obs_range_traffic_control_m=100.0, obs_range_partner_m=100.0, @@ -325,6 +326,9 @@ def __init__( self.obs_norm_road_seg_width_m = float(obs_norm_road_seg_width_m) self.obs_norm_z_m = float(obs_norm_z_m) self.eval_perceived_size_margin_m = float(eval_perceived_size_margin_m) + self.obs_partner_split_length_m = float(obs_partner_split_length_m) + if not np.isfinite(self.obs_partner_split_length_m) or self.obs_partner_split_length_m < 0.0: + raise ValueError(f"obs_partner_split_length_m must be finite and >= 0. Got: {obs_partner_split_length_m}") self.eval_standstill_jerk_deadband_mps3 = float(eval_standstill_jerk_deadband_mps3) if self.eval_standstill_jerk_deadband_mps3 < 0: raise ValueError( @@ -638,6 +642,7 @@ def _env_init_kwargs(self, map_file, max_agents): "obs_norm_road_seg_width_m": self.obs_norm_road_seg_width_m, "obs_norm_z_m": self.obs_norm_z_m, "eval_perceived_size_margin_m": self.eval_perceived_size_margin_m, + "obs_partner_split_length_m": self.obs_partner_split_length_m, "eval_standstill_jerk_deadband_mps3": self.eval_standstill_jerk_deadband_mps3, "obs_range_traffic_control_m": self.obs_range_traffic_control_m, "obs_range_partner_m": self.obs_range_partner_m, diff --git a/scripts/kesai/12_alpasim.sh b/scripts/kesai/12_alpasim.sh index 6d7f5d662e..0483f9966a 100644 --- a/scripts/kesai/12_alpasim.sh +++ b/scripts/kesai/12_alpasim.sh @@ -31,6 +31,7 @@ CKPT=$RUN_DIR/final_model.pt GOAL_MODE=gt_time:5 # gt_time: | gt | route GOAL_RADIUS_M=2 # goal arrival radius Damiano's reference evals use; the teacher's own gt_time default is 6 VRU_SIZE_PAD_M=0.8 # added to the length and width of every pedestrian and two-wheeler box the teacher sees; 0 = true boxes +PARTNER_SPLIT_LENGTH_M=7 # actors longer than this (training max 7 m) reach the teacher as ceil(length / this) touching pieces; 0 = one box RENDER_VIDEO=0 # one mp4 per scene; 1 also streams the teacher BEV and makes every policy step ~5x slower OBS_REPLAY=1 # PufferDrive observation replay html per scene -> $OUT/obs_replays CAM_W=640 # 16:9 render size; the teacher never sees the cameras @@ -119,11 +120,12 @@ EOF [ ${#SCENES[@]} -gt 0 ] || { echo "no scenes to evaluate"; exit 1; } # results live in the model's own eval folder, next to the PufferDrive benchmark evals -OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}_r${GOAL_RADIUS_M}$([ "$VRU_SIZE_PAD_M" = "0" ] || echo "_vrupad$VRU_SIZE_PAD_M")_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} +OUT=$RUN_DIR/eval/alpasim_${GOAL_MODE//:/}_r${GOAL_RADIUS_M}$([ "$VRU_SIZE_PAD_M" = "0" ] || echo "_vrupad$VRU_SIZE_PAD_M")$([ "$PARTNER_SPLIT_LENGTH_M" = "0" ] || echo "_split$PARTNER_SPLIT_LENGTH_M")_$(basename "$CKPT" .pt)_$(date +%Y%m%d_%H%M%S)_${SLURM_JOB_ID:-local} mkdir -p "$OUT" { echo "checkpoint $CKPT"; echo "scenes ${#SCENES[@]}"; echo "topology $TOPOLOGY parallel $PARALLEL goal_mode $GOAL_MODE goal_radius_m $GOAL_RADIUS_M" echo "vru_size_pad_m $VRU_SIZE_PAD_M" + echo "partner_split_length_m $PARTNER_SPLIT_LENGTH_M" for repo in "$PD" "$ALPAGYM_ROOT" "$ALPASIM_ROOT"; do echo "git $repo $(git -C "$repo" rev-parse --short HEAD 2>/dev/null) $(git -C "$repo" diff --quiet 2>/dev/null || echo dirty)" done @@ -175,6 +177,7 @@ cd "$ALPAGYM_ROOT" || exit 1 --teacher_goal_mode "$GOAL_MODE" \ --teacher_goal_radius "$GOAL_RADIUS_M" \ --teacher_vru_size_pad_m "$VRU_SIZE_PAD_M" \ + --teacher_partner_split_length_m "$PARTNER_SPLIT_LENGTH_M" \ --render_video "$RENDER_VIDEO_FLAG" \ --teacher_obs_replay "$OBS_REPLAY_FLAG" \ --local_scene_dir "$USDZ_DIR" \ diff --git a/tests/drive/test_drive_observations_rewards.c b/tests/drive/test_drive_observations_rewards.c index 09a7c7711f..9c4e656952 100644 --- a/tests/drive/test_drive_observations_rewards.c +++ b/tests/drive/test_drive_observations_rewards.c @@ -300,6 +300,75 @@ static int test_partner_obs_relative_velocity(void) { return 0; } +static int test_partner_obs_split_long_partner(void) { + // Ego heading +x; 4 m car 10 m behind; 13 m truck 20 m ahead heading +y -> split at 7 m into 6.5 m pieces at y -/+3.25. + Drive env = {0}; + env.obs_norm_speed_mps = 10.0f; + env.obs_norm_xy_offset_m = 100.0f; + env.obs_norm_z_m = 10.0f; + env.obs_norm_veh_length_m = 10.0f; + env.obs_norm_veh_width_m = 5.0f; + env.obs_range_partner_m = 100.0f; + env.obs_partner_relative_velocity = 1; + env.num_agents = 3; + env.active_agent_count = 3; + int active[3] = {0, 1, 2}; + env.active_agent_indices = active; + Agent agents[3]; + agents[0] = drive_test_agent(0.0f, 0.0f, 0.0f); + agents[1] = drive_test_agent(20.0f, 0.0f, (float) M_PI / 2.0f); + agents[1].sim_length = 13.0f; + agents[1].sim_vy = 5.0f; + agents[2] = drive_test_agent(-10.0f, 0.0f, 0.0f); + for (int agent_idx = 0; agent_idx < 3; agent_idx++) { + update_agent_speed(&agents[agent_idx]); + } + env.agents = agents; + PartnerScanEntry partner_scan[3] = { + {agents[0].sim_x, agents[0].sim_y, agents[0].sim_z, 0}, + {agents[1].sim_x, agents[1].sim_y, agents[1].sim_z, 1}, + {agents[2].sim_x, agents[2].sim_y, agents[2].sim_z, 2}, + }; + const int row_features = PARTNER_FEATURES + PARTNER_RELATIVE_VELOCITY_FEATURES; + float obs[3 * (PARTNER_FEATURES + PARTNER_RELATIVE_VELOCITY_FEATURES)]; + int partner_count = 0; + + env.obs_slots_partners_n = 3; + env.obs_partner_split_length_m = 0.0f; + memset(obs, 0, sizeof(obs)); + int end_idx = write_partner_obs(&env, &agents[0], 0, partner_scan, obs, 0, &partner_count); + EXPECT_EQ_INT(partner_count, 2); + EXPECT_EQ_INT(end_idx, 3 * row_features); + EXPECT_NEAR(obs[0], -0.1f, 1e-6f); + EXPECT_NEAR(obs[row_features], 0.2f, 1e-6f); + EXPECT_NEAR(obs[row_features + 1], 0.0f, 1e-6f); + EXPECT_NEAR(obs[row_features + 3], 1.3f, 1e-6f); + + env.obs_partner_split_length_m = 7.0f; + memset(obs, 0, sizeof(obs)); + end_idx = write_partner_obs(&env, &agents[0], 0, partner_scan, obs, 0, &partner_count); + EXPECT_EQ_INT(partner_count, 3); + EXPECT_EQ_INT(end_idx, 3 * row_features); + EXPECT_NEAR(obs[0], -0.1f, 1e-6f); + EXPECT_NEAR(obs[3], 0.4f, 1e-6f); + EXPECT_NEAR(obs[row_features], 0.2f, 1e-6f); + EXPECT_NEAR(obs[row_features + 1], -0.0325f, 1e-6f); + EXPECT_NEAR(obs[row_features + 3], 0.65f, 1e-6f); + EXPECT_NEAR(obs[2 * row_features], 0.2f, 1e-6f); + EXPECT_NEAR(obs[2 * row_features + 1], 0.0325f, 1e-6f); + EXPECT_EQ_INT( + memcmp(&obs[row_features + 2], &obs[2 * row_features + 2], (row_features - 2) * sizeof(float)), 0); + + env.obs_slots_partners_n = 2; + memset(obs, 0, sizeof(obs)); + end_idx = write_partner_obs(&env, &agents[0], 0, partner_scan, obs, 0, &partner_count); + EXPECT_EQ_INT(partner_count, 2); + EXPECT_EQ_INT(end_idx, 2 * row_features); + EXPECT_NEAR(obs[0], -0.1f, 1e-6f); + EXPECT_NEAR(obs[row_features + 1], -0.0325f, 1e-6f); + return 0; +} + static int test_reward_lane_align_wrong_way(void) { Drive env; Agent agent; @@ -380,6 +449,7 @@ int main(void) { RUN_TEST(test_reward_goal_speed_gating); RUN_TEST(test_reward_coef_goal_speed_pinned); RUN_TEST(test_partner_obs_relative_velocity); + RUN_TEST(test_partner_obs_split_long_partner); RUN_TEST(test_reward_lane_align_wrong_way); return test_summary(failures); } From 34551d3869ee5cd4181118ec57939551ab3042f8 Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Tue, 13 Oct 2026 13:39:01 +0200 Subject: [PATCH 049/123] fix compiler warning --- pufferlib/ocean/drive/drive.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index 937c452c37..0aaad3df8a 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -5337,7 +5337,7 @@ static void move_dynamics(Drive *env, int action_idx, int agent_idx) { int j_lat_idx = action_val % num_lat; j_long = JERK_LONG[j_long_idx]; j_lat = JERK_LAT[j_lat_idx]; - } else if (env->action_type == ACTION_TYPE_CONTINUOUS) { + } else { float (*action_array_f)[2] = (float (*)[2]) env->actions; // Asymmetric scaling for longitudinal jerk to match discrete action space // Discrete: JERK_LONG = [-15, -4, 0, 4] (more braking than acceleration) From 19f635da49b952e3a2691ffeac0b6ef99398f68e Mon Sep 17 00:00:00 2001 From: Bernhard Jaeger Date: Tue, 13 Oct 2026 17:34:57 +0200 Subject: [PATCH 050/123] format only --- .../cosim/carla/lb1/leaderboard_agent.py | 22 +++++++++++++++---- .../ocean/cosim/carla/leaderboard_agent.py | 12 ++++++++-- pufferlib/ocean/drive/datatypes.h | 3 ++- pufferlib/ocean/drive/drive.h | 2 +- pufferlib/ocean/drive/map_data.h | 2 +- pufferlib/ocean/env_binding.h | 18 ++++++++++----- scripts/attribute_training_infractions.py | 17 +++++++++++--- scripts/mfu_bench.py | 11 +++++++++- scripts/visualize_map_bins.py | 16 ++++++++++---- tests/drive/test_drive_observations_rewards.c | 6 ++--- 10 files changed, 84 insertions(+), 25 deletions(-) diff --git a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py index ae3d34959f..4bdcc5366c 100644 --- a/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/lb1/leaderboard_agent.py @@ -105,7 +105,13 @@ def partner_row(actor): is_walker = 1.0 if "walker" in actor.type_id else 0.0 is_two_wheeler = 1.0 if actor.attributes.get("number_of_wheels") == "2" else 0.0 return actor_state_row(actor) + [ - box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, is_two_wheeler, actor.id + box.location.z, + box.extent.x, + box.extent.y, + box.extent.z, + is_walker, + is_two_wheeler, + actor.id, ] @@ -430,7 +436,9 @@ def _init_on_first_step(self): for w in self.vehicle.get_physics_control().wheels ] front_left, rear_left = wheels[0][1:], wheels[2][1:] - half_wheelbase_m = 0.5 * math.sqrt(sum((a - b) ** 2 for a, b in zip(front_left, rear_left))) / WHEEL_POSITION_CM_PER_M + half_wheelbase_m = ( + 0.5 * math.sqrt(sum((a - b) ** 2 for a, b in zip(front_left, rear_left))) / WHEEL_POSITION_CM_PER_M + ) body_rear_m, body_front_m = body.location.x - body.extent.x, body.location.x + body.extent.x assert body_rear_m < -half_wheelbase_m < half_wheelbase_m < body_front_m self.body_chord_offsets_m = (body_rear_m, -half_wheelbase_m, 0.0, half_wheelbase_m, body_front_m) @@ -537,7 +545,11 @@ def _teleport_carla_ego(self, motion): self._previous_body_z = z self._previous_lane_center_z = carla_z if wp is None else wp.transform.location.z self._previous_pitch_rad = pitch_rad - forward = (math.cos(yaw_rad) * math.cos(pitch_rad), math.sin(yaw_rad) * math.cos(pitch_rad), math.sin(pitch_rad)) + forward = ( + math.cos(yaw_rad) * math.cos(pitch_rad), + math.sin(yaw_rad) * math.cos(pitch_rad), + math.sin(pitch_rad), + ) # Zero momentum before the teleport: CARLA's collision resolver reacts violently to a # physics body carrying velocity into a new pose (carla issue #8076). # One ordered batch: the server reorders async actor commands, and a late zero left the ego reading 0 m/s on 3 % of ticks @@ -548,7 +560,9 @@ def _teleport_carla_ego(self, motion): carla.command.ApplyTargetAngularVelocity(self.vehicle.id, zero), carla.command.ApplyTransform( self.vehicle.id, - carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=0.0)), + carla.Transform( + carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=0.0) + ), ), carla.command.ApplyTargetVelocity( self.vehicle.id, carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2]) diff --git a/pufferlib/ocean/cosim/carla/leaderboard_agent.py b/pufferlib/ocean/cosim/carla/leaderboard_agent.py index 06f1df48cc..75905a15ea 100644 --- a/pufferlib/ocean/cosim/carla/leaderboard_agent.py +++ b/pufferlib/ocean/cosim/carla/leaderboard_agent.py @@ -113,7 +113,13 @@ def partner_row(actor): is_walker = 1.0 if "walker" in actor.type_id else 0.0 is_two_wheeler = 1.0 if actor.attributes.get("number_of_wheels") == "2" else 0.0 return actor_state_row(actor) + [ - box.location.z, box.extent.x, box.extent.y, box.extent.z, is_walker, is_two_wheeler, actor.id + box.location.z, + box.extent.x, + box.extent.y, + box.extent.z, + is_walker, + is_two_wheeler, + actor.id, ] @@ -268,7 +274,9 @@ def _teleport_carla_ego(self, motion): carla.command.ApplyTargetAngularVelocity(self.vehicle.id, zero), carla.command.ApplyTransform( self.vehicle.id, - carla.Transform(carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=roll_deg)), + carla.Transform( + carla.Location(x=x, y=y, z=z), carla.Rotation(pitch=pitch_deg, yaw=yaw_deg, roll=roll_deg) + ), ), carla.command.ApplyTargetVelocity( self.vehicle.id, carla.Vector3D(x=speed * forward[0], y=speed * forward[1], z=speed * forward[2]) diff --git a/pufferlib/ocean/drive/datatypes.h b/pufferlib/ocean/drive/datatypes.h index ee1f973afb..c2f74413f4 100644 --- a/pufferlib/ocean/drive/datatypes.h +++ b/pufferlib/ocean/drive/datatypes.h @@ -134,7 +134,8 @@ struct Agent { float reward_coefs[NUM_REWARD_COEFS]; int phantom_braking_counter; // >0 means currently phantom braking - int partner_blindness_counter; // >0 means partner visibility is restricted to a forward corridor (ego width + margin) + int partner_blindness_counter; // >0 means partner visibility is restricted to a forward corridor (ego width + + // margin) unsigned char is_blind_partner; // episode-level flag: agent may restrict partner visibility unsigned char is_phantom_braker; // episode-level flag: agent may phantom-brake }; diff --git a/pufferlib/ocean/drive/drive.h b/pufferlib/ocean/drive/drive.h index 0aaad3df8a..be671aec5b 100644 --- a/pufferlib/ocean/drive/drive.h +++ b/pufferlib/ocean/drive/drive.h @@ -304,7 +304,7 @@ struct Drive { float obs_norm_z_m; float eval_perceived_size_margin_m; float obs_partner_split_length_m; // 0 disables; longer partners are observed as ceil(length / this) equal pieces - int cosim_eval_semantics; // co-sim: apply the eval-only obs/action tweaks without native eval batching + int cosim_eval_semantics; // co-sim: apply the eval-only obs/action tweaks without native eval batching float eval_standstill_jerk_deadband_mps3; // 0 disables; eval-only standstill deadband on j_long float obs_range_traffic_control_m; float obs_range_partner_m; diff --git a/pufferlib/ocean/drive/map_data.h b/pufferlib/ocean/drive/map_data.h index f079e77935..a5be75089a 100644 --- a/pufferlib/ocean/drive/map_data.h +++ b/pufferlib/ocean/drive/map_data.h @@ -106,7 +106,7 @@ static void merge_strided_observation_segments(RoadMapElement *road, int obs_str for (int segment_idx = 0; segment_idx < segment_count; segment_idx++) { if (segment_idx > run_start_idx && fabsf(normalize_heading(road->headings[segment_idx] - road->headings[run_start_idx])) - > OBS_STRIDE_HEADING_THRESHOLD) { + > OBS_STRIDE_HEADING_THRESHOLD) { close_observation_segment_run(road, run_start_idx, segment_idx - 1); run_start_idx = segment_idx; } diff --git a/pufferlib/ocean/env_binding.h b/pufferlib/ocean/env_binding.h index 4f6be5168b..37b0cacf68 100644 --- a/pufferlib/ocean/env_binding.h +++ b/pufferlib/ocean/env_binding.h @@ -1005,7 +1005,7 @@ static PyObject *vec_set_agent_states(PyObject *self, PyObject *args) { PyObject *yaw_rate_arr = PyTuple_GetItem(args, 8); PyObject *accel_long_arr = PyTuple_GetItem(args, 9); PyObject *seconds_stopped_arr = PyTuple_GetItem(args, 10); // None keeps c_step's accumulation - PyObject *steering_arr = PyTuple_GetItem(args, 11); // None derives steering from yaw_rate + PyObject *steering_arr = PyTuple_GetItem(args, 11); // None derives steering from yaw_rate if (!PyArray_Check(idx_arr) || !PyArray_Check(x_arr) || !PyArray_Check(y_arr) || !PyArray_Check(z_arr) || !PyArray_Check(heading_arr) || !PyArray_Check(vx_arr) || !PyArray_Check(vy_arr) || !PyArray_Check(yaw_rate_arr) || !PyArray_Check(accel_long_arr)) { @@ -1033,8 +1033,17 @@ static PyObject *vec_set_agent_states(PyObject *self, PyObject *args) { = seconds_stopped_arr == Py_None ? NULL : (float *) PyArray_DATA((PyArrayObject *) seconds_stopped_arr); float *steering = steering_arr == Py_None ? NULL : (float *) PyArray_DATA((PyArrayObject *) steering_arr); int count = (int) PyArray_SIZE((PyArrayObject *) idx_arr); - PyObject *value_arrs[10] = { - x_arr, y_arr, z_arr, heading_arr, vx_arr, vy_arr, yaw_rate_arr, accel_long_arr, seconds_stopped_arr, steering_arr}; + PyObject *value_arrs[10] + = {x_arr, + y_arr, + z_arr, + heading_arr, + vx_arr, + vy_arr, + yaw_rate_arr, + accel_long_arr, + seconds_stopped_arr, + steering_arr}; for (int k = 0; k < 10; k++) { if (value_arrs[k] == Py_None) { continue; @@ -1196,8 +1205,7 @@ static PyObject *vec_set_stop_signs(PyObject *self, PyObject *args) { static PyObject *vec_set_traffic_light_lines(PyObject *self, PyObject *args) { if (PyTuple_Size(args) != 3) { - PyErr_SetString( - PyExc_TypeError, "vec_set_traffic_light_lines requires 3 arguments (vec_env, indices, lines)"); + PyErr_SetString(PyExc_TypeError, "vec_set_traffic_light_lines requires 3 arguments (vec_env, indices, lines)"); return NULL; } VecEnv *vec = unpack_vecenv(args); diff --git a/scripts/attribute_training_infractions.py b/scripts/attribute_training_infractions.py index f79a825d49..7b767f5481 100644 --- a/scripts/attribute_training_infractions.py +++ b/scripts/attribute_training_infractions.py @@ -3,6 +3,7 @@ usage: python scripts/attribute_training_infractions.py /renders//replays [dt] (needs eval.keep_zlib_replays=true on the render) """ + import glob import os import sys @@ -102,7 +103,15 @@ def analyze(path, dt): if kind == "collision": tag, detail = classify_collision(f32, i32, m, t, a) elif kind == "offroad": - tag = "offroad within spawn frames" if t < SPAWN_FRAMES else ("offroad at creep speed (<1 m/s)" if speed < 1.0 else ("offroad slow (1-4 m/s)" if speed < 4.0 else "offroad fast (>4 m/s)")) + tag = ( + "offroad within spawn frames" + if t < SPAWN_FRAMES + else ( + "offroad at creep speed (<1 m/s)" + if speed < 1.0 + else ("offroad slow (1-4 m/s)" if speed < 4.0 else "offroad fast (>4 m/s)") + ) + ) detail = f"{speed:.1f} m/s, lane dist {m[tp, a, LANE_DIST_IDX]:.1f} m" else: tag = kind @@ -127,8 +136,10 @@ def main(): if not perturbed: totals[(kind, tag)] += 1 n_inf = sum(totals.values()) - print(f"\n== totals over {len(paths)} episodes, {logged_total} logged agent-episodes, {n_inf} first infractions" - f" ({n_inf / max(logged_total, 1):.3f} per agent-episode)") + print( + f"\n== totals over {len(paths)} episodes, {logged_total} logged agent-episodes, {n_inf} first infractions" + f" ({n_inf / max(logged_total, 1):.3f} per agent-episode)" + ) for (kind, tag), n in sorted(totals.items(), key=lambda kv: -kv[1]): print(f" {n:3d} {kind:9s} {tag}") diff --git a/scripts/mfu_bench.py b/scripts/mfu_bench.py index 90828f05f6..90b82d73ca 100644 --- a/scripts/mfu_bench.py +++ b/scripts/mfu_bench.py @@ -26,7 +26,16 @@ ENV_NAME = "puffer_drive" RECOMPILE_EPOCH_FACTOR = 1.25 -PROFILE_SECTIONS = ("env", "eval_copy", "eval_forward", "eval_misc", "train_copy", "train_forward", "learn", "train_misc") +PROFILE_SECTIONS = ( + "env", + "eval_copy", + "eval_forward", + "eval_misc", + "train_copy", + "train_forward", + "learn", + "train_misc", +) SLOT_ENCODER_COUNTS = { "lane_encoder": "obs_slots_lane_kept", "boundary_encoder": "obs_slots_boundary_kept", diff --git a/scripts/visualize_map_bins.py b/scripts/visualize_map_bins.py index 29d7e7b3a4..e5ef9be56d 100644 --- a/scripts/visualize_map_bins.py +++ b/scripts/visualize_map_bins.py @@ -27,7 +27,11 @@ def _polyline(road): def _edges(data): - return [{"id": int(r["id"]), "type": int(r["type"]), "pts": _polyline(r)} for r in data["roads"] if r["type"] in ROAD_EDGE_TYPES] + return [ + {"id": int(r["id"]), "type": int(r["type"]), "pts": _polyline(r)} + for r in data["roads"] + if r["type"] in ROAD_EDGE_TYPES + ] def collect_map(bin_file: pathlib.Path, compare_file: pathlib.Path | None) -> dict: @@ -375,7 +379,7 @@ def build_html(maps: list[dict], meta: dict) -> str:
-
drag to pan · wheel to zoom · click to drop a mark (1/2/3 category) · u undo · c copy report · bins: {meta['bin_dir']}{compare_note}
+
drag to pan · wheel to zoom · click to drop a mark (1/2/3 category) · u undo · c copy report · bins: {meta["bin_dir"]}{compare_note}