deha_hf / methods /method_a.py
hkayabilisim's picture
Initial commit
ab4021e
Raw
History Blame Contribute Delete
16.6 kB
"""
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