linkbikenet 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linkbikenet-0.5.0/PKG-INFO +90 -0
- linkbikenet-0.5.0/README.md +56 -0
- linkbikenet-0.5.0/linkbikenet/__init__.py +12 -0
- linkbikenet-0.5.0/linkbikenet/config.py +23 -0
- linkbikenet-0.5.0/linkbikenet/functions.py +244 -0
- linkbikenet-0.5.0/linkbikenet/linkbikenet.py +157 -0
- linkbikenet-0.5.0/linkbikenet.egg-info/PKG-INFO +90 -0
- linkbikenet-0.5.0/linkbikenet.egg-info/SOURCES.txt +13 -0
- linkbikenet-0.5.0/linkbikenet.egg-info/dependency_links.txt +1 -0
- linkbikenet-0.5.0/linkbikenet.egg-info/requires.txt +13 -0
- linkbikenet-0.5.0/linkbikenet.egg-info/top_level.txt +1 -0
- linkbikenet-0.5.0/pyproject.toml +59 -0
- linkbikenet-0.5.0/setup.cfg +4 -0
- linkbikenet-0.5.0/setup.py +5 -0
- linkbikenet-0.5.0/tests/test_functions.py +38 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkbikenet
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: BikeNetKit Python package to link disconnected bicycle network components
|
|
5
|
+
Author: Manuel Knepper, Michael Szell
|
|
6
|
+
Maintainer-email: Manuel Knepper <manuel.knepper@gmx.net>
|
|
7
|
+
License-Expression: AGPL-3.0-or-later
|
|
8
|
+
Project-URL: Repository, https://github.com/BikeNetKit/LinkBikeNet
|
|
9
|
+
Project-URL: Issues, https://github.com/BikeNetKit/LinkBikeNet/issues
|
|
10
|
+
Keywords: Bicycle network planning,Networks,OpenStreetMap,Urban Planning,Urban Mobility
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Natural Language :: English
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: geopandas>=0.14
|
|
24
|
+
Requires-Dist: matplotlib
|
|
25
|
+
Requires-Dist: osmnx>=1.9.4
|
|
26
|
+
Requires-Dist: geojson
|
|
27
|
+
Requires-Dist: pip
|
|
28
|
+
Requires-Dist: numpy
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == "test"
|
|
31
|
+
Provides-Extra: doc
|
|
32
|
+
Requires-Dist: sphinx; extra == "doc"
|
|
33
|
+
Requires-Dist: momepy; extra == "doc"
|
|
34
|
+
|
|
35
|
+
# Bike Net Kit / Link Bike Net
|
|
36
|
+
|
|
37
|
+
[](https://bikenetkit.github.io/FixBikeNet/)
|
|
38
|
+
[](https://github.com/BikeNetKit/FixBikeNet/actions/workflows/test.yml)
|
|
39
|
+
[](https://pypi.org/project/LinkBikeNet/)
|
|
40
|
+
[](https://github.com/astral-sh/ruff)
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
The Python package `linkbikenet` finds links between the disconnected components in a city's bicycle network.
|
|
44
|
+
|
|
45
|
+
The software downloads and pre-processes data from OpenStreetMap, identifies the components and the needed links and saves the results. The source code builds on (https://github.com/nateraluis/bicycle-network-growth).
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
<!--**Publication**: [https://doi.org/10.1111/gean.12324](https://doi.org/10.1111/gean.12324)-->
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
### The easy way
|
|
54
|
+
|
|
55
|
+
The currently default way to install LinkBikeNet is using pip:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pip install linkbikenet
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
<!-- > [!IMPORTANT]
|
|
62
|
+
> As of 2026-07-27, the conda-forge installation is not yet working. We will remove this note once it works.
|
|
63
|
+
|
|
64
|
+
The best way to install LinkBikeNet is using [`conda`](https://docs.conda.io/projects/conda/en/latest/index.html) and the `conda-forge` channel:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
conda install -c conda-forge linkbikenet
|
|
68
|
+
``` -->
|
|
69
|
+
|
|
70
|
+
<!--If this does not work, consult our [installation docs](https://bikenetkit.github.io/FixBikeNet/installation/).
|
|
71
|
+
|
|
72
|
+
### Advanced and development installations
|
|
73
|
+
See our [installation docs](https://bikenetkit.github.io/FixBikeNet/installation/) for details.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
We provide a minimum working example in two formats:
|
|
78
|
+
|
|
79
|
+
- Python script ([examples/mwe.py](examples/mwe.py))
|
|
80
|
+
- Jupyter notebook ([examples/mwe.ipynb](examples/mwe.ipynb))
|
|
81
|
+
|
|
82
|
+
## Docs
|
|
83
|
+
Find more information in our docs: [https://bikenetkit.github.io/FixBikeNet/](https://bikenetkit.github.io/FixBikeNet/)
|
|
84
|
+
-->
|
|
85
|
+
|
|
86
|
+
## Supported by
|
|
87
|
+
Development of BikeNetKit/LinkBikeNet was supported by the [Innovation Fund Denmark](https://innovationsfonden.dk/en) and the EU HORIZON project [JUST STREETS](https://www.just-streets.eu).
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
[](https://innovationsfonden.dk/en)    [](https://commission.europa.eu/index_en)   [](https://www.just-streets.eu/)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Bike Net Kit / Link Bike Net
|
|
2
|
+
|
|
3
|
+
[](https://bikenetkit.github.io/FixBikeNet/)
|
|
4
|
+
[](https://github.com/BikeNetKit/FixBikeNet/actions/workflows/test.yml)
|
|
5
|
+
[](https://pypi.org/project/LinkBikeNet/)
|
|
6
|
+
[](https://github.com/astral-sh/ruff)
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
The Python package `linkbikenet` finds links between the disconnected components in a city's bicycle network.
|
|
10
|
+
|
|
11
|
+
The software downloads and pre-processes data from OpenStreetMap, identifies the components and the needed links and saves the results. The source code builds on (https://github.com/nateraluis/bicycle-network-growth).
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
<!--**Publication**: [https://doi.org/10.1111/gean.12324](https://doi.org/10.1111/gean.12324)-->
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### The easy way
|
|
20
|
+
|
|
21
|
+
The currently default way to install LinkBikeNet is using pip:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install linkbikenet
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
<!-- > [!IMPORTANT]
|
|
28
|
+
> As of 2026-07-27, the conda-forge installation is not yet working. We will remove this note once it works.
|
|
29
|
+
|
|
30
|
+
The best way to install LinkBikeNet is using [`conda`](https://docs.conda.io/projects/conda/en/latest/index.html) and the `conda-forge` channel:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
conda install -c conda-forge linkbikenet
|
|
34
|
+
``` -->
|
|
35
|
+
|
|
36
|
+
<!--If this does not work, consult our [installation docs](https://bikenetkit.github.io/FixBikeNet/installation/).
|
|
37
|
+
|
|
38
|
+
### Advanced and development installations
|
|
39
|
+
See our [installation docs](https://bikenetkit.github.io/FixBikeNet/installation/) for details.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
We provide a minimum working example in two formats:
|
|
44
|
+
|
|
45
|
+
- Python script ([examples/mwe.py](examples/mwe.py))
|
|
46
|
+
- Jupyter notebook ([examples/mwe.ipynb](examples/mwe.ipynb))
|
|
47
|
+
|
|
48
|
+
## Docs
|
|
49
|
+
Find more information in our docs: [https://bikenetkit.github.io/FixBikeNet/](https://bikenetkit.github.io/FixBikeNet/)
|
|
50
|
+
-->
|
|
51
|
+
|
|
52
|
+
## Supported by
|
|
53
|
+
Development of BikeNetKit/LinkBikeNet was supported by the [Innovation Fund Denmark](https://innovationsfonden.dk/en) and the EU HORIZON project [JUST STREETS](https://www.just-streets.eu).
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
[](https://innovationsfonden.dk/en)    [](https://commission.europa.eu/index_en)   [](https://www.just-streets.eu/)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
|
+
|
|
4
|
+
from . import config
|
|
5
|
+
from . import functions
|
|
6
|
+
from linkbikenet.linkbikenet import linkbikenet
|
|
7
|
+
|
|
8
|
+
__author__ = "MS, MK"
|
|
9
|
+
__author_email__ = "email@domain.com"
|
|
10
|
+
|
|
11
|
+
with contextlib.suppress(PackageNotFoundError):
|
|
12
|
+
__version__ = version("linkbikenet")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
cycleway_bike_infra = [
|
|
2
|
+
'track'
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
cycleway_right_bike_infra = [
|
|
6
|
+
'track',
|
|
7
|
+
'opposite_track'
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
cycleway_left_bike_infra = [
|
|
11
|
+
'track',
|
|
12
|
+
'opposite_track'
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
cycleway_both_bike_infra = [
|
|
16
|
+
'track',
|
|
17
|
+
'opposite_track'
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
highway_bike_infra = [
|
|
21
|
+
'cycleway',
|
|
22
|
+
'living_street'
|
|
23
|
+
]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from . import config
|
|
2
|
+
import geopandas as gpd
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.spatial import cKDTree
|
|
5
|
+
from shapely.geometry import LineString
|
|
6
|
+
|
|
7
|
+
def map_edges_to_bike_infrastructure(g):
|
|
8
|
+
"""
|
|
9
|
+
map if edges in graph have bike infrastructure as specified in config.py
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
g :networkx.MultiDiGraph
|
|
14
|
+
simplified graph representing the street network
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
g : networkx.MultiDiGraph
|
|
19
|
+
simplified graph representing the street network, with added binary edge attribute "pbi"
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# add binary edge attribute "pbi" (protected bike infra: True/False)
|
|
23
|
+
for edge in g.edges(keys=True):
|
|
24
|
+
if g.edges[edge].get("cycleway") in config.cycleway_bike_infra:
|
|
25
|
+
g.edges[edge]["pbi"] = 1
|
|
26
|
+
elif g.edges[edge].get("cycleway:right") in config.cycleway_right_bike_infra:
|
|
27
|
+
g.edges[edge]["pbi"] = 1
|
|
28
|
+
elif g.edges[edge].get("cycleway:left") in config.cycleway_left_bike_infra:
|
|
29
|
+
g.edges[edge]["pbi"] = 1
|
|
30
|
+
elif g.edges[edge].get("cycleway:both") in config.cycleway_both_bike_infra:
|
|
31
|
+
g.edges[edge]["pbi"] = 1
|
|
32
|
+
elif g.edges[edge].get("highway") in config.highway_bike_infra:
|
|
33
|
+
g.edges[edge]["pbi"] = 1
|
|
34
|
+
else:
|
|
35
|
+
g.edges[edge]["pbi"] = 0
|
|
36
|
+
return g
|
|
37
|
+
|
|
38
|
+
def find_edges_to_drop(g):
|
|
39
|
+
"""
|
|
40
|
+
find parallel edges that have different pbi values, list the ones with pbi=0
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
g : networkx.MultiDiGraph
|
|
45
|
+
simplified graph representing the street network, with added binary edge attribute "pbi"
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
edges_to_drop: list
|
|
50
|
+
unique list of edges to drop-> edges where pbi values differ and pbi value=0 gets dropped
|
|
51
|
+
"""
|
|
52
|
+
# to find parallel edges, get all u,v tuples for which u,v,w>0 exists:
|
|
53
|
+
uvs = [edge[:2] for edge in list(g.edges) if edge[2] > 0] # >0 includes key=1, key=2, ...
|
|
54
|
+
uvs = list(set(uvs))
|
|
55
|
+
edges_to_drop = []
|
|
56
|
+
|
|
57
|
+
for uv in uvs:
|
|
58
|
+
# collect all parallel edges for u-v node pair;
|
|
59
|
+
# account for the fact that edges are directed! uv[::-1]==vu might also be on the list
|
|
60
|
+
parallel_edges = [edge for edge in list(g.edges) if (edge[:2] == uv) or (edge[:2] == uv[::-1])]
|
|
61
|
+
|
|
62
|
+
# get set of PBIs for this u-v parallel edge list
|
|
63
|
+
pbis = set([g.edges[e]["pbi"] for e in parallel_edges])
|
|
64
|
+
|
|
65
|
+
# if we have both pbi==0 and pbi==1,
|
|
66
|
+
if len(pbis) == 2:
|
|
67
|
+
# add edges with pbi==0 to edges_to_drop list
|
|
68
|
+
to_drop = [e for e in parallel_edges if g.edges[e]["pbi"] == 0]
|
|
69
|
+
edges_to_drop += to_drop
|
|
70
|
+
|
|
71
|
+
edges_to_drop = list(set(edges_to_drop))
|
|
72
|
+
return edges_to_drop
|
|
73
|
+
|
|
74
|
+
def graph_edges_to_gdf(G):
|
|
75
|
+
"""
|
|
76
|
+
creates a geodataframe with edges attributes from a simple nx graph
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
G: networkx.Graph
|
|
80
|
+
undirected simple graph representing the street network with weighted edges
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
edges_gdf: geopandas.GeoDataFrame
|
|
85
|
+
geodataframe with edges from G, including edge attributes
|
|
86
|
+
"""
|
|
87
|
+
rows = []
|
|
88
|
+
for u, v, data in G.edges(data=True):
|
|
89
|
+
# if geometry already exists on the edge, use it
|
|
90
|
+
if "geometry" in data:
|
|
91
|
+
geom = data["geometry"]
|
|
92
|
+
else:
|
|
93
|
+
# otherwise build a straight line from node coordinates
|
|
94
|
+
geom = LineString([
|
|
95
|
+
(G.nodes[u]["x"], G.nodes[u]["y"]),
|
|
96
|
+
(G.nodes[v]["x"], G.nodes[v]["y"])
|
|
97
|
+
])
|
|
98
|
+
rows.append({
|
|
99
|
+
"u": u,
|
|
100
|
+
"v": v,
|
|
101
|
+
**data,
|
|
102
|
+
"geometry": geom
|
|
103
|
+
})
|
|
104
|
+
edges_gdf = gpd.GeoDataFrame(
|
|
105
|
+
rows,
|
|
106
|
+
geometry="geometry",
|
|
107
|
+
crs=G.graph.get("crs")
|
|
108
|
+
).set_index(["u", "v"])
|
|
109
|
+
|
|
110
|
+
return edges_gdf
|
|
111
|
+
|
|
112
|
+
def pair_between_largest_components(wcc):
|
|
113
|
+
"""
|
|
114
|
+
Find the closest pair of nodes between the two largest components
|
|
115
|
+
using a KD-tree.
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
components : list of networkx.Graph
|
|
119
|
+
Components sorted with the largest first.
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
closest_pair : tuple
|
|
123
|
+
The two nodes that should be connected
|
|
124
|
+
"""
|
|
125
|
+
G1 = wcc[0]
|
|
126
|
+
G2 = wcc[1]
|
|
127
|
+
|
|
128
|
+
# Coordinates of nodes in the second component
|
|
129
|
+
nodes2 = list(G2.nodes())
|
|
130
|
+
coords2 = np.array([
|
|
131
|
+
(G2.nodes[n]["x"], G2.nodes[n]["y"])
|
|
132
|
+
for n in nodes2
|
|
133
|
+
])
|
|
134
|
+
# Build KD-tree
|
|
135
|
+
tree = cKDTree(coords2)
|
|
136
|
+
closest_pair = None
|
|
137
|
+
min_dist = np.inf
|
|
138
|
+
|
|
139
|
+
# Query the nearest node in G2 for every node in G1
|
|
140
|
+
for n1, data in G1.nodes(data=True):
|
|
141
|
+
coord = np.array([data["x"], data["y"]])
|
|
142
|
+
dist, idx = tree.query(coord)
|
|
143
|
+
if dist < min_dist:
|
|
144
|
+
min_dist = dist
|
|
145
|
+
closest_pair = (n1, nodes2[idx])
|
|
146
|
+
|
|
147
|
+
return closest_pair
|
|
148
|
+
|
|
149
|
+
def pair_between_nearest_components(wcc):
|
|
150
|
+
"""
|
|
151
|
+
Find the pair of nodes connecting the largest component to the
|
|
152
|
+
geographically nearest remaining component.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
wcc : list of nx.Graph
|
|
157
|
+
Connected components sorted with the largest first.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
closest_pair : tuple
|
|
162
|
+
The two nodes that should be connected
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
largest = wcc[0]
|
|
166
|
+
|
|
167
|
+
# Build KD-tree for the largest component
|
|
168
|
+
largest_nodes = list(largest.nodes())
|
|
169
|
+
largest_xy = np.array([
|
|
170
|
+
(largest.nodes[n]["x"], largest.nodes[n]["y"])
|
|
171
|
+
for n in largest_nodes
|
|
172
|
+
])
|
|
173
|
+
tree = cKDTree(largest_xy)
|
|
174
|
+
|
|
175
|
+
closest_pair = None
|
|
176
|
+
best_distance = np.inf
|
|
177
|
+
# Compare every remaining component to the largest
|
|
178
|
+
for comp in wcc[1:]:
|
|
179
|
+
comp_nodes = list(comp.nodes())
|
|
180
|
+
comp_xy = np.array([
|
|
181
|
+
(comp.nodes[n]["x"], comp.nodes[n]["y"])
|
|
182
|
+
for n in comp_nodes
|
|
183
|
+
])
|
|
184
|
+
distances, indices = tree.query(comp_xy)
|
|
185
|
+
i = np.argmin(distances)
|
|
186
|
+
if distances[i] < best_distance:
|
|
187
|
+
best_distance = distances[i]
|
|
188
|
+
closest_pair = (
|
|
189
|
+
largest_nodes[indices[i]],
|
|
190
|
+
comp_nodes[i]
|
|
191
|
+
)
|
|
192
|
+
return closest_pair
|
|
193
|
+
|
|
194
|
+
def get_correct_edgetuples(edge_gdf, nodelist):
|
|
195
|
+
"""
|
|
196
|
+
helper function that maps a node list (output of nx.shortest_paths)
|
|
197
|
+
to the correct set of edge tuples that can be used for INDEXING THE EDGE GDF
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
edge_gdf: geopandas.geodataframe.GeoDataFrame
|
|
202
|
+
The street network, in a projected coordinate reference system
|
|
203
|
+
nodelist: list
|
|
204
|
+
A list of nodes that make up source and targets of edges
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
edgelist_final: list
|
|
209
|
+
List of edge tuples that can be used for INDEXING THE EDGE GDF
|
|
210
|
+
"""
|
|
211
|
+
edgelist_prelim = zip(nodelist, nodelist[1:])
|
|
212
|
+
edgelist_final = []
|
|
213
|
+
temp_gdf = edge_gdf.sort_index() # To circumvent PerformanceWarning, see https://stackoverflow.com/questions/54307300/what-causes-indexing-past-lexsort-depth-warning-in-pandas
|
|
214
|
+
for edge_prelim in edgelist_prelim:
|
|
215
|
+
if edge_prelim in temp_gdf.index:
|
|
216
|
+
edgelist_final.append(edge_prelim)
|
|
217
|
+
else:
|
|
218
|
+
edgelist_final.append(tuple([edge_prelim[1], edge_prelim[0]]))
|
|
219
|
+
return edgelist_final
|
|
220
|
+
|
|
221
|
+
def create_gdf_with_geoms(df, edges):
|
|
222
|
+
"""
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
df: pandas.DataFrame
|
|
226
|
+
Dataframe with path nodes and path edges
|
|
227
|
+
edges: geopandas.GeoDataFrame
|
|
228
|
+
The street network, in a projected coordinate reference system
|
|
229
|
+
|
|
230
|
+
Returns
|
|
231
|
+
-------
|
|
232
|
+
gdf: geopandas.GeoDataFrame
|
|
233
|
+
projected GeoDataFrame with path nodes and path edges and merged geometries
|
|
234
|
+
"""
|
|
235
|
+
# get geometry by merging all geoms from edge gdf
|
|
236
|
+
df = df.copy()
|
|
237
|
+
df["geometry"] = df.edge_list.apply(
|
|
238
|
+
lambda x: edges.loc[x].geometry.union_all()
|
|
239
|
+
)
|
|
240
|
+
# convert edges into a gdf
|
|
241
|
+
gdf = gpd.GeoDataFrame(df, crs=edges.crs, geometry="geometry")
|
|
242
|
+
# merge multilinestring into linestring where possible (should be possible everywhere)
|
|
243
|
+
gdf["geometry"] = gdf.line_merge()
|
|
244
|
+
return gdf
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# imports
|
|
2
|
+
import os
|
|
3
|
+
import osmnx as ox
|
|
4
|
+
import networkx as nx
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from linkbikenet.functions import *
|
|
8
|
+
|
|
9
|
+
def linkbikenet(
|
|
10
|
+
city_name,
|
|
11
|
+
connection_strategy = "largest",
|
|
12
|
+
proj_crs = "3857",
|
|
13
|
+
export_data = True,
|
|
14
|
+
export_file_format = "geojson"
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Creates links between components of bicycle networks in cities. How components are connected depends on the connection strategy that was chosen.
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
city_name : str
|
|
21
|
+
name of the city that the analysis should be performed on
|
|
22
|
+
connection_strategy : str, default="largest
|
|
23
|
+
strategy to use for connecting between components. Default is "largest", second option is "closest"
|
|
24
|
+
proj_crs : str, default '3857'
|
|
25
|
+
coordinate reference system that is used to project osm data. Default is '3857' (WGS 84 / Pseudo-Mercator)
|
|
26
|
+
export_data : bool, optional, default True
|
|
27
|
+
If set to True, data will be saved to a file. The filename is [slug].gpkg, where slug is a string id made out of city_name
|
|
28
|
+
export_file_format : str, optional, default "geojson"
|
|
29
|
+
File format for the data export, relevant if export_data set to True. Default "geojson", also possible "gpkg". If exporting as geojson, generates extra files for street network and city boundary. If exporting as gkpg, these are added all in one file as extra layers.
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
gdf: geopandas.GeoDataFrame
|
|
33
|
+
geodataframe with the proposed links, ordered after strategy chosen
|
|
34
|
+
"""
|
|
35
|
+
# check if user input is valid
|
|
36
|
+
if type(city_name) != str:
|
|
37
|
+
raise TypeError("city_name must be a string")
|
|
38
|
+
if type(proj_crs) != str:
|
|
39
|
+
raise TypeError("proj_crs must be a string")
|
|
40
|
+
if connection_strategy != "largest" and connection_strategy != "closest":
|
|
41
|
+
raise TypeError("connection_strategy must be 'largest' or 'closest'")
|
|
42
|
+
if type(export_data) is not bool:
|
|
43
|
+
raise TypeError("export_data must be a boolean")
|
|
44
|
+
if export_file_format != "geojson" and export_file_format != "gpkg":
|
|
45
|
+
raise ValueError("export_file_format must be 'geojson' or 'gpkg'")
|
|
46
|
+
|
|
47
|
+
### downloading and preprocessing data from OSM
|
|
48
|
+
print("Downloading OSM data..")
|
|
49
|
+
|
|
50
|
+
ox.settings.useful_tags_way = ["highway", "cycleway", "cycleway:right", "cycleway:left", "cycleway:both",
|
|
51
|
+
"cyclestreet"]
|
|
52
|
+
|
|
53
|
+
# fetch street network from OSM
|
|
54
|
+
g = ox.graph_from_place(
|
|
55
|
+
city_name, network_type='all_public', simplify=False, retain_all=True
|
|
56
|
+
)
|
|
57
|
+
g = ox.simplify_graph(
|
|
58
|
+
g,
|
|
59
|
+
edge_attrs_differ=['cycleway', 'highway', 'cycleway:right', 'cycleway:left', 'cycleway:both'],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# project graph for distance calculations
|
|
63
|
+
g = ox.project_graph(g, to_crs=proj_crs)
|
|
64
|
+
|
|
65
|
+
# check which edges have existing bicycle infrastructure and assign "pbi = 1" to them, all other edges get "pbi = 0".
|
|
66
|
+
g = map_edges_to_bike_infrastructure(g)
|
|
67
|
+
|
|
68
|
+
# finding parallel edges and dropping them
|
|
69
|
+
print("Dropping parallel edges..")
|
|
70
|
+
edges_to_drop = find_edges_to_drop(g)
|
|
71
|
+
g.remove_edges_from(edges_to_drop)
|
|
72
|
+
|
|
73
|
+
# Capital-G: the Graph() object we will be working with from now on
|
|
74
|
+
G = nx.Graph(g)
|
|
75
|
+
|
|
76
|
+
# build graph that only contains edges with protected bicycle infrastructure
|
|
77
|
+
edges = [
|
|
78
|
+
(u, v)
|
|
79
|
+
for u, v, data in G.edges(data=True)
|
|
80
|
+
if data.get("pbi") == 1
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
H = G.edge_subgraph(edges).copy()
|
|
84
|
+
|
|
85
|
+
# computing all weakly connected components to find out how many there are. This informs the amount of loops later
|
|
86
|
+
wcc = [H.subgraph(c).copy() for c in sorted(nx.connected_components(H), key=lambda c: sum(
|
|
87
|
+
[l[-1] for l in H.subgraph(c).copy().edges.data('length')]), reverse=True)]
|
|
88
|
+
|
|
89
|
+
to_iterate = len(wcc) - 1
|
|
90
|
+
closest_pairs = []
|
|
91
|
+
|
|
92
|
+
# check which strategy was chosen and execute the corresponding algorithm
|
|
93
|
+
print("Calculating links...")
|
|
94
|
+
if connection_strategy == "largest":
|
|
95
|
+
for i in range(to_iterate):
|
|
96
|
+
wcc = [H.subgraph(c).copy() for c in sorted(nx.connected_components(H), key=lambda c: sum(
|
|
97
|
+
[l[-1] for l in H.subgraph(c).copy().edges.data('length')]), reverse=True)]
|
|
98
|
+
pair = pair_between_largest_components(wcc)
|
|
99
|
+
closest_pairs.append(pair)
|
|
100
|
+
H.add_edge(pair[0], pair[1], length=0)
|
|
101
|
+
|
|
102
|
+
elif connection_strategy == "closest":
|
|
103
|
+
for i in range(to_iterate):
|
|
104
|
+
wcc = [H.subgraph(c).copy() for c in sorted(nx.connected_components(H), key=lambda c: sum(
|
|
105
|
+
[l[-1] for l in H.subgraph(c).copy().edges.data('length')]), reverse=True)]
|
|
106
|
+
pair = pair_between_nearest_components(wcc)
|
|
107
|
+
closest_pairs.append(pair)
|
|
108
|
+
H.add_edge(pair[0], pair[1], length=0)
|
|
109
|
+
|
|
110
|
+
# find paths between node pairs so we can generate geometries
|
|
111
|
+
paths = []
|
|
112
|
+
for pair in closest_pairs:
|
|
113
|
+
try:
|
|
114
|
+
path = nx.shortest_path(G, pair[0], pair[1], weight='length')
|
|
115
|
+
except nx.NetworkXNoPath:
|
|
116
|
+
continue
|
|
117
|
+
paths.append(path)
|
|
118
|
+
|
|
119
|
+
edges_gdf = graph_edges_to_gdf(G)
|
|
120
|
+
df = pd.DataFrame()
|
|
121
|
+
df['nodelist'] = paths
|
|
122
|
+
|
|
123
|
+
df['edge_list'] = df.nodelist.apply(lambda x: get_correct_edgetuples(edges_gdf, x))
|
|
124
|
+
gdf = create_gdf_with_geoms(df, edges_gdf)
|
|
125
|
+
|
|
126
|
+
edges_pbi_gdf = edges_gdf[edges_gdf["pbi"] == 1]
|
|
127
|
+
|
|
128
|
+
# Generate export data filename
|
|
129
|
+
if export_data:
|
|
130
|
+
os.makedirs("./results/", exist_ok=True)
|
|
131
|
+
export_data_filename = (
|
|
132
|
+
city_name + "." + export_file_format
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if export_data:
|
|
136
|
+
### save data
|
|
137
|
+
print("Saving data..")
|
|
138
|
+
edges_pbi_gdf.drop(["osmid"], axis=1, inplace=True)
|
|
139
|
+
city_boundary = ox.geocoder.geocode_to_gdf(city_name)
|
|
140
|
+
city_boundary.to_crs(epsg=proj_crs, inplace=True)
|
|
141
|
+
# We have meter precision, so rounding to integers is fine. Better would be to
|
|
142
|
+
# change dtypes to int, but this does not seem possible without manual looping.
|
|
143
|
+
city_boundary.geometry = city_boundary.geometry.set_precision(grid_size=1)
|
|
144
|
+
edges_pbi_gdf.geometry = edges_pbi_gdf.geometry.set_precision(grid_size=1)
|
|
145
|
+
gdf.geometry = gdf.geometry.set_precision(grid_size=1)
|
|
146
|
+
if export_file_format == "geojson":
|
|
147
|
+
gdf.to_file("./results/" + export_data_filename, driver="GeoJSON")
|
|
148
|
+
edges_pbi_gdf.to_file("./results/" + city_name + "-existing_bike_network.geojson", driver="GeoJSON")
|
|
149
|
+
city_boundary.to_file("./results/" + city_name + "-city_boundary.geojson", driver="GeoJSON")
|
|
150
|
+
elif export_file_format == "gpkg":
|
|
151
|
+
gdf.to_file("./results/" + export_data_filename, driver="GPKG", layer="Identified links")
|
|
152
|
+
edges_pbi_gdf.to_file("./results/" + export_data_filename, driver="GPKG", layer="Existing bike network",
|
|
153
|
+
append=True)
|
|
154
|
+
city_boundary.to_file("./results/" + export_data_filename, driver="GPKG", layer="City boundary",
|
|
155
|
+
append=True)
|
|
156
|
+
|
|
157
|
+
return gdf
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkbikenet
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: BikeNetKit Python package to link disconnected bicycle network components
|
|
5
|
+
Author: Manuel Knepper, Michael Szell
|
|
6
|
+
Maintainer-email: Manuel Knepper <manuel.knepper@gmx.net>
|
|
7
|
+
License-Expression: AGPL-3.0-or-later
|
|
8
|
+
Project-URL: Repository, https://github.com/BikeNetKit/LinkBikeNet
|
|
9
|
+
Project-URL: Issues, https://github.com/BikeNetKit/LinkBikeNet/issues
|
|
10
|
+
Keywords: Bicycle network planning,Networks,OpenStreetMap,Urban Planning,Urban Mobility
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Natural Language :: English
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: geopandas>=0.14
|
|
24
|
+
Requires-Dist: matplotlib
|
|
25
|
+
Requires-Dist: osmnx>=1.9.4
|
|
26
|
+
Requires-Dist: geojson
|
|
27
|
+
Requires-Dist: pip
|
|
28
|
+
Requires-Dist: numpy
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == "test"
|
|
31
|
+
Provides-Extra: doc
|
|
32
|
+
Requires-Dist: sphinx; extra == "doc"
|
|
33
|
+
Requires-Dist: momepy; extra == "doc"
|
|
34
|
+
|
|
35
|
+
# Bike Net Kit / Link Bike Net
|
|
36
|
+
|
|
37
|
+
[](https://bikenetkit.github.io/FixBikeNet/)
|
|
38
|
+
[](https://github.com/BikeNetKit/FixBikeNet/actions/workflows/test.yml)
|
|
39
|
+
[](https://pypi.org/project/LinkBikeNet/)
|
|
40
|
+
[](https://github.com/astral-sh/ruff)
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
The Python package `linkbikenet` finds links between the disconnected components in a city's bicycle network.
|
|
44
|
+
|
|
45
|
+
The software downloads and pre-processes data from OpenStreetMap, identifies the components and the needed links and saves the results. The source code builds on (https://github.com/nateraluis/bicycle-network-growth).
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
<!--**Publication**: [https://doi.org/10.1111/gean.12324](https://doi.org/10.1111/gean.12324)-->
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
### The easy way
|
|
54
|
+
|
|
55
|
+
The currently default way to install LinkBikeNet is using pip:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pip install linkbikenet
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
<!-- > [!IMPORTANT]
|
|
62
|
+
> As of 2026-07-27, the conda-forge installation is not yet working. We will remove this note once it works.
|
|
63
|
+
|
|
64
|
+
The best way to install LinkBikeNet is using [`conda`](https://docs.conda.io/projects/conda/en/latest/index.html) and the `conda-forge` channel:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
conda install -c conda-forge linkbikenet
|
|
68
|
+
``` -->
|
|
69
|
+
|
|
70
|
+
<!--If this does not work, consult our [installation docs](https://bikenetkit.github.io/FixBikeNet/installation/).
|
|
71
|
+
|
|
72
|
+
### Advanced and development installations
|
|
73
|
+
See our [installation docs](https://bikenetkit.github.io/FixBikeNet/installation/) for details.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
We provide a minimum working example in two formats:
|
|
78
|
+
|
|
79
|
+
- Python script ([examples/mwe.py](examples/mwe.py))
|
|
80
|
+
- Jupyter notebook ([examples/mwe.ipynb](examples/mwe.ipynb))
|
|
81
|
+
|
|
82
|
+
## Docs
|
|
83
|
+
Find more information in our docs: [https://bikenetkit.github.io/FixBikeNet/](https://bikenetkit.github.io/FixBikeNet/)
|
|
84
|
+
-->
|
|
85
|
+
|
|
86
|
+
## Supported by
|
|
87
|
+
Development of BikeNetKit/LinkBikeNet was supported by the [Innovation Fund Denmark](https://innovationsfonden.dk/en) and the EU HORIZON project [JUST STREETS](https://www.just-streets.eu).
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
[](https://innovationsfonden.dk/en)    [](https://commission.europa.eu/index_en)   [](https://www.just-streets.eu/)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
linkbikenet/__init__.py
|
|
5
|
+
linkbikenet/config.py
|
|
6
|
+
linkbikenet/functions.py
|
|
7
|
+
linkbikenet/linkbikenet.py
|
|
8
|
+
linkbikenet.egg-info/PKG-INFO
|
|
9
|
+
linkbikenet.egg-info/SOURCES.txt
|
|
10
|
+
linkbikenet.egg-info/dependency_links.txt
|
|
11
|
+
linkbikenet.egg-info/requires.txt
|
|
12
|
+
linkbikenet.egg-info/top_level.txt
|
|
13
|
+
tests/test_functions.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
linkbikenet
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "linkbikenet"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Manuel Knepper" },
|
|
10
|
+
{ name = "Michael Szell" },
|
|
11
|
+
]
|
|
12
|
+
maintainers = [{ name = "Manuel Knepper", email = "manuel.knepper@gmx.net" }]
|
|
13
|
+
license = "AGPL-3.0-or-later"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
description = "BikeNetKit Python package to link disconnected bicycle network components"
|
|
16
|
+
keywords = ["Bicycle network planning", "Networks", "OpenStreetMap", "Urban Planning", "Urban Mobility"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Natural Language :: English",
|
|
28
|
+
]
|
|
29
|
+
requires-python = ">=3.10"
|
|
30
|
+
dependencies = [
|
|
31
|
+
"geopandas>=0.14",
|
|
32
|
+
"matplotlib",
|
|
33
|
+
"osmnx>=1.9.4",
|
|
34
|
+
"geojson",
|
|
35
|
+
"pip",
|
|
36
|
+
"numpy",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Repository = "https://github.com/BikeNetKit/LinkBikeNet"
|
|
41
|
+
Issues = "https://github.com/BikeNetKit/LinkBikeNet/issues"
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
test = ["pytest"]
|
|
45
|
+
doc = ["sphinx", "momepy"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
48
|
+
"__init__.py" = ["F401"] # "Imported but unused: happens with packages
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
exclude = [
|
|
52
|
+
"examples*",
|
|
53
|
+
"tests*",
|
|
54
|
+
"results",
|
|
55
|
+
"docs*",
|
|
56
|
+
"data*",
|
|
57
|
+
"scripts*",
|
|
58
|
+
]
|
|
59
|
+
namespaces = false
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import networkx as nx
|
|
2
|
+
import pytest
|
|
3
|
+
from linkbikenet.functions import *
|
|
4
|
+
|
|
5
|
+
@pytest.fixture
|
|
6
|
+
def create_test_components_largest():
|
|
7
|
+
G = nx.Graph()
|
|
8
|
+
G.add_nodes_from([(1, {"x": 1, "y": 2}), (2, {"x": 5, "y": 6}), (3, {"x": 7, "y": 8}), (4, {"x": 10, "y": 10}), (5, {"x": 11, "y": 11}), (6, {"x": 15, "y": 20})])
|
|
9
|
+
G.add_edges_from([(1, 2, {'length': 5}), (2, 3, {'length': 2}), (1, 3, {'length': 7}), (4, 5, {'length': 1})])
|
|
10
|
+
wcc = [G.subgraph(c).copy() for c in sorted(nx.connected_components(G), key=lambda c: sum(
|
|
11
|
+
[l[-1] for l in G.subgraph(c).copy().edges.data('length')]), reverse=True)]
|
|
12
|
+
return wcc
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def create_validation_pair_largest():
|
|
16
|
+
pair = 3,4
|
|
17
|
+
return pair
|
|
18
|
+
|
|
19
|
+
def test_pair_between_largest_components(create_test_components_largest, create_validation_pair_largest):
|
|
20
|
+
assert pair_between_largest_components(create_test_components_largest) == create_validation_pair_largest
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def create_test_components_nearest():
|
|
25
|
+
G = nx.Graph()
|
|
26
|
+
G.add_nodes_from([(1, {"x": 1, "y": 2}), (2, {"x": 5, "y": 6}), (3, {"x": 7, "y": 8}), (4, {"x": 30, "y": 30}), (5, {"x": 31, "y": 31}), (6, {"x": 10, "y": 10})])
|
|
27
|
+
G.add_edges_from([(1, 2, {'length': 5}), (2, 3, {'length': 2}), (1, 3, {'length': 7}), (4, 5, {'length': 1})])
|
|
28
|
+
wcc = [G.subgraph(c).copy() for c in sorted(nx.connected_components(G), key=lambda c: sum(
|
|
29
|
+
[l[-1] for l in G.subgraph(c).copy().edges.data('length')]), reverse=True)]
|
|
30
|
+
return wcc
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def create_validation_pair_nearest():
|
|
34
|
+
pair = 3,6
|
|
35
|
+
return pair
|
|
36
|
+
|
|
37
|
+
def test_pair_between_nearest_components(create_test_components_nearest, create_validation_pair_nearest):
|
|
38
|
+
assert pair_between_nearest_components(create_test_components_nearest) == create_validation_pair_nearest
|