Repository: projectmesa/mesa

Issue:
Add a common interface to all spaces for getting agents
**What's the problem this feature will solve?**
Depending on the type of space, how you have to get the agents in the space differs. This is shown below with pseudo code and represents my current best guess. This makes the plotting of spaces much more complicated than it needs to be because it requires you to write custom agent getters for at least 4 cases.

```python

old_style_grids = _Grid()  # and thus all its subclasses
coordinates = [(x,y) for x in range(self.height)] for y in range(self.width)]
agents = list(old_style_grids.iter_cell_list_contents(coordinates))

cont_space = ContinuousSpace()
agents = [entry for entry in cont_space._agent_to_index]

old_style_network = NetworkGrid()
agents = list(old_style_network.iter_cell_list_contents(old_style_network.nodes))  # my best guess ATM, I might be wrong

new_style_spaces = DiscreteGrid()   # and thus all its subclasses
agents =list(new_style_spaces.all_cells.agents)

```

**Describe the solution you'd like**
I propose to add an `agents` property to all spaces (new and old) that returns an iterator over the agents in the space. This would enable cleaning up the matplotlib and altair code for plotting spaces quite a bit. Implementing this would be a stepping stone in in addressing #2401.




Discussion:
Do you want only the agents, or also an identifier/coordinate in this iterator in a tuple or dict form? In the former case I’m curious how this would differ from model.agents in most cases, and in the latter, we should think a bit about its structure.
> Do you want only the agents, or also an identifier/coordinate in this iterator in a tuple or dict form? In the former case I’m curious how this would differ from model.agents in most cases, and in the latter, we should think a bit about its structure.

I thought about having an iterator that would return `(x, y), Agent` and could be convinced that doing so is better. I initially decided against it because agents in networks don't have x,y, coordinates but a node_id (true for both old style and new style networks), and you don't necessarily use this just for visualization. 

