Repository: projectmesa/mesa
Pull request #1972 (2024-01-18 07:46:34)

feat: Implement Slider class for JupyterViz
This provides API parity with the previous Tornado viz. Supersedes #1945.
For the Schelling experimental example, from
```python
model_params = {
    "density": {
        "type": "SliderFloat",
        "value": 0.8,
        "label": "Agent density",
        "min": 0.1,
        "max": 1.0,
        "step": 0.1,
    },
    "minority_pc": {
        "type": "SliderFloat",
        "value": 0.2,
        "label": "Fraction minority",
        "min": 0.0,
        "max": 1.0,
        "step": 0.05,
    },
    "homophily": {
        "type": "SliderInt",
        "value": 3,
        "label": "Homophily",
        "min": 0,
        "max": 8,
        "step": 1,
    },
    "width": 20,
    "height": 20,
}
```
to
```python
model_params = {
    "density": Slider("Agent density", 0.8, 0.1, 1.0, 0.1),
    "minority_pc": Slider("Fraction minority", 0.2, 0.0, 1.0, 0.05),
    "homophily": Slider("Homophily", 3, 0, 8, 1),
    "width": 20,
    "height": 20,
}
```
I haven't added test because still waiting for agreement. But I have tested this manually on the Schelling experimental example.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) Report
Attention: `19 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`b60caa4`)](https://app.codecov.io/gh/projectmesa/mesa/commit/b60caa460620e7ed4a3b6cace02750d3d5464b11?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) 79.27% compared to head [(`0656213`)](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) 78.24%.
> Report is 2 commits behind head on main.

| [Files](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) | Patch % | Lines |
|---|---|---|
| [mesa/experimental/UserParam.py](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS9leHBlcmltZW50YWwvVXNlclBhcmFtLnB5) | 31.57% | [13 Missing :warning: ](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) |
| [mesa/experimental/jupyter\_viz.py](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa#diff-bWVzYS9leHBlcmltZW50YWwvanVweXRlcl92aXoucHk=) | 33.33% | [5 Missing and 1 partial :warning: ](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=projectmesa) |

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


```diff
@@            Coverage Diff             @@
##             main    #1972      +/-   ##
==========================================
- Coverage   79.27%   78.24%   -1.03%     
==========================================
  Files          16       17       +1     
  Lines        1298     1324      +26     
  Branches      291      295       +4     
==========================================
+ Hits         1029     1036       +7     
- Misses        230      248      +18     
- Partials       39       40       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/projectmesa/mesa/pull/1972?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).

quaquel: I really like this API. 
rht: Added test. This PR is ready for review.
quaquel: > Do we want to drop the explicit distinction between float and int? Is that implied with the values (mainly step) that you input now?

I am fine with dropping the distinction, but I can see arguments to have explicit int and float sliders. If desired, one possibility would be to just add a dtype kwarg to optionally control this behavior. If dtype is none, you derive the dtype from the arguments.
rht: Added an optional dtype argument.
EwoutH: @ankitk50 since you’re also working of visualization, what’s your take?
ankitk50: I would agree with being explicit with float/int data type. This would also later cater to the possibility of a custom slider with strings` ['min', 'mid', 'max']`.
quaquel: With the optional dtype argument, it should be quite easy to expand to categorical sliders with string labels later down the line. I can't fully judge the code because I am unfamiliar with solara. But from an API and extensibility point of view, this looks all fine to me.

Patch:
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 00000000..e1e146a6
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,83 @@
+name: Performance benchmarks
+
+on:
+  pull_request_target:
+    types: [opened, ready_for_review, labeled]
+    branches:
+      - main
+    paths:
+      - '**.py'
+      - '.github/workflows/benchmarks.yml'
+
+permissions:
+  issues: write
+  pull-requests: write
+
+jobs:
+  run-benchmarks:
+    if: >
+      github.event.action == 'labeled' && contains(github.event.pull_request.labels.*.name, 'trigger-benchmarks') ||
+      github.event.action == 'opened' ||
+      github.event.action == 'ready_for_review'
+    runs-on: ubuntu-latest
+    steps:
+      # Python and dependency setup
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.12'
+      - name: Add project directory to PYTHONPATH
+        run: echo "PYTHONPATH=$PYTHONPATH:$(pwd)" >> $GITHUB_ENV
+      - name: Install dependencies
+        run: pip install numpy pandas tqdm tabulate
+      # Benchmarks on the projectmesa main branch
+      - name: Checkout main branch
+        uses: actions/checkout@v4
+        with:
+          ref: main
+          repository: projectmesa/mesa
+      - name: Run benchmarks on main branch
+        working-directory: benchmarks
+        run: python global_benchmark.py
+      # Upload benchmarks, checkout PR branch, download benchmarks
+      - name: Upload benchmark results
+        uses: actions/upload-artifact@v4
+        with:
+          name: timings-main
+          path: benchmarks/timings_1.pickle
+      - name: Checkout PR branch
+        uses: actions/checkout@v4
+        if: github.event_name == 'pull_request_target'
+        with:
+          ref: ${{ github.event.pull_request.head.ref }}
+          repository: ${{ github.event.pull_request.head.repo.full_name }}
+          fetch-depth: 0
+          persist-credentials: false
+          clean: false
+      - name: Download benchmark results
+        uses: actions/download-artifact@v4
+        with:
+          name: timings-main
+          path: benchmarks
+      # Run benchmarks on the PR branch
+      - name: Run benchmarks on PR branch
+        working-directory: benchmarks
+        run: python global_benchmark.py
+      # Run compare script and create comment
+      - name: Run compare timings and encode output
+        working-directory: benchmarks
+        run: |
+          TIMING_COMPARISON=$(python compare_timings.py | base64 -w 0)  # Base64 encode the output
+          echo "TIMING_COMPARISON=$TIMING_COMPARISON" >> $GITHUB_ENV
+      - name: Comment PR
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const output = Buffer.from(process.env.TIMING_COMPARISON, 'base64').toString('utf-8');
+            const issue_number = context.issue.number;
+            github.rest.issues.createComment({
+              issue_number: issue_number,
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              body: 'Performance benchmarks:\n\n' + output
+            });
diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml
index df5916ec..b017c817 100644
--- a/.github/workflows/build_lint.yml
+++ b/.github/workflows/build_lint.yml
@@ -56,7 +56,7 @@ jobs:
       run: |
         echo "Adding GNU tar to PATH"
         echo C:\Program Files\Git\usr\bin>>"%GITHUB_PATH%"
-    - uses: actions/cache@v3
+    - uses: actions/cache@v4
       with:
         path: ${{ env.pythonLocation }}
         key: ${{ runner.os }}-pip-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}
@@ -86,7 +86,7 @@ jobs:
       uses: actions/setup-python@v5
       with:
         python-version: "3.12"
-    - uses: actions/cache@v3
+    - uses: actions/cache@v4
       with:
         path: ${{ env.pythonLocation }}
         key: test-examples-pip-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}
