Repository: projectmesa/mesa
Pull request #2348 (2024-10-12 10:55:21)

Add convenience properties for grid width and height
90% of all use cases of grid will be 2D so it is convenient to have easy access to the width and the height of the grid. 

While porting the examples over to the experimental grid spaces, I frequently need to write `self.grid.dimensions[0]` etc. This makes it a bit more readable because I can just do `self.grid.width` etc.


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 69fe21bf..5bd43ecc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -119,6 +119,24 @@ ruff .
 
 The license of this project is located in [LICENSE].  By submitting a contribution to this project, you are agreeing that your contribution will be released under the terms of this license.
 
+## Maintainers
+Some notes useful for Mesa maintainers.
+
+### Releases
+To create a new release, follow these steps:
+
+1. Ensure all pull requests (PRs) have a clear title and are labeled with at least one label. Check [this link](https://github.com/projectmesa/mesa/pulls?q=is%3Apr+is%3Amerged+no%3Alabel+merged%3A%3E%3D2024-03-01+) to see if all PRs are labeled. These labels will be used when drafting the changelog using the [`.github/release.yml`](https://github.com/projectmesa/mesa/blob/main/.github/release.yml) configuration.
+2. Navigate to the [Releases](https://github.com/projectmesa/mesa/releases) section in the GitHub UI and click the _Draft a new release_ button.
+3. Specify the upcoming tag in the _Choose a tag_ and _Release title_ fields (e.g., `v3.0.0`).
+   - For pre-releases, add a `a`, `b` or `rc` and a number behind the version tag (see [Versioning](https://packaging.python.org/en/latest/discussions/versioning/)), and check the box _Set as a pre-release_.
+4. Use the _Generate release notes_ button to automatically create release notes. Review them carefully for accuracy, and update labels and edit PR titles if necessary (step 1).
+5. Write a _Highlights_ section summarizing the most important features or changes in this release.
+6. Copy the release notes and save them by clicking the grey _Save draft_ button.
+7. Open a new PR to update the version number in [`mesa/__init__.py`](https://github.com/projectmesa/mesa/blob/main/mesa/__init__.py) and add the copied release notes to the [`HISTORY.md`](https://github.com/projectmesa/mesa/blob/main/HISTORY.md).
+8. Once this PR is merged, return to the _Releases_ section and publish the draft release.
+9. The [`release.yml`](https://github.com/projectmesa/mesa/blob/main/.github/workflows/release.yml) CI workflow should automatically create and upload the package to PyPI. Verify this on [PyPI.org](https://pypi.org/project/mesa/).
+10. Finally, after release, open a new PR to update the version number in [`mesa/__init__.py`](https://github.com/projectmesa/mesa/blob/main/mesa/__init__.py) for the next release (e.g., `"3.1.0.dev"`).
+
 ## Special Thanks
 
 A special thanks to the following projects who offered inspiration for this contributing file.
diff --git a/mesa/agent.py b/mesa/agent.py
index 4d28f474..2b6d2f99 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -99,14 +99,18 @@ class AgentSet(MutableSet, Sequence):
         which means that agents not referenced elsewhere in the program may be automatically removed from the AgentSet.
     """
 
-    def __init__(self, agents: Iterable[Agent], model: Model):
+    def __init__(self, agents: Iterable[Agent], random: Random | None = None):
         """Initializes the AgentSet with a collection of agents and a reference to the model.
 
         Args:
             agents (Iterable[Agent]): An iterable of Agent objects to be included in the set.
-            model (Model): The ABM model instance to which this AgentSet belongs.
+            random (Random): the random number generator
         """
-        self.model = model
+        if random is None:
+            random = (
+                Random()
+            )  # FIXME see issue 1981, how to get the central rng from model
+        self.random = random
         self._agents = weakref.WeakKeyDictionary({agent: None for agent in agents})
 
     def __len__(self) -> int:
@@ -177,7 +181,7 @@ class AgentSet(MutableSet, Sequence):
 
         agents = agent_generator(filter_func, agent_type, at_most)
 
-        return AgentSet(agents, self.model) if not inplace else self._update(agents)
+        return AgentSet(agents, self.random) if not inplace else self._update(agents)
 
     def shuffle(self, inplace: bool = False) -> AgentSet:
         """Randomly shuffle the order of agents in the AgentSet.
@@ -200,7 +204,7 @@ class AgentSet(MutableSet, Sequence):
             return self
         else:
             return AgentSet(
-                (agent for ref in weakrefs if (agent := ref()) is not None), self.model
+                (agent for ref in weakrefs if (agent := ref()) is not None), self.random
             )
 
     def sort(
@@ -225,7 +229,7 @@ class AgentSet(MutableSet, Sequence):
         sorted_agents = sorted(self._agents.keys(), key=key, reverse=not ascending)
 
         return (
-            AgentSet(sorted_agents, self.model)
+            AgentSet(sorted_agents, self.random)
             if not inplace
             else self._update(sorted_agents)
         )
@@ -477,7 +481,7 @@ class AgentSet(MutableSet, Sequence):
         Returns:
             dict: A dictionary representing the state of the AgentSet.
         """
-        return {"agents": list(self._agents.keys()), "model": self.model}
+        return {"agents": list(self._agents.keys()), "random": self.random}
 
     def __setstate__(self, state):
         """Set the state of the AgentSet during deserialization.
@@ -485,18 +489,9 @@ class AgentSet(MutableSet, Sequence):
         Args:
             state (dict): A dictionary representing the state to restore.
         """
-        self.model = state["model"]
+        self.random = state["random"]
         self._update(state["agents"])
 
-    @property
-    def random(self) -> Random:
-        """Provide access to the model's random number generator.
-
-        Returns:
-            Random: The random number generator associated with the model.
-        """
-        return self.model.random
-
     def groupby(self, by: Callable | str, result_type: str = "agentset") -> GroupBy:
         """Group agents by the specified attribute or return from the callable.
 
@@ -529,7 +524,7 @@ class AgentSet(MutableSet, Sequence):
 
         if result_type == "agentset":
             return GroupBy(
-                {k: AgentSet(v, model=self.model) for k, v in groups.items()}
+                {k: AgentSet(v, random=self.random) for k, v in groups.items()}
             )
         else:
             return GroupBy(groups)
diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py
index a48c498f..5d396b5d 100644
--- a/mesa/experimental/__init__.py
+++ b/mesa/experimental/__init__.py
@@ -2,6 +2,12 @@
 
 from mesa.experimental import cell_space
 
-from .solara_viz import JupyterViz, Slider, SolaraViz, make_text
+try:
+    from .solara_viz import JupyterViz, Slider, SolaraViz, make_text
 
-__all__ = ["cell_space", "JupyterViz", "SolaraViz", "make_text", "Slider"]
+    __all__ = ["cell_space", "JupyterViz", "Slider", "SolaraViz", "make_text"]
+except ImportError:
+    print(
+        "Could not import SolaraViz. If you need it, install with 'pip install --pre mesa[viz]'"
+    )
+    __all__ = ["cell_space"]
diff --git a/mesa/experimental/cell_space/grid.py b/mesa/experimental/cell_space/grid.py
index 4d525964..299572fa 100644
--- a/mesa/experimental/cell_space/grid.py
+++ b/mesa/experimental/cell_space/grid.py
@@ -22,8 +22,21 @@ class Grid(DiscreteSpace[T], Generic[T]):
         random (Random): the random number generator
         _try_random (bool): whether to get empty cell be repeatedly trying random cell
 
+    Notes:
+        width and height are accessible via properties, higher dimensions can be retrieved via dimensions
+
     """
 
+    @property
+    def width(self) -> int:
+        """Convenience access to the width of the grid."""
+        return self.dimensions[0]
+
+    @property
+    def height(self) -> int:
+        """Convenience access to the height of the grid."""
+        return self.dimensions[1]
+
     def __init__(
         self,
         dimensions: Sequence[int],
diff --git a/mesa/model.py b/mesa/model.py
index e4f70793..b6500eb4 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -52,8 +52,6 @@ class Model:
         self.running = True
         self.steps: int = 0
 
-        self._setup_agent_registration()
-
         self._seed = seed
         if self._seed is None:
             # We explicitly specify the seed here so that we know its value in
@@ -65,6 +63,9 @@ class Model:
         self._user_step = self.step
         self.step = self._wrapped_step
 
+        # setup agent registration data structures
+        self._setup_agent_registration()
+
     def _wrapped_step(self, *args: Any, **kwargs: Any) -> None:
         """Automatically increments time and steps after calling the user's step method."""
         # Automatically increment time and step counters
@@ -119,7 +120,9 @@ class Model:
         self._agents_by_type: dict[
             type[Agent], AgentSet
         ] = {}  # a dict with an agentset for each class of agents
-        self._all_agents = AgentSet([], self)  # an agenset with all agents
+        self._all_agents = AgentSet(
+            [], random=self.random
+        )  # an agenset with all agents
 
     def register_agent(self, agent):
         """Register the agent with the model.
@@ -153,7 +156,7 @@ class Model:
                 [
                     agent,
                 ],
-                self,
+                random=self.random,
             )
 
         self._all_agents.add(agent)


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 73aab897..aa5ff935 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -62,7 +62,7 @@ def test_agentset():
     model = Model()
     agents = [AgentTest(model) for _ in range(10)]
 
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     assert agents[0] in agentset
     assert len(agentset) == len(agents)
@@ -118,7 +118,7 @@ def test_agentset():
 
     # because AgentSet uses weakrefs, we need hard refs as well....
     other_agents, another_set = pickle.loads(  # noqa: S301
-        pickle.dumps([agents, AgentSet(agents, model)])
+        pickle.dumps([agents, AgentSet(agents, random=model.random)])
     )
     assert all(
         a1.unique_id == a2.unique_id for a1, a2 in zip(another_set, other_agents)
@@ -129,11 +129,11 @@ def test_agentset():
 def test_agentset_initialization():
     """Test agentset initialization."""
     model = Model()
-    empty_agentset = AgentSet([], model)
+    empty_agentset = AgentSet([], random=model.random)
     assert len(empty_agentset) == 0
 
     agents = [AgentTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
     assert len(agentset) == 10
 
 
@@ -141,7 +141,7 @@ def test_agentset_serialization():
     """Test pickleability of agentset."""
     model = Model()
     agents = [AgentTest(model) for _ in range(5)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     serialized = pickle.dumps(agentset)
     deserialized = pickle.loads(serialized)  # noqa: S301
@@ -156,7 +156,7 @@ def test_agent_membership():
     """Test agent membership in AgentSet."""
     model = Model()
     agents = [AgentTest(model) for _ in range(5)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     assert agents[0] in agentset
     assert AgentTest(model) not in agentset
@@ -166,7 +166,7 @@ def test_agent_add_remove_discard():
     """Test adding, removing and discarding agents from AgentSet."""
     model = Model()
     agent = AgentTest(model)
-    agentset = AgentSet([], model)
+    agentset = AgentSet([], random=model.random)
 
     agentset.add(agent)
     assert agent in agentset
@@ -186,7 +186,7 @@ def test_agentset_get_item():
     """Test integer based access to AgentSet."""
     model = Model()
     agents = [AgentTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     assert agentset[0] == agents[0]
     assert agentset[-1] == agents[-1]
@@ -200,7 +200,7 @@ def test_agentset_do_str():
     """Test AgentSet.do with str."""
     model = Model()
     agents = [AgentTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     with pytest.raises(AttributeError):
         agentset.do("non_existing_method")
@@ -213,7 +213,7 @@ def test_agentset_do_str():
     n = 10
     model = Model()
     agents = [AgentDoTest(model) for _ in range(n)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
     for agent in agents:
         agent.agent_set = agentset
 
@@ -223,7 +223,7 @@ def test_agentset_do_str():
     # setup
     model = Model()
     agents = [AgentDoTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
     for agent in agents:
         agent.agent_set = agentset
 
@@ -235,7 +235,7 @@ def test_agentset_do_callable():
     """Test AgentSet.do with callable."""
     model = Model()
     agents = [AgentTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     # Test callable with non-existent function
     with pytest.raises(AttributeError):
@@ -249,7 +249,7 @@ def test_agentset_do_callable():
     n = 10
     model = Model()
     agents = [AgentDoTest(model) for _ in range(n)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
     for agent in agents:
         agent.agent_set = agentset
 
@@ -260,7 +260,7 @@ def test_agentset_do_callable():
     # setup again for lambda function tests
     model = Model()
     agents = [AgentDoTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
     for agent in agents:
         agent.agent_set = agentset
 
@@ -278,7 +278,7 @@ def test_agentset_do_callable():
     # setup again for actual function tests
     model = Model()
     agents = [AgentDoTest(model) for _ in range(n)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
     for agent in agents:
         agent.agent_set = agentset
 
@@ -289,7 +289,7 @@ def test_agentset_do_callable():
     # setup again for actual function tests
     model = Model()
     agents = [AgentDoTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
     for agent in agents:
         agent.agent_set = agentset
 
@@ -354,7 +354,7 @@ def test_agentset_agg():
         agent.energy = i + 1
         agent.wealth = 10 * (i + 1)
 
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     # Test min aggregation
     min_energy = agentset.agg("energy", min)
@@ -391,7 +391,7 @@ def test_agentset_set_method():
 
     model = Model()
     agents = [TestAgentWithAttribute(model, age=i) for i in range(5)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     # Set a new attribute "health" and an existing attribute "age" for all agents
     agentset.set("health", 100).set("age", 50).set("status", "active")
@@ -410,7 +410,7 @@ def test_agentset_map_str():
     """Test AgentSet.map with strings."""
     model = Model()
     agents = [AgentTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     with pytest.raises(AttributeError):
         agentset.do("non_existing_method")
@@ -423,7 +423,7 @@ def test_agentset_map_callable():
     """Test AgentSet.map with callable."""
     model = Model()
     agents = [AgentTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     # Test callable with non-existent function
     with pytest.raises(AttributeError):
@@ -450,7 +450,7 @@ def test_agentset_shuffle_do():
             self.called = True
 
     agents = [TestAgentShuffleDo(model) for _ in range(100)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     # Test shuffle_do with a string method name
     agentset.shuffle_do("test_method")
@@ -477,7 +477,7 @@ def test_agentset_get_attribute():
     """Test AgentSet.get for attributes."""
     model = Model()
     agents = [AgentTest(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     unique_ids = agentset.get("unique_id")
     assert unique_ids == [agent.unique_id for agent in agents]
@@ -491,7 +491,7 @@ def test_agentset_get_attribute():
         agent = AgentTest(model)
         agent.i = i**2
         agents.append(agent)
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     values = agentset.get(["unique_id", "i"])
 
@@ -521,7 +521,7 @@ def test_agentset_select_by_type():
 
     # Combine the two types of agents
     mixed_agents = test_agents + other_agents
-    agentset = AgentSet(mixed_agents, model)
+    agentset = AgentSet(mixed_agents, random=model.random)
 
     # Test selection by type
     selected_test_agents = agentset.select(agent_type=AgentTest)
@@ -544,11 +544,11 @@ def test_agentset_shuffle():
     model = Model()
     test_agents = [AgentTest(model) for _ in range(12)]
 
-    agentset = AgentSet(test_agents, model=model)
+    agentset = AgentSet(test_agents, random=model.random)
     agentset = agentset.shuffle()
     assert not all(a1 == a2 for a1, a2 in zip(test_agents, agentset))
 
-    agentset = AgentSet(test_agents, model=model)
+    agentset = AgentSet(test_agents, random=model.random)
     agentset.shuffle(inplace=True)
     assert not all(a1 == a2 for a1, a2 in zip(test_agents, agentset))
 
@@ -567,7 +567,7 @@ def test_agentset_groupby():
 
     model = Model()
     agents = [TestAgent(model) for _ in range(10)]
-    agentset = AgentSet(agents, model)
+    agentset = AgentSet(agents, random=model.random)
 
     groups = agentset.groupby("even")
     assert len(groups.groups[True]) == 5