Source code for modelly.core.model

"""The model class for Modelly framework.

Core Objects: Model
"""

# Postpone annotation evaluation to avoid NameError from forward references (PEP 563). Remove once Python 3.14+ is required.
from __future__ import annotations

from collections.abc import Sequence
from pathlib import Path
from typing import Any, Self

import numpy as np

from modelly.core.agent import Agent
from modelly.core.checkpoint import CheckpointWriter, load_model, save_model
from modelly.core.columns import ColumnStore
from modelly.core.display import model_html, model_markdown
from modelly.core.exceptions import CheckpointError
from modelly.core.registry import AgentRegistry
from modelly.core.rng import FastRandom
from modelly.data.recorders.datasets import DataRegistry
from modelly.log import create_module_logger, method_logger
from modelly.scenarios import Scenario
from modelly.time import (
    EventGenerator,
    EventList,
    Priority,
    Schedule,
)
from modelly.time.schedule import HasSchedule
from modelly.time.signals import (
    HasEmitters,
    ModelSignals,
    ObservableSignals,
)

SeedLike = int | np.integer | Sequence[int] | np.random.SeedSequence
RNGLike = np.random.Generator | np.random.BitGenerator


_modelly_logger = create_module_logger()

# Subscriber keys and the observable signals of ``agents``; see register_agent.
_AGENT_SIGNALS = frozenset({ModelSignals.AGENT_ADDED, ModelSignals.AGENT_REMOVED})


