KSvend Claude Happy commited on
Commit
1b9ac83
·
1 Parent(s): e0a0ce4

docs: add Phase C spec and implementation plan — SAR, built-up, visual overview

Browse files

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

docs/superpowers/plans/2026-03-31-phase-c-sar-buildup-overview.md ADDED
@@ -0,0 +1,1971 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase C — SAR Backscatter, Built-up Extent, Visual Overview
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:** Add SAR backscatter and built-up settlement indicators via openEO, plus a visual overview post-processing step that produces a composite score and true-color satellite snapshot.
6
+
7
+ **Architecture:** Two new `BaseIndicator` subclasses follow the Phase B openEO pattern (graph builder → GeoTIFF download → zonal stats → classify). The visual overview is a post-processing step in the worker that synthesizes indicator results into a weighted composite score and renders a standalone true-color map. Report PDF gains an overview page with summary table.
8
+
9
+ **Tech Stack:** openEO (CDSE), rasterio, numpy, matplotlib/cartopy, reportlab — all existing dependencies.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-03-31-phase-c-sar-buildup-overview-design.md`
12
+
13
+ ---
14
+
15
+ ### Task 1: Add SAR and built-up graph builders to openEO client
16
+
17
+ **Files:**
18
+ - Modify: `app/openeo_client.py`
19
+ - Modify: `tests/test_openeo_client.py`
20
+
21
+ - [ ] **Step 1: Write failing tests for `build_sar_graph` and `build_buildup_graph`**
22
+
23
+ Append to `tests/test_openeo_client.py`:
24
+
25
+ ```python
26
+ def test_build_sar_graph():
27
+ """build_sar_graph() loads Sentinel-1 GRD with VV and VH bands."""
28
+ mock_conn = MagicMock()
29
+ mock_cube = MagicMock()
30
+ mock_conn.load_collection.return_value = mock_cube
31
+
32
+ from app.openeo_client import build_sar_graph
33
+
34
+ bbox = {"west": 32.45, "south": 15.65, "east": 32.65, "north": 15.8}
35
+ result = build_sar_graph(
36
+ conn=mock_conn,
37
+ bbox=bbox,
38
+ temporal_extent=["2025-03-01", "2026-03-01"],
39
+ resolution_m=100,
40
+ )
41
+
42
+ mock_conn.load_collection.assert_called_once()
43
+ call_kwargs = mock_conn.load_collection.call_args
44
+ assert call_kwargs[1]["collection_id"] == "SENTINEL1_GRD"
45
+ assert "VV" in call_kwargs[1]["bands"]
46
+ assert "VH" in call_kwargs[1]["bands"]
47
+
48
+
49
+ def test_build_buildup_graph():
50
+ """build_buildup_graph() loads Sentinel-2 with SWIR, NIR, Red, and SCL bands."""
51
+ mock_conn = MagicMock()
52
+ mock_cube = MagicMock()
53
+ mock_conn.load_collection.return_value = mock_cube
54
+
55
+ from app.openeo_client import build_buildup_graph
56
+
57
+ bbox = {"west": 32.45, "south": 15.65, "east": 32.65, "north": 15.8}
58
+ result = build_buildup_graph(
59
+ conn=mock_conn,
60
+ bbox=bbox,
61
+ temporal_extent=["2025-03-01", "2026-03-01"],
62
+ resolution_m=100,
63
+ )
64
+
65
+ mock_conn.load_collection.assert_called_once()
66
+ call_kwargs = mock_conn.load_collection.call_args
67
+ assert call_kwargs[1]["collection_id"] == "SENTINEL2_L2A"
68
+ assert "B04" in call_kwargs[1]["bands"]
69
+ assert "B08" in call_kwargs[1]["bands"]
70
+ assert "B11" in call_kwargs[1]["bands"]
71
+ assert "SCL" in call_kwargs[1]["bands"]
72
+ ```
73
+
74
+ - [ ] **Step 2: Run tests to verify they fail**
75
+
76
+ Run: `pytest tests/test_openeo_client.py::test_build_sar_graph tests/test_openeo_client.py::test_build_buildup_graph -v`
77
+ Expected: FAIL with `ImportError` — functions don't exist yet.
78
+
79
+ - [ ] **Step 3: Implement `build_sar_graph` in `app/openeo_client.py`**
80
+
81
+ Add after the `build_lst_graph` function (after line 200):
82
+
83
+ ```python
84
+ def build_sar_graph(
85
+ *,
86
+ conn: openeo.Connection,
87
+ bbox: dict[str, float],
88
+ temporal_extent: list[str],
89
+ resolution_m: int = 100,
90
+ ) -> openeo.DataCube:
91
+ """Build an openEO process graph for Sentinel-1 GRD SAR backscatter.
92
+
93
+ Loads VV and VH polarizations, filters ascending orbits, converts
94
+ to dB scale, and aggregates to monthly median composites.
95
+
96
+ Returns an openEO DataCube (not yet executed).
97
+ """
98
+ cube = conn.load_collection(
99
+ collection_id="SENTINEL1_GRD",
100
+ spatial_extent=bbox,
101
+ temporal_extent=temporal_extent,
102
+ bands=["VV", "VH"],
103
+ )
104
+
105
+ # Filter ascending orbits for radiometric consistency
106
+ cube = cube.filter_labels(
107
+ condition=lambda x: x == "ASCENDING",
108
+ dimension="sar:orbit_state",
109
+ )
110
+
111
+ # Convert linear backscatter to dB: 10 * log10(linear)
112
+ cube = cube.apply(lambda x: 10.0 * x.log(base=10))
113
+
114
+ # Monthly median composite
115
+ monthly = cube.aggregate_temporal_period("month", reducer="median")
116
+
117
+ # Resample to target resolution
118
+ if resolution_m > 10:
119
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320)
120
+
121
+ return monthly
122
+ ```
123
+
124
+ - [ ] **Step 4: Implement `build_buildup_graph` in `app/openeo_client.py`**
125
+
126
+ Add after `build_sar_graph`:
127
+
128
+ ```python
129
+ def build_buildup_graph(
130
+ *,
131
+ conn: openeo.Connection,
132
+ bbox: dict[str, float],
133
+ temporal_extent: list[str],
134
+ resolution_m: int = 100,
135
+ ) -> openeo.DataCube:
136
+ """Build an openEO process graph for monthly NDBI built-up index composites.
137
+
138
+ NDBI = (B11 - B08) / (B11 + B08). Higher values indicate impervious surfaces.
139
+ Cloud-masked via SCL band, aggregated to monthly medians.
140
+
141
+ The binary built-up mask (NDBI > 0 AND NDVI < 0.2) is computed in the
142
+ indicator's process() method, not here — keeping the graph builder simple.
143
+ """
144
+ cube = conn.load_collection(
145
+ collection_id="SENTINEL2_L2A",
146
+ spatial_extent=bbox,
147
+ temporal_extent=temporal_extent,
148
+ bands=["B04", "B08", "B11", "SCL"],
149
+ )
150
+
151
+ # Cloud mask: keep only vegetation, bare soil, water (SCL classes 4,5,6)
152
+ scl = cube.band("SCL")
153
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
154
+ cube = cube.mask(~cloud_mask)
155
+
156
+ # NDBI = (SWIR - NIR) / (SWIR + NIR)
157
+ b11 = cube.band("B11")
158
+ b08 = cube.band("B08")
159
+ ndbi = (b11 - b08) / (b11 + b08)
160
+
161
+ # Monthly median composite
162
+ monthly = ndbi.aggregate_temporal_period("month", reducer="median")
163
+
164
+ # Resample to target resolution
165
+ if resolution_m > 10:
166
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320)
167
+
168
+ return monthly
169
+ ```
170
+
171
+ - [ ] **Step 5: Run tests to verify they pass**
172
+
173
+ Run: `pytest tests/test_openeo_client.py -v`
174
+ Expected: ALL PASS (6 tests total — 4 existing + 2 new).
175
+
176
+ - [ ] **Step 6: Commit**
177
+
178
+ ```bash
179
+ git add app/openeo_client.py tests/test_openeo_client.py
180
+ git commit -m "feat: add SAR and built-up graph builders to openEO client"
181
+ ```
182
+
183
+ ---
184
+
185
+ ### Task 2: Implement SAR backscatter indicator
186
+
187
+ **Files:**
188
+ - Create: `app/indicators/sar.py`
189
+ - Create: `tests/test_indicator_sar.py`
190
+
191
+ - [ ] **Step 1: Write failing tests for SAR indicator**
192
+
193
+ Create `tests/test_indicator_sar.py`:
194
+
195
+ ```python
196
+ """Tests for app.indicators.sar — SAR backscatter via openEO."""
197
+ from __future__ import annotations
198
+
199
+ import os
200
+ import tempfile
201
+ from unittest.mock import MagicMock, patch
202
+ from datetime import date
203
+
204
+ import numpy as np
205
+ import rasterio
206
+ from rasterio.transform import from_bounds
207
+ import pytest
208
+
209
+ from app.models import AOI, TimeRange, StatusLevel, TrendDirection, ConfidenceLevel
210
+
211
+ BBOX = [32.45, 15.65, 32.65, 15.8]
212
+
213
+
214
+ @pytest.fixture
215
+ def test_aoi():
216
+ return AOI(name="Test", bbox=BBOX)
217
+
218
+
219
+ @pytest.fixture
220
+ def test_time_range():
221
+ return TimeRange(start=date(2025, 3, 1), end=date(2026, 3, 1))
222
+
223
+
224
+ def _mock_sar_tif(path: str, n_months: int = 12):
225
+ """Create synthetic SAR backscatter GeoTIFF in dB scale.
226
+
227
+ Interleaved bands: VV_m1, VH_m1, VV_m2, VH_m2, ...
228
+ Total bands = n_months * 2.
229
+ """
230
+ rng = np.random.default_rng(50)
231
+ n_bands = n_months * 2
232
+ data = np.zeros((n_bands, 10, 10), dtype=np.float32)
233
+ for m in range(n_months):
234
+ # VV: typical range -15 to -5 dB
235
+ data[m * 2] = rng.uniform(-12, -6, (10, 10))
236
+ # VH: typically 5-8 dB lower than VV
237
+ data[m * 2 + 1] = data[m * 2] - rng.uniform(5, 8, (10, 10))
238
+ with rasterio.open(
239
+ path, "w", driver="GTiff", height=10, width=10, count=n_bands,
240
+ dtype="float32", crs="EPSG:4326",
241
+ transform=from_bounds(*BBOX, 10, 10), nodata=-9999.0,
242
+ ) as dst:
243
+ for i in range(n_bands):
244
+ dst.write(data[i], i + 1)
245
+
246
+
247
+ def _mock_rgb_tif(path: str):
248
+ rng = np.random.default_rng(43)
249
+ data = rng.integers(500, 1500, (3, 10, 10), dtype=np.uint16)
250
+ with rasterio.open(
251
+ path, "w", driver="GTiff", height=10, width=10, count=3,
252
+ dtype="uint16", crs="EPSG:4326",
253
+ transform=from_bounds(*BBOX, 10, 10), nodata=0,
254
+ ) as dst:
255
+ for i in range(3):
256
+ dst.write(data[i], i + 1)
257
+
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_sar_process_returns_result(test_aoi, test_time_range):
261
+ """SarIndicator.process() returns a valid IndicatorResult."""
262
+ from app.indicators.sar import SarIndicator
263
+
264
+ indicator = SarIndicator()
265
+
266
+ with tempfile.TemporaryDirectory() as tmpdir:
267
+ sar_path = os.path.join(tmpdir, "sar.tif")
268
+ rgb_path = os.path.join(tmpdir, "rgb.tif")
269
+ _mock_sar_tif(sar_path)
270
+ _mock_rgb_tif(rgb_path)
271
+
272
+ mock_cube = MagicMock()
273
+
274
+ def fake_download(path, **kwargs):
275
+ import shutil
276
+ if "sar" in path:
277
+ shutil.copy(sar_path, path)
278
+ else:
279
+ shutil.copy(rgb_path, path)
280
+
281
+ mock_cube.download = MagicMock(side_effect=fake_download)
282
+
283
+ with patch("app.indicators.sar.get_connection"), \
284
+ patch("app.indicators.sar.build_sar_graph", return_value=mock_cube), \
285
+ patch("app.indicators.sar.build_true_color_graph", return_value=mock_cube):
286
+ result = await indicator.process(test_aoi, test_time_range)
287
+
288
+ assert result.indicator_id == "sar"
289
+ assert result.status in (StatusLevel.GREEN, StatusLevel.AMBER, StatusLevel.RED)
290
+ assert result.trend in (TrendDirection.IMPROVING, TrendDirection.STABLE, TrendDirection.DETERIORATING)
291
+ assert result.data_source == "satellite"
292
+ assert "SAR" in result.methodology or "backscatter" in result.methodology.lower()
293
+ assert len(result.chart_data.get("dates", [])) > 0
294
+
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_sar_falls_back_on_failure(test_aoi, test_time_range):
298
+ """SarIndicator falls back gracefully when openEO fails."""
299
+ from app.indicators.sar import SarIndicator
300
+
301
+ indicator = SarIndicator()
302
+
303
+ with patch("app.indicators.sar.get_connection", side_effect=Exception("CDSE down")):
304
+ result = await indicator.process(test_aoi, test_time_range)
305
+
306
+ assert result.indicator_id == "sar"
307
+ assert result.confidence == ConfidenceLevel.LOW
308
+ assert "Insufficient" in result.headline or "placeholder" in result.data_source
309
+
310
+
311
+ def test_sar_compute_stats():
312
+ """_compute_stats() extracts VV monthly means from interleaved SAR raster."""
313
+ from app.indicators.sar import SarIndicator
314
+
315
+ with tempfile.TemporaryDirectory() as tmpdir:
316
+ path = os.path.join(tmpdir, "sar.tif")
317
+ _mock_sar_tif(path, n_months=12)
318
+ stats = SarIndicator._compute_stats(path)
319
+
320
+ assert "monthly_vv_means" in stats
321
+ assert len(stats["monthly_vv_means"]) == 12
322
+ assert "overall_vv_mean" in stats
323
+ # VV should be negative dB values
324
+ assert stats["overall_vv_mean"] < 0
325
+ assert "valid_months" in stats
326
+ assert stats["valid_months"] == 12
327
+
328
+
329
+ def test_sar_classify_change():
330
+ """_classify() maps change area percentage to correct status."""
331
+ from app.indicators.sar import SarIndicator
332
+
333
+ assert SarIndicator._classify(change_pct=3.0, flood_months=0) == StatusLevel.GREEN
334
+ assert SarIndicator._classify(change_pct=10.0, flood_months=0) == StatusLevel.AMBER
335
+ assert SarIndicator._classify(change_pct=10.0, flood_months=2) == StatusLevel.AMBER
336
+ assert SarIndicator._classify(change_pct=20.0, flood_months=0) == StatusLevel.RED
337
+ assert SarIndicator._classify(change_pct=3.0, flood_months=3) == StatusLevel.RED
338
+ ```
339
+
340
+ - [ ] **Step 2: Run tests to verify they fail**
341
+
342
+ Run: `pytest tests/test_indicator_sar.py -v`
343
+ Expected: FAIL with `ModuleNotFoundError` — module doesn't exist yet.
344
+
345
+ - [ ] **Step 3: Implement SAR indicator**
346
+
347
+ Create `app/indicators/sar.py`:
348
+
349
+ ```python
350
+ """SAR Backscatter Indicator — Sentinel-1 GRD via CDSE openEO.
351
+
352
+ Computes monthly VV/VH median composites, detects ground surface change
353
+ and potential flood events against a baseline period.
354
+ """
355
+ from __future__ import annotations
356
+
357
+ import logging
358
+ import os
359
+ import tempfile
360
+ from datetime import date
361
+ from typing import Any
362
+
363
+ import numpy as np
364
+ import rasterio
365
+
366
+ from app.config import RESOLUTION_M
367
+ from app.indicators.base import BaseIndicator, SpatialData
368
+ from app.models import (
369
+ AOI,
370
+ TimeRange,
371
+ IndicatorResult,
372
+ StatusLevel,
373
+ TrendDirection,
374
+ ConfidenceLevel,
375
+ )
376
+ from app.openeo_client import get_connection, build_sar_graph, build_true_color_graph, _bbox_dict
377
+
378
+ logger = logging.getLogger(__name__)
379
+
380
+ BASELINE_YEARS = 3
381
+ CHANGE_THRESHOLD_DB = 3.0 # dB change considered significant
382
+ FLOOD_SIGMA = 2.0 # Standard deviations below baseline mean
383
+
384
+
385
+ class SarIndicator(BaseIndicator):
386
+ id = "sar"
387
+ name = "SAR Backscatter"
388
+ category = "D10"
389
+ question = "Is ground surface changing?"
390
+ estimated_minutes = 10
391
+
392
+ _true_color_path: str | None = None
393
+
394
+ async def process(
395
+ self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
396
+ ) -> IndicatorResult:
397
+ try:
398
+ return await self._process_openeo(aoi, time_range, season_months)
399
+ except Exception as exc:
400
+ logger.warning("SAR openEO processing failed: %s", exc)
401
+ return self._fallback(aoi, time_range)
402
+
403
+ async def _process_openeo(
404
+ self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
405
+ ) -> IndicatorResult:
406
+ import asyncio
407
+
408
+ conn = get_connection()
409
+ bbox = _bbox_dict(aoi.bbox)
410
+
411
+ current_start = time_range.start.isoformat()
412
+ current_end = time_range.end.isoformat()
413
+ baseline_start = date(
414
+ time_range.start.year - BASELINE_YEARS,
415
+ time_range.start.month,
416
+ time_range.start.day,
417
+ ).isoformat()
418
+ baseline_end = time_range.start.isoformat()
419
+
420
+ results_dir = tempfile.mkdtemp(prefix="aperture_sar_")
421
+
422
+ current_cube = build_sar_graph(
423
+ conn=conn, bbox=bbox,
424
+ temporal_extent=[current_start, current_end],
425
+ resolution_m=RESOLUTION_M,
426
+ )
427
+ baseline_cube = build_sar_graph(
428
+ conn=conn, bbox=bbox,
429
+ temporal_extent=[baseline_start, baseline_end],
430
+ resolution_m=RESOLUTION_M,
431
+ )
432
+ true_color_cube = build_true_color_graph(
433
+ conn=conn, bbox=bbox,
434
+ temporal_extent=[current_start, current_end],
435
+ resolution_m=RESOLUTION_M,
436
+ )
437
+
438
+ loop = asyncio.get_event_loop()
439
+ current_path = os.path.join(results_dir, "sar_current.tif")
440
+ baseline_path = os.path.join(results_dir, "sar_baseline.tif")
441
+ true_color_path = os.path.join(results_dir, "true_color.tif")
442
+
443
+ await loop.run_in_executor(None, current_cube.download, current_path)
444
+ await loop.run_in_executor(None, baseline_cube.download, baseline_path)
445
+ await loop.run_in_executor(None, true_color_cube.download, true_color_path)
446
+
447
+ self._true_color_path = true_color_path
448
+
449
+ current_stats = self._compute_stats(current_path)
450
+ baseline_stats = self._compute_stats(baseline_path)
451
+
452
+ # Check for insufficient data
453
+ if current_stats["valid_months"] == 0:
454
+ return self._insufficient_data(aoi, time_range)
455
+
456
+ # Change detection: mean VV difference per pixel
457
+ change_db = current_stats["overall_vv_mean"] - baseline_stats["overall_vv_mean"]
458
+
459
+ # Compute % of area with significant change
460
+ change_pct = self._compute_change_area_pct(
461
+ current_path, baseline_path, current_stats, baseline_stats
462
+ )
463
+
464
+ # Flood detection: months where VV < baseline_mean - 2σ
465
+ flood_months = self._count_flood_months(
466
+ current_stats["monthly_vv_means"],
467
+ baseline_stats["overall_vv_mean"],
468
+ baseline_stats["vv_std"],
469
+ )
470
+
471
+ status = self._classify(change_pct, flood_months)
472
+ trend = self._compute_trend(current_stats["monthly_vv_means"])
473
+ confidence = (
474
+ ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
475
+ else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
476
+ else ConfidenceLevel.LOW
477
+ )
478
+
479
+ chart_data = self._build_chart_data(
480
+ current_stats["monthly_vv_means"],
481
+ baseline_stats["monthly_vv_means"],
482
+ time_range,
483
+ )
484
+
485
+ # Headline
486
+ parts = []
487
+ if change_pct >= 5:
488
+ parts.append(f"{change_pct:.0f}% ground surface change")
489
+ if flood_months > 0:
490
+ parts.append(f"{flood_months} potential flood event{'s' if flood_months > 1 else ''}")
491
+ if parts:
492
+ headline = f"SAR detects {', '.join(parts)}"
493
+ else:
494
+ headline = "Stable backscatter conditions — no significant ground change detected"
495
+
496
+ # Store raster path for map rendering — write a change map
497
+ change_map_path = os.path.join(results_dir, "sar_change.tif")
498
+ self._write_change_raster(current_path, baseline_path, change_map_path)
499
+
500
+ self._spatial_data = SpatialData(
501
+ map_type="raster",
502
+ label="SAR VV Change (dB)",
503
+ colormap="RdBu_r",
504
+ vmin=-6,
505
+ vmax=6,
506
+ )
507
+ self._indicator_raster_path = change_map_path
508
+ self._render_band = 1
509
+
510
+ return IndicatorResult(
511
+ indicator_id=self.id,
512
+ headline=headline,
513
+ status=status,
514
+ trend=trend,
515
+ confidence=confidence,
516
+ map_layer_path=change_map_path,
517
+ chart_data=chart_data,
518
+ data_source="satellite",
519
+ summary=(
520
+ f"Mean VV backscatter change: {change_db:+.1f} dB. "
521
+ f"{change_pct:.1f}% of AOI shows significant change (>{CHANGE_THRESHOLD_DB} dB). "
522
+ f"{flood_months} month(s) with potential flood signals. "
523
+ f"Pixel-level analysis at {RESOLUTION_M}m resolution from "
524
+ f"{current_stats['valid_months']} monthly composites."
525
+ ),
526
+ methodology=(
527
+ f"Sentinel-1 GRD IW VV/VH polarizations, ascending orbit. "
528
+ f"Linear backscatter converted to dB (10·log₁₀). "
529
+ f"Monthly median composites at {RESOLUTION_M}m resolution. "
530
+ f"Change detection: >{CHANGE_THRESHOLD_DB} dB difference vs "
531
+ f"{BASELINE_YEARS}-year baseline. "
532
+ f"Flood mapping: VV < baseline_mean − {FLOOD_SIGMA}σ. "
533
+ f"Processed via CDSE openEO."
534
+ ),
535
+ limitations=[
536
+ f"Resampled to {RESOLUTION_M}m — fine-scale changes not captured.",
537
+ "Ascending orbit filter may reduce temporal coverage in some areas.",
538
+ "Sentinel-1 coverage over East Africa can be inconsistent.",
539
+ "VV decrease may indicate flooding, moisture, or vegetation change — not uniquely flood.",
540
+ ],
541
+ )
542
+
543
+ @staticmethod
544
+ def _compute_stats(tif_path: str) -> dict[str, Any]:
545
+ """Extract monthly VV statistics from interleaved SAR GeoTIFF.
546
+
547
+ Bands are interleaved: VV_m1, VH_m1, VV_m2, VH_m2, ...
548
+ """
549
+ with rasterio.open(tif_path) as src:
550
+ n_bands = src.count
551
+ n_months = n_bands // 2
552
+ monthly_vv_means: list[float] = []
553
+ all_vv_values: list[float] = []
554
+
555
+ for m in range(n_months):
556
+ vv_band = m * 2 + 1 # 1-based: bands 1, 3, 5, ...
557
+ data = src.read(vv_band).astype(np.float32)
558
+ nodata = src.nodata
559
+ if nodata is not None:
560
+ valid = data[data != nodata]
561
+ else:
562
+ valid = data.ravel()
563
+ if len(valid) > 0:
564
+ mean_val = float(np.nanmean(valid))
565
+ monthly_vv_means.append(mean_val)
566
+ all_vv_values.extend(valid.tolist())
567
+ else:
568
+ monthly_vv_means.append(0.0)
569
+
570
+ valid_months = sum(1 for m in monthly_vv_means if m != 0.0)
571
+ valid_means = [m for m in monthly_vv_means if m != 0.0]
572
+ overall_vv_mean = float(np.mean(valid_means)) if valid_means else 0.0
573
+ vv_std = float(np.std(all_vv_values)) if all_vv_values else 1.0
574
+
575
+ return {
576
+ "monthly_vv_means": monthly_vv_means,
577
+ "overall_vv_mean": overall_vv_mean,
578
+ "vv_std": vv_std,
579
+ "valid_months": valid_months,
580
+ }
581
+
582
+ @staticmethod
583
+ def _compute_change_area_pct(
584
+ current_path: str, baseline_path: str,
585
+ current_stats: dict, baseline_stats: dict,
586
+ ) -> float:
587
+ """Compute % of AOI area with >3 dB VV change (pixel-level)."""
588
+ with rasterio.open(current_path) as csrc, rasterio.open(baseline_path) as bsrc:
589
+ c_months = csrc.count // 2
590
+ b_months = bsrc.count // 2
591
+
592
+ # Read all VV bands and compute per-pixel means
593
+ c_vv = []
594
+ for m in range(c_months):
595
+ c_vv.append(csrc.read(m * 2 + 1).astype(np.float32))
596
+ c_mean = np.nanmean(np.stack(c_vv), axis=0)
597
+
598
+ b_vv = []
599
+ for m in range(b_months):
600
+ b_vv.append(bsrc.read(m * 2 + 1).astype(np.float32))
601
+ b_mean = np.nanmean(np.stack(b_vv), axis=0)
602
+
603
+ diff = np.abs(c_mean - b_mean)
604
+ significant = np.sum(diff > CHANGE_THRESHOLD_DB)
605
+ total = diff.size
606
+ return float(significant / total * 100) if total > 0 else 0.0
607
+
608
+ @staticmethod
609
+ def _count_flood_months(
610
+ monthly_vv: list[float], baseline_mean: float, baseline_std: float
611
+ ) -> int:
612
+ """Count months where mean VV is anomalously low (potential flood)."""
613
+ threshold = baseline_mean - FLOOD_SIGMA * baseline_std
614
+ return sum(1 for v in monthly_vv if v != 0.0 and v < threshold)
615
+
616
+ @staticmethod
617
+ def _classify(change_pct: float, flood_months: int) -> StatusLevel:
618
+ if change_pct >= 15 or flood_months >= 3:
619
+ return StatusLevel.RED
620
+ if change_pct >= 5 or flood_months >= 1:
621
+ return StatusLevel.AMBER
622
+ return StatusLevel.GREEN
623
+
624
+ @staticmethod
625
+ def _compute_trend(monthly_vv: list[float]) -> TrendDirection:
626
+ valid = [v for v in monthly_vv if v != 0.0]
627
+ if len(valid) < 4:
628
+ return TrendDirection.STABLE
629
+ mid = len(valid) // 2
630
+ first_half = np.mean(valid[:mid])
631
+ second_half = np.mean(valid[mid:])
632
+ diff = abs(second_half - first_half)
633
+ if diff < 1.0:
634
+ return TrendDirection.STABLE
635
+ if second_half > first_half:
636
+ return TrendDirection.IMPROVING
637
+ return TrendDirection.DETERIORATING
638
+
639
+ @staticmethod
640
+ def _build_chart_data(
641
+ current_monthly: list[float],
642
+ baseline_monthly: list[float],
643
+ time_range: TimeRange,
644
+ ) -> dict[str, Any]:
645
+ year = time_range.end.year
646
+ n = len(current_monthly)
647
+ dates = [f"{year}-{m + 1:02d}" for m in range(n)]
648
+ values = [round(v, 2) for v in current_monthly]
649
+ b_mean = [round(v, 2) for v in baseline_monthly[:n]] if baseline_monthly else []
650
+ b_min = [round(v - 2.0, 2) for v in b_mean]
651
+ b_max = [round(v + 2.0, 2) for v in b_mean]
652
+
653
+ return {
654
+ "dates": dates,
655
+ "values": values,
656
+ "baseline_mean": b_mean,
657
+ "baseline_min": b_min,
658
+ "baseline_max": b_max,
659
+ "label": "VV Backscatter (dB)",
660
+ }
661
+
662
+ @staticmethod
663
+ def _write_change_raster(current_path: str, baseline_path: str, output_path: str) -> None:
664
+ """Write a single-band GeoTIFF of VV change (current_mean - baseline_mean)."""
665
+ with rasterio.open(current_path) as csrc:
666
+ c_months = csrc.count // 2
667
+ c_vv = [csrc.read(m * 2 + 1).astype(np.float32) for m in range(c_months)]
668
+ c_mean = np.nanmean(np.stack(c_vv), axis=0)
669
+ profile = csrc.profile.copy()
670
+
671
+ with rasterio.open(baseline_path) as bsrc:
672
+ b_months = bsrc.count // 2
673
+ b_vv = [bsrc.read(m * 2 + 1).astype(np.float32) for m in range(b_months)]
674
+ b_mean = np.nanmean(np.stack(b_vv), axis=0)
675
+
676
+ change = c_mean - b_mean
677
+ profile.update(count=1, dtype="float32")
678
+ with rasterio.open(output_path, "w", **profile) as dst:
679
+ dst.write(change, 1)
680
+
681
+ def _insufficient_data(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
682
+ """Return result when no Sentinel-1 data is available."""
683
+ return IndicatorResult(
684
+ indicator_id=self.id,
685
+ headline="Insufficient SAR data for this area and time period",
686
+ status=StatusLevel.GREEN,
687
+ trend=TrendDirection.STABLE,
688
+ confidence=ConfidenceLevel.LOW,
689
+ map_layer_path="",
690
+ chart_data={"dates": [], "values": [], "label": "VV Backscatter (dB)"},
691
+ data_source="placeholder",
692
+ summary=(
693
+ "No Sentinel-1 GRD scenes were available for the requested "
694
+ "area and time period. SAR coverage over parts of East Africa "
695
+ "is inconsistent."
696
+ ),
697
+ methodology="Sentinel-1 GRD — no data available for processing.",
698
+ limitations=["No SAR data available. This indicator was skipped."],
699
+ )
700
+
701
+ def _fallback(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
702
+ return self._insufficient_data(aoi, time_range)
703
+ ```
704
+
705
+ - [ ] **Step 4: Run tests to verify they pass**
706
+
707
+ Run: `pytest tests/test_indicator_sar.py -v`
708
+ Expected: ALL PASS (4 tests).
709
+
710
+ - [ ] **Step 5: Commit**
711
+
712
+ ```bash
713
+ git add app/indicators/sar.py tests/test_indicator_sar.py
714
+ git commit -m "feat: add SAR backscatter indicator with change detection and flood mapping"
715
+ ```
716
+
717
+ ---
718
+
719
+ ### Task 3: Implement built-up settlement extent indicator
720
+
721
+ **Files:**
722
+ - Create: `app/indicators/buildup.py`
723
+ - Create: `tests/test_indicator_buildup.py`
724
+
725
+ - [ ] **Step 1: Write failing tests for built-up indicator**
726
+
727
+ Create `tests/test_indicator_buildup.py`:
728
+
729
+ ```python
730
+ """Tests for app.indicators.buildup — built-up extent via NDBI from openEO."""
731
+ from __future__ import annotations
732
+
733
+ import os
734
+ import tempfile
735
+ from unittest.mock import MagicMock, patch
736
+ from datetime import date
737
+
738
+ import numpy as np
739
+ import rasterio
740
+ from rasterio.transform import from_bounds
741
+ import pytest
742
+
743
+ from app.models import AOI, TimeRange, StatusLevel, TrendDirection, ConfidenceLevel
744
+
745
+ BBOX = [32.45, 15.65, 32.65, 15.8]
746
+
747
+
748
+ @pytest.fixture
749
+ def test_aoi():
750
+ return AOI(name="Test", bbox=BBOX)
751
+
752
+
753
+ @pytest.fixture
754
+ def test_time_range():
755
+ return TimeRange(start=date(2025, 3, 1), end=date(2026, 3, 1))
756
+
757
+
758
+ def _mock_ndbi_tif(path: str, n_months: int = 12, buildup_fraction: float = 0.15):
759
+ """Create synthetic NDBI GeoTIFF. Values > 0 with low NDVI = built-up."""
760
+ rng = np.random.default_rng(55)
761
+ data = np.zeros((n_months, 10, 10), dtype=np.float32)
762
+ for m in range(n_months):
763
+ vals = rng.normal(-0.15, 0.2, (10, 10))
764
+ buildup_mask = rng.random((10, 10)) < buildup_fraction
765
+ vals[buildup_mask] = rng.uniform(0.05, 0.4, buildup_mask.sum())
766
+ data[m] = vals
767
+ with rasterio.open(
768
+ path, "w", driver="GTiff", height=10, width=10, count=n_months,
769
+ dtype="float32", crs="EPSG:4326",
770
+ transform=from_bounds(*BBOX, 10, 10), nodata=-9999.0,
771
+ ) as dst:
772
+ for i in range(n_months):
773
+ dst.write(data[i], i + 1)
774
+
775
+
776
+ def _mock_rgb_tif(path: str):
777
+ rng = np.random.default_rng(43)
778
+ data = rng.integers(500, 1500, (3, 10, 10), dtype=np.uint16)
779
+ with rasterio.open(
780
+ path, "w", driver="GTiff", height=10, width=10, count=3,
781
+ dtype="uint16", crs="EPSG:4326",
782
+ transform=from_bounds(*BBOX, 10, 10), nodata=0,
783
+ ) as dst:
784
+ for i in range(3):
785
+ dst.write(data[i], i + 1)
786
+
787
+
788
+ @pytest.mark.asyncio
789
+ async def test_buildup_process_returns_result(test_aoi, test_time_range):
790
+ """BuiltupIndicator.process() returns a valid IndicatorResult."""
791
+ from app.indicators.buildup import BuiltupIndicator
792
+
793
+ indicator = BuiltupIndicator()
794
+
795
+ with tempfile.TemporaryDirectory() as tmpdir:
796
+ ndbi_path = os.path.join(tmpdir, "ndbi.tif")
797
+ rgb_path = os.path.join(tmpdir, "rgb.tif")
798
+ _mock_ndbi_tif(ndbi_path)
799
+ _mock_rgb_tif(rgb_path)
800
+
801
+ mock_cube = MagicMock()
802
+
803
+ def fake_download(path, **kwargs):
804
+ import shutil
805
+ if "ndbi" in path or "buildup" in path:
806
+ shutil.copy(ndbi_path, path)
807
+ else:
808
+ shutil.copy(rgb_path, path)
809
+
810
+ mock_cube.download = MagicMock(side_effect=fake_download)
811
+
812
+ with patch("app.indicators.buildup.get_connection"), \
813
+ patch("app.indicators.buildup.build_buildup_graph", return_value=mock_cube), \
814
+ patch("app.indicators.buildup.build_true_color_graph", return_value=mock_cube):
815
+ result = await indicator.process(test_aoi, test_time_range)
816
+
817
+ assert result.indicator_id == "buildup"
818
+ assert result.status in (StatusLevel.GREEN, StatusLevel.AMBER, StatusLevel.RED)
819
+ assert result.data_source == "satellite"
820
+ assert "NDBI" in result.methodology or "built-up" in result.methodology.lower()
821
+ assert len(result.chart_data.get("dates", [])) > 0
822
+
823
+
824
+ @pytest.mark.asyncio
825
+ async def test_buildup_falls_back_on_failure(test_aoi, test_time_range):
826
+ """BuiltupIndicator falls back gracefully when openEO fails."""
827
+ from app.indicators.buildup import BuiltupIndicator
828
+
829
+ indicator = BuiltupIndicator()
830
+
831
+ with patch("app.indicators.buildup.get_connection", side_effect=Exception("CDSE down")):
832
+ result = await indicator.process(test_aoi, test_time_range)
833
+
834
+ assert result.indicator_id == "buildup"
835
+ assert result.data_source == "placeholder"
836
+
837
+
838
+ def test_buildup_compute_stats():
839
+ """_compute_stats() extracts built-up fraction from NDBI raster."""
840
+ from app.indicators.buildup import BuiltupIndicator
841
+
842
+ with tempfile.TemporaryDirectory() as tmpdir:
843
+ path = os.path.join(tmpdir, "ndbi.tif")
844
+ _mock_ndbi_tif(path, n_months=12, buildup_fraction=0.2)
845
+ stats = BuiltupIndicator._compute_stats(path)
846
+
847
+ assert "monthly_buildup_fractions" in stats
848
+ assert len(stats["monthly_buildup_fractions"]) == 12
849
+ assert "overall_buildup_fraction" in stats
850
+ assert 0 < stats["overall_buildup_fraction"] < 1
851
+ assert "valid_months" in stats
852
+
853
+
854
+ def test_buildup_classify():
855
+ """_classify() maps change percentage to correct status."""
856
+ from app.indicators.buildup import BuiltupIndicator
857
+
858
+ assert BuiltupIndicator._classify(change_pct=5.0) == StatusLevel.GREEN
859
+ assert BuiltupIndicator._classify(change_pct=15.0) == StatusLevel.AMBER
860
+ assert BuiltupIndicator._classify(change_pct=35.0) == StatusLevel.RED
861
+ assert BuiltupIndicator._classify(change_pct=-25.0) == StatusLevel.AMBER
862
+ assert BuiltupIndicator._classify(change_pct=-35.0) == StatusLevel.RED
863
+ ```
864
+
865
+ - [ ] **Step 2: Run tests to verify they fail**
866
+
867
+ Run: `pytest tests/test_indicator_buildup.py -v`
868
+ Expected: FAIL with `ModuleNotFoundError`.
869
+
870
+ - [ ] **Step 3: Implement built-up indicator**
871
+
872
+ Create `app/indicators/buildup.py`:
873
+
874
+ ```python
875
+ """Built-up / Settlement Extent Indicator — NDBI via CDSE openEO.
876
+
877
+ Computes monthly NDBI composites from Sentinel-2 L2A, classifies built-up
878
+ pixels (NDBI > 0 AND NDVI < 0.2), and tracks settlement extent change
879
+ against a baseline period.
880
+ """
881
+ from __future__ import annotations
882
+
883
+ import logging
884
+ import os
885
+ import tempfile
886
+ from datetime import date
887
+ from typing import Any
888
+
889
+ import numpy as np
890
+ import rasterio
891
+
892
+ from app.config import RESOLUTION_M
893
+ from app.indicators.base import BaseIndicator, SpatialData
894
+ from app.models import (
895
+ AOI,
896
+ TimeRange,
897
+ IndicatorResult,
898
+ StatusLevel,
899
+ TrendDirection,
900
+ ConfidenceLevel,
901
+ )
902
+ from app.openeo_client import get_connection, build_buildup_graph, build_true_color_graph, _bbox_dict
903
+
904
+ logger = logging.getLogger(__name__)
905
+
906
+ BASELINE_YEARS = 3
907
+ NDBI_THRESHOLD = 0.0 # NDBI > 0 = potential built-up
908
+
909
+
910
+ class BuiltupIndicator(BaseIndicator):
911
+ id = "buildup"
912
+ name = "Settlement Extent"
913
+ category = "D11"
914
+ question = "Is settlement area changing?"
915
+ estimated_minutes = 8
916
+
917
+ _true_color_path: str | None = None
918
+
919
+ async def process(
920
+ self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
921
+ ) -> IndicatorResult:
922
+ try:
923
+ return await self._process_openeo(aoi, time_range, season_months)
924
+ except Exception as exc:
925
+ logger.warning("Built-up openEO processing failed, using placeholder: %s", exc)
926
+ return self._fallback(aoi, time_range)
927
+
928
+ async def _process_openeo(
929
+ self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
930
+ ) -> IndicatorResult:
931
+ import asyncio
932
+
933
+ conn = get_connection()
934
+ bbox = _bbox_dict(aoi.bbox)
935
+
936
+ current_start = time_range.start.isoformat()
937
+ current_end = time_range.end.isoformat()
938
+ baseline_start = date(
939
+ time_range.start.year - BASELINE_YEARS,
940
+ time_range.start.month,
941
+ time_range.start.day,
942
+ ).isoformat()
943
+ baseline_end = time_range.start.isoformat()
944
+
945
+ results_dir = tempfile.mkdtemp(prefix="aperture_buildup_")
946
+
947
+ current_cube = build_buildup_graph(
948
+ conn=conn, bbox=bbox,
949
+ temporal_extent=[current_start, current_end],
950
+ resolution_m=RESOLUTION_M,
951
+ )
952
+ baseline_cube = build_buildup_graph(
953
+ conn=conn, bbox=bbox,
954
+ temporal_extent=[baseline_start, baseline_end],
955
+ resolution_m=RESOLUTION_M,
956
+ )
957
+ true_color_cube = build_true_color_graph(
958
+ conn=conn, bbox=bbox,
959
+ temporal_extent=[current_start, current_end],
960
+ resolution_m=RESOLUTION_M,
961
+ )
962
+
963
+ loop = asyncio.get_event_loop()
964
+ current_path = os.path.join(results_dir, "ndbi_current.tif")
965
+ baseline_path = os.path.join(results_dir, "ndbi_baseline.tif")
966
+ true_color_path = os.path.join(results_dir, "true_color.tif")
967
+
968
+ await loop.run_in_executor(None, current_cube.download, current_path)
969
+ await loop.run_in_executor(None, baseline_cube.download, baseline_path)
970
+ await loop.run_in_executor(None, true_color_cube.download, true_color_path)
971
+
972
+ self._true_color_path = true_color_path
973
+
974
+ current_stats = self._compute_stats(current_path)
975
+ baseline_stats = self._compute_stats(baseline_path)
976
+
977
+ current_frac = current_stats["overall_buildup_fraction"]
978
+ baseline_frac = baseline_stats["overall_buildup_fraction"]
979
+
980
+ # Convert fractions to area using AOI size
981
+ aoi_ha = aoi.area_km2 * 100 # km² → hectares
982
+ current_ha = current_frac * aoi_ha
983
+ baseline_ha = baseline_frac * aoi_ha
984
+
985
+ if baseline_frac > 0:
986
+ change_pct = ((current_frac - baseline_frac) / baseline_frac) * 100
987
+ else:
988
+ change_pct = 100.0 if current_frac > 0 else 0.0
989
+
990
+ status = self._classify(change_pct)
991
+ trend = self._compute_trend(change_pct)
992
+ confidence = (
993
+ ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
994
+ else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
995
+ else ConfidenceLevel.LOW
996
+ )
997
+
998
+ chart_data = self._build_chart_data(
999
+ current_stats["monthly_buildup_fractions"],
1000
+ baseline_stats["monthly_buildup_fractions"],
1001
+ time_range,
1002
+ aoi_ha,
1003
+ )
1004
+
1005
+ # Headline
1006
+ if abs(change_pct) < 10:
1007
+ headline = f"Built-up extent stable at approximately {current_ha:.0f} ha"
1008
+ elif change_pct > 0:
1009
+ headline = f"Settlement area expanded {change_pct:.0f}% ({baseline_ha:.0f} → {current_ha:.0f} ha) compared to baseline"
1010
+ else:
1011
+ headline = f"Potential settlement contraction: {abs(change_pct):.0f}% decrease in built-up area"
1012
+
1013
+ # Write change raster for map rendering
1014
+ change_map_path = os.path.join(results_dir, "buildup_change.tif")
1015
+ self._write_change_raster(current_path, baseline_path, change_map_path)
1016
+
1017
+ self._spatial_data = SpatialData(
1018
+ map_type="raster",
1019
+ label="Built-up Change",
1020
+ colormap="PiYG",
1021
+ vmin=-1,
1022
+ vmax=1,
1023
+ )
1024
+ self._indicator_raster_path = change_map_path
1025
+ self._render_band = 1
1026
+
1027
+ return IndicatorResult(
1028
+ indicator_id=self.id,
1029
+ headline=headline,
1030
+ status=status,
1031
+ trend=trend,
1032
+ confidence=confidence,
1033
+ map_layer_path=change_map_path,
1034
+ chart_data=chart_data,
1035
+ data_source="satellite",
1036
+ summary=(
1037
+ f"Built-up area covers {current_frac*100:.1f}% of the AOI "
1038
+ f"({current_ha:.0f} ha) compared to {baseline_frac*100:.1f}% baseline "
1039
+ f"({baseline_ha:.0f} ha), a {change_pct:+.1f}% change. "
1040
+ f"Pixel-level NDBI analysis at {RESOLUTION_M}m resolution."
1041
+ ),
1042
+ methodology=(
1043
+ f"Sentinel-2 L2A pixel-level NDBI = (B11 − B08) / (B11 + B08). "
1044
+ f"Built-up classified as NDBI > {NDBI_THRESHOLD}. "
1045
+ f"Cloud-masked using SCL band. "
1046
+ f"Monthly median composites at {RESOLUTION_M}m. "
1047
+ f"Baseline: {BASELINE_YEARS}-year built-up extent. "
1048
+ f"Processed via CDSE openEO."
1049
+ ),
1050
+ limitations=[
1051
+ f"Resampled to {RESOLUTION_M}m — detects settlement extent, not individual buildings.",
1052
+ "NDBI may confuse bare rock/sand with built-up in arid landscapes.",
1053
+ "Seasonal vegetation cycles can cause false positives at settlement fringes.",
1054
+ "For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed.",
1055
+ ],
1056
+ )
1057
+
1058
+ @staticmethod
1059
+ def _compute_stats(tif_path: str) -> dict[str, Any]:
1060
+ """Extract monthly built-up fraction from NDBI GeoTIFF.
1061
+
1062
+ Built-up = NDBI > 0 (simplified; full pipeline would also check NDVI < 0.2
1063
+ but the graph builder only outputs NDBI).
1064
+ """
1065
+ with rasterio.open(tif_path) as src:
1066
+ n_bands = src.count
1067
+ monthly_fractions: list[float] = []
1068
+ peak_frac = -1.0
1069
+ peak_band = 1
1070
+ for band in range(1, n_bands + 1):
1071
+ data = src.read(band).astype(np.float32)
1072
+ nodata = src.nodata
1073
+ if nodata is not None:
1074
+ valid = data[data != nodata]
1075
+ else:
1076
+ valid = data.ravel()
1077
+ if len(valid) > 0:
1078
+ buildup_pixels = np.sum(valid > NDBI_THRESHOLD)
1079
+ frac = float(buildup_pixels / len(valid))
1080
+ monthly_fractions.append(frac)
1081
+ if frac > peak_frac:
1082
+ peak_frac = frac
1083
+ peak_band = band
1084
+ else:
1085
+ monthly_fractions.append(0.0)
1086
+
1087
+ valid_months = sum(1 for f in monthly_fractions if f > 0)
1088
+ overall = float(np.mean(monthly_fractions)) if monthly_fractions else 0.0
1089
+
1090
+ return {
1091
+ "monthly_buildup_fractions": monthly_fractions,
1092
+ "overall_buildup_fraction": overall,
1093
+ "valid_months": max(valid_months, len(monthly_fractions)),
1094
+ "peak_buildup_band": peak_band,
1095
+ }
1096
+
1097
+ @staticmethod
1098
+ def _classify(change_pct: float) -> StatusLevel:
1099
+ abs_change = abs(change_pct)
1100
+ if abs_change < 10:
1101
+ return StatusLevel.GREEN
1102
+ if abs_change < 30:
1103
+ return StatusLevel.AMBER
1104
+ return StatusLevel.RED
1105
+
1106
+ @staticmethod
1107
+ def _compute_trend(change_pct: float) -> TrendDirection:
1108
+ if abs(change_pct) < 10:
1109
+ return TrendDirection.STABLE
1110
+ if change_pct > 30:
1111
+ return TrendDirection.DETERIORATING # rapid expansion = potential displacement
1112
+ if change_pct < -10:
1113
+ return TrendDirection.DETERIORATING # contraction = potential destruction
1114
+ return TrendDirection.STABLE
1115
+
1116
+ @staticmethod
1117
+ def _build_chart_data(
1118
+ current_monthly: list[float],
1119
+ baseline_monthly: list[float],
1120
+ time_range: TimeRange,
1121
+ aoi_ha: float,
1122
+ ) -> dict[str, Any]:
1123
+ year = time_range.end.year
1124
+ n = min(len(current_monthly), len(baseline_monthly))
1125
+ dates = [f"{year}-{m + 1:02d}" for m in range(n)]
1126
+ values = [round(v * aoi_ha, 1) for v in current_monthly[:n]]
1127
+ b_mean = [round(v * aoi_ha, 1) for v in baseline_monthly[:n]]
1128
+ b_min = [round(max(v * aoi_ha - aoi_ha * 0.02, 0), 1) for v in baseline_monthly[:n]]
1129
+ b_max = [round(v * aoi_ha + aoi_ha * 0.02, 1) for v in baseline_monthly[:n]]
1130
+
1131
+ return {
1132
+ "dates": dates,
1133
+ "values": values,
1134
+ "baseline_mean": b_mean,
1135
+ "baseline_min": b_min,
1136
+ "baseline_max": b_max,
1137
+ "label": "Built-up area (ha)",
1138
+ }
1139
+
1140
+ @staticmethod
1141
+ def _write_change_raster(current_path: str, baseline_path: str, output_path: str) -> None:
1142
+ """Write single-band change raster: current built-up mask minus baseline."""
1143
+ with rasterio.open(current_path) as csrc:
1144
+ c_data = [csrc.read(b + 1).astype(np.float32) for b in range(csrc.count)]
1145
+ c_mean = np.nanmean(np.stack(c_data), axis=0)
1146
+ profile = csrc.profile.copy()
1147
+
1148
+ with rasterio.open(baseline_path) as bsrc:
1149
+ b_data = [bsrc.read(b + 1).astype(np.float32) for b in range(bsrc.count)]
1150
+ b_mean = np.nanmean(np.stack(b_data), axis=0)
1151
+
1152
+ # Built-up masks
1153
+ c_buildup = (c_mean > NDBI_THRESHOLD).astype(np.float32)
1154
+ b_buildup = (b_mean > NDBI_THRESHOLD).astype(np.float32)
1155
+ change = c_buildup - b_buildup # +1 = new, -1 = lost, 0 = no change
1156
+
1157
+ profile.update(count=1, dtype="float32")
1158
+ with rasterio.open(output_path, "w", **profile) as dst:
1159
+ dst.write(change, 1)
1160
+
1161
+ def _fallback(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
1162
+ rng = np.random.default_rng(11)
1163
+ baseline = float(rng.uniform(5, 20))
1164
+ current = baseline * float(rng.uniform(0.85, 1.15))
1165
+ change = current - baseline
1166
+
1167
+ return IndicatorResult(
1168
+ indicator_id=self.id,
1169
+ headline=f"Settlement data degraded ({current:.1f}% extent)",
1170
+ status=StatusLevel.GREEN if abs(change) < 5 else StatusLevel.AMBER,
1171
+ trend=TrendDirection.STABLE,
1172
+ confidence=ConfidenceLevel.LOW,
1173
+ map_layer_path="",
1174
+ chart_data={
1175
+ "dates": [str(time_range.start.year), str(time_range.end.year)],
1176
+ "values": [round(baseline, 1), round(current, 1)],
1177
+ "label": "Built-up area (ha)",
1178
+ },
1179
+ data_source="placeholder",
1180
+ summary="openEO processing unavailable. Showing placeholder values.",
1181
+ methodology="Placeholder — no satellite data processed.",
1182
+ limitations=["Data is synthetic. openEO backend was unreachable."],
1183
+ )
1184
+ ```
1185
+
1186
+ - [ ] **Step 4: Run tests to verify they pass**
1187
+
1188
+ Run: `pytest tests/test_indicator_buildup.py -v`
1189
+ Expected: ALL PASS (4 tests).
1190
+
1191
+ - [ ] **Step 5: Commit**
1192
+
1193
+ ```bash
1194
+ git add app/indicators/buildup.py tests/test_indicator_buildup.py
1195
+ git commit -m "feat: add built-up settlement extent indicator via NDBI"
1196
+ ```
1197
+
1198
+ ---
1199
+
1200
+ ### Task 4: Register new indicators
1201
+
1202
+ **Files:**
1203
+ - Modify: `app/indicators/__init__.py`
1204
+
1205
+ - [ ] **Step 1: Add imports and register both new indicators**
1206
+
1207
+ Add to the end of the import block in `app/indicators/__init__.py`:
1208
+
1209
+ ```python
1210
+ from app.indicators.sar import SarIndicator
1211
+ from app.indicators.buildup import BuiltupIndicator
1212
+ ```
1213
+
1214
+ Add to the end of the registration block:
1215
+
1216
+ ```python
1217
+ registry.register(SarIndicator())
1218
+ registry.register(BuiltupIndicator())
1219
+ ```
1220
+
1221
+ - [ ] **Step 2: Verify the registry lists the new indicators**
1222
+
1223
+ Run: `python -c "from app.indicators import registry; ids = registry.list_ids(); assert 'sar' in ids; assert 'buildup' in ids; print('OK:', ids)"`
1224
+ Expected: Prints `OK:` followed by list including `sar` and `buildup`.
1225
+
1226
+ - [ ] **Step 3: Run full test suite to verify no regressions**
1227
+
1228
+ Run: `pytest tests/ -v --tb=short`
1229
+ Expected: All existing tests still pass, plus the new tests from Tasks 1-3.
1230
+
1231
+ - [ ] **Step 4: Commit**
1232
+
1233
+ ```bash
1234
+ git add app/indicators/__init__.py
1235
+ git commit -m "feat: register SAR and built-up indicators in registry"
1236
+ ```
1237
+
1238
+ ---
1239
+
1240
+ ### Task 5: Add composite score computation and overview weights
1241
+
1242
+ **Files:**
1243
+ - Modify: `app/config.py`
1244
+ - Create: `app/outputs/overview.py`
1245
+ - Create: `tests/test_overview.py`
1246
+
1247
+ - [ ] **Step 1: Add overview weights to config**
1248
+
1249
+ Append to `app/config.py`:
1250
+
1251
+ ```python
1252
+ # Expert weights for the visual overview composite score.
1253
+ # Normalized to 1.0. Indicators not selected or skipped are excluded
1254
+ # and weights are re-normalized.
1255
+ OVERVIEW_WEIGHTS: dict[str, float] = {
1256
+ "fires": 0.20,
1257
+ "sar": 0.15,
1258
+ "buildup": 0.15,
1259
+ "ndvi": 0.12,
1260
+ "water": 0.10,
1261
+ "rainfall": 0.10,
1262
+ "lst": 0.08,
1263
+ "no2": 0.05,
1264
+ "nightlights": 0.05,
1265
+ }
1266
+ ```
1267
+
1268
+ - [ ] **Step 2: Write failing tests for composite score**
1269
+
1270
+ Create `tests/test_overview.py`:
1271
+
1272
+ ```python
1273
+ """Tests for app.outputs.overview — composite score computation."""
1274
+ from __future__ import annotations
1275
+
1276
+ import json
1277
+ import os
1278
+ import tempfile
1279
+
1280
+ import pytest
1281
+
1282
+ from app.models import (
1283
+ IndicatorResult,
1284
+ StatusLevel,
1285
+ TrendDirection,
1286
+ ConfidenceLevel,
1287
+ )
1288
+
1289
+
1290
+ def _make_result(indicator_id: str, status: StatusLevel) -> IndicatorResult:
1291
+ return IndicatorResult(
1292
+ indicator_id=indicator_id,
1293
+ headline="Test",
1294
+ status=status,
1295
+ trend=TrendDirection.STABLE,
1296
+ confidence=ConfidenceLevel.HIGH,
1297
+ map_layer_path="",
1298
+ chart_data={},
1299
+ summary="Test summary",
1300
+ methodology="Test methodology",
1301
+ limitations=[],
1302
+ )
1303
+
1304
+
1305
+ def test_composite_score_all_green():
1306
+ """All GREEN indicators produce score >= 70 and GREEN status."""
1307
+ from app.outputs.overview import compute_composite_score
1308
+
1309
+ results = [
1310
+ _make_result("ndvi", StatusLevel.GREEN),
1311
+ _make_result("water", StatusLevel.GREEN),
1312
+ _make_result("fires", StatusLevel.GREEN),
1313
+ ]
1314
+ score = compute_composite_score(results)
1315
+
1316
+ assert score["score"] == 100
1317
+ assert score["status"] == "GREEN"
1318
+ assert "GREEN" in score["headline"]
1319
+
1320
+
1321
+ def test_composite_score_mixed():
1322
+ """Mix of statuses produces weighted score and correct status."""
1323
+ from app.outputs.overview import compute_composite_score
1324
+
1325
+ results = [
1326
+ _make_result("fires", StatusLevel.RED), # weight 0.20, score 0
1327
+ _make_result("ndvi", StatusLevel.GREEN), # weight 0.12, score 100
1328
+ _make_result("buildup", StatusLevel.AMBER), # weight 0.15, score 50
1329
+ ]
1330
+ score = compute_composite_score(results)
1331
+
1332
+ # Renormalized weights: fires=0.20/0.47=0.426, ndvi=0.12/0.47=0.255, buildup=0.15/0.47=0.319
1333
+ # Score = 0.426*0 + 0.255*100 + 0.319*50 = 0 + 25.5 + 15.96 = 41.5 → AMBER
1334
+ assert 30 <= score["score"] <= 50
1335
+ assert score["status"] == "AMBER"
1336
+
1337
+
1338
+ def test_composite_score_all_red():
1339
+ """All RED indicators produce score < 40 and RED status."""
1340
+ from app.outputs.overview import compute_composite_score
1341
+
1342
+ results = [
1343
+ _make_result("fires", StatusLevel.RED),
1344
+ _make_result("sar", StatusLevel.RED),
1345
+ ]
1346
+ score = compute_composite_score(results)
1347
+
1348
+ assert score["score"] == 0
1349
+ assert score["status"] == "RED"
1350
+
1351
+
1352
+ def test_composite_score_empty():
1353
+ """Empty results produce neutral score."""
1354
+ from app.outputs.overview import compute_composite_score
1355
+
1356
+ score = compute_composite_score([])
1357
+
1358
+ assert score["score"] == 0
1359
+ assert score["status"] == "RED"
1360
+
1361
+
1362
+ def test_composite_score_headline_mentions_drivers():
1363
+ """Headline identifies the top contributing indicator(s) to concern."""
1364
+ from app.outputs.overview import compute_composite_score
1365
+
1366
+ results = [
1367
+ _make_result("fires", StatusLevel.RED),
1368
+ _make_result("ndvi", StatusLevel.GREEN),
1369
+ _make_result("water", StatusLevel.GREEN),
1370
+ ]
1371
+ score = compute_composite_score(results)
1372
+
1373
+ assert "fires" in score["headline"].lower() or "fire" in score["headline"].lower()
1374
+
1375
+
1376
+ def test_write_overview_score_json():
1377
+ """write_overview_score() writes valid JSON to disk."""
1378
+ from app.outputs.overview import compute_composite_score, write_overview_score
1379
+
1380
+ results = [
1381
+ _make_result("ndvi", StatusLevel.GREEN),
1382
+ _make_result("fires", StatusLevel.AMBER),
1383
+ ]
1384
+ score_data = compute_composite_score(results)
1385
+
1386
+ with tempfile.TemporaryDirectory() as tmpdir:
1387
+ path = os.path.join(tmpdir, "overview_score.json")
1388
+ write_overview_score(score_data, path)
1389
+
1390
+ assert os.path.exists(path)
1391
+ with open(path) as f:
1392
+ loaded = json.load(f)
1393
+ assert loaded["score"] == score_data["score"]
1394
+ assert loaded["status"] == score_data["status"]
1395
+ ```
1396
+
1397
+ - [ ] **Step 3: Run tests to verify they fail**
1398
+
1399
+ Run: `pytest tests/test_overview.py -v`
1400
+ Expected: FAIL with `ModuleNotFoundError`.
1401
+
1402
+ - [ ] **Step 4: Implement overview module**
1403
+
1404
+ Create `app/outputs/overview.py`:
1405
+
1406
+ ```python
1407
+ """Visual overview — composite score computation and overview outputs."""
1408
+ from __future__ import annotations
1409
+
1410
+ import json
1411
+ from typing import Any, Sequence
1412
+
1413
+ from app.config import OVERVIEW_WEIGHTS
1414
+ from app.models import IndicatorResult, StatusLevel
1415
+
1416
+ _STATUS_SCORES = {
1417
+ StatusLevel.GREEN: 100,
1418
+ StatusLevel.AMBER: 50,
1419
+ StatusLevel.RED: 0,
1420
+ }
1421
+
1422
+ # Display names for headline generation
1423
+ _INDICATOR_NAMES = {
1424
+ "fires": "fires",
1425
+ "sar": "SAR ground change",
1426
+ "buildup": "settlement expansion",
1427
+ "ndvi": "vegetation decline",
1428
+ "water": "water extent change",
1429
+ "rainfall": "rainfall anomaly",
1430
+ "lst": "thermal stress",
1431
+ "no2": "air quality",
1432
+ "nightlights": "nighttime lights",
1433
+ }
1434
+
1435
+
1436
+ def compute_composite_score(results: Sequence[IndicatorResult]) -> dict[str, Any]:
1437
+ """Compute weighted composite score from indicator results.
1438
+
1439
+ Returns a dict with: score (0-100), status (GREEN/AMBER/RED),
1440
+ headline, weights_used, per_indicator breakdown.
1441
+ """
1442
+ if not results:
1443
+ return {
1444
+ "score": 0,
1445
+ "status": "RED",
1446
+ "headline": "Area conditions: RED (score 0/100) — no indicators available",
1447
+ "weights_used": {},
1448
+ "per_indicator": {},
1449
+ }
1450
+
1451
+ # Gather scores for completed indicators (exclude status=None-like edge cases)
1452
+ per_indicator: dict[str, dict] = {}
1453
+ active_weights: dict[str, float] = {}
1454
+
1455
+ for result in results:
1456
+ ind_id = result.indicator_id
1457
+ weight = OVERVIEW_WEIGHTS.get(ind_id, 0.05)
1458
+ ind_score = _STATUS_SCORES.get(result.status, 50)
1459
+ per_indicator[ind_id] = {
1460
+ "status": result.status.value.upper(),
1461
+ "score": ind_score,
1462
+ }
1463
+ active_weights[ind_id] = weight
1464
+
1465
+ # Re-normalize weights
1466
+ total_weight = sum(active_weights.values())
1467
+ if total_weight == 0:
1468
+ total_weight = 1.0
1469
+ norm_weights = {k: v / total_weight for k, v in active_weights.items()}
1470
+
1471
+ # Weighted average
1472
+ composite = sum(
1473
+ norm_weights[ind_id] * per_indicator[ind_id]["score"]
1474
+ for ind_id in norm_weights
1475
+ )
1476
+ composite = round(composite)
1477
+
1478
+ # Classify
1479
+ if composite >= 70:
1480
+ status = "GREEN"
1481
+ elif composite >= 40:
1482
+ status = "AMBER"
1483
+ else:
1484
+ status = "RED"
1485
+
1486
+ # Headline: identify top 1-2 drivers of concern
1487
+ headline = _build_headline(composite, status, per_indicator, norm_weights)
1488
+
1489
+ return {
1490
+ "score": composite,
1491
+ "status": status,
1492
+ "headline": headline,
1493
+ "weights_used": {k: round(v, 3) for k, v in norm_weights.items()},
1494
+ "per_indicator": per_indicator,
1495
+ }
1496
+
1497
+
1498
+ def _build_headline(
1499
+ score: int, status: str,
1500
+ per_indicator: dict, weights: dict,
1501
+ ) -> str:
1502
+ """Build a human-readable headline identifying concern drivers."""
1503
+ if status == "GREEN":
1504
+ return f"Area conditions: GREEN (score {score}/100) — stable across all indicators"
1505
+
1506
+ # Find indicators contributing most to non-GREEN score
1507
+ # Impact = weight * (100 - indicator_score) — higher means more concern
1508
+ impacts = []
1509
+ for ind_id, data in per_indicator.items():
1510
+ if data["score"] < 100:
1511
+ impact = weights.get(ind_id, 0) * (100 - data["score"])
1512
+ name = _INDICATOR_NAMES.get(ind_id, ind_id)
1513
+ impacts.append((impact, name))
1514
+
1515
+ impacts.sort(reverse=True)
1516
+ drivers = [name for _, name in impacts[:2]]
1517
+ driver_str = " and ".join(drivers) if drivers else "multiple indicators"
1518
+
1519
+ return f"Area conditions: {status} (score {score}/100) — {driver_str} drive concern"
1520
+
1521
+
1522
+ def write_overview_score(score_data: dict[str, Any], output_path: str) -> None:
1523
+ """Write composite score data to a JSON file."""
1524
+ with open(output_path, "w") as f:
1525
+ json.dump(score_data, f, indent=2)
1526
+ ```
1527
+
1528
+ - [ ] **Step 5: Run tests to verify they pass**
1529
+
1530
+ Run: `pytest tests/test_overview.py -v`
1531
+ Expected: ALL PASS (6 tests).
1532
+
1533
+ - [ ] **Step 6: Commit**
1534
+
1535
+ ```bash
1536
+ git add app/config.py app/outputs/overview.py tests/test_overview.py
1537
+ git commit -m "feat: add composite score computation with expert weights"
1538
+ ```
1539
+
1540
+ ---
1541
+
1542
+ ### Task 6: Add overview map renderer
1543
+
1544
+ **Files:**
1545
+ - Modify: `app/outputs/maps.py`
1546
+ - Modify: `tests/test_overview.py`
1547
+
1548
+ - [ ] **Step 1: Write failing test for `render_overview_map`**
1549
+
1550
+ Append to `tests/test_overview.py`:
1551
+
1552
+ ```python
1553
+ def test_render_overview_map():
1554
+ """render_overview_map() produces a PNG file."""
1555
+ import rasterio
1556
+ from rasterio.transform import from_bounds
1557
+ import numpy as np
1558
+ from app.models import AOI
1559
+ from app.outputs.maps import render_overview_map
1560
+
1561
+ aoi = AOI(name="Test Khartoum", bbox=[32.45, 15.65, 32.65, 15.8])
1562
+
1563
+ with tempfile.TemporaryDirectory() as tmpdir:
1564
+ # Create synthetic true-color GeoTIFF
1565
+ rgb_path = os.path.join(tmpdir, "rgb.tif")
1566
+ rng = np.random.default_rng(43)
1567
+ data = rng.integers(500, 1500, (3, 10, 10), dtype=np.uint16)
1568
+ with rasterio.open(
1569
+ rgb_path, "w", driver="GTiff", height=10, width=10, count=3,
1570
+ dtype="uint16", crs="EPSG:4326",
1571
+ transform=from_bounds(32.45, 15.65, 32.65, 15.8, 10, 10), nodata=0,
1572
+ ) as dst:
1573
+ for i in range(3):
1574
+ dst.write(data[i], i + 1)
1575
+
1576
+ output_path = os.path.join(tmpdir, "overview_map.png")
1577
+ render_overview_map(
1578
+ true_color_path=rgb_path,
1579
+ aoi=aoi,
1580
+ output_path=output_path,
1581
+ title="Test Khartoum — Satellite Overview",
1582
+ date_range="2025-03 to 2026-03",
1583
+ )
1584
+
1585
+ assert os.path.exists(output_path)
1586
+ assert os.path.getsize(output_path) > 1000 # Not an empty/trivial file
1587
+ ```
1588
+
1589
+ - [ ] **Step 2: Run test to verify it fails**
1590
+
1591
+ Run: `pytest tests/test_overview.py::test_render_overview_map -v`
1592
+ Expected: FAIL with `ImportError`.
1593
+
1594
+ - [ ] **Step 3: Implement `render_overview_map` in `app/outputs/maps.py`**
1595
+
1596
+ Add at the end of `app/outputs/maps.py`:
1597
+
1598
+ ```python
1599
+ def render_overview_map(
1600
+ *,
1601
+ true_color_path: str,
1602
+ aoi: AOI,
1603
+ output_path: str,
1604
+ title: str = "",
1605
+ date_range: str = "",
1606
+ ) -> None:
1607
+ """Render a standalone true-color satellite overview (no indicator overlay).
1608
+
1609
+ Parameters
1610
+ ----------
1611
+ true_color_path:
1612
+ Path to a 3-band (R,G,B) GeoTIFF.
1613
+ aoi:
1614
+ AOI for bounding box overlay and title.
1615
+ output_path:
1616
+ Path to write the output PNG.
1617
+ title:
1618
+ Title text rendered above the image.
1619
+ date_range:
1620
+ Date range annotation rendered below the title.
1621
+ """
1622
+ import rasterio
1623
+
1624
+ fig, ax = plt.subplots(figsize=(8, 6), dpi=200, facecolor=SHELL)
1625
+ ax.set_facecolor(SHELL)
1626
+
1627
+ with rasterio.open(true_color_path) as src:
1628
+ rgb = src.read([1, 2, 3]).astype(np.float32)
1629
+ extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
1630
+
1631
+ # Sentinel-2 reflectance scaling
1632
+ rgb_max = max(rgb.max(), 1.0)
1633
+ scale = 3000.0 if rgb_max > 255 else 255.0
1634
+ rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
1635
+ ax.imshow(rgb_normalized, extent=extent, aspect="auto")
1636
+
1637
+ # AOI outline
1638
+ _draw_aoi_rect(ax, aoi, INK)
1639
+
1640
+ # Title and date range
1641
+ if title:
1642
+ ax.set_title(title, fontsize=10, color=INK, fontweight="bold", pad=8)
1643
+ if date_range:
1644
+ ax.text(
1645
+ 0.5, -0.05, date_range,
1646
+ transform=ax.transAxes, ha="center", fontsize=7, color=INK_MUTED,
1647
+ )
1648
+
1649
+ ax.set_xlim(extent[0], extent[1])
1650
+ ax.set_ylim(extent[2], extent[3])
1651
+ ax.tick_params(labelsize=6, colors=INK_MUTED)
1652
+ ax.set_xlabel("Longitude", fontsize=7, color=INK_MUTED)
1653
+ ax.set_ylabel("Latitude", fontsize=7, color=INK_MUTED)
1654
+
1655
+ for spine in ax.spines.values():
1656
+ spine.set_color(INK_MUTED)
1657
+ spine.set_linewidth(0.5)
1658
+
1659
+ plt.tight_layout()
1660
+ fig.savefig(output_path, dpi=200, bbox_inches="tight", facecolor=SHELL)
1661
+ plt.close(fig)
1662
+ ```
1663
+
1664
+ - [ ] **Step 4: Run test to verify it passes**
1665
+
1666
+ Run: `pytest tests/test_overview.py -v`
1667
+ Expected: ALL PASS (7 tests).
1668
+
1669
+ - [ ] **Step 5: Commit**
1670
+
1671
+ ```bash
1672
+ git add app/outputs/maps.py tests/test_overview.py
1673
+ git commit -m "feat: add standalone true-color overview map renderer"
1674
+ ```
1675
+
1676
+ ---
1677
+
1678
+ ### Task 7: Integrate visual overview into the worker pipeline
1679
+
1680
+ **Files:**
1681
+ - Modify: `app/worker.py`
1682
+
1683
+ - [ ] **Step 1: Add overview post-processing to `process_job` in `app/worker.py`**
1684
+
1685
+ Add the overview import at the top of `app/worker.py` (after existing imports):
1686
+
1687
+ ```python
1688
+ from app.outputs.overview import compute_composite_score, write_overview_score
1689
+ from app.outputs.maps import render_overview_map
1690
+ ```
1691
+
1692
+ Then, in the `process_job` function, insert the following block **after** the summary map generation (after line 148: `output_files.append(summary_map_path)`) and **before** the PDF report generation (before line 151: `report_path = ...`):
1693
+
1694
+ ```python
1695
+ # --- Visual Overview ---
1696
+ overview_score = compute_composite_score(job.results)
1697
+
1698
+ overview_score_path = os.path.join(results_dir, "overview_score.json")
1699
+ write_overview_score(overview_score, overview_score_path)
1700
+ output_files.append(overview_score_path)
1701
+
1702
+ # Overview map: reuse true-color from any raster indicator, or skip
1703
+ overview_map_path = os.path.join(results_dir, "overview_map.png")
1704
+ true_color_path = None
1705
+ for ind_id in job.request.indicator_ids:
1706
+ ind_obj = registry.get(ind_id)
1707
+ tc = getattr(ind_obj, '_true_color_path', None)
1708
+ if tc and os.path.exists(tc):
1709
+ true_color_path = tc
1710
+ break
1711
+
1712
+ if true_color_path:
1713
+ render_overview_map(
1714
+ true_color_path=true_color_path,
1715
+ aoi=job.request.aoi,
1716
+ output_path=overview_map_path,
1717
+ title=f"{job.request.aoi.name} — Satellite Overview",
1718
+ date_range=f"{job.request.time_range.start} to {job.request.time_range.end}",
1719
+ )
1720
+ output_files.append(overview_map_path)
1721
+ ```
1722
+
1723
+ - [ ] **Step 2: Update `generate_pdf_report` call to pass overview data**
1724
+
1725
+ Replace the existing `generate_pdf_report(...)` call (around line 151-159) with:
1726
+
1727
+ ```python
1728
+ # Generate PDF report
1729
+ report_path = os.path.join(results_dir, "report.pdf")
1730
+ generate_pdf_report(
1731
+ aoi=job.request.aoi,
1732
+ time_range=job.request.time_range,
1733
+ results=job.results,
1734
+ output_path=report_path,
1735
+ summary_map_path=summary_map_path,
1736
+ indicator_map_paths=indicator_map_paths,
1737
+ overview_score=overview_score,
1738
+ overview_map_path=overview_map_path if true_color_path else "",
1739
+ )
1740
+ output_files.append(report_path)
1741
+ ```
1742
+
1743
+ - [ ] **Step 3: Run existing worker test to verify no regressions**
1744
+
1745
+ Run: `pytest tests/test_worker.py -v --tb=short`
1746
+ Expected: PASS (the worker test may need a minor mock update — if it fails, check that `compute_composite_score` doesn't break on mock results).
1747
+
1748
+ - [ ] **Step 4: Commit**
1749
+
1750
+ ```bash
1751
+ git add app/worker.py
1752
+ git commit -m "feat: integrate visual overview into worker pipeline"
1753
+ ```
1754
+
1755
+ ---
1756
+
1757
+ ### Task 8: Add overview page and summary table to PDF report
1758
+
1759
+ **Files:**
1760
+ - Modify: `app/outputs/report.py`
1761
+
1762
+ - [ ] **Step 1: Update `generate_pdf_report` signature to accept overview data**
1763
+
1764
+ In `app/outputs/report.py`, update the function signature (around line 224):
1765
+
1766
+ ```python
1767
+ def generate_pdf_report(
1768
+ *,
1769
+ aoi: AOI,
1770
+ time_range: TimeRange,
1771
+ results: Sequence[IndicatorResult],
1772
+ output_path: str,
1773
+ summary_map_path: str = "",
1774
+ indicator_map_paths: dict[str, str] | None = None,
1775
+ overview_score: dict | None = None,
1776
+ overview_map_path: str = "",
1777
+ ) -> None:
1778
+ ```
1779
+
1780
+ - [ ] **Step 2: Add overview page to the story, after the title block and before "How to Read"**
1781
+
1782
+ Insert the following after the title block's horizontal rule (after line ~307, the `story.append(Spacer(1, 6 * mm))` line that follows the `HRFlowable`):
1783
+
1784
+ ```python
1785
+ # ------------------------------------------------------------------ #
1786
+ # Visual Overview #
1787
+ # ------------------------------------------------------------------ #
1788
+ if overview_score:
1789
+ story.append(Paragraph("Area Overview", styles["section_heading"]))
1790
+
1791
+ # Overview map (full width)
1792
+ if overview_map_path and os.path.exists(overview_map_path):
1793
+ from reportlab.platypus import Image
1794
+ img = Image(overview_map_path, width=14 * cm, height=10.5 * cm)
1795
+ img.hAlign = "CENTER"
1796
+ story.append(img)
1797
+ story.append(Spacer(1, 3 * mm))
1798
+
1799
+ # Composite score badge + headline
1800
+ composite_status_enum = {
1801
+ "GREEN": StatusLevel.GREEN,
1802
+ "AMBER": StatusLevel.AMBER,
1803
+ "RED": StatusLevel.RED,
1804
+ }.get(overview_score.get("status", "RED"), StatusLevel.RED)
1805
+
1806
+ badge = _status_badge_table(composite_status_enum, styles)
1807
+ headline = Paragraph(
1808
+ overview_score.get("headline", ""),
1809
+ styles["indicator_headline"],
1810
+ )
1811
+ row = Table(
1812
+ [[badge, headline]],
1813
+ colWidths=[2.2 * cm, None],
1814
+ )
1815
+ row.setStyle(TableStyle([
1816
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
1817
+ ("LEFTPADDING", (1, 0), (1, 0), 8),
1818
+ ("TOPPADDING", (0, 0), (-1, -1), 0),
1819
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
1820
+ ]))
1821
+ story.append(row)
1822
+ story.append(Spacer(1, 4 * mm))
1823
+
1824
+ # Summary table: all indicators in compact grid
1825
+ summary_header = [
1826
+ Paragraph("<b>Indicator</b>", styles["body"]),
1827
+ Paragraph("<b>Status</b>", styles["body"]),
1828
+ Paragraph("<b>Trend</b>", styles["body"]),
1829
+ Paragraph("<b>Confidence</b>", styles["body"]),
1830
+ Paragraph("<b>Headline</b>", styles["body"]),
1831
+ ]
1832
+ summary_rows = [summary_header]
1833
+ for result in results:
1834
+ label = _indicator_label(result.indicator_id)
1835
+ status_color = STATUS_COLORS[result.status]
1836
+ status_cell = Paragraph(
1837
+ f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
1838
+ ParagraphStyle(
1839
+ "ov_badge",
1840
+ fontName="Helvetica-Bold",
1841
+ fontSize=7,
1842
+ textColor=colors.white,
1843
+ alignment=TA_CENTER,
1844
+ ),
1845
+ )
1846
+ summary_rows.append([
1847
+ Paragraph(label, styles["body_muted"]),
1848
+ status_cell,
1849
+ Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
1850
+ Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
1851
+ Paragraph(result.headline[:80], styles["body_muted"]),
1852
+ ])
1853
+
1854
+ ov_col_w = (PAGE_W - 2 * MARGIN)
1855
+ ov_table = Table(
1856
+ summary_rows,
1857
+ colWidths=[ov_col_w * 0.15, ov_col_w * 0.10, ov_col_w * 0.12, ov_col_w * 0.13, ov_col_w * 0.50],
1858
+ )
1859
+ ov_ts = TableStyle([
1860
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
1861
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
1862
+ ("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
1863
+ ("TOPPADDING", (0, 0), (-1, -1), 3),
1864
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
1865
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
1866
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
1867
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
1868
+ ("ALIGN", (1, 1), (1, -1), "CENTER"),
1869
+ ])
1870
+ for row_idx, result in enumerate(results, start=1):
1871
+ ov_ts.add("BACKGROUND", (1, row_idx), (1, row_idx), STATUS_COLORS[result.status])
1872
+ ov_table.setStyle(ov_ts)
1873
+ story.append(ov_table)
1874
+
1875
+ story.append(Spacer(1, 6 * mm))
1876
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
1877
+ story.append(Spacer(1, 4 * mm))
1878
+ ```
1879
+
1880
+ - [ ] **Step 3: Run report test to verify no regressions**
1881
+
1882
+ Run: `pytest tests/test_report.py -v --tb=short`
1883
+ Expected: PASS. The existing test passes `overview_score=None` by default (keyword arg defaults to None), so it should still work.
1884
+
1885
+ - [ ] **Step 4: Commit**
1886
+
1887
+ ```bash
1888
+ git add app/outputs/report.py
1889
+ git commit -m "feat: add overview page with composite score and summary table to PDF report"
1890
+ ```
1891
+
1892
+ ---
1893
+
1894
+ ### Task 9: Update display names and add overview outputs to package
1895
+
1896
+ **Files:**
1897
+ - Modify: `app/outputs/report.py` (display names)
1898
+ - Modify: `app/outputs/package.py` (no changes needed — already packages all `output_files`)
1899
+
1900
+ - [ ] **Step 1: Add display names for new indicators**
1901
+
1902
+ In `app/outputs/report.py`, update the `_DISPLAY_NAMES` dict (around line 28-31):
1903
+
1904
+ ```python
1905
+ _DISPLAY_NAMES: dict[str, str] = {
1906
+ "no2": "NO2",
1907
+ "lst": "LST",
1908
+ "sar": "SAR Backscatter",
1909
+ "buildup": "Settlement Extent",
1910
+ }
1911
+ ```
1912
+
1913
+ - [ ] **Step 2: Verify package already includes overview files**
1914
+
1915
+ The worker already appends `overview_score_path` and `overview_map_path` to `output_files` (from Task 7), and `create_data_package` iterates `output_files`. No changes needed to `package.py`.
1916
+
1917
+ Verify: `grep -n "output_files.append" app/worker.py` — confirm `overview_score_path` and `overview_map_path` are listed.
1918
+
1919
+ - [ ] **Step 3: Run full test suite**
1920
+
1921
+ Run: `pytest tests/ -v --tb=short`
1922
+ Expected: ALL PASS.
1923
+
1924
+ - [ ] **Step 4: Commit**
1925
+
1926
+ ```bash
1927
+ git add app/outputs/report.py
1928
+ git commit -m "feat: add display names for SAR and built-up indicators"
1929
+ ```
1930
+
1931
+ ---
1932
+
1933
+ ### Task 10: Full integration verification
1934
+
1935
+ - [ ] **Step 1: Count total tests**
1936
+
1937
+ Run: `pytest tests/ -v --tb=short 2>&1 | tail -5`
1938
+ Expected: All tests pass. Should be ~135+ tests (121 existing + ~14 new).
1939
+
1940
+ - [ ] **Step 2: Verify new indicators appear in API catalogue**
1941
+
1942
+ Run: `python -c "
1943
+ from app.indicators import registry
1944
+ cat = registry.catalogue()
1945
+ ids = [m.id for m in cat]
1946
+ print('Registered:', ids)
1947
+ assert 'sar' in ids, 'SAR not registered'
1948
+ assert 'buildup' in ids, 'Built-up not registered'
1949
+ print('Phase C indicators registered OK')
1950
+ "`
1951
+ Expected: Prints registered indicator list including `sar` and `buildup`.
1952
+
1953
+ - [ ] **Step 3: Verify imports are clean**
1954
+
1955
+ Run: `python -c "
1956
+ from app.indicators.sar import SarIndicator
1957
+ from app.indicators.buildup import BuiltupIndicator
1958
+ from app.outputs.overview import compute_composite_score, write_overview_score
1959
+ from app.outputs.maps import render_overview_map
1960
+ print('All Phase C imports OK')
1961
+ "`
1962
+ Expected: Prints `All Phase C imports OK`.
1963
+
1964
+ - [ ] **Step 4: Final commit (if any stray changes)**
1965
+
1966
+ ```bash
1967
+ git status
1968
+ # If clean, no commit needed.
1969
+ # If there are changes:
1970
+ git add -A && git commit -m "chore: Phase C integration cleanup"
1971
+ ```
docs/superpowers/specs/2026-03-31-phase-c-sar-buildup-overview-design.md ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase C Design Spec — SAR Backscatter, Built-up Extent, Visual Overview
2
+
3
+ **Date:** 2026-03-31
4
+ **Status:** Approved
5
+ **Depends on:** Phase A (openEO foundation), Phase B (indicator migration)
6
+
7
+ ## Summary
8
+
9
+ Phase C adds two new satellite indicators (SAR backscatter, built-up extent) and a visual overview post-processing step. Both indicators follow the established openEO graph builder + raster indicator pattern from Phases A and B. The visual overview is a worker post-processing step (not an indicator) that synthesizes results into a composite score and true-color satellite snapshot.
10
+
11
+ **Approach:** Two new `BaseIndicator` subclasses + post-processing in the worker pipeline + report enhancements.
12
+
13
+ ## Context
14
+
15
+ ### SR4S Heritage
16
+
17
+ The built-up indicator is informed by the SR4S research pipeline (Super-Resolution for Conflict Settlement Monitoring), which uses OpenSR latent diffusion to upscale Sentinel-2 to 2.5m and U-Net building segmentation for settlement detection. SR4S requires GPU and cannot run on Aperture's current cpu-basic HF Space. The index-based NDBI approach adopted here answers the same question (settlement extent and change) at 10m resolution without GPU, trading building-level precision for operational feasibility.
18
+
19
+ SR4S's validation methodology (Open Buildings V3, IOM DTM correlation) informs the thresholds and limitations documented below.
20
+
21
+ ### Architecture Fit
22
+
23
+ Both new indicators follow the exact pattern established in Phase B:
24
+
25
+ 1. openEO graph builder in `openeo_client.py` → downloads GeoTIFF
26
+ 2. Indicator `process()` reads raster, computes zonal statistics, classifies status
27
+ 3. Sets `SpatialData(map_type="raster", vmin, vmax, colormap)` for map rendering
28
+ 4. Returns `IndicatorResult` with chart data (monthly arrays + baseline)
29
+
30
+ The visual overview is a new post-processing step in the worker, alongside existing summary map, PDF report, and ZIP package generation.
31
+
32
+ ---
33
+
34
+ ## Indicator 1: SAR Backscatter (`sar`)
35
+
36
+ ### Data Source
37
+
38
+ **Sentinel-1 GRD IW** via CDSE openEO (`SENTINEL1_GRD` collection). VV and VH polarizations, ascending orbit direction preferred for radiometric consistency.
39
+
40
+ ### Graph Builder: `build_sar_graph(bbox, temporal_extent, resolution)`
41
+
42
+ - Load `SENTINEL1_GRD`, bands `VV` and `VH`
43
+ - Filter by orbit direction: ascending
44
+ - Convert linear backscatter to dB: `10 * log10(linear)`
45
+ - Aggregate: monthly median composites
46
+ - Resample to configurable resolution (default 100m via `config.RESOLUTION_M`)
47
+ - Output: multi-band GeoTIFF (months × 2 polarizations, interleaved: VV_m1, VH_m1, VV_m2, VH_m2, ...)
48
+
49
+ ### Analysis
50
+
51
+ The indicator splits monthly data into baseline (first half of time range) and analysis (second half) periods, consistent with all other indicators. Three analyses from the same monthly composites:
52
+
53
+ **1. Change detection**
54
+ - Mean VV backscatter (dB) in analysis period vs baseline period, per pixel
55
+ - Percentage of AOI area with >3 dB change = "significant change area"
56
+
57
+ **2. Time series monitoring**
58
+ - Monthly mean VV and VH over AOI
59
+ - Chart data: `dates` (YYYY-MM), `values` (monthly VV mean), `baseline_mean/min/max` arrays
60
+ - Same chart renderer as all other monthly indicators
61
+
62
+ **3. Flood / wet surface mapping**
63
+ - Flag pixels where VV < baseline_mean - 2σ as potential inundation
64
+ - Report: % of AOI flagged, number of anomalous months
65
+
66
+ ### Status Classification
67
+
68
+ | Status | Condition |
69
+ |--------|-----------|
70
+ | GREEN | <5% area with significant change, no flood anomalies |
71
+ | AMBER | 5–15% area changed OR 1–2 flood anomaly months |
72
+ | RED | >15% area changed OR ≥3 flood anomaly months |
73
+
74
+ ### Trend
75
+
76
+ - IMPROVING: Significant change area decreasing over analysis period
77
+ - STABLE: No clear directional trend
78
+ - DETERIORATING: Significant change area increasing over analysis period
79
+
80
+ ### Map Output
81
+
82
+ - Raster: VV change (analysis mean - baseline mean, dB) overlaid on true-color
83
+ - Colormap: `RdBu_r` (red = decrease / potential flood, blue = increase / potential construction)
84
+ - `vmin=-6`, `vmax=6`
85
+ - Label: "SAR VV Change (dB)"
86
+
87
+ ### Graceful Degradation
88
+
89
+ - **No Sentinel-1 scenes available:** Return `IndicatorResult` with `status=None`, `confidence=LOW`, headline "Insufficient SAR data for this area and time period", methodology explaining the data gap. Do not fail the job.
90
+ - **<3 months of data:** Proceed but set `confidence=LOW` and note in limitations.
91
+
92
+ ### Headline Examples
93
+
94
+ - "SAR detects 12% ground surface change, 2 potential flood events"
95
+ - "Stable backscatter conditions — no significant ground change detected"
96
+ - "Insufficient SAR data for this area and time period"
97
+
98
+ ---
99
+
100
+ ## Indicator 2: Built-up / Settlement Extent (`buildup`)
101
+
102
+ ### Data Source
103
+
104
+ **Sentinel-2 L2A** via CDSE openEO. Bands B04 (Red), B08 (NIR), B11 (SWIR), SCL (cloud mask). Same source as NDVI and MNDWI indicators, different band combination.
105
+
106
+ ### Graph Builder: `build_buildup_graph(bbox, temporal_extent, resolution)`
107
+
108
+ - Load `SENTINEL2_L2A`, bands `B04`, `B08`, `B11`, `SCL`
109
+ - Cloud mask using SCL classes 4, 5, 6 (vegetation, bare soil, water — same as existing graphs)
110
+ - Compute per pixel:
111
+ - **NDBI** = (B11 - B08) / (B11 + B08)
112
+ - **NDVI** = (B08 - B04) / (B08 + B04)
113
+ - Derive **built-up mask**: NDBI > 0 AND NDVI < 0.2
114
+ - Separates impervious surfaces from bare soil (high NDBI + moderate NDVI)
115
+ - Aggregate: monthly median for NDBI continuous values, monthly built-up fraction from binary mask
116
+ - Output: multi-band GeoTIFF with monthly NDBI values (one band per month). The binary built-up mask and NDVI are computed in the indicator's `process()` method from the downloaded NDBI + source bands, not in the openEO graph — this keeps the graph builder simple and consistent with the NDVI/MNDWI pattern.
117
+
118
+ ### Analysis
119
+
120
+ The indicator splits monthly data into baseline (first half of time range) and analysis (second half) periods, consistent with all other indicators.
121
+
122
+ **1. Settlement extent**
123
+ - Built-up area (hectares) per month from binary mask
124
+ - Baseline mean area vs analysis mean area
125
+
126
+ **2. Change detection**
127
+ - Percentage change in built-up fraction between baseline and analysis periods
128
+ - Monthly time series with baseline band + mean line (same chart pattern)
129
+
130
+ **3. Expansion mapping**
131
+ - Pixels built-up in analysis but not baseline = new settlement
132
+ - Pixels built-up in baseline but not analysis = destruction / abandonment
133
+ - Report: net change in hectares, expansion %, contraction %
134
+
135
+ ### Status Classification
136
+
137
+ | Status | Condition |
138
+ |--------|-----------|
139
+ | GREEN | Built-up area stable (< ±10% change from baseline) |
140
+ | AMBER | 10–30% change (expansion or contraction) |
141
+ | RED | >30% change — rapid urbanization or significant destruction |
142
+
143
+ ### Trend
144
+
145
+ - IMPROVING: Stable or gradual growth consistent with baseline trajectory
146
+ - STABLE: No significant change
147
+ - DETERIORATING: Rapid expansion (potential displacement) or contraction (potential destruction)
148
+
149
+ ### Map Output
150
+
151
+ - Raster: built-up change (analysis mask - baseline mask) overlaid on true-color
152
+ - Colormap: `PiYG` (pink = new built-up, green = lost built-up, white = no change)
153
+ - `vmin=-1`, `vmax=1`
154
+ - Label: "Built-up Change"
155
+
156
+ ### Headline Examples
157
+
158
+ - "Settlement area expanded 25% (320 → 400 ha) compared to baseline"
159
+ - "Built-up extent stable at approximately 850 ha"
160
+ - "Potential settlement contraction: 15% decrease in built-up area"
161
+
162
+ ### Limitations
163
+
164
+ - 10m resolution detects settlement *extent*, not individual buildings
165
+ - NDBI confuses bare rock/sand with built-up in arid landscapes — the NDVI < 0.2 filter mitigates but doesn't eliminate this
166
+ - Seasonal vegetation cycles can cause false positives at settlement fringes
167
+ - For building-level analysis, the SR4S pipeline (GPU-dependent) would be needed as a future upgrade
168
+
169
+ ---
170
+
171
+ ## Visual Overview (Post-processing Step)
172
+
173
+ The visual overview is NOT an indicator. It is a post-processing step in the worker pipeline that runs after all indicators complete, alongside summary map, PDF report, and ZIP package generation.
174
+
175
+ ### Component A: Composite Score
176
+
177
+ A single headline status (GREEN/AMBER/RED) with a numeric score (0–100) summarizing overall area conditions.
178
+
179
+ **Expert weights** (normalized to 1.0):
180
+
181
+ | Indicator | Weight | Rationale |
182
+ |-----------|--------|-----------|
183
+ | `fires` | 0.20 | Direct threat signal |
184
+ | `sar` | 0.15 | Ground-truth change detection |
185
+ | `buildup` | 0.15 | Settlement dynamics |
186
+ | `ndvi` | 0.12 | Vegetation / food production |
187
+ | `water` | 0.10 | Water availability |
188
+ | `rainfall` | 0.10 | Climate conditions |
189
+ | `lst` | 0.08 | Thermal stress |
190
+ | `no2` | 0.05 | Atmospheric disruption |
191
+ | `nightlights` | 0.05 | Economic activity proxy |
192
+
193
+ **Scoring logic:**
194
+ 1. Map each completed indicator's status to a score: GREEN=100, AMBER=50, RED=0
195
+ 2. Indicators with `status=None` (skipped/degraded) are excluded
196
+ 3. Re-normalize weights across only the completed indicators
197
+ 4. Weighted average → composite score
198
+ 5. Composite thresholds: ≥70 = GREEN, 40–69 = AMBER, <40 = RED
199
+
200
+ **Output file:** `results/{job_id}/overview_score.json`
201
+
202
+ ```json
203
+ {
204
+ "score": 54,
205
+ "status": "AMBER",
206
+ "headline": "Area conditions: AMBER (score 54/100) — fires and settlement expansion drive concern",
207
+ "weights_used": {"fires": 0.20, "ndvi": 0.12, "buildup": 0.15},
208
+ "per_indicator": {"fires": {"status": "RED", "score": 0}, "ndvi": {"status": "GREEN", "score": 100}, "buildup": {"status": "AMBER", "score": 50}}
209
+ }
210
+ ```
211
+
212
+ **Headline generation:**
213
+ - Identify the 1–2 indicators contributing most to a non-GREEN score
214
+ - Template: "Area conditions: {STATUS} (score {N}/100) — {driver description}"
215
+
216
+ ### Component B: True-Color Satellite Snapshot
217
+
218
+ A standalone cloud-free RGB composite of the AOI as visual context.
219
+
220
+ **Implementation:**
221
+ - Reuse the existing `build_true_color_graph()` from `openeo_client.py`
222
+ - If any raster indicator (NDVI, water, LST, SAR, buildup) already downloaded a true-color GeoTIFF during this job, reuse it — no second download
223
+ - If no raster indicator was selected, fetch true-color independently
224
+
225
+ **Rendering:** New function `render_overview_map(true_color_path, aoi, output_path)` in `maps.py`:
226
+ - Full-page true-color image (no indicator overlay)
227
+ - AOI boundary rectangle
228
+ - Title: "{AOI name} — Satellite Overview"
229
+ - Date range annotation
230
+ - Scale bar
231
+
232
+ **Output file:** `results/{job_id}/overview_map.png`
233
+
234
+ ### Report Integration
235
+
236
+ New first section in the PDF report, before individual indicator sections:
237
+
238
+ 1. **Overview page:**
239
+ - True-color satellite snapshot (full width)
240
+ - Composite score badge (large, colored: GREEN/AMBER/RED)
241
+ - One-line composite headline
242
+ 2. **Summary table:**
243
+ - All selected indicators in a compact grid: name, status badge, trend arrow, confidence, one-line headline
244
+ 3. **Individual indicator sections** follow as they do now
245
+
246
+ ### Worker Pipeline Changes
247
+
248
+ In `worker.py`, after the indicator processing loop completes and before existing output generation:
249
+
250
+ 1. Compute composite score from collected `IndicatorResult` list
251
+ 2. Locate or fetch true-color GeoTIFF
252
+ 3. Render overview map via `render_overview_map()`
253
+ 4. Write `overview_score.json`
254
+ 5. Pass composite score + overview map path to `generate_pdf_report()`
255
+ 6. Include both in ZIP package
256
+
257
+ ---
258
+
259
+ ## Files Modified
260
+
261
+ | File | Change |
262
+ |------|--------|
263
+ | `app/openeo_client.py` | Add `build_sar_graph()`, `build_buildup_graph()` |
264
+ | `app/indicators/sar.py` | New file: SAR backscatter indicator |
265
+ | `app/indicators/buildup.py` | New file: built-up extent indicator |
266
+ | `app/indicators/__init__.py` | Register `sar` and `buildup` indicators |
267
+ | `app/worker.py` | Add visual overview post-processing step |
268
+ | `app/outputs/maps.py` | Add `render_overview_map()` |
269
+ | `app/outputs/report.py` | Add overview page + summary table to PDF |
270
+ | `app/outputs/package.py` | Include overview outputs in ZIP |
271
+ | `app/config.py` | Add `OVERVIEW_WEIGHTS` dict |
272
+ | `tests/test_indicator_sar.py` | New file: SAR indicator tests |
273
+ | `tests/test_indicator_buildup.py` | New file: built-up indicator tests |
274
+ | `tests/test_overview.py` | New file: composite score + overview tests |
275
+ | `tests/test_openeo_client.py` | Add tests for new graph builders |
276
+
277
+ ## Dependencies
278
+
279
+ No new Python packages required. All processing uses existing dependencies:
280
+ - `openeo` — graph builders (already installed)
281
+ - `rasterio` — GeoTIFF reading (already installed)
282
+ - `numpy` — array math (already installed)
283
+ - `matplotlib` / `cartopy` — map rendering (already installed)
284
+ - `reportlab` — PDF generation (already installed)
285
+
286
+ ## Out of Scope
287
+
288
+ - SR4S U-Net model integration (requires GPU upgrade)
289
+ - User-configurable indicator weights (fixed expert weights for now)
290
+ - SAR interferometry / InSAR (requires SLC data, not GRD)
291
+ - Building footprint extraction