Skip to content

Conversation

@EwoutH
Copy link
Member

@EwoutH EwoutH commented Nov 30, 2025

Summary

Introduces model.time as the single, canonical source of simulation time in Mesa. All components (DataCollector, visualization, user code, simulators) can now rely on model.time to get the current simulation time, regardless of whether the model uses simple stepping or discrete event simulation.

Motive

Previously, time in Mesa was fragmented across different locations depending on the simulation mode:

  • Simple models used model.steps as a proxy for time
  • Discrete event simulations stored time in simulator.time
  • Components needing time had to implement fallback chains like:
    def _get_time(self):
        if hasattr(model, "simulator") and hasattr(model.simulator, "time"):
            return model.simulator.time
        if hasattr(model, "time"):
            return model.time
        return float(model.steps)

This caused problems for features that need consistent time access, such as the ContinuousObservable in #2851 and the conceptual model of space discussed in #2585. The discussion in #2228 (which originated in #2223) established consensus that Mesa needs a universal truth for time.

Implementation

Model class changes:

  • Add time: float attribute initialized to 0.0
  • Add step_duration: float parameter (default 1.0) controlling time advancement per step
  • Add _simulator: Simulator | None to track if a simulator controls time
  • Modify _wrapped_step() to only auto-increment time when no simulator is attached

Simulator class changes:

  • Remove internal self.time attribute
  • Write directly to model.time during event execution
  • Add deprecated time property delegating to model.time for backward compatibility
  • Register with model via model._simulator = self in setup()
  • Reset model.time to start_time in reset()

Design kept minimal: a simple attribute rather than a property or RunControl class, following YAGNI principles while solving the immediate problem.

Usage Examples

Basic usage (time auto-increments with steps):

model = Model()
model.step()
print(model.time)  # 1.0
model.step()
print(model.time)  # 2.0

Custom step duration (useful for staged activation):

model = Model(step_duration=0.25)
model.step()
print(model.time)  # 0.25
model.step()
print(model.time)  # 0.5

With discrete event simulator (simulator controls time):

model = Model()
simulator = DEVSimulator()
simulator.setup(model)

simulator.schedule_event_absolute(some_function, 2.5)
simulator.run_until(3.0)
print(model.time)  # 3.0

Components can now simply use model.time:

class MyDataCollector:
    def collect(self, model):
        timestamp = model.time  # Always works, no fallbacks needed

Compatibility

  • simulator.time still works but emits a DeprecationWarning directing users to model.time (considering DEVS is still experimental, this is quite gentle)
  • Code directly accessing simulator.time without triggering the property (unlikely) would break (again, experimental, and all test pass)

Additional Notes

@EwoutH EwoutH requested a review from quaquel November 30, 2025 18:53
@EwoutH EwoutH added the feature Release notes label label Nov 30, 2025
This commit introduces `model.time` as the single, canonical source for simulation time in Mesa, addressing the long-standing issue of having multiple competing time sources (model.steps, simulator.time) that caused confusion and required workarounds in components like DataCollector, visualization, and the experimental ContinuousObservable.

Previously, time information was scattered: basic models only had `model.steps`, while models using simulators had to access `simulator.time`. This forced components to implement fallback chains like checking for simulator.time, then model.time, then model.steps. The new design establishes `model.time` as the ground truth that all components can rely on.

Key changes to mesa/model.py:
- Add `time: float = 0.0` attribute that tracks simulation time
- Add `step_duration: float = 1.0` parameter allowing customization of how much time passes per step (useful for staged activation where you might want 0.25 per stage)
- Add `_simulator: Simulator | None` to track whether a simulator controls time progression
- Modify `_wrapped_step()` to only auto-increment time when no simulator is attached

Key changes to mesa/experimental/devs/simulator.py:
- Remove `self.time` from Simulator, now writes directly to `model.time`
- Add deprecated `time` property that delegates to `model.time` with a DeprecationWarning for backward compatibility
- Register simulator with model via `model._simulator = self` in setup()
- Update all time reads/writes to use `model.time` instead of `self.time`
- Improve error messages to be more descriptive

