| """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, |
| } |
|
|
| |
| _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": {}, |
| } |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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()} |
|
|
| |
| composite = sum( |
| norm_weights[ind_id] * per_product[ind_id]["score"] |
| for ind_id in norm_weights |
| ) |
| composite = round(composite) |
|
|
| |
| if composite >= 70: |
| status = "GREEN" |
| elif composite >= 40: |
| status = "AMBER" |
| else: |
| status = "RED" |
|
|
| |
| 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" |
|
|
| |
| 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) |
|
|