Repository: projectmesa/mesa
Pull request #1925 (2023-12-23 14:18:09)

Create release.yml file for automatic release notes generation
Adds a configuration file `.github/release.yml` that lets GitHub automatically draft release notes based on the labels attached to PRs that will be included in that release. These drafted release notes can form a consistent basis for our changelog and manually expanded with context for certain PRs if needed.

The currently proposed labels are `experimental`, `breaking`, `feature`, `enhancement`, `bug`, `docs`, `ci`, `testing`, `dependency`, `maintenance` and `packaging`. The last five are grouped in the changelog under _Maintenance 🔧_.

For this to work nicely, it's important that all merged PRs have one of these labels attached to them and that the PR titles are sufficiently descriptive.

See [Automatically generated release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes).

You can see an example of how this looks [here](https://github.com/quaquel/EMAworkbench/releases) (the _Highlights_ and _Breaking changes_ sections are written by hand, the rest is automatically generated).

@jackiekazil and @tpike3 please review.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/projectmesa/mesa/pull/1925?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`7bd42e7`)](https://app.codecov.io/gh/projectmesa/mesa/commit/7bd42e78516203083e576630af8a7f458bcd09c0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) 79.14% compared to head [(`3aa742f`)](https://app.codecov.io/gh/projectmesa/mesa/pull/1925?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) 79.14%.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #1925   +/-   ##
=======================================
  Coverage   79.14%   79.14%           
=======================================
  Files          15       15           
  Lines        1098     1098           
  Branches      236      236           
=======================================
  Hits          869      869           
  Misses        198      198           
  Partials       31       31           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/projectmesa/mesa/pull/1925?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa).


Patch:
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 00000000..0277adf3
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,34 @@
+# Docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
+changelog:
+  exclude:
+    labels:
+      - ignore-for-release
+  categories:
+    - title: ⚠️ Breaking changes
+      labels:
+        - breaking
+    - title: 🧪 Experimental features
+      labels:
+        - experimental
+    - title: 🎉 New features added
+      labels:
+        - feature
+    - title: 🛠 Enhancements made
+      labels:
+        - enhancement
+    - title: 🐛 Bugs fixed
+      labels:
+        - bug
+    - title: 📜 Documentation improvements
+      labels:
+        - docs
+    - title: 🔧 Maintenance
+      labels:
+        - ci
+        - testing
+        - dependency
+        - maintenance
+        - packaging
+    - title: Other changes
+      labels:
+        - "*"
diff --git a/mesa/agent.py b/mesa/agent.py
index 0bb2f218..d62994ea 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -11,6 +11,7 @@ import contextlib
 import operator
 import warnings
 import weakref
+from collections import defaultdict
 from collections.abc import MutableSet, Sequence
 from random import Random
 
@@ -47,13 +48,26 @@ class Agent:
         self.pos: Position | None = None
 
         # register agent
-        self.model._agents[type(self)][self] = None
+        try:
+            self.model._agents[type(self)][self] = None
+        except AttributeError:
+            # model super has not been called
+            self.model._agents = defaultdict(dict)
+            self.model.agentset_experimental_warning_given = False
+
+            warnings.warn(
+                "The Mesa Model class wasn’t initialized. In the future, you need to explicitly initialize the Model by calling super().__init__() on initialization.",
+                FutureWarning,
+                stacklevel=2,
+            )
+
 
     def remove(self) -> None:
         """Remove and delete the agent from the model."""
         with contextlib.suppress(KeyError):
             self.model._agents[type(self)].pop(self)
 
+
     def step(self) -> None:
         """A single step of the agent."""
 
diff --git a/mesa/model.py b/mesa/model.py
index 22e65448..6b5338da 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -85,7 +85,7 @@ class Model:
 
     def get_agents_of_type(self, agenttype: type) -> AgentSet:
         """Retrieves an AgentSet containing all agents of the specified type."""
