"""Core graph construction and dual-graph semantics.
This module is intentionally small. It keeps only the cityImage graph boundary:
* convert prepared node/edge GeoDataFrames into NetworkX graphs;
* build the dual graph representation used by imageability/region workflows;
* map dual-graph results back to primal edge IDs;
* calculate simple node degree counts from edge tables.
Network loading, topology cleaning, centrality, and community detection now live
in dedicated modules or are delegated to external libraries.
"""
from __future__ import annotations
from collections.abc import Hashable
from typing import Any
import geopandas as gpd
import networkx as nx
import pandas as pd
from shapely.geometry import LineString
from .angles import angle_line_geometries
pd.set_option("display.precision", 3)
def _is_missing_scalar(value: Any) -> bool:
"""Return True for scalar missing values, False for list-like/geometries."""
if isinstance(value, list):
return False
try:
return bool(pd.isna(value))
except (TypeError, ValueError):
return False
def _node_attribute_columns(gdf: pd.DataFrame) -> list[str]:
"""Return node attribute columns safe to attach to NetworkX nodes."""
return [
column
for column in gdf.columns
if not gdf[column].apply(lambda value: isinstance(value, list)).any()
]
def _set_node_attributes_from_gdf(
graph: nx.Graph,
nodes_gdf: gpd.GeoDataFrame,
) -> None:
"""Attach non-list, non-missing node attributes to a NetworkX graph."""
attributes = nodes_gdf.to_dict()
for attribute_name in _node_attribute_columns(nodes_gdf):
attribute_values = {
key: value
for key, value in attributes[attribute_name].items()
if not _is_missing_scalar(value)
}
nx.set_node_attributes(graph, values=attribute_values, name=attribute_name)
def _edge_attributes(row: pd.Series, exclude: set[str]) -> dict[str, Any]:
"""Return edge attributes to attach to a NetworkX edge."""
return {
label: value
for label, value in row.items()
if label not in exclude and (isinstance(value, list) or not _is_missing_scalar(value))
}
[docs]
def graph_fromGDF(
nodes_gdf: gpd.GeoDataFrame,
edges_gdf: gpd.GeoDataFrame,
) -> nx.Graph:
"""Create an undirected NetworkX graph from cityImage node/edge GeoDataFrames."""
nodes = nodes_gdf.copy()
edges = edges_gdf.copy()
nodes = nodes.set_index("nodeID", drop=False)
nodes.index.name = None
graph = nx.Graph()
graph.add_nodes_from(nodes.index)
_set_node_attributes_from_gdf(graph, nodes)
for _, row in edges.iterrows():
graph.add_edge(row["u"], row["v"], **_edge_attributes(row, {"u", "v"}))
return graph
[docs]
def multiGraph_fromGDF(
nodes_gdf: gpd.GeoDataFrame,
edges_gdf: gpd.GeoDataFrame,
) -> nx.MultiGraph:
"""Create an undirected NetworkX MultiGraph from cityImage graph GeoDataFrames.
This function is retained for legacy workflows with parallel edges. New code
should usually prefer ``graph_fromGDF`` unless edge multiplicity matters.
"""
nodes = nodes_gdf.copy()
edges = edges_gdf.copy()
nodes = nodes.set_index("nodeID", drop=False)
nodes.index.name = None
multigraph = nx.MultiGraph()
multigraph.add_nodes_from(nodes.index)
_set_node_attributes_from_gdf(multigraph, nodes)
for _, row in edges.iterrows():
key = row["key"] if "key" in row.index else 0
multigraph.add_edge(
row["u"],
row["v"],
key=key,
**_edge_attributes(row, {"u", "v", "key"}),
)
return multigraph
def _intersecting_edge_ids(edges: gpd.GeoDataFrame, row: pd.Series) -> list[Hashable]:
"""Return edge IDs sharing either endpoint with an edge row."""
return list(
edges.loc[
(edges["u"] == row["u"])
| (edges["u"] == row["v"])
| (edges["v"] == row["v"])
| (edges["v"] == row["u"])
].index
)
def _oneway_intersecting_edge_ids(edges: gpd.GeoDataFrame, row: pd.Series) -> list[Hashable]:
"""Return directed/oneway-aware dual-neighbour edge IDs."""
if row["oneway"] == 1:
mask = (edges["u"] == row["v"]) | ((edges["v"] == row["v"]) & (edges["oneway"] == 0))
else:
mask = (
(edges["u"] == row["v"])
| ((edges["v"] == row["v"]) & (edges["oneway"] == 0))
| (edges["u"] == row["u"])
| ((edges["v"] == row["u"]) & (edges["oneway"] == 0))
)
return list(edges.loc[mask].index)
[docs]
def dual_gdf(
nodes_gdf: gpd.GeoDataFrame,
edges_gdf: gpd.GeoDataFrame,
crs: Any,
oneway: bool = False,
angle: str | None = None,
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
"""Create dual-node and dual-edge GeoDataFrames from a primal street graph.
Dual nodes represent primal street segments. Dual edges connect street
segments sharing a junction. Their length is the mean of the two original
segment lengths; optional angle values encode deflection between original
geometries.
"""
nodes = nodes_gdf.copy().set_index("nodeID", drop=False)
nodes.index.name = None
edges = edges_gdf.copy().set_index("edgeID", drop=False)
edges.index.name = None
centroids = edges.copy()
centroids["centroid"] = centroids.geometry.centroid
if oneway:
if "oneway" not in centroids.columns:
raise ValueError("edges_gdf must contain 'oneway' when oneway=True")
centroids["intersecting"] = centroids.apply(
lambda row: _oneway_intersecting_edge_ids(centroids, row),
axis=1,
)
else:
centroids["intersecting"] = centroids.apply(
lambda row: _intersecting_edge_ids(centroids, row),
axis=1,
)
nodes_dual_data = centroids.drop(columns=["geometry", "centroid"])
nodes_dual = gpd.GeoDataFrame(nodes_dual_data, crs=crs, geometry=centroids["centroid"])
nodes_dual["x"] = [geometry.x for geometry in nodes_dual.geometry]
nodes_dual["y"] = [geometry.y for geometry in nodes_dual.geometry]
nodes_dual.index = nodes_dual.edgeID
nodes_dual.index.name = None
new_edges: list[dict[str, Any]] = []
processed: set[tuple[Hashable, Hashable]] = set()
for row in nodes_dual.itertuples():
for intersecting in row.intersecting:
if (
row.Index == intersecting
or (row.Index, intersecting) in processed
or (intersecting, row.Index) in processed
):
continue
intersecting_row = nodes_dual.loc[intersecting]
distance = (row.length + intersecting_row.length) / 2
geometry = LineString([row.geometry, intersecting_row.geometry])
new_edges.append(
{
"u": row.Index,
"v": intersecting,
"geometry": geometry,
"length": distance,
}
)
processed.add((row.Index, intersecting))
edges_dual = gpd.GeoDataFrame(
new_edges,
columns=["u", "v", "geometry", "length"],
crs=crs,
geometry="geometry",
)
if angle != "radians":
edges_dual["deg"] = edges_dual.apply(
lambda row: angle_line_geometries(
edges.loc[row["u"]].geometry,
edges.loc[row["v"]].geometry,
degree=True,
calculation_type="deflection",
),
axis=1,
)
else:
edges_dual["rad"] = edges_dual.apply(
lambda row: angle_line_geometries(
edges.loc[row["u"]].geometry,
edges.loc[row["v"]].geometry,
degree=False,
calculation_type="deflection",
),
axis=1,
)
return nodes_dual, edges_dual
[docs]
def dual_graph_fromGDF(
nodes_dual: gpd.GeoDataFrame,
edges_dual: gpd.GeoDataFrame,
) -> nx.Graph:
"""Create a NetworkX graph from dual-node and dual-edge GeoDataFrames."""
nodes = nodes_dual.copy().set_index("edgeID", drop=False)
nodes.index.name = None
edges = edges_dual.copy()
edges["u"] = edges["u"].astype(int)
edges["v"] = edges["v"].astype(int)
dual_graph = nx.Graph()
dual_graph.add_nodes_from(nodes.index)
_set_node_attributes_from_gdf(dual_graph, nodes)
for _, row in edges.iterrows():
dual_graph.add_edge(row["u"], row["v"], **_edge_attributes(row, {"u", "v"}))
return dual_graph
[docs]
def dual_id_dict(
dict_values: dict[Any, Any],
graph: nx.Graph,
node_attribute: str,
) -> dict[Any, Any]:
"""Map a dual-graph node dictionary to a dictionary keyed by primal edge IDs."""
return {graph.nodes[node][node_attribute]: value for node, value in dict_values.items()}
[docs]
def nodes_degree(edges_gdf: gpd.GeoDataFrame) -> dict[Any, int]:
"""Return node degree counts from an edge GeoDataFrame with ``u``/``v`` columns."""
return edges_gdf[["u", "v"]].stack().value_counts().to_dict()
[docs]
def from_nx_to_gdf(
graph: nx.Graph,
crs: Any,
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
"""Convert a NetworkX graph with geometry attributes into node/edge GeoDataFrames.
This is retained as a small adapter for workflows that already have a
geometry-bearing NetworkX graph. It does not perform loading or topology
repair.
"""
nodes_gdf = gpd.GeoDataFrame(
[
{**data, "nodeID": node, "geometry": data["geometry"]}
for node, data in graph.nodes(data=True)
],
crs=crs,
)
edges_gdf = gpd.GeoDataFrame(
[
{**data, "u": u, "v": v, "geometry": data["geometry"]}
for u, v, data in graph.edges(data=True)
],
crs=crs,
)
return nodes_gdf, edges_gdf