diff --git a/examples/exportalldata_onecity.py b/examples/exportalldata_onecity.py index 9e62860..fdfc3d0 100755 --- a/examples/exportalldata_onecity.py +++ b/examples/exportalldata_onecity.py @@ -3,8 +3,10 @@ Parameters ---------- -nominatimstring : str, optional, default Bath +nominatimstring : str, optional, default Barcelona The string to search for in nominatim/OSM. +export_file_format : str, optional, default "geojson" + File format for the data export. Default "geojson", also possible "gpkg". If exporting as geojson, generates extra files for seed points and city boundary. If exporting as gkpg, these are added all in one file as extra layers. Notes ------- @@ -18,25 +20,34 @@ Examples -------- ->>> python exportalldata_onecity.py Barcelona +>>> python exportalldata_onecity.py Barcelona gpkg """ import growbikenet as gbn import sys -nominatimstring = "Bath" +nominatimstring = "Barcelona" +export_file_format = "geojson" if len(sys.argv) >= 2: nominatimstring = sys.argv[1] -print("Exporting data for " + nominatimstring) +if len(sys.argv) >= 3: + export_file_format = sys.argv[2] + +print("Exporting " + export_file_format + " data for " + nominatimstring) -for s in ["grid", "rail"]: - print("\n" + "Exporting " + s) - gbn.growbikenet( - city_name=nominatimstring, - proj_crs="3857", - ranking="all", - seed_point_type=s, - export_data=True, - export_plots=False, - export_video=False, - ) +for seed_point_type in ["grid", "rail"]: + for ranking in ["betweenness_centrality", "closeness_centrality", "random"]: + for ens in [None, 500]: + if ens: ens_string = ", with existing bike network" + else: ens_string = "" + print("\n" + "Exporting " + seed_point_type + ", " + ranking + ens_string) + gbn.growbikenet( + city_name=nominatimstring, + ranking=ranking, + seed_point_type=seed_point_type, + export_data=True, + export_plots=False, + export_video=False, + export_file_format=export_file_format, + existing_network_spacing=ens, + ) diff --git a/examples/mwe.py b/examples/mwe.py index 42058f7..5edb738 100644 --- a/examples/mwe.py +++ b/examples/mwe.py @@ -3,14 +3,14 @@ import growbikenet as gbn a_edges = gbn.growbikenet( - city_name="Bath", - proj_crs="3857", + city_name="Oelde", ranking="betweenness_centrality", - existing_network_spacing=600, + existing_network_spacing=None, export_file_format="gpkg", export_data=True, export_plots=False, export_video=False, + allow_edge_overlaps=False, ) # data is saved in current working directory diff --git a/growbikenet/functions.py b/growbikenet/functions.py index e600d14..f018890 100644 --- a/growbikenet/functions.py +++ b/growbikenet/functions.py @@ -5,7 +5,7 @@ import osmnx as ox from scipy.spatial import Delaunay from shapely.prepared import prep -from shapely.geometry import Point +from shapely.geometry import Point, MultiLineString def intersects_properly(geom1, geom2): @@ -28,7 +28,7 @@ def intersects_properly(geom1, geom2): return geom1.intersects(geom2) and not geom1.touches(geom2) -def prepare_network(city_name, proj_crs, network_type='all', custom_filter=None, retain_all=True): +def prepare_network(city_name, proj_crs, network_type='all_public', custom_filter=None, retain_all=True): """Download and prepare a street network from OSM via OSMnx Downloads a network with a given network_type and custom_filter using ox.graph_from_place. Then, stores the undirected OSM data in gdfs and projects using proj_crs. @@ -550,6 +550,30 @@ def create_delaunay_edges(nodes_gdf): return df +def remove_edge_overlaps(edges_in): + """In the grown network, remove edge overlaps stepwise + + Parameters + ---------- + edges_in: geopandas.geodataframe.GeoDataFrame + The grown bike network, in a projected coordinate reference system + + Returns + ------- + edges_out: geopandas.geodataframe.GeoDataFrame + The grown bike network without edge overlaps, in a projected coordinate reference system + """ + edges_out = edges_in.copy() + grown_net = MultiLineString() + for row in edges_in.itertuples(): + grown_net_new = grown_net | row.geometry # Union + if grown_net_new.length > grown_net.length: + edges_out.loc[row.Index, ['geometry']] = grown_net_new - grown_net # Difference + else: # There was nothing added, so we delete the row + edges_out.drop(index=row.Index, inplace=True) + grown_net = grown_net_new + return edges_out + def df_from_graph(A, method): """Create a dataframe from an input graph @@ -558,7 +582,7 @@ def df_from_graph(A, method): A: networkx.graph Graph created from triangulation edge list method: str - Method used to rank edges. Must be 'betweenness_centrality' (default), 'closeness_centrality', or 'all'. If 'all', will also add a random ranking. + Method used to rank edges. Must be 'betweenness_centrality' (default), 'closeness_centrality', or 'random'. Returns ------- @@ -566,29 +590,23 @@ def df_from_graph(A, method): Dataframe with source and target information for each edge, as well as edge attributes as columns """ - if method == "all": + if method != "random": attrs = { edge: { - "betweenness_centrality": data.get("betweenness_centrality"), - "closeness_centrality": data.get("closeness_centrality"), + method: data.get(method), "geometry": data.get("geometry"), } for edge, data in A.edges.items() } - df = pd.DataFrame.from_dict( - attrs, - orient="index", - columns=["betweenness_centrality", "closeness_centrality", "geometry"], - ) + df = pd.DataFrame.from_dict(attrs, orient="index", columns=[method, "geometry"]) else: attrs = { edge: { - method: data.get(method), "geometry": data.get("geometry"), } for edge, data in A.edges.items() } - df = pd.DataFrame.from_dict(attrs, orient="index", columns=[method, "geometry"]) + df = pd.DataFrame.from_dict(attrs, orient="index", columns=["geometry"]) df["node_tuple"] = df.index df["source"] = [t[0] for t in df.node_tuple] df["target"] = [t[1] for t in df.node_tuple] @@ -604,27 +622,26 @@ def rank_df(df, method): df: pandas.DataFrame Dataframe with source and target information for each edge, as well as edge attributes as columns method: str - Method used to rank edges. Must be 'betweenness_centrality' (default), 'closeness_centrality', or 'all'. If 'all', will also add a random ranking. + Method used to rank edges. Must be 'betweenness_centrality' (default), 'closeness_centrality', or 'random'. Results ------- df: pandas.DataFrame Dataframe sorted by specified ranking method. """ - if method == "all": - for m in ["betweenness_centrality", "closeness_centrality"]: - df = df.sort_values(by=m, ascending=False) - df.reset_index(drop=True, inplace=True) - df["ordering_" + m] = ( - df.index - ) # ranking is the order of appearance in the method's ranking + if method == "random": # ranking is random df["ordering_random"] = np.random.permutation(np.arange(df.shape[0])) - else: + df = df.sort_values(by="ordering_random", ascending=False) + df.reset_index(drop=True, inplace=True) + df["ordering_" + method] = ( + df.index + ) + else: # ranking is the order of appearance in the method's ranking df = df.sort_values(by=method, ascending=False) df.reset_index(drop=True, inplace=True) df["ordering_" + method] = ( df.index - ) # ranking is the order of appearance in the method's ranking + ) return df diff --git a/growbikenet/growbikenet.py b/growbikenet/growbikenet.py index d2305d5..ea624c0 100644 --- a/growbikenet/growbikenet.py +++ b/growbikenet/growbikenet.py @@ -19,6 +19,7 @@ prepare_network, update_with_existing_bike_network, update_seed_points_with_existing_bike_network, + remove_edge_overlaps, ) from growbikenet.visualizations import make_video, create_plots @@ -36,6 +37,7 @@ def growbikenet( export_file_format="geojson", export_plots=False, export_video=False, + allow_edge_overlaps=False, ): """Creates a list of edges ordered by a specified ranking method. @@ -48,7 +50,7 @@ def growbikenet( proj_crs : str, default '3857' Coordinate reference system that is used to project osm data. Default is '3857' (WGS 84 / Pseudo-Mercator) ranking : str, default 'betweenness_centrality' - Method used to rank edges. Must be 'betweenness_centrality' (default), 'closeness_centrality', or 'all'. If 'all', will also add a random ranking. + Method used to rank edges. Must be 'betweenness_centrality' (default), 'closeness_centrality', or 'random'. seed_point_type : str, optional, default 'grid' If set to 'grid', creates a square grid If set to 'rail', uses rail stations @@ -68,6 +70,8 @@ def growbikenet( If set to True, plots will be saved to a file export_video : bool, optional, default False If set to True, video will be saved to a file (only possible if export_plots is set to True) + allow_edge_overlaps : bool, default False + If set to False, removes edge overlaps in consecutive growth stages. In this case, growth stages that do not add anything new are deleted. Returns ------- @@ -87,9 +91,9 @@ def growbikenet( raise TypeError("proj_crs must be a string") if type(ranking) is not str: raise TypeError("ranking must be a string") - if ranking not in ["betweenness_centrality", "closeness_centrality", "all"]: + if ranking not in ["betweenness_centrality", "closeness_centrality", "random"]: raise ValueError( - "ranking must be either 'betweenness_centrality', 'closeness_centrality', or 'all'" + "ranking must be either 'betweenness_centrality', 'closeness_centrality', or 'random'" ) if seed_point_type != "grid" and seed_point_type != "rail": raise ValueError("seed_point_type must be 'grid' or 'rail'") @@ -135,7 +139,7 @@ def growbikenet( # Due to retain_all=False, this fetches the largest connected component nodes, edges, g_undir = prepare_network(city_name, proj_crs, network_type='all_public', retain_all=False) - if existing_network_spacing: + if existing_network_spacing: # TO DO: Check for empty bike infra! nodes, edges, g_undir, nodes_exnw, edges_exnw = update_with_existing_bike_network(city_name, proj_crs, g_undir) ### Create seed points @@ -164,9 +168,9 @@ def growbikenet( if existing_network_spacing: seed_points_snapped = update_seed_points_with_existing_bike_network(seed_points_snapped, nodes_exnw, existing_network_spacing, proj_crs) - # Abort if only 0 or 1 seed points - if len(seed_points_snapped) < 2: - raise RuntimeError("Found less than 2 seed points, but more are needed.") + # Abort if less than 3 seed points. Delaunay needs at least 3. + if len(seed_points_snapped) < 3: + raise RuntimeError("Found less than 3 seed points, but more are needed.") ### Triangulate # Triangulation is calculated for the abstract network, but metrics (betweenness, closeness) are calculated for the routed network accounting for lengths. @@ -199,13 +203,13 @@ def growbikenet( ### Compute edge attributes print("Computing edge attributes..") - if ranking == "betweenness_centrality" or ranking == "all": + if ranking == "betweenness_centrality": # Add betweenness attributes to edges bc_values = nx.edge_betweenness_centrality( A, weight="distance", normalized=True ) nx.set_edge_attributes(A, bc_values, name="betweenness_centrality") - if ranking == "closeness_centrality" or ranking == "all": + elif ranking == "closeness_centrality": # Add closeness attributes to nodes and edges cc_values_nodes = nx.closeness_centrality(A, distance="distance") nx.set_node_attributes(A, cc_values_nodes, name="closeness_centrality") @@ -222,6 +226,27 @@ def growbikenet( a_edges = gpd.GeoDataFrame(a_edges, crs=proj_crs, geometry="geometry") + # Add existing bike network on top, https://stackoverflow.com/a/43408736 + if existing_network_spacing: + bikenet = gpd.GeoDataFrame({c: None for c in a_edges.columns}, index=[-1], crs=proj_crs) + bikenet.loc[-1, 'geometry'] = gpd.GeoSeries(edges_exnw.geometry).union_all() + a_edges.loc[-1] = bikenet.loc[-1] + a_edges.index = a_edges.index+1 + a_edges.sort_index(inplace=True) + a_edges.crs = proj_crs + + # Remove edge overlaps + if not allow_edge_overlaps: + a_edges = remove_edge_overlaps(a_edges) + overlap_string = "" + else: + overlap_string = "_overlap" + + # Add lengths and cumulative lengths, rounded to integer meters + a_edges['length'] = a_edges.geometry.length + a_edges['length_cumulative'] = a_edges.geometry.length.cumsum() + a_edges = a_edges.astype({'length': int, 'length_cumulative': int}) + # Generate export data filename if export_data or export_plots or export_video: os.makedirs("./results/", exist_ok=True) @@ -229,8 +254,12 @@ def growbikenet( city_string = city_name else: city_string = export_data_slug + if existing_network_spacing: + exnw_string = "_with-bikenw" + else: + exnw_string = "" export_data_filename = ( - slugify(city_string) + "-" + ranking + "-" + seed_point_type + "." + export_file_format + slugify(city_string) + "-" + ranking + "-" + seed_point_type + overlap_string + exnw_string + "." + export_file_format ) # Save to file @@ -250,7 +279,11 @@ def growbikenet( seed_points_snapped.to_file("./results/"+slugify(city_string)+"-"+seed_point_type+".geojson", driver="GeoJSON") city_boundary.to_file("./results/"+slugify(city_string)+"-city_boundary.geojson", driver="GeoJSON") elif export_file_format == "gpkg": - a_edges.to_file("./results/"+export_data_filename, driver="GPKG", layer="Bike network") + if existing_network_spacing: + a_edges.iloc[[0]].to_file("./results/"+export_data_filename, driver="GPKG", layer="Existing bike network") + a_edges.iloc[1:-1].to_file("./results/"+export_data_filename, driver="GPKG", layer="Grown bike network", append=True) + else: + a_edges.to_file("./results/"+export_data_filename, driver="GPKG", layer="Grown bike network") seed_points_snapped.to_file("./results/"+export_data_filename, driver="GPKG", layer="Seed points", append=True) city_boundary.to_file("./results/"+export_data_filename, driver="GPKG", layer="City boundary", append=True) @@ -269,26 +302,20 @@ def growbikenet( # Define linewidths lws = {"street": 0.75, "bike": 2} - if ranking == "all": - ranking_list = ["betweenness_centrality", "closeness_centrality", "random"] - else: - ranking_list = [ranking] - for ranking_this in ranking_list: - os.makedirs("./results/plots/ordering_"+ranking_this+"/", exist_ok=True) - create_plots( - routed_edges_gdf, - seed_points_snapped, - streetcolor, - edgecolor, - seedcolor, - lws, - ranking_this, - ) + os.makedirs("./results/plots/ordering_"+ranking+"/", exist_ok=True) + create_plots( + routed_edges_gdf, + seed_points_snapped, + streetcolor, + edgecolor, + seedcolor, + lws, + ranking, + ) if export_video: - for ranking_this in ranking_list: - print("Generating video..") - os.makedirs("./results/plots/ordering_"+ranking_this+"/video/", exist_ok=True) - make_video(img_folder_name="./results/plots/ordering_"+ranking_this+"/", fps=5) + print("Generating video..") + os.makedirs("./results/plots/ordering_"+ranking+"/video/", exist_ok=True) + make_video(img_folder_name="./results/plots/ordering_"+ranking+"/", fps=5) return a_edges diff --git a/growbikenet/visualizations.py b/growbikenet/visualizations.py index 606862a..68da98c 100644 --- a/growbikenet/visualizations.py +++ b/growbikenet/visualizations.py @@ -9,7 +9,7 @@ def make_video( img_folder_name, # folder where imgs are stored - fps=1, # files per second + fps=5, # files per second ): list_of_filenames = glob.glob(f"{img_folder_name}/*.png") # list of filenames @@ -63,30 +63,25 @@ def make_video( def create_plots( routed_edges_gdf, seed_points_snapped, streetcolor, edgecolor, seedcolor, lws, ranking ): - if ranking == "all": - ranking_list = ["betweenness_centrality", "closeness_centrality", "random"] - else: - ranking_list = [ranking] - for ranking_this in ranking_list: - for ordering in sorted(routed_edges_gdf["ordering_"+ranking_this].unique()): - fig, ax = plt.subplots(1, 1, figsize=(10, 10)) + for ordering in sorted(routed_edges_gdf["ordering_"+ranking].unique()): + fig, ax = plt.subplots(1, 1, figsize=(10, 10)) - # first, plot street network as "base line" - routed_edges_gdf.plot(ax=ax, color=streetcolor, lw=lws["street"], zorder=0) + # first, plot street network as "base line" + routed_edges_gdf.plot(ax=ax, color=streetcolor, lw=lws["street"], zorder=0) - # plot all edges up to current rank + # plot all edges up to current rank - routed_edges_gdf[routed_edges_gdf["ordering_"+ranking_this] <= ordering].plot( - ax=ax, color=edgecolor, lw=lws["bike"], zorder=1 - ) + routed_edges_gdf[routed_edges_gdf["ordering_"+ranking] <= ordering].plot( + ax=ax, color=edgecolor, lw=lws["bike"], zorder=1 + ) - seed_points_snapped.plot(ax=ax, color=seedcolor, zorder=2) + seed_points_snapped.plot(ax=ax, color=seedcolor, zorder=2) - ax.set_axis_off() + ax.set_axis_off() - plot_id = "{:03d}".format(ordering) # format plot ID with leading zeros + plot_id = "{:03d}".format(ordering) # format plot ID with leading zeros - fig.savefig(f"./results/plots/ordering_{ranking_this}/{plot_id}.png", dpi=150, bbox_inches='tight') + fig.savefig(f"./results/plots/ordering_{ranking}/{plot_id}.png", dpi=150, bbox_inches='tight') - plt.close() + plt.close() diff --git a/tests/test_data/oelde_growbikenet.gpkg b/tests/test_data/oelde_growbikenet.gpkg index 7090f6e..232c1a2 100644 Binary files a/tests/test_data/oelde_growbikenet.gpkg and b/tests/test_data/oelde_growbikenet.gpkg differ diff --git a/tests/test_main.py b/tests/test_main.py index 42ff0fa..3d7ba52 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,7 +5,7 @@ @pytest.fixture def create_validation_gdf(): - gdf = gpd.read_file("./tests/test_data/oelde_growbikenet.gpkg") + gdf = gpd.read_file("./tests/test_data/oelde_growbikenet.gpkg", layer='Grown bike network') return gdf @@ -14,7 +14,7 @@ def test_growbikenet(create_validation_gdf): growbikenet( city_name="Oelde", proj_crs="3857", - ranking="all", + ranking="betweenness_centrality", export_data=False, export_plots=False, export_video=False,