geo-adjacency 1.0.7__tar.gz → 1.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,32 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: geo-adjacency
3
- Version: 1.0.7
4
- Summary:
3
+ Version: 1.1.2
4
+ Summary: A package to determine which geometries are adjacent to each other, accounting for obstacles and gaps between features.
5
+ Home-page: https://asmyth01.github.io/geo-adjacency/
5
6
  License: MIT
7
+ Keywords: voronoi,adjacency,geospatial,geometry
6
8
  Author: Andrew Smyth
7
- Author-email: andrews@zillowgroup.com
9
+ Author-email: andrew.j.smyth.89@gmail.com
8
10
  Requires-Python: >=3.9,<3.13
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
9
13
  Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
10
16
  Classifier: Programming Language :: Python :: 3
11
17
  Classifier: Programming Language :: Python :: 3.9
12
18
  Classifier: Programming Language :: Python :: 3.10
13
19
  Classifier: Programming Language :: Python :: 3.11
14
20
  Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Classifier: Topic :: Scientific/Engineering :: GIS
15
23
  Requires-Dist: matplotlib (>=3.8.1,<4.0.0)
16
24
  Requires-Dist: numpy (>=1.26.2,<2.0.0)
17
25
  Requires-Dist: scipy (>=1.11.3,<2.0.0)
18
26
  Requires-Dist: setuptools (>=69.0.0,<70.0.0)
19
27
  Requires-Dist: shapely (>=2.0.2,<3.0.0)
28
+ Project-URL: Documentation, https://asmyth01.github.io/geo-adjacency/
29
+ Project-URL: Repository, https://github.com/andrewsmyth/geo-adjacency
20
30
  Description-Content-Type: text/markdown
21
31
 
22
32
  # Geo-Adjacency
