Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

experimental: Integrate PropertyLayers into cell space #2319

Merged
merged 9 commits into from
Oct 1, 2024
22 changes: 21 additions & 1 deletion mesa/experimental/cell_space/cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

from collections.abc import Callable
from functools import cache, cached_property
from random import Random
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from mesa.experimental.cell_space.cell_collection import CellCollection
from mesa.space import PropertyLayer

if TYPE_CHECKING:
from mesa.agent import Agent
Expand Down Expand Up @@ -69,6 +71,7 @@ def __init__(
self.capacity: int = capacity
self.properties: dict[Coordinate, object] = {}
self.random = random
self.property_layers: dict[str, PropertyLayer] = {}
Copy link
Member

@quaquel quaquel Sep 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry I don't understand why this is here. should this not be linked to the property layers defined at the grid level?

Copy link
Member Author

@EwoutH EwoutH Sep 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also is, but it might be useful to be able to access your value in a PropertyLayer directly from the cell.

A few lines below you can see that a Cell can do get_property and set_property.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that and agree with it. I now also looked at add_property_layer and start to understand what is going on.

  1. I suggest making this self._property_layers so it's not declared as part of the public API. My concern is that one might add a layer via cell directly which would then not exist on the grid.
  2. I think overall, a more elegant solution is possible here (but that can be a separate PR). My idea would be to add on the fly Descriptors to cells for each property layer. This would make it possible to de cell.property_layer_name, which would return the value for this cell in the named property layer. Another benefit would be that we don't have code by default in cell that is only functional in OrthogonalGrids.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On 2) my high-level idea would be that we use the existing property dictionary of the cell and add to it an entry with the name of the property layer, so cell.properties[property_layer_name]. getting the value should be easy, but I don't know how that would work for setting the value. Any ideas?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of an immediate solution other than to subclass dict and have a dedicated PropertiesDict for all this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was thinking about that. It might be great as an API, but can also create naming conflicts.

Unfortunately, I'm really making a sprint on my thesis now, so not much conceptual-thought capacity to work on this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the one minor change (to a private attribute), I think we can merge this for now. I might have some time over the weekend to play around with more descriptor fun 😄 and see what I can do.

Copy link
Member Author

@EwoutH EwoutH Sep 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go ahead in making the change and merging!

It's all experimental, we can go YOLO.


def connect(self, other: Cell, key: Coordinate | None = None) -> None:
"""Connects this cell to another cell.
Expand Down Expand Up @@ -191,3 +194,20 @@ def _neighborhood(
if not include_center:
neighborhood.pop(self, None)
return neighborhood

# PropertyLayer methods
def get_property(self, property_name: str) -> Any:
"""Get the value of a property."""
return self.property_layers[property_name].data[self.coordinate]

def set_property(self, property_name: str, value: Any):
"""Set the value of a property."""
self.property_layers[property_name].set_cell(self.coordinate, value)

def modify_property(
self, property_name: str, operation: Callable, value: Any = None
):
"""Modify the value of a property."""
self.property_layers[property_name].modify_cell(
self.coordinate, operation, value
)
65 changes: 63 additions & 2 deletions mesa/experimental/cell_space/discrete_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

from __future__ import annotations

from collections.abc import Callable
from functools import cached_property
from random import Random
from typing import Generic, TypeVar
from typing import Any, Generic, TypeVar

from mesa.experimental.cell_space.cell import Cell
from mesa.experimental.cell_space.cell_collection import CellCollection
from mesa.space import PropertyLayer

T = TypeVar("T", bound=Cell)

Expand All @@ -21,7 +23,7 @@ class DiscreteSpace(Generic[T]):
random (Random): The random number generator
cell_klass (Type) : the type of cell class
empties (CellCollection) : collecction of all cells that are empty

property_layers (dict[str, PropertyLayer]): the property layers of the discrete space
"""

def __init__(
Expand All @@ -47,6 +49,7 @@ def __init__(

self._empties: dict[tuple[int, ...], None] = {}
self._empties_initialized = False
self.property_layers: dict[str, PropertyLayer] = {}

@property
def cutoff_empties(self): # noqa
Expand All @@ -73,3 +76,61 @@ def empties(self) -> CellCollection[T]:
def select_random_empty_cell(self) -> T:
"""Select random empty cell."""
return self.random.choice(list(self.empties))

# PropertyLayer methods
def add_property_layer(
self, property_layer: PropertyLayer, add_to_cells: bool = True
):
"""Add a property layer to the grid.

Args:
property_layer: the property layer to add
add_to_cells: whether to add the property layer to all cells (default: True)
"""
if property_layer.name in self.property_layers:
raise ValueError(f"Property layer {property_layer.name} already exists.")
self.property_layers[property_layer.name] = property_layer
if add_to_cells:
for cell in self._cells.values():
cell.property_layers[property_layer.name] = property_layer

def remove_property_layer(self, property_name: str, remove_from_cells: bool = True):
"""Remove a property layer from the grid.

Args:
property_name: the name of the property layer to remove
remove_from_cells: whether to remove the property layer from all cells (default: True)
"""
del self.property_layers[property_name]
if remove_from_cells:
for cell in self._cells.values():
del cell.property_layers[property_name]

def set_property(
self, property_name: str, value, condition: Callable[[T], bool] | None = None
):
"""Set the value of a property for all cells in the grid.

Args:
property_name: the name of the property to set
value: the value to set
condition: a function that takes a cell and returns a boolean
"""
self.property_layers[property_name].set_cells(value, condition)
quaquel marked this conversation as resolved.
Show resolved Hide resolved

def modify_properties(
self,
property_name: str,
operation: Callable,
value: Any = None,
condition: Callable[[T], bool] | None = None,
):
"""Modify the values of a specific property for all cells in the grid.

Args:
property_name: the name of the property to modify
operation: the operation to perform
value: the value to use in the operation
condition: a function that takes a cell and returns a boolean (used to filter cells)
"""
self.property_layers[property_name].modify_cells(operation, value, condition)
88 changes: 88 additions & 0 deletions tests/test_cell_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import random

import numpy as np
import pytest

from mesa import Model
Expand All @@ -15,6 +16,7 @@
OrthogonalVonNeumannGrid,
VoronoiGrid,
)
from mesa.space import PropertyLayer


def test_orthogonal_grid_neumann():
Expand Down Expand Up @@ -524,3 +526,89 @@ def test_cell_collection():

cells = collection.select()
assert len(cells) == len(collection)


### PropertyLayer tests
def test_property_layer_integration():
"""Test integration of PropertyLayer with DiscrateSpace and Cell."""
width, height = 10, 10
grid = OrthogonalMooreGrid((width, height), torus=False)

# Test adding a PropertyLayer to the grid
elevation = PropertyLayer("elevation", width, height, default_value=0)
grid.add_property_layer(elevation)
assert "elevation" in grid.property_layers
assert len(grid.property_layers) == 1

# Test accessing PropertyLayer from a cell
cell = grid._cells[(0, 0)]
assert "elevation" in cell.property_layers
assert cell.get_property("elevation") == 0

# Test setting property value for a cell
cell.set_property("elevation", 100)
assert cell.get_property("elevation") == 100

# Test modifying property value for a cell
cell.modify_property("elevation", lambda x: x + 50)
assert cell.get_property("elevation") == 150

cell.modify_property("elevation", np.add, 50)
assert cell.get_property("elevation") == 200

# Test modifying PropertyLayer values
grid.set_property("elevation", 100, condition=lambda value: value == 200)
assert cell.get_property("elevation") == 100

# Test modifying PropertyLayer using numpy operations
grid.modify_properties("elevation", np.add, 50)
assert cell.get_property("elevation") == 150

# Test removing a PropertyLayer
grid.remove_property_layer("elevation")
assert "elevation" not in grid.property_layers
assert "elevation" not in cell.property_layers


def test_multiple_property_layers():
"""Test initialization of DiscrateSpace with PropertyLayers."""
width, height = 5, 5
elevation = PropertyLayer("elevation", width, height, default_value=0)
temperature = PropertyLayer("temperature", width, height, default_value=20)

# Test initialization with a single PropertyLayer
grid1 = OrthogonalMooreGrid((width, height), torus=False)
grid1.add_property_layer(elevation)
assert "elevation" in grid1.property_layers
assert len(grid1.property_layers) == 1

# Test initialization with multiple PropertyLayers
grid2 = OrthogonalMooreGrid((width, height), torus=False)
grid2.add_property_layer(temperature, add_to_cells=False)
grid2.add_property_layer(elevation, add_to_cells=True)

assert "temperature" in grid2.property_layers
assert "elevation" in grid2.property_layers
assert len(grid2.property_layers) == 2

# Modify properties
grid2.modify_properties("elevation", lambda x: x + 10)
grid2.modify_properties("temperature", lambda x: x + 5)

for cell in grid2.all_cells:
assert cell.get_property("elevation") == 10
# Assert error temperature, since it was not added to cells
with pytest.raises(KeyError):
cell.get_property("temperature")


def test_property_layer_errors():
"""Test error handling for PropertyLayers."""
width, height = 5, 5
grid = OrthogonalMooreGrid((width, height), torus=False)
elevation = PropertyLayer("elevation", width, height, default_value=0)

# Test adding a PropertyLayer with an existing name
grid.add_property_layer(elevation)
with pytest.raises(ValueError, match="Property layer elevation already exists."):
grid.add_property_layer(elevation)
Loading