geo-adjacency 1.2.1__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.
- {geo_adjacency-1.2.1 → geo_adjacency-1.3.0}/PKG-INFO +11 -8
- geo_adjacency-1.3.0/geo_adjacency/__init__.py +19 -0
- geo_adjacency-1.3.0/geo_adjacency/adjacency.py +630 -0
- geo_adjacency-1.3.0/geo_adjacency/logging_config.py +55 -0
- geo_adjacency-1.3.0/geo_adjacency/utils.py +95 -0
- {geo_adjacency-1.2.1 → geo_adjacency-1.3.0}/pyproject.toml +22 -7
- geo_adjacency-1.2.1/geo_adjacency/__init__.py +0 -0
- geo_adjacency-1.2.1/geo_adjacency/adjacency.py +0 -598
- geo_adjacency-1.2.1/geo_adjacency/utils.py +0 -137
- {geo_adjacency-1.2.1 → geo_adjacency-1.3.0}/LICENSE +0 -0
- {geo_adjacency-1.2.1 → geo_adjacency-1.3.0}/README.md +0 -0
- {geo_adjacency-1.2.1 → geo_adjacency-1.3.0}/geo_adjacency/exception.py +0 -0
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: geo-adjacency
|
|
3
|
-
Version: 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
|
+
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:
|
|
23
|
-
Requires-Dist:
|
|
24
|
-
Requires-Dist:
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist:
|
|
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,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()
|