diff --git a/CHANGELOG.md b/CHANGELOG.md index 229dbb262..253986fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Read the v2 [migration guide](https://github.com/gboeing/osmnx/issues/1123) - make which_result function parameter consistently able to accept a list throughout package (#1113) - make utils_geo.bbox_from_point function return a tuple of floats for consistency with rest of package (#1113) - change add_node_elevations_google default batch_size to 512 to match Google's limit (#1115) +- support analysis of directional edge bearings on MultiDiGraph input (#1137 #1139) - 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) diff --git a/osmnx/bearing.py b/osmnx/bearing.py index 0dbc2211b..ab482a22a 100644 --- a/osmnx/bearing.py +++ b/osmnx/bearing.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import overload +from warnings import warn import networkx as nx import numpy as np @@ -122,27 +123,30 @@ def add_edge_bearings(G: nx.MultiDiGraph) -> nx.MultiDiGraph: def orientation_entropy( - Gu: nx.MultiGraph, + G: nx.MultiGraph, *, num_bins: int = 36, min_length: float = 0, weight: str | None = None, ) -> float: """ - Calculate undirected graph's orientation entropy. + Calculate graph's orientation entropy. Orientation entropy is the Shannon entropy of the graphs' edges' - bidirectional bearings across evenly spaced bins. Ignores self-loop edges + bearings across evenly spaced bins. Ignores self-loop edges as their bearings are undefined. + For MultiGraph input, calculates entropy of bidirectional bearings. + For MultiDiGraph input, calculates entropy of directional bearings. + For more info see: Boeing, G. 2019. "Urban Spatial Order: Street Network Orientation, Configuration, and Entropy." Applied Network Science, 4 (1), 67. https://doi.org/10.1007/s41109-019-0189-1 Parameters ---------- - Gu - Undirected, unprojected graph with `bearing` attributes on each edge. + G + Unprojected graph with `bearing` attributes on each edge. num_bins Number of bins. For example, if `num_bins=36` is provided, then each bin will represent 10 degrees around the compass. @@ -157,32 +161,34 @@ def orientation_entropy( Returns ------- entropy - The orientation entropy of `Gu`. + The orientation entropy of `G`. """ # check if we were able to import scipy if scipy is None: # pragma: no cover msg = "scipy must be installed as an optional dependency to calculate entropy." raise ImportError(msg) - bin_counts, _ = _bearings_distribution(Gu, num_bins, min_length, weight) + bin_counts, _ = _bearings_distribution(G, num_bins, min_length, weight) entropy: float = scipy.stats.entropy(bin_counts) return entropy def _extract_edge_bearings( - Gu: nx.MultiGraph, + G: nx.MultiGraph, min_length: float, weight: str | None, ) -> npt.NDArray[np.float64]: """ - Extract undirected graph's bidirectional edge bearings. + Extract graph's edge bearings. - For example, if an edge has a bearing of 90 degrees then we will record + A MultiGraph input receives bidirectional bearings. + For example, if an undirected edge has a bearing of 90 degrees then we will record bearings of both 90 degrees and 270 degrees for this edge. + For MultiDiGraph input, record only one bearing per edge. Parameters ---------- - Gu - Undirected, unprojected graph with `bearing` attributes on each edge. + G + Unprojected graph with `bearing` attributes on each edge. min_length Ignore edges with `length` attributes less than `min_length`. Useful to ignore the noise of many very short edges. @@ -195,13 +201,13 @@ def _extract_edge_bearings( Returns ------- bearings - The bidirectional edge bearings of `Gu`. + The edge bearings of `Gu`. """ - if nx.is_directed(Gu) or projection.is_projected(Gu.graph["crs"]): # pragma: no cover - msg = "Graph must be undirected and unprojected to analyze edge bearings." + if projection.is_projected(G.graph["crs"]): # pragma: no cover + msg = "Graph must be unprojected to analyze edge bearings." raise ValueError(msg) bearings = [] - for u, v, data in Gu.edges(data=True): + for u, v, data in G.edges(data=True): # ignore self-loops and any edges below min_length if u != v and data["length"] >= min_length: if weight: @@ -211,15 +217,25 @@ def _extract_edge_bearings( # don't weight bearings, just take one value per edge bearings.append(data["bearing"]) - # drop any nulls, calculate reverse bearings, concatenate and return + # drop any nulls bearings_array = np.array(bearings) bearings_array = bearings_array[~np.isnan(bearings_array)] + if nx.is_directed(G): + # https://github.com/gboeing/osmnx/issues/1137 + msg = ( + "Extracting directional bearings (one bearing per edge) due to MultiDiGraph input. " + "To extract bidirectional bearings (two bearings per edge, including the reverse bearing), " + "supply an undirected graph instead via `osmnx.get_undirected(G)`." + ) + warn(msg, category=UserWarning, stacklevel=2) + return bearings_array + # for undirected graphs, add reverse bearings and return bearings_array_r = (bearings_array - 180) % 360 return np.concatenate([bearings_array, bearings_array_r]) def _bearings_distribution( - Gu: nx.MultiGraph, + G: nx.MultiGraph, num_bins: int, min_length: float, weight: str | None, @@ -235,8 +251,8 @@ def _bearings_distribution( Parameters ---------- - Gu - Undirected, unprojected graph with `bearing` attributes on each edge. + G + Unprojected graph with `bearing` attributes on each edge. num_bins Number of bins for the bearing histogram. min_length @@ -256,7 +272,7 @@ def _bearings_distribution( n = num_bins * 2 bins = np.arange(n + 1) * 360 / n - bearings = _extract_edge_bearings(Gu, min_length, weight) + bearings = _extract_edge_bearings(G, min_length, weight) count, bin_edges = np.histogram(bearings, bins=bins) # move last bin to front, so eg 0.01 degrees and 359.99 degrees will be diff --git a/osmnx/plot.py b/osmnx/plot.py index e4be2fd6a..2e2316ce8 100644 --- a/osmnx/plot.py +++ b/osmnx/plot.py @@ -664,7 +664,7 @@ def plot_footprints( # noqa: PLR0913 def plot_orientation( # noqa: PLR0913 - Gu: nx.MultiGraph, + G: nx.MultiGraph, *, num_bins: int = 36, min_length: float = 0, @@ -682,7 +682,10 @@ def plot_orientation( # noqa: PLR0913 xtick_font: dict[str, Any] | None = None, ) -> tuple[Figure, PolarAxes]: """ - Plot a polar histogram of a spatial network's bidirectional edge bearings. + Plot a polar histogram of a spatial network's edge bearings. + + A MultiGraph input receives bidirectional bearings, while a MultiDiGraph + input receives directional bearings (one bearing per edge). Ignores self-loop edges as their bearings are undefined. See also the `bearings` module. @@ -693,8 +696,8 @@ def plot_orientation( # noqa: PLR0913 Parameters ---------- - Gu - Undirected, unprojected graph with `bearing` attributes on each edge. + G + Unprojected graph with `bearing` attributes on each edge. num_bins Number of bins. For example, if `num_bins=36` is provided, then each bin will represent 10 degrees around the compass. @@ -747,7 +750,7 @@ def plot_orientation( # noqa: PLR0913 # get the bearings' distribution's bin counts and edges bin_counts, bin_edges = bearing._bearings_distribution( - Gu, + G, num_bins, min_length=min_length, weight=weight, diff --git a/tests/test_osmnx.py b/tests/test_osmnx.py index 9c71cbd7f..43f292c2f 100644 --- a/tests/test_osmnx.py +++ b/tests/test_osmnx.py @@ -141,6 +141,21 @@ def test_stats() -> None: G_clean = ox.consolidate_intersections(G, rebuild_graph=False) +def test_extract_edge_bearings_directionality() -> None: + """Test support of edge bearings for directed and undirected graphs.""" + G = nx.MultiDiGraph(crs="epsg:4326") + G.add_node("point_1", x=0.0, y=0.0) + G.add_node("point_2", x=0.0, y=1.0) # latitude increases northward + G.add_edge("point_1", "point_2") + G = ox.distance.add_edge_lengths(G) + G = ox.add_edge_bearings(G) + with pytest.warns(UserWarning, match="Extracting directional bearings"): + bearings = ox.bearing._extract_edge_bearings(G, min_length=0.0, weight=None) + assert list(bearings) == [0.0] # north + bearings = ox.bearing._extract_edge_bearings(G.to_undirected(), min_length=0.0, weight=None) + assert list(bearings) == [0.0, 180.0] # north and south + + def test_osm_xml() -> None: """Test working with .osm XML data.""" # test loading a graph from a local .osm xml file