Repository: projectmesa/mesa
Pull request #1914 (2023-12-18 22:54:25)

build(deps): bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's releases</a>.</em></p>
<blockquote>
<h2>v4.0.0</h2>
<h2>What's Changed</h2>
<p>The release of upload-artifact@v4 and download-artifact@v4 are major changes to the backend architecture of Artifacts. They have numerous performance and behavioral improvements.</p>
<p>For more information, see the <a href="https://github.com/actions/toolkit/tree/main/packages/artifact"><code>@​actions/artifact</code></a> documentation.</p>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/vmjoseph"><code>@​vmjoseph</code></a> made their first contribution in <a href="https://redirect.github.com/actions/upload-artifact/pull/464">actions/upload-artifact#464</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/upload-artifact/compare/v3...v4.0.0">https://github.com/actions/upload-artifact/compare/v3...v4.0.0</a></p>
<h2>v3.1.3</h2>
<h2>What's Changed</h2>
<ul>
<li>chore(github): remove trailing whitespaces by <a href="https://github.com/ljmf00"><code>@​ljmf00</code></a> in <a href="https://redirect.github.com/actions/upload-artifact/pull/313">actions/upload-artifact#313</a></li>
<li>Bump <code>@​actions/artifact</code> version to v1.1.2 by <a href="https://github.com/bethanyj28"><code>@​bethanyj28</code></a> in <a href="https://redirect.github.com/actions/upload-artifact/pull/436">actions/upload-artifact#436</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/upload-artifact/compare/v3...v3.1.3">https://github.com/actions/upload-artifact/compare/v3...v3.1.3</a></p>
<h2>v3.1.2</h2>
<ul>
<li>Update all <code>@actions/*</code> NPM packages to their latest versions- <a href="https://redirect.github.com/actions/upload-artifact/issues/374">#374</a></li>
<li>Update all dev dependencies to their most recent versions - <a href="https://redirect.github.com/actions/upload-artifact/issues/375">#375</a></li>
</ul>
<h2>v3.1.1</h2>
<ul>
<li>Update actions/core package to latest version to remove <code>set-output</code> deprecation warning <a href="https://redirect.github.com/actions/upload-artifact/issues/351">#351</a></li>
</ul>
<h2>v3.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump <code>@​actions/artifact</code> to v1.1.0 (<a href="https://redirect.github.com/actions/upload-artifact/pull/327">actions/upload-artifact#327</a>)
<ul>
<li>Adds checksum headers on artifact upload (<a href="https://redirect.github.com/actions/toolkit/pull/1095">actions/toolkit#1095</a>) (<a href="https://redirect.github.com/actions/toolkit/pull/1063">actions/toolkit#1063</a>)</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/actions/upload-artifact/commit/c7d193f32edcb7bfad88892161225aeda64e9392"><code>c7d193f</code></a> Merge pull request <a href="https://redirect.github.com/actions/upload-artifact/issues/466">#466</a> from actions/v4-beta</li>
<li><a href="https://github.com/actions/upload-artifact/commit/13131bb095770b4070a7477c3cd2d96e1c16d9f4"><code>13131bb</code></a> licensed cache</li>
<li><a href="https://github.com/actions/upload-artifact/commit/4a6c273b9834f66a1d05c170dc3f80f9cdb9def1"><code>4a6c273</code></a> Merge branch 'main' into v4-beta</li>
<li><a href="https://github.com/actions/upload-artifact/commit/f391bb91a3d3118aeca171c365bb319ece276b37"><code>f391bb9</code></a> Merge pull request <a href="https://redirect.github.com/actions/upload-artifact/issues/465">#465</a> from actions/robherley/v4-documentation</li>
<li><a href="https://github.com/actions/upload-artifact/commit/9653d03c4b74c32144e02dae644fea70e079d4b3"><code>9653d03</code></a> Apply suggestions from code review</li>
<li><a href="https://github.com/actions/upload-artifact/commit/875b63076402f25ef9d52c294c86ba4f97810575"><code>875b630</code></a> add limitations section</li>
<li><a href="https://github.com/actions/upload-artifact/commit/ecb21463e93740a6be75c3116242169bfdbcb15a"><code>ecb2146</code></a> add compression example</li>
<li><a href="https://github.com/actions/upload-artifact/commit/5e7604f84a055838f64ed68bb9904751523081ae"><code>5e7604f</code></a> trim some repeated info</li>
<li><a href="https://github.com/actions/upload-artifact/commit/d6437d07581fe318a364512e6cf6b1dca6b4f92c"><code>d6437d0</code></a> naming</li>
<li><a href="https://github.com/actions/upload-artifact/commit/1b561557037b4957d7d184e9aac02bec86c771eb"><code>1b56155</code></a> s/v4-beta/v4/g</li>
<li>Additional commits viewable in <a href="https://github.com/actions/upload-artifact/compare/v3...v4">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/projectmesa/mesa/pull/1914?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 [(`9495a5a`)](https://app.codecov.io/gh/projectmesa/mesa/commit/9495a5acb6256104e95b02c71f6a8eecffb412f1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) 77.53% compared to head [(`6e20d8c`)](https://app.codecov.io/gh/projectmesa/mesa/pull/1914?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) 77.53%.


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


```diff
@@           Coverage Diff           @@
##             main    #1914   +/-   ##
=======================================
  Coverage   77.53%   77.53%           
=======================================
  Files          15       15           
  Lines        1015     1015           
  Branches      221      221           
=======================================
  Hits          787      787           
  Misses        197      197           
  Partials       31       31           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/projectmesa/mesa/pull/1914?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/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml
index f39c6da8..d4e1a9d7 100644
--- a/.github/workflows/build_lint.yml
+++ b/.github/workflows/build_lint.yml
@@ -38,8 +38,6 @@ jobs:
             python-version: "3.10"
           - os: ubuntu
             python-version: "3.9"
-          - os: ubuntu
-            python-version: "3.8"
           # Disabled for now. See https://github.com/projectmesa/mesa/issues/1253
           #- os: ubuntu
           #  python-version: 'pypy-3.8'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 09a0e36e..8d32eb7a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -35,7 +35,7 @@ jobs:
         run: python -m build
       - name: Upload package as artifact to GitHub
         if: github.repository == 'projectmesa/mesa' && startsWith(github.ref, 'refs/tags')
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: package
           path: dist/
diff --git a/HISTORY.md b/HISTORY.md
index 50aef1e9..3a3f9aa5 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -2,6 +2,44 @@
 title: Release History
 ---
 
+# 2.2.0 (2024-01-09)
+
+# Highlights
+This release incorporates several majors changes to Mesa. Notably, this provides two experimental features. (1) It reimagines Mesa's agent handling to have agents in the model class and store them in sets (you may submit feedback at https://github.com/projectmesa/mesa/discussions/1919). The intent is to make Mesa more efficient and give users more control over agent scheduling to allow for innumerable options. (2) It adds property layer and property grid so users can layer on multiple grids making Mesa and Geo-Mesa more similar (you may submit feedback at https://github.com/projectmesa/mesa/discussions/1932).
+
+# What's Changed
+
+## 🧪 Experimental features
+* Introduce AgentSet class by @EwoutH in https://github.com/projectmesa/mesa/pull/1916
+* Reimplement schedulers to use AgentSet by @quaquel in https://github.com/projectmesa/mesa/pull/1926
+* Work around for initializing model._agent by @quaquel in https://github.com/projectmesa/mesa/pull/1928
+* space: Implement PropertyLayer and _PropertyGrid by @EwoutH in https://github.com/projectmesa/mesa/pull/1898
+
+## 🛠 Enhancements made
+* Native support for multiple agent types by @EwoutH in https://github.com/projectmesa/mesa/pull/1894
+* Document empties property by @EwoutH in https://github.com/projectmesa/mesa/pull/1888
+* Add DiscreteEventScheduler by @EwoutH in https://github.com/projectmesa/mesa/pull/1890
+* space: Let move_agent choose from multiple positions by @EwoutH in https://github.com/projectmesa/mesa/pull/1920
+
+## 🐛 Bugs fixed
+* Honor disabled space drawer option when rendering in the browser by @rlskoeser in https://github.com/projectmesa/mesa/pull/1907
+
+## 📜 Documentation improvements
+* docs: Fix README.md inline code formatting by @rht in https://github.com/projectmesa/mesa/pull/1887
+* Add experimental warning to DiscreteEventScheduler by @EwoutH in https://github.com/projectmesa/mesa/pull/1924
+
+## 🔧 Maintenance
+* ci: Speed up pip install by caching deps install output by @rht in https://github.com/projectmesa/mesa/pull/1885
+* [pre-commit.ci] pre-commit autoupdate by @pre-commit-ci in https://github.com/projectmesa/mesa/pull/1899
+* build(deps): bump actions/setup-python from 4 to 5 by @dependabot in https://github.com/projectmesa/mesa/pull/1904
+* Create release.yml file for automatic release notes generation by @EwoutH in https://github.com/projectmesa/mesa/pull/1925
+* Drop support for Python 3.8 by @rht in https://github.com/projectmesa/mesa/pull/1756
+
+## New Contributors
+* @quaquel made their first contribution in https://github.com/projectmesa/mesa/pull/1928
+
+**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.1.5...v2.2.0
+
 # 2.1.5 (2023-11-26)
 
 This release has some critical fixes to JupyterViz/Solara frontend to prevent
diff --git a/docs/tutorials/intro_tutorial.ipynb b/docs/tutorials/intro_tutorial.ipynb
index a77f4a13..f130dbcc 100644
--- a/docs/tutorials/intro_tutorial.ipynb
+++ b/docs/tutorials/intro_tutorial.ipynb
@@ -60,7 +60,7 @@
    "source": [
     "### Tutorial Setup\n",
     "\n",
-    "Create and activate a [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs/). *Python version 3.8 or higher is required*.\n",
+    "Create and activate a [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs/). *Python version 3.9 or higher is required*.\n",
     "\n",
     "Install Mesa:\n",
     "\n",
diff --git a/mesa/__init__.py b/mesa/__init__.py
index e047e84e..e27d7de1 100644
--- a/mesa/__init__.py
+++ b/mesa/__init__.py
@@ -25,7 +25,7 @@ __all__ = [
 ]
 
 __title__ = "mesa"
-__version__ = "2.1.5"
+__version__ = "2.3.0-dev"
 __license__ = "Apache 2.0"
 _this_year = datetime.datetime.now(tz=datetime.timezone.utc).date().year
 __copyright__ = f"Copyright {_this_year} Project Mesa Team"
diff --git a/mesa/agent.py b/mesa/agent.py
index e29b233c..0b204c22 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -7,10 +7,16 @@ Core Objects: Agent
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
 
+import contextlib
+import operator
+import warnings
+import weakref
+from collections import defaultdict
+from collections.abc import Iterable, Iterator, MutableSet, Sequence
 from random import Random
 
 # mypy
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any, Callable
 
 if TYPE_CHECKING:
     # We ensure that these are not imported during runtime to prevent cyclic
@@ -41,12 +47,24 @@ class Agent:
         self.model = model
         self.pos: Position | None = None
 
-        # Register the agent with the model using defaultdict
-        self.model.agents[type(self)][self] = None
+        # register agent
+        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 was not 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."""
-        self.model.agents[type(self)].pop(self)
+        with contextlib.suppress(KeyError):
+            self.model._agents[type(self)].pop(self)
 
     def step(self) -> None:
         """A single step of the agent."""
@@ -57,3 +75,281 @@ class Agent:
     @property
     def random(self) -> Random:
         return self.model.random
+
+
+class AgentSet(MutableSet, Sequence):
+    """
+    .. warning::
+        The AgentSet is experimental. It may be changed or removed in any and all future releases, including
+        patch releases.
+        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/1919
+
+    A collection class that represents an ordered set of agents within an agent-based model (ABM). This class
+    extends both MutableSet and Sequence, providing set-like functionality with order preservation and
+    sequence operations.
+
+    Attributes:
+        model (Model): The ABM model instance to which this AgentSet belongs.
+
+    Methods:
+        __len__, __iter__, __contains__, select, shuffle, sort, _update, do, get, __getitem__,
+        add, discard, remove, __getstate__, __setstate__, random
+
+    Note:
+        The AgentSet maintains weak references to agents, allowing for efficient management of agent lifecycles
+        without preventing garbage collection. It is associated with a specific model instance, enabling
+        interactions with the model's environment and other agents.The implementation uses a WeakKeyDictionary to store agents,
+        which means that agents not referenced elsewhere in the program may be automatically removed from the AgentSet.
+    """
+
+    agentset_experimental_warning_given = False
+
+    def __init__(self, agents: Iterable[Agent], model: Model):
+        """
+        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.
+        """
+        self.model = model
+
+        if not self.__class__.agentset_experimental_warning_given:
+            self.__class__.agentset_experimental_warning_given = True
+            warnings.warn(
+                "The AgentSet is experimental. It may be changed or removed in any and all future releases, including patch releases.\n"
+                "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/1919",
+                FutureWarning,
+                stacklevel=2,
+            )
+
+        self._agents = weakref.WeakKeyDictionary()
+        for agent in agents:
+            self._agents[agent] = None
+
+    def __len__(self) -> int:
+        """Return the number of agents in the AgentSet."""
+        return len(self._agents)
+
+    def __iter__(self) -> Iterator[Agent]:
+        """Provide an iterator over the agents in the AgentSet."""
+        return self._agents.keys()
+
+    def __contains__(self, agent: Agent) -> bool:
+        """Check if an agent is in the AgentSet. Can be used like `agent in agentset`."""
+        return agent in self._agents
+
+    def select(
+        self,
+        filter_func: Callable[[Agent], bool] | None = None,
+        n: int = 0,
+        inplace: bool = False,
+        agent_type: type[Agent] | None = None,
+    ) -> AgentSet:
+        """
+        Select a subset of agents from the AgentSet based on a filter function and/or quantity limit.
+
+        Args:
+            filter_func (Callable[[Agent], bool], optional): A function that takes an Agent and returns True if the
+                agent should be included in the result. Defaults to None, meaning no filtering is applied.
+            n (int, optional): The number of agents to select. If 0, all matching agents are selected. Defaults to 0.
+            inplace (bool, optional): If True, modifies the current AgentSet; otherwise, returns a new AgentSet. Defaults to False.
+            agent_type (type[Agent], optional): The class type of the agents to select. Defaults to None, meaning no type filtering is applied.
+
+        Returns:
+            AgentSet: A new AgentSet containing the selected agents, unless inplace is True, in which case the current AgentSet is updated.
+        """
+
+        def agent_generator():
+            count = 0
+            for agent in self:
+                if filter_func and not filter_func(agent):
+                    continue
+                if agent_type and not isinstance(agent, agent_type):
+                    continue
+                yield agent
+                count += 1
+                # default of n is zero, zo evaluates to False
+                if n and count >= n:
+                    break
+
+        agents = agent_generator()
+
+        return AgentSet(agents, self.model) if not inplace else self._update(agents)
+
+    def shuffle(self, inplace: bool = False) -> AgentSet:
+        """
+        Randomly shuffle the order of agents in the AgentSet.
+
+        Args:
+            inplace (bool, optional): If True, shuffles the agents in the current AgentSet; otherwise, returns a new shuffled AgentSet. Defaults to False.
+
+        Returns:
+            AgentSet: A shuffled AgentSet. Returns the current AgentSet if inplace is True.
+        """
+        shuffled_agents = list(self)
+        self.random.shuffle(shuffled_agents)
+
+        return (
+            AgentSet(shuffled_agents, self.model)
+            if not inplace
+            else self._update(shuffled_agents)
+        )
+
+    def sort(
+        self,
+        key: Callable[[Agent], Any] | str,
+        ascending: bool = False,
+        inplace: bool = False,
+    ) -> AgentSet:
+        """
+        Sort the agents in the AgentSet based on a specified attribute or custom function.
+
+        Args:
+            key (Callable[[Agent], Any] | str): A function or attribute name based on which the agents are sorted.
+            ascending (bool, optional): If True, the agents are sorted in ascending order. Defaults to False.
+            inplace (bool, optional): If True, sorts the agents in the current AgentSet; otherwise, returns a new sorted AgentSet. Defaults to False.
+
+        Returns:
+            AgentSet: A sorted AgentSet. Returns the current AgentSet if inplace is True.
+        """
+        if isinstance(key, str):
+            key = operator.attrgetter(key)
+
+        sorted_agents = sorted(self._agents.keys(), key=key, reverse=not ascending)
+
+        return (
+            AgentSet(sorted_agents, self.model)
+            if not inplace
+            else self._update(sorted_agents)
+        )
+
+    def _update(self, agents: Iterable[Agent]):
+        """Update the AgentSet with a new set of agents.
+        This is a private method primarily used internally by other methods like select, shuffle, and sort.
+        """
+        _agents = weakref.WeakKeyDictionary()
+        for agent in agents:
+            _agents[agent] = None
+
+        self._agents = _agents
+        return self
+
+    def do(
+        self, method_name: str, *args, return_results: bool = False, **kwargs
+    ) -> AgentSet | list[Any]:
+        """
+        Invoke a method on each agent in the AgentSet.
+
+        Args:
+            method_name (str): The name of the method to call on each agent.
+            return_results (bool, optional): If True, returns the results of the method calls; otherwise, returns the AgentSet itself. Defaults to False, so you can chain method calls.
+            *args: Variable length argument list passed to the method being called.
+            **kwargs: Arbitrary keyword arguments passed to the method being called.
+
+        Returns:
+            AgentSet | list[Any]: The results of the method calls if return_results is True, otherwise the AgentSet itself.
+        """
+        res = [getattr(agent, method_name)(*args, **kwargs) for agent in self._agents]
+
+        return res if return_results else self
+
+    def get(self, attr_name: str) -> list[Any]:
+        """
+        Retrieve a specified attribute from each agent in the AgentSet.
+
+        Args:
+            attr_name (str): The name of the attribute to retrieve from each agent.
+
+        Returns:
+            list[Any]: A list of attribute values from each agent in the set.
+        """
+        return [getattr(agent, attr_name) for agent in self._agents]
+
+    def __getitem__(self, item: int | slice) -> Agent:
+        """
+        Retrieve an agent or a slice of agents from the AgentSet.
+
+        Args:
+            item (int | slice): The index or slice for selecting agents.
+
+        Returns:
+            Agent | list[Agent]: The selected agent or list of agents based on the index or slice provided.
+        """
+        return list(self._agents.keys())[item]
+
+    def add(self, agent: Agent):
+        """
+        Add an agent to the AgentSet.
+
+        Args:
+            agent (Agent): The agent to add to the set.
+
+        Note:
+            This method is an implementation of the abstract method from MutableSet.
+        """
+        self._agents[agent] = None
+
+    def discard(self, agent: Agent):
+        """
+        Remove an agent from the AgentSet if it exists.
+
+        This method does not raise an error if the agent is not present.
+
+        Args:
+            agent (Agent): The agent to remove from the set.
+
+        Note:
+            This method is an implementation of the abstract method from MutableSet.
+        """
+        with contextlib.suppress(KeyError):
+            del self._agents[agent]
+
+    def remove(self, agent: Agent):
+        """
+        Remove an agent from the AgentSet.
+
+        This method raises an error if the agent is not present.
+
+        Args:
+            agent (Agent): The agent to remove from the set.
+
+        Note:
+            This method is an implementation of the abstract method from MutableSet.
+        """
+        del self._agents[agent]
+
+    def __getstate__(self):
+        """
+        Retrieve the state of the AgentSet for serialization.
+
+        Returns:
+            dict: A dictionary representing the state of the AgentSet.
+        """
+        return {"agents": list(self._agents.keys()), "model": self.model}
+
+    def __setstate__(self, state):
+        """
+        Set the state of the AgentSet during deserialization.
+
+        Args:
+            state (dict): A dictionary representing the state to restore.
+        """
+        self.model = state["model"]
+        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
+
+
+# consider adding for performance reasons
+# for Sequence: __reversed__, index, and count
+# for MutableSet clear, pop, remove, __ior__, __iand__, __ixor__, and __isub__
diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py
index c9c52847..aba96855 100644
--- a/mesa/batchrunner.py
+++ b/mesa/batchrunner.py
@@ -1,17 +1,8 @@
 import itertools
+from collections.abc import Iterable, Mapping
 from functools import partial
 from multiprocessing import Pool
-from typing import (
-    Any,
-    Dict,
-    Iterable,
-    List,
-    Mapping,
-    Optional,
-    Tuple,
-    Type,
-    Union,
-)
+from typing import Any, Optional, Union
 
 from tqdm.auto import tqdm
 
@@ -19,7 +10,7 @@ from mesa.model import Model
 
 
 def batch_run(
-    model_cls: Type[Model],
+    model_cls: type[Model],
     parameters: Mapping[str, Union[Any, Iterable[Any]]],
     # We still retain the Optional[int] because users may set it to None (i.e. use all CPUs)
     number_processes: Optional[int] = 1,
@@ -27,7 +18,7 @@ def batch_run(
     data_collection_period: int = -1,
     max_steps: int = 1000,
     display_progress: bool = True,
-) -> List[Dict[str, Any]]:
+) -> list[dict[str, Any]]:
     """Batch run a mesa model with a set of parameter values.
 
     Parameters
@@ -67,7 +58,7 @@ def batch_run(
         data_collection_period=data_collection_period,
     )
 
-    results: List[Dict[str, Any]] = []
+    results: list[dict[str, Any]] = []
 
     with tqdm(total=len(runs_list), disable=not display_progress) as pbar:
         if number_processes == 1:
@@ -85,8 +76,8 @@ def batch_run(
 
 
 def _make_model_kwargs(
-    parameters: Mapping[str, Union[Any, Iterable[Any]]]
-) -> List[Dict[str, Any]]:
+    parameters: Mapping[str, Union[Any, Iterable[Any]]],
+) -> list[dict[str, Any]]:
     """Create model kwargs from parameters dictionary.
 
     Parameters
@@ -116,11 +107,11 @@ def _make_model_kwargs(
 
 
 def _model_run_func(
-    model_cls: Type[Model],
-    run: Tuple[int, int, Dict[str, Any]],
+    model_cls: type[Model],
+    run: tuple[int, int, dict[str, Any]],
     max_steps: int,
     data_collection_period: int,
-) -> List[Dict[str, Any]]:
+) -> list[dict[str, Any]]:
     """Run a single model run and collect model and agent data.
 
     Parameters
@@ -185,7 +176,7 @@ def _model_run_func(
 def _collect_data(
     model: Model,
     step: int,
-) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
     """Collect model and agent data from a model using mesas datacollector."""
     dc = model.datacollector
 
diff --git a/mesa/datacollection.py b/mesa/datacollection.py
index 6eecd989..88f1863f 100644
--- a/mesa/datacollection.py
+++ b/mesa/datacollection.py
@@ -34,11 +34,13 @@ The default DataCollector here makes several assumptions:
     * The schedule has an agent list called agents
     * For collecting agent-level variables, agents must have a unique_id
 """
+import contextlib
 import itertools
 import types
 from functools import partial
 
-import pandas as pd
+with contextlib.suppress(ImportError):
+    import pandas as pd
 
 
 class DataCollector:
diff --git a/mesa/experimental/jupyter_viz.py b/mesa/experimental/jupyter_viz.py
index 3f4dd5ab..f4ac1271 100644
--- a/mesa/experimental/jupyter_viz.py
+++ b/mesa/experimental/jupyter_viz.py
@@ -1,6 +1,6 @@
 import sys
 import threading
-from typing import List, Optional
+from typing import Optional
 
 import matplotlib.pyplot as plt
 import networkx as nx
@@ -317,7 +317,7 @@ def UserInputs(user_params, on_change=None):
 
 
 @solara.component
-def SpaceMatplotlib(model, agent_portrayal, dependencies: Optional[List[any]] = None):
+def SpaceMatplotlib(model, agent_portrayal, dependencies: Optional[list[any]] = None):
     space_fig = Figure()
     space_ax = space_fig.subplots()
     space = getattr(model, "grid", None)
@@ -406,8 +406,17 @@ def make_plot(model, measure):
     fig = Figure()
     ax = fig.subplots()
     df = model.datacollector.get_model_vars_dataframe()
-    ax.plot(df.loc[:, measure])
-    ax.set_ylabel(measure)
+    if isinstance(measure, str):
+        ax.plot(df.loc[:, measure])
+        ax.set_ylabel(measure)
+    elif isinstance(measure, dict):
+        for m, color in measure.items():
+            ax.plot(df.loc[:, m], label=m, color=color)
+        fig.legend()
+    elif isinstance(measure, (list, tuple)):
+        for m in measure:
+            ax.plot(df.loc[:, m], label=m)
+        fig.legend()
     # Set integer x axis
     ax.xaxis.set_major_locator(MaxNLocator(integer=True))
     solara.FigureMatplotlib(fig)
diff --git a/mesa/model.py b/mesa/model.py
index 96398b44..ca0c653c 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -7,12 +7,14 @@ Core Objects: Model
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
 
+import itertools
 import random
 from collections import defaultdict
 
 # mypy
 from typing import Any
 
+from mesa.agent import Agent, AgentSet
 from mesa.datacollection import DataCollector
 
 
@@ -27,8 +29,20 @@ class Model:
         running: A boolean indicating if the model should continue running.
         schedule: An object to manage the order and execution of agent steps.
         current_id: A counter for assigning unique IDs to agents.
-        agents: A defaultdict mapping each agent type to a dict of its instances.
-                Agent instances are saved in the nested dict keys, with the values being None.
+        _agents: A defaultdict mapping each agent type to a dict of its instances.
+                 This private attribute is used internally to manage agents.
+
+    Properties:
+        agents: An AgentSet containing all agents in the model, generated from the _agents attribute.
+        agent_types: A list of different agent types present in the model.
+
+    Methods:
+        get_agents_of_type: Returns an AgentSet of agents of the specified type.
+        run_model: Runs the model's simulation until a defined end condition is reached.
+        step: Executes a single step of the model's simulation process.
+        next_id: Generates and returns the next unique identifier for an agent.
+        reset_randomizer: Resets the model's random number generator with a new or existing seed.
+        initialize_data_collector: Sets up the data collector for the model, requiring an initialized scheduler and agents.
     """
 
     def __new__(cls, *args: Any, **kwargs: Any) -> Any:
@@ -51,12 +65,27 @@ class Model:
         self.running = True
         self.schedule = None
         self.current_id = 0
-        self.agents: defaultdict[type, dict] = defaultdict(dict)
+        self._agents: defaultdict[type, dict] = defaultdict(dict)
+
+        # Warning flags for current experimental features. These make sure a warning is only printed once per model.
+        self.agentset_experimental_warning_given = False
 
     @property
-    def agent_types(self) -> list:
+    def agents(self) -> AgentSet:
+        """Provides an AgentSet of all agents in the model, combining agents from all types."""
+        all_agents = itertools.chain(
+            *(agents_by_type.keys() for agents_by_type in self._agents.values())
+        )
+        return AgentSet(all_agents, self)
+
+    @property
+    def agent_types(self) -> list[type]:
         """Return a list of different agent types."""
-        return list(self.agents.keys())
+        return list(self._agents.keys())
+
+    def get_agents_of_type(self, agenttype: type[Agent]) -> AgentSet:
+        """Retrieves an AgentSet containing all agents of the specified type."""
+        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..969f1838 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -12,34 +12,25 @@ ContinuousSpace: a two-dimensional space where each agent has an arbitrary
                  position of `float`'s.
 NetworkGrid: a network where each node contains zero or more agents.
 """
-# Instruction for PyLint to suppress variable name errors, since we have a
-# good reason to use one-character variable names for x and y.
-# pylint: disable=invalid-name
 
 # Mypy; for the `|` operator purpose
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
 
 import collections
+import contextlib
+import inspect
 import itertools
 import math
+import warnings
+from collections.abc import Iterable, Iterator, Sequence
 from numbers import Real
-from typing import (
-    Any,
-    Callable,
-    Iterable,
-    Iterator,
-    List,
-    Sequence,
-    Tuple,
-    TypeVar,
-    Union,
-    cast,
-    overload,
-)
+from typing import Any, Callable, TypeVar, Union, cast, overload
 from warnings import warn
 
-import networkx as nx
+with contextlib.suppress(ImportError):
+    import networkx as nx
+
 import numpy as np
 import numpy.typing as npt
 
@@ -49,15 +40,15 @@ from .agent import Agent
 # for better performance, we calculate the tuple to use in the is_integer function
 _types_integer = (int, np.integer)
 
-Coordinate = Tuple[int, int]
+Coordinate = tuple[int, int]
 # used in ContinuousSpace
-FloatCoordinate = Union[Tuple[float, float], npt.NDArray[float]]
+FloatCoordinate = Union[tuple[float, float], npt.NDArray[float]]
 NetworkCoordinate = int
 
 Position = Union[Coordinate, FloatCoordinate, NetworkCoordinate]
 
 GridContent = Union[Agent, None]
-MultiGridContent = List[Agent]
+MultiGridContent = list[Agent]
 
 F = TypeVar("F", bound=Callable[..., Any])
 
@@ -431,6 +422,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 = []
@@ -485,7 +539,424 @@ class _Grid:
         return len(self.empties) > 0
 
 
-class SingleGrid(_Grid):
+def is_single_argument_function(function):
+    """Check if a function is a single argument function."""
+    return (
+        inspect.isfunction(function)
+        and len(inspect.signature(function).parameters) == 1
+    )
+
+
+def ufunc_requires_additional_input(ufunc):
+    # NumPy ufuncs have a 'nargs' attribute indicating the number of input arguments
+    # For binary ufuncs (like np.add), nargs is 2
+    return ufunc.nargs > 1
+
+
+class PropertyLayer:
+    """
+    A class representing a layer of properties in a two-dimensional grid. Each cell in the grid
+    can store a value of a specified data type.
+
+    Attributes:
+        name (str): The name of the property layer.
+        width (int): The width of the grid (number of columns).
+        height (int): The height of the grid (number of rows).
+        data (numpy.ndarray): A NumPy array representing the grid data.
+
+    Methods:
+        set_cell(position, value): Sets the value of a single cell.
+        set_cells(value, condition=None): Sets the values of multiple cells, optionally based on a condition.
+        modify_cell(position, operation, value): Modifies the value of a single cell using an operation.
+        modify_cells(operation, value, condition_function): Modifies the values of multiple cells using an operation.
+        select_cells(condition, return_list): Selects cells that meet a specified condition.
+        aggregate_property(operation): Performs an aggregate operation over all cells.
+    """
+
+    agentset_experimental_warning_given = False
+
+    def __init__(
+        self, name: str, width: int, height: int, default_value, dtype=np.float64
+    ):
+        """
+        Initializes a new PropertyLayer instance.
+
+        Args:
+            name (str): The name of the property layer.
+            width (int): The width of the grid (number of columns). Must be a positive integer.
+            height (int): The height of the grid (number of rows). Must be a positive integer.
+            default_value: The default value to initialize each cell in the grid. Should ideally
+                           be of the same type as specified by the dtype parameter.
+            dtype (data-type, optional): The desired data-type for the grid's elements. Default is np.float64.
+
+        Raises:
+            ValueError: If width or height is not a positive integer.
+
+        Notes:
+            A UserWarning is raised if the default_value is not of a type compatible with dtype.
+            The dtype parameter can accept both Python data types (like bool, int or float) and NumPy data types
+            (like np.int64 or np.float64). Using NumPy data types is recommended (except for bool) for better control
+            over the precision and efficiency of data storage and computations, especially in cases of large data
+            volumes or specialized numerical operations.
+        """
+        self.name = name
+        self.width = width
+        self.height = height
+
+        # Check that width and height are positive integers
+        if (not isinstance(width, int) or width < 1) or (
+            not isinstance(height, int) or height < 1
+        ):
+            raise ValueError(
+                f"Width and height must be positive integers, got {width} and {height}."
+            )
+        # Check if the dtype is suitable for the data
+        if not isinstance(default_value, dtype):
+            warn(
+                f"Default value {default_value} ({type(default_value).__name__}) might not be best suitable with dtype={dtype.__name__}.",
+                UserWarning,
+                stacklevel=2,
+            )
+
+        self.data = np.full((width, height), default_value, dtype=dtype)
+
+        if not self.__class__.agentset_experimental_warning_given:
+            warnings.warn(
+                "The new PropertyLayer and _PropertyGrid classes experimental. It may be changed or removed in any and all future releases, including patch releases.\n"
+                "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",
+                FutureWarning,
+                stacklevel=2,
+            )
+            self.__class__.agentset_experimental_warning_given = True
+
+    def set_cell(self, position: Coordinate, value):
+        """
+        Update a single cell's value in-place.
+        """
+        self.data[position] = value
+
+    def set_cells(self, value, condition=None):
+        """
+        Perform a batch update either on the entire grid or conditionally, in-place.
+
+        Args:
+            value: The value to be used for the update.
+            condition: (Optional) A callable (like a lambda function or a NumPy ufunc)
+                       that returns a boolean array when applied to the data.
+        """
+        if condition is None:
+            np.copyto(self.data, value)  # In-place update
+        else:
+            if isinstance(condition, np.ufunc):
+                # Directly apply NumPy ufunc
+                condition_result = condition(self.data)
+            else:
+                # Vectorize non-ufunc conditions
+                vectorized_condition = np.vectorize(condition)
+                condition_result = vectorized_condition(self.data)
+
+            if (
+                not isinstance(condition_result, np.ndarray)
+                or condition_result.shape != self.data.shape
+            ):
+                raise ValueError(
+                    "Result of condition must be a NumPy array with the same shape as the grid."
+                )
+
+            np.copyto(self.data, value, where=condition_result)
+
+    def modify_cell(self, position: Coordinate, operation, value=None):
+        """
+        Modify a single cell using an operation, which can be a lambda function or a NumPy ufunc.
+        If a NumPy ufunc is used, an additional value should be provided.
+
+        Args:
+            position: The grid coordinates of the cell to modify.
+            operation: A function to apply. Can be a lambda function or a NumPy ufunc.
+            value: The value to be used if the operation is a NumPy ufunc. Ignored for lambda functions.
+        """
+        current_value = self.data[position]
+
+        # Determine if the operation is a lambda function or a NumPy ufunc
+        if is_single_argument_function(operation):
+            # Lambda function case
+            self.data[position] = operation(current_value)
+        elif value is not None:
+            # NumPy ufunc case
+            self.data[position] = operation(current_value, value)
+        else:
+            raise ValueError("Invalid operation or missing value for NumPy ufunc.")
+
+    def modify_cells(self, operation, value=None, condition_function=None):
+        """
+        Modify cells using an operation, which can be a lambda function or a NumPy ufunc.
+        If a NumPy ufunc is used, an additional value should be provided.
+
+        Args:
+            operation: A function to apply. Can be a lambda function or a NumPy ufunc.
+            value: The value to be used if the operation is a NumPy ufunc. Ignored for lambda functions.
+            condition_function: (Optional) A callable that returns a boolean array when applied to the data.
+        """
+        condition_array = np.ones_like(
+            self.data, dtype=bool
+        )  # Default condition (all cells)
+        if condition_function is not None:
+            if isinstance(condition_function, np.ufunc):
+                condition_array = condition_function(self.data)
+            else:
+                vectorized_condition = np.vectorize(condition_function)
+                condition_array = vectorized_condition(self.data)
+
+        # Check if the operation is a lambda function or a NumPy ufunc
+        if isinstance(operation, np.ufunc):
+            if ufunc_requires_additional_input(operation):
+                if value is None:
+                    raise ValueError("This ufunc requires an additional input value.")
+                modified_data = operation(self.data, value)
+            else:
+                modified_data = operation(self.data)
+        else:
+            # Vectorize non-ufunc operations
+            vectorized_operation = np.vectorize(operation)
+            modified_data = vectorized_operation(self.data)
+
+        self.data = np.where(condition_array, modified_data, self.data)
+
+    def select_cells(self, condition, return_list=True):
+        """
+        Find cells that meet a specified condition using NumPy's boolean indexing, in-place.
+
+        Args:
+            condition: A callable that returns a boolean array when applied to the data.
+            return_list: (Optional) If True, return a list of (x, y) tuples. Otherwise, return a boolean array.
+
+        Returns:
+            A list of (x, y) tuples or a boolean array.
+        """
+        condition_array = condition(self.data)
+        if return_list:
+            return list(zip(*np.where(condition_array)))
+        else:
+            return condition_array
+
+    def aggregate_property(self, operation):
+        """Perform an aggregate operation (e.g., sum, mean) on a property across all cells.
+
+        Args:
+            operation: A function to apply. Can be a lambda function or a NumPy ufunc.
+        """
+        return operation(self.data)
+
+
+class _PropertyGrid(_Grid):
+    """
+    A private subclass of _Grid that supports the addition of property layers, enabling
+    the representation and manipulation of additional data layers on the grid. This class is
+    intended for internal use within the Mesa framework and is currently utilized by SingleGrid
+    and MultiGrid classes to provide enhanced grid functionality.
+
+    The `_PropertyGrid` extends the capabilities of a basic grid by allowing each cell
+    to have multiple properties, each represented by a separate PropertyLayer.
+    These properties can be used to model complex environments where each cell
+    has multiple attributes or states.
+
+    Attributes:
+        properties (dict): A dictionary mapping property layer names to PropertyLayer instances.
+        empty_mask: Returns a boolean mask indicating empty cells on the grid.
+
+    Methods:
+        add_property_layer(property_layer): Adds a new property layer to the grid.
+        remove_property_layer(property_name): Removes a property layer from the grid by its name.
+        get_neighborhood_mask(pos, moore, include_center, radius): Generates a boolean mask of the neighborhood.
+        select_cells_by_properties(conditions, mask, return_list): Selects cells based on multiple property conditions,
+            optionally with a mask, returning either a list of coordinates or a mask.
+        select_extreme_value_cells(property_name, mode, mask, return_list): Selects cells with extreme values of a property,
+            optionally with a mask, returning either a list of coordinates or a mask.
+
+    Mask Usage:
+        Several methods in this class accept a mask as an input, which is a NumPy ndarray of boolean values. This mask
+        specifies the cells to be considered (True) or ignored (False) in operations. Users can create custom masks,
+        including neighborhood masks, to apply specific conditions or constraints. Additionally, methods that deal with
+        cell selection or agent movement can return either a list of cell coordinates or a mask, based on the 'return_list'
+        parameter. This flexibility allows for more nuanced control and customization of grid operations, catering to a wide
+        range of modeling requirements and scenarios.
+
+    Note:
+        This class is not intended for direct use in user models but is currently used by the SingleGrid and MultiGrid.
+    """
+
+    def __init__(
+        self,
+        width: int,
+        height: int,
+        torus: bool,
+        property_layers: None | PropertyLayer | list[PropertyLayer] = None,
+    ):
+        """
+        Initializes a new _PropertyGrid instance with specified dimensions and optional property layers.
+
+        Args:
+            width (int): The width of the grid (number of columns).
+            height (int): The height of the grid (number of rows).
+            torus (bool): A boolean indicating if the grid should behave like a torus.
+            property_layers (None | PropertyLayer | list[PropertyLayer], optional): A single PropertyLayer instance,
+                a list of PropertyLayer instances, or None to initialize without any property layers.
+
+        Raises:
+            ValueError: If a property layer's dimensions do not match the grid dimensions.
+        """
+        super().__init__(width, height, torus)
+        self.properties = {}
+
+        # Initialize an empty mask as a boolean NumPy array
+        self._empty_mask = np.ones((self.width, self.height), dtype=bool)
+
+        # Handle both single PropertyLayer instance and list of PropertyLayer instances
+        if property_layers:
+            # If a single PropertyLayer is passed, convert it to a list
+            if isinstance(property_layers, PropertyLayer):
+                property_layers = [property_layers]
+
+            for layer in property_layers:
+                self.add_property_layer(layer)
+
+    @property
+    def empty_mask(self) -> np.ndarray:
+        """
+        Returns a boolean mask indicating empty cells on the grid.
+        """
+        return self._empty_mask
+
+    # Add and remove properties to the grid
+    def add_property_layer(self, property_layer: PropertyLayer):
+        """
+        Adds a new property layer to the grid.
+
+        Args:
+            property_layer (PropertyLayer): The PropertyLayer instance to be added to the grid.
+
+        Raises:
+            ValueError: If a property layer with the same name already exists in the grid.
+            ValueError: If the dimensions of the property layer do not match the grid's dimensions.
+        """
+        if property_layer.name in self.properties:
+            raise ValueError(f"Property layer {property_layer.name} already exists.")
+        if property_layer.width != self.width or property_layer.height != self.height:
+            raise ValueError(
+                f"Property layer dimensions {property_layer.width}x{property_layer.height} do not match grid dimensions {self.width}x{self.height}."
+            )
+        self.properties[property_layer.name] = property_layer
+
+    def remove_property_layer(self, property_name: str):
+        """
+        Removes a property layer from the grid by its name.
+
+        Args:
+            property_name (str): The name of the property layer to be removed.
+
+        Raises:
+            ValueError: If a property layer with the given name does not exist in the grid.
+        """
+        if property_name not in self.properties:
+            raise ValueError(f"Property layer {property_name} does not exist.")
+        del self.properties[property_name]
+
+    def get_neighborhood_mask(
+        self, pos: Coordinate, moore: bool, include_center: bool, radius: int
+    ) -> np.ndarray:
+        """
+        Generate a boolean mask representing the neighborhood.
+        Helper method for select_cells_multi_properties() and move_agent_to_random_cell()
+
+        Args:
+            pos (Coordinate): Center of the neighborhood.
+            moore (bool): True for Moore neighborhood, False for Von Neumann.
+            include_center (bool): Include the central cell in the neighborhood.
+            radius (int): The radius of the neighborhood.
+
+        Returns:
+            np.ndarray: A boolean mask representing the neighborhood.
+        """
+        neighborhood = self.get_neighborhood(pos, moore, include_center, radius)
+        mask = np.zeros((self.width, self.height), dtype=bool)
+
+        # Convert the neighborhood list to a NumPy array and use advanced indexing
+        coords = np.array(neighborhood)
+        mask[coords[:, 0], coords[:, 1]] = True
+        return mask
+
+    def select_cells(
+        self,
+        conditions: dict | None = None,
+        extreme_values: dict | None = None,
+        masks: np.ndarray | list[np.ndarray] = None,
+        only_empty: bool = False,
+        return_list: bool = True,
+    ) -> list[Coordinate] | np.ndarray:
+        """
+        Select cells based on property conditions, extreme values, and/or masks, with an option to only select empty cells.
+
+        Args:
+            conditions (dict): A dictionary where keys are property names and values are callables that return a boolean when applied.
+            extreme_values (dict): A dictionary where keys are property names and values are either 'highest' or 'lowest'.
+            masks (np.ndarray | list[np.ndarray], optional): A mask or list of masks to restrict the selection.
+            only_empty (bool, optional): If True, only select cells that are empty. Default is False.
+            return_list (bool, optional): If True, return a list of coordinates, otherwise return a mask.
+
+        Returns:
+            Union[list[Coordinate], np.ndarray]: Coordinates where conditions are satisfied or the combined mask.
+        """
+        # Initialize the combined mask
+        combined_mask = np.ones((self.width, self.height), dtype=bool)
+
+        # Apply the masks
+        if masks is not None:
+            if isinstance(masks, list):
+                for mask in masks:
+                    combined_mask = np.logical_and(combined_mask, mask)
+            else:
+                combined_mask = np.logical_and(combined_mask, masks)
+
+        # Apply the empty mask if only_empty is True
+        if only_empty:
+            combined_mask = np.logical_and(combined_mask, self.empty_mask)
+
+        # Apply conditions
+        if conditions:
+            for prop_name, condition in conditions.items():
+                prop_layer = self.properties[prop_name].data
+                prop_mask = condition(prop_layer)
+                combined_mask = np.logical_and(combined_mask, prop_mask)
+
+        # Apply extreme values
+        if extreme_values:
+            for property_name, mode in extreme_values.items():
+                prop_values = self.properties[property_name].data
+
+                # Create a masked array using the combined_mask
+                masked_values = np.ma.masked_array(prop_values, mask=~combined_mask)
+
+                if mode == "highest":
+                    target_value = masked_values.max()
+                elif mode == "lowest":
+                    target_value = masked_values.min()
+                else:
+                    raise ValueError(
+                        f"Invalid mode {mode}. Choose from 'highest' or 'lowest'."
+                    )
+
+                extreme_value_mask = prop_values == target_value
+                combined_mask = np.logical_and(combined_mask, extreme_value_mask)
+
+        # Generate output
+        if return_list:
+            selected_cells = list(zip(*np.where(combined_mask)))
+            return selected_cells
+        else:
+            return combined_mask
+
+
+class SingleGrid(_PropertyGrid):
     """Rectangular grid where each cell contains exactly at most one agent.
 
     Grid cells are indexed by [x, y], where [0, 0] is assumed to be the
@@ -513,6 +984,7 @@ class SingleGrid(_Grid):
             self._grid[x][y] = agent
             if self._empties_built:
                 self._empties.discard(pos)
+            self._empty_mask[pos] = False
             agent.pos = pos
         else:
             raise Exception("Cell not empty")
@@ -525,10 +997,11 @@ class SingleGrid(_Grid):
         self._grid[x][y] = self.default_val()
         if self._empties_built:
             self._empties.add(pos)
+        self._empty_mask[agent.pos] = True
         agent.pos = None
 
 
-class MultiGrid(_Grid):
+class MultiGrid(_PropertyGrid):
     """Rectangular grid where each cell can contain more than one agent.
 
     Grid cells are indexed by [x, y], where [0, 0] is assumed to be at
@@ -560,6 +1033,7 @@ class MultiGrid(_Grid):
             agent.pos = pos
             if self._empties_built:
                 self._empties.discard(pos)
+                self._empty_mask[agent.pos] = True
 
     def remove_agent(self, agent: Agent) -> None:
         """Remove the agent from the given location and set its pos attribute to None."""
@@ -568,6 +1042,7 @@ class MultiGrid(_Grid):
         self._grid[x][y].remove(agent)
         if self._empties_built and self.is_cell_empty(pos):
             self._empties.add(pos)
+            self._empty_mask[agent.pos] = False
         agent.pos = None
 
     @accept_tuple_argument
diff --git a/mesa/time.py b/mesa/time.py
index ca54bab7..b93b7863 100644
--- a/mesa/time.py
+++ b/mesa/time.py
@@ -26,12 +26,14 @@ Key concepts:
 from __future__ import annotations
 
 import heapq
-from collections import defaultdict
+import warnings
+import weakref
+from collections.abc import Iterable
 
 # mypy
 from typing import Union
 
-from mesa.agent import Agent
+from mesa.agent import Agent, AgentSet
 from mesa.model import Model
 
 # BaseScheduler has a self.time of int, while
@@ -59,12 +61,25 @@ class BaseScheduler:
         - agents (property): Returns a list of all agent instances.
     """
 
-    def __init__(self, model: Model) -> None:
-        """Create a new, empty BaseScheduler."""
+    def __init__(self, model: Model, agents: Iterable[Agent] | None = None) -> None:
+        """Create a new BaseScheduler.
+
+        Args:
+            model (Model): The model to which the schedule belongs
+            agents (Iterable[Agent], None, optional): An iterable of agents who are controlled by the schedule
+
+        """
         self.model = model
         self.steps = 0
         self.time: TimeT = 0
-        self._agents: dict[int, Agent] = {}
+
+        if agents is None:
+            agents = []
+
+        self._agents: AgentSet = AgentSet(agents, model)
+
+        self._remove_warning_given = False
+        self._agents_key_warning_given = False
 
     def add(self, agent: Agent) -> None:
         """Add an Agent object to the schedule.
@@ -73,20 +88,23 @@ class BaseScheduler:
             agent: An Agent to be added to the schedule. NOTE: The agent must
             have a step() method.
         """
-        if agent.unique_id in self._agents:
-            raise Exception(
-                f"Agent with unique id {agent.unique_id!r} already added to scheduler"
-            )
 
-        self._agents[agent.unique_id] = agent
+        if agent not in self._agents:
+            self._agents.add(agent)
+        else:
+            raise ValueError("agent already added to scheduler")
 
     def remove(self, agent: Agent) -> None:
         """Remove all instances of a given agent from the schedule.
 
+        Note:
+            It is only necessary to explicitly remove agents from the schedule if
+            the agent is not removed from the model.
+
         Args:
             agent: An agent object.
         """
-        del self._agents[agent.unique_id]
+        self._agents.remove(agent)
 
     def step(self) -> None:
         """Execute the step of all the agents, one at a time."""
@@ -101,25 +119,31 @@ class BaseScheduler:
         return len(self._agents)
 
     @property
-    def agents(self) -> list[Agent]:
-        return list(self._agents.values())
+    def agents(self) -> AgentSet:
+        # a bit dirty, but returns a copy of the internal agent set
+        return self._agents.select()
 
     def get_agent_keys(self, shuffle: bool = False) -> list[int]:
         # To be able to remove and/or add agents during stepping
         # it's necessary to cast the keys view to a list.
-        agent_keys = list(self._agents.keys())
+
+        if not self._agents_key_warning_given:
+            self._agents_key_warning_given = True
+            warnings.warn(
+                "Because of the shift to using weakrefs, this method will be removed in a future version",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+
+        agent_keys = [agent.unique_id for agent in self._agents]
         if shuffle:
             self.model.random.shuffle(agent_keys)
         return agent_keys
 
-    def do_each(self, method, agent_keys=None, shuffle=False):
-        if agent_keys is None:
-            agent_keys = self.get_agent_keys()
+    def do_each(self, method, shuffle=False):
         if shuffle:
-            self.model.random.shuffle(agent_keys)
-        for agent_key in agent_keys:
-            if agent_key in self._agents:
-                getattr(self._agents[agent_key], method)()
+            self.agents.shuffle(inplace=True)
+        self.agents.do(method)
 
 
 class RandomActivation(BaseScheduler):
@@ -201,6 +225,7 @@ class StagedActivation(BaseScheduler):
     def __init__(
         self,
         model: Model,
+        agents: Iterable[Agent] | None = None,
         stage_list: list[str] | None = None,
         shuffle: bool = False,
         shuffle_between_stages: bool = False,
@@ -208,15 +233,16 @@ class StagedActivation(BaseScheduler):
         """Create an empty Staged Activation schedule.
 
         Args:
-            - model: Model object associated with the schedule.
-            - stage_list: List of strings of names of stages to run, in the
+            model (Model): The model to which the schedule belongs
+            agents (Iterable[Agent], None, optional): An iterable of agents who are controlled by the schedule
+            stage_list (:obj:`list` of :obj:`str`): List of strings of names of stages to run, in the
                          order to run them in.
-            - shuffle: If True, shuffle the order of agents each step.
-            - shuffle_between_stages: If True, shuffle the agents after each
+            shuffle (bool, optional): If True, shuffle the order of agents each step.
+            shuffle_between_stages (bool, optional): If True, shuffle the agents after each
                                     stage; otherwise, only shuffle at the start
                                     of each step.
         """
-        super().__init__(model)
+        super().__init__(model, agents)
         self.stage_list = stage_list if stage_list else ["step"]
         self.shuffle = shuffle
         self.shuffle_between_stages = shuffle_between_stages
@@ -224,17 +250,14 @@ class StagedActivation(BaseScheduler):
 
     def step(self) -> None:
         """Executes all the stages for all agents."""
-        # To be able to remove and/or add agents during stepping
-        # it's necessary for the keys view to be a list.
-        agent_keys = self.get_agent_keys(self.shuffle)
+        shuffle = self.shuffle
         for stage in self.stage_list:
             if stage.startswith("model."):
                 getattr(self.model, stage[6:])()
             else:
-                self.do_each(stage, agent_keys=agent_keys)
-            # We recompute the keys because some agents might have been removed
-            # in the previous loop.
-            agent_keys = self.get_agent_keys(self.shuffle_between_stages)
+                self.do_each(stage, shuffle=shuffle)
+
+            shuffle = self.shuffle_between_stages
             self.time += self.stage_time
 
         self.steps += 1
@@ -264,9 +287,24 @@ class RandomActivationByType(BaseScheduler):
         - get_type_count: Returns the count of agents of a specific type.
     """
 
-    def __init__(self, model: Model) -> None:
-        super().__init__(model)
-        self.agents_by_type = defaultdict(dict)
+    def __init__(self, model: Model, agents: Iterable[Agent] | None = None) -> None:
+        super().__init__(model, agents)
+        """
+
+        Args:
+            model (Model): The model to which the schedule belongs
+            agents (Iterable[Agent], None, optional): An iterable of agents who are controlled by the schedule
+        """
+
+        # can't be a defaultdict because we need to pass model to AgentSet
+        self.agents_by_type: [type, AgentSet] = {}
+
+        if agents is not None:
+            for agent in agents:
+                try:
+                    self.agents_by_type[type(agent)].add(agent)
+                except KeyError:
+                    self.agents_by_type[type(agent)] = AgentSet([agent], self.model)
 
     def add(self, agent: Agent) -> None:
         """
@@ -276,17 +314,18 @@ class RandomActivationByType(BaseScheduler):
             agent: An Agent to be added to the schedule.
         """
         super().add(agent)
-        agent_class: type[Agent] = type(agent)
-        self.agents_by_type[agent_class][agent.unique_id] = agent
+
+        try:
+            self.agents_by_type[type(agent)].add(agent)
+        except KeyError:
+            self.agents_by_type[type(agent)] = AgentSet([agent], self.model)
 
     def remove(self, agent: Agent) -> None:
         """
         Remove all instances of a given agent from the schedule.
         """
-        del self._agents[agent.unique_id]
-
-        agent_class: type[Agent] = type(agent)
-        del self.agents_by_type[agent_class][agent.unique_id]
+        super().remove(agent)
+        self.agents_by_type[type(agent)].remove(agent)
 
     def step(self, shuffle_types: bool = True, shuffle_agents: bool = True) -> None:
         """
@@ -308,26 +347,25 @@ class RandomActivationByType(BaseScheduler):
         self.steps += 1
         self.time += 1
 
-    def step_type(self, type_class: type[Agent], shuffle_agents: bool = True) -> None:
+    def step_type(self, agenttype: type[Agent], shuffle_agents: bool = True) -> None:
         """
         Shuffle order and run all agents of a given type.
         This method is equivalent to the NetLogo 'ask [breed]...'.
 
         Args:
-            type_class: Class object of the type to run.
+            agenttype: Class object of the type to run.
         """
-        agent_keys: list[int] = list(self.agents_by_type[type_class].keys())
+        agents = self.agents_by_type[agenttype]
+
         if shuffle_agents:
-            self.model.random.shuffle(agent_keys)
-        for agent_key in agent_keys:
-            if agent_key in self.agents_by_type[type_class]:
-                self.agents_by_type[type_class][agent_key].step()
+            agents.shuffle(inplace=True)
+        agents.do("step")
 
-    def get_type_count(self, type_class: type[Agent]) -> int:
+    def get_type_count(self, agenttype: type[Agent]) -> int:
         """
         Returns the current number of agents of certain type in the queue.
         """
-        return len(self.agents_by_type[type_class])
+        return len(self.agents_by_type[agenttype])
 
 
 class DiscreteEventScheduler(BaseScheduler):
@@ -375,18 +413,37 @@ class DiscreteEventScheduler(BaseScheduler):
     """
 
     def __init__(self, model: Model, time_step: TimeT = 1) -> None:
+        """
+
+        Args:
+            model (Model): The model to which the schedule belongs
+            time_step (TimeT): The fixed time step between steps
+
+        """
         super().__init__(model)
-        self.event_queue: list[tuple[TimeT, float, Agent]] = []
+        self.event_queue: list[tuple[TimeT, float, weakref.ref]] = []
         self.time_step: TimeT = time_step  # Fixed time period for each step
 
+        warnings.warn(
+            "The DiscreteEventScheduler is experimental. It may be changed or removed in any and all future releases, including patch releases.\n"
+            "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/1923",
+            FutureWarning,
+            stacklevel=2,
+        )
+
     def schedule_event(self, time: TimeT, agent: Agent) -> None:
         """Schedule an event for an agent at a specific time."""
         if time < self.time:
             raise ValueError(
                 f"Scheduled time ({time}) must be >= the current time ({self.time})"
             )
+        if agent not in self._agents:
+            raise ValueError(
+                "trying to schedule an event for agent which is not known to the scheduler"
+            )
+
         # Create an event, sorted first on time, secondary on a random value
-        event = (time, self.model.random.random(), agent)
+        event = (time, self.model.random.random(), weakref.ref(agent))
         heapq.heappush(self.event_queue, event)
 
     def schedule_in(self, delay: TimeT, agent: Agent) -> None:
@@ -403,12 +460,12 @@ class DiscreteEventScheduler(BaseScheduler):
         while self.event_queue and self.event_queue[0][0] <= end_time:
             # Get the next event (ignore the random value during unpacking)
             time, _, agent = heapq.heappop(self.event_queue)
+            agent = agent()  # unpack weakref
 
-            # Advance time to the event's time
-            self.time = time
-
-            # Execute the event
-            if agent.unique_id in self._agents:
+            if agent:
+                # Advance time to the event's time
+                self.time = time
+                # Execute the event
                 agent.step()
 
         # After processing events, advance time by the time_step
diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py
index 754dca50..e8b7256e 100644
--- a/mesa/visualization/__init__.py
+++ b/mesa/visualization/__init__.py
@@ -1,4 +1,7 @@
-from mesa_viz_tornado.ModularVisualization import *  # noqa
-from mesa_viz_tornado.modules import *  # noqa
-from mesa_viz_tornado.UserParam import *  # noqa
-from mesa_viz_tornado.TextVisualization import *  # noqa
+import contextlib
+
+with contextlib.suppress(ImportError):
+    from mesa_viz_tornado.ModularVisualization import *  # noqa
+    from mesa_viz_tornado.modules import *  # noqa
+    from mesa_viz_tornado.UserParam import *  # noqa
+    from mesa_viz_tornado.TextVisualization import *  # noqa
diff --git a/pyproject.toml b/pyproject.toml
index ca20d2d8..7e84213d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
 name = "Mesa"
 description = "Agent-based modeling (ABM) in Python 3+"
 license = { text = "Apache 2.0" }
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 authors = [
   { name = "Project Mesa Team", email = "projectmesa@googlegroups.com" },
 ]
@@ -28,7 +28,6 @@ classifiers = [
   "Topic :: Scientific/Engineering :: Artificial Intelligence",
   "Intended Audience :: Science/Research",
   "Programming Language :: Python :: 3 :: Only",
-  "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "License :: OSI Approved :: Apache Software License",
@@ -58,13 +57,9 @@ dev = [
   "pytest-cov",
   "sphinx",
 ]
-# Explicitly install ipykernel for Python 3.8.
-# See https://stackoverflow.com/questions/28831854/how-do-i-add-python3-kernel-to-jupyter-ipython
-# Could be removed in the future
 docs = [
   "sphinx",
   "ipython",
-  "ipykernel",
   "pydata_sphinx_theme",
   "seaborn",
   "myst-nb",
@@ -134,6 +129,6 @@ extend-ignore = [
     "ISC001",  # ruff format asks to disable this feature
 ]
 extend-exclude = ["docs", "build"]
-# Hardcode to Python 3.8.
+# Hardcode to Python 3.9.
 # Reminder to update mesa-examples if the value below is changed.
-target-version = "py38"
+target-version = "py39"


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 561208f2..3cc8f9ad 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -1,16 +1,208 @@
-from mesa.agent import Agent
+import pickle
+
+import pytest
+
+from mesa.agent import Agent, AgentSet
 from mesa.model import Model
 
 
-def test_agent_removal():
-    class TestAgent(Agent):
-        pass
+class TestAgent(Agent):
+    def get_unique_identifier(self):
+        return self.unique_id
 
+
+def test_agent_removal():
     model = Model()
     agent = TestAgent(model.next_id(), model)
     # Check if the agent is added
-    assert agent in model.agents[type(agent)]
+    assert agent in model.agents
 
     agent.remove()
     # Check if the agent is removed
-    assert agent not in model.agents[type(agent)]
+    assert agent not in model.agents
+
+
+def test_agentset():
+    # create agentset
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+
+    agentset = AgentSet(agents, model)
+
+    assert agents[0] in agentset
+    assert len(agentset) == len(agents)
+    assert all(a1 == a2 for a1, a2 in zip(agentset[0:5], agents[0:5]))
+
+    for a1, a2 in zip(agentset, agents):
+        assert a1 == a2
+
+    def test_function(agent):
+        return agent.unique_id > 5
+
+    assert len(agentset.select(test_function)) == 5
+    assert len(agentset.select(test_function, n=2)) == 2
+    assert len(agentset.select(test_function, inplace=True)) == 5
+    assert agentset.select(inplace=True) == agentset
+    assert all(a1 == a2 for a1, a2 in zip(agentset.select(), agentset))
+    assert all(a1 == a2 for a1, a2 in zip(agentset.select(n=5), agentset[:5]))
+
+    assert len(agentset.shuffle().select(n=5)) == 5
+
+    def test_function(agent):
+        return agent.unique_id
+
+    assert all(
+        a1 == a2
+        for a1, a2 in zip(agentset.sort(test_function, ascending=False), agentset[::-1])
+    )
+    assert all(
+        a1 == a2
+        for a1, a2 in zip(agentset.sort("unique_id", ascending=False), agentset[::-1])
+    )
+
+    assert all(
+        a1 == a2.unique_id for a1, a2 in zip(agentset.get("unique_id"), agentset)
+    )
+    assert all(
+        a1 == a2.unique_id
+        for a1, a2 in zip(
+            agentset.do("get_unique_identifier", return_results=True), agentset
+        )
+    )
+    assert agentset == agentset.do("get_unique_identifier")
+
+    agentset.discard(agents[0])
+    assert agents[0] not in agentset
+    agentset.discard(agents[0])  # check if no error is raised on discard
+
+    with pytest.raises(KeyError):
+        agentset.remove(agents[0])
+
+    agentset.add(agents[0])
+    assert agents[0] in 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)])
+    )
+    assert all(
+        a1.unique_id == a2.unique_id for a1, a2 in zip(another_set, other_agents)
+    )
+    assert len(another_set) == len(other_agents)
+
+
+def test_agentset_initialization():
+    model = Model()
+    empty_agentset = AgentSet([], model)
+    assert len(empty_agentset) == 0
+
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+    assert len(agentset) == 10
+
+
+def test_agentset_serialization():
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(5)]
+    agentset = AgentSet(agents, model)
+
+    serialized = pickle.dumps(agentset)
+    deserialized = pickle.loads(serialized)  # noqa: S301
+
+    original_ids = [agent.unique_id for agent in agents]
+    deserialized_ids = [agent.unique_id for agent in deserialized]
+
+    assert deserialized_ids == original_ids
+
+
+def test_agent_membership():
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(5)]
+    agentset = AgentSet(agents, model)
+
+    assert agents[0] in agentset
+    assert TestAgent(model.next_id(), model) not in agentset
+
+
+def test_agent_add_remove_discard():
+    model = Model()
+    agent = TestAgent(model.next_id(), model)
+    agentset = AgentSet([], model)
+
+    agentset.add(agent)
+    assert agent in agentset
+
+    agentset.remove(agent)
+    assert agent not in agentset
+
+    agentset.add(agent)
+    agentset.discard(agent)
+    assert agent not in agentset
+
+    with pytest.raises(KeyError):
+        agentset.remove(agent)
+
+
+def test_agentset_get_item():
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+
+    assert agentset[0] == agents[0]
+    assert agentset[-1] == agents[-1]
+    assert agentset[1:3] == agents[1:3]
+
+    with pytest.raises(IndexError):
+        _ = agentset[20]
+
+
+def test_agentset_do_method():
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+
+    with pytest.raises(AttributeError):
+        agentset.do("non_existing_method")
+
+
+def test_agentset_get_attribute():
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+
+    unique_ids = agentset.get("unique_id")
+    assert unique_ids == [agent.unique_id for agent in agents]
+
+    with pytest.raises(AttributeError):
+        agentset.get("non_existing_attribute")
+
+
+class OtherAgentType(Agent):
+    def get_unique_identifier(self):
+        return self.unique_id
+
+
+def test_agentset_select_by_type():
+    model = Model()
+    # Create a mix of agents of two different types
+    test_agents = [TestAgent(model.next_id(), model) for _ in range(4)]
+    other_agents = [OtherAgentType(model.next_id(), model) for _ in range(6)]
+
+    # Combine the two types of agents
+    mixed_agents = test_agents + other_agents
+    agentset = AgentSet(mixed_agents, model)
+
+    # Test selection by type
+    selected_test_agents = agentset.select(agent_type=TestAgent)
+    assert len(selected_test_agents) == len(test_agents)
+    assert all(isinstance(agent, TestAgent) for agent in selected_test_agents)
+    assert len(selected_test_agents) == 4
+
+    selected_other_agents = agentset.select(agent_type=OtherAgentType)
+    assert len(selected_other_agents) == len(other_agents)
+    assert all(isinstance(agent, OtherAgentType) for agent in selected_other_agents)
+    assert len(selected_other_agents) == 6
+
+    # Test with no type specified (should select all agents)
+    all_agents = agentset.select()
+    assert len(all_agents) == len(mixed_agents)
diff --git a/tests/test_model.py b/tests/test_model.py
index c54634d8..874d45f9 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -49,5 +49,5 @@ def test_agent_types():
 
     model = Model()
     test_agent = TestAgent(model.next_id(), model)
-    assert test_agent in model.agents[type(test_agent)]
+    assert test_agent in model.agents
     assert type(test_agent) in model.agent_types
diff --git a/tests/test_space.py b/tests/test_space.py
index f8f2cc94..b7524b6d 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -4,7 +4,7 @@ import networkx as nx
 import numpy as np
 import pytest
 
-from mesa.space import ContinuousSpace, NetworkGrid, SingleGrid
+from mesa.space import ContinuousSpace, NetworkGrid, PropertyLayer, SingleGrid
 from tests.test_grid import MockAgent
 
 TEST_AGENTS = [(-20, -20), (-20, -20.05), (65, 18)]
@@ -278,6 +278,165 @@ class TestSpaceAgentMapping(unittest.TestCase):
             self.space.remove_agent(agent_to_remove)
 
 
+class TestPropertyLayer(unittest.TestCase):
+    def setUp(self):
+        self.layer = PropertyLayer("test_layer", 10, 10, 0)
+
+    # Initialization Test
+    def test_initialization(self):
+        self.assertEqual(self.layer.name, "test_layer")
+        self.assertEqual(self.layer.width, 10)
+        self.assertEqual(self.layer.height, 10)
+        self.assertTrue(np.array_equal(self.layer.data, np.zeros((10, 10))))
+
+    # Set Cell Test
+    def test_set_cell(self):
+        self.layer.set_cell((5, 5), 1)
+        self.assertEqual(self.layer.data[5, 5], 1)
+
+    # Set Cells Tests
+    def test_set_cells_no_condition(self):
+        self.layer.set_cells(2)
+        np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 2))
+
+    def test_set_cells_with_condition(self):
+        self.layer.set_cell((5, 5), 1)
+
+        def condition(x):
+            return x == 0
+
+        self.layer.set_cells(3, condition)
+        self.assertEqual(self.layer.data[5, 5], 1)
+        self.assertEqual(self.layer.data[0, 0], 3)
+        # Check if the sum is correct
+        self.assertEqual(np.sum(self.layer.data), 3 * 99 + 1)
+
+    def test_set_cells_with_random_condition(self):
+        # Probability for a cell to be updated
+        update_probability = 0.5
+
+        # Define a condition with a random part
+        def condition(val):
+            return np.random.rand() < update_probability
+
+        # Apply set_cells
+        self.layer.set_cells(True, condition)
+
+        # Count the number of cells that were set to True
+        true_count = np.sum(self.layer.data)
+
+        width = self.layer.width
+        height = self.layer.height
+
+        # Calculate expected range (with some tolerance for randomness)
+        expected_min = width * height * update_probability * 0.5
+        expected_max = width * height * update_probability * 1.5
+
+        # Check if the true_count falls within the expected range
+        assert expected_min <= true_count <= expected_max
+
+    # Modify Cell Test
+    def test_modify_cell_lambda(self):
+        self.layer.data = np.zeros((10, 10))
+        self.layer.modify_cell((2, 2), lambda x: x + 5)
+        self.assertEqual(self.layer.data[2, 2], 5)
+
+    def test_modify_cell_ufunc(self):
+        self.layer.data = np.ones((10, 10))
+        self.layer.modify_cell((3, 3), np.add, 4)
+        self.assertEqual(self.layer.data[3, 3], 5)
+
+    def test_modify_cell_invalid_operation(self):
+        with self.assertRaises(ValueError):
+            self.layer.modify_cell((1, 1), np.add)  # Missing value for ufunc
+
+    # Modify Cells Test
+    def test_modify_cells_lambda(self):
+        self.layer.data = np.zeros((10, 10))
+        self.layer.modify_cells(lambda x: x + 2)
+        np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 2))
+
+    def test_modify_cells_ufunc(self):
+        self.layer.data = np.ones((10, 10))
+        self.layer.modify_cells(np.multiply, 3)
+        np.testing.assert_array_equal(self.layer.data, np.full((10, 10), 3))
+
+    def test_modify_cells_invalid_operation(self):
+        with self.assertRaises(ValueError):
+            self.layer.modify_cells(np.add)  # Missing value for ufunc
+
+    # Aggregate Property Test
+    def test_aggregate_property_lambda(self):
+        self.layer.data = np.arange(100).reshape(10, 10)
+        result = self.layer.aggregate_property(lambda x: np.sum(x))
+        self.assertEqual(result, np.sum(np.arange(100)))
+
+    def test_aggregate_property_ufunc(self):
+        self.layer.data = np.full((10, 10), 2)
+        result = self.layer.aggregate_property(np.mean)
+        self.assertEqual(result, 2)
+
+    # Edge Case: Negative or Zero Dimensions
+    def test_initialization_negative_dimensions(self):
+        with self.assertRaises(ValueError):
+            PropertyLayer("test_layer", -10, 10, 0)
+
+    def test_initialization_zero_dimensions(self):
+        with self.assertRaises(ValueError):
+            PropertyLayer("test_layer", 0, 10, 0)
+
+    # Edge Case: Out-of-Bounds Cell Access
+    def test_set_cell_out_of_bounds(self):
+        with self.assertRaises(IndexError):
+            self.layer.set_cell((10, 10), 1)
+
+    def test_modify_cell_out_of_bounds(self):
+        with self.assertRaises(IndexError):
+            self.layer.modify_cell((10, 10), lambda x: x + 5)
+
+    # Edge Case: Selecting Cells with Complex Conditions
+    def test_select_cells_complex_condition(self):
+        self.layer.data = np.random.rand(10, 10)
+        selected = self.layer.select_cells(lambda x: (x > 0.5) & (x < 0.75))
+        for c in selected:
+            self.assertTrue(0.5 < self.layer.data[c] < 0.75)
+
+    # More edge cases
+    def test_set_cells_with_numpy_ufunc(self):
+        # Set some cells to a specific value
+        self.layer.data[0:5, 0:5] = 5
+
+        # Use a numpy ufunc as a condition. Here, we're using `np.greater`
+        # which will return True for cells with values greater than 2.
+        condition = np.greater
+        self.layer.set_cells(10, lambda x: condition(x, 2))
+
+        # Check if cells that had value greater than 2 are now set to 10
+        updated_cells = self.layer.data[0:5, 0:5]
+        np.testing.assert_array_equal(updated_cells, np.full((5, 5), 10))
+
+        # Check if cells that had value 0 (less than or equal to 2) remain unchanged
+        unchanged_cells = self.layer.data[5:, 5:]
+        np.testing.assert_array_equal(unchanged_cells, np.zeros((5, 5)))
+
+    def test_modify_cell_boundary_condition(self):
+        self.layer.data = np.zeros((10, 10))
+        self.layer.modify_cell((0, 0), lambda x: x + 5)
+        self.layer.modify_cell((9, 9), lambda x: x + 5)
+        self.assertEqual(self.layer.data[0, 0], 5)
+        self.assertEqual(self.layer.data[9, 9], 5)
+
+    def test_aggregate_property_std_dev(self):
+        self.layer.data = np.arange(100).reshape(10, 10)
+        result = self.layer.aggregate_property(np.std)
+        self.assertAlmostEqual(result, np.std(np.arange(100)), places=5)
+
+    def test_data_type_consistency(self):
+        self.layer.data = np.zeros((10, 10), dtype=int)
+        self.layer.set_cell((5, 5), 5.5)
+        self.assertIsInstance(self.layer.data[5, 5], self.layer.data.dtype.type)
+
+
 class TestSingleGrid(unittest.TestCase):
     def setUp(self):
         self.space = SingleGrid(50, 50, False)
@@ -312,6 +471,16 @@ class TestSingleGrid(unittest.TestCase):
         with self.assertRaises(Exception):
             self.space.move_to_empty(a)
 
+    def test_empty_mask_consistency(self):
+        # Check that the empty mask is consistent with the empties set
+        empty_mask = self.space.empty_mask
+        empties = self.space.empties
+        for i in range(self.space.width):
+            for j in range(self.space.height):
+                mask_value = empty_mask[i, j]
+                empties_value = (i, j) in empties
+                assert mask_value == empties_value
+
     def move_agent(self):
         agent_number = 0
         initial_pos = TEST_AGENTS_GRID[agent_number]
@@ -327,6 +496,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 +546,304 @@ 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 TestSingleGridWithPropertyGrid(unittest.TestCase):
+    def setUp(self):
+        self.grid = SingleGrid(10, 10, False)
+        self.property_layer1 = PropertyLayer("layer1", 10, 10, 0)
+        self.property_layer2 = PropertyLayer("layer2", 10, 10, 1)
+        self.grid.add_property_layer(self.property_layer1)
+        self.grid.add_property_layer(self.property_layer2)
+
+    # Test adding and removing property layers
+    def test_add_property_layer(self):
+        self.assertIn("layer1", self.grid.properties)
+        self.assertIn("layer2", self.grid.properties)
+
+    def test_remove_property_layer(self):
+        self.grid.remove_property_layer("layer1")
+        self.assertNotIn("layer1", self.grid.properties)
+
+    def test_add_property_layer_mismatched_dimensions(self):
+        with self.assertRaises(ValueError):
+            self.grid.add_property_layer(PropertyLayer("layer3", 5, 5, 0))
+
+    def test_add_existing_property_layer(self):
+        with self.assertRaises(ValueError):
+            self.grid.add_property_layer(self.property_layer1)
+
+    def test_remove_nonexistent_property_layer(self):
+        with self.assertRaises(ValueError):
+            self.grid.remove_property_layer("nonexistent_layer")
+
+    # Test getting masks
+    def test_get_empty_mask(self):
+        empty_mask = self.grid.empty_mask
+        self.assertTrue(np.all(empty_mask == np.ones((10, 10), dtype=bool)))
+
+    def test_get_empty_mask_with_agent(self):
+        agent = MockAgent(0, self.grid)
+        self.grid.place_agent(agent, (4, 6))
+
+        empty_mask = self.grid.empty_mask
+        expected_mask = np.ones((10, 10), dtype=bool)
+        expected_mask[4, 6] = False
+
+        self.assertTrue(np.all(empty_mask == expected_mask))
+
+    def test_get_neighborhood_mask(self):
+        agent = MockAgent(0, self.grid)
+        agent2 = MockAgent(1, self.grid)
+        self.grid.place_agent(agent, (5, 5))
+        self.grid.place_agent(agent2, (5, 6))
+        neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1)
+        expected_mask = np.zeros((10, 10), dtype=bool)
+        expected_mask[4:7, 4:7] = True
+        expected_mask[5, 5] = False
+        self.assertTrue(np.all(neighborhood_mask == expected_mask))
+
+    # Test selecting and moving to cells based on multiple conditions
+    def test_select_cells_by_properties(self):
+        def condition(x):
+            return x == 0
+
+        selected_cells = self.grid.select_cells({"layer1": condition})
+        self.assertEqual(len(selected_cells), 100)
+
+    def test_select_cells_by_properties_return_mask(self):
+        def condition(x):
+            return x == 0
+
+        selected_mask = self.grid.select_cells({"layer1": condition}, return_list=False)
+        self.assertTrue(isinstance(selected_mask, np.ndarray))
+        self.assertTrue(selected_mask.all())
+
+    def test_move_agent_to_cell_by_properties(self):
+        agent = MockAgent(1, self.grid)
+        self.grid.place_agent(agent, (5, 5))
+        conditions = {"layer1": lambda x: x == 0}
+        target_cells = self.grid.select_cells(conditions)
+        self.grid.move_agent_to_one_of(agent, target_cells)
+        # Agent should move, since none of the cells match the condition
+        self.assertNotEqual(agent.pos, (5, 5))
+
+    def test_move_agent_no_eligible_cells(self):
+        agent = MockAgent(3, self.grid)
+        self.grid.place_agent(agent, (5, 5))
+        conditions = {"layer1": lambda x: x != 0}
+        target_cells = self.grid.select_cells(conditions)
+        self.grid.move_agent_to_one_of(agent, target_cells)
+        self.assertEqual(agent.pos, (5, 5))
+
+    # Test selecting and moving to cells based on extreme values
+    def test_select_extreme_value_cells(self):
+        self.grid.properties["layer2"].set_cell((3, 1), 1.1)
+        target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"})
+        self.assertIn((3, 1), target_cells)
+
+    def test_select_extreme_value_cells_return_mask(self):
+        self.grid.properties["layer2"].set_cell((3, 1), 1.1)
+        target_mask = self.grid.select_cells(
+            extreme_values={"layer2": "highest"}, return_list=False
+        )
+        self.assertTrue(isinstance(target_mask, np.ndarray))
+        self.assertTrue(target_mask[3, 1])
+
+    def test_move_agent_to_extreme_value_cell(self):
+        agent = MockAgent(2, self.grid)
+        self.grid.place_agent(agent, (5, 5))
+        self.grid.properties["layer2"].set_cell((3, 1), 1.1)
+        target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"})
+        self.grid.move_agent_to_one_of(agent, target_cells)
+        self.assertEqual(agent.pos, (3, 1))
+
+    # Test using masks
+    def test_select_cells_by_properties_with_empty_mask(self):
+        self.grid.place_agent(
+            MockAgent(0, self.grid), (5, 5)
+        )  # Placing an agent to ensure some cells are not empty
+        empty_mask = self.grid.empty_mask
+
+        def condition(x):
+            return x == 0
+
+        selected_cells = self.grid.select_cells({"layer1": condition}, masks=empty_mask)
+        self.assertNotIn(
+            (5, 5), selected_cells
+        )  # (5, 5) should not be in the selection as it's not empty
+
+    def test_select_cells_by_properties_with_neighborhood_mask(self):
+        neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1)
+
+        def condition(x):
+            return x == 0
+
+        selected_cells = self.grid.select_cells(
+            {"layer1": condition}, masks=neighborhood_mask
+        )
+        expected_selection = [
+            (4, 4),
+            (4, 5),
+            (4, 6),
+            (5, 4),
+            (5, 6),
+            (6, 4),
+            (6, 5),
+            (6, 6),
+        ]  # Cells in the neighborhood of (5, 5)
+        self.assertCountEqual(selected_cells, expected_selection)
+
+    def test_move_agent_to_cell_by_properties_with_empty_mask(self):
+        agent = MockAgent(1, self.grid)
+        self.grid.place_agent(agent, (5, 5))
+        self.grid.place_agent(
+            MockAgent(2, self.grid), (4, 5)
+        )  # Placing another agent to create a non-empty cell
+        empty_mask = self.grid.empty_mask
+        conditions = {"layer1": lambda x: x == 0}
+        target_cells = self.grid.select_cells(conditions, masks=empty_mask)
+        self.grid.move_agent_to_one_of(agent, target_cells)
+        self.assertNotEqual(
+            agent.pos, (4, 5)
+        )  # Agent should not move to (4, 5) as it's not empty
+
+    def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self):
+        agent = MockAgent(1, self.grid)
+        self.grid.place_agent(agent, (5, 5))
+        neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1)
+        conditions = {"layer1": lambda x: x == 0}
+        target_cells = self.grid.select_cells(conditions, masks=neighborhood_mask)
+        self.grid.move_agent_to_one_of(agent, target_cells)
+        self.assertIn(
+            agent.pos, [(4, 4), (4, 5), (4, 6), (5, 4), (5, 6), (6, 4), (6, 5), (6, 6)]
+        )  # Agent should move within the neighborhood
+
+    # Test invalid inputs
+    def test_invalid_property_name_in_conditions(self):
+        def condition(x):
+            return x == 0
+
+        with self.assertRaises(KeyError):
+            self.grid.select_cells(conditions={"nonexistent_layer": condition})
+
+    # Test if coordinates means the same between the grid and the property layer
+    def test_property_layer_coordinates(self):
+        agent = MockAgent(0, self.grid)
+        correct_pos = (1, 8)
+        incorrect_pos = (8, 1)
+        self.grid.place_agent(agent, correct_pos)
+
+        # Simple check on layer 1: set by agent, check by layer
+        self.grid.properties["layer1"].set_cell(agent.pos, 2)
+        self.assertEqual(self.grid.properties["layer1"].data[agent.pos], 2)
+
+        # More complicated check on layer 2: set by layer, check by agent
+        self.grid.properties["layer2"].set_cell(correct_pos, 3)
+        self.grid.properties["layer2"].set_cell(incorrect_pos, 4)
+
+        correct_grid_value = self.grid.properties["layer2"].data[correct_pos]
+        incorrect_grid_value = self.grid.properties["layer2"].data[incorrect_pos]
+        agent_grid_value = self.grid.properties["layer2"].data[agent.pos]
+
+        self.assertEqual(correct_grid_value, agent_grid_value)
+        self.assertNotEqual(incorrect_grid_value, agent_grid_value)
+
+    # Test selecting cells with only_empty parameter
+    def test_select_cells_only_empty(self):
+        self.grid.place_agent(MockAgent(0, self.grid), (5, 5))  # Occupying a cell
+        selected_cells = self.grid.select_cells(only_empty=True)
+        self.assertNotIn(
+            (5, 5), selected_cells
+        )  # The occupied cell should not be selected
+
+    def test_select_cells_only_empty_with_conditions(self):
+        self.grid.place_agent(MockAgent(1, self.grid), (5, 5))
+        self.grid.properties["layer1"].set_cell((5, 5), 2)
+        self.grid.properties["layer1"].set_cell((6, 6), 2)
+
+        def condition(x):
+            return x == 2
+
+        selected_cells = self.grid.select_cells({"layer1": condition}, only_empty=True)
+        self.assertIn((6, 6), selected_cells)
+        self.assertNotIn((5, 5), selected_cells)
+
+    # Test selecting cells with multiple extreme values
+    def test_select_cells_multiple_extreme_values(self):
+        self.grid.properties["layer1"].set_cell((1, 1), 3)
+        self.grid.properties["layer1"].set_cell((2, 2), 3)
+        self.grid.properties["layer2"].set_cell((2, 2), 0.5)
+        self.grid.properties["layer2"].set_cell((3, 3), 0.5)
+        selected_cells = self.grid.select_cells(
+            extreme_values={"layer1": "highest", "layer2": "lowest"}
+        )
+        self.assertIn((2, 2), selected_cells)
+        self.assertNotIn((1, 1), selected_cells)
+        self.assertNotIn((3, 3), selected_cells)
+        self.assertEqual(len(selected_cells), 1)
+
+
 class TestSingleNetworkGrid(unittest.TestCase):
     GRAPH_SIZE = 10
 
diff --git a/tests/test_time.py b/tests/test_time.py
index 5d6d66f9..3cfc2f35 100644
--- a/tests/test_time.py
+++ b/tests/test_time.py
@@ -34,8 +34,7 @@ class MockAgent(Agent):
     def kill_other_agent(self):
         for agent in self.model.schedule.agents:
             if agent is not self:
-                self.model.schedule.remove(agent)
-                break
+                agent.remove()
 
     def stage_one(self):
         if self.model.enable_kill_other_agent:
@@ -78,7 +77,9 @@ class MockModel(Model):
         # Make scheduler
         if activation == STAGED:
             model_stages = ["stage_one", "model.model_stage", "stage_two"]
-            self.schedule = StagedActivation(self, model_stages, shuffle=shuffle)
+            self.schedule = StagedActivation(
+                self, stage_list=model_stages, shuffle=shuffle
+            )
         elif activation == RANDOM:
             self.schedule = RandomActivation(self)
         elif activation == SIMULTANEOUS:
@@ -140,9 +141,9 @@ class TestStagedActivation(TestCase):
         Test the staged activation can remove an agent
         """
         model = MockModel(shuffle=True)
-        agent_keys = list(model.schedule._agents.keys())
-        agent = model.schedule._agents[agent_keys[0]]
-        model.schedule.remove(agent)
+        agents = list(model.schedule._agents)
+        agent = agents[0]
+        model.schedule.remove(agents[0])
         assert agent not in model.schedule.agents
 
     def test_intrastep_remove(self):
@@ -166,6 +167,13 @@ class TestRandomActivation(TestCase):
     Test the random activation.
     """
 
+    def test_init(self):
+        model = Model()
+        agents = [MockAgent(model.next_id(), model) for _ in range(10)]
+
+        scheduler = RandomActivation(model, agents)
+        assert all(agent in scheduler.agents for agent in agents)
+
     def test_random_activation_step_shuffles(self):
         """
         Test the random activation step
@@ -205,6 +213,17 @@ class TestRandomActivation(TestCase):
         model.step()
         assert len(model.log) == 1
 
+    def test_get_agent_keys(self):
+        model = MockModel(activation=RANDOM)
+
+        keys = model.schedule.get_agent_keys()
+        agent_ids = [agent.unique_id for agent in model.agents]
+        assert all(entry_i == entry_j for entry_i, entry_j in zip(keys, agent_ids))
+
+        keys = model.schedule.get_agent_keys(shuffle=True)
+        agent_ids = {agent.unique_id for agent in model.agents}
+        assert all(entry in agent_ids for entry in keys)
+
 
 class TestSimultaneousActivation(TestCase):
     """
@@ -231,6 +250,14 @@ class TestRandomActivationByType(TestCase):
     does step for one type of agents, not the entire agents.
     """
 
+    def test_init(self):
+        model = Model()
+        agents = [MockAgent(model.next_id(), model) for _ in range(10)]
+        agents += [Agent(model.next_id(), model) for _ in range(10)]
+
+        scheduler = RandomActivationByType(model, agents)
+        assert all(agent in scheduler.agents for agent in agents)
+
     def test_random_activation_step_shuffles(self):
         """
         Test the random activation by type step
@@ -262,18 +289,32 @@ class TestRandomActivationByType(TestCase):
         # one step for each of 2 agents
         assert all(x == 1 for x in agent_steps)
 
-    def test_add_non_unique_ids(self):
+    def test_random_activation_counts(self):
         """
-        Test that adding agent with duplicate ids result in an error.
-        TODO: we need to run this test on all schedulers, not just
-        RandomActivationByType.
+        Test the random activation by type step causes each agent to step
         """
+
         model = MockModel(activation=RANDOM_BY_TYPE)
-        a = MockAgent(0, model)
-        b = MockAgent(0, model)
-        model.schedule.add(a)
-        with self.assertRaises(Exception):
-            model.schedule.add(b)
+
+        agent_types = model.agent_types
+        for agent_type in agent_types:
+            assert model.schedule.get_type_count(agent_type) == len(
+                model.get_agents_of_type(agent_type)
+            )
+
+    # def test_add_non_unique_ids(self):
+    #     """
+    #     Test that adding agent with duplicate ids result in an error.
+    #     TODO: we need to run this test on all schedulers, not just
+    #     TODO:: identical IDs is something for the agent, not the scheduler and should be tested there
+    #     RandomActivationByType.
+    #     """
+    #     model = MockModel(activation=RANDOM_BY_TYPE)
+    #     a = MockAgent(0, model)
+    #     b = MockAgent(0, model)
+    #     model.schedule.add(a)
+    #     with self.assertRaises(Exception):
+    #         model.schedule.add(b)
 
 
 class TestDiscreteEventScheduler(TestCase):
@@ -297,19 +338,19 @@ class TestDiscreteEventScheduler(TestCase):
         self.assertEqual(len(self.scheduler.event_queue), 1)
         event_time, _, event_agent = self.scheduler.event_queue[0]
         self.assertEqual(event_time, 5)
-        self.assertEqual(event_agent, self.agent1)
+        self.assertEqual(event_agent(), self.agent1)
 
     def test_schedule_event_with_float_time(self):
         self.scheduler.schedule_event(5.5, self.agent1)
         self.assertEqual(len(self.scheduler.event_queue), 1)
         event_time, _, event_agent = self.scheduler.event_queue[0]
         self.assertEqual(event_time, 5.5)
-        self.assertEqual(event_agent, self.agent1)
+        self.assertEqual(event_agent(), self.agent1)
 
     def test_schedule_in(self):
         self.scheduler.schedule_in(3, self.agent2)
         _, _, event_agent = self.scheduler.event_queue[0]
-        self.assertEqual(event_agent, self.agent2)
+        self.assertEqual(event_agent(), self.agent2)
         self.assertEqual(self.scheduler.get_next_event_time(), self.scheduler.time + 3)
 
     # Testing Event Execution and Time Advancement
@@ -337,6 +378,11 @@ class TestDiscreteEventScheduler(TestCase):
         with self.assertRaises(ValueError):
             self.scheduler.schedule_event(-1, self.agent1)
 
+    def test_invalid_aget_time(self):
+        with self.assertRaises(ValueError):
+            agent3 = MockAgent(3, self.model)
+            self.scheduler.schedule_event(2, agent3)
+
     def test_immediate_event_execution(self):
         # Current time of the scheduler
         current_time = self.scheduler.time
@@ -366,7 +412,7 @@ class TestDiscreteEventScheduler(TestCase):
         self.assertEqual(len(self.scheduler.event_queue), 1)
         event_time, _, event_agent = self.scheduler.event_queue[0]
         self.assertEqual(event_time, self.scheduler.time)
-        self.assertEqual(event_agent, new_agent)
+        self.assertEqual(event_agent(), new_agent)
 
         # Step the scheduler and check if the agent's step method is executed
         self.scheduler.step()