Source code for modelly.data.recorders.numpy_dataset

"""NumpyAgentDataSet: agent attributes stored as columns of a contiguous NumPy array."""

from __future__ import annotations

from contextlib import suppress
from typing import TYPE_CHECKING

import numpy as np

from modelly.core.agent import Agent

if TYPE_CHECKING:
    from modelly.data.recorders.base import BaseDataRecorder
    from modelly.data.recorders.config import DatasetConfig
    from modelly.data.recorders.protocol import DataSet


[docs] class NumpyAgentDataSet[A: Agent]: """A NumPy array data set for storing agent data. Uses swap-with-last removal to keep data contiguous, allowing views. Note: This class does not inherit from BaseDataSet because it doesn't use the attrgetter-based collection mechanism. """ _GROWTH_FACTOR = 2.0 _MIN_GROWTH = 100 def __init__( self, name: str, agent_type: type[A], fields: str | list[str] | None = None, n: int = 100, dtype: np.dtype | type = np.float64, ): """Initialize the dataset. Args: name: Name of the dataset agent_type: The agent class to install properties on fields: attribute names to track n: Initial capacity dtype: NumPy dtype for the array Raises: ValueError: if no attributes are specified """ if fields is None: raise ValueError("please pass one or more fields to collect") elif isinstance(fields, str): fields = [fields] self.name = name self._attributes = fields self._closed = False self.dtype = dtype self._index_in_table = f"_index_datatable_{name}" self._init_storage(n) self.agent_type = agent_type if not hasattr(agent_type, "_datasets"): agent_type._datasets = set() agent_type._datasets.add(self.name) agent_type._extra_hooks = True self._install_properties() def _init_storage(self, n: int) -> None: """Allocate the data and id arrays with capacity n, and the index mappings.""" # Core data storage - always contiguous from 0 to _n_active-1 self._agent_data: np.ndarray = np.empty( (n, len(self._attributes)), dtype=self.dtype ) self._agent_ids: np.ndarray = np.zeros(n, dtype=int) self._n_active = 0 # Mappings - index is always position in _agent_data self._index_to_agent: dict[int, A] = {} self._agent_to_index: dict[A, int] = {} self._attribute_to_index: dict[str, int] = { attr: i for i, attr in enumerate(self._attributes) } def _make_getter_setter(self, attribute_name: str): """Generate getter and setter for the specified attribute.""" j = self._attribute_to_index[attribute_name] index_attr = self._index_in_table data = self._agent_data def getter(agent: A): return data[agent.__dict__[index_attr], j] def setter(agent: A, value): data[agent.__dict__[index_attr], j] = value return getter, setter def _install_properties(self) -> None: """Install properties on the agent class for all attributes.""" for attr in self._attributes: setattr(self.agent_type, attr, property(*self._make_getter_setter(attr))) def _expand_storage(self) -> None: """Expand the internal array when out of space.""" current_size = self._agent_data.shape[0] growth = max(int(current_size * (self._GROWTH_FACTOR - 1)), self._MIN_GROWTH) new_size = current_size + growth new_data = np.empty((new_size, len(self._attributes)), dtype=self.dtype) new_data[:current_size] = self._agent_data self._agent_data = new_data new_ids = np.zeros(new_size, dtype=int) new_ids[:current_size] = self._agent_ids self._agent_ids = new_ids # Reinstall properties to capture new array reference self._install_properties() def _check_closed(self) -> None: """Raise if dataset has been closed.""" if self._closed: raise RuntimeError(f"DataSet '{self.name}' has been closed")
[docs] def add_agent(self, agent: A) -> int: """Add an agent to the dataset. Args: agent: The agent to add Returns: The index assigned to the agent Raises: RuntimeError if the dataset has been closed. """ self._check_closed() index = self._n_active # Expand if necessary if index >= self._agent_data.shape[0]: self._expand_storage() # Store index on agent agent.__dict__[self._index_in_table] = index # Update mappings self._agent_ids[index] = agent.unique_id self._agent_to_index[agent] = index self._index_to_agent[index] = agent self._n_active += 1 return index
[docs] def remove_agent(self, agent: A) -> None: """Remove an agent from the dataset using swap-with-last. Args: agent: The agent to remove Raises: ValueError if the agent is not in the dataset """ self._check_closed() index = agent.__dict__.get(self._index_in_table) if index is None: raise ValueError("agent not in dataset") last_index = self._n_active - 1 if index != last_index: # Swap data row with last active row self._agent_data[index] = self._agent_data[last_index] self._agent_ids[index] = self._agent_ids[last_index] # Update the swapped agent's index swapped_agent = self._index_to_agent[last_index] swapped_agent.__dict__[self._index_in_table] = index self._agent_to_index[swapped_agent] = index self._index_to_agent[index] = swapped_agent # Remove the agent del self._agent_to_index[agent] del self._index_to_agent[last_index] agent.__dict__.pop(self._index_in_table, None) self._n_active -= 1
@property def data(self) -> np.ndarray: """Return active agent data as a VIEW (no copy). Warning: Modifying the returned array modifies the underlying data. """ self._check_closed() return self._agent_data[: self._n_active] @property def data_copy(self) -> np.ndarray: """Return a copy of active agent data.""" self._check_closed() return self._agent_data[: self._n_active].copy() @property def agent_ids(self) -> np.ndarray: """Return the agent ids as a view (no copy).""" return self._agent_ids[: self._n_active] @property def active_agents(self) -> list[A]: """Return list of all active agents (order matches data rows).""" self._check_closed() return [self._index_to_agent[i] for i in range(self._n_active)] def _reset(self) -> None: """Reset the dataset to an empty state.""" self._agent_to_index.clear() self._index_to_agent.clear() self._n_active = 0 def __len__(self) -> int: """Return the number of active agents.""" return self._n_active def __repr__(self) -> str: """Return string representation.""" return ( f"NumpyAgentDataSet(name={self.name!r}, " f"active={self._n_active}, " f"capacity={self._agent_data.shape[0]})" )
[docs] def close(self) -> None: """Close the dataset and remove properties from agent class.""" if self._closed: return self._reset() # Remove properties from agent class for attr in self._attributes: with suppress(AttributeError): delattr(self.agent_type, attr) self._closed = True
[docs] def record( self, recorder: BaseDataRecorder, configuration: DatasetConfig | None = None ) -> DataSet: """Record the collected data.""" recorder.add_dataset(self, configuration=configuration) return self