# TODO: We can add `= Scenario` default type when Python 3.13+ is required
[docs] class Model[A: Agent, S: Scenario](AgentRegistry[A], HasSchedule, HasEmitters): """Base class for models in the Modelly ABM library. This class serves as a foundational structure for creating agent-based models. It includes the basic attributes and methods necessary for initializing and running a simulation model. Type Parameters: A: The agent type used in this model S: The scenario type used in this model Attributes: running: A boolean indicating if the model should continue running. time: the current simulation time. random: a seeded python.random number generator. rng: a seeded numpy.random.Generator scenario: the scenario instance containing model parameters Notes: Model.agents returns the AgentSet containing all agents registered with the model. Changing the content of the AgentSet directly can result in strange behavior. If you want change the composition of this AgentSet, ensure you operate on a copy. """ # time is a plain attribute for speed: reads are ~40ns vs ~100ns through a # data descriptor. The CHANGED signal is emitted once per time advance by # _set_time(), so BaseDataRecorder and observe("time", ...) keep working. # "time" is registered as an observable in __init_subclass__ (below) and at # module level for the base Model class itself. # True when the class keeps the default registration methods, which lets # Agent.__init__ and Agent.remove inline them (see agent.py). _default_registry: bool = True def __init_subclass__(cls, **kwargs): """Register ``time`` as an observable for every Model subclass.""" super().__init_subclass__(**kwargs) cls.observables["time"] = ObservableSignals cls.observables["agents"] = _AGENT_SIGNALS cls._default_registry = ( cls.register_agent is Model.register_agent and cls.deregister_agent is Model.deregister_agent ) if not cls._default_registry: cls._quiet = False # Agent.__init__ must call the override def _init_scenario( self, rng: RNGLike | SeedLike | None, scenario: S | type[S] ) -> None: """Set the scenario and the two generators it seeds.""" if isinstance(scenario, Scenario) and rng is not None: raise ValueError("Pass either rng or scenario, not both.") if not isinstance(scenario, Scenario): scenario = scenario(rng=rng) # type: ignore[assignment] self.scenario = scenario self.rng: np.random.Generator = scenario.rng self.random = FastRandom(scenario._stdlib_seed) def _init_default_schedule(self) -> None: """Wrap the user's step in the default schedule (every 1.0 time units).""" self._user_step = self.step self._default_schedule: EventGenerator = EventGenerator( self, self._user_step, Schedule(interval=1.0, start=1.0), priority=Priority.HIGH, ).start() self.step = self._wrapped_step @property def scenario(self) -> S: """Return scenario instance.""" return self._scenario @scenario.setter def scenario(self, scenario: S) -> None: """Set scenario instance.""" self._scenario = scenario @method_logger(__name__) def __init__( self, *args: Any, rng: RNGLike | SeedLike | None = None, scenario: S | type[S] = Scenario, **kwargs: Any, ) -> None: """Create a new model. Overload this method with the actual code to initialize the model. Always start with super().__init__() to initialize the model object properly. Args: args: arguments to pass onto super rng: Seed for the random number generator. Accepts any value accepted by numpy.random.default_rng(). Ignored if a Scenario instance is passed; used to instantiate the scenario when a Scenario class is passed. scenario: A Scenario instance or subclass to use for this model. If a class is passed it is instantiated with rng. If an instance is passed, rng must not be set. kwargs: keyword arguments to pass onto super Notes: Pass either rng or a Scenario instance, not both. Passing rng alongside a Scenario class is valid — rng is forwarded to the class constructor. """ super().__init__(*args, **kwargs) self.running: bool = True self.time = 0.0 self.agent_id_counter: int = 1 # Event list for event-based execution self._event_list: EventList = EventList() # Strong references to active EventGenerators (prevent GC) self._event_generators: set[EventGenerator] = set() self._init_scenario(rng, scenario) self._init_default_schedule() self._init_registries() self.data_registry = DataRegistry() # Column tables for agent classes that declare Column attributes. self.columns = ColumnStore(self.random, self.rng)
[docs] def run_model(self) -> None: """Run the model until the end condition is reached. Overload as needed. """ while self.running: self.step()
[docs] def step(self) -> None: """A single step. Fill in here."""
# Jupyter shows a model as its class, scenario, time and population by class. _repr_html_ = model_html _repr_markdown_ = model_markdown
[docs] def save(self, path: str | Path, *, compress: bool = False) -> None: """Save the whole model to one checkpoint file. The file holds the agents, spaces, columns, scheduled events, data collected so far and the exact state of ``random`` and ``rng``, so ``Model.load`` resumes the run draw for draw. Args: path: The file to write. compress: Gzip the payload (smaller, slower). Raises: CheckpointError: If a callback the model holds, such as an event callback or a reporter, is a lambda or nested function. """ save_model(self, path, compress=compress)
[docs] @classmethod def load(cls, path: str | Path) -> Self: """Load a model saved with ``save``. Warning: A checkpoint is a pickle: loading one runs code chosen by whoever wrote it. Only load files you created or otherwise trust. Args: path: The checkpoint file. Returns: The saved model, an instance of the class it was saved from. Raises: CheckpointError: If the file is not a checkpoint, was written by another Modelly or Python release, or holds no ``cls``. """ model = load_model(path) if not isinstance(model, cls): raise CheckpointError( f"{path} holds a {type(model).__name__}, not a {cls.__name__}." ) return model
[docs] def checkpoint_every( self, interval: float, path_pattern: str, *, compress: bool = False ) -> CheckpointWriter: """Save a checkpoint every ``interval`` time units. Args: interval: Time between checkpoints; the first is ``interval`` from now. path_pattern: The file name, formatted with ``time``, e.g. ``"run-{time:04.0f}.ckpt"``. compress: Gzip each checkpoint. Returns: The writer; call its ``stop()`` to end the checkpoints. """ return CheckpointWriter(self, interval, path_pattern, compress)
[docs] def remove_all_agents(self): """Remove all agents from the model. Notes: This method calls agent.remove for all agents in the model. If you need to remove agents from e.g., a SingleGrid, you can either explicitly implement your own agent.remove method or clean this up near where you are calling this method. """ # we need to wrap keys in a list to avoid a RunTimeError: dictionary changed size during iteration for agent in list(self._all_agents): agent.remove() self.data_registry.close() # this is needed to ensure GC works properly
# Register "time" as an observable for the base Model class itself (subclasses # get it via Model.__init_subclass__). Model.time is a plain attribute now, so # the descriptor generator no longer discovers it automatically. Model.observables["time"] = ObservableSignals Model.observables["agents"] = _AGENT_SIGNALS