Skip to content

Commit 1128efe

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 1128efe

File tree

3 files changed

+62
-35
lines changed

3 files changed

+62
-35
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 & 31 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,26 @@ 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():
8696
raise ValueError(
87-
"trying to setup model, but events have already been scheduled. Call simulator.setup before any scheduling"
97+
"Events already scheduled. Call setup before scheduling."
8898
)
8999

90100
self.model = model
101+
model._simulator = self # Register simulator with model
91102

92103
def reset(self):
93-
"""Reset the simulator by clearing the event list and removing the model to simulate."""
104+
"""Reset the simulator."""
94105
self.event_list.clear()
95-
self.model = None
96-
self.time = self.start_time
106+
if self.model is not None:
107+
self.model._simulator = None
108+
self.model.time = self.start_time
109+
self.model = None
97110

98111
def run_until(self, end_time: int | float) -> None:
99112
"""Run the simulator until the end time.
@@ -106,23 +119,23 @@ def run_until(self, end_time: int | float) -> None:
106119
107120
"""
108121
if self.model is None:
109-
raise Exception(
110-
"simulator has not been setup, call simulator.setup(model) first"
122+
raise RuntimeError(
123+
"Simulator not set up. Call simulator.setup(model) first."
111124
)
112125

113126
while True:
114127
try:
115128
event = self.event_list.pop_event()
116-
except IndexError: # event list is empty
117-
self.time = end_time
129+
except IndexError:
130+
self.model.time = end_time
118131
break
119132

120133
if event.time <= end_time:
121-
self.time = event.time
134+
self.model.time = event.time
122135
event.execute()
123136
else:
124-
self.time = end_time
125-
self._schedule_event(event) # reschedule event
137+
self.model.time = end_time
138+
self._schedule_event(event)
126139
break
127140

128141
def run_next_event(self):
@@ -133,17 +146,17 @@ def run_next_event(self):
133146
134147
"""
135148
if self.model is None:
136-
raise Exception(
137-
"simulator has not been setup, call simulator.setup(model) first"
149+
raise RuntimeError(
150+
"Simulator not set up. Call simulator.setup(model) first."
138151
)
139152

140153
try:
141154
event = self.event_list.pop_event()
142-
except IndexError: # event list is empty
155+
except IndexError:
143156
return
144-
else:
145-
self.time = event.time
146-
event.execute()
157+
158+
self.model.time = event.time
159+
event.execute()
147160

148161
def run_for(self, time_delta: int | float):
149162
"""Run the simulator for the specified time delta.
@@ -154,7 +167,7 @@ def run_for(self, time_delta: int | float):
154167
155168
"""
156169
# fixme, raise initialization error or something like it if model.setup has not been called
157-
end_time = self.time + time_delta
170+
end_time = self.model.time + time_delta
158171
self.run_until(end_time)
159172

160173
def schedule_event_now(
@@ -240,7 +253,7 @@ def schedule_event_relative(
240253
241254
"""
242255
event = SimulationEvent(
243-
self.time + time_delta,
256+
self.model.time + time_delta,
244257
function,
245258
priority=priority,
246259
function_args=function_args,
@@ -344,29 +357,29 @@ def run_until(self, end_time: int) -> None:
344357
345358
"""
346359
if self.model is None:
347-
raise Exception(
348-
"simulator has not been setup, call simulator.setup(model) first"
360+
raise RuntimeError(
361+
"Simulator not set up. Call simulator.setup(model) first."
349362
)
350363

351364
while True:
352365
try:
353366
event = self.event_list.pop_event()
354367
except IndexError:
355-
self.time = end_time
368+
self.model.time = float(end_time)
356369
break
357370

358-
# fixme: the alternative would be to wrap model.step with an annotation which
359-
# handles this scheduling.
360371
if event.time <= end_time:
361-
self.time = event.time
372+
self.model.time = float(event.time)
373+
374+
# Reschedule model.step for next tick if this is a step event
362375
if event.fn() == self.model.step:
363376
self.schedule_event_next_tick(
364377
self.model.step, priority=Priority.HIGH
365378
)
366379

367380
event.execute()
368381
else:
369-
self.time = end_time
382+
self.model.time = float(end_time)
370383
self._schedule_event(event)
371384
break
372385

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)