Skip to content

Commit 4cc03a4

Browse files
authored
Support discrete event scheduling (#2066)
# Summary This PR adds an experiment feature that puts event scheduling at the heart of how MESA works. An earlier draft of this code was shown and discussed in #2032. This code generalizes #1890 by making discrete event scheduling central to how mesa models are run. This makes it trivial to maintain discrete time progression while allowing for event scheduling, thus making it possible to build hybrid ABM-DEVS models. # Motive Agent-based models can be quite slow because, typically, they involve the activation of all agents at each tick. However, this can become very expensive if it can be known upfront that the agent is not active. Combining ABM tick-based activation with event scheduling makes it easy to avoid activating dormant agents. For example, in Epstein's civil violence model, agents who are in jail need not be activated until they are released from jail. This release from jail can be scheduled as an event, thus avoiding unnecessary agent activations. Likewise, in Wolf-Sheep with grass, the regrowth of grass patches can be scheduled instead of activating all patches for each tick only to decrement a counter. # Implementation The experimental feature adds three core new classes: Simulator, EventList, and SimulationEvent. The simulator is analogous to a numerical solver for ODEs. Theoretically, the idea of a Simulator is rooted in the work of [Zeigler](https://www.sciencedirect.com/book/9780128133705/theory-of-modeling-and-simulation), The Simulator is responsible for controlling and advancing time. The EventList is a heapq sorted list of SimulationEvents. SimulationEvents are sorted based on their time of execution, their priority, and their unique_id. A SimulationEvent is, in essence, a callable that is to be executed at a particular simulation time instant. This PR adds two specific simulators: ABMSimulator and DEVSSimulator. ABMSimulator uses integers as the base unit of time and automatically ensures that `model.step` is scheduled for each tick. DEVSSimulator uses float as the base unit of time. It allows for full discrete event scheduling. Using these new classes requires a minor modification to a Model instance. It needs a simulator attribute to be able to schedule events. # Usage The basic usage is straightforward as shown below. We instantiate an ABMSimulator, instantiate the model, and call `simulator.setup`. Next, we can run the model for, e.g., 100 time steps). ```python simulator = ABMSimulator() model = WolfSheep(simulator,25, 25, 60, 40, 0.2, 0.1, 20, seed=15,) simulator.setup(model) simulator.run(100) print(model.time) # prints 100 simulator.run(50) print(model.time) # prints 150 ``` The simulator comes with a whole range of methods for scheduling events: `schedule_event_now`, `schedule_event_relative`, `schedule_event_absolute`, and the ABMSimulator also has a `schedule_event_next_tick`. See `experimental/devs/examples/*.*` for more details on how to use these methods.
1 parent b2856f4 commit 4cc03a4

File tree

13 files changed

+1363
-279
lines changed

13 files changed

+1363
-279
lines changed

benchmarks/Flocking/flocking.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def __init__(
102102
cohere=0.03,
103103
separate=0.015,
104104
match=0.05,
105+
simulator=None,
105106
):
106107
"""
107108
Create a new Flockers model.
@@ -118,18 +119,18 @@ def __init__(
118119
"""
119120
super().__init__(seed=seed)
120121
self.population = population
121-
self.vision = vision
122-
self.speed = speed
123-
self.separation = separation
122+
self.width = width
123+
self.height = height
124+
self.simulator = simulator
125+
124126
self.schedule = mesa.time.RandomActivation(self)
125-
self.space = mesa.space.ContinuousSpace(width, height, True)
126-
self.factors = {"cohere": cohere, "separate": separate, "match": match}
127-
self.make_agents()
127+
self.space = mesa.space.ContinuousSpace(self.width, self.height, True)
128+
self.factors = {
129+
"cohere": cohere,
130+
"separate": separate,
131+
"match": match,
132+
}
128133

129-
def make_agents(self):
130-
"""
131-
Create self.population agents, with random positions and starting directions.
132-
"""
133134
for i in range(self.population):
134135
x = self.random.random() * self.space.x_max
135136
y = self.random.random() * self.space.y_max
@@ -138,10 +139,11 @@ def make_agents(self):
138139
boid = Boid(
139140
unique_id=i,
140141
model=self,
141-
speed=self.speed,
142+
pos=pos,
143+
speed=speed,
142144
direction=direction,
143-
vision=self.vision,
144-
separation=self.separation,
145+
vision=vision,
146+
separation=separation,
145147
**self.factors,
146148
)
147149
self.space.place_agent(boid, pos)

benchmarks/Schelling/schelling.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def __init__(
4949
density=0.8,
5050
minority_pc=0.5,
5151
seed=None,
52+
simulator=None,
5253
):
5354
"""
5455
Create a new Schelling model.
@@ -62,10 +63,8 @@ def __init__(
6263
seed: Seed for Reproducibility
6364
"""
6465
super().__init__(seed=seed)
65-
self.height = height
66-
self.width = width
67-
self.density = density
6866
self.minority_pc = minority_pc
67+
self.simulator = simulator
6968

7069
self.schedule = RandomActivation(self)
7170
self.grid = OrthogonalMooreGrid(
@@ -75,14 +74,12 @@ def __init__(
7574
random=self.random,
7675
)
7776

78-
self.happy = 0
79-
8077
# Set up agents
8178
# We use a grid iterator that returns
8279
# the coordinates of a cell as well as
8380
# its contents. (coord_iter)
8481
for cell in self.grid:
85-
if self.random.random() < self.density:
82+
if self.random.random() < density:
8683
agent_type = 1 if self.random.random() < self.minority_pc else 0
8784
agent = SchellingAgent(
8885
self.next_id(), self, agent_type, radius, homophily

benchmarks/WolfSheep/wolf_sheep.py

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from mesa import Model
1515
from mesa.experimental.cell_space import CellAgent, OrthogonalVonNeumannGrid
16-
from mesa.time import RandomActivationByType
16+
from mesa.experimental.devs import ABMSimulator
1717

1818

1919
class Animal(CellAgent):
@@ -36,7 +36,6 @@ def spawn_offspring(self):
3636
self.energy_from_food,
3737
)
3838
offspring.move_to(self.cell)
39-
self.model.schedule.add(offspring)
4039

4140
def feed(self): ...
4241

@@ -93,26 +92,41 @@ class GrassPatch(CellAgent):
9392
A patch of grass that grows at a fixed rate and it is eaten by sheep
9493
"""
9594

96-
def __init__(self, unique_id, model, fully_grown, countdown):
95+
@property
96+
def fully_grown(self):
97+
return self._fully_grown
98+
99+
@fully_grown.setter
100+
def fully_grown(self, value: bool) -> None:
101+
self._fully_grown = value
102+
103+
if not value:
104+
self.model.simulator.schedule_event_relative(
105+
setattr,
106+
self.grass_regrowth_time,
107+
function_args=[self, "fully_grown", True],
108+
)
109+
110+
def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
97111
"""
112+
TODO:: fully grown can just be an int --> so one less param (i.e. countdown)
113+
98114
Creates a new patch of grass
99115
100116
Args:
101117
fully_grown: (boolean) Whether the patch of grass is fully grown or not
102118
countdown: Time for the patch of grass to be fully grown again
119+
grass_regrowth_time : time to fully regrow grass
120+
countdown : Time for the patch of grass to be fully regrown if fully grown is False
103121
"""
104122
super().__init__(unique_id, model)
105-
self.fully_grown = fully_grown
106-
self.countdown = countdown
123+
self._fully_grown = fully_grown
124+
self.grass_regrowth_time = grass_regrowth_time
107125

108-
def step(self):
109126
if not self.fully_grown:
110-
if self.countdown <= 0:
111-
# Set as fully grown
112-
self.fully_grown = True
113-
self.countdown = self.model.grass_regrowth_time
114-
else:
115-
self.countdown -= 1
127+
self.model.simulator.schedule_event_relative(
128+
setattr, countdown, function_args=[self, "fully_grown", True]
129+
)
116130

117131

118132
class WolfSheep(Model):
@@ -124,6 +138,7 @@ class WolfSheep(Model):
124138

125139
def __init__(
126140
self,
141+
simulator,
127142
height,
128143
width,
129144
initial_sheep,
@@ -139,27 +154,26 @@ def __init__(
139154
Create a new Wolf-Sheep model with the given parameters.
140155
141156
Args:
157+
simulator: ABMSimulator instance
142158
initial_sheep: Number of sheep to start with
143159
initial_wolves: Number of wolves to start with
144160
sheep_reproduce: Probability of each sheep reproducing each step
145161
wolf_reproduce: Probability of each wolf reproducing each step
146162
wolf_gain_from_food: Energy a wolf gains from eating a sheep
147-
grass: Whether to have the sheep eat grass for energy
148163
grass_regrowth_time: How long it takes for a grass patch to regrow
149164
once it is eaten
150165
sheep_gain_from_food: Energy sheep gain from grass, if enabled.
151-
moore:
152-
seed
166+
seed : the random seed
153167
"""
154168
super().__init__(seed=seed)
155169
# Set parameters
156170
self.height = height
157171
self.width = width
172+
self.simulator = simulator
173+
158174
self.initial_sheep = initial_sheep
159175
self.initial_wolves = initial_wolves
160-
self.grass_regrowth_time = grass_regrowth_time
161176

162-
self.schedule = RandomActivationByType(self)
163177
self.grid = OrthogonalVonNeumannGrid(
164178
[self.height, self.width],
165179
torus=False,
@@ -175,10 +189,13 @@ def __init__(
175189
)
176190
energy = self.random.randrange(2 * sheep_gain_from_food)
177191
sheep = Sheep(
178-
self.next_id(), self, energy, sheep_reproduce, sheep_gain_from_food
192+
self.next_id(),
193+
self,
194+
energy,
195+
sheep_reproduce,
196+
sheep_gain_from_food,
179197
)
180198
sheep.move_to(self.grid[pos])
181-
self.schedule.add(sheep)
182199

183200
# Create wolves
184201
for _ in range(self.initial_wolves):
@@ -188,33 +205,50 @@ def __init__(
188205
)
189206
energy = self.random.randrange(2 * wolf_gain_from_food)
190207
wolf = Wolf(
191-
self.next_id(), self, energy, wolf_reproduce, wolf_gain_from_food
208+
self.next_id(),
209+
self,
210+
energy,
211+
wolf_reproduce,
212+
wolf_gain_from_food,
192213
)
193214
wolf.move_to(self.grid[pos])
194-
self.schedule.add(wolf)
195215

196216
# Create grass patches
197217
possibly_fully_grown = [True, False]
198218
for cell in self.grid:
199219
fully_grown = self.random.choice(possibly_fully_grown)
200220
if fully_grown:
201-
countdown = self.grass_regrowth_time
221+
countdown = grass_regrowth_time
202222
else:
203-
countdown = self.random.randrange(self.grass_regrowth_time)
204-
patch = GrassPatch(self.next_id(), self, fully_grown, countdown)
223+
countdown = self.random.randrange(grass_regrowth_time)
224+
patch = GrassPatch(
225+
self.next_id(), self, fully_grown, countdown, grass_regrowth_time
226+
)
205227
patch.move_to(cell)
206-
self.schedule.add(patch)
207228

208229
def step(self):
209-
self.schedule.step()
230+
self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
231+
self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")
210232

211233

212234
if __name__ == "__main__":
213235
import time
214236

215-
model = WolfSheep(25, 25, 60, 40, 0.2, 0.1, 20, seed=15)
237+
simulator = ABMSimulator()
238+
model = WolfSheep(
239+
simulator,
240+
25,
241+
25,
242+
60,
243+
40,
244+
0.2,
245+
0.1,
246+
20,
247+
seed=15,
248+
)
249+
250+
simulator.setup(model)
216251

217252
start_time = time.perf_counter()
218-
for _ in range(100):
219-
model.step()
253+
simulator.run(100)
220254
print("Time:", time.perf_counter() - start_time)

benchmarks/global_benchmark.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from configurations import configurations
99

10+
from mesa.experimental.devs.simulator import ABMSimulator
11+
1012
# making sure we use this version of mesa and not one
1113
# also installed in site_packages or so.
1214
sys.path.insert(0, os.path.abspath(".."))
@@ -15,13 +17,16 @@
1517
# Generic function to initialize and run a model
1618
def run_model(model_class, seed, parameters):
1719
start_init = timeit.default_timer()
18-
model = model_class(seed=seed, **parameters)
19-
# time.sleep(0.001)
20+
simulator = ABMSimulator()
21+
model = model_class(simulator=simulator, seed=seed, **parameters)
22+
simulator.setup(model)
2023

2124
end_init_start_run = timeit.default_timer()
2225

23-
for _ in range(config["steps"]):
24-
model.step()
26+
simulator.run_for(config["steps"])
27+
28+
# for _ in range(config["steps"]):
29+
# model.step()
2530
# time.sleep(0.0001)
2631
end_run = timeit.default_timer()
2732

mesa/experimental/devs/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .eventlist import Priority, SimulationEvent
2+
from .simulator import ABMSimulator, DEVSimulator
3+
4+
__all__ = ["ABMSimulator", "DEVSimulator", "SimulationEvent", "Priority"]

0 commit comments

Comments
 (0)