Source code for cityImage.landuse.utils

import re
from typing import Any

import numpy as np
import pandas as pd

FALSEY_OSM_TAG_VALUES = {"no", "false", "0"}
_COLON_RE = re.compile(r":+")
_WS_RE = re.compile(r"\s+")
_BAD_CHARS_RE = re.compile(r"[^a-z0-9_]+")
_MULTI_UNDERSCORE_RE = re.compile(r"_+")

# Tokens to drop after normalization.
_DROP = {"yes", "no", "true", "false", "1", "0", "unknown", "unclassified", "fixme", "none", ""}


def _is_missing_scalar(value: Any) -> bool:
    """True for None/NA/NaN scalars; False for list-like values."""
    if value is None or value is pd.NA:
        return True
    if isinstance(value, (list, tuple, set, dict, np.ndarray, pd.Series)):
        return False
    return bool(pd.isna(value))


def _is_truthy_osm_tag_value(value: Any) -> bool:
    """Treat a tag as present unless missing or explicitly falsey ('no', 'false', '0')."""
    if _is_missing_scalar(value):
        return False
    return str(value).strip().lower() not in FALSEY_OSM_TAG_VALUES


def _deduplicate_preserving_order(values):
    """Fast de-duplication preserving first-seen order (assumes hashable tokens)."""
    seen = set()
    out = []
    for v in values:
        if v not in seen:
            seen.add(v)
            out.append(v)
    return out


def _is_canonical_token(token: str) -> bool:
    """
    True if `token` already matches _normalize_token() output:
    - lowercase
    - no whitespace / hyphens
    - underscores not duplicated or dangling
    - characters limited to [a-z0-9_]
    """
    if not token:
        return False
    if token != token.lower():
        return False
    if any(character in token for character in (" ", "\t", "\r", "\n", "-")):
        return False
    if token.startswith("_") or token.endswith("_"):
        return False
    if "__" in token:
        return False
    return _BAD_CHARS_RE.search(token) is None


def _to_list(value: Any) -> list[Any]:
    """
    Normalize a value into a Python list.

    Rules:
      - None -> []
      - pandas/NumPy missing scalars (NaN, pd.NA, NaT) -> []
      - list -> value (as-is)
      - tuple/set -> list(value)
      - numpy.ndarray / pandas.Series -> value.tolist()
      - string -> [string]
      - anything else -> [value]
    """
    if value is None:
        return []

    if isinstance(value, str):
        return [value]

    if isinstance(value, list):
        return value

    if isinstance(value, (tuple, set)):
        return list(value)

    if isinstance(value, (pd.Series, np.ndarray)):
        return value.tolist()

    try:
        if pd.isna(value):
            return []
    except Exception:
        pass

    return [value]


def _normalize_token(value: object) -> str | None:
    """
    Normalize a raw OSM scalar into a canonical token.

    Policy:
      - lowercase
      - trim
      - remove ':' entirely (so raw noise like "fi:re_station" -> "fire_station")
      - collapse whitespace/hyphens/etc. into underscores
      - remove non [a-z0-9_]
      - collapse duplicate underscores
      - strip leading/trailing underscores
    """
    if value is None:
        return None

    string = str(value).strip().lower()
    if not string:
        return None

    # IMPORTANT: strip ':' at ingestion (qualifiers are added later by code)
    string = _COLON_RE.sub("", string)

    string = string.replace("-", "_")
    string = _WS_RE.sub("_", string)
    string = _BAD_CHARS_RE.sub("_", string)
    string = _MULTI_UNDERSCORE_RE.sub("_", string).strip("_")

    return string or None


def _clean_tokens(raw_tokens: Any) -> list[str]:
    """
    Clean + normalize tokens, splitting ';' multi-values, dropping junk, de-duping in order.

    Returns
    -------
    list[str]
        Normalized base tokens (no qualification is applied here).
    """
    if raw_tokens is None:
        return []

    if isinstance(raw_tokens, str):
        items = [raw_tokens]
    elif isinstance(raw_tokens, (list, tuple, set, np.ndarray, pd.Series)):
        items = list(raw_tokens)
    else:
        items = [raw_tokens]

    cleaned: list[str] = []
    seen: set[str] = set()

    def _add(token: str | None) -> None:
        if token and token not in _DROP and token not in seen:
            seen.add(token)
            cleaned.append(token)

    for item in items:
        if item is None:
            continue

        text = item.strip() if isinstance(item, str) else str(item).strip()
        if not text:
            continue

        if ";" not in text:
            token = text if _is_canonical_token(text) else _normalize_token(text)
            _add(token)
            continue

        for part in (p.strip() for p in text.split(";")):
            if part:
                _add(_normalize_token(part))

    return cleaned


[docs] def find_land_use_values_matching( buildings_gdf, land_uses_column: str = "land_uses", pattern: str = r"^shop", # e.g. "^shop" or "shop" ignore_case: bool = True, return_counts: bool = True, ) -> pd.Series | list[str]: """ Find land-use labels in buildings_gdf[land_uses_column] matching a regex pattern. Assumes cells contain either: - list[str] of labels, or - a scalar label (string), or - missing/empty. Parameters ---------- buildings_gdf GeoDataFrame / DataFrame with the land use column. land_uses_column : str, default "land_uses" Column containing list-like or scalar land-use labels. pattern : str, default "^shop" Regex pattern to match labels (e.g. "^shop" matches "shop_*"). ignore_case : bool, default True If True uses case-insensitive matching. return_counts : bool, default True If True returns value_counts() of matched labels. If False returns sorted unique matched labels. Returns ------- pandas.Series | list[str] Counts (Series) or sorted unique labels. """ flags = re.IGNORECASE if ignore_case else 0 rx = re.compile(pattern, flags) exploded = buildings_gdf[land_uses_column].explode().dropna() matched = exploded.map(lambda v: str(v).strip()).loc[ lambda s: s.map(lambda x: bool(rx.search(x))) ] return matched.value_counts() if return_counts else sorted(set(matched))