Skip to content

Commit c19f53e

Browse files
authored
Voronoi Tesselation based Discrete Space (#2084)
This feature allows the user to build a discrete space based on a random sample of points, where neighbors are defined by Delaunay Triangulation. More specifically, Delaunay Triangulation is a dual-graph representation of the Voronoi Tesselation. Using this algorithm, we can easily find nearest neighbors without delimiting cells edges.
1 parent efa51cd commit c19f53e

File tree

5 files changed

+353
-0
lines changed

5 files changed

+353
-0
lines changed

mesa/experimental/cell_space/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
OrthogonalVonNeumannGrid,
1010
)
1111
from mesa.experimental.cell_space.network import Network
12+
from mesa.experimental.cell_space.voronoi import VoronoiGrid
1213

1314
__all__ = [
1415
"CellCollection",
@@ -20,4 +21,5 @@
2021
"OrthogonalMooreGrid",
2122
"OrthogonalVonNeumannGrid",
2223
"Network",
24+
"VoronoiGrid",
2325
]
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
from collections.abc import Sequence
2+
from itertools import combinations
3+
from random import Random
4+
5+
import numpy as np
6+
7+
from mesa.experimental.cell_space.cell import Cell
8+
from mesa.experimental.cell_space.discrete_space import DiscreteSpace
9+
10+
11+
class Delaunay:
12+
"""
13+
Class to compute a Delaunay triangulation in 2D
14+
ref: http://github.com/jmespadero/pyDelaunay2D
15+
"""
16+
17+
def __init__(self, center: tuple = (0, 0), radius: int = 9999) -> None:
18+
"""
19+
Init and create a new frame to contain the triangulation
20+
center: Optional position for the center of the frame. Default (0,0)
21+
radius: Optional distance from corners to the center.
22+
"""
23+
center = np.asarray(center)
24+
# Create coordinates for the corners of the frame
25+
self.coords = [
26+
center + radius * np.array((-1, -1)),
27+
center + radius * np.array((+1, -1)),
28+
center + radius * np.array((+1, +1)),
29+
center + radius * np.array((-1, +1)),
30+
]
31+
32+
# Create two dicts to store triangle neighbours and circumcircles.
33+
self.triangles = {}
34+
self.circles = {}
35+
36+
# Create two CCW triangles for the frame
37+
triangle1 = (0, 1, 3)
38+
triangle2 = (2, 3, 1)
39+
self.triangles[triangle1] = [triangle2, None, None]
40+
self.triangles[triangle2] = [triangle1, None, None]
41+
42+
# Compute circumcenters and circumradius for each triangle
43+
for t in self.triangles:
44+
self.circles[t] = self._circumcenter(t)
45+
46+
def _circumcenter(self, triangle: list) -> tuple:
47+
"""
48+
Compute circumcenter and circumradius of a triangle in 2D.
49+
"""
50+
points = np.asarray([self.coords[v] for v in triangle])
51+
points2 = np.dot(points, points.T)
52+
a = np.bmat([[2 * points2, [[1], [1], [1]]], [[[1, 1, 1, 0]]]])
53+
54+
b = np.hstack((np.sum(points * points, axis=1), [1]))
55+
x = np.linalg.solve(a, b)
56+
bary_coords = x[:-1]
57+
center = np.dot(bary_coords, points)
58+
59+
radius = np.sum(np.square(points[0] - center)) # squared distance
60+
return (center, radius)
61+
62+
def _in_circle(self, triangle: list, point: list) -> bool:
63+
"""
64+
Check if point p is inside of precomputed circumcircle of triangle.
65+
"""
66+
center, radius = self.circles[triangle]
67+
return np.sum(np.square(center - point)) <= radius
68+
69+
def add_point(self, point: Sequence) -> None:
70+
"""
71+
Add a point to the current DT, and refine it using Bowyer-Watson.
72+
"""
73+
point_index = len(self.coords)
74+
self.coords.append(np.asarray(point))
75+
76+
bad_triangles = []
77+
for triangle in self.triangles:
78+
if self._in_circle(triangle, point):
79+
bad_triangles.append(triangle)
80+
81+
boundary = []
82+
triangle = bad_triangles[0]
83+
edge = 0
84+
85+
while True:
86+
opposite_triangle = self.triangles[triangle][edge]
87+
if opposite_triangle not in bad_triangles:
88+
boundary.append(
89+
(
90+
triangle[(edge + 1) % 3],
91+
triangle[(edge - 1) % 3],
92+
opposite_triangle,
93+
)
94+
)
95+
edge = (edge + 1) % 3
96+
if boundary[0][0] == boundary[-1][1]:
97+
break
98+
else:
99+
edge = (self.triangles[opposite_triangle].index(triangle) + 1) % 3
100+
triangle = opposite_triangle
101+
102+
for triangle in bad_triangles:
103+
del self.triangles[triangle]
104+
del self.circles[triangle]
105+
106+
new_triangles = []
107+
for e0, e1, opposite_triangle in boundary:
108+
triangle = (point_index, e0, e1)
109+
self.circles[triangle] = self._circumcenter(triangle)
110+
self.triangles[triangle] = [opposite_triangle, None, None]
111+
if opposite_triangle:
112+
for i, neighbor in enumerate(self.triangles[opposite_triangle]):
113+
if neighbor and e1 in neighbor and e0 in neighbor:
114+
self.triangles[opposite_triangle][i] = triangle
115+
116+
new_triangles.append(triangle)
117+
118+
n = len(new_triangles)
119+
for i, triangle in enumerate(new_triangles):
120+
self.triangles[triangle][1] = new_triangles[(i + 1) % n] # next
121+
self.triangles[triangle][2] = new_triangles[(i - 1) % n] # previous
122+
123+
def export_triangles(self) -> list:
124+
"""
125+
Export the current list of Delaunay triangles
126+
"""
127+
triangles_list = [
128+
(a - 4, b - 4, c - 4)
129+
for (a, b, c) in self.triangles
130+
if a > 3 and b > 3 and c > 3
131+
]
132+
return triangles_list
133+
134+
def export_voronoi_regions(self):
135+
"""
136+
Export coordinates and regions of Voronoi diagram as indexed data.
137+
"""
138+
use_vertex = {i: [] for i in range(len(self.coords))}
139+
vor_coors = []
140+
index = {}
141+
for triangle_index, (a, b, c) in enumerate(sorted(self.triangles)):
142+
vor_coors.append(self.circles[(a, b, c)][0])
143+
use_vertex[a] += [(b, c, a)]
144+
use_vertex[b] += [(c, a, b)]
145+
use_vertex[c] += [(a, b, c)]
146+
147+
index[(a, b, c)] = triangle_index
148+
index[(c, a, b)] = triangle_index
149+
index[(b, c, a)] = triangle_index
150+
151+
regions = {}
152+
for i in range(4, len(self.coords)):
153+
vertex = use_vertex[i][0][0]
154+
region = []
155+
for _ in range(len(use_vertex[i])):
156+
triangle = next(
157+
triangle for triangle in use_vertex[i] if triangle[0] == vertex
158+
)
159+
region.append(index[triangle])
160+
vertex = triangle[1]
161+
regions[i - 4] = region
162+
163+
return vor_coors, regions
164+
165+
166+
def round_float(x: float) -> int:
167+
return int(x * 500)
168+
169+
170+
class VoronoiGrid(DiscreteSpace):
171+
triangulation: Delaunay
172+
voronoi_coordinates: list
173+
regions: list
174+
175+
def __init__(
176+
self,
177+
centroids_coordinates: Sequence[Sequence[float]],
178+
capacity: float | None = None,
179+
random: Random | None = None,
180+
cell_klass: type[Cell] = Cell,
181+
capacity_function: callable = round_float,
182+
cell_coloring_property: str | None = None,
183+
) -> None:
184+
"""
185+
A Voronoi Tessellation Grid.
186+
187+
Given a set of points, this class creates a grid where a cell is centered in each point,
188+
its neighbors are given by Voronoi Tessellation cells neighbors
189+
and the capacity by the polygon area.
190+
191+
Args:
192+
centroids_coordinates: coordinates of centroids to build the tessellation space
193+
capacity (int) : capacity of the cells in the discrete space
194+
random (Random): random number generator
195+
CellKlass (type[Cell]): type of cell class
196+
capacity_function (Callable): function to compute (int) capacity according to (float) area
197+
cell_coloring_property (str): voronoi visualization polygon fill property
198+
"""
199+
super().__init__(capacity=capacity, random=random, cell_klass=cell_klass)
200+
self.centroids_coordinates = centroids_coordinates
201+
self._validate_parameters()
202+
203+
self._cells = {
204+
i: cell_klass(self.centroids_coordinates[i], capacity, random=self.random)
205+
for i in range(len(self.centroids_coordinates))
206+
}
207+
208+
self.regions = None
209+
self.triangulation = None
210+
self.voronoi_coordinates = None
211+
self.capacity_function = capacity_function
212+
self.cell_coloring_property = cell_coloring_property
213+
214+
self._connect_cells()
215+
self._build_cell_polygons()
216+
217+
def _connect_cells(self) -> None:
218+
"""
219+
Connect cells to neighbors based on given centroids and using Delaunay Triangulation
220+
"""
221+
self.triangulation = Delaunay()
222+
for centroid in self.centroids_coordinates:
223+
self.triangulation.add_point(centroid)
224+
225+
for point in self.triangulation.export_triangles():
226+
for i, j in combinations(point, 2):
227+
self._cells[i].connect(self._cells[j])
228+
self._cells[j].connect(self._cells[i])
229+
230+
def _validate_parameters(self) -> None:
231+
if self.capacity is not None and not isinstance(self.capacity, float | int):
232+
raise ValueError("Capacity must be a number or None.")
233+
if not isinstance(self.centroids_coordinates, Sequence) or not isinstance(
234+
self.centroids_coordinates[0], Sequence
235+
):
236+
raise ValueError("Centroids should be a list of lists")
237+
dimension_1 = len(self.centroids_coordinates[0])
238+
for coordinate in self.centroids_coordinates:
239+
if dimension_1 != len(coordinate):
240+
raise ValueError("Centroid coordinates should be a homogeneous array")
241+
242+
def _get_voronoi_regions(self) -> tuple:
243+
if self.voronoi_coordinates is None or self.regions is None:
244+
self.voronoi_coordinates, self.regions = (
245+
self.triangulation.export_voronoi_regions()
246+
)
247+
return self.voronoi_coordinates, self.regions
248+
249+
@staticmethod
250+
def _compute_polygon_area(polygon: list) -> float:
251+
polygon = np.array(polygon)
252+
x = polygon[:, 0]
253+
y = polygon[:, 1]
254+
return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
255+
256+
def _build_cell_polygons(self):
257+
coordinates, regions = self._get_voronoi_regions()
258+
for region in regions:
259+
polygon = [coordinates[i] for i in regions[region]]
260+
self._cells[region].properties["polygon"] = polygon
261+
polygon_area = self._compute_polygon_area(polygon)
262+
self._cells[region].properties["area"] = polygon_area
263+
self._cells[region].capacity = self.capacity_function(polygon_area)
264+
self._cells[region].properties[self.cell_coloring_property] = 0

mesa/visualization/components/matplotlib.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from matplotlib.ticker import MaxNLocator
77

88
import mesa
9+
from mesa.experimental.cell_space import VoronoiGrid
910

1011

1112
@solara.component
@@ -20,6 +21,8 @@ def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = Non
2021
_draw_network_grid(space, space_ax, agent_portrayal)
2122
elif isinstance(space, mesa.space.ContinuousSpace):
2223
_draw_continuous_space(space, space_ax, agent_portrayal)
24+
elif isinstance(space, VoronoiGrid):
25+
_draw_voronoi(space, space_ax, agent_portrayal)
2326
else:
2427
_draw_grid(space, space_ax, agent_portrayal)
2528
solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies)
@@ -150,6 +153,56 @@ def portray(space):
150153
_split_and_scatter(portray(space), space_ax)
151154

152155

156+
def _draw_voronoi(space, space_ax, agent_portrayal):
157+
def portray(g):
158+
x = []
159+
y = []
160+
s = [] # size
161+
c = [] # color
162+
163+
for cell in g.all_cells:
164+
for agent in cell.agents:
165+
data = agent_portrayal(agent)
166+
x.append(cell.coordinate[0])
167+
y.append(cell.coordinate[1])
168+
if "size" in data:
169+
s.append(data["size"])
170+
if "color" in data:
171+
c.append(data["color"])
172+
out = {"x": x, "y": y}
173+
# This is the default value for the marker size, which auto-scales
174+
# according to the grid area.
175+
out["s"] = s
176+
if len(c) > 0:
177+
out["c"] = c
178+
179+
return out
180+
181+
x_list = [i[0] for i in space.centroids_coordinates]
182+
y_list = [i[1] for i in space.centroids_coordinates]
183+
x_max = max(x_list)
184+
x_min = min(x_list)
185+
y_max = max(y_list)
186+
y_min = min(y_list)
187+
188+
width = x_max - x_min
189+
x_padding = width / 20
190+
height = y_max - y_min
191+
y_padding = height / 20
192+
space_ax.set_xlim(x_min - x_padding, x_max + x_padding)
193+
space_ax.set_ylim(y_min - y_padding, y_max + y_padding)
194+
space_ax.scatter(**portray(space))
195+
196+
for cell in space.all_cells:
197+
polygon = cell.properties["polygon"]
198+
space_ax.fill(
199+
*zip(*polygon),
200+
alpha=min(1, cell.properties[space.cell_coloring_property]),
201+
c="red",
202+
) # Plot filled polygon
203+
space_ax.plot(*zip(*polygon), color="black") # Plot polygon edges in red
204+
205+
153206
@solara.component
154207
def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
155208
fig = Figure()

tests/test_cell_space.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Network,
1212
OrthogonalMooreGrid,
1313
OrthogonalVonNeumannGrid,
14+
VoronoiGrid,
1415
)
1516

1617

@@ -372,6 +373,28 @@ def test_networkgrid():
372373
assert connection.coordinate in G.neighbors(i)
373374

374375

376+
def test_voronoigrid():
377+
points = [[0, 1], [1, 3], [1.1, 1], [1, 1]]
378+
379+
grid = VoronoiGrid(points)
380+
381+
assert len(grid._cells) == len(points)
382+
383+
# Check cell neighborhood
384+
assert len(grid._cells[0]._connections) == 2
385+
for connection in grid._cells[0]._connections:
386+
assert connection.coordinate in [[1, 1], [1, 3]]
387+
388+
with pytest.raises(ValueError):
389+
VoronoiGrid(points, capacity="str")
390+
391+
with pytest.raises(ValueError):
392+
VoronoiGrid((1, 1))
393+
394+
with pytest.raises(ValueError):
395+
VoronoiGrid([[0, 1], [0, 1, 1]])
396+
397+
375398
def test_empties_space():
376399
import networkx as nx
377400

0 commit comments

Comments
 (0)