Source code for cityImage.regions

"""District/region and gateway semantics.

This module keeps the cityImage-specific district/gateway semantics while
making community detection an explicitly delegated operation.

Preferred delegated workflow:

```python
partition = community.best_partition(dual_graph, weight="topo")
regions = ci.regions_from_dual_partition(partition, dual_graph, edges_gdf, column="p_topo")
```

Convenience wrappers ``identify_regions`` and ``identify_regions_primal`` are
retained, but they lazy-import ``python-louvain``/``community`` and only wrap its
``best_partition`` result.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

import geopandas as gpd
import numpy as np
import pandas as pd
from shapely.ops import polygonize_full, unary_union

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

INVALID_DISTRICT = 999999


def _best_partition(graph: Any, weight: str) -> dict[Any, int]:
    """Delegate modularity/community detection to python-louvain (imported lazily)."""
    import community

    return community.best_partition(graph, weight=weight)


def _graph_from_gdfs(
    nodes_gdf: gpd.GeoDataFrame,
    edges_gdf: gpd.GeoDataFrame,
):
    """Build a NetworkX graph using cityImage graph semantics, imported lazily."""
    from .graph import graph_fromGDF

    return graph_fromGDF(nodes_gdf, edges_gdf)


def _dual_id_dict(partition: Mapping[Any, int], dual_graph: Any, attribute: str) -> dict[Any, int]:
    """Map a dual-graph node partition to a primal edge attribute value."""
    regions: dict[Any, int] = {}
    for node, district in partition.items():
        regions[dual_graph.nodes[node][attribute]] = district
    return regions


def _min_distance_geometry_gdf(geometry: Any, gdf: gpd.GeoDataFrame) -> tuple[float, Any]:
    """Return minimum distance and index label of the nearest geometry row."""
    if gdf.empty:
        raise ValueError("Cannot calculate nearest geometry from an empty GeoDataFrame")

    distances = gdf.geometry.distance(geometry)
    index = distances.idxmin()
    return float(distances.loc[index]), index


def _geometry_union(geometries: gpd.GeoSeries) -> Any:
    """Union geometries with GeoPandas/Shapely compatibility."""
    if hasattr(geometries, "union_all"):
        return geometries.union_all()
    return unary_union(list(geometries))


[docs] def regions_from_dual_partition( partition: Mapping[Any, int], dual_graph: Any, edges_gdf: gpd.GeoDataFrame, *, column: str = "p_topo", ) -> gpd.GeoDataFrame: """Assign a precomputed dual-graph partition to primal street segments. Parameters ---------- partition Mapping from dual-graph node IDs to district/community IDs. dual_graph Dual graph whose nodes expose an ``edgeID`` attribute. edges_gdf Primal street-segment GeoDataFrame containing an ``edgeID`` column. column Output district column name. """ edge_partition = _dual_id_dict(partition, dual_graph, "edgeID") regions = edges_gdf.copy() regions[column] = regions["edgeID"].map(edge_partition) return regions
[docs] def regions_from_primal_partition( partition: Mapping[Any, int], nodes_gdf: gpd.GeoDataFrame, *, column: str = "p_topo", ) -> gpd.GeoDataFrame: """Assign a precomputed primal-graph partition to street nodes.""" regions = nodes_gdf.copy() regions[column] = regions["nodeID"].map(partition) return regions
[docs] def identify_regions( dual_graph: Any, edges_gdf: gpd.GeoDataFrame, weight: str | None = None ) -> gpd.GeoDataFrame: """Identify edge-based regions using delegated python-louvain partitioning. This convenience function preserves the old cityImage behaviour but the community-detection algorithm itself is delegated to python-louvain. """ if weight is None: weight = "topo" partition = _best_partition(dual_graph, weight=weight) return regions_from_dual_partition( partition, dual_graph, edges_gdf, column=f"p_{weight}", )
[docs] def identify_regions_primal( graph: Any, nodes_gdf: gpd.GeoDataFrame, weight: str | None = None ) -> gpd.GeoDataFrame: """Identify node-based regions using delegated python-louvain partitioning.""" if weight is None: weight = "topo" partition = _best_partition(graph, weight=weight) return regions_from_primal_partition(partition, nodes_gdf, column=f"p_{weight}")
[docs] def polygonise_partitions( edges_gdf: gpd.GeoDataFrame, column: str, convex_hull: bool = True, buffer: float = 30, ) -> gpd.GeoDataFrame: """Create district polygons from edge-based partition labels. This preserves the old geometry pipeline: union partition edges, ``polygonize_full``, union the result, buffer, and optionally return the convex hull. """ polygons = [] partition_ids = [] for partition_id in edges_gdf[column].unique(): partition_edges = edges_gdf[edges_gdf[column] == partition_id] polygonised = polygonize_full(_geometry_union(partition_edges.geometry)) polygon = unary_union(polygonised).buffer(buffer) polygons.append(polygon.convex_hull if convex_hull else polygon) partition_ids.append(partition_id) return gpd.GeoDataFrame( {column: partition_ids}, geometry=polygons, crs=edges_gdf.crs, )
[docs] def district_to_nodes_from_edges( nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, ) -> gpd.GeoDataFrame: """Assign edge-based district IDs to nodes using nearest street segment.""" nodes_gdf = nodes_gdf.copy() sindex = edges_gdf.sindex nodes_gdf[column] = nodes_gdf.apply( lambda row: _assign_district_to_node(row["geometry"], edges_gdf, sindex, column), axis=1, ) nodes_gdf[column] = nodes_gdf[column].astype(int) return nodes_gdf
def _assign_district_to_node( node_geometry: Any, edges_gdf: gpd.GeoDataFrame, sindex: Any, column: str, ) -> int: """Assign one node to the district of its nearest edge.""" search_area = node_geometry.buffer(100) possible_matches_index = list(sindex.intersection(search_area.bounds)) possible_matches = edges_gdf.iloc[possible_matches_index].copy() _, nearest_index = _min_distance_geometry_gdf(node_geometry, possible_matches) return int(edges_gdf.loc[nearest_index][column])
[docs] def districts_to_edges_from_nodes( nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, ) -> gpd.GeoDataFrame: """Assign node-based district IDs to edges. The output preserves the old columns: ``{column}_uv``, ``{column}_u``, and ``{column}_v``. """ edges_gdf = edges_gdf.copy() edges_gdf[f"{column}_uv"] = INVALID_DISTRICT edges_gdf[f"{column}_u"] = INVALID_DISTRICT edges_gdf[f"{column}_v"] = INVALID_DISTRICT edges_gdf[[f"{column}_uv", f"{column}_u", f"{column}_v"]] = edges_gdf.apply( lambda row: _assign_district_to_edge(row["edgeID"], nodes_gdf, edges_gdf, column), axis=1, result_type="expand", ) return edges_gdf
def _assign_district_to_edge( edge_id: Any, nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, ) -> tuple[int, int, int]: """Return edge district assignment from the districts of endpoint nodes.""" edge = edges_gdf.loc[edge_id] district_u = int(nodes_gdf.loc[edge.u][column]) district_v = int(nodes_gdf.loc[edge.v][column]) district_uv = district_u if district_u == district_v else INVALID_DISTRICT return district_uv, district_u, district_v
[docs] def district_to_nodes_from_polygons( nodes_gdf: gpd.GeoDataFrame, partitions_gdf: gpd.GeoDataFrame, column: str, ) -> gpd.GeoDataFrame: """Assign polygon-based district IDs to nodes using nearest polygon.""" nodes_gdf = nodes_gdf.copy() nodes_gdf[column] = nodes_gdf.apply( lambda row: _assign_district_to_node_from_polygons(row["geometry"], partitions_gdf, column), axis=1, ) nodes_gdf[column] = nodes_gdf[column].astype(int) return nodes_gdf
def _assign_district_to_node_from_polygons( node_geometry: Any, partitions_gdf: gpd.GeoDataFrame, column: str, ) -> int: """Assign one node to the nearest district polygon.""" _, nearest_index = _min_distance_geometry_gdf(node_geometry, partitions_gdf) return int(partitions_gdf.loc[nearest_index][column])
[docs] def amend_nodes_membership( nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, min_size_district: int = 10, ) -> gpd.GeoDataFrame: """Amend node membership based on connectivity and minimum district size.""" nodes_gdf = nodes_gdf.copy() # Node look-ups below use .loc by nodeID, so index by nodeID regardless of the caller's index # (a plain RangeIndex would otherwise KeyError once a small/disconnected district is amended). nodes_gdf = nodes_gdf.set_index("nodeID", drop=False) nodes_gdf.index.name = None nodes_gdf = _check_disconnected_districts(nodes_gdf, edges_gdf, column, min_size_district) while INVALID_DISTRICT in nodes_gdf[column].unique(): current_nodes_gdf = nodes_gdf nodes_gdf[column] = nodes_gdf.apply( lambda row, current_nodes_gdf=current_nodes_gdf: _amend_node_membership( row["nodeID"], current_nodes_gdf, edges_gdf, column ), axis=1, ) nodes_gdf = _check_disconnected_districts(nodes_gdf, edges_gdf, column, min_size_district) return nodes_gdf
def _amend_node_membership( node_id: Any, nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, ) -> int: """Amend one invalid node to the most plausible neighbouring district.""" if nodes_gdf.loc[node_id][column] != INVALID_DISTRICT: return int(nodes_gdf.loc[node_id][column]) tmp_edges = edges_gdf[(edges_gdf.u == node_id) | (edges_gdf.v == node_id)].copy() unique_nodes = list(np.unique(tmp_edges[["u", "v"]].values)) unique_nodes.remove(node_id) tmp_nodes = nodes_gdf[ (nodes_gdf.nodeID.isin(unique_nodes)) & (nodes_gdf[column] != INVALID_DISTRICT) ].copy() if tmp_nodes.empty: return INVALID_DISTRICT districts_sorted = tmp_nodes[column].value_counts(sort=True, ascending=False) if len(districts_sorted) == 1: return int(districts_sorted.idxmax()) if districts_sorted.iloc[0] > districts_sorted.iloc[1]: return int(districts_sorted.idxmax()) candidate_districts = list(districts_sorted.iloc[0:2].index) tmp_nodes = tmp_nodes[tmp_nodes[column].isin(candidate_districts)] _, closest_index = _min_distance_geometry_gdf(nodes_gdf.loc[node_id].geometry, tmp_nodes) return int(tmp_nodes.loc[closest_index][column]) def _check_disconnected_districts( nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, min_size: int = 10, ) -> gpd.GeoDataFrame: """Mark too-small/disconnected districts as invalid.""" try: import networkx as nx except ImportError as exc: raise ImportError("amend_nodes_membership requires networkx") from exc nodes_gdf = nodes_gdf.copy() for district in nodes_gdf[column].unique(): if district == INVALID_DISTRICT: continue tmp_nodes = nodes_gdf[nodes_gdf[column] == district].copy() tmp_edges = edges_gdf[ edges_gdf.u.isin(tmp_nodes.nodeID) & edges_gdf.v.isin(tmp_nodes.nodeID) ].copy() if len(tmp_nodes) < min_size: nodes_gdf.loc[nodes_gdf.nodeID.isin(tmp_nodes.nodeID), column] = INVALID_DISTRICT continue tmp_graph = _graph_from_gdfs(tmp_nodes, tmp_edges) if not nx.is_connected(tmp_graph): largest_component = max(nx.connected_components(tmp_graph), key=len) component_graph = tmp_graph.subgraph(largest_component) to_check = [ node for node in list(tmp_nodes.nodeID) if node not in list(component_graph.nodes()) ] nodes_gdf.loc[nodes_gdf.nodeID.isin(to_check), column] = INVALID_DISTRICT return nodes_gdf
[docs] def find_gateways( nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, ) -> gpd.GeoDataFrame: """Identify nodes lying on a district boundary.""" nodes_gdf = nodes_gdf.copy() nodes_gdf["gateway"] = nodes_gdf.apply( lambda row: _gateway(row["nodeID"], nodes_gdf, edges_gdf, column), axis=1, ) return nodes_gdf
def _gateway( node_id: Any, nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame, column: str, ) -> int: """Return 1 if a node connects to another district, otherwise 0.""" connected_edges = edges_gdf[(edges_gdf.u == node_id) | (edges_gdf.v == node_id)].copy() connected_nodes = nodes_gdf[ nodes_gdf.nodeID.isin(connected_edges.u) | nodes_gdf.nodeID.isin(connected_edges.v) ].copy() if len(connected_nodes[column].unique()) > 1: return 1 return 0