Source code for modelly.space.cells.index

"""Integer rows for the cells of a discrete space, and sparse operators over them.

Every discrete space (grid, network, Voronoi mesh) is a set of cells joined by
``connections``. Numbering the cells gives each one a row, and the connections
become a sparse adjacency matrix. Whole-population questions then cost one
numpy call each instead of one Python loop per agent:

* ``counts(rows)``: how many agents, or how much of a quantity, sit in each cell;
* ``neighbor_sum(values)``: for every cell, the sum of ``values`` over its neighbours;
* ``random_neighbor(rows)``: one uniformly chosen neighbour for every row.

Agents that declare a ``Column`` for their cell row (``row = Column(np.intp)``,
kept current with ``self.row = cell.index`` when they move) can combine these
with ``ColumnTable.draw`` into a vectorised step phase. See
``docs/vectorised_phases.md`` for the equivalence arguments.
"""

from __future__ import annotations

from collections.abc import Iterable, Iterator
from typing import TYPE_CHECKING

import numpy as np

if TYPE_CHECKING:
    from scipy.sparse import csr_matrix

    from modelly.core.agent import Agent
    from modelly.core.model import Model
    from modelly.space.base import DiscreteSpace
    from modelly.space.cells.cell import Cell


[docs] class CellIndex: """Rows for the cells of ``space`` and the sparse adjacency between them. Building the index writes ``cell.index`` on every cell, in the order of ``space.all_cells``. For grids that order is the C order of the coordinates, so a per-cell vector reshaped to the grid's dimensions is a property layer. """ def __init__(self, space: DiscreteSpace) -> None: """Number the cells of ``space``; the adjacency is built on first use.""" cells = space.all_cells.cells for row, cell in enumerate(cells): cell.index = row self.cells: list[Cell] = cells self.n = len(cells) self.dimensions = tuple(getattr(space, "dimensions", ())) self._adjacency: csr_matrix | None = None # agent class -> (model, column name) for agents placed by a row column self._tracked: dict[type, tuple[Model, str]] = {} space._cell_index = self
[docs] def track(self, model: Model, agent_class: type, column: str = "row") -> None: """Declare that ``agent_class`` keeps its cell as a row ``column``. Such agents are in no cell's agent list; tracking them makes the space list them (``space.agents``) and lets the visualisation place them at ``index.cells[agent.<column>]``. """ self._tracked[agent_class] = (model, column)
[docs] def tracked_agents(self) -> Iterator[Agent]: """The live agents of every tracked class, in table order.""" for agent_class, (model, _) in self._tracked.items(): if agent_class in model.columns: yield from model.columns[agent_class].agents
[docs] def cell_of(self, agent: Agent) -> Cell: """The cell a tracked agent occupies, from its row column.""" try: _, column = self._tracked[type(agent)] except KeyError as e: raise ValueError( f"{type(agent).__name__} has no cell and is not tracked; call " "CellIndex.track(model, agent_class, column) for row-placed agents." ) from e return self.cells[getattr(agent, column)]
def __len__(self) -> int: """Return the number of cells.""" return self.n
[docs] def rows(self, cells: Iterable[Cell]) -> np.ndarray: """Return the row of each cell in ``cells``.""" return np.fromiter((cell.index for cell in cells), dtype=np.intp)
[docs] def counts(self, rows: np.ndarray, weights: np.ndarray | None = None) -> np.ndarray: """Per-cell total of ``weights`` (or of ones) over the agents sitting in ``rows``.""" if weights is None: return np.bincount(rows, minlength=self.n) return np.bincount(rows, weights=weights, minlength=self.n)
@property def adjacency(self) -> csr_matrix: """The cell adjacency as a CSR matrix with one entry per connection.""" if self._adjacency is None: self._adjacency = self._build_adjacency() return self._adjacency def _build_adjacency(self) -> csr_matrix: from scipy.sparse import csr_matrix # noqa: PLC0415 - scipy is heavy cells = self.cells degrees = np.fromiter((len(cell.connections) for cell in cells), dtype=np.intp) indices = np.fromiter( (other.index for cell in cells for other in cell.connections.values()), dtype=np.intp, count=int(degrees.sum()), ) indptr = np.concatenate(([0], np.cumsum(degrees))) data = np.ones(indices.shape[0], dtype=np.int32) return csr_matrix((data, indices, indptr), shape=(self.n, self.n))
[docs] def neighbor_sum(self, values: np.ndarray) -> np.ndarray: """For every cell, the sum of ``values`` over its neighbouring cells.""" return self.adjacency @ np.asarray(values)
[docs] def random_neighbor(self, rows: np.ndarray, rng: np.random.Generator) -> np.ndarray: """One uniformly chosen neighbour row per entry of ``rows``. A row without neighbours is returned unchanged. """ adjacency = self.adjacency if adjacency.nnz == 0: return np.array(rows, copy=True) start = adjacency.indptr[rows] degree = adjacency.indptr[rows + 1] - start pick = start + (rng.random(rows.shape[0]) * degree).astype(np.intp) chosen = adjacency.indices[np.minimum(pick, adjacency.nnz - 1)] return np.where(degree > 0, chosen, rows)
[docs] def to_layer(self, values: np.ndarray) -> np.ndarray: """Reshape a per-cell vector to the grid's dimensions (a property layer view).""" if not self.dimensions: raise ValueError("to_layer needs a space with dimensions (a grid).") return np.asarray(values).reshape(self.dimensions)