KSvend Claude Happy commited on
Commit
3d48a34
·
1 Parent(s): 9d401d9

feat: upgrade Water, SAR, and Settlement indicators with seasonal analysis

Browse files

Apply the same analytical upgrade pattern from NDVI to all three remaining
batch indicators:
- Seasonal baselines via compute_seasonal_stats_aoi
- Per-month z-score anomaly detection
- Pixel-level hotspot detection with compute_zscore_raster
- Four-factor confidence scoring
- Native resolution (Water 20m, SAR 10m, Settlement 20m)
- Seasonal envelope chart data with anomaly flags

SAR uses custom seasonal stats from VV means (interleaved VV/VH bands
can't go through compute_seasonal_stats_aoi directly).

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>

Files changed (3) hide show
  1. app/indicators/buildup.py +334 -108
  2. app/indicators/sar.py +415 -115
  3. app/indicators/water.py +296 -92
app/indicators/buildup.py CHANGED
@@ -1,8 +1,9 @@
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 NDVI < 0.2), and tracks settlement extent change
5
- against a baseline period.
 
6
  """
7
  from __future__ import annotations
8
 
@@ -15,7 +16,13 @@ from typing import Any
15
  import numpy as np
16
  import rasterio
17
 
18
- from app.config import RESOLUTION_M
 
 
 
 
 
 
19
  from app.indicators.base import BaseIndicator, SpatialData
20
  from app.models import (
21
  AOI,
@@ -26,6 +33,14 @@ from app.models import (
26
  ConfidenceLevel,
27
  )
28
  from app.openeo_client import get_connection, build_buildup_graph, build_true_color_graph, _bbox_dict, submit_as_batch
 
 
 
 
 
 
 
 
29
 
30
  logger = logging.getLogger(__name__)
31
 
@@ -57,22 +72,26 @@ class BuiltupIndicator(BaseIndicator):
57
  time_range.start.month,
58
  time_range.start.day,
59
  ).isoformat()
60
- baseline_end = time_range.start.isoformat()
 
 
 
 
61
 
62
  current_cube = build_buildup_graph(
63
  conn=conn, bbox=bbox,
64
  temporal_extent=[current_start, current_end],
65
- resolution_m=RESOLUTION_M,
66
  )
67
  baseline_cube = build_buildup_graph(
68
  conn=conn, bbox=bbox,
69
  temporal_extent=[baseline_start, baseline_end],
70
- resolution_m=RESOLUTION_M,
71
  )
72
  true_color_cube = build_true_color_graph(
73
  conn=conn, bbox=bbox,
74
  temporal_extent=[current_start, current_end],
75
- resolution_m=RESOLUTION_M,
76
  )
77
 
78
  return [
@@ -121,44 +140,105 @@ class BuiltupIndicator(BaseIndicator):
121
 
122
  self._true_color_path = true_color_path
123
 
 
124
  current_stats = self._compute_stats(current_path)
 
125
  current_frac = current_stats["overall_buildup_fraction"]
 
126
  aoi_ha = aoi.area_km2 * 100 # km² → hectares
127
  current_ha = current_frac * aoi_ha
128
 
 
 
129
  if baseline_path:
 
130
  baseline_stats = self._compute_stats(baseline_path)
131
  baseline_frac = baseline_stats["overall_buildup_fraction"]
132
  baseline_ha = baseline_frac * aoi_ha
133
 
134
- if baseline_frac > 0:
135
- change_pct = ((current_frac - baseline_frac) / baseline_frac) * 100
136
- else:
137
- change_pct = 100.0 if current_frac > 0 else 0.0
138
 
139
- confidence = (
140
- ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
141
- else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
142
- else ConfidenceLevel.LOW
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  )
 
 
144
 
145
- chart_data = self._build_chart_data(
146
- current_stats["monthly_buildup_fractions"],
147
- baseline_stats["monthly_buildup_fractions"],
148
- time_range,
149
- aoi_ha,
150
- )
151
 
152
- status = self._classify(change_pct)
153
- trend = self._compute_trend(change_pct)
 
 
154
 
155
- if abs(change_pct) < 10:
156
- headline = f"Built-up extent stable at approximately {current_ha:.0f} ha"
157
- elif change_pct > 0:
158
- headline = f"Settlement area expanded {change_pct:.0f}% ({baseline_ha:.0f} → {current_ha:.0f} ha) compared to baseline"
 
 
 
 
 
 
 
159
  else:
160
- headline = f"Potential settlement contraction: {abs(change_pct):.0f}% decrease in built-up area"
 
 
 
161
 
 
162
  change_map_path = os.path.join(results_dir, "buildup_change.tif")
163
  self._write_change_raster(current_path, baseline_path, change_map_path)
164
 
@@ -181,31 +261,46 @@ class BuiltupIndicator(BaseIndicator):
181
  map_layer_path=change_map_path,
182
  chart_data=chart_data,
183
  data_source="satellite",
 
 
 
 
184
  summary=(
185
  f"Built-up area covers {current_frac*100:.1f}% of the AOI "
186
- f"({current_ha:.0f} ha) compared to {baseline_frac*100:.1f}% baseline "
187
- f"({baseline_ha:.0f} ha), a {change_pct:+.1f}% change. "
188
- f"Pixel-level NDBI analysis at {RESOLUTION_M}m resolution."
 
 
189
  ),
190
  methodology=(
191
- f"Sentinel-2 L2A pixel-level NDBI = (B11 B08) / (B11 + B08). "
192
  f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
193
  f"Cloud-masked using SCL band. "
194
- f"Monthly median composites at {RESOLUTION_M}m. "
195
- f"Baseline: {BASELINE_YEARS}-year built-up extent. "
 
196
  f"Processed via CDSE openEO batch jobs."
197
  ),
198
  limitations=[
199
- f"Resampled to {RESOLUTION_M}m detects settlement extent, not individual buildings.",
200
  "NDBI may confuse bare rock/sand with built-up in arid landscapes.",
201
  "Seasonal vegetation cycles can cause false positives at settlement fringes.",
202
  "For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed.",
 
203
  ],
204
  )
205
  else:
206
  # Degraded mode — no baseline
207
- change_pct = 0.0
 
 
208
  confidence = ConfidenceLevel.LOW
 
 
 
 
 
209
  peak_band = current_stats["peak_buildup_band"]
210
 
211
  chart_data = {
@@ -214,7 +309,7 @@ class BuiltupIndicator(BaseIndicator):
214
  "label": "Built-up area (ha)",
215
  }
216
 
217
- headline = f"Built-up extent: {current_frac*100:.1f}% baseline unavailable"
218
 
219
  self._spatial_data = SpatialData(
220
  map_type="raster",
@@ -229,30 +324,34 @@ class BuiltupIndicator(BaseIndicator):
229
  return IndicatorResult(
230
  indicator_id=self.id,
231
  headline=headline,
232
- status=self._classify(change_pct),
233
- trend=TrendDirection.STABLE,
234
  confidence=confidence,
235
  map_layer_path=current_path,
236
  chart_data=chart_data,
237
  data_source="satellite",
 
 
 
 
238
  summary=(
239
  f"Built-up area covers {current_frac*100:.1f}% of the AOI "
240
- f"({current_ha:.0f} ha). Baseline unavailable change not computed. "
241
- f"Pixel-level NDBI analysis at {RESOLUTION_M}m resolution."
242
  ),
243
  methodology=(
244
- f"Sentinel-2 L2A pixel-level NDBI = (B11 B08) / (B11 + B08). "
245
  f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
246
  f"Cloud-masked using SCL band. "
247
- f"Monthly median composites at {RESOLUTION_M}m. "
248
  f"Processed via CDSE openEO batch jobs."
249
  ),
250
  limitations=[
251
- f"Resampled to {RESOLUTION_M}m detects settlement extent, not individual buildings.",
252
  "NDBI may confuse bare rock/sand with built-up in arid landscapes.",
253
  "Seasonal vegetation cycles can cause false positives at settlement fringes.",
254
  "For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed.",
255
- "Baseline unavailable change and trend not computed.",
256
  ],
257
  )
258
 
@@ -276,24 +375,28 @@ class BuiltupIndicator(BaseIndicator):
276
  time_range.start.month,
277
  time_range.start.day,
278
  ).isoformat()
279
- baseline_end = time_range.start.isoformat()
 
 
 
 
280
 
281
  results_dir = tempfile.mkdtemp(prefix="aperture_buildup_")
282
 
283
  current_cube = build_buildup_graph(
284
  conn=conn, bbox=bbox,
285
  temporal_extent=[current_start, current_end],
286
- resolution_m=RESOLUTION_M,
287
  )
288
  baseline_cube = build_buildup_graph(
289
  conn=conn, bbox=bbox,
290
  temporal_extent=[baseline_start, baseline_end],
291
- resolution_m=RESOLUTION_M,
292
  )
293
  true_color_cube = build_true_color_graph(
294
  conn=conn, bbox=bbox,
295
  temporal_extent=[current_start, current_end],
296
- resolution_m=RESOLUTION_M,
297
  )
298
 
299
  loop = asyncio.get_event_loop()
@@ -301,7 +404,6 @@ class BuiltupIndicator(BaseIndicator):
301
  baseline_path = os.path.join(results_dir, "ndbi_baseline.tif")
302
  true_color_path = os.path.join(results_dir, "true_color.tif")
303
 
304
- # 10-minute timeout per download
305
  timeout = 600
306
  await asyncio.wait_for(
307
  loop.run_in_executor(None, current_cube.download, current_path), timeout=timeout
@@ -315,44 +417,99 @@ class BuiltupIndicator(BaseIndicator):
315
 
316
  self._true_color_path = true_color_path
317
 
 
318
  current_stats = self._compute_stats(current_path)
319
  baseline_stats = self._compute_stats(baseline_path)
320
-
321
  current_frac = current_stats["overall_buildup_fraction"]
322
- baseline_frac = baseline_stats["overall_buildup_fraction"]
323
-
324
- # Convert fractions to area using AOI size
325
  aoi_ha = aoi.area_km2 * 100 # km² → hectares
326
  current_ha = current_frac * aoi_ha
 
327
  baseline_ha = baseline_frac * aoi_ha
328
 
329
- if baseline_frac > 0:
330
- change_pct = ((current_frac - baseline_frac) / baseline_frac) * 100
 
 
 
 
 
 
 
 
331
  else:
332
- change_pct = 100.0 if current_frac > 0 else 0.0
333
-
334
- status = self._classify(change_pct)
335
- trend = self._compute_trend(change_pct)
336
- confidence = (
337
- ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
338
- else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
339
- else ConfidenceLevel.LOW
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  )
341
-
342
- chart_data = self._build_chart_data(
343
- current_stats["monthly_buildup_fractions"],
344
- baseline_stats["monthly_buildup_fractions"],
345
- time_range,
346
- aoi_ha,
 
 
347
  )
348
 
349
- # Headline
350
- if abs(change_pct) < 10:
351
- headline = f"Built-up extent stable at approximately {current_ha:.0f} ha"
352
- elif change_pct > 0:
353
- headline = f"Settlement area expanded {change_pct:.0f}% ({baseline_ha:.0f} → {current_ha:.0f} ha) compared to baseline"
 
 
 
 
 
 
354
  else:
355
- headline = f"Potential settlement contraction: {abs(change_pct):.0f}% decrease in built-up area"
 
 
 
356
 
357
  # Write change raster for map rendering
358
  change_map_path = os.path.join(results_dir, "buildup_change.tif")
@@ -377,31 +534,39 @@ class BuiltupIndicator(BaseIndicator):
377
  map_layer_path=change_map_path,
378
  chart_data=chart_data,
379
  data_source="satellite",
 
 
 
 
380
  summary=(
381
  f"Built-up area covers {current_frac*100:.1f}% of the AOI "
382
- f"({current_ha:.0f} ha) compared to {baseline_frac*100:.1f}% baseline "
383
- f"({baseline_ha:.0f} ha), a {change_pct:+.1f}% change. "
384
- f"Pixel-level NDBI analysis at {RESOLUTION_M}m resolution."
 
 
385
  ),
386
  methodology=(
387
- f"Sentinel-2 L2A pixel-level NDBI = (B11 B08) / (B11 + B08). "
388
  f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
389
  f"Cloud-masked using SCL band. "
390
- f"Monthly median composites at {RESOLUTION_M}m. "
391
- f"Baseline: {BASELINE_YEARS}-year built-up extent. "
392
- f"Processed via CDSE openEO."
 
393
  ),
394
  limitations=[
395
- f"Resampled to {RESOLUTION_M}m detects settlement extent, not individual buildings.",
396
  "NDBI may confuse bare rock/sand with built-up in arid landscapes.",
397
  "Seasonal vegetation cycles can cause false positives at settlement fringes.",
398
  "For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed.",
 
399
  ],
400
  )
401
 
402
  @staticmethod
403
  def _compute_stats(tif_path: str) -> dict[str, Any]:
404
- """Extract monthly built-up fraction from NDBI GeoTIFF.
405
 
406
  Built-up = NDBI > 0 (simplified; full pipeline would also check NDVI < 0.2
407
  but the graph builder only outputs NDBI).
@@ -409,6 +574,7 @@ class BuiltupIndicator(BaseIndicator):
409
  with rasterio.open(tif_path) as src:
410
  n_bands = src.count
411
  monthly_fractions: list[float] = []
 
412
  peak_frac = -1.0
413
  peak_band = 1
414
  for band in range(1, n_bands + 1):
@@ -421,53 +587,114 @@ class BuiltupIndicator(BaseIndicator):
421
  if len(valid) > 0:
422
  buildup_pixels = np.sum(valid > NDBI_THRESHOLD)
423
  frac = float(buildup_pixels / len(valid))
 
424
  monthly_fractions.append(frac)
 
425
  if frac > peak_frac:
426
  peak_frac = frac
427
  peak_band = band
428
  else:
429
  monthly_fractions.append(0.0)
 
430
 
431
- overall = float(np.mean(monthly_fractions)) if monthly_fractions else 0.0
 
 
 
 
 
432
 
433
  return {
434
  "monthly_buildup_fractions": monthly_fractions,
435
- "overall_buildup_fraction": overall,
436
- "valid_months": len(monthly_fractions),
 
437
  "peak_buildup_band": peak_band,
 
 
438
  }
