Skip to content

Commit 64f2216

Browse files
committed
Add model.time as universal source of truth for simulation time
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.
1 parent 39003c8 commit 64f2216

File tree

3 files changed

+62
-37
lines changed

3 files changed

+62
-37
lines changed

mesa/experimental/devs/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@
1919
"""
2020

2121
from .eventlist import Priority, SimulationEvent
22-
from .simulator import ABMSimulator, DEVSimulator
22+
from .simulator import ABMSimulator, DEVSimulator, Simulator
2323

24-
__all__ = ["ABMSimulator", "DEVSimulator", "Priority", "SimulationEvent"]
24+
__all__ = ["ABMSimulator", "DEVSimulator", "Priority", "SimulationEvent", "Simulator"]

mesa/experimental/devs/simulator.py

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from __future__ import annotations
2424

2525
import numbers
26+
import warnings
2627
from collections.abc import Callable
2728
from typing import TYPE_CHECKING, Any
2829

@@ -61,9 +62,17 @@ def __init__(self, time_unit: type, start_time: int | float):
6162
self.event_list = EventList()
6263
self.start_time = start_time
6364
self.time_unit = time_unit
64-
65-
self.time = self.start_time
66-
self.model = None
65+
self.model: Model | None = None
66+
67+
@property
68+
def time(self) -> float:
69+
"""Simulator time (deprecated)."""
70+
warnings.warn(
71+
"simulator.time is deprecated, use model.time instead",
72+
DeprecationWarning,
73+
stacklevel=2,
74+
)
75+
return self.model.time
6776

6877
def check_time_unit(self, time: int | float) -> bool: ... # noqa: D102
6978

@@ -78,22 +87,24 @@ def setup(self, model: Model) -> None:
7887
Exception if event list is not empty
7988
8089
"""
81-
if self.time != self.start_time:
90+
if model.time != self.start_time:
8291
raise ValueError(
83-
"trying to setup model, but current time is not equal to start_time, Has the simulator been reset or freshly initialized?"
92+
f"Model time ({model.time}) does not match simulator start_time ({self.start_time}). "
93+
"Has the model already been run?"
8494
)
8595
if not self.event_list.is_empty():
86-
raise ValueError(
87-
"trying to setup model, but events have already been scheduled. Call simulator.setup before any scheduling"
88-
)
96+
raise ValueError("Events already scheduled. Call setup before scheduling.")
8997

9098
self.model = model
99+
model._simulator = self # Register simulator with model
91100

92101
def reset(self):
93-
"""Reset the simulator by clearing the event list and removing the model to simulate."""
102+
"""Reset the simulator."""
94103
self.event_list.clear()
95-
self.model = None
96-
self.time = self.start_time
104+
if self.model is not None:
105+
self.model._simulator = None
106+
self.model.time = self.start_time
107+
self.model = None
97108

