geo-adjacency 1.2.2__tar.gz → 1.3.0__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,26 +1,29 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: geo-adjacency
3
- Version: 1.2.2
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: geopandas (>=1.1.1,<2.0.0)
23
+ Requires-Dist: isort (>=6.0.1,<7.0.0)
22
24
  Requires-Dist: matplotlib (>=3.8.1)
23
25
  Requires-Dist: numpy (>=1.26.2)
26
+ Requires-Dist: pandas (>=2.3.1,<3.0.0)
24
27
  Requires-Dist: scipy (>=1.11.3)
25
28
  Requires-Dist: setuptools (>=69.0.0)
26
29
  Requires-Dist: shapely (>=2.0.2)
@@ -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
+ """
@@ -0,0 +1,630 @@
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. The methodology is described in detail in the project documentation.
13
+ """
14
+
15
+ import logging
16
+ from typing import Dict, Generator, List, Tuple, Union
17
+ from collections import defaultdict
18
+
19
+ import geopandas as gpd
20
+ import numpy as np
21
+ import pandas as pd
22
+ from matplotlib import pyplot as plt
23
+ from scipy.spatial import Voronoi
24
+ from shapely import LineString, MultiPoint, Point, Polygon, box
25
+ from shapely import ops as shapely_ops
26
+ from shapely.geometry.base import BaseGeometry
27
+
28
+ from geo_adjacency.exception import ImmutablePropertyError
29
+ from geo_adjacency.logging_config import setup_logger
30
+ from geo_adjacency.utils import count_unique_coords, add_geometry_to_plot
31
+
32
+ # Create a custom logger using the centralized logging configuration
33
+ log: logging.Logger = setup_logger(__name__)
34
+
35
+
36
+ class AdjacencyEngine:
37
+ """
38
+ A class for calculating the adjacency of a set of geometries to another geometry or set
39
+ of geometries, given a set of obstacles. Optionally supports distance constraints and
40
+ bounding box filtering.
41
+
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.
45
+ """
46
+
47
+ __slots__ = (
48
+ "_source_gdf",
49
+ "_target_gdf",
50
+ "_obstacle_gdf",
51
+ "_adjacency_dict",
52
+ "_vor",
53
+ "_all_features_gdf",
54
+ "_max_distance",
55
+ "_bounding_rectangle",
56
+ "_min_overlapping_voronoi_vertices",
57
+ "_coord_to_feature_cache",
58
+ "_geometry_voronoi_vertices",
59
+ )
60
+
61
+ def __init__(
62
+ self,
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,
66
+ **kwargs,
67
+ ):
68
+ """
69
+ Note: only Multipolygons, Polygons, LineStrings and Points are supported. It is assumed all
70
+ features are in the same projection.
71
+
72
+ Args:
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.
85
+
86
+ Keyword Args:
87
+ densify_features (bool, optional): If True, we will add additional points to the
88
+ features to improve accuracy of the voronoi diagram. If densify_features is True and
89
+ max_segment_length is false, then the max_segment_length will be calculated based on
90
+ the average segment length of all features, divided by 5.
91
+ max_segment_length (Union[float, None], optional): The maximum distance between vertices
92
+ that we want in projection units. densify_features must be True, or an error will be thrown.
93
+ max_distance (Union[float, None], optional): The maximum distance between two features
94
+ for them to be candidates for adjacency. Units are same as geometry coordinate system.
95
+ bounding_box (Union[float, float, float, float, None], optional): Set a bounding box
96
+ for the analysis. Only include features that intersect the box in the output.
97
+ This is useful for removing data from the edges from the final analysis, as these
98
+ are often not accurate. This is particularly helpful when analyzing a large data set
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.
102
+
103
+ """
104
+
105
+ densify_features = kwargs.get("densify_features", False)
106
+ max_segment_length = kwargs.get("max_segment_length", None)
107
+ self._max_distance = kwargs.get("max_distance", None)
108
+ self._min_overlapping_voronoi_vertices = kwargs.get(
109
+ "min_overlapping_voronoi_vertices", 2
110
+ )
111
+ if kwargs.get("bounding_box", None):
112
+ minx, miny, maxx, maxy = kwargs.get("bounding_box")
113
+ assert (
114
+ minx < maxx and miny < maxy
115
+ ), "Bounding box must have minx < maxx and miny < maxy"
116
+ self._bounding_rectangle: Polygon = box(minx, miny, maxx, maxy)
117
+ else:
118
+ self._bounding_rectangle = None
119
+
120
+ if max_segment_length and not densify_features:
121
+ raise ValueError(
122
+ "densify_features must be True if max_segment_length is not None"
123
+ )
124
+
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
129
+ )
130
+ self._obstacle_gdf = (
131
+ self._to_geodataframe(obstacle_geoms)
132
+ if obstacle_geoms is not None
133
+ else None
134
+ )
135
+
136
+ self._adjacency_dict: Union[Dict[int, List[int]], None] = None
137
+ self._vor = None
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
141
+
142
+ if densify_features:
143
+ if max_segment_length is None:
144
+ max_segment_length = self._calc_segmentation_dist()
145
+ log.info("Calculated max_segment_length of %s" % max_segment_length)
146
+
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
160
+
161
+ def _to_geodataframe(
162
+ self, geoms_input: Union[List[BaseGeometry], gpd.GeoDataFrame]
163
+ ) -> gpd.GeoDataFrame:
164
+ """
165
+ Convert input geometries to a GeoDataFrame for vectorized operations.
166
+
167
+ Args:
168
+ geoms_input: Either a list of geometries or an existing GeoDataFrame
169
+
170
+ Returns:
171
+ gpd.GeoDataFrame: A GeoDataFrame with geometries and optional attributes
172
+ """
173
+ return (
174
+ geoms_input
175
+ if isinstance(geoms_input, gpd.GeoDataFrame)
176
+ else gpd.GeoDataFrame(geometry=geoms_input)
177
+ )
178
+
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
188
+
189
+ @property
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:
196
+ """
197
+ All source, target, and obstacle features concatenated into a single GeoDataFrame.
198
+ The order is preserved: source, then target, then obstacles.
199
+
200
+ Returns:
201
+ gpd.GeoDataFrame: Combined GeoDataFrame with all geometries and a 'feature_type' column.
202
+ """
203
+
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)
219
+
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)
225
+
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.")
236
+
237
+ def _calc_segmentation_dist(self, divisor=5):
238
+ """
239
+ Try to create a well-fitting maximum length for all line segments in all features. Take
240
+ the average distance between all coordinate pairs and divide by 5. This means that the
241
+ average segment will be divided into five segments.
242
+
243
+ This won't work as well if the different geometry sets have significantly different
244
+ average segment lengths. In that case, it is advisable to prepare the data appropriately
245
+ beforehand.
246
+
247
+ Args:
248
+ divisor (int, optional): Divide the average segment length by this number to get the new desired
249
+ segment length.
250
+
251
+ Returns:
252
+ float: Average segment length divided by divisor.
253
+ """
254
+
255
+ return float(
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()
260
+ / divisor
261
+ )
262
+
263
+ def get_geometry_from_coord_index(self, coord_index: int) -> Tuple[str, int]:
264
+ """
265
+ Map a coordinate index back to its source geometry.
266
+
267
+ Given a coordinate index from the flattened coordinate list used for Voronoi
268
+ analysis, determine which geometry the coordinate belongs to.
269
+
270
+ Args:
271
+ coord_index (int): The index of the coordinate in the flattened coordinate list.
272
+
273
+ Returns:
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.
280
+ """
281
+ if self._coord_to_feature_cache is None:
282
+ all_features_gdf = self.all_features_gdf
283
+
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]
309
+
310
+ @property
311
+ def vor(self) -> Voronoi:
312
+ """
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.
318
+
319
+ Returns:
320
+ scipy.spatial.Voronoi: The Voronoi diagram object containing regions,
321
+ vertices, and other spatial relationships.
322
+ """
323
+ if not self._vor:
324
+ self._vor = Voronoi(self.all_features_gdf.geometry.get_coordinates().values)
325
+ return self._vor
326
+
327
+ @vor.setter
328
+ def vor(self, _):
329
+ raise ImmutablePropertyError("Property vor is immutable.")
330
+
331
+ def _get_voronoi_vertex_idx_for_coord_idx(
332
+ self, feature_coord_index: int
333
+ ) -> Generator[int, None, None]:
334
+ """
335
+ For a given feature coordinate index, return the indices of the voronoi vertices. Ignore
336
+ any "-1"s, which indicate vertices at infinity; these provide no adjacency information.
337
+
338
+ Args:
339
+ feature_coord_index (int): The index of the coordinate in self.all_coordinates
340
+
341
+ Returns:
342
+ Generator[int, None, None]: A generator of the indices of the voronoi vertices.
343
+ """
344
+ return (
345
+ i
346
+ for i in self.vor.regions[self.vor.point_region[feature_coord_index]]
347
+ if i != -1
348
+ )
349
+
350
+ def _tag_geometries_with_voronoi_vertices(self):
351
+ """
352
+ Create mapping of geometries to their Voronoi vertices. Runs the voronoi analysis
353
+ if it has not been done already.
354
+
355
+ Returns:
356
+ None
357
+ """
358
+ # Initialize geometry-to-voronoi mapping
359
+ self._geometry_voronoi_vertices = {}
360
+
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)
381
+
382
+ def _determine_adjacency(
383
+ self,
384
+ source_gdf: gpd.GeoDataFrame,
385
+ target_gdf: gpd.GeoDataFrame,
386
+ source_type: str = "source",
387
+ target_type: str = "target",
388
+ ):
389
+ """
390
+ Determines the adjacency relationship between two GeoDataFrames using vectorized operations.
391
+ Stores the result in self._adjacency_dict.
392
+
393
+ Args:
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.')
398
+
399
+ Returns:
400
+ None
401
+ """
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))
462
+
463
+ def get_adjacency_dict(self) -> Dict[int, List[int]]:
464
+ """
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.
469
+
470
+ If no targets were specified, then calculate adjacency between source features and other
471
+ source features.
472
+
473
+ Returns:
474
+ Dict[int, List[int]]: A dictionary mapping source geometry indices to lists of
475
+ adjacent target geometry indices.
476
+ """
477
+
478
+ """Note: We want adjacent features to have at least two overlapping vertices, otherwise we
479
+ might call the features adjacent when their Voronoi regions don't share any edges."""
480
+
481
+ if self._adjacency_dict is None:
482
+ self._tag_geometries_with_voronoi_vertices()
483
+
484
+ # If any two geometries have shared voronoi vertices, then their voronoi regions
485
+ # intersect, therefore the input geometries are adjacent.
486
+ self._adjacency_dict = defaultdict(list)
487
+
488
+ # Get adjacency between source and 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
+ )
493
+ # If no target specified, get adjacency between source and other source features.
494
+ else:
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.
508
+
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)
562
+
563
+ def plot_adjacency_dict(self) -> None:
564
+ """
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.
569
+
570
+ Returns:
571
+ None
572
+ """
573
+ # Plot the adjacency linkages between the source and target
574
+ if self.target_gdf is not None and len(self.target_gdf) > 0:
575
+ for source_i, target_is in self.get_adjacency_dict().items():
576
+ source_poly = self.source_gdf.iloc[source_i].geometry
577
+ target_polys = [
578
+ self.target_gdf.iloc[target_i].geometry for target_i in target_is
579
+ ]
580
+
581
+ # Plot the linestrings between the source and target polygons
582
+ links = []
583
+ for target_poly in target_polys:
584
+ if target_poly:
585
+ try:
586
+ links.append(
587
+ LineString(
588
+ shapely_ops.nearest_points(target_poly, source_poly)
589
+ )
590
+ )
591
+ except ValueError:
592
+ log.error(
593
+ f"Error creating link between '{target_poly}' and '{source_poly}'"
594
+ )
595
+ add_geometry_to_plot(links, "green")
596
+ # If no target specified, get adjacency between source and other source features.
597
+ else:
598
+ for source_i, source_2_is in self.get_adjacency_dict().items():
599
+ source_poly = self.source_gdf.iloc[source_i].geometry
600
+ target_polys = [
601
+ self.source_gdf.iloc[source_2_i].geometry
602
+ for source_2_i in source_2_is
603
+ if source_2_i > source_i
604
+ ]
605
+
606
+ # Plot the linestrings between the source and target polygons
607
+ links = [
608
+ LineString([target_poly.centroid, source_poly.centroid])
609
+ for target_poly in target_polys
610
+ if target_poly is not None
611
+ ]
612
+ add_geometry_to_plot(links, "green")
613
+
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")
626
+
627
+ plt.title("Adjacency linkages between source and target")
628
+ plt.xlabel("Longitude")
629
+ plt.ylabel("Latitude")
630
+ plt.show()