Repository: projectmesa/mesa
Pull request #2322 (2024-09-24 09:45:10)

The big backport: Bring many Mesa 3.0 features to 2.4.0
A big backport which brings many Mesa 3.0 features to Mesa 2.4.0 in a backward-compatible way.

New deprecation warnings are included, but no breaking changes.

The HISTORY.md contain new release notes (please review) and a draft release is [available here](https://github.com/projectmesa/mesa/releases/tag/untagged-56ce2c0247231bbd07e8).


Patch:
diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml
index 2e19ca72..f253d950 100644
--- a/.github/workflows/build_lint.yml
+++ b/.github/workflows/build_lint.yml
@@ -33,6 +33,8 @@ jobs:
         os: [windows, ubuntu, macos]
         python-version: ["3.12"]
         include:
+          - os: ubuntu
+            python-version: "3.13"
           - os: ubuntu
             python-version: "3.11"
           - os: ubuntu
@@ -49,6 +51,7 @@ jobs:
       uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
+        allow-prereleases: true
         cache: 'pip'
     - name: Install uv
       run: pip install uv
diff --git a/HISTORY.md b/HISTORY.md
index 94880ce3..f39d8096 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,6 +1,70 @@
 ---
 title: Release History
 ---
+# 2.4.0 (2024-09-24)
+## Highlights
+Mesa 2.4.0 brings several key improvements from the upcoming 3.0 series, while maintaining compatibility with existing Mesa 2.x models.
+
+The DataCollector now supports collecting data from specific Agent subclasses using the new `agenttype_reporters` parameter ([#2300](https://github.com/projectmesa/mesa/pull/2300)). This allows collecting different metrics for different agent types. For example:
+
+```python
+self.datacollector = DataCollector(
+    agenttype_reporters={
+        Wolf: {"sheep_eaten": "sheep_eaten"},
+        Sheep: {"wool": "wool_amount"}
+    }
+)
+```
+
+The AgentSet class, which underpins `model.agents`, has received major enhancements:
+- A new `groupby()` method to split agents into groups ([#2220](https://github.com/projectmesa/mesa/pull/2220))
+- An `agg()` method to quickly compute aggregate values ([#2266](https://github.com/projectmesa/mesa/pull/2266))
+- A faster `shuffle_do()` method for more efficient random agent activation ([#2283](https://github.com/projectmesa/mesa/pull/2283))
+- The `select()` method now allows choosing a fraction of agents ([#2253](https://github.com/projectmesa/mesa/pull/2253))
+- The `do()` method can now take any callable function, not just string method names ([#2219](https://github.com/projectmesa/mesa/pull/2219))
+
+Other notable improvements include:
+- The Model class now exposes an `agents_by_type` property for easier access to agents of specific types ([#2267](https://github.com/projectmesa/mesa/pull/2267))
+- Performance enhancements for `Model.agents` ([#2251](https://github.com/projectmesa/mesa/pull/2251))
+- The `AgentSet.get()` method now handles missing values with optional default value ([#2279](https://github.com/projectmesa/mesa/pull/2279))
+
+This release also fixes a bug in the Grid's `move_agent_to_one_of` method with `selection="closest"`, which previously selected locations deterministically instead of randomly ([#2118](https://github.com/projectmesa/mesa/pull/2118)).
+
+Finally, we've made significant documentation improvements, including the addition of a new [Migration guide](https://mesa.readthedocs.io/en/latest/migration_guide.html) to help users transition to future Mesa versions ([#2257](https://github.com/projectmesa/mesa/pull/2257)).
+
+## What's Changed
+### 🎉 New features added
+* Add AgentSet.groupby by @quaquel in [#2220](https://github.com/projectmesa/mesa/pull/2220)
+* AgentSet: Add `agg` method by @EwoutH in [#2266](https://github.com/projectmesa/mesa/pull/2266)
+* GroupBy: Add `count` and `agg` methods by @EwoutH in [#2290](https://github.com/projectmesa/mesa/pull/2290)
+* datacollector: Allow collecting data from Agent (sub)classes by @EwoutH in [#2300](https://github.com/projectmesa/mesa/pull/2300)
+* Add optimized shuffle_do() method to AgentSet by @EwoutH in [#2283](https://github.com/projectmesa/mesa/pull/2283)
+
+### 🛠 Enhancements made
+* Allow AgentSet.do() to take Callable function by @quaquel in [#2219](https://github.com/projectmesa/mesa/pull/2219)
+* Split AgentSet into map and do to separate return types by @quaquel in [#2237](https://github.com/projectmesa/mesa/pull/2237)
+* Performance enhancements for Model.agents by @quaquel in [#2251](https://github.com/projectmesa/mesa/pull/2251)
+* AgentSet: Allow selecting a fraction of agents in the AgentSet by @EwoutH in [#2253](https://github.com/projectmesa/mesa/pull/2253)
+* Model: Replace `get_agents_of_type` method with `agents_by_type` property by @EwoutH in [#2267](https://github.com/projectmesa/mesa/pull/2267)
+* Add default values and missing value handling to `agentset.get` by @quaquel in [#2279](https://github.com/projectmesa/mesa/pull/2279)
+
+### 🐛 Bugs fixed
+* Jupyter_viz: Allow measures to be None by @EwoutH in [#2163](https://github.com/projectmesa/mesa/pull/2163)
+* Fix deterministic behavior in `move_agent_to_one_of` with `selection="closest"` by @OrenBochman in [#2118](https://github.com/projectmesa/mesa/pull/2118)
+
+### 📜 Documentation improvements
+* Contribution: Add "I have no idea where to start" section by @EwoutH in [#2258](https://github.com/projectmesa/mesa/pull/2258)
+* Write initial Mesa Migration guide by @EwoutH in [#2257](https://github.com/projectmesa/mesa/pull/2257)
+* Docs: Fix broken relative links by removing `.html` suffix by @EwoutH in [#2274](https://github.com/projectmesa/mesa/pull/2274)
+* Readthedocs: Don't let notebook failures pass silently by @EwoutH in [#2276](https://github.com/projectmesa/mesa/pull/2276)
+* update migration guide to describe solaraviz updates by @Corvince in [#2297](https://github.com/projectmesa/mesa/pull/2297)
+* Migration Guide: Add Model initialization requirement and automatic Agent.unique_id assignment by @EwoutH in [#2302](https://github.com/projectmesa/mesa/pull/2302)
+
+### 🔧 Maintenance
+* make typing behavior of AgentSet.get explicit by @quaquel in [#2293](https://github.com/projectmesa/mesa/pull/2293)
+
+**Full Changelog**: [https://github.com/projectmesa/mesa/compare/v2.3.4...v2.4.0](https://github.com/projectmesa/mesa/compare/v2.3.4...v2.4.0)
+
 # 2.3.4 (2024-09-04)
 ## Highlights
 Two fixes in our docs: The visualization tutorial started using an example model that wasn't compatible with Mesa 2.x anymore, and some relative links were broken. This release fixes both.
diff --git a/README.md b/README.md
index d2af3e46..d542e862 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
 | Meta | [![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) |
 | Chat | [![chat](https://img.shields.io/matrix/project-mesa:matrix.org?label=chat&logo=Matrix)](https://matrix.to/#/#project-mesa:matrix.org) |
 
-*This is the `2.3.x-maintenance` branch. Example models for Mesa 2.x can be found [here](https://github.com/projectmesa/mesa-examples/tree/mesa-2.x/examples).*
+*This is the `2.4.x-maintenance` branch. Example models for Mesa 2.x can be found [here](https://github.com/projectmesa/mesa-examples/tree/mesa-2.x/examples).*
 
 Mesa allows users to quickly create agent-based models using built-in
 core components (such as spatial grids and agent schedulers) or
diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py
index ecb78594..62d4dd82 100644
--- a/benchmarks/WolfSheep/wolf_sheep.py
+++ b/benchmarks/WolfSheep/wolf_sheep.py
@@ -227,8 +227,9 @@ class WolfSheep(Model):
             patch.move_to(cell)
 
     def step(self):
-        self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
-        self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")
+        """Run one step of the model."""
+        self.agents_by_type[Sheep].shuffle_do("step")
+        self.agents_by_type[Wolf].shuffle_do("step")
 
 
 if __name__ == "__main__":
diff --git a/docs/index.md b/docs/index.md
index fc65870d..3a43137d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -81,6 +81,7 @@ ABM features users have shared that you may want to use in your model
 Mesa Overview <overview>
 tutorials/intro_tutorial
 tutorials/visualization_tutorial
+Migration guide <migration_guide>
 Best Practices <best-practices>
 How-to Guide <howto>
 API Documentation <apis/api_main>
diff --git a/docs/migration_guide.md b/docs/migration_guide.md
new file mode 100644
index 00000000..5b24b72f
--- /dev/null
+++ b/docs/migration_guide.md
@@ -0,0 +1,294 @@
+# Mesa Migration guide
+This guide contains breaking changes between major Mesa versions and how to resolve them.
+
+Non-breaking changes aren't included, for those see our [Release history](https://github.com/projectmesa/mesa/releases).
+
+
+## Mesa 3.0
+Mesa 3.0 introduces significant changes to core functionalities, including agent and model initialization, scheduling, and visualization. The guide below outlines these changes and provides instructions for migrating your existing Mesa projects to version 3.0.
+
+_This guide is a work in progress. The development of it is tracked in [Issue #2233](https://github.com/projectmesa/mesa/issues/2233)._
+
+
+### Upgrade strategy
+We recommend the following upgrade strategy:
+- Update the the latest Mesa 2.x release (`mesa<3`).
+- Update the the latest Mesa 3.0.x release (`mesa<3.1`).
+- Update to the latest Mesa 3.x release (`mesa<4`).
+
+With each update, resolve all errors and warnings, before updating to the next one.
+
+
+### Reserved and private variables
+<!-- TODO: Update this section based on https://github.com/projectmesa/mesa/discussions/2230 -->
+
+#### Reserved variables
+Currently, we have reserved the following variables:
+  - Model: `agents`, `current_id`, `random`, `running`, `steps`, `time`.
+  - Agent: `unique_id`, `model`.
+
+You can use (read) any reserved variable, but Mesa may update them automatically and rely on them, so modify/update at your own risk.
+
+#### Private variables
+Any variables starting with an underscore (`_`) are considered private and for Mesa's internal use. We might use any of those. Modifying or overwriting any private variable is at your own risk.
+
+- Ref: [Discussion #2230](https://github.com/projectmesa/mesa/discussions/2230), [PR #2225](https://github.com/projectmesa/mesa/pull/2225)
+
+
+### Removal of `mesa.flat` namespace
+The `mesa.flat` namespace is removed. Use the full namespace for your imports.
+
+- Ref: [PR #2091](https://github.com/projectmesa/mesa/pull/2091)
+
+
+### Mandatory Model initialization with `super().__init__()`
+In Mesa 3.0, it is now mandatory to call `super().__init__()` when initializing your model class. This ensures that all necessary Mesa model variables are correctly set up and agents are properly added to the model.
+
+Make sure all your model classes explicitly call `super().__init__()` in their `__init__` method:
+
+```python
+class MyModel(mesa.Model):
+    def __init__(self, *args, **kwargs):
+        super().__init__()  # This is now required!
+        # Your model initialization code here
+```
+
+This change ensures that all Mesa models are properly initialized, which is crucial for:
+- Correctly adding agents to the model
+- Setting up other essential Mesa model variables
+- Maintaining consistency across all models
+
+If you forget to call `super().__init__()`, you'll now see this error:
+
+```
+RuntimeError: The Mesa Model class was not initialized. You must explicitly initialize the Model by calling super().__init__() on initialization.
+```
+
+- Ref: [PR #2218](https://github.com/projectmesa/mesa/pull/2218), [PR #1928](https://github.com/projectmesa/mesa/pull/1928), Mesa-examples [PR #83](https://github.com/projectmesa/mesa-examples/pull/83)
+
+
+### Automatic assignment of `unique_id` to Agents
+In Mesa 3.0, `unique_id` for agents is now automatically assigned, simplifying agent creation and ensuring unique IDs across all agents in a model.
+
+1. Remove `unique_id` from agent initialization:
+   ```python
+   # Old
+   agent = MyAgent(self.next_id(), self, ...)
+
+   # New
+   agent = MyAgent(self, ...)
+   ```
+2. `Model.next_id()` is deprecated and will always return 0. Remove any calls to this method.
+3. `unique_id` is now unique relative to a Model instance and starts from 1.
+4. If you previously used custom `unique_id` values, you'll need to store that information in a separate attribute.
+5. Deprecation warning: Initializing an agent with two arguments (`unique_id` and `model`) will raise a warning. The `unique_id` argument will be ignored.
+
+- Ref: [PR #2226](https://github.com/projectmesa/mesa/pull/2226), [PR #2260](https://github.com/projectmesa/mesa/pull/2260), Mesa-examples [PR #194](https://github.com/projectmesa/mesa-examples/pull/194), [Issue #2213](https://github.com/projectmesa/mesa/issues/2213)
+
+
+### AgentSet and `Model.agents`
+In Mesa 3.0, the Model class internally manages agents using several data structures:
+
+- `self._agents`: A dictionary containing hard references to all agents, indexed by their `unique_id`.
+- `self._agents_by_type`: A dictionary of AgentSets, organizing agents by their type.
+- `self._all_agents`: An AgentSet containing all agents in the model.
+
+These internal structures are used to efficiently manage and access agents. Users should interact with agents through the public `model.agents` property, which returns the `self._all_agents` AgentSet.
+
+#### `Model.agents`
+- Attempting to set `model.agents` now raises an `AttributeError` instead of a warning. This attribute is reserved for internal use by Mesa.
+- If you were previously setting `model.agents` in your code, you must update it to use a different attribute name for custom agent storage.
+
+For example, replace:
+```python
+model.agents = my_custom_agents
+```
+
+With:
+```python
+model.custom_agents = my_custom_agents
+```
+
+
+### Time and schedulers
+<!-- TODO general explanation-->
+
+#### Automatic increase of the `steps` counter
+The `steps` counter is now automatically increased. With each call to `Model.steps()` it's increased by 1, at the beginning of the step.
+
+You can access it by `Model.steps`, and it's internally in the datacollector, batchrunner and the visualisation.
+
+- Ref: [PR #2223](https://github.com/projectmesa/mesa/pull/2223), Mesa-examples [PR #161](https://github.com/projectmesa/mesa-examples/pull/161)
+
+#### Removal of `Model._time` and rename `._steps`
+- `Model._time` is removed. You can define your own time variable if needed.
+- `Model._steps` steps is renamed to `Model.steps`.
+
+#### Removal of `Model._advance_time()`
+- The `Model._advance_time()` method is removed. This now happens automatically.
+
+#### Replacing Schedulers with AgentSet functionality
+The whole Time module in Mesa is deprecated, and all schedulers are being replaced with AgentSet functionality and the internal `Model.steps` counter. This allows much more flexibility in how to activate Agents and makes it explicit what's done exactly.
+
+Here's how to replace each scheduler:
+
+##### BaseScheduler
+Replace:
+```python
+self.schedule = BaseScheduler(self)
+self.schedule.step()
+```
+With:
+```python
+self.agents.do("step")
+```
+
+##### RandomActivation
+Replace:
+```python
+self.schedule = RandomActivation(self)
+self.schedule.step()
+```
+With:
+```python
+self.agents.shuffle_do("step")
+```
+
+##### SimultaneousActivation
+Replace:
+```python
+self.schedule = SimultaneousActivation(self)
+self.schedule.step()
+```
+With:
+```python
+self.agents.do("step")
+self.agents.do("advance")
+```
+
+##### StagedActivation
+Replace:
+```python
+self.schedule = StagedActivation(self, ["stage1", "stage2", "stage3"])
+self.schedule.step()
+```
+With:
+```python
+for stage in ["stage1", "stage2", "stage3"]:
+    self.agents.do(stage)
+```
+
+If you were using the `shuffle` and/or `shuffle_between_stages` options:
+```python
+stages = ["stage1", "stage2", "stage3"]
+if shuffle:
+    self.random.shuffle(stages)
+for stage in stages:
+    if shuffle_between_stages:
+        self.agents.shuffle_do(stage)
+    else:
+        self.agents.do(stage)
+```
+
+##### RandomActivationByType
+Replace:
+```python
+self.schedule = RandomActivationByType(self)
+self.schedule.step()
+```
+With:
+```python
+for agent_class in self.agent_types:
+    self.agents_by_type[agent_class].shuffle_do("step")
+```
+
+###### Replacing `step_type`
+The `RandomActivationByType` scheduler had a `step_type` method that allowed stepping only agents of a specific type. To replicate this functionality using AgentSet:
+
+Replace:
+```python
+self.schedule.step_type(AgentType)
+```
+
+With:
+```python
+self.agents_by_type[AgentType].shuffle_do("step")
+```
+
+##### General Notes
+
+1. The `Model.steps` counter is now automatically incremented. You don't need to manage it manually.
+2. If you were using `self.schedule.agents`, replace it with `self.agents`.
+3. If you were using `self.schedule.get_agent_count()`, replace it with `len(self.agents)`.
+4. If you were using `self.schedule.agents_by_type`, replace it with `self.agents_by_type`.
+5. Instead of `self.schedule.add()` and `self.schedule.remove()`, agents are now automatically added to and removed from the model's AgentSet when they are created or removed.
+
+From now on you're now not bound by 5 distinct schedulers, but can mix and match any combination of AgentSet methods (`do`, `shuffle`, `select`, etc.) to get the desired Agent activation.
+
+Ref: Original discussion [#1912](https://github.com/projectmesa/mesa/discussions/1912), decision discussion [#2231](https://github.com/projectmesa/mesa/discussions/2231), example updates [#183](https://github.com/projectmesa/mesa-examples/pull/183) and [#201](https://github.com/projectmesa/mesa-examples/pull/201), PR [#2306](https://github.com/projectmesa/mesa/pull/2306)
+
+### Visualisation
+
+Mesa has adopted a new API for our frontend. If you already migrated to the experimental new SolaraViz you can still use
+the import from mesa.experimental. Otherwise here is a list of things you need to change.
+
+#### Model Initialization
+
+Previously SolaraViz was initialized by providing a `model_cls` and a `model_params`. This has changed to expect a model instance `model`. You can still provide (user-settable) `model_params`, but only if users should be able to change them. It is now also possible to pass in a "reactive model" by first calling `model = solara.reactive(model)`. This is useful for notebook environments. It allows you to pass the model to the SolaraViz Module, but continue to use the model. For example calling `model.value.step()` (notice the extra .value) will automatically update the plots. This currently only automatically works for the step method, you can force visualization updates by calling `model.value.force_update()`.
+
+#### Default space visualization
+
+Previously we included a default space drawer that you could configure with an `agent_portrayal` function. You now have to explicitly create a space drawer with the `agent_portrayal` function
+
+```python
+# old
+from mesa.experimental import SolaraViz
+
+SolaraViz(model_cls, model_params, agent_portrayal=agent_portrayal)
+
+# new
+from mesa.visualization import SolaraViz, make_space_matplotlib
+
+SolaraViz(model, components=[make_space_matplotlib(agent_portrayal)])
+```
+
+#### Plotting "measures"
+
+"Measure" plots also need to be made explicit here. Previously, measure could either be 1) A function that receives a model and returns a solara component or 2) A string or list of string of variables that are collected by the datacollector and are to be plotted as a line plot. 1) still works, but you can pass that function to "components" directly. 2) needs to explicitly call the `make_plot_measure()`function.
+
+```python
+# old
+from mesa.experimental import SolaraViz
+
+def make_plot(model):
+    ...
+
+SolaraViz(model_cls, model_params, measures=[make_plot, "foo", ["bar", "baz"]])
+
+# new
+from mesa.visualization import SolaraViz, make_plot_measure
+
+SolaraViz(model, components=[make_plot, make_plot_measure("foo"), make_plot_measure("bar", "baz")])
+```
+
+#### Plotting text
+
+To plot model-dependent text the experimental SolaraViz provided a `make_text` function that wraps another functions that receives the model and turns its string return value into a solara text component. Again, this other function can now be passed directly to the new SolaraViz components array. It is okay if your function just returns a string.
+
+```python
+# old
+from mesa.experimental import SolaraViz, make_text
+
+def show_steps(model):
+    return f"Steps: {model.steps}"
+
+SolaraViz(model_cls, model_params, measures=make_text(show_steps))
+
+# new
+from mesa.visualisation import SolaraViz
+
+def show_steps(model):
+    return f"Steps: {model.steps}"
+
+SolaraViz(model, components=[show_steps])
+```
diff --git a/mesa/__init__.py b/mesa/__init__.py
index b7eae51e..3754bf57 100644
--- a/mesa/__init__.py
+++ b/mesa/__init__.py
@@ -26,7 +26,7 @@ __all__ = [
 ]
 
 __title__ = "mesa"
-__version__ = "2.3.4"
+__version__ = "2.4.0"
 __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 24220cd0..ec289acb 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -14,11 +14,11 @@ import operator
 import warnings
 import weakref
 from collections import defaultdict
-from collections.abc import Iterable, Iterator, MutableSet, Sequence
+from collections.abc import Hashable, Iterable, Iterator, MutableSet, Sequence
 from random import Random
 
 # mypy
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any, Callable, Literal, overload
 
 if TYPE_CHECKING:
     # We ensure that these are not imported during runtime to prevent cyclic
@@ -49,25 +49,12 @@ class Agent:
         self.model = model
         self.pos: Position | None = 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.agents_[type(self)][self] = None
-            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,
-            )
+        self.model.register_agent(self)
 
     def remove(self) -> None:
         """Remove and delete the agent from the model."""
         with contextlib.suppress(KeyError):
-            self.model.agents_[type(self)].pop(self)
+            self.model.deregister_agent(self)
 
     def step(self) -> None:
         """A single step of the agent."""
@@ -129,9 +116,10 @@ class AgentSet(MutableSet, Sequence):
     def select(
         self,
         filter_func: Callable[[Agent], bool] | None = None,
-        n: int = 0,
+        at_most: int | float = float("inf"),
         inplace: bool = False,
         agent_type: type[Agent] | None = None,
+        n: int | None = None,
     ) -> AgentSet:
         """
         Select a subset of agents from the AgentSet based on a filter function and/or quantity limit.
@@ -139,29 +127,47 @@ class AgentSet(MutableSet, Sequence):
         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.
+            at_most (int | float, optional): The maximum amount of agents to select. Defaults to infinity.
+              - If an integer, at most the first number of matching agents are selected.
+              - If a float between 0 and 1, at most that fraction of original the agents are selected.
             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.
+
+        Notes:
+            - at_most just return the first n or fraction of agents. To take a random sample, shuffle() beforehand.
+            - at_most is an upper limit. When specifying other criteria, the number of agents returned can be smaller.
         """
+        if n is not None:
+            warnings.warn(
+                "The parameter 'n' is deprecated. Use 'at_most' instead.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            at_most = n
 
-        if filter_func is None and agent_type is None and n == 0:
+        inf = float("inf")
+        if filter_func is None and agent_type is None and at_most == inf:
             return self if inplace else copy.copy(self)
 
-        def agent_generator(filter_func=None, agent_type=None, n=0):
+        # Check if at_most is of type float
+        if at_most <= 1.0 and isinstance(at_most, float):
+            at_most = int(len(self) * at_most)  # Note that it rounds down (floor)
+
+        def agent_generator(filter_func, agent_type, at_most):
             count = 0
             for agent in self:
+                if count >= at_most:
+                    break
                 if (not filter_func or filter_func(agent)) and (
                     not agent_type or isinstance(agent, agent_type)
                 ):
                     yield agent
                     count += 1
-                    if 0 < n <= count:
-                        break
 
-        agents = agent_generator(filter_func, agent_type, n)
+        agents = agent_generator(filter_func, agent_type, at_most)
 
         return AgentSet(agents, self.model) if not inplace else self._update(agents)
 
@@ -226,53 +232,179 @@ class AgentSet(MutableSet, Sequence):
         self._agents = weakref.WeakKeyDictionary({agent: None for agent in agents})
         return self
 
-    def do(
-        self, method_name: str, *args, return_results: bool = False, **kwargs
-    ) -> AgentSet | list[Any]:
+    def do(self, method: str | Callable, *args, **kwargs) -> AgentSet:
         """
-        Invoke a method on each agent in the AgentSet.
+        Invoke a method or function 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.
+            method (str, callable): the callable to do on each agent
+
+                                        * in case of str, the name of the method to call on each agent.
+                                        * in case of callable, the function to be called with each agent as first argument
+
+            *args: Variable length argument list passed to the callable being called.
+            **kwargs: Arbitrary keyword arguments passed to the callable being called.
 
         Returns:
-            AgentSet | list[Any]: The results of the method calls if return_results is True, otherwise the AgentSet itself.
+            AgentSet | list[Any]: The results of the callable calls if return_results is True, otherwise the AgentSet itself.
         """
+        try:
+            return_results = kwargs.pop("return_results")
+        except KeyError:
+            return_results = False
+        else:
+            warnings.warn(
+                "Using return_results is deprecated. Use AgenSet.do in case of return_results=False, and "
+                "AgentSet.map in case of return_results=True",
+                stacklevel=2,
+            )
+
+        if return_results:
+            return self.map(method, *args, **kwargs)
+
         # we iterate over the actual weakref keys and check if weakref is alive before calling the method
-        res = [
-            getattr(agent, method_name)(*args, **kwargs)
-            for agentref in self._agents.keyrefs()
-            if (agent := agentref()) is not None
-        ]
+        if isinstance(method, str):
+            for agentref in self._agents.keyrefs():
+                if (agent := agentref()) is not None:
+                    getattr(agent, method)(*args, **kwargs)
+        else:
+            for agentref in self._agents.keyrefs():
+                if (agent := agentref()) is not None:
+                    method(agent, *args, **kwargs)
+
+        return self
 
-        return res if return_results else self
+    def shuffle_do(self, method: str | Callable, *args, **kwargs) -> AgentSet:
+        """Shuffle the agents in the AgentSet and then invoke a method or function on each agent.
 
-    def get(self, attr_names: str | list[str]) -> list[Any]:
+        It's a fast, optimized version of calling shuffle() followed by do().
+        """
+        agents = list(self._agents.keys())
+        self.random.shuffle(agents)
+
+        if isinstance(method, str):
+            for agent in agents:
+                getattr(agent, method)(*args, **kwargs)
+        else:
+            for agent in agents:
+                method(agent, *args, **kwargs)
+
+        return self
+
+    def map(self, method: str | Callable, *args, **kwargs) -> list[Any]:
+        """
+        Invoke a method or function on each agent in the AgentSet and return the results.
+
+        Args:
+            method (str, callable): the callable to apply on each agent
+
+                                        * in case of str, the name of the method to call on each agent.
+                                        * in case of callable, the function to be called with each agent as first argument
+
+            *args: Variable length argument list passed to the callable being called.
+            **kwargs: Arbitrary keyword arguments passed to the callable being called.
+
+        Returns:
+           list[Any]: The results of the callable calls
+        """
+        # we iterate over the actual weakref keys and check if weakref is alive before calling the method
+        if isinstance(method, str):
+            res = [
+                getattr(agent, method)(*args, **kwargs)
+                for agentref in self._agents.keyrefs()
+                if (agent := agentref()) is not None
+            ]
+        else:
+            res = [
+                method(agent, *args, **kwargs)
+                for agentref in self._agents.keyrefs()
+                if (agent := agentref()) is not None
+            ]
+
+        return res
+
+    def agg(self, attribute: str, func: Callable) -> Any:
+        """
+        Aggregate an attribute of all agents in the AgentSet using a specified function.
+
+        Args:
+            attribute (str): The name of the attribute to aggregate.
+            func (Callable): The function to apply to the attribute values (e.g., min, max, sum, np.mean).
+
+        Returns:
+            Any: The result of applying the function to the attribute values. Often a single value.
+        """
+        values = self.get(attribute)
+        return func(values)
+
+    @overload
+    def get(
+        self,
+        attr_names: str,
+        handle_missing: Literal["error", "default"] = "error",
+        default_value: Any = None,
+    ) -> list[Any]: ...
+
+    @overload
+    def get(
+        self,
+        attr_names: list[str],
+        handle_missing: Literal["error", "default"] = "error",
+        default_value: Any = None,
+    ) -> list[list[Any]]: ...
+
+    def get(
+        self,
+        attr_names,
+        handle_missing="error",
+        default_value=None,
+    ):
         """
         Retrieve the specified attribute(s) from each agent in the AgentSet.
 
         Args:
             attr_names (str | list[str]): The name(s) of the attribute(s) to retrieve from each agent.
+            handle_missing (str, optional): How to handle missing attributes. Can be:
+                                            - 'error' (default): raises an AttributeError if attribute is missing.
+                                            - 'default': returns the specified default_value.
+            default_value (Any, optional): The default value to return if 'handle_missing' is set to 'default'
+                                           and the agent does not have the attribute.
 
         Returns:
-            list[Any]: A list with the attribute value for each agent in the set if attr_names is a str
-            list[list[Any]]: A list with a list of attribute values for each agent in the set if attr_names is a list of str
+            list[Any]: A list with the attribute value for each agent if attr_names is a str.
+            list[list[Any]]: A list with a lists of attribute values for each agent if attr_names is a list of str.
 
         Raises:
-            AttributeError if an agent does not have the specified attribute(s)
-
+            AttributeError: If 'handle_missing' is 'error' and the agent does not have the specified attribute(s).
+            ValueError: If an unknown 'handle_missing' option is provided.
         """
+        is_single_attr = isinstance(attr_names, str)
+
+        if handle_missing == "error":
+            if is_single_attr:
+                return [getattr(agent, attr_names) for agent in self._agents]
+            else:
+                return [
+                    [getattr(agent, attr) for attr in attr_names]
+                    for agent in self._agents
+                ]
+
+        elif handle_missing == "default":
+            if is_single_attr:
+                return [
+                    getattr(agent, attr_names, default_value) for agent in self._agents
+                ]
+            else:
+                return [
+                    [getattr(agent, attr, default_value) for attr in attr_names]
+                    for agent in self._agents
+                ]
 
-        if isinstance(attr_names, str):
-            return [getattr(agent, attr_names) for agent in self._agents]
         else:
-            return [
-                [getattr(agent, attr_name) for attr_name in attr_names]
-                for agent in self._agents
-            ]
+            raise ValueError(
+                f"Unknown handle_missing option: {handle_missing}, "
+                "should be one of 'error' or 'default'"
+            )
 
     def set(self, attr_name: str, value: Any) -> AgentSet:
         """
@@ -371,7 +503,139 @@ class AgentSet(MutableSet, Sequence):
         """
         return self.model.random
 
+    def groupby(self, by: Callable | str, result_type: str = "agentset") -> GroupBy:
+        """
+        Group agents by the specified attribute or return from the callable
+
+        Args:
+            by (Callable, str): used to determine what to group agents by
+
+                                * if ``by`` is a callable, it will be called for each agent and the return is used
+                                  for grouping
+                                * if ``by`` is a str, it should refer to an attribute on the agent and the value
+                                  of this attribute will be used for grouping
+            result_type (str, optional): The datatype for the resulting groups {"agentset", "list"}
+        Returns:
+            GroupBy
+
+
+        Notes:
+        There might be performance benefits to using `result_type='list'` if you don't need the advanced functionality
+        of an AgentSet.
+
+        """
+        groups = defaultdict(list)
+
+        if isinstance(by, Callable):
+            for agent in self:
+                groups[by(agent)].append(agent)
+        else:
+            for agent in self:
+                groups[getattr(agent, by)].append(agent)
+
+        if result_type == "agentset":
+            return GroupBy(
+                {k: AgentSet(v, model=self.model) for k, v in groups.items()}
+            )
+        else:
+            return GroupBy(groups)
+
+    # consider adding for performance reasons
+    # for Sequence: __reversed__, index, and count
+    # for MutableSet clear, pop, remove, __ior__, __iand__, __ixor__, and __isub__
+
+
+class GroupBy:
+    """Helper class for AgentSet.groupby
+
+
+    Attributes:
+        groups (dict): A dictionary with the group_name as key and group as values
+
+    """
+
+    def __init__(self, groups: dict[Any, list | AgentSet]):
+        self.groups: dict[Any, list | AgentSet] = groups
+
+    def map(self, method: Callable | str, *args, **kwargs) -> dict[Any, Any]:
+        """Apply the specified callable to each group and return the results.
+
+        Args:
+            method (Callable, str): The callable to apply to each group,
+
+                                    * if ``method`` is a callable, it will be called it will be called with the group as first argument
+                                    * if ``method`` is a str, it should refer to a method on the group
+
+                                    Additional arguments and keyword arguments will be passed on to the callable.
+
+        Returns:
+            dict with group_name as key and the return of the method as value
+
+        Notes:
+            this method is useful for methods or functions that do return something. It
+            will break method chaining. For that, use ``do`` instead.
+
+        """
+        if isinstance(method, str):
+            return {
+                k: getattr(v, method)(*args, **kwargs) for k, v in self.groups.items()
+            }
+        else:
+            return {k: method(v, *args, **kwargs) for k, v in self.groups.items()}
+
+    def do(self, method: Callable | str, *args, **kwargs) -> GroupBy:
+        """Apply the specified callable to each group
+
+        Args:
+            method (Callable, str): The callable to apply to each group,
+
+                                    * if ``method`` is a callable, it will be called it will be called with the group as first argument
+                                    * if ``method`` is a str, it should refer to a method on the group
+
+                                    Additional arguments and keyword arguments will be passed on to the callable.
+
+        Returns:
+            the original GroupBy instance
+
+        Notes:
+            this method is useful for methods or functions that don't return anything and/or
+            if you want to chain multiple do calls
+
+        """
+        if isinstance(method, str):
+            for v in self.groups.values():
+                getattr(v, method)(*args, **kwargs)
+        else:
+            for v in self.groups.values():
+                method(v, *args, **kwargs)
+
+        return self
+
+    def count(self) -> dict[Any, int]:
+        """Return the count of agents in each group.
+
+        Returns:
+            dict: A dictionary mapping group names to the number of agents in each group.
+        """
+        return {k: len(v) for k, v in self.groups.items()}
+
+    def agg(self, attr_name: str, func: Callable) -> dict[Hashable, Any]:
+        """Aggregate the values of a specific attribute across each group using the provided function.
+
+        Args:
+            attr_name (str): The name of the attribute to aggregate.
+            func (Callable): The function to apply (e.g., sum, min, max, mean).
+
+        Returns:
+            dict[Hashable, Any]: A dictionary mapping group names to the result of applying the aggregation function.
+        """
+        return {
+            group_name: func([getattr(agent, attr_name) for agent in group])
+            for group_name, group in self.groups.items()
+        }
+
+    def __iter__(self):
+        return iter(self.groups.items())
 
-# consider adding for performance reasons
-# for Sequence: __reversed__, index, and count
-# for MutableSet clear, pop, remove, __ior__, __iand__, __ixor__, and __isub__
+    def __len__(self):
+        return len(self.groups)
diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py
index d1695332..a6038456 100644
--- a/mesa/batchrunner.py
+++ b/mesa/batchrunner.py
@@ -1,4 +1,5 @@
 import itertools
+import multiprocessing
 from collections.abc import Iterable, Mapping
 from functools import partial
 from multiprocessing import Pool
@@ -8,6 +9,8 @@ from tqdm.auto import tqdm
 
 from mesa.model import Model
 
+multiprocessing.set_start_method("spawn", force=True)
+
 
 def batch_run(
     model_cls: type[Model],
diff --git a/mesa/datacollection.py b/mesa/datacollection.py
index 1c6db20e..c3253e89 100644
--- a/mesa/datacollection.py
+++ b/mesa/datacollection.py
@@ -3,28 +3,30 @@ Mesa Data Collection Module
 ===========================
 
 DataCollector is meant to provide a simple, standard way to collect data
-generated by a Mesa model. It collects three types of data: model-level data,
-agent-level data, and tables.
+generated by a Mesa model. It collects four types of data: model-level data,
+agent-level data, agent-type-level data, and tables.
 
-A DataCollector is instantiated with two dictionaries of reporter names and
-associated variable names or functions for each, one for model-level data and
-one for agent-level data; a third dictionary provides table names and columns.
-Variable names are converted into functions which retrieve attributes of that
-name.
+A DataCollector is instantiated with three dictionaries of reporter names and
+associated variable names or functions for each, one for model-level data,
+one for agent-level data, and one for agent-type-level data; a fourth dictionary
+provides table names and columns. Variable names are converted into functions
+which retrieve attributes of that name.
 
 When the collect() method is called, each model-level function is called, with
 the model as the argument, and the results associated with the relevant
-variable. Then the agent-level functions are called on each agent.
+variable. Then the agent-level functions are called on each agent, and the
+agent-type-level functions are called on each agent of the specified type.
 
 Additionally, other objects can write directly to tables by passing in an
 appropriate dictionary object for a table row.
 
-The DataCollector then stores the data it collects in dictionaries:
     * model_vars maps each reporter to a list of its values
     * tables maps each table to a dictionary, with each column as a key with a
       list as its value.
-    * _agent_records maps each model step to a list of each agents id
+    * _agent_records maps each model step to a list of each agent's id
       and its values.
+    * _agenttype_records maps each model step to a dictionary of agent types,
+      each containing a list of each agent's id and its values.
 
 Finally, DataCollector can create a pandas DataFrame from each collection.
 
@@ -36,6 +38,7 @@ The default DataCollector here makes several assumptions:
 import contextlib
 import itertools
 import types
+import warnings
 from copy import deepcopy
 from functools import partial
 
@@ -46,24 +49,25 @@ with contextlib.suppress(ImportError):
 class DataCollector:
     """Class for collecting data generated by a Mesa model.
 
-    A DataCollector is instantiated with dictionaries of names of model- and
-    agent-level variables to collect, associated with attribute names or
-    functions which actually collect them. When the collect(...) method is
-    called, it collects these attributes and executes these functions one by
-    one and stores the results.
+    A DataCollector is instantiated with dictionaries of names of model-,
+    agent-, and agent-type-level variables to collect, associated with
+    attribute names or functions which actually collect them. When the
+    collect(...) method is called, it collects these attributes and executes
+    these functions one by one and stores the results.
     """
 
     def __init__(
         self,
         model_reporters=None,
         agent_reporters=None,
+        agenttype_reporters=None,
         tables=None,
     ):
-        """
-        Instantiate a DataCollector with lists of model and agent reporters.
-        Both model_reporters and agent_reporters accept a dictionary mapping a
-        variable name to either an attribute name, a function, a method of a class/instance,
-        or a function with parameters placed in a list.
+        """Instantiate a DataCollector with lists of model, agent, and agent-type reporters.
+
+        Both model_reporters, agent_reporters, and agenttype_reporters accept a
+        dictionary mapping a variable name to either an attribute name, a function,
+        a method of a class/instance, or a function with parameters placed in a list.
 
         Model reporters can take four types of arguments:
         1. Lambda function:
@@ -87,6 +91,10 @@ class DataCollector:
         4. Functions with parameters placed in a list:
            {"Agent_Function": [function, [param_1, param_2]]}
 
+        Agenttype reporters take a dictionary mapping agent types to dictionaries
+        of reporter names and attributes/funcs/methods, similar to agent_reporters:
+           {Wolf: {"energy": lambda a: a.energy}}
+
         The tables arg accepts a dictionary mapping names of tables to lists of
         columns. For example, if we want to allow agents to write their age
         when they are destroyed (to keep track of lifespans), it might look
@@ -96,6 +104,8 @@ class DataCollector:
         Args:
             model_reporters: Dictionary of reporter names and attributes/funcs/methods.
             agent_reporters: Dictionary of reporter names and attributes/funcs/methods.
+            agenttype_reporters: Dictionary of agent types to dictionaries of
+                                 reporter names and attributes/funcs/methods.
             tables: Dictionary of table names to lists of column names.
 
         Notes:
@@ -105,9 +115,11 @@ class DataCollector:
         """
         self.model_reporters = {}
         self.agent_reporters = {}
+        self.agenttype_reporters = {}
 
         self.model_vars = {}
         self._agent_records = {}
+        self._agenttype_records = {}
         self.tables = {}
 
         if model_reporters is not None:
@@ -118,6 +130,11 @@ class DataCollector:
             for name, reporter in agent_reporters.items():
                 self._new_agent_reporter(name, reporter)
 
+        if agenttype_reporters is not None:
+            for agent_type, reporters in agenttype_reporters.items():
+                for name, reporter in reporters.items():
+                    self._new_agenttype_reporter(agent_type, name, reporter)
+
         if tables is not None:
             for name, columns in tables.items():
                 self._new_table(name, columns)
@@ -165,6 +182,38 @@ class DataCollector:
 
         self.agent_reporters[name] = reporter
 
+    def _new_agenttype_reporter(self, agent_type, name, reporter):
+        """Add a new agent-type-level reporter to collect.
+
+        Args:
+            agent_type: The type of agent to collect data for.
+            name: Name of the agent-type-level variable to collect.
+            reporter: Attribute string, function object, method of a class/instance, or
+                      function with parameters placed in a list that returns the
+                      variable when given an agent instance.
+        """
+        if agent_type not in self.agenttype_reporters:
+            self.agenttype_reporters[agent_type] = {}
+
+        # Use the same logic as _new_agent_reporter
+        if isinstance(reporter, str):
+            attribute_name = reporter
+
+            def attr_reporter(agent):
+                return getattr(agent, attribute_name, None)
+
+            reporter = attr_reporter
+
+        elif isinstance(reporter, list):
+            func, params = reporter[0], reporter[1]
+
+            def func_with_params(agent):
+                return func(agent, *params)
+
+            reporter = func_with_params
+
+        self.agenttype_reporters[agent_type][name] = reporter
+
     def _new_table(self, table_name, table_columns):
         """Add a new table that objects can write to.
 
@@ -192,6 +241,34 @@ class DataCollector:
         )
         return agent_records
 
+    def _record_agenttype(self, model, agent_type):
+        """Record agent-type data in a mapping of functions and agents."""
+        rep_funcs = self.agenttype_reporters[agent_type].values()
+
+        def get_reports(agent):
+            _prefix = (agent.model._steps, agent.unique_id)
+            reports = tuple(rep(agent) for rep in rep_funcs)
+            return _prefix + reports
+
+        agent_types = model.agent_types
+        if agent_type in agent_types:
+            agents = model.agents_by_type[agent_type]
+        else:
+            from mesa import Agent
+
+            if issubclass(agent_type, Agent):
+                agents = [
+                    agent for agent in model.agents if isinstance(agent, agent_type)
+                ]
+            else:
+                # Raise error if agent_type is not in model.agent_types
+                raise ValueError(
+                    f"Agent type {agent_type} is not recognized as an Agent type in the model or Agent subclass. Use an Agent (sub)class, like {agent_types}."
+                )
+
+        agenttype_records = map(get_reports, agents)
+        return agenttype_records
+
     def collect(self, model):
         """Collect all the data for the given model object."""
         if self.model_reporters:
@@ -218,6 +295,14 @@ class DataCollector:
             agent_records = self._record_agents(model)
             self._agent_records[model._steps] = list(agent_records)
 
+        if self.agenttype_reporters:
+            self._agenttype_records[model._steps] = {}
+            for agent_type in self.agenttype_reporters:
+                agenttype_records = self._record_agenttype(model, agent_type)
+                self._agenttype_records[model._steps][agent_type] = list(
+                    agenttype_records
+                )
+
     def add_table_row(self, table_name, row, ignore_missing=False):
         """Add a row dictionary to a specific table.
 
@@ -274,6 +359,36 @@ class DataCollector:
         )
         return df
 
+    def get_agenttype_vars_dataframe(self, agent_type):
+        """Create a pandas DataFrame from the agent-type variables for a specific agent type.
+        The DataFrame has one column for each variable, with two additional
+        columns for tick and agent_id.
+        Args:
+            agent_type: The type of agent to get the data for.
+        """
+        # Check if self.agenttype_reporters dictionary is empty for this agent type, if so return empty DataFrame
+        if agent_type not in self.agenttype_reporters:
+            warnings.warn(
+                f"No agent-type reporters have been defined for {agent_type} in the DataCollector, returning empty DataFrame.",
+                UserWarning,
+                stacklevel=2,
+            )
+            return pd.DataFrame()
+
+        all_records = itertools.chain.from_iterable(
+            records[agent_type]
+            for records in self._agenttype_records.values()
+            if agent_type in records
+        )
+        rep_names = list(self.agenttype_reporters[agent_type])
+
+        df = pd.DataFrame.from_records(
+            data=all_records,
+            columns=["Step", "AgentID", *rep_names],
+            index=["Step", "AgentID"],
+        )
+        return df
+
     def get_table_dataframe(self, table_name):
         """Create a pandas DataFrame from a particular table.
 
diff --git a/mesa/experimental/devs/examples/epstein_civil_violence.py b/mesa/experimental/devs/examples/epstein_civil_violence.py
index 9a7314e0..8b044751 100644
--- a/mesa/experimental/devs/examples/epstein_civil_violence.py
+++ b/mesa/experimental/devs/examples/epstein_civil_violence.py
@@ -261,7 +261,8 @@ class EpsteinCivilViolence(Model):
         self.active_agents = self.agents
 
     def step(self):
-        self.active_agents.shuffle(inplace=True).do("step")
+        """Run one step of the model."""
+        self.active_agents.shuffle_do("step")
 
 
 if __name__ == "__main__":
diff --git a/mesa/experimental/devs/examples/wolf_sheep.py b/mesa/experimental/devs/examples/wolf_sheep.py
index 9fa6b3d9..f11b697f 100644
--- a/mesa/experimental/devs/examples/wolf_sheep.py
+++ b/mesa/experimental/devs/examples/wolf_sheep.py
@@ -231,8 +231,9 @@ class WolfSheep(mesa.Model):
             self.grid.place_agent(patch, pos)
 
     def step(self):
-        self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
-        self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")
+        """Perform one step of the model."""
+        self.agents_by_type[Sheep].shuffle_do("step")
+        self.agents_by_type[Wolf].shuffle_do("step")
 
 
 if __name__ == "__main__":
diff --git a/mesa/experimental/jupyter_viz.py b/mesa/experimental/jupyter_viz.py
index a4dec7f6..c69b2b89 100644
--- a/mesa/experimental/jupyter_viz.py
+++ b/mesa/experimental/jupyter_viz.py
@@ -143,14 +143,15 @@ def JupyterViz(
             # otherwise, do nothing (do not draw space)
 
             # 5. Plots
-            for measure in measures:
-                if callable(measure):
-                    # Is a custom object
-                    measure(model)
-                else:
-                    components_matplotlib.PlotMatplotlib(
-                        model, measure, dependencies=dependencies
-                    )
+            if measures:
+                for measure in measures:
+                    if callable(measure):
+                        # Is a custom object
+                        measure(model)
+                    else:
+                        components_matplotlib.PlotMatplotlib(
+                            model, measure, dependencies=dependencies
+                        )
 
     def render_in_browser():
         # if space drawer is disabled, do not include it
diff --git a/mesa/model.py b/mesa/model.py
index 724fea2e..71dcc149 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -8,10 +8,8 @@ Core Objects: Model
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
 
-import itertools
 import random
 import warnings
-from collections import defaultdict
 
 # mypy
 from typing import Any, Union
@@ -33,20 +31,28 @@ 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.
-                 This private attribute is used internally to manage agents.
 
     Properties:
-        agents: An AgentSet containing all agents in the model, generated from the _agents attribute.
+        agents: An AgentSet containing all agents in the model
         agent_types: A list of different agent types present in the model.
+        agents_by_type: A dictionary where the keys are agent types and the values are the corresponding AgentSets.
 
     Methods:
         get_agents_of_type: Returns an AgentSet of agents of the specified type.
+            Deprecated: Use agents_by_type[agenttype] instead.
         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.
+        register_agent : register an agent with the model
+        deregister_agent : remove an agent from the model
+
+    Notes:
+        Model.agents returns the AgentSet containing all agents registered with the model. Changing
+        the content of the AgentSet directly can result in strange behavior. If you want change the
+        composition of this AgentSet, ensure you operate on a copy.
+
     """
 
     def __new__(cls, *args: Any, **kwargs: Any) -> Any:
@@ -58,6 +64,7 @@ class Model:
             # advance.
             obj._seed = random.random()
         obj.random = random.Random(obj._seed)
+
         # TODO: Remove these 2 lines just before Mesa 3.0
         obj._steps = 0
         obj._time = 0
@@ -72,7 +79,8 @@ class Model:
         self.running = True
         self.schedule = None
         self.current_id = 0
-        self.agents_: defaultdict[type, dict] = defaultdict(dict)
+
+        self._setup_agent_registration()
 
         self._steps: int = 0
         self._time: TimeT = 0  # the model's clock
@@ -80,33 +88,93 @@ class Model:
     @property
     def agents(self) -> AgentSet:
         """Provides an AgentSet of all agents in the model, combining agents from all types."""
-
-        if hasattr(self, "_agents"):
-            return self._agents
-        else:
-            all_agents = itertools.chain.from_iterable(self.agents_.values())
-            return AgentSet(all_agents, self)
+        return self._all_agents
 
     @agents.setter
     def agents(self, agents: Any) -> None:
         warnings.warn(
-            "You are trying to set model.agents. In a next release, this attribute is used "
-            "by MESA itself so you cannot use it directly anymore."
-            "Please adjust your code to use a different attribute name for custom agent storage",
+            "You are trying to set model.agents. In Mesa 3.0 and higher, this attribute is "
+            "used by Mesa itself, so you cannot use it directly anymore."
+            "Please adjust your code to use a different attribute name for custom agent storage.",
             UserWarning,
             stacklevel=2,
         )
 
-        self._agents = agents
-
     @property
     def agent_types(self) -> list[type]:
-        """Return a list of different agent types."""
-        return list(self.agents_.keys())
+        """Return a list of all unique agent types registered with the model."""
+        return list(self._agents_by_type.keys())
+
+    @property
+    def agents_by_type(self) -> dict[type[Agent], AgentSet]:
+        """A dictionary where the keys are agent types and the values are the corresponding AgentSets."""
+        return self._agents_by_type
 
     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)
+        """Deprecated: Retrieves an AgentSet containing all agents of the specified type."""
+        warnings.warn(
+            f"Model.get_agents_of_type() is deprecated, please replace get_agents_of_type({agenttype})"
+            f"with the property agents_by_type[{agenttype}].",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return self.agents_by_type[agenttype]
+
+    def _setup_agent_registration(self):
+        """helper method to initialize the agent registration datastructures"""
+        self._agents = {}  # the hard references to all agents in the model
+        self._agents_by_type: dict[
+            type[Agent], AgentSet
+        ] = {}  # a dict with an agentset for each class of agents
+        self._all_agents = AgentSet([], self)  # an agenset with all agents
+
+    def register_agent(self, agent):
+        """Register the agent with the model
+
+        Args:
+            agent: The agent to register.
+
+        Notes:
+            This method is called automatically by ``Agent.__init__``, so there is no need to use this
+            if you are subclassing Agent and calling its super in the ``__init__`` method.
+
+        """
+        if not hasattr(self, "_agents"):
+            self._setup_agent_registration()
+
+            warnings.warn(
+                "The Mesa Model class was not initialized. In the future, you need to explicitly initialize "
+                "the Model by calling super().__init__() on initialization.",
+                FutureWarning,
+                stacklevel=2,
+            )
+
+        self._agents[agent] = None
+
+        # because AgentSet requires model, we cannot use defaultdict
+        # tricks with a function won't work because model then cannot be pickled
+        try:
+            self._agents_by_type[type(agent)].add(agent)
+        except KeyError:
+            self._agents_by_type[type(agent)] = AgentSet(
+                [
+                    agent,
+                ],
+                self,
+            )
+
+        self._all_agents.add(agent)
+
+    def deregister_agent(self, agent):
+        """Deregister the agent with the model
+
+        Notes::
+        This method is called automatically by ``Agent.remove``
+
+        """
+        del self._agents[agent]
+        self._agents_by_type[type(agent)].remove(agent)
+        self._all_agents.remove(agent)
 
     def run_model(self) -> None:
         """Run the model until the end condition is reached. Overload as
@@ -144,6 +212,7 @@ class Model:
         self,
         model_reporters=None,
         agent_reporters=None,
+        agenttype_reporters=None,
         tables=None,
     ) -> None:
         if not hasattr(self, "schedule") or self.schedule is None:
@@ -157,6 +226,7 @@ class Model:
         self.datacollector = DataCollector(
             model_reporters=model_reporters,
             agent_reporters=agent_reporters,
+            agenttype_reporters=agenttype_reporters,
             tables=tables,
         )
         # Collect data for the first time during initialization.
diff --git a/mesa/space.py b/mesa/space.py
index d9aa7fb6..e464b8d1 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -459,15 +459,21 @@ class _Grid:
             elif selection == "closest":
                 current_pos = agent.pos
                 # Find the closest position without sorting all positions
-                closest_pos = None
+                # TODO: See if this method can be optimized further
+                closest_pos = []
                 min_distance = float("inf")
                 agent.random.shuffle(pos)
                 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
+                        closest_pos.clear()
+                        closest_pos.append(p)
+                    elif distance == min_distance:
+                        closest_pos.append(p)
+
+                chosen_pos = agent.random.choice(closest_pos)
+
             else:
                 raise ValueError(
                     f"Invalid selection method {selection}. Choose 'random' or 'closest'."
diff --git a/pyproject.toml b/pyproject.toml
index 2ec2d3f1..9cb3186f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,7 @@ classifiers = [
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
+  "Programming Language :: Python :: 3.13",
   "License :: OSI Approved :: Apache Software License",
   "Operating System :: OS Independent",
   "Development Status :: 3 - Alpha",


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 0033d7a1..4ea8c8b1 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -1,5 +1,6 @@
 import pickle
 
+import numpy as np
 import pytest
 
 from mesa.agent import Agent, AgentSet
@@ -59,6 +60,12 @@ def test_agentset():
     def test_function(agent):
         return agent.unique_id > 5
 
+    assert len(agentset.select(at_most=0.2)) == 2  # Select 20% of agents
+    assert len(agentset.select(at_most=0.549)) == 5  # Select 50% of agents
+    assert len(agentset.select(at_most=0.09)) == 0  # Select 0% of agents
+    assert len(agentset.select(at_most=1.0)) == 10  # Select 100% agents
+    assert len(agentset.select(at_most=1)) == 1  # Select 1 agent
+
     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
@@ -83,12 +90,6 @@ def test_agentset():
     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])
@@ -176,7 +177,7 @@ def test_agentset_get_item():
         _ = agentset[20]
 
 
-def test_agentset_do_method():
+def test_agentset_do_str():
     model = Model()
     agents = [TestAgent(model.next_id(), model) for _ in range(10)]
     agentset = AgentSet(agents, model)
@@ -210,6 +211,181 @@ def test_agentset_do_method():
     assert len(agentset) == 0
 
 
+def test_agentset_do_callable():
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+
+    # Test callable with non-existent function
+    with pytest.raises(AttributeError):
+        agentset.do(lambda agent: agent.non_existing_method())
+
+    # tests for addition and removal in do using callables
+    # do iterates, so no error should be raised to change size while iterating
+    # related to issue #1595
+
+    # setup for lambda function tests
+    n = 10
+    model = Model()
+    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agentset = AgentSet(agents, model)
+    for agent in agents:
+        agent.agent_set = agentset
+
+    # Lambda for addition
+    agentset.do(lambda agent: agent.do_add())
+    assert len(agentset) == 2 * n
+
+    # setup again for lambda function tests
+    model = Model()
+    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+    for agent in agents:
+        agent.agent_set = agentset
+
+    # Lambda for removal
+    agentset.do(lambda agent: agent.do_remove())
+    assert len(agentset) == 0
+
+    # setup for actual function tests
+    def add_function(agent):
+        agent.do_add()
+
+    def remove_function(agent):
+        agent.do_remove()
+
+    # setup again for actual function tests
+    model = Model()
+    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agentset = AgentSet(agents, model)
+    for agent in agents:
+        agent.agent_set = agentset
+
+    # Actual function for addition
+    agentset.do(add_function)
+    assert len(agentset) == 2 * n
+
+    # setup again for actual function tests
+    model = Model()
+    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+    for agent in agents:
+        agent.agent_set = agentset
+
+    # Actual function for removal
+    agentset.do(remove_function)
+    assert len(agentset) == 0
+
+
+def test_agentset_get():
+    model = Model()
+    _ = [TestAgent(i, model) for i in range(10)]
+
+    agentset = model.agents
+
+    agentset.set("a", 5)
+    agentset.set("b", 6)
+
+    # Case 1: Normal retrieval of existing attributes
+    values = agentset.get(["a", "b"])
+    assert all((a == 5) & (b == 6) for a, b in values)
+
+    # Case 2: Raise AttributeError when attribute doesn't exist
+    with pytest.raises(AttributeError):
+        agentset.get("unknown_attribute")
+
+    # Case 3: Use default value when attribute is missing
+    results = agentset.get(
+        "unknown_attribute", handle_missing="default", default_value=True
+    )
+    assert all(results) is True
+
+    # Case 4: Retrieve mixed attributes with default value for missing ones
+    values = agentset.get(
+        ["a", "unknown_attribute"], handle_missing="default", default_value=True
+    )
+    assert all((a == 5) & (unknown is True) for a, unknown in values)
+
+    # Case 5: Invalid handle_missing value raises ValueError
+    with pytest.raises(ValueError):
+        agentset.get("unknown_attribute", handle_missing="some nonsense value")
+
+    # Case 6: Retrieve multiple attributes with mixed existence and 'default' handling
+    values = agentset.get(
+        ["a", "b", "unknown_attribute"], handle_missing="default", default_value=0
+    )
+    assert all((a == 5) & (b == 6) & (unknown == 0) for a, b, unknown in values)
+
+    # Case 7: 'default' handling when one attribute is completely missing from some agents
+    agentset.select(at_most=0.5).set("c", 8)  # Only some agents have attribute 'c'
+    values = agentset.get(["a", "c"], handle_missing="default", default_value=-1)
+    assert all((a == 5) & (c in [8, -1]) for a, c in values)
+
+
+def test_agentset_agg():
+    model = Model()
+    agents = [TestAgent(i, model) for i in range(10)]
+
+    # Assign some values to attributes
+    for i, agent in enumerate(agents):
+        agent.energy = i + 1
+        agent.wealth = 10 * (i + 1)
+
+    agentset = AgentSet(agents, model)
+
+    # Test min aggregation
+    min_energy = agentset.agg("energy", min)
+    assert min_energy == 1
+
+    # Test max aggregation
+    max_energy = agentset.agg("energy", max)
+    assert max_energy == 10
+
+    # Test sum aggregation
+    total_energy = agentset.agg("energy", sum)
+    assert total_energy == sum(range(1, 11))
+
+    # Test mean aggregation using numpy
+    avg_wealth = agentset.agg("wealth", np.mean)
+    assert avg_wealth == 55.0
+
+    # Test aggregation with a custom function
+    def custom_func(values):
+        return sum(values) / len(values)
+
+    custom_avg_energy = agentset.agg("energy", custom_func)
+    assert custom_avg_energy == 5.5
+
+
+def test_agentset_map_str():
+    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")
+
+    results = agentset.map("get_unique_identifier")
+    assert all(i == entry for i, entry in zip(results, range(1, 11)))
+
+
+def test_agentset_map_callable():
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+
+    # Test callable with non-existent function
+    with pytest.raises(AttributeError):
+        agentset.map(lambda agent: agent.non_existing_method())
+
+    # tests for addition and removal in do using callables
+    # do iterates, so no error should be raised to change size while iterating
+    # related to issue #1595
+
+    results = agentset.map(lambda agent: agent.unique_id)
+    assert all(i == entry for i, entry in zip(results, range(1, 11)))
+
+
 def test_agentset_set_method():
     # Initialize the model and agents with and without existing attributes
     class TestAgentWithAttribute(Agent):
@@ -234,6 +410,42 @@ def test_agentset_set_method():
         assert agent.status == "active"
 
 
+def test_agentset_shuffle_do():
+    """Test AgentSet.shuffle_do method."""
+    model = Model()
+
+    class TestAgentShuffleDo(Agent):
+        def __init__(self, unique_id, model):
+            super().__init__(unique_id, model)
+            self.called = False
+
+        def test_method(self):
+            self.called = True
+
+    agents = [TestAgentShuffleDo(i, model) for i in range(100)]
+    agentset = AgentSet(agents, model)
+
+    # Test shuffle_do with a string method name
+    agentset.shuffle_do("test_method")
+    assert all(agent.called for agent in agents)
+
+    # Reset the called flag
+    for agent in agents:
+        agent.called = False
+
+    # Test shuffle_do with a callable
+    agentset.shuffle_do(lambda agent: setattr(agent, "called", True))
+    assert all(agent.called for agent in agents)
+
+    # Verify that the order is indeed shuffled
+    original_order = list(agentset)
+    shuffled_order = []
+    agentset.shuffle_do(lambda agent: shuffled_order.append(agent))
+    assert (
+        original_order != shuffled_order
+    ), "The order should be different after shuffle_do"
+
+
 def test_agentset_get_attribute():
     model = Model()
     agents = [TestAgent(model.next_id(), model) for _ in range(10)]
@@ -306,3 +518,77 @@ def test_agentset_shuffle():
     agentset = AgentSet(test_agents, model=model)
     agentset.shuffle(inplace=True)
     assert not all(a1 == a2 for a1, a2 in zip(test_agents, agentset))
+
+
+def test_agentset_groupby():
+    class TestAgent(Agent):
+        def __init__(self, unique_id, model):
+            super().__init__(unique_id, model)
+            self.even = self.unique_id % 2 == 0
+            self.value = self.unique_id * 10
+
+        def get_unique_identifier(self):
+            return self.unique_id
+
+    model = Model()
+    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agentset = AgentSet(agents, model)
+
+    groups = agentset.groupby("even")
+    assert len(groups.groups[True]) == 5
+    assert len(groups.groups[False]) == 5
+
+    groups = agentset.groupby(lambda a: a.unique_id % 2 == 0)
+    assert len(groups.groups[True]) == 5
+    assert len(groups.groups[False]) == 5
+    assert len(groups) == 2
+
+    for group_name, group in groups:
+        assert len(group) == 5
+        assert group_name in {True, False}
+
+    sizes = agentset.groupby("even", result_type="list").map(len)
+    assert sizes == {True: 5, False: 5}
+
+    attributes = agentset.groupby("even", result_type="agentset").map("get", "even")
+    for group_name, group in attributes.items():
+        assert all(group_name == entry for entry in group)
+
+    groups = agentset.groupby("even", result_type="agentset")
+    another_ref_to_groups = groups.do("do", "step")
+    assert groups == another_ref_to_groups
+
+    groups = agentset.groupby("even", result_type="agentset")
+    another_ref_to_groups = groups.do(lambda x: x.do("step"))
+    assert groups == another_ref_to_groups
+
+    # New tests for count() method
+    groups = agentset.groupby("even")
+    count_result = groups.count()
+    assert count_result == {True: 5, False: 5}
+
+    # New tests for agg() method
+    groups = agentset.groupby("even")
+    sum_result = groups.agg("value", sum)
+    assert sum_result[True] == sum(agent.value for agent in agents if agent.even)
+    assert sum_result[False] == sum(agent.value for agent in agents if not agent.even)
+
+    max_result = groups.agg("value", max)
+    assert max_result[True] == max(agent.value for agent in agents if agent.even)
+    assert max_result[False] == max(agent.value for agent in agents if not agent.even)
+
+    min_result = groups.agg("value", min)
+    assert min_result[True] == min(agent.value for agent in agents if agent.even)
+    assert min_result[False] == min(agent.value for agent in agents if not agent.even)
+
+    # Test with a custom aggregation function
+    def custom_agg(values):
+        return sum(values) / len(values) if values else 0
+
+    custom_result = groups.agg("value", custom_agg)
+    assert custom_result[True] == custom_agg(
+        [agent.value for agent in agents if agent.even]
+    )
+    assert custom_result[False] == custom_agg(
+        [agent.value for agent in agents if not agent.even]
+    )
diff --git a/tests/test_datacollector.py b/tests/test_datacollector.py
index bf74e379..04b281fe 100644
--- a/tests/test_datacollector.py
+++ b/tests/test_datacollector.py
@@ -5,6 +5,7 @@ Test the DataCollector
 import unittest
 
 from mesa import Agent, Model
+from mesa.datacollection import DataCollector
 from mesa.time import BaseScheduler
 
 
@@ -40,12 +41,28 @@ def agent_function_with_params(agent, multiplier, offset):
     return (agent.val * multiplier) + offset
 
 
-class DifferentMockAgent(MockAgent):
-    # We define a different MockAgent to test for attributes that are present
-    # only in 1 type of agent, but not the other.
+class MockAgentA(MockAgent):
+    """Agent subclass A for testing agent-type-specific reporters."""
+
     def __init__(self, unique_id, model, val=0):
-        super().__init__(unique_id, model, val=val)
-        self.val3 = val + 42
+        super().__init__(unique_id, model, val)
+        self.type_a_val = val * 2
+
+    def step(self):
+        super().step()
+        self.type_a_val = self.val * 2
+
+
+class MockAgentB(MockAgent):
+    """Agent subclass B for testing agent-type-specific reporters."""
+
+    def __init__(self, unique_id, model, val=0):
+        super().__init__(unique_id, model, val)
+        self.type_b_val = val * 3
+
+    def step(self):
+        super().step()
+        self.type_b_val = self.val * 3
 
 
 class MockModel(Model):
@@ -92,6 +109,34 @@ class MockModel(Model):
         self.datacollector.collect(self)
 
 
+class MockModelWithAgentTypes(Model):
+    """Model for testing agent-type-specific reporters."""
+
+    def __init__(self):
+        super().__init__()
+        self.model_val = 100
+
+        for i in range(10):
+            if i % 2 == 0:
+                MockAgentA(i, self, val=i)
+            else:
+                MockAgentB(i, self, val=i)
+
+        self.datacollector = DataCollector(
+            model_reporters={"total_agents": lambda m: len(m.agents)},
+            agent_reporters={"value": lambda a: a.val},
+            agenttype_reporters={
+                MockAgentA: {"type_a_val": lambda a: a.type_a_val},
+                MockAgentB: {"type_b_val": lambda a: a.type_b_val},
+            },
+        )
+
+    def step(self):
+        self._steps += 1
+        self.agents.do("step")
+        self.datacollector.collect(self)
+
+
 class TestDataCollector(unittest.TestCase):
     def setUp(self):
         """
@@ -230,5 +275,131 @@ class TestDataCollectorInitialization(unittest.TestCase):
         )
 
 
+class TestDataCollectorWithAgentTypes(unittest.TestCase):
+    """Tests for DataCollector with agent-type-specific reporters."""
+
+    def setUp(self):
+        """Create the model and run it a set number of steps."""
+        self.model = MockModelWithAgentTypes()
+        for _ in range(5):
+            self.model.step()
+
+    def test_agenttype_vars(self):
+        """Test agent-type-specific variable collection."""
+        data_collector = self.model.datacollector
+
+        # Test MockAgentA data
+        agent_a_data = data_collector.get_agenttype_vars_dataframe(MockAgentA)
+        self.assertIn("type_a_val", agent_a_data.columns)
+        self.assertEqual(len(agent_a_data), 25)  # 5 agents * 5 steps
+        for (step, agent_id), value in agent_a_data["type_a_val"].items():
+            expected_value = agent_id * 2 + step * 2
+            self.assertEqual(value, expected_value)
+
+        # Test MockAgentB data
+        agent_b_data = data_collector.get_agenttype_vars_dataframe(MockAgentB)
+        self.assertIn("type_b_val", agent_b_data.columns)
+        self.assertEqual(len(agent_b_data), 25)  # 5 agents * 5 steps
+        for (step, agent_id), value in agent_b_data["type_b_val"].items():
+            expected_value = agent_id * 3 + step * 3
+            self.assertEqual(value, expected_value)
+
+    def test_agenttype_and_agent_vars(self):
+        """Test that agent-type-specific and general agent variables are collected correctly."""
+        data_collector = self.model.datacollector
+
+        agent_vars = data_collector.get_agent_vars_dataframe()
+        agent_a_vars = data_collector.get_agenttype_vars_dataframe(MockAgentA)
+        agent_b_vars = data_collector.get_agenttype_vars_dataframe(MockAgentB)
+
+        # Check that general agent variables are present for all agents
+        self.assertIn("value", agent_vars.columns)
+
+        # Check that agent-type-specific variables are only present in their respective dataframes
+        self.assertIn("type_a_val", agent_a_vars.columns)
+        self.assertNotIn("type_a_val", agent_b_vars.columns)
+        self.assertIn("type_b_val", agent_b_vars.columns)
+        self.assertNotIn("type_b_val", agent_a_vars.columns)
+
+    def test_nonexistent_agenttype(self):
+        """Test that requesting data for a non-existent agent type raises a warning."""
+        data_collector = self.model.datacollector
+
+        class NonExistentAgent(Agent):
+            pass
+
+        with self.assertWarns(UserWarning):
+            non_existent_data = data_collector.get_agenttype_vars_dataframe(
+                NonExistentAgent
+            )
+            self.assertTrue(non_existent_data.empty)
+
+    def test_agenttype_reporter_string_attribute(self):
+        """Test agent-type-specific reporter with string attribute."""
+        model = MockModelWithAgentTypes()
+        model.datacollector._new_agenttype_reporter(MockAgentA, "string_attr", "val")
+        model.step()
+
+        agent_a_data = model.datacollector.get_agenttype_vars_dataframe(MockAgentA)
+        self.assertIn("string_attr", agent_a_data.columns)
+        for (_step, agent_id), value in agent_a_data["string_attr"].items():
+            expected_value = agent_id + 1
+            self.assertEqual(value, expected_value)
+
+    def test_agenttype_reporter_function_with_params(self):
+        """Test agent-type-specific reporter with function and parameters."""
+
+        def test_func(agent, multiplier):
+            return agent.val * multiplier
+
+        model = MockModelWithAgentTypes()
+        model.datacollector._new_agenttype_reporter(
+            MockAgentB, "func_param", [test_func, [2]]
+        )
+        model.step()
+
+        agent_b_data = model.datacollector.get_agenttype_vars_dataframe(MockAgentB)
+        self.assertIn("func_param", agent_b_data.columns)
+        for (_step, agent_id), value in agent_b_data["func_param"].items():
+            expected_value = (agent_id + 1) * 2
+            self.assertEqual(value, expected_value)
+
+    def test_agenttype_reporter_multiple_types(self):
+        """Test adding reporters for multiple agent types."""
+        model = MockModelWithAgentTypes()
+        model.datacollector._new_agenttype_reporter(
+            MockAgentA, "type_a_val", lambda a: a.type_a_val
+        )
+        model.datacollector._new_agenttype_reporter(
+            MockAgentB, "type_b_val", lambda a: a.type_b_val
+        )
+        model.step()
+
+        agent_a_data = model.datacollector.get_agenttype_vars_dataframe(MockAgentA)
+        agent_b_data = model.datacollector.get_agenttype_vars_dataframe(MockAgentB)
+
+        self.assertIn("type_a_val", agent_a_data.columns)
+        self.assertIn("type_b_val", agent_b_data.columns)
+        self.assertNotIn("type_b_val", agent_a_data.columns)
+        self.assertNotIn("type_a_val", agent_b_data.columns)
+
+    def test_agenttype_superclass_reporter(self):
+        """Test adding a reporter for a superclass of an agent type."""
+        model = MockModelWithAgentTypes()
+        model.datacollector._new_agenttype_reporter(MockAgent, "val", lambda a: a.val)
+        model.datacollector._new_agenttype_reporter(Agent, "val", lambda a: a.val)
+        for _ in range(3):
+            model._steps += 1
+            model.step()
+
+        super_data = model.datacollector.get_agenttype_vars_dataframe(MockAgent)
+        agent_data = model.datacollector.get_agenttype_vars_dataframe(Agent)
+        self.assertIn("val", super_data.columns)
+        self.assertIn("val", agent_data.columns)
+        self.assertEqual(len(super_data), 30)  # 10 agents * 3 steps
+        self.assertEqual(len(agent_data), 30)
+        self.assertTrue(super_data.equals(agent_data))
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_model.py b/tests/test_model.py
index 874d45f9..a7d6ad55 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -1,4 +1,4 @@
-from mesa.agent import Agent
+from mesa.agent import Agent, AgentSet
 from mesa.model import Model
 
 
@@ -51,3 +51,19 @@ def test_agent_types():
     test_agent = TestAgent(model.next_id(), model)
     assert test_agent in model.agents
     assert type(test_agent) in model.agent_types
+
+
+def test_agents_by_type():
+    class Wolf(Agent):
+        pass
+
+    class Sheep(Agent):
+        pass
+
+    model = Model()
+    wolf = Wolf(1, model)
+    sheep = Sheep(2, model)
+
+    assert model.agents_by_type[Wolf] == AgentSet([wolf], model)
+    assert model.agents_by_type[Sheep] == AgentSet([sheep], model)
+    assert len(model.agents_by_type) == 2
diff --git a/tests/test_space.py b/tests/test_space.py
index 23cf8c95..2da9ee2a 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -509,6 +509,23 @@ class TestSingleGrid(unittest.TestCase):
         self.space.move_agent_to_one_of(agent, possible_positions, selection="closest")
         assert agent.pos == (6, 6)
 
+    def test_move_agent_closest_selection_multiple(self):
+        random_locations = []
+        agent = self.agents[0]
+        agent.pos = (5, 5)
+        repetititions = 10
+
+        for _ in range(repetititions):
+            possible_positions = [(4, 4), (6, 6), (10, 10), (20, 20)]
+            self.space.move_agent_to_one_of(
+                agent, possible_positions, selection="closest"
+            )
+            random_locations.append(agent.pos)
+            assert agent.pos in possible_positions
+            self.space.move_agent_to_one_of(agent, [(5, 5)], selection="closest")
+        non_random_locations = [random_locations[0]] * repetititions
+        assert random_locations != non_random_locations
+
     def test_move_agent_invalid_selection(self):
         agent = self.agents[0]
         possible_positions = [(10, 10), (20, 20), (30, 30)]
diff --git a/tests/test_time.py b/tests/test_time.py
index a9d8e2e6..4203ca5d 100644
--- a/tests/test_time.py
+++ b/tests/test_time.py
@@ -320,7 +320,7 @@ class TestRandomActivationByType(TestCase):
         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)
+                model.agents_by_type[agent_type]
             )
 
     # def test_add_non_unique_ids(self):