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 all 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 @@ -6,6 +6,7 @@
from matplotlib.ticker import MaxNLocator

import mesa
from mesa.experimental.cell_space import VoronoiGrid


@solara.component
Expand All @@ -20,6 +21,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 25 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L25

Added line #L25 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 @@ -150,6 +153,56 @@
_split_and_scatter(portray(space), space_ax)


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

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#L157-L161

Added lines #L157 - L161 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 167 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L165-L167

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

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

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L169

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

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

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L171-L172

Added lines #L171 - L172 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 175 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L175

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

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

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L177

Added line #L177 was not covered by tests

return out

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

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L179

Added line #L179 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 186 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L183-L186

Added lines #L183 - L186 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 194 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L188-L194

Added lines #L188 - L194 were not covered by tests

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

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

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L197-L198

Added lines #L197 - L198 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 203 in mesa/visualization/components/matplotlib.py

View check run for this annotation

Codecov / codecov/patch

mesa/visualization/components/matplotlib.py#L203

Added line #L203 was not covered by tests


@solara.component
def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
fig = Figure()
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
Loading