geo-adjacency 1.2.2__py3-none-any.whl → 1.3.1__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 +19 -0
- geo_adjacency/adjacency.py +383 -351
- geo_adjacency/logging_config.py +55 -0
- geo_adjacency/utils.py +62 -104
- {geo_adjacency-1.2.2.dist-info → geo_adjacency-1.3.1.dist-info}/METADATA +9 -4
- geo_adjacency-1.3.1.dist-info/RECORD +9 -0
- {geo_adjacency-1.2.2.dist-info → geo_adjacency-1.3.1.dist-info}/WHEEL +1 -1
- geo_adjacency-1.2.2.dist-info/RECORD +0 -8
- {geo_adjacency-1.2.2.dist-info → geo_adjacency-1.3.1.dist-info/licenses}/LICENSE +0 -0
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
|
+
"""
|
geo_adjacency/adjacency.py
CHANGED
|
@@ -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.
|
|
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
|
|
19
|
+
import geopandas as gpd
|
|
26
20
|
import numpy as np
|
|
27
|
-
import
|
|
28
|
-
from
|
|
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,
|
|
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.
|
|
35
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
178
|
-
|
|
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
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
48
|
+
"_source_gdf",
|
|
49
|
+
"_target_gdf",
|
|
50
|
+
"_obstacle_gdf",
|
|
186
51
|
"_adjacency_dict",
|
|
187
|
-
"_feature_indices",
|
|
188
52
|
"_vor",
|
|
189
|
-
"
|
|
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
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
-
"
|
|
122
|
+
"densify_features must be True if max_segment_length is not None"
|
|
248
123
|
)
|
|
249
124
|
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
254
|
-
|
|
255
|
-
if
|
|
256
|
-
else
|
|
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.
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
161
|
+
def _to_geodataframe(
|
|
162
|
+
self, geoms_input: Union[List[BaseGeometry], gpd.GeoDataFrame]
|
|
163
|
+
) -> gpd.GeoDataFrame:
|
|
287
164
|
"""
|
|
288
|
-
|
|
289
|
-
not be changed. This property cannot be set manually.
|
|
165
|
+
Convert input geometries to a GeoDataFrame for vectorized operations.
|
|
290
166
|
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
173
|
+
return (
|
|
174
|
+
geoms_input
|
|
175
|
+
if isinstance(geoms_input, gpd.GeoDataFrame)
|
|
176
|
+
else gpd.GeoDataFrame(geometry=geoms_input)
|
|
177
|
+
)
|
|
296
178
|
|
|
297
|
-
@
|
|
298
|
-
def
|
|
299
|
-
|
|
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
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
201
|
+
gpd.GeoDataFrame: Combined GeoDataFrame with all geometries and a 'feature_type' column.
|
|
309
202
|
"""
|
|
310
203
|
|
|
311
|
-
if
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
Do not participate in the adjacency_dict.
|
|
265
|
+
Map a coordinate index back to its source geometry.
|
|
382
266
|
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
271
|
+
coord_index (int): The index of the coordinate in the flattened coordinate list.
|
|
401
272
|
|
|
402
273
|
Returns:
|
|
403
|
-
|
|
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
|
|
406
|
-
|
|
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
|
-
|
|
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
|
|
419
|
-
|
|
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
|
|
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(
|
|
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
|
|
350
|
+
def _tag_geometries_with_voronoi_vertices(self):
|
|
452
351
|
"""
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
#
|
|
461
|
-
|
|
358
|
+
# Initialize geometry-to-voronoi mapping
|
|
359
|
+
self._geometry_voronoi_vertices = {}
|
|
462
360
|
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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,
|
|
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
|
|
390
|
+
Determines the adjacency relationship between two GeoDataFrames using vectorized operations.
|
|
391
|
+
Stores the result in self._adjacency_dict.
|
|
392
|
+
|
|
477
393
|
Args:
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
512
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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.
|
|
482
|
+
self._tag_geometries_with_voronoi_vertices()
|
|
528
483
|
|
|
529
|
-
# If any two
|
|
530
|
-
#
|
|
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.
|
|
535
|
-
self._determine_adjacency(
|
|
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(
|
|
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
|
-
|
|
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
|
|
545
|
-
|
|
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.
|
|
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.
|
|
576
|
+
source_poly = self.source_gdf.iloc[source_i].geometry
|
|
554
577
|
target_polys = [
|
|
555
|
-
self.
|
|
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
|
-
|
|
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.
|
|
599
|
+
source_poly = self.source_gdf.iloc[source_i].geometry
|
|
577
600
|
target_polys = [
|
|
578
|
-
self.
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
3
|
-
by end users.
|
|
4
|
-
"""
|
|
2
|
+
Utility functions for geometric analysis and plotting.
|
|
5
3
|
|
|
6
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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,26 +1,31 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: geo-adjacency
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.1
|
|
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
|
+
License-File: LICENSE
|
|
6
7
|
Keywords: voronoi,adjacency,geospatial,geometry
|
|
7
8
|
Author: Andrew Smyth
|
|
8
9
|
Author-email: andrew.j.smyth.89@gmail.com
|
|
9
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.10,<3.15
|
|
10
11
|
Classifier: Development Status :: 4 - Beta
|
|
11
12
|
Classifier: Intended Audience :: Science/Research
|
|
12
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
13
14
|
Classifier: Operating System :: OS Independent
|
|
14
15
|
Classifier: Programming Language :: Python
|
|
15
16
|
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
22
|
Classifier: Topic :: Scientific/Engineering
|
|
21
23
|
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
24
|
+
Requires-Dist: geopandas (>=1.1.1,<2.0.0)
|
|
25
|
+
Requires-Dist: isort (>=6.0.1,<7.0.0)
|
|
22
26
|
Requires-Dist: matplotlib (>=3.8.1)
|
|
23
27
|
Requires-Dist: numpy (>=1.26.2)
|
|
28
|
+
Requires-Dist: pandas (>=2.3.1,<3.0.0)
|
|
24
29
|
Requires-Dist: scipy (>=1.11.3)
|
|
25
30
|
Requires-Dist: setuptools (>=69.0.0)
|
|
26
31
|
Requires-Dist: shapely (>=2.0.2)
|
|
@@ -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.1.dist-info/METADATA,sha256=tm5lV72uYRUOoVYeuXhbNPz0SYnjPbOqgPKzB4BTTkg,4222
|
|
7
|
+
geo_adjacency-1.3.1.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
8
|
+
geo_adjacency-1.3.1.dist-info/licenses/LICENSE,sha256=p0PMGdB2iuOndKPbBCVhTNe9TMIxZRpJ64bQ_CoUIqY,1065
|
|
9
|
+
geo_adjacency-1.3.1.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.2.dist-info/LICENSE,sha256=p0PMGdB2iuOndKPbBCVhTNe9TMIxZRpJ64bQ_CoUIqY,1065
|
|
6
|
-
geo_adjacency-1.2.2.dist-info/METADATA,sha256=nSSZrgkf0848gPZDDJCjCCTL0JeWBOyg3nwGkoWT4IE,4028
|
|
7
|
-
geo_adjacency-1.2.2.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
8
|
-
geo_adjacency-1.2.2.dist-info/RECORD,,
|
|
File without changes
|