diff --git a/.gitignore b/.gitignore
index e2819243..814f6a5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Benchmarking
+benchmarks/**/*.pickle
+
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
@@ -66,6 +69,7 @@ target/
 
 # PyCharm environment files
 .idea/
+*.pclprof
 
 # VSCode environment files
 .vscode/
@@ -85,3 +89,4 @@ pythonenv*/
 # JS dependencies
 mesa/visualization/templates/external/
 mesa/visualization/templates/js/external/
+
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2d83ddc8..661543ba 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,5 @@
 ci:
     autoupdate_schedule: 'monthly'
-    autofix_prs: false
 
 repos:
 - repo: https://github.com/astral-sh/ruff-pre-commit
@@ -10,6 +9,7 @@ repos:
     # Run the linter.
     - id: ruff
       types_or: [ python, pyi, jupyter ]
+      args: [ --fix ]
     # Run the formatter.
     - id: ruff-format
       types_or: [ python, pyi, jupyter ]
diff --git a/HISTORY.md b/HISTORY.md
index 5d08eaa1..e54e6455 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,6 +1,19 @@
 ---
 title: Release History
 ---
+# 2.2.2 (2024-01-22)
+
+## Highlights
+
+Mesa 2.2.2 is a small bugfix release, for models in which users had defined  `Model.agents` (`self.agents` in a Model (sub)class). This is deprecated, but for now allowed. See [#1919 (comment)](https://github.com/projectmesa/mesa/discussions/1919#discussioncomment-8141844).
+
+## What's Changed
+### 🐛 Bugs fixed
+* Allow user models to assign `Model.agents` for now, but add warning by @quaquel in [#1976](https://github.com/projectmesa/mesa/pull/1976)
+
+**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.1...v2.2.2
+
+
 # 2.2.1 (2024-01-16)
 
 ## Highlights
diff --git a/benchmarks/Flocking/__init__.py b/benchmarks/Flocking/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/benchmarks/Flocking/flocking.py b/benchmarks/Flocking/flocking.py
new file mode 100644
index 00000000..0331d356
--- /dev/null
+++ b/benchmarks/Flocking/flocking.py
@@ -0,0 +1,169 @@
+"""
+Flockers
+=============================================================
+A Mesa implementation of Craig Reynolds's Boids flocker model.
+Uses numpy arrays to represent vectors.
+"""
+
+import numpy as np
+
+from mesa import Agent, Model
+from mesa.space import ContinuousSpace
+from mesa.time import RandomActivation
+
+
+class Boid(Agent):
+    """
+    A Boid-style flocker agent.
+
+    The agent follows three behaviors to flock:
+        - Cohesion: steering towards neighboring agents.
+        - Separation: avoiding getting too close to any other agent.
+        - Alignment: try to fly in the same direction as the neighbors.
+
+    Boids have a vision that defines the radius in which they look for their
+    neighbors to flock with. Their speed (a scalar) and velocity (a vector)
+    define their movement. Separation is their desired minimum distance from
+    any other Boid.
+    """
+
+    def __init__(
+        self,
+        unique_id,
+        model,
+        pos,
+        speed,
+        velocity,
+        vision,
+        separation,
+        cohere=0.03,
+        separate=0.015,
+        match=0.05,
+    ):
+        """
+        Create a new Boid flocker agent.
+
+        Args:
+            unique_id: Unique agent identifier.
+            pos: Starting position
+            speed: Distance to move per step.
+            heading: numpy vector for the Boid's direction of movement.
+            vision: Radius to look around for nearby Boids.
+            separation: Minimum distance to maintain from other Boids.
+            cohere: the relative importance of matching neighbors' positions
+            separate: the relative importance of avoiding close neighbors
+            match: the relative importance of matching neighbors' headings
+
+        """
+        super().__init__(unique_id, model)
+        self.pos = np.array(pos)
+        self.speed = speed
+        self.velocity = velocity
+        self.vision = vision
+        self.separation = separation
+        self.cohere_factor = cohere
+        self.separate_factor = separate
+        self.match_factor = match
+
+    def step(self):
+        """
+        Get the Boid's neighbors, compute the new vector, and move accordingly.
+        """
+
+        neighbors = self.model.space.get_neighbors(self.pos, self.vision, False)
+        n = 0
+        match_vector, separation_vector, cohere = np.zeros((3, 2))
+        for neighbor in neighbors:
+            n += 1
+            heading = self.model.space.get_heading(self.pos, neighbor.pos)
+            cohere += heading
+            if self.model.space.get_distance(self.pos, neighbor.pos) < self.separation:
+                separation_vector -= heading
+            match_vector += neighbor.velocity
+        n = max(n, 1)
+        cohere = cohere * self.cohere_factor
+        separation_vector = separation_vector * self.separate_factor
+        match_vector = match_vector * self.match_factor
+        self.velocity += (cohere + separation_vector + match_vector) / n
+        self.velocity /= np.linalg.norm(self.velocity)
+        new_pos = self.pos + self.velocity * self.speed
+        self.model.space.move_agent(self, new_pos)
+
+
+class BoidFlockers(Model):
+    """
+    Flocker model class. Handles agent creation, placement and scheduling.
+    """
+
+    def __init__(
+        self,
+        seed,
+        population,
+        width,
+        height,
+        vision,
+        speed=1,
+        separation=1,
+        cohere=0.03,
+        separate=0.015,
+        match=0.05,
+    ):
+        """
+        Create a new Flockers model.
+
+        Args:
+            population: Number of Boids
+            width, height: Size of the space.
+            speed: How fast should the Boids move.
+            vision: How far around should each Boid look for its neighbors
+            separation: What's the minimum distance each Boid will attempt to
+                    keep from any other
+            cohere, separate, match: factors for the relative importance of
+                    the three drives."""
+        super().__init__(seed=seed)
+        self.population = population
+        self.vision = vision
+        self.speed = speed
+        self.separation = separation
+        self.schedule = RandomActivation(self)
+        self.space = ContinuousSpace(width, height, True)
+        self.factors = {"cohere": cohere, "separate": separate, "match": match}
+        self.make_agents()
+
+    def make_agents(self):
+        """
+        Create self.population agents, with random positions and starting headings.
+        """
+        for i in range(self.population):
+            x = self.random.random() * self.space.x_max
+            y = self.random.random() * self.space.y_max
+            pos = np.array((x, y))
+            velocity = np.random.random(2) * 2 - 1
+            boid = Boid(
+                i,
+                self,
+                pos,
+                self.speed,
+                velocity,
+                self.vision,
+                self.separation,
+                **self.factors,
+            )
+            self.space.place_agent(boid, pos)
+            self.schedule.add(boid)
+
+    def step(self):
+        self.schedule.step()
+
+
+if __name__ == "__main__":
+    import time
+
+    # model = BoidFlockers(15, 200, 100, 100, 5)
+    model = BoidFlockers(15, 400, 100, 100, 15)
+
+    start_time = time.perf_counter()
+    for _ in range(100):
+        model.step()
+
+    print(time.perf_counter() - start_time)
diff --git a/benchmarks/Schelling/__init__.py b/benchmarks/Schelling/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py
new file mode 100644
index 00000000..3f9e7567
--- /dev/null
+++ b/benchmarks/Schelling/schelling.py
@@ -0,0 +1,88 @@
+from mesa import Agent, Model
+from mesa.space import SingleGrid
+from mesa.time import RandomActivation
+
+
+class SchellingAgent(Agent):
+    """
+    Schelling segregation agent
+    """
+
+    def __init__(self, unique_id, model, agent_type):
+        """
+        Create a new Schelling agent.
+        Args:
+           unique_id: Unique identifier for the agent.
+           x, y: Agent initial location.
+           agent_type: Indicator for the agent's type (minority=1, majority=0)
+        """
+        super().__init__(unique_id, model)
+        self.type = agent_type
+
+    def step(self):
+        similar = 0
+        for neighbor in self.model.grid.iter_neighbors(
+            self.pos, moore=True, radius=self.model.radius
+        ):
+            if neighbor.type == self.type:
+                similar += 1
+
+        # If unhappy, move:
+        if similar < self.model.homophily:
+            self.model.grid.move_to_empty(self)
+        else:
+            self.model.happy += 1
+
+
+class Schelling(Model):
+    """
+    Model class for the Schelling segregation model.
+    """
+
+    def __init__(
+        self, seed, height, width, homophily, radius, density, minority_pc=0.5
+    ):
+        """ """
+        super().__init__(seed=seed)
+        self.height = height
+        self.width = width
+        self.density = density
+        self.minority_pc = minority_pc
+        self.homophily = homophily
+        self.radius = radius
+
+        self.schedule = RandomActivation(self)
+        self.grid = SingleGrid(height, width, torus=True)
+
+        self.happy = 0
+
+        # Set up agents
+        # We use a grid iterator that returns
+        # the coordinates of a cell as well as
+        # its contents. (coord_iter)
+        for _cont, pos in self.grid.coord_iter():
+            if self.random.random() < self.density:
+                agent_type = 1 if self.random.random() < self.minority_pc else 0
+                agent = SchellingAgent(self.next_id(), self, agent_type)
+                self.grid.place_agent(agent, pos)
+                self.schedule.add(agent)
+
+    def step(self):
+        """
+        Run one step of the model.
+        """
+        self.happy = 0  # Reset counter of happy agents
+        self.schedule.step()
+
+
+if __name__ == "__main__":
+    import time
+
+    # model = Schelling(15, 40, 40, 3, 1, 0.625)
+    model = Schelling(15, 100, 100, 8, 2, 0.8)
+
+    start_time = time.perf_counter()
+    for _ in range(100):
+        model.step()
+
+    print(time.perf_counter() - start_time)
diff --git a/benchmarks/WolfSheep/__init__.py b/benchmarks/WolfSheep/__init__.py
new file mode 100644
index 00000000..98b1e9fd
--- /dev/null
+++ b/benchmarks/WolfSheep/__init__.py
@@ -0,0 +1,14 @@
+from .wolf_sheep import WolfSheep
+
+if __name__ == "__main__":
+    # for profiling this benchmark model
+    import time
+
+    # model = WolfSheep(15, 25, 25, 60, 40, 0.2, 0.1, 20)
+    model = WolfSheep(15, 100, 100, 1000, 500, 0.4, 0.2, 20)
+
+    start_time = time.perf_counter()
+    for _ in range(100):
+        model.step()
+
+    print(time.perf_counter() - start_time)
diff --git a/benchmarks/WolfSheep/agents.py b/benchmarks/WolfSheep/agents.py
new file mode 100644
index 00000000..9ad9f765
--- /dev/null
+++ b/benchmarks/WolfSheep/agents.py
@@ -0,0 +1,111 @@
+from mesa import Agent
+
+from .random_walk import RandomWalker
+
+
+class Sheep(RandomWalker):
+    """
+    A sheep that walks around, reproduces (asexually) and gets eaten.
+
+    The init is the same as the RandomWalker.
+    """
+
+    def __init__(self, unique_id, pos, model, moore, energy=None):
+        super().__init__(unique_id, pos, model, moore=moore)
+        self.energy = energy
+
+    def step(self):
+        """
+        A model step. Move, then eat grass and reproduce.
+        """
+        self.random_move()
+
+        # Reduce energy
+        self.energy -= 1
+
+        # If there is grass available, eat it
+        this_cell = self.model.grid.get_cell_list_contents(self.pos)
+        grass_patch = next(obj for obj in this_cell if isinstance(obj, GrassPatch))
+        if grass_patch.fully_grown:
+            self.energy += self.model.sheep_gain_from_food
+            grass_patch.fully_grown = False
+
+        # Death
+        if self.energy < 0:
+            self.model.grid.remove_agent(self)
+            self.model.schedule.remove(self)
+        elif self.random.random() < self.model.sheep_reproduce:
+            # Create a new sheep:
+            self.energy /= 2
+            lamb = Sheep(
+                self.model.next_id(), self.pos, self.model, self.moore, self.energy
+            )
+            self.model.grid.place_agent(lamb, self.pos)
+            self.model.schedule.add(lamb)
+
+
+class Wolf(RandomWalker):
+    """
+    A wolf that walks around, reproduces (asexually) and eats sheep.
+    """
+
+    def __init__(self, unique_id, pos, model, moore, energy=None):
+        super().__init__(unique_id, pos, model, moore=moore)
+        self.energy = energy
+
+    def step(self):
+        self.random_move()
+        self.energy -= 1
+
+        # If there are sheep present, eat one
+        x, y = self.pos
+        this_cell = self.model.grid.get_cell_list_contents([self.pos])
+        sheep = [obj for obj in this_cell if isinstance(obj, Sheep)]
+        if len(sheep) > 0:
+            sheep_to_eat = self.random.choice(sheep)
+            self.energy += self.model.wolf_gain_from_food
+
+            # Kill the sheep
+            self.model.grid.remove_agent(sheep_to_eat)
+            self.model.schedule.remove(sheep_to_eat)
+
+        # Death or reproduction
+        if self.energy < 0:
+            self.model.grid.remove_agent(self)
+            self.model.schedule.remove(self)
+        elif self.random.random() < self.model.wolf_reproduce:
+            # Create a new wolf cub
+            self.energy /= 2
+            cub = Wolf(
+                self.model.next_id(), self.pos, self.model, self.moore, self.energy
+            )
+            self.model.grid.place_agent(cub, cub.pos)
+            self.model.schedule.add(cub)
+
+
+class GrassPatch(Agent):
+    """
+    A patch of grass that grows at a fixed rate and it is eaten by sheep
+    """
+
+    def __init__(self, unique_id, pos, model, fully_grown, countdown):
+        """
+        Creates a new patch of grass
+
+        Args:
+            grown: (boolean) Whether the patch of grass is fully grown or not
+            countdown: Time for the patch of grass to be fully grown again
+        """
+        super().__init__(unique_id, model)
+        self.fully_grown = fully_grown
+        self.countdown = countdown
+        self.pos = pos
+
+    def step(self):
+        if not self.fully_grown:
+            if self.countdown <= 0:
+                # Set as fully grown
+                self.fully_grown = True
+                self.countdown = self.model.grass_regrowth_time
+            else:
+                self.countdown -= 1
diff --git a/benchmarks/WolfSheep/random_walk.py b/benchmarks/WolfSheep/random_walk.py
new file mode 100644
index 00000000..e55a1f7e
--- /dev/null
+++ b/benchmarks/WolfSheep/random_walk.py
@@ -0,0 +1,37 @@
+"""
+Generalized behavior for random walking, one grid cell at a time.
+"""
+
+from mesa import Agent
+
+
+class RandomWalker(Agent):
+    """
+    Class implementing random walker methods in a generalized manner.
+
+    Not intended to be used on its own, but to inherit its methods to multiple
+    other agents.
+
+    """
+
+    def __init__(self, unique_id, pos, model, moore=True):
+        """
+        grid: The MultiGrid object in which the agent lives.
+        x: The agent's current x coordinate
+        y: The agent's current y coordinate
+        moore: If True, may move in all 8 directions.
+                Otherwise, only up, down, left, right.
+        """
+        super().__init__(unique_id, model)
+        self.pos = pos
+        self.moore = moore
+
+    def random_move(self):
+        """
+        Step one cell in any allowable direction.
+        """
+        # Pick the next cell from the adjacent cells.
+        next_moves = self.model.grid.get_neighborhood(self.pos, self.moore, True)
+        next_move = self.random.choice(next_moves)
+        # Now move:
+        self.model.grid.move_agent(self, next_move)
diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py
new file mode 100644
index 00000000..b08afbc5
--- /dev/null
+++ b/benchmarks/WolfSheep/wolf_sheep.py
@@ -0,0 +1,103 @@
+"""
+Wolf-Sheep Predation Model
+================================
+
+Replication of the model found in NetLogo:
+    Wilensky, U. (1997). NetLogo Wolf Sheep Predation model.
+    http://ccl.northwestern.edu/netlogo/models/WolfSheepPredation.
+    Center for Connected Learning and Computer-Based Modeling,
+    Northwestern University, Evanston, IL.
+"""
+
+import mesa
+from mesa.space import MultiGrid
+from mesa.time import RandomActivationByType
+
+from .agents import GrassPatch, Sheep, Wolf
+
+
+class WolfSheep(mesa.Model):
+    """
+    Wolf-Sheep Predation Model
+
+    A model for simulating wolf and sheep (predator-prey) ecosystem modelling.
+    """
+
+    def __init__(
+        self,
+        seed,
+        height,
+        width,
+        initial_sheep,
+        initial_wolves,
+        sheep_reproduce,
+        wolf_reproduce,
+        grass_regrowth_time,
+        wolf_gain_from_food=13,
+        sheep_gain_from_food=5,
+    ):
+        """
+        Create a new Wolf-Sheep model with the given parameters.
+
+        Args:
+            initial_sheep: Number of sheep to start with
+            initial_wolves: Number of wolves to start with
+            sheep_reproduce: Probability of each sheep reproducing each step
+            wolf_reproduce: Probability of each wolf reproducing each step
+            wolf_gain_from_food: Energy a wolf gains from eating a sheep
+            grass: Whether to have the sheep eat grass for energy
+            grass_regrowth_time: How long it takes for a grass patch to regrow
+                                 once it is eaten
+            sheep_gain_from_food: Energy sheep gain from grass, if enabled.
+        """
+        super().__init__(seed=seed)
+        # Set parameters
+        self.height = height
+        self.width = width
+        self.initial_sheep = initial_sheep
+        self.initial_wolves = initial_wolves
+        self.sheep_reproduce = sheep_reproduce
+        self.wolf_reproduce = wolf_reproduce
+        self.wolf_gain_from_food = wolf_gain_from_food
+        self.grass_regrowth_time = grass_regrowth_time
+        self.sheep_gain_from_food = sheep_gain_from_food
+
+        self.schedule = RandomActivationByType(self)
+        self.grid = MultiGrid(self.height, self.width, torus=False)
+
+        # Create sheep:
+        for _i in range(self.initial_sheep):
+            pos = (
+                self.random.randrange(self.width),
+                self.random.randrange(self.height),
+            )
+            energy = self.random.randrange(2 * self.sheep_gain_from_food)
+            sheep = Sheep(self.next_id(), pos, self, True, energy)
+            self.grid.place_agent(sheep, pos)
+            self.schedule.add(sheep)
+
+        # Create wolves
+        for _i in range(self.initial_wolves):
+            pos = (
+                self.random.randrange(self.width),
+                self.random.randrange(self.height),
+            )
+            energy = self.random.randrange(2 * self.wolf_gain_from_food)
+            wolf = Wolf(self.next_id(), pos, self, True, energy)
+            self.grid.place_agent(wolf, pos)
+            self.schedule.add(wolf)
+
+        # Create grass patches
+        possibly_fully_grown = [True, False]
+        for _agent, pos in self.grid.coord_iter():
+            fully_grown = self.random.choice(possibly_fully_grown)
+            if fully_grown:
+                countdown = self.grass_regrowth_time
+            else:
+                countdown = self.random.randrange(self.grass_regrowth_time)
+            patch = GrassPatch(self.next_id(), pos, self, fully_grown, countdown)
+            self.grid.place_agent(patch, pos)
+            self.schedule.add(patch)
+
+    def step(self):
+        self.schedule.step()
diff --git a/benchmarks/compare_timings.py b/benchmarks/compare_timings.py
new file mode 100644
index 00000000..d6f1c11c
--- /dev/null
+++ b/benchmarks/compare_timings.py
@@ -0,0 +1,87 @@
+import pickle
+
+import numpy as np
+import pandas as pd
+
+filename1 = "timings_1"
+filename2 = "timings_2"
+
+with open(f"{filename1}.pickle", "rb") as handle:
+    timings_1 = pickle.load(handle)  # noqa: S301
+
+with open(f"{filename2}.pickle", "rb") as handle:
+    timings_2 = pickle.load(handle)  # noqa: S301
+
+
+# Function to calculate the percentage change and perform bootstrap to estimate the confidence interval
+def bootstrap_percentage_change_confidence_interval(data1, data2, n=1000):
+    change_samples = []
+    for _ in range(n):
+        sampled_indices = np.random.choice(
+            range(len(data1)), size=len(data1), replace=True
+        )
+        sampled_data1 = np.array(data1)[sampled_indices]
+        sampled_data2 = np.array(data2)[sampled_indices]
+        change = 100 * (sampled_data2 - sampled_data1) / sampled_data1
+        change_samples.append(np.mean(change))
+    lower, upper = np.percentile(change_samples, [2.5, 97.5])
+    return np.mean(change_samples), lower, upper
+
+
+# DataFrame to store the results
+results_df = pd.DataFrame()
+
+
+# Function to determine the emoji based on change and confidence interval
+def performance_emoji(lower, upper):
+    if upper < -3:
+        return "🟢"  # Emoji for faster performance
+    elif lower > 3:
+        return "🔴"  # Emoji for slower performance
+    else:
+        return "🔵"  # Emoji for insignificant change
+
+
+# Iterate over the models and sizes, perform analysis, and populate the DataFrame
+for model, size in timings_1:
+    model_name = model.__name__
+
+    # Calculate percentage change and confidence interval for init times
+    (
+        init_change,
+        init_lower,
+        init_upper,
+    ) = bootstrap_percentage_change_confidence_interval(
+        timings_1[(model, size)][0], timings_2[(model, size)][0]
+    )
+    init_emoji = performance_emoji(init_lower, init_upper)
+    init_summary = (
+        f"{init_emoji} {init_change:+.1f}% [{init_lower:+.1f}%, {init_upper:+.1f}%]"
+    )
+
+    # Calculate percentage change and confidence interval for run times
+    run_change, run_lower, run_upper = bootstrap_percentage_change_confidence_interval(
+        timings_1[(model, size)][1], timings_2[(model, size)][1]
+    )
+    run_emoji = performance_emoji(run_lower, run_upper)
+    run_summary = (
+        f"{run_emoji} {run_change:+.1f}% [{run_lower:+.1f}%, {run_upper:+.1f}%]"
+    )
+
+    # Append results to DataFrame
+    row = pd.DataFrame(
+        {
+            "Model": [model_name],
+            "Size": [size],
+            "Init time [95% CI]": [init_summary],
+            "Run time [95% CI]": [run_summary],
+        }
+    )
+
+    results_df = pd.concat([results_df, row], ignore_index=True)
+
+# Convert DataFrame to markdown with specified alignments
+markdown_representation = results_df.to_markdown(index=False, tablefmt="github")
+
+# Display the markdown representation
+print(markdown_representation)
diff --git a/benchmarks/configurations.py b/benchmarks/configurations.py
new file mode 100644
index 00000000..c42f69ad
--- /dev/null
+++ b/benchmarks/configurations.py
@@ -0,0 +1,84 @@
+from Flocking.flocking import BoidFlockers
+from Schelling.schelling import Schelling
+from WolfSheep.wolf_sheep import WolfSheep
+
+configurations = {
+    # Schelling Model Configurations
+    Schelling: {
+        "small": {
+            "seeds": 50,
+            "replications": 5,
+            "steps": 20,
+            "parameters": {
+                "height": 40,
+                "width": 40,
+                "homophily": 3,
+                "radius": 1,
+                "density": 0.625,
+            },
+        },
+        "large": {
+            "seeds": 10,
+            "replications": 3,
+            "steps": 10,
+            "parameters": {
+                "height": 100,
+                "width": 100,
+                "homophily": 8,
+                "radius": 2,
+                "density": 0.8,
+            },
+        },
+    },
+    # WolfSheep Model Configurations
+    WolfSheep: {
+        "small": {
+            "seeds": 50,
+            "replications": 5,
+            "steps": 40,
+            "parameters": {
+                "height": 25,
+                "width": 25,
+                "initial_sheep": 60,
+                "initial_wolves": 40,
+                "sheep_reproduce": 0.2,
+                "wolf_reproduce": 0.1,
+                "grass_regrowth_time": 20,
+            },
+        },
+        "large": {
+            "seeds": 10,
+            "replications": 3,
+            "steps": 20,
+            "parameters": {
+                "height": 100,
+                "width": 100,
+                "initial_sheep": 1000,
+                "initial_wolves": 500,
+                "sheep_reproduce": 0.4,
+                "wolf_reproduce": 0.2,
+                "grass_regrowth_time": 10,
+            },
+        },
+    },
+    # BoidFlockers Model Configurations
+    BoidFlockers: {
+        "small": {
+            "seeds": 25,
+            "replications": 3,
+            "steps": 20,
+            "parameters": {"population": 200, "width": 100, "height": 100, "vision": 5},
+        },
+        "large": {
+            "seeds": 10,
+            "replications": 3,
+            "steps": 10,
+            "parameters": {
+                "population": 400,
+                "width": 150,
+                "height": 150,
+                "vision": 15,
+            },
+        },
+    },
+}
diff --git a/benchmarks/global_benchmark.py b/benchmarks/global_benchmark.py
new file mode 100644
index 00000000..78c14a57
--- /dev/null
+++ b/benchmarks/global_benchmark.py
@@ -0,0 +1,78 @@
+import gc
+import os
+import pickle
+import sys
+import time
+import timeit
+
+# making sure we use this version of mesa and not one
+# also installed in site_packages or so.
+sys.path.insert(0, os.path.abspath(".."))
+
+from configurations import configurations  # noqa: E402
+
+
+# Generic function to initialize and run a model
+def run_model(model_class, seed, parameters):
+    start_init = timeit.default_timer()
+    model = model_class(seed=seed, **parameters)
+    #   time.sleep(0.001)
+
+    end_init_start_run = timeit.default_timer()
+
+    for _ in range(config["steps"]):
+        model.step()
+    #       time.sleep(0.0001)
+    end_run = timeit.default_timer()
+
+    return (end_init_start_run - start_init), (end_run - end_init_start_run)
+
+
+# Function to run experiments and save the fastest replication for each seed
+def run_experiments(model_class, config):
+    gc.enable()
+    sys.path.insert(0, os.path.abspath("."))
+
+    init_times = []
+    run_times = []
+    for seed in range(1, config["seeds"] + 1):
+        fastest_init = float("inf")
+        fastest_run = float("inf")
+        for _replication in range(1, config["replications"] + 1):
+            init_time, run_time = run_model(model_class, seed, config["parameters"])
+            if init_time < fastest_init:
+                fastest_init = init_time
+            if run_time < fastest_run:
+                fastest_run = run_time
+        init_times.append(fastest_init)
+        run_times.append(fastest_run)
+
+    return init_times, run_times
+
+
+print(f"{time.strftime('%H:%M:%S', time.localtime())} starting benchmarks.")
+results_dict = {}
+for model, model_config in configurations.items():
+    for size, config in model_config.items():
+        results = run_experiments(model, config)
+
+        mean_init = sum(results[0]) / len(results[0])
+        mean_run = sum(results[1]) / len(results[1])
+
+        print(
+            f"{time.strftime('%H:%M:%S', time.localtime())} {model.__name__:<14} ({size}) timings: Init {mean_init:.5f} s; Run {mean_run:.4f} s"
+        )
+
+        results_dict[model, size] = results
+
+# Change this name to anything you like
+save_name = "timings"
+
+i = 1
+while os.path.exists(f"{save_name}_{i}.pickle"):
+    i += 1
+
+with open(f"{save_name}_{i}.pickle", "wb") as handle:
+    pickle.dump(results_dict, handle, protocol=pickle.HIGHEST_PROTOCOL)
+
+print(f"Done benchmarking. Saved results to {save_name}_{i}.pickle.")
diff --git a/codecov.yaml b/codecov.yaml
index 4993daf0..ad078b23 100644
--- a/codecov.yaml
+++ b/codecov.yaml
@@ -7,5 +7,6 @@ coverage:
 
 ignore:
   - "**experimental/"
+  - "benchmarks/**"
 
-comment: off
\ No newline at end of file
+comment: off
diff --git a/mesa/__init__.py b/mesa/__init__.py
index 36bd5823..e27d7de1 100644
--- a/mesa/__init__.py
+++ b/mesa/__init__.py
@@ -25,7 +25,7 @@ __all__ = [
 ]
 
 __title__ = "mesa"
-__version__ = "2.2.1"
+__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 fcf643d3..6155284b 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -54,6 +54,7 @@ class Agent:
         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(
diff --git a/mesa/datacollection.py b/mesa/datacollection.py
index 88f1863f..3fcd205c 100644
--- a/mesa/datacollection.py
+++ b/mesa/datacollection.py
@@ -14,8 +14,7 @@ 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 in the model
-scheduler.
+variable. Then the agent-level functions are called on each agent.
 
 Additionally, other objects can write directly to tables by passing in an
 appropriate dictionary object for a table row.
@@ -30,8 +29,7 @@ The DataCollector then stores the data it collects in dictionaries:
 Finally, DataCollector can create a pandas DataFrame from each collection.
 
 The default DataCollector here makes several assumptions:
-    * The model has a schedule object called 'schedule'
-    * The schedule has an agent list called agents
+    * The model has an agent list called agents
     * For collecting agent-level variables, agents must have a unique_id
 """
 import contextlib
@@ -67,7 +65,7 @@ class DataCollector:
 
         Model reporters can take four types of arguments:
         1. Lambda function:
-           {"agent_count": lambda m: m.schedule.get_agent_count()}
+           {"agent_count": lambda m: len(m.agents)}
         2. Method of a class/instance:
            {"agent_count": self.get_agent_count} # self here is a class instance
            {"agent_count": Model.get_agent_count} # Model here is a class
@@ -180,11 +178,14 @@ class DataCollector:
         rep_funcs = self.agent_reporters.values()
 
         def get_reports(agent):
-            _prefix = (agent.model.schedule.steps, agent.unique_id)
+            _prefix = (agent.model._steps, agent.unique_id)
             reports = tuple(rep(agent) for rep in rep_funcs)
             return _prefix + reports
 
-        agent_records = map(get_reports, model.schedule.agents)
+        agent_records = map(
+            get_reports,
+            model.schedule.agents if hasattr(model, "schedule") else model.agents,
+        )
         return agent_records
 
     def collect(self, model):
@@ -207,7 +208,7 @@ class DataCollector:
 
         if self.agent_reporters:
             agent_records = self._record_agents(model)
-            self._agent_records[model.schedule.steps] = list(agent_records)
+            self._agent_records[model._steps] = list(agent_records)
 
     def add_table_row(self, table_name, row, ignore_missing=False):
         """Add a row dictionary to a specific table.
diff --git a/mesa/experimental/UserParam.py b/mesa/experimental/UserParam.py
new file mode 100644
index 00000000..58715f32
--- /dev/null
+++ b/mesa/experimental/UserParam.py
@@ -0,0 +1,56 @@
+class UserParam:
+    _ERROR_MESSAGE = "Missing or malformed inputs for '{}' Option '{}'"
+
+    def maybe_raise_error(self, param_type, valid):
+        if valid:
+            return
+        msg = self._ERROR_MESSAGE.format(param_type, self.label)
+        raise ValueError(msg)
+
+
+class Slider(UserParam):
+    """
+    A number-based slider input with settable increment.
+
+    Example:
+
+    slider_option = Slider("My Slider", value=123, min=10, max=200, step=0.1)
+
+    Args:
+        label: The displayed label in the UI
+        value: The initial value of the slider
+        min: The minimum possible value of the slider
+        max: The maximum possible value of the slider
+        step: The step between min and max for a range of possible values
+        dtype: either int or float
+    """
+
+    def __init__(
+        self,
+        label="",
+        value=None,
+        min=None,
+        max=None,
+        step=1,
+        dtype=None,
+    ):
+        self.label = label
+        self.value = value
+        self.min = min
+        self.max = max
+        self.step = step
+
+        # Validate option type to make sure values are supplied properly
+        valid = not (self.value is None or self.min is None or self.max is None)
+        self.maybe_raise_error("slider", valid)
+
+        if dtype is None:
+            self.is_float_slider = self._check_values_are_float(value, min, max, step)
+        else:
+            self.is_float_slider = dtype == float
+
+    def _check_values_are_float(self, value, min, max, step):
+        return any(isinstance(n, float) for n in (value, min, max, step))
+
+    def get(self, attr):
+        return getattr(self, attr)
diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py
index 964dc5d1..c04f2258 100644
--- a/mesa/experimental/__init__.py
+++ b/mesa/experimental/__init__.py
@@ -1,1 +1,1 @@
-from .jupyter_viz import JupyterViz, make_text  # noqa
+from .jupyter_viz import JupyterViz, make_text, Slider  # noqa
diff --git a/mesa/experimental/components/matplotlib.py b/mesa/experimental/components/matplotlib.py
index e2389b5c..b688e07f 100644
--- a/mesa/experimental/components/matplotlib.py
+++ b/mesa/experimental/components/matplotlib.py
@@ -22,7 +22,6 @@ def SpaceMatplotlib(model, agent_portrayal, dependencies: Optional[list[any]] =
         _draw_continuous_space(space, space_ax, agent_portrayal)
     else:
         _draw_grid(space, space_ax, agent_portrayal)
-    space_ax.set_axis_off()
     solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies)
 
 
@@ -55,6 +54,8 @@ def _draw_grid(space, space_ax, agent_portrayal):
             out["c"] = c
         return out
 
+    space_ax.set_xlim(-1, space.width)
+    space_ax.set_ylim(-1, space.height)
     space_ax.scatter(**portray(space))
 
 
@@ -91,6 +92,23 @@ def _draw_continuous_space(space, space_ax, agent_portrayal):
             out["c"] = c
         return out
 
+    # Determine border style based on space.torus
+    border_style = "solid" if not space.torus else (0, (5, 10))
+
+    # Set the border of the plot
+    for spine in space_ax.spines.values():
+        spine.set_linewidth(1.5)
+        spine.set_color("black")
+        spine.set_linestyle(border_style)
+
+    width = space.x_max - space.x_min
+    x_padding = width / 20
+    height = space.y_max - space.y_min
+    y_padding = height / 20
+    space_ax.set_xlim(space.x_min - x_padding, space.x_max + x_padding)
+    space_ax.set_ylim(space.y_min - y_padding, space.y_max + y_padding)
+
+    # Portray and scatter the agents in the space
     space_ax.scatter(**portray(space))
 
 
diff --git a/mesa/experimental/jupyter_viz.py b/mesa/experimental/jupyter_viz.py
index 7a586239..ef04db73 100644
--- a/mesa/experimental/jupyter_viz.py
+++ b/mesa/experimental/jupyter_viz.py
@@ -7,6 +7,7 @@ import solara
 from solara.alias import rv
 
 import mesa.experimental.components.matplotlib as components_matplotlib
+from mesa.experimental.UserParam import Slider
 
 # Avoid interactive backend
 plt.switch_backend("agg")
@@ -43,7 +44,7 @@ def JupyterViz(
     # 1. Set up model parameters
     user_params, fixed_params = split_model_params(model_params)
     model_parameters, set_model_parameters = solara.use_state(
-        {**fixed_params, **{k: v["value"] for k, v in user_params.items()}}
+        {**fixed_params, **{k: v.get("value") for k, v in user_params.items()}}
     )
 
     # 2. Set up Model
@@ -181,7 +182,7 @@ def ModelController(model, play_interval, current_step, reset_counter):
     def do_step():
         model.step()
         previous_step.value = current_step.value
-        current_step.value += 1
+        current_step.value = model._steps
 
     def do_play():
         model.running = True
@@ -254,6 +255,8 @@ def split_model_params(model_params):
 
 
 def check_param_is_fixed(param):
+    if isinstance(param, Slider):
+        return False
     if not isinstance(param, dict):
         return True
     if "type" not in param:
@@ -273,13 +276,26 @@ def UserInputs(user_params, on_change=None):
     """
 
     for name, options in user_params.items():
-        # label for the input is "label" from options or name
-        label = options.get("label", name)
-        input_type = options.get("type")
 
         def change_handler(value, name=name):
             on_change(name, value)
 
+        if isinstance(options, Slider):
+            slider_class = (
+                solara.SliderFloat if options.is_float_slider else solara.SliderInt
+            )
+            slider_class(
+                options.label,
+                on_value=change_handler,
+                min=options.min,
+                max=options.max,
+                step=options.step,
+            )
+            continue
+
+        # label for the input is "label" from options or name
+        label = options.get("label", name)
+        input_type = options.get("type")
         if input_type == "SliderInt":
             solara.SliderInt(
                 label,
diff --git a/mesa/model.py b/mesa/model.py
index 1bd9b3a7..b2b12340 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -13,11 +13,13 @@ import warnings
 from collections import defaultdict
 
 # mypy
-from typing import Any
+from typing import Any, Union
 
 from mesa.agent import Agent, AgentSet
 from mesa.datacollection import DataCollector
 
+TimeT = Union[float, int]
+
 
 class Model:
     """Base class for models in the Mesa ABM library.
@@ -68,6 +70,9 @@ class Model:
         self.current_id = 0
         self.agents_: defaultdict[type, dict] = defaultdict(dict)
 
+        self._steps: int = 0
+        self._time: TimeT = 0  # the model's clock
+
         # Warning flags for current experimental features. These make sure a warning is only printed once per model.
         self.agentset_experimental_warning_given = False
 
@@ -112,6 +117,11 @@ class Model:
     def step(self) -> None:
         """A single step. Fill in here."""
 
+    def _advance_time(self, deltat: TimeT = 1):
+        """Increment the model's steps counter and clock."""
+        self._steps += 1
+        self._time += deltat
+
     def next_id(self) -> int:
         """Return the next unique ID for agents, increment current_id"""
         self.current_id += 1
diff --git a/mesa/space.py b/mesa/space.py
index b730c2da..9be3b563 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -1539,9 +1539,11 @@ class NetworkGrid:
             neighborhood = sorted(neighbors_with_distance.keys())
         return neighborhood
 
-    def get_neighbors(self, node_id: int, include_center: bool = False) -> list[Agent]:
-        """Get all agents in adjacent nodes."""
-        neighborhood = self.get_neighborhood(node_id, include_center)
+    def get_neighbors(
+        self, node_id: int, include_center: bool = False, radius: int = 1
+    ) -> list[Agent]:
+        """Get all agents in adjacent nodes (within a certain radius)."""
+        neighborhood = self.get_neighborhood(node_id, include_center, radius)
         return self.get_cell_list_contents(neighborhood)
 
     def move_agent(self, agent: Agent, node_id: int) -> None:
diff --git a/mesa/time.py b/mesa/time.py
index c56fa638..50d564be 100644
--- a/mesa/time.py
+++ b/mesa/time.py
@@ -73,6 +73,8 @@ class BaseScheduler:
         self.model = model
         self.steps = 0
         self.time: TimeT = 0
+        self._original_step = self.step
+        self.step = self._wrapped_step
 
         if agents is None:
             agents = []
@@ -115,6 +117,11 @@ class BaseScheduler:
         self.steps += 1
         self.time += 1
 
+    def _wrapped_step(self):
+        """Wrapper for the step method to include time and step updating."""
+        self._original_step()
+        self.model._advance_time()
+
     def get_agent_count(self) -> int:
         """Returns the current number of agents in the queue."""
         return len(self._agents)
@@ -143,8 +150,8 @@ class BaseScheduler:
 
     def do_each(self, method, shuffle=False):
         if shuffle:
-            self.agents.shuffle(inplace=True)
-        self.agents.do(method)
+            self._agents.shuffle(inplace=True)
+        self._agents.do(method)
 
 
 class RandomActivation(BaseScheduler):
@@ -299,7 +306,7 @@ class RandomActivationByType(BaseScheduler):
 
         agentsbytype = defaultdict(dict)
         for k, v in self._agents_by_type.items():
-            agentsbytype[k] = {agent: agent.unique_id for agent in v}
+            agentsbytype[k] = {agent.unique_id: agent for agent in v}
 
         return agentsbytype
 


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 5861038d..60a0eec1 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -251,3 +251,16 @@ def test_agentset_select_by_type():
     # Test with no type specified (should select all agents)
     all_agents = agentset.select()
     assert len(all_agents) == len(mixed_agents)
+
+
+def test_agentset_shuffle():
+    model = Model()
+    test_agents = [TestAgent(model.next_id(), model) for _ in range(12)]
+
+    agentset = AgentSet(test_agents, model=model)
+    agentset = agentset.shuffle()
+    assert not all(a1 == a2 for a1, a2 in zip(test_agents, agentset))
+
+    agentset = AgentSet(test_agents, model=model)
+    agentset.shuffle(inplace=True)
+    assert not all(a1 == a2 for a1, a2 in zip(test_agents, agentset))
diff --git a/tests/test_jupyter_viz.py b/tests/test_jupyter_viz.py
index 526e9140..248e8c31 100644
--- a/tests/test_jupyter_viz.py
+++ b/tests/test_jupyter_viz.py
@@ -4,7 +4,7 @@ from unittest.mock import Mock, patch
 import ipyvuetify as vw
 import solara
 
-from mesa.experimental.jupyter_viz import JupyterViz, UserInputs
+from mesa.experimental.jupyter_viz import JupyterViz, Slider, UserInputs
 
 
 class TestMakeUserInput(unittest.TestCase):
@@ -133,3 +133,17 @@ class TestJupyterViz(unittest.TestCase):
         altspace_drawer.assert_called_with(
             mock_model_class.return_value, agent_portrayal
         )
+
+
+def test_slider():
+    slider_float = Slider("Agent density", 0.8, 0.1, 1.0, 0.1)
+    assert slider_float.is_float_slider
+    assert slider_float.value == 0.8
+    assert slider_float.get("value") == 0.8
+    assert slider_float.min == 0.1
+    assert slider_float.max == 1.0
+    assert slider_float.step == 0.1
+    slider_int = Slider("Homophily", 3, 0, 8, 1)
+    assert not slider_int.is_float_slider
+    slider_dtype_float = Slider("Homophily", 3, 0, 8, 1, dtype=float)
+    assert slider_dtype_float.is_float_slider
diff --git a/tests/test_space.py b/tests/test_space.py
index b7524b6d..aabdf98b 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -280,7 +280,7 @@ class TestSpaceAgentMapping(unittest.TestCase):
 
 class TestPropertyLayer(unittest.TestCase):
     def setUp(self):
-        self.layer = PropertyLayer("test_layer", 10, 10, 0)
+        self.layer = PropertyLayer("test_layer", 10, 10, 0, dtype=int)
 
     # Initialization Test
     def test_initialization(self):
@@ -379,11 +379,11 @@ class TestPropertyLayer(unittest.TestCase):
     # Edge Case: Negative or Zero Dimensions
     def test_initialization_negative_dimensions(self):
         with self.assertRaises(ValueError):
-            PropertyLayer("test_layer", -10, 10, 0)
+            PropertyLayer("test_layer", -10, 10, 0, dtype=int)
 
     def test_initialization_zero_dimensions(self):
         with self.assertRaises(ValueError):
-            PropertyLayer("test_layer", 0, 10, 0)
+            PropertyLayer("test_layer", 0, 10, 0, dtype=int)
 
     # Edge Case: Out-of-Bounds Cell Access
     def test_set_cell_out_of_bounds(self):
@@ -611,8 +611,8 @@ class TestSingleGridTorus(unittest.TestCase):
 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.property_layer1 = PropertyLayer("layer1", 10, 10, 0, dtype=int)
+        self.property_layer2 = PropertyLayer("layer2", 10, 10, 1.0, dtype=float)
         self.grid.add_property_layer(self.property_layer1)
         self.grid.add_property_layer(self.property_layer2)
 
@@ -627,7 +627,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
 
     def test_add_property_layer_mismatched_dimensions(self):
         with self.assertRaises(ValueError):
-            self.grid.add_property_layer(PropertyLayer("layer3", 5, 5, 0))
+            self.grid.add_property_layer(PropertyLayer("layer3", 5, 5, 0, dtype=int))
 
     def test_add_existing_property_layer(self):
         with self.assertRaises(ValueError):
@@ -867,12 +867,54 @@ class TestSingleNetworkGrid(unittest.TestCase):
             a = self.agents[i]
             assert a.pos == pos
 
-    def test_get_neighbors(self):
+    def test_get_neighborhood(self):
         assert len(self.space.get_neighborhood(0, include_center=True)) == 3
         assert len(self.space.get_neighborhood(0, include_center=False)) == 2
         assert len(self.space.get_neighborhood(2, include_center=True, radius=3)) == 7
         assert len(self.space.get_neighborhood(2, include_center=False, radius=3)) == 6
 
+    def test_get_neighbors(self):
+        """
+        Test the get_neighbors method with varying radius and include_center values. Note there are agents on node 0, 1 and 5.
+        """
+        # Test with default radius (1) and include_center = False
+        neighbors_default = self.space.get_neighbors(0, include_center=False)
+        self.assertEqual(
+            len(neighbors_default),
+            1,
+            "Should have 1 neighbors with default radius and exclude center",
+        )
+
+        # Test with default radius (1) and include_center = True
+        neighbors_include_center = self.space.get_neighbors(0, include_center=True)
+        self.assertEqual(
+            len(neighbors_include_center),
+            2,
+            "Should have 2 neighbors (including center) with default radius",
+        )
+
+        # Test with radius = 2 and include_center = False
+        neighbors_radius_2 = self.space.get_neighbors(0, include_center=False, radius=5)
+        expected_count_radius_2 = 2
+        self.assertEqual(
+            len(neighbors_radius_2),
+            expected_count_radius_2,
+            f"Should have {expected_count_radius_2} neighbors with radius 2 and exclude center",
+        )
+
+        # Test with radius = 2 and include_center = True
+        neighbors_radius_2_include_center = self.space.get_neighbors(
+            0, include_center=True, radius=5
+        )
+        expected_count_radius_2_include_center = (
+            3  # Adjust this based on your network structure
+        )
+        self.assertEqual(
+            len(neighbors_radius_2_include_center),
+            expected_count_radius_2_include_center,
+            f"Should have {expected_count_radius_2_include_center} neighbors (including center) with radius 2",
+        )
+
     def test_move_agent(self):
         initial_pos = 1
         agent_number = 1
diff --git a/tests/test_time.py b/tests/test_time.py
index 3cfc2f35..53967d1a 100644
--- a/tests/test_time.py
+++ b/tests/test_time.py
@@ -224,6 +224,28 @@ class TestRandomActivation(TestCase):
         agent_ids = {agent.unique_id for agent in model.agents}
         assert all(entry in agent_ids for entry in keys)
 
+    def test_not_sequential(self):
+        model = MockModel(activation=RANDOM)
+        # Create 10 agents
+        for _ in range(10):
+            model.schedule.add(MockAgent(model.next_id(), model))
+        # Run 3 steps
+        for _ in range(3):
+            model.step()
+        # Filter out non-integer elements from the log
+        filtered_log = [item for item in model.log if isinstance(item, int)]
+
+        # Check that there are no 18 consecutive agents id's in the filtered log
+        total_agents = 10
+        assert not any(
+            all(
+                (filtered_log[(i + j) % total_agents] - filtered_log[i]) % total_agents
+                == j % total_agents
+                for j in range(18)
+            )
+            for i in range(len(filtered_log))
+        ), f"Agents are activated sequentially:\n{filtered_log}"
+
 
 class TestSimultaneousActivation(TestCase):
     """