Aperture / app /outputs /overview.py
KSvend
refactor: rename "indicators" to "EO products" throughout
df6bf75
"""Visual overview — composite score computation and overview outputs."""
from __future__ import annotations
import json
from typing import Any, Sequence
from app.config import OVERVIEW_WEIGHTS
from app.models import ProductResult, StatusLevel
_STATUS_SCORES = {
StatusLevel.GREEN: 100,
StatusLevel.AMBER: 50,
StatusLevel.RED: 0,
}
# Display names for headline generation
_PRODUCT_NAMES = {
"ndvi": "vegetation decline",
"sar": "SAR ground change",
"water": "water extent change",
"buildup": "settlement expansion",
}
def compute_composite_score(results: Sequence[ProductResult]) -> dict[str, Any]:
"""Compute weighted composite score from EO product results.
Returns a dict with: score (0-100), status (GREEN/AMBER/RED),
headline, weights_used, per_product breakdown.
"""
if not results:
return {
"score": 0,
"status": "RED",
"headline": "Area conditions: RED (score 0/100) — no EO products available",
"weights_used": {},
"per_product": {},
}
# Gather scores for completed EO products
per_product: dict[str, dict] = {}
active_weights: dict[str, float] = {}
for result in results:
ind_id = result.product_id
weight = OVERVIEW_WEIGHTS.get(ind_id, 0.05)
ind_score = _STATUS_SCORES.get(result.status, 50)
# Penalize score based on anomaly months (up to -20 points)
anomaly_penalty = min(result.anomaly_months * 3, 20)
ind_score = max(ind_score - anomaly_penalty, 0)
per_product[ind_id] = {
"status": result.status.value.upper(),
"score": ind_score,
"anomaly_months": result.anomaly_months,
}
active_weights[ind_id] = weight
# Re-normalize weights
total_weight = sum(active_weights.values())
if total_weight == 0:
total_weight = 1.0
norm_weights = {k: v / total_weight for k, v in active_weights.items()}
# Weighted average
composite = sum(
norm_weights[ind_id] * per_product[ind_id]["score"]
for ind_id in norm_weights
)
composite = round(composite)
# Classify
if composite >= 70:
status = "GREEN"
elif composite >= 40:
status = "AMBER"
else:
status = "RED"
# Headline: identify top 1-2 drivers of concern
headline = _build_headline(composite, status, per_product, norm_weights)
return {
"score": composite,
"status": status,
"headline": headline,
"weights_used": {k: round(v, 3) for k, v in norm_weights.items()},
"per_product": per_product,
}
def _build_headline(
score: int, status: str,
per_product: dict, weights: dict,
) -> str:
"""Build a human-readable headline identifying concern drivers."""
if status == "GREEN":
return f"Area conditions: GREEN (score {score}/100) — stable across all EO products"
# Find EO products contributing most to non-GREEN score
impacts = []
for ind_id, data in per_product.items():
if data["score"] < 100:
impact = weights.get(ind_id, 0) * (100 - data["score"])
name = _PRODUCT_NAMES.get(ind_id, ind_id)
impacts.append((impact, name))
impacts.sort(reverse=True)
drivers = [name for _, name in impacts[:2]]
driver_str = " and ".join(drivers) if drivers else "multiple EO products"
return f"Area conditions: {status} (score {score}/100) — {driver_str} drive concern"
def write_overview_score(score_data: dict[str, Any], output_path: str) -> None:
"""Write composite score data to a JSON file."""
with open(output_path, "w") as f:
json.dump(score_data, f, indent=2)