KSvend Claude Happy commited on
Commit ·
3d48a34
1
Parent(s): 9d401d9
feat: upgrade Water, SAR, and Settlement indicators with seasonal analysis
Browse filesApply 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>
- app/indicators/buildup.py +334 -108
- app/indicators/sar.py +415 -115
- 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
|
| 5 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
current_cube = build_buildup_graph(
|
| 63 |
conn=conn, bbox=bbox,
|
| 64 |
temporal_extent=[current_start, current_end],
|
| 65 |
-
resolution_m=
|
| 66 |
)
|
| 67 |
baseline_cube = build_buildup_graph(
|
| 68 |
conn=conn, bbox=bbox,
|
| 69 |
temporal_extent=[baseline_start, baseline_end],
|
| 70 |
-
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=
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
else:
|
| 137 |
-
change_pct = 100.0 if current_frac > 0 else 0.0
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
)
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
baseline_stats["monthly_buildup_fractions"],
|
| 148 |
-
time_range,
|
| 149 |
-
aoi_ha,
|
| 150 |
-
)
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
else:
|
| 160 |
-
headline =
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 187 |
-
f"(
|
| 188 |
-
f"
|
|
|
|
|
|
|
| 189 |
),
|
| 190 |
methodology=(
|
| 191 |
-
f"Sentinel-2 L2A pixel-level NDBI = (B11
|
| 192 |
f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
|
| 193 |
f"Cloud-masked using SCL band. "
|
| 194 |
-
f"Monthly median composites at {
|
| 195 |
-
f"Baseline: {BASELINE_YEARS}-year
|
|
|
|
| 196 |
f"Processed via CDSE openEO batch jobs."
|
| 197 |
),
|
| 198 |
limitations=[
|
| 199 |
-
f"Resampled to {
|
| 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 |
-
|
|
|
|
|
|
|
| 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}%
|
| 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=
|
| 233 |
-
trend=
|
| 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
|
| 241 |
-
f"Pixel-level NDBI analysis at {
|
| 242 |
),
|
| 243 |
methodology=(
|
| 244 |
-
f"Sentinel-2 L2A pixel-level NDBI = (B11
|
| 245 |
f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
|
| 246 |
f"Cloud-masked using SCL band. "
|
| 247 |
-
f"Monthly median composites at {
|
| 248 |
f"Processed via CDSE openEO batch jobs."
|
| 249 |
),
|
| 250 |
limitations=[
|
| 251 |
-
f"Resampled to {
|
| 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
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 287 |
)
|
| 288 |
baseline_cube = build_buildup_graph(
|
| 289 |
conn=conn, bbox=bbox,
|
| 290 |
temporal_extent=[baseline_start, baseline_end],
|
| 291 |
-
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=
|
| 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 |
-
|
| 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 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
else:
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
)
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
| 347 |
)
|
| 348 |
|
| 349 |
-
# Headline
|
| 350 |
-
if abs(
|
| 351 |
-
headline =
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
else:
|
| 355 |
-
headline =
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 383 |
-
f"(
|
| 384 |
-
f"
|
|
|
|
|
|
|
| 385 |
),
|
| 386 |
methodology=(
|
| 387 |
-
f"Sentinel-2 L2A pixel-level NDBI = (B11
|
| 388 |
f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
|
| 389 |
f"Cloud-masked using SCL band. "
|
| 390 |
-
f"Monthly median composites at {
|
| 391 |
-
f"Baseline: {BASELINE_YEARS}-year
|
| 392 |
-
f"
|
|
|
|
| 393 |
),
|
| 394 |
limitations=[
|
| 395 |
-
f"Resampled to {
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
return {
|
| 434 |
"monthly_buildup_fractions": monthly_fractions,
|
| 435 |
-
"overall_buildup_fraction":
|
| 436 |
-
"valid_months":
|
|
|
|
| 437 |
"peak_buildup_band": peak_band,
|
|
|
|
|
|
|
| 438 |
}
|
| 439 |
|
| 440 |
@staticmethod
|
| 441 |
-
def
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
return StatusLevel.AMBER
|
| 447 |
-
return StatusLevel.
|
| 448 |
|
| 449 |
@staticmethod
|
| 450 |
-
def
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
return TrendDirection.STABLE
|
| 453 |
-
|
|
|
|
|
|
|
| 454 |
return TrendDirection.DETERIORATING
|
|
|
|
|
|
|
| 455 |
return TrendDirection.STABLE
|
| 456 |
|
| 457 |
@staticmethod
|
| 458 |
-
def
|
| 459 |
-
|
| 460 |
-
|
| 461 |
time_range: TimeRange,
|
|
|
|
| 462 |
aoi_ha: float,
|
| 463 |
) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
| 464 |
year = time_range.end.year
|
| 465 |
-
|
| 466 |
-
dates = [
|
| 467 |
-
values = [
|
| 468 |
-
b_mean = [
|
| 469 |
-
b_min = [
|
| 470 |
-
b_max = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 66 |
)
|
| 67 |
baseline_cube = build_sar_graph(
|
| 68 |
conn=conn, bbox=bbox,
|
| 69 |
temporal_extent=[baseline_start, baseline_end],
|
| 70 |
-
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=
|
| 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
|
| 125 |
return self._insufficient_data(aoi, time_range)
|
| 126 |
|
|
|
|
|
|
|
| 127 |
if baseline_path:
|
| 128 |
baseline_stats = self._compute_stats(baseline_path)
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
|
| 144 |
-
else ConfidenceLevel.LOW
|
| 145 |
)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 159 |
-
f"SAR
|
| 160 |
-
|
| 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
|
| 179 |
-
f"{
|
|
|
|
|
|
|
|
|
|
| 180 |
f"{flood_months} month(s) with potential flood signals. "
|
| 181 |
-
f"Pixel-level analysis at {
|
| 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 |
-
|
| 191 |
-
|
| 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 {
|
| 215 |
-
f"{
|
| 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 {
|
| 235 |
-
f"
|
| 236 |
-
f"
|
|
|
|
| 237 |
f"Flood mapping: VV < baseline_mean − {FLOOD_SIGMA}σ. "
|
| 238 |
f"Processed via CDSE openEO batch jobs."
|
| 239 |
),
|
| 240 |
limitations=[
|
| 241 |
-
f"Resampled to {
|
| 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=
|
| 276 |
)
|
| 277 |
baseline_cube = build_sar_graph(
|
| 278 |
conn=conn, bbox=bbox,
|
| 279 |
temporal_extent=[baseline_start, baseline_end],
|
| 280 |
-
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=
|
| 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 |
-
|
| 311 |
-
if current_stats["valid_months"] == 0:
|
| 312 |
return self._insufficient_data(aoi, time_range)
|
| 313 |
|
| 314 |
-
|
| 315 |
-
change_db = current_stats["overall_vv_mean"] - baseline_stats["overall_vv_mean"]
|
| 316 |
|
| 317 |
-
#
|
| 318 |
-
|
| 319 |
-
|
| 320 |
)
|
| 321 |
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 330 |
-
|
| 331 |
-
|
| 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 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
| 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
|
| 351 |
else:
|
| 352 |
-
headline = "Stable backscatter conditions
|
| 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
|
| 379 |
-
f"{
|
|
|
|
|
|
|
|
|
|
| 380 |
f"{flood_months} month(s) with potential flood signals. "
|
| 381 |
-
f"Pixel-level analysis at {
|
| 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 {
|
| 388 |
-
f"
|
| 389 |
-
f"
|
|
|
|
| 390 |
f"Flood mapping: VV < baseline_mean − {FLOOD_SIGMA}σ. "
|
| 391 |
f"Processed via CDSE openEO."
|
| 392 |
),
|
| 393 |
limitations=[
|
| 394 |
-
f"Resampled to {
|
| 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
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
current_cube = build_mndwi_graph(
|
| 62 |
conn=conn, bbox=bbox,
|
| 63 |
temporal_extent=[current_start, current_end],
|
| 64 |
-
resolution_m=
|
| 65 |
)
|
| 66 |
baseline_cube = build_mndwi_graph(
|
| 67 |
conn=conn, bbox=bbox,
|
| 68 |
temporal_extent=[baseline_start, baseline_end],
|
| 69 |
-
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=
|
| 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 |
-
#
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
)
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
)
|
|
|
|
|
|
|
| 139 |
else:
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 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 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 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 |
-
|
| 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
|
| 182 |
-
f"
|
| 183 |
-
f"
|
| 184 |
-
|
| 185 |
-
f"
|
| 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 {
|
| 193 |
-
f"Baseline: {BASELINE_YEARS}-year
|
| 194 |
-
f"
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 232 |
)
|
| 233 |
baseline_cube = build_mndwi_graph(
|
| 234 |
conn=conn, bbox=bbox,
|
| 235 |
temporal_extent=[baseline_start, baseline_end],
|
| 236 |
-
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=
|
| 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 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
)
|
|
|
|
|
|
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
time_range,
|
| 274 |
)
|
|
|
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
| 279 |
else:
|
| 280 |
-
headline = f"Water extent {
|
| 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
|
| 304 |
-
f"
|
| 305 |
-
f"
|
|
|
|
|
|
|
| 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 {
|
| 311 |
-
f"Baseline: {BASELINE_YEARS}-year
|
| 312 |
-
f"
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 348 |
|
| 349 |
return {
|
| 350 |
"monthly_water_fractions": monthly_fractions,
|
| 351 |
-
"
|
| 352 |
-
"
|
|
|
|
|
|
|
|
|
|
| 353 |
"peak_water_band": peak_band,
|
| 354 |
}
|
| 355 |
|
| 356 |
@staticmethod
|
| 357 |
-
def
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
return StatusLevel.AMBER
|
| 362 |
-
return StatusLevel.
|
| 363 |
|
| 364 |
@staticmethod
|
| 365 |
-
def
|
| 366 |
-
|
|
|
|
|
|
|
| 367 |
return TrendDirection.STABLE
|
| 368 |
-
if
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
@staticmethod
|
| 373 |
-
def
|
| 374 |
-
|
| 375 |
-
|
| 376 |
time_range: TimeRange,
|
|
|
|
| 377 |
) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
| 378 |
year = time_range.end.year
|
| 379 |
-
|
| 380 |
-
dates = [
|
| 381 |
-
values = [
|
| 382 |
-
b_mean = [
|
| 383 |
-
b_min = [
|
| 384 |
-
b_max = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
|