ssb-sgis 1.0.5__py3-none-any.whl → 1.0.6__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.
- sgis/__init__.py +5 -5
- sgis/debug_config.py +1 -0
- sgis/geopandas_tools/buffer_dissolve_explode.py +3 -40
- sgis/geopandas_tools/conversion.py +37 -9
- sgis/geopandas_tools/general.py +330 -106
- sgis/geopandas_tools/geometry_types.py +38 -33
- sgis/io/dapla_functions.py +33 -17
- sgis/maps/explore.py +16 -5
- sgis/maps/map.py +3 -0
- sgis/maps/maps.py +0 -1
- sgis/networkanalysis/closing_network_holes.py +100 -22
- sgis/networkanalysis/cutting_lines.py +4 -147
- sgis/networkanalysis/finding_isolated_networks.py +6 -0
- sgis/networkanalysis/nodes.py +4 -110
- sgis/parallel/parallel.py +267 -182
- sgis/raster/image_collection.py +790 -837
- sgis/raster/indices.py +0 -90
- sgis/raster/regex.py +146 -0
- sgis/raster/sentinel_config.py +9 -0
- {ssb_sgis-1.0.5.dist-info → ssb_sgis-1.0.6.dist-info}/METADATA +1 -1
- {ssb_sgis-1.0.5.dist-info → ssb_sgis-1.0.6.dist-info}/RECORD +23 -25
- sgis/raster/cube.py +0 -1274
- sgis/raster/cubebase.py +0 -25
- sgis/raster/raster.py +0 -1475
- {ssb_sgis-1.0.5.dist-info → ssb_sgis-1.0.6.dist-info}/LICENSE +0 -0
- {ssb_sgis-1.0.5.dist-info → ssb_sgis-1.0.6.dist-info}/WHEEL +0 -0
sgis/io/dapla_functions.py
CHANGED
|
@@ -11,6 +11,7 @@ import geopandas as gpd
|
|
|
11
11
|
import joblib
|
|
12
12
|
import pandas as pd
|
|
13
13
|
import pyarrow
|
|
14
|
+
import pyarrow.parquet as pq
|
|
14
15
|
import shapely
|
|
15
16
|
from geopandas import GeoDataFrame
|
|
16
17
|
from geopandas import GeoSeries
|
|
@@ -18,6 +19,7 @@ from geopandas.io.arrow import _geopandas_to_arrow
|
|
|
18
19
|
from pandas import DataFrame
|
|
19
20
|
from pyarrow import ArrowInvalid
|
|
20
21
|
|
|
22
|
+
from ..geopandas_tools.general import get_common_crs
|
|
21
23
|
from ..geopandas_tools.sfilter import sfilter
|
|
22
24
|
|
|
23
25
|
PANDAS_FALLBACK_INFO = " Set pandas_fallback=True to ignore this error."
|
|
@@ -63,6 +65,7 @@ def read_geopandas(
|
|
|
63
65
|
if not isinstance(gcs_path, (str | Path | os.PathLike)):
|
|
64
66
|
kwargs |= {"file_system": file_system, "pandas_fallback": pandas_fallback}
|
|
65
67
|
|
|
68
|
+
cols = {}
|
|
66
69
|
if mask is not None:
|
|
67
70
|
if not isinstance(gcs_path, GeoSeries):
|
|
68
71
|
bounds_series: GeoSeries = get_bounds_series(
|
|
@@ -95,14 +98,25 @@ def read_geopandas(
|
|
|
95
98
|
paths = list(gcs_path)
|
|
96
99
|
|
|
97
100
|
if threads is None:
|
|
98
|
-
threads = min(len(
|
|
101
|
+
threads = min(len(paths), int(multiprocessing.cpu_count())) or 1
|
|
99
102
|
|
|
100
103
|
# recursive read with threads
|
|
101
104
|
with joblib.Parallel(n_jobs=threads, backend="threading") as parallel:
|
|
102
105
|
dfs: list[GeoDataFrame] = parallel(
|
|
103
106
|
joblib.delayed(read_geopandas)(x, **kwargs) for x in paths
|
|
104
107
|
)
|
|
105
|
-
|
|
108
|
+
|
|
109
|
+
if dfs:
|
|
110
|
+
df = pd.concat(dfs, ignore_index=True)
|
|
111
|
+
try:
|
|
112
|
+
df = GeoDataFrame(df)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
if not pandas_fallback:
|
|
115
|
+
print(e)
|
|
116
|
+
raise e
|
|
117
|
+
else:
|
|
118
|
+
df = GeoDataFrame(cols | {"geometry": []})
|
|
119
|
+
|
|
106
120
|
if mask is not None:
|
|
107
121
|
return sfilter(df, mask)
|
|
108
122
|
return df
|
|
@@ -131,6 +145,8 @@ def read_geopandas(
|
|
|
131
145
|
raise e.__class__(
|
|
132
146
|
f"{e.__class__.__name__}: {e} for {df}." + more_txt
|
|
133
147
|
) from e
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise e.__class__(f"{e.__class__.__name__}: {e} for {gcs_path}.") from e
|
|
134
150
|
|
|
135
151
|
else:
|
|
136
152
|
with file_system.open(gcs_path, mode="rb") as file:
|
|
@@ -148,6 +164,10 @@ def read_geopandas(
|
|
|
148
164
|
raise e.__class__(
|
|
149
165
|
f"{e.__class__.__name__}: {e} for {df}. " + more_txt
|
|
150
166
|
) from e
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise e.__class__(
|
|
169
|
+
f"{e.__class__.__name__}: {e} for {df}." + more_txt
|
|
170
|
+
) from e
|
|
151
171
|
|
|
152
172
|
if mask is not None:
|
|
153
173
|
return sfilter(df, mask)
|
|
@@ -159,14 +179,14 @@ def _get_bounds_parquet(
|
|
|
159
179
|
) -> tuple[list[float], dict] | tuple[None, None]:
|
|
160
180
|
with file_system.open(path) as f:
|
|
161
181
|
try:
|
|
162
|
-
num_rows =
|
|
182
|
+
num_rows = pq.read_metadata(f).num_rows
|
|
163
183
|
except ArrowInvalid as e:
|
|
164
184
|
if not file_system.isfile(f):
|
|
165
185
|
return None, None
|
|
166
186
|
raise ArrowInvalid(e, path) from e
|
|
167
187
|
if not num_rows:
|
|
168
188
|
return None, None
|
|
169
|
-
meta =
|
|
189
|
+
meta = pq.read_schema(f).metadata
|
|
170
190
|
try:
|
|
171
191
|
meta = json.loads(meta[b"geo"])["columns"]["geometry"]
|
|
172
192
|
except KeyError as e:
|
|
@@ -182,7 +202,7 @@ def _get_bounds_parquet(
|
|
|
182
202
|
|
|
183
203
|
def _get_columns(path: str | Path, file_system: dp.gcs.GCSFileSystem) -> pd.Index:
|
|
184
204
|
with file_system.open(path) as f:
|
|
185
|
-
schema =
|
|
205
|
+
schema = pq.read_schema(f)
|
|
186
206
|
index_cols = _get_index_cols(schema)
|
|
187
207
|
return pd.Index(schema.names).difference(index_cols)
|
|
188
208
|
|
|
@@ -266,17 +286,13 @@ def get_bounds_series(
|
|
|
266
286
|
for path in paths
|
|
267
287
|
)
|
|
268
288
|
crss = {json.dumps(x[1]) for x in bounds}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
elif len(crss) == 1:
|
|
277
|
-
crs = next(iter(crss))
|
|
278
|
-
else:
|
|
279
|
-
raise ValueError(f"crs mismatch: {crss}")
|
|
289
|
+
crs = get_common_crs(
|
|
290
|
+
[
|
|
291
|
+
crs
|
|
292
|
+
for crs in crss
|
|
293
|
+
if not any(str(crs).lower() == txt for txt in ["none", "null"])
|
|
294
|
+
]
|
|
295
|
+
)
|
|
280
296
|
return GeoSeries(
|
|
281
297
|
[shapely.box(*bbox[0]) if bbox[0] is not None else None for bbox in bounds],
|
|
282
298
|
index=paths,
|
|
@@ -355,7 +371,7 @@ def write_geopandas(
|
|
|
355
371
|
schema_version=None,
|
|
356
372
|
write_covering_bbox=write_covering_bbox,
|
|
357
373
|
)
|
|
358
|
-
|
|
374
|
+
pq.write_table(table, buffer, compression="snappy", **kwargs)
|
|
359
375
|
return
|
|
360
376
|
|
|
361
377
|
layer = kwargs.pop("layer", None)
|
sgis/maps/explore.py
CHANGED
|
@@ -22,6 +22,7 @@ import matplotlib
|
|
|
22
22
|
import matplotlib.pyplot as plt
|
|
23
23
|
import numpy as np
|
|
24
24
|
import pandas as pd
|
|
25
|
+
import shapely
|
|
25
26
|
import xyzservices
|
|
26
27
|
from folium import plugins
|
|
27
28
|
from geopandas import GeoDataFrame
|
|
@@ -219,7 +220,7 @@ class Explore(Map):
|
|
|
219
220
|
"OpenStreetMap",
|
|
220
221
|
"dark",
|
|
221
222
|
"norge_i_bilder",
|
|
222
|
-
"grunnkart",
|
|
223
|
+
# "grunnkart",
|
|
223
224
|
)
|
|
224
225
|
|
|
225
226
|
def __init__(
|
|
@@ -464,9 +465,9 @@ class Explore(Map):
|
|
|
464
465
|
pass
|
|
465
466
|
|
|
466
467
|
try:
|
|
467
|
-
sample = to_gdf(to_shapely(sample)).
|
|
468
|
+
sample = to_gdf(to_shapely(sample)).pipe(make_all_singlepart)
|
|
468
469
|
except Exception:
|
|
469
|
-
sample = to_gdf(to_shapely(to_bbox(sample))).
|
|
470
|
+
sample = to_gdf(to_shapely(to_bbox(sample))).pipe(make_all_singlepart)
|
|
470
471
|
|
|
471
472
|
random_point = sample.sample_points(size=1)
|
|
472
473
|
|
|
@@ -666,7 +667,12 @@ class Explore(Map):
|
|
|
666
667
|
if not len(gdf):
|
|
667
668
|
continue
|
|
668
669
|
|
|
669
|
-
gdf = self._to_single_geom_type(gdf)
|
|
670
|
+
gdf = self._to_single_geom_type(make_all_singlepart(gdf))
|
|
671
|
+
|
|
672
|
+
if not len(gdf):
|
|
673
|
+
continue
|
|
674
|
+
|
|
675
|
+
gdf.geometry = shapely.force_2d(gdf.geometry.values)
|
|
670
676
|
gdf = self._prepare_gdf_for_map(gdf)
|
|
671
677
|
|
|
672
678
|
gjs = self._make_geojson(
|
|
@@ -737,7 +743,12 @@ class Explore(Map):
|
|
|
737
743
|
if not len(gdf):
|
|
738
744
|
continue
|
|
739
745
|
|
|
740
|
-
gdf = self._to_single_geom_type(gdf)
|
|
746
|
+
gdf = self._to_single_geom_type(make_all_singlepart(gdf))
|
|
747
|
+
|
|
748
|
+
if not len(gdf):
|
|
749
|
+
continue
|
|
750
|
+
|
|
751
|
+
gdf.geometry = shapely.force_2d(gdf.geometry.values)
|
|
741
752
|
gdf = self._prepare_gdf_for_map(gdf)
|
|
742
753
|
|
|
743
754
|
classified = self._classify_from_bins(gdf, bins=self.bins)
|
sgis/maps/map.py
CHANGED
|
@@ -16,6 +16,7 @@ from geopandas import GeoDataFrame
|
|
|
16
16
|
from geopandas import GeoSeries
|
|
17
17
|
from jenkspy import jenks_breaks
|
|
18
18
|
from mapclassify import classify
|
|
19
|
+
from pandas.errors import PerformanceWarning
|
|
19
20
|
from shapely import Geometry
|
|
20
21
|
|
|
21
22
|
from ..geopandas_tools.conversion import to_gdf
|
|
@@ -41,6 +42,8 @@ except ImportError:
|
|
|
41
42
|
warnings.filterwarnings(
|
|
42
43
|
action="ignore", category=matplotlib.MatplotlibDeprecationWarning
|
|
43
44
|
)
|
|
45
|
+
warnings.filterwarnings(action="ignore", category=PerformanceWarning)
|
|
46
|
+
|
|
44
47
|
pd.options.mode.chained_assignment = None
|
|
45
48
|
|
|
46
49
|
|
sgis/maps/maps.py
CHANGED
|
@@ -3,17 +3,78 @@
|
|
|
3
3
|
import geopandas as gpd
|
|
4
4
|
import numpy as np
|
|
5
5
|
import pandas as pd
|
|
6
|
+
import shapely
|
|
6
7
|
from geopandas import GeoDataFrame
|
|
7
8
|
from geopandas import GeoSeries
|
|
8
9
|
from pandas import DataFrame
|
|
9
|
-
from shapely import shortest_line
|
|
10
10
|
|
|
11
11
|
from ..geopandas_tools.conversion import coordinate_array
|
|
12
|
+
from ..geopandas_tools.general import get_line_segments
|
|
12
13
|
from ..geopandas_tools.neighbors import k_nearest_neighbors
|
|
14
|
+
from ..geopandas_tools.sfilter import sfilter
|
|
13
15
|
from .nodes import make_edge_wkt_cols
|
|
14
16
|
from .nodes import make_node_ids
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
def get_k_nearest_points_for_deadends(
|
|
20
|
+
lines: GeoDataFrame, k: int, max_distance: int
|
|
21
|
+
) -> GeoDataFrame:
|
|
22
|
+
|
|
23
|
+
assert lines.index.is_unique
|
|
24
|
+
lines = lines.assign(_range_idx_left=range(len(lines)))
|
|
25
|
+
points = (
|
|
26
|
+
lines.assign(
|
|
27
|
+
geometry=lambda x: x.extract_unique_points().values,
|
|
28
|
+
_range_idx_right=range(len(lines)),
|
|
29
|
+
)
|
|
30
|
+
.explode(index_parts=False)
|
|
31
|
+
.sort_index()
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
points_grouper = points.groupby("_range_idx_right")["geometry"]
|
|
35
|
+
nodes = pd.concat(
|
|
36
|
+
[
|
|
37
|
+
points_grouper.nth(0),
|
|
38
|
+
points_grouper.nth(-1),
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
nodes.index.name = "_range_idx_right"
|
|
42
|
+
nodes = nodes.reset_index()
|
|
43
|
+
|
|
44
|
+
def has_no_duplicates(nodes):
|
|
45
|
+
counts = nodes.geometry.value_counts()
|
|
46
|
+
return nodes.geometry.isin(counts[counts == 1].index)
|
|
47
|
+
|
|
48
|
+
deadends = nodes[has_no_duplicates].reset_index(drop=True)
|
|
49
|
+
|
|
50
|
+
deadends_buffered = deadends.assign(geometry=lambda x: x.buffer(max_distance))
|
|
51
|
+
|
|
52
|
+
segs_by_deadends = (
|
|
53
|
+
sfilter(lines, deadends_buffered)
|
|
54
|
+
.pipe(get_line_segments)
|
|
55
|
+
.sjoin(deadends_buffered)
|
|
56
|
+
.loc[lambda x: x["_range_idx_left"] != x["_range_idx_right"]]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
lines_between = shapely.shortest_line(
|
|
60
|
+
segs_by_deadends.geometry.values,
|
|
61
|
+
deadends.loc[segs_by_deadends["index_right"].values].geometry.values,
|
|
62
|
+
)
|
|
63
|
+
segs_by_deadends.geometry.loc[:] = shapely.get_point(lines_between, 0)
|
|
64
|
+
|
|
65
|
+
length_mapper = dict(enumerate(shapely.length(lines_between)))
|
|
66
|
+
sorted_lengths = dict(
|
|
67
|
+
reversed(sorted(length_mapper.items(), key=lambda item: item[1]))
|
|
68
|
+
)
|
|
69
|
+
nearest_first = segs_by_deadends.iloc[list(sorted_lengths)]
|
|
70
|
+
|
|
71
|
+
k_nearest_per_deadend = nearest_first.geometry.groupby(level=0).apply(
|
|
72
|
+
lambda x: x.head(k)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return GeoDataFrame({"geometry": k_nearest_per_deadend.values}, crs=lines.crs)
|
|
76
|
+
|
|
77
|
+
|
|
17
78
|
def close_network_holes(
|
|
18
79
|
gdf: GeoDataFrame,
|
|
19
80
|
max_distance: int | float,
|
|
@@ -94,13 +155,22 @@ def close_network_holes(
|
|
|
94
155
|
lines, nodes = make_node_ids(gdf)
|
|
95
156
|
|
|
96
157
|
# remove duplicates of lines going both directions
|
|
97
|
-
lines["
|
|
98
|
-
"_".join(sorted([
|
|
99
|
-
for
|
|
158
|
+
lines["_sorted"] = [
|
|
159
|
+
"_".join(sorted([source, target])) + str(round(length, 4))
|
|
160
|
+
for source, target, length in zip(
|
|
161
|
+
lines["source"], lines["target"], lines.length, strict=True
|
|
162
|
+
)
|
|
100
163
|
]
|
|
101
164
|
|
|
165
|
+
lines = lines.drop_duplicates("_sorted").drop(columns="_sorted")
|
|
166
|
+
|
|
167
|
+
# new_lines, angles = _close_holes_all_lines(
|
|
102
168
|
new_lines: GeoSeries = _close_holes_all_lines(
|
|
103
|
-
lines
|
|
169
|
+
lines,
|
|
170
|
+
nodes,
|
|
171
|
+
max_distance,
|
|
172
|
+
max_angle,
|
|
173
|
+
idx_start=1,
|
|
104
174
|
)
|
|
105
175
|
|
|
106
176
|
new_lines = gpd.GeoDataFrame(
|
|
@@ -128,15 +198,6 @@ def close_network_holes(
|
|
|
128
198
|
return pd.concat([lines, new_lines], ignore_index=True)
|
|
129
199
|
|
|
130
200
|
|
|
131
|
-
def get_angle(array_a: np.ndarray, array_b: np.ndarray) -> np.ndarray:
|
|
132
|
-
dx = array_b[:, 0] - array_a[:, 0]
|
|
133
|
-
dy = array_b[:, 1] - array_a[:, 1]
|
|
134
|
-
|
|
135
|
-
angles_rad = np.arctan2(dx, dy)
|
|
136
|
-
angles_degrees = np.degrees(angles_rad)
|
|
137
|
-
return angles_degrees
|
|
138
|
-
|
|
139
|
-
|
|
140
201
|
def close_network_holes_to_deadends(
|
|
141
202
|
gdf: GeoDataFrame,
|
|
142
203
|
max_distance: int | float,
|
|
@@ -221,7 +282,7 @@ def _close_holes_all_lines(
|
|
|
221
282
|
) -> GeoSeries:
|
|
222
283
|
k = min(len(nodes), 50)
|
|
223
284
|
|
|
224
|
-
# make
|
|
285
|
+
# make points for the deadends and the other endpoint of the deadend lines
|
|
225
286
|
deadends_target = lines.loc[lines["n_target"] == 1].rename(
|
|
226
287
|
columns={"target_wkt": "wkt", "source_wkt": "wkt_other_end"}
|
|
227
288
|
)
|
|
@@ -251,6 +312,7 @@ def _close_holes_all_lines(
|
|
|
251
312
|
# and endpoints of the new lines in lists, looping through the k neighbour points
|
|
252
313
|
new_sources: list[str] = []
|
|
253
314
|
new_targets: list[str] = []
|
|
315
|
+
# all_angles = []
|
|
254
316
|
for i in np.arange(idx_start, k):
|
|
255
317
|
# to break out of the loop if no new_targets that meet the condition are found
|
|
256
318
|
len_now = len(new_sources)
|
|
@@ -270,8 +332,11 @@ def _close_holes_all_lines(
|
|
|
270
332
|
deadends_other_end_array, deadends_array
|
|
271
333
|
)
|
|
272
334
|
|
|
273
|
-
|
|
274
|
-
np.abs(
|
|
335
|
+
def get_angle_difference(angle1, angle2):
|
|
336
|
+
return np.abs((angle1 - angle2 + 180) % 360 - 180)
|
|
337
|
+
|
|
338
|
+
angles_difference = get_angle_difference(
|
|
339
|
+
angles_deadend_to_deadend_other_end, angles_deadend_to_node
|
|
275
340
|
)
|
|
276
341
|
|
|
277
342
|
angles_difference[
|
|
@@ -284,14 +349,18 @@ def _close_holes_all_lines(
|
|
|
284
349
|
to_idx = indices[condition]
|
|
285
350
|
to_wkt = nodes.iloc[to_idx]["wkt"]
|
|
286
351
|
|
|
352
|
+
# all_angles = all_angles + [
|
|
353
|
+
# diff
|
|
354
|
+
# for f, diff in zip(from_wkt, angles_difference[condition], strict=True)
|
|
355
|
+
# if f not in new_sources
|
|
356
|
+
# ]
|
|
357
|
+
|
|
287
358
|
# now add the wkts to the lists of new sources and targets. If the source
|
|
288
359
|
# is already added, the new wks will not be added again
|
|
289
360
|
new_targets = new_targets + [
|
|
290
361
|
t for f, t in zip(from_wkt, to_wkt, strict=True) if f not in new_sources
|
|
291
362
|
]
|
|
292
|
-
new_sources = new_sources + [
|
|
293
|
-
f for f, _ in zip(from_wkt, to_wkt, strict=True) if f not in new_sources
|
|
294
|
-
]
|
|
363
|
+
new_sources = new_sources + [f for f in from_wkt if f not in new_sources]
|
|
295
364
|
|
|
296
365
|
# break out of the loop when no new new_targets meet the condition
|
|
297
366
|
if len_now == len(new_sources):
|
|
@@ -300,7 +369,16 @@ def _close_holes_all_lines(
|
|
|
300
369
|
# make GeoSeries with straight lines
|
|
301
370
|
new_sources = gpd.GeoSeries.from_wkt(new_sources, crs=lines.crs)
|
|
302
371
|
new_targets = gpd.GeoSeries.from_wkt(new_targets, crs=lines.crs)
|
|
303
|
-
return shortest_line(new_sources, new_targets)
|
|
372
|
+
return shapely.shortest_line(new_sources, new_targets) # , all_angles
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def get_angle(array_a: np.ndarray, array_b: np.ndarray) -> np.ndarray:
|
|
376
|
+
dx = array_b[:, 0] - array_a[:, 0]
|
|
377
|
+
dy = array_b[:, 1] - array_a[:, 1]
|
|
378
|
+
|
|
379
|
+
angles_rad = np.arctan2(dx, dy)
|
|
380
|
+
angles_degrees = np.degrees(angles_rad)
|
|
381
|
+
return angles_degrees
|
|
304
382
|
|
|
305
383
|
|
|
306
384
|
def _find_holes_deadends(
|
|
@@ -347,7 +425,7 @@ def _find_holes_deadends(
|
|
|
347
425
|
to_geom = deadends.loc[to_idx, "geometry"].reset_index(drop=True)
|
|
348
426
|
|
|
349
427
|
# GeoDataFrame with straight lines
|
|
350
|
-
new_lines = shortest_line(from_geom, to_geom)
|
|
428
|
+
new_lines = shapely.shortest_line(from_geom, to_geom)
|
|
351
429
|
new_lines = gpd.GeoDataFrame({"geometry": new_lines}, geometry="geometry", crs=crs)
|
|
352
430
|
|
|
353
431
|
return new_lines
|
|
@@ -7,19 +7,16 @@ import pandas as pd
|
|
|
7
7
|
from geopandas import GeoDataFrame
|
|
8
8
|
from pandas import DataFrame
|
|
9
9
|
from pandas import Series
|
|
10
|
-
from shapely import extract_unique_points
|
|
11
10
|
from shapely import force_2d
|
|
12
11
|
from shapely.geometry import LineString
|
|
13
12
|
from shapely.geometry import Point
|
|
14
13
|
|
|
15
|
-
from ..geopandas_tools.
|
|
16
|
-
from ..geopandas_tools.conversion import to_gdf
|
|
14
|
+
from ..geopandas_tools.general import _split_lines_by_points_along_line
|
|
17
15
|
from ..geopandas_tools.geometry_types import get_geom_type
|
|
18
|
-
from ..geopandas_tools.neighbors import get_k_nearest_neighbors
|
|
19
16
|
from ..geopandas_tools.point_operations import snap_all
|
|
20
17
|
from ..geopandas_tools.point_operations import snap_within_distance
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
|
|
19
|
+
PRECISION = 1e-6
|
|
23
20
|
|
|
24
21
|
|
|
25
22
|
def split_lines_by_nearest_point(
|
|
@@ -75,8 +72,6 @@ def split_lines_by_nearest_point(
|
|
|
75
72
|
Not all lines were split. That is because some points were closest to an endpoint
|
|
76
73
|
of a line.
|
|
77
74
|
"""
|
|
78
|
-
PRECISION = 1e-6
|
|
79
|
-
|
|
80
75
|
if not len(gdf) or not len(points):
|
|
81
76
|
return gdf
|
|
82
77
|
|
|
@@ -103,145 +98,7 @@ def split_lines_by_nearest_point(
|
|
|
103
98
|
else:
|
|
104
99
|
snapped = snap_all(points, gdf)
|
|
105
100
|
|
|
106
|
-
|
|
107
|
-
snapped_buff = buff(snapped, PRECISION, resolution=16)
|
|
108
|
-
relevant_lines, the_other_lines = sfilter_split(gdf, snapped_buff)
|
|
109
|
-
|
|
110
|
-
if max_distance and not len(relevant_lines):
|
|
111
|
-
if splitted_col:
|
|
112
|
-
return gdf.assign(**{splitted_col: 1})
|
|
113
|
-
return gdf
|
|
114
|
-
|
|
115
|
-
# need consistent coordinate dimensions later
|
|
116
|
-
# (doing it down here to not overwrite the original data)
|
|
117
|
-
relevant_lines.geometry = force_2d(relevant_lines.geometry)
|
|
118
|
-
snapped.geometry = force_2d(snapped.geometry)
|
|
119
|
-
|
|
120
|
-
# split the lines with buffer + difference, since shaply.split usually doesn't work
|
|
121
|
-
# relevant_lines["_idx"] = range(len(relevant_lines))
|
|
122
|
-
splitted = relevant_lines.overlay(snapped_buff, how="difference").explode(
|
|
123
|
-
ignore_index=True
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
# linearrings (maybe coded as linestrings) that were not split,
|
|
127
|
-
# do not have edges and must be added in the end
|
|
128
|
-
boundaries = splitted.geometry.boundary
|
|
129
|
-
circles = splitted[boundaries.is_empty]
|
|
130
|
-
splitted = splitted[~boundaries.is_empty]
|
|
131
|
-
|
|
132
|
-
if not len(splitted):
|
|
133
|
-
return pd.concat([the_other_lines, circles], ignore_index=True)
|
|
134
|
-
|
|
135
|
-
# the endpoints of the new lines are now sligtly off. Using get_k_nearest_neighbors
|
|
136
|
-
# to get the exact snapped point coordinates, . This will map the sligtly
|
|
137
|
-
# wrong line endpoints with the point the line was split by.
|
|
138
|
-
|
|
139
|
-
snapped["point_coords"] = [(geom.x, geom.y) for geom in snapped.geometry]
|
|
140
|
-
|
|
141
|
-
# get line endpoints as columns (source_coords and target_coords)
|
|
142
|
-
splitted = make_edge_coords_cols(splitted)
|
|
143
|
-
|
|
144
|
-
splitted_source = to_gdf(splitted["source_coords"], crs=gdf.crs)
|
|
145
|
-
splitted_target = to_gdf(splitted["target_coords"], crs=gdf.crs)
|
|
146
|
-
|
|
147
|
-
def get_nearest(splitted: GeoDataFrame, snapped: GeoDataFrame) -> pd.DataFrame:
|
|
148
|
-
"""Find the nearest snapped point for each source and target of the lines."""
|
|
149
|
-
return get_k_nearest_neighbors(splitted, snapped, k=1).loc[
|
|
150
|
-
lambda x: x["distance"] <= PRECISION * 2
|
|
151
|
-
]
|
|
152
|
-
|
|
153
|
-
# snapped = snapped.set_index("point_coords")
|
|
154
|
-
snapped.index = snapped.geometry
|
|
155
|
-
dists_source = get_nearest(splitted_source, snapped)
|
|
156
|
-
dists_target = get_nearest(splitted_target, snapped)
|
|
157
|
-
|
|
158
|
-
# neighbor_index: point coordinates as tuple
|
|
159
|
-
pointmapper_source: pd.Series = dists_source["neighbor_index"]
|
|
160
|
-
pointmapper_target: pd.Series = dists_target["neighbor_index"]
|
|
161
|
-
|
|
162
|
-
# now, we can replace the source/target coordinate with the coordinates of
|
|
163
|
-
# the snapped points.
|
|
164
|
-
|
|
165
|
-
splitted = _change_line_endpoint(
|
|
166
|
-
splitted,
|
|
167
|
-
indices=dists_source.index,
|
|
168
|
-
pointmapper=pointmapper_source,
|
|
169
|
-
change_what="first",
|
|
170
|
-
) # i=0)
|
|
171
|
-
|
|
172
|
-
# same for the lines where the target was split, but change the last coordinate
|
|
173
|
-
splitted = _change_line_endpoint(
|
|
174
|
-
splitted,
|
|
175
|
-
indices=dists_target.index,
|
|
176
|
-
pointmapper=pointmapper_target,
|
|
177
|
-
change_what="last",
|
|
178
|
-
) # , i=-1)
|
|
179
|
-
|
|
180
|
-
if splitted_col:
|
|
181
|
-
splitted[splitted_col] = 1
|
|
182
|
-
|
|
183
|
-
return pd.concat([the_other_lines, splitted, circles], ignore_index=True).drop(
|
|
184
|
-
["source_coords", "target_coords"], axis=1
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _change_line_endpoint(
|
|
189
|
-
gdf: GeoDataFrame,
|
|
190
|
-
indices: pd.Index,
|
|
191
|
-
pointmapper: pd.Series,
|
|
192
|
-
change_what: str | int,
|
|
193
|
-
) -> GeoDataFrame:
|
|
194
|
-
"""Modify the endpoints of selected lines in a GeoDataFrame based on an index mapping.
|
|
195
|
-
|
|
196
|
-
This function updates the geometry of specified line features within a GeoDataFrame,
|
|
197
|
-
changing either the first or last point of each line to new coordinates provided by a mapping.
|
|
198
|
-
It is typically used in scenarios where line endpoints need to be adjusted to new locations,
|
|
199
|
-
such as in network adjustments or data corrections.
|
|
200
|
-
|
|
201
|
-
Args:
|
|
202
|
-
gdf: A GeoDataFrame containing line geometries.
|
|
203
|
-
indices: An Index object identifying the rows in the GeoDataFrame whose endpoints will be changed.
|
|
204
|
-
pointmapper: A Series mapping from the index of lines to new point geometries.
|
|
205
|
-
change_what: Specifies which endpoint of the line to change. Accepts 'first' or 0 for the
|
|
206
|
-
starting point, and 'last' or -1 for the ending point.
|
|
207
|
-
|
|
208
|
-
Returns:
|
|
209
|
-
A GeoDataFrame with the specified line endpoints updated according to the pointmapper.
|
|
210
|
-
|
|
211
|
-
Raises:
|
|
212
|
-
ValueError: If `change_what` is not one of the accepted values ('first', 'last', 0, -1).
|
|
213
|
-
"""
|
|
214
|
-
assert gdf.index.is_unique
|
|
215
|
-
|
|
216
|
-
if change_what == "first" or change_what == 0:
|
|
217
|
-
keep = "first"
|
|
218
|
-
elif change_what == "last" or change_what == -1:
|
|
219
|
-
keep = "last"
|
|
220
|
-
else:
|
|
221
|
-
raise ValueError(
|
|
222
|
-
f"change_what should be 'first' or 'last' or 0 or -1. Got {change_what}"
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
is_relevant = gdf.index.isin(indices)
|
|
226
|
-
relevant_lines = gdf.loc[is_relevant]
|
|
227
|
-
|
|
228
|
-
relevant_lines.geometry = extract_unique_points(relevant_lines.geometry)
|
|
229
|
-
relevant_lines = relevant_lines.explode(index_parts=False)
|
|
230
|
-
|
|
231
|
-
relevant_lines.loc[lambda x: ~x.index.duplicated(keep=keep), "geometry"] = (
|
|
232
|
-
relevant_lines.loc[lambda x: ~x.index.duplicated(keep=keep)]
|
|
233
|
-
.index.map(pointmapper)
|
|
234
|
-
.values
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
is_line = relevant_lines.groupby(level=0).size() > 1
|
|
238
|
-
relevant_lines_mapped = (
|
|
239
|
-
relevant_lines.loc[is_line].groupby(level=0)["geometry"].agg(LineString)
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
gdf.loc[relevant_lines_mapped.index, "geometry"] = relevant_lines_mapped
|
|
243
|
-
|
|
244
|
-
return gdf
|
|
101
|
+
return _split_lines_by_points_along_line(gdf, snapped, splitted_col=splitted_col)
|
|
245
102
|
|
|
246
103
|
|
|
247
104
|
def cut_lines(
|
|
@@ -91,6 +91,11 @@ def get_component_size(gdf: GeoDataFrame) -> GeoDataFrame:
|
|
|
91
91
|
3 346
|
|
92
92
|
Name: count, dtype: int64
|
|
93
93
|
"""
|
|
94
|
+
if not len(gdf):
|
|
95
|
+
gdf["component_index"] = None
|
|
96
|
+
gdf["component_size"] = None
|
|
97
|
+
return gdf
|
|
98
|
+
|
|
94
99
|
gdf, _ = make_node_ids(gdf)
|
|
95
100
|
|
|
96
101
|
edges = [
|
|
@@ -109,6 +114,7 @@ def get_component_size(gdf: GeoDataFrame) -> GeoDataFrame:
|
|
|
109
114
|
for idx in component
|
|
110
115
|
},
|
|
111
116
|
).transpose()
|
|
117
|
+
|
|
112
118
|
mapper.columns = ["component_index", "component_size"]
|
|
113
119
|
|
|
114
120
|
gdf["component_index"] = gdf["source"].map(mapper["component_index"])
|