diff --git a/mesa/space.py b/mesa/space.py index 987afaf9b51..d1dc2ada339 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -12,17 +12,16 @@ position of `float`'s. NetworkGrid: a network where each node contains zero or more agents. """ -# Instruction for PyLint to suppress variable name errors, since we have a -# good reason to use one-character variable names for x and y. -# pylint: disable=invalid-name # Mypy; for the `|` operator purpose # Remove this __future__ import once the oldest supported Python is 3.10 from __future__ import annotations import collections +import inspect import itertools import math +import warnings from numbers import Real from typing import ( Any, @@ -548,7 +547,424 @@ def exists_empty_cells(self) -> bool: return len(self.empties) > 0 -class SingleGrid(_Grid): +def is_single_argument_function(function): + """Check if a function is a single argument function.""" + return ( + inspect.isfunction(function) + and len(inspect.signature(function).parameters) == 1 + ) + + +def ufunc_requires_additional_input(ufunc): + # NumPy ufuncs have a 'nargs' attribute indicating the number of input arguments + # For binary ufuncs (like np.add), nargs is 2 + return ufunc.nargs > 1 + + +class PropertyLayer: + """ + A class representing a layer of properties in a two-dimensional grid. Each cell in the grid + can store a value of a specified data type. + + Attributes: + name (str): The name of the property layer. + width (int): The width of the grid (number of columns). + height (int): The height of the grid (number of rows). + data (numpy.ndarray): A NumPy array representing the grid data. + + Methods: + set_cell(position, value): Sets the value of a single cell. + set_cells(value, condition=None): Sets the values of multiple cells, optionally based on a condition. + modify_cell(position, operation, value): Modifies the value of a single cell using an operation. + modify_cells(operation, value, condition_function): Modifies the values of multiple cells using an operation. + select_cells(condition, return_list): Selects cells that meet a specified condition. + aggregate_property(operation): Performs an aggregate operation over all cells. + """ + + agentset_experimental_warning_given = False + + def __init__( + self, name: str, width: int, height: int, default_value, dtype=np.float64 + ): + """ + Initializes a new PropertyLayer instance. + + Args: + name (str): The name of the property layer. + width (int): The width of the grid (number of columns). Must be a positive integer. + height (int): The height of the grid (number of rows). Must be a positive integer. + default_value: The default value to initialize each cell in the grid. Should ideally + be of the same type as specified by the dtype parameter. + dtype (data-type, optional): The desired data-type for the grid's elements. Default is np.float64. + + Raises: + ValueError: If width or height is not a positive integer. + + Notes: + A UserWarning is raised if the default_value is not of a type compatible with dtype. + The dtype parameter can accept both Python data types (like bool, int or float) and NumPy data types + (like np.int64 or np.float64). Using NumPy data types is recommended (except for bool) for better control + over the precision and efficiency of data storage and computations, especially in cases of large data + volumes or specialized numerical operations. + """ + self.name = name + self.width = width + self.height = height + + # Check that width and height are positive integers + if (not isinstance(width, int) or width < 1) or ( + not isinstance(height, int) or height < 1 + ): + raise ValueError( + f"Width and height must be positive integers, got {width} and {height}." + ) + # Check if the dtype is suitable for the data + if not isinstance(default_value, dtype): + warn( + f"Default value {default_value} ({type(default_value).__name__}) might not be best suitable with dtype={dtype.__name__}.", + UserWarning, + stacklevel=2, + ) + + self.data = np.full((width, height), default_value, dtype=dtype) + + if not self.__class__.agentset_experimental_warning_given: + warnings.warn( + "The new PropertyLayer and _PropertyGrid classes experimental. It may be changed or removed in any and all future releases, including patch releases.\n" + "We would love to hear what you think about this new feature. If you have any thoughts, share them with us here: https://github.com/projectmesa/mesa/discussions/1932", + FutureWarning, + stacklevel=2, + ) + self.__class__.agentset_experimental_warning_given = True + + def set_cell(self, position: Coordinate, value): + """ + Update a single cell's value in-place. + """ + self.data[position] = value + + def set_cells(self, value, condition=None): + """ + Perform a batch update either on the entire grid or conditionally, in-place. + + Args: + value: The value to be used for the update. + condition: (Optional) A callable (like a lambda function or a NumPy ufunc) + that returns a boolean array when applied to the data. + """ + if condition is None: + np.copyto(self.data, value) # In-place update + else: + if isinstance(condition, np.ufunc): + # Directly apply NumPy ufunc + condition_result = condition(self.data) + else: + # Vectorize non-ufunc conditions + vectorized_condition = np.vectorize(condition) + condition_result = vectorized_condition(self.data) + + if ( + not isinstance(condition_result, np.ndarray) + or condition_result.shape != self.data.shape + ): + raise ValueError( + "Result of condition must be a NumPy array with the same shape as the grid." + ) + + np.copyto(self.data, value, where=condition_result) + + def modify_cell(self, position: Coordinate, operation, value=None): + """ + Modify a single cell using an operation, which can be a lambda function or a NumPy ufunc. + If a NumPy ufunc is used, an additional value should be provided. + + Args: + position: The grid coordinates of the cell to modify. + operation: A function to apply. Can be a lambda function or a NumPy ufunc. + value: The value to be used if the operation is a NumPy ufunc. Ignored for lambda functions. + """ + current_value = self.data[position] + + # Determine if the operation is a lambda function or a NumPy ufunc + if is_single_argument_function(operation): + # Lambda function case + self.data[position] = operation(current_value) + elif value is not None: + # NumPy ufunc case + self.data[position] = operation(current_value, value) + else: + raise ValueError("Invalid operation or missing value for NumPy ufunc.") + + def modify_cells(self, operation, value=None, condition_function=None): + """ + Modify cells using an operation, which can be a lambda function or a NumPy ufunc. + If a NumPy ufunc is used, an additional value should be provided. + + Args: + operation: A function to apply. Can be a lambda function or a NumPy ufunc. + value: The value to be used if the operation is a NumPy ufunc. Ignored for lambda functions. + condition_function: (Optional) A callable that returns a boolean array when applied to the data. + """ + condition_array = np.ones_like( + self.data, dtype=bool + ) # Default condition (all cells) + if condition_function is not None: + if isinstance(condition_function, np.ufunc): + condition_array = condition_function(self.data) + else: + vectorized_condition = np.vectorize(condition_function) + condition_array = vectorized_condition(self.data) + + # Check if the operation is a lambda function or a NumPy ufunc + if isinstance(operation, np.ufunc): + if ufunc_requires_additional_input(operation): + if value is None: + raise ValueError("This ufunc requires an additional input value.") + modified_data = operation(self.data, value) + else: + modified_data = operation(self.data) + else: + # Vectorize non-ufunc operations + vectorized_operation = np.vectorize(operation) + modified_data = vectorized_operation(self.data) + + self.data = np.where(condition_array, modified_data, self.data) + + def select_cells(self, condition, return_list=True): + """ + Find cells that meet a specified condition using NumPy's boolean indexing, in-place. + + Args: + condition: A callable that returns a boolean array when applied to the data. + return_list: (Optional) If True, return a list of (x, y) tuples. Otherwise, return a boolean array. + + Returns: + A list of (x, y) tuples or a boolean array. + """ + condition_array = condition(self.data) + if return_list: + return list(zip(*np.where(condition_array))) + else: + return condition_array + + def aggregate_property(self, operation): + """Perform an aggregate operation (e.g., sum, mean) on a property across all cells. + + Args: + operation: A function to apply. Can be a lambda function or a NumPy ufunc. + """ + return operation(self.data) + + +class _PropertyGrid(_Grid): + """ + A private subclass of _Grid that supports the addition of property layers, enabling + the representation and manipulation of additional data layers on the grid. This class is + intended for internal use within the Mesa framework and is currently utilized by SingleGrid + and MultiGrid classes to provide enhanced grid functionality. + + The `_PropertyGrid` extends the capabilities of a basic grid by allowing each cell + to have multiple properties, each represented by a separate PropertyLayer. + These properties can be used to model complex environments where each cell + has multiple attributes or states. + + Attributes: + properties (dict): A dictionary mapping property layer names to PropertyLayer instances. + empty_mask: Returns a boolean mask indicating empty cells on the grid. + + Methods: + add_property_layer(property_layer): Adds a new property layer to the grid. + remove_property_layer(property_name): Removes a property layer from the grid by its name. + get_neighborhood_mask(pos, moore, include_center, radius): Generates a boolean mask of the neighborhood. + select_cells_by_properties(conditions, mask, return_list): Selects cells based on multiple property conditions, + optionally with a mask, returning either a list of coordinates or a mask. + select_extreme_value_cells(property_name, mode, mask, return_list): Selects cells with extreme values of a property, + optionally with a mask, returning either a list of coordinates or a mask. + + Mask Usage: + Several methods in this class accept a mask as an input, which is a NumPy ndarray of boolean values. This mask + specifies the cells to be considered (True) or ignored (False) in operations. Users can create custom masks, + including neighborhood masks, to apply specific conditions or constraints. Additionally, methods that deal with + cell selection or agent movement can return either a list of cell coordinates or a mask, based on the 'return_list' + parameter. This flexibility allows for more nuanced control and customization of grid operations, catering to a wide + range of modeling requirements and scenarios. + + Note: + This class is not intended for direct use in user models but is currently used by the SingleGrid and MultiGrid. + """ + + def __init__( + self, + width: int, + height: int, + torus: bool, + property_layers: None | PropertyLayer | list[PropertyLayer] = None, + ): + """ + Initializes a new _PropertyGrid instance with specified dimensions and optional property layers. + + Args: + width (int): The width of the grid (number of columns). + height (int): The height of the grid (number of rows). + torus (bool): A boolean indicating if the grid should behave like a torus. + property_layers (None | PropertyLayer | list[PropertyLayer], optional): A single PropertyLayer instance, + a list of PropertyLayer instances, or None to initialize without any property layers. + + Raises: + ValueError: If a property layer's dimensions do not match the grid dimensions. + """ + super().__init__(width, height, torus) + self.properties = {} + + # Initialize an empty mask as a boolean NumPy array + self._empty_mask = np.ones((self.width, self.height), dtype=bool) + + # Handle both single PropertyLayer instance and list of PropertyLayer instances + if property_layers: + # If a single PropertyLayer is passed, convert it to a list + if isinstance(property_layers, PropertyLayer): + property_layers = [property_layers] + + for layer in property_layers: + self.add_property_layer(layer) + + @property + def empty_mask(self) -> np.ndarray: + """ + Returns a boolean mask indicating empty cells on the grid. + """ + return self._empty_mask + + # Add and remove properties to the grid + def add_property_layer(self, property_layer: PropertyLayer): + """ + Adds a new property layer to the grid. + + Args: + property_layer (PropertyLayer): The PropertyLayer instance to be added to the grid. + + Raises: + ValueError: If a property layer with the same name already exists in the grid. + ValueError: If the dimensions of the property layer do not match the grid's dimensions. + """ + if property_layer.name in self.properties: + raise ValueError(f"Property layer {property_layer.name} already exists.") + if property_layer.width != self.width or property_layer.height != self.height: + raise ValueError( + f"Property layer dimensions {property_layer.width}x{property_layer.height} do not match grid dimensions {self.width}x{self.height}." + ) + self.properties[property_layer.name] = property_layer + + def remove_property_layer(self, property_name: str): + """ + Removes a property layer from the grid by its name. + + Args: + property_name (str): The name of the property layer to be removed. + + Raises: + ValueError: If a property layer with the given name does not exist in the grid. + """ + if property_name not in self.properties: + raise ValueError(f"Property layer {property_name} does not exist.") + del self.properties[property_name] + + def get_neighborhood_mask( + self, pos: Coordinate, moore: bool, include_center: bool, radius: int + ) -> np.ndarray: + """ + Generate a boolean mask representing the neighborhood. + Helper method for select_cells_multi_properties() and move_agent_to_random_cell() + + Args: + pos (Coordinate): Center of the neighborhood. + moore (bool): True for Moore neighborhood, False for Von Neumann. + include_center (bool): Include the central cell in the neighborhood. + radius (int): The radius of the neighborhood. + + Returns: + np.ndarray: A boolean mask representing the neighborhood. + """ + neighborhood = self.get_neighborhood(pos, moore, include_center, radius) + mask = np.zeros((self.width, self.height), dtype=bool) + + # Convert the neighborhood list to a NumPy array and use advanced indexing + coords = np.array(neighborhood) + mask[coords[:, 0], coords[:, 1]] = True + return mask + + def select_cells( + self, + conditions: dict | None = None, + extreme_values: dict | None = None, + masks: np.ndarray | list[np.ndarray] = None, + only_empty: bool = False, + return_list: bool = True, + ) -> list[Coordinate] | np.ndarray: + """ + Select cells based on property conditions, extreme values, and/or masks, with an option to only select empty cells. + + Args: + conditions (dict): A dictionary where keys are property names and values are callables that return a boolean when applied. + extreme_values (dict): A dictionary where keys are property names and values are either 'highest' or 'lowest'. + masks (np.ndarray | list[np.ndarray], optional): A mask or list of masks to restrict the selection. + only_empty (bool, optional): If True, only select cells that are empty. Default is False. + return_list (bool, optional): If True, return a list of coordinates, otherwise return a mask. + + Returns: + Union[list[Coordinate], np.ndarray]: Coordinates where conditions are satisfied or the combined mask. + """ + # Initialize the combined mask + combined_mask = np.ones((self.width, self.height), dtype=bool) + + # Apply the masks + if masks is not None: + if isinstance(masks, list): + for mask in masks: + combined_mask = np.logical_and(combined_mask, mask) + else: + combined_mask = np.logical_and(combined_mask, masks) + + # Apply the empty mask if only_empty is True + if only_empty: + combined_mask = np.logical_and(combined_mask, self.empty_mask) + + # Apply conditions + if conditions: + for prop_name, condition in conditions.items(): + prop_layer = self.properties[prop_name].data + prop_mask = condition(prop_layer) + combined_mask = np.logical_and(combined_mask, prop_mask) + + # Apply extreme values + if extreme_values: + for property_name, mode in extreme_values.items(): + prop_values = self.properties[property_name].data + + # Create a masked array using the combined_mask + masked_values = np.ma.masked_array(prop_values, mask=~combined_mask) + + if mode == "highest": + target_value = masked_values.max() + elif mode == "lowest": + target_value = masked_values.min() + else: + raise ValueError( + f"Invalid mode {mode}. Choose from 'highest' or 'lowest'." + ) + + extreme_value_mask = prop_values == target_value + combined_mask = np.logical_and(combined_mask, extreme_value_mask) + + # Generate output + if return_list: + selected_cells = list(zip(*np.where(combined_mask))) + return selected_cells + else: + return combined_mask + + +class SingleGrid(_PropertyGrid): """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 @@ -576,6 +992,7 @@ def place_agent(self, agent: Agent, pos: Coordinate) -> None: self._grid[x][y] = agent if self._empties_built: self._empties.discard(pos) + self._empty_mask[pos] = False agent.pos = pos else: raise Exception("Cell not empty") @@ -588,10 +1005,11 @@ def remove_agent(self, agent: Agent) -> None: self._grid[x][y] = self.default_val() if self._empties_built: self._empties.add(pos) + self._empty_mask[agent.pos] = True agent.pos = None -class MultiGrid(_Grid): +class MultiGrid(_PropertyGrid): """Rectangular grid where each cell can contain more than one agent. Grid cells are indexed by [x, y], where [0, 0] is assumed to be at @@ -623,6 +1041,7 @@ def place_agent(self, agent: Agent, pos: Coordinate) -> None: agent.pos = pos if self._empties_built: self._empties.discard(pos) + self._empty_mask[agent.pos] = True def remove_agent(self, agent: Agent) -> None: """Remove the agent from the given location and set its pos attribute to None.""" @@ -631,6 +1050,7 @@ def remove_agent(self, agent: Agent) -> None: self._grid[x][y].remove(agent) if self._empties_built and self.is_cell_empty(pos): self._empties.add(pos) + self._empty_mask[agent.pos] = False agent.pos = None @accept_tuple_argument diff --git a/tests/test_space.py b/tests/test_space.py index ab0e0da3cc0..b7524b6d916 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from mesa.space import ContinuousSpace, NetworkGrid, SingleGrid +from mesa.space import ContinuousSpace, NetworkGrid, PropertyLayer, SingleGrid from tests.test_grid import MockAgent TEST_AGENTS = [(-20, -20), (-20, -20.05), (65, 18)] @@ -278,6 +278,165 @@ def test_remove_middle(self): self.space.remove_agent(agent_to_remove) +class TestPropertyLayer(unittest.TestCase): + def setUp(self): + self.layer = PropertyLayer("test_layer", 10, 10, 0) + + # Initialization Test + def test_initialization(self): + self.assertEqual(self.layer.name, "test_layer") + self.assertEqual(self.layer.width, 10) + self.assertEqual(self.layer.height, 10) + self.assertTrue(np.array_equal(self.layer.data, np.zeros((10, 10)))) + + # Set Cell Test + def test_set_cell(self): + self.layer.set_cell((5, 5), 1) + self.assertEqual(self.layer.data[5, 5], 1) + + # Set Cells Tests + def test_set_cells_no_condition(self): + self.layer.set_cells(2) + np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 2)) + + def test_set_cells_with_condition(self): + self.layer.set_cell((5, 5), 1) + + def condition(x): + return x == 0 + + self.layer.set_cells(3, condition) + self.assertEqual(self.layer.data[5, 5], 1) + self.assertEqual(self.layer.data[0, 0], 3) + # Check if the sum is correct + self.assertEqual(np.sum(self.layer.data), 3 * 99 + 1) + + def test_set_cells_with_random_condition(self): + # Probability for a cell to be updated + update_probability = 0.5 + + # Define a condition with a random part + def condition(val): + return np.random.rand() < update_probability + + # Apply set_cells + self.layer.set_cells(True, condition) + + # Count the number of cells that were set to True + true_count = np.sum(self.layer.data) + + width = self.layer.width + height = self.layer.height + + # Calculate expected range (with some tolerance for randomness) + expected_min = width * height * update_probability * 0.5 + expected_max = width * height * update_probability * 1.5 + + # Check if the true_count falls within the expected range + assert expected_min <= true_count <= expected_max + + # Modify Cell Test + def test_modify_cell_lambda(self): + self.layer.data = np.zeros((10, 10)) + self.layer.modify_cell((2, 2), lambda x: x + 5) + self.assertEqual(self.layer.data[2, 2], 5) + + def test_modify_cell_ufunc(self): + self.layer.data = np.ones((10, 10)) + self.layer.modify_cell((3, 3), np.add, 4) + self.assertEqual(self.layer.data[3, 3], 5) + + def test_modify_cell_invalid_operation(self): + with self.assertRaises(ValueError): + self.layer.modify_cell((1, 1), np.add) # Missing value for ufunc + + # Modify Cells Test + def test_modify_cells_lambda(self): + self.layer.data = np.zeros((10, 10)) + self.layer.modify_cells(lambda x: x + 2) + np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 2)) + + def test_modify_cells_ufunc(self): + self.layer.data = np.ones((10, 10)) + self.layer.modify_cells(np.multiply, 3) + np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 3)) + + def test_modify_cells_invalid_operation(self): + with self.assertRaises(ValueError): + self.layer.modify_cells(np.add) # Missing value for ufunc + + # Aggregate Property Test + def test_aggregate_property_lambda(self): + self.layer.data = np.arange(100).reshape(10, 10) + result = self.layer.aggregate_property(lambda x: np.sum(x)) + self.assertEqual(result, np.sum(np.arange(100))) + + def test_aggregate_property_ufunc(self): + self.layer.data = np.full((10, 10), 2) + result = self.layer.aggregate_property(np.mean) + self.assertEqual(result, 2) + + # Edge Case: Negative or Zero Dimensions + def test_initialization_negative_dimensions(self): + with self.assertRaises(ValueError): + PropertyLayer("test_layer", -10, 10, 0) + + def test_initialization_zero_dimensions(self): + with self.assertRaises(ValueError): + PropertyLayer("test_layer", 0, 10, 0) + + # Edge Case: Out-of-Bounds Cell Access + def test_set_cell_out_of_bounds(self): + with self.assertRaises(IndexError): + self.layer.set_cell((10, 10), 1) + + def test_modify_cell_out_of_bounds(self): + with self.assertRaises(IndexError): + self.layer.modify_cell((10, 10), lambda x: x + 5) + + # Edge Case: Selecting Cells with Complex Conditions + def test_select_cells_complex_condition(self): + self.layer.data = np.random.rand(10, 10) + selected = self.layer.select_cells(lambda x: (x > 0.5) & (x < 0.75)) + for c in selected: + self.assertTrue(0.5 < self.layer.data[c] < 0.75) + + # More edge cases + def test_set_cells_with_numpy_ufunc(self): + # Set some cells to a specific value + self.layer.data[0:5, 0:5] = 5 + + # Use a numpy ufunc as a condition. Here, we're using `np.greater` + # which will return True for cells with values greater than 2. + condition = np.greater + self.layer.set_cells(10, lambda x: condition(x, 2)) + + # Check if cells that had value greater than 2 are now set to 10 + updated_cells = self.layer.data[0:5, 0:5] + np.testing.assert_array_equal(updated_cells, np.full((5, 5), 10)) + + # Check if cells that had value 0 (less than or equal to 2) remain unchanged + unchanged_cells = self.layer.data[5:, 5:] + np.testing.assert_array_equal(unchanged_cells, np.zeros((5, 5))) + + def test_modify_cell_boundary_condition(self): + self.layer.data = np.zeros((10, 10)) + self.layer.modify_cell((0, 0), lambda x: x + 5) + self.layer.modify_cell((9, 9), lambda x: x + 5) + self.assertEqual(self.layer.data[0, 0], 5) + self.assertEqual(self.layer.data[9, 9], 5) + + def test_aggregate_property_std_dev(self): + self.layer.data = np.arange(100).reshape(10, 10) + result = self.layer.aggregate_property(np.std) + self.assertAlmostEqual(result, np.std(np.arange(100)), places=5) + + def test_data_type_consistency(self): + self.layer.data = np.zeros((10, 10), dtype=int) + self.layer.set_cell((5, 5), 5.5) + self.assertIsInstance(self.layer.data[5, 5], self.layer.data.dtype.type) + + class TestSingleGrid(unittest.TestCase): def setUp(self): self.space = SingleGrid(50, 50, False) @@ -312,6 +471,16 @@ def test_empty_cells(self): with self.assertRaises(Exception): self.space.move_to_empty(a) + def test_empty_mask_consistency(self): + # Check that the empty mask is consistent with the empties set + empty_mask = self.space.empty_mask + empties = self.space.empties + for i in range(self.space.width): + for j in range(self.space.height): + mask_value = empty_mask[i, j] + empties_value = (i, j) in empties + assert mask_value == empties_value + def move_agent(self): agent_number = 0 initial_pos = TEST_AGENTS_GRID[agent_number] @@ -439,6 +608,242 @@ def test_distance_squared_torus(self): assert self.space._distance_squared(pos1, pos2) == expected_distance_squared +class TestSingleGridWithPropertyGrid(unittest.TestCase): + def setUp(self): + self.grid = SingleGrid(10, 10, False) + self.property_layer1 = PropertyLayer("layer1", 10, 10, 0) + self.property_layer2 = PropertyLayer("layer2", 10, 10, 1) + self.grid.add_property_layer(self.property_layer1) + self.grid.add_property_layer(self.property_layer2) + + # Test adding and removing property layers + def test_add_property_layer(self): + self.assertIn("layer1", self.grid.properties) + self.assertIn("layer2", self.grid.properties) + + def test_remove_property_layer(self): + self.grid.remove_property_layer("layer1") + self.assertNotIn("layer1", self.grid.properties) + + def test_add_property_layer_mismatched_dimensions(self): + with self.assertRaises(ValueError): + self.grid.add_property_layer(PropertyLayer("layer3", 5, 5, 0)) + + def test_add_existing_property_layer(self): + with self.assertRaises(ValueError): + self.grid.add_property_layer(self.property_layer1) + + def test_remove_nonexistent_property_layer(self): + with self.assertRaises(ValueError): + self.grid.remove_property_layer("nonexistent_layer") + + # Test getting masks + def test_get_empty_mask(self): + empty_mask = self.grid.empty_mask + self.assertTrue(np.all(empty_mask == np.ones((10, 10), dtype=bool))) + + def test_get_empty_mask_with_agent(self): + agent = MockAgent(0, self.grid) + self.grid.place_agent(agent, (4, 6)) + + empty_mask = self.grid.empty_mask + expected_mask = np.ones((10, 10), dtype=bool) + expected_mask[4, 6] = False + + self.assertTrue(np.all(empty_mask == expected_mask)) + + def test_get_neighborhood_mask(self): + agent = MockAgent(0, self.grid) + agent2 = MockAgent(1, self.grid) + self.grid.place_agent(agent, (5, 5)) + self.grid.place_agent(agent2, (5, 6)) + neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) + expected_mask = np.zeros((10, 10), dtype=bool) + expected_mask[4:7, 4:7] = True + expected_mask[5, 5] = False + self.assertTrue(np.all(neighborhood_mask == expected_mask)) + + # Test selecting and moving to cells based on multiple conditions + def test_select_cells_by_properties(self): + def condition(x): + return x == 0 + + selected_cells = self.grid.select_cells({"layer1": condition}) + self.assertEqual(len(selected_cells), 100) + + def test_select_cells_by_properties_return_mask(self): + def condition(x): + return x == 0 + + selected_mask = self.grid.select_cells({"layer1": condition}, return_list=False) + self.assertTrue(isinstance(selected_mask, np.ndarray)) + self.assertTrue(selected_mask.all()) + + def test_move_agent_to_cell_by_properties(self): + agent = MockAgent(1, self.grid) + self.grid.place_agent(agent, (5, 5)) + conditions = {"layer1": lambda x: x == 0} + target_cells = self.grid.select_cells(conditions) + self.grid.move_agent_to_one_of(agent, target_cells) + # Agent should move, since none of the cells match the condition + self.assertNotEqual(agent.pos, (5, 5)) + + def test_move_agent_no_eligible_cells(self): + agent = MockAgent(3, self.grid) + self.grid.place_agent(agent, (5, 5)) + conditions = {"layer1": lambda x: x != 0} + target_cells = self.grid.select_cells(conditions) + self.grid.move_agent_to_one_of(agent, target_cells) + self.assertEqual(agent.pos, (5, 5)) + + # Test selecting and moving to cells based on extreme values + def test_select_extreme_value_cells(self): + self.grid.properties["layer2"].set_cell((3, 1), 1.1) + target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"}) + self.assertIn((3, 1), target_cells) + + def test_select_extreme_value_cells_return_mask(self): + self.grid.properties["layer2"].set_cell((3, 1), 1.1) + target_mask = self.grid.select_cells( + extreme_values={"layer2": "highest"}, return_list=False + ) + self.assertTrue(isinstance(target_mask, np.ndarray)) + self.assertTrue(target_mask[3, 1]) + + def test_move_agent_to_extreme_value_cell(self): + agent = MockAgent(2, self.grid) + self.grid.place_agent(agent, (5, 5)) + self.grid.properties["layer2"].set_cell((3, 1), 1.1) + target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"}) + self.grid.move_agent_to_one_of(agent, target_cells) + self.assertEqual(agent.pos, (3, 1)) + + # Test using masks + def test_select_cells_by_properties_with_empty_mask(self): + self.grid.place_agent( + MockAgent(0, self.grid), (5, 5) + ) # Placing an agent to ensure some cells are not empty + empty_mask = self.grid.empty_mask + + def condition(x): + return x == 0 + + selected_cells = self.grid.select_cells({"layer1": condition}, masks=empty_mask) + self.assertNotIn( + (5, 5), selected_cells + ) # (5, 5) should not be in the selection as it's not empty + + def test_select_cells_by_properties_with_neighborhood_mask(self): + neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) + + def condition(x): + return x == 0 + + selected_cells = self.grid.select_cells( + {"layer1": condition}, masks=neighborhood_mask + ) + expected_selection = [ + (4, 4), + (4, 5), + (4, 6), + (5, 4), + (5, 6), + (6, 4), + (6, 5), + (6, 6), + ] # Cells in the neighborhood of (5, 5) + self.assertCountEqual(selected_cells, expected_selection) + + def test_move_agent_to_cell_by_properties_with_empty_mask(self): + agent = MockAgent(1, self.grid) + self.grid.place_agent(agent, (5, 5)) + self.grid.place_agent( + MockAgent(2, self.grid), (4, 5) + ) # Placing another agent to create a non-empty cell + empty_mask = self.grid.empty_mask + conditions = {"layer1": lambda x: x == 0} + target_cells = self.grid.select_cells(conditions, masks=empty_mask) + self.grid.move_agent_to_one_of(agent, target_cells) + self.assertNotEqual( + agent.pos, (4, 5) + ) # Agent should not move to (4, 5) as it's not empty + + def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self): + agent = MockAgent(1, self.grid) + self.grid.place_agent(agent, (5, 5)) + neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) + conditions = {"layer1": lambda x: x == 0} + target_cells = self.grid.select_cells(conditions, masks=neighborhood_mask) + self.grid.move_agent_to_one_of(agent, target_cells) + self.assertIn( + agent.pos, [(4, 4), (4, 5), (4, 6), (5, 4), (5, 6), (6, 4), (6, 5), (6, 6)] + ) # Agent should move within the neighborhood + + # Test invalid inputs + def test_invalid_property_name_in_conditions(self): + def condition(x): + return x == 0 + + with self.assertRaises(KeyError): + self.grid.select_cells(conditions={"nonexistent_layer": condition}) + + # Test if coordinates means the same between the grid and the property layer + def test_property_layer_coordinates(self): + agent = MockAgent(0, self.grid) + correct_pos = (1, 8) + incorrect_pos = (8, 1) + self.grid.place_agent(agent, correct_pos) + + # Simple check on layer 1: set by agent, check by layer + self.grid.properties["layer1"].set_cell(agent.pos, 2) + self.assertEqual(self.grid.properties["layer1"].data[agent.pos], 2) + + # More complicated check on layer 2: set by layer, check by agent + self.grid.properties["layer2"].set_cell(correct_pos, 3) + self.grid.properties["layer2"].set_cell(incorrect_pos, 4) + + correct_grid_value = self.grid.properties["layer2"].data[correct_pos] + incorrect_grid_value = self.grid.properties["layer2"].data[incorrect_pos] + agent_grid_value = self.grid.properties["layer2"].data[agent.pos] + + self.assertEqual(correct_grid_value, agent_grid_value) + self.assertNotEqual(incorrect_grid_value, agent_grid_value) + + # Test selecting cells with only_empty parameter + def test_select_cells_only_empty(self): + self.grid.place_agent(MockAgent(0, self.grid), (5, 5)) # Occupying a cell + selected_cells = self.grid.select_cells(only_empty=True) + self.assertNotIn( + (5, 5), selected_cells + ) # The occupied cell should not be selected + + def test_select_cells_only_empty_with_conditions(self): + self.grid.place_agent(MockAgent(1, self.grid), (5, 5)) + self.grid.properties["layer1"].set_cell((5, 5), 2) + self.grid.properties["layer1"].set_cell((6, 6), 2) + + def condition(x): + return x == 2 + + selected_cells = self.grid.select_cells({"layer1": condition}, only_empty=True) + self.assertIn((6, 6), selected_cells) + self.assertNotIn((5, 5), selected_cells) + + # Test selecting cells with multiple extreme values + def test_select_cells_multiple_extreme_values(self): + self.grid.properties["layer1"].set_cell((1, 1), 3) + self.grid.properties["layer1"].set_cell((2, 2), 3) + self.grid.properties["layer2"].set_cell((2, 2), 0.5) + self.grid.properties["layer2"].set_cell((3, 3), 0.5) + selected_cells = self.grid.select_cells( + extreme_values={"layer1": "highest", "layer2": "lowest"} + ) + self.assertIn((2, 2), selected_cells) + self.assertNotIn((1, 1), selected_cells) + self.assertNotIn((3, 3), selected_cells) + self.assertEqual(len(selected_cells), 1) + + class TestSingleNetworkGrid(unittest.TestCase): GRAPH_SIZE = 10