439
 
440
  @staticmethod
441
- def _classify(change_pct: float) -> StatusLevel:
442
- abs_change = abs(change_pct)
443
- if abs_change < 10:
444
- return StatusLevel.GREEN
445
- if abs_change < 30:
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  return StatusLevel.AMBER
447
- return StatusLevel.RED
448
 
449
  @staticmethod
450
- def _compute_trend(change_pct: float) -> TrendDirection:
451
- if abs(change_pct) < 10:
 
 
 
 
 
452
  return TrendDirection.STABLE
453
- if abs(change_pct) >= 30:
 
 
454
  return TrendDirection.DETERIORATING
 
 
455
  return TrendDirection.STABLE
456
 
457
  @staticmethod
458
- def _build_chart_data(
459
- current_monthly: list[float],
460
- baseline_monthly: list[float],
461
  time_range: TimeRange,
 
462
  aoi_ha: float,
463
  ) -> dict[str, Any]:
 
 
 
464
  year = time_range.end.year
465
- n = min(len(current_monthly), len(baseline_monthly))
466
- dates = [f"{year}-{m + 1:02d}" for m in range(n)]
467
- values = [round(v * aoi_ha, 1) for v in current_monthly[:n]]
468
- b_mean = [round(v * aoi_ha, 1) for v in baseline_monthly[:n]]
469
- b_min = [round(max(v * aoi_ha - aoi_ha * 0.02, 0), 1) for v in baseline_monthly[:n]]
470
- b_max = [round(v * aoi_ha + aoi_ha * 0.02, 1) for v in baseline_monthly[:n]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
  return {
473
  "dates": dates,
@@ -475,6 +702,7 @@ class BuiltupIndicator(BaseIndicator):
475
  "baseline_mean": b_mean,
476
  "baseline_min": b_min,
477
  "baseline_max": b_max,
 
478
  "label": "Built-up area (ha)",
479
  }
480
 
@@ -497,5 +725,3 @@ class BuiltupIndicator(BaseIndicator):
497
  profile.update(count=1, dtype="float32")
498
  with rasterio.open(output_path, "w", **profile) as dst:
499
  dst.write(change, 1)
500
-
501
-
 
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
5
+ baseline using z-score analysis, hotspot detection, and four-factor
6
+ confidence scoring.
7
  """
8
  from __future__ import annotations
9
 
 
16
  import numpy as np
17
  import rasterio
18
 
19
+ from app.config import (
20
+ BUILDUP_RESOLUTION_M,
21
+ TRUECOLOR_RESOLUTION_M,
22
+ MIN_STD_BUILDUP,
23
+ ZSCORE_THRESHOLD,
24
+ MIN_CLUSTER_PIXELS,
25
+ )
26
  from app.indicators.base import BaseIndicator, SpatialData
27
  from app.models import (
28
  AOI,
 
33
  ConfidenceLevel,
34
  )
35
  from app.openeo_client import get_connection, build_buildup_graph, build_true_color_graph, _bbox_dict, submit_as_batch
36
+ from app.analysis.seasonal import (
37
+ group_bands_by_calendar_month,
38
+ compute_seasonal_stats_aoi,
39
+ compute_seasonal_stats_pixel,
40
+ compute_zscore,
41
+ )
42
+ from app.analysis.change import compute_zscore_raster, detect_hotspots, cluster_hotspots
43
+ from app.analysis.confidence import compute_confidence
44
 
45
  logger = logging.getLogger(__name__)
46
 
 
72
  time_range.start.month,
73
  time_range.start.day,
74
  ).isoformat()
75
+ baseline_end = date(
76
+ time_range.start.year,
77
+ time_range.start.month,
78
+ time_range.start.day,
79
+ ).isoformat()
80
 
81
  current_cube = build_buildup_graph(
82
  conn=conn, bbox=bbox,
83
  temporal_extent=[current_start, current_end],
84
+ resolution_m=BUILDUP_RESOLUTION_M,
85
  )
86
  baseline_cube = build_buildup_graph(
87
  conn=conn, bbox=bbox,
88
  temporal_extent=[baseline_start, baseline_end],
89
+ resolution_m=BUILDUP_RESOLUTION_M,
90
  )
91
  true_color_cube = build_true_color_graph(
92
  conn=conn, bbox=bbox,
93
  temporal_extent=[current_start, current_end],
94
+ resolution_m=TRUECOLOR_RESOLUTION_M,
95
  )
96
 
97
  return [
 
140
 
141
  self._true_color_path = true_color_path
142
 
143
+ # --- Seasonal baseline analysis ---
144
  current_stats = self._compute_stats(current_path)
145
+ current_mean = current_stats["overall_mean"]
146
  current_frac = current_stats["overall_buildup_fraction"]
147
+ n_current_bands = current_stats["valid_months"]
148
  aoi_ha = aoi.area_km2 * 100 # km² → hectares
149
  current_ha = current_frac * aoi_ha
150
 
151
+ spatial_completeness = self._compute_spatial_completeness(current_path)
152
+
153
  if baseline_path:
154
+ seasonal_stats = compute_seasonal_stats_aoi(baseline_path, n_years=BASELINE_YEARS)
155
  baseline_stats = self._compute_stats(baseline_path)
156
  baseline_frac = baseline_stats["overall_buildup_fraction"]
157
  baseline_ha = baseline_frac * aoi_ha
158
 
159
+ start_month = time_range.start.month
160
+ most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
 
 
161
 
162
+ # Z-score for overall current mean NDBI vs seasonal baseline
163
+ if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
164
+ s = seasonal_stats[most_recent_month]
165
+ z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_BUILDUP)
166
+ else:
167
+ z_current = 0.0
168
+
169
+ # Per-month z-scores and anomaly count
170
+ anomaly_months = 0
171
+ monthly_zscores = []
172
+ for i, val in enumerate(current_stats["monthly_means"]):
173
+ cal_month = ((start_month + i - 1) % 12) + 1
174
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
175
+ z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
176
+ seasonal_stats[cal_month]["std"], MIN_STD_BUILDUP)
177
+ monthly_zscores.append(z)
178
+ if abs(z) > ZSCORE_THRESHOLD:
179
+ anomaly_months += 1
180
+ else:
181
+ monthly_zscores.append(0.0)
182
+
183
+ # Pixel-level hotspot detection
184
+ month_map = group_bands_by_calendar_month(baseline_stats["valid_months_total"], BASELINE_YEARS)
185
+ hotspot_pct = 0.0
186
+ self._zscore_raster = None
187
+ self._hotspot_mask = None
188
+ if most_recent_month in month_map and len(month_map[most_recent_month]) > 0:
189
+ pixel_stats = compute_seasonal_stats_pixel(baseline_path, month_map[most_recent_month])
190
+ with rasterio.open(current_path) as src:
191
+ current_band_idx = min(n_current_bands, src.count)
192
+ current_data = src.read(current_band_idx).astype(np.float32)
193
+ if src.nodata is not None:
194
+ current_data[current_data == src.nodata] = np.nan
195
+
196
+ z_raster = compute_zscore_raster(current_data, pixel_stats["mean"],
197
+ pixel_stats["std"], MIN_STD_BUILDUP)
198
+ hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
199
+ self._zscore_raster = z_raster
200
+ self._hotspot_mask = hotspot_mask
201
+
202
+ # Four-factor confidence scoring
203
+ baseline_depth = sum(1 for m in range(1, 13)
204
+ if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
205
+ mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
206
+ if m in seasonal_stats) / max(baseline_depth, 1))
207
+ conf = compute_confidence(
208
+ valid_months=n_current_bands,
209
+ mean_obs_per_composite=5.0,
210
+ baseline_years_with_data=int(mean_baseline_years),
211
+ spatial_completeness=spatial_completeness,
212
  )
213
+ confidence = conf["level"]
214
+ confidence_factors = conf["factors"]
215
 
216
+ status = self._classify_zscore(z_current, hotspot_pct)
217
+ trend = self._compute_trend_zscore(monthly_zscores)
 
 
 
 
218
 
219
+ chart_data = self._build_seasonal_chart_data(
220
+ current_stats["monthly_buildup_fractions"], seasonal_stats,
221
+ time_range, monthly_zscores, aoi_ha,
222
+ )
223
 
224
+ # Headline z-score driven, with hectare context
225
+ if abs(z_current) <= 1.0:
226
+ headline = (
227
+ f"Settlement extent stable at ~{current_ha:.0f} ha "
228
+ f"(z={z_current:+.1f})"
229
+ )
230
+ elif z_current > 0:
231
+ headline = (
232
+ f"Settlement expansion detected: {current_ha:.0f} ha "
233
+ f"(z={z_current:+.1f} above seasonal baseline)"
234
+ )
235
  else:
236
+ headline = (
237
+ f"Settlement contraction detected: {current_ha:.0f} ha "
238
+ f"(z={z_current:+.1f} below seasonal baseline)"
239
+ )
240
 
241
+ # Write change raster for map rendering
242
  change_map_path = os.path.join(results_dir, "buildup_change.tif")
243
  self._write_change_raster(current_path, baseline_path, change_map_path)
244
 
 
261
  map_layer_path=change_map_path,
262
  chart_data=chart_data,
263
  data_source="satellite",
264
+ anomaly_months=anomaly_months,
265
+ z_score_current=round(z_current, 2),
266
+ hotspot_pct=round(hotspot_pct, 1),
267
+ confidence_factors=confidence_factors,
268
  summary=(
269
  f"Built-up area covers {current_frac*100:.1f}% of the AOI "
270
+ f"({current_ha:.0f} ha), mean NDBI {current_mean:.3f} "
271
+ f"(z-score {z_current:+.1f} vs seasonal baseline). "
272
+ f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
273
+ f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
274
+ f"Pixel-level NDBI analysis at {BUILDUP_RESOLUTION_M}m resolution."
275
  ),
276
  methodology=(
277
+ f"Sentinel-2 L2A pixel-level NDBI = (B11 \u2212 B08) / (B11 + B08). "
278
  f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
279
  f"Cloud-masked using SCL band. "
280
+ f"Monthly median composites at {BUILDUP_RESOLUTION_M}m native resolution. "
281
+ f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
282
+ f"Anomaly detection via z-scores (threshold: \u00b1{ZSCORE_THRESHOLD}). "
283
  f"Processed via CDSE openEO batch jobs."
284
  ),
285
  limitations=[
286
+ f"Resampled to {BUILDUP_RESOLUTION_M}m \u2014 detects settlement extent, not individual buildings.",
287
  "NDBI may confuse bare rock/sand with built-up in arid landscapes.",
288
  "Seasonal vegetation cycles can cause false positives at settlement fringes.",
289
  "For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed.",
290
+ "Z-score anomalies assume baseline is representative of normal conditions.",
291
  ],
292
  )
293
  else:
294
  # Degraded mode — no baseline
295
+ z_current = 0.0
296
+ anomaly_months = 0
297
+ hotspot_pct = 0.0
298
  confidence = ConfidenceLevel.LOW
299
+ confidence_factors = {}
300
+ status = StatusLevel.GREEN
301
+ trend = TrendDirection.STABLE
302
+ self._zscore_raster = None
303
+ self._hotspot_mask = None
304
  peak_band = current_stats["peak_buildup_band"]
305
 
306
  chart_data = {
 
309
  "label": "Built-up area (ha)",
310
  }
311
 
312
+ headline = f"Built-up extent: {current_frac*100:.1f}% ({current_ha:.0f} ha) \u2014 baseline unavailable"
313
 
314
  self._spatial_data = SpatialData(
315
  map_type="raster",
 
324
  return IndicatorResult(
325
  indicator_id=self.id,
326
  headline=headline,
327
+ status=status,
328
+ trend=trend,
329
  confidence=confidence,
330
  map_layer_path=current_path,
331
  chart_data=chart_data,
332
  data_source="satellite",
333
+ anomaly_months=anomaly_months,
334
+ z_score_current=round(z_current, 2),
335
+ hotspot_pct=round(hotspot_pct, 1),
336
+ confidence_factors=confidence_factors,
337
  summary=(
338
  f"Built-up area covers {current_frac*100:.1f}% of the AOI "
339
+ f"({current_ha:.0f} ha). Baseline unavailable \u2014 change not computed. "
340
+ f"Pixel-level NDBI analysis at {BUILDUP_RESOLUTION_M}m resolution."
341
  ),
342
  methodology=(
343
+ f"Sentinel-2 L2A pixel-level NDBI = (B11 \u2212 B08) / (B11 + B08). "
344
  f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
345
  f"Cloud-masked using SCL band. "
346
+ f"Monthly median composites at {BUILDUP_RESOLUTION_M}m. "
347
  f"Processed via CDSE openEO batch jobs."
348
  ),
349
  limitations=[
350
+ f"Resampled to {BUILDUP_RESOLUTION_M}m \u2014 detects settlement extent, not individual buildings.",
351
  "NDBI may confuse bare rock/sand with built-up in arid landscapes.",
352
  "Seasonal vegetation cycles can cause false positives at settlement fringes.",
353
  "For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed.",
354
+ "Baseline unavailable \u2014 change and trend not computed.",
355
  ],
356
  )
357
 
 
375
  time_range.start.month,
376
  time_range.start.day,
377
  ).isoformat()
378
+ baseline_end = date(
379
+ time_range.start.year,
380
+ time_range.start.month,
381
+ time_range.start.day,
382
+ ).isoformat()
383
 
384
  results_dir = tempfile.mkdtemp(prefix="aperture_buildup_")
385
 
386
  current_cube = build_buildup_graph(
387
  conn=conn, bbox=bbox,
388
  temporal_extent=[current_start, current_end],
389
+ resolution_m=BUILDUP_RESOLUTION_M,
390
  )
391
  baseline_cube = build_buildup_graph(
392
  conn=conn, bbox=bbox,
393
  temporal_extent=[baseline_start, baseline_end],
394
+ resolution_m=BUILDUP_RESOLUTION_M,
395
  )
396
  true_color_cube = build_true_color_graph(
397
  conn=conn, bbox=bbox,
398
  temporal_extent=[current_start, current_end],
399
+ resolution_m=TRUECOLOR_RESOLUTION_M,
400
  )
401
 
402
  loop = asyncio.get_event_loop()
 
404
  baseline_path = os.path.join(results_dir, "ndbi_baseline.tif")
405
  true_color_path = os.path.join(results_dir, "true_color.tif")
406
 
 
407
  timeout = 600
408
  await asyncio.wait_for(
409
  loop.run_in_executor(None, current_cube.download, current_path), timeout=timeout
 
417
 
418
  self._true_color_path = true_color_path
419
 
420
+ # --- Seasonal baseline analysis ---
421
  current_stats = self._compute_stats(current_path)
422
  baseline_stats = self._compute_stats(baseline_path)
423
+ current_mean = current_stats["overall_mean"]
424
  current_frac = current_stats["overall_buildup_fraction"]
425
+ n_current_bands = current_stats["valid_months"]
 
 
426
  aoi_ha = aoi.area_km2 * 100 # km² → hectares
427
  current_ha = current_frac * aoi_ha
428
+ baseline_frac = baseline_stats["overall_buildup_fraction"]
429
  baseline_ha = baseline_frac * aoi_ha
430
 
431
+ spatial_completeness = self._compute_spatial_completeness(current_path)
432
+
433
+ seasonal_stats = compute_seasonal_stats_aoi(baseline_path, n_years=BASELINE_YEARS)
434
+ start_month = time_range.start.month
435
+ most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
436
+
437
+ # Z-score for overall current mean NDBI vs seasonal baseline
438
+ if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
439
+ s = seasonal_stats[most_recent_month]
440
+ z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_BUILDUP)
441
  else:
442
+ z_current = 0.0
443
+
444
+ # Per-month z-scores and anomaly count
445
+ anomaly_months = 0
446
+ monthly_zscores = []
447
+ for i, val in enumerate(current_stats["monthly_means"]):
448
+ cal_month = ((start_month + i - 1) % 12) + 1
449
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
450
+ z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
451
+ seasonal_stats[cal_month]["std"], MIN_STD_BUILDUP)
452
+ monthly_zscores.append(z)
453
+ if abs(z) > ZSCORE_THRESHOLD:
454
+ anomaly_months += 1
455
+ else:
456
+ monthly_zscores.append(0.0)
457
+
458
+ # Pixel-level hotspot detection
459
+ month_map = group_bands_by_calendar_month(baseline_stats["valid_months_total"], BASELINE_YEARS)
460
+ hotspot_pct = 0.0
461
+ self._zscore_raster = None
462
+ self._hotspot_mask = None
463
+ if most_recent_month in month_map and len(month_map[most_recent_month]) > 0:
464
+ pixel_stats = compute_seasonal_stats_pixel(baseline_path, month_map[most_recent_month])
465
+ with rasterio.open(current_path) as src:
466
+ current_band_idx = min(n_current_bands, src.count)
467
+ current_data = src.read(current_band_idx).astype(np.float32)
468
+ if src.nodata is not None:
469
+ current_data[current_data == src.nodata] = np.nan
470
+ z_raster = compute_zscore_raster(current_data, pixel_stats["mean"],
471
+ pixel_stats["std"], MIN_STD_BUILDUP)
472
+ hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
473
+ self._zscore_raster = z_raster
474
+ self._hotspot_mask = hotspot_mask
475
+
476
+ # Four-factor confidence scoring
477
+ baseline_depth = sum(1 for m in range(1, 13)
478
+ if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
479
+ mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
480
+ if m in seasonal_stats) / max(baseline_depth, 1))
481
+ conf = compute_confidence(
482
+ valid_months=n_current_bands,
483
+ mean_obs_per_composite=5.0,
484
+ baseline_years_with_data=int(mean_baseline_years),
485
+ spatial_completeness=spatial_completeness,
486
  )
487
+ confidence = conf["level"]
488
+ confidence_factors = conf["factors"]
489
+
490
+ status = self._classify_zscore(z_current, hotspot_pct)
491
+ trend = self._compute_trend_zscore(monthly_zscores)
492
+ chart_data = self._build_seasonal_chart_data(
493
+ current_stats["monthly_buildup_fractions"], seasonal_stats,
494
+ time_range, monthly_zscores, aoi_ha,
495
  )
496
 
497
+ # Headline — z-score driven, with hectare context
498
+ if abs(z_current) <= 1.0:
499
+ headline = (
500
+ f"Settlement extent stable at ~{current_ha:.0f} ha "
501
+ f"(z={z_current:+.1f})"
502
+ )
503
+ elif z_current > 0:
504
+ headline = (
505
+ f"Settlement expansion detected: {current_ha:.0f} ha "
506
+ f"(z={z_current:+.1f} above seasonal baseline)"
507
+ )
508
  else:
509
+ headline = (
510
+ f"Settlement contraction detected: {current_ha:.0f} ha "
511
+ f"(z={z_current:+.1f} below seasonal baseline)"
512
+ )
513
 
514
  # Write change raster for map rendering
515
  change_map_path = os.path.join(results_dir, "buildup_change.tif")
 
534
  map_layer_path=change_map_path,
535
  chart_data=chart_data,
536
  data_source="satellite",
537
+ anomaly_months=anomaly_months,
538
+ z_score_current=round(z_current, 2),
539
+ hotspot_pct=round(hotspot_pct, 1),
540
+ confidence_factors=confidence_factors,
541
  summary=(
542
  f"Built-up area covers {current_frac*100:.1f}% of the AOI "
543
+ f"({current_ha:.0f} ha), mean NDBI {current_mean:.3f} "
544
+ f"(z-score {z_current:+.1f} vs seasonal baseline). "
545
+ f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
546
+ f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
547
+ f"Pixel-level NDBI analysis at {BUILDUP_RESOLUTION_M}m resolution."
548
  ),
549
  methodology=(
550
+ f"Sentinel-2 L2A pixel-level NDBI = (B11 \u2212 B08) / (B11 + B08). "
551
  f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
552
  f"Cloud-masked using SCL band. "
553
+ f"Monthly median composites at {BUILDUP_RESOLUTION_M}m native resolution. "
554
+ f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
555
+ f"Anomaly detection via z-scores (threshold: \u00b1{ZSCORE_THRESHOLD}). "
556
+ f"Processed server-side via CDSE openEO."
557
  ),
558
  limitations=[
559
+ f"Resampled to {BUILDUP_RESOLUTION_M}m \u2014 detects settlement extent, not individual buildings.",
560
  "NDBI may confuse bare rock/sand with built-up in arid landscapes.",
561
  "Seasonal vegetation cycles can cause false positives at settlement fringes.",
562
  "For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed.",
563
+ "Z-score anomalies assume baseline is representative of normal conditions.",
564
  ],
565
  )
566
 
567
  @staticmethod
568
  def _compute_stats(tif_path: str) -> dict[str, Any]:
569
+ """Extract monthly built-up fraction and raw NDBI stats from GeoTIFF.
570
 
571
  Built-up = NDBI > 0 (simplified; full pipeline would also check NDVI < 0.2
572
  but the graph builder only outputs NDBI).
 
574
  with rasterio.open(tif_path) as src:
575
  n_bands = src.count
576
  monthly_fractions: list[float] = []
577
+ monthly_means: list[float] = []
578
  peak_frac = -1.0
579
  peak_band = 1
580
  for band in range(1, n_bands + 1):
 
587
  if len(valid) > 0:
588
  buildup_pixels = np.sum(valid > NDBI_THRESHOLD)
589
  frac = float(buildup_pixels / len(valid))
590
+ mean_val = float(np.nanmean(valid))
591
  monthly_fractions.append(frac)
592
+ monthly_means.append(mean_val)
593
  if frac > peak_frac:
594
  peak_frac = frac
595
  peak_band = band
596
  else:
597
  monthly_fractions.append(0.0)
598
+ monthly_means.append(0.0)
599
 
600
+ overall_frac = float(np.mean(monthly_fractions)) if monthly_fractions else 0.0
601
+ valid_months = sum(1 for m in monthly_means if m != 0.0)
602
+ overall_mean = (
603
+ float(np.mean([m for m in monthly_means if m != 0.0]))
604
+ if valid_months > 0 else 0.0
605
+ )
606
 
607
  return {
608
  "monthly_buildup_fractions": monthly_fractions,
609
+ "overall_buildup_fraction": overall_frac,
610
+ "valid_months": valid_months,
611
+ "valid_months_total": n_bands,
612
  "peak_buildup_band": peak_band,
613
+ "overall_mean": overall_mean,
614
+ "monthly_means": monthly_means,
615
  }
616
 
617
  @staticmethod
618
+ def _compute_spatial_completeness(tif_path: str) -> float:
619
+ """Compute fraction of AOI with valid (non-nodata) pixels."""
620
+ with rasterio.open(tif_path) as src:
621
+ data = src.read(1).astype(np.float32)
622
+ nodata = src.nodata
623
+ if nodata is not None:
624
+ valid = np.sum(data != nodata)
625
+ else:
626
+ valid = np.sum(~np.isnan(data))
627
+ total = data.size
628
+ return float(valid / total) if total > 0 else 0.0
629
+
630
+ @staticmethod
631
+ def _classify_zscore(z_score: float, hotspot_pct: float) -> StatusLevel:
632
+ """Classify status using z-score and hotspot percentage."""
633
+ if abs(z_score) > ZSCORE_THRESHOLD or hotspot_pct > 25:
634
+ return StatusLevel.RED
635
+ if abs(z_score) > 1.0 or hotspot_pct > 10:
636
  return StatusLevel.AMBER
637
+ return StatusLevel.GREEN
638
 
639
  @staticmethod
640
+ def _compute_trend_zscore(monthly_zscores: list[float]) -> TrendDirection:
641
+ """Compute trend from direction of monthly z-scores."""
642
+ valid = [z for z in monthly_zscores if z != 0.0]
643
+ if len(valid) < 2:
644
+ return TrendDirection.STABLE
645
+ within_normal = sum(1 for z in valid if abs(z) <= 1.0)
646
+ if within_normal > len(valid) / 2:
647
  return TrendDirection.STABLE
648
+ negative = sum(1 for z in valid if z < -1.0)
649
+ positive = sum(1 for z in valid if z > 1.0)
650
+ if negative > positive:
651
  return TrendDirection.DETERIORATING
652
+ if positive > negative:
653
+ return TrendDirection.IMPROVING
654
  return TrendDirection.STABLE
655
 
656
  @staticmethod
657
+ def _build_seasonal_chart_data(
658
+ current_monthly_fractions: list[float],
659
+ seasonal_stats: dict[int, dict],
660
  time_range: TimeRange,
661
+ monthly_zscores: list[float],
662
  aoi_ha: float,
663
  ) -> dict[str, Any]:
664
+ """Build chart data with seasonal baseline envelope, in hectares."""
665
+ start_month = time_range.start.month
666
+ n = len(current_monthly_fractions)
667
  year = time_range.end.year
668
+
669
+ dates = []
670
+ values = []
671
+ b_mean = []
672
+ b_min = []
673
+ b_max = []
674
+ anomaly_flags = []
675
+
676
+ for i in range(n):
677
+ cal_month = ((start_month + i - 1) % 12) + 1
678
+ dates.append(f"{year}-{cal_month:02d}")
679
+ values.append(round(current_monthly_fractions[i] * aoi_ha, 1))
680
+
681
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
682
+ s = seasonal_stats[cal_month]
683
+ # Seasonal stats are raw NDBI means; convert to fraction > threshold,
684
+ # approximate using the baseline mean as a proxy for buildup fraction.
685
+ # For the envelope, use min/max from seasonal stats scaled to hectares.
686
+ b_mean.append(round(s["mean"] * aoi_ha, 1) if s["mean"] > 0 else 0.0)
687
+ b_min.append(round(s["min"] * aoi_ha, 1) if s["min"] > 0 else 0.0)
688
+ b_max.append(round(s["max"] * aoi_ha, 1) if s["max"] > 0 else 0.0)
689
+ else:
690
+ b_mean.append(0.0)
691
+ b_min.append(0.0)
692
+ b_max.append(0.0)
693
+
694
+ if i < len(monthly_zscores):
695
+ anomaly_flags.append(abs(monthly_zscores[i]) > ZSCORE_THRESHOLD)
696
+ else:
697
+ anomaly_flags.append(False)
698
 
699
  return {
700
  "dates": dates,
 
702
  "baseline_mean": b_mean,
703
  "baseline_min": b_min,
704
  "baseline_max": b_max,
705
+ "anomaly_flags": anomaly_flags,
706
  "label": "Built-up area (ha)",
707
  }
708
 
 
725
  profile.update(count=1, dtype="float32")
726
  with rasterio.open(output_path, "w", **profile) as dst:
727
  dst.write(change, 1)
 
 
app/indicators/sar.py CHANGED
@@ -1,7 +1,8 @@
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 baseline period.
 
5
  """
6
  from __future__ import annotations
7
 
@@ -14,7 +15,13 @@ from typing import Any
14
  import numpy as np
15
  import rasterio
16
 
17
- from app.config import RESOLUTION_M
 
 
 
 
 
 
18
  from app.indicators.base import BaseIndicator, SpatialData
19
  from app.models import (
20
  AOI,
@@ -25,6 +32,11 @@ from app.models import (
25
  ConfidenceLevel,
26
  )
27
  from app.openeo_client import get_connection, build_sar_graph, build_true_color_graph, _bbox_dict, submit_as_batch
 
 
 
 
 
28
 
29
  logger = logging.getLogger(__name__)
30
 
@@ -62,17 +74,17 @@ class SarIndicator(BaseIndicator):
62
  current_cube = build_sar_graph(
63
  conn=conn, bbox=bbox,
64
  temporal_extent=[current_start, current_end],
65
- resolution_m=RESOLUTION_M,
66
  )
67
  baseline_cube = build_sar_graph(
68
  conn=conn, bbox=bbox,
69
  temporal_extent=[baseline_start, baseline_end],
70
- resolution_m=RESOLUTION_M,
71
  )
72
  true_color_cube = build_true_color_graph(
73
  conn=conn, bbox=bbox,
74
  temporal_extent=[current_start, current_end],
75
- resolution_m=RESOLUTION_M,
76
  )
77
 
78
  return [
@@ -119,46 +131,119 @@ class SarIndicator(BaseIndicator):
119
  except Exception as exc:
120
  logger.warning("SAR true-color batch download failed: %s", exc)
121
 
 
122
  current_stats = self._compute_stats(current_path)
 
 
123
 
124
- if current_stats["valid_months"] == 0:
125
  return self._insufficient_data(aoi, time_range)
126
 
 
 
127
  if baseline_path:
128
  baseline_stats = self._compute_stats(baseline_path)
129
 
130
- change_db = current_stats["overall_vv_mean"] - baseline_stats["overall_vv_mean"]
131
- change_pct = self._compute_change_area_pct(
132
- current_path, baseline_path, current_stats, baseline_stats
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  flood_months = self._count_flood_months(
135
  current_stats["monthly_vv_means"],
136
  baseline_stats["overall_vv_mean"],
137
  baseline_stats["vv_std"],
138
  )
139
- status = self._classify(change_pct, flood_months)
140
- trend = self._compute_trend(current_stats["monthly_vv_means"])
141
- confidence = (
142
- ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
143
- else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
144
- else ConfidenceLevel.LOW
145
  )
146
- chart_data = self._build_chart_data(
147
- current_stats["monthly_vv_means"],
148
- baseline_stats["monthly_vv_means"],
149
- time_range,
 
 
150
  )
151
 
152
  # Headline
153
  parts = []
 
 
154
  if change_pct >= 5:
155
  parts.append(f"{change_pct:.0f}% ground surface change")
156
  if flood_months > 0:
157
  parts.append(f"{flood_months} potential flood event{'s' if flood_months > 1 else ''}")
158
- headline = (
159
- f"SAR detects {', '.join(parts)}" if parts
160
- else "Stable backscatter conditions — no significant ground change detected"
161
- )
162
 
163
  change_map_path = os.path.join(results_dir, "sar_change.tif")
164
  self._write_change_raster(current_path, baseline_path, change_map_path)
@@ -175,21 +260,28 @@ class SarIndicator(BaseIndicator):
175
  map_layer_path = change_map_path
176
 
177
  summary = (
178
- f"Mean VV backscatter change: {change_db:+.1f} dB. "
179
- f"{change_pct:.1f}% of AOI shows significant change (>{CHANGE_THRESHOLD_DB} dB). "
 
 
 
180
  f"{flood_months} month(s) with potential flood signals. "
181
- f"Pixel-level analysis at {RESOLUTION_M}m resolution from "
182
- f"{current_stats['valid_months']} monthly composites."
183
  )
184
  extra_limitations: list[str] = []
185
  else:
186
  # Degraded mode — no baseline
 
 
 
 
 
 
 
187
  change_db = 0.0
188
- change_pct = 0.0
189
  flood_months = 0
190
- status = self._classify(change_pct, flood_months)
191
- trend = TrendDirection.STABLE
192
- confidence = ConfidenceLevel.LOW
193
  chart_data = {
194
  "dates": [f"{time_range.end.year}-{m+1:02d}" for m in range(len(current_stats["monthly_vv_means"]))],
195
  "values": [round(v, 2) for v in current_stats["monthly_vv_means"]],
@@ -211,8 +303,8 @@ class SarIndicator(BaseIndicator):
211
  summary = (
212
  f"Current SAR data available but baseline could not be retrieved. "
213
  f"No change detection possible. "
214
- f"Pixel-level analysis at {RESOLUTION_M}m resolution from "
215
- f"{current_stats['valid_months']} monthly composites."
216
  )
217
  extra_limitations = ["Baseline unavailable — change and trend not computed."]
218
 
@@ -227,21 +319,27 @@ class SarIndicator(BaseIndicator):
227
  map_layer_path=map_layer_path,
228
  chart_data=chart_data,
229
  data_source="satellite",
 
 
 
 
230
  summary=summary,
231
  methodology=(
232
  f"Sentinel-1 GRD IW VV/VH polarizations, ascending orbit. "
233
  f"Linear backscatter converted to dB (10·log₁₀). "
234
- f"Monthly median composites at {RESOLUTION_M}m resolution. "
235
- f"Change detection: >{CHANGE_THRESHOLD_DB} dB difference vs "
236
- f"{BASELINE_YEARS}-year baseline. "
 
237
  f"Flood mapping: VV < baseline_mean − {FLOOD_SIGMA}σ. "
238
  f"Processed via CDSE openEO batch jobs."
239
  ),
240
  limitations=[
241
- f"Resampled to {RESOLUTION_M}m — fine-scale changes not captured.",
242
  "Ascending orbit filter may reduce temporal coverage in some areas.",
243
  "Sentinel-1 coverage over East Africa can be inconsistent.",
244
  "VV decrease may indicate flooding, moisture, or vegetation change — not uniquely flood.",
 
245
  ] + extra_limitations,
246
  )
247
 
@@ -272,17 +370,17 @@ class SarIndicator(BaseIndicator):
272
  current_cube = build_sar_graph(
273
  conn=conn, bbox=bbox,
274
  temporal_extent=[current_start, current_end],
275
- resolution_m=RESOLUTION_M,
276
  )
277
  baseline_cube = build_sar_graph(
278
  conn=conn, bbox=bbox,
279
  temporal_extent=[baseline_start, baseline_end],
280
- resolution_m=RESOLUTION_M,
281
  )
282
  true_color_cube = build_true_color_graph(
283
  conn=conn, bbox=bbox,
284
  temporal_extent=[current_start, current_end],
285
- resolution_m=RESOLUTION_M,
286
  )
287
 
288
  loop = asyncio.get_event_loop()
@@ -304,52 +402,117 @@ class SarIndicator(BaseIndicator):
304
 
305
  self._true_color_path = true_color_path
306
 
 
307
  current_stats = self._compute_stats(current_path)
308
  baseline_stats = self._compute_stats(baseline_path)
 
 
309
 
310
- # Check for insufficient data
311
- if current_stats["valid_months"] == 0:
312
  return self._insufficient_data(aoi, time_range)
313
 
314
- # Change detection: mean VV difference per pixel
315
- change_db = current_stats["overall_vv_mean"] - baseline_stats["overall_vv_mean"]
316
 
317
- # Compute % of area with significant change
318
- change_pct = self._compute_change_area_pct(
319
- current_path, baseline_path, current_stats, baseline_stats
320
  )
321
 
322
- # Flood detection: months where VV < baseline_mean - 2σ
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  flood_months = self._count_flood_months(
324
  current_stats["monthly_vv_means"],
325
  baseline_stats["overall_vv_mean"],
326
  baseline_stats["vv_std"],
327
  )
328
 
329
- status = self._classify(change_pct, flood_months)
330
- trend = self._compute_trend(current_stats["monthly_vv_means"])
331
- confidence = (
332
- ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
333
- else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
334
- else ConfidenceLevel.LOW
335
  )
336
 
337
- chart_data = self._build_chart_data(
338
- current_stats["monthly_vv_means"],
339
- baseline_stats["monthly_vv_means"],
340
- time_range,
 
341
  )
342
 
343
  # Headline
344
  parts = []
 
 
345
  if change_pct >= 5:
346
  parts.append(f"{change_pct:.0f}% ground surface change")
347
  if flood_months > 0:
348
  parts.append(f"{flood_months} potential flood event{'s' if flood_months > 1 else ''}")
349
  if parts:
350
- headline = f"SAR detects {', '.join(parts)}"
351
  else:
352
- headline = "Stable backscatter conditions no significant ground change detected"
353
 
354
  # Store raster path for map rendering — write a change map
355
  change_map_path = os.path.join(results_dir, "sar_change.tif")
@@ -374,30 +537,42 @@ class SarIndicator(BaseIndicator):
374
  map_layer_path=change_map_path,
375
  chart_data=chart_data,
376
  data_source="satellite",
 
 
 
 
377
  summary=(
378
- f"Mean VV backscatter change: {change_db:+.1f} dB. "
379
- f"{change_pct:.1f}% of AOI shows significant change (>{CHANGE_THRESHOLD_DB} dB). "
 
 
 
380
  f"{flood_months} month(s) with potential flood signals. "
381
- f"Pixel-level analysis at {RESOLUTION_M}m resolution from "
382
- f"{current_stats['valid_months']} monthly composites."
383
  ),
384
  methodology=(
385
  f"Sentinel-1 GRD IW VV/VH polarizations, ascending orbit. "
386
  f"Linear backscatter converted to dB (10·log₁₀). "
387
- f"Monthly median composites at {RESOLUTION_M}m resolution. "
388
- f"Change detection: >{CHANGE_THRESHOLD_DB} dB difference vs "
389
- f"{BASELINE_YEARS}-year baseline. "
 
390
  f"Flood mapping: VV < baseline_mean − {FLOOD_SIGMA}σ. "
391
  f"Processed via CDSE openEO."
392
  ),
393
  limitations=[
394
- f"Resampled to {RESOLUTION_M}m — fine-scale changes not captured.",
395
  "Ascending orbit filter may reduce temporal coverage in some areas.",
396
  "Sentinel-1 coverage over East Africa can be inconsistent.",
397
  "VV decrease may indicate flooding, moisture, or vegetation change — not uniquely flood.",
 
398
  ],
399
  )
400
 
 
 
 
 
401
  @staticmethod
402
  def _compute_stats(tif_path: str) -> dict[str, Any]:
403
  """Extract monthly VV statistics from interleaved SAR GeoTIFF.
@@ -435,8 +610,180 @@ class SarIndicator(BaseIndicator):
435
  "overall_vv_mean": overall_vv_mean,
436
  "vv_std": vv_std,
437
  "valid_months": valid_months,
 
438
  }
