Source code for cityImage.centrality

"""Network centrality helpers used by cityImage.

This module replaces the old ``graph_centrality.py`` module.

Design decision
---------------
cityImage no longer owns generic network centrality algorithms. Standard
centralities are delegated to NetworkX or igraph. cityImage keeps only the thin
API glue and the two project-specific metrics that were already part of the
library behaviour:

* reach centrality based on reachable node attributes within a network radius;
* straightness centrality using Euclidean/network-distance ratios.

``igraph`` is a core dependency but is still imported lazily (only when a centrality
operation runs), so ``import cityImage`` stays light.
"""

from __future__ import annotations

from math import sqrt
from typing import Any

import pandas as pd

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


def _import_igraph():
    """Import igraph lazily, keeping it out of module import so ``import cityImage`` stays light."""
    import igraph as ig

    return ig


def _nodes_dict(ig_graph: Any) -> dict[int, tuple[float, float]]:
    """Create a dictionary of igraph node indices and their x/y coordinates."""
    return {v.index: (v["x"], v["y"]) for v in ig_graph.vs}


def _assign_attributes_from_nx_to_igraph(nx_graph: Any, ig_graph: Any) -> None:
    """Transfer NetworkX node attributes to matching igraph vertices."""
    original_ids = list(nx_graph.nodes)
    for idx, node in enumerate(ig_graph.vs):
        nx_node_attrs = nx_graph.nodes[original_ids[idx]]
        for attr, value in nx_node_attrs.items():
            node[attr] = value


def _euclidean_distance(xs: float, ys: float, xt: float, yt: float) -> float:
    """Return Euclidean distance between source and target coordinates."""
    return sqrt((xs - xt) ** 2 + (ys - yt) ** 2)


def _nx_to_igraph(nx_graph: Any, weight: str) -> tuple[Any, list[Any]]:
    """Convert a NetworkX graph to igraph and preserve node attributes."""
    ig = _import_igraph()

    original_ids = list(nx_graph.nodes)
    id_to_index = {original_id: index for index, original_id in enumerate(original_ids)}

    edges = [(id_to_index[u], id_to_index[v]) for u, v in nx_graph.edges()]
    weights = [nx_graph[u][v][weight] for u, v in nx_graph.edges()]

    ig_graph = ig.Graph(edges=edges, directed=nx_graph.is_directed())
    ig_graph.es["weight"] = weights
    _assign_attributes_from_nx_to_igraph(nx_graph, ig_graph)

    return ig_graph, original_ids


def _networkx_centrality(
    nx_graph: Any,
    *,
    measure: str,
    weight: str,
    normalized: bool,
) -> dict[Any, float]:
    """Delegate standard centralities to NetworkX."""
    if measure == "betweenness":
        return nx_graph.__class__.__module__ and __import__("networkx").betweenness_centrality(
            nx_graph,
            weight=weight,
            normalized=normalized,
        )

    if measure == "closeness":
        # NetworkX's closeness centrality uses the ``distance`` keyword for edge
        # distances. The function is normalised according to NetworkX semantics.
        return __import__("networkx").closeness_centrality(nx_graph, distance=weight)

    raise ValueError(f"NetworkX backend does not support measure: {measure}")


[docs] def calculate_centrality( nx_graph: Any, measure: str = "betweenness", weight: str = "weight", radius: float | None = None, attribute: str | None = None, normalized: bool = False, backend: str = "igraph", ) -> dict[Any, float]: """Calculate centrality values for a NetworkX graph. Parameters ---------- nx_graph Input NetworkX graph. measure One of ``"betweenness"``, ``"closeness"``, ``"straightness"``, or ``"reach"``. weight Edge attribute used as distance/weight. radius Required for ``measure="reach"``. attribute Required for ``measure="reach"``; node attribute to sum over reachable nodes. normalized Normalisation flag. Preserves the existing straightness semantics and is also passed through to NetworkX betweenness when ``backend="networkx"``. backend ``"igraph"`` preserves historical cityImage behaviour. ``"networkx"`` delegates standard betweenness/closeness directly to NetworkX. Reach and straightness always use the igraph-distance implementation because they are cityImage-specific helpers. Returns ------- dict Mapping from original NetworkX node IDs to centrality values. """ if measure in {"betweenness", "closeness"} and backend == "networkx": return _networkx_centrality( nx_graph, measure=measure, weight=weight, normalized=normalized, ) if backend != "igraph": raise ValueError("backend must be either 'igraph' or 'networkx'") ig_graph, original_ids = _nx_to_igraph(nx_graph, weight=weight) if measure == "betweenness": centrality_values = ig_graph.betweenness(weights="weight") elif measure == "closeness": centrality_values = ig_graph.closeness(weights="weight") elif measure == "straightness": centrality_values = straightness_centrality( ig_graph, weight="weight", normalized=normalized, ) elif measure == "reach": if radius is None or attribute is None: raise ValueError( "For reach centrality, both radius and attribute parameters must be provided" ) centrality_values = reach_centrality( ig_graph, weight="weight", radius=radius, attribute=attribute, ) else: raise ValueError(f"Unsupported centrality type: {measure}") return {original_ids[i]: centrality_values[i] for i in range(len(original_ids))}
[docs] def reach_centrality( ig_graph: Any, weight: str, radius: float, attribute: str, ) -> dict[int, float]: """Calculate reach centrality from an igraph graph. Reach centrality is the sum of a node attribute over all other nodes reachable within a network-distance radius. """ n_nodes = ig_graph.vcount() reach: dict[int, float] = {} shortest_paths = ig_graph.distances(weights=weight) for node in range(n_nodes): sp = shortest_paths[node] reach[node] = sum( ig_graph.vs[target][attribute] for target, dist in enumerate(sp) if dist != float("inf") and dist <= radius and target != node ) return reach
[docs] def straightness_centrality( ig_graph: Any, weight: str, normalized: bool = True, ) -> dict[int, float]: """Calculate straightness centrality from an igraph graph. Straightness is the mean ratio between Euclidean distance and weighted network distance to all reachable targets. """ n_nodes = ig_graph.vcount() coord_nodes = _nodes_dict(ig_graph) values: dict[int, float] = {} shortest_paths = ig_graph.distances(weights=weight) for node in range(n_nodes): sp = shortest_paths[node] if len(sp) > 0 and n_nodes > 1: straightness = 0.0 for target, network_dist in enumerate(sp): if node != target and network_dist < float("inf"): euclidean_dist = _euclidean_distance(*coord_nodes[node], *coord_nodes[target]) straightness += euclidean_dist / network_dist values[node] = straightness / (n_nodes - 1.0) if normalized: reachable_nodes = sum(1 for dist in sp if dist < float("inf")) - 1 if reachable_nodes > 0: values[node] *= (n_nodes - 1.0) / reachable_nodes else: values[node] = 0.0 else: values[node] = 0.0 return values
[docs] def weight_nodes( nodes_gdf: Any, services_gdf: Any, nx_graph: Any, field_name: str, radius: float = 400, ) -> Any: """Assign local opportunity counts to nodes and to the NetworkX graph.""" sindex = services_gdf.sindex for node_id, node in nodes_gdf.iterrows(): buffer = node["geometry"].buffer(radius) possible_matches_index = list(sindex.intersection(buffer.bounds)) possible_matches = services_gdf.iloc[possible_matches_index] precise_matches = possible_matches[possible_matches.intersects(buffer)] weight_value = len(precise_matches) nodes_gdf.at[node_id, field_name] = weight_value nx_graph.nodes[node_id][field_name] = weight_value return nx_graph
def _dict_to_df(list_dict: list[dict[Any, Any]], list_col: list[str]) -> pd.DataFrame: """Build a DataFrame from aligned dictionaries without depending on utilities.py.""" df = pd.DataFrame(list_dict).T df.columns = [f"d{i}" for i, _ in enumerate(df, 1)] df.columns = list_col return df
[docs] def append_edges_metrics( edges_gdf: pd.DataFrame, graph: Any, dicts: list[dict[Any, Any]], column_names: list[str], ) -> pd.DataFrame: """Attach edge-level centrality values to an edges GeoDataFrame.""" edge_ids = {(u, v): graph[u][v]["edgeID"] for u, v in graph.edges()} missing_values = [item for item in list(edges_gdf.index) if item not in list(edge_ids.values())] dicts = [*dicts, edge_ids] column_names = [*column_names, "edgeID"] tmp = _dict_to_df(dicts, column_names) tmp.edgeID = tmp.edgeID.astype(int) edges_gdf = pd.merge(edges_gdf, tmp, on="edgeID", how="left") edges_gdf.index = edges_gdf.edgeID edges_gdf.index.name = None for metric in column_names: if metric == "edgeID": continue for edge_id in missing_values: edges_gdf.at[edge_id, metric] = 0.0 return edges_gdf
class Error(Exception): """Base class for centrality exceptions.""" class columnError(Error): """Raised when a column name is not provided.""" class nameError(Error): """Raised when an unsupported or missing centrality name is requested."""