Source code for cityImage.landuse.classify

"""
Stage 2: resolve raw OSM triplets into stable land-use labels.

Purpose
-------
Take per-row raw triplets produced by Stage 1:
    ["<token>:<group>:<domain>", ...]
and apply deterministic rule-based resolution to:
- enforce canonical domain/group targets for specific tokens,
- reclassify special cases (conditional on source domain),
- remove redundant container parents when child evidence exists,
- suppress place_of_worship features when a religious building is present,
- preserve UNCLASSIFIED triplets unchanged.

Inputs
------
buildings_gdf[land_uses_raw_column]:
    Scalar or list-like of triplets formatted as:
        "<token>:<group>:<domain>"

Outputs
-------
buildings_gdf[land_uses_raw_column] (in-place update pattern) or a new column:
    list[str] of resolved triplets, still formatted as:
        "<token>:<group>:<domain>"

Optionally (downstream):
- Stage 2 can be followed by projecting triplets to macro-groups only
  (e.g., ["education", "healthcare", ...]) for propagation in Stage 3.

Resolution model
----------------
Stage 2 is a pure resolver: it assumes Stage 1 already handled:
- token normalization and junk filtering,
- amenity exclusions,
- within-domain group-label redundancy removal.

Stage 2 does NOT:
- normalize tokens,
- infer new tokens,
- merge with external sources,
- apply geometry logic.

Deterministic pass order
------------------------
Given triplets in original row order, Stage 2 applies:

1) UNCLASSIFIED pass-through
   - any triplet with token OR group OR domain == "UNCLASSIFIED"
   - emitted as-is (only exact-deduped)
   - excluded from all rule logic and from presence computations

2) PASS 1: when reclassify (per occurrence)
   - evaluate RESOLUTION_RULES[token]["when"] clauses for that triplet
   - match on domain_equals only
   - first matching clause wins
   - applies only "reclassify"

3) PASS 2: compute presence AFTER (when + canonical)
   - build domain -> set(tokens) presence map used for:
       - POW global suppression
       - container evaluation

4) Canonicalization (token-level)
   - if token has a canonical target, force (group, domain) to canonical
   - emit at most one canonical instance per token (first occurrence wins)

5) POW global suppression
   - if ANY religious building is present:
         (domain="building" and token in POW_RELIGIOUS_BUILDINGS)
     then drop ALL triplets with domain == "place_of_worship"

6) Containers
   - if a parent token is present in its parent_domain and any configured child
     condition is met, drop that parent triplet (token, parent_domain)

7) Exact triplet de-duplication
   - final output preserves order; identical triplets are emitted once

"""

from __future__ import annotations

from typing import Any

import pandas as pd

from .tags import OSM_DOMAIN_GROUPS
from .utils import _to_list

UNCLASSIFIED = "UNCLASSIFIED"


[docs] def classify_land_uses_raws_into_OSMgroups( buildings_gdf, land_uses_raw_column: str = "land_uses_raw", new_group_column: str = "land_uses" ): """ Convert per-row triplets into per-row macro-group labels. Input ----- buildings_gdf[land_uses_raw_column]: Scalar or list-like of triplets formatted as: "<token>:<group>:<domain>" Output ------ buildings_gdf[new_group_column]: list[str] of group labels, order-preserving and de-duplicated. UNCLASSIFIED is kept (never dropped). Notes ----- - Malformed triplets (not 3-part) are ignored. - De-dup is by group string only (first occurrence wins). """ buildings_gdf = buildings_gdf.copy() def _row_triplets_to_groups(cell: Any) -> list[str]: triplets = _to_list(cell) groups: list[str] = [] seen: set[str] = set() for triplet in triplets: string = str(triplet).strip() parts = string.split(":", 2) if len(parts) != 3: continue _, group, _ = parts group = group.strip() if group in seen: continue seen.add(group) groups.append(group) return groups buildings_gdf[new_group_column] = buildings_gdf[land_uses_raw_column].apply( _row_triplets_to_groups ) return buildings_gdf
[docs] def find_unclassified_tokens_OSM_groups( buildings_gdf, land_uses_raw_column: str = "land_uses_raw", return_counts: bool = True, mode: str = "token", # "token" | "token_domain" | "triplet" ): """ Find UNCLASSIFIED items in "<token>:<group>:<domain>" triplets. mode: - "token": returns token only - "token_domain": returns "token:domain" - "triplet": returns full triplet string """ exploded = buildings_gdf[land_uses_raw_column].apply(_to_list).explode().dropna() def _parse(triplet: Any) -> tuple[str, str, str] | None: string = str(triplet).strip() parts = string.split(":", 2) if len(parts) != 3: return None return parts[0], parts[1], parts[2] parsed = exploded.map(_parse).dropna() unclassified = parsed[parsed.map(lambda x: x[1] == UNCLASSIFIED)] if mode == "token": values = unclassified.map(lambda x: x[0]) elif mode == "token_domain": values = unclassified.map(lambda x: f"{x[0]}:{x[2]}") elif mode == "triplet": values = unclassified.map(lambda x: f"{x[0]}:{x[1]}:{x[2]}") else: raise ValueError("mode must be one of: 'token', 'token_domain', 'triplet'") return values.value_counts() if return_counts else sorted(set(values))
def apply_manual_triplet_overrides( buildings_gdf, overrides: dict[str, str], land_uses_raw_column: str = "land_uses_raw", ): """ Override UNCLASSIFIED triplets using a user mapping token -> group (strict, domain-agnostic). Only triplets where group == UNCLASSIFIED are eligible. Triplet format ------------- "<token>:<group>:<domain>" Validation (strict) ------------------- - The target group must exist in the GLOBAL set of macro-groups (union of all OSM_DOMAIN_GROUPS[domain].keys()). - Domain is not used for validation (downstream consumes group only). - Malformed triplets are kept unchanged. Parameters ---------- buildings_gdf GeoDataFrame containing the column of triplets. overrides : dict[str, str] Mapping {token -> group} where group must be in the global macro-group vocabulary. land_uses_raw_column : str, default "land_uses_raw" Column containing scalar or list-like triplets. Returns ------- GeoDataFrame Copy with updated triplets in `land_uses_raw_column`. Raises ------ ValueError - if overrides is empty / not a dict - if an override group is not in the global vocabulary """ if not isinstance(overrides, dict) or not overrides: raise ValueError("overrides must be a non-empty dict[token -> group]") buildings_gdf = buildings_gdf.copy() allowed_groups: set[str] = {group for groups in OSM_DOMAIN_GROUPS.values() for group in groups} def _override_triplet(triplet: Any) -> str | None: if triplet is None: return None string = str(triplet).strip() if not string: return None parts = string.split(":", 2) if len(parts) != 3: return string # malformed -> keep as-is token, group, domain = parts if group != UNCLASSIFIED: return string if token not in overrides: return string new_group = str(overrides[token]).strip() if new_group not in allowed_groups: raise ValueError(f"Invalid override: token '{token}' -> '{new_group}'") return f"{token}:{new_group}:{domain}" def _process_cell(cell: Any) -> list[str]: triplets = _to_list(cell) if not triplets: return [] out: list[str] = [] for triplet in triplets: updated = _override_triplet(triplet) if updated is not None: out.append(updated) return out buildings_gdf[land_uses_raw_column] = buildings_gdf[land_uses_raw_column].apply(_process_cell) return buildings_gdf
[docs] def classify_land_uses_intoDMAs( buildings_gdf, land_uses_column: str = "land_uses", macro_to_dma: dict[str, str] | None = None, ): """Classify cityImage land-use groups into DMA functional categories. The DMA categories are live, work, visit, and mixed combinations of those functions. Input labels should already be normalised cityImage land-use groups, for example values derived from OSM shop, amenity, office, tourism, leisure, or building tags. Parameters ---------- buildings_gdf : geopandas.GeoDataFrame Building table containing normalised land-use labels. land_uses_column : str Column containing list-like land-use groups. macro_to_dma : mapping, optional Mapping from macro groups to DMA categories. Returns ------- geopandas.GeoDataFrame Copy of the building table with DMA classifications. """ gdf = buildings_gdf.copy() if land_uses_column not in gdf.columns: raise ValueError(f"GeoDataFrame must contain '{land_uses_column}'") # Reasonable defaults for your OSM macro-groups default_macro_to_dma = { # LIVE "residential": "live", "accommodation": "live", # WORK "commercial": "work", "office": "work", "industrial": "work", "craft": "work", "agricultural": "work", "education": "work", "healthcare": "work", "public_service": "work", "transportation": "work", "waste_management": "work", "power_technical": "work", "storage": "work", "cars": "work", "civic_amenity": "work", # VISIT "tourism": "visit", "leisure": "visit", "sustenance": "visit", "entertainment_arts_culture": "visit", "financial": "visit", "religious": "visit", "place_of_worship": "visit", "sports": "visit", } if macro_to_dma is None: macro_to_dma = default_macro_to_dma def _dims_from_macros(cell: Any) -> set[str]: dims: set[str] = set() for macro in _to_list(cell): if macro is None: continue s = str(macro).strip() if not s or s == UNCLASSIFIED: continue if s.startswith("shop_"): dims.add("visit") continue d = macro_to_dma.get(s) if d in {"live", "work", "visit"}: dims.add(d) return dims def _dims_to_dma_label(dims: set[str]) -> str: if not dims: return "other" if dims == {"live"}: return "live" if dims == {"work"}: return "work" if dims == {"visit"}: return "visit" if dims == {"live", "work"}: return "live_work" if dims == {"live", "visit"}: return "live_visit" if dims == {"work", "visit"}: return "work_visit" if dims == {"live", "work", "visit"}: return "live_work_visit" return "other" # defensive dims_series = gdf[land_uses_column].apply(_dims_from_macros) gdf["DMA"] = dims_series.apply(_dims_to_dma_label) return gdf
[docs] def classify_land_use( buildings_gdf, raw_land_use_column: str, new_land_use_column: str, categories: list[list[object]], strings: list[str], ): """Classify sparse attribute values using explicit category lists. This wrapper is kept because older examples/tests call `classify_land_use`. New code should prefer `classify_sparse_land_uses` for non-OSM sparse land-use attributes. `categories[i]` contains raw values and `strings[i]` is the target class. Scalar and list-like cells are supported. Unmatched values are preserved. """ if len(categories) != len(strings): raise ValueError("categories and strings must have the same length") gdf = buildings_gdf.copy() lookup = {} for raw_values, target in zip(categories, strings, strict=False): for raw_value in raw_values: lookup[raw_value] = target if raw_value is not None: lookup[str(raw_value).strip().lower()] = target def _classify_one(value): if value is None: return None try: if pd.isna(value): return None except Exception: pass return lookup.get(value, lookup.get(str(value).strip().lower(), value)) def _classify_cell(value): values = _to_list(value) if not values: return None if len(values) == 1: return _classify_one(values[0]) out = [] seen = set() for item in values: classified = _classify_one(item) if classified is None: continue if classified not in seen: seen.add(classified) out.append(classified) return out gdf[new_land_use_column] = gdf[raw_land_use_column].apply(_classify_cell) return gdf