"""Graph Tessellation for the population submodule of the superblockify package."""
from datetime import timedelta, datetime
from geopandas import GeoDataFrame
from matplotlib import pyplot as plt
from numpy import vstack, linspace
from osmnx import graph_to_gdfs, plot_graph
from osmnx.projection import is_projected
from pandas import Series
from scipy.spatial import Voronoi # pylint: disable=no-name-in-module
from shapely import Polygon, get_coordinates, line_interpolate_point, polygons
from shapely.lib import GEOSException
from ..config import logger
[docs]
def add_edge_cells(graph, **tess_kwargs):
"""Add edge tessellation cells to edge attributes in the graph.
Tessellates the graph into plane using a Voronoi cell approach.
Function writes to edge attribute `cells` of the graph in-place.
Furthermore, `cell_id` is added to the edge attributes, for easier summary of
statistics later.
The approach was developed inspired by the :class:`momepy.Tessellation` class
and tessellates with :class:`scipy.spatial.Voronoi`.
Parameters
----------
graph : networkx.MultiDiGraph
The graph to tessellate.
**tess_kwargs
Keyword arguments for the
:func:`superblockify.population.tessellation.get_edge_cells` function.
Raises
------
ValueError
If the graph is not in a projected coordinate system.
ValueError
If the limit and the edge points are disjoint.
Notes
-----
The graph must be in a projected coordinate system.
"""
edge_cells = get_edge_cells(graph, **tess_kwargs)
# Add cells to graph (in-place)
logger.debug("Adding cells to graph.")
for edge_keys, polygon in edge_cells.geometry.items():
for edge_key in edge_keys:
graph.edges[edge_key]["cell"] = polygon
graph.edges[edge_key]["cell_id"] = edge_cells.index.get_loc(edge_keys)
[docs]
def get_edge_cells(graph, limit=None, segment=25, show_plot=False):
"""Get edge tessellation cells for the graph.
Tessellates the graph into plane using a Voronoi cell approach.
The approach was developed inspired by the :class:`momepy.Tessellation` class
and tessellates with :class:`scipy.spatial.Voronoi`.
Parameters
----------
graph : networkx.MultiDiGraph
The graph to tessellate.
limit : shapely.geometry.Polygon or None
The limit of the tessellation. Must be in the same CRS as the graph.
If None, it will be calculated as the exterior of the 100m buffered unary
union of the graph's edges.
segment : float
The maximum distance for the point interpolation. Default is 25.
show_plot : bool
If True, a plot of the tessellation will be shown. Default is False.
Returns
-------
geopandas.GeoDataFrame
A GeoDataFrame with the tuple of edge keys as index and the tessellation
cells as geometry.
Raises
------
ValueError
If the graph is not in a projected coordinate system.
ValueError
If the limit and the edge points are disjoint.
Notes
-----
The graph must be in a projected coordinate system.
"""
# Check if the graph is projected
if not is_projected(graph.graph["crs"]):
raise ValueError(
"The graph must be in a projected coordinate system. "
"Use `osmnx.project_graph` to project the graph."
)
logger.info(
"Calculating edge cells for graph with %d edges.", graph.number_of_edges()
)
start_time = datetime.now()
edges = get_edge_polygons(graph)
logger.debug("Prepared %d edge polygons. Next, interpolating points.", len(edges))
# Make limit polygon
if limit is None:
limit = Polygon(edges.unary_union.buffer(100).exterior)
else:
# Check if limit and edges are disjoint
if limit.disjoint(edges.unary_union):
raise ValueError(
"The limit and the edge points are disjoint. "
"Please provide a limit that intersects the edges."
)
# Convert edges to points and their multiindex
edge_points, edge_indices = edges_to_points(edges, segment=segment)
# Convert limit hull to points
hull_points = line_interpolate_point(
limit.boundary,
linspace(
0.1,
limit.length - 0.1,
num=int((limit.length - 0.1) // segment),
),
)
# Add to point array
edge_points = vstack([edge_points, get_coordinates(hull_points)])
edge_indices += [-1] * len(hull_points)
# Create Voronoi tessellation
logger.debug("Prepared %d points. Creating Voronoi tessellation.", len(edge_points))
edge_voronoi_diagram = Voronoi(edge_points)
# Reconstruct edge cells from Voronoi point tessellation
logger.debug("Reconstructing edge cells.")
edge_cells = reconstruct_edge_cells(
edge_voronoi_diagram, edge_indices, graph.graph["crs"]
)
# Plot tessellation
if show_plot:
fig, axe = plt.subplots(figsize=(8, 8))
edge_cells.to_crs(graph.graph["crs"]).plot(
ax=axe, cmap="tab20", edgecolor="white", alpha=0.5
)
plot_graph(graph, ax=axe, node_size=0, edge_color="black", edge_linewidth=0.5)
axe.set_axis_off()
axe.set_title("Edge tessellation")
fig.tight_layout()
logger.info(
"Tessellated %d edge cells in %s.",
len(edge_cells),
timedelta(seconds=(datetime.now() - start_time).seconds),
)
return edge_cells
[docs]
def get_edge_polygons(graph):
"""Prepare edge polygons for tessellation.
This returns a GeoDataFrame where edges with same start and end node are
merged, if their geometry is equal or a reversed version of the other.
Parameters
----------
graph : networkx.MultiDiGraph
The graph to tessellate.
Returns
-------
edges : geopandas.GeoDataFrame
The edges with their polygons.
"""
edges = graph_to_gdfs(
graph, nodes=False, edges=True, node_geometry=False, fill_edge_geometry=True
)
# Merge two columns, when the geometry of one is equal or the reverse of the other
# 1. Group edge ig (u, v) == (v, u) or (u, v) == (u, v)
# Match by node pair (u, v) where u < v
# get flat index of the first match
edges["edge_id"] = edges.index.to_flat_index()
# sort the edge_id tuples
edges["node_pair"] = edges["edge_id"].apply(lambda x: tuple(sorted(x[:2])))
# 2. Aggregate if the geometry is equal or the reverse of the other
# Merge columns if geometry_1 == geometry_2 or geometry_1 == geometry_2.reverse()
# reverse geometry if x_start >= x_end (analogous to sorted node_pair)
edges["geometry"] = edges["geometry"].apply(
lambda x: x if x.coords[0] < x.coords[-1] else x.reverse()
)
# 3. Group by node_pair and geometry, aggregate edge_id
edges = (
edges.groupby(["node_pair", "geometry"])
.agg(
{
"edge_id": tuple, # tuple of edge indices
} # tuple, because it is immutable and thus hashable for indexing
)
.reset_index()
)
edges.set_index("edge_id", inplace=True)
edges.drop(columns=["node_pair"], inplace=True)
return GeoDataFrame(edges, geometry="geometry", crs=graph.graph["crs"])
[docs]
def edges_to_points(edges, segment=25):
"""Convert edges to points.
Parameters
----------
edges : geopandas.GeoDataFrame
The edges to convert to points.
segment : float
The maximum distance for the point interpolation. Default is 25.
Returns
-------
points : geopandas.GeoDataFrame
The points.
indices : list of int
The indices of the points in the edges.
Notes
-----
The points are interpolated along the edges with a maximum distance of `segment`.
"""
edge_points = []
edge_indices = []
# iterate over street_id, geometry
for idx, geometry in edges["geometry"].items():
if geometry.length < 2 * segment:
# for edges that would result in no or one point, take the middle
pts = [line_interpolate_point(geometry, 0.5, normalized=True)]
else:
# interpolate points along the line
pts = line_interpolate_point(
geometry,
linspace(
0.1,
geometry.length - 0.1,
num=int((geometry.length - 0.1) // segment),
),
) # offset to keep nodes out
edge_points.append(get_coordinates(pts))
# get multi_indices of first row
edge_indices += [idx] * len(pts)
points = vstack(edge_points)
return points, edge_indices
[docs]
def reconstruct_edge_cells(voronoi_diagram, indices, crs):
"""Reconstruct edge cells from a Voronoi diagram.
Regions with the hull index `-1` are discarded.
Parameters
----------
voronoi_diagram : scipy.spatial.Voronoi
The Voronoi diagram to reconstruct.
indices : list
The indices of the points in the Voronoi diagram.
crs : value
The CRS used for the GeoDataFrame. Must be the same as the graph.
Anything compatible with
:meth:`pyproj.CRS.from_user_input() <pyproj.crs.CRS.from_user_input>`.
Returns
-------
cells : geopandas.GeoDataFrame
The Voronoi cells by their indices.
"""
# Construct cell polygons for each of the dense points
point_vertices = Series(voronoi_diagram.regions).take(voronoi_diagram.point_region)
point_polygons = []
for region in point_vertices:
if -1 not in region:
point_polygons.append(polygons(voronoi_diagram.vertices[region]))
else:
point_polygons.append(None)
# Create GeoDataFrame with cells and edge multiindex
poly_gdf = GeoDataFrame(
geometry=point_polygons,
crs=crs,
index=indices,
columns=["geometry"],
)
# Drop cells that are outside the boundary
poly_gdf = poly_gdf.loc[poly_gdf.index != -1]
# Dissolve cells by edge multiindex
try:
return poly_gdf.dissolve(by=poly_gdf.index)
except GEOSException as err: # pragma: no cover
raise ValueError(
"The tessellation might contain invalid geometries. "
"Try increasing the segment length."
) from err