Source code for cityImage.network_topology

"""Network topology preparation utilities.

This module is the hard replacement for the old split modules:

* ``graph_clean.py``
* ``graph_consolidate.py``
* ``graph_topology.py``

The purpose is to keep all legacy graph-preparation behaviour in one explicit
boundary module, while the core cityImage semantics rely on already-prepared
``nodes_gdf``/``edges_gdf`` inputs.

No live OSM/file loading is owned here. For new work, prefer external network
preparation tools first, then pass cleaned GeoDataFrames into cityImage. These
helpers are retained for workflows that need the historical cityImage topology
operations.
"""

from __future__ import annotations

from collections import defaultdict

import geopandas as gpd
import networkx as nx
import numpy as np
import pandas as pd
from shapely.geometry import LineString, Point

from .data_utils import convert_numeric_columns
from .geometry import center_line, split_line_at_MultiPoint
from .graph import graph_fromGDF, nodes_degree
from .network import join_nodes_edges_by_coordinates, obtain_nodes_gdf

pd.set_option("display.precision", 3)


# -----------------------------------------------------------------------------
# Topology fixing
# -----------------------------------------------------------------------------
[docs] def fix_network_topology(nodes_gdf, edges_gdf): """ Node the network at shared, un-noded vertices. An edge is split at one of its own internal vertices only when that vertex **coincides with a vertex** (endpoint or internal) of another edge: two edges genuinely meet there but the junction was never noded. Crossings that do **not** share a vertex are left intact — this covers grade-separated bridges/tunnels and ways that merely cross in 2D, which must not be noded, as well as an edge whose vertex happens to fall on another edge's interior (the split would only be undone by the pseudo-node simplification anyway). No new vertices are ever introduced. Parameters ---------- nodes_gdf: Point GeoDataFrame The nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. Returns ------- nodes_gdf, edges_gdf: tuple of GeoDataFrames The (possibly unchanged) nodes and the updated edges. """ edges_gdf = edges_gdf.copy() coords_list = [list(geometry.coords) for geometry in edges_gdf.geometry] edges_gdf["coords"] = coords_list def _coord_key(coord, ndigits=10): return (round(float(coord[0]), ndigits), round(float(coord[1]), ndigits)) # coordinate -> set of edge positions carrying it as any vertex (endpoint or internal) vertex_edges = defaultdict(set) for pos, coords in enumerate(coords_list): for coord in coords: vertex_edges[_coord_key(coord)].add(pos) # An internal vertex that is also a vertex of a *different* edge is a shared, un-noded junction # -> a split point. Endpoints are already u/v nodes, so only internal vertices are considered. to_fix_points = [] for pos, coords in enumerate(coords_list): endpoints = {_coord_key(coords[0]), _coord_key(coords[-1])} points, seen = [], set() for coord in coords[1:-1]: key = _coord_key(coord) if key in endpoints or key in seen: continue if vertex_edges[key] - {pos}: # the vertex belongs to another edge too points.append(Point(coord[0], coord[1])) seen.add(key) to_fix_points.append(points) edges_gdf["to_fix"] = to_fix_points edges_gdf["fixing"] = [len(item) > 0 for item in to_fix_points] to_fix = edges_gdf[edges_gdf["fixing"]].copy() edges_gdf = edges_gdf[~edges_gdf["fixing"]] if len(to_fix) == 0: # Nothing to split: drop temp columns and return the unchanged nodes alongside the edges, # matching the (nodes_gdf, edges_gdf) contract callers unpack. edges_gdf = edges_gdf.drop(columns=["coords", "to_fix", "fixing"], errors="ignore") return nodes_gdf, edges_gdf return _add_fixed_edges(edges_gdf, to_fix)
[docs] def fix_fake_self_loops(nodes_gdf, edges_gdf): """ Fix the network topology by removing (fake) self-loops and adding fixed edges. Parameters ---------- nodes_gdf: Point GeoDataFrame The nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. Returns ------- LineString GeoDataFrame The updated edges GeoDataFrame. """ edges_gdf = edges_gdf.copy() edges_gdf["coords"] = [list(geometry.coords) for geometry in edges_gdf.geometry] # all the coordinates but the from and to vertices' ones. edges_gdf["coords"] = [coords[1:-1] for coords in edges_gdf.coords] # convert nodes_gdf['x'] and nodes_gdf['y'] to numpy arrays for faster computation x = list(nodes_gdf["x"]) y = list(nodes_gdf["y"]) # create a set of all coordinates in nodes. This essentially correspond to the from and to nodes of the edges currently in the edges_gdf nodes_set = set(zip(x, y, strict=False)) to_fix = [] # loop through the coordinates in edges_gdf.coords and check if they are in the nodes_set. This means that one of the edges coords (not from and to), # coincide with some other edge from or to vertex (indicating some sort of loop) for coords in edges_gdf.coords: fix_coords = [] for coord in coords: if coord in nodes_set: fix_coords.append(coord) to_fix.append(fix_coords) # assign the results to self_loops['to_fix'] edges_gdf["to_fix"] = to_fix edges_gdf["fixing"] = [len(to_fix) > 0 for to_fix in edges_gdf["to_fix"]] to_fix = edges_gdf[edges_gdf["fixing"]].copy() edges_gdf = edges_gdf[~edges_gdf["fixing"]] if len(to_fix) == 0: return nodes_gdf, edges_gdf return _add_fixed_edges(edges_gdf, to_fix)
def _add_fixed_edges(edges_gdf, to_fix_gdf): """ Add fixed edges to the edges GeoDataFrame. Parameters ---------- edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. to_fix_gdf: GeoDataFrame The GeoDataFrame containing the edges to be fixed. Returns ------- nodes_gdf, edges_gdf: tuple of GeoDataFrames The cleaned junctions and street segments GeoDataFrames. """ dfs = [] def _split_row_geometry(row): split_points = [point if isinstance(point, Point) else Point(point) for point in row.to_fix] return split_line_at_MultiPoint(row.geometry, split_points, z=None) new_geometries = to_fix_gdf.apply(_split_row_geometry, axis=1) new_geometries = pd.DataFrame(new_geometries, columns=["lines"]) def append_new_geometries(row): for n, line in enumerate(row): # assigning the resulting geometries ix = row.name index = ix if n == 0 else max(edges_gdf.index) + 1 # copy attributes row = to_fix_gdf.loc[ix].copy() # and assign geometry an new edgeID row["edgeID"] = index row["geometry"] = line dfs.append(row.to_frame().T) new_geometries.apply(lambda row: append_new_geometries(row), axis=1) rows = pd.concat(dfs, ignore_index=True) rows = rows.explode(column="geometry") # concatenate the dataframes and assign to edges_gdf edges_gdf = pd.concat([edges_gdf, rows], ignore_index=True) edges_gdf.drop(["u", "v", "to_fix", "fixing", "coords"], inplace=True, axis=1) edges_gdf["length"] = edges_gdf.geometry.length edges_gdf["edgeID"] = edges_gdf.index nodes_gdf = obtain_nodes_gdf(edges_gdf, edges_gdf.crs) nodes_gdf, edges_gdf = join_nodes_edges_by_coordinates(nodes_gdf, edges_gdf) return nodes_gdf, edges_gdf
[docs] def remove_disconnected_islands(nodes_gdf, edges_gdf): """ Remove disconnected islands from a graph. Parameters: ----------- nodes_gdf: Point GeoDataFrame The nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. Returns ------- nodes_gdf, edges_gdf: tuple of GeoDataFrames The updated junctions and street segments GeoDataFrame. """ Ng = graph_fromGDF(nodes_gdf, edges_gdf) if not nx.is_connected(Ng): largest_component = max(nx.connected_components(Ng), key=len) # Create a subgraph of Ng consisting only of this component: G = Ng.subgraph(largest_component) to_keep = list(G.nodes()) nodes_gdf = nodes_gdf[nodes_gdf["nodeID"].isin(to_keep)] edges_gdf = edges_gdf[ (edges_gdf.u.isin(nodes_gdf["nodeID"])) & (edges_gdf.v.isin(nodes_gdf["nodeID"])) ] return nodes_gdf, edges_gdf
# ----------------------------------------------------------------------------- # Network cleaning # -----------------------------------------------------------------------------
[docs] def clean_network( nodes_gdf, edges_gdf, dead_ends=False, remove_islands=True, same_vertexes_edges=True, self_loops=False, fix_topology=False, preserve_direction=False, nodes_to_keep_regardless=None, ): """ Cleans a street network by applying a series of topology and geometry corrections to nodes and edges GeoDataFrames. This function can: - Remove pseudo-nodes - Remove duplicate nodes and edges (by geometry or node pairing) - Remove disconnected islands (optional) - Remove edges with the same vertexes but different geometry (optional) - Remove dead-ends (optional) - Remove self-loops (optional) - Fix topology by breaking lines at intersections (optional) Parameters ---------- nodes_gdf : GeoDataFrame Point GeoDataFrame containing network nodes (junctions), must include a unique node ID column. edges_gdf : GeoDataFrame LineString GeoDataFrame containing street segments, must include columns for start/end node IDs and geometry. dead_ends : bool, optional If True, removes dead-end nodes and corresponding edges. Default is False. remove_islands : bool, optional If True, removes disconnected components ("islands") in the network. Default is True. same_vertexes_edges : bool, optional If True, treats multiple edges between the same pair of nodes as duplicates. Keeps only the longest edge when it is at least 10% longer than the others, otherwise replaces them with a center line. Default is True. self_loops : bool, optional If True, removes self-loop edges (where start and end node are the same). Default is False. fix_topology : bool, optional If True, breaks lines at intersections with other lines in the streets GeoDataFrame. Default is False. preserve_direction : bool, optional If True, considers edge direction: edges with the same coordinates but opposite directions are not considered duplicates. If False, such edges are treated as duplicates. Default is False. nodes_to_keep_regardless : list, optional List of node IDs to always keep, even if they would otherwise be removed (e.g. for transport stations). Default is empty list. Returns ------- nodes_gdf : GeoDataFrame Cleaned nodes GeoDataFrame. edges_gdf : GeoDataFrame Cleaned edges GeoDataFrame. """ if nodes_to_keep_regardless is None: nodes_to_keep_regardless = [] crs = nodes_gdf.crs nodes_gdf, edges_gdf = _prepare_dataframes(nodes_gdf, edges_gdf) # removes fake self-loops wrongly coded by the data source nodes_gdf, edges_gdf = fix_fake_self_loops(nodes_gdf, edges_gdf) if dead_ends: nodes_gdf, edges_gdf = fix_dead_ends(nodes_gdf, edges_gdf) if remove_islands: nodes_gdf, edges_gdf = remove_disconnected_islands(nodes_gdf, edges_gdf) if fix_topology: nodes_gdf, edges_gdf = fix_network_topology(nodes_gdf, edges_gdf) cycle = 0 while ( (not _are_edges_simplified(edges_gdf, preserve_direction) and same_vertexes_edges) | (not _are_nodes_simplified(nodes_gdf, edges_gdf, nodes_to_keep_regardless)) | (cycle == 0) ): edges_gdf["length"] = edges_gdf[ "geometry" ].length # recomputing length, to account for small changes cycle += 1 nodes_gdf, edges_gdf = clean_duplicate_nodes(nodes_gdf, edges_gdf) # eliminate loops if self_loops: edges_gdf = edges_gdf[edges_gdf["u"] != edges_gdf["v"]] if dead_ends: nodes_gdf, edges_gdf = fix_dead_ends(nodes_gdf, edges_gdf) nodes_gdf, edges_gdf = clean_duplicate_edges(nodes_gdf, edges_gdf, preserve_direction) # edges with different geometries but same u-v nodes pairs if same_vertexes_edges: nodes_gdf, edges_gdf = clean_same_vertexes_edges( nodes_gdf, edges_gdf, preserve_direction ) # simplify the graph nodes_gdf, edges_gdf = simplify_graph(nodes_gdf, edges_gdf, nodes_to_keep_regardless) # repreat eliminate loops if self_loops: edges_gdf = edges_gdf[edges_gdf["u"] != edges_gdf["v"]] if dead_ends: nodes_gdf, edges_gdf = fix_dead_ends(nodes_gdf, edges_gdf) # No second island removal here: the loop (node/edge de-duplication, dead-end and pseudo-node # removal) can only merge or peel, never split a component, so any islands were already removed # before the loop. nodes_gdf["x"], nodes_gdf["y"] = list( zip(*[(r.coords[0][0], r.coords[0][1]) for r in nodes_gdf.geometry], strict=False) ) edges_gdf = correct_edge_geometries(nodes_gdf, edges_gdf) # correct edges coordinates return _finalize_dataframes(nodes_gdf, edges_gdf, crs)
def _prepare_dataframes(nodes_gdf, edges_gdf): """ Prepare nodes and edges dataframes for further analysis by extracting the x,y coordinates of the nodes and adding new columns to the edges dataframe. Parameters ---------- nodes_gdf: Point GeoDataFrame nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. crs : str, or pyproj.CRS Coordinate Reference System for the output GeoDataFrames. Can be a string (e.g. 'EPSG:32633'), or a pyproj.CRS object. Returns: ---------- nodes_gdf, edges_gdf: tuple of GeoDataFrames The cleaned junctions and street segments GeoDataFrames. """ nodes_gdf = nodes_gdf.copy().set_index("nodeID", drop=False) edges_gdf = edges_gdf.copy().set_index("edgeID", drop=False) nodes_gdf.index.name, edges_gdf.index.name = None, None nodes_gdf["x"], nodes_gdf["y"] = nodes_gdf.geometry.x, nodes_gdf.geometry.y edges_gdf.sort_index(inplace=True) if "highway" in edges_gdf.columns: edges_gdf = edges_gdf[edges_gdf["highway"] != "elevator"] return nodes_gdf, edges_gdf def _finalize_dataframes(nodes_gdf, edges_gdf, crs): """ Final steps to output clean dataframes. Parameters ---------- nodes_gdf: Point GeoDataFrame nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. crs : str, or pyproj.CRS Coordinate Reference System for the output GeoDataFrames. Can be a string (e.g. 'EPSG:32633'), or a pyproj.CRS object. Returns: ---------- nodes_gdf, edges_gdf: tuple of GeoDataFrames The cleaned junctions and street segments GeoDataFrames. """ nodes_gdf.drop(["wkt"], axis=1, inplace=True, errors="ignore") # remove temporary columns edges_gdf.drop( ["coords", "tmp", "code", "wkt", "fixing", "to_fix"], axis=1, inplace=True, errors="ignore" ) # remove temporary columns edges_gdf["length"] = edges_gdf["geometry"].length edges_gdf.set_index("edgeID", drop=False, inplace=True, append=False) nodes_gdf.set_index("nodeID", drop=False, inplace=True, append=False) nodes_gdf.index.name = None edges_gdf.index.name = None nodes_gdf = convert_numeric_columns(nodes_gdf) edges_gdf = convert_numeric_columns(edges_gdf) nodes_gdf.set_crs(crs, inplace=True) edges_gdf.set_crs(crs, inplace=True) return nodes_gdf, edges_gdf def _are_nodes_simplified(nodes_gdf, edges_gdf, nodes_to_keep_regardless=None): """ The function checks the presence of pseudo-junctions, by using the edges_gdf GeoDataFrame. Parameters ---------- edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. Returns ------- bool Whether the nodes of the network are simplified or not. """ if nodes_to_keep_regardless is None: nodes_to_keep_regardless = [] degree = nodes_degree(edges_gdf) to_edit = [node for node, deg in degree.items() if deg == 2] # Exclude nodes to keep regardless if nodes_to_keep_regardless: to_edit = [node for node in to_edit if node not in nodes_to_keep_regardless] return len(to_edit) == 0 def _are_edges_simplified(edges_gdf, preserve_direction): """ The function checks the presence of possible duplicate geometries in the edges_gdf GeoDataFrame. Parameters ---------- edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. Returns ------- simplified: bool Whether the edges of the network are simplified or not. """ edges_gdf = edges_gdf.copy() if not preserve_direction: edges_gdf["code"] = np.where( edges_gdf["v"] >= edges_gdf["u"], edges_gdf["u"].astype(str) + "-" + edges_gdf["v"].astype(str), edges_gdf["v"].astype(str) + "-" + edges_gdf["u"].astype(str), ) else: edges_gdf["code"] = edges_gdf["u"].astype(str) + "-" + edges_gdf["v"].astype(str) duplicates = edges_gdf.duplicated("code") return not duplicates.any()
[docs] def clean_duplicate_nodes(nodes_gdf, edges_gdf): """ Removes duplicate nodes in a network based on coincident geometry, updating both nodes and edges GeoDataFrames. Nodes with exactly matching geometries are considered duplicates and merged into a single node. All references to duplicate node IDs in the edges GeoDataFrame are updated to the retained node ID. Parameters ---------- nodes_gdf : GeoDataFrame Point GeoDataFrame containing network nodes (junctions). edges_gdf : GeoDataFrame LineString GeoDataFrame containing street segments. Returns ------- nodes_gdf : GeoDataFrame Cleaned nodes GeoDataFrame with duplicates removed. edges_gdf : GeoDataFrame Edges GeoDataFrame with references to duplicate node IDs updated. """ nodes_gdf = nodes_gdf.copy().set_index("nodeID", drop=False) nodes_gdf.index.name = None # detecting duplicate geometries nodes_gdf["wkt"] = nodes_gdf["geometry"].apply(lambda geom: geom.wkt) # Detect duplicates subset_cols = ["wkt", "z"] if "z" in nodes_gdf.columns else ["wkt"] new_nodes = nodes_gdf.drop_duplicates(subset=subset_cols).copy() # assign univocal nodeID to edges which have 'u' or 'v' referring to duplicate nodes # Identify duplicate nodes to_edit = set(nodes_gdf.index) - set(new_nodes.index) if not to_edit: return nodes_gdf.drop(columns="wkt"), edges_gdf # No changes needed # Map duplicates to their new nodeIDs node_mapping = { old_node: new_nodes[new_nodes["geometry"] == nodes_gdf.loc[old_node, "geometry"]].index[0] for old_node in to_edit } # readjusting edges' nodes too, accordingly edges_gdf[["u", "v"]] = edges_gdf[["u", "v"]].replace(node_mapping) return new_nodes.drop(columns="wkt"), edges_gdf
[docs] def simplify_graph( nodes_gdf, edges_gdf, nodes_to_keep_regardless=None, ): """ The function identify pseudo-nodes, namely nodes that represent intersection between only 2 segments. The segments geometries are merged and the node is removed from the nodes_gdf GeoDataFrame. Parameters ---------- nodes_gdf: Point GeoDataFrame The nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. nodes_to_keep_regardless: list List of nodeIDs representing nodes to keep, even when pseudo-nodes (e.g. stations, when modelling transport networks). Returns ------- nodes_gdf, edges_gdf: tuple of GeoDataFrames The cleaned junctions and street segments GeoDataFrames. """ if nodes_to_keep_regardless is None: nodes_to_keep_regardless = [] nodes_gdf = nodes_gdf.copy() edges_gdf = edges_gdf.copy() to_edit = list(set(n for n, d in nodes_degree(edges_gdf).items() if d == 2)) if len(to_edit) == 0: return (nodes_gdf, edges_gdf) if nodes_to_keep_regardless: to_edit_list = list(to_edit) tmp_nodes = nodes_gdf[ (nodes_gdf["nodeID"].isin(to_edit_list)) & (~nodes_gdf["nodeID"].isin(nodes_to_keep_regardless)) ].copy() to_edit = list(tmp_nodes["nodeID"]) # Mutable edge state and a node->edges incidence map, so each pseudo-node is resolved with O(1) # dict lookups instead of the old per-node full-frame scan and per-merge .drop (both O(edges), # making the whole pass O(pseudo-nodes x edges)). The merge rules — which two incident edges # (lowest frame position first) and the orientation of the merged line — are unchanged. u_of = edges_gdf["u"].to_dict() v_of = edges_gdf["v"].to_dict() geom_of = edges_gdf["geometry"].to_dict() order = {eid: pos for pos, eid in enumerate(edges_gdf.index)} incidence = defaultdict(set) for eid in edges_gdf.index: incidence[u_of[eid]].add(eid) incidence[v_of[eid]].add(eid) dropped_edges: set = set() dropped_nodes: set = set() def _coord_key(coord, ndigits=10): return tuple(round(float(value), ndigits) for value in coord[:2]) for nodeID in to_edit: incident = sorted( (e for e in incidence[nodeID] if e not in dropped_edges), key=order.__getitem__ ) if len(incident) == 0: dropped_nodes.add(nodeID) continue if len(incident) == 1: continue # possible dead end first, second = incident[0], incident[1] u1, v1, u2, v2 = u_of[first], v_of[first], u_of[second], v_of[second] coords_first, coords_second = list(geom_of[first].coords), list(geom_of[second].coords) if u1 == u2: # meeting at u new_u, new_v = v1, v2 line_a, line_b = coords_first[::-1], coords_second elif u1 == v2: # meeting at u and v new_u, new_v = u2, v1 line_a, line_b = coords_second, coords_first elif v1 == u2: # meeting at v and u new_u, new_v = u1, v2 line_a, line_b = coords_first, coords_second else: # meeting at v and v new_u, new_v = u1, u2 line_a, line_b = coords_first, coords_second[::-1] # detach both edges from their endpoints and remove the pseudo-node and second segment incidence[u1].discard(first) incidence[v1].discard(first) incidence[u2].discard(second) incidence[v2].discard(second) dropped_edges.add(second) dropped_nodes.add(nodeID) # if the merge would create a node-line (u == v), drop the first segment too if new_u == new_v: dropped_edges.add(first) continue if _coord_key(line_a[-1]) == _coord_key(line_b[0]): merged_line = line_a + line_b[1:] else: merged_line = line_a + line_b u_of[first], v_of[first] = new_u, new_v geom_of[first] = LineString(merged_line) incidence[new_u].add(first) incidence[new_v].add(first) surviving = [eid for eid in edges_gdf.index if eid not in dropped_edges] edges_gdf = edges_gdf.loc[surviving].copy() edges_gdf["u"] = edges_gdf.index.map(u_of) edges_gdf["v"] = edges_gdf.index.map(v_of) edges_gdf["geometry"] = edges_gdf.index.map(geom_of) edges_gdf = edges_gdf[edges_gdf["u"] != edges_gdf["v"]] # eliminate node-lines if dropped_nodes: nodes_gdf = nodes_gdf.drop( index=[n for n in dropped_nodes if n in nodes_gdf.index], errors="ignore" ) return nodes_gdf, edges_gdf
[docs] def fix_dead_ends(nodes_gdf, edges_gdf): """ The function removes dead-ends. In other words, it eliminates nodes from where only one segment originates, and the relative segment. Parameters ---------- nodes_gdf: Point GeoDataFrame The nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame Returns ------- nodes_gdf, edges_gdf: tuple of GeoDataFrames The cleaned junctions and street segments GeoDataFrames. """ nodes_gdf, edges_gdf = nodes_gdf.copy(), edges_gdf.copy() # Find dead-end nodes degree = nodes_degree(edges_gdf) dead_end_nodes = [node for node, deg in degree.items() if deg == 1] if not dead_end_nodes: return nodes_gdf, edges_gdf # Drop dead-end nodes and their edges nodes_gdf = nodes_gdf.drop(dead_end_nodes) edges_gdf = edges_gdf[ ~edges_gdf["u"].isin(dead_end_nodes) & ~edges_gdf["v"].isin(dead_end_nodes) ] return nodes_gdf, edges_gdf
[docs] def clean_same_vertexes_edges(nodes_gdf, edges_gdf, preserve_direction=False): """ Removes duplicate edges with the same start and end nodes (same vertexes) in a network GeoDataFrame. For each pair of edges with the same node pair ('u', 'v'), the function: - Keeps only the longest edge if one is at least 10% longer than the other(s). - If their lengths are similar, generates a center line geometry to represent both and assigns it to one edge. - Drops all other duplicate edges. - Updates the node GeoDataFrame to retain only nodes still referenced by any edge. If `preserve_direction` is False, treats edges as undirected (edges (u,v) and (v,u) are considered duplicates). If True, edges in opposite directions are not treated as duplicates. Parameters ---------- nodes_gdf : GeoDataFrame GeoDataFrame of nodes (junctions), must include unique node IDs. edges_gdf : GeoDataFrame GeoDataFrame of street segments (edges), must include 'u', 'v', 'geometry', and 'length' columns. preserve_direction : bool Whether to preserve edge direction (see above). Returns ------- nodes_gdf : GeoDataFrame Filtered nodes, only those referenced by remaining edges. edges_gdf : GeoDataFrame Deduplicated edges with updated geometry where applicable. """ to_drop = set() if not preserve_direction: edges_gdf["code"] = np.where( edges_gdf["v"] >= edges_gdf["u"], edges_gdf.u.astype(str) + "-" + edges_gdf.v.astype(str), edges_gdf.v.astype(str) + "-" + edges_gdf.u.astype(str), ) else: edges_gdf["code"] = edges_gdf.u.astype(str) + "-" + edges_gdf.v.astype(str) if not edges_gdf.duplicated("code").any(): return nodes_gdf, edges_gdf groups = ( edges_gdf.groupby("code") .filter(lambda x: len(x) > 1)[["code", "length", "edgeID"]] .sort_values(by=["code", "length"]) ) max_lengths = edges_gdf.groupby("code").agg({"length": "max"}).to_dict()["length"] for code, g in edges_gdf.groupby("code"): if g[g.length < max_lengths[code] * 0.9].shape[0] > 0: to_drop.update(list(g[g.length < max_lengths[code] * 0.9]["edgeID"])) groups = groups.drop(list(to_drop), axis=0) groups_filtered = ( groups.groupby("code") .filter(lambda x: len(x) > 1)[["code", "length", "edgeID"]] .sort_values(by=["code", "length"]) ) first_indexes = list(groups_filtered.groupby("code")[["edgeID"]].first()["edgeID"]) others = set(groups_filtered["edgeID"].to_list()) - set(first_indexes) to_drop.update(others) # Update the geometry of the first edge in each group to the center line of the edge to update for index in first_indexes: code = edges_gdf.loc[index]["code"] geometryA = edges_gdf.loc[index].geometry geometryB = edges_gdf.query("code == @code").iloc[1].geometry cl = center_line([geometryA, geometryB]) edges_gdf.at[index, "geometry"] = cl edges_gdf = edges_gdf.drop(list(to_drop), axis=0) # only keep nodes which are actually used by the edges in the GeoDataFrame to_keep = list(set(list(edges_gdf["u"].unique()) + list(edges_gdf["v"].unique()))) nodes_gdf = nodes_gdf[nodes_gdf["nodeID"].isin(to_keep)] return nodes_gdf, edges_gdf
[docs] def clean_duplicate_edges( nodes_gdf, edges_gdf, preserve_direction=False, ): """ Cleans and deduplicates network edges, and removes unused nodes. The function performs the following: - Generates a unique 'code' for each edge, based on node IDs, with or without preserving direction. - Removes self-loop edges (edges from a node to itself). - Drops duplicate edges based on geometry (including reversal if direction is not preserved). - Removes edges that are geometrically duplicates, even if node order is reversed (for undirected graphs). - Updates the node GeoDataFrame to keep only those nodes actually used by the remaining edges. Parameters ---------- nodes_gdf : GeoDataFrame GeoDataFrame containing nodes, must include a 'nodeID' column. edges_gdf : GeoDataFrame GeoDataFrame containing edges, must include 'u', 'v', and 'geometry' columns. preserve_direction : bool, optional If True, edge direction is preserved; edges (u,v) and (v,u) are considered distinct. If False, edges are treated as undirected and geometric duplicates (with reversed coords) are removed. Default is False. Returns ------- nodes_gdf : GeoDataFrame Filtered nodes GeoDataFrame, containing only nodes referenced by the cleaned edges. edges_gdf : GeoDataFrame Cleaned edges GeoDataFrame, deduplicated and without self-loops. """ if not preserve_direction: edges_gdf["code"] = np.where( edges_gdf["v"] >= edges_gdf["u"], edges_gdf.u.astype(str) + "-" + edges_gdf.v.astype(str), edges_gdf.v.astype(str) + "-" + edges_gdf.u.astype(str), ) else: edges_gdf["code"] = edges_gdf.u.astype(str) + "-" + edges_gdf.v.astype(str) # eliminate node-lines edges_gdf = edges_gdf[edges_gdf["u"] != edges_gdf["v"]] # dropping duplicate-geometries edges geometries = edges_gdf["geometry"].apply(lambda geom: geom.wkb) edges_gdf = edges_gdf.loc[geometries.drop_duplicates().index] # dropping edges with same geometry but with coords in different orders (depending on their directions) # Reordering coordinates to allow for comparison between edges edges_gdf["coords"] = [list(c.coords) for c in edges_gdf.geometry] if not preserve_direction: condition = (edges_gdf.u.astype(str) + "-" + edges_gdf.v.astype(str)) != edges_gdf.code edges_gdf.loc[condition, "coords"] = pd.Series( [x[::-1] for x in edges_gdf.loc[condition]["coords"]], index=edges_gdf.loc[condition].index, ) edges_gdf["tmp"] = edges_gdf["coords"].apply(tuple) edges_gdf.drop_duplicates(["tmp"], keep="first", inplace=True) # only keep nodes which are actually used by the edges in the GeoDataFrame to_keep = list(set(list(edges_gdf["u"].unique()) + list(edges_gdf["v"].unique()))) nodes_gdf = nodes_gdf[nodes_gdf["nodeID"].isin(to_keep)] return nodes_gdf, edges_gdf
[docs] def correct_edge_geometries(nodes_gdf, edges_gdf): """ The function adjusts the edges LineString coordinates consistently with their relative u and v nodes' coordinates. It might be necessary to run the function after having cleaned the network. Parameters ---------- nodes_gdf: Point GeoDataFrame The nodes (junctions) GeoDataFrame. edges_gdf: LineString GeoDataFrame The street segments GeoDataFrame. Returns ------- edges_gdf: LineString GeoDataFrame The updated street segments GeoDataFrame. """ def _update_line_geometry_coords(u, v, nodes_gdf, line_geometry): """ It supports the correct_edges function checks that the edges coordinates are consistent with their relative u and v nodes'coordinates. It can be necessary to run the function after having cleaned the network. """ line_coords = list(line_geometry.coords) line_coords[0] = (nodes_gdf.loc[u]["x"], nodes_gdf.loc[u]["y"]) line_coords[-1] = (nodes_gdf.loc[v]["x"], nodes_gdf.loc[v]["y"]) new_line_geometry = LineString([coor for coor in line_coords]) return new_line_geometry edges_gdf["geometry"] = edges_gdf.apply( lambda row: _update_line_geometry_coords(row["u"], row["v"], nodes_gdf, row["geometry"]), axis=1, ) return edges_gdf
# ----------------------------------------------------------------------------- # Node/edge consolidation # -----------------------------------------------------------------------------
[docs] def consolidate_nodes( nodes_gdf, edges_gdf, consolidate_edges_too=False, tolerance=20, ): """ Consolidates nodes in a spatial network that are within a given distance (tolerance), preserving topology and unclustered nodes. Nodes within `tolerance` distance are clustered together and represented by a single consolidated node at the cluster centroid. For clusters containing disconnected components, each connected component is further split into its own consolidated node. Optionally, edges can be updated to reference the new consolidated node IDs and geometries. Parameters ---------- nodes_gdf : GeoDataFrame GeoDataFrame of nodes, must include columns 'nodeID' and 'geometry'. If present, 'z' is averaged for clusters. edges_gdf : GeoDataFrame GeoDataFrame of edges for checking network connectivity. consolidate_edges_too : bool, optional If True, also returns the updated edges GeoDataFrame (default: False). tolerance : float, optional Distance threshold for clustering nodes (in CRS units). Nodes within this distance are merged (default: 20). Returns ------- consolidated_nodes_gdf : GeoDataFrame GeoDataFrame of consolidated nodes. Columns include: - 'old_nodeIDs': list of merged node IDs - 'x', 'y': centroid coordinates - 'z' (optional): averaged elevation for the cluster - 'nodeID': new node ID - 'geometry': consolidated node Point geometry consolidated_edges_gdf : GeoDataFrame (optional) Only returned if `consolidate_edges_too` is True. Edges with endpoints mapped to new consolidated node IDs and geometries """ nodes_gdf = nodes_gdf.copy().set_index("nodeID", drop=False) nodes_gdf.index.name = None nodes_gdf.drop(columns=["x", "y"], inplace=True, errors="ignore") graph = graph_fromGDF(nodes_gdf, edges_gdf) # Step 1: Cluster nodes within tolerance clusters = nodes_gdf.buffer(tolerance).union_all() clusters = clusters.geoms if hasattr(clusters, "geoms") else [clusters] clusters = gpd.GeoDataFrame(geometry=gpd.GeoSeries(clusters, crs=nodes_gdf.crs)) clusters["x"] = clusters.geometry.centroid.x clusters["y"] = clusters.geometry.centroid.y # Step 2: Assign nodes to clusters new_column = "new_nodeID" gdf = gpd.sjoin(nodes_gdf, clusters, how="left", predicate="within").drop(columns="geometry") gdf.rename(columns={"index_right": new_column}, inplace=True) new_nodeID = gdf[new_column].max() + 1 # Step 3: Split non-connected components in clusters for _cluster_label, nodes_subset in gdf.groupby(new_column): if len(nodes_subset) > 1: # Skip unclustered nodes wccs = list(nx.connected_components(graph.subgraph(nodes_subset.index))) if len(wccs) > 1: for wcc in wccs: idx = list(wcc) subcluster_centroid = nodes_gdf.loc[idx].geometry.unary_union.centroid gdf.loc[idx, ["x", "y"]] = subcluster_centroid.x, subcluster_centroid.y gdf.loc[idx, new_column] = new_nodeID new_nodeID += 1 # Step 4: Consolidate nodes, but preserve unclustered ones consolidated_nodes = [] has_z = "z" in nodes_gdf.columns oldIDs_column = "old_nodeID" for new_nodeID, nodes_subset in gdf.groupby(new_column): old_nodeIDs = nodes_subset["nodeID"].to_list() cluster_x, cluster_y = nodes_subset.iloc[0][["x", "y"]] new_node = { oldIDs_column: old_nodeIDs, "x": cluster_x, "y": cluster_y, "nodeID": new_nodeID, } if has_z: new_node["z"] = ( nodes_gdf.loc[old_nodeIDs, "z"].mean() if len(old_nodeIDs) > 1 else nodes_gdf.loc[old_nodeIDs[0], "z"] ) consolidated_nodes.append(new_node) # Convert list of dicts to DataFrame consolidated_nodes_df = pd.DataFrame(consolidated_nodes) # Create final GeoDataFrame consolidated_nodes_gdf = gpd.GeoDataFrame( consolidated_nodes_df, geometry=gpd.points_from_xy( consolidated_nodes_df["x"], consolidated_nodes_df["y"], consolidated_nodes_df["z"] if "z" in consolidated_nodes_df.columns else None, ), crs=nodes_gdf.crs, ) if consolidate_edges_too: return consolidated_nodes_gdf, consolidate_edges(edges_gdf, consolidated_nodes_gdf) return consolidated_nodes_gdf
[docs] def consolidate_edges(edges_gdf, consolidated_nodes_gdf): """Consolidate edge geometries after node consolidation. Parameters ---------- nodes_gdf : geopandas.GeoDataFrame cityImage node table. edges_gdf : geopandas.GeoDataFrame cityImage edge table. consolidation_map : dict Mapping from original node IDs to consolidated node IDs. Returns ------- geopandas.GeoDataFrame Edge table with updated endpoints and geometries. Notes ----- This helper preserves cityImage endpoint and identifier semantics while applying geometry consolidation. It is not a generic line-merge wrapper. """ oldIDs_column = "old_nodeID" # Create a mapping from old_nodeIDs to their corresponding nodeID and geometry nodes_mapping = consolidated_nodes_gdf.explode(oldIDs_column)[ [oldIDs_column, "geometry", "nodeID"] ].set_index(oldIDs_column) def _update_edge(row): old_u, old_v, geom = row["u"], row["v"], row["geometry"] # Map old_u and old_v to their corresponding new nodeIDs new_u_id = nodes_mapping.loc[old_u, "nodeID"] new_v_id = nodes_mapping.loc[old_v, "nodeID"] # Get the new geometries for u and v new_u_geom = nodes_mapping.loc[old_u, "geometry"] new_v_geom = nodes_mapping.loc[old_v, "geometry"] # Update the geometry (replace first and last coordinates) if isinstance(geom, LineString): new_coords = [new_u_geom.coords[0]] + list(geom.coords[1:-1]) + [new_v_geom.coords[0]] geom = LineString(new_coords) return pd.Series({"u": new_u_id, "v": new_v_id, "geometry": geom}) # Apply updates to the edges consolidated_edges = edges_gdf.copy() consolidated_edges[["u", "v", "geometry"]] = consolidated_edges.apply(_update_edge, axis=1) consolidated_edges = consolidated_edges[consolidated_edges.u != consolidated_edges.v] consolidated_edges.index = consolidated_edges["edgeID"] consolidated_edges.index.name = None return consolidated_edges