KSvend Claude Happy commited on
Commit ·
9d401d9
1
Parent(s): ae4c60c
chore: remove NDVI harvest diagnostic print statements
Browse filesPipeline confirmed stable — remove debug prints added during batch job troubleshooting.
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/ndvi.py +261 -99
app/indicators/ndvi.py
CHANGED
|
@@ -15,7 +15,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 +32,14 @@ from app.models import (
|
|
| 26 |
ConfidenceLevel,
|
| 27 |
)
|
| 28 |
from app.openeo_client import get_connection, build_ndvi_graph, build_true_color_graph, _bbox_dict, submit_as_batch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
|
@@ -65,17 +79,17 @@ class NdviIndicator(BaseIndicator):
|
|
| 65 |
current_cube = build_ndvi_graph(
|
| 66 |
conn=conn, bbox=bbox,
|
| 67 |
temporal_extent=[current_start, current_end],
|
| 68 |
-
resolution_m=
|
| 69 |
)
|
| 70 |
baseline_cube = build_ndvi_graph(
|
| 71 |
conn=conn, bbox=bbox,
|
| 72 |
temporal_extent=[baseline_start, baseline_end],
|
| 73 |
-
resolution_m=
|
| 74 |
)
|
| 75 |
true_color_cube = build_true_color_graph(
|
| 76 |
conn=conn, bbox=bbox,
|
| 77 |
temporal_extent=[current_start, current_end],
|
| 78 |
-
resolution_m=
|
| 79 |
)
|
| 80 |
|
| 81 |
return [
|
|
@@ -98,10 +112,7 @@ class NdviIndicator(BaseIndicator):
|
|
| 98 |
current_dir = os.path.join(results_dir, "current")
|
| 99 |
os.makedirs(current_dir, exist_ok=True)
|
| 100 |
paths = current_job.download_results(current_dir)
|
| 101 |
-
print(f"[Aperture] NDVI current download_results returned: {paths}")
|
| 102 |
-
print(f"[Aperture] NDVI current dir listing: {os.listdir(current_dir)}")
|
| 103 |
current_path = self._find_tif(paths, current_dir)
|
| 104 |
-
print(f"[Aperture] NDVI current_path: {current_path} (exists={os.path.exists(current_path)}, size={os.path.getsize(current_path) if os.path.exists(current_path) else 'N/A'})")
|
| 105 |
except Exception as exc:
|
| 106 |
raise RuntimeError(f"NDVI current period data unavailable: {exc}") from exc
|
| 107 |
|
|
@@ -111,13 +122,9 @@ class NdviIndicator(BaseIndicator):
|
|
| 111 |
baseline_dir = os.path.join(results_dir, "baseline")
|
| 112 |
os.makedirs(baseline_dir, exist_ok=True)
|
| 113 |
paths = baseline_job.download_results(baseline_dir)
|
| 114 |
-
print(f"[Aperture] NDVI baseline download_results returned: {paths}")
|
| 115 |
-
print(f"[Aperture] NDVI baseline dir listing: {os.listdir(baseline_dir)}")
|
| 116 |
baseline_path = self._find_tif(paths, baseline_dir)
|
| 117 |
-
print(f"[Aperture] NDVI baseline_path: {baseline_path} (exists={os.path.exists(baseline_path)}, size={os.path.getsize(baseline_path) if os.path.exists(baseline_path) else 'N/A'})")
|
| 118 |
except Exception as exc:
|
| 119 |
logger.warning("NDVI baseline batch download failed, degrading: %s", exc)
|
| 120 |
-
print(f"[Aperture] NDVI baseline download EXCEPTION: {type(exc).__name__}: {exc}")
|
| 121 |
|
| 122 |
# Download true-color — optional
|
| 123 |
true_color_path = None
|
|
@@ -125,52 +132,108 @@ class NdviIndicator(BaseIndicator):
|
|
| 125 |
tc_dir = os.path.join(results_dir, "truecolor")
|
| 126 |
os.makedirs(tc_dir, exist_ok=True)
|
| 127 |
paths = true_color_job.download_results(tc_dir)
|
| 128 |
-
print(f"[Aperture] NDVI true-color download_results returned: {paths}")
|
| 129 |
true_color_path = self._find_tif(paths, tc_dir)
|
| 130 |
-
print(f"[Aperture] NDVI true_color_path: {true_color_path}")
|
| 131 |
except Exception as exc:
|
| 132 |
logger.warning("NDVI true-color batch download failed: %s", exc)
|
| 133 |
-
print(f"[Aperture] NDVI true-color download EXCEPTION: {type(exc).__name__}: {exc}")
|
| 134 |
|
| 135 |
-
#
|
| 136 |
-
print(f"[Aperture] NDVI computing stats from: {current_path}")
|
| 137 |
current_stats = self._compute_stats(current_path)
|
| 138 |
-
print(f"[Aperture] NDVI current_stats: valid_months={current_stats.get('valid_months')}, overall_mean={current_stats.get('overall_mean')}")
|
| 139 |
current_mean = current_stats["overall_mean"]
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
if baseline_path:
|
|
|
|
| 142 |
baseline_stats = self._compute_stats(baseline_path)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
)
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
)
|
|
|
|
|
|
|
| 155 |
else:
|
| 156 |
-
|
| 157 |
-
|
|
|
|
| 158 |
confidence = ConfidenceLevel.LOW
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
chart_data = {
|
| 160 |
"dates": [f"{time_range.end.year}-{m+1:02d}" for m in range(len(current_stats["monthly_means"]))],
|
| 161 |
"values": [round(v, 3) for v in current_stats["monthly_means"]],
|
| 162 |
"label": "NDVI",
|
| 163 |
}
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
headline = f"Vegetation stable (NDVI {current_mean:.2f}, \u0394{change:+.2f} vs baseline)"
|
| 170 |
-
elif change > 0:
|
| 171 |
-
headline = f"Vegetation greening (NDVI +{change:.2f} vs baseline)"
|
| 172 |
else:
|
| 173 |
-
headline = f"Vegetation decline (NDVI {
|
| 174 |
|
| 175 |
self._spatial_data = SpatialData(
|
| 176 |
map_type="raster", label="NDVI", colormap="RdYlGn",
|
|
@@ -181,7 +244,6 @@ class NdviIndicator(BaseIndicator):
|
|
| 181 |
self._ndvi_peak_band = current_stats["peak_month_band"]
|
| 182 |
self._render_band = current_stats["peak_month_band"]
|
| 183 |
|
| 184 |
-
print(f"[Aperture] NDVI harvest returning REAL result: mean={current_mean:.3f}, baseline_mean={baseline_mean:.3f}, valid_months={current_stats['valid_months']}")
|
| 185 |
return IndicatorResult(
|
| 186 |
indicator_id=self.id,
|
| 187 |
headline=headline,
|
|
@@ -191,24 +253,28 @@ class NdviIndicator(BaseIndicator):
|
|
| 191 |
map_layer_path=current_path,
|
| 192 |
chart_data=chart_data,
|
| 193 |
data_source="satellite",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
summary=(
|
| 195 |
-
f"Mean NDVI is {current_mean:.3f}
|
| 196 |
-
f"
|
| 197 |
-
f"
|
| 198 |
-
f"{
|
| 199 |
),
|
| 200 |
methodology=(
|
| 201 |
f"Sentinel-2 L2A pixel-level NDVI = (B08 \u2212 B04) / (B08 + B04). "
|
| 202 |
f"Cloud-masked using SCL band (classes 4, 5, 6 retained). "
|
| 203 |
-
f"Monthly median composites at {
|
| 204 |
-
f"Baseline: {BASELINE_YEARS}-year
|
|
|
|
| 205 |
f"Processed server-side via CDSE openEO batch jobs."
|
| 206 |
),
|
| 207 |
limitations=[
|
| 208 |
-
f"Resampled to {RESOLUTION_M}m \u2014 sub-field variability not captured at this resolution.",
|
| 209 |
"Cloud cover reduces observation count in rainy seasons.",
|
| 210 |
"NDVI does not distinguish crop from natural vegetation.",
|
| 211 |
-
"
|
| 212 |
] + (["Baseline unavailable \u2014 change and trend not computed."] if not baseline_path else []),
|
| 213 |
)
|
| 214 |
|
|
@@ -246,17 +312,17 @@ class NdviIndicator(BaseIndicator):
|
|
| 246 |
current_cube = build_ndvi_graph(
|
| 247 |
conn=conn, bbox=bbox,
|
| 248 |
temporal_extent=[current_start, current_end],
|
| 249 |
-
resolution_m=
|
| 250 |
)
|
| 251 |
baseline_cube = build_ndvi_graph(
|
| 252 |
conn=conn, bbox=bbox,
|
| 253 |
temporal_extent=[baseline_start, baseline_end],
|
| 254 |
-
resolution_m=
|
| 255 |
)
|
| 256 |
true_color_cube = build_true_color_graph(
|
| 257 |
conn=conn, bbox=bbox,
|
| 258 |
temporal_extent=[current_start, current_end],
|
| 259 |
-
resolution_m=
|
| 260 |
)
|
| 261 |
|
| 262 |
# Download results (sequential to manage memory on free tier)
|
|
@@ -271,36 +337,82 @@ class NdviIndicator(BaseIndicator):
|
|
| 271 |
|
| 272 |
self._true_color_path = true_color_path
|
| 273 |
|
| 274 |
-
#
|
| 275 |
current_stats = self._compute_stats(current_path)
|
| 276 |
baseline_stats = self._compute_stats(baseline_path)
|
| 277 |
-
|
| 278 |
current_mean = current_stats["overall_mean"]
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
)
|
|
|
|
|
|
|
| 289 |
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
time_range,
|
| 295 |
)
|
|
|
|
| 296 |
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
headline = f"Vegetation greening (NDVI +{change:.2f} vs baseline)"
|
| 302 |
else:
|
| 303 |
-
headline = f"Vegetation decline (NDVI {
|
| 304 |
|
| 305 |
# Spatial data — store the current NDVI path for map rendering
|
| 306 |
self._spatial_data = SpatialData(
|
|
@@ -325,24 +437,28 @@ class NdviIndicator(BaseIndicator):
|
|
| 325 |
map_layer_path=current_path,
|
| 326 |
chart_data=chart_data,
|
| 327 |
data_source="satellite",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
summary=(
|
| 329 |
-
f"Mean NDVI is {current_mean:.3f}
|
| 330 |
-
f"
|
| 331 |
-
f"
|
| 332 |
-
f"{
|
| 333 |
),
|
| 334 |
methodology=(
|
| 335 |
f"Sentinel-2 L2A pixel-level NDVI = (B08 \u2212 B04) / (B08 + B04). "
|
| 336 |
f"Cloud-masked using SCL band (classes 4, 5, 6 retained). "
|
| 337 |
-
f"Monthly median composites at {
|
| 338 |
-
f"Baseline: {BASELINE_YEARS}-year
|
|
|
|
| 339 |
f"Processed server-side via CDSE openEO."
|
| 340 |
),
|
| 341 |
limitations=[
|
| 342 |
-
f"Resampled to {RESOLUTION_M}m \u2014 sub-field variability not captured at this resolution.",
|
| 343 |
"Cloud cover reduces observation count in rainy seasons.",
|
| 344 |
"NDVI does not distinguish crop from natural vegetation.",
|
| 345 |
-
"
|
| 346 |
],
|
| 347 |
)
|
| 348 |
|
|
@@ -377,42 +493,87 @@ class NdviIndicator(BaseIndicator):
|
|
| 377 |
"monthly_means": monthly_means,
|
| 378 |
"overall_mean": overall_mean,
|
| 379 |
"valid_months": valid_months,
|
|
|
|
| 380 |
"peak_month_band": peak_band,
|
| 381 |
}
|
| 382 |
|
| 383 |
@staticmethod
|
| 384 |
-
def
|
| 385 |
-
"""
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
return StatusLevel.AMBER
|
| 390 |
-
return StatusLevel.
|
| 391 |
|
| 392 |
@staticmethod
|
| 393 |
-
def
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
return TrendDirection.STABLE
|
| 396 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
return TrendDirection.IMPROVING
|
| 398 |
-
return TrendDirection.
|
| 399 |
|
| 400 |
@staticmethod
|
| 401 |
-
def
|
| 402 |
current_monthly: list[float],
|
| 403 |
-
|
| 404 |
time_range: TimeRange,
|
|
|
|
| 405 |
) -> dict[str, Any]:
|
| 406 |
-
"""Build chart data with
|
|
|
|
|
|
|
| 407 |
year = time_range.end.year
|
| 408 |
-
n = min(len(current_monthly), len(baseline_monthly))
|
| 409 |
-
dates = [f"{year}-{m + 1:02d}" for m in range(n)]
|
| 410 |
-
values = [round(v, 3) for v in current_monthly[:n]]
|
| 411 |
-
b_mean = [round(v, 3) for v in baseline_monthly[:n]]
|
| 412 |
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
return {
|
| 418 |
"dates": dates,
|
|
@@ -420,6 +581,7 @@ class NdviIndicator(BaseIndicator):
|
|
| 420 |
"baseline_mean": b_mean,
|
| 421 |
"baseline_min": b_min,
|
| 422 |
"baseline_max": b_max,
|
|
|
|
| 423 |
"label": "NDVI",
|
| 424 |
}
|
| 425 |
|
|
|
|
| 15 |
import numpy as np
|
| 16 |
import rasterio
|
| 17 |
|
| 18 |
+
from app.config import (
|
| 19 |
+
NDVI_RESOLUTION_M,
|
| 20 |
+
TRUECOLOR_RESOLUTION_M,
|
| 21 |
+
MIN_STD_NDVI,
|
| 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_ndvi_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 |
|
|
|
|
| 79 |
current_cube = build_ndvi_graph(
|
| 80 |
conn=conn, bbox=bbox,
|
| 81 |
temporal_extent=[current_start, current_end],
|
| 82 |
+
resolution_m=NDVI_RESOLUTION_M,
|
| 83 |
)
|
| 84 |
baseline_cube = build_ndvi_graph(
|
| 85 |
conn=conn, bbox=bbox,
|
| 86 |
temporal_extent=[baseline_start, baseline_end],
|
| 87 |
+
resolution_m=NDVI_RESOLUTION_M,
|
| 88 |
)
|
| 89 |
true_color_cube = build_true_color_graph(
|
| 90 |
conn=conn, bbox=bbox,
|
| 91 |
temporal_extent=[current_start, current_end],
|
| 92 |
+
resolution_m=TRUECOLOR_RESOLUTION_M,
|
| 93 |
)
|
| 94 |
|
| 95 |
return [
|
|
|
|
| 112 |
current_dir = os.path.join(results_dir, "current")
|
| 113 |
os.makedirs(current_dir, exist_ok=True)
|
| 114 |
paths = current_job.download_results(current_dir)
|
|
|
|
|
|
|
| 115 |
current_path = self._find_tif(paths, current_dir)
|
|
|
|
| 116 |
except Exception as exc:
|
| 117 |
raise RuntimeError(f"NDVI current period data unavailable: {exc}") from exc
|
| 118 |
|
|
|
|
| 122 |
baseline_dir = os.path.join(results_dir, "baseline")
|
| 123 |
os.makedirs(baseline_dir, exist_ok=True)
|
| 124 |
paths = baseline_job.download_results(baseline_dir)
|
|
|
|
|
|
|
| 125 |
baseline_path = self._find_tif(paths, baseline_dir)
|
|
|
|
| 126 |
except Exception as exc:
|
| 127 |
logger.warning("NDVI baseline batch download failed, degrading: %s", exc)
|
|
|
|
| 128 |
|
| 129 |
# Download true-color — optional
|
| 130 |
true_color_path = None
|
|
|
|
| 132 |
tc_dir = os.path.join(results_dir, "truecolor")
|
| 133 |
os.makedirs(tc_dir, exist_ok=True)
|
| 134 |
paths = true_color_job.download_results(tc_dir)
|
|
|
|
| 135 |
true_color_path = self._find_tif(paths, tc_dir)
|
|
|
|
| 136 |
except Exception as exc:
|
| 137 |
logger.warning("NDVI true-color batch download failed: %s", exc)
|
|
|
|
| 138 |
|
| 139 |
+
# --- Seasonal baseline analysis ---
|
|
|
|
| 140 |
current_stats = self._compute_stats(current_path)
|
|
|
|
| 141 |
current_mean = current_stats["overall_mean"]
|
| 142 |
+
n_current_bands = current_stats["valid_months"]
|
| 143 |
+
|
| 144 |
+
spatial_completeness = self._compute_spatial_completeness(current_path)
|
| 145 |
|
| 146 |
if baseline_path:
|
| 147 |
+
seasonal_stats = compute_seasonal_stats_aoi(baseline_path, n_years=BASELINE_YEARS)
|
| 148 |
baseline_stats = self._compute_stats(baseline_path)
|
| 149 |
+
|
| 150 |
+
start_month = time_range.start.month
|
| 151 |
+
most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
|
| 152 |
+
|
| 153 |
+
if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
|
| 154 |
+
s = seasonal_stats[most_recent_month]
|
| 155 |
+
z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_NDVI)
|
| 156 |
+
else:
|
| 157 |
+
z_current = 0.0
|
| 158 |
+
|
| 159 |
+
anomaly_months = 0
|
| 160 |
+
monthly_zscores = []
|
| 161 |
+
for i, val in enumerate(current_stats["monthly_means"]):
|
| 162 |
+
if val <= 0:
|
| 163 |
+
monthly_zscores.append(0.0)
|
| 164 |
+
continue
|
| 165 |
+
cal_month = ((start_month + i - 1) % 12) + 1
|
| 166 |
+
if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
|
| 167 |
+
z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
|
| 168 |
+
seasonal_stats[cal_month]["std"], MIN_STD_NDVI)
|
| 169 |
+
monthly_zscores.append(z)
|
| 170 |
+
if abs(z) > ZSCORE_THRESHOLD:
|
| 171 |
+
anomaly_months += 1
|
| 172 |
+
else:
|
| 173 |
+
monthly_zscores.append(0.0)
|
| 174 |
+
|
| 175 |
+
month_map = group_bands_by_calendar_month(baseline_stats["valid_months_total"], BASELINE_YEARS)
|
| 176 |
+
hotspot_pct = 0.0
|
| 177 |
+
self._zscore_raster = None
|
| 178 |
+
self._hotspot_mask = None
|
| 179 |
+
if most_recent_month in month_map and len(month_map[most_recent_month]) > 0:
|
| 180 |
+
pixel_stats = compute_seasonal_stats_pixel(baseline_path, month_map[most_recent_month])
|
| 181 |
+
with rasterio.open(current_path) as src:
|
| 182 |
+
current_band_idx = min(n_current_bands, src.count)
|
| 183 |
+
current_data = src.read(current_band_idx).astype(np.float32)
|
| 184 |
+
if src.nodata is not None:
|
| 185 |
+
current_data[current_data == src.nodata] = np.nan
|
| 186 |
+
|
| 187 |
+
z_raster = compute_zscore_raster(current_data, pixel_stats["mean"],
|
| 188 |
+
pixel_stats["std"], MIN_STD_NDVI)
|
| 189 |
+
hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
|
| 190 |
+
self._zscore_raster = z_raster
|
| 191 |
+
self._hotspot_mask = hotspot_mask
|
| 192 |
+
|
| 193 |
+
baseline_depth = sum(1 for m in range(1, 13)
|
| 194 |
+
if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
|
| 195 |
+
mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
|
| 196 |
+
if m in seasonal_stats) / max(baseline_depth, 1))
|
| 197 |
+
conf = compute_confidence(
|
| 198 |
+
valid_months=n_current_bands,
|
| 199 |
+
mean_obs_per_composite=5.0,
|
| 200 |
+
baseline_years_with_data=int(mean_baseline_years),
|
| 201 |
+
spatial_completeness=spatial_completeness,
|
| 202 |
)
|
| 203 |
+
confidence = conf["level"]
|
| 204 |
+
confidence_factors = conf["factors"]
|
| 205 |
+
|
| 206 |
+
status = self._classify_zscore(z_current, hotspot_pct)
|
| 207 |
+
trend = self._compute_trend_zscore(monthly_zscores)
|
| 208 |
+
|
| 209 |
+
chart_data = self._build_seasonal_chart_data(
|
| 210 |
+
current_stats["monthly_means"], seasonal_stats, time_range, monthly_zscores,
|
| 211 |
)
|
| 212 |
+
|
| 213 |
+
change = current_mean - baseline_stats["overall_mean"]
|
| 214 |
else:
|
| 215 |
+
z_current = 0.0
|
| 216 |
+
anomaly_months = 0
|
| 217 |
+
hotspot_pct = 0.0
|
| 218 |
confidence = ConfidenceLevel.LOW
|
| 219 |
+
confidence_factors = {}
|
| 220 |
+
status = StatusLevel.GREEN
|
| 221 |
+
trend = TrendDirection.STABLE
|
| 222 |
+
change = 0.0
|
| 223 |
+
self._zscore_raster = None
|
| 224 |
+
self._hotspot_mask = None
|
| 225 |
chart_data = {
|
| 226 |
"dates": [f"{time_range.end.year}-{m+1:02d}" for m in range(len(current_stats["monthly_means"]))],
|
| 227 |
"values": [round(v, 3) for v in current_stats["monthly_means"]],
|
| 228 |
"label": "NDVI",
|
| 229 |
}
|
| 230 |
|
| 231 |
+
if abs(z_current) <= 1.0:
|
| 232 |
+
headline = f"Vegetation within normal range (NDVI {current_mean:.2f}, z={z_current:+.1f})"
|
| 233 |
+
elif z_current > 0:
|
| 234 |
+
headline = f"Vegetation greening (NDVI {current_mean:.2f}, z={z_current:+.1f} above seasonal average)"
|
|
|
|
|
|
|
|
|
|
| 235 |
else:
|
| 236 |
+
headline = f"Vegetation decline (NDVI {current_mean:.2f}, z={z_current:+.1f} below seasonal average)"
|
| 237 |
|
| 238 |
self._spatial_data = SpatialData(
|
| 239 |
map_type="raster", label="NDVI", colormap="RdYlGn",
|
|
|
|
| 244 |
self._ndvi_peak_band = current_stats["peak_month_band"]
|
| 245 |
self._render_band = current_stats["peak_month_band"]
|
| 246 |
|
|
|
|
| 247 |
return IndicatorResult(
|
| 248 |
indicator_id=self.id,
|
| 249 |
headline=headline,
|
|
|
|
| 253 |
map_layer_path=current_path,
|
| 254 |
chart_data=chart_data,
|
| 255 |
data_source="satellite",
|
| 256 |
+
anomaly_months=anomaly_months,
|
| 257 |
+
z_score_current=round(z_current, 2),
|
| 258 |
+
hotspot_pct=round(hotspot_pct, 1),
|
| 259 |
+
confidence_factors=confidence_factors,
|
| 260 |
summary=(
|
| 261 |
+
f"Mean NDVI is {current_mean:.3f} (z-score {z_current:+.1f} vs seasonal baseline). "
|
| 262 |
+
f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
|
| 263 |
+
f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
|
| 264 |
+
f"Pixel-level analysis at {NDVI_RESOLUTION_M}m resolution."
|
| 265 |
),
|
| 266 |
methodology=(
|
| 267 |
f"Sentinel-2 L2A pixel-level NDVI = (B08 \u2212 B04) / (B08 + B04). "
|
| 268 |
f"Cloud-masked using SCL band (classes 4, 5, 6 retained). "
|
| 269 |
+
f"Monthly median composites at {NDVI_RESOLUTION_M}m native resolution. "
|
| 270 |
+
f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
|
| 271 |
+
f"Anomaly detection via z-scores (threshold: \u00b1{ZSCORE_THRESHOLD}). "
|
| 272 |
f"Processed server-side via CDSE openEO batch jobs."
|
| 273 |
),
|
| 274 |
limitations=[
|
|
|
|
| 275 |
"Cloud cover reduces observation count in rainy seasons.",
|
| 276 |
"NDVI does not distinguish crop from natural vegetation.",
|
| 277 |
+
"Z-score anomalies assume baseline is representative of normal conditions.",
|
| 278 |
] + (["Baseline unavailable \u2014 change and trend not computed."] if not baseline_path else []),
|
| 279 |
)
|
| 280 |
|
|
|
|
| 312 |
current_cube = build_ndvi_graph(
|
| 313 |
conn=conn, bbox=bbox,
|
| 314 |
temporal_extent=[current_start, current_end],
|
| 315 |
+
resolution_m=NDVI_RESOLUTION_M,
|
| 316 |
)
|
| 317 |
baseline_cube = build_ndvi_graph(
|
| 318 |
conn=conn, bbox=bbox,
|
| 319 |
temporal_extent=[baseline_start, baseline_end],
|
| 320 |
+
resolution_m=NDVI_RESOLUTION_M,
|
| 321 |
)
|
| 322 |
true_color_cube = build_true_color_graph(
|
| 323 |
conn=conn, bbox=bbox,
|
| 324 |
temporal_extent=[current_start, current_end],
|
| 325 |
+
resolution_m=TRUECOLOR_RESOLUTION_M,
|
| 326 |
)
|
| 327 |
|
| 328 |
# Download results (sequential to manage memory on free tier)
|
|
|
|
| 337 |
|
| 338 |
self._true_color_path = true_color_path
|
| 339 |
|
| 340 |
+
# --- Seasonal baseline analysis ---
|
| 341 |
current_stats = self._compute_stats(current_path)
|
| 342 |
baseline_stats = self._compute_stats(baseline_path)
|
|
|
|
| 343 |
current_mean = current_stats["overall_mean"]
|
| 344 |
+
n_current_bands = current_stats["valid_months"]
|
| 345 |
+
spatial_completeness = self._compute_spatial_completeness(current_path)
|
| 346 |
+
|
| 347 |
+
seasonal_stats = compute_seasonal_stats_aoi(baseline_path, n_years=BASELINE_YEARS)
|
| 348 |
+
start_month = time_range.start.month
|
| 349 |
+
most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
|
| 350 |
+
|
| 351 |
+
if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
|
| 352 |
+
s = seasonal_stats[most_recent_month]
|
| 353 |
+
z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_NDVI)
|
| 354 |
+
else:
|
| 355 |
+
z_current = 0.0
|
| 356 |
+
|
| 357 |
+
anomaly_months = 0
|
| 358 |
+
monthly_zscores = []
|
| 359 |
+
for i, val in enumerate(current_stats["monthly_means"]):
|
| 360 |
+
if val <= 0:
|
| 361 |
+
monthly_zscores.append(0.0)
|
| 362 |
+
continue
|
| 363 |
+
cal_month = ((start_month + i - 1) % 12) + 1
|
| 364 |
+
if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
|
| 365 |
+
z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
|
| 366 |
+
seasonal_stats[cal_month]["std"], MIN_STD_NDVI)
|
| 367 |
+
monthly_zscores.append(z)
|
| 368 |
+
if abs(z) > ZSCORE_THRESHOLD:
|
| 369 |
+
anomaly_months += 1
|
| 370 |
+
else:
|
| 371 |
+
monthly_zscores.append(0.0)
|
| 372 |
+
|
| 373 |
+
month_map = group_bands_by_calendar_month(baseline_stats["valid_months_total"], BASELINE_YEARS)
|
| 374 |
+
hotspot_pct = 0.0
|
| 375 |
+
self._zscore_raster = None
|
| 376 |
+
self._hotspot_mask = None
|
| 377 |
+
if most_recent_month in month_map and len(month_map[most_recent_month]) > 0:
|
| 378 |
+
pixel_stats = compute_seasonal_stats_pixel(baseline_path, month_map[most_recent_month])
|
| 379 |
+
with rasterio.open(current_path) as src:
|
| 380 |
+
current_band_idx = min(n_current_bands, src.count)
|
| 381 |
+
current_data = src.read(current_band_idx).astype(np.float32)
|
| 382 |
+
if src.nodata is not None:
|
| 383 |
+
current_data[current_data == src.nodata] = np.nan
|
| 384 |
+
z_raster = compute_zscore_raster(current_data, pixel_stats["mean"],
|
| 385 |
+
pixel_stats["std"], MIN_STD_NDVI)
|
| 386 |
+
hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
|
| 387 |
+
self._zscore_raster = z_raster
|
| 388 |
+
self._hotspot_mask = hotspot_mask
|
| 389 |
+
|
| 390 |
+
baseline_depth = sum(1 for m in range(1, 13)
|
| 391 |
+
if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
|
| 392 |
+
mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
|
| 393 |
+
if m in seasonal_stats) / max(baseline_depth, 1))
|
| 394 |
+
conf = compute_confidence(
|
| 395 |
+
valid_months=n_current_bands,
|
| 396 |
+
mean_obs_per_composite=5.0,
|
| 397 |
+
baseline_years_with_data=int(mean_baseline_years),
|
| 398 |
+
spatial_completeness=spatial_completeness,
|
| 399 |
)
|
| 400 |
+
confidence = conf["level"]
|
| 401 |
+
confidence_factors = conf["factors"]
|
| 402 |
|
| 403 |
+
status = self._classify_zscore(z_current, hotspot_pct)
|
| 404 |
+
trend = self._compute_trend_zscore(monthly_zscores)
|
| 405 |
+
chart_data = self._build_seasonal_chart_data(
|
| 406 |
+
current_stats["monthly_means"], seasonal_stats, time_range, monthly_zscores,
|
|
|
|
| 407 |
)
|
| 408 |
+
change = current_mean - baseline_stats["overall_mean"]
|
| 409 |
|
| 410 |
+
if abs(z_current) <= 1.0:
|
| 411 |
+
headline = f"Vegetation within normal range (NDVI {current_mean:.2f}, z={z_current:+.1f})"
|
| 412 |
+
elif z_current > 0:
|
| 413 |
+
headline = f"Vegetation greening (NDVI {current_mean:.2f}, z={z_current:+.1f} above seasonal average)"
|
|
|
|
| 414 |
else:
|
| 415 |
+
headline = f"Vegetation decline (NDVI {current_mean:.2f}, z={z_current:+.1f} below seasonal average)"
|
| 416 |
|
| 417 |
# Spatial data — store the current NDVI path for map rendering
|
| 418 |
self._spatial_data = SpatialData(
|
|
|
|
| 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"Mean NDVI is {current_mean:.3f} (z-score {z_current:+.1f} vs seasonal baseline). "
|
| 446 |
+
f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
|
| 447 |
+
f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
|
| 448 |
+
f"Pixel-level analysis at {NDVI_RESOLUTION_M}m resolution."
|
| 449 |
),
|
| 450 |
methodology=(
|
| 451 |
f"Sentinel-2 L2A pixel-level NDVI = (B08 \u2212 B04) / (B08 + B04). "
|
| 452 |
f"Cloud-masked using SCL band (classes 4, 5, 6 retained). "
|
| 453 |
+
f"Monthly median composites at {NDVI_RESOLUTION_M}m native resolution. "
|
| 454 |
+
f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
|
| 455 |
+
f"Anomaly detection via z-scores (threshold: \u00b1{ZSCORE_THRESHOLD}). "
|
| 456 |
f"Processed server-side via CDSE openEO."
|
| 457 |
),
|
| 458 |
limitations=[
|
|
|
|
| 459 |
"Cloud cover reduces observation count in rainy seasons.",
|
| 460 |
"NDVI does not distinguish crop from natural vegetation.",
|
| 461 |
+
"Z-score anomalies assume baseline is representative of normal conditions.",
|
| 462 |
],
|
| 463 |
)
|
| 464 |
|
|
|
|
| 493 |
"monthly_means": monthly_means,
|
| 494 |
"overall_mean": overall_mean,
|
| 495 |
"valid_months": valid_months,
|
| 496 |
+
"valid_months_total": n_bands,
|
| 497 |
"peak_month_band": peak_band,
|
| 498 |
}
|
| 499 |
|
| 500 |
@staticmethod
|
| 501 |
+
def _compute_spatial_completeness(tif_path: str) -> float:
|
| 502 |
+
"""Compute fraction of AOI with valid (non-nodata) pixels."""
|
| 503 |
+
with rasterio.open(tif_path) as src:
|
| 504 |
+
data = src.read(1).astype(np.float32)
|
| 505 |
+
nodata = src.nodata
|
| 506 |
+
if nodata is not None:
|
| 507 |
+
valid = np.sum(data != nodata)
|
| 508 |
+
else:
|
| 509 |
+
valid = np.sum(~np.isnan(data))
|
| 510 |
+
total = data.size
|
| 511 |
+
return float(valid / total) if total > 0 else 0.0
|
| 512 |
+
|
| 513 |
+
@staticmethod
|
| 514 |
+
def _classify_zscore(z_score: float, hotspot_pct: float) -> StatusLevel:
|
| 515 |
+
"""Classify status using z-score and hotspot percentage."""
|
| 516 |
+
if abs(z_score) > ZSCORE_THRESHOLD or hotspot_pct > 25:
|
| 517 |
+
return StatusLevel.RED
|
| 518 |
+
if abs(z_score) > 1.0 or hotspot_pct > 10:
|
| 519 |
return StatusLevel.AMBER
|
| 520 |
+
return StatusLevel.GREEN
|
| 521 |
|
| 522 |
@staticmethod
|
| 523 |
+
def _compute_trend_zscore(monthly_zscores: list[float]) -> TrendDirection:
|
| 524 |
+
"""Compute trend from direction of monthly z-scores."""
|
| 525 |
+
valid = [z for z in monthly_zscores if z != 0.0]
|
| 526 |
+
if len(valid) < 2:
|
| 527 |
+
return TrendDirection.STABLE
|
| 528 |
+
within_normal = sum(1 for z in valid if abs(z) <= 1.0)
|
| 529 |
+
if within_normal > len(valid) / 2:
|
| 530 |
return TrendDirection.STABLE
|
| 531 |
+
negative = sum(1 for z in valid if z < -1.0)
|
| 532 |
+
positive = sum(1 for z in valid if z > 1.0)
|
| 533 |
+
if negative > positive:
|
| 534 |
+
return TrendDirection.DETERIORATING
|
| 535 |
+
if positive > negative:
|
| 536 |
return TrendDirection.IMPROVING
|
| 537 |
+
return TrendDirection.STABLE
|
| 538 |
|
| 539 |
@staticmethod
|
| 540 |
+
def _build_seasonal_chart_data(
|
| 541 |
current_monthly: list[float],
|
| 542 |
+
seasonal_stats: dict[int, dict],
|
| 543 |
time_range: TimeRange,
|
| 544 |
+
monthly_zscores: list[float],
|
| 545 |
) -> dict[str, Any]:
|
| 546 |
+
"""Build chart data with seasonal baseline envelope."""
|
| 547 |
+
start_month = time_range.start.month
|
| 548 |
+
n = len(current_monthly)
|
| 549 |
year = time_range.end.year
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
|
| 551 |
+
dates = []
|
| 552 |
+
values = []
|
| 553 |
+
b_mean = []
|
| 554 |
+
b_min = []
|
| 555 |
+
b_max = []
|
| 556 |
+
anomaly_flags = []
|
| 557 |
+
|
| 558 |
+
for i in range(n):
|
| 559 |
+
cal_month = ((start_month + i - 1) % 12) + 1
|
| 560 |
+
dates.append(f"{year}-{cal_month:02d}")
|
| 561 |
+
values.append(round(current_monthly[i], 3))
|
| 562 |
+
|
| 563 |
+
if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
|
| 564 |
+
s = seasonal_stats[cal_month]
|
| 565 |
+
b_mean.append(round(s["mean"], 3))
|
| 566 |
+
b_min.append(round(s["min"], 3))
|
| 567 |
+
b_max.append(round(s["max"], 3))
|
| 568 |
+
else:
|
| 569 |
+
b_mean.append(0.0)
|
| 570 |
+
b_min.append(0.0)
|
| 571 |
+
b_max.append(0.0)
|
| 572 |
+
|
| 573 |
+
if i < len(monthly_zscores):
|
| 574 |
+
anomaly_flags.append(abs(monthly_zscores[i]) > ZSCORE_THRESHOLD)
|
| 575 |
+
else:
|
| 576 |
+
anomaly_flags.append(False)
|
| 577 |
|
| 578 |
return {
|
| 579 |
"dates": dates,
|
|
|
|
| 581 |
"baseline_mean": b_mean,
|
| 582 |
"baseline_min": b_min,
|
| 583 |
"baseline_max": b_max,
|
| 584 |
+
"anomaly_flags": anomaly_flags,
|
| 585 |
"label": "NDVI",
|
| 586 |
}
|
| 587 |
|