Source code for cityImage.schema

"""Schema helpers for cityImage core data tables.

This module defines the minimum column contracts used by the scoring layer.
It is intentionally lightweight: it validates data produced by OSMnx,
GeoPandas, momepy, cityseer, or any other upstream workflow without forcing
cityImage to own the whole loading/cleaning pipeline.
"""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any

import geopandas as gpd
import numpy as np
import pandas as pd

NODE_ID = "nodeID"
EDGE_ID = "edgeID"
BUILDING_ID = "buildingID"
GEOMETRY = "geometry"

LAND_USES = "land_uses"
LAND_USES_OVERLAP = "land_uses_overlap"
LAND_USES_RAW = "land_uses_raw"

AREA = "area"
HEIGHT = "height"
BASE = "base"

REQUIRED_NODES_COLUMNS = (NODE_ID, GEOMETRY)
REQUIRED_EDGES_COLUMNS = (EDGE_ID, "u", "v", GEOMETRY)
REQUIRED_BUILDINGS_COLUMNS = (BUILDING_ID, GEOMETRY)

OPTIONAL_BUILDINGS_COLUMNS = (
    AREA,
    HEIGHT,
    BASE,
    LAND_USES,
    LAND_USES_OVERLAP,
    LAND_USES_RAW,
)


[docs] class SchemaError(ValueError): """Raised when a GeoDataFrame does not match a cityImage schema contract."""
[docs] @dataclass(frozen=True) class SchemaReport: """Simple validation report returned by schema-checking helpers.""" name: str ok: bool missing_columns: tuple[str, ...] = () message: str = ""
[docs] def missing_columns(gdf: pd.DataFrame, required_columns: Iterable[str]) -> tuple[str, ...]: """Return required columns that are absent from a DataFrame.""" return tuple(column for column in required_columns if column not in gdf.columns)
[docs] def require_columns( gdf: pd.DataFrame, required_columns: Iterable[str], *, frame_name: str = "DataFrame", ) -> None: """Raise a SchemaError if required columns are missing.""" missing = missing_columns(gdf, required_columns) if missing: missing_str = ", ".join(missing) raise SchemaError(f"{frame_name} is missing required columns: {missing_str}")
[docs] def require_geometry( gdf: gpd.GeoDataFrame, *, frame_name: str = "GeoDataFrame", allow_empty: bool = False, ) -> None: """Validate that a GeoDataFrame has a geometry column with valid entries.""" if not isinstance(gdf, gpd.GeoDataFrame): raise SchemaError(f"{frame_name} must be a GeoDataFrame") if gdf.geometry.name not in gdf.columns: raise SchemaError(f"{frame_name} must have an active geometry column") if gdf.geometry.isna().any(): raise SchemaError(f"{frame_name} contains missing geometries") if not allow_empty and gdf.geometry.is_empty.any(): raise SchemaError(f"{frame_name} contains empty geometries")
def _is_list_like_cell(value: Any) -> bool: """Return True when a cell contains a non-string iterable.""" return isinstance(value, (list, tuple, set))
[docs] def require_land_use_lists( buildings_gdf: pd.DataFrame, *, land_uses_column: str = LAND_USES, overlaps_column: str = LAND_USES_OVERLAP, frame_name: str = "buildings_gdf", ) -> None: """Validate list-like land-use columns used by pragmatic scoring.""" require_columns(buildings_gdf, [land_uses_column], frame_name=frame_name) bad_land_uses = ~buildings_gdf[land_uses_column].apply(_is_list_like_cell) if bad_land_uses.any(): raise SchemaError(f"{frame_name}.{land_uses_column} must contain list-like values") if overlaps_column not in buildings_gdf.columns: return bad_overlaps = ~buildings_gdf[overlaps_column].apply(_is_list_like_cell) if bad_overlaps.any(): raise SchemaError(f"{frame_name}.{overlaps_column} must contain list-like values") lengths_match = buildings_gdf.apply( lambda row: len(row[land_uses_column]) == len(row[overlaps_column]), axis=1, ) if not lengths_match.all(): raise SchemaError( f"{frame_name}.{land_uses_column} and {frame_name}.{overlaps_column} " "must have matching list lengths" )
[docs] def validate_nodes_gdf(nodes_gdf: gpd.GeoDataFrame) -> SchemaReport: """Validate the minimal cityImage node table contract.""" missing = missing_columns(nodes_gdf, REQUIRED_NODES_COLUMNS) if missing: return SchemaReport("nodes_gdf", False, missing, "missing required columns") require_geometry(nodes_gdf, frame_name="nodes_gdf") return SchemaReport("nodes_gdf", True)
[docs] def validate_edges_gdf(edges_gdf: gpd.GeoDataFrame) -> SchemaReport: """Validate the minimal cityImage edge table contract.""" missing = missing_columns(edges_gdf, REQUIRED_EDGES_COLUMNS) if missing: return SchemaReport("edges_gdf", False, missing, "missing required columns") require_geometry(edges_gdf, frame_name="edges_gdf") return SchemaReport("edges_gdf", True)
[docs] def validate_buildings_gdf( buildings_gdf: gpd.GeoDataFrame, *, require_land_uses: bool = False, require_height: bool = False, ) -> SchemaReport: """Validate the minimal cityImage building table contract.""" required = list(REQUIRED_BUILDINGS_COLUMNS) if require_height: required.append(HEIGHT) if require_land_uses: required.append(LAND_USES) missing = missing_columns(buildings_gdf, required) if missing: return SchemaReport("buildings_gdf", False, missing, "missing required columns") require_geometry(buildings_gdf, frame_name="buildings_gdf") if require_land_uses: require_land_use_lists(buildings_gdf) return SchemaReport("buildings_gdf", True)
[docs] def ensure_building_schema_defaults( buildings_gdf: gpd.GeoDataFrame, *, add_area: bool = True, ) -> gpd.GeoDataFrame: """Return buildings with common non-semantic cityImage defaults populated. This function fills geometry-derived and numeric defaults only. It does not classify land use and does not fabricate ``land_uses`` or ``land_uses_overlap``. """ gdf = buildings_gdf.copy() require_columns(gdf, REQUIRED_BUILDINGS_COLUMNS, frame_name="buildings_gdf") require_geometry(gdf, frame_name="buildings_gdf") if add_area and AREA not in gdf.columns: gdf[AREA] = gdf.geometry.area if HEIGHT not in gdf.columns: gdf[HEIGHT] = np.nan if BASE not in gdf.columns: gdf[BASE] = 0.0 return gdf