-        return AgentSet(self._agents[agenttype].values(), self)
+        return AgentSet(self._agents[agenttype].keys(), self)
 
     def run_model(self) -> None:
         """Run the model until the end condition is reached. Overload as
diff --git a/mesa/space.py b/mesa/space.py
index 18cf7314..987afaf9 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -431,6 +431,69 @@ class _Grid:
         self.remove_agent(agent)
         self.place_agent(agent, pos)
 
+    def move_agent_to_one_of(
+        self,
+        agent: Agent,
+        pos: list[Coordinate],
+        selection: str = "random",
+        handle_empty: str | None = None,
+    ) -> None:
+        """
+        Move an agent to one of the given positions.
+
+        Args:
+            agent: Agent object to move. Assumed to have its current location stored in a 'pos' tuple.
+            pos: List of possible positions.
+            selection: String, either "random" (default) or "closest". If "closest" is selected and multiple
+                       cells are the same distance, one is chosen randomly.
+            handle_empty: String, either "warning", "error" or None (default). If "warning" or "error" is selected
+                          and no positions are given (an empty list), a warning or error is raised respectively.
+        """
+        # Only move agent if there are positions given (non-empty list)
+        if pos:
+            if selection == "random":
+                chosen_pos = agent.random.choice(pos)
+            elif selection == "closest":
+                current_pos = agent.pos
+                # Find the closest position without sorting all positions
+                closest_pos = None
+                min_distance = float("inf")
+                for p in pos:
+                    distance = self._distance_squared(p, current_pos)
+                    if distance < min_distance:
+                        min_distance = distance
+                        closest_pos = p
+                chosen_pos = closest_pos
+            else:
+                raise ValueError(
+                    f"Invalid selection method {selection}. Choose 'random' or 'closest'."
+                )
+            #  Move agent to chosen position
+            self.move_agent(agent, chosen_pos)
+
+        # If no positions are given, throw warning/error if selected
+        elif handle_empty == "warning":
+            warn(
+                f"No positions given, could not move agent {agent.unique_id}.",
+                RuntimeWarning,
+                stacklevel=2,
+            )
+        elif handle_empty == "error":
+            raise ValueError(
+                f"No positions given, could not move agent {agent.unique_id}."
+            )
+
+    def _distance_squared(self, pos1: Coordinate, pos2: Coordinate) -> float:
+        """
+        Calculate the squared Euclidean distance between two points for performance.
+        """
+        # Use squared Euclidean distance to avoid sqrt operation
+        dx, dy = abs(pos1[0] - pos2[0]), abs(pos1[1] - pos2[1])
+        if self.torus:
+            dx = min(dx, self.width - dx)
+            dy = min(dy, self.height - dy)
+        return dx**2 + dy**2
+
     def swap_pos(self, agent_a: Agent, agent_b: Agent) -> None:
         """Swap agents positions"""
         agents_no_pos = []


Test patch:
diff --git a/tests/test_space.py b/tests/test_space.py
index f8f2cc94..ab0e0da3 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -327,6 +327,33 @@ class TestSingleGrid(unittest.TestCase):
         assert self.space[initial_pos[0]][initial_pos[1]] is None
         assert self.space[final_pos[0]][final_pos[1]] == _agent
 
+    def test_move_agent_random_selection(self):
+        agent = self.agents[0]
+        possible_positions = [(10, 10), (20, 20), (30, 30)]
+        self.space.move_agent_to_one_of(agent, possible_positions, selection="random")
+        assert agent.pos in possible_positions
+
+    def test_move_agent_closest_selection(self):
+        agent = self.agents[0]
+        agent.pos = (5, 5)
+        possible_positions = [(6, 6), (10, 10), (20, 20)]
+        self.space.move_agent_to_one_of(agent, possible_positions, selection="closest")
+        assert agent.pos == (6, 6)
+
+    def test_move_agent_invalid_selection(self):
+        agent = self.agents[0]
+        possible_positions = [(10, 10), (20, 20), (30, 30)]
+        with self.assertRaises(ValueError):
+            self.space.move_agent_to_one_of(
+                agent, possible_positions, selection="invalid_option"
+            )
+
+    def test_distance_squared(self):
+        pos1 = (3, 4)
+        pos2 = (0, 0)
+        expected_distance_squared = 3**2 + 4**2
+        assert self.space._distance_squared(pos1, pos2) == expected_distance_squared
+
     def test_iter_cell_list_contents(self):
         """
         Test neighborhood retrieval
@@ -350,6 +377,68 @@ class TestSingleGrid(unittest.TestCase):
         assert len(cell_list_4) == 1
 
 
+class TestSingleGridTorus(unittest.TestCase):
+    def setUp(self):
+        self.space = SingleGrid(50, 50, True)  # Torus is True here
+        self.agents = []
+        for i, pos in enumerate(TEST_AGENTS_GRID):
+            a = MockAgent(i, None)
+            self.agents.append(a)
+            self.space.place_agent(a, pos)
+
+    def test_move_agent_random_selection(self):
+        agent = self.agents[0]
+        possible_positions = [(49, 49), (1, 1), (25, 25)]
+        self.space.move_agent_to_one_of(agent, possible_positions, selection="random")
+        assert agent.pos in possible_positions
+
+    def test_move_agent_closest_selection(self):
+        agent = self.agents[0]
+        agent.pos = (0, 0)
+        possible_positions = [(3, 3), (49, 49), (25, 25)]
+        self.space.move_agent_to_one_of(agent, possible_positions, selection="closest")
+        # Expecting (49, 49) to be the closest in a torus grid
+        assert agent.pos == (49, 49)
+
+    def test_move_agent_invalid_selection(self):
+        agent = self.agents[0]
+        possible_positions = [(10, 10), (20, 20), (30, 30)]
+        with self.assertRaises(ValueError):
+            self.space.move_agent_to_one_of(
+                agent, possible_positions, selection="invalid_option"
+            )
+
+    def test_move_agent_empty_list(self):
+        agent = self.agents[0]
+        possible_positions = []
+        agent.pos = (3, 3)
+        self.space.move_agent_to_one_of(agent, possible_positions, selection="random")
+        assert agent.pos == (3, 3)
+
+    def test_move_agent_empty_list_warning(self):
+        agent = self.agents[0]
+        possible_positions = []
+        # Should assert RuntimeWarning
+        with self.assertWarns(RuntimeWarning):
+            self.space.move_agent_to_one_of(
+                agent, possible_positions, selection="random", handle_empty="warning"
+            )
+
+    def test_move_agent_empty_list_error(self):
+        agent = self.agents[0]
+        possible_positions = []
+        with self.assertRaises(ValueError):
+            self.space.move_agent_to_one_of(
+                agent, possible_positions, selection="random", handle_empty="error"
+            )
+
+    def test_distance_squared_torus(self):
+        pos1 = (0, 0)
+        pos2 = (49, 49)
+        expected_distance_squared = 1**2 + 1**2  # In torus, these points are close
+        assert self.space._distance_squared(pos1, pos2) == expected_distance_squared
+
+
 class TestSingleNetworkGrid(unittest.TestCase):
     GRAPH_SIZE = 10