ssb-sgis 0.1.5__py3-none-any.whl → 0.1.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/geopandas_tools/general.py +3 -5
- sgis/geopandas_tools/geometry_types.py +1 -1
- sgis/geopandas_tools/line_operations.py +1 -1
- sgis/maps/explore.py +0 -85
- sgis/maps/legend.py +5 -5
- sgis/maps/maps.py +5 -1
- sgis/maps/thematicmap.py +35 -10
- sgis/networkanalysis/_get_route.py +168 -114
- sgis/networkanalysis/_od_cost_matrix.py +7 -9
- sgis/networkanalysis/_points.py +0 -18
- sgis/networkanalysis/directednetwork.py +2 -2
- sgis/networkanalysis/network.py +16 -25
- sgis/networkanalysis/networkanalysis.py +301 -123
- sgis/networkanalysis/networkanalysisrules.py +2 -2
- {ssb_sgis-0.1.5.dist-info → ssb_sgis-0.1.6.dist-info}/METADATA +15 -7
- ssb_sgis-0.1.6.dist-info/RECORD +35 -0
- ssb_sgis-0.1.5.dist-info/RECORD +0 -35
- {ssb_sgis-0.1.5.dist-info → ssb_sgis-0.1.6.dist-info}/LICENSE +0 -0
- {ssb_sgis-0.1.5.dist-info → ssb_sgis-0.1.6.dist-info}/WHEEL +0 -0
sgis/geopandas_tools/general.py
CHANGED
|
@@ -368,8 +368,8 @@ def to_lines(*gdfs: GeoDataFrame, copy: bool = True) -> GeoDataFrame:
|
|
|
368
368
|
>>> poly1["poly1"] = 1
|
|
369
369
|
>>> line = sg.to_lines(poly1)
|
|
370
370
|
>>> line
|
|
371
|
-
|
|
372
|
-
0
|
|
371
|
+
geometry poly1
|
|
372
|
+
0 LINESTRING (0.00000 0.00000, 0.00000 1.00000, ... 1
|
|
373
373
|
|
|
374
374
|
Convert two overlapping polygons to linestrings.
|
|
375
375
|
|
|
@@ -385,13 +385,11 @@ def to_lines(*gdfs: GeoDataFrame, copy: bool = True) -> GeoDataFrame:
|
|
|
385
385
|
4 NaN 1.0 LINESTRING (0.50000 1.00000, 0.50000 1.50000, ...
|
|
386
386
|
5 NaN 1.0 LINESTRING (1.00000 0.50000, 0.50000 0.50000)
|
|
387
387
|
|
|
388
|
-
Plot before and after
|
|
388
|
+
Plot before and after.
|
|
389
389
|
|
|
390
390
|
>>> sg.qtm(poly1, poly2)
|
|
391
|
-
<Axes: >
|
|
392
391
|
>>> lines["l"] = lines.length
|
|
393
392
|
>>> sg.qtm(lines, "l")
|
|
394
|
-
<Axes: >
|
|
395
393
|
"""
|
|
396
394
|
|
|
397
395
|
if any(any(gdf.geom_type.isin(["Point", "MultiPoint"])) for gdf in gdfs):
|
|
@@ -698,7 +698,7 @@ def close_network_holes_to_deadends(
|
|
|
698
698
|
|
|
699
699
|
Fill gaps shorter than 1.1 meters.
|
|
700
700
|
|
|
701
|
-
>>> filled = sg.close_network_holes_to_deadends(roads, max_distance=1.1
|
|
701
|
+
>>> filled = sg.close_network_holes_to_deadends(roads, max_distance=1.1)
|
|
702
702
|
>>> roads = sg.get_largest_component(roads)
|
|
703
703
|
>>> roads.connected.value_counts()
|
|
704
704
|
1.0 100315
|
sgis/maps/explore.py
CHANGED
|
@@ -85,42 +85,6 @@ class Explore(Map):
|
|
|
85
85
|
self.cmap_stop = kwargs.pop("cmap_stop", 256)
|
|
86
86
|
|
|
87
87
|
def explore(self, column: str | None = None, **kwargs) -> None:
|
|
88
|
-
"""Interactive map of the GeoDataFrames with layers that can be toggles on/off.
|
|
89
|
-
|
|
90
|
-
It displays all the GeoDataFrames and displays them together in an interactive
|
|
91
|
-
map with a common legend. The layers can be toggled on and off.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
column: The column to color the geometries by. Defaults to the column
|
|
95
|
-
that was specified last.
|
|
96
|
-
**kwargs: Keyword arguments to pass to geopandas.GeoDataFrame.explore, for
|
|
97
|
-
instance 'cmap' to change the colors, 'scheme' to change how the data
|
|
98
|
-
is grouped. This defaults to 'fisherjenks' for numeric data.
|
|
99
|
-
|
|
100
|
-
See also:
|
|
101
|
-
samplemap: same functionality, but shows only a random area of a given size.
|
|
102
|
-
clipmap: same functionality, but shows only the areas clipped by a given
|
|
103
|
-
mask.
|
|
104
|
-
|
|
105
|
-
Examples
|
|
106
|
-
--------
|
|
107
|
-
>>> from sgis import read_parquet_url
|
|
108
|
-
>>> roads = read_parquet_url("https://media.githubusercontent.com/media/statisticsnorway/ssb-sgis/main/tests/testdata/roads_oslo_2022.parquet")
|
|
109
|
-
>>> points = read_parquet_url("https://media.githubusercontent.com/media/statisticsnorway/ssb-sgis/main/tests/testdata/points_oslo.parquet")
|
|
110
|
-
|
|
111
|
-
Simple explore of two GeoDataFrames.
|
|
112
|
-
|
|
113
|
-
>>> from sgis import Explore
|
|
114
|
-
>>> ex = Explore(roads, points)
|
|
115
|
-
>>> ex.explore()
|
|
116
|
-
|
|
117
|
-
With column.
|
|
118
|
-
|
|
119
|
-
>>> roads["meters"] = roads.length
|
|
120
|
-
>>> points["meters"] = points.length
|
|
121
|
-
>>> ex = Explore(roads, points, column="meters")
|
|
122
|
-
>>> ex.samplemap()
|
|
123
|
-
"""
|
|
124
88
|
if column:
|
|
125
89
|
self._column = column
|
|
126
90
|
self._update_column()
|
|
@@ -135,34 +99,6 @@ class Explore(Map):
|
|
|
135
99
|
sample_from_first: bool = True,
|
|
136
100
|
**kwargs,
|
|
137
101
|
) -> None:
|
|
138
|
-
"""Shows an interactive map of a random area of the GeoDataFrames.
|
|
139
|
-
|
|
140
|
-
It takes a random sample point of the GeoDataFrames, and shows all geometries
|
|
141
|
-
within a given radius of this point. Displays an interactive map with a common
|
|
142
|
-
legend. The layers can be toggled on and off.
|
|
143
|
-
|
|
144
|
-
The radius to plot can be changed with the 'size' parameter.
|
|
145
|
-
|
|
146
|
-
For more info about the labeling and coloring of the map, see the explore
|
|
147
|
-
method.
|
|
148
|
-
|
|
149
|
-
Args:
|
|
150
|
-
size: the radius to buffer the sample point by before clipping with the
|
|
151
|
-
data.
|
|
152
|
-
column: The column to color the geometries by. Defaults to the column
|
|
153
|
-
that was specified last.
|
|
154
|
-
sample_from_first: If True (default), the sample point is taken from
|
|
155
|
-
the first specified GeoDataFrame. If False, all GeoDataFrames are
|
|
156
|
-
considered.
|
|
157
|
-
**kwargs: Keyword arguments to pass to geopandas.GeoDataFrame.explore, for
|
|
158
|
-
instance 'cmap' to change the colors, 'scheme' to change how the data
|
|
159
|
-
is grouped. This defaults to 'fisherjenks' for numeric data.
|
|
160
|
-
|
|
161
|
-
See also:
|
|
162
|
-
explore: same functionality, but shows the entire area of the geometries.
|
|
163
|
-
clipmap: same functionality, but shows only the areas clipped by a given
|
|
164
|
-
mask.
|
|
165
|
-
"""
|
|
166
102
|
if column:
|
|
167
103
|
self._column = column
|
|
168
104
|
self._update_column()
|
|
@@ -200,27 +136,6 @@ class Explore(Map):
|
|
|
200
136
|
column: str | None = None,
|
|
201
137
|
**kwargs,
|
|
202
138
|
) -> None:
|
|
203
|
-
"""Shows an interactive map of a of the GeoDataFrames clipped by the mask.
|
|
204
|
-
|
|
205
|
-
It clips all the GeoDataFrames in the Explore instance to the mask extent,
|
|
206
|
-
and displays the resulting geometries in an interactive map with a common
|
|
207
|
-
legends. The layers can be toggled on and off.
|
|
208
|
-
|
|
209
|
-
For more info about the labeling and coloring of the map, see the explore
|
|
210
|
-
method.
|
|
211
|
-
|
|
212
|
-
Args:
|
|
213
|
-
mask: the geometry to clip the data by.
|
|
214
|
-
column: The column to color the geometries by. Defaults to the column
|
|
215
|
-
that was specified last.
|
|
216
|
-
**kwargs: Keyword arguments to pass to geopandas.GeoDataFrame.explore, for
|
|
217
|
-
instance 'cmap' to change the colors, 'scheme' to change how the data
|
|
218
|
-
is grouped. This defaults to 'fisherjenks' for numeric data.
|
|
219
|
-
|
|
220
|
-
See also:
|
|
221
|
-
explore: same functionality, but shows the entire area of the geometries.
|
|
222
|
-
samplemap: same functionality, but shows only a random area of a given size.
|
|
223
|
-
"""
|
|
224
139
|
if column:
|
|
225
140
|
self._column = column
|
|
226
141
|
self._update_column()
|
sgis/maps/legend.py
CHANGED
|
@@ -157,9 +157,7 @@ class Legend:
|
|
|
157
157
|
else:
|
|
158
158
|
self._markersize = size
|
|
159
159
|
|
|
160
|
-
def
|
|
161
|
-
self, ax, categories_colors: dict, nan_label: str
|
|
162
|
-
):
|
|
160
|
+
def _prepare_categorical_legend(self, categories_colors: dict, nan_label: str):
|
|
163
161
|
for attr in self.__dict__.keys():
|
|
164
162
|
if attr in self.kwargs:
|
|
165
163
|
self[attr] = self.kwargs.pop(attr)
|
|
@@ -196,6 +194,8 @@ class Legend:
|
|
|
196
194
|
markeredgewidth=0,
|
|
197
195
|
)
|
|
198
196
|
)
|
|
197
|
+
|
|
198
|
+
def _actually_add_legend(self, ax):
|
|
199
199
|
legend = ax.legend(
|
|
200
200
|
self._patches,
|
|
201
201
|
self._categories,
|
|
@@ -433,9 +433,8 @@ class ContinousLegend(Legend):
|
|
|
433
433
|
if not self._legend:
|
|
434
434
|
raise ValueError("Cannot modify legend before it is created.")
|
|
435
435
|
|
|
436
|
-
def
|
|
436
|
+
def _prepare_continous_legend(
|
|
437
437
|
self,
|
|
438
|
-
ax,
|
|
439
438
|
bins: list[float],
|
|
440
439
|
colors: list[str],
|
|
441
440
|
nan_label: str,
|
|
@@ -524,6 +523,7 @@ class ContinousLegend(Legend):
|
|
|
524
523
|
label = self._two_value_label(min_rounded, max_rounded)
|
|
525
524
|
self._categories.append(label)
|
|
526
525
|
|
|
526
|
+
def _actually_add_legend(self, ax):
|
|
527
527
|
legend = ax.legend(
|
|
528
528
|
self._patches,
|
|
529
529
|
self._categories,
|
sgis/maps/maps.py
CHANGED
|
@@ -37,12 +37,16 @@ def explore(
|
|
|
37
37
|
show_in_browser: bool = False,
|
|
38
38
|
**kwargs,
|
|
39
39
|
) -> None:
|
|
40
|
-
"""Interactive map of GeoDataFrames with layers that can be
|
|
40
|
+
"""Interactive map of GeoDataFrames with layers that can be toggled on/off.
|
|
41
41
|
|
|
42
42
|
It takes all the given GeoDataFrames and displays them together in an
|
|
43
43
|
interactive map with a common legend. If 'column' is not specified, each
|
|
44
44
|
GeoDataFrame is given a unique color.
|
|
45
45
|
|
|
46
|
+
If the column is of type string and only one GeoDataFrame is given, the unique
|
|
47
|
+
values will be split into separate GeoDataFrames so that each value can be toggled
|
|
48
|
+
on/off.
|
|
49
|
+
|
|
46
50
|
The coloring can be changed with the 'cmap' parameter. The default colormap is a
|
|
47
51
|
custom, strongly colored palette. If a numerical colum is given, the 'viridis'
|
|
48
52
|
palette is used.
|
sgis/maps/thematicmap.py
CHANGED
|
@@ -146,26 +146,56 @@ class ThematicMap(Map):
|
|
|
146
146
|
This method should be run after customising the map, but before saving.
|
|
147
147
|
"""
|
|
148
148
|
|
|
149
|
+
__test = kwargs.pop("__test", False)
|
|
149
150
|
include_legend = bool(kwargs.pop("legend", self.legend))
|
|
150
151
|
|
|
151
|
-
self._prepare_plot(**kwargs)
|
|
152
|
-
|
|
153
152
|
if "color" in kwargs:
|
|
154
|
-
kwargs
|
|
153
|
+
kwargs.pop("column", None)
|
|
155
154
|
self.legend = None
|
|
156
155
|
include_legend = False
|
|
157
156
|
elif hasattr(self, "color"):
|
|
158
|
-
kwargs
|
|
157
|
+
kwargs.pop("column", None)
|
|
159
158
|
kwargs["color"] = self.color
|
|
160
159
|
self.legend = None
|
|
161
160
|
include_legend = False
|
|
161
|
+
|
|
162
162
|
elif self._is_categorical:
|
|
163
163
|
kwargs = self._prepare_categorical_plot(kwargs)
|
|
164
|
+
self.legend._prepare_categorical_legend(
|
|
165
|
+
categories_colors=self._categories_colors_dict,
|
|
166
|
+
nan_label=self.nan_label,
|
|
167
|
+
)
|
|
168
|
+
|
|
164
169
|
else:
|
|
165
170
|
kwargs = self._prepare_continous_plot(kwargs)
|
|
171
|
+
if self.legend:
|
|
172
|
+
if not self.legend._rounding_has_been_set:
|
|
173
|
+
self.legend._rounding = self.legend._get_rounding(
|
|
174
|
+
array=self._gdf.loc[~self._nan_idx, self._column]
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self.legend._prepare_continous_legend(
|
|
178
|
+
bins=self.bins,
|
|
179
|
+
colors=self._unique_colors,
|
|
180
|
+
nan_label=self.nan_label,
|
|
181
|
+
bin_values=self._bins_unique_values,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if self.legend and not self.legend._position_has_been_set:
|
|
185
|
+
self.legend._position = self.legend._get_best_legend_position(
|
|
186
|
+
self._gdf, k=self._k + bool(len(self._nan_idx))
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if __test:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
self._prepare_plot(**kwargs)
|
|
166
193
|
|
|
167
194
|
if self.legend:
|
|
168
|
-
self._actually_add_legend()
|
|
195
|
+
self.ax = self.legend._actually_add_legend(ax=self.ax)
|
|
196
|
+
|
|
197
|
+
# if self.legend:
|
|
198
|
+
# self._actually_add_legend()
|
|
169
199
|
|
|
170
200
|
self._gdf.plot(legend=include_legend, ax=self.ax, **kwargs)
|
|
171
201
|
|
|
@@ -257,11 +287,6 @@ class ThematicMap(Map):
|
|
|
257
287
|
self._gdf, k=self._k + bool(len(self._nan_idx))
|
|
258
288
|
)
|
|
259
289
|
|
|
260
|
-
if not self._is_categorical and not self.legend._rounding_has_been_set:
|
|
261
|
-
self.legend._rounding = self.legend._get_rounding(
|
|
262
|
-
array=self._gdf.loc[~self._nan_idx, self._column]
|
|
263
|
-
)
|
|
264
|
-
|
|
265
290
|
if self._is_categorical:
|
|
266
291
|
self.ax = self.legend._actually_add_categorical_legend(
|
|
267
292
|
ax=self.ax,
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import warnings
|
|
2
2
|
|
|
3
|
+
import numpy as np
|
|
3
4
|
import pandas as pd
|
|
4
5
|
from geopandas import GeoDataFrame
|
|
5
6
|
from igraph import Graph
|
|
6
|
-
|
|
7
|
-
from .network import _edge_ids
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# run functions for get_route, get_k_routes and get_route_frequencies
|
|
11
|
-
|
|
12
|
-
# TODO: clean up this mess. Make smaller base functions and three separated for route, frequency and k_routes
|
|
7
|
+
from pandas import DataFrame
|
|
13
8
|
|
|
14
9
|
|
|
15
10
|
def _get_route(
|
|
@@ -18,162 +13,199 @@ def _get_route(
|
|
|
18
13
|
destinations: GeoDataFrame,
|
|
19
14
|
weight: str,
|
|
20
15
|
roads: GeoDataFrame,
|
|
21
|
-
summarise: bool = False,
|
|
22
16
|
rowwise: bool = False,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
):
|
|
26
|
-
"""Super function used in the NetworkAnalysis class.
|
|
17
|
+
) -> GeoDataFrame:
|
|
18
|
+
"""Function used in the get_route method of NetworkAnalysis."""
|
|
27
19
|
|
|
28
|
-
Big, ugly super function that is used in the get_route, get_k_routes
|
|
29
|
-
and get_route_frequencies methods of the NetworkAnalysis class.
|
|
30
|
-
"""
|
|
31
20
|
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
32
21
|
|
|
33
|
-
|
|
34
|
-
route_func = _run_get_k_routes
|
|
35
|
-
else:
|
|
36
|
-
route_func = _run_get_route
|
|
22
|
+
od_pairs = _create_od_pairs(origins, destinations, rowwise)
|
|
37
23
|
|
|
38
|
-
resultlist: list[
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
des_id,
|
|
50
|
-
graph,
|
|
51
|
-
roads,
|
|
52
|
-
summarise,
|
|
53
|
-
weight,
|
|
54
|
-
k,
|
|
55
|
-
drop_middle_percent,
|
|
56
|
-
)
|
|
24
|
+
resultlist: list[DataFrame] = []
|
|
25
|
+
|
|
26
|
+
for ori_id, des_id in od_pairs:
|
|
27
|
+
indices = _get_one_route(graph, ori_id, des_id)
|
|
28
|
+
|
|
29
|
+
if not indices:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
line_ids = _create_line_id_df(indices["source_target_weight"], ori_id, des_id)
|
|
33
|
+
|
|
34
|
+
resultlist.append(line_ids)
|
|
57
35
|
|
|
58
36
|
if not resultlist:
|
|
59
|
-
warnings.warn(
|
|
37
|
+
warnings.warn(
|
|
38
|
+
"No paths were found. Try larger search_tolerance or search_factor. "
|
|
39
|
+
"Or close_network_holes() or remove_isolated()."
|
|
40
|
+
)
|
|
60
41
|
return pd.DataFrame(columns=["origin", "destination", weight, "geometry"])
|
|
61
42
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.groupby("source_target_weight")["n"]
|
|
67
|
-
.count()
|
|
68
|
-
)
|
|
43
|
+
results: DataFrame = pd.concat(resultlist)
|
|
44
|
+
assert list(results.columns) == ["origin", "destination"], list(results.columns)
|
|
45
|
+
lines: GeoDataFrame = _get_line_geometries(results, roads, weight)
|
|
46
|
+
lines = lines.dissolve(by=["origin", "destination"], aggfunc="sum", as_index=False)
|
|
69
47
|
|
|
70
|
-
|
|
48
|
+
return lines[["origin", "destination", weight, "geometry"]]
|
|
71
49
|
|
|
72
|
-
roads["n"] = roads["source_target_weight"].map(counted)
|
|
73
50
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
51
|
+
def _get_k_routes(
|
|
52
|
+
graph: Graph,
|
|
53
|
+
origins: GeoDataFrame,
|
|
54
|
+
destinations: GeoDataFrame,
|
|
55
|
+
weight: str,
|
|
56
|
+
roads: GeoDataFrame,
|
|
57
|
+
k: int,
|
|
58
|
+
drop_middle_percent: int,
|
|
59
|
+
rowwise: bool,
|
|
60
|
+
) -> GeoDataFrame:
|
|
61
|
+
"""Function used in the get_k_routes method of NetworkAnalysis."""
|
|
62
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
63
|
+
od_pairs = _create_od_pairs(origins, destinations, rowwise)
|
|
77
64
|
|
|
78
|
-
|
|
65
|
+
resultlist: list[DataFrame] = []
|
|
66
|
+
|
|
67
|
+
for ori_id, des_id in od_pairs:
|
|
68
|
+
k_lines: DataFrame = _loop_k_routes(
|
|
69
|
+
graph, ori_id, des_id, k, drop_middle_percent
|
|
70
|
+
)
|
|
71
|
+
if k_lines is not None:
|
|
72
|
+
resultlist.append(k_lines)
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
except Exception:
|
|
83
|
-
raise ValueError(
|
|
74
|
+
if not resultlist:
|
|
75
|
+
warnings.warn(
|
|
84
76
|
"No paths were found. Try larger search_tolerance or search_factor. "
|
|
85
77
|
"Or close_network_holes() or remove_isolated()."
|
|
86
78
|
)
|
|
79
|
+
return pd.DataFrame(columns=["origin", "destination", weight, "geometry"])
|
|
87
80
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
81
|
+
results: DataFrame = pd.concat(resultlist)
|
|
82
|
+
assert list(results.columns) == ["origin", "destination", "k"], list(
|
|
83
|
+
results.columns
|
|
84
|
+
)
|
|
85
|
+
lines: GeoDataFrame = _get_line_geometries(results, roads, weight)
|
|
91
86
|
|
|
92
|
-
|
|
87
|
+
lines = lines.dissolve(
|
|
88
|
+
by=["origin", "destination", "k"], aggfunc="sum", as_index=False
|
|
89
|
+
)
|
|
93
90
|
|
|
94
|
-
return
|
|
91
|
+
return lines[["origin", "destination", weight, "k", "geometry"]]
|
|
95
92
|
|
|
96
93
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
94
|
+
def _get_route_frequencies(
|
|
95
|
+
graph,
|
|
96
|
+
origins,
|
|
97
|
+
destinations,
|
|
98
|
+
rowwise,
|
|
99
|
+
roads,
|
|
100
|
+
weight_df: DataFrame | None = None,
|
|
101
|
+
):
|
|
102
|
+
"""Function used in the get_route_frequencies method of NetworkAnalysis."""
|
|
103
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
104
|
+
od_pairs = _create_od_pairs(origins, destinations, rowwise)
|
|
105
|
+
|
|
106
|
+
if weight_df is not None and len(weight_df) != len(od_pairs):
|
|
107
|
+
error_message = _make_keyerror_message(rowwise, weight_df, origins)
|
|
108
|
+
raise ValueError(error_message)
|
|
109
|
+
|
|
110
|
+
resultlist: list[DataFrame] = []
|
|
111
|
+
|
|
112
|
+
for ori_id, des_id in od_pairs:
|
|
113
|
+
indices = _get_one_route(graph, ori_id, des_id)
|
|
114
|
+
|
|
115
|
+
if not indices:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
line_ids = DataFrame({"source_target_weight": indices["source_target_weight"]})
|
|
119
|
+
line_ids["origin"] = ori_id
|
|
120
|
+
line_ids["destination"] = des_id
|
|
121
|
+
|
|
122
|
+
if weight_df is not None:
|
|
123
|
+
try:
|
|
124
|
+
line_ids["multiplier"] = weight_df.loc[ori_id, des_id].iloc[0]
|
|
125
|
+
except KeyError as e:
|
|
126
|
+
error_message = _make_keyerror_message(rowwise, weight_df, origins)
|
|
127
|
+
raise KeyError(error_message) from e
|
|
128
|
+
else:
|
|
129
|
+
line_ids["multiplier"] = 1
|
|
130
|
+
|
|
131
|
+
resultlist.append(line_ids)
|
|
132
|
+
|
|
133
|
+
summarised = (
|
|
134
|
+
pd.concat(resultlist, ignore_index=True)
|
|
135
|
+
.groupby("source_target_weight")["multiplier"]
|
|
136
|
+
.sum()
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
roads["frequency"] = roads["source_target_weight"].map(summarised)
|
|
140
|
+
|
|
141
|
+
roads_visited = roads.loc[
|
|
142
|
+
roads.frequency.notna(), roads.columns.difference(["source_target_weight"])
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
return roads_visited
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _create_od_pairs(
|
|
149
|
+
origins: GeoDataFrame, destinations: GeoDataFrame, rowwise: bool
|
|
150
|
+
) -> zip | pd.MultiIndex:
|
|
151
|
+
"""Get all od combinaions if not rowwise."""
|
|
152
|
+
if rowwise:
|
|
153
|
+
return zip(origins.temp_idx, destinations.temp_idx)
|
|
154
|
+
else:
|
|
155
|
+
return pd.MultiIndex.from_product([origins.temp_idx, destinations.temp_idx])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_one_route(graph: Graph, ori_id: str, des_id: str):
|
|
159
|
+
"""Get the edges for one route."""
|
|
107
160
|
res = graph.get_shortest_paths(
|
|
108
161
|
weights="weight", v=ori_id, to=des_id, output="epath"
|
|
109
162
|
)
|
|
110
|
-
|
|
111
163
|
if not res[0]:
|
|
112
164
|
return []
|
|
113
165
|
|
|
114
|
-
|
|
166
|
+
return graph.es[res[0]]
|
|
115
167
|
|
|
116
|
-
if summarise:
|
|
117
|
-
return [pd.DataFrame({"source_target_weight": source_target_weight})]
|
|
118
168
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
]
|
|
169
|
+
def _get_line_geometries(line_ids, roads, weight) -> GeoDataFrame:
|
|
170
|
+
road_mapper = roads.set_index(["source_target_weight"])[[weight, "geometry"]]
|
|
171
|
+
line_ids = line_ids.join(road_mapper)
|
|
172
|
+
return GeoDataFrame(line_ids, geometry="geometry", crs=roads.crs)
|
|
124
173
|
|
|
125
|
-
# if len(line) != len(source_target_weight) - 2:
|
|
126
|
-
# raise ValueError("length mismatch", len(line), len(source_target_weight))
|
|
127
174
|
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
def _create_line_id_df(source_target_weight: list, ori_id, des_id) -> DataFrame:
|
|
176
|
+
line_ids = DataFrame(index=source_target_weight)
|
|
130
177
|
|
|
131
|
-
|
|
132
|
-
|
|
178
|
+
# remove edges from ori/des to the roads
|
|
179
|
+
line_ids = line_ids.loc[~line_ids.index.str.endswith("_0")]
|
|
133
180
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
line[weight] = weight_sum
|
|
181
|
+
line_ids["origin"] = ori_id
|
|
182
|
+
line_ids["destination"] = des_id
|
|
137
183
|
|
|
138
|
-
|
|
139
|
-
return [line]
|
|
140
|
-
else:
|
|
141
|
-
return [line], graph.es[res[0]]["edge_tuples"]
|
|
184
|
+
return line_ids
|
|
142
185
|
|
|
143
186
|
|
|
144
|
-
def
|
|
145
|
-
ori_id: str,
|
|
146
|
-
des_id: str,
|
|
147
|
-
graph: Graph,
|
|
148
|
-
roads: GeoDataFrame,
|
|
149
|
-
summarise: bool,
|
|
150
|
-
weight: str,
|
|
151
|
-
k: int,
|
|
152
|
-
drop_middle_percent,
|
|
153
|
-
) -> list[GeoDataFrame]:
|
|
187
|
+
def _loop_k_routes(graph: Graph, ori_id, des_id, k, drop_middle_percent) -> DataFrame:
|
|
154
188
|
"""Workaround for igraph's get_k_shortest_paths.
|
|
155
189
|
|
|
156
190
|
igraph's get_k_shorest_paths doesn't seem to work (gives just the same path k
|
|
157
|
-
times), so doing it manually. Run
|
|
191
|
+
times), so doing it manually. Run _get_one_route, then remove the edges in the
|
|
158
192
|
middle of the route, given with drop_middle_percent, repeat k times.
|
|
159
193
|
"""
|
|
160
194
|
graph = graph.copy()
|
|
161
195
|
|
|
162
|
-
lines: list[
|
|
196
|
+
lines: list[DataFrame] = []
|
|
163
197
|
|
|
164
198
|
for i in range(k):
|
|
165
|
-
|
|
166
|
-
ori_id, des_id, graph, roads, summarise, weight, k, drop_middle_percent
|
|
167
|
-
)
|
|
199
|
+
indices = _get_one_route(graph, ori_id, des_id)
|
|
168
200
|
|
|
169
|
-
if not
|
|
201
|
+
if not indices:
|
|
170
202
|
continue
|
|
171
203
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
204
|
+
line_ids = _create_line_id_df(indices["source_target_weight"], ori_id, des_id)
|
|
205
|
+
line_ids["k"] = i + 1
|
|
206
|
+
lines.append(line_ids)
|
|
175
207
|
|
|
176
|
-
|
|
208
|
+
edge_tuples = indices["edge_tuples"]
|
|
177
209
|
|
|
178
210
|
n_edges_to_keep = (
|
|
179
211
|
len(edge_tuples) - len(edge_tuples) * drop_middle_percent / 100
|
|
@@ -187,4 +219,26 @@ def _run_get_k_routes(
|
|
|
187
219
|
to_be_dropped = edge_tuples[n_edges_to_keep:-n_edges_to_keep]
|
|
188
220
|
graph.delete_edges(to_be_dropped)
|
|
189
221
|
|
|
190
|
-
|
|
222
|
+
if lines:
|
|
223
|
+
return pd.concat(lines)
|
|
224
|
+
else:
|
|
225
|
+
return pd.DataFrame()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _make_keyerror_message(rowwise, weight_df, origins) -> str:
|
|
229
|
+
"""Add help info to error message if key in weight_df is missing.
|
|
230
|
+
|
|
231
|
+
If empty resultlist, assume all indices are wrong. Else, assume
|
|
232
|
+
"""
|
|
233
|
+
error_message = (
|
|
234
|
+
"'weight_df' does not contain all indices of each OD pair combination. "
|
|
235
|
+
)
|
|
236
|
+
if not rowwise and len(weight_df) == len(origins):
|
|
237
|
+
error_message = error_message + (
|
|
238
|
+
"Did you mean to set rowwise to True? "
|
|
239
|
+
"If not, make sure weight_df contains all combinations of "
|
|
240
|
+
"origin-destination pairs. Either specified as a MultiIndex or as the "
|
|
241
|
+
"first two columns of 'weight_df'. So (0, 0), (0, 1), (1, 0), (1, 1) etc."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return error_message
|
|
@@ -16,12 +16,7 @@ def _od_cost_matrix(
|
|
|
16
16
|
lines: bool = False,
|
|
17
17
|
rowwise: bool = False,
|
|
18
18
|
) -> DataFrame | GeoDataFrame:
|
|
19
|
-
|
|
20
|
-
raise ValueError(
|
|
21
|
-
"'origins' and 'destinations' must have the same length when rowwise=True"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
results = graph.distances(
|
|
19
|
+
distances: list[list[str]] = graph.distances(
|
|
25
20
|
weights="weight",
|
|
26
21
|
source=origins["temp_idx"],
|
|
27
22
|
target=destinations["temp_idx"],
|
|
@@ -29,10 +24,10 @@ def _od_cost_matrix(
|
|
|
29
24
|
|
|
30
25
|
ori_idx, des_idx, costs = [], [], []
|
|
31
26
|
for i, f_idx in enumerate(origins["temp_idx"]):
|
|
32
|
-
for
|
|
27
|
+
for j, t_idx in enumerate(destinations["temp_idx"]):
|
|
33
28
|
ori_idx.append(f_idx)
|
|
34
29
|
des_idx.append(t_idx)
|
|
35
|
-
costs.append(
|
|
30
|
+
costs.append(distances[i][j])
|
|
36
31
|
|
|
37
32
|
results = (
|
|
38
33
|
pd.DataFrame(data={"origin": ori_idx, "destination": des_idx, weight: costs})
|
|
@@ -44,7 +39,10 @@ def _od_cost_matrix(
|
|
|
44
39
|
# so filtering to rowwise afterwards instead
|
|
45
40
|
if rowwise:
|
|
46
41
|
rowwise_df = DataFrame(
|
|
47
|
-
{
|
|
42
|
+
{
|
|
43
|
+
"origin": origins["temp_idx"].reset_index(drop=True),
|
|
44
|
+
"destination": destinations["temp_idx"].reset_index(drop=True),
|
|
45
|
+
}
|
|
48
46
|
)
|
|
49
47
|
results = rowwise_df.merge(results, on=["origin", "destination"], how="left")
|
|
50
48
|
|