Mesa 3.0.2__py3-none-any.whl → 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Mesa might be problematic. Click here for more details.

Files changed (50) hide show
  1. mesa/__init__.py +4 -6
  2. mesa/agent.py +48 -25
  3. mesa/batchrunner.py +14 -1
  4. mesa/datacollection.py +1 -6
  5. mesa/examples/__init__.py +2 -2
  6. mesa/examples/advanced/epstein_civil_violence/app.py +5 -0
  7. mesa/examples/advanced/pd_grid/app.py +5 -0
  8. mesa/examples/advanced/sugarscape_g1mt/app.py +7 -2
  9. mesa/examples/basic/boid_flockers/app.py +5 -0
  10. mesa/examples/basic/boltzmann_wealth_model/app.py +8 -5
  11. mesa/examples/basic/boltzmann_wealth_model/st_app.py +1 -1
  12. mesa/examples/basic/conways_game_of_life/app.py +5 -0
  13. mesa/examples/basic/conways_game_of_life/st_app.py +2 -2
  14. mesa/examples/basic/schelling/app.py +5 -0
  15. mesa/examples/basic/virus_on_network/app.py +5 -0
  16. mesa/experimental/__init__.py +17 -10
  17. mesa/experimental/cell_space/__init__.py +19 -7
  18. mesa/experimental/cell_space/cell.py +22 -37
  19. mesa/experimental/cell_space/cell_agent.py +12 -1
  20. mesa/experimental/cell_space/cell_collection.py +18 -3
  21. mesa/experimental/cell_space/discrete_space.py +15 -64
  22. mesa/experimental/cell_space/grid.py +74 -4
  23. mesa/experimental/cell_space/network.py +13 -1
  24. mesa/experimental/cell_space/property_layer.py +444 -0
  25. mesa/experimental/cell_space/voronoi.py +13 -1
  26. mesa/experimental/devs/__init__.py +20 -2
  27. mesa/experimental/devs/eventlist.py +19 -1
  28. mesa/experimental/devs/simulator.py +24 -8
  29. mesa/experimental/mesa_signals/__init__.py +23 -0
  30. mesa/experimental/mesa_signals/mesa_signal.py +485 -0
  31. mesa/experimental/mesa_signals/observable_collections.py +133 -0
  32. mesa/experimental/mesa_signals/signals_util.py +52 -0
  33. mesa/mesa_logging.py +190 -0
  34. mesa/model.py +17 -74
  35. mesa/visualization/__init__.py +2 -2
  36. mesa/visualization/mpl_space_drawing.py +2 -2
  37. mesa/visualization/solara_viz.py +23 -5
  38. {mesa-3.0.2.dist-info → mesa-3.1.0.dist-info}/METADATA +3 -4
  39. {mesa-3.0.2.dist-info → mesa-3.1.0.dist-info}/RECORD +43 -44
  40. {mesa-3.0.2.dist-info → mesa-3.1.0.dist-info}/WHEEL +1 -1
  41. mesa/experimental/UserParam.py +0 -67
  42. mesa/experimental/components/altair.py +0 -81
  43. mesa/experimental/components/matplotlib.py +0 -242
  44. mesa/experimental/devs/examples/epstein_civil_violence.py +0 -305
  45. mesa/experimental/devs/examples/wolf_sheep.py +0 -250
  46. mesa/experimental/solara_viz.py +0 -453
  47. mesa/time.py +0 -391
  48. {mesa-3.0.2.dist-info → mesa-3.1.0.dist-info}/entry_points.txt +0 -0
  49. {mesa-3.0.2.dist-info → mesa-3.1.0.dist-info}/licenses/LICENSE +0 -0
  50. {mesa-3.0.2.dist-info → mesa-3.1.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,17 +1,28 @@
1
- """DiscreteSpace base class."""
1
+ """Base class for building cell-based spatial environments.
2
+
3
+ DiscreteSpace provides the core functionality needed by all cell-based spaces:
4
+ - Cell creation and tracking
5
+ - Agent-cell relationship management
6
+ - Property layer support
7
+ - Random selection capabilities
8
+ - Capacity management
9
+
10
+ This serves as the foundation for specific space implementations like grids
11
+ and networks, ensuring consistent behavior and shared functionality across
12
+ different space types. All concrete cell space implementations (grids, networks, etc.)
13
+ inherit from this class.
14
+ """
2
15
 
3
16
  from __future__ import annotations
4
17
 
5
18
  import warnings
6
- from collections.abc import Callable
7
19
  from functools import cached_property
8
20
  from random import Random
9
- from typing import Any, Generic, TypeVar
21
+ from typing import Generic, TypeVar
10
22
 
11
23
  from mesa.agent import AgentSet
12
24
  from mesa.experimental.cell_space.cell import Cell
13
25
  from mesa.experimental.cell_space.cell_collection import CellCollection
14
- from mesa.space import PropertyLayer
15
26
 
16
27
  T = TypeVar("T", bound=Cell)
17
28
 
@@ -61,8 +72,6 @@ class DiscreteSpace(Generic[T]):
61
72
  self.cell_klass = cell_klass
62
73
 
63
74
  self._empties: dict[tuple[int, ...], None] = {}
64
- self._empties_initialized = False
65
- self.property_layers: dict[str, PropertyLayer] = {}
66
75
 
67
76
  @property
68
77
  def cutoff_empties(self): # noqa
@@ -98,64 +107,6 @@ class DiscreteSpace(Generic[T]):
98
107
  """Select random empty cell."""
99
108
  return self.random.choice(list(self.empties))
100
109
 
101
- # PropertyLayer methods
102
- def add_property_layer(
103
- self, property_layer: PropertyLayer, add_to_cells: bool = True
104
- ):
105
- """Add a property layer to the grid.
106
-
107
- Args:
108
- property_layer: the property layer to add
109
- add_to_cells: whether to add the property layer to all cells (default: True)
110
- """
111
- if property_layer.name in self.property_layers:
112
- raise ValueError(f"Property layer {property_layer.name} already exists.")
113
- self.property_layers[property_layer.name] = property_layer
114
- if add_to_cells:
115
- for cell in self._cells.values():
116
- cell._mesa_property_layers[property_layer.name] = property_layer
117
-
118
- def remove_property_layer(self, property_name: str, remove_from_cells: bool = True):
119
- """Remove a property layer from the grid.
120
-
121
- Args:
122
- property_name: the name of the property layer to remove
123
- remove_from_cells: whether to remove the property layer from all cells (default: True)
124
- """
125
- del self.property_layers[property_name]
126
- if remove_from_cells:
127
- for cell in self._cells.values():
128
- del cell._mesa_property_layers[property_name]
129
-
130
- def set_property(
131
- self, property_name: str, value, condition: Callable[[T], bool] | None = None
132
- ):
133
- """Set the value of a property for all cells in the grid.
134
-
135
- Args:
136
- property_name: the name of the property to set
137
- value: the value to set
138
- condition: a function that takes a cell and returns a boolean
139
- """
140
- self.property_layers[property_name].set_cells(value, condition)
141
-
142
- def modify_properties(
143
- self,
144
- property_name: str,
145
- operation: Callable,
146
- value: Any = None,
147
- condition: Callable[[T], bool] | None = None,
148
- ):
149
- """Modify the values of a specific property for all cells in the grid.
150
-
151
- Args:
152
- property_name: the name of the property to modify
153
- operation: the operation to perform
154
- value: the value to use in the operation
155
- condition: a function that takes a cell and returns a boolean (used to filter cells)
156
- """
157
- self.property_layers[property_name].modify_cells(operation, value, condition)
158
-
159
110
  def __setstate__(self, state):
160
111
  """Set the state of the discrete space and rebuild the connections."""
161
112
  self.__dict__ = state
@@ -1,18 +1,62 @@
1
- """Various Grid Spaces."""
1
+ """Grid-based cell space implementations with different connection patterns.
2
+
3
+ Provides several grid types for organizing cells:
4
+ - OrthogonalMooreGrid: 8 neighbors in 2D, (3^n)-1 in nD
5
+ - OrthogonalVonNeumannGrid: 4 neighbors in 2D, 2n in nD
6
+ - HexGrid: 6 neighbors in hexagonal pattern (2D only)
7
+
8
+ Each grid type supports optional wrapping (torus) and cell capacity limits.
9
+ Choose based on how movement and connectivity should work in your model -
10
+ Moore for unrestricted movement, Von Neumann for orthogonal-only movement,
11
+ or Hex for more uniform distances.
12
+ """
2
13
 
3
14
  from __future__ import annotations
4
15
 
16
+ import copyreg
5
17
  from collections.abc import Sequence
6
18
  from itertools import product
7
19
  from random import Random
8
- from typing import Generic, TypeVar
20
+ from typing import Any, Generic, TypeVar
9
21
 
10
22
  from mesa.experimental.cell_space import Cell, DiscreteSpace
23
+ from mesa.experimental.cell_space.property_layer import (
24
+ HasPropertyLayers,
25
+ PropertyDescriptor,
26
+ )
11
27
 
12
28
  T = TypeVar("T", bound=Cell)
13
29
 
14
30
 
15
- class Grid(DiscreteSpace[T], Generic[T]):
31
+ def pickle_gridcell(obj):
32
+ """Helper function for pickling GridCell instances."""
33
+ # we have the base class and the state via __getstate__
34
+ args = obj.__class__.__bases__[0], obj.__getstate__()
35
+ return unpickle_gridcell, args
36
+
37
+
38
+ def unpickle_gridcell(parent, fields):
39
+ """Helper function for unpickling GridCell instances."""
40
+ # since the class is dynamically created, we recreate it here
41
+ cell_klass = type(
42
+ "GridCell",
43
+ (parent,),
44
+ {"_mesa_properties": set()},
45
+ )
46
+ instance = cell_klass(
47
+ (0, 0)
48
+ ) # we use a default coordinate and overwrite it with the correct value next
49
+
50
+ # __gestate__ returns a tuple with dict and slots, but slots contains the dict so we can just use the
51
+ # second item only
52
+ for k, v in fields[1].items():
53
+ if k != "__dict__":
54
+ setattr(instance, k, v)
55
+
56
+ return instance
57
+
58
+
59
+ class Grid(DiscreteSpace[T], Generic[T], HasPropertyLayers):
16
60
  """Base class for all grid classes.
17
61
 
18
62
  Attributes:
@@ -60,14 +104,23 @@ class Grid(DiscreteSpace[T], Generic[T]):
60
104
  self._try_random = True
61
105
  self._ndims = len(dimensions)
62
106
  self._validate_parameters()
107
+ self.cell_klass = type(
108
+ "GridCell",
109
+ (self.cell_klass,),
110
+ {"_mesa_properties": set()},
111
+ )
112
+
113
+ # we register the pickle_gridcell helper function
114
+ copyreg.pickle(self.cell_klass, pickle_gridcell)
63
115
 
64
116
  coordinates = product(*(range(dim) for dim in self.dimensions))
65
117
 
66
118
  self._cells = {
67
- coord: cell_klass(coord, capacity, random=self.random)
119
+ coord: self.cell_klass(coord, capacity, random=self.random)
68
120
  for coord in coordinates
69
121
  }
70
122
  self._connect_cells()
123
+ self.create_property_layer("empty", default_value=True, dtype=bool)
71
124
 
72
125
  def _connect_cells(self) -> None:
73
126
  if self._ndims == 2:
@@ -126,6 +179,23 @@ class Grid(DiscreteSpace[T], Generic[T]):
126
179
  if 0 <= ni < height and 0 <= nj < width:
127
180
  cell.connect(self._cells[ni, nj], (di, dj))
128
181
 
182
+ def __getstate__(self) -> dict[str, Any]:
183
+ """Custom __getstate__ for handling dynamic GridCell class and PropertyDescriptors."""
184
+ state = super().__getstate__()
185
+ state = {k: v for k, v in state.items() if k != "cell_klass"}
186
+ return state
187
+
188
+ def __setstate__(self, state: dict[str, Any]) -> None:
189
+ """Custom __setstate__ for handling dynamic GridCell class and PropertyDescriptors."""
190
+ self.__dict__ = state
191
+ self._connect_cells() # using super fails for this for some reason, so we repeat ourselves
192
+
193
+ self.cell_klass = type(
194
+ self._cells[(0, 0)]
195
+ ) # the __reduce__ function handles this for us nicely
196
+ for layer in self._mesa_property_layers.values():
197
+ setattr(self.cell_klass, layer.name, PropertyDescriptor(layer))
198
+
129
199
 
130
200
  class OrthogonalMooreGrid(Grid[T]):
131
201
  """Grid where cells are connected to their 8 neighbors.
@@ -1,4 +1,16 @@
1
- """A Network grid."""
1
+ """Network-based cell space using arbitrary connection patterns.
2
+
3
+ Creates spaces where cells connect based on network relationships rather than
4
+ spatial proximity. Built on NetworkX graphs, this enables:
5
+ - Arbitrary connectivity patterns between cells
6
+ - Graph-based neighborhood definitions
7
+ - Logical rather than physical distances
8
+ - Dynamic connectivity changes
9
+ - Integration with NetworkX's graph algorithms
10
+
11
+ Useful for modeling systems like social networks, transportation systems,
12
+ or any environment where connectivity matters more than physical location.
13
+ """
2
14
 
3
15
  from random import Random
4
16
  from typing import Any
@@ -0,0 +1,444 @@
1
+ """Efficient storage and manipulation of cell properties across spaces.
2
+
3
+ PropertyLayers provide a way to associate properties with cells in a space efficiently.
4
+ The module includes:
5
+ - PropertyLayer class for managing grid-wide properties
6
+ - Property access descriptors for cells
7
+ - Batch operations for property modification
8
+ - Property-based cell selection
9
+ - Integration with numpy for efficient operations
10
+
11
+ This system separates property storage from cells themselves, enabling
12
+ fast bulk operations and sophisticated property-based behaviors while
13
+ maintaining an intuitive interface through cell attributes. Properties
14
+ can represent environmental factors, cell states, or any other grid-wide
15
+ attributes.
16
+ """
17
+
18
+ import warnings
19
+ from collections.abc import Callable, Sequence
20
+ from typing import Any, TypeVar
21
+
22
+ import numpy as np
23
+
24
+ from mesa.experimental.cell_space import Cell
25
+
26
+ Coordinate = Sequence[int]
27
+ T = TypeVar("T", bound=Cell)
28
+
29
+
30
+ class PropertyLayer:
31
+ """A class representing a layer of properties in a two-dimensional grid.
32
+
33
+ Each cell in the grid can store a value of a specified data type.
34
+
35
+ Attributes:
36
+ name: The name of the property layer.
37
+ dimensions: The width of the grid (number of columns).
38
+ data: A NumPy array representing the grid data.
39
+
40
+ """
41
+
42
+ # Fixme
43
+ # can't we simplify this a lot
44
+ # in essence, this is just a numpy array with a name and fixed dimensions
45
+ # all other functionality seems redundant to me?
46
+
47
+ @property
48
+ def data(self): # noqa: D102
49
+ return self._mesa_data
50
+
51
+ @data.setter
52
+ def data(self, value):
53
+ self.set_cells(value)
54
+
55
+ propertylayer_experimental_warning_given = False
56
+
57
+ def __init__(
58
+ self, name: str, dimensions: Sequence[int], default_value=0.0, dtype=float
59
+ ):
60
+ """Initializes a new PropertyLayer instance.
61
+
62
+ Args:
63
+ name: The name of the property layer.
64
+ dimensions: the dimensions of the property layer.
65
+ default_value: The default value to initialize each cell in the grid. Should ideally
66
+ be of the same type as specified by the dtype parameter.
67
+ dtype (data-type, optional): The desired data-type for the grid's elements. Default is float.
68
+
69
+ Notes:
70
+ A UserWarning is raised if the default_value is not of a type compatible with dtype.
71
+ The dtype parameter can accept both Python data types (like bool, int or float) and NumPy data types
72
+ (like np.int64 or np.float64).
73
+ """
74
+ self.name = name
75
+ self.dimensions = dimensions
76
+
77
+ # Check if the dtype is suitable for the data
78
+ if not isinstance(default_value, dtype):
79
+ warnings.warn(
80
+ f"Default value {default_value} ({type(default_value).__name__}) might not be best suitable with dtype={dtype.__name__}.",
81
+ UserWarning,
82
+ stacklevel=2,
83
+ )
84
+
85
+ # fixme why not initialize with empty?
86
+ self._mesa_data = np.full(self.dimensions, default_value, dtype=dtype)
87
+
88
+ if not self.__class__.propertylayer_experimental_warning_given:
89
+ warnings.warn(
90
+ "The property layer functionality and associated classes are experimental. It may be changed or removed in any and all future releases, including patch releases.\n"
91
+ "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",
92
+ FutureWarning,
93
+ stacklevel=2,
94
+ )
95
+ self.__class__.propertylayer_experimental_warning_given = True
96
+
97
+ @classmethod
98
+ def from_data(cls, name: str, data: np.ndarray):
99
+ """Create a property layer from a NumPy array.
100
+
101
+ Args:
102
+ name: The name of the property layer.
103
+ data: A NumPy array representing the grid data.
104
+
105
+ """
106
+ layer = cls(
107
+ name,
108
+ data.shape,
109
+ default_value=data[*[0 for _ in range(len(data.shape))]],
110
+ dtype=data.dtype.type,
111
+ )
112
+ layer.set_cells(data)
113
+ return layer
114
+
115
+ def set_cells(self, value, condition: Callable | None = None):
116
+ """Perform a batch update either on the entire grid or conditionally, in-place.
117
+
118
+ Args:
119
+ value: The value to be used for the update.
120
+ condition: (Optional) A callable that returns a boolean array when applied to the data.
121
+ """
122
+ if condition is None:
123
+ np.copyto(self._mesa_data, value) # In-place update
124
+ else:
125
+ vectorized_condition = np.vectorize(condition)
126
+ condition_result = vectorized_condition(self._mesa_data)
127
+ np.copyto(self._mesa_data, value, where=condition_result)
128
+
129
+ def modify_cells(
130
+ self,
131
+ operation: Callable,
132
+ value=None,
133
+ condition: Callable | None = None,
134
+ ):
135
+ """Modify cells using an operation, which can be a lambda function or a NumPy ufunc.
136
+
137
+ If a NumPy ufunc is used, an additional value should be provided.
138
+
139
+ Args:
140
+ operation: A function to apply. Can be a lambda function or a NumPy ufunc.
141
+ value: The value to be used if the operation is a NumPy ufunc. Ignored for lambda functions.
142
+ condition: (Optional) A callable that returns a boolean array when applied to the data.
143
+ """
144
+ condition_array = np.ones_like(
145
+ self._mesa_data, dtype=bool
146
+ ) # Default condition (all cells)
147
+ if condition is not None:
148
+ vectorized_condition = np.vectorize(condition)
149
+ condition_array = vectorized_condition(self._mesa_data)
150
+
151
+ # Check if the operation is a lambda function or a NumPy ufunc
152
+ if isinstance(operation, np.ufunc):
153
+ if ufunc_requires_additional_input(operation):
154
+ if value is None:
155
+ raise ValueError("This ufunc requires an additional input value.")
156
+ modified_data = operation(self._mesa_data, value)
157
+ else:
158
+ modified_data = operation(self._mesa_data)
159
+ else:
160
+ # Vectorize non-ufunc operations
161
+ vectorized_operation = np.vectorize(operation)
162
+ modified_data = vectorized_operation(self._mesa_data)
163
+
164
+ self._mesa_data = np.where(condition_array, modified_data, self._mesa_data)
165
+
166
+ def select_cells(self, condition: Callable, return_list=True):
167
+ """Find cells that meet a specified condition using NumPy's boolean indexing, in-place.
168
+
169
+ Args:
170
+ condition: A callable that returns a boolean array when applied to the data.
171
+ return_list: (Optional) If True, return a list of (x, y) tuples. Otherwise, return a boolean array.
172
+
173
+ Returns:
174
+ A list of (x, y) tuples or a boolean array.
175
+ """
176
+ # fixme: consider splitting into two separate functions
177
+ # select_cells_boolean
178
+ # select_cells_index
179
+
180
+ condition_array = condition(self._mesa_data)
181
+ if return_list:
182
+ return list(zip(*np.where(condition_array)))
183
+ else:
184
+ return condition_array
185
+
186
+ def aggregate(self, operation: Callable):
187
+ """Perform an aggregate operation (e.g., sum, mean) on a property across all cells.
188
+
189
+ Args:
190
+ operation: A function to apply. Can be a lambda function or a NumPy ufunc.
191
+ """
192
+ return operation(self._mesa_data)
193
+
194
+
195
+ class HasPropertyLayers:
196
+ """Mixin-like class to add property layer functionality to Grids.
197
+
198
+ Property layers can be added to a grid using create_property_layer or add_property_layer. Once created, property
199
+ layers can be accessed as attributes if the name used for the layer is a valid python identifier.
200
+
201
+ """
202
+
203
+ # fixme is there a way to indicate that a mixin only works with specific classes?
204
+ def __init__(self, *args, **kwargs):
205
+ """Initialize a HasPropertyLayers instance."""
206
+ super().__init__(*args, **kwargs)
207
+ self._mesa_property_layers = {}
208
+
209
+ def create_property_layer(
210
+ self,
211
+ name: str,
212
+ default_value=0.0,
213
+ dtype=float,
214
+ ):
215
+ """Add a property layer to the grid.
216
+
217
+ Args:
218
+ name: The name of the property layer.
219
+ default_value: The default value of the property layer.
220
+ dtype: The data type of the property layer.
221
+
222
+ Returns:
223
+ Property layer instance.
224
+
225
+ """
226
+ layer = PropertyLayer(
227
+ name, self.dimensions, default_value=default_value, dtype=dtype
228
+ )
229
+ self.add_property_layer(layer)
230
+ return layer
231
+
232
+ def add_property_layer(self, layer: PropertyLayer):
233
+ """Add a predefined property layer to the grid.
234
+
235
+ Args:
236
+ layer: The property layer to add.
237
+
238
+ Raises:
239
+ ValueError: If the dimensions of the layer and the grid are not the same.
240
+
241
+ """
242
+ if layer.dimensions != self.dimensions:
243
+ raise ValueError(
244
+ "Dimensions of property layer do not match the dimensions of the grid"
245
+ )
246
+ if layer.name in self._mesa_property_layers:
247
+ raise ValueError(f"Property layer {layer.name} already exists.")
248
+ if (
249
+ layer.name in self.cell_klass.__slots__
250
+ or layer.name in self.cell_klass.__dict__
251
+ ):
252
+ raise ValueError(
253
+ f"Property layer {layer.name} clashes with existing attribute in {self.cell_klass.__name__}"
254
+ )
255
+
256
+ self._mesa_property_layers[layer.name] = layer
257
+ setattr(self.cell_klass, layer.name, PropertyDescriptor(layer))
258
+ self.cell_klass._mesa_properties.add(layer.name)
259
+
260
+ def remove_property_layer(self, property_name: str):
261
+ """Remove a property layer from the grid.
262
+
263
+ Args:
264
+ property_name: the name of the property layer to remove
265
+ remove_from_cells: whether to remove the property layer from all cells (default: True)
266
+ """
267
+ del self._mesa_property_layers[property_name]
268
+ delattr(self.cell_klass, property_name)
269
+ self.cell_klass._mesa_properties.remove(property_name)
270
+
271
+ def set_property(
272
+ self, property_name: str, value, condition: Callable[[T], bool] | None = None
273
+ ):
274
+ """Set the value of a property for all cells in the grid.
275
+
276
+ Args:
277
+ property_name: the name of the property to set
278
+ value: the value to set
279
+ condition: a function that takes a cell and returns a boolean
280
+ """
281
+ self._mesa_property_layers[property_name].set_cells(value, condition)
282
+
283
+ def modify_properties(
284
+ self,
285
+ property_name: str,
286
+ operation: Callable,
287
+ value: Any = None,
288
+ condition: Callable[[T], bool] | None = None,
289
+ ):
290
+ """Modify the values of a specific property for all cells in the grid.
291
+
292
+ Args:
293
+ property_name: the name of the property to modify
294
+ operation: the operation to perform
295
+ value: the value to use in the operation
296
+ condition: a function that takes a cell and returns a boolean (used to filter cells)
297
+ """
298
+ self._mesa_property_layers[property_name].modify_cells(
299
+ operation, value, condition
300
+ )
301
+
302
+ def get_neighborhood_mask(
303
+ self, coordinate: Coordinate, include_center: bool = True, radius: int = 1
304
+ ) -> np.ndarray:
305
+ """Generate a boolean mask representing the neighborhood.
306
+
307
+ Args:
308
+ coordinate: Center of the neighborhood.
309
+ include_center: Include the central cell in the neighborhood.
310
+ radius: The radius of the neighborhood.
311
+
312
+ Returns:
313
+ np.ndarray: A boolean mask representing the neighborhood.
314
+ """
315
+ cell = self._cells[coordinate]
316
+ neighborhood = cell.get_neighborhood(
317
+ include_center=include_center, radius=radius
318
+ )
319
+ mask = np.zeros(self.dimensions, dtype=bool)
320
+
321
+ # Convert the neighborhood list to a NumPy array and use advanced indexing
322
+ coords = np.array([c.coordinate for c in neighborhood])
323
+ indices = [coords[:, i] for i in range(coords.shape[1])]
324
+ mask[*indices] = True
325
+ return mask
326
+
327
+ def select_cells(
328
+ self,
329
+ conditions: dict | None = None,
330
+ extreme_values: dict | None = None,
331
+ masks: np.ndarray | list[np.ndarray] = None,
332
+ only_empty: bool = False,
333
+ return_list: bool = True,
334
+ ) -> list[Coordinate] | np.ndarray:
335
+ """Select cells based on property conditions, extreme values, and/or masks, with an option to only select empty cells.
336
+
337
+ Args:
338
+ conditions (dict): A dictionary where keys are property names and values are callables that return a boolean when applied.
339
+ extreme_values (dict): A dictionary where keys are property names and values are either 'highest' or 'lowest'.
340
+ masks (np.ndarray | list[np.ndarray], optional): A mask or list of masks to restrict the selection.
341
+ only_empty (bool, optional): If True, only select cells that are empty. Default is False.
342
+ return_list (bool, optional): If True, return a list of coordinates, otherwise return a mask.
343
+
344
+ Returns:
345
+ Union[list[Coordinate], np.ndarray]: Coordinates where conditions are satisfied or the combined mask.
346
+ """
347
+ # fixme: consider splitting into two separate functions
348
+ # select_cells_boolean
349
+ # select_cells_index
350
+ # also we might want to change the naming to avoid classes with PropertyLayer
351
+
352
+ # Initialize the combined mask
353
+ combined_mask = np.ones(self.dimensions, dtype=bool)
354
+
355
+ # Apply the masks
356
+ if masks is not None:
357
+ if isinstance(masks, list):
358
+ for mask in masks:
359
+ combined_mask = np.logical_and(combined_mask, mask)
360
+ else:
361
+ combined_mask = np.logical_and(combined_mask, masks)
362
+
363
+ # Apply the empty mask if only_empty is True
364
+ if only_empty:
365
+ combined_mask = np.logical_and(
366
+ combined_mask, self._mesa_property_layers["empty"]
367
+ )
368
+
369
+ # Apply conditions
370
+ if conditions:
371
+ for prop_name, condition in conditions.items():
372
+ prop_layer = self._mesa_property_layers[prop_name].data
373
+ prop_mask = condition(prop_layer)
374
+ combined_mask = np.logical_and(combined_mask, prop_mask)
375
+
376
+ # Apply extreme values
377
+ if extreme_values:
378
+ for property_name, mode in extreme_values.items():
379
+ prop_values = self._mesa_property_layers[property_name].data
380
+
381
+ # Create a masked array using the combined_mask
382
+ masked_values = np.ma.masked_array(prop_values, mask=~combined_mask)
383
+
384
+ if mode == "highest":
385
+ target_value = masked_values.max()
386
+ elif mode == "lowest":
387
+ target_value = masked_values.min()
388
+ else:
389
+ raise ValueError(
390
+ f"Invalid mode {mode}. Choose from 'highest' or 'lowest'."
391
+ )
392
+
393
+ extreme_value_mask = prop_values == target_value
394
+ combined_mask = np.logical_and(combined_mask, extreme_value_mask)
395
+
396
+ # Generate output
397
+ if return_list:
398
+ selected_cells = list(zip(*np.where(combined_mask)))
399
+ return selected_cells
400
+ else:
401
+ return combined_mask
402
+
403
+ def __getattr__(self, name: str) -> Any: # noqa: D105
404
+ try:
405
+ return self._mesa_property_layers[name]
406
+ except KeyError as e:
407
+ raise AttributeError(
408
+ f"'{type(self).__name__}' object has no property layer called '{name}'"
409
+ ) from e
410
+
411
+ def __setattr__(self, key, value): # noqa: D105
412
+ # fixme
413
+ # this might be done more elegantly, the main problem is that _mesa_property_layers must already be defined to avoid infinite recursion errors from happening
414
+ # also, this protection only works if the attribute is added after the layer, not the other way around
415
+ try:
416
+ layers = self.__dict__["_mesa_property_layers"]
417
+ except KeyError:
418
+ super().__setattr__(key, value)
419
+ else:
420
+ if key in layers:
421
+ raise AttributeError(
422
+ f"'{type(self).__name__}' object already has a property layer with name '{key}'"
423
+ )
424
+ else:
425
+ super().__setattr__(key, value)
426
+
427
+
428
+ class PropertyDescriptor:
429
+ """Descriptor for giving cells attribute like access to values defined in property layers."""
430
+
431
+ def __init__(self, property_layer: PropertyLayer): # noqa: D107
432
+ self.layer: PropertyLayer = property_layer
433
+
434
+ def __get__(self, instance: Cell, owner): # noqa: D105
435
+ return self.layer.data[instance.coordinate]
436
+
437
+ def __set__(self, instance: Cell, value): # noqa: D105
438
+ self.layer.data[instance.coordinate] = value
439
+
440
+
441
+ def ufunc_requires_additional_input(ufunc): # noqa: D103
442
+ # NumPy ufuncs have a 'nargs' attribute indicating the number of input arguments
443
+ # For binary ufuncs (like np.add), nargs is 2
444
+ return ufunc.nargs > 1