@@ -0,0 +1,544 @@
1
+ """
2
+ The `adjacency` module implements the AdjacencyEngine class,
3
+ which allows us to calculate adjacency relationships. Adjacency relationships are between a set of source geometries,
4
+ or between source geometries and a second set of target geometries. Obstacle geometries can be passed in to
5
+ stand between sources or sources and targets, but they are not included in the output.
6
+
7
+ For example, if we wanted to know what trees in a forest are adjacent to the shore of a lake, we could
8
+ pass in a set of Point geometries to the trees, a Polygon to represent the lake, and a LineString to represent
9
+ a road passing between some of the trees and the shore.
10
+
11
+ `AdjacencyEngine` utilizes a Voronoi diagram of all the vertices in all the geometries combined to determine
12
+ which geometries are adjacent to each other. See
13
+ """
14
+
15
+ import math
16
+ from collections import defaultdict
17
+ from typing import List, Union, Dict, Tuple, Generator
18
+
19
+ try:
20
+ from typing_extensions import Self # Python < 3.11
21
+ except ImportError:
22
+ from typing import Self # Python >= 3.11
23
+ import logging
24
+
25
+ import matplotlib.pyplot as plt
26
+ import numpy as np
27
+ import shapely.ops
28
+ from scipy.spatial import distance
29
+ from scipy.spatial import Voronoi
30
+ from shapely import LineString, Point, Polygon, MultiPolygon
31
+ from shapely.geometry.base import BaseGeometry
32
+
33
+ from geo_adjacency.exception import ImmutablePropertyError
34
+ from geo_adjacency.utils import (
35
+ add_geometry_to_plot,
36
+ coords_from_point,
37
+ coords_from_ring,
38
+ coords_from_polygon,
39
+ coords_from_multipolygon,
40
+ )
41
+
42
+ # ToDo: Support geometries with Z-coordinates
43
+ # Create a custom logger
44
+ log: logging.Logger = logging.getLogger(__name__)
45
+
46
+ # Create handlers
47
+ c_handler: logging.StreamHandler = logging.StreamHandler()
48
+ c_handler.setLevel(logging.WARNING)
49
+
50
+ # Create formatters and add it to handlers
51
+ c_format: logging.Formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
52
+ f_format: logging.Formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
53
+ c_handler.setFormatter(c_format)
54
+
55
+ # Add handlers to the logger
56
+ log.addHandler(c_handler)
57
+
58
+
59
+ class _Feature:
60
+ """
61
+ A _Feature is a wrapper around a Shapely geometry that allows us to easily determine if two
62
+ geometries are adjacent.
63
+ """
64
+ __slots__ = ("_geometry", "_coords", "voronoi_points")
65
+
66
+ def __init__(self, geometry: BaseGeometry):
67
+ """
68
+ Create a _Feature from a Shapely geometry.
69
+
70
+ Args:
71
+ geometry (BaseGeometry): A valid Shapely Geometry, either a Point, LineString, Polygon, or
72
+ MultiPolygon.
73
+ """
74
+
75
+ if not isinstance(geometry, (Point, Polygon, MultiPolygon, LineString)):
76
+ raise TypeError(
77
+ "Cannot create _Feature for geometry type '%s'." % type(geometry)
78
+ )
79
+
80
+ assert geometry.is_valid, "Could not process invalid geometry: %s" % geometry.wkt
81
+
82
+ self._geometry: BaseGeometry = geometry
83
+ self._coords: Union[List[Tuple[float, float]], None] = None
84
+ self.voronoi_points: set = set()
85
+
86
+ def __str__(self):
87
+ return str(self.geometry)
88
+
89
+ def __repr__(self):
90
+ return f"<_Feature: {str(self.geometry)}>"
91
+
92
+ def _is_adjacent(
93
+ self, other: Self, min_overlapping_voronoi_vertices: int = 2
94
+ ) -> bool:
95
+ """
96
+ Determine if two features are adjacent based on how many Voronoi vertices they share. Note:
97
+ the Voronoi analysis must have been run, or this will always return False.
98
+ Args:
99
+ other (_Feature): Another _Feature to compare to.
100
+ min_overlapping_voronoi_vertices (int): The minimum number of Voronoi vertices that
101
+ must be shared to be considered adjacent.
102
+
103
+ Returns:
104
+ bool: True if the two features are adjacent.
105
+
106
+ """
107
+ assert isinstance(other, type(self)), "Cannot compare '%s' with '%s'." % (
108
+ type(self),
109
+ type(other),
110
+ )
111
+ if len(self.voronoi_points) == 0 and len(other.voronoi_points) == 0:
112
+ log.warning(
113
+ "No Voronoi vertices found for either feature. Did you run the analysis yet?"
114
+ )
115
+ return False
116
+ return (
117
+ len(self.voronoi_points & other.voronoi_points)
118
+ >= min_overlapping_voronoi_vertices
119
+ )
120
+
121
+ @property
122
+ def geometry(self):
123
+ """
124
+ Access the Shapely geometry of the feature.
125
+
126
+ Returns:
127
+ BaseGeometry: The Shapely geometry of the feature.
128
+
129
+ """
130
+ return self._geometry
131
+
132
+ @geometry.setter
133
+ def geometry(self, geometry):
134
+ self._geometry = geometry
135
+ self._coords = None
136
+
137
+ @property
138
+ def coords(self) -> List[Tuple[float, float]]:
139
+ """
140
+ Convenience property for accessing the coordinates of the geometry as a list of 2-tuples.
141
+
142
+ Returns:
143
+ List[Tuple[float, float]]: A list of coordinate tuples.
144
+
145
+ """
146
+
147
+ if not self._coords:
148
+ if isinstance(self.geometry, Point):
149
+ self._coords = coords_from_point(self.geometry)
150
+ elif isinstance(self.geometry, LineString):
151
+ self._coords = coords_from_ring(self.geometry)
152
+ elif isinstance(self.geometry, Polygon):
153
+ self._coords = coords_from_polygon(self.geometry)
154
+ elif isinstance(self.geometry, MultiPolygon):
155
+ self._coords = coords_from_multipolygon(self.geometry)
156
+ else:
157
+ raise TypeError(f"Unknown geometry type '{type(self.geometry)}'")
158
+ return self._coords
159
+
160
+ @coords.setter
161
+ def coords(self, coords):
162
+ raise ImmutablePropertyError("Property coords is immutable.")
163
+
164
+
165
+ class AdjacencyEngine:
166
+ """
167
+ A class for calculating the adjacency of a set of geometries to another geometry or set
168
+ of geometries, given a set of obstacles, within a given radius.
169
+
170
+ First, the Voronoi diagram is generated for each geometry and obstacle. Then, we check which
171
+ voronoi shapes intersect one another. If they do, then the two underlying geometries are
172
+ adjacent.
173
+ """
174
+
175
+ __slots__ = (
176
+ "_source_features",
177
+ "_target_features",
178
+ "_obstacle_features",
179
+ "_adjacency_dict",
180
+ "_feature_indices",
181
+ "_vor",
182
+ "_all_features",
183
+ "_all_coordinates",
184
+ )
185
+
186
+ def __init__(
187
+ self,
188
+ source_geoms: List[BaseGeometry],
189
+ target_geoms: Union[List[BaseGeometry], None] = None,
190
+ obstacle_geoms: Union[List[BaseGeometry], None] = None,
191
+ densify_features: bool = False,
192
+ max_segment_length: Union[float, None] = None,
193
+ ):
194
+ """
195
+ Note: only Multipolygons, Polygons, LineStrings and Points are supported. It is assumed all
196
+ features are in the same projection.
197
+
198
+ Args:
199
+ source_geoms (List[BaseGeometry]): List of Shapely geometries. We will which ones are adjacent to
200
+ which others, unless target_geoms is specified.
201
+ target_geoms (Union[List[BaseGeometry], None]), optional): list of Shapley geometries. if not None, We will
202
+ test if these features are adjacent to the source features.
203
+ obstacle_geoms (Union[List[BaseGeometry], None]), optional): List
204
+ of Shapely geometries. These features will not be tested for adjacency, but they can
205
+ prevent a source and target feature from being adjacent.
206
+ densify_features (bool, optional): If
207
+ True, we will add additional points to the features to improve accuracy of the voronoi
208
+ diagram. If densify_features is True and max_segment_length is false, then the max_segment_length
209
+ will be calculated based on the average segment length of all features, divided by 5.
210
+ max_segment_length (Union[float, None], optional): The maximum distance between vertices that we want.
211
+ In projection units. densify_features must be True, or an error will be thrown.
212
+ """
213
+
214
+ if max_segment_length and not densify_features:
215
+ raise ValueError(
216
+ "interpolate_points must be True if interpolation_distance is not None"
217
+ )
218
+
219
+ self._source_features: Tuple[_Feature] = tuple(
220
+ [_Feature(geom) for geom in source_geoms]
221
+ )
222
+ self._target_features: Tuple[_Feature] = (
223
+ tuple([_Feature(geom) for geom in target_geoms])
224
+ if target_geoms
225
+ else tuple()
226
+ )
227
+ self._obstacle_features: Union[Tuple[_Feature], None] = (
228
+ tuple([_Feature(geom) for geom in obstacle_geoms])
229
+ if obstacle_geoms
230
+ else tuple()
231
+ )
232
+ self._adjacency_dict: Union[Dict[int, List[int]], None] = None
233
+ self._feature_indices: Union[Dict[int, int], None] = None
234
+ self._vor = None
235
+ self._all_coordinates = None
236
+
237
+ """All source, target, and obstacle features in a single list. The order of this list must
238
+ not be changed."""
239
+ self._all_features: Tuple[_Feature, ...] = tuple(
240
+ [*self.source_features, *self.target_features, *self.obstacle_features]
241
+ )
242
+
243
+ if densify_features:
244
+ if max_segment_length is None:
245
+ max_segment_length = self._calc_segmentation_dist()
246
+ log.info("Calculated max_segment_length of %s" % max_segment_length)
247
+
248
+ for feature in self.all_features:
249
+ if not isinstance(feature.geometry, Point):
250
+ feature.geometry = feature.geometry.segmentize(max_segment_length)
251
+ # Reset all coordinates
252
+ self._all_coordinates = None
253
+
254
+ @property
255
+ def all_features(self):
256
+ """
257
+ All source, target, and obstacle features in a single list. The order of this list must
258
+ not be changed. This property cannot be set manually.
259
+
260
+ Returns:
261
+ List[_Feature]: A list of _Features.
262
+
263
+ """
264
+ return self._all_features
265
+
266
+ @all_features.setter
267
+ def all_features(self, value):
268
+ raise ImmutablePropertyError("Property all_features is immutable.")
269
+
270
+ @property
271
+ def all_coordinates(self):
272
+ """
273
+ All source, target, and obstacle coordinates in a single list. The order of this list must
274
+ not be changed. This property cannot be set manually.
275
+
276
+ Returns:
277
+ List[tuple[float, float]]: A list of coordinate tuples.
278
+ """
279
+
280
+ if not self._all_coordinates:
281
+ self._all_coordinates = []
282
+ for feature in self.all_features:
283
+ self._all_coordinates.extend(feature.coords)
284
+
285
+ self._all_coordinates = tuple(self._all_coordinates)
286
+ return self._all_coordinates
287
+
288
+ @all_coordinates.setter
289
+ def all_coordinates(self, value):
290
+ raise ImmutablePropertyError("Property all_coordinates is immutable.")
291
+
292
+ def _calc_segmentation_dist(self, divisor=5):
293
+ """
294
+ Try to create a well-fitting maximum length for all line segments in all features. Take
295
+ the average distance between all coordinate pairs and divide by 5. This means that the
296
+ average segment will be divided into five segments.
297
+
298
+ This won't work as well if the different geometry sets have significantly different
299
+ average segment lengths. In that case, it is advisable to prepare the data appropriately
300
+ beforehand.
301
+
302
+ Args:
303
+ divisor (int, optional): Divide the average segment length by this number to get the new desired
304
+ segment length.
305
+
306
+ Returns:
307
+ float: Average segment length divided by divisor.
308
+ """
309
+
310
+ return float(
311
+ (
312
+ sum(distance.pdist(self.all_coordinates, "euclidean"))
313
+ / math.pow(len(self.all_coordinates), 2)
314
+ )
315
+ / divisor
316
+ )
317
+
318
+ @property
319
+ def source_features(self) -> Tuple[_Feature]:
320
+ """
321
+ Features which will be the keys in the adjacency_dict.
322
+
323
+ Returns:
324
+ List[_Feature]: A list of _Features.
325
+
326
+ """
327
+ return self._source_features
328
+
329
+ @source_features.setter
330
+ def source_features(self, features: Tuple[BaseGeometry]):
331
+ raise ImmutablePropertyError("Property source_features is immutable.")
332
+
333
+ @property
334
+ def target_features(self) -> Tuple[_Feature]:
335
+ """
336
+ Features which will be the values in the adjacency_dict.
337
+ Returns:
338
+ List[_Feature]: A list of _Features.
339
+ """
340
+ return self._target_features
341
+
342
+ @target_features.setter
343
+ def target_features(self, _):
344
+ raise ImmutablePropertyError("Property target_features is immutable.")
345
+
346
+ @property
347
+ def obstacle_features(self) -> Tuple[_Feature]:
348
+ """
349
+ Features which can prevent source and target features from being adjacent. They
350
+ Do not participate in the adjacency_dict.
351
+
352
+ Returns:
353
+ List[_Feature]: A list of _Features.
354
+ """
355
+ return self._obstacle_features
356
+
357
+ @obstacle_features.setter
358
+ def obstacle_features(self, _):
359
+ raise ImmutablePropertyError("Property obstacle_features is immutable.")
360
+
361
+ def get_feature_from_coord_index(self, coord_index: int) -> _Feature:
362
+ """
363
+ A list which is the length of self._all_coordinates. For each coordinate, we add the
364
+ index of the corresponding feature from the list self.all_features. This is used to
365
+ determine which coordinate belongs to which feature after we calculate the voronoi
366
+ diagram.
367
+
368
+ Args:
369
+ coord_index (int): The index of the coordinate in self._all_coordinates
370
+
371
+ Returns:
372
+ _Feature: A _Feature at the given index.
373
+ """
374
+ if not self._feature_indices:
375
+ self._feature_indices = {}
376
+ c = -1
377
+ for f, feature in enumerate(self.all_features):
378
+ for _ in range(len(feature.coords)):
379
+ c += 1
380
+ self._feature_indices[c] = f
381
+
382
+ return self.all_features[self._feature_indices[coord_index]]
383
+
384
+ @property
385
+ def vor(self):
386
+ """
387
+ The Voronoi diagram object returned by Scipy. Useful primarily for debugging an
388
+ adjacency analysis.
389
+
390
+ Returns:
391
+ scipy.spatial.Voronoi: The Scipy Voronoi object.
392
+ """
393
+ if not self._vor:
394
+ self._vor = Voronoi(np.array(self.all_coordinates))
395
+ return self._vor
396
+
397
+ @vor.setter
398
+ def vor(self, _):
399
+ raise ImmutablePropertyError("Property vor is immutable.")
400
+
401
+ def _get_voronoi_vertex_idx_for_coord_idx(self, feature_coord_index: int) -> Generator[int, None, None]:
402
+ """
403
+ For a given feature coordinate index, return the indices of the voronoi vertices. Ignore
404
+ any "-1"s, which indicate vertices at infinity; these provide no adjacency information.
405
+
406
+ Args:
407
+ feature_coord_index (int): The index of the coordinate in self.all_coordinates
408
+
409
+ Returns:
410
+ Generator[int, None, None]: A generator of the indices of the voronoi vertices.
411
+ """
412
+ return (i for i in self.vor.regions[self.vor.point_region[feature_coord_index]] if i != -1)
413
+
414
+ def _tag_feature_with_voronoi_vertices(self):
415
+ """
416
+ Tag each feature with the vertices of the voronoi region it belongs to. Runs the
417
+ voronoi analysis if it has not been done already. This is broken out mostly for testing.
418
+ Do not call this function directly.
419
+
420
+ Returns:
421
+ None
422
+ """
423
+ # We don't need to tag obstacles with their voronoi vertices
424
+ obstacle_coord_len = sum(len(feat.coords) for feat in self.obstacle_features)
425
+
426
+ # Tag each feature with the vertices of the voronoi region it
427
+ # belongs to
428
+ for feature_coord_index in range(
429
+ len(self.all_coordinates) - obstacle_coord_len
430
+ ):
431
+ feature = self.get_feature_from_coord_index(feature_coord_index)
432
+ for i in self._get_voronoi_vertex_idx_for_coord_idx(feature_coord_index):
433
+ feature.voronoi_points.add(i)
434
+
435
+ def _determine_adjacency(
436
+ self, source_set: Tuple[_Feature], target_set: Tuple[_Feature]
437
+ ):
438
+ """
439
+ Determines the adjacency relationship between two sets of features.
440
+ Args:
441
+ source_set (Tuple[_Feature]): The set of source features.
442
+ target_set (Tuple[_Feature]): The set of target features.
443
+
444
+ Returns:
445
+ None
446
+ """
447
+ min_overlapping_voronoi_vertices = 2
448
+ for source_index, source_feature in enumerate(source_set):
449
+ for target_index, target_feature in enumerate(target_set):
450
+ if source_feature != target_feature and source_feature._is_adjacent(
451
+ target_feature, min_overlapping_voronoi_vertices
452
+ ):
453
+ self._adjacency_dict[source_index].append(target_index)
454
+
455
+ def get_adjacency_dict(self) -> Dict[int, List[int]]:
456
+ """
457
+ Returns a dictionary of indices. They keys are the indices of feature_geoms. The values
458
+ are the indices of any target geometries which are adjacent to the feature_geoms.
459
+
460
+ If no targets were specified, then calculate adjacency between source features and other
461
+ source features.
462
+
463
+ Returns:
464
+ dict: A dictionary of indices. The keys are the indices of feature_geoms. The
465
+ values are the indices of any adjacent features.
466
+
467
+ """
468
+
469
+ """Note: We want adjacent features to have at least two overlapping vertices, otherwise we
470
+ might call the features adjacent when their Voronoi regions don't share any edges."""
471
+
472
+ if self._adjacency_dict is None:
473
+ self._tag_feature_with_voronoi_vertices()
474
+
475
+ # If any two features have any voronoi indices in common, then their voronoi regions
476
+ # must intersect, therefore the input features are adjacent.
477
+ self._adjacency_dict = defaultdict(list)
478
+
479
+ # Get adjacency between source and target features
480
+ if len(self.target_features) > 0:
481
+ self._determine_adjacency(self.source_features, self.target_features)
482
+ # If no target specified, get adjacency between source and other source features.
483
+ else:
484
+ self._determine_adjacency(self.source_features, self.source_features)
485
+
486
+ return self._adjacency_dict
487
+
488
+ def plot_adjacency_dict(self) -> None:
489
+ """
490
+ Plot the adjacency linkages between the source and target with pyplot. Runs the analysis if
491
+ it has not already been run.
492
+
493
+ Returns:
494
+ None
495
+ """
496
+ # Plot the adjacency linkages between the source and target
497
+ if len(self.target_features) > 0:
498
+ for source_i, target_is in self.get_adjacency_dict().items():
499
+ source_poly = self.source_features[source_i].geometry
500
+ target_polys = [
501
+ self.target_features[target_i].geometry for target_i in target_is
502
+ ]
503
+
504
+ # Plot the linestrings between the source and target polygons
505
+ links = []
506
+ for target_poly in target_polys:
507
+ if target_poly:
508
+ try:
509
+ links.append(
510
+ LineString(
511
+ shapely.ops.nearest_points(target_poly, source_poly)
512
+ )
513
+ )
514
+ except ValueError:
515
+ log.error(
516
+ f"Error creating link between '{target_poly}' and '{source_poly}'"
517
+ )
518
+ add_geometry_to_plot(links, "green")
519
+
520
+ else:
521
+ for source_i, source_2_is in self.get_adjacency_dict().items():
522
+ source_poly = self.source_features[source_i].geometry
523
+ target_polys = [
524
+ self.source_features[source_2_i].geometry
525
+ for source_2_i in source_2_is
526
+ if source_2_i > source_i
527
+ ]
528
+
529
+ # Plot the linestrings between the source and target polygons
530
+ links = [
531
+ LineString([target_poly.centroid, source_poly.centroid])
532
+ for target_poly in target_polys
533
+ if target_poly is not None
534
+ ]
535
+ add_geometry_to_plot(links, "green")
536
+
537
+ add_geometry_to_plot([t.geometry for t in self.target_features], "blue")
538
+ add_geometry_to_plot([t.geometry for t in self.source_features], "grey")
539
+ add_geometry_to_plot([t.geometry for t in self.obstacle_features], "red")
540
+
541
+ plt.title("Adjacency linkages between source and target")
542
+ plt.xlabel("Longitude")
543
+ plt.ylabel("Latitude")
544
+ plt.show()
@@ -2,9 +2,11 @@
2
2
  Custom exceptions.
