diff --git a/src/scenic/core/object_types.py b/src/scenic/core/object_types.py index 5f46f5aec..8ecdff064 100644 --- a/src/scenic/core/object_types.py +++ b/src/scenic/core/object_types.py @@ -1017,7 +1017,7 @@ class Object(OrientedPoint): behavior: Behavior for dynamic agents, if any (see :ref:`dynamics`). Default value ``None``. lastActions: Tuple of :term:`actions` taken by this agent in the last time step - (or `None` if the object is not an agent or this is the first time step). + (an empty tuple if the object is not an agent or this is the first time step). """ _scenic_properties = { @@ -1042,7 +1042,7 @@ class Object(OrientedPoint): "angularVelocity": PropertyDefault((), {"dynamic"}, lambda self: Vector(0, 0, 0)), "angularSpeed": PropertyDefault((), {"dynamic"}, lambda self: 0), "behavior": None, - "lastActions": None, + "lastActions": tuple(), # weakref to scenario which created this object, for internal use "_parentScenario": None, } diff --git a/src/scenic/core/simulators.py b/src/scenic/core/simulators.py index 539c8fdab..832b03632 100644 --- a/src/scenic/core/simulators.py +++ b/src/scenic/core/simulators.py @@ -11,7 +11,7 @@ """ import abc -from collections import OrderedDict, defaultdict +from collections import defaultdict import enum import math import numbers @@ -294,7 +294,10 @@ class Simulation(abc.ABC): timestep (float): Length of each time step in seconds. objects: List of Scenic objects (instances of `Object`) existing in the simulation. This list will change if objects are created dynamically. - agents: List of :term:`agents` in the simulation. + agents: List of :term:`agents` in the simulation. An agent is any object that has + or had a behavior at any point in the simulation. The agents list may have objects + appended to the end as the simulation progresses (if a non-agent object has its + behavior overridden), but once an object is in the agents list its position is fixed. result (`SimulationResult`): Result of the simulation, or `None` if it has not yet completed. This is the primary object which should be inspected to get data out of the simulation: the other undocumented attributes of this class @@ -331,7 +334,6 @@ def __init__( self.result = None self.scene = scene self.objects = [] - self.agents = [] self.trajectory = [] self.records = defaultdict(list) self.currentTime = 0 @@ -398,7 +400,7 @@ def __init__( for obj in self.objects: disableDynamicProxyFor(obj) for agent in self.agents: - if agent.behavior._isRunning: + if agent.behavior and agent.behavior._isRunning: agent.behavior._stop() # If the simulation was terminated by an exception (including rejections), # some scenarios may still be running; we need to clean them up without @@ -441,10 +443,25 @@ def _run(self, dynamicScenario, maxSteps): if maxSteps and self.currentTime >= maxSteps: return TerminationType.timeLimit, f"reached time limit ({maxSteps} steps)" + # Clear lastActions for all objects + for obj in self.objects: + obj.lastActions = tuple() + + # Update agents with any objects that now have behaviors (and are not already agents) + self.agents += [ + obj for obj in self.objects if obj.behavior and obj not in self.agents + ] + # Compute the actions of the agents in this time step - allActions = OrderedDict() + allActions = defaultdict(tuple) schedule = self.scheduleForAgents() + if not set(self.agents) == set(schedule): + raise RuntimeError("Simulator schedule does not contain all agents") for agent in schedule: + # If agent doesn't have a behavior right now, continue + if not agent.behavior: + continue + # Run the agent's behavior to get its actions actions = agent.behavior._step() @@ -472,11 +489,13 @@ def _run(self, dynamicScenario, maxSteps): # Save actions for execution below allActions[agent] = actions + # Log lastActions + agent.lastActions = actions + # Execute the actions if self.verbosity >= 3: for agent, actions in allActions.items(): print(f" Agent {agent} takes action(s) {actions}") - agent.lastActions = actions self.actionSequence.append(allActions) self.executeActions(allActions) @@ -492,6 +511,7 @@ def setup(self): but should call the parent implementation to create the objects in the initial scene (through `createObjectInSimulator`). """ + self.agents = [] for obj in self.scene.objects: self._createObject(obj) @@ -624,9 +644,9 @@ def executeActions(self, allActions): functionality. Args: - allActions: an :obj:`~collections.OrderedDict` mapping each agent to a tuple - of actions. The order of agents in the dict should be respected in case - the order of actions matters. + allActions: a :obj:`~collections.defaultdict` mapping each agent to a tuple + of actions, with the default value being an empty tuple. The order of + agents in the dict should be respected in case the order of actions matters. """ for agent, actions in allActions.items(): for action in actions: diff --git a/tests/core/test_simulators.py b/tests/core/test_simulators.py index 70851495e..149c1cad1 100644 --- a/tests/core/test_simulators.py +++ b/tests/core/test_simulators.py @@ -64,3 +64,33 @@ class TestObj: assert result is not None assert result.records["test_val_1"] == [(0, "bar"), (1, "bar"), (2, "bar")] assert result.records["test_val_2"] == result.records["test_val_3"] == "bar" + + +def test_simulator_bad_scheduler(): + class TestSimulation(DummySimulation): + def scheduleForAgents(self): + # Don't include the last agent + return self.agents[:-1] + + class TestSimulator(DummySimulator): + def createSimulation(self, scene, **kwargs): + return TestSimulation(scene, **kwargs) + + scenario = compileScenic( + """ + behavior Foo(): + take 1 + + class TestObj: + allowCollisions: True + behavior: Foo + + for _ in range(5): + new TestObj + """ + ) + + scene, _ = scenario.generate(maxIterations=1) + simulator = TestSimulator() + with pytest.raises(RuntimeError): + result = simulator.simulate(scene, maxSteps=2) diff --git a/tests/syntax/test_dynamics.py b/tests/syntax/test_dynamics.py index 513796cd1..0bd724b03 100644 --- a/tests/syntax/test_dynamics.py +++ b/tests/syntax/test_dynamics.py @@ -2085,3 +2085,33 @@ def test_record(): (2, (4, 0, 0)), (3, (6, 0, 0)), ) + + +## lastActions Property +def test_lastActions(): + scenario = compileScenic( + """ + behavior Foo(): + for i in range(4): + take i + ego = new Object with behavior Foo, with allowCollisions True + other = new Object with allowCollisions True + record ego.lastActions as ego_lastActions + record other.lastActions as other_lastActions + """ + ) + result = sampleResult(scenario, maxSteps=4) + assert tuple(result.records["ego_lastActions"]) == ( + (0, tuple()), + (1, (0,)), + (2, (1,)), + (3, (2,)), + (4, (3,)), + ) + assert tuple(result.records["other_lastActions"]) == ( + (0, tuple()), + (1, tuple()), + (2, tuple()), + (3, tuple()), + (4, tuple()), + ) diff --git a/tests/syntax/test_modular.py b/tests/syntax/test_modular.py index 2a967831a..a7a89903e 100644 --- a/tests/syntax/test_modular.py +++ b/tests/syntax/test_modular.py @@ -9,6 +9,7 @@ from scenic.core.simulators import DummySimulator, TerminationType from tests.utils import ( compileScenic, + sampleActionsFromScene, sampleEgo, sampleEgoActions, sampleEgoFrom, @@ -809,6 +810,75 @@ def test_override_behavior(): assert tuple(actions) == (1, -1, -2, 2) +def test_override_none_behavior(): + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object + compose: + wait + do Sub() for 2 steps + wait + scenario Sub(): + setup: + override ego with behavior Bar + behavior Bar(): + x = -1 + while True: + take x + x -= 1 + """, + scenario="Main", + ) + actions = sampleEgoActions(scenario, maxSteps=4) + assert tuple(actions) == (None, -1, -2, None) + + +def test_override_leakage(): + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object with prop 1 + compose: + do Sub1() + scenario Sub1(): + setup: + override ego with prop 2, with behavior Bar + behavior Bar(): + terminate + """, + scenario="Main", + ) + scene = sampleScene(scenario) + assert scene.objects[0].prop == 1 + sampleActionsFromScene(scene) + assert scene.objects[0].prop == 1 + + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object with prop 1 + compose: + do Sub1() + scenario Sub1(): + setup: + override ego with prop 2, with behavior Bar + behavior Bar(): + raise NotImplementedError() + wait + """, + scenario="Main", + ) + scene = sampleScene(scenario) + assert scene.objects[0].prop == 1 + with pytest.raises(NotImplementedError): + sampleActionsFromScene(scene) + assert scene.objects[0].prop == 1 + + def test_override_dynamic(): with pytest.raises(SpecifierError): compileScenic( @@ -1048,3 +1118,42 @@ def test_scenario_signature(body): assert name4 == "qux" assert p4.default is inspect.Parameter.empty assert p4.kind is inspect.Parameter.VAR_KEYWORD + + +# lastActions Property +def test_lastActions_modular(): + scenario = compileScenic( + """ + scenario Main(): + setup: + ego = new Object + record ego.lastActions as lastActions + compose: + do Sub1() for 2 steps + do Sub2() for 2 steps + do Sub1() for 2 steps + wait + scenario Sub1(): + setup: + override ego with behavior Bar + scenario Sub2(): + setup: + override ego with behavior None + behavior Bar(): + x = -1 + while True: + take x + x -= 1 + """, + scenario="Main", + ) + result = sampleResult(scenario, maxSteps=6) + assert tuple(result.records["lastActions"]) == ( + (0, tuple()), + (1, (-1,)), + (2, (-2,)), + (3, tuple()), + (4, tuple()), + (5, (-1,)), + (6, (-2,)), + ) diff --git a/tests/utils.py b/tests/utils.py index 7dd5dcaee..9f4816ad2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -92,7 +92,10 @@ def sampleEgoActions( asMapping=False, timestep=timestep, ) - return [actions[0] for actions in allActions] + return [ + actions[0] if actions else (None if singleAction else tuple()) + for actions in allActions + ] def sampleEgoActionsFromScene( @@ -108,7 +111,10 @@ def sampleEgoActionsFromScene( ) if allActions is None: return None - return [actions[0] for actions in allActions] + return [ + actions[0] if actions else (None if singleAction else tuple()) + for actions in allActions + ] def sampleActions(