Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configurable node/edge attribute aggregation when simplifying graph or consolidating intersections #1155

Merged
merged 8 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Read the v2 [migration guide](https://github.com/gboeing/osmnx/issues/1123)
- make consolidate_intersections function retain unique attribute values when consolidating nodes (#1144)
- add OSM junction and railway tags to the default settings.useful_tags_node (#1144)
- fix graph projection creating useless lat and lon node attributes (#1144)
- add edge_attr_aggs argument to simplify_graph function to specify aggregation behavior (#1155)
- add node_attr_aggs argument to the consolidate_intersections function to specify aggregation behavior (#1155)
- make optional function parameters keyword-only throughout package (#1134)
- make dist function parameters required rather than optional throughout package (#1134)
- make which_result function parameter consistently able to accept a list throughout package (#1113)
Expand Down
4 changes: 2 additions & 2 deletions osmnx/_osm_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def _save_graph_xml(
`settings.data_folder/graph.osm`.
way_tag_aggs
Keys are OSM way tag keys and values are aggregation functions
(anything accepted as an argument by pandas.agg). Allows user to
(anything accepted as an argument by `pandas.agg`). Allows user to
aggregate graph edge attribute values into single OSM way values. If
None, or if some tag's key does not exist in the dict, the way
attribute will be assigned the value of the first edge of the way.
Expand Down Expand Up @@ -304,7 +304,7 @@ def _add_ways_xml(
edges into ways.
way_tag_aggs
Keys are OSM way tag keys and values are aggregation functions
(anything accepted as an argument by pandas.agg). Allows user to
(anything accepted as an argument by `pandas.agg`). Allows user to
aggregate graph edge attribute values into single OSM way values. If
None, or if some tag's key does not exist in the dict, the way
attribute will be assigned the value of the first edge of the way.
Expand Down
5 changes: 3 additions & 2 deletions osmnx/elevation.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ def add_edge_grades(G: nx.MultiDiGraph, *, add_absolute: bool = True) -> nx.Mult
"""
Calculate and add `grade` attributes to all graph edges.

Vectorized function to calculate the directed grade (ie, rise over run)
Vectorized function to calculate the directed grade (i.e., rise over run)
for each edge in the graph and add it to the edge as an attribute. Nodes
must already have `elevation` attributes before using this function.
must already have `elevation` and `length` attributes before using this
function.

See also the `add_node_elevations_raster` and `add_node_elevations_google`
functions.
Expand Down
129 changes: 82 additions & 47 deletions osmnx/simplification.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

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
from shapely.geometry import Polygon

from . import convert
from . import settings
from . import stats
from . import utils
from ._errors import GraphSimplificationError
Expand Down Expand Up @@ -60,13 +60,13 @@ def _is_endpoint(
The ID of the node.
node_attrs_include
Node attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if it has one
or more of the attributes in `node_attrs_include`.
determination. A node is always an endpoint if it possesses one or
more of the attributes in `node_attrs_include`.
edge_attrs_differ
Edge attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if its
incident edges have different values than each other for any of the
edge attributes in `edge_attrs_differ`.
determination. A node is always an endpoint if its incident edges have
different values than each other for any attribute in
`edge_attrs_differ`.

Returns
-------
Expand Down Expand Up @@ -209,13 +209,13 @@ def _get_paths_to_simplify(
Input graph.
node_attrs_include
Node attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if it has one
or more of the attributes in `node_attrs_include`.
determination. A node is always an endpoint if it possesses one or
more of the attributes in `node_attrs_include`.
edge_attrs_differ
Edge attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if its
incident edges have different values than each other for any of the
edge attributes in `edge_attrs_differ`.
determination. A node is always an endpoint if its incident edges have
different values than each other for any attribute in
`edge_attrs_differ`.

Yields
------
Expand Down Expand Up @@ -253,13 +253,13 @@ def _remove_rings(
Input graph.
node_attrs_include
Node attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if it has one
or more of the attributes in `node_attrs_include`.
determination. A node is always an endpoint if it possesses one or
more of the attributes in `node_attrs_include`.
edge_attrs_differ
Edge attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if its
incident edges have different values than each other for any of the
edge attributes in `edge_attrs_differ`.
determination. A node is always an endpoint if its incident edges have
different values than each other for any attribute in
`edge_attrs_differ`.

Returns
-------
Expand All @@ -274,18 +274,19 @@ def _remove_rings(
return G


def simplify_graph( # noqa: PLR0912
def simplify_graph( # noqa: C901, PLR0912
G: nx.MultiDiGraph,
*,
node_attrs_include: Iterable[str] | None = None,
edge_attrs_differ: Iterable[str] | None = None,
remove_rings: bool = True,
track_merged: bool = False,
edge_attr_aggs: dict[str, Any] | None = None,
) -> nx.MultiDiGraph:
"""
Simplify a graph's topology by removing interstitial nodes.

This simplifies graph topology by removing all nodes that are not
This simplifies the graph's topology by removing all nodes that are not
intersections or dead-ends, by creating an edge directly between the end
points that encapsulate them while retaining the full geometry of the
original edges, saved as a new `geometry` attribute on the new edge.
Expand All @@ -312,19 +313,26 @@ def simplify_graph( # noqa: PLR0912
Input graph.
node_attrs_include
Node attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if it has one
or more of the attributes in `node_attrs_include`.
determination. A node is always an endpoint if it possesses one or
more of the attributes in `node_attrs_include`.
edge_attrs_differ
Edge attribute names for relaxing the strictness of endpoint
determination. If not None, a node is always an endpoint if its
incident edges have different values than each other for any of the
edge attributes in `edge_attrs_differ`.
determination. A node is always an endpoint if its incident edges have
different values than each other for any attribute in
`edge_attrs_differ`.
remove_rings
If True, remove any graph components that consist only of a single
chordless cycle (i.e., an isolated self-contained ring).
track_merged
If True, add `merged_edges` attribute on simplified edges, containing
a list of all the `(u, v)` node pairs that were merged together.
edge_attr_aggs
Allows user to aggregate edge segment attributes when simplifying an
edge. Keys are edge attribute names and values are aggregation
functions to apply to these attributes when they exist for a set of
edges being merged. Edge attributes not in `edge_attr_aggs` will
contain the unique values across the merged edge segments. If None,
defaults to `{"length": sum, "travel_time": sum}`.

Returns
-------
Expand All @@ -339,8 +347,9 @@ def simplify_graph( # noqa: PLR0912
msg = "Begin topologically simplifying the graph..."
utils.log(msg, level=lg.INFO)

# define edge segment attributes to sum upon edge simplification
attrs_to_sum = {"length", "travel_time"}
# default edge segment attributes to aggregate upon simplification
if edge_attr_aggs is None:
edge_attr_aggs = {"length": sum, "travel_time": sum}

# make a copy to not mutate original graph object caller passed in
G = G.copy()
Expand Down Expand Up @@ -386,15 +395,15 @@ def simplify_graph( # noqa: PLR0912

# consolidate the path's edge segments' attribute values
for attr in path_attributes:
if attr in attrs_to_sum:
# if this attribute must be summed, sum it now
path_attributes[attr] = sum(path_attributes[attr])
if attr in edge_attr_aggs:
# if this attribute's values must be aggregated, do so now
agg_func = edge_attr_aggs[attr]
path_attributes[attr] = agg_func(path_attributes[attr])
elif len(set(path_attributes[attr])) == 1:
# if there's only 1 unique value in this attribute list,
# consolidate it to the single value (the zero-th):
# if there's only 1 unique value, keep that single value
path_attributes[attr] = path_attributes[attr][0]
else:
# otherwise, if there are multiple values, keep one of each
# otherwise, if there are multiple uniques, keep one of each
path_attributes[attr] = list(set(path_attributes[attr]))

# construct the new consolidated edge's geometry for this path
Expand Down Expand Up @@ -441,6 +450,7 @@ def consolidate_intersections(
rebuild_graph: bool = True,
dead_ends: bool = False,
reconnect_edges: bool = True,
node_attr_aggs: dict[str, Any] | None = None,
) -> nx.MultiDiGraph | gpd.GeoSeries:
"""
Consolidate intersections comprising clusters of nearby nodes.
Expand Down Expand Up @@ -491,6 +501,12 @@ def consolidate_intersections(
False, the returned graph has no edges (which is faster if you just
need topologically consolidated intersection counts). Ignored if
`rebuild_graph` is not True.
node_attr_aggs
Allows user to aggregate node attributes values when merging nodes.
Keys are node attribute names and values are aggregation functions
(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}`.

Returns
-------
Expand All @@ -515,7 +531,12 @@ def consolidate_intersections(
return G

# otherwise
return _consolidate_intersections_rebuild_graph(G, tolerance, reconnect_edges)
return _consolidate_intersections_rebuild_graph(
G,
tolerance,
reconnect_edges,
node_attr_aggs,
)

# otherwise, if we're not rebuilding the graph
if len(G) == 0:
Expand Down Expand Up @@ -555,6 +576,7 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915
G: nx.MultiDiGraph,
tolerance: float,
reconnect_edges: bool, # noqa: FBT001
node_attr_aggs: dict[str, Any] | None,
) -> nx.MultiDiGraph:
"""
Consolidate intersections comprising clusters of nearby nodes.
Expand Down Expand Up @@ -582,15 +604,24 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915
If True, reconnect edges (and their geometries) to the consolidated
nodes in rebuilt graph, and update the edge length attributes. If
False, the returned graph has no edges (which is faster if you just
need topologically consolidated intersection counts). Ignored if
`rebuild_graph` is not True.
need topologically consolidated intersection counts).
node_attr_aggs
Allows user to aggregate node attributes values when merging nodes.
Keys are node attribute names and values are aggregation functions
(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}`.

Returns
-------
Gc
A rebuilt graph with consolidated intersections and reconnected edge
geometries.
"""
# default node attributes to aggregate upon consolidation
if node_attr_aggs is None:
node_attr_aggs = {"elevation": np.mean}

# 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
Expand All @@ -603,9 +634,7 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915
# attach each node to its cluster of merged nodes. first get the original
# graph's node points then spatial join to give each node the label of
# cluster it's within. make cluster labels type string.
node_points = convert.graph_to_gdfs(G, edges=False)
cols = set(node_points.columns).intersection(["geometry", *settings.useful_tags_node])
node_points = node_points[list(cols)]
node_points = convert.graph_to_gdfs(G, edges=False).drop(columns=["x", "y"])
gdf = gpd.sjoin(node_points, node_clusters, how="left", predicate="within")
gdf = gdf.drop(columns="geometry").rename(columns={"index_right": "cluster"})
gdf["cluster"] = gdf["cluster"].astype(str)
Expand All @@ -615,8 +644,7 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915
# move each component to its own cluster (otherwise you will connect
# nodes together that are not truly connected, e.g., nearby deadends or
# surface streets with bridge).
groups = gdf.groupby("cluster")
for cluster_label, nodes_subset in groups:
for cluster_label, nodes_subset in gdf.groupby("cluster"):
if len(nodes_subset) > 1:
# identify all the (weakly connected) component in cluster
wccs = list(nx.weakly_connected_components(G.subgraph(nodes_subset.index)))
Expand Down Expand Up @@ -657,22 +685,24 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915
"x": nodes_subset["x"].iloc[0],
"y": nodes_subset["y"].iloc[0],
}
for col in set(nodes_subset.columns).intersection(settings.useful_tags_node):
for col in set(nodes_subset.columns):
# get the unique non-null values (we won't add null attrs)
unique_vals = list(set(nodes_subset[col].dropna()))
if len(unique_vals) == 1:
if len(unique_vals) > 0 and col in node_attr_aggs:
# if this attribute's values must be aggregated, do so now
node_attrs[col] = nodes_subset[col].agg(node_attr_aggs[col])
elif col == "street_count":
# if user doesn't specifically handle street_count with an
# agg function, just skip it here then calculate it later
continue
elif len(unique_vals) == 1:
# if there's 1 unique value for this attribute, keep it
node_attrs[col] = unique_vals[0]
elif len(unique_vals) > 1:
# if there are multiple unique values, keep all uniques
# if there are multiple unique values, keep one of each
node_attrs[col] = unique_vals
Gc.add_node(cluster_label, **node_attrs)

# calculate street_count attribute for all nodes lacking it
null_nodes = [n for n, sc in Gc.nodes(data="street_count") if sc is None]
street_count = stats.count_streets_per_node(Gc, nodes=null_nodes)
nx.set_node_attributes(Gc, street_count, name="street_count")

if len(G.edges) == 0 or not reconnect_edges:
# if reconnect_edges is False or there are no edges in original graph
# (after dead-end removed), then skip edges and return new graph as-is
Expand Down Expand Up @@ -720,4 +750,9 @@ def _consolidate_intersections_rebuild_graph( # noqa: C901,PLR0912,PLR0915
# update the edge length attribute, given the new geometry
Gc.edges[u, v, k]["length"] = new_geom.length

# calculate street_count attribute for all nodes lacking it
null_nodes = [n for n, sc in Gc.nodes(data="street_count") if sc is None]
street_counts = stats.count_streets_per_node(Gc, nodes=null_nodes)
nx.set_node_attributes(Gc, street_counts, name="street_count")

return Gc
10 changes: 6 additions & 4 deletions osmnx/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@

from __future__ import annotations

import itertools
import logging as lg
from collections import Counter
from itertools import chain
from typing import TYPE_CHECKING
from typing import Any

Expand All @@ -34,7 +34,9 @@

def streets_per_node(G: nx.MultiDiGraph) -> dict[int, int]:
"""
Count streets (undirected edges) incident on each node.
Retrieve nodes' `street_count` attribute values.

See also the `count_streets_per_node` function for the calculation.

Parameters
----------
Expand Down Expand Up @@ -303,7 +305,7 @@ def count_streets_per_node(
# appear twice in the undirected graph (u,v,0 and u,v,1 where u=v), but
# one-way self-loops will appear only once
Gu = G.to_undirected(reciprocal=False, as_view=True)
self_loop_edges = set(nx.selfloop_edges(Gu))
self_loop_edges = set(nx.selfloop_edges(Gu, keys=False))

# get all non-self-loop undirected edges, including parallel edges
non_self_loop_edges = [e for e in Gu.edges(keys=False) if e not in self_loop_edges]
Expand All @@ -313,7 +315,7 @@ def count_streets_per_node(
all_unique_edges = non_self_loop_edges + list(self_loop_edges)

# flatten list of (u, v) edge tuples to count how often each node appears
edges_flat = itertools.chain.from_iterable(all_unique_edges)
edges_flat = chain.from_iterable(all_unique_edges)
counts = Counter(edges_flat)
streets_per_node = {node: counts[node] for node in nodes}

Expand Down
3 changes: 3 additions & 0 deletions tests/test_osmnx.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ def test_elevation() -> None:
G = ox.elevation.add_node_elevations_raster(G, rasters)
assert pd.notna(pd.Series(dict(G.nodes(data="elevation")))).all()

# consolidate nodes with elevation (by default will aggregate via mean)
G = ox.simplification.consolidate_intersections(G)

# add edge grades and their absolute values
G = ox.add_edge_grades(G, add_absolute=True)

Expand Down