diff --git a/docs/api.rst b/docs/api.rst index 440f450f2..7f33c12d2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -173,6 +173,15 @@ it to the replay, but this greatly increases the size of the encoded simulation. can return to one later for further analysis), but it is not guaranteed to be compatible across major versions of Scenic. +.. _xosc_export: + +OpenScenarioXML Export +---------------------- + +Scenic provides experimental support for exporting completed simulations via `toOpenScenario`. +This function currently only supports cars and pedestrians, and may be subject to breaking changes +in the future. + .. seealso:: If you get exceptions or unexpected behavior when using the API, Scenic provides various debugging features: see :ref:`debugging`. .. rubric:: Footnotes diff --git a/docs/simulators.rst b/docs/simulators.rst index ee5d0b0d1..5a966660b 100644 --- a/docs/simulators.rst +++ b/docs/simulators.rst @@ -14,6 +14,10 @@ See the individual entries for details on each interface's capabilities and how While Scenic aims to support multiple Python versions, some simulators may have more limited compatibility. Be sure to check the documentation of each simulator to confirm which Python versions are supported. +.. note:: + Scenic also supports outputing data in formats that may be imported into other simulators and tools (e.g. :ref:`xosc_export`). + For more details, see :ref:`serialization`. + .. contents:: List of Simulators :local: @@ -163,7 +167,6 @@ This interface is part of the VerifAI toolkit; documentation and examples can be .. _VerifAI repository: https://github.com/BerkeleyLearnVerify/VerifAI - Deprecated ========== diff --git a/pyproject.toml b/pyproject.toml index dc667ba7a..6f6cdf907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,9 @@ metadrive = [ "metadrive-simulator >= 0.4.3", "sumolib >= 1.21.0", ] +openscenario = [ + "scenariogeneration" +] test = [ # minimum dependencies for running tests (used for tox virtualenvs) "pytest >= 7.0.0", "pytest-cov >= 3.0.0", @@ -68,6 +71,7 @@ test = [ # minimum dependencies for running tests (used for tox virtualenvs) test-full = [ # like 'test' but adds dependencies for optional features "scenic[test]", # all dependencies from 'test' extra above "scenic[guideways]", # for running guideways modules + "scenic[openscenario]", "astor >= 0.8.1", 'carla >= 0.9.12; python_version <= "3.12" and (platform_system == "Linux" or platform_system == "Windows")', "dill", diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index 580ffb32b..08fca26eb 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -8,9 +8,11 @@ import hashlib import io import math +import os import pickle import struct import types +import warnings from scenic.core.distributions import Samplable, needsSampling from scenic.core.utils import DefaultIdentityDict diff --git a/src/scenic/core/simulators.py b/src/scenic/core/simulators.py index 3e9c0308f..0e42f9662 100644 --- a/src/scenic/core/simulators.py +++ b/src/scenic/core/simulators.py @@ -807,11 +807,28 @@ def currentState(self): """Return the current state of the simulation. The definition of 'state' is up to the simulator; the 'state' is simply saved - at each time step to define the 'trajectory' of the simulation. - - The default implementation returns a tuple of the positions of all objects. + at each time step to define the 'trajectory' of the simulation. Changing this + method is however discouraged, unless one is adding additional attributes to + the returned ``SimulationState`` object. + + The default implementation returns a custom ``SimulationState`` object, which is a tuple + of positions of all objects (for backwards compatibility) and also has two attributes: + ``positions`` and ``orientations``, which are themselves tuples of the positions and + orientations of all objects. """ - return tuple(obj.position for obj in self.objects) + + class SimulationState(tuple): + def __new__(cls, positions, orientations): + return super().__new__(cls, positions) + + def __init__(self, positions, orientations): + self.positions = positions + self.orientations = orientations + + positions = tuple(obj.position for obj in self.objects) + orientation = tuple(obj.orientation for obj in self.objects) + + return SimulationState(positions, orientation) @property def currentRealTime(self): diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index 40191b14b..575609142 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -134,6 +134,10 @@ class DrivingObject: def isVehicle(self): return False + @property + def isPedestrian(self): + return False + @property def isCar(self): return False @@ -282,6 +286,25 @@ class Vehicle(DrivingObject): color (:obj:`Color` or RGB tuple): Color of the vehicle. The default value is a distribution derived from car color popularity statistics; see :obj:`Color.defaultCarColor`. + wheelbase: The distance between the front and rear axles of the vehicle. Default value is 0.6 + times the length of the vehicle. + maxSteeringAngle: The maximum steering angle of the vehicle. The full steering range would be + two times this value, going from (-maxSteeringAngle, maxSteeringAngle). Default value + 35 degrees. + wheelDiameter: The diameter of the *entire* wheel (including the tire). Default value is 0.7 meters. + trackWidth: Distance between the vehicle's wheels when pointed straight ahead. Default value + is 0.85 times the width of the vehicle. + groundClearance: The distance between the bottom of the vehicle's chassis and the ground. Default + value is half the wheel diameter. + maxSpeed: The maximum rated speed of the vehicle. Default value is 45 meters per second (~100 mph). + This value is not enforced by Scenic and is provided simply for other tools to reference (e.g. + exporting to OpenScenarioXML). + maxAcceleration: The maximum rated acceleration of the vehicle. Default value is 5 meters per second^2. + This value is not enforced by Scenic and is provided simply for other tools to reference (e.g. + exporting to OpenScenarioXML). + maxDeceleration: The maximum rated deceleration of the vehicle. Default value is 10 meters per second^2. + This value is not enforced by Scenic and is provided simply for other tools to reference (e.g. + exporting to OpenScenarioXML). """ regionContainedIn: roadOrShoulder position: new Point on road @@ -291,6 +314,14 @@ class Vehicle(DrivingObject): width: 2 length: 4.5 color: Color.defaultCarColor() + wheelbase: 0.6*self.length + maxSteeringAngle: 35 deg + wheelDiameter: 0.7 + trackWidth: 0.85*self.width + groundClearance: 0.5*self.wheelDiameter + maxSpeed: 45 + maxAcceleration: 5 + maxDeceleration: 10 @property def isVehicle(self): @@ -317,6 +348,7 @@ class Pedestrian(DrivingObject): length: The default length is 0.75 m. color: The default color is turquoise. Pedestrian colors are not necessarily used by simulators, but do appear in the debugging diagram. + mass: Default value is 65 kg. """ regionContainedIn: network.walkableRegion position: new Point on network.walkableRegion @@ -325,6 +357,11 @@ class Pedestrian(DrivingObject): width: 0.75 length: 0.75 color: [0, 0.5, 1] + mass: 65 + + @property + def isPedestrian(self): + return True ## Stub sensor implementations diff --git a/src/scenic/domains/driving/openscenario.py b/src/scenic/domains/driving/openscenario.py new file mode 100644 index 000000000..6c317b365 --- /dev/null +++ b/src/scenic/domains/driving/openscenario.py @@ -0,0 +1,232 @@ +"""Functionality for interfacing the Scenic driving domain with OpenScenario.""" + +import math +import os +import warnings + +from scenic.core.vectors import Vector + + +def toOpenScenario( + simulation, + scenario, + mapPath=None, + scenarioName="ScenicScenario", +): + """Export a `Simulation` as a `scenariogeneration.xosc.scenario `_ object. + + Args: + simulation: The `Simulation` to be exported to XOSC + scenario: The scenario from which simulation was sampled. + mapPath: The path to the XODR map used to run the simulation. If + one is not provided the `map` param of the scenario is used. + scenarioName: The name of the scenario in the generated XOSC file. + """ + try: + import scenariogeneration + from scenariogeneration import ScenarioGenerator, xosc + except ModuleNotFoundError as e: + raise ModuleNotFoundError( + "The `scenariogeneration` package is required to use Scenic's XOSC export functionality." + ) from e + + if simulation.result is None: + raise RuntimeError("Cannot export incomplete scenario to OpenScenario XML") + + scene = simulation.scene + + if len(scene.objects) != len(simulation.trajectory[-1].positions): + raise RuntimeError("Cannot export scenario with dynamically created objects.") + + # Create catalog + xosc_catalog = xosc.Catalog() + + # Create parameters + xosc_paramdec = xosc.ParameterDeclarations() + + # Extract map + if mapPath is None: + if "map" not in scenario.params: + raise ValueError( + "No `mapPath` provided and scenario does not have a `map` parameter defined." + ) + mapPath = os.path.abspath(scenario.params["map"]) + xosc_road = xosc.RoadNetwork(roadfile=mapPath) + + # Create entitities + entities = xosc.Entities() + xosc_objects = {} + for obj_i, obj in enumerate(scene.objects): + if getattr(obj, "isVehicle", False): + obj_name = getattr(obj, "name", f"Vehicle{obj_i}") + veh_bb = xosc.BoundingBox( + obj.width, + obj.length, + obj.height, + 0, + 0, + 0, + ) + veh_fa = xosc.Axle( + obj.maxSteeringAngle, + obj.wheelDiameter, + obj.trackWidth, + obj.wheelbase, + obj.groundClearance, + ) + veh_ra = xosc.Axle( + obj.maxSteeringAngle, + obj.wheelDiameter, + obj.trackWidth, + 0, + obj.groundClearance, + ) + xosc_obj = xosc.Vehicle( + name=obj_name, + vehicle_type=xosc.VehicleCategory.car, + boundingbox=veh_bb, + frontaxle=veh_fa, + rearaxle=veh_ra, + max_speed=obj.maxSpeed, + max_acceleration=obj.maxAcceleration, + max_deceleration=obj.maxDeceleration, + mass=None, + model3d=None, + max_acceleration_rate=None, + max_deceleration_rate=None, + role=None, + ) + elif getattr(obj, "isPedestrian", False): + obj_name = getattr(obj, "name", f"Pedestrian{obj_i}") + ped_bb = xosc.BoundingBox( + obj.width, + obj.length, + obj.height, + 0, + 0, + 0, + ) + xosc_obj = xosc.Pedestrian( + name=obj_name, + mass=obj.mass, + boundingbox=ped_bb, + category=xosc.PedestrianCategory.pedestrian, + model=None, + role=None, + ) + else: + warnings.warn( + f"Object {obj} of unsupported type is being ignored during XOSC export." + ) + continue + + xosc_objects[obj] = xosc_obj + entities.add_scenario_object(obj_name, xosc_obj) + + # Helper function + def pos_to_WorldPosition(obj, pos, yaw): + # XOSC Reference point is back axle, so we must translate Scenic's + # convention to this. + state_position = ( + pos.offsetRotated(yaw, Vector(0, -0.5 * obj.wheelbase, 0)) + if obj.isVehicle + else pos + ) + state_orientation = yaw + math.radians(90) + return xosc.WorldPosition( + x=state_position.x, + y=state_position.y, + z=state_position.z, + h=state_orientation, + ) + + # Initial states + init = xosc.Init() + + for obj, xosc_obj in xosc_objects.items(): + obj_init_action = xosc.TeleportAction( + pos_to_WorldPosition(obj, obj.position, obj.heading) + ) + init.add_init_action(xosc_obj.name, obj_init_action) + + # Dynamics + xosc_act = xosc.Act( + "MainAct", + xosc.ValueTrigger( + "StartSimulation", + 0, + xosc.ConditionEdge.none, + xosc.SimulationTimeCondition(0, xosc.Rule.greaterThan), + ), + ) + + for obj_i, (obj, xosc_obj) in enumerate(xosc_objects.items()): + action_times = [] + action_positions = [] + for t, states in enumerate(simulation.trajectory): + action_positions.append( + pos_to_WorldPosition( + obj, states.positions[obj_i], states.orientations[obj_i].yaw + ) + ) + action_times.append(simulation.timestep * t) + + polyline = xosc.Polyline(time=action_times, positions=action_positions) + trajectory = xosc.Trajectory(name=f"Trajectory_{xosc_obj.name}", closed=False) + trajectory.add_shape(polyline) + + traj_action = xosc.FollowTrajectoryAction( + trajectory=trajectory, + following_mode=xosc.FollowingMode.position, + reference_domain=xosc.ReferenceContext.absolute, + scale=1, + offset=0, + ) + + event = xosc.Event(f"Event_{xosc_obj.name}", xosc.Priority.override) + event.add_trigger( + xosc.ValueTrigger( + f"TimeTrigger_{xosc_obj.name}", + 0, + xosc.ConditionEdge.none, + xosc.SimulationTimeCondition(0, xosc.Rule.greaterThan), + ) + ) + event.add_action(f"Action_{xosc_obj.name}", action=traj_action) + + maneuver = xosc.Maneuver("Maneuver_{xosc_obj.name}") + maneuver.add_event(event) + + manuever_group = xosc.ManeuverGroup(f"ManeuverGroup_{xosc_obj.name}") + manuever_group.add_maneuver(maneuver) + manuever_group.add_actor(xosc_obj.name) + + xosc_act.add_maneuver_group(manuever_group) + + # Create storyboard + xosc_sb = xosc.StoryBoard( + init, + xosc.ValueTrigger( + "StopSimulation", + 0, + xosc.ConditionEdge.rising, + xosc.SimulationTimeCondition( + simulation.currentRealTime, xosc.Rule.greaterThan + ), + "stop", + ), + ) + xosc_sb.add_act(xosc_act) + + # Create scenario + xosc_scenario = xosc.Scenario( + scenarioName, + "Scenic", + xosc_paramdec, + entities=entities, + storyboard=xosc_sb, + roadnetwork=xosc_road, + catalog=xosc_catalog, + ) + + return xosc_scenario diff --git a/src/scenic/simulators/metadrive/simulator.py b/src/scenic/simulators/metadrive/simulator.py index 76607d5bc..075134f55 100644 --- a/src/scenic/simulators/metadrive/simulator.py +++ b/src/scenic/simulators/metadrive/simulator.py @@ -39,7 +39,7 @@ def __init__( timestep=0.1, render=True, render3D=False, - real_time=True, + real_time=None, screen_record=False, screen_record_filename=None, screen_record_path="metadrive_gifs", @@ -51,7 +51,10 @@ def __init__( self.timestep = timestep self.sumo_map = sumo_map self.xodr_map = xodr_map - self.real_time = real_time + if real_time is None: + self.real_time = self.render or self.render3D + else: + self.real_time = real_time self.screen_record = screen_record self.screen_record_filename = screen_record_filename self.screen_record_path = screen_record_path diff --git a/src/scenic/syntax/translator.py b/src/scenic/syntax/translator.py index 58e856607..066fdd2c2 100644 --- a/src/scenic/syntax/translator.py +++ b/src/scenic/syntax/translator.py @@ -165,12 +165,14 @@ def _scenarioFromStream( oldModules = list(sys.modules.keys()) try: with topLevelNamespace(path) as namespace: + veneer.activate(compileOptions, namespace) compileStream(stream, namespace, compileOptions, filename) + # Construct a Scenario from the resulting namespace + return constructScenarioFrom(namespace, scenario) finally: + veneer.deactivate() if not _cacheImports: purgeModulesUnsafeToCache(oldModules) - # Construct a Scenario from the resulting namespace - return constructScenarioFrom(namespace, scenario) @contextmanager @@ -274,53 +276,50 @@ def compileStream(stream, namespace, compileOptions, filename): if errors.verbosityLevel >= 2: veneer.verbosePrint(f" Compiling Scenic module from {filename}...") startTime = time.time() - veneer.activate(compileOptions, namespace) - try: - # Execute preamble - exec(compile(preamble, "", "exec"), namespace) - namespace[namespaceReference] = namespace - - # Parse the source - source = stream.read().decode("utf-8") - scenic_tree = parse_string(source, "exec", filename=filename) - - if dumpScenicAST: - print(f"### Begin Scenic AST of {filename}") - print(dump(scenic_tree, include_attributes=False, indent=4)) - print("### End Scenic AST") - - # Compile the Scenic AST into a Python AST - tree, requirements = compileScenicAST(scenic_tree, filename=filename) - astHasher = hashlib.blake2b(digest_size=4) - astHasher.update(ast.dump(tree).encode()) - - if dumpFinalAST: - print(f"### Begin final AST of {filename}") - print(dump(tree, include_attributes=True, indent=4)) - print("### End final AST") - - pythonSource = astToSource(tree) - if dumpASTPython: - if pythonSource is None: - raise RuntimeError( - "dumping the Python equivalent of the AST" - " requires the astor package" - ) - print(f"### Begin Python equivalent of final AST of {filename}") - print(pythonSource) - print("### End Python equivalent of final AST") - # Compile the Python AST tree - code = compileTranslatedTree(tree, filename) + # Execute preamble + exec(compile(preamble, "", "exec"), namespace) + namespace[namespaceReference] = namespace - # Execute it - executeCodeIn(code, namespace) + # Parse the source + source = stream.read().decode("utf-8") + scenic_tree = parse_string(source, "exec", filename=filename) + + if dumpScenicAST: + print(f"### Begin Scenic AST of {filename}") + print(dump(scenic_tree, include_attributes=False, indent=4)) + print("### End Scenic AST") + + # Compile the Scenic AST into a Python AST + tree, requirements = compileScenicAST(scenic_tree, filename=filename) + astHasher = hashlib.blake2b(digest_size=4) + astHasher.update(ast.dump(tree).encode()) + + if dumpFinalAST: + print(f"### Begin final AST of {filename}") + print(dump(tree, include_attributes=True, indent=4)) + print("### End final AST") + + pythonSource = astToSource(tree) + if dumpASTPython: + if pythonSource is None: + raise RuntimeError( + "dumping the Python equivalent of the AST" " requires the astor package" + ) + print(f"### Begin Python equivalent of final AST of {filename}") + print(pythonSource) + print("### End Python equivalent of final AST") + + # Compile the Python AST tree + code = compileTranslatedTree(tree, filename) + + # Execute it + executeCodeIn(code, namespace) + + # Extract scenario state from veneer and store it + astHash = astHasher.digest() + storeScenarioStateIn(namespace, requirements, astHash, compileOptions) - # Extract scenario state from veneer and store it - astHash = astHasher.digest() - storeScenarioStateIn(namespace, requirements, astHash, compileOptions) - finally: - veneer.deactivate() if errors.verbosityLevel >= 2: totalTime = time.time() - startTime veneer.verbosePrint(f" Compiled Scenic module in {totalTime:.4g} seconds.") diff --git a/src/scenic/syntax/veneer.py b/src/scenic/syntax/veneer.py index b79745030..65a1bbbb4 100644 --- a/src/scenic/syntax/veneer.py +++ b/src/scenic/syntax/veneer.py @@ -477,7 +477,7 @@ def instantiateSimulator(factory, params): def beginSimulation(sim): global currentSimulation, currentScenario, inInitialScenario, runningScenarios - global _globalParameters + global _globalParameters, mode2D if isActive(): raise RuntimeError("tried to start simulation during Scenic compilation!") assert currentSimulation is None @@ -489,6 +489,7 @@ def beginSimulation(sim): inInitialScenario = currentScenario._setup is None currentScenario._bindTo(sim.scene) _globalParameters = dict(sim.scene.params) + mode2D = currentSimulation.scene.compileOptions.mode2D # rebind globals that could be referenced by behaviors to their sampled values for modName, ( @@ -502,12 +503,13 @@ def beginSimulation(sim): def endSimulation(sim): global currentSimulation, currentScenario, currentBehavior, runningScenarios - global _globalParameters + global _globalParameters, mode2D currentSimulation = None currentScenario = None runningScenarios = [] currentBehavior = None _globalParameters = {} + mode2D = False for modName, ( namespace, diff --git a/tests/core/test_serialization.py b/tests/core/test_serialization.py index 6ae1d1c95..31056daf3 100644 --- a/tests/core/test_serialization.py +++ b/tests/core/test_serialization.py @@ -7,8 +7,6 @@ import io import math import random -import subprocess -import sys import numpy import pytest diff --git a/tests/domains/driving/test_driving.py b/tests/domains/driving/test_driving.py index 50d09266d..22ba0e95e 100644 --- a/tests/domains/driving/test_driving.py +++ b/tests/domains/driving/test_driving.py @@ -7,7 +7,9 @@ from scenic.core.distributions import RejectionException from scenic.core.errors import InvalidScenarioError from scenic.core.geometry import TriangulationError +from scenic.domains.driving.openscenario import toOpenScenario from scenic.domains.driving.roads import Network +from scenic.simulators.newtonian import NewtonianSimulator from tests.utils import compileScenic, pickle_test, sampleEgo, sampleScene, tryPickling # Suppress all warnings from OpenDRIVE parser @@ -229,3 +231,69 @@ def test_invalid_road_scenario(cached_maps): param foo = ego.lane """, ) + + +def test_xosc_export(getAssetPath): + simulator = NewtonianSimulator("Town01") + code = f""" + param render = False + model scenic.simulators.newtonian.driving_model + + ego = new Car with behavior FollowLaneBehavior() + + immobileCar = new Car visible + + behavior Walk(): + take SetWalkingSpeedAction(1) + + new Pedestrian behind ego by 5, + with regionContainedIn None, + with behavior Walk() + + terminate after 5 seconds + """ + + scenario = compileScenic( + code, mode2D=True, params={"map": getAssetPath("maps/CARLA/Town01.xodr")} + ) + scene, _ = scenario.generate() + simulation = simulator.simulate(scene) + toOpenScenario(simulation, scenario, scene) + + +def test_xosc_export_dynamic_objects(getAssetPath): + simulator = NewtonianSimulator("Town01") + code = f""" + param render = False + model scenic.simulators.newtonian.driving_model + + scenario SpawnCar(): + setup: + new Car at 0@0 + + scenario Main(): + setup: + ego = new Car with behavior FollowLaneBehavior() + + immobileCar = new Car visible + + behavior Walk(): + take SetWalkingSpeedAction(1) + + new Pedestrian behind ego by 5, + with regionContainedIn None, + with behavior Walk() + + terminate after 5 seconds + + compose: + wait for 1 seconds + do SpawnCar() + """ + scenario = compileScenic( + code, mode2D=True, params={"map": getAssetPath("maps/CARLA/Town01.xodr")} + ) + scene, _ = scenario.generate() + simulation = simulator.simulate(scene) + with pytest.raises(RuntimeError, match="(.*)dynamically created objects(.*)"): + toOpenScenario(simulation, scenario, scene) diff --git a/tests/simulators/metadrive/test_metadrive.py b/tests/simulators/metadrive/test_metadrive.py index 1c1ea9ad1..4fe573d2e 100644 --- a/tests/simulators/metadrive/test_metadrive.py +++ b/tests/simulators/metadrive/test_metadrive.py @@ -84,7 +84,9 @@ def test_pickle(loadLocalScenario): def getMetadriveSimulator(getAssetPath): base = getAssetPath("maps/CARLA") - def _getMetadriveSimulator(town, *, render=False, render3D=False, **kwargs): + def _getMetadriveSimulator( + town, *, render=False, render3D=False, real_time=False, **kwargs + ): openDrivePath = os.path.join(base, f"{town}.xodr") sumoPath = os.path.join(base, f"{town}.net.xml") simulator = MetaDriveSimulator( @@ -92,6 +94,7 @@ def _getMetadriveSimulator(town, *, render=False, render3D=False, **kwargs): xodr_map=openDrivePath, render=render, render3D=render3D, + real_time=real_time, **kwargs, ) return simulator, openDrivePath, sumoPath