I deliberately did not want to go the `model.agents` route because it would hard code that models can only have a single space. I, however, want to keep open the possibility of [building this type of model](https://www.youtube.com/watch?v=gxAaO2rsdIs) in MESA (which I did in 2020 when first learning MESA). 
What about returning an iterator with `(something, agent)` tuples? Then something can be space specific, a coordinate where applicable but a node id for Network etc. If you only use the agents, you can just do `_, agent`.

I agree multiple grid support should be designed for.
You know what, I’m pivoting on this. `.agents` return should be consistent in Mesa, so I would suggest it only returning the agents, but in an AgentSet, just like `model.agents`.

If we want to add something with coordinates/identifiers we can always do that separately.
I am still not convinced. At a minimum, it would imply a weird mismatch between the name of the property `agents` and what is returned. Moreover, I can see this agent property being broadly useful, so why design it for the specific use case of getting the coordinates and the agent as used in the solara plotting context? With this property in place, you can do


```python

# if you need the coordinates
for agent in space.agents:
    match agent:
        case CellAgent():
            pos = agent.cell.coordinate
        case _:
            pos = agent.pos

# some other purpose
agents = AgentSet(space.agents, random=self.random)

```

and move on depending on what you are trying to achieve. 
`.agent_locations` could return a dict, `Agent: location`
`.location_agents` could return a dict, `location: [agent, agent, …]`

in both location can be space specific, either a coordinate or a identifier.
> You know what, I’m pivoting on this. .agents return should be consistent in Mesa, so I would suggest it only returning the agents, but in an AgentSet, just like model.agents.

Fair point. I'll give that a go. I was hesitant about this in the first place because of performance concerns based on some tests I have done on `Cell.agents`, but that won't really apply here. 

> .agent_locations could return a dict, Agent: location
> .location_agents could return a dict, location: [agent, agent, …]
>
> in both location can be space specific, either a coordinate or a identifier.

I like the general idea of this but I want to hold of on that for now. In preperation for 3.1, I would like to refactor ContinuousSpace and add the agent movement as mixins analous to how its done with `FixedAgent` and `CellAgent`. That would be a good moment to look at some kind of Locatable mixin that would return e.g., `x`, `y`, `position`. An idea like this could be build on that. Or with `space.agents` returning an agentset, you can simply do `space.agents.get(['x', 'y'])`. 



Patch:
diff --git a/mesa/experimental/cell_space/discrete_space.py b/mesa/experimental/cell_space/discrete_space.py
index 7ed06a84aae..e30d913b23f 100644
--- a/mesa/experimental/cell_space/discrete_space.py
+++ b/mesa/experimental/cell_space/discrete_space.py
@@ -7,6 +7,7 @@
 from random import Random
 from typing import Any, Generic, TypeVar
 
+from mesa.agent import AgentSet
 from mesa.experimental.cell_space.cell import Cell
 from mesa.experimental.cell_space.cell_collection import CellCollection
 from mesa.space import PropertyLayer
@@ -55,6 +56,11 @@ def __init__(
     def cutoff_empties(self):  # noqa
         return 7.953 * len(self._cells) ** 0.384
 
+    @property
+    def agents(self) -> AgentSet:
+        """Return an AgentSet with the agents in the space."""
+        return AgentSet(self.all_cells.agents, random=self.random)
+
     def _connect_cells(self): ...
     def _connect_single_cell(self, cell: T): ...
 
diff --git a/mesa/space.py b/mesa/space.py
index e3991fcc2d0..df501b8639c 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -43,7 +43,7 @@
 import numpy.typing as npt
 
 # For Mypy
-from .agent import Agent
+from .agent import Agent, AgentSet
 
 # for better performance, we calculate the tuple to use in the is_integer function
 _types_integer = (int, np.integer)
@@ -164,6 +164,26 @@ def build_empties(self) -> None:
     @overload
     def __getitem__(self, index: int | Sequence[Coordinate]) -> list[GridContent]: ...
 
+    @property
+    def agents(self) -> AgentSet:
+        """Return an AgentSet with the agents in the space."""
+        agents = []
+        for entry in self:
+            if not entry:
+                continue
+            if not isinstance(entry, list):
+                entry = [entry]  # noqa PLW2901
+            for agent in entry:
+                agents.append(agent)
+
+        # getting the rng is a bit hacky because old style spaces don't have the rng
+        try:
+            rng = agents[0].random
+        except IndexError:
+            # there are no agents in the space
+            rng = None
+        return AgentSet(agents, random=rng)
+
     @overload
     def __getitem__(
         self, index: tuple[int | slice, int | slice]
@@ -1344,6 +1364,19 @@ def __init__(
         self._index_to_agent: dict[int, Agent] = {}
         self._agent_to_index: dict[Agent, int | None] = {}
 
+    @property
+    def agents(self) -> AgentSet:
+        """Return an AgentSet with the agents in the space."""
+        agents = list(self._agent_to_index)
+
+        # getting the rng is a bit hacky because old style spaces don't have the rng
+        try:
+            rng = agents[0].random
+        except IndexError:
+            # there are no agents in the space
+            rng = None
+        return AgentSet(agents, random=rng)
+
     def _build_agent_cache(self):
         """Cache agents positions to speed up neighbors calculations."""
         self._index_to_agent = {}
@@ -1517,6 +1550,27 @@ def __init__(self, g: Any) -> None:
         for node_id in self.G.nodes:
             g.nodes[node_id]["agent"] = self.default_val()
 
+    @property
+    def agents(self) -> AgentSet:
+        """Return an AgentSet with the agents in the space."""
+        agents = []
+        for node_id in self.G.nodes:
+            entry = self.G.nodes[node_id]["agent"]
+            if not entry:
+                continue
+            if not isinstance(entry, list):
+                entry = [entry]
+            for agent in entry:
+                agents.append(agent)
+
+        # getting the rng is a bit hacky because old style spaces don't have the rng
+        try:
+            rng = agents[0].random
+        except IndexError:
+            # there are no agents in the space
+            rng = None
+        return AgentSet(agents, random=rng)
+
     @staticmethod
     def default_val() -> list:
         """Default value for a new node."""


Test patch:
diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py
index fd6f946902f..13159784c22 100644
--- a/tests/test_cell_space.py
+++ b/tests/test_cell_space.py
@@ -439,9 +439,29 @@ def test_empties_space():
     for i in range(8):
         grid._cells[i].add_agent(CellAgent(model))
 
+
+def test_agents_property():
+    """Test empties method for Discrete Spaces."""
+    import networkx as nx
+
+    n = 10
+    m = 20
+    seed = 42
+    G = nx.gnm_random_graph(n, m, seed=seed)  # noqa: N806
+    grid = Network(G)
+
+    model = Model()
+    for i in range(8):
+        grid._cells[i].add_agent(CellAgent(model))
+
     cell = grid.select_random_empty_cell()
     assert cell.coordinate in {8, 9}
 
+    assert len(grid.agents) == 8
+
+    for i, j in enumerate(sorted(grid.agents.get("unique_id"))):
+        assert (i + 1) == j  # unique_id starts from 1
+
 
 def test_cell():
     """Test Cell class."""
diff --git a/tests/test_space.py b/tests/test_space.py
index d8d537bcf31..dd9b335ce54 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -198,6 +198,13 @@ def setUp(self):
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
+    def test_agents_property(self):
+        """Test whether space.agents returns correct agents."""
+        for i, agent in enumerate(self.space.agents):
+            self.assertEqual(i, agent.unique_id)
+
+        self.assertEqual(len(REMOVAL_TEST_AGENTS), len(self.space.agents))
+
     def test_remove_first(self):
         """Test removing the first entry."""
         agent_to_remove = self.agents[0]
@@ -403,6 +410,13 @@ def setUp(self):  # noqa: D102
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
+    def test_agents_property(self):
+        """Test whether space.agents returns correct agents."""
+        for i, agent in enumerate(self.space.agents):
+            self.assertEqual(i, agent.unique_id)
+
+        self.assertEqual(len(TEST_AGENTS_GRID), len(self.space.agents))
+
     def test_agent_positions(self):
         """Ensure that the agents are all placed properly."""
         for i, pos in enumerate(TEST_AGENTS_GRID):
@@ -827,6 +841,13 @@ def setUp(self):
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
+    def test_agents_property(self):
+        """Test whether space.agents returns correct agents."""
+        for i, agent in enumerate(self.space.agents):
+            self.assertEqual(i, agent.unique_id)
+
+        self.assertEqual(len(TEST_AGENTS_NETWORK_SINGLE), len(self.space.agents))
+
     def test_agent_positions(self):
         """Ensure that the agents are all placed properly."""
         for i, pos in enumerate(TEST_AGENTS_NETWORK_SINGLE):