Source code for cityImage.scoring

"""High-level building landmark scoring facade.

This module is the thin semantic layer that should become the main public
entry point for cityImage scoring. It consumes already-prepared GeoDataFrames
and delegates component computations to ``cityImage.landmarks`` only when a
score is actually requested.

The purpose is to keep the scientific cityImage API separate from data loading,
network cleaning, plotting, and optional heavyweight dependencies.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from importlib import import_module
from typing import Any

import geopandas as gpd

from .schema import (
    LAND_USES,
    LAND_USES_OVERLAP,
    ensure_building_schema_defaults,
    validate_buildings_gdf,
    validate_edges_gdf,
)

DEFAULT_GLOBAL_INDEX_WEIGHTS: dict[str, float] = {
    "fac": 0.30,
    "height": 0.20,
    "3dvis": 0.50,
    "area": 0.40,
    "2dvis": 0.00,
    "neigh": 0.30,
    "road": 0.30,
}

DEFAULT_GLOBAL_COMPONENT_WEIGHTS: dict[str, float] = {
    "vScore": 0.25,
    "sScore": 0.35,
    "cScore": 0.10,
    "pScore": 0.30,
}

DEFAULT_LOCAL_INDEX_WEIGHTS: dict[str, float] = DEFAULT_GLOBAL_INDEX_WEIGHTS.copy()
DEFAULT_LOCAL_COMPONENT_WEIGHTS: dict[str, float] = DEFAULT_GLOBAL_COMPONENT_WEIGHTS.copy()


[docs] @dataclass(frozen=True) class LandmarkScoringConfig: """Configuration for high-level cityImage building scoring.""" global_index_weights: Mapping[str, float] = field( default_factory=lambda: DEFAULT_GLOBAL_INDEX_WEIGHTS.copy() ) global_component_weights: Mapping[str, float] = field( default_factory=lambda: DEFAULT_GLOBAL_COMPONENT_WEIGHTS.copy() ) local_index_weights: Mapping[str, float] = field( default_factory=lambda: DEFAULT_LOCAL_INDEX_WEIGHTS.copy() ) local_component_weights: Mapping[str, float] = field( default_factory=lambda: DEFAULT_LOCAL_COMPONENT_WEIGHTS.copy() ) pragmatic_search_radius: float = 1500.0 pragmatic_default_land_use: str = "unclassified" local_rescaling_radius: float = 1500.0 visibility_method: str = "longest"
def _landmark_module() -> Any: """Import the implementation module only when scoring is requested.""" return import_module(".landmarks", __package__)
[docs] def validate_score_weights( weights: Mapping[str, float], *, name: str = "weights", expected_sum: float | None = 1.0, tolerance: float = 1e-6, ) -> dict[str, float]: """Return numeric weights after validating their values and optional sum.""" if not weights: raise ValueError(f"{name} must not be empty") clean_weights: dict[str, float] = {} for key, value in weights.items(): clean_value = float(value) if clean_value < 0: raise ValueError(f"{name}.{key} must be non-negative") clean_weights[str(key)] = clean_value if expected_sum is not None: actual_sum = sum(clean_weights.values()) if abs(actual_sum - expected_sum) > tolerance: raise ValueError(f"{name} must sum to {expected_sum}; got {actual_sum:.12g}") return clean_weights
def _validate_buildings_for_scoring( buildings_gdf: gpd.GeoDataFrame, *, require_land_uses: bool = False, ) -> None: """Validate the building schema and raise a concise error if invalid.""" report = validate_buildings_gdf(buildings_gdf, require_land_uses=require_land_uses) if not report.ok: missing = ", ".join(report.missing_columns) raise ValueError(f"buildings_gdf does not match cityImage schema: {missing}")
[docs] def score_building_components( buildings_gdf: gpd.GeoDataFrame, *, edges_gdf: gpd.GeoDataFrame | None = None, obstructions_gdf: gpd.GeoDataFrame | None = None, sight_lines: gpd.GeoDataFrame | None = None, historic_elements_gdf: gpd.GeoDataFrame | None = None, cultural_score_column: str | None = None, cultural_from_osm: bool = False, compute_structural: bool = False, compute_visibility: bool = True, compute_cultural: bool = True, compute_pragmatic: bool = True, visibility_method: str = "longest", pragmatic_search_radius: float = 1500.0, pragmatic_default_land_use: str = "unclassified", land_uses_column: str = LAND_USES, land_uses_overlap_column: str = LAND_USES_OVERLAP, structural_kwargs: Mapping[str, Any] | None = None, validate_schema: bool = True, ) -> gpd.GeoDataFrame: """Compute requested building-level cityImage component scores. This function intentionally assumes that data has already been loaded and standardised. Use ``standardize_buildings_gdf`` and related adapters before calling it when inputs come from external workflows. """ buildings = ensure_building_schema_defaults(buildings_gdf) if validate_schema: _validate_buildings_for_scoring(buildings, require_land_uses=False) impl = _landmark_module() if compute_structural: if edges_gdf is None: raise ValueError("compute_structural=True requires edges_gdf") if validate_schema: report = validate_edges_gdf(edges_gdf) if not report.ok: missing = ", ".join(report.missing_columns) raise ValueError(f"edges_gdf does not match cityImage schema: {missing}") buildings = impl.structural_score( buildings, obstructions_gdf, edges_gdf, **dict(structural_kwargs or {}), ) if compute_visibility: buildings = impl.visibility_score( buildings, sight_lines=sight_lines, method=visibility_method, ) if compute_cultural: buildings = impl.cultural_score( buildings, historic_elements_gdf=historic_elements_gdf, score_column=cultural_score_column, from_OSM=cultural_from_osm, ) if compute_pragmatic: buildings = impl.pragmatic_score( buildings, search_radius=pragmatic_search_radius, default_land_use=pragmatic_default_land_use, land_uses_column=land_uses_column, overlaps_column=land_uses_overlap_column, ) return buildings
[docs] def score_buildings_global( buildings_gdf: gpd.GeoDataFrame, *, index_weights: Mapping[str, float] | None = None, component_weights: Mapping[str, float] | None = None, ) -> gpd.GeoDataFrame: """Compute global cityImage landmarkness scores from component columns.""" index_weights = dict(index_weights or DEFAULT_GLOBAL_INDEX_WEIGHTS) component_weights = validate_score_weights( component_weights or DEFAULT_GLOBAL_COMPONENT_WEIGHTS, name="component_weights", ) return _landmark_module().compute_global_scores( buildings_gdf, index_weights, component_weights, )
[docs] def score_buildings_local( buildings_gdf: gpd.GeoDataFrame, *, index_weights: Mapping[str, float] | None = None, component_weights: Mapping[str, float] | None = None, rescaling_radius: float = 1500.0, ) -> gpd.GeoDataFrame: """Compute local cityImage landmarkness scores from component columns.""" index_weights = dict(index_weights or DEFAULT_LOCAL_INDEX_WEIGHTS) component_weights = validate_score_weights( component_weights or DEFAULT_LOCAL_COMPONENT_WEIGHTS, name="component_weights", ) return _landmark_module().compute_local_scores( buildings_gdf, index_weights, component_weights, rescaling_radius=rescaling_radius, )
[docs] def score_cityimage_buildings( buildings_gdf: gpd.GeoDataFrame, *, config: LandmarkScoringConfig | None = None, compute_global: bool = True, compute_local: bool = False, **component_kwargs: Any, ) -> gpd.GeoDataFrame: """Run the high-level building component/global/local scoring pipeline.""" config = config or LandmarkScoringConfig() buildings = score_building_components( buildings_gdf, visibility_method=config.visibility_method, pragmatic_search_radius=config.pragmatic_search_radius, pragmatic_default_land_use=config.pragmatic_default_land_use, **component_kwargs, ) if compute_global: buildings = score_buildings_global( buildings, index_weights=config.global_index_weights, component_weights=config.global_component_weights, ) if compute_local: buildings = score_buildings_local( buildings, index_weights=config.local_index_weights, component_weights=config.local_component_weights, rescaling_radius=config.local_rescaling_radius, ) return buildings