Skip to content

Add a signal at start of run#3284

Merged
quaquel merged 6 commits intomesa:mainfrom
quaquel:run_started
Feb 12, 2026
Merged

Add a signal at start of run#3284
quaquel merged 6 commits intomesa:mainfrom
quaquel:run_started

Conversation

@quaquel
Copy link
Copy Markdown
Member

@quaquel quaquel commented Feb 11, 2026

This PR ensures that time is set to 0.0 on the first call to advance_time. This means that anything that subscribes to changes of time can handle the t=0 situation after the model has been fully initialized.

This is one way of handling the problem. It is, in my view, for now, the simplest way to make the new data collection work. More sophisticated approaches require either additional SignalTypes (e.g., RunSignals.RUN_STARTEDk, RunSignals.RUN_ENDED), but these in turn require a RunConfiguration object. Feedback on this approach is welcome.

The way it works is that I set self._time to 0.0 in the init. This ensures that any get of time will return 0.0 (required in many tests). Then, in _advance_time we check this attribute and explicitly set self.time to 0.0. This, in turn, triggers a signal that time is changed to 0.0. And this signal can be used to trigger, e.g., data recording.

If we agree, I'll add additional tests.

@quaquel quaquel changed the title Update model.py Add a signal at start of run Feb 11, 2026
@github-actions
Copy link
Copy Markdown

Performance benchmarks:

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

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 11, 2026

One thing that I’ve been encountering while actually building stuff with both this and events, is that control over the order becomes more difficult. You need to keep carefully keep track of priorities with events. For signals, how can you currently determine the order of execution if multiple things subscribe to the same signal?

The previous data collection stuff we just setup at the end of the init. I assume here is the problem that there’s user init code executed after that? Can/need we to wrap the user init? Or add a post init?

edit: I thought we discussed something like this at some point, just found it:

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 12, 2026

One thing that I’ve been encountering while actually building stuff with both this and events, is that control over the order becomes more difficult. You need to keep carefully keep track of priorities with events. For signals, how can you currently determine the order of execution if multiple things subscribe to the same signal?

Both events and signals are first-in in first-out. Of course, with events, you have priority to further control this. For signals, you don't have priorities.

The previous data collection stuff we just setup at the end of the init. I assume here is the problem that there’s user init code executed after that? Can/need we to wrap the user init? Or add a post init?

The problem is that with the old collection, the user explicitly has to call collect. With the new stuff, we aim for a fully reactive design. So, in #3145, after initializing the DataRecorder, it wants to collect immediately if the first collect is on t=0. But of course, this forces the user to initialize the recorder at the end of the __init__. Which is not a nice design.

There is no __post_init__ for normal Python classes. Yes, you can do various things using __init_subclass__, but in my view, this solution is simpler. It also is a bit of a stopgap solution. For Mesa 4, we really should add a RunConfiguration class that at a minimum specifies start_time and end_time, but which might be extended to include datetime and calendar information.

@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 -1.2% [-1.8%, -0.7%] 🔵 -0.5% [-0.7%, -0.4%]
BoltzmannWealth large 🔵 +1.2% [+0.4%, +1.9%] 🔵 +0.0% [-1.5%, +1.7%]
Schelling small 🔵 +1.4% [+1.2%, +1.7%] 🔵 +0.8% [+0.7%, +1.0%]
Schelling large 🔵 +0.6% [+0.0%, +1.2%] 🔵 +1.1% [+0.1%, +2.2%]
WolfSheep small 🔵 -0.6% [-0.9%, -0.4%] 🔵 -0.5% [-0.8%, -0.3%]
WolfSheep large 🔵 +1.2% [+0.1%, +2.3%] 🔵 -0.8% [-1.4%, -0.2%]
BoidFlockers small 🔵 -2.5% [-2.8%, -2.2%] 🔵 -2.3% [-2.5%, -2.1%]
BoidFlockers large 🔵 -2.7% [-3.0%, -2.4%] 🔵 -1.8% [-2.0%, -1.6%]

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 12, 2026

So what if somebody wants to check self.time before the first run is called?

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 12, 2026

So what if somebody wants to check self.time before the first run is called?

It just returns 0.0, because in the __init__ I set the private variable of the observable to 0.0.

Just to clarify. An Observable is a descriptor. A common pattern in descriptors is to have the __get__ tied to a name (e.g., time) and use an internal private variable (typically _{public_name}, so _time) for storage. I exploit that here by setting the private variable explicitly in model.__init__. Any __get__ on model.time afterwards will just return the value of the private variable (so. 0.0). Once we start running, we explicitly do model.time = 0.0, which emits an ObservableSignals.CHANGED signal and sets the private variable (again) to 0.0.

This PR is a somewhat convoluted way to emit a signal indicating that the run has started without having to add new signals. For mesa 4, I want to add RunControlSignals like RUN_STARTED, RUN_STOPPED, and RUN_ENDED. But this requires the TIME api to be further fleshed out on the condition side, likely a RunConfiguration class, and perhaps some other things. For 3.5, and the further development of the new data collection, this solution is good enough.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 12, 2026

Thanks for explaining. Are our observables build in such a way that they always (and automatically) have a private variable with the exact same name but with an underscore? If not, where does that assignment happen?

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 12, 2026

Are our observables build in such a way that they always (and automatically) have a private variable with the exact same name but with an underscore? I

yes.

Copy link
Copy Markdown
Member

@EwoutH EwoutH left a comment

Choose a reason for hiding this comment

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

Looks good!

@quaquel quaquel merged commit 86e71ed into mesa:main Feb 12, 2026
13 checks passed
@quaquel quaquel deleted the run_started branch February 12, 2026 17:22
souro26 pushed a commit to souro26/mesa that referenced this pull request Feb 12, 2026
codebreaker32 added a commit to codebreaker32/mesa that referenced this pull request Feb 13, 2026
quaquel pushed a commit that referenced this pull request Feb 13, 2026
@EwoutH EwoutH added experimental Release notes label enhancement Release notes label labels Feb 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Release notes label experimental Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants