Add unified event scheduling and time progression API#3155
Add unified event scheduling and time progression API#3155
Conversation
17bc005 to
a1a060c
Compare
This comment was marked as outdated.
This comment was marked as outdated.
a1a060c to
0146fdc
Compare
|
Performance benchmarks:
|
|
While not for this PR, in #2921 (comment) I look forward to an advanced scheduling API possible on this architecture. |
| self.event_list.add_event(event) | ||
|
|
||
|
|
||
| class RunControl: |
There was a problem hiding this comment.
more of a conceptual question, but I suggest we include in this also some additional information on e.g., time unit, and max run time.
There was a problem hiding this comment.
I think that would be useful indeed. We have to think about how that's initialized.
Would it fit in a Scenario? Makes conceptual sense I think, that a Scenario has a defined max run time (and time unit)?
I want to look at datetime support at some point, this could be part of that.
Other PR though.
There was a problem hiding this comment.
I am going back and forth on this. I see good arguments for including it in the scenario, but also good arguments for including it inside the run control. I agree its a future PR.
|
I would like to move this forward. @quaquel, anything needed from your side? Other @mesa/maintainers if you haven't taken a look at PR, please do. Probably the biggest Mesa change since the AgentSet. Edit: I rebased the PR on the latest |
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.
Introduces a @scheduled decorator to mark model methods for automatic recurring scheduling. The Scheduler now detects and schedules these methods at specified intervals and priorities, simplifying periodic event management in Mesa models.
Refactored the @scheduled decorator to support usage with and without parentheses. Updated Model initialization to check for scheduled step methods and avoid double-wrapping. Modified Scheduler to detect scheduled methods from the class definition and properly schedule bound instance methods. Enhanced recurring event scheduling and rescheduling logic in RunControl and Scheduler for more robust simulation event handling.
Introduces a new test suite for the unified time and event scheduling API, covering Scheduler, RunControl, the @scheduled decorator, Model integration, and various edge cases. These tests verify correct event scheduling, priorities, cancellation, model run methods, agent self-scheduling, and error handling.
Introduces the RecurringEvent class to handle events that automatically reschedule themselves after execution. Updates the Scheduler and RunControl logic to use RecurringEvent for scheduled methods, simplifying recurring event management and removing manual rescheduling code.
Added a PendingDeprecationWarning to discourage calling model.step() directly in a loop, guiding users to use the new @scheduled decorator and run methods introduced in Mesa 3.5. Updated the migration guide with detailed instructions and examples for adopting the unified time API.
Replaces direct import of Scheduler with a conditional import under TYPE_CHECKING to avoid runtime dependencies and improve type hinting.
ModelController and SimulatorController now detect if the model uses the new @scheduled API and call run_for(1) instead of step(). This ensures compatibility with both traditional and unified model stepping methods.
Reorganized and consolidated import statements in mesa/experimental/devs/eventlist.py for improved readability and to avoid redundant imports.
Replaces usage of ABMSimulator with direct scheduling via model methods and the @scheduled decorator. Updates agent scheduling and test code to reflect the new approach.
Simplifies the run_model function by removing conditional logic for ABMSimulator and using model.run_for for all models.
851045d to
957d048
Compare
It's a lot of code to review. Can you indicate to me which parts are, in essence, copied from/renamed by devs, and what is new? Anything that is copied over/renamed, I won't need to review as closely. |
|
I think the following things are most important:
The functional code is maybe 200 lines, the rest is docs or tests. |
|
@EwoutH This is very cool! I am sorry I haven't had time to do a thorough review, I have been overwhelmed with life. However, at a glance a couple thoughts:
|
| time: int | float, | ||
| function: Callable, | ||
| scheduler: Scheduler, | ||
| interval: int | float, |
There was a problem hiding this comment.
Can interval also be a Callable?
There was a problem hiding this comment.
In this PR I haven't implemented that, but I do propose it in #2921 (comment). So in the final API, in my opinion, yes.
| if playing.value: | ||
| for _ in range(render_interval.value): | ||
| model.value.step() | ||
| if uses_scheduled: |
There was a problem hiding this comment.
beyond this PR, but looking ahead to mesa 4. Do we want to make use_scheduled the required behavior for solara?
There was a problem hiding this comment.
Yes, because there won't be anything else.
| # Check if model uses @scheduled (new unified API) or traditional step | ||
| uses_scheduled = hasattr(model.value.__class__.step, "_scheduled") | ||
|
|
There was a problem hiding this comment.
for mesa 4, we need to find a cleaner solution for this. Basically, the coupling between models and solara is too tight
There was a problem hiding this comment.
Agreed. We remove this for Mesa 4.
| self._run_control = RunControl(self, self._scheduler) | ||
|
|
||
| # Check the class definition, not the instance | ||
| if hasattr(self.__class__.step, "_scheduled"): |
There was a problem hiding this comment.
is there not a way to identify all methods annotated with @scheduled? It can even be made part of the decorator that it registers it somewhere for easy retrieval.
There was a problem hiding this comment.
I agree that would be useful. Will look into it.
Note that this specific line is just for backwards compatibility, it will be removed with Mesa 4.
| PendingDeprecationWarning, | ||
| stacklevel=2, | ||
| ) | ||
|
|
There was a problem hiding this comment.
this answers one of my earlier comments. I agree
| ) | ||
| # Call the original user-defined step method | ||
| self._user_step(*args, **kwargs) | ||
| _mesa_logger.info( |
There was a problem hiding this comment.
if you use @method_logger on step this should not be needed to be done separately
There was a problem hiding this comment.
How would that work exactly? (haven't used it before)
There was a problem hiding this comment.
Just annotate the step method with @method_logger(__name__) but that would not include the step and time info, so perhaps less than ideal for this use case. As an aside, it might be an idea to expand the logging with time info once this PR is done.
I would suggest going to debug level for this.
| """Run the model for a specific duration.""" | ||
| self._run_control.run_for(duration) | ||
|
|
||
| def run_while(self, condition: Callable[[Model], bool]) -> None: |
There was a problem hiding this comment.
I prefer having a seperate run_while over including condition in the other run methods. It keeps the implementation of the other methods simple. And running for 500 ticks unless a condition happens before that can easily be included as part of the condition. So it's simple to do via run_while.
colinfrisch
left a comment
There was a problem hiding this comment.
I really like the idea, the api format you chose, and from what I've seen in your updates of the wolf sheep example, it simplifies the code and removes quite a bit more lines than it adds. I'm just a bit worried about migration with mesa-llm that work based on step wrappers, which might be difficult to update (maybe it's also the case for other mesa elements), but it should be manageable. I'll try to review the code more line-by line as soon as I have a bit more time.
| def _setup_scheduled_methods(self) -> None: | ||
| """Find and schedule all methods decorated with @scheduled.""" | ||
| # Iterate through the class, not the instance | ||
| for name in dir(self.model.__class__): |
There was a problem hiding this comment.
I would make it part of the behavior of the annotation that it creates this overview, instead of having to figure it out after the fact
There was a problem hiding this comment.
I see your point. There might be some problem with when this exactly happens, it might happen to late or too early in the model (class) init. Or not and I remember it wrong.
Will take a look at it.
| self.model = model | ||
| self.scheduler = scheduler | ||
|
|
||
| def run_until(self, end_time: int | float) -> None: |
There was a problem hiding this comment.
This might be simplified if we had special simulator events. So we could just schedule a stop simulation event at the end time, and you don't need the while loop or the end-time check.
A stop simulation event would be the lowest-priority event, so all other events at the end time would still be processed.
There was a problem hiding this comment.
Good idea. I think something like that would be useful, see also #3155 (comment).
There was a problem hiding this comment.
yes, I saw your comment there and immediately thought about this remark.
We could even play with priority then. For run_until etc. you use the lowest, while for condition you might want to use highest. The main thing is whether you want to stop in the middle of a step method of a classic abm or not.
There was a problem hiding this comment.
The fact that we now even can discus if we want to stop in the middle of a step (and have that level of control), is quite awesome in itself. We’ve come a long way since the days of RandomActivation
No worries, I know how it is, already appreciated!
Fully agree. I'm committed to doing so, once we get through this stage of design and implementation.
I see two options here:
I think it would make sense to have one central place to stop a model from running, that also might eliminate a lot of API surface. So I'm nudging towards some implementation of 1, but then there should be full support of it. Supporting it in some place and not in others is in my opinion the worst of two worlds.
Thanks, appreciated!
I can understand. This will require some rewiring on other parts of Mesa to. Two thoughts:
|
|
Thanks for working on this. I think it is awsome. Based on the discussion (#2921 (comment)) I thought there is a core Similarly does it make sense to have a core This is to allow users to combine model.schedule_at(self.condition_1)
model.schedule_after(self.condition_2)into one single Similarly it would be possible to write In addition, what's the difference between |
|
Thanks for your insights, some very useful stuff in there!
This is indeed nog part of this PR. There is a bit of historical bias here in this PR, it was focussed on adding the framework for the new capabilities while keeping both compatibility with the current stepping as with the simulator. However, my vision for the final API is to have one powerful and flexible
Jan and I have this discussion many times, sometime extensively, and also in #2921. Jan prefers more specialized functions, I prefer more general ones. For both are good arguments. I think we have to weigh this each time.
Conceptually, quite clean (with minor remarks), but implementation wise, very hard. How do you know when So, our current consensus is that if you want to do that kind of conditional stuff, just add a schedule() call where ever you check/determine that condition.
model.run_for(10) # Runs for exactly 10 time units
model.run_until(10) # Runs until t=10So if you call this at t=5, the first runs to t=15 and the second to t=10. |
|
This is a really nice step forward. From a user / example-writer perspective, the biggest value I see is that I’m planning to build a small Mesa example that stresses: I’ll report back if I run into any rough edges or migration pain points. |
|
For all reviewers: Don’t focus too much on the code right now, based on accumulated insights from the discussion in here and in #2921 (comment), I’m going to do a bit cleaner version based around a central API review and discussion is of course welcome. Think from the perspective of the user. |
|
Thanks for the clarification, that makes sense. From a user / example-writer perspective, a single expressive I’ll focus on building a small example around: and try to express all of that using the proposed |
|
Thanks of all the input. Based on it, I continued development, leading to #3188 |
|
Thanks for all the insights. This was useful pathfinding (as was #3188). To be continued in: |
Summary
This PR implements a unified time and event scheduling API for Mesa, making discrete event simulation a first-class feature alongside traditional time-stepping. The new API provides a clean, intuitive interface that supports everything from simple step-based models to advanced discrete event simulations—all within the same framework.
After problem and high-level design agreement in #2921, and many pathfinding PRs (#2928, #2932 and #3152), this one is ready for production.
Motivation
Currently, Mesa supports two distinct approaches to time advancement. The traditional approach has users call
model.step()repeatedly in a for-loop. For discrete event simulation, users must work with experimentalSimulatorclasses (ABMSimulator,DEVSimulator), managing two separate objects (model and simulator). This creates a fundamental disconnect: discrete event simulation feels bolted-on rather than integrated into Mesa's core.This separation creates problems. Users must understand the distinction between simulator types before they can use event scheduling. Combining regular stepping with event scheduling is awkward because they live in different parts of the API. Most importantly, the current design doesn't guide users through a natural progression from simple stepping to advanced discrete event patterns.
This PR unifies both approaches under a single, clean API that lives directly on the
Modelclass. Time advancement and event scheduling become complementary features of the same system rather than separate mechanisms. Users can now start with the simplest pattern and progressively adopt more advanced features without rewriting their models.Implementation
New Module:
mesa/timeflow.pyThe new module introduces two core classes and a decorator:
Scheduler: Handles event scheduling withschedule_at(),schedule_after(), andcancel()methods. Automatically discovers and schedules@scheduledmethods.RunControl: Handles time advancement withrun_until(),run_for(),run_while(), andrun_next_event()methods.@scheduleddecorator: Marks methods for automatic recurring execution with custom intervals and priorities.Model Integration
The
Modelclass instantiates_schedulerand_run_controlin__init__and exposes their methods directly. It detects@scheduledmethods and skips wrapping them with the traditional_wrapped_stepmechanism. Whenstep()is called in the old for-loop pattern, it issues aPendingDeprecationWarningexplaining the new approach.Experimental DEVS Updates
The
eventlist.pygains aRecurringEventclass that automatically reschedules itself after execution, solving the weak reference issues that prevented lambda functions from working.Visualization Updates
SolaraViz now detects whether models use
@scheduledand calls the appropriate run method (run_for(1)for new-style models vsstep()for old-style models), ensuring visualizations work with both patterns.Note: I did the bare minimum to get this working, More validation is needed.
Usage Examples
You can mix and match everything
Notes
Model) come from backwards support for both the wrapped step and the experimental simulators. Both can be removed in Mesa 4.0, hugely simplifying it.Review questions
run_while()and the role ofmodel.running. Options:conditionkeyword argument torun_until()andrun_for()model.runningif setFalse.References: #2921