Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Voronoi Tessellation based Discrete Space #2084

Merged
merged 52 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
fa8c15d
Implemented Voronoi based discrete space
vitorfrois Mar 20, 2024
301a5ff
Using coordinates instead of dimensions and density
vitorfrois Mar 20, 2024
a8a17ab
deleted comments
vitorfrois Mar 21, 2024
7e4d79e
incremented docstrings
vitorfrois Mar 23, 2024
8a892ee
added voronoi tests
vitorfrois Mar 23, 2024
a079b95
warn if placing already placed agent
puer-robustus Mar 17, 2024
6b8e760
ci: Use uv pip for faster build (#2038)
rht Mar 23, 2024
63bffa4
test: Remove place_agent duplicate warnings
rht Mar 24, 2024
1e6b37c
Update Ruff to 0.3.4; apply ruff format . (#2088)
rht Mar 25, 2024
c72dbff
docs: Fixes typos and header level error in introductory tutorial (#2…
puer-robustus Mar 31, 2024
36adaff
[pre-commit.ci] pre-commit autoupdate
pre-commit-ci[bot] Apr 1, 2024
a0de92f
Support discrete event scheduling (#2066)
quaquel Apr 10, 2024
6f99202
Update version number to 2.3.0-rc1 and add release notes (#2114)
EwoutH Apr 19, 2024
d399cd1
HISTORY.md: Update 2.3.0 notes
EwoutH Apr 19, 2024
e09d26b
Update version number and release notes for 2.3.0 release (#2116)
EwoutH Apr 23, 2024
3f61d41
Make agent move to actual random closest position (#2119)
EwoutH Apr 23, 2024
2066164
HISTORY.md: Fix two spelling errors (#2120)
EwoutH Apr 23, 2024
2b769c2
flocking benchmark: Remove unneeded passing of pos
EwoutH Apr 24, 2024
13633e6
Set version to 3.0.0-dev
EwoutH Apr 24, 2024
ccb793e
CI: Add weekly scheduled run to all CI workflows
EwoutH May 6, 2024
c60ef30
Drop support for Python 3.9, require Python >= 3.10 (#2132)
EwoutH May 8, 2024
e4684ac
breaking change: Rename mesa-viz-tornado namespace to visualization_old
rht Mar 27, 2024
152b32c
Set JupyterViz as stable
rht Mar 27, 2024
69802b1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 8, 2024
c5feb55
datacollector: store separate snapshots of model data per step (#2129)
EwoutH May 8, 2024
911c853
[pre-commit.ci] pre-commit autoupdate (#2131)
pre-commit-ci[bot] May 8, 2024
af59fdd
Fix image on landing page of docs. (#2146)
jackiekazil May 21, 2024
502b256
Replace links in docs - google group to matrix. (#2148)
jackiekazil May 22, 2024
1af245a
Add experimental features to the docs (#2154)
stephenfmann Jun 11, 2024
74cec55
Add script to list unabeled PR's since latest release
rht Feb 22, 2024
069bfa1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 22, 2024
32407f1
[pre-commit.ci] pre-commit autoupdate (#2151)
pre-commit-ci[bot] Jul 2, 2024
b5d7d96
Remove mesa.flat namespace (#2091)
rht Jul 3, 2024
5694d54
Fix pre-commit issues: Codespel and typecheck (#2161)
EwoutH Jul 3, 2024
6fda3fc
Remove visualization_old (mesa-viz-tornado) (#2133)
rht Jul 3, 2024
27c2ce4
Jupyter_viz: Allow measures to be None (#2163)
EwoutH Jul 3, 2024
95e8d81
Extend visualization documentation (#2162)
EwoutH Jul 3, 2024
0c0fb53
Jupyter Viz: Don't avoid interactive backend (#2165)
EwoutH Jul 4, 2024
ec4233a
Remove last remnants of visualization_old (#2169)
rht Jul 4, 2024
fb4177c
Set version to 3.0.0a0 and update release notes (#2167)
EwoutH Jul 4, 2024
998965f
Add .venv/ to .gitignore (#2172)
EwoutH Jul 8, 2024
a7090fc
Add original conference paper link to docs (#2160)
ENUMERA8OR Jul 9, 2024
909831f
added voronoi visualization
vitorfrois Jul 15, 2024
981473f
var names
vitorfrois Jul 15, 2024
5b66f73
voronoi visualization
vitorfrois Jul 15, 2024
554dc43
Merge branch 'main' into frois-voronoi
vitorfrois Jul 15, 2024
0939e3c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 15, 2024
ab1d1b6
added voronoi tests
vitorfrois Jul 15, 2024
c2d7379
added voronoi tests
vitorfrois Jul 15, 2024
f1857a6
removed pyhull dependency
vitorfrois Aug 28, 2024
b307e2b
Merge remote-tracking branch 'upstream/main' into pr/2084
EwoutH Aug 30, 2024
e0ded76
Update test_solara_viz.py
EwoutH Aug 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mesa/experimental/cell_space/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
OrthogonalVonNeumannGrid,
)
from mesa.experimental.cell_space.network import Network
from mesa.experimental.cell_space.voronoi import VoronoiGrid

__all__ = [
"CellCollection",
Expand All @@ -20,4 +21,5 @@
"OrthogonalMooreGrid",
"OrthogonalVonNeumannGrid",
"Network",
"VoronoiGrid",
]
264 changes: 264 additions & 0 deletions mesa/experimental/cell_space/voronoi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
from collections.abc import Sequence
from itertools import combinations
from random import Random

import numpy as np

from mesa.experimental.cell_space.cell import Cell
from mesa.experimental.cell_space.discrete_space import DiscreteSpace


class Delaunay:
EwoutH marked this conversation as resolved.
Show resolved Hide resolved
"""
Class to compute a Delaunay triangulation in 2D
ref: http://github.com/jmespadero/pyDelaunay2D
vitorfrois marked this conversation as resolved.
Show resolved Hide resolved
"""

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 export_triangles(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 export_voronoi_regions(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(x * 500)


class VoronoiGrid(DiscreteSpace):
triangulation: Delaunay
voronoi_coordinates: list
regions: list

def __init__(
vitorfrois marked this conversation as resolved.
Show resolved Hide resolved
self,
centroids_coordinates: Sequence[Sequence[float]],
Copy link
Member

@EwoutH EwoutH Jul 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this work with Shapely Points? That might be very useful for Mesa geo applications (cc @wang-boyu).

Just add with adding a simple test case for this, maybe it will just work.

capacity: float | None = None,
random: Random | None = None,
cell_klass: type[Cell] = Cell,
capacity_function: callable = round_float,
cell_coloring_property: str | None = None,
) -> None:
"""
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
self._validate_parameters()

self._cells = {
i: cell_klass(self.centroids_coordinates[i], capacity, random=self.random)
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_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
"""
self.triangulation = Delaunay()
for centroid in self.centroids_coordinates:
self.triangulation.add_point(centroid)

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])

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) or 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")

def _get_voronoi_regions(self) -> tuple:
if self.voronoi_coordinates is None or self.regions is None:
self.voronoi_coordinates, self.regions = (
self.triangulation.export_voronoi_regions()
)
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_property] = 0
53 changes: 53 additions & 0 deletions mesa/visualization/components/matplotlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from matplotlib.ticker import MaxNLocator

import mesa
from mesa.experimental.cell_space import VoronoiGrid


@solara.component
Expand All @@ -18,6 +19,8 @@
_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)

Check warning on line 23 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L23

Added line #L23 was not covered by tests
else:
_draw_grid(space, space_ax, agent_portrayal)
solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies)
Expand Down Expand Up @@ -113,6 +116,56 @@
space_ax.scatter(**portray(space))


def _draw_voronoi(space, space_ax, agent_portrayal):
def portray(g):
x = []
y = []
s = [] # size
c = [] # color

Check warning on line 124 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L120-L124

Added lines #L120 - L124 were not covered by tests

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])

Check warning on line 130 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L128-L130

Added lines #L128 - L130 were not covered by tests
if "size" in data:
s.append(data["size"])

Check warning on line 132 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L132

Added line #L132 was not covered by tests
if "color" in data:
c.append(data["color"])
out = {"x": x, "y": y}

Check warning on line 135 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L134-L135

Added lines #L134 - L135 were not covered by tests
# This is the default value for the marker size, which auto-scales
# according to the grid area.
out["s"] = s

Check warning on line 138 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L138

Added line #L138 was not covered by tests
if len(c) > 0:
out["c"] = c

Check warning on line 140 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L140

Added line #L140 was not covered by tests

return out

Check warning on line 142 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L142

Added line #L142 was not covered by tests

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)

Check warning on line 149 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L146-L149

Added lines #L146 - L149 were not covered by tests

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))

Check warning on line 157 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L151-L157

Added lines #L151 - L157 were not covered by tests

for cell in space.all_cells:
polygon = cell.properties["polygon"]
space_ax.fill(

Check warning on line 161 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L160-L161

Added lines #L160 - L161 were not covered by tests
*zip(*polygon),
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

Check warning on line 166 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L166

Added line #L166 was not covered by tests


@solara.component
def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
fig = Figure()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies = [
"pandas",
"solara",
"tqdm",
"pyhull",
vitorfrois marked this conversation as resolved.
Show resolved Hide resolved
]
dynamic = ["version"]

Expand Down
23 changes: 23 additions & 0 deletions tests/test_cell_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Network,
OrthogonalMooreGrid,
OrthogonalVonNeumannGrid,
VoronoiGrid,
)


Expand Down Expand Up @@ -372,6 +373,28 @@ 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]]

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

Expand Down
Loading