|
| 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 |
0 commit comments