Repository: projectmesa/mesa

Issue:
[1.1.0 regression] Image files from examples don't load (on Windows)
**Describe the bug**
On Windows using Mesa 1.1.0, agent portrayal images don't load when running an example.

**Expected behavior**
Agent images load without errors in the example model.

**To Reproduce**

1. Checkout Mesa 1.1.0 (or the current main branch)
2. Run the `run.py` file of one of the examples (_sugarscape_cg_ or _wolf_sheep_) (on Windows?)

**Additional context**

### 1.0.0

<img width="300" alt="Screenshot_797" src="https://user-images.githubusercontent.com/15776622/196971492-45090e68-bae1-4150-a168-a5a3c1b98465.png"><img width="300" alt="Screenshot_798" src="https://user-images.githubusercontent.com/15776622/196971495-6870ce27-6635-4bc6-a9d6-78213b05cf10.png">

### 1.1.0

<img width="300" alt="Screenshot_799" src="https://user-images.githubusercontent.com/15776622/196971800-b04e2668-1fc2-46ec-bc86-7156662cc54c.png"><img width="400" alt="Screenshot_796" src="https://user-images.githubusercontent.com/15776622/196971554-23d7eb80-e4e9-4aed-b3e3-1f19c4e21db5.png">


Discussion:

Patch:
diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml
index 4943a5bb..5d4b6d80 100644
--- a/.github/workflows/build_lint.yml
+++ b/.github/workflows/build_lint.yml
@@ -36,6 +36,8 @@ jobs:
             python-version: "3.9"
           - os: ubuntu
             python-version: "3.8"
+          - os: ubuntu
+            python-version: "3.7"
           # 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 396dfc75..c069e3eb 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,15 +4,15 @@ ci:
 
 repos:
 -   repo: https://github.com/psf/black
-    rev: 22.10.0
+    rev: 22.8.0
     hooks:
       - id: black-jupyter
         exclude: ^mesa/cookiecutter-mesa/
 -   repo: https://github.com/asottile/pyupgrade
-    rev: v3.1.0
+    rev: v2.38.2
     hooks:
     -   id: pyupgrade
-        args: [--py38-plus]
+        args: [--py37-plus]
 -   repo: https://github.com/pre-commit/pre-commit-hooks
     rev: v4.3.0  # Use the ref you want to point at
     hooks:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 292b2461..43d7edd8 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -14,7 +14,7 @@ formats:
 
 # Optionally set the version of Python and requirements required to build your docs
 python:
-  version: "3.8"
+  version: 3.7
   install:
     - method: pip
       path: .
diff --git a/HISTORY.rst b/HISTORY.rst
index abcb75d8..e319b817 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,6 +3,11 @@
 Release History
 ---------------
 
+1.1.1 (2022-10-21)
+++++++++++++++++++
+
+This release fixes https://github.com/projectmesa/mesa/issues/1461 where custom user-specified portrayal images don't load in the visualization server.
+
 1.1.0 (2022-10-10) Safford
 ++++++++++++++++++++++++++
 
@@ -138,7 +143,7 @@ Release History
 
 * Dependency updates
     * build(deps): bump cookiecutter from 2.1.0 to 2.1.1 dependencies #1360
-    * Update Pipfile.lock (dependencies) #1374, #1350, #1301, #1224, #1203, #1135 by github-actions bot
+    * Update Pipfile.lock (dependencies) #1350, #1301, #1224, #1203, #1135 by github-actions bot
     * Migrate D3 from v4 to v7 #1088
 
 * Other Improvements
@@ -193,8 +198,6 @@ Release History
 * examples: correcting comment in examples/pd_grid/pd_grid/agent.py #1247
 * space: Clarify the return object of get_cell_list_contents #1242
 * width and height were changed up #1149
-* fix typo in best-practices.rst #1368
-* fix: examples: Make space x, y order consistent #1366
 
 
 