439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  @staticmethod
441
  def _compute_change_area_pct(
442
  current_path: str, baseline_path: str,
@@ -470,52 +817,6 @@ class SarIndicator(BaseIndicator):
470
  threshold = baseline_mean - FLOOD_SIGMA * baseline_std
471
  return sum(1 for v in monthly_vv if v != 0.0 and v < threshold)
472
 
473
- @staticmethod
474
- def _classify(change_pct: float, flood_months: int) -> StatusLevel:
475
- if change_pct >= 15 or flood_months >= 3:
476
- return StatusLevel.RED
477
- if change_pct >= 5 or flood_months >= 1:
478
- return StatusLevel.AMBER
479
- return StatusLevel.GREEN
480
-
481
- @staticmethod
482
- def _compute_trend(monthly_vv: list[float]) -> TrendDirection:
483
- valid = [v for v in monthly_vv if v != 0.0]
484
- if len(valid) < 4:
485
- return TrendDirection.STABLE
486
- mid = len(valid) // 2
487
- first_half = np.mean(valid[:mid])
488
- second_half = np.mean(valid[mid:])
489
- diff = abs(second_half - first_half)
490
- if diff < 1.0:
491
- return TrendDirection.STABLE
492
- if second_half > first_half:
493
- return TrendDirection.IMPROVING
494
- return TrendDirection.DETERIORATING
495
-
496
- @staticmethod
497
- def _build_chart_data(
498
- current_monthly: list[float],
499
- baseline_monthly: list[float],
500
- time_range: TimeRange,
501
- ) -> dict[str, Any]:
502
- year = time_range.end.year
503
- n = min(len(current_monthly), len(baseline_monthly))
504
- dates = [f"{year}-{m + 1:02d}" for m in range(n)]
505
- values = [round(v, 2) for v in current_monthly[:n]]
506
- b_mean = [round(v, 2) for v in baseline_monthly[:n]] if baseline_monthly else []
507
- b_min = [round(v - 2.0, 2) for v in b_mean]
508
- b_max = [round(v + 2.0, 2) for v in b_mean]
509
-
510
- return {
511
- "dates": dates,
512
- "values": values,
513
- "baseline_mean": b_mean,
514
- "baseline_min": b_min,
515
- "baseline_max": b_max,
516
- "label": "VV Backscatter (dB)",
517
- }
518
-
519
  @staticmethod
520
  def _write_change_raster(current_path: str, baseline_path: str, output_path: str) -> None:
521
  """Write a single-band GeoTIFF of VV change (current_mean - baseline_mean)."""
@@ -542,4 +843,3 @@ class SarIndicator(BaseIndicator):
542
  "area and time period. SAR coverage over parts of East Africa "
543
  "is inconsistent."
544
  )
