From f3c2f43f8aec288d7a08770bd54488e011066a7d Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:29:53 +0200 Subject: [PATCH 01/21] addind conversion to geobjects --- mesa_frames/abstract/agents.py | 24 +++++++- mesa_frames/concrete/agents.py | 13 +++-- mesa_frames/concrete/agentset_pandas.py | 22 ++++++- mesa_frames/concrete/agentset_polars.py | 12 +++- mesa_frames/concrete/model.py | 13 +++++ mesa_frames/concrete/space.py | 76 +++++++++++++++++++++++++ mesa_frames/{types.py => types_.py} | 36 +++++++++++- pyproject.toml | 6 ++ 8 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 mesa_frames/concrete/space.py rename mesa_frames/{types.py => types_.py} (50%) diff --git a/mesa_frames/abstract/agents.py b/mesa_frames/abstract/agents.py index 4217648..299aa2a 100644 --- a/mesa_frames/abstract/agents.py +++ b/mesa_frames/abstract/agents.py @@ -9,11 +9,12 @@ from typing_extensions import Any, Self, overload from mesa_frames.abstract.mixin import CopyMixin -from mesa_frames.types import BoolSeries, DataFrame, IdsLike, Index, MaskLike, Series +from mesa_frames.types_ import BoolSeries, DataFrame, IdsLike, Index, MaskLike, Series if TYPE_CHECKING: from mesa_frames.concrete.agents import AgentSetDF from mesa_frames.concrete.model import ModelDF + from mesa_frames.concrete.space import SpaceDF class AgentContainer(CopyMixin): @@ -359,6 +360,23 @@ def sort( A new or updated AgentContainer. """ + @abstractmethod + def _convert_to_geobject(self, space: SpaceDF, inplace: bool = True) -> Self: + """Converts the DataFrame(s) of AgentContainer to GeoDataFrame(s). + + Parameters + ---------- + space : SpaceDF + The space to add to the AgentContainer. Determines the geometry type. + inplace : bool + Whether to add the space column in place. + + Returns + ------- + Self + """ + ... + def __add__(self, other) -> Self: return self.add(agents=other, inplace=False) @@ -735,7 +753,9 @@ def __init__(self, model: ModelDF) -> None: @abstractmethod def add( - self, agents: DataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True + self, + agents: DataFrame | Sequence[Any] | dict[str, Any], + inplace: bool = True, ) -> Self: """Add agents to the AgentSetDF diff --git a/mesa_frames/concrete/agents.py b/mesa_frames/concrete/agents.py index 14891b8..fec000d 100644 --- a/mesa_frames/concrete/agents.py +++ b/mesa_frames/concrete/agents.py @@ -1,14 +1,13 @@ from collections import defaultdict from collections.abc import Callable, Collection, Iterable, Iterator, Sequence -from typing import Literal, cast +from typing import TYPE_CHECKING, Literal, cast import polars as pl from typing_extensions import Any, Self, overload -from typing import TYPE_CHECKING - from mesa_frames.abstract.agents import AgentContainer, AgentSetDF -from mesa_frames.types import ( +from mesa_frames.concrete.space import SpaceDF +from mesa_frames.types_ import ( AgnosticMask, BoolSeries, DataFrame, @@ -319,6 +318,12 @@ def sort( ] return obj + def _convert_to_geobject(self, space: SpaceDF, inplace: bool = True) -> Self: + obj = self._get_obj(inplace) + for agentset in obj._agentsets: + agentset._convert_to_geobject(space, inplace=True) + return obj + def _check_ids_presence(self, other: list[AgentSetDF]) -> pl.DataFrame: """Check if the IDs of the agents to be added are unique. diff --git a/mesa_frames/concrete/agentset_pandas.py b/mesa_frames/concrete/agentset_pandas.py index 8f4ce4d..848e247 100644 --- a/mesa_frames/concrete/agentset_pandas.py +++ b/mesa_frames/concrete/agentset_pandas.py @@ -1,17 +1,20 @@ from collections.abc import Callable, Collection, Iterable, Iterator, Sequence from typing import TYPE_CHECKING +import geopandas as gpd import pandas as pd import polars as pl from typing_extensions import Any, Self, overload from mesa_frames.abstract.agents import AgentSetDF from mesa_frames.concrete.agentset_polars import AgentSetPolars -from mesa_frames.types import PandasIdsLike, PandasMaskLike +from mesa_frames.types_ import PandasIdsLike, PandasMaskLike if TYPE_CHECKING: from mesa_frames.concrete.model import ModelDF +from mesa_frames.concrete.space import SpaceDF + class AgentSetPandas(AgentSetDF): _agents: pd.DataFrame @@ -108,11 +111,18 @@ def __init__(self, model: "ModelDF") -> None: def add( self, - agents: pd.DataFrame | Sequence[Any] | dict[str, Any], + agents: pd.DataFrame | gpd.GeoDataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True, ) -> Self: obj = self._get_obj(inplace) - if isinstance(agents, pd.DataFrame): + if isinstance(agents, gpd.GeoDataFrame): + try: + self.model.space + except ValueError: + raise ValueError( + "You are adding agents with a GeoDataFrame but haven't set model.space. Set it before adding agents with a GeoDataFrame or add agents with a standard DataFrame" + ) + if isinstance(agents, (pd.DataFrame, gpd.GeoDataFrame)): new_agents = agents if "unique_id" != agents.index.name: try: @@ -312,6 +322,12 @@ def _concatenate_agentsets( self.remove(ids_to_remove, inplace=True) return self + def _convert_to_geobject(self, space: SpaceDF, inplace: bool = True) -> Self: + obj = self._get_obj(inplace) + + obj._agents = gpd.GeoDataFrame(obj._agents, geometry=None) + return obj + def _get_bool_mask( self, mask: PandasMaskLike = None, diff --git a/mesa_frames/concrete/agentset_polars.py b/mesa_frames/concrete/agentset_polars.py index 358a310..f7f6dff 100644 --- a/mesa_frames/concrete/agentset_polars.py +++ b/mesa_frames/concrete/agentset_polars.py @@ -1,12 +1,13 @@ from collections.abc import Callable, Collection, Iterable, Iterator, Sequence from typing import TYPE_CHECKING +import geopolars as gpl import polars as pl from polars._typing import IntoExpr from typing_extensions import Any, Self, overload from mesa_frames.concrete.agents import AgentSetDF -from mesa_frames.types import PolarsIdsLike, PolarsMaskLike +from mesa_frames.types_ import PolarsIdsLike, PolarsMaskLike if TYPE_CHECKING: from mesa_frames.concrete.agentset_pandas import AgentSetPandas @@ -136,7 +137,14 @@ def add( The updated AgentSetPolars. """ obj = self._get_obj(inplace) - if isinstance(agents, pl.DataFrame): + if isinstance(agents, gpl.GeoDataFrame): + try: + self.model.space + except ValueError: + raise ValueError( + "You are adding agents with a GeoDataFrame but haven't set model.space. Set it before adding agents with a GeoDataFrame or add agents with a standard DataFrame" + ) + if isinstance(agents, gpl.GeoDataFrame, pl.DataFrame): if "unique_id" not in agents.columns: raise KeyError("DataFrame must have a unique_id column.") new_agents = agents diff --git a/mesa_frames/concrete/model.py b/mesa_frames/concrete/model.py index be4ef43..3bea3ce 100644 --- a/mesa_frames/concrete/model.py +++ b/mesa_frames/concrete/model.py @@ -5,6 +5,7 @@ from typing_extensions import Any from mesa_frames.concrete.agents import AgentsDF +from mesa_frames.concrete.space import SpaceDF if TYPE_CHECKING: from mesa_frames.abstract.agents import AgentSetDF @@ -59,6 +60,7 @@ class ModelDF: _seed: int | Sequence[int] running: bool _agents: AgentsDF + _space: SpaceDF | None def __new__( cls, seed: int | Sequence[int] | None = None, *args: Any, **kwargs: Any @@ -147,3 +149,14 @@ def agents(self, agents: AgentsDF) -> None: @property def agent_types(self) -> list[type]: return [agent.__class__ for agent in self._agents._agentsets] + + @property + def space(self) -> SpaceDF: + if not self._space: + raise ValueError("You haven't set the space for the model. Use model.space = your_space") + return self._space + + @space.setter + def space(self, space: SpaceDF) -> None: + self._space = space + self._agents._convert_to_geobject(self._space, inplace=True) diff --git a/mesa_frames/concrete/space.py b/mesa_frames/concrete/space.py new file mode 100644 index 0000000..ff6138b --- /dev/null +++ b/mesa_frames/concrete/space.py @@ -0,0 +1,76 @@ +""" +Mesa Frames Space Module +================= + +Objects used to add a spatial component to a model. + +Grid: base grid, which creates a rectangular grid. +SingleGrid: extension to Grid which strictly enforces one agent per cell. +MultiGrid: extension to Grid where each cell can contain a set of agents. +HexGrid: extension to Grid to handle hexagonal neighbors. +ContinuousSpace: a two-dimensional space where each agent has an arbitrary + position of `float`'s. +NetworkGrid: a network where each node contains zero or more agents. +""" + +from mesa_frames.abstract.agents import AgentContainer +from mesa_frames.types_ import IdsLike, PositionsLike + + +class SpaceDF: + def _check_empty_pos(pos: PositionsLike) -> bool: + """Check if the given positions are empty. + + Parameters + ---------- + pos : DataFrame | tuple[Series, Series] | Series + Input positions to check. + + Returns + ------- + Series[bool] + Whether + """ + + +class SingleGrid(SpaceDF): + """Rectangular grid where each cell contains exactly at most one agent. + + Grid cells are indexed by [x, y], where [0, 0] is assumed to be the + bottom-left and [width-1, height-1] is the top-right. If a grid is + toroidal, the top and bottom, and left and right, edges wrap to each other. + + This class provides a property `empties` that returns a set of coordinates + for all empty cells in the grid. It is automatically updated whenever + agents are added or removed from the grid. The `empties` property should be + used for efficient access to current empty cells rather than manually + iterating over the grid to check for emptiness. + + """ + + def place_agents(self, agents: IdsLike | AgentContainer, pos: PositionsLike): + """Place agents on the grid at the coordinates specified in pos. + NOTE: The cells must be empty. + + + Parameters + ---------- + agents : IdsLike | AgentContainer + + pos : DataFrame | tuple[Series, Series] + _description_ + """ + + def _check_empty_pos(pos: PositionsLike) -> bool: + """Check if the given positions are empty. + + Parameters + ---------- + pos : DataFrame | tuple[Series, Series] + Input positions to check. + + Returns + ------- + bool + _description_ + """ diff --git a/mesa_frames/types.py b/mesa_frames/types_.py similarity index 50% rename from mesa_frames/types.py rename to mesa_frames/types_.py index 232aa0f..a2c273c 100644 --- a/mesa_frames/types.py +++ b/mesa_frames/types_.py @@ -1,9 +1,11 @@ from collections.abc import Collection -from typing import Literal +import geopandas as gpd +import geopolars as gpl import pandas as pd import polars as pl from numpy import ndarray +from typing_extensions import Literal, Sequence ####----- Agnostic Types -----#### AgnosticMask = Literal["all", "active"] | None @@ -23,10 +25,38 @@ ###----- Generic -----### -DataFrame = pd.DataFrame | pl.DataFrame -Series = pd.Series | pl.Series +GeoDataFame = gpd.GeoDataFrame | gpl.GeoDataFrame +GeoSeries = gpd.GeoSeries | gpl.GeoSeries +DataFrame = pd.DataFrame | pl.DataFrame | GeoDataFame +Series = pd.Series | pl.Series | GeoSeries Index = pd.Index | pl.Series BoolSeries = pd.Series | pl.Series MaskLike = AgnosticMask | PandasMaskLike | PolarsMaskLike IdsLike = AgnosticIds | PandasIdsLike | PolarsIdsLike + + +###----- Time ------### TimeT = float | int + + +###----- Space -----### +Coordinates = tuple[int, int] | tuple[float, float] +Node_ID = int +AgnosticPositionsLike = ( + Sequence[Coordinates] | Sequence[Node_ID] | Coordinates | Node_ID +) +PolarsPositionsLike = ( + AgnosticPositionsLike + | pl.DataFrame + | tuple[pl.Series, pl.Series] + | gpl.GeoSeries + | pl.Series +) +PandasPositionsLike = ( + AgnosticPositionsLike + | pd.DataFrame + | tuple[pd.Series, pd.Series] + | gpd.GeoSeries + | pd.Series +) +PositionsLike = AgnosticPositionsLike | PolarsPositionsLike | PandasPositionsLike diff --git a/pyproject.toml b/pyproject.toml index b9c5d27..ece6ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,10 +19,13 @@ dependencies = [ pandas = [ "pandas~=2.2", "pyarrow", + "geopandas" ] polars = [ "polars>=1.0.0", #polars._typing (see mesa_frames.types) added in 1.0.0 + "geopolars" ] + dev = [ "mesa_frames[pandas,polars]", "pytest", @@ -31,5 +34,8 @@ dev = [ "mesa", ] +[tool.hatch.envs.dev] +features = ["dev"] + [tool.hatch.build.targets.wheel] packages = ["mesa_frames"] From 9c2b281eef345874aca87f4e1ab71fc34e379a74 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 00:08:04 +0200 Subject: [PATCH 02/21] change from types to types_ to avoid import issues --- mesa_frames/abstract/agents.py | 2 +- mesa_frames/concrete/agents.py | 6 +- mesa_frames/concrete/pandas/agentset.py | 439 ++++++++++++++++++++++ mesa_frames/concrete/polars/agentset.py | 472 ++++++++++++++++++++++++ mesa_frames/types.py | 32 -- mesa_frames/types_.py | 73 ++++ tests/test_agents.py | 2 +- 7 files changed, 988 insertions(+), 38 deletions(-) create mode 100644 mesa_frames/concrete/pandas/agentset.py create mode 100644 mesa_frames/concrete/polars/agentset.py delete mode 100644 mesa_frames/types.py create mode 100644 mesa_frames/types_.py diff --git a/mesa_frames/abstract/agents.py b/mesa_frames/abstract/agents.py index 4217648..93baaaa 100644 --- a/mesa_frames/abstract/agents.py +++ b/mesa_frames/abstract/agents.py @@ -9,7 +9,7 @@ from typing_extensions import Any, Self, overload from mesa_frames.abstract.mixin import CopyMixin -from mesa_frames.types import BoolSeries, DataFrame, IdsLike, Index, MaskLike, Series +from mesa_frames.types_ import BoolSeries, DataFrame, IdsLike, Index, MaskLike, Series if TYPE_CHECKING: from mesa_frames.concrete.agents import AgentSetDF diff --git a/mesa_frames/concrete/agents.py b/mesa_frames/concrete/agents.py index 14891b8..3f8530e 100644 --- a/mesa_frames/concrete/agents.py +++ b/mesa_frames/concrete/agents.py @@ -1,14 +1,12 @@ from collections import defaultdict from collections.abc import Callable, Collection, Iterable, Iterator, Sequence -from typing import Literal, cast +from typing import TYPE_CHECKING, Literal, cast import polars as pl from typing_extensions import Any, Self, overload -from typing import TYPE_CHECKING - from mesa_frames.abstract.agents import AgentContainer, AgentSetDF -from mesa_frames.types import ( +from mesa_frames.types_ import ( AgnosticMask, BoolSeries, DataFrame, diff --git a/mesa_frames/concrete/pandas/agentset.py b/mesa_frames/concrete/pandas/agentset.py new file mode 100644 index 0000000..14b45a0 --- /dev/null +++ b/mesa_frames/concrete/pandas/agentset.py @@ -0,0 +1,439 @@ +from collections.abc import Callable, Collection, Iterable, Iterator, Sequence +from typing import TYPE_CHECKING + +import geopandas as gpd +import pandas as pd +import polars as pl +from typing_extensions import Any, Self, overload + +from mesa_frames.abstract.agents import AgentSetDF +from mesa_frames.concrete.pandas.mixin import PandasMixin +from mesa_frames.concrete.polars.agentset import AgentSetPolars +from mesa_frames.types_ import PandasIdsLike, PandasMaskLike + +if TYPE_CHECKING: + pass + + +class AgentSetPandas(AgentSetDF, PandasMixin): + _agents: pd.DataFrame + _mask: pd.Series + _copy_with_method: dict[str, tuple[str, list[str]]] = { + "_agents": ("copy", ["deep"]), + "_mask": ("copy", ["deep"]), + } + """A pandas-based implementation of the AgentSet. + + Attributes + ---------- + _agents : pd.DataFrame + The agents in the AgentSet. + _copy_only_reference : list[str] = ['_model'] + A list of attributes to copy with a reference only. + _copy_with_method: dict[str, tuple[str, list[str]]] = { + "_agents": ("copy", ["deep"]), + "_mask": ("copy", ["deep"]), + } + A dictionary of attributes to copy with a specified method and arguments. + _mask : pd.Series + A boolean mask indicating which agents are active. + _model : ModelDF + The model that the AgentSetDF belongs to. + + Properties + ---------- + active_agents(self) -> pd.DataFrame + Get the active agents in the AgentSetPandas. + agents(self) -> pd.DataFrame + Get or set the agents in the AgentSetPandas. + inactive_agents(self) -> pd.DataFrame + Get the inactive agents in the AgentSetPandas. + model(self) -> ModelDF + Get the model associated with the AgentSetPandas. + random(self) -> Generator + Get the random number generator associated with the model. + + Methods + ------- + __init__(self, model: ModelDF) -> None + Initialize a new AgentSetPandas. + add(self, other: pd.DataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True) -> Self + Add agents to the AgentSetPandas. + contains(self, ids: PandasIdsLike) -> bool | pd.Series + Check if agents with the specified IDs are in the AgentSetPandas. + copy(self, deep: bool = False, memo: dict | None = None) -> Self + Create a copy of the AgentSetPandas. + discard(self, ids: PandasIdsLike, inplace: bool = True) -> Self + Remove an agent from the AgentSetPandas. Does not raise an error if the agent is not found. + do(self, method_name: str, *args, return_results: bool = False, inplace: bool = True, **kwargs) -> Self | Any + Invoke a method on the AgentSetPandas. + get(self, attr_names: str | Collection[str] | None, mask: PandasMaskLike = None) -> pd.Series | pd.DataFrame + Retrieve the value of a specified attribute for each agent in the AgentSetPandas. + remove(self, ids: PandasIdsLike, inplace: bool = True) -> Self + Remove agents from the AgentSetPandas. + select(self, mask: PandasMaskLike = None, filter_func: Callable[[Self], PandasMaskLike] | None = None, n: int | None = None, negate: bool = False, inplace: bool = True) -> Self + Select agents in the AgentSetPandas based on the given criteria. + set(self, attr_names: str | Collection[str] | dict[str, Any] | None = None, values: Any | None = None, mask: PandasMaskLike | None = None, inplace: bool = True) -> Self + Set the value of a specified attribute or attributes for each agent in the mask in the AgentSetPandas. + shuffle(self, inplace: bool = True) -> Self + Shuffle the order of agents in the AgentSetPandas. + sort(self, by: str | Sequence[str], ascending: bool | Sequence[bool] = True, inplace: bool = True, **kwargs) -> Self + Sort the agents in the AgentSetPandas based on the given criteria. + to_polars(self) -> "AgentSetPolars" + Convert the AgentSetPandas to an AgentSetPolars. + _get_bool_mask(self, mask: PandasMaskLike = None) -> pd.Series + Get a boolean mask for selecting agents. + _get_masked_df(self, mask: PandasMaskLike = None) -> pd.DataFrame + Get a DataFrame of agents that match the mask. + __getattr__(self, key: str) -> pd.Series + Retrieve an attribute of the underlying DataFrame. + __iter__(self) -> Iterator + Get an iterator for the agents in the AgentSetPandas. + __len__(self) -> int + Get the number of agents in the AgentSetPandas. + __repr__(self) -> str + Get the string representation of the AgentSetPandas. + __reversed__(self) -> Iterator + Get a reversed iterator for the agents in the AgentSetPandas. + __str__(self) -> str + Get the string representation of the AgentSetPandas. + """ + + def add( + self, + agents: pd.DataFrame | gpd.GeoDataFrame | Sequence[Any] | dict[str, Any], + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + if isinstance(agents, gpd.GeoDataFrame): + try: + self.model.space + except ValueError: + raise ValueError( + "You are adding agents with a GeoDataFrame but haven't set model.space. Set it before adding agents with a GeoDataFrame or add agents with a standard DataFrame" + ) + if isinstance(agents, (pd.DataFrame, gpd.GeoDataFrame)): + new_agents = agents + if "unique_id" != agents.index.name: + try: + new_agents.set_index("unique_id", inplace=True, drop=True) + except KeyError: + raise KeyError("DataFrame must have a unique_id column/index.") + elif isinstance(agents, dict): + if "unique_id" not in agents: + raise KeyError("Dictionary must have a unique_id key.") + index = agents.pop("unique_id") + if not isinstance(index, list): + index = [index] + new_agents = pd.DataFrame(agents, index=pd.Index(index, name="unique_id")) + else: + if len(agents) != len(obj._agents.columns) + 1: + raise ValueError( + "Length of data must match the number of columns in the AgentSet if being added as a Collection." + ) + columns = pd.Index(["unique_id"]).append(obj._agents.columns.copy()) + new_agents = pd.DataFrame([agents], columns=columns).set_index( + "unique_id", drop=True + ) + + if new_agents.index.dtype != "int64": + raise TypeError("unique_id must be of type int64.") + + if not obj._agents.index.intersection(new_agents.index).empty: + raise KeyError("Some IDs already exist in the agent set.") + + original_active_indices = obj._mask.index[obj._mask].copy() + + obj._agents = pd.concat([obj._agents, new_agents]) + + obj._update_mask(original_active_indices, new_agents.index) + + return obj + + @overload + def contains(self, agents: int) -> bool: ... + + @overload + def contains(self, agents: PandasIdsLike) -> pd.Series: ... + + def contains(self, agents: PandasIdsLike) -> bool | pd.Series: + if isinstance(agents, pd.Series): + return agents.isin(self._agents.index) + elif isinstance(agents, pd.Index): + return pd.Series( + agents.isin(self._agents.index), index=agents, dtype=pd.BooleanDtype() + ) + elif isinstance(agents, Collection): + return pd.Series(list(agents), index=list(agents)).isin(self._agents.index) + else: + return agents in self._agents.index + + def get( + self, + attr_names: str | Collection[str] | None = None, + mask: PandasMaskLike = None, + ) -> pd.Index | pd.Series | pd.DataFrame: + mask = self._get_bool_mask(mask) + if attr_names is None: + return self._agents.loc[mask] + else: + if attr_names == "unique_id": + return self._agents.loc[mask].index + if isinstance(attr_names, str): + return self._agents.loc[mask, attr_names] + if isinstance(attr_names, Collection): + return self._agents.loc[mask, list(attr_names)] + + def remove( + self, + ids: PandasIdsLike, + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + initial_len = len(obj._agents) + mask = obj._get_bool_mask(ids) + remove_ids = obj._agents[mask].index + original_active_indices = obj._mask.index[obj._mask].copy() + obj._agents.drop(remove_ids, inplace=True) + if len(obj._agents) == initial_len: + raise KeyError("Some IDs were not found in agent set.") + + self._update_mask(original_active_indices) + return obj + + def set( + self, + attr_names: str | dict[str, Any] | Collection[str] | None = None, + values: Any | None = None, + mask: PandasMaskLike = None, + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + b_mask = obj._get_bool_mask(mask) + masked_df = obj._get_masked_df(mask) + + if not attr_names: + attr_names = masked_df.columns + + if isinstance(attr_names, dict): + for key, val in attr_names.items(): + masked_df.loc[:, key] = val + elif ( + isinstance(attr_names, str) + or ( + isinstance(attr_names, Collection) + and all(isinstance(n, str) for n in attr_names) + ) + ) and values is not None: + if not isinstance(attr_names, str): # isinstance(attr_names, Collection) + attr_names = list(attr_names) + masked_df.loc[:, attr_names] = values + else: + raise ValueError( + "Either attr_names must be a dictionary with columns as keys and values or values must be provided." + ) + + non_masked_df = obj._agents[~b_mask] + original_index = obj._agents.index + obj._agents = pd.concat([non_masked_df, masked_df]) + obj._agents = obj._agents.reindex(original_index) + return obj + + def select( + self, + mask: PandasMaskLike = None, + filter_func: Callable[[Self], PandasMaskLike] | None = None, + n: int | None = None, + negate: bool = False, + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + bool_mask = obj._get_bool_mask(mask) + if filter_func: + bool_mask = bool_mask & obj._get_bool_mask(filter_func(obj)) + if negate: + bool_mask = ~bool_mask + if n is not None: + bool_mask = pd.Series( + obj._agents.index.isin(obj._agents[bool_mask].sample(n).index), + index=obj._agents.index, + ) + obj._mask = bool_mask + return obj + + def shuffle(self, inplace: bool = True) -> Self: + obj = self._get_obj(inplace) + obj._agents = obj._agents.sample(frac=1) + return obj + + def sort( + self, + by: str | Sequence[str], + ascending: bool | Sequence[bool] = True, + inplace: bool = True, + **kwargs, + ) -> Self: + obj = self._get_obj(inplace) + obj._agents.sort_values(by=by, ascending=ascending, **kwargs, inplace=True) + return obj + + def to_polars(self) -> AgentSetPolars: + new_obj = AgentSetPolars(self._model) + new_obj._agents = pl.DataFrame(self._agents) + new_obj._mask = pl.Series(self._mask) + return new_obj + + def _concatenate_agentsets( + self, + agentsets: Iterable[Self], + duplicates_allowed: bool = True, + keep_first_only: bool = True, + original_masked_index: pd.Index | None = None, + ) -> Self: + if not duplicates_allowed: + indices = [self._agents.index.to_series()] + [ + agentset._agents.index.to_series() for agentset in agentsets + ] + pd.concat(indices, verify_integrity=True) + if duplicates_allowed & keep_first_only: + final_df = self._agents.copy() + final_mask = self._mask.copy() + for obj in iter(agentsets): + final_df = final_df.combine_first(obj._agents) + final_mask = final_mask.combine_first(obj._mask) + else: + final_df = pd.concat([obj._agents for obj in agentsets]) + final_mask = pd.concat([obj._mask for obj in agentsets]) + self._agents = final_df + self._mask = final_mask + if not isinstance(original_masked_index, type(None)): + ids_to_remove = original_masked_index.difference(self._agents.index) + if not ids_to_remove.empty: + self.remove(ids_to_remove, inplace=True) + return self + + def _get_bool_mask( + self, + mask: PandasMaskLike = None, + ) -> pd.Series: + if isinstance(mask, pd.Series) and mask.dtype == bool: + return mask + elif isinstance(mask, pd.DataFrame): + return pd.Series( + self._agents.index.isin(mask.index), index=self._agents.index + ) + elif isinstance(mask, list): + return pd.Series(self._agents.index.isin(mask), index=self._agents.index) + elif mask is None or mask == "all": + return pd.Series(True, index=self._agents.index) + elif mask == "active": + return self._mask + else: + return pd.Series(self._agents.index.isin([mask]), index=self._agents.index) + + def _get_masked_df( + self, + mask: PandasMaskLike = None, + ) -> pd.DataFrame: + if isinstance(mask, pd.Series) and mask.dtype == bool: + return self._agents.loc[mask] + elif isinstance(mask, pd.DataFrame): + if mask.index.name != "unique_id": + if "unique_id" in mask.columns: + mask.set_index("unique_id", inplace=True, drop=True) + else: + raise KeyError("DataFrame must have a unique_id column/index.") + return pd.DataFrame(index=mask.index).join( + self._agents, on="unique_id", how="left" + ) + elif isinstance(mask, pd.Series): + mask_df = mask.to_frame("unique_id").set_index("unique_id") + return mask_df.join(self._agents, on="unique_id", how="left") + elif mask is None or mask == "all": + return self._agents + elif mask == "active": + return self._agents.loc[self._mask] + else: + mask_series = pd.Series(mask) + mask_df = mask_series.to_frame("unique_id").set_index("unique_id") + return mask_df.join(self._agents, on="unique_id", how="left") + + @overload + def _get_obj_copy(self, obj: pd.Series) -> pd.Series: ... + + @overload + def _get_obj_copy(self, obj: pd.DataFrame) -> pd.DataFrame: ... + + @overload + def _get_obj_copy(self, obj: pd.Index) -> pd.Index: ... + + def _get_obj_copy( + self, obj: pd.Series | pd.DataFrame | pd.Index + ) -> pd.Series | pd.DataFrame | pd.Index: + return obj.copy() + + def _update_mask( + self, + original_active_indices: pd.Index, + new_active_indices: pd.Index | None = None, + ) -> None: + # Update the mask with the old active agents and the new agents + if new_active_indices is None: + self._mask = pd.Series( + self._agents.index.isin(original_active_indices), + index=self._agents.index, + dtype=pd.BooleanDtype(), + ) + else: + self._mask = pd.Series( + self._agents.index.isin(original_active_indices) + | self._agents.index.isin(new_active_indices), + index=self._agents.index, + dtype=pd.BooleanDtype(), + ) + + def __getattr__(self, name: str) -> Any: + super().__getattr__(name) + return getattr(self._agents, name) + + def __iter__(self) -> Iterator[dict[str, Any]]: + for index, row in self._agents.iterrows(): + row_dict = row.to_dict() + row_dict["unique_id"] = index + yield row_dict + + def __len__(self) -> int: + return len(self._agents) + + def __reversed__(self) -> Iterator: + return iter(self._agents[::-1].iterrows()) + + @property + def agents(self) -> pd.DataFrame: + return self._agents + + @agents.setter + def agents(self, new_agents: pd.DataFrame) -> None: + if new_agents.index.name == "unique_id": + pass + elif "unique_id" in new_agents.columns: + new_agents.set_index("unique_id", inplace=True, drop=True) + else: + raise KeyError("The DataFrame should have a 'unique_id' index/column") + self._agents = new_agents + + @property + def active_agents(self) -> pd.DataFrame: + return self._agents.loc[self._mask] + + @active_agents.setter + def active_agents(self, mask: PandasMaskLike) -> None: + self.select(mask=mask, inplace=True) + + @property + def inactive_agents(self) -> pd.DataFrame: + return self._agents.loc[~self._mask] + + @property + def index(self) -> pd.Index: + return self._agents.index diff --git a/mesa_frames/concrete/polars/agentset.py b/mesa_frames/concrete/polars/agentset.py new file mode 100644 index 0000000..c8d0ecc --- /dev/null +++ b/mesa_frames/concrete/polars/agentset.py @@ -0,0 +1,472 @@ +from collections.abc import Callable, Collection, Iterable, Iterator, Sequence +from typing import TYPE_CHECKING + +import geopolars as gpl +import polars as pl +from polars._typing import IntoExpr +from typing_extensions import Any, Self, overload + +from mesa_frames.concrete.agents import AgentSetDF +from mesa_frames.concrete.polars.mixin import PolarsMixin +from mesa_frames.types_ import PolarsIdsLike, PolarsMaskLike + +if TYPE_CHECKING: + from mesa_frames.concrete.pandas.agentset import AgentSetPandas + + +class AgentSetPolars(AgentSetDF, PolarsMixin): + _agents: pl.DataFrame + _copy_with_method: dict[str, tuple[str, list[str]]] = { + "_agents": ("clone", []), + } + _copy_only_reference: list[str] = ["_model", "_mask"] + _mask: pl.Expr | pl.Series + + """A polars-based implementation of the AgentSet. + + Attributes + ---------- + _agents : pl.DataFrame + The agents in the AgentSet. + _copy_only_reference : list[str] = ["_model", "_mask"] + A list of attributes to copy with a reference only. + _copy_with_method: dict[str, tuple[str, list[str]]] = { + "_agents": ("copy", ["deep"]), + "_mask": ("copy", ["deep"]), + } + A dictionary of attributes to copy with a specified method and arguments. + model : ModelDF + The model to which the AgentSet belongs. + _mask : pl.Series + A boolean mask indicating which agents are active. + + Properties + ---------- + active_agents(self) -> pl.DataFrame + Get the active agents in the AgentSetPolars. + agents(self) -> pl.DataFrame + Get or set the agents in the AgentSetPolars. + inactive_agents(self) -> pl.DataFrame + Get the inactive agents in the AgentSetPolars. + model(self) -> ModelDF + Get the model associated with the AgentSetPolars. + random(self) -> Generator + Get the random number generator associated with the model. + + + Methods + ------- + __init__(self, model: ModelDF) -> None + Initialize a new AgentSetPolars. + add(self, other: pl.DataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True) -> Self + Add agents to the AgentSetPolars. + contains(self, ids: PolarsIdsLike) -> bool | pl.Series + Check if agents with the specified IDs are in the AgentSetPolars. + copy(self, deep: bool = False, memo: dict | None = None) -> Self + Create a copy of the AgentSetPolars. + discard(self, ids: PolarsIdsLike, inplace: bool = True) -> Self + Remove an agent from the AgentSetPolars. Does not raise an error if the agent is not found. + do(self, method_name: str, *args, return_results: bool = False, inplace: bool = True, **kwargs) -> Self | Any + Invoke a method on the AgentSetPolars. + get(self, attr_names: IntoExpr | Iterable[IntoExpr] | None, mask: PolarsMaskLike = None) -> pl.Series | pl.DataFrame + Retrieve the value of a specified attribute for each agent in the AgentSetPolars. + remove(self, ids: PolarsIdsLike, inplace: bool = True) -> Self + Remove agents from the AgentSetPolars. + select(self, mask: PolarsMaskLike = None, filter_func: Callable[[Self], PolarsMaskLike] | None = None, n: int | None = None, negate: bool = False, inplace: bool = True) -> Self + Select agents in the AgentSetPolars based on the given criteria. + set(self, attr_names: str | Collection[str] | dict[str, Any] | None = None, values: Any | None = None, mask: PolarsMaskLike | None = None, inplace: bool = True) -> Self + Set the value of a specified attribute or attributes for each agent in the mask in the AgentSetPolars. + shuffle(self, inplace: bool = True) -> Self + Shuffle the order of agents in the AgentSetPolars. + sort(self, by: str | Sequence[str], ascending: bool | Sequence[bool] = True, inplace: bool = True, **kwargs) -> Self + Sort the agents in the AgentSetPolars based on the given criteria. + to_pandas(self) -> "AgentSetPandas" + Convert the AgentSetPolars to an AgentSetPandas. + _get_bool_mask(self, mask: PolarsMaskLike = None) -> pl.Series | pl.Expr + Get a boolean mask for selecting agents. + _get_masked_df(self, mask: PolarsMaskLike = None) -> pl.DataFrame + Get a DataFrame of agents that match the mask. + __getattr__(self, key: str) -> pl.Series + Retrieve an attribute of the underlying DataFrame. + __iter__(self) -> Iterator + Get an iterator for the agents in the AgentSetPolars. + __len__(self) -> int + Get the number of agents in the AgentSetPolars. + __repr__(self) -> str + Get the string representation of the AgentSetPolars. + __reversed__(self) -> Iterator + Get a reversed iterator for the agents in the AgentSetPolars. + __str__(self) -> str + Get the string representation of the AgentSetPolars. + + """ + + def add( + self, + agents: pl.DataFrame | Sequence[Any] | dict[str, Any], + inplace: bool = True, + ) -> Self: + """Add agents to the AgentSetPolars. + + Parameters + ---------- + other : pl.DataFrame | Sequence[Any] | dict[str, Any] + The agents to add. + inplace : bool, optional + Whether to add the agents in place, by default True. + + Returns + ------- + Self + The updated AgentSetPolars. + """ + obj = self._get_obj(inplace) + if isinstance(agents, gpl.GeoDataFrame): + try: + self.model.space + except ValueError: + raise ValueError( + "You are adding agents with a GeoDataFrame but haven't set model.space. Set it before adding agents with a GeoDataFrame or add agents with a standard DataFrame" + ) + if isinstance(agents, gpl.GeoDataFrame, pl.DataFrame): + if "unique_id" not in agents.columns: + raise KeyError("DataFrame must have a unique_id column.") + new_agents = agents + elif isinstance(agents, dict): + if "unique_id" not in agents: + raise KeyError("Dictionary must have a unique_id key.") + new_agents = pl.DataFrame(agents) + else: + if len(agents) != len(obj._agents.columns): + raise ValueError( + "Length of data must match the number of columns in the AgentSet if being added as a Collection." + ) + new_agents = pl.DataFrame([agents], schema=obj._agents.schema) + + if new_agents["unique_id"].dtype != pl.Int64: + raise TypeError("unique_id column must be of type int64.") + + # If self._mask is pl.Expr, then new mask is the same. + # If self._mask is pl.Series[bool], then new mask has to be updated. + + if isinstance(obj._mask, pl.Series): + original_active_indices = obj._agents.filter(obj._mask)["unique_id"] + + obj._agents = pl.concat([obj._agents, new_agents], how="diagonal_relaxed") + + if isinstance(obj._mask, pl.Series): + obj._update_mask(original_active_indices, new_agents["unique_id"]) + + return obj + + @overload + def contains(self, agents: int) -> bool: ... + + @overload + def contains(self, agents: PolarsIdsLike) -> pl.Series: ... + + def contains( + self, + agents: PolarsIdsLike, + ) -> bool | pl.Series: + if isinstance(agents, pl.Series): + return agents.is_in(self._agents["unique_id"]) + elif isinstance(agents, Collection): + return pl.Series(agents).is_in(self._agents["unique_id"]) + else: + return agents in self._agents["unique_id"] + + def get( + self, + attr_names: IntoExpr | Iterable[IntoExpr] | None, + mask: PolarsMaskLike = None, + ) -> pl.Series | pl.DataFrame: + masked_df = self._get_masked_df(mask) + attr_names = self.agents.select(attr_names).columns.copy() + if not attr_names: + return masked_df + masked_df = masked_df.select(attr_names) + if masked_df.shape[1] == 1: + return masked_df[masked_df.columns[0]] + return masked_df + + def remove(self, ids: PolarsIdsLike, inplace: bool = True) -> Self: + obj = self._get_obj(inplace=inplace) + initial_len = len(obj._agents) + mask = obj._get_bool_mask(ids) + + if isinstance(obj._mask, pl.Series): + original_active_indices = obj._agents.filter(obj._mask)["unique_id"] + + obj._agents = obj._agents.filter(mask.not_()) + if len(obj._agents) == initial_len: + raise KeyError(f"IDs {ids} not found in agent set.") + + if isinstance(obj._mask, pl.Series): + obj._update_mask(original_active_indices) + return obj + + def set( + self, + attr_names: str | Collection[str] | dict[str, Any] | None = None, + values: Any | None = None, + mask: PolarsMaskLike = None, + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + b_mask = obj._get_bool_mask(mask) + masked_df = obj._get_masked_df(mask) + + if not attr_names: + attr_names = masked_df.columns + attr_names.remove("unique_id") + + def process_single_attr( + masked_df: pl.DataFrame, attr_name: str, values: Any + ) -> pl.DataFrame: + if isinstance(values, pl.DataFrame): + return masked_df.with_columns(values.to_series().alias(attr_name)) + elif isinstance(values, pl.Expr): + return masked_df.with_columns(values.alias(attr_name)) + if isinstance(values, pl.Series): + return masked_df.with_columns(values.alias(attr_name)) + else: + if isinstance(values, Collection): + values = pl.Series(values) + else: + values = pl.repeat(values, len(masked_df)) + return masked_df.with_columns(values.alias(attr_name)) + + if isinstance(attr_names, str) and values is not None: + masked_df = process_single_attr(masked_df, attr_names, values) + elif isinstance(attr_names, Collection) and values is not None: + if isinstance(values, Collection) and len(attr_names) == len(values): + for attribute, val in zip(attr_names, values): + masked_df = process_single_attr(masked_df, attribute, val) + else: + for attribute in attr_names: + masked_df = process_single_attr(masked_df, attribute, values) + elif isinstance(attr_names, dict): + for key, val in attr_names.items(): + masked_df = process_single_attr(masked_df, key, val) + else: + raise ValueError( + "attr_names must be a string, a collection of string or a dictionary with columns as keys and values." + ) + non_masked_df = obj._agents.filter(b_mask.not_()) + original_index = obj._agents.select("unique_id") + obj._agents = pl.concat([non_masked_df, masked_df], how="diagonal_relaxed") + obj._agents = original_index.join(obj._agents, on="unique_id", how="left") + return obj + + def select( + self, + mask: PolarsMaskLike = None, + filter_func: Callable[[Self], pl.Series] | None = None, + n: int | None = None, + negate: bool = False, + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + mask = obj._get_bool_mask(mask) + if filter_func: + mask = mask & filter_func(obj) + if n is not None: + mask = (obj._agents["unique_id"]).is_in( + obj._agents.filter(mask).sample(n)["unique_id"] + ) + if negate: + mask = mask.not_() + obj._mask = mask + return obj + + def shuffle(self, inplace: bool = True) -> Self: + obj = self._get_obj(inplace) + obj._agents = obj._agents.sample(fraction=1, shuffle=True) + return obj + + def sort( + self, + by: str | Sequence[str], + ascending: bool | Sequence[bool] = True, + inplace: bool = True, + **kwargs, + ) -> Self: + obj = self._get_obj(inplace) + if isinstance(ascending, bool): + descending = not ascending + else: + descending = [not a for a in ascending] + obj._agents = obj._agents.sort(by=by, descending=descending, **kwargs) + return obj + + def to_pandas(self) -> "AgentSetPandas": + from mesa_frames.concrete.pandas.agentset_pandas import AgentSetPandas + + new_obj = AgentSetPandas(self._model) + new_obj._agents = self._agents.to_pandas() + if isinstance(self._mask, pl.Series): + new_obj._mask = self._mask.to_pandas() + else: # self._mask is Expr + new_obj._mask = ( + self._agents["unique_id"] + .is_in(self._agents.filter(self._mask)["unique_id"]) + .to_pandas() + ) + return new_obj + + def _concatenate_agentsets( + self, + agentsets: Iterable[Self], + duplicates_allowed: bool = True, + keep_first_only: bool = True, + original_masked_index: pl.Series | None = None, + ) -> Self: + if not duplicates_allowed: + indices_list = [self._agents["unique_id"]] + [ + agentset._agents["unique_id"] for agentset in agentsets + ] + all_indices = pl.concat(indices_list) + if all_indices.is_duplicated().any(): + raise ValueError( + "Some ids are duplicated in the AgentSetDFs that are trying to be concatenated" + ) + if duplicates_allowed & keep_first_only: + # Find the original_index list (ie longest index list), to sort correctly the rows after concatenation + max_length = max(len(agentset) for agentset in agentsets) + for agentset in agentsets: + if len(agentset) == max_length: + original_index = agentset._agents["unique_id"] + final_dfs = [self._agents] + final_active_indices = [self._agents["unique_id"]] + final_indices = self._agents["unique_id"].clone() + for obj in iter(agentsets): + # Remove agents that are already in the final DataFrame + final_dfs.append( + obj._agents.filter(pl.col("unique_id").is_in(final_indices).not_()) + ) + # Add the indices of the active agents of current AgentSet + final_active_indices.append(obj._agents.filter(obj._mask)["unique_id"]) + # Update the indices of the agents in the final DataFrame + final_indices = pl.concat( + [final_indices, final_dfs[-1]["unique_id"]], how="vertical" + ) + # Left-join original index with concatenated dfs to keep original ids order + final_df = original_index.to_frame().join( + pl.concat(final_dfs, how="diagonal_relaxed"), on="unique_id", how="left" + ) + # + final_active_index = pl.concat(final_active_indices, how="vertical") + + else: + final_df = pl.concat( + [obj._agents for obj in agentsets], how="diagonal_relaxed" + ) + final_active_index = pl.concat( + [obj._agents.filter(obj._mask)["unique_id"] for obj in agentsets] + ) + final_mask = final_df["unique_id"].is_in(final_active_index) + self._agents = final_df + self._mask = final_mask + # If some ids were removed in the do-method, we need to remove them also from final_df + if not isinstance(original_masked_index, type(None)): + ids_to_remove = original_masked_index.filter( + original_masked_index.is_in(self._agents["unique_id"]).not_() + ) + if not ids_to_remove.is_empty(): + self.remove(ids_to_remove, inplace=True) + return self + + @overload + def _get_obj_copy(self, obj: pl.Series) -> pl.Series: ... + + @overload + def _get_obj_copy(self, obj: pl.DataFrame) -> pl.DataFrame: ... + + def _get_obj_copy(self, obj: pl.Series | pl.DataFrame) -> pl.Series | pl.DataFrame: + return obj.clone() + + def _update_mask( + self, original_active_indices: pl.Series, new_indices: pl.Series | None = None + ) -> None: + if new_indices is not None: + self._mask = self._agents["unique_id"].is_in( + original_active_indices + ) | self._agents["unique_id"].is_in(new_indices) + else: + self._mask = self._agents["unique_id"].is_in(original_active_indices) + + def __getattr__(self, key: str) -> pl.Series: + super().__getattr__(key) + return self._agents[key] + + @overload + def __getitem__( + self, + key: str | tuple[PolarsMaskLike, str], + ) -> pl.Series: ... + + @overload + def __getitem__( + self, + key: ( + PolarsMaskLike + | Collection[str] + | tuple[ + PolarsMaskLike, + Collection[str], + ] + ), + ) -> pl.DataFrame: ... + + def __getitem__( + self, + key: ( + str + | Collection[str] + | PolarsMaskLike + | tuple[PolarsMaskLike, str] + | tuple[ + PolarsMaskLike, + Collection[str], + ] + ), + ) -> pl.Series | pl.DataFrame: + attr = super().__getitem__(key) + assert isinstance(attr, (pl.Series, pl.DataFrame)) + return attr + + def __iter__(self) -> Iterator[dict[str, Any]]: + return iter(self._agents.iter_rows(named=True)) + + def __len__(self) -> int: + return len(self._agents) + + def __reversed__(self) -> Iterator: + return reversed(iter(self._agents.iter_rows(named=True))) + + @property + def agents(self) -> pl.DataFrame: + return self._agents + + @agents.setter + def agents(self, agents: pl.DataFrame) -> None: + if "unique_id" not in agents.columns: + raise KeyError("DataFrame must have a unique_id column.") + self._agents = agents + + @property + def active_agents(self) -> pl.DataFrame: + return self.agents.filter(self._mask) + + @active_agents.setter + def active_agents(self, mask: PolarsMaskLike) -> None: + self.select(mask=mask, inplace=True) + + @property + def inactive_agents(self) -> pl.DataFrame: + return self.agents.filter(~self._mask) + + @property + def index(self) -> pl.Series: + return self._agents["unique_id"] diff --git a/mesa_frames/types.py b/mesa_frames/types.py deleted file mode 100644 index 232aa0f..0000000 --- a/mesa_frames/types.py +++ /dev/null @@ -1,32 +0,0 @@ -from collections.abc import Collection -from typing import Literal - -import pandas as pd -import polars as pl -from numpy import ndarray - -####----- Agnostic Types -----#### -AgnosticMask = Literal["all", "active"] | None -AgnosticIds = int | Collection[int] - -###----- Pandas Types -----### - -ArrayLike = pd.api.extensions.ExtensionArray | ndarray -AnyArrayLike = ArrayLike | pd.Index | pd.Series -PandasMaskLike = AgnosticMask | pd.Series | pd.DataFrame | AnyArrayLike -PandasIdsLike = AgnosticIds | pd.Series | pd.Index - -###----- Polars Types -----### - -PolarsMaskLike = AgnosticMask | pl.Expr | pl.Series | pl.DataFrame | Collection[int] -PolarsIdsLike = AgnosticIds | pl.Series - -###----- Generic -----### - -DataFrame = pd.DataFrame | pl.DataFrame -Series = pd.Series | pl.Series -Index = pd.Index | pl.Series -BoolSeries = pd.Series | pl.Series -MaskLike = AgnosticMask | PandasMaskLike | PolarsMaskLike -IdsLike = AgnosticIds | PandasIdsLike | PolarsIdsLike -TimeT = float | int diff --git a/mesa_frames/types_.py b/mesa_frames/types_.py new file mode 100644 index 0000000..2e9c601 --- /dev/null +++ b/mesa_frames/types_.py @@ -0,0 +1,73 @@ +from collections.abc import Collection + +import geopandas as gpd +import geopolars as gpl +import numpy as np +import pandas as pd +import polars as pl +from numpy import ndarray +from typing_extensions import Literal, Sequence + +####----- Agnostic Types -----#### +AgnosticMask = Literal["all", "active"] | None +AgnosticIds = int | Collection[int] + +###----- Pandas Types -----### + +ArrayLike = pd.api.extensions.ExtensionArray | ndarray +AnyArrayLike = ArrayLike | pd.Index | pd.Series +PandasMaskLike = AgnosticMask | pd.Series | pd.DataFrame | AnyArrayLike +PandasIdsLike = AgnosticIds | pd.Series | pd.Index +PandasGridCapacity = np.ndarray + +###----- Polars Types -----### + +PolarsMaskLike = AgnosticMask | pl.Expr | pl.Series | pl.DataFrame | Collection[int] +PolarsIdsLike = AgnosticIds | pl.Series +PolarsGridCapacity = list[pl.Expr] + +###----- Generic -----### + +GeoDataFrame = gpd.GeoDataFrame | gpl.GeoDataFrame +GeoSeries = gpd.GeoSeries | gpl.GeoSeries +DataFrame = pd.DataFrame | pl.DataFrame +Series = pd.Series | pl.Series | GeoSeries +Index = pd.Index | pl.Series +BoolSeries = pd.Series | pl.Series +MaskLike = AgnosticMask | PandasMaskLike | PolarsMaskLike +IdsLike = AgnosticIds | PandasIdsLike | PolarsIdsLike + +###----- Time ------### +TimeT = float | int + + +###----- Space -----### + +NetworkCoordinate = int | DataFrame + +GridCoordinate = int | Sequence[int] | DataFrame + +DiscreteCoordinate = NetworkCoordinate | GridCoordinate +ContinousCoordinate = float | Sequence[float] | DataFrame + +SpaceCoordinate = DiscreteCoordinate | ContinousCoordinate + + +NetworkCoordinates = NetworkCoordinate | Collection[NetworkCoordinate] +GridCoordinates = ( + GridCoordinate | Sequence[int | slice | Sequence[int]] | Collection[GridCoordinate] +) + +DiscreteCoordinates = NetworkCoordinates | GridCoordinates +ContinousCoordinates = ( + ContinousCoordinate + | Sequence[float | Sequence[float]] + | Collection[ContinousCoordinate] +) + +SpaceCoordinates = DiscreteCoordinates | ContinousCoordinates + +GridCapacity = PandasGridCapacity | PolarsGridCapacity +NetworkCapacity = DataFrame + +DiscreteSpaceCapacity = GridCapacity | NetworkCapacity diff --git a/tests/test_agents.py b/tests/test_agents.py index 59d7be1..f1886b1 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -6,7 +6,7 @@ from mesa_frames import AgentsDF, ModelDF from mesa_frames.abstract.agents import AgentSetDF -from mesa_frames.types import MaskLike +from mesa_frames.types_ import MaskLike from tests.test_agentset_pandas import ( ExampleAgentSetPandas, fix1_AgentSetPandas, From 9546b1064daaaa123307079eae71b887a630c489 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 00:08:29 +0200 Subject: [PATCH 03/21] creation of DataFrameMixin --- mesa_frames/abstract/mixin.py | 80 ++++++++++++++- mesa_frames/concrete/pandas/mixin.py | 119 ++++++++++++++++++++++ mesa_frames/concrete/polars/mixin.py | 143 +++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 mesa_frames/concrete/pandas/mixin.py create mode 100644 mesa_frames/concrete/polars/mixin.py diff --git a/mesa_frames/abstract/mixin.py b/mesa_frames/abstract/mixin.py index a62752d..258a331 100644 --- a/mesa_frames/abstract/mixin.py +++ b/mesa_frames/abstract/mixin.py @@ -1,7 +1,9 @@ from abc import ABC, abstractmethod from copy import copy, deepcopy -from typing_extensions import Self +from typing_extensions import Any, Collection, Iterator, Literal, Self, Sequence + +from mesa_frames.types_ import BoolSeries, DataFrame, MaskLike, Series class CopyMixin(ABC): @@ -142,3 +144,79 @@ def __deepcopy__(self, memo: dict) -> Self: A deep copy of the AgentContainer. """ return self.copy(deep=True, memo=memo) + + +class DataFrameMixin(ABC): + @abstractmethod + def _df_add_columns( + self, original_df: DataFrame, new_columns: list[str], data: Any + ) -> DataFrame: ... + + @abstractmethod + def _df_combine_first( + self, original_df: DataFrame, new_df: DataFrame, index_cols: list[str] + ) -> DataFrame: ... + + @abstractmethod + def _df_concat( + self, + dfs: Collection[DataFrame], + how: Literal["horizontal"] | Literal["vertical"] = "vertical", + ignore_index: bool = False, + ) -> DataFrame: ... + + @abstractmethod + def _df_constructor( + self, + data: Sequence[Sequence] | dict[str | Any] | None = None, + columns: list[str] | None = None, + index_col: str | list[str] | None = None, + dtypes: dict[str, Any] | None = None, + ) -> DataFrame: ... + + @abstractmethod + def _df_get_bool_mask( + self, + df: DataFrame, + index_col: str, + mask: MaskLike | None = None, + negate: bool = False, + ) -> BoolSeries: ... + + @abstractmethod + def _df_get_masked_df( + self, + df: DataFrame, + index_col: str, + mask: MaskLike | None = None, + columns: list[str] | None = None, + negate: bool = False, + ) -> DataFrame: ... + + @abstractmethod + def _df_iterator(self, df: DataFrame) -> Iterator[dict[str, Any]]: ... + + @abstractmethod + def _df_remove( + self, df: DataFrame, ids: Sequence[Any], index_col: str | None = None + ) -> DataFrame: ... + + @abstractmethod + def _df_sample( + self, + df: DataFrame, + n: int | None = None, + frac: float | None = None, + with_replacement: bool = False, + shuffle: bool = False, + seed: int | None = None, + ) -> DataFrame: ... + + @abstractmethod + def _srs_constructor( + self, + data: Sequence[Any] | None = None, + name: str | None = None, + dtype: Any | None = None, + index: Sequence[Any] | None = None, + ) -> Series: ... diff --git a/mesa_frames/concrete/pandas/mixin.py b/mesa_frames/concrete/pandas/mixin.py new file mode 100644 index 0000000..b207f5f --- /dev/null +++ b/mesa_frames/concrete/pandas/mixin.py @@ -0,0 +1,119 @@ +import pandas as pd +from typing_extensions import Any, Collection, Iterator, Literal, Sequence + +from mesa_frames.abstract.mixin import DataFrameMixin +from mesa_frames.types_ import PandasMaskLike + + +class PandasMixin(DataFrameMixin): + def _df_add_columns( + self, original_df: pd.DataFrame, new_columns: list[str], data: Any + ) -> pd.DataFrame: + original_df[new_columns] = data + return original_df + + def _df_combine_first( + self, original_df: pd.DataFrame, new_df: pd.DataFrame, index_cols: list[str] + ) -> pd.DataFrame: + return original_df.combine_first(new_df) + + def _df_concat( + self, + dfs: Collection[pd.DataFrame], + how: Literal["horizontal"] | Literal["vertical"] = "vertical", + ignore_index: bool = False, + ) -> pd.DataFrame: + return pd.concat( + dfs, axis=0 if how == "vertical" else 1, ignore_index=ignore_index + ) + + def _df_constructor( + self, + data: Sequence[Sequence] | dict[str | Any] | None = None, + columns: list[str] | None = None, + index_col: str | list[str] | None = None, + dtypes: dict[str, Any] | None = None, + ) -> pd.DataFrame: + df = pd.DataFrame(data=data, columns=columns).astype(dtypes) + if index_col: + df.set_index(index_col) + return df + + def _df_get_bool_mask( + self, + df: pd.DataFrame, + index_col: str, + mask: PandasMaskLike = None, + negate: bool = False, + ) -> pd.Series: + if isinstance(mask, pd.Series) and mask.dtype == bool and len(mask) == len(df): + result = mask + elif isinstance(mask, pd.DataFrame): + if mask.index.name == index_col: + result = pd.Series(df.index.isin(mask.index), index=df.index) + elif index_col in mask.columns: + result = pd.Series(df.index.isin(mask[index_col]), index=df.index) + else: + raise ValueError( + f"A DataFrame mask must have a column/index with name {index_col}" + ) + elif mask is None or mask == "all": + result = pd.Series(True, index=df.index) + elif isinstance(mask, Sequence): + result = pd.Series(df.index.isin(mask), index=df.index) + else: + result = pd.Series(df.index.isin([mask]), index=df.index) + + if negate: + result = ~result + + return result + + def _df_get_masked_df( + self, + df: pd.DataFrame, + index_col: str, + mask: PandasMaskLike | None = None, + columns: list[str] | None = None, + negate: bool = False, + ) -> pd.DataFrame: + b_mask = self._df_get_bool_mask(df, index_col, mask, negate) + if columns: + return df.loc[b_mask, columns] + return df.loc[b_mask] + + def _df_iterator(self, df: pd.DataFrame) -> Iterator[dict[str, Any]]: + for index, row in df.iterrows(): + row_dict = row.to_dict() + row_dict["unique_id"] = index + yield row_dict + + def _df_remove( + self, + df: pd.DataFrame, + ids: Sequence[Any], + index_col: str | None = None, + ) -> pd.DataFrame: + return df[~df.index.isin(ids)] + + def _df_sample( + self, + df: pd.DataFrame, + n: int | None = None, + frac: float | None = None, + with_replacement: bool = False, + shuffle: bool = False, + seed: int | None = None, + ) -> pd.DataFrame: + return df.sample( + n=n, frac=frac, replace=with_replacement, shuffle=shuffle, random_state=seed + ) + + def _srs_constructor( + self, + data: Sequence[Sequence] | None = None, + name: str | None = None, + dtype: Any | None = None, + index: Sequence[Any] | None = None, + ) -> pd.Series: + return pd.Series(data, name=name, dtype=dtype, index=index) diff --git a/mesa_frames/concrete/polars/mixin.py b/mesa_frames/concrete/polars/mixin.py new file mode 100644 index 0000000..7300c19 --- /dev/null +++ b/mesa_frames/concrete/polars/mixin.py @@ -0,0 +1,143 @@ +import polars as pl +from typing_extensions import Any, Collection, Iterator, Literal, Sequence + +from mesa_frames.abstract.mixin import DataFrameMixin +from mesa_frames.types_ import PolarsMaskLike + + +class PolarsMixin(DataFrameMixin): + # TODO: complete with other dtypes + _dtypes_mapping: dict[str, Any] = {"int64": pl.Int64, "bool": pl.Boolean} + + def _df_add_columns( + self, original_df: pl.DataFrame, new_columns: list[str], data: Any + ) -> pl.DataFrame: + return original_df.with_columns( + **{col: value for col, value in zip(new_columns, data)} + ) + + def _df_combine_first( + self, original_df: pl.DataFrame, new_df: pl.DataFrame, index_cols: list[str] + ) -> pl.DataFrame: + new_df = original_df.join(new_df, on=index_cols, how="full", suffix="_right") + # Find columns with the _right suffix and update the corresponding original columns + updated_columns = [] + for col in new_df.columns: + if col.endswith("_right"): + original_col = col.replace("_right", "") + updated_columns.append( + pl.when(pl.col(col).is_not_null()) + .then(pl.col(col)) + .otherwise(pl.col(original_col)) + .alias(original_col) + ) + + # Apply the updates and remove the _right columns + new_df = new_df.with_columns(updated_columns).select( + pl.col(r"^(?!.*_right$).*") + ) + return new_df + + def _df_concat( + self, + dfs: Collection[pl.DataFrame], + how: Literal["horizontal"] | Literal["vertical"] = "vertical", + ignore_index: bool = False, + ) -> pl.DataFrame: + return pl.concat( + dfs, how="vertical_relaxed" if how == "vertical" else "horizontal_relaxed" + ) + + def _df_constructor( + self, + data: Sequence[Sequence] | dict[str | Any] | None = None, + columns: list[str] | None = None, + index_col: str | list[str] | None = None, + dtypes: dict[str, str] | None = None, + ) -> pl.DataFrame: + dtypes = {k: self._dtypes_mapping.get(v, v) for k, v in dtypes.items()} + return pl.DataFrame(data=data, schema=dtypes if dtypes else columns) + + def _df_get_bool_mask( + self, + df: pl.DataFrame, + index_col: str, + mask: PolarsMaskLike = None, + negate: bool = False, + ) -> pl.Series | pl.Expr: + def bool_mask_from_series(mask: pl.Series) -> pl.Series: + if ( + isinstance(mask, pl.Series) + and mask.dtype == pl.Boolean + and len(mask) == len(df) + ): + return mask + return df[index_col].is_in(mask) + + if isinstance(mask, pl.Expr): + result = mask + elif isinstance(mask, pl.Series): + result = bool_mask_from_series(mask) + elif isinstance(mask, pl.DataFrame): + if index_col in mask.columns: + result = bool_mask_from_series(mask[index_col]) + elif len(mask.columns) == 1 and mask.dtypes[0] == pl.Boolean: + result = bool_mask_from_series(mask[mask.columns[0]]) + else: + raise KeyError( + f"DataFrame must have an {index_col} column or a single boolean column." + ) + elif mask is None or mask == "all": + result = pl.Series([True] * len(df)) + elif isinstance(mask, Collection): + result = bool_mask_from_series(pl.Series(mask)) + else: + result = bool_mask_from_series(pl.Series([mask])) + + if negate: + result = ~result + + return result + + def _df_get_masked_df( + self, + df: pl.DataFrame, + index_col: str, + mask: PolarsMaskLike | None = None, + columns: list[str] | None = None, + negate: bool = False, + ) -> pl.DataFrame: + b_mask = self._df_get_bool_mask(df, index_col, mask, negate=negate) + if columns: + return df.filter(b_mask)[columns] + return df.filter(b_mask) + + def _df_iterator(self, df: pl.DataFrame) -> Iterator[dict[str, Any]]: + return iter(df.iter_rows(named=True)) + + def _df_remove( + self, df: pl.DataFrame, ids: Sequence[Any], index_col: str | None = None + ) -> pl.DataFrame: + return df.filter(pl.col(index_col).is_in(ids).not_()) + + def _df_sample( + self, + df: pl.DataFrame, + n: int | None = None, + frac: float | None = None, + with_replacement: bool = False, + shuffle: bool = False, + seed: int | None = None, + ) -> pl.DataFrame: + return df.sample( + n=n, frac=frac, replace=with_replacement, shuffle=shuffle, seed=seed + ) + + def _srs_constructor( + self, + data: Sequence[Any] | None = None, + name: str | None = None, + dtype: Any | None = None, + index: Sequence[Any] | None = None, + ) -> pl.Series: + return pl.Series(name=name, values=data, dtype=self._dtypes_mapping[dtype]) From a716118aff16acab2b6af89202f79db6fabd6c52 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 22:12:57 +0000 Subject: [PATCH 04/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa_frames/abstract/mixin.py | 3 ++- mesa_frames/concrete/pandas/mixin.py | 3 ++- mesa_frames/concrete/polars/mixin.py | 3 ++- mesa_frames/types_.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/mesa_frames/abstract/mixin.py b/mesa_frames/abstract/mixin.py index 258a331..088901e 100644 --- a/mesa_frames/abstract/mixin.py +++ b/mesa_frames/abstract/mixin.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod from copy import copy, deepcopy -from typing_extensions import Any, Collection, Iterator, Literal, Self, Sequence +from typing_extensions import Any, Self +from typing import Collection, Iterator, Literal, Sequence from mesa_frames.types_ import BoolSeries, DataFrame, MaskLike, Series diff --git a/mesa_frames/concrete/pandas/mixin.py b/mesa_frames/concrete/pandas/mixin.py index b207f5f..fca88e9 100644 --- a/mesa_frames/concrete/pandas/mixin.py +++ b/mesa_frames/concrete/pandas/mixin.py @@ -1,5 +1,6 @@ import pandas as pd -from typing_extensions import Any, Collection, Iterator, Literal, Sequence +from typing_extensions import Any +from typing import Collection, Iterator, Literal, Sequence from mesa_frames.abstract.mixin import DataFrameMixin from mesa_frames.types_ import PandasMaskLike diff --git a/mesa_frames/concrete/polars/mixin.py b/mesa_frames/concrete/polars/mixin.py index 7300c19..70ccfdd 100644 --- a/mesa_frames/concrete/polars/mixin.py +++ b/mesa_frames/concrete/polars/mixin.py @@ -1,5 +1,6 @@ import polars as pl -from typing_extensions import Any, Collection, Iterator, Literal, Sequence +from typing_extensions import Any +from typing import Collection, Iterator, Literal, Sequence from mesa_frames.abstract.mixin import DataFrameMixin from mesa_frames.types_ import PolarsMaskLike diff --git a/mesa_frames/types_.py b/mesa_frames/types_.py index 2e9c601..bc4e1ec 100644 --- a/mesa_frames/types_.py +++ b/mesa_frames/types_.py @@ -6,7 +6,7 @@ import pandas as pd import polars as pl from numpy import ndarray -from typing_extensions import Literal, Sequence +from typing import Literal, Sequence ####----- Agnostic Types -----#### AgnosticMask = Literal["all", "active"] | None From b181c8bc174a40657d50ced871cb087db1acca0f Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 00:18:51 +0200 Subject: [PATCH 05/21] Abstract Space and GridDF --- mesa_frames/concrete/space.py | 1760 +++++++++++++++++++++++++++++++++ 1 file changed, 1760 insertions(+) diff --git a/mesa_frames/concrete/space.py b/mesa_frames/concrete/space.py index ff6138b..0afd06e 100644 --- a/mesa_frames/concrete/space.py +++ b/mesa_frames/concrete/space.py @@ -74,3 +74,1763 @@ def _check_empty_pos(pos: PositionsLike) -> bool: bool _description_ """ + +""" +Mesa Frames Space Module +================= + +Objects used to add a spatial component to a model. + +""" + +from abc import abstractmethod +from functools import lru_cache +from itertools import product +from typing import cast +from warnings import warn + +import geopandas as gpd +import networkx as nx +import numpy as np + +# if TYPE_CHECKING: +import pandas as pd +import polars as pl +import shapely as shp +from numpy.random import Generator +from pyproj import CRS +from typing_extensions import ( + Any, + Callable, + Collection, + Iterable, + Iterator, + Self, + Sequence, +) + +from mesa_frames.abstract.agents import AgentContainer, AgentSetDF +from mesa_frames.abstract.mixin import CopyMixin, DataFrameMixin +from mesa_frames.concrete.model import ModelDF +from mesa_frames.concrete.pandas.mixin import PandasMixin +from mesa_frames.concrete.polars.mixin import PolarsMixin +from mesa_frames.types_ import ( + DataFrame, + DiscreteCoordinate, + DiscreteCoordinates, + DiscreteSpaceCapacity, + GeoDataFrame, + GridCapacity, + GridCoordinate, + GridCoordinates, + SpaceCoordinate, + SpaceCoordinates, +) + +ESPG = int + + +class SpaceDF(CopyMixin, DataFrameMixin): + _model: ModelDF + _agents: DataFrame | GeoDataFrame + + def __init__(self, model: ModelDF) -> None: + """Create a new CellSet object. + + Parameters + ---------- + model : ModelDF + + Returns + ------- + None + """ + self._model = model + + def iter_neighbors( + self, + radius: int | float | Sequence[int] | Sequence[float], + pos: SpaceCoordinate | SpaceCoordinates | None = None, + agents: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + include_center: bool = False, + ) -> Iterator[dict[str, Any]]: + """Return an iterator over the neighboring agents from the given positions or agents according to specified radiuses. + Either the positions or the agents must be specified (not both). + + Parameters + ---------- + radius : int | float + The radius of the neighborhood + pos : SpaceCoordinate | SpaceCoordinates | None, optional + The positions to get the neighbors from, by default None + agents : int | Sequence[int] | None, optional + The agents to get the neigbors from, by default None + include_center : bool, optional + If the position or agent should be included in the result, by default False + + Yields + ------ + Iterator[dict[str, Any]] + An iterator over neighboring agents where each agent is a dictionary with: + - Attributes of the agent (the columns of its AgentSetDF dataframe) + - Keys which are suffixed by '_center' to indicate the original center (eg. ['dim_0_center', 'dim_1_center', ...] for Grids, ['node_id_center', 'edge_id_center'] for Networks, 'agent_id_center' for agents) + + Raises + ------ + ValueError + If both pos and agents are None or if both pos and agents are not None. + """ + return self._df_iterator( + self.get_neighbors( + radius=radius, pos=pos, agents=agents, include_center=include_center + ) + ) + + def iter_directions( + self, + pos0: SpaceCoordinate | SpaceCoordinates | None = None, + pos1: SpaceCoordinate | SpaceCoordinates | None = None, + agents0: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + agents1: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + ) -> Iterator[dict[str, Any]]: + """Return an iterator over the direction from pos0 to pos1 or agents0 to agents1. + + Parameters + ---------- + pos0 : SpaceCoordinate | SpaceCoordinates | None, optional + The starting positions, by default None + pos1 : SpaceCoordinate | SpaceCoordinates | None, optional + The ending positions, by default None + agents0 : int | Sequence[int] | None, optional + The starting agents, by default None + agents1 : int | Sequence[int] | None, optional + The ending agents, by default None + + Yields + ------ + Iterator[dict[str, Any]] + An iterator over the direction from pos0 to pos1 or agents0 to agents1 where each direction is a dictionary with: + - Keys called according to the coordinates of the space(['dim_0', 'dim_1', ...] in Grids, ['node_id', 'edge_id'] in Networks) + - Values representing the value of coordinates according to the dimension + """ + return self._df_iterator( + self.get_directions(pos0=pos0, pos1=pos1, agents0=agents0, agents1=agents1) + ) + + def iter_distances( + self, + pos0: SpaceCoordinate | SpaceCoordinates | None = None, + pos1: SpaceCoordinate | SpaceCoordinates | None = None, + agents0: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + agents1: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + ) -> Iterator[dict[str, Any]]: + """Return an iterator over the distance from pos0 to pos1 or agents0 to agents1. + + Parameters + ---------- + pos0 : SpaceCoordinate | SpaceCoordinates | None, optional + The starting positions, by default None + pos1 : SpaceCoordinate | SpaceCoordinates | None, optional + The ending positions, by default None + agents0 : int | Sequence[int] | None, optional + The starting agents, by default None + agents1 : int | Sequence[int] | None, optional + The ending agents, by default None + + Yields + ------ + Iterator[dict[str, Any]] + An iterator over the distance from pos0 to pos1 or agents0 to agents1 where each distance is a dictionary with: + - A single key 'distance' representing the distance between the two positions + """ + return self._df_iterator( + self.get_distances(pos0=pos0, pos1=pos1, agents0=agents0, agents1=agents1) + ) + + def random_agents( + self, + n: int, + seed: int | None = None, + ) -> DataFrame: + """Return a random sample of agents from the space. + + Parameters + ---------- + n : int + The number of agents to sample + seed : int | None, optional + The seed for the sampling, by default None + If None, an integer from the model's random number generator is used. + + Returns + ------- + DataFrame + A DataFrame with the sampled agents + """ + if seed is None: + seed = self.random.integers(0) + return self._df_sample(self._agents, n=n, seed=seed) + + @abstractmethod + def get_directions( + self, + pos0: SpaceCoordinate | SpaceCoordinates | None = None, + pos1: SpaceCoordinate | SpaceCoordinates | None = None, + agents0: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + agents1: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + ) -> DataFrame: + """Returns the direction 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. + They must have the same length. + + Parameters + ---------- + pos0 : SpaceCoordinate | SpaceCoordinates | None = None + The starting positions + pos1 : SpaceCoordinate | SpaceCoordinates | None = None + The ending positions + agents0 : int | Sequence[int] | None = None + The starting agents + agents1 : int | Sequence[int] | None = None + The ending agents + + 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: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + agents1: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + ) -> DataFrame: + """Returns the distance from pos0 to pos1. + 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. + pos0 and pos1 must be the same type of coordinates and have the same length. + agents0 and agents1 must have the same length. + + Parameters + ---------- + pos0 : SpaceCoordinate | SpaceCoordinates | None = None + The starting positions + pos1 : SpaceCoordinate | SpaceCoordinates | None = None + The ending positions + agents0 : int | Sequence[int] | None = None + The starting agents + agents1 : int | Sequence[int] | None = None + The ending agents + + Returns + ------- + DataFrame + A DataFrame where each row represents the distance from pos0 to pos1 or agents0 to agents1 + """ + ... + + @abstractmethod + def get_neighbors( + self, + radius: int | float | Sequence[int] | Sequence[float], + pos: SpaceCoordinate | SpaceCoordinates | None = None, + agents: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None = None, + include_center: bool = False, + ) -> DataFrame: + """Get the neighboring agents from given positions or agents according to a radius. + Either positions or agents must be specified, not both. + + Parameters + ---------- + radius : int | float + The radius of the neighborhood + pos : SpaceCoordinates | None, optional + The coordinates of the cell to get the neighborhood from, by default None + agent : int | None, optional + The id of the agent to get the neighborhood from, by default None + include_center : bool, optional + If the cell or agent 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_agents( + self, + agents: int | Collection[int] | 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 : AgentContainer | Collection[AgentContainer] | int | Sequence[int] + The agents to place in the space + pos : 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 already placed in the space. + ValueError + - If some agents are not part of the model. + - If agents is int | Sequence[int] and some agents are present multiple times. + + Returns + ------- + Self + """ + ... + + @abstractmethod + def move_to_empty( + self, + agents: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None, + inplace: bool = True, + ) -> Self: + """Move agents to empty cells/positions in the space. + + Parameters + ---------- + agents : AgentContainer | Collection[AgentContainer] | int | Sequence[int] + The agents to move to empty cells/positions + inplace : bool, optional + Whether to perform the operation inplace, by default True + + Returns + ------- + Self + """ + ... + + @abstractmethod + def random_pos( + self, + n: int, + seed: int | None = None, + ) -> DataFrame: + """Return a random sample of positions from the space. + + Parameters + ---------- + n : int + The number of positions to sample + seed : int | None, optional + The seed for the sampling, by default None + If None, an integer from the model's random number generator is used. + + Returns + ------- + DataFrame + A DataFrame with the sampled positions + """ + ... + + @abstractmethod + def remove_agents( + self, + agents: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None, + inplace: bool = True, + ): + """Remove agents from the space + + Parameters + ---------- + agents : AgentContainer | Collection[AgentContainer] | int | Sequence[int] + 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 + """ + ... + + @abstractmethod + def swap_agents( + self, + agents0: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None, + agents1: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None, + ) -> 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 : AgentContainer | Collection[AgentContainer] | int | Sequence[int] + The first set of agents to swap + agents1 : AgentContainer | Collection[AgentContainer] | int | Sequence[int] + The second set of agents to swap + + Returns + ------- + Self + """ + + @abstractmethod + def __repr__(self) -> str: ... + + @abstractmethod + def __str__(self) -> 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 + ------- + AgentsDF + """ + return self._agents + + @property + def model(self) -> ModelDF: + """The model to which the space belongs. + + Returns + ------- + ModelDF + """ + self._model + + @property + def random(self) -> Generator: + """The model's random number generator. + + Returns + ------- + Generator + """ + return self.model.random + + +class GeoSpaceDF(SpaceDF): ... + + +class DiscreteSpaceDF(SpaceDF): + _capacity: int | None + _cells: DataFrame + _cells_col_names: list[str] + _center_col_names: list[str] + + def __init__( + self, + model: ModelDF, + capacity: int | None = None, + ): + super().__init__(model) + self._capacity = capacity + + def iter_neighborhood( + self, + radius: int | Sequence[int], + pos: DiscreteCoordinate | DataFrame | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> Iterator[dict[str, Any]]: + """Return an iterator over the neighborhood cells from a given position according to a radius. + + Parameters + ---------- + pos : DiscreteCoordinates + The coordinates of the cell to get the neighborhood from + radius : int + The radius of the neighborhood + include_center : bool, optional + If the cell in the center of the neighborhood should be included in the result, by default False + + Returns + ------ + Iterator[dict[str, Any]] + An iterator over neighboring cell where each cell is a dictionary with: + - Keys called according to the coordinates of the space(['dim_0', 'dim_1', ...] in Grids, ['node_id', 'edge_id'] in Networks) + - Values representing the value of coordinates according to the dimension + + """ + return self._df_iterator( + self.get_neighborhood( + radius=radius, pos=pos, agents=agents, include_center=include_center + ) + ) + + def move_to_empty( + self, + agents: int + | Collection[int] + | AgentContainer + | Collection[AgentContainer] + | None, + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + + # Get Ids of agents + # TODO: fix this + if isinstance(agents, AgentContainer | Collection[AgentContainer]): + agents = agents.index + + # Check ids presence in model + b_contained = obj.model.agents.contains(agents) + if (isinstance(b_contained, pl.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 empty cells + empty_cells = obj._get_empty_cells(skip_agents=agents) + if len(empty_cells) < len(agents): + raise ValueError("Not enough empty cells to move agents") + + # Place agents + obj._agents = obj.move_agents(agents, empty_cells) + return obj + + def get_empty_cells( + self, + n: int | None = None, + with_replacement: bool = True, + ) -> DataFrame: + """Get the empty cells in the space (cells without any agent). + + + Parameters + ---------- + n : int | None, optional + _description_, by default None + with_replacement : bool, optional + If with_replacement is False, all cells are different. + If with_replacement is True, some cells could be the same (but such that the total number of selection per cells is less or equal than the capacity), by default True + + Returns + ------- + DataFrame + _description_ + """ + return self._sample_cells( + n, with_replacement, condition=lambda cap: cap == self._capacity + ) + + def get_free_cells( + self, + n: int | None = None, + with_replacement: bool = True, + ) -> DataFrame: + """Get the free cells in the space (cells that have not reached maximum capacity). + + Parameters + ---------- + n : int + The number of empty cells to get + with_replacement : bool, optional + If with_replacement is False, all cells are different. + If with_replacement is True, some cells could be the same (but such that the total number of selection per cells is at less or equal than the remaining capacity), by default True + + Returns + ------- + DataFrame + A DataFrame with free cells + """ + return self._sample_cells(n, with_replacement, condition=lambda cap: cap > 0) + + def get_full_cells( + self, + n: int | None = None, + with_replacement: bool = True, + ) -> DataFrame: + """Get the full cells in the space. + + Parameters + ---------- + n : int + The number of full cells to get + + Returns + ------- + DataFrame + A DataFrame with full cells + """ + return self._sample_cells(n, with_replacement, condition=lambda cap: cap == 0) + + @abstractmethod + def get_neighborhood( + self, + radius: int | float | Sequence[int] | Sequence[float], + pos: DiscreteCoordinate | DataFrame | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> DataFrame: + """Get the neighborhood cells from a given position. + + Parameters + ---------- + pos : DiscreteCoordinates + The coordinates of the cell to get the neighborhood from + radius : int + The radius of the neighborhood + 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, cells: DiscreteCoordinates | None = None) -> DataFrame: + """Retrieve the dataframe of specified cells with their properties and agents. + + Parameters + ---------- + cells : CellCoordinates, default is optional (all cells retrieved) + + Returns + ------- + DataFrame + A DataFrame where columns representing the CellCoordiantes + (['x', 'y' in Grids, ['node_id', 'edge_id'] in Network]), an agent_id columns containing a list of agents + in the cell and the properties of the cell + """ + ... + + @abstractmethod + def set_cells( + self, + properties: DataFrame, + cells: DiscreteCoordinates | None = None, + inplace: bool = True, + ) -> Self: + """Set the properties of the specified cells. + Either the properties df must contain both the cell coordinates and the properties or + the cell coordinates must be specified separately. + 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 + ---------- + properties : DataFrame + The properties of the cells + inplace : bool + Whether to perform the operation inplace + + Returns + ------- + Self + """ + ... + + @abstractmethod + def _get_empty_cells( + self, + skip_agents: Collection[int] | None = None, + ): ... + + @abstractmethod + def _sample_cells( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[DiscreteSpaceCapacity], DiscreteSpaceCapacity], + ) -> DataFrame: + """Sample cells from the grid according to a condition on the capacity. + + Parameters + ---------- + n : int | None + The number of cells to sample + with_replacement : bool + If the sampling should be with replacement + condition : Callable[[DiscreteSpaceCapacity], DiscreteSpaceCapacity] + The condition to apply on the capacity + + Returns + ------- + DataFrame + """ + ... + + def __getitem__(self, cells: DiscreteCoordinates): + return self.get_cells(cells) + + def __setitem__(self, cells: DiscreteCoordinates, properties: DataFrame): + self.set_cells(properties=properties, cells=cells) + + def __getattr__(self, key: str) -> DataFrame: + # Fallback, if key is not found in the object, + # then it must mean that it's in the _cells dataframe + return self._cells[key] + + def is_free(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame: + """Check whether the input positions are free (there exists at least one remaining spot in the cells) + + Parameters + ---------- + pos : GridCoordinate | GridCoordinates + The positions to check for + + Returns + ------- + DataFrame + A dataframe with positions and a boolean column "free" + """ + df = self._df_constructor(data=pos, columns=self._cells_col_names) + return self._df_add_columns( + df, ["free"], self._df_get_bool_mask(df, mask=self.full_cells, negate=True) + ) + + 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 : GridCoordinate | GridCoordinates + The positions to check for + + Returns + ------- + DataFrame + A dataframe with positions and a boolean column "empty" + """ + df = self._df_constructor(data=pos, columns=self._cells_col_names) + return self._df_add_columns( + df, ["empty"], self._df_get_bool_mask(df, mask=self._cells, negate=True) + ) + + 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 : GridCoordinate | GridCoordinates + The positions to check for + + Returns + ------- + DataFrame + A dataframe with positions and a boolean column "full" + """ + df = self._df_constructor(data=pos, columns=self._cells_col_names) + return self._df_add_columns( + df, ["full"], self._df_get_bool_mask(df, mask=self.full_cells, negate=True) + ) + + # We use lru_cache because cached_property does not support a custom setter. + # TODO: Test if there's an effective increase in performance + @property + @lru_cache(maxsize=1) + def cells(self) -> DataFrame: + return self.get_cells() + + @cells.setter + def cells(self, df: DataFrame): + return self.set_cells(df, inplace=True) + + @property + def full_cells(self) -> DataFrame: + df = self.cells + return self._df_get_masked_df( + self._cells, mask=df["n_agents"] == df["capacity"] + ) + + +class GridDF(DiscreteSpaceDF): + _agents: DataFrame + _cells: DataFrame + _empty_grid: GridCapacity + _torus: bool + _offsets: DataFrame + + def __init__( + self, + model: ModelDF, + dimensions: Sequence[int], + torus: bool = False, + capacity: int | None = None, + neighborhood_type: str = "moore", + ): + """Grid cells are indexed, where [0, ..., 0] is assumed to be the + bottom-left and [dimensions[0]-1, ..., dimensions[n]-1] is the top-right. If a grid is + toroidal, the top and bottom, and left and right, edges wrap to each other. + + Parameters + ---------- + model : ModelDF + The model selfect to which the grid belongs + dimensions: Sequence[int] + The dimensions of the grid + torus : bool, optional + If the grid should be a torus, by default False + capacity : int | None, optional + The maximum number of agents that can be placed in a cell, by default None + neighborhood_type: str, optional + The type of neighborhood to consider, by default 'moore'. + If 'moore', the neighborhood is the 8 cells around the center cell. + If 'von_neumann', the neighborhood is the 4 cells around the center cell. + If 'hexagonal', the neighborhood is 6 cells around the center cell. + """ + super().__init__(model, capacity) + self._dimensions = dimensions + self._torus = torus + self._cells_col_names = [f"dim_{k}" for k in range(len(dimensions))] + self._center_col_names = [x + "_center" for x in self._cells_col_names] + self._agents = self._df_constructor( + columns=["agent_id"] + self._cells_col_names, index_col="agent_id" + ) + self._cells = self._df_constructor( + columns=self._cells_col_names + ["capacity"], + index_cols=self._cells_col_names, + ) + self._offsets = self._compute_offsets(neighborhood_type) + self._empty_grid = self._generate_empty_grid(dimensions) + + def get_directions( + self, + pos0: GridCoordinate | GridCoordinates | None = None, + pos1: GridCoordinate | GridCoordinates | None = None, + agents0: int | Sequence[int] | None = None, + agents1: int | Sequence[int] | None = None, + ) -> DataFrame: + pos0_df = self._get_df_coords(pos0, agents0) + pos1_df = self._get_df_coords(pos1, agents1) + assert len(pos0_df) == len(pos1_df), "objects must have the same length" + return pos1_df - pos0_df + + def get_neighbors( + self, + radius: int | Sequence[int], + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> DataFrame: + assert ( + pos is None and agents is not None or pos is not None and agents is None + ), "Either pos or agents must be specified" + neighborhood_df = self.get_neighborhood( + radius=radius, pos=pos, agents=agents, include_center=include_center + ) + return self._df_get_masked_df( + neighborhood_df, index_col="agent_id", columns=self._agents.columns + ) + + def get_cells(self, cells: GridCoordinates | None = None) -> DataFrame: + coords = self._get_df_coords(cells) + return self._get_cells_df(coords) + + def move_agents( + self, + agents: AgentSetDF | Iterable[AgentSetDF] | int | Sequence[int], + pos: GridCoordinates, + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + + # Get Ids of agents + if isinstance(agents, AgentContainer | Collection[AgentContainer]): + agents = agents.index + + # Check ids presence in model + b_contained = obj.model.agents.contains(agents) + if (isinstance(b_contained, pl.Series) and not b_contained.all()) or ( + isinstance(b_contained, bool) and not b_contained + ): + raise ValueError("Some agents are not in the model") + + # Check ids are unique + agents = pl.Series(agents) + if agents.unique_counts() != len(agents): + raise ValueError("Some agents are present multiple times") + + # Warn if agents are already placed + if agents.is_in(obj._agents["agent_id"]): + warn("Some agents are already placed in the grid", RuntimeWarning) + + # Place agents (checking that capacity is not ) + coords = obj._get_df_coords(pos) + obj._agents = obj._place_agents_df(agents, coords) + return obj + + def out_of_bounds(self, pos: SpaceCoordinates) -> DataFrame: + """Check if a position is out of bounds. + + Parameters + ---------- + pos : SpaceCoordinates + + + Returns + ------- + DataFrame + A DataFrame with a' column representing the coordinates and an 'out_of_bounds' containing boolean values. + """ + pos_df = self._get_df_coords(pos) + out_of_bounds = pos_df < 0 | pos_df >= self._dimensions + return self._df_constructor( + data=[pos_df, out_of_bounds], + ) + + def remove_agents( + self, + agents: AgentContainer | Collection[AgentContainer] | int | Sequence[int], + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + + # Get Ids of agents + if isinstance(agents, AgentContainer | Collection[AgentContainer]): + agents = agents.index + + # Check ids presence in model + b_contained = obj.model.agents.contains(agents) + if (isinstance(b_contained, pl.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._agents = obj._df_remove(obj._agents, ids=agents, index_col="agent_id") + + return obj + + def torus_adj(self, pos: GridCoordinates) -> DataFrame: + """Get the toroidal adjusted coordinates of a position. + + Parameters + ---------- + pos : GridCoordinates + The coordinates to adjust + + Returns + ------- + DataFrame + The adjusted coordinates + """ + df_coords = self._get_df_coords(pos) + df_coords = df_coords % self._dimensions + return df_coords + + @abstractmethod + def get_neighborhood( + self, + radius: int | Sequence[int], + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> DataFrame: ... + + def _get_df_coords( + self, + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + ) -> DataFrame: + """Get the DataFrame of coordinates from the specified positions or agents. + + Parameters + ---------- + pos : GridCoordinate | GridCoordinates | None, optional + agents : int | Sequence[int] | None, optional + + Returns + ------- + DataFrame + A dataframe where each column represent a column + + Raises + ------ + ValueError + If neither pos or agents are specified + """ + assert ( + pos is not None or agents is not None + ), "Either pos or agents must be specified" + if agents: + return self._df_get_masked_df( + self._agents, index_col="agent_id", mask=agents + ) + if isinstance(pos, DataFrame): + return pos[self._cells_col_names] + elif isinstance(pos, Sequence) 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] = pl.arange(start=start, end=stop, step=step) + elif isinstance(c, int): + pos[i] = [c] + return self._df_constructor(data=pos, columns=self._cells_col_names) + elif isinstance(pos, Collection) and all( + len(c) == len(self._dimensions) for c in pos + ): + # This means that we have a collection of coordinates + sequences = [] + for i in range(len(self._dimensions)): + sequences.append([c[i] for c in pos]) + return self._df_constructor(data=sequences, columns=self._cells_col_names) + elif isinstance(pos, int) and len(self._dimensions) == 1: + return self._df_constructor(data=[pos], columns=self._cells_col_names) + else: + raise ValueError("Invalid coordinates") + + def _compute_offsets(self, neighborhood_type: str) -> DataFrame: + """Generate offsets for the neighborhood. + + Parameters + ---------- + neighborhood_type : str + _description_ + + Returns + ------- + DataFrame + _description_ + + Raises + ------ + ValueError + _description_ + ValueError + _description_ + """ + 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 len(self._dimensions) != 2: + raise ValueError("Hexagonal grid only supports 2 dimensions") + directions = [(-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0)] + else: + raise ValueError("Invalid neighborhood type specified") + + return self._df_constructor(data=directions, columns=self._cells_col_names) + + @abstractmethod + def _generate_empty_grid(self, dimensions: Sequence[int]) -> Any: + """Generate an empty grid with the specified dimensions. + + Parameters + ---------- + dimensions : Sequence[int] + + Returns + ------- + Any + """ + + @abstractmethod + def _get_cells_df(self, coords: GridCoordinates) -> DataFrame: ... + + @abstractmethod + def _place_agents_df( + self, agents: int | Sequence[int], coords: GridCoordinates + ) -> DataFrame: ... + + @abstractmethod + def _sample_cells( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[GridCapacity], GridCapacity], + ) -> DataFrame: ... + + def __getitem__(self, cells: GridCoordinates): + return super().__getitem__(cells) + + def __setitem__(self, cells: GridCoordinates, properties: DataFrame): + return super().__setitem__(cells, properties) + + +class GridPandas(GridDF, PandasMixin): + _agents: pd.DataFrame + _cells: pd.DataFrame + _empty_grid: np.ndarray + _offsets: pd.DataFrame + + def get_distances( + self, + pos0: SpaceCoordinate | SpaceCoordinates | None = None, + pos1: SpaceCoordinate | SpaceCoordinates | None = None, + agents0: int | Sequence[int] | None = None, + agents1: int | Sequence[int] | None = None, + ) -> pd.DataFrame: + pos0_df = self._get_df_coords(pos0, agents0) + pos1_df = self._get_df_coords(pos1, agents1) + return pd.DataFrame(np.linalg.norm(pos1_df - pos0_df, axis=1)) + + def get_neighborhood( + self, + radius: int | Sequence[int], + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> pd.DataFrame: + pos_df = self._get_df_coords(pos) + + # Create all possible neighbors by multipling directions by the radius and adding original pos + neighbors_df = self._offsets.join( + [pd.Series(np.arange(1, radius + 1), name="radius"), pos_df], + how="cross", + rsuffix="_center", + ) + + neighbors_df = ( + neighbors_df[self._cells_col_names] * neighbors_df["radius"] + + neighbors_df[self._center_col_names] + ).drop(columns=["radius"]) + + # If torus, "normalize" (take modulo) for out-of-bounds cells + if self._torus: + neighbors_df = self.torus_adj(neighbors_df) + + # Filter out-of-bound neighbors (all ensures that if any coordinates violates, it gets excluded) + neighbors_df = neighbors_df[ + ((neighbors_df >= 0) & (neighbors_df < self._dimensions)).all(axis=1) + ] + + if include_center: + pos_df[self._center_col_names] = pos_df[self._cells_col_names] + neighbors_df = pd.concat([neighbors_df, pos_df], ignore_index=True) + + return neighbors_df + + def set_cells(self, df: pd.DataFrame, inplace: bool = True) -> Self: + if df.index.names != self._cells_col_names or not all( + k in df.columns for k in self._cells_col_names + ): + raise ValueError( + "The dataframe must have columns/MultiIndex 'dim_0', 'dim_1', ..." + ) + obj = self._get_obj(inplace) + df = df.set_index(self._cells_col_names) + obj._cells = df.combine_first(obj._cells) + return obj + + def _generate_empty_grid( + self, dimensions: Sequence[int], capacity: int + ) -> np.ogrid: + return np.full(dimensions, capacity, dtype=int) + + def _get_df_coords( + self, + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + ) -> pd.DataFrame: + return super()._get_df_coords(pos=pos, agents=agents) + + def _get_cells_df(self, coords: GridCoordinates) -> pd.DataFrame: + return ( + pd.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) + .set_index(self._cells_col_names) + .merge( + self._agents.reset_index(), + how="left", + left_index=True, + right_on=self._cells_col_names, + ) + .groupby(level=self._cells_col_names) + .agg(agents=("index", list), n_agents=("index", "size")) + .merge(self._cells, how="left", left_index=True, right_index=True) + ) + + def _place_agents_df( + self, agents: int | Sequence[int], coords: GridCoordinates + ) -> pd.DataFrame: + new_df = pd.DataFrame( + {k: v for k, v in zip(self._cells_col_names, coords)}, + index=pd.Index(agents, name="agent_id"), + ) + new_df = self._agents.combine_first(new_df) + + # Check if the capacity is respected + capacity_df = ( + new_df.value_counts(subset=self._cells_col_names) + .to_frame("n_agents") + .merge(self._cells["capacity"], on=self._cells_col_names) + ) + capacity_df["capacity"] = capacity_df["capacity"].fillna(self._capacity) + if (capacity_df["n_agents"] > capacity_df["capacity"]).any(): + raise ValueError( + "There is at least a cell where the number of agents would be higher than the capacity of the cell" + ) + + return new_df + + def _sample_cells( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[np.ndarray], np.ndarray], + ) -> pd.DataFrame: + # Get the coordinates and remaining capacities of the cells + coords = np.array(np.where(condition(self._empty_grid))).T + capacities = self._empty_grid[tuple(coords.T)] + + if n is not None: + if with_replacement: + assert ( + n <= capacities.sum() + ), "Requested sample size exceeds the total available capacity." + + # Initialize the sampled coordinates list + sampled_coords = [] + + # Resample until we have the correct number of samples with valid capacities + while len(sampled_coords) < n: + # Calculate the remaining samples needed + remaining_samples = n - len(sampled_coords) + + # Compute uniform probabilities for sampling (excluding full cells) + probabilities = np.ones(len(coords)) / len(coords) + + # Sample with replacement using uniform probabilities + sampled_indices = np.random.choice( + len(coords), + size=remaining_samples, + replace=True, + p=probabilities, + ) + new_sampled_coords = coords[sampled_indices] + + # Update capacities + unique_coords, counts = np.unique( + new_sampled_coords, axis=0, return_counts=True + ) + self._empty_grid[tuple(unique_coords.T)] -= counts + + # Check if any cells exceed their capacity and need to be resampled + over_capacity_mask = self._empty_grid[tuple(unique_coords.T)] < 0 + valid_coords = unique_coords[~over_capacity_mask] + invalid_coords = unique_coords[over_capacity_mask] + + # Add valid coordinates to the sampled list + sampled_coords.extend(valid_coords) + + # Restore capacities for invalid coordinates + if len(invalid_coords) > 0: + self._empty_grid[tuple(invalid_coords.T)] += counts[ + over_capacity_mask + ] + + # Update coords based on the current state of the grid + coords = np.array(np.where(condition(self._empty_grid))).T + + sampled_coords = np.array(sampled_coords[:n]) + else: + assert n <= len( + coords + ), "Requested sample size exceeds the number of available cells." + + # Sample without replacement + sampled_indices = np.random.choice(len(coords), size=n, replace=False) + sampled_coords = coords[sampled_indices] + + # No need to update capacities as sampling is without replacement + else: + sampled_coords = coords + + # Convert the coordinates to a DataFrame + sampled_cells = pd.DataFrame(sampled_coords, columns=self._cells_col_names) + + return sampled_cells + + +class GridPolars(GridDF, PolarsMixin): + _agents: pl.DataFrame + _cells: pl.DataFrame + _empty_grid: list[pl.Expr] + _offsets: pl.DataFrame + + def get_distances( + self, + pos0: SpaceCoordinate | SpaceCoordinates | None = None, + pos1: SpaceCoordinate | SpaceCoordinates | None = None, + agents0: int | Sequence[int] | None = None, + agents1: int | Sequence[int] | None = None, + ) -> pl.DataFrame: + pos0_df = self._get_df_coords(pos0, agents0) + pos1_df = self._get_df_coords(pos1, agents1) + return pos0_df - pos1_df + + def get_neighborhood( + self, + radius: int | Sequence[int], + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> pl.DataFrame: + pos_df = self._get_df_coords(pos) + + # Create all possible neighbors by multiplying directions by the radius and adding original pos + neighbors_df = self._offsets.join( + [pl.arange(1, radius + 1, eager=True).to_frame(name="radius"), pos_df], + how="cross", + suffix="_center", + ) + + neighbors_df = neighbors_df.with_columns( + ( + pl.col(self._cells_col_names) * pl.col("radius") + + pl.col(self._center_col_names) + ).alias(pl.col(self._cells_col_names)) + ).drop("radius") + + # If torus, "normalize" (take modulo) for out-of-bounds cells + if self._torus: + neighbors_df = self.torus_adj(neighbors_df) + neighbors_df = cast( + pl.DataFrame, neighbors_df + ) # Previous return is Any according to linter but should be DataFrame + + # Filter out-of-bound neighbors + neighbors_df = neighbors_df.filter( + pl.all((neighbors_df < self._dimensions) & (neighbors_df >= 0)) + ) + + if include_center: + pos_df.with_columns( + pl.col(self._cells_col_names).alias(self._center_col_names) + ) + neighbors_df = pl.concat([neighbors_df, pos_df], how="vertical") + + return neighbors_df + + def set_cells(self, df: pl.DataFrame, inplace: bool = True) -> Self: + if not all(k in df.columns for k in self._cells_col_names): + raise ValueError( + "The dataframe must have an columns/MultiIndex 'dim_0', 'dim_1', ..." + ) + obj = self._get_obj(inplace) + obj._cells = obj._combine_first(obj._cells, df, on=self._cells_col_names) + return obj + + def _generate_empty_grid(self, dimensions: Sequence[int]) -> list[pl.Expr]: + return [pl.arange(0, d, eager=False) for d in dimensions] + + def _get_df_coords( + self, + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + ) -> pl.DataFrame: + return super()._get_df_coords(pos, agents) + + def _get_cells_df(self, coords: GridCoordinates) -> pl.DataFrame: + return ( + pl.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) + .join(self._agents, how="left", on=self._cells_col_names) + .group_by(self._cells_col_names) + .agg( + pl.col("agent_id").list().alias("agents"), + pl.col("agent_id").count().alias("n_agents"), + ) + .join(self._cells, on=self._cells_col_names, how="left") + ) + + def _place_agents_df( + self, agents: int | Sequence[int], coords: GridCoordinates + ) -> pl.DataFrame: + new_df = pl.DataFrame( + {"agent_id": agents}.update( + {k: v for k, v in zip(self._cells_col_names, coords)} + ) + ) + new_df: pl.DataFrame = self._df_combine_first( + self._agents, new_df, on="agent_id" + ) + + # Check if the capacity is respected + capacity_df = ( + new_df.group_by(self._cells_col_names) + .count() + .join( + self._cells[self._cells_col_names + ["capacity"]], + on=self._cells_col_names, + ) + ) + capacity_df = capacity_df.with_columns( + capacity=pl.col("capacity").fill_null(self._capacity) + ) + if (capacity_df["count"] > capacity_df["capacity"]).any(): + raise ValueError( + "There is at least a cell where the number of agents would be higher than the capacity of the cell" + ) + + return new_df + + def _sample_cells_lazy( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[pl.Expr], pl.Expr], + ) -> pl.DataFrame: + # Create a base DataFrame with all grid coordinates and default capacities + grid_df = pl.DataFrame(self._empty_grid).with_columns( + [pl.lit(self._capacity).alias("capacity")] + ) + + # Apply the condition to filter the cells + grid_df = grid_df.filter(condition(pl.col("capacity"))) + + if n is not None: + if with_replacement: + assert ( + n <= grid_df.select(pl.sum("capacity")).item() + ), "Requested sample size exceeds the total available capacity." + + # Initialize the sampled DataFrame + sampled_df = pl.DataFrame() + + # Resample until we have the correct number of samples with valid capacities + while sampled_df.shape[0] < n: + # Calculate the remaining samples needed + remaining_samples = n - sampled_df.shape[0] + + # Sample with replacement using uniform probabilities + sampled_part = grid_df.sample( + n=remaining_samples, with_replacement=True + ) + + # Count occurrences of each sampled coordinate + count_df = sampled_part.group_by(self._cells_col_names).agg( + pl.count("capacity").alias("sampled_count") + ) + + # Adjust capacities based on counts + grid_df = ( + grid_df.join(count_df, on=self._cells_col_names, how="left") + .with_columns( + [ + ( + pl.col("capacity") + - pl.col("sampled_count").fill_null(0) + ).alias("capacity") + ] + ) + .drop("sampled_count") + ) + + # Ensure no cell exceeds its capacity + valid_sampled_part = sampled_part.join( + grid_df.filter(pl.col("capacity") >= 0), + on=self._cells_col_names, + how="inner", + ) + + # Add valid samples to the result + sampled_df = pl.concat([sampled_df, valid_sampled_part]) + + # Filter out over-capacity cells from the grid + grid_df = grid_df.filter(pl.col("capacity") > 0) + + sampled_df = sampled_df.head(n) # Ensure we have exactly n samples + else: + assert ( + n <= grid_df.height + ), "Requested sample size exceeds the number of available cells." + + # Sample without replacement + sampled_df = grid_df.sample(n=n, with_replacement=False) + else: + sampled_df = grid_df + + return sampled_df + + def _sample_cells_eager( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[pl.Expr], pl.Expr], + ) -> pl.DataFrame: + # Create a base DataFrame with all grid coordinates and default capacities + grid_df = pl.DataFrame(self._empty_grid).with_columns( + [pl.lit(self._capacity).alias("capacity")] + ) + + # If there are any specific capacities in self._cells, update the grid_df with these values + if not self._cells.is_empty(): + grid_df = ( + grid_df.join(self._cells, on=self._cells_col_names, how="left") + .with_columns( + [ + pl.col("capacity_right") + .fill_null(pl.col("capacity")) + .alias("capacity") + ] + ) + .drop("capacity_right") + ) + + # Apply the condition to filter the cells + grid_df = grid_df.filter(condition(pl.col("capacity"))) + + if n is not None: + if with_replacement: + assert ( + n <= grid_df.select(pl.sum("capacity")).item() + ), "Requested sample size exceeds the total available capacity." + + # Initialize the sampled DataFrame + sampled_df = pl.DataFrame() + + # Resample until we have the correct number of samples with valid capacities + while sampled_df.shape[0] < n: + # Calculate the remaining samples needed + remaining_samples = n - sampled_df.shape[0] + + # Sample with replacement using uniform probabilities + sampled_part = grid_df.sample( + n=remaining_samples, with_replacement=True + ) + + # Count occurrences of each sampled coordinate + count_df = sampled_part.group_by(self._cells_col_names).agg( + pl.count("capacity").alias("sampled_count") + ) + + # Adjust capacities based on counts + grid_df = ( + grid_df.join(count_df, on=self._cells_col_names, how="left") + .with_columns( + [ + ( + pl.col("capacity") + - pl.col("sampled_count").fill_null(0) + ).alias("capacity") + ] + ) + .drop("sampled_count") + ) + + # Ensure no cell exceeds its capacity + valid_sampled_part = sampled_part.join( + grid_df.filter(pl.col("capacity") >= 0), + on=self._cells_col_names, + how="inner", + ) + + # Add valid samples to the result + sampled_df = pl.concat([sampled_df, valid_sampled_part]) + + # Filter out over-capacity cells from the grid + grid_df = grid_df.filter(pl.col("capacity") > 0) + + sampled_df = sampled_df.head(n) # Ensure we have exactly n samples + else: + assert ( + n <= grid_df.height + ), "Requested sample size exceeds the number of available cells." + + # Sample without replacement + sampled_df = grid_df.sample(n=n, with_replacement=False) + else: + sampled_df = grid_df + + return sampled_df + + def _sample_cells( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[pl.Expr], pl.Expr], + ) -> pl.DataFrame: + if "capacity" not in self._cells.columns: + return self._sample_cells_lazy(n, with_replacement, condition) + else: + return self._sample_cells_eager(n, with_replacement, condition) + + +class GeoGridDF(GridDF, GeoSpaceDF): ... + + +class NetworkDF(DiscreteSpaceDF): + _network: nx.Graph + _nodes: pd.DataFrame + _links: pd.DataFrame + + def torus_adj(self, pos): + raise NotImplementedError("No concept of torus in Networks") + + @abstractmethod + def __iter__(self) -> Iterable: + pass + + @abstractmethod + def connect(self, nodes0, nodes1): + pass + + @abstractmethod + def disconnect(self, nodes0, nodes1): + pass + + +class ContinousSpaceDF(GeoSpaceDF): + _agents: gpd.GeoDataFrame + _limits: Sequence[float] + + def __init__(self, model: ModelDF, ref_sys: CRS | ESPG | str | None = None) -> None: + """Create a new CellSet object. + + Parameters + ---------- + model : ModelDF + ref_sys : CRS | ESPG | str | None, optional + Coordinate Reference System. ESPG is an integer, by default None + + Returns + ------- + None + """ + super().__init__(model) + self._cells = gpd.GeoDataFrame(columns=["agent_id", "geometry"], crs=ref_sys) + + def get_neighborhood( + self, + pos: shp.Point | Sequence[tuple[int | float, int | float]], + radius: float | int, + include_center: bool = False, + inplace: bool = True, + **kwargs, + ) -> gpd.GeoDataFrame: + """Get the neighborhood cells from a given position. + + Parameters + ---------- + pos : shp.Point | Sequence[int, int] + The selfect to get the neighborhood from. + radius : float | int + The radius of the neighborhood + include_center : bool, optional + If the cell in the center of the neighborhood should be included in the result, by default False + inplace : bool, optional + If the method should return a new instance of the class or modify the current one, by default True + **kwargs + Extra arguments to be passed to shp.Point.buffer. + + Returns + ------- + GeoDataFrame + Cells in the neighborhood + """ + if isinstance(pos, Sequence[int, int]): + pos = shp.Point(pos) + pos = pos.buffer(distance=radius, **kwargs) + if include_center: + return self._cells[self._cells.within(other=pos)] + else: + return self._cells[ + self._cells.within(other=pos) & ~self._cells.intersects(other=pos) + ] + + def get_direction(self, pos0, pos1): + pass + + @property + def crs(self) -> CRS: + if self._agents.crs is None: + raise ValueError("CRS not set") + return self._agents.crs + + @crs.setter + def crs(self, ref_sys: CRS | ESPG | str | None): + if isinstance(ref_sys, ESPG): + self._agents = self._agents.to_crs(espg=ref_sys) + else: + self._agents = self._agents.to_crs(crs=ref_sys) + return self + + +class MultiSpaceDF(Collection[SpaceDF]): + _spaces: Collection[SpaceDF] From a9c9925e56567d4388896b17df881b5f5563f585 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 00:23:43 +0200 Subject: [PATCH 06/21] removing space types (has it's own PR) --- mesa_frames/types_.py | 43 ++----------------------------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/mesa_frames/types_.py b/mesa_frames/types_.py index bc4e1ec..c34e792 100644 --- a/mesa_frames/types_.py +++ b/mesa_frames/types_.py @@ -1,12 +1,9 @@ from collections.abc import Collection +from typing import Literal -import geopandas as gpd -import geopolars as gpl -import numpy as np import pandas as pd import polars as pl from numpy import ndarray -from typing import Literal, Sequence ####----- Agnostic Types -----#### AgnosticMask = Literal["all", "active"] | None @@ -18,20 +15,16 @@ AnyArrayLike = ArrayLike | pd.Index | pd.Series PandasMaskLike = AgnosticMask | pd.Series | pd.DataFrame | AnyArrayLike PandasIdsLike = AgnosticIds | pd.Series | pd.Index -PandasGridCapacity = np.ndarray ###----- Polars Types -----### PolarsMaskLike = AgnosticMask | pl.Expr | pl.Series | pl.DataFrame | Collection[int] PolarsIdsLike = AgnosticIds | pl.Series -PolarsGridCapacity = list[pl.Expr] ###----- Generic -----### -GeoDataFrame = gpd.GeoDataFrame | gpl.GeoDataFrame -GeoSeries = gpd.GeoSeries | gpl.GeoSeries DataFrame = pd.DataFrame | pl.DataFrame -Series = pd.Series | pl.Series | GeoSeries +Series = pd.Series | pl.Series Index = pd.Index | pl.Series BoolSeries = pd.Series | pl.Series MaskLike = AgnosticMask | PandasMaskLike | PolarsMaskLike @@ -39,35 +32,3 @@ ###----- Time ------### TimeT = float | int - - -###----- Space -----### - -NetworkCoordinate = int | DataFrame - -GridCoordinate = int | Sequence[int] | DataFrame - -DiscreteCoordinate = NetworkCoordinate | GridCoordinate -ContinousCoordinate = float | Sequence[float] | DataFrame - -SpaceCoordinate = DiscreteCoordinate | ContinousCoordinate - - -NetworkCoordinates = NetworkCoordinate | Collection[NetworkCoordinate] -GridCoordinates = ( - GridCoordinate | Sequence[int | slice | Sequence[int]] | Collection[GridCoordinate] -) - -DiscreteCoordinates = NetworkCoordinates | GridCoordinates -ContinousCoordinates = ( - ContinousCoordinate - | Sequence[float | Sequence[float]] - | Collection[ContinousCoordinate] -) - -SpaceCoordinates = DiscreteCoordinates | ContinousCoordinates - -GridCapacity = PandasGridCapacity | PolarsGridCapacity -NetworkCapacity = DataFrame - -DiscreteSpaceCapacity = GridCapacity | NetworkCapacity From cecf5af256e228268187b9942f400ae609442207 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 22:23:56 +0000 Subject: [PATCH 07/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa_frames/abstract/mixin.py | 3 ++- mesa_frames/concrete/pandas/mixin.py | 3 ++- mesa_frames/concrete/polars/mixin.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mesa_frames/abstract/mixin.py b/mesa_frames/abstract/mixin.py index 088901e..6f59e2c 100644 --- a/mesa_frames/abstract/mixin.py +++ b/mesa_frames/abstract/mixin.py @@ -2,7 +2,8 @@ from copy import copy, deepcopy from typing_extensions import Any, Self -from typing import Collection, Iterator, Literal, Sequence +from typing import Literal +from collections.abc import Collection, Iterator, Sequence from mesa_frames.types_ import BoolSeries, DataFrame, MaskLike, Series diff --git a/mesa_frames/concrete/pandas/mixin.py b/mesa_frames/concrete/pandas/mixin.py index fca88e9..bb1d546 100644 --- a/mesa_frames/concrete/pandas/mixin.py +++ b/mesa_frames/concrete/pandas/mixin.py @@ -1,6 +1,7 @@ import pandas as pd from typing_extensions import Any -from typing import Collection, Iterator, Literal, Sequence +from typing import Literal +from collections.abc import Collection, Iterator, Sequence from mesa_frames.abstract.mixin import DataFrameMixin from mesa_frames.types_ import PandasMaskLike diff --git a/mesa_frames/concrete/polars/mixin.py b/mesa_frames/concrete/polars/mixin.py index 70ccfdd..e292281 100644 --- a/mesa_frames/concrete/polars/mixin.py +++ b/mesa_frames/concrete/polars/mixin.py @@ -1,6 +1,7 @@ import polars as pl from typing_extensions import Any -from typing import Collection, Iterator, Literal, Sequence +from typing import Literal +from collections.abc import Collection, Iterator, Sequence from mesa_frames.abstract.mixin import DataFrameMixin from mesa_frames.types_ import PolarsMaskLike From 3d7246159377e8e2c6653b47b1e7bc236f8e5009 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 00:28:29 +0200 Subject: [PATCH 08/21] update space types --- mesa_frames/types_.py | 55 ++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/mesa_frames/types_.py b/mesa_frames/types_.py index a2c273c..b9056ff 100644 --- a/mesa_frames/types_.py +++ b/mesa_frames/types_.py @@ -1,4 +1,5 @@ from collections.abc import Collection +from typing import Literal, Sequence import geopandas as gpd import geopolars as gpl @@ -17,18 +18,20 @@ AnyArrayLike = ArrayLike | pd.Index | pd.Series PandasMaskLike = AgnosticMask | pd.Series | pd.DataFrame | AnyArrayLike PandasIdsLike = AgnosticIds | pd.Series | pd.Index +PandasGridCapacity = ndarray ###----- Polars Types -----### PolarsMaskLike = AgnosticMask | pl.Expr | pl.Series | pl.DataFrame | Collection[int] PolarsIdsLike = AgnosticIds | pl.Series +PolarsGridCapacity = list[pl.Expr] ###----- Generic -----### -GeoDataFame = gpd.GeoDataFrame | gpl.GeoDataFrame +GeoDataFrame = gpd.GeoDataFrame | gpl.GeoDataFrame GeoSeries = gpd.GeoSeries | gpl.GeoSeries -DataFrame = pd.DataFrame | pl.DataFrame | GeoDataFame -Series = pd.Series | pl.Series | GeoSeries +DataFrame = pd.DataFrame | pl.DataFrame +Series = pd.Series | pl.Series Index = pd.Index | pl.Series BoolSeries = pd.Series | pl.Series MaskLike = AgnosticMask | PandasMaskLike | PolarsMaskLike @@ -38,25 +41,33 @@ ###----- Time ------### TimeT = float | int - ###----- Space -----### -Coordinates = tuple[int, int] | tuple[float, float] -Node_ID = int -AgnosticPositionsLike = ( - Sequence[Coordinates] | Sequence[Node_ID] | Coordinates | Node_ID -) -PolarsPositionsLike = ( - AgnosticPositionsLike - | pl.DataFrame - | tuple[pl.Series, pl.Series] - | gpl.GeoSeries - | pl.Series + +NetworkCoordinate = int | DataFrame + +GridCoordinate = int | Sequence[int] | DataFrame + +DiscreteCoordinate = NetworkCoordinate | GridCoordinate +ContinousCoordinate = float | Sequence[float] | DataFrame + +SpaceCoordinate = DiscreteCoordinate | ContinousCoordinate + + +NetworkCoordinates = NetworkCoordinate | Collection[NetworkCoordinate] +GridCoordinates = ( + GridCoordinate | Sequence[int | slice | Sequence[int]] | Collection[GridCoordinate] ) -PandasPositionsLike = ( - AgnosticPositionsLike - | pd.DataFrame - | tuple[pd.Series, pd.Series] - | gpd.GeoSeries - | pd.Series + +DiscreteCoordinates = NetworkCoordinates | GridCoordinates +ContinousCoordinates = ( + ContinousCoordinate + | Sequence[float | Sequence[float]] + | Collection[ContinousCoordinate] ) -PositionsLike = AgnosticPositionsLike | PolarsPositionsLike | PandasPositionsLike + +SpaceCoordinates = DiscreteCoordinates | ContinousCoordinates + +GridCapacity = PandasGridCapacity | PolarsGridCapacity +NetworkCapacity = DataFrame + +DiscreteSpaceCapacity = GridCapacity | NetworkCapacity From 4722edbbd4a4fba04943706b575a4b4a8db42288 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 00:31:37 +0200 Subject: [PATCH 09/21] update types with types_ --- mesa_frames/concrete/agentset_pandas.py | 2 +- mesa_frames/concrete/agentset_polars.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mesa_frames/concrete/agentset_pandas.py b/mesa_frames/concrete/agentset_pandas.py index 8f4ce4d..152b2b1 100644 --- a/mesa_frames/concrete/agentset_pandas.py +++ b/mesa_frames/concrete/agentset_pandas.py @@ -7,7 +7,7 @@ from mesa_frames.abstract.agents import AgentSetDF from mesa_frames.concrete.agentset_polars import AgentSetPolars -from mesa_frames.types import PandasIdsLike, PandasMaskLike +from mesa_frames.types_ import PandasIdsLike, PandasMaskLike if TYPE_CHECKING: from mesa_frames.concrete.model import ModelDF diff --git a/mesa_frames/concrete/agentset_polars.py b/mesa_frames/concrete/agentset_polars.py index 358a310..72785e3 100644 --- a/mesa_frames/concrete/agentset_polars.py +++ b/mesa_frames/concrete/agentset_polars.py @@ -6,7 +6,7 @@ from typing_extensions import Any, Self, overload from mesa_frames.concrete.agents import AgentSetDF -from mesa_frames.types import PolarsIdsLike, PolarsMaskLike +from mesa_frames.types_ import PolarsIdsLike, PolarsMaskLike if TYPE_CHECKING: from mesa_frames.concrete.agentset_pandas import AgentSetPandas diff --git a/pyproject.toml b/pyproject.toml index cd17c04..4a8e2ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ pandas = [ "pyarrow", ] polars = [ - "polars>=1.0.0", #polars._typing (see mesa_frames.types) added in 1.0.0 + "polars>=1.0.0", #polars._typing (see mesa_frames.types_) added in 1.0.0 ] dev = [ "mesa_frames[pandas,polars]", From 21c5ef8487e9b912d9e1b24dc195bb4dd3d1475f Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:47:25 +0200 Subject: [PATCH 10/21] Moved agentset to library folder --- mesa_frames/concrete/agentset_pandas.py | 439 ------------------- mesa_frames/concrete/agentset_polars.py | 554 ------------------------ mesa_frames/concrete/pandas/agentset.py | 20 +- mesa_frames/concrete/polars/agentset.py | 103 ++++- 4 files changed, 104 insertions(+), 1012 deletions(-) delete mode 100644 mesa_frames/concrete/agentset_pandas.py delete mode 100644 mesa_frames/concrete/agentset_polars.py diff --git a/mesa_frames/concrete/agentset_pandas.py b/mesa_frames/concrete/agentset_pandas.py deleted file mode 100644 index 152b2b1..0000000 --- a/mesa_frames/concrete/agentset_pandas.py +++ /dev/null @@ -1,439 +0,0 @@ -from collections.abc import Callable, Collection, Iterable, Iterator, Sequence -from typing import TYPE_CHECKING - -import pandas as pd -import polars as pl -from typing_extensions import Any, Self, overload - -from mesa_frames.abstract.agents import AgentSetDF -from mesa_frames.concrete.agentset_polars import AgentSetPolars -from mesa_frames.types_ import PandasIdsLike, PandasMaskLike - -if TYPE_CHECKING: - from mesa_frames.concrete.model import ModelDF - - -class AgentSetPandas(AgentSetDF): - _agents: pd.DataFrame - _mask: pd.Series - _copy_with_method: dict[str, tuple[str, list[str]]] = { - "_agents": ("copy", ["deep"]), - "_mask": ("copy", ["deep"]), - } - """A pandas-based implementation of the AgentSet. - - Attributes - ---------- - _agents : pd.DataFrame - The agents in the AgentSet. - _copy_only_reference : list[str] = ['_model'] - A list of attributes to copy with a reference only. - _copy_with_method: dict[str, tuple[str, list[str]]] = { - "_agents": ("copy", ["deep"]), - "_mask": ("copy", ["deep"]), - } - A dictionary of attributes to copy with a specified method and arguments. - _mask : pd.Series - A boolean mask indicating which agents are active. - _model : ModelDF - The model that the AgentSetDF belongs to. - - Properties - ---------- - active_agents(self) -> pd.DataFrame - Get the active agents in the AgentSetPandas. - agents(self) -> pd.DataFrame - Get or set the agents in the AgentSetPandas. - inactive_agents(self) -> pd.DataFrame - Get the inactive agents in the AgentSetPandas. - model(self) -> ModelDF - Get the model associated with the AgentSetPandas. - random(self) -> Generator - Get the random number generator associated with the model. - - Methods - ------- - __init__(self, model: ModelDF) -> None - Initialize a new AgentSetPandas. - add(self, other: pd.DataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True) -> Self - Add agents to the AgentSetPandas. - contains(self, ids: PandasIdsLike) -> bool | pd.Series - Check if agents with the specified IDs are in the AgentSetPandas. - copy(self, deep: bool = False, memo: dict | None = None) -> Self - Create a copy of the AgentSetPandas. - discard(self, ids: PandasIdsLike, inplace: bool = True) -> Self - Remove an agent from the AgentSetPandas. Does not raise an error if the agent is not found. - do(self, method_name: str, *args, return_results: bool = False, inplace: bool = True, **kwargs) -> Self | Any - Invoke a method on the AgentSetPandas. - get(self, attr_names: str | Collection[str] | None, mask: PandasMaskLike = None) -> pd.Series | pd.DataFrame - Retrieve the value of a specified attribute for each agent in the AgentSetPandas. - remove(self, ids: PandasIdsLike, inplace: bool = True) -> Self - Remove agents from the AgentSetPandas. - select(self, mask: PandasMaskLike = None, filter_func: Callable[[Self], PandasMaskLike] | None = None, n: int | None = None, negate: bool = False, inplace: bool = True) -> Self - Select agents in the AgentSetPandas based on the given criteria. - set(self, attr_names: str | Collection[str] | dict[str, Any] | None = None, values: Any | None = None, mask: PandasMaskLike | None = None, inplace: bool = True) -> Self - Set the value of a specified attribute or attributes for each agent in the mask in the AgentSetPandas. - shuffle(self, inplace: bool = True) -> Self - Shuffle the order of agents in the AgentSetPandas. - sort(self, by: str | Sequence[str], ascending: bool | Sequence[bool] = True, inplace: bool = True, **kwargs) -> Self - Sort the agents in the AgentSetPandas based on the given criteria. - to_polars(self) -> "AgentSetPolars" - Convert the AgentSetPandas to an AgentSetPolars. - _get_bool_mask(self, mask: PandasMaskLike = None) -> pd.Series - Get a boolean mask for selecting agents. - _get_masked_df(self, mask: PandasMaskLike = None) -> pd.DataFrame - Get a DataFrame of agents that match the mask. - __getattr__(self, key: str) -> pd.Series - Retrieve an attribute of the underlying DataFrame. - __iter__(self) -> Iterator - Get an iterator for the agents in the AgentSetPandas. - __len__(self) -> int - Get the number of agents in the AgentSetPandas. - __repr__(self) -> str - Get the string representation of the AgentSetPandas. - __reversed__(self) -> Iterator - Get a reversed iterator for the agents in the AgentSetPandas. - __str__(self) -> str - Get the string representation of the AgentSetPandas. - """ - - def __init__(self, model: "ModelDF") -> None: - self._model = model - self._agents = ( - pd.DataFrame(columns=["unique_id"]) - .astype({"unique_id": "int64"}) - .set_index("unique_id") - ) - self._mask = pd.Series(True, index=self._agents.index, dtype=pd.BooleanDtype()) - - def add( - self, - agents: pd.DataFrame | Sequence[Any] | dict[str, Any], - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - if isinstance(agents, pd.DataFrame): - new_agents = agents - if "unique_id" != agents.index.name: - try: - new_agents.set_index("unique_id", inplace=True, drop=True) - except KeyError: - raise KeyError("DataFrame must have a unique_id column/index.") - elif isinstance(agents, dict): - if "unique_id" not in agents: - raise KeyError("Dictionary must have a unique_id key.") - index = agents.pop("unique_id") - if not isinstance(index, list): - index = [index] - new_agents = pd.DataFrame(agents, index=pd.Index(index, name="unique_id")) - else: - if len(agents) != len(obj._agents.columns) + 1: - raise ValueError( - "Length of data must match the number of columns in the AgentSet if being added as a Collection." - ) - columns = pd.Index(["unique_id"]).append(obj._agents.columns.copy()) - new_agents = pd.DataFrame([agents], columns=columns).set_index( - "unique_id", drop=True - ) - - if new_agents.index.dtype != "int64": - raise TypeError("unique_id must be of type int64.") - - if not obj._agents.index.intersection(new_agents.index).empty: - raise KeyError("Some IDs already exist in the agent set.") - - original_active_indices = obj._mask.index[obj._mask].copy() - - obj._agents = pd.concat([obj._agents, new_agents]) - - obj._update_mask(original_active_indices, new_agents.index) - - return obj - - @overload - def contains(self, agents: int) -> bool: ... - - @overload - def contains(self, agents: PandasIdsLike) -> pd.Series: ... - - def contains(self, agents: PandasIdsLike) -> bool | pd.Series: - if isinstance(agents, pd.Series): - return agents.isin(self._agents.index) - elif isinstance(agents, pd.Index): - return pd.Series( - agents.isin(self._agents.index), index=agents, dtype=pd.BooleanDtype() - ) - elif isinstance(agents, Collection): - return pd.Series(list(agents), index=list(agents)).isin(self._agents.index) - else: - return agents in self._agents.index - - def get( - self, - attr_names: str | Collection[str] | None = None, - mask: PandasMaskLike = None, - ) -> pd.Index | pd.Series | pd.DataFrame: - mask = self._get_bool_mask(mask) - if attr_names is None: - return self._agents.loc[mask] - else: - if attr_names == "unique_id": - return self._agents.loc[mask].index - if isinstance(attr_names, str): - return self._agents.loc[mask, attr_names] - if isinstance(attr_names, Collection): - return self._agents.loc[mask, list(attr_names)] - - def remove( - self, - ids: PandasIdsLike, - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - initial_len = len(obj._agents) - mask = obj._get_bool_mask(ids) - remove_ids = obj._agents[mask].index - original_active_indices = obj._mask.index[obj._mask].copy() - obj._agents.drop(remove_ids, inplace=True) - if len(obj._agents) == initial_len: - raise KeyError("Some IDs were not found in agent set.") - - self._update_mask(original_active_indices) - return obj - - def set( - self, - attr_names: str | dict[str, Any] | Collection[str] | None = None, - values: Any | None = None, - mask: PandasMaskLike = None, - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - b_mask = obj._get_bool_mask(mask) - masked_df = obj._get_masked_df(mask) - - if not attr_names: - attr_names = masked_df.columns - - if isinstance(attr_names, dict): - for key, val in attr_names.items(): - masked_df.loc[:, key] = val - elif ( - isinstance(attr_names, str) - or ( - isinstance(attr_names, Collection) - and all(isinstance(n, str) for n in attr_names) - ) - ) and values is not None: - if not isinstance(attr_names, str): # isinstance(attr_names, Collection) - attr_names = list(attr_names) - masked_df.loc[:, attr_names] = values - else: - raise ValueError( - "Either attr_names must be a dictionary with columns as keys and values or values must be provided." - ) - - non_masked_df = obj._agents[~b_mask] - original_index = obj._agents.index - obj._agents = pd.concat([non_masked_df, masked_df]) - obj._agents = obj._agents.reindex(original_index) - return obj - - def select( - self, - mask: PandasMaskLike = None, - filter_func: Callable[[Self], PandasMaskLike] | None = None, - n: int | None = None, - negate: bool = False, - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - bool_mask = obj._get_bool_mask(mask) - if filter_func: - bool_mask = bool_mask & obj._get_bool_mask(filter_func(obj)) - if negate: - bool_mask = ~bool_mask - if n is not None: - bool_mask = pd.Series( - obj._agents.index.isin(obj._agents[bool_mask].sample(n).index), - index=obj._agents.index, - ) - obj._mask = bool_mask - return obj - - def shuffle(self, inplace: bool = True) -> Self: - obj = self._get_obj(inplace) - obj._agents = obj._agents.sample(frac=1) - return obj - - def sort( - self, - by: str | Sequence[str], - ascending: bool | Sequence[bool] = True, - inplace: bool = True, - **kwargs, - ) -> Self: - obj = self._get_obj(inplace) - obj._agents.sort_values(by=by, ascending=ascending, **kwargs, inplace=True) - return obj - - def to_polars(self) -> AgentSetPolars: - new_obj = AgentSetPolars(self._model) - new_obj._agents = pl.DataFrame(self._agents) - new_obj._mask = pl.Series(self._mask) - return new_obj - - def _concatenate_agentsets( - self, - agentsets: Iterable[Self], - duplicates_allowed: bool = True, - keep_first_only: bool = True, - original_masked_index: pd.Index | None = None, - ) -> Self: - if not duplicates_allowed: - indices = [self._agents.index.to_series()] + [ - agentset._agents.index.to_series() for agentset in agentsets - ] - pd.concat(indices, verify_integrity=True) - if duplicates_allowed & keep_first_only: - final_df = self._agents.copy() - final_mask = self._mask.copy() - for obj in iter(agentsets): - final_df = final_df.combine_first(obj._agents) - final_mask = final_mask.combine_first(obj._mask) - else: - final_df = pd.concat([obj._agents for obj in agentsets]) - final_mask = pd.concat([obj._mask for obj in agentsets]) - self._agents = final_df - self._mask = final_mask - if not isinstance(original_masked_index, type(None)): - ids_to_remove = original_masked_index.difference(self._agents.index) - if not ids_to_remove.empty: - self.remove(ids_to_remove, inplace=True) - return self - - def _get_bool_mask( - self, - mask: PandasMaskLike = None, - ) -> pd.Series: - if isinstance(mask, pd.Series) and mask.dtype == bool: - return mask - elif isinstance(mask, pd.DataFrame): - return pd.Series( - self._agents.index.isin(mask.index), index=self._agents.index - ) - elif isinstance(mask, list): - return pd.Series(self._agents.index.isin(mask), index=self._agents.index) - elif mask is None or mask == "all": - return pd.Series(True, index=self._agents.index) - elif mask == "active": - return self._mask - else: - return pd.Series(self._agents.index.isin([mask]), index=self._agents.index) - - def _get_masked_df( - self, - mask: PandasMaskLike = None, - ) -> pd.DataFrame: - if isinstance(mask, pd.Series) and mask.dtype == bool: - return self._agents.loc[mask] - elif isinstance(mask, pd.DataFrame): - if mask.index.name != "unique_id": - if "unique_id" in mask.columns: - mask.set_index("unique_id", inplace=True, drop=True) - else: - raise KeyError("DataFrame must have a unique_id column/index.") - return pd.DataFrame(index=mask.index).join( - self._agents, on="unique_id", how="left" - ) - elif isinstance(mask, pd.Series): - mask_df = mask.to_frame("unique_id").set_index("unique_id") - return mask_df.join(self._agents, on="unique_id", how="left") - elif mask is None or mask == "all": - return self._agents - elif mask == "active": - return self._agents.loc[self._mask] - else: - mask_series = pd.Series(mask) - mask_df = mask_series.to_frame("unique_id").set_index("unique_id") - return mask_df.join(self._agents, on="unique_id", how="left") - - @overload - def _get_obj_copy(self, obj: pd.Series) -> pd.Series: ... - - @overload - def _get_obj_copy(self, obj: pd.DataFrame) -> pd.DataFrame: ... - - @overload - def _get_obj_copy(self, obj: pd.Index) -> pd.Index: ... - - def _get_obj_copy( - self, obj: pd.Series | pd.DataFrame | pd.Index - ) -> pd.Series | pd.DataFrame | pd.Index: - return obj.copy() - - def _update_mask( - self, - original_active_indices: pd.Index, - new_active_indices: pd.Index | None = None, - ) -> None: - # Update the mask with the old active agents and the new agents - if new_active_indices is None: - self._mask = pd.Series( - self._agents.index.isin(original_active_indices), - index=self._agents.index, - dtype=pd.BooleanDtype(), - ) - else: - self._mask = pd.Series( - self._agents.index.isin(original_active_indices) - | self._agents.index.isin(new_active_indices), - index=self._agents.index, - dtype=pd.BooleanDtype(), - ) - - def __getattr__(self, name: str) -> Any: - super().__getattr__(name) - return getattr(self._agents, name) - - def __iter__(self) -> Iterator[dict[str, Any]]: - for index, row in self._agents.iterrows(): - row_dict = row.to_dict() - row_dict["unique_id"] = index - yield row_dict - - def __len__(self) -> int: - return len(self._agents) - - def __reversed__(self) -> Iterator: - return iter(self._agents[::-1].iterrows()) - - @property - def agents(self) -> pd.DataFrame: - return self._agents - - @agents.setter - def agents(self, new_agents: pd.DataFrame) -> None: - if new_agents.index.name == "unique_id": - pass - elif "unique_id" in new_agents.columns: - new_agents.set_index("unique_id", inplace=True, drop=True) - else: - raise KeyError("The DataFrame should have a 'unique_id' index/column") - self._agents = new_agents - - @property - def active_agents(self) -> pd.DataFrame: - return self._agents.loc[self._mask] - - @active_agents.setter - def active_agents(self, mask: PandasMaskLike) -> None: - self.select(mask=mask, inplace=True) - - @property - def inactive_agents(self) -> pd.DataFrame: - return self._agents.loc[~self._mask] - - @property - def index(self) -> pd.Index: - return self._agents.index diff --git a/mesa_frames/concrete/agentset_polars.py b/mesa_frames/concrete/agentset_polars.py deleted file mode 100644 index 72785e3..0000000 --- a/mesa_frames/concrete/agentset_polars.py +++ /dev/null @@ -1,554 +0,0 @@ -from collections.abc import Callable, Collection, Iterable, Iterator, Sequence -from typing import TYPE_CHECKING - -import polars as pl -from polars._typing import IntoExpr -from typing_extensions import Any, Self, overload - -from mesa_frames.concrete.agents import AgentSetDF -from mesa_frames.types_ import PolarsIdsLike, PolarsMaskLike - -if TYPE_CHECKING: - from mesa_frames.concrete.agentset_pandas import AgentSetPandas - from mesa_frames.concrete.model import ModelDF - - -class AgentSetPolars(AgentSetDF): - _agents: pl.DataFrame - _copy_with_method: dict[str, tuple[str, list[str]]] = { - "_agents": ("clone", []), - } - _copy_only_reference: list[str] = ["_model", "_mask"] - _mask: pl.Expr | pl.Series - - """A polars-based implementation of the AgentSet. - - Attributes - ---------- - _agents : pl.DataFrame - The agents in the AgentSet. - _copy_only_reference : list[str] = ["_model", "_mask"] - A list of attributes to copy with a reference only. - _copy_with_method: dict[str, tuple[str, list[str]]] = { - "_agents": ("copy", ["deep"]), - "_mask": ("copy", ["deep"]), - } - A dictionary of attributes to copy with a specified method and arguments. - model : ModelDF - The model to which the AgentSet belongs. - _mask : pl.Series - A boolean mask indicating which agents are active. - - Properties - ---------- - active_agents(self) -> pl.DataFrame - Get the active agents in the AgentSetPolars. - agents(self) -> pl.DataFrame - Get or set the agents in the AgentSetPolars. - inactive_agents(self) -> pl.DataFrame - Get the inactive agents in the AgentSetPolars. - model(self) -> ModelDF - Get the model associated with the AgentSetPolars. - random(self) -> Generator - Get the random number generator associated with the model. - - - Methods - ------- - __init__(self, model: ModelDF) -> None - Initialize a new AgentSetPolars. - add(self, other: pl.DataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True) -> Self - Add agents to the AgentSetPolars. - contains(self, ids: PolarsIdsLike) -> bool | pl.Series - Check if agents with the specified IDs are in the AgentSetPolars. - copy(self, deep: bool = False, memo: dict | None = None) -> Self - Create a copy of the AgentSetPolars. - discard(self, ids: PolarsIdsLike, inplace: bool = True) -> Self - Remove an agent from the AgentSetPolars. Does not raise an error if the agent is not found. - do(self, method_name: str, *args, return_results: bool = False, inplace: bool = True, **kwargs) -> Self | Any - Invoke a method on the AgentSetPolars. - get(self, attr_names: IntoExpr | Iterable[IntoExpr] | None, mask: PolarsMaskLike = None) -> pl.Series | pl.DataFrame - Retrieve the value of a specified attribute for each agent in the AgentSetPolars. - remove(self, ids: PolarsIdsLike, inplace: bool = True) -> Self - Remove agents from the AgentSetPolars. - select(self, mask: PolarsMaskLike = None, filter_func: Callable[[Self], PolarsMaskLike] | None = None, n: int | None = None, negate: bool = False, inplace: bool = True) -> Self - Select agents in the AgentSetPolars based on the given criteria. - set(self, attr_names: str | Collection[str] | dict[str, Any] | None = None, values: Any | None = None, mask: PolarsMaskLike | None = None, inplace: bool = True) -> Self - Set the value of a specified attribute or attributes for each agent in the mask in the AgentSetPolars. - shuffle(self, inplace: bool = True) -> Self - Shuffle the order of agents in the AgentSetPolars. - sort(self, by: str | Sequence[str], ascending: bool | Sequence[bool] = True, inplace: bool = True, **kwargs) -> Self - Sort the agents in the AgentSetPolars based on the given criteria. - to_pandas(self) -> "AgentSetPandas" - Convert the AgentSetPolars to an AgentSetPandas. - _get_bool_mask(self, mask: PolarsMaskLike = None) -> pl.Series | pl.Expr - Get a boolean mask for selecting agents. - _get_masked_df(self, mask: PolarsMaskLike = None) -> pl.DataFrame - Get a DataFrame of agents that match the mask. - __getattr__(self, key: str) -> pl.Series - Retrieve an attribute of the underlying DataFrame. - __iter__(self) -> Iterator - Get an iterator for the agents in the AgentSetPolars. - __len__(self) -> int - Get the number of agents in the AgentSetPolars. - __repr__(self) -> str - Get the string representation of the AgentSetPolars. - __reversed__(self) -> Iterator - Get a reversed iterator for the agents in the AgentSetPolars. - __str__(self) -> str - Get the string representation of the AgentSetPolars. - - """ - - def __init__(self, model: "ModelDF") -> None: - """Initialize a new AgentSetPolars. - - Parameters - ---------- - model : ModelDF - The model that the agent set belongs to. - - Returns - ------- - None - """ - self._model = model - self._agents = pl.DataFrame(schema={"unique_id": pl.Int64}) - self._mask = pl.repeat(True, len(self._agents), dtype=pl.Boolean, eager=True) - - def add( - self, - agents: pl.DataFrame | Sequence[Any] | dict[str, Any], - inplace: bool = True, - ) -> Self: - """Add agents to the AgentSetPolars. - - Parameters - ---------- - other : pl.DataFrame | Sequence[Any] | dict[str, Any] - The agents to add. - inplace : bool, optional - Whether to add the agents in place, by default True. - - Returns - ------- - Self - The updated AgentSetPolars. - """ - obj = self._get_obj(inplace) - if isinstance(agents, pl.DataFrame): - if "unique_id" not in agents.columns: - raise KeyError("DataFrame must have a unique_id column.") - new_agents = agents - elif isinstance(agents, dict): - if "unique_id" not in agents: - raise KeyError("Dictionary must have a unique_id key.") - new_agents = pl.DataFrame(agents) - else: - if len(agents) != len(obj._agents.columns): - raise ValueError( - "Length of data must match the number of columns in the AgentSet if being added as a Collection." - ) - new_agents = pl.DataFrame([agents], schema=obj._agents.schema) - - if new_agents["unique_id"].dtype != pl.Int64: - raise TypeError("unique_id column must be of type int64.") - - # If self._mask is pl.Expr, then new mask is the same. - # If self._mask is pl.Series[bool], then new mask has to be updated. - - if isinstance(obj._mask, pl.Series): - original_active_indices = obj._agents.filter(obj._mask)["unique_id"] - - obj._agents = pl.concat([obj._agents, new_agents], how="diagonal_relaxed") - - if isinstance(obj._mask, pl.Series): - obj._update_mask(original_active_indices, new_agents["unique_id"]) - - return obj - - @overload - def contains(self, agents: int) -> bool: ... - - @overload - def contains(self, agents: PolarsIdsLike) -> pl.Series: ... - - def contains( - self, - agents: PolarsIdsLike, - ) -> bool | pl.Series: - if isinstance(agents, pl.Series): - return agents.is_in(self._agents["unique_id"]) - elif isinstance(agents, Collection): - return pl.Series(agents).is_in(self._agents["unique_id"]) - else: - return agents in self._agents["unique_id"] - - def get( - self, - attr_names: IntoExpr | Iterable[IntoExpr] | None, - mask: PolarsMaskLike = None, - ) -> pl.Series | pl.DataFrame: - masked_df = self._get_masked_df(mask) - attr_names = self.agents.select(attr_names).columns.copy() - if not attr_names: - return masked_df - masked_df = masked_df.select(attr_names) - if masked_df.shape[1] == 1: - return masked_df[masked_df.columns[0]] - return masked_df - - def remove(self, ids: PolarsIdsLike, inplace: bool = True) -> Self: - obj = self._get_obj(inplace=inplace) - initial_len = len(obj._agents) - mask = obj._get_bool_mask(ids) - - if isinstance(obj._mask, pl.Series): - original_active_indices = obj._agents.filter(obj._mask)["unique_id"] - - obj._agents = obj._agents.filter(mask.not_()) - if len(obj._agents) == initial_len: - raise KeyError(f"IDs {ids} not found in agent set.") - - if isinstance(obj._mask, pl.Series): - obj._update_mask(original_active_indices) - return obj - - def set( - self, - attr_names: str | Collection[str] | dict[str, Any] | None = None, - values: Any | None = None, - mask: PolarsMaskLike = None, - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - b_mask = obj._get_bool_mask(mask) - masked_df = obj._get_masked_df(mask) - - if not attr_names: - attr_names = masked_df.columns - attr_names.remove("unique_id") - - def process_single_attr( - masked_df: pl.DataFrame, attr_name: str, values: Any - ) -> pl.DataFrame: - if isinstance(values, pl.DataFrame): - return masked_df.with_columns(values.to_series().alias(attr_name)) - elif isinstance(values, pl.Expr): - return masked_df.with_columns(values.alias(attr_name)) - if isinstance(values, pl.Series): - return masked_df.with_columns(values.alias(attr_name)) - else: - if isinstance(values, Collection): - values = pl.Series(values) - else: - values = pl.repeat(values, len(masked_df)) - return masked_df.with_columns(values.alias(attr_name)) - - if isinstance(attr_names, str) and values is not None: - masked_df = process_single_attr(masked_df, attr_names, values) - elif isinstance(attr_names, Collection) and values is not None: - if isinstance(values, Collection) and len(attr_names) == len(values): - for attribute, val in zip(attr_names, values): - masked_df = process_single_attr(masked_df, attribute, val) - else: - for attribute in attr_names: - masked_df = process_single_attr(masked_df, attribute, values) - elif isinstance(attr_names, dict): - for key, val in attr_names.items(): - masked_df = process_single_attr(masked_df, key, val) - else: - raise ValueError( - "attr_names must be a string, a collection of string or a dictionary with columns as keys and values." - ) - non_masked_df = obj._agents.filter(b_mask.not_()) - original_index = obj._agents.select("unique_id") - obj._agents = pl.concat([non_masked_df, masked_df], how="diagonal_relaxed") - obj._agents = original_index.join(obj._agents, on="unique_id", how="left") - return obj - - def select( - self, - mask: PolarsMaskLike = None, - filter_func: Callable[[Self], pl.Series] | None = None, - n: int | None = None, - negate: bool = False, - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - mask = obj._get_bool_mask(mask) - if filter_func: - mask = mask & filter_func(obj) - if n is not None: - mask = (obj._agents["unique_id"]).is_in( - obj._agents.filter(mask).sample(n)["unique_id"] - ) - if negate: - mask = mask.not_() - obj._mask = mask - return obj - - def shuffle(self, inplace: bool = True) -> Self: - obj = self._get_obj(inplace) - obj._agents = obj._agents.sample(fraction=1, shuffle=True) - return obj - - def sort( - self, - by: str | Sequence[str], - ascending: bool | Sequence[bool] = True, - inplace: bool = True, - **kwargs, - ) -> Self: - obj = self._get_obj(inplace) - if isinstance(ascending, bool): - descending = not ascending - else: - descending = [not a for a in ascending] - obj._agents = obj._agents.sort(by=by, descending=descending, **kwargs) - return obj - - def to_pandas(self) -> "AgentSetPandas": - from mesa_frames.concrete.agentset_pandas import AgentSetPandas - - new_obj = AgentSetPandas(self._model) - new_obj._agents = self._agents.to_pandas() - if isinstance(self._mask, pl.Series): - new_obj._mask = self._mask.to_pandas() - else: # self._mask is Expr - new_obj._mask = ( - self._agents["unique_id"] - .is_in(self._agents.filter(self._mask)["unique_id"]) - .to_pandas() - ) - return new_obj - - def _concatenate_agentsets( - self, - agentsets: Iterable[Self], - duplicates_allowed: bool = True, - keep_first_only: bool = True, - original_masked_index: pl.Series | None = None, - ) -> Self: - if not duplicates_allowed: - indices_list = [self._agents["unique_id"]] + [ - agentset._agents["unique_id"] for agentset in agentsets - ] - all_indices = pl.concat(indices_list) - if all_indices.is_duplicated().any(): - raise ValueError( - "Some ids are duplicated in the AgentSetDFs that are trying to be concatenated" - ) - if duplicates_allowed & keep_first_only: - # Find the original_index list (ie longest index list), to sort correctly the rows after concatenation - max_length = max(len(agentset) for agentset in agentsets) - for agentset in agentsets: - if len(agentset) == max_length: - original_index = agentset._agents["unique_id"] - final_dfs = [self._agents] - final_active_indices = [self._agents["unique_id"]] - final_indices = self._agents["unique_id"].clone() - for obj in iter(agentsets): - # Remove agents that are already in the final DataFrame - final_dfs.append( - obj._agents.filter(pl.col("unique_id").is_in(final_indices).not_()) - ) - # Add the indices of the active agents of current AgentSet - final_active_indices.append(obj._agents.filter(obj._mask)["unique_id"]) - # Update the indices of the agents in the final DataFrame - final_indices = pl.concat( - [final_indices, final_dfs[-1]["unique_id"]], how="vertical" - ) - # Left-join original index with concatenated dfs to keep original ids order - final_df = original_index.to_frame().join( - pl.concat(final_dfs, how="diagonal_relaxed"), on="unique_id", how="left" - ) - # - final_active_index = pl.concat(final_active_indices, how="vertical") - - else: - final_df = pl.concat( - [obj._agents for obj in agentsets], how="diagonal_relaxed" - ) - final_active_index = pl.concat( - [obj._agents.filter(obj._mask)["unique_id"] for obj in agentsets] - ) - final_mask = final_df["unique_id"].is_in(final_active_index) - self._agents = final_df - self._mask = final_mask - # If some ids were removed in the do-method, we need to remove them also from final_df - if not isinstance(original_masked_index, type(None)): - ids_to_remove = original_masked_index.filter( - original_masked_index.is_in(self._agents["unique_id"]).not_() - ) - if not ids_to_remove.is_empty(): - self.remove(ids_to_remove, inplace=True) - return self - - def _get_bool_mask( - self, - mask: PolarsMaskLike = None, - ) -> pl.Series | pl.Expr: - def bool_mask_from_series(mask: pl.Series) -> pl.Series: - if ( - isinstance(mask, pl.Series) - and mask.dtype == pl.Boolean - and len(mask) == len(self._agents) - ): - return mask - return self._agents["unique_id"].is_in(mask) - - if isinstance(mask, pl.Expr): - return mask - elif isinstance(mask, pl.Series): - return bool_mask_from_series(mask) - elif isinstance(mask, pl.DataFrame): - if "unique_id" in mask.columns: - return bool_mask_from_series(mask["unique_id"]) - elif len(mask.columns) == 1 and mask.dtypes[0] == pl.Boolean: - return bool_mask_from_series(mask[mask.columns[0]]) - else: - raise KeyError( - "DataFrame must have a 'unique_id' column or a single boolean column." - ) - elif mask is None or mask == "all": - return pl.repeat(True, len(self._agents)) - elif mask == "active": - return self._mask - elif isinstance(mask, Collection): - return bool_mask_from_series(pl.Series(mask)) - else: - return bool_mask_from_series(pl.Series([mask])) - - def _get_masked_df( - self, - mask: PolarsMaskLike = None, - ) -> pl.DataFrame: - if (isinstance(mask, pl.Series) and mask.dtype == pl.Boolean) or isinstance( - mask, pl.Expr - ): - return self._agents.filter(mask) - elif isinstance(mask, pl.DataFrame): - if not mask["unique_id"].is_in(self._agents["unique_id"]).all(): - raise KeyError( - "Some 'unique_id' of mask are not present in DataFrame 'unique_id'." - ) - return mask.select("unique_id").join( - self._agents, on="unique_id", how="left" - ) - elif isinstance(mask, pl.Series): - if not mask.is_in(self._agents["unique_id"]).all(): - raise KeyError( - "Some 'unique_id' of mask are not present in DataFrame 'unique_id'." - ) - mask_df = mask.to_frame("unique_id") - return mask_df.join(self._agents, on="unique_id", how="left") - elif mask is None or mask == "all": - return self._agents - elif mask == "active": - return self._agents.filter(self._mask) - else: - if isinstance(mask, Collection): - mask_series = pl.Series(mask) - else: - mask_series = pl.Series([mask]) - if not mask_series.is_in(self._agents["unique_id"]).all(): - raise KeyError( - "Some 'unique_id' of mask are not present in DataFrame 'unique_id'." - ) - mask_df = mask_series.to_frame("unique_id") - return mask_df.join(self._agents, on="unique_id", how="left") - - @overload - def _get_obj_copy(self, obj: pl.Series) -> pl.Series: ... - - @overload - def _get_obj_copy(self, obj: pl.DataFrame) -> pl.DataFrame: ... - - def _get_obj_copy(self, obj: pl.Series | pl.DataFrame) -> pl.Series | pl.DataFrame: - return obj.clone() - - def _update_mask( - self, original_active_indices: pl.Series, new_indices: pl.Series | None = None - ) -> None: - if new_indices is not None: - self._mask = self._agents["unique_id"].is_in( - original_active_indices - ) | self._agents["unique_id"].is_in(new_indices) - else: - self._mask = self._agents["unique_id"].is_in(original_active_indices) - - def __getattr__(self, key: str) -> pl.Series: - super().__getattr__(key) - return self._agents[key] - - @overload - def __getitem__( - self, - key: str | tuple[PolarsMaskLike, str], - ) -> pl.Series: ... - - @overload - def __getitem__( - self, - key: ( - PolarsMaskLike - | Collection[str] - | tuple[ - PolarsMaskLike, - Collection[str], - ] - ), - ) -> pl.DataFrame: ... - - def __getitem__( - self, - key: ( - str - | Collection[str] - | PolarsMaskLike - | tuple[PolarsMaskLike, str] - | tuple[ - PolarsMaskLike, - Collection[str], - ] - ), - ) -> pl.Series | pl.DataFrame: - attr = super().__getitem__(key) - assert isinstance(attr, (pl.Series, pl.DataFrame)) - return attr - - def __iter__(self) -> Iterator[dict[str, Any]]: - return iter(self._agents.iter_rows(named=True)) - - def __len__(self) -> int: - return len(self._agents) - - def __reversed__(self) -> Iterator: - return reversed(iter(self._agents.iter_rows(named=True))) - - @property - def agents(self) -> pl.DataFrame: - return self._agents - - @agents.setter - def agents(self, agents: pl.DataFrame) -> None: - if "unique_id" not in agents.columns: - raise KeyError("DataFrame must have a unique_id column.") - self._agents = agents - - @property - def active_agents(self) -> pl.DataFrame: - return self.agents.filter(self._mask) - - @active_agents.setter - def active_agents(self, mask: PolarsMaskLike) -> None: - self.select(mask=mask, inplace=True) - - @property - def inactive_agents(self) -> pl.DataFrame: - return self.agents.filter(~self._mask) - - @property - def index(self) -> pl.Series: - return self._agents["unique_id"] diff --git a/mesa_frames/concrete/pandas/agentset.py b/mesa_frames/concrete/pandas/agentset.py index 14b45a0..0fcb1d0 100644 --- a/mesa_frames/concrete/pandas/agentset.py +++ b/mesa_frames/concrete/pandas/agentset.py @@ -12,7 +12,7 @@ from mesa_frames.types_ import PandasIdsLike, PandasMaskLike if TYPE_CHECKING: - pass + from mesa_frames.concrete.model import ModelDF class AgentSetPandas(AgentSetDF, PandasMixin): @@ -99,20 +99,22 @@ class AgentSetPandas(AgentSetDF, PandasMixin): Get the string representation of the AgentSetPandas. """ + def __init__(self, model: "ModelDF") -> None: + self._model = model + self._agents = ( + pd.DataFrame(columns=["unique_id"]) + .astype({"unique_id": "int64"}) + .set_index("unique_id") + ) + self._mask = pd.Series(True, index=self._agents.index, dtype=pd.BooleanDtype()) + def add( self, agents: pd.DataFrame | gpd.GeoDataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True, ) -> Self: obj = self._get_obj(inplace) - if isinstance(agents, gpd.GeoDataFrame): - try: - self.model.space - except ValueError: - raise ValueError( - "You are adding agents with a GeoDataFrame but haven't set model.space. Set it before adding agents with a GeoDataFrame or add agents with a standard DataFrame" - ) - if isinstance(agents, (pd.DataFrame, gpd.GeoDataFrame)): + if isinstance(agents, pd.DataFrame): new_agents = agents if "unique_id" != agents.index.name: try: diff --git a/mesa_frames/concrete/polars/agentset.py b/mesa_frames/concrete/polars/agentset.py index c8d0ecc..a9ad914 100644 --- a/mesa_frames/concrete/polars/agentset.py +++ b/mesa_frames/concrete/polars/agentset.py @@ -1,7 +1,6 @@ from collections.abc import Callable, Collection, Iterable, Iterator, Sequence from typing import TYPE_CHECKING -import geopolars as gpl import polars as pl from polars._typing import IntoExpr from typing_extensions import Any, Self, overload @@ -11,6 +10,7 @@ from mesa_frames.types_ import PolarsIdsLike, PolarsMaskLike if TYPE_CHECKING: + from mesa_frames.concrete.model import ModelDF from mesa_frames.concrete.pandas.agentset import AgentSetPandas @@ -101,6 +101,22 @@ class AgentSetPolars(AgentSetDF, PolarsMixin): """ + def __init__(self, model: "ModelDF") -> None: + """Initialize a new AgentSetPolars. + + Parameters + ---------- + model : ModelDF + The model that the agent set belongs to. + + Returns + ------- + None + """ + self._model = model + self._agents = pl.DataFrame(schema={"unique_id": pl.Int64}) + self._mask = pl.repeat(True, len(self._agents), dtype=pl.Boolean, eager=True) + def add( self, agents: pl.DataFrame | Sequence[Any] | dict[str, Any], @@ -121,14 +137,7 @@ def add( The updated AgentSetPolars. """ obj = self._get_obj(inplace) - if isinstance(agents, gpl.GeoDataFrame): - try: - self.model.space - except ValueError: - raise ValueError( - "You are adding agents with a GeoDataFrame but haven't set model.space. Set it before adding agents with a GeoDataFrame or add agents with a standard DataFrame" - ) - if isinstance(agents, gpl.GeoDataFrame, pl.DataFrame): + if isinstance(agents, pl.DataFrame): if "unique_id" not in agents.columns: raise KeyError("DataFrame must have a unique_id column.") new_agents = agents @@ -301,7 +310,7 @@ def sort( return obj def to_pandas(self) -> "AgentSetPandas": - from mesa_frames.concrete.pandas.agentset_pandas import AgentSetPandas + from mesa_frames.concrete.pandas.agentset import AgentSetPandas new_obj = AgentSetPandas(self._model) new_obj._agents = self._agents.to_pandas() @@ -377,6 +386,80 @@ def _concatenate_agentsets( self.remove(ids_to_remove, inplace=True) return self + def _get_bool_mask( + self, + mask: PolarsMaskLike = None, + ) -> pl.Series | pl.Expr: + def bool_mask_from_series(mask: pl.Series) -> pl.Series: + if ( + isinstance(mask, pl.Series) + and mask.dtype == pl.Boolean + and len(mask) == len(self._agents) + ): + return mask + return self._agents["unique_id"].is_in(mask) + + if isinstance(mask, pl.Expr): + return mask + elif isinstance(mask, pl.Series): + return bool_mask_from_series(mask) + elif isinstance(mask, pl.DataFrame): + if "unique_id" in mask.columns: + return bool_mask_from_series(mask["unique_id"]) + elif len(mask.columns) == 1 and mask.dtypes[0] == pl.Boolean: + return bool_mask_from_series(mask[mask.columns[0]]) + else: + raise KeyError( + "DataFrame must have a 'unique_id' column or a single boolean column." + ) + elif mask is None or mask == "all": + return pl.repeat(True, len(self._agents)) + elif mask == "active": + return self._mask + elif isinstance(mask, Collection): + return bool_mask_from_series(pl.Series(mask)) + else: + return bool_mask_from_series(pl.Series([mask])) + + def _get_masked_df( + self, + mask: PolarsMaskLike = None, + ) -> pl.DataFrame: + if (isinstance(mask, pl.Series) and mask.dtype == pl.Boolean) or isinstance( + mask, pl.Expr + ): + return self._agents.filter(mask) + elif isinstance(mask, pl.DataFrame): + if not mask["unique_id"].is_in(self._agents["unique_id"]).all(): + raise KeyError( + "Some 'unique_id' of mask are not present in DataFrame 'unique_id'." + ) + return mask.select("unique_id").join( + self._agents, on="unique_id", how="left" + ) + elif isinstance(mask, pl.Series): + if not mask.is_in(self._agents["unique_id"]).all(): + raise KeyError( + "Some 'unique_id' of mask are not present in DataFrame 'unique_id'." + ) + mask_df = mask.to_frame("unique_id") + return mask_df.join(self._agents, on="unique_id", how="left") + elif mask is None or mask == "all": + return self._agents + elif mask == "active": + return self._agents.filter(self._mask) + else: + if isinstance(mask, Collection): + mask_series = pl.Series(mask) + else: + mask_series = pl.Series([mask]) + if not mask_series.is_in(self._agents["unique_id"]).all(): + raise KeyError( + "Some 'unique_id' of mask are not present in DataFrame 'unique_id'." + ) + mask_df = mask_series.to_frame("unique_id") + return mask_df.join(self._agents, on="unique_id", how="left") + @overload def _get_obj_copy(self, obj: pl.Series) -> pl.Series: ... From 831324a239e4146e568076ec029344644e1b89b0 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:50:15 +0200 Subject: [PATCH 11/21] update __init__ --- mesa_frames/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mesa_frames/__init__.py b/mesa_frames/__init__.py index 61f25b5..4288c36 100644 --- a/mesa_frames/__init__.py +++ b/mesa_frames/__init__.py @@ -1,6 +1,6 @@ from mesa_frames.concrete.agents import AgentsDF -from mesa_frames.concrete.agentset_pandas import AgentSetPandas -from mesa_frames.concrete.agentset_polars import AgentSetPolars +from mesa_frames.concrete.pandas.agentset import AgentSetPandas +from mesa_frames.concrete.polars.agentset import AgentSetPolars from mesa_frames.concrete.model import ModelDF __all__ = [ From 853a19ea79ad98dffe3454a66c3cf63bbd482cf6 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:51:46 +0200 Subject: [PATCH 12/21] remove geopandas --- mesa_frames/concrete/pandas/agentset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mesa_frames/concrete/pandas/agentset.py b/mesa_frames/concrete/pandas/agentset.py index 0fcb1d0..99ed58a 100644 --- a/mesa_frames/concrete/pandas/agentset.py +++ b/mesa_frames/concrete/pandas/agentset.py @@ -1,7 +1,6 @@ from collections.abc import Callable, Collection, Iterable, Iterator, Sequence from typing import TYPE_CHECKING -import geopandas as gpd import pandas as pd import polars as pl from typing_extensions import Any, Self, overload From 7d128a8303d965f5aea7c578ce7f6b221aae6f66 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:52:44 +0200 Subject: [PATCH 13/21] removed gpd --- mesa_frames/concrete/pandas/agentset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesa_frames/concrete/pandas/agentset.py b/mesa_frames/concrete/pandas/agentset.py index 99ed58a..0378ae5 100644 --- a/mesa_frames/concrete/pandas/agentset.py +++ b/mesa_frames/concrete/pandas/agentset.py @@ -109,7 +109,7 @@ def __init__(self, model: "ModelDF") -> None: def add( self, - agents: pd.DataFrame | gpd.GeoDataFrame | Sequence[Any] | dict[str, Any], + agents: pd.DataFrame | Sequence[Any] | dict[str, Any], inplace: bool = True, ) -> Self: obj = self._get_obj(inplace) From 0d37506b37d1f567fb5ce999cd8ae764ec03066c Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 10:45:15 +0200 Subject: [PATCH 14/21] Merge 'main' --- docs/images/readme_plot_2.png | Bin 50688 -> 0 bytes docs/scripts/readme_plot.py | 23 ++++++----------------- 2 files changed, 6 insertions(+), 17 deletions(-) delete mode 100644 docs/images/readme_plot_2.png diff --git a/docs/images/readme_plot_2.png b/docs/images/readme_plot_2.png deleted file mode 100644 index db9589555c53dd83ebc2a2f9ad796d6525765d92..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50688 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 diff --git a/docs/scripts/readme_plot.py b/docs/scripts/readme_plot.py index e9f0f24..f4e55b9 100644 --- a/docs/scripts/readme_plot.py +++ b/docs/scripts/readme_plot.py @@ -161,7 +161,7 @@ def give_money(self): ) -class MoneyAgentPandas(AgentSetPandas): +class MoneyAgentPandasConcise(AgentSetPandas): def __init__(self, n: int, model: ModelDF) -> None: super().__init__(model) ## Adding the agents to the agent set @@ -180,23 +180,18 @@ def step(self) -> None: def give_money(self): ## Active agents are changed to wealthy agents - # 1. Using a native expression - # self.select(self.agents['wealth'] > 0) - # 2. Using the __getitem__ method + # 1. Using the __getitem__ method # self.select(self["wealth"] > 0) - # 3. Using the fallback __getattr__ method + # 2. Using the fallback __getattr__ method self.select(self.wealth > 0) # Receiving agents are sampled (only native expressions currently supported) other_agents = self.agents.sample(n=len(self.active_agents), replace=True) # Wealth of wealthy is decreased by 1 - # 1. Using a native expression - """b_mask = self.active_agents.index.isin(self.agents) - self.agents.loc[b_mask, "wealth"] -= 1""" - # 2. Using the __setitem__ method with self.active_agents mask + # 1. Using the __setitem__ method with self.active_agents mask # self[self.active_agents, "wealth"] -= 1 - # 3. Using the __setitem__ method with "active" mask + # 2. Using the __setitem__ method with "active" mask self["active", "wealth"] -= 1 # Compute the income of the other agents (only native expressions currently supported) @@ -239,13 +234,7 @@ def give_money(self): self.agents, new_wealth, on="unique_id", how="left", suffixes=("", "_new") ) merged["wealth"] = merged["wealth"] + merged["wealth_new"].fillna(0) - self.agents = merged.drop(columns=["wealth_new"])""" - - # 2. Using the set method - # self.set(attr_names="wealth", values=self["wealth"] + new_wealth["wealth"], mask=new_wealth) - - # 3. Using the __setitem__ method - self[new_wealth, "wealth"] += new_wealth["wealth"] + self.agents = merged.drop(columns=["wealth_new"]) class MoneyModelDF(ModelDF): From 81fce0141585f2f0f9319bc84689281e9d80929e Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 10:49:04 +0200 Subject: [PATCH 15/21] merge fix --- mesa_frames/abstract/agents.py | 18 ------- mesa_frames/concrete/agents.py | 6 --- mesa_frames/concrete/model.py | 5 +- mesa_frames/concrete/space.py | 89 ++-------------------------------- 4 files changed, 7 insertions(+), 111 deletions(-) diff --git a/mesa_frames/abstract/agents.py b/mesa_frames/abstract/agents.py index 299aa2a..ce412c3 100644 --- a/mesa_frames/abstract/agents.py +++ b/mesa_frames/abstract/agents.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from mesa_frames.concrete.agents import AgentSetDF from mesa_frames.concrete.model import ModelDF - from mesa_frames.concrete.space import SpaceDF class AgentContainer(CopyMixin): @@ -360,23 +359,6 @@ def sort( A new or updated AgentContainer. """ - @abstractmethod - def _convert_to_geobject(self, space: SpaceDF, inplace: bool = True) -> Self: - """Converts the DataFrame(s) of AgentContainer to GeoDataFrame(s). - - Parameters - ---------- - space : SpaceDF - The space to add to the AgentContainer. Determines the geometry type. - inplace : bool - Whether to add the space column in place. - - Returns - ------- - Self - """ - ... - def __add__(self, other) -> Self: return self.add(agents=other, inplace=False) diff --git a/mesa_frames/concrete/agents.py b/mesa_frames/concrete/agents.py index 1bae75b..3f8530e 100644 --- a/mesa_frames/concrete/agents.py +++ b/mesa_frames/concrete/agents.py @@ -317,12 +317,6 @@ def sort( ] return obj - def _convert_to_geobject(self, space: SpaceDF, inplace: bool = True) -> Self: - obj = self._get_obj(inplace) - for agentset in obj._agentsets: - agentset._convert_to_geobject(space, inplace=True) - return obj - def _check_ids_presence(self, other: list[AgentSetDF]) -> pl.DataFrame: """Check if the IDs of the agents to be added are unique. diff --git a/mesa_frames/concrete/model.py b/mesa_frames/concrete/model.py index 3bea3ce..b9243e2 100644 --- a/mesa_frames/concrete/model.py +++ b/mesa_frames/concrete/model.py @@ -153,10 +153,11 @@ def agent_types(self) -> list[type]: @property def space(self) -> SpaceDF: if not self._space: - raise ValueError("You haven't set the space for the model. Use model.space = your_space") + raise ValueError( + "You haven't set the space for the model. Use model.space = your_space" + ) return self._space @space.setter def space(self, space: SpaceDF) -> None: self._space = space - self._agents._convert_to_geobject(self._space, inplace=True) diff --git a/mesa_frames/concrete/space.py b/mesa_frames/concrete/space.py index 0afd06e..5cc0bf5 100644 --- a/mesa_frames/concrete/space.py +++ b/mesa_frames/concrete/space.py @@ -4,83 +4,6 @@ Objects used to add a spatial component to a model. -Grid: base grid, which creates a rectangular grid. -SingleGrid: extension to Grid which strictly enforces one agent per cell. -MultiGrid: extension to Grid where each cell can contain a set of agents. -HexGrid: extension to Grid to handle hexagonal neighbors. -ContinuousSpace: a two-dimensional space where each agent has an arbitrary - position of `float`'s. -NetworkGrid: a network where each node contains zero or more agents. -""" - -from mesa_frames.abstract.agents import AgentContainer -from mesa_frames.types_ import IdsLike, PositionsLike - - -class SpaceDF: - def _check_empty_pos(pos: PositionsLike) -> bool: - """Check if the given positions are empty. - - Parameters - ---------- - pos : DataFrame | tuple[Series, Series] | Series - Input positions to check. - - Returns - ------- - Series[bool] - Whether - """ - - -class SingleGrid(SpaceDF): - """Rectangular grid where each cell contains exactly at most one agent. - - Grid cells are indexed by [x, y], where [0, 0] is assumed to be the - bottom-left and [width-1, height-1] is the top-right. If a grid is - toroidal, the top and bottom, and left and right, edges wrap to each other. - - This class provides a property `empties` that returns a set of coordinates - for all empty cells in the grid. It is automatically updated whenever - agents are added or removed from the grid. The `empties` property should be - used for efficient access to current empty cells rather than manually - iterating over the grid to check for emptiness. - - """ - - def place_agents(self, agents: IdsLike | AgentContainer, pos: PositionsLike): - """Place agents on the grid at the coordinates specified in pos. - NOTE: The cells must be empty. - - - Parameters - ---------- - agents : IdsLike | AgentContainer - - pos : DataFrame | tuple[Series, Series] - _description_ - """ - - def _check_empty_pos(pos: PositionsLike) -> bool: - """Check if the given positions are empty. - - Parameters - ---------- - pos : DataFrame | tuple[Series, Series] - Input positions to check. - - Returns - ------- - bool - _description_ - """ - -""" -Mesa Frames Space Module -================= - -Objects used to add a spatial component to a model. - """ from abc import abstractmethod @@ -93,7 +16,6 @@ def _check_empty_pos(pos: PositionsLike) -> bool: import networkx as nx import numpy as np -# if TYPE_CHECKING: import pandas as pd import polars as pl import shapely as shp @@ -101,14 +23,11 @@ def _check_empty_pos(pos: PositionsLike) -> bool: from pyproj import CRS from typing_extensions import ( Any, - Callable, - Collection, - Iterable, - Iterator, Self, - Sequence, ) +from collections.abc import Callable, Collection, Iterable, Iterator, Sequence + from mesa_frames.abstract.agents import AgentContainer, AgentSetDF from mesa_frames.abstract.mixin import CopyMixin, DataFrameMixin from mesa_frames.concrete.model import ModelDF @@ -168,7 +87,7 @@ def iter_neighbors( pos : SpaceCoordinate | SpaceCoordinates | None, optional The positions to get the neighbors from, by default None agents : int | Sequence[int] | None, optional - The agents to get the neigbors from, by default None + The agents to get the neighbors from, by default None include_center : bool, optional If the position or agent should be included in the result, by default False @@ -1260,7 +1179,7 @@ def get_neighborhood( ) -> pd.DataFrame: pos_df = self._get_df_coords(pos) - # Create all possible neighbors by multipling directions by the radius and adding original pos + # Create all possible neighbors by multiplying directions by the radius and adding original pos neighbors_df = self._offsets.join( [pd.Series(np.arange(1, radius + 1), name="radius"), pos_df], how="cross", From 0f87a514c4dd91fde69721cde8121ff0290bcf08 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 11:04:42 +0200 Subject: [PATCH 16/21] moved space to abstract and concrete --- mesa_frames/{concrete => abstract}/space.py | 574 ++------------------ mesa_frames/concrete/model.py | 7 +- mesa_frames/concrete/pandas/space.py | 207 +++++++ mesa_frames/concrete/polars/space.py | 317 +++++++++++ 4 files changed, 562 insertions(+), 543 deletions(-) rename mesa_frames/{concrete => abstract}/space.py (67%) create mode 100644 mesa_frames/concrete/pandas/space.py create mode 100644 mesa_frames/concrete/polars/space.py diff --git a/mesa_frames/concrete/space.py b/mesa_frames/abstract/space.py similarity index 67% rename from mesa_frames/concrete/space.py rename to mesa_frames/abstract/space.py index 5cc0bf5..27f0c4c 100644 --- a/mesa_frames/concrete/space.py +++ b/mesa_frames/abstract/space.py @@ -1,38 +1,21 @@ -""" -Mesa Frames Space Module -================= - -Objects used to add a spatial component to a model. - -""" - from abc import abstractmethod +from collections.abc import Callable, Collection, Iterable, Iterator, Sequence from functools import lru_cache from itertools import product -from typing import cast from warnings import warn import geopandas as gpd import networkx as nx -import numpy as np - import pandas as pd import polars as pl import shapely as shp from numpy.random import Generator from pyproj import CRS -from typing_extensions import ( - Any, - Self, -) - -from collections.abc import Callable, Collection, Iterable, Iterator, Sequence +from typing_extensions import Any, Self from mesa_frames.abstract.agents import AgentContainer, AgentSetDF from mesa_frames.abstract.mixin import CopyMixin, DataFrameMixin from mesa_frames.concrete.model import ModelDF -from mesa_frames.concrete.pandas.mixin import PandasMixin -from mesa_frames.concrete.polars.mixin import PolarsMixin from mesa_frames.types_ import ( DataFrame, DiscreteCoordinate, @@ -1153,528 +1136,9 @@ def __setitem__(self, cells: GridCoordinates, properties: DataFrame): return super().__setitem__(cells, properties) -class GridPandas(GridDF, PandasMixin): - _agents: pd.DataFrame - _cells: pd.DataFrame - _empty_grid: np.ndarray - _offsets: pd.DataFrame - - def get_distances( - self, - pos0: SpaceCoordinate | SpaceCoordinates | None = None, - pos1: SpaceCoordinate | SpaceCoordinates | None = None, - agents0: int | Sequence[int] | None = None, - agents1: int | Sequence[int] | None = None, - ) -> pd.DataFrame: - pos0_df = self._get_df_coords(pos0, agents0) - pos1_df = self._get_df_coords(pos1, agents1) - return pd.DataFrame(np.linalg.norm(pos1_df - pos0_df, axis=1)) - - def get_neighborhood( - self, - radius: int | Sequence[int], - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - include_center: bool = False, - ) -> pd.DataFrame: - pos_df = self._get_df_coords(pos) - - # Create all possible neighbors by multiplying directions by the radius and adding original pos - neighbors_df = self._offsets.join( - [pd.Series(np.arange(1, radius + 1), name="radius"), pos_df], - how="cross", - rsuffix="_center", - ) - - neighbors_df = ( - neighbors_df[self._cells_col_names] * neighbors_df["radius"] - + neighbors_df[self._center_col_names] - ).drop(columns=["radius"]) - - # If torus, "normalize" (take modulo) for out-of-bounds cells - if self._torus: - neighbors_df = self.torus_adj(neighbors_df) - - # Filter out-of-bound neighbors (all ensures that if any coordinates violates, it gets excluded) - neighbors_df = neighbors_df[ - ((neighbors_df >= 0) & (neighbors_df < self._dimensions)).all(axis=1) - ] - - if include_center: - pos_df[self._center_col_names] = pos_df[self._cells_col_names] - neighbors_df = pd.concat([neighbors_df, pos_df], ignore_index=True) - - return neighbors_df - - def set_cells(self, df: pd.DataFrame, inplace: bool = True) -> Self: - if df.index.names != self._cells_col_names or not all( - k in df.columns for k in self._cells_col_names - ): - raise ValueError( - "The dataframe must have columns/MultiIndex 'dim_0', 'dim_1', ..." - ) - obj = self._get_obj(inplace) - df = df.set_index(self._cells_col_names) - obj._cells = df.combine_first(obj._cells) - return obj - - def _generate_empty_grid( - self, dimensions: Sequence[int], capacity: int - ) -> np.ogrid: - return np.full(dimensions, capacity, dtype=int) - - def _get_df_coords( - self, - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - ) -> pd.DataFrame: - return super()._get_df_coords(pos=pos, agents=agents) - - def _get_cells_df(self, coords: GridCoordinates) -> pd.DataFrame: - return ( - pd.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) - .set_index(self._cells_col_names) - .merge( - self._agents.reset_index(), - how="left", - left_index=True, - right_on=self._cells_col_names, - ) - .groupby(level=self._cells_col_names) - .agg(agents=("index", list), n_agents=("index", "size")) - .merge(self._cells, how="left", left_index=True, right_index=True) - ) - - def _place_agents_df( - self, agents: int | Sequence[int], coords: GridCoordinates - ) -> pd.DataFrame: - new_df = pd.DataFrame( - {k: v for k, v in zip(self._cells_col_names, coords)}, - index=pd.Index(agents, name="agent_id"), - ) - new_df = self._agents.combine_first(new_df) - - # Check if the capacity is respected - capacity_df = ( - new_df.value_counts(subset=self._cells_col_names) - .to_frame("n_agents") - .merge(self._cells["capacity"], on=self._cells_col_names) - ) - capacity_df["capacity"] = capacity_df["capacity"].fillna(self._capacity) - if (capacity_df["n_agents"] > capacity_df["capacity"]).any(): - raise ValueError( - "There is at least a cell where the number of agents would be higher than the capacity of the cell" - ) - - return new_df - - def _sample_cells( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[np.ndarray], np.ndarray], - ) -> pd.DataFrame: - # Get the coordinates and remaining capacities of the cells - coords = np.array(np.where(condition(self._empty_grid))).T - capacities = self._empty_grid[tuple(coords.T)] - - if n is not None: - if with_replacement: - assert ( - n <= capacities.sum() - ), "Requested sample size exceeds the total available capacity." - - # Initialize the sampled coordinates list - sampled_coords = [] - - # Resample until we have the correct number of samples with valid capacities - while len(sampled_coords) < n: - # Calculate the remaining samples needed - remaining_samples = n - len(sampled_coords) - - # Compute uniform probabilities for sampling (excluding full cells) - probabilities = np.ones(len(coords)) / len(coords) - - # Sample with replacement using uniform probabilities - sampled_indices = np.random.choice( - len(coords), - size=remaining_samples, - replace=True, - p=probabilities, - ) - new_sampled_coords = coords[sampled_indices] - - # Update capacities - unique_coords, counts = np.unique( - new_sampled_coords, axis=0, return_counts=True - ) - self._empty_grid[tuple(unique_coords.T)] -= counts - - # Check if any cells exceed their capacity and need to be resampled - over_capacity_mask = self._empty_grid[tuple(unique_coords.T)] < 0 - valid_coords = unique_coords[~over_capacity_mask] - invalid_coords = unique_coords[over_capacity_mask] - - # Add valid coordinates to the sampled list - sampled_coords.extend(valid_coords) - - # Restore capacities for invalid coordinates - if len(invalid_coords) > 0: - self._empty_grid[tuple(invalid_coords.T)] += counts[ - over_capacity_mask - ] - - # Update coords based on the current state of the grid - coords = np.array(np.where(condition(self._empty_grid))).T - - sampled_coords = np.array(sampled_coords[:n]) - else: - assert n <= len( - coords - ), "Requested sample size exceeds the number of available cells." - - # Sample without replacement - sampled_indices = np.random.choice(len(coords), size=n, replace=False) - sampled_coords = coords[sampled_indices] - - # No need to update capacities as sampling is without replacement - else: - sampled_coords = coords - - # Convert the coordinates to a DataFrame - sampled_cells = pd.DataFrame(sampled_coords, columns=self._cells_col_names) - - return sampled_cells - - -class GridPolars(GridDF, PolarsMixin): - _agents: pl.DataFrame - _cells: pl.DataFrame - _empty_grid: list[pl.Expr] - _offsets: pl.DataFrame - - def get_distances( - self, - pos0: SpaceCoordinate | SpaceCoordinates | None = None, - pos1: SpaceCoordinate | SpaceCoordinates | None = None, - agents0: int | Sequence[int] | None = None, - agents1: int | Sequence[int] | None = None, - ) -> pl.DataFrame: - pos0_df = self._get_df_coords(pos0, agents0) - pos1_df = self._get_df_coords(pos1, agents1) - return pos0_df - pos1_df - - def get_neighborhood( - self, - radius: int | Sequence[int], - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - include_center: bool = False, - ) -> pl.DataFrame: - pos_df = self._get_df_coords(pos) - - # Create all possible neighbors by multiplying directions by the radius and adding original pos - neighbors_df = self._offsets.join( - [pl.arange(1, radius + 1, eager=True).to_frame(name="radius"), pos_df], - how="cross", - suffix="_center", - ) - - neighbors_df = neighbors_df.with_columns( - ( - pl.col(self._cells_col_names) * pl.col("radius") - + pl.col(self._center_col_names) - ).alias(pl.col(self._cells_col_names)) - ).drop("radius") - - # If torus, "normalize" (take modulo) for out-of-bounds cells - if self._torus: - neighbors_df = self.torus_adj(neighbors_df) - neighbors_df = cast( - pl.DataFrame, neighbors_df - ) # Previous return is Any according to linter but should be DataFrame - - # Filter out-of-bound neighbors - neighbors_df = neighbors_df.filter( - pl.all((neighbors_df < self._dimensions) & (neighbors_df >= 0)) - ) - - if include_center: - pos_df.with_columns( - pl.col(self._cells_col_names).alias(self._center_col_names) - ) - neighbors_df = pl.concat([neighbors_df, pos_df], how="vertical") - - return neighbors_df - - def set_cells(self, df: pl.DataFrame, inplace: bool = True) -> Self: - if not all(k in df.columns for k in self._cells_col_names): - raise ValueError( - "The dataframe must have an columns/MultiIndex 'dim_0', 'dim_1', ..." - ) - obj = self._get_obj(inplace) - obj._cells = obj._combine_first(obj._cells, df, on=self._cells_col_names) - return obj - - def _generate_empty_grid(self, dimensions: Sequence[int]) -> list[pl.Expr]: - return [pl.arange(0, d, eager=False) for d in dimensions] - - def _get_df_coords( - self, - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - ) -> pl.DataFrame: - return super()._get_df_coords(pos, agents) - - def _get_cells_df(self, coords: GridCoordinates) -> pl.DataFrame: - return ( - pl.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) - .join(self._agents, how="left", on=self._cells_col_names) - .group_by(self._cells_col_names) - .agg( - pl.col("agent_id").list().alias("agents"), - pl.col("agent_id").count().alias("n_agents"), - ) - .join(self._cells, on=self._cells_col_names, how="left") - ) - - def _place_agents_df( - self, agents: int | Sequence[int], coords: GridCoordinates - ) -> pl.DataFrame: - new_df = pl.DataFrame( - {"agent_id": agents}.update( - {k: v for k, v in zip(self._cells_col_names, coords)} - ) - ) - new_df: pl.DataFrame = self._df_combine_first( - self._agents, new_df, on="agent_id" - ) - - # Check if the capacity is respected - capacity_df = ( - new_df.group_by(self._cells_col_names) - .count() - .join( - self._cells[self._cells_col_names + ["capacity"]], - on=self._cells_col_names, - ) - ) - capacity_df = capacity_df.with_columns( - capacity=pl.col("capacity").fill_null(self._capacity) - ) - if (capacity_df["count"] > capacity_df["capacity"]).any(): - raise ValueError( - "There is at least a cell where the number of agents would be higher than the capacity of the cell" - ) - - return new_df - - def _sample_cells_lazy( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[pl.Expr], pl.Expr], - ) -> pl.DataFrame: - # Create a base DataFrame with all grid coordinates and default capacities - grid_df = pl.DataFrame(self._empty_grid).with_columns( - [pl.lit(self._capacity).alias("capacity")] - ) - - # Apply the condition to filter the cells - grid_df = grid_df.filter(condition(pl.col("capacity"))) - - if n is not None: - if with_replacement: - assert ( - n <= grid_df.select(pl.sum("capacity")).item() - ), "Requested sample size exceeds the total available capacity." - - # Initialize the sampled DataFrame - sampled_df = pl.DataFrame() - - # Resample until we have the correct number of samples with valid capacities - while sampled_df.shape[0] < n: - # Calculate the remaining samples needed - remaining_samples = n - sampled_df.shape[0] - - # Sample with replacement using uniform probabilities - sampled_part = grid_df.sample( - n=remaining_samples, with_replacement=True - ) - - # Count occurrences of each sampled coordinate - count_df = sampled_part.group_by(self._cells_col_names).agg( - pl.count("capacity").alias("sampled_count") - ) - - # Adjust capacities based on counts - grid_df = ( - grid_df.join(count_df, on=self._cells_col_names, how="left") - .with_columns( - [ - ( - pl.col("capacity") - - pl.col("sampled_count").fill_null(0) - ).alias("capacity") - ] - ) - .drop("sampled_count") - ) - - # Ensure no cell exceeds its capacity - valid_sampled_part = sampled_part.join( - grid_df.filter(pl.col("capacity") >= 0), - on=self._cells_col_names, - how="inner", - ) - - # Add valid samples to the result - sampled_df = pl.concat([sampled_df, valid_sampled_part]) - - # Filter out over-capacity cells from the grid - grid_df = grid_df.filter(pl.col("capacity") > 0) - - sampled_df = sampled_df.head(n) # Ensure we have exactly n samples - else: - assert ( - n <= grid_df.height - ), "Requested sample size exceeds the number of available cells." - - # Sample without replacement - sampled_df = grid_df.sample(n=n, with_replacement=False) - else: - sampled_df = grid_df - - return sampled_df - - def _sample_cells_eager( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[pl.Expr], pl.Expr], - ) -> pl.DataFrame: - # Create a base DataFrame with all grid coordinates and default capacities - grid_df = pl.DataFrame(self._empty_grid).with_columns( - [pl.lit(self._capacity).alias("capacity")] - ) - - # If there are any specific capacities in self._cells, update the grid_df with these values - if not self._cells.is_empty(): - grid_df = ( - grid_df.join(self._cells, on=self._cells_col_names, how="left") - .with_columns( - [ - pl.col("capacity_right") - .fill_null(pl.col("capacity")) - .alias("capacity") - ] - ) - .drop("capacity_right") - ) - - # Apply the condition to filter the cells - grid_df = grid_df.filter(condition(pl.col("capacity"))) - - if n is not None: - if with_replacement: - assert ( - n <= grid_df.select(pl.sum("capacity")).item() - ), "Requested sample size exceeds the total available capacity." - - # Initialize the sampled DataFrame - sampled_df = pl.DataFrame() - - # Resample until we have the correct number of samples with valid capacities - while sampled_df.shape[0] < n: - # Calculate the remaining samples needed - remaining_samples = n - sampled_df.shape[0] - - # Sample with replacement using uniform probabilities - sampled_part = grid_df.sample( - n=remaining_samples, with_replacement=True - ) - - # Count occurrences of each sampled coordinate - count_df = sampled_part.group_by(self._cells_col_names).agg( - pl.count("capacity").alias("sampled_count") - ) - - # Adjust capacities based on counts - grid_df = ( - grid_df.join(count_df, on=self._cells_col_names, how="left") - .with_columns( - [ - ( - pl.col("capacity") - - pl.col("sampled_count").fill_null(0) - ).alias("capacity") - ] - ) - .drop("sampled_count") - ) - - # Ensure no cell exceeds its capacity - valid_sampled_part = sampled_part.join( - grid_df.filter(pl.col("capacity") >= 0), - on=self._cells_col_names, - how="inner", - ) - - # Add valid samples to the result - sampled_df = pl.concat([sampled_df, valid_sampled_part]) - - # Filter out over-capacity cells from the grid - grid_df = grid_df.filter(pl.col("capacity") > 0) - - sampled_df = sampled_df.head(n) # Ensure we have exactly n samples - else: - assert ( - n <= grid_df.height - ), "Requested sample size exceeds the number of available cells." - - # Sample without replacement - sampled_df = grid_df.sample(n=n, with_replacement=False) - else: - sampled_df = grid_df - - return sampled_df - - def _sample_cells( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[pl.Expr], pl.Expr], - ) -> pl.DataFrame: - if "capacity" not in self._cells.columns: - return self._sample_cells_lazy(n, with_replacement, condition) - else: - return self._sample_cells_eager(n, with_replacement, condition) - - class GeoGridDF(GridDF, GeoSpaceDF): ... -class NetworkDF(DiscreteSpaceDF): - _network: nx.Graph - _nodes: pd.DataFrame - _links: pd.DataFrame - - def torus_adj(self, pos): - raise NotImplementedError("No concept of torus in Networks") - - @abstractmethod - def __iter__(self) -> Iterable: - pass - - @abstractmethod - def connect(self, nodes0, nodes1): - pass - - @abstractmethod - def disconnect(self, nodes0, nodes1): - pass - - class ContinousSpaceDF(GeoSpaceDF): _agents: gpd.GeoDataFrame _limits: Sequence[float] @@ -1751,5 +1215,35 @@ def crs(self, ref_sys: CRS | ESPG | str | None): return self -class MultiSpaceDF(Collection[SpaceDF]): - _spaces: Collection[SpaceDF] +class MultiSpaceDF(SpaceDF): + _spaces: list[SpaceDF] + + def __init__(self, model: ModelDF) -> None: + super().__init__(model) + self._spaces = [] + + def add(self, space: SpaceDF, inplace: bool = True) -> Self: + obj = self._get_obj(inplace) + obj._spaces.append(space) + return obj + + +class NetworkDF(DiscreteSpaceDF): + _network: nx.Graph + _nodes: pd.DataFrame + _links: pd.DataFrame + + def torus_adj(self, pos): + raise NotImplementedError("No concept of torus in Networks") + + @abstractmethod + def __iter__(self) -> Iterable: + pass + + @abstractmethod + def connect(self, nodes0, nodes1): + pass + + @abstractmethod + def disconnect(self, nodes0, nodes1): + pass diff --git a/mesa_frames/concrete/model.py b/mesa_frames/concrete/model.py index b9243e2..fa1d430 100644 --- a/mesa_frames/concrete/model.py +++ b/mesa_frames/concrete/model.py @@ -4,8 +4,8 @@ import numpy as np from typing_extensions import Any +from mesa_frames.abstract.space import MultiSpaceDF, SpaceDF from mesa_frames.concrete.agents import AgentsDF -from mesa_frames.concrete.space import SpaceDF if TYPE_CHECKING: from mesa_frames.abstract.agents import AgentSetDF @@ -60,7 +60,7 @@ class ModelDF: _seed: int | Sequence[int] running: bool _agents: AgentsDF - _space: SpaceDF | None + _space: MultiSpaceDF def __new__( cls, seed: int | Sequence[int] | None = None, *args: Any, **kwargs: Any @@ -79,6 +79,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.schedule = None self.current_id = 0 self._agents = AgentsDF(self) + self._space = MultiSpaceDF(self) def get_agents_of_type(self, agent_type: type) -> "AgentSetDF": """Retrieve the AgentSetDF of a specified type. @@ -160,4 +161,4 @@ def space(self) -> SpaceDF: @space.setter def space(self, space: SpaceDF) -> None: - self._space = space + self._space.add(space) diff --git a/mesa_frames/concrete/pandas/space.py b/mesa_frames/concrete/pandas/space.py new file mode 100644 index 0000000..f550bc7 --- /dev/null +++ b/mesa_frames/concrete/pandas/space.py @@ -0,0 +1,207 @@ +from collections.abc import Callable, Sequence +from typing_extensions import Self +import numpy as np +import pandas as pd + +from mesa_frames.abstract.space import GridDF +from mesa_frames.concrete.pandas.mixin import PandasMixin +from mesa_frames.types_ import ( + GridCoordinate, + GridCoordinates, + SpaceCoordinate, + SpaceCoordinates, +) + + +class GridPandas(GridDF, PandasMixin): + _agents: pd.DataFrame + _cells: pd.DataFrame + _empty_grid: np.ndarray + _offsets: pd.DataFrame + + def get_distances( + self, + pos0: SpaceCoordinate | SpaceCoordinates | None = None, + pos1: SpaceCoordinate | SpaceCoordinates | None = None, + agents0: int | Sequence[int] | None = None, + agents1: int | Sequence[int] | None = None, + ) -> pd.DataFrame: + pos0_df = self._get_df_coords(pos0, agents0) + pos1_df = self._get_df_coords(pos1, agents1) + return pd.DataFrame(np.linalg.norm(pos1_df - pos0_df, axis=1)) + + def get_neighborhood( + self, + radius: int | Sequence[int], + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> pd.DataFrame: + pos_df = self._get_df_coords(pos) + + # Create all possible neighbors by multiplying directions by the radius and adding original pos + neighbors_df = self._offsets.join( + [pd.Series(np.arange(1, radius + 1), name="radius"), pos_df], + how="cross", + rsuffix="_center", + ) + + neighbors_df = ( + neighbors_df[self._cells_col_names] * neighbors_df["radius"] + + neighbors_df[self._center_col_names] + ).drop(columns=["radius"]) + + # If torus, "normalize" (take modulo) for out-of-bounds cells + if self._torus: + neighbors_df = self.torus_adj(neighbors_df) + + # Filter out-of-bound neighbors (all ensures that if any coordinates violates, it gets excluded) + neighbors_df = neighbors_df[ + ((neighbors_df >= 0) & (neighbors_df < self._dimensions)).all(axis=1) + ] + + if include_center: + pos_df[self._center_col_names] = pos_df[self._cells_col_names] + neighbors_df = pd.concat([neighbors_df, pos_df], ignore_index=True) + + return neighbors_df + + def set_cells(self, df: pd.DataFrame, inplace: bool = True) -> Self: + if df.index.names != self._cells_col_names or not all( + k in df.columns for k in self._cells_col_names + ): + raise ValueError( + "The dataframe must have columns/MultiIndex 'dim_0', 'dim_1', ..." + ) + obj = self._get_obj(inplace) + df = df.set_index(self._cells_col_names) + obj._cells = df.combine_first(obj._cells) + return obj + + def _generate_empty_grid( + self, dimensions: Sequence[int], capacity: int + ) -> np.ogrid: + return np.full(dimensions, capacity, dtype=int) + + def _get_df_coords( + self, + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + ) -> pd.DataFrame: + return super()._get_df_coords(pos=pos, agents=agents) + + def _get_cells_df(self, coords: GridCoordinates) -> pd.DataFrame: + return ( + pd.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) + .set_index(self._cells_col_names) + .merge( + self._agents.reset_index(), + how="left", + left_index=True, + right_on=self._cells_col_names, + ) + .groupby(level=self._cells_col_names) + .agg(agents=("index", list), n_agents=("index", "size")) + .merge(self._cells, how="left", left_index=True, right_index=True) + ) + + def _place_agents_df( + self, agents: int | Sequence[int], coords: GridCoordinates + ) -> pd.DataFrame: + new_df = pd.DataFrame( + {k: v for k, v in zip(self._cells_col_names, coords)}, + index=pd.Index(agents, name="agent_id"), + ) + new_df = self._agents.combine_first(new_df) + + # Check if the capacity is respected + capacity_df = ( + new_df.value_counts(subset=self._cells_col_names) + .to_frame("n_agents") + .merge(self._cells["capacity"], on=self._cells_col_names) + ) + capacity_df["capacity"] = capacity_df["capacity"].fillna(self._capacity) + if (capacity_df["n_agents"] > capacity_df["capacity"]).any(): + raise ValueError( + "There is at least a cell where the number of agents would be higher than the capacity of the cell" + ) + + return new_df + + def _sample_cells( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[np.ndarray], np.ndarray], + ) -> pd.DataFrame: + # Get the coordinates and remaining capacities of the cells + coords = np.array(np.where(condition(self._empty_grid))).T + capacities = self._empty_grid[tuple(coords.T)] + + if n is not None: + if with_replacement: + assert ( + n <= capacities.sum() + ), "Requested sample size exceeds the total available capacity." + + # Initialize the sampled coordinates list + sampled_coords = [] + + # Resample until we have the correct number of samples with valid capacities + while len(sampled_coords) < n: + # Calculate the remaining samples needed + remaining_samples = n - len(sampled_coords) + + # Compute uniform probabilities for sampling (excluding full cells) + probabilities = np.ones(len(coords)) / len(coords) + + # Sample with replacement using uniform probabilities + sampled_indices = np.random.choice( + len(coords), + size=remaining_samples, + replace=True, + p=probabilities, + ) + new_sampled_coords = coords[sampled_indices] + + # Update capacities + unique_coords, counts = np.unique( + new_sampled_coords, axis=0, return_counts=True + ) + self._empty_grid[tuple(unique_coords.T)] -= counts + + # Check if any cells exceed their capacity and need to be resampled + over_capacity_mask = self._empty_grid[tuple(unique_coords.T)] < 0 + valid_coords = unique_coords[~over_capacity_mask] + invalid_coords = unique_coords[over_capacity_mask] + + # Add valid coordinates to the sampled list + sampled_coords.extend(valid_coords) + + # Restore capacities for invalid coordinates + if len(invalid_coords) > 0: + self._empty_grid[tuple(invalid_coords.T)] += counts[ + over_capacity_mask + ] + + # Update coords based on the current state of the grid + coords = np.array(np.where(condition(self._empty_grid))).T + + sampled_coords = np.array(sampled_coords[:n]) + else: + assert n <= len( + coords + ), "Requested sample size exceeds the number of available cells." + + # Sample without replacement + sampled_indices = np.random.choice(len(coords), size=n, replace=False) + sampled_coords = coords[sampled_indices] + + # No need to update capacities as sampling is without replacement + else: + sampled_coords = coords + + # Convert the coordinates to a DataFrame + sampled_cells = pd.DataFrame(sampled_coords, columns=self._cells_col_names) + + return sampled_cells diff --git a/mesa_frames/concrete/polars/space.py b/mesa_frames/concrete/polars/space.py new file mode 100644 index 0000000..f4715cf --- /dev/null +++ b/mesa_frames/concrete/polars/space.py @@ -0,0 +1,317 @@ +from collections.abc import Callable, Sequence +from typing import cast +from typing_extensions import Self +import polars as pl + +from mesa_frames.abstract.space import GridDF +from mesa_frames.concrete.polars.mixin import PolarsMixin +from mesa_frames.types_ import ( + GridCoordinate, + GridCoordinates, + SpaceCoordinate, + SpaceCoordinates, +) + + +class GridPolars(GridDF, PolarsMixin): + _agents: pl.DataFrame + _cells: pl.DataFrame + _empty_grid: list[pl.Expr] + _offsets: pl.DataFrame + + def get_distances( + self, + pos0: SpaceCoordinate | SpaceCoordinates | None = None, + pos1: SpaceCoordinate | SpaceCoordinates | None = None, + agents0: int | Sequence[int] | None = None, + agents1: int | Sequence[int] | None = None, + ) -> pl.DataFrame: + pos0_df = self._get_df_coords(pos0, agents0) + pos1_df = self._get_df_coords(pos1, agents1) + return pos0_df - pos1_df + + def get_neighborhood( + self, + radius: int | Sequence[int], + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + include_center: bool = False, + ) -> pl.DataFrame: + pos_df = self._get_df_coords(pos) + + # Create all possible neighbors by multiplying directions by the radius and adding original pos + neighbors_df = self._offsets.join( + [pl.arange(1, radius + 1, eager=True).to_frame(name="radius"), pos_df], + how="cross", + suffix="_center", + ) + + neighbors_df = neighbors_df.with_columns( + ( + pl.col(self._cells_col_names) * pl.col("radius") + + pl.col(self._center_col_names) + ).alias(pl.col(self._cells_col_names)) + ).drop("radius") + + # If torus, "normalize" (take modulo) for out-of-bounds cells + if self._torus: + neighbors_df = self.torus_adj(neighbors_df) + neighbors_df = cast( + pl.DataFrame, neighbors_df + ) # Previous return is Any according to linter but should be DataFrame + + # Filter out-of-bound neighbors + neighbors_df = neighbors_df.filter( + pl.all((neighbors_df < self._dimensions) & (neighbors_df >= 0)) + ) + + if include_center: + pos_df.with_columns( + pl.col(self._cells_col_names).alias(self._center_col_names) + ) + neighbors_df = pl.concat([neighbors_df, pos_df], how="vertical") + + return neighbors_df + + def set_cells(self, df: pl.DataFrame, inplace: bool = True) -> Self: + if not all(k in df.columns for k in self._cells_col_names): + raise ValueError( + "The dataframe must have an columns/MultiIndex 'dim_0', 'dim_1', ..." + ) + obj = self._get_obj(inplace) + obj._cells = obj._combine_first(obj._cells, df, on=self._cells_col_names) + return obj + + def _generate_empty_grid(self, dimensions: Sequence[int]) -> list[pl.Expr]: + return [pl.arange(0, d, eager=False) for d in dimensions] + + def _get_df_coords( + self, + pos: GridCoordinate | GridCoordinates | None = None, + agents: int | Sequence[int] | None = None, + ) -> pl.DataFrame: + return super()._get_df_coords(pos, agents) + + def _get_cells_df(self, coords: GridCoordinates) -> pl.DataFrame: + return ( + pl.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) + .join(self._agents, how="left", on=self._cells_col_names) + .group_by(self._cells_col_names) + .agg( + pl.col("agent_id").list().alias("agents"), + pl.col("agent_id").count().alias("n_agents"), + ) + .join(self._cells, on=self._cells_col_names, how="left") + ) + + def _place_agents_df( + self, agents: int | Sequence[int], coords: GridCoordinates + ) -> pl.DataFrame: + new_df = pl.DataFrame( + {"agent_id": agents}.update( + {k: v for k, v in zip(self._cells_col_names, coords)} + ) + ) + new_df: pl.DataFrame = self._df_combine_first( + self._agents, new_df, on="agent_id" + ) + + # Check if the capacity is respected + capacity_df = ( + new_df.group_by(self._cells_col_names) + .count() + .join( + self._cells[self._cells_col_names + ["capacity"]], + on=self._cells_col_names, + ) + ) + capacity_df = capacity_df.with_columns( + capacity=pl.col("capacity").fill_null(self._capacity) + ) + if (capacity_df["count"] > capacity_df["capacity"]).any(): + raise ValueError( + "There is at least a cell where the number of agents would be higher than the capacity of the cell" + ) + + return new_df + + def _sample_cells_lazy( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[pl.Expr], pl.Expr], + ) -> pl.DataFrame: + # Create a base DataFrame with all grid coordinates and default capacities + grid_df = pl.DataFrame(self._empty_grid).with_columns( + [pl.lit(self._capacity).alias("capacity")] + ) + + # Apply the condition to filter the cells + grid_df = grid_df.filter(condition(pl.col("capacity"))) + + if n is not None: + if with_replacement: + assert ( + n <= grid_df.select(pl.sum("capacity")).item() + ), "Requested sample size exceeds the total available capacity." + + # Initialize the sampled DataFrame + sampled_df = pl.DataFrame() + + # Resample until we have the correct number of samples with valid capacities + while sampled_df.shape[0] < n: + # Calculate the remaining samples needed + remaining_samples = n - sampled_df.shape[0] + + # Sample with replacement using uniform probabilities + sampled_part = grid_df.sample( + n=remaining_samples, with_replacement=True + ) + + # Count occurrences of each sampled coordinate + count_df = sampled_part.group_by(self._cells_col_names).agg( + pl.count("capacity").alias("sampled_count") + ) + + # Adjust capacities based on counts + grid_df = ( + grid_df.join(count_df, on=self._cells_col_names, how="left") + .with_columns( + [ + ( + pl.col("capacity") + - pl.col("sampled_count").fill_null(0) + ).alias("capacity") + ] + ) + .drop("sampled_count") + ) + + # Ensure no cell exceeds its capacity + valid_sampled_part = sampled_part.join( + grid_df.filter(pl.col("capacity") >= 0), + on=self._cells_col_names, + how="inner", + ) + + # Add valid samples to the result + sampled_df = pl.concat([sampled_df, valid_sampled_part]) + + # Filter out over-capacity cells from the grid + grid_df = grid_df.filter(pl.col("capacity") > 0) + + sampled_df = sampled_df.head(n) # Ensure we have exactly n samples + else: + assert ( + n <= grid_df.height + ), "Requested sample size exceeds the number of available cells." + + # Sample without replacement + sampled_df = grid_df.sample(n=n, with_replacement=False) + else: + sampled_df = grid_df + + return sampled_df + + def _sample_cells_eager( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[pl.Expr], pl.Expr], + ) -> pl.DataFrame: + # Create a base DataFrame with all grid coordinates and default capacities + grid_df = pl.DataFrame(self._empty_grid).with_columns( + [pl.lit(self._capacity).alias("capacity")] + ) + + # If there are any specific capacities in self._cells, update the grid_df with these values + if not self._cells.is_empty(): + grid_df = ( + grid_df.join(self._cells, on=self._cells_col_names, how="left") + .with_columns( + [ + pl.col("capacity_right") + .fill_null(pl.col("capacity")) + .alias("capacity") + ] + ) + .drop("capacity_right") + ) + + # Apply the condition to filter the cells + grid_df = grid_df.filter(condition(pl.col("capacity"))) + + if n is not None: + if with_replacement: + assert ( + n <= grid_df.select(pl.sum("capacity")).item() + ), "Requested sample size exceeds the total available capacity." + + # Initialize the sampled DataFrame + sampled_df = pl.DataFrame() + + # Resample until we have the correct number of samples with valid capacities + while sampled_df.shape[0] < n: + # Calculate the remaining samples needed + remaining_samples = n - sampled_df.shape[0] + + # Sample with replacement using uniform probabilities + sampled_part = grid_df.sample( + n=remaining_samples, with_replacement=True + ) + + # Count occurrences of each sampled coordinate + count_df = sampled_part.group_by(self._cells_col_names).agg( + pl.count("capacity").alias("sampled_count") + ) + + # Adjust capacities based on counts + grid_df = ( + grid_df.join(count_df, on=self._cells_col_names, how="left") + .with_columns( + [ + ( + pl.col("capacity") + - pl.col("sampled_count").fill_null(0) + ).alias("capacity") + ] + ) + .drop("sampled_count") + ) + + # Ensure no cell exceeds its capacity + valid_sampled_part = sampled_part.join( + grid_df.filter(pl.col("capacity") >= 0), + on=self._cells_col_names, + how="inner", + ) + + # Add valid samples to the result + sampled_df = pl.concat([sampled_df, valid_sampled_part]) + + # Filter out over-capacity cells from the grid + grid_df = grid_df.filter(pl.col("capacity") > 0) + + sampled_df = sampled_df.head(n) # Ensure we have exactly n samples + else: + assert ( + n <= grid_df.height + ), "Requested sample size exceeds the number of available cells." + + # Sample without replacement + sampled_df = grid_df.sample(n=n, with_replacement=False) + else: + sampled_df = grid_df + + return sampled_df + + def _sample_cells( + self, + n: int | None, + with_replacement: bool, + condition: Callable[[pl.Expr], pl.Expr], + ) -> pl.DataFrame: + if "capacity" not in self._cells.columns: + return self._sample_cells_lazy(n, with_replacement, condition) + else: + return self._sample_cells_eager(n, with_replacement, condition) From d90544d536904c80e0bfbb30d95a762e7c767e63 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:47:03 +0200 Subject: [PATCH 17/21] fixed typing --- mesa_frames/abstract/space.py | 30 ++++++++++++++++++------------ mesa_frames/types_.py | 9 ++++++--- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/mesa_frames/abstract/space.py b/mesa_frames/abstract/space.py index 27f0c4c..0855f85 100644 --- a/mesa_frames/abstract/space.py +++ b/mesa_frames/abstract/space.py @@ -13,9 +13,10 @@ from pyproj import CRS from typing_extensions import Any, Self +from typing import TYPE_CHECKING + from mesa_frames.abstract.agents import AgentContainer, AgentSetDF from mesa_frames.abstract.mixin import CopyMixin, DataFrameMixin -from mesa_frames.concrete.model import ModelDF from mesa_frames.types_ import ( DataFrame, DiscreteCoordinate, @@ -31,17 +32,20 @@ ESPG = int +if TYPE_CHECKING: + from mesa_frames.concrete.model import ModelDF + class SpaceDF(CopyMixin, DataFrameMixin): - _model: ModelDF + _model: "ModelDF" _agents: DataFrame | GeoDataFrame - def __init__(self, model: ModelDF) -> None: + def __init__(self, model: "ModelDF") -> None: """Create a new CellSet object. Parameters ---------- - model : ModelDF + model : 'ModelDF' Returns ------- @@ -470,12 +474,12 @@ def agents(self) -> DataFrame | GeoDataFrame: return self._agents @property - def model(self) -> ModelDF: + def model(self) -> "ModelDF": """The model to which the space belongs. Returns ------- - ModelDF + 'ModelDF' """ self._model @@ -501,7 +505,7 @@ class DiscreteSpaceDF(SpaceDF): def __init__( self, - model: ModelDF, + model: "ModelDF", capacity: int | None = None, ): super().__init__(model) @@ -832,7 +836,7 @@ class GridDF(DiscreteSpaceDF): def __init__( self, - model: ModelDF, + model: "ModelDF", dimensions: Sequence[int], torus: bool = False, capacity: int | None = None, @@ -844,7 +848,7 @@ def __init__( Parameters ---------- - model : ModelDF + model : 'ModelDF' The model selfect to which the grid belongs dimensions: Sequence[int] The dimensions of the grid @@ -1143,12 +1147,14 @@ class ContinousSpaceDF(GeoSpaceDF): _agents: gpd.GeoDataFrame _limits: Sequence[float] - def __init__(self, model: ModelDF, ref_sys: CRS | ESPG | str | None = None) -> None: + def __init__( + self, model: "ModelDF", ref_sys: CRS | ESPG | str | None = None + ) -> None: """Create a new CellSet object. Parameters ---------- - model : ModelDF + model : 'ModelDF' ref_sys : CRS | ESPG | str | None, optional Coordinate Reference System. ESPG is an integer, by default None @@ -1218,7 +1224,7 @@ def crs(self, ref_sys: CRS | ESPG | str | None): class MultiSpaceDF(SpaceDF): _spaces: list[SpaceDF] - def __init__(self, model: ModelDF) -> None: + def __init__(self, model: "ModelDF") -> None: super().__init__(model) self._spaces = [] diff --git a/mesa_frames/types_.py b/mesa_frames/types_.py index 0af02e9..b1b4ddf 100644 --- a/mesa_frames/types_.py +++ b/mesa_frames/types_.py @@ -1,10 +1,13 @@ from collections.abc import Collection -from typing import Literal, Sequence +from typing import Literal +from collections.abc import Sequence + +import geopandas as gpd +import geopolars as gpl import pandas as pd import polars as pl from numpy import ndarray -from typing_extensions import Literal, Sequence ####----- Agnostic Types -----#### AgnosticMask = Literal["all", "active"] | None @@ -25,7 +28,7 @@ PolarsGridCapacity = list[pl.Expr] ###----- Generic -----### - +GeoDataFrame = gpd.GeoDataFrame | gpl.GeoDataFrame DataFrame = pd.DataFrame | pl.DataFrame Series = pd.Series | pl.Series Series = pd.Series | pl.Series From 48d33060a9b24139ffc95e52ddc8d847cce2134d Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Sat, 20 Jul 2024 11:07:23 +0200 Subject: [PATCH 18/21] refining discretespacedf --- mesa_frames/abstract/space.py | 715 +++++++++++----------------------- 1 file changed, 236 insertions(+), 479 deletions(-) diff --git a/mesa_frames/abstract/space.py b/mesa_frames/abstract/space.py index fb4988a..c6dd928 100644 --- a/mesa_frames/abstract/space.py +++ b/mesa_frames/abstract/space.py @@ -1,27 +1,23 @@ from abc import abstractmethod -from collections.abc import Callable, Collection, Iterable, Sequence +from collections.abc import Callable, Collection, Sequence from functools import lru_cache -from itertools import product from typing import TYPE_CHECKING -from warnings import warn import polars as pl from numpy.random import Generator -from typing_extensions import Any, Self +from typing_extensions import Self -from collections.abc import Iterator +from typing import Literal from mesa_frames.abstract.agents import AgentContainer from mesa_frames.abstract.mixin import CopyMixin, DataFrameMixin from mesa_frames.types_ import ( + BoolSeries, DataFrame, DiscreteCoordinate, DiscreteCoordinates, DiscreteSpaceCapacity, GeoDataFrame, - GridCapacity, - GridCoordinate, - GridCoordinates, IdsLike, SpaceCoordinate, SpaceCoordinates, @@ -30,13 +26,67 @@ ESPG = int if TYPE_CHECKING: - from mesa_frames.abstract.agents import AgentSetDF 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. + + Methods + ------- + __init__(model: 'ModelDF') + Create a new SpaceDF object. + random_agents(n: int, seed: int | None = None) -> DataFrame + Return a random sample of agents from the space. + get_directions( + 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 + Returns the directions from pos0 to pos1 or agents0 and agents1. + get_distances( + 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 + Returns the distances from pos0 to pos1 or agents0 and agents1. + get_neighbors( + radius: int | float | Sequence[int] | Sequence[float], + pos: Space + ) -> DataFrame + Get the neighboring agents from given positions or agents according to the specified radiuses. + move_agents( + agents: IdsLike | AgentContainer | Collection[AgentContainer], + pos + ) -> Self + Place agents in the space according to the specified coordinates. + move_to_empty( + agents: IdsLike | AgentContainer | Collection[AgentContainer], + inplace: bool = True, + ) -> Self + Move agents to empty cells/positions in the space. + random_pos( + n: int, + seed: int | None = None, + ) -> DataFrame + Return a random sample of positions from the space. + remove_agents( + agents: IdsLike | AgentContainer | Collection[AgentContainer], + inplace: bool = True, + ) + Remove agents from the space. + swap_agents( + agents0: IdsLike | AgentContainer | Collection[AgentContainer], + agents1: IdsLike | AgentContainer | Collection[AgentContainer], + ) -> Self + Swap the positions of the agents in the space. + """ + _model: "ModelDF" - _agents: DataFrame | GeoDataFrame + _agents: DataFrame | GeoDataFrame # Stores the agents placed in the space def __init__(self, model: "ModelDF") -> None: """Create a new SpaceDF object. @@ -337,148 +387,173 @@ def random(self) -> Generator: class DiscreteSpaceDF(SpaceDF): - _capacity: int | None - _cells: DataFrame - _cells_col_names: list[str] - _center_col_names: list[str] + """The DiscreteSpaceDF class is an abstract class that defines the interface for all discrete space classes (Grids and Networks) in mesa_frames. + + Methods + ------- + __init__(model: 'ModelDF', capacity: int | None = None) + Create a new DiscreteSpaceDF object. + is_free(pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame + Check whether the input positions are free (there exists at least one remaining spot in the cells). + is_empty(pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame + Check whether the input positions are empty (there isn't any single agent in the cells). + is_full(pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame + Check whether the input positions are full (there isn't any spot available in the cells). + move_to_empty(agents: IdsLike | AgentContainer | Collection[AgentContainer], inplace: bool = True) -> Self + Move agents to empty cells in the space (cells where there isn't any single agent). + move_to_free(agents: IdsLike | AgentContainer | Collection[AgentContainer], inplace: bool = True) -> Self + Move agents to free cells in the space (cells where there is at least one spot available). + sample_cells(n: int, cell_type: Literal["any", "empty", "free", "full"] = "any", with_replacement: bool = True) -> DataFrame + Sample cells from the grid according to the specified cell_type. + get_neighborhood(radius: int | float | Sequence[int] | Sequence[float], pos: DiscreteCoordinate | Discrete + Get the neighborhood cells from a given position. + get_cells(cells: DiscreteCoordinates | None = None) -> DataFrame + Retrieve a dataframe of specified cells with their properties and agents. + set_cells(properties: DataFrame, cells: DiscreteCoordinates | None = None, inplace: bool = True) -> Self + Set the properties of the specified cells. + """ + + _capacity: int | None # The maximum capacity for cells (default is infinite) + _cells: DataFrame # Stores the properties of the cells + _cells_col_names: list[ + str + ] # The column names of the _cells dataframe (eg. ['dim_0', 'dim_1', ...] in Grids, ['node_id', 'edge_id'] in Networks) + _center_col_names: list[ + str + ] # The column names of the center cells/agents in the get_neighbors method (eg. ['dim_0_center', 'dim_1_center', ...] in Grids, ['node_id_center', 'edge_id_center'] in Networks) def __init__( self, model: "ModelDF", capacity: int | None = None, ): + """Create a DiscreteSpaceDF object. + NOTE: The capacity specified here is the default capacity, + it can be set also per cell through the set_cells method. + + Parameters + ---------- + model : ModelDF + The model to which the space belongs + capacity : int | None, optional + The maximum capacity for cells, by default None (infinite) + """ super().__init__(model) self._capacity = capacity - def iter_neighborhood( - self, - radius: int | Sequence[int], - pos: DiscreteCoordinate | DataFrame | None = None, - agents: int | Sequence[int] | None = None, - include_center: bool = False, - ) -> Iterator[dict[str, Any]]: - """Return an iterator over the neighborhood cells from a given position according to a radius. + def is_free(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame: + """Check whether the input positions are free (there exists at least one remaining spot in the cells) Parameters ---------- - pos : DiscreteCoordinates - The coordinates of the cell to get the neighborhood from - radius : int - The radius of the neighborhood - include_center : bool, optional - If the cell in the center of the neighborhood should be included in the result, by default False + pos : GridCoordinate | GridCoordinates + The positions to check for Returns - ------ - Iterator[dict[str, Any]] - An iterator over neighboring cell where each cell is a dictionary with: - - Keys called according to the coordinates of the space(['dim_0', 'dim_1', ...] in Grids, ['node_id', 'edge_id'] in Networks) - - Values representing the value of coordinates according to the dimension - + ------- + DataFrame + A dataframe with positions and a boolean column "free" """ - return self._df_iterator( - self.get_neighborhood( - radius=radius, pos=pos, agents=agents, include_center=include_center - ) + df = self._df_constructor(data=pos, columns=self._cells_col_names) + return self._df_add_columns( + df, ["free"], self._df_get_bool_mask(df, mask=self.full_cells, negate=True) ) - def move_to_empty( - self, - agents: int - | Collection[int] - | AgentContainer - | Collection[AgentContainer] - | None, - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - - # Get Ids of agents - # TODO: fix this - if isinstance(agents, AgentContainer | Collection[AgentContainer]): - agents = agents.index - - # Check ids presence in model - b_contained = obj.model.agents.contains(agents) - if (isinstance(b_contained, pl.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 empty cells - empty_cells = obj._get_empty_cells(skip_agents=agents) - if len(empty_cells) < len(agents): - raise ValueError("Not enough empty cells to move agents") + def is_empty(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame: + """Check whether the input positions are empty (there isn't any single agent in the cells) - # Place agents - obj._agents = obj.move_agents(agents, empty_cells) - return obj + Parameters + ---------- + pos : GridCoordinate | GridCoordinates + The positions to check for - def get_empty_cells( - self, - n: int | None = None, - with_replacement: bool = True, - ) -> DataFrame: - """Get the empty cells in the space (cells without any agent). + Returns + ------- + DataFrame + A dataframe with positions and a boolean column "empty" + """ + df = self._df_constructor(data=pos, columns=self._cells_col_names) + return self._df_add_columns( + df, ["empty"], self._df_get_bool_mask(df, mask=self._cells, negate=True) + ) + 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 ---------- - n : int | None, optional - _description_, by default None - with_replacement : bool, optional - If with_replacement is False, all cells are different. - If with_replacement is True, some cells could be the same (but such that the total number of selection per cells is less or equal than the capacity), by default True + pos : GridCoordinate | GridCoordinates + The positions to check for Returns ------- DataFrame - _description_ + A dataframe with positions and a boolean column "full" """ - return self._sample_cells( - n, with_replacement, condition=lambda cap: cap == self._capacity + df = self._df_constructor(data=pos, columns=self._cells_col_names) + return self._df_add_columns( + df, ["full"], self._df_get_bool_mask(df, mask=self.full_cells, negate=True) ) - def get_free_cells( + def move_to_empty( self, - n: int | None = None, - with_replacement: bool = True, - ) -> DataFrame: - """Get the free cells in the space (cells that have not reached maximum capacity). + agents: IdsLike | AgentContainer | Collection[AgentContainer], + inplace: bool = True, + ) -> Self: + return self._move_agents_to_cells(agents, cell_type="empty", inplace=inplace) + + def move_to_free( + self, + agents: IdsLike | AgentContainer | Collection[AgentContainer], + inplace: bool = True, + ) -> Self: + """Move agents to free cells/positions in the space (cells/positions where there is at least one spot available). Parameters ---------- - n : int - The number of empty cells to get - with_replacement : bool, optional - If with_replacement is False, all cells are different. - If with_replacement is True, some cells could be the same (but such that the total number of selection per cells is at less or equal than the remaining capacity), by default True + agents : IdsLike | AgentContainer | Collection[AgentContainer] + The agents to move to free cells/positions + inplace : bool, optional + Whether to perform the operation inplace, by default True Returns ------- - DataFrame - A DataFrame with free cells + Self """ - return self._sample_cells(n, with_replacement, condition=lambda cap: cap > 0) + return self._move_agents_to_cells(agents, cell_type="free", inplace=inplace) - def get_full_cells( + def sample_cells( self, - n: int | None = None, + n: int, + cell_type: Literal["any", "empty", "free", "full"] = "any", with_replacement: bool = True, ) -> DataFrame: - """Get the full cells in the space. + """Sample cells from the grid according to the specified cell_type. Parameters ---------- n : int - The number of full cells to get + The number of cells to sample + cell_type : Literal["any", "empty", "free", "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 Returns ------- DataFrame - A DataFrame with full cells + A DataFrame with the sampled cells """ - return self._sample_cells(n, with_replacement, condition=lambda cap: cap == 0) + match cell_type: + case "any": + condition = self._any_cell_condition + case "empty": + condition = self._empty_cell_condition + case "free": + condition = self._free_cell_condition + case "full": + condition = self._full_cell_condition + return self._sample_cells(n, with_replacement, condition=condition) @abstractmethod def get_neighborhood( @@ -510,7 +585,7 @@ def get_neighborhood( @abstractmethod def get_cells(self, cells: DiscreteCoordinates | None = None) -> DataFrame: - """Retrieve the dataframe of specified cells with their properties and agents. + """Retrieve a dataframe of specified cells with their properties and agents. Parameters ---------- @@ -519,9 +594,7 @@ def get_cells(self, cells: DiscreteCoordinates | None = None) -> DataFrame: Returns ------- DataFrame - A DataFrame where columns representing the CellCoordiantes - (['x', 'y' in Grids, ['node_id', 'edge_id'] in Network]), an agent_id columns containing a list of agents - in the cell and the properties of the cell + A DataFrame with the properties of the cells and the agents placed in them. """ ... @@ -552,18 +625,53 @@ def set_cells( """ ... - @abstractmethod - def _get_empty_cells( + def _move_agents_to_cells( self, - skip_agents: Collection[int] | None = None, - ): ... + agents: IdsLike | AgentContainer | Collection[AgentContainer], + cell_type: Literal["empty", "free"], + inplace: bool = True, + ) -> Self: + obj = self._get_obj(inplace) + + # Get Ids of agents + # TODO: fix this + if isinstance(agents, AgentContainer | Collection[AgentContainer]): + agents = agents.index + + # Check ids presence in model + b_contained = obj.model.agents.contains(agents) + if (isinstance(b_contained, pl.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 = obj.sample_cells(len(agents), cell_type=cell_type) + + # Place agents + obj._agents = obj.move_agents(agents, cells) + return obj + + # We define the cell conditions here, because ruff does not allow lambda functions + + def _any_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries: + return True + + def _empty_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries: + return cap == self._capacity + + def _free_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries: + return cap > 0 + + def _full_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries: + return cap == 0 @abstractmethod def _sample_cells( self, n: int | None, with_replacement: bool, - condition: Callable[[DiscreteSpaceCapacity], DiscreteSpaceCapacity], + condition: Callable[[DiscreteSpaceCapacity], BoolSeries], ) -> DataFrame: """Sample cells from the grid according to a condition on the capacity. @@ -573,7 +681,7 @@ def _sample_cells( The number of cells to sample with_replacement : bool If the sampling should be with replacement - condition : Callable[[DiscreteSpaceCapacity], DiscreteSpaceCapacity] + condition : Callable[[DiscreteSpaceCapacity], BoolSeries] The condition to apply on the capacity Returns @@ -589,66 +697,14 @@ def __setitem__(self, cells: DiscreteCoordinates, properties: DataFrame): self.set_cells(properties=properties, cells=cells) def __getattr__(self, key: str) -> DataFrame: - # Fallback, if key is not found in the object, + # 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 is_free(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame: - """Check whether the input positions are free (there exists at least one remaining spot in the cells) - - Parameters - ---------- - pos : GridCoordinate | GridCoordinates - The positions to check for - - Returns - ------- - DataFrame - A dataframe with positions and a boolean column "free" - """ - df = self._df_constructor(data=pos, columns=self._cells_col_names) - return self._df_add_columns( - df, ["free"], self._df_get_bool_mask(df, mask=self.full_cells, negate=True) - ) - - 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 : GridCoordinate | GridCoordinates - The positions to check for - - Returns - ------- - DataFrame - A dataframe with positions and a boolean column "empty" - """ - df = self._df_constructor(data=pos, columns=self._cells_col_names) - return self._df_add_columns( - df, ["empty"], self._df_get_bool_mask(df, mask=self._cells, negate=True) - ) - - 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 : GridCoordinate | GridCoordinates - The positions to check for - - Returns - ------- - DataFrame - A dataframe with positions and a boolean column "full" - """ - df = self._df_constructor(data=pos, columns=self._cells_col_names) - return self._df_add_columns( - df, ["full"], self._df_get_bool_mask(df, mask=self.full_cells, negate=True) - ) - # We use lru_cache because cached_property does not support a custom setter. + # It should improve performance if cell properties haven't changed between accesses. # TODO: Test if there's an effective increase in performance + @property @lru_cache(maxsize=1) def cells(self) -> DataFrame: @@ -659,321 +715,22 @@ def cells(self, df: DataFrame): return self.set_cells(df, inplace=True) @property - def full_cells(self) -> DataFrame: - df = self.cells - return self._df_get_masked_df( - self._cells, mask=df["n_agents"] == df["capacity"] - ) - - -class GridDF(DiscreteSpaceDF): - _agents: DataFrame - _cells: DataFrame - _empty_grid: GridCapacity - _torus: bool - _offsets: DataFrame - - def __init__( - self, - model: "ModelDF", - dimensions: Sequence[int], - torus: bool = False, - capacity: int | None = None, - neighborhood_type: str = "moore", - ): - """Grid cells are indexed, where [0, ..., 0] is assumed to be the - bottom-left and [dimensions[0]-1, ..., dimensions[n]-1] is the top-right. If a grid is - toroidal, the top and bottom, and left and right, edges wrap to each other. - - Parameters - ---------- - model : 'ModelDF' - The model selfect to which the grid belongs - dimensions: Sequence[int] - The dimensions of the grid - torus : bool, optional - If the grid should be a torus, by default False - capacity : int | None, optional - The maximum number of agents that can be placed in a cell, by default None - neighborhood_type: str, optional - The type of neighborhood to consider, by default 'moore'. - If 'moore', the neighborhood is the 8 cells around the center cell. - If 'von_neumann', the neighborhood is the 4 cells around the center cell. - If 'hexagonal', the neighborhood is 6 cells around the center cell. - """ - super().__init__(model, capacity) - self._dimensions = dimensions - self._torus = torus - self._cells_col_names = [f"dim_{k}" for k in range(len(dimensions))] - self._center_col_names = [x + "_center" for x in self._cells_col_names] - self._agents = self._df_constructor( - columns=["agent_id"] + self._cells_col_names, index_col="agent_id" - ) - self._cells = self._df_constructor( - columns=self._cells_col_names + ["capacity"], - index_cols=self._cells_col_names, + @lru_cache(maxsize=1) + def empty_cells(self) -> DataFrame: + return self._sample_cells( + None, with_replacement=False, condition=self._empty_cell_condition ) - self._offsets = self._compute_offsets(neighborhood_type) - self._empty_grid = self._generate_empty_grid(dimensions) - - def get_directions( - self, - pos0: GridCoordinate | GridCoordinates | None = None, - pos1: GridCoordinate | GridCoordinates | None = None, - agents0: int | Sequence[int] | None = None, - agents1: int | Sequence[int] | None = None, - ) -> DataFrame: - pos0_df = self._get_df_coords(pos0, agents0) - pos1_df = self._get_df_coords(pos1, agents1) - assert len(pos0_df) == len(pos1_df), "objects must have the same length" - return pos1_df - pos0_df - def get_neighbors( - self, - radius: int | Sequence[int], - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - include_center: bool = False, - ) -> DataFrame: - assert ( - pos is None and agents is not None or pos is not None and agents is None - ), "Either pos or agents must be specified" - neighborhood_df = self.get_neighborhood( - radius=radius, pos=pos, agents=agents, include_center=include_center - ) - return self._df_get_masked_df( - neighborhood_df, index_col="agent_id", columns=self._agents.columns + @property + @lru_cache(maxsize=1) + def free_cells(self) -> DataFrame: + return self._sample_cells( + None, with_replacement=False, condition=self._free_cell_condition ) - def get_cells(self, cells: GridCoordinates | None = None) -> DataFrame: - coords = self._get_df_coords(cells) - return self._get_cells_df(coords) - - def move_agents( - self, - agents: AgentSetDF | Iterable[AgentSetDF] | int | Sequence[int], - pos: GridCoordinates, - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - - # Get Ids of agents - if isinstance(agents, AgentContainer | Collection[AgentContainer]): - agents = agents.index - - # Check ids presence in model - b_contained = obj.model.agents.contains(agents) - if (isinstance(b_contained, pl.Series) and not b_contained.all()) or ( - isinstance(b_contained, bool) and not b_contained - ): - raise ValueError("Some agents are not in the model") - - # Check ids are unique - agents = pl.Series(agents) - if agents.unique_counts() != len(agents): - raise ValueError("Some agents are present multiple times") - - # Warn if agents are already placed - if agents.is_in(obj._agents["agent_id"]): - warn("Some agents are already placed in the grid", RuntimeWarning) - - # Place agents (checking that capacity is not ) - coords = obj._get_df_coords(pos) - obj._agents = obj._place_agents_df(agents, coords) - return obj - - def out_of_bounds(self, pos: SpaceCoordinates) -> DataFrame: - """Check if a position is out of bounds. - - Parameters - ---------- - pos : SpaceCoordinates - - - Returns - ------- - DataFrame - A DataFrame with a' column representing the coordinates and an 'out_of_bounds' containing boolean values. - """ - pos_df = self._get_df_coords(pos) - out_of_bounds = pos_df < 0 | pos_df >= self._dimensions - return self._df_constructor( - data=[pos_df, out_of_bounds], + @property + @lru_cache(maxsize=1) + def full_cells(self) -> DataFrame: + return self._sample_cells( + None, with_replacement=False, condition=self._full_cell_condition ) - - def remove_agents( - self, - agents: AgentContainer | Collection[AgentContainer] | int | Sequence[int], - inplace: bool = True, - ) -> Self: - obj = self._get_obj(inplace) - - # Get Ids of agents - if isinstance(agents, AgentContainer | Collection[AgentContainer]): - agents = agents.index - - # Check ids presence in model - b_contained = obj.model.agents.contains(agents) - if (isinstance(b_contained, pl.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._agents = obj._df_remove(obj._agents, ids=agents, index_col="agent_id") - - return obj - - def torus_adj(self, pos: GridCoordinates) -> DataFrame: - """Get the toroidal adjusted coordinates of a position. - - Parameters - ---------- - pos : GridCoordinates - The coordinates to adjust - - Returns - ------- - DataFrame - The adjusted coordinates - """ - df_coords = self._get_df_coords(pos) - df_coords = df_coords % self._dimensions - return df_coords - - @abstractmethod - def get_neighborhood( - self, - radius: int | Sequence[int], - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - include_center: bool = False, - ) -> DataFrame: ... - - def _get_df_coords( - self, - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - ) -> DataFrame: - """Get the DataFrame of coordinates from the specified positions or agents. - - Parameters - ---------- - pos : GridCoordinate | GridCoordinates | None, optional - agents : int | Sequence[int] | None, optional - - Returns - ------- - DataFrame - A dataframe where each column represent a column - - Raises - ------ - ValueError - If neither pos or agents are specified - """ - assert ( - pos is not None or agents is not None - ), "Either pos or agents must be specified" - if agents: - return self._df_get_masked_df( - self._agents, index_col="agent_id", mask=agents - ) - if isinstance(pos, DataFrame): - return pos[self._cells_col_names] - elif isinstance(pos, Sequence) 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] = pl.arange(start=start, end=stop, step=step) - elif isinstance(c, int): - pos[i] = [c] - return self._df_constructor(data=pos, columns=self._cells_col_names) - elif isinstance(pos, Collection) and all( - len(c) == len(self._dimensions) for c in pos - ): - # This means that we have a collection of coordinates - sequences = [] - for i in range(len(self._dimensions)): - sequences.append([c[i] for c in pos]) - return self._df_constructor(data=sequences, columns=self._cells_col_names) - elif isinstance(pos, int) and len(self._dimensions) == 1: - return self._df_constructor(data=[pos], columns=self._cells_col_names) - else: - raise ValueError("Invalid coordinates") - - def _compute_offsets(self, neighborhood_type: str) -> DataFrame: - """Generate offsets for the neighborhood. - - Parameters - ---------- - neighborhood_type : str - _description_ - - Returns - ------- - DataFrame - _description_ - - Raises - ------ - ValueError - _description_ - ValueError - _description_ - """ - 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 len(self._dimensions) != 2: - raise ValueError("Hexagonal grid only supports 2 dimensions") - directions = [(-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0)] - else: - raise ValueError("Invalid neighborhood type specified") - - return self._df_constructor(data=directions, columns=self._cells_col_names) - - @abstractmethod - def _generate_empty_grid(self, dimensions: Sequence[int]) -> Any: - """Generate an empty grid with the specified dimensions. - - Parameters - ---------- - dimensions : Sequence[int] - - Returns - ------- - Any - """ - - @abstractmethod - def _get_cells_df(self, coords: GridCoordinates) -> DataFrame: ... - - @abstractmethod - def _place_agents_df( - self, agents: int | Sequence[int], coords: GridCoordinates - ) -> DataFrame: ... - - @abstractmethod - def _sample_cells( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[GridCapacity], GridCapacity], - ) -> DataFrame: ... - - def __getitem__(self, cells: GridCoordinates): - return super().__getitem__(cells) - - def __setitem__(self, cells: GridCoordinates, properties: DataFrame): - return super().__setitem__(cells, properties) From d01c427d047af3b6362103f4418a4f1b4f71a763 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Sat, 20 Jul 2024 11:16:05 +0200 Subject: [PATCH 19/21] Removing lru_cache --- mesa_frames/abstract/space.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mesa_frames/abstract/space.py b/mesa_frames/abstract/space.py index c6dd928..8cbd51b 100644 --- a/mesa_frames/abstract/space.py +++ b/mesa_frames/abstract/space.py @@ -1,6 +1,5 @@ from abc import abstractmethod from collections.abc import Callable, Collection, Sequence -from functools import lru_cache from typing import TYPE_CHECKING import polars as pl @@ -701,12 +700,7 @@ def __getattr__(self, key: str) -> DataFrame: # then it must mean that it's in the _cells dataframe return self._cells[key] - # We use lru_cache because cached_property does not support a custom setter. - # It should improve performance if cell properties haven't changed between accesses. - # TODO: Test if there's an effective increase in performance - @property - @lru_cache(maxsize=1) def cells(self) -> DataFrame: return self.get_cells() @@ -715,21 +709,18 @@ def cells(self, df: DataFrame): return self.set_cells(df, inplace=True) @property - @lru_cache(maxsize=1) def empty_cells(self) -> DataFrame: return self._sample_cells( None, with_replacement=False, condition=self._empty_cell_condition ) @property - @lru_cache(maxsize=1) def free_cells(self) -> DataFrame: return self._sample_cells( None, with_replacement=False, condition=self._free_cell_condition ) @property - @lru_cache(maxsize=1) def full_cells(self) -> DataFrame: return self._sample_cells( None, with_replacement=False, condition=self._full_cell_condition From 7bb5b1103eb6862fd529a4e0c5d6ef54db2affaa Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Sat, 20 Jul 2024 11:17:43 +0200 Subject: [PATCH 20/21] Removing concrete pandas, polars implementation --- mesa_frames/concrete/pandas/space.py | 207 ----------------- mesa_frames/concrete/polars/space.py | 317 --------------------------- 2 files changed, 524 deletions(-) delete mode 100644 mesa_frames/concrete/pandas/space.py delete mode 100644 mesa_frames/concrete/polars/space.py diff --git a/mesa_frames/concrete/pandas/space.py b/mesa_frames/concrete/pandas/space.py deleted file mode 100644 index f550bc7..0000000 --- a/mesa_frames/concrete/pandas/space.py +++ /dev/null @@ -1,207 +0,0 @@ -from collections.abc import Callable, Sequence -from typing_extensions import Self -import numpy as np -import pandas as pd - -from mesa_frames.abstract.space import GridDF -from mesa_frames.concrete.pandas.mixin import PandasMixin -from mesa_frames.types_ import ( - GridCoordinate, - GridCoordinates, - SpaceCoordinate, - SpaceCoordinates, -) - - -class GridPandas(GridDF, PandasMixin): - _agents: pd.DataFrame - _cells: pd.DataFrame - _empty_grid: np.ndarray - _offsets: pd.DataFrame - - def get_distances( - self, - pos0: SpaceCoordinate | SpaceCoordinates | None = None, - pos1: SpaceCoordinate | SpaceCoordinates | None = None, - agents0: int | Sequence[int] | None = None, - agents1: int | Sequence[int] | None = None, - ) -> pd.DataFrame: - pos0_df = self._get_df_coords(pos0, agents0) - pos1_df = self._get_df_coords(pos1, agents1) - return pd.DataFrame(np.linalg.norm(pos1_df - pos0_df, axis=1)) - - def get_neighborhood( - self, - radius: int | Sequence[int], - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - include_center: bool = False, - ) -> pd.DataFrame: - pos_df = self._get_df_coords(pos) - - # Create all possible neighbors by multiplying directions by the radius and adding original pos - neighbors_df = self._offsets.join( - [pd.Series(np.arange(1, radius + 1), name="radius"), pos_df], - how="cross", - rsuffix="_center", - ) - - neighbors_df = ( - neighbors_df[self._cells_col_names] * neighbors_df["radius"] - + neighbors_df[self._center_col_names] - ).drop(columns=["radius"]) - - # If torus, "normalize" (take modulo) for out-of-bounds cells - if self._torus: - neighbors_df = self.torus_adj(neighbors_df) - - # Filter out-of-bound neighbors (all ensures that if any coordinates violates, it gets excluded) - neighbors_df = neighbors_df[ - ((neighbors_df >= 0) & (neighbors_df < self._dimensions)).all(axis=1) - ] - - if include_center: - pos_df[self._center_col_names] = pos_df[self._cells_col_names] - neighbors_df = pd.concat([neighbors_df, pos_df], ignore_index=True) - - return neighbors_df - - def set_cells(self, df: pd.DataFrame, inplace: bool = True) -> Self: - if df.index.names != self._cells_col_names or not all( - k in df.columns for k in self._cells_col_names - ): - raise ValueError( - "The dataframe must have columns/MultiIndex 'dim_0', 'dim_1', ..." - ) - obj = self._get_obj(inplace) - df = df.set_index(self._cells_col_names) - obj._cells = df.combine_first(obj._cells) - return obj - - def _generate_empty_grid( - self, dimensions: Sequence[int], capacity: int - ) -> np.ogrid: - return np.full(dimensions, capacity, dtype=int) - - def _get_df_coords( - self, - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - ) -> pd.DataFrame: - return super()._get_df_coords(pos=pos, agents=agents) - - def _get_cells_df(self, coords: GridCoordinates) -> pd.DataFrame: - return ( - pd.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) - .set_index(self._cells_col_names) - .merge( - self._agents.reset_index(), - how="left", - left_index=True, - right_on=self._cells_col_names, - ) - .groupby(level=self._cells_col_names) - .agg(agents=("index", list), n_agents=("index", "size")) - .merge(self._cells, how="left", left_index=True, right_index=True) - ) - - def _place_agents_df( - self, agents: int | Sequence[int], coords: GridCoordinates - ) -> pd.DataFrame: - new_df = pd.DataFrame( - {k: v for k, v in zip(self._cells_col_names, coords)}, - index=pd.Index(agents, name="agent_id"), - ) - new_df = self._agents.combine_first(new_df) - - # Check if the capacity is respected - capacity_df = ( - new_df.value_counts(subset=self._cells_col_names) - .to_frame("n_agents") - .merge(self._cells["capacity"], on=self._cells_col_names) - ) - capacity_df["capacity"] = capacity_df["capacity"].fillna(self._capacity) - if (capacity_df["n_agents"] > capacity_df["capacity"]).any(): - raise ValueError( - "There is at least a cell where the number of agents would be higher than the capacity of the cell" - ) - - return new_df - - def _sample_cells( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[np.ndarray], np.ndarray], - ) -> pd.DataFrame: - # Get the coordinates and remaining capacities of the cells - coords = np.array(np.where(condition(self._empty_grid))).T - capacities = self._empty_grid[tuple(coords.T)] - - if n is not None: - if with_replacement: - assert ( - n <= capacities.sum() - ), "Requested sample size exceeds the total available capacity." - - # Initialize the sampled coordinates list - sampled_coords = [] - - # Resample until we have the correct number of samples with valid capacities - while len(sampled_coords) < n: - # Calculate the remaining samples needed - remaining_samples = n - len(sampled_coords) - - # Compute uniform probabilities for sampling (excluding full cells) - probabilities = np.ones(len(coords)) / len(coords) - - # Sample with replacement using uniform probabilities - sampled_indices = np.random.choice( - len(coords), - size=remaining_samples, - replace=True, - p=probabilities, - ) - new_sampled_coords = coords[sampled_indices] - - # Update capacities - unique_coords, counts = np.unique( - new_sampled_coords, axis=0, return_counts=True - ) - self._empty_grid[tuple(unique_coords.T)] -= counts - - # Check if any cells exceed their capacity and need to be resampled - over_capacity_mask = self._empty_grid[tuple(unique_coords.T)] < 0 - valid_coords = unique_coords[~over_capacity_mask] - invalid_coords = unique_coords[over_capacity_mask] - - # Add valid coordinates to the sampled list - sampled_coords.extend(valid_coords) - - # Restore capacities for invalid coordinates - if len(invalid_coords) > 0: - self._empty_grid[tuple(invalid_coords.T)] += counts[ - over_capacity_mask - ] - - # Update coords based on the current state of the grid - coords = np.array(np.where(condition(self._empty_grid))).T - - sampled_coords = np.array(sampled_coords[:n]) - else: - assert n <= len( - coords - ), "Requested sample size exceeds the number of available cells." - - # Sample without replacement - sampled_indices = np.random.choice(len(coords), size=n, replace=False) - sampled_coords = coords[sampled_indices] - - # No need to update capacities as sampling is without replacement - else: - sampled_coords = coords - - # Convert the coordinates to a DataFrame - sampled_cells = pd.DataFrame(sampled_coords, columns=self._cells_col_names) - - return sampled_cells diff --git a/mesa_frames/concrete/polars/space.py b/mesa_frames/concrete/polars/space.py deleted file mode 100644 index f4715cf..0000000 --- a/mesa_frames/concrete/polars/space.py +++ /dev/null @@ -1,317 +0,0 @@ -from collections.abc import Callable, Sequence -from typing import cast -from typing_extensions import Self -import polars as pl - -from mesa_frames.abstract.space import GridDF -from mesa_frames.concrete.polars.mixin import PolarsMixin -from mesa_frames.types_ import ( - GridCoordinate, - GridCoordinates, - SpaceCoordinate, - SpaceCoordinates, -) - - -class GridPolars(GridDF, PolarsMixin): - _agents: pl.DataFrame - _cells: pl.DataFrame - _empty_grid: list[pl.Expr] - _offsets: pl.DataFrame - - def get_distances( - self, - pos0: SpaceCoordinate | SpaceCoordinates | None = None, - pos1: SpaceCoordinate | SpaceCoordinates | None = None, - agents0: int | Sequence[int] | None = None, - agents1: int | Sequence[int] | None = None, - ) -> pl.DataFrame: - pos0_df = self._get_df_coords(pos0, agents0) - pos1_df = self._get_df_coords(pos1, agents1) - return pos0_df - pos1_df - - def get_neighborhood( - self, - radius: int | Sequence[int], - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - include_center: bool = False, - ) -> pl.DataFrame: - pos_df = self._get_df_coords(pos) - - # Create all possible neighbors by multiplying directions by the radius and adding original pos - neighbors_df = self._offsets.join( - [pl.arange(1, radius + 1, eager=True).to_frame(name="radius"), pos_df], - how="cross", - suffix="_center", - ) - - neighbors_df = neighbors_df.with_columns( - ( - pl.col(self._cells_col_names) * pl.col("radius") - + pl.col(self._center_col_names) - ).alias(pl.col(self._cells_col_names)) - ).drop("radius") - - # If torus, "normalize" (take modulo) for out-of-bounds cells - if self._torus: - neighbors_df = self.torus_adj(neighbors_df) - neighbors_df = cast( - pl.DataFrame, neighbors_df - ) # Previous return is Any according to linter but should be DataFrame - - # Filter out-of-bound neighbors - neighbors_df = neighbors_df.filter( - pl.all((neighbors_df < self._dimensions) & (neighbors_df >= 0)) - ) - - if include_center: - pos_df.with_columns( - pl.col(self._cells_col_names).alias(self._center_col_names) - ) - neighbors_df = pl.concat([neighbors_df, pos_df], how="vertical") - - return neighbors_df - - def set_cells(self, df: pl.DataFrame, inplace: bool = True) -> Self: - if not all(k in df.columns for k in self._cells_col_names): - raise ValueError( - "The dataframe must have an columns/MultiIndex 'dim_0', 'dim_1', ..." - ) - obj = self._get_obj(inplace) - obj._cells = obj._combine_first(obj._cells, df, on=self._cells_col_names) - return obj - - def _generate_empty_grid(self, dimensions: Sequence[int]) -> list[pl.Expr]: - return [pl.arange(0, d, eager=False) for d in dimensions] - - def _get_df_coords( - self, - pos: GridCoordinate | GridCoordinates | None = None, - agents: int | Sequence[int] | None = None, - ) -> pl.DataFrame: - return super()._get_df_coords(pos, agents) - - def _get_cells_df(self, coords: GridCoordinates) -> pl.DataFrame: - return ( - pl.DataFrame({k: v for k, v in zip(self._cells_col_names, coords)}) - .join(self._agents, how="left", on=self._cells_col_names) - .group_by(self._cells_col_names) - .agg( - pl.col("agent_id").list().alias("agents"), - pl.col("agent_id").count().alias("n_agents"), - ) - .join(self._cells, on=self._cells_col_names, how="left") - ) - - def _place_agents_df( - self, agents: int | Sequence[int], coords: GridCoordinates - ) -> pl.DataFrame: - new_df = pl.DataFrame( - {"agent_id": agents}.update( - {k: v for k, v in zip(self._cells_col_names, coords)} - ) - ) - new_df: pl.DataFrame = self._df_combine_first( - self._agents, new_df, on="agent_id" - ) - - # Check if the capacity is respected - capacity_df = ( - new_df.group_by(self._cells_col_names) - .count() - .join( - self._cells[self._cells_col_names + ["capacity"]], - on=self._cells_col_names, - ) - ) - capacity_df = capacity_df.with_columns( - capacity=pl.col("capacity").fill_null(self._capacity) - ) - if (capacity_df["count"] > capacity_df["capacity"]).any(): - raise ValueError( - "There is at least a cell where the number of agents would be higher than the capacity of the cell" - ) - - return new_df - - def _sample_cells_lazy( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[pl.Expr], pl.Expr], - ) -> pl.DataFrame: - # Create a base DataFrame with all grid coordinates and default capacities - grid_df = pl.DataFrame(self._empty_grid).with_columns( - [pl.lit(self._capacity).alias("capacity")] - ) - - # Apply the condition to filter the cells - grid_df = grid_df.filter(condition(pl.col("capacity"))) - - if n is not None: - if with_replacement: - assert ( - n <= grid_df.select(pl.sum("capacity")).item() - ), "Requested sample size exceeds the total available capacity." - - # Initialize the sampled DataFrame - sampled_df = pl.DataFrame() - - # Resample until we have the correct number of samples with valid capacities - while sampled_df.shape[0] < n: - # Calculate the remaining samples needed - remaining_samples = n - sampled_df.shape[0] - - # Sample with replacement using uniform probabilities - sampled_part = grid_df.sample( - n=remaining_samples, with_replacement=True - ) - - # Count occurrences of each sampled coordinate - count_df = sampled_part.group_by(self._cells_col_names).agg( - pl.count("capacity").alias("sampled_count") - ) - - # Adjust capacities based on counts - grid_df = ( - grid_df.join(count_df, on=self._cells_col_names, how="left") - .with_columns( - [ - ( - pl.col("capacity") - - pl.col("sampled_count").fill_null(0) - ).alias("capacity") - ] - ) - .drop("sampled_count") - ) - - # Ensure no cell exceeds its capacity - valid_sampled_part = sampled_part.join( - grid_df.filter(pl.col("capacity") >= 0), - on=self._cells_col_names, - how="inner", - ) - - # Add valid samples to the result - sampled_df = pl.concat([sampled_df, valid_sampled_part]) - - # Filter out over-capacity cells from the grid - grid_df = grid_df.filter(pl.col("capacity") > 0) - - sampled_df = sampled_df.head(n) # Ensure we have exactly n samples - else: - assert ( - n <= grid_df.height - ), "Requested sample size exceeds the number of available cells." - - # Sample without replacement - sampled_df = grid_df.sample(n=n, with_replacement=False) - else: - sampled_df = grid_df - - return sampled_df - - def _sample_cells_eager( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[pl.Expr], pl.Expr], - ) -> pl.DataFrame: - # Create a base DataFrame with all grid coordinates and default capacities - grid_df = pl.DataFrame(self._empty_grid).with_columns( - [pl.lit(self._capacity).alias("capacity")] - ) - - # If there are any specific capacities in self._cells, update the grid_df with these values - if not self._cells.is_empty(): - grid_df = ( - grid_df.join(self._cells, on=self._cells_col_names, how="left") - .with_columns( - [ - pl.col("capacity_right") - .fill_null(pl.col("capacity")) - .alias("capacity") - ] - ) - .drop("capacity_right") - ) - - # Apply the condition to filter the cells - grid_df = grid_df.filter(condition(pl.col("capacity"))) - - if n is not None: - if with_replacement: - assert ( - n <= grid_df.select(pl.sum("capacity")).item() - ), "Requested sample size exceeds the total available capacity." - - # Initialize the sampled DataFrame - sampled_df = pl.DataFrame() - - # Resample until we have the correct number of samples with valid capacities - while sampled_df.shape[0] < n: - # Calculate the remaining samples needed - remaining_samples = n - sampled_df.shape[0] - - # Sample with replacement using uniform probabilities - sampled_part = grid_df.sample( - n=remaining_samples, with_replacement=True - ) - - # Count occurrences of each sampled coordinate - count_df = sampled_part.group_by(self._cells_col_names).agg( - pl.count("capacity").alias("sampled_count") - ) - - # Adjust capacities based on counts - grid_df = ( - grid_df.join(count_df, on=self._cells_col_names, how="left") - .with_columns( - [ - ( - pl.col("capacity") - - pl.col("sampled_count").fill_null(0) - ).alias("capacity") - ] - ) - .drop("sampled_count") - ) - - # Ensure no cell exceeds its capacity - valid_sampled_part = sampled_part.join( - grid_df.filter(pl.col("capacity") >= 0), - on=self._cells_col_names, - how="inner", - ) - - # Add valid samples to the result - sampled_df = pl.concat([sampled_df, valid_sampled_part]) - - # Filter out over-capacity cells from the grid - grid_df = grid_df.filter(pl.col("capacity") > 0) - - sampled_df = sampled_df.head(n) # Ensure we have exactly n samples - else: - assert ( - n <= grid_df.height - ), "Requested sample size exceeds the number of available cells." - - # Sample without replacement - sampled_df = grid_df.sample(n=n, with_replacement=False) - else: - sampled_df = grid_df - - return sampled_df - - def _sample_cells( - self, - n: int | None, - with_replacement: bool, - condition: Callable[[pl.Expr], pl.Expr], - ) -> pl.DataFrame: - if "capacity" not in self._cells.columns: - return self._sample_cells_lazy(n, with_replacement, condition) - else: - return self._sample_cells_eager(n, with_replacement, condition) From 657aec83ebacc4c010367c02c7ef2b2d31329da6 Mon Sep 17 00:00:00 2001 From: Adam Amer <136176500+adamamer20@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:00:34 +0200 Subject: [PATCH 21/21] changed free to available, specified role of get_neighborhood and set_cells --- mesa_frames/abstract/space.py | 78 +++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/mesa_frames/abstract/space.py b/mesa_frames/abstract/space.py index 8cbd51b..53238fd 100644 --- a/mesa_frames/abstract/space.py +++ b/mesa_frames/abstract/space.py @@ -1,13 +1,11 @@ from abc import abstractmethod from collections.abc import Callable, Collection, Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import polars as pl from numpy.random import Generator from typing_extensions import Self -from typing import Literal - from mesa_frames.abstract.agents import AgentContainer from mesa_frames.abstract.mixin import CopyMixin, DataFrameMixin from mesa_frames.types_ import ( @@ -392,20 +390,20 @@ class DiscreteSpaceDF(SpaceDF): ------- __init__(model: 'ModelDF', capacity: int | None = None) Create a new DiscreteSpaceDF object. - is_free(pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame - Check whether the input positions are free (there exists at least one remaining spot in the cells). + is_available(pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame + Check whether the input positions are available (there exists at least one remaining spot in the cells). is_empty(pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame Check whether the input positions are empty (there isn't any single agent in the cells). is_full(pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame Check whether the input positions are full (there isn't any spot available in the cells). move_to_empty(agents: IdsLike | AgentContainer | Collection[AgentContainer], inplace: bool = True) -> Self Move agents to empty cells in the space (cells where there isn't any single agent). - move_to_free(agents: IdsLike | AgentContainer | Collection[AgentContainer], inplace: bool = True) -> Self - Move agents to free cells in the space (cells where there is at least one spot available). - sample_cells(n: int, cell_type: Literal["any", "empty", "free", "full"] = "any", with_replacement: bool = True) -> DataFrame + move_to_available(agents: IdsLike | AgentContainer | Collection[AgentContainer], inplace: bool = True) -> Self + Move agents to available cells in the space (cells where there is at least one spot available). + sample_cells(n: int, cell_type: Literal["any", "empty", "available", "full"] = "any", with_replacement: bool = True) -> DataFrame Sample cells from the grid according to the specified cell_type. - get_neighborhood(radius: int | float | Sequence[int] | Sequence[float], pos: DiscreteCoordinate | Discrete - Get the neighborhood cells from a given position. + get_neighborhood(radius: int | float | Sequence[int] | Sequence[float], 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. get_cells(cells: DiscreteCoordinates | None = None) -> DataFrame Retrieve a dataframe of specified cells with their properties and agents. set_cells(properties: DataFrame, cells: DiscreteCoordinates | None = None, inplace: bool = True) -> Self @@ -440,8 +438,8 @@ def __init__( super().__init__(model) self._capacity = capacity - def is_free(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame: - """Check whether the input positions are free (there exists at least one remaining spot in the cells) + 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 ---------- @@ -451,11 +449,13 @@ def is_free(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame: Returns ------- DataFrame - A dataframe with positions and a boolean column "free" + A dataframe with positions and a boolean column "available" """ df = self._df_constructor(data=pos, columns=self._cells_col_names) return self._df_add_columns( - df, ["free"], self._df_get_bool_mask(df, mask=self.full_cells, negate=True) + df, + ["available"], + self._df_get_bool_mask(df, mask=self.full_cells, negate=True), ) def is_empty(self, pos: DiscreteCoordinate | DiscreteCoordinates) -> DataFrame: @@ -501,17 +501,17 @@ def move_to_empty( ) -> Self: return self._move_agents_to_cells(agents, cell_type="empty", inplace=inplace) - def move_to_free( + def move_to_available( self, agents: IdsLike | AgentContainer | Collection[AgentContainer], inplace: bool = True, ) -> Self: - """Move agents to free cells/positions in the space (cells/positions where there is at least one spot available). + """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 free cells/positions + The agents to move to available cells/positions inplace : bool, optional Whether to perform the operation inplace, by default True @@ -519,12 +519,14 @@ def move_to_free( ------- Self """ - return self._move_agents_to_cells(agents, cell_type="free", inplace=inplace) + return self._move_agents_to_cells( + agents, cell_type="available", inplace=inplace + ) def sample_cells( self, n: int, - cell_type: Literal["any", "empty", "free", "full"] = "any", + cell_type: Literal["any", "empty", "available", "full"] = "any", with_replacement: bool = True, ) -> DataFrame: """Sample cells from the grid according to the specified cell_type. @@ -533,7 +535,7 @@ def sample_cells( ---------- n : int The number of cells to sample - cell_type : Literal["any", "empty", "free", "full"], optional + 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 @@ -548,8 +550,8 @@ def sample_cells( condition = self._any_cell_condition case "empty": condition = self._empty_cell_condition - case "free": - condition = self._free_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) @@ -558,18 +560,21 @@ def sample_cells( def get_neighborhood( self, radius: int | float | Sequence[int] | Sequence[float], - pos: DiscreteCoordinate | DataFrame | None = None, - agents: int | Sequence[int] | None = None, + pos: DiscreteCoordinate | DiscreteCoordinates | None = None, + agents: IdsLike | AgentContainer | Collection[AgentContainer] = None, include_center: bool = False, ) -> DataFrame: - """Get the neighborhood cells from a given position. + """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 ---------- - pos : DiscreteCoordinates - The coordinates of the cell to get the neighborhood from - radius : int - The radius of the neighborhood + radius : int | float | Sequence[int] | Sequence[float] + 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 @@ -605,8 +610,9 @@ def set_cells( inplace: bool = True, ) -> Self: """Set the properties of the specified cells. - Either the properties df must contain both the cell coordinates and the properties or - the cell coordinates must be specified separately. + This method mirrors the functionality of mesa's PropertyLayer, but allows also to set properties only of specific cells. + Either the properties DF must contain both the cell coordinates and the properties + or the cell coordinates must 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. @@ -615,6 +621,8 @@ def set_cells( ---------- properties : DataFrame The properties of the cells + cells : DiscreteCoordinates | None, optional + The coordinates of the cells to set the properties for, by default None (all cells) inplace : bool Whether to perform the operation inplace @@ -627,7 +635,7 @@ def set_cells( def _move_agents_to_cells( self, agents: IdsLike | AgentContainer | Collection[AgentContainer], - cell_type: Literal["empty", "free"], + cell_type: Literal["any", "empty", "available"], inplace: bool = True, ) -> Self: obj = self._get_obj(inplace) @@ -659,7 +667,7 @@ def _any_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries: def _empty_cell_condition(self, cap: DiscreteSpaceCapacity) -> BoolSeries: return cap == self._capacity - def _free_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: @@ -715,9 +723,9 @@ def empty_cells(self) -> DataFrame: ) @property - def free_cells(self) -> DataFrame: + def available_cells(self) -> DataFrame: return self._sample_cells( - None, with_replacement=False, condition=self._free_cell_condition + None, with_replacement=False, condition=self._available_cell_condition ) @property