diff --git a/examples/sugarscape_cg/sugarscape_cg/server.py b/examples/sugarscape_cg/sugarscape_cg/server.py
index 54a3e857..7ceb3323 100644
--- a/examples/sugarscape_cg/sugarscape_cg/server.py
+++ b/examples/sugarscape_cg/sugarscape_cg/server.py
@@ -10,24 +10,25 @@ def SsAgent_portrayal(agent):
     if agent is None:
         return
 
+    portrayal = {}
+
     if type(agent) is SsAgent:
-        return {"Shape": "sugarscape_cg/resources/ant.png", "scale": 0.9, "Layer": 1}
+        portrayal["Shape"] = "sugarscape_cg/resources/ant.png"
+        portrayal["scale"] = 0.9
+        portrayal["Layer"] = 1
 
     elif type(agent) is Sugar:
         if agent.amount != 0:
-            color = color_dic[agent.amount]
+            portrayal["Color"] = color_dic[agent.amount]
         else:
-            color = "#D6F5D6"
-        return {
-            "Color": color,
-            "Shape": "rect",
-            "Filled": "true",
-            "Layer": 0,
-            "w": 1,
-            "h": 1,
-        }
-
-    return {}
+            portrayal["Color"] = "#D6F5D6"
+        portrayal["Shape"] = "rect"
+        portrayal["Filled"] = "true"
+        portrayal["Layer"] = 0
+        portrayal["w"] = 1
+        portrayal["h"] = 1
+
+    return portrayal
 
 
 canvas_element = mesa.visualization.CanvasGrid(SsAgent_portrayal, 50, 50, 500, 500)
diff --git a/mesa/__init__.py b/mesa/__init__.py
index b5d5c7a7..1371a900 100644
--- a/mesa/__init__.py
+++ b/mesa/__init__.py
@@ -26,6 +26,6 @@ __all__ = [
 ]
 
 __title__ = "mesa"
-__version__ = "1.1.0"
+__version__ = "1.1.1"
 __license__ = "Apache 2.0"
 __copyright__ = f"Copyright {datetime.date.today().year} Project Mesa Team"
diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py
index 8c6a1ef5..76d6a317 100644
--- a/mesa/batchrunner.py
+++ b/mesa/batchrunner.py
@@ -8,6 +8,7 @@ A single class to manage a batch run or parameter sweep of a given model.
 import copy
 import itertools
 import random
+from collections import OrderedDict
 from functools import partial
 from itertools import count, product
 from multiprocessing import Pool, cpu_count
@@ -309,8 +310,9 @@ class FixedBatchRunner:
         if self.agent_reporters:
             self.agent_vars = {}
 
-        self.datacollector_model_reporters = {}
-        self.datacollector_agent_reporters = {}
+        # Make Compatible with Python 3.5
+        self.datacollector_model_reporters = OrderedDict()
+        self.datacollector_agent_reporters = OrderedDict()
 
         self.display_progress = display_progress
 
@@ -405,7 +407,7 @@ class FixedBatchRunner:
 
     def collect_model_vars(self, model):
         """Run reporters and collect model-level variables."""
-        model_vars = {}
+        model_vars = OrderedDict()
         for var, reporter in self.model_reporters.items():
             model_vars[var] = reporter(model)
 
@@ -413,9 +415,9 @@ class FixedBatchRunner:
 
     def collect_agent_vars(self, model):
         """Run reporters and collect agent-level variables."""
-        agent_vars = {}
+        agent_vars = OrderedDict()
         for agent in model.schedule._agents.values():
-            agent_record = {}
+            agent_record = OrderedDict()
             for var, reporter in self.agent_reporters.items():
                 agent_record[var] = getattr(agent, reporter)
             agent_vars[agent.unique_id] = agent_record
diff --git a/mesa/space.py b/mesa/space.py
index 87d8c641..afa9a250 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -18,7 +18,6 @@ MultiGrid: extension to Grid where each cell is a set of objects.
 from __future__ import annotations
 
 import itertools
