KSvend Claude Happy commited on
Commit Β·
df6bf75
1
Parent(s): 70fbdf4
refactor: rename "indicators" to "EO products" throughout
Browse filesFull rename across codebase:
- app/indicators/ β app/eo_products/
- IndicatorResult β ProductResult, IndicatorMeta β ProductMeta
- IndicatorRegistry β ProductRegistry, BaseIndicator β BaseProduct
- NdviIndicator β NdviProduct (and Water, SAR, Buildup)
- indicator_id β product_id in models, APIs, and frontend
- /api/indicators β /api/eo-products
- All user-facing text updated
- Frontend page/element IDs updated
- 27 tests passing
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
- app/advisor.py +7 -7
- app/analysis/compound.py +8 -8
- app/analysis/confidence.py +1 -1
- app/analysis/seasonal.py +1 -1
- app/api/jobs.py +2 -2
- app/api/{indicators_api.py β products_api.py} +3 -3
- app/config.py +2 -2
- app/database.py +5 -5
- app/eo_products/__init__.py +11 -0
- app/{indicators β eo_products}/base.py +19 -19
- app/{indicators β eo_products}/buildup.py +16 -16
- app/{indicators β eo_products}/ndvi.py +13 -13
- app/{indicators β eo_products}/sar.py +14 -14
- app/{indicators β eo_products}/water.py +13 -13
- app/indicators/__init__.py +0 -11
- app/main.py +12 -12
- app/models.py +8 -8
- app/outputs/charts.py +4 -4
- app/outputs/maps.py +1 -1
- app/outputs/narrative.py +12 -12
- app/outputs/overview.py +20 -20
- app/outputs/report.py +26 -26
- app/outputs/thresholds.py +2 -2
- app/worker.py +62 -62
- frontend/index.html +20 -20
- frontend/js/api.js +15 -15
- frontend/js/app.js +22 -22
- frontend/js/{indicators.js β products.js} +26 -26
- frontend/js/results.js +8 -8
- tests/conftest.py +5 -5
- tests/test_models.py +5 -5
- tests/test_narrative.py +3 -3
app/advisor.py
CHANGED
|
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
|
|
| 12 |
_SYSTEM_PROMPT = """\
|
| 13 |
You are a remote sensing advisor for humanitarian programme teams. Given a geographic location, provide a brief analysis recommendation.
|
| 14 |
|
| 15 |
-
Available
|
| 16 |
- ndvi: Vegetation health from Sentinel-2. Detects drought, crop stress, deforestation.
|
| 17 |
- water: Water extent (MNDWI) from Sentinel-2. Detects flooding, drought, reservoir changes.
|
| 18 |
- sar: Radar backscatter from Sentinel-1. Detects ground surface changes, flooding, construction.
|
|
@@ -25,7 +25,7 @@ Respond with JSON only, no markdown. Structure:
|
|
| 25 |
"context": "1-3 sentences about this region and recent relevant events",
|
| 26 |
"recommended_start": "YYYY-MM-DD",
|
| 27 |
"recommended_end": "YYYY-MM-DD",
|
| 28 |
-
"
|
| 29 |
"reasoning": "1 sentence per indicator explaining why it is relevant here"
|
| 30 |
}"""
|
| 31 |
|
|
@@ -33,7 +33,7 @@ _EMPTY_RESPONSE = {
|
|
| 33 |
"context": None,
|
| 34 |
"recommended_start": None,
|
| 35 |
"recommended_end": None,
|
| 36 |
-
"
|
| 37 |
"reasoning": None,
|
| 38 |
}
|
| 39 |
|
|
@@ -85,7 +85,7 @@ async def get_aoi_advice(bbox: list[float]) -> dict:
|
|
| 85 |
advice = json.loads(raw)
|
| 86 |
|
| 87 |
# Validate expected keys are present
|
| 88 |
-
for key in ("context", "recommended_start", "recommended_end", "
|
| 89 |
if key not in advice:
|
| 90 |
logger.warning("AOI advisor response missing key: %s", key)
|
| 91 |
return _EMPTY_RESPONSE
|
|
@@ -94,10 +94,10 @@ async def get_aoi_advice(bbox: list[float]) -> dict:
|
|
| 94 |
date.fromisoformat(advice["recommended_start"])
|
| 95 |
date.fromisoformat(advice["recommended_end"])
|
| 96 |
|
| 97 |
-
# Filter
|
| 98 |
valid_ids = {"ndvi", "water", "sar", "buildup"}
|
| 99 |
-
advice["
|
| 100 |
-
i for i in advice["
|
| 101 |
]
|
| 102 |
|
| 103 |
return advice
|
|
|
|
| 12 |
_SYSTEM_PROMPT = """\
|
| 13 |
You are a remote sensing advisor for humanitarian programme teams. Given a geographic location, provide a brief analysis recommendation.
|
| 14 |
|
| 15 |
+
Available EO products:
|
| 16 |
- ndvi: Vegetation health from Sentinel-2. Detects drought, crop stress, deforestation.
|
| 17 |
- water: Water extent (MNDWI) from Sentinel-2. Detects flooding, drought, reservoir changes.
|
| 18 |
- sar: Radar backscatter from Sentinel-1. Detects ground surface changes, flooding, construction.
|
|
|
|
| 25 |
"context": "1-3 sentences about this region and recent relevant events",
|
| 26 |
"recommended_start": "YYYY-MM-DD",
|
| 27 |
"recommended_end": "YYYY-MM-DD",
|
| 28 |
+
"product_priorities": ["indicator_id", ...],
|
| 29 |
"reasoning": "1 sentence per indicator explaining why it is relevant here"
|
| 30 |
}"""
|
| 31 |
|
|
|
|
| 33 |
"context": None,
|
| 34 |
"recommended_start": None,
|
| 35 |
"recommended_end": None,
|
| 36 |
+
"product_priorities": None,
|
| 37 |
"reasoning": None,
|
| 38 |
}
|
| 39 |
|
|
|
|
| 85 |
advice = json.loads(raw)
|
| 86 |
|
| 87 |
# Validate expected keys are present
|
| 88 |
+
for key in ("context", "recommended_start", "recommended_end", "product_priorities", "reasoning"):
|
| 89 |
if key not in advice:
|
| 90 |
logger.warning("AOI advisor response missing key: %s", key)
|
| 91 |
return _EMPTY_RESPONSE
|
|
|
|
| 94 |
date.fromisoformat(advice["recommended_start"])
|
| 95 |
date.fromisoformat(advice["recommended_end"])
|
| 96 |
|
| 97 |
+
# Filter product_priorities to only known indicators
|
| 98 |
valid_ids = {"ndvi", "water", "sar", "buildup"}
|
| 99 |
+
advice["product_priorities"] = [
|
| 100 |
+
i for i in advice["product_priorities"] if i in valid_ids
|
| 101 |
]
|
| 102 |
|
| 103 |
return advice
|
app/analysis/compound.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Cross-
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import numpy as np
|
|
@@ -15,10 +15,10 @@ def compute_overlap_pct(mask_a: np.ndarray, mask_b: np.ndarray) -> float:
|
|
| 15 |
return float(intersection / min_count * 100)
|
| 16 |
|
| 17 |
|
| 18 |
-
def _tag_confidence(
|
| 19 |
-
if
|
| 20 |
return "strong"
|
| 21 |
-
if
|
| 22 |
return "moderate"
|
| 23 |
return "weak"
|
| 24 |
|
|
@@ -28,12 +28,12 @@ def detect_compound_signals(
|
|
| 28 |
pixel_area_ha: float,
|
| 29 |
threshold: float = 2.0,
|
| 30 |
) -> list[CompoundSignal]:
|
| 31 |
-
"""Test for compound signal patterns across
|
| 32 |
decline: dict[str, np.ndarray] = {}
|
| 33 |
increase: dict[str, np.ndarray] = {}
|
| 34 |
-
for
|
| 35 |
-
decline[
|
| 36 |
-
increase[
|
| 37 |
|
| 38 |
signals: list[CompoundSignal] = []
|
| 39 |
|
|
|
|
| 1 |
+
"""Cross-EO-product compound signal detection."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import numpy as np
|
|
|
|
| 15 |
return float(intersection / min_count * 100)
|
| 16 |
|
| 17 |
|
| 18 |
+
def _tag_confidence(n_products: int, overlap_pct: float) -> str:
|
| 19 |
+
if n_products >= 3 and overlap_pct > 20:
|
| 20 |
return "strong"
|
| 21 |
+
if n_products >= 2 and overlap_pct >= 10:
|
| 22 |
return "moderate"
|
| 23 |
return "weak"
|
| 24 |
|
|
|
|
| 28 |
pixel_area_ha: float,
|
| 29 |
threshold: float = 2.0,
|
| 30 |
) -> list[CompoundSignal]:
|
| 31 |
+
"""Test for compound signal patterns across EO product z-score rasters."""
|
| 32 |
decline: dict[str, np.ndarray] = {}
|
| 33 |
increase: dict[str, np.ndarray] = {}
|
| 34 |
+
for product_id, z in zscore_rasters.items():
|
| 35 |
+
decline[product_id] = z < -threshold
|
| 36 |
+
increase[product_id] = z > threshold
|
| 37 |
|
| 38 |
signals: list[CompoundSignal] = []
|
| 39 |
|
app/analysis/confidence.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Three-factor confidence scoring for EO
|
| 2 |
|
| 3 |
Factors: temporal coverage, baseline depth, spatial completeness.
|
| 4 |
Observation density was removed β without per-scene metadata from
|
|
|
|
| 1 |
+
"""Three-factor confidence scoring for EO products.
|
| 2 |
|
| 3 |
Factors: temporal coverage, baseline depth, spatial completeness.
|
| 4 |
Observation density was removed β without per-scene metadata from
|
app/analysis/seasonal.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Seasonal baseline computation for EO
|
| 2 |
|
| 3 |
Groups multi-year monthly composites by calendar month and computes
|
| 4 |
per-pixel and AOI-level statistics for seasonal anomaly detection.
|
|
|
|
| 1 |
+
"""Seasonal baseline computation for EO products.
|
| 2 |
|
| 3 |
Groups multi-year monthly composites by calendar month and computes
|
| 4 |
per-pixel and AOI-level statistics for seasonal anomaly detection.
|
app/api/jobs.py
CHANGED
|
@@ -21,7 +21,7 @@ async def list_jobs(email: str = Depends(get_current_user)):
|
|
| 21 |
"status": j.status.value,
|
| 22 |
"aoi_name": j.request.aoi.name,
|
| 23 |
"created_at": j.created_at.isoformat(),
|
| 24 |
-
"
|
| 25 |
}
|
| 26 |
for j in jobs
|
| 27 |
]
|
|
@@ -45,7 +45,7 @@ async def get_job(job_id: str, email: str = Depends(get_current_user)):
|
|
| 45 |
"id": job.id,
|
| 46 |
"status": job.status.value,
|
| 47 |
"progress": job.progress,
|
| 48 |
-
"
|
| 49 |
"results": [r.model_dump() for r in job.results],
|
| 50 |
"created_at": job.created_at.isoformat(),
|
| 51 |
"updated_at": job.updated_at.isoformat(),
|
|
|
|
| 21 |
"status": j.status.value,
|
| 22 |
"aoi_name": j.request.aoi.name,
|
| 23 |
"created_at": j.created_at.isoformat(),
|
| 24 |
+
"product_count": len(j.request.indicator_ids),
|
| 25 |
}
|
| 26 |
for j in jobs
|
| 27 |
]
|
|
|
|
| 45 |
"id": job.id,
|
| 46 |
"status": job.status.value,
|
| 47 |
"progress": job.progress,
|
| 48 |
+
"product_ids": job.request.indicator_ids,
|
| 49 |
"results": [r.model_dump() for r in job.results],
|
| 50 |
"created_at": job.created_at.isoformat(),
|
| 51 |
"updated_at": job.updated_at.isoformat(),
|
app/api/{indicators_api.py β products_api.py}
RENAMED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
from fastapi import APIRouter
|
| 2 |
-
from app.
|
| 3 |
|
| 4 |
-
router = APIRouter(prefix="/api/
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("")
|
| 8 |
-
async def
|
| 9 |
return [meta.model_dump() for meta in registry.catalogue()]
|
|
|
|
| 1 |
from fastapi import APIRouter
|
| 2 |
+
from app.eo_products import registry
|
| 3 |
|
| 4 |
+
router = APIRouter(prefix="/api/eo-products", tags=["eo-products"])
|
| 5 |
|
| 6 |
|
| 7 |
@router.get("")
|
| 8 |
+
async def list_products():
|
| 9 |
return [meta.model_dump() for meta in registry.catalogue()]
|
app/config.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
|
| 4 |
import os
|
| 5 |
|
| 6 |
-
# Per-
|
| 7 |
NDVI_RESOLUTION_M: int = 10
|
| 8 |
WATER_RESOLUTION_M: int = 20
|
| 9 |
SAR_RESOLUTION_M: int = 10
|
|
@@ -38,7 +38,7 @@ OPENEO_CLIENT_SECRET: str | None = os.environ.get("OPENEO_CLIENT_SECRET")
|
|
| 38 |
ANTHROPIC_API_KEY: str | None = os.environ.get("ANTHROPIC_API_KEY")
|
| 39 |
|
| 40 |
# Expert weights for the visual overview composite score.
|
| 41 |
-
# Normalized to 1.0.
|
| 42 |
# and weights are re-normalized.
|
| 43 |
OVERVIEW_WEIGHTS: dict[str, float] = {
|
| 44 |
"ndvi": 0.30,
|
|
|
|
| 3 |
|
| 4 |
import os
|
| 5 |
|
| 6 |
+
# Per-product native resolutions (meters)
|
| 7 |
NDVI_RESOLUTION_M: int = 10
|
| 8 |
WATER_RESOLUTION_M: int = 20
|
| 9 |
SAR_RESOLUTION_M: int = 10
|
|
|
|
| 38 |
ANTHROPIC_API_KEY: str | None = os.environ.get("ANTHROPIC_API_KEY")
|
| 39 |
|
| 40 |
# Expert weights for the visual overview composite score.
|
| 41 |
+
# Normalized to 1.0. Products not selected or skipped are excluded
|
| 42 |
# and weights are re-normalized.
|
| 43 |
OVERVIEW_WEIGHTS: dict[str, float] = {
|
| 44 |
"ndvi": 0.30,
|
app/database.py
CHANGED
|
@@ -6,7 +6,7 @@ from datetime import datetime, UTC
|
|
| 6 |
|
| 7 |
import aiosqlite
|
| 8 |
|
| 9 |
-
from app.models import Job, JobRequest, JobStatus,
|
| 10 |
|
| 11 |
|
| 12 |
class Database:
|
|
@@ -80,7 +80,7 @@ class Database:
|
|
| 80 |
await db.commit()
|
| 81 |
|
| 82 |
async def update_job_progress(
|
| 83 |
-
self, job_id: str,
|
| 84 |
) -> None:
|
| 85 |
await self._ensure_init()
|
| 86 |
now = datetime.now(UTC).isoformat()
|
|
@@ -90,14 +90,14 @@ class Database:
|
|
| 90 |
)
|
| 91 |
row = await cursor.fetchone()
|
| 92 |
progress = json.loads(row[0])
|
| 93 |
-
progress[
|
| 94 |
await db.execute(
|
| 95 |
"UPDATE jobs SET progress_json = ?, updated_at = ? WHERE id = ?",
|
| 96 |
(json.dumps(progress), now, job_id),
|
| 97 |
)
|
| 98 |
await db.commit()
|
| 99 |
|
| 100 |
-
async def save_job_result(self, job_id: str, result:
|
| 101 |
await self._ensure_init()
|
| 102 |
now = datetime.now(UTC).isoformat()
|
| 103 |
async with aiosqlite.connect(self.db_path) as db:
|
|
@@ -121,7 +121,7 @@ class Database:
|
|
| 121 |
status=JobStatus(row["status"]),
|
| 122 |
progress=json.loads(row["progress_json"]),
|
| 123 |
results=[
|
| 124 |
-
|
| 125 |
for r in json.loads(row["results_json"])
|
| 126 |
],
|
| 127 |
error=row["error"],
|
|
|
|
| 6 |
|
| 7 |
import aiosqlite
|
| 8 |
|
| 9 |
+
from app.models import Job, JobRequest, JobStatus, ProductResult
|
| 10 |
|
| 11 |
|
| 12 |
class Database:
|
|
|
|
| 80 |
await db.commit()
|
| 81 |
|
| 82 |
async def update_job_progress(
|
| 83 |
+
self, job_id: str, product_id: str, indicator_status: str
|
| 84 |
) -> None:
|
| 85 |
await self._ensure_init()
|
| 86 |
now = datetime.now(UTC).isoformat()
|
|
|
|
| 90 |
)
|
| 91 |
row = await cursor.fetchone()
|
| 92 |
progress = json.loads(row[0])
|
| 93 |
+
progress[product_id] = indicator_status
|
| 94 |
await db.execute(
|
| 95 |
"UPDATE jobs SET progress_json = ?, updated_at = ? WHERE id = ?",
|
| 96 |
(json.dumps(progress), now, job_id),
|
| 97 |
)
|
| 98 |
await db.commit()
|
| 99 |
|
| 100 |
+
async def save_job_result(self, job_id: str, result: ProductResult) -> None:
|
| 101 |
await self._ensure_init()
|
| 102 |
now = datetime.now(UTC).isoformat()
|
| 103 |
async with aiosqlite.connect(self.db_path) as db:
|
|
|
|
| 121 |
status=JobStatus(row["status"]),
|
| 122 |
progress=json.loads(row["progress_json"]),
|
| 123 |
results=[
|
| 124 |
+
ProductResult.model_validate(r)
|
| 125 |
for r in json.loads(row["results_json"])
|
| 126 |
],
|
| 127 |
error=row["error"],
|
app/eo_products/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.eo_products.base import ProductRegistry
|
| 2 |
+
from app.eo_products.ndvi import NdviProduct
|
| 3 |
+
from app.eo_products.water import WaterProduct
|
| 4 |
+
from app.eo_products.sar import SarProduct
|
| 5 |
+
from app.eo_products.buildup import BuiltupProduct
|
| 6 |
+
|
| 7 |
+
registry = ProductRegistry()
|
| 8 |
+
registry.register(NdviProduct())
|
| 9 |
+
registry.register(WaterProduct())
|
| 10 |
+
registry.register(SarProduct())
|
| 11 |
+
registry.register(BuiltupProduct())
|
app/{indicators β eo_products}/base.py
RENAMED
|
@@ -7,7 +7,7 @@ from typing import Optional
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
|
| 10 |
-
from app.models import AOI, TimeRange,
|
| 11 |
|
| 12 |
|
| 13 |
@dataclass
|
|
@@ -24,7 +24,7 @@ class SpatialData:
|
|
| 24 |
vmax: float | None = None
|
| 25 |
|
| 26 |
|
| 27 |
-
class
|
| 28 |
id: str
|
| 29 |
name: str
|
| 30 |
category: str
|
|
@@ -35,8 +35,8 @@ class BaseIndicator(abc.ABC):
|
|
| 35 |
_zscore_raster: np.ndarray | None = None
|
| 36 |
_hotspot_mask: np.ndarray | None = None
|
| 37 |
|
| 38 |
-
def meta(self) ->
|
| 39 |
-
return
|
| 40 |
id=self.id,
|
| 41 |
name=self.name,
|
| 42 |
category=self.category,
|
|
@@ -45,7 +45,7 @@ class BaseIndicator(abc.ABC):
|
|
| 45 |
)
|
| 46 |
|
| 47 |
@abc.abstractmethod
|
| 48 |
-
async def process(self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None) ->
|
| 49 |
...
|
| 50 |
|
| 51 |
uses_batch: bool = False
|
|
@@ -53,14 +53,14 @@ class BaseIndicator(abc.ABC):
|
|
| 53 |
async def submit_batch(
|
| 54 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 55 |
) -> list:
|
| 56 |
-
"""Submit openEO batch jobs. Override in batch
|
| 57 |
raise NotImplementedError(f"{self.id} does not support batch processing")
|
| 58 |
|
| 59 |
async def harvest(
|
| 60 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 61 |
batch_jobs: list | None = None,
|
| 62 |
-
) ->
|
| 63 |
-
"""Download completed batch jobs and compute result. Override in batch
|
| 64 |
raise NotImplementedError(f"{self.id} does not support batch harvesting")
|
| 65 |
|
| 66 |
def get_spatial_data(self) -> Optional[SpatialData]:
|
|
@@ -128,20 +128,20 @@ class BaseIndicator(abc.ABC):
|
|
| 128 |
raise FileNotFoundError(f"No GeoTIFF found in {fallback_dir}")
|
| 129 |
|
| 130 |
|
| 131 |
-
class
|
| 132 |
def __init__(self) -> None:
|
| 133 |
-
self.
|
| 134 |
|
| 135 |
-
def register(self,
|
| 136 |
-
self.
|
| 137 |
|
| 138 |
-
def get(self,
|
| 139 |
-
if
|
| 140 |
-
raise KeyError(f"Unknown
|
| 141 |
-
return self.
|
| 142 |
|
| 143 |
def list_ids(self) -> list[str]:
|
| 144 |
-
return list(self.
|
| 145 |
|
| 146 |
-
def catalogue(self) -> list[
|
| 147 |
-
return [
|
|
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
|
| 10 |
+
from app.models import AOI, TimeRange, ProductResult, ProductMeta
|
| 11 |
|
| 12 |
|
| 13 |
@dataclass
|
|
|
|
| 24 |
vmax: float | None = None
|
| 25 |
|
| 26 |
|
| 27 |
+
class BaseProduct(abc.ABC):
|
| 28 |
id: str
|
| 29 |
name: str
|
| 30 |
category: str
|
|
|
|
| 35 |
_zscore_raster: np.ndarray | None = None
|
| 36 |
_hotspot_mask: np.ndarray | None = None
|
| 37 |
|
| 38 |
+
def meta(self) -> ProductMeta:
|
| 39 |
+
return ProductMeta(
|
| 40 |
id=self.id,
|
| 41 |
name=self.name,
|
| 42 |
category=self.category,
|
|
|
|
| 45 |
)
|
| 46 |
|
| 47 |
@abc.abstractmethod
|
| 48 |
+
async def process(self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None) -> ProductResult:
|
| 49 |
...
|
| 50 |
|
| 51 |
uses_batch: bool = False
|
|
|
|
| 53 |
async def submit_batch(
|
| 54 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 55 |
) -> list:
|
| 56 |
+
"""Submit openEO batch jobs. Override in batch EO products."""
|
| 57 |
raise NotImplementedError(f"{self.id} does not support batch processing")
|
| 58 |
|
| 59 |
async def harvest(
|
| 60 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 61 |
batch_jobs: list | None = None,
|
| 62 |
+
) -> ProductResult:
|
| 63 |
+
"""Download completed batch jobs and compute result. Override in batch EO products."""
|
| 64 |
raise NotImplementedError(f"{self.id} does not support batch harvesting")
|
| 65 |
|
| 66 |
def get_spatial_data(self) -> Optional[SpatialData]:
|
|
|
|
| 128 |
raise FileNotFoundError(f"No GeoTIFF found in {fallback_dir}")
|
| 129 |
|
| 130 |
|
| 131 |
+
class ProductRegistry:
|
| 132 |
def __init__(self) -> None:
|
| 133 |
+
self._products: dict[str, BaseProduct] = {}
|
| 134 |
|
| 135 |
+
def register(self, product: BaseProduct) -> None:
|
| 136 |
+
self._products[product.id] = product
|
| 137 |
|
| 138 |
+
def get(self, product_id: str) -> BaseProduct:
|
| 139 |
+
if product_id not in self._products:
|
| 140 |
+
raise KeyError(f"Unknown EO product: {product_id}")
|
| 141 |
+
return self._products[product_id]
|
| 142 |
|
| 143 |
def list_ids(self) -> list[str]:
|
| 144 |
+
return list(self._products.keys())
|
| 145 |
|
| 146 |
+
def catalogue(self) -> list[ProductMeta]:
|
| 147 |
+
return [p.meta() for p in self._products.values()]
|
app/{indicators β eo_products}/buildup.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
Computes monthly NDBI composites from Sentinel-2 L2A, classifies built-up
|
| 4 |
pixels (NDBI > 0), and tracks settlement extent change against a seasonal
|
|
@@ -23,11 +23,11 @@ from app.config import (
|
|
| 23 |
ZSCORE_THRESHOLD,
|
| 24 |
MIN_CLUSTER_PIXELS,
|
| 25 |
)
|
| 26 |
-
from app.
|
| 27 |
from app.models import (
|
| 28 |
AOI,
|
| 29 |
TimeRange,
|
| 30 |
-
|
| 31 |
StatusLevel,
|
| 32 |
TrendDirection,
|
| 33 |
ConfidenceLevel,
|
|
@@ -48,7 +48,7 @@ BASELINE_YEARS = 5
|
|
| 48 |
NDBI_THRESHOLD = 0.0 # NDBI > 0 = potential built-up
|
| 49 |
|
| 50 |
|
| 51 |
-
class
|
| 52 |
id = "buildup"
|
| 53 |
name = "Settlement Extent"
|
| 54 |
category = "D11"
|
|
@@ -103,7 +103,7 @@ class BuiltupIndicator(BaseIndicator):
|
|
| 103 |
async def harvest(
|
| 104 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 105 |
batch_jobs: list | None = None,
|
| 106 |
-
) ->
|
| 107 |
"""Download completed batch job results and compute built-up statistics."""
|
| 108 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 109 |
|
|
@@ -249,11 +249,11 @@ class BuiltupIndicator(BaseIndicator):
|
|
| 249 |
vmin=-1,
|
| 250 |
vmax=1,
|
| 251 |
)
|
| 252 |
-
self.
|
| 253 |
self._render_band = 1
|
| 254 |
|
| 255 |
-
return
|
| 256 |
-
|
| 257 |
headline=headline,
|
| 258 |
status=status,
|
| 259 |
trend=trend,
|
|
@@ -318,11 +318,11 @@ class BuiltupIndicator(BaseIndicator):
|
|
| 318 |
vmin=-0.5,
|
| 319 |
vmax=0.5,
|
| 320 |
)
|
| 321 |
-
self.
|
| 322 |
self._render_band = peak_band
|
| 323 |
|
| 324 |
-
return
|
| 325 |
-
|
| 326 |
headline=headline,
|
| 327 |
status=status,
|
| 328 |
trend=trend,
|
|
@@ -357,12 +357,12 @@ class BuiltupIndicator(BaseIndicator):
|
|
| 357 |
|
| 358 |
async def process(
|
| 359 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 360 |
-
) ->
|
| 361 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 362 |
|
| 363 |
async def _process_openeo(
|
| 364 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 365 |
-
) ->
|
| 366 |
import asyncio
|
| 367 |
|
| 368 |
conn = get_connection()
|
|
@@ -521,11 +521,11 @@ class BuiltupIndicator(BaseIndicator):
|
|
| 521 |
vmin=-1,
|
| 522 |
vmax=1,
|
| 523 |
)
|
| 524 |
-
self.
|
| 525 |
self._render_band = 1
|
| 526 |
|
| 527 |
-
return
|
| 528 |
-
|
| 529 |
headline=headline,
|
| 530 |
status=status,
|
| 531 |
trend=trend,
|
|
|
|
| 1 |
+
"""Settlement Extent EO product β NDBI via CDSE openEO.
|
| 2 |
|
| 3 |
Computes monthly NDBI composites from Sentinel-2 L2A, classifies built-up
|
| 4 |
pixels (NDBI > 0), and tracks settlement extent change against a seasonal
|
|
|
|
| 23 |
ZSCORE_THRESHOLD,
|
| 24 |
MIN_CLUSTER_PIXELS,
|
| 25 |
)
|
| 26 |
+
from app.eo_products.base import BaseProduct, SpatialData
|
| 27 |
from app.models import (
|
| 28 |
AOI,
|
| 29 |
TimeRange,
|
| 30 |
+
ProductResult,
|
| 31 |
StatusLevel,
|
| 32 |
TrendDirection,
|
| 33 |
ConfidenceLevel,
|
|
|
|
| 48 |
NDBI_THRESHOLD = 0.0 # NDBI > 0 = potential built-up
|
| 49 |
|
| 50 |
|
| 51 |
+
class BuiltupProduct(BaseProduct):
|
| 52 |
id = "buildup"
|
| 53 |
name = "Settlement Extent"
|
| 54 |
category = "D11"
|
|
|
|
| 103 |
async def harvest(
|
| 104 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 105 |
batch_jobs: list | None = None,
|
| 106 |
+
) -> ProductResult:
|
| 107 |
"""Download completed batch job results and compute built-up statistics."""
|
| 108 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 109 |
|
|
|
|
| 249 |
vmin=-1,
|
| 250 |
vmax=1,
|
| 251 |
)
|
| 252 |
+
self._product_raster_path = change_map_path
|
| 253 |
self._render_band = 1
|
| 254 |
|
| 255 |
+
return ProductResult(
|
| 256 |
+
product_id=self.id,
|
| 257 |
headline=headline,
|
| 258 |
status=status,
|
| 259 |
trend=trend,
|
|
|
|
| 318 |
vmin=-0.5,
|
| 319 |
vmax=0.5,
|
| 320 |
)
|
| 321 |
+
self._product_raster_path = current_path
|
| 322 |
self._render_band = peak_band
|
| 323 |
|
| 324 |
+
return ProductResult(
|
| 325 |
+
product_id=self.id,
|
| 326 |
headline=headline,
|
| 327 |
status=status,
|
| 328 |
trend=trend,
|
|
|
|
| 357 |
|
| 358 |
async def process(
|
| 359 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 360 |
+
) -> ProductResult:
|
| 361 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 362 |
|
| 363 |
async def _process_openeo(
|
| 364 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 365 |
+
) -> ProductResult:
|
| 366 |
import asyncio
|
| 367 |
|
| 368 |
conn = get_connection()
|
|
|
|
| 521 |
vmin=-1,
|
| 522 |
vmax=1,
|
| 523 |
)
|
| 524 |
+
self._product_raster_path = change_map_path
|
| 525 |
self._render_band = 1
|
| 526 |
|
| 527 |
+
return ProductResult(
|
| 528 |
+
product_id=self.id,
|
| 529 |
headline=headline,
|
| 530 |
status=status,
|
| 531 |
trend=trend,
|
app/{indicators β eo_products}/ndvi.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""NDVI
|
| 2 |
|
| 3 |
Computes monthly median NDVI composites from Sentinel-2 L2A, compares
|
| 4 |
current period to a 5-year baseline, and classifies vegetation change
|
|
@@ -22,11 +22,11 @@ from app.config import (
|
|
| 22 |
ZSCORE_THRESHOLD,
|
| 23 |
MIN_CLUSTER_PIXELS,
|
| 24 |
)
|
| 25 |
-
from app.
|
| 26 |
from app.models import (
|
| 27 |
AOI,
|
| 28 |
TimeRange,
|
| 29 |
-
|
| 30 |
StatusLevel,
|
| 31 |
TrendDirection,
|
| 32 |
ConfidenceLevel,
|
|
@@ -46,7 +46,7 @@ logger = logging.getLogger(__name__)
|
|
| 46 |
BASELINE_YEARS = 5
|
| 47 |
|
| 48 |
|
| 49 |
-
class
|
| 50 |
id = "ndvi"
|
| 51 |
name = "Vegetation (NDVI)"
|
| 52 |
category = "D2"
|
|
@@ -101,7 +101,7 @@ class NdviIndicator(BaseIndicator):
|
|
| 101 |
async def harvest(
|
| 102 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 103 |
batch_jobs: list | None = None,
|
| 104 |
-
) ->
|
| 105 |
"""Download completed batch job results and compute NDVI statistics."""
|
| 106 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 107 |
|
|
@@ -234,13 +234,13 @@ class NdviIndicator(BaseIndicator):
|
|
| 234 |
map_type="raster", label="NDVI", colormap="RdYlGn",
|
| 235 |
vmin=-0.2, vmax=0.9,
|
| 236 |
)
|
| 237 |
-
self.
|
| 238 |
self._true_color_path = true_color_path
|
| 239 |
self._ndvi_peak_band = current_stats["peak_month_band"]
|
| 240 |
self._render_band = current_stats["peak_month_band"]
|
| 241 |
|
| 242 |
-
return
|
| 243 |
-
|
| 244 |
headline=headline,
|
| 245 |
status=status,
|
| 246 |
trend=trend,
|
|
@@ -275,12 +275,12 @@ class NdviIndicator(BaseIndicator):
|
|
| 275 |
|
| 276 |
async def process(
|
| 277 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 278 |
-
) ->
|
| 279 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 280 |
|
| 281 |
async def _process_openeo(
|
| 282 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 283 |
-
) ->
|
| 284 |
import asyncio
|
| 285 |
|
| 286 |
conn = get_connection()
|
|
@@ -415,13 +415,13 @@ class NdviIndicator(BaseIndicator):
|
|
| 415 |
vmax=0.9,
|
| 416 |
)
|
| 417 |
# Store paths for the worker to use
|
| 418 |
-
self.
|
| 419 |
self._true_color_path = true_color_path
|
| 420 |
self._ndvi_peak_band = current_stats["peak_month_band"]
|
| 421 |
self._render_band = current_stats["peak_month_band"]
|
| 422 |
|
| 423 |
-
return
|
| 424 |
-
|
| 425 |
headline=headline,
|
| 426 |
status=status,
|
| 427 |
trend=trend,
|
|
|
|
| 1 |
+
"""Vegetation (NDVI) EO product β pixel-level via CDSE openEO.
|
| 2 |
|
| 3 |
Computes monthly median NDVI composites from Sentinel-2 L2A, compares
|
| 4 |
current period to a 5-year baseline, and classifies vegetation change
|
|
|
|
| 22 |
ZSCORE_THRESHOLD,
|
| 23 |
MIN_CLUSTER_PIXELS,
|
| 24 |
)
|
| 25 |
+
from app.eo_products.base import BaseProduct, SpatialData
|
| 26 |
from app.models import (
|
| 27 |
AOI,
|
| 28 |
TimeRange,
|
| 29 |
+
ProductResult,
|
| 30 |
StatusLevel,
|
| 31 |
TrendDirection,
|
| 32 |
ConfidenceLevel,
|
|
|
|
| 46 |
BASELINE_YEARS = 5
|
| 47 |
|
| 48 |
|
| 49 |
+
class NdviProduct(BaseProduct):
|
| 50 |
id = "ndvi"
|
| 51 |
name = "Vegetation (NDVI)"
|
| 52 |
category = "D2"
|
|
|
|
| 101 |
async def harvest(
|
| 102 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 103 |
batch_jobs: list | None = None,
|
| 104 |
+
) -> ProductResult:
|
| 105 |
"""Download completed batch job results and compute NDVI statistics."""
|
| 106 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 107 |
|
|
|
|
| 234 |
map_type="raster", label="NDVI", colormap="RdYlGn",
|
| 235 |
vmin=-0.2, vmax=0.9,
|
| 236 |
)
|
| 237 |
+
self._product_raster_path = current_path
|
| 238 |
self._true_color_path = true_color_path
|
| 239 |
self._ndvi_peak_band = current_stats["peak_month_band"]
|
| 240 |
self._render_band = current_stats["peak_month_band"]
|
| 241 |
|
| 242 |
+
return ProductResult(
|
| 243 |
+
product_id=self.id,
|
| 244 |
headline=headline,
|
| 245 |
status=status,
|
| 246 |
trend=trend,
|
|
|
|
| 275 |
|
| 276 |
async def process(
|
| 277 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 278 |
+
) -> ProductResult:
|
| 279 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 280 |
|
| 281 |
async def _process_openeo(
|
| 282 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 283 |
+
) -> ProductResult:
|
| 284 |
import asyncio
|
| 285 |
|
| 286 |
conn = get_connection()
|
|
|
|
| 415 |
vmax=0.9,
|
| 416 |
)
|
| 417 |
# Store paths for the worker to use
|
| 418 |
+
self._product_raster_path = current_path
|
| 419 |
self._true_color_path = true_color_path
|
| 420 |
self._ndvi_peak_band = current_stats["peak_month_band"]
|
| 421 |
self._render_band = current_stats["peak_month_band"]
|
| 422 |
|
| 423 |
+
return ProductResult(
|
| 424 |
+
product_id=self.id,
|
| 425 |
headline=headline,
|
| 426 |
status=status,
|
| 427 |
trend=trend,
|
app/{indicators β eo_products}/sar.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""SAR Backscatter
|
| 2 |
|
| 3 |
Computes monthly VV/VH median composites, detects ground surface change
|
| 4 |
and potential flood events against a seasonal baseline using z-score
|
|
@@ -22,11 +22,11 @@ from app.config import (
|
|
| 22 |
ZSCORE_THRESHOLD,
|
| 23 |
MIN_CLUSTER_PIXELS,
|
| 24 |
)
|
| 25 |
-
from app.
|
| 26 |
from app.models import (
|
| 27 |
AOI,
|
| 28 |
TimeRange,
|
| 29 |
-
|
| 30 |
StatusLevel,
|
| 31 |
TrendDirection,
|
| 32 |
ConfidenceLevel,
|
|
@@ -45,7 +45,7 @@ CHANGE_THRESHOLD_DB = 3.0 # dB change considered significant
|
|
| 45 |
FLOOD_SIGMA = 2.0 # Standard deviations below baseline mean
|
| 46 |
|
| 47 |
|
| 48 |
-
class
|
| 49 |
id = "sar"
|
| 50 |
name = "SAR Backscatter"
|
| 51 |
category = "D10"
|
|
@@ -96,7 +96,7 @@ class SarIndicator(BaseIndicator):
|
|
| 96 |
async def harvest(
|
| 97 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 98 |
batch_jobs: list | None = None,
|
| 99 |
-
) ->
|
| 100 |
"""Download completed batch job results and compute SAR statistics."""
|
| 101 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 102 |
|
|
@@ -255,7 +255,7 @@ class SarIndicator(BaseIndicator):
|
|
| 255 |
vmin=-6,
|
| 256 |
vmax=6,
|
| 257 |
)
|
| 258 |
-
self.
|
| 259 |
self._render_band = 1
|
| 260 |
map_layer_path = change_map_path
|
| 261 |
|
|
@@ -296,7 +296,7 @@ class SarIndicator(BaseIndicator):
|
|
| 296 |
vmin=-25,
|
| 297 |
vmax=0,
|
| 298 |
)
|
| 299 |
-
self.
|
| 300 |
self._render_band = 1
|
| 301 |
map_layer_path = current_path
|
| 302 |
|
|
@@ -310,8 +310,8 @@ class SarIndicator(BaseIndicator):
|
|
| 310 |
|
| 311 |
self._true_color_path = true_color_path
|
| 312 |
|
| 313 |
-
return
|
| 314 |
-
|
| 315 |
headline=headline,
|
| 316 |
status=status,
|
| 317 |
trend=trend,
|
|
@@ -345,12 +345,12 @@ class SarIndicator(BaseIndicator):
|
|
| 345 |
|
| 346 |
async def process(
|
| 347 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 348 |
-
) ->
|
| 349 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 350 |
|
| 351 |
async def _process_openeo(
|
| 352 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 353 |
-
) ->
|
| 354 |
import asyncio
|
| 355 |
|
| 356 |
conn = get_connection()
|
|
@@ -524,11 +524,11 @@ class SarIndicator(BaseIndicator):
|
|
| 524 |
vmin=-6,
|
| 525 |
vmax=6,
|
| 526 |
)
|
| 527 |
-
self.
|
| 528 |
self._render_band = 1
|
| 529 |
|
| 530 |
-
return
|
| 531 |
-
|
| 532 |
headline=headline,
|
| 533 |
status=status,
|
| 534 |
trend=trend,
|
|
|
|
| 1 |
+
"""SAR Backscatter EO product β Sentinel-1 GRD via CDSE openEO.
|
| 2 |
|
| 3 |
Computes monthly VV/VH median composites, detects ground surface change
|
| 4 |
and potential flood events against a seasonal baseline using z-score
|
|
|
|
| 22 |
ZSCORE_THRESHOLD,
|
| 23 |
MIN_CLUSTER_PIXELS,
|
| 24 |
)
|
| 25 |
+
from app.eo_products.base import BaseProduct, SpatialData
|
| 26 |
from app.models import (
|
| 27 |
AOI,
|
| 28 |
TimeRange,
|
| 29 |
+
ProductResult,
|
| 30 |
StatusLevel,
|
| 31 |
TrendDirection,
|
| 32 |
ConfidenceLevel,
|
|
|
|
| 45 |
FLOOD_SIGMA = 2.0 # Standard deviations below baseline mean
|
| 46 |
|
| 47 |
|
| 48 |
+
class SarProduct(BaseProduct):
|
| 49 |
id = "sar"
|
| 50 |
name = "SAR Backscatter"
|
| 51 |
category = "D10"
|
|
|
|
| 96 |
async def harvest(
|
| 97 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 98 |
batch_jobs: list | None = None,
|
| 99 |
+
) -> ProductResult:
|
| 100 |
"""Download completed batch job results and compute SAR statistics."""
|
| 101 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 102 |
|
|
|
|
| 255 |
vmin=-6,
|
| 256 |
vmax=6,
|
| 257 |
)
|
| 258 |
+
self._product_raster_path = change_map_path
|
| 259 |
self._render_band = 1
|
| 260 |
map_layer_path = change_map_path
|
| 261 |
|
|
|
|
| 296 |
vmin=-25,
|
| 297 |
vmax=0,
|
| 298 |
)
|
| 299 |
+
self._product_raster_path = current_path
|
| 300 |
self._render_band = 1
|
| 301 |
map_layer_path = current_path
|
| 302 |
|
|
|
|
| 310 |
|
| 311 |
self._true_color_path = true_color_path
|
| 312 |
|
| 313 |
+
return ProductResult(
|
| 314 |
+
product_id=self.id,
|
| 315 |
headline=headline,
|
| 316 |
status=status,
|
| 317 |
trend=trend,
|
|
|
|
| 345 |
|
| 346 |
async def process(
|
| 347 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 348 |
+
) -> ProductResult:
|
| 349 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 350 |
|
| 351 |
async def _process_openeo(
|
| 352 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 353 |
+
) -> ProductResult:
|
| 354 |
import asyncio
|
| 355 |
|
| 356 |
conn = get_connection()
|
|
|
|
| 524 |
vmin=-6,
|
| 525 |
vmax=6,
|
| 526 |
)
|
| 527 |
+
self._product_raster_path = change_map_path
|
| 528 |
self._render_band = 1
|
| 529 |
|
| 530 |
+
return ProductResult(
|
| 531 |
+
product_id=self.id,
|
| 532 |
headline=headline,
|
| 533 |
status=status,
|
| 534 |
trend=trend,
|
app/{indicators β eo_products}/water.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Water Bodies
|
| 2 |
|
| 3 |
Computes monthly MNDWI composites from Sentinel-2 L2A, classifies water
|
| 4 |
pixels (MNDWI > 0), and tracks water extent change against a 5-year
|
|
@@ -22,11 +22,11 @@ from app.config import (
|
|
| 22 |
ZSCORE_THRESHOLD,
|
| 23 |
MIN_CLUSTER_PIXELS,
|
| 24 |
)
|
| 25 |
-
from app.
|
| 26 |
from app.models import (
|
| 27 |
AOI,
|
| 28 |
TimeRange,
|
| 29 |
-
|
| 30 |
StatusLevel,
|
| 31 |
TrendDirection,
|
| 32 |
ConfidenceLevel,
|
|
@@ -47,7 +47,7 @@ BASELINE_YEARS = 5
|
|
| 47 |
WATER_THRESHOLD = 0.0 # MNDWI > 0 = water
|
| 48 |
|
| 49 |
|
| 50 |
-
class
|
| 51 |
id = "water"
|
| 52 |
name = "Water Bodies"
|
| 53 |
category = "D9"
|
|
@@ -102,7 +102,7 @@ class WaterIndicator(BaseIndicator):
|
|
| 102 |
async def harvest(
|
| 103 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 104 |
batch_jobs: list | None = None,
|
| 105 |
-
) ->
|
| 106 |
"""Download completed batch job results and compute water statistics."""
|
| 107 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 108 |
|
|
@@ -247,12 +247,12 @@ class WaterIndicator(BaseIndicator):
|
|
| 247 |
vmin=-0.5,
|
| 248 |
vmax=0.5,
|
| 249 |
)
|
| 250 |
-
self.
|
| 251 |
self._true_color_path = true_color_path
|
| 252 |
self._render_band = current_stats["peak_water_band"]
|
| 253 |
|
| 254 |
-
return
|
| 255 |
-
|
| 256 |
headline=headline,
|
| 257 |
status=status,
|
| 258 |
trend=trend,
|
|
@@ -289,12 +289,12 @@ class WaterIndicator(BaseIndicator):
|
|
| 289 |
|
| 290 |
async def process(
|
| 291 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 292 |
-
) ->
|
| 293 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 294 |
|
| 295 |
async def _process_openeo(
|
| 296 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 297 |
-
) ->
|
| 298 |
import asyncio
|
| 299 |
|
| 300 |
conn = get_connection()
|
|
@@ -429,12 +429,12 @@ class WaterIndicator(BaseIndicator):
|
|
| 429 |
vmin=-0.5,
|
| 430 |
vmax=0.5,
|
| 431 |
)
|
| 432 |
-
self.
|
| 433 |
self._true_color_path = true_color_path
|
| 434 |
self._render_band = current_stats["peak_water_band"]
|
| 435 |
|
| 436 |
-
return
|
| 437 |
-
|
| 438 |
headline=headline,
|
| 439 |
status=status,
|
| 440 |
trend=trend,
|
|
|
|
| 1 |
+
"""Water Bodies EO product β pixel-level MNDWI via CDSE openEO.
|
| 2 |
|
| 3 |
Computes monthly MNDWI composites from Sentinel-2 L2A, classifies water
|
| 4 |
pixels (MNDWI > 0), and tracks water extent change against a 5-year
|
|
|
|
| 22 |
ZSCORE_THRESHOLD,
|
| 23 |
MIN_CLUSTER_PIXELS,
|
| 24 |
)
|
| 25 |
+
from app.eo_products.base import BaseProduct, SpatialData
|
| 26 |
from app.models import (
|
| 27 |
AOI,
|
| 28 |
TimeRange,
|
| 29 |
+
ProductResult,
|
| 30 |
StatusLevel,
|
| 31 |
TrendDirection,
|
| 32 |
ConfidenceLevel,
|
|
|
|
| 47 |
WATER_THRESHOLD = 0.0 # MNDWI > 0 = water
|
| 48 |
|
| 49 |
|
| 50 |
+
class WaterProduct(BaseProduct):
|
| 51 |
id = "water"
|
| 52 |
name = "Water Bodies"
|
| 53 |
category = "D9"
|
|
|
|
| 102 |
async def harvest(
|
| 103 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None,
|
| 104 |
batch_jobs: list | None = None,
|
| 105 |
+
) -> ProductResult:
|
| 106 |
"""Download completed batch job results and compute water statistics."""
|
| 107 |
current_job, baseline_job, true_color_job = batch_jobs
|
| 108 |
|
|
|
|
| 247 |
vmin=-0.5,
|
| 248 |
vmax=0.5,
|
| 249 |
)
|
| 250 |
+
self._product_raster_path = current_path
|
| 251 |
self._true_color_path = true_color_path
|
| 252 |
self._render_band = current_stats["peak_water_band"]
|
| 253 |
|
| 254 |
+
return ProductResult(
|
| 255 |
+
product_id=self.id,
|
| 256 |
headline=headline,
|
| 257 |
status=status,
|
| 258 |
trend=trend,
|
|
|
|
| 289 |
|
| 290 |
async def process(
|
| 291 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
|
| 292 |
+
) -> ProductResult:
|
| 293 |
return await self._process_openeo(aoi, time_range, season_months)
|
| 294 |
|
| 295 |
async def _process_openeo(
|
| 296 |
self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
|
| 297 |
+
) -> ProductResult:
|
| 298 |
import asyncio
|
| 299 |
|
| 300 |
conn = get_connection()
|
|
|
|
| 429 |
vmin=-0.5,
|
| 430 |
vmax=0.5,
|
| 431 |
)
|
| 432 |
+
self._product_raster_path = current_path
|
| 433 |
self._true_color_path = true_color_path
|
| 434 |
self._render_band = current_stats["peak_water_band"]
|
| 435 |
|
| 436 |
+
return ProductResult(
|
| 437 |
+
product_id=self.id,
|
| 438 |
headline=headline,
|
| 439 |
status=status,
|
| 440 |
trend=trend,
|
app/indicators/__init__.py
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
from app.indicators.base import IndicatorRegistry
|
| 2 |
-
from app.indicators.ndvi import NdviIndicator
|
| 3 |
-
from app.indicators.water import WaterIndicator
|
| 4 |
-
from app.indicators.sar import SarIndicator
|
| 5 |
-
from app.indicators.buildup import BuiltupIndicator
|
| 6 |
-
|
| 7 |
-
registry = IndicatorRegistry()
|
| 8 |
-
registry.register(NdviIndicator())
|
| 9 |
-
registry.register(WaterIndicator())
|
| 10 |
-
registry.register(SarIndicator())
|
| 11 |
-
registry.register(BuiltupIndicator())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
CHANGED
|
@@ -9,9 +9,9 @@ from fastapi.staticfiles import StaticFiles
|
|
| 9 |
from fastapi import Depends
|
| 10 |
from app.database import Database
|
| 11 |
from app.worker import worker_loop
|
| 12 |
-
from app.
|
| 13 |
from app.api.jobs import router as jobs_router, init_router as init_jobs
|
| 14 |
-
from app.api.
|
| 15 |
from app.api.auth import router as auth_router, get_current_user
|
| 16 |
from app.advisor import get_aoi_advice
|
| 17 |
from app.models import AoiAdviceRequest
|
|
@@ -31,7 +31,7 @@ def create_app(db_path: str = "aperture.db", run_worker: bool = False) -> FastAP
|
|
| 31 |
if OPENEO_CLIENT_ID and OPENEO_CLIENT_SECRET:
|
| 32 |
print(f"[Aperture] CDSE credentials configured (client_id={OPENEO_CLIENT_ID[:8]}...)")
|
| 33 |
else:
|
| 34 |
-
print("[Aperture] WARNING: CDSE credentials NOT configured β EO
|
| 35 |
from app.config import ANTHROPIC_API_KEY
|
| 36 |
if ANTHROPIC_API_KEY:
|
| 37 |
print("[Aperture] Anthropic API key configured (AOI advisor enabled)")
|
|
@@ -60,7 +60,7 @@ def create_app(db_path: str = "aperture.db", run_worker: bool = False) -> FastAP
|
|
| 60 |
|
| 61 |
init_jobs(db)
|
| 62 |
app.include_router(jobs_router)
|
| 63 |
-
app.include_router(
|
| 64 |
app.include_router(auth_router)
|
| 65 |
|
| 66 |
@app.post("/api/aoi-advice")
|
|
@@ -109,23 +109,23 @@ def create_app(db_path: str = "aperture.db", run_worker: bool = False) -> FastAP
|
|
| 109 |
filename=f"aperture_package_{job_id}.zip",
|
| 110 |
)
|
| 111 |
|
| 112 |
-
@app.get("/api/jobs/{job_id}/maps/{
|
| 113 |
-
async def
|
| 114 |
await _verify_job_access(job_id, email)
|
| 115 |
-
map_path = _HERE.parent / "results" / job_id / f"{
|
| 116 |
if not map_path.exists():
|
| 117 |
-
raise HTTPException(status_code=404, detail="Map not available for this
|
| 118 |
return FileResponse(
|
| 119 |
path=str(map_path),
|
| 120 |
media_type="image/png",
|
| 121 |
)
|
| 122 |
|
| 123 |
-
@app.get("/api/jobs/{job_id}/spatial/{
|
| 124 |
-
async def
|
| 125 |
await _verify_job_access(job_id, email)
|
| 126 |
-
spatial_path = _HERE.parent / "results" / job_id / f"{
|
| 127 |
if not spatial_path.exists():
|
| 128 |
-
raise HTTPException(status_code=404, detail="Spatial data not available for this
|
| 129 |
import json as _json
|
| 130 |
with open(spatial_path) as f:
|
| 131 |
data = _json.load(f)
|
|
|
|
| 9 |
from fastapi import Depends
|
| 10 |
from app.database import Database
|
| 11 |
from app.worker import worker_loop
|
| 12 |
+
from app.eo_products import registry
|
| 13 |
from app.api.jobs import router as jobs_router, init_router as init_jobs
|
| 14 |
+
from app.api.products_api import router as products_router
|
| 15 |
from app.api.auth import router as auth_router, get_current_user
|
| 16 |
from app.advisor import get_aoi_advice
|
| 17 |
from app.models import AoiAdviceRequest
|
|
|
|
| 31 |
if OPENEO_CLIENT_ID and OPENEO_CLIENT_SECRET:
|
| 32 |
print(f"[Aperture] CDSE credentials configured (client_id={OPENEO_CLIENT_ID[:8]}...)")
|
| 33 |
else:
|
| 34 |
+
print("[Aperture] WARNING: CDSE credentials NOT configured β EO products will fail")
|
| 35 |
from app.config import ANTHROPIC_API_KEY
|
| 36 |
if ANTHROPIC_API_KEY:
|
| 37 |
print("[Aperture] Anthropic API key configured (AOI advisor enabled)")
|
|
|
|
| 60 |
|
| 61 |
init_jobs(db)
|
| 62 |
app.include_router(jobs_router)
|
| 63 |
+
app.include_router(products_router)
|
| 64 |
app.include_router(auth_router)
|
| 65 |
|
| 66 |
@app.post("/api/aoi-advice")
|
|
|
|
| 109 |
filename=f"aperture_package_{job_id}.zip",
|
| 110 |
)
|
| 111 |
|
| 112 |
+
@app.get("/api/jobs/{job_id}/maps/{product_id}")
|
| 113 |
+
async def get_product_map(job_id: str, product_id: str, email: str = Depends(get_current_user)):
|
| 114 |
await _verify_job_access(job_id, email)
|
| 115 |
+
map_path = _HERE.parent / "results" / job_id / f"{product_id}_map.png"
|
| 116 |
if not map_path.exists():
|
| 117 |
+
raise HTTPException(status_code=404, detail="Map not available for this EO product")
|
| 118 |
return FileResponse(
|
| 119 |
path=str(map_path),
|
| 120 |
media_type="image/png",
|
| 121 |
)
|
| 122 |
|
| 123 |
+
@app.get("/api/jobs/{job_id}/spatial/{product_id}")
|
| 124 |
+
async def get_product_spatial(job_id: str, product_id: str, email: str = Depends(get_current_user)):
|
| 125 |
await _verify_job_access(job_id, email)
|
| 126 |
+
spatial_path = _HERE.parent / "results" / job_id / f"{product_id}_spatial.json"
|
| 127 |
if not spatial_path.exists():
|
| 128 |
+
raise HTTPException(status_code=404, detail="Spatial data not available for this EO product")
|
| 129 |
import json as _json
|
| 130 |
with open(spatial_path) as f:
|
| 131 |
data = _json.load(f)
|
app/models.py
CHANGED
|
@@ -91,7 +91,7 @@ class TimeRange(BaseModel):
|
|
| 91 |
class JobRequest(BaseModel):
|
| 92 |
aoi: AOI
|
| 93 |
time_range: TimeRange = Field(default_factory=TimeRange)
|
| 94 |
-
|
| 95 |
email: str
|
| 96 |
season_start: int = Field(default=1, ge=1, le=12)
|
| 97 |
season_end: int = Field(default=12, ge=1, le=12)
|
|
@@ -107,16 +107,16 @@ class JobRequest(BaseModel):
|
|
| 107 |
else:
|
| 108 |
return list(range(self.season_start, 13)) + list(range(1, self.season_end + 1))
|
| 109 |
|
| 110 |
-
@field_validator("
|
| 111 |
@classmethod
|
| 112 |
-
def
|
| 113 |
if len(v) == 0:
|
| 114 |
-
raise ValueError("At least one
|
| 115 |
return v
|
| 116 |
|
| 117 |
|
| 118 |
-
class
|
| 119 |
-
|
| 120 |
headline: str
|
| 121 |
status: StatusLevel
|
| 122 |
trend: TrendDirection
|
|
@@ -140,11 +140,11 @@ class Job(BaseModel):
|
|
| 140 |
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 141 |
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 142 |
progress: dict[str, str] = Field(default_factory=dict)
|
| 143 |
-
results: list[
|
| 144 |
error: str | None = None
|
| 145 |
|
| 146 |
|
| 147 |
-
class
|
| 148 |
id: str
|
| 149 |
name: str
|
| 150 |
category: str
|
|
|
|
| 91 |
class JobRequest(BaseModel):
|
| 92 |
aoi: AOI
|
| 93 |
time_range: TimeRange = Field(default_factory=TimeRange)
|
| 94 |
+
product_ids: list[str]
|
| 95 |
email: str
|
| 96 |
season_start: int = Field(default=1, ge=1, le=12)
|
| 97 |
season_end: int = Field(default=12, ge=1, le=12)
|
|
|
|
| 107 |
else:
|
| 108 |
return list(range(self.season_start, 13)) + list(range(1, self.season_end + 1))
|
| 109 |
|
| 110 |
+
@field_validator("product_ids")
|
| 111 |
@classmethod
|
| 112 |
+
def require_at_least_one_product(cls, v: list[str]) -> list[str]:
|
| 113 |
if len(v) == 0:
|
| 114 |
+
raise ValueError("At least one EO product must be selected")
|
| 115 |
return v
|
| 116 |
|
| 117 |
|
| 118 |
+
class ProductResult(BaseModel):
|
| 119 |
+
product_id: str
|
| 120 |
headline: str
|
| 121 |
status: StatusLevel
|
| 122 |
trend: TrendDirection
|
|
|
|
| 140 |
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 141 |
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 142 |
progress: dict[str, str] = Field(default_factory=dict)
|
| 143 |
+
results: list[ProductResult] = Field(default_factory=list)
|
| 144 |
error: str | None = None
|
| 145 |
|
| 146 |
|
| 147 |
+
class ProductMeta(BaseModel):
|
| 148 |
id: str
|
| 149 |
name: str
|
| 150 |
category: str
|
app/outputs/charts.py
CHANGED
|
@@ -32,7 +32,7 @@ TREND_ARROWS = {
|
|
| 32 |
def render_timeseries_chart(
|
| 33 |
*,
|
| 34 |
chart_data: dict[str, Any],
|
| 35 |
-
|
| 36 |
status: StatusLevel,
|
| 37 |
trend: TrendDirection,
|
| 38 |
output_path: str,
|
|
@@ -45,7 +45,7 @@ def render_timeseries_chart(
|
|
| 45 |
chart_data:
|
| 46 |
Dict with keys ``"dates"`` (list of ISO strings ``YYYY-MM``) and
|
| 47 |
``"values"`` (list of numeric values).
|
| 48 |
-
|
| 49 |
Human-readable indicator name.
|
| 50 |
status:
|
| 51 |
Traffic-light status β drives the line/fill colour.
|
|
@@ -67,7 +67,7 @@ def render_timeseries_chart(
|
|
| 67 |
"SAR Backscatter": "VV backscatter (dB)",
|
| 68 |
"Settlement Extent": "Built-up area (%)",
|
| 69 |
}
|
| 70 |
-
y_label = _default_labels.get(
|
| 71 |
|
| 72 |
status_color = STATUS_COLORS[status]
|
| 73 |
arrow = TREND_ARROWS[trend]
|
|
@@ -184,7 +184,7 @@ def render_timeseries_chart(
|
|
| 184 |
|
| 185 |
# Title with trend arrow
|
| 186 |
ax.set_title(
|
| 187 |
-
f"{
|
| 188 |
fontsize=12, fontweight="bold", color=INK, pad=10,
|
| 189 |
)
|
| 190 |
|
|
|
|
| 32 |
def render_timeseries_chart(
|
| 33 |
*,
|
| 34 |
chart_data: dict[str, Any],
|
| 35 |
+
product_name: str,
|
| 36 |
status: StatusLevel,
|
| 37 |
trend: TrendDirection,
|
| 38 |
output_path: str,
|
|
|
|
| 45 |
chart_data:
|
| 46 |
Dict with keys ``"dates"`` (list of ISO strings ``YYYY-MM``) and
|
| 47 |
``"values"`` (list of numeric values).
|
| 48 |
+
product_name:
|
| 49 |
Human-readable indicator name.
|
| 50 |
status:
|
| 51 |
Traffic-light status β drives the line/fill colour.
|
|
|
|
| 67 |
"SAR Backscatter": "VV backscatter (dB)",
|
| 68 |
"Settlement Extent": "Built-up area (%)",
|
| 69 |
}
|
| 70 |
+
y_label = _default_labels.get(product_name, "")
|
| 71 |
|
| 72 |
status_color = STATUS_COLORS[status]
|
| 73 |
arrow = TREND_ARROWS[trend]
|
|
|
|
| 184 |
|
| 185 |
# Title with trend arrow
|
| 186 |
ax.set_title(
|
| 187 |
+
f"{product_name} {arrow}",
|
| 188 |
fontsize=12, fontweight="bold", color=INK, pad=10,
|
| 189 |
)
|
| 190 |
|
app/outputs/maps.py
CHANGED
|
@@ -8,7 +8,7 @@ import matplotlib.patches as mpatches
|
|
| 8 |
import matplotlib.colors as mcolors
|
| 9 |
import numpy as np
|
| 10 |
|
| 11 |
-
from app.
|
| 12 |
from app.models import AOI, StatusLevel
|
| 13 |
|
| 14 |
# MERLx palette
|
|
|
|
| 8 |
import matplotlib.colors as mcolors
|
| 9 |
import numpy as np
|
| 10 |
|
| 11 |
+
from app.eo_products.base import SpatialData
|
| 12 |
from app.models import AOI, StatusLevel
|
| 13 |
|
| 14 |
# MERLx palette
|
app/outputs/narrative.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
|
| 4 |
from typing import Sequence
|
| 5 |
|
| 6 |
-
from app.models import
|
| 7 |
|
| 8 |
|
| 9 |
# --- Per-indicator interpretation templates ---
|
|
@@ -39,24 +39,24 @@ _CROSS_PATTERNS: list[tuple[dict[str, set[StatusLevel]], str]] = [
|
|
| 39 |
]
|
| 40 |
|
| 41 |
_LEAD_TEMPLATES = {
|
| 42 |
-
StatusLevel.RED: "The situation shows critical concern across one or more
|
| 43 |
StatusLevel.AMBER: "The situation shows elevated concern requiring monitoring.",
|
| 44 |
-
StatusLevel.GREEN: "All
|
| 45 |
}
|
| 46 |
|
| 47 |
|
| 48 |
-
def get_interpretation(
|
| 49 |
-
"""Return a 1-2 sentence interpretation for the given
|
| 50 |
return _INTERPRETATIONS.get(
|
| 51 |
-
(
|
| 52 |
-
f"{
|
| 53 |
)
|
| 54 |
|
| 55 |
|
| 56 |
-
def generate_narrative(results: Sequence[
|
| 57 |
"""Generate a cross-indicator narrative paragraph from indicator results."""
|
| 58 |
if not results:
|
| 59 |
-
return "No
|
| 60 |
|
| 61 |
parts: list[str] = []
|
| 62 |
|
|
@@ -72,7 +72,7 @@ def generate_narrative(results: Sequence[IndicatorResult]) -> str:
|
|
| 72 |
parts.append(f"{r.headline}.")
|
| 73 |
|
| 74 |
# 3. Cross-indicator connection
|
| 75 |
-
result_map = {r.
|
| 76 |
for required, sentence in _CROSS_PATTERNS:
|
| 77 |
if all(
|
| 78 |
ind_id in result_map and result_map[ind_id] in allowed_statuses
|
|
@@ -94,11 +94,11 @@ def generate_compound_signals_text(signals: list) -> str:
|
|
| 94 |
Returns text describing triggered compound signals.
|
| 95 |
"""
|
| 96 |
if not signals:
|
| 97 |
-
return "No compound signals detected across the
|
| 98 |
|
| 99 |
triggered = [s for s in signals if s.triggered]
|
| 100 |
if not triggered:
|
| 101 |
-
return "No compound signals detected across the
|
| 102 |
|
| 103 |
parts = []
|
| 104 |
for s in triggered:
|
|
|
|
| 3 |
|
| 4 |
from typing import Sequence
|
| 5 |
|
| 6 |
+
from app.models import ProductResult, StatusLevel, TrendDirection
|
| 7 |
|
| 8 |
|
| 9 |
# --- Per-indicator interpretation templates ---
|
|
|
|
| 39 |
]
|
| 40 |
|
| 41 |
_LEAD_TEMPLATES = {
|
| 42 |
+
StatusLevel.RED: "The situation shows critical concern across one or more EO products.",
|
| 43 |
StatusLevel.AMBER: "The situation shows elevated concern requiring monitoring.",
|
| 44 |
+
StatusLevel.GREEN: "All EO products are within normal ranges for this area and period.",
|
| 45 |
}
|
| 46 |
|
| 47 |
|
| 48 |
+
def get_interpretation(product_id: str, status: StatusLevel) -> str:
|
| 49 |
+
"""Return a 1-2 sentence interpretation for the given EO product and status."""
|
| 50 |
return _INTERPRETATIONS.get(
|
| 51 |
+
(product_id, status),
|
| 52 |
+
f"{product_id.replace('_', ' ').title()} status is {status.value}.",
|
| 53 |
)
|
| 54 |
|
| 55 |
|
| 56 |
+
def generate_narrative(results: Sequence[ProductResult]) -> str:
|
| 57 |
"""Generate a cross-indicator narrative paragraph from indicator results."""
|
| 58 |
if not results:
|
| 59 |
+
return "No EO product data available for narrative generation."
|
| 60 |
|
| 61 |
parts: list[str] = []
|
| 62 |
|
|
|
|
| 72 |
parts.append(f"{r.headline}.")
|
| 73 |
|
| 74 |
# 3. Cross-indicator connection
|
| 75 |
+
result_map = {r.product_id: r.status for r in results}
|
| 76 |
for required, sentence in _CROSS_PATTERNS:
|
| 77 |
if all(
|
| 78 |
ind_id in result_map and result_map[ind_id] in allowed_statuses
|
|
|
|
| 94 |
Returns text describing triggered compound signals.
|
| 95 |
"""
|
| 96 |
if not signals:
|
| 97 |
+
return "No compound signals detected across the EO product set."
|
| 98 |
|
| 99 |
triggered = [s for s in signals if s.triggered]
|
| 100 |
if not triggered:
|
| 101 |
+
return "No compound signals detected across the EO product set."
|
| 102 |
|
| 103 |
parts = []
|
| 104 |
for s in triggered:
|
app/outputs/overview.py
CHANGED
|
@@ -5,7 +5,7 @@ import json
|
|
| 5 |
from typing import Any, Sequence
|
| 6 |
|
| 7 |
from app.config import OVERVIEW_WEIGHTS
|
| 8 |
-
from app.models import
|
| 9 |
|
| 10 |
_STATUS_SCORES = {
|
| 11 |
StatusLevel.GREEN: 100,
|
|
@@ -14,7 +14,7 @@ _STATUS_SCORES = {
|
|
| 14 |
}
|
| 15 |
|
| 16 |
# Display names for headline generation
|
| 17 |
-
|
| 18 |
"ndvi": "vegetation decline",
|
| 19 |
"sar": "SAR ground change",
|
| 20 |
"water": "water extent change",
|
|
@@ -22,27 +22,27 @@ _INDICATOR_NAMES = {
|
|
| 22 |
}
|
| 23 |
|
| 24 |
|
| 25 |
-
def compute_composite_score(results: Sequence[
|
| 26 |
-
"""Compute weighted composite score from
|
| 27 |
|
| 28 |
Returns a dict with: score (0-100), status (GREEN/AMBER/RED),
|
| 29 |
-
headline, weights_used,
|
| 30 |
"""
|
| 31 |
if not results:
|
| 32 |
return {
|
| 33 |
"score": 0,
|
| 34 |
"status": "RED",
|
| 35 |
-
"headline": "Area conditions: RED (score 0/100) β no
|
| 36 |
"weights_used": {},
|
| 37 |
-
"
|
| 38 |
}
|
| 39 |
|
| 40 |
-
# Gather scores for completed
|
| 41 |
-
|
| 42 |
active_weights: dict[str, float] = {}
|
| 43 |
|
| 44 |
for result in results:
|
| 45 |
-
ind_id = result.
|
| 46 |
weight = OVERVIEW_WEIGHTS.get(ind_id, 0.05)
|
| 47 |
ind_score = _STATUS_SCORES.get(result.status, 50)
|
| 48 |
|
|
@@ -50,7 +50,7 @@ def compute_composite_score(results: Sequence[IndicatorResult]) -> dict[str, Any
|
|
| 50 |
anomaly_penalty = min(result.anomaly_months * 3, 20)
|
| 51 |
ind_score = max(ind_score - anomaly_penalty, 0)
|
| 52 |
|
| 53 |
-
|
| 54 |
"status": result.status.value.upper(),
|
| 55 |
"score": ind_score,
|
| 56 |
"anomaly_months": result.anomaly_months,
|
|
@@ -65,7 +65,7 @@ def compute_composite_score(results: Sequence[IndicatorResult]) -> dict[str, Any
|
|
| 65 |
|
| 66 |
# Weighted average
|
| 67 |
composite = sum(
|
| 68 |
-
norm_weights[ind_id] *
|
| 69 |
for ind_id in norm_weights
|
| 70 |
)
|
| 71 |
composite = round(composite)
|
|
@@ -79,36 +79,36 @@ def compute_composite_score(results: Sequence[IndicatorResult]) -> dict[str, Any
|
|
| 79 |
status = "RED"
|
| 80 |
|
| 81 |
# Headline: identify top 1-2 drivers of concern
|
| 82 |
-
headline = _build_headline(composite, status,
|
| 83 |
|
| 84 |
return {
|
| 85 |
"score": composite,
|
| 86 |
"status": status,
|
| 87 |
"headline": headline,
|
| 88 |
"weights_used": {k: round(v, 3) for k, v in norm_weights.items()},
|
| 89 |
-
"
|
| 90 |
}
|
| 91 |
|
| 92 |
|
| 93 |
def _build_headline(
|
| 94 |
score: int, status: str,
|
| 95 |
-
|
| 96 |
) -> str:
|
| 97 |
"""Build a human-readable headline identifying concern drivers."""
|
| 98 |
if status == "GREEN":
|
| 99 |
-
return f"Area conditions: GREEN (score {score}/100) β stable across all
|
| 100 |
|
| 101 |
-
# Find
|
| 102 |
impacts = []
|
| 103 |
-
for ind_id, data in
|
| 104 |
if data["score"] < 100:
|
| 105 |
impact = weights.get(ind_id, 0) * (100 - data["score"])
|
| 106 |
-
name =
|
| 107 |
impacts.append((impact, name))
|
| 108 |
|
| 109 |
impacts.sort(reverse=True)
|
| 110 |
drivers = [name for _, name in impacts[:2]]
|
| 111 |
-
driver_str = " and ".join(drivers) if drivers else "multiple
|
| 112 |
|
| 113 |
return f"Area conditions: {status} (score {score}/100) β {driver_str} drive concern"
|
| 114 |
|
|
|
|
| 5 |
from typing import Any, Sequence
|
| 6 |
|
| 7 |
from app.config import OVERVIEW_WEIGHTS
|
| 8 |
+
from app.models import ProductResult, StatusLevel
|
| 9 |
|
| 10 |
_STATUS_SCORES = {
|
| 11 |
StatusLevel.GREEN: 100,
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
# Display names for headline generation
|
| 17 |
+
_PRODUCT_NAMES = {
|
| 18 |
"ndvi": "vegetation decline",
|
| 19 |
"sar": "SAR ground change",
|
| 20 |
"water": "water extent change",
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
|
| 25 |
+
def compute_composite_score(results: Sequence[ProductResult]) -> dict[str, Any]:
|
| 26 |
+
"""Compute weighted composite score from EO product results.
|
| 27 |
|
| 28 |
Returns a dict with: score (0-100), status (GREEN/AMBER/RED),
|
| 29 |
+
headline, weights_used, per_product breakdown.
|
| 30 |
"""
|
| 31 |
if not results:
|
| 32 |
return {
|
| 33 |
"score": 0,
|
| 34 |
"status": "RED",
|
| 35 |
+
"headline": "Area conditions: RED (score 0/100) β no EO products available",
|
| 36 |
"weights_used": {},
|
| 37 |
+
"per_product": {},
|
| 38 |
}
|
| 39 |
|
| 40 |
+
# Gather scores for completed EO products
|
| 41 |
+
per_product: dict[str, dict] = {}
|
| 42 |
active_weights: dict[str, float] = {}
|
| 43 |
|
| 44 |
for result in results:
|
| 45 |
+
ind_id = result.product_id
|
| 46 |
weight = OVERVIEW_WEIGHTS.get(ind_id, 0.05)
|
| 47 |
ind_score = _STATUS_SCORES.get(result.status, 50)
|
| 48 |
|
|
|
|
| 50 |
anomaly_penalty = min(result.anomaly_months * 3, 20)
|
| 51 |
ind_score = max(ind_score - anomaly_penalty, 0)
|
| 52 |
|
| 53 |
+
per_product[ind_id] = {
|
| 54 |
"status": result.status.value.upper(),
|
| 55 |
"score": ind_score,
|
| 56 |
"anomaly_months": result.anomaly_months,
|
|
|
|
| 65 |
|
| 66 |
# Weighted average
|
| 67 |
composite = sum(
|
| 68 |
+
norm_weights[ind_id] * per_product[ind_id]["score"]
|
| 69 |
for ind_id in norm_weights
|
| 70 |
)
|
| 71 |
composite = round(composite)
|
|
|
|
| 79 |
status = "RED"
|
| 80 |
|
| 81 |
# Headline: identify top 1-2 drivers of concern
|
| 82 |
+
headline = _build_headline(composite, status, per_product, norm_weights)
|
| 83 |
|
| 84 |
return {
|
| 85 |
"score": composite,
|
| 86 |
"status": status,
|
| 87 |
"headline": headline,
|
| 88 |
"weights_used": {k: round(v, 3) for k, v in norm_weights.items()},
|
| 89 |
+
"per_product": per_product,
|
| 90 |
}
|
| 91 |
|
| 92 |
|
| 93 |
def _build_headline(
|
| 94 |
score: int, status: str,
|
| 95 |
+
per_product: dict, weights: dict,
|
| 96 |
) -> str:
|
| 97 |
"""Build a human-readable headline identifying concern drivers."""
|
| 98 |
if status == "GREEN":
|
| 99 |
+
return f"Area conditions: GREEN (score {score}/100) β stable across all EO products"
|
| 100 |
|
| 101 |
+
# Find EO products contributing most to non-GREEN score
|
| 102 |
impacts = []
|
| 103 |
+
for ind_id, data in per_product.items():
|
| 104 |
if data["score"] < 100:
|
| 105 |
impact = weights.get(ind_id, 0) * (100 - data["score"])
|
| 106 |
+
name = _PRODUCT_NAMES.get(ind_id, ind_id)
|
| 107 |
impacts.append((impact, name))
|
| 108 |
|
| 109 |
impacts.sort(reverse=True)
|
| 110 |
drivers = [name for _, name in impacts[:2]]
|
| 111 |
+
driver_str = " and ".join(drivers) if drivers else "multiple EO products"
|
| 112 |
|
| 113 |
return f"Area conditions: {status} (score {score}/100) β {driver_str} drive concern"
|
| 114 |
|
app/outputs/report.py
CHANGED
|
@@ -22,7 +22,7 @@ from reportlab.platypus import (
|
|
| 22 |
)
|
| 23 |
from reportlab.platypus.flowables import KeepTogether
|
| 24 |
|
| 25 |
-
from app.models import AOI, TimeRange,
|
| 26 |
|
| 27 |
# Display names for indicator IDs that don't title-case correctly
|
| 28 |
_DISPLAY_NAMES: dict[str, str] = {
|
|
@@ -31,8 +31,8 @@ _DISPLAY_NAMES: dict[str, str] = {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
|
| 34 |
-
def
|
| 35 |
-
return _DISPLAY_NAMES.get(
|
| 36 |
|
| 37 |
|
| 38 |
# MERLx palette (as reportlab Color objects)
|
|
@@ -158,8 +158,8 @@ def _status_badge_table(status: StatusLevel, styles: dict) -> Table:
|
|
| 158 |
return t
|
| 159 |
|
| 160 |
|
| 161 |
-
def
|
| 162 |
-
result:
|
| 163 |
styles: dict,
|
| 164 |
map_path: str = "",
|
| 165 |
chart_path: str = "",
|
|
@@ -232,7 +232,7 @@ def _indicator_block(
|
|
| 232 |
elements.append(Paragraph(result.summary, styles["body"]))
|
| 233 |
|
| 234 |
# What this means
|
| 235 |
-
interpretation = get_interpretation(result.
|
| 236 |
elements.append(Paragraph("<b>What this means</b>", styles["body_muted"]))
|
| 237 |
elements.append(Paragraph(interpretation, styles["body"]))
|
| 238 |
|
|
@@ -253,7 +253,7 @@ def _indicator_block(
|
|
| 253 |
return elements
|
| 254 |
|
| 255 |
|
| 256 |
-
def _detect_data_sources(results: Sequence[
|
| 257 |
"""Scan methodology text to auto-detect known satellite data sources."""
|
| 258 |
known_sources = ["Sentinel-2", "Sentinel-1", "FIRMS", "CHIRPS", "MODIS", "Landsat", "VIIRS"]
|
| 259 |
found: list[str] = []
|
|
@@ -270,11 +270,11 @@ def generate_pdf_report(
|
|
| 270 |
*,
|
| 271 |
aoi: AOI,
|
| 272 |
time_range: TimeRange,
|
| 273 |
-
results: Sequence[
|
| 274 |
output_path: str,
|
| 275 |
summary_map_path: str = "",
|
| 276 |
-
|
| 277 |
-
|
| 278 |
overview_score: dict | None = None,
|
| 279 |
overview_map_path: str = "",
|
| 280 |
compound_signals: list | None = None,
|
|
@@ -419,7 +419,7 @@ def generate_pdf_report(
|
|
| 419 |
green_count = sum(1 for r in results if r.status == StatusLevel.GREEN)
|
| 420 |
total = len(results)
|
| 421 |
count_line = (
|
| 422 |
-
f"This report covers <b>{total}</b>
|
| 423 |
f"over the period {time_range.start} to {time_range.end}. "
|
| 424 |
f"<b><font color='{_RED_HEX}'>{red_count}</font></b> at RED, "
|
| 425 |
f"<b><font color='{_AMBER_HEX}'>{amber_count}</font></b> at AMBER, "
|
|
@@ -433,7 +433,7 @@ def generate_pdf_report(
|
|
| 433 |
|
| 434 |
# Compact summary table
|
| 435 |
summary_header = [
|
| 436 |
-
Paragraph("<b>
|
| 437 |
Paragraph("<b>Status</b>", styles["body"]),
|
| 438 |
Paragraph("<b>Trend</b>", styles["body"]),
|
| 439 |
Paragraph("<b>Confidence</b>", styles["body"]),
|
|
@@ -442,7 +442,7 @@ def generate_pdf_report(
|
|
| 442 |
]
|
| 443 |
summary_rows = [summary_header]
|
| 444 |
for result in results:
|
| 445 |
-
label =
|
| 446 |
status_cell = Paragraph(
|
| 447 |
f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
|
| 448 |
ParagraphStyle(
|
|
@@ -507,7 +507,7 @@ def generate_pdf_report(
|
|
| 507 |
else:
|
| 508 |
story.append(Spacer(1, 2 * mm))
|
| 509 |
story.append(Paragraph(
|
| 510 |
-
"No compound signals detected across the
|
| 511 |
styles["body_muted"],
|
| 512 |
))
|
| 513 |
|
|
@@ -518,21 +518,21 @@ def generate_pdf_report(
|
|
| 518 |
# ================================================================== #
|
| 519 |
# SECTION 3: Indicator Deep Dives #
|
| 520 |
# ================================================================== #
|
| 521 |
-
story.append(Paragraph("
|
| 522 |
story.append(Spacer(1, 2 * mm))
|
| 523 |
|
| 524 |
for result in results:
|
| 525 |
-
|
| 526 |
-
map_path = (
|
| 527 |
-
hotspot_path = (
|
| 528 |
|
| 529 |
# Auto-detect chart path from output directory
|
| 530 |
-
chart_path = os.path.join(output_dir, f"{result.
|
| 531 |
if not os.path.exists(chart_path):
|
| 532 |
chart_path = ""
|
| 533 |
|
| 534 |
-
block = [Paragraph(
|
| 535 |
-
block +=
|
| 536 |
story.append(KeepTogether(block))
|
| 537 |
|
| 538 |
# ================================================================== #
|
|
@@ -544,10 +544,10 @@ def generate_pdf_report(
|
|
| 544 |
# Methodology subsection
|
| 545 |
story.append(Paragraph("Methodology", styles["section_heading"]))
|
| 546 |
for result in results:
|
| 547 |
-
|
| 548 |
story.append(
|
| 549 |
Paragraph(
|
| 550 |
-
f"<b>{
|
| 551 |
styles["body"],
|
| 552 |
)
|
| 553 |
)
|
|
@@ -558,7 +558,7 @@ def generate_pdf_report(
|
|
| 558 |
story.append(Spacer(1, 2 * mm))
|
| 559 |
|
| 560 |
conf_header = [
|
| 561 |
-
Paragraph("<b>
|
| 562 |
Paragraph("<b>Temporal</b>", styles["body"]),
|
| 563 |
Paragraph("<b>Baseline Depth</b>", styles["body"]),
|
| 564 |
Paragraph("<b>Spatial Compl.</b>", styles["body"]),
|
|
@@ -569,7 +569,7 @@ def generate_pdf_report(
|
|
| 569 |
f = result.confidence_factors
|
| 570 |
if f:
|
| 571 |
conf_rows.append([
|
| 572 |
-
Paragraph(
|
| 573 |
Paragraph(f"{f.get('temporal', 0):.2f}", styles["body_muted"]),
|
| 574 |
Paragraph(f"{f.get('baseline_depth', 0):.2f}", styles["body_muted"]),
|
| 575 |
Paragraph(f"{f.get('spatial_completeness', 0):.2f}", styles["body_muted"]),
|
|
@@ -616,7 +616,7 @@ def generate_pdf_report(
|
|
| 616 |
"remote sensing data. Results are intended to support humanitarian situation analysis "
|
| 617 |
"and should be interpreted alongside ground-truth information and expert judgement. "
|
| 618 |
"Temporal coverage, cloud contamination, and sensor resolution may affect the "
|
| 619 |
-
"reliability of individual
|
| 620 |
)
|
| 621 |
story.append(Paragraph("Disclaimer", styles["section_heading"]))
|
| 622 |
story.append(Paragraph(disclaimer, styles["body_muted"]))
|
|
|
|
| 22 |
)
|
| 23 |
from reportlab.platypus.flowables import KeepTogether
|
| 24 |
|
| 25 |
+
from app.models import AOI, TimeRange, ProductResult, StatusLevel
|
| 26 |
|
| 27 |
# Display names for indicator IDs that don't title-case correctly
|
| 28 |
_DISPLAY_NAMES: dict[str, str] = {
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
|
| 34 |
+
def _product_label(product_id: str) -> str:
|
| 35 |
+
return _DISPLAY_NAMES.get(product_id, product_id.replace("_", " ").title())
|
| 36 |
|
| 37 |
|
| 38 |
# MERLx palette (as reportlab Color objects)
|
|
|
|
| 158 |
return t
|
| 159 |
|
| 160 |
|
| 161 |
+
def _product_block(
|
| 162 |
+
result: ProductResult,
|
| 163 |
styles: dict,
|
| 164 |
map_path: str = "",
|
| 165 |
chart_path: str = "",
|
|
|
|
| 232 |
elements.append(Paragraph(result.summary, styles["body"]))
|
| 233 |
|
| 234 |
# What this means
|
| 235 |
+
interpretation = get_interpretation(result.product_id, result.status)
|
| 236 |
elements.append(Paragraph("<b>What this means</b>", styles["body_muted"]))
|
| 237 |
elements.append(Paragraph(interpretation, styles["body"]))
|
| 238 |
|
|
|
|
| 253 |
return elements
|
| 254 |
|
| 255 |
|
| 256 |
+
def _detect_data_sources(results: Sequence[ProductResult]) -> str:
|
| 257 |
"""Scan methodology text to auto-detect known satellite data sources."""
|
| 258 |
known_sources = ["Sentinel-2", "Sentinel-1", "FIRMS", "CHIRPS", "MODIS", "Landsat", "VIIRS"]
|
| 259 |
found: list[str] = []
|
|
|
|
| 270 |
*,
|
| 271 |
aoi: AOI,
|
| 272 |
time_range: TimeRange,
|
| 273 |
+
results: Sequence[ProductResult],
|
| 274 |
output_path: str,
|
| 275 |
summary_map_path: str = "",
|
| 276 |
+
product_map_paths: dict[str, str] | None = None,
|
| 277 |
+
product_hotspot_paths: dict[str, str] | None = None,
|
| 278 |
overview_score: dict | None = None,
|
| 279 |
overview_map_path: str = "",
|
| 280 |
compound_signals: list | None = None,
|
|
|
|
| 419 |
green_count = sum(1 for r in results if r.status == StatusLevel.GREEN)
|
| 420 |
total = len(results)
|
| 421 |
count_line = (
|
| 422 |
+
f"This report covers <b>{total}</b> EO product(s) for <b>{aoi.name}</b> "
|
| 423 |
f"over the period {time_range.start} to {time_range.end}. "
|
| 424 |
f"<b><font color='{_RED_HEX}'>{red_count}</font></b> at RED, "
|
| 425 |
f"<b><font color='{_AMBER_HEX}'>{amber_count}</font></b> at AMBER, "
|
|
|
|
| 433 |
|
| 434 |
# Compact summary table
|
| 435 |
summary_header = [
|
| 436 |
+
Paragraph("<b>EO Product</b>", styles["body"]),
|
| 437 |
Paragraph("<b>Status</b>", styles["body"]),
|
| 438 |
Paragraph("<b>Trend</b>", styles["body"]),
|
| 439 |
Paragraph("<b>Confidence</b>", styles["body"]),
|
|
|
|
| 442 |
]
|
| 443 |
summary_rows = [summary_header]
|
| 444 |
for result in results:
|
| 445 |
+
label = _product_label(result.product_id)
|
| 446 |
status_cell = Paragraph(
|
| 447 |
f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
|
| 448 |
ParagraphStyle(
|
|
|
|
| 507 |
else:
|
| 508 |
story.append(Spacer(1, 2 * mm))
|
| 509 |
story.append(Paragraph(
|
| 510 |
+
"No compound signals detected across the EO product set.",
|
| 511 |
styles["body_muted"],
|
| 512 |
))
|
| 513 |
|
|
|
|
| 518 |
# ================================================================== #
|
| 519 |
# SECTION 3: Indicator Deep Dives #
|
| 520 |
# ================================================================== #
|
| 521 |
+
story.append(Paragraph("EO Product Detail", styles["section_heading"]))
|
| 522 |
story.append(Spacer(1, 2 * mm))
|
| 523 |
|
| 524 |
for result in results:
|
| 525 |
+
product_label = _product_label(result.product_id)
|
| 526 |
+
map_path = (product_map_paths or {}).get(result.product_id, "")
|
| 527 |
+
hotspot_path = (product_hotspot_paths or {}).get(result.product_id, "")
|
| 528 |
|
| 529 |
# Auto-detect chart path from output directory
|
| 530 |
+
chart_path = os.path.join(output_dir, f"{result.product_id}_chart.png")
|
| 531 |
if not os.path.exists(chart_path):
|
| 532 |
chart_path = ""
|
| 533 |
|
| 534 |
+
block = [Paragraph(product_label, styles["section_heading"])]
|
| 535 |
+
block += _product_block(result, styles, map_path=map_path, chart_path=chart_path, hotspot_path=hotspot_path)
|
| 536 |
story.append(KeepTogether(block))
|
| 537 |
|
| 538 |
# ================================================================== #
|
|
|
|
| 544 |
# Methodology subsection
|
| 545 |
story.append(Paragraph("Methodology", styles["section_heading"]))
|
| 546 |
for result in results:
|
| 547 |
+
product_label = _product_label(result.product_id)
|
| 548 |
story.append(
|
| 549 |
Paragraph(
|
| 550 |
+
f"<b>{product_label}:</b> {result.methodology}",
|
| 551 |
styles["body"],
|
| 552 |
)
|
| 553 |
)
|
|
|
|
| 558 |
story.append(Spacer(1, 2 * mm))
|
| 559 |
|
| 560 |
conf_header = [
|
| 561 |
+
Paragraph("<b>EO Product</b>", styles["body"]),
|
| 562 |
Paragraph("<b>Temporal</b>", styles["body"]),
|
| 563 |
Paragraph("<b>Baseline Depth</b>", styles["body"]),
|
| 564 |
Paragraph("<b>Spatial Compl.</b>", styles["body"]),
|
|
|
|
| 569 |
f = result.confidence_factors
|
| 570 |
if f:
|
| 571 |
conf_rows.append([
|
| 572 |
+
Paragraph(_product_label(result.product_id), styles["body_muted"]),
|
| 573 |
Paragraph(f"{f.get('temporal', 0):.2f}", styles["body_muted"]),
|
| 574 |
Paragraph(f"{f.get('baseline_depth', 0):.2f}", styles["body_muted"]),
|
| 575 |
Paragraph(f"{f.get('spatial_completeness', 0):.2f}", styles["body_muted"]),
|
|
|
|
| 616 |
"remote sensing data. Results are intended to support humanitarian situation analysis "
|
| 617 |
"and should be interpreted alongside ground-truth information and expert judgement. "
|
| 618 |
"Temporal coverage, cloud contamination, and sensor resolution may affect the "
|
| 619 |
+
"reliability of individual EO products."
|
| 620 |
)
|
| 621 |
story.append(Paragraph("Disclaimer", styles["section_heading"]))
|
| 622 |
story.append(Paragraph(disclaimer, styles["body_muted"]))
|
app/outputs/thresholds.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
from app.models import StatusLevel
|
| 2 |
|
| 3 |
|
| 4 |
-
def classify_indicator(
|
| 5 |
-
classifier = THRESHOLDS.get(
|
| 6 |
if classifier is None:
|
| 7 |
return StatusLevel.GREEN
|
| 8 |
return classifier(metrics)
|
|
|
|
| 1 |
from app.models import StatusLevel
|
| 2 |
|
| 3 |
|
| 4 |
+
def classify_indicator(product_id: str, metrics: dict) -> StatusLevel:
|
| 5 |
+
classifier = THRESHOLDS.get(product_id)
|
| 6 |
if classifier is None:
|
| 7 |
return StatusLevel.GREEN
|
| 8 |
return classifier(metrics)
|
app/worker.py
CHANGED
|
@@ -6,9 +6,9 @@ import os
|
|
| 6 |
import time
|
| 7 |
import traceback
|
| 8 |
from app.database import Database
|
| 9 |
-
from app.
|
| 10 |
from app.models import JobStatus
|
| 11 |
-
from app.outputs.report import generate_pdf_report,
|
| 12 |
from app.outputs.package import create_data_package
|
| 13 |
from app.outputs.charts import render_timeseries_chart
|
| 14 |
from app.outputs.maps import render_indicator_map, render_status_map
|
|
@@ -58,7 +58,7 @@ def _save_spatial_json(spatial, status_value: str, path: str) -> None:
|
|
| 58 |
json.dump(obj, f)
|
| 59 |
|
| 60 |
|
| 61 |
-
async def process_job(job_id: str, db: Database, registry:
|
| 62 |
job = await db.get_job(job_id)
|
| 63 |
if job is None:
|
| 64 |
logger.error(f"Job {job_id} not found")
|
|
@@ -67,28 +67,28 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 67 |
try:
|
| 68 |
spatial_cache = {}
|
| 69 |
|
| 70 |
-
# Separate batch vs non-batch
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
for
|
| 74 |
-
|
| 75 |
-
if
|
| 76 |
-
|
| 77 |
else:
|
| 78 |
-
|
| 79 |
|
| 80 |
-
# -- Process batch
|
| 81 |
-
for
|
| 82 |
# Submit
|
| 83 |
-
await db.update_job_progress(job_id,
|
| 84 |
-
jobs = await
|
| 85 |
job.request.aoi,
|
| 86 |
job.request.time_range,
|
| 87 |
season_months=job.request.season_months(),
|
| 88 |
)
|
| 89 |
job_ids = [getattr(j, 'job_id', '?') for j in jobs]
|
| 90 |
-
print(f"[Aperture] Submitted {
|
| 91 |
-
await db.update_job_progress(job_id,
|
| 92 |
|
| 93 |
# Poll β exit early once first job finishes + grace period for others
|
| 94 |
GRACE_PERIOD = 600 # 10 min grace after first job finishes
|
|
@@ -98,28 +98,28 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 98 |
elapsed = time.monotonic() - poll_start
|
| 99 |
statuses = [j.status() for j in jobs]
|
| 100 |
job_ids = [getattr(j, 'job_id', '?') for j in jobs]
|
| 101 |
-
print(f"[Aperture] Poll {
|
| 102 |
|
| 103 |
if all(s == "finished" for s in statuses):
|
| 104 |
-
logger.info("Batch jobs finished for %s",
|
| 105 |
break
|
| 106 |
elif any(s in ("error", "canceled") for s in statuses):
|
| 107 |
-
logger.warning("Batch job failed for %s: %s",
|
| 108 |
break
|
| 109 |
|
| 110 |
# Track when first job finishes
|
| 111 |
if first_finished_at is None and any(s == "finished" for s in statuses):
|
| 112 |
first_finished_at = time.monotonic()
|
| 113 |
-
print(f"[Aperture] {
|
| 114 |
|
| 115 |
# Grace period: once any job finished, give others 10 min then harvest partial
|
| 116 |
if first_finished_at and (time.monotonic() - first_finished_at) >= GRACE_PERIOD:
|
| 117 |
-
logger.info("Grace period expired for %s, harvesting partial results",
|
| 118 |
-
print(f"[Aperture] {
|
| 119 |
break
|
| 120 |
|
| 121 |
if elapsed >= BATCH_TIMEOUT:
|
| 122 |
-
logger.warning("Batch poll timeout after %.0fs for %s", elapsed,
|
| 123 |
break
|
| 124 |
|
| 125 |
await asyncio.sleep(BATCH_POLL_INTERVAL)
|
|
@@ -129,7 +129,7 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 129 |
if not any_finished:
|
| 130 |
failed_statuses = list(zip(job_ids, statuses))
|
| 131 |
raise RuntimeError(
|
| 132 |
-
f"All batch jobs failed for {
|
| 133 |
)
|
| 134 |
|
| 135 |
# Wrap non-finished jobs so download_results() fails fast
|
|
@@ -138,34 +138,34 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 138 |
j if s == "finished" else _SkippedJob(getattr(j, 'job_id', '?'))
|
| 139 |
for j, s in zip(jobs, statuses)
|
| 140 |
]
|
| 141 |
-
await db.update_job_progress(job_id,
|
| 142 |
-
result = await
|
| 143 |
job.request.aoi,
|
| 144 |
job.request.time_range,
|
| 145 |
season_months=job.request.season_months(),
|
| 146 |
batch_jobs=harvest_jobs,
|
| 147 |
)
|
| 148 |
|
| 149 |
-
spatial =
|
| 150 |
if spatial is not None:
|
| 151 |
-
spatial_cache[
|
| 152 |
-
print(f"[Aperture] Saving result for {
|
| 153 |
await db.save_job_result(job_id, result)
|
| 154 |
-
await db.update_job_progress(job_id,
|
| 155 |
|
| 156 |
-
# -- Process non-batch
|
| 157 |
-
for
|
| 158 |
-
await db.update_job_progress(job_id,
|
| 159 |
-
result = await
|
| 160 |
job.request.aoi,
|
| 161 |
job.request.time_range,
|
| 162 |
season_months=job.request.season_months(),
|
| 163 |
)
|
| 164 |
-
spatial =
|
| 165 |
if spatial is not None:
|
| 166 |
-
spatial_cache[
|
| 167 |
await db.save_job_result(job_id, result)
|
| 168 |
-
await db.update_job_progress(job_id,
|
| 169 |
|
| 170 |
# Generate outputs
|
| 171 |
job = await db.get_job(job_id)
|
|
@@ -179,7 +179,7 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 179 |
chart_path = os.path.join(results_dir, f"{result.indicator_id}_chart.png")
|
| 180 |
render_timeseries_chart(
|
| 181 |
chart_data=result.chart_data,
|
| 182 |
-
indicator_name=
|
| 183 |
status=result.status,
|
| 184 |
trend=result.trend,
|
| 185 |
output_path=chart_path,
|
|
@@ -192,10 +192,10 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 192 |
|
| 193 |
if spatial is not None and spatial.map_type == "raster":
|
| 194 |
# Raster-on-true-color rendering for openEO/download indicators
|
| 195 |
-
|
| 196 |
-
raster_path = getattr(
|
| 197 |
-
true_color_path = getattr(
|
| 198 |
-
render_band = getattr(
|
| 199 |
from app.outputs.maps import render_raster_map
|
| 200 |
render_raster_map(
|
| 201 |
true_color_path=true_color_path,
|
|
@@ -230,17 +230,17 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 230 |
|
| 231 |
# Generate hotspot maps for indicators with z-score data
|
| 232 |
from app.outputs.maps import render_hotspot_map
|
| 233 |
-
|
| 234 |
for result in job.results:
|
| 235 |
-
|
| 236 |
-
zscore_raster = getattr(
|
| 237 |
-
hotspot_mask = getattr(
|
| 238 |
-
true_color_path_ind = getattr(
|
| 239 |
|
| 240 |
if zscore_raster is not None and hotspot_mask is not None:
|
| 241 |
hotspot_path = os.path.join(results_dir, f"{result.indicator_id}_hotspot.png")
|
| 242 |
|
| 243 |
-
raster_path = getattr(
|
| 244 |
if raster_path:
|
| 245 |
import rasterio
|
| 246 |
with rasterio.open(raster_path) as src:
|
|
@@ -259,7 +259,7 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 259 |
output_path=hotspot_path,
|
| 260 |
label=result.indicator_id.upper(),
|
| 261 |
)
|
| 262 |
-
|
| 263 |
output_files.append(hotspot_path)
|
| 264 |
|
| 265 |
# Cross-indicator compound signal detection
|
|
@@ -268,8 +268,8 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 268 |
|
| 269 |
zscore_rasters = {}
|
| 270 |
for result in job.results:
|
| 271 |
-
|
| 272 |
-
z = getattr(
|
| 273 |
if z is not None:
|
| 274 |
zscore_rasters[result.indicator_id] = z
|
| 275 |
|
|
@@ -297,7 +297,7 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 297 |
)
|
| 298 |
del resampled
|
| 299 |
|
| 300 |
-
# Release z-score rasters from
|
| 301 |
del zscore_rasters
|
| 302 |
for result in job.results:
|
| 303 |
registry.get(result.indicator_id).release_rasters()
|
|
@@ -309,11 +309,11 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 309 |
output_files.append(signals_path)
|
| 310 |
|
| 311 |
# Build map paths dict for PDF
|
| 312 |
-
|
| 313 |
for result in job.results:
|
| 314 |
mp = os.path.join(results_dir, f"{result.indicator_id}_map.png")
|
| 315 |
if os.path.exists(mp):
|
| 316 |
-
|
| 317 |
|
| 318 |
# Generate summary map (worst-case status)
|
| 319 |
from app.models import StatusLevel
|
|
@@ -332,12 +332,12 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 332 |
write_overview_score(overview_score, overview_score_path)
|
| 333 |
output_files.append(overview_score_path)
|
| 334 |
|
| 335 |
-
# Overview map: reuse true-color from any raster
|
| 336 |
overview_map_path = os.path.join(results_dir, "overview_map.png")
|
| 337 |
true_color_path = None
|
| 338 |
-
for
|
| 339 |
-
|
| 340 |
-
tc = getattr(
|
| 341 |
if tc and os.path.exists(tc):
|
| 342 |
true_color_path = tc
|
| 343 |
break
|
|
@@ -360,8 +360,8 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 360 |
results=job.results,
|
| 361 |
output_path=report_path,
|
| 362 |
summary_map_path=summary_map_path,
|
| 363 |
-
|
| 364 |
-
|
| 365 |
overview_score=overview_score,
|
| 366 |
overview_map_path=overview_map_path if true_color_path else "",
|
| 367 |
compound_signals=compound_signals,
|
|
@@ -385,7 +385,7 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 385 |
await db.update_job_status(job_id, JobStatus.FAILED, error=str(e))
|
| 386 |
|
| 387 |
|
| 388 |
-
async def worker_loop(db: Database, registry:
|
| 389 |
logger.info("Background worker started")
|
| 390 |
while True:
|
| 391 |
job = await db.get_next_queued_job()
|
|
|
|
| 6 |
import time
|
| 7 |
import traceback
|
| 8 |
from app.database import Database
|
| 9 |
+
from app.eo_products.base import ProductRegistry
|
| 10 |
from app.models import JobStatus
|
| 11 |
+
from app.outputs.report import generate_pdf_report, _product_label
|
| 12 |
from app.outputs.package import create_data_package
|
| 13 |
from app.outputs.charts import render_timeseries_chart
|
| 14 |
from app.outputs.maps import render_indicator_map, render_status_map
|
|
|
|
| 58 |
json.dump(obj, f)
|
| 59 |
|
| 60 |
|
| 61 |
+
async def process_job(job_id: str, db: Database, registry: ProductRegistry) -> None:
|
| 62 |
job = await db.get_job(job_id)
|
| 63 |
if job is None:
|
| 64 |
logger.error(f"Job {job_id} not found")
|
|
|
|
| 67 |
try:
|
| 68 |
spatial_cache = {}
|
| 69 |
|
| 70 |
+
# Separate batch vs non-batch EO products
|
| 71 |
+
batch_products = {}
|
| 72 |
+
process_products = []
|
| 73 |
+
for product_id in job.request.indicator_ids:
|
| 74 |
+
product = registry.get(product_id)
|
| 75 |
+
if product.uses_batch:
|
| 76 |
+
batch_products[product_id] = product
|
| 77 |
else:
|
| 78 |
+
process_products.append((product_id, product))
|
| 79 |
|
| 80 |
+
# -- Process batch EO products sequentially --
|
| 81 |
+
for product_id, product in batch_products.items():
|
| 82 |
# Submit
|
| 83 |
+
await db.update_job_progress(job_id, product_id, "submitting")
|
| 84 |
+
jobs = await product.submit_batch(
|
| 85 |
job.request.aoi,
|
| 86 |
job.request.time_range,
|
| 87 |
season_months=job.request.season_months(),
|
| 88 |
)
|
| 89 |
job_ids = [getattr(j, 'job_id', '?') for j in jobs]
|
| 90 |
+
print(f"[Aperture] Submitted {product_id} batch jobs: {job_ids}")
|
| 91 |
+
await db.update_job_progress(job_id, product_id, "processing on CDSE")
|
| 92 |
|
| 93 |
# Poll β exit early once first job finishes + grace period for others
|
| 94 |
GRACE_PERIOD = 600 # 10 min grace after first job finishes
|
|
|
|
| 98 |
elapsed = time.monotonic() - poll_start
|
| 99 |
statuses = [j.status() for j in jobs]
|
| 100 |
job_ids = [getattr(j, 'job_id', '?') for j in jobs]
|
| 101 |
+
print(f"[Aperture] Poll {product_id} ({elapsed:.0f}s): {list(zip(job_ids, statuses))}")
|
| 102 |
|
| 103 |
if all(s == "finished" for s in statuses):
|
| 104 |
+
logger.info("Batch jobs finished for %s", product_id)
|
| 105 |
break
|
| 106 |
elif any(s in ("error", "canceled") for s in statuses):
|
| 107 |
+
logger.warning("Batch job failed for %s: %s", product_id, statuses)
|
| 108 |
break
|
| 109 |
|
| 110 |
# Track when first job finishes
|
| 111 |
if first_finished_at is None and any(s == "finished" for s in statuses):
|
| 112 |
first_finished_at = time.monotonic()
|
| 113 |
+
print(f"[Aperture] {product_id}: first job finished, {GRACE_PERIOD}s grace for remaining")
|
| 114 |
|
| 115 |
# Grace period: once any job finished, give others 10 min then harvest partial
|
| 116 |
if first_finished_at and (time.monotonic() - first_finished_at) >= GRACE_PERIOD:
|
| 117 |
+
logger.info("Grace period expired for %s, harvesting partial results", product_id)
|
| 118 |
+
print(f"[Aperture] {product_id}: grace period expired, proceeding with partial results")
|
| 119 |
break
|
| 120 |
|
| 121 |
if elapsed >= BATCH_TIMEOUT:
|
| 122 |
+
logger.warning("Batch poll timeout after %.0fs for %s", elapsed, product_id)
|
| 123 |
break
|
| 124 |
|
| 125 |
await asyncio.sleep(BATCH_POLL_INTERVAL)
|
|
|
|
| 129 |
if not any_finished:
|
| 130 |
failed_statuses = list(zip(job_ids, statuses))
|
| 131 |
raise RuntimeError(
|
| 132 |
+
f"All batch jobs failed for {product_id}: {failed_statuses}"
|
| 133 |
)
|
| 134 |
|
| 135 |
# Wrap non-finished jobs so download_results() fails fast
|
|
|
|
| 138 |
j if s == "finished" else _SkippedJob(getattr(j, 'job_id', '?'))
|
| 139 |
for j, s in zip(jobs, statuses)
|
| 140 |
]
|
| 141 |
+
await db.update_job_progress(job_id, product_id, "downloading")
|
| 142 |
+
result = await product.harvest(
|
| 143 |
job.request.aoi,
|
| 144 |
job.request.time_range,
|
| 145 |
season_months=job.request.season_months(),
|
| 146 |
batch_jobs=harvest_jobs,
|
| 147 |
)
|
| 148 |
|
| 149 |
+
spatial = product.get_spatial_data()
|
| 150 |
if spatial is not None:
|
| 151 |
+
spatial_cache[product_id] = spatial
|
| 152 |
+
print(f"[Aperture] Saving result for {product_id}: data_source={result.data_source}, headline={result.headline[:60]}")
|
| 153 |
await db.save_job_result(job_id, result)
|
| 154 |
+
await db.update_job_progress(job_id, product_id, "complete")
|
| 155 |
|
| 156 |
+
# -- Process non-batch EO products --
|
| 157 |
+
for product_id, product in process_products:
|
| 158 |
+
await db.update_job_progress(job_id, product_id, "processing")
|
| 159 |
+
result = await product.process(
|
| 160 |
job.request.aoi,
|
| 161 |
job.request.time_range,
|
| 162 |
season_months=job.request.season_months(),
|
| 163 |
)
|
| 164 |
+
spatial = product.get_spatial_data()
|
| 165 |
if spatial is not None:
|
| 166 |
+
spatial_cache[product_id] = spatial
|
| 167 |
await db.save_job_result(job_id, result)
|
| 168 |
+
await db.update_job_progress(job_id, product_id, "complete")
|
| 169 |
|
| 170 |
# Generate outputs
|
| 171 |
job = await db.get_job(job_id)
|
|
|
|
| 179 |
chart_path = os.path.join(results_dir, f"{result.indicator_id}_chart.png")
|
| 180 |
render_timeseries_chart(
|
| 181 |
chart_data=result.chart_data,
|
| 182 |
+
indicator_name=_product_label(result.indicator_id),
|
| 183 |
status=result.status,
|
| 184 |
trend=result.trend,
|
| 185 |
output_path=chart_path,
|
|
|
|
| 192 |
|
| 193 |
if spatial is not None and spatial.map_type == "raster":
|
| 194 |
# Raster-on-true-color rendering for openEO/download indicators
|
| 195 |
+
product_obj = registry.get(result.indicator_id)
|
| 196 |
+
raster_path = getattr(product_obj, '_product_raster_path', None)
|
| 197 |
+
true_color_path = getattr(product_obj, '_true_color_path', None)
|
| 198 |
+
render_band = getattr(product_obj, '_render_band', 1)
|
| 199 |
from app.outputs.maps import render_raster_map
|
| 200 |
render_raster_map(
|
| 201 |
true_color_path=true_color_path,
|
|
|
|
| 230 |
|
| 231 |
# Generate hotspot maps for indicators with z-score data
|
| 232 |
from app.outputs.maps import render_hotspot_map
|
| 233 |
+
product_hotspot_paths = {}
|
| 234 |
for result in job.results:
|
| 235 |
+
product_obj = registry.get(result.indicator_id)
|
| 236 |
+
zscore_raster = getattr(product_obj, '_zscore_raster', None)
|
| 237 |
+
hotspot_mask = getattr(product_obj, '_hotspot_mask', None)
|
| 238 |
+
true_color_path_ind = getattr(product_obj, '_true_color_path', None)
|
| 239 |
|
| 240 |
if zscore_raster is not None and hotspot_mask is not None:
|
| 241 |
hotspot_path = os.path.join(results_dir, f"{result.indicator_id}_hotspot.png")
|
| 242 |
|
| 243 |
+
raster_path = getattr(product_obj, '_product_raster_path', None)
|
| 244 |
if raster_path:
|
| 245 |
import rasterio
|
| 246 |
with rasterio.open(raster_path) as src:
|
|
|
|
| 259 |
output_path=hotspot_path,
|
| 260 |
label=result.indicator_id.upper(),
|
| 261 |
)
|
| 262 |
+
product_hotspot_paths[result.indicator_id] = hotspot_path
|
| 263 |
output_files.append(hotspot_path)
|
| 264 |
|
| 265 |
# Cross-indicator compound signal detection
|
|
|
|
| 268 |
|
| 269 |
zscore_rasters = {}
|
| 270 |
for result in job.results:
|
| 271 |
+
product_obj = registry.get(result.indicator_id)
|
| 272 |
+
z = getattr(product_obj, '_zscore_raster', None)
|
| 273 |
if z is not None:
|
| 274 |
zscore_rasters[result.indicator_id] = z
|
| 275 |
|
|
|
|
| 297 |
)
|
| 298 |
del resampled
|
| 299 |
|
| 300 |
+
# Release z-score rasters from EO product instances to free memory
|
| 301 |
del zscore_rasters
|
| 302 |
for result in job.results:
|
| 303 |
registry.get(result.indicator_id).release_rasters()
|
|
|
|
| 309 |
output_files.append(signals_path)
|
| 310 |
|
| 311 |
# Build map paths dict for PDF
|
| 312 |
+
product_map_paths = {}
|
| 313 |
for result in job.results:
|
| 314 |
mp = os.path.join(results_dir, f"{result.indicator_id}_map.png")
|
| 315 |
if os.path.exists(mp):
|
| 316 |
+
product_map_paths[result.indicator_id] = mp
|
| 317 |
|
| 318 |
# Generate summary map (worst-case status)
|
| 319 |
from app.models import StatusLevel
|
|
|
|
| 332 |
write_overview_score(overview_score, overview_score_path)
|
| 333 |
output_files.append(overview_score_path)
|
| 334 |
|
| 335 |
+
# Overview map: reuse true-color from any raster EO product, or skip
|
| 336 |
overview_map_path = os.path.join(results_dir, "overview_map.png")
|
| 337 |
true_color_path = None
|
| 338 |
+
for product_id in job.request.indicator_ids:
|
| 339 |
+
product_obj = registry.get(product_id)
|
| 340 |
+
tc = getattr(product_obj, '_true_color_path', None)
|
| 341 |
if tc and os.path.exists(tc):
|
| 342 |
true_color_path = tc
|
| 343 |
break
|
|
|
|
| 360 |
results=job.results,
|
| 361 |
output_path=report_path,
|
| 362 |
summary_map_path=summary_map_path,
|
| 363 |
+
product_map_paths=product_map_paths,
|
| 364 |
+
product_hotspot_paths=product_hotspot_paths,
|
| 365 |
overview_score=overview_score,
|
| 366 |
overview_map_path=overview_map_path if true_color_path else "",
|
| 367 |
compound_signals=compound_signals,
|
|
|
|
| 385 |
await db.update_job_status(job_id, JobStatus.FAILED, error=str(e))
|
| 386 |
|
| 387 |
|
| 388 |
+
async def worker_loop(db: Database, registry: ProductRegistry) -> None:
|
| 389 |
logger.info("Background worker started")
|
| 390 |
while True:
|
| 391 |
job = await db.get_next_queued_job()
|
frontend/index.html
CHANGED
|
@@ -41,8 +41,8 @@
|
|
| 41 |
overflow: hidden;
|
| 42 |
}
|
| 43 |
|
| 44 |
-
/*
|
| 45 |
-
#page-
|
| 46 |
height: 100vh;
|
| 47 |
flex-direction: column;
|
| 48 |
overflow: hidden;
|
|
@@ -107,7 +107,7 @@
|
|
| 107 |
|
| 108 |
<!-- Sub-copy -->
|
| 109 |
<p class="landing-sub">
|
| 110 |
-
Select your area, choose your
|
| 111 |
satellite analysis delivered β using free, open data.
|
| 112 |
</p>
|
| 113 |
|
|
@@ -179,7 +179,7 @@
|
|
| 179 |
<div class="steps">
|
| 180 |
<span class="step-dot" data-step-page="define-area"></span>
|
| 181 |
<span class="step-connector"></span>
|
| 182 |
-
<span class="step-dot" data-step-page="
|
| 183 |
<span class="step-connector"></span>
|
| 184 |
<span class="step-dot" data-step-page="confirm"></span>
|
| 185 |
</div>
|
|
@@ -293,7 +293,7 @@
|
|
| 293 |
|
| 294 |
<div class="map-sidebar-footer">
|
| 295 |
<button id="aoi-continue-btn" class="btn btn-primary" style="width:100%;" disabled>
|
| 296 |
-
Continue to
|
| 297 |
</button>
|
| 298 |
</div>
|
| 299 |
|
|
@@ -308,9 +308,9 @@
|
|
| 308 |
|
| 309 |
|
| 310 |
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 311 |
-
PAGE 3 β
|
| 312 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 313 |
-
<div id="page-
|
| 314 |
|
| 315 |
<!-- Top bar -->
|
| 316 |
<div class="indicators-topbar">
|
|
@@ -320,7 +320,7 @@
|
|
| 320 |
<div class="steps">
|
| 321 |
<span class="step-dot" data-step-page="define-area"></span>
|
| 322 |
<span class="step-connector"></span>
|
| 323 |
-
<span class="step-dot" data-step-page="
|
| 324 |
<span class="step-connector"></span>
|
| 325 |
<span class="step-dot" data-step-page="confirm"></span>
|
| 326 |
</div>
|
|
@@ -328,32 +328,32 @@
|
|
| 328 |
<div style="display:flex; align-items:center; gap: var(--space-5);">
|
| 329 |
<a href="#" class="topbar-link nav-my-analyses">My analyses</a>
|
| 330 |
<a href="#" class="topbar-link nav-signout">Sign out</a>
|
| 331 |
-
<button id="
|
| 332 |
</div>
|
| 333 |
</div>
|
| 334 |
|
| 335 |
<!-- Page heading -->
|
| 336 |
<div style="padding: var(--space-8) var(--space-12) 0; flex-shrink:0; background-color: var(--surface); border-bottom: 1px solid var(--border-light);">
|
| 337 |
-
<h1 style="font-size: var(--text-xl); font-weight: 600; margin-bottom: var(--space-2);">
|
| 338 |
<p style="font-size: var(--text-xs); color: var(--ink-muted); margin-bottom: var(--space-8);">
|
| 339 |
-
Select the satellite-derived
|
| 340 |
</p>
|
| 341 |
</div>
|
| 342 |
|
| 343 |
-
<!--
|
| 344 |
-
<div id="
|
| 345 |
|
| 346 |
<!-- Sticky summary bar -->
|
| 347 |
<div class="indicators-summary-bar">
|
| 348 |
-
<span id="
|
| 349 |
-
Select at least one
|
| 350 |
</span>
|
| 351 |
-
<button id="
|
| 352 |
Continue
|
| 353 |
</button>
|
| 354 |
</div>
|
| 355 |
|
| 356 |
-
</div><!-- /page-
|
| 357 |
|
| 358 |
|
| 359 |
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -369,7 +369,7 @@
|
|
| 369 |
<div class="steps">
|
| 370 |
<span class="step-dot" data-step-page="define-area"></span>
|
| 371 |
<span class="step-connector"></span>
|
| 372 |
-
<span class="step-dot" data-step-page="
|
| 373 |
<span class="step-connector"></span>
|
| 374 |
<span class="step-dot" data-step-page="confirm"></span>
|
| 375 |
</div>
|
|
@@ -396,7 +396,7 @@
|
|
| 396 |
<span id="confirm-season" class="confirm-row-value">January β December</span>
|
| 397 |
</div>
|
| 398 |
<div class="confirm-row">
|
| 399 |
-
<span class="confirm-row-label">
|
| 400 |
<span id="confirm-indicators" class="confirm-row-value">0</span>
|
| 401 |
</div>
|
| 402 |
</div>
|
|
@@ -431,7 +431,7 @@
|
|
| 431 |
<span id="status-job-id" class="font-data text-xxs text-muted"></span>
|
| 432 |
</div>
|
| 433 |
<h1 class="status-title">Analysis in progress</h1>
|
| 434 |
-
<p class="status-subtitle">Your
|
| 435 |
</div>
|
| 436 |
|
| 437 |
<div id="status-list" class="status-list">
|
|
|
|
| 41 |
overflow: hidden;
|
| 42 |
}
|
| 43 |
|
| 44 |
+
/* Products: full column */
|
| 45 |
+
#page-products {
|
| 46 |
height: 100vh;
|
| 47 |
flex-direction: column;
|
| 48 |
overflow: hidden;
|
|
|
|
| 107 |
|
| 108 |
<!-- Sub-copy -->
|
| 109 |
<p class="landing-sub">
|
| 110 |
+
Select your area, choose your EO products, get evidence-grade
|
| 111 |
satellite analysis delivered β using free, open data.
|
| 112 |
</p>
|
| 113 |
|
|
|
|
| 179 |
<div class="steps">
|
| 180 |
<span class="step-dot" data-step-page="define-area"></span>
|
| 181 |
<span class="step-connector"></span>
|
| 182 |
+
<span class="step-dot" data-step-page="products"></span>
|
| 183 |
<span class="step-connector"></span>
|
| 184 |
<span class="step-dot" data-step-page="confirm"></span>
|
| 185 |
</div>
|
|
|
|
| 293 |
|
| 294 |
<div class="map-sidebar-footer">
|
| 295 |
<button id="aoi-continue-btn" class="btn btn-primary" style="width:100%;" disabled>
|
| 296 |
+
Continue to EO products
|
| 297 |
</button>
|
| 298 |
</div>
|
| 299 |
|
|
|
|
| 308 |
|
| 309 |
|
| 310 |
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 311 |
+
PAGE 3 β SELECT EO PRODUCTS
|
| 312 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
| 313 |
+
<div id="page-products" class="page">
|
| 314 |
|
| 315 |
<!-- Top bar -->
|
| 316 |
<div class="indicators-topbar">
|
|
|
|
| 320 |
<div class="steps">
|
| 321 |
<span class="step-dot" data-step-page="define-area"></span>
|
| 322 |
<span class="step-connector"></span>
|
| 323 |
+
<span class="step-dot" data-step-page="products"></span>
|
| 324 |
<span class="step-connector"></span>
|
| 325 |
<span class="step-dot" data-step-page="confirm"></span>
|
| 326 |
</div>
|
|
|
|
| 328 |
<div style="display:flex; align-items:center; gap: var(--space-5);">
|
| 329 |
<a href="#" class="topbar-link nav-my-analyses">My analyses</a>
|
| 330 |
<a href="#" class="topbar-link nav-signout">Sign out</a>
|
| 331 |
+
<button id="products-back-btn" class="btn btn-secondary">Back</button>
|
| 332 |
</div>
|
| 333 |
</div>
|
| 334 |
|
| 335 |
<!-- Page heading -->
|
| 336 |
<div style="padding: var(--space-8) var(--space-12) 0; flex-shrink:0; background-color: var(--surface); border-bottom: 1px solid var(--border-light);">
|
| 337 |
+
<h1 style="font-size: var(--text-xl); font-weight: 600; margin-bottom: var(--space-2);">Select EO Products</h1>
|
| 338 |
<p style="font-size: var(--text-xs); color: var(--ink-muted); margin-bottom: var(--space-8);">
|
| 339 |
+
Select the satellite-derived EO products to include in your analysis.
|
| 340 |
</p>
|
| 341 |
</div>
|
| 342 |
|
| 343 |
+
<!-- Products grid (scrolls) -->
|
| 344 |
+
<div id="products-grid" class="indicators-grid"></div>
|
| 345 |
|
| 346 |
<!-- Sticky summary bar -->
|
| 347 |
<div class="indicators-summary-bar">
|
| 348 |
+
<span id="products-summary-text" class="summary-text">
|
| 349 |
+
Select at least one EO product to continue.
|
| 350 |
</span>
|
| 351 |
+
<button id="products-continue-btn" class="btn btn-primary" disabled>
|
| 352 |
Continue
|
| 353 |
</button>
|
| 354 |
</div>
|
| 355 |
|
| 356 |
+
</div><!-- /page-products -->
|
| 357 |
|
| 358 |
|
| 359 |
<!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 369 |
<div class="steps">
|
| 370 |
<span class="step-dot" data-step-page="define-area"></span>
|
| 371 |
<span class="step-connector"></span>
|
| 372 |
+
<span class="step-dot" data-step-page="products"></span>
|
| 373 |
<span class="step-connector"></span>
|
| 374 |
<span class="step-dot" data-step-page="confirm"></span>
|
| 375 |
</div>
|
|
|
|
| 396 |
<span id="confirm-season" class="confirm-row-value">January β December</span>
|
| 397 |
</div>
|
| 398 |
<div class="confirm-row">
|
| 399 |
+
<span class="confirm-row-label">EO Products</span>
|
| 400 |
<span id="confirm-indicators" class="confirm-row-value">0</span>
|
| 401 |
</div>
|
| 402 |
</div>
|
|
|
|
| 431 |
<span id="status-job-id" class="font-data text-xxs text-muted"></span>
|
| 432 |
</div>
|
| 433 |
<h1 class="status-title">Analysis in progress</h1>
|
| 434 |
+
<p class="status-subtitle">Your EO products are being processed. This page updates automatically.</p>
|
| 435 |
</div>
|
| 436 |
|
| 437 |
<div id="status-list" class="status-list">
|
frontend/js/api.js
CHANGED
|
@@ -7,7 +7,7 @@ const BASE = ''; // same-origin; empty string = relative to current host
|
|
| 7 |
|
| 8 |
/**
|
| 9 |
* Fetch wrapper with JSON handling and error propagation.
|
| 10 |
-
* @param {string} path - Relative path, e.g. "/api/
|
| 11 |
* @param {object} opts - fetch() options (optional)
|
| 12 |
* @returns {Promise<any>}
|
| 13 |
*/
|
|
@@ -35,21 +35,21 @@ async function apiFetch(path, opts = {}) {
|
|
| 35 |
return res.json();
|
| 36 |
}
|
| 37 |
|
| 38 |
-
/* ββ
|
| 39 |
|
| 40 |
/**
|
| 41 |
-
* List all available
|
| 42 |
* @returns {Promise<Array<{id, name, category, question, estimated_minutes}>>}
|
| 43 |
*/
|
| 44 |
-
export async function
|
| 45 |
-
return apiFetch('/api/
|
| 46 |
}
|
| 47 |
|
| 48 |
/* ββ Jobs ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 49 |
|
| 50 |
/**
|
| 51 |
* Submit a new analysis job.
|
| 52 |
-
* @param {{aoi, time_range,
|
| 53 |
* @returns {Promise<{id, status}>}
|
| 54 |
*/
|
| 55 |
export async function submitJob(payload) {
|
|
@@ -114,23 +114,23 @@ export function packageUrl(jobId) {
|
|
| 114 |
}
|
| 115 |
|
| 116 |
/**
|
| 117 |
-
* Returns the URL for an
|
| 118 |
* @param {string} jobId
|
| 119 |
-
* @param {string}
|
| 120 |
* @returns {string}
|
| 121 |
*/
|
| 122 |
-
export function mapUrl(jobId,
|
| 123 |
-
return `${BASE}/api/jobs/${jobId}/maps/${
|
| 124 |
}
|
| 125 |
|
| 126 |
/**
|
| 127 |
-
* Returns the URL for
|
| 128 |
* @param {string} jobId
|
| 129 |
-
* @param {string}
|
| 130 |
* @returns {string}
|
| 131 |
*/
|
| 132 |
-
export function spatialUrl(jobId,
|
| 133 |
-
return `${BASE}/api/jobs/${jobId}/spatial/${
|
| 134 |
}
|
| 135 |
|
| 136 |
/* ββ AOI Advisor βββββββββββββββββββββββββββββββββββββββββ */
|
|
@@ -138,7 +138,7 @@ export function spatialUrl(jobId, indicatorId) {
|
|
| 138 |
/**
|
| 139 |
* Get Claude-powered region insight for an AOI.
|
| 140 |
* @param {Array<number>} bbox - [minLon, minLat, maxLon, maxLat]
|
| 141 |
-
* @returns {Promise<{context, recommended_start, recommended_end,
|
| 142 |
*/
|
| 143 |
export async function getAoiAdvice(bbox) {
|
| 144 |
try {
|
|
|
|
| 7 |
|
| 8 |
/**
|
| 9 |
* Fetch wrapper with JSON handling and error propagation.
|
| 10 |
+
* @param {string} path - Relative path, e.g. "/api/eo-products"
|
| 11 |
* @param {object} opts - fetch() options (optional)
|
| 12 |
* @returns {Promise<any>}
|
| 13 |
*/
|
|
|
|
| 35 |
return res.json();
|
| 36 |
}
|
| 37 |
|
| 38 |
+
/* ββ EO Products βββββββββββββββββββββββββββββββββββββββββββ */
|
| 39 |
|
| 40 |
/**
|
| 41 |
+
* List all available EO products.
|
| 42 |
* @returns {Promise<Array<{id, name, category, question, estimated_minutes}>>}
|
| 43 |
*/
|
| 44 |
+
export async function listProducts() {
|
| 45 |
+
return apiFetch('/api/eo-products');
|
| 46 |
}
|
| 47 |
|
| 48 |
/* ββ Jobs ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 49 |
|
| 50 |
/**
|
| 51 |
* Submit a new analysis job.
|
| 52 |
+
* @param {{aoi, time_range, product_ids, email}} payload
|
| 53 |
* @returns {Promise<{id, status}>}
|
| 54 |
*/
|
| 55 |
export async function submitJob(payload) {
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
/**
|
| 117 |
+
* Returns the URL for an EO product map PNG.
|
| 118 |
* @param {string} jobId
|
| 119 |
+
* @param {string} productId
|
| 120 |
* @returns {string}
|
| 121 |
*/
|
| 122 |
+
export function mapUrl(jobId, productId) {
|
| 123 |
+
return `${BASE}/api/jobs/${jobId}/maps/${productId}`;
|
| 124 |
}
|
| 125 |
|
| 126 |
/**
|
| 127 |
+
* Returns the URL for EO product spatial data JSON.
|
| 128 |
* @param {string} jobId
|
| 129 |
+
* @param {string} productId
|
| 130 |
* @returns {string}
|
| 131 |
*/
|
| 132 |
+
export function spatialUrl(jobId, productId) {
|
| 133 |
+
return `${BASE}/api/jobs/${jobId}/spatial/${productId}`;
|
| 134 |
}
|
| 135 |
|
| 136 |
/* ββ AOI Advisor βββββββββββββββββββββββββββββββββββββββββ */
|
|
|
|
| 138 |
/**
|
| 139 |
* Get Claude-powered region insight for an AOI.
|
| 140 |
* @param {Array<number>} bbox - [minLon, minLat, maxLon, maxLat]
|
| 141 |
+
* @returns {Promise<{context, recommended_start, recommended_end, product_priorities, reasoning} | null>}
|
| 142 |
*/
|
| 143 |
export async function getAoiAdvice(bbox) {
|
| 144 |
try {
|
frontend/js/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
|
| 6 |
import { submitJob, getJob, requestMagicLink, verifyToken, listJobs, getAoiAdvice } from './api.js';
|
| 7 |
import { initAoiMap, setAoiSize, geocode, initResultsMap } from './map.js';
|
| 8 |
-
import {
|
| 9 |
import { renderResults } from './results.js';
|
| 10 |
|
| 11 |
/* ββ Application State βββββββββββββββββββββββββββββββββββββ */
|
|
@@ -16,7 +16,7 @@ const state = {
|
|
| 16 |
timeRange: null, // { start, end }
|
| 17 |
seasonStart: 1, // 1-12
|
| 18 |
seasonEnd: 12, // 1-12
|
| 19 |
-
|
| 20 |
jobId: null, // string
|
| 21 |
jobData: null, // full job response
|
| 22 |
};
|
|
@@ -44,7 +44,7 @@ function clearSession() {
|
|
| 44 |
|
| 45 |
/* ββ Router ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 46 |
|
| 47 |
-
const PAGES = ['landing', 'login', 'define-area', '
|
| 48 |
let _currentPage = null;
|
| 49 |
let _pollTimer = null;
|
| 50 |
|
|
@@ -55,7 +55,7 @@ function navigate(pageId) {
|
|
| 55 |
}
|
| 56 |
|
| 57 |
// Auth guard β redirect to login if not authenticated
|
| 58 |
-
const AUTH_REQUIRED = ['define-area', '
|
| 59 |
if (AUTH_REQUIRED.includes(pageId) && !state.session) {
|
| 60 |
pageId = 'login';
|
| 61 |
}
|
|
@@ -79,7 +79,7 @@ const pageSetup = {
|
|
| 79 |
'landing': setupLanding,
|
| 80 |
'login': setupLogin,
|
| 81 |
'define-area': setupDefineArea,
|
| 82 |
-
'
|
| 83 |
'confirm': setupConfirm,
|
| 84 |
'status': setupStatus,
|
| 85 |
'results': setupResults,
|
|
@@ -286,29 +286,29 @@ function setupDefineArea() {
|
|
| 286 |
state.seasonStart = parseInt(document.getElementById('season-start').value);
|
| 287 |
state.seasonEnd = parseInt(document.getElementById('season-end').value);
|
| 288 |
|
| 289 |
-
navigate('
|
| 290 |
}, { once: true });
|
| 291 |
}
|
| 292 |
|
| 293 |
-
/*
|
| 294 |
|
| 295 |
-
function
|
| 296 |
-
updateSteps('
|
| 297 |
|
| 298 |
-
const gridEl = document.getElementById('
|
| 299 |
-
const summaryEl = document.getElementById('
|
| 300 |
-
const continueBtn = document.getElementById('
|
| 301 |
-
const backBtn = document.getElementById('
|
| 302 |
|
| 303 |
backBtn.addEventListener('click', () => navigate('define-area'), { once: true });
|
| 304 |
|
| 305 |
continueBtn.addEventListener('click', () => {
|
| 306 |
-
state.
|
| 307 |
navigate('confirm');
|
| 308 |
}, { once: true });
|
| 309 |
|
| 310 |
-
|
| 311 |
-
state.
|
| 312 |
});
|
| 313 |
}
|
| 314 |
|
|
@@ -322,7 +322,7 @@ function setupConfirm() {
|
|
| 322 |
document.getElementById('confirm-area-name').textContent = aoi.name || 'β';
|
| 323 |
document.getElementById('confirm-period-start').textContent = state.timeRange?.start || 'β';
|
| 324 |
document.getElementById('confirm-period-end').textContent = state.timeRange?.end || 'β';
|
| 325 |
-
document.getElementById('confirm-indicators').textContent = state.
|
| 326 |
|
| 327 |
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
| 328 |
const seasonText = `${monthNames[state.seasonStart - 1]} β ${monthNames[state.seasonEnd - 1]}`;
|
|
@@ -338,7 +338,7 @@ function setupConfirm() {
|
|
| 338 |
const backBtn = document.getElementById('confirm-back-btn');
|
| 339 |
const submitBtn = document.getElementById('confirm-submit-btn');
|
| 340 |
|
| 341 |
-
backBtn.addEventListener('click', () => navigate('
|
| 342 |
|
| 343 |
submitBtn.addEventListener('click', async () => {
|
| 344 |
const email = document.getElementById('confirm-email').value.trim();
|
|
@@ -356,7 +356,7 @@ function setupConfirm() {
|
|
| 356 |
start: state.timeRange.start,
|
| 357 |
end: state.timeRange.end,
|
| 358 |
},
|
| 359 |
-
|
| 360 |
email,
|
| 361 |
season_start: state.seasonStart,
|
| 362 |
season_end: state.seasonEnd,
|
|
@@ -415,7 +415,7 @@ function setupStatus() {
|
|
| 415 |
|
| 416 |
function renderStatusList(listEl, job) {
|
| 417 |
const progress = job.progress || {};
|
| 418 |
-
const allIds = job.
|
| 419 |
|
| 420 |
if (!allIds.length) {
|
| 421 |
listEl.innerHTML = '<div class="status-item"><span class="status-dot status-dot-queued"></span><span class="status-item-name text-muted">Queuedβ¦</span></div>';
|
|
@@ -500,7 +500,7 @@ function setupHistory() {
|
|
| 500 |
<span class="history-card-date">${_esc(dateStr)}</span>
|
| 501 |
<span><span class="badge badge-${job.status}">${_esc(job.status)}</span></span>
|
| 502 |
</div>
|
| 503 |
-
<div class="history-card-indicators">${job.indicator_count}
|
| 504 |
`;
|
| 505 |
card.addEventListener('click', async () => {
|
| 506 |
state.jobId = job.id;
|
|
@@ -552,7 +552,7 @@ function wireTopbarLinks() {
|
|
| 552 |
|
| 553 |
/* ββ Steps Indicator βββββββββββββββββββββββββββββββββββββββ */
|
| 554 |
|
| 555 |
-
const STEP_PAGES = ['define-area', '
|
| 556 |
|
| 557 |
function updateSteps(currentPage) {
|
| 558 |
const dots = document.querySelectorAll('[data-step-page]');
|
|
|
|
| 5 |
|
| 6 |
import { submitJob, getJob, requestMagicLink, verifyToken, listJobs, getAoiAdvice } from './api.js';
|
| 7 |
import { initAoiMap, setAoiSize, geocode, initResultsMap } from './map.js';
|
| 8 |
+
import { initProducts, getSelectedIds } from './products.js';
|
| 9 |
import { renderResults } from './results.js';
|
| 10 |
|
| 11 |
/* ββ Application State βββββββββββββββββββββββββββββββββββββ */
|
|
|
|
| 16 |
timeRange: null, // { start, end }
|
| 17 |
seasonStart: 1, // 1-12
|
| 18 |
seasonEnd: 12, // 1-12
|
| 19 |
+
products: [], // string[]
|
| 20 |
jobId: null, // string
|
| 21 |
jobData: null, // full job response
|
| 22 |
};
|
|
|
|
| 44 |
|
| 45 |
/* ββ Router ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 46 |
|
| 47 |
+
const PAGES = ['landing', 'login', 'define-area', 'products', 'confirm', 'status', 'results', 'history'];
|
| 48 |
let _currentPage = null;
|
| 49 |
let _pollTimer = null;
|
| 50 |
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
// Auth guard β redirect to login if not authenticated
|
| 58 |
+
const AUTH_REQUIRED = ['define-area', 'products', 'confirm', 'status', 'results', 'history'];
|
| 59 |
if (AUTH_REQUIRED.includes(pageId) && !state.session) {
|
| 60 |
pageId = 'login';
|
| 61 |
}
|
|
|
|
| 79 |
'landing': setupLanding,
|
| 80 |
'login': setupLogin,
|
| 81 |
'define-area': setupDefineArea,
|
| 82 |
+
'products': setupProducts,
|
| 83 |
'confirm': setupConfirm,
|
| 84 |
'status': setupStatus,
|
| 85 |
'results': setupResults,
|
|
|
|
| 286 |
state.seasonStart = parseInt(document.getElementById('season-start').value);
|
| 287 |
state.seasonEnd = parseInt(document.getElementById('season-end').value);
|
| 288 |
|
| 289 |
+
navigate('products');
|
| 290 |
}, { once: true });
|
| 291 |
}
|
| 292 |
|
| 293 |
+
/* Products ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 294 |
|
| 295 |
+
function setupProducts() {
|
| 296 |
+
updateSteps('products');
|
| 297 |
|
| 298 |
+
const gridEl = document.getElementById('products-grid');
|
| 299 |
+
const summaryEl = document.getElementById('products-summary-text');
|
| 300 |
+
const continueBtn = document.getElementById('products-continue-btn');
|
| 301 |
+
const backBtn = document.getElementById('products-back-btn');
|
| 302 |
|
| 303 |
backBtn.addEventListener('click', () => navigate('define-area'), { once: true });
|
| 304 |
|
| 305 |
continueBtn.addEventListener('click', () => {
|
| 306 |
+
state.products = getSelectedIds();
|
| 307 |
navigate('confirm');
|
| 308 |
}, { once: true });
|
| 309 |
|
| 310 |
+
initProducts(gridEl, summaryEl, continueBtn, (ids) => {
|
| 311 |
+
state.products = ids;
|
| 312 |
});
|
| 313 |
}
|
| 314 |
|
|
|
|
| 322 |
document.getElementById('confirm-area-name').textContent = aoi.name || 'β';
|
| 323 |
document.getElementById('confirm-period-start').textContent = state.timeRange?.start || 'β';
|
| 324 |
document.getElementById('confirm-period-end').textContent = state.timeRange?.end || 'β';
|
| 325 |
+
document.getElementById('confirm-indicators').textContent = state.products.length;
|
| 326 |
|
| 327 |
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
| 328 |
const seasonText = `${monthNames[state.seasonStart - 1]} β ${monthNames[state.seasonEnd - 1]}`;
|
|
|
|
| 338 |
const backBtn = document.getElementById('confirm-back-btn');
|
| 339 |
const submitBtn = document.getElementById('confirm-submit-btn');
|
| 340 |
|
| 341 |
+
backBtn.addEventListener('click', () => navigate('products'), { once: true });
|
| 342 |
|
| 343 |
submitBtn.addEventListener('click', async () => {
|
| 344 |
const email = document.getElementById('confirm-email').value.trim();
|
|
|
|
| 356 |
start: state.timeRange.start,
|
| 357 |
end: state.timeRange.end,
|
| 358 |
},
|
| 359 |
+
product_ids: state.products,
|
| 360 |
email,
|
| 361 |
season_start: state.seasonStart,
|
| 362 |
season_end: state.seasonEnd,
|
|
|
|
| 415 |
|
| 416 |
function renderStatusList(listEl, job) {
|
| 417 |
const progress = job.progress || {};
|
| 418 |
+
const allIds = job.product_ids || Object.keys(progress);
|
| 419 |
|
| 420 |
if (!allIds.length) {
|
| 421 |
listEl.innerHTML = '<div class="status-item"><span class="status-dot status-dot-queued"></span><span class="status-item-name text-muted">Queuedβ¦</span></div>';
|
|
|
|
| 500 |
<span class="history-card-date">${_esc(dateStr)}</span>
|
| 501 |
<span><span class="badge badge-${job.status}">${_esc(job.status)}</span></span>
|
| 502 |
</div>
|
| 503 |
+
<div class="history-card-indicators">${job.indicator_count} EO product${job.indicator_count !== 1 ? 's' : ''}</div>
|
| 504 |
`;
|
| 505 |
card.addEventListener('click', async () => {
|
| 506 |
state.jobId = job.id;
|
|
|
|
| 552 |
|
| 553 |
/* ββ Steps Indicator βββββββββββββββββββββββββββββββββββββββ */
|
| 554 |
|
| 555 |
+
const STEP_PAGES = ['define-area', 'products', 'confirm'];
|
| 556 |
|
| 557 |
function updateSteps(currentPage) {
|
| 558 |
const dots = document.querySelectorAll('[data-step-page]');
|
frontend/js/{indicators.js β products.js}
RENAMED
|
@@ -1,42 +1,42 @@
|
|
| 1 |
/**
|
| 2 |
-
* Aperture β
|
| 3 |
-
* Renders and manages the
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
import {
|
| 7 |
|
| 8 |
-
let
|
| 9 |
let _selected = new Set();
|
| 10 |
let _onSelectionChange = null; // callback(selectedIds[])
|
| 11 |
|
| 12 |
/**
|
| 13 |
-
* Load
|
| 14 |
-
* @param {HTMLElement} gridEl - Container for
|
| 15 |
* @param {HTMLElement} summaryEl - Summary bar text element
|
| 16 |
* @param {HTMLElement} continueBtn - Continue button to enable/disable
|
| 17 |
-
* @param {function} onChange - Called with selected
|
| 18 |
*/
|
| 19 |
-
export async function
|
| 20 |
_onSelectionChange = onChange;
|
| 21 |
_selected.clear();
|
| 22 |
_updateSummary(summaryEl, continueBtn);
|
| 23 |
|
| 24 |
// Show loading state
|
| 25 |
-
gridEl.innerHTML = '<p class="text-muted text-sm" style="grid-column:1/-1; padding: 20px 0;">Loading
|
| 26 |
|
| 27 |
try {
|
| 28 |
-
|
| 29 |
} catch (err) {
|
| 30 |
-
gridEl.innerHTML = `<p class="text-muted text-sm" style="grid-column:1/-1; padding: 20px 0;">Failed to load
|
| 31 |
return;
|
| 32 |
}
|
| 33 |
|
| 34 |
gridEl.innerHTML = '';
|
| 35 |
|
| 36 |
-
for (const
|
| 37 |
-
const card = _buildCard(
|
| 38 |
card.addEventListener('click', () => {
|
| 39 |
-
_toggleCard(
|
| 40 |
_updateSummary(summaryEl, continueBtn);
|
| 41 |
});
|
| 42 |
gridEl.appendChild(card);
|
|
@@ -44,7 +44,7 @@ export async function initIndicators(gridEl, summaryEl, continueBtn, onChange) {
|
|
| 44 |
}
|
| 45 |
|
| 46 |
/**
|
| 47 |
-
* Returns the currently selected
|
| 48 |
* @returns {string[]}
|
| 49 |
*/
|
| 50 |
export function getSelectedIds() {
|
|
@@ -53,20 +53,20 @@ export function getSelectedIds() {
|
|
| 53 |
|
| 54 |
/* ββ Internal helpers ββββββββββββββββββββββββββββββββββββββ */
|
| 55 |
|
| 56 |
-
function _buildCard(
|
| 57 |
const card = document.createElement('div');
|
| 58 |
card.className = 'indicator-card';
|
| 59 |
-
card.dataset.id =
|
| 60 |
card.setAttribute('role', 'checkbox');
|
| 61 |
card.setAttribute('aria-checked', 'false');
|
| 62 |
card.setAttribute('tabindex', '0');
|
| 63 |
|
| 64 |
card.innerHTML = `
|
| 65 |
-
<div class="indicator-card-name">${_esc(
|
| 66 |
-
<div class="indicator-card-question">${_esc(
|
| 67 |
<div class="indicator-card-meta">
|
| 68 |
-
<span class="indicator-card-category">${_esc(
|
| 69 |
-
<span class="indicator-card-time font-data">~${
|
| 70 |
</div>
|
| 71 |
`;
|
| 72 |
|
|
@@ -97,15 +97,15 @@ function _toggleCard(id, cardEl) {
|
|
| 97 |
|
| 98 |
function _updateSummary(summaryEl, continueBtn) {
|
| 99 |
const count = _selected.size;
|
| 100 |
-
const totalMinutes =
|
| 101 |
-
.filter(
|
| 102 |
-
.reduce((sum,
|
| 103 |
|
| 104 |
if (count === 0) {
|
| 105 |
-
summaryEl.innerHTML = 'Select at least one
|
| 106 |
continueBtn.disabled = true;
|
| 107 |
} else {
|
| 108 |
-
summaryEl.innerHTML = `<strong>${count}
|
| 109 |
continueBtn.disabled = false;
|
| 110 |
}
|
| 111 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Aperture β EO Product Card Selection
|
| 3 |
+
* Renders and manages the product grid on the Select EO Products page.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
import { listProducts } from './api.js';
|
| 7 |
|
| 8 |
+
let _products = [];
|
| 9 |
let _selected = new Set();
|
| 10 |
let _onSelectionChange = null; // callback(selectedIds[])
|
| 11 |
|
| 12 |
/**
|
| 13 |
+
* Load EO products from the API and render cards into the grid element.
|
| 14 |
+
* @param {HTMLElement} gridEl - Container for product cards
|
| 15 |
* @param {HTMLElement} summaryEl - Summary bar text element
|
| 16 |
* @param {HTMLElement} continueBtn - Continue button to enable/disable
|
| 17 |
+
* @param {function} onChange - Called with selected product IDs array
|
| 18 |
*/
|
| 19 |
+
export async function initProducts(gridEl, summaryEl, continueBtn, onChange) {
|
| 20 |
_onSelectionChange = onChange;
|
| 21 |
_selected.clear();
|
| 22 |
_updateSummary(summaryEl, continueBtn);
|
| 23 |
|
| 24 |
// Show loading state
|
| 25 |
+
gridEl.innerHTML = '<p class="text-muted text-sm" style="grid-column:1/-1; padding: 20px 0;">Loading EO productsβ¦</p>';
|
| 26 |
|
| 27 |
try {
|
| 28 |
+
_products = await listProducts();
|
| 29 |
} catch (err) {
|
| 30 |
+
gridEl.innerHTML = `<p class="text-muted text-sm" style="grid-column:1/-1; padding: 20px 0;">Failed to load EO products: ${err.message}</p>`;
|
| 31 |
return;
|
| 32 |
}
|
| 33 |
|
| 34 |
gridEl.innerHTML = '';
|
| 35 |
|
| 36 |
+
for (const product of _products) {
|
| 37 |
+
const card = _buildCard(product);
|
| 38 |
card.addEventListener('click', () => {
|
| 39 |
+
_toggleCard(product.id, card);
|
| 40 |
_updateSummary(summaryEl, continueBtn);
|
| 41 |
});
|
| 42 |
gridEl.appendChild(card);
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
/**
|
| 47 |
+
* Returns the currently selected product IDs.
|
| 48 |
* @returns {string[]}
|
| 49 |
*/
|
| 50 |
export function getSelectedIds() {
|
|
|
|
| 53 |
|
| 54 |
/* ββ Internal helpers ββββββββββββββββββββββββββββββββββββββ */
|
| 55 |
|
| 56 |
+
function _buildCard(product) {
|
| 57 |
const card = document.createElement('div');
|
| 58 |
card.className = 'indicator-card';
|
| 59 |
+
card.dataset.id = product.id;
|
| 60 |
card.setAttribute('role', 'checkbox');
|
| 61 |
card.setAttribute('aria-checked', 'false');
|
| 62 |
card.setAttribute('tabindex', '0');
|
| 63 |
|
| 64 |
card.innerHTML = `
|
| 65 |
+
<div class="indicator-card-name">${_esc(product.name)}</div>
|
| 66 |
+
<div class="indicator-card-question">${_esc(product.question)}</div>
|
| 67 |
<div class="indicator-card-meta">
|
| 68 |
+
<span class="indicator-card-category">${_esc(product.category)}</span>
|
| 69 |
+
<span class="indicator-card-time font-data">~${product.estimated_minutes} min</span>
|
| 70 |
</div>
|
| 71 |
`;
|
| 72 |
|
|
|
|
| 97 |
|
| 98 |
function _updateSummary(summaryEl, continueBtn) {
|
| 99 |
const count = _selected.size;
|
| 100 |
+
const totalMinutes = _products
|
| 101 |
+
.filter(p => _selected.has(p.id))
|
| 102 |
+
.reduce((sum, p) => sum + p.estimated_minutes, 0);
|
| 103 |
|
| 104 |
if (count === 0) {
|
| 105 |
+
summaryEl.innerHTML = 'Select at least one EO product to continue.';
|
| 106 |
continueBtn.disabled = true;
|
| 107 |
} else {
|
| 108 |
+
summaryEl.innerHTML = `<strong>${count} EO product${count !== 1 ? 's' : ''} selected</strong> β estimated delivery: ~${totalMinutes} minute${totalMinutes !== 1 ? 's' : ''}`;
|
| 109 |
continueBtn.disabled = false;
|
| 110 |
}
|
| 111 |
}
|
frontend/js/results.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
/**
|
| 2 |
* Aperture β Results Dashboard
|
| 3 |
-
* Renders
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { reportUrl, packageUrl, spatialUrl, authDownload, authHeaders } from './api.js';
|
|
@@ -13,7 +13,7 @@ let _currentBbox = null;
|
|
| 13 |
* Populate the results panel with indicator result cards.
|
| 14 |
* @param {HTMLElement} panelEl - Scrollable panel container
|
| 15 |
* @param {HTMLElement} footerEl - Footer with download links
|
| 16 |
-
* @param {Array} results - Array of
|
| 17 |
* @param {string} jobId - Job ID (for download links)
|
| 18 |
* @param {Array<number>} bbox - AOI bounding box for map overlays
|
| 19 |
*/
|
|
@@ -39,7 +39,7 @@ export function renderResults(panelEl, footerEl, results, jobId, bbox) {
|
|
| 39 |
const first = panelEl.querySelector('.result-card');
|
| 40 |
if (first) {
|
| 41 |
first.classList.add('active');
|
| 42 |
-
|
| 43 |
}
|
| 44 |
|
| 45 |
// Download buttons (use fetch + blob to send auth headers)
|
|
@@ -64,7 +64,7 @@ export function renderResults(panelEl, footerEl, results, jobId, bbox) {
|
|
| 64 |
function _buildResultCard(result) {
|
| 65 |
const card = document.createElement('div');
|
| 66 |
card.className = 'result-card';
|
| 67 |
-
card.dataset.
|
| 68 |
card.setAttribute('role', 'button');
|
| 69 |
card.setAttribute('tabindex', '0');
|
| 70 |
|
|
@@ -76,7 +76,7 @@ function _buildResultCard(result) {
|
|
| 76 |
|
| 77 |
card.innerHTML = `
|
| 78 |
<div class="result-card-header">
|
| 79 |
-
<span class="result-card-name">${_esc(result.
|
| 80 |
${statusBadge}
|
| 81 |
</div>
|
| 82 |
${placeholderBadge}
|
|
@@ -89,7 +89,7 @@ function _buildResultCard(result) {
|
|
| 89 |
const panel = card.closest('.results-panel-body');
|
| 90 |
panel.querySelectorAll('.result-card').forEach(c => c.classList.remove('active'));
|
| 91 |
card.classList.add('active');
|
| 92 |
-
|
| 93 |
});
|
| 94 |
|
| 95 |
card.addEventListener('keydown', (e) => {
|
|
@@ -129,9 +129,9 @@ function _esc(str) {
|
|
| 129 |
.replace(/"/g, '"');
|
| 130 |
}
|
| 131 |
|
| 132 |
-
function
|
| 133 |
if (!_currentJobId || !_currentBbox) return;
|
| 134 |
-
const url = spatialUrl(_currentJobId,
|
| 135 |
fetch(url, { headers: authHeaders() })
|
| 136 |
.then(res => {
|
| 137 |
if (res.ok) return res.json();
|
|
|
|
| 1 |
/**
|
| 2 |
* Aperture β Results Dashboard
|
| 3 |
+
* Renders EO product result cards and manages the active-card state.
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { reportUrl, packageUrl, spatialUrl, authDownload, authHeaders } from './api.js';
|
|
|
|
| 13 |
* Populate the results panel with indicator result cards.
|
| 14 |
* @param {HTMLElement} panelEl - Scrollable panel container
|
| 15 |
* @param {HTMLElement} footerEl - Footer with download links
|
| 16 |
+
* @param {Array} results - Array of ProductResult objects
|
| 17 |
* @param {string} jobId - Job ID (for download links)
|
| 18 |
* @param {Array<number>} bbox - AOI bounding box for map overlays
|
| 19 |
*/
|
|
|
|
| 39 |
const first = panelEl.querySelector('.result-card');
|
| 40 |
if (first) {
|
| 41 |
first.classList.add('active');
|
| 42 |
+
_showMapForProduct(first.dataset.productId);
|
| 43 |
}
|
| 44 |
|
| 45 |
// Download buttons (use fetch + blob to send auth headers)
|
|
|
|
| 64 |
function _buildResultCard(result) {
|
| 65 |
const card = document.createElement('div');
|
| 66 |
card.className = 'result-card';
|
| 67 |
+
card.dataset.productId = result.product_id;
|
| 68 |
card.setAttribute('role', 'button');
|
| 69 |
card.setAttribute('tabindex', '0');
|
| 70 |
|
|
|
|
| 76 |
|
| 77 |
card.innerHTML = `
|
| 78 |
<div class="result-card-header">
|
| 79 |
+
<span class="result-card-name">${_esc(result.product_id)}</span>
|
| 80 |
${statusBadge}
|
| 81 |
</div>
|
| 82 |
${placeholderBadge}
|
|
|
|
| 89 |
const panel = card.closest('.results-panel-body');
|
| 90 |
panel.querySelectorAll('.result-card').forEach(c => c.classList.remove('active'));
|
| 91 |
card.classList.add('active');
|
| 92 |
+
_showMapForProduct(result.product_id);
|
| 93 |
});
|
| 94 |
|
| 95 |
card.addEventListener('keydown', (e) => {
|
|
|
|
| 129 |
.replace(/"/g, '"');
|
| 130 |
}
|
| 131 |
|
| 132 |
+
function _showMapForProduct(productId) {
|
| 133 |
if (!_currentJobId || !_currentBbox) return;
|
| 134 |
+
const url = spatialUrl(_currentJobId, productId);
|
| 135 |
fetch(url, { headers: authHeaders() })
|
| 136 |
.then(res => {
|
| 137 |
if (res.ok) return res.json();
|
tests/conftest.py
CHANGED
|
@@ -41,13 +41,13 @@ def synthetic_monthly_raster(tmp_path):
|
|
| 41 |
|
| 42 |
|
| 43 |
@pytest.fixture
|
| 44 |
-
def
|
| 45 |
-
"""Factory for
|
| 46 |
-
from app.models import
|
| 47 |
|
| 48 |
def _make(**overrides):
|
| 49 |
defaults = dict(
|
| 50 |
-
|
| 51 |
headline="Test headline",
|
| 52 |
status=StatusLevel.GREEN,
|
| 53 |
trend=TrendDirection.STABLE,
|
|
@@ -64,6 +64,6 @@ def mock_indicator_result():
|
|
| 64 |
confidence_factors={},
|
| 65 |
)
|
| 66 |
defaults.update(overrides)
|
| 67 |
-
return
|
| 68 |
|
| 69 |
return _make
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
@pytest.fixture
|
| 44 |
+
def mock_product_result():
|
| 45 |
+
"""Factory for ProductResult with sensible defaults."""
|
| 46 |
+
from app.models import ProductResult, StatusLevel, TrendDirection, ConfidenceLevel
|
| 47 |
|
| 48 |
def _make(**overrides):
|
| 49 |
defaults = dict(
|
| 50 |
+
product_id="ndvi",
|
| 51 |
headline="Test headline",
|
| 52 |
status=StatusLevel.GREEN,
|
| 53 |
trend=TrendDirection.STABLE,
|
|
|
|
| 64 |
confidence_factors={},
|
| 65 |
)
|
| 66 |
defaults.update(overrides)
|
| 67 |
+
return ProductResult(**defaults)
|
| 68 |
|
| 69 |
return _make
|
tests/test_models.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
"""Tests for updated data models."""
|
| 2 |
-
from app.models import
|
| 3 |
|
| 4 |
|
| 5 |
def test_indicator_result_new_fields():
|
| 6 |
-
result =
|
| 7 |
-
|
| 8 |
headline="Test",
|
| 9 |
status=StatusLevel.AMBER,
|
| 10 |
trend=TrendDirection.DETERIORATING,
|
|
@@ -31,8 +31,8 @@ def test_indicator_result_new_fields():
|
|
| 31 |
|
| 32 |
|
| 33 |
def test_indicator_result_defaults_for_new_fields():
|
| 34 |
-
result =
|
| 35 |
-
|
| 36 |
headline="Test",
|
| 37 |
status=StatusLevel.GREEN,
|
| 38 |
trend=TrendDirection.STABLE,
|
|
|
|
| 1 |
"""Tests for updated data models."""
|
| 2 |
+
from app.models import ProductResult, StatusLevel, TrendDirection, ConfidenceLevel
|
| 3 |
|
| 4 |
|
| 5 |
def test_indicator_result_new_fields():
|
| 6 |
+
result = ProductResult(
|
| 7 |
+
product_id="ndvi",
|
| 8 |
headline="Test",
|
| 9 |
status=StatusLevel.AMBER,
|
| 10 |
trend=TrendDirection.DETERIORATING,
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def test_indicator_result_defaults_for_new_fields():
|
| 34 |
+
result = ProductResult(
|
| 35 |
+
product_id="ndvi",
|
| 36 |
headline="Test",
|
| 37 |
status=StatusLevel.GREEN,
|
| 38 |
trend=TrendDirection.STABLE,
|
tests/test_narrative.py
CHANGED
|
@@ -2,13 +2,13 @@
|
|
| 2 |
import pytest
|
| 3 |
|
| 4 |
|
| 5 |
-
def test_generate_narrative_includes_zscore_context(
|
| 6 |
"""Narrative references z-score context when anomaly data is present."""
|
| 7 |
from app.outputs.narrative import generate_narrative
|
| 8 |
|
| 9 |
results = [
|
| 10 |
-
|
| 11 |
-
|
| 12 |
status="amber",
|
| 13 |
headline="Vegetation decline (z=-1.8)",
|
| 14 |
z_score_current=-1.8,
|
|
|
|
| 2 |
import pytest
|
| 3 |
|
| 4 |
|
| 5 |
+
def test_generate_narrative_includes_zscore_context(mock_product_result):
|
| 6 |
"""Narrative references z-score context when anomaly data is present."""
|
| 7 |
from app.outputs.narrative import generate_narrative
|
| 8 |
|
| 9 |
results = [
|
| 10 |
+
mock_product_result(
|
| 11 |
+
product_id="ndvi",
|
| 12 |
status="amber",
|
| 13 |
headline="Vegetation decline (z=-1.8)",
|
| 14 |
z_score_current=-1.8,
|