diff --git a/benchmarks/configurations.py b/benchmarks/configurations.py index e1e193c4e6d..62f52bf3181 100644 --- a/benchmarks/configurations.py +++ b/benchmarks/configurations.py @@ -90,14 +90,19 @@ "seeds": 25, "replications": 3, "steps": 20, - "parameters": {"population": 200, "width": 100, "height": 100, "vision": 5}, + "parameters": { + "population_size": 200, + "width": 100, + "height": 100, + "vision": 5, + }, }, "large": { "seeds": 10, "replications": 3, "steps": 10, "parameters": { - "population": 400, + "population_size": 400, "width": 150, "height": 150, "vision": 15, diff --git a/mesa/examples/basic/boid_flockers/agents.py b/mesa/examples/basic/boid_flockers/agents.py index a8f23f915aa..10f31cdc530 100644 --- a/mesa/examples/basic/boid_flockers/agents.py +++ b/mesa/examples/basic/boid_flockers/agents.py @@ -6,10 +6,10 @@ import numpy as np -from mesa import Agent +from mesa.experimental.continuous_space import ContinuousSpaceAgent -class Boid(Agent): +class Boid(ContinuousSpaceAgent): """A Boid-style flocker agent. The agent follows three behaviors to flock: @@ -26,10 +26,12 @@ class Boid(Agent): def __init__( self, model, - speed, - direction, - vision, - separation, + space, + position=(0, 0), + speed=1, + direction=(1, 1), + vision=1, + separation=1, cohere=0.03, separate=0.015, match=0.05, @@ -46,7 +48,8 @@ def __init__( separate: Relative importance of avoiding close neighbors (default: 0.015) match: Relative importance of matching neighbors' directions (default: 0.05) """ - super().__init__(model) + super().__init__(space, model) + self.position = position self.speed = speed self.direction = direction self.vision = vision @@ -58,47 +61,31 @@ def __init__( 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, True) + neighbors, distances = self.get_neighbors_in_radius(radius=self.vision) self.neighbors = [n for n in neighbors if n is not self] # If no neighbors, maintain current direction - if not self.neighbors: - new_pos = self.pos + self.direction * self.speed - self.model.space.move_agent(self, new_pos) + if not neighbors: + self.position += self.direction * self.speed return - # Initialize vectors for the three flocking behaviors - cohere = np.zeros(2) # Cohesion vector - match_vector = np.zeros(2) # Alignment vector - separation_vector = np.zeros(2) # Separation vector + delta = self.space.calculate_difference_vector(self.position, agents=neighbors) - # Calculate the contribution of each neighbor to the three behaviors - for neighbor in self.neighbors: - heading = self.model.space.get_heading(self.pos, neighbor.pos) - distance = self.model.space.get_distance(self.pos, neighbor.pos) - - # Cohesion - steer towards the average position of neighbors - cohere += heading - - # Separation - avoid getting too close - if distance < self.separation: - separation_vector -= heading - - # Alignment - match neighbors' flying direction - match_vector += neighbor.direction - - # Weight each behavior by its factor and normalize by number of neighbors - n = len(self.neighbors) - cohere = cohere * self.cohere_factor - separation_vector = separation_vector * self.separate_factor - match_vector = match_vector * self.match_factor + cohere_vector = delta.sum(axis=0) * self.cohere_factor + separation_vector = ( + -1 * delta[distances < self.separation].sum(axis=0) * self.separate_factor + ) + match_vector = ( + np.asarray([n.direction for n in neighbors]).sum(axis=0) * self.match_factor + ) # Update direction based on the three behaviors - self.direction += (cohere + separation_vector + match_vector) / n + self.direction += (cohere_vector + separation_vector + match_vector) / len( + neighbors + ) # Normalize direction vector self.direction /= np.linalg.norm(self.direction) # Move boid - new_pos = self.pos + self.direction * self.speed - self.model.space.move_agent(self, new_pos) + self.position += self.direction * self.speed diff --git a/mesa/examples/basic/boid_flockers/app.py b/mesa/examples/basic/boid_flockers/app.py index 16b1ccf7f68..174e2736a2a 100644 --- a/mesa/examples/basic/boid_flockers/app.py +++ b/mesa/examples/basic/boid_flockers/app.py @@ -1,3 +1,8 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../..")) + from mesa.examples.basic.boid_flockers.model import BoidFlockers from mesa.visualization import Slider, SolaraViz, make_space_component @@ -17,7 +22,7 @@ def boid_draw(agent): "value": 42, "label": "Random Seed", }, - "population": Slider( + "population_size": Slider( label="Number of boids", value=100, min=10, diff --git a/mesa/examples/basic/boid_flockers/model.py b/mesa/examples/basic/boid_flockers/model.py index 5b4974f3a20..c5d46a12b85 100644 --- a/mesa/examples/basic/boid_flockers/model.py +++ b/mesa/examples/basic/boid_flockers/model.py @@ -5,11 +5,17 @@ Uses numpy arrays to represent vectors. """ +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../..")) + + import numpy as np from mesa import Model from mesa.examples.basic.boid_flockers.agents import Boid -from mesa.space import ContinuousSpace +from mesa.experimental.continuous_space import ContinuousSpace class BoidFlockers(Model): @@ -17,7 +23,7 @@ class BoidFlockers(Model): def __init__( self, - population=100, + population_size=100, width=100, height=100, speed=1, @@ -31,7 +37,7 @@ def __init__( """Create a new Boids Flocking model. Args: - population: Number of Boids in the simulation (default: 100) + population_size: Number of Boids in the simulation (default: 100) width: Width of the space (default: 100) height: Height of the space (default: 100) speed: How fast the Boids move (default: 1) @@ -44,48 +50,35 @@ def __init__( """ super().__init__(seed=seed) - # Model Parameters - self.population = population - self.vision = vision - self.speed = speed - self.separation = separation - # Set up the space - self.space = ContinuousSpace(width, height, torus=True) - - # Store flocking weights - self.factors = {"cohere": cohere, "separate": separate, "match": match} + self.space = ContinuousSpace( + [[0, width], [0, height]], + torus=True, + random=self.random, + n_agents=population_size, + ) # Create and place the Boid agents - self.make_agents() + positions = self.rng.random(size=(population_size, 2)) * self.space.size + directions = self.rng.uniform(-1, 1, size=(population_size, 2)) + Boid.create_agents( + self, + population_size, + self.space, + position=positions, + direction=directions, + cohere=cohere, + separate=separate, + match=match, + speed=speed, + vision=vision, + separation=separation, + ) # For tracking statistics self.average_heading = None self.update_average_heading() - def make_agents(self): - """Create and place all Boid agents randomly in the space.""" - for _ in range(self.population): - # Random position - x = self.random.random() * self.space.x_max - y = self.random.random() * self.space.y_max - pos = np.array((x, y)) - - # Random initial direction - direction = np.random.random(2) * 2 - 1 # Random vector between -1 and 1 - direction /= np.linalg.norm(direction) # Normalize - - # Create and place the Boid - boid = Boid( - model=self, - speed=self.speed, - direction=direction, - vision=self.vision, - separation=self.separation, - **self.factors, - ) - self.space.place_agent(boid, pos) - def update_average_heading(self): """Calculate the average heading (direction) of all Boids.""" if not self.agents: diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py index 946b2ba53fc..8f0ac91df48 100644 --- a/mesa/experimental/__init__.py +++ b/mesa/experimental/__init__.py @@ -15,6 +15,6 @@ - Features graduate from experimental status once their APIs are stabilized """ -from mesa.experimental import cell_space, devs, mesa_signals +from mesa.experimental import cell_space, continuous_space, devs, mesa_signals -__all__ = ["cell_space", "devs", "mesa_signals"] +__all__ = ["cell_space", "continuous_space", "devs", "mesa_signals"] diff --git a/mesa/experimental/continuous_space/__init__.py b/mesa/experimental/continuous_space/__init__.py new file mode 100644 index 00000000000..13425cc0b8c --- /dev/null +++ b/mesa/experimental/continuous_space/__init__.py @@ -0,0 +1,8 @@ +"""Continuous space support.""" + +from mesa.experimental.continuous_space.continuous_space import ContinuousSpace +from mesa.experimental.continuous_space.continuous_space_agents import ( + ContinuousSpaceAgent, +) + +__all__ = ["ContinuousSpace", "ContinuousSpaceAgent"] diff --git a/mesa/experimental/continuous_space/continuous_space.py b/mesa/experimental/continuous_space/continuous_space.py new file mode 100644 index 00000000000..232eb38414b --- /dev/null +++ b/mesa/experimental/continuous_space/continuous_space.py @@ -0,0 +1,273 @@ +"""A Continuous Space class.""" + +import warnings +from collections.abc import Iterable +from itertools import compress +from random import Random + +import numpy as np +from numpy.typing import ArrayLike +from scipy.spatial.distance import cdist + +from mesa.agent import Agent, AgentSet + + +class ContinuousSpace: + """Continuous space where each agent can have an arbitrary position.""" + + @property + def x_min(self): # noqa: D102 + # compatibility with solara_viz + return self.dimensions[0, 0] + + @property + def x_max(self): # noqa: D102 + # compatibility with solara_viz + return self.dimensions[0, 1] + + @property + def y_min(self): # noqa: D102 + # compatibility with solara_viz + return self.dimensions[1, 0] + + @property + def y_max(self): # noqa: D102 + # compatibility with solara_viz + return self.dimensions[1, 1] + + @property + def width(self): # noqa: D102 + # compatibility with solara_viz + return self.size[0] + + @property + def height(self): # noqa: D102 + # compatibility with solara_viz + return self.size[1] + + def __init__( + self, + dimensions: ArrayLike, + torus: bool = False, + random: Random | None = None, + n_agents: int = 100, + ) -> None: + """Create a new continuous space. + + Args: + dimensions: a numpy array like object where each row specifies the minimum and maximum value of that dimension. + torus: boolean for whether the space wraps around or not + random: a seeded stdlib random.Random instance + n_agents: the expected number of agents in the space + + Internally, a numpy array is used to store the positions of all agents. This is resized if needed, + but you can control the initial size explicitly by passing n_agents. + + + """ + if random is None: + warnings.warn( + "Random number generator not specified, this can make models non-reproducible. Please pass a random number generator explicitly", + UserWarning, + stacklevel=2, + ) + random = Random() + self.random = random + + self.dimensions: np.array = np.asanyarray(dimensions) + self.ndims: int = self.dimensions.shape[0] + self.size: np.array = self.dimensions[:, 1] - self.dimensions[:, 0] + self.center: np.array = np.sum(self.dimensions, axis=1) / 2 + + self.torus: bool = torus + + # self._agent_positions is the array containing all agent positions + # plus potential extra empty rows + # agent_positions is a view into _agent_positions containing only the filled rows + self._agent_positions: np.array = np.empty( + (n_agents, self.dimensions.shape[0]), dtype=float + ) + self.agent_positions: ( + np.array + ) # a view on _agent_positions containing all active positions + + # the list of agents in the space + self.active_agents = [] + self._n_agents = 0 # the number of active agents in the space + + # a mapping from agents to index and vice versa + self._index_to_agent: dict[int, Agent] = {} + self._agent_to_index: dict[Agent, int | None] = {} + + @property + def agents(self) -> AgentSet: + """Return an AgentSet with the agents in the space.""" + return AgentSet(self.active_agents, random=self.random) + + def _add_agent(self, agent: Agent) -> int: + """Helper method for adding an agent to the space. + + This method manages the numpy array with the agent positions and ensuring it is + enlarged if and when needed. It is called automatically by ContinousSpaceAgent when created. + + """ + index = self._n_agents + self._n_agents += 1 + + if self._agent_positions.shape[0] <= index: + # we are out of space + fraction = 0.2 # we add 20% Fixme + n = int(round(fraction * self._n_agents)) + self._agent_positions = np.vstack( + [ + self._agent_positions, + np.empty( + (n, self.dimensions.shape[0]), + ), + ] + ) + + self._agent_to_index[agent] = index + self._index_to_agent[index] = agent + + # we want to maintain a view rather than a copy on the active agents and positions + # this is essential for the performance of the rest of this code + self.active_agents.append(agent) + self.agent_positions = self._agent_positions[0 : self._n_agents] + + return index + + def _remove_agent(self, agent: Agent) -> None: + """Remove an agent from the space. + + This method is automatically called by ContinuousSpaceAgent.remove. + + """ + index = self._agent_to_index[agent] + self._agent_to_index.pop(agent, None) + self._index_to_agent.pop(index, None) + del self.active_agents[index] + + # we update all indices + for agent in self.active_agents[index::]: + old_index = self._agent_to_index[agent] + self._agent_to_index[agent] = old_index - 1 + self._index_to_agent[old_index - 1] = agent + + # we move all data below the removed agent one row up + self._agent_positions[index : self._n_agents - 1] = self._agent_positions[ + index + 1 : self._n_agents + ] + self._n_agents -= 1 + self.agent_positions = self._agent_positions[0 : self._n_agents] + + def calculate_difference_vector(self, point: np.ndarray, agents=None) -> np.ndarray: + """Calculate the difference vector between the point and all agenents. + + Args: + point: the point to calculate the difference vector for + agents: the agents to calculate the difference vector of point with. By default, + all agents are considered. + + + """ + point = np.asanyarray(point) + positions = ( + self.agent_positions + if agents is None + else self._agent_positions[[self._agent_to_index[a] for a in agents]] + ) + + delta = positions - point + + if self.torus: + inverse_delta = delta - np.sign(delta) * self.size + + # we need to use the lowest absolute value from delta and inverse delta + logical = np.abs(delta) < np.abs(inverse_delta) + + out = np.zeros(delta.shape) + out[logical] = delta[logical] + out[~logical] = inverse_delta[~logical] + + delta = out + + return delta + + def calculate_distances( + self, point: ArrayLike, agents: Iterable[Agent] | None = None, **kwargs + ) -> tuple[np.ndarray, list]: + """Calculate the distance between the point and all agents. + + Args: + point: the point to calculate the difference vector for + agents: the agents to calculate the difference vector of point with. By default, + all agents are considered. + kwargs: any additional keyword arguments are passed to scipy's cdist, which is used + only if torus is False. This allows for non-Euclidian distance measures. + + """ + point = np.asanyarray(point) + + if agents is None: + positions = self.agent_positions + agents = self.active_agents + else: + positions = self._agent_positions[[self._agent_to_index[a] for a in agents]] + agents = np.asarray(agents) + + if self.torus: + delta = np.abs(point - positions) + delta = np.minimum(delta, self.size - delta, out=delta) + + # + is much faster than np.sum or array.sum + dists = delta[:, 0] ** 2 + for i in range(1, self.ndims): + dists += delta[:, i] ** 2 + dists = np.sqrt(dists) + else: + dists = cdist(point[np.newaxis, :], positions, **kwargs)[0, :] + return dists, agents + + def get_agents_in_radius( + self, point: ArrayLike, radius: float | int = 1 + ) -> tuple[list, np.ndarray]: + """Return the agents and their distances within a radius for the point.""" + distances, agents = self.calculate_distances(point) + logical = distances <= radius + agents = list(compress(agents, logical)) + return ( + agents, + distances[logical], + ) + + def get_k_nearest_agents( + self, point: ArrayLike, k: int = 1 + ) -> tuple[list, np.ndarray]: + """Return the k nearest agents and their distances to the point. + + Notes: + This method returns exactly k agents, ignoring ties. In case of ties, the + earlier an agent is inserted the higher it will rank. + + """ + dists, agents = self.calculate_distances(point) + + indices = np.argpartition(dists, k)[:k] + agents = [agents[i] for i in indices] + return agents, dists[indices] + + def in_bounds(self, point: ArrayLike) -> bool: + """Check if point is inside the bounds of the space.""" + return bool( + ( + (np.asanyarray(point) >= self.dimensions[:, 0]) + & (point <= self.dimensions[:, 1]) + ).all() + ) + + def torus_correct(self, point: ArrayLike) -> np.ndarray: + """Apply a torus correction to the point.""" + return self.dimensions[:, 0] + np.mod( + np.asanyarray(point) - self.dimensions[:, 0], self.size + ) diff --git a/mesa/experimental/continuous_space/continuous_space_agents.py b/mesa/experimental/continuous_space/continuous_space_agents.py new file mode 100644 index 00000000000..f4ea9c2885f --- /dev/null +++ b/mesa/experimental/continuous_space/continuous_space_agents.py @@ -0,0 +1,101 @@ +"""Continuous space agents.""" + +from __future__ import annotations + +from itertools import compress +from typing import Protocol + +import numpy as np + +from mesa.agent import Agent +from mesa.experimental.continuous_space import ContinuousSpace + + +class HasPositionProtocol(Protocol): + """Protocol for continuous space position holders.""" + + position: np.ndarray + + +class ContinuousSpaceAgent(Agent): + """A continuous space agent. + + Attributes: + space (ContinuousSpace): the continuous space in which the agent is located + position (np.ndarray): the position of the agent + + """ + + __slots__ = ["_mesa_index", "space"] + + @property + def position(self) -> np.ndarray: + """Position of the agent.""" + return self.space.agent_positions[self.space._agent_to_index[self]] + + @position.setter + def position(self, value: np.ndarray) -> None: + if not self.space.in_bounds(value): + if self.space.torus: + value = self.space.torus_correct(value) + else: + raise ValueError(f"point {value} is outside the bounds of the space") + + self.space.agent_positions[self.space._agent_to_index[self]] = value + + @property + def pos(self): # noqa: D102 + # just here for compatibility with solara_viz. + return self.position + + @pos.setter + def pos(self, value): + # just here for compatibility solara_viz. + pass + + def __init__(self, space: ContinuousSpace, model): + """Initialize a continuous space agent. + + Args: + space: the continuous space in which the agent is located + model: the model to which the agent belongs + + """ + super().__init__(model) + self.space: ContinuousSpace = space + self.space._add_agent(self) + # self.position[:] = np.nan + + def remove(self) -> None: + """Remove and delete the agent from the model and continuous space.""" + super().remove() + self.space._remove_agent(self) + self._mesa_index = None + self.space = None + + def get_neighbors_in_radius( + self, radius: float | int = 1 + ) -> tuple[list, np.ndarray]: + """Get neighbors within radius. + + Args: + radius: radius within which to look for neighbors + + """ + agents, dists = self.space.get_agents_in_radius(self.position, radius=radius) + logical = np.asarray([agent is not self for agent in agents]) + agents = list(compress(agents, logical)) + return agents, dists[logical] + + def get_nearest_neighbors(self, k: int = 1) -> tuple[list, np.ndarray]: + """Get neighbors within radius. + + Args: + k: the number of nearest neighbors to return + + """ + # return includes self, so we need to get k+1 + agents, dists = self.space.get_k_nearest_agents(self.position, k=k + 1) + logical = np.asarray([agent is not self for agent in agents]) + agents = list(compress(agents, logical)) + return agents, dists[logical] diff --git a/mesa/visualization/mpl_space_drawing.py b/mesa/visualization/mpl_space_drawing.py index 784d370eba0..6e7fa9a05d5 100644 --- a/mesa/visualization/mpl_space_drawing.py +++ b/mesa/visualization/mpl_space_drawing.py @@ -143,7 +143,10 @@ def draw_space( draw_orthogonal_grid(space, agent_portrayal, ax=ax, **space_drawing_kwargs) case mesa.space.NetworkGrid() | mesa.experimental.cell_space.Network(): draw_network(space, agent_portrayal, ax=ax, **space_drawing_kwargs) - case mesa.space.ContinuousSpace(): + case ( + mesa.space.ContinuousSpace() + | mesa.experimental.continuous_space.ContinuousSpace() + ): draw_continuous_space(space, agent_portrayal, ax=ax) case VoronoiGrid(): draw_voronoi_grid(space, agent_portrayal, ax=ax) diff --git a/pyproject.toml b/pyproject.toml index 51265df9301..db68f3986c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "numpy", "pandas", "tqdm", + "scipy" ] dynamic = ["version"] diff --git a/tests/test_continuous_space.py b/tests/test_continuous_space.py new file mode 100644 index 00000000000..41d7ca33186 --- /dev/null +++ b/tests/test_continuous_space.py @@ -0,0 +1,432 @@ +"""Tests for continuous space.""" + +import numpy as np +import pytest + +from mesa import Model +from mesa.experimental.continuous_space import ContinuousSpace, ContinuousSpaceAgent + + +def test_continuous_space(): + """Test ContinuousSpace class.""" + model = Model(seed=42) + + dimensions = np.asarray([[0, 1], [-1, 0]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + # check some default fields + assert space.ndims == 2 + assert np.all(space.size == [1, 1]) + assert np.all(space.center == [0.5, -0.5]) + assert len(space.agents) == 0 + + # check in_bounds + assert space.in_bounds([0.5, -0.5]) + assert not space.in_bounds([-0.5, -0.5]) + assert not space.in_bounds([1.5, -0.5]) + assert not space.in_bounds([0.5, 0.5]) + assert not space.in_bounds([0.5, -1.5]) + + # check torus correction + space = ContinuousSpace(dimensions, torus=True, random=model.random) + assert np.all(space.torus_correct([-0.5, 0.5]) == [0.5, -0.5]) + assert np.all(space.torus_correct([0.5, -0.5]) == [0.5, -0.5]) + assert np.all(space.torus_correct([0.5, -0.5]) == [0.5, -0.5]) + assert np.all(space.torus_correct([1.5, -0.5]) == [0.5, -0.5]) + assert np.all(space.torus_correct([0.5, -1.5]) == [0.5, -0.5]) + + # check 3d + dimensions = np.asarray([[0, 2], [-2, 0], [-2, 2]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + # check some default fields + assert space.ndims == 3 + assert np.all(space.size == [2, 2, 4]) + assert np.all(space.center == [1, -1, 0]) + + # check in_bounds + assert space.in_bounds([1, -1, 0]) + assert not space.in_bounds([-0.5, -1, 0]) + assert not space.in_bounds([2.5, -1, 0]) + assert not space.in_bounds([1, 0.5, 0]) + assert not space.in_bounds([1, -2.5, 0]) + assert not space.in_bounds([1, -1, -3]) + assert not space.in_bounds([1, -1, 3]) + assert not space.in_bounds([-0.5, -1, 3]) + assert not space.in_bounds([1, 0.5, 3]) + + +def test_continuous_agent(): + """Test ContinuousSpaceAgent class.""" + model = Model(seed=42) + + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + for _ in range(10): + agent = ContinuousSpaceAgent(space, model) + position = [agent.random.random(), agent.random.random()] + agent.position = position + agent.coordinate = position + + assert space.agent_positions.shape == (10, 2) + for agent in space.agents: + a = agent.position + b = space._agent_positions[space._agent_to_index[agent]] + assert np.all(a == b) + assert np.all(agent.position == agent.coordinate) + + # add more agents, triggering a resizeing of the array + for _ in range(100): + agent = ContinuousSpaceAgent(space, model) + position = [agent.random.random(), agent.random.random()] + agent.position = position + agent.coordinate = position + + assert space.agent_positions.shape == (110, 2) + for agent in space.agents: + a = agent.position + b = space._agent_positions[space._agent_to_index[agent]] + assert np.all(a == b) + assert np.all(agent.position == agent.coordinate) + + # remove all agents and check if the view is updated throughout correctly + for i, agent in enumerate(space.agents): + assert np.all( + agent.position == agent.coordinate + ) ## check if updates of indices is correctly done + agent.remove() + assert space.agent_positions.shape == (110 - 1 - i, 2) + + model = Model(seed=42) + + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=True, random=model.random) + agent = ContinuousSpaceAgent(space, model) + agent.position = [1.1, 1.1] + assert np.allclose(agent.position, [0.1, 0.1]) + + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + agent = ContinuousSpaceAgent(space, model) + with pytest.raises(ValueError): + agent.position = [1.1, 1.1] + + +def test_continous_space_calculate_distances(): + """Test ContinuousSpace.distance method.""" + # non torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.1, 0.1] + + distances, agents = space.calculate_distances([0.1, 0.9]) + assert np.all( + distances + == [ + 0.8, + ] + ) + assert np.all( + agents + == [ + agent, + ] + ) + + distances, agents = space.calculate_distances([0.9, 0.1]) + assert np.all( + distances + == [ + 0.8, + ] + ) + assert np.all( + agents + == [ + agent, + ] + ) + + # torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=True, random=model.random) + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.1, 0.1] + + distances, agents = space.calculate_distances([0.1, 0.9]) + assert np.all( + np.isclose( + distances, + [ + 0.2, + ], + ) + ) + assert np.all( + agents + == [ + agent, + ] + ) + + distances, agents = space.calculate_distances([0.9, 0.1]) + assert np.all( + np.isclose( + distances, + [ + 0.2, + ], + ) + ) + assert np.all( + agents + == [ + agent, + ] + ) + + distances, agents = space.calculate_distances([0.9, 0.9]) + assert np.all( + np.isclose( + distances, + [ + 0.2 * 2**0.5, + ], + ) + ) + assert np.all( + agents + == [ + agent, + ] + ) + + distances, agents = space.calculate_distances( + [0.9, 0.9], + agents=[ + agent, + ], + ) + assert np.all( + np.isclose( + distances, + [ + 0.2 * 2**0.5, + ], + ) + ) + assert np.all( + agents + == [ + agent, + ] + ) + + +def test_continous_space_difference_vector(): + """Test ContinuousSpace.get_difference_vector method.""" + # non torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.1, 0.1] + + vector = space.calculate_difference_vector([0.1, 0.9]) + assert np.all(vector == [0, -0.8]) + + vector = space.calculate_difference_vector([0.9, 0.1]) + assert np.all(vector == [-0.8, 0]) + + # torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=True, random=model.random) + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.1, 0.1] + + vector = space.calculate_difference_vector([0.1, 0.9]) + assert np.allclose(vector, [0, 0.2]) + + vector = space.calculate_difference_vector([0.9, 0.1]) + assert np.allclose(vector, [0.2, 0]) + + vector = space.calculate_difference_vector([0.9, 0.9]) + assert np.allclose(vector, [0.2, 0.2]) + + +def test_continuous_space_get_k_nearest_agents(): # noqa: D103 + # non torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.1, 0.1] + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.1, 0.9] + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.9, 0.1] + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.9, 0.9] + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.5, 0.5] + + agents, distances = space.get_k_nearest_agents([0.1, 0.1], k=1) + assert len(agents) == 1 + assert np.allclose( + distances, + [ + 0, + ], + ) + + agents, distances = space.get_k_nearest_agents([0.5, 0.1], k=1) + assert len(agents) == 1 + assert np.allclose(distances, [0.4, 0.4]) + + agents, distances = space.get_k_nearest_agents([0.5, 0.1], k=2) + assert len(agents) == 2 + assert np.allclose(distances, [0.4, 0.4]) + + # torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=True, random=model.random) + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.1, 0.1] + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.9, 0.1] + + agent = ContinuousSpaceAgent(space, model) + agent.position = [0.9, 0.9] + + agents, distances = space.get_k_nearest_agents([0.0, 0.1], k=2) + assert len(agents) == 2 + assert np.allclose(distances, [0.1, 0.1]) + + +def test_continuous_space_get_agents_in_radius(): # noqa: D103 + # non torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + positions = [ + [0.1, 0.1], + [0.1, 0.9], + [0.9, 0.1], + [0.9, 0.9], + [0.5, 0.5], + ] + + for position in positions: + agent = ContinuousSpaceAgent(space, model) + agent.position = position + + agents, distances = space.get_agents_in_radius([0.1, 0.1], radius=0.1) + assert len(agents) == 1 + assert np.allclose( + distances, + [ + 0, + ], + ) + + agents, distances = space.get_agents_in_radius([0.5, 0.1], radius=0.4) + assert len(agents) == 3 + assert np.allclose(distances, [0.4, 0.4, 0.4]) + + agents, distances = space.get_agents_in_radius([0.5, 0.5], radius=1) + assert len(agents) == 5 + + # torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=True, random=model.random) + + positions = [ + [0.1, 0.1], + [0.1, 0.9], + [0.9, 0.1], + [0.9, 0.9], + [0.5, 0.5], + ] + + for position in positions: + agent = ContinuousSpaceAgent(space, model) + agent.position = position + + agents, distances = space.get_agents_in_radius([0.0, 0.1], radius=0.1) + assert len(agents) == 2 + assert np.allclose(distances, [0.1, 0.1]) + + +def test_get_neighbor_methos(): # noqa: D103 + # non torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=False, random=model.random) + + positions = [ + [0.1, 0.1], + [0.1, 0.9], + [0.9, 0.1], + [0.9, 0.9], + [0.5, 0.5], + ] + + for position in positions: + agent = ContinuousSpaceAgent(space, model) + agent.position = position + + agent: ContinuousSpaceAgent = model.agents[-1] # 0.5, 0.5 + agents, distances = agent.get_neighbors_in_radius(1) + assert len(agents) == 4 + + agents, distances = agent.get_neighbors_in_radius(0.1) + assert len(agents) == 0 + + agent: ContinuousSpaceAgent = model.agents[0] # 0.1, 0.1 + agents, distances = agent.get_nearest_neighbors(k=2) + assert len(agents) == 2 + + # torus + model = Model(seed=42) + dimensions = np.asarray([[0, 1], [0, 1]]) + space = ContinuousSpace(dimensions, torus=True, random=model.random) + + positions = [ + [0.1, 0.1], + [0.1, 0.9], + [0.9, 0.1], + [0.9, 0.9], + [0.5, 0.5], + ] + + for position in positions: + agent = ContinuousSpaceAgent(space, model) + agent.position = position + + agent: ContinuousSpaceAgent = model.agents[-1] # 0.5, 0.5 + agents, distances = agent.get_neighbors_in_radius(1) + assert len(agents) == 4 + + agent: ContinuousSpaceAgent = model.agents[0] # 0.1, 0.1 + agents, distances = agent.get_nearest_neighbors(k=2) + assert len(agents) == 2 + assert np.allclose(distances, [0.2, 0.2])