From 1cd2aa65bce0bdc1a3b3d71247da141107e8949f Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Sun, 16 Nov 2025 20:55:51 -0800 Subject: [PATCH 01/35] Work on OpenScenarioXML Export. --- src/scenic/core/serialization.py | 76 ++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index a7c52367a..e473032b3 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -364,3 +364,79 @@ def readStr(stream): Serializer.addCodec(str, writeStr, readStr) + +from scenariogeneration import ScenarioGenerator, xosc + + +def toOpenScenario( + scenario, + scene, + simulationResult, + wheelbaseRatio=0.6, + maxSteeringAngle=0.523598775598, + wheelDiameter=0.8, + trackWidth=1.68, + groundClearance=0.4, + maxSpeed=69, + maxAcceleration=10, + maxDeceleration=10, +): + # Create catalog + xosc_catalog = xosc.Catalog() + + # Extract map + assert "map" in scenario.params + map_path = scenario.params["map"] + xosc_road = xosc.RoadNetwork(roadfile=map_path) + + # Create entitities + entities = xosc.Entities() + xosc_objects = [] + for obj_i, obj in enumerate(scene.objects): + veh_name = obj.name if hasattr(obj, "name") else f"Vehicle_{obj_i}" + # NOTE: XOSC coordinate system swaps X and Y compared to Scenic. + veh_bb = xosc.BoundingBox( + obj.length, + obj.width, + obj.height, + 0.5 * wheelbaseRatio * obj.length, + 0, + obj.height / 2, + ) + veh_fa = xosc.Axle( + maxSteeringAngle, + wheelDiameter, + trackWidth, + wheelbaseRatio * obj.length, + groundClearance, + ) + veh_ra = xosc.Axle( + maxSteeringAngle, wheelDiameter, trackWidth, 0, groundClearance + ) + xosc_veh = xosc.Vehicle( + name=veh_name, + vehicle_type=xosc.VehicleCategory.car, + boundingbox=veh_bb, + frontaxle=veh_fa, + rearaxle=veh_ra, + max_speed=maxSpeed, + max_acceleration=maxAcceleration, + max_deceleration=maxDeceleration, + mass=None, + model3d=None, + max_acceleration_rate=None, + max_deceleration_rate=None, + role=None, + ) + xosc_objects.append(xosc_veh) + entities.add_scenario_object(veh_name, xosc_veh) + + # Create init + init = xosc.Init() + + for xosc_obj in xosc_objects: + breakpoint() + obj_init_action = xosc.TeleportAction(xosc.LanePosition(25, 0, -1, 1)) + init.add_init_action(xosc_obj.name, obj_init_action) + + assert False From 8c056c2a07e915da540073a82836d7fc8c67cfb8 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Tue, 18 Nov 2025 15:09:52 -0800 Subject: [PATCH 02/35] Initial OpenScenarioXML export implementation. --- src/scenic/core/regions.py | 3 + src/scenic/core/serialization.py | 110 ++++++++++++++++++++++++++++--- src/scenic/core/simulators.py | 14 +++- 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/src/scenic/core/regions.py b/src/scenic/core/regions.py index 38e876d01..00cb8bca5 100644 --- a/src/scenic/core/regions.py +++ b/src/scenic/core/regions.py @@ -3695,6 +3695,9 @@ def distanceTo(self, point) -> float: dist2D = self.lineString.distance(makeShapelyPoint(point)) return math.hypot(dist2D, point.z) + def distanceAlong(self, point) -> float: + return shapely.line_locate_point(self.lineString, makeShapelyPoint(point)) + def projectVector(self, point, onDirection): raise TypeError('PolylineRegion does not support projection using "on"') diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index e473032b3..e869b2358 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -7,6 +7,7 @@ import io import math +import os import pickle import struct import types @@ -372,6 +373,8 @@ def toOpenScenario( scenario, scene, simulationResult, + mapPath=None, + scenarioName="ScenicScenario", wheelbaseRatio=0.6, maxSteeringAngle=0.523598775598, wheelDiameter=0.8, @@ -384,22 +387,27 @@ def toOpenScenario( # Create catalog xosc_catalog = xosc.Catalog() + # Create parameters + xosc_paramdec = xosc.ParameterDeclarations() + # Extract map assert "map" in scenario.params - map_path = scenario.params["map"] + map_path = mapPath if mapPath is not None else os.path.abspath(scenario.params["map"]) xosc_road = xosc.RoadNetwork(roadfile=map_path) + # network = scenario.dynamicScenario._dummyNamespace["network"] + # Create entitities entities = xosc.Entities() - xosc_objects = [] + xosc_objects = {} for obj_i, obj in enumerate(scene.objects): - veh_name = obj.name if hasattr(obj, "name") else f"Vehicle_{obj_i}" + veh_name = obj.name if hasattr(obj, "name") else f"Vehicle{obj_i}" # NOTE: XOSC coordinate system swaps X and Y compared to Scenic. veh_bb = xosc.BoundingBox( obj.length, obj.width, obj.height, - 0.5 * wheelbaseRatio * obj.length, + 0, 0, obj.height / 2, ) @@ -428,15 +436,99 @@ def toOpenScenario( max_deceleration_rate=None, role=None, ) - xosc_objects.append(xosc_veh) + xosc_objects[obj] = xosc_veh entities.add_scenario_object(veh_name, xosc_veh) # Create init init = xosc.Init() - for xosc_obj in xosc_objects: - breakpoint() - obj_init_action = xosc.TeleportAction(xosc.LanePosition(25, 0, -1, 1)) + for obj, xosc_obj in xosc_objects.items(): + init_position = xosc.WorldPosition(x=obj.x, y=obj.y, z=obj.z, h=obj.heading) + obj_init_action = xosc.TeleportAction(init_position) init.add_init_action(xosc_obj.name, obj_init_action) - assert False + # 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(simulationResult.trajectory): + state_position = states.positions[obj_i] + state_orientation = states.orientations[obj_i].yaw + action_times.append(simulationResult.timestep * t) + pos = xosc.WorldPosition( + x=state_position.x, + y=state_position.y, + z=state_position.z, + h=state_orientation, + ) + action_positions.append(pos) + + 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}_{t}", + 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( + simulationResult.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/core/simulators.py b/src/scenic/core/simulators.py index 3e9c0308f..f662c451d 100644 --- a/src/scenic/core/simulators.py +++ b/src/scenic/core/simulators.py @@ -811,7 +811,19 @@ def currentState(self): The default implementation returns a tuple of the positions 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): From 048d3f00d9229e9c165703df33e8829e9178d184 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Wed, 19 Nov 2025 09:28:24 -0800 Subject: [PATCH 03/35] Tidying up --- src/scenic/core/regions.py | 3 --- src/scenic/core/serialization.py | 9 ++++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/scenic/core/regions.py b/src/scenic/core/regions.py index 00cb8bca5..38e876d01 100644 --- a/src/scenic/core/regions.py +++ b/src/scenic/core/regions.py @@ -3695,9 +3695,6 @@ def distanceTo(self, point) -> float: dist2D = self.lineString.distance(makeShapelyPoint(point)) return math.hypot(dist2D, point.z) - def distanceAlong(self, point) -> float: - return shapely.line_locate_point(self.lineString, makeShapelyPoint(point)) - def projectVector(self, point, onDirection): raise TypeError('PolylineRegion does not support projection using "on"') diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index e869b2358..006fdf925 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -11,6 +11,7 @@ import pickle import struct import types +import warnings from scenic.core.distributions import Samplable, needsSampling from scenic.core.utils import DefaultIdentityDict @@ -395,12 +396,14 @@ def toOpenScenario( map_path = mapPath if mapPath is not None else os.path.abspath(scenario.params["map"]) xosc_road = xosc.RoadNetwork(roadfile=map_path) - # network = scenario.dynamicScenario._dummyNamespace["network"] - # Create entitities entities = xosc.Entities() xosc_objects = {} for obj_i, obj in enumerate(scene.objects): + if not hasattr(obj, "isVehicle") or not obj.isVehicle: + warnings.warn("Non-vehicle object {} is ignored.") + continue + veh_name = obj.name if hasattr(obj, "name") else f"Vehicle{obj_i}" # NOTE: XOSC coordinate system swaps X and Y compared to Scenic. veh_bb = xosc.BoundingBox( @@ -488,7 +491,7 @@ def toOpenScenario( event = xosc.Event(f"Event_{xosc_obj.name}", xosc.Priority.override) event.add_trigger( xosc.ValueTrigger( - f"TimeTrigger_{xosc_obj.name}_{t}", + f"TimeTrigger_{xosc_obj.name}", 0, xosc.ConditionEdge.none, xosc.SimulationTimeCondition(0, xosc.Rule.greaterThan), From 6ff049afa9eca8eb99baf3d4af52564ba7e9a872 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Tue, 25 Nov 2025 13:11:49 -0800 Subject: [PATCH 04/35] Fixed coordinate system --- src/scenic/core/serialization.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index 006fdf925..731589fcb 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -405,10 +405,9 @@ def toOpenScenario( continue veh_name = obj.name if hasattr(obj, "name") else f"Vehicle{obj_i}" - # NOTE: XOSC coordinate system swaps X and Y compared to Scenic. veh_bb = xosc.BoundingBox( - obj.length, obj.width, + obj.length, obj.height, 0, 0, @@ -446,7 +445,9 @@ def toOpenScenario( init = xosc.Init() for obj, xosc_obj in xosc_objects.items(): - init_position = xosc.WorldPosition(x=obj.x, y=obj.y, z=obj.z, h=obj.heading) + init_position = xosc.WorldPosition( + x=obj.x, y=obj.y, z=obj.z, h=obj.heading + math.radians(90) + ) obj_init_action = xosc.TeleportAction(init_position) init.add_init_action(xosc_obj.name, obj_init_action) @@ -466,7 +467,7 @@ def toOpenScenario( action_positions = [] for t, states in enumerate(simulationResult.trajectory): state_position = states.positions[obj_i] - state_orientation = states.orientations[obj_i].yaw + state_orientation = states.orientations[obj_i].yaw + math.radians(90) action_times.append(simulationResult.timestep * t) pos = xosc.WorldPosition( x=state_position.x, From 0b45e3d4ade8c101b8f26bb90115bb25f4777615 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Tue, 25 Nov 2025 13:43:39 -0800 Subject: [PATCH 05/35] Updated dependencies and fixed wheelbase offset --- pyproject.toml | 4 ++++ src/scenic/core/serialization.py | 20 ++++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b1ade058c..cc988206a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ metadrive = [ "metadrive-simulator@git+https://github.com/metadriverse/metadrive.git@85e5dadc6c7436d324348f6e3d8f8e680c06b4db", "sumolib >= 1.21.0", ] +openscenario = [ + "scenariogeneration" +] test = [ # minimum dependencies for running tests (used for tox virtualenvs) "pytest >= 7.0.0, <9", "pytest-cov >= 3.0.0", @@ -68,6 +71,7 @@ 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[metadrive]", + "scenic[openscenario]", "astor >= 0.8.1", 'carla >= 0.9.12; python_version <= "3.10" and (platform_system == "Linux" or platform_system == "Windows")', "dill", diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index 731589fcb..b4a66d516 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -15,6 +15,7 @@ from scenic.core.distributions import Samplable, needsSampling from scenic.core.utils import DefaultIdentityDict +from scenic.core.vectors import Vector ## JSON @@ -411,7 +412,7 @@ def toOpenScenario( obj.height, 0, 0, - obj.height / 2, + 0, ) veh_fa = xosc.Axle( maxSteeringAngle, @@ -445,8 +446,16 @@ def toOpenScenario( init = xosc.Init() for obj, xosc_obj in xosc_objects.items(): + scenic_yaw = obj.yaw + state_orientation = scenic_yaw + math.radians(90) + state_position = obj.position.offsetRotated( + scenic_yaw, Vector(0, -0.5 * wheelbaseRatio * obj.length, 0) + ) init_position = xosc.WorldPosition( - x=obj.x, y=obj.y, z=obj.z, h=obj.heading + math.radians(90) + x=state_position.x, + y=state_position.y, + z=state_position.z, + h=state_orientation, ) obj_init_action = xosc.TeleportAction(init_position) init.add_init_action(xosc_obj.name, obj_init_action) @@ -466,8 +475,11 @@ def toOpenScenario( action_times = [] action_positions = [] for t, states in enumerate(simulationResult.trajectory): - state_position = states.positions[obj_i] - state_orientation = states.orientations[obj_i].yaw + math.radians(90) + scenic_yaw = states.orientations[obj_i].yaw + state_orientation = scenic_yaw + math.radians(90) + state_position = states.positions[obj_i].offsetRotated( + scenic_yaw, Vector(0, -0.5 * wheelbaseRatio * obj.length, 0) + ) action_times.append(simulationResult.timestep * t) pos = xosc.WorldPosition( x=state_position.x, From 8ecd602149f8f06b6ae299f344603a2ed81dfffb Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 15 Dec 2025 18:45:02 -0800 Subject: [PATCH 06/35] Added pedestrians to OpenScenarioXML export --- src/scenic/core/serialization.py | 96 +++++++++++++++---------- src/scenic/domains/driving/model.scenic | 8 +++ 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index b4a66d516..bba11549d 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -385,6 +385,7 @@ def toOpenScenario( maxSpeed=69, maxAcceleration=10, maxDeceleration=10, + pedestrianMass=65, ): # Create catalog xosc_catalog = xosc.Catalog() @@ -401,46 +402,65 @@ def toOpenScenario( entities = xosc.Entities() xosc_objects = {} for obj_i, obj in enumerate(scene.objects): - if not hasattr(obj, "isVehicle") or not obj.isVehicle: - warnings.warn("Non-vehicle object {} is ignored.") + if hasattr(obj, "isVehicle") and obj.isVehicle: + obj_name = obj.name if hasattr(obj, "name") else f"Vehicle{obj_i}" + veh_bb = xosc.BoundingBox( + obj.width, + obj.length, + obj.height, + 0, + 0, + 0, + ) + veh_fa = xosc.Axle( + maxSteeringAngle, + wheelDiameter, + trackWidth, + wheelbaseRatio * obj.length, + groundClearance, + ) + veh_ra = xosc.Axle( + maxSteeringAngle, wheelDiameter, trackWidth, 0, groundClearance + ) + xosc_obj = xosc.Vehicle( + name=obj_name, + vehicle_type=xosc.VehicleCategory.car, + boundingbox=veh_bb, + frontaxle=veh_fa, + rearaxle=veh_ra, + max_speed=maxSpeed, + max_acceleration=maxAcceleration, + max_deceleration=maxDeceleration, + mass=None, + model3d=None, + max_acceleration_rate=None, + max_deceleration_rate=None, + role=None, + ) + elif hasattr(obj, "isPedestrian") and obj.isPedestrian: + obj_name = obj.name if hasattr(obj, "name") else 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=pedestrianMass, + boundingbox=ped_bb, + category=xosc.PedestrianCategory.pedestrian, + model=None, + role=None, + ) + else: + warnings.warn(f"Unknown object {obj} is ignored.") continue - veh_name = obj.name if hasattr(obj, "name") else f"Vehicle{obj_i}" - veh_bb = xosc.BoundingBox( - obj.width, - obj.length, - obj.height, - 0, - 0, - 0, - ) - veh_fa = xosc.Axle( - maxSteeringAngle, - wheelDiameter, - trackWidth, - wheelbaseRatio * obj.length, - groundClearance, - ) - veh_ra = xosc.Axle( - maxSteeringAngle, wheelDiameter, trackWidth, 0, groundClearance - ) - xosc_veh = xosc.Vehicle( - name=veh_name, - vehicle_type=xosc.VehicleCategory.car, - boundingbox=veh_bb, - frontaxle=veh_fa, - rearaxle=veh_ra, - max_speed=maxSpeed, - max_acceleration=maxAcceleration, - max_deceleration=maxDeceleration, - mass=None, - model3d=None, - max_acceleration_rate=None, - max_deceleration_rate=None, - role=None, - ) - xosc_objects[obj] = xosc_veh - entities.add_scenario_object(veh_name, xosc_veh) + xosc_objects[obj] = xosc_obj + entities.add_scenario_object(obj_name, xosc_obj) # Create init init = xosc.Init() diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index b6d0754af..4a3219a56 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -133,6 +133,10 @@ class DrivingObject: def isVehicle(self): return False + @property + def isPedestrian(self): + return False + @property def isCar(self): return False @@ -325,6 +329,10 @@ class Pedestrian(DrivingObject): length: 0.75 color: [0, 0.5, 1] + @property + def isPedestrian(self): + return True + ## Utility functions def withinDistanceToAnyCars(car, thresholdDistance): From 5e2b7e33d84c428b26212033fa49cb28c224fedf Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Thu, 28 May 2026 20:07:44 -0700 Subject: [PATCH 07/35] XOSC export improvements, fixes, and test. --- src/scenic/core/serialization.py | 80 ++++++++++---------- src/scenic/domains/driving/model.scenic | 28 +++++++ tests/core/test_serialization.py | 50 +++++++++++- tests/simulators/metadrive/test_metadrive.py | 5 +- 4 files changed, 120 insertions(+), 43 deletions(-) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index 0dab3290c..ab7058a7e 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -403,15 +403,6 @@ def toOpenScenario( simulationResult, mapPath=None, scenarioName="ScenicScenario", - wheelbaseRatio=0.6, - maxSteeringAngle=0.523598775598, - wheelDiameter=0.8, - trackWidth=1.68, - groundClearance=0.4, - maxSpeed=69, - maxAcceleration=10, - maxDeceleration=10, - pedestrianMass=65, ): try: import scenariogeneration @@ -447,14 +438,18 @@ def toOpenScenario( 0, ) veh_fa = xosc.Axle( - maxSteeringAngle, - wheelDiameter, - trackWidth, - wheelbaseRatio * obj.length, - groundClearance, + obj.maxSteeringAngle, + obj.wheelDiameter, + obj.trackWidth, + obj.wheelbase, + obj.groundClearance, ) veh_ra = xosc.Axle( - maxSteeringAngle, wheelDiameter, trackWidth, 0, groundClearance + obj.maxSteeringAngle, + obj.wheelDiameter, + obj.trackWidth, + 0, + obj.groundClearance, ) xosc_obj = xosc.Vehicle( name=obj_name, @@ -462,9 +457,9 @@ def toOpenScenario( boundingbox=veh_bb, frontaxle=veh_fa, rearaxle=veh_ra, - max_speed=maxSpeed, - max_acceleration=maxAcceleration, - max_deceleration=maxDeceleration, + max_speed=obj.maxSpeed, + max_acceleration=obj.maxAcceleration, + max_deceleration=obj.maxDeceleration, mass=None, model3d=None, max_acceleration_rate=None, @@ -483,35 +478,45 @@ def toOpenScenario( ) xosc_obj = xosc.Pedestrian( name=obj_name, - mass=pedestrianMass, + mass=obj.mass, boundingbox=ped_bb, category=xosc.PedestrianCategory.pedestrian, model=None, role=None, ) else: - warnings.warn(f"Unknown object {obj} is ignored.") + 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) - # Create init - init = xosc.Init() - - for obj, xosc_obj in xosc_objects.items(): - scenic_yaw = obj.yaw - state_orientation = scenic_yaw + math.radians(90) - state_position = obj.position.offsetRotated( - scenic_yaw, Vector(0, -0.5 * wheelbaseRatio * obj.length, 0) + # 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 ) - init_position = xosc.WorldPosition( + state_orientation = yaw + math.radians(90) + return xosc.WorldPosition( x=state_position.x, y=state_position.y, z=state_position.z, h=state_orientation, ) - obj_init_action = xosc.TeleportAction(init_position) + + # 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.yaw) + ) init.add_init_action(xosc_obj.name, obj_init_action) # Dynamics @@ -529,19 +534,12 @@ def toOpenScenario( action_times = [] action_positions = [] for t, states in enumerate(simulationResult.trajectory): - scenic_yaw = states.orientations[obj_i].yaw - state_orientation = scenic_yaw + math.radians(90) - state_position = states.positions[obj_i].offsetRotated( - scenic_yaw, Vector(0, -0.5 * wheelbaseRatio * obj.length, 0) + action_positions.append( + pos_to_WorldPosition( + obj, states.positions[obj_i], states.orientations[obj_i].yaw + ) ) action_times.append(simulationResult.timestep * t) - pos = xosc.WorldPosition( - x=state_position.x, - y=state_position.y, - z=state_position.z, - h=state_orientation, - ) - action_positions.append(pos) polyline = xosc.Polyline(time=action_times, positions=action_positions) trajectory = xosc.Trajectory(name=f"Trajectory_{xosc_obj.name}", closed=False) diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index 7652b43cb..8db0c2805 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -286,6 +286,24 @@ 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 + 30 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: 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 @@ -295,6 +313,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): @@ -321,6 +347,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 @@ -329,6 +356,7 @@ class Pedestrian(DrivingObject): width: 0.75 length: 0.75 color: [0, 0.5, 1] + mass: 65 @property def isPedestrian(self): diff --git a/tests/core/test_serialization.py b/tests/core/test_serialization.py index 6ae1d1c95..e7b88ccc2 100644 --- a/tests/core/test_serialization.py +++ b/tests/core/test_serialization.py @@ -13,8 +13,14 @@ import numpy import pytest -from scenic.core.serialization import SerializationError, Serializer, deterministicHash +from scenic.core.serialization import ( + SerializationError, + Serializer, + deterministicHash, + toOpenScenario, +) from scenic.core.simulators import DivergenceError, DummySimulator +from tests.simulators.metadrive.test_metadrive import getMetadriveSimulator from tests.utils import ( areEquivalent, compileScenic, @@ -507,3 +513,45 @@ class Foo: digest2 = deterministicHash(mapping2) # Non-scalar values should hash in a stable way, independent of identity. assert digest1 == digest2 + + +def test_xosc_export(getMetadriveSimulator): + simulator, openDrivePath, sumoPath = getMetadriveSimulator("Town01") + code = f""" + param map = r'{openDrivePath}' + param sumo_map = r'{sumoPath}' + + model scenic.simulators.metadrive.model + + behavior DriveAndBrakeForPedestrians(): + try: + do FollowLaneBehavior() + interrupt when withinDistanceToAnyPedestrians(self, 10): + take SetThrottleAction(0), SetBrakeAction(1) + + behavior CrossRoad(): + while distance from self to ego > 15: + wait + take SetWalkingDirectionAction(self.heading), SetWalkingSpeedAction(1) + + ego = new Car with behavior DriveAndBrakeForPedestrians() + + rightCurb = ego.laneGroup.curb + spot = new OrientedPoint on visible rightCurb + + parkedCar = new Car right of spot by 1, with regionContainedIn None + + require distance from ego to parkedCar > 30 + + new Pedestrian ahead of parkedCar by 3, + facing 90 deg relative to parkedCar, + with behavior CrossRoad() + + terminate after 30 seconds + """ + + scenario = compileScenic(code, mode2D=True, params={"map": openDrivePath}) + scene, _ = scenario.generate() + simulationResult = simulator.simulate(scene) + assert simulationResult is not None + xosc_scenario = toOpenScenario(scenario, scene, simulationResult) 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 From 743cf43ce3f61800acc2f3abdcc33a6b1fee6a96 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Thu, 28 May 2026 20:10:51 -0700 Subject: [PATCH 08/35] Fixed metadrive import? --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2aace9537..6f6cdf907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,6 @@ 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[metadrive]", "scenic[openscenario]", "astor >= 0.8.1", 'carla >= 0.9.12; python_version <= "3.12" and (platform_system == "Linux" or platform_system == "Windows")', From 992be2554b0519fe112ee5ad2ecb01d3ff68c1cd Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Thu, 28 May 2026 20:16:07 -0700 Subject: [PATCH 09/35] Tweaked Metadrive real_time default. --- src/scenic/simulators/metadrive/simulator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 From 15c729a59c114340a3afd1f9830c2b894951b337 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Thu, 28 May 2026 20:38:41 -0700 Subject: [PATCH 10/35] Added documentation --- docs/api.rst | 7 +++++++ docs/simulators.rst | 5 ++++- src/scenic/core/serialization.py | 22 +++++++++++++++++++--- tests/core/test_serialization.py | 2 +- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 440f450f2..5cfe99f09 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -173,6 +173,13 @@ 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/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index ab7058a7e..77fb68938 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -398,12 +398,22 @@ def readStr(stream): def toOpenScenario( + simulationResult, scenario, scene, - simulationResult, mapPath=None, scenarioName="ScenicScenario", ): + """Export a `SimulationResult` as a `scenariogeneration` `xosc` object. + + Args: + simulationResult: The `SimulationResult` to be exported to XOSC + scenario: The scenario from which simulationResult was sampled. + scene: The scene from which simulationResult 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 @@ -419,8 +429,14 @@ def toOpenScenario( xosc_paramdec = xosc.ParameterDeclarations() # Extract map - assert "map" in scenario.params - map_path = mapPath if mapPath is not None else os.path.abspath(scenario.params["map"]) + if map_path is None: + if "map" not in scenario.params: + raise ValueError( + "No `mapPath` provided and scenario does not have a `map` parameter defined." + ) + map_path = ( + mapPath if mapPath is not None else os.path.abspath(scenario.params["map"]) + ) xosc_road = xosc.RoadNetwork(roadfile=map_path) # Create entitities diff --git a/tests/core/test_serialization.py b/tests/core/test_serialization.py index e7b88ccc2..c4ca8ca0d 100644 --- a/tests/core/test_serialization.py +++ b/tests/core/test_serialization.py @@ -554,4 +554,4 @@ def test_xosc_export(getMetadriveSimulator): scene, _ = scenario.generate() simulationResult = simulator.simulate(scene) assert simulationResult is not None - xosc_scenario = toOpenScenario(scenario, scene, simulationResult) + xosc_scenario = toOpenScenario(simulationResult, scenario, scene) From ab311ca44bc4bf302ba32ed4b3bb5dc2a16673ee Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Thu, 28 May 2026 20:45:31 -0700 Subject: [PATCH 11/35] Minor fixes. --- src/scenic/core/serialization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index 77fb68938..3e339699c 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -429,15 +429,15 @@ def toOpenScenario( xosc_paramdec = xosc.ParameterDeclarations() # Extract map - if map_path is None: + if mapPath is None: if "map" not in scenario.params: raise ValueError( "No `mapPath` provided and scenario does not have a `map` parameter defined." ) - map_path = ( + mapPath = ( mapPath if mapPath is not None else os.path.abspath(scenario.params["map"]) ) - xosc_road = xosc.RoadNetwork(roadfile=map_path) + xosc_road = xosc.RoadNetwork(roadfile=mapPath) # Create entitities entities = xosc.Entities() From 54a2a5eb8b87922ca0e99f953f6e07ed6fe65376 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Fri, 29 May 2026 06:46:18 -0700 Subject: [PATCH 12/35] Fixed blank line --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index 5cfe99f09..23ac150e7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -176,6 +176,7 @@ it to the replay, but this greatly increases the size of the encoded simulation. .. _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. From 25fe1a108ae56beefba1d2fc5ea039fd627525de Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Fri, 29 May 2026 06:56:39 -0700 Subject: [PATCH 13/35] Another blank line? --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index 23ac150e7..7f33c12d2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -174,6 +174,7 @@ it to the replay, but this greatly increases the size of the encoded simulation. compatible across major versions of Scenic. .. _xosc_export: + OpenScenarioXML Export ---------------------- From 518c30810aedbac9a2331b6bbbd3f850cf19cb9a Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Fri, 29 May 2026 07:09:24 -0700 Subject: [PATCH 14/35] Fix scenariogeneration link --- src/scenic/core/serialization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index 3e339699c..4b0ed9dc7 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -404,7 +404,7 @@ def toOpenScenario( mapPath=None, scenarioName="ScenicScenario", ): - """Export a `SimulationResult` as a `scenariogeneration` `xosc` object. + """Export a `SimulationResult` as a `scenariogeneration.xosc` object. Args: simulationResult: The `SimulationResult` to be exported to XOSC From 96b3ee1bd6419196117a1effe94bb6cc959e4226 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Fri, 29 May 2026 08:09:14 -0700 Subject: [PATCH 15/35] Fixed link --- src/scenic/core/serialization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenic/core/serialization.py b/src/scenic/core/serialization.py index 4b0ed9dc7..97c083734 100644 --- a/src/scenic/core/serialization.py +++ b/src/scenic/core/serialization.py @@ -404,7 +404,7 @@ def toOpenScenario( mapPath=None, scenarioName="ScenicScenario", ): - """Export a `SimulationResult` as a `scenariogeneration.xosc` object. + """Export a `SimulationResult` as a `scenariogeneration.xosc.scenario `_ object. Args: simulationResult: The `SimulationResult` to be exported to XOSC From f9557b568a0eb60f626c3faa34d141454cdbbb76 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Sat, 30 May 2026 14:15:27 -0700 Subject: [PATCH 16/35] Added support for parallel behaviors. --- src/scenic/core/dynamics/behaviors.py | 37 +++++++++++++++++++++------ src/scenic/core/simulators.py | 2 ++ src/scenic/syntax/compiler.py | 4 +-- tests/syntax/test_dynamics.py | 32 +++++++++++++---------- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/scenic/core/dynamics/behaviors.py b/src/scenic/core/dynamics/behaviors.py index 7c12ef7c2..ebd85dfc6 100644 --- a/src/scenic/core/dynamics/behaviors.py +++ b/src/scenic/core/dynamics/behaviors.py @@ -109,7 +109,7 @@ def alarmHandler(signum, frame): try: actions = self._runningIterator.send(None) except StopIteration: - actions = () # behavior ended early + return None return actions def _stop(self, reason=None): @@ -124,18 +124,39 @@ def _isFinished(self): def _invokeInner(self, agent, subs): import scenic.syntax.veneer as veneer - assert len(subs) == 1 - sub = subs[0] - if not isinstance(sub, Behavior): - raise TypeError(f"expected a behavior, got {sub}") - sub._start(agent) - with veneer.executeInBehavior(sub): + # Validate all inner behaviors + for sub in subs: + if not isinstance(sub, Behavior): + raise TypeError(f"expected a behavior, got {sub}") + sub._start(agent) + + # Create a generator for each inner behavior that yields the appropriate actions, cleaning up when done. + def make_inner_generator(sub): try: - yield from sub._runningIterator + while sub._isRunning: + actions = sub._step() + if actions is None: + return + yield actions finally: if sub._isRunning: sub._stop() + inner_generators = [make_inner_generator(sub) for sub in subs] + + # Yield from a generator that zips all the inner generators together without padding, until all inner generators have terminated. + while True: + try: + raw_actions = next(itertools.zip_longest(*inner_generators)) + yield tuple( + filter( + lambda x: x is not None, + itertools.chain.from_iterable(raw_actions), + ) + ) + except StopIteration: + return + def __repr__(self): items = itertools.chain( (repr(arg) for arg in self._args), diff --git a/src/scenic/core/simulators.py b/src/scenic/core/simulators.py index f662c451d..97469f479 100644 --- a/src/scenic/core/simulators.py +++ b/src/scenic/core/simulators.py @@ -489,6 +489,8 @@ def _run(self, dynamicScenario, maxSteps): # Run the agent's behavior to get its actions actions = agent.behavior._step() + if actions is None: + actions = tuple() # Handle pseudo-actions marking the end of a simulation/scenario if isinstance(actions, _EndSimulationAction): diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index 0bf027823..ac1beeae9 100644 --- a/src/scenic/syntax/compiler.py +++ b/src/scenic/syntax/compiler.py @@ -1249,9 +1249,9 @@ def visit_TerminateSimulation(self, node: s.TerminateSimulation): @context(Context.DYNAMIC) def visit_Do(self, node: s.Do): - if (self.inBehavior or self.inMonitor) and len(node.elts) > 1: + if self.inMonitor and len(node.elts) > 1: raise self.makeSyntaxError( - f"`do` can only take one action inside a {'behavior' if self.inBehavior else 'monitor'}", + f"`do` can only take one action inside a monitor", node, ) return self.makeDoLike(node, node.elts) diff --git a/tests/syntax/test_dynamics.py b/tests/syntax/test_dynamics.py index 4699a2921..3a7bd3c43 100644 --- a/tests/syntax/test_dynamics.py +++ b/tests/syntax/test_dynamics.py @@ -188,6 +188,25 @@ def test_behavior_take_empty_tuple(): assert tuple(actions) == (None, 7) +def test_parallel_behaviors(): + scenario = compileScenic( + """ + behavior Fizz(): + take "Fizz" + + behavior Buzz(): + take "Buzz" + + behavior Foo(): + take "Start" + do Buzz(), Fizz() + ego = new Object with behavior Foo + """ + ) + actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) + assert tuple(actions) == (("Start",), ("Buzz", "Fizz")) + + # Various errors @@ -770,19 +789,6 @@ def test_behavior_invoke_mistyped(): sampleActions(scenario) -def test_behavior_invoke_multiple(): - with pytest.raises(ScenicSyntaxError): - compileScenic( - """ - behavior Foo(): - take 5 - behavior Bar(): - do Foo(), Foo() - ego = new Object with behavior Bar - """ - ) - - def test_behavior_tuple_invalid(): scenario = compileScenic( """ From 2c1551d8e560efcf538948603e47b2b85912a48a Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Sat, 30 May 2026 14:19:24 -0700 Subject: [PATCH 17/35] Naming tweak. --- src/scenic/core/dynamics/behaviors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenic/core/dynamics/behaviors.py b/src/scenic/core/dynamics/behaviors.py index ebd85dfc6..f33e2ce8e 100644 --- a/src/scenic/core/dynamics/behaviors.py +++ b/src/scenic/core/dynamics/behaviors.py @@ -147,7 +147,7 @@ def make_inner_generator(sub): # Yield from a generator that zips all the inner generators together without padding, until all inner generators have terminated. while True: try: - raw_actions = next(itertools.zip_longest(*inner_generators)) + raw_actions_list = next(itertools.zip_longest(*inner_generators)) yield tuple( filter( lambda x: x is not None, From 28f7c77eea290e8136a77731353ebde15b56b985 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Sat, 30 May 2026 14:22:19 -0700 Subject: [PATCH 18/35] Small fix. --- src/scenic/core/dynamics/behaviors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenic/core/dynamics/behaviors.py b/src/scenic/core/dynamics/behaviors.py index f33e2ce8e..1bab40903 100644 --- a/src/scenic/core/dynamics/behaviors.py +++ b/src/scenic/core/dynamics/behaviors.py @@ -151,7 +151,7 @@ def make_inner_generator(sub): yield tuple( filter( lambda x: x is not None, - itertools.chain.from_iterable(raw_actions), + itertools.chain.from_iterable(raw_actions_list), ) ) except StopIteration: From 2e62dc85dc8f32d45658275528231d39ce5139bf Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Sat, 30 May 2026 16:00:57 -0700 Subject: [PATCH 19/35] Consolidate PID controller code --- src/scenic/domains/driving/behaviors.scenic | 13 ++- src/scenic/domains/driving/controllers.py | 108 +++++++------------- 2 files changed, 49 insertions(+), 72 deletions(-) diff --git a/src/scenic/domains/driving/behaviors.scenic b/src/scenic/domains/driving/behaviors.scenic index 173500190..a7a575cb7 100644 --- a/src/scenic/domains/driving/behaviors.scenic +++ b/src/scenic/domains/driving/behaviors.scenic @@ -39,9 +39,20 @@ behavior WalkForwardBehavior(): behavior ConstantThrottleBehavior(x): take SetThrottleAction(x) +behavior FollowPathBehavior(path, target_speed, controllers): + """ + Follows the given path at the given target_speed, using the specified controller(s). + + Args: + path: A `PolylineRegion` representing the desired vehicle path. + target_speed: The desired speed to maintain. + controllers: Either a tuple (LonController, LatController) or a single controller for + longitudinal and lateral controls. + """ + behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTraffic=False): """ - Follow's the lane on which the vehicle is at, unless the laneToFollow is specified. + Follows the lane on which the vehicle is at, unless the laneToFollow is specified. Once the vehicle reaches an intersection, by default, the vehicle will take the straight route. If straight route is not available, then any availble turn route will be taken, uniformly randomly. If turning at the intersection, the vehicle will slow down to make the turn, safely. diff --git a/src/scenic/domains/driving/controllers.py b/src/scenic/domains/driving/controllers.py index ce7d46135..00c51f3d9 100644 --- a/src/scenic/domains/driving/controllers.py +++ b/src/scenic/domains/driving/controllers.py @@ -12,54 +12,49 @@ .. _CARLA: https://carla.org/ """ +from abc import ABC, abstractmethod from collections import deque import numpy as np -class PIDLongitudinalController: - """Longitudinal control using a PID to reach a target speed. +class LongitudinalController(ABC): + @abstractmethod + def compute_throttle(self): + pass - Arguments: - K_P: Proportional gain - K_D: Derivative gain - K_I: Integral gain - dt: time step - """ - def __init__(self, K_P=0.5, K_D=0.1, K_I=0.2, dt=0.1): - self._k_p = K_P - self._k_d = K_D - self._k_i = K_I - self._dt = dt - self._error_buffer = deque(maxlen=10) +class LateralController(ABC): + @abstractmethod + def compute_steering(self): + pass - def run_step(self, speed_error): - """Estimate the throttle/brake of the vehicle based on the PID equations. - Arguments: - speed_error: target speed minus current speed +class PIDController: + def __init__(self, K_P=0.5, K_D=0.1, K_I=0.2, dt=0.1): + self.kp = K_P + self.ki = K_I + self.kd = K_D + self.dt = dt + self.i_term = 0 + self.last_error = None + self.windup_guard = 20.0 - Returns: - a signal between -1 and 1, with negative values indicating braking. - """ - error = speed_error - self._error_buffer.append(error) + def run_step(self, error): + # Compute terms + p_term = error + self.i_term += np.clip(error * self.dt, -self.windup_guard, self.windup_guard) + d_term = (error - self.last_error) / self.dt if self.last_error else 0 - if len(self._error_buffer) >= 2: - _de = (self._error_buffer[-1] - self._error_buffer[-2]) / self._dt - _ie = sum(self._error_buffer) * self._dt - else: - _de = 0.0 - _ie = 0.0 + # Remember last error for next calculation + self.last_error = error - return np.clip( - (self._k_p * error) + (self._k_d * _de) + (self._k_i * _ie), -1.0, 1.0 - ) + output = (self.kp * p_term) + (self.ki * self.i_term) + (self.kd * d_term) + return np.clip(output, -1, 1) -class PIDLateralController: - """Lateral control using a PID to track a trajectory. +class PIDLongitudinalController(PIDController): + """Longitudinal control using a PID to reach a target speed. Arguments: K_P: Proportional gain @@ -68,42 +63,13 @@ class PIDLateralController: dt: time step """ - def __init__(self, K_P=0.3, K_D=0.2, K_I=0, dt=0.1): - self.Kp = K_P - self.Kd = K_D - self.Ki = K_I - self.PTerm = 0 - self.ITerm = 0 - self.DTerm = 0 - self.dt = dt - self.last_error = 0 - self.windup_guard = 20.0 - self.output = 0 - - def run_step(self, cte): - """Estimate the steering angle of the vehicle based on the PID equations. - - Arguments: - cte: cross-track error (distance to right of desired trajectory) - - Returns: - a signal between -1 and 1, with -1 meaning maximum steering to the left. - """ - error = cte - delta_error = error - self.last_error - self.PTerm = self.Kp * error - self.ITerm += error * self.dt - - if self.ITerm < -self.windup_guard: - self.ITerm = -self.windup_guard - elif self.ITerm > self.windup_guard: - self.ITerm = self.windup_guard - - self.DTerm = delta_error / self.dt - - # Remember last error for next calculation - self.last_error = error - self.output = self.PTerm + (self.Ki * self.ITerm) + (self.Kd * self.DTerm) +class PIDLateralController(PIDController): + """Lateral control using a PID to track a trajectory. - return np.clip(self.output, -1, 1) + Arguments: + K_P: Proportional gain + K_D: Derivative gain + K_I: Integral gain + dt: time step + """ From 04003e177facc37c22b39269f6f90d8bc4860b7f Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 1 Jun 2026 16:16:20 -0700 Subject: [PATCH 20/35] Initial pure pursuit implementation and reorginization. --- src/scenic/domains/driving/actions.py | 9 +- src/scenic/domains/driving/behaviors.scenic | 197 +++++++++++++------- src/scenic/domains/driving/controllers.py | 114 ++++++++++- src/scenic/domains/driving/model.scenic | 4 +- 4 files changed, 238 insertions(+), 86 deletions(-) diff --git a/src/scenic/domains/driving/actions.py b/src/scenic/domains/driving/actions.py index 7f99cce1a..f2ab406d1 100644 --- a/src/scenic/domains/driving/actions.py +++ b/src/scenic/domains/driving/actions.py @@ -15,6 +15,8 @@ import math +import numpy as np + from scenic.core.simulators import Action from scenic.core.vectors import Vector @@ -241,11 +243,8 @@ def __init__( brake = min(abs(throttle), max_brake) # Steering regulation: changes cannot happen abruptly, can't steer too much. - - if steer > past_steer + 0.1: - steer = past_steer + 0.1 - elif steer < past_steer - 0.1: - steer = past_steer - 0.1 + if past_steer is not None: + steer = np.clip(steer, past_steer - 0.1, past_steer + 0.1) if steer >= 0: steer = min(max_steer, steer) diff --git a/src/scenic/domains/driving/behaviors.scenic b/src/scenic/domains/driving/behaviors.scenic index a7a575cb7..80b90dd4a 100644 --- a/src/scenic/domains/driving/behaviors.scenic +++ b/src/scenic/domains/driving/behaviors.scenic @@ -49,8 +49,63 @@ behavior FollowPathBehavior(path, target_speed, controllers): controllers: Either a tuple (LonController, LatController) or a single controller for longitudinal and lateral controls. """ + _lon_controller, _lat_controller = controllers + while True: + speed_error = target_speed - self.speed + + # compute throttle : Longitudinal Control + throttle = _lon_controller.run_step(speed_error) + + # compute steering : Lateral Control + last_steer_angle = _lat_controller.lastSteerAngle + current_steer_angle = _lat_controller.computeSteering(path, self) + + print() + take RegulatedControlAction(throttle, current_steer_angle, past_steer=last_steer_angle) + +def getFollowLanePath(obj, minPathDistance, preferStraight, path_metadata=None): + import shapely + import itertools + + def mergeLineStrings(geoms): + return shapely.geometry.LineString(itertools.chain.from_iterable(geom.coords for geom in geoms)) + + if path_metadata is None: + current_lane = ego.lane + initial_path = obj.lane.centerline.lineString + else: + current_lane = path_metadata[0] + initial_path = path_metadata[1] + + assert isinstance(initial_path, shapely.geometry.LineString) + + ego_pt = shapely.geometry.Point(*obj.position) + path = shapely.ops.substring(initial_path, initial_path.project(ego_pt), initial_path.length) + + while path.length < minPathDistance: + straight_manuevers = [m for m in current_lane.maneuvers if m.type == ManeuverType.STRAIGHT] + + if preferStraight and straight_manuevers: + target_maneuver = Uniform(*straight_manuevers) + else: + if len(current_lane.maneuvers) > 0: + target_maneuver = Uniform(*current_lane.maneuvers) + else: + # No more maneuvers. Raise a warning and return centerline as is + warnings.warn("Could not generate path of desired distance. Returning path as is.") + break + + if target_maneuver.connectingLane != None: + path = mergeLineStrings([path, target_maneuver.connectingLane.centerline.lineString, target_maneuver.endLane.centerline.lineString]) + else: + path = mergeLineStrings([path, target_maneuver.endLane.centerline.lineString]) + + current_lane = target_maneuver.endLane -behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTraffic=False): + assert isinstance(path, shapely.geometry.LineString) + return PolylineRegion(polyline=path), (current_lane, path) + +behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, preferStraight=True, controllers=None): """ Follows the lane on which the vehicle is at, unless the laneToFollow is specified. Once the vehicle reaches an intersection, by default, the vehicle will take the straight route. @@ -61,7 +116,9 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTra e.g. do FollowLaneBehavior() until ... :param target_speed: Its unit is in m/s. By default, it is set to 10 m/s - :param laneToFollow: If the lane to follow is different from the lane that the vehicle is on, this parameter can be used to specify that lane. By default, this variable will be set to None, which means that the vehicle will follow the lane that it is currently on. + :param laneToFollow: If the lane to follow is different from the lane that the vehicle is on, this + parameter can be used to specify that lane. By default, this variable will be set to None, which + means that the vehicle will follow the lane that it is currently on. """ past_steer_angle = 0 @@ -88,78 +145,71 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, is_oppositeTra nearby_intersection = current_lane.centerline[-1] # instantiate longitudinal and lateral controllers - _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) - - while True: - - if self.speed is not None: - current_speed = self.speed - else: - current_speed = past_speed - - if not entering_intersection and (distance from self.position to nearby_intersection) < TRIGGER_DISTANCE_TO_SLOWDOWN: - entering_intersection = True - intersection_passed = False - straight_manuevers = filter(lambda i: i.type == ManeuverType.STRAIGHT, current_lane.maneuvers) - - if len(straight_manuevers) > 0: - select_maneuver = Uniform(*straight_manuevers) - else: - if len(current_lane.maneuvers) > 0: - select_maneuver = Uniform(*current_lane.maneuvers) - else: - take SetBrakeAction(1.0) - break - - # assumption: there always will be a maneuver - if select_maneuver.connectingLane != None: - current_centerline = concatenateCenterlines([current_centerline, select_maneuver.connectingLane.centerline, \ - select_maneuver.endLane.centerline]) - else: - current_centerline = concatenateCenterlines([current_centerline, select_maneuver.endLane.centerline]) - - current_lane = select_maneuver.endLane - end_lane = current_lane - - if current_lane.maneuvers != (): - nearby_intersection = current_lane.maneuvers[0].intersection - if nearby_intersection == None: - nearby_intersection = current_lane.centerline[-1] - else: - nearby_intersection = current_lane.centerline[-1] - - if select_maneuver.type != ManeuverType.STRAIGHT: - in_turning_lane = True - target_speed = TARGET_SPEED_FOR_TURNING - - do TurnBehavior(trajectory = current_centerline) - - - if (end_lane is not None) and (self.position in end_lane) and not intersection_passed: - intersection_passed = True - in_turning_lane = False - entering_intersection = False - target_speed = original_target_speed - _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) - - nearest_line_points = current_centerline.nearestSegmentTo(self.position) - nearest_line_segment = PolylineRegion(nearest_line_points) - cte = nearest_line_segment.signedDistanceTo(self.position) - if is_oppositeTraffic: - cte = -cte - - speed_error = target_speed - current_speed + if controllers: + _lon_controller, _lat_controller = controllers + else: + _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) - # compute throttle : Longitudinal Control - throttle = _lon_controller.run_step(speed_error) + path_metadata = None - # compute steering : Lateral Control - current_steer_angle = _lat_controller.run_step(cte) + # DEBUG + _lat_controller.simulation = simulation() - take RegulatedControlAction(throttle, current_steer_angle, past_steer_angle) - past_steer_angle = current_steer_angle - past_speed = current_speed + while True: + # if not entering_intersection and (distance from self.position to nearby_intersection) < TRIGGER_DISTANCE_TO_SLOWDOWN: + # entering_intersection = True + # intersection_passed = False + # straight_manuevers = filter(lambda i: i.type == ManeuverType.STRAIGHT, current_lane.maneuvers) + + # if len(straight_manuevers) > 0: + # select_maneuver = Uniform(*straight_manuevers) + # else: + # if len(current_lane.maneuvers) > 0: + # select_maneuver = Uniform(*current_lane.maneuvers) + # else: + # take SetBrakeAction(1.0) + # break + + # # assumption: there always will be a maneuver + # if select_maneuver.connectingLane != None: + # current_centerline = concatenateCenterlines([current_centerline, select_maneuver.connectingLane.centerline, \ + # select_maneuver.endLane.centerline]) + # else: + # current_centerline = concatenateCenterlines([current_centerline, select_maneuver.endLane.centerline]) + + # current_lane = select_maneuver.endLane + # end_lane = current_lane + + # if current_lane.maneuvers != (): + # nearby_intersection = current_lane.maneuvers[0].intersection + # if nearby_intersection == None: + # nearby_intersection = current_lane.centerline[-1] + # else: + # nearby_intersection = current_lane.centerline[-1] + + # if select_maneuver.type != ManeuverType.STRAIGHT: + # in_turning_lane = True + # target_speed = TARGET_SPEED_FOR_TURNING + + # do TurnBehavior(trajectory=current_centerline, target_speed=target_speed, controllers=(_lon_controller, _lat_controller)) + + + # if (end_lane is not None) and (self.position in end_lane) and not intersection_passed: + # intersection_passed = True + # in_turning_lane = False + # entering_intersection = False + # target_speed = original_target_speed + # # _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) + + # nearest_line_points = current_centerline.nearestSegmentTo(self.position) + # nearest_line_segment = PolylineRegion(nearest_line_points) + # path = nearest_line_segment + + replan_time = 5 + min_path_distance = max(2*replan_time*self.speed, 50) + path, path_metadata = getFollowLanePath(self, min_path_distance, preferStraight=preferStraight, path_metadata=path_metadata) + do FollowPathBehavior(path, target_speed, controllers) for replan_time seconds behavior FollowTrajectoryBehavior(target_speed = 10, trajectory = None, turn_speed=None): """ @@ -217,7 +267,7 @@ behavior FollowTrajectoryBehavior(target_speed = 10, trajectory = None, turn_spe -behavior TurnBehavior(trajectory, target_speed=6): +behavior TurnBehavior(trajectory, target_speed=6, controllers=None): """ This behavior uses a controller specifically tuned for turning at an intersection. This behavior is only operational within an intersection, @@ -230,7 +280,10 @@ behavior TurnBehavior(trajectory, target_speed=6): trajectory_centerline = concatenateCenterlines([traj.centerline for traj in trajectory]) # instantiate longitudinal and lateral controllers - _lon_controller, _lat_controller = simulation().getTurningControllers(self) + if controllers: + _lon_controller, _lat_controller = controllers + else: + _lon_controller, _lat_controller = simulation().getTurningControllers(self) past_steer_angle = 0 diff --git a/src/scenic/domains/driving/controllers.py b/src/scenic/domains/driving/controllers.py index 00c51f3d9..5aecf9493 100644 --- a/src/scenic/domains/driving/controllers.py +++ b/src/scenic/domains/driving/controllers.py @@ -14,43 +14,62 @@ from abc import ABC, abstractmethod from collections import deque +import math import numpy as np +import shapely +from shapely.geometry import LineString, MultiPoint, Point as ShapelyPoint + +from scenic.core.regions import CircularRegion, PolylineRegion, toPolygon +from scenic.core.vectors import Vector class LongitudinalController(ABC): @abstractmethod - def compute_throttle(self): + def computeThrottle(self): pass class LateralController(ABC): + def __init__(self): + super().__init__() + self.lastSteerAngle = None + @abstractmethod - def compute_steering(self): + def computeSteering(self, trajectory, obj): pass class PIDController: - def __init__(self, K_P=0.5, K_D=0.1, K_I=0.2, dt=0.1): + def __init__(self, K_P, K_D, K_I, dt): + super().__init__() self.kp = K_P self.ki = K_I self.kd = K_D self.dt = dt self.i_term = 0 self.last_error = None - self.windup_guard = 20.0 + self.windup_guard = 0.5 / self.ki if self.ki != 0 else 0 def run_step(self, error): # Compute terms p_term = error - self.i_term += np.clip(error * self.dt, -self.windup_guard, self.windup_guard) + self.i_term += error * self.dt + self.i_term = np.clip(self.i_term, -self.windup_guard, self.windup_guard) d_term = (error - self.last_error) / self.dt if self.last_error else 0 + print(f"Error: {error}, LastError: {self.last_error}") # Remember last error for next calculation self.last_error = error output = (self.kp * p_term) + (self.ki * self.i_term) + (self.kd * d_term) - return np.clip(output, -1, 1) + clipped_output = np.clip(output, -1, 1) + print(f"p_term: {p_term}") + print(f"i_term: {self.i_term}") + print(f"d_term: {d_term}") + print(f"PID Output: {output}") + print(f"PID Clipped Output: {clipped_output}") + return clipped_output class PIDLongitudinalController(PIDController): @@ -63,8 +82,11 @@ class PIDLongitudinalController(PIDController): dt: time step """ + def __init__(self, K_P=0.5, K_D=0.1, K_I=0.2, dt=0.1): + super().__init__(K_P, K_D, K_I, dt) + -class PIDLateralController(PIDController): +class PIDLateralController(PIDController, LateralController): """Lateral control using a PID to track a trajectory. Arguments: @@ -73,3 +95,81 @@ class PIDLateralController(PIDController): K_I: Integral gain dt: time step """ + + def __init__(self, K_P=0.3, K_D=0.2, K_I=0, dt=0.1): + super().__init__(K_P, K_D, K_I, dt) + + def computeSteering(self, trajectory, obj): + assert isinstance(trajectory, PolylineRegion) + cte = trajectory.signedDistanceTo(obj.position) + # TODO: opposite traffic check? + steer_angle = self.run_step(cte) + self.lastSteerAngle = steer_angle + return steer_angle + + +class PurePursuitLateralController(LateralController): + def __init__(self, lookaheadDistance=lambda obj: obj.speed + 1): + super().__init__() + self.lookaheadDistance = lookaheadDistance + self._lastTargetPoint = None + + def _findTargetPoint(self, trajectory, obj, lookaheadDistance): + assert isinstance(trajectory, PolylineRegion) + traj_line_string = toPolygon(trajectory) + + # Find candidate target points + obj_pt = ShapelyPoint(*obj.position) + obj_traj_dist = traj_line_string.project(obj_pt) + forward_traj = shapely.ops.substring( + traj_line_string, obj_traj_dist, traj_line_string.length + ) + lookahead_circle = toPolygon( + CircularRegion(forward_traj.coords[0], lookaheadDistance) + ) + intersection_geometry = lookahead_circle.boundary.intersection(forward_traj) + + if intersection_geometry.is_empty: + # No viable target points. If we have a last target point, aim for that. Otherwise, + # aim for the closest point on the trajectory. + if self._lastTargetPoint: + target_point = self._lastTargetPoint + else: + target_point = trajectory.project(obj.position) + elif isinstance(intersection_geometry, ShapelyPoint): + target_point = intersection_geometry + elif isinstance(intersection_geometry, MultiPoint): + # There are multiple candidate target points. Pick the one that appears first on the path. + target_point = sorted( + intersection_geometry.geoms, key=lambda pt: shapely.distance(pt, obj_pt) + )[0] + else: + # We've gotten something strange. Fall back to a representative point as the target. + target_point = intersection_geometry.representative_point() + + # Store last target point and return + self._lastTargetPoint = target_point + return Vector(target_point.x, target_point.y) + + def computeSteering(self, trajectory, obj): + # Compute target steering angle + lookaheadDistance = self.lookaheadDistance(obj) + targetPoint = self._findTargetPoint(trajectory, obj, lookaheadDistance) + alpha = obj.angleTo(targetPoint) - obj.heading + delta = -math.atan2(2 * obj.wheelbase * math.sin(alpha), lookaheadDistance) + + # Convert target steering angle to relative value in [-1, 1] + rel_steering_angle = np.clip(delta / obj.maxSteeringAngle, -1, 1) + + # DEBUG + print(f"SPEED: {obj.speed}") + print(f"ALPHA: {delta}") + print(f"DELTA: {rel_steering_angle}") + print(f"RELATIVE STEERING ANGLE: {rel_steering_angle}") + # import pygame + # pygame.draw.circle(self.simulation.screen, (0,1,0), self.simulation.scenicToScreenVal(targetPoint), 5) + # pygame.display.update() + # import time + # time.sleep(0.2) + self.lastSteerAngle = rel_steering_angle + return rel_steering_angle diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index 8db0c2805..f5fbed5ae 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -290,7 +290,7 @@ class Vehicle(DrivingObject): 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 - 30 degrees. + 40 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. @@ -314,7 +314,7 @@ class Vehicle(DrivingObject): length: 4.5 color: Color.defaultCarColor() wheelbase: 0.6*self.length - maxSteeringAngle: 35 deg + maxSteeringAngle: 40 deg wheelDiameter: 0.7 trackWidth: 0.85*self.width groundClearance: 0.5*self.wheelDiameter From 6d802b231f4aaf0fda2acb1f3789b9f21efa2c2e Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 22 Jun 2026 16:59:27 -0700 Subject: [PATCH 21/35] Progress on controllers --- src/scenic/domains/driving/behaviors.scenic | 201 +++++++++----------- src/scenic/domains/driving/controllers.py | 99 +++++----- src/scenic/domains/driving/model.scenic | 12 ++ 3 files changed, 147 insertions(+), 165 deletions(-) diff --git a/src/scenic/domains/driving/behaviors.scenic b/src/scenic/domains/driving/behaviors.scenic index 80b90dd4a..1ca88063a 100644 --- a/src/scenic/domains/driving/behaviors.scenic +++ b/src/scenic/domains/driving/behaviors.scenic @@ -4,13 +4,16 @@ These behaviors are automatically imported when using the driving domain. """ import math +from abc import ABC, abstractmethod +import shapely +from shapely.geometry import LineString, MultiPoint, Point as ShapelyPoint + +from scenic.core.regions import toPolygon from scenic.domains.driving.actions import * import scenic.domains.driving.model as _model from scenic.domains.driving.roads import ManeuverType -def concatenateCenterlines(centerlines=[]): - return PolylineRegion.unionAll(centerlines) behavior ConstantThrottleBehavior(x): while True: @@ -39,30 +42,6 @@ behavior WalkForwardBehavior(): behavior ConstantThrottleBehavior(x): take SetThrottleAction(x) -behavior FollowPathBehavior(path, target_speed, controllers): - """ - Follows the given path at the given target_speed, using the specified controller(s). - - Args: - path: A `PolylineRegion` representing the desired vehicle path. - target_speed: The desired speed to maintain. - controllers: Either a tuple (LonController, LatController) or a single controller for - longitudinal and lateral controls. - """ - _lon_controller, _lat_controller = controllers - while True: - speed_error = target_speed - self.speed - - # compute throttle : Longitudinal Control - throttle = _lon_controller.run_step(speed_error) - - # compute steering : Lateral Control - last_steer_angle = _lat_controller.lastSteerAngle - current_steer_angle = _lat_controller.computeSteering(path, self) - - print() - take RegulatedControlAction(throttle, current_steer_angle, past_steer=last_steer_angle) - def getFollowLanePath(obj, minPathDistance, preferStraight, path_metadata=None): import shapely import itertools @@ -105,7 +84,7 @@ def getFollowLanePath(obj, minPathDistance, preferStraight, path_metadata=None): assert isinstance(path, shapely.geometry.LineString) return PolylineRegion(polyline=path), (current_lane, path) -behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, preferStraight=True, controllers=None): +behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, preferStraight=True): """ Follows the lane on which the vehicle is at, unless the laneToFollow is specified. Once the vehicle reaches an intersection, by default, the vehicle will take the straight route. @@ -121,97 +100,97 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, preferStraight means that the vehicle will follow the lane that it is currently on. """ - past_steer_angle = 0 - past_speed = 0 # making an assumption here that the agent starts from zero speed - if laneToFollow is None: - current_lane = self.lane - else: - current_lane = laneToFollow + assert self.longitudinalController is not None + assert self.lateralController is not None - current_centerline = current_lane.centerline - in_turning_lane = False # assumption that the agent is not instantiated within a connecting lane - intersection_passed = False - entering_intersection = False # assumption that the agent is not instantiated within an intersection - end_lane = None - original_target_speed = target_speed - TARGET_SPEED_FOR_TURNING = 5 # KM/H - TRIGGER_DISTANCE_TO_SLOWDOWN = 10 # FOR TURNING AT INTERSECTIONS + path_metadata = None - if current_lane.maneuvers != (): - nearby_intersection = current_lane.maneuvers[0].intersection - if nearby_intersection == None: - nearby_intersection = current_lane.centerline[-1] - else: - nearby_intersection = current_lane.centerline[-1] + while True: + replan_time = 10 + min_path_distance = max(2*replan_time*target_speed, 50) + path, path_metadata = getFollowLanePath(self, min_path_distance, preferStraight=preferStraight, path_metadata=path_metadata) + traj = Trajectory.createFixedSpeedTrajectory(path, target_speed, ts=simulation().timestep) + do FollowTrajectoryBehavior(traj) for replan_time seconds - # instantiate longitudinal and lateral controllers - if controllers: - _lon_controller, _lat_controller = controllers - else: - _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) +class Trajectory(object): + def __init__(self, polyline, ts): + assert isinstance(polyline, PolylineRegion) - path_metadata = None + self.polyline = polyline + self.ts = ts - # DEBUG - _lat_controller.simulation = simulation() + @property + def start(self): + return self.polyline.start - while True: + @property + def end(self): + return self.polyline.end - # if not entering_intersection and (distance from self.position to nearby_intersection) < TRIGGER_DISTANCE_TO_SLOWDOWN: - # entering_intersection = True - # intersection_passed = False - # straight_manuevers = filter(lambda i: i.type == ManeuverType.STRAIGHT, current_lane.maneuvers) - - # if len(straight_manuevers) > 0: - # select_maneuver = Uniform(*straight_manuevers) - # else: - # if len(current_lane.maneuvers) > 0: - # select_maneuver = Uniform(*current_lane.maneuvers) - # else: - # take SetBrakeAction(1.0) - # break - - # # assumption: there always will be a maneuver - # if select_maneuver.connectingLane != None: - # current_centerline = concatenateCenterlines([current_centerline, select_maneuver.connectingLane.centerline, \ - # select_maneuver.endLane.centerline]) - # else: - # current_centerline = concatenateCenterlines([current_centerline, select_maneuver.endLane.centerline]) - - # current_lane = select_maneuver.endLane - # end_lane = current_lane - - # if current_lane.maneuvers != (): - # nearby_intersection = current_lane.maneuvers[0].intersection - # if nearby_intersection == None: - # nearby_intersection = current_lane.centerline[-1] - # else: - # nearby_intersection = current_lane.centerline[-1] - - # if select_maneuver.type != ManeuverType.STRAIGHT: - # in_turning_lane = True - # target_speed = TARGET_SPEED_FOR_TURNING - - # do TurnBehavior(trajectory=current_centerline, target_speed=target_speed, controllers=(_lon_controller, _lat_controller)) - - - # if (end_lane is not None) and (self.position in end_lane) and not intersection_passed: - # intersection_passed = True - # in_turning_lane = False - # entering_intersection = False - # target_speed = original_target_speed - # # _lon_controller, _lat_controller = simulation().getLaneFollowingControllers(self) - - # nearest_line_points = current_centerline.nearestSegmentTo(self.position) - # nearest_line_segment = PolylineRegion(nearest_line_points) - # path = nearest_line_segment - - replan_time = 5 - min_path_distance = max(2*replan_time*self.speed, 50) - path, path_metadata = getFollowLanePath(self, min_path_distance, preferStraight=preferStraight, path_metadata=path_metadata) - do FollowPathBehavior(path, target_speed, controllers) for replan_time seconds + @property + def duration(self): + return self.ts * len(self.polyline.points) + + @property + def length(self): + return self.polyline.length + + def getRelativeTime(self, pos): + return toPolygon(self.polyline).project(ShapelyPoint(*pos), normalized=True)*self.duration + + def getTimedDistance(self, timeA, timeB): + return shapely.ops.substring(toPolygon(self.polyline), timeA/self.duration, timeB/self.duration, normalized=True).length + + def __getitem__(self, time): + pt = toPolygon(self.polyline).interpolate(time/self.duration, normalized=True) + return Vector(pt.x, pt.y) + + @staticmethod + def createFixedSpeedTrajectory(polyline, targetSpeed, ts): + target_dist = 0 + points = [] + while target_dist < polyline.length: + points.append(polyline.lineString.interpolate(target_dist)) + target_dist += targetSpeed*ts + + return Trajectory(PolylineRegion(polyline=LineString(points)), ts=ts) + +behavior FollowTrajectoryBehavior(trajectory, terminationDistance=1): + """ + Follows the given `Trajectory`. + + The behavior terminates when either of the following conditions are met the vehicle position is within + `terminationDistance` of the end of the trajectory. -behavior FollowTrajectoryBehavior(target_speed = 10, trajectory = None, turn_speed=None): + Args: + trajectory: A `Trajectory`. + terminationDistance: The behavior will terminate when the vehicle position is within `terminationDistance` + of the end of the trajectory. + """ + assert isinstance(trajectory, Trajectory) + assert self.longitudinalController is not None + assert self.lateralController is not None + + while distance from self.position to trajectory.end > terminationDistance: + # Compute throttle : Longitudinal Control + throttle = self.longitudinalController.computeThrottle(trajectory, self) + if throttle > 0: + throttle_action = SetThrottleAction(throttle) + else: + throttle_action = SetBrakeAction(-throttle) + + # Compute steering : Lateral Control + steer = self.lateralController.computeSteering(trajectory, self, simulation()) + steer_action = SetSteerAction(steer) + + take throttle_action, steer_action + +## Legacy Behaviors ## + +def concatenateCenterlines(centerlines=[]): + return PolylineRegion.unionAll(centerlines) + +behavior FollowTrajectoryBehaviorOld(target_speed = 10, trajectory = None, turn_speed=None): """ Follows the given trajectory. The behavior terminates once the end of the trajectory is reached. @@ -265,8 +244,6 @@ behavior FollowTrajectoryBehavior(target_speed = 10, trajectory = None, turn_spe take RegulatedControlAction(throttle, current_steer_angle, past_steer_angle) past_steer_angle = current_steer_angle - - behavior TurnBehavior(trajectory, target_speed=6, controllers=None): """ This behavior uses a controller specifically tuned for turning at an intersection. diff --git a/src/scenic/domains/driving/controllers.py b/src/scenic/domains/driving/controllers.py index 5aecf9493..59ce92868 100644 --- a/src/scenic/domains/driving/controllers.py +++ b/src/scenic/domains/driving/controllers.py @@ -26,27 +26,23 @@ class LongitudinalController(ABC): @abstractmethod - def computeThrottle(self): + def computeThrottle(self, trajectory, veh): pass class LateralController(ABC): - def __init__(self): - super().__init__() - self.lastSteerAngle = None - @abstractmethod - def computeSteering(self, trajectory, obj): + def computeSteering(self, trajectory, veh): pass class PIDController: - def __init__(self, K_P, K_D, K_I, dt): + def __init__(self, dt, *, K_P, K_D, K_I, wg): super().__init__() + self.dt = dt self.kp = K_P self.ki = K_I self.kd = K_D - self.dt = dt self.i_term = 0 self.last_error = None self.windup_guard = 0.5 / self.ki if self.ki != 0 else 0 @@ -58,71 +54,73 @@ def run_step(self, error): self.i_term = np.clip(self.i_term, -self.windup_guard, self.windup_guard) d_term = (error - self.last_error) / self.dt if self.last_error else 0 - print(f"Error: {error}, LastError: {self.last_error}") # Remember last error for next calculation self.last_error = error output = (self.kp * p_term) + (self.ki * self.i_term) + (self.kd * d_term) clipped_output = np.clip(output, -1, 1) - print(f"p_term: {p_term}") - print(f"i_term: {self.i_term}") - print(f"d_term: {d_term}") - print(f"PID Output: {output}") - print(f"PID Clipped Output: {clipped_output}") return clipped_output -class PIDLongitudinalController(PIDController): +class PIDLongitudinalController(PIDController, LongitudinalController): """Longitudinal control using a PID to reach a target speed. Arguments: + dt: time step K_P: Proportional gain K_D: Derivative gain K_I: Integral gain - dt: time step + wg: The windup guard's cap on the integral components contribution + to the total control signal. """ - def __init__(self, K_P=0.5, K_D=0.1, K_I=0.2, dt=0.1): - super().__init__(K_P, K_D, K_I, dt) + def __init__(self, dt=0.1, *, K_P=0.5, K_D=0.1, K_I=0.2, wg=0.5): + super().__init__(dt=dt, K_P=K_P, K_D=K_D, K_I=K_I, wg=wg) + + def computeThrottle(self, trajectory, veh): + curr_time = trajectory.getRelativeTime(veh.position) + ts_dist = trajectory.getTimedDistance(curr_time, curr_time + trajectory.ts) + target_speed = ts_dist / trajectory.ts + + cte = target_speed - veh.speed + return self.run_step(cte) class PIDLateralController(PIDController, LateralController): """Lateral control using a PID to track a trajectory. Arguments: + dt: time step K_P: Proportional gain K_D: Derivative gain K_I: Integral gain - dt: time step + wg: The windup guard's cap on the integral components contribution + to the total control signal. """ - def __init__(self, K_P=0.3, K_D=0.2, K_I=0, dt=0.1): - super().__init__(K_P, K_D, K_I, dt) + def __init__(self, dt=0.1, *, K_P=0.3, K_D=0.2, K_I=0, wg=0): + super().__init__(dt=dt, K_P=K_P, K_D=K_D, K_I=K_I, wg=wg) - def computeSteering(self, trajectory, obj): - assert isinstance(trajectory, PolylineRegion) - cte = trajectory.signedDistanceTo(obj.position) - # TODO: opposite traffic check? + def computeSteering(self, trajectory, veh): + cte = trajectory.signedDistanceTo(veh.position) steer_angle = self.run_step(cte) - self.lastSteerAngle = steer_angle return steer_angle class PurePursuitLateralController(LateralController): - def __init__(self, lookaheadDistance=lambda obj: obj.speed + 1): + def __init__(self, lookaheadDistance=lambda veh: veh.speed + 1): super().__init__() self.lookaheadDistance = lookaheadDistance self._lastTargetPoint = None - def _findTargetPoint(self, trajectory, obj, lookaheadDistance): - assert isinstance(trajectory, PolylineRegion) - traj_line_string = toPolygon(trajectory) + def _findTargetPoint(self, trajectory, veh, lookaheadDistance): + traj_line_string = toPolygon(trajectory.polyline) # Find candidate target points - obj_pt = ShapelyPoint(*obj.position) - obj_traj_dist = traj_line_string.project(obj_pt) + veh_pt = ShapelyPoint(*veh.position) + veh_traj_dist = traj_line_string.project(veh_pt) forward_traj = shapely.ops.substring( - traj_line_string, obj_traj_dist, traj_line_string.length + traj_line_string, veh_traj_dist, traj_line_string.length ) lookahead_circle = toPolygon( CircularRegion(forward_traj.coords[0], lookaheadDistance) @@ -135,13 +133,16 @@ def _findTargetPoint(self, trajectory, obj, lookaheadDistance): if self._lastTargetPoint: target_point = self._lastTargetPoint else: - target_point = trajectory.project(obj.position) + target_point = trajectory.polyline.project(veh.position) + + # Our target point isn't actually at the correct lookaheadDistance, so we need to update it. + lookaheadDistance = shapely.distance(veh_pt, target_point) elif isinstance(intersection_geometry, ShapelyPoint): target_point = intersection_geometry elif isinstance(intersection_geometry, MultiPoint): # There are multiple candidate target points. Pick the one that appears first on the path. target_point = sorted( - intersection_geometry.geoms, key=lambda pt: shapely.distance(pt, obj_pt) + intersection_geometry.geoms, key=lambda pt: traj_line_string.project(pt) )[0] else: # We've gotten something strange. Fall back to a representative point as the target. @@ -151,25 +152,17 @@ def _findTargetPoint(self, trajectory, obj, lookaheadDistance): self._lastTargetPoint = target_point return Vector(target_point.x, target_point.y) - def computeSteering(self, trajectory, obj): + def computeSteering(self, trajectory, veh, simulation): # Compute target steering angle - lookaheadDistance = self.lookaheadDistance(obj) - targetPoint = self._findTargetPoint(trajectory, obj, lookaheadDistance) - alpha = obj.angleTo(targetPoint) - obj.heading - delta = -math.atan2(2 * obj.wheelbase * math.sin(alpha), lookaheadDistance) + lookaheadDistance = self.lookaheadDistance(veh) + targetPoint = self._findTargetPoint(trajectory, veh, lookaheadDistance) + rw_position = veh.position.offsetRotated( + veh.heading, Vector(0, -0.5 * veh.wheelbase, 0) + ) + alpha = rw_position.angleTo(targetPoint) - veh.heading + delta = -math.atan2(2 * veh.wheelbase * math.sin(alpha), lookaheadDistance) # Convert target steering angle to relative value in [-1, 1] - rel_steering_angle = np.clip(delta / obj.maxSteeringAngle, -1, 1) - - # DEBUG - print(f"SPEED: {obj.speed}") - print(f"ALPHA: {delta}") - print(f"DELTA: {rel_steering_angle}") - print(f"RELATIVE STEERING ANGLE: {rel_steering_angle}") - # import pygame - # pygame.draw.circle(self.simulation.screen, (0,1,0), self.simulation.scenicToScreenVal(targetPoint), 5) - # pygame.display.update() - # import time - # time.sleep(0.2) - self.lastSteerAngle = rel_steering_angle + rel_steering_angle = np.clip(delta / veh.maxSteeringAngle, -1, 1) + return rel_steering_angle diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index f5fbed5ae..b13298c51 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -313,6 +313,10 @@ class Vehicle(DrivingObject): width: 2 length: 4.5 color: Color.defaultCarColor() + + lateralController: None + longitudinalController: None + wheelbase: 0.6*self.length maxSteeringAngle: 40 deg wheelDiameter: 0.7 @@ -326,6 +330,14 @@ class Vehicle(DrivingObject): def isVehicle(self): return True + def startDynamicSimulation(self): + defaultLongitudinalController, defaultLateralController = simulation().getLaneFollowingControllers(self) + + if self.longitudinalController is None: + self.longitudinalController = defaultLongitudinalController + if self.lateralController is None: + self.lateralController = defaultLateralController + class Car(Vehicle): """A car.""" @property From b163047b795c57aab0184cb03e5cf3638ce7ff71 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 12:20:20 -0700 Subject: [PATCH 22/35] Added fix for lane order in backwards LaneGroups. --- src/scenic/domains/driving/roads.py | 2 +- src/scenic/formats/opendrive/xodr_parser.py | 2 +- tests/domains/driving/test_network.py | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/scenic/domains/driving/roads.py b/src/scenic/domains/driving/roads.py index f06377c5a..3fecfaf86 100644 --- a/src/scenic/domains/driving/roads.py +++ b/src/scenic/domains/driving/roads.py @@ -987,7 +987,7 @@ def _currentFormatVersion(cls): :meta private: """ - return 35 + return 36 class DigestMismatchError(Exception): """Exception raised when loading a cached map not matching the original file.""" diff --git a/src/scenic/formats/opendrive/xodr_parser.py b/src/scenic/formats/opendrive/xodr_parser.py index 8175d2c83..cf274db09 100644 --- a/src/scenic/formats/opendrive/xodr_parser.py +++ b/src/scenic/formats/opendrive/xodr_parser.py @@ -1143,7 +1143,7 @@ def getEdges(forward): leftEdge=leftEdge, rightEdge=rightEdge, road=None, - lanes=tuple(backwardLanes), + lanes=tuple(reversed(backwardLanes)), curb=(backwardShoulder.rightEdge if backwardShoulder else rightEdge), sidewalk=backwardSidewalk, bikeLane=None, diff --git a/tests/domains/driving/test_network.py b/tests/domains/driving/test_network.py index b03061cd2..e3c6ee460 100644 --- a/tests/domains/driving/test_network.py +++ b/tests/domains/driving/test_network.py @@ -1,8 +1,11 @@ from pathlib import Path +import random import pytest +import shapely from scenic.core.distributions import RejectionException +from scenic.core.regions import toPolygon from scenic.domains.driving.roads import Intersection, Network from tests.domains.driving.conftest import mapFolder @@ -257,6 +260,16 @@ def test_sidewalk(network): assert network.elementAt(pt) is sw +def test_laneGroup_lane_order(network): + for _ in range(30): + lg = random.choice(network.laneGroups) + lane_0_dist = shapely.distance(toPolygon(lg.lanes[0]), toPolygon(lg.curb)) + lane_distances = [ + shapely.distance(toPolygon(lane), toPolygon(lg.curb)) for lane in lg.lanes + ] + assert all(lane_dist >= lane_0_dist - 0.1 for lane_dist in lane_distances) + + # --- Tests for cached network pickles --- From 4d6abc2bf0188512f7f860a02abfaf063fee7f77 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 13:31:07 -0700 Subject: [PATCH 23/35] Additional controller tweaks. --- src/scenic/domains/driving/behaviors.scenic | 22 +++++++++++++-------- src/scenic/domains/driving/controllers.py | 4 ++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/scenic/domains/driving/behaviors.scenic b/src/scenic/domains/driving/behaviors.scenic index 1ca88063a..0f835903e 100644 --- a/src/scenic/domains/driving/behaviors.scenic +++ b/src/scenic/domains/driving/behaviors.scenic @@ -42,22 +42,27 @@ behavior WalkForwardBehavior(): behavior ConstantThrottleBehavior(x): take SetThrottleAction(x) -def getFollowLanePath(obj, minPathDistance, preferStraight, path_metadata=None): +def getFollowLanePath(obj, minPathDistance, preferStraight, laneToFollow=None, path_metadata=None): import shapely import itertools - def mergeLineStrings(geoms): - return shapely.geometry.LineString(itertools.chain.from_iterable(geom.coords for geom in geoms)) + if laneToFollow is None: + laneToFollow = obj.lane + elif not isinstance(laneToFollow, Lane): + raise ValueError("`laneToFollow` is not a `Lane`.") if path_metadata is None: - current_lane = ego.lane - initial_path = obj.lane.centerline.lineString + current_lane = laneToFollow + initial_path = current_lane.centerline.lineString else: current_lane = path_metadata[0] initial_path = path_metadata[1] assert isinstance(initial_path, shapely.geometry.LineString) + def mergeLineStrings(geoms): + return shapely.geometry.LineString(itertools.chain.from_iterable(geom.coords for geom in geoms)) + ego_pt = shapely.geometry.Point(*obj.position) path = shapely.ops.substring(initial_path, initial_path.project(ego_pt), initial_path.length) @@ -84,7 +89,7 @@ def getFollowLanePath(obj, minPathDistance, preferStraight, path_metadata=None): assert isinstance(path, shapely.geometry.LineString) return PolylineRegion(polyline=path), (current_lane, path) -behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, preferStraight=True): +behavior FollowLaneBehavior(target_speed=10, laneToFollow=None, preferStraight=True): """ Follows the lane on which the vehicle is at, unless the laneToFollow is specified. Once the vehicle reaches an intersection, by default, the vehicle will take the straight route. @@ -108,7 +113,8 @@ behavior FollowLaneBehavior(target_speed = 10, laneToFollow=None, preferStraight while True: replan_time = 10 min_path_distance = max(2*replan_time*target_speed, 50) - path, path_metadata = getFollowLanePath(self, min_path_distance, preferStraight=preferStraight, path_metadata=path_metadata) + path, path_metadata = getFollowLanePath(self, min_path_distance, + preferStraight=preferStraight, laneToFollow=laneToFollow, path_metadata=path_metadata) traj = Trajectory.createFixedSpeedTrajectory(path, target_speed, ts=simulation().timestep) do FollowTrajectoryBehavior(traj) for replan_time seconds @@ -180,7 +186,7 @@ behavior FollowTrajectoryBehavior(trajectory, terminationDistance=1): throttle_action = SetBrakeAction(-throttle) # Compute steering : Lateral Control - steer = self.lateralController.computeSteering(trajectory, self, simulation()) + steer = self.lateralController.computeSteering(trajectory, self) steer_action = SetSteerAction(steer) take throttle_action, steer_action diff --git a/src/scenic/domains/driving/controllers.py b/src/scenic/domains/driving/controllers.py index 59ce92868..f45298186 100644 --- a/src/scenic/domains/driving/controllers.py +++ b/src/scenic/domains/driving/controllers.py @@ -102,7 +102,7 @@ def __init__(self, dt=0.1, *, K_P=0.3, K_D=0.2, K_I=0, wg=0): super().__init__(dt=dt, K_P=K_P, K_D=K_D, K_I=K_I, wg=wg) def computeSteering(self, trajectory, veh): - cte = trajectory.signedDistanceTo(veh.position) + cte = trajectory.polyline.signedDistanceTo(veh.position) steer_angle = self.run_step(cte) return steer_angle @@ -152,7 +152,7 @@ def _findTargetPoint(self, trajectory, veh, lookaheadDistance): self._lastTargetPoint = target_point return Vector(target_point.x, target_point.y) - def computeSteering(self, trajectory, veh, simulation): + def computeSteering(self, trajectory, veh): # Compute target steering angle lookaheadDistance = self.lookaheadDistance(veh) targetPoint = self._findTargetPoint(trajectory, veh, lookaheadDistance) From cdfef5bd0ee8b64099b20ea2fa6c96df7833e521 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 14:19:59 -0700 Subject: [PATCH 24/35] Added followFromTrajectory method. --- src/scenic/core/vectors.py | 30 +++++++++++++++++++++++++++++- tests/core/test_vectors.py | 18 ++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/scenic/core/vectors.py b/src/scenic/core/vectors.py index c4ae11f48..912a79e36 100644 --- a/src/scenic/core/vectors.py +++ b/src/scenic/core/vectors.py @@ -702,6 +702,24 @@ def __getitem__(self, pos) -> Orientation: val, f"value function of {self.name} returned non-orientation" ) + @distributionMethod + def followFromTrajectory(self, pos, dist, steps=None, stepSize=None): + """Follow the field from a point for a given distance, returning intermediate points. + + Uses the forward Euler approximation, covering the given distance with + equal-size steps. The number of steps can be given manually, or computed + automatically from a desired step size. + + Arguments: + pos (`Vector`): point to start from. + dist (float): distance to travel. + steps (int): number of steps to take, or :obj:`None` to compute the number of + steps based on the distance (default :obj:`None`). + stepSize (float): length used to compute how many steps to take, or + :obj:`None` to use the field's default step size. + """ + return self._followFromHelper(pos, dist, steps, stepSize, allPoints=True) + @vectorDistributionMethod def followFrom(self, pos, dist, steps=None, stepSize=None): """Follow the field from a point for a given distance. @@ -718,6 +736,14 @@ def followFrom(self, pos, dist, steps=None, stepSize=None): stepSize (float): length used to compute how many steps to take, or :obj:`None` to use the field's default step size. """ + return self._followFromHelper(pos, dist, steps, stepSize, allPoints=False) + + def _followFromHelper(self, pos, dist, steps, stepSize, allPoints): + if allPoints: + from scenic.core.regions import PolylineRegion + + pts = [] + if steps is None: steps = self.minSteps stepSize = self.defaultStepSize if stepSize is None else stepSize @@ -729,8 +755,10 @@ def followFrom(self, pos, dist, steps=None, stepSize=None): for i in range(steps): rot = self[pos].getRotation() pos += rot.apply(step) + if allPoints: + pts.append(Vector(*pos)) - return Vector(*pos) + return PolylineRegion(points=pts) if allPoints else Vector(*pos) @staticmethod def forUnionOf(regions, tolerance=0): diff --git a/tests/core/test_vectors.py b/tests/core/test_vectors.py index 82f8be7ff..a48f959be 100644 --- a/tests/core/test_vectors.py +++ b/tests/core/test_vectors.py @@ -69,3 +69,21 @@ def test_distribution_method_encapsulation_lazy(): assert not needsLazyEvaluation(evpt) assert isinstance(evpt, VectorMethodDistribution) assert evpt.method is underlyingFunction(vf.followFrom) + + +def test_vf_follow(): + vf = VectorField( + "Foo", lambda pos: 0 if int(pos.x + pos.y) % 2 == 0 else (math.radians(-90)) + ) + + start_pt = Vector(0, 0) + end_pt = vf.followFrom(start_pt, 5, stepSize=1) + traj = vf.followFromTrajectory(start_pt, 5, stepSize=1) + + assert traj.points[-1] == pytest.approx(end_pt) + + for pt_a, pt_b in itertools.pairwise(traj.points): + if int(sum(pt_a)) % 2 == 0: + assert pt_a + Vector(0, 1) == pytest.approx(pt_b) + else: + assert pt_a + Vector(1, 0) == pytest.approx(pt_b) From 9b5243edada1d2efdfbf5be0e780d19614ddedd7 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 14:23:27 -0700 Subject: [PATCH 25/35] Fix docs building. --- src/scenic/domains/driving/behaviors.scenic | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scenic/domains/driving/behaviors.scenic b/src/scenic/domains/driving/behaviors.scenic index 0f835903e..85e00546a 100644 --- a/src/scenic/domains/driving/behaviors.scenic +++ b/src/scenic/domains/driving/behaviors.scenic @@ -163,14 +163,14 @@ class Trajectory(object): behavior FollowTrajectoryBehavior(trajectory, terminationDistance=1): """ - Follows the given `Trajectory`. + Follows the given Trajectory. The behavior terminates when either of the following conditions are met the vehicle position is within - `terminationDistance` of the end of the trajectory. + terminationDistance of the end of the trajectory. Args: - trajectory: A `Trajectory`. - terminationDistance: The behavior will terminate when the vehicle position is within `terminationDistance` + trajectory: A Trajectory. + terminationDistance: The behavior will terminate when the vehicle position is within terminationDistance of the end of the trajectory. """ assert isinstance(trajectory, Trajectory) From 5924262c78aa7979e602eebe166ac292173f7749 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 14:40:13 -0700 Subject: [PATCH 26/35] Added setOrientation functionality to driving domain --- src/scenic/domains/driving/actions.py | 11 +++++++++++ src/scenic/domains/driving/model.scenic | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/scenic/domains/driving/actions.py b/src/scenic/domains/driving/actions.py index f2ab406d1..d8304ea9c 100644 --- a/src/scenic/domains/driving/actions.py +++ b/src/scenic/domains/driving/actions.py @@ -18,6 +18,7 @@ import numpy as np from scenic.core.simulators import Action +from scenic.core.type_support import toOrientation from scenic.core.vectors import Vector ## Mixin classes indicating support for various types of actions. @@ -108,6 +109,16 @@ def applyTo(self, obj, sim): obj.setVelocity(vel) +class SetOrientationAction(Action): + """Set the orientation of an agent.""" + + def __init__(self, orientation): + self.orientation = toOrientation(orientation) + + def applyTo(self, obj, sim): + obj.setOrientation(self.orientation) + + ## Actions available to vehicles which can steer diff --git a/src/scenic/domains/driving/model.scenic b/src/scenic/domains/driving/model.scenic index b13298c51..3526a8990 100644 --- a/src/scenic/domains/driving/model.scenic +++ b/src/scenic/domains/driving/model.scenic @@ -270,6 +270,9 @@ class DrivingObject: def setVelocity(self, vel): raise NotImplementedError + def setOrientation(self, orientation): + raise NotImplementedError + class Vehicle(DrivingObject): """Vehicles which drive, such as cars. From ebc20b1580c490bd84f72e653ee7821192bd1ad5 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 15:33:09 -0700 Subject: [PATCH 27/35] Added python floor to pairwise test. --- tests/core/test_vectors.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/core/test_vectors.py b/tests/core/test_vectors.py index a48f959be..b9768b1bd 100644 --- a/tests/core/test_vectors.py +++ b/tests/core/test_vectors.py @@ -71,6 +71,9 @@ def test_distribution_method_encapsulation_lazy(): assert evpt.method is underlyingFunction(vf.followFrom) +@pytest.mark.skipif( + sys.version_info < (3, 10), reason="Pairwise requires Python 3.10 or higher." +) def test_vf_follow(): vf = VectorField( "Foo", lambda pos: 0 if int(pos.x + pos.y) % 2 == 0 else (math.radians(-90)) From 2887b04b28636c45de43bbae37e47ddb12f7f15d Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 16:53:32 -0700 Subject: [PATCH 28/35] Prepare for `apparent heading to` syntax. --- src/scenic/syntax/ast.py | 2 +- src/scenic/syntax/compiler.py | 4 ++-- src/scenic/syntax/scenic.gram | 4 ++-- src/scenic/syntax/veneer.py | 4 ++-- tests/syntax/test_compiler.py | 8 ++++---- tests/syntax/test_parser.py | 30 ++++++++++++++++-------------- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/scenic/syntax/ast.py b/src/scenic/syntax/ast.py index f21a49aaf..ed11764fd 100644 --- a/src/scenic/syntax/ast.py +++ b/src/scenic/syntax/ast.py @@ -435,7 +435,7 @@ class RelativeHeadingOp(AST): base: Optional[ast.AST] = None -class ApparentHeadingOp(AST): +class ApparentHeadingOfOp(AST): target: ast.AST base: Optional[ast.AST] = None diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index ac1beeae9..f9f50c5b2 100644 --- a/src/scenic/syntax/compiler.py +++ b/src/scenic/syntax/compiler.py @@ -1683,9 +1683,9 @@ def visit_RelativeHeadingOp(self, node: s.RelativeHeadingOp): ), ) - def visit_ApparentHeadingOp(self, node: s.ApparentHeadingOp): + def visit_ApparentHeadingOfOp(self, node: s.ApparentHeadingOfOp): return ast.Call( - func=ast.Name(id="ApparentHeading", ctx=loadCtx), + func=ast.Name(id="ApparentHeadingOf", ctx=loadCtx), args=[self.visit(node.target)], keywords=( [] diff --git a/src/scenic/syntax/scenic.gram b/src/scenic/syntax/scenic.gram index ec3e63f10..316e8cabb 100644 --- a/src/scenic/syntax/scenic.gram +++ b/src/scenic/syntax/scenic.gram @@ -1846,8 +1846,8 @@ scenic_prefix_operators: | "relative" "heading" "of" e1=expression 'from' e2=scenic_prefix_operators { s.RelativeHeadingOp(target=e1, base=e2, LOCATIONS) } | "relative" "heading" "of" e1=scenic_prefix_operators { s.RelativeHeadingOp(target=e1, LOCATIONS) } # apparent heading of - | "apparent" "heading" "of" e1=expression 'from' e2=scenic_prefix_operators { s.ApparentHeadingOp(target=e1, base=e2, LOCATIONS) } - | "apparent" "heading" "of" e1=scenic_prefix_operators { s.ApparentHeadingOp(target=e1, LOCATIONS) } + | "apparent" "heading" "of" e1=expression 'from' e2=scenic_prefix_operators { s.ApparentHeadingOfOp(target=e1, base=e2, LOCATIONS) } + | "apparent" "heading" "of" e1=scenic_prefix_operators { s.ApparentHeadingOfOp(target=e1, LOCATIONS) } # distance from/to | &"distance" scenic_distance_from_op # distance past diff --git a/src/scenic/syntax/veneer.py b/src/scenic/syntax/veneer.py index b79745030..28605ba4e 100644 --- a/src/scenic/syntax/veneer.py +++ b/src/scenic/syntax/veneer.py @@ -65,7 +65,7 @@ "BottomBackLeft", "BottomBackRight", "RelativeHeading", - "ApparentHeading", + "ApparentHeadingOf", "RelativePosition", "DistanceFrom", "DistancePast", @@ -1275,7 +1275,7 @@ def RelativeHeading(X, Y=None): return normalizeAngle(X.yaw - Y.yaw) -def ApparentHeading(X, Y=None): +def ApparentHeadingOf(X, Y=None): """The :grammar:`apparent heading of [from ]` operator. If the :grammar:`from ` is omitted, the position of ego is used. diff --git a/tests/syntax/test_compiler.py b/tests/syntax/test_compiler.py index 597b85b55..8bc3b6375 100644 --- a/tests/syntax/test_compiler.py +++ b/tests/syntax/test_compiler.py @@ -2076,17 +2076,17 @@ def test_relative_heading_op_base(self): assert False def test_apparent_heading_op(self): - node, _ = compileScenicAST(ApparentHeadingOp(Name("X"))) + node, _ = compileScenicAST(ApparentHeadingOfOp(Name("X"))) match node: - case Call(Name("ApparentHeading"), [Name("X")]): + case Call(Name("ApparentHeadingOf"), [Name("X")]): assert True case _: assert False def test_apparent_heading_op_base(self): - node, _ = compileScenicAST(ApparentHeadingOp(Name("X"), Name("Y"))) + node, _ = compileScenicAST(ApparentHeadingOfOp(Name("X"), Name("Y"))) match node: - case Call(Name("ApparentHeading"), [Name("X")], [keyword("Y", Name("Y"))]): + case Call(Name("ApparentHeadingOf"), [Name("X")], [keyword("Y", Name("Y"))]): assert True case _: assert False diff --git a/tests/syntax/test_parser.py b/tests/syntax/test_parser.py index b259b2ad0..d50379722 100644 --- a/tests/syntax/test_parser.py +++ b/tests/syntax/test_parser.py @@ -2222,7 +2222,7 @@ def test_apparent_heading(self): mod = parse_string_helper("apparent heading of x") stmt = mod.body[0] match stmt: - case Expr(ApparentHeadingOp(Name("x"))): + case Expr(ApparentHeadingOfOp(Name("x"))): assert True case _: assert False @@ -2231,7 +2231,7 @@ def test_apparent_heading_from(self): mod = parse_string_helper("apparent heading of x from y") stmt = mod.body[0] match stmt: - case Expr(ApparentHeadingOp(Name("x"), Name("y"))): + case Expr(ApparentHeadingOfOp(Name("x"), Name("y"))): assert True case _: assert False @@ -2241,27 +2241,27 @@ def test_apparent_heading_from(self): [ ( "apparent heading of apparent heading of A from B", - ApparentHeadingOp( - ApparentHeadingOp(Name("A", Load()), Name("B", Load())) + ApparentHeadingOfOp( + ApparentHeadingOfOp(Name("A", Load()), Name("B", Load())) ), ), ( "apparent heading of apparent heading of A from B from C", - ApparentHeadingOp( - ApparentHeadingOp(Name("A", Load()), Name("B", Load())), + ApparentHeadingOfOp( + ApparentHeadingOfOp(Name("A", Load()), Name("B", Load())), Name("C", Load()), ), ), ( "apparent heading of A from apparent heading of B from C", - ApparentHeadingOp( + ApparentHeadingOfOp( Name("A", Load()), - ApparentHeadingOp(Name("B", Load()), Name("C", Load())), + ApparentHeadingOfOp(Name("B", Load()), Name("C", Load())), ), ), ( "apparent heading of A << B from C", - ApparentHeadingOp( + ApparentHeadingOfOp( BinOp(Name("A", Load()), LShift(), Name("B", Load())), Name("C", Load()), ), @@ -2269,32 +2269,34 @@ def test_apparent_heading_from(self): ( "apparent heading of A from B << C", BinOp( - ApparentHeadingOp(Name("A", Load()), Name("B", Load())), + ApparentHeadingOfOp(Name("A", Load()), Name("B", Load())), LShift(), Name("C", Load()), ), ), ( "apparent heading of A + B from C", - ApparentHeadingOp( + ApparentHeadingOfOp( BinOp(Name("A", Load()), Add(), Name("B", Load())), Name("C", Load()), ), ), ( "apparent heading of A from B + C", - ApparentHeadingOp( + ApparentHeadingOfOp( Name("A", Load()), BinOp(Name("B", Load()), Add(), Name("C", Load())), ), ), ( "apparent heading of A << B", - BinOp(ApparentHeadingOp(Name("A", Load())), LShift(), Name("B", Load())), + BinOp( + ApparentHeadingOfOp(Name("A", Load())), LShift(), Name("B", Load()) + ), ), ( "apparent heading of A + B", - ApparentHeadingOp(BinOp(Name("A", Load()), Add(), Name("B", Load()))), + ApparentHeadingOfOp(BinOp(Name("A", Load()), Add(), Name("B", Load()))), ), ], ) From 6c89a4a540a11746f77da66ab21ac97416d5e4de Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Mon, 6 Jul 2026 17:20:09 -0700 Subject: [PATCH 29/35] Added `apparent heading to X from Y` operator. --- src/scenic/core/object_types.py | 4 + src/scenic/domains/driving/behaviors.scenic | 1 + src/scenic/syntax/ast.py | 5 ++ src/scenic/syntax/compiler.py | 11 +++ src/scenic/syntax/scenic.gram | 3 + src/scenic/syntax/veneer.py | 16 +++- tests/syntax/test_compiler.py | 20 ++++- tests/syntax/test_operators.py | 40 ++++++++- tests/syntax/test_parser.py | 94 ++++++++++++++++++++- 9 files changed, 185 insertions(+), 9 deletions(-) diff --git a/src/scenic/core/object_types.py b/src/scenic/core/object_types.py index 11ff01004..1d648252d 100644 --- a/src/scenic/core/object_types.py +++ b/src/scenic/core/object_types.py @@ -988,6 +988,10 @@ def distancePast(self, vec): diff = self.position - vec return diff.rotatedBy(-self.heading).y + def apparentHeadingTo(self, vec): + """The apparent heading to a given point, from the perspective of this `OrientedPoint`.""" + return normalizeAngle(self.position.angleTo(vec) - self.heading) + def toHeading(self) -> float: return self.heading diff --git a/src/scenic/domains/driving/behaviors.scenic b/src/scenic/domains/driving/behaviors.scenic index 85e00546a..5db4794a6 100644 --- a/src/scenic/domains/driving/behaviors.scenic +++ b/src/scenic/domains/driving/behaviors.scenic @@ -5,6 +5,7 @@ These behaviors are automatically imported when using the driving domain. import math from abc import ABC, abstractmethod +import warnings import shapely from shapely.geometry import LineString, MultiPoint, Point as ShapelyPoint diff --git a/src/scenic/syntax/ast.py b/src/scenic/syntax/ast.py index ed11764fd..35664adb1 100644 --- a/src/scenic/syntax/ast.py +++ b/src/scenic/syntax/ast.py @@ -440,6 +440,11 @@ class ApparentHeadingOfOp(AST): base: Optional[ast.AST] = None +class ApparentHeadingToOp(AST): + target: ast.AST + base: Optional[ast.AST] = None + + class DistanceFromOp(AST): # because `to` and `from` are symmetric, the first operand will be `target` and the second will be `base` target: ast.AST diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index f9f50c5b2..3daa536cd 100644 --- a/src/scenic/syntax/compiler.py +++ b/src/scenic/syntax/compiler.py @@ -1694,6 +1694,17 @@ def visit_ApparentHeadingOfOp(self, node: s.ApparentHeadingOfOp): ), ) + def visit_ApparentHeadingToOp(self, node: s.ApparentHeadingToOp): + return ast.Call( + func=ast.Name(id="ApparentHeadingTo", ctx=loadCtx), + args=[self.visit(node.target)], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), + ) + def visit_DistanceFromOp(self, node: s.DistanceFromOp): return ast.Call( func=ast.Name(id="DistanceFrom", ctx=loadCtx), diff --git a/src/scenic/syntax/scenic.gram b/src/scenic/syntax/scenic.gram index 316e8cabb..d573c031b 100644 --- a/src/scenic/syntax/scenic.gram +++ b/src/scenic/syntax/scenic.gram @@ -1848,6 +1848,9 @@ scenic_prefix_operators: # apparent heading of | "apparent" "heading" "of" e1=expression 'from' e2=scenic_prefix_operators { s.ApparentHeadingOfOp(target=e1, base=e2, LOCATIONS) } | "apparent" "heading" "of" e1=scenic_prefix_operators { s.ApparentHeadingOfOp(target=e1, LOCATIONS) } + # apparent heading to + | "apparent" "heading" "to" e1=expression 'from' e2=scenic_prefix_operators { s.ApparentHeadingToOp(target=e1, base=e2, LOCATIONS) } + | "apparent" "heading" "to" e1=scenic_prefix_operators { s.ApparentHeadingToOp(target=e1, LOCATIONS) } # distance from/to | &"distance" scenic_distance_from_op # distance past diff --git a/src/scenic/syntax/veneer.py b/src/scenic/syntax/veneer.py index 28605ba4e..bc987d0f4 100644 --- a/src/scenic/syntax/veneer.py +++ b/src/scenic/syntax/veneer.py @@ -66,6 +66,7 @@ "BottomBackRight", "RelativeHeading", "ApparentHeadingOf", + "ApparentHeadingTo", "RelativePosition", "DistanceFrom", "DistancePast", @@ -1284,10 +1285,23 @@ def ApparentHeadingOf(X, Y=None): raise TypeError('"apparent heading of X from Y" with X not an OrientedPoint') if Y is None: Y = ego() - Y = toVector(Y, '"relative heading of X from Y" with Y not a vector') + Y = toVector(Y, '"apparent heading of X from Y" with Y not a vector') return apparentHeadingAtPoint(X.position, X.heading, Y) +def ApparentHeadingTo(X, Y=None): + """The :grammar:`apparent heading to [from ]` operator. + + If the :grammar:`from ` is omitted, the ego is used. + """ + X = toVector(X, '"apparent heading to X from Y" with X not a vector') + if Y is None: + Y = ego() + if not isA(Y, OrientedPoint): + raise TypeError('"apparent heading to X from Y" with Y not an OrientedPoint') + return Y.apparentHeadingTo(X) + + def DistanceFrom(X, Y=None): """The :scenic:`distance from {X} to {Y}` polymorphic operator. diff --git a/tests/syntax/test_compiler.py b/tests/syntax/test_compiler.py index 8bc3b6375..2100c364a 100644 --- a/tests/syntax/test_compiler.py +++ b/tests/syntax/test_compiler.py @@ -2075,7 +2075,7 @@ def test_relative_heading_op_base(self): case _: assert False - def test_apparent_heading_op(self): + def test_apparent_heading_of_op(self): node, _ = compileScenicAST(ApparentHeadingOfOp(Name("X"))) match node: case Call(Name("ApparentHeadingOf"), [Name("X")]): @@ -2083,7 +2083,7 @@ def test_apparent_heading_op(self): case _: assert False - def test_apparent_heading_op_base(self): + def test_apparent_heading_of_op_base(self): node, _ = compileScenicAST(ApparentHeadingOfOp(Name("X"), Name("Y"))) match node: case Call(Name("ApparentHeadingOf"), [Name("X")], [keyword("Y", Name("Y"))]): @@ -2091,6 +2091,22 @@ def test_apparent_heading_op_base(self): case _: assert False + def test_apparent_heading_to_op(self): + node, _ = compileScenicAST(ApparentHeadingToOp(Name("X"))) + match node: + case Call(Name("ApparentHeadingTo"), [Name("X")]): + assert True + case _: + assert False + + def test_apparent_heading_to_op_base(self): + node, _ = compileScenicAST(ApparentHeadingToOp(Name("X"), Name("Y"))) + match node: + case Call(Name("ApparentHeadingTo"), [Name("X")], [keyword("Y", Name("Y"))]): + assert True + case _: + assert False + def test_distance_to_op(self): node, _ = compileScenicAST(DistanceFromOp(Name("X"), None)) match node: diff --git a/tests/syntax/test_operators.py b/tests/syntax/test_operators.py index 7c405c789..dd3e9d16f 100644 --- a/tests/syntax/test_operators.py +++ b/tests/syntax/test_operators.py @@ -43,8 +43,8 @@ def test_relative_heading_from(): assert ego.heading == pytest.approx(math.radians(70 + 10)) -# Apparent heading -def test_apparent_heading(): +# Apparent heading of +def test_apparent_heading_of(): p = sampleParamPFrom( """ ego = new Object facing 30 deg @@ -55,7 +55,7 @@ def test_apparent_heading(): assert p == pytest.approx(math.radians(65 + 45)) -def test_apparent_heading_no_ego(): +def test_apparent_heading_of_no_ego(): with pytest.raises(InvalidScenarioError): compileScenic( """ @@ -65,7 +65,7 @@ def test_apparent_heading_no_ego(): ) -def test_apparent_heading_from(): +def test_apparent_heading_of_from(): ego = sampleEgoFrom( """ OP = new OrientedPoint at 10@15, facing -60 deg @@ -75,6 +75,38 @@ def test_apparent_heading_from(): assert ego.heading == pytest.approx(math.radians(-60 - 45)) +def test_apparent_heading_to(): + p = sampleParamPFrom( + """ + ego = new Object facing 30 deg + other = new Object facing 65 deg, at 10@10 + param p = apparent heading to other + """ + ) + assert p == pytest.approx(math.radians(-30 - 45)) + + +def test_apparent_heading_to_no_ego(): + with pytest.raises(InvalidScenarioError): + compileScenic( + """ + other = new Object + ego = new Object at 2@2, facing apparent heading to other + """ + ) + + +def test_apparent_heading_to_from(): + p = sampleParamPFrom( + """ + foo = new Object facing 30 deg + other = new Object facing 65 deg, at 10@10 + param p = apparent heading to other from foo + """ + ) + assert p == pytest.approx(math.radians(-30 - 45)) + + # Angle def test_angle(): p = sampleParamPFrom( diff --git a/tests/syntax/test_parser.py b/tests/syntax/test_parser.py index d50379722..c0430904a 100644 --- a/tests/syntax/test_parser.py +++ b/tests/syntax/test_parser.py @@ -2218,7 +2218,7 @@ def test_relative_heading_from(self): def test_relative_heading_precedence(self, code, expected): assert_equal_source_ast(code, expected) - def test_apparent_heading(self): + def test_apparent_heading_of(self): mod = parse_string_helper("apparent heading of x") stmt = mod.body[0] match stmt: @@ -2227,7 +2227,7 @@ def test_apparent_heading(self): case _: assert False - def test_apparent_heading_from(self): + def test_apparent_heading_of_from(self): mod = parse_string_helper("apparent heading of x from y") stmt = mod.body[0] match stmt: @@ -2303,6 +2303,96 @@ def test_apparent_heading_from(self): def test_apparent_heading_precedence(self, code, expected): assert_equal_source_ast(code, expected) + def test_apparent_heading_to_from(self): + mod = parse_string_helper("apparent heading to x from y") + stmt = mod.body[0] + match stmt: + case Expr(ApparentHeadingToOp(Name("x"), Name("y"))): + assert True + case _: + assert False + + @pytest.mark.parametrize( + "code,expected", + [ + ( + "apparent heading to apparent heading to A from B", + ApparentHeadingToOp( + ApparentHeadingToOp(Name("A", Load()), Name("B", Load())) + ), + ), + ( + "apparent heading to apparent heading to A from B from C", + ApparentHeadingToOp( + ApparentHeadingToOp(Name("A", Load()), Name("B", Load())), + Name("C", Load()), + ), + ), + ( + "apparent heading to A from apparent heading to B from C", + ApparentHeadingToOp( + Name("A", Load()), + ApparentHeadingToOp(Name("B", Load()), Name("C", Load())), + ), + ), + ( + "apparent heading of A from apparent heading to B from C", + ApparentHeadingOfOp( + Name("A", Load()), + ApparentHeadingToOp(Name("B", Load()), Name("C", Load())), + ), + ), + ( + "apparent heading to A from apparent heading of B from C", + ApparentHeadingToOp( + Name("A", Load()), + ApparentHeadingOfOp(Name("B", Load()), Name("C", Load())), + ), + ), + ( + "apparent heading to A << B from C", + ApparentHeadingToOp( + BinOp(Name("A", Load()), LShift(), Name("B", Load())), + Name("C", Load()), + ), + ), + ( + "apparent heading to A from B << C", + BinOp( + ApparentHeadingToOp(Name("A", Load()), Name("B", Load())), + LShift(), + Name("C", Load()), + ), + ), + ( + "apparent heading to A + B from C", + ApparentHeadingToOp( + BinOp(Name("A", Load()), Add(), Name("B", Load())), + Name("C", Load()), + ), + ), + ( + "apparent heading to A from B + C", + ApparentHeadingToOp( + Name("A", Load()), + BinOp(Name("B", Load()), Add(), Name("C", Load())), + ), + ), + ( + "apparent heading to A << B", + BinOp( + ApparentHeadingToOp(Name("A", Load())), LShift(), Name("B", Load()) + ), + ), + ( + "apparent heading to A + B", + ApparentHeadingToOp(BinOp(Name("A", Load()), Add(), Name("B", Load()))), + ), + ], + ) + def test_apparent_heading_precedence(self, code, expected): + assert_equal_source_ast(code, expected) + def test_distance_from(self): mod = parse_string_helper("distance from x") stmt = mod.body[0] From d2cd9e33e8fca1d3b986292298bc81e1edd2aa30 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Tue, 7 Jul 2026 10:06:45 -0700 Subject: [PATCH 30/35] Added documentation. --- docs/reference/operators.rst | 15 +++++++++ src/scenic/syntax/ast.py | 20 ++++++++++++ src/scenic/syntax/compiler.py | 40 ++++++++++++++++++++++++ src/scenic/syntax/scenic.gram | 4 +++ src/scenic/syntax/veneer.py | 43 ++++++++++++++++++++++++++ tests/syntax/test_compiler.py | 32 +++++++++++++++++++ tests/syntax/test_operators.py | 56 ++++++++++++++++++++++++++++++++++ tests/syntax/test_parser.py | 36 ++++++++++++++++++++++ 8 files changed, 246 insertions(+) diff --git a/docs/reference/operators.rst b/docs/reference/operators.rst index 4a39539e9..0273979b6 100644 --- a/docs/reference/operators.rst +++ b/docs/reference/operators.rst @@ -28,6 +28,12 @@ apparent heading of *OrientedPoint* [from *vector*] --------------------------------------------------- The apparent heading of the OrientedPoint, with respect to the line of sight from ego (or the position provided with the optional from vector) +.. _apparent heading to {vector} [from {OrientedPoint}]: + +apparent heading to *vector* [from *OrientedPoint*] +--------------------------------------------------- +The apparent heading to the vector, with respect to the line of sight from ego (or the OrientedPoint provided with the optional from OrientedPoint) + .. _distance [from {vector}] to {vector}: .. _distance from: @@ -75,6 +81,15 @@ Whether an `Object`/`Region` intersects another `Object`/`Region`, i.e. whether When working with 2D regions, it can be useful to check intersection with the :term:`footprint` of a region, e.g. when checking whether a car intersects a given lane. In this case, one would write :scenic:`car intersects lane.footprint` instead of :scenic:`car intersects lane`. For more details, see :term:`footprint`. +.. _{vector} (ahead of | behind | left of | right of) {region}: + +*vector* (ahead of | behind | left of | right of) *OrientedPoint* +----------------------------------------------------------------- +Whether a vector is to a respective side of an `OrientedPoint` (i.e. within +- ~90 degrees from that direction), accounting for the heading of the `OrientedPoint`. These operators are convenient shorthand for comparing :sampref:`apparent heading to {vector} [from {OrientedPoint}]` to a range of values. + +Note: For vectors very close to a boundary point (i.e. an apparent heading to the vector in (89.99, 90.01) or (-89.99, -90.01) for "ahead of" and "behind"), both operators will resolve to False. + +Example: If an `OrientedPoint` had an apparent heading to a vector of 45 degrees, it would be both "ahead of" and "left of" the `OrientedPoint` but not "behind" or "right of". Orientation Operators ===================== diff --git a/src/scenic/syntax/ast.py b/src/scenic/syntax/ast.py index 35664adb1..56eca6a1f 100644 --- a/src/scenic/syntax/ast.py +++ b/src/scenic/syntax/ast.py @@ -655,3 +655,23 @@ class CanSeeOp(AST): class IntersectsOp(AST): left: ast.AST right: ast.AST + + +class AheadOfOp(AST): + left: ast.AST + right: ast.AST + + +class BehindOp(AST): + left: ast.AST + right: ast.AST + + +class LeftOfOp(AST): + left: ast.AST + right: ast.AST + + +class RightOfOp(AST): + left: ast.AST + right: ast.AST diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index 3daa536cd..4e03ce67b 100644 --- a/src/scenic/syntax/compiler.py +++ b/src/scenic/syntax/compiler.py @@ -1861,3 +1861,43 @@ def visit_IntersectsOp(self, node: s.IntersectsOp): ], keywords=[], ) + + def visit_AheadOfOp(self, node: s.AheadOfOp): + return ast.Call( + func=ast.Name(id="AheadOfOp", ctx=loadCtx), + args=[ + self.visit(node.left), + self.visit(node.right), + ], + keywords=[], + ) + + def visit_BehindOp(self, node: s.BehindOp): + return ast.Call( + func=ast.Name(id="BehindOp", ctx=loadCtx), + args=[ + self.visit(node.left), + self.visit(node.right), + ], + keywords=[], + ) + + def visit_LeftOfOp(self, node: s.LeftOfOp): + return ast.Call( + func=ast.Name(id="LeftOfOp", ctx=loadCtx), + args=[ + self.visit(node.left), + self.visit(node.right), + ], + keywords=[], + ) + + def visit_RightOfOp(self, node: s.RightOfOp): + return ast.Call( + func=ast.Name(id="RightOfOp", ctx=loadCtx), + args=[ + self.visit(node.left), + self.visit(node.right), + ], + keywords=[], + ) diff --git a/src/scenic/syntax/scenic.gram b/src/scenic/syntax/scenic.gram index d573c031b..31377a6de 100644 --- a/src/scenic/syntax/scenic.gram +++ b/src/scenic/syntax/scenic.gram @@ -1803,6 +1803,10 @@ bitwise_or: | scenic_not_visible_from | scenic_can_see | scenic_intersects + | a=bitwise_or "ahead" "of" b=bitwise_xor { s.AheadOfOp(left=a, right=b, LOCATIONS) } + | a=bitwise_or "behind" b=bitwise_xor { s.BehindOp(left=a, right=b, LOCATIONS) } + | a=bitwise_or "left" "of" b=bitwise_xor { s.LeftOfOp(left=a, right=b, LOCATIONS) } + | a=bitwise_or "right" "of" b=bitwise_xor { s.RightOfOp(left=a, right=b, LOCATIONS) } | a=bitwise_or '|' b=bitwise_xor { ast.BinOp(left=a, op=ast.BitOr(), right=b, LOCATIONS) } | bitwise_xor diff --git a/src/scenic/syntax/veneer.py b/src/scenic/syntax/veneer.py index bc987d0f4..d171dbeb3 100644 --- a/src/scenic/syntax/veneer.py +++ b/src/scenic/syntax/veneer.py @@ -85,6 +85,10 @@ "Implies", "VisibleFromOp", "NotVisibleFromOp", + "AheadOfOp", + "BehindOp", + "LeftOfOp", + "RightOfOp", # Primitive types "Vector", "Orientation", @@ -250,6 +254,7 @@ from contextlib import contextmanager import functools import importlib +import math import numbers from pathlib import Path import sys @@ -1401,6 +1406,44 @@ def NotVisibleFromOp(region, base): return region.difference(base.visibleRegion) +def AheadOfOp(X, Y): + """The :grammar:` ahead of ` operator.""" + X = toVector(X, '"X ahead of Y" with X not a vector') + if not isA(Y, OrientedPoint): + raise TypeError('"X ahead of Y" with Y not an OrientedPoint') + + return math.radians(-89.99) < ApparentHeadingTo(X, Y) < math.radians(89.99) + + +def BehindOp(X, Y): + """The :grammar:` behind ` operator.""" + X = toVector(X, '"X behind Y" with X not a vector') + if not isA(Y, OrientedPoint): + raise TypeError('"X behind Y" with Y not an OrientedPoint') + + return ApparentHeadingTo(X, Y) < -math.radians(90.01) or math.radians( + 90.01 + ) < ApparentHeadingTo(X, Y) + + +def LeftOfOp(X, Y): + """The :grammar:` left of ` operator.""" + X = toVector(X, '"X left of Y" with X not a vector') + if not isA(Y, OrientedPoint): + raise TypeError('"X left of Y" with Y not an OrientedPoint') + + return math.radians(0.01) < ApparentHeadingTo(X, Y) < math.radians(180) + + +def RightOfOp(X, Y): + """The :grammar:` right of ` operator.""" + X = toVector(X, '"X right of Y" with X not a vector') + if not isA(Y, OrientedPoint): + raise TypeError('"X right of Y" with Y not an OrientedPoint') + + return -math.radians(180) < ApparentHeadingTo(X, Y) < math.radians(-0.01) + + def CanSee(X, Y): """The :scenic:`{X} can see {Y}` polymorphic operator. diff --git a/tests/syntax/test_compiler.py b/tests/syntax/test_compiler.py index 2100c364a..d4abc5956 100644 --- a/tests/syntax/test_compiler.py +++ b/tests/syntax/test_compiler.py @@ -2306,6 +2306,38 @@ def test_offset_along_op(self): case _: assert False + def test_ahead_of_op(self): + node, _ = compileScenicAST(AheadOfOp(Name("X"), Name("Y"))) + match node: + case Call(Name("AheadOfOp"), [Name("X"), Name("Y")]): + assert True + case _: + assert False + + def test_behind_op(self): + node, _ = compileScenicAST(BehindOp(Name("X"), Name("Y"))) + match node: + case Call(Name("BehindOp"), [Name("X"), Name("Y")]): + assert True + case _: + assert False + + def test_left_of_op(self): + node, _ = compileScenicAST(LeftOfOp(Name("X"), Name("Y"))) + match node: + case Call(Name("LeftOfOp"), [Name("X"), Name("Y")]): + assert True + case _: + assert False + + def test_right_of_op(self): + node, _ = compileScenicAST(RightOfOp(Name("X"), Name("Y"))) + match node: + case Call(Name("RightOfOp"), [Name("X"), Name("Y")]): + assert True + case _: + assert False + def test_can_see_op(self): node, _ = compileScenicAST(CanSeeOp(Name("X"), Name("Y"))) match node: diff --git a/tests/syntax/test_operators.py b/tests/syntax/test_operators.py index dd3e9d16f..1817b0dc6 100644 --- a/tests/syntax/test_operators.py +++ b/tests/syntax/test_operators.py @@ -634,6 +634,62 @@ def test_intersects_diff_z(): assert p == (True, False, False) +def test_ahead_of_op(): + p_vals = [ + sampleParamPFrom( + f""" + ego = new Object + foo = new Point offset along {i*45} deg by 0@1 + param p = foo ahead of ego + """ + ) + for i in range(8) + ] + assert p_vals == [True, True, False, False, False, False, False, True] + + +def test_behind_op(): + p_vals = [ + sampleParamPFrom( + f""" + ego = new Object + foo = new Point offset along {i*45} deg by 0@1 + param p = foo behind ego + """ + ) + for i in range(8) + ] + assert p_vals == [False, False, False, True, True, True, False, False] + + +def test_left_of_op(): + p_vals = [ + sampleParamPFrom( + f""" + ego = new Object + foo = new Point offset along {i*45} deg by 0@1 + param p = foo left of ego + """ + ) + for i in range(8) + ] + assert p_vals == [False, True, True, True, False, False, False, False] + + +def test_right_of_op(): + p_vals = [ + sampleParamPFrom( + f""" + ego = new Object + foo = new Point offset along {i*45} deg by 0@1 + param p = foo right of ego + """ + ) + for i in range(8) + ] + assert p_vals == [False, False, False, False, False, True, True, True] + + ## Heading operators diff --git a/tests/syntax/test_parser.py b/tests/syntax/test_parser.py index c0430904a..1394778c0 100644 --- a/tests/syntax/test_parser.py +++ b/tests/syntax/test_parser.py @@ -3097,6 +3097,42 @@ def test_can_see(self): case _: assert False + def test_ahead_of(self): + mod = parse_string_helper("x ahead of y ") + stmt = mod.body[0] + match stmt: + case Expr(AheadOfOp(Name("x"), Name("y"))): + assert True + case _: + assert False + + def test_behind(self): + mod = parse_string_helper("x behind y ") + stmt = mod.body[0] + match stmt: + case Expr(BehindOp(Name("x"), Name("y"))): + assert True + case _: + assert False + + def test_left_of(self): + mod = parse_string_helper("x left of y ") + stmt = mod.body[0] + match stmt: + case Expr(LeftOfOp(Name("x"), Name("y"))): + assert True + case _: + assert False + + def test_right_of(self): + mod = parse_string_helper("x right of y ") + stmt = mod.body[0] + match stmt: + case Expr(RightOfOp(Name("x"), Name("y"))): + assert True + case _: + assert False + def test_intersects(self): mod = parse_string_helper("x intersects y ") stmt = mod.body[0] From 7bc9cbc96343b31129167325ad0d157ff6ed97c7 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Tue, 7 Jul 2026 12:38:52 -0700 Subject: [PATCH 31/35] Added logic for dynamically rebuilding parser on changes. --- .gitignore | 3 ++- src/scenic/syntax/__init__.py | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index aa502d3e5..c1288f49a 100644 --- a/.gitignore +++ b/.gitignore @@ -137,8 +137,9 @@ dmypy.json *.cproject -# generated parser +# generated parser and checksum src/scenic/syntax/parser.py +src/scenic/syntax/.parser_checksum # generated media/output simulation.gif diff --git a/src/scenic/syntax/__init__.py b/src/scenic/syntax/__init__.py index 9ea3b509d..8c9041b80 100644 --- a/src/scenic/syntax/__init__.py +++ b/src/scenic/syntax/__init__.py @@ -1,5 +1,6 @@ """The Scenic compiler and associated support code.""" +import hashlib as _hashlib import pathlib as _pathlib import subprocess as _subprocess import sys as _sys @@ -8,6 +9,7 @@ _projectRootDir = _syntaxDir.parent.parent.parent _grammarPath = _syntaxDir / "scenic.gram" _parserPath = _syntaxDir / "parser.py" +_checksumPath = _syntaxDir / ".parser_checksum" def buildParser(): @@ -30,10 +32,30 @@ def buildParser(): capture_output=True, text=True, ) + + with open(_checksumPath, "wb") as f: + f.write(getParserHash()) + return result -if not _parserPath.exists(): +def getParserHash(): + with open(_parserPath, "rb") as f: + data = f.read() + return _hashlib.blake2b(data).digest() + + +def checksumValid(): + if not _checksumPath.exists(): + return False + + with open(_checksumPath, "rb") as f: + checksum = f.read() + + return checksum == getParserHash() + + +if not _parserPath.exists() or not checksumValid(): _result = buildParser() _retcode = _result.returncode if _retcode != 0: From 0e8e936cdb9bf5e53483d75433a75eb8043988f4 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Wed, 8 Jul 2026 12:34:57 -0700 Subject: [PATCH 32/35] followFrom and followFromTrajectory now attempt to coerce pos to a vector. --- src/scenic/core/vectors.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/scenic/core/vectors.py b/src/scenic/core/vectors.py index 912a79e36..83c640cfe 100644 --- a/src/scenic/core/vectors.py +++ b/src/scenic/core/vectors.py @@ -41,6 +41,7 @@ canCoerceType, coerceToFloat, toOrientation, + toVector, ) from scenic.core.utils import argsToString, cached_property @@ -718,7 +719,9 @@ def followFromTrajectory(self, pos, dist, steps=None, stepSize=None): stepSize (float): length used to compute how many steps to take, or :obj:`None` to use the field's default step size. """ - return self._followFromHelper(pos, dist, steps, stepSize, allPoints=True) + return self._followFromHelper( + toVector(pos), dist, steps, stepSize, allPoints=True + ) @vectorDistributionMethod def followFrom(self, pos, dist, steps=None, stepSize=None): @@ -736,7 +739,9 @@ def followFrom(self, pos, dist, steps=None, stepSize=None): stepSize (float): length used to compute how many steps to take, or :obj:`None` to use the field's default step size. """ - return self._followFromHelper(pos, dist, steps, stepSize, allPoints=False) + return self._followFromHelper( + toVector(pos), dist, steps, stepSize, allPoints=False + ) def _followFromHelper(self, pos, dist, steps, stepSize, allPoints): if allPoints: From b9b1570a27480f495d103a3c5ef65c778ef03fe5 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Wed, 8 Jul 2026 13:53:34 -0700 Subject: [PATCH 33/35] Added centerlines field to networks --- src/scenic/domains/driving/roads.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/scenic/domains/driving/roads.py b/src/scenic/domains/driving/roads.py index 3fecfaf86..2d39994ab 100644 --- a/src/scenic/domains/driving/roads.py +++ b/src/scenic/domains/driving/roads.py @@ -888,6 +888,7 @@ class Network: sidewalkRegion: PolygonalRegion = None curbRegion: PolylineRegion = None shoulderRegion: PolygonalRegion = None + centerlines: PolylineRegion = None #: Traffic flow vector field aggregated over all roads (0 elsewhere). roadDirection: VectorField = None @@ -950,6 +951,11 @@ def __attrs_post_init__(self): edges.append(road.backwardLanes.curb) self.curbRegion = PolylineRegion.unionAll(edges) + if self.centerlines is None: + self.centerlines = PolylineRegion.unionAll( + [lane.centerline for lane in self.lanes] + ) + if self.roadDirection is None: # TODO replace with a PolygonalVectorField for better pruning self.roadDirection = VectorField("roadDirection", self._defaultRoadDirection) From 513299c4d066bad27d0e0c4a43270367411d2261 Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Wed, 8 Jul 2026 13:54:20 -0700 Subject: [PATCH 34/35] Renamed centerlineRegion --- src/scenic/domains/driving/roads.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/scenic/domains/driving/roads.py b/src/scenic/domains/driving/roads.py index 2d39994ab..4c72ad7bd 100644 --- a/src/scenic/domains/driving/roads.py +++ b/src/scenic/domains/driving/roads.py @@ -888,7 +888,7 @@ class Network: sidewalkRegion: PolygonalRegion = None curbRegion: PolylineRegion = None shoulderRegion: PolygonalRegion = None - centerlines: PolylineRegion = None + centerlineRegion: PolylineRegion = None #: Traffic flow vector field aggregated over all roads (0 elsewhere). roadDirection: VectorField = None @@ -951,8 +951,8 @@ def __attrs_post_init__(self): edges.append(road.backwardLanes.curb) self.curbRegion = PolylineRegion.unionAll(edges) - if self.centerlines is None: - self.centerlines = PolylineRegion.unionAll( + if self.centerlineRegion is None: + self.centerlineRegion = PolylineRegion.unionAll( [lane.centerline for lane in self.lanes] ) From 502ebab9f443b1ca20bf7e6447304b1c7c37265e Mon Sep 17 00:00:00 2001 From: Eric Vin Date: Wed, 8 Jul 2026 17:19:27 -0700 Subject: [PATCH 35/35] Added tests for imported record, require monitor, and terminate statements. --- tests/syntax/helper_record.scenic | 1 + tests/syntax/helper_require_monitor.scenic | 5 +++ tests/syntax/helper_terminate.scenic | 1 + tests/syntax/test_imports.py | 46 +++++++++++++++++++++- 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/syntax/helper_record.scenic create mode 100644 tests/syntax/helper_require_monitor.scenic create mode 100644 tests/syntax/helper_terminate.scenic diff --git a/tests/syntax/helper_record.scenic b/tests/syntax/helper_record.scenic new file mode 100644 index 000000000..d77654391 --- /dev/null +++ b/tests/syntax/helper_record.scenic @@ -0,0 +1 @@ +record initial 1 as "foo" diff --git a/tests/syntax/helper_require_monitor.scenic b/tests/syntax/helper_require_monitor.scenic new file mode 100644 index 000000000..033f619dd --- /dev/null +++ b/tests/syntax/helper_require_monitor.scenic @@ -0,0 +1,5 @@ +monitor Bar(): + wait for 1 steps + terminate + +require monitor Bar() diff --git a/tests/syntax/helper_terminate.scenic b/tests/syntax/helper_terminate.scenic new file mode 100644 index 000000000..3e0d520d5 --- /dev/null +++ b/tests/syntax/helper_terminate.scenic @@ -0,0 +1 @@ +terminate after 1 seconds diff --git a/tests/syntax/test_imports.py b/tests/syntax/test_imports.py index c5b3ca0a5..a2ddbf666 100644 --- a/tests/syntax/test_imports.py +++ b/tests/syntax/test_imports.py @@ -13,7 +13,7 @@ from scenic import scenarioFromFile from scenic.core.errors import ScenicSyntaxError from scenic.syntax.translator import InvalidScenarioError -from tests.utils import compileScenic, sampleScene, sampleSceneFrom +from tests.utils import compileScenic, sampleResult, sampleScene, sampleSceneFrom def test_import_top_absolute(request): @@ -77,6 +77,50 @@ def test_inherit_requirements(runLocally): assert constrainedObj.position.x > 0 +def test_inherit_records(runLocally): + with runLocally(): + scenario = compileScenic( + """ + import helper_record + ego = new Object + """ + ) + + result = sampleResult(scenario, maxSteps=1) + assert "foo" in result.records + assert result.records["foo"] == 1 + + +def test_inherit_terminate(runLocally): + with runLocally(): + scenario = compileScenic( + """ + import helper_terminate + ego = new Object + record 1 as "foo" + """ + ) + + result = sampleResult(scenario, maxSteps=5) + assert "foo" in result.records + assert result.records["foo"] == [(0, 1)] + + +def test_inherit_require_monitor(runLocally): + with runLocally(): + scenario = compileScenic( + """ + import helper_require_monitor + ego = new Object + record 1 as "foo" + """ + ) + + result = sampleResult(scenario, maxSteps=5) + assert "foo" in result.records + assert result.records["foo"] == [(0, 1)] + + def test_inherit_constructors(runLocally): with runLocally(): scenario = compileScenic("from helper import Caerbannog\n" "ego = new Caerbannog")