-import collections
 import math
 from warnings import warn
 
@@ -231,7 +230,7 @@ class Grid:
             radius: radius, in cells, of neighborhood to get.
 
         Returns:
-            An iterator of coordinate tuples representing the neighborhood. For
+            A list of coordinate tuples representing the neighborhood. For
             example with radius 1, it will return list with number of elements
             equals at most 9 (8) if Moore, 5 (4) if Von Neumann (if not
             including the center).
@@ -405,19 +404,23 @@ class Grid:
         """
         pos = self.torus_adj(pos)
         self.remove_agent(agent)
-        self.place_agent(agent, pos)
+        self._place_agent(agent, pos)
+        agent.pos = pos
 
     def place_agent(self, agent: Agent, pos: Coordinate) -> None:
-        """Place the agent at the specified location, and set its pos variable."""
+        """Position an agent on the grid, and set its pos variable."""
+        self._place_agent(agent, pos)
+        agent.pos = pos
+
+    def _place_agent(self, agent: Agent, pos: Coordinate) -> None:
+        """Place the agent at the correct location."""
         x, y = pos
         self.grid[x][y] = agent
         self.empties.discard(pos)
-        agent.pos = pos
 
     def remove_agent(self, agent: Agent) -> None:
         """Remove the agent from the grid and set its pos attribute to None."""
-        if (pos := agent.pos) is None:
-            return
+        pos = agent.pos
         x, y = pos
         self.grid[x][y] = self.default_val()
         self.empties.add(pos)
@@ -468,7 +471,8 @@ class Grid:
         else:
             new_pos = agent.random.choice(sorted(self.empties))
         self.remove_agent(agent)
-        self.place_agent(agent, new_pos)
+        self._place_agent(agent, new_pos)
+        agent.pos = new_pos
 
     def find_empty(self) -> Coordinate | None:
         """Pick a random empty cell."""
@@ -504,22 +508,25 @@ class SingleGrid(Grid):
         self, agent: Agent, x: int | str = "random", y: int | str = "random"
     ) -> None:
         """Position an agent on the grid.
-        This is used when first placing agents! Setting either x or y to "random"
-        gives the same behavior as 'move_to_empty()' to get a random position.
-        If x or y are positive, they are used.
+        This is used when first placing agents! Use 'move_to_empty()'
+        when you want agents to jump to an empty cell.
         Use 'swap_pos()' to swap agents positions.
+        If x or y are positive, they are used, but if "random",
+        we get a random position.
+        Ensure this random position is not occupied (in Grid).
         """
         if x == "random" or y == "random":
             if len(self.empties) == 0:
                 raise Exception("ERROR: Grid full")
-            self.move_to_empty(agent)
+            coords = agent.random.choice(sorted(self.empties))
         else:
             coords = (x, y)
-            self.place_agent(agent, coords)
+        agent.pos = coords
+        self._place_agent(agent, coords)
 
-    def place_agent(self, agent: Agent, pos: Coordinate) -> None:
+    def _place_agent(self, agent: Agent, pos: Coordinate) -> None:
         if self.is_cell_empty(pos):
-            super().place_agent(agent, pos)
+            super()._place_agent(agent, pos)
         else:
             raise Exception("Cell not empty")
 
@@ -551,13 +558,12 @@ class MultiGrid(Grid):
         """Default value for new cell elements."""
         return []
 
-    def place_agent(self, agent: Agent, pos: Coordinate) -> None:
-        """Place the agent at the specified location, and set its pos variable."""
+    def _place_agent(self, agent: Agent, pos: Coordinate) -> None:
+        """Place the agent at the correct location."""
         x, y = pos
         if agent not in self.grid[x][y]:
             self.grid[x][y].append(agent)
         self.empties.discard(pos)
-        agent.pos = pos
 
     def remove_agent(self, agent: Agent) -> None:
         """Remove the agent from the given location and set its pos attribute to None."""
@@ -605,20 +611,11 @@ class HexGrid(Grid):
             in the neighborhood of a certain point.
     """
 
