Source code for modelly.time.schedule

"""Event scheduling and time advance for :class:`modelly.core.model.Model`.

A mixin so the model file stays readable; every method here needs the
model's ``_event_list``, ``_event_generators``, ``time`` and signals.
"""

from __future__ import annotations

import warnings
from collections.abc import Callable

from modelly.time.events.event import Event, Priority, _create_callable_reference
from modelly.time.events.generator import EventGenerator, Schedule
from modelly.time.signals import ModelSignals, ObservableSignals, emit


[docs] class HasSchedule: """Scheduling, running and time advance, mixed into ``Model``.""" def _set_time(self, value: float) -> None: """Set ``time`` and notify subscribers when it changes.""" old = self.time self.time = value if old != value: self.notify("time", ObservableSignals.CHANGED, old=old, new=value) def _wrapped_step(self) -> None: """Advance time by one unit, processing any scheduled events.""" self._advance_time(self.time + 1) def _advance_time(self, until: float) -> None: """Advance time to the given point, processing events along the way. Args: until: The time to advance to """ if until <= self.time: warnings.warn( f"end time {until} is not larger than current time {self.time}", RuntimeWarning, stacklevel=2, ) return while True: try: event = self._event_list.pop_event() except IndexError: break if event.time <= until: self._set_time(event.time) event.execute() else: self._event_list.add_event(event) break self._set_time(until)
[docs] def schedule_event( self, function: Callable, *, at: float | None = None, after: float | None = None, priority: Priority = Priority.DEFAULT, ) -> Event: """Schedule a one-off event. Args: function: The callable to execute at: Absolute time to execute (mutually exclusive with after) after: Relative time from now to execute (mutually exclusive with at) priority: Priority level for the event Returns: The scheduled Event (can be used to cancel) Raises: ValueError: If both or neither of at/after are specified ValueError: If both or neither of at/after are specified, or if the scheduled time is in the past. """ if (at is None) == (after is None): raise ValueError("Specify exactly one of 'at' or 'after'") time = at if at is not None else self.time + after # Enforce monotonic time progression if time < self.time: raise ValueError( f"Cannot schedule event in the past. " f"Scheduled time is {time}, but current time is {self.time}" ) callback_ref = _create_callable_reference(function) function = None function = callback_ref() if function is None: raise ValueError("function must be alive at Event creation.") event = Event(time, function, priority=priority) self._event_list.add_event(event) return event
[docs] def schedule_recurring( self, function: Callable, schedule: Schedule, priority: Priority = Priority.DEFAULT, ) -> EventGenerator: """Schedule a recurring event based on a Schedule. Args: function: The callable to execute repeatedly schedule: The Schedule defining when events occur priority: Priority level for generated events Returns: The EventGenerator (can be used to stop) Raises: ValueError: If the schedule start time is in the past. """ generator = EventGenerator(self, function, schedule, priority) generator.start() return generator
[docs] @emit("model", ModelSignals.RUN_ENDED, when="after") def run_for(self, duration: float | int) -> None: """Run the model for the specified duration. Args: duration: Time units to advance """ self._advance_time(self.time + duration)
[docs] @emit("model", ModelSignals.RUN_ENDED, when="after") def run_until(self, end_time: float | int) -> None: """Run the model until the specified time. Args: end_time: Absolute time to run until If model.time is larger than end_time, the method returns directly. """ if self.time > end_time: warnings.warn( f"end_time {end_time} is not larger than current time {self.time}", RuntimeWarning, stacklevel=2, ) return self._advance_time(end_time)