KSvend Claude Happy commited on
Commit
df6bf75
Β·
1 Parent(s): 70fbdf4

refactor: rename "indicators" to "EO products" throughout

Browse files

Full 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 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 Earth observation indicators:
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
- "indicator_priorities": ["indicator_id", ...],
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
- "indicator_priorities": None,
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", "indicator_priorities", "reasoning"):
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 indicator_priorities to only known indicators
98
  valid_ids = {"ndvi", "water", "sar", "buildup"}
99
- advice["indicator_priorities"] = [
100
- i for i in advice["indicator_priorities"] if i in valid_ids
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-indicator compound signal detection."""
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(n_indicators: int, overlap_pct: float) -> str:
19
- if n_indicators >= 3 and overlap_pct > 20:
20
  return "strong"
21
- if n_indicators >= 2 and overlap_pct >= 10:
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 indicator z-score rasters."""
32
  decline: dict[str, np.ndarray] = {}
33
  increase: dict[str, np.ndarray] = {}
34
- for ind_id, z in zscore_rasters.items():
35
- decline[ind_id] = z < -threshold
36
- increase[ind_id] = z > threshold
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 indicators.
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 indicators.
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
- "indicator_count": len(j.request.indicator_ids),
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
- "indicator_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(),
 
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.indicators import registry
3
 
4
- router = APIRouter(prefix="/api/indicators", tags=["indicators"])
5
 
6
 
7
  @router.get("")
8
- async def list_indicators():
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-indicator native resolutions (meters)
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. Indicators not selected or skipped are excluded
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, IndicatorResult
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, indicator_id: str, indicator_status: 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[indicator_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: IndicatorResult) -> None:
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
- IndicatorResult.model_validate(r)
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, IndicatorResult, IndicatorMeta
11
 
12
 
13
  @dataclass
@@ -24,7 +24,7 @@ class SpatialData:
24
  vmax: float | None = None
25
 
26
 
27
- class BaseIndicator(abc.ABC):
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) -> IndicatorMeta:
39
- return IndicatorMeta(
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) -> IndicatorResult:
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 indicators."""
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
- ) -> IndicatorResult:
63
- """Download completed batch jobs and compute result. Override in batch indicators."""
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 IndicatorRegistry:
132
  def __init__(self) -> None:
133
- self._indicators: dict[str, BaseIndicator] = {}
134
 
135
- def register(self, indicator: BaseIndicator) -> None:
136
- self._indicators[indicator.id] = indicator
137
 
138
- def get(self, indicator_id: str) -> BaseIndicator:
139
- if indicator_id not in self._indicators:
140
- raise KeyError(f"Unknown indicator: {indicator_id}")
141
- return self._indicators[indicator_id]
142
 
143
  def list_ids(self) -> list[str]:
144
- return list(self._indicators.keys())
145
 
146
- def catalogue(self) -> list[IndicatorMeta]:
147
- return [ind.meta() for ind in self._indicators.values()]
 
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
- """Built-up / Settlement Extent Indicator β€” 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,11 +23,11 @@ from app.config import (
23
  ZSCORE_THRESHOLD,
24
  MIN_CLUSTER_PIXELS,
25
  )
26
- from app.indicators.base import BaseIndicator, SpatialData
27
  from app.models import (
28
  AOI,
29
  TimeRange,
30
- IndicatorResult,
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 BuiltupIndicator(BaseIndicator):
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
- ) -> IndicatorResult:
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._indicator_raster_path = change_map_path
253
  self._render_band = 1
254
 
255
- return IndicatorResult(
256
- indicator_id=self.id,
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._indicator_raster_path = current_path
322
  self._render_band = peak_band
323
 
324
- return IndicatorResult(
325
- indicator_id=self.id,
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
- ) -> IndicatorResult:
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
- ) -> IndicatorResult:
366
  import asyncio
367
 
368
  conn = get_connection()
@@ -521,11 +521,11 @@ class BuiltupIndicator(BaseIndicator):
521
  vmin=-1,
522
  vmax=1,
523
  )
524
- self._indicator_raster_path = change_map_path
525
  self._render_band = 1
526
 
527
- return IndicatorResult(
528
- indicator_id=self.id,
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 Vegetation Indicator β€” 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,11 +22,11 @@ from app.config import (
22
  ZSCORE_THRESHOLD,
23
  MIN_CLUSTER_PIXELS,
24
  )
25
- from app.indicators.base import BaseIndicator, SpatialData
26
  from app.models import (
27
  AOI,
28
  TimeRange,
29
- IndicatorResult,
30
  StatusLevel,
31
  TrendDirection,
32
  ConfidenceLevel,
@@ -46,7 +46,7 @@ logger = logging.getLogger(__name__)
46
  BASELINE_YEARS = 5
47
 
48
 
49
- class NdviIndicator(BaseIndicator):
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
- ) -> IndicatorResult:
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._indicator_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 IndicatorResult(
243
- indicator_id=self.id,
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
- ) -> IndicatorResult:
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
- ) -> IndicatorResult:
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._indicator_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 IndicatorResult(
424
- indicator_id=self.id,
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 Indicator β€” 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,11 +22,11 @@ from app.config import (
22
  ZSCORE_THRESHOLD,
23
  MIN_CLUSTER_PIXELS,
24
  )
25
- from app.indicators.base import BaseIndicator, SpatialData
26
  from app.models import (
27
  AOI,
28
  TimeRange,
29
- IndicatorResult,
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 SarIndicator(BaseIndicator):
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
- ) -> IndicatorResult:
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._indicator_raster_path = change_map_path
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._indicator_raster_path = current_path
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 IndicatorResult(
314
- indicator_id=self.id,
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
- ) -> IndicatorResult:
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
- ) -> IndicatorResult:
354
  import asyncio
355
 
356
  conn = get_connection()
@@ -524,11 +524,11 @@ class SarIndicator(BaseIndicator):
524
  vmin=-6,
525
  vmax=6,
526
  )
527
- self._indicator_raster_path = change_map_path
528
  self._render_band = 1
529
 
530
- return IndicatorResult(
531
- indicator_id=self.id,
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 Indicator β€” 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,11 +22,11 @@ from app.config import (
22
  ZSCORE_THRESHOLD,
23
  MIN_CLUSTER_PIXELS,
24
  )
25
- from app.indicators.base import BaseIndicator, SpatialData
26
  from app.models import (
27
  AOI,
28
  TimeRange,
29
- IndicatorResult,
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 WaterIndicator(BaseIndicator):
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
- ) -> IndicatorResult:
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._indicator_raster_path = current_path
251
  self._true_color_path = true_color_path
252
  self._render_band = current_stats["peak_water_band"]
253
 
254
- return IndicatorResult(
255
- indicator_id=self.id,
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
- ) -> IndicatorResult:
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
- ) -> IndicatorResult:
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._indicator_raster_path = current_path
433
  self._true_color_path = true_color_path
434
  self._render_band = current_stats["peak_water_band"]
435
 
436
- return IndicatorResult(
437
- indicator_id=self.id,
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.indicators import registry
13
  from app.api.jobs import router as jobs_router, init_router as init_jobs
14
- from app.api.indicators_api import router as indicators_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,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 indicators 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,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(indicators_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/{indicator_id}")
113
- async def get_indicator_map(job_id: str, indicator_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"{indicator_id}_map.png"
116
  if not map_path.exists():
117
- raise HTTPException(status_code=404, detail="Map not available for this indicator")
118
  return FileResponse(
119
  path=str(map_path),
120
  media_type="image/png",
121
  )
122
 
123
- @app.get("/api/jobs/{job_id}/spatial/{indicator_id}")
124
- async def get_indicator_spatial(job_id: str, indicator_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"{indicator_id}_spatial.json"
127
  if not spatial_path.exists():
128
- raise HTTPException(status_code=404, detail="Spatial data not available for this indicator")
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
- indicator_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,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("indicator_ids")
111
  @classmethod
112
- def require_at_least_one_indicator(cls, v: list[str]) -> list[str]:
113
  if len(v) == 0:
114
- raise ValueError("At least one indicator must be selected")
115
  return v
116
 
117
 
118
- class IndicatorResult(BaseModel):
119
- indicator_id: str
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[IndicatorResult] = Field(default_factory=list)
144
  error: str | None = None
145
 
146
 
147
- class IndicatorMeta(BaseModel):
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
- indicator_name: str,
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
- indicator_name:
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(indicator_name, "")
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"{indicator_name} {arrow}",
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.indicators.base import SpatialData
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 IndicatorResult, StatusLevel, TrendDirection
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 indicators.",
43
  StatusLevel.AMBER: "The situation shows elevated concern requiring monitoring.",
44
- StatusLevel.GREEN: "All indicators are within normal ranges for this area and period.",
45
  }
46
 
47
 
48
- def get_interpretation(indicator_id: str, status: StatusLevel) -> str:
49
- """Return a 1-2 sentence interpretation for the given indicator and status."""
50
  return _INTERPRETATIONS.get(
51
- (indicator_id, status),
52
- f"{indicator_id.replace('_', ' ').title()} status is {status.value}.",
53
  )
54
 
55
 
56
- def generate_narrative(results: Sequence[IndicatorResult]) -> str:
57
  """Generate a cross-indicator narrative paragraph from indicator results."""
58
  if not results:
59
- return "No indicator data available for narrative generation."
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.indicator_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,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 indicator set."
98
 
99
  triggered = [s for s in signals if s.triggered]
100
  if not triggered:
101
- return "No compound signals detected across the indicator set."
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 IndicatorResult, StatusLevel
9
 
10
  _STATUS_SCORES = {
11
  StatusLevel.GREEN: 100,
@@ -14,7 +14,7 @@ _STATUS_SCORES = {
14
  }
15
 
16
  # Display names for headline generation
17
- _INDICATOR_NAMES = {
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[IndicatorResult]) -> dict[str, Any]:
26
- """Compute weighted composite score from indicator results.
27
 
28
  Returns a dict with: score (0-100), status (GREEN/AMBER/RED),
29
- headline, weights_used, per_indicator breakdown.
30
  """
31
  if not results:
32
  return {
33
  "score": 0,
34
  "status": "RED",
35
- "headline": "Area conditions: RED (score 0/100) β€” no indicators available",
36
  "weights_used": {},
37
- "per_indicator": {},
38
  }
39
 
40
- # Gather scores for completed indicators
41
- per_indicator: dict[str, dict] = {}
42
  active_weights: dict[str, float] = {}
43
 
44
  for result in results:
45
- ind_id = result.indicator_id
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
- per_indicator[ind_id] = {
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] * per_indicator[ind_id]["score"]
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, per_indicator, 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_indicator": per_indicator,
90
  }
91
 
92
 
93
  def _build_headline(
94
  score: int, status: str,
95
- per_indicator: 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 indicators"
100
 
101
- # Find indicators contributing most to non-GREEN score
102
  impacts = []
103
- for ind_id, data in per_indicator.items():
104
  if data["score"] < 100:
105
  impact = weights.get(ind_id, 0) * (100 - data["score"])
106
- name = _INDICATOR_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 indicators"
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, IndicatorResult, StatusLevel
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 _indicator_label(indicator_id: str) -> str:
35
- return _DISPLAY_NAMES.get(indicator_id, indicator_id.replace("_", " ").title())
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 _indicator_block(
162
- result: IndicatorResult,
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.indicator_id, result.status)
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[IndicatorResult]) -> 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,11 +270,11 @@ def generate_pdf_report(
270
  *,
271
  aoi: AOI,
272
  time_range: TimeRange,
273
- results: Sequence[IndicatorResult],
274
  output_path: str,
275
  summary_map_path: str = "",
276
- indicator_map_paths: dict[str, str] | None = None,
277
- indicator_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,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> indicator(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,7 +433,7 @@ def generate_pdf_report(
433
 
434
  # Compact summary table
435
  summary_header = [
436
- Paragraph("<b>Indicator</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,7 +442,7 @@ def generate_pdf_report(
442
  ]
443
  summary_rows = [summary_header]
444
  for result in results:
445
- label = _indicator_label(result.indicator_id)
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 indicator set.",
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("Indicator Detail", styles["section_heading"]))
522
  story.append(Spacer(1, 2 * mm))
523
 
524
  for result in results:
525
- indicator_label = _indicator_label(result.indicator_id)
526
- map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
527
- hotspot_path = (indicator_hotspot_paths or {}).get(result.indicator_id, "")
528
 
529
  # Auto-detect chart path from output directory
530
- chart_path = os.path.join(output_dir, f"{result.indicator_id}_chart.png")
531
  if not os.path.exists(chart_path):
532
  chart_path = ""
533
 
534
- block = [Paragraph(indicator_label, styles["section_heading"])]
535
- block += _indicator_block(result, styles, map_path=map_path, chart_path=chart_path, hotspot_path=hotspot_path)
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
- indicator_label = _indicator_label(result.indicator_id)
548
  story.append(
549
  Paragraph(
550
- f"<b>{indicator_label}:</b> {result.methodology}",
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>Indicator</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,7 +569,7 @@ def generate_pdf_report(
569
  f = result.confidence_factors
570
  if f:
571
  conf_rows.append([
572
- Paragraph(_indicator_label(result.indicator_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,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 indicators."
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(indicator_id: str, metrics: dict) -> StatusLevel:
5
- classifier = THRESHOLDS.get(indicator_id)
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.indicators.base import IndicatorRegistry
10
  from app.models import JobStatus
11
- from app.outputs.report import generate_pdf_report, _indicator_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,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: IndicatorRegistry) -> None:
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 indicators
71
- batch_indicators = {}
72
- process_indicators = []
73
- for indicator_id in job.request.indicator_ids:
74
- indicator = registry.get(indicator_id)
75
- if indicator.uses_batch:
76
- batch_indicators[indicator_id] = indicator
77
  else:
78
- process_indicators.append((indicator_id, indicator))
79
 
80
- # -- Process batch indicators sequentially --
81
- for indicator_id, indicator in batch_indicators.items():
82
  # Submit
83
- await db.update_job_progress(job_id, indicator_id, "submitting")
84
- jobs = await indicator.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 {indicator_id} batch jobs: {job_ids}")
91
- await db.update_job_progress(job_id, indicator_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,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 {indicator_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", indicator_id)
105
  break
106
  elif any(s in ("error", "canceled") for s in statuses):
107
- logger.warning("Batch job failed for %s: %s", indicator_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] {indicator_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", indicator_id)
118
- print(f"[Aperture] {indicator_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, indicator_id)
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 {indicator_id}: {failed_statuses}"
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, indicator_id, "downloading")
142
- result = await indicator.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 = indicator.get_spatial_data()
150
  if spatial is not None:
151
- spatial_cache[indicator_id] = spatial
152
- print(f"[Aperture] Saving result for {indicator_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, indicator_id, "complete")
155
 
156
- # -- Process non-batch indicators --
157
- for indicator_id, indicator in process_indicators:
158
- await db.update_job_progress(job_id, indicator_id, "processing")
159
- result = await indicator.process(
160
  job.request.aoi,
161
  job.request.time_range,
162
  season_months=job.request.season_months(),
163
  )
164
- spatial = indicator.get_spatial_data()
165
  if spatial is not None:
166
- spatial_cache[indicator_id] = spatial
167
  await db.save_job_result(job_id, result)
168
- await db.update_job_progress(job_id, indicator_id, "complete")
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=_indicator_label(result.indicator_id),
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
- indicator_obj = registry.get(result.indicator_id)
196
- raster_path = getattr(indicator_obj, '_indicator_raster_path', None)
197
- true_color_path = getattr(indicator_obj, '_true_color_path', None)
198
- render_band = getattr(indicator_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,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
- indicator_hotspot_paths = {}
234
  for result in job.results:
235
- indicator_obj = registry.get(result.indicator_id)
236
- zscore_raster = getattr(indicator_obj, '_zscore_raster', None)
237
- hotspot_mask = getattr(indicator_obj, '_hotspot_mask', None)
238
- true_color_path_ind = getattr(indicator_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(indicator_obj, '_indicator_raster_path', None)
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
- indicator_hotspot_paths[result.indicator_id] = hotspot_path
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
- indicator_obj = registry.get(result.indicator_id)
272
- z = getattr(indicator_obj, '_zscore_raster', None)
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 indicator instances to free memory
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
- indicator_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
- indicator_map_paths[result.indicator_id] = mp
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 indicator, or skip
336
  overview_map_path = os.path.join(results_dir, "overview_map.png")
337
  true_color_path = None
338
- for ind_id in job.request.indicator_ids:
339
- ind_obj = registry.get(ind_id)
340
- tc = getattr(ind_obj, '_true_color_path', None)
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
- indicator_map_paths=indicator_map_paths,
364
- indicator_hotspot_paths=indicator_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,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: IndicatorRegistry) -> None:
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
- /* Indicators: full column */
45
- #page-indicators {
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 indicators, get evidence-grade
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="indicators"></span>
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 indicators
297
  </button>
298
  </div>
299
 
@@ -308,9 +308,9 @@
308
 
309
 
310
  <!-- ═══════════════════════════════════════════════════════════
311
- PAGE 3 β€” CHOOSE INDICATORS
312
  ═══════════════════════════════════════════════════════════ -->
313
- <div id="page-indicators" class="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="indicators"></span>
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="indicators-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);">Choose indicators</h1>
338
  <p style="font-size: var(--text-xs); color: var(--ink-muted); margin-bottom: var(--space-8);">
339
- Select the satellite-derived indicators to include in your analysis.
340
  </p>
341
  </div>
342
 
343
- <!-- Indicator grid (scrolls) -->
344
- <div id="indicators-grid" class="indicators-grid"></div>
345
 
346
  <!-- Sticky summary bar -->
347
  <div class="indicators-summary-bar">
348
- <span id="indicators-summary-text" class="summary-text">
349
- Select at least one indicator to continue.
350
  </span>
351
- <button id="indicators-continue-btn" class="btn btn-primary" disabled>
352
  Continue
353
  </button>
354
  </div>
355
 
356
- </div><!-- /page-indicators -->
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="indicators"></span>
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">Indicators</span>
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 indicators are being processed. This page updates automatically.</p>
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/indicators"
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
- /* ── Indicators ─────────────────────────────────────────── */
39
 
40
  /**
41
- * List all available indicators.
42
  * @returns {Promise<Array<{id, name, category, question, estimated_minutes}>>}
43
  */
44
- export async function listIndicators() {
45
- return apiFetch('/api/indicators');
46
  }
47
 
48
  /* ── Jobs ────────────────────────────────────────────────── */
49
 
50
  /**
51
  * Submit a new analysis job.
52
- * @param {{aoi, time_range, indicator_ids, email}} payload
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 indicator map PNG.
118
  * @param {string} jobId
119
- * @param {string} indicatorId
120
  * @returns {string}
121
  */
122
- export function mapUrl(jobId, indicatorId) {
123
- return `${BASE}/api/jobs/${jobId}/maps/${indicatorId}`;
124
  }
125
 
126
  /**
127
- * Returns the URL for indicator spatial data JSON.
128
  * @param {string} jobId
129
- * @param {string} indicatorId
130
  * @returns {string}
131
  */
132
- export function spatialUrl(jobId, indicatorId) {
133
- return `${BASE}/api/jobs/${jobId}/spatial/${indicatorId}`;
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, indicator_priorities, reasoning} | null>}
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 { initIndicators, getSelectedIds } from './indicators.js';
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
- indicators: [], // string[]
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', 'indicators', 'confirm', 'status', 'results', 'history'];
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', 'indicators', 'confirm', 'status', 'results', 'history'];
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
- 'indicators': setupIndicators,
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('indicators');
290
  }, { once: true });
291
  }
292
 
293
- /* Indicators ──────────────────────────────────────────────── */
294
 
295
- function setupIndicators() {
296
- updateSteps('indicators');
297
 
298
- const gridEl = document.getElementById('indicators-grid');
299
- const summaryEl = document.getElementById('indicators-summary-text');
300
- const continueBtn = document.getElementById('indicators-continue-btn');
301
- const backBtn = document.getElementById('indicators-back-btn');
302
 
303
  backBtn.addEventListener('click', () => navigate('define-area'), { once: true });
304
 
305
  continueBtn.addEventListener('click', () => {
306
- state.indicators = getSelectedIds();
307
  navigate('confirm');
308
  }, { once: true });
309
 
310
- initIndicators(gridEl, summaryEl, continueBtn, (ids) => {
311
- state.indicators = ids;
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.indicators.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,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('indicators'), { once: true });
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
- indicator_ids: state.indicators,
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.indicator_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,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} indicator${job.indicator_count !== 1 ? 's' : ''}</div>
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', 'indicators', 'confirm'];
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 β€” Indicator Card Selection
3
- * Renders and manages the indicator grid on the Choose Indicators page.
4
  */
5
 
6
- import { listIndicators } from './api.js';
7
 
8
- let _indicators = [];
9
  let _selected = new Set();
10
  let _onSelectionChange = null; // callback(selectedIds[])
11
 
12
  /**
13
- * Load indicators from the API and render cards into the grid element.
14
- * @param {HTMLElement} gridEl - Container for indicator 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 indicator IDs array
18
  */
19
- export async function initIndicators(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 indicators…</p>';
26
 
27
  try {
28
- _indicators = await listIndicators();
29
  } catch (err) {
30
- gridEl.innerHTML = `<p class="text-muted text-sm" style="grid-column:1/-1; padding: 20px 0;">Failed to load indicators: ${err.message}</p>`;
31
  return;
32
  }
33
 
34
  gridEl.innerHTML = '';
35
 
36
- for (const ind of _indicators) {
37
- const card = _buildCard(ind);
38
  card.addEventListener('click', () => {
39
- _toggleCard(ind.id, card);
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 indicator IDs.
48
  * @returns {string[]}
49
  */
50
  export function getSelectedIds() {
@@ -53,20 +53,20 @@ export function getSelectedIds() {
53
 
54
  /* ── Internal helpers ────────────────────────────────────── */
55
 
56
- function _buildCard(ind) {
57
  const card = document.createElement('div');
58
  card.className = 'indicator-card';
59
- card.dataset.id = ind.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(ind.name)}</div>
66
- <div class="indicator-card-question">${_esc(ind.question)}</div>
67
  <div class="indicator-card-meta">
68
- <span class="indicator-card-category">${_esc(ind.category)}</span>
69
- <span class="indicator-card-time font-data">~${ind.estimated_minutes} min</span>
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 = _indicators
101
- .filter(i => _selected.has(i.id))
102
- .reduce((sum, i) => sum + i.estimated_minutes, 0);
103
 
104
  if (count === 0) {
105
- summaryEl.innerHTML = 'Select at least one indicator to continue.';
106
  continueBtn.disabled = true;
107
  } else {
108
- summaryEl.innerHTML = `<strong>${count} indicator${count !== 1 ? 's' : ''} selected</strong> β€” estimated delivery: ~${totalMinutes} minute${totalMinutes !== 1 ? 's' : ''}`;
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 indicator result cards and manages the active-card state.
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 IndicatorResult objects
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
- _showMapForIndicator(first.dataset.indicatorId);
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.indicatorId = result.indicator_id;
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.indicator_id)}</span>
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
- _showMapForIndicator(result.indicator_id);
93
  });
94
 
95
  card.addEventListener('keydown', (e) => {
@@ -129,9 +129,9 @@ function _esc(str) {
129
  .replace(/"/g, '&quot;');
130
  }
131
 
132
- function _showMapForIndicator(indicatorId) {
133
  if (!_currentJobId || !_currentBbox) return;
134
- const url = spatialUrl(_currentJobId, indicatorId);
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, '&quot;');
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 mock_indicator_result():
45
- """Factory for IndicatorResult with sensible defaults."""
46
- from app.models import IndicatorResult, StatusLevel, TrendDirection, ConfidenceLevel
47
 
48
  def _make(**overrides):
49
  defaults = dict(
50
- indicator_id="ndvi",
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 IndicatorResult(**defaults)
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 IndicatorResult, StatusLevel, TrendDirection, ConfidenceLevel
3
 
4
 
5
  def test_indicator_result_new_fields():
6
- result = IndicatorResult(
7
- indicator_id="ndvi",
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 = IndicatorResult(
35
- indicator_id="ndvi",
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(mock_indicator_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_indicator_result(
11
- indicator_id="ndvi",
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,