Source code for modelly.time.events.generator
"""Recurring events: a Schedule describes when, an EventGenerator makes it happen."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from types import MethodType
from typing import TYPE_CHECKING, Any
from weakref import WeakMethod, ref
from modelly.time.events.event import Event, Priority, _create_callable_reference
if TYPE_CHECKING:
from modelly.core.model import Model
[docs]
@dataclass(frozen=True, slots=True)
class Schedule:
"""Defines when something should happen repeatedly.
Attributes:
interval: Time between executions (fixed value or callable returning value)
start: Absolute time to begin (None = use current model time + interval)
end: Absolute time to stop (None = no end)
count: Maximum executions (None = unlimited)
"""
interval: float | int | Callable[[Model], float | int] = 1.0
start: float | None = None
end: float | None = None
count: int | None = None
def __post_init__(self):
"""Validate schedule parameters."""
if not callable(self.interval) and self.interval <= 0:
raise ValueError(f"Schedule interval must be > 0, got {self.interval}")
if self.count is not None and self.count <= 0:
raise ValueError(
f"Schedule count must be > 0 if provided, got {self.count}"
)
if self.start is not None and self.end is not None and self.start > self.end:
raise ValueError(
f"Schedule start ({self.start}) cannot be after end ({self.end})"
)
[docs]
class EventGenerator:
"""A generator that creates recurring events based on a Schedule.
Unlike a single Event, an EventGenerator is persistent and can be
stopped or configured with stop conditions.
Attributes:
model: The model this generator belongs to
function: The callable to execute for each generated event
schedule: The Schedule defining when events occur
priority: Priority level for generated events
Notes:
Event generators use a weak reference to the callable. Therefore, you cannot pass a lambda function in fn.
A simulation event where the callable no longer exists (e.g., because the agent has been removed from the model)
will fail silently. If you want to use functools.partial, please assign the partial function to a variable
prior to creating the generator.
"""
def __init__(
self,
model: Model,
function: Callable[..., None],
schedule: Schedule,
priority: Priority = Priority.DEFAULT,
) -> None:
"""Initialize an EventGenerator.
Args:
model: The model this generator belongs to
function: The callable to execute for each generated event.
Use functools.partial to bind arguments.
schedule: The Schedule defining timing
priority: Priority level for generated events
"""
self.model = model
self.function = _create_callable_reference(function)
self.schedule = schedule
self.priority = priority
self._active: bool = False
self._paused: bool = False
self._current_event: Event | None = None
self._execution_count: int = 0
@property
def is_active(self) -> bool:
"""Return whether the generator is currently active."""
return self._active
@property
def execution_count(self) -> int:
"""Return the number of times this generator has executed."""
return self._execution_count
@property
def next_scheduled_time(self) -> float | None:
"""Return the time of the next scheduled execution, or None if not scheduled."""
if self._current_event is None:
return None
return self._current_event.time
def _get_interval(self) -> float | int:
"""Get the next interval value."""
if callable(self.schedule.interval):
interval = self.schedule.interval(self.model)
if interval <= 0:
raise ValueError(f"Interval must be > 0, got {interval}")
return interval
return self.schedule.interval
def _should_stop(self, next_time: float) -> bool:
"""Check if the generator should stop before scheduling the next event."""
return (
self.schedule.count is not None
and self._execution_count >= self.schedule.count
) or (self.schedule.end is not None and next_time > self.schedule.end)
def _execute_and_reschedule(self) -> None:
"""Execute the function and schedule the next event."""
if not self._active or self._paused:
return
# Check the weakref at execution time, as Event does; a dead callback
# stops the generator silently (no error raised).
fn = self.function()
if fn is None:
self.stop()
return
firing_event = self._current_event
fn()
self._execution_count += 1
# The callback may have stopped, paused, restarted, or resumed this
# generator. Both start() and resume() schedule a new event and replace
# _current_event, so in that case do not schedule a second one.
if not self._active or self._paused or self._current_event is not firing_event:
return
next_time = self.model.time + self._get_interval()
if not self._should_stop(next_time):
self._schedule_next(next_time)
else:
self._finish()
def _finish(self) -> None:
"""Deactivate after the last scheduled execution and leave the model."""
self._active = False
self._current_event = None
self.model._event_generators.discard(self)
def _schedule_next(self, time: float) -> None:
"""Schedule the next event at the given time."""
self._current_event = Event(
time,
self._execute_and_reschedule,
priority=self.priority,
)
self.model._event_list.add_event(self._current_event)
[docs]
def start(self) -> EventGenerator:
"""Start the event generator.
Returns:
Self for method chaining
Raises:
ValueError: If schedule.start is in the past, e.g. when restarting
a stopped generator after its start time has passed.
"""
if self._active:
return self
if self.schedule.start is not None and self.schedule.start < self.model.time:
raise ValueError(
f"Cannot start recurring schedule in the past. "
f"Start time is {self.schedule.start}, current time is {self.model.time}"
)
if self.schedule.start is not None:
start_time = self.schedule.start
else:
# Default: start at next interval from now
start_time = self.model.time + self._get_interval()
self._active = True
self.model._event_generators.add(self)
self._schedule_next(start_time)
return self
[docs]
def stop(self) -> None:
"""Stop the event generator immediately."""
self._active = False
self._paused = False
if self._current_event is not None:
self._current_event.cancel()
self._current_event = None
self.model._event_generators.discard(self)
[docs]
def pause(self) -> None:
"""Pause the event generator temporarily.
This cancels the currently scheduled event but keeps the generator
active in the model. Execution can be resumed later using resume().
"""
if not self._active or self._paused:
return
self._paused = True
if self._current_event is not None:
self._current_event.cancel()
self._current_event = None
[docs]
def resume(self) -> None:
"""Resume a paused event generator."""
if not self._active or not self._paused:
return
self._paused = False
next_time = self.model.time + self._get_interval()
if not self._should_stop(next_time):
self._schedule_next(next_time)
else:
self._active = False
self.model._event_generators.discard(self)
def __getstate__(self) -> dict[str, Any]:
"""Prepare state for pickling."""
state = self.__dict__.copy()
fn = self.function() if self.function is not None else None
state["_fn_strong"] = fn
state["function"] = None
return state
def __setstate__(self, state: dict[str, Any]) -> None:
"""Restore state after unpickling."""
# Keep strong reference alive during entire method
fn = state.pop("_fn_strong")
# Update state first (keeps references alive)
self.__dict__.update(state)
# Now recreate weak reference
if fn is not None:
if isinstance(fn, MethodType):
self.function = WeakMethod(fn)
else:
self.function = ref(fn)
else:
self.function = None