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

plot_orientation: support directed graph bearings #1139

Merged
merged 6 commits into from
Mar 6, 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 37 additions & 21 deletions osmnx/bearing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from typing import overload
from warnings import warn

import networkx as nx
import numpy as np
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions osmnx/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions tests/test_osmnx.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@ def test_stats() -> None:
G_clean = ox.consolidate_intersections(G, rebuild_graph=False)


def test_extract_edge_bearings_directionality() -> None:
gboeing marked this conversation as resolved.
Show resolved Hide resolved
"""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
Expand Down