"""
Abstract base classes for spatial components in mesa-frames.
This module defines the core abstractions for spatial structures in the mesa-frames
extension. It provides the foundation for implementing various types of spaces,
including discrete spaces and grids, using DataFrame-based approaches for improved
performance and scalability.
Classes:
SpaceDF(CopyMixin, DataFrameMixin):
An abstract base class that defines the common interface for all space
classes in mesa-frames. It combines fast copying functionality with
DataFrame operations.
DiscreteSpaceDF(SpaceDF):
An abstract base class for discrete space implementations, such as grids
and networks. It extends SpaceDF with methods specific to discrete spaces.
GridDF(DiscreteSpaceDF):
An abstract base class for grid-based spaces. It inherits from
DiscreteSpaceDF and adds grid-specific functionality.
These abstract classes are designed to be subclassed by concrete implementations
that use Polars library as their backend.
They provide a common interface and shared functionality across different types
of spatial structures in agent-based models.
Usage:
These classes should not be instantiated directly. Instead, they should be
subclassed to create concrete implementations:
from mesa_frames.abstract.space import GridDF
class GridPolars(GridDF):
def __init__(self, model, dimensions, torus, capacity, neighborhood_type):
super().__init__(model, dimensions, torus, capacity, neighborhood_type)
# Implementation using polars DataFrame
...
# Implement other abstract methods
Note:
The abstract methods in these classes use Python's @abstractmethod decorator,
ensuring that concrete subclasses must implement these methods.
Attributes and methods of each class are documented in their respective docstrings.
For more detailed information on each class, refer to their individual docstrings.
"""
from abc import abstractmethod
from collections.abc import Callable, Collection, Sequence, Sized
from itertools import product
from typing import TYPE_CHECKING, Literal
from warnings import warn
import numpy as np
import polars as pl
from numpy.random import Generator
from typing_extensions import Any, Self
from mesa_frames import AgentsDF
from mesa_frames.abstract.agents import AgentContainer, AgentSetDF
from mesa_frames.abstract.mixin import CopyMixin, DataFrameMixin
from mesa_frames.types_ import (
ArrayLike,
BoolSeries,
DataFrame,
DiscreteCoordinate,
DiscreteCoordinates,
DiscreteSpaceCapacity,
GridCapacity,
GridCoordinate,
GridCoordinates,
IdsLike,
Series,
SpaceCoordinate,
SpaceCoordinates,
)
ESPG = int
if TYPE_CHECKING:
from mesa_frames.concrete.model import ModelDF
class SpaceDF(CopyMixin, DataFrameMixin):
"""The SpaceDF class is an abstract class that defines the interface for all space classes in mesa_frames."""
_model: "ModelDF"
_agents: DataFrame # | GeoDataFrame # Stores the agents placed in the space
_center_col_names: list[
str
] # The column names of the center pos/agents in the neighbors/neighborhood method (eg. ['dim_0_center', 'dim_1_center', ...] in Grids, ['node_id_center', 'edge_id_center'] in Networks)
_pos_col_names: list[
str
] # The column names of the positions in the _agents dataframe (eg. ['dim_0', 'dim_1', ...] in Grids, ['node_id', 'edge_id'] in Networks)
def __init__(self, model: "ModelDF") -> None:
"""Create a new SpaceDF.
Parameters
----------
model : ModelDF
"""
self._model = model
def move_agents(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
pos: SpaceCoordinate | SpaceCoordinates,
inplace: bool = True,
) -> Self:
"""Move agents in the Space to the specified coordinates.
If some agents are not placed,raises a RuntimeWarning.
Parameters
----------
agents : IdsLike | AgentContainer | Collection[AgentContainer]
The agents to move
pos : SpaceCoordinate | SpaceCoordinates
The coordinates for each agents. The length of the coordinates must match the number of agents.
inplace : bool, optional
Whether to perform the operation inplace, by default True
Raises
------
RuntimeWarning
If some agents are not placed in the space.
ValueError
- If some agents are not part of the model.
- If agents is IdsLike and some agents are present multiple times.
Returns
-------
Self
"""
obj = self._get_obj(inplace=inplace)
return obj._place_or_move_agents(agents=agents, pos=pos, is_move=True)
def place_agents(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
pos: SpaceCoordinate | SpaceCoordinates,
inplace: bool = True,
) -> Self:
"""Place agents in the space according to the specified coordinates. If some agents are already placed, raises a RuntimeWarning.
Parameters
----------
agents : IdsLike | AgentContainer | Collection[AgentContainer]
The agents to place in the space
pos : SpaceCoordinate | SpaceCoordinates
The coordinates for each agents. The length of the coordinates must match the number of agents.
inplace : bool, optional
Whether to perform the operation inplace, by default True
Returns
-------
Self
Raises
------
RuntimeWarning
If some agents are already placed in the space.
ValueError
- If some agents are not part of the model.
- If agents is IdsLike and some agents are present multiple times.
"""
obj = self._get_obj(inplace=inplace)
return obj._place_or_move_agents(agents=agents, pos=pos, is_move=False)
def random_agents(
self,
n: int,
) -> DataFrame:
"""Return a random sample of agents from the space.
Parameters
----------
n : int
The number of agents to sample
Returns
-------
DataFrame
A DataFrame with the sampled agents
"""
seed = self.random.integers(np.iinfo(np.int32).max)
return self._df_sample(self._agents, n=n, seed=seed)
def swap_agents(
self,
agents0: IdsLike | AgentContainer | Collection[AgentContainer],
agents1: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
"""Swap the positions of the agents in the space.
agents0 and agents1 must have the same length and all agents must be placed in the space.
Parameters
----------
agents0 : IdsLike | AgentContainer | Collection[AgentContainer]
The first set of agents to swap
agents1 : IdsLike | AgentContainer | Collection[AgentContainer]
The second set of agents to swap
inplace : bool, optional
Whether to perform the operation inplace, by default True
Returns
-------
Self
"""
agents0 = self._get_ids_srs(agents0)
agents1 = self._get_ids_srs(agents1)
if __debug__:
if len(agents0) != len(agents1):
raise ValueError("The two sets of agents must have the same length")
if not self._df_contains(self._agents, "agent_id", agents0).all():
raise ValueError("Some agents in agents0 are not in the space")
if not self._df_contains(self._agents, "agent_id", agents1).all():
raise ValueError("Some agents in agents1 are not in the space")
if self._srs_contains(agents0, agents1).any():
raise ValueError("Some agents are present in both agents0 and agents1")
obj = self._get_obj(inplace)
agents0_df = obj._df_get_masked_df(
obj._agents, index_cols="agent_id", mask=agents0
)
agents1_df = obj._df_get_masked_df(
obj._agents, index_cols="agent_id", mask=agents1
)
agents0_df = obj._df_set_index(agents0_df, "agent_id", agents1)
agents1_df = obj._df_set_index(agents1_df, "agent_id", agents0)
obj._agents = obj._df_combine_first(
agents0_df, obj._agents, index_cols="agent_id"
)
obj._agents = obj._df_combine_first(
agents1_df, obj._agents, index_cols="agent_id"
)
return obj
@abstractmethod
def get_directions(
self,
pos0: SpaceCoordinate | SpaceCoordinates | None = None,
pos1: SpaceCoordinate | SpaceCoordinates | None = None,
agents0: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
agents1: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
normalize: bool = False,
) -> DataFrame:
"""Return the directions from pos0 to pos1 or agents0 and agents1.
If the space is a Network, the direction is the shortest path between the two nodes.
In all other cases, the direction is the direction vector between the two positions.
Either positions (pos0, pos1) or agents (agents0, agents1) must be specified, not both and they must have the same length.
Parameters
----------
pos0 : SpaceCoordinate | SpaceCoordinates | None, optional
The starting positions
pos1 : SpaceCoordinate | SpaceCoordinates | None, optional
The ending positions
agents0 : IdsLike | AgentContainer | Collection[AgentContainer] | None, optional
The starting agents
agents1 : IdsLike | AgentContainer | Collection[AgentContainer] | None, optional
The ending agents
normalize : bool, optional
Whether to normalize the vectors to unit norm. By default False
Returns
-------
DataFrame
A DataFrame where each row represents the direction from pos0 to pos1 or agents0 to agents1
"""
...
@abstractmethod
def get_distances(
self,
pos0: SpaceCoordinate | SpaceCoordinates | None = None,
pos1: SpaceCoordinate | SpaceCoordinates | None = None,
agents0: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
agents1: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
) -> DataFrame:
"""Return the distances from pos0 to pos1 or agents0 and agents1.
If the space is a Network, the distance is the number of nodes of the shortest path between the two nodes.
In all other cases, the distance is Euclidean/l2/Frobenius norm.
You should specify either positions (pos0, pos1) or agents (agents0, agents1), not both and they must have the same length.
Parameters
----------
pos0 : SpaceCoordinate | SpaceCoordinates | None, optional
The starting positions
pos1 : SpaceCoordinate | SpaceCoordinates | None, optional
The ending positions
agents0 : IdsLike | AgentContainer | Collection[AgentContainer] | None, optional
The starting agents
agents1 : IdsLike | AgentContainer | Collection[AgentContainer] | None, optional
The ending agents
Returns
-------
DataFrame
A DataFrame where each row represents the distance from pos0 to pos1 or agents0 to agents1
"""
return ...
@abstractmethod
def get_neighbors(
self,
radius: int | float | Sequence[int] | Sequence[float] | ArrayLike,
pos: SpaceCoordinate | SpaceCoordinates | None = None,
agents: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
include_center: bool = False,
) -> DataFrame:
"""Get the neighboring agents from given positions or agents according to the specified radiuses.
Either positions (pos0, pos1) or agents (agents0, agents1) must be specified, not both and they must have the same length.
Parameters
----------
radius : int | float | Sequence[int] | Sequence[float] | ArrayLike
The radius(es) of the neighborhood
pos : SpaceCoordinate | SpaceCoordinates | None, optional
The coordinates of the cell to get the neighborhood from, by default None
agents : IdsLike | AgentContainer | Collection[AgentContainer] | None, optional
The id of the agents to get the neighborhood from, by default None
include_center : bool, optional
If the center cells or agents should be included in the result, by default False
Returns
-------
DataFrame
A dataframe with neighboring agents.
The columns with '_center' suffix represent the center agent/position.
Raises
------
ValueError
If both pos and agent are None or if both pos and agent are not None.
"""
...
@abstractmethod
def move_to_empty(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
"""Move agents to empty cells/positions in the space (cells/positions where there isn't any single agent).
Parameters
----------
agents : IdsLike | AgentContainer | Collection[AgentContainer]
The agents to move to empty cells/positions
inplace : bool, optional
Whether to perform the operation inplace, by default True
Returns
-------
Self
"""
...
@abstractmethod
def place_to_empty(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
"""Place agents in empty cells/positions in the space (cells/positions where there isn't any single agent).
Parameters
----------
agents : IdsLike | AgentContainer | Collection[AgentContainer]
The agents to place in empty cells/positions
inplace : bool, optional
Whether to perform the operation inplace, by default True
Returns
-------
Self
"""
...
@abstractmethod
def random_pos(
self,
n: int,
) -> DataFrame:
"""Return a random sample of positions from the space.
Parameters
----------
n : int
The number of positions to sample
Returns
-------
DataFrame
A DataFrame with the sampled positions
"""
...
@abstractmethod
def remove_agents(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
"""Remove agents from the space.
Does not remove the agents from the model.
Parameters
----------
agents : IdsLike | AgentContainer | Collection[AgentContainer]
The agents to remove from the space
inplace : bool, optional
Whether to perform the operation inplace, by default True
Raises
------
ValueError
If some agents are not part of the model.
Returns
-------
Self
"""
return ...
def _get_ids_srs(
self, agents: IdsLike | AgentContainer | Collection[AgentContainer]
) -> Series:
if isinstance(agents, Sized) and len(agents) == 0:
return self._srs_constructor([], name="agent_id")
if isinstance(agents, AgentSetDF):
return self._srs_constructor(
self._df_index(agents, "unique_id"), name="agent_id"
)
elif isinstance(agents, AgentsDF):
return self._srs_constructor(agents._ids, name="agent_id")
elif isinstance(agents, Collection) and (isinstance(agents[0], AgentContainer)):
ids = []
for a in agents:
if isinstance(a, AgentSetDF):
ids.append(
self._srs_constructor(
self._df_index(a, "unique_id"), name="agent_id"
)
)
elif isinstance(a, AgentsDF):
ids.append(self._srs_constructor(a._ids, name="agent_id"))
return self._df_concat(ids, ignore_index=True)
elif isinstance(agents, int):
return self._srs_constructor([agents], name="agent_id")
else: # IDsLike
return self._srs_constructor(agents, name="agent_id")
@abstractmethod
def _place_or_move_agents(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
pos: SpaceCoordinate | SpaceCoordinates,
is_move: bool,
) -> Self:
"""Move or place agents.
Only the runtime warning change.
Parameters
----------
agents : IdsLike | AgentContainer | Collection[AgentContainer]
The agents to move/place
pos : SpaceCoordinate | SpaceCoordinates
The position to move/place agents to
is_move : bool
Whether the operation is "move" or "place"
Returns
-------
Self
"""
@abstractmethod
def __repr__(self) -> str:
"""Return a string representation of the SpaceDF.
Returns
-------
str
"""
...
@abstractmethod
def __str__(self) -> str:
"""Return a string representation of the SpaceDF.
Returns
-------
str
"""
...
@property
def agents(self) -> DataFrame: # | GeoDataFrame:
"""Get the ids of the agents placed in the cell set, along with their coordinates or geometries.
Returns
-------
DataFrame
"""
return self._agents
@property
def model(self) -> "ModelDF":
"""The model to which the space belongs.
Returns
-------
'ModelDF'
"""
return self._model
@property
def random(self) -> Generator:
"""The model's random number generator.
Returns
-------
Generator
"""
return self.model.random
class DiscreteSpaceDF(SpaceDF):
"""The DiscreteSpaceDF class is an abstract class that defines the interface for all discrete space classes (Grids and Networks) in mesa_frames."""
_agents: DataFrame
_capacity: int | None # The maximum capacity for cells (default is infinite)
_cells: DataFrame # Stores the properties of the cells
_cells_capacity: (
DiscreteSpaceCapacity # Storing the remaining capacity of the cells in the grid
)
def __init__(
self,
model: "ModelDF",
capacity: int | None = None,
):
"""Create a new DiscreteSpaceDF.
Parameters
----------
model : ModelDF
The model to which the space belongs
capacity : int | None, optional
The maximum capacity for cells (default is infinite), by default None
"""
super().__init__(model)
self._capacity = capacity
def is_available(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame:
"""Check whether the input positions are available (there exists at least one remaining spot in the cells).
Parameters
----------
pos : DiscreteCoordinate | DiscreteCoordinates
The positions to check for
Returns
-------
DataFrame
A dataframe with positions and a boolean column "available"
"""
return self._check_cells(pos, "available")
def is_empty(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame:
"""Check whether the input positions are empty (there isn't any single agent in the cells).
Parameters
----------
pos : DiscreteCoordinate | DiscreteCoordinates
The positions to check for
Returns
-------
DataFrame
A dataframe with positions and a boolean column "empty"
"""
return self._check_cells(pos, "empty")
def is_full(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame:
"""Check whether the input positions are full (there isn't any spot available in the cells).
Parameters
----------
pos : DiscreteCoordinate | DiscreteCoordinates
The positions to check for
Returns
-------
DataFrame
A dataframe with positions and a boolean column "full"
"""
return self._check_cells(pos, "full")
def move_to_empty(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
obj = self._get_obj(inplace)
return obj._place_or_move_agents_to_cells(
agents, cell_type="empty", is_move=True
)
def move_to_available(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
"""Move agents to available cells/positions in the space (cells/positions where there is at least one spot available).
Parameters
----------
agents : IdsLike | AgentContainer | Collection[AgentContainer]
The agents to move to available cells/positions
inplace : bool, optional
Whether to perform the operation inplace, by default True
Returns
-------
Self
"""
obj = self._get_obj(inplace)
return obj._place_or_move_agents_to_cells(
agents, cell_type="available", is_move=True
)
def place_to_empty(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
obj = self._get_obj(inplace)
return obj._place_or_move_agents_to_cells(
agents, cell_type="empty", is_move=False
)
def place_to_available(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
inplace: bool = True,
) -> Self:
obj = self._get_obj(inplace)
return obj._place_or_move_agents_to_cells(
agents, cell_type="available", is_move=False
)
def random_pos(self, n: int) -> DataFrame | pl.DataFrame:
return self.sample_cells(n, cell_type="any", with_replacement=True)
def sample_cells(
self,
n: int,
cell_type: Literal["any", "empty", "available", "full"] = "any",
with_replacement: bool = True,
respect_capacity: bool = True,
) -> DataFrame:
"""Sample cells from the grid according to the specified cell_type.
Parameters
----------
n : int
The number of cells to sample
cell_type : Literal["any", "empty", "available", "full"], optional
The type of cells to sample, by default "any"
with_replacement : bool, optional
If the sampling should be with replacement, by default True
respect_capacity : bool, optional
If the capacity of the cells should be respected in the sampling.
This is only relevant if cell_type is "empty" or "available", by default True
Returns
-------
DataFrame
A DataFrame with the sampled cells
"""
match cell_type:
case "any":
condition = self._any_cell_condition
case "empty":
condition = self._empty_cell_condition
case "available":
condition = self._available_cell_condition
case "full":
condition = self._full_cell_condition
return self._sample_cells(
n,
with_replacement,
condition=condition,
respect_capacity=respect_capacity,
)
def set_cells(
self,
cells: DataFrame | DiscreteCoordinate | DiscreteCoordinates,
properties: DataFrame | dict[str, Any] | None = None,
inplace: bool = True,
) -> Self:
"""Set the properties of the specified cells.
This method mirrors the functionality of mesa's PropertyLayer, but allows also to set properties only of specific cells.
Either the cells DF must contain both the cells' coordinates and the properties
or the cells' coordinates can be specified separately with the cells argument.
If the Space is a Grid, the cell coordinates must be GridCoordinates.
If the Space is a Network, the cell coordinates must be NetworkCoordinates.
Parameters
----------
cells : DataFrame | DiscreteCoordinate | DiscreteCoordinates
The cells to set the properties for. It can contain the coordinates of the cells or both the coordinates and the properties.
properties : DataFrame | dict[str, Any] | None, optional
The properties of the cells, by default None if the cells argument contains the properties
inplace : bool
Whether to perform the operation inplace
Returns
-------
Self
"""
obj = self._get_obj(inplace)
# Convert cells to DataFrame
if isinstance(cells, DataFrame):
cells_df = cells
else:
cells_df = obj._get_df_coords(cells)
cells_df = obj._df_set_index(cells_df, index_name=obj._pos_col_names)
if __debug__:
if isinstance(cells_df, DataFrame) and any(
k not in obj._df_column_names(cells_df) for k in obj._pos_col_names
):
raise ValueError(
f"The cells DataFrame must have the columns {obj._pos_col_names}"
)
if properties:
cells_df = obj._df_constructor(
data=properties, index=self._df_index(cells_df, obj._pos_col_names)
)
if "capacity" in obj._df_column_names(cells_df):
obj._cells_capacity = obj._update_capacity_cells(cells_df)
obj._cells = obj._df_combine_first(
cells_df, obj._cells, index_cols=obj._pos_col_names
)
return obj
@abstractmethod
def get_neighborhood(
self,
radius: int | float | Sequence[int] | Sequence[float] | ArrayLike,
pos: DiscreteCoordinate | DiscreteCoordinates | None = None,
agents: IdsLike | AgentContainer | Collection[AgentContainer] = None,
include_center: bool = False,
) -> DataFrame:
"""Get the neighborhood cells from the given positions (pos) or agents according to the specified radiuses.
Either positions (pos) or agents must be specified, not both.
Parameters
----------
radius : int | float | Sequence[int] | Sequence[float] | ArrayLike
The radius(es) of the neighborhoods
pos : DiscreteCoordinate | DiscreteCoordinates | None, optional
The coordinates of the cell(s) to get the neighborhood from
agents : IdsLike | AgentContainer | Collection[AgentContainer], optional
The agent(s) to get the neighborhood from
include_center : bool, optional
If the cell in the center of the neighborhood should be included in the result, by default False
Returns
-------
DataFrame
A dataframe where
- Columns are called according to the coordinates of the space(['dim_0', 'dim_1', ...] in Grids, ['node_id', 'edge_id'] in Networks)
- Rows represent the coordinates of a neighboring cells
"""
...
@abstractmethod
def get_cells(
self, coords: DiscreteCoordinate | DiscreteCoordinates | None = None
) -> DataFrame:
"""Retrieve a dataframe of specified cells with their properties and agents.
Parameters
----------
coords : DiscreteCoordinate | DiscreteCoordinates | None, optional
The cells to retrieve. Default is None (all cells retrieved)
Returns
-------
DataFrame
A DataFrame with the properties of the cells and the agents placed in them.
"""
...
# We define the cell conditions here, because ruff does not allow lambda functions
def _any_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries:
return self._cells_capacity
@abstractmethod
def _empty_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries: ...
def _available_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries:
return cap > 0
def _full_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries:
return cap == 0
def _check_cells(
self,
pos: DiscreteCoordinate | DiscreteCoordinates,
state: Literal["empty", "full", "available"],
) -> DataFrame:
"""
Check the state of cells at given positions.
Parameters
----------
pos : DiscreteCoordinate | DiscreteCoordinates
The positions to check
state : Literal["empty", "full", "available"]
The state to check for ("empty", "full", or "available")
Returns
-------
DataFrame
A dataframe with positions and a boolean column indicating the state
"""
pos_df = self._get_df_coords(pos)
if state == "empty":
mask = self.empty_cells
elif state == "full":
mask = self.full_cells
elif state == "available":
mask = self.available_cells
return self._df_with_columns(
original_df=pos_df,
data=self._df_get_bool_mask(
pos_df,
index_cols=self._pos_col_names,
mask=mask,
),
new_columns=state,
)
def _place_or_move_agents_to_cells(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
cell_type: Literal["any", "empty", "available"],
is_move: bool,
) -> Self:
# Get Ids of agents
agents = self._get_ids_srs(agents)
if __debug__:
# Check ids presence in model
b_contained = self.model.agents.contains(agents)
if (isinstance(b_contained, Series) and not b_contained.all()) or (
isinstance(b_contained, bool) and not b_contained
):
raise ValueError("Some agents are not in the model")
# Get cells of specified type
cells = self.sample_cells(len(agents), cell_type=cell_type)
# Place agents
if is_move:
self.move_agents(agents, cells)
else:
self.place_agents(agents, cells)
return self
@abstractmethod
def _get_df_coords(
self,
pos: DiscreteCoordinate | DiscreteCoordinates | None = None,
agents: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
) -> DataFrame:
"""Get the DataFrame of coordinates from the specified positions or agents.
Parameters
----------
pos : DiscreteCoordinate | DiscreteCoordinates | None, optional
The positions to get the DataFrame from, by default None
agents : IdsLike | AgentContainer | Collection[AgentContainer] | None, optional
The agents to get the DataFrame from, by default None
Returns
-------
DataFrame
A dataframe where the columns are the coordinates col_names and the rows are the positions
Raises
------
ValueError
If neither pos or agents are specified
"""
...
@abstractmethod
def _sample_cells(
self,
n: int | None,
with_replacement: bool,
condition: Callable[[DiscreteSpaceCapacity], BoolSeries],
respect_capacity: bool = True,
) -> DataFrame:
"""Sample cells from the grid according to a condition on the capacity.
Parameters
----------
n : int | None
The number of cells to sample. If None, samples the maximum available.
with_replacement : bool
If the sampling should be with replacement
condition : Callable[[DiscreteSpaceCapacity], BoolSeries]
The condition to apply on the capacity
respect_capacity : bool, optional
If the capacity should be respected in the sampling.
This is only relevant if cell_type is "empty" or "available", by default True
Returns
-------
DataFrame
"""
...
@abstractmethod
def _update_capacity_cells(self, cells: DataFrame) -> DiscreteSpaceCapacity:
"""Update the cells' capacity after setting new properties.
Parameters
----------
cells : DataFrame
A DF with the cells to update the capacity and the 'capacity' column
Returns
-------
DiscreteSpaceCapacity
The updated cells' capacity
"""
...
@abstractmethod
def _update_capacity_agents(
self, agents: DataFrame, operation: Literal["movement", "removal"]
) -> DiscreteSpaceCapacity:
"""Update the cells' capacity after moving agents.
Parameters
----------
agents : DataFrame
The moved agents with their new positions
operation : Literal["movement", "removal"]
The operation that was performed on the agents
Returns
-------
DiscreteSpaceCapacity
The updated cells' capacity
"""
...
def __getitem__(self, cells: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame:
"""Get the properties and agents of the specified cells.
Parameters
----------
cells : DiscreteCoordinate | DiscreteCoordinates
The cells to get the properties for
Returns
-------
DataFrame
A DataFrame with the properties and agents of the cells
"""
return self.get_cells(cells)
def __getattr__(self, key: str) -> DataFrame:
"""Get the properties of the cells.
Parameters
----------
key : str
The property to get
Returns
-------
DataFrame
A DataFrame with the properties of the cells
"""
# Fallback, if key (property) is not found in the object,
# then it must mean that it's in the _cells dataframe
return self._cells[key]
def __setitem__(self, cells: DiscreteCoordinates, properties: DataFrame):
"""Set the properties of the specified cells.
Parameters
----------
cells : DiscreteCoordinates
The cells to set the properties for
properties : DataFrame
The properties to set
"""
self.set_cells(cells=cells, properties=properties, inplace=True)
def __repr__(self) -> str:
return f"{self.__class__.__name__}\n{str(self.cells)}"
def __str__(self) -> str:
return f"{self.__class__.__name__}\n{str(self.cells)}"
@property
def cells(self) -> DataFrame:
"""
Obtain the properties and agents of the cells in the grid.
Returns
-------
DataFrame
A Dataframe with all cells, their properties and their agents
"""
return self.get_cells()
@cells.setter
def cells(self, df: DataFrame):
return self.set_cells(df, inplace=True)
@property
def empty_cells(self) -> DataFrame:
"""Get the empty cells (cells without any agent) in the grid.
Returns
-------
DataFrame
A DataFrame with the empty cells
"""
return self._sample_cells(
None, with_replacement=False, condition=self._empty_cell_condition
)
@property
def available_cells(self) -> DataFrame:
"""Get the available cells (cells with at least one spot available) in the grid.
Returns
-------
DataFrame
A DataFrame with the available cells
"""
return self._sample_cells(
None, with_replacement=False, condition=self._available_cell_condition
)
@property
def full_cells(self) -> DataFrame:
"""Get the full cells (cells without any spot available) in the grid.
Returns
-------
DataFrame
A DataFrame with the full cells
"""
return self._sample_cells(
None, with_replacement=False, condition=self._full_cell_condition
)
@property
@abstractmethod
def remaining_capacity(self) -> int | None:
"""The remaining capacity of the cells in the grid.
Returns
-------
int | None
None if the capacity is infinite, otherwise the remaining capacity
"""
...
class GridDF(DiscreteSpaceDF):
"""The GridDF class is an abstract class that defines the interface for all grid classes in mesa-frames.
Inherits from DiscreteSpaceDF.
Warning
-------
For rectangular grids:
In this implementation, [0, ..., 0] is the bottom-left corner and
[dimensions[0]-1, ..., dimensions[n-1]-1] is the top-right corner, consistent with
Cartesian coordinates and Matplotlib/Seaborn plot outputs.
The convention is different from `np.genfromtxt`_ and its use in the
`mesa-examples Sugarscape model`_, where [0, ..., 0] is the top-left corner
and [dimensions[0]-1, ..., dimensions[n-1]-1] is the bottom-right corner.
For hexagonal grids:
The coordinates are ordered according to the axial coordinate system.
In this system, the hexagonal grid uses two axes (q and r) at 60 degrees to each other.
The q-axis points to the right, and the r-axis points up and to the right.
The [0, 0] coordinate is at the bottom-left corner of the grid.
.. _np.genfromtxt: https://numpy.org/doc/stable/reference/generated/numpy.genfromtxt.html
.. _mesa-examples Sugarscape model: https://github.com/projectmesa/mesa-examples/blob/e137a60e4e2f2546901bec497e79c4a7b0cc69bb/examples/sugarscape_g1mt/sugarscape_g1mt/model.py#L93-L94
"""
_cells_capacity: (
GridCapacity # Storing the remaining capacity of the cells in the grid
)
_neighborhood_type: Literal[
"moore", "von_neumann", "hexagonal"
] # The type of neighborhood to consider
_offsets: DataFrame # The offsets to compute the neighborhood of a cell
_torus: bool # If the grid is a torus
def __init__(
self,
model: "ModelDF",
dimensions: Sequence[int],
torus: bool = False,
capacity: int | None = None,
neighborhood_type: str = "moore",
):
"""Create a new GridDF.
Parameters
----------
model : ModelDF
The model to which the space belongs
dimensions : Sequence[int]
The dimensions of the grid
torus : bool, optional
If the grid is a torus, by default False
capacity : int | None, optional
The maximum capacity for cells (default is infinite), by default None
neighborhood_type : str, optional
The type of neighborhood to consider, by default "moore"
"""
super().__init__(model, capacity)
self._dimensions = dimensions
self._torus = torus
self._pos_col_names = [f"dim_{k}" for k in range(len(dimensions))]
self._center_col_names = [x + "_center" for x in self._pos_col_names]
self._agents = self._df_constructor(
columns=["agent_id"] + self._pos_col_names,
index_cols="agent_id",
dtypes={col: int for col in ["agent_id"] + self._pos_col_names},
)
cells_df_dtypes = {col: int for col in self._pos_col_names}
cells_df_dtypes.update(
{"capacity": float} # Capacity can be float if we want to represent np.nan
)
self._cells = self._df_constructor(
columns=self._pos_col_names + ["capacity"],
index_cols=self._pos_col_names,
dtypes=cells_df_dtypes,
)
self._offsets = self._compute_offsets(neighborhood_type)
self._cells_capacity = self._generate_empty_grid(dimensions, capacity)
self._neighborhood_type = neighborhood_type
def get_directions(
self,
pos0: GridCoordinate | GridCoordinates | None = None,
pos1: GridCoordinate | GridCoordinates | None = None,
agents0: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
agents1: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
normalize: bool = False,
) -> DataFrame:
result = self._calculate_differences(pos0, pos1, agents0, agents1)
if normalize:
result = self._df_div(result, other=self._df_norm(result))
return result
def get_distances(
self,
pos0: GridCoordinate | GridCoordinates | None = None,
pos1: GridCoordinate | GridCoordinates | None = None,
agents0: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
agents1: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
) -> DataFrame:
result = self._calculate_differences(pos0, pos1, agents0, agents1)
return self._df_norm(result, "distance", True)
def get_neighbors(
self,
radius: int | Sequence[int],
pos: GridCoordinate | GridCoordinates | None = None,
agents: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
include_center: bool = False,
) -> DataFrame:
neighborhood_df = self.get_neighborhood(
radius=radius, pos=pos, agents=agents, include_center=include_center
)
return self._df_get_masked_df(
df=self._agents,
index_cols=self._pos_col_names,
mask=neighborhood_df,
)
def get_neighborhood(
self,
radius: int | Sequence[int] | ArrayLike,
pos: GridCoordinate | GridCoordinates | None = None,
agents: IdsLike | AgentContainer | Collection[AgentContainer] = None,
include_center: bool = False,
) -> DataFrame:
pos_df = self._get_df_coords(pos, agents)
if __debug__:
if isinstance(radius, ArrayLike):
if len(radius) != len(pos_df):
raise ValueError(
"The length of the radius sequence must be equal to the number of positions/agents"
)
## Create all possible neighbors by multiplying offsets by the radius and adding original pos
# If radius is a sequence, get the maximum radius (we will drop unnecessary neighbors later, time-efficient but memory-inefficient)
if isinstance(radius, ArrayLike):
radius_srs = self._srs_constructor(radius, name="radius")
radius_df = self._srs_to_df(radius_srs)
max_radius = radius_srs.max()
else:
max_radius = radius
range_df = self._srs_to_df(
self._srs_range(name="radius", start=1, end=max_radius + 1)
)
neighbors_df = self._df_join(
self._offsets,
range_df,
how="cross",
)
neighbors_df = self._df_with_columns(
neighbors_df,
data=self._df_mul(
neighbors_df[self._pos_col_names], neighbors_df["radius"]
),
new_columns=self._pos_col_names,
)
if self.neighborhood_type == "hexagonal":
# We need to add in-between cells for hexagonal grids
# In-between offsets (for every radius k>=2, we need k-1 in-between cells)
in_between_cols = ["in_between_dim_0", "in_between_dim_1"]
radius_srs = self._srs_constructor(
np.repeat(np.arange(1, max_radius + 1), np.arange(0, max_radius)),
name="radius",
)
radius_df = self._srs_to_df(radius_srs)
radius_df = self._df_with_columns(
radius_df,
self._df_groupby_cumcount(radius_df, "radius", name="offset"),
new_columns="offset",
)
in_between_df = self._df_join(
self._in_between_offsets,
radius_df,
how="cross",
)
# We multiply the radius to get the directional cells
in_between_df = self._df_with_columns(
in_between_df,
data=self._df_mul(
in_between_df[self._pos_col_names], in_between_df["radius"]
),
new_columns=self._pos_col_names,
)
# We multiply the offset (from the directional cells) to get the in-between offset for each radius
in_between_df = self._df_with_columns(
in_between_df,
data=self._df_mul(
in_between_df[in_between_cols], in_between_df["offset"]
),
new_columns=in_between_cols,
)
# We add the in-between offset to the directional cells to obtain the in-between cells
in_between_df = self._df_with_columns(
in_between_df,
data=self._df_add(
in_between_df[self._pos_col_names],
self._df_rename_columns(
in_between_df[in_between_cols],
in_between_cols,
self._pos_col_names,
),
),
new_columns=self._pos_col_names,
)
in_between_df = self._df_drop_columns(
in_between_df, in_between_cols + ["offset"]
)
neighbors_df = self._df_concat(
[neighbors_df, in_between_df], how="vertical"
)
radius_df = self._df_drop_columns(radius_df, "offset")
neighbors_df = self._df_join(
neighbors_df, pos_df, how="cross", suffix="_center"
)
center_df = self._df_rename_columns(
neighbors_df[self._center_col_names],
self._center_col_names,
self._pos_col_names,
) # We rename the columns to the original names for the addition
neighbors_df = self._df_with_columns(
original_df=neighbors_df,
new_columns=self._pos_col_names,
data=self._df_add(
neighbors_df[self._pos_col_names],
center_df,
),
)
# If radius is a sequence, filter unnecessary neighbors
if isinstance(radius, ArrayLike):
radius_df = self._df_rename_columns(
self._df_concat([pos_df, radius_df], how="horizontal"),
self._pos_col_names + ["radius"],
self._center_col_names + ["max_radius"],
)
neighbors_df = self._df_join(
neighbors_df,
radius_df,
on=self._center_col_names,
)
neighbors_df = self._df_get_masked_df(
neighbors_df, mask=neighbors_df["radius"] <= neighbors_df["max_radius"]
)
neighbors_df = self._df_drop_columns(neighbors_df, "max_radius")
# If torus, "normalize" (take modulo) for out-of-bounds cells
if self._torus:
neighbors_df = self._df_with_columns(
neighbors_df,
data=self.torus_adj(neighbors_df[self._pos_col_names]),
new_columns=self._pos_col_names,
)
# Remove duplicates
neighbors_df = self._df_drop_duplicates(neighbors_df, self._pos_col_names)
# Filter out-of-bound neighbors
mask = self._df_all(
self._df_and(
self._df_lt(
neighbors_df[self._pos_col_names], self._dimensions, axis="columns"
),
neighbors_df >= 0,
)
)
neighbors_df = self._df_get_masked_df(neighbors_df, mask=mask)
if include_center:
center_df = self._df_rename_columns(
pos_df, self._pos_col_names, self._center_col_names
)
pos_df = self._df_with_columns(
pos_df,
data=0,
new_columns="radius",
)
pos_df = self._df_concat([pos_df, center_df], how="horizontal")
neighbors_df = self._df_concat(
[pos_df, neighbors_df], how="vertical", ignore_index=True
)
return neighbors_df
def get_cells(
self, coords: GridCoordinate | GridCoordinates | None = None
) -> DataFrame:
# TODO : Consider whether not outputting the agents at all (fastest),
# outputting a single agent per cell (current)
# or outputting all agents per cell in a imploded list (slowest, https://stackoverflow.com/a/66018377)
if not coords:
cells_df = self._cells
else:
coords_df = self._get_df_coords(pos=coords)
cells_df = self._df_get_masked_df(
df=self._cells, index_cols=self._pos_col_names, mask=coords_df
)
return self._df_join(
left=cells_df,
right=self._agents,
index_cols=self._pos_col_names,
on=self._pos_col_names,
)
def out_of_bounds(self, pos: GridCoordinate | GridCoordinates) -> DataFrame:
"""Check if a position is out of bounds in a non-toroidal grid.
Parameters
----------
pos : GridCoordinate | GridCoordinates
The position to check
Returns
-------
DataFrame
A DataFrame with the coordinates and an 'out_of_bounds' containing boolean values.
Raises
------
ValueError
If the grid is a torus
"""
if self.torus:
raise ValueError("This method is only valid for non-torus grids")
pos_df = self._get_df_coords(pos, check_bounds=False)
out_of_bounds = self._df_all(
self._df_or(
pos_df < 0,
self._df_ge(
pos_df,
self._dimensions,
axis="columns",
index_cols=self._pos_col_names,
),
),
name="out_of_bounds",
)
return self._df_concat(
objs=[pos_df, self._srs_to_df(out_of_bounds)], how="horizontal"
)
def remove_agents(
self,
agents: AgentContainer | Collection[AgentContainer] | int | Sequence[int],
inplace: bool = True,
) -> Self:
obj = self._get_obj(inplace)
agents = obj._get_ids_srs(agents)
if __debug__:
# Check ids presence in model
b_contained = obj.model.agents.contains(agents)
if (isinstance(b_contained, Series) and not b_contained.all()) or (
isinstance(b_contained, bool) and not b_contained
):
raise ValueError("Some agents are not in the model")
# Remove agents
obj._cells_capacity = obj._update_capacity_agents(agents, operation="removal")
obj._agents = obj._df_remove(obj._agents, mask=agents, index_cols="agent_id")
return obj
def torus_adj(self, pos: GridCoordinate | GridCoordinates) -> DataFrame:
"""Get the toroidal adjusted coordinates of a position.
Parameters
----------
pos : GridCoordinate | GridCoordinates
The coordinates to adjust
Returns
-------
DataFrame
The adjusted coordinates
"""
df_coords = self._get_df_coords(pos)
df_coords = self._df_mod(df_coords, self._dimensions, axis="columns")
return df_coords
def _calculate_differences(
self,
pos0: GridCoordinate | GridCoordinates | None,
pos1: GridCoordinate | GridCoordinates | None,
agents0: IdsLike | AgentContainer | Collection[AgentContainer] | None,
agents1: IdsLike | AgentContainer | Collection[AgentContainer] | None,
) -> DataFrame:
"""Calculate the differences between two positions or agents.
Parameters
----------
pos0 : GridCoordinate | GridCoordinates | None
The starting positions
pos1 : GridCoordinate | GridCoordinates | None
The ending positions
agents0 : IdsLike | AgentContainer | Collection[AgentContainer] | None
The starting agents
agents1 : IdsLike | AgentContainer | Collection[AgentContainer] | None
The ending agents
Returns
-------
DataFrame
Raises
------
ValueError
If objects do not have the same length
"""
pos0_df = self._get_df_coords(pos0, agents0)
pos1_df = self._get_df_coords(pos1, agents1)
if __debug__ and len(pos0_df) != len(pos1_df):
raise ValueError("objects must have the same length")
return pos1_df - pos0_df
def _compute_offsets(self, neighborhood_type: str) -> DataFrame:
"""Generate offsets for the neighborhood.
Parameters
----------
neighborhood_type : str
The type of neighborhood to consider
Returns
-------
DataFrame
A DataFrame with the offsets
Raises
------
ValueError
If the neighborhood type is invalid
ValueError
If the grid has more than 2 dimensions and the neighborhood type is 'hexagonal'
"""
if neighborhood_type == "moore":
ranges = [range(-1, 2) for _ in self._dimensions]
directions = [d for d in product(*ranges) if any(d)]
elif neighborhood_type == "von_neumann":
ranges = [range(-1, 2) for _ in self._dimensions]
directions = [
d for d in product(*ranges) if sum(map(abs, d)) <= 1 and any(d)
]
elif neighborhood_type == "hexagonal":
if __debug__ and len(self._dimensions) > 2:
raise ValueError(
"Hexagonal neighborhood is only valid for 2-dimensional grids"
)
directions = [
(1, 0), # East
(1, -1), # South-West
(0, -1), # South-East
(-1, 0), # West
(-1, 1), # North-West
(0, 1), # North-East
]
in_between = [
(-1, -1), # East -> South-East
(0, 1), # South-West -> West
(-1, 0), # South-East -> South-West
(1, 1), # West -> North-West
(1, 0), # North-West -> North-East
(0, -1), # North-East -> East
]
df = self._df_constructor(data=directions, columns=self._pos_col_names)
self._in_between_offsets = self._df_with_columns(
df,
data=in_between,
new_columns=["in_between_dim_0", "in_between_dim_1"],
)
return df
else:
raise ValueError("Invalid neighborhood type specified")
return self._df_constructor(data=directions, columns=self._pos_col_names)
def _get_df_coords(
self,
pos: GridCoordinate | GridCoordinates | None = None,
agents: IdsLike | AgentContainer | Collection[AgentContainer] | None = None,
check_bounds: bool = True,
) -> DataFrame:
"""Get the DataFrame of coordinates from the specified positions or agents.
Parameters
----------
pos : GridCoordinate | GridCoordinates | None, optional
The positions to get the DataFrame from, by default None
agents : IdsLike | AgentContainer | Collection[AgentContainer] | None, optional
The agents to get the DataFrame from, by default None
check_bounds: bool, optional
If the positions should be checked for out-of-bounds in non-toroidal grids, by default True
Returns
-------
DataFrame
A dataframe where the columns are "dim_0, dim_1, ..." and the rows are the coordinates
Raises
------
ValueError
If neither pos or agents are specified
"""
if __debug__:
if pos is None and agents is None:
raise ValueError("Neither pos or agents are specified")
elif pos is not None and agents is not None:
raise ValueError("Both pos and agents are specified")
# If the grid is non-toroidal, we have to check whether any position is out of bounds
if not self.torus and pos is not None and check_bounds:
pos = self.out_of_bounds(pos)
if pos["out_of_bounds"].any():
raise ValueError(
"If the grid is non-toroidal, every position must be in-bound"
)
if agents is not None:
agents = self._get_ids_srs(agents)
# Check ids presence in model
b_contained = self.model.agents.contains(agents)
if (isinstance(b_contained, Series) and not b_contained.all()) or (
isinstance(b_contained, bool) and not b_contained
):
raise ValueError("Some agents are not present in the model")
# Check ids presence in the grid
b_contained = self._df_contains(self._agents, "agent_id", agents)
if (isinstance(b_contained, Series) and not b_contained.all()) or (
isinstance(b_contained, bool) and not b_contained
):
raise ValueError("Some agents are not placed in the grid")
# Check ids are unique
agents = pl.Series(agents)
if agents.n_unique() != len(agents):
raise ValueError("Some agents are present multiple times")
if agents is not None:
df = self._df_get_masked_df(
self._agents, index_cols="agent_id", mask=agents
)
df = self._df_reindex(df, agents, "agent_id")
return self._df_reset_index(df, index_cols="agent_id", drop=True)
if isinstance(pos, DataFrame):
return self._df_reset_index(pos[self._pos_col_names], drop=True)
elif (
isinstance(pos, Collection)
and isinstance(pos[0], Collection)
and (len(pos[0]) == len(self._dimensions))
): # We only test the first coordinate for performance
# This means that we have a collection of coordinates
return self._df_constructor(
data=pos,
columns=self._pos_col_names,
dtypes={col: int for col in self._pos_col_names},
)
elif isinstance(pos, ArrayLike) and len(pos) == len(self._dimensions):
# This means that the sequence is already a sequence where each element is the
# sequence of coordinates for dimension i
for i, c in enumerate(pos):
if isinstance(c, slice):
start = c.start if c.start is not None else 0
step = c.step if c.step is not None else 1
stop = c.stop if c.stop is not None else self._dimensions[i]
pos[i] = self._srs_range(start=start, stop=stop, step=step)
return self._df_constructor(
data=[pos],
columns=self._pos_col_names,
dtypes={col: int for col in self._pos_col_names},
)
elif isinstance(pos, int) and len(self._dimensions) == 1:
return self._df_constructor(
data=[pos],
columns=self._pos_col_names,
dtypes={col: int for col in self._pos_col_names},
)
else:
raise ValueError("Invalid coordinates")
def _place_or_move_agents(
self,
agents: IdsLike | AgentContainer | Collection[AgentContainer],
pos: GridCoordinate | GridCoordinates,
is_move: bool,
) -> Self:
agents = self._get_ids_srs(agents)
if __debug__:
# Warn if agents are already placed
if is_move:
if not self._df_contains(self._agents, "agent_id", agents).all():
warn("Some agents are not present in the grid", RuntimeWarning)
else: # is "place"
if self._df_contains(self._agents, "agent_id", agents).any():
warn("Some agents are already present in the grid", RuntimeWarning)
# Check if agents are present in the model
b_contained = self.model.agents.contains(agents)
if (isinstance(b_contained, Series) and not b_contained.all()) or (
isinstance(b_contained, bool) and not b_contained
):
raise ValueError("Some agents are not present in the model")
# Check if there is enough capacity
if self._capacity:
# If len(agents) > remaining_capacity + len(agents that will move)
if len(agents) > self.remaining_capacity + len(
self._df_get_masked_df(
self._agents,
index_cols="agent_id",
mask=agents,
)
):
raise ValueError("Not enough capacity in the space for all agents")
# Place or move agents (checking that capacity is respected)
pos_df = self._get_df_coords(pos)
agents_df = self._srs_to_df(agents)
if __debug__:
if len(agents_df) != len(pos_df):
raise ValueError("The number of agents and positions must be equal")
new_df = self._df_concat(
[agents_df, pos_df], how="horizontal", index_cols="agent_id"
)
self._cells_capacity = self._update_capacity_agents(
new_df, operation="movement"
)
self._agents = self._df_combine_first(
new_df, self._agents, index_cols="agent_id"
)
return self
@abstractmethod
def _generate_empty_grid(
self, dimensions: Sequence[int], capacity: int
) -> GridCapacity:
"""Generate an empty grid with the specified dimensions and capacity.
Parameters
----------
dimensions : Sequence[int]
The dimensions of the grid
capacity : int
The capacity of the grid
Returns
-------
GridCapacity
"""
...
@property
def dimensions(self) -> Sequence[int]:
"""The dimensions of the grid.
They are set uniquely at the creation of the grid.
Returns
-------
Sequence[int]
The dimensions of the grid
"""
return self._dimensions
@property
def neighborhood_type(self) -> Literal["moore", "von_neumann", "hexagonal"]:
"""The type of neighborhood to consider (moore, von_neumann, hexagonal).
It is set uniquely at the creation of the grid.
Returns
-------
Literal['moore', 'von_neumann', 'hexagonal']
"""
return self._neighborhood_type
@property
def torus(self) -> bool:
"""If the grid is a torus (wraps around at the edges).
Can be set uniquely at the creation of the grid.
Returns
-------
bool
Whether the grid is a torus
"""
return self._torus