From 4b3664627a272d58e19a809c1397d8894b3d42a7 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 17 Apr 2024 23:14:19 +0200 Subject: [PATCH 01/16] Add conditional tolerance for intersection consolidation --- osmnx/simplification.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 8d2675fc0..866d8c3c3 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -5,6 +5,7 @@ import geopandas as gpd import networkx as nx +import numpy as np from shapely.geometry import LineString from shapely.geometry import MultiPolygon from shapely.geometry import Point @@ -405,7 +406,7 @@ def simplify_graph( # noqa: C901 def consolidate_intersections( - G, tolerance=10, rebuild_graph=True, dead_ends=False, reconnect_edges=True + G, tolerance=10, rebuild_graph=True, dead_ends=False, reconnect_edges=True, tolerance_column=None ): """ Consolidate intersections comprising clusters of nearby nodes. @@ -418,6 +419,11 @@ def consolidate_intersections( Note the tolerance represents a per-node buffering radius: for example, to consolidate nodes within 10 meters of each other, use tolerance=5. + It's also possible to specify difference tolerances for each node. This can + be done by adding an attribute to each node with contains the tolerance, and + passing the name of that argument as tolerance_column argument. If a node + does not have a value in the tolerance_column, the default tolerance is used. + When rebuild_graph=False, it uses a purely geometrical (and relatively fast) algorithm to identify "geometrically close" nodes, merge them, and return just the merged intersections' centroids. When rebuild_graph=True, @@ -457,6 +463,10 @@ def consolidate_intersections( edge length attributes; if False, returned graph has no edges (which is faster if you just need topologically consolidated intersection counts). + tolerance_column : str, optional + The name of the column in the nodes GeoDataFrame that contains + individual tolerance values for each node. If None, the default + tolerance is used for all nodes. Returns ------- @@ -481,7 +491,7 @@ def consolidate_intersections( return G # otherwise - return _consolidate_intersections_rebuild_graph(G, tolerance, reconnect_edges) + return _consolidate_intersections_rebuild_graph(G, tolerance, reconnect_edges, tolerance_column) # otherwise, if we're not rebuilding the graph if not G: @@ -489,10 +499,10 @@ def consolidate_intersections( return gpd.GeoSeries(crs=G.graph["crs"]) # otherwise, return the centroids of the merged intersection polygons - return _merge_nodes_geometric(G, tolerance).centroid + return _merge_nodes_geometric(G, tolerance, tolerance_column).centroid -def _merge_nodes_geometric(G, tolerance): +def _merge_nodes_geometric(G, tolerance, tolerance_column): """ Geometrically merge nodes within some distance of each other. @@ -509,15 +519,25 @@ def _merge_nodes_geometric(G, tolerance): merged : GeoSeries the merged overlapping polygons of the buffered nodes """ - # buffer nodes GeoSeries then get unary union to merge overlaps - merged = convert.graph_to_gdfs(G, edges=False)["geometry"].buffer(tolerance).unary_union + gdf_nodes = convert.graph_to_gdfs(G, edges=False) + + if tolerance_column and tolerance_column in gdf_nodes.columns: + # Use the values from the tolerance_column as an array for buffering + buffer_distances = gdf_nodes[tolerance_column].values + # If a node does not have a value in the tolerance_column, use the default tolerance + buffer_distances[np.isnan(buffer_distances)] = tolerance + # Buffer nodes to the specified distances and merge them + merged = gdf_nodes['geometry'].buffer(distance=buffer_distances).unary_union + else: + # Use the default tolerance for all nodes + merged = gdf_nodes['geometry'].buffer(distance=tolerance).unary_union # if only a single node results, make it iterable to convert to GeoSeries merged = MultiPolygon([merged]) if isinstance(merged, Polygon) else merged return gpd.GeoSeries(merged.geoms, crs=G.graph["crs"]) -def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=True): +def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=True, tolerance_column=None): """ Consolidate intersections comprising clusters of nearby nodes. @@ -556,7 +576,7 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr # STEP 1 # buffer nodes to passed-in distance and merge overlaps. turn merged nodes # into gdf and get centroids of each cluster as x, y - node_clusters = gpd.GeoDataFrame(geometry=_merge_nodes_geometric(G, tolerance)) + node_clusters = gpd.GeoDataFrame(geometry=_merge_nodes_geometric(G, tolerance, tolerance_column)) centroids = node_clusters.centroid node_clusters["x"] = centroids.x node_clusters["y"] = centroids.y From 272aee0cf5513ea794c0c4e3535d971f158ab616 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 18 Apr 2024 11:41:58 +0200 Subject: [PATCH 02/16] Simplify filling NaN tolerance numbers in consolidation Co-Authored-By: Martin Fleischmann --- osmnx/simplification.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 866d8c3c3..5fff96800 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -522,10 +522,8 @@ def _merge_nodes_geometric(G, tolerance, tolerance_column): gdf_nodes = convert.graph_to_gdfs(G, edges=False) if tolerance_column and tolerance_column in gdf_nodes.columns: - # Use the values from the tolerance_column as an array for buffering - buffer_distances = gdf_nodes[tolerance_column].values # If a node does not have a value in the tolerance_column, use the default tolerance - buffer_distances[np.isnan(buffer_distances)] = tolerance + buffer_distances = gdf_nodes[tolerance_column].fillna(tolerance) # Buffer nodes to the specified distances and merge them merged = gdf_nodes['geometry'].buffer(distance=buffer_distances).unary_union else: From 95395d4d09a2e73086a1e97c728cb8e3826fd306 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 18 Apr 2024 11:44:30 +0200 Subject: [PATCH 03/16] consolidation: Rename tolerance_column to tolerance_attribute --- osmnx/simplification.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 5fff96800..001233223 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -406,7 +406,7 @@ def simplify_graph( # noqa: C901 def consolidate_intersections( - G, tolerance=10, rebuild_graph=True, dead_ends=False, reconnect_edges=True, tolerance_column=None + G, tolerance=10, rebuild_graph=True, dead_ends=False, reconnect_edges=True, tolerance_attribute=None ): """ Consolidate intersections comprising clusters of nearby nodes. @@ -421,8 +421,8 @@ def consolidate_intersections( It's also possible to specify difference tolerances for each node. This can be done by adding an attribute to each node with contains the tolerance, and - passing the name of that argument as tolerance_column argument. If a node - does not have a value in the tolerance_column, the default tolerance is used. + passing the name of that argument as tolerance_attribute argument. If a node + does not have a value in the tolerance_attribute, the default tolerance is used. When rebuild_graph=False, it uses a purely geometrical (and relatively fast) algorithm to identify "geometrically close" nodes, merge them, and @@ -463,10 +463,9 @@ def consolidate_intersections( edge length attributes; if False, returned graph has no edges (which is faster if you just need topologically consolidated intersection counts). - tolerance_column : str, optional - The name of the column in the nodes GeoDataFrame that contains - individual tolerance values for each node. If None, the default - tolerance is used for all nodes. + tolerance_attribute : str, optional + The name of the attribute that contains individual tolerance values for + each node. If None, the default tolerance is used for all nodes. Returns ------- @@ -491,7 +490,7 @@ def consolidate_intersections( return G # otherwise - return _consolidate_intersections_rebuild_graph(G, tolerance, reconnect_edges, tolerance_column) + return _consolidate_intersections_rebuild_graph(G, tolerance, reconnect_edges, tolerance_attribute) # otherwise, if we're not rebuilding the graph if not G: @@ -499,10 +498,10 @@ def consolidate_intersections( return gpd.GeoSeries(crs=G.graph["crs"]) # otherwise, return the centroids of the merged intersection polygons - return _merge_nodes_geometric(G, tolerance, tolerance_column).centroid + return _merge_nodes_geometric(G, tolerance, tolerance_attribute).centroid -def _merge_nodes_geometric(G, tolerance, tolerance_column): +def _merge_nodes_geometric(G, tolerance, tolerance_attribute): """ Geometrically merge nodes within some distance of each other. @@ -521,9 +520,9 @@ def _merge_nodes_geometric(G, tolerance, tolerance_column): """ gdf_nodes = convert.graph_to_gdfs(G, edges=False) - if tolerance_column and tolerance_column in gdf_nodes.columns: - # If a node does not have a value in the tolerance_column, use the default tolerance - buffer_distances = gdf_nodes[tolerance_column].fillna(tolerance) + if tolerance_attribute and tolerance_attribute in gdf_nodes.columns: + # If a node does not have a value in the tolerance_attribute, use the default tolerance + buffer_distances = gdf_nodes[tolerance_attribute].fillna(tolerance) # Buffer nodes to the specified distances and merge them merged = gdf_nodes['geometry'].buffer(distance=buffer_distances).unary_union else: @@ -535,7 +534,7 @@ def _merge_nodes_geometric(G, tolerance, tolerance_column): return gpd.GeoSeries(merged.geoms, crs=G.graph["crs"]) -def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=True, tolerance_column=None): +def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=True, tolerance_attribute=None): """ Consolidate intersections comprising clusters of nearby nodes. @@ -574,7 +573,7 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr # STEP 1 # buffer nodes to passed-in distance and merge overlaps. turn merged nodes # into gdf and get centroids of each cluster as x, y - node_clusters = gpd.GeoDataFrame(geometry=_merge_nodes_geometric(G, tolerance, tolerance_column)) + node_clusters = gpd.GeoDataFrame(geometry=_merge_nodes_geometric(G, tolerance, tolerance_attribute)) centroids = node_clusters.centroid node_clusters["x"] = centroids.x node_clusters["y"] = centroids.y From b9ccb489fee052e86f90d7f8810288a18210aff0 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 18 Apr 2024 11:50:09 +0200 Subject: [PATCH 04/16] simplification: Fix ruff errors - Remove unused numpy import (after refactoring it wasn't necessary anymore). - Add tolerance_attribute docstring to private functions --- osmnx/simplification.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 001233223..ff36e199c 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -5,7 +5,6 @@ import geopandas as gpd import networkx as nx -import numpy as np from shapely.geometry import LineString from shapely.geometry import MultiPolygon from shapely.geometry import Point @@ -512,6 +511,9 @@ def _merge_nodes_geometric(G, tolerance, tolerance_attribute): tolerance : float buffer nodes to this distance (in graph's geometry's units) then merge overlapping polygons into a single polygon via a unary union operation + tolerance_attribute : str, optional + The name of the attribute that contains individual tolerance values for + each node. If None, the default tolerance is used for all nodes. Returns ------- @@ -563,6 +565,9 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr edge length attributes; if False, returned graph has no edges (which is faster if you just need topologically consolidated intersection counts). + tolerance_attribute : str, optional + The name of the attribute that contains individual tolerance values for + each node. If None, the default tolerance is used for all nodes. Returns ------- From 458521f99393d0fb613455b4c31566a583699a22 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 18 Apr 2024 11:57:37 +0200 Subject: [PATCH 05/16] simplification: Ruff format --- osmnx/simplification.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index ff36e199c..1df4d0c9a 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -405,7 +405,12 @@ def simplify_graph( # noqa: C901 def consolidate_intersections( - G, tolerance=10, rebuild_graph=True, dead_ends=False, reconnect_edges=True, tolerance_attribute=None + G, + tolerance=10, + rebuild_graph=True, + dead_ends=False, + reconnect_edges=True, + tolerance_attribute=None, ): """ Consolidate intersections comprising clusters of nearby nodes. @@ -489,7 +494,9 @@ def consolidate_intersections( return G # otherwise - return _consolidate_intersections_rebuild_graph(G, tolerance, reconnect_edges, tolerance_attribute) + return _consolidate_intersections_rebuild_graph( + G, tolerance, reconnect_edges, tolerance_attribute + ) # otherwise, if we're not rebuilding the graph if not G: @@ -526,17 +533,19 @@ def _merge_nodes_geometric(G, tolerance, tolerance_attribute): # If a node does not have a value in the tolerance_attribute, use the default tolerance buffer_distances = gdf_nodes[tolerance_attribute].fillna(tolerance) # Buffer nodes to the specified distances and merge them - merged = gdf_nodes['geometry'].buffer(distance=buffer_distances).unary_union + merged = gdf_nodes["geometry"].buffer(distance=buffer_distances).unary_union else: # Use the default tolerance for all nodes - merged = gdf_nodes['geometry'].buffer(distance=tolerance).unary_union + merged = gdf_nodes["geometry"].buffer(distance=tolerance).unary_union # if only a single node results, make it iterable to convert to GeoSeries merged = MultiPolygon([merged]) if isinstance(merged, Polygon) else merged return gpd.GeoSeries(merged.geoms, crs=G.graph["crs"]) -def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=True, tolerance_attribute=None): +def _consolidate_intersections_rebuild_graph( + G, tolerance=10, reconnect_edges=True, tolerance_attribute=None +): """ Consolidate intersections comprising clusters of nearby nodes. @@ -578,7 +587,9 @@ def _consolidate_intersections_rebuild_graph(G, tolerance=10, reconnect_edges=Tr # STEP 1 # buffer nodes to passed-in distance and merge overlaps. turn merged nodes # into gdf and get centroids of each cluster as x, y - node_clusters = gpd.GeoDataFrame(geometry=_merge_nodes_geometric(G, tolerance, tolerance_attribute)) + node_clusters = gpd.GeoDataFrame( + geometry=_merge_nodes_geometric(G, tolerance, tolerance_attribute) + ) centroids = node_clusters.centroid node_clusters["x"] = centroids.x node_clusters["y"] = centroids.y From 5addaa1b9306305e837411e6de203c92d813e91b Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 24 Apr 2024 19:34:09 +0200 Subject: [PATCH 06/16] Run ruff after retarget to v2 branch --- osmnx/simplification.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 0d87e8fcb..0f11c8659 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -453,7 +453,6 @@ def consolidate_intersections( node_attr_aggs: dict[str, Any] | None = None, tolerance_attribute=None, ) -> nx.MultiDiGraph | gpd.GeoSeries: - """ Consolidate intersections comprising clusters of nearby nodes. @@ -558,7 +557,9 @@ def consolidate_intersections( return _merge_nodes_geometric(G, tolerance, tolerance_attribute).centroid -def _merge_nodes_geometric(G: nx.MultiDiGraph, tolerance: float, tolerance_attribute: str | None = None) -> gpd.GeoSeries: +def _merge_nodes_geometric( + G: nx.MultiDiGraph, tolerance: float, tolerance_attribute: str | None = None +) -> gpd.GeoSeries: """ Geometrically merge nodes within some distance of each other. @@ -652,7 +653,7 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 # buffer nodes to passed-in distance and merge overlaps. turn merged nodes # into gdf and get centroids of each cluster as x, y node_clusters = gpd.GeoDataFrame( - geometry=_merge_nodes_geometric(G, tolerance, tolerance_attribute) + geometry=_merge_nodes_geometric(G, tolerance, tolerance_attribute), ) centroids = node_clusters.centroid node_clusters["x"] = centroids.x From 18d8b69f2356bd6517e0a300896664ef54cf2f4d Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Wed, 24 Apr 2024 19:37:27 +0200 Subject: [PATCH 07/16] Add missing type hint --- osmnx/simplification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 0f11c8659..e2591bd34 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -451,7 +451,7 @@ def consolidate_intersections( dead_ends: bool = False, reconnect_edges: bool = True, node_attr_aggs: dict[str, Any] | None = None, - tolerance_attribute=None, + tolerance_attribute: str | None = None, ) -> nx.MultiDiGraph | gpd.GeoSeries: """ Consolidate intersections comprising clusters of nearby nodes. @@ -558,7 +558,7 @@ def consolidate_intersections( def _merge_nodes_geometric( - G: nx.MultiDiGraph, tolerance: float, tolerance_attribute: str | None = None + G: nx.MultiDiGraph, tolerance: float, tolerance_attribute: str | None = None, ) -> gpd.GeoSeries: """ Geometrically merge nodes within some distance of each other. From 933a7e3158ba6f25ae527714a8eaa8e84fda1673 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 25 Apr 2024 09:44:11 +0200 Subject: [PATCH 08/16] consolidate_intersections: Allow tolerance to accept dictionary This commit updates the consolidate_intersections function to accept the tolerance parameter as either a float or a dictionary mapping node IDs to floats. It removes the previously suggested tolerance_attribute. --- osmnx/simplification.py | 44 ++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index e2591bd34..c9c5f0057 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -446,12 +446,11 @@ def simplify_graph( # noqa: C901, PLR0912 def consolidate_intersections( G: nx.MultiDiGraph, *, - tolerance: float = 10, + tolerance: float | dict[int, float] = 10, rebuild_graph: bool = True, dead_ends: bool = False, reconnect_edges: bool = True, node_attr_aggs: dict[str, Any] | None = None, - tolerance_attribute: str | None = None, ) -> nx.MultiDiGraph | gpd.GeoSeries: """ Consolidate intersections comprising clusters of nearby nodes. @@ -493,7 +492,8 @@ def consolidate_intersections( A projected graph. tolerance Nodes are buffered to this distance (in graph's geometry's units) and - subsequent overlaps are dissolved into a single node. + subsequent overlaps are dissolved into a single node. Can be a float + value or a dictionary mapping node IDs to individual tolerance values. rebuild_graph If True, consolidate the nodes topologically, rebuild the graph, and return as MultiDiGraph. Otherwise, consolidate the nodes geometrically @@ -513,9 +513,6 @@ def consolidate_intersections( (anything accepted as an argument by `pandas.agg`). Node attributes not in `node_attr_aggs` will contain the unique values across the merged nodes. If None, defaults to `{"elevation": numpy.mean}`. - tolerance_attribute : str, optional - The name of the attribute that contains individual tolerance values for - each node. If None, the default tolerance is used for all nodes. Returns ------- @@ -545,7 +542,6 @@ def consolidate_intersections( tolerance, reconnect_edges, node_attr_aggs, - tolerance_attribute, ) # otherwise, if we're not rebuilding the graph @@ -554,11 +550,12 @@ def consolidate_intersections( return gpd.GeoSeries(crs=G.graph["crs"]) # otherwise, return the centroids of the merged intersection polygons - return _merge_nodes_geometric(G, tolerance, tolerance_attribute).centroid + return _merge_nodes_geometric(G, tolerance).centroid def _merge_nodes_geometric( - G: nx.MultiDiGraph, tolerance: float, tolerance_attribute: str | None = None, + G: nx.MultiDiGraph, + tolerance: float | dict[int, float], ) -> gpd.GeoSeries: """ Geometrically merge nodes within some distance of each other. @@ -570,9 +567,8 @@ def _merge_nodes_geometric( tolerance Buffer nodes to this distance (in graph's geometry's units) then merge overlapping polygons into a single polygon via unary union operation. - tolerance_attribute : str, optional - The name of the attribute that contains individual tolerance values for - each node. If None, the default tolerance is used for all nodes. + Can be a float value or a dictionary mapping node IDs to individual + tolerance values. Returns ------- @@ -581,14 +577,15 @@ def _merge_nodes_geometric( """ gdf_nodes = convert.graph_to_gdfs(G, edges=False) - if tolerance_attribute and tolerance_attribute in gdf_nodes.columns: - # If a node does not have a value in the tolerance_attribute, use the default tolerance - buffer_distances = gdf_nodes[tolerance_attribute].fillna(tolerance) - # Buffer nodes to the specified distances and merge them - merged = gdf_nodes["geometry"].buffer(distance=buffer_distances).unary_union + if isinstance(tolerance, dict): + # Map tolerances to node IDs, using NaN for nodes not in the dictionary, and fill NaN with zero + buffer_distances = gdf_nodes.index.to_series().map(tolerance).fillna(0) else: # Use the default tolerance for all nodes - merged = gdf_nodes["geometry"].buffer(distance=tolerance).unary_union + buffer_distances = tolerance + + # Buffer nodes to the specified distances and merge them + merged = gdf_nodes.geometry.buffer(distance=buffer_distances).unary_union # if only a single node results, make it iterable to convert to GeoSeries merged = MultiPolygon([merged]) if isinstance(merged, Polygon) else merged @@ -597,10 +594,9 @@ def _merge_nodes_geometric( def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 G: nx.MultiDiGraph, - tolerance: float, + tolerance: float | dict[int, float], reconnect_edges: bool, # noqa: FBT001 node_attr_aggs: dict[str, Any] | None, - tolerance_attribute: str | None = None, ) -> nx.MultiDiGraph: """ Consolidate intersections comprising clusters of nearby nodes. @@ -623,7 +619,8 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 A projected graph. tolerance Nodes are buffered to this distance (in graph's geometry's units) and - subsequent overlaps are dissolved into a single node. + subsequent overlaps are dissolved into a single node. Can be a float + value or a dictionary mapping node IDs to individual tolerance values. reconnect_edges If True, reconnect edges (and their geometries) to the consolidated nodes in rebuilt graph, and update the edge length attributes. If @@ -635,9 +632,6 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 (anything accepted as an argument by `pandas.agg`). Node attributes not in `node_attr_aggs` will contain the unique values across the merged nodes. If None, defaults to `{"elevation": numpy.mean}`. - tolerance_attribute : str, optional - The name of the attribute that contains individual tolerance values for - each node. If None, the default tolerance is used for all nodes. Returns ------- @@ -653,7 +647,7 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 # buffer nodes to passed-in distance and merge overlaps. turn merged nodes # into gdf and get centroids of each cluster as x, y node_clusters = gpd.GeoDataFrame( - geometry=_merge_nodes_geometric(G, tolerance, tolerance_attribute), + geometry=_merge_nodes_geometric(G, tolerance), ) centroids = node_clusters.centroid node_clusters["x"] = centroids.x From 30cd57565a3d3aca54c0354bc45f31ee2b0df420 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 25 Apr 2024 17:00:18 +0200 Subject: [PATCH 09/16] Update geometric node merging to handle missing tolerances Update `_merge_nodes_geometric` to manage absent tolerance values by reverting to original geometries instead of creating POLYGON EMPTY. --- osmnx/simplification.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index c9c5f0057..f25767181 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -9,6 +9,7 @@ import geopandas as gpd import networkx as nx import numpy as np +import pandas as pd from shapely.geometry import LineString from shapely.geometry import MultiPolygon from shapely.geometry import Point @@ -578,14 +579,18 @@ def _merge_nodes_geometric( gdf_nodes = convert.graph_to_gdfs(G, edges=False) if isinstance(tolerance, dict): - # Map tolerances to node IDs, using NaN for nodes not in the dictionary, and fill NaN with zero - buffer_distances = gdf_nodes.index.to_series().map(tolerance).fillna(0) + # Create a Series of tolerances, reindexed to match the nodes + tolerances = pd.Series(tolerance).reindex(gdf_nodes.index) + # Buffer nodes to the specified distance + buffered_geoms = gdf_nodes.geometry.buffer(tolerances) + # Replace POLYGON EMPTY with original geometries if buffer distance was effectively zero or missing + buffered_geoms = buffered_geoms.fillna(gdf_nodes["geometry"]) else: - # Use the default tolerance for all nodes - buffer_distances = tolerance + # Buffer nodes to the specified distance + buffered_geoms = gdf_nodes.geometry.buffer(tolerance) - # Buffer nodes to the specified distances and merge them - merged = gdf_nodes.geometry.buffer(distance=buffer_distances).unary_union + # Merge overlapping geometries into a single geometry + merged = buffered_geoms.unary_union # if only a single node results, make it iterable to convert to GeoSeries merged = MultiPolygon([merged]) if isinstance(merged, Polygon) else merged From 59d0112f577a14154789971bf23835f77fa751a1 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 25 Apr 2024 17:28:54 +0200 Subject: [PATCH 10/16] Update docstring and merging for consolidation --- osmnx/simplification.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index f25767181..4d7d3e2ad 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -465,9 +465,8 @@ def consolidate_intersections( consolidate nodes within 10 meters of each other, use `tolerance=5`. It's also possible to specify difference tolerances for each node. This can - be done by adding an attribute to each node with contains the tolerance, and - passing the name of that argument as tolerance_attribute argument. If a node - does not have a value in the tolerance_attribute, the default tolerance is used. + be done by passing a dictionary mapping node IDs to individual tolerance + values, like `tolerance={1: 5, 2: 10}`. When `rebuild_graph` is False, it uses a purely geometric (and relatively fast) algorithm to identify "geometrically close" nodes, merge them, and @@ -583,7 +582,7 @@ def _merge_nodes_geometric( tolerances = pd.Series(tolerance).reindex(gdf_nodes.index) # Buffer nodes to the specified distance buffered_geoms = gdf_nodes.geometry.buffer(tolerances) - # Replace POLYGON EMPTY with original geometries if buffer distance was effectively zero or missing + # Replace the missing values with the original points buffered_geoms = buffered_geoms.fillna(gdf_nodes["geometry"]) else: # Buffer nodes to the specified distance @@ -593,7 +592,7 @@ def _merge_nodes_geometric( merged = buffered_geoms.unary_union # if only a single node results, make it iterable to convert to GeoSeries - merged = MultiPolygon([merged]) if isinstance(merged, Polygon) else merged + merged = merged.geoms if hasattr(merged, "geoms") else merged return gpd.GeoSeries(merged.geoms, crs=G.graph["crs"]) From 7ddb90df1f0174242c06b37f28d23b0a28611d3a Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 25 Apr 2024 17:31:38 +0200 Subject: [PATCH 11/16] Add line to changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 862d5be01..af6610670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Read the v2 [migration guide](https://github.com/gboeing/osmnx/issues/1123) - handle implicit maxspeed values in add_edge_speeds function (#1153) - change add_node_elevations_google default batch_size to 512 to match Google's limit (#1115) - allow analysis of MultiDiGraph directional edge bearings and orientation (#1139) +- allow passing node-specific tolerances values for intersection consolidation (#1160) - fix bug in \_downloader.\_save_to_cache function usage (#1107) - fix bug in handling requests ConnectionError when querying Overpass status endpoint (#1113) - fix minor bugs throughout to address inconsistencies revealed by type enforcement (#1107 #1114) From 73c85f5de8119b97de2f1a976ed5133e31a5cec8 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 25 Apr 2024 17:36:05 +0200 Subject: [PATCH 12/16] remove unused imports --- osmnx/simplification.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 4d7d3e2ad..426a0832b 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -11,9 +11,7 @@ import numpy as np import pandas as pd from shapely.geometry import LineString -from shapely.geometry import MultiPolygon from shapely.geometry import Point -from shapely.geometry import Polygon from . import convert from . import stats From 8b06cf485fb43cfb47bfa8d5429b8a9f7a9fefcb Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 25 Apr 2024 17:36:58 +0200 Subject: [PATCH 13/16] Add simple consolidate_intersections tolerance dict tests Don't cover the actual behaviour, just check if passing a dictionary (with and without all nodes covered) leads to a runtime error or not. --- tests/test_osmnx.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_osmnx.py b/tests/test_osmnx.py index 5ac378a8d..57230a0cd 100644 --- a/tests/test_osmnx.py +++ b/tests/test_osmnx.py @@ -131,6 +131,11 @@ def test_stats() -> None: G_clean = ox.consolidate_intersections(G, rebuild_graph=True) G_clean = ox.consolidate_intersections(G, rebuild_graph=False) + # test passing dict of tolerances to consolidate_intersections + tolerance_dict = {node: 10 for node in G.nodes} + G_clean = ox.consolidate_intersections(G_proj, tolerance={0: 10}, rebuild_graph=True) + G_clean = ox.consolidate_intersections(G_proj, tolerance=tolerance_dict, rebuild_graph=True) + def test_bearings() -> None: """Test bearings and orientation entropy.""" From 1e68ec946003dfde28f8851b9e614247c804bbd8 Mon Sep 17 00:00:00 2001 From: Ewout ter Hoeven Date: Thu, 25 Apr 2024 17:40:07 +0200 Subject: [PATCH 14/16] formatting --- osmnx/simplification.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 426a0832b..819e1aad2 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -552,8 +552,7 @@ def consolidate_intersections( def _merge_nodes_geometric( - G: nx.MultiDiGraph, - tolerance: float | dict[int, float], + G: nx.MultiDiGraph, tolerance: float | dict[int, float] ) -> gpd.GeoSeries: """ Geometrically merge nodes within some distance of each other. @@ -648,9 +647,7 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 # STEP 1 # buffer nodes to passed-in distance and merge overlaps. turn merged nodes # into gdf and get centroids of each cluster as x, y - node_clusters = gpd.GeoDataFrame( - geometry=_merge_nodes_geometric(G, tolerance), - ) + node_clusters = gpd.GeoDataFrame(geometry=_merge_nodes_geometric(G, tolerance)) centroids = node_clusters.centroid node_clusters["x"] = centroids.x node_clusters["y"] = centroids.y From edd26b2574fd7883abd558602a69f65b4b218ee7 Mon Sep 17 00:00:00 2001 From: Geoff Boeing Date: Thu, 25 Apr 2024 15:50:20 -0700 Subject: [PATCH 15/16] merge in v2 branch, fix formatting, fix attr access --- osmnx/simplification.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/osmnx/simplification.py b/osmnx/simplification.py index 819e1aad2..f6d96a629 100644 --- a/osmnx/simplification.py +++ b/osmnx/simplification.py @@ -10,8 +10,8 @@ import networkx as nx import numpy as np import pandas as pd -from shapely.geometry import LineString -from shapely.geometry import Point +from shapely import LineString +from shapely import Point from . import convert from . import stats @@ -552,7 +552,8 @@ def consolidate_intersections( def _merge_nodes_geometric( - G: nx.MultiDiGraph, tolerance: float | dict[int, float] + G: nx.MultiDiGraph, + tolerance: float | dict[int, float], ) -> gpd.GeoSeries: """ Geometrically merge nodes within some distance of each other. @@ -588,9 +589,9 @@ def _merge_nodes_geometric( # Merge overlapping geometries into a single geometry merged = buffered_geoms.unary_union - # if only a single node results, make it iterable to convert to GeoSeries + # extract the member geometries if it's a multi-geometry merged = merged.geoms if hasattr(merged, "geoms") else merged - return gpd.GeoSeries(merged.geoms, crs=G.graph["crs"]) + return gpd.GeoSeries(merged, crs=G.graph["crs"]) def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915 From e4e972e9602e84394d81f5f8085a67b6f68cf4e2 Mon Sep 17 00:00:00 2001 From: Geoff Boeing Date: Thu, 25 Apr 2024 15:52:05 -0700 Subject: [PATCH 16/16] fix tests --- tests/test_osmnx.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_osmnx.py b/tests/test_osmnx.py index 57230a0cd..4426c788d 100644 --- a/tests/test_osmnx.py +++ b/tests/test_osmnx.py @@ -125,6 +125,7 @@ def test_stats() -> None: reconnect_edges=False, ) G_clean = ox.consolidate_intersections(G_proj, tolerance=10, rebuild_graph=False) + G_clean = ox.consolidate_intersections(G_proj, tolerance=50000, rebuild_graph=True) # try consolidating an empty graph G = nx.MultiDiGraph(crs="epsg:4326") @@ -132,9 +133,16 @@ def test_stats() -> None: G_clean = ox.consolidate_intersections(G, rebuild_graph=False) # test passing dict of tolerances to consolidate_intersections - tolerance_dict = {node: 10 for node in G.nodes} - G_clean = ox.consolidate_intersections(G_proj, tolerance={0: 10}, rebuild_graph=True) - G_clean = ox.consolidate_intersections(G_proj, tolerance=tolerance_dict, rebuild_graph=True) + tols: dict[int, float] + # every node present + tols = {node: 5 for node in G_proj.nodes} + G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True) + # one node missing + tols.popitem() + G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True) + # one node 0 + tols[next(iter(tols))] = 0 + G_clean = ox.consolidate_intersections(G_proj, tolerance=tols, rebuild_graph=True) def test_bearings() -> None: