From be494d3965cb270aa7c5f1694c470497c8bf11e6 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 13:34:40 +0100 Subject: [PATCH 01/37] space: Implement PropertyLayer and _PropertyGrid --- mesa/space.py | 151 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 987afaf9b51..d524fb2c697 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -21,6 +21,7 @@ from __future__ import annotations import collections +import inspect import itertools import math from numbers import Real @@ -548,7 +549,153 @@ def exists_empty_cells(self) -> bool: return len(self.empties) > 0 -class SingleGrid(_Grid): +def is_lambda_function(function): + """Check if a function is a lambda function.""" + return ( + inspect.isfunction(function) + and len(inspect.signature(function).parameters) == 1 + ) + + +class PropertyLayer: + def __init__( + self, name: str, width: int, height: int, default_value, dtype=np.float32 + ): + self.name = name + self.width = width + self.height = height + self.data = np.full((width, height), default_value, dtype=dtype) + + 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 that returns a boolean array when applied to the data. + """ + if condition is None: + np.copyto(self.data, value) # In-place update + else: + # Ensure condition is a boolean array of the same shape as self.data + if ( + not isinstance(condition, np.ndarray) + or condition.shape != self.data.shape + ): + raise ValueError( + "Condition must be a NumPy array with the same shape as the grid." + ) + np.copyto(self.data, value, where=condition) # Conditional in-place update + + 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_lambda_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. + """ + if condition_function is not None: + condition_array = np.vectorize(condition_function)(self.data) + else: + condition_array = np.ones_like(self.data, dtype=bool) # All cells + + # Check if the operation is a lambda function or a NumPy ufunc + if is_lambda_function(operation): + # Lambda function case + modified_data = np.vectorize(operation)(self.data) + elif value is not None: + # NumPy ufunc case + modified_data = operation(self.data, value) + else: + raise ValueError("Invalid operation or missing value for NumPy ufunc.") + + 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. + """ + + # Check if the operation is a lambda function or a NumPy ufunc + if is_lambda_function(operation): + # Lambda function case + return operation(self.data) + else: + # NumPy ufunc case + return operation(self.data) + + +class _PropertyGrid(_Grid): + def __init__(self, width: int, height: int, torus: bool): + super().__init__(width, height, torus) + self.properties = {} + + # Add and remove properties to the grid + def add_property_layer(self, property_layer: PropertyLayer): + self.properties[property_layer.name] = property_layer + + def remove_property_layer(self, property_name: str): + if property_name not in self.properties: + raise ValueError(f"Property layer {property_name} does not exist.") + del self.properties[property_name] + + # TODO: + # - Select cells conditionally based on multiple properties + # - Move random cells conditionally based on multiple properties + # - Move to cell with highest/lowest/closest property value + + +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 @@ -591,7 +738,7 @@ def remove_agent(self, agent: Agent) -> None: 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 From 6820b1228fea5f66500bead3d73676d4fe67a6dd Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 16:55:39 +0100 Subject: [PATCH 02/37] Allow initializing a _PropertyGrid with PropertyLayer --- mesa/space.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/mesa/space.py b/mesa/space.py index d524fb2c697..485c3623750 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -676,12 +676,33 @@ def aggregate_property(self, operation): class _PropertyGrid(_Grid): - def __init__(self, width: int, height: int, torus: bool): + def __init__( + self, + width: int, + height: int, + torus: bool, + property_layers: None | PropertyLayer | list[PropertyLayer] = None, + ): super().__init__(width, height, torus) self.properties = {} + # 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) + # Add and remove properties to the grid def add_property_layer(self, property_layer: PropertyLayer): + 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): From f3ad411c3ab6689a40dc52897859cb492a351e2c Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 17:20:19 +0100 Subject: [PATCH 03/37] _PropertyGrid: Implement multi-property cell selection and enhanced agent movement This commit introduces three significant enhancements for grids based on the _PropertyGrid in the mesa.space module: 1. `select_cells_multi_properties`: Allows for the selection of cells based on multiple property conditions, using a combination of NumPy operations. This method returns a list of coordinates satisfying the specified conditions. 2. `move_agent_to_random_cell`: Enables moving an agent to a random cell that meets specified property conditions, enhancing the flexibility in agent movements. 3. `move_agent_to_extreme_value_cell`: Facilitates moving an agent to a cell with the highest, lowest, or closest property value, based on a specified mode. This method extends agent movement capabilities, allowing for more dynamic and condition-based relocations. --- mesa/space.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 485c3623750..4dba07f1e13 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -710,10 +710,73 @@ def remove_property_layer(self, property_name: str): raise ValueError(f"Property layer {property_name} does not exist.") del self.properties[property_name] - # TODO: - # - Select cells conditionally based on multiple properties - # - Move random cells conditionally based on multiple properties - # - Move to cell with highest/lowest/closest property value + def select_cells_multi_properties(self, conditions: dict) -> List[Coordinate]: + """ + Select cells based on multiple property conditions using NumPy. + + Args: + conditions (dict): A dictionary where keys are property names and values are + callables that take a single argument (the property value) + and return a boolean. + + Returns: + List[Coordinate]: A list of coordinates where the conditions are satisfied. + """ + # Start with a mask of all True values + combined_mask = np.ones((self.width, self.height), dtype=bool) + + for prop_name, condition in conditions.items(): + prop_layer = self.properties[prop_name].data + # Apply the condition to the property layer + prop_mask = condition(prop_layer) + # Combine with the existing mask using logical AND + combined_mask = np.logical_and(combined_mask, prop_mask) + + # Extract coordinates from the combined mask + selected_cells = list(zip(*np.where(combined_mask))) + return selected_cells + + def move_agent_to_random_cell(self, agent: Agent, conditions: dict) -> None: + """ + Move an agent to a random cell that meets specified property conditions. + If no eligible cells are found, issue a warning and keep the agent in its current position. + + Args: + agent (Agent): The agent to move. + conditions (dict): Conditions for selecting the cell. + """ + eligible_cells = self.select_cells_multi_properties(conditions) + if not eligible_cells: + warn(f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", RuntimeWarning) + return # Agent stays in the current position + + # Randomly choose one of the eligible cells and move the agent + new_pos = agent.random.choice(eligible_cells) + self.move_agent(agent, new_pos) + + def move_agent_to_extreme_value_cell(self, agent: Agent, property_name: str, mode: str) -> None: + """ + Move an agent to a cell with the highest, lowest, or closest property value. + + Args: + agent (Agent): The agent to move. + property_name (str): The name of the property layer. + mode (str): 'highest', 'lowest', or 'closest'. + """ + prop_values = self.properties[property_name].data + if mode == 'highest': + target_value = np.max(prop_values) + elif mode == 'lowest': + target_value = np.min(prop_values) + elif mode == 'closest': + agent_value = prop_values[agent.pos] + target_value = prop_values[np.abs(prop_values - agent_value).argmin()] + else: + raise ValueError(f"Invalid mode {mode}. Choose from 'highest', 'lowest', or 'closest'.") + + target_cells = list(zip(*np.where(prop_values == target_value))) + new_pos = agent.random.choice(target_cells) + self.move_agent(agent, new_pos) class SingleGrid(_PropertyGrid): From 38faff5222c4f9424a5d70bfb0b5e8a608617d24 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 17:51:22 +0100 Subject: [PATCH 04/37] _PropertyGrid: Add optional neighborhood filtering to spatial methods - Updated `select_cells_multi_properties`, `move_agent_to_random_cell`, and `move_agent_to_extreme_value_cell` methods in the `_PropertyGrid` class to include an optional neighborhood filtering feature. - Added `only_neighborhood` parameter to these methods to allow for conditional operations within a specified neighborhood around an agent's position. - Introduced `get_neighborhood_mask` as a helper function to create a boolean mask for neighborhood-based selections, enhancing performance and readability. - Modified methods to utilize NumPy for efficient array operations, improving the overall performance of grid-based spatial calculations. - Ensured backward compatibility by setting `only_neighborhood` to `False` by default, allowing existing code to function without modification. --- mesa/space.py | 126 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 18 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 4dba07f1e13..d7d3fcbb2d5 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -710,17 +710,50 @@ def remove_property_layer(self, property_name: str): raise ValueError(f"Property layer {property_name} does not exist.") del self.properties[property_name] - def select_cells_multi_properties(self, conditions: dict) -> List[Coordinate]: + def get_neighborhood_mask( + self, pos: Coordinate, moore: bool, include_center: bool, radius: int + ) -> np.ndarray: """ - Select cells based on multiple property conditions using NumPy. + Generate a boolean mask representing the neighborhood. + + 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_multi_properties( + self, + conditions: dict, + only_neighborhood: bool = False, + pos: None | Coordinate = None, + moore: bool = True, + include_center: bool = False, + radius: int = 1, + ) -> list[Coordinate]: + """ + Select cells based on multiple property conditions using NumPy, optionally within a neighborhood. Args: conditions (dict): A dictionary where keys are property names and values are callables that take a single argument (the property value) and return a boolean. + only_neighborhood (bool): If True, restrict selection to the neighborhood. + pos, moore, include_center, radius: Optional neighborhood parameters. Returns: - List[Coordinate]: A list of coordinates where the conditions are satisfied. + List[Coordinate]: Coordinates where conditions are satisfied. """ # Start with a mask of all True values combined_mask = np.ones((self.width, self.height), dtype=bool) @@ -732,50 +765,107 @@ def select_cells_multi_properties(self, conditions: dict) -> List[Coordinate]: # Combine with the existing mask using logical AND combined_mask = np.logical_and(combined_mask, prop_mask) + if only_neighborhood and pos is not None: + neighborhood_mask = self.get_neighborhood_mask( + pos, moore, include_center, radius + ) + combined_mask = np.logical_and(combined_mask, neighborhood_mask) + # Extract coordinates from the combined mask selected_cells = list(zip(*np.where(combined_mask))) return selected_cells - def move_agent_to_random_cell(self, agent: Agent, conditions: dict) -> None: + def move_agent_to_random_cell( + self, + agent: Agent, + conditions: dict, + only_neighborhood: bool = False, + moore: bool = True, + include_center: bool = False, + radius: int = 1, + ) -> None: """ - Move an agent to a random cell that meets specified property conditions. + Move an agent to a random cell that meets specified property conditions, optionally within a neighborhood. If no eligible cells are found, issue a warning and keep the agent in its current position. Args: agent (Agent): The agent to move. conditions (dict): Conditions for selecting the cell. + only_neighborhood, moore, include_center, radius: Optional neighborhood parameters. """ - eligible_cells = self.select_cells_multi_properties(conditions) + pos = agent.pos if only_neighborhood else None + eligible_cells = self.select_cells_multi_properties( + conditions, + only_neighborhood, + pos, + moore, + include_center, + radius, + ) if not eligible_cells: - warn(f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", RuntimeWarning) + warn( + f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", + RuntimeWarning, + stacklevel=2, + ) return # Agent stays in the current position # Randomly choose one of the eligible cells and move the agent new_pos = agent.random.choice(eligible_cells) self.move_agent(agent, new_pos) - def move_agent_to_extreme_value_cell(self, agent: Agent, property_name: str, mode: str) -> None: + def move_agent_to_extreme_value_cell( + self, + agent: Agent, + property_name: str, + mode: str, + only_neighborhood: bool = False, + moore: bool = True, + include_center: bool = False, + radius: int = 1, + ) -> None: """ - Move an agent to a cell with the highest, lowest, or closest property value. + Move an agent to a cell with the highest, lowest, or closest property value, + optionally within a neighborhood. Args: agent (Agent): The agent to move. property_name (str): The name of the property layer. mode (str): 'highest', 'lowest', or 'closest'. + only_neighborhood, moore, include_center, radius: Optional neighborhood parameters. """ + pos = agent.pos if only_neighborhood else None prop_values = self.properties[property_name].data - if mode == 'highest': - target_value = np.max(prop_values) - elif mode == 'lowest': - target_value = np.min(prop_values) - elif mode == 'closest': + + if pos is not None: + # Mask out cells outside the neighborhood. + neighborhood_mask = self.get_neighborhood_mask( + pos, moore, include_center, radius + ) + # Use NaN for out-of-neighborhood cells + masked_prop_values = np.where(neighborhood_mask, prop_values, np.nan) + else: + masked_prop_values = prop_values + + # Find the target value + if mode == "highest": + target_value = np.nanmax(masked_prop_values) + elif mode == "lowest": + target_value = np.nanmin(masked_prop_values) + elif mode == "closest": agent_value = prop_values[agent.pos] - target_value = prop_values[np.abs(prop_values - agent_value).argmin()] + target_value = masked_prop_values[ + np.nanargmin(np.abs(masked_prop_values - agent_value)) + ] else: - raise ValueError(f"Invalid mode {mode}. Choose from 'highest', 'lowest', or 'closest'.") + raise ValueError( + f"Invalid mode {mode}. Choose from 'highest', 'lowest', or 'closest'." + ) - target_cells = list(zip(*np.where(prop_values == target_value))) - new_pos = agent.random.choice(target_cells) + # Find the coordinates of the target value(s) + target_cells = np.column_stack(np.where(masked_prop_values == target_value)) + # If there are multiple target cells, randomly choose one + new_pos = tuple(agent.random.choice(target_cells, axis=0)) self.move_agent(agent, new_pos) From ddeb91c3ed13d8e869d4d5bac559a642bd0fdf45 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 21:15:11 +0100 Subject: [PATCH 05/37] PropertyLayer: Check dimensions and dtype on init Checks on initialization are cheap, and it helps users make the right decision for (NumPy) data type. --- mesa/space.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mesa/space.py b/mesa/space.py index d7d3fcbb2d5..82189ec1266 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -564,6 +564,22 @@ def __init__( 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) def set_cell(self, position: Coordinate, value): From 1216d51af6cb13000e0c05d8131d7a15dc0a5ad9 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 21:22:42 +0100 Subject: [PATCH 06/37] Add docstring to PropertyLayer --- mesa/space.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/mesa/space.py b/mesa/space.py index 82189ec1266..d4b2705c283 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -558,9 +558,49 @@ def is_lambda_function(function): 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): 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. + """ + def __init__( self, name: str, width: int, height: int, default_value, dtype=np.float32 ): + """ + 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.float32. + + 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.int16 or np.float32). 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 From f978f58d68482ed316e6086236af5ff9bdcede25 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 21:23:36 +0100 Subject: [PATCH 07/37] tests: Add tests for PropertyLayer --- tests/test_space.py | 101 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/tests/test_space.py b/tests/test_space.py index ab0e0da3cc0..0128d7e2780 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,105 @@ 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): + condition = np.full((10, 10), False) + condition[5, :] = True # Only update the 5th row + self.layer.set_cells(3, condition) + self.assertEqual(np.sum(self.layer.data[5, :] == 3), 10) + self.assertEqual(np.sum(self.layer.data != 3), 90) + + def test_set_cells_invalid_condition(self): + with self.assertRaises(ValueError): + self.layer.set_cells(4, condition=np.full((5, 5), False)) # Invalid shape + + # Modify Cells 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 + + # Select 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) + + class TestSingleGrid(unittest.TestCase): def setUp(self): self.space = SingleGrid(50, 50, False) From 900cd6282dd958e1cfa60b3304ac69b6978163ef Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 21:34:36 +0100 Subject: [PATCH 08/37] Add docstring for _PropertyGrid --- mesa/space.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/mesa/space.py b/mesa/space.py index d4b2705c283..63bc13fc6ce 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -732,6 +732,34 @@ def aggregate_property(self, operation): 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. + + 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_multi_properties(conditions, only_neighborhood, pos, moore, include_center, radius): + Selects cells based on multiple property conditions, optionally within a neighborhood. + move_agent_to_random_cell(agent, conditions, only_neighborhood, moore, include_center, radius): + Moves an agent to a random cell meeting specified property conditions, optionally within a neighborhood. + move_agent_to_extreme_value_cell(agent, property_name, mode, only_neighborhood, moore, include_center, radius): + Moves an agent to a cell with extreme value of a property, optionally within a neighborhood. + + 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, @@ -739,6 +767,19 @@ def __init__( 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 = {} @@ -753,6 +794,16 @@ def __init__( # 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: @@ -762,6 +813,15 @@ def add_property_layer(self, property_layer: PropertyLayer): 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] @@ -771,6 +831,7 @@ def get_neighborhood_mask( ) -> np.ndarray: """ Generate a boolean mask representing the neighborhood. + Helper method for select_cells_multi_properties(). Args: pos (Coordinate): Center of the neighborhood. From 04beb82342fd3fce0358475f887a6a9e04633034 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 21:37:56 +0100 Subject: [PATCH 09/37] _PropertyGrid: Make _get_neighborhood_mask private Mark the _get_neighborhood_mask method as private, since it's intended as a helper function for select_cells_multi_properties() and move_agent_to_random_cell() --- mesa/space.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 63bc13fc6ce..d4ddffed6b9 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -826,12 +826,12 @@ def remove_property_layer(self, property_name: str): raise ValueError(f"Property layer {property_name} does not exist.") del self.properties[property_name] - def get_neighborhood_mask( + 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(). + Helper method for select_cells_multi_properties() and move_agent_to_random_cell() Args: pos (Coordinate): Center of the neighborhood. @@ -883,7 +883,7 @@ def select_cells_multi_properties( combined_mask = np.logical_and(combined_mask, prop_mask) if only_neighborhood and pos is not None: - neighborhood_mask = self.get_neighborhood_mask( + neighborhood_mask = self._get_neighborhood_mask( pos, moore, include_center, radius ) combined_mask = np.logical_and(combined_mask, neighborhood_mask) @@ -956,7 +956,7 @@ def move_agent_to_extreme_value_cell( if pos is not None: # Mask out cells outside the neighborhood. - neighborhood_mask = self.get_neighborhood_mask( + neighborhood_mask = self._get_neighborhood_mask( pos, moore, include_center, radius ) # Use NaN for out-of-neighborhood cells From 64ee7d3b0137fb64cfd458146d761aae4265b78b Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 22:19:27 +0100 Subject: [PATCH 10/37] move_agent_to_extreme_value_cell: remove closest option closest is more difficult than highest or lowest, since closest needs a target to compare against. For now too complex without proven need, so I removed it. --- mesa/space.py | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index d4ddffed6b9..e76ea0651cc 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -760,6 +760,7 @@ class _PropertyGrid(_Grid): 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, @@ -942,47 +943,41 @@ def move_agent_to_extreme_value_cell( radius: int = 1, ) -> None: """ - Move an agent to a cell with the highest, lowest, or closest property value, + Move an agent to a cell with the highest or lowest property value, optionally within a neighborhood. + If multiple cells have the same extreme value, one of them is chosen randomly. + If no eligible cells are found, issue a warning and keep the agent in its current position. + Args: agent (Agent): The agent to move. property_name (str): The name of the property layer. - mode (str): 'highest', 'lowest', or 'closest'. + mode (str): 'highest' or 'lowest'. only_neighborhood, moore, include_center, radius: Optional neighborhood parameters. """ - pos = agent.pos if only_neighborhood else None prop_values = self.properties[property_name].data - - if pos is not None: - # Mask out cells outside the neighborhood. + if only_neighborhood: neighborhood_mask = self._get_neighborhood_mask( - pos, moore, include_center, radius + agent.pos, moore, include_center, radius ) - # Use NaN for out-of-neighborhood cells masked_prop_values = np.where(neighborhood_mask, prop_values, np.nan) else: masked_prop_values = prop_values - # Find the target value + # Find coordinates of target cells directly if mode == "highest": - target_value = np.nanmax(masked_prop_values) + target_cells = np.column_stack( + np.where(masked_prop_values == np.nanmax(masked_prop_values)) + ) elif mode == "lowest": - target_value = np.nanmin(masked_prop_values) - elif mode == "closest": - agent_value = prop_values[agent.pos] - target_value = masked_prop_values[ - np.nanargmin(np.abs(masked_prop_values - agent_value)) - ] - else: - raise ValueError( - f"Invalid mode {mode}. Choose from 'highest', 'lowest', or 'closest'." + target_cells = np.column_stack( + np.where(masked_prop_values == np.nanmin(masked_prop_values)) ) + else: + raise ValueError(f"Invalid mode {mode}. Choose from 'highest' or 'lowest'.") - # Find the coordinates of the target value(s) - target_cells = np.column_stack(np.where(masked_prop_values == target_value)) # If there are multiple target cells, randomly choose one - new_pos = tuple(agent.random.choice(target_cells, axis=0)) + new_pos = tuple(agent.random.choice(target_cells)) self.move_agent(agent, new_pos) From db9779c7a40255852c76d48b08d13a092901d480 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 22:20:14 +0100 Subject: [PATCH 11/37] tests: Add tests for _PropertyGrid SingleGrid inherits from _PropertyGrid, so testing though SingleGrid. --- tests/test_space.py | 116 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/test_space.py b/tests/test_space.py index 0128d7e2780..78866d5058a 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -538,6 +538,122 @@ 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 selecting cells + def test_select_cells_multi_properties(self): + condition = lambda x: x == 0 + selected_cells = self.grid.select_cells_multi_properties({"layer1": condition}) + self.assertEqual(len(selected_cells), 100) # All cells should be selected + + def test_select_cells_with_multiple_properties(self): + condition1 = lambda x: x == 0 + condition2 = lambda x: x == 1 + selected_cells = self.grid.select_cells_multi_properties( + {"layer1": condition1, "layer2": condition2} + ) + self.assertEqual( + len(selected_cells), 100 + ) # All cells should meet both conditions + + def test_select_cells_with_neighborhood(self): + condition = lambda x: x == 0 + selected_cells = self.grid.select_cells_multi_properties( + {"layer1": condition}, only_neighborhood=True, pos=(5, 5), radius=1 + ) + # Expect a selection of cells around (5, 5) + expected_selection = [ + (4, 4), + (4, 5), + (4, 6), + (5, 4), + (5, 6), + (6, 4), + (6, 5), + (6, 6), + ] + self.assertCountEqual(selected_cells, expected_selection) + + def test_select_no_cells_due_to_conflicting_conditions(self): + condition1 = lambda x: x == 0 # All cells in layer1 meet this + condition2 = lambda x: x != 1 # No cells in layer2 meet this + selected_cells = self.grid.select_cells_multi_properties( + {"layer1": condition1, "layer2": condition2} + ) + self.assertEqual(len(selected_cells), 0) + + # Test moving agents to cells + def test_move_agent_to_random_cell(self): + agent = MockAgent(1, self.grid) + self.grid.place_agent(agent, (5, 5)) + conditions = {"layer1": lambda x: x == 0} + self.grid.move_agent_to_random_cell(agent, conditions) + 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} # No cell meets this condition + self.grid.move_agent_to_random_cell(agent, conditions) + # Agent should not move + self.assertEqual(agent.pos, (5, 5)) + + # Move to cells with the highest or lowest value in a layer + 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) + self.grid.move_agent_to_extreme_value_cell(agent, "layer2", "highest") + self.assertEqual(agent.pos, (3, 1)) + + def test_move_agent_to_extreme_value_cell_lowest(self): + agent = MockAgent(4, self.grid) + self.grid.place_agent(agent, (5, 5)) + self.grid.properties["layer2"].set_cell((6, 7), 0) + self.grid.move_agent_to_extreme_value_cell(agent, "layer2", "lowest") + # Agent should move to a cell with the lowest value in layer2 (which is 1 for all cells, so position should not change) + self.assertEqual(agent.pos, (6, 7)) + + # Edge Cases: Invalid property name or mode + def test_invalid_property_name_in_conditions(self): + condition = lambda x: x == 0 + with self.assertRaises(KeyError): + self.grid.select_cells_multi_properties({"nonexistent_layer": condition}) + + def test_invalid_mode_in_move_to_extreme(self): + agent = MockAgent(6, self.grid) + self.grid.place_agent(agent, (5, 5)) + with self.assertRaises(ValueError): + self.grid.move_agent_to_extreme_value_cell(agent, "layer1", "invalid_mode") + + class TestSingleNetworkGrid(unittest.TestCase): GRAPH_SIZE = 10 From 5be029c87cfd903469673844638bcca49455ae98 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 22:42:18 +0100 Subject: [PATCH 12/37] PropertyLayer: Fix handling of 'condition' callable in set_cells Resolved an inconsistency in the set_cells method of our grid class. The method's documentation stated that the 'condition' argument should be a callable (such as a lambda function or a NumPy ufunc), but the implementation incorrectly treated 'condition' as a NumPy array. This update rectifies the implementation to align with the documented behavior. Now, the 'condition' argument is correctly called with the grid data as input, and its output (expected to be a boolean array) is used for conditional in-place updates of the grid. This change ensures that the function operates correctly when provided with callable conditions, fulfilling the intended functionality. Includes: - Calling 'condition' with self.data and using its output for conditional updates. - Adjusted error handling to check the output of the callable, ensuring it's a NumPy array with the correct shape. - Updated comments within the method for clarity. --- mesa/space.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index e76ea0651cc..93a9c57e972 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -634,20 +634,23 @@ def set_cells(self, value, condition=None): Args: value: The value to be used for the update. - condition: (Optional) A callable that returns a boolean array when applied to the data. + 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: - # Ensure condition is a boolean array of the same shape as self.data + # Call the condition and check if the result is a boolean array + condition_result = condition(self.data) if ( - not isinstance(condition, np.ndarray) - or condition.shape != self.data.shape + not isinstance(condition_result, np.ndarray) + or condition_result.shape != self.data.shape ): raise ValueError( - "Condition must be a NumPy array with the same shape as the grid." + "Result of condition must be a NumPy array with the same shape as the grid." ) - np.copyto(self.data, value, where=condition) # Conditional in-place update + # Conditional in-place update + np.copyto(self.data, value, where=condition_result) def modify_cell(self, position: Coordinate, operation, value=None): """ From 2ac72a567c76df71c9bad772064118b72d118044 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 4 Dec 2023 22:44:55 +0100 Subject: [PATCH 13/37] PropertyLayer: Remove unnecessary check in aggregate_property Whether it's a Lambda function or NumPy ufunc, they can be called the same way. --- mesa/space.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 93a9c57e972..ac57447514c 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -724,14 +724,7 @@ def aggregate_property(self, operation): Args: operation: A function to apply. Can be a lambda function or a NumPy ufunc. """ - - # Check if the operation is a lambda function or a NumPy ufunc - if is_lambda_function(operation): - # Lambda function case - return operation(self.data) - else: - # NumPy ufunc case - return operation(self.data) + return operation(self.data) class _PropertyGrid(_Grid): From 6ead37946098d458fa23f42d180a04c2d34af4c1 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 08:44:33 +0100 Subject: [PATCH 14/37] _PropertyGrid: Take mask as input for selection functions Take mask as input for selection functions. This way, it isn't needed to parse all neighbourhood elements. It also allows to use custom masks like for empty cells, etc. get_neighborhood_mask is now a public (not private) method again. --- mesa/space.py | 85 +++++++++++++++------------------------------------ 1 file changed, 25 insertions(+), 60 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index ac57447514c..22ec5d60f06 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -746,12 +746,12 @@ class _PropertyGrid(_Grid): 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_multi_properties(conditions, only_neighborhood, pos, moore, include_center, radius): - Selects cells based on multiple property conditions, optionally within a neighborhood. - move_agent_to_random_cell(agent, conditions, only_neighborhood, moore, include_center, radius): - Moves an agent to a random cell meeting specified property conditions, optionally within a neighborhood. - move_agent_to_extreme_value_cell(agent, property_name, mode, only_neighborhood, moore, include_center, radius): - Moves an agent to a cell with extreme value of a property, optionally within a neighborhood. + select_cells_multi_properties(conditions, mask): + Selects cells based on multiple property conditions, optionally with a mask. + move_agent_to_random_cell(agent, conditions, mask): + Moves an agent to a random cell meeting specified property conditions, optionally with a mask. + move_agent_to_extreme_value_cell(agent, property_name, mode, mask): + Moves an agent to a cell with extreme value of a property, optionally with a mask. Note: This class is not intended for direct use in user models but is currently used by the SingleGrid and MultiGrid. @@ -823,7 +823,7 @@ def remove_property_layer(self, property_name: str): raise ValueError(f"Property layer {property_name} does not exist.") del self.properties[property_name] - def _get_neighborhood_mask( + def get_neighborhood_mask( self, pos: Coordinate, moore: bool, include_center: bool, radius: int ) -> np.ndarray: """ @@ -848,29 +848,25 @@ def _get_neighborhood_mask( return mask def select_cells_multi_properties( - self, - conditions: dict, - only_neighborhood: bool = False, - pos: None | Coordinate = None, - moore: bool = True, - include_center: bool = False, - radius: int = 1, + self, conditions: dict, mask: np.ndarray = None ) -> list[Coordinate]: """ - Select cells based on multiple property conditions using NumPy, optionally within a neighborhood. + Select cells based on multiple property conditions using NumPy, optionally with a mask. Args: conditions (dict): A dictionary where keys are property names and values are callables that take a single argument (the property value) and return a boolean. - only_neighborhood (bool): If True, restrict selection to the neighborhood. - pos, moore, include_center, radius: Optional neighborhood parameters. + mask (np.ndarray, optional): A boolean mask to restrict the selection. Returns: List[Coordinate]: Coordinates where conditions are satisfied. """ - # Start with a mask of all True values - combined_mask = np.ones((self.width, self.height), dtype=bool) + # If no mask is provided, use a default mask of all True values + if mask is None: + mask = np.ones((self.width, self.height), dtype=bool) + + combined_mask = mask for prop_name, condition in conditions.items(): prop_layer = self.properties[prop_name].data @@ -879,43 +875,22 @@ def select_cells_multi_properties( # Combine with the existing mask using logical AND combined_mask = np.logical_and(combined_mask, prop_mask) - if only_neighborhood and pos is not None: - neighborhood_mask = self._get_neighborhood_mask( - pos, moore, include_center, radius - ) - combined_mask = np.logical_and(combined_mask, neighborhood_mask) - - # Extract coordinates from the combined mask selected_cells = list(zip(*np.where(combined_mask))) return selected_cells def move_agent_to_random_cell( - self, - agent: Agent, - conditions: dict, - only_neighborhood: bool = False, - moore: bool = True, - include_center: bool = False, - radius: int = 1, + self, agent: Agent, conditions: dict, mask: np.ndarray = None ) -> None: """ - Move an agent to a random cell that meets specified property conditions, optionally within a neighborhood. + Move an agent to a random cell that meets specified property conditions, optionally with a mask. If no eligible cells are found, issue a warning and keep the agent in its current position. Args: agent (Agent): The agent to move. conditions (dict): Conditions for selecting the cell. - only_neighborhood, moore, include_center, radius: Optional neighborhood parameters. + mask (np.ndarray, optional): A boolean mask to restrict the selection. """ - pos = agent.pos if only_neighborhood else None - eligible_cells = self.select_cells_multi_properties( - conditions, - only_neighborhood, - pos, - moore, - include_center, - radius, - ) + eligible_cells = self.select_cells_multi_properties(conditions, mask) if not eligible_cells: warn( f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", @@ -924,23 +899,16 @@ def move_agent_to_random_cell( ) return # Agent stays in the current position - # Randomly choose one of the eligible cells and move the agent new_pos = agent.random.choice(eligible_cells) self.move_agent(agent, new_pos) def move_agent_to_extreme_value_cell( - self, - agent: Agent, - property_name: str, - mode: str, - only_neighborhood: bool = False, - moore: bool = True, - include_center: bool = False, - radius: int = 1, + self, agent: Agent, property_name: str, mode: str, mask: np.ndarray = None ) -> None: """ Move an agent to a cell with the highest or lowest property value, - optionally within a neighborhood. + optionally with a mask. + If multiple cells have the same extreme value, one of them is chosen randomly. If no eligible cells are found, issue a warning and keep the agent in its current position. @@ -949,14 +917,11 @@ def move_agent_to_extreme_value_cell( agent (Agent): The agent to move. property_name (str): The name of the property layer. mode (str): 'highest' or 'lowest'. - only_neighborhood, moore, include_center, radius: Optional neighborhood parameters. + mask (np.ndarray, optional): A boolean mask to restrict the selection. """ prop_values = self.properties[property_name].data - if only_neighborhood: - neighborhood_mask = self._get_neighborhood_mask( - agent.pos, moore, include_center, radius - ) - masked_prop_values = np.where(neighborhood_mask, prop_values, np.nan) + if mask is not None: + masked_prop_values = np.where(mask, prop_values, np.nan) else: masked_prop_values = prop_values From f5702722c6b356c5d942260c8f4e23ea446a8237 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 08:54:31 +0100 Subject: [PATCH 15/37] _PropertyGrid: Give option to return list or mask Give the select_cells_multi_properties method an option to return a mask instead of a list of cells. list is still default. --- mesa/space.py | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 22ec5d60f06..5c0cedce7e5 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -753,6 +753,14 @@ class _PropertyGrid(_Grid): move_agent_to_extreme_value_cell(agent, property_name, mode, mask): Moves an agent to a cell with extreme value of a property, optionally with 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. """ @@ -848,8 +856,8 @@ def get_neighborhood_mask( return mask def select_cells_multi_properties( - self, conditions: dict, mask: np.ndarray = None - ) -> list[Coordinate]: + self, conditions: dict, mask: np.ndarray = None, return_list: bool = True + ) -> list[Coordinate] | np.ndarray: """ Select cells based on multiple property conditions using NumPy, optionally with a mask. @@ -858,25 +866,27 @@ def select_cells_multi_properties( callables that take a single argument (the property value) and return a boolean. mask (np.ndarray, optional): A boolean mask to restrict the selection. + return_list (bool, optional): If True, return a list of coordinates, otherwise return the mask. Returns: - List[Coordinate]: Coordinates where conditions are satisfied. + Union[list[Coordinate], np.ndarray]: Coordinates where conditions are satisfied or the combined mask. """ - # If no mask is provided, use a default mask of all True values - if mask is None: - mask = np.ones((self.width, self.height), dtype=bool) - - combined_mask = mask + # Start with a mask of all True values + combined_mask = np.ones((self.width, self.height), dtype=bool) for prop_name, condition in conditions.items(): prop_layer = self.properties[prop_name].data - # Apply the condition to the property layer prop_mask = condition(prop_layer) - # Combine with the existing mask using logical AND combined_mask = np.logical_and(combined_mask, prop_mask) - selected_cells = list(zip(*np.where(combined_mask))) - return selected_cells + if mask is not None: + combined_mask = np.logical_and(combined_mask, mask) + + if return_list: + selected_cells = list(zip(*np.where(combined_mask))) + return selected_cells + else: + return combined_mask def move_agent_to_random_cell( self, agent: Agent, conditions: dict, mask: np.ndarray = None @@ -890,7 +900,10 @@ def move_agent_to_random_cell( conditions (dict): Conditions for selecting the cell. mask (np.ndarray, optional): A boolean mask to restrict the selection. """ - eligible_cells = self.select_cells_multi_properties(conditions, mask) + eligible_cells = self.select_cells_multi_properties( + conditions, mask, return_list=True + ) + if not eligible_cells: warn( f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", From 250678b07a93ad2834d7cd03d9eb7b861b45a198 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 09:04:11 +0100 Subject: [PATCH 16/37] _PropertyGrid: Split move_agent_to_extreme_value_cell into two methods Split the move_agent_to_extreme_value_cell into two functions: - select_extreme_value_cells, which selects target cells - move_agent_to_extreme_value_cell, which moves the agent to that cell --- mesa/space.py | 55 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 5c0cedce7e5..151c62270ab 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -915,22 +915,24 @@ def move_agent_to_random_cell( new_pos = agent.random.choice(eligible_cells) self.move_agent(agent, new_pos) - def move_agent_to_extreme_value_cell( - self, agent: Agent, property_name: str, mode: str, mask: np.ndarray = None - ) -> None: + def select_extreme_value_cells( + self, + property_name: str, + mode: str, + mask: np.ndarray = None, + return_list: bool = True, + ) -> list[Coordinate] | np.ndarray: """ - Move an agent to a cell with the highest or lowest property value, - optionally with a mask. - - - If multiple cells have the same extreme value, one of them is chosen randomly. - If no eligible cells are found, issue a warning and keep the agent in its current position. + Select cells with the highest or lowest property value, optionally with a mask. Args: - agent (Agent): The agent to move. property_name (str): The name of the property layer. mode (str): 'highest' or 'lowest'. mask (np.ndarray, optional): A boolean mask to restrict the selection. + return_list (bool, optional): If True, return a list of coordinates, otherwise return an ndarray. + + Returns: + list[Coordinate] or np.ndarray: Coordinates of cells with the extreme property value. """ prop_values = self.properties[property_name].data if mask is not None: @@ -938,7 +940,6 @@ def move_agent_to_extreme_value_cell( else: masked_prop_values = prop_values - # Find coordinates of target cells directly if mode == "highest": target_cells = np.column_stack( np.where(masked_prop_values == np.nanmax(masked_prop_values)) @@ -950,7 +951,37 @@ def move_agent_to_extreme_value_cell( else: raise ValueError(f"Invalid mode {mode}. Choose from 'highest' or 'lowest'.") - # If there are multiple target cells, randomly choose one + if return_list: + return list(map(tuple, target_cells)) + else: + return target_cells + + def move_agent_to_extreme_value_cell( + self, agent: Agent, property_name: str, mode: str, mask: np.ndarray = None + ) -> None: + """ + Move an agent to a cell with the highest or lowest property value, + optionally with a mask. + + Args: + agent (Agent): The agent to move. + property_name (str): The name of the property layer. + mode (str): 'highest' or 'lowest'. + mask (np.ndarray, optional): A boolean mask to restrict the selection. + """ + target_cells = self.select_extreme_value_cells( + property_name, mode, mask, return_list=True + ) + + # If no eligible cells are found, issue a warning and keep the agent in its current position. + if not target_cells.size: + warn( + f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", + RuntimeWarning, + stacklevel=2, + ) + return # Agent stays in the current position + new_pos = tuple(agent.random.choice(target_cells)) self.move_agent(agent, new_pos) From 24102c02059e3e6bbabb64bfd3806a91c36bb268 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 09:18:14 +0100 Subject: [PATCH 17/37] _PropertyGrid: Add utility function to get empty mask --- mesa/space.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/mesa/space.py b/mesa/space.py index 151c62270ab..83b86ea92e5 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -745,6 +745,7 @@ class _PropertyGrid(_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_empty_mask(): Generates a boolean mask indicating empty cells on the grid. get_neighborhood_mask(pos, moore, include_center, radius): Generates a boolean mask of the neighborhood. select_cells_multi_properties(conditions, mask): Selects cells based on multiple property conditions, optionally with a mask. @@ -831,6 +832,25 @@ def remove_property_layer(self, property_name: str): raise ValueError(f"Property layer {property_name} does not exist.") del self.properties[property_name] + def get_empty_mask(self) -> np.ndarray: + """ + Generate a boolean mask indicating empty cells on the grid. + + Returns: + np.ndarray: A boolean mask where True represents an empty cell and False represents an occupied cell. + """ + # Initialize a mask filled with False (indicating occupied cells) + empty_mask = np.full((self.width, self.height), False, dtype=bool) + + # Convert the list of empty cell coordinates to a NumPy array + empty_cells = np.array(list(self.empties)) + + # Use advanced indexing to set empty cells to True + if empty_cells.size > 0: # Check if there are any empty cells + empty_mask[empty_cells[:, 0], empty_cells[:, 1]] = True + + return empty_mask + def get_neighborhood_mask( self, pos: Coordinate, moore: bool, include_center: bool, radius: int ) -> np.ndarray: From 6b80a2a06356acf01298f0ec52ebbe0904962592 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 09:21:12 +0100 Subject: [PATCH 18/37] _PropertyGrid: Update method docstring --- mesa/space.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 83b86ea92e5..7c77648e497 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -747,12 +747,14 @@ class _PropertyGrid(_Grid): remove_property_layer(property_name): Removes a property layer from the grid by its name. get_empty_mask(): Generates a boolean mask indicating empty cells on the grid. get_neighborhood_mask(pos, moore, include_center, radius): Generates a boolean mask of the neighborhood. - select_cells_multi_properties(conditions, mask): - Selects cells based on multiple property conditions, optionally with a mask. - move_agent_to_random_cell(agent, conditions, mask): - Moves an agent to a random cell meeting specified property conditions, optionally with a mask. - move_agent_to_extreme_value_cell(agent, property_name, mode, mask): - Moves an agent to a cell with extreme value of a property, optionally with a mask. + select_cells_multi_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. + move_agent_to_random_cell(agent, conditions, mask): Moves an agent to a random cell meeting specified property + conditions, optionally with a mask. + move_agent_to_extreme_value_cell(agent, property_name, mode, mask): Moves an agent to a cell with extreme value of + a property, optionally with 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 From 2197bd34c40a39b30e89b50a5378300d2eb8f64c Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 09:33:33 +0100 Subject: [PATCH 19/37] _PropertyGrid: Rename functions to select and move by multiple properties Hopefully these names are a little clearer --- mesa/space.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 7c77648e497..c55e1563701 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -747,11 +747,11 @@ class _PropertyGrid(_Grid): remove_property_layer(property_name): Removes a property layer from the grid by its name. get_empty_mask(): Generates a boolean mask indicating empty cells on the grid. get_neighborhood_mask(pos, moore, include_center, radius): Generates a boolean mask of the neighborhood. - select_cells_multi_properties(conditions, mask, return_list): Selects cells based on multiple property conditions, + 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. - move_agent_to_random_cell(agent, conditions, mask): Moves an agent to a random cell meeting specified property + move_agent_to_cell_by_properties(agent, conditions, mask): Moves an agent to a random cell meeting specified property conditions, optionally with a mask. move_agent_to_extreme_value_cell(agent, property_name, mode, mask): Moves an agent to a cell with extreme value of a property, optionally with a mask. @@ -877,7 +877,7 @@ def get_neighborhood_mask( mask[coords[:, 0], coords[:, 1]] = True return mask - def select_cells_multi_properties( + def select_cells_by_properties( self, conditions: dict, mask: np.ndarray = None, return_list: bool = True ) -> list[Coordinate] | np.ndarray: """ @@ -910,7 +910,7 @@ def select_cells_multi_properties( else: return combined_mask - def move_agent_to_random_cell( + def move_agent_to_cell_by_properties( self, agent: Agent, conditions: dict, mask: np.ndarray = None ) -> None: """ @@ -922,7 +922,7 @@ def move_agent_to_random_cell( conditions (dict): Conditions for selecting the cell. mask (np.ndarray, optional): A boolean mask to restrict the selection. """ - eligible_cells = self.select_cells_multi_properties( + eligible_cells = self.select_cells_by_properties( conditions, mask, return_list=True ) From 8afbc9973205c8d04a845d45f293b7b2ea894d2a Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 10:24:26 +0100 Subject: [PATCH 20/37] get_empty_mask: Use faster np.zeros np.zeros is faster than np.full --- mesa/space.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index c55e1563701..0dccff13755 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -842,7 +842,7 @@ def get_empty_mask(self) -> np.ndarray: np.ndarray: A boolean mask where True represents an empty cell and False represents an occupied cell. """ # Initialize a mask filled with False (indicating occupied cells) - empty_mask = np.full((self.width, self.height), False, dtype=bool) + empty_mask = np.zeros((self.width, self.height), dtype=bool) # Convert the list of empty cell coordinates to a NumPy array empty_cells = np.array(list(self.empties)) @@ -996,7 +996,7 @@ def move_agent_to_extreme_value_cell( ) # If no eligible cells are found, issue a warning and keep the agent in its current position. - if not target_cells.size: + if len(target_cells) == 0: warn( f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", RuntimeWarning, From b0358f9a194910c14e28317e5cd0e3c9c45d5c86 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 10:46:28 +0100 Subject: [PATCH 21/37] Optimize select_extreme_value_cells method for performance This commit introduces several optimizations to the select_extreme_value_cells method of the _PropertyGrid class. These changes are aimed at enhancing the performance, especially for larger datasets, by reducing computational complexity and leveraging efficient numpy array operations. Key Changes: - Condensed the mask application process to a single conditional statement, reducing unnecessary operations when no mask is provided. - Streamlined the calculation of extreme values (maximum or minimum) using direct numpy functions without separate branching. - Optimized the creation of the target mask by utilizing numpy's inherent functions for array comparison, thereby minimizing the computational overhead. - Improved the efficiency of converting the mask to a list of coordinates using np.argwhere and tolist(), which is more suited for numpy arrays. These enhancements ensure that the method is more efficient and performant, particularly when handling large grids or property layers. Fixes: - The method now correctly returns a mask instead of an ndarray of coordinates when return_list is set to False. This fix aligns the method's behavior with its intended functionality and improves its usability in grid operations. --- mesa/space.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 0dccff13755..5aa85d1a572 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -951,32 +951,29 @@ def select_extreme_value_cells( property_name (str): The name of the property layer. mode (str): 'highest' or 'lowest'. mask (np.ndarray, optional): A boolean mask to restrict the selection. - return_list (bool, optional): If True, return a list of coordinates, otherwise return an ndarray. + return_list (bool, optional): If True, return a list of coordinates, otherwise return a mask. Returns: - list[Coordinate] or np.ndarray: Coordinates of cells with the extreme property value. + Union[list[Coordinate], np.ndarray]: List of coordinates or a boolean mask of cells with the extreme property value. """ prop_values = self.properties[property_name].data if mask is not None: - masked_prop_values = np.where(mask, prop_values, np.nan) - else: - masked_prop_values = prop_values + prop_values = np.where(mask, prop_values, np.nan) if mode == "highest": - target_cells = np.column_stack( - np.where(masked_prop_values == np.nanmax(masked_prop_values)) - ) + extreme_value = np.nanmax(prop_values) elif mode == "lowest": - target_cells = np.column_stack( - np.where(masked_prop_values == np.nanmin(masked_prop_values)) - ) + extreme_value = np.nanmin(prop_values) else: raise ValueError(f"Invalid mode {mode}. Choose from 'highest' or 'lowest'.") + # Optimize the mask creation using numpy's inherent functions + target_mask = prop_values == extreme_value + if return_list: - return list(map(tuple, target_cells)) + return list(zip(*np.where(target_mask))) else: - return target_cells + return target_mask def move_agent_to_extreme_value_cell( self, agent: Agent, property_name: str, mode: str, mask: np.ndarray = None From eedbec72811634085319c34182b5a05fa22f250e Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 10:46:55 +0100 Subject: [PATCH 22/37] Update PropertyGrid tests --- tests/test_space.py | 162 ++++++++++++++++++++++++++++++-------------- 1 file changed, 110 insertions(+), 52 deletions(-) diff --git a/tests/test_space.py b/tests/test_space.py index 78866d5058a..472460992a6 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -567,65 +567,74 @@ def test_remove_nonexistent_property_layer(self): with self.assertRaises(ValueError): self.grid.remove_property_layer("nonexistent_layer") - # Test selecting cells - def test_select_cells_multi_properties(self): - condition = lambda x: x == 0 - selected_cells = self.grid.select_cells_multi_properties({"layer1": condition}) - self.assertEqual(len(selected_cells), 100) # All cells should be selected - - def test_select_cells_with_multiple_properties(self): - condition1 = lambda x: x == 0 - condition2 = lambda x: x == 1 - selected_cells = self.grid.select_cells_multi_properties( - {"layer1": condition1, "layer2": condition2} - ) - self.assertEqual( - len(selected_cells), 100 - ) # All cells should meet both conditions + # Test getting masks + def test_get_empty_mask(self): + empty_mask = self.grid.get_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)) - def test_select_cells_with_neighborhood(self): + empty_mask = self.grid.get_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): condition = lambda x: x == 0 - selected_cells = self.grid.select_cells_multi_properties( - {"layer1": condition}, only_neighborhood=True, pos=(5, 5), radius=1 - ) - # Expect a selection of cells around (5, 5) - expected_selection = [ - (4, 4), - (4, 5), - (4, 6), - (5, 4), - (5, 6), - (6, 4), - (6, 5), - (6, 6), - ] - self.assertCountEqual(selected_cells, expected_selection) + selected_cells = self.grid.select_cells_by_properties({"layer1": condition}) + self.assertEqual(len(selected_cells), 100) - def test_select_no_cells_due_to_conflicting_conditions(self): - condition1 = lambda x: x == 0 # All cells in layer1 meet this - condition2 = lambda x: x != 1 # No cells in layer2 meet this - selected_cells = self.grid.select_cells_multi_properties( - {"layer1": condition1, "layer2": condition2} + def test_select_cells_by_properties_return_mask(self): + condition = lambda x: x == 0 + selected_mask = self.grid.select_cells_by_properties( + {"layer1": condition}, return_list=False ) - self.assertEqual(len(selected_cells), 0) + self.assertTrue(isinstance(selected_mask, np.ndarray)) + self.assertTrue(selected_mask.all()) - # Test moving agents to cells - def test_move_agent_to_random_cell(self): + 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} - self.grid.move_agent_to_random_cell(agent, conditions) + self.grid.move_agent_to_cell_by_properties(agent, conditions) 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} # No cell meets this condition - self.grid.move_agent_to_random_cell(agent, conditions) - # Agent should not move + conditions = {"layer1": lambda x: x != 0} + self.grid.move_agent_to_cell_by_properties(agent, conditions) self.assertEqual(agent.pos, (5, 5)) - # Move to cells with the highest or lowest value in a layer + # 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_extreme_value_cells("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_extreme_value_cells( + "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)) @@ -633,19 +642,68 @@ def test_move_agent_to_extreme_value_cell(self): self.grid.move_agent_to_extreme_value_cell(agent, "layer2", "highest") self.assertEqual(agent.pos, (3, 1)) - def test_move_agent_to_extreme_value_cell_lowest(self): - agent = MockAgent(4, self.grid) + # 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.get_empty_mask() + condition = lambda x: x == 0 + selected_cells = self.grid.select_cells_by_properties( + {"layer1": condition}, mask=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) + condition = lambda x: x == 0 + selected_cells = self.grid.select_cells_by_properties( + {"layer1": condition}, mask=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.properties["layer2"].set_cell((6, 7), 0) - self.grid.move_agent_to_extreme_value_cell(agent, "layer2", "lowest") - # Agent should move to a cell with the lowest value in layer2 (which is 1 for all cells, so position should not change) - self.assertEqual(agent.pos, (6, 7)) + self.grid.place_agent( + MockAgent(2, self.grid), (4, 5) + ) # Placing another agent to create a non-empty cell + empty_mask = self.grid.get_empty_mask() + conditions = {"layer1": lambda x: x == 0} + self.grid.move_agent_to_cell_by_properties(agent, conditions, mask=empty_mask) + 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} + self.grid.move_agent_to_cell_by_properties( + agent, conditions, mask=neighborhood_mask + ) + 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 - # Edge Cases: Invalid property name or mode + # Test invalid inputs def test_invalid_property_name_in_conditions(self): condition = lambda x: x == 0 with self.assertRaises(KeyError): - self.grid.select_cells_multi_properties({"nonexistent_layer": condition}) + self.grid.select_cells_by_properties({"nonexistent_layer": condition}) def test_invalid_mode_in_move_to_extreme(self): agent = MockAgent(6, self.grid) From 0351d1490c037c80c06879ed1f370d78778d93ac Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 10:50:36 +0100 Subject: [PATCH 23/37] Update test_space.py Fix remaining TestPropertyLayer A condition always is a function, never a mask (for now). --- tests/test_space.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/test_space.py b/tests/test_space.py index 472460992a6..2ede37b71a2 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -300,15 +300,13 @@ def test_set_cells_no_condition(self): np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 2)) def test_set_cells_with_condition(self): - condition = np.full((10, 10), False) - condition[5, :] = True # Only update the 5th row + self.layer.set_cell((5, 5), 1) + condition = lambda x: x == 0 self.layer.set_cells(3, condition) - self.assertEqual(np.sum(self.layer.data[5, :] == 3), 10) - self.assertEqual(np.sum(self.layer.data != 3), 90) - - def test_set_cells_invalid_condition(self): - with self.assertRaises(ValueError): - self.layer.set_cells(4, condition=np.full((5, 5), False)) # Invalid shape + 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) # Modify Cells Test def test_modify_cell_lambda(self): From 50d960224e42cd1f753a4330f848698dc9576fab Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 6 Dec 2023 11:03:38 +0100 Subject: [PATCH 24/37] Add a test if the coordinate systems are identical Checks if the property layer and the property grid use coordinates in the same way. --- tests/test_space.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_space.py b/tests/test_space.py index 2ede37b71a2..38fe325fb61 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -709,6 +709,28 @@ def test_invalid_mode_in_move_to_extreme(self): with self.assertRaises(ValueError): self.grid.move_agent_to_extreme_value_cell(agent, "layer1", "invalid_mode") + # 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) + class TestSingleNetworkGrid(unittest.TestCase): GRAPH_SIZE = 10 From 04718e780a6e8aa83915286726db1c6c582ce1bc Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Tue, 12 Dec 2023 12:55:50 +0100 Subject: [PATCH 25/37] PropertyLayer: Improve ufunc handling and lambda vectorization This update significantly improves the PropertyLayer's efficiency and robustness, particularly in handling lambda functions and NumPy universal functions (ufuncs). It addresses a critical bug where lambda conditions were incorrectly returning single boolean values instead of boolean arrays (masks) required for grid operations. The enhancements include checks to optimize the use of ufuncs and ensure accurate application based on their argument requirements. Motivation: - The primary motivation for these changes was to fix a bug in the `set_cells` method where lambda functions used as conditions were returning single boolean values instead of boolean arrays. This behavior led to errors and inefficient grid updates. - To address this, we optimized the method to correctly handle lambda functions and vectorize them only when necessary, ensuring they return a mask as required. - Furthermore, the implementation was fine-tuned to better handle NumPy ufuncs, avoiding redundant vectorization and ensuring performance optimization. Details: - Enhanced `set_cells` to properly handle lambda conditions by vectorizing them to return a boolean array that matches the grid's shape. - Added functionality to detect and directly apply NumPy ufuncs in both `set_cells` and `modify_cells`, bypassing unnecessary vectorization. - Implemented a shape check in `set_cells` to ensure the condition results align with the grid's dimensions. - Introduced `ufunc_requires_additional_input`, a utility function that determines if a ufunc needs an extra input, enhancing the accuracy of operations in `modify_cells`. - Ensured that non-ufunc conditions and lambda functions are correctly handled with appropriate vectorization, maintaining backward compatibility and flexibility. - The changes result in a more robust and efficient PropertyLayer, capable of handling a variety of functions with improved performance. Impact: These updates resolve the lambda function bug and significantly boost the PropertyLayer's performance and reliability, especially when dealing with different types of functions. The codebase is now better equipped to handle complex grid operations efficiently and accurately. --- mesa/space.py | 56 ++++++++++++++++++++++++++-------------- tests/test_space.py | 63 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 5aa85d1a572..b99ff576cd8 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -557,6 +557,16 @@ def is_lambda_function(function): ) +def is_numpy_ufunc(function): + return isinstance(function, np.ufunc) + + +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 @@ -640,16 +650,17 @@ def set_cells(self, value, condition=None): if condition is None: np.copyto(self.data, value) # In-place update else: - # Call the condition and check if the result is a boolean array - condition_result = 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." - ) - # Conditional in-place update + if is_numpy_ufunc(condition): + # 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): @@ -684,20 +695,25 @@ def modify_cells(self, operation, value=None, condition_function=None): 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: - condition_array = np.vectorize(condition_function)(self.data) - else: - condition_array = np.ones_like(self.data, dtype=bool) # All cells + if is_numpy_ufunc(condition_function): + 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 is_lambda_function(operation): - # Lambda function case - modified_data = np.vectorize(operation)(self.data) - elif value is not None: - # NumPy ufunc case - modified_data = operation(self.data, value) + if is_numpy_ufunc(operation): + # Check if the ufunc requires an additional input + 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: - raise ValueError("Invalid operation or missing value for NumPy ufunc.") + # 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) diff --git a/tests/test_space.py b/tests/test_space.py index 38fe325fb61..c689f828775 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -308,7 +308,30 @@ def test_set_cells_with_condition(self): # Check if the sum is correct self.assertEqual(np.sum(self.layer.data), 3 * 99 + 1) - # Modify Cells Test + 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 + condition = lambda val: 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.8 + expected_max = width * height * update_probability * 1.2 + + # 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) @@ -323,7 +346,7 @@ def test_modify_cell_invalid_operation(self): with self.assertRaises(ValueError): self.layer.modify_cell((1, 1), np.add) # Missing value for ufunc - # Select Cells Test + # Modify Cells Test def test_modify_cells_lambda(self): self.layer.data = np.zeros((10, 10)) self.layer.modify_cells(lambda x: x + 2) @@ -374,6 +397,42 @@ def test_select_cells_complex_condition(self): 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): From a119cd9daa56734f6ae6ac30914de7e0f8abec8a Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 25 Dec 2023 16:30:12 +0100 Subject: [PATCH 26/37] Remove move functions from PropertGrid As can be seen in the tests, movement can be done by selecting cells (with select_cells_by_properties and select_extreme_value_cells) and by then moving to them with the new move_agent_to_one_of. --- mesa/space.py | 60 --------------------------------------------- tests/test_space.py | 25 +++++++++---------- 2 files changed, 12 insertions(+), 73 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index b99ff576cd8..66744fe8f2f 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -767,10 +767,6 @@ class _PropertyGrid(_Grid): 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. - move_agent_to_cell_by_properties(agent, conditions, mask): Moves an agent to a random cell meeting specified property - conditions, optionally with a mask. - move_agent_to_extreme_value_cell(agent, property_name, mode, mask): Moves an agent to a cell with extreme value of - a property, optionally with 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 @@ -926,33 +922,6 @@ def select_cells_by_properties( else: return combined_mask - def move_agent_to_cell_by_properties( - self, agent: Agent, conditions: dict, mask: np.ndarray = None - ) -> None: - """ - Move an agent to a random cell that meets specified property conditions, optionally with a mask. - If no eligible cells are found, issue a warning and keep the agent in its current position. - - Args: - agent (Agent): The agent to move. - conditions (dict): Conditions for selecting the cell. - mask (np.ndarray, optional): A boolean mask to restrict the selection. - """ - eligible_cells = self.select_cells_by_properties( - conditions, mask, return_list=True - ) - - if not eligible_cells: - warn( - f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", - RuntimeWarning, - stacklevel=2, - ) - return # Agent stays in the current position - - new_pos = agent.random.choice(eligible_cells) - self.move_agent(agent, new_pos) - def select_extreme_value_cells( self, property_name: str, @@ -991,35 +960,6 @@ def select_extreme_value_cells( else: return target_mask - def move_agent_to_extreme_value_cell( - self, agent: Agent, property_name: str, mode: str, mask: np.ndarray = None - ) -> None: - """ - Move an agent to a cell with the highest or lowest property value, - optionally with a mask. - - Args: - agent (Agent): The agent to move. - property_name (str): The name of the property layer. - mode (str): 'highest' or 'lowest'. - mask (np.ndarray, optional): A boolean mask to restrict the selection. - """ - target_cells = self.select_extreme_value_cells( - property_name, mode, mask, return_list=True - ) - - # If no eligible cells are found, issue a warning and keep the agent in its current position. - if len(target_cells) == 0: - warn( - f"No eligible cells found. Agent {agent.unique_id} remains in the current position.", - RuntimeWarning, - stacklevel=2, - ) - return # Agent stays in the current position - - new_pos = tuple(agent.random.choice(target_cells)) - self.move_agent(agent, new_pos) - class SingleGrid(_PropertyGrid): """Rectangular grid where each cell contains exactly at most one agent. diff --git a/tests/test_space.py b/tests/test_space.py index c689f828775..d2f7d277526 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -433,7 +433,6 @@ def test_data_type_consistency(self): 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) @@ -668,14 +667,17 @@ 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} - self.grid.move_agent_to_cell_by_properties(agent, conditions) + target_cells = self.grid.select_cells_by_properties(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} - self.grid.move_agent_to_cell_by_properties(agent, conditions) + target_cells = self.grid.select_cells_by_properties(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 @@ -696,7 +698,8 @@ 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) - self.grid.move_agent_to_extreme_value_cell(agent, "layer2", "highest") + target_cells = self.grid.select_extreme_value_cells("layer2", "highest") + self.grid.move_agent_to_one_of(agent, target_cells) self.assertEqual(agent.pos, (3, 1)) # Test using masks @@ -739,7 +742,8 @@ def test_move_agent_to_cell_by_properties_with_empty_mask(self): ) # Placing another agent to create a non-empty cell empty_mask = self.grid.get_empty_mask() conditions = {"layer1": lambda x: x == 0} - self.grid.move_agent_to_cell_by_properties(agent, conditions, mask=empty_mask) + target_cells = self.grid.select_cells_by_properties(conditions, mask=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 @@ -749,9 +753,10 @@ def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self): 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} - self.grid.move_agent_to_cell_by_properties( - agent, conditions, mask=neighborhood_mask + target_cells = self.grid.select_cells_by_properties( + conditions, mask=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 @@ -762,12 +767,6 @@ def test_invalid_property_name_in_conditions(self): with self.assertRaises(KeyError): self.grid.select_cells_by_properties({"nonexistent_layer": condition}) - def test_invalid_mode_in_move_to_extreme(self): - agent = MockAgent(6, self.grid) - self.grid.place_agent(agent, (5, 5)) - with self.assertRaises(ValueError): - self.grid.move_agent_to_extreme_value_cell(agent, "layer1", "invalid_mode") - # Test if coordinates means the same between the grid and the property layer def test_property_layer_coordinates(self): agent = MockAgent(0, self.grid) From b1e6a9b3a88f8d04cd2018e86db18e6761e0a135 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 25 Dec 2023 16:31:03 +0100 Subject: [PATCH 27/37] Black formatting, ruff fixes --- mesa/space.py | 13 ++++++++++--- tests/test_space.py | 30 +++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 66744fe8f2f..22f2133cc4d 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -658,8 +658,13 @@ def set_cells(self, value, condition=None): 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.") + 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) @@ -695,7 +700,9 @@ def modify_cells(self, operation, value=None, condition_function=None): 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) + condition_array = np.ones_like( + self.data, dtype=bool + ) # Default condition (all cells) if condition_function is not None: if is_numpy_ufunc(condition_function): condition_array = condition_function(self.data) diff --git a/tests/test_space.py b/tests/test_space.py index d2f7d277526..59d4808e368 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -301,7 +301,10 @@ def test_set_cells_no_condition(self): def test_set_cells_with_condition(self): self.layer.set_cell((5, 5), 1) - condition = lambda x: x == 0 + + 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) @@ -313,7 +316,8 @@ def test_set_cells_with_random_condition(self): update_probability = 0.5 # Define a condition with a random part - condition = lambda val: np.random.rand() < update_probability + def condition(val): + return np.random.rand() < update_probability # Apply set_cells self.layer.set_cells(True, condition) @@ -651,12 +655,16 @@ def test_get_neighborhood_mask(self): # Test selecting and moving to cells based on multiple conditions def test_select_cells_by_properties(self): - condition = lambda x: x == 0 + def condition(x): + return x == 0 + selected_cells = self.grid.select_cells_by_properties({"layer1": condition}) self.assertEqual(len(selected_cells), 100) def test_select_cells_by_properties_return_mask(self): - condition = lambda x: x == 0 + def condition(x): + return x == 0 + selected_mask = self.grid.select_cells_by_properties( {"layer1": condition}, return_list=False ) @@ -708,7 +716,10 @@ def test_select_cells_by_properties_with_empty_mask(self): MockAgent(0, self.grid), (5, 5) ) # Placing an agent to ensure some cells are not empty empty_mask = self.grid.get_empty_mask() - condition = lambda x: x == 0 + + def condition(x): + return x == 0 + selected_cells = self.grid.select_cells_by_properties( {"layer1": condition}, mask=empty_mask ) @@ -718,7 +729,10 @@ def test_select_cells_by_properties_with_empty_mask(self): def test_select_cells_by_properties_with_neighborhood_mask(self): neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) - condition = lambda x: x == 0 + + def condition(x): + return x == 0 + selected_cells = self.grid.select_cells_by_properties( {"layer1": condition}, mask=neighborhood_mask ) @@ -763,7 +777,9 @@ def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self): # Test invalid inputs def test_invalid_property_name_in_conditions(self): - condition = lambda x: x == 0 + def condition(x): + return x == 0 + with self.assertRaises(KeyError): self.grid.select_cells_by_properties({"nonexistent_layer": condition}) From 3d752a45c6a98cc2c1f72b78d3e98f6cb4718b2e Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Fri, 29 Dec 2023 14:27:02 +0100 Subject: [PATCH 28/37] _Property_grid: Implement single select_cells method Implement a single select_cells method that incorperates the functionality of both select_cells_by_properties and select_extreme_value_cells. It now also allows multiple extreme values and multiple masks. Further, it adds a boolean to only include empty cells. --- mesa/space.py | 102 +++++++++++++++++++++----------------------- tests/test_space.py | 68 ++++++++++++++++++++--------- 2 files changed, 97 insertions(+), 73 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 22f2133cc4d..618c7b7ce89 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -896,77 +896,73 @@ def get_neighborhood_mask( mask[coords[:, 0], coords[:, 1]] = True return mask - def select_cells_by_properties( - self, conditions: dict, mask: np.ndarray = None, return_list: bool = True + 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 multiple property conditions using NumPy, optionally with a mask. + 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 take a single argument (the property value) - and return a boolean. - mask (np.ndarray, optional): A boolean mask to restrict the selection. - return_list (bool, optional): If True, return a list of coordinates, otherwise return the mask. + 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. """ - # Start with a mask of all True values + # Initialize the combined mask combined_mask = np.ones((self.width, self.height), dtype=bool) - 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 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: + empty_mask = self.get_empty_mask() + combined_mask = np.logical_and(combined_mask, 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 + if mode == "highest": + target_value = np.nanmax(prop_values) + elif mode == "lowest": + target_value = np.nanmin(prop_values) + else: + raise ValueError( + f"Invalid mode {mode}. Choose from 'highest' or 'lowest'." + ) - if mask is not None: - combined_mask = np.logical_and(combined_mask, mask) + 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 - def select_extreme_value_cells( - self, - property_name: str, - mode: str, - mask: np.ndarray = None, - return_list: bool = True, - ) -> list[Coordinate] | np.ndarray: - """ - Select cells with the highest or lowest property value, optionally with a mask. - - Args: - property_name (str): The name of the property layer. - mode (str): 'highest' or 'lowest'. - mask (np.ndarray, optional): A boolean mask to restrict the selection. - return_list (bool, optional): If True, return a list of coordinates, otherwise return a mask. - - Returns: - Union[list[Coordinate], np.ndarray]: List of coordinates or a boolean mask of cells with the extreme property value. - """ - prop_values = self.properties[property_name].data - if mask is not None: - prop_values = np.where(mask, prop_values, np.nan) - - if mode == "highest": - extreme_value = np.nanmax(prop_values) - elif mode == "lowest": - extreme_value = np.nanmin(prop_values) - else: - raise ValueError(f"Invalid mode {mode}. Choose from 'highest' or 'lowest'.") - - # Optimize the mask creation using numpy's inherent functions - target_mask = prop_values == extreme_value - - if return_list: - return list(zip(*np.where(target_mask))) - else: - return target_mask - class SingleGrid(_PropertyGrid): """Rectangular grid where each cell contains exactly at most one agent. diff --git a/tests/test_space.py b/tests/test_space.py index 59d4808e368..f9739566e81 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -658,16 +658,14 @@ def test_select_cells_by_properties(self): def condition(x): return x == 0 - selected_cells = self.grid.select_cells_by_properties({"layer1": condition}) + 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_by_properties( - {"layer1": condition}, return_list=False - ) + selected_mask = self.grid.select_cells({"layer1": condition}, return_list=False) self.assertTrue(isinstance(selected_mask, np.ndarray)) self.assertTrue(selected_mask.all()) @@ -675,7 +673,7 @@ 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_by_properties(conditions) + 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)) @@ -684,20 +682,20 @@ 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_by_properties(conditions) + 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_extreme_value_cells("layer2", "highest") + 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_extreme_value_cells( - "layer2", "highest", return_list=False + 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]) @@ -706,7 +704,7 @@ 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_extreme_value_cells("layer2", "highest") + 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)) @@ -720,9 +718,7 @@ def test_select_cells_by_properties_with_empty_mask(self): def condition(x): return x == 0 - selected_cells = self.grid.select_cells_by_properties( - {"layer1": condition}, mask=empty_mask - ) + 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 @@ -733,8 +729,8 @@ def test_select_cells_by_properties_with_neighborhood_mask(self): def condition(x): return x == 0 - selected_cells = self.grid.select_cells_by_properties( - {"layer1": condition}, mask=neighborhood_mask + selected_cells = self.grid.select_cells( + {"layer1": condition}, masks=neighborhood_mask ) expected_selection = [ (4, 4), @@ -756,7 +752,7 @@ def test_move_agent_to_cell_by_properties_with_empty_mask(self): ) # Placing another agent to create a non-empty cell empty_mask = self.grid.get_empty_mask() conditions = {"layer1": lambda x: x == 0} - target_cells = self.grid.select_cells_by_properties(conditions, mask=empty_mask) + 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) @@ -767,9 +763,7 @@ def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self): 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_by_properties( - conditions, mask=neighborhood_mask - ) + 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)] @@ -781,7 +775,7 @@ def condition(x): return x == 0 with self.assertRaises(KeyError): - self.grid.select_cells_by_properties({"nonexistent_layer": condition}) + 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): @@ -805,6 +799,40 @@ def test_property_layer_coordinates(self): 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 From 26d2c801cd149ad3fd35f87d7d8254c47aa4eec8 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Fri, 29 Dec 2023 15:45:11 +0100 Subject: [PATCH 29/37] _PropertyGrid: Keep empty_mask property, fix extreme_value bug Keeps a property empty_mask, that's updated together with the empties property. But because it's a mask it's incredibly fast for other mask-based operations. Also fix a bug in the extreme_value part of the select() method: It now only considers values from the mask. --- mesa/space.py | 47 ++++++++++++++++++++++----------------------- tests/test_space.py | 22 +++++++++++++++------ 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index 618c7b7ce89..7128a8aed9b 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -764,11 +764,11 @@ class _PropertyGrid(_Grid): 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_empty_mask(): Generates a boolean mask indicating empty cells on the grid. 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. @@ -810,6 +810,10 @@ def __init__( 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) + print(self._empty_mask) + # Handle both single PropertyLayer instance and list of PropertyLayer instances if property_layers: # If a single PropertyLayer is passed, convert it to a list @@ -819,6 +823,13 @@ def __init__( 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): """ @@ -853,25 +864,6 @@ def remove_property_layer(self, property_name: str): raise ValueError(f"Property layer {property_name} does not exist.") del self.properties[property_name] - def get_empty_mask(self) -> np.ndarray: - """ - Generate a boolean mask indicating empty cells on the grid. - - Returns: - np.ndarray: A boolean mask where True represents an empty cell and False represents an occupied cell. - """ - # Initialize a mask filled with False (indicating occupied cells) - empty_mask = np.zeros((self.width, self.height), dtype=bool) - - # Convert the list of empty cell coordinates to a NumPy array - empty_cells = np.array(list(self.empties)) - - # Use advanced indexing to set empty cells to True - if empty_cells.size > 0: # Check if there are any empty cells - empty_mask[empty_cells[:, 0], empty_cells[:, 1]] = True - - return empty_mask - def get_neighborhood_mask( self, pos: Coordinate, moore: bool, include_center: bool, radius: int ) -> np.ndarray: @@ -930,8 +922,7 @@ def select_cells( # Apply the empty mask if only_empty is True if only_empty: - empty_mask = self.get_empty_mask() - combined_mask = np.logical_and(combined_mask, empty_mask) + combined_mask = np.logical_and(combined_mask, self.empty_mask) # Apply conditions if conditions: @@ -944,10 +935,14 @@ def select_cells( 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 = np.nanmax(prop_values) + target_value = masked_values.max() elif mode == "lowest": - target_value = np.nanmin(prop_values) + target_value = masked_values.min() else: raise ValueError( f"Invalid mode {mode}. Choose from 'highest' or 'lowest'." @@ -992,6 +987,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") @@ -1004,6 +1000,7 @@ 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 @@ -1039,6 +1036,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.""" @@ -1047,6 +1045,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 f9739566e81..b7524b6d916 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -329,8 +329,8 @@ def condition(val): height = self.layer.height # Calculate expected range (with some tolerance for randomness) - expected_min = width * height * update_probability * 0.8 - expected_max = width * height * update_probability * 1.2 + 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 @@ -471,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] @@ -629,14 +639,14 @@ def test_remove_nonexistent_property_layer(self): # Test getting masks def test_get_empty_mask(self): - empty_mask = self.grid.get_empty_mask() + 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.get_empty_mask() + empty_mask = self.grid.empty_mask expected_mask = np.ones((10, 10), dtype=bool) expected_mask[4, 6] = False @@ -713,7 +723,7 @@ 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.get_empty_mask() + empty_mask = self.grid.empty_mask def condition(x): return x == 0 @@ -750,7 +760,7 @@ def test_move_agent_to_cell_by_properties_with_empty_mask(self): self.grid.place_agent( MockAgent(2, self.grid), (4, 5) ) # Placing another agent to create a non-empty cell - empty_mask = self.grid.get_empty_mask() + 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) From 598bdea90be057b2f7543859d109705df1caed4e Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Fri, 29 Dec 2023 16:10:05 +0100 Subject: [PATCH 30/37] space: Mark PropertyLayer as experimental (add warning) --- mesa/space.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mesa/space.py b/mesa/space.py index 7128a8aed9b..d783e76faf5 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -24,6 +24,7 @@ import inspect import itertools import math +import warnings from numbers import Real from typing import ( Any, @@ -587,6 +588,8 @@ class PropertyLayer: 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.float32 ): @@ -632,6 +635,15 @@ def __init__( 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. From 6e12379dc1194522be515c7373974a2d79016b7f Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 6 Jan 2024 05:44:55 -0500 Subject: [PATCH 31/37] Rename is_lambda_function -> is_single_argument_function --- mesa/space.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index d783e76faf5..e53b34bf9bf 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -550,8 +550,8 @@ def exists_empty_cells(self) -> bool: return len(self.empties) > 0 -def is_lambda_function(function): - """Check if a function is a lambda function.""" +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 @@ -693,7 +693,7 @@ def modify_cell(self, position: Coordinate, operation, value=None): current_value = self.data[position] # Determine if the operation is a lambda function or a NumPy ufunc - if is_lambda_function(operation): + if is_single_argument_function(operation): # Lambda function case self.data[position] = operation(current_value) elif value is not None: From 627eccb59777b43bd36e7f81c04a5b290f1a438e Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 6 Jan 2024 05:55:17 -0500 Subject: [PATCH 32/37] Remove is_numpy_ufunc function abstraction --- mesa/space.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index e53b34bf9bf..b616080f8f0 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -558,10 +558,6 @@ def is_single_argument_function(function): ) -def is_numpy_ufunc(function): - return isinstance(function, np.ufunc) - - 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 @@ -662,7 +658,7 @@ def set_cells(self, value, condition=None): if condition is None: np.copyto(self.data, value) # In-place update else: - if is_numpy_ufunc(condition): + if isinstance(condition, np.ufunc): # Directly apply NumPy ufunc condition_result = condition(self.data) else: @@ -716,14 +712,14 @@ def modify_cells(self, operation, value=None, condition_function=None): self.data, dtype=bool ) # Default condition (all cells) if condition_function is not None: - if is_numpy_ufunc(condition_function): + 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 is_numpy_ufunc(operation): + if isinstance(operation, np.ufunc): # Check if the ufunc requires an additional input if ufunc_requires_additional_input(operation): if value is None: From f5f5065387715c1bd75de7ced36835e4a692cacd Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 6 Jan 2024 06:19:14 -0500 Subject: [PATCH 33/37] fix: Handle all cases for modified_data in modify_cells --- mesa/space.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mesa/space.py b/mesa/space.py index b616080f8f0..768502fb628 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -720,11 +720,12 @@ def modify_cells(self, operation, value=None, condition_function=None): # Check if the operation is a lambda function or a NumPy ufunc if isinstance(operation, np.ufunc): - # Check if the ufunc requires an additional input 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) From f96a9ef011f70b753034a27ff7d9530c2d6e4091 Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 6 Jan 2024 06:21:17 -0500 Subject: [PATCH 34/37] Remove stray print function --- mesa/space.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mesa/space.py b/mesa/space.py index 768502fb628..ffd82f118ba 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -821,7 +821,6 @@ def __init__( # Initialize an empty mask as a boolean NumPy array self._empty_mask = np.ones((self.width, self.height), dtype=bool) - print(self._empty_mask) # Handle both single PropertyLayer instance and list of PropertyLayer instances if property_layers: From 3e7bd7b6c016e6c9957969f00fd74d8c46f371c5 Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 6 Jan 2024 06:24:45 -0500 Subject: [PATCH 35/37] Remove unused PyLint directive --- mesa/space.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index ffd82f118ba..c449f8b98d2 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -12,9 +12,6 @@ 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 c71c9f54722c0a24bbbfb37248b7209c283efd44 Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 6 Jan 2024 06:26:41 -0500 Subject: [PATCH 36/37] PropertyLayer: Default to float64 (consistent with Python float) --- mesa/space.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mesa/space.py b/mesa/space.py index c449f8b98d2..832e81c569c 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -584,7 +584,7 @@ class PropertyLayer: agentset_experimental_warning_given = False def __init__( - self, name: str, width: int, height: int, default_value, dtype=np.float32 + self, name: str, width: int, height: int, default_value, dtype=np.float64 ): """ Initializes a new PropertyLayer instance. @@ -595,7 +595,7 @@ def __init__( 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.float32. + 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. @@ -603,7 +603,7 @@ def __init__( 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.int16 or np.float32). Using NumPy data types is recommended (except for bool) for better control + (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. """ From 363fb0b6469808581c9e37b10934eba6e67bb3af Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 6 Jan 2024 06:28:15 -0500 Subject: [PATCH 37/37] set_cells doc: Indicate that condition is optional --- mesa/space.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesa/space.py b/mesa/space.py index 832e81c569c..d1dc2ada339 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -574,7 +574,7 @@ class PropertyLayer: Methods: set_cell(position, value): Sets the value of a single cell. - set_cells(value, condition): Sets the values of multiple cells, optionally based on a condition. + 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.