geo-adjacency 1.0.7__py3-none-any.whl → 1.1.2__py3-none-any.whl

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,61 +1,86 @@
1
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.
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
10
13
  """
11
14
 
12
15
  import math
13
- import os
14
- import time
15
16
  from collections import defaultdict
16
- from typing import List, Union, Dict, Tuple
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
17
23
  import logging
18
24
 
19
- from scipy.spatial import distance, voronoi_plot_2d
25
+ import matplotlib.pyplot as plt
26
+ import numpy as np
27
+ import shapely.ops
28
+ from scipy.spatial import distance
20
29
  from scipy.spatial import Voronoi
21
- from shapely import Polygon, MultiPolygon, LineString, Point
30
+ from shapely import LineString, Point, Polygon, MultiPolygon
22
31
  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
32
 
29
33
  from geo_adjacency.exception import ImmutablePropertyError
30
- from geo_adjacency.utils import flatten_list, add_geometry_to_plot
31
-
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
32
43
  # Create a custom logger
33
- logger = logging.getLogger(__name__)
44
+ log: logging.Logger = logging.getLogger(__name__)
34
45
 
35
46
  # Create handlers
36
- c_handler = logging.StreamHandler()
37
- f_handler = logging.FileHandler("file.log")
47
+ c_handler: logging.StreamHandler = logging.StreamHandler()
38
48
  c_handler.setLevel(logging.WARNING)
39
- f_handler.setLevel(logging.ERROR)
40
49
 
41
50
  # 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")
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")
45
53
  c_handler.setFormatter(c_format)
46
- f_handler.setFormatter(f_format)
47
54
 
48
55
  # Add handlers to the logger
49
- logger.addHandler(c_handler)
50
- logger.addHandler(f_handler)
56
+ log.addHandler(c_handler)
51
57
 
52
58
 
53
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
+ """
54
64
  __slots__ = ("_geometry", "_coords", "voronoi_points")
55
65
 
56
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
+
57
82
  self._geometry: BaseGeometry = geometry
58
- self._coords = None
83
+ self._coords: Union[List[Tuple[float, float]], None] = None
59
84
  self.voronoi_points: set = set()
60
85
 
61
86
  def __str__(self):
@@ -64,11 +89,43 @@ class _Feature:
64
89
  def __repr__(self):
65
90
  return f"<_Feature: {str(self.geometry)}>"
66
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
+
67
121
  @property
68
122
  def geometry(self):
69
123
  """
70
124
  Access the Shapely geometry of the feature.
71
- :return: BaseGeometry
125
+
126
+ Returns:
127
+ BaseGeometry: The Shapely geometry of the feature.
128
+
72
129
  """
73
130
  return self._geometry
74
131
 
@@ -82,23 +139,28 @@ class _Feature:
82
139
  """
83
140
  Convenience property for accessing the coordinates of the geometry as a list of 2-tuples.
84
141
 
85
- :return: List[Tuple[float, float]]: A list of coordinate tuples.
142
+ Returns:
143
+ List[Tuple[float, float]]: A list of coordinate tuples.
144
+
86
145
  """
146
+
87
147
  if not self._coords:
88
148
  if isinstance(self.geometry, Point):
89
- self._coords = tuple([(self.geometry.x, self.geometry.y)])
149
+ self._coords = coords_from_point(self.geometry)
150
+ elif isinstance(self.geometry, LineString):
151
+ self._coords = coords_from_ring(self.geometry)
90
152
  elif isinstance(self.geometry, Polygon):
91
- self._coords = tuple(mapping(self.geometry)["coordinates"][0])
153
+ self._coords = coords_from_polygon(self.geometry)
92
154
  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)
155
+ self._coords = coords_from_multipolygon(self.geometry)
97
156
  else:
98
- raise TypeError(
99
- f"Unknown geometry type '{type(self.geometry)}'")
157
+ raise TypeError(f"Unknown geometry type '{type(self.geometry)}'")
100
158
  return self._coords
101
159
 
160
+ @coords.setter
161
+ def coords(self, coords):
162
+ raise ImmutablePropertyError("Property coords is immutable.")
163
+
102
164
 
103
165
  class AdjacencyEngine:
104
166
  """
@@ -110,36 +172,43 @@ class AdjacencyEngine:
110
172
  adjacent.
111
173
  """
112
174
 
113
- __slots__ = ("_source_features", "_target_features", "_obstacle_features", "_adjacency_dict",
114
- "_feature_indices", "_vor", "all_features", "all_coordinates")
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
+ )
115
185
 
116
186
  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,
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,
123
193
  ):
124
194
  """
125
- Note: only Multipolygons, Polygons, and LineStrings are supported. It is assumed all
195
+ Note: only Multipolygons, Polygons, LineStrings and Points are supported. It is assumed all
126
196
  features are in the same projection.
127
197
 
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
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
131
202
  test if these features are adjacent to the source features.
132
- :param obstacle_geoms: List
203
+ obstacle_geoms (Union[List[BaseGeometry], None]), optional): List
133
204
  of Shapely geometries. These features will not be tested for adjacency, but they can
134
205
  prevent a source and target feature from being adjacent.
135
- :param densify_features: If
206
+ densify_features (bool, optional): If
136
207
  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
208
+ diagram. If densify_features is True and max_segment_length is false, then the max_segment_length
141
209
  will be calculated based on the average segment length of all features, divided by 5.
142
- This often works well.
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.
143
212
  """
144
213
 
145
214
  if max_segment_length and not densify_features:
@@ -147,38 +216,80 @@ class AdjacencyEngine:
147
216
  "interpolate_points must be True if interpolation_distance is not None"
148
217
  )
149
218
 
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
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
161
234
  self._vor = None
235
+ self._all_coordinates = None
162
236
 
163
237
  """All source, target, and obstacle features in a single list. The order of this list must
164
238
  not be changed."""
165
- self.all_features: Tuple[_Feature, ...] = tuple([*self.source_features, *self.target_features,
166
- *self.obstacle_features])
239
+ self._all_features: Tuple[_Feature, ...] = tuple(
240
+ [*self.source_features, *self.target_features, *self.obstacle_features]
241
+ )
167
242
 
168
243
  if densify_features:
169
244
  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)
245
+ max_segment_length = self._calc_segmentation_dist()
246
+ log.info("Calculated max_segment_length of %s" % max_segment_length)
172
247
 
173
248
  for feature in self.all_features:
174
- feature.geometry = feature.geometry.segmentize(
175
- max_segment_length)
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.
176
259
 
177
- self.all_coordinates: Tuple[Tuple[float, float]] = tuple(flatten_list(
178
- [feature.coords for feature in self.all_features]
179
- ))
260
+ Returns:
261
+ List[_Feature]: A list of _Features.
180
262
 
181
- def calc_segmentation_dist(self, divisor=5):
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):
182
293
  """
183
294
  Try to create a well-fitting maximum length for all line segments in all features. Take
184
295
  the average distance between all coordinate pairs and divide by 5. This means that the
@@ -188,19 +299,18 @@ class AdjacencyEngine:
188
299
  average segment lengths. In that case, it is advisable to prepare the data appropriately
189
300
  beforehand.
190
301
 
191
- :param divisor: Divide the average segment length by this number to get the new desired
302
+ Args:
303
+ divisor (int, optional): Divide the average segment length by this number to get the new desired
192
304
  segment length.
193
305
 
194
- :return:
306
+ Returns:
307
+ float: Average segment length divided by divisor.
195
308
  """
196
309
 
197
- all_coordinates = flatten_list(
198
- [feature.coords for feature in self.all_features]
199
- )
200
310
  return float(
201
311
  (
202
- sum(distance.pdist(all_coordinates, "euclidean"))
203
- / math.pow(len(all_coordinates), 2)
312
+ sum(distance.pdist(self.all_coordinates, "euclidean"))
313
+ / math.pow(len(self.all_coordinates), 2)
204
314
  )
205
315
  / divisor
206
316
  )
@@ -209,7 +319,10 @@ class AdjacencyEngine:
209
319
  def source_features(self) -> Tuple[_Feature]:
210
320
  """
211
321
  Features which will be the keys in the adjacency_dict.
212
- :return: List of source features.
322
+
323
+ Returns:
324
+ List[_Feature]: A list of _Features.
325
+
213
326
  """
214
327
  return self._source_features
215
328
 
@@ -221,7 +334,8 @@ class AdjacencyEngine:
221
334
  def target_features(self) -> Tuple[_Feature]:
222
335
  """
223
336
  Features which will be the values in the adjacency_dict.
224
- :return: List of target features.
337
+ Returns:
338
+ List[_Feature]: A list of _Features.
225
339
  """
226
340
  return self._target_features
227
341
 
@@ -234,14 +348,15 @@ class AdjacencyEngine:
234
348
  """
235
349
  Features which can prevent source and target features from being adjacent. They
236
350
  Do not participate in the adjacency_dict.
237
- :return: List of obstacle features.
351
+
352
+ Returns:
353
+ List[_Feature]: A list of _Features.
238
354
  """
239
355
  return self._obstacle_features
240
356
 
241
357
  @obstacle_features.setter
242
358
  def obstacle_features(self, _):
243
- raise ImmutablePropertyError(
244
- "Property obstacle_features is immutable.")
359
+ raise ImmutablePropertyError("Property obstacle_features is immutable.")
245
360
 
246
361
  def get_feature_from_coord_index(self, coord_index: int) -> _Feature:
247
362
  """
@@ -250,8 +365,11 @@ class AdjacencyEngine:
250
365
  determine which coordinate belongs to which feature after we calculate the voronoi
251
366
  diagram.
252
367
 
253
- :return: A _Feature.
368
+ Args:
369
+ coord_index (int): The index of the coordinate in self._all_coordinates
254
370
 
371
+ Returns:
372
+ _Feature: A _Feature at the given index.
255
373
  """
256
374
  if not self._feature_indices:
257
375
  self._feature_indices = {}
@@ -268,7 +386,9 @@ class AdjacencyEngine:
268
386
  """
269
387
  The Voronoi diagram object returned by Scipy. Useful primarily for debugging an
270
388
  adjacency analysis.
271
- :return: Voronoi object.
389
+
390
+ Returns:
391
+ scipy.spatial.Voronoi: The Scipy Voronoi object.
272
392
  """
273
393
  if not self._vor:
274
394
  self._vor = Voronoi(np.array(self.all_coordinates))
@@ -278,50 +398,90 @@ class AdjacencyEngine:
278
398
  def vor(self, _):
279
399
  raise ImmutablePropertyError("Property vor is immutable.")
280
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
+
281
455
  def get_adjacency_dict(self) -> Dict[int, List[int]]:
282
456
  """
283
457
  Returns a dictionary of indices. They keys are the indices of feature_geoms. The values
284
458
  are the indices of any target geometries which are adjacent to the feature_geoms.
285
459
 
286
- :return: dict A dictionary of indices. The keys are the indices of feature_geoms. The
287
- values are the indices of any
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
+
288
467
  """
289
468
 
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
- )
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."""
295
471
 
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)
472
+ if self._adjacency_dict is None:
473
+ self._tag_feature_with_voronoi_vertices()
308
474
 
309
475
  # If any two features have any voronoi indices in common, then their voronoi regions
310
476
  # must intersect, therefore the input features are adjacent.
311
477
  self._adjacency_dict = defaultdict(list)
312
478
 
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)
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)
325
485
 
326
486
  return self._adjacency_dict
327
487
 
@@ -329,32 +489,54 @@ class AdjacencyEngine:
329
489
  """
330
490
  Plot the adjacency linkages between the source and target with pyplot. Runs the analysis if
331
491
  it has not already been run.
332
- :return: None
492
+
493
+ Returns:
494
+ None
333
495
  """
334
496
  # 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")
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")
358
540
 
359
541
  plt.title("Adjacency linkages between source and target")
360
542
  plt.xlabel("Longitude")
@@ -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)
geo_adjacency/utils.py CHANGED
@@ -1,32 +1,120 @@
1
1
  """
2
- Convenience functions.
2
+ Utility functions. These are designed for use in the AdjacencyEngine only, and should not be called
3
+ by end users.
3
4
  """
4
5
 
6
+ from typing import List, Tuple
7
+
5
8
  from matplotlib import pyplot as plt
6
9
  from shapely import Point, LineString, Polygon, MultiPolygon
7
10
 
8
11
 
9
- def flatten_list(list_of_lists):
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]]:
10
66
  """
11
- Flattens a list of lists.
12
- :param list_of_lists: The list of lists.
13
- :return: A flattened list.
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.
14
75
  """
15
- flattened_list = []
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
16
83
 
17
- for sublist in list_of_lists:
18
- for item in sublist:
19
- flattened_list.append(item)
20
84
 
21
- return flattened_list
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:])
22
105
 
23
106
 
24
107
  def add_geometry_to_plot(geoms, color="black"):
25
108
  """
26
- When updating the test data, it may be useful to visualize it.
27
- :param geoms:
28
- :param color:
29
- :return:
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
30
118
  """
31
119
  for geom in geoms:
32
120
  if isinstance(geom, Point):
@@ -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,8 @@
1
+ geo_adjacency/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ geo_adjacency/adjacency.py,sha256=bL6ErCwgtpvNu0XfgpQwCJ6RlK2M-vi-qx_veE1_0Rk,20844
3
+ geo_adjacency/exception.py,sha256=zZNdBOm5LpuiCpNuqH1FNLhiPnQqyCyuhOTMBDnLSTQ,230
4
+ geo_adjacency/utils.py,sha256=57Q-nRZQlW1QetlLoucbDr1jm3CRHYRCVzrarm7xxZw,4188
5
+ geo_adjacency-1.1.2.dist-info/LICENSE,sha256=p0PMGdB2iuOndKPbBCVhTNe9TMIxZRpJ64bQ_CoUIqY,1065
6
+ geo_adjacency-1.1.2.dist-info/METADATA,sha256=fpHyeHH726bndsYxfE_CWAtJLmBN9MlN3lFiraRX8PA,3857
7
+ geo_adjacency-1.1.2.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
8
+ geo_adjacency-1.1.2.dist-info/RECORD,,
geo_adjacency/file.log DELETED
@@ -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,9 +0,0 @@
1
- geo_adjacency/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- geo_adjacency/adjacency.py,sha256=vonZw4pZDkuAR_-zWO2zEflsvvepG9njT5APFGnMW2M,14201
3
- geo_adjacency/exception.py,sha256=-1fdZVR8LayIJmu3Zj2CA-5ItuFH-mOONPCe4g5M6l8,228
4
- geo_adjacency/file.log,sha256=Zc7rMTU9N_LJKx68zsOwDJcuTnD8Mxll3iHlmZWQUzg,620
5
- geo_adjacency/utils.py,sha256=gqAV-0tE_P5VIAorJc92u0-jUM-POlcaJnAzFDxcbEk,1333
6
- geo_adjacency-1.0.7.dist-info/LICENSE,sha256=p0PMGdB2iuOndKPbBCVhTNe9TMIxZRpJ64bQ_CoUIqY,1065
7
- geo_adjacency-1.0.7.dist-info/METADATA,sha256=PEmj1BtFUJ80Ux5m0V86ayNUzqWR2rLcGJKfsVP7WuI,3215
8
- geo_adjacency-1.0.7.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
9
- geo_adjacency-1.0.7.dist-info/RECORD,,