Skip to content

Add unified time and event scheduling API to Model#3152

Closed
EwoutH wants to merge 1 commit intomesa:mainfrom
EwoutH:timeflow
Closed

Add unified time and event scheduling API to Model#3152
EwoutH wants to merge 1 commit intomesa:mainfrom
EwoutH:timeflow

Conversation

@EwoutH
Copy link
Copy Markdown
Member

@EwoutH EwoutH commented Jan 16, 2026

Summary

This PR implements the first phase of the unified time and event scheduling API discussed in #2921. It integrates event scheduling directly into the Model class, making it simple to use both traditional time-stepping and discrete event simulation without breaking any existing functionality.

Basically, this PR does two things:

  1. Introduce a nicer API for DEVS
  2. Letting Model.step() run the model for 1 timestep

Motivation

Currently, users who want to use event scheduling must work with the experimental Simulator classes (ABMSimulator, DEVSimulator), which requires managing two separate objects and understanding the distinction between them. This creates unnecessary complexity for a feature that should be a natural part of Mesa models.

This PR makes event scheduling a first-class citizen by integrating it directly into Model, while maintaining full backward compatibility with existing step-based models.

Usage patterns

This API enables classical ABM, pure event-driven, and hybrid stuff in between. I think

1. Pure classical ABM

Everything happens in step(), time advances in integer increments:

class WolfSheep(Model):
    def step(self):
        self.agents.shuffle_do("step")

model = WolfSheep()
for _ in range(100):
    model.step()  # t=1, 2, 3, ...

This is what most people who don't use a simulator do now.

2. Hybrid: step + events

Regular stepping with scheduled one-off or agent-triggered events:

class WolfSheep(Model):
    def __init__(self):
        super().__init__()
        self.schedule_at(self.drought, time=50)  # One-off event
    
    def step(self):
        self.agents.shuffle_do("step")
    
    def drought(self):
        self.grass_layer.modify_cells(lambda g: g * 0.5)

model = WolfSheep()
for _ in range(100):
    model.step()  # step() at t=1,2,3... + drought fires at t=50

This is functionally equivalent to the ABMScheduler, but you still call model.step(). So instead of a scheduler scheduling the step, this works the other way around, the step run the scheduled events (if any).

3. Pure event-driven (no step)

No regular stepping, only scheduled events with continuous time:

class QueueingModel(Model):
    def __init__(self, arrival_rate):
        super().__init__()
        self.arrival_rate = arrival_rate
        self.schedule_at(self.customer_arrival, time=0)
    
    def customer_arrival(self):
        Customer(self)
        next_time = self.time + self.random.expovariate(self.arrival_rate)
        self.schedule_at(self.customer_arrival, time=next_time)

model = QueueingModel(arrival_rate=2.0)
model.run_until(1000.0)  # Time jumps between events: t=0, 0.3, 0.8, 1.2, ...

This is functionally equivalent to the DEVScheduler.


This PR is fully backward compatible. Users can opt-in to new features without modifying existing code.

Curious what everybody thinks of this approach. Being backwards compatible has a lot going for it, as well as the easy intro into scheduling events.

The only disadvantage might be that users could expect the run() methods to also perform the step. But I think we can explain that.

We could optionally add a run_steps() utility method:

def run_steps(self, n_steps):
    """Run n steps using the step() method."""
    for _ in range(n_steps):
        self.step()

If we agree on this direction, I will add/update tests, the simulator, example models and the tutorial.

Part of #2921.

Introduces clean scheduling and run control methods directly on Model, providing a unified interface for both traditional time-step advancement and discrete event simulation.

New Model methods:
- schedule_at(callback, time): Schedule event at absolute time
- schedule_after(callback, delay): Schedule event after delay
- cancel_event(event): Cancel a scheduled event
- run_until(end_time): Run until specific time
- run_for(duration): Run for specific duration
- run_while(condition): Run while condition is true
- run_next_event(): Execute next scheduled event

Implementation:
- Creates timeflow.py with Scheduler and RunControl classes
- Model delegates to internal _scheduler and _run_control instances
- Modifies _wrapped_step to use run_for(1) internally
- Non-breaking: existing step-based models work unchanged

This lays the foundation for more advanced features like recurring methods and agent self-scheduling while maintaining full backward compatibility.
@EwoutH EwoutH requested a review from quaquel January 16, 2026 14:22
@EwoutH EwoutH added the feature Release notes label label Jan 16, 2026
@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🟢 -6.8% [-7.3%, -6.3%] 🔵 +3.0% [+2.5%, +3.3%]
BoltzmannWealth large 🔵 -0.4% [-0.9%, +0.1%] 🟢 -10.3% [-11.5%, -8.9%]
Schelling small 🔵 +1.3% [+1.0%, +1.5%] 🔵 -0.8% [-0.9%, -0.6%]
Schelling large 🔵 +1.1% [+0.8%, +1.5%] 🔵 -3.0% [-4.5%, -1.6%]
WolfSheep small 🔵 -0.4% [-0.6%, -0.1%] 🔵 +0.0% [-0.2%, +0.3%]
WolfSheep large 🔵 -0.9% [-1.8%, -0.0%] 🟢 -6.0% [-7.2%, -4.7%]
BoidFlockers small 🔵 +0.6% [+0.2%, +1.0%] 🔵 -0.1% [-0.3%, +0.1%]
BoidFlockers large 🔵 +1.2% [+0.6%, +1.9%] 🔵 +0.6% [+0.4%, +0.9%]

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Jan 16, 2026

This is functionally equivalent to the ABMScheduler, but you still call model.step(). So instead of a scheduler scheduling the step, this works the other way around, the step run the scheduled events (if any).

The other approach is that we make step() a special method, that runs always on integer time. Then all run() function will schedule a step on each integer time. If you don't want to use the step, you just leave it empty.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Jan 16, 2026

The latter would make for an extremely clean API:

Pattern 1: Pure classical ABM

class WolfSheep(Model):
    def step(self):
        self.agents.shuffle_do("step")

model = WolfSheep()
model.run_for(100)  # step() auto-scheduled at t=1,2,3...

Pattern 2: Hybrid

class WolfSheep(Model):
    def __init__(self):
        super().__init__()
        self.schedule_at(self.drought, time=50)
    
    def step(self):
        self.agents.shuffle_do("step")

model = WolfSheep()
model.run_for(100)  # step() at t=1,2,3... + drought at t=50

Pattern 3: Pure event-driven (no step)

class QueueingModel(Model):
    def __init__(self):
        super().__init__()
        # Don't override step(), so it doesn't get auto-scheduled
        self.schedule_at(self.customer_arrival, time=0)

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 16, 2026

I can only comment on the API at the moment; I have no time to review the code at this moment.

  1. I would expect that model.run_for etc. would just work out of the box, or would require something minor from my side to make it work. A simple option would be to add an annotation like @scheduled. Can you elaborate on what you mean by making step a special method? However, if we can avoid this, that would be even nicer.
  2. I like the fact that by default, the current approach, where you write your own for loop, still works.
  3. In what way can the user specify the run setup? I would like to have a default run setup (e.g, time_step=1, end_time=100)

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Jan 16, 2026

Succeeded by production PR #3155.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants