diff --git a/docs/source/mwe.ipynb b/docs/source/mwe.ipynb index 185941c..072b28a 100644 --- a/docs/source/mwe.ipynb +++ b/docs/source/mwe.ipynb @@ -590,7 +590,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.7" + "version": "3.14.8" } }, "nbformat": 4, diff --git a/docs/source/usage_01_gap_identification.ipynb b/docs/source/usage_01_gap_identification.ipynb index 4b0b85d..7108010 100644 --- a/docs/source/usage_01_gap_identification.ipynb +++ b/docs/source/usage_01_gap_identification.ipynb @@ -28,7 +28,7 @@ "tags": [] }, "source": [ - "**Parameters covered**: `numgaps`, `radius`, `mingap`, `maxgap`" + "**Parameters covered**: `numgaps`, `settings.radius`, `mingap`, `maxgap`" ] }, { @@ -92,7 +92,7 @@ "source": [ "FixBikeNet is based on the idea that any shortest path between two nodes on an existing bicycle network, where the entire path goes through the non-bicycle network, is a potential gap. Out of these potentially millions of gaps in a city, FixBikeNet identifies those that are most important to close in terms of providing the most cycled meters (per gap meter). It ranks the identified gaps and selects the top `numgaps` - the first parameter for running FixBikeNet. The ranking happens through betweenness centrality, a proxy for cyclist flow. This measure assumes that cyclists move from each intersection to each other intersection in the city.\n", "\n", - "The assumption of such a global all-to-all flow is relaxed in FixBikeNet to only consider local betweenness flows within a `radius` - the second parameter. This relaxation is done for several reasons: \n", + "The assumption of such a global all-to-all flow is relaxed in FixBikeNet to only consider local betweenness flows within a `settings.radius` (that is loaded from `fbn.settings`). This relaxation is done for several reasons: \n", "1) Typical cyclists are usually not traversing the whole city\n", "2) global betweenness is biased towards the city center while local betweenness allows identifying gaps on neighborhood levels\n", "3) calculations are much more effective, especially for large cities." diff --git a/examples/development.py b/examples/development.py index b42accc..6187d23 100644 --- a/examples/development.py +++ b/examples/development.py @@ -21,10 +21,10 @@ # }, # ) +fbn.settings.radius = 1000 fbn.fixbikenet( city_query="Frederiksberg Municipality", - radius = 1000, mingap = 0, maxgap = 100, numgaps = 20, diff --git a/fixbikenet/fixbikenet.py b/fixbikenet/fixbikenet.py index c514aa0..489fd3d 100644 --- a/fixbikenet/fixbikenet.py +++ b/fixbikenet/fixbikenet.py @@ -47,7 +47,6 @@ def fixbikenet( city_query, - radius = 2500, mingap = 20, maxgap = 800, numgaps = 50, @@ -56,26 +55,34 @@ def fixbikenet( export_plot=False, import_files={}, ): - """Find gaps in bicycle networks and return the `numgaps` that are the most important to fill. + """Find gaps in bicycle networks and return the `numgaps` that are the most + important to fill. Parameters ---------- city_query : str - name of the city that the analysis should be performed on - radius : int, default 2500 - cut-off length for computation of local betweenness centrality, in meters + Name of the city that the analysis should be performed on. mingap : int, default 20 - Minimum distance between node pairs to be considered as a potential gap, in meters. + Minimum distance between node pairs to be considered as a potential + gap, in meters. maxgap : int, default 800 - maximum distance between node pairs to be considered as a potential gap, in meters + Maximum distance between node pairs to be considered as a potential + gap, in meters. numgaps : int, default 50 Number of gaps to find. - export_data : bool, optional, default True - 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 + export_data : bool, default True + If set to True, data will be saved to a file. The filename is + [slug].[settings.export_file_format], where slug is a string id made + out of `city_query` (or `city_id` if set). city_id : str | None, default None - If set, the slugified city_id is used in the filename of the data export. For example, a city_id "Athens" will slugify into "athens" in filenames. If set to None, the slugified city_query is used in the filename of the data export. It is useful to set a city_id for cities where the city_query is not the city name, for example to set for a city_query "Municipality of Athens" the city_id to "Athens". + If set, the slugified `city_id` is used in the filename of the data + export. For example, a `city_id` "Athens" will slugify into "athens" + in filenames. If set to None, the slugified `city_query` is used in the + filename of the data export. It is useful to set a `city_id` for cities + where the `city_query` is not the city name, for example to set for a + `city_query` "Municipality of Athens" the `city_id` to "Athens". export_plot : bool, optional, default False - If set to True, plot will be saved to a file + If set to True, plot will be saved to a file. import_files: dict, default {} The following key:value entries can be set: @@ -104,7 +111,7 @@ def fixbikenet( Returns ------- gaps_ordered : geopandas.geodataframe.GeoDataFrame - ordered geodataframe with the `numgaps` most important gaps to fill + Ordered geodataframe with the `numgaps` most important gaps to fill. References ---------- @@ -115,7 +122,7 @@ def fixbikenet( starttime = time.time() np.random.seed(settings.random_seed) # Set random number generator seed for reproducibility setting_was_auto = _validate_settings() - import_files = _validate_parameters(city_query, radius, mingap, maxgap, export_data, import_files) + import_files = _validate_parameters(city_query, mingap, maxgap, export_data, import_files) _print_header(city_query) ### Import data files @@ -187,7 +194,7 @@ def fixbikenet( found_gaps, found_gaps_nsp = find_actual_gaps(G, potential_gaps, mingap) # calculating local betweenness score dependent on radius - ebc = compute_local_betweenness_centrality(G, nodes_gdf, radius) + ebc = compute_local_betweenness_centrality(G, nodes_gdf) G_lengths = nx.get_edge_attributes(G, "length") nx.set_edge_attributes(G, G_lengths, "distance") diff --git a/fixbikenet/functions.py b/fixbikenet/functions.py index 0db08c3..15061a7 100644 --- a/fixbikenet/functions.py +++ b/fixbikenet/functions.py @@ -25,13 +25,12 @@ def _validate_parameters( city_query, - radius, mingap, maxgap, export_data, import_files, ): - """ Check if user parameter input is valid. If not, raise an exception or + """Check if user parameter input is valid. If not, raise an exception or warning. Parameters @@ -49,8 +48,6 @@ def _validate_parameters( """ if type(city_query) != str: raise TypeError("city_query must be a string") - if type(radius) != int: - raise TypeError("radius must be an integer") if type(mingap) != int: raise TypeError("mingap must be an integer") if type(maxgap) != int: @@ -71,7 +68,7 @@ def _validate_parameters( return import_files def _validate_settings(): - """ Check if user settings input is valid. If not, raise an exception or + """Check if user settings input is valid. If not, raise an exception or warning. Returns @@ -85,6 +82,8 @@ def _validate_settings(): raise TypeError("constants._CRS_CALCULATIONS must be a string") if settings.export_file_format != "geojson" and settings.export_file_format != "gpkg": raise ValueError("settings.export_file_format must be 'geojson' or 'gpkg'") + if type(settings.radius) != int or settings.radius <= 0: + raise TypeError("settings.radius must be a positive integer") setting_was_auto = {'crs_calculations': False} # Ask whether constants._CRS_CALCULATIONS was 'auto' @@ -93,7 +92,7 @@ def _validate_settings(): return setting_was_auto def _resolve_crs_calculations(gdf): - """ Resolve constants._CRS_CALCULATIONS = 'auto' + """Resolve constants._CRS_CALCULATIONS = 'auto' Parameters ---------- @@ -190,12 +189,16 @@ def download_network(city_query, network_type='drive', custom_filter=None, retai def import_network(street_network): """Import and project a street network from gpkg file - For all edges between a pair of nodes u and v there must be one edge with key 0. + For all edges between a pair of nodes u and v there must be one edge with + key 0. Parameters ---------- street_network : str - The street network will be loaded from this file. Must be a gpkg file in unprojected crs EPSG:4326 with layers nodes and edges, with the structure that a osmnx street network g has after saving its undirected version via ox.io.save_graph_geopackage(). For example: + The street network will be loaded from this file. Must be a gpkg file + in unprojected crs EPSG:4326 with layers nodes and edges, with the + structure that a osmnx street network g has after saving its undirected + version via ox.io.save_graph_geopackage(). For example: >>> g = ox.graph_from_place("Barcelona", network_type='all') >>> g = nx.MultiGraph(ox.convert.to_digraph(g)) >>> ox.io.save_graph_geopackage(g, "Barcelona_streets.gpkg") @@ -205,13 +208,13 @@ def import_network(street_network): Returns ------- nodes : geopandas.geodataframe.GeoDataFrame - Extracted OSM nodes, projected + Extracted OSM nodes, projected. edges : geopandas.geodataframe.GeoDataFrame - Extracted OSM edges, projected + Extracted OSM edges, projected. g_undir : networkx.classes.multigraph.MultiGraph - Extracted networkX graph, undirected + Extracted networkX graph, undirected. city_boundary_gdf : geopandas.geodataframe.GeoDataFrame - Convex hull of the street network + Convex hull of the street network. """ nodes = gpd.read_file(settings.import_path+street_network, layer='nodes') @@ -260,7 +263,8 @@ def assign_pbi_to_edges(g): Returns ------- g : networkx.MultiDiGraph - Simplified graph representing the street network, with added binary edge attribute "pbi". + Simplified graph representing the street network, with added binary + edge attribute "pbi". """ for edge in g.edges(keys=True): assigned_pbi = 0 @@ -278,20 +282,21 @@ def assign_pbi_to_edges(g): def bike_infra_mapping_gdf(g, edges_gdf): - """ - add binary edge attribute pbi to edges_gdf + """Add binary edge attribute pbi to edges_gdf. Parameters ---------- g : networkx.MultiDiGraph - simplified graph representing the street network, with added binary edge attribute "pbi" - edges_gdf: geopandas.GeoDataFrame - edges representing the street network + Simplified graph representing the street network, with added binary + edge attribute "pbi". + edges_gdf : geopandas.GeoDataFrame + Edges representing the street network. Returns ------- - edges_gdf: geopandas.GeoDataFrame - edges representing the street network with added binary attribute "pbi" + edges_gdf : geopandas.GeoDataFrame + Edges representing the street network with added binary attribute + "pbi". """ # Build dict of edge attribute attr_dict = { @@ -304,18 +309,20 @@ def bike_infra_mapping_gdf(g, edges_gdf): return edges_gdf def find_edges_to_drop(g): - """ - find parallel edges that have different pbi values, list the ones with pbi=0 + """Find parallel edges that have different pbi values, list the ones with + pbi=0. Parameters ---------- g : networkx.MultiDiGraph - simplified graph representing the street network, with added binary edge attribute "pbi" + Simplified graph representing the street network, with added binary + edge attribute "pbi". Returns ------- - edges_to_drop: list - unique list of edges to drop-> edges where pbi values differ and pbi value=0 gets dropped + edges_to_drop : list + Unique list of edges to drop-> edges where pbi values differ and pbi + value=0 gets dropped. """ # to find parallel edges, get all u,v tuples for which u,v,w>0 exists: uvs = [edge[:2] for edge in list(g.edges) if edge[2] > 0] # >0 includes key=1, key=2, ... @@ -372,18 +379,19 @@ def weigh_edges(G): return G def find_contact_nodes(G): - """ - find nodes that have both edges with protected and without protected bike infrastructure incident on them + """Find nodes that have both edges with protected and without protected + bike infrastructure incident on them. Parameters ---------- - G:networkx.Graph - undirected simple graph representing the street network with weighted edges + G : networkx.Graph + Undirected simple graph representing the street network with weighted + edges. Return ------ - contact_nodes: list - list of all nodes that fulfill criteria to be a contact node + contact_nodes : list + list of all nodes that fulfill criteria to be a contact node. """ contact_nodes = [] for node in G.nodes: @@ -393,22 +401,22 @@ def find_contact_nodes(G): return contact_nodes def find_potential_gaps(contact_nodes, nodes_gdf, maxgap): - """ - finds potential gaps in protected bicycle network, corresponding to two contact nodes that are within maxgap euclidean distance of each other + """Find potential gaps in protected bicycle network, corresponding to two + contact nodes that are within maxgap euclidean distance of each other. Parameters ---------- contact_nodes : list - list of all nodes that fulfill criteria to be a contact node + list of all nodes that fulfill criteria to be a contact node. nodes_gdf : geopandas.GeoDataFrame - all nodes in street network + All nodes in street network. maxgap : int - user defined maximal euclidean distance between two contact nodes + User defined maximal euclidean distance between two contact nodes. Returns ------- potential_gaps : list - all unique potential gaps in protected bicycle network + All unique potential gaps in protected bicycle network. """ potential_gaps = [] nodes_gdf['osmid'] = nodes_gdf.index @@ -440,7 +448,8 @@ def find_actual_gaps(G, potential_gaps, mingap): potential_gaps : list All unique potential gaps in protected bicycle network. mingap : int - Minimum distance between node pairs to be considered as a potential gap, in meters. + Minimum distance between node pairs to be considered as a potential + gap, in meters. Returns ------- @@ -623,23 +632,22 @@ def _get_weighted_distances(B, num_types, inverted=False): return dist_weighted_by_types_dict -def compute_local_betweenness_centrality(G, nodes_gdf, radius): - """ - computes weighted betweenness centrality for paths within radius +def compute_local_betweenness_centrality(G, nodes_gdf): + """Compute weighted betweenness centrality for paths within + `settings.radius`. Parameters ---------- - G: networkx.Graph - undirected simple graph representing the street network with weighted edges - nodes_gdf: geopandas.GeoDataFrame - all nodes in street network - radius: int - maximum length of path for betweennessn centrality calculation, set by user + G : networkx.Graph + Undirected simple graph representing the street network with weighted + edges. + nodes_gdf : geopandas.GeoDataFrame + All nodes in street network. Returns ------- - ebc: dict - local betweenness centrality values for all edges in network + ebc : dict + Local betweenness centrality values for all edges in network. """ # set current ebc value of all G edges to 0 for edge in G.edges: @@ -648,7 +656,7 @@ def compute_local_betweenness_centrality(G, nodes_gdf, radius): # create dict that will be updated at each step ebc = nx.get_edge_attributes(G, "ebc") - # For each node, compute "local" ebc (buffered with radius!) + # For each node, compute "local" ebc (buffered with settings.radius!) # For computational reasons, only over a subset of randomly drawn # constants._BETWEENNESS_RANDOM_NODES nodes random_nodes = np.random.choice(list(G.nodes), size=constants._BETWEENNESS_RANDOM_NODES) @@ -661,7 +669,7 @@ def compute_local_betweenness_centrality(G, nodes_gdf, radius): bar_format='{l_bar}{bar:'+str(constants._PROGRESS_BAR_LENGTH-7)+'}{r_bar}', disable=settings.silent, ): - node_buffer = nodes_gdf.loc[node, "geometry"].buffer(radius) + node_buffer = nodes_gdf.loc[node, "geometry"].buffer(settings.radius) q = nodes_gdf.sindex.query(node_buffer, predicate="intersects") neighbours = list(nodes_gdf.iloc[q].index) local_ebc = nx.edge_betweenness_centrality_subset( @@ -693,7 +701,7 @@ def rank_gaps_by_b(found_gaps_nsp, G, ebc): Returns ------- Bs: list - list of values of b for all gaps in protected bicycle network + list of values of b for all gaps in protected bicycle network. """ Bs = [] for nodelist in tqdm( @@ -715,21 +723,21 @@ def rank_gaps_by_b(found_gaps_nsp, G, ebc): return Bs def get_correct_edgetuples(edge_gdf, nodelist): - """ - helper function that maps a node list (output of nx.shortest_paths) - to the correct set of edge tuples that can be used for INDEXING THE EDGE GDF + """Helper function that maps a node list (output of nx.shortest_paths) + to the correct set of edge tuples that can be used for INDEXING THE EDGE + GDF. Parameters ---------- - edge_gdf: geopandas.geodataframe.GeoDataFrame - The street network, in a projected coordinate reference system - nodelist: list - A list of nodes that make up source and targets of edges + edge_gdf : geopandas.geodataframe.GeoDataFrame + The street network, in a projected coordinate reference system. + nodelist : list + A list of nodes that make up source and targets of edges. Returns ------- - edgelist_final: list - List of edge tuples that can be used for INDEXING THE EDGE GDF + edgelist_final : list + List of edge tuples that can be used for INDEXING THE EDGE GDF/ """ edgelist_prelim = zip(nodelist, nodelist[1:]) edgelist_final = [] @@ -743,17 +751,19 @@ def get_correct_edgetuples(edge_gdf, nodelist): def create_gdf_with_geoms(df, edges): """ + Parameters ---------- - df: pandas.DataFrame - Dataframe with path nodes and path edges - edges: geopandas.GeoDataFrame - The street network, in a projected coordinate reference system + df : pandas.DataFrame + Dataframe with path nodes and path edges. + edges : geopandas.GeoDataFrame + The street network, in a projected coordinate reference system. Returns ------- - gdf: geopandas.GeoDataFrame - projected GeoDataFrame with path nodes and path edges and merged geometries + gdf : geopandas.GeoDataFrame + Projected GeoDataFrame with path nodes and path edges and merged + geometries. """ # get geometry by merging all geoms from edge gdf df = df.copy() @@ -768,15 +778,17 @@ def create_gdf_with_geoms(df, edges): def graph_nodes_to_gdf(G): """ + Parameters ---------- - G: networkx.Graph - undirected simple graph representing the street network with weighted edges + G : networkx.Graph + Undirected simple graph representing the street network with weighted + edges. Returns ------- - nodes_gdf: geopandas.GeoDataFrame - geodataframe with nodes from G + nodes_gdf : geopandas.GeoDataFrame + geodataframe with nodes from G. """ rows = [] for n, data in G.nodes(data=True): @@ -795,15 +807,17 @@ def graph_nodes_to_gdf(G): def graph_edges_to_gdf(G): """ + Parameters ---------- - G: networkx.Graph - undirected simple graph representing the street network with weighted edges + G : networkx.Graph + Undirected simple graph representing the street network with weighted + edges. Returns ------- - edges_gdf: geopandas.GeoDataFrame - geodataframe with edges from G, including edge attributes + edges_gdf : geopandas.GeoDataFrame + geodataframe with edges from G, including edge attributes. """ rows = [] for u, v, data in G.edges(data=True): @@ -831,22 +845,21 @@ def graph_edges_to_gdf(G): return edges_gdf def compute_benefit_metric(comp, node_path, ebc): - """ - computes Benefit metric B for edge in connected component of edges. + """Compute benefit metric B for edge in connected component of edges. Parameters ---------- comp : networkx.Graph - connected component of edges + Connected component of edges. node_path : list - list of nodes on path - ebc: dict - local betweenness centrality values for all edges in network + list of nodes on path. + ebc : dict + Local betweenness centrality values for all edges in network. Returns ------- - B: float - Benefit metric B for edge + B : float + Benefit metric B for edge. """ edgelist = [tuple(sorted(z)) for z in zip(node_path, node_path[1:])] lengths = np.array([comp.edges[edge]["length"] for edge in edgelist]) @@ -857,22 +870,27 @@ def compute_benefit_metric(comp, node_path, ebc): def gap_declustering(gaps_df, G, ebc, contact_nodes, mingap): """ + Parameters ---------- gaps_df : pd.DataFrame - Dataframe containing gaps in protected bicycle network + Dataframe containing gaps in protected bicycle network. G : networkx.Graph - undirected simple graph representing the street network with weighted edges + undirected simple graph representing the street network with weighted + edges. ebc : dict - local betweenness centrality values for all edges in network + local betweenness centrality values for all edges in network. contact_nodes : list + List of contact nodes. mingap : int - Minimum distance between node pairs to be considered as a potential gap, in meters. + Minimum distance between node pairs to be considered as a potential + gap, in meters. Returns ------- result : pd.DataFrame - Dataframe with node path for gaps and the newly calculated benefit metric + Dataframe with node path for gaps and the newly calculated benefit + metric. """ C = nx.Graph() C.graph.update(G.graph) @@ -987,20 +1005,22 @@ def gap_declustering(gaps_df, G, ebc, contact_nodes, mingap): return result def slugify(s): - """Slugify a string + """Slugify a string. Source: https://github.com/Chalarangelo/30-seconds-of-code/blob/master/content/snippets/python/s/slugify.md - Note: A clean global solution would be using unidecode, but we do not want extra dependencies for this. We assume European city names in latin alphabet, some special letters like Hungarian long ö already mapped. + Note: A clean global solution would be using unidecode, but we do not want + extra dependencies for this. We assume European city names in latin + alphabet, some special letters like Hungarian long ö already mapped. Parameters ---------- s : str - String to slufigy + String to slufigy. Returns ------- s : str - Slugified string + Slugified string. """ s = s.lower().strip() s = re.sub(r'[\s-]+', '', s) # Remove white spaces, - diff --git a/fixbikenet/settings.py b/fixbikenet/settings.py index bca011e..c045692 100644 --- a/fixbikenet/settings.py +++ b/fixbikenet/settings.py @@ -13,6 +13,8 @@ meters. import_path : str Path to import files (as defined in `fixbikenet`'s import_files parameter). +radius : int, default 2500 + Cut-off length for computation of local betweenness centrality, in meters. random_seed : int, default 43 Random number generator seed for reproducibility silent : bool, default False @@ -25,5 +27,6 @@ import_point_data_snap_distance = 100 import_path = "./" +radius = 2500 random_seed = 43 # off-by-one error silent = False \ No newline at end of file diff --git a/tests/test_fixbikenet.py b/tests/test_fixbikenet.py index bf06e39..dc5e9e3 100644 --- a/tests/test_fixbikenet.py +++ b/tests/test_fixbikenet.py @@ -17,9 +17,9 @@ def test_fixbikenet_case_success_offline1(validation_gdf_frederiksberg): """Verify that the offline version of fixbikenet works as intended. """ fbn.constants._ROUTING_PENALTY = {0: 1.5, 1: 1} + fbn.settings.radius = 1000 gaps_ordered = fbn.fixbikenet( city_query="Frederiksberg", - radius = 1000, mingap = 0, maxgap = 100, numgaps = 20,