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.
@@ -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
+ [![Docs](https://github.com/BikeNetKit/LinkBikeNet/actions/workflows/docs.yml/badge.svg)](https://bikenetkit.github.io/FixBikeNet/)
38
+ [![Test](https://github.com/BikeNetKit/LinkBikeNet/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/BikeNetKit/FixBikeNet/actions/workflows/test.yml)
39
+ [![PyPI - Version](https://img.shields.io/pypi/v/linkbikenet)](https://pypi.org/project/LinkBikeNet/)
40
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
+ ![Human created](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/badge_humancreated.svg)
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
+ [![Innovation Fund Denmark](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_innovationfund.png)](https://innovationsfonden.dk/en) &emsp;&emsp; [![European Union](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_eu.png)](https://commission.europa.eu/index_en) &ensp; [![JUST STREETS](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_juststreets.png)](https://www.just-streets.eu/)
@@ -0,0 +1,56 @@
1
+ # Bike Net Kit / Link Bike Net
2
+
3
+ [![Docs](https://github.com/BikeNetKit/LinkBikeNet/actions/workflows/docs.yml/badge.svg)](https://bikenetkit.github.io/FixBikeNet/)
4
+ [![Test](https://github.com/BikeNetKit/LinkBikeNet/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/BikeNetKit/FixBikeNet/actions/workflows/test.yml)
5
+ [![PyPI - Version](https://img.shields.io/pypi/v/linkbikenet)](https://pypi.org/project/LinkBikeNet/)
6
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
7
+ ![Human created](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/badge_humancreated.svg)
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
+ [![Innovation Fund Denmark](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_innovationfund.png)](https://innovationsfonden.dk/en) &emsp;&emsp; [![European Union](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_eu.png)](https://commission.europa.eu/index_en) &ensp; [![JUST STREETS](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_juststreets.png)](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
+ [![Docs](https://github.com/BikeNetKit/LinkBikeNet/actions/workflows/docs.yml/badge.svg)](https://bikenetkit.github.io/FixBikeNet/)
38
+ [![Test](https://github.com/BikeNetKit/LinkBikeNet/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/BikeNetKit/FixBikeNet/actions/workflows/test.yml)
39
+ [![PyPI - Version](https://img.shields.io/pypi/v/linkbikenet)](https://pypi.org/project/LinkBikeNet/)
40
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
+ ![Human created](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/badge_humancreated.svg)
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
+ [![Innovation Fund Denmark](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_innovationfund.png)](https://innovationsfonden.dk/en) &emsp;&emsp; [![European Union](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_eu.png)](https://commission.europa.eu/index_en) &ensp; [![JUST STREETS](https://raw.githubusercontent.com/BikeNetKit/.github/refs/heads/main/profile/_static/logo_juststreets.png)](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,13 @@
1
+ geopandas>=0.14
2
+ matplotlib
3
+ osmnx>=1.9.4
4
+ geojson
5
+ pip
6
+ numpy
7
+
8
+ [doc]
9
+ sphinx
10
+ momepy
11
+
12
+ [test]
13
+ pytest
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from setuptools import find_packages, setup
2
+ setup(
3
+ name="linkbikenet",
4
+ packages=find_packages(),
5
+ )
@@ -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