545
-
 
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
5
+ anomaly detection, hotspot clustering, and four-factor confidence scoring.
6
  """
7
  from __future__ import annotations
8
 
 
15
  import numpy as np
16
  import rasterio
17
 
18
+ from app.config import (
19
+ SAR_RESOLUTION_M,
20
+ TRUECOLOR_RESOLUTION_M,
21
+ MIN_STD_SAR,
22
+ ZSCORE_THRESHOLD,
23
+ MIN_CLUSTER_PIXELS,
24
+ )
25
  from app.indicators.base import BaseIndicator, SpatialData
26
  from app.models import (
27
  AOI,
 
32
  ConfidenceLevel,
33
  )
34
  from app.openeo_client import get_connection, build_sar_graph, build_true_color_graph, _bbox_dict, submit_as_batch
35
+ from app.analysis.seasonal import (
36
+ compute_zscore,
37
+ )
38
+ from app.analysis.change import compute_zscore_raster, detect_hotspots
39
+ from app.analysis.confidence import compute_confidence
40
 
41
  logger = logging.getLogger(__name__)
42
 
 
74
  current_cube = build_sar_graph(
75
  conn=conn, bbox=bbox,
76
  temporal_extent=[current_start, current_end],
77
+ resolution_m=SAR_RESOLUTION_M,
78
  )
79
  baseline_cube = build_sar_graph(
80
  conn=conn, bbox=bbox,
81
  temporal_extent=[baseline_start, baseline_end],
82
+ resolution_m=SAR_RESOLUTION_M,
83
  )
84
  true_color_cube = build_true_color_graph(
85
  conn=conn, bbox=bbox,
86
  temporal_extent=[current_start, current_end],
87
+ resolution_m=TRUECOLOR_RESOLUTION_M,
88
  )
89
 
90
  return [
 
131
  except Exception as exc:
132
  logger.warning("SAR true-color batch download failed: %s", exc)
133
 
134
+ # --- Seasonal baseline analysis ---
135
  current_stats = self._compute_stats(current_path)
136
+ current_mean = current_stats["overall_vv_mean"]
137
+ n_current_bands = current_stats["valid_months"]
138
 
139
+ if n_current_bands == 0:
140
  return self._insufficient_data(aoi, time_range)
141
 
142
+ spatial_completeness = self._compute_spatial_completeness(current_path)
143
+
144
  if baseline_path:
145
  baseline_stats = self._compute_stats(baseline_path)
146
 
147
+ # Build seasonal stats from baseline VV monthly means grouped by calendar month
148
+ seasonal_stats = self._build_seasonal_stats_from_vv(
149
+ baseline_stats["monthly_vv_means"], BASELINE_YEARS,
150
+ )
151
+
152
+ start_month = time_range.start.month
153
+ most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
154
+
155
+ if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
156
+ s = seasonal_stats[most_recent_month]
157
+ z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_SAR)
158
+ else:
159
+ z_current = 0.0
160
+
161
+ # Per-month z-scores and anomaly count
162
+ anomaly_months = 0
163
+ monthly_zscores: list[float] = []
164
+ for i, val in enumerate(current_stats["monthly_vv_means"]):
165
+ if val == 0.0:
166
+ monthly_zscores.append(0.0)
167
+ continue
168
+ cal_month = ((start_month + i - 1) % 12) + 1
169
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
170
+ z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
171
+ seasonal_stats[cal_month]["std"], MIN_STD_SAR)
172
+ monthly_zscores.append(z)
173
+ if abs(z) > ZSCORE_THRESHOLD:
174
+ anomaly_months += 1
175
+ else:
176
+ monthly_zscores.append(0.0)
177
+
178
+ # Pixel-level hotspot detection using VV bands
179
+ hotspot_pct = 0.0
180
+ self._zscore_raster = None
181
+ self._hotspot_mask = None
182
+ baseline_vv_bands = self._get_vv_band_indices_for_month(
183
+ baseline_stats["valid_months_total"], BASELINE_YEARS, most_recent_month,
184
  )
185
+ if baseline_vv_bands:
186
+ pixel_mean, pixel_std = self._compute_vv_pixel_stats(
187
+ baseline_path, baseline_vv_bands,
188
+ )
189
+ # Read most recent current VV band
190
+ with rasterio.open(current_path) as src:
191
+ n_current_total = src.count // 2
192
+ current_vv_idx = min(n_current_bands, n_current_total) * 2 - 1 # 1-based VV
193
+ current_data = src.read(current_vv_idx).astype(np.float32)
194
+ if src.nodata is not None:
195
+ current_data[current_data == src.nodata] = np.nan
196
+
197
+ z_raster = compute_zscore_raster(current_data, pixel_mean, pixel_std, MIN_STD_SAR)
198
+ hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
199
+ self._zscore_raster = z_raster
200
+ self._hotspot_mask = hotspot_mask
201
+
202
+ # Confidence scoring
203
+ baseline_depth = sum(1 for m in range(1, 13)
204
+ if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
205
+ mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
206
+ if m in seasonal_stats) / max(baseline_depth, 1))
207
+ conf = compute_confidence(
208
+ valid_months=n_current_bands,
209
+ mean_obs_per_composite=5.0,
210
+ baseline_years_with_data=int(mean_baseline_years),
211
+ spatial_completeness=spatial_completeness,
212
+ )
213
+ confidence = conf["level"]
214
+ confidence_factors = conf["factors"]
215
+
216
+ # Legacy flood detection (integrated with z-scores)
217
  flood_months = self._count_flood_months(
218
  current_stats["monthly_vv_means"],
219
  baseline_stats["overall_vv_mean"],
220
  baseline_stats["vv_std"],
221
  )
222
+
223
+ change_db = current_mean - baseline_stats["overall_vv_mean"]
224
+ change_pct = self._compute_change_area_pct(
225
+ current_path, baseline_path, current_stats, baseline_stats,
 
 
226
  )
227
+
228
+ status = self._classify_zscore(z_current, hotspot_pct)
229
+ trend = self._compute_trend_zscore(monthly_zscores)
230
+
231
+ chart_data = self._build_seasonal_chart_data(
232
+ current_stats["monthly_vv_means"], seasonal_stats, time_range, monthly_zscores,
233
  )
234
 
235
  # Headline
236
  parts = []
237
+ if abs(z_current) > ZSCORE_THRESHOLD:
238
+ parts.append(f"z={z_current:+.1f} vs seasonal baseline")
239
  if change_pct >= 5:
240
  parts.append(f"{change_pct:.0f}% ground surface change")
241
  if flood_months > 0:
242
  parts.append(f"{flood_months} potential flood event{'s' if flood_months > 1 else ''}")
243
+ if parts:
244
+ headline = f"SAR backscatter anomaly: {', '.join(parts)} (mean VV {current_mean:.1f} dB)"
245
+ else:
246
+ headline = f"Stable backscatter conditions (mean VV {current_mean:.1f} dB, z={z_current:+.1f})"
247
 
248
  change_map_path = os.path.join(results_dir, "sar_change.tif")
249
  self._write_change_raster(current_path, baseline_path, change_map_path)
 
260
  map_layer_path = change_map_path
261
 
262
  summary = (
263
+ f"Mean VV backscatter: {current_mean:.1f} dB (z-score {z_current:+.1f} vs seasonal baseline). "
264
+ f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
265
+ f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
266
+ f"Mean VV change: {change_db:+.1f} dB. "
267
+ f"{change_pct:.1f}% area with >{CHANGE_THRESHOLD_DB} dB change. "
268
  f"{flood_months} month(s) with potential flood signals. "
269
+ f"Pixel-level analysis at {SAR_RESOLUTION_M}m resolution."
 
270
  )
271
  extra_limitations: list[str] = []
272
  else:
273
  # Degraded mode — no baseline
274
+ z_current = 0.0
275
+ anomaly_months = 0
276
+ hotspot_pct = 0.0
277
+ confidence = ConfidenceLevel.LOW
278
+ confidence_factors = {}
279
+ status = StatusLevel.GREEN
280
+ trend = TrendDirection.STABLE
281
  change_db = 0.0
 
282
  flood_months = 0
283
+ self._zscore_raster = None
284
+ self._hotspot_mask = None
 
285
  chart_data = {
286
  "dates": [f"{time_range.end.year}-{m+1:02d}" for m in range(len(current_stats["monthly_vv_means"]))],
287
  "values": [round(v, 2) for v in current_stats["monthly_vv_means"]],
 
303
  summary = (
304
  f"Current SAR data available but baseline could not be retrieved. "
305
  f"No change detection possible. "
306
+ f"Pixel-level analysis at {SAR_RESOLUTION_M}m resolution from "
307
+ f"{n_current_bands} monthly composites."
308
  )
309
  extra_limitations = ["Baseline unavailable — change and trend not computed."]
310
 
 
319
  map_layer_path=map_layer_path,
320
  chart_data=chart_data,
321
  data_source="satellite",
322
+ anomaly_months=anomaly_months,
323
+ z_score_current=round(z_current, 2),
324
+ hotspot_pct=round(hotspot_pct, 1),
325
+ confidence_factors=confidence_factors,
326
  summary=summary,
327
  methodology=(
328
  f"Sentinel-1 GRD IW VV/VH polarizations, ascending orbit. "
329
  f"Linear backscatter converted to dB (10·log₁₀). "
330
+ f"Monthly median composites at {SAR_RESOLUTION_M}m resolution. "
331
+ f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
332
+ f"Anomaly detection via z-scores (threshold: ±{ZSCORE_THRESHOLD}). "
333
+ f"Change detection: >{CHANGE_THRESHOLD_DB} dB difference vs baseline. "
334
  f"Flood mapping: VV < baseline_mean − {FLOOD_SIGMA}σ. "
335
  f"Processed via CDSE openEO batch jobs."
336
  ),
337
  limitations=[
338
+ f"Resampled to {SAR_RESOLUTION_M}m — fine-scale changes not captured.",
339
  "Ascending orbit filter may reduce temporal coverage in some areas.",
340
  "Sentinel-1 coverage over East Africa can be inconsistent.",
341
  "VV decrease may indicate flooding, moisture, or vegetation change — not uniquely flood.",
342
+ "Z-score anomalies assume baseline is representative of normal conditions.",
343
  ] + extra_limitations,
344
  )
345
 
 
370
  current_cube = build_sar_graph(
371
  conn=conn, bbox=bbox,
372
  temporal_extent=[current_start, current_end],
373
+ resolution_m=SAR_RESOLUTION_M,
374
  )
375
  baseline_cube = build_sar_graph(
376
  conn=conn, bbox=bbox,
377
  temporal_extent=[baseline_start, baseline_end],
378
+ resolution_m=SAR_RESOLUTION_M,
379
  )
380
  true_color_cube = build_true_color_graph(
381
  conn=conn, bbox=bbox,
382
  temporal_extent=[current_start, current_end],
383
+ resolution_m=TRUECOLOR_RESOLUTION_M,
384
  )
385
 
386
  loop = asyncio.get_event_loop()
 
402
 
403
  self._true_color_path = true_color_path
404
 
405
+ # --- Seasonal baseline analysis ---
406
  current_stats = self._compute_stats(current_path)
407
  baseline_stats = self._compute_stats(baseline_path)
408
+ current_mean = current_stats["overall_vv_mean"]
409
+ n_current_bands = current_stats["valid_months"]
410
 
411
+ if n_current_bands == 0:
 
412
  return self._insufficient_data(aoi, time_range)
413
 
414
+ spatial_completeness = self._compute_spatial_completeness(current_path)
 
415
 
416
+ # Build seasonal stats from baseline VV monthly means grouped by calendar month
417
+ seasonal_stats = self._build_seasonal_stats_from_vv(
418
+ baseline_stats["monthly_vv_means"], BASELINE_YEARS,
419
  )
420
 
421
+ start_month = time_range.start.month
422
+ most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
423
+
424
+ if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
425
+ s = seasonal_stats[most_recent_month]
426
+ z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_SAR)
427
+ else:
428
+ z_current = 0.0
429
+
430
+ # Per-month z-scores and anomaly count
431
+ anomaly_months = 0
432
+ monthly_zscores: list[float] = []
433
+ for i, val in enumerate(current_stats["monthly_vv_means"]):
434
+ if val == 0.0:
435
+ monthly_zscores.append(0.0)
436
+ continue
437
+ cal_month = ((start_month + i - 1) % 12) + 1
438
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
439
+ z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
440
+ seasonal_stats[cal_month]["std"], MIN_STD_SAR)
441
+ monthly_zscores.append(z)
442
+ if abs(z) > ZSCORE_THRESHOLD:
443
+ anomaly_months += 1
444
+ else:
445
+ monthly_zscores.append(0.0)
446
+
447
+ # Pixel-level hotspot detection using VV bands
448
+ hotspot_pct = 0.0
449
+ self._zscore_raster = None
450
+ self._hotspot_mask = None
451
+ baseline_vv_bands = self._get_vv_band_indices_for_month(
452
+ baseline_stats["valid_months_total"], BASELINE_YEARS, most_recent_month,
453
+ )
454
+ if baseline_vv_bands:
455
+ pixel_mean, pixel_std = self._compute_vv_pixel_stats(
456
+ baseline_path, baseline_vv_bands,
457
+ )
458
+ # Read most recent current VV band
459
+ with rasterio.open(current_path) as src:
460
+ n_current_total = src.count // 2
461
+ current_vv_idx = min(n_current_bands, n_current_total) * 2 - 1 # 1-based VV
462
+ current_data = src.read(current_vv_idx).astype(np.float32)
463
+ if src.nodata is not None:
464
+ current_data[current_data == src.nodata] = np.nan
465
+
466
+ z_raster = compute_zscore_raster(current_data, pixel_mean, pixel_std, MIN_STD_SAR)
467
+ hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
468
+ self._zscore_raster = z_raster
469
+ self._hotspot_mask = hotspot_mask
470
+
471
+ # Confidence scoring
472
+ baseline_depth = sum(1 for m in range(1, 13)
473
+ if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
474
+ mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
475
+ if m in seasonal_stats) / max(baseline_depth, 1))
476
+ conf = compute_confidence(
477
+ valid_months=n_current_bands,
478
+ mean_obs_per_composite=5.0,
479
+ baseline_years_with_data=int(mean_baseline_years),
480
+ spatial_completeness=spatial_completeness,
481
+ )
482
+ confidence = conf["level"]
483
+ confidence_factors = conf["factors"]
484
+
485
+ # Legacy flood detection (integrated with z-scores)
486
  flood_months = self._count_flood_months(
487
  current_stats["monthly_vv_means"],
488
  baseline_stats["overall_vv_mean"],
489
  baseline_stats["vv_std"],
490
  )
491
 
492
+ change_db = current_mean - baseline_stats["overall_vv_mean"]
493
+ change_pct = self._compute_change_area_pct(
494
+ current_path, baseline_path, current_stats, baseline_stats,
 
 
 
495
  )
496
 
497
+ status = self._classify_zscore(z_current, hotspot_pct)
498
+ trend = self._compute_trend_zscore(monthly_zscores)
499
+
500
+ chart_data = self._build_seasonal_chart_data(
501
+ current_stats["monthly_vv_means"], seasonal_stats, time_range, monthly_zscores,
502
  )
503
 
504
  # Headline
505
  parts = []
506
+ if abs(z_current) > ZSCORE_THRESHOLD:
507
+ parts.append(f"z={z_current:+.1f} vs seasonal baseline")
508
  if change_pct >= 5:
509
  parts.append(f"{change_pct:.0f}% ground surface change")
510
  if flood_months > 0:
511
  parts.append(f"{flood_months} potential flood event{'s' if flood_months > 1 else ''}")
512
  if parts:
513
+ headline = f"SAR backscatter anomaly: {', '.join(parts)} (mean VV {current_mean:.1f} dB)"
514
  else:
515
+ headline = f"Stable backscatter conditions (mean VV {current_mean:.1f} dB, z={z_current:+.1f})"
516
 
517
  # Store raster path for map rendering — write a change map
518
  change_map_path = os.path.join(results_dir, "sar_change.tif")
 
537
  map_layer_path=change_map_path,
538
  chart_data=chart_data,
539
  data_source="satellite",
540
+ anomaly_months=anomaly_months,
541
+ z_score_current=round(z_current, 2),
542
+ hotspot_pct=round(hotspot_pct, 1),
543
+ confidence_factors=confidence_factors,
544
  summary=(
545
+ f"Mean VV backscatter: {current_mean:.1f} dB (z-score {z_current:+.1f} vs seasonal baseline). "
546
+ f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
547
+ f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
548
+ f"Mean VV change: {change_db:+.1f} dB. "
549
+ f"{change_pct:.1f}% area with >{CHANGE_THRESHOLD_DB} dB change. "
550
  f"{flood_months} month(s) with potential flood signals. "
551
+ f"Pixel-level analysis at {SAR_RESOLUTION_M}m resolution."
 
552
  ),
553
  methodology=(
554
  f"Sentinel-1 GRD IW VV/VH polarizations, ascending orbit. "
555
  f"Linear backscatter converted to dB (10·log₁₀). "
556
+ f"Monthly median composites at {SAR_RESOLUTION_M}m resolution. "
557
+ f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
558
+ f"Anomaly detection via z-scores (threshold: ±{ZSCORE_THRESHOLD}). "
559
+ f"Change detection: >{CHANGE_THRESHOLD_DB} dB difference vs baseline. "
560
  f"Flood mapping: VV < baseline_mean − {FLOOD_SIGMA}σ. "
561
  f"Processed via CDSE openEO."
562
  ),
563
  limitations=[
564
+ f"Resampled to {SAR_RESOLUTION_M}m — fine-scale changes not captured.",
565
  "Ascending orbit filter may reduce temporal coverage in some areas.",
566
  "Sentinel-1 coverage over East Africa can be inconsistent.",
567
  "VV decrease may indicate flooding, moisture, or vegetation change — not uniquely flood.",
568
+ "Z-score anomalies assume baseline is representative of normal conditions.",
569
  ],
570
  )
571
 
572
+ # ------------------------------------------------------------------
573
+ # Statistics helpers
574
+ # ------------------------------------------------------------------
575
+
576
  @staticmethod
577
  def _compute_stats(tif_path: str) -> dict[str, Any]:
578
  """Extract monthly VV statistics from interleaved SAR GeoTIFF.
 
