From fa8c15d1910ca6a8f354bf6ad2e99e87c3728592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Wed, 20 Mar 2024 17:13:36 -0300 Subject: [PATCH 01/50] Implemented Voronoi based discrete space --- mesa/experimental/cell_space/__init__.py | 2 + mesa/experimental/cell_space/voronoi.py | 73 ++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 76 insertions(+) create mode 100644 mesa/experimental/cell_space/voronoi.py diff --git a/mesa/experimental/cell_space/__init__.py b/mesa/experimental/cell_space/__init__.py index dce296aebce..8db71025616 100644 --- a/mesa/experimental/cell_space/__init__.py +++ b/mesa/experimental/cell_space/__init__.py @@ -9,6 +9,7 @@ OrthogonalVonNeumannGrid, ) from mesa.experimental.cell_space.network import Network +from mesa.experimental.cell_space.voronoi import VoronoiGrid __all__ = [ "CellCollection", @@ -20,4 +21,5 @@ "OrthogonalMooreGrid", "OrthogonalVonNeumannGrid", "Network", + "VoronoiGrid", ] diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py new file mode 100644 index 00000000000..b838a9fb7e2 --- /dev/null +++ b/mesa/experimental/cell_space/voronoi.py @@ -0,0 +1,73 @@ +from collections.abc import Sequence +from itertools import combinations +from random import Random +from typing import Optional + +from numpy import array as np_array +from numpy.random import uniform as np_uniform +from pyhull.delaunay import DelaunayTri + +from mesa.experimental.cell_space.cell import Cell +from mesa.experimental.cell_space.discrete_space import DiscreteSpace + + +class VoronoiGrid(DiscreteSpace): + def __init__( + self, + dimensions: Optional[Sequence[int]] = None, + density: Optional[float] = None, + capacity: float | None = None, + random: Optional[Random] = None, + cell_klass: type[Cell] = Cell, + ) -> None: + """A Voronoi Tessellation Grid + + Args: + dimensions (Sequence[int]): a sequence of space dimensions + density (float): density of cells in the space + capacity (int) : the capacity of the cell + random (Random): + CellKlass (type[Cell]): The base Cell class to use in the Network + + """ + super().__init__(capacity=capacity, random=random, cell_klass=cell_klass) + self.dimensions = dimensions + self.density = density + self._ndims = len(dimensions) + self._validate_parameters() + + _grid_total_space = 1 + for dim in self.dimensions: + _grid_total_space *= dim + + self.number_cells = _grid_total_space * density + + self.np_coordinates = np_array( + [np_uniform(0, dim, self.number_cells) for dim in self.dimensions] + ).T + + self._cells = { + i: cell_klass(self.np_coordinates[i], capacity, random=self.random) + for i in range(self.number_cells) + } + + self._connect_cells() + + def _connect_cells(self): + self._connect_cells_nd() + + def _connect_cells_nd(self): + triangulation = DelaunayTri(self.np_coordinates) + + for p in triangulation.vertices: + for i, j in combinations(p, 2): + self._cells[i].connect(self._cells[j]) + self._cells[j].connect(self._cells[i]) + + def _validate_parameters(self): + if not isinstance(self.density, float) and not (self.density > 0): + raise ValueError("Density should be a positive float.") + if not all(isinstance(dim, int) and dim > 0 for dim in self.dimensions): + raise ValueError("Dimensions must be a list of positive integers.") + if self.capacity is not None and not isinstance(self.capacity, (float, int)): + raise ValueError("Capacity must be a number or None.") diff --git a/pyproject.toml b/pyproject.toml index 4d3d4ad491c..d583b5cfa58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "pandas", "solara", "tqdm", + "pyhull", ] dynamic = ["version"] From 301a5ffefea12fa35b338e38c1c8754dee2e028c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Wed, 20 Mar 2024 18:01:48 -0300 Subject: [PATCH 02/50] Using coordinates instead of dimensions and density --- mesa/experimental/cell_space/voronoi.py | 41 ++++++++++++------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py index b838a9fb7e2..2a6b7193857 100644 --- a/mesa/experimental/cell_space/voronoi.py +++ b/mesa/experimental/cell_space/voronoi.py @@ -3,8 +3,6 @@ from random import Random from typing import Optional -from numpy import array as np_array -from numpy.random import uniform as np_uniform from pyhull.delaunay import DelaunayTri from mesa.experimental.cell_space.cell import Cell @@ -14,8 +12,7 @@ class VoronoiGrid(DiscreteSpace): def __init__( self, - dimensions: Optional[Sequence[int]] = None, - density: Optional[float] = None, + centroids_coordinates: Optional[Sequence[Sequence[float]]] = None, capacity: float | None = None, random: Optional[Random] = None, cell_klass: type[Cell] = Cell, @@ -31,24 +28,22 @@ def __init__( """ super().__init__(capacity=capacity, random=random, cell_klass=cell_klass) - self.dimensions = dimensions - self.density = density - self._ndims = len(dimensions) + self.centroids_coordinates = centroids_coordinates self._validate_parameters() - _grid_total_space = 1 - for dim in self.dimensions: - _grid_total_space *= dim + # _grid_total_space = 1 + # for dim in self.dimensions: + # _grid_total_space *= dim - self.number_cells = _grid_total_space * density + # self.number_cells = _grid_total_space * density - self.np_coordinates = np_array( - [np_uniform(0, dim, self.number_cells) for dim in self.dimensions] - ).T + # self.np_coordinates = np_array( + # [np_uniform(0, dim, self.number_cells) for dim in self.dimensions] + # ).T self._cells = { - i: cell_klass(self.np_coordinates[i], capacity, random=self.random) - for i in range(self.number_cells) + i: cell_klass(self.centroids_coordinates[i], capacity, random=self.random) + for i in range(len(self.centroids_coordinates)) } self._connect_cells() @@ -57,7 +52,7 @@ def _connect_cells(self): self._connect_cells_nd() def _connect_cells_nd(self): - triangulation = DelaunayTri(self.np_coordinates) + triangulation = DelaunayTri(self.centroids_coordinates) for p in triangulation.vertices: for i, j in combinations(p, 2): @@ -65,9 +60,13 @@ def _connect_cells_nd(self): self._cells[j].connect(self._cells[i]) def _validate_parameters(self): - if not isinstance(self.density, float) and not (self.density > 0): - raise ValueError("Density should be a positive float.") - if not all(isinstance(dim, int) and dim > 0 for dim in self.dimensions): - raise ValueError("Dimensions must be a list of positive integers.") if self.capacity is not None and not isinstance(self.capacity, (float, int)): raise ValueError("Capacity must be a number or None.") + if not isinstance(self.centroids_coordinates, Sequence) and not isinstance( + self.centroids_coordinates[0], Sequence + ): + raise ValueError("Centroids should be a list of lists") + dimension_1 = len(self.centroids_coordinates[0]) + for coordinate in self.centroids_coordinates: + if dimension_1 != len(coordinate): + raise ValueError("Centroid coordinates should be a homogeneous array") From a8a17ab2d9348d26339ff28ff096f846e53d4d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Wed, 20 Mar 2024 23:05:53 -0300 Subject: [PATCH 03/50] deleted comments --- mesa/experimental/cell_space/voronoi.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py index 2a6b7193857..519be0e2c7c 100644 --- a/mesa/experimental/cell_space/voronoi.py +++ b/mesa/experimental/cell_space/voronoi.py @@ -31,16 +31,6 @@ def __init__( self.centroids_coordinates = centroids_coordinates self._validate_parameters() - # _grid_total_space = 1 - # for dim in self.dimensions: - # _grid_total_space *= dim - - # self.number_cells = _grid_total_space * density - - # self.np_coordinates = np_array( - # [np_uniform(0, dim, self.number_cells) for dim in self.dimensions] - # ).T - self._cells = { i: cell_klass(self.centroids_coordinates[i], capacity, random=self.random) for i in range(len(self.centroids_coordinates)) From 7e4d79eb3add61ece20a6cecf96208d0251a237f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Sat, 23 Mar 2024 18:42:47 -0300 Subject: [PATCH 04/50] incremented docstrings --- mesa/experimental/cell_space/voronoi.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py index 519be0e2c7c..02a52c2cbdb 100644 --- a/mesa/experimental/cell_space/voronoi.py +++ b/mesa/experimental/cell_space/voronoi.py @@ -12,20 +12,18 @@ class VoronoiGrid(DiscreteSpace): def __init__( self, - centroids_coordinates: Optional[Sequence[Sequence[float]]] = None, - capacity: float | None = None, + centroids_coordinates: Sequence[Sequence[float]], + capacity: Optional[float] = None, random: Optional[Random] = None, cell_klass: type[Cell] = Cell, ) -> None: """A Voronoi Tessellation Grid Args: - dimensions (Sequence[int]): a sequence of space dimensions - density (float): density of cells in the space - capacity (int) : the capacity of the cell - random (Random): - CellKlass (type[Cell]): The base Cell class to use in the Network - + centroids_coordinates: coordinates of centroids to build the tessellation space + capacity (int) : capacity of the cells in the discrete space + random (Random): random number generator + CellKlass (type[Cell]): type of cell class """ super().__init__(capacity=capacity, random=random, cell_klass=cell_klass) self.centroids_coordinates = centroids_coordinates @@ -38,10 +36,8 @@ def __init__( self._connect_cells() - def _connect_cells(self): - self._connect_cells_nd() - - def _connect_cells_nd(self): + def _connect_cells(self) -> None: + """Connect cells to neighbors based on given centroids and using Delaunay Triangulation""" triangulation = DelaunayTri(self.centroids_coordinates) for p in triangulation.vertices: @@ -49,7 +45,7 @@ def _connect_cells_nd(self): self._cells[i].connect(self._cells[j]) self._cells[j].connect(self._cells[i]) - def _validate_parameters(self): + def _validate_parameters(self) -> None: if self.capacity is not None and not isinstance(self.capacity, (float, int)): raise ValueError("Capacity must be a number or None.") if not isinstance(self.centroids_coordinates, Sequence) and not isinstance( From 8a892ee51025f6f7ce84ebf7755a8531f3b61138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Sat, 23 Mar 2024 18:43:20 -0300 Subject: [PATCH 05/50] added voronoi tests --- tests/test_cell_space.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py index 050e39a09a6..d0324f7e450 100644 --- a/tests/test_cell_space.py +++ b/tests/test_cell_space.py @@ -11,6 +11,7 @@ Network, OrthogonalMooreGrid, OrthogonalVonNeumannGrid, + VoronoiGrid, ) @@ -372,6 +373,19 @@ def test_networkgrid(): assert connection.coordinate in G.neighbors(i) +def test_voronoigrid(): + points = [[0, 1], [1, 3], [1.1, 1], [1, 1]] + + grid = VoronoiGrid(points) + + assert len(grid._cells) == len(points) + + # Check cell neighborhood + assert len(grid._cells[0]._connections) == 2 + for connection in grid._cells[0]._connections: + assert connection.coordinate in [[1, 1], [1, 3]] + + def test_empties_space(): import networkx as nx From a079b958d60d7297fc4fa071f7a78bd03f73d745 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 17 Mar 2024 23:08:31 +0100 Subject: [PATCH 06/50] warn if placing already placed agent While it might be desired in a specific model to have the same agent be placed in multiple spots simultaneously, the typical use case is that one agent has one position at every given moment. This commit decorates the place_agent() method in a way that it emits a warning when called with an agent which already has a location. Fixes: #1522 --- mesa/space.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/mesa/space.py b/mesa/space.py index 0990c0026cb..79c570ae89e 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -71,6 +71,21 @@ def is_integer(x: Real) -> bool: return isinstance(x, _types_integer) +def warn_if_agent_has_position_already(placement_func): + def wrapper(self, agent, *args, **kwargs): + if agent.pos is not None: + warnings.warn( + f"""Agent {agent.unique_id} is being placed with +place_agent() despite already having the position {agent.pos}. In most +cases, you'd want to clear the current position with remove_agent() +before placing the agent again.""", + stacklevel=2, + ) + placement_func(self, agent, *args, **kwargs) + + return wrapper + + class _Grid: """Base class for a rectangular grid. @@ -976,6 +991,7 @@ class SingleGrid(_PropertyGrid): the grid for empty spaces. """ + @warn_if_agent_has_position_already def place_agent(self, agent: Agent, pos: Coordinate) -> None: """Place the agent at the specified location, and set its pos variable.""" if self.is_cell_empty(pos): @@ -1024,6 +1040,7 @@ def default_val() -> MultiGridContent: """Default value for new cell elements.""" return [] + @warn_if_agent_has_position_already def place_agent(self, agent: Agent, pos: Coordinate) -> None: """Place the agent at the specified location, and set its pos variable.""" x, y = pos @@ -1355,6 +1372,7 @@ def _invalidate_agent_cache(self): self._agent_points = None self._index_to_agent = {} + @warn_if_agent_has_position_already def place_agent(self, agent: Agent, pos: FloatCoordinate) -> None: """Place a new agent in the space. @@ -1515,6 +1533,7 @@ def default_val() -> list: """Default value for a new node.""" return [] + @warn_if_agent_has_position_already def place_agent(self, agent: Agent, node_id: int) -> None: """Place an agent in a node.""" self.G.nodes[node_id]["agent"].append(agent) From 6b8e760cbe3a729eb338b1cb7fb3f757c79fc935 Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 23 Mar 2024 07:11:22 -0400 Subject: [PATCH 07/50] ci: Use uv pip for faster build (#2038) uv is a fast drop-in pip, pip-compile, virtualenv etc replacement created by the creator of Ruff. --- .github/workflows/build_lint.yml | 47 ++++++-------------------------- .github/workflows/release.yml | 1 + 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml index 9f6f450ce9c..c7b6b4f00fc 100644 --- a/.github/workflows/build_lint.yml +++ b/.github/workflows/build_lint.yml @@ -46,30 +46,12 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - if: ${{ runner.os == 'Windows' }} - # This is needed so that restoring cache on Windows is fast. - # See until https://github.com/actions/cache/issues/752 is resolved. - name: Use GNU tar - shell: cmd - run: | - echo "Adding GNU tar to PATH" - echo C:\Program Files\Git\usr\bin>>"%GITHUB_PATH%" - - uses: actions/cache@v4 - with: - path: ${{ env.pythonLocation }} - key: ${{ runner.os }}-pip-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }} - - name: Install dependencies - # Only if the cache misses - # Based on https://github.com/pypa/pip/issues/8049#issuecomment-633845028 - # read_requirements.py should be removed once - # https://github.com/pypa/pip/issues/11440 is resolved. - if: steps.cache.outputs.cache-hit != 'true' - run: | - pip install toml - python tests/read_requirements.py > requirements.txt - pip install -r requirements.txt + cache: 'pip' + - name: Install uv + run: pip install uv - name: Install Mesa - run: pip install --no-deps . + # See https://github.com/astral-sh/uv/issues/1945 + run: uv pip install --system .[dev] - name: Test with pytest run: pytest --durations=10 --cov=mesa tests/ --cov-report=xml - if: matrix.os == 'ubuntu' @@ -84,22 +66,11 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" - - uses: actions/cache@v4 - with: - path: ${{ env.pythonLocation }} - key: test-examples-pip-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }} - - name: Install dependencies - # Only if the cache misses - # Based on https://github.com/pypa/pip/issues/8049#issuecomment-633845028 - # read_requirements.py should be removed once - # https://github.com/pypa/pip/issues/11440 is resolved. - if: steps.cache.outputs.cache-hit != 'true' - run: | - pip install toml - python tests/read_requirements.py > requirements.txt - pip install -r requirements.txt + cache: 'pip' + - name: Install uv + run: pip install uv - name: Install Mesa - run: pip install --no-deps . + run: uv pip install --system .[dev] - name: Checkout mesa-examples uses: actions/checkout@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ce4e87bd22..627987f3b1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + cache: 'pip' - name: Install dependencies run: pip install -U pip hatch - name: Build distributions From 63bffa4ecca98890c02e2793ec94a0d37ad792f3 Mon Sep 17 00:00:00 2001 From: rht Date: Sat, 23 Mar 2024 20:04:25 -0400 Subject: [PATCH 08/50] test: Remove place_agent duplicate warnings --- benchmarks/Flocking/flocking.py | 4 --- tests/test_grid.py | 20 +++++++------- tests/test_space.py | 48 ++++++++++++++++----------------- 3 files changed, 34 insertions(+), 38 deletions(-) diff --git a/benchmarks/Flocking/flocking.py b/benchmarks/Flocking/flocking.py index 352cacb84f4..025bd71cd5f 100644 --- a/benchmarks/Flocking/flocking.py +++ b/benchmarks/Flocking/flocking.py @@ -29,7 +29,6 @@ def __init__( self, unique_id, model, - pos, speed, direction, vision, @@ -43,7 +42,6 @@ def __init__( Args: unique_id: Unique agent identifier. - pos: Starting position speed: Distance to move per step. direction: numpy vector for the Boid's direction of movement. vision: Radius to look around for nearby Boids. @@ -54,7 +52,6 @@ def __init__( """ super().__init__(unique_id, model) - self.pos = np.array(pos) self.speed = speed self.direction = direction self.vision = vision @@ -141,7 +138,6 @@ def make_agents(self): boid = Boid( unique_id=i, model=self, - pos=pos, speed=self.speed, direction=direction, vision=self.vision, diff --git a/tests/test_grid.py b/tests/test_grid.py index 686d8e78b59..b2500bbccb8 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -24,10 +24,10 @@ class MockAgent: Minimalistic agent for testing purposes. """ - def __init__(self, unique_id, pos): + def __init__(self, unique_id): self.random = random.Random(0) self.unique_id = unique_id - self.pos = pos + self.pos = None class TestSingleGrid(unittest.TestCase): @@ -53,7 +53,7 @@ def setUp(self): continue counter += 1 # Create and place the mock agent - a = MockAgent(counter, None) + a = MockAgent(counter) self.agents.append(a) self.grid.place_agent(a, (x, y)) @@ -258,7 +258,7 @@ def setUp(self): continue counter += 1 # Create and place the mock agent - a = MockAgent(counter, None) + a = MockAgent(counter) self.agents.append(a) self.grid.place_agent(a, (x, y)) self.num_agents = len(self.agents) @@ -270,7 +270,7 @@ def test_enforcement(self, mock_model): """ assert len(self.grid.empties) == 9 - a = MockAgent(100, None) + a = MockAgent(100) with self.assertRaises(Exception): self.grid.place_agent(a, (0, 1)) @@ -288,12 +288,12 @@ def test_enforcement(self, mock_model): # Place agents until the grid is full empty_cells = len(self.grid.empties) for i in range(empty_cells): - a = MockAgent(101 + i, None) + a = MockAgent(101 + i) self.grid.move_to_empty(a) self.num_agents += 1 assert len(self.grid.empties) == 0 - a = MockAgent(110, None) + a = MockAgent(110) with self.assertRaises(Exception): self.grid.move_to_empty(a) with self.assertRaises(Exception): @@ -334,7 +334,7 @@ def setUp(self): for _i in range(TEST_MULTIGRID[x][y]): counter += 1 # Create and place the mock agent - a = MockAgent(counter, None) + a = MockAgent(counter) self.agents.append(a) self.grid.place_agent(a, (x, y)) @@ -393,7 +393,7 @@ def setUp(self): continue counter += 1 # Create and place the mock agent - a = MockAgent(counter, None) + a = MockAgent(counter) self.agents.append(a) self.grid.place_agent(a, (x, y)) @@ -447,7 +447,7 @@ def setUp(self): continue counter += 1 # Create and place the mock agent - a = MockAgent(counter, None) + a = MockAgent(counter) self.agents.append(a) self.grid.place_agent(a, (x, y)) diff --git a/tests/test_space.py b/tests/test_space.py index aabdf98ba8d..23cf8c95450 100644 --- a/tests/test_space.py +++ b/tests/test_space.py @@ -42,7 +42,7 @@ def test_agents_add_many(self): """ positions = np.random.rand(TEST_AGENTS_PERF, 2) for i in range(TEST_AGENTS_PERF): - a = MockAgent(i, None) + a = MockAgent(i) pos = [positions[i, 0], positions[i, 1]] self.space.place_agent(a, pos) @@ -59,7 +59,7 @@ def setUp(self): self.space = ContinuousSpace(70, 20, True, -30, -30) self.agents = [] for i, pos in enumerate(TEST_AGENTS): - a = MockAgent(i, None) + a = MockAgent(i) self.agents.append(a) self.space.place_agent(a, pos) @@ -126,7 +126,7 @@ def test_bounds(self): """ boundary_agents = [] for i, pos in enumerate(OUTSIDE_POSITIONS): - a = MockAgent(len(self.agents) + i, None) + a = MockAgent(len(self.agents) + i) boundary_agents.append(a) self.space.place_agent(a, pos) @@ -152,7 +152,7 @@ def setUp(self): self.space = ContinuousSpace(70, 20, False, -30, -30) self.agents = [] for i, pos in enumerate(TEST_AGENTS): - a = MockAgent(i, None) + a = MockAgent(i) self.agents.append(a) self.space.place_agent(a, pos) @@ -208,7 +208,7 @@ def test_bounds(self): Test positions outside of boundary """ for i, pos in enumerate(OUTSIDE_POSITIONS): - a = MockAgent(len(self.agents) + i, None) + a = MockAgent(len(self.agents) + i) with self.assertRaises(Exception): self.space.place_agent(a, pos) @@ -231,7 +231,7 @@ def setUp(self): self.space = ContinuousSpace(70, 50, False, -30, -30) self.agents = [] for i, pos in enumerate(REMOVAL_TEST_AGENTS): - a = MockAgent(i, None) + a = MockAgent(i) self.agents.append(a) self.space.place_agent(a, pos) @@ -442,7 +442,7 @@ def setUp(self): self.space = SingleGrid(50, 50, False) self.agents = [] for i, pos in enumerate(TEST_AGENTS_GRID): - a = MockAgent(i, None) + a = MockAgent(i) self.agents.append(a) self.space.place_agent(a, pos) @@ -466,7 +466,7 @@ def test_remove_agent(self): def test_empty_cells(self): if self.space.exists_empty_cells(): for i, pos in enumerate(list(self.space.empties)): - a = MockAgent(-i, pos) + a = MockAgent(-i) self.space.place_agent(a, pos) with self.assertRaises(Exception): self.space.move_to_empty(a) @@ -551,7 +551,7 @@ def setUp(self): self.space = SingleGrid(50, 50, True) # Torus is True here self.agents = [] for i, pos in enumerate(TEST_AGENTS_GRID): - a = MockAgent(i, None) + a = MockAgent(i) self.agents.append(a) self.space.place_agent(a, pos) @@ -643,7 +643,7 @@ def test_get_empty_mask(self): self.assertTrue(np.all(empty_mask == np.ones((10, 10), dtype=bool))) def test_get_empty_mask_with_agent(self): - agent = MockAgent(0, self.grid) + agent = MockAgent(0) self.grid.place_agent(agent, (4, 6)) empty_mask = self.grid.empty_mask @@ -653,8 +653,8 @@ def test_get_empty_mask_with_agent(self): self.assertTrue(np.all(empty_mask == expected_mask)) def test_get_neighborhood_mask(self): - agent = MockAgent(0, self.grid) - agent2 = MockAgent(1, self.grid) + agent = MockAgent(0) + agent2 = MockAgent(1) self.grid.place_agent(agent, (5, 5)) self.grid.place_agent(agent2, (5, 6)) neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) @@ -680,7 +680,7 @@ def condition(x): self.assertTrue(selected_mask.all()) def test_move_agent_to_cell_by_properties(self): - agent = MockAgent(1, self.grid) + agent = MockAgent(1) self.grid.place_agent(agent, (5, 5)) conditions = {"layer1": lambda x: x == 0} target_cells = self.grid.select_cells(conditions) @@ -689,7 +689,7 @@ def test_move_agent_to_cell_by_properties(self): self.assertNotEqual(agent.pos, (5, 5)) def test_move_agent_no_eligible_cells(self): - agent = MockAgent(3, self.grid) + agent = MockAgent(3) self.grid.place_agent(agent, (5, 5)) conditions = {"layer1": lambda x: x != 0} target_cells = self.grid.select_cells(conditions) @@ -711,7 +711,7 @@ def test_select_extreme_value_cells_return_mask(self): self.assertTrue(target_mask[3, 1]) def test_move_agent_to_extreme_value_cell(self): - agent = MockAgent(2, self.grid) + agent = MockAgent(2) self.grid.place_agent(agent, (5, 5)) self.grid.properties["layer2"].set_cell((3, 1), 1.1) target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"}) @@ -721,7 +721,7 @@ def test_move_agent_to_extreme_value_cell(self): # Test using masks def test_select_cells_by_properties_with_empty_mask(self): self.grid.place_agent( - MockAgent(0, self.grid), (5, 5) + MockAgent(0), (5, 5) ) # Placing an agent to ensure some cells are not empty empty_mask = self.grid.empty_mask @@ -755,10 +755,10 @@ def condition(x): self.assertCountEqual(selected_cells, expected_selection) def test_move_agent_to_cell_by_properties_with_empty_mask(self): - agent = MockAgent(1, self.grid) + agent = MockAgent(1) self.grid.place_agent(agent, (5, 5)) self.grid.place_agent( - MockAgent(2, self.grid), (4, 5) + MockAgent(2), (4, 5) ) # Placing another agent to create a non-empty cell empty_mask = self.grid.empty_mask conditions = {"layer1": lambda x: x == 0} @@ -769,7 +769,7 @@ def test_move_agent_to_cell_by_properties_with_empty_mask(self): ) # Agent should not move to (4, 5) as it's not empty def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self): - agent = MockAgent(1, self.grid) + agent = MockAgent(1) self.grid.place_agent(agent, (5, 5)) neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1) conditions = {"layer1": lambda x: x == 0} @@ -789,7 +789,7 @@ def condition(x): # Test if coordinates means the same between the grid and the property layer def test_property_layer_coordinates(self): - agent = MockAgent(0, self.grid) + agent = MockAgent(0) correct_pos = (1, 8) incorrect_pos = (8, 1) self.grid.place_agent(agent, correct_pos) @@ -811,14 +811,14 @@ def test_property_layer_coordinates(self): # Test selecting cells with only_empty parameter def test_select_cells_only_empty(self): - self.grid.place_agent(MockAgent(0, self.grid), (5, 5)) # Occupying a cell + self.grid.place_agent(MockAgent(0), (5, 5)) # Occupying a cell selected_cells = self.grid.select_cells(only_empty=True) self.assertNotIn( (5, 5), selected_cells ) # The occupied cell should not be selected def test_select_cells_only_empty_with_conditions(self): - self.grid.place_agent(MockAgent(1, self.grid), (5, 5)) + self.grid.place_agent(MockAgent(1), (5, 5)) self.grid.properties["layer1"].set_cell((5, 5), 2) self.grid.properties["layer1"].set_cell((6, 6), 2) @@ -855,7 +855,7 @@ def setUp(self): self.space = NetworkGrid(G) self.agents = [] for i, pos in enumerate(TEST_AGENTS_NETWORK_SINGLE): - a = MockAgent(i, None) + a = MockAgent(i) self.agents.append(a) self.space.place_agent(a, pos) @@ -968,7 +968,7 @@ def setUp(self): self.space = NetworkGrid(G) self.agents = [] for i, pos in enumerate(TEST_AGENTS_NETWORK_MULTIPLE): - a = MockAgent(i, None) + a = MockAgent(i) self.agents.append(a) self.space.place_agent(a, pos) From 1e6b37c3a6f984e70d77c15cf5c16521b9c19df2 Mon Sep 17 00:00:00 2001 From: rht Date: Mon, 25 Mar 2024 15:05:34 -0400 Subject: [PATCH 09/50] Update Ruff to 0.3.4; apply ruff format . (#2088) * Update Ruff to 0.3.4; apply ruff format . * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- benchmarks/WolfSheep/wolf_sheep.py | 3 +-- mesa/__init__.py | 1 + mesa/agent.py | 1 + mesa/datacollection.py | 1 + mesa/experimental/cell_space/discrete_space.py | 3 +-- mesa/experimental/cell_space/grid.py | 6 ++---- mesa/model.py | 3 ++- mesa/space.py | 12 ++++-------- pyproject.toml | 1 + tests/test_datacollector.py | 1 + tests/test_grid.py | 1 + 12 files changed, 17 insertions(+), 18 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 32f2f7d53a9..a8467d6f4f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.2.0 + rev: v0.3.4 hooks: # Run the linter. - id: ruff diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py index 11e07a9e954..5e1877dbed6 100644 --- a/benchmarks/WolfSheep/wolf_sheep.py +++ b/benchmarks/WolfSheep/wolf_sheep.py @@ -38,8 +38,7 @@ def spawn_offspring(self): offspring.move_to(self.cell) self.model.schedule.add(offspring) - def feed(self): - ... + def feed(self): ... def die(self): self.cell.remove_agent(self) diff --git a/mesa/__init__.py b/mesa/__init__.py index e27d7de1cb0..7bc416a6d29 100644 --- a/mesa/__init__.py +++ b/mesa/__init__.py @@ -3,6 +3,7 @@ Core Objects: Model, and Agent. """ + import datetime import mesa.space as space diff --git a/mesa/agent.py b/mesa/agent.py index 7ae76871b95..ab5e1800138 100644 --- a/mesa/agent.py +++ b/mesa/agent.py @@ -3,6 +3,7 @@ Core Objects: Agent """ + # Mypy; for the `|` operator purpose # Remove this __future__ import once the oldest supported Python is 3.10 from __future__ import annotations diff --git a/mesa/datacollection.py b/mesa/datacollection.py index f5f6ead235b..59247953d82 100644 --- a/mesa/datacollection.py +++ b/mesa/datacollection.py @@ -32,6 +32,7 @@ * The model has an agent list called agents * For collecting agent-level variables, agents must have a unique_id """ + import contextlib import itertools import types diff --git a/mesa/experimental/cell_space/discrete_space.py b/mesa/experimental/cell_space/discrete_space.py index d2161c5b46a..6c92c320cb1 100644 --- a/mesa/experimental/cell_space/discrete_space.py +++ b/mesa/experimental/cell_space/discrete_space.py @@ -43,8 +43,7 @@ def __init__( def cutoff_empties(self): return 7.953 * len(self._cells) ** 0.384 - def _connect_single_cell(self, cell: T): - ... + def _connect_single_cell(self, cell: T): ... @cached_property def all_cells(self): diff --git a/mesa/experimental/cell_space/grid.py b/mesa/experimental/cell_space/grid.py index cc4b4b9e489..e73d18f46fd 100644 --- a/mesa/experimental/cell_space/grid.py +++ b/mesa/experimental/cell_space/grid.py @@ -51,11 +51,9 @@ def _connect_cells(self) -> None: else: self._connect_cells_nd() - def _connect_cells_2d(self) -> None: - ... + def _connect_cells_2d(self) -> None: ... - def _connect_cells_nd(self) -> None: - ... + def _connect_cells_nd(self) -> None: ... def _validate_parameters(self): if not all(isinstance(dim, int) and dim > 0 for dim in self.dimensions): diff --git a/mesa/model.py b/mesa/model.py index be5230dc99b..724fea2ee77 100644 --- a/mesa/model.py +++ b/mesa/model.py @@ -3,6 +3,7 @@ Core Objects: Model """ + # Mypy; for the `|` operator purpose # Remove this __future__ import once the oldest supported Python is 3.10 from __future__ import annotations @@ -55,7 +56,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Any: if obj._seed is None: # We explicitly specify the seed here so that we know its value in # advance. - obj._seed = random.random() # noqa: S311 + obj._seed = random.random() obj.random = random.Random(obj._seed) # TODO: Remove these 2 lines just before Mesa 3.0 obj._steps = 0 diff --git a/mesa/space.py b/mesa/space.py index 79c570ae89e..b9044387234 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -150,14 +150,12 @@ def build_empties(self) -> None: self._empties_built = True @overload - def __getitem__(self, index: int | Sequence[Coordinate]) -> list[GridContent]: - ... + def __getitem__(self, index: int | Sequence[Coordinate]) -> list[GridContent]: ... @overload def __getitem__( self, index: tuple[int | slice, int | slice] - ) -> GridContent | list[GridContent]: - ... + ) -> GridContent | list[GridContent]: ... def __getitem__(self, index): """Access contents from the grid.""" @@ -420,11 +418,9 @@ def get_cell_list_contents(self, cell_list: Iterable[Coordinate]) -> list[Agent] """ return list(self.iter_cell_list_contents(cell_list)) - def place_agent(self, agent: Agent, pos: Coordinate) -> None: - ... + def place_agent(self, agent: Agent, pos: Coordinate) -> None: ... - def remove_agent(self, agent: Agent) -> None: - ... + def remove_agent(self, agent: Agent) -> None: ... def move_agent(self, agent: Agent, pos: Coordinate) -> None: """Move an agent from its current position to a new position. diff --git a/pyproject.toml b/pyproject.toml index d583b5cfa58..7995526c556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,4 +133,5 @@ extend-ignore = [ "S310", # Audit URL open for permitted schemes. Allowing use of `file:` or custom schemes is often unexpected. "S603", # `subprocess` call: check for execution of untrusted input "ISC001", # ruff format asks to disable this feature + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes ] diff --git a/tests/test_datacollector.py b/tests/test_datacollector.py index a56ca47745c..bf74e3790d8 100644 --- a/tests/test_datacollector.py +++ b/tests/test_datacollector.py @@ -1,6 +1,7 @@ """ Test the DataCollector """ + import unittest from mesa import Agent, Model diff --git a/tests/test_grid.py b/tests/test_grid.py index b2500bbccb8..be68b1ffe2f 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,6 +1,7 @@ """ Test the Grid objects. """ + import random import unittest from unittest.mock import Mock, patch From c72dbff57ae327bf97fdd87b6d818a1a6c1851d0 Mon Sep 17 00:00:00 2001 From: puer-robustus <163356713+puer-robustus@users.noreply.github.com> Date: Sun, 31 Mar 2024 15:01:29 +0200 Subject: [PATCH 10/50] docs: Fixes typos and header level error in introductory tutorial (#2087) This fixes - some minor grammatical errors, - an incorrect header indentation level, --- docs/tutorials/intro_tutorial.ipynb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/intro_tutorial.ipynb b/docs/tutorials/intro_tutorial.ipynb index c3b452efa38..65f2a586561 100644 --- a/docs/tutorials/intro_tutorial.ipynb +++ b/docs/tutorials/intro_tutorial.ipynb @@ -136,7 +136,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Import Dependencies\n", + "### Import Dependencies\n", "This includes importing of dependencies needed for the tutorial." ] }, @@ -243,7 +243,7 @@ "\n", "**Model-specific information:** A new class is named `RandomActivationByAgent` is created which extends `mesa.time.RandomActivation` creating a subclass of the `RandomActivation` class from Mesa. This class activates all the agents once per step, in random order. Every agent is expected to have a ``step`` method. The step method is the action the agent takes when it is activated by the model schedule. We add an agent to the schedule using the `add` method; when we call the schedule's `step` method, the model shuffles the order of the agents, then activates and executes each agent's ```step``` method. The scheduler is then added to the model.\n", "\n", - "**Code implementation:** The technical details about the timer object can be found in the [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py). Mesa offers a few different built-in scheduler classes, with a common interface. That makes it easy to change the activation regime a given model uses, and see whether it changes the model behavior. The details pertaining to the scheduler interface can be located the same [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py).\n", + "**Code implementation:** The technical details about the timer object can be found in the [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py). Mesa offers a few different built-in scheduler classes, with a common interface. That makes it easy to change the activation regime a given model uses, and see whether it changes the model behavior. The details pertaining to the scheduler interface can be located in the same [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py).\n", "\n", "With that in mind, the `MoneyAgent` code is modified below to visually show when a new agent is created. The MoneyModel code is modified by adding the RandomActivation method to the model. with the scheduler added looks like this:" ] @@ -543,7 +543,7 @@ "\n", "Mesa has two main types of grids: `SingleGrid` and `MultiGrid`*. `SingleGrid` enforces at most one agent per cell; `MultiGrid` allows multiple agents to be in the same cell. Since we want agents to be able to share a cell, we use `MultiGrid`.\n", "\n", - "*However there are more types of space to include `HexGrid`, `NetworkGrid`, and the previously mentioned `ContinuousSpace`. Similar to `mesa.time` context is retained with `mesa.space.[enter class]`. You can see the different classes as [mesa.space](https://github.com/projectmesa/mesa/blob/main/mesa/space.py) " + "*However there are more types of space to include `HexGrid`, `NetworkGrid`, and the previously mentioned `ContinuousSpace`. Similar to `mesa.time` context is retained with `mesa.space.[enter class]`. You can inspect the different classes at [mesa.space](https://github.com/projectmesa/mesa/blob/main/mesa/space.py)." ] }, { @@ -829,9 +829,9 @@ "source": [ "At every step of the model, the datacollector will collect and store the model-level current Gini coefficient, as well as each agent's wealth, associating each with the current step.\n", "\n", - "We run the model just as we did above. Now is when an interactive session, especially via a Notebook, comes in handy: the DataCollector can export the data its collected as a pandas\\* DataFrame, for easy interactive analysis. \n", + "We run the model just as we did above. Now is when an interactive session, especially via a Notebook, comes in handy: the DataCollector can export the data its collected as a pandas* DataFrame, for easy interactive analysis. \n", "\n", - "\\*If you are new to Python, please be aware that pandas is already installed as a dependency of Mesa and that [pandas](https://pandas.pydata.org/docs/) is a \"fast, powerful, flexible and easy to use open source data analysis and manipulation tool\". pandas is great resource to help analyze the data collected in your models " + "*If you are new to Python, please be aware that pandas is already installed as a dependency of Mesa and that [pandas](https://pandas.pydata.org/docs/) is a \"fast, powerful, flexible and easy to use open source data analysis and manipulation tool\". pandas is great resource to help analyze the data collected in your models." ] }, { @@ -1288,7 +1288,7 @@ "cell_type": "markdown", "source": [ "### Analyzing model reporters: Comparing 5 scenarios\n", - "Other insight might be gathered when we compare the Gini coefficient of different scenarios. For example, we can compare the Gini coefficient of a population with 25 agents to the Gini coefficient of a population with 400 agents. While doing this, we increase the number of iterations to 25 to get a better estimate of the Gini coefficient for each population size and get usable error estimations." + "Other insights might be gathered when we compare the Gini coefficient of different scenarios. For example, we can compare the Gini coefficient of a population with 25 agents to the Gini coefficient of a population with 400 agents. While doing this, we increase the number of iterations to 25 to get a better estimate of the Gini coefficient for each population size and get usable error estimations." ], "metadata": { "collapsed": false From 36adaff9b00b7fd1723c0dde8d396ed669280862 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 22:24:15 +0000 Subject: [PATCH 11/50] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.3.4 → v0.3.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.4...v0.3.5) - [github.com/asottile/pyupgrade: v3.15.0 → v3.15.2](https://github.com/asottile/pyupgrade/compare/v3.15.0...v3.15.2) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a8467d6f4f1..d00bb6d49f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.3.4 + rev: v0.3.5 hooks: # Run the linter. - id: ruff @@ -14,7 +14,7 @@ repos: - id: ruff-format types_or: [ python, pyi, jupyter ] - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.15.2 hooks: - id: pyupgrade args: [--py38-plus] From a0de92f3bfe65d765fcb802c055bcddfe833d9a8 Mon Sep 17 00:00:00 2001 From: Jan Kwakkel Date: Wed, 10 Apr 2024 13:49:46 +0200 Subject: [PATCH 12/50] Support discrete event scheduling (#2066) # Summary This PR adds an experiment feature that puts event scheduling at the heart of how MESA works. An earlier draft of this code was shown and discussed in #2032. This code generalizes #1890 by making discrete event scheduling central to how mesa models are run. This makes it trivial to maintain discrete time progression while allowing for event scheduling, thus making it possible to build hybrid ABM-DEVS models. # Motive Agent-based models can be quite slow because, typically, they involve the activation of all agents at each tick. However, this can become very expensive if it can be known upfront that the agent is not active. Combining ABM tick-based activation with event scheduling makes it easy to avoid activating dormant agents. For example, in Epstein's civil violence model, agents who are in jail need not be activated until they are released from jail. This release from jail can be scheduled as an event, thus avoiding unnecessary agent activations. Likewise, in Wolf-Sheep with grass, the regrowth of grass patches can be scheduled instead of activating all patches for each tick only to decrement a counter. # Implementation The experimental feature adds three core new classes: Simulator, EventList, and SimulationEvent. The simulator is analogous to a numerical solver for ODEs. Theoretically, the idea of a Simulator is rooted in the work of [Zeigler](https://www.sciencedirect.com/book/9780128133705/theory-of-modeling-and-simulation), The Simulator is responsible for controlling and advancing time. The EventList is a heapq sorted list of SimulationEvents. SimulationEvents are sorted based on their time of execution, their priority, and their unique_id. A SimulationEvent is, in essence, a callable that is to be executed at a particular simulation time instant. This PR adds two specific simulators: ABMSimulator and DEVSSimulator. ABMSimulator uses integers as the base unit of time and automatically ensures that `model.step` is scheduled for each tick. DEVSSimulator uses float as the base unit of time. It allows for full discrete event scheduling. Using these new classes requires a minor modification to a Model instance. It needs a simulator attribute to be able to schedule events. # Usage The basic usage is straightforward as shown below. We instantiate an ABMSimulator, instantiate the model, and call `simulator.setup`. Next, we can run the model for, e.g., 100 time steps). ```python simulator = ABMSimulator() model = WolfSheep(simulator,25, 25, 60, 40, 0.2, 0.1, 20, seed=15,) simulator.setup(model) simulator.run(100) print(model.time) # prints 100 simulator.run(50) print(model.time) # prints 150 ``` The simulator comes with a whole range of methods for scheduling events: `schedule_event_now`, `schedule_event_relative`, `schedule_event_absolute`, and the ABMSimulator also has a `schedule_event_next_tick`. See `experimental/devs/examples/*.*` for more details on how to use these methods. --- benchmarks/Flocking/flocking.py | 28 +- benchmarks/Schelling/schelling.py | 9 +- benchmarks/WolfSheep/wolf_sheep.py | 92 ++++-- benchmarks/global_benchmark.py | 13 +- mesa/experimental/devs/__init__.py | 4 + mesa/experimental/devs/eventlist.py | 166 ++++++++++ .../devs/examples/epstein_civil_violence.py | 273 ++++++++++++++++ mesa/experimental/devs/examples/wolf_sheep.py | 250 +++++++++++++++ mesa/experimental/devs/simulator.py | 293 ++++++++++++++++++ mesa/time.py | 113 +------ tests/test_devs.py | 282 +++++++++++++++++ tests/test_examples.py | 4 +- tests/test_time.py | 115 ------- 13 files changed, 1363 insertions(+), 279 deletions(-) create mode 100644 mesa/experimental/devs/__init__.py create mode 100644 mesa/experimental/devs/eventlist.py create mode 100644 mesa/experimental/devs/examples/epstein_civil_violence.py create mode 100644 mesa/experimental/devs/examples/wolf_sheep.py create mode 100644 mesa/experimental/devs/simulator.py create mode 100644 tests/test_devs.py diff --git a/benchmarks/Flocking/flocking.py b/benchmarks/Flocking/flocking.py index 025bd71cd5f..fc19c6c3820 100644 --- a/benchmarks/Flocking/flocking.py +++ b/benchmarks/Flocking/flocking.py @@ -102,6 +102,7 @@ def __init__( cohere=0.03, separate=0.015, match=0.05, + simulator=None, ): """ Create a new Flockers model. @@ -118,18 +119,18 @@ def __init__( """ super().__init__(seed=seed) self.population = population - self.vision = vision - self.speed = speed - self.separation = separation + self.width = width + self.height = height + self.simulator = simulator + self.schedule = mesa.time.RandomActivation(self) - self.space = mesa.space.ContinuousSpace(width, height, True) - self.factors = {"cohere": cohere, "separate": separate, "match": match} - self.make_agents() + self.space = mesa.space.ContinuousSpace(self.width, self.height, True) + self.factors = { + "cohere": cohere, + "separate": separate, + "match": match, + } - def make_agents(self): - """ - Create self.population agents, with random positions and starting directions. - """ for i in range(self.population): x = self.random.random() * self.space.x_max y = self.random.random() * self.space.y_max @@ -138,10 +139,11 @@ def make_agents(self): boid = Boid( unique_id=i, model=self, - speed=self.speed, + pos=pos, + speed=speed, direction=direction, - vision=self.vision, - separation=self.separation, + vision=vision, + separation=separation, **self.factors, ) self.space.place_agent(boid, pos) diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py index c7dd3bf1deb..b7702a84bed 100644 --- a/benchmarks/Schelling/schelling.py +++ b/benchmarks/Schelling/schelling.py @@ -49,6 +49,7 @@ def __init__( density=0.8, minority_pc=0.5, seed=None, + simulator=None, ): """ Create a new Schelling model. @@ -62,10 +63,8 @@ def __init__( seed: Seed for Reproducibility """ super().__init__(seed=seed) - self.height = height - self.width = width - self.density = density self.minority_pc = minority_pc + self.simulator = simulator self.schedule = RandomActivation(self) self.grid = OrthogonalMooreGrid( @@ -75,14 +74,12 @@ def __init__( random=self.random, ) - 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 cell in self.grid: - if self.random.random() < self.density: + if self.random.random() < density: agent_type = 1 if self.random.random() < self.minority_pc else 0 agent = SchellingAgent( self.next_id(), self, agent_type, radius, homophily diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py index 5e1877dbed6..ecb78594272 100644 --- a/benchmarks/WolfSheep/wolf_sheep.py +++ b/benchmarks/WolfSheep/wolf_sheep.py @@ -13,7 +13,7 @@ from mesa import Model from mesa.experimental.cell_space import CellAgent, OrthogonalVonNeumannGrid -from mesa.time import RandomActivationByType +from mesa.experimental.devs import ABMSimulator class Animal(CellAgent): @@ -36,7 +36,6 @@ def spawn_offspring(self): self.energy_from_food, ) offspring.move_to(self.cell) - self.model.schedule.add(offspring) def feed(self): ... @@ -93,26 +92,41 @@ class GrassPatch(CellAgent): A patch of grass that grows at a fixed rate and it is eaten by sheep """ - def __init__(self, unique_id, model, fully_grown, countdown): + @property + def fully_grown(self): + return self._fully_grown + + @fully_grown.setter + def fully_grown(self, value: bool) -> None: + self._fully_grown = value + + if not value: + self.model.simulator.schedule_event_relative( + setattr, + self.grass_regrowth_time, + function_args=[self, "fully_grown", True], + ) + + def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time): """ + TODO:: fully grown can just be an int --> so one less param (i.e. countdown) + Creates a new patch of grass Args: fully_grown: (boolean) Whether the patch of grass is fully grown or not countdown: Time for the patch of grass to be fully grown again + grass_regrowth_time : time to fully regrow grass + countdown : Time for the patch of grass to be fully regrown if fully grown is False """ super().__init__(unique_id, model) - self.fully_grown = fully_grown - self.countdown = countdown + self._fully_grown = fully_grown + self.grass_regrowth_time = grass_regrowth_time - 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 + self.model.simulator.schedule_event_relative( + setattr, countdown, function_args=[self, "fully_grown", True] + ) class WolfSheep(Model): @@ -124,6 +138,7 @@ class WolfSheep(Model): def __init__( self, + simulator, height, width, initial_sheep, @@ -139,27 +154,26 @@ def __init__( Create a new Wolf-Sheep model with the given parameters. Args: + simulator: ABMSimulator instance 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. - moore: - seed + seed : the random seed """ super().__init__(seed=seed) # Set parameters self.height = height self.width = width + self.simulator = simulator + self.initial_sheep = initial_sheep self.initial_wolves = initial_wolves - self.grass_regrowth_time = grass_regrowth_time - self.schedule = RandomActivationByType(self) self.grid = OrthogonalVonNeumannGrid( [self.height, self.width], torus=False, @@ -175,10 +189,13 @@ def __init__( ) energy = self.random.randrange(2 * sheep_gain_from_food) sheep = Sheep( - self.next_id(), self, energy, sheep_reproduce, sheep_gain_from_food + self.next_id(), + self, + energy, + sheep_reproduce, + sheep_gain_from_food, ) sheep.move_to(self.grid[pos]) - self.schedule.add(sheep) # Create wolves for _ in range(self.initial_wolves): @@ -188,33 +205,50 @@ def __init__( ) energy = self.random.randrange(2 * wolf_gain_from_food) wolf = Wolf( - self.next_id(), self, energy, wolf_reproduce, wolf_gain_from_food + self.next_id(), + self, + energy, + wolf_reproduce, + wolf_gain_from_food, ) wolf.move_to(self.grid[pos]) - self.schedule.add(wolf) # Create grass patches possibly_fully_grown = [True, False] for cell in self.grid: fully_grown = self.random.choice(possibly_fully_grown) if fully_grown: - countdown = self.grass_regrowth_time + countdown = grass_regrowth_time else: - countdown = self.random.randrange(self.grass_regrowth_time) - patch = GrassPatch(self.next_id(), self, fully_grown, countdown) + countdown = self.random.randrange(grass_regrowth_time) + patch = GrassPatch( + self.next_id(), self, fully_grown, countdown, grass_regrowth_time + ) patch.move_to(cell) - self.schedule.add(patch) def step(self): - self.schedule.step() + self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step") + self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step") if __name__ == "__main__": import time - model = WolfSheep(25, 25, 60, 40, 0.2, 0.1, 20, seed=15) + simulator = ABMSimulator() + model = WolfSheep( + simulator, + 25, + 25, + 60, + 40, + 0.2, + 0.1, + 20, + seed=15, + ) + + simulator.setup(model) start_time = time.perf_counter() - for _ in range(100): - model.step() + simulator.run(100) print("Time:", time.perf_counter() - start_time) diff --git a/benchmarks/global_benchmark.py b/benchmarks/global_benchmark.py index 677d352b5c7..539e1ca4bbe 100644 --- a/benchmarks/global_benchmark.py +++ b/benchmarks/global_benchmark.py @@ -7,6 +7,8 @@ from configurations import configurations +from mesa.experimental.devs.simulator import ABMSimulator + # 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("..")) @@ -15,13 +17,16 @@ # 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) + simulator = ABMSimulator() + model = model_class(simulator=simulator, seed=seed, **parameters) + simulator.setup(model) end_init_start_run = timeit.default_timer() - for _ in range(config["steps"]): - model.step() + simulator.run_for(config["steps"]) + + # for _ in range(config["steps"]): + # model.step() # time.sleep(0.0001) end_run = timeit.default_timer() diff --git a/mesa/experimental/devs/__init__.py b/mesa/experimental/devs/__init__.py new file mode 100644 index 00000000000..b6dca39e29c --- /dev/null +++ b/mesa/experimental/devs/__init__.py @@ -0,0 +1,4 @@ +from .eventlist import Priority, SimulationEvent +from .simulator import ABMSimulator, DEVSimulator + +__all__ = ["ABMSimulator", "DEVSimulator", "SimulationEvent", "Priority"] diff --git a/mesa/experimental/devs/eventlist.py b/mesa/experimental/devs/eventlist.py new file mode 100644 index 00000000000..48af72a4315 --- /dev/null +++ b/mesa/experimental/devs/eventlist.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import itertools +from enum import IntEnum +from heapq import heapify, heappop, heappush +from types import MethodType +from typing import Any, Callable +from weakref import WeakMethod, ref + + +class Priority(IntEnum): + LOW = 10 + DEFAULT = 5 + HIGH = 1 + + +class SimulationEvent: + """A simulation event + + the callable is wrapped using weakref, so there is no need to explicitly cancel event if e.g., an agent + is removed from the simulation. + + Attributes: + time (float): The simulation time of the event + fn (Callable): The function to execute for this event + priority (Priority): The priority of the event + unique_id (int) the unique identifier of the event + function_args (list[Any]): Argument for the function + function_kwargs (Dict[str, Any]): Keyword arguments for the function + + """ + + _ids = itertools.count() + + @property + def CANCELED(self) -> bool: + return self._canceled + + def __init__( + self, + time: int | float, + function: Callable, + priority: Priority = Priority.DEFAULT, + function_args: list[Any] | None = None, + function_kwargs: dict[str, Any] | None = None, + ) -> None: + super().__init__() + if not callable(function): + raise Exception() + + self.time = time + self.priority = priority.value + self._canceled = False + + if isinstance(function, MethodType): + function = WeakMethod(function) + else: + function = ref(function) + + self.fn = function + self.unique_id = next(self._ids) + self.function_args = function_args if function_args else [] + self.function_kwargs = function_kwargs if function_kwargs else {} + + def execute(self): + """execute this event""" + if not self._canceled: + fn = self.fn() + if fn is not None: + fn(*self.function_args, **self.function_kwargs) + + def cancel(self) -> None: + """cancel this event""" + self._canceled = True + self.fn = None + self.function_args = [] + self.function_kwargs = {} + + def __lt__(self, other): + # Define a total ordering for events to be used by the heapq + return (self.time, self.priority, self.unique_id) < ( + other.time, + other.priority, + other.unique_id, + ) + + +class EventList: + """An event list + + This is a heap queue sorted list of events. Events are always removed from the left, so heapq is a performant and + appropriate data structure. Events are sorted based on their time stamp, their priority, and their unique_id + as a tie-breaker, guaranteeing a complete ordering. + + """ + + def __init__(self): + self._events: list[SimulationEvent] = [] + heapify(self._events) + + def add_event(self, event: SimulationEvent): + """Add the event to the event list + + Args: + event (SimulationEvent): The event to be added + + """ + + heappush(self._events, event) + + def peak_ahead(self, n: int = 1) -> list[SimulationEvent]: + """Look at the first n non-canceled event in the event list + + Args: + n (int): The number of events to look ahead + + Returns: + list[SimulationEvent] + + Raises: + IndexError: If the eventlist is empty + + Notes: + this method can return a list shorted then n if the number of non-canceled events on the event list + is less than n. + + """ + # look n events ahead + if self.is_empty(): + raise IndexError("event list is empty") + + peek: list[SimulationEvent] = [] + for event in self._events: + if not event.CANCELED: + peek.append(event) + if len(peek) >= n: + return peek + return peek + + def pop_event(self) -> SimulationEvent: + """pop the first element from the event list""" + while self._events: + event = heappop(self._events) + if not event.CANCELED: + return event + raise IndexError("Event list is empty") + + def is_empty(self) -> bool: + return len(self) == 0 + + def __contains__(self, event: SimulationEvent) -> bool: + return event in self._events + + def __len__(self) -> int: + return len(self._events) + + def remove(self, event: SimulationEvent) -> None: + """remove an event from the event list""" + # we cannot simply remove items from _eventlist because this breaks + # heap structure invariant. So, we use a form of lazy deletion. + # SimEvents have a CANCELED flag that we set to True, while popping and peak_ahead + # silently ignore canceled events + event.cancel() + + def clear(self): + self._events.clear() diff --git a/mesa/experimental/devs/examples/epstein_civil_violence.py b/mesa/experimental/devs/examples/epstein_civil_violence.py new file mode 100644 index 00000000000..9a7314e0ff3 --- /dev/null +++ b/mesa/experimental/devs/examples/epstein_civil_violence.py @@ -0,0 +1,273 @@ +import enum +import math + +from mesa import Agent, Model +from mesa.experimental.devs.simulator import ABMSimulator +from mesa.space import SingleGrid + + +class EpsteinAgent(Agent): + def __init__(self, unique_id, model, vision, movement): + super().__init__(unique_id, model) + self.vision = vision + self.movement = movement + + +class AgentState(enum.IntEnum): + QUIESCENT = enum.auto() + ARRESTED = enum.auto() + ACTIVE = enum.auto() + + +class Citizen(EpsteinAgent): + """ + A member of the general population, may or may not be in active rebellion. + Summary of rule: If grievance - risk > threshold, rebel. + + Attributes: + unique_id: unique int + model : + hardship: Agent's 'perceived hardship (i.e., physical or economic + privation).' Exogenous, drawn from U(0,1). + regime_legitimacy: Agent's perception of regime legitimacy, equal + across agents. Exogenous. + risk_aversion: Exogenous, drawn from U(0,1). + threshold: if (grievance - (risk_aversion * arrest_probability)) > + threshold, go/remain Active + vision: number of cells in each direction (N, S, E and W) that agent + can inspect + condition: Can be "Quiescent" or "Active;" deterministic function of + greivance, perceived risk, and + grievance: deterministic function of hardship and regime_legitimacy; + how aggrieved is agent at the regime? + arrest_probability: agent's assessment of arrest probability, given + rebellion + """ + + def __init__( + self, + unique_id, + model, + vision, + movement, + hardship, + regime_legitimacy, + risk_aversion, + threshold, + arrest_prob_constant, + ): + """ + Create a new Citizen. + Args: + unique_id: unique int + model : model instance + hardship: Agent's 'perceived hardship (i.e., physical or economic + privation).' Exogenous, drawn from U(0,1). + regime_legitimacy: Agent's perception of regime legitimacy, equal + across agents. Exogenous. + risk_aversion: Exogenous, drawn from U(0,1). + threshold: if (grievance - (risk_aversion * arrest_probability)) > + threshold, go/remain Active + vision: number of cells in each direction (N, S, E and W) that + agent can inspect. Exogenous. + """ + super().__init__(unique_id, model, vision, movement) + self.hardship = hardship + self.regime_legitimacy = regime_legitimacy + self.risk_aversion = risk_aversion + self.threshold = threshold + self.condition = AgentState.QUIESCENT + self.grievance = self.hardship * (1 - self.regime_legitimacy) + self.arrest_probability = None + self.arrest_prob_constant = arrest_prob_constant + + def step(self): + """ + Decide whether to activate, then move if applicable. + """ + self.update_neighbors() + self.update_estimated_arrest_probability() + net_risk = self.risk_aversion * self.arrest_probability + if self.grievance - net_risk > self.threshold: + self.condition = AgentState.ACTIVE + else: + self.condition = AgentState.QUIESCENT + if self.movement and self.empty_neighbors: + new_pos = self.random.choice(self.empty_neighbors) + self.model.grid.move_agent(self, new_pos) + + def update_neighbors(self): + """ + Look around and see who my neighbors are + """ + self.neighborhood = self.model.grid.get_neighborhood( + self.pos, moore=True, radius=self.vision + ) + self.neighbors = self.model.grid.get_cell_list_contents(self.neighborhood) + self.empty_neighbors = [ + c for c in self.neighborhood if self.model.grid.is_cell_empty(c) + ] + + def update_estimated_arrest_probability(self): + """ + Based on the ratio of cops to actives in my neighborhood, estimate the + p(Arrest | I go active). + """ + cops_in_vision = len([c for c in self.neighbors if isinstance(c, Cop)]) + actives_in_vision = 1.0 # citizen counts herself + for c in self.neighbors: + if isinstance(c, Citizen) and c.condition == AgentState.ACTIVE: + actives_in_vision += 1 + self.arrest_probability = 1 - math.exp( + -1 * self.arrest_prob_constant * (cops_in_vision / actives_in_vision) + ) + + def sent_to_jail(self, value): + self.model.active_agents.remove(self) + self.condition = AgentState.ARRESTED + self.model.simulator.schedule_event_relative(self.release_from_jail, value) + + def release_from_jail(self): + self.model.active_agents.add(self) + self.condition = AgentState.QUIESCENT + + +class Cop(EpsteinAgent): + """ + A cop for life. No defection. + Summary of rule: Inspect local vision and arrest a random active agent. + + Attributes: + unique_id: unique int + x, y: Grid coordinates + vision: number of cells in each direction (N, S, E and W) that cop is + able to inspect + """ + + def __init__(self, unique_id, model, vision, movement, max_jail_term): + super().__init__(unique_id, model, vision, movement) + self.max_jail_term = max_jail_term + + def step(self): + """ + Inspect local vision and arrest a random active agent. Move if + applicable. + """ + self.update_neighbors() + active_neighbors = [] + for agent in self.neighbors: + if isinstance(agent, Citizen) and agent.condition == "Active": + active_neighbors.append(agent) + if active_neighbors: + arrestee = self.random.choice(active_neighbors) + arrestee.sent_to_jail(self.random.randint(0, self.max_jail_term)) + if self.movement and self.empty_neighbors: + new_pos = self.random.choice(self.empty_neighbors) + self.model.grid.move_agent(self, new_pos) + + def update_neighbors(self): + """ + Look around and see who my neighbors are. + """ + self.neighborhood = self.model.grid.get_neighborhood( + self.pos, moore=True, radius=self.vision + ) + self.neighbors = self.model.grid.get_cell_list_contents(self.neighborhood) + self.empty_neighbors = [ + c for c in self.neighborhood if self.model.grid.is_cell_empty(c) + ] + + +class EpsteinCivilViolence(Model): + """ + Model 1 from "Modeling civil violence: An agent-based computational + approach," by Joshua Epstein. + http://www.pnas.org/content/99/suppl_3/7243.full + Attributes: + height: grid height + width: grid width + citizen_density: approximate % of cells occupied by citizens. + cop_density: approximate % of cells occupied by cops. + citizen_vision: number of cells in each direction (N, S, E and W) that + citizen can inspect + cop_vision: number of cells in each direction (N, S, E and W) that cop + can inspect + legitimacy: (L) citizens' perception of regime legitimacy, equal + across all citizens + max_jail_term: (J_max) + active_threshold: if (grievance - (risk_aversion * arrest_probability)) + > threshold, citizen rebels + arrest_prob_constant: set to ensure agents make plausible arrest + probability estimates + movement: binary, whether agents try to move at step end + max_iters: model may not have a natural stopping point, so we set a + max. + """ + + def __init__( + self, + width=40, + height=40, + citizen_density=0.7, + cop_density=0.074, + citizen_vision=7, + cop_vision=7, + legitimacy=0.8, + max_jail_term=1000, + active_threshold=0.1, + arrest_prob_constant=2.3, + movement=True, + max_iters=1000, + seed=None, + ): + super().__init__(seed) + if cop_density + citizen_density > 1: + raise ValueError("Cop density + citizen density must be less than 1") + + self.width = width + self.height = height + self.citizen_density = citizen_density + self.cop_density = cop_density + + self.max_iters = max_iters + + self.grid = SingleGrid(self.width, self.height, torus=True) + + for _, pos in self.grid.coord_iter(): + if self.random.random() < self.cop_density: + agent = Cop( + self.next_id(), + self, + cop_vision, + movement, + max_jail_term, + ) + elif self.random.random() < (self.cop_density + self.citizen_density): + agent = Citizen( + self.next_id(), + self, + citizen_vision, + movement, + hardship=self.random.random(), + regime_legitimacy=legitimacy, + risk_aversion=self.random.random(), + threshold=active_threshold, + arrest_prob_constant=arrest_prob_constant, + ) + else: + continue + self.grid.place_agent(agent, pos) + + self.active_agents = self.agents + + def step(self): + self.active_agents.shuffle(inplace=True).do("step") + + +if __name__ == "__main__": + model = EpsteinCivilViolence(seed=15) + simulator = ABMSimulator() + + simulator.setup(model) + + simulator.run(time_delta=100) diff --git a/mesa/experimental/devs/examples/wolf_sheep.py b/mesa/experimental/devs/examples/wolf_sheep.py new file mode 100644 index 00000000000..9fa6b3d96c7 --- /dev/null +++ b/mesa/experimental/devs/examples/wolf_sheep.py @@ -0,0 +1,250 @@ +""" +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.experimental.devs.simulator import ABMSimulator + + +class Animal(mesa.Agent): + def __init__(self, unique_id, model, moore, energy, p_reproduce, energy_from_food): + super().__init__(unique_id, model) + self.energy = energy + self.p_reproduce = p_reproduce + self.energy_from_food = energy_from_food + self.moore = moore + + def random_move(self): + 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) + + def spawn_offspring(self): + self.energy /= 2 + offspring = self.__class__( + self.model.next_id(), + self.model, + self.moore, + self.energy, + self.p_reproduce, + self.energy_from_food, + ) + self.model.grid.place_agent(offspring, self.pos) + + def feed(self): ... + + def die(self): + self.model.grid.remove_agent(self) + self.remove() + + def step(self): + self.random_move() + self.energy -= 1 + + self.feed() + + if self.energy < 0: + self.die() + elif self.random.random() < self.p_reproduce: + self.spawn_offspring() + + +class Sheep(Animal): + """ + A sheep that walks around, reproduces (asexually) and gets eaten. + + The init is the same as the RandomWalker. + """ + + def feed(self): + # If there is grass available, eat it + agents = self.model.grid.get_cell_list_contents(self.pos) + grass_patch = next(obj for obj in agents if isinstance(obj, GrassPatch)) + if grass_patch.fully_grown: + self.energy += self.energy_from_food + grass_patch.fully_grown = False + + +class Wolf(Animal): + """ + A wolf that walks around, reproduces (asexually) and eats sheep. + """ + + def feed(self): + agents = self.model.grid.get_cell_list_contents(self.pos) + sheep = [obj for obj in agents if isinstance(obj, Sheep)] + if len(sheep) > 0: + sheep_to_eat = self.random.choice(sheep) + self.energy += self.energy + + # Kill the sheep + sheep_to_eat.die() + + +class GrassPatch(mesa.Agent): + """ + A patch of grass that grows at a fixed rate and it is eaten by sheep + """ + + @property + def fully_grown(self) -> bool: + return self._fully_grown + + @fully_grown.setter + def fully_grown(self, value: bool): + self._fully_grown = value + + if not value: + self.model.simulator.schedule_event_relative( + setattr, + self.grass_regrowth_time, + function_args=[self, "fully_grown", True], + ) + + def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time): + """ + 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.grass_regrowth_time = grass_regrowth_time + + if not self.fully_grown: + self.model.simulator.schedule_event_relative( + setattr, countdown, function_args=[self, "fully_grown", True] + ) + + def set_fully_grown(self): + self.fully_grown = True + + +class WolfSheep(mesa.Model): + """ + Wolf-Sheep Predation Model + + A model for simulating wolf and sheep (predator-prey) ecosystem modelling. + """ + + def __init__( + self, + height, + width, + initial_sheep, + initial_wolves, + sheep_reproduce, + wolf_reproduce, + grass_regrowth_time, + wolf_gain_from_food=13, + sheep_gain_from_food=5, + moore=False, + simulator=None, + seed=None, + ): + """ + 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. + moore: + """ + super().__init__(seed=seed) + # Set parameters + self.height = height + self.width = width + self.initial_sheep = initial_sheep + self.initial_wolves = initial_wolves + self.simulator = simulator + + # self.sheep_reproduce = sheep_reproduce + # self.wolf_reproduce = wolf_reproduce + # self.grass_regrowth_time = grass_regrowth_time + # self.wolf_gain_from_food = wolf_gain_from_food + # self.sheep_gain_from_food = sheep_gain_from_food + # self.moore = moore + + self.grid = mesa.space.MultiGrid(self.height, self.width, torus=False) + + for _ in range(self.initial_sheep): + pos = ( + self.random.randrange(self.width), + self.random.randrange(self.height), + ) + energy = self.random.randrange(2 * sheep_gain_from_food) + sheep = Sheep( + self.next_id(), + self, + moore, + energy, + sheep_reproduce, + sheep_gain_from_food, + ) + self.grid.place_agent(sheep, pos) + + # Create wolves + for _ in range(self.initial_wolves): + pos = ( + self.random.randrange(self.width), + self.random.randrange(self.height), + ) + energy = self.random.randrange(2 * wolf_gain_from_food) + wolf = Wolf( + self.next_id(), + self, + moore, + energy, + wolf_reproduce, + wolf_gain_from_food, + ) + self.grid.place_agent(wolf, pos) + + # 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 = grass_regrowth_time + else: + countdown = self.random.randrange(grass_regrowth_time) + patch = GrassPatch( + self.next_id(), self, fully_grown, countdown, grass_regrowth_time + ) + self.grid.place_agent(patch, pos) + + def step(self): + self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step") + self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step") + + +if __name__ == "__main__": + import time + + simulator = ABMSimulator() + + model = WolfSheep(25, 25, 60, 40, 0.2, 0.1, 20, simulator=simulator, seed=15) + + simulator.setup(model) + + start_time = time.perf_counter() + simulator.run(100) + print(simulator.time) + print("Time:", time.perf_counter() - start_time) diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py new file mode 100644 index 00000000000..11c33f4e074 --- /dev/null +++ b/mesa/experimental/devs/simulator.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import numbers +from typing import Any, Callable + +from mesa import Model + +from .eventlist import EventList, Priority, SimulationEvent + + +class Simulator: + """The Simulator controls the time advancement of the model. + + The simulator uses next event time progression to advance the simulation time, and execute the next event + + Attributes: + event_list (EventList): The list of events to execute + time (float | int): The current simulation time + time_unit (type) : The unit of the simulation time + model (Model): The model to simulate + + + """ + + # TODO: add replication support + # TODO: add experimentation support + + def __init__(self, time_unit: type, start_time: int | float): + # should model run in a separate thread, + # and we can then interact with start, stop, run_until, and step? + self.event_list = EventList() + self.start_time = start_time + self.time_unit = time_unit + + self.time = self.start_time + self.model = None + + def check_time_unit(self, time: int | float) -> bool: ... + + def setup(self, model: Model) -> None: + """Set up the simulator with the model to simulate + + Args: + model (Model): The model to simulate + + """ + self.event_list.clear() + self.model = model + + def reset(self): + """Reset the simulator by clearing the event list and removing the model to simulate""" + self.event_list.clear() + self.model = None + self.time = self.start_time + + def run_until(self, end_time: int | float) -> None: + while True: + try: + event = self.event_list.pop_event() + except IndexError: # event list is empty + self.time = end_time + break + + if event.time <= end_time: + self.time = event.time + event.execute() + else: + self.time = end_time + self._schedule_event(event) # reschedule event + break + + def run_for(self, time_delta: int | float): + """run the simulator for the specified time delta + + Args: + time_delta (float| int): The time delta. The simulator is run from the current time to the current time + plus the time delta + + """ + end_time = self.time + time_delta + self.run_until(end_time) + + def schedule_event_now( + self, + function: Callable, + priority: Priority = Priority.DEFAULT, + function_args: list[Any] | None = None, + function_kwargs: dict[str, Any] | None = None, + ) -> SimulationEvent: + """Schedule event for the current time instant + + Args: + function (Callable): The callable to execute for this event + priority (Priority): the priority of the event, optional + function_args (List[Any]): list of arguments for function + function_kwargs (Dict[str, Any]): dict of keyword arguments for function + + Returns: + SimulationEvent: the simulation event that is scheduled + + """ + return self.schedule_event_relative( + function, + 0.0, + priority=priority, + function_args=function_args, + function_kwargs=function_kwargs, + ) + + def schedule_event_absolute( + self, + function: Callable, + time: int | float, + priority: Priority = Priority.DEFAULT, + function_args: list[Any] | None = None, + function_kwargs: dict[str, Any] | None = None, + ) -> SimulationEvent: + """Schedule event for the specified time instant + + Args: + function (Callable): The callable to execute for this event + time (int | float): the time for which to schedule the event + priority (Priority): the priority of the event, optional + function_args (List[Any]): list of arguments for function + function_kwargs (Dict[str, Any]): dict of keyword arguments for function + + Returns: + SimulationEvent: the simulation event that is scheduled + + """ + if self.time > time: + raise ValueError("trying to schedule an event in the past") + + event = SimulationEvent( + time, + function, + priority=priority, + function_args=function_args, + function_kwargs=function_kwargs, + ) + self._schedule_event(event) + return event + + def schedule_event_relative( + self, + function: Callable, + time_delta: int | float, + priority: Priority = Priority.DEFAULT, + function_args: list[Any] | None = None, + function_kwargs: dict[str, Any] | None = None, + ) -> SimulationEvent: + """Schedule event for the current time plus the time delta + + Args: + function (Callable): The callable to execute for this event + time_delta (int | float): the time delta + priority (Priority): the priority of the event, optional + function_args (List[Any]): list of arguments for function + function_kwargs (Dict[str, Any]): dict of keyword arguments for function + + Returns: + SimulationEvent: the simulation event that is scheduled + + """ + event = SimulationEvent( + self.time + time_delta, + function, + priority=priority, + function_args=function_args, + function_kwargs=function_kwargs, + ) + self._schedule_event(event) + return event + + def cancel_event(self, event: SimulationEvent) -> None: + """remove the event from the event list + + Args: + event (SimulationEvent): The simulation event to remove + + """ + + self.event_list.remove(event) + + def _schedule_event(self, event: SimulationEvent): + if not self.check_time_unit(event.time): + raise ValueError( + f"time unit mismatch {event.time} is not of time unit {self.time_unit}" + ) + + # check timeunit of events + self.event_list.add_event(event) + + +class ABMSimulator(Simulator): + """This simulator uses incremental time progression, while allowing for additional event scheduling. + + The basic time unit of this simulator is an integer. It schedules `model.step` for each tick with the + highest priority. This implies that by default, `model.step` is the first event executed at a specific tick. + In addition, discrete event scheduling, using integer as the time unit is fully supported, paving the way + for hybrid ABM-DEVS simulations. + + """ + + def __init__(self): + super().__init__(int, 0) + + def setup(self, model): + super().setup(model) + self.schedule_event_now(self.model.step, priority=Priority.HIGH) + + def check_time_unit(self, time) -> bool: + if isinstance(time, int): + return True + if isinstance(time, float): + return time.is_integer() + else: + return False + + def schedule_event_next_tick( + self, + function: Callable, + priority: Priority = Priority.DEFAULT, + function_args: list[Any] | None = None, + function_kwargs: dict[str, Any] | None = None, + ) -> SimulationEvent: + """Schedule a SimulationEvent for the next tick + + Args + function (Callable): the callable to execute + priority (Priority): the priority of the event + function_args (List[Any]): List of arguments to pass to the callable + function_kwargs (Dict[str, Any]): List of keyword arguments to pass to the callable + + """ + return self.schedule_event_relative( + function, + 1, + priority=priority, + function_args=function_args, + function_kwargs=function_kwargs, + ) + + def run_until(self, end_time: int) -> None: + """run the simulator up to and included the specified end time + + Args: + end_time (float| int): The end_time delta. The simulator is until the specified end time + + """ + while True: + try: + event = self.event_list.pop_event() + except IndexError: + self.time = end_time + break + + if event.time <= end_time: + self.time = event.time + if event.fn() == self.model.step: + self.schedule_event_next_tick( + self.model.step, priority=Priority.HIGH + ) + + event.execute() + else: + self.time = end_time + self._schedule_event(event) + break + + def run_for(self, time_delta: int): + """run the simulator for the specified time delta + + Args: + time_delta (float| int): The time delta. The simulator is run from the current time to the current time + plus the time delta + + """ + end_time = self.time + time_delta - 1 + self.run_until(end_time) + + +class DEVSimulator(Simulator): + """A simulator where the unit of time is a float. Can be used for full-blown discrete event simulating using + event scheduling. + + """ + + def __init__(self): + super().__init__(float, 0.0) + + def check_time_unit(self, time) -> bool: + return isinstance(time, numbers.Number) diff --git a/mesa/time.py b/mesa/time.py index 50d564be551..10fa4005ac2 100644 --- a/mesa/time.py +++ b/mesa/time.py @@ -25,9 +25,7 @@ # Remove this __future__ import once the oldest supported Python is 3.10 from __future__ import annotations -import heapq import warnings -import weakref from collections import defaultdict from collections.abc import Iterable @@ -393,46 +391,7 @@ def get_type_count(self, agenttype: type[Agent]) -> int: class DiscreteEventScheduler(BaseScheduler): """ - A scheduler for discrete event simulation in Mesa. - - This scheduler manages events where each event is associated with a - specific time and agent. The scheduler advances time not in fixed - increments, but to the moment the next event is scheduled to occur. - - This implementation uses a priority queue (heapq) to manage events. Each - event is a tuple of the form (time, random_value, agent), where: - - time (float): The scheduled time for the event. - - random_value (float): A secondary sorting criterion to randomize - the order of events that are scheduled for the same time. - - agent (Agent): The agent associated with the event. - - The random value for secondary sorting ensures that when two events are - scheduled for the same time, their execution order is randomized, thus - preventing direct comparison issues between different types of agents and - maintaining the integrity of the simulation's randomness. - - Attributes: - model (Model): The model instance associated with the scheduler. - event_queue (list): A priority queue of scheduled events. - time_step (int or float): The fixed time period by which the model advances - on each step. Defaults to 1. - - Methods: - schedule_event(time, agent): Schedule an event for a specific time. - schedule_in(delay, agent): Schedule an event after a specified delay. - step(): Execute all events within the next time_step period. - get_next_event_time(): Returns the time of the next scheduled event. - - Usage: - 1. Instantiate the DiscreteEventScheduler with a model instance and a time_step period. - 2. Add agents to the scheduler using schedule.add(). With schedule_now=True (default), - the first event for the agent will be scheduled immediately. - 3. In the Agent step() method, schedule the next event for the agent - (using schedule_in or schedule_event). - 3. Add self.schedule.step() to the model's step() method, as usual. - - Now, with each model step, the scheduler will execute all events within the - next time_step period, and advance time one time_step forward. + This class has been deprecated and replaced by the functionality provided by experimental.devs """ def __init__(self, model: Model, time_step: TimeT = 1) -> None: @@ -444,72 +403,6 @@ def __init__(self, model: Model, time_step: TimeT = 1) -> None: """ super().__init__(model) - self.event_queue: list[tuple[TimeT, float, weakref.ref]] = [] - self.time_step: TimeT = time_step # Fixed time period for each step - - warnings.warn( - "The DiscreteEventScheduler is experimental. It may be changed or removed in any and all future releases, including patch releases.\n" - "We would love to hear what you think about this new feature. If you have any thoughts, share them with us here: https://github.com/projectmesa/mesa/discussions/1923", - FutureWarning, - stacklevel=2, + raise Exception( + "DiscreteEventScheduler is deprecated in favor of the functionality provided by experimental.devs" ) - - def schedule_event(self, time: TimeT, agent: Agent) -> None: - """Schedule an event for an agent at a specific time.""" - if time < self.time: - raise ValueError( - f"Scheduled time ({time}) must be >= the current time ({self.time})" - ) - if agent not in self._agents: - raise ValueError( - "trying to schedule an event for agent which is not known to the scheduler" - ) - - # Create an event, sorted first on time, secondary on a random value - event = (time, self.model.random.random(), weakref.ref(agent)) - heapq.heappush(self.event_queue, event) - - def schedule_in(self, delay: TimeT, agent: Agent) -> None: - """Schedule an event for an agent after a specified delay.""" - if delay < 0: - raise ValueError("Delay must be non-negative") - event_time = self.time + delay - self.schedule_event(event_time, agent) - - def step(self) -> None: - """Execute the next event and advance the time.""" - end_time = self.time + self.time_step - - while self.event_queue and self.event_queue[0][0] <= end_time: - # Get the next event (ignore the random value during unpacking) - time, _, agent = heapq.heappop(self.event_queue) - agent = agent() # unpack weakref - - if agent: - # Advance time to the event's time - self.time = time - # Execute the event - agent.step() - - # After processing events, advance time by the time_step - self.time = end_time - self.steps += 1 - - def get_next_event_time(self) -> TimeT | None: - """Returns the time of the next scheduled event.""" - if not self.event_queue: - return None - return self.event_queue[0][0] - - def add(self, agent: Agent, schedule_now: bool = True) -> None: - """Add an Agent object to the schedule and optionally schedule its first event. - - Args: - agent: An Agent to be added to the schedule. Must have a step() method. - schedule_now: If True, schedules the first event for the agent immediately. - """ - super().add(agent) # Call the add method from BaseScheduler - - if schedule_now: - # Schedule the first event immediately - self.schedule_event(self.time, agent) diff --git a/tests/test_devs.py b/tests/test_devs.py new file mode 100644 index 00000000000..06d3629ed16 --- /dev/null +++ b/tests/test_devs.py @@ -0,0 +1,282 @@ +from unittest.mock import MagicMock + +import pytest + +from mesa import Model +from mesa.experimental.devs.eventlist import EventList, Priority, SimulationEvent +from mesa.experimental.devs.simulator import ABMSimulator, DEVSimulator + + +def test_devs_simulator(): + simulator = DEVSimulator() + + # setup + model = MagicMock(spec=Model) + simulator.setup(model) + + assert len(simulator.event_list) == 0 + assert simulator.model == model + assert simulator.time == 0 + + # schedule_event_now + fn1 = MagicMock() + event1 = simulator.schedule_event_now(fn1) + assert event1 in simulator.event_list + assert len(simulator.event_list) == 1 + + # schedule_event_absolute + fn2 = MagicMock() + event2 = simulator.schedule_event_absolute(fn2, 1.0) + assert event2 in simulator.event_list + assert len(simulator.event_list) == 2 + + # schedule_event_relative + fn3 = MagicMock() + event3 = simulator.schedule_event_relative(fn3, 0.5) + assert event3 in simulator.event_list + assert len(simulator.event_list) == 3 + + # run_for + simulator.run_for(0.8) + fn1.assert_called_once() + fn3.assert_called_once() + assert simulator.time == 0.8 + + simulator.run_for(0.2) + fn2.assert_called_once() + assert simulator.time == 1.0 + + simulator.run_for(0.2) + assert simulator.time == 1.2 + + with pytest.raises(ValueError): + simulator.schedule_event_absolute(fn2, 0.5) + + # cancel_event + simulator = DEVSimulator() + model = MagicMock(spec=Model) + simulator.setup(model) + fn = MagicMock() + event = simulator.schedule_event_relative(fn, 0.5) + simulator.cancel_event(event) + assert event.CANCELED + + # simulator reset + simulator.reset() + assert len(simulator.event_list) == 0 + assert simulator.model is None + assert simulator.time == 0.0 + + +def test_abm_simulator(): + simulator = ABMSimulator() + + # setup + model = MagicMock(spec=Model) + simulator.setup(model) + + # schedule_event_next_tick + fn = MagicMock() + simulator.schedule_event_next_tick(fn) + assert len(simulator.event_list) == 2 + + simulator.run_for(3) + assert model.step.call_count == 3 + assert simulator.time == 2 + + +def test_simulation_event(): + some_test_function = MagicMock() + + time = 10 + event = SimulationEvent( + time, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + + assert event.time == time + assert event.fn() is some_test_function + assert event.function_args == [] + assert event.function_kwargs == {} + assert event.priority == Priority.DEFAULT + + # execute + event.execute() + some_test_function.assert_called_once() + + with pytest.raises(Exception): + SimulationEvent( + time, None, priority=Priority.DEFAULT, function_args=[], function_kwargs={} + ) + + # check calling with arguments + some_test_function = MagicMock() + event = SimulationEvent( + time, + some_test_function, + priority=Priority.DEFAULT, + function_args=["1"], + function_kwargs={"x": 2}, + ) + event.execute() + some_test_function.assert_called_once_with("1", x=2) + + # check if we pass over deletion of callable silently because of weakrefs + def some_test_function(x, y): + return x + y + + event = SimulationEvent(time, some_test_function, priority=Priority.DEFAULT) + del some_test_function + event.execute() + + # cancel + some_test_function = MagicMock() + event = SimulationEvent( + time, + some_test_function, + priority=Priority.DEFAULT, + function_args=["1"], + function_kwargs={"x": 2}, + ) + event.cancel() + assert event.fn is None + assert event.function_args == [] + assert event.function_kwargs == {} + assert event.priority == Priority.DEFAULT + assert event.CANCELED + + # comparison for sorting + event1 = SimulationEvent( + 10, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + event2 = SimulationEvent( + 10, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + assert event1 < event2 # based on just unique_id as tiebraker + + event1 = SimulationEvent( + 11, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + event2 = SimulationEvent( + 10, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + assert event1 > event2 + + event1 = SimulationEvent( + 10, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + event2 = SimulationEvent( + 10, + some_test_function, + priority=Priority.HIGH, + function_args=[], + function_kwargs={}, + ) + assert event1 > event2 + + +def test_eventlist(): + event_list = EventList() + + assert len(event_list._events) == 0 + assert isinstance(event_list._events, list) + assert event_list.is_empty() + + # add event + some_test_function = MagicMock() + event = SimulationEvent( + 1, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + event_list.add_event(event) + assert len(event_list) == 1 + assert event in event_list + + # remove event + event_list.remove(event) + assert len(event_list) == 1 + assert event.CANCELED + + # peak ahead + event_list = EventList() + for i in range(10): + event = SimulationEvent( + i, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + event_list.add_event(event) + events = event_list.peak_ahead(2) + assert len(events) == 2 + assert events[0].time == 0 + assert events[1].time == 1 + + events = event_list.peak_ahead(11) + assert len(events) == 10 + + event_list._events[6].cancel() + events = event_list.peak_ahead(10) + assert len(events) == 9 + + event_list = EventList() + with pytest.raises(Exception): + event_list.peak_ahead() + + # pop event + event_list = EventList() + for i in range(10): + event = SimulationEvent( + i, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + event_list.add_event(event) + event = event_list.pop_event() + assert event.time == 0 + + event_list = EventList() + event = SimulationEvent( + 9, + some_test_function, + priority=Priority.DEFAULT, + function_args=[], + function_kwargs={}, + ) + event_list.add_event(event) + event.cancel() + with pytest.raises(Exception): + event_list.pop_event() + + # clear + event_list.clear() + assert len(event_list) == 0 diff --git a/tests/test_examples.py b/tests/test_examples.py index 1c149da4b75..e5c0381f065 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -51,12 +51,12 @@ def test_examples(self): print(f"testing example {example!r}") with self.active_example_dir(example): try: - # model.py at the top level + # epstein_civil_violence.py at the top level mod = importlib.import_module("model") server = importlib.import_module("server") server.server.render_model() except ImportError: - # /model.py + # /epstein_civil_violence.py mod = importlib.import_module(f"{example.replace('-', '_')}.model") server = importlib.import_module( f"{example.replace('-', '_')}.server" diff --git a/tests/test_time.py b/tests/test_time.py index 53967d1a8ef..a9d8e2e6055 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -8,7 +8,6 @@ from mesa import Agent, Model from mesa.time import ( BaseScheduler, - DiscreteEventScheduler, RandomActivation, RandomActivationByType, SimultaneousActivation, @@ -339,119 +338,5 @@ def test_random_activation_counts(self): # model.schedule.add(b) -class TestDiscreteEventScheduler(TestCase): - def setUp(self): - self.model = MockModel() - self.scheduler = DiscreteEventScheduler(self.model, time_step=1) - self.model.schedule = self.scheduler - self.agent1 = MockAgent(1, self.model) - self.agent2 = MockAgent(2, self.model) - self.model.schedule.add(self.agent1, schedule_now=False) - self.model.schedule.add(self.agent2, schedule_now=False) - - # Testing Initialization and Attributes - def test_initialization(self): - self.assertIsInstance(self.scheduler.event_queue, list) - self.assertEqual(self.scheduler.time_step, 1) - - # Testing Event Scheduling - def test_schedule_event(self): - self.scheduler.schedule_event(5, self.agent1) - self.assertEqual(len(self.scheduler.event_queue), 1) - event_time, _, event_agent = self.scheduler.event_queue[0] - self.assertEqual(event_time, 5) - self.assertEqual(event_agent(), self.agent1) - - def test_schedule_event_with_float_time(self): - self.scheduler.schedule_event(5.5, self.agent1) - self.assertEqual(len(self.scheduler.event_queue), 1) - event_time, _, event_agent = self.scheduler.event_queue[0] - self.assertEqual(event_time, 5.5) - self.assertEqual(event_agent(), self.agent1) - - def test_schedule_in(self): - self.scheduler.schedule_in(3, self.agent2) - _, _, event_agent = self.scheduler.event_queue[0] - self.assertEqual(event_agent(), self.agent2) - self.assertEqual(self.scheduler.get_next_event_time(), self.scheduler.time + 3) - - # Testing Event Execution and Time Advancement - def test_step_function(self): - self.scheduler.schedule_event(1, self.agent1) - self.scheduler.schedule_event(2, self.agent2) - self.scheduler.step() - self.assertEqual(self.scheduler.time, 1) - self.assertEqual(self.agent1.steps, 1) - self.assertEqual(self.agent2.steps, 0) - - def test_time_advancement(self): - self.scheduler.schedule_event(5, self.agent1) - self.scheduler.step() - self.assertEqual(self.scheduler.time, 1) - self.scheduler.step() - self.assertEqual(self.scheduler.time, 2) - - def test_no_events(self): - self.scheduler.step() - self.assertEqual(self.scheduler.time, 1) - - # Testing Edge Cases and Error Handling - def test_invalid_event_time(self): - with self.assertRaises(ValueError): - self.scheduler.schedule_event(-1, self.agent1) - - def test_invalid_aget_time(self): - with self.assertRaises(ValueError): - agent3 = MockAgent(3, self.model) - self.scheduler.schedule_event(2, agent3) - - def test_immediate_event_execution(self): - # Current time of the scheduler - current_time = self.scheduler.time - - # Schedule an event at the current time - self.scheduler.schedule_event(current_time, self.agent1) - - # Step the scheduler and check if the event is executed immediately - self.scheduler.step() - self.assertEqual(self.agent1.steps, 1) - - # The time should advance to the next time step after execution - self.assertEqual(self.scheduler.time, current_time + 1) - - # Testing Utility Functions - def test_get_next_event_time(self): - self.scheduler.schedule_event(10, self.agent1) - self.assertEqual(self.scheduler.get_next_event_time(), 10) - - # Test add() method with and without immediate scheduling - def test_add_with_immediate_scheduling(self): - # Add an agent with schedule_now set to True (default behavior) - new_agent = MockAgent(3, self.model) - self.scheduler.add(new_agent) - - # Check if the agent's first event is scheduled immediately - self.assertEqual(len(self.scheduler.event_queue), 1) - event_time, _, event_agent = self.scheduler.event_queue[0] - self.assertEqual(event_time, self.scheduler.time) - self.assertEqual(event_agent(), new_agent) - - # Step the scheduler and check if the agent's step method is executed - self.scheduler.step() - self.assertEqual(new_agent.steps, 1) - - def test_add_without_immediate_scheduling(self): - # Add an agent with schedule_now set to False - new_agent = MockAgent(4, self.model) - self.scheduler.add(new_agent, schedule_now=False) - - # Check if the event queue is not updated - self.assertEqual(len(self.scheduler.event_queue), 0) - - # Step the scheduler and verify that the agent's step method is not executed - self.scheduler.step() - self.assertEqual(new_agent.steps, 0) - - if __name__ == "__main__": unittest.main() From 6f9920235d296abf1dfe50abb3e0e9c8a0355b5b Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Fri, 19 Apr 2024 09:11:26 +0200 Subject: [PATCH 13/50] Update version number to 2.3.0-rc1 and add release notes (#2114) --- HISTORY.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++ mesa/__init__.py | 2 +- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index e54e645532a..3d7a9161b6e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,121 @@ --- title: Release History --- +# 2.3.0-rc1 (2024-04-18) +## Highlights +The 2.3.0-rc1 release is our first release candidate pre-release, meant to test all the new features and enhancement for the upcoming 2.3.0 release. + +There are two main new features: +- The experimental cell-centric discrete spaces, as added in #1994. It allows having cells with not only properties but also active behaviors: the `CellAgent`. Its inspired by NetLogo's [patches](https://ccl.northwestern.edu/netlogo/bind/primitive/patches.html) but extend and generalize this concept further. +- Full support for discrete event scheduling, as added in #2066. It allows scheduling events (like Agent actions) at any time, including non-integer timesteps. + +There are a lot of other features: The Jupyter visualisation now supports sliders, `NetworkGrid.get_neighbors()` supports a radius, `AgentSet.get()` can retrieve multiple attributes and there are now benchmarks to track Mesa performance during development. + +Finally, 2.3.0 stabilizes the `AgentSet` (including `model.agents`), making it the first experimental Mesa feature that is taken out of it's experimental phase. + +Install this pre-release with: +``` +pip install --pre mesa +``` +We would love feedback before we release 2.3.0 stable in ~1 week. + +## What's Changed +### 🧪 Experimental features +* Add cell-centric discrete spaces (experimental) by @Corvince in https://github.com/projectmesa/mesa/pull/1994 +### 🎉 New features added +* Add performance benchmarking scripts by @EwoutH in https://github.com/projectmesa/mesa/pull/1979 +* feat: Implement Slider class for JupyterViz by @rht in https://github.com/projectmesa/mesa/pull/1972 +* Stabilize AgentSet by @EwoutH in https://github.com/projectmesa/mesa/pull/2065 +* Support discrete event scheduling by @quaquel in https://github.com/projectmesa/mesa/pull/2066 +### 🛠 Enhancements made +* JupyterViz: Automatically deduce display name from model class by @rht in https://github.com/projectmesa/mesa/pull/1975 +* Add radius argument to NetworkGrid.get_neighbors() by @EwoutH in https://github.com/projectmesa/mesa/pull/1973 +* Speedup of Agentset.shuffle by @quaquel in https://github.com/projectmesa/mesa/pull/2010 +* feat: Let mesa runserver detect server.py as fallback by @rht in https://github.com/projectmesa/mesa/pull/2015 +* JupyterViz: {Convert make_plot & prepare ColorCard} to become Solara component by @rht in https://github.com/projectmesa/mesa/pull/2020 +* new feature: AgentSet.get can retrieve one or more then one attribute by @quaquel in https://github.com/projectmesa/mesa/pull/2044 +* Update CODE_OF_CONDUCT.md to version 2+ of contrib convenant by @jackiekazil in https://github.com/projectmesa/mesa/pull/2052 +* Improve flocking benchmark by @coderbeta1 in https://github.com/projectmesa/mesa/pull/2054 +* Remove JupyterViz Altair marker overlap for huge grid size by @rht in https://github.com/projectmesa/mesa/pull/2062 +* Add tooltip option to Altair chart by @FoFFolo in https://github.com/projectmesa/mesa/pull/2082 +* feat: Display model seed & allow user to specify it in JupyterViz by @rht in https://github.com/projectmesa/mesa/pull/2069 +* warn if placing already placed agent by @puer-robustus in https://github.com/projectmesa/mesa/pull/2083 +### 🐛 Bugs fixed +* fix: Apply default value to slider by @rht in https://github.com/projectmesa/mesa/pull/2016 +* fix: Initialize model _steps and _time during __new__ by @rht in https://github.com/projectmesa/mesa/pull/2026 +* fix: Use model.schedule only when it is not None by @rht in https://github.com/projectmesa/mesa/pull/2050 +* fix: Remove JupyterViz grid marker overlap for huge grid size by @rht in https://github.com/projectmesa/mesa/pull/2049 +### 📜 Documentation improvements +* Improve readability of badges by @rht in https://github.com/projectmesa/mesa/pull/2009 +* More pythonic implementation of wolf sheep by @quaquel in https://github.com/projectmesa/mesa/pull/2011 +* Adding super().__init__() to MoneyModel tutorial by @sw23 in https://github.com/projectmesa/mesa/pull/2025 +* docs: Convert howto.rst -> howto.md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2033 +* docs: Convert best-practices,overview,packages,mesa,index to .md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2034 +* docs: Convert api/*.rst -> api/*.md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2035 +* docs: Rewrite howto.md using ChatGPT for clarity and conciseness by @rht in https://github.com/projectmesa/mesa/pull/2037 +* docs: Corrected Contributing Guide Link to Ensure Accessibility by @sahusiddharth in https://github.com/projectmesa/mesa/pull/2057 +* Rename links to internal .rst files to .md by @rht in https://github.com/projectmesa/mesa/pull/2058 +* docs: improve introductory tutorial by @puer-robustus in https://github.com/projectmesa/mesa/pull/2087 +### 🔧 Maintenance +* Quality of Life: Make codecov less meticulous by @Corvince in https://github.com/projectmesa/mesa/pull/1966 +* Add CI workflow for performance benchmarks by @EwoutH in https://github.com/projectmesa/mesa/pull/1983 +* tests: Resolve warnings by defining PropertyLayer dtypes by @EwoutH in https://github.com/projectmesa/mesa/pull/1987 +* benchmarks.yml: Fix PR branch checkout when triggered by comment by @EwoutH in https://github.com/projectmesa/mesa/pull/1998 +* Quality of life: automatically fix ruff errors by @Corvince in https://github.com/projectmesa/mesa/pull/2004 +* benchmarks.yml: Run on addition of label instead of comment by @EwoutH in https://github.com/projectmesa/mesa/pull/2002 +* ci: Move codespell to pre-commit by @rht in https://github.com/projectmesa/mesa/pull/2040 +* Schelling by @coderbeta1 in https://github.com/projectmesa/mesa/pull/2053 +* Move ruff lint settings into dedicated section by @Corvince in https://github.com/projectmesa/mesa/pull/2073 +* ci: Use uv pip for faster build by @rht in https://github.com/projectmesa/mesa/pull/2038 +* test: Remove place_agent duplicate warnings by @rht in https://github.com/projectmesa/mesa/pull/2086 +### Other changes +* Minor edits to benchmarking code by @quaquel in https://github.com/projectmesa/mesa/pull/1985 +* build(deps): bump codecov/codecov-action from 3 to 4 by @dependabot in https://github.com/projectmesa/mesa/pull/2030 +* [pre-commit.ci] pre-commit autoupdate by @pre-commit-ci in https://github.com/projectmesa/mesa/pull/2029 +* tests: Speed up test_batch_run by @rht in https://github.com/projectmesa/mesa/pull/2039 +* Update benchmarks.yml by @Corvince in https://github.com/projectmesa/mesa/pull/2043 +* docs: Convert visualization .rst -> .md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2036 +* docs: Convert CONTRIBUTING .rst -> .md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2041 +* Correct wolf energy gained from eating sheep by @JackAtOmenApps in https://github.com/projectmesa/mesa/pull/2048 +* feat: Implement Altair version of grid visualization by @rht in https://github.com/projectmesa/mesa/pull/1991 + +## New Contributors +* @sw23 made their first contribution in https://github.com/projectmesa/mesa/pull/2025 +* @JackAtOmenApps made their first contribution in https://github.com/projectmesa/mesa/pull/2048 +* @coderbeta1 made their first contribution in https://github.com/projectmesa/mesa/pull/2054 +* @sahusiddharth made their first contribution in https://github.com/projectmesa/mesa/pull/2057 +* @FoFFolo made their first contribution in https://github.com/projectmesa/mesa/pull/2082 +* @puer-robustus made their first contribution in https://github.com/projectmesa/mesa/pull/2083 + +**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.4...2.3.0-rc1 + +# 2.2.4 (2024-01-26) +## Highlights +Mesa v2.2.4 is a small but important bugfix release for the 2.2 release series. It fixes an essential bug in where agents weren't shuffled in the `BaseScheduler`, affecting mainly the `RandomActivation` scheduler (effectively making it sequential activation)([#2007](https://github.com/projectmesa/mesa/pull/2007)). It also fixes a small behaviour change in `RandomActivationByType.agents_by_type()` ([#1996](https://github.com/projectmesa/mesa/pull/1996)). Furthermore, this release adds an internal clock to the `Model`, which allows to use a Mesa model without a scheduler (using the `AgentSet` API)([#1942](https://github.com/projectmesa/mesa/pull/1942)). + +Updating from previous 2.2 releases is highly recommended, especially when using the `RandomActivation` scheduler. + +## What's Changed +### 🛠 Enhancements made +* refactor: Remove dependence on model.schedule, add clock to Model by @rht in https://github.com/projectmesa/mesa/pull/1942 +### 🐛 Bugs fixed +* Fix AgentSet inplace shuffle (and thus RandomActivation), add tests by @EwoutH and @quaquel in https://github.com/projectmesa/mesa/pull/2007 +* fix: Reverse dict key and value for agents_by_type by @rht in https://github.com/projectmesa/mesa/pull/1996 + +**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.3...v2.2.4 + +# 2.2.3 (2024-01-22) +## Highlights +Mesa 2.2.3 is a small release with two improvements to the experimental Solara visualisation, on request of one of our contributors. No stable features have changed. + +## What's Changed +### 🧪 Experimental features +* solara_viz: Add borders around ContinuousSpace by @EwoutH in https://github.com/projectmesa/mesa/pull/1988 +### 🐛 Bugs fixed +* fix: Explicitly specify JupyterViz space view limits by @rht in https://github.com/projectmesa/mesa/pull/1984 + +**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.2...v2.2.3 + # 2.2.2 (2024-01-22) ## Highlights diff --git a/mesa/__init__.py b/mesa/__init__.py index 7bc416a6d29..4cef05c3d8e 100644 --- a/mesa/__init__.py +++ b/mesa/__init__.py @@ -26,7 +26,7 @@ ] __title__ = "mesa" -__version__ = "2.3.0-dev" +__version__ = "2.3.0-rc1" __license__ = "Apache 2.0" _this_year = datetime.datetime.now(tz=datetime.timezone.utc).date().year __copyright__ = f"Copyright {_this_year} Project Mesa Team" From d399cd1299103ba9755162c3d58a4ba1e7cc29f3 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Fri, 19 Apr 2024 09:53:34 +0200 Subject: [PATCH 14/50] HISTORY.md: Update 2.3.0 notes --- HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 3d7a9161b6e..8b65e404f48 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,7 +9,7 @@ There are two main new features: - The experimental cell-centric discrete spaces, as added in #1994. It allows having cells with not only properties but also active behaviors: the `CellAgent`. Its inspired by NetLogo's [patches](https://ccl.northwestern.edu/netlogo/bind/primitive/patches.html) but extend and generalize this concept further. - Full support for discrete event scheduling, as added in #2066. It allows scheduling events (like Agent actions) at any time, including non-integer timesteps. -There are a lot of other features: The Jupyter visualisation now supports sliders, `NetworkGrid.get_neighbors()` supports a radius, `AgentSet.get()` can retrieve multiple attributes and there are now benchmarks to track Mesa performance during development. +There are a lot of other features: The Jupyter visualisation now supports easier way to specify sliders, `NetworkGrid.get_neighbors()` supports a radius, `AgentSet.get()` can retrieve multiple attributes and there are now benchmarks to track Mesa performance during development. Finally, 2.3.0 stabilizes the `AgentSet` (including `model.agents`), making it the first experimental Mesa feature that is taken out of it's experimental phase. From e09d26b95ed3dd7e6a879d478413a349ad9971ce Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Tue, 23 Apr 2024 20:42:06 +0200 Subject: [PATCH 15/50] Update version number and release notes for 2.3.0 release (#2116) * Update version number and release notes for 2.3.0 release * Fix 2.3.0 date * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- HISTORY.md | 15 +++++++++------ mesa/__init__.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 8b65e404f48..98d4a06aad9 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,9 +1,9 @@ --- title: Release History --- -# 2.3.0-rc1 (2024-04-18) +# 2.3.0 (2024-04-23) ## Highlights -The 2.3.0-rc1 release is our first release candidate pre-release, meant to test all the new features and enhancement for the upcoming 2.3.0 release. +Mesa 2.3.0 is a big feature release and the last feature release before 3.0. There are two main new features: - The experimental cell-centric discrete spaces, as added in #1994. It allows having cells with not only properties but also active behaviors: the `CellAgent`. Its inspired by NetLogo's [patches](https://ccl.northwestern.edu/netlogo/bind/primitive/patches.html) but extend and generalize this concept further. @@ -13,11 +13,11 @@ There are a lot of other features: The Jupyter visualisation now supports easier Finally, 2.3.0 stabilizes the `AgentSet` (including `model.agents`), making it the first experimental Mesa feature that is taken out of it's experimental phase. -Install this pre-release with: +Install this release with: ``` -pip install --pre mesa +pip install --upgrade mesa ``` -We would love feedback before we release 2.3.0 stable in ~1 week. +The Mesa 2.3.x-series supports Python 3.9 to 3.12. The next major release will require Python 3.10. ## What's Changed ### 🧪 Experimental features @@ -87,7 +87,10 @@ We would love feedback before we release 2.3.0 stable in ~1 week. * @FoFFolo made their first contribution in https://github.com/projectmesa/mesa/pull/2082 * @puer-robustus made their first contribution in https://github.com/projectmesa/mesa/pull/2083 -**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.4...2.3.0-rc1 +**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.4...2.3.0 + +# 2.3.0-rc1 (2024-04-18) +Mesa 2.3.0-rc1 is pre-release in preperation for 2.3.0 stable. It had the same release notes as 2.3.0. # 2.2.4 (2024-01-26) ## Highlights diff --git a/mesa/__init__.py b/mesa/__init__.py index 4cef05c3d8e..211ce6c6eaf 100644 --- a/mesa/__init__.py +++ b/mesa/__init__.py @@ -26,7 +26,7 @@ ] __title__ = "mesa" -__version__ = "2.3.0-rc1" +__version__ = "2.3.0" __license__ = "Apache 2.0" _this_year = datetime.datetime.now(tz=datetime.timezone.utc).date().year __copyright__ = f"Copyright {_this_year} Project Mesa Team" From 3f61d41955c5411e1fa399593b75f12ee054e0b4 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Tue, 23 Apr 2024 21:32:33 +0200 Subject: [PATCH 16/50] Make agent move to actual random closest position (#2119) This fixes a bug in which move_agent_to_one_of is deterministic when pos has multiple closest choices and selection="closest". --- mesa/space.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mesa/space.py b/mesa/space.py index b9044387234..2be9503eef7 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -461,6 +461,7 @@ def move_agent_to_one_of( # Find the closest position without sorting all positions closest_pos = None min_distance = float("inf") + agent.random.shuffle(pos) for p in pos: distance = self._distance_squared(p, current_pos) if distance < min_distance: From 20661649b37edcbfc97c766731437c5678d4a72b Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Tue, 23 Apr 2024 21:32:48 +0200 Subject: [PATCH 17/50] HISTORY.md: Fix two spelling errors (#2120) --- HISTORY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 98d4a06aad9..191a7ebc771 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -34,7 +34,7 @@ The Mesa 2.3.x-series supports Python 3.9 to 3.12. The next major release will r * feat: Let mesa runserver detect server.py as fallback by @rht in https://github.com/projectmesa/mesa/pull/2015 * JupyterViz: {Convert make_plot & prepare ColorCard} to become Solara component by @rht in https://github.com/projectmesa/mesa/pull/2020 * new feature: AgentSet.get can retrieve one or more then one attribute by @quaquel in https://github.com/projectmesa/mesa/pull/2044 -* Update CODE_OF_CONDUCT.md to version 2+ of contrib convenant by @jackiekazil in https://github.com/projectmesa/mesa/pull/2052 +* Update CODE_OF_CONDUCT.md to version 2+ of contrib covenant by @jackiekazil in https://github.com/projectmesa/mesa/pull/2052 * Improve flocking benchmark by @coderbeta1 in https://github.com/projectmesa/mesa/pull/2054 * Remove JupyterViz Altair marker overlap for huge grid size by @rht in https://github.com/projectmesa/mesa/pull/2062 * Add tooltip option to Altair chart by @FoFFolo in https://github.com/projectmesa/mesa/pull/2082 @@ -90,7 +90,7 @@ The Mesa 2.3.x-series supports Python 3.9 to 3.12. The next major release will r **Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.4...2.3.0 # 2.3.0-rc1 (2024-04-18) -Mesa 2.3.0-rc1 is pre-release in preperation for 2.3.0 stable. It had the same release notes as 2.3.0. +Mesa 2.3.0-rc1 is pre-release in preparation for 2.3.0 stable. It had the same release notes as 2.3.0. # 2.2.4 (2024-01-26) ## Highlights From 2b769c2f342a4b4ef7bf6775b37bda5562fac567 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 24 Apr 2024 11:09:34 +0200 Subject: [PATCH 18/50] flocking benchmark: Remove unneeded passing of pos pos is not a part of the Boid init, which is causing an error. It can be safely removed, because it's already initialized the line below with `self.space.place_agent(boid, pos)` --- benchmarks/Flocking/flocking.py | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/Flocking/flocking.py b/benchmarks/Flocking/flocking.py index fc19c6c3820..ff9f97c0c96 100644 --- a/benchmarks/Flocking/flocking.py +++ b/benchmarks/Flocking/flocking.py @@ -139,7 +139,6 @@ def __init__( boid = Boid( unique_id=i, model=self, - pos=pos, speed=speed, direction=direction, vision=vision, From 13633e6cdad5fd5819385ea0ff7a453d7cac83bd Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 24 Apr 2024 10:39:55 +0200 Subject: [PATCH 19/50] Set version to 3.0.0-dev Set the Mesa version to 3.0.0-dev for development towards 3.0! --- mesa/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesa/__init__.py b/mesa/__init__.py index 211ce6c6eaf..e4b92ab093d 100644 --- a/mesa/__init__.py +++ b/mesa/__init__.py @@ -26,7 +26,7 @@ ] __title__ = "mesa" -__version__ = "2.3.0" +__version__ = "3.0.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" From ccb793e51c5cf32062cb45be90abcb7cfbee6cb4 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 6 May 2024 11:26:27 +0200 Subject: [PATCH 20/50] CI: Add weekly scheduled run to all CI workflows Adds a weekly scheduled run to all CI workflows, which run each Monday at 06:00 UTC. A periodically scheduled run can detect errors and warnings appearing in changes in upstream dependencies or the build environment. By having a scheduled run they can be traced back easier to a dependency or environment change, then if they would pop up in a random PR. Also enabled triggering a run manually (the workflow_dispatch). --- .github/workflows/build_lint.yml | 3 +++ .github/workflows/release.yml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml index c7b6b4f00fc..2edadaedcbe 100644 --- a/.github/workflows/build_lint.yml +++ b/.github/workflows/build_lint.yml @@ -10,6 +10,9 @@ on: pull_request: paths-ignore: - '**.md' + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' # This will cancel previous run if a newer job that obsoletes the said previous # run, is started. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 627987f3b1c..b8f01c47751 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ on: - '**.md' - '**.rst' workflow_dispatch: + schedule: + - cron: '0 6 * * 1' permissions: id-token: write From c60ef303ec30b2ab7176a9d2b3b83d6735dfa6c0 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 8 May 2024 07:11:07 +0200 Subject: [PATCH 21/50] Drop support for Python 3.9, require Python >= 3.10 (#2132) * Drop support for Python 3.9, require Python >= 3.10 Starting development for Mesa 3.0 and following SPEC 0 we can drop Python 3.9 support and require Python 3.10 or higher for future development. This allows adopting more modern Python features and simplifies the testing and CI configurations. See the Python 3.10 release notes: https://docs.python.org/3/whatsnew/3.10.html The 2.3.x release series will keep supporting Python 3.9. * Update type hinting for Python 3.10 `X | Y` can now be used for type annotations, including making a variable optional with `X | None` * intro_tutorial.ipynb: Require Python 3.10 * typing: Replace Union with pipe (|) We can do this now in Python 3.10! --- .github/workflows/build_lint.yml | 2 -- .pre-commit-config.yaml | 2 +- docs/tutorials/intro_tutorial.ipynb | 2 +- mesa/agent.py | 4 ++-- mesa/batchrunner.py | 8 ++++---- mesa/datacollection.py | 2 +- mesa/experimental/cell_space/cell_collection.py | 4 ++-- mesa/experimental/cell_space/grid.py | 2 +- mesa/experimental/cell_space/network.py | 6 +++--- mesa/experimental/components/altair.py | 3 +-- mesa/experimental/components/matplotlib.py | 8 +++----- mesa/experimental/devs/eventlist.py | 3 ++- mesa/experimental/devs/simulator.py | 3 ++- mesa/model.py | 4 ++-- mesa/space.py | 10 +++++----- mesa/time.py | 4 +--- pyproject.toml | 7 +++---- 17 files changed, 34 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml index 2edadaedcbe..2beefecb313 100644 --- a/.github/workflows/build_lint.yml +++ b/.github/workflows/build_lint.yml @@ -37,8 +37,6 @@ jobs: python-version: "3.11" - os: ubuntu python-version: "3.10" - - os: ubuntu - python-version: "3.9" # Disabled for now. See https://github.com/projectmesa/mesa/issues/1253 #- os: ubuntu # python-version: 'pypy-3.8' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d00bb6d49f6..fa4ea7fa879 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: rev: v3.15.2 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py310-plus] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 # Use the ref you want to point at hooks: diff --git a/docs/tutorials/intro_tutorial.ipynb b/docs/tutorials/intro_tutorial.ipynb index 65f2a586561..a9fe56875e1 100644 --- a/docs/tutorials/intro_tutorial.ipynb +++ b/docs/tutorials/intro_tutorial.ipynb @@ -60,7 +60,7 @@ "source": [ "### Tutorial Setup\n", "\n", - "Create and activate a [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs/). *Python version 3.9 or higher is required*.\n", + "Create and activate a [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs/). *Python version 3.10 or higher is required*.\n", "\n", "Install Mesa:\n", "\n", diff --git a/mesa/agent.py b/mesa/agent.py index ab5e1800138..2f0e842d382 100644 --- a/mesa/agent.py +++ b/mesa/agent.py @@ -14,11 +14,11 @@ import warnings import weakref from collections import defaultdict -from collections.abc import Iterable, Iterator, MutableSet, Sequence +from collections.abc import Callable, Iterable, Iterator, MutableSet, Sequence from random import Random # mypy -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # We ensure that these are not imported during runtime to prevent cyclic diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py index aba96855c66..a74eddbeacc 100644 --- a/mesa/batchrunner.py +++ b/mesa/batchrunner.py @@ -2,7 +2,7 @@ from collections.abc import Iterable, Mapping from functools import partial from multiprocessing import Pool -from typing import Any, Optional, Union +from typing import Any from tqdm.auto import tqdm @@ -11,9 +11,9 @@ def batch_run( model_cls: type[Model], - parameters: Mapping[str, Union[Any, Iterable[Any]]], + parameters: Mapping[str, Any | Iterable[Any]], # We still retain the Optional[int] because users may set it to None (i.e. use all CPUs) - number_processes: Optional[int] = 1, + number_processes: int | None = 1, iterations: int = 1, data_collection_period: int = -1, max_steps: int = 1000, @@ -76,7 +76,7 @@ def batch_run( def _make_model_kwargs( - parameters: Mapping[str, Union[Any, Iterable[Any]]], + parameters: Mapping[str, Any | Iterable[Any]], ) -> list[dict[str, Any]]: """Create model kwargs from parameters dictionary. diff --git a/mesa/datacollection.py b/mesa/datacollection.py index 59247953d82..3082a67db6c 100644 --- a/mesa/datacollection.py +++ b/mesa/datacollection.py @@ -196,7 +196,7 @@ def collect(self, model): if self.model_reporters: for var, reporter in self.model_reporters.items(): # Check if lambda or partial function - if isinstance(reporter, (types.LambdaType, partial)): + if isinstance(reporter, types.LambdaType | partial): self.model_vars[var].append(reporter(model)) # Check if model attribute elif isinstance(reporter, str): diff --git a/mesa/experimental/cell_space/cell_collection.py b/mesa/experimental/cell_space/cell_collection.py index 9ca36589849..114301db100 100644 --- a/mesa/experimental/cell_space/cell_collection.py +++ b/mesa/experimental/cell_space/cell_collection.py @@ -1,10 +1,10 @@ from __future__ import annotations import itertools -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from functools import cached_property from random import Random -from typing import TYPE_CHECKING, Callable, Generic, TypeVar +from typing import TYPE_CHECKING, Generic, TypeVar if TYPE_CHECKING: from mesa.experimental.cell_space.cell import Cell diff --git a/mesa/experimental/cell_space/grid.py b/mesa/experimental/cell_space/grid.py index e73d18f46fd..f08657d2107 100644 --- a/mesa/experimental/cell_space/grid.py +++ b/mesa/experimental/cell_space/grid.py @@ -60,7 +60,7 @@ def _validate_parameters(self): raise ValueError("Dimensions must be a list of positive integers.") if not isinstance(self.torus, bool): raise ValueError("Torus must be a boolean.") - if self.capacity is not None and not isinstance(self.capacity, (float, int)): + if self.capacity is not None and not isinstance(self.capacity, float | int): raise ValueError("Capacity must be a number or None.") def select_random_empty_cell(self) -> T: diff --git a/mesa/experimental/cell_space/network.py b/mesa/experimental/cell_space/network.py index 57c4d492bb0..3983287e4ef 100644 --- a/mesa/experimental/cell_space/network.py +++ b/mesa/experimental/cell_space/network.py @@ -1,5 +1,5 @@ from random import Random -from typing import Any, Optional +from typing import Any from mesa.experimental.cell_space.cell import Cell from mesa.experimental.cell_space.discrete_space import DiscreteSpace @@ -11,8 +11,8 @@ class Network(DiscreteSpace): def __init__( self, G: Any, # noqa: N803 - capacity: Optional[int] = None, - random: Optional[Random] = None, + capacity: int | None = None, + random: Random | None = None, cell_klass: type[Cell] = Cell, ) -> None: """A Networked grid diff --git a/mesa/experimental/components/altair.py b/mesa/experimental/components/altair.py index 6952c03f336..f9d1a81c172 100644 --- a/mesa/experimental/components/altair.py +++ b/mesa/experimental/components/altair.py @@ -1,5 +1,4 @@ import contextlib -from typing import Optional import solara @@ -8,7 +7,7 @@ @solara.component -def SpaceAltair(model, agent_portrayal, dependencies: Optional[list[any]] = None): +def SpaceAltair(model, agent_portrayal, dependencies: list[any] | None = None): space = getattr(model, "grid", None) if space is None: # Sometimes the space is defined as model.space instead of model.grid diff --git a/mesa/experimental/components/matplotlib.py b/mesa/experimental/components/matplotlib.py index 85f8df9cb1d..6284944aa7f 100644 --- a/mesa/experimental/components/matplotlib.py +++ b/mesa/experimental/components/matplotlib.py @@ -1,5 +1,3 @@ -from typing import Optional - import networkx as nx import solara from matplotlib.figure import Figure @@ -9,7 +7,7 @@ @solara.component -def SpaceMatplotlib(model, agent_portrayal, dependencies: Optional[list[any]] = None): +def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = None): space_fig = Figure() space_ax = space_fig.subplots() space = getattr(model, "grid", None) @@ -116,7 +114,7 @@ def portray(space): @solara.component -def PlotMatplotlib(model, measure, dependencies: Optional[list[any]] = None): +def PlotMatplotlib(model, measure, dependencies: list[any] | None = None): fig = Figure() ax = fig.subplots() df = model.datacollector.get_model_vars_dataframe() @@ -127,7 +125,7 @@ def PlotMatplotlib(model, measure, dependencies: Optional[list[any]] = None): for m, color in measure.items(): ax.plot(df.loc[:, m], label=m, color=color) fig.legend() - elif isinstance(measure, (list, tuple)): + elif isinstance(measure, list | tuple): for m in measure: ax.plot(df.loc[:, m], label=m) fig.legend() diff --git a/mesa/experimental/devs/eventlist.py b/mesa/experimental/devs/eventlist.py index 48af72a4315..a0ec9337d66 100644 --- a/mesa/experimental/devs/eventlist.py +++ b/mesa/experimental/devs/eventlist.py @@ -1,10 +1,11 @@ from __future__ import annotations import itertools +from collections.abc import Callable from enum import IntEnum from heapq import heapify, heappop, heappush from types import MethodType -from typing import Any, Callable +from typing import Any from weakref import WeakMethod, ref diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py index 11c33f4e074..74f018e883d 100644 --- a/mesa/experimental/devs/simulator.py +++ b/mesa/experimental/devs/simulator.py @@ -1,7 +1,8 @@ from __future__ import annotations import numbers -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from mesa import Model diff --git a/mesa/model.py b/mesa/model.py index 724fea2ee77..d998e112f26 100644 --- a/mesa/model.py +++ b/mesa/model.py @@ -14,12 +14,12 @@ from collections import defaultdict # mypy -from typing import Any, Union +from typing import Any from mesa.agent import Agent, AgentSet from mesa.datacollection import DataCollector -TimeT = Union[float, int] +TimeT = float | int class Model: diff --git a/mesa/space.py b/mesa/space.py index 2be9503eef7..a6d070f0291 100644 --- a/mesa/space.py +++ b/mesa/space.py @@ -23,9 +23,9 @@ import itertools import math import warnings -from collections.abc import Iterable, Iterator, Sequence +from collections.abc import Callable, Iterable, Iterator, Sequence from numbers import Real -from typing import Any, Callable, TypeVar, Union, cast, overload +from typing import Any, TypeVar, cast, overload from warnings import warn with contextlib.suppress(ImportError): @@ -42,12 +42,12 @@ Coordinate = tuple[int, int] # used in ContinuousSpace -FloatCoordinate = Union[tuple[float, float], npt.NDArray[float]] +FloatCoordinate = tuple[float, float] | npt.NDArray[float] NetworkCoordinate = int -Position = Union[Coordinate, FloatCoordinate, NetworkCoordinate] +Position = Coordinate | FloatCoordinate | NetworkCoordinate -GridContent = Union[Agent, None] +GridContent = Agent | None MultiGridContent = list[Agent] F = TypeVar("F", bound=Callable[..., Any]) diff --git a/mesa/time.py b/mesa/time.py index 10fa4005ac2..e47563b4880 100644 --- a/mesa/time.py +++ b/mesa/time.py @@ -30,14 +30,12 @@ from collections.abc import Iterable # mypy -from typing import Union - from mesa.agent import Agent, AgentSet from mesa.model import Model # BaseScheduler has a self.time of int, while # StagedActivation has a self.time of float -TimeT = Union[float, int] +TimeT = float | int class BaseScheduler: diff --git a/pyproject.toml b/pyproject.toml index 7995526c556..51ce2c16fd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "Mesa" description = "Agent-based modeling (ABM) in Python" license = { text = "Apache 2.0" } -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ { name = "Project Mesa Team", email = "projectmesa@googlegroups.com" }, ] @@ -25,7 +25,6 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -82,9 +81,9 @@ path = "mesa/__init__.py" [tool.ruff] # See https://github.com/charliermarsh/ruff#rules for error code definitions. -# Hardcode to Python 3.9. +# Hardcode to Python 3.10. # Reminder to update mesa-examples if the value below is changed. -target-version = "py39" +target-version = "py310" extend-exclude = ["docs", "build"] [tool.ruff.lint] From e4684ac846e2ce4b0a6b4259a46d2d8415610f33 Mon Sep 17 00:00:00 2001 From: rht Date: Wed, 27 Mar 2024 03:55:35 -0400 Subject: [PATCH 22/50] breaking change: Rename mesa-viz-tornado namespace to visualization_old --- docs/mesa.md | 2 +- docs/mesa.visualization.md | 46 ----------- docs/mesa.visualization.modules.md | 76 ------------------- docs/mesa.visualization_old.md | 46 +++++++++++ docs/mesa.visualization_old.modules.md | 76 +++++++++++++++++++ docs/modular-visualization.md | 10 +-- docs/overview.md | 8 +- docs/tutorials/adv_tutorial_legacy.ipynb | 28 +++---- mesa/__init__.py | 4 +- .../{{cookiecutter.snake}}/server.pytemplate | 6 +- .../ModularVisualization.py | 0 .../TextVisualization.py | 0 .../UserParam.py | 0 .../__init__.py | 0 .../modules.py | 0 tests/test_import_namespace.py | 4 +- tests/test_main.py | 2 +- tests/test_tornado.py | 2 +- tests/test_usersettableparam.py | 2 +- tests/test_visualization.py | 6 +- 20 files changed, 159 insertions(+), 159 deletions(-) delete mode 100644 docs/mesa.visualization.md delete mode 100644 docs/mesa.visualization.modules.md create mode 100644 docs/mesa.visualization_old.md create mode 100644 docs/mesa.visualization_old.modules.md rename mesa/{visualization => visualization_old}/ModularVisualization.py (100%) rename mesa/{visualization => visualization_old}/TextVisualization.py (100%) rename mesa/{visualization => visualization_old}/UserParam.py (100%) rename mesa/{visualization => visualization_old}/__init__.py (100%) rename mesa/{visualization => visualization_old}/modules.py (100%) diff --git a/docs/mesa.md b/docs/mesa.md index 08a7148cbce..6c3e8cc8696 100644 --- a/docs/mesa.md +++ b/docs/mesa.md @@ -3,7 +3,7 @@ ## Subpackages ```{toctree} -mesa.visualization +mesa.visualization_old ``` ## Submodules diff --git a/docs/mesa.visualization.md b/docs/mesa.visualization.md deleted file mode 100644 index 5efa966222e..00000000000 --- a/docs/mesa.visualization.md +++ /dev/null @@ -1,46 +0,0 @@ -# mesa.visualization package - -## Subpackages - -```{toctree} -mesa.visualization.modules -``` - -## Submodules - -## mesa.visualization.ModularVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.ModularVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.TextVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.TextVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.UserParam module - -```{eval-rst} -.. automodule:: mesa.visualization.UserParam - :members: - :undoc-members: - :show-inheritance: - -``` - -## Module contents - -```{eval-rst} -.. automodule:: mesa.visualization - :members: - :undoc-members: - :show-inheritance: -``` diff --git a/docs/mesa.visualization.modules.md b/docs/mesa.visualization.modules.md deleted file mode 100644 index 6380f982c47..00000000000 --- a/docs/mesa.visualization.modules.md +++ /dev/null @@ -1,76 +0,0 @@ -# mesa.visualization.modules package - -## Submodules - -## mesa.visualization.modules.BarChartVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.modules.BarChartVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.modules.CanvasGridVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.modules.CanvasGridVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.modules.ChartVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.modules.ChartVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.modules.HexGridVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.modules.HexGridVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.modules.NetworkVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.modules.NetworkVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.modules.PieChartVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.modules.PieChartVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization.modules.TextVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization.modules.TextVisualization - :members: - :undoc-members: - :show-inheritance: - -``` - -## Module contents - -```{eval-rst} -.. automodule:: mesa.visualization.modules - :members: - :undoc-members: - :show-inheritance: -``` diff --git a/docs/mesa.visualization_old.md b/docs/mesa.visualization_old.md new file mode 100644 index 00000000000..700eabf12c9 --- /dev/null +++ b/docs/mesa.visualization_old.md @@ -0,0 +1,46 @@ +# mesa.visualization_old package + +## Subpackages + +```{toctree} +mesa.visualization_old.modules +``` + +## Submodules + +## mesa.visualization_old.ModularVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.ModularVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.TextVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.TextVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.UserParam module + +```{eval-rst} +.. automodule:: mesa.visualization_old.UserParam + :members: + :undoc-members: + :show-inheritance: + +``` + +## Module contents + +```{eval-rst} +.. automodule:: mesa.visualization_old + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/mesa.visualization_old.modules.md b/docs/mesa.visualization_old.modules.md new file mode 100644 index 00000000000..55d94b64e28 --- /dev/null +++ b/docs/mesa.visualization_old.modules.md @@ -0,0 +1,76 @@ +# mesa.visualization_old.modules package + +## Submodules + +## mesa.visualization_old.modules.BarChartVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules.BarChartVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.modules.CanvasGridVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules.CanvasGridVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.modules.ChartVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules.ChartVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.modules.HexGridVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules.HexGridVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.modules.NetworkVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules.NetworkVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.modules.PieChartVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules.PieChartVisualization + :members: + :undoc-members: + :show-inheritance: +``` + +## mesa.visualization_old.modules.TextVisualization module + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules.TextVisualization + :members: + :undoc-members: + :show-inheritance: + +``` + +## Module contents + +```{eval-rst} +.. automodule:: mesa.visualization_old.modules + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/modular-visualization.md b/docs/modular-visualization.md index 93fb4673a08..716c2f1b6e3 100644 --- a/docs/modular-visualization.md +++ b/docs/modular-visualization.md @@ -1,4 +1,4 @@ -# Modular Visualization - An In-Depth Look +# Modular Visualization - An In-Depth Look (Deprecated) Modular visualization is one of Mesa's core features. Mesa is designed to provide predefined visualization modules, which can be easily @@ -127,7 +127,7 @@ Suppose we want a module which can get an arbitrary variable out of a model, and display its name and value. Let's create a new subclass: ```python -from mesa.visualization.ModularTextVisualization import TextElement +from mesa.visualization_old.ModularTextVisualization import TextElement class AttributeElement(TextElement): def __init__(self, attr_name): @@ -205,7 +205,7 @@ Now let's take a look at the TextModule's Python counterpart, here's the whole thing: ```python -from mesa.visualization.ModularVisualization import VisualizationElement +from mesa.visualization_old.ModularVisualization import VisualizationElement class TextElement(VisualizationElement): js_includes = ["TextModule.js"] @@ -240,8 +240,8 @@ the list, the code passes that element of the data to the *render* function of the corresponding element, in the elements array. Currently, module JavaScript files live in the -*mesa/visualization/templates* directory, and the Python files live in -*mesa/visualization/modules*. +*mesa/visualization_old/templates* directory, and the Python files live in +*mesa/visualization_old/modules*. When creating a new module, the Python and JavaScript code need to be written in synch: the module Python-side **render** method needs to diff --git a/docs/overview.md b/docs/overview.md index a384b02e11e..923d64d1031 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -119,8 +119,8 @@ batch_df = batch_run.get_model_vars_dataframe() Finally, you may want to directly observe your model as it runs. Mesa's main visualization tool uses a small local web server to render the model in a browser, using JavaScript. There are different components for drawing different types of data: for example, grids for drawing agents moving around on a grid, or charts for showing how some data changes as the model runs. A few core modules are: -- mesa.visualization.ModularVisualization -- mesa.visualization.modules +- mesa.visualization_old.ModularVisualization +- mesa.visualization_old.modules To quickly spin up a model visualization, you might do something like: @@ -135,8 +135,8 @@ def agent_portrayal(agent): "r": 0.5} return portrayal -grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500) -server = mesa.visualization.ModularServer(MyModel, +grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500) +server = mesa.visualization_old.ModularServer(MyModel, [grid], "My Model", {'n_agents': 10}) diff --git a/docs/tutorials/adv_tutorial_legacy.ipynb b/docs/tutorials/adv_tutorial_legacy.ipynb index c0c26e422fb..92b04f9baf6 100644 --- a/docs/tutorials/adv_tutorial_legacy.ipynb +++ b/docs/tutorials/adv_tutorial_legacy.ipynb @@ -86,7 +86,7 @@ }, "outputs": [], "source": [ - "grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500)" + "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)" ] }, { @@ -109,7 +109,7 @@ "metadata": {}, "outputs": [], "source": [ - "server = mesa.visualization.ModularServer(\n", + "server = mesa.visualization_old.ModularServer(\n", " MoneyModel, [grid], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n", ")\n", "server.port = 8521 # the default" @@ -140,8 +140,8 @@ " \"r\": 0.5}\n", " return portrayal\n", "\n", - "grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n", - "server = mesa.visualization.ModularServer(MoneyModel,\n", + "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n", + "server = mesa.visualization_old.ModularServer(MoneyModel,\n", " [grid],\n", " \"Money Model\",\n", " {\"N\":100, \"width\":10, \"height\":10})\n", @@ -221,11 +221,11 @@ "metadata": {}, "outputs": [], "source": [ - "chart = mesa.visualization.ChartModule(\n", + "chart = mesa.visualization_old.ChartModule(\n", " [{\"Label\": \"Gini\", \"Color\": \"Black\"}], data_collector_name=\"datacollector\"\n", ")\n", "\n", - "server = mesa.visualization.ModularServer(\n", + "server = mesa.visualization_old.ModularServer(\n", " MoneyModel, [grid, chart], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n", ")" ] @@ -253,12 +253,12 @@ " return portrayal\n", "\n", "\n", - "grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n", - "chart = mesa.visualization.ChartModule(\n", + "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n", + "chart = mesa.visualization_old.ChartModule(\n", " [{\"Label\": \"Gini\", \"Color\": \"Black\"}], data_collector_name=\"datacollector\"\n", ")\n", "\n", - "server = mesa.visualization.ModularServer(\n", + "server = mesa.visualization_old.ModularServer(\n", " MoneyModel, [grid, chart], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n", ")\n", "server.port = 8521 # The default\n", @@ -421,7 +421,7 @@ "In a Python file (either its own, or in the same file as your visualization code), import the `VisualizationElement` class we'll inherit from, and create the new visualization class.\n", "\n", "```python\n", - " from mesa.visualization.ModularVisualization import VisualizationElement, CHART_JS_FILE\n", + " from mesa.visualization_old.ModularVisualization import VisualizationElement, CHART_JS_FILE\n", "\n", " class HistogramModule(VisualizationElement):\n", " package_includes = [CHART_JS_FILE]\n", @@ -438,7 +438,7 @@ " self.js_code = \"elements.push(\" + new_element + \");\"\n", "```\n", "\n", - "There are a few things going on here. `package_includes` is a list of JavaScript files that are part of Mesa itself that the visualization element relies on. You can see the included files in [mesa/visualization/templates/](https://github.com/projectmesa/mesa/tree/main/mesa/visualization/templates). Similarly, `local_includes` is a list of JavaScript files in the same directory as the class code itself. Note that both of these are class variables, not object variables -- they hold for all particular objects.\n", + "There are a few things going on here. `package_includes` is a list of JavaScript files that are part of Mesa itself that the visualization element relies on. You can see the included files in [mesa/visualization_old/templates/](https://github.com/projectmesa/mesa/tree/main/mesa/visualization_old/templates). Similarly, `local_includes` is a list of JavaScript files in the same directory as the class code itself. Note that both of these are class variables, not object variables -- they hold for all particular objects.\n", "\n", "Next, look at the `__init__` method. It takes three arguments: the number of bins, and the width and height for the histogram. It then uses these values to populate the `js_code` property; this is code that the server will insert into the visualization page, which will run when the page loads. In this case, it creates a new HistogramModule (the class we created in JavaScript in the step above) with the desired bins, width and height; it then appends (`push`es) this object to `elements`, the list of visualization elements that the visualization page itself maintains.\n", "\n", @@ -461,8 +461,8 @@ "Now, you can create your new HistogramModule and add it to the server:\n", "\n", "```python\n", - " histogram = mesa.visualization.HistogramModule(list(range(10)), 200, 500)\n", - " server = mesa.visualization.ModularServer(MoneyModel, \n", + " histogram = mesa.visualization_old.HistogramModule(list(range(10)), 200, 500)\n", + " server = mesa.visualization_old.ModularServer(MoneyModel, \n", " [grid, histogram, chart], \n", " \"Money Model\", \n", " {\"N\":100, \"width\":10, \"height\":10})\n", @@ -473,7 +473,7 @@ "\n", "![Histogram Visualization](files/viz_histogram.png)\n", "\n", - "If you've felt comfortable with this section, it might be instructive to read the code for the [ModularServer](https://github.com/projectmesa/mesa/blob/main/mesa/visualization/ModularVisualization.py#L259) and the [modular_template](https://github.com/projectmesa/mesa/blob/main/mesa/visualization/templates/modular_template.html) to get a better idea of how all the pieces fit together." + "If you've felt comfortable with this section, it might be instructive to read the code for the [ModularServer](https://github.com/projectmesa/mesa/blob/main/mesa/visualization_old/ModularVisualization.py#L259) and the [modular_template](https://github.com/projectmesa/mesa/blob/main/mesa/visualization_old/templates/modular_template.html) to get a better idea of how all the pieces fit together." ] }, { diff --git a/mesa/__init__.py b/mesa/__init__.py index e4b92ab093d..529507e1f8b 100644 --- a/mesa/__init__.py +++ b/mesa/__init__.py @@ -8,7 +8,7 @@ import mesa.space as space import mesa.time as time -import mesa.visualization as visualization +import mesa.visualization_old as visualization_old from mesa.agent import Agent from mesa.batchrunner import batch_run from mesa.datacollection import DataCollector @@ -19,7 +19,7 @@ "Agent", "time", "space", - "visualization", + "visualization_old", "DataCollector", "batch_run", "experimental", diff --git a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate index 832f2df7db4..7b168b6d63b 100644 --- a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate +++ b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate @@ -21,14 +21,14 @@ def circle_portrayal_example(agent): return portrayal -canvas_element = mesa.visualization.CanvasGrid( +canvas_element = mesa.visualization_old.CanvasGrid( circle_portrayal_example, 20, 20, 500, 500 ) -chart_element = mesa.visualization.ChartModule([{"Label": "{{ cookiecutter.camel }}", "Color": "Pink"}]) +chart_element = mesa.visualization_old.ChartModule([{"Label": "{{ cookiecutter.camel }}", "Color": "Pink"}]) model_kwargs = {"num_agents": 10, "width": 10, "height": 10} -server = mesa.visualization.ModularServer( +server = mesa.visualization_old.ModularServer( {{cookiecutter.model}}, [canvas_element, chart_element], "{{ cookiecutter.camel }}", diff --git a/mesa/visualization/ModularVisualization.py b/mesa/visualization_old/ModularVisualization.py similarity index 100% rename from mesa/visualization/ModularVisualization.py rename to mesa/visualization_old/ModularVisualization.py diff --git a/mesa/visualization/TextVisualization.py b/mesa/visualization_old/TextVisualization.py similarity index 100% rename from mesa/visualization/TextVisualization.py rename to mesa/visualization_old/TextVisualization.py diff --git a/mesa/visualization/UserParam.py b/mesa/visualization_old/UserParam.py similarity index 100% rename from mesa/visualization/UserParam.py rename to mesa/visualization_old/UserParam.py diff --git a/mesa/visualization/__init__.py b/mesa/visualization_old/__init__.py similarity index 100% rename from mesa/visualization/__init__.py rename to mesa/visualization_old/__init__.py diff --git a/mesa/visualization/modules.py b/mesa/visualization_old/modules.py similarity index 100% rename from mesa/visualization/modules.py rename to mesa/visualization_old/modules.py diff --git a/tests/test_import_namespace.py b/tests/test_import_namespace.py index f9489711004..20a06f3368d 100644 --- a/tests/test_import_namespace.py +++ b/tests/test_import_namespace.py @@ -15,9 +15,9 @@ def test_import(): _ = MultiGrid _ = mf.MultiGrid - from mesa.visualization.ModularVisualization import ModularServer + from mesa.visualization_old.ModularVisualization import ModularServer - _ = mesa.visualization.ModularServer + _ = mesa.visualization_old.ModularServer _ = ModularServer _ = mf.ModularServer diff --git a/tests/test_main.py b/tests/test_main.py index 9f7f9834626..b6bba370bf3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -24,7 +24,7 @@ def tearDown(self): "Skipping test_run, because examples folder was moved. More discussion needed." ) def test_run(self): - with patch("mesa.visualization.ModularServer") as ModularServer: # noqa: N806 + with patch("mesa.visualization_old.ModularServer") as ModularServer: # noqa: N806 example_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), "../examples/wolf_sheep") ) diff --git a/tests/test_tornado.py b/tests/test_tornado.py index 24025a586be..52f40417455 100644 --- a/tests/test_tornado.py +++ b/tests/test_tornado.py @@ -4,7 +4,7 @@ from tornado.testing import AsyncHTTPTestCase from mesa import Model -from mesa.visualization.ModularVisualization import ModularServer +from mesa.visualization_old.ModularVisualization import ModularServer class TestServer(AsyncHTTPTestCase): diff --git a/tests/test_usersettableparam.py b/tests/test_usersettableparam.py index 8f02fa4b0b6..52ba971ef2a 100644 --- a/tests/test_usersettableparam.py +++ b/tests/test_usersettableparam.py @@ -1,6 +1,6 @@ from unittest import TestCase -from mesa.visualization.UserParam import ( +from mesa.visualization_old.UserParam import ( Checkbox, Choice, NumberInput, diff --git a/tests/test_visualization.py b/tests/test_visualization.py index ca9f0949558..b04a76243f2 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -5,9 +5,9 @@ from mesa.model import Model from mesa.space import MultiGrid from mesa.time import SimultaneousActivation -from mesa.visualization.ModularVisualization import ModularServer -from mesa.visualization.modules import CanvasGrid, TextElement -from mesa.visualization.UserParam import ( +from mesa.visualization_old.ModularVisualization import ModularServer +from mesa.visualization_old.modules import CanvasGrid, TextElement +from mesa.visualization_old.UserParam import ( NumberInput, Slider, ) From 152b32c9f4593c6dd13b218622f9732074140ead Mon Sep 17 00:00:00 2001 From: rht Date: Wed, 27 Mar 2024 03:59:15 -0400 Subject: [PATCH 23/50] Set JupyterViz as stable --- docs/tutorials/visualization_tutorial.ipynb | 2 +- mesa/experimental/__init__.py | 3 +-- mesa/{experimental => visualization}/UserParam.py | 0 mesa/visualization/__init__.py | 4 ++++ mesa/{experimental => visualization}/components/altair.py | 0 .../components/matplotlib.py | 0 mesa/{experimental => visualization}/jupyter_viz.py | 6 +++--- tests/test_jupyter_viz.py | 4 ++-- 8 files changed, 11 insertions(+), 8 deletions(-) rename mesa/{experimental => visualization}/UserParam.py (100%) create mode 100644 mesa/visualization/__init__.py rename mesa/{experimental => visualization}/components/altair.py (100%) rename mesa/{experimental => visualization}/components/matplotlib.py (100%) rename mesa/{experimental => visualization}/jupyter_viz.py (98%) diff --git a/docs/tutorials/visualization_tutorial.ipynb b/docs/tutorials/visualization_tutorial.ipynb index f19b21421ef..493324eae18 100644 --- a/docs/tutorials/visualization_tutorial.ipynb +++ b/docs/tutorials/visualization_tutorial.ipynb @@ -119,7 +119,7 @@ }, "outputs": [], "source": [ - "from mesa.experimental import JupyterViz\n", + "from mesa.visualization import JupyterViz\n", "\n", "page = JupyterViz(\n", " BoltzmannWealthModel,\n", diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py index 961b8762791..37e0202b888 100644 --- a/mesa/experimental/__init__.py +++ b/mesa/experimental/__init__.py @@ -1,5 +1,4 @@ -from .jupyter_viz import JupyterViz, make_text, Slider # noqa from mesa.experimental import cell_space -__all__ = ["JupyterViz", "make_text", "Slider", "cell_space"] +__all__ = ["cell_space"] diff --git a/mesa/experimental/UserParam.py b/mesa/visualization/UserParam.py similarity index 100% rename from mesa/experimental/UserParam.py rename to mesa/visualization/UserParam.py diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py new file mode 100644 index 00000000000..2e36d7186b0 --- /dev/null +++ b/mesa/visualization/__init__.py @@ -0,0 +1,4 @@ +from .jupyter_viz import JupyterViz, make_text, Slider + + +__all__ = ["JupyterViz", "make_text", "Slider"] diff --git a/mesa/experimental/components/altair.py b/mesa/visualization/components/altair.py similarity index 100% rename from mesa/experimental/components/altair.py rename to mesa/visualization/components/altair.py diff --git a/mesa/experimental/components/matplotlib.py b/mesa/visualization/components/matplotlib.py similarity index 100% rename from mesa/experimental/components/matplotlib.py rename to mesa/visualization/components/matplotlib.py diff --git a/mesa/experimental/jupyter_viz.py b/mesa/visualization/jupyter_viz.py similarity index 98% rename from mesa/experimental/jupyter_viz.py rename to mesa/visualization/jupyter_viz.py index a4dec7f6a9a..2067dcfd88c 100644 --- a/mesa/experimental/jupyter_viz.py +++ b/mesa/visualization/jupyter_viz.py @@ -6,9 +6,9 @@ import solara from solara.alias import rv -import mesa.experimental.components.altair as components_altair -import mesa.experimental.components.matplotlib as components_matplotlib -from mesa.experimental.UserParam import Slider +import mesa.visualization.components.altair as components_altair +import mesa.visualization.components.matplotlib as components_matplotlib +from mesa.visualization.UserParam import Slider # Avoid interactive backend plt.switch_backend("agg") diff --git a/tests/test_jupyter_viz.py b/tests/test_jupyter_viz.py index f67daa09518..76ee666ee8a 100644 --- a/tests/test_jupyter_viz.py +++ b/tests/test_jupyter_viz.py @@ -5,7 +5,7 @@ import solara import mesa -from mesa.experimental.jupyter_viz import JupyterViz, Slider, UserInputs +from mesa.visualization.jupyter_viz import JupyterViz, Slider, UserInputs class TestMakeUserInput(unittest.TestCase): @@ -84,7 +84,7 @@ def Test(user_params): def test_call_space_drawer(mocker): mock_space_matplotlib = mocker.patch( - "mesa.experimental.components.matplotlib.SpaceMatplotlib" + "mesa.visualization.components.matplotlib.SpaceMatplotlib" ) model = mesa.Model() From 69802b1a45739db836bd61478f2d15a76847cf12 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 8 May 2024 05:13:44 +0000 Subject: [PATCH 24/50] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/experimental/__init__.py | 1 - mesa/visualization/__init__.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py index 37e0202b888..e37cdb042e3 100644 --- a/mesa/experimental/__init__.py +++ b/mesa/experimental/__init__.py @@ -1,4 +1,3 @@ from mesa.experimental import cell_space - __all__ = ["cell_space"] diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py index 2e36d7186b0..f6611e0a039 100644 --- a/mesa/visualization/__init__.py +++ b/mesa/visualization/__init__.py @@ -1,4 +1,3 @@ -from .jupyter_viz import JupyterViz, make_text, Slider - +from .jupyter_viz import JupyterViz, Slider, make_text __all__ = ["JupyterViz", "make_text", "Slider"] From c5feb5524293f9bd9bd019ff6e94f0b1e33df065 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 8 May 2024 08:57:28 +0200 Subject: [PATCH 25/50] datacollector: store separate snapshots of model data per step (#2129) * datecollector: store separate snapshots of model data per step - Ensure `DataCollector` stores unique copies of the data at each model step. - Use `deepcopy` in `collect` method to avoid storing references that lead to each step holding the same data. - Fixes the issue where PropertyLayer data was stored identically for all steps, preventing accurate visualization and analysis of model progression. * datacollection: Add deepcopy comment --- mesa/datacollection.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/mesa/datacollection.py b/mesa/datacollection.py index 3082a67db6c..712482af8e2 100644 --- a/mesa/datacollection.py +++ b/mesa/datacollection.py @@ -36,6 +36,7 @@ import contextlib import itertools import types +from copy import deepcopy from functools import partial with contextlib.suppress(ImportError): @@ -197,17 +198,21 @@ def collect(self, model): for var, reporter in self.model_reporters.items(): # Check if lambda or partial function if isinstance(reporter, types.LambdaType | partial): - self.model_vars[var].append(reporter(model)) + # Use deepcopy to store a copy of the data, + # preventing references from being updated across steps. + self.model_vars[var].append(deepcopy(reporter(model))) # Check if model attribute elif isinstance(reporter, str): - self.model_vars[var].append(getattr(model, reporter, None)) + self.model_vars[var].append( + deepcopy(getattr(model, reporter, None)) + ) # Check if function with arguments elif isinstance(reporter, list): - self.model_vars[var].append(reporter[0](*reporter[1])) - # TODO: Check if method of a class, as of now it is assumed - # implicitly if the other checks fail. + self.model_vars[var].append(deepcopy(reporter[0](*reporter[1]))) + # Assume it's a callable otherwise (e.g., method) + # TODO: Check if method of a class explicitly else: - self.model_vars[var].append(reporter()) + self.model_vars[var].append(deepcopy(reporter())) if self.agent_reporters: agent_records = self._record_agents(model) From 911c8539a80a7e79d4f5e74c3c774e85ad5d14b9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 8 May 2024 03:42:21 -0400 Subject: [PATCH 26/50] [pre-commit.ci] pre-commit autoupdate (#2131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.4.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.4.3) - [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fa4ea7fa879..0cbd083d5e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.3.5 + rev: v0.4.3 hooks: # Run the linter. - id: ruff @@ -19,7 +19,7 @@ repos: - id: pyupgrade args: [--py310-plus] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 # Use the ref you want to point at + rev: v4.6.0 # Use the ref you want to point at hooks: - id: trailing-whitespace - id: check-toml From af59fdd6af5e7a862caf07600c80504be6f12c8f Mon Sep 17 00:00:00 2001 From: Jackie Kazil Date: Tue, 21 May 2024 02:50:34 -0400 Subject: [PATCH 27/50] Fix image on landing page of docs. (#2146) --- docs/index.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/index.md b/docs/index.md index 237ce20fdd3..313978023ab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,11 +20,9 @@ Mesa allows users to quickly create agent-based models using built-in core components (such as spatial grids and agent schedulers) or customized implementations; visualize them using a browser-based interface; and analyze their results using Python's data analysis tools. Its goal is to be the Python-based counterpart to NetLogo, Repast, or MASON. -```{image} https://raw.githubusercontent.com/projectmesa/mesa/main/docs/images/Mesa_Screenshot.png -:alt: A screenshot of the Schelling Model in Mesa -:scale: 100% -:width: 100% -``` + +![A screenshot of the Schelling Model in Mesa|100%](https://raw.githubusercontent.com/projectmesa/mesa/main/docs/images/Mesa_Screenshot.png) + *Above: A Mesa implementation of the Schelling segregation model, being visualized in a browser window and analyzed in a Jupyter From 502b2568901a5ea870f337f8f0b44c23296006c4 Mon Sep 17 00:00:00 2001 From: Jackie Kazil Date: Wed, 22 May 2024 02:09:47 -0400 Subject: [PATCH 28/50] Replace links in docs - google group to matrix. (#2148) --- docs/index.md | 4 ++-- docs/packages.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 313978023ab..117dd906729 100644 --- a/docs/index.md +++ b/docs/index.md @@ -53,7 +53,7 @@ For more help on using Mesa, check out the following resources: - [Mesa Introductory Tutorial] - [Mesa Visualization Tutorial] - [GitHub Issue Tracker] -- [Email list] +- [Matrix chat room] - [PyPI] ## Contributing back to Mesa @@ -93,9 +93,9 @@ tutorials/adv_tutorial_legacy.ipynb - {ref}`search` [contributors guide]: https://github.com/projectmesa/mesa/blob/main/CONTRIBUTING.md -[email list]: https://groups.google.com/d/forum/projectmesa [github]: https://github.com/projectmesa/mesa/ [github issue tracker]: https://github.com/projectmesa/mesa/issues +[matrix chat room]: https://matrix.to/#/#project-mesa:matrix.org [mesa]: https://github.com/projectmesa/mesa/ [mesa introductory tutorial]: tutorials/intro_tutorial.html [mesa visualization tutorial]: tutorials/visualization_tutorial.html diff --git a/docs/packages.md b/docs/packages.md index e15c6baac4f..8b13b30d613 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -91,7 +91,7 @@ Most likely you created an ABM that has the code that you want to share in it, w > > 7. `git add` all the files to the repo, which means the repo starts to track the files. Then `git commit` the files with a meaningful message. To learn more about this see: [Saving changes](https://www.atlassian.com/git/tutorials/saving-changes). Finally, you will want to `git push` all your changes to GitHub, see: [Git Push](https://help.github.com/articles/pushing-to-a-remote/). > -> 8. Let people know about your package on the [MESA Wiki Page](https://github.com/projectmesa/mesa/wiki) and share it on the [email list](https://groups.google.com/forum/#!forum/projectmesa). In the future, we will create more of a directory, but at this point we are not there yet. +> 8. Let people know about your package on the [MESA Wiki Page](https://github.com/projectmesa/mesa/wiki) and share it in the [matrix chat room](https://matrix.to/#/#project-mesa:matrix.org). In the future, we will create more of a directory, but at this point we are not there yet. From this point, someone can clone your repo and then add your repo to their Python path and use it in their project. However, if you want to take your package to the next level, you will want to add more structure to your package and share it on PyPI. From 1af245a4a5459d5d7d4d7719958eb173df09d432 Mon Sep 17 00:00:00 2001 From: Stephen Mann Date: Tue, 11 Jun 2024 12:58:46 +0200 Subject: [PATCH 29/50] Add experimental features to the docs (#2154) Adding the objects from experimental.cell_space and experimental.devs to the ReadTheDocs documentation. --- docs/apis/api_main.md | 1 + docs/apis/experimental.md | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 docs/apis/experimental.md diff --git a/docs/apis/api_main.md b/docs/apis/api_main.md index ae77d47a466..e2e45242f68 100644 --- a/docs/apis/api_main.md +++ b/docs/apis/api_main.md @@ -9,4 +9,5 @@ space datacollection batchrunner visualization +experimental ``` diff --git a/docs/apis/experimental.md b/docs/apis/experimental.md new file mode 100644 index 00000000000..0d20ba6f554 --- /dev/null +++ b/docs/apis/experimental.md @@ -0,0 +1,60 @@ +# Experimental + +```{eval-rst} +.. automodule:: experimental.__init__ + :members: +``` + +## Cell Space + +```{eval-rst} +.. automodule:: experimental.cell_space.__init__ + :members: +``` + +```{eval-rst} +.. automodule:: experimental.cell_space.cell + :members: +``` + +```{eval-rst} +.. automodule:: experimental.cell_space.cell_agent + :members: +``` + +```{eval-rst} +.. automodule:: experimental.cell_space.cell_collection + :members: +``` + +```{eval-rst} +.. automodule:: experimental.cell_space.discrete_space + :members: +``` + +```{eval-rst} +.. automodule:: experimental.cell_space.grid + :members: +``` + +```{eval-rst} +.. automodule:: experimental.cell_space.network + :members: +``` + +## Devs + +```{eval-rst} +.. automodule:: experimental.devs.__init__ + :members: +``` + +```{eval-rst} +.. automodule:: experimental.devs.eventlist + :members: +``` + +```{eval-rst} +.. automodule:: experimental.devs.simulator + :members: +``` From 74cec558e287a56b22b6c3f0f6a5a79e7954e8fe Mon Sep 17 00:00:00 2001 From: rht Date: Wed, 21 Feb 2024 23:25:19 -0500 Subject: [PATCH 30/50] Add script to list unabeled PR's since latest release --- maintenance/fetch_unlabeled_prs.py | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 maintenance/fetch_unlabeled_prs.py diff --git a/maintenance/fetch_unlabeled_prs.py b/maintenance/fetch_unlabeled_prs.py new file mode 100644 index 00000000000..281dc952571 --- /dev/null +++ b/maintenance/fetch_unlabeled_prs.py @@ -0,0 +1,79 @@ +import os +from datetime import datetime + +import requests + +# Configuration +# Your GitHub Personal Access Token +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +if GITHUB_TOKEN is None: + print("Please specify your GitHub Personal Access Token as GITHUB_TOKEN in your .bashrc") + exit() +GITHUB_USERNAME = "projectmesa" +GITHUB_REPO = "mesa" +HEADERS = {"Authorization": f"token {GITHUB_TOKEN}"} +TIMEOUT = 20 + + +def get_latest_release_date() -> str: + """Fetches the latest release date from the GitHub repository.""" + url = ( + f"https://api.github.com/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/releases/latest" + ) + response = requests.get(url, headers=HEADERS, timeout=TIMEOUT) + response.raise_for_status() # Raises an exception for HTTP error codes + return response.json()["published_at"] + + +def get_closed_pull_requests_since_latest_release( + latest_release_date, +) -> list[dict[str, any]]: + """Fetches pull requests created or updated after the latest release date, then filters by merged date.""" + pull_requests = [] + page = 1 + while True: + # Fetch PRs that were created or updated after the latest release date + url = f"https://api.github.com/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/pulls?state=closed&base=main&sort=updated&direction=desc&page={page}" + response = requests.get(url, headers=HEADERS, timeout=TIMEOUT) + response.raise_for_status() + prs = response.json() + if not prs: + break + + # Convert latest release date to datetime for comparison + latest_release_datetime = datetime.strptime( + latest_release_date, "%Y-%m-%dT%H:%M:%SZ" + ).astimezone() + + for pr in prs: + # Convert PR's `updated_at` to datetime for comparison + pr_updated_at = datetime.strptime( + pr["updated_at"], "%Y-%m-%dT%H:%M:%SZ" + ).astimezone() + # Stop fetching if PR was updated before the latest release + if pr_updated_at < latest_release_datetime: + return pull_requests + + if pr["merged_at"]: + pr_merged_at = datetime.strptime( + pr["merged_at"], "%Y-%m-%dT%H:%M:%SZ" + ).astimezone() + if pr_merged_at > latest_release_datetime and not pr["labels"]: + pull_requests.append(pr) + page += 1 + return pull_requests + + +def main() -> None: + # Based on https://github.com/projectmesa/mesa/pull/1917#issuecomment-1871352058 + latest_release_date = get_latest_release_date() + pull_requests = get_closed_pull_requests_since_latest_release(latest_release_date) + if len(pull_requests) <= 0: + return + print("These pull requests must be labeled:") + for pr in pull_requests: + print(f" PR #{pr['number']}: {pr['title']} - Merged at: {pr['merged_at']}") + + +if __name__ == "__main__": + main() From 069bfa18839c72a8add24e70f35c0c3c6c82f38b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 22 Feb 2024 04:37:19 +0000 Subject: [PATCH 31/50] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- maintenance/fetch_unlabeled_prs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/maintenance/fetch_unlabeled_prs.py b/maintenance/fetch_unlabeled_prs.py index 281dc952571..0b01a291a01 100644 --- a/maintenance/fetch_unlabeled_prs.py +++ b/maintenance/fetch_unlabeled_prs.py @@ -7,7 +7,9 @@ # Your GitHub Personal Access Token GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") if GITHUB_TOKEN is None: - print("Please specify your GitHub Personal Access Token as GITHUB_TOKEN in your .bashrc") + print( + "Please specify your GitHub Personal Access Token as GITHUB_TOKEN in your .bashrc" + ) exit() GITHUB_USERNAME = "projectmesa" GITHUB_REPO = "mesa" From 32407f1996d606d00863e5509cc1f68ad10484da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 22:40:19 -0400 Subject: [PATCH 32/50] [pre-commit.ci] pre-commit autoupdate (#2151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.3 → v0.5.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.3...v0.5.0) - [github.com/asottile/pyupgrade: v3.15.2 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.15.2...v3.16.0) - [github.com/codespell-project/codespell: v2.2.6 → v2.3.0](https://github.com/codespell-project/codespell/compare/v2.2.6...v2.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cbd083d5e8..7e61b08680d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.3 + rev: v0.5.0 hooks: # Run the linter. - id: ruff @@ -14,7 +14,7 @@ repos: - id: ruff-format types_or: [ python, pyi, jupyter ] - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.16.0 hooks: - id: pyupgrade args: [--py310-plus] @@ -25,7 +25,7 @@ repos: - id: check-toml - id: check-yaml - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell args: [ From b5d7d969ff3dcac93351294abab884292d222451 Mon Sep 17 00:00:00 2001 From: rht Date: Wed, 3 Jul 2024 06:42:50 -0400 Subject: [PATCH 33/50] Remove mesa.flat namespace (#2091) This doesn't seem to be used as often as the current simple namespace. --- mesa/flat/__init__.py | 6 ------ mesa/flat/visualization.py | 5 ----- tests/test_import_namespace.py | 5 ----- 3 files changed, 16 deletions(-) delete mode 100644 mesa/flat/__init__.py delete mode 100644 mesa/flat/visualization.py diff --git a/mesa/flat/__init__.py b/mesa/flat/__init__.py deleted file mode 100644 index 728d82734bc..00000000000 --- a/mesa/flat/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from mesa import * # noqa -from mesa.time import * # noqa -from mesa.space import * # noqa -from mesa.datacollection import * # noqa -from .visualization import * # noqa -from mesa.batchrunner import batch_run # noqa diff --git a/mesa/flat/visualization.py b/mesa/flat/visualization.py deleted file mode 100644 index 2eb7b802c5e..00000000000 --- a/mesa/flat/visualization.py +++ /dev/null @@ -1,5 +0,0 @@ -# This collects all of Mesa visualization components under a flat namespace. -from mesa_viz_tornado.ModularVisualization import * # noqa -from mesa_viz_tornado.modules import * # noqa -from mesa_viz_tornado.UserParam import * # noqa -from mesa_viz_tornado.TextVisualization import * # noqa diff --git a/tests/test_import_namespace.py b/tests/test_import_namespace.py index 20a06f3368d..6a001319f24 100644 --- a/tests/test_import_namespace.py +++ b/tests/test_import_namespace.py @@ -2,30 +2,25 @@ def test_import(): # This tests the new, simpler Mesa namespace. See # https://github.com/projectmesa/mesa/pull/1294. import mesa - import mesa.flat as mf from mesa.time import RandomActivation _ = mesa.time.RandomActivation _ = RandomActivation - _ = mf.RandomActivation from mesa.space import MultiGrid _ = mesa.space.MultiGrid _ = MultiGrid - _ = mf.MultiGrid from mesa.visualization_old.ModularVisualization import ModularServer _ = mesa.visualization_old.ModularServer _ = ModularServer - _ = mf.ModularServer from mesa.datacollection import DataCollector _ = DataCollector _ = mesa.DataCollector - _ = mf.DataCollector from mesa.batchrunner import batch_run From 5694d54fde7e9fd833a9c927a28286a8fafc9e65 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 3 Jul 2024 12:52:53 +0200 Subject: [PATCH 34/50] Fix pre-commit issues: Codespel and typecheck (#2161) Fixes two things pre-commit detected: - Some codespell errors - A case of E721 Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks --- .codespellignore | 1 + CODE_OF_CONDUCT.md | 2 +- docs/tutorials/adv_tutorial_legacy.ipynb | 2 +- mesa/visualization/UserParam.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.codespellignore b/.codespellignore index 94a0147d024..ce210f88049 100644 --- a/.codespellignore +++ b/.codespellignore @@ -6,3 +6,4 @@ inactivate ue fpr falsy +assertIn diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 63fd8cc5dde..d3bc0186184 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -5,7 +5,7 @@ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, +identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. diff --git a/docs/tutorials/adv_tutorial_legacy.ipynb b/docs/tutorials/adv_tutorial_legacy.ipynb index 92b04f9baf6..e29d2732413 100644 --- a/docs/tutorials/adv_tutorial_legacy.ipynb +++ b/docs/tutorials/adv_tutorial_legacy.ipynb @@ -158,7 +158,7 @@ "\n", "![Redcircles Visualization](files/viz_redcircles.png)\n", "\n", - "Click `Step` to advance the model by one step, and the agents will move around. Click `Start` and the agents will keep moving around, at the rate set by the 'fps' (frames per second) slider at the top. Try moving it around and see how the speed of the model changes. Pressing `Stop` will pause the model; presing `Start` again will restart it. Finally, `Reset` will start a new instantiation of the model.\n", + "Click `Step` to advance the model by one step, and the agents will move around. Click `Start` and the agents will keep moving around, at the rate set by the 'fps' (frames per second) slider at the top. Try moving it around and see how the speed of the model changes. Pressing `Stop` will pause the model; pressing `Start` again will restart it. Finally, `Reset` will start a new instantiation of the model.\n", "\n", "To stop the visualization server, go back to the terminal where you launched it, and press Control+c." ] diff --git a/mesa/visualization/UserParam.py b/mesa/visualization/UserParam.py index 58715f32156..5b342471ddb 100644 --- a/mesa/visualization/UserParam.py +++ b/mesa/visualization/UserParam.py @@ -47,7 +47,7 @@ def __init__( if dtype is None: self.is_float_slider = self._check_values_are_float(value, min, max, step) else: - self.is_float_slider = dtype == float + self.is_float_slider = dtype is float def _check_values_are_float(self, value, min, max, step): return any(isinstance(n, float) for n in (value, min, max, step)) From 6fda3fc7f94253aaf8190f799a5f42bcea230dcf Mon Sep 17 00:00:00 2001 From: rht Date: Wed, 3 Jul 2024 07:10:25 -0400 Subject: [PATCH 35/50] Remove visualization_old (mesa-viz-tornado) (#2133) Removing the old, legacy visualisation. It will still be available in 2.3.x. Checkout the Visualization Tutorial (https://mesa.readthedocs.io/en/latest/tutorials/visualization_tutorial.html) to migrate to the new visualisation. --- docs/index.md | 1 - docs/mesa.md | 6 - docs/mesa.visualization_old.md | 46 -- docs/mesa.visualization_old.modules.md | 76 --- docs/modular-visualization.md | 249 --------- docs/overview.md | 30 -- docs/tutorials/adv_tutorial_legacy.ipynb | 510 ------------------ docs/tutorials/intro_tutorial.ipynb | 4 +- mesa/__init__.py | 2 - .../{{cookiecutter.snake}}/app.pytemplate | 27 + .../{{cookiecutter.snake}}/run.pytemplate | 3 - .../{{cookiecutter.snake}}/model.pytemplate | 2 +- .../{{cookiecutter.snake}}/server.pytemplate | 36 -- pyproject.toml | 1 - tests/test_import_namespace.py | 5 - tests/test_tornado.py | 41 -- tests/test_usersettableparam.py | 57 -- tests/test_visualization.py | 105 ---- 18 files changed, 29 insertions(+), 1172 deletions(-) delete mode 100644 docs/mesa.visualization_old.md delete mode 100644 docs/mesa.visualization_old.modules.md delete mode 100644 docs/modular-visualization.md delete mode 100644 docs/tutorials/adv_tutorial_legacy.ipynb create mode 100644 mesa/cookiecutter-mesa/{{cookiecutter.snake}}/app.pytemplate delete mode 100644 mesa/cookiecutter-mesa/{{cookiecutter.snake}}/run.pytemplate delete mode 100644 mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate delete mode 100644 tests/test_tornado.py delete mode 100644 tests/test_usersettableparam.py delete mode 100644 tests/test_visualization.py diff --git a/docs/index.md b/docs/index.md index 117dd906729..14bc0ba026b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -83,7 +83,6 @@ Best Practices How-to Guide API Documentation Mesa Packages -tutorials/adv_tutorial_legacy.ipynb ``` # Indices and tables diff --git a/docs/mesa.md b/docs/mesa.md index 6c3e8cc8696..512bb23f208 100644 --- a/docs/mesa.md +++ b/docs/mesa.md @@ -1,11 +1,5 @@ # mesa package -## Subpackages - -```{toctree} -mesa.visualization_old -``` - ## Submodules ## mesa.agent module diff --git a/docs/mesa.visualization_old.md b/docs/mesa.visualization_old.md deleted file mode 100644 index 700eabf12c9..00000000000 --- a/docs/mesa.visualization_old.md +++ /dev/null @@ -1,46 +0,0 @@ -# mesa.visualization_old package - -## Subpackages - -```{toctree} -mesa.visualization_old.modules -``` - -## Submodules - -## mesa.visualization_old.ModularVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.ModularVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.TextVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.TextVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.UserParam module - -```{eval-rst} -.. automodule:: mesa.visualization_old.UserParam - :members: - :undoc-members: - :show-inheritance: - -``` - -## Module contents - -```{eval-rst} -.. automodule:: mesa.visualization_old - :members: - :undoc-members: - :show-inheritance: -``` diff --git a/docs/mesa.visualization_old.modules.md b/docs/mesa.visualization_old.modules.md deleted file mode 100644 index 55d94b64e28..00000000000 --- a/docs/mesa.visualization_old.modules.md +++ /dev/null @@ -1,76 +0,0 @@ -# mesa.visualization_old.modules package - -## Submodules - -## mesa.visualization_old.modules.BarChartVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules.BarChartVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.modules.CanvasGridVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules.CanvasGridVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.modules.ChartVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules.ChartVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.modules.HexGridVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules.HexGridVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.modules.NetworkVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules.NetworkVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.modules.PieChartVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules.PieChartVisualization - :members: - :undoc-members: - :show-inheritance: -``` - -## mesa.visualization_old.modules.TextVisualization module - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules.TextVisualization - :members: - :undoc-members: - :show-inheritance: - -``` - -## Module contents - -```{eval-rst} -.. automodule:: mesa.visualization_old.modules - :members: - :undoc-members: - :show-inheritance: -``` diff --git a/docs/modular-visualization.md b/docs/modular-visualization.md deleted file mode 100644 index 716c2f1b6e3..00000000000 --- a/docs/modular-visualization.md +++ /dev/null @@ -1,249 +0,0 @@ -# Modular Visualization - An In-Depth Look (Deprecated) - -Modular visualization is one of Mesa's core features. Mesa is designed -to provide predefined visualization modules, which can be easily -subclassed for your needs, and mixed-and-matched to visualize your -particular model. (Some day, Mesa hopes to host a wide variety.) This -document describes how to use and create new visualization modules. - -## Overview - -An interactive modular visualization is a set of **Elements**, each of -which is an instance of a visualization **Module**. To visualize a -model, create a new ModularServer with a list of the elements you want -to visualize, in the order you want them to appear on the visualization -web page. - -For example, if you have a model `MyModel`, and two elements, -*canvas_vis* and *graph_vis*, you would create a visualization with -them via: - -```python -server = ModularServer(MyModel, [canvas_vis, graph_vis]) -server.launch() -``` - -Then you will be able to view the elements in your browser at -. If you prefer a different port, for example -8887, you can pass it to the server as an argument. - -```python -server.launch(8887) -``` - -Under the hood, each visualization module consists of two parts: - -1. **Data rending** - Python code which can take a model object and - renders it into some data describing the visualization. -2. **Browser rendering** - JavaScript object which in turn receives data - from the Python render (via the ModularServer) and actually draws it - in the browser. - -## Using Pre-Built Modules - -Mesa already comes with some pre-built modules. Using the built-ins -allow you to build a visualization without worrying about the HTML and -javascript. Consult the documentation for a variety of modules. - -One built-in module is **CanvasGrid**, which you can use to visualize -objects located on grid cells. The CanvasGrid will cover a majority of -agent-based models, particularly the simpler ones. - -CanvasGrid iterates over every object in every cell of your model's grid -(it assumes that your model has a grid named **grid**) and converts it -into a dictionary which defines how it will be drawn. It does this via a -**portrayal_method**: a function which the user defines, which takes an -object as an input and outputs a dictionary with the following keys: - -``` -"Shape": Can be "circle", "rect" or "arrowHead" - For Circles: - "r": The radius, defined as a fraction of cell size. r=1 will fill the entire cell. - For rectangles: - "w", "h": The width and height of the rectangle, which are in fractions of cell width and height. - For arrowHead: - "scale": Proportion scaling as a fraction of cell size. - "heading_x": represents x direction unit vector. - "heading_y": represents y direction unit vector. -"Color": The color to draw the shape in; needs to be a valid HTML color, e.g."Red" or "#AA08F8" -"Filled": either "true" or "false", and determines whether the shape is filled or not. -"Layer": Layer number of 0 or above; higher-numbered layers are drawn above lower-numbered layers. -"text": Text to overlay on top of the shape. Normally, agent's unique_id is used . -"text_color": Color of the text overlay. -(Shapes also have "x" and "y" coordinates, for the x and y of the grid cell in which it is, but CanvasGrid adds those automatically). -``` - -For example, suppose for a Schelling model, we want to draw all agents -as circles; red ones for the majority (agent type=0), and blue ones for -the minority (agent type=1). The function to do this might look like -this: - -```python -def schelling_draw(agent): - if agent is None: - return - portrayal = {"Shape": "circle", "r": 0.5, "Filled": "true", "Layer": 0} - if agent.type == 0: - portrayal["Color"] = "Red" - else: - portrayal["Color"] = "Blue" - return portrayal -``` - -In addition, a CanvasGrid needs to know the width and height of the grid -(in number of cells), and the width and height in pixels of the grid to -draw in the browser. - -To continue our Schelling example: suppose we have a Schelling model -with a grid of 10x10, which we want to draw at 500px X 500px. using the -portrayal function we wrote above, we would instantiate our -visualization element as follows: - -```python -canvas_element = CanvasGrid(schelling_draw, 10, 10, 500, 500) -``` - -Then, to launch a server with this grid as the only visualization -element: - -```python -server = ModularServer(SchellingModel, [canvas_element], "Schelling") -server.launch() -``` - -## Sub-Classing Modules - -In some cases, you may want to customize the internals of an existing -visualization module. The best way to do this is to create a subclass of -it. - -For example, the TextElement module provides an HTML template to render -raw text, but nothing else. To use it, we need to create our own -subclass, which implements a **render** method to get -visualization-ready data (in this case, just a text string) out of a -model object. - -Suppose we want a module which can get an arbitrary variable out of a -model, and display its name and value. Let's create a new subclass: - -```python -from mesa.visualization_old.ModularTextVisualization import TextElement - -class AttributeElement(TextElement): - def __init__(self, attr_name): - ''' - Create a new text attribute element. - - Args: - attr_name: The name of the attribute to extract from the model. - - Example return: "happy: 10" - ''' - self.attr_name = attr_name - - def render(self, model): - val = getattr(model, self.attr_name) - return attr_name + ": " + str(val) -``` - -Now, if we wanted to use our new AttributeElement to add the number of -happy agents to our Schelling visualization, it might look something -like this: - -```python -happy_element = AttributeElement("happy") -server = ModularServer(SchellingModel, [canvas_element, happy_element], "Schelling") -server.launch() -``` - -Note that, in this case, we only wanted to change the Python-side render -method. We're still using the parent module's HTML and JavaScript -template. - -## Creating a new browser display - -But what if we want more than just a different Python renderer; we want -to substantially change how a module displays in the browser, or create -a completely new module? To do this, we need to open up the JavaScript -as well: - -Let's take a look at the internals of **TextModule.js**, the JavaScript -for the TextVisualization. Here it is, in all its glory: - -```javascript -const TextModule = function () { - const text = document.createElement("p"); - text.className = "lead"; - - // Append text tag to #elements: - document.getElementById("elements").appendChild(text); - - this.render = function (data) { - text.innerHTML = data; - }; - - this.reset = function () { - text.innerHTML = ""; - }; -}; -``` - -This code is the JavaScript equivalent of defining a class. When -instantiated, a TextModule object will create a new paragraph tag and -append it to the parent HTML page's *body*. The object will have two -methods attached: - -1. *render(data)* -- replaces the inner HTML contents of the - paragraph with the text it gets as an input. This function will be - called at each step of the model, to draw the data associated with - the model coming over the websocket. -2. *reset* -- replaces the contents of the div with a blank. This - function will be called when the user presses the Reset button. - -Now let's take a look at the TextModule's Python counterpart, -**TextElement** (which resides in **TextVisualization.py**). Again, -here's the whole thing: - -```python -from mesa.visualization_old.ModularVisualization import VisualizationElement - -class TextElement(VisualizationElement): - js_includes = ["TextModule.js"] - js_code = "elements.push(new TextModule());" -``` - -That's it! Notice that it is lacking a *render()* method, like the one -we defined above. Look at what is there: *js_includes* is a list of -JavaScript files to import into the page when this element is present. -In this case, it just imports **TextModule.js**. - -Next, *js_code* is some JavaScript code, in Python string form, to run -when the visualization page loads. In this case, *new TextModule()* -creates a new TextModule object as defined above (which, remember, also -appends a new paragraph to the page body) which is then appended to the -array *elements*, that stores all the visualization elements currently -on the page. - -To help understand why it looks like this, here's a snippet of -JavaScript from the overall visualization template itself, on how to -handle incoming data: - -```javascript -data = msg["data"] -for (var i in elements) { - elements[i].render(data[i]); -} -``` - -Data to visualize arrive over the websocket as a list. For each index of -the list, the code passes that element of the data to the *render* -function of the corresponding element, in the elements array. - -Currently, module JavaScript files live in the -*mesa/visualization_old/templates* directory, and the Python files live in -*mesa/visualization_old/modules*. - -When creating a new module, the Python and JavaScript code need to be -written in synch: the module Python-side **render** method needs to -output data in the exact same format that the JavaScript **render** -function receives as an input. diff --git a/docs/overview.md b/docs/overview.md index 923d64d1031..de0bad3b441 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -114,33 +114,3 @@ As with the data collector, once the runs are all over, you can extract the data ```python batch_df = batch_run.get_model_vars_dataframe() ``` - -### Visualization modules - -Finally, you may want to directly observe your model as it runs. Mesa's main visualization tool uses a small local web server to render the model in a browser, using JavaScript. There are different components for drawing different types of data: for example, grids for drawing agents moving around on a grid, or charts for showing how some data changes as the model runs. A few core modules are: - -- mesa.visualization_old.ModularVisualization -- mesa.visualization_old.modules - -To quickly spin up a model visualization, you might do something like: - -```python -import mesa - -def agent_portrayal(agent): - portrayal = {"Shape": "circle", - "Filled": "true", - "Layer": 0, - "Color": "red", - "r": 0.5} - return portrayal - -grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500) -server = mesa.visualization_old.ModularServer(MyModel, - [grid], - "My Model", - {'n_agents': 10}) -server.launch() -``` - -This will launch the browser-based visualization, on the default port 8521. diff --git a/docs/tutorials/adv_tutorial_legacy.ipynb b/docs/tutorials/adv_tutorial_legacy.ipynb deleted file mode 100644 index e29d2732413..00000000000 --- a/docs/tutorials/adv_tutorial_legacy.ipynb +++ /dev/null @@ -1,510 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Advanced Tutorial\n", - "This is the legacy version of the advanced tutorial. We recommend you to read the newer (current) version because it is easier." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Adding visualization\n", - "\n", - "So far, we've built a model, run it, and analyzed some output afterwards. However, one of the advantages of agent-based models is that we can often watch them run step by step, potentially spotting unexpected patterns, behaviors or bugs, or developing new intuitions, hypotheses, or insights. Other times, watching a model run can explain it to an unfamiliar audience better than static explanations. Like many ABM frameworks, Mesa allows you to create an interactive visualization of the model. In this section we'll walk through creating a visualization using built-in components, and (for advanced users) how to create a new visualization element.\n", - "\n", - "**Note for Jupyter users: Due to conflicts with the tornado server Mesa uses and Jupyter, the interactive browser of your model will load but likely not work. This will require you to use run the code from .py files. The Mesa development team is working to develop a** [Jupyter compatible interface](https://github.com/projectmesa/mesa/issues/1363).\n", - "\n", - "First, a quick explanation of how Mesa's interactive visualization works. Visualization is done in a browser window, using JavaScript to draw the different things being visualized at each step of the model. To do this, Mesa launches a small web server, which runs the model, turns each step into a JSON object (essentially, structured plain text) and sends those steps to the browser.\n", - "\n", - "A visualization is built up of a few different modules: for example, a module for drawing agents on a grid, and another one for drawing a chart of some variable. Each module has a Python part, which runs on the server and turns a model state into JSON data; and a JavaScript side, which takes that JSON data and draws it in the browser window. Mesa comes with a few modules built in, and let you add your own as well." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Grid Visualization\n", - "\n", - "To start with, let's have a visualization where we can watch the agents moving around the grid. For this, you will need to put your model code in a separate Python source file. For now, let us use the `MoneyModel` created in the [Introductory Tutorial](https://mesa.readthedocs.io/en/stable/tutorials/intro_tutorial.html) saved to `MoneyModel.py` file provided.\n", - "Next, in a new source file (e.g. `MoneyModel_Viz.py`) include the code shown in the following cells to run and avoid Jupyter compatibility issue." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# If MoneyModel.py is where your code is:\n", - "from MoneyModel import mesa, MoneyModel" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Mesa's `CanvasGrid` visualization class works by looping over every cell in a grid, and generating a portrayal for every agent it finds. A portrayal is a dictionary (which can easily be turned into a JSON object) which tells the JavaScript side how to draw it. The only thing we need to provide is a function which takes an agent, and returns a portrayal object. Here's the simplest one: it'll draw each agent as a red, filled circle which fills half of each cell." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "def agent_portrayal(agent):\n", - " portrayal = {\n", - " \"Shape\": \"circle\",\n", - " \"Color\": \"red\",\n", - " \"Filled\": \"true\",\n", - " \"Layer\": 0,\n", - " \"r\": 0.5,\n", - " }\n", - " return portrayal" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In addition to the portrayal method, we instantiate a canvas grid with its width and height in cells, and in pixels. In this case, let's create a 10x10 grid, drawn in 500 x 500 pixels." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now we create and launch the actual server. We do this with the following arguments:\n", - "\n", - "* The model class we're running and visualizing; in this case, `MoneyModel`.\n", - "* A list of module objects to include in the visualization; here, just `[grid]`\n", - "* The title of the model: \"Money Model\"\n", - "* Any inputs or arguments for the model itself. In this case, 100 agents, and height and width of 10.\n", - "\n", - "Once we create the server, we set the port (use default 8521 here) for it to listen on (you can treat this as just a piece of the URL you’ll open in the browser). " - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "server = mesa.visualization_old.ModularServer(\n", - " MoneyModel, [grid], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n", - ")\n", - "server.port = 8521 # the default" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finally, when you’re ready to run the visualization, use the server’s launch() method." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The full code for source file `MoneyModel_Viz.py` should now look like:\n", - "\n", - "```python\n", - "from MoneyModel import mesa, MoneyModel\n", - "\n", - "\n", - "def agent_portrayal(agent):\n", - " portrayal = {\"Shape\": \"circle\",\n", - " \"Filled\": \"true\",\n", - " \"Layer\": 0,\n", - " \"Color\": \"red\",\n", - " \"r\": 0.5}\n", - " return portrayal\n", - "\n", - "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n", - "server = mesa.visualization_old.ModularServer(MoneyModel,\n", - " [grid],\n", - " \"Money Model\",\n", - " {\"N\":100, \"width\":10, \"height\":10})\n", - "server.port = 8521 # The default\n", - "server.launch()\n", - "```\n", - "Now run this file; this should launch the interactive visualization server and open your web browser automatically. (If the browser doesn't open automatically, try pointing it at [http://127.0.0.1:8521](http://127.0.0.1:8521) manually. If this doesn't show you the visualization, something may have gone wrong with the server launch.)\n", - "\n", - "You should see something like the figure below: the model title, an empty space where the grid will be, and a control panel off to the right.\n", - "\n", - "![Empty Visualization](files/viz_empty.png)\n", - "\n", - "Click the `Reset` button on the control panel, and you should see the grid fill up with red circles, representing agents.\n", - "\n", - "![Redcircles Visualization](files/viz_redcircles.png)\n", - "\n", - "Click `Step` to advance the model by one step, and the agents will move around. Click `Start` and the agents will keep moving around, at the rate set by the 'fps' (frames per second) slider at the top. Try moving it around and see how the speed of the model changes. Pressing `Stop` will pause the model; pressing `Start` again will restart it. Finally, `Reset` will start a new instantiation of the model.\n", - "\n", - "To stop the visualization server, go back to the terminal where you launched it, and press Control+c." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Changing the agents\n", - "\n", - "In the visualization above, all we could see is the agents moving around -- but not how much money they had, or anything else of interest. Let's change it so that agents who are broke (wealth 0) are drawn in grey, smaller, and above agents who still have money.\n", - "\n", - "To do this, we go back to our `agent_portrayal` code and add some code to change the portrayal based on the agent properties and launch the server again." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "def agent_portrayal(agent):\n", - " portrayal = {\"Shape\": \"circle\", \"Filled\": \"true\", \"r\": 0.5}\n", - "\n", - " if agent.wealth > 0:\n", - " portrayal[\"Color\"] = \"red\"\n", - " portrayal[\"Layer\"] = 0\n", - " else:\n", - " portrayal[\"Color\"] = \"grey\"\n", - " portrayal[\"Layer\"] = 1\n", - " portrayal[\"r\"] = 0.2\n", - " return portrayal" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This will open a new browser window pointed at the updated visualization. Initially it looks the same, but advance the model and smaller grey circles start to appear. Note that since the zero-wealth agents have a higher layer number, they are drawn on top of the red agents.\n", - "\n", - "![Greycircles Visualization](files/viz_greycircles.png)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Adding a chart\n", - "\n", - "Next, let's add another element to the visualization: a chart, tracking the model's Gini Coefficient. This is another built-in element that Mesa provides.\n", - "\n", - "The basic chart pulls data from the model's DataCollector, and draws it as a line graph using the [Charts.js](http://www.chartjs.org/) JavaScript libraries. We instantiate a chart element with a list of series for the chart to track. Each series is defined in a dictionary, and has a `Label` (which must match the name of a model-level variable collected by the DataCollector) and a `Color` name. We can also give the chart the name of the DataCollector object in the model.\n", - "\n", - "Finally, we add the chart to the list of elements in the server. The elements are added to the visualization in the order they appear, so the chart will appear underneath the grid." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "chart = mesa.visualization_old.ChartModule(\n", - " [{\"Label\": \"Gini\", \"Color\": \"Black\"}], data_collector_name=\"datacollector\"\n", - ")\n", - "\n", - "server = mesa.visualization_old.ModularServer(\n", - " MoneyModel, [grid, chart], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Launch the visualization and start a model run, either by launching the server here or through the full code for source file `MoneyModel_Viz.py`.\n", - "\n", - "```python\n", - "from MoneyModel import mesa, MoneyModel\n", - "\n", - "\n", - "def agent_portrayal(agent):\n", - " portrayal = {\"Shape\": \"circle\", \"Filled\": \"true\", \"r\": 0.5}\n", - "\n", - " if agent.wealth > 0:\n", - " portrayal[\"Color\"] = \"red\"\n", - " portrayal[\"Layer\"] = 0\n", - " else:\n", - " portrayal[\"Color\"] = \"grey\"\n", - " portrayal[\"Layer\"] = 1\n", - " portrayal[\"r\"] = 0.2\n", - " return portrayal\n", - "\n", - "\n", - "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n", - "chart = mesa.visualization_old.ChartModule(\n", - " [{\"Label\": \"Gini\", \"Color\": \"Black\"}], data_collector_name=\"datacollector\"\n", - ")\n", - "\n", - "server = mesa.visualization_old.ModularServer(\n", - " MoneyModel, [grid, chart], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n", - ")\n", - "server.port = 8521 # The default\n", - "server.launch()\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You'll see a line chart underneath the grid. Every step of the model, the line chart updates along with the grid. Reset the model, and the chart resets too.\n", - "\n", - "![Chart Visualization](files/viz_chart.png)\n", - "\n", - "**Note:** You might notice that the chart line only starts after a couple of steps; this is due to a bug in Charts.js which will hopefully be fixed soon." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Building your own visualization component\n", - "\n", - "**Note:** This section is for users who have a basic familiarity with JavaScript. If that's not you, don't worry! (If you're an advanced JavaScript coder and find things that we've done wrong or inefficiently, please [let us know](https://github.com/projectmesa/mesa/issues)!)\n", - "\n", - "If the visualization elements provided by Mesa aren't enough for you, you can build your own and plug them into the model server.\n", - "\n", - "First, you need to understand how the visualization works under the hood. Remember that each visualization module has two sides: a Python object that runs on the server and generates JSON data from the model state (the server side), and a JavaScript object that runs in the browser and turns the JSON into something it renders on the screen (the client side).\n", - "\n", - "Obviously, the two sides of each visualization must be designed in tandem. They result in one Python class, and one JavaScript `.js` file. The path to the JavaScript file is a property of the Python class.\n", - "\n", - "For this example, let's build a simple histogram visualization, which can count the number of agents with each value of wealth. We'll use the [Charts.js](http://www.chartjs.org/) JavaScript library, which is already included with Mesa. If you go and look at its documentation, you'll see that it had no histogram functionality, which means we have to build our own out of a bar chart. We'll keep the histogram as simple as possible, giving it a fixed number of integer bins. If you were designing a more general histogram to add to the Mesa repository for everyone to use across different models, obviously you'd want something more general." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Client-Side Code\n", - "\n", - "In general, the server- and client-side are written in tandem. However, if you're like me and more comfortable with Python than JavaScript, it makes sense to figure out how to get the JavaScript working first, and then write the Python to be compatible with that.\n", - "\n", - "In the same directory as your model, create a new file called `HistogramModule.js`. This will store the JavaScript code for the client side of the new module.\n", - "\n", - "JavaScript classes can look alien to people coming from other languages -- specifically, they can look like functions. (The Mozilla [Introduction to Object-Oriented JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Introduction_to_Object-Oriented_JavaScript) is a good starting point). In `HistogramModule.js`, start by creating the class itself:\n", - "\n", - "```javascript\n", - "const HistogramModule = function(bins, canvas_width, canvas_height) {\n", - " // The actual code will go here.\n", - "};\n", - "```\n", - "\n", - "Note that our object is instantiated with three arguments: the number of integer bins, and the width and height (in pixels) the chart will take up in the visualization window.\n", - "\n", - "When the visualization object is instantiated, the first thing it needs to do is prepare to draw on the current page. To do so, it adds a [canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) tag to the page. It also gets the canvas' context, which is required for doing anything with it.\n", - "\n", - "```javascript\n", - "const HistogramModule = function(bins, canvas_width, canvas_height) {\n", - " // Create the canvas object:\n", - " const canvas = document.createElement(\"canvas\");\n", - " Object.assign(canvas, {\n", - " width: canvas_width,\n", - " height: canvas_height,\n", - " style: \"border:1px dotted\",\n", - " });\n", - " // Append it to #elements:\n", - " const elements = document.getElementById(\"elements\");\n", - " elements.appendChild(canvas);\n", - "\n", - " // Create the context and the drawing controller:\n", - " const context = canvas.getContext(\"2d\");\n", - "};\n", - "```\n", - "\n", - "Look at the Charts.js [bar chart documentation](http://www.chartjs.org/docs/#bar-chart-introduction). You'll see some of the boilerplate needed to get a chart set up. Especially important is the `data` object, which includes the datasets, labels, and color options. In this case, we want just one dataset (we'll keep things simple and name it \"Data\"); it has `bins` for categories, and the value of each category starts out at zero. Finally, using these boilerplate objects and the canvas context we created, we can create the chart object.\n", - "\n", - "```javascript\n", - "const HistogramModule = function(bins, canvas_width, canvas_height) {\n", - " // Create the canvas object:\n", - " const canvas = document.createElement(\"canvas\");\n", - " Object.assign(canvas, {\n", - " width: canvas_width,\n", - " height: canvas_height,\n", - " style: \"border:1px dotted\",\n", - " });\n", - " // Append it to #elements:\n", - " const elements = document.getElementById(\"elements\");\n", - " elements.appendChild(canvas);\n", - "\n", - " // Create the context and the drawing controller:\n", - " const context = canvas.getContext(\"2d\");\n", - "\n", - " // Prep the chart properties and series:\n", - " const datasets = [{\n", - " label: \"Data\",\n", - " fillColor: \"rgba(151,187,205,0.5)\",\n", - " strokeColor: \"rgba(151,187,205,0.8)\",\n", - " highlightFill: \"rgba(151,187,205,0.75)\",\n", - " highlightStroke: \"rgba(151,187,205,1)\",\n", - " data: []\n", - " }];\n", - "\n", - " // Add a zero value for each bin\n", - " for (var i in bins)\n", - " datasets[0].data.push(0);\n", - "\n", - " const data = {\n", - " labels: bins,\n", - " datasets: datasets\n", - " };\n", - "\n", - " const options = {\n", - " scaleBeginsAtZero: true\n", - " };\n", - "\n", - " // Create the chart object\n", - " let chart = new Chart(context, {type: 'bar', data: data, options: options});\n", - "\n", - " // Now what?\n", - "};\n", - "```\n", - "\n", - "There are two methods every client-side visualization class must implement to be able to work: `render(data)` to render the incoming data, and `reset()` which is called to clear the visualization when the user hits the reset button and starts a new model run.\n", - "\n", - "In this case, the easiest way to pass data to the histogram is as an array, one value for each bin. We can then just loop over the array and update the values in the chart's dataset.\n", - "\n", - "There are a few ways to reset the chart, but the easiest is probably to destroy it and create a new chart object in its place.\n", - "\n", - "With that in mind, we can add these two methods to the class:\n", - "\n", - "```javascript\n", - "const HistogramModule = function(bins, canvas_width, canvas_height) {\n", - " // ...Everything from above...\n", - " this.render = function(data) {\n", - " datasets[0].data = data;\n", - " chart.update();\n", - " };\n", - "\n", - " this.reset = function() {\n", - " chart.destroy();\n", - " chart = new Chart(context, {type: 'bar', data: data, options: options});\n", - " };\n", - "};\n", - "```\n", - "\n", - "Note the `this`. before the method names. This makes them public and ensures that they are accessible outside of the object itself. All the other variables inside the class are only accessible inside the object itself, but not outside of it." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Server-Side Code\n", - "\n", - "Can we get back to Python code? Please?\n", - "\n", - "Every JavaScript visualization element has an equal and opposite server-side Python element. The Python class needs to also have a `render` method, to get data out of the model object and into a JSON-ready format. It also needs to point towards the code where the relevant JavaScript lives, and add the JavaScript object to the model page.\n", - "\n", - "In a Python file (either its own, or in the same file as your visualization code), import the `VisualizationElement` class we'll inherit from, and create the new visualization class.\n", - "\n", - "```python\n", - " from mesa.visualization_old.ModularVisualization import VisualizationElement, CHART_JS_FILE\n", - "\n", - " class HistogramModule(VisualizationElement):\n", - " package_includes = [CHART_JS_FILE]\n", - " local_includes = [\"HistogramModule.js\"]\n", - "\n", - " def __init__(self, bins, canvas_height, canvas_width):\n", - " self.canvas_height = canvas_height\n", - " self.canvas_width = canvas_width\n", - " self.bins = bins\n", - " new_element = \"new HistogramModule({}, {}, {})\"\n", - " new_element = new_element.format(bins, \n", - " canvas_width, \n", - " canvas_height)\n", - " self.js_code = \"elements.push(\" + new_element + \");\"\n", - "```\n", - "\n", - "There are a few things going on here. `package_includes` is a list of JavaScript files that are part of Mesa itself that the visualization element relies on. You can see the included files in [mesa/visualization_old/templates/](https://github.com/projectmesa/mesa/tree/main/mesa/visualization_old/templates). Similarly, `local_includes` is a list of JavaScript files in the same directory as the class code itself. Note that both of these are class variables, not object variables -- they hold for all particular objects.\n", - "\n", - "Next, look at the `__init__` method. It takes three arguments: the number of bins, and the width and height for the histogram. It then uses these values to populate the `js_code` property; this is code that the server will insert into the visualization page, which will run when the page loads. In this case, it creates a new HistogramModule (the class we created in JavaScript in the step above) with the desired bins, width and height; it then appends (`push`es) this object to `elements`, the list of visualization elements that the visualization page itself maintains.\n", - "\n", - "Now, the last thing we need is the `render` method. If we were making a general-purpose visualization module we'd want this to be more general, but in this case we can hard-code it to our model.\n", - "\n", - "```python\n", - "import numpy as np\n", - "\n", - "class HistogramModule(VisualizationElement):\n", - " # ... Everything from above...\n", - "\n", - " def render(self, model):\n", - " wealth_vals = [agent.wealth for agent in model.schedule.agents]\n", - " hist = np.histogram(wealth_vals, bins=self.bins)[0]\n", - " return [int(x) for x in hist]\n", - "```\n", - "\n", - "Every time the render method is called (with a model object as the argument) it uses numpy to generate counts of agents with each wealth value in the bins, and then returns a list of these values. Note that the `render` method doesn't return a JSON string -- just an object that can be turned into JSON, in this case a Python list (with Python integers as the values; the `json` library doesn't like dealing with numpy's integer type).\n", - "\n", - "Now, you can create your new HistogramModule and add it to the server:\n", - "\n", - "```python\n", - " histogram = mesa.visualization_old.HistogramModule(list(range(10)), 200, 500)\n", - " server = mesa.visualization_old.ModularServer(MoneyModel, \n", - " [grid, histogram, chart], \n", - " \"Money Model\", \n", - " {\"N\":100, \"width\":10, \"height\":10})\n", - " server.launch()\n", - "```\n", - "\n", - "Run this code, and you should see your brand-new histogram added to the visualization and updating along with the model!\n", - "\n", - "![Histogram Visualization](files/viz_histogram.png)\n", - "\n", - "If you've felt comfortable with this section, it might be instructive to read the code for the [ModularServer](https://github.com/projectmesa/mesa/blob/main/mesa/visualization_old/ModularVisualization.py#L259) and the [modular_template](https://github.com/projectmesa/mesa/blob/main/mesa/visualization_old/templates/modular_template.html) to get a better idea of how all the pieces fit together." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Happy Modeling!\n", - "\n", - "This document is a work in progress. If you see any errors, exclusions or have any problems please contact [us](https://github.com/projectmesa/mesa/issues)." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.6" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/tutorials/intro_tutorial.ipynb b/docs/tutorials/intro_tutorial.ipynb index a9fe56875e1..014bd2b53e8 100644 --- a/docs/tutorials/intro_tutorial.ipynb +++ b/docs/tutorials/intro_tutorial.ipynb @@ -29,9 +29,7 @@ "\n", "### More Tutorials: \n", "\n", - "Visualization: There is a separate [visualization tutorial](https://mesa.readthedocs.io/en/stable/tutorials/visualization_tutorial.html) that will take users through building a visualization for this model (aka Boltzmann Wealth Model).\n", - "\n", - "Advanced Visualization (legacy): There is also an [advanced visualization tutorial](https://mesa.readthedocs.io/en/stable/tutorials/adv_tutorial_legacy.html) that will show users how to use the JavaScript based visualization option, which also uses this model as its base. " + "Visualization: There is a separate [visualization tutorial](https://mesa.readthedocs.io/en/stable/tutorials/visualization_tutorial.html) that will take users through building a visualization for this model (aka Boltzmann Wealth Model)." ] }, { diff --git a/mesa/__init__.py b/mesa/__init__.py index 529507e1f8b..09f93313175 100644 --- a/mesa/__init__.py +++ b/mesa/__init__.py @@ -8,7 +8,6 @@ import mesa.space as space import mesa.time as time -import mesa.visualization_old as visualization_old from mesa.agent import Agent from mesa.batchrunner import batch_run from mesa.datacollection import DataCollector @@ -19,7 +18,6 @@ "Agent", "time", "space", - "visualization_old", "DataCollector", "batch_run", "experimental", diff --git a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/app.pytemplate b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/app.pytemplate new file mode 100644 index 00000000000..eac8824569c --- /dev/null +++ b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/app.pytemplate @@ -0,0 +1,27 @@ +""" +Configure visualization elements and instantiate a server +""" +from mesa.visualization import JupyterViz + +from {{ cookiecutter.snake }}.model import {{ cookiecutter.model }}, {{ cookiecutter.agent }} # noqa + +import mesa + + +def circle_portrayal_example(agent): + return { + "size": 40, + # This is Matplotlib's color + "color": "tab:pink", + } + + +model_params = {"num_agents": 10, "width": 10, "height": 10} + +page = JupyterViz( + {{cookiecutter.model}}, + model_params, + measures=["num_agents"], + agent_portrayal=circle_portrayal_example +) +page # noqa diff --git a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/run.pytemplate b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/run.pytemplate deleted file mode 100644 index 66f5cea5f6a..00000000000 --- a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/run.pytemplate +++ /dev/null @@ -1,3 +0,0 @@ -from {{cookiecutter.snake}}.server import server # noqa - -server.launch() diff --git a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/model.pytemplate b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/model.pytemplate index eedca040807..b33789a720a 100644 --- a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/model.pytemplate +++ b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/model.pytemplate @@ -47,7 +47,7 @@ class {{cookiecutter.model}}(mesa.Model): self.grid.place_agent(agent, (x, y)) # example data collector - self.datacollector = mesa.datacollection.DataCollector() + self.datacollector = mesa.datacollection.DataCollector({"num_agents": "num_agents"}) self.running = True self.datacollector.collect(self) diff --git a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate deleted file mode 100644 index 7b168b6d63b..00000000000 --- a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate +++ /dev/null @@ -1,36 +0,0 @@ -""" -Configure visualization elements and instantiate a server -""" - -from .model import {{ cookiecutter.model }}, {{ cookiecutter.agent }} # noqa - -import mesa - - -def circle_portrayal_example(agent): - if agent is None: - return - - portrayal = { - "Shape": "circle", - "Filled": "true", - "Layer": 0, - "r": 0.5, - "Color": "Pink", - } - return portrayal - - -canvas_element = mesa.visualization_old.CanvasGrid( - circle_portrayal_example, 20, 20, 500, 500 -) -chart_element = mesa.visualization_old.ChartModule([{"Label": "{{ cookiecutter.camel }}", "Color": "Pink"}]) - -model_kwargs = {"num_agents": 10, "width": 10, "height": 10} - -server = mesa.visualization_old.ModularServer( - {{cookiecutter.model}}, - [canvas_element, chart_element], - "{{ cookiecutter.camel }}", - model_kwargs, -) diff --git a/pyproject.toml b/pyproject.toml index 51ce2c16fd6..d0bec775ae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ dependencies = [ "click", "cookiecutter", "matplotlib", - "mesa_viz_tornado~=0.1.0,>=0.1.3", "networkx", "numpy", "pandas", diff --git a/tests/test_import_namespace.py b/tests/test_import_namespace.py index 6a001319f24..5e9679e6abb 100644 --- a/tests/test_import_namespace.py +++ b/tests/test_import_namespace.py @@ -12,11 +12,6 @@ def test_import(): _ = mesa.space.MultiGrid _ = MultiGrid - from mesa.visualization_old.ModularVisualization import ModularServer - - _ = mesa.visualization_old.ModularServer - _ = ModularServer - from mesa.datacollection import DataCollector _ = DataCollector diff --git a/tests/test_tornado.py b/tests/test_tornado.py deleted file mode 100644 index 52f40417455..00000000000 --- a/tests/test_tornado.py +++ /dev/null @@ -1,41 +0,0 @@ -import json - -import tornado -from tornado.testing import AsyncHTTPTestCase - -from mesa import Model -from mesa.visualization_old.ModularVisualization import ModularServer - - -class TestServer(AsyncHTTPTestCase): - def get_app(self): - app = ModularServer(Model, []) - return app - - def test_homepage(self): - response = self.fetch("/") - assert response.error is None - - @tornado.testing.gen_test - def test_websocket(self): - ws_url = "ws://localhost:" + str(self.get_http_port()) + "/ws" - ws_client = yield tornado.websocket.websocket_connect(ws_url) - - # Now we can run a test on the WebSocket. - response = yield ws_client.read_message() - msg = json.loads(response) - assert msg["type"] == "model_params" - - ws_client.write_message('{"type": "get_step"}') - response = yield ws_client.read_message() - msg = json.loads(response) - assert msg["type"] == "viz_state" - - ws_client.write_message('{"type": "reset"}') - response = yield ws_client.read_message() - msg = json.loads(response) - assert msg["type"] == "viz_state" - - ws_client.write_message("Unknown message!") - response = yield ws_client.read_message() - assert response is None diff --git a/tests/test_usersettableparam.py b/tests/test_usersettableparam.py deleted file mode 100644 index 52ba971ef2a..00000000000 --- a/tests/test_usersettableparam.py +++ /dev/null @@ -1,57 +0,0 @@ -from unittest import TestCase - -from mesa.visualization_old.UserParam import ( - Checkbox, - Choice, - NumberInput, - Slider, - StaticText, -) - - -class TestOption(TestCase): - def setUp(self): - self.number_option = NumberInput("number", value=123) - self.checkbox_option = Checkbox(value=True) - self.choice_option = Choice( - value="I am your default choice", - choices=["I am your default choice", "I am your other choice"], - ) - self.slider_option = Slider(value=123, min_value=100, max_value=200) - self.static_text_option = StaticText("Hurr, Durr Im'a Sheep") - - def test_number(self): - option = self.number_option - assert option.value == 123 - option.value = 321 - assert option.value == 321 - - def test_checkbox(self): - option = self.checkbox_option - assert option.value - option.value = False - assert not option.value - - def test_choice(self): - option = self.choice_option - assert option.value == "I am your default choice" - option.value = "I am your other choice" - assert option.value == "I am your other choice" - option.value = "I am not an available choice" - assert option.value == "I am your default choice" - - def test_slider(self): - option = self.slider_option - assert option.value == 123 - option.value = 150 - assert option.value == 150 - option.value = 0 - assert option.value == 100 - option.value = 300 - assert option.value == 200 - assert option.json["value"] == 200 - with self.assertRaises(ValueError): - Slider() - - def test_static_text(self): - assert self.static_text_option.value == "Hurr, Durr Im'a Sheep" diff --git a/tests/test_visualization.py b/tests/test_visualization.py deleted file mode 100644 index b04a76243f2..00000000000 --- a/tests/test_visualization.py +++ /dev/null @@ -1,105 +0,0 @@ -from collections import defaultdict -from unittest import TestCase - -import mesa -from mesa.model import Model -from mesa.space import MultiGrid -from mesa.time import SimultaneousActivation -from mesa.visualization_old.ModularVisualization import ModularServer -from mesa.visualization_old.modules import CanvasGrid, TextElement -from mesa.visualization_old.UserParam import ( - NumberInput, - Slider, -) - - -class MockAgent(mesa.Agent): - """ - Minimalistic agent implementation for testing purposes - """ - - def __init__(self, unique_id, model, val): - super().__init__(unique_id, model) - self.unique_id = unique_id - self.val = val - self.local = 0 - - def step(self): - self.val += 1 - self.local += 0.25 - - -class MockModel(Model): - """Test model for testing""" - - def __init__(self, width, height, key1=103, key2=104): - super().__init__() - self.width = width - self.height = height - self.key1 = (key1,) - self.key2 = key2 - self.schedule = SimultaneousActivation(self) - self.grid = MultiGrid(width, height, torus=True) - - for _c, pos in self.grid.coord_iter(): - x, y = pos - a = MockAgent(x + y * 100, self, x * y * 3) - self.grid.place_agent(a, (x, y)) - self.schedule.add(a) - - def step(self): - self.schedule.step() - - -class TestModularServer(TestCase): - """Test server for testing""" - - def portrayal(self, cell): - return { - "Shape": "rect", - "w": 1, - "h": 1, - "Filled": "true", - "Layer": 0, - "x": 0, - "y": 0, - "Color": "black", - } - - def setUp(self): - self.user_params = { - "width": 1, - "height": 1, - "key1": NumberInput("Test Parameter", 101), - "key2": Slider("Test Parameter", 200, 0, 300, 10), - } - - self.viz_elements = [ - CanvasGrid(self.portrayal, 10, 10, 20, 20), - TextElement(), - # ChartModule([{"Label": "Wolves", "Color": "#AA0000"}, # Todo - test chart module - # {"Label": "Sheep", "Color": "#666666"}]) - ] - - self.server = ModularServer( - MockModel, self.viz_elements, "Test Model", model_params=self.user_params - ) - - def test_canvas_render_model_state(self): - test_portrayal = self.portrayal(None) - test_grid_state = defaultdict(list) - test_grid_state[test_portrayal["Layer"]].append(test_portrayal) - - state = self.server.render_model() - assert state[0] == test_grid_state - - def test_text_render_model_state(self): - state = self.server.render_model() - assert state[1] == "VisualizationElement goes here." - - def test_user_params(self): - print(self.server.user_params) - assert self.server.user_params == { - "key1": NumberInput("Test Parameter", 101).json, - "key2": Slider("Test Parameter", 200, 0, 300, 10).json, - } From 27c2ce42251fd23f695c999c92f54b717f41b912 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 3 Jul 2024 15:46:57 +0200 Subject: [PATCH 36/50] Jupyter_viz: Allow measures to be None (#2163) Currently measures crashed when it's None (which is the default). The only way to circumvent this is adding [], but that's non-ideal and not obvious. This PR fixes that, so that measures can actually be the default value of None. This also proves we need to test Jupyter viz in notebooks, not only in console, since both have different code paths. --- mesa/visualization/jupyter_viz.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mesa/visualization/jupyter_viz.py b/mesa/visualization/jupyter_viz.py index 2067dcfd88c..1e3a3f85d6d 100644 --- a/mesa/visualization/jupyter_viz.py +++ b/mesa/visualization/jupyter_viz.py @@ -143,14 +143,15 @@ def render_in_jupyter(): # otherwise, do nothing (do not draw space) # 5. Plots - for measure in measures: - if callable(measure): - # Is a custom object - measure(model) - else: - components_matplotlib.PlotMatplotlib( - model, measure, dependencies=dependencies - ) + if measures: + for measure in measures: + if callable(measure): + # Is a custom object + measure(model) + else: + components_matplotlib.PlotMatplotlib( + model, measure, dependencies=dependencies + ) def render_in_browser(): # if space drawer is disabled, do not include it From 95e8d8171ec4aaa72d0e5844b6c5679ec731d396 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 3 Jul 2024 15:48:49 +0200 Subject: [PATCH 37/50] Extend visualization documentation (#2162) - Add docstring to Jupyter viz module and functions - Render the docstring in Read the Docs: https://mesa.readthedocs.io/en/latest/apis/visualization.html --- docs/apis/visualization.md | 35 +++----- mesa/visualization/jupyter_viz.py | 127 +++++++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 39 deletions(-) diff --git a/docs/apis/visualization.md b/docs/apis/visualization.md index fde799e0f54..fd558041e86 100644 --- a/docs/apis/visualization.md +++ b/docs/apis/visualization.md @@ -1,38 +1,21 @@ # Visualization -```{eval-rst} -.. automodule:: visualization.__init__ - :members: -``` - -```{eval-rst} -.. automodule:: visualization.ModularVisualization - :members: -``` - -```{eval-rst} -.. automodule:: visualization.TextVisualization - :members: -``` +For a detailed tutorial, please refer to our [Visualization Tutorial](../tutorials/visualization_tutorial.ipynb). -## Modules +## Jupyter Visualization ```{eval-rst} -.. automodule:: visualization.modules.__init__ +.. automodule:: mesa.visualization.jupyter_viz :members: + :undoc-members: + :show-inheritance: ``` -```{eval-rst} -.. automodule:: visualization.modules.CanvasGridVisualization - :members: -``` - -```{eval-rst} -.. automodule:: visualization.modules.ChartVisualization - :members: -``` +## User Parameters ```{eval-rst} -.. automodule:: visualization.modules.TextVisualization +.. automodule:: mesa.visualization.UserParam :members: + :undoc-members: + :show-inheritance: ``` diff --git a/mesa/visualization/jupyter_viz.py b/mesa/visualization/jupyter_viz.py index 1e3a3f85d6d..2d0691b290f 100644 --- a/mesa/visualization/jupyter_viz.py +++ b/mesa/visualization/jupyter_viz.py @@ -1,3 +1,28 @@ +""" +Mesa visualization module for creating interactive model visualizations. + +This module provides components to create browser- and Jupyter notebook-based visualizations of +Mesa models, allowing users to watch models run step-by-step and interact with model parameters. + +Key features: + - JupyterViz: Main component for creating visualizations, supporting grid displays and plots + - ModelController: Handles model execution controls (step, play, pause, reset) + - UserInputs: Generates UI elements for adjusting model parameters + - Card: Renders individual visualization elements (space, measures) + +The module uses Solara for rendering in Jupyter notebooks or as standalone web applications. +It supports various types of visualizations including matplotlib plots, agent grids, and +custom visualization components. + +Usage: + 1. Define an agent_portrayal function to specify how agents should be displayed + 2. Set up model_params to define adjustable parameters + 3. Create a JupyterViz instance with your model, parameters, and desired measures + 4. Display the visualization in a Jupyter notebook or run as a Solara app + +See the Visualization Tutorial and example models for more details. +""" + import sys import threading @@ -19,6 +44,21 @@ def Card( model, measures, agent_portrayal, space_drawer, dependencies, color, layout_type ): + """ + Create a card component for visualizing model space or measures. + + Args: + model: The Mesa model instance + measures: List of measures to be plotted + agent_portrayal: Function to define agent appearance + space_drawer: Method to render agent space + dependencies: List of dependencies for updating the visualization + color: Background color of the card + layout_type: Type of layout (Space or Measure) + + Returns: + rv.Card: A card component containing the visualization + """ with rv.Card( style_=f"background-color: {color}; width: 100%; height: 100%" ) as main: @@ -60,19 +100,21 @@ def JupyterViz( play_interval=150, seed=None, ): - """Initialize a component to visualize a model. + """ + Initialize a component to visualize a model. + Args: - model_class: class of the model to instantiate - model_params: parameters for initializing the model - measures: list of callables or data attributes to plot - name: name for display - agent_portrayal: options for rendering agents (dictionary) - space_drawer: method to render the agent space for + model_class: Class of the model to instantiate + model_params: Parameters for initializing the model + measures: List of callables or data attributes to plot + name: Name for display + agent_portrayal: Options for rendering agents (dictionary) + space_drawer: Method to render the agent space for the model; default implementation is the `SpaceMatplotlib` component; simulations with no space to visualize should specify `space_drawer=False` - play_interval: play interval (default: 150) - seed: the random seed used to initialize the model + play_interval: Play interval (default: 150) + seed: The random seed used to initialize the model """ if name is None: name = model_class.__name__ @@ -88,6 +130,7 @@ def JupyterViz( # 2. Set up Model def make_model(): + """Create a new model instance with current parameters and seed.""" model = model_class.__new__( model_class, **model_parameters, seed=reactive_seed.value ) @@ -106,6 +149,7 @@ def make_model(): ) def handle_change_model_params(name: str, value: any): + """Update model parameters when user input changes.""" set_model_parameters({**model_parameters, name: value}) # 3. Set up UI @@ -115,12 +159,14 @@ def handle_change_model_params(name: str, value: any): # render layout and plot def do_reseed(): + """Update the random seed for the model.""" reactive_seed.value = model.random.random() # jupyter dependencies = [current_step.value, reactive_seed.value] def render_in_jupyter(): + """Render the visualization components in Jupyter notebook.""" with solara.GridFixed(columns=2): UserInputs(user_params, on_change=handle_change_model_params) ModelController(model, play_interval, current_step, reset_counter) @@ -154,6 +200,7 @@ def render_in_jupyter(): ) def render_in_browser(): + """Render the visualization components in a web browser.""" # if space drawer is disabled, do not include it layout_types = [{"Space": "default"}] if space_drawer else [] @@ -205,6 +252,15 @@ def render_in_browser(): @solara.component def ModelController(model, play_interval, current_step, reset_counter): + """ + Create controls for model execution (step, play, pause, reset). + + Args: + model: The model being visualized + play_interval: Interval between steps during play + current_step: Reactive value for the current step + reset_counter: Counter to trigger model reset + """ playing = solara.use_reactive(False) thread = solara.use_reactive(None) # We track the previous step to detect if user resets the model via @@ -214,6 +270,7 @@ def ModelController(model, play_interval, current_step, reset_counter): previous_step = solara.use_reactive(0) def on_value_play(change): + """Handle play/pause state changes.""" if previous_step.value > current_step.value and current_step.value == 0: # We add extra checks for current_step.value == 0, just to be sure. # We automatically stop the playing if a model is reset. @@ -224,31 +281,37 @@ def on_value_play(change): playing.value = False def do_step(): + """Advance the model by one step.""" model.step() previous_step.value = current_step.value current_step.value = model._steps def do_play(): + """Run the model continuously.""" model.running = True while model.running: do_step() def threaded_do_play(): + """Start a new thread for continuous model execution.""" if thread is not None and thread.is_alive(): return thread.value = threading.Thread(target=do_play) thread.start() def do_pause(): + """Pause the model execution.""" if (thread is None) or (not thread.is_alive()): return model.running = False thread.join() def do_reset(): + """Reset the model.""" reset_counter.value += 1 def do_set_playing(value): + """Set the playing state.""" if current_step.value == 0: # This means the model has been recreated, and the step resets to # 0. We want to avoid triggering the playing.value = False in the @@ -292,6 +355,15 @@ def do_set_playing(value): def split_model_params(model_params): + """ + Split model parameters into user-adjustable and fixed parameters. + + Args: + model_params: Dictionary of all model parameters + + Returns: + tuple: (user_adjustable_params, fixed_params) + """ model_params_input = {} model_params_fixed = {} for k, v in model_params.items(): @@ -303,6 +375,15 @@ def split_model_params(model_params): def check_param_is_fixed(param): + """ + Check if a parameter is fixed (not user-adjustable). + + Args: + param: Parameter to check + + Returns: + bool: True if parameter is fixed, False otherwise + """ if isinstance(param, Slider): return False if not isinstance(param, dict): @@ -313,14 +394,15 @@ def check_param_is_fixed(param): @solara.component def UserInputs(user_params, on_change=None): - """Initialize user inputs for configurable model parameters. + """ + Initialize user inputs for configurable model parameters. Currently supports :class:`solara.SliderInt`, :class:`solara.SliderFloat`, :class:`solara.Select`, and :class:`solara.Checkbox`. - Props: - user_params: dictionary with options for the input, including label, + Args: + user_params: Dictionary with options for the input, including label, min and max values, and other fields specific to the input type. - on_change: function to be called with (name, value) when the value of an input changes. + on_change: Function to be called with (name, value) when the value of an input changes. """ for name, options in user_params.items(): @@ -381,6 +463,16 @@ def change_handler(value, name=name): def make_text(renderer): + """ + Create a function that renders text using Markdown. + + Args: + renderer: Function that takes a model and returns a string + + Returns: + function: A function that renders the text as Markdown + """ + def function(model): solara.Markdown(renderer(model)) @@ -388,6 +480,15 @@ def function(model): def make_initial_grid_layout(layout_types): + """ + Create an initial grid layout for visualization components. + + Args: + layout_types: List of layout types (Space or Measure) + + Returns: + list: Initial grid layout configuration + """ return [ { "i": i, From 0c0fb53a918803ece03ae2136c12fd7fcb3cb61d Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 4 Jul 2024 09:56:02 +0200 Subject: [PATCH 38/50] Jupyter Viz: Don't avoid interactive backend (#2165) * Jupyter Viz: Don't avoid interactive backend We were avoiding the interactive backend, but that isn't recommended anymore and Solara should take care of that itself. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- mesa/visualization/jupyter_viz.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mesa/visualization/jupyter_viz.py b/mesa/visualization/jupyter_viz.py index 2d0691b290f..ffd6b06104e 100644 --- a/mesa/visualization/jupyter_viz.py +++ b/mesa/visualization/jupyter_viz.py @@ -26,7 +26,6 @@ import sys import threading -import matplotlib.pyplot as plt import reacton.ipywidgets as widgets import solara from solara.alias import rv @@ -35,9 +34,6 @@ import mesa.visualization.components.matplotlib as components_matplotlib from mesa.visualization.UserParam import Slider -# Avoid interactive backend -plt.switch_backend("agg") - # TODO: Turn this function into a Solara component once the current_step.value # dependency is passed to measure() From ec4233a69a27b64c41e0743c4149b953ceaf7ed3 Mon Sep 17 00:00:00 2001 From: rht Date: Thu, 4 Jul 2024 12:06:37 -0400 Subject: [PATCH 39/50] Remove last remnants of visualization_old (#2169) --- .../visualization_old/ModularVisualization.py | 1 - mesa/visualization_old/TextVisualization.py | 1 - mesa/visualization_old/UserParam.py | 1 - mesa/visualization_old/__init__.py | 7 ---- mesa/visualization_old/modules.py | 1 - tests/test_main.py | 34 ------------------- 6 files changed, 45 deletions(-) delete mode 100644 mesa/visualization_old/ModularVisualization.py delete mode 100644 mesa/visualization_old/TextVisualization.py delete mode 100644 mesa/visualization_old/UserParam.py delete mode 100644 mesa/visualization_old/__init__.py delete mode 100644 mesa/visualization_old/modules.py delete mode 100644 tests/test_main.py diff --git a/mesa/visualization_old/ModularVisualization.py b/mesa/visualization_old/ModularVisualization.py deleted file mode 100644 index dbdbf3b3f9f..00000000000 --- a/mesa/visualization_old/ModularVisualization.py +++ /dev/null @@ -1 +0,0 @@ -from mesa_viz_tornado.ModularVisualization import * # noqa diff --git a/mesa/visualization_old/TextVisualization.py b/mesa/visualization_old/TextVisualization.py deleted file mode 100644 index f41c90ea595..00000000000 --- a/mesa/visualization_old/TextVisualization.py +++ /dev/null @@ -1 +0,0 @@ -from mesa_viz_tornado.TextVisualization import * # noqa diff --git a/mesa/visualization_old/UserParam.py b/mesa/visualization_old/UserParam.py deleted file mode 100644 index d41362389f4..00000000000 --- a/mesa/visualization_old/UserParam.py +++ /dev/null @@ -1 +0,0 @@ -from mesa_viz_tornado.UserParam import * # noqa diff --git a/mesa/visualization_old/__init__.py b/mesa/visualization_old/__init__.py deleted file mode 100644 index e8b7256e25f..00000000000 --- a/mesa/visualization_old/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -import contextlib - -with contextlib.suppress(ImportError): - from mesa_viz_tornado.ModularVisualization import * # noqa - from mesa_viz_tornado.modules import * # noqa - from mesa_viz_tornado.UserParam import * # noqa - from mesa_viz_tornado.TextVisualization import * # noqa diff --git a/mesa/visualization_old/modules.py b/mesa/visualization_old/modules.py deleted file mode 100644 index 40fc193a522..00000000000 --- a/mesa/visualization_old/modules.py +++ /dev/null @@ -1 +0,0 @@ -from mesa_viz_tornado.modules import * # noqa diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index b6bba370bf3..00000000000 --- a/tests/test_main.py +++ /dev/null @@ -1,34 +0,0 @@ -import os -import sys -import unittest -from unittest.mock import patch - -from click.testing import CliRunner - -from mesa.main import cli - - -class TestCli(unittest.TestCase): - """ - Test CLI commands - """ - - def setUp(self): - self.old_sys_path = sys.path[:] - self.runner = CliRunner() - - def tearDown(self): - sys.path[:] = self.old_sys_path - - @unittest.skip( - "Skipping test_run, because examples folder was moved. More discussion needed." - ) - def test_run(self): - with patch("mesa.visualization_old.ModularServer") as ModularServer: # noqa: N806 - example_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "../examples/wolf_sheep") - ) - with self.runner.isolated_filesystem(): - result = self.runner.invoke(cli, ["runserver", example_dir]) - assert result.exit_code == 0, result.output - assert ModularServer().launch.call_count == 1 From fb4177c39217106d8c2af9a048ea70442667e0f9 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 4 Jul 2024 21:05:12 +0200 Subject: [PATCH 40/50] Set version to 3.0.0a0 and update release notes (#2167) Sets the version to 3.0.0a0 for the first Mesa 3.0 pre-release, and updates the release notes with 2.3.1 and 3.0.0a0 changelog --- HISTORY.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ mesa/__init__.py | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 191a7ebc771..999c89920c4 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,55 @@ --- title: Release History --- +# 3.0.0a0 (2024-07-04) +## Highlights +This is the first pre-release in the Mesa 3.0 series, which is still in active development. The `v3.0.0a0` pre-release can help active Mesa developers help starting to test the latest features in their models. + +Since it's in active development, more breaking changes may follow and it's not recommended for general usage. + +There are two major breaking changes at this point: +- The old visualisation is removed, in favor of the new, Solara based, Jupyter Viz. This was already available in the 2.3.x release series, but is now stabilized. Checkout out our new [Visualization Tutorial](https://mesa.readthedocs.io/en/latest/tutorials/visualization_tutorial.html). More examples and a migration guide will follow later in the Mesa 3.0 development. +- The `mesa.flat` namespace is removed, since was not used very often. + +Mesa 3.0 will require Python 3.10+. + +This pre-release can be installed with `pip install mesa --upgrade --pre`. + +## What's Changed +### ⚠️ Breaking changes +* Remove mesa.flat namespace by @rht in https://github.com/projectmesa/mesa/pull/2091 +* breaking: Remove visualization_old (mesa-viz-tornado) by @rht in https://github.com/projectmesa/mesa/pull/2133 +### 🎉 New features added +* Set JupyterViz as stable by @rht in https://github.com/projectmesa/mesa/pull/2090 +### 🐛 Bugs fixed +* Jupyter_viz: Allow measures to be None by @EwoutH in https://github.com/projectmesa/mesa/pull/2163 +* Jupyter Viz: Don't avoid interactive backend by @EwoutH in https://github.com/projectmesa/mesa/pull/2165 +### 📜 Documentation improvements +* Fix image on landing page of docs. by @jackiekazil in https://github.com/projectmesa/mesa/pull/2146 +* Replace links in docs - google group to matrix. by @jackiekazil in https://github.com/projectmesa/mesa/pull/2148 +* Update visualisation docs by @EwoutH in https://github.com/projectmesa/mesa/pull/2162 +### 🔧 Maintenance +* CI: Add weekly scheduled run to all CI workflows by @EwoutH in https://github.com/projectmesa/mesa/pull/2130 +* Drop support for Python 3.9, require Python >= 3.10 by @EwoutH in https://github.com/projectmesa/mesa/pull/2132 +* Add script to list unlabeled PR's since latest release by @rht in https://github.com/projectmesa/mesa/pull/2047 + +## New Contributors +* @stephenfmann made their first contribution in https://github.com/projectmesa/mesa/pull/2154 + +**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.3.1...v3.0.0a0 + +# 2.3.1 (2024-07-03) +## Highlights +Mesa 2.3.1 is a small patch release with a datacollector bug fixed and improved documentation. + +## What's Changed +### 🐛 Bugs fixed +* datacollector: store separate snapshots of model data per step by @EwoutH in https://github.com/projectmesa/mesa/pull/2129 +### 📜 Documentation improvements +* Add experimental features to documentation as per #2122 by @stephenfmann in https://github.com/projectmesa/mesa/pull/2154 + +**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.3.0...v2.3.1 + # 2.3.0 (2024-04-23) ## Highlights Mesa 2.3.0 is a big feature release and the last feature release before 3.0. diff --git a/mesa/__init__.py b/mesa/__init__.py index 09f93313175..bc4db1b72f6 100644 --- a/mesa/__init__.py +++ b/mesa/__init__.py @@ -24,7 +24,7 @@ ] __title__ = "mesa" -__version__ = "3.0.0-dev" +__version__ = "3.0.0a0" __license__ = "Apache 2.0" _this_year = datetime.datetime.now(tz=datetime.timezone.utc).date().year __copyright__ = f"Copyright {_this_year} Project Mesa Team" From 998965fabd3796b8add4049270f95df0a8d4a9e0 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Mon, 8 Jul 2024 10:35:37 +0200 Subject: [PATCH 41/50] Add .venv/ to .gitignore (#2172) Don't track virtual environment files in Git --- .gitignore | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 814f6a5daf3..78b8b417a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ __pycache__/ .Python env/ venv/ +pythonenv*/ +.venv/ build/ develop-eggs/ dist/ @@ -83,10 +85,6 @@ target/ .dmypy.json dmypy.json -# pythonenv -pythonenv*/ - # JS dependencies mesa/visualization/templates/external/ mesa/visualization/templates/js/external/ - From a7090fc8e5b5e32127bc6665d80983a49eddba2a Mon Sep 17 00:00:00 2001 From: Aditya Tiwari <65213780+ENUMERA8OR@users.noreply.github.com> Date: Tue, 9 Jul 2024 12:45:45 +0530 Subject: [PATCH 42/50] Add original conference paper link to docs (#2160) --- docs/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/index.md b/docs/index.md index 14bc0ba026b..669f9294a3a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,6 +18,8 @@ [Mesa] is an Apache2 licensed agent-based modeling (or ABM) framework in Python. +The original conference paper is [available here](http://conference.scipy.org.s3-website-us-east-1.amazonaws.com/proceedings/scipy2015/jacqueline_kazil.html). + Mesa allows users to quickly create agent-based models using built-in core components (such as spatial grids and agent schedulers) or customized implementations; visualize them using a browser-based interface; and analyze their results using Python's data analysis tools. Its goal is to be the Python-based counterpart to NetLogo, Repast, or MASON. From 909831f3b2418bb0b044874511d779601e19e027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Mon, 15 Jul 2024 12:21:44 -0300 Subject: [PATCH 43/50] added voronoi visualization --- mesa/experimental/cell_space/voronoi.py | 247 +++++++++++++++++++- mesa/visualization/components/matplotlib.py | 54 +++++ 2 files changed, 293 insertions(+), 8 deletions(-) diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py index 02a52c2cbdb..f128ffdf4df 100644 --- a/mesa/experimental/cell_space/voronoi.py +++ b/mesa/experimental/cell_space/voronoi.py @@ -1,21 +1,219 @@ from collections.abc import Sequence from itertools import combinations + +# from pyhull.delaunay import DelaunayTri +# from pyhull.voronoi import VoronoiTess +from math import sqrt from random import Random -from typing import Optional -from pyhull.delaunay import DelaunayTri +import numpy as np from mesa.experimental.cell_space.cell import Cell from mesa.experimental.cell_space.discrete_space import DiscreteSpace +class Delaunay: + """ + Class to compute a Delaunay triangulation in 2D + ref: http://github.com/jmespadero/pyDelaunay2D + """ + + def __init__(self, center: tuple = (0, 0), radius: int = 9999) -> None: + """ + Init and create a new frame to contain the triangulation + center: Optional position for the center of the frame. Default (0,0) + radius: Optional distance from corners to the center. + """ + center = np.asarray(center) + # Create coordinates for the corners of the frame + self.coords = [ + center + radius * np.array((-1, -1)), + center + radius * np.array((+1, -1)), + center + radius * np.array((+1, +1)), + center + radius * np.array((-1, +1)), + ] + + # Create two dicts to store triangle neighbours and circumcircles. + self.triangles = {} + self.circles = {} + + # Create two CCW triangles for the frame + triangle1 = (0, 1, 3) + triangle2 = (2, 3, 1) + self.triangles[triangle1] = [triangle2, None, None] + self.triangles[triangle2] = [triangle1, None, None] + + # Compute circumcenters and circumradius for each triangle + for t in self.triangles: + self.circles[t] = self.circumcenter(t) + + def circumcenter(self, triangle: list) -> tuple: + """ + Compute circumcenter and circumradius of a triangle in 2D. + """ + points = np.asarray([self.coords[v] for v in triangle]) + points2 = np.dot(points, points.T) + a = np.bmat([[2 * points2, [[1], [1], [1]]], [[[1, 1, 1, 0]]]]) + + b = np.hstack((np.sum(points * points, axis=1), [1])) + x = np.linalg.solve(a, b) + bary_coords = x[:-1] + center = np.dot(bary_coords, points) + + radius = np.sum(np.square(points[0] - center)) # squared distance + return (center, radius) + + def in_circle(self, triangle: list, point: list) -> bool: + """ + Check if point p is inside of precomputed circumcircle of triangle. + """ + center, radius = self.circles[triangle] + return np.sum(np.square(center - point)) <= radius + + def add_point(self, point: Sequence) -> None: + """ + Add a point to the current DT, and refine it using Bowyer-Watson. + """ + point_index = len(self.coords) + self.coords.append(np.asarray(point)) + + bad_triangles = [] + for triangle in self.triangles: + if self.in_circle(triangle, point): + bad_triangles.append(triangle) + + boundary = [] + triangle = bad_triangles[0] + edge = 0 + + while True: + opposite_triangle = self.triangles[triangle][edge] + if opposite_triangle not in bad_triangles: + boundary.append( + ( + triangle[(edge + 1) % 3], + triangle[(edge - 1) % 3], + opposite_triangle, + ) + ) + edge = (edge + 1) % 3 + if boundary[0][0] == boundary[-1][1]: + break + else: + edge = (self.triangles[opposite_triangle].index(triangle) + 1) % 3 + triangle = opposite_triangle + + for triangle in bad_triangles: + del self.triangles[triangle] + del self.circles[triangle] + + new_triangles = [] + for e0, e1, opposite_triangle in boundary: + triangle = (point_index, e0, e1) + self.circles[triangle] = self.circumcenter(triangle) + self.triangles[triangle] = [opposite_triangle, None, None] + if opposite_triangle: + for i, neighbor in enumerate(self.triangles[opposite_triangle]): + if neighbor and e1 in neighbor and e0 in neighbor: + self.triangles[opposite_triangle][i] = triangle + + new_triangles.append(triangle) + + n = len(new_triangles) + for i, triangle in enumerate(new_triangles): + self.triangles[triangle][1] = new_triangles[(i + 1) % n] # next + self.triangles[triangle][2] = new_triangles[(i - 1) % n] # previous + + def exportTriangles(self) -> list: + """ + Export the current list of Delaunay triangles + """ + triangles_list = [ + (a - 4, b - 4, c - 4) + for (a, b, c) in self.triangles + if a > 3 and b > 3 and c > 3 + ] + return triangles_list + + def exportCircles(self) -> list: + """ + Export the circumcircles as a list of (center, radius) + """ + circles_list = [ + (self.circles[(a, b, c)][0], sqrt(self.circles[(a, b, c)][1])) + for (a, b, c) in self.triangles + if a > 3 and b > 3 and c > 3 + ] + return circles_list + + def exportDT(self) -> tuple[list, list]: + """ + Export the current set of Delaunay coordinates and triangles. + """ + coord = self.coords[4:] + + tris = [ + (a - 4, b - 4, c - 4) + for (a, b, c) in self.triangles + if a > 3 and b > 3 and c > 3 + ] + return coord, tris + + def exportExtendedDT(self): + """ + Export the Extended Delaunay Triangulation (with the frame vertex). + """ + return self.coords, list(self.triangles) + + def exportVoronoiRegions(self): + """ + Export coordinates and regions of Voronoi diagram as indexed data. + """ + use_vertex = {i: [] for i in range(len(self.coords))} + vor_coors = [] + index = {} + for triangle_index, (a, b, c) in enumerate(sorted(self.triangles)): + vor_coors.append(self.circles[(a, b, c)][0]) + use_vertex[a] += [(b, c, a)] + use_vertex[b] += [(c, a, b)] + use_vertex[c] += [(a, b, c)] + + index[(a, b, c)] = triangle_index + index[(c, a, b)] = triangle_index + index[(b, c, a)] = triangle_index + + regions = {} + for i in range(4, len(self.coords)): + vertex = use_vertex[i][0][0] + region = [] + for _ in range(len(use_vertex[i])): + triangle = next( + triangle for triangle in use_vertex[i] if triangle[0] == vertex + ) + region.append(index[triangle]) + vertex = triangle[1] + regions[i - 4] = region + + return vor_coors, regions + + +def round_float(x: float) -> int: + return int(np.ceil(x)) * 2000 + + class VoronoiGrid(DiscreteSpace): + triangulation: Delaunay + voronoi_coordinates: list + regions: list + def __init__( self, centroids_coordinates: Sequence[Sequence[float]], - capacity: Optional[float] = None, - random: Optional[Random] = None, + capacity: float | None = None, + random: Random | None = None, cell_klass: type[Cell] = Cell, + capacity_function: callable = round_float, + cell_coloring_attribute: str | None = None, ) -> None: """A Voronoi Tessellation Grid @@ -34,19 +232,28 @@ def __init__( for i in range(len(self.centroids_coordinates)) } + self.regions = None + self.triangulation = None + self.voronoi_coordinates = None + self.capacity_function = capacity_function + self.cell_coloring_attribute = cell_coloring_attribute + self._connect_cells() + self._build_cell_polygons() def _connect_cells(self) -> None: """Connect cells to neighbors based on given centroids and using Delaunay Triangulation""" - triangulation = DelaunayTri(self.centroids_coordinates) + self.triangulation = Delaunay() + for centroid in self.centroids_coordinates: + self.triangulation.add_point(centroid) - for p in triangulation.vertices: - for i, j in combinations(p, 2): + for point in self.triangulation.exportTriangles(): + for i, j in combinations(point, 2): self._cells[i].connect(self._cells[j]) self._cells[j].connect(self._cells[i]) def _validate_parameters(self) -> None: - if self.capacity is not None and not isinstance(self.capacity, (float, int)): + if self.capacity is not None and not isinstance(self.capacity, float | int): raise ValueError("Capacity must be a number or None.") if not isinstance(self.centroids_coordinates, Sequence) and not isinstance( self.centroids_coordinates[0], Sequence @@ -56,3 +263,27 @@ def _validate_parameters(self) -> None: for coordinate in self.centroids_coordinates: if dimension_1 != len(coordinate): raise ValueError("Centroid coordinates should be a homogeneous array") + + def _get_voronoi_regions(self) -> tuple: + if self.voronoi_coordinates is None or self.regions is None: + self.voronoi_coordinates, self.regions = ( + self.triangulation.exportVoronoiRegions() + ) + return self.voronoi_coordinates, self.regions + + @staticmethod + def _compute_polygon_area(polygon: list) -> float: + polygon = np.array(polygon) + x = polygon[:, 0] + y = polygon[:, 1] + return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) + + def _build_cell_polygons(self): + coordinates, regions = self._get_voronoi_regions() + for region in regions: + polygon = [coordinates[i] for i in regions[region]] + self._cells[region].properties["polygon"] = polygon + polygon_area = self._compute_polygon_area(polygon) + self._cells[region].properties["area"] = polygon_area + self._cells[region].capacity = self.capacity_function(polygon_area) + self._cells[region].properties[self.cell_coloring_attribute] = 0 diff --git a/mesa/visualization/components/matplotlib.py b/mesa/visualization/components/matplotlib.py index 6284944aa7f..24d548b11a5 100644 --- a/mesa/visualization/components/matplotlib.py +++ b/mesa/visualization/components/matplotlib.py @@ -4,6 +4,7 @@ from matplotlib.ticker import MaxNLocator import mesa +from mesa.experimental.cell_space import VoronoiGrid @solara.component @@ -18,6 +19,8 @@ def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = Non _draw_network_grid(space, space_ax, agent_portrayal) elif isinstance(space, mesa.space.ContinuousSpace): _draw_continuous_space(space, space_ax, agent_portrayal) + elif isinstance(space, VoronoiGrid): + _draw_voronoi(space, space_ax, agent_portrayal) else: _draw_grid(space, space_ax, agent_portrayal) solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies) @@ -53,6 +56,7 @@ def portray(g): out["s"] = s if len(c) > 0: out["c"] = c + out["marker"] = data["marker"] return out space_ax.set_xlim(-1, space.width) @@ -113,6 +117,56 @@ def portray(space): space_ax.scatter(**portray(space)) +def _draw_voronoi(space, space_ax, agent_portrayal): + def portray(g): + x = [] + y = [] + s = [] # size + c = [] # color + + for cell in g.all_cells: + for agent in cell.agents: + data = agent_portrayal(agent) + x.append(cell.coordinate[0]) + y.append(cell.coordinate[1]) + if "size" in data: + s.append(data["size"]) + if "color" in data: + c.append(data["color"]) + out = {"x": x, "y": y} + # This is the default value for the marker size, which auto-scales + # according to the grid area. + out["s"] = s + if len(c) > 0: + out["c"] = c + + return out + + x_list = [i[0] for i in space.centroids_coordinates] + y_list = [i[1] for i in space.centroids_coordinates] + x_max = max(x_list) + x_min = min(x_list) + y_max = max(y_list) + y_min = min(y_list) + + width = x_max - x_min + x_padding = width / 20 + height = y_max - y_min + y_padding = height / 20 + space_ax.set_xlim(x_min - x_padding, x_max + x_padding) + space_ax.set_ylim(y_min - y_padding, y_max + y_padding) + space_ax.scatter(**portray(space)) + + for cell in space.all_cells: + polygon = cell.properties["polygon"] + space_ax.fill( + *zip(*polygon), + alpha=min(1, cell.properties[space.cell_coloring_attribute]), + c="red", + ) # Plot filled polygon + space_ax.plot(*zip(*polygon), color="black") # Plot polygon edges in red + + @solara.component def PlotMatplotlib(model, measure, dependencies: list[any] | None = None): fig = Figure() From 981473f12daca6a8b8d044c4a542a60ce9fd408a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Mon, 15 Jul 2024 13:05:59 -0300 Subject: [PATCH 44/50] var names --- mesa/experimental/cell_space/voronoi.py | 73 ++++++++----------------- 1 file changed, 24 insertions(+), 49 deletions(-) diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py index f128ffdf4df..aba317bfcdd 100644 --- a/mesa/experimental/cell_space/voronoi.py +++ b/mesa/experimental/cell_space/voronoi.py @@ -1,9 +1,5 @@ from collections.abc import Sequence from itertools import combinations - -# from pyhull.delaunay import DelaunayTri -# from pyhull.voronoi import VoronoiTess -from math import sqrt from random import Random import numpy as np @@ -45,9 +41,9 @@ def __init__(self, center: tuple = (0, 0), radius: int = 9999) -> None: # Compute circumcenters and circumradius for each triangle for t in self.triangles: - self.circles[t] = self.circumcenter(t) + self.circles[t] = self._circumcenter(t) - def circumcenter(self, triangle: list) -> tuple: + def _circumcenter(self, triangle: list) -> tuple: """ Compute circumcenter and circumradius of a triangle in 2D. """ @@ -63,7 +59,7 @@ def circumcenter(self, triangle: list) -> tuple: radius = np.sum(np.square(points[0] - center)) # squared distance return (center, radius) - def in_circle(self, triangle: list, point: list) -> bool: + def _in_circle(self, triangle: list, point: list) -> bool: """ Check if point p is inside of precomputed circumcircle of triangle. """ @@ -79,7 +75,7 @@ def add_point(self, point: Sequence) -> None: bad_triangles = [] for triangle in self.triangles: - if self.in_circle(triangle, point): + if self._in_circle(triangle, point): bad_triangles.append(triangle) boundary = [] @@ -110,7 +106,7 @@ def add_point(self, point: Sequence) -> None: new_triangles = [] for e0, e1, opposite_triangle in boundary: triangle = (point_index, e0, e1) - self.circles[triangle] = self.circumcenter(triangle) + self.circles[triangle] = self._circumcenter(triangle) self.triangles[triangle] = [opposite_triangle, None, None] if opposite_triangle: for i, neighbor in enumerate(self.triangles[opposite_triangle]): @@ -124,7 +120,7 @@ def add_point(self, point: Sequence) -> None: self.triangles[triangle][1] = new_triangles[(i + 1) % n] # next self.triangles[triangle][2] = new_triangles[(i - 1) % n] # previous - def exportTriangles(self) -> list: + def export_triangles(self) -> list: """ Export the current list of Delaunay triangles """ @@ -135,37 +131,7 @@ def exportTriangles(self) -> list: ] return triangles_list - def exportCircles(self) -> list: - """ - Export the circumcircles as a list of (center, radius) - """ - circles_list = [ - (self.circles[(a, b, c)][0], sqrt(self.circles[(a, b, c)][1])) - for (a, b, c) in self.triangles - if a > 3 and b > 3 and c > 3 - ] - return circles_list - - def exportDT(self) -> tuple[list, list]: - """ - Export the current set of Delaunay coordinates and triangles. - """ - coord = self.coords[4:] - - tris = [ - (a - 4, b - 4, c - 4) - for (a, b, c) in self.triangles - if a > 3 and b > 3 and c > 3 - ] - return coord, tris - - def exportExtendedDT(self): - """ - Export the Extended Delaunay Triangulation (with the frame vertex). - """ - return self.coords, list(self.triangles) - - def exportVoronoiRegions(self): + def export_voronoi_regions(self): """ Export coordinates and regions of Voronoi diagram as indexed data. """ @@ -198,7 +164,7 @@ def exportVoronoiRegions(self): def round_float(x: float) -> int: - return int(np.ceil(x)) * 2000 + return int(x * 500) class VoronoiGrid(DiscreteSpace): @@ -213,15 +179,22 @@ def __init__( random: Random | None = None, cell_klass: type[Cell] = Cell, capacity_function: callable = round_float, - cell_coloring_attribute: str | None = None, + cell_coloring_property: str | None = None, ) -> None: - """A Voronoi Tessellation Grid + """ + A Voronoi Tessellation Grid. + + Given a set of points, this class creates a grid where a cell is centered in each point, + its neighbors are given by Voronoi Tessellation cells neighbors + and the capacity by the polygon area. Args: centroids_coordinates: coordinates of centroids to build the tessellation space capacity (int) : capacity of the cells in the discrete space random (Random): random number generator CellKlass (type[Cell]): type of cell class + capacity_function (Callable): function to compute (int) capacity according to (float) area + cell_coloring_property (str): voronoi visualization polygon fill property """ super().__init__(capacity=capacity, random=random, cell_klass=cell_klass) self.centroids_coordinates = centroids_coordinates @@ -236,18 +209,20 @@ def __init__( self.triangulation = None self.voronoi_coordinates = None self.capacity_function = capacity_function - self.cell_coloring_attribute = cell_coloring_attribute + self.cell_coloring_property = cell_coloring_property self._connect_cells() self._build_cell_polygons() def _connect_cells(self) -> None: - """Connect cells to neighbors based on given centroids and using Delaunay Triangulation""" + """ + Connect cells to neighbors based on given centroids and using Delaunay Triangulation + """ self.triangulation = Delaunay() for centroid in self.centroids_coordinates: self.triangulation.add_point(centroid) - for point in self.triangulation.exportTriangles(): + for point in self.triangulation.export_triangles(): for i, j in combinations(point, 2): self._cells[i].connect(self._cells[j]) self._cells[j].connect(self._cells[i]) @@ -267,7 +242,7 @@ def _validate_parameters(self) -> None: def _get_voronoi_regions(self) -> tuple: if self.voronoi_coordinates is None or self.regions is None: self.voronoi_coordinates, self.regions = ( - self.triangulation.exportVoronoiRegions() + self.triangulation.export_voronoi_regions() ) return self.voronoi_coordinates, self.regions @@ -286,4 +261,4 @@ def _build_cell_polygons(self): polygon_area = self._compute_polygon_area(polygon) self._cells[region].properties["area"] = polygon_area self._cells[region].capacity = self.capacity_function(polygon_area) - self._cells[region].properties[self.cell_coloring_attribute] = 0 + self._cells[region].properties[self.cell_coloring_property] = 0 From 5b66f73265baff80ab54789eede33b929c51b5d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Mon, 15 Jul 2024 13:06:18 -0300 Subject: [PATCH 45/50] voronoi visualization --- mesa/visualization/components/matplotlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesa/visualization/components/matplotlib.py b/mesa/visualization/components/matplotlib.py index 24d548b11a5..e25003e9c2c 100644 --- a/mesa/visualization/components/matplotlib.py +++ b/mesa/visualization/components/matplotlib.py @@ -161,7 +161,7 @@ def portray(g): polygon = cell.properties["polygon"] space_ax.fill( *zip(*polygon), - alpha=min(1, cell.properties[space.cell_coloring_attribute]), + alpha=min(1, cell.properties[space.cell_coloring_property]), c="red", ) # Plot filled polygon space_ax.plot(*zip(*polygon), color="black") # Plot polygon edges in red From 0939e3c717db16349cbabad22659de950377647a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:13:15 +0000 Subject: [PATCH 46/50] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/visualization/components/matplotlib.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mesa/visualization/components/matplotlib.py b/mesa/visualization/components/matplotlib.py index debd8a42bfe..53542a41fab 100644 --- a/mesa/visualization/components/matplotlib.py +++ b/mesa/visualization/components/matplotlib.py @@ -115,6 +115,7 @@ def portray(space): # Portray and scatter the agents in the space space_ax.scatter(**portray(space)) + def _draw_voronoi(space, space_ax, agent_portrayal): def portray(g): x = [] @@ -164,6 +165,7 @@ def portray(g): ) # Plot filled polygon space_ax.plot(*zip(*polygon), color="black") # Plot polygon edges in red + @solara.component def PlotMatplotlib(model, measure, dependencies: list[any] | None = None): fig = Figure() From ab1d1b60e2e5b7ce4f1545f28451673b2523fa67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Mon, 15 Jul 2024 14:41:54 -0300 Subject: [PATCH 47/50] added voronoi tests --- mesa/experimental/cell_space/voronoi.py | 2 +- tests/test_cell_space.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py index aba317bfcdd..4395ca4ead8 100644 --- a/mesa/experimental/cell_space/voronoi.py +++ b/mesa/experimental/cell_space/voronoi.py @@ -230,7 +230,7 @@ def _connect_cells(self) -> None: def _validate_parameters(self) -> None: if self.capacity is not None and not isinstance(self.capacity, float | int): raise ValueError("Capacity must be a number or None.") - if not isinstance(self.centroids_coordinates, Sequence) and not isinstance( + if not isinstance(self.centroids_coordinates, Sequence) or not isinstance( self.centroids_coordinates[0], Sequence ): raise ValueError("Centroids should be a list of lists") diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py index d0324f7e450..a918f59e53b 100644 --- a/tests/test_cell_space.py +++ b/tests/test_cell_space.py @@ -385,6 +385,15 @@ def test_voronoigrid(): for connection in grid._cells[0]._connections: assert connection.coordinate in [[1, 1], [1, 3]] + with pytest.raises(ValueError): + VoronoiGrid(points, capacity="str") + + with pytest.raises(ValueError): + VoronoiGrid((1, 1)) + + with pytest.raises(ValueError): + VoronoiGrid([[0, 1], [0, 1, 1]]) + def test_empties_space(): import networkx as nx From c2d7379b5815e5d49990b177e2d3196c2617d7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Mon, 15 Jul 2024 15:11:52 -0300 Subject: [PATCH 48/50] added voronoi tests --- tests/test_jupyter_viz.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_jupyter_viz.py b/tests/test_jupyter_viz.py index 76ee666ee8a..84c44b9aec0 100644 --- a/tests/test_jupyter_viz.py +++ b/tests/test_jupyter_viz.py @@ -138,6 +138,17 @@ def test_call_space_drawer(mocker): ) altspace_drawer.assert_called_with(model, agent_portrayal) + # check voronoi space drawer + voronoi_model = mesa.Model() + voronoi_model.grid = mesa.experimental.cell_space.VoronoiGrid( + centroids_coordinates=[(0, 1), (0, 0), (1, 0)], + ) + solara.render( + JupyterViz( + model_class=voronoi_model, model_params={}, agent_portrayal=agent_portrayal + ) + ) + def test_slider(): slider_float = Slider("Agent density", 0.8, 0.1, 1.0, 0.1) From f1857a685a24916212e1d4fd6b305614ce081d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Fr=C3=B3is?= Date: Tue, 27 Aug 2024 23:08:28 -0300 Subject: [PATCH 49/50] removed pyhull dependency --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d0bec775ae5..1f8933c56d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ dependencies = [ "pandas", "solara", "tqdm", - "pyhull", ] dynamic = ["version"] From e0ded761acdc26f23ab9644f671c2e35ea45ffac Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Fri, 30 Aug 2024 16:34:10 +0200 Subject: [PATCH 50/50] Update test_solara_viz.py --- tests/test_solara_viz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py index 0b5e8655a4c..660277b3d7e 100644 --- a/tests/test_solara_viz.py +++ b/tests/test_solara_viz.py @@ -146,7 +146,7 @@ def test_call_space_drawer(mocker): centroids_coordinates=[(0, 1), (0, 0), (1, 0)], ) solara.render( - JupyterViz( + SolaraViz( model_class=voronoi_model, model_params={}, agent_portrayal=agent_portrayal ) )