"""Agent related classes.
Core Objects: Agent.
"""
# Postpone annotation evaluation to avoid NameError from forward references (PEP 563). Remove once Python 3.14+ is required.
from __future__ import annotations
import itertools
from collections.abc import Iterator
from random import Random
from typing import TYPE_CHECKING, ClassVar
import numpy as np
import pandas as pd
if TYPE_CHECKING:
from modelly.core.model import Model
from modelly.core.agentset import AgentSet, _resolve_per_agent_values
from modelly.core.column_access import collect_column_specs, install_column_properties
from modelly.core.columns import Column
[docs]
class Agent[M: Model]:
"""Base class for a model agent in Modelly.
Attributes:
model (Model): A reference to the model instance.
unique_id (int): A unique identifier for this agent.
Notes:
Agents must be hashable to be used in an AgentSet.
In Python 3, defining `__eq__` without `__hash__` makes an object unhashable,
which will break AgentSet usage.
unique_id is unique relative to a model instance and starts from 1
"""
_datasets: ClassVar = set()
_column_specs: ClassVar[dict[str, Column]] = {}
_column_row: int = -1 # instance attribute once the agent has a column row
# True when no class after Agent in the MRO defines __init__, so the
# cooperative super().__init__() would only reach object.__init__.
_plain_init: ClassVar[bool] = True
# True when the class has columns or datasets to update on birth and death.
_extra_hooks: ClassVar[bool] = False
_repr_excluded_fields: ClassVar[set[str]] = {
"model",
"current_action",
"unique_id",
"_column_table",
"_column_row",
}
def __init_subclass__(cls, **kwargs):
"""Called when Agent is subclassed, giving each subclass its own dataset set."""
super().__init_subclass__(**kwargs)
# Each subclass gets its own dataset set
# we use strings on this to avoid memory leaks
# and ensure the retrieved dataset belongs to the same
# model instance as the agent
cls._datasets = set()
# Columns declared on this class or inherited, in MRO order; each one is
# replaced on the class by a fast property (see modelly.core.column_access).
specs = collect_column_specs(cls)
cls._column_specs = specs
install_column_properties(cls, specs)
after_agent = cls.__mro__[cls.__mro__.index(Agent) + 1 : -1]
cls._plain_init = not any("__init__" in vars(base) for base in after_agent)
cls._extra_hooks = bool(specs)
def __init__(self, model: M, *args, **kwargs) -> None:
"""Create a new agent.
Args:
model (Model): The model instance in which the agent exists.
args: Passed on to super.
kwargs: Passed on to super.
Notes:
to make proper use of python's super, in each class remove the arguments and
keyword arguments you need and pass on the rest to super
"""
if args or kwargs:
super().__init__(*args, **kwargs)
elif not self._plain_init:
super().__init__() # a cooperative base class needs the call
self.model: M = model
if model._quiet:
# Model.register_agent written out for the common case (default
# registry, nobody listening): a birth is the hottest path in
# lifecycle-heavy models. Kept in sync with it (tested).
model._all_agents[self] = None
self.unique_id = model.agent_id_counter
model.agent_id_counter += 1
try:
model._agents_by_type[type(self)][self] = None
except KeyError:
model._register_new_type(self)
else:
self.unique_id = None
model.register_agent(self)
if self._extra_hooks:
if self._column_specs:
model.columns.register(self)
for dataset in self._datasets:
model.data_registry[dataset].add_agent(self)
[docs]
@classmethod
def column_specs(cls) -> dict[str, Column]:
"""The ``Column`` declarations of this class (name to column), in table order.
On the class, a column attribute is the fast property that reads the
agent's row; this returns the declarations themselves (dtype, default).
"""
return dict(cls._column_specs)
[docs]
def remove(self) -> None:
"""Remove and delete the agent from the model."""
# try/except rather than contextlib.suppress: the context manager costs
# about 300 ns per call and this runs on every agent death.
model = self.model
try:
if model._quiet:
# Model.deregister_agent written out; see __init__.
del model._agents_by_type[type(self)][self]
del model._all_agents[self]
else:
model.deregister_agent(self)
except KeyError:
pass # already removed
if self._extra_hooks:
if self._column_specs and self._column_row >= 0:
model.columns.unregister(self)
del self._column_table # its Column attributes now name the agent
# ensures models are also removed from datasets
for dataset in self._datasets:
model.data_registry[dataset].remove_agent(self)
[docs]
def step(self) -> None:
"""A single step of the agent."""
def advance(self) -> None: # noqa: D102
pass
[docs]
@classmethod
def create_agents[T: Agent](
cls: type[T], model: Model, n: int, *args, **kwargs
) -> AgentSet[T]:
"""Create N agents.
Args:
model: the model to which the agents belong
args: arguments to pass onto agent instances
each arg is either a single object or a sequence of length n
n: the number of agents to create
kwargs: keyword arguments to pass onto agent instances
each keyword arg is either a single object or a sequence of length n
Returns:
AgentSet containing the agents created.
Warning:
A list, tuple, ndarray, or pandas Series argument is treated as one
value per agent and must have length n; a length mismatch raises
ValueError. This is especially easy to hit with coordinate tuples:
create_agents(model, 2, pos=(10, 20)) does NOT give both agents
pos=(10, 20); it gives agent 0 pos=10 and agent 1 pos=20, since the
tuple's length (2) matches n (2).
To assign the same sequence value to every agent, broadcast it
explicitly as a length-n list of that value, e.g.:
create_agents(model, 2, pos=[(10, 20)] * 2)
Raises:
ValueError: If a sequence argument's length does not match n.
"""
agents = []
if not args and not kwargs:
for _ in range(n):
agents.append(cls(model))
return AgentSet(agents, random=model.random)
pos_iter, kw_keys, kw_iter = _per_agent_iters(args, kwargs, n)
# We rely on range(n) to drive the loop length
if kwargs:
for _, p_args, k_vals in zip(range(n), pos_iter, kw_iter):
agents.append(cls(model, *p_args, **dict(zip(kw_keys, k_vals))))
else:
for _, p_args in zip(range(n), pos_iter):
agents.append(cls(model, *p_args))
return AgentSet(agents, random=model.random)
[docs]
@classmethod
def from_dataframe[T: Agent](
cls: type[T], model: Model, df: pd.DataFrame, **kwargs
) -> AgentSet[T]:
"""Create agents from a pandas DataFrame.
Each row of the DataFrame represents one agent. The DataFrame columns are
mapped to the agent's constructor as keyword arguments. Additional keyword
arguments (`**kwargs`) can be used to set constant attributes for all agents.
Args:
model: The model instance.
df: The pandas DataFrame. Each row represents an agent.
**kwargs: Constant values to pass to every agent's constructor.
Only non-sequence data is allowed in kwargs to avoid ambiguity
with DataFrame columns.
Returns:
AgentSet containing the agents created.
Note:
If you need to pass variable data or sequences, add them as columns
to the DataFrame before calling this method.
"""
for key, value in kwargs.items():
if isinstance(value, (list, np.ndarray, tuple, pd.Series)):
raise TypeError(
f"from_dataframe does not support sequence data in kwargs ('{key}'). "
"Please add this data to the DataFrame before calling from_dataframe."
)
agents = [
cls(model, **{**record, **kwargs})
for record in df.to_dict(orient="records")
]
return AgentSet(agents, random=model.random)
def __str__(self) -> str:
"""Return a human-readable string representation of the agent."""
return f"{self.__class__.__name__}, agent_id = {self.unique_id}"
def __repr__(self) -> str:
"""Return an unambiguous string representation including agent state."""
# Get excluded fields (allows subclasses to override)
excluded = self._repr_excluded_fields
# Get user-defined attributes (exclude private and Modelly fields)
user_attrs = {
k: v
for k, v in self.__dict__.items()
if not k.startswith("_") and k not in excluded
}
if user_attrs:
attr_str = ", ".join(f"{k}={v!r}" for k, v in user_attrs.items())
return f"<{self.__class__.__name__} id={self.unique_id} {attr_str}>"
else:
return f"<{self.__class__.__name__} id={self.unique_id}>"
@property
def random(self) -> Random:
"""Return a seeded stdlib rng."""
return self.model.random
@property
def rng(self) -> np.random.Generator:
"""Return a seeded np.random rng."""
return self.model.rng
@property
def scenario(self):
"""Return the scenario associated with the model."""
return self.model.scenario
def _per_agent_iters(
args: tuple, kwargs: dict, n: int
) -> tuple[Iterator, list, Iterator]:
"""Return the positional iterator, keyword names and keyword iterator of ``create_agents``.
A sequence must have length n (assigned per agent); a length mismatch
raises. Anything else is broadcast.
"""
arg_iters = [_resolve_per_agent_values(arg, n) for arg in args]
kw_keys = list(kwargs.keys())
kw_val_iters = [_resolve_per_agent_values(v, n) for v in kwargs.values()]
# If arg_iters is empty, zip(*[]) returns nothing, so we use repeat(())
pos_iter = zip(*arg_iters) if arg_iters else itertools.repeat(())
kw_iter = zip(*kw_val_iters) if kw_val_iters else itertools.repeat(())
return pos_iter, kw_keys, kw_iter