3
3
  """
4
4
 
5
+
5
6
  class ImmutablePropertyError(BaseException):
6
7
  """
7
8
  Raise when a property is immutable because the setter does nothing.
8
9
  """
10
+
9
11
  def __init__(self, message):
10
12
  super().__init__(message)
@@ -0,0 +1,137 @@
1
+ """
2
+ Utility functions. These are designed for use in the AdjacencyEngine only, and should not be called
3
+ by end users.
4
+ """
5
+
6
+ from typing import List, Tuple
7
+
8
+ from matplotlib import pyplot as plt
9
+ from shapely import Point, LineString, Polygon, MultiPolygon
10
+
11
+
12
+ def coords_from_point(point: Point) -> List[Tuple[float, float]]:
13
+ """
14
+ Convert a Point into a tuple of (x, y). We put this inside a list for consistency with other
15
+ coordinate methods to allow us to seamlessly merge them later.
16
+
17
+ Args:
18
+ point (Point): A Shapely Point.
19
+
20
+ Returns:
21
+ List[Tuple[float, float]]: A list of coordinate tuples.
22
+
23
+ """
24
+ assert isinstance(point, Point), "Geometry must be a Point, not '%s'." % type(point)
25
+ return [(float(point.x), float(point.y))]
26
+
27
+
28
+ def coords_from_ring(ring: LineString) -> List[Tuple[float, float]]:
29
+ """
30
+ Convert a LinearRing into a list of (x, y) tuples.
31
+
32
+ Args:
33
+ ring (LineString): A Shapely LinearString.
34
+
35
+ Returns:
36
+ List[Tuple[float, float]]: A list of coordinate tuples.
37
+ """
38
+ assert isinstance(
39
+ ring, LineString
40
+ ), "Geometry must be a LinearRing, not '%s'." % type(ring)
41
+ return [(float(coord[0]), float(coord[1])) for coord in ring.coords]
42
+
43
+
44
+ def coords_from_polygon(polygon: Polygon) -> List[Tuple[float, float]]:
45
+ """
46
+ Convert a Polygon into a list of (x, y) tuples. Does not repeat the first coordinate to close
47
+ the ring.
48
+
49
+ Args:
50
+ polygon (Polygon): A Shapely Polygon.
51
+
52
+ Returns:
53
+ List[Tuple[float, float]]: A list of coordinate tuples.
54
+ """
55
+ assert isinstance(polygon, Polygon), "Geometry must be a Polygon, not '%s'." % type(
56
+ polygon
57
+ )
58
+ coords = []
59
+ coords.extend(coords_from_ring(polygon.exterior)[:-1])
60
+ for ring in polygon.interiors:
61
+ coords.extend(coords_from_ring(ring)[:-1])
62
+ return coords
63
+
64
+
65
+ def coords_from_multipolygon(multipolygon: MultiPolygon) -> List[Tuple[float, float]]:
66
+ """
67
+ Convert a MultiPolygon into a list of (x, y) tuples. Does not repeat the first coordinate to
68
+ close the ring.
69
+
70
+ Args:
71
+ multipolygon (MultiPolygon): A Shapely MultiPolygon.
72
+
73
+ Returns:
74
+ List[Tuple[float, float]]: A list of coordinate tuples.
75
+ """
76
+ assert isinstance(
77
+ multipolygon, MultiPolygon
78
+ ), "Geometry must be a MultiPolygon, not '%s'." % type(multipolygon)
79
+ coords = []
80
+ for polygon in multipolygon.geoms:
81
+ coords.extend(coords_from_polygon(polygon))
82
+ return coords
83
+
84
+
85
+ def flatten_list(nested_list) -> List:
86
+ """
87
+ Flatten a list of lists.
88
+ Args:
89
+ nested_list (List): A list of lists.
90
+
91
+ Returns:
92
+
93
+ """
94
+ # check if list is empty
95
+ if not bool(nested_list):
96
+ return nested_list
97
+
98
+ # to check instance of list is empty or not
99
+ if isinstance(nested_list[0], list):
100
+ # call function with sublist as argument
101
+ return flatten_list(*nested_list[:1]) + flatten_list(nested_list[1:])
102
+
103
+ # Call function with sublist as argument
104
+ return nested_list[:1] + flatten_list(nested_list[1:])
105
+
106
+
107
+ def add_geometry_to_plot(geoms, color="black"):
108
+ """
109
+ When updating the test data, it may be useful to visualize it. Add a geometry to the global
110
+ maplotlib plt object. The next time we call plt.show(), this geometry will be plotted.
111
+
112
+ Args:
113
+ geoms (List[BaseGeometry]): A list of Shapely geometries.
114
+ color (str): The color we want the geometry to be in the plot.
115
+
116
+ Returns:
117
+ None
118
+ """
119
+ for geom in geoms:
120
+ if isinstance(geom, Point):
121
+ plt.plot(
122
+ geom.x,
123
+ geom.y,
124
+ marker="o",
125
+ markersize=5,
126
+ markeredgecolor="black",
127
+ markerfacecolor=color,
128
+ )
129
+ elif isinstance(geom, LineString):
130
+ plt.plot(*geom.coords.xy, color=color)
131
+ elif isinstance(geom, Polygon):
132
+ plt.plot(*geom.exterior.xy, color=color, linestyle="-")
133
+ elif isinstance(geom, MultiPolygon):
134
+ for sub_poly in geom.geoms:
135
+ plt.plot(*sub_poly.exterior.xy, color=color, linewidth=3)
136
+ else:
137
+ raise TypeError("Unknown geometry type")
@@ -0,0 +1,47 @@
1
+ [tool.poetry]
2
+ name = "geo-adjacency"
3
+ version = "1.1.2"
4
+ description = "A package to determine which geometries are adjacent to each other, accounting for obstacles and gaps between features."
5
+ authors = ["Andrew Smyth <andrew.j.smyth.89@gmail.com>"]
6
+ readme = "README.md"
7
+ packages = [{include = "geo_adjacency"}]
8
+ license = "MIT"
9
+ repository = "https://github.com/andrewsmyth/geo-adjacency"
10
+ homepage = "https://asmyth01.github.io/geo-adjacency/"
11
+ documentation = "https://asmyth01.github.io/geo-adjacency/"
12
+ keywords = ["voronoi", "adjacency", "geospatial", "geometry"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Science/Research",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Scientific/Engineering",
25
+ "Topic :: Scientific/Engineering :: GIS",
26
+ ]
27
+
28
+ [tool.poetry.dependencies]
29
+ python = "<3.13,>=3.9"
30
+ scipy = "^1.11.3"
31
+ shapely = "^2.0.2"
32
+ numpy = "^1.26.2"
33
+ matplotlib = "^3.8.1"
34
+ setuptools = "^69.0.0"
35
+
36
+ [tool.poetry.dev-dependencies]
37
+ pytest = "^7.4.3"
38
+ pylint = "^3.0.2"
39
+
40
+
41
+ [tool.poetry.group.dev.dependencies]
42
+ pytest = "^7.4.3"
43
+ pytest-cov = "^4.1.0"
44
+
45
+ [build-system]
46
+ requires = ["poetry-core"]
47
+ build-backend = "poetry.core.masonry.api"
@@ -1,362 +0,0 @@
1
- """
2
- The adjacency module is a part of the geo-adjacency package, which provides functionality for
3
- calculating and analyzing adjacency between geometries. It implements the AdjacencyEngine class,
4
- which allows users to determine adjacency relationships between a set of source and target
5
- geometries, taking into account obstacles and a specified radius. The module utilizes the Voronoi
6
- diagram to generate adjacency linkages and provides methods for plotting the adjacency linkages on
7
- a map. The module also handles cases where there are gaps between features, ensuring accurate
8
- adjacency analysis. Overall, the adjacency module enables users to answer questions about spatial
9
- adjacency, providing valuable insights for geospatial analysis and decision-making processes.
10
- """
11
-
12
- import math
13
- import os
14
- import time
15
- from collections import defaultdict
16
- from typing import List, Union, Dict, Tuple
17
- import logging
18
-
19
- from scipy.spatial import distance, voronoi_plot_2d
20
- from scipy.spatial import Voronoi
21
- from shapely import Polygon, MultiPolygon, LineString, Point
22
- from shapely.geometry.base import BaseGeometry
23
- from shapely.ops import nearest_points
24
- from shapely.wkt import loads
25
- from shapely.geometry import mapping
26
- import numpy as np
27
- import matplotlib.pyplot as plt
28
-
29
- from geo_adjacency.exception import ImmutablePropertyError
30
- from geo_adjacency.utils import flatten_list, add_geometry_to_plot
31
-
32
- # Create a custom logger
33
- logger = logging.getLogger(__name__)
34
-
35
- # Create handlers
36
- c_handler = logging.StreamHandler()
37
- f_handler = logging.FileHandler("file.log")
38
- c_handler.setLevel(logging.WARNING)
39
- f_handler.setLevel(logging.ERROR)
40
-
41
- # Create formatters and add it to handlers
42
- c_format = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
43
- f_format = logging.Formatter(
44
- "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
45
- c_handler.setFormatter(c_format)
46
- f_handler.setFormatter(f_format)
47
-
48
- # Add handlers to the logger
49
- logger.addHandler(c_handler)
50
- logger.addHandler(f_handler)
51
-
52
-
53
- class _Feature:
54
- __slots__ = ("_geometry", "_coords", "voronoi_points")
55
-
56
- def __init__(self, geometry: BaseGeometry):
57
- self._geometry: BaseGeometry = geometry
58
- self._coords = None
59
- self.voronoi_points: set = set()
60
-
61
- def __str__(self):
62
- return str(self.geometry)
63
-
64
- def __repr__(self):
65
- return f"<_Feature: {str(self.geometry)}>"
66
-
67
- @property
68
- def geometry(self):
69
- """
70
- Access the Shapely geometry of the feature.
71
- :return: BaseGeometry
72
- """
73
- return self._geometry
74
-
75
- @geometry.setter
76
- def geometry(self, geometry):
77
- self._geometry = geometry
78
- self._coords = None
79
-
80
- @property
81
- def coords(self) -> List[Tuple[float, float]]:
82
- """
83
- Convenience property for accessing the coordinates of the geometry as a list of 2-tuples.
84
-
85
- :return: List[Tuple[float, float]]: A list of coordinate tuples.
86
- """
87
- if not self._coords:
88
- if isinstance(self.geometry, Point):
89
- self._coords = tuple([(self.geometry.x, self.geometry.y)])
90
- elif isinstance(self.geometry, Polygon):
91
- self._coords = tuple(mapping(self.geometry)["coordinates"][0])
92
- elif isinstance(self.geometry, MultiPolygon):
93
- self._coords = tuple(flatten_list(
94
- mapping(self.geometry)["coordinates"][0]))
95
- elif isinstance(self.geometry, LineString):
96
- self._coords = tuple((x, y) for x, y in self.geometry.coords)
97
- else:
98
- raise TypeError(
99
- f"Unknown geometry type '{type(self.geometry)}'")
100
- return self._coords
101
-
102
-
103
- class AdjacencyEngine:
104
- """
105
- A class for calculating the adjacency of a set of geometries to another geometry or set
106
- of geometries, given a set of obstacles, within a given radius.
107
-
108
- First, the Voronoi diagram is generated for each geometry and obstacle. Then, we check which
109
- voronoi shapes intersect one another. If they do, then the two underlying geometries are
110
- adjacent.
111
- """
112
-
113
- __slots__ = ("_source_features", "_target_features", "_obstacle_features", "_adjacency_dict",
114
- "_feature_indices", "_vor", "all_features", "all_coordinates")
115
-
116
- def __init__(
117
- self,
118
- source_geoms: List[BaseGeometry],
119
- target_geoms: List[BaseGeometry],
120
- obstacle_geoms: Union[List[BaseGeometry], None] = None,
121
- densify_features: bool = False,
122
- max_segment_length: Union[float, None] = None,
123
- ):
124
- """
125
- Note: only Multipolygons, Polygons, and LineStrings are supported. It is assumed all
126
- features are in the same projection.
127
-
128
- :param source_geoms: List of Shapely geometries. We will test if these features are
129
- adjacent to the target features.
130
- :param target_geoms: List of Shapley geometries. We will
131
- test if these features are adjacent to the source features.
132
- :param obstacle_geoms: List
133
- of Shapely geometries. These features will not be tested for adjacency, but they can
134
- prevent a source and target feature from being adjacent.
135
- :param densify_features: If
136
- True, we will add additional points to the features to improve accuracy of the voronoi
137
- diagram.
138
- :param max_segment_length: The maximum distance between vertices that we want.
139
- In projection units. densify_features must be True, or an error will be thrown. If
140
- densify_features is True and max_segment_length is false, then the max_segment_length
141
- will be calculated based on the average segment length of all features, divided by 5.
142
- This often works well.
143
- """
144
-
145
- if max_segment_length and not densify_features:
146
- raise ValueError(
147
- "interpolate_points must be True if interpolation_distance is not None"
148
- )
149
-
150
- self._source_features: Tuple[_Feature] = tuple([
151
- _Feature(geom) for geom in source_geoms
152
- ])
153
- self._target_features: Tuple[_Feature] = tuple([
154
- _Feature(geom) for geom in target_geoms
155
- ])
156
- self._obstacle_features: Union[Tuple[_Feature], None] = tuple([
157
- _Feature(geom) for geom in obstacle_geoms
158
- ])
159
- self._adjacency_dict = None
160
- self._feature_indices = None
161
- self._vor = None
162
-
163
- """All source, target, and obstacle features in a single list. The order of this list must
164
- not be changed."""
165
- self.all_features: Tuple[_Feature, ...] = tuple([*self.source_features, *self.target_features,
166
- *self.obstacle_features])
167
-
168
- if densify_features:
169
- if max_segment_length is None:
170
- max_segment_length = self.calc_segmentation_dist()
171
- logger.info("Calculated max_segment_length of %s" % max_segment_length)
172
-
173
- for feature in self.all_features:
174
- feature.geometry = feature.geometry.segmentize(
175
- max_segment_length)
176
-
177
- self.all_coordinates: Tuple[Tuple[float, float]] = tuple(flatten_list(
178
- [feature.coords for feature in self.all_features]
179
- ))
180
-
181
- def calc_segmentation_dist(self, divisor=5):
182
- """
183
- Try to create a well-fitting maximum length for all line segments in all features. Take
184
- the average distance between all coordinate pairs and divide by 5. This means that the
185
- average segment will be divided into five segments.
186
-
187
- This won't work as well if the different geometry sets have significantly different
188
- average segment lengths. In that case, it is advisable to prepare the data appropriately
189
- beforehand.
190
-
191
- :param divisor: Divide the average segment length by this number to get the new desired
192
- segment length.
193
-
194
- :return:
195
- """
196
-
197
- all_coordinates = flatten_list(
198
- [feature.coords for feature in self.all_features]
199
- )
200
- return float(
201
- (
202
- sum(distance.pdist(all_coordinates, "euclidean"))
203
- / math.pow(len(all_coordinates), 2)
204
- )
205
- / divisor
206
- )
207
-
208
- @property
209
- def source_features(self) -> Tuple[_Feature]:
210
- """
211
- Features which will be the keys in the adjacency_dict.
212
- :return: List of source features.
213
- """
214
- return self._source_features
215
-
216
- @source_features.setter
217
- def source_features(self, features: Tuple[BaseGeometry]):
218
- raise ImmutablePropertyError("Property source_features is immutable.")
219
-
220
- @property
221
- def target_features(self) -> Tuple[_Feature]:
222
- """
223
- Features which will be the values in the adjacency_dict.
224
- :return: List of target features.
225
- """
226
- return self._target_features
227
-
228
- @target_features.setter
229
- def target_features(self, _):
230
- raise ImmutablePropertyError("Property target_features is immutable.")
231
-
232
- @property
233
- def obstacle_features(self) -> Tuple[_Feature]:
234
- """
235
- Features which can prevent source and target features from being adjacent. They
236
- Do not participate in the adjacency_dict.
237
- :return: List of obstacle features.
238
- """
239
- return self._obstacle_features
240
-
241
- @obstacle_features.setter
242
- def obstacle_features(self, _):
243
- raise ImmutablePropertyError(
244
- "Property obstacle_features is immutable.")
245
-
246
- def get_feature_from_coord_index(self, coord_index: int) -> _Feature:
247
- """
248
- A list which is the length of self._all_coordinates. For each coordinate, we add the
249
- index of the corresponding feature from the list self.all_features. This is used to
250
- determine which coordinate belongs to which feature after we calculate the voronoi
251
- diagram.
252
-
253
- :return: A _Feature.
254
-
255
- """
256
- if not self._feature_indices:
257
- self._feature_indices = {}
258
- c = -1
259
- for f, feature in enumerate(self.all_features):
260
- for _ in range(len(feature.coords)):
261
- c += 1
262
- self._feature_indices[c] = f
263
-
264
- return self.all_features[self._feature_indices[coord_index]]
265
-
266
- @property
267
- def vor(self):
268
- """
269
- The Voronoi diagram object returned by Scipy. Useful primarily for debugging an
270
- adjacency analysis.
271
- :return: Voronoi object.
272
- """
273
- if not self._vor:
274
- self._vor = Voronoi(np.array(self.all_coordinates))
275
- return self._vor
276
-
277
- @vor.setter
278
- def vor(self, _):
279
- raise ImmutablePropertyError("Property vor is immutable.")
280
-
281
- def get_adjacency_dict(self) -> Dict[int, List[int]]:
282
- """
283
- Returns a dictionary of indices. They keys are the indices of feature_geoms. The values
284
- are the indices of any target geometries which are adjacent to the feature_geoms.
285
-
286
- :return: dict A dictionary of indices. The keys are the indices of feature_geoms. The
287
- values are the indices of any
288
- """
289
-
290
- if self._adjacency_dict is None:
291
- # We don't need to tag obstacles with their voronoi vertices
292
- obstacle_coord_len = sum(
293
- len(feat.coords) for feat in self.obstacle_features
294
- )
295
-
296
- # Tag each feature with the vertices of the voronoi region it
297
- # belongs to
298
- for coord_index in range(
299
- len(self.all_coordinates) - obstacle_coord_len):
300
- feature = self.get_feature_from_coord_index(coord_index)
301
- for vor_vertex_index in self.vor.regions[
302
- self.vor.point_region[coord_index]
303
- ]:
304
- # "-1" indices indicate the vertex goes to infinity. These don't provide us
305
- # with adjacency information, so we ignore them.
306
- if vor_vertex_index != -1:
307
- feature.voronoi_points.add(vor_vertex_index)
308
-
309
- # If any two features have any voronoi indices in common, then their voronoi regions
310
- # must intersect, therefore the input features are adjacent.
311
- self._adjacency_dict = defaultdict(list)
312
-
313
- for coord_index, source_feature in enumerate(self.source_features):
314
- for vor_region_index, target_feature in enumerate(
315
- self.target_features):
316
- if (
317
- len(
318
- source_feature.voronoi_points
319
- & target_feature.voronoi_points
320
- )
321
- > 1
322
- ):
323
- self._adjacency_dict[coord_index].append(
324
- vor_region_index)
325
-
326
- return self._adjacency_dict
327
-
328
- def plot_adjacency_dict(self) -> None:
329
- """
330
- Plot the adjacency linkages between the source and target with pyplot. Runs the analysis if
331
- it has not already been run.
332
- :return: None
333
- """
334
- # Plot the adjacency linkages between the source and target
335
- for source_i, target_is in self.get_adjacency_dict().items():
336
- source_poly = self.source_features[source_i].geometry
337
- target_polys = [
338
- self.target_features[target_i].geometry for target_i in
339
- target_is
340
- ]
341
-
342
- # Plot the linestrings between the source and target polygons
343
- links = [
344
- LineString(
345
- [nearest_points(source_poly, target_poly)
346
- [1], source_poly.centroid]
347
- )
348
- for target_poly in target_polys
349
- ]
350
- add_geometry_to_plot(links, "green")
351
-
352
- add_geometry_to_plot(
353
- [t.geometry for t in self.target_features], "blue")
354
- add_geometry_to_plot(
355
- [t.geometry for t in self.source_features], "grey")
356
- add_geometry_to_plot(
357
- [t.geometry for t in self.obstacle_features], "red")
358
-
359
- plt.title("Adjacency linkages between source and target")
360
- plt.xlabel("Longitude")
361
- plt.ylabel("Latitude")
362
- plt.show()
@@ -1,10 +0,0 @@
1
- 2023-11-20 19:19:58,755 - __main__ - ERROR - This is an error
2
- 2023-11-20 19:21:23,871 - __main__ - ERROR - This is an error
3
- 2023-11-20 19:22:38,493 - __main__ - ERROR - This is an error
4
- 2023-11-20 19:22:48,026 - __main__ - ERROR - This is an error
5
- 2023-11-20 19:23:34,369 - __main__ - ERROR - This is an error
6
- 2023-11-20 19:23:44,297 - __main__ - ERROR - This is an error
7
- 2023-11-20 19:26:28,000 - __main__ - ERROR - This is an error
8
- 2023-11-20 19:26:58,737 - __main__ - ERROR - This is an error
9
- 2023-11-20 19:28:41,686 - __main__ - ERROR - This is an error
10
- 2023-11-20 19:29:03,485 - __main__ - ERROR - This is an error
@@ -1,49 +0,0 @@
1
- """
2
- Convenience functions.
3
- """
4
-
5
- from matplotlib import pyplot as plt
6
- from shapely import Point, LineString, Polygon, MultiPolygon
7
-
8
-
9
- def flatten_list(list_of_lists):
10
- """
11
- Flattens a list of lists.
12
- :param list_of_lists: The list of lists.
13
- :return: A flattened list.
14
- """
15
- flattened_list = []
16
-
17
- for sublist in list_of_lists:
18
- for item in sublist:
19
- flattened_list.append(item)
20
-
21
- return flattened_list
22
-
23
-
24
- def add_geometry_to_plot(geoms, color="black"):
25
- """
26
- When updating the test data, it may be useful to visualize it.
27
- :param geoms:
28
- :param color:
29
- :return:
30
- """
31
- for geom in geoms:
32
- if isinstance(geom, Point):
33
- plt.plot(
34
- geom.x,
35
- geom.y,
36
- marker="o",
37
- markersize=5,
38
- markeredgecolor="black",
39
- markerfacecolor=color,
40
- )
41
- elif isinstance(geom, LineString):
42
- plt.plot(*geom.coords.xy, color=color)
43
- elif isinstance(geom, Polygon):
44
- plt.plot(*geom.exterior.xy, color=color, linestyle="-")
45
- elif isinstance(geom, MultiPolygon):
46
- for sub_poly in geom.geoms:
47
- plt.plot(*sub_poly.exterior.xy, color=color, linewidth=3)
48
- else:
49
- raise TypeError("Unknown geometry type")
@@ -1,25 +0,0 @@
1
- [tool.poetry]
2
- name = "geo-adjacency"
3
- version = "1.0.7"
4
- description = ""
5
- authors = ["Andrew Smyth <andrews@zillowgroup.com>"]
6
- readme = "README.md"
7
- packages = [{include = "geo_adjacency"}]
8
- license = "MIT"
9
-
10
- [tool.poetry.dependencies]
11
- python = "<3.13,>=3.9"
12
- scipy = "^1.11.3"
13
- shapely = "^2.0.2"
14
- numpy = "^1.26.2"
15
- matplotlib = "^3.8.1"
16
- setuptools = "^69.0.0"
17
-
18
- [tool.poetry.dev-dependencies]
19
- pytest = "^7.4.3"
20
- pylint = "^3.0.2"
21
-
22
-
23
- [build-system]
24
- requires = ["poetry-core"]
25
- build-backend = "poetry.core.masonry.api"
File without changes
File without changes