610
  "overall_vv_mean": overall_vv_mean,
611
  "vv_std": vv_std,
612
  "valid_months": valid_months,
613
+ "valid_months_total": n_bands // 2,
614
  }
615
 
616
+ @staticmethod
617
+ def _build_seasonal_stats_from_vv(
618
+ monthly_vv_means: list[float],
619
+ n_years: int,
620
+ ) -> dict[int, dict[str, Any]]:
621
+ """Group baseline monthly VV means by calendar month and compute stats.
622
+
623
+ This avoids calling ``compute_seasonal_stats_aoi`` directly on the
624
+ interleaved SAR TIFF (which would mix VV and VH bands).
625
+ """
626
+ grouped: dict[int, list[float]] = {m: [] for m in range(1, 13)}
627
+ for i, val in enumerate(monthly_vv_means):
628
+ if val == 0.0:
629
+ continue
630
+ cal_month = (i % 12) + 1
631
+ grouped[cal_month].append(val)
632
+
633
+ stats: dict[int, dict[str, Any]] = {}
634
+ for month, values in grouped.items():
635
+ if values:
636
+ arr = np.array(values)
637
+ stats[month] = {
638
+ "mean": float(np.mean(arr)),
639
+ "median": float(np.median(arr)),
640
+ "std": float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0,
641
+ "min": float(np.min(arr)),
642
+ "max": float(np.max(arr)),
643
+ "n_years": len(values),
644
+ }
645
+ else:
646
+ stats[month] = {
647
+ "mean": 0.0, "median": 0.0, "std": 0.0,
648
+ "min": 0.0, "max": 0.0, "n_years": 0,
649
+ }
650
+ return stats
651
+
652
+ @staticmethod
653
+ def _get_vv_band_indices_for_month(
654
+ n_total_months: int,
655
+ n_years: int,
656
+ target_month: int,
657
+ ) -> list[int]:
658
+ """Return 1-based VV band indices for a given calendar month.
659
+
660
+ Bands are interleaved VV/VH, so VV for month *i* (0-based) is at
661
+ band ``i * 2 + 1`` (1-based).
662
+ """
663
+ vv_bands: list[int] = []
664
+ for i in range(n_total_months):
665
+ cal_month = (i % 12) + 1
666
+ if cal_month == target_month:
667
+ vv_bands.append(i * 2 + 1) # 1-based
668
+ return vv_bands
669
+
670
+ @staticmethod
671
+ def _compute_vv_pixel_stats(
672
+ tif_path: str,
673
+ vv_band_indices: list[int],
674
+ ) -> tuple[np.ndarray, np.ndarray]:
675
+ """Compute per-pixel mean and std from specific VV bands."""
676
+ with rasterio.open(tif_path) as src:
677
+ nodata = src.nodata
678
+ stack = []
679
+ for band in vv_band_indices:
680
+ data = src.read(band).astype(np.float32)
681
+ if nodata is not None:
682
+ data[data == nodata] = np.nan
683
+ stack.append(data)
684
+
685
+ arr = np.stack(stack, axis=0)
686
+ with np.errstate(all="ignore"):
687
+ mean = np.nanmean(arr, axis=0)
688
+ std = np.nanstd(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
689
+ return mean, std
690
+
691
+ @staticmethod
692
+ def _compute_spatial_completeness(tif_path: str) -> float:
693
+ """Compute fraction of AOI with valid (non-nodata) pixels."""
694
+ with rasterio.open(tif_path) as src:
695
+ data = src.read(1).astype(np.float32)
696
+ nodata = src.nodata
697
+ if nodata is not None:
698
+ valid = np.sum(data != nodata)
699
+ else:
700
+ valid = np.sum(~np.isnan(data))
701
+ total = data.size
702
+ return float(valid / total) if total > 0 else 0.0
703
+
704
+ # ------------------------------------------------------------------
705
+ # Classification helpers (z-score based)
706
+ # ------------------------------------------------------------------
707
+
708
+ @staticmethod
709
+ def _classify_zscore(z_score: float, hotspot_pct: float) -> StatusLevel:
710
+ """Classify status using z-score and hotspot percentage."""
711
+ if abs(z_score) > ZSCORE_THRESHOLD or hotspot_pct > 25:
712
+ return StatusLevel.RED
713
+ if abs(z_score) > 1.0 or hotspot_pct > 10:
714
+ return StatusLevel.AMBER
715
+ return StatusLevel.GREEN
716
+
717
+ @staticmethod
718
+ def _compute_trend_zscore(monthly_zscores: list[float]) -> TrendDirection:
719
+ """Compute trend from direction of monthly z-scores."""
720
+ valid = [z for z in monthly_zscores if z != 0.0]
721
+ if len(valid) < 2:
722
+ return TrendDirection.STABLE
723
+ within_normal = sum(1 for z in valid if abs(z) <= 1.0)
724
+ if within_normal > len(valid) / 2:
725
+ return TrendDirection.STABLE
726
+ negative = sum(1 for z in valid if z < -1.0)
727
+ positive = sum(1 for z in valid if z > 1.0)
728
+ if negative > positive:
729
+ return TrendDirection.DETERIORATING
730
+ if positive > negative:
731
+ return TrendDirection.IMPROVING
732
+ return TrendDirection.STABLE
733
+
734
+ @staticmethod
735
+ def _build_seasonal_chart_data(
736
+ current_monthly: list[float],
737
+ seasonal_stats: dict[int, dict],
738
+ time_range: TimeRange,
739
+ monthly_zscores: list[float],
740
+ ) -> dict[str, Any]:
741
+ """Build chart data with seasonal baseline envelope."""
742
+ start_month = time_range.start.month
743
+ n = len(current_monthly)
744
+ year = time_range.end.year
745
+
746
+ dates: list[str] = []
747
+ values: list[float] = []
748
+ b_mean: list[float] = []
749
+ b_min: list[float] = []
750
+ b_max: list[float] = []
751
+ anomaly_flags: list[bool] = []
752
+
753
+ for i in range(n):
754
+ cal_month = ((start_month + i - 1) % 12) + 1
755
+ dates.append(f"{year}-{cal_month:02d}")
756
+ values.append(round(current_monthly[i], 2))
757
+
758
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
759
+ s = seasonal_stats[cal_month]
760
+ b_mean.append(round(s["mean"], 2))
761
+ b_min.append(round(s["min"], 2))
762
+ b_max.append(round(s["max"], 2))
763
+ else:
764
+ b_mean.append(0.0)
765
+ b_min.append(0.0)
766
+ b_max.append(0.0)
767
+
768
+ if i < len(monthly_zscores):
769
+ anomaly_flags.append(abs(monthly_zscores[i]) > ZSCORE_THRESHOLD)
770
+ else:
771
+ anomaly_flags.append(False)
772
+
773
+ return {
774
+ "dates": dates,
775
+ "values": values,
776
+ "baseline_mean": b_mean,
777
+ "baseline_min": b_min,
778
+ "baseline_max": b_max,
779
+ "anomaly_flags": anomaly_flags,
780
+ "label": "VV Backscatter (dB)",
781
+ }
782
+
783
+ # ------------------------------------------------------------------
784
+ # SAR-specific helpers (kept as-is)
785
+ # ------------------------------------------------------------------
786
+
787
  @staticmethod
788
  def _compute_change_area_pct(
789
  current_path: str, baseline_path: str,
 
817
  threshold = baseline_mean - FLOOD_SIGMA * baseline_std
818
  return sum(1 for v in monthly_vv if v != 0.0 and v < threshold)
819
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  @staticmethod
821
  def _write_change_raster(current_path: str, baseline_path: str, output_path: str) -> None:
822
  """Write a single-band GeoTIFF of VV change (current_mean - baseline_mean)."""
 
843
  "area and time period. SAR coverage over parts of East Africa "
844
  "is inconsistent."
845
  )
 
app/indicators/water.py CHANGED
@@ -1,7 +1,8 @@
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 baseline.
 
5
  """
6
  from __future__ import annotations
7
 
@@ -14,7 +15,13 @@ from typing import Any
14
  import numpy as np
15
  import rasterio
16
 
17
- from app.config import RESOLUTION_M
 
 
 
 
 
 
18
  from app.indicators.base import BaseIndicator, SpatialData
19
  from app.models import (
20
  AOI,
@@ -25,6 +32,14 @@ from app.models import (
25
  ConfidenceLevel,
26
  )
27
  from app.openeo_client import get_connection, build_mndwi_graph, build_true_color_graph, _bbox_dict, submit_as_batch
 
 
 
 
 
 
 
 
28
 
29
  logger = logging.getLogger(__name__)
30
 
@@ -56,22 +71,26 @@ class WaterIndicator(BaseIndicator):
56
  time_range.start.month,
57
  time_range.start.day,
58
  ).isoformat()
59
- baseline_end = time_range.start.isoformat()
 
 
 
 
60
 
61
  current_cube = build_mndwi_graph(
62
  conn=conn, bbox=bbox,
63
  temporal_extent=[current_start, current_end],
64
- resolution_m=RESOLUTION_M,
65
  )
66
  baseline_cube = build_mndwi_graph(
67
  conn=conn, bbox=bbox,
68
  temporal_extent=[baseline_start, baseline_end],
69
- resolution_m=RESOLUTION_M,
70
  )
71
  true_color_cube = build_true_color_graph(
72
  conn=conn, bbox=bbox,
73
  temporal_extent=[current_start, current_end],
74
- resolution_m=RESOLUTION_M,
75
  )
76
 
77
  return [
@@ -118,44 +137,105 @@ class WaterIndicator(BaseIndicator):
118
  except Exception as exc:
119
  logger.warning("Water true-color batch download failed: %s", exc)
120
 
121
- # Compute statistics
122
  current_stats = self._compute_stats(current_path)
 
123
  current_frac = current_stats["overall_water_fraction"]
 
 
 
124
 
125
  if baseline_path:
 
126
  baseline_stats = self._compute_stats(baseline_path)
127
- baseline_frac = baseline_stats["overall_water_fraction"]
128
- change_pp = (current_frac - baseline_frac) * 100
129
- confidence = (
130
- ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
131
- else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
132
- else ConfidenceLevel.LOW
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  )
134
- chart_data = self._build_chart_data(
135
- current_stats["monthly_water_fractions"],
136
- baseline_stats["monthly_water_fractions"],
137
- time_range,
 
 
 
 
138
  )
 
 
139
  else:
140
- baseline_frac = current_frac
141
- change_pp = 0.0
 
142
  confidence = ConfidenceLevel.LOW
 
 
 
 
 
 
143
  chart_data = {
144
  "dates": [f"{time_range.end.year}-{m+1:02d}" for m in range(len(current_stats["monthly_water_fractions"]))],
145
  "values": [round(v * 100, 1) for v in current_stats["monthly_water_fractions"]],
146
  "label": "Water extent (%)",
147
  }
148
 
149
- status = self._classify(abs(change_pp))
150
- trend = self._compute_trend(change_pp) if baseline_path else TrendDirection.STABLE
151
-
152
- if not baseline_path:
153
- headline = f"Water extent: {current_frac*100:.1f}% — baseline unavailable"
154
- elif abs(change_pp) <= 5:
155
- headline = f"Water extent stable ({current_frac*100:.1f}%, \u0394{change_pp:+.1f}pp)"
156
  else:
157
- direction = "increase" if change_pp > 0 else "decrease"
158
- headline = f"Water extent {direction} ({change_pp:+.1f}pp vs baseline)"
159
 
160
  self._spatial_data = SpatialData(
161
  map_type="raster",
@@ -177,27 +257,30 @@ class WaterIndicator(BaseIndicator):
177
  map_layer_path=current_path,
178
  chart_data=chart_data,
179
  data_source="satellite",
 
 
 
 
180
  summary=(
181
- f"Water covers {current_frac*100:.1f}% of the AOI compared to "
182
- f"{baseline_frac*100:.1f}% baseline ({change_pp:+.1f}pp). "
183
- f"Pixel-level MNDWI analysis at {RESOLUTION_M}m resolution."
184
- ) if baseline_path else (
185
- f"Water covers {current_frac*100:.1f}% of the AOI. "
186
- f"Baseline unavailable. "
187
- f"Pixel-level MNDWI analysis at {RESOLUTION_M}m resolution."
188
  ),
189
  methodology=(
190
  f"Sentinel-2 L2A pixel-level MNDWI = (B03 \u2212 B11) / (B03 + B11). "
191
  f"Cloud-masked using SCL band. Water classified as MNDWI > {WATER_THRESHOLD}. "
192
- f"Monthly median composites at {RESOLUTION_M}m. "
193
- f"Baseline: {BASELINE_YEARS}-year water extent frequency. "
194
- f"Processed via CDSE openEO batch jobs."
 
195
  ),
196
  limitations=[
197
- f"Resampled to {RESOLUTION_M}m \u2014 small water bodies may be missed.",
198
  "Cloud/shadow contamination can cause false water detections.",
199
  "Seasonal flooding may appear as change if analysis windows differ.",
200
  "MNDWI threshold is fixed; turbid water may be misclassified.",
 
201
  ] + (["Baseline unavailable \u2014 change and trend not computed."] if not baseline_path else []),
202
  )
203
 
@@ -221,24 +304,28 @@ class WaterIndicator(BaseIndicator):
221
  time_range.start.month,
222
  time_range.start.day,
223
  ).isoformat()
224
- baseline_end = time_range.start.isoformat()
 
 
 
 
225
 
226
  results_dir = tempfile.mkdtemp(prefix="aperture_water_")
227
 
228
  current_cube = build_mndwi_graph(
229
  conn=conn, bbox=bbox,
230
  temporal_extent=[current_start, current_end],
231
- resolution_m=RESOLUTION_M,
232
  )
233
  baseline_cube = build_mndwi_graph(
234
  conn=conn, bbox=bbox,
235
  temporal_extent=[baseline_start, baseline_end],
236
- resolution_m=RESOLUTION_M,
237
  )
238
  true_color_cube = build_true_color_graph(
239
  conn=conn, bbox=bbox,
240
  temporal_extent=[current_start, current_end],
241
- resolution_m=RESOLUTION_M,
242
  )
243
 
244
  loop = asyncio.get_event_loop()
@@ -252,32 +339,83 @@ class WaterIndicator(BaseIndicator):
252
 
253
  self._true_color_path = true_color_path
254
 
 
255
  current_stats = self._compute_stats(current_path)
256
  baseline_stats = self._compute_stats(baseline_path)
257
-
258
  current_frac = current_stats["overall_water_fraction"]
259
- baseline_frac = baseline_stats["overall_water_fraction"]
260
- change_pp = (current_frac - baseline_frac) * 100 # percentage points
261
-
262
- status = self._classify(abs(change_pp))
263
- trend = self._compute_trend(change_pp)
264
- confidence = (
265
- ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
266
- else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
267
- else ConfidenceLevel.LOW
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  )
 
 
269
 
270
- chart_data = self._build_chart_data(
271
- current_stats["monthly_water_fractions"],
272
- baseline_stats["monthly_water_fractions"],
273
- time_range,
274
  )
 
275
 
276
- direction = "increase" if change_pp > 0 else "decrease"
277
- if abs(change_pp) <= 5:
278
- headline = f"Water extent stable ({current_frac*100:.1f}%, \u0394{change_pp:+.1f}pp)"
 
279
  else:
280
- headline = f"Water extent {direction} ({change_pp:+.1f}pp vs baseline)"
281
 
282
  self._spatial_data = SpatialData(
283
  map_type="raster",
@@ -299,32 +437,40 @@ class WaterIndicator(BaseIndicator):
299
  map_layer_path=current_path,
300
  chart_data=chart_data,
301
  data_source="satellite",
 
 
 
 
302
  summary=(
303
- f"Water covers {current_frac*100:.1f}% of the AOI compared to "
304
- f"{baseline_frac*100:.1f}% baseline ({change_pp:+.1f}pp). "
305
- f"Pixel-level MNDWI analysis at {RESOLUTION_M}m resolution."
 
 
306
  ),
307
  methodology=(
308
  f"Sentinel-2 L2A pixel-level MNDWI = (B03 \u2212 B11) / (B03 + B11). "
309
  f"Cloud-masked using SCL band. Water classified as MNDWI > {WATER_THRESHOLD}. "
310
- f"Monthly median composites at {RESOLUTION_M}m. "
311
- f"Baseline: {BASELINE_YEARS}-year water extent frequency. "
312
- f"Processed via CDSE openEO."
 
313
  ),
314
  limitations=[
315
- f"Resampled to {RESOLUTION_M}m \u2014 small water bodies may be missed.",
316
  "Cloud/shadow contamination can cause false water detections.",
317
  "Seasonal flooding may appear as change if analysis windows differ.",
318
  "MNDWI threshold is fixed; turbid water may be misclassified.",
 
319
  ],
320
  )
321
 
322
  @staticmethod
323
  def _compute_stats(tif_path: str) -> dict[str, Any]:
324
- """Extract monthly water fraction statistics from MNDWI GeoTIFF."""
325
  with rasterio.open(tif_path) as src:
326
  n_bands = src.count
327
  monthly_fractions = []
 
328
  peak_frac = -1.0
329
  peak_band = 1
330
  for band in range(1, n_bands + 1):
@@ -338,50 +484,108 @@ class WaterIndicator(BaseIndicator):
338
  water_pixels = np.sum(valid > WATER_THRESHOLD)
339
  frac = float(water_pixels / len(valid))
340
  monthly_fractions.append(frac)
 
 
341
  if frac > peak_frac:
342
  peak_frac = frac
343
  peak_band = band
344
  else:
345
  monthly_fractions.append(0.0)
 
346
 
347
- overall = float(np.mean(monthly_fractions)) if monthly_fractions else 0.0
 
 
348
 
349
  return {
350
  "monthly_water_fractions": monthly_fractions,
351
- "overall_water_fraction": overall,
352
- "valid_months": len(monthly_fractions),
 
 
 
353
  "peak_water_band": peak_band,
354
  }
355
 
356
  @staticmethod
357
- def _classify(change_pp: float) -> StatusLevel:
358
- if change_pp <= 10:
359
- return StatusLevel.GREEN
360
- if change_pp <= 25:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  return StatusLevel.AMBER
362
- return StatusLevel.RED
363
 
364
  @staticmethod
365
- def _compute_trend(change_pp: float) -> TrendDirection:
366
- if abs(change_pp) <= 5:
 
 
367
  return TrendDirection.STABLE
368
- if change_pp > 0:
369
- return TrendDirection.DETERIORATING # flooding
370
- return TrendDirection.DETERIORATING # drought
 
 
 
 
 
 
 
371
 
372
  @staticmethod
373
- def _build_chart_data(
374
- current_monthly: list[float],
375
- baseline_monthly: list[float],
376
  time_range: TimeRange,
 
377
  ) -> dict[str, Any]:
 
 
 
378
  year = time_range.end.year
379
- n = min(len(current_monthly), len(baseline_monthly))
380
- dates = [f"{year}-{m + 1:02d}" for m in range(n)]
381
- values = [round(v * 100, 1) for v in current_monthly[:n]]
382
- b_mean = [round(v * 100, 1) for v in baseline_monthly[:n]]
383
- b_min = [round(max(v * 100 - 5, 0), 1) for v in baseline_monthly[:n]]
384
- b_max = [round(min(v * 100 + 5, 100), 1) for v in baseline_monthly[:n]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
  return {
387
  "dates": dates,
@@ -389,6 +593,6 @@ class WaterIndicator(BaseIndicator):
389
  "baseline_mean": b_mean,
390
  "baseline_min": b_min,
391
  "baseline_max": b_max,
 
392
  "label": "Water extent (%)",
393
  }
394
-
 
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
5
+ seasonal baseline using z-score anomaly detection.
6
  """
7
  from __future__ import annotations
8
 
 
15
  import numpy as np
16
  import rasterio
17
 
18
+ from app.config import (
19
+ WATER_RESOLUTION_M,
20
+ TRUECOLOR_RESOLUTION_M,
21
+ MIN_STD_WATER,
22
+ ZSCORE_THRESHOLD,
23
+ MIN_CLUSTER_PIXELS,
24
+ )
25
  from app.indicators.base import BaseIndicator, SpatialData
26
  from app.models import (
27
  AOI,
 
32
  ConfidenceLevel,
33
  )
34
  from app.openeo_client import get_connection, build_mndwi_graph, build_true_color_graph, _bbox_dict, submit_as_batch
35
+ from app.analysis.seasonal import (
36
+ group_bands_by_calendar_month,
37
+ compute_seasonal_stats_aoi,
38
+ compute_seasonal_stats_pixel,
39
+ compute_zscore,
40
+ )
41
+ from app.analysis.change import compute_zscore_raster, detect_hotspots, cluster_hotspots
42
+ from app.analysis.confidence import compute_confidence
43
 
44
  logger = logging.getLogger(__name__)
45
 
 
71
  time_range.start.month,
72
  time_range.start.day,
73
  ).isoformat()
74
+ baseline_end = date(
75
+ time_range.start.year,
76
+ time_range.start.month,
77
+ time_range.start.day,
78
+ ).isoformat()
79
 
80
  current_cube = build_mndwi_graph(
81
  conn=conn, bbox=bbox,
82
  temporal_extent=[current_start, current_end],
83
+ resolution_m=WATER_RESOLUTION_M,
84
  )
85
  baseline_cube = build_mndwi_graph(
86
  conn=conn, bbox=bbox,
87
  temporal_extent=[baseline_start, baseline_end],
88
+ resolution_m=WATER_RESOLUTION_M,
89
  )
90
  true_color_cube = build_true_color_graph(
91
  conn=conn, bbox=bbox,
92
  temporal_extent=[current_start, current_end],
93
+ resolution_m=TRUECOLOR_RESOLUTION_M,
94
  )
95
 
96
  return [
 
137
  except Exception as exc:
138
  logger.warning("Water true-color batch download failed: %s", exc)
139
 
140
+ # --- Seasonal baseline analysis ---
141
  current_stats = self._compute_stats(current_path)
142
+ current_mean = current_stats["overall_mean"]
143
  current_frac = current_stats["overall_water_fraction"]
144
+ n_current_bands = current_stats["valid_months"]
145
+
146
+ spatial_completeness = self._compute_spatial_completeness(current_path)
147
 
148
  if baseline_path:
149
+ seasonal_stats = compute_seasonal_stats_aoi(baseline_path, n_years=BASELINE_YEARS)
150
  baseline_stats = self._compute_stats(baseline_path)
151
+
152
+ start_month = time_range.start.month
153
+ most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
154
+
155
+ if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
156
+ s = seasonal_stats[most_recent_month]
157
+ z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_WATER)
158
+ else:
159
+ z_current = 0.0
160
+
161
+ anomaly_months = 0
162
+ monthly_zscores = []
163
+ for i, val in enumerate(current_stats["monthly_means"]):
164
+ if val <= -1.0:
165
+ monthly_zscores.append(0.0)
166
+ continue
167
+ cal_month = ((start_month + i - 1) % 12) + 1
168
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
169
+ z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
170
+ seasonal_stats[cal_month]["std"], MIN_STD_WATER)
171
+ monthly_zscores.append(z)
172
+ if abs(z) > ZSCORE_THRESHOLD:
173
+ anomaly_months += 1
174
+ else:
175
+ monthly_zscores.append(0.0)
176
+
177
+ month_map = group_bands_by_calendar_month(baseline_stats["valid_months_total"], BASELINE_YEARS)
178
+ hotspot_pct = 0.0
179
+ self._zscore_raster = None
180
+ self._hotspot_mask = None
181
+ if most_recent_month in month_map and len(month_map[most_recent_month]) > 0:
182
+ pixel_stats = compute_seasonal_stats_pixel(baseline_path, month_map[most_recent_month])
183
+ with rasterio.open(current_path) as src:
184
+ current_band_idx = min(n_current_bands, src.count)
185
+ current_data = src.read(current_band_idx).astype(np.float32)
186
+ if src.nodata is not None:
187
+ current_data[current_data == src.nodata] = np.nan
188
+
189
+ z_raster = compute_zscore_raster(current_data, pixel_stats["mean"],
190
+ pixel_stats["std"], MIN_STD_WATER)
191
+ hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
192
+ self._zscore_raster = z_raster
193
+ self._hotspot_mask = hotspot_mask
194
+
195
+ baseline_depth = sum(1 for m in range(1, 13)
196
+ if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
197
+ mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
198
+ if m in seasonal_stats) / max(baseline_depth, 1))
199
+ conf = compute_confidence(
200
+ valid_months=n_current_bands,
201
+ mean_obs_per_composite=5.0,
202
+ baseline_years_with_data=int(mean_baseline_years),
203
+ spatial_completeness=spatial_completeness,
204
  )
205
+ confidence = conf["level"]
206
+ confidence_factors = conf["factors"]
207
+
208
+ status = self._classify_zscore(z_current, hotspot_pct)
209
+ trend = self._compute_trend_zscore(monthly_zscores)
210
+
211
+ chart_data = self._build_seasonal_chart_data(
212
+ current_stats["monthly_water_fractions"], seasonal_stats, time_range, monthly_zscores,
213
  )
214
+
215
+ change = current_mean - baseline_stats["overall_mean"]
216
  else:
217
+ z_current = 0.0
218
+ anomaly_months = 0
219
+ hotspot_pct = 0.0
220
  confidence = ConfidenceLevel.LOW
221
+ confidence_factors = {}
222
+ status = StatusLevel.GREEN
223
+ trend = TrendDirection.STABLE
224
+ change = 0.0
225
+ self._zscore_raster = None
226
+ self._hotspot_mask = None
227
  chart_data = {
228
  "dates": [f"{time_range.end.year}-{m+1:02d}" for m in range(len(current_stats["monthly_water_fractions"]))],
229
  "values": [round(v * 100, 1) for v in current_stats["monthly_water_fractions"]],
230
  "label": "Water extent (%)",
231
  }
232
 
233
+ if abs(z_current) <= 1.0:
234
+ headline = f"Water extent within seasonal range ({current_frac*100:.1f}%, z={z_current:+.1f})"
235
+ elif z_current > 0:
236
+ headline = f"Water extent above seasonal average ({current_frac*100:.1f}%, z={z_current:+.1f})"
 
 
 
237
  else:
238
+ headline = f"Water extent anomaly detected ({current_frac*100:.1f}%, z={z_current:+.1f})"
 
239
 
240
  self._spatial_data = SpatialData(
241
  map_type="raster",
 
257
  map_layer_path=current_path,
258
  chart_data=chart_data,
259
  data_source="satellite",
260
+ anomaly_months=anomaly_months,
261
+ z_score_current=round(z_current, 2),
262
+ hotspot_pct=round(hotspot_pct, 1),
263
+ confidence_factors=confidence_factors,
264
  summary=(
265
+ f"Water covers {current_frac*100:.1f}% of the AOI (mean MNDWI {current_mean:.3f}, "
266
+ f"z-score {z_current:+.1f} vs seasonal baseline). "
267
+ f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
268
+ f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
269
+ f"Pixel-level MNDWI analysis at {WATER_RESOLUTION_M}m resolution."
 
 
270
  ),
271
  methodology=(
272
  f"Sentinel-2 L2A pixel-level MNDWI = (B03 \u2212 B11) / (B03 + B11). "
273
  f"Cloud-masked using SCL band. Water classified as MNDWI > {WATER_THRESHOLD}. "
274
+ f"Monthly median composites at {WATER_RESOLUTION_M}m native resolution. "
275
+ f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
276
+ f"Anomaly detection via z-scores (threshold: \u00b1{ZSCORE_THRESHOLD}). "
277
+ f"Processed server-side via CDSE openEO batch jobs."
278
  ),
279
  limitations=[
 
280
  "Cloud/shadow contamination can cause false water detections.",
281
  "Seasonal flooding may appear as change if analysis windows differ.",
282
  "MNDWI threshold is fixed; turbid water may be misclassified.",
283
+ "Z-score anomalies assume baseline is representative of normal conditions.",
284
  ] + (["Baseline unavailable \u2014 change and trend not computed."] if not baseline_path else []),
285
  )
286
 
 
304
  time_range.start.month,
305
  time_range.start.day,
306
  ).isoformat()
307
+ baseline_end = date(
308
+ time_range.start.year,
309
+ time_range.start.month,
310
+ time_range.start.day,
311
+ ).isoformat()
312
 
313
  results_dir = tempfile.mkdtemp(prefix="aperture_water_")
314
 
315
  current_cube = build_mndwi_graph(
316
  conn=conn, bbox=bbox,
317
  temporal_extent=[current_start, current_end],
318
+ resolution_m=WATER_RESOLUTION_M,
319
  )
320
  baseline_cube = build_mndwi_graph(
321
  conn=conn, bbox=bbox,
322
  temporal_extent=[baseline_start, baseline_end],
323
+ resolution_m=WATER_RESOLUTION_M,
324
  )
325
  true_color_cube = build_true_color_graph(
326
  conn=conn, bbox=bbox,
327
  temporal_extent=[current_start, current_end],
328
+ resolution_m=TRUECOLOR_RESOLUTION_M,
329
  )
330
 
331
  loop = asyncio.get_event_loop()
 
339
 
340
  self._true_color_path = true_color_path
341
 
342
+ # --- Seasonal baseline analysis ---
343
  current_stats = self._compute_stats(current_path)
344
  baseline_stats = self._compute_stats(baseline_path)
345
+ current_mean = current_stats["overall_mean"]
346
  current_frac = current_stats["overall_water_fraction"]
347
+ n_current_bands = current_stats["valid_months"]
348
+ spatial_completeness = self._compute_spatial_completeness(current_path)
349
+
350
+ seasonal_stats = compute_seasonal_stats_aoi(baseline_path, n_years=BASELINE_YEARS)
351
+ start_month = time_range.start.month
352
+ most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
353
+
354
+ if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
355
+ s = seasonal_stats[most_recent_month]
356
+ z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_WATER)
357
+ else:
358
+ z_current = 0.0
359
+
360
+ anomaly_months = 0
361
+ monthly_zscores = []
362
+ for i, val in enumerate(current_stats["monthly_means"]):
363
+ if val <= -1.0:
364
+ monthly_zscores.append(0.0)
365
+ continue
366
+ cal_month = ((start_month + i - 1) % 12) + 1
367
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
368
+ z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
369
+ seasonal_stats[cal_month]["std"], MIN_STD_WATER)
370
+ monthly_zscores.append(z)
371
+ if abs(z) > ZSCORE_THRESHOLD:
372
+ anomaly_months += 1
373
+ else:
374
+ monthly_zscores.append(0.0)
375
+
376
+ month_map = group_bands_by_calendar_month(baseline_stats["valid_months_total"], BASELINE_YEARS)
377
+ hotspot_pct = 0.0
378
+ self._zscore_raster = None
379
+ self._hotspot_mask = None
380
+ if most_recent_month in month_map and len(month_map[most_recent_month]) > 0:
381
+ pixel_stats = compute_seasonal_stats_pixel(baseline_path, month_map[most_recent_month])
382
+ with rasterio.open(current_path) as src:
383
+ current_band_idx = min(n_current_bands, src.count)
384
+ current_data = src.read(current_band_idx).astype(np.float32)
385
+ if src.nodata is not None:
386
+ current_data[current_data == src.nodata] = np.nan
387
+ z_raster = compute_zscore_raster(current_data, pixel_stats["mean"],
388
+ pixel_stats["std"], MIN_STD_WATER)
389
+ hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
390
+ self._zscore_raster = z_raster
391
+ self._hotspot_mask = hotspot_mask
392
+
393
+ baseline_depth = sum(1 for m in range(1, 13)
394
+ if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
395
+ mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
396
+ if m in seasonal_stats) / max(baseline_depth, 1))
397
+ conf = compute_confidence(
398
+ valid_months=n_current_bands,
399
+ mean_obs_per_composite=5.0,
400
+ baseline_years_with_data=int(mean_baseline_years),
401
+ spatial_completeness=spatial_completeness,
402
  )
403
+ confidence = conf["level"]
404
+ confidence_factors = conf["factors"]
405
 
406
+ status = self._classify_zscore(z_current, hotspot_pct)
407
+ trend = self._compute_trend_zscore(monthly_zscores)
408
+ chart_data = self._build_seasonal_chart_data(
409
+ current_stats["monthly_water_fractions"], seasonal_stats, time_range, monthly_zscores,
410
  )
411
+ change = current_mean - baseline_stats["overall_mean"]
412
 
413
+ if abs(z_current) <= 1.0:
414
+ headline = f"Water extent within seasonal range ({current_frac*100:.1f}%, z={z_current:+.1f})"
415
+ elif z_current > 0:
416
+ headline = f"Water extent above seasonal average ({current_frac*100:.1f}%, z={z_current:+.1f})"
417
  else:
418
+ headline = f"Water extent anomaly detected ({current_frac*100:.1f}%, z={z_current:+.1f})"
419
 
420
  self._spatial_data = SpatialData(
421
  map_type="raster",
 
437
  map_layer_path=current_path,
438
  chart_data=chart_data,
439
  data_source="satellite",
440
+ anomaly_months=anomaly_months,
441
+ z_score_current=round(z_current, 2),
442
+ hotspot_pct=round(hotspot_pct, 1),
443
+ confidence_factors=confidence_factors,
444
  summary=(
445
+ f"Water covers {current_frac*100:.1f}% of the AOI (mean MNDWI {current_mean:.3f}, "
446
+ f"z-score {z_current:+.1f} vs seasonal baseline). "
447
+ f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
448
+ f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
449
+ f"Pixel-level MNDWI analysis at {WATER_RESOLUTION_M}m resolution."
450
  ),
451
  methodology=(
452
  f"Sentinel-2 L2A pixel-level MNDWI = (B03 \u2212 B11) / (B03 + B11). "
453
  f"Cloud-masked using SCL band. Water classified as MNDWI > {WATER_THRESHOLD}. "
454
+ f"Monthly median composites at {WATER_RESOLUTION_M}m native resolution. "
455
+ f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
456
+ f"Anomaly detection via z-scores (threshold: \u00b1{ZSCORE_THRESHOLD}). "
457
+ f"Processed server-side via CDSE openEO."
458
  ),
459
  limitations=[
 
460
  "Cloud/shadow contamination can cause false water detections.",
461
  "Seasonal flooding may appear as change if analysis windows differ.",
462
  "MNDWI threshold is fixed; turbid water may be misclassified.",
463
+ "Z-score anomalies assume baseline is representative of normal conditions.",
464
  ],
465
  )
466
 
467
  @staticmethod
468
  def _compute_stats(tif_path: str) -> dict[str, Any]:
469
+ """Extract monthly water fraction and MNDWI statistics from a multi-band GeoTIFF."""
470
  with rasterio.open(tif_path) as src:
471
  n_bands = src.count
472
  monthly_fractions = []
473
+ monthly_means = []
474
  peak_frac = -1.0
475
  peak_band = 1
476
  for band in range(1, n_bands + 1):
 
484
  water_pixels = np.sum(valid > WATER_THRESHOLD)
485
  frac = float(water_pixels / len(valid))
486
  monthly_fractions.append(frac)
487
+ mean_val = float(np.nanmean(valid))
488
+ monthly_means.append(mean_val)
489
  if frac > peak_frac:
490
  peak_frac = frac
491
  peak_band = band
492
  else:
493
  monthly_fractions.append(0.0)
494
+ monthly_means.append(0.0)
495
 
496
+ overall_frac = float(np.mean(monthly_fractions)) if monthly_fractions else 0.0
497
+ valid_months = sum(1 for m in monthly_means if m > -1.0)
498
+ overall_mean = float(np.mean([m for m in monthly_means if m > -1.0])) if valid_months > 0 else 0.0
499
 
500
  return {
501
  "monthly_water_fractions": monthly_fractions,
502
+ "monthly_means": monthly_means,
503
+ "overall_water_fraction": overall_frac,
504
+ "overall_mean": overall_mean,
505
+ "valid_months": valid_months,
506
+ "valid_months_total": n_bands,
507
  "peak_water_band": peak_band,
508
  }
509
 
510
  @staticmethod
511
+ def _compute_spatial_completeness(tif_path: str) -> float:
512
+ """Compute fraction of AOI with valid (non-nodata) pixels."""
513
+ with rasterio.open(tif_path) as src:
514
+ data = src.read(1).astype(np.float32)
515
+ nodata = src.nodata
516
+ if nodata is not None:
517
+ valid = np.sum(data != nodata)
518
+ else:
519
+ valid = np.sum(~np.isnan(data))
520
+ total = data.size
521
+ return float(valid / total) if total > 0 else 0.0
522
+
523
+ @staticmethod
524
+ def _classify_zscore(z_score: float, hotspot_pct: float) -> StatusLevel:
525
+ """Classify status using z-score and hotspot percentage."""
526
+ if abs(z_score) > ZSCORE_THRESHOLD or hotspot_pct > 25:
527
+ return StatusLevel.RED
528
+ if abs(z_score) > 1.0 or hotspot_pct > 10:
529
  return StatusLevel.AMBER
530
+ return StatusLevel.GREEN
531
 
532
  @staticmethod
533
+ def _compute_trend_zscore(monthly_zscores: list[float]) -> TrendDirection:
534
+ """Compute trend from direction of monthly z-scores."""
535
+ valid = [z for z in monthly_zscores if z != 0.0]
536
+ if len(valid) < 2:
537
  return TrendDirection.STABLE
538
+ within_normal = sum(1 for z in valid if abs(z) <= 1.0)
539
+ if within_normal > len(valid) / 2:
540
+ return TrendDirection.STABLE
541
+ negative = sum(1 for z in valid if z < -1.0)
542
+ positive = sum(1 for z in valid if z > 1.0)
543
+ if negative > positive:
544
+ return TrendDirection.DETERIORATING
545
+ if positive > negative:
546
+ return TrendDirection.IMPROVING
547
+ return TrendDirection.STABLE
548
 
549
  @staticmethod
550
+ def _build_seasonal_chart_data(
551
+ current_monthly_fractions: list[float],
552
+ seasonal_stats: dict[int, dict],
553
  time_range: TimeRange,
554
+ monthly_zscores: list[float],
555
  ) -> dict[str, Any]:
556
+ """Build chart data with seasonal baseline envelope (water extent %)."""
557
+ start_month = time_range.start.month
558
+ n = len(current_monthly_fractions)
559
  year = time_range.end.year
560
+
561
+ dates = []
562
+ values = []
563
+ b_mean = []
564
+ b_min = []
565
+ b_max = []
566
+ anomaly_flags = []
567
+
568
+ for i in range(n):
569
+ cal_month = ((start_month + i - 1) % 12) + 1
570
+ dates.append(f"{year}-{cal_month:02d}")
571
+ values.append(round(current_monthly_fractions[i] * 100, 1))
572
+
573
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
574
+ s = seasonal_stats[cal_month]
575
+ # Convert MNDWI-based stats to approximate water fraction percentages
576
+ # using baseline mean/min/max as proxies for extent envelope
577
+ b_mean.append(round(s["mean"] * 100, 1) if s["mean"] > 0 else 0.0)
578
+ b_min.append(round(s["min"] * 100, 1) if s["min"] > 0 else 0.0)
579
+ b_max.append(round(s["max"] * 100, 1) if s["max"] > 0 else 0.0)
580
+ else:
581
+ b_mean.append(0.0)
582
+ b_min.append(0.0)
583
+ b_max.append(0.0)
584
+
585
+ if i < len(monthly_zscores):
586
+ anomaly_flags.append(abs(monthly_zscores[i]) > ZSCORE_THRESHOLD)
587
+ else:
588
+ anomaly_flags.append(False)
589
 
590
  return {
591
  "dates": dates,
 
593
  "baseline_mean": b_mean,
594
  "baseline_min": b_min,
595
  "baseline_max": b_max,
596
+ "anomaly_flags": anomaly_flags,
597
  "label": "Water extent (%)",
598
  }