Source code for iduedu.graph.validation

from typing import Any

import geopandas as gpd
import pandas as pd
import pyproj


def gdf_crs(frame) -> Any | None:
    """Return a GeoDataFrame CRS or ``None`` for non-geospatial frames."""
    if not isinstance(frame, gpd.GeoDataFrame):
        return None
    try:
        return frame.crs
    except AttributeError:
        return None


def sync_graph_crs(graph) -> None:
    """Synchronize graph, node and edge CRS metadata.

    Raises:
        ValueError: If node and edge CRS values conflict with the graph CRS.
    """
    nodes_crs = gdf_crs(graph.nodes_gdf)
    edges_crs = gdf_crs(graph.edges_gdf)
    inferred_crs = graph.crs or nodes_crs or edges_crs

    if inferred_crs is None:
        graph.crs = None
        return

    inferred_crs = pyproj.CRS.from_user_input(inferred_crs)

    for name, frame_crs in (("nodes_gdf", nodes_crs), ("edges_gdf", edges_crs)):
        if frame_crs is not None and frame_crs != inferred_crs:
            raise ValueError(f"{name}.crs={frame_crs} does not match graph crs={inferred_crs}")

    if isinstance(graph.nodes_gdf, gpd.GeoDataFrame) and nodes_crs is None and graph.nodes_gdf.active_geometry_name:
        graph.nodes_gdf = graph.nodes_gdf.set_crs(inferred_crs)
    if isinstance(graph.edges_gdf, gpd.GeoDataFrame) and edges_crs is None and graph.edges_gdf.active_geometry_name:
        graph.edges_gdf = graph.edges_gdf.set_crs(inferred_crs)

    graph.crs = inferred_crs


def validate_nodes(graph) -> None:
    """Validate the node table contract of an ``UrbanGraph``.

    Raises:
        TypeError: If ``nodes_gdf`` is not a DataFrame-like object.
        ValueError: If node ids are duplicated or geometries are invalid.
    """
    nodes = graph.nodes_gdf
    if not isinstance(nodes, (gpd.GeoDataFrame, pd.DataFrame)):
        raise TypeError(f"nodes_gdf must be DataFrame or GeoDataFrame, got {type(nodes).__name__}")
    if nodes.index.has_duplicates:
        raise ValueError("nodes_gdf.index must be unique")
    if isinstance(nodes, gpd.GeoDataFrame):
        if nodes.geometry.isna().any():
            raise ValueError("nodes_gdf.geometry contains NaN")
        if (~nodes.geometry.geom_type.isin(["Point"])).any():
            raise ValueError("All nodes_gdf geometries must be Point")


def validate_edges(graph) -> None:
    """Validate the edge table contract of an ``UrbanGraph``.

    Raises:
        TypeError: If ``edges_gdf`` is not a DataFrame-like object.
        ValueError: If required columns, topology keys or geometries are invalid.
    """
    edges = graph.edges_gdf
    if not isinstance(edges, (gpd.GeoDataFrame, pd.DataFrame)):
        raise TypeError(f"edges_gdf must be DataFrame or GeoDataFrame, got {type(edges).__name__}")
    if edges.empty:
        return
    required = {"u", "v", "geometry", "length_meter", "time_min"}
    missing = required - set(edges.columns)
    if missing:
        raise ValueError(f"edges_gdf missing required columns: {sorted(missing)}")
    if edges[["u", "v"]].isna().any().any():
        raise ValueError("edges_gdf columns ['u', 'v'] must not contain NaN")
    if graph.edge_direction_column is not None:
        if graph.edge_direction_column not in edges.columns:
            raise ValueError(f"edges_gdf missing edge_direction_column {graph.edge_direction_column!r}")
        if edges[graph.edge_direction_column].isna().any():
            raise ValueError(f"edges_gdf[{graph.edge_direction_column!r}] contains NaN")
        values = set(edges[graph.edge_direction_column].dropna().unique())
        if not values <= {False, True, 0, 1}:
            raise ValueError(f"edges_gdf[{graph.edge_direction_column!r}] must contain only boolean values")
    if graph.is_multigraph:
        if "k" not in edges.columns:
            raise ValueError("edges_gdf must contain 'k' for multigraph")
        if edges[["u", "v", "k"]].duplicated().any():
            raise ValueError("edges_gdf must have unique ['u','v','k']")
    else:
        if edges[["u", "v"]].duplicated().any():
            raise ValueError("edges_gdf must have unique ['u','v'] for non-multigraph")
    if isinstance(edges, gpd.GeoDataFrame):
        if edges.geometry.isna().any():
            raise ValueError("edges_gdf.geometry contains NaN")
        if (~edges.geometry.geom_type.isin(["LineString"])).any():
            raise ValueError("All edges_gdf geometries must be LineString")


def validate_nodes_edges(graph) -> None:
    """Validate consistency between node ids, edge endpoints and CRS values."""
    nodes = graph.nodes_gdf
    edges = graph.edges_gdf

    if isinstance(nodes, gpd.GeoDataFrame) and isinstance(edges, gpd.GeoDataFrame):
        nodes_crs = gdf_crs(nodes)
        edges_crs = gdf_crs(edges)
        if nodes_crs is not None and edges_crs is not None and nodes_crs != edges_crs:
            raise ValueError(f"nodes and edges crs mismatch: nodes.crs={nodes_crs}, edges.crs={edges_crs}")

    if edges.empty:
        return

    edge_nodes = pd.Index(pd.concat([edges["u"], edges["v"]], ignore_index=True).unique())
    missing_nodes = edge_nodes.difference(nodes.index)

    if not missing_nodes.empty:
        raise ValueError(f"Some edge endpoints are missing in nodes_gdf.index: {missing_nodes.tolist()[:10]}")


[docs] def validate_graph(graph) -> None: """Validate all node, edge, topology and CRS contracts of an ``UrbanGraph``. Raises: TypeError: If graph tables use unsupported types. ValueError: If graph table contracts are violated. """ validate_nodes(graph) validate_edges(graph) sync_graph_crs(graph) validate_nodes_edges(graph)