Repository: projectmesa/mesa
Pull request #2502 (2024-11-13 16:11:34)

cell_space: Allow CellCollection to be empty
Sometimes a CellCollection needs to be empty, especially after filtering with `.select()`. However, the code would raise a StopIteration error trying to get `capacity` from the first cell. This PR handles empty collections gracefully.

### Summary
Fix StopIteration error when creating empty CellCollections by allowing `capacity` to be None when there are no cells.

### Bug / Issue
Creating a CellCollection with no cells (after filtering) raises StopIteration:
```python
# This raises StopIteration if no cells meet the condition
cells = neighbors.select(lambda cell: not any(isinstance(obj, Wolf) for obj in cell.agents))
```

### Implementation
Check for empty collection before accessing first cell's capacity:
```python
self._capacity: int | None = (
    next(iter(self._cells.keys())).capacity if self._cells else None
)
```

### Testing
- Verify empty CellCollection creation
- Test select() with no matching cells
- Check behavior of empty collection methods

github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]      | Run time [95% CI]       |
|-----------------|--------|-------------------------|-------------------------|
| BoltzmannWealth | small  | 🔵 -0.6% [-1.0%, -0.1%] | 🔵 -0.3% [-0.4%, -0.1%] |
| BoltzmannWealth | large  | 🔵 -0.9% [-1.2%, -0.5%] | 🔵 -1.0% [-1.2%, -0.8%] |
| Schelling       | small  | 🔵 -1.4% [-1.7%, -1.1%] | 🔵 +0.1% [-0.2%, +0.3%] |
| Schelling       | large  | 🔵 -1.2% [-1.4%, -0.9%] | 🔵 +0.5% [+0.2%, +1.0%] |
| WolfSheep       | small  | 🔵 +0.0% [-0.2%, +0.3%] | 🔵 +0.8% [+0.6%, +0.9%] |
| WolfSheep       | large  | 🔵 +0.1% [-0.5%, +0.8%] | 🔵 +2.5% [+1.6%, +3.8%] |
| BoidFlockers    | small  | 🔵 -0.5% [-1.1%, +0.1%] | 🔵 +0.3% [-0.6%, +1.3%] |
| BoidFlockers    | large  | 🔵 -0.2% [-1.2%, +0.8%] | 🔵 +0.6% [-0.2%, +1.3%] |


Patch:
diff --git a/mesa/examples/advanced/epstein_civil_violence/app.py b/mesa/examples/advanced/epstein_civil_violence/app.py
index d3eb4643..13ac8947 100644
--- a/mesa/examples/advanced/epstein_civil_violence/app.py
+++ b/mesa/examples/advanced/epstein_civil_violence/app.py
@@ -44,6 +44,11 @@ def post_process(ax):
 
 
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "height": 40,
     "width": 40,
     "citizen_density": Slider("Initial Agent Density", 0.7, 0.0, 0.9, 0.1),
diff --git a/mesa/examples/advanced/pd_grid/app.py b/mesa/examples/advanced/pd_grid/app.py
index 94c2d71f..d99136fb 100644
--- a/mesa/examples/advanced/pd_grid/app.py
+++ b/mesa/examples/advanced/pd_grid/app.py
@@ -24,6 +24,11 @@ def pd_agent_portrayal(agent):
 
 # Model parameters
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "width": Slider("Grid Width", value=50, min=10, max=100, step=1),
     "height": Slider("Grid Height", value=50, min=10, max=100, step=1),
     "activation_order": {
diff --git a/mesa/examples/advanced/sugarscape_g1mt/app.py b/mesa/examples/advanced/sugarscape_g1mt/app.py
index 6564cb68..49039436 100644
--- a/mesa/examples/advanced/sugarscape_g1mt/app.py
+++ b/mesa/examples/advanced/sugarscape_g1mt/app.py
@@ -47,6 +47,11 @@ def SpaceDrawer(model):
 
 
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "width": 50,
     "height": 50,
     # Population parameters
@@ -66,10 +71,10 @@ model_params = {
     "enable_trade": {"type": "Checkbox", "value": True, "label": "Enable Trading"},
 }
 
-model1 = SugarscapeG1mt()
+model = SugarscapeG1mt()
 
 page = SolaraViz(
-    model1,
+    model,
     components=[SpaceDrawer, make_plot_component(["Trader", "Price"])],
     model_params=model_params,
     name="Sugarscape {G1, M, T}",
diff --git a/mesa/examples/basic/boid_flockers/app.py b/mesa/examples/basic/boid_flockers/app.py
index 074c1ab3..16b1ccf7 100644
--- a/mesa/examples/basic/boid_flockers/app.py
+++ b/mesa/examples/basic/boid_flockers/app.py
@@ -12,6 +12,11 @@ def boid_draw(agent):
 
 
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "population": Slider(
         label="Number of boids",
         value=100,
diff --git a/mesa/examples/basic/boltzmann_wealth_model/app.py b/mesa/examples/basic/boltzmann_wealth_model/app.py
index c03d1763..b86c4b87 100644
--- a/mesa/examples/basic/boltzmann_wealth_model/app.py
+++ b/mesa/examples/basic/boltzmann_wealth_model/app.py
@@ -12,6 +12,11 @@ def agent_portrayal(agent):
 
 
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "n": {
         "type": "SliderInt",
         "value": 50,
@@ -20,11 +25,6 @@ model_params = {
         "max": 100,
         "step": 1,
     },
-    "seed": {
-        "type": "InputText",
-        "value": 42,
-        "label": "Random Seed",
-    },
     "width": 10,
     "height": 10,
 }
diff --git a/mesa/examples/basic/conways_game_of_life/app.py b/mesa/examples/basic/conways_game_of_life/app.py
index e2219c9d..4423f873 100644
--- a/mesa/examples/basic/conways_game_of_life/app.py
+++ b/mesa/examples/basic/conways_game_of_life/app.py
@@ -20,6 +20,11 @@ def post_process(ax):
 
 
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "width": {
         "type": "SliderInt",
         "value": 50,
diff --git a/mesa/examples/basic/schelling/app.py b/mesa/examples/basic/schelling/app.py
index 933b3c27..56c0547a 100644
--- a/mesa/examples/basic/schelling/app.py
+++ b/mesa/examples/basic/schelling/app.py
@@ -19,6 +19,11 @@ def agent_portrayal(agent):
 
 
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "density": Slider("Agent density", 0.8, 0.1, 1.0, 0.1),
     "minority_pc": Slider("Fraction minority", 0.2, 0.0, 1.0, 0.05),
     "homophily": Slider("Homophily", 3, 0, 8, 1),
diff --git a/mesa/examples/basic/virus_on_network/app.py b/mesa/examples/basic/virus_on_network/app.py
index 7f3a905b..f278f233 100644
--- a/mesa/examples/basic/virus_on_network/app.py
+++ b/mesa/examples/basic/virus_on_network/app.py
@@ -35,6 +35,11 @@ def get_resistant_susceptible_ratio(model):
 
 
 model_params = {
+    "seed": {
+        "type": "InputText",
+        "value": 42,
+        "label": "Random Seed",
+    },
     "num_nodes": Slider(
         label="Number of agents",
         value=10,
diff --git a/mesa/experimental/cell_space/cell_collection.py b/mesa/experimental/cell_space/cell_collection.py
index ead67021..ca49a472 100644
--- a/mesa/experimental/cell_space/cell_collection.py
+++ b/mesa/experimental/cell_space/cell_collection.py
@@ -48,8 +48,10 @@ class CellCollection(Generic[T]):
         else:
             self._cells = {cell: cell.agents for cell in cells}
 
-        #
-        self._capacity: int = next(iter(self._cells.keys())).capacity
+        # Get capacity from first cell if collection is not empty
+        self._capacity: int | None = (
+            next(iter(self._cells.keys())).capacity if self._cells else None
+        )
 
         if random is None:
             warnings.warn(
diff --git a/mesa/model.py b/mesa/model.py
index 5e5efc2a..5d638a01 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -9,7 +9,6 @@ from __future__ import annotations
 
 import random
 import sys
-import warnings
 from collections.abc import Sequence
 
 # mypy
@@ -101,7 +100,13 @@ class Model:
         self.step = self._wrapped_step
 
         # setup agent registration data structures
-        self._setup_agent_registration()
+        self._agents = {}  # the hard references to all agents in the model
+        self._agents_by_type: dict[
+            type[Agent], AgentSet
+        ] = {}  # a dict with an agentset for each class of agents
+        self._all_agents = AgentSet(
+            [], random=self.random
+        )  # an agenset with all agents
 
     def _wrapped_step(self, *args: Any, **kwargs: Any) -> None:
         """Automatically increments time and steps after calling the user's step method."""
@@ -133,16 +138,6 @@ class Model:
         """A dictionary where the keys are agent types and the values are the corresponding AgentSets."""
         return self._agents_by_type
 
-    def _setup_agent_registration(self):
-        """Helper method to initialize the agent registration datastructures."""
-        self._agents = {}  # the hard references to all agents in the model
-        self._agents_by_type: dict[
-            type[Agent], AgentSet
-        ] = {}  # a dict with an agentset for each class of agents
-        self._all_agents = AgentSet(
-            [], random=self.random
-        )  # an agenset with all agents
-
     def register_agent(self, agent):
         """Register the agent with the model.
 
@@ -154,16 +149,6 @@ class Model:
             if you are subclassing Agent and calling its super in the ``__init__`` method.
 
         """
-        if not hasattr(self, "_agents"):
-            self._setup_agent_registration()
-
-            warnings.warn(
-                "The Mesa Model class was not initialized. In the future, you need to explicitly initialize "
-                "the Model by calling super().__init__() on initialization.",
-                FutureWarning,
-                stacklevel=2,
-            )
-
         self._agents[agent] = None
 
         # because AgentSet requires model, we cannot use defaultdict


Test patch:
diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py
index add5f6f8..b1170174 100644
--- a/tests/test_cell_space.py
+++ b/tests/test_cell_space.py
@@ -583,6 +583,40 @@ def test_cell_collection():
     assert len(cells) == len(collection)
 
 
+def test_empty_cell_collection():
+    """Test that CellCollection properly handles empty collections."""
+    rng = random.Random(42)
+
+    # Test initializing with empty collection
+    collection = CellCollection([], random=rng)
+    assert len(collection) == 0
+    assert collection._capacity is None
+    assert list(collection.cells) == []
+    assert list(collection.agents) == []
+
+    # Test selecting from empty collection
+    selected = collection.select(lambda cell: True)
+    assert len(selected) == 0
+    assert selected._capacity is None
+
+    # Test filtering to empty collection
+    n = 10
+    full_collection = CellCollection(
+        [Cell((i,), random=rng) for i in range(n)], random=rng
+    )
+    assert len(full_collection) == n
+
+    # Filter to empty collection
+    empty_result = full_collection.select(lambda cell: False)
+    assert len(empty_result) == 0
+    assert empty_result._capacity is None
+
+    # Test at_most with empty collection
+    at_most_result = full_collection.select(lambda cell: False, at_most=5)
+    assert len(at_most_result) == 0
+    assert at_most_result._capacity is None
+
+
 ### PropertyLayer tests
 def test_property_layer_integration():
     """Test integration of PropertyLayer with DiscrateSpace and Cell."""