-    def torus_adj_2d(self, pos: Coordinate) -> Coordinate:
-        return pos[0] % self.width, pos[1] % self.height
-
     def iter_neighborhood(
         self, pos: Coordinate, include_center: bool = False, radius: int = 1
     ) -> Iterator[Coordinate]:
         """Return an iterator over cell coordinates that are in the
-        neighborhood of a certain point. To calculate the neighborhood
-        for a HexGrid the parity of the x coordinate of the point is
-        important, the neighborhood can be sketched as:
-
-            Always: (0,-), (0,+)
-            When x is even: (-,+), (-,0), (+,+), (+,0)
-            When x is odd:  (-,0), (-,-), (+,0), (+,-)
+        neighborhood of a certain point.
 
         Args:
             pos: Coordinate tuple for the neighborhood to get.
@@ -627,66 +624,52 @@ class HexGrid(Grid):
             radius: radius, in cells, of neighborhood to get.
 
         Returns:
-            An iterator of coordinate tuples representing the neighborhood. For
+            A list of coordinate tuples representing the neighborhood. For
             example with radius 1, it will return list with number of elements
             equals at most 9 (8) if Moore, 5 (4) if Von Neumann (if not
             including the center).
         """
 
-        queue = collections.deque()
-        queue.append(pos)
+        def torus_adj_2d(pos: Coordinate) -> Coordinate:
+            return (pos[0] % self.width, pos[1] % self.height)
+
         coordinates = set()
 
-        while radius > 0:
-
-            level_size = len(queue)
-            radius -= 1
-
-            for i in range(level_size):
-
-                x, y = queue.pop()
-
-                if x % 2 == 0:
-                    adjacent = [
-                        (x, y - 1),
-                        (x, y + 1),
-                        (x - 1, y + 1),
-                        (x - 1, y),
-                        (x + 1, y + 1),
-                        (x + 1, y),
-                    ]
-                else:
-                    adjacent = [
-                        (x, y - 1),
-                        (x, y + 1),
-                        (x - 1, y),
-                        (x - 1, y - 1),
-                        (x + 1, y),
-                        (x + 1, y - 1),
-                    ]
-
-                if self.torus:
-                    adjacent = [
-                        coord
-                        for coord in map(self.torus_adj_2d, adjacent)
-                        if coord not in coordinates
-                    ]
-                else:
-                    adjacent = [
-                        coord
-                        for coord in adjacent
-                        if not self.out_of_bounds(coord) and coord not in coordinates
-                    ]
-
-                coordinates.update(adjacent)
-
-                if radius > 0:
-                    queue.extendleft(adjacent)
+        def find_neighbors(pos: Coordinate, radius: int) -> None:
+            x, y = pos
 
-        if include_center:
-            coordinates.add(pos)
-        else:
-            coordinates.discard(pos)
+            """
+            Both: (0,-), (0,+)
+
+            Even: (-,+), (-,0), (+,+), (+,0)
+            Odd:  (-,0), (-,-), (+,0), (+,-)
+            """
+            adjacent = [(x, y - 1), (x, y + 1)]
+
+            if include_center:
+                adjacent.append(pos)
+
+            if x % 2 == 0:
+                adjacent += [(x - 1, y + 1), (x - 1, y), (x + 1, y + 1), (x + 1, y)]
+            else:
+                adjacent += [(x - 1, y), (x - 1, y - 1), (x + 1, y), (x + 1, y - 1)]
+
+            if self.torus is False:
+                adjacent = list(
+                    filter(lambda coords: not self.out_of_bounds(coords), adjacent)
+                )
+            else:
+                adjacent = [torus_adj_2d(coord) for coord in adjacent]
+
+            coordinates.update(adjacent)
+
+            if radius > 1:
+                [find_neighbors(coords, radius - 1) for coords in adjacent]
+
+        find_neighbors(pos, radius)
+
+        if not include_center and pos in coordinates:
+            coordinates.remove(pos)
 
         yield from coordinates
 
