Repository: projectmesa/mesa
Pull request #1973 (2024-01-18 10:39:54)

Add radius argument to NetworkGrid.get_neighbors()
For some reason `get_neighborhood()` did have the `radius` input argument, but `get_neighbors()` did not have it in the NetworkGrid. This commit resolves that inconsistency and allows `get_neighbors()` to take a radius as input and return the agents in that radius. Tests are included.


Patch:
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 00000000..09eb1f95
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,77 @@
+name: Performance benchmarks
+
+on:
+  pull_request_target:
+    types: [opened, ready_for_review]
+    branches:
+      - main
+  issue_comment:
+    types: [created]
+
+permissions:
+  issues: write
+  pull-requests: write
+
+jobs:
+  run-benchmarks:
+    if: >
+      github.event_name == 'pull_request_target' ||
+      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/rerun-benchmarks'))
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout PR branch
+        uses: actions/checkout@v4
+      - 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
+      - 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
+      - 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
+        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
+      - name: Run benchmarks on PR branch
+        working-directory: benchmarks
+        run: python global_benchmark.py
+      - 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/.gitignore b/.gitignore
index e2819243..1b04c123 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Benchmarking
+benchmarking/**/*.pickle
+
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 02b1d464..32c591a8 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -15,7 +15,7 @@ formats:
 build:
   os: "ubuntu-22.04"
   tools:
-    python: "3.9"
+    python: "3"
 
 # Optionally set the version of Python and requirements required to build your docs
 python:
diff --git a/benchmarks/Flocking/Flocking.py b/benchmarks/Flocking/Flocking.py
new file mode 100644
index 00000000..4a5e8529
--- /dev/null
+++ b/benchmarks/Flocking/Flocking.py
@@ -0,0 +1,80 @@
+"""
+Flockers
+=============================================================
+A Mesa implementation of Craig Reynolds's Boids flocker model.
+Uses numpy arrays to represent vectors.
+"""
+
+import numpy as np
+
+from mesa import Model
+from mesa.space import ContinuousSpace
+from mesa.time import RandomActivation
+
+from .boid import Boid
+
+
+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()
diff --git a/benchmarks/Flocking/__init__.py b/benchmarks/Flocking/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/benchmarks/Flocking/boid.py b/benchmarks/Flocking/boid.py
new file mode 100644
index 00000000..598ea32b
--- /dev/null
+++ b/benchmarks/Flocking/boid.py
@@ -0,0 +1,81 @@
+import numpy as np
+
+from mesa import Agent
+
+
+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)
diff --git a/benchmarks/Schelling/Schelling.py b/benchmarks/Schelling/Schelling.py
new file mode 100644
index 00000000..eb1379ed
--- /dev/null
+++ b/benchmarks/Schelling/Schelling.py
@@ -0,0 +1,77 @@
+import random
+
+from mesa import Agent, Model
+from mesa.space import SingleGrid
+from mesa.time import RandomActivation
+
+
+class SchellingAgent(Agent):
+    """
+    Schelling segregation agent
+    """
+
+    def __init__(self, pos, 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__(pos, model)
+        self.pos = pos
+        self.type = agent_type
+
+    def step(self):
+        similar = 0
+        r = self.model.radius
+        for neighbor in self.model.grid.iter_neighbors(self.pos, moore=True, radius=r):
+            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 SchellingModel(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 random.random() < self.density:  # noqa: S311
+                agent_type = 1 if random.random() < self.minority_pc else 0  # noqa: S311
+                agent = SchellingAgent(pos, 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()
diff --git a/benchmarks/Schelling/__init__.py b/benchmarks/Schelling/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/benchmarks/WolfSheep/WolfSheep.py b/benchmarks/WolfSheep/WolfSheep.py
new file mode 100644
index 00000000..b08afbc5
--- /dev/null
+++ b/benchmarks/WolfSheep/WolfSheep.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/WolfSheep/__init__.py b/benchmarks/WolfSheep/__init__.py
new file mode 100644
index 00000000..e69de29b
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/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..ab896040
--- /dev/null
+++ b/benchmarks/configurations.py
@@ -0,0 +1,84 @@
+from Flocking.Flocking import BoidFlockers
+from Schelling.Schelling import SchellingModel
+from WolfSheep.WolfSheep import WolfSheep
+
+configurations = {
+    # Schelling Model Configurations
+    SchellingModel: {
+        "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..9547d94a
--- /dev/null
+++ b/benchmarks/global_benchmark.py
@@ -0,0 +1,74 @@
+import gc
+import os
+import pickle
+import sys
+import time
+import timeit
+
+from configurations import configurations
+
+
+# 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
new file mode 100644
index 00000000..ad078b23
--- /dev/null
+++ b/codecov.yaml
@@ -0,0 +1,12 @@
+coverage:
+  status:
+    project:
+      default:
+        target: 90%
+        threshold: 1%
+
+ignore:
+  - "**experimental/"
+  - "benchmarks/**"
+
+comment: off
diff --git a/mesa/agent.py b/mesa/agent.py
index 8688dc70..fcf643d3 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -50,10 +50,10 @@ class Agent:
 
         # register agent
         try:
-            self.model._agents[type(self)][self] = None
+            self.model.agents_[type(self)][self] = None
         except AttributeError:
             # model super has not been called
-            self.model._agents = defaultdict(dict)
+            self.model.agents_ = defaultdict(dict)
             self.model.agentset_experimental_warning_given = False
 
             warnings.warn(
@@ -65,7 +65,7 @@ class Agent:
     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.agents_[type(self)].pop(self)
 
     def step(self) -> None:
         """A single step of the agent."""
diff --git a/mesa/experimental/jupyter_viz.py b/mesa/experimental/jupyter_viz.py
index 2e5f0664..7a586239 100644
--- a/mesa/experimental/jupyter_viz.py
+++ b/mesa/experimental/jupyter_viz.py
@@ -17,7 +17,7 @@ def JupyterViz(
     model_class,
     model_params,
     measures=None,
-    name="Mesa Model",
+    name=None,
     agent_portrayal=None,
     space_drawer="default",
     play_interval=150,
@@ -35,6 +35,9 @@ def JupyterViz(
             specify `space_drawer=False`
         play_interval: play interval (default: 150)
     """
+    if name is None:
+        name = model_class.__name__
+
     current_step = solara.use_reactive(0)
 
     # 1. Set up model parameters
diff --git a/mesa/model.py b/mesa/model.py
index ca0c653c..1bd9b3a7 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -9,6 +9,7 @@ from __future__ import annotations
 
 import itertools
 import random
+import warnings
 from collections import defaultdict
 
 # mypy
@@ -29,7 +30,7 @@ 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.
+        agents_: A defaultdict mapping each agent type to a dict of its instances.
                  This private attribute is used internally to manage agents.
 
     Properties:
@@ -65,7 +66,7 @@ class Model:
         self.running = True
         self.schedule = None
         self.current_id = 0
-        self._agents: defaultdict[type, dict] = defaultdict(dict)
+        self.agents_: defaultdict[type, dict] = defaultdict(dict)
 
         # Warning flags for current experimental features. These make sure a warning is only printed once per model.
         self.agentset_experimental_warning_given = False
@@ -73,19 +74,33 @@ class Model:
     @property
     def agents(self) -> AgentSet:
         """Provides an AgentSet of all agents in the model, combining agents from all types."""
-        all_agents = itertools.chain(
-            *(agents_by_type.keys() for agents_by_type in self._agents.values())
+
+        if hasattr(self, "_agents"):
+            return self._agents
+        else:
+            all_agents = itertools.chain.from_iterable(self.agents_.values())
+            return AgentSet(all_agents, self)
+
+    @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",
+            UserWarning,
+            stacklevel=2,
         )
-        return AgentSet(all_agents, self)
+
+        self._agents = agents
 
     @property
     def agent_types(self) -> list[type]:
         """Return a list of different agent types."""
-        return list(self._agents.keys())
+        return list(self.agents_.keys())
 
     def get_agents_of_type(self, agenttype: type[Agent]) -> AgentSet:
         """Retrieves an AgentSet containing all agents of the specified type."""
-        return AgentSet(self._agents[agenttype].keys(), self)
+        return AgentSet(self.agents_[agenttype].keys(), self)
 
     def run_model(self) -> None:
         """Run the model until the end condition is reached. Overload as
diff --git a/mesa/space.py b/mesa/space.py
index 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:


Test patch:
diff --git a/tests/test_jupyter_viz.py b/tests/test_jupyter_viz.py
index 6f88c7d1..526e9140 100644
--- a/tests/test_jupyter_viz.py
+++ b/tests/test_jupyter_viz.py
@@ -85,6 +85,7 @@ class TestJupyterViz(unittest.TestCase):
     @patch("mesa.experimental.components.matplotlib.SpaceMatplotlib")
     def test_call_space_drawer(self, mock_space_matplotlib):
         mock_model_class = Mock()
+        mock_model_class.__name__ = "MockModelClass"
         agent_portrayal = {
             "Shape": "circle",
             "color": "gray",
diff --git a/tests/test_space.py b/tests/test_space.py
index b7524b6d..539d2c0e 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -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