Skip to content

Refactor step scheduling to use EventGenerator internally#3260

Merged
EwoutH merged 6 commits intomesa:mainfrom
EwoutH:default_schedule
Feb 9, 2026
Merged

Refactor step scheduling to use EventGenerator internally#3260
EwoutH merged 6 commits intomesa:mainfrom
EwoutH:default_schedule

Conversation

@EwoutH
Copy link
Copy Markdown
Member

@EwoutH EwoutH commented Feb 8, 2026

See the "Does Mesa need a "heartbeat"?" discussion in #2921 (comment).

Summary

Replaces the hand-rolled step rescheduling loop in Model with an internal EventGenerator, simplifying the code while keeping all behavior and API identical.

Motive

The current step scheduling uses a manual pattern: _do_step() calls _schedule_step(), which creates a SimulationEvent that calls _do_step() again. This is functionally an EventGenerator — but reimplemented ad-hoc. Now that EventGenerator exists (#3201), we should use it.

This also removes _schedule_step() entirely and simplifies both _do_step() and _wrapped_step(), reducing the amount of scheduling logic in Model.

Implementation

The self-rescheduling loop (_do_step()_schedule_step()SimulationEvent_do_step()) is replaced by a single _default_schedule EventGenerator created and started in Model.__init__(). This simplifies _wrapped_step() to just self._advance_time(self.time + 1), reduces _do_step() to incrementing steps and calling the user's step method, and removes _schedule_step() entirely.

On the simulator side, Simulator.setup() now checks model._simulator is not None instead of event_list.is_empty() (since the event list has events from _default_schedule at init), ABMSimulator.setup() no longer needs to manually schedule the first step, and DEVSSimulator.setup() explicitly stops _default_schedule before clearing the event list.

Additional Notes

  • No public API changes: this is purely an internal refactor
  • All existing model.step(), run_for(), run_until() behavior is unchanged
  • _default_schedule is private (_-prefixed). Exposing it as a public API with a property setter is left for a follow-up PR
  • The Simulator.setup() guard change from checking event_list.is_empty() to model._simulator is not None is necessary because the event list now contains events from _default_schedule immediately after init

Replace manual SimulationEvent scheduling with an EventGenerator-based default schedule. The model now stores the user's step as _user_step and installs a default_schedule EventGenerator (interval 1.0, start 1.0, high priority) that calls _do_step and is started during init. Removed the old _schedule_step helper and the SimulationEvent-based scheduling; _do_step no longer reschedules itself (rescheduling is handled by the EventGenerator). Updated imports to include EventGenerator and Schedule.
Remove the ABMSimulator explicit initial step scheduling because the model's default_schedule is started in Model.__init__. In DEVSimulator.setup explicitly stop the model.default_schedule and clear any pre-scheduled events to avoid automatic step scheduling for pure DEVS models.
Turn default_schedule into a managed property: assigning a new EventGenerator stops any existing one and auto-starts the new one, and assigning None disables the schedule. Remove the immediate .start() call from the constructor so the setter handles lifecycle. Add a docstring clarifying behavior.
@EwoutH EwoutH added the feature Release notes label label Feb 8, 2026
@quaquel
Copy link
Copy Markdown
Member

quaquel commented Feb 8, 2026

Thanks for this PR. I agree with the basic premise of using EvenGenerator for this, but I am not sure about the current implementation. I'll try to take a closer look asap.

This is potential groundwork for a "heartbeat" concept: a default recurring event that Mesa components (data collection, visualization, stop conditions) can (by default) attach to. By exposing it as default_schedule, we make this pattern explicit and user-configurable without committing to a larger API yet.

With model.time being reactive, there is simply no need for a heartbeat. Anything that is triggered by time changing can just observe model.time and take if from there.

Disable stepping for pure event-driven models:

I don't really like this I prefer opt-in over opt-out.

Currently model.step() is overloaded to mean two things:

the user's model logic ("what happens each step")
time advancement ("advance the clock by 1").

I agree wtih you on this completely and decoupling the two is the way to go. Time then becomes the purview of the evenlists and the run control. And this is also why I wanted time to be reactive.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Feb 8, 2026

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 -0.2% [-1.4%, +0.8%] 🔵 -2.0% [-2.3%, -1.7%]
BoltzmannWealth large 🔵 -0.5% [-1.1%, +0.1%] 🔵 +1.3% [-0.1%, +2.7%]
Schelling small 🔵 +2.2% [+1.7%, +2.6%] 🔵 +1.3% [+1.2%, +1.5%]
Schelling large 🔵 +0.9% [+0.2%, +1.6%] 🔵 +4.8% [+2.7%, +6.8%]
WolfSheep small 🔵 +0.6% [+0.2%, +1.0%] 🔵 +0.3% [+0.1%, +0.4%]
WolfSheep large 🔵 +0.4% [-1.4%, +1.8%] 🔵 +3.3% [+1.4%, +5.1%]
BoidFlockers small 🔵 +0.4% [+0.1%, +0.7%] 🔵 +1.3% [+1.1%, +1.6%]
BoidFlockers large 🔵 -0.3% [-0.8%, +0.1%] 🔵 +0.2% [-0.2%, +0.5%]

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Feb 8, 2026

I don't really like this I prefer opt-in over opt-out.

I think we almost go to a philosophical level of "are ticks core to ABM or were they just the easy implementation and everybody stuck with it".

Anyway, we can't change that in Mesa 3.x, but it's something to discuss for Mesa 4. In that case it just will be as easy as setting default_schedule to None by default.

But discussing the merrit of a default_schedule is important.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Feb 8, 2026

But discussing the merrit of a default_schedule is important.

As indicated, I am not convinced by it, but without diving into the code I am also not sure how you could otherwise achieve the things that are clearly valuable here. Namely, replacing "the internal hand-rolled step rescheduling mechanism with an exposed EventGenerator", and more cleanly separating the step method from the time increment.

What I would like to see is an easy way for the user to specify that e.g. model.step is to be run on a given schedule with meaningful defaults (i.e., interval=1, start=0, stop=None, count=None). Where I guess I make a difference is that would prefer (even if only in MESA 4) to not make an assumption about this and leave it to the user to specify it. This was also behind the earlier idea of just adding a @scheduled to model.step.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Feb 8, 2026

If we do see value in this refactor but not in exposing it (currently) to the public, we could of course make default_schedule private (_default_schedule).

@mesa/maintainers I would like some more opinions and perspectives if we need a default scheduled method ("heart beat") or not. You don't need to dive in to the code, just conceptually if you see use/need for it. See also: #2921 (comment).

self.default_schedule = EventGenerator(
self,
self._do_step,
Schedule(interval=1.0, start=1.0),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why start at 1 instead of at zero? zero is just the state of the system prior to any events I guess (so the initial condition)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically because it’s the current convention in Mesa. 0 is setup, 1 the first step. Original discussion: #2229 (comment)

(thank our excellent migration guide to help me find this back).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recall that discussion, so yes this makes perfect sense.

I quick semi related question: do we allow Schedule(start=0)? Or should start always be larger than 0?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my view we explicitly allow that. The default is just to start after one interval, but that can be any non-negative number.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Feb 8, 2026

I reviewed the code. I don't have major issues with this. I think it is helpful to separate mesa 3 from mesa 4 going forward. We currently have a lot of baggage with wrapped_step and model.steps etc. This makes finding an elegant backward compatible solution clearly non trivial. So, I am fine with this solution in Mesa 3, but would prefer to revisit this for mesa 4.

Also, coverage is a bit low, so I assume you'll update some tests after which I am fine with approving this.

Create an internal _default_schedule EventGenerator (and store the original user step in _user_step), start it immediately, and assign the wrapped step to self.step. Remove the public default_schedule property and its getter/setter to simplify lifecycle management. Update DEVSimulator to stop model._default_schedule instead of model.default_schedule. This avoids accidental recursion/misuse and ensures the recurring step event is started and stopped reliably.
@EwoutH EwoutH changed the title Expose step scheduling as default_schedule EventGenerator Refactor step scheduling to use EventGenerator internally Feb 9, 2026
@EwoutH EwoutH added maintenance Release notes label enhancement Release notes label and removed feature Release notes label maintenance Release notes label labels Feb 9, 2026
@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Feb 9, 2026

I realized this PR was doing two things: An internal refactor and exposing a new user API surface. That doesn't make it atomic, and so, let's split the two.

I refactored this PR to only do the former. Then we can later discuss the latter.

Since we don't expose a new API or enable new behavior, no additional tests are needed.

@EwoutH EwoutH mentioned this pull request Feb 9, 2026
42 tasks
@quaquel
Copy link
Copy Markdown
Member

quaquel commented Feb 9, 2026

Since we don't expose a new API or enable new behavior, no additional tests are needed.

I don't agree with this claim. The relevant question is how the coverage has changed due to refactoring and ensuring that the refactored code is still properly covered. That is the case here judged by codecov, but it also makes me wonder how the percentage is so low. I guess because the number of lines changed is quite small.

@EwoutH EwoutH merged commit 05e2a0a into mesa:main Feb 9, 2026
12 of 14 checks passed
@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Feb 9, 2026

Fair point. I'm planning on fully revising the test suite once we have done the post branching to Mesa 4 cleanup.

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

Labels

enhancement Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants