diff --git a/Docs/CHANGELOG.md b/Docs/CHANGELOG.md index c8518ff5f..8c209f5e8 100644 --- a/Docs/CHANGELOG.md +++ b/Docs/CHANGELOG.md @@ -1,4 +1,5 @@ ## Table of Contents +* [Unreleased — CARLA 0.10.0 / UE5 compatibility](#unreleased--carla-0100--ue5-compatibility) * [CARLA ScenarioRunner 0.9.16](#carla-scenariorunner-0916) * [CARLA ScenarioRunner 0.9.15](#carla-scenariorunner-0915) * [CARLA ScenarioRunner 0.9.13](#carla-scenariorunner-0913) @@ -13,6 +14,35 @@ * [CARLA ScenarioRunner 0.9.5](#carla-scenariorunner-095) * [CARLA ScenarioRunner 0.9.2](#carla-scenariorunner-092) +## Unreleased — CARLA 0.10.0 / UE5 compatibility + +This release extends ScenarioRunner to run against the **CARLA 0.10.0** server (UE5 / Chaos physics) while keeping full compatibility with **CARLA 0.9.x** (UE4 / PhysX). The default supported map on UE5 is `Town10HD_Opt`; other UE5 maps remain reachable for users who explicitly target them via their own configs, `--configFile`, or `--additionalScenario`. + +### :rocket: New Features +* New `srunner/tools/carla_compat.py` runtime version-detection layer. Auto-detects the installed CARLA wheel and exposes `IS_UE5` / `IS_UE4` flags plus version-keyed lookup tables. The `SR_CARLA_VERSION` environment variable can force a version when the wheel disagrees with the running server. +* `CarlaDataProvider.create_blueprint` consults a version-keyed alias table on UE5, translating legacy blueprint ids (`vehicle.lincoln.mkz_2017`, `vehicle.audi.tt`, `vehicle.dodge.charger_police_2020`, `vehicle.carlamotors.carlacola`, …) to the 0.10.0 catalogue. The per-category fallback dict is also version-keyed so missing models degrade to a documented substitute instead of crashing. +* New `srunner/examples_ue5/` directory for Town10HD_Opt-targeted scenario configs. `srunner/tools/scenario_parser.py` reads `examples_ue5/*.xml` first on UE5 servers, then falls back to `examples/*.xml`, with per-scenario-name dedup so UE5 entries override their legacy siblings. Initial UE5 configs added for `FollowLeadingVehicle_1`, `FollowLeadingVehicleWithObstacle_1`, and `OtherLeadingVehicle_1`. + +### :ghost: Maintenance +* `VehicleVelocityControl.__init__` skips the wheel-friction-zeroing setup on UE5. The Chaos `WheelPhysicsControl.friction_force_multiplier` parameter is documented but `apply_physics_control` silently discards wheel-attribute writes on the current 0.10.0 build, and no other documented UE5 API exposes per-wheel friction-override for this controller's kinematic-injection use. The UE4 `tire_friction = 0` path is unchanged. +* `RouteLightsBehavior` (in `srunner/scenariomanager/lights_sim.py`, used by `--route` mode) is now engine-aware. On UE5 it skips the removed `LightManager` API entirely: no `set_day_night_cycle` calls (UE5 has no auto sun motion to disable — `weather.sun_altitude_angle` is the documented day/night control, which `_get_night_mode` already drives off) and no per-street-light `turn_on`/`turn_off` enumeration (UE5 exposes no documented per-`Light` runtime toggle). Vehicle-light control (`Vehicle.set_light_state` / `get_light_state`) is preserved on both engines. On UE5 the behavior emits a one-time INFO log at construction so users know street lights are not being controlled. +* `MetricsParser` and `Osc2TraceParser` (in `srunner/metrics/tools/`) now branch the Physics Control block parsing on `IS_UE5`. On 0.9.x the existing PhysX path is unchanged. On 0.10.0 the new `srunner/metrics/tools/recorder_chaos.py` walker handles the rewritten Chaos serialization — scalar fields, `forward_gear_ratios` / `reverse_gear_ratios` lists, and the per-wheel `friction_force_multiplier` / `cornering_stiffness` / `max_brake_torque` / `affected_by_*` / `suspension_axis` set documented at `carla-ue5/Docs/python_api.md` §`carla.VehiclePhysicsControl` and §`carla.WheelPhysicsControl`. The parsers also walk past the new top-level recorder sections introduced by the 0.10.0 server (`Vehicle door animations`, `Weathers`, `Walkers Bones`) so downstream blocks remain reachable. PhysX-only fields (`MOI`, `damping_rate*`, `clutch_strength`, `use_gear_autobox`) are not invented out of Chaos data — they are absent from the resulting `VehiclePhysicsControl` on UE5. +* `RosAgent.publish_can` (in `srunner/autoagents/ros_agent.py`) drops the PhysX-only physics block on UE5. The `CarlaEgoVehicleInfo` message schema is modelled on the removed PhysX fields (`tire_friction`, `damping_rate`, `moi`, `damping_rate_full_throttle`, `damping_rate_zero_throttle_clutch_disengaged`, `use_gear_autobox`, `clutch_strength`); the Chaos struct exposes a different set with different semantics, so there is no faithful field-by-field port — downstream ROS consumers parse PhysX semantics. On UE5 the agent emits a one-time deprecation warning pointing at the native CARLA ROS 2 bridge (the supported ROS path going forward) and populates only the fields that survived (`mass`, `drag_coefficient`, `center_of_mass`). UE4 behavior is unchanged. +* New carla-free unit suites — `tests/test_carla_compat.py` (16 tests: env override, `IS_UE5` thresholding, blueprint-id aliases, category fallbacks across both engines) and `tests/test_recorder_chaos.py` (25 tests: Chaos wheel-line tokenizer, Physics Control block walker, end-to-end `MetricsParser` / `Osc2TraceParser` runs against a committed 0.10.0 recorder fixture at `tests/fixtures/recorder_ue5_town10.txt`). Installs a stub `carla` module so the tests run in any Python env without a CARLA wheel installed. + +### :wrench: Behavior delta on UE5 +* `VehicleVelocityControl` on UE5 continues to track heading-aligned `set_target_velocity` calls tightly (measured 101% of target over 5 s @ 1 m/s along the vehicle's forward axis); sharp lateral / cornering motion tracks less faithfully than on UE4 because wheel lateral friction cannot currently be suppressed. Tracked upstream in carla-ue5. +* `RouteLightsBehavior` no longer controls street lights on UE5. Vehicle headlights / position lights still flip on and off based on `weather.sun_altitude_angle` (validated live: state `Position|LowBeam` set under sun_altitude=-30°, cleared under sun_altitude=75°). +* Legacy bicycle / motorbike blueprints are absent from the 0.10.0 catalogue; the alias table substitutes a four-wheel vehicle so spawns succeed, but two-wheeler scenarios lose their visual identity until upstream content lands. +* The offline `MetricsParser` / `Osc2TraceParser` `physics_control` shape differs between engines. On 0.9.x it returns a `VehiclePhysicsControl` populated with PhysX fields (`tire_friction`, `damping_rate`, `forward_gears`, …). On 0.10.0 it returns a Chaos-shaped `VehiclePhysicsControl` (`friction_force_multiplier`, `cornering_stiffness`, `max_brake_torque`, `forward_gear_ratios`, …). Metric scripts that read these structs must branch on `srunner.tools.carla_compat.IS_UE5` to interpret the right field set. A subset of Chaos list fields (`forward_gear_ratios`, `reverse_gear_ratios`) is settable but currently not readable from Python on the 0.10.0 wheel under test — an upstream `boost::python` to-Python converter gap, not a parser issue. +* The legacy `RosAgent` (`srunner/autoagents/ros_agent.py`) omits PhysX-only vehicle-info fields on UE5 and logs a one-time deprecation warning. Users on 0.10.0 should consume telemetry via the native CARLA ROS 2 bridge instead of the legacy `CarlaEgoVehicleInfo` schema. + +### :bug: Bug Fixes +* Fixed a latent bug in `CarlaDataProvider.create_blueprint`: when the requested model id was missing and the per-category fallback id was also missing (empty filter result), the fallback path raised an uncaught `ValueError: 'a' cannot be empty` from `np.random.choice([])`. The fallback now degrades gracefully (fallback → `vehicle.*` → explicit diagnostic `ValueError`). + +### :white_check_mark: Validation +* End-to-end validated on a live CARLA 0.10.0 server (Town10HD_Opt) with the full compat stack integrated: `FollowLeadingVehicle_1` (60.05s game), `FollowLeadingVehicleWithObstacle_1` (120.05s game), and `OtherLeadingVehicle_1` (80.05s game) all run spawn → behavior-tree tick → criteria evaluation → result reporting. `CollisionTest` reports `SUCCESS` on every run; the `TIMEOUT` global result is expected in `--scenario` mode without `--agent`. + ## CARLA ScenarioRunner 0.9.16 ### :rocket: New Features diff --git a/Docs/getting_scenariorunner.md b/Docs/getting_scenariorunner.md index d853dccd4..362cb13ff 100644 --- a/Docs/getting_scenariorunner.md +++ b/Docs/getting_scenariorunner.md @@ -254,5 +254,42 @@ python scenario_runner.py --help --- +## Running against CARLA 0.10.0 (UE5) + +ScenarioRunner supports both **CARLA 0.9.x** (UE4 / PhysX) and **CARLA 0.10.0+** (UE5 / Chaos) from the same source tree. The engine is auto-detected at runtime from the installed `carla` wheel; no code path needs to be selected by hand. + +__1. Install the matching CARLA 0.10.0 wheel.__ Use a Python ≥ 3.8 environment and install the `carla-0.10.0-*.whl` shipped with the 0.10.0 server build, then `pip install -r requirements.txt` from this repository. + +__2. Default supported map: `Town10HD_Opt`.__ The bundled UE5 example configs target `Town10HD_Opt` only. Other UE5 maps that ship with the server (e.g. `Town15`, `Mine_01`) remain reachable: target them with your own configs via `--configFile` / `--additionalScenario`. + +__3. Auto-detected blueprint translation.__ Legacy 0.9.x blueprint ids (e.g. `vehicle.lincoln.mkz_2017`, `vehicle.audi.tt`, `vehicle.dodge.charger_police_2020`) are translated to their 0.10.0 catalogue equivalents on the fly. Bicycle / motorbike blueprints are absent in 0.10.0 and substituted with a four-wheel vehicle so spawns succeed. + +__4. Version override (advanced).__ If the running server disagrees with the installed wheel, force the detection with the `SR_CARLA_VERSION` environment variable: + +```sh +export SR_CARLA_VERSION=0.10.0 # force UE5 / Chaos code path +export SR_CARLA_VERSION=0.9.16 # force UE4 / PhysX code path +``` + +__5. Example run against a UE5 server.__ + +```sh +# Launch the 0.10.0 server in another terminal first +python scenario_runner.py --scenario FollowLeadingVehicle_1 --reloadWorld --sync +``` + +The runner picks up `srunner/examples_ue5/FollowLeadingVehicle.xml` (Town10HD_Opt) on a UE5 server, and falls back to `srunner/examples/FollowLeadingVehicle.xml` (Town01) on a 0.9.x server. + +### Behavior deltas on UE5 + +A small number of UE4-only APIs have no working UE5 replacement and are dropped on the UE5 code path. See [CHANGELOG](CHANGELOG.md) for the full inventory. + +* `VehicleVelocityControl` (used by the OpenSCENARIO 2.0 `follow_trajectory` modifier with `control: "velocity"`) tracks heading-aligned trajectories tightly on UE5 but degrades on sharp lateral / cornering moves because Chaos exposes no working API to zero wheel friction. +* `RouteLightsBehavior` (used by `--route` mode) does not control street lights on UE5. The removed `LightManager` API has no UE5 replacement. Vehicle headlights / position lights still respond to night vs. day weather (via `weather.sun_altitude_angle`). +* The offline `MetricsParser` / `Osc2TraceParser` `physics_control` event returns a Chaos-shaped `VehiclePhysicsControl` on UE5 (`friction_force_multiplier`, `cornering_stiffness`, `max_brake_torque`, `forward_gear_ratios`, …) instead of the PhysX-shaped one on 0.9.x. Metric scripts that read these structs must branch on `srunner.tools.carla_compat.IS_UE5` to interpret the right field set. +* The legacy `RosAgent` (`srunner/autoagents/ros_agent.py`) omits PhysX-only `CarlaEgoVehicleInfo` fields on UE5 (`tire_friction`, `damping_rate`, `moi`, `clutch_strength`, `use_gear_autobox`, `damping_rate_*`) and emits a one-time deprecation warning. Use the native CARLA ROS 2 bridge that ships with 0.10.0 for full physics telemetry. + +--- + Thus concludes the installation process for ScenarioRunner. In case any unexpected error or issue occurs, the [CARLA forum](https://forum.carla.org/c/using-carla/scenario-runner) is open to everybody. There is an _ScenarioRunner_ category to post problems and doubts regarding this module. diff --git a/srunner/autoagents/ros_agent.py b/srunner/autoagents/ros_agent.py index b2690a4bc..2a154df76 100644 --- a/srunner/autoagents/ros_agent.py +++ b/srunner/autoagents/ros_agent.py @@ -39,6 +39,7 @@ ) from srunner.autoagents.autonomous_agent import AutonomousAgent +from srunner.tools.carla_compat import IS_UE5 class RosAgent(AutonomousAgent): @@ -358,24 +359,49 @@ def publish_can(self, sensor_id, data): self.vehicle_info_publisher = rospy.Publisher( '/carla/ego_vehicle/vehicle_info', CarlaEgoVehicleInfo, queue_size=1, latch=True) info_msg = CarlaEgoVehicleInfo() - for wheel in data['wheels']: - wheel_info = CarlaEgoVehicleInfoWheel() - wheel_info.tire_friction = wheel['tire_friction'] - wheel_info.damping_rate = wheel['damping_rate'] - wheel_info.steer_angle = wheel['steer_angle'] - wheel_info.disable_steering = wheel['disable_steering'] - info_msg.wheels.append(wheel_info) - info_msg.max_rpm = data['max_rpm'] - info_msg.moi = data['moi'] - info_msg.damping_rate_full_throttle = data['damping_rate_full_throttle'] - info_msg.damping_rate_zero_throttle_clutch_disengaged = data['damping_rate_zero_throttle_clutch_disengaged'] - info_msg.use_gear_autobox = data['use_gear_autobox'] - info_msg.clutch_strength = data['clutch_strength'] - info_msg.mass = data['mass'] - info_msg.drag_coefficient = data['drag_coefficient'] - info_msg.center_of_mass.x = data['center_of_mass']['x'] - info_msg.center_of_mass.y = data['center_of_mass']['y'] - info_msg.center_of_mass.z = data['center_of_mass']['z'] + if IS_UE5: + # CARLA 0.10.0 (UE5 / Chaos) removed every PhysX-shaped field this + # message schema was modelled on (tire_friction, damping_rate, moi, + # damping_rate_full_throttle, damping_rate_zero_throttle_clutch_disengaged, + # use_gear_autobox, clutch_strength). The Chaos struct exposes a + # different set with different semantics (friction_force_multiplier, + # cornering_stiffness, max_brake_torque, forward_gear_ratios, ...) so + # there is no faithful field-by-field port — downstream ROS consumers + # parse PhysX semantics. We populate only the fields that survived + # (mass, drag_coefficient, center_of_mass) and leave the rest at + # their CarlaEgoVehicleInfo defaults. CARLA 0.10.0 ships a native + # ROS 2 bridge that is the supported ROS path going forward. + if not getattr(self, "_ros_agent_ue5_warned", False): + rospy.logwarn( + "RosAgent: omitting PhysX-only vehicle info fields on " + "CARLA 0.10.0 (UE5/Chaos) — use the native CARLA ROS 2 " + "bridge for full physics telemetry." + ) + self._ros_agent_ue5_warned = True + info_msg.mass = data['mass'] + info_msg.drag_coefficient = data['drag_coefficient'] + info_msg.center_of_mass.x = data['center_of_mass']['x'] + info_msg.center_of_mass.y = data['center_of_mass']['y'] + info_msg.center_of_mass.z = data['center_of_mass']['z'] + else: + for wheel in data['wheels']: + wheel_info = CarlaEgoVehicleInfoWheel() + wheel_info.tire_friction = wheel['tire_friction'] + wheel_info.damping_rate = wheel['damping_rate'] + wheel_info.steer_angle = wheel['steer_angle'] + wheel_info.disable_steering = wheel['disable_steering'] + info_msg.wheels.append(wheel_info) + info_msg.max_rpm = data['max_rpm'] + info_msg.moi = data['moi'] + info_msg.damping_rate_full_throttle = data['damping_rate_full_throttle'] + info_msg.damping_rate_zero_throttle_clutch_disengaged = data['damping_rate_zero_throttle_clutch_disengaged'] + info_msg.use_gear_autobox = data['use_gear_autobox'] + info_msg.clutch_strength = data['clutch_strength'] + info_msg.mass = data['mass'] + info_msg.drag_coefficient = data['drag_coefficient'] + info_msg.center_of_mass.x = data['center_of_mass']['x'] + info_msg.center_of_mass.y = data['center_of_mass']['y'] + info_msg.center_of_mass.z = data['center_of_mass']['z'] self.vehicle_info_publisher.publish(info_msg) msg = CarlaEgoVehicleStatus() msg.header = self.get_header() diff --git a/srunner/examples_ue5/FollowLeadingVehicle.xml b/srunner/examples_ue5/FollowLeadingVehicle.xml new file mode 100644 index 000000000..d008bb093 --- /dev/null +++ b/srunner/examples_ue5/FollowLeadingVehicle.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/srunner/examples_ue5/LeadingVehicle.xml b/srunner/examples_ue5/LeadingVehicle.xml new file mode 100644 index 000000000..57816a5d6 --- /dev/null +++ b/srunner/examples_ue5/LeadingVehicle.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/srunner/metrics/tools/metrics_parser.py b/srunner/metrics/tools/metrics_parser.py index d270143e5..8052890c4 100644 --- a/srunner/metrics/tools/metrics_parser.py +++ b/srunner/metrics/tools/metrics_parser.py @@ -13,6 +13,9 @@ import carla +from srunner.tools.carla_compat import IS_UE5 +from srunner.metrics.tools.recorder_chaos import parse_chaos_physics_block, skip_indented_section + def parse_actor(info): """Returns a dictionary with the basic actor information""" @@ -345,6 +348,11 @@ def parse_recorder_info(self): self.next_row() + # CARLA 0.10.0 (UE5) records additional sections the 0.9.x parser + # had no awareness of. Walk past them so downstream blocks (in + # particular Physics Control events) remain reachable. + skip_indented_section(self, ' Vehicle door animations') + if self.frame_row.startswith(' Positions'): self.next_row() @@ -403,6 +411,9 @@ def parse_recorder_info(self): frame_state["actors"][actor_id].update({"lights": lights}) self.next_row() + # 0.10.0 weather snapshot — not modelled by this parser yet. + skip_indented_section(self, ' Weathers') + if self.frame_row.startswith(' Scene light changes'): self.next_row() @@ -469,60 +480,66 @@ def parse_recorder_info(self): if self.frame_row.startswith(' Physics Control'): self.next_row() - actor_id = None - while self.frame_row.startswith(' '): - - elements = self.get_row_elements(2, " ") - actor_id = int(elements[1]) - physics_control = carla.VehiclePhysicsControl() - self.next_row() + if IS_UE5: + # CARLA 0.10.0 (Chaos) rewrites the Physics Control block — + # see srunner/metrics/tools/recorder_chaos.py for the format + # and the documented Chaos field set. + parse_chaos_physics_block(self, frame_state["events"]["physics_control"]) + else: + actor_id = None + while self.frame_row.startswith(' '): - forward_gears = [] - wheels = [] - while self.frame_row.startswith(' '): - - if self.frame_row.startswith(' '): - elements = self.get_row_elements(4, " ") - if elements[0] == "gear": - forward_gears.append(parse_gears_control(elements)) - elif elements[0] == "wheel": - wheels.append(parse_wheels_control(elements)) - - else: - elements = self.get_row_elements(3, " = ") - name = elements[0] - - if name == "center_of_mass": - values = elements[1].split(" ") - value = carla.Vector3D( - float(values[0][1:-1]), - float(values[1][:-1]), - float(values[2][:-1]), - ) - setattr(physics_control, name, value) - elif name == "torque_curve" or name == "steering_curve": - values = elements[1].split(" ") - value = parse_vector_list(values) - setattr(physics_control, name, value) - - elif name == "use_gear_auto_box": - name = "use_gear_autobox" - value = elements[1] == "true" - setattr(physics_control, name, value) - - elif "forward_gears" in name or "wheels" in name: - pass + elements = self.get_row_elements(2, " ") + actor_id = int(elements[1]) + physics_control = carla.VehiclePhysicsControl() + self.next_row() - else: - name = name.lower() - value = float(elements[1]) - setattr(physics_control, name, value) + forward_gears = [] + wheels = [] + while self.frame_row.startswith(' '): - self.next_row() + if self.frame_row.startswith(' '): + elements = self.get_row_elements(4, " ") + if elements[0] == "gear": + forward_gears.append(parse_gears_control(elements)) + elif elements[0] == "wheel": + wheels.append(parse_wheels_control(elements)) - setattr(physics_control, "forward_gears", forward_gears) - setattr(physics_control, "wheels", wheels) - frame_state["events"]["physics_control"].update({actor_id: physics_control}) + else: + elements = self.get_row_elements(3, " = ") + name = elements[0] + + if name == "center_of_mass": + values = elements[1].split(" ") + value = carla.Vector3D( + float(values[0][1:-1]), + float(values[1][:-1]), + float(values[2][:-1]), + ) + setattr(physics_control, name, value) + elif name == "torque_curve" or name == "steering_curve": + values = elements[1].split(" ") + value = parse_vector_list(values) + setattr(physics_control, name, value) + + elif name == "use_gear_auto_box": + name = "use_gear_autobox" + value = elements[1] == "true" + setattr(physics_control, name, value) + + elif "forward_gears" in name or "wheels" in name: + pass + + else: + name = name.lower() + value = float(elements[1]) + setattr(physics_control, name, value) + + self.next_row() + + setattr(physics_control, "forward_gears", forward_gears) + setattr(physics_control, "wheels", wheels) + frame_state["events"]["physics_control"].update({actor_id: physics_control}) if self.frame_row.startswith(' Traffic Light time events'): self.next_row() @@ -535,6 +552,9 @@ def parse_recorder_info(self): frame_state["events"]["traffic_light_state_time"].update({actor_id: state_times}) self.next_row() + # 0.10.0 per-walker bone transforms — not consumed yet. + skip_indented_section(self, ' Walkers Bones') + frames_info.append(frame_state) return simulation_info, actors_info, frames_info diff --git a/srunner/metrics/tools/osc2_trace_parser.py b/srunner/metrics/tools/osc2_trace_parser.py index 1fdf4b5ad..96563dd80 100644 --- a/srunner/metrics/tools/osc2_trace_parser.py +++ b/srunner/metrics/tools/osc2_trace_parser.py @@ -13,6 +13,9 @@ import carla +from srunner.tools.carla_compat import IS_UE5 +from srunner.metrics.tools.recorder_chaos import parse_chaos_physics_block, skip_indented_section + def parse_actor(info): """Returns a dictionary with the basic actor information""" @@ -350,6 +353,11 @@ def parse_recorder_info(self): self.next_row() + # CARLA 0.10.0 (UE5) records additional sections the 0.9.x parser + # had no awareness of. Walk past them so downstream blocks (in + # particular Physics Control events) remain reachable. + skip_indented_section(self, " Vehicle door animations") + if self.frame_row.startswith(" Positions"): self.next_row() @@ -405,6 +413,9 @@ def parse_recorder_info(self): frame_state["actors"][actor_id].update({"lights": lights}) self.next_row() + # 0.10.0 weather snapshot — not modelled by this parser yet. + skip_indented_section(self, " Weathers") + if self.frame_row.startswith(" Scene light changes"): self.next_row() @@ -476,60 +487,68 @@ def parse_recorder_info(self): if self.frame_row.startswith(" Physics Control"): self.next_row() - actor_id = None - while self.frame_row.startswith(" "): - elements = self.get_row_elements(2, " ") - actor_id = int(elements[1]) - physics_control = carla.VehiclePhysicsControl() - self.next_row() + if IS_UE5: + # CARLA 0.10.0 (Chaos) rewrites the Physics Control block — + # see srunner/metrics/tools/recorder_chaos.py for the format + # and the documented Chaos field set. + parse_chaos_physics_block( + self, frame_state["events"]["physics_control"] + ) + else: + actor_id = None + while self.frame_row.startswith(" "): + elements = self.get_row_elements(2, " ") + actor_id = int(elements[1]) + physics_control = carla.VehiclePhysicsControl() + self.next_row() - forward_gears = [] - wheels = [] - while self.frame_row.startswith(" "): - if self.frame_row.startswith(" "): - elements = self.get_row_elements(4, " ") - if elements[0] == "gear": - forward_gears.append(parse_gears_control(elements)) - elif elements[0] == "wheel": - wheels.append(parse_wheels_control(elements)) - - else: - elements = self.get_row_elements(3, " = ") - name = elements[0] - - if name == "center_of_mass": - values = elements[1].split(" ") - value = carla.Vector3D( - float(values[0][1:-1]), - float(values[1][:-1]), - float(values[2][:-1]), - ) - setattr(physics_control, name, value) - elif name == "torque_curve" or name == "steering_curve": - values = elements[1].split(" ") - value = parse_vector_list(values) - setattr(physics_control, name, value) - - elif name == "use_gear_auto_box": - name = "use_gear_autobox" - value = elements[1] == "true" - setattr(physics_control, name, value) - - elif "forward_gears" in name or "wheels" in name: - pass + forward_gears = [] + wheels = [] + while self.frame_row.startswith(" "): + if self.frame_row.startswith(" "): + elements = self.get_row_elements(4, " ") + if elements[0] == "gear": + forward_gears.append(parse_gears_control(elements)) + elif elements[0] == "wheel": + wheels.append(parse_wheels_control(elements)) else: - name = name.lower() - value = float(elements[1]) - setattr(physics_control, name, value) - - self.next_row() - - setattr(physics_control, "forward_gears", forward_gears) - setattr(physics_control, "wheels", wheels) - frame_state["events"]["physics_control"].update( - {actor_id: physics_control} - ) + elements = self.get_row_elements(3, " = ") + name = elements[0] + + if name == "center_of_mass": + values = elements[1].split(" ") + value = carla.Vector3D( + float(values[0][1:-1]), + float(values[1][:-1]), + float(values[2][:-1]), + ) + setattr(physics_control, name, value) + elif name == "torque_curve" or name == "steering_curve": + values = elements[1].split(" ") + value = parse_vector_list(values) + setattr(physics_control, name, value) + + elif name == "use_gear_auto_box": + name = "use_gear_autobox" + value = elements[1] == "true" + setattr(physics_control, name, value) + + elif "forward_gears" in name or "wheels" in name: + pass + + else: + name = name.lower() + value = float(elements[1]) + setattr(physics_control, name, value) + + self.next_row() + + setattr(physics_control, "forward_gears", forward_gears) + setattr(physics_control, "wheels", wheels) + frame_state["events"]["physics_control"].update( + {actor_id: physics_control} + ) if self.frame_row.startswith(" Traffic Light time events"): self.next_row() @@ -544,6 +563,9 @@ def parse_recorder_info(self): ) self.next_row() + # 0.10.0 per-walker bone transforms — not consumed yet. + skip_indented_section(self, " Walkers Bones") + frames_info.append(frame_state) return simulation_info, actors_info, frames_info diff --git a/srunner/metrics/tools/recorder_chaos.py b/srunner/metrics/tools/recorder_chaos.py new file mode 100644 index 000000000..4201fcd48 --- /dev/null +++ b/srunner/metrics/tools/recorder_chaos.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Intel Corporation +# +# This work is licensed under the terms of the MIT license. +# For a copy, see . + +""" +Chaos-format helpers for the CARLA recorder text dump on UE5 / CARLA 0.10.0+. + +The recorder serialization of `Physics Control events` diverges structurally +between the PhysX (0.9.x) and Chaos (0.10.0+) backends: + +PhysX (0.9.x):: + + Physics Control events: 1 + Id: 42 + max_rpm = 5000 + ... + wheel 1 = (tire_friction 3.5 damping_rate 0.25 ...) + ... + gear 1 = (ratio 4 down_ratio 0.5 up_ratio 0.65) + +Chaos (0.10.0+):: + + Physics Control events: 1 + Id: 42 + max_torque = 550 + use_gear_auto_box = true + ... + torque_curve = (0, 500) (5000, 500) ... + forward_gear_ratios: + gear 0: ratio 4 + gear 1: ratio 2.5 + reverse_gear_ratios: + gear 0: ratio 2.943 + wheels: + wheel #0: + axle_type: offset: (0, 0, 0) wheel_radius: 35.5 ... friction_force_multiplier: 3.5 ... + +`wheel #N:` headers and their data lines start at column 0/1 (no shared indent +with the rest of the physics block), so the per-version paths cannot share a +walker. This module owns the Chaos walker; PhysX stays inline in the parsers. + +Field-name and type reference (citations in `carla-ue5/Docs/python_api.md`): +- `carla.VehiclePhysicsControl` — §carla.VehiclePhysicsControl, lines ~2793-2895. +- `carla.WheelPhysicsControl` — §carla.WheelPhysicsControl, lines ~3209-3320. +""" + +from __future__ import print_function + +import carla + + +# --------------------------------------------------------------------------- +# carla.VehiclePhysicsControl — known Chaos fields the recorder dumps +# --------------------------------------------------------------------------- + +_VEHICLE_SCALAR_FIELDS = frozenset({ + "max_torque", "max_rpm", "idle_rpm", "brake_effect", + "rev_up_moi", "rev_down_rate", + "front_rear_split", "gear_change_time", "final_ratio", + "change_up_rpm", "change_down_rpm", "transmission_efficiency", + "mass", "drag_coefficient", + "chassis_width", "chassis_height", "downforce_coefficient", + "sleep_threshold", "sleep_slope_limit", +}) + +_VEHICLE_INT_FIELDS = frozenset({ + "differential_type", +}) + +_VEHICLE_BOOL_FIELDS = frozenset({ + "use_automatic_gears", "use_sweep_wheel_collision", +}) + +# Recorder dumps the legacy PhysX-flavoured name even on Chaos servers; +# remap to the documented Chaos attribute. +_VEHICLE_BOOL_ALIASES = { + "use_gear_auto_box": "use_automatic_gears", +} + +# Field names the recorder dumps that aren't on Chaos VehiclePhysicsControl. +# `MOI` is a server-internal artefact (Chaos exposes `rev_up_moi`, which is +# a different quantity — do not silently remap). +_VEHICLE_IGNORED = frozenset({ + "MOI", +}) + + +# --------------------------------------------------------------------------- +# carla.WheelPhysicsControl — known Chaos fields the recorder dumps per wheel +# --------------------------------------------------------------------------- + +_WHEEL_SCALAR_FIELDS = frozenset({ + "axel_type", + "max_steer_angle", "wheel_radius", "wheel_width", "wheel_mass", + "cornering_stiffness", "friction_force_multiplier", + "side_slip_modifier", "slip_threshold", "skid_threshold", + "max_wheelspin_rotation", + "max_brake_torque", "max_handbrake_torque", + "suspension_force_offset", + "suspension_max_raise", "suspension_max_drop", "suspension_damping_ratio", + "wheel_load_ratio", "spring_rate", "spring_preload", + "suspension_smoothing", "rollbar_scaling", +}) + +_WHEEL_BOOL_FIELDS = frozenset({ + "affected_by_steering", "affected_by_brake", + "affected_by_handbrake", "affected_by_engine", + "abs_enabled", "traction_control_enabled", +}) + +_WHEEL_VECTOR_FIELDS = frozenset({ + "offset", "suspension_axis", +}) + +# Recorder ↔ Chaos Python attribute name remap. +# - `max_hand_brake_torque` (recorder) vs `max_handbrake_torque` (Chaos attr). +# - `axle_type` (recorder, correct spelling) vs `axel_type` (Chaos binding, +# carla-ue5/Docs/python_api.md §carla.WheelPhysicsControl.axel_type — the +# upstream attribute really is mis-spelled). Drop this alias once upstream +# renames the binding. +_WHEEL_FIELD_ALIASES = { + "max_hand_brake_torque": "max_handbrake_torque", + "axle_type": "axel_type", +} + +# Runtime telemetry the recorder appends after the wheel struct; not on +# WheelPhysicsControl itself and not useful for replay. +_WHEEL_IGNORED = frozenset({ + "wheel_index", "location", "old_location", "velocity", + "external_torque_combine_method", "lateral_slip_graph", + "sweep_shape", "sweep_type", +}) + + +# --------------------------------------------------------------------------- +# Parsing primitives +# --------------------------------------------------------------------------- + + +def _parse_vector3d(token_str): + """Parse a '(x, y, z)' substring into a carla.Vector3D.""" + s = token_str.strip() + if not (s.startswith("(") and s.endswith(")")): + raise ValueError("not a vector literal: {!r}".format(token_str)) + inner = s[1:-1] + parts = [p.strip() for p in inner.split(",")] + if len(parts) != 3: + raise ValueError("expected 3 components: {!r}".format(token_str)) + return carla.Vector3D(float(parts[0]), float(parts[1]), float(parts[2])) + + +def _parse_vector2d_list(s): + """Parse '(0, 500) (5000, 500) ...' into a list of carla.Vector2D. + + Best-effort: a missing closing paren stops parsing at that point rather + than raising, so a truncated recorder line just yields the pairs it + could decode. + """ + out = [] + i = 0 + while i < len(s): + if s[i] == "(": + j = s.find(")", i) + if j == -1: + break + inner = s[i + 1:j] + parts = [p.strip() for p in inner.split(",")] + if len(parts) == 2: + try: + out.append(carla.Vector2D(float(parts[0]), float(parts[1]))) + except ValueError: + pass + i = j + 1 + else: + i += 1 + return out + + +def _set_if_settable(obj, name, value): + """Best-effort setattr; tolerate read-only / unknown attributes.""" + try: + setattr(obj, name, value) + except (AttributeError, TypeError, ValueError): + pass + + +# --------------------------------------------------------------------------- +# Wheel line tokenizer +# --------------------------------------------------------------------------- + + +def _parse_wheel_line(line): + """Build a Chaos `WheelPhysicsControl` from a single recorder wheel line. + + The recorder emits each wheel on one line as space-separated `key: value` + pairs, where values may be scalars, vectors `(x, y, z)`, or empty (when + two keys are adjacent: `axle_type: offset: ...`). Unknown keys are + skipped — the format is not stable across upstream releases. + """ + wheel = carla.WheelPhysicsControl() + tokens = line.split() + + cur_key = None + cur_vals = [] + + def flush(): + if cur_key is None: + return + _apply_wheel_field(wheel, cur_key, cur_vals) + + for tok in tokens: + if tok.endswith(":") and not tok.startswith("("): + flush() + cur_key = tok[:-1] + cur_vals = [] + else: + cur_vals.append(tok) + flush() + return wheel + + +def _apply_wheel_field(wheel, key, val_tokens): + if key in _WHEEL_FIELD_ALIASES: + key = _WHEEL_FIELD_ALIASES[key] + if key in _WHEEL_IGNORED: + return + if not val_tokens: + return + + if key in _WHEEL_VECTOR_FIELDS: + try: + _set_if_settable(wheel, key, _parse_vector3d(" ".join(val_tokens))) + except ValueError: + pass + return + + if key in _WHEEL_BOOL_FIELDS: + try: + _set_if_settable(wheel, key, bool(int(val_tokens[0]))) + except ValueError: + pass + return + + if key in _WHEEL_SCALAR_FIELDS: + try: + _set_if_settable(wheel, key, float(val_tokens[0])) + except ValueError: + pass + return + + # Unknown field — skip silently. The recorder may emit fields the local + # Python binding doesn't expose (or vice versa); robust parsing tolerates + # the gap rather than crashing the metrics tool. + + +# --------------------------------------------------------------------------- +# Vehicle scalar line (' key = value') +# --------------------------------------------------------------------------- + + +def _apply_vehicle_scalar(physics_control, name, value_str): + if name in _VEHICLE_IGNORED: + return + + if name == "center_of_mass" and value_str.startswith("("): + try: + physics_control.center_of_mass = _parse_vector3d(value_str) + except (ValueError, AttributeError): + pass + return + + if name in ("torque_curve", "steering_curve"): + try: + _set_if_settable(physics_control, name, _parse_vector2d_list(value_str)) + except ValueError: + pass + return + + if not value_str: + # Recorder may emit fields with empty values (`differential_type = `). + # Guard before the bool branches so an empty bool isn't silently + # coerced to False. + return + + if name in _VEHICLE_BOOL_ALIASES: + _set_if_settable(physics_control, + _VEHICLE_BOOL_ALIASES[name], + value_str == "true") + return + + if name in _VEHICLE_BOOL_FIELDS: + _set_if_settable(physics_control, name, value_str == "true") + return + + if name in _VEHICLE_INT_FIELDS: + try: + _set_if_settable(physics_control, name, int(float(value_str))) + except ValueError: + pass + return + + if name in _VEHICLE_SCALAR_FIELDS: + try: + _set_if_settable(physics_control, name, float(value_str)) + except ValueError: + pass + return + + +# --------------------------------------------------------------------------- +# Shared section-skip helper for the new 0.10.0 recorder sections that the +# 0.9.x text-walkers have no schema for. The pattern is identical in both +# `MetricsParser` and `Osc2TraceParser`: header at one-space indent, body +# rows at two-space indent. +# --------------------------------------------------------------------------- + + +def skip_indented_section(parser, header_prefix): + """If `parser.frame_row` starts with `header_prefix`, consume it and any + rows indented with two leading spaces. No-op otherwise.""" + if not parser.frame_row.startswith(header_prefix): + return + parser.next_row() + while parser.frame_row.startswith(" "): + parser.next_row() + + +# --------------------------------------------------------------------------- +# Block walker — entry point used by metrics_parser / osc2_trace_parser +# --------------------------------------------------------------------------- + + +def _is_block_terminator(row): + """Return True if `row` ends the current Physics Control events block.""" + if not row: + return True + if row.startswith("Frame "): + return True + # New top-level recorder section: ` ...`. + if row.startswith(" ") and len(row) > 1 and row[1].isupper(): + return True + return False + + +def parse_chaos_physics_block(parser, physics_dest): + """Parse a UE5 / Chaos Physics Control events block. + + `parser` must expose the same attributes the in-file parsers do + (`frame_row`, `get_row_elements(indent, sep)`, `next_row()`). On entry, + `parser.frame_row` points at the first line AFTER ` Physics Control + events: N` (i.e. the first ` Id: N` row). On exit, `parser.frame_row` + points at the row that terminated the block (next section or `Frame`). + + Populates `physics_dest` (the events.physics_control dict) in place. + """ + while not _is_block_terminator(parser.frame_row): + row = parser.frame_row + if row.startswith(" Id:"): + _parse_chaos_actor(parser, physics_dest) + else: + # Stray / unexpected row — advance to avoid an infinite loop. + parser.next_row() + + +def _parse_chaos_actor(parser, physics_dest): + elements = parser.get_row_elements(2, " ") # 'Id:' 'N' + actor_id = int(elements[1]) + physics_control = carla.VehiclePhysicsControl() + forward_gear_ratios = [] + reverse_gear_ratios = [] + wheels = [] + parser.next_row() + + while True: + row = parser.frame_row + if _is_block_terminator(row) or row.startswith(" Id:"): + break + + if row.startswith(" forward_gear_ratios:"): + parser.next_row() + while parser.frame_row.startswith(" gear "): + toks = parser.frame_row.split() + if len(toks) >= 4 and toks[0] == "gear" and toks[2] == "ratio": + forward_gear_ratios.append(float(toks[3])) + parser.next_row() + continue + + if row.startswith(" reverse_gear_ratios:"): + parser.next_row() + while parser.frame_row.startswith(" gear "): + toks = parser.frame_row.split() + if len(toks) >= 4 and toks[0] == "gear" and toks[2] == "ratio": + reverse_gear_ratios.append(float(toks[3])) + parser.next_row() + continue + + if row.startswith(" wheels:"): + parser.next_row() + while parser.frame_row.startswith("wheel #"): + parser.next_row() + if parser.frame_row.startswith(" ") and not parser.frame_row.startswith(" "): + wheels.append(_parse_wheel_line(parser.frame_row)) + parser.next_row() + continue + + if row.startswith(" ") and " = " in row: + name, _, value = row.lstrip().partition(" = ") + _apply_vehicle_scalar(physics_control, name.strip(), value.strip()) + parser.next_row() + continue + + # Unknown indented row — skip to avoid getting stuck. + parser.next_row() + + _set_if_settable(physics_control, "forward_gear_ratios", forward_gear_ratios) + _set_if_settable(physics_control, "reverse_gear_ratios", reverse_gear_ratios) + _set_if_settable(physics_control, "wheels", wheels) + physics_dest[actor_id] = physics_control diff --git a/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py b/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py index c90c35c1a..6a49cada4 100644 --- a/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py +++ b/srunner/scenariomanager/actorcontrols/vehicle_velocity_control.py @@ -18,6 +18,7 @@ from srunner.scenariomanager.carla_data_provider import CarlaDataProvider from srunner.scenariomanager.actorcontrols.basic_control import BasicControl from srunner.scenariomanager.timer import GameTime +from srunner.tools.carla_compat import IS_UE5 class VehicleVelocityControl(BasicControl): @@ -37,13 +38,23 @@ def __init__(self, actor, args=None): super(VehicleVelocityControl, self).__init__(actor) - # Remove the friction of the wheels so that the velocities aren't reduced by the ground - physics_control = self._actor.get_physics_control() - wheels_control = physics_control.wheels - for w in wheels_control: - w.tire_friction = 0 - physics_control.wheels = wheels_control - self._actor.apply_physics_control(physics_control) + # Remove the friction of the wheels so that the velocities aren't reduced by the ground. + # 0.9.x (PhysX): WheelPhysicsControl.tire_friction is the working knob. + # 0.10.0 (Chaos): no equivalent. Writes to either tire_friction or the documented + # Chaos parameter friction_force_multiplier are silently accepted by + # apply_physics_control but never reach the simulation (read-back keeps the + # default), and set_simulate_physics(False) breaks set_target_velocity entirely. + # No working UE5 API exists to zero wheel friction for this controller's + # kinematic-injection use, so on UE5 we skip the setup. set_target_velocity in + # run_step still tracks well when the requested velocity is aligned with the + # vehicle's heading; expect degraded tracking on sharp lateral / cornering moves. + if not IS_UE5: + physics_control = self._actor.get_physics_control() + wheels_control = physics_control.wheels + for w in wheels_control: + w.tire_friction = 0 + physics_control.wheels = wheels_control + self._actor.apply_physics_control(physics_control) # This is the maximum amount of time used to aim the vehicle towards the next point. # Used to avoid weird lateral slides if the time between point is high. diff --git a/srunner/scenariomanager/carla_data_provider.py b/srunner/scenariomanager/carla_data_provider.py index a7a8fa629..3b57d1d80 100644 --- a/srunner/scenariomanager/carla_data_provider.py +++ b/srunner/scenariomanager/carla_data_provider.py @@ -21,6 +21,8 @@ import carla from agents.navigation.global_route_planner import GlobalRoutePlanner +from srunner.tools.carla_compat import actor_blueprint_categories, resolve_blueprint_id + TYPE_CHECKING = False if TYPE_CHECKING: from typing import Iterable @@ -668,37 +670,35 @@ def check_attribute_value(blueprint, name, value): return False - _actor_blueprint_categories = { - 'car': 'vehicle.tesla.model3', - 'van': 'vehicle.volkswagen.t2', - 'truck': 'vehicle.carlamotors.carlacola', - 'trailer': '', - 'semitrailer': '', - 'bus': 'vehicle.volkswagen.t2', - 'motorbike': 'vehicle.kawasaki.ninja', - 'bicycle': 'vehicle.diamondback.century', - 'train': '', - 'tram': '', - 'pedestrian': 'walker.pedestrian.0001', - 'misc': 'static.prop.streetbarrier' - } + _actor_blueprint_categories = actor_blueprint_categories() + + # Translate legacy ids (e.g. vehicle.lincoln.mkz_2017) to the engine-native + # equivalent on UE5; passthrough on 0.9.x. + resolved_model = resolve_blueprint_id(model) # Set the model - try: - blueprints = CarlaDataProvider._blueprint_library.filter(model) - if attribute_filter is not None: - for key, value in attribute_filter.items(): - blueprints = [x for x in blueprints if check_attribute_value(x, key, value)] + blueprints = list(CarlaDataProvider._blueprint_library.filter(resolved_model)) + if attribute_filter is not None: + for key, value in attribute_filter.items(): + blueprints = [x for x in blueprints if check_attribute_value(x, key, value)] + if blueprints: blueprint = CarlaDataProvider._rng.choice(blueprints) - except ValueError: - # The model is not part of the blueprint library. Let's take a default one for the given category - bp_filter = "vehicle.*" - new_model = _actor_blueprint_categories[actor_category] - if new_model != '': - bp_filter = new_model - print("WARNING: Actor model {} not available. Using instead {}".format(model, new_model)) - blueprint = CarlaDataProvider._rng.choice(CarlaDataProvider._blueprint_library.filter(bp_filter)) + else: + # The model is not part of the blueprint library. Try the category fallback, + # then degrade to 'vehicle.*' so an empty filter never throws uncaught. + fallback_model = _actor_blueprint_categories.get(actor_category, '') + print("WARNING: Actor model {} not available. Using instead {}".format( + model, fallback_model or 'vehicle.*')) + fallback_candidates = list(CarlaDataProvider._blueprint_library.filter(fallback_model)) \ + if fallback_model else [] + if not fallback_candidates: + fallback_candidates = list(CarlaDataProvider._blueprint_library.filter("vehicle.*")) + if not fallback_candidates: + raise ValueError( + "No blueprint available for model {} (category {}); " + "blueprint library is empty even for 'vehicle.*'".format(model, actor_category)) + blueprint = CarlaDataProvider._rng.choice(fallback_candidates) # Set the color if color: diff --git a/srunner/scenariomanager/lights_sim.py b/srunner/scenariomanager/lights_sim.py index 4fc5ad384..5e3b97d07 100644 --- a/srunner/scenariomanager/lights_sim.py +++ b/srunner/scenariomanager/lights_sim.py @@ -15,6 +15,7 @@ import carla from srunner.scenariomanager.carla_data_provider import CarlaDataProvider +from srunner.tools.carla_compat import IS_UE5 class RouteLightsBehavior(py_trees.behaviour.Behaviour): @@ -33,6 +34,10 @@ class RouteLightsBehavior(py_trees.behaviour.Behaviour): # In cases where more than one weather conditition is active, decrease the thresholds COMBINED_THRESHOLD = 10 + # Process-wide latch so the UE5 LightManager-disabled notice fires once, + # not once per RouteLightsBehavior instance. + _ue5_notice_emitted = False + def __init__(self, ego_vehicle, radius=50, radius_increase=15, name="LightsBehavior"): """ Setup parameters @@ -42,8 +47,25 @@ def __init__(self, ego_vehicle, radius=50, radius_increase=15, name="LightsBehav self._radius = radius self._radius_increase = radius_increase self._world = CarlaDataProvider.get_world() - self._light_manager = self._world.get_lightmanager() - self._light_manager.set_day_night_cycle(False) + # 0.9.x (UE4): LightManager owns the street-light registry and the + # automatic sun motion (set_day_night_cycle). We disable the cycle so + # weather.sun_altitude_angle drives night-mode unambiguously from below. + # 0.10.0 (UE5): LightManager was removed entirely. There is no auto sun + # motion to disable, and no documented per-Light runtime toggle API + # (MapLayer.StreetLights is a map-layer load flag, not a runtime + # toggle). _get_night_mode below already reads weather.sun_altitude_angle + # which is the documented UE5 day/night control, so on UE5 we skip the + # manager entirely and only drive vehicle lights. + if IS_UE5: + self._light_manager = None + if not RouteLightsBehavior._ue5_notice_emitted: + print("[RouteLightsBehavior] CARLA 0.10.0 / UE5 detected: " + "street lights will not be controlled (no LightManager API). " + "Vehicle lights are still driven from weather.sun_altitude_angle.") + RouteLightsBehavior._ue5_notice_emitted = True + else: + self._light_manager = self._world.get_lightmanager() + self._light_manager.set_day_night_cycle(False) self._vehicle_lights = carla.VehicleLightState.Position | carla.VehicleLightState.LowBeam self._prev_night_mode = False @@ -90,21 +112,23 @@ def _turn_close_lights_on(self, location): ego_speed = CarlaDataProvider.get_velocity(self._ego_vehicle) radius = max(self._radius, self._radius_increase * ego_speed) - # Street lights - on_lights = [] - off_lights = [] + # Street lights — only on engines that expose a LightManager (UE4 / 0.9.x). + # UE5 / 0.10.0 has no documented per-Light runtime toggle, so we skip it. + if self._light_manager is not None: + on_lights = [] + off_lights = [] - all_lights = self._light_manager.get_all_lights() - for light in all_lights: - if light.location.distance(location) > radius: - if light.is_on: - off_lights.append(light) - else: - if not light.is_on: - on_lights.append(light) + all_lights = self._light_manager.get_all_lights() + for light in all_lights: + if light.location.distance(location) > radius: + if light.is_on: + off_lights.append(light) + else: + if not light.is_on: + on_lights.append(light) - self._light_manager.turn_on(on_lights) - self._light_manager.turn_off(off_lights) + self._light_manager.turn_on(on_lights) + self._light_manager.turn_off(off_lights) # Vehicles all_vehicles = CarlaDataProvider.get_all_actors().filter('*vehicle.*') @@ -127,9 +151,11 @@ def _turn_close_lights_on(self, location): def _turn_all_lights_off(self): """Turns off the lights of all object""" - all_lights = self._light_manager.get_all_lights() - off_lights = [l for l in all_lights if l.is_on] - self._light_manager.turn_off(off_lights) + # Street lights — only on engines that expose a LightManager (UE4 / 0.9.x). + if self._light_manager is not None: + all_lights = self._light_manager.get_all_lights() + off_lights = [l for l in all_lights if l.is_on] + self._light_manager.turn_off(off_lights) # Vehicles all_vehicles = CarlaDataProvider.get_all_actors().filter('*vehicle.*') @@ -146,5 +172,6 @@ def _turn_all_lights_off(self): self._ego_vehicle.set_light_state(carla.VehicleLightState(lights)) def terminate(self, new_status): - self._light_manager.set_day_night_cycle(True) + if self._light_manager is not None: + self._light_manager.set_day_night_cycle(True) return super().terminate(new_status) diff --git a/srunner/tools/carla_compat.py b/srunner/tools/carla_compat.py new file mode 100644 index 000000000..921159e53 --- /dev/null +++ b/srunner/tools/carla_compat.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Intel Corporation +# +# This work is licensed under the terms of the MIT license. +# For a copy, see . + +""" +Runtime compatibility shim between CARLA 0.9.x (UE4 / PhysX) and CARLA 0.10.0+ +(UE5 / Chaos). Exposes a version probe, an `IS_UE5` engine flag, and +version-aware lookup tables that translate legacy blueprint ids and +per-category fallbacks to the ids that actually ship on the running server. + +Version mapping: + 0.9.x -> UE4 / PhysX (IS_UE5 == False) + 0.10.x -> UE5 / Chaos (IS_UE5 == True) + +Detection order (highest priority first): + 1. `SR_CARLA_VERSION` environment variable, if set. Useful when the + installed wheel version disagrees with the running server, or for + smoke tests that need to force a path. Value is a version string + (e.g. `0.9.16`, `0.10.0`). + 2. The `carla` python package version reported by importlib.metadata. + +Design notes: +- 0.10.0 renamed and trimmed the vehicle/walker catalogue. Several 0.9.x ids + (tesla.model3, volkswagen.t2, kawasaki.ninja, diamondback.century, ...) have + no direct UE5 equivalent. For those, the alias table points at the closest + documented substitute from the 0.10.0 catalogue rather than failing the + spawn outright. See .claude/reports/carla_ue5_0.10.0_compatibility_gap_analysis.md + section G1 for the full inventory. +- The shim never invents an id: every UE5 target is taken from the 0.10.0 + vehicle/walker listing observed in the running server. +- On 0.9.x the lookup is a passthrough, so legacy behaviour is preserved. +""" + +from __future__ import print_function + +import os + +try: + from importlib.metadata import version as _carla_version, PackageNotFoundError + + def _read_carla_version_str(): + return _carla_version("carla") +except ImportError: + import pkg_resources + + PackageNotFoundError = pkg_resources.DistributionNotFound # type: ignore[misc,assignment] + + def _read_carla_version_str(): + return pkg_resources.get_distribution("carla").version + +from packaging.version import Version + + +UE5_THRESHOLD = Version("0.10.0") +"""Version at which CARLA switches from UE4 / PhysX to UE5 / Chaos.""" + +_VERSION_OVERRIDE_ENV = "SR_CARLA_VERSION" + + +def _detect_carla_version(): + override = os.environ.get(_VERSION_OVERRIDE_ENV) + if override: + return Version(override) + try: + return Version(_read_carla_version_str()) + except PackageNotFoundError as exc: + raise RuntimeError( + "scenario_runner could not determine the CARLA version: the `carla` " + "Python package is not installed. Install the CARLA wheel matching " + "your server, or set the {} environment variable " + "(e.g. SR_CARLA_VERSION=0.10.0).".format(_VERSION_OVERRIDE_ENV) + ) from exc + + +CARLA_VERSION = _detect_carla_version() +"""Resolved CARLA version (env override `SR_CARLA_VERSION` or installed wheel).""" + +IS_UE5 = CARLA_VERSION >= UE5_THRESHOLD +"""True when running against CARLA 0.10.0+ (UE5 / Chaos).""" + +IS_UE4 = not IS_UE5 +"""True when running against CARLA 0.9.x (UE4 / PhysX). Convenience alias.""" + + +def carla_version(): + """Return the resolved CARLA version as a packaging.Version.""" + return CARLA_VERSION + + +def is_ue5(): + """Function form of IS_UE5 (kept for callers that prefer call-style).""" + return IS_UE5 + + +# --------------------------------------------------------------------------- +# Blueprint id aliases — 0.9.x identifier -> 0.10.0 identifier +# +# Targets are the actual ids advertised by a live CARLA 0.10.0 server's +# blueprint library. When a 0.9.x model has no direct UE5 sibling (tesla, +# kawasaki, diamondback, etc.) we fall through to the closest documented +# substitute so spawns succeed; that substitution is recorded here, not +# fabricated at call time. +# --------------------------------------------------------------------------- + +_UE5_VEHICLE_ALIASES = { + # Direct renames + 'vehicle.lincoln.mkz_2017': 'vehicle.lincoln.mkz', + 'vehicle.lincoln.mkz2017': 'vehicle.lincoln.mkz', + 'vehicle.audi.tt': 'vehicle.ue4.audi.tt', + 'vehicle.dodge.charger_police_2020': 'vehicle.dodgecop.charger', + 'vehicle.carlamotors.carlacola': 'vehicle.carlacola.actors', + + # Removed in 0.10.0 — substituted to closest catalogue entry + 'vehicle.tesla.model3': 'vehicle.lincoln.mkz', + 'vehicle.volkswagen.t2': 'vehicle.sprinter.mercedes', + 'vehicle.nissan.micra': 'vehicle.mini.cooper', + 'vehicle.kawasaki.ninja': 'vehicle.mini.cooper', + 'vehicle.diamondback.century': 'vehicle.mini.cooper', + 'vehicle.bh.crossbike': 'vehicle.mini.cooper', + 'vehicle.gazelle.omafiets': 'vehicle.mini.cooper', +} + +_UE5_WALKER_ALIASES = { + # 0.10.0 pedestrians start at .0015 (no .0001..0014). + 'walker.pedestrian.0001': 'walker.pedestrian.0015', +} + + +# --------------------------------------------------------------------------- +# Per-category fallback used by CarlaDataProvider.create_blueprint when a +# requested model id is missing from the library. Values must resolve on the +# active server. +# --------------------------------------------------------------------------- + +_LEGACY_CATEGORY_FALLBACKS = { + 'car': 'vehicle.tesla.model3', + 'van': 'vehicle.volkswagen.t2', + 'truck': 'vehicle.carlamotors.carlacola', + 'trailer': '', + 'semitrailer': '', + 'bus': 'vehicle.volkswagen.t2', + 'motorbike': 'vehicle.kawasaki.ninja', + 'bicycle': 'vehicle.diamondback.century', + 'train': '', + 'tram': '', + 'pedestrian': 'walker.pedestrian.0001', + 'misc': 'static.prop.streetbarrier', +} + +_UE5_CATEGORY_FALLBACKS = { + 'car': 'vehicle.lincoln.mkz', + 'van': 'vehicle.sprinter.mercedes', + 'truck': 'vehicle.carlacola.actors', + 'trailer': '', + 'semitrailer': '', + 'bus': 'vehicle.sprinter.mercedes', + 'motorbike': 'vehicle.mini.cooper', + 'bicycle': 'vehicle.mini.cooper', + 'train': '', + 'tram': '', + 'pedestrian': 'walker.pedestrian.0015', + 'misc': 'static.prop.streetbarrier', +} + + +def resolve_blueprint_id(model): + """Translate a possibly-legacy blueprint id to one valid on the running server. + + On 0.9.x this is a passthrough. On 0.10.0+ it applies the documented + vehicle / walker rename tables. Unknown ids pass through unchanged so the + existing wildcard `filter()` path still has a chance. + """ + if not is_ue5() or not isinstance(model, str): + return model + if model in _UE5_VEHICLE_ALIASES: + return _UE5_VEHICLE_ALIASES[model] + if model in _UE5_WALKER_ALIASES: + return _UE5_WALKER_ALIASES[model] + return model + + +def actor_blueprint_categories(): + """Return the per-category fallback dict appropriate for the running server.""" + return _UE5_CATEGORY_FALLBACKS if is_ue5() else _LEGACY_CATEGORY_FALLBACKS diff --git a/srunner/tools/scenario_parser.py b/srunner/tools/scenario_parser.py index d35e7ed90..4507a62ea 100644 --- a/srunner/tools/scenario_parser.py +++ b/srunner/tools/scenario_parser.py @@ -17,6 +17,38 @@ from srunner.scenarioconfigs.scenario_configuration import ScenarioConfiguration, ActorConfigurationData from srunner.scenarioconfigs.route_scenario_configuration import RouteConfiguration +from srunner.tools.carla_compat import IS_UE5 + + +def _example_config_globs(extension): + """Return the list of glob patterns for example config files of the given extension, + in priority order (highest priority first). + + On UE5 servers (0.10.0+) we read configs from srunner/examples_ue5/ first so that + scenarios authored for Town10HD_Opt take precedence over the legacy Town01-05 + entries when the same scenario name is defined in both locations. On 0.9.x only + the legacy examples directory is read. + """ + root = os.getenv('SCENARIO_RUNNER_ROOT', "./") + patterns = [] + if IS_UE5: + patterns.append("{}/srunner/examples_ue5/*.{}".format(root, extension)) + patterns.append("{}/srunner/examples/*.{}".format(root, extension)) + return patterns + + +def _collect_example_config_files(extensions): + """Glob all example config files for the active engine version, in priority order. + + Each glob result is sorted so the within-directory walk is deterministic + across platforms; the across-directory priority (examples_ue5 before + examples on UE5) is preserved by the outer loop ordering. + """ + files = [] + for ext in extensions: + for pattern in _example_config_globs(ext): + files.extend(sorted(glob.glob(pattern))) + return files class ScenarioConfigurationParser(object): @@ -43,8 +75,9 @@ def parse_scenario_configuration(scenario_name, additional_config_file_name): scenario_group = False scenario_configurations = [] + seen_names = set() - list_of_config_files = glob.glob("{}/srunner/examples/*.xml".format(os.getenv('SCENARIO_RUNNER_ROOT', "./"))) + list_of_config_files = _collect_example_config_files(["xml"]) if additional_config_file_name != '': list_of_config_files.append(additional_config_file_name) @@ -63,6 +96,12 @@ def parse_scenario_configuration(scenario_name, additional_config_file_name): elif scenario_group and scenario_config_type != scenario_name: continue + # Dedupe by scenario name. Files are walked in priority order + # (examples_ue5 first on UE5), so the first occurrence wins. + if scenario_config_name in seen_names: + continue + seen_names.add(scenario_config_name) + config = ScenarioConfiguration() config.town = scenario.attrib.get('town') config.name = scenario_config_name @@ -100,8 +139,7 @@ def get_list_of_scenarios(additional_config_file_name): Parse *all* config files and provide a list with all scenarios @return """ - list_of_config_files = glob.glob("{}/srunner/examples/*.xml".format(os.getenv('SCENARIO_RUNNER_ROOT', "./"))) - list_of_config_files += glob.glob("{}/srunner/examples/*.xosc".format(os.getenv('SCENARIO_RUNNER_ROOT', "./"))) + list_of_config_files = _collect_example_config_files(["xml", "xosc"]) if additional_config_file_name != '': list_of_config_files.append(additional_config_file_name) diff --git a/tests/fixtures/recorder_ue5_town10.txt b/tests/fixtures/recorder_ue5_town10.txt new file mode 100644 index 000000000..0f1fb59b1 Binary files /dev/null and b/tests/fixtures/recorder_ue5_town10.txt differ diff --git a/tests/test_carla_compat.py b/tests/test_carla_compat.py new file mode 100644 index 000000000..ad92273a6 --- /dev/null +++ b/tests/test_carla_compat.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Intel Corporation +# +# This work is licensed under the terms of the MIT license. +# For a copy, see . + +""" +Unit tests for `srunner.tools.carla_compat`. + +Carla-free: forces version detection via the `SR_CARLA_VERSION` env override +and reloads the module per test so both the UE4 (0.9.x / PhysX) and UE5 +(0.10.x / Chaos) code paths are exercised from the same process. +""" + +from __future__ import print_function + +import importlib +import os +import sys +import unittest + +# Make `import srunner.tools.carla_compat` resolvable when this file is run +# directly from `tests/` (the existing OSC2 unit suites do the same). +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + + +def _reload_carla_compat(version): + """Reload `srunner.tools.carla_compat` under a forced version.""" + os.environ["SR_CARLA_VERSION"] = version + sys.modules.pop("srunner.tools.carla_compat", None) + return importlib.import_module("srunner.tools.carla_compat") + + +class CarlaCompatVersionDetectionTests(unittest.TestCase): + """Version probe + IS_UE5 / IS_UE4 flags.""" + + def setUp(self): + self._saved = os.environ.get("SR_CARLA_VERSION") + + def tearDown(self): + if self._saved is None: + os.environ.pop("SR_CARLA_VERSION", None) + else: + os.environ["SR_CARLA_VERSION"] = self._saved + sys.modules.pop("srunner.tools.carla_compat", None) + + def test_env_override_resolves_to_version(self): + from packaging.version import Version + compat = _reload_carla_compat("0.10.0") + self.assertEqual(compat.CARLA_VERSION, Version("0.10.0")) + + def test_is_ue5_true_at_threshold(self): + compat = _reload_carla_compat("0.10.0") + self.assertTrue(compat.IS_UE5) + self.assertFalse(compat.IS_UE4) + self.assertTrue(compat.is_ue5()) + + def test_is_ue5_true_above_threshold(self): + compat = _reload_carla_compat("0.10.1") + self.assertTrue(compat.IS_UE5) + + def test_is_ue5_false_for_0_9_x(self): + compat = _reload_carla_compat("0.9.16") + self.assertFalse(compat.IS_UE5) + self.assertTrue(compat.IS_UE4) + self.assertFalse(compat.is_ue5()) + + def test_is_ue5_false_for_0_9_14_minimum(self): + compat = _reload_carla_compat("0.9.14") + self.assertFalse(compat.IS_UE5) + + +class CarlaCompatBlueprintAliasTests(unittest.TestCase): + """`resolve_blueprint_id` translation tables — UE5 alias, UE4 passthrough.""" + + def setUp(self): + self._saved = os.environ.get("SR_CARLA_VERSION") + + def tearDown(self): + if self._saved is None: + os.environ.pop("SR_CARLA_VERSION", None) + else: + os.environ["SR_CARLA_VERSION"] = self._saved + sys.modules.pop("srunner.tools.carla_compat", None) + + def test_ue5_renames_lincoln_mkz(self): + compat = _reload_carla_compat("0.10.0") + self.assertEqual( + compat.resolve_blueprint_id("vehicle.lincoln.mkz_2017"), + "vehicle.lincoln.mkz", + ) + + def test_ue5_renames_audi_tt_with_ue4_prefix(self): + compat = _reload_carla_compat("0.10.0") + self.assertEqual( + compat.resolve_blueprint_id("vehicle.audi.tt"), + "vehicle.ue4.audi.tt", + ) + + def test_ue5_substitutes_removed_tesla_model3(self): + compat = _reload_carla_compat("0.10.0") + # tesla.model3 is gone in 0.10.0 — alias substitutes a documented car. + self.assertEqual( + compat.resolve_blueprint_id("vehicle.tesla.model3"), + "vehicle.lincoln.mkz", + ) + + def test_ue5_substitutes_removed_two_wheelers(self): + compat = _reload_carla_compat("0.10.0") + for legacy in ( + "vehicle.diamondback.century", + "vehicle.bh.crossbike", + "vehicle.gazelle.omafiets", + "vehicle.kawasaki.ninja", + ): + self.assertEqual( + compat.resolve_blueprint_id(legacy), + "vehicle.mini.cooper", + msg="expected mini.cooper substitute for {!r}".format(legacy), + ) + + def test_ue5_remaps_first_pedestrian(self): + compat = _reload_carla_compat("0.10.0") + # 0.10.0 pedestrians start at .0015 — .0001 is gone. + self.assertEqual( + compat.resolve_blueprint_id("walker.pedestrian.0001"), + "walker.pedestrian.0015", + ) + + def test_ue5_passes_unknown_ids_through(self): + compat = _reload_carla_compat("0.10.0") + # Wildcard or unknown ids should fall through so the existing + # filter() path still gets a chance. + self.assertEqual( + compat.resolve_blueprint_id("vehicle.*"), + "vehicle.*", + ) + self.assertEqual( + compat.resolve_blueprint_id("vehicle.future_model.x"), + "vehicle.future_model.x", + ) + + def test_ue5_non_string_inputs_pass_through(self): + compat = _reload_carla_compat("0.10.0") + self.assertIsNone(compat.resolve_blueprint_id(None)) + self.assertEqual(compat.resolve_blueprint_id(42), 42) + + def test_ue4_is_a_passthrough(self): + compat = _reload_carla_compat("0.9.16") + for legacy in ( + "vehicle.lincoln.mkz_2017", + "vehicle.tesla.model3", + "vehicle.audi.tt", + "walker.pedestrian.0001", + "vehicle.diamondback.century", + ): + self.assertEqual(compat.resolve_blueprint_id(legacy), legacy) + + +class CarlaCompatCategoryFallbackTests(unittest.TestCase): + """`actor_blueprint_categories()` — per-category fallback ids per engine.""" + + def setUp(self): + self._saved = os.environ.get("SR_CARLA_VERSION") + + def tearDown(self): + if self._saved is None: + os.environ.pop("SR_CARLA_VERSION", None) + else: + os.environ["SR_CARLA_VERSION"] = self._saved + sys.modules.pop("srunner.tools.carla_compat", None) + + def test_ue5_car_fallback_resolves_on_0_10_0(self): + compat = _reload_carla_compat("0.10.0") + cats = compat.actor_blueprint_categories() + self.assertEqual(cats["car"], "vehicle.lincoln.mkz") + self.assertEqual(cats["pedestrian"], "walker.pedestrian.0015") + # Two-wheelers absent from 0.10.0 — fall back to a four-wheel car. + self.assertEqual(cats["bicycle"], "vehicle.mini.cooper") + self.assertEqual(cats["motorbike"], "vehicle.mini.cooper") + + def test_ue4_car_fallback_keeps_legacy_ids(self): + compat = _reload_carla_compat("0.9.16") + cats = compat.actor_blueprint_categories() + self.assertEqual(cats["car"], "vehicle.tesla.model3") + self.assertEqual(cats["pedestrian"], "walker.pedestrian.0001") + self.assertEqual(cats["bicycle"], "vehicle.diamondback.century") + self.assertEqual(cats["motorbike"], "vehicle.kawasaki.ninja") + + def test_both_engines_expose_the_same_categories(self): + ue4 = _reload_carla_compat("0.9.16").actor_blueprint_categories() + ue5 = _reload_carla_compat("0.10.0").actor_blueprint_categories() + self.assertEqual(set(ue4.keys()), set(ue5.keys())) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_recorder_chaos.py b/tests/test_recorder_chaos.py new file mode 100644 index 000000000..140f5d957 --- /dev/null +++ b/tests/test_recorder_chaos.py @@ -0,0 +1,518 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Intel Corporation +# +# This work is licensed under the terms of the MIT license. +# For a copy, see . + +""" +Unit tests for `srunner.metrics.tools.recorder_chaos` (the UE5 / Chaos +recorder Physics Control walker) and the new UE5 section-skip handlers +added to `MetricsParser` / `Osc2TraceParser`. + +Carla-free: installs a minimal stub `carla` module into `sys.modules` +before importing the targets, so the suite runs in any Python env with +no CARLA wheel installed. +""" + +from __future__ import print_function + +import os +import sys +import types +import unittest + + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +_FIXTURE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + + +# --------------------------------------------------------------------------- +# Fake `carla` module — covers everything the metrics parsers reach for. +# --------------------------------------------------------------------------- + +def _install_fake_carla(): + """Install a stub `carla` module if (and only if) one isn't already loaded. + + Real `carla` wheels expose strict C++ bindings; the fake stays permissive + so unit tests aren't held hostage to upstream attribute-set semantics. + """ + if "carla" in sys.modules and hasattr(sys.modules["carla"], "VehiclePhysicsControl"): + return + + fake = types.ModuleType("carla") + + class _Bag(object): + """Permissive object that accepts kwargs and arbitrary setattrs.""" + + def __init__(self, *args, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + class Vector3D(object): + def __init__(self, x=0.0, y=0.0, z=0.0): + self.x = float(x) + self.y = float(y) + self.z = float(z) + + def __sub__(self, other): + return Vector3D(self.x - other.x, self.y - other.y, self.z - other.z) + + def __truediv__(self, scalar): + return Vector3D(self.x / scalar, self.y / scalar, self.z / scalar) + + class Vector2D(object): + def __init__(self, x=0.0, y=0.0): + self.x = float(x) + self.y = float(y) + + class Location(Vector3D): + pass + + class Rotation(object): + def __init__(self, pitch=0.0, yaw=0.0, roll=0.0): + self.pitch = float(pitch) + self.yaw = float(yaw) + self.roll = float(roll) + + class Transform(object): + def __init__(self, location=None, rotation=None): + self.location = location or Location() + self.rotation = rotation or Rotation() + + class BoundingBox(object): + def __init__(self, location=None, extent=None): + self.location = location or Location() + self.extent = extent or Vector3D() + + class VehicleControl(_Bag): + pass + + class Color(object): + def __init__(self, r=0, g=0, b=0): + self.r, self.g, self.b = r, g, b + + class _Enum(object): + def __init__(self, name): + self.name = name + + def __repr__(self): + return "".format(self.name) + + class VehicleLightState(object): + NONE = _Enum("None") + Position = _Enum("Position") + LowBeam = _Enum("LowBeam") + HighBeam = _Enum("HighBeam") + Brake = _Enum("Brake") + RightBlinker = _Enum("RightBlinker") + LeftBlinker = _Enum("LeftBlinker") + Reverse = _Enum("Reverse") + Fog = _Enum("Fog") + Interior = _Enum("Interior") + Special1 = _Enum("Special1") + Special2 = _Enum("Special2") + + class TrafficLightState(object): + Red = _Enum("Red") + Yellow = _Enum("Yellow") + Green = _Enum("Green") + Off = _Enum("Off") + Unknown = _Enum("Unknown") + + class LightGroup(object): + NONE = _Enum("LightGroup.NONE") + + class LightState(_Bag): + pass + + class VehiclePhysicsControl(_Bag): + pass + + class WheelPhysicsControl(_Bag): + pass + + class GearPhysicsControl(_Bag): + pass + + fake.Vector3D = Vector3D + fake.Vector2D = Vector2D + fake.Location = Location + fake.Rotation = Rotation + fake.Transform = Transform + fake.BoundingBox = BoundingBox + fake.VehicleControl = VehicleControl + fake.Color = Color + fake.VehicleLightState = VehicleLightState + fake.TrafficLightState = TrafficLightState + fake.LightGroup = LightGroup + fake.LightState = LightState + fake.VehiclePhysicsControl = VehiclePhysicsControl + fake.WheelPhysicsControl = WheelPhysicsControl + fake.GearPhysicsControl = GearPhysicsControl + + sys.modules["carla"] = fake + + +_install_fake_carla() + +# Snapshot SR_CARLA_VERSION so tearDownModule can restore it for any +# downstream suite that runs in the same process. +_PREV_SR_CARLA_VERSION = os.environ.get("SR_CARLA_VERSION") + +# Force IS_UE5 before importing modules that snapshot it at import time. +os.environ["SR_CARLA_VERSION"] = "0.10.0" +sys.modules.pop("srunner.tools.carla_compat", None) +sys.modules.pop("srunner.metrics.tools.recorder_chaos", None) +sys.modules.pop("srunner.metrics.tools.metrics_parser", None) +sys.modules.pop("srunner.metrics.tools.osc2_trace_parser", None) + +from srunner.metrics.tools import recorder_chaos +from srunner.metrics.tools.metrics_parser import MetricsParser +from srunner.metrics.tools.osc2_trace_parser import Osc2TraceParser + + +def tearDownModule(): + """Restore the SR_CARLA_VERSION env var so the suite is hermetic.""" + if _PREV_SR_CARLA_VERSION is None: + os.environ.pop("SR_CARLA_VERSION", None) + else: + os.environ["SR_CARLA_VERSION"] = _PREV_SR_CARLA_VERSION + # Drop the carla_compat snapshot so later suites re-detect with the + # restored env (or with no env, if the wheel is present). + sys.modules.pop("srunner.tools.carla_compat", None) + + +# --------------------------------------------------------------------------- +# A tiny parser-like object so `parse_chaos_physics_block` can be exercised +# without dragging in the full MetricsParser / recorder framing. +# --------------------------------------------------------------------------- + +class _MockParser(object): + def __init__(self, rows): + self._rows = list(rows) + self._i = 0 + self.frame_row = self._rows[0] if self._rows else "" + + def next_row(self): + self._i += 1 + if self._i < len(self._rows): + self.frame_row = self._rows[self._i] + else: + self.frame_row = "" + + def get_row_elements(self, indent_num, split_string): + return self.frame_row[indent_num:].split(split_string) + + +# --------------------------------------------------------------------------- +# Wheel-line tokenizer +# --------------------------------------------------------------------------- + + +class ChaosWheelTokenizerTests(unittest.TestCase): + """`_parse_wheel_line` — Chaos wheel data row tokenizer.""" + + SAMPLE = ( + " axle_type: offset: (0.000000, 0.000000, 0.000000) wheel_radius: 35.5 " + "wheel_width: 23 wheel_mass: 20 cornering_stiffness: 1000 " + "friction_force_multiplier: 3.5 side_slip_modifier: 1 slip_threshold: 20 " + "skid_threshold: 20 max_steer_angle: 70 affected_by_steering: 1 " + "affected_by_brake: 1 affected_by_handbrake: 1 affected_by_engine: 1 " + "abs_enabled: 1 traction_control_enabled: 1 max_wheelspin_rotation: 30 " + "external_torque_combine_method: lateral_slip_graph: [] " + "suspension_axis: (0.000000, 0.000000, -1.000000) " + "suspension_force_offset: (0.000000, 0.000000, 0.000000) " + "suspension_max_raise: 8 suspension_max_drop: 10 suspension_damping_ratio: 1 " + "wheel_load_ratio: 0.5 spring_rate: 250 spring_preload: 50 " + "suspension_smoothing: 4 rollbar_scaling: 0.3 sweep_shape: sweep_type: " + "max_brake_torque: 1000 max_hand_brake_torque: 2000 wheel_index: 0 " + "location: (0.000000, 0.000000, 0.000000) " + "old_location: (0.000000, 0.000000, 0.000000) " + "velocity: (0.000000, 0.000000, 0.000000)" + ) + + def test_parses_friction_force_multiplier(self): + w = recorder_chaos._parse_wheel_line(self.SAMPLE) + self.assertEqual(w.friction_force_multiplier, 3.5) + + def test_parses_wheel_geometry_scalars(self): + w = recorder_chaos._parse_wheel_line(self.SAMPLE) + self.assertEqual(w.wheel_radius, 35.5) + self.assertEqual(w.wheel_width, 23.0) + self.assertEqual(w.wheel_mass, 20.0) + self.assertEqual(w.max_steer_angle, 70.0) + self.assertEqual(w.cornering_stiffness, 1000.0) + + def test_parses_brake_torques(self): + w = recorder_chaos._parse_wheel_line(self.SAMPLE) + self.assertEqual(w.max_brake_torque, 1000.0) + + def test_remaps_recorder_max_hand_brake_torque_to_chaos_attr(self): + w = recorder_chaos._parse_wheel_line(self.SAMPLE) + # Recorder dumps `max_hand_brake_torque` (extra underscore); the + # documented Chaos attribute is `max_handbrake_torque`. + self.assertEqual(w.max_handbrake_torque, 2000.0) + + def test_parses_boolean_flags(self): + w = recorder_chaos._parse_wheel_line(self.SAMPLE) + self.assertTrue(w.affected_by_steering) + self.assertTrue(w.affected_by_brake) + self.assertTrue(w.affected_by_handbrake) + self.assertTrue(w.affected_by_engine) + self.assertTrue(w.abs_enabled) + self.assertTrue(w.traction_control_enabled) + + def test_parses_vector_field(self): + w = recorder_chaos._parse_wheel_line(self.SAMPLE) + self.assertEqual(w.suspension_axis.x, 0.0) + self.assertEqual(w.suspension_axis.y, 0.0) + self.assertEqual(w.suspension_axis.z, -1.0) + + def test_handles_empty_value_between_keys(self): + # `axle_type:` with no value, immediately followed by `offset: ...` + # — empty values must be skipped, not crash. The recorder spelling + # (`axle_type`) is aliased to the Chaos binding spelling + # (`axel_type`); neither should end up set when the value is empty. + line = " axle_type: offset: (0.0, 0.0, 0.0) wheel_radius: 35.5" + w = recorder_chaos._parse_wheel_line(line) + self.assertEqual(w.wheel_radius, 35.5) + self.assertFalse(hasattr(w, "axle_type")) + self.assertFalse(hasattr(w, "axel_type")) + + def test_skips_runtime_only_fields(self): + # `wheel_index`, `location`, `old_location`, `velocity` aren't on + # WheelPhysicsControl — they should be silently dropped. + w = recorder_chaos._parse_wheel_line(self.SAMPLE) + for attr in ("wheel_index", "location", "old_location", "velocity", + "external_torque_combine_method", "lateral_slip_graph", + "sweep_shape", "sweep_type"): + self.assertFalse(hasattr(w, attr), + "runtime-only field {!r} should not be set".format(attr)) + + def test_skips_unknown_fields(self): + # An unknown field shouldn't crash and shouldn't be applied. + line = " future_field: 7.5 wheel_radius: 35.5" + w = recorder_chaos._parse_wheel_line(line) + self.assertEqual(w.wheel_radius, 35.5) + self.assertFalse(hasattr(w, "future_field")) + + +# --------------------------------------------------------------------------- +# Full Physics Control block walker (driven by _MockParser) +# --------------------------------------------------------------------------- + + +class ChaosPhysicsBlockTests(unittest.TestCase): + """`parse_chaos_physics_block` — full Physics Control events block walker.""" + + BLOCK_HEAD = " Id: 49" + SCALAR_ROWS = [ + " max_torque = 550", + " max_rpm = 6500", + " MOI = 5", + " rev_down_rate = 600", + " differential_type = ", + " front_rear_split = 0.5", + " use_gear_auto_box = true", + " gear_change_time = 0.1", + " final_ratio = 3.21", + " change_up_rpm = 4000", + " change_down_rpm = 1500", + " transmission_efficiency = 0.9", + " mass = 1696", + " drag_coefficient = 0.3", + " center_of_mass = (0.15, 0, 0.35)", + " torque_curve = (0, 500) (5000, 500) (0, 500) (1000, 347)", + " steering_curve = (0, 1) (10, 0.5) (20, 0.9)", + " forward_gear_ratios:", + " gear 0: ratio 4", + " gear 1: ratio 2.5", + " gear 2: ratio 1.912", + " reverse_gear_ratios:", + " gear 0: ratio 2.943", + " wheels:", + "wheel #0:", + " axle_type: wheel_radius: 35.5 friction_force_multiplier: 3.5 " + "max_brake_torque: 1000 max_hand_brake_torque: 2000 wheel_index: 0", + "wheel #1:", + " axle_type: wheel_radius: 35.5 friction_force_multiplier: 3.5 " + "max_brake_torque: 1000 max_hand_brake_torque: 2000 wheel_index: 1", + # Terminator — a new top-level section. + " Traffic Light time events: 0", + ] + + def _parse(self): + rows = [self.BLOCK_HEAD] + self.SCALAR_ROWS + parser = _MockParser(rows) + dest = {} + recorder_chaos.parse_chaos_physics_block(parser, dest) + return dest, parser + + def test_actor_key_present(self): + dest, _ = self._parse() + self.assertIn(49, dest) + + def test_scalar_fields_populated(self): + dest, _ = self._parse() + pc = dest[49] + self.assertEqual(pc.max_torque, 550.0) + self.assertEqual(pc.max_rpm, 6500.0) + self.assertEqual(pc.mass, 1696.0) + self.assertEqual(pc.drag_coefficient, 0.3) + self.assertEqual(pc.gear_change_time, 0.1) + self.assertEqual(pc.final_ratio, 3.21) + self.assertEqual(pc.change_up_rpm, 4000.0) + self.assertEqual(pc.change_down_rpm, 1500.0) + self.assertEqual(pc.front_rear_split, 0.5) + + def test_use_gear_auto_box_remapped_to_chaos_attr(self): + dest, _ = self._parse() + pc = dest[49] + # Recorder field is `use_gear_auto_box`; Chaos attr is + # `use_automatic_gears`. + self.assertTrue(pc.use_automatic_gears) + + def test_differential_type_empty_value_does_not_crash(self): + # Empty `differential_type = ` line must be skipped, not raise. + dest, _ = self._parse() + pc = dest[49] + self.assertFalse(hasattr(pc, "differential_type")) + + def test_empty_bool_value_does_not_coerce_to_false(self): + # An empty bool value (e.g. `use_gear_auto_box = `) must skip, not + # set the alias target to False (which would happen if the empty + # guard ran after the bool branches). + rows = [ + " Id: 7", + " use_gear_auto_box = ", + " use_sweep_wheel_collision = ", + " Traffic Light time events: 0", + ] + parser = _MockParser(rows) + dest = {} + recorder_chaos.parse_chaos_physics_block(parser, dest) + pc = dest[7] + self.assertFalse(hasattr(pc, "use_automatic_gears")) + self.assertFalse(hasattr(pc, "use_sweep_wheel_collision")) + + def test_moi_recorder_field_is_not_aliased_to_rev_up_moi(self): + # `MOI` is a server-internal artifact; Chaos `rev_up_moi` is a + # different quantity. No silent remap. + dest, _ = self._parse() + pc = dest[49] + self.assertFalse(hasattr(pc, "rev_up_moi")) + self.assertFalse(hasattr(pc, "moi")) + + def test_center_of_mass_parsed_as_vector(self): + dest, _ = self._parse() + pc = dest[49] + self.assertEqual(pc.center_of_mass.x, 0.15) + self.assertEqual(pc.center_of_mass.z, 0.35) + + def test_torque_and_steering_curves_parsed_as_vector2d_lists(self): + dest, _ = self._parse() + pc = dest[49] + self.assertEqual(len(pc.torque_curve), 4) + self.assertEqual(pc.torque_curve[0].x, 0.0) + self.assertEqual(pc.torque_curve[0].y, 500.0) + self.assertEqual(len(pc.steering_curve), 3) + + def test_forward_and_reverse_gear_ratios_parsed_as_lists(self): + dest, _ = self._parse() + pc = dest[49] + self.assertEqual(pc.forward_gear_ratios, [4.0, 2.5, 1.912]) + self.assertEqual(pc.reverse_gear_ratios, [2.943]) + + def test_wheels_list_populated(self): + dest, _ = self._parse() + pc = dest[49] + self.assertEqual(len(pc.wheels), 2) + self.assertEqual(pc.wheels[0].wheel_radius, 35.5) + self.assertEqual(pc.wheels[0].friction_force_multiplier, 3.5) + # max_hand_brake_torque (recorder) → max_handbrake_torque (Chaos attr) + self.assertEqual(pc.wheels[0].max_handbrake_torque, 2000.0) + + def test_walker_exits_at_block_terminator(self): + # After the loop, frame_row should sit at the next top-level section. + _, parser = self._parse() + self.assertTrue(parser.frame_row.startswith(" Traffic Light time events")) + + def test_empty_block_does_not_crash(self): + # `Physics Control events: 0` — caller advanced past the header, + # so the walker sees the next section straight away and exits. + parser = _MockParser([" Traffic Light time events: 0"]) + dest = {} + recorder_chaos.parse_chaos_physics_block(parser, dest) + self.assertEqual(dest, {}) + + +# --------------------------------------------------------------------------- +# End-to-end MetricsParser / Osc2TraceParser on the captured 0.10.0 dump. +# Confirms the section-skip handlers (Vehicle door animations / Weathers / +# Walkers Bones) plus the IS_UE5 branch wire up correctly. +# --------------------------------------------------------------------------- + + +class MetricsParserUE5FixtureTests(unittest.TestCase): + """Run the full parser on a real 0.10.0 recorder dump (Town10HD_Opt).""" + + @classmethod + def setUpClass(cls): + with open(os.path.join(_FIXTURE_DIR, "recorder_ue5_town10.txt")) as f: + cls.text = f.read() + + def test_metrics_parser_reaches_physics_control(self): + sim, actors, frames = MetricsParser(self.text).parse_recorder_info() + self.assertEqual(sim["map"], "Town10HD_Opt") + self.assertGreater(len(frames), 0) + # The Physics Control block lives in Frame 1 (index 0). + self.assertIn(49, frames[0]["events"]["physics_control"]) + + def test_metrics_parser_populates_chaos_scalars(self): + _, _, frames = MetricsParser(self.text).parse_recorder_info() + pc = frames[0]["events"]["physics_control"][49] + self.assertEqual(pc.max_torque, 550.0) + self.assertEqual(pc.max_rpm, 6500.0) + self.assertEqual(pc.mass, 1696.0) + self.assertTrue(pc.use_automatic_gears) + self.assertEqual(len(pc.wheels), 4) + # Each wheel carries the Chaos friction multiplier. + for w in pc.wheels: + self.assertEqual(w.friction_force_multiplier, 3.5) + + def test_metrics_parser_walks_past_vehicle_door_animations(self): + # Without the UE5 section-skip handler the walker would stall on + # `Vehicle door animations` and never reach Positions; positions + # being non-empty proves the handler fired. + _, _, frames = MetricsParser(self.text).parse_recorder_info() + self.assertGreater(len(frames[0]["actors"]), 0, + "section-skip handler missed `Vehicle door animations`") + + def test_metrics_parser_still_reports_vehicle_controls(self): + # Vehicle animations is downstream of the new sections — its data + # ending up in frame_state['actors'][id]['control'] confirms the + # walker traversed past every skip-handler in order. + _, _, frames = MetricsParser(self.text).parse_recorder_info() + any_control = any( + "control" in d for fr in frames for d in fr["actors"].values() + ) + self.assertTrue(any_control) + + def test_osc2_trace_parser_matches_metrics_parser_shape(self): + sim_a, _, frames_a = MetricsParser(self.text).parse_recorder_info() + sim_b, _, frames_b = Osc2TraceParser(self.text).parse_recorder_info() + self.assertEqual(sim_a["map"], sim_b["map"]) + self.assertEqual(len(frames_a), len(frames_b)) + # Same actor seen in both physics_control dicts. + self.assertEqual( + set(frames_a[0]["events"]["physics_control"].keys()), + set(frames_b[0]["events"]["physics_control"].keys()), + ) + + +if __name__ == "__main__": + unittest.main()