The design intentionally keeps things minimal: no RunControl abstraction, no complex time management classes. Simulators simply write to `model.time` directly, and the default step wrapper increments time by `step_duration` when no simulator is attached. This provides a single way to do things while remaining backward compatible.
Replace MagicMock(spec=Model) with real Model instances since mocks lack the new time attribute. Update assertions to use model.time instead of simulator.time. Add tests for time increment, step_duration, simulator attachment, and deprecation warning. Fix AgentSet constructor calls to use random= keyword argument.
@github-actions
Copy link

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 +2.9% [+1.8%, +4.1%] 🔵 -0.1% [-0.3%, +0.0%]
BoltzmannWealth large 🔵 -0.9% [-1.5%, -0.3%] 🔵 +0.1% [-1.1%, +1.6%]
Schelling small 🔵 -0.5% [-0.7%, -0.3%] 🔵 -1.1% [-1.3%, -0.9%]
Schelling large 🔵 +0.3% [-0.2%, +0.9%] 🔵 +1.2% [+0.3%, +2.3%]
WolfSheep small 🔵 +1.2% [+0.8%, +1.5%] 🔵 +0.5% [+0.3%, +0.6%]
WolfSheep large 🔵 +0.4% [-0.2%, +1.1%] 🔵 +2.5% [+1.3%, +4.2%]
BoidFlockers small 🔵 -1.4% [-2.0%, -0.9%] 🔵 -0.6% [-0.8%, -0.4%]
BoidFlockers large 🔵 -1.8% [-2.2%, -1.3%] 🔵 -0.5% [-0.8%, -0.3%]

@quaquel
Copy link
Member

quaquel commented Dec 1, 2025

  1. I suggest making step_duration protected: _step_duration. It is good that users can control it, but we reserve the right to modify how the controlling step duration works in the future without breaking Semantic Versioning. I still would like to think through a more detailed way for users to set up time, including support for date time and calendars. This will interact with step_duration and so I don't want to be locked into this yet. model.time is generic enough that I am fine with locking this in as part of the public api.
  2. I am not sure about the API for the simulators. You now do
model = Model()
simulator = DEVSimulator()
simulator.setup(model)

However, in the Wolfsheep example, I have moved simulator.setup into the __init__ method of the Model class. I am inclined to make this the preferred api by adding simulator as a keyword argument to Model. and make model.simulator part of the public API of the model class if simulator is not None.

As an aside: what are people's thoughts on stabilizing the simulator stuff?

@EwoutH
Copy link
Member Author

EwoutH commented Dec 1, 2025

Thanks for getting back.

  1. I suggest making step_duration protected: _step_duration.

Maybe we need to think this a bit further through before going one way or the other. I also want to add datetime support for example.

Do what do you think about a separate RunControl class vs integrating it in the model? In this effort, I started with it, but realized it wasn't needed and mainly added API complexity.

I am not sure about the API for the simulators.

I just copied (previous) best-practices. It can be that's now outdated. Should I update it?

As an aside: what are people's thoughts on stabilizing the simulator stuff?

Perfectly ok with it. Used it intensively without problems during my thesis.

@quaquel
Copy link
Member

quaquel commented Dec 1, 2025

  1. I would suggest using composition for RunControl. So, a dedicated class that can be passed to Model.__init__ or something along those lines.
  2. I suggest keeping this PR focused on just making model.time part of the public API and minimizing all other changes. Having model.time as the sole truth is valuable as such. Let's worry about expanding it in separate PR's.
  3. The simulator stuff is fine as is for now. I'll make a separate PR asap as part of a move towards stabilizing it.

Make Model._step_duration private allows us to update it's working in the future without breaking compatibility. For example if changes are needed for variable durations or datetime support.
@EwoutH
Copy link
Member Author

EwoutH commented Dec 1, 2025

  1. I suggest making step_duration protected: _step_duration.

Done.

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