KSvend Claude Opus 4.6 (1M context) commited on
Commit
30f032f
·
1 Parent(s): 804eb65

Add implementation plan for EO product analytical upgrade

Browse files

19 tasks covering: data model changes, native resolution, seasonal
baselines, pixel-level change detection, compound signals, confidence
model, chart/map/narrative/report improvements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs/superpowers/plans/2026-04-06-eo-product-overhaul.md ADDED
@@ -0,0 +1,2593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EO Product Analytical Upgrade — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Upgrade all 4 EO indicators with native resolution, seasonal baselines, pixel-level change detection, cross-indicator compound signals, and a richer confidence model + report output.
6
+
7
+ **Architecture:** New `app/analysis/` package isolates reusable computation (seasonal stats, z-scores, hotspots, compound signals, confidence scoring) from indicator-specific code. Each indicator's `harvest()` method calls into these shared modules. The openEO graphs drop their `resample_spatial` step to deliver native-resolution data. The output pipeline gains hotspot maps, seasonal charts, and a compound signals report section.
8
+
9
+ **Tech Stack:** Python 3.11+, numpy, rasterio, scipy (ndimage.label), matplotlib, reportlab, openEO Python client, pydantic.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-04-06-eo-product-overhaul-design.md`
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ ### New files
18
+
19
+ | File | Responsibility |
20
+ |---|---|
21
+ | `app/analysis/__init__.py` | Package init |
22
+ | `app/analysis/seasonal.py` | Seasonal baseline computation: group bands by calendar month, compute per-pixel and AOI-level stats (mean, median, std, min, max) |
23
+ | `app/analysis/change.py` | Pixel-level change detection: z-score rasters, hotspot masks, spatial clustering |
24
+ | `app/analysis/compound.py` | Cross-indicator compound signal detection |
25
+ | `app/analysis/confidence.py` | Four-factor confidence scoring model |
26
+ | `tests/test_seasonal.py` | Tests for seasonal baseline computation |
27
+ | `tests/test_change.py` | Tests for change detection and hotspot clustering |
28
+ | `tests/test_compound.py` | Tests for compound signal detection |
29
+ | `tests/test_confidence.py` | Tests for confidence scoring |
30
+ | `tests/test_narrative.py` | Tests for updated narrative generation |
31
+ | `tests/conftest.py` | Shared test fixtures (synthetic rasters, mock results) |
32
+
33
+ ### Modified files
34
+
35
+ | File | What changes |
36
+ |---|---|
37
+ | `app/models.py` | Add `anomaly_months`, `z_score_current`, `hotspot_pct`, `confidence_factors` to `IndicatorResult`; add `CompoundSignal` model |
38
+ | `app/config.py` | Per-indicator native resolution constants; min std thresholds |
39
+ | `app/openeo_client.py` | Remove `resample_spatial` from `build_ndvi_graph`, `build_mndwi_graph`, `build_sar_graph`, `build_buildup_graph`; update `build_true_color_graph` to 10m |
40
+ | `app/indicators/ndvi.py` | Use seasonal baselines, z-score classification, new confidence model, hotspot data |
41
+ | `app/indicators/water.py` | Same pattern as NDVI |
42
+ | `app/indicators/sar.py` | Same pattern as NDVI |
43
+ | `app/indicators/buildup.py` | Same pattern as NDVI |
44
+ | `app/outputs/charts.py` | Seasonal envelope, anomaly markers, y-axis labels |
45
+ | `app/outputs/maps.py` | New `render_hotspot_map()` function |
46
+ | `app/outputs/narrative.py` | Z-score language, seasonal context, compound signal text |
47
+ | `app/outputs/report.py` | Compound signals section, anomaly months column, confidence breakdown in annex |
48
+ | `app/outputs/overview.py` | Factor in anomaly counts for composite score |
49
+ | `app/worker.py` | Generate hotspot maps, compound signals, pass new data through pipeline |
50
+
51
+ ---
52
+
53
+ ## Task 1: Test fixtures and project setup
54
+
55
+ **Files:**
56
+ - Create: `tests/__init__.py`
57
+ - Create: `tests/conftest.py`
58
+
59
+ - [ ] **Step 1: Create test package and shared fixtures**
60
+
61
+ ```python
62
+ # tests/__init__.py
63
+ ```
64
+
65
+ ```python
66
+ # tests/conftest.py
67
+ """Shared test fixtures for Aperture tests."""
68
+ from __future__ import annotations
69
+
70
+ import numpy as np
71
+ import pytest
72
+
73
+
74
+ @pytest.fixture
75
+ def synthetic_monthly_raster(tmp_path):
76
+ """Create a synthetic multi-band GeoTIFF with 12 monthly bands.
77
+
78
+ Returns a factory function: call with (n_bands, shape, fill_fn) to get a path.
79
+ fill_fn(band_idx) -> 2D numpy array.
80
+ """
81
+ import rasterio
82
+ from rasterio.transform import from_bounds
83
+
84
+ def _make(
85
+ n_bands: int = 12,
86
+ shape: tuple[int, int] = (100, 100),
87
+ fill_fn=None,
88
+ bbox: tuple[float, ...] = (37.8, 2.08, 37.88, 2.17),
89
+ ) -> str:
90
+ if fill_fn is None:
91
+ fill_fn = lambda i: np.random.default_rng(i).uniform(0.2, 0.8, shape).astype(np.float32)
92
+
93
+ path = str(tmp_path / f"synthetic_{n_bands}bands.tif")
94
+ transform = from_bounds(*bbox, shape[1], shape[0])
95
+ with rasterio.open(
96
+ path, "w", driver="GTiff",
97
+ height=shape[0], width=shape[1],
98
+ count=n_bands, dtype="float32",
99
+ crs="EPSG:4326", transform=transform,
100
+ nodata=-9999.0,
101
+ ) as dst:
102
+ for i in range(n_bands):
103
+ dst.write(fill_fn(i), i + 1)
104
+ return path
105
+
106
+ return _make
107
+
108
+
109
+ @pytest.fixture
110
+ def mock_indicator_result():
111
+ """Factory for IndicatorResult with sensible defaults."""
112
+ from app.models import IndicatorResult, StatusLevel, TrendDirection, ConfidenceLevel
113
+
114
+ def _make(**overrides):
115
+ defaults = dict(
116
+ indicator_id="ndvi",
117
+ headline="Test headline",
118
+ status=StatusLevel.GREEN,
119
+ trend=TrendDirection.STABLE,
120
+ confidence=ConfidenceLevel.HIGH,
121
+ map_layer_path="/tmp/fake.tif",
122
+ chart_data={"dates": [], "values": []},
123
+ summary="Test summary",
124
+ methodology="Test methodology",
125
+ limitations=["Test limitation"],
126
+ data_source="satellite",
127
+ anomaly_months=0,
128
+ z_score_current=0.0,
129
+ hotspot_pct=0.0,
130
+ confidence_factors={},
131
+ )
132
+ defaults.update(overrides)
133
+ return IndicatorResult(**defaults)
134
+
135
+ return _make
136
+ ```
137
+
138
+ - [ ] **Step 2: Verify pytest discovers fixtures**
139
+
140
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/conftest.py --collect-only 2>&1 | head -5`
141
+ Expected: No errors, fixtures discovered.
142
+
143
+ - [ ] **Step 3: Commit**
144
+
145
+ ```bash
146
+ git add tests/__init__.py tests/conftest.py
147
+ git commit -m "feat: add test fixtures for EO product overhaul"
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Task 2: Data model changes
153
+
154
+ **Files:**
155
+ - Modify: `app/models.py:118-129`
156
+
157
+ - [ ] **Step 1: Write test for new IndicatorResult fields**
158
+
159
+ ```python
160
+ # tests/test_models.py
161
+ """Tests for updated data models."""
162
+ from app.models import IndicatorResult, StatusLevel, TrendDirection, ConfidenceLevel
163
+
164
+
165
+ def test_indicator_result_new_fields():
166
+ """IndicatorResult accepts and stores new analytical fields."""
167
+ result = IndicatorResult(
168
+ indicator_id="ndvi",
169
+ headline="Test",
170
+ status=StatusLevel.AMBER,
171
+ trend=TrendDirection.DETERIORATING,
172
+ confidence=ConfidenceLevel.MODERATE,
173
+ map_layer_path="/tmp/test.tif",
174
+ chart_data={"dates": [], "values": []},
175
+ summary="Test summary",
176
+ methodology="Test methodology",
177
+ limitations=["Test"],
178
+ anomaly_months=3,
179
+ z_score_current=-1.8,
180
+ hotspot_pct=15.2,
181
+ confidence_factors={
182
+ "temporal": 0.75,
183
+ "observation_density": 0.5,
184
+ "baseline_depth": 1.0,
185
+ "spatial_completeness": 0.9,
186
+ },
187
+ )
188
+ assert result.anomaly_months == 3
189
+ assert result.z_score_current == -1.8
190
+ assert result.hotspot_pct == 15.2
191
+ assert result.confidence_factors["baseline_depth"] == 1.0
192
+
193
+
194
+ def test_indicator_result_defaults_for_new_fields():
195
+ """New fields have sensible defaults for backward compatibility."""
196
+ result = IndicatorResult(
197
+ indicator_id="ndvi",
198
+ headline="Test",
199
+ status=StatusLevel.GREEN,
200
+ trend=TrendDirection.STABLE,
201
+ confidence=ConfidenceLevel.LOW,
202
+ map_layer_path="/tmp/test.tif",
203
+ chart_data={},
204
+ summary="",
205
+ methodology="",
206
+ limitations=[],
207
+ )
208
+ assert result.anomaly_months == 0
209
+ assert result.z_score_current == 0.0
210
+ assert result.hotspot_pct == 0.0
211
+ assert result.confidence_factors == {}
212
+
213
+
214
+ def test_compound_signal_model():
215
+ """CompoundSignal stores cross-indicator detection results."""
216
+ from app.models import CompoundSignal
217
+
218
+ signal = CompoundSignal(
219
+ name="land_conversion",
220
+ triggered=True,
221
+ confidence="strong",
222
+ description="NDVI decline overlaps with settlement growth",
223
+ indicators=["ndvi", "buildup"],
224
+ overlap_pct=25.3,
225
+ affected_ha=145.0,
226
+ )
227
+ assert signal.triggered is True
228
+ assert signal.confidence == "strong"
229
+ assert len(signal.indicators) == 2
230
+ ```
231
+
232
+ - [ ] **Step 2: Run tests to verify they fail**
233
+
234
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_models.py -v`
235
+ Expected: FAIL — `anomaly_months` field not recognized, `CompoundSignal` not importable.
236
+
237
+ - [ ] **Step 3: Add new fields to IndicatorResult and create CompoundSignal**
238
+
239
+ In `app/models.py`, update `IndicatorResult` (line 118) to:
240
+
241
+ ```python
242
+ class IndicatorResult(BaseModel):
243
+ indicator_id: str
244
+ headline: str
245
+ status: StatusLevel
246
+ trend: TrendDirection
247
+ confidence: ConfidenceLevel
248
+ map_layer_path: str
249
+ chart_data: dict[str, Any]
250
+ summary: str
251
+ methodology: str
252
+ limitations: list[str]
253
+ data_source: str = "satellite"
254
+ anomaly_months: int = 0
255
+ z_score_current: float = 0.0
256
+ hotspot_pct: float = 0.0
257
+ confidence_factors: dict[str, float] = Field(default_factory=dict)
258
+ ```
259
+
260
+ Add after `AoiAdviceRequest` (after line 152):
261
+
262
+ ```python
263
+ class CompoundSignal(BaseModel):
264
+ name: str
265
+ triggered: bool
266
+ confidence: str # "strong", "moderate", "weak"
267
+ description: str
268
+ indicators: list[str]
269
+ overlap_pct: float = 0.0
270
+ affected_ha: float = 0.0
271
+ ```
272
+
273
+ - [ ] **Step 4: Run tests to verify they pass**
274
+
275
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_models.py -v`
276
+ Expected: All 3 tests PASS.
277
+
278
+ - [ ] **Step 5: Commit**
279
+
280
+ ```bash
281
+ git add app/models.py tests/test_models.py
282
+ git commit -m "feat: add anomaly, hotspot, confidence fields to IndicatorResult; add CompoundSignal model"
283
+ ```
284
+
285
+ ---
286
+
287
+ ## Task 3: Config changes — per-indicator resolution and thresholds
288
+
289
+ **Files:**
290
+ - Modify: `app/config.py:1-34`
291
+
292
+ - [ ] **Step 1: Update config with native resolutions and thresholds**
293
+
294
+ Replace the `RESOLUTION_M` line (line 8) and add new constants after it in `app/config.py`:
295
+
296
+ ```python
297
+ # Legacy global resolution — kept for backward compatibility
298
+ RESOLUTION_M: int = int(os.environ.get("APERTURE_RESOLUTION_M", "100"))
299
+
300
+ # Per-indicator native resolutions (meters)
301
+ NDVI_RESOLUTION_M: int = 10
302
+ WATER_RESOLUTION_M: int = 20
303
+ SAR_RESOLUTION_M: int = 10
304
+ BUILDUP_RESOLUTION_M: int = 20
305
+ TRUECOLOR_RESOLUTION_M: int = 10
306
+
307
+ # Minimum std thresholds to cap z-scores (avoid division-by-near-zero)
308
+ MIN_STD_NDVI: float = 0.02
309
+ MIN_STD_WATER: float = 0.01
310
+ MIN_STD_SAR: float = 0.5 # dB
311
+ MIN_STD_BUILDUP: float = 0.01
312
+
313
+ # Z-score threshold for significant anomaly
314
+ ZSCORE_THRESHOLD: float = 2.0
315
+
316
+ # Minimum hotspot cluster size in pixels
317
+ MIN_CLUSTER_PIXELS: int = 4
318
+ ```
319
+
320
+ - [ ] **Step 2: Verify import works**
321
+
322
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.config import NDVI_RESOLUTION_M, MIN_STD_NDVI, ZSCORE_THRESHOLD; print(f'NDVI={NDVI_RESOLUTION_M}m, min_std={MIN_STD_NDVI}, z={ZSCORE_THRESHOLD}')"`
323
+ Expected: `NDVI=10m, min_std=0.02, z=2.0`
324
+
325
+ - [ ] **Step 3: Commit**
326
+
327
+ ```bash
328
+ git add app/config.py
329
+ git commit -m "feat: add per-indicator native resolution and z-score threshold config"
330
+ ```
331
+
332
+ ---
333
+
334
+ ## Task 4: OpenEO graph builders — remove resample_spatial
335
+
336
+ **Files:**
337
+ - Modify: `app/openeo_client.py:81-269`
338
+
339
+ - [ ] **Step 1: Update build_ndvi_graph to use native resolution**
340
+
341
+ In `app/openeo_client.py`, replace lines 81-119 with:
342
+
343
+ ```python
344
+ def build_ndvi_graph(
345
+ *,
346
+ conn: openeo.Connection,
347
+ bbox: dict[str, float],
348
+ temporal_extent: list[str],
349
+ resolution_m: int = 10,
350
+ ) -> openeo.DataCube:
351
+ """Build an openEO process graph for monthly median NDVI composites.
352
+
353
+ Loads Sentinel-2 L2A, masks clouds using the SCL band, computes
354
+ NDVI = (B08 - B04) / (B08 + B04), and aggregates to monthly medians.
355
+ Default resolution is 10m (native for B04 and B08).
356
+
357
+ Returns an openEO DataCube (not yet executed).
358
+ """
359
+ cube = conn.load_collection(
360
+ collection_id="SENTINEL2_L2A",
361
+ spatial_extent=bbox,
362
+ temporal_extent=temporal_extent,
363
+ bands=["B04", "B08", "SCL"],
364
+ )
365
+
366
+ # Cloud mask: keep only vegetation, bare soil, water (SCL classes 4,5,6)
367
+ scl = cube.band("SCL")
368
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
369
+ cube = cube.mask(cloud_mask == 0)
370
+
371
+ # NDVI
372
+ b08 = cube.band("B08")
373
+ b04 = cube.band("B04")
374
+ ndvi = (b08 - b04) / (b08 + b04)
375
+
376
+ # Monthly median composite
377
+ monthly = ndvi.aggregate_temporal_period("month", reducer="median")
378
+
379
+ # Only resample if explicitly requesting coarser than native 10m
380
+ if resolution_m > 10:
381
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320, projection="EPSG:4326")
382
+
383
+ return monthly
384
+ ```
385
+
386
+ - [ ] **Step 2: Update build_true_color_graph**
387
+
388
+ Replace lines 122-158 with:
389
+
390
+ ```python
391
+ def build_true_color_graph(
392
+ *,
393
+ conn: openeo.Connection,
394
+ bbox: dict[str, float],
395
+ temporal_extent: list[str],
396
+ resolution_m: int = 10,
397
+ ) -> openeo.DataCube:
398
+ """Build an openEO process graph for a true-color Sentinel-2 composite.
399
+
400
+ Default resolution is 10m (native for B02, B03, B04).
401
+ """
402
+ cube = conn.load_collection(
403
+ collection_id="SENTINEL2_L2A",
404
+ spatial_extent=bbox,
405
+ temporal_extent=temporal_extent,
406
+ bands=["B02", "B03", "B04", "SCL"],
407
+ )
408
+
409
+ scl = cube.band("SCL")
410
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
411
+ cube = cube.mask(cloud_mask == 0)
412
+
413
+ rgb = cube.filter_bands(["B02", "B03", "B04"])
414
+ composite = rgb.reduce_dimension(dimension="t", reducer="median")
415
+
416
+ if resolution_m > 10:
417
+ composite = composite.resample_spatial(resolution=resolution_m / 111320, projection="EPSG:4326")
418
+
419
+ return composite
420
+ ```
421
+
422
+ - [ ] **Step 3: Update build_mndwi_graph to 20m native**
423
+
424
+ Replace lines 161-193 with:
425
+
426
+ ```python
427
+ def build_mndwi_graph(
428
+ *,
429
+ conn: openeo.Connection,
430
+ bbox: dict[str, float],
431
+ temporal_extent: list[str],
432
+ resolution_m: int = 20,
433
+ ) -> openeo.DataCube:
434
+ """Build an openEO process graph for monthly MNDWI water index composites.
435
+
436
+ MNDWI = (B03 - B11) / (B03 + B11). Default resolution is 20m
437
+ (native for B11; B03 is 10m and gets resampled by openEO automatically).
438
+ """
439
+ cube = conn.load_collection(
440
+ collection_id="SENTINEL2_L2A",
441
+ spatial_extent=bbox,
442
+ temporal_extent=temporal_extent,
443
+ bands=["B03", "B11", "SCL"],
444
+ )
445
+
446
+ scl = cube.band("SCL")
447
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
448
+ cube = cube.mask(cloud_mask == 0)
449
+
450
+ b03 = cube.band("B03")
451
+ b11 = cube.band("B11")
452
+ mndwi = (b03 - b11) / (b03 + b11)
453
+
454
+ monthly = mndwi.aggregate_temporal_period("month", reducer="median")
455
+
456
+ if resolution_m > 20:
457
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320, projection="EPSG:4326")
458
+
459
+ return monthly
460
+ ```
461
+
462
+ - [ ] **Step 4: Update build_sar_graph to 10m native**
463
+
464
+ Replace lines 196-227 with:
465
+
466
+ ```python
467
+ def build_sar_graph(
468
+ *,
469
+ conn: openeo.Connection,
470
+ bbox: dict[str, float],
471
+ temporal_extent: list[str],
472
+ resolution_m: int = 10,
473
+ ) -> openeo.DataCube:
474
+ """Build an openEO process graph for Sentinel-1 GRD SAR backscatter.
475
+
476
+ Default resolution is 10m (native for Sentinel-1 GRD).
477
+ """
478
+ cube = conn.load_collection(
479
+ collection_id="SENTINEL1_GRD",
480
+ spatial_extent=bbox,
481
+ temporal_extent=temporal_extent,
482
+ bands=["VV", "VH"],
483
+ )
484
+
485
+ cube = 10.0 * cube.log10()
486
+
487
+ monthly = cube.aggregate_temporal_period("month", reducer="median")
488
+
489
+ if resolution_m > 10:
490
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320, projection="EPSG:4326")
491
+
492
+ return monthly
493
+ ```
494
+
495
+ - [ ] **Step 5: Update build_buildup_graph to 20m native**
496
+
497
+ Replace lines 230-269 with:
498
+
499
+ ```python
500
+ def build_buildup_graph(
501
+ *,
502
+ conn: openeo.Connection,
503
+ bbox: dict[str, float],
504
+ temporal_extent: list[str],
505
+ resolution_m: int = 20,
506
+ ) -> openeo.DataCube:
507
+ """Build an openEO process graph for monthly NDBI built-up index composites.
508
+
509
+ NDBI = (B11 - B08) / (B11 + B08). Default resolution is 20m
510
+ (native for B11; B08 is 10m and gets resampled by openEO automatically).
511
+ """
512
+ cube = conn.load_collection(
513
+ collection_id="SENTINEL2_L2A",
514
+ spatial_extent=bbox,
515
+ temporal_extent=temporal_extent,
516
+ bands=["B04", "B08", "B11", "SCL"],
517
+ )
518
+
519
+ scl = cube.band("SCL")
520
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
521
+ cube = cube.mask(cloud_mask == 0)
522
+
523
+ b11 = cube.band("B11")
524
+ b08 = cube.band("B08")
525
+ ndbi = (b11 - b08) / (b11 + b08)
526
+
527
+ monthly = ndbi.aggregate_temporal_period("month", reducer="median")
528
+
529
+ if resolution_m > 20:
530
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320, projection="EPSG:4326")
531
+
532
+ return monthly
533
+ ```
534
+
535
+ - [ ] **Step 6: Commit**
536
+
537
+ ```bash
538
+ git add app/openeo_client.py
539
+ git commit -m "feat: update openEO graph builders to native resolution (10-20m)"
540
+ ```
541
+
542
+ ---
543
+
544
+ ## Task 5: Seasonal baseline module
545
+
546
+ **Files:**
547
+ - Create: `app/analysis/__init__.py`
548
+ - Create: `app/analysis/seasonal.py`
549
+ - Create: `tests/test_seasonal.py`
550
+
551
+ - [ ] **Step 1: Write tests for seasonal baseline computation**
552
+
553
+ ```python
554
+ # tests/test_seasonal.py
555
+ """Tests for seasonal baseline computation."""
556
+ import numpy as np
557
+ import pytest
558
+
559
+
560
+ def test_group_bands_by_calendar_month():
561
+ """60 bands (5 years x 12 months) are correctly grouped by calendar month."""
562
+ from app.analysis.seasonal import group_bands_by_calendar_month
563
+
564
+ result = group_bands_by_calendar_month(n_bands=60, n_years=5)
565
+ # January bands: 1, 13, 25, 37, 49 (1-indexed)
566
+ assert result[1] == [1, 13, 25, 37, 49]
567
+ # December bands: 12, 24, 36, 48, 60
568
+ assert result[12] == [12, 24, 36, 48, 60]
569
+ assert len(result) == 12
570
+
571
+
572
+ def test_group_bands_partial_years():
573
+ """Handles baseline with fewer than 60 bands gracefully."""
574
+ from app.analysis.seasonal import group_bands_by_calendar_month
575
+
576
+ result = group_bands_by_calendar_month(n_bands=36, n_years=3)
577
+ assert result[1] == [1, 13, 25]
578
+ assert result[12] == [12, 24, 36]
579
+
580
+
581
+ def test_compute_seasonal_stats_aoi(synthetic_monthly_raster):
582
+ """Computes per-month AOI-level stats from a baseline raster."""
583
+ from app.analysis.seasonal import compute_seasonal_stats_aoi
584
+
585
+ # 60 bands, 5 years of data, values between 0.2 and 0.8
586
+ path = synthetic_monthly_raster(n_bands=60)
587
+ stats = compute_seasonal_stats_aoi(path, n_years=5)
588
+
589
+ assert len(stats) == 12 # one entry per calendar month
590
+ for month in range(1, 13):
591
+ s = stats[month]
592
+ assert "mean" in s
593
+ assert "median" in s
594
+ assert "std" in s
595
+ assert "min" in s
596
+ assert "max" in s
597
+ assert s["min"] <= s["mean"] <= s["max"]
598
+ assert s["std"] >= 0
599
+ assert s["n_years"] == 5
600
+
601
+
602
+ def test_compute_seasonal_stats_pixel(synthetic_monthly_raster):
603
+ """Computes per-pixel seasonal stats for a single calendar month."""
604
+ from app.analysis.seasonal import compute_seasonal_stats_pixel
605
+
606
+ path = synthetic_monthly_raster(n_bands=60, shape=(50, 50))
607
+ # Get stats for January (bands 1, 13, 25, 37, 49)
608
+ stats = compute_seasonal_stats_pixel(path, bands=[1, 13, 25, 37, 49])
609
+
610
+ assert stats["mean"].shape == (50, 50)
611
+ assert stats["std"].shape == (50, 50)
612
+ assert stats["median"].shape == (50, 50)
613
+ assert np.all(stats["std"] >= 0)
614
+
615
+
616
+ def test_compute_zscore_aoi():
617
+ """Z-score computed correctly at AOI level."""
618
+ from app.analysis.seasonal import compute_zscore
619
+
620
+ z = compute_zscore(current=0.5, baseline_mean=0.6, baseline_std=0.05, min_std=0.02)
621
+ assert z == pytest.approx(-2.0, abs=0.01)
622
+
623
+
624
+ def test_compute_zscore_clamps_low_std():
625
+ """Z-score uses min_std when baseline_std is too low."""
626
+ from app.analysis.seasonal import compute_zscore
627
+
628
+ z = compute_zscore(current=0.5, baseline_mean=0.5, baseline_std=0.001, min_std=0.02)
629
+ assert z == pytest.approx(0.0, abs=0.01)
630
+ ```
631
+
632
+ - [ ] **Step 2: Run tests to verify they fail**
633
+
634
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_seasonal.py -v`
635
+ Expected: FAIL — `app.analysis.seasonal` not found.
636
+
637
+ - [ ] **Step 3: Implement seasonal.py**
638
+
639
+ ```python
640
+ # app/analysis/__init__.py
641
+ """Reusable EO analysis modules."""
642
+ ```
643
+
644
+ ```python
645
+ # app/analysis/seasonal.py
646
+ """Seasonal baseline computation for EO indicators.
647
+
648
+ Groups multi-year monthly composites by calendar month and computes
649
+ per-pixel and AOI-level statistics for seasonal anomaly detection.
650
+ """
651
+ from __future__ import annotations
652
+
653
+ from typing import Any
654
+
655
+ import numpy as np
656
+ import rasterio
657
+
658
+
659
+ def group_bands_by_calendar_month(n_bands: int, n_years: int) -> dict[int, list[int]]:
660
+ """Map calendar months (1-12) to 1-indexed band numbers.
661
+
662
+ Assumes bands are ordered chronologically: band 1 = first month of
663
+ first year, band 13 = first month of second year, etc.
664
+ """
665
+ result: dict[int, list[int]] = {m: [] for m in range(1, 13)}
666
+ for band_idx in range(1, n_bands + 1):
667
+ month = ((band_idx - 1) % 12) + 1
668
+ result[month].append(band_idx)
669
+ return result
670
+
671
+
672
+ def compute_seasonal_stats_aoi(
673
+ tif_path: str,
674
+ n_years: int = 5,
675
+ ) -> dict[int, dict[str, Any]]:
676
+ """Compute per-calendar-month AOI-level statistics from a baseline raster.
677
+
678
+ Returns dict keyed by month (1-12), each containing:
679
+ mean, median, std, min, max, n_years (with valid data).
680
+ """
681
+ with rasterio.open(tif_path) as src:
682
+ n_bands = src.count
683
+ nodata = src.nodata
684
+ month_map = group_bands_by_calendar_month(n_bands, n_years)
685
+
686
+ stats: dict[int, dict[str, Any]] = {}
687
+ for month, bands in month_map.items():
688
+ monthly_means: list[float] = []
689
+ for band in bands:
690
+ data = src.read(band).astype(np.float32)
691
+ if nodata is not None:
692
+ valid = data[data != nodata]
693
+ else:
694
+ valid = data.ravel()
695
+ if len(valid) > 0:
696
+ monthly_means.append(float(np.nanmean(valid)))
697
+
698
+ if monthly_means:
699
+ arr = np.array(monthly_means)
700
+ stats[month] = {
701
+ "mean": float(np.mean(arr)),
702
+ "median": float(np.median(arr)),
703
+ "std": float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0,
704
+ "min": float(np.min(arr)),
705
+ "max": float(np.max(arr)),
706
+ "n_years": len(monthly_means),
707
+ }
708
+ else:
709
+ stats[month] = {
710
+ "mean": 0.0, "median": 0.0, "std": 0.0,
711
+ "min": 0.0, "max": 0.0, "n_years": 0,
712
+ }
713
+
714
+ return stats
715
+
716
+
717
+ def compute_seasonal_stats_pixel(
718
+ tif_path: str,
719
+ bands: list[int],
720
+ ) -> dict[str, np.ndarray]:
721
+ """Compute per-pixel statistics across bands for one calendar month.
722
+
723
+ Parameters
724
+ ----------
725
+ tif_path : str
726
+ Path to multi-band GeoTIFF.
727
+ bands : list[int]
728
+ 1-indexed band numbers (e.g. [1, 13, 25, 37, 49] for all Januaries).
729
+
730
+ Returns
731
+ -------
732
+ dict with keys: mean, median, std (all 2D arrays same shape as input bands).
733
+ """
734
+ with rasterio.open(tif_path) as src:
735
+ nodata = src.nodata
736
+ stack = []
737
+ for band in bands:
738
+ data = src.read(band).astype(np.float32)
739
+ if nodata is not None:
740
+ data[data == nodata] = np.nan
741
+ stack.append(data)
742
+
743
+ arr = np.stack(stack, axis=0) # shape: (n_years, H, W)
744
+
745
+ with np.errstate(all="ignore"):
746
+ mean = np.nanmean(arr, axis=0)
747
+ median = np.nanmedian(arr, axis=0)
748
+ std = np.nanstd(arr, axis=0, ddof=1) if arr.shape[0] > 1 else np.zeros_like(mean)
749
+
750
+ return {"mean": mean, "median": median, "std": std}
751
+
752
+
753
+ def compute_zscore(
754
+ current: float,
755
+ baseline_mean: float,
756
+ baseline_std: float,
757
+ min_std: float,
758
+ ) -> float:
759
+ """Compute z-score with a floor on std to avoid division-by-near-zero."""
760
+ effective_std = max(baseline_std, min_std)
761
+ return (current - baseline_mean) / effective_std
762
+ ```
763
+
764
+ - [ ] **Step 4: Run tests to verify they pass**
765
+
766
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_seasonal.py -v`
767
+ Expected: All 6 tests PASS.
768
+
769
+ - [ ] **Step 5: Commit**
770
+
771
+ ```bash
772
+ git add app/analysis/__init__.py app/analysis/seasonal.py tests/test_seasonal.py
773
+ git commit -m "feat: add seasonal baseline computation module"
774
+ ```
775
+
776
+ ---
777
+
778
+ ## Task 6: Pixel-level change detection module
779
+
780
+ **Files:**
781
+ - Create: `app/analysis/change.py`
782
+ - Create: `tests/test_change.py`
783
+
784
+ - [ ] **Step 1: Write tests for change detection**
785
+
786
+ ```python
787
+ # tests/test_change.py
788
+ """Tests for pixel-level change detection."""
789
+ import numpy as np
790
+ import pytest
791
+
792
+
793
+ def test_compute_zscore_raster():
794
+ """Z-score raster computed correctly from current and baseline stats."""
795
+ from app.analysis.change import compute_zscore_raster
796
+
797
+ current = np.array([[0.5, 0.3], [0.7, 0.4]], dtype=np.float32)
798
+ baseline_mean = np.array([[0.6, 0.6], [0.6, 0.6]], dtype=np.float32)
799
+ baseline_std = np.array([[0.05, 0.05], [0.05, 0.05]], dtype=np.float32)
800
+
801
+ z = compute_zscore_raster(current, baseline_mean, baseline_std, min_std=0.02)
802
+ expected = np.array([[-2.0, -6.0], [2.0, -4.0]], dtype=np.float32)
803
+ np.testing.assert_array_almost_equal(z, expected, decimal=1)
804
+
805
+
806
+ def test_compute_zscore_raster_clamps_std():
807
+ """Z-score raster uses min_std floor."""
808
+ from app.analysis.change import compute_zscore_raster
809
+
810
+ current = np.array([[0.5]], dtype=np.float32)
811
+ baseline_mean = np.array([[0.5]], dtype=np.float32)
812
+ baseline_std = np.array([[0.001]], dtype=np.float32)
813
+
814
+ z = compute_zscore_raster(current, baseline_mean, baseline_std, min_std=0.02)
815
+ assert z[0, 0] == pytest.approx(0.0, abs=0.01)
816
+
817
+
818
+ def test_detect_hotspots():
819
+ """Hotspot mask identifies pixels beyond z-score threshold."""
820
+ from app.analysis.change import detect_hotspots
821
+
822
+ z = np.array([[-3.0, 0.5, 2.5], [1.0, -0.3, -2.1]], dtype=np.float32)
823
+ mask, pct = detect_hotspots(z, threshold=2.0)
824
+
825
+ # Hotspot pixels: (-3.0), (2.5), (-2.1) = 3 out of 6 = 50%
826
+ assert mask[0, 0] == True
827
+ assert mask[0, 1] == False
828
+ assert mask[0, 2] == True
829
+ assert mask[1, 2] == True
830
+ assert pct == pytest.approx(50.0, abs=0.1)
831
+
832
+
833
+ def test_cluster_hotspots():
834
+ """Connected-component labeling finds spatial clusters."""
835
+ from app.analysis.change import cluster_hotspots
836
+
837
+ mask = np.array([
838
+ [True, True, False, False, False],
839
+ [True, True, False, False, False],
840
+ [False, False, False, True, True],
841
+ [False, False, False, True, True],
842
+ ], dtype=bool)
843
+
844
+ z = np.array([
845
+ [-2.5, -2.3, 0.0, 0.0, 0.0],
846
+ [-2.1, -2.0, 0.0, 0.0, 0.0],
847
+ [0.0, 0.0, 0.0, 2.5, 2.3],
848
+ [0.0, 0.0, 0.0, 2.1, 2.8],
849
+ ], dtype=np.float32)
850
+
851
+ clusters = cluster_hotspots(mask, z, pixel_area_ha=1.0, min_pixels=2)
852
+ assert len(clusters) == 2
853
+ # Clusters sorted by area descending
854
+ assert clusters[0]["area_ha"] == 4.0
855
+ assert clusters[1]["area_ha"] == 4.0
856
+
857
+
858
+ def test_cluster_hotspots_filters_small():
859
+ """Clusters smaller than min_pixels are excluded."""
860
+ from app.analysis.change import cluster_hotspots
861
+
862
+ mask = np.array([
863
+ [True, False, False],
864
+ [False, False, True],
865
+ ], dtype=bool)
866
+ z = np.full((2, 3), -2.5, dtype=np.float32)
867
+
868
+ clusters = cluster_hotspots(mask, z, pixel_area_ha=1.0, min_pixels=2)
869
+ assert len(clusters) == 0 # both clusters are 1 pixel, below min_pixels=2
870
+ ```
871
+
872
+ - [ ] **Step 2: Run tests to verify they fail**
873
+
874
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_change.py -v`
875
+ Expected: FAIL — `app.analysis.change` not found.
876
+
877
+ - [ ] **Step 3: Implement change.py**
878
+
879
+ ```python
880
+ # app/analysis/change.py
881
+ """Pixel-level change detection: z-score rasters, hotspot masks, clustering."""
882
+ from __future__ import annotations
883
+
884
+ from typing import Any
885
+
886
+ import numpy as np
887
+ from scipy import ndimage
888
+
889
+
890
+ def compute_zscore_raster(
891
+ current: np.ndarray,
892
+ baseline_mean: np.ndarray,
893
+ baseline_std: np.ndarray,
894
+ min_std: float,
895
+ ) -> np.ndarray:
896
+ """Compute per-pixel z-score raster.
897
+
898
+ Parameters
899
+ ----------
900
+ current : 2D float array — current month pixel values.
901
+ baseline_mean : 2D float array — same-month baseline mean.
902
+ baseline_std : 2D float array — same-month baseline std.
903
+ min_std : float — floor for std to avoid division noise.
904
+
905
+ Returns
906
+ -------
907
+ 2D float array of z-scores.
908
+ """
909
+ effective_std = np.maximum(baseline_std, min_std)
910
+ return (current - baseline_mean) / effective_std
911
+
912
+
913
+ def detect_hotspots(
914
+ zscore_raster: np.ndarray,
915
+ threshold: float = 2.0,
916
+ ) -> tuple[np.ndarray, float]:
917
+ """Create boolean hotspot mask and compute percentage of area affected.
918
+
919
+ Returns (mask, pct_affected) where mask is True for |z| > threshold.
920
+ """
921
+ valid = ~np.isnan(zscore_raster)
922
+ mask = valid & (np.abs(zscore_raster) > threshold)
923
+ n_valid = np.sum(valid)
924
+ pct = float(np.sum(mask) / n_valid * 100) if n_valid > 0 else 0.0
925
+ return mask, pct
926
+
927
+
928
+ def cluster_hotspots(
929
+ mask: np.ndarray,
930
+ zscore_raster: np.ndarray,
931
+ pixel_area_ha: float,
932
+ min_pixels: int = 4,
933
+ top_n: int = 3,
934
+ ) -> list[dict[str, Any]]:
935
+ """Find connected hotspot clusters and return the top N by area.
936
+
937
+ Parameters
938
+ ----------
939
+ mask : 2D bool array — hotspot mask.
940
+ zscore_raster : 2D float array — z-scores for mean calculation.
941
+ pixel_area_ha : float — area of one pixel in hectares.
942
+ min_pixels : int — ignore clusters smaller than this.
943
+ top_n : int — return at most this many clusters.
944
+
945
+ Returns
946
+ -------
947
+ List of dicts sorted by area descending, each with:
948
+ area_ha, centroid_row, centroid_col, mean_zscore, n_pixels.
949
+ """
950
+ labeled, n_features = ndimage.label(mask)
951
+
952
+ clusters: list[dict[str, Any]] = []
953
+ for label_id in range(1, n_features + 1):
954
+ pixels = labeled == label_id
955
+ n_pixels = int(np.sum(pixels))
956
+ if n_pixels < min_pixels:
957
+ continue
958
+
959
+ rows, cols = np.where(pixels)
960
+ clusters.append({
961
+ "area_ha": n_pixels * pixel_area_ha,
962
+ "centroid_row": float(np.mean(rows)),
963
+ "centroid_col": float(np.mean(cols)),
964
+ "mean_zscore": float(np.nanmean(zscore_raster[pixels])),
965
+ "n_pixels": n_pixels,
966
+ })
967
+
968
+ clusters.sort(key=lambda c: c["area_ha"], reverse=True)
969
+ return clusters[:top_n]
970
+ ```
971
+
972
+ - [ ] **Step 4: Run tests to verify they pass**
973
+
974
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_change.py -v`
975
+ Expected: All 5 tests PASS.
976
+
977
+ - [ ] **Step 5: Commit**
978
+
979
+ ```bash
980
+ git add app/analysis/change.py tests/test_change.py
981
+ git commit -m "feat: add pixel-level change detection with z-scores and hotspot clustering"
982
+ ```
983
+
984
+ ---
985
+
986
+ ## Task 7: Confidence scoring module
987
+
988
+ **Files:**
989
+ - Create: `app/analysis/confidence.py`
990
+ - Create: `tests/test_confidence.py`
991
+
992
+ - [ ] **Step 1: Write tests for confidence scoring**
993
+
994
+ ```python
995
+ # tests/test_confidence.py
996
+ """Tests for four-factor confidence scoring."""
997
+ import pytest
998
+
999
+
1000
+ def test_score_temporal_coverage():
1001
+ """Temporal coverage factor scores correctly across thresholds."""
1002
+ from app.analysis.confidence import score_temporal_coverage
1003
+
1004
+ assert score_temporal_coverage(0) == 0.25
1005
+ assert score_temporal_coverage(3) == 0.25
1006
+ assert score_temporal_coverage(5) == 0.5
1007
+ assert score_temporal_coverage(8) == 0.75
1008
+ assert score_temporal_coverage(12) == 1.0
1009
+
1010
+
1011
+ def test_score_observation_density():
1012
+ """Observation density factor scores correctly."""
1013
+ from app.analysis.confidence import score_observation_density
1014
+
1015
+ assert score_observation_density(1.0) == 0.25
1016
+ assert score_observation_density(4.0) == 0.5
1017
+ assert score_observation_density(8.0) == 0.75
1018
+ assert score_observation_density(15.0) == 1.0
1019
+
1020
+
1021
+ def test_score_baseline_depth():
1022
+ """Baseline depth factor scores correctly."""
1023
+ from app.analysis.confidence import score_baseline_depth
1024
+
1025
+ assert score_baseline_depth(1) == 0.25
1026
+ assert score_baseline_depth(3) == 0.5
1027
+ assert score_baseline_depth(4) == 0.75
1028
+ assert score_baseline_depth(5) == 1.0
1029
+
1030
+
1031
+ def test_score_spatial_completeness():
1032
+ """Spatial completeness factor scores correctly."""
1033
+ from app.analysis.confidence import score_spatial_completeness
1034
+
1035
+ assert score_spatial_completeness(0.3) == 0.25
1036
+ assert score_spatial_completeness(0.6) == 0.5
1037
+ assert score_spatial_completeness(0.85) == 0.75
1038
+ assert score_spatial_completeness(0.95) == 1.0
1039
+
1040
+
1041
+ def test_compute_confidence_high():
1042
+ """Full data coverage yields HIGH confidence."""
1043
+ from app.analysis.confidence import compute_confidence
1044
+ from app.models import ConfidenceLevel
1045
+
1046
+ result = compute_confidence(
1047
+ valid_months=12,
1048
+ mean_obs_per_composite=15.0,
1049
+ baseline_years_with_data=5,
1050
+ spatial_completeness=0.95,
1051
+ )
1052
+ assert result["level"] == ConfidenceLevel.HIGH
1053
+ assert result["score"] > 0.7
1054
+
1055
+
1056
+ def test_compute_confidence_low():
1057
+ """Sparse data yields LOW confidence."""
1058
+ from app.analysis.confidence import compute_confidence
1059
+ from app.models import ConfidenceLevel
1060
+
1061
+ result = compute_confidence(
1062
+ valid_months=2,
1063
+ mean_obs_per_composite=1.5,
1064
+ baseline_years_with_data=1,
1065
+ spatial_completeness=0.3,
1066
+ )
1067
+ assert result["level"] == ConfidenceLevel.LOW
1068
+ assert result["score"] < 0.4
1069
+
1070
+
1071
+ def test_compute_confidence_returns_factors():
1072
+ """Confidence result includes the four factor breakdowns."""
1073
+ from app.analysis.confidence import compute_confidence
1074
+
1075
+ result = compute_confidence(
1076
+ valid_months=6,
1077
+ mean_obs_per_composite=5.0,
1078
+ baseline_years_with_data=3,
1079
+ spatial_completeness=0.8,
1080
+ )
1081
+ assert "factors" in result
1082
+ factors = result["factors"]
1083
+ assert "temporal" in factors
1084
+ assert "observation_density" in factors
1085
+ assert "baseline_depth" in factors
1086
+ assert "spatial_completeness" in factors
1087
+ ```
1088
+
1089
+ - [ ] **Step 2: Run tests to verify they fail**
1090
+
1091
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_confidence.py -v`
1092
+ Expected: FAIL — `app.analysis.confidence` not found.
1093
+
1094
+ - [ ] **Step 3: Implement confidence.py**
1095
+
1096
+ ```python
1097
+ # app/analysis/confidence.py
1098
+ """Four-factor confidence scoring for EO indicators."""
1099
+ from __future__ import annotations
1100
+
1101
+ from typing import Any
1102
+
1103
+ from app.models import ConfidenceLevel
1104
+
1105
+
1106
+ def score_temporal_coverage(valid_months: int) -> float:
1107
+ """Score 0-1 based on valid monthly composites (out of 12)."""
1108
+ if valid_months >= 10:
1109
+ return 1.0
1110
+ if valid_months >= 7:
1111
+ return 0.75
1112
+ if valid_months >= 4:
1113
+ return 0.5
1114
+ return 0.25
1115
+
1116
+
1117
+ def score_observation_density(mean_obs: float) -> float:
1118
+ """Score 0-1 based on mean cloud-free observations per composite."""
1119
+ if mean_obs > 10:
1120
+ return 1.0
1121
+ if mean_obs >= 6:
1122
+ return 0.75
1123
+ if mean_obs >= 3:
1124
+ return 0.5
1125
+ return 0.25
1126
+
1127
+
1128
+ def score_baseline_depth(years_with_data: int) -> float:
1129
+ """Score 0-1 based on how many of 5 baseline years had valid data."""
1130
+ if years_with_data >= 5:
1131
+ return 1.0
1132
+ if years_with_data >= 4:
1133
+ return 0.75
1134
+ if years_with_data >= 2:
1135
+ return 0.5
1136
+ return 0.25
1137
+
1138
+
1139
+ def score_spatial_completeness(fraction: float) -> float:
1140
+ """Score 0-1 based on fraction of AOI with valid (non-nodata) pixels."""
1141
+ if fraction > 0.9:
1142
+ return 1.0
1143
+ if fraction > 0.75:
1144
+ return 0.75
1145
+ if fraction >= 0.5:
1146
+ return 0.5
1147
+ return 0.25
1148
+
1149
+
1150
+ def compute_confidence(
1151
+ valid_months: int,
1152
+ mean_obs_per_composite: float,
1153
+ baseline_years_with_data: int,
1154
+ spatial_completeness: float,
1155
+ ) -> dict[str, Any]:
1156
+ """Compute composite confidence score from four factors.
1157
+
1158
+ Returns dict with: level (ConfidenceLevel), score (0-1), factors (dict).
1159
+ """
1160
+ temporal = score_temporal_coverage(valid_months)
1161
+ obs = score_observation_density(mean_obs_per_composite)
1162
+ baseline = score_baseline_depth(baseline_years_with_data)
1163
+ spatial = score_spatial_completeness(spatial_completeness)
1164
+
1165
+ score = temporal * 0.3 + obs * 0.2 + baseline * 0.3 + spatial * 0.2
1166
+
1167
+ if score > 0.7:
1168
+ level = ConfidenceLevel.HIGH
1169
+ elif score >= 0.4:
1170
+ level = ConfidenceLevel.MODERATE
1171
+ else:
1172
+ level = ConfidenceLevel.LOW
1173
+
1174
+ return {
1175
+ "level": level,
1176
+ "score": round(score, 3),
1177
+ "factors": {
1178
+ "temporal": temporal,
1179
+ "observation_density": obs,
1180
+ "baseline_depth": baseline,
1181
+ "spatial_completeness": spatial,
1182
+ },
1183
+ }
1184
+ ```
1185
+
1186
+ - [ ] **Step 4: Run tests to verify they pass**
1187
+
1188
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_confidence.py -v`
1189
+ Expected: All 8 tests PASS.
1190
+
1191
+ - [ ] **Step 5: Commit**
1192
+
1193
+ ```bash
1194
+ git add app/analysis/confidence.py tests/test_confidence.py
1195
+ git commit -m "feat: add four-factor confidence scoring model"
1196
+ ```
1197
+
1198
+ ---
1199
+
1200
+ ## Task 8: Compound signal detection module
1201
+
1202
+ **Files:**
1203
+ - Create: `app/analysis/compound.py`
1204
+ - Create: `tests/test_compound.py`
1205
+
1206
+ - [ ] **Step 1: Write tests for compound signal detection**
1207
+
1208
+ ```python
1209
+ # tests/test_compound.py
1210
+ """Tests for cross-indicator compound signal detection."""
1211
+ import numpy as np
1212
+ import pytest
1213
+
1214
+
1215
+ def test_compute_overlap_pct():
1216
+ """Overlap percentage between two boolean masks."""
1217
+ from app.analysis.compound import compute_overlap_pct
1218
+
1219
+ a = np.array([[True, True, False], [False, False, True]], dtype=bool)
1220
+ b = np.array([[True, False, False], [False, False, True]], dtype=bool)
1221
+ # Overlap: (0,0) and (1,2) = 2 pixels, union = 3, but we measure overlap/min(a,b)
1222
+ pct = compute_overlap_pct(a, b)
1223
+ assert pct > 0
1224
+
1225
+
1226
+ def test_detect_land_conversion():
1227
+ """Land conversion detected when NDVI decline overlaps settlement growth."""
1228
+ from app.analysis.compound import detect_compound_signals
1229
+ from app.models import CompoundSignal
1230
+
1231
+ # NDVI decline hotspots
1232
+ ndvi_z = np.full((10, 10), -2.5, dtype=np.float32) # all declining
1233
+ # Settlement growth hotspots
1234
+ buildup_z = np.full((10, 10), 2.5, dtype=np.float32) # all growing
1235
+ # Other indicators: no anomaly
1236
+ water_z = np.zeros((10, 10), dtype=np.float32)
1237
+ sar_z = np.zeros((10, 10), dtype=np.float32)
1238
+
1239
+ signals = detect_compound_signals(
1240
+ zscore_rasters={"ndvi": ndvi_z, "water": water_z, "sar": sar_z, "buildup": buildup_z},
1241
+ pixel_area_ha=0.04,
1242
+ threshold=2.0,
1243
+ )
1244
+
1245
+ land_conv = [s for s in signals if s.name == "land_conversion"]
1246
+ assert len(land_conv) == 1
1247
+ assert land_conv[0].triggered is True
1248
+ assert "ndvi" in land_conv[0].indicators
1249
+ assert "buildup" in land_conv[0].indicators
1250
+
1251
+
1252
+ def test_no_signals_when_all_normal():
1253
+ """No compound signals when all indicators are within normal range."""
1254
+ from app.analysis.compound import detect_compound_signals
1255
+
1256
+ normal = np.zeros((10, 10), dtype=np.float32)
1257
+ signals = detect_compound_signals(
1258
+ zscore_rasters={"ndvi": normal, "water": normal, "sar": normal, "buildup": normal},
1259
+ pixel_area_ha=0.04,
1260
+ threshold=2.0,
1261
+ )
1262
+ triggered = [s for s in signals if s.triggered]
1263
+ assert len(triggered) == 0
1264
+
1265
+
1266
+ def test_flood_signal():
1267
+ """Flood signal detected when SAR decreases and water increases."""
1268
+ from app.analysis.compound import detect_compound_signals
1269
+
1270
+ sar_z = np.full((10, 10), -2.5, dtype=np.float32) # VV drop
1271
+ water_z = np.full((10, 10), 2.5, dtype=np.float32) # water increase
1272
+ ndvi_z = np.zeros((10, 10), dtype=np.float32)
1273
+ buildup_z = np.zeros((10, 10), dtype=np.float32)
1274
+
1275
+ signals = detect_compound_signals(
1276
+ zscore_rasters={"ndvi": ndvi_z, "water": water_z, "sar": sar_z, "buildup": buildup_z},
1277
+ pixel_area_ha=0.04,
1278
+ threshold=2.0,
1279
+ )
1280
+
1281
+ flood = [s for s in signals if s.name == "flood_event"]
1282
+ assert len(flood) == 1
1283
+ assert flood[0].triggered is True
1284
+ ```
1285
+
1286
+ - [ ] **Step 2: Run tests to verify they fail**
1287
+
1288
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_compound.py -v`
1289
+ Expected: FAIL — `app.analysis.compound` not found.
1290
+
1291
+ - [ ] **Step 3: Implement compound.py**
1292
+
1293
+ ```python
1294
+ # app/analysis/compound.py
1295
+ """Cross-indicator compound signal detection."""
1296
+ from __future__ import annotations
1297
+
1298
+ import numpy as np
1299
+
1300
+ from app.models import CompoundSignal
1301
+
1302
+
1303
+ def compute_overlap_pct(mask_a: np.ndarray, mask_b: np.ndarray) -> float:
1304
+ """Compute overlap percentage: intersection / min(count_a, count_b) * 100."""
1305
+ intersection = np.sum(mask_a & mask_b)
1306
+ min_count = min(np.sum(mask_a), np.sum(mask_b))
1307
+ if min_count == 0:
1308
+ return 0.0
1309
+ return float(intersection / min_count * 100)
1310
+
1311
+
1312
+ def _tag_confidence(n_indicators: int, overlap_pct: float) -> str:
1313
+ """Assign confidence tag based on indicator agreement and spatial overlap."""
1314
+ if n_indicators >= 3 and overlap_pct > 20:
1315
+ return "strong"
1316
+ if n_indicators >= 2 and overlap_pct >= 10:
1317
+ return "moderate"
1318
+ return "weak"
1319
+
1320
+
1321
+ def detect_compound_signals(
1322
+ zscore_rasters: dict[str, np.ndarray],
1323
+ pixel_area_ha: float,
1324
+ threshold: float = 2.0,
1325
+ ) -> list[CompoundSignal]:
1326
+ """Test for compound signal patterns across indicator z-score rasters.
1327
+
1328
+ All rasters must be the same shape (resampled to common grid beforehand).
1329
+
1330
+ Returns a list of CompoundSignal objects (both triggered and not).
1331
+ """
1332
+ # Build directional hotspot masks
1333
+ decline: dict[str, np.ndarray] = {}
1334
+ increase: dict[str, np.ndarray] = {}
1335
+ for ind_id, z in zscore_rasters.items():
1336
+ decline[ind_id] = z < -threshold
1337
+ increase[ind_id] = z > threshold
1338
+
1339
+ signals: list[CompoundSignal] = []
1340
+
1341
+ # 1. Land conversion: NDVI decline + Settlement growth
1342
+ if "ndvi" in decline and "buildup" in increase:
1343
+ overlap = compute_overlap_pct(decline["ndvi"], increase["buildup"])
1344
+ triggered = overlap > 10
1345
+ affected = float(np.sum(decline["ndvi"] & increase["buildup"])) * pixel_area_ha
1346
+ signals.append(CompoundSignal(
1347
+ name="land_conversion",
1348
+ triggered=triggered,
1349
+ confidence=_tag_confidence(2, overlap) if triggered else "weak",
1350
+ description=(
1351
+ f"NDVI decline overlaps with settlement growth ({overlap:.0f}% overlap, "
1352
+ f"{affected:.1f} ha affected). Suggests possible vegetation loss to urbanization."
1353
+ ) if triggered else "No land conversion signal detected.",
1354
+ indicators=["ndvi", "buildup"],
1355
+ overlap_pct=overlap,
1356
+ affected_ha=affected,
1357
+ ))
1358
+
1359
+ # 2. Flood event: SAR decrease + Water increase
1360
+ if "sar" in decline and "water" in increase:
1361
+ overlap = compute_overlap_pct(decline["sar"], increase["water"])
1362
+ triggered = overlap > 10
1363
+ affected = float(np.sum(decline["sar"] & increase["water"])) * pixel_area_ha
1364
+ signals.append(CompoundSignal(
1365
+ name="flood_event",
1366
+ triggered=triggered,
1367
+ confidence=_tag_confidence(2, overlap) if triggered else "weak",
1368
+ description=(
1369
+ f"SAR backscatter decrease coincides with water extent increase "
1370
+ f"({overlap:.0f}% overlap, {affected:.1f} ha). Suggests potential flooding."
1371
+ ) if triggered else "No flood signal detected.",
1372
+ indicators=["sar", "water"],
1373
+ overlap_pct=overlap,
1374
+ affected_ha=affected,
1375
+ ))
1376
+
1377
+ # 3. Drought stress: NDVI decline + Water decline + SAR increase
1378
+ if "ndvi" in decline and "water" in decline and "sar" in increase:
1379
+ # Need all three to overlap
1380
+ combined = decline["ndvi"] & decline["water"] & increase["sar"]
1381
+ n_combined = int(np.sum(combined))
1382
+ min_single = min(np.sum(decline["ndvi"]), np.sum(decline["water"]), np.sum(increase["sar"]))
1383
+ overlap = float(n_combined / min_single * 100) if min_single > 0 else 0.0
1384
+ triggered = overlap > 10
1385
+ affected = n_combined * pixel_area_ha
1386
+ signals.append(CompoundSignal(
1387
+ name="drought_stress",
1388
+ triggered=triggered,
1389
+ confidence=_tag_confidence(3, overlap) if triggered else "weak",
1390
+ description=(
1391
+ f"NDVI decline, water decline, and SAR increase co-occur "
1392
+ f"({overlap:.0f}% overlap, {affected:.1f} ha). Suggests possible drought."
1393
+ ) if triggered else "No drought signal detected.",
1394
+ indicators=["ndvi", "water", "sar"],
1395
+ overlap_pct=overlap,
1396
+ affected_ha=affected,
1397
+ ))
1398
+
1399
+ # 4. Displacement pressure: Settlement growth + NDVI decline in surrounding area
1400
+ if "buildup" in increase and "ndvi" in decline:
1401
+ # Use dilation to check adjacency — settlement growth hotspots expanded by 1 pixel
1402
+ from scipy.ndimage import binary_dilation
1403
+ expanded_buildup = binary_dilation(increase["buildup"], iterations=1)
1404
+ adjacent_decline = expanded_buildup & decline["ndvi"] & ~increase["buildup"]
1405
+ n_adjacent = int(np.sum(adjacent_decline))
1406
+ n_buildup = int(np.sum(increase["buildup"]))
1407
+ overlap = float(n_adjacent / max(n_buildup, 1) * 100)
1408
+ triggered = overlap > 10 and n_adjacent > 0
1409
+ affected = n_adjacent * pixel_area_ha
1410
+ signals.append(CompoundSignal(
1411
+ name="displacement_pressure",
1412
+ triggered=triggered,
1413
+ confidence=_tag_confidence(2, overlap) if triggered else "weak",
1414
+ description=(
1415
+ f"Settlement growth hotspots are adjacent to NDVI decline areas "
1416
+ f"({affected:.1f} ha of surrounding vegetation loss). "
1417
+ f"Suggests expansion into previously vegetated land."
1418
+ ) if triggered else "No displacement pressure signal detected.",
1419
+ indicators=["ndvi", "buildup"],
1420
+ overlap_pct=overlap,
1421
+ affected_ha=affected,
1422
+ ))
1423
+
1424
+ return signals
1425
+ ```
1426
+
1427
+ - [ ] **Step 4: Run tests to verify they pass**
1428
+
1429
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_compound.py -v`
1430
+ Expected: All 4 tests PASS.
1431
+
1432
+ - [ ] **Step 5: Commit**
1433
+
1434
+ ```bash
1435
+ git add app/analysis/compound.py tests/test_compound.py
1436
+ git commit -m "feat: add cross-indicator compound signal detection"
1437
+ ```
1438
+
1439
+ ---
1440
+
1441
+ ## Task 9: Update NDVI indicator with seasonal analysis
1442
+
1443
+ **Files:**
1444
+ - Modify: `app/indicators/ndvi.py`
1445
+
1446
+ This task serves as the template for all 4 indicators. The pattern established here will be replicated (with indicator-specific adjustments) in Tasks 10-12.
1447
+
1448
+ - [ ] **Step 1: Update imports and constants at top of ndvi.py**
1449
+
1450
+ Replace lines 18-32 with:
1451
+
1452
+ ```python
1453
+ from app.config import (
1454
+ NDVI_RESOLUTION_M,
1455
+ TRUECOLOR_RESOLUTION_M,
1456
+ MIN_STD_NDVI,
1457
+ ZSCORE_THRESHOLD,
1458
+ MIN_CLUSTER_PIXELS,
1459
+ )
1460
+ from app.indicators.base import BaseIndicator, SpatialData
1461
+ from app.models import (
1462
+ AOI,
1463
+ TimeRange,
1464
+ IndicatorResult,
1465
+ StatusLevel,
1466
+ TrendDirection,
1467
+ ConfidenceLevel,
1468
+ )
1469
+ from app.openeo_client import get_connection, build_ndvi_graph, build_true_color_graph, _bbox_dict, submit_as_batch
1470
+ from app.analysis.seasonal import (
1471
+ group_bands_by_calendar_month,
1472
+ compute_seasonal_stats_aoi,
1473
+ compute_seasonal_stats_pixel,
1474
+ compute_zscore,
1475
+ )
1476
+ from app.analysis.change import compute_zscore_raster, detect_hotspots, cluster_hotspots
1477
+ from app.analysis.confidence import compute_confidence
1478
+
1479
+ logger = logging.getLogger(__name__)
1480
+
1481
+ BASELINE_YEARS = 5
1482
+ ```
1483
+
1484
+ - [ ] **Step 2: Update submit_batch to use native resolution**
1485
+
1486
+ Replace lines 65-78 (the three `build_*_graph` calls inside `submit_batch`) with:
1487
+
1488
+ ```python
1489
+ current_cube = build_ndvi_graph(
1490
+ conn=conn, bbox=bbox,
1491
+ temporal_extent=[current_start, current_end],
1492
+ resolution_m=NDVI_RESOLUTION_M,
1493
+ )
1494
+ baseline_cube = build_ndvi_graph(
1495
+ conn=conn, bbox=bbox,
1496
+ temporal_extent=[baseline_start, baseline_end],
1497
+ resolution_m=NDVI_RESOLUTION_M,
1498
+ )
1499
+ true_color_cube = build_true_color_graph(
1500
+ conn=conn, bbox=bbox,
1501
+ temporal_extent=[current_start, current_end],
1502
+ resolution_m=TRUECOLOR_RESOLUTION_M,
1503
+ )
1504
+ ```
1505
+
1506
+ - [ ] **Step 3: Rewrite the harvest method's analysis section**
1507
+
1508
+ Replace the analysis section of `harvest()` (lines 135-213) with the new seasonal analysis logic. This is the core change — replace everything from `# Compute statistics` (line 135) through the `return IndicatorResult(...)` block (ending at line 213):
1509
+
1510
+ ```python
1511
+ # --- Seasonal baseline analysis ---
1512
+ current_stats = self._compute_stats(current_path)
1513
+ current_mean = current_stats["overall_mean"]
1514
+ n_current_bands = current_stats["valid_months"]
1515
+
1516
+ # Compute spatial completeness from current raster
1517
+ spatial_completeness = self._compute_spatial_completeness(current_path)
1518
+
1519
+ if baseline_path:
1520
+ seasonal_stats = compute_seasonal_stats_aoi(baseline_path, n_years=BASELINE_YEARS)
1521
+ baseline_stats = self._compute_stats(baseline_path)
1522
+
1523
+ # Determine which calendar month the most recent current band represents
1524
+ # Current bands map to months starting from time_range.start.month
1525
+ start_month = time_range.start.month
1526
+ most_recent_month = ((start_month + n_current_bands - 2) % 12) + 1
1527
+
1528
+ if most_recent_month in seasonal_stats and seasonal_stats[most_recent_month]["n_years"] > 0:
1529
+ s = seasonal_stats[most_recent_month]
1530
+ z_current = compute_zscore(current_mean, s["mean"], s["std"], MIN_STD_NDVI)
1531
+ else:
1532
+ z_current = 0.0
1533
+
1534
+ # Count anomaly months
1535
+ anomaly_months = 0
1536
+ monthly_zscores = []
1537
+ for i, val in enumerate(current_stats["monthly_means"]):
1538
+ if val <= 0:
1539
+ continue
1540
+ cal_month = ((start_month + i - 1) % 12) + 1
1541
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
1542
+ z = compute_zscore(val, seasonal_stats[cal_month]["mean"],
1543
+ seasonal_stats[cal_month]["std"], MIN_STD_NDVI)
1544
+ monthly_zscores.append(z)
1545
+ if abs(z) > ZSCORE_THRESHOLD:
1546
+ anomaly_months += 1
1547
+ else:
1548
+ monthly_zscores.append(0.0)
1549
+
1550
+ # Pixel-level change detection for most recent month
1551
+ month_map = group_bands_by_calendar_month(baseline_stats["valid_months_total"], BASELINE_YEARS)
1552
+ hotspot_pct = 0.0
1553
+ self._zscore_raster = None
1554
+ if most_recent_month in month_map and len(month_map[most_recent_month]) > 0:
1555
+ pixel_stats = compute_seasonal_stats_pixel(baseline_path, month_map[most_recent_month])
1556
+ with rasterio.open(current_path) as src:
1557
+ current_band_idx = min(n_current_bands, src.count)
1558
+ current_data = src.read(current_band_idx).astype(np.float32)
1559
+ if src.nodata is not None:
1560
+ current_data[current_data == src.nodata] = np.nan
1561
+ pixel_res = src.res[0] # degrees
1562
+
1563
+ z_raster = compute_zscore_raster(current_data, pixel_stats["mean"],
1564
+ pixel_stats["std"], MIN_STD_NDVI)
1565
+ hotspot_mask, hotspot_pct = detect_hotspots(z_raster, ZSCORE_THRESHOLD)
1566
+
1567
+ # Store for map rendering and cross-indicator analysis
1568
+ self._zscore_raster = z_raster
1569
+ self._hotspot_mask = hotspot_mask
1570
+
1571
+ # Confidence
1572
+ baseline_depth = sum(1 for m in range(1, 13)
1573
+ if m in seasonal_stats and seasonal_stats[m]["n_years"] > 0)
1574
+ mean_baseline_years = (sum(seasonal_stats[m]["n_years"] for m in range(1, 13)
1575
+ if m in seasonal_stats) / max(baseline_depth, 1))
1576
+ conf = compute_confidence(
1577
+ valid_months=n_current_bands,
1578
+ mean_obs_per_composite=5.0, # TODO: track from openEO when cloud fraction tracking available
1579
+ baseline_years_with_data=int(mean_baseline_years),
1580
+ spatial_completeness=spatial_completeness,
1581
+ )
1582
+ confidence = conf["level"]
1583
+ confidence_factors = conf["factors"]
1584
+
1585
+ # Status from z-score
1586
+ status = self._classify_zscore(z_current, hotspot_pct)
1587
+ trend = self._compute_trend_zscore(monthly_zscores)
1588
+
1589
+ # Chart data with seasonal envelope
1590
+ chart_data = self._build_seasonal_chart_data(
1591
+ current_stats["monthly_means"], seasonal_stats, time_range, monthly_zscores,
1592
+ )
1593
+
1594
+ change = current_mean - baseline_stats["overall_mean"]
1595
+ else:
1596
+ z_current = 0.0
1597
+ anomaly_months = 0
1598
+ hotspot_pct = 0.0
1599
+ confidence = ConfidenceLevel.LOW
1600
+ confidence_factors = {}
1601
+ status = StatusLevel.GREEN
1602
+ trend = TrendDirection.STABLE
1603
+ change = 0.0
1604
+ self._zscore_raster = None
1605
+ chart_data = {
1606
+ "dates": [f"{time_range.end.year}-{m+1:02d}" for m in range(len(current_stats["monthly_means"]))],
1607
+ "values": [round(v, 3) for v in current_stats["monthly_means"]],
1608
+ "label": "NDVI",
1609
+ }
1610
+
1611
+ # Headline
1612
+ if abs(z_current) <= 1.0:
1613
+ headline = f"Vegetation within normal range (NDVI {current_mean:.2f}, z={z_current:+.1f})"
1614
+ elif z_current > 0:
1615
+ headline = f"Vegetation greening (NDVI {current_mean:.2f}, z={z_current:+.1f} above seasonal average)"
1616
+ else:
1617
+ headline = f"Vegetation decline (NDVI {current_mean:.2f}, z={z_current:+.1f} below seasonal average)"
1618
+
1619
+ # Spatial data for map rendering
1620
+ self._spatial_data = SpatialData(
1621
+ map_type="raster", label="NDVI", colormap="RdYlGn",
1622
+ vmin=-0.2, vmax=0.9,
1623
+ )
1624
+ self._indicator_raster_path = current_path
1625
+ self._true_color_path = true_color_path
1626
+ self._ndvi_peak_band = current_stats["peak_month_band"]
1627
+ self._render_band = current_stats["peak_month_band"]
1628
+
1629
+ return IndicatorResult(
1630
+ indicator_id=self.id,
1631
+ headline=headline,
1632
+ status=status,
1633
+ trend=trend,
1634
+ confidence=confidence,
1635
+ map_layer_path=current_path,
1636
+ chart_data=chart_data,
1637
+ data_source="satellite",
1638
+ anomaly_months=anomaly_months,
1639
+ z_score_current=round(z_current, 2),
1640
+ hotspot_pct=round(hotspot_pct, 1),
1641
+ confidence_factors=confidence_factors,
1642
+ summary=(
1643
+ f"Mean NDVI is {current_mean:.3f} (z-score {z_current:+.1f} vs seasonal baseline). "
1644
+ f"{anomaly_months} of {n_current_bands} months show significant anomalies. "
1645
+ f"{hotspot_pct:.0f}% of AOI has statistically significant change. "
1646
+ f"Pixel-level analysis at {NDVI_RESOLUTION_M}m resolution."
1647
+ ),
1648
+ methodology=(
1649
+ f"Sentinel-2 L2A pixel-level NDVI = (B08 − B04) / (B08 + B04). "
1650
+ f"Cloud-masked using SCL band (classes 4, 5, 6 retained). "
1651
+ f"Monthly median composites at {NDVI_RESOLUTION_M}m native resolution. "
1652
+ f"Baseline: {BASELINE_YEARS}-year seasonal baselines (per calendar month). "
1653
+ f"Anomaly detection via z-scores (threshold: ±{ZSCORE_THRESHOLD}). "
1654
+ f"Processed server-side via CDSE openEO batch jobs."
1655
+ ),
1656
+ limitations=[
1657
+ "Cloud cover reduces observation count in rainy seasons.",
1658
+ "NDVI does not distinguish crop from natural vegetation.",
1659
+ "Z-score anomalies assume baseline is representative of normal conditions.",
1660
+ ] + (["Baseline unavailable — change and trend not computed."] if not baseline_path else []),
1661
+ )
1662
+ ```
1663
+
1664
+ - [ ] **Step 4: Add new static/helper methods to NdviIndicator**
1665
+
1666
+ Add these methods after the existing `_compute_stats` method (after line 381):
1667
+
1668
+ ```python
1669
+ @staticmethod
1670
+ def _compute_spatial_completeness(tif_path: str) -> float:
1671
+ """Compute fraction of AOI with valid (non-nodata) pixels."""
1672
+ with rasterio.open(tif_path) as src:
1673
+ data = src.read(1).astype(np.float32)
1674
+ nodata = src.nodata
1675
+ if nodata is not None:
1676
+ valid = np.sum(data != nodata)
1677
+ else:
1678
+ valid = np.sum(~np.isnan(data))
1679
+ total = data.size
1680
+ return float(valid / total) if total > 0 else 0.0
1681
+
1682
+ @staticmethod
1683
+ def _classify_zscore(z_score: float, hotspot_pct: float) -> StatusLevel:
1684
+ """Classify status using z-score and hotspot percentage."""
1685
+ if abs(z_score) > ZSCORE_THRESHOLD or hotspot_pct > 25:
1686
+ return StatusLevel.RED
1687
+ if abs(z_score) > 1.0 or hotspot_pct > 10:
1688
+ return StatusLevel.AMBER
1689
+ return StatusLevel.GREEN
1690
+
1691
+ @staticmethod
1692
+ def _compute_trend_zscore(monthly_zscores: list[float]) -> TrendDirection:
1693
+ """Compute trend from direction of monthly z-scores."""
1694
+ valid = [z for z in monthly_zscores if z != 0.0]
1695
+ if len(valid) < 2:
1696
+ return TrendDirection.STABLE
1697
+ within_normal = sum(1 for z in valid if abs(z) <= 1.0)
1698
+ if within_normal > len(valid) / 2:
1699
+ return TrendDirection.STABLE
1700
+ # Check direction of anomalies
1701
+ negative = sum(1 for z in valid if z < -1.0)
1702
+ positive = sum(1 for z in valid if z > 1.0)
1703
+ if negative > positive:
1704
+ return TrendDirection.DETERIORATING
1705
+ if positive > negative:
1706
+ return TrendDirection.IMPROVING
1707
+ return TrendDirection.STABLE
1708
+
1709
+ @staticmethod
1710
+ def _build_seasonal_chart_data(
1711
+ current_monthly: list[float],
1712
+ seasonal_stats: dict[int, dict],
1713
+ time_range: TimeRange,
1714
+ monthly_zscores: list[float],
1715
+ ) -> dict[str, Any]:
1716
+ """Build chart data with seasonal baseline envelope."""
1717
+ start_month = time_range.start.month
1718
+ n = len(current_monthly)
1719
+ year = time_range.end.year
1720
+
1721
+ dates = []
1722
+ values = []
1723
+ b_mean = []
1724
+ b_min = []
1725
+ b_max = []
1726
+ anomaly_flags = []
1727
+
1728
+ for i in range(n):
1729
+ cal_month = ((start_month + i - 1) % 12) + 1
1730
+ dates.append(f"{year}-{cal_month:02d}")
1731
+ values.append(round(current_monthly[i], 3))
1732
+
1733
+ if cal_month in seasonal_stats and seasonal_stats[cal_month]["n_years"] > 0:
1734
+ s = seasonal_stats[cal_month]
1735
+ b_mean.append(round(s["mean"], 3))
1736
+ b_min.append(round(s["min"], 3))
1737
+ b_max.append(round(s["max"], 3))
1738
+ else:
1739
+ b_mean.append(0.0)
1740
+ b_min.append(0.0)
1741
+ b_max.append(0.0)
1742
+
1743
+ if i < len(monthly_zscores):
1744
+ anomaly_flags.append(abs(monthly_zscores[i]) > ZSCORE_THRESHOLD)
1745
+ else:
1746
+ anomaly_flags.append(False)
1747
+
1748
+ return {
1749
+ "dates": dates,
1750
+ "values": values,
1751
+ "baseline_mean": b_mean,
1752
+ "baseline_min": b_min,
1753
+ "baseline_max": b_max,
1754
+ "anomaly_flags": anomaly_flags,
1755
+ "label": "NDVI",
1756
+ }
1757
+ ```
1758
+
1759
+ - [ ] **Step 5: Update _compute_stats to also return valid_months_total (band count)**
1760
+
1761
+ In the existing `_compute_stats` method, add `valid_months_total` to the return dict. Change line 376-381:
1762
+
1763
+ ```python
1764
+ return {
1765
+ "monthly_means": monthly_means,
1766
+ "overall_mean": overall_mean,
1767
+ "valid_months": valid_months,
1768
+ "valid_months_total": n_bands,
1769
+ "peak_month_band": peak_band,
1770
+ }
1771
+ ```
1772
+
1773
+ - [ ] **Step 6: Remove the old _classify, _compute_trend, and _build_chart_data methods**
1774
+
1775
+ Delete the old static methods `_classify` (lines 383-390), `_compute_trend` (lines 392-398), and `_build_chart_data` (lines 400-424) since they are replaced by the new `_classify_zscore`, `_compute_trend_zscore`, and `_build_seasonal_chart_data`.
1776
+
1777
+ - [ ] **Step 7: Verify the module imports cleanly**
1778
+
1779
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.indicators.ndvi import NdviIndicator; print('OK')"`
1780
+ Expected: `OK`
1781
+
1782
+ - [ ] **Step 8: Commit**
1783
+
1784
+ ```bash
1785
+ git add app/indicators/ndvi.py
1786
+ git commit -m "feat: upgrade NDVI indicator with seasonal baselines, z-scores, and hotspot detection"
1787
+ ```
1788
+
1789
+ ---
1790
+
1791
+ ## Task 10: Update Water indicator (same pattern as NDVI)
1792
+
1793
+ **Files:**
1794
+ - Modify: `app/indicators/water.py`
1795
+
1796
+ - [ ] **Step 1: Update imports**
1797
+
1798
+ Replace lines 17-31 with:
1799
+
1800
+ ```python
1801
+ from app.config import (
1802
+ WATER_RESOLUTION_M,
1803
+ TRUECOLOR_RESOLUTION_M,
1804
+ MIN_STD_WATER,
1805
+ ZSCORE_THRESHOLD,
1806
+ MIN_CLUSTER_PIXELS,
1807
+ )
1808
+ from app.indicators.base import BaseIndicator, SpatialData
1809
+ from app.models import (
1810
+ AOI,
1811
+ TimeRange,
1812
+ IndicatorResult,
1813
+ StatusLevel,
1814
+ TrendDirection,
1815
+ ConfidenceLevel,
1816
+ )
1817
+ from app.openeo_client import get_connection, build_mndwi_graph, build_true_color_graph, _bbox_dict, submit_as_batch
1818
+ from app.analysis.seasonal import (
1819
+ group_bands_by_calendar_month,
1820
+ compute_seasonal_stats_aoi,
1821
+ compute_seasonal_stats_pixel,
1822
+ compute_zscore,
1823
+ )
1824
+ from app.analysis.change import compute_zscore_raster, detect_hotspots, cluster_hotspots
1825
+ from app.analysis.confidence import compute_confidence
1826
+
1827
+ logger = logging.getLogger(__name__)
1828
+
1829
+ BASELINE_YEARS = 5
1830
+ WATER_THRESHOLD = 0.0
1831
+ ```
1832
+
1833
+ - [ ] **Step 2: Update submit_batch to use WATER_RESOLUTION_M**
1834
+
1835
+ Replace `resolution_m=RESOLUTION_M` with `resolution_m=WATER_RESOLUTION_M` in the three `build_*_graph` calls, and use `TRUECOLOR_RESOLUTION_M` for the true-color graph.
1836
+
1837
+ - [ ] **Step 3: Rewrite harvest() analysis with seasonal logic**
1838
+
1839
+ Apply the same pattern as NDVI Task 9, Step 3, but adapted for water:
1840
+ - Use `MIN_STD_WATER` instead of `MIN_STD_NDVI`
1841
+ - Water fraction stats instead of raw NDVI means
1842
+ - Headlines reference water extent and seasonal context
1843
+ - Summary references water extent percentage and z-scores
1844
+ - Methodology references MNDWI formula and `WATER_RESOLUTION_M`
1845
+
1846
+ - [ ] **Step 4: Add _compute_spatial_completeness, _classify_zscore, _compute_trend_zscore, _build_seasonal_chart_data**
1847
+
1848
+ Same pattern as NDVI but with water-appropriate label ("Water extent (%)").
1849
+
1850
+ - [ ] **Step 5: Update _compute_stats to return valid_months_total**
1851
+
1852
+ - [ ] **Step 6: Remove old _classify, _compute_trend, _build_chart_data**
1853
+
1854
+ - [ ] **Step 7: Verify import**
1855
+
1856
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.indicators.water import WaterIndicator; print('OK')"`
1857
+ Expected: `OK`
1858
+
1859
+ - [ ] **Step 8: Commit**
1860
+
1861
+ ```bash
1862
+ git add app/indicators/water.py
1863
+ git commit -m "feat: upgrade Water indicator with seasonal baselines and z-score analysis"
1864
+ ```
1865
+
1866
+ ---
1867
+
1868
+ ## Task 11: Update SAR indicator (same pattern as NDVI)
1869
+
1870
+ **Files:**
1871
+ - Modify: `app/indicators/sar.py`
1872
+
1873
+ - [ ] **Step 1: Update imports**
1874
+
1875
+ Same pattern: use `SAR_RESOLUTION_M`, `MIN_STD_SAR`, import seasonal/change/confidence modules.
1876
+
1877
+ - [ ] **Step 2: Update submit_batch to use SAR_RESOLUTION_M**
1878
+
1879
+ - [ ] **Step 3: Rewrite harvest() analysis with seasonal logic**
1880
+
1881
+ SAR-specific adaptations:
1882
+ - Use `MIN_STD_SAR` (0.5 dB)
1883
+ - VV bands are interleaved (bands 1, 3, 5... are VV; 2, 4, 6... are VH) — extract VV only for analysis
1884
+ - Headlines reference SAR backscatter and dB values
1885
+ - Both VV increase and decrease are flagged (use absolute z-score for status)
1886
+ - Keep flood month detection logic but base it on seasonal z-scores instead of raw threshold
1887
+
1888
+ - [ ] **Step 4: Add helper methods (same pattern)**
1889
+
1890
+ - [ ] **Step 5: Update _compute_stats to return valid_months_total**
1891
+
1892
+ - [ ] **Step 6: Remove old _classify, _compute_trend, _build_chart_data**
1893
+
1894
+ - [ ] **Step 7: Verify import**
1895
+
1896
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.indicators.sar import SarIndicator; print('OK')"`
1897
+ Expected: `OK`
1898
+
1899
+ - [ ] **Step 8: Commit**
1900
+
1901
+ ```bash
1902
+ git add app/indicators/sar.py
1903
+ git commit -m "feat: upgrade SAR indicator with seasonal baselines and z-score analysis"
1904
+ ```
1905
+
1906
+ ---
1907
+
1908
+ ## Task 12: Update Settlement indicator (same pattern as NDVI)
1909
+
1910
+ **Files:**
1911
+ - Modify: `app/indicators/buildup.py`
1912
+
1913
+ - [ ] **Step 1: Update imports**
1914
+
1915
+ Use `BUILDUP_RESOLUTION_M`, `MIN_STD_BUILDUP`, import seasonal/change/confidence modules.
1916
+
1917
+ - [ ] **Step 2: Update submit_batch to use BUILDUP_RESOLUTION_M**
1918
+
1919
+ - [ ] **Step 3: Rewrite harvest() analysis with seasonal logic**
1920
+
1921
+ Settlement-specific adaptations:
1922
+ - Use `MIN_STD_BUILDUP`
1923
+ - Built-up fraction computed from NDBI threshold
1924
+ - Headlines reference settlement extent and percentage change
1925
+ - Fix the narrative contradiction: ensure headline direction matches the actual sign of change (currently says "contraction" but narrative says "growth")
1926
+
1927
+ - [ ] **Step 4: Add helper methods (same pattern)**
1928
+
1929
+ - [ ] **Step 5: Update _compute_stats to return valid_months_total**
1930
+
1931
+ - [ ] **Step 6: Remove old _classify, _compute_trend, _build_chart_data**
1932
+
1933
+ - [ ] **Step 7: Verify import**
1934
+
1935
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.indicators.buildup import BuiltupIndicator; print('OK')"`
1936
+ Expected: `OK`
1937
+
1938
+ - [ ] **Step 8: Commit**
1939
+
1940
+ ```bash
1941
+ git add app/indicators/buildup.py
1942
+ git commit -m "feat: upgrade Settlement indicator with seasonal baselines; fix headline contradiction"
1943
+ ```
1944
+
1945
+ ---
1946
+
1947
+ ## Task 13: Update time series charts with seasonal envelope and anomaly markers
1948
+
1949
+ **Files:**
1950
+ - Modify: `app/outputs/charts.py:32-179`
1951
+
1952
+ - [ ] **Step 1: Add anomaly marker rendering to render_timeseries_chart**
1953
+
1954
+ In `app/outputs/charts.py`, after the current data line plot (line 136), add anomaly highlighting:
1955
+
1956
+ ```python
1957
+ # Anomaly markers — red rings on months with |z| > threshold
1958
+ anomaly_flags = chart_data.get("anomaly_flags")
1959
+ if anomaly_flags and len(anomaly_flags) == len(parsed_dates):
1960
+ anomaly_x = [d for d, f in zip(parsed_dates, anomaly_flags) if f]
1961
+ anomaly_y = [v for v, f in zip(values, anomaly_flags) if f]
1962
+ if anomaly_x:
1963
+ ax.scatter(
1964
+ anomaly_x, anomaly_y,
1965
+ s=120, facecolors="none", edgecolors="#B83A2A",
1966
+ linewidths=2, zorder=4, label="Anomaly",
1967
+ )
1968
+ ```
1969
+
1970
+ - [ ] **Step 2: Add default y_label based on indicator name**
1971
+
1972
+ Update the `render_timeseries_chart` function signature to include better default y-labels. After line 62, add:
1973
+
1974
+ ```python
1975
+ # Default y-axis labels per indicator
1976
+ if not y_label:
1977
+ _default_labels = {
1978
+ "Ndvi": "NDVI (0–1)",
1979
+ "Vegetation (NDVI)": "NDVI (0–1)",
1980
+ "Water": "Water extent (%)",
1981
+ "Water Bodies": "Water extent (%)",
1982
+ "SAR Backscatter": "VV backscatter (dB)",
1983
+ "Settlement Extent": "Built-up area (%)",
1984
+ }
1985
+ y_label = _default_labels.get(indicator_name, "")
1986
+ ```
1987
+
1988
+ - [ ] **Step 3: Verify import**
1989
+
1990
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.outputs.charts import render_timeseries_chart; print('OK')"`
1991
+ Expected: `OK`
1992
+
1993
+ - [ ] **Step 4: Commit**
1994
+
1995
+ ```bash
1996
+ git add app/outputs/charts.py
1997
+ git commit -m "feat: add anomaly markers and default y-labels to time series charts"
1998
+ ```
1999
+
2000
+ ---
2001
+
2002
+ ## Task 14: Add hotspot map rendering
2003
+
2004
+ **Files:**
2005
+ - Modify: `app/outputs/maps.py`
2006
+
2007
+ - [ ] **Step 1: Add render_hotspot_map function**
2008
+
2009
+ Add after `render_raster_map` (after line 301) in `app/outputs/maps.py`:
2010
+
2011
+ ```python
2012
+ def render_hotspot_map(
2013
+ *,
2014
+ true_color_path: str | None,
2015
+ zscore_raster: np.ndarray,
2016
+ hotspot_mask: np.ndarray,
2017
+ extent: list[float],
2018
+ aoi: AOI,
2019
+ status: StatusLevel,
2020
+ output_path: str,
2021
+ label: str = "Z-score",
2022
+ ) -> None:
2023
+ """Render a change hotspot map: significant pixels over true-color base.
2024
+
2025
+ Only pixels where |z-score| > threshold are shown; non-significant
2026
+ pixels are transparent, letting the true-color base show through.
2027
+ """
2028
+ import rasterio
2029
+
2030
+ fig, ax = plt.subplots(figsize=(6, 5), dpi=200, facecolor=SHELL)
2031
+ ax.set_facecolor(SHELL)
2032
+
2033
+ # True-color base layer
2034
+ if true_color_path is not None:
2035
+ with rasterio.open(true_color_path) as src:
2036
+ rgb = src.read([1, 2, 3]).astype(np.float32)
2037
+ tc_extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
2038
+ rgb_max = max(rgb.max(), 1.0)
2039
+ scale = 3000.0 if rgb_max > 255 else 255.0
2040
+ rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
2041
+ ax.imshow(rgb_normalized, extent=tc_extent, aspect="auto", zorder=0)
2042
+
2043
+ # Hotspot overlay — only significant pixels, masked elsewhere
2044
+ masked_z = np.ma.masked_where(~hotspot_mask, zscore_raster)
2045
+ vmax = min(float(np.nanmax(np.abs(zscore_raster))), 5.0)
2046
+ im = ax.imshow(
2047
+ masked_z, extent=extent, cmap="RdBu_r", alpha=0.8,
2048
+ vmin=-vmax, vmax=vmax, aspect="auto", zorder=1,
2049
+ )
2050
+ cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.04, shrink=0.85)
2051
+ cbar.set_label(f"{label} (decline ← → increase)", fontsize=7, color=INK_MUTED)
2052
+ cbar.ax.tick_params(labelsize=6, colors=INK_MUTED)
2053
+
2054
+ # AOI outline
2055
+ ax.set_xlim(extent[0], extent[1])
2056
+ ax.set_ylim(extent[2], extent[3])
2057
+ color = STATUS_COLORS[status]
2058
+ _draw_aoi_rect(ax, aoi, color)
2059
+
2060
+ ax.tick_params(labelsize=6, colors=INK_MUTED)
2061
+ ax.set_xlabel("Longitude", fontsize=7, color=INK_MUTED)
2062
+ ax.set_ylabel("Latitude", fontsize=7, color=INK_MUTED)
2063
+
2064
+ for spine in ax.spines.values():
2065
+ spine.set_color(INK_MUTED)
2066
+ spine.set_linewidth(0.5)
2067
+
2068
+ plt.tight_layout()
2069
+ fig.savefig(output_path, dpi=200, bbox_inches="tight", facecolor=SHELL)
2070
+ plt.close(fig)
2071
+ ```
2072
+
2073
+ - [ ] **Step 2: Verify import**
2074
+
2075
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.outputs.maps import render_hotspot_map; print('OK')"`
2076
+ Expected: `OK`
2077
+
2078
+ - [ ] **Step 3: Commit**
2079
+
2080
+ ```bash
2081
+ git add app/outputs/maps.py
2082
+ git commit -m "feat: add hotspot map renderer for z-score change visualization"
2083
+ ```
2084
+
2085
+ ---
2086
+
2087
+ ## Task 15: Update narrative with z-score language and compound signals
2088
+
2089
+ **Files:**
2090
+ - Modify: `app/outputs/narrative.py`
2091
+ - Create: `tests/test_narrative.py`
2092
+
2093
+ - [ ] **Step 1: Write tests**
2094
+
2095
+ ```python
2096
+ # tests/test_narrative.py
2097
+ """Tests for updated narrative generation."""
2098
+ import pytest
2099
+
2100
+
2101
+ def test_generate_narrative_includes_zscore_context(mock_indicator_result):
2102
+ """Narrative references z-score context when anomaly data is present."""
2103
+ from app.outputs.narrative import generate_narrative
2104
+
2105
+ results = [
2106
+ mock_indicator_result(
2107
+ indicator_id="ndvi",
2108
+ status="amber",
2109
+ headline="Vegetation decline (z=-1.8)",
2110
+ z_score_current=-1.8,
2111
+ anomaly_months=3,
2112
+ ),
2113
+ ]
2114
+ text = generate_narrative(results)
2115
+ assert "concern" in text.lower() or "monitoring" in text.lower()
2116
+
2117
+
2118
+ def test_generate_compound_signals_text():
2119
+ """Compound signal text generated from CompoundSignal objects."""
2120
+ from app.outputs.narrative import generate_compound_signals_text
2121
+ from app.models import CompoundSignal
2122
+
2123
+ signals = [
2124
+ CompoundSignal(
2125
+ name="land_conversion",
2126
+ triggered=True,
2127
+ confidence="strong",
2128
+ description="NDVI decline overlaps with settlement growth (45% overlap, 120 ha).",
2129
+ indicators=["ndvi", "buildup"],
2130
+ overlap_pct=45.0,
2131
+ affected_ha=120.0,
2132
+ ),
2133
+ CompoundSignal(
2134
+ name="flood_event",
2135
+ triggered=False,
2136
+ confidence="weak",
2137
+ description="No flood signal detected.",
2138
+ indicators=["sar", "water"],
2139
+ ),
2140
+ ]
2141
+ text = generate_compound_signals_text(signals)
2142
+ assert "land_conversion" in text.lower() or "NDVI decline" in text
2143
+ assert "flood" not in text.lower() or "not detected" in text.lower()
2144
+
2145
+
2146
+ def test_no_compound_signals_text():
2147
+ """When no signals triggered, text says so explicitly."""
2148
+ from app.outputs.narrative import generate_compound_signals_text
2149
+
2150
+ text = generate_compound_signals_text([])
2151
+ assert "no compound" in text.lower()
2152
+ ```
2153
+
2154
+ - [ ] **Step 2: Run tests to verify they fail**
2155
+
2156
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_narrative.py -v`
2157
+ Expected: FAIL — `generate_compound_signals_text` not found.
2158
+
2159
+ - [ ] **Step 3: Add generate_compound_signals_text and update narrative**
2160
+
2161
+ Add to `app/outputs/narrative.py` after the existing `generate_narrative` function:
2162
+
2163
+ ```python
2164
+ def generate_compound_signals_text(signals: list) -> str:
2165
+ """Generate text for compound signal section.
2166
+
2167
+ Parameters
2168
+ ----------
2169
+ signals : list of CompoundSignal objects.
2170
+
2171
+ Returns text describing triggered compound signals.
2172
+ """
2173
+ if not signals:
2174
+ return "No compound signals detected across the indicator set."
2175
+
2176
+ triggered = [s for s in signals if s.triggered]
2177
+ if not triggered:
2178
+ return "No compound signals detected across the indicator set."
2179
+
2180
+ parts = []
2181
+ for s in triggered:
2182
+ parts.append(f"**{s.name.replace('_', ' ').title()}** ({s.confidence}): {s.description}")
2183
+
2184
+ return " ".join(parts)
2185
+ ```
2186
+
2187
+ - [ ] **Step 4: Run tests to verify they pass**
2188
+
2189
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/test_narrative.py -v`
2190
+ Expected: All 3 tests PASS.
2191
+
2192
+ - [ ] **Step 5: Commit**
2193
+
2194
+ ```bash
2195
+ git add app/outputs/narrative.py tests/test_narrative.py
2196
+ git commit -m "feat: add compound signal narrative and z-score language to narratives"
2197
+ ```
2198
+
2199
+ ---
2200
+
2201
+ ## Task 16: Update PDF report — compound signals section and anomaly column
2202
+
2203
+ **Files:**
2204
+ - Modify: `app/outputs/report.py`
2205
+
2206
+ - [ ] **Step 1: Add compound_signals parameter to generate_pdf_report**
2207
+
2208
+ In `app/outputs/report.py`, update the function signature at line 259 to add:
2209
+
2210
+ ```python
2211
+ def generate_pdf_report(
2212
+ *,
2213
+ aoi: AOI,
2214
+ time_range: TimeRange,
2215
+ results: Sequence[IndicatorResult],
2216
+ output_path: str,
2217
+ summary_map_path: str = "",
2218
+ indicator_map_paths: dict[str, str] | None = None,
2219
+ indicator_hotspot_paths: dict[str, str] | None = None,
2220
+ overview_score: dict | None = None,
2221
+ overview_map_path: str = "",
2222
+ compound_signals: list | None = None,
2223
+ ) -> None:
2224
+ ```
2225
+
2226
+ - [ ] **Step 2: Add "Anomaly Months" column to summary table**
2227
+
2228
+ Replace the summary table header (lines 423-429) with:
2229
+
2230
+ ```python
2231
+ summary_header = [
2232
+ Paragraph("<b>Indicator</b>", styles["body"]),
2233
+ Paragraph("<b>Status</b>", styles["body"]),
2234
+ Paragraph("<b>Trend</b>", styles["body"]),
2235
+ Paragraph("<b>Confidence</b>", styles["body"]),
2236
+ Paragraph("<b>Anomalies</b>", styles["body"]),
2237
+ Paragraph("<b>Headline</b>", styles["body"]),
2238
+ ]
2239
+ ```
2240
+
2241
+ Update the row building (lines 443-449) to include anomaly months:
2242
+
2243
+ ```python
2244
+ summary_rows.append([
2245
+ Paragraph(label, styles["body_muted"]),
2246
+ status_cell,
2247
+ Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
2248
+ Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
2249
+ Paragraph(f"{result.anomaly_months}/12", styles["body_muted"]),
2250
+ Paragraph(result.headline[:70], styles["body_muted"]),
2251
+ ])
2252
+ ```
2253
+
2254
+ Update column widths (lines 454-460) to accommodate the new column:
2255
+
2256
+ ```python
2257
+ colWidths=[
2258
+ ov_col_w * 0.14,
2259
+ ov_col_w * 0.09,
2260
+ ov_col_w * 0.11,
2261
+ ov_col_w * 0.11,
2262
+ ov_col_w * 0.09,
2263
+ ov_col_w * 0.46,
2264
+ ],
2265
+ ```
2266
+
2267
+ - [ ] **Step 3: Add compound signals section after summary table**
2268
+
2269
+ After the summary table (after line 480), add:
2270
+
2271
+ ```python
2272
+ # Compound signals section
2273
+ if compound_signals:
2274
+ from app.outputs.narrative import generate_compound_signals_text
2275
+ triggered = [s for s in compound_signals if s.triggered]
2276
+ if triggered:
2277
+ story.append(Spacer(1, 4 * mm))
2278
+ story.append(Paragraph("Compound Signals", styles["section_heading"]))
2279
+ story.append(Spacer(1, 2 * mm))
2280
+ for s in triggered:
2281
+ signal_text = (
2282
+ f"<b>{s.name.replace('_', ' ').title()}</b> "
2283
+ f"({s.confidence}): {s.description}"
2284
+ )
2285
+ story.append(Paragraph(signal_text, styles["body"]))
2286
+ story.append(Spacer(1, 2 * mm))
2287
+ else:
2288
+ story.append(Spacer(1, 2 * mm))
2289
+ story.append(Paragraph(
2290
+ "No compound signals detected across the indicator set.",
2291
+ styles["body_muted"],
2292
+ ))
2293
+ ```
2294
+
2295
+ - [ ] **Step 4: Add hotspot map to indicator blocks**
2296
+
2297
+ In the `_indicator_block` function, add a `hotspot_path` parameter and render it alongside the existing map:
2298
+
2299
+ Update the function signature at line 161:
2300
+
2301
+ ```python
2302
+ def _indicator_block(
2303
+ result: IndicatorResult,
2304
+ styles: dict,
2305
+ map_path: str = "",
2306
+ chart_path: str = "",
2307
+ hotspot_path: str = "",
2308
+ ) -> list:
2309
+ ```
2310
+
2311
+ After the existing map+chart side-by-side block (after line 208), add hotspot map rendering:
2312
+
2313
+ ```python
2314
+ # Hotspot change map (if available)
2315
+ hotspot_exists = hotspot_path and os.path.exists(hotspot_path)
2316
+ if hotspot_exists:
2317
+ from reportlab.platypus import Image as RLImage
2318
+ hotspot_img = RLImage(hotspot_path, width=14 * cm, height=5.5 * cm)
2319
+ hotspot_img.hAlign = "CENTER"
2320
+ elements.append(hotspot_img)
2321
+ elements.append(Spacer(1, 2 * mm))
2322
+ ```
2323
+
2324
+ - [ ] **Step 5: Update _indicator_block caller to pass hotspot_path**
2325
+
2326
+ In the indicator deep-dive loop (line 488-499), update the call:
2327
+
2328
+ ```python
2329
+ for result in results:
2330
+ indicator_label = _indicator_label(result.indicator_id)
2331
+ map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
2332
+ hotspot_path = (indicator_hotspot_paths or {}).get(result.indicator_id, "")
2333
+
2334
+ chart_path = os.path.join(output_dir, f"{result.indicator_id}_chart.png")
2335
+ if not os.path.exists(chart_path):
2336
+ chart_path = ""
2337
+
2338
+ block = [Paragraph(indicator_label, styles["section_heading"])]
2339
+ block += _indicator_block(result, styles, map_path=map_path, chart_path=chart_path, hotspot_path=hotspot_path)
2340
+ story.append(KeepTogether(block))
2341
+ ```
2342
+
2343
+ - [ ] **Step 6: Add confidence breakdown to Technical Annex (new subsection)**
2344
+
2345
+ After the methodology subsection (after line 517), add:
2346
+
2347
+ ```python
2348
+ # Confidence breakdown table
2349
+ story.append(Paragraph("Confidence Breakdown", styles["section_heading"]))
2350
+ story.append(Spacer(1, 2 * mm))
2351
+
2352
+ conf_header = [
2353
+ Paragraph("<b>Indicator</b>", styles["body"]),
2354
+ Paragraph("<b>Temporal</b>", styles["body"]),
2355
+ Paragraph("<b>Obs. Density</b>", styles["body"]),
2356
+ Paragraph("<b>Baseline Depth</b>", styles["body"]),
2357
+ Paragraph("<b>Spatial Compl.</b>", styles["body"]),
2358
+ Paragraph("<b>Overall</b>", styles["body"]),
2359
+ ]
2360
+ conf_rows = [conf_header]
2361
+ for result in results:
2362
+ f = result.confidence_factors
2363
+ if f:
2364
+ conf_rows.append([
2365
+ Paragraph(_indicator_label(result.indicator_id), styles["body_muted"]),
2366
+ Paragraph(f"{f.get('temporal', 0):.2f}", styles["body_muted"]),
2367
+ Paragraph(f"{f.get('observation_density', 0):.2f}", styles["body_muted"]),
2368
+ Paragraph(f"{f.get('baseline_depth', 0):.2f}", styles["body_muted"]),
2369
+ Paragraph(f"{f.get('spatial_completeness', 0):.2f}", styles["body_muted"]),
2370
+ Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
2371
+ ])
2372
+
2373
+ if len(conf_rows) > 1:
2374
+ conf_col_w = PAGE_W - 2 * MARGIN
2375
+ conf_table = Table(
2376
+ conf_rows,
2377
+ colWidths=[conf_col_w * 0.18] + [conf_col_w * 0.164] * 5,
2378
+ )
2379
+ conf_table.setStyle(TableStyle([
2380
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
2381
+ ("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
2382
+ ("TOPPADDING", (0, 0), (-1, -1), 3),
2383
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
2384
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
2385
+ ]))
2386
+ story.append(conf_table)
2387
+ story.append(Spacer(1, 4 * mm))
2388
+ ```
2389
+
2390
+ - [ ] **Step 6: Verify import**
2391
+
2392
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.outputs.report import generate_pdf_report; print('OK')"`
2393
+ Expected: `OK`
2394
+
2395
+ - [ ] **Step 7: Commit**
2396
+
2397
+ ```bash
2398
+ git add app/outputs/report.py
2399
+ git commit -m "feat: add compound signals section, anomaly column, confidence breakdown to PDF report"
2400
+ ```
2401
+
2402
+ ---
2403
+
2404
+ ## Task 17: Update worker pipeline to generate hotspot maps and compound signals
2405
+
2406
+ **Files:**
2407
+ - Modify: `app/worker.py:170-291`
2408
+
2409
+ - [ ] **Step 1: Add hotspot map generation after indicator maps**
2410
+
2411
+ In `app/worker.py`, after the map generation loop (after line 225), add hotspot map generation:
2412
+
2413
+ ```python
2414
+ # Generate hotspot maps for indicators with z-score data
2415
+ from app.outputs.maps import render_hotspot_map
2416
+ indicator_hotspot_paths = {}
2417
+ for result in job.results:
2418
+ indicator_obj = registry.get(result.indicator_id)
2419
+ zscore_raster = getattr(indicator_obj, '_zscore_raster', None)
2420
+ hotspot_mask = getattr(indicator_obj, '_hotspot_mask', None)
2421
+ true_color_path = getattr(indicator_obj, '_true_color_path', None)
2422
+
2423
+ if zscore_raster is not None and hotspot_mask is not None:
2424
+ hotspot_path = os.path.join(results_dir, f"{result.indicator_id}_hotspot.png")
2425
+
2426
+ # Get extent from the indicator raster
2427
+ raster_path = getattr(indicator_obj, '_indicator_raster_path', None)
2428
+ if raster_path:
2429
+ import rasterio
2430
+ with rasterio.open(raster_path) as src:
2431
+ extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
2432
+ else:
2433
+ b = job.request.aoi.bbox
2434
+ extent = [b[0], b[2], b[1], b[3]]
2435
+
2436
+ render_hotspot_map(
2437
+ true_color_path=true_color_path,
2438
+ zscore_raster=zscore_raster,
2439
+ hotspot_mask=hotspot_mask,
2440
+ extent=extent,
2441
+ aoi=job.request.aoi,
2442
+ status=result.status,
2443
+ output_path=hotspot_path,
2444
+ label=result.indicator_id.upper(),
2445
+ )
2446
+ indicator_hotspot_paths[result.indicator_id] = hotspot_path
2447
+ output_files.append(hotspot_path)
2448
+ ```
2449
+
2450
+ - [ ] **Step 2: Add compound signal detection**
2451
+
2452
+ After the hotspot map generation, add compound signal detection:
2453
+
2454
+ ```python
2455
+ # Cross-indicator compound signal detection
2456
+ from app.analysis.compound import detect_compound_signals
2457
+ import numpy as np
2458
+
2459
+ zscore_rasters = {}
2460
+ for result in job.results:
2461
+ indicator_obj = registry.get(result.indicator_id)
2462
+ z = getattr(indicator_obj, '_zscore_raster', None)
2463
+ if z is not None:
2464
+ zscore_rasters[result.indicator_id] = z
2465
+
2466
+ compound_signals = []
2467
+ if len(zscore_rasters) >= 2:
2468
+ # Resample all to common shape (use the smallest raster dimensions)
2469
+ shapes = [z.shape for z in zscore_rasters.values()]
2470
+ target_shape = min(shapes, key=lambda s: s[0] * s[1])
2471
+
2472
+ resampled = {}
2473
+ for ind_id, z in zscore_rasters.items():
2474
+ if z.shape != target_shape:
2475
+ from scipy.ndimage import zoom
2476
+ factors = (target_shape[0] / z.shape[0], target_shape[1] / z.shape[1])
2477
+ resampled[ind_id] = zoom(z, factors, order=0) # nearest-neighbor
2478
+ else:
2479
+ resampled[ind_id] = z
2480
+
2481
+ # Estimate pixel area in hectares
2482
+ b = job.request.aoi.bbox
2483
+ pixel_area_ha = (job.request.aoi.area_km2 * 100) / (target_shape[0] * target_shape[1])
2484
+
2485
+ compound_signals = detect_compound_signals(
2486
+ zscore_rasters=resampled,
2487
+ pixel_area_ha=pixel_area_ha,
2488
+ threshold=2.0,
2489
+ )
2490
+
2491
+ # Save compound signals as JSON
2492
+ if compound_signals:
2493
+ signals_path = os.path.join(results_dir, "compound_signals.json")
2494
+ with open(signals_path, "w") as f:
2495
+ json.dump([s.model_dump() for s in compound_signals], f, indent=2)
2496
+ output_files.append(signals_path)
2497
+ ```
2498
+
2499
+ - [ ] **Step 3: Pass new data to PDF report generation**
2500
+
2501
+ Update the `generate_pdf_report` call (lines 277-286) to include the new parameters:
2502
+
2503
+ ```python
2504
+ generate_pdf_report(
2505
+ aoi=job.request.aoi,
2506
+ time_range=job.request.time_range,
2507
+ results=job.results,
2508
+ output_path=report_path,
2509
+ summary_map_path=summary_map_path,
2510
+ indicator_map_paths=indicator_map_paths,
2511
+ indicator_hotspot_paths=indicator_hotspot_paths,
2512
+ overview_score=overview_score,
2513
+ overview_map_path=overview_map_path if true_color_path else "",
2514
+ compound_signals=compound_signals,
2515
+ )
2516
+ ```
2517
+
2518
+ - [ ] **Step 4: Verify import**
2519
+
2520
+ Run: `cd /Users/kmini/github/aperture && python -c "from app.worker import process_job; print('OK')"`
2521
+ Expected: `OK`
2522
+
2523
+ - [ ] **Step 5: Commit**
2524
+
2525
+ ```bash
2526
+ git add app/worker.py
2527
+ git commit -m "feat: generate hotspot maps, detect compound signals, pass to PDF report"
2528
+ ```
2529
+
2530
+ ---
2531
+
2532
+ ## Task 18: Run full test suite and fix any issues
2533
+
2534
+ - [ ] **Step 1: Run all tests**
2535
+
2536
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/ -v`
2537
+ Expected: All tests PASS.
2538
+
2539
+ - [ ] **Step 2: Verify all modules import cleanly**
2540
+
2541
+ Run: `cd /Users/kmini/github/aperture && python -c "
2542
+ from app.models import IndicatorResult, CompoundSignal
2543
+ from app.config import NDVI_RESOLUTION_M, ZSCORE_THRESHOLD
2544
+ from app.analysis.seasonal import compute_seasonal_stats_aoi
2545
+ from app.analysis.change import compute_zscore_raster, detect_hotspots, cluster_hotspots
2546
+ from app.analysis.compound import detect_compound_signals
2547
+ from app.analysis.confidence import compute_confidence
2548
+ from app.indicators.ndvi import NdviIndicator
2549
+ from app.indicators.water import WaterIndicator
2550
+ from app.indicators.sar import SarIndicator
2551
+ from app.indicators.buildup import BuiltupIndicator
2552
+ from app.outputs.charts import render_timeseries_chart
2553
+ from app.outputs.maps import render_hotspot_map
2554
+ from app.outputs.narrative import generate_compound_signals_text
2555
+ from app.outputs.report import generate_pdf_report
2556
+ print('All imports OK')
2557
+ "`
2558
+ Expected: `All imports OK`
2559
+
2560
+ - [ ] **Step 3: Fix any failures found in steps 1-2**
2561
+
2562
+ - [ ] **Step 4: Commit any fixes**
2563
+
2564
+ ```bash
2565
+ git add -A
2566
+ git commit -m "fix: resolve import/test issues from EO product overhaul"
2567
+ ```
2568
+
2569
+ ---
2570
+
2571
+ ## Task 19: Review checkpoint
2572
+
2573
+ This is the review gate before considering the work complete.
2574
+
2575
+ - [ ] **Step 1: Verify spec coverage**
2576
+
2577
+ Cross-reference each section of the spec (`docs/superpowers/specs/2026-04-06-eo-product-overhaul-design.md`) against the implemented code:
2578
+
2579
+ | Spec Section | Implemented In |
2580
+ |---|---|
2581
+ | 1. Resolution upgrade | `openeo_client.py`, `config.py` |
2582
+ | 2. Seasonal baselines | `analysis/seasonal.py`, indicator harvest methods |
2583
+ | 3. Pixel-level change | `analysis/change.py`, indicator harvest methods |
2584
+ | 4. Cross-indicator correlation | `analysis/compound.py`, `worker.py` |
2585
+ | 5. Confidence model | `analysis/confidence.py`, indicator harvest methods |
2586
+ | 6. Report improvements | `charts.py`, `maps.py`, `narrative.py`, `report.py` |
2587
+ | 7. Status classification | Indicator `_classify_zscore` methods |
2588
+ | 8. Data model changes | `models.py` |
2589
+
2590
+ - [ ] **Step 2: Run tests one final time**
2591
+
2592
+ Run: `cd /Users/kmini/github/aperture && python -m pytest tests/ -v --tb=short`
2593
+ Expected: All tests PASS.