geo-adjacency 1.2.1__py3-none-any.whl → 1.3.0__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.
geo_adjacency/__init__.py CHANGED
@@ -0,0 +1,19 @@
1
+ """
2
+ Geo-adjacency: Spatial adjacency analysis using Voronoi diagrams.
3
+
4
+ This package provides tools for determining adjacency relationships between
5
+ geometric features, even when they don't directly touch. It uses Voronoi
6
+ diagram analysis to identify spatial relationships that account for gaps
7
+ and obstacles between features.
8
+
9
+ Main class:
10
+ AdjacencyEngine: The primary class for performing adjacency analysis.
11
+
12
+ Example:
13
+ >>> from geo_adjacency.adjacency import AdjacencyEngine
14
+ >>> from shapely.geometry import Point
15
+ >>> sources = [Point(0, 0), Point(1, 0)]
16
+ >>> targets = [Point(0, 1), Point(1, 1)]
17
+ >>> engine = AdjacencyEngine(sources, targets)
18
+ >>> adjacencies = engine.get_adjacency_dict()
19
+ """
@@ -9,208 +9,79 @@ pass in a set of Point geometries to the trees, a Polygon to represent the lake,
9
9
  a road passing between some of the trees and the shore.
10
10
 
11
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
12
+ which geometries are adjacent to each other. The methodology is described in detail in the project documentation.
13
13
  """
14
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
15
  import logging
16
+ from typing import Dict, Generator, List, Tuple, Union
17
+ from collections import defaultdict
24
18
 
25
- import matplotlib.pyplot as plt
19
+ import geopandas as gpd
26
20
  import numpy as np
27
- import shapely.ops
28
- from scipy.spatial import distance
21
+ import pandas as pd
22
+ from matplotlib import pyplot as plt
29
23
  from scipy.spatial import Voronoi
30
- from shapely import LineString, Point, Polygon, MultiPolygon, box
24
+ from shapely import LineString, MultiPoint, Point, Polygon, box
25
+ from shapely import ops as shapely_ops
31
26
  from shapely.geometry.base import BaseGeometry
32
27
 
33
28
  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(
52
- "%(name)s - %(levelname)s - %(message)s"
53
- )
54
- f_format: logging.Formatter = logging.Formatter(
55
- "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
56
- )
57
- c_handler.setFormatter(c_format)
58
-
59
- # Add handlers to the logger
60
- log.addHandler(c_handler)
61
-
62
-
63
- class _Feature:
64
- """
65
- A _Feature is a wrapper around a Shapely geometry that allows us to easily determine if two
66
- geometries are adjacent.
67
- """
68
-
69
- __slots__ = ("_geometry", "_coords", "voronoi_points")
70
-
71
- def __init__(self, geometry: BaseGeometry):
72
- """
73
- Create a _Feature from a Shapely geometry.
74
-
75
- Args:
76
- geometry (BaseGeometry): A valid Shapely Geometry, either a Point, LineString, Polygon, or
77
- MultiPolygon.
78
- """
79
-
80
- if not isinstance(geometry, (Point, Polygon, MultiPolygon, LineString)):
81
- raise TypeError(
82
- "Cannot create _Feature for geometry type '%s'." % type(geometry)
83
- )
84
-
85
- assert geometry.is_valid, (
86
- "Could not process invalid geometry: %s" % geometry.wkt
87
- )
88
-
89
- self._geometry: BaseGeometry = geometry
90
- self._coords: Union[List[Tuple[float, float]], None] = None
91
- self.voronoi_points: set = set()
29
+ from geo_adjacency.logging_config import setup_logger
30
+ from geo_adjacency.utils import count_unique_coords, add_geometry_to_plot
92
31
 
93
- def __str__(self):
94
- return str(self.geometry)
95
-
96
- def __repr__(self):
97
- return f"<_Feature: {str(self.geometry)}>"
98
-
99
- def _is_adjacent(
100
- self, other: Self, min_overlapping_voronoi_vertices: int = 2
101
- ) -> bool:
102
- """
103
- Determine if two features are adjacent based on how many Voronoi vertices they share. Note:
104
- the Voronoi analysis must have been run, or this will always return False.
105
- Args:
106
- other (_Feature): Another _Feature to compare to.
107
- min_overlapping_voronoi_vertices (int): The minimum number of Voronoi vertices that
108
- must be shared to be considered adjacent.
109
-
110
- Returns:
111
- bool: True if the two features are adjacent.
112
-
113
- """
114
- assert isinstance(other, type(self)), "Cannot compare '%s' with '%s'." % (
115
- type(self),
116
- type(other),
117
- )
118
- if len(self.voronoi_points) == 0 and len(other.voronoi_points) == 0:
119
- log.warning(
120
- "No Voronoi vertices found for either feature. Did you run the analysis yet?"
121
- )
122
- return False
123
- return (
124
- len(self.voronoi_points & other.voronoi_points)
125
- >= min_overlapping_voronoi_vertices
126
- )
127
-
128
- @property
129
- def geometry(self):
130
- """
131
- Access the Shapely geometry of the feature.
132
-
133
- Returns:
134
- BaseGeometry: The Shapely geometry of the feature.
135
-
136
- """
137
- return self._geometry
138
-
139
- @geometry.setter
140
- def geometry(self, geometry):
141
- self._geometry = geometry
142
- self._coords = None
143
-
144
- @property
145
- def coords(self) -> List[Tuple[float, float]]:
146
- """
147
- Convenience property for accessing the coordinates of the geometry as a list of 2-tuples.
148
-
149
- Returns:
150
- List[Tuple[float, float]]: A list of coordinate tuples.
151
-
152
- """
153
-
154
- if not self._coords:
155
- if isinstance(self.geometry, Point):
156
- self._coords = coords_from_point(self.geometry)
157
- elif isinstance(self.geometry, LineString):
158
- self._coords = coords_from_ring(self.geometry)
159
- elif isinstance(self.geometry, Polygon):
160
- self._coords = coords_from_polygon(self.geometry)
161
- elif isinstance(self.geometry, MultiPolygon):
162
- self._coords = coords_from_multipolygon(self.geometry)
163
- else:
164
- raise TypeError(f"Unknown geometry type '{type(self.geometry)}'")
165
- return self._coords
166
-
167
- @coords.setter
168
- def coords(self, coords):
169
- raise ImmutablePropertyError("Property coords is immutable.")
32
+ # Create a custom logger using the centralized logging configuration
33
+ log: logging.Logger = setup_logger(__name__)
170
34
 
171
35
 
172
36
  class AdjacencyEngine:
173
37
  """
174
38
  A class for calculating the adjacency of a set of geometries to another geometry or set
175
- of geometries, given a set of obstacles, within a given radius.
39
+ of geometries, given a set of obstacles. Optionally supports distance constraints and
40
+ bounding box filtering.
176
41
 
177
- First, the Voronoi diagram is generated for each geometry and obstacle. Then, we check which
178
- voronoi shapes intersect one another. If they do, then the two underlying geometries are
179
- adjacent.
42
+ First, the Voronoi diagram is generated for all geometry vertices including obstacles.
43
+ Then, we check which Voronoi regions share vertices. If they share enough vertices
44
+ (configurable threshold), then the underlying geometries are considered adjacent.
180
45
  """
181
46
 
182
47
  __slots__ = (
183
- "_source_features",
184
- "_target_features",
185
- "_obstacle_features",
48
+ "_source_gdf",
49
+ "_target_gdf",
50
+ "_obstacle_gdf",
186
51
  "_adjacency_dict",
187
- "_feature_indices",
188
52
  "_vor",
189
- "_all_features",
190
- "_all_coordinates",
53
+ "_all_features_gdf",
191
54
  "_max_distance",
192
55
  "_bounding_rectangle",
56
+ "_min_overlapping_voronoi_vertices",
57
+ "_coord_to_feature_cache",
58
+ "_geometry_voronoi_vertices",
193
59
  )
194
60
 
195
61
  def __init__(
196
62
  self,
197
- source_geoms: List[BaseGeometry],
198
- target_geoms: Union[List[BaseGeometry], None] = None,
199
- obstacle_geoms: Union[List[BaseGeometry], None] = None,
63
+ source_geoms: Union[List[BaseGeometry], gpd.GeoDataFrame],
64
+ target_geoms: Union[List[BaseGeometry], gpd.GeoDataFrame, None] = None,
65
+ obstacle_geoms: Union[List[BaseGeometry], gpd.GeoDataFrame, None] = None,
200
66
  **kwargs,
201
67
  ):
202
68
  """
203
- Note: only Multipolygons, Polygons, LineStrings and Points are supported. It is assumed all
69
+ Note: only Multipolygons, Polygons, LineStrings and Points are supported. It is assumed all
204
70
  features are in the same projection.
205
71
 
206
72
  Args:
207
- source_geoms (List[BaseGeometry]): List of Shapely geometries. We will which ones are adjacent to
208
- which others, unless target_geoms is specified.
209
- target_geoms (Union[List[BaseGeometry], None]), optional): list of Shapley geometries. if not None, We will
210
- test if these features are adjacent to the source features.
211
- obstacle_geoms (Union[List[BaseGeometry], None]), optional): List
212
- of Shapely geometries. These features will not be tested for adjacency, but they can
213
- prevent a source and target feature from being adjacent.
73
+ source_geoms (Union[List[BaseGeometry], gpd.GeoDataFrame]): List of Shapely geometries
74
+ or a GeoPandas GeoDataFrame. We will determine which ones are adjacent to which others,
75
+ unless target_geoms is specified. If a list is provided, it will be converted to a
76
+ GeoDataFrame internally for vectorized operations.
77
+ target_geoms (Union[List[BaseGeometry], gpd.GeoDataFrame, None], optional): List of
78
+ Shapely geometries or a GeoPandas GeoDataFrame. If not None, we will test if these
79
+ features are adjacent to the source features. If a list is provided, it will be
80
+ converted to a GeoDataFrame internally.
81
+ obstacle_geoms (Union[List[BaseGeometry], gpd.GeoDataFrame, None], optional): List
82
+ of Shapely geometries or a GeoPandas GeoDataFrame. These features will not be tested
83
+ for adjacency, but they can prevent a source and target feature from being adjacent.
84
+ If a list is provided, it will be converted to a GeoDataFrame internally.
214
85
 
215
86
  Keyword Args:
216
87
  densify_features (bool, optional): If True, we will add additional points to the
@@ -218,7 +89,7 @@ class AdjacencyEngine:
218
89
  max_segment_length is false, then the max_segment_length will be calculated based on
219
90
  the average segment length of all features, divided by 5.
220
91
  max_segment_length (Union[float, None], optional): The maximum distance between vertices
221
- that we want iIn projection units. densify_features must be True, or an error will be thrown.
92
+ that we want in projection units. densify_features must be True, or an error will be thrown.
222
93
  max_distance (Union[float, None], optional): The maximum distance between two features
223
94
  for them to be candidates for adjacency. Units are same as geometry coordinate system.
224
95
  bounding_box (Union[float, float, float, float, None], optional): Set a bounding box
@@ -226,13 +97,17 @@ class AdjacencyEngine:
226
97
  This is useful for removing data from the edges from the final analysis, as these
227
98
  are often not accurate. This is particularly helpful when analyzing a large data set
228
99
  in a windowed fashion. Expected format is (minx, miny, maxx, maxy).
100
+ min_overlapping_voronoi_vertices (int, optional): Minimum number of Voronoi vertices
101
+ that must be shared between features to be considered adjacent. Default 2.
229
102
 
230
103
  """
231
104
 
232
105
  densify_features = kwargs.get("densify_features", False)
233
106
  max_segment_length = kwargs.get("max_segment_length", None)
234
107
  self._max_distance = kwargs.get("max_distance", None)
235
-
108
+ self._min_overlapping_voronoi_vertices = kwargs.get(
109
+ "min_overlapping_voronoi_vertices", 2
110
+ )
236
111
  if kwargs.get("bounding_box", None):
237
112
  minx, miny, maxx, maxy = kwargs.get("bounding_box")
238
113
  assert (
@@ -244,81 +119,120 @@ class AdjacencyEngine:
244
119
 
245
120
  if max_segment_length and not densify_features:
246
121
  raise ValueError(
247
- "interpolate_points must be True if interpolation_distance is not None"
122
+ "densify_features must be True if max_segment_length is not None"
248
123
  )
249
124
 
250
- self._source_features: Tuple[_Feature] = tuple(
251
- [_Feature(geom) for geom in source_geoms]
125
+ # Convert inputs to GeoDataFrames for vectorized operations
126
+ self._source_gdf = self._to_geodataframe(source_geoms)
127
+ self._target_gdf = (
128
+ self._to_geodataframe(target_geoms) if target_geoms is not None else None
252
129
  )
253
- self._target_features: Tuple[_Feature] = (
254
- tuple([_Feature(geom) for geom in target_geoms])
255
- if target_geoms
256
- else tuple()
257
- )
258
- self._obstacle_features: Union[Tuple[_Feature], None] = (
259
- tuple([_Feature(geom) for geom in obstacle_geoms])
260
- if obstacle_geoms
261
- else tuple()
130
+ self._obstacle_gdf = (
131
+ self._to_geodataframe(obstacle_geoms)
132
+ if obstacle_geoms is not None
133
+ else None
262
134
  )
135
+
263
136
  self._adjacency_dict: Union[Dict[int, List[int]], None] = None
264
- self._feature_indices: Union[Dict[int, int], None] = None
265
137
  self._vor = None
266
- self._all_coordinates = None
267
-
268
- """All source, target, and obstacle features in a single list. The order of this list must
269
- not be changed."""
270
- self._all_features: Tuple[_Feature, ...] = tuple(
271
- [*self.source_features, *self.target_features, *self.obstacle_features]
272
- )
138
+ self._all_features_gdf = None
139
+ self._geometry_voronoi_vertices = None
140
+ self._coord_to_feature_cache: Union[Dict[int, Tuple[str, int]], None] = None
273
141
 
274
142
  if densify_features:
275
143
  if max_segment_length is None:
276
144
  max_segment_length = self._calc_segmentation_dist()
277
145
  log.info("Calculated max_segment_length of %s" % max_segment_length)
278
146
 
279
- for feature in self.all_features:
280
- if not isinstance(feature.geometry, Point):
281
- feature.geometry = feature.geometry.segmentize(max_segment_length)
282
- # Reset all coordinates
283
- self._all_coordinates = None
147
+ # Apply segmentation to all GeoDataFrames
148
+ for gdf in [self.source_gdf, self.target_gdf, self.obstacle_gdf]:
149
+ if gdf is not None:
150
+ # Apply segmentation to non-point geometries
151
+ mask = ~gdf.geometry.apply(
152
+ lambda geom: isinstance(geom, (Point, MultiPoint))
153
+ )
154
+ if mask.any():
155
+ gdf.loc[mask, "geometry"] = gdf.loc[mask, "geometry"].apply(
156
+ lambda geom: geom.segmentize(max_segment_length)
157
+ )
158
+ # Reset all features cache
159
+ self._all_features_gdf = None
284
160
 
285
- @property
286
- def all_features(self):
161
+ def _to_geodataframe(
162
+ self, geoms_input: Union[List[BaseGeometry], gpd.GeoDataFrame]
163
+ ) -> gpd.GeoDataFrame:
287
164
  """
288
- All source, target, and obstacle features in a single list. The order of this list must
289
- not be changed. This property cannot be set manually.
165
+ Convert input geometries to a GeoDataFrame for vectorized operations.
290
166
 
291
- Returns:
292
- List[_Feature]: A list of _Features.
167
+ Args:
168
+ geoms_input: Either a list of geometries or an existing GeoDataFrame
293
169
 
170
+ Returns:
171
+ gpd.GeoDataFrame: A GeoDataFrame with geometries and optional attributes
294
172
  """
295
- return self._all_features
173
+ return (
174
+ geoms_input
175
+ if isinstance(geoms_input, gpd.GeoDataFrame)
176
+ else gpd.GeoDataFrame(geometry=geoms_input)
177
+ )
296
178
 
297
- @all_features.setter
298
- def all_features(self, value):
299
- raise ImmutablePropertyError("Property all_features is immutable.")
179
+ @property
180
+ def source_gdf(self) -> gpd.GeoDataFrame:
181
+ """Access the source geometries as a GeoDataFrame."""
182
+ return self._source_gdf
183
+
184
+ @property
185
+ def target_gdf(self) -> Union[gpd.GeoDataFrame, None]:
186
+ """Access the target geometries as a GeoDataFrame."""
187
+ return self._target_gdf
300
188
 
301
189
  @property
302
- def all_coordinates(self):
190
+ def obstacle_gdf(self) -> Union[gpd.GeoDataFrame, None]:
191
+ """Access the obstacle geometries as a GeoDataFrame."""
192
+ return self._obstacle_gdf
193
+
194
+ @property
195
+ def all_features_gdf(self) -> gpd.GeoDataFrame:
303
196
  """
304
- All source, target, and obstacle coordinates in a single list. The order of this list must
305
- not be changed. This property cannot be set manually.
197
+ All source, target, and obstacle features concatenated into a single GeoDataFrame.
198
+ The order is preserved: source, then target, then obstacles.
306
199
 
307
200
  Returns:
308
- List[tuple[float, float]]: A list of coordinate tuples.
201
+ gpd.GeoDataFrame: Combined GeoDataFrame with all geometries and a 'feature_type' column.
309
202
  """
310
203
 
311
- if not self._all_coordinates:
312
- self._all_coordinates = []
313
- for feature in self.all_features:
314
- self._all_coordinates.extend(feature.coords)
204
+ if self._all_features_gdf is None:
205
+ # Concatenate all GeoDataFrames while preserving order
206
+ gdfs_to_concat = []
207
+
208
+ if self.source_gdf is not None and len(self.source_gdf) > 0:
209
+ source_copy = self.source_gdf.copy()
210
+ source_copy["feature_type"] = "source"
211
+ source_copy["original_index"] = range(len(source_copy))
212
+ gdfs_to_concat.append(source_copy)
213
+
214
+ if self.target_gdf is not None and len(self.target_gdf) > 0:
215
+ target_copy = self.target_gdf.copy()
216
+ target_copy["feature_type"] = "target"
217
+ target_copy["original_index"] = range(len(target_copy))
218
+ gdfs_to_concat.append(target_copy)
315
219
 
316
- self._all_coordinates = tuple(self._all_coordinates)
317
- return self._all_coordinates
220
+ if self.obstacle_gdf is not None and len(self.obstacle_gdf) > 0:
221
+ obstacle_copy = self.obstacle_gdf.copy()
222
+ obstacle_copy["feature_type"] = "obstacle"
223
+ obstacle_copy["original_index"] = range(len(obstacle_copy))
224
+ gdfs_to_concat.append(obstacle_copy)
318
225
 
319
- @all_coordinates.setter
320
- def all_coordinates(self, value):
321
- raise ImmutablePropertyError("Property all_coordinates is immutable.")
226
+ if gdfs_to_concat:
227
+ self._all_features_gdf = pd.concat(gdfs_to_concat, ignore_index=True)
228
+ else:
229
+ self._all_features_gdf = gpd.GeoDataFrame()
230
+
231
+ return self._all_features_gdf
232
+
233
+ @all_features_gdf.setter
234
+ def all_features_gdf(self, value):
235
+ raise ImmutablePropertyError("Property all_features is immutable.")
322
236
 
323
237
  def _calc_segmentation_dist(self, divisor=5):
324
238
  """
@@ -339,90 +253,75 @@ class AdjacencyEngine:
339
253
  """
340
254
 
341
255
  return float(
342
- (
343
- sum(distance.pdist(self.all_coordinates, "euclidean"))
344
- / math.pow(len(self.all_coordinates), 2)
345
- )
256
+ sum(self.all_features_gdf.geometry.length)
257
+ / self.all_features_gdf.apply(
258
+ lambda row: count_unique_coords(row.geometry), axis=1
259
+ ).sum()
346
260
  / divisor
347
261
  )
348
262
 
349
- @property
350
- def source_features(self) -> Tuple[_Feature]:
351
- """
352
- Features which will be the keys in the adjacency_dict.
353
-
354
- Returns:
355
- List[_Feature]: A list of _Features.
356
-
357
- """
358
- return self._source_features
359
-
360
- @source_features.setter
361
- def source_features(self, features: Tuple[BaseGeometry]):
362
- raise ImmutablePropertyError("Property source_features is immutable.")
363
-
364
- @property
365
- def target_features(self) -> Tuple[_Feature]:
366
- """
367
- Features which will be the values in the adjacency_dict.
368
- Returns:
369
- List[_Feature]: A list of _Features.
370
- """
371
- return self._target_features
372
-
373
- @target_features.setter
374
- def target_features(self, _):
375
- raise ImmutablePropertyError("Property target_features is immutable.")
376
-
377
- @property
378
- def obstacle_features(self) -> Tuple[_Feature]:
263
+ def get_geometry_from_coord_index(self, coord_index: int) -> Tuple[str, int]:
379
264
  """
380
- Features which can prevent source and target features from being adjacent. They
381
- Do not participate in the adjacency_dict.
265
+ Map a coordinate index back to its source geometry.
382
266
 
383
- Returns:
384
- List[_Feature]: A list of _Features.
385
- """
386
- return self._obstacle_features
387
-
388
- @obstacle_features.setter
389
- def obstacle_features(self, _):
390
- raise ImmutablePropertyError("Property obstacle_features is immutable.")
391
-
392
- def get_feature_from_coord_index(self, coord_index: int) -> _Feature:
393
- """
394
- A list which is the length of self._all_coordinates. For each coordinate, we add the
395
- index of the corresponding feature from the list self.all_features. This is used to
396
- determine which coordinate belongs to which feature after we calculate the voronoi
397
- diagram.
267
+ Given a coordinate index from the flattened coordinate list used for Voronoi
268
+ analysis, determine which geometry the coordinate belongs to.
398
269
 
399
270
  Args:
400
- coord_index (int): The index of the coordinate in self._all_coordinates
271
+ coord_index (int): The index of the coordinate in the flattened coordinate list.
401
272
 
402
273
  Returns:
403
- _Feature: A _Feature at the given index.
274
+ Tuple[str, int]: A tuple of (feature_type, geometry_index) where:
275
+ - feature_type is 'source', 'target', or 'obstacle'
276
+ - geometry_index is the index within that feature type's GeoDataFrame
277
+
278
+ Raises:
279
+ KeyError: If the coordinate index is not found in the cache.
404
280
  """
405
- if not self._feature_indices:
406
- self._feature_indices = {}
407
- c = -1
408
- for f, feature in enumerate(self.all_features):
409
- for _ in range(len(feature.coords)):
410
- c += 1
411
- self._feature_indices[c] = f
281
+ if self._coord_to_feature_cache is None:
282
+ all_features_gdf = self.all_features_gdf
412
283
 
413
- return self.all_features[self._feature_indices[coord_index]]
284
+ if len(all_features_gdf) == 0:
285
+ self._coord_to_feature_cache = {}
286
+ else:
287
+ # Build coordinate counts by actually extracting coordinates (matches Voronoi exactly)
288
+ all_coords = all_features_gdf.geometry.get_coordinates()
289
+ coord_counts = all_coords.groupby(all_coords.index).size().tolist()
290
+
291
+ # Build the cache using fully vectorized operations
292
+ # Create arrays for all coordinate indices and their corresponding geometry info
293
+ coord_indices = np.arange(len(all_coords))
294
+ geom_indices = all_coords.index.values
295
+
296
+ # Extract feature info as arrays for vectorized lookup
297
+ feature_types = all_features_gdf["feature_type"].values[geom_indices]
298
+ original_indices = all_features_gdf["original_index"].values[geom_indices]
299
+
300
+ # Build cache with dictionary comprehension, ensuring Python int types
301
+ self._coord_to_feature_cache = {
302
+ int(coord_idx): (feature_type, int(orig_idx))
303
+ for coord_idx, feature_type, orig_idx in zip(
304
+ coord_indices, feature_types, original_indices
305
+ )
306
+ }
307
+
308
+ return self._coord_to_feature_cache[coord_index]
414
309
 
415
310
  @property
416
- def vor(self):
311
+ def vor(self) -> Voronoi:
417
312
  """
418
- The Voronoi diagram object returned by Scipy. Useful primarily for debugging an
419
- adjacency analysis.
313
+ The Voronoi diagram used for adjacency analysis.
314
+
315
+ Lazily computed Voronoi diagram from all geometry coordinates. This property
316
+ provides access to the underlying Scipy Voronoi object, which is useful
317
+ for debugging, visualization, or advanced analysis.
420
318
 
421
319
  Returns:
422
- scipy.spatial.Voronoi: The Scipy Voronoi object.
320
+ scipy.spatial.Voronoi: The Voronoi diagram object containing regions,
321
+ vertices, and other spatial relationships.
423
322
  """
424
323
  if not self._vor:
425
- self._vor = Voronoi(np.array(self.all_coordinates))
324
+ self._vor = Voronoi(self.all_features_gdf.geometry.get_coordinates().values)
426
325
  return self._vor
427
326
 
428
327
  @vor.setter
@@ -448,111 +347,235 @@ class AdjacencyEngine:
448
347
  if i != -1
449
348
  )
450
349
 
451
- def _tag_feature_with_voronoi_vertices(self):
350
+ def _tag_geometries_with_voronoi_vertices(self):
452
351
  """
453
- Tag each feature with the vertices of the voronoi region it belongs to. Runs the
454
- voronoi analysis if it has not been done already. This is broken out mostly for testing.
455
- Do not call this function directly.
352
+ Create mapping of geometries to their Voronoi vertices. Runs the voronoi analysis
353
+ if it has not been done already.
456
354
 
457
355
  Returns:
458
356
  None
459
357
  """
460
- # We don't need to tag obstacles with their voronoi vertices
461
- obstacle_coord_len = sum(len(feat.coords) for feat in self.obstacle_features)
358
+ # Initialize geometry-to-voronoi mapping
359
+ self._geometry_voronoi_vertices = {}
462
360
 
463
- # Tag each feature with the vertices of the voronoi region it
464
- # belongs to
465
- for feature_coord_index in range(
466
- len(self.all_coordinates) - obstacle_coord_len
467
- ):
468
- feature = self.get_feature_from_coord_index(feature_coord_index)
469
- for i in self._get_voronoi_vertex_idx_for_coord_idx(feature_coord_index):
470
- feature.voronoi_points.add(i)
361
+ # Iterate through ALL coordinates (since Voronoi is built from all coordinates)
362
+ # but only map non-obstacle geometries
363
+ total_coord_count = len(self.all_features_gdf.geometry.get_coordinates())
364
+ for feature_coord_index in range(total_coord_count):
365
+ dataframe_type, geometry_idx = self.get_geometry_from_coord_index(
366
+ feature_coord_index
367
+ )
368
+
369
+ # Only process non-obstacle geometries for adjacency mapping
370
+ if dataframe_type != "obstacle":
371
+ # Create key for this geometry
372
+ geom_key = (dataframe_type, geometry_idx)
373
+ if geom_key not in self._geometry_voronoi_vertices:
374
+ self._geometry_voronoi_vertices[geom_key] = set()
375
+
376
+ # Add Voronoi vertices for this coordinate
377
+ for i in self._get_voronoi_vertex_idx_for_coord_idx(
378
+ feature_coord_index
379
+ ):
380
+ self._geometry_voronoi_vertices[geom_key].add(i)
471
381
 
472
382
  def _determine_adjacency(
473
- self, source_set: Tuple[_Feature], target_set: Tuple[_Feature]
383
+ self,
384
+ source_gdf: gpd.GeoDataFrame,
385
+ target_gdf: gpd.GeoDataFrame,
386
+ source_type: str = "source",
387
+ target_type: str = "target",
474
388
  ):
475
389
  """
476
- Determines the adjacency relationship between two sets of features.
390
+ Determines the adjacency relationship between two GeoDataFrames using vectorized operations.
391
+ Stores the result in self._adjacency_dict.
392
+
477
393
  Args:
478
- source_set (Tuple[_Feature]): The set of source features.
479
- target_set (Tuple[_Feature]): The set of target features.
394
+ source_gdf (gpd.GeoDataFrame): The source GeoDataFrame.
395
+ target_gdf (gpd.GeoDataFrame): The target GeoDataFrame.
396
+ source_type (str): Type identifier for source ('source', 'target', etc.)
397
+ target_type (str): Type identifier for target ('source', 'target', etc.')
480
398
 
481
399
  Returns:
482
400
  None
483
401
  """
484
- min_overlapping_voronoi_vertices = 2
485
- for source_index, source_feature in enumerate(source_set):
486
- if (
487
- self._bounding_rectangle is not None
488
- and not self._bounding_rectangle.intersects(source_feature.geometry)
489
- ):
490
- continue
491
- for target_index, target_feature in enumerate(target_set):
492
- if source_feature != target_feature:
493
- if (
494
- self._max_distance is not None
495
- and source_feature.geometry.distance(target_feature.geometry)
496
- > self._max_distance
497
- ) or (
498
- self._bounding_rectangle is not None
499
- and not self._bounding_rectangle.intersects(
500
- target_feature.geometry
501
- )
502
- ):
503
- continue
504
- if source_feature._is_adjacent(
505
- target_feature, min_overlapping_voronoi_vertices
506
- ):
507
- self._adjacency_dict[source_index].append(target_index)
402
+ # Early return if either GeoDataFrame is empty
403
+ if len(source_gdf) == 0 or len(target_gdf) == 0:
404
+ return
405
+
406
+ # Apply bounding rectangle filter to both source and target at once
407
+ if self._bounding_rectangle is not None:
408
+ source_mask = source_gdf.geometry.intersects(self._bounding_rectangle)
409
+ target_mask = target_gdf.geometry.intersects(self._bounding_rectangle)
410
+
411
+ valid_source_indices = source_gdf.index[source_mask].tolist()
412
+ valid_target_indices = target_gdf.index[target_mask].tolist()
413
+
414
+ # Early return if no valid geometries
415
+ if not valid_source_indices or not valid_target_indices:
416
+ return
417
+ else:
418
+ valid_source_indices = list(range(len(source_gdf)))
419
+ valid_target_indices = list(range(len(target_gdf)))
420
+
421
+ # Generate candidate pairs efficiently
422
+ if self._max_distance is not None:
423
+ # Spatial join for distance-constrained adjacency
424
+ src_buffered = source_gdf.iloc[valid_source_indices].copy()
425
+ src_buffered.geometry = src_buffered.geometry.buffer(self._max_distance)
426
+ src_buffered["src_idx"] = valid_source_indices
427
+
428
+ tgt_indexed = target_gdf.iloc[valid_target_indices].assign(tgt_idx=valid_target_indices)
429
+
430
+ pairs = gpd.sjoin(src_buffered, tgt_indexed, predicate="intersects")
431
+ if len(pairs) == 0:
432
+ return
433
+
434
+ source_indices, target_indices = pairs["src_idx"].values, pairs["tgt_idx"].values
435
+ else:
436
+ # All-pairs approach using numpy broadcasting
437
+ source_indices, target_indices = np.meshgrid(valid_source_indices, valid_target_indices, indexing="ij")
438
+ source_indices, target_indices = source_indices.ravel(), target_indices.ravel()
439
+
440
+ # Filter out same-geometry pairs for source-to-source adjacency
441
+ if source_gdf is target_gdf:
442
+ mask = source_indices != target_indices
443
+ source_indices = source_indices[mask]
444
+ target_indices = target_indices[mask]
445
+
446
+ # Voronoi adjacency check
447
+ for source_idx, target_idx in zip(source_indices, target_indices):
448
+ source_key = (source_type, int(source_idx))
449
+ target_key = (target_type, int(target_idx))
450
+
451
+ # Check if both geometries have Voronoi vertices
452
+ if (source_key in self._geometry_voronoi_vertices and
453
+ target_key in self._geometry_voronoi_vertices):
454
+
455
+ source_voronoi = self._geometry_voronoi_vertices[source_key]
456
+ target_voronoi = self._geometry_voronoi_vertices[target_key]
457
+
458
+ # Check if they share enough Voronoi vertices
459
+ shared_vertices = len(source_voronoi.intersection(target_voronoi))
460
+ if shared_vertices >= self._min_overlapping_voronoi_vertices:
461
+ self._adjacency_dict[int(source_idx)].append(int(target_idx))
508
462
 
509
463
  def get_adjacency_dict(self) -> Dict[int, List[int]]:
510
464
  """
511
- Returns a dictionary of indices. They keys are the indices of feature_geoms. The values
512
- are the indices of any target geometries which are adjacent to the feature_geoms.
465
+ Returns a dictionary of adjacency relationships by index.
466
+
467
+ The keys are the indices of source geometries. The values are lists of indices
468
+ of target geometries that are adjacent to each source geometry.
513
469
 
514
470
  If no targets were specified, then calculate adjacency between source features and other
515
471
  source features.
516
472
 
517
473
  Returns:
518
- dict: A dictionary of indices. The keys are the indices of feature_geoms. The
519
- values are the indices of any adjacent features.
520
-
474
+ Dict[int, List[int]]: A dictionary mapping source geometry indices to lists of
475
+ adjacent target geometry indices.
521
476
  """
522
477
 
523
478
  """Note: We want adjacent features to have at least two overlapping vertices, otherwise we
524
479
  might call the features adjacent when their Voronoi regions don't share any edges."""
525
480
 
526
481
  if self._adjacency_dict is None:
527
- self._tag_feature_with_voronoi_vertices()
482
+ self._tag_geometries_with_voronoi_vertices()
528
483
 
529
- # If any two features have any voronoi indices in common, then their voronoi regions
530
- # must intersect, therefore the input features are adjacent.
484
+ # If any two geometries have shared voronoi vertices, then their voronoi regions
485
+ # intersect, therefore the input geometries are adjacent.
531
486
  self._adjacency_dict = defaultdict(list)
532
487
 
533
488
  # Get adjacency between source and target features
534
- if len(self.target_features) > 0:
535
- self._determine_adjacency(self.source_features, self.target_features)
489
+ if self.target_gdf is not None and len(self.target_gdf) > 0:
490
+ self._determine_adjacency(
491
+ self.source_gdf, self.target_gdf, "source", "target"
492
+ )
536
493
  # If no target specified, get adjacency between source and other source features.
537
494
  else:
538
- self._determine_adjacency(self.source_features, self.source_features)
495
+ self._determine_adjacency(
496
+ self.source_gdf, self.source_gdf, "source", "source"
497
+ )
498
+
499
+ # Convert numpy integers to regular Python integers to match return type annotation
500
+ return {
501
+ int(k): [int(v) for v in values]
502
+ for k, values in self._adjacency_dict.items()
503
+ }
504
+
505
+ def get_adjacency_gdf(self) -> Union[gpd.GeoDataFrame, None]:
506
+ """
507
+ Returns adjacency relationships as a GeoDataFrame with source and target geometries and attributes.
539
508
 
540
- return self._adjacency_dict
509
+ Returns:
510
+ gpd.GeoDataFrame or None: DataFrame with adjacency relationships including geometries and
511
+ any attributes from the original source/target GeoDataFrames.
512
+ Returns None if no adjacencies found.
513
+ """
514
+ adjacency_dict = self.get_adjacency_dict()
515
+
516
+ if not adjacency_dict or all(
517
+ len(targets) == 0 for targets in adjacency_dict.values()
518
+ ):
519
+ return None
520
+
521
+ # Prepare data for GeoDataFrame
522
+ rows = []
523
+
524
+ # Determine target set (targets if available, otherwise sources for source-source adjacency)
525
+ target_gdf = (
526
+ self.target_gdf if self.target_gdf is not None else self.source_gdf
527
+ )
528
+
529
+ for source_idx, target_list in adjacency_dict.items():
530
+ for target_idx in target_list:
531
+ source_geom = self.source_gdf.iloc[source_idx].geometry
532
+ target_geom = target_gdf.iloc[target_idx].geometry
533
+
534
+ row_data = {
535
+ "source_idx": source_idx,
536
+ "target_idx": target_idx,
537
+ "source_geometry": source_geom,
538
+ "target_geometry": target_geom,
539
+ "geometry": source_geom, # Primary geometry column
540
+ }
541
+
542
+ # Add source attributes
543
+ if len(self.source_gdf.columns) > 1:
544
+ source_row = self.source_gdf.iloc[source_idx]
545
+ for col in source_row.index:
546
+ if col != "geometry":
547
+ row_data[f"source_{col}"] = source_row[col]
548
+
549
+ # Add target attributes
550
+ if len(target_gdf.columns) > 1:
551
+ target_row = target_gdf.iloc[target_idx]
552
+ for col in target_row.index:
553
+ if col != "geometry":
554
+ row_data[f"target_{col}"] = target_row[col]
555
+
556
+ rows.append(row_data)
557
+
558
+ if not rows:
559
+ return None
560
+
561
+ return gpd.GeoDataFrame(rows)
541
562
 
542
563
  def plot_adjacency_dict(self) -> None:
543
564
  """
544
- Plot the adjacency linkages between the source and target with pyplot. Runs the analysis if
545
- it has not already been run.
565
+ Plot the adjacency linkages between source and target geometries using matplotlib.
566
+
567
+ Runs the adjacency analysis if it has not already been run. Shows source geometries
568
+ in grey, target geometries in blue, obstacles in red, and adjacency links in green.
546
569
 
547
570
  Returns:
548
571
  None
549
572
  """
550
573
  # Plot the adjacency linkages between the source and target
551
- if len(self.target_features) > 0:
574
+ if self.target_gdf is not None and len(self.target_gdf) > 0:
552
575
  for source_i, target_is in self.get_adjacency_dict().items():
553
- source_poly = self.source_features[source_i].geometry
576
+ source_poly = self.source_gdf.iloc[source_i].geometry
554
577
  target_polys = [
555
- self.target_features[target_i].geometry for target_i in target_is
578
+ self.target_gdf.iloc[target_i].geometry for target_i in target_is
556
579
  ]
557
580
 
558
581
  # Plot the linestrings between the source and target polygons
@@ -562,7 +585,7 @@ class AdjacencyEngine:
562
585
  try:
563
586
  links.append(
564
587
  LineString(
565
- shapely.ops.nearest_points(target_poly, source_poly)
588
+ shapely_ops.nearest_points(target_poly, source_poly)
566
589
  )
567
590
  )
568
591
  except ValueError:
@@ -570,12 +593,12 @@ class AdjacencyEngine:
570
593
  f"Error creating link between '{target_poly}' and '{source_poly}'"
571
594
  )
572
595
  add_geometry_to_plot(links, "green")
573
-
596
+ # If no target specified, get adjacency between source and other source features.
574
597
  else:
575
598
  for source_i, source_2_is in self.get_adjacency_dict().items():
576
- source_poly = self.source_features[source_i].geometry
599
+ source_poly = self.source_gdf.iloc[source_i].geometry
577
600
  target_polys = [
578
- self.source_features[source_2_i].geometry
601
+ self.source_gdf.iloc[source_2_i].geometry
579
602
  for source_2_i in source_2_is
580
603
  if source_2_i > source_i
581
604
  ]
@@ -588,9 +611,18 @@ class AdjacencyEngine:
588
611
  ]
589
612
  add_geometry_to_plot(links, "green")
590
613
 
591
- add_geometry_to_plot([t.geometry for t in self.target_features], "blue")
592
- add_geometry_to_plot([t.geometry for t in self.source_features], "grey")
593
- add_geometry_to_plot([t.geometry for t in self.obstacle_features], "red")
614
+ # Plot all geometries
615
+ target_geoms = (
616
+ list(self.target_gdf.geometry) if self.target_gdf is not None else []
617
+ )
618
+ source_geoms = list(self.source_gdf.geometry)
619
+ obstacle_geoms = (
620
+ list(self.obstacle_gdf.geometry) if self.obstacle_gdf is not None else []
621
+ )
622
+
623
+ add_geometry_to_plot(target_geoms, "blue")
624
+ add_geometry_to_plot(source_geoms, "grey")
625
+ add_geometry_to_plot(obstacle_geoms, "red")
594
626
 
595
627
  plt.title("Adjacency linkages between source and target")
596
628
  plt.xlabel("Longitude")
@@ -0,0 +1,55 @@
1
+ """
2
+ Logging configuration for the geo-adjacency package.
3
+
4
+ This module provides centralized logging setup and configuration for all modules
5
+ in the geo_adjacency package.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+
12
+ def setup_logger(
13
+ name: str,
14
+ level: int = logging.WARNING,
15
+ console_format: Optional[str] = None,
16
+ file_format: Optional[str] = None,
17
+ ) -> logging.Logger:
18
+ """
19
+ Set up a logger with consistent formatting and handlers.
20
+
21
+ Args:
22
+ name (str): Name of the logger (typically __name__ from calling module)
23
+ level (int): Logging level (default: logging.WARNING)
24
+ console_format (Optional[str]): Custom format string for console output
25
+ file_format (Optional[str]): Custom format string for file output
26
+
27
+ Returns:
28
+ logging.Logger: Configured logger instance
29
+ """
30
+ logger = logging.getLogger(name)
31
+
32
+ # Prevent adding duplicate handlers
33
+ if logger.handlers:
34
+ return logger
35
+
36
+ # Set default formats if not provided
37
+ if console_format is None:
38
+ console_format = "%(name)s - %(levelname)s - %(message)s"
39
+
40
+ if file_format is None:
41
+ file_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
42
+
43
+ # Create console handler
44
+ console_handler = logging.StreamHandler()
45
+ console_handler.setLevel(level)
46
+
47
+ # Create formatters
48
+ console_formatter = logging.Formatter(console_format)
49
+ console_handler.setFormatter(console_formatter)
50
+
51
+ # Add handlers to logger
52
+ logger.addHandler(console_handler)
53
+ logger.setLevel(level)
54
+
55
+ return logger
geo_adjacency/utils.py CHANGED
@@ -1,120 +1,39 @@
1
1
  """
2
- Utility functions. These are designed for use in the AdjacencyEngine only, and should not be called
3
- by end users.
4
- """
2
+ Utility functions for geometric analysis and plotting.
5
3
 
6
- from typing import List, Tuple
4
+ This module provides utility functions for coordinate counting, geometry plotting,
5
+ and other spatial operations used internally by the AdjacencyEngine. These functions
6
+ are designed for internal use and should not be called directly by end users.
7
+ """
7
8
 
8
9
  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:])
10
+ from shapely import (
11
+ LinearRing,
12
+ LineString,
13
+ MultiLineString,
14
+ MultiPolygon,
15
+ Point,
16
+ Polygon,
17
+ )
18
+ from shapely.geometry.base import BaseGeometry
105
19
 
106
20
 
107
21
  def add_geometry_to_plot(geoms, color="black"):
108
22
  """
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.
23
+ Add Shapely geometries to the current matplotlib plot.
24
+
25
+ Useful for visualizing geometric data and test results. Each geometry type
26
+ is rendered appropriately (points as markers, lines as paths, polygons as outlines).
111
27
 
112
28
  Args:
113
- geoms (List[BaseGeometry]): A list of Shapely geometries.
114
- color (str): The color we want the geometry to be in the plot.
29
+ geoms (List[BaseGeometry]): A list of Shapely geometries to plot.
30
+ color (str, optional): The color for rendering the geometries. Defaults to "black".
115
31
 
116
32
  Returns:
117
33
  None
34
+
35
+ Raises:
36
+ TypeError: If an unsupported geometry type is encountered.
118
37
  """
119
38
  for geom in geoms:
120
39
  if isinstance(geom, Point):
@@ -135,3 +54,42 @@ def add_geometry_to_plot(geoms, color="black"):
135
54
  plt.plot(*sub_poly.exterior.xy, color=color, linewidth=3)
136
55
  else:
137
56
  raise TypeError("Unknown geometry type")
57
+
58
+
59
+ def count_unique_coords(geom: BaseGeometry) -> int:
60
+ """
61
+ Count the number of coordinate points in a Shapely geometry.
62
+
63
+ This function counts all coordinate points that define a geometry's shape,
64
+ with special handling for different geometry types:
65
+ - Points: Always returns 1
66
+ - LineStrings: Returns number of coordinate points
67
+ - Polygons: Returns exterior coords + interior ring coords, minus duplicates
68
+ - Multi-geometries: Sums coordinates from all component geometries
69
+
70
+ Args:
71
+ geom (BaseGeometry): The Shapely geometry to analyze.
72
+
73
+ Returns:
74
+ int: The total number of coordinate points in the geometry.
75
+
76
+ Raises:
77
+ ValueError: If the geometry type is not supported.
78
+ """
79
+ if isinstance(geom, Point):
80
+ return 1
81
+ elif isinstance(geom, LineString):
82
+ return len(geom.coords)
83
+ elif isinstance(geom, MultiLineString):
84
+ return sum(len(line.coords) for line in geom.geoms)
85
+ elif isinstance(geom, Polygon):
86
+ return (
87
+ len(geom.exterior.coords)
88
+ + sum(len(ring.coords) for ring in geom.interiors)
89
+ - len(geom.interiors)
90
+ - 1
91
+ )
92
+ elif isinstance(geom, MultiPolygon):
93
+ return sum(count_unique_coords(poly) for poly in geom.geoms)
94
+ else:
95
+ raise ValueError(f"Unknown geometry type: {type(geom)}")
@@ -1,29 +1,32 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: geo-adjacency
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: A package to determine which geometries are adjacent to each other, accounting for obstacles and gaps between features.
5
5
  License: MIT
6
6
  Keywords: voronoi,adjacency,geospatial,geometry
7
7
  Author: Andrew Smyth
8
8
  Author-email: andrew.j.smyth.89@gmail.com
9
- Requires-Python: >=3.9,<3.13
9
+ Requires-Python: >=3.10,<3.13
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Science/Research
12
12
  Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.9
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.9
20
20
  Classifier: Topic :: Scientific/Engineering
21
21
  Classifier: Topic :: Scientific/Engineering :: GIS
22
- Requires-Dist: matplotlib (>=3.8.1,<4.0.0)
23
- Requires-Dist: numpy (>=1.26.2,<2.0.0)
24
- Requires-Dist: scipy (>=1.11.3,<2.0.0)
25
- Requires-Dist: setuptools (>=69.0.0,<70.0.0)
26
- Requires-Dist: shapely (>=2.0.2,<3.0.0)
22
+ Requires-Dist: geopandas (>=1.1.1,<2.0.0)
23
+ Requires-Dist: isort (>=6.0.1,<7.0.0)
24
+ Requires-Dist: matplotlib (>=3.8.1)
25
+ Requires-Dist: numpy (>=1.26.2)
26
+ Requires-Dist: pandas (>=2.3.1,<3.0.0)
27
+ Requires-Dist: scipy (>=1.11.3)
28
+ Requires-Dist: setuptools (>=69.0.0)
29
+ Requires-Dist: shapely (>=2.0.2)
27
30
  Project-URL: Documentation, https://asmyth01.github.io/geo-adjacency/
28
31
  Project-URL: Homepage, https://asmyth01.github.io/geo-adjacency/
29
32
  Project-URL: Repository, https://github.com/andrewsmyth/geo-adjacency
@@ -0,0 +1,9 @@
1
+ geo_adjacency/__init__.py,sha256=kKOXvPFJruPkAMBzFwN1Me1G-zCFRwl4kj-YOvpLv3Y,723
2
+ geo_adjacency/adjacency.py,sha256=i_Oj-PFUTQWuEPrK1mW4EjsBtk_9AUkovmqpSf9HZ70,27860
3
+ geo_adjacency/exception.py,sha256=zZNdBOm5LpuiCpNuqH1FNLhiPnQqyCyuhOTMBDnLSTQ,230
4
+ geo_adjacency/logging_config.py,sha256=ufvzXVRKVJi1QCO-KLF0BX2U4y1UG0OxnxCrEqzp0Bo,1569
5
+ geo_adjacency/utils.py,sha256=zKSvLXKNY0Y7eLCxeUaOy10L72aoIj-U8nlfpBsCwMs,3186
6
+ geo_adjacency-1.3.0.dist-info/LICENSE,sha256=p0PMGdB2iuOndKPbBCVhTNe9TMIxZRpJ64bQ_CoUIqY,1065
7
+ geo_adjacency-1.3.0.dist-info/METADATA,sha256=Rfg3S6Dr0enBzkxfdpVyy53IxPbcDHwaFsXGxsncOhI,4148
8
+ geo_adjacency-1.3.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
9
+ geo_adjacency-1.3.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- geo_adjacency/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- geo_adjacency/adjacency.py,sha256=pIxXEQjcjFKH95Xk27O8qYrD3ZY88JWOA7qOId82JW4,22941
3
- geo_adjacency/exception.py,sha256=zZNdBOm5LpuiCpNuqH1FNLhiPnQqyCyuhOTMBDnLSTQ,230
4
- geo_adjacency/utils.py,sha256=57Q-nRZQlW1QetlLoucbDr1jm3CRHYRCVzrarm7xxZw,4188
5
- geo_adjacency-1.2.1.dist-info/LICENSE,sha256=p0PMGdB2iuOndKPbBCVhTNe9TMIxZRpJ64bQ_CoUIqY,1065
6
- geo_adjacency-1.2.1.dist-info/METADATA,sha256=UE247P9ViLLifF9ueIfzV7kcld52fPsWtKDGIyvlxbE,4064
7
- geo_adjacency-1.2.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
8
- geo_adjacency-1.2.1.dist-info/RECORD,,