98109
def run_until(self, end_time: int | float) -> None:
99110
"""Run the simulator until the end time.
@@ -106,23 +117,23 @@ def run_until(self, end_time: int | float) -> None:
106117
107118
"""
108119
if self.model is None:
109-
raise Exception(
110-
"simulator has not been setup, call simulator.setup(model) first"
120+
raise RuntimeError(
121+
"Simulator not set up. Call simulator.setup(model) first."
111122
)
112123

113124
while True:
114125
try:
115126
event = self.event_list.pop_event()
116-
except IndexError: # event list is empty
117-
self.time = end_time
127+
except IndexError:
128+
self.model.time = end_time
118129
break
119130

120131
if event.time <= end_time:
121-
self.time = event.time
132+
self.model.time = event.time
122133
event.execute()
123134
else:
124-
self.time = end_time
125-
self._schedule_event(event) # reschedule event
135+
self.model.time = end_time
136+
self._schedule_event(event)
126137
break
127138

128139
def run_next_event(self):
@@ -133,17 +144,17 @@ def run_next_event(self):
133144
134145
"""
135146
if self.model is None:
136-
raise Exception(
137-
"simulator has not been setup, call simulator.setup(model) first"
147+
raise RuntimeError(
148+
"Simulator not set up. Call simulator.setup(model) first."
138149
)
139150

140151
try:
141152
event = self.event_list.pop_event()
142-
except IndexError: # event list is empty
153+
except IndexError:
143154
return
144-
else:
145-
self.time = event.time
146-
event.execute()
155+
156+
self.model.time = event.time
157+
event.execute()
147158

148159
def run_for(self, time_delta: int | float):
149160
"""Run the simulator for the specified time delta.
@@ -154,7 +165,7 @@ def run_for(self, time_delta: int | float):
154165
155166
"""
156167
# fixme, raise initialization error or something like it if model.setup has not been called
157-
end_time = self.time + time_delta
168+
end_time = self.model.time + time_delta
158169
self.run_until(end_time)
159170

160171
def schedule_event_now(
@@ -240,7 +251,7 @@ def schedule_event_relative(
240251
241252
"""
242253
event = SimulationEvent(
243-
self.time + time_delta,
254+
self.model.time + time_delta,
244255
function,
245256
priority=priority,
246257
function_args=function_args,
@@ -344,29 +355,29 @@ def run_until(self, end_time: int) -> None:
344355
345356
"""
346357
if self.model is None:
347-
raise Exception(
348-
"simulator has not been setup, call simulator.setup(model) first"
358+
raise RuntimeError(
359+
"Simulator not set up. Call simulator.setup(model) first."
349360
)
350361

351362
while True:
352363
try:
353364
event = self.event_list.pop_event()
354365
except IndexError:
355-
self.time = end_time
366+
self.model.time = float(end_time)
356367
break
357368

358-
# fixme: the alternative would be to wrap model.step with an annotation which
359-
# handles this scheduling.
360369
if event.time <= end_time:
361-
self.time = event.time
370+
self.model.time = float(event.time)
371+
372+
# Reschedule model.step for next tick if this is a step event
362373
if event.fn() == self.model.step:
363374
self.schedule_event_next_tick(
364375
self.model.step, priority=Priority.HIGH
365376
)
366377

367378
event.execute()
368379
else:
369-
self.time = end_time
380+
self.model.time = float(end_time)
370381
self._schedule_event(event)
371382
break
372383

mesa/model.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import numpy as np
1818

1919
from mesa.agent import Agent, AgentSet
20+
from mesa.experimental.devs import Simulator
2021
from mesa.mesa_logging import create_module_logger, method_logger
2122

2223
SeedLike = int | np.integer | Sequence[int] | np.random.SeedSequence
@@ -52,6 +53,7 @@ def __init__(
5253
*args: Any,
5354
seed: float | None = None,
5455
rng: RNGLike | SeedLike | None = None,
56+
step_duration: float = 1.0,
5557
**kwargs: Any,
5658
) -> None:
5759
"""Create a new model.
@@ -65,15 +67,21 @@ def __init__(
6567
rng : Pseudorandom number generator state. When `rng` is None, a new `numpy.random.Generator` is created
6668
using entropy from the operating system. Types other than `numpy.random.Generator` are passed to
6769
`numpy.random.default_rng` to instantiate a `Generator`.
70+
step_duration: How much time advances each step (default 1.0)
6871
kwargs: keyword arguments to pass onto super
6972
7073
Notes:
7174
you have to pass either seed or rng, but not both.
7275
7376
"""
7477
super().__init__(*args, **kwargs)
75-
self.running = True
78+
self.running: bool = True
7679
self.steps: int = 0
80+
self.time: float = 0.0
81+
self.step_duration: float = step_duration
82+
83+
# Track if a simulator is controlling time
84+
self._simulator: Simulator | None = None
7785

7886
if (seed is not None) and (rng is not None):
7987
raise ValueError("you have to pass either rng or seed, not both")
@@ -117,7 +125,13 @@ def _wrapped_step(self, *args: Any, **kwargs: Any) -> None:
117125
"""Automatically increments time and steps after calling the user's step method."""
118126
# Automatically increment time and step counters
119127
self.steps += 1
120-
_mesa_logger.info(f"calling model.step for timestep {self.steps} ")
128+
# Only auto-increment time if no simulator is controlling it
129+
if self._simulator is None:
130+
self.time += self.step_duration
131+
132+
_mesa_logger.info(
133+
f"calling model.step for step {self.steps} at time {self.time}"
134+
)
121135
# Call the original user-defined step method
122136
self._user_step(*args, **kwargs)
123137

0 commit comments

Comments
 (0)