@@ -959,7 +942,7 @@ class NetworkGrid:
     def place_agent(self, agent: Agent, node_id: int) -> None:
         """Place a agent in a node."""
 
-        self.G.nodes[node_id]["agent"].append(agent)
+        self._place_agent(agent, node_id)
         agent.pos = node_id
 
     def get_neighbors(self, node_id: int, include_center: bool = False) -> list[int]:
@@ -975,7 +958,13 @@ class NetworkGrid:
         """Move an agent from its current node to a new node."""
 
         self.remove_agent(agent)
-        self.place_agent(agent, node_id)
+        self._place_agent(agent, node_id)
+        agent.pos = node_id
+
+    def _place_agent(self, agent: Agent, node_id: int) -> None:
+        """Place the agent at the correct node."""
+
+        self.G.nodes[node_id]["agent"].append(agent)
 
     def remove_agent(self, agent: Agent) -> None:
         """Remove the agent from the network and set its pos attribute to None."""
diff --git a/mesa/time.py b/mesa/time.py
index 3b42a56a..f126578e 100644
--- a/mesa/time.py
+++ b/mesa/time.py
@@ -25,7 +25,7 @@ Key concepts:
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
 
-from collections import defaultdict
+from collections import OrderedDict, defaultdict
 
 # mypy
 from typing import Iterator, Union
@@ -53,7 +53,7 @@ class BaseScheduler:
         self.model = model
         self.steps = 0
         self.time: TimeT = 0
-        self._agents: dict[int, Agent] = {}
+        self._agents: dict[int, Agent] = OrderedDict()
 
     def add(self, agent: Agent) -> None:
         """Add an Agent object to the schedule.
@@ -89,7 +89,7 @@ class BaseScheduler:
 
     def get_agent_count(self) -> int:
         """Returns the current number of agents in the queue."""
-        return len(self._agents)
+        return len(self._agents.keys())
 
     @property
     def agents(self) -> list[Agent]:
@@ -142,12 +142,14 @@ class SimultaneousActivation(BaseScheduler):
 
     def step(self) -> None:
         """Step all agents, then advance them."""
-        for agent in self._agents.values():
-            agent.step()
-        # the previous steps might remove some agents, but
-        # this loop will go over the remaining existing agents
-        for agent in self._agents.values():
-            agent.advance()
+        agent_keys = list(self._agents.keys())
+        for agent_key in agent_keys:
+            self._agents[agent_key].step()
+        # We recompute the keys because some agents might have been removed in
+        # the previous loop.
+        agent_keys = list(self._agents.keys())
+        for agent_key in agent_keys:
+            self._agents[agent_key].advance()
         self.steps += 1
         self.time += 1
 
@@ -290,4 +292,4 @@ class RandomActivationByType(BaseScheduler):
         """
         Returns the current number of agents of certain type in the queue.
         """
-        return len(self.agents_by_type[type_class])
+        return len(self.agents_by_type[type_class].values())
diff --git a/mesa/visualization/ModularVisualization.py b/mesa/visualization/ModularVisualization.py
index e367eaaf..0b49b3f6 100644
--- a/mesa/visualization/ModularVisualization.py
+++ b/mesa/visualization/ModularVisualization.py
@@ -297,7 +297,12 @@ class ModularServer(tornado.web.Application):
             tornado.web.StaticFileHandler,
             {"path": os.path.dirname(__file__) + "/templates"},
         )
-        self.handlers = [page_handler, socket_handler, static_handler]
+        custom_handler = (
+            r"/local/custom/(.*)",
+            tornado.web.StaticFileHandler,
+            {"path": ""},
+        )
+        self.handlers = [page_handler, socket_handler, static_handler, custom_handler]
 
         self.settings = {
             "debug": True,
diff --git a/mesa/visualization/templates/js/GridDraw.js b/mesa/visualization/templates/js/GridDraw.js
index 27a1ccb4..b24e529d 100644
--- a/mesa/visualization/templates/js/GridDraw.js
+++ b/mesa/visualization/templates/js/GridDraw.js
@@ -359,7 +359,7 @@ const GridVisualization = function (
 
   this.drawCustomImage = function (shape, x, y, scale, text, text_color_) {
     const img = new Image();
-    img.src = "local/".concat(shape);
+    img.src = "local/custom/".concat(shape);
     if (scale === undefined) {
       scale = 1;
     }
diff --git a/setup.py b/setup.py
index 8a59a0f8..1c0b01a1 100644
--- a/setup.py
+++ b/setup.py
@@ -125,6 +125,7 @@ setup(
         "Topic :: Scientific/Engineering :: Artificial Intelligence",
         "Intended Audience :: Science/Research",
         "Programming Language :: Python :: 3 :: Only",
+        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
@@ -137,5 +138,5 @@ setup(
         [console_scripts]
         mesa=mesa.main:cli
     """,
-    python_requires=">=3.8",
+    python_requires=">=3.7",
 )


Test patch:
diff --git a/tests/test_grid.py b/tests/test_grid.py
index 3b43872e..7d348eca 100644
--- a/tests/test_grid.py
+++ b/tests/test_grid.py
@@ -3,7 +3,6 @@ Test the Grid objects.
 """
 import random
 import unittest
-from unittest.mock import patch, Mock
 from mesa.space import Grid, SingleGrid, MultiGrid, HexGrid
 
 # Initial agent positions for testing
@@ -232,8 +231,7 @@ class TestSingleGrid(unittest.TestCase):
                 self.grid.place_agent(a, (x, y))
         self.num_agents = len(self.agents)
 
-    @patch.object(MockAgent, "model", create=True)
-    def test_enforcement(self, mock_model):
+    def test_enforcement(self):
         """
         Test the SingleGrid empty count and enforcement.
         """
@@ -241,10 +239,9 @@ class TestSingleGrid(unittest.TestCase):
         assert len(self.grid.empties) == 9
         a = MockAgent(100, None)
         with self.assertRaises(Exception):
-            self.grid.place_agent(a, (0, 1))
+            self.grid._place_agent(a, (0, 1))
 
         # Place the agent in an empty cell
-        mock_model.schedule.get_agent_count = Mock(side_effect=lambda: len(self.agents))
         self.grid.position_agent(a)
         self.num_agents += 1
         # Test whether after placing, the empty cells are reduced by 1
@@ -394,10 +391,6 @@ class TestHexGrid(unittest.TestCase):
         neighborhood = self.grid.get_neighborhood((1, 1), include_center=True)
         assert len(neighborhood) == 7
 
-        neighborhood = self.grid.get_neighborhood((0, 0), radius=4)
-        assert len(neighborhood) == 13
-        assert sum(x + y for x, y in neighborhood) == 39
-
 
 class TestHexGridTorus(TestBaseGrid):
     """
@@ -442,13 +435,6 @@ class TestHexGridTorus(TestBaseGrid):
         neighborhood = self.grid.get_neighborhood((2, 4))
         assert len(neighborhood) == 6
 
-        neighborhood = self.grid.get_neighborhood((1, 1), include_center=True, radius=2)
-        assert len(neighborhood) == 13
-
-        neighborhood = self.grid.get_neighborhood((0, 0), radius=4)
-        assert len(neighborhood) == 14
-        assert sum(x + y for x, y in neighborhood) == 45
-
 
 class TestIndexing:
     # Create a grid where the content of each coordinate is a tuple of its coordinates