""" Method A — Lumen-based anomaly scoring per lumen group. ======================================================== Enriches the input points with luminous-flux features and flags suspect poles whose observed radiance is anomalously low compared to other poles with the same lumen rating. Required attributes ------------------- ARMATUR_GU — armature power (e.g. "150 W") ARMATUR_AD — armature count (0 / empty treated as 1) lumen / LUMEN / lumen_filled — optional, used if present Pipeline -------- 1. Raster extraction (same as RAST): inner circle → l_pole, annulus → l_bg 2. Global background stats: mu_bg, sigma_bg, per-pole score s 3. Lumen enrichment: estimate lumen for each pole from input / medians / reference table / fallback efficacy. 4. Anomaly scoring per lumen group: anomaly_score = (nmf - group_median_nmf) / group_mad_nmf where nmf = img_radiance_near_minus_far 5. Suspect flags: is_suspect (score < -2), is_strong_suspect (score < -3) Produced attributes ------------------- power_w, armatur_ad_used, total_powe, lm_per_armatur_used, lumen, lumen_filled, lumen_source, rad_flux_w, up_flux_w, upward_ratio, luminous_efficacy_lm_per_w, maintenance_factor, lumen_maintained, l_pole, l_bg, mu_bg, sigma_bg, s, img_radiance_near_mean, img_radiance_near_minus_far, group_label, group_median_nmf, group_mad_nmf, anomaly_score, is_suspect, is_strong_suspect, method """ import math import re import json from statistics import median as _median from typing import Any, Dict, Iterable, List, Optional, Tuple import numpy as np import geopandas as gpd import rasterio from rasterio.mask import mask as rasterio_mask from scipy.stats import median_abs_deviation # ====================================================================== # Enrichment configuration # ====================================================================== DEFAULT_LM_PER_ARMATUR: Dict[int, float] = { 125: 11875.0, 150: 14000.0, 250: 25000.0, 400: 38000.0, } DEFAULT_UPWARD_RATIO = 0.15 DEFAULT_LUMINOUS_EFFICACY = 683.0 DEFAULT_MAINTENANCE_FACTOR = 0.80 DEFAULT_FALLBACK_ETA = 100.0 # lm/W # ====================================================================== # Enrichment helpers # ====================================================================== def _safe_float(value: Any) -> Optional[float]: if value is None: return None if isinstance(value, bool): return float(value) if isinstance(value, (int, float)): if math.isnan(value) or math.isinf(value): return None return float(value) if isinstance(value, str): txt = value.strip().replace(",", ".") if txt == "": return None try: out = float(txt) if math.isnan(out) or math.isinf(out): return None return out except ValueError: return None return None def _extract_power_w(text: Any) -> Optional[int]: if text is None: return None s = str(text) m = re.search(r"(\d+(?:\.\d+)?)", s) if not m: return None try: return int(round(float(m.group(1)))) except ValueError: return None def _normalize_armatur_count(value: Any) -> int: x = _safe_float(value) if x is None or x <= 0: return 1 return int(round(x)) def _find_existing_lumen_key(properties: Dict[str, Any]) -> Optional[str]: candidates = [ "lumen", "LUMEN", "lumen_filled", "LUMEN_FILLED", "lumen_recalc_used", "LUMEN_RECALC_USED", ] for key in candidates: if key in properties: return key return None def _list_medians(values: Iterable[float]) -> Optional[float]: vals = [float(v) for v in values if v is not None and not math.isnan(v) and math.isfinite(v)] if not vals: return None return float(_median(vals)) def _build_training_stats( features: List[Dict[str, Any]] ) -> Tuple[Dict[Tuple[int, int], float], Dict[int, float]]: combo_values: Dict[Tuple[int, int], List[float]] = {} power_lm_per_arm_values: Dict[int, List[float]] = {} for feat in features: props = feat.get("properties", {}) or {} lum_key = _find_existing_lumen_key(props) if lum_key is None: continue lum = _safe_float(props.get(lum_key)) power_w = _extract_power_w(props.get("ARMATUR_GU")) arm_count = _normalize_armatur_count(props.get("ARMATUR_AD")) if lum is None or lum <= 0 or power_w is None: continue combo_values.setdefault((power_w, arm_count), []).append(lum) power_lm_per_arm_values.setdefault(power_w, []).append(lum / arm_count) combo_median = {k: _list_medians(v) for k, v in combo_values.items()} power_median = {k: _list_medians(v) for k, v in power_lm_per_arm_values.items()} return combo_median, power_median def _enrich_feature( feature: Dict[str, Any], combo_median: Dict[Tuple[int, int], float], power_median: Dict[int, float], lm_per_armatur_ref: Dict[int, float], upward_ratio: float, luminous_efficacy: float, maintenance_factor: float, fallback_eta: float, ) -> Dict[str, Any]: feat = { "type": feature.get("type", "Feature"), "geometry": feature.get("geometry"), "properties": dict(feature.get("properties", {}) or {}), } p = feat["properties"] power_w = _extract_power_w(p.get("ARMATUR_GU")) arm_count_original = _safe_float(p.get("ARMATUR_AD")) arm_count = _normalize_armatur_count(p.get("ARMATUR_AD")) total_powe = float(power_w * arm_count) if power_w is not None else None lum_key = _find_existing_lumen_key(p) input_lumen = _safe_float(p.get(lum_key)) if lum_key is not None else None lm_per_armatur_used = None lumen = None lumen_source = None if input_lumen is not None and input_lumen > 0: lumen = float(input_lumen) lm_per_armatur_used = lumen / arm_count lumen_source = f"input:{lum_key}" elif power_w is not None and (power_w, arm_count) in combo_median \ and combo_median[(power_w, arm_count)] is not None: lumen = float(combo_median[(power_w, arm_count)]) lm_per_armatur_used = lumen / arm_count lumen_source = "median_same_power_same_count" elif power_w is not None and power_w in power_median \ and power_median[power_w] is not None: lm_per_armatur_used = float(power_median[power_w]) lumen = lm_per_armatur_used * arm_count lumen_source = "median_same_power_lm_per_arm" elif power_w is not None and power_w in lm_per_armatur_ref: lm_per_armatur_used = float(lm_per_armatur_ref[power_w]) lumen = lm_per_armatur_used * arm_count lumen_source = "reference_table" elif power_w is not None: lm_per_armatur_used = float(fallback_eta * power_w) lumen = lm_per_armatur_used * arm_count lumen_source = "fallback_eta" rad_flux_w = None up_flux_w = None lumen_maintained = None if lumen is not None: rad_flux_w = float(lumen / luminous_efficacy) up_flux_w = float(rad_flux_w * upward_ratio) lumen_maintained = float(lumen * maintenance_factor) p["power_w"] = power_w p["armatur_ad_original"] = arm_count_original p["armatur_ad_used"] = arm_count p["total_powe"] = total_powe p["lm_per_armatur_used"] = lm_per_armatur_used p["lumen"] = lumen p["lumen_filled"] = lumen p["lumen_source"] = lumen_source p["rad_flux_w"] = rad_flux_w p["up_flux_w"] = up_flux_w p["upward_ratio"] = upward_ratio p["luminous_efficacy_lm_per_w"] = luminous_efficacy p["maintenance_factor"] = maintenance_factor p["lumen_maintained"] = lumen_maintained return feat def enrich_geojson( data: Dict[str, Any], lm_per_armatur_ref: Dict[int, float], upward_ratio: float, luminous_efficacy: float, maintenance_factor: float, fallback_eta: float, ) -> Dict[str, Any]: features = data.get("features", []) combo_median, power_median = _build_training_stats(features) new_features = [ _enrich_feature( feature=f, combo_median=combo_median, power_median=power_median, lm_per_armatur_ref=lm_per_armatur_ref, upward_ratio=upward_ratio, luminous_efficacy=luminous_efficacy, maintenance_factor=maintenance_factor, fallback_eta=fallback_eta, ) for f in features ] out = dict(data) out["features"] = new_features out.setdefault("name", "enriched_poles") return out # ====================================================================== # Main entry point # ====================================================================== def run(tiff_path, vector_path, band=1, threshold=3.0, inner_radius=5, outer_radius=15): """ Run the Method A analysis. Parameters ---------- tiff_path : str Path to the input GeoTIFF. vector_path : str Path to the input vector file (pole points). band : int Raster band to use (1, 2 or 3). threshold : float Observability threshold (used for the ``s`` score, kept for compatibility; Method A primarily uses anomaly_score). inner_radius : int Inner radius of the annulus in metres. outer_radius : int Outer radius of the annulus in metres. Returns ------- geopandas.GeoDataFrame Enriched poles with anomaly-scoring attributes. """ # ------------------------------------------------------------------ # Read vector data # ------------------------------------------------------------------ gdf = gpd.read_file(vector_path) for col in gdf.columns: if str(gdf[col].dtype).startswith('datetime'): gdf[col] = gdf[col].apply( lambda x: x.isoformat() if x is not None and not (isinstance(x, float) and math.isnan(x)) else None ) # ------------------------------------------------------------------ # Raster extraction (same as RAST) # ------------------------------------------------------------------ with rasterio.open(tiff_path) as src: raster_crs = src.crs gdf_raster_crs = gdf.to_crs(raster_crs) centroid = gdf_raster_crs.geometry.unary_union.centroid lon, lat = centroid.x, centroid.y if gdf_raster_crs.crs.is_geographic: utm_zone = int((lon + 180) / 6) + 1 hemisphere = 'north' if lat >= 0 else 'south' utm_crs = f'+proj=utm +zone={utm_zone} +{hemisphere} +datum=WGS84' else: utm_crs = raster_crs gdf_proj = gdf_raster_crs.to_crs(utm_crs) l_pole_list = [] l_bg_list = [] near_mean_list = [] near_minus_far_list = [] for _idx, row in gdf_proj.iterrows(): point = row.geometry inner_circle = point.buffer(inner_radius) outer_circle = point.buffer(outer_radius) annulus = outer_circle.difference(inner_circle) inner_gdf = gpd.GeoDataFrame( geometry=[inner_circle], crs=utm_crs).to_crs(raster_crs) annulus_gdf = gpd.GeoDataFrame( geometry=[annulus], crs=utm_crs).to_crs(raster_crs) inner_geom = [inner_gdf.geometry.iloc[0]] annulus_geom = [annulus_gdf.geometry.iloc[0]] try: inner_data, _ = rasterio_mask( src, inner_geom, crop=True, nodata=0, filled=True) band_data_inner = inner_data[band - 1] valid_inner = band_data_inner[band_data_inner != 0] l_pole = float(np.max(valid_inner)) if len(valid_inner) > 0 else 0.0 near_mean = float(np.mean(valid_inner)) if len(valid_inner) > 0 else 0.0 except Exception: l_pole = 0.0 near_mean = 0.0 try: annulus_data, _ = rasterio_mask( src, annulus_geom, crop=True, nodata=0, filled=True) band_data_annulus = annulus_data[band - 1] valid_annulus = band_data_annulus[band_data_annulus != 0] l_bg = float(np.max(valid_annulus)) if len(valid_annulus) > 0 else 0.0 far_mean = float(np.mean(valid_annulus)) if len(valid_annulus) > 0 else 0.0 except Exception: l_bg = 0.0 far_mean = 0.0 l_pole_list.append(l_pole) l_bg_list.append(l_bg) near_mean_list.append(near_mean) near_minus_far_list.append(near_mean - far_mean) # ------------------------------------------------------------------ # Global statistics # ------------------------------------------------------------------ l_bg_array = np.array(l_bg_list) mu_bg = float(np.median(l_bg_array)) sigma_bg = float(median_abs_deviation(l_bg_array)) s_list = [] for l_pole in l_pole_list: s = (l_pole - mu_bg) / sigma_bg if sigma_bg > 0 else 0.0 s_list.append(s) # ------------------------------------------------------------------ # Step 1: Enrich the input GeoJSON with lumen-based features # ------------------------------------------------------------------ raw_geojson = json.loads(gdf.to_json()) enriched_geojson = enrich_geojson( data=raw_geojson, lm_per_armatur_ref=DEFAULT_LM_PER_ARMATUR, upward_ratio=DEFAULT_UPWARD_RATIO, luminous_efficacy=DEFAULT_LUMINOUS_EFFICACY, maintenance_factor=DEFAULT_MAINTENANCE_FACTOR, fallback_eta=DEFAULT_FALLBACK_ETA, ) enriched_gdf = gpd.GeoDataFrame.from_features( enriched_geojson['features'], crs=gdf.crs) # Carry over the radiance columns enriched_gdf['l_pole'] = l_pole_list enriched_gdf['l_bg'] = l_bg_list enriched_gdf['mu_bg'] = mu_bg enriched_gdf['sigma_bg'] = sigma_bg enriched_gdf['s'] = s_list enriched_gdf['img_radiance_near_mean'] = near_mean_list enriched_gdf['img_radiance_near_minus_far'] = near_minus_far_list # ------------------------------------------------------------------ # Step 2: Anomaly scoring per lumen group # ------------------------------------------------------------------ lumen_filled = enriched_gdf['lumen_filled'].values nmf = enriched_gdf['img_radiance_near_minus_far'].values valid_mask = np.array([ (lf is not None and not math.isnan(float(lf)) if lf is not None else False) and not math.isnan(float(n)) for lf, n in zip(lumen_filled, nmf) ]) group_median_arr = np.full(len(enriched_gdf), np.nan) group_mad_arr = np.full(len(enriched_gdf), np.nan) anomaly_score_arr = np.full(len(enriched_gdf), np.nan) group_label_arr = np.full(len(enriched_gdf), '', dtype=object) min_group_size = 5 valid_indices = np.where(valid_mask)[0] if len(valid_indices) > 0: valid_lumen = np.array([float(lumen_filled[i]) for i in valid_indices]) valid_nmf = np.array([float(nmf[i]) for i in valid_indices]) unique_lumens = np.unique(valid_lumen) for lum_val in unique_lumens: group_idx_in_valid = np.where(valid_lumen == lum_val)[0] if len(group_idx_in_valid) < min_group_size: continue global_indices = valid_indices[group_idx_in_valid] group_nmf = valid_nmf[group_idx_in_valid] med_nmf = float(np.median(group_nmf)) mad_nmf = float(median_abs_deviation(group_nmf)) label = str(round(lum_val)) for gi, nmf_val in zip(global_indices, group_nmf): group_median_arr[gi] = med_nmf group_mad_arr[gi] = mad_nmf group_label_arr[gi] = label if mad_nmf < 1e-10: anomaly_score_arr[gi] = nmf_val - med_nmf else: anomaly_score_arr[gi] = (nmf_val - med_nmf) / mad_nmf enriched_gdf['group_label'] = group_label_arr enriched_gdf['group_median_nmf'] = group_median_arr enriched_gdf['group_mad_nmf'] = group_mad_arr enriched_gdf['anomaly_score'] = anomaly_score_arr enriched_gdf['is_suspect'] = anomaly_score_arr < -2 enriched_gdf['is_strong_suspect'] = anomaly_score_arr < -3 enriched_gdf['method'] = 'Method A' return enriched_gdf