Skip to content

Commit

Permalink
Support discrete event scheduling (#2066)
Browse files Browse the repository at this point in the history
# 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.
  • Loading branch information
quaquel authored Apr 10, 2024
1 parent b2856f4 commit 4cc03a4
Show file tree
Hide file tree
Showing 13 changed files with 1,363 additions and 279 deletions.
28 changes: 15 additions & 13 deletions benchmarks/Flocking/flocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def __init__(
cohere=0.03,
separate=0.015,
match=0.05,
simulator=None,
):
"""
Create a new Flockers model.
Expand All @@ -118,18 +119,18 @@ def __init__(
"""
super().__init__(seed=seed)
self.population = population
self.vision = vision
self.speed = speed
self.separation = separation
self.width = width
self.height = height
self.simulator = simulator

self.schedule = mesa.time.RandomActivation(self)
self.space = mesa.space.ContinuousSpace(width, height, True)
self.factors = {"cohere": cohere, "separate": separate, "match": match}
self.make_agents()
self.space = mesa.space.ContinuousSpace(self.width, self.height, True)
self.factors = {
"cohere": cohere,
"separate": separate,
"match": match,
}

def make_agents(self):
"""
Create self.population agents, with random positions and starting directions.
"""
for i in range(self.population):
x = self.random.random() * self.space.x_max
y = self.random.random() * self.space.y_max
Expand All @@ -138,10 +139,11 @@ def make_agents(self):
boid = Boid(
unique_id=i,
model=self,
speed=self.speed,
pos=pos,
speed=speed,
direction=direction,
vision=self.vision,
separation=self.separation,
vision=vision,
separation=separation,
**self.factors,
)
self.space.place_agent(boid, pos)
Expand Down
9 changes: 3 additions & 6 deletions benchmarks/Schelling/schelling.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def __init__(
density=0.8,
minority_pc=0.5,
seed=None,
simulator=None,
):
"""
Create a new Schelling model.
Expand All @@ -62,10 +63,8 @@ def __init__(
seed: Seed for Reproducibility
"""
super().__init__(seed=seed)
self.height = height
self.width = width
self.density = density
self.minority_pc = minority_pc
self.simulator = simulator

self.schedule = RandomActivation(self)
self.grid = OrthogonalMooreGrid(
Expand All @@ -75,14 +74,12 @@ def __init__(
random=self.random,
)

self.happy = 0

# Set up agents
# We use a grid iterator that returns
# the coordinates of a cell as well as
# its contents. (coord_iter)
for cell in self.grid:
if self.random.random() < self.density:
if self.random.random() < density:
agent_type = 1 if self.random.random() < self.minority_pc else 0
agent = SchellingAgent(
self.next_id(), self, agent_type, radius, homophily
Expand Down
92 changes: 63 additions & 29 deletions benchmarks/WolfSheep/wolf_sheep.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from mesa import Model
from mesa.experimental.cell_space import CellAgent, OrthogonalVonNeumannGrid
from mesa.time import RandomActivationByType
from mesa.experimental.devs import ABMSimulator


class Animal(CellAgent):
Expand All @@ -36,7 +36,6 @@ def spawn_offspring(self):
self.energy_from_food,
)
offspring.move_to(self.cell)
self.model.schedule.add(offspring)

def feed(self): ...

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

def __init__(self, unique_id, model, fully_grown, countdown):
@property
def fully_grown(self):
return self._fully_grown

@fully_grown.setter
def fully_grown(self, value: bool) -> None:
self._fully_grown = value

if not value:
self.model.simulator.schedule_event_relative(
setattr,
self.grass_regrowth_time,
function_args=[self, "fully_grown", True],
)

def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
"""
TODO:: fully grown can just be an int --> so one less param (i.e. countdown)
Creates a new patch of grass
Args:
fully_grown: (boolean) Whether the patch of grass is fully grown or not
countdown: Time for the patch of grass to be fully grown again
grass_regrowth_time : time to fully regrow grass
countdown : Time for the patch of grass to be fully regrown if fully grown is False
"""
super().__init__(unique_id, model)
self.fully_grown = fully_grown
self.countdown = countdown
self._fully_grown = fully_grown
self.grass_regrowth_time = grass_regrowth_time

def step(self):
if not self.fully_grown:
if self.countdown <= 0:
# Set as fully grown
self.fully_grown = True
self.countdown = self.model.grass_regrowth_time
else:
self.countdown -= 1
self.model.simulator.schedule_event_relative(
setattr, countdown, function_args=[self, "fully_grown", True]
)


class WolfSheep(Model):
Expand All @@ -124,6 +138,7 @@ class WolfSheep(Model):

def __init__(
self,
simulator,
height,
width,
initial_sheep,
Expand All @@ -139,27 +154,26 @@ def __init__(
Create a new Wolf-Sheep model with the given parameters.
Args:
simulator: ABMSimulator instance
initial_sheep: Number of sheep to start with
initial_wolves: Number of wolves to start with
sheep_reproduce: Probability of each sheep reproducing each step
wolf_reproduce: Probability of each wolf reproducing each step
wolf_gain_from_food: Energy a wolf gains from eating a sheep
grass: Whether to have the sheep eat grass for energy
grass_regrowth_time: How long it takes for a grass patch to regrow
once it is eaten
sheep_gain_from_food: Energy sheep gain from grass, if enabled.
moore:
seed
seed : the random seed
"""
super().__init__(seed=seed)
# Set parameters
self.height = height
self.width = width
self.simulator = simulator

self.initial_sheep = initial_sheep
self.initial_wolves = initial_wolves
self.grass_regrowth_time = grass_regrowth_time

self.schedule = RandomActivationByType(self)
self.grid = OrthogonalVonNeumannGrid(
[self.height, self.width],
torus=False,
Expand All @@ -175,10 +189,13 @@ def __init__(
)
energy = self.random.randrange(2 * sheep_gain_from_food)
sheep = Sheep(
self.next_id(), self, energy, sheep_reproduce, sheep_gain_from_food
self.next_id(),
self,
energy,
sheep_reproduce,
sheep_gain_from_food,
)
sheep.move_to(self.grid[pos])
self.schedule.add(sheep)

# Create wolves
for _ in range(self.initial_wolves):
Expand All @@ -188,33 +205,50 @@ def __init__(
)
energy = self.random.randrange(2 * wolf_gain_from_food)
wolf = Wolf(
self.next_id(), self, energy, wolf_reproduce, wolf_gain_from_food
self.next_id(),
self,
energy,
wolf_reproduce,
wolf_gain_from_food,
)
wolf.move_to(self.grid[pos])
self.schedule.add(wolf)

# Create grass patches
possibly_fully_grown = [True, False]
for cell in self.grid:
fully_grown = self.random.choice(possibly_fully_grown)
if fully_grown:
countdown = self.grass_regrowth_time
countdown = grass_regrowth_time
else:
countdown = self.random.randrange(self.grass_regrowth_time)
patch = GrassPatch(self.next_id(), self, fully_grown, countdown)
countdown = self.random.randrange(grass_regrowth_time)
patch = GrassPatch(
self.next_id(), self, fully_grown, countdown, grass_regrowth_time
)
patch.move_to(cell)
self.schedule.add(patch)

def step(self):
self.schedule.step()
self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")


if __name__ == "__main__":
import time

model = WolfSheep(25, 25, 60, 40, 0.2, 0.1, 20, seed=15)
simulator = ABMSimulator()
model = WolfSheep(
simulator,
25,
25,
60,
40,
0.2,
0.1,
20,
seed=15,
)

simulator.setup(model)

start_time = time.perf_counter()
for _ in range(100):
model.step()
simulator.run(100)
print("Time:", time.perf_counter() - start_time)
13 changes: 9 additions & 4 deletions benchmarks/global_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from configurations import configurations

from mesa.experimental.devs.simulator import ABMSimulator

# making sure we use this version of mesa and not one
# also installed in site_packages or so.
sys.path.insert(0, os.path.abspath(".."))
Expand All @@ -15,13 +17,16 @@
# Generic function to initialize and run a model
def run_model(model_class, seed, parameters):
start_init = timeit.default_timer()
model = model_class(seed=seed, **parameters)
# time.sleep(0.001)
simulator = ABMSimulator()
model = model_class(simulator=simulator, seed=seed, **parameters)
simulator.setup(model)

end_init_start_run = timeit.default_timer()

for _ in range(config["steps"]):
model.step()
simulator.run_for(config["steps"])

# for _ in range(config["steps"]):
# model.step()
# time.sleep(0.0001)
end_run = timeit.default_timer()

Expand Down
4 changes: 4 additions & 0 deletions mesa/experimental/devs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .eventlist import Priority, SimulationEvent
from .simulator import ABMSimulator, DEVSimulator

__all__ = ["ABMSimulator", "DEVSimulator", "SimulationEvent", "Priority"]
Loading

0 comments on commit 4cc03a4

Please sign in to comment.