KSvend Claude Happy commited on
Commit
c766f2c
·
1 Parent(s): 0ca7a83

docs: add Phase A implementation plan — openEO NDVI pipeline

Browse files

9-task plan covering: openeo dependency, config module, openEO client,
test fixtures, raster map renderer, NDVI indicator, worker integration,
and end-to-end smoke test. All tasks are TDD with exact code and commands.

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-a-openeo-ndvi-pipeline.md ADDED
@@ -0,0 +1,1564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase A: openEO Client + NDVI Indicator + Raster Map Renderer — 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:** Prove the full CDSE openEO pipeline end-to-end — from processing graph submission through GeoTIFF download to raster-on-satellite-imagery map rendering in the PDF report — by implementing the NDVI vegetation indicator as the first openEO-powered indicator.
6
+
7
+ **Architecture:** A new `app/openeo_client.py` manages CDSE authentication and exposes graph-builder functions that return GeoTIFFs. A new `app/indicators/ndvi.py` replaces the old scene-metadata vegetation indicator with pixel-level NDVI composites. The map renderer (`app/outputs/maps.py`) gains a new `render_raster_map()` function that overlays indicator rasters on a true-color Sentinel-2 composite. Resolution is configurable via `app/config.py`.
8
+
9
+ **Tech Stack:** Python 3.11, openeo ≥0.28.0, rasterio, rioxarray, matplotlib, numpy
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-03-31-openeo-eo-upgrade-design.md`
12
+
13
+ ---
14
+
15
+ ## File Map
16
+
17
+ | File | Action | Responsibility |
18
+ |------|--------|----------------|
19
+ | `app/config.py` | Create | Centralized configuration constants (resolution, AOI limits, openEO backend URL) |
20
+ | `app/openeo_client.py` | Create | openEO connection management, authentication, generic graph execution |
21
+ | `app/indicators/ndvi.py` | Create | NDVI vegetation indicator using openEO (replaces `vegetation.py`) |
22
+ | `app/indicators/__init__.py` | Modify | Register `NdviIndicator`, keep `VegetationIndicator` as fallback |
23
+ | `app/outputs/maps.py` | Modify | Add `render_raster_map()` for raster-on-true-color rendering |
24
+ | `app/worker.py` | Modify | Pass true-color composite path to map renderer |
25
+ | `Dockerfile` | Modify | Add `openeo` dependency, keep cartopy for now (Phase D removal) |
26
+ | `pyproject.toml` | Modify | Add `openeo>=0.28.0` to dependencies |
27
+ | `tests/test_config.py` | Create | Test configuration defaults and env overrides |
28
+ | `tests/test_openeo_client.py` | Create | Test openEO graph builders with mocked connection |
29
+ | `tests/test_indicator_ndvi.py` | Create | Test NDVI indicator processing with mocked openEO |
30
+ | `tests/test_raster_maps.py` | Create | Test raster map rendering with sample GeoTIFFs |
31
+ | `tests/fixtures/` | Create | Sample GeoTIFF files for unit tests |
32
+
33
+ ---
34
+
35
+ ### Task 1: Add `openeo` Dependency
36
+
37
+ **Files:**
38
+ - Modify: `pyproject.toml:6-26`
39
+ - Modify: `Dockerfile:25-35`
40
+
41
+ - [ ] **Step 1: Add openeo to pyproject.toml**
42
+
43
+ In `pyproject.toml`, add `"openeo>=0.28.0"` to the dependencies list. In the dependencies array, after the `"planetary-computer>=1.0.0"` line, add:
44
+
45
+ ```toml
46
+ "openeo>=0.28.0",
47
+ ```
48
+
49
+ - [ ] **Step 2: Add openeo to Dockerfile builder stage**
50
+
51
+ In `Dockerfile`, in the second `RUN pip install` block (line 25-35), add `"openeo>=0.28.0"` after `"planetary-computer>=1.0.0"`:
52
+
53
+ ```dockerfile
54
+ RUN pip install --no-cache-dir --prefer-binary \
55
+ "fastapi>=0.110.0" \
56
+ "uvicorn[standard]>=0.27.0" \
57
+ "aiosqlite>=0.20.0" \
58
+ "pydantic>=2.6.0" \
59
+ "httpx>=0.27.0" \
60
+ "pystac-client>=0.7.0" \
61
+ "stackstac>=0.5.0" \
62
+ "reportlab>=4.1.0" \
63
+ "tqdm>=4.66.0" \
64
+ "planetary-computer>=1.0.0" \
65
+ "openeo>=0.28.0"
66
+ ```
67
+
68
+ - [ ] **Step 3: Install openeo locally**
69
+
70
+ Run: `cd /Users/kmini/Github/Aperture && pip install "openeo>=0.28.0"`
71
+
72
+ Expected: Installs successfully. The `openeo` package is pure Python with minimal native deps.
73
+
74
+ - [ ] **Step 4: Verify import**
75
+
76
+ Run: `cd /Users/kmini/Github/Aperture && python -c "import openeo; print(openeo.__version__)"`
77
+
78
+ Expected: Prints version number (0.28.x or higher).
79
+
80
+ - [ ] **Step 5: Commit**
81
+
82
+ ```bash
83
+ git add pyproject.toml Dockerfile
84
+ git commit -m "build: add openeo dependency for CDSE processing"
85
+ ```
86
+
87
+ ---
88
+
89
+ ### Task 2: Create Configuration Module
90
+
91
+ **Files:**
92
+ - Create: `app/config.py`
93
+ - Create: `tests/test_config.py`
94
+
95
+ - [ ] **Step 1: Write tests for config**
96
+
97
+ Create `tests/test_config.py`:
98
+
99
+ ```python
100
+ """Tests for app.config — centralized configuration."""
101
+ import os
102
+ import pytest
103
+
104
+
105
+ def test_default_resolution():
106
+ """Default resolution is 100m (free-tier HF Space)."""
107
+ from app.config import RESOLUTION_M
108
+ assert RESOLUTION_M == 100
109
+
110
+
111
+ def test_default_max_aoi():
112
+ """Default max AOI is 500 km²."""
113
+ from app.config import MAX_AOI_KM2
114
+ assert MAX_AOI_KM2 == 500
115
+
116
+
117
+ def test_default_openeo_backend():
118
+ """Default openEO backend is CDSE."""
119
+ from app.config import OPENEO_BACKEND
120
+ assert OPENEO_BACKEND == "openeo.dataspace.copernicus.eu"
121
+
122
+
123
+ def test_resolution_env_override(monkeypatch):
124
+ """APERTURE_RESOLUTION_M env var overrides the default."""
125
+ monkeypatch.setenv("APERTURE_RESOLUTION_M", "20")
126
+ # Force reimport to pick up env var
127
+ import importlib
128
+ import app.config
129
+ importlib.reload(app.config)
130
+ assert app.config.RESOLUTION_M == 20
131
+ # Reset
132
+ monkeypatch.delenv("APERTURE_RESOLUTION_M", raising=False)
133
+ importlib.reload(app.config)
134
+ ```
135
+
136
+ - [ ] **Step 2: Run tests to verify they fail**
137
+
138
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_config.py -v`
139
+
140
+ Expected: FAIL — `ModuleNotFoundError: No module named 'app.config'`
141
+
142
+ - [ ] **Step 3: Create config module**
143
+
144
+ Create `app/config.py`:
145
+
146
+ ```python
147
+ """Centralized configuration for Aperture."""
148
+ from __future__ import annotations
149
+
150
+ import os
151
+
152
+ # Spatial resolution in meters for openEO processing.
153
+ # 100m for free-tier HF Space (2GB RAM). Set to 10 or 20 for upgraded hardware.
154
+ RESOLUTION_M: int = int(os.environ.get("APERTURE_RESOLUTION_M", "100"))
155
+
156
+ # Maximum AOI size in km². Soft limit to prevent excessive processing.
157
+ MAX_AOI_KM2: int = int(os.environ.get("APERTURE_MAX_AOI_KM2", "500"))
158
+
159
+ # openEO backend URL.
160
+ OPENEO_BACKEND: str = os.environ.get(
161
+ "OPENEO_BACKEND", "openeo.dataspace.copernicus.eu"
162
+ )
163
+
164
+ # CDSE OAuth2 credentials (set as secrets in HF Spaces).
165
+ OPENEO_CLIENT_ID: str | None = os.environ.get("OPENEO_CLIENT_ID")
166
+ OPENEO_CLIENT_SECRET: str | None = os.environ.get("OPENEO_CLIENT_SECRET")
167
+ ```
168
+
169
+ - [ ] **Step 4: Run tests to verify they pass**
170
+
171
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_config.py -v`
172
+
173
+ Expected: All PASS.
174
+
175
+ - [ ] **Step 5: Commit**
176
+
177
+ ```bash
178
+ git add app/config.py tests/test_config.py
179
+ git commit -m "feat: add centralized config module with resolution and openEO settings"
180
+ ```
181
+
182
+ ---
183
+
184
+ ### Task 3: Create openEO Client Module
185
+
186
+ **Files:**
187
+ - Create: `app/openeo_client.py`
188
+ - Create: `tests/test_openeo_client.py`
189
+
190
+ - [ ] **Step 1: Write test for connection factory**
191
+
192
+ Create `tests/test_openeo_client.py`:
193
+
194
+ ```python
195
+ """Tests for app.openeo_client — openEO connection and graph builders."""
196
+ from __future__ import annotations
197
+
198
+ from unittest.mock import MagicMock, patch
199
+ import pytest
200
+
201
+
202
+ def test_get_connection_creates_authenticated_connection():
203
+ """get_connection() connects to CDSE and authenticates."""
204
+ mock_conn = MagicMock()
205
+ with patch("openeo.connect", return_value=mock_conn) as mock_connect:
206
+ from app.openeo_client import get_connection
207
+ conn = get_connection()
208
+
209
+ mock_connect.assert_called_once_with("openeo.dataspace.copernicus.eu")
210
+ mock_conn.authenticate_oidc.assert_called_once()
211
+ assert conn is mock_conn
212
+
213
+
214
+ def test_get_connection_reuses_cached():
215
+ """Subsequent calls return the same connection object."""
216
+ mock_conn = MagicMock()
217
+ with patch("openeo.connect", return_value=mock_conn):
218
+ from app.openeo_client import get_connection, _reset_connection
219
+ _reset_connection() # Clear any cached connection
220
+ conn1 = get_connection()
221
+ conn2 = get_connection()
222
+ assert conn1 is conn2
223
+ _reset_connection()
224
+ ```
225
+
226
+ - [ ] **Step 2: Write test for NDVI graph builder**
227
+
228
+ Append to `tests/test_openeo_client.py`:
229
+
230
+ ```python
231
+ def test_build_ndvi_graph():
232
+ """build_ndvi_graph() returns a datacube with NDVI computation."""
233
+ mock_conn = MagicMock()
234
+ mock_cube = MagicMock()
235
+ mock_conn.load_collection.return_value = mock_cube
236
+
237
+ # Mock the band operations
238
+ mock_b08 = MagicMock()
239
+ mock_b04 = MagicMock()
240
+ mock_cube.band.side_effect = lambda b: mock_b08 if b == "B08" else mock_b04
241
+ mock_ndvi = MagicMock()
242
+ mock_b08.__sub__ = MagicMock(return_value=MagicMock())
243
+ mock_b08.__add__ = MagicMock(return_value=MagicMock())
244
+ (mock_b08 - mock_b04).__truediv__ = MagicMock(return_value=mock_ndvi)
245
+
246
+ from app.openeo_client import build_ndvi_graph
247
+
248
+ bbox = {"west": 32.45, "south": 15.65, "east": 32.65, "north": 15.8}
249
+ result = build_ndvi_graph(
250
+ conn=mock_conn,
251
+ bbox=bbox,
252
+ temporal_extent=["2025-03-01", "2026-03-01"],
253
+ resolution_m=100,
254
+ )
255
+
256
+ mock_conn.load_collection.assert_called_once()
257
+ call_kwargs = mock_conn.load_collection.call_args
258
+ assert call_kwargs[1]["collection_id"] == "SENTINEL2_L2A"
259
+ assert call_kwargs[1]["spatial_extent"] == bbox
260
+ assert "B04" in call_kwargs[1]["bands"]
261
+ assert "B08" in call_kwargs[1]["bands"]
262
+ assert "SCL" in call_kwargs[1]["bands"]
263
+
264
+
265
+ def test_build_true_color_graph():
266
+ """build_true_color_graph() returns a datacube with RGB bands."""
267
+ mock_conn = MagicMock()
268
+ mock_cube = MagicMock()
269
+ mock_conn.load_collection.return_value = mock_cube
270
+
271
+ from app.openeo_client import build_true_color_graph
272
+
273
+ bbox = {"west": 32.45, "south": 15.65, "east": 32.65, "north": 15.8}
274
+ result = build_true_color_graph(
275
+ conn=mock_conn,
276
+ bbox=bbox,
277
+ temporal_extent=["2025-03-01", "2026-03-01"],
278
+ resolution_m=100,
279
+ )
280
+
281
+ mock_conn.load_collection.assert_called_once()
282
+ call_kwargs = mock_conn.load_collection.call_args
283
+ bands = call_kwargs[1]["bands"]
284
+ assert "B04" in bands
285
+ assert "B03" in bands
286
+ assert "B02" in bands
287
+ ```
288
+
289
+ - [ ] **Step 3: Run tests to verify they fail**
290
+
291
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_openeo_client.py -v`
292
+
293
+ Expected: FAIL — `ModuleNotFoundError: No module named 'app.openeo_client'`
294
+
295
+ - [ ] **Step 4: Implement openeo_client module**
296
+
297
+ Create `app/openeo_client.py`:
298
+
299
+ ```python
300
+ """openEO connection management and processing graph builders.
301
+
302
+ All heavy raster processing happens server-side on CDSE. This module
303
+ defines the processing graphs and handles authentication. The returned
304
+ datacubes are downloaded as GeoTIFFs by indicator code.
305
+ """
306
+ from __future__ import annotations
307
+
308
+ import logging
309
+ from typing import Any
310
+
311
+ import openeo
312
+
313
+ from app.config import OPENEO_BACKEND, OPENEO_CLIENT_ID, OPENEO_CLIENT_SECRET
314
+
315
+ logger = logging.getLogger(__name__)
316
+
317
+ _connection: openeo.Connection | None = None
318
+
319
+
320
+ def get_connection() -> openeo.Connection:
321
+ """Get or create an authenticated openEO connection to CDSE.
322
+
323
+ The connection is cached for reuse across indicator calls within a job.
324
+ """
325
+ global _connection
326
+ if _connection is not None:
327
+ return _connection
328
+
329
+ logger.info("Connecting to openEO backend: %s", OPENEO_BACKEND)
330
+ conn = openeo.connect(OPENEO_BACKEND)
331
+
332
+ if OPENEO_CLIENT_ID and OPENEO_CLIENT_SECRET:
333
+ conn.authenticate_oidc(
334
+ client_id=OPENEO_CLIENT_ID,
335
+ client_secret=OPENEO_CLIENT_SECRET,
336
+ )
337
+ else:
338
+ conn.authenticate_oidc()
339
+
340
+ _connection = conn
341
+ return conn
342
+
343
+
344
+ def _reset_connection() -> None:
345
+ """Reset the cached connection (for testing)."""
346
+ global _connection
347
+ _connection = None
348
+
349
+
350
+ def _bbox_dict(bbox: list[float]) -> dict[str, float]:
351
+ """Convert [min_lon, min_lat, max_lon, max_lat] to openEO spatial_extent."""
352
+ return {
353
+ "west": bbox[0],
354
+ "south": bbox[1],
355
+ "east": bbox[2],
356
+ "north": bbox[3],
357
+ }
358
+
359
+
360
+ def build_ndvi_graph(
361
+ *,
362
+ conn: openeo.Connection,
363
+ bbox: dict[str, float],
364
+ temporal_extent: list[str],
365
+ resolution_m: int = 100,
366
+ ) -> openeo.DataCube:
367
+ """Build an openEO process graph for monthly median NDVI composites.
368
+
369
+ Loads Sentinel-2 L2A, masks clouds using the SCL band, computes
370
+ NDVI = (B08 - B04) / (B08 + B04), and aggregates to monthly medians.
371
+
372
+ Returns an openEO DataCube (not yet executed).
373
+ """
374
+ cube = conn.load_collection(
375
+ collection_id="SENTINEL2_L2A",
376
+ spatial_extent=bbox,
377
+ temporal_extent=temporal_extent,
378
+ bands=["B04", "B08", "SCL"],
379
+ )
380
+
381
+ # Cloud mask: keep only vegetation, bare soil, water (SCL classes 4,5,6)
382
+ scl = cube.band("SCL")
383
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
384
+ cube = cube.mask(~cloud_mask)
385
+
386
+ # NDVI
387
+ b08 = cube.band("B08")
388
+ b04 = cube.band("B04")
389
+ ndvi = (b08 - b04) / (b08 + b04)
390
+
391
+ # Monthly median composite
392
+ monthly = ndvi.aggregate_temporal_period("month", reducer="median")
393
+
394
+ # Resample to target resolution
395
+ if resolution_m > 10:
396
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320)
397
+
398
+ return monthly
399
+
400
+
401
+ def build_true_color_graph(
402
+ *,
403
+ conn: openeo.Connection,
404
+ bbox: dict[str, float],
405
+ temporal_extent: list[str],
406
+ resolution_m: int = 100,
407
+ ) -> openeo.DataCube:
408
+ """Build an openEO process graph for a true-color Sentinel-2 composite.
409
+
410
+ Loads B04 (Red), B03 (Green), B02 (Blue), masks clouds,
411
+ and creates a median composite over the temporal extent.
412
+
413
+ Returns an openEO DataCube (not yet executed).
414
+ """
415
+ cube = conn.load_collection(
416
+ collection_id="SENTINEL2_L2A",
417
+ spatial_extent=bbox,
418
+ temporal_extent=temporal_extent,
419
+ bands=["B02", "B03", "B04", "SCL"],
420
+ )
421
+
422
+ # Cloud mask
423
+ scl = cube.band("SCL")
424
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
425
+ cube = cube.mask(~cloud_mask)
426
+
427
+ # Drop SCL, keep RGB
428
+ rgb = cube.filter_bands(["B02", "B03", "B04"])
429
+
430
+ # Temporal median composite
431
+ composite = rgb.reduce_dimension(dimension="t", reducer="median")
432
+
433
+ # Resample
434
+ if resolution_m > 10:
435
+ composite = composite.resample_spatial(resolution=resolution_m / 111320)
436
+
437
+ return composite
438
+ ```
439
+
440
+ - [ ] **Step 5: Run tests**
441
+
442
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_openeo_client.py -v`
443
+
444
+ Expected: All PASS.
445
+
446
+ - [ ] **Step 6: Commit**
447
+
448
+ ```bash
449
+ git add app/openeo_client.py tests/test_openeo_client.py
450
+ git commit -m "feat: add openEO client with NDVI and true-color graph builders"
451
+ ```
452
+
453
+ ---
454
+
455
+ ### Task 4: Create Test GeoTIFF Fixtures
456
+
457
+ **Files:**
458
+ - Create: `tests/fixtures/create_fixtures.py` (generator script)
459
+ - Create: `tests/fixtures/ndvi_monthly.tif` (generated)
460
+ - Create: `tests/fixtures/true_color.tif` (generated)
461
+ - Create: `tests/conftest.py` (modify — add fixture paths)
462
+
463
+ - [ ] **Step 1: Create fixtures directory**
464
+
465
+ Run: `mkdir -p /Users/kmini/Github/Aperture/tests/fixtures`
466
+
467
+ - [ ] **Step 2: Create fixture generator script**
468
+
469
+ Create `tests/fixtures/create_fixtures.py`:
470
+
471
+ ```python
472
+ """Generate small synthetic GeoTIFFs for unit tests.
473
+
474
+ Run this script once to create the test fixtures:
475
+ python tests/fixtures/create_fixtures.py
476
+ """
477
+ from __future__ import annotations
478
+
479
+ import os
480
+ import numpy as np
481
+ import rasterio
482
+ from rasterio.transform import from_bounds
483
+
484
+ FIXTURES_DIR = os.path.dirname(__file__)
485
+
486
+ # AOI: small region near Khartoum (~20x15 pixels at 100m = 2km x 1.5km)
487
+ WEST, SOUTH, EAST, NORTH = 32.45, 15.65, 32.65, 15.8
488
+ WIDTH, HEIGHT = 22, 17 # ~100m pixels
489
+
490
+
491
+ def _transform():
492
+ return from_bounds(WEST, SOUTH, EAST, NORTH, WIDTH, HEIGHT)
493
+
494
+
495
+ def create_ndvi_monthly():
496
+ """12-band GeoTIFF: monthly median NDVI (Jan-Dec), float32, range -0.2 to 0.9."""
497
+ rng = np.random.default_rng(42)
498
+ path = os.path.join(FIXTURES_DIR, "ndvi_monthly.tif")
499
+ data = np.zeros((12, HEIGHT, WIDTH), dtype=np.float32)
500
+ for month in range(12):
501
+ # Seasonal NDVI pattern: peak in rainy season (Jul-Sep)
502
+ base = 0.25 + 0.35 * np.sin(np.pi * (month - 3) / 6)
503
+ data[month] = base + rng.normal(0, 0.05, (HEIGHT, WIDTH))
504
+ data = np.clip(data, -0.2, 0.9)
505
+
506
+ with rasterio.open(
507
+ path, "w", driver="GTiff",
508
+ height=HEIGHT, width=WIDTH, count=12,
509
+ dtype="float32", crs="EPSG:4326",
510
+ transform=_transform(), nodata=-9999.0,
511
+ ) as dst:
512
+ for i in range(12):
513
+ dst.write(data[i], i + 1)
514
+ print(f"Created {path} ({os.path.getsize(path)} bytes)")
515
+
516
+
517
+ def create_true_color():
518
+ """3-band GeoTIFF: RGB (B04, B03, B02) reflectance, uint16, range 0-10000."""
519
+ rng = np.random.default_rng(43)
520
+ path = os.path.join(FIXTURES_DIR, "true_color.tif")
521
+ data = np.zeros((3, HEIGHT, WIDTH), dtype=np.uint16)
522
+ # Semi-arid landscape: brownish-green
523
+ data[0] = rng.integers(800, 1500, (HEIGHT, WIDTH)) # Red
524
+ data[1] = rng.integers(700, 1300, (HEIGHT, WIDTH)) # Green
525
+ data[2] = rng.integers(500, 1000, (HEIGHT, WIDTH)) # Blue
526
+
527
+ with rasterio.open(
528
+ path, "w", driver="GTiff",
529
+ height=HEIGHT, width=WIDTH, count=3,
530
+ dtype="uint16", crs="EPSG:4326",
531
+ transform=_transform(), nodata=0,
532
+ ) as dst:
533
+ for i in range(3):
534
+ dst.write(data[i], i + 1)
535
+ print(f"Created {path} ({os.path.getsize(path)} bytes)")
536
+
537
+
538
+ if __name__ == "__main__":
539
+ create_ndvi_monthly()
540
+ create_true_color()
541
+ print("Done.")
542
+ ```
543
+
544
+ - [ ] **Step 3: Generate the fixtures**
545
+
546
+ Run: `cd /Users/kmini/Github/Aperture && python tests/fixtures/create_fixtures.py`
547
+
548
+ Expected: Two files created in `tests/fixtures/` (~5-15KB each).
549
+
550
+ - [ ] **Step 4: Add fixture paths to conftest**
551
+
552
+ Read the current `tests/conftest.py` first, then append the fixture paths. Add to `tests/conftest.py`:
553
+
554
+ ```python
555
+ import os
556
+ import pytest
557
+
558
+ FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
559
+
560
+
561
+ @pytest.fixture
562
+ def ndvi_monthly_tif():
563
+ """Path to a 12-band synthetic NDVI monthly GeoTIFF."""
564
+ path = os.path.join(FIXTURES_DIR, "ndvi_monthly.tif")
565
+ if not os.path.exists(path):
566
+ pytest.skip("Test fixtures not generated. Run: python tests/fixtures/create_fixtures.py")
567
+ return path
568
+
569
+
570
+ @pytest.fixture
571
+ def true_color_tif():
572
+ """Path to a 3-band synthetic true-color GeoTIFF."""
573
+ path = os.path.join(FIXTURES_DIR, "true_color.tif")
574
+ if not os.path.exists(path):
575
+ pytest.skip("Test fixtures not generated. Run: python tests/fixtures/create_fixtures.py")
576
+ return path
577
+ ```
578
+
579
+ - [ ] **Step 5: Verify fixtures load**
580
+
581
+ Run: `cd /Users/kmini/Github/Aperture && python -c "import rasterio; ds = rasterio.open('tests/fixtures/ndvi_monthly.tif'); print(f'NDVI: {ds.count} bands, {ds.width}x{ds.height}'); ds = rasterio.open('tests/fixtures/true_color.tif'); print(f'RGB: {ds.count} bands, {ds.width}x{ds.height}')"`
582
+
583
+ Expected:
584
+ ```
585
+ NDVI: 12 bands, 22x17
586
+ RGB: 3 bands, 22x17
587
+ ```
588
+
589
+ - [ ] **Step 6: Commit**
590
+
591
+ ```bash
592
+ git add tests/fixtures/ tests/conftest.py
593
+ git commit -m "test: add synthetic GeoTIFF fixtures for raster map tests"
594
+ ```
595
+
596
+ ---
597
+
598
+ ### Task 5: Add Raster Map Renderer
599
+
600
+ **Files:**
601
+ - Modify: `app/outputs/maps.py:1-202`
602
+ - Create: `tests/test_raster_maps.py`
603
+
604
+ - [ ] **Step 1: Write test for raster-on-true-color map rendering**
605
+
606
+ Create `tests/test_raster_maps.py`:
607
+
608
+ ```python
609
+ """Tests for raster map rendering — indicator overlaid on true-color composite."""
610
+ from __future__ import annotations
611
+
612
+ import os
613
+ import tempfile
614
+ import pytest
615
+
616
+ from app.models import AOI, StatusLevel
617
+
618
+
619
+ @pytest.fixture
620
+ def test_aoi():
621
+ return AOI(name="Test", bbox=[32.45, 15.65, 32.65, 15.8])
622
+
623
+
624
+ def test_render_raster_map_produces_png(test_aoi, ndvi_monthly_tif, true_color_tif):
625
+ """render_raster_map() produces a valid PNG from GeoTIFFs."""
626
+ from app.outputs.maps import render_raster_map
627
+
628
+ with tempfile.TemporaryDirectory() as tmpdir:
629
+ out = os.path.join(tmpdir, "ndvi_map.png")
630
+ render_raster_map(
631
+ true_color_path=true_color_tif,
632
+ indicator_path=ndvi_monthly_tif,
633
+ indicator_band=7, # July (peak NDVI)
634
+ aoi=test_aoi,
635
+ status=StatusLevel.GREEN,
636
+ output_path=out,
637
+ cmap="RdYlGn",
638
+ vmin=-0.2,
639
+ vmax=0.9,
640
+ label="NDVI",
641
+ )
642
+ assert os.path.exists(out)
643
+ assert os.path.getsize(out) > 5000 # Reasonable PNG size
644
+
645
+
646
+ def test_render_raster_map_without_true_color(test_aoi, ndvi_monthly_tif):
647
+ """render_raster_map() works without a true-color base (just indicator)."""
648
+ from app.outputs.maps import render_raster_map
649
+
650
+ with tempfile.TemporaryDirectory() as tmpdir:
651
+ out = os.path.join(tmpdir, "ndvi_no_base.png")
652
+ render_raster_map(
653
+ true_color_path=None,
654
+ indicator_path=ndvi_monthly_tif,
655
+ indicator_band=7,
656
+ aoi=test_aoi,
657
+ status=StatusLevel.AMBER,
658
+ output_path=out,
659
+ cmap="RdYlGn",
660
+ vmin=-0.2,
661
+ vmax=0.9,
662
+ label="NDVI",
663
+ )
664
+ assert os.path.exists(out)
665
+ assert os.path.getsize(out) > 3000
666
+
667
+
668
+ def test_render_true_color_standalone(test_aoi, true_color_tif):
669
+ """render_raster_map() renders just the true-color composite when no indicator."""
670
+ from app.outputs.maps import render_raster_map
671
+
672
+ with tempfile.TemporaryDirectory() as tmpdir:
673
+ out = os.path.join(tmpdir, "rgb_only.png")
674
+ render_raster_map(
675
+ true_color_path=true_color_tif,
676
+ indicator_path=None,
677
+ indicator_band=1,
678
+ aoi=test_aoi,
679
+ status=StatusLevel.GREEN,
680
+ output_path=out,
681
+ label="True Color",
682
+ )
683
+ assert os.path.exists(out)
684
+ assert os.path.getsize(out) > 3000
685
+ ```
686
+
687
+ - [ ] **Step 2: Run tests to verify they fail**
688
+
689
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_raster_maps.py -v`
690
+
691
+ Expected: FAIL — `ImportError: cannot import name 'render_raster_map' from 'app.outputs.maps'`
692
+
693
+ - [ ] **Step 3: Implement render_raster_map()**
694
+
695
+ Add to the bottom of `app/outputs/maps.py` (after the existing `_render_grid` function, line 201):
696
+
697
+ ```python
698
+ def render_raster_map(
699
+ *,
700
+ true_color_path: str | None,
701
+ indicator_path: str | None,
702
+ indicator_band: int = 1,
703
+ aoi: AOI,
704
+ status: StatusLevel,
705
+ output_path: str,
706
+ cmap: str = "RdYlGn",
707
+ vmin: float | None = None,
708
+ vmax: float | None = None,
709
+ alpha: float = 0.6,
710
+ label: str = "",
711
+ ) -> None:
712
+ """Render an indicator raster overlaid on a true-color Sentinel-2 composite.
713
+
714
+ Parameters
715
+ ----------
716
+ true_color_path:
717
+ Path to a 3-band (R,G,B) GeoTIFF. If None, renders on plain background.
718
+ indicator_path:
719
+ Path to indicator GeoTIFF. Reads band `indicator_band`. If None,
720
+ renders only the true-color composite.
721
+ indicator_band:
722
+ 1-based band index to read from the indicator GeoTIFF.
723
+ aoi:
724
+ AOI for bounding box overlay.
725
+ status:
726
+ Traffic-light status — used for AOI outline color.
727
+ output_path:
728
+ Path to write the output PNG.
729
+ cmap:
730
+ Matplotlib colormap name for the indicator overlay.
731
+ vmin, vmax:
732
+ Value range for the indicator colormap.
733
+ alpha:
734
+ Transparency of the indicator overlay (0=transparent, 1=opaque).
735
+ label:
736
+ Colorbar label string.
737
+ """
738
+ import rasterio
739
+
740
+ fig, ax = plt.subplots(figsize=(6, 5), dpi=200, facecolor=SHELL)
741
+ ax.set_facecolor(SHELL)
742
+
743
+ extent = None
744
+
745
+ # Render true-color base layer
746
+ if true_color_path is not None:
747
+ with rasterio.open(true_color_path) as src:
748
+ rgb = src.read([1, 2, 3]).astype(np.float32)
749
+ extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
750
+ # Sentinel-2 reflectance scaling (values typically 0-10000)
751
+ rgb_max = max(rgb.max(), 1.0)
752
+ scale = 3000.0 if rgb_max > 255 else 255.0
753
+ rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
754
+ ax.imshow(rgb_normalized, extent=extent, aspect="auto", zorder=0)
755
+
756
+ # Render indicator raster overlay
757
+ if indicator_path is not None:
758
+ with rasterio.open(indicator_path) as src:
759
+ data = src.read(indicator_band).astype(np.float32)
760
+ nodata = src.nodata
761
+ ind_extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
762
+ if extent is None:
763
+ extent = ind_extent
764
+ masked = np.ma.masked_where(
765
+ (data == nodata) if nodata is not None else np.zeros_like(data, dtype=bool),
766
+ data,
767
+ )
768
+ im = ax.imshow(
769
+ masked, extent=ind_extent, cmap=cmap, alpha=alpha,
770
+ vmin=vmin, vmax=vmax, aspect="auto", zorder=1,
771
+ )
772
+ cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.04, shrink=0.85)
773
+ cbar.set_label(label, fontsize=7, color=INK_MUTED)
774
+ cbar.ax.tick_params(labelsize=6, colors=INK_MUTED)
775
+
776
+ # AOI outline
777
+ if extent is not None:
778
+ ax.set_xlim(extent[0], extent[1])
779
+ ax.set_ylim(extent[2], extent[3])
780
+ color = STATUS_COLORS[status]
781
+ _draw_aoi_rect(ax, aoi, color)
782
+
783
+ # Axis labels
784
+ ax.tick_params(labelsize=6, colors=INK_MUTED)
785
+ ax.set_xlabel("Longitude", fontsize=7, color=INK_MUTED)
786
+ ax.set_ylabel("Latitude", fontsize=7, color=INK_MUTED)
787
+
788
+ # Clean spines
789
+ for spine in ax.spines.values():
790
+ spine.set_color(INK_MUTED)
791
+ spine.set_linewidth(0.5)
792
+
793
+ plt.tight_layout()
794
+ fig.savefig(output_path, dpi=200, bbox_inches="tight", facecolor=SHELL)
795
+ plt.close(fig)
796
+ ```
797
+
798
+ Also add `import rasterio` check at the top of the file won't be needed — we import inside the function to keep the module loadable even if rasterio isn't installed (existing pattern in this file with cartopy).
799
+
800
+ - [ ] **Step 4: Run tests**
801
+
802
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_raster_maps.py -v`
803
+
804
+ Expected: All PASS.
805
+
806
+ - [ ] **Step 5: Run existing map tests to verify no regressions**
807
+
808
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_maps.py -v`
809
+
810
+ Expected: All existing tests still PASS.
811
+
812
+ - [ ] **Step 6: Commit**
813
+
814
+ ```bash
815
+ git add app/outputs/maps.py tests/test_raster_maps.py
816
+ git commit -m "feat: add raster-on-true-color map renderer (render_raster_map)"
817
+ ```
818
+
819
+ ---
820
+
821
+ ### Task 6: Create NDVI Indicator
822
+
823
+ **Files:**
824
+ - Create: `app/indicators/ndvi.py`
825
+ - Create: `tests/test_indicator_ndvi.py`
826
+
827
+ - [ ] **Step 1: Write test for NDVI indicator with mocked openEO**
828
+
829
+ Create `tests/test_indicator_ndvi.py`:
830
+
831
+ ```python
832
+ """Tests for app.indicators.ndvi — pixel-level NDVI via openEO."""
833
+ from __future__ import annotations
834
+
835
+ import os
836
+ import tempfile
837
+ from unittest.mock import MagicMock, patch, AsyncMock
838
+ from datetime import date
839
+
840
+ import numpy as np
841
+ import pytest
842
+
843
+ from app.models import AOI, TimeRange, StatusLevel, TrendDirection, ConfidenceLevel
844
+
845
+
846
+ @pytest.fixture
847
+ def test_aoi():
848
+ return AOI(name="Test Khartoum", bbox=[32.45, 15.65, 32.65, 15.8])
849
+
850
+
851
+ @pytest.fixture
852
+ def test_time_range():
853
+ return TimeRange(start=date(2025, 3, 1), end=date(2026, 3, 1))
854
+
855
+
856
+ def _mock_ndvi_tif(path: str, n_months: int = 12):
857
+ """Create a small synthetic NDVI GeoTIFF at the given path."""
858
+ import rasterio
859
+ from rasterio.transform import from_bounds
860
+ rng = np.random.default_rng(42)
861
+ data = np.zeros((n_months, 10, 10), dtype=np.float32)
862
+ for m in range(n_months):
863
+ data[m] = 0.3 + 0.2 * np.sin(np.pi * (m - 3) / 6) + rng.normal(0, 0.02, (10, 10))
864
+ with rasterio.open(
865
+ path, "w", driver="GTiff", height=10, width=10, count=n_months,
866
+ dtype="float32", crs="EPSG:4326",
867
+ transform=from_bounds(32.45, 15.65, 32.65, 15.8, 10, 10),
868
+ nodata=-9999.0,
869
+ ) as dst:
870
+ for i in range(n_months):
871
+ dst.write(data[i], i + 1)
872
+
873
+
874
+ def _mock_true_color_tif(path: str):
875
+ """Create a small synthetic true-color GeoTIFF."""
876
+ import rasterio
877
+ from rasterio.transform import from_bounds
878
+ rng = np.random.default_rng(43)
879
+ data = rng.integers(500, 1500, (3, 10, 10), dtype=np.uint16)
880
+ with rasterio.open(
881
+ path, "w", driver="GTiff", height=10, width=10, count=3,
882
+ dtype="uint16", crs="EPSG:4326",
883
+ transform=from_bounds(32.45, 15.65, 32.65, 15.8, 10, 10),
884
+ nodata=0,
885
+ ) as dst:
886
+ for i in range(3):
887
+ dst.write(data[i], i + 1)
888
+
889
+
890
+ @pytest.mark.asyncio
891
+ async def test_ndvi_process_returns_indicator_result(test_aoi, test_time_range):
892
+ """NdviIndicator.process() returns a valid IndicatorResult."""
893
+ from app.indicators.ndvi import NdviIndicator
894
+
895
+ indicator = NdviIndicator()
896
+
897
+ with tempfile.TemporaryDirectory() as tmpdir:
898
+ ndvi_path = os.path.join(tmpdir, "ndvi.tif")
899
+ rgb_path = os.path.join(tmpdir, "rgb.tif")
900
+ _mock_ndvi_tif(ndvi_path)
901
+ _mock_true_color_tif(rgb_path)
902
+
903
+ # Mock the openEO download to write our test files
904
+ mock_cube = MagicMock()
905
+ mock_cube.download = MagicMock()
906
+
907
+ with patch("app.indicators.ndvi.get_connection") as mock_get_conn, \
908
+ patch("app.indicators.ndvi.build_ndvi_graph", return_value=mock_cube) as mock_ndvi, \
909
+ patch("app.indicators.ndvi.build_true_color_graph", return_value=mock_cube) as mock_rgb:
910
+
911
+ # When download() is called, copy our fixture to the target path
912
+ def fake_download(path, **kwargs):
913
+ import shutil
914
+ if "ndvi" in path:
915
+ shutil.copy(ndvi_path, path)
916
+ else:
917
+ shutil.copy(rgb_path, path)
918
+
919
+ mock_cube.download.side_effect = fake_download
920
+
921
+ result = await indicator.process(test_aoi, test_time_range)
922
+
923
+ assert result.indicator_id == "ndvi"
924
+ assert result.status in (StatusLevel.GREEN, StatusLevel.AMBER, StatusLevel.RED)
925
+ assert result.trend in (TrendDirection.IMPROVING, TrendDirection.STABLE, TrendDirection.DETERIORATING)
926
+ assert result.confidence in (ConfidenceLevel.HIGH, ConfidenceLevel.MODERATE, ConfidenceLevel.LOW)
927
+ assert "NDVI" in result.methodology or "ndvi" in result.methodology.lower()
928
+ assert len(result.chart_data.get("dates", [])) > 0
929
+ assert len(result.chart_data.get("values", [])) > 0
930
+
931
+
932
+ @pytest.mark.asyncio
933
+ async def test_ndvi_falls_back_to_vegetation_on_failure(test_aoi, test_time_range):
934
+ """NdviIndicator falls back gracefully when openEO fails."""
935
+ from app.indicators.ndvi import NdviIndicator
936
+
937
+ indicator = NdviIndicator()
938
+
939
+ with patch("app.indicators.ndvi.get_connection", side_effect=Exception("CDSE down")):
940
+ result = await indicator.process(test_aoi, test_time_range)
941
+
942
+ assert result.indicator_id == "ndvi"
943
+ assert result.data_source == "placeholder"
944
+
945
+
946
+ def test_ndvi_compute_stats():
947
+ """_compute_stats() extracts correct statistics from a multi-band raster."""
948
+ from app.indicators.ndvi import NdviIndicator
949
+
950
+ with tempfile.TemporaryDirectory() as tmpdir:
951
+ path = os.path.join(tmpdir, "ndvi.tif")
952
+ _mock_ndvi_tif(path, n_months=12)
953
+
954
+ stats = NdviIndicator._compute_stats(path)
955
+
956
+ assert "monthly_means" in stats
957
+ assert len(stats["monthly_means"]) == 12
958
+ assert "overall_mean" in stats
959
+ assert 0 < stats["overall_mean"] < 1
960
+ assert "valid_months" in stats
961
+ assert stats["valid_months"] == 12
962
+ ```
963
+
964
+ - [ ] **Step 2: Run tests to verify they fail**
965
+
966
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_ndvi.py -v`
967
+
968
+ Expected: FAIL — `ModuleNotFoundError: No module named 'app.indicators.ndvi'`
969
+
970
+ - [ ] **Step 3: Implement NdviIndicator**
971
+
972
+ Create `app/indicators/ndvi.py`:
973
+
974
+ ```python
975
+ """NDVI Vegetation Indicator — pixel-level via CDSE openEO.
976
+
977
+ Computes monthly median NDVI composites from Sentinel-2 L2A, compares
978
+ current period to a 5-year baseline, and classifies vegetation change
979
+ using percentage-point anomaly thresholds.
980
+ """
981
+ from __future__ import annotations
982
+
983
+ import logging
984
+ import os
985
+ import tempfile
986
+ from datetime import date
987
+ from typing import Any
988
+
989
+ import numpy as np
990
+ import rasterio
991
+
992
+ from app.config import RESOLUTION_M
993
+ from app.indicators.base import BaseIndicator, SpatialData
994
+ from app.models import (
995
+ AOI,
996
+ TimeRange,
997
+ IndicatorResult,
998
+ StatusLevel,
999
+ TrendDirection,
1000
+ ConfidenceLevel,
1001
+ )
1002
+ from app.openeo_client import get_connection, build_ndvi_graph, build_true_color_graph, _bbox_dict
1003
+
1004
+ logger = logging.getLogger(__name__)
1005
+
1006
+ BASELINE_YEARS = 5
1007
+
1008
+
1009
+ class NdviIndicator(BaseIndicator):
1010
+ id = "ndvi"
1011
+ name = "Vegetation (NDVI)"
1012
+ category = "D2"
1013
+ question = "Is vegetation cover declining?"
1014
+ estimated_minutes = 8
1015
+
1016
+ _true_color_path: str | None = None
1017
+
1018
+ async def process(
1019
+ self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
1020
+ ) -> IndicatorResult:
1021
+ try:
1022
+ return await self._process_openeo(aoi, time_range, season_months)
1023
+ except Exception as exc:
1024
+ logger.warning("NDVI openEO processing failed, using placeholder: %s", exc)
1025
+ return self._fallback(aoi, time_range)
1026
+
1027
+ async def _process_openeo(
1028
+ self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None
1029
+ ) -> IndicatorResult:
1030
+ import asyncio
1031
+
1032
+ conn = get_connection()
1033
+ bbox = _bbox_dict(aoi.bbox)
1034
+
1035
+ current_start = time_range.start.isoformat()
1036
+ current_end = time_range.end.isoformat()
1037
+
1038
+ # Baseline period: BASELINE_YEARS before the current start
1039
+ baseline_start = date(
1040
+ time_range.start.year - BASELINE_YEARS,
1041
+ time_range.start.month,
1042
+ time_range.start.day,
1043
+ ).isoformat()
1044
+ baseline_end = date(
1045
+ time_range.start.year,
1046
+ time_range.start.month,
1047
+ time_range.start.day,
1048
+ ).isoformat()
1049
+
1050
+ results_dir = tempfile.mkdtemp(prefix="aperture_ndvi_")
1051
+
1052
+ # Build processing graphs
1053
+ current_cube = build_ndvi_graph(
1054
+ conn=conn, bbox=bbox,
1055
+ temporal_extent=[current_start, current_end],
1056
+ resolution_m=RESOLUTION_M,
1057
+ )
1058
+ baseline_cube = build_ndvi_graph(
1059
+ conn=conn, bbox=bbox,
1060
+ temporal_extent=[baseline_start, baseline_end],
1061
+ resolution_m=RESOLUTION_M,
1062
+ )
1063
+ true_color_cube = build_true_color_graph(
1064
+ conn=conn, bbox=bbox,
1065
+ temporal_extent=[current_start, current_end],
1066
+ resolution_m=RESOLUTION_M,
1067
+ )
1068
+
1069
+ # Download results (sequential to manage memory on free tier)
1070
+ loop = asyncio.get_event_loop()
1071
+ current_path = os.path.join(results_dir, "ndvi_current.tif")
1072
+ baseline_path = os.path.join(results_dir, "ndvi_baseline.tif")
1073
+ true_color_path = os.path.join(results_dir, "true_color.tif")
1074
+
1075
+ await loop.run_in_executor(None, current_cube.download, current_path)
1076
+ await loop.run_in_executor(None, baseline_cube.download, baseline_path)
1077
+ await loop.run_in_executor(None, true_color_cube.download, true_color_path)
1078
+
1079
+ self._true_color_path = true_color_path
1080
+
1081
+ # Compute statistics
1082
+ current_stats = self._compute_stats(current_path)
1083
+ baseline_stats = self._compute_stats(baseline_path)
1084
+
1085
+ current_mean = current_stats["overall_mean"]
1086
+ baseline_mean = baseline_stats["overall_mean"]
1087
+ change = current_mean - baseline_mean
1088
+
1089
+ status = self._classify(change)
1090
+ trend = self._compute_trend(change)
1091
+ confidence = (
1092
+ ConfidenceLevel.HIGH if current_stats["valid_months"] >= 6
1093
+ else ConfidenceLevel.MODERATE if current_stats["valid_months"] >= 3
1094
+ else ConfidenceLevel.LOW
1095
+ )
1096
+
1097
+ # Build chart data
1098
+ chart_data = self._build_chart_data(
1099
+ current_stats["monthly_means"],
1100
+ baseline_stats["monthly_means"],
1101
+ time_range,
1102
+ )
1103
+
1104
+ # Headline
1105
+ if abs(change) <= 0.05:
1106
+ headline = f"Vegetation stable (NDVI {current_mean:.2f}, Δ{change:+.2f} vs baseline)"
1107
+ elif change > 0:
1108
+ headline = f"Vegetation greening (NDVI +{change:.2f} vs baseline)"
1109
+ else:
1110
+ headline = f"Vegetation decline (NDVI {change:.2f} vs baseline)"
1111
+
1112
+ # Spatial data — store the current NDVI path for map rendering
1113
+ self._spatial_data = SpatialData(
1114
+ map_type="raster",
1115
+ label="NDVI",
1116
+ colormap="RdYlGn",
1117
+ )
1118
+ # Store paths for the worker to use
1119
+ self._indicator_raster_path = current_path
1120
+ self._true_color_path = true_color_path
1121
+ self._ndvi_peak_band = current_stats["peak_month_band"]
1122
+
1123
+ return IndicatorResult(
1124
+ indicator_id=self.id,
1125
+ headline=headline,
1126
+ status=status,
1127
+ trend=trend,
1128
+ confidence=confidence,
1129
+ map_layer_path=current_path,
1130
+ chart_data=chart_data,
1131
+ data_source="satellite",
1132
+ summary=(
1133
+ f"Mean NDVI is {current_mean:.3f} compared to a {BASELINE_YEARS}-year "
1134
+ f"baseline of {baseline_mean:.3f} (Δ{change:+.3f}). "
1135
+ f"Pixel-level analysis at {RESOLUTION_M}m resolution from "
1136
+ f"{current_stats['valid_months']} monthly composites."
1137
+ ),
1138
+ methodology=(
1139
+ f"Sentinel-2 L2A pixel-level NDVI = (B08 − B04) / (B08 + B04). "
1140
+ f"Cloud-masked using SCL band (classes 4, 5, 6 retained). "
1141
+ f"Monthly median composites at {RESOLUTION_M}m resolution. "
1142
+ f"Baseline: {BASELINE_YEARS}-year monthly medians. "
1143
+ f"Processed server-side via CDSE openEO."
1144
+ ),
1145
+ limitations=[
1146
+ f"Resampled to {RESOLUTION_M}m — sub-field variability not captured at this resolution.",
1147
+ "Cloud cover reduces observation count in rainy seasons.",
1148
+ "NDVI does not distinguish crop from natural vegetation.",
1149
+ "Seasonal variation may mask long-term trends if analysis windows differ.",
1150
+ ],
1151
+ )
1152
+
1153
+ @staticmethod
1154
+ def _compute_stats(tif_path: str) -> dict[str, Any]:
1155
+ """Extract monthly and overall NDVI statistics from a multi-band GeoTIFF."""
1156
+ with rasterio.open(tif_path) as src:
1157
+ n_bands = src.count
1158
+ monthly_means = []
1159
+ peak_val = -999.0
1160
+ peak_band = 1
1161
+ for band in range(1, n_bands + 1):
1162
+ data = src.read(band).astype(np.float32)
1163
+ nodata = src.nodata
1164
+ if nodata is not None:
1165
+ valid = data[data != nodata]
1166
+ else:
1167
+ valid = data.ravel()
1168
+ if len(valid) > 0:
1169
+ mean = float(np.nanmean(valid))
1170
+ monthly_means.append(mean)
1171
+ if mean > peak_val:
1172
+ peak_val = mean
1173
+ peak_band = band
1174
+ else:
1175
+ monthly_means.append(0.0)
1176
+
1177
+ valid_months = sum(1 for m in monthly_means if m > 0)
1178
+ overall_mean = float(np.mean([m for m in monthly_means if m > 0])) if valid_months > 0 else 0.0
1179
+
1180
+ return {
1181
+ "monthly_means": monthly_means,
1182
+ "overall_mean": overall_mean,
1183
+ "valid_months": valid_months,
1184
+ "peak_month_band": peak_band,
1185
+ }
1186
+
1187
+ @staticmethod
1188
+ def _classify(change: float) -> StatusLevel:
1189
+ """Classify NDVI anomaly into traffic-light status."""
1190
+ if change >= -0.05:
1191
+ return StatusLevel.GREEN
1192
+ if change >= -0.15:
1193
+ return StatusLevel.AMBER
1194
+ return StatusLevel.RED
1195
+
1196
+ @staticmethod
1197
+ def _compute_trend(change: float) -> TrendDirection:
1198
+ if abs(change) <= 0.05:
1199
+ return TrendDirection.STABLE
1200
+ if change > 0:
1201
+ return TrendDirection.IMPROVING
1202
+ return TrendDirection.DETERIORATING
1203
+
1204
+ @staticmethod
1205
+ def _build_chart_data(
1206
+ current_monthly: list[float],
1207
+ baseline_monthly: list[float],
1208
+ time_range: TimeRange,
1209
+ ) -> dict[str, Any]:
1210
+ """Build chart data with monthly NDVI values and baseline band."""
1211
+ year = time_range.end.year
1212
+ n = min(len(current_monthly), len(baseline_monthly))
1213
+ dates = [f"{year}-{m + 1:02d}" for m in range(n)]
1214
+ values = [round(v, 3) for v in current_monthly[:n]]
1215
+ b_mean = [round(v, 3) for v in baseline_monthly[:n]]
1216
+
1217
+ # For baseline band, use mean ± 0.05 as approximate range
1218
+ # (proper per-year min/max requires storing all baseline years)
1219
+ b_min = [round(max(v - 0.05, -0.2), 3) for v in baseline_monthly[:n]]
1220
+ b_max = [round(min(v + 0.05, 0.9), 3) for v in baseline_monthly[:n]]
1221
+
1222
+ return {
1223
+ "dates": dates,
1224
+ "values": values,
1225
+ "baseline_mean": b_mean,
1226
+ "baseline_min": b_min,
1227
+ "baseline_max": b_max,
1228
+ "label": "NDVI",
1229
+ }
1230
+
1231
+ def _fallback(self, aoi: AOI, time_range: TimeRange) -> IndicatorResult:
1232
+ """Return a placeholder result when openEO processing fails."""
1233
+ rng = np.random.default_rng(7)
1234
+ baseline = float(rng.uniform(0.25, 0.45))
1235
+ current = baseline * float(rng.uniform(0.90, 1.02))
1236
+ change = current - baseline
1237
+
1238
+ return IndicatorResult(
1239
+ indicator_id=self.id,
1240
+ headline=f"Vegetation data degraded (NDVI ≈{current:.2f})",
1241
+ status=self._classify(change),
1242
+ trend=self._compute_trend(change),
1243
+ confidence=ConfidenceLevel.LOW,
1244
+ map_layer_path="",
1245
+ chart_data={
1246
+ "dates": [str(time_range.start.year), str(time_range.end.year)],
1247
+ "values": [round(baseline, 3), round(current, 3)],
1248
+ "label": "NDVI",
1249
+ },
1250
+ data_source="placeholder",
1251
+ summary="openEO processing unavailable. Showing placeholder values.",
1252
+ methodology="Placeholder — no satellite data processed.",
1253
+ limitations=["Data is synthetic. openEO backend was unreachable."],
1254
+ )
1255
+ ```
1256
+
1257
+ - [ ] **Step 4: Run tests**
1258
+
1259
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_ndvi.py -v`
1260
+
1261
+ Expected: All PASS.
1262
+
1263
+ - [ ] **Step 5: Commit**
1264
+
1265
+ ```bash
1266
+ git add app/indicators/ndvi.py tests/test_indicator_ndvi.py
1267
+ git commit -m "feat: add pixel-level NDVI indicator via CDSE openEO"
1268
+ ```
1269
+
1270
+ ---
1271
+
1272
+ ### Task 7: Register NDVI Indicator and Update Worker
1273
+
1274
+ **Files:**
1275
+ - Modify: `app/indicators/__init__.py:1-22`
1276
+ - Modify: `app/indicators/base.py:13-22` (SpatialData — add `raster` map_type support)
1277
+ - Modify: `app/worker.py:91-106` (map rendering — handle raster type)
1278
+
1279
+ - [ ] **Step 1: Add NDVI to indicator registry**
1280
+
1281
+ In `app/indicators/__init__.py`, add the import and registration. Replace the full file:
1282
+
1283
+ ```python
1284
+ from app.indicators.base import IndicatorRegistry
1285
+ from app.indicators.fires import FiresIndicator
1286
+ from app.indicators.cropland import CroplandIndicator
1287
+ from app.indicators.vegetation import VegetationIndicator
1288
+ from app.indicators.rainfall import RainfallIndicator
1289
+ from app.indicators.water import WaterIndicator
1290
+ from app.indicators.no2 import NO2Indicator
1291
+ from app.indicators.lst import LSTIndicator
1292
+ from app.indicators.nightlights import NightlightsIndicator
1293
+ from app.indicators.food_security import FoodSecurityIndicator
1294
+ from app.indicators.ndvi import NdviIndicator
1295
+
1296
+ registry = IndicatorRegistry()
1297
+ registry.register(NdviIndicator())
1298
+ registry.register(FiresIndicator())
1299
+ registry.register(CroplandIndicator())
1300
+ registry.register(VegetationIndicator())
1301
+ registry.register(RainfallIndicator())
1302
+ registry.register(WaterIndicator())
1303
+ registry.register(NO2Indicator())
1304
+ registry.register(LSTIndicator())
1305
+ registry.register(NightlightsIndicator())
1306
+ registry.register(FoodSecurityIndicator())
1307
+ ```
1308
+
1309
+ - [ ] **Step 2: Update worker to handle raster map type**
1310
+
1311
+ In `app/worker.py`, replace lines 91-106 (the map generation block inside the `for result in job.results:` loop):
1312
+
1313
+ ```python
1314
+ # Generate map PNG for every indicator
1315
+ spatial = spatial_cache.get(result.indicator_id)
1316
+ map_path = os.path.join(results_dir, f"{result.indicator_id}_map.png")
1317
+
1318
+ if spatial is not None and spatial.map_type == "raster":
1319
+ # New: raster-on-true-color rendering for openEO indicators
1320
+ indicator_obj = registry.get(result.indicator_id)
1321
+ raster_path = getattr(indicator_obj, '_indicator_raster_path', None)
1322
+ true_color_path = getattr(indicator_obj, '_true_color_path', None)
1323
+ peak_band = getattr(indicator_obj, '_ndvi_peak_band', 1)
1324
+ from app.outputs.maps import render_raster_map
1325
+ render_raster_map(
1326
+ true_color_path=true_color_path,
1327
+ indicator_path=raster_path,
1328
+ indicator_band=peak_band,
1329
+ aoi=job.request.aoi,
1330
+ status=result.status,
1331
+ output_path=map_path,
1332
+ cmap=spatial.colormap,
1333
+ vmin=-0.2 if "ndvi" in result.indicator_id else None,
1334
+ vmax=0.9 if "ndvi" in result.indicator_id else None,
1335
+ label=spatial.label,
1336
+ )
1337
+ elif spatial is not None:
1338
+ render_indicator_map(
1339
+ spatial=spatial,
1340
+ aoi=job.request.aoi,
1341
+ status=result.status,
1342
+ output_path=map_path,
1343
+ )
1344
+ else:
1345
+ render_status_map(
1346
+ aoi=job.request.aoi,
1347
+ status=result.status,
1348
+ output_path=map_path,
1349
+ )
1350
+ ```
1351
+
1352
+ - [ ] **Step 3: Run existing tests to verify no regressions**
1353
+
1354
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_worker.py tests/test_indicator_base.py -v`
1355
+
1356
+ Expected: All PASS.
1357
+
1358
+ - [ ] **Step 4: Run the full test suite**
1359
+
1360
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v --timeout=60`
1361
+
1362
+ Expected: All tests PASS. The NDVI indicator tests pass with mocked openEO, existing indicators are unaffected.
1363
+
1364
+ - [ ] **Step 5: Commit**
1365
+
1366
+ ```bash
1367
+ git add app/indicators/__init__.py app/worker.py
1368
+ git commit -m "feat: register NDVI indicator and add raster map support to worker"
1369
+ ```
1370
+
1371
+ ---
1372
+
1373
+ ### Task 8: End-to-End Smoke Test
1374
+
1375
+ This task verifies the full pipeline works with mocked openEO — from indicator processing through map rendering to report generation.
1376
+
1377
+ **Files:**
1378
+ - Create: `tests/test_ndvi_e2e.py`
1379
+
1380
+ - [ ] **Step 1: Write end-to-end test**
1381
+
1382
+ Create `tests/test_ndvi_e2e.py`:
1383
+
1384
+ ```python
1385
+ """End-to-end test: NDVI indicator → raster map → chart → report section.
1386
+
1387
+ Uses mocked openEO (no CDSE credentials needed) with synthetic GeoTIFFs.
1388
+ """
1389
+ from __future__ import annotations
1390
+
1391
+ import os
1392
+ import tempfile
1393
+ from unittest.mock import MagicMock, patch
1394
+ from datetime import date
1395
+
1396
+ import numpy as np
1397
+ import rasterio
1398
+ from rasterio.transform import from_bounds
1399
+ import pytest
1400
+
1401
+ from app.models import AOI, TimeRange, StatusLevel
1402
+ from app.outputs.charts import render_timeseries_chart
1403
+ from app.outputs.maps import render_raster_map
1404
+
1405
+
1406
+ BBOX = [32.45, 15.65, 32.65, 15.8]
1407
+ WIDTH, HEIGHT = 15, 12
1408
+
1409
+
1410
+ def _write_ndvi_tif(path: str):
1411
+ rng = np.random.default_rng(42)
1412
+ data = np.zeros((12, HEIGHT, WIDTH), dtype=np.float32)
1413
+ for m in range(12):
1414
+ data[m] = 0.3 + 0.2 * np.sin(np.pi * (m - 3) / 6) + rng.normal(0, 0.03, (HEIGHT, WIDTH))
1415
+ with rasterio.open(
1416
+ path, "w", driver="GTiff", height=HEIGHT, width=WIDTH, count=12,
1417
+ dtype="float32", crs="EPSG:4326",
1418
+ transform=from_bounds(*BBOX, WIDTH, HEIGHT), nodata=-9999.0,
1419
+ ) as dst:
1420
+ for i in range(12):
1421
+ dst.write(data[i], i + 1)
1422
+
1423
+
1424
+ def _write_rgb_tif(path: str):
1425
+ rng = np.random.default_rng(43)
1426
+ data = rng.integers(500, 1500, (3, HEIGHT, WIDTH), dtype=np.uint16)
1427
+ with rasterio.open(
1428
+ path, "w", driver="GTiff", height=HEIGHT, width=WIDTH, count=3,
1429
+ dtype="uint16", crs="EPSG:4326",
1430
+ transform=from_bounds(*BBOX, WIDTH, HEIGHT), nodata=0,
1431
+ ) as dst:
1432
+ for i in range(3):
1433
+ dst.write(data[i], i + 1)
1434
+
1435
+
1436
+ @pytest.mark.asyncio
1437
+ async def test_ndvi_full_pipeline():
1438
+ """Full pipeline: process NDVI → render raster map → render chart."""
1439
+ from app.indicators.ndvi import NdviIndicator
1440
+
1441
+ aoi = AOI(name="Khartoum Test", bbox=BBOX)
1442
+ time_range = TimeRange(start=date(2025, 3, 1), end=date(2026, 3, 1))
1443
+
1444
+ with tempfile.TemporaryDirectory() as tmpdir:
1445
+ ndvi_path = os.path.join(tmpdir, "ndvi.tif")
1446
+ rgb_path = os.path.join(tmpdir, "rgb.tif")
1447
+ _write_ndvi_tif(ndvi_path)
1448
+ _write_rgb_tif(rgb_path)
1449
+
1450
+ mock_cube = MagicMock()
1451
+
1452
+ def fake_download(path, **kwargs):
1453
+ import shutil
1454
+ if "ndvi" in path:
1455
+ shutil.copy(ndvi_path, path)
1456
+ else:
1457
+ shutil.copy(rgb_path, path)
1458
+
1459
+ mock_cube.download = MagicMock(side_effect=fake_download)
1460
+
1461
+ with patch("app.indicators.ndvi.get_connection") as mock_conn, \
1462
+ patch("app.indicators.ndvi.build_ndvi_graph", return_value=mock_cube), \
1463
+ patch("app.indicators.ndvi.build_true_color_graph", return_value=mock_cube):
1464
+
1465
+ indicator = NdviIndicator()
1466
+ result = await indicator.process(aoi, time_range)
1467
+
1468
+ # Verify result quality
1469
+ assert result.indicator_id == "ndvi"
1470
+ assert result.data_source == "satellite"
1471
+ assert "NDVI" in result.methodology
1472
+ assert "pixel" in result.methodology.lower()
1473
+ assert len(result.chart_data["dates"]) >= 6
1474
+ assert all(isinstance(v, float) for v in result.chart_data["values"])
1475
+
1476
+ # Render the raster map
1477
+ map_out = os.path.join(tmpdir, "ndvi_map.png")
1478
+ raster_path = indicator._indicator_raster_path
1479
+ tc_path = indicator._true_color_path
1480
+ peak = indicator._ndvi_peak_band
1481
+
1482
+ render_raster_map(
1483
+ true_color_path=tc_path,
1484
+ indicator_path=raster_path,
1485
+ indicator_band=peak,
1486
+ aoi=aoi,
1487
+ status=result.status,
1488
+ output_path=map_out,
1489
+ cmap="RdYlGn",
1490
+ vmin=-0.2,
1491
+ vmax=0.9,
1492
+ label="NDVI",
1493
+ )
1494
+ assert os.path.exists(map_out)
1495
+ assert os.path.getsize(map_out) > 10000 # Real map should be >10KB
1496
+
1497
+ # Render the chart
1498
+ chart_out = os.path.join(tmpdir, "ndvi_chart.png")
1499
+ render_timeseries_chart(
1500
+ chart_data=result.chart_data,
1501
+ indicator_name="Vegetation (NDVI)",
1502
+ status=result.status,
1503
+ trend=result.trend,
1504
+ output_path=chart_out,
1505
+ y_label="NDVI",
1506
+ )
1507
+ assert os.path.exists(chart_out)
1508
+ assert os.path.getsize(chart_out) > 5000
1509
+ ```
1510
+
1511
+ - [ ] **Step 2: Run the e2e test**
1512
+
1513
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_ndvi_e2e.py -v`
1514
+
1515
+ Expected: PASS — the full pipeline works with mocked openEO.
1516
+
1517
+ - [ ] **Step 3: Run the complete test suite**
1518
+
1519
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v --timeout=120`
1520
+
1521
+ Expected: All tests PASS, including new and existing tests.
1522
+
1523
+ - [ ] **Step 4: Commit**
1524
+
1525
+ ```bash
1526
+ git add tests/test_ndvi_e2e.py
1527
+ git commit -m "test: add end-to-end smoke test for NDVI openEO pipeline"
1528
+ ```
1529
+
1530
+ ---
1531
+
1532
+ ### Task 9: Final Verification and Cleanup
1533
+
1534
+ - [ ] **Step 1: Run full test suite one more time**
1535
+
1536
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v --timeout=120 2>&1 | tail -30`
1537
+
1538
+ Expected: All tests PASS. Note the total count — should be the existing ~121 tests plus the ~10 new tests.
1539
+
1540
+ - [ ] **Step 2: Verify no import cycles**
1541
+
1542
+ Run: `cd /Users/kmini/Github/Aperture && python -c "from app.indicators import registry; print('Registered:', registry.list_ids())"`
1543
+
1544
+ Expected: Prints the list including `ndvi` alongside existing indicators.
1545
+
1546
+ - [ ] **Step 3: Verify the config module loads cleanly**
1547
+
1548
+ Run: `cd /Users/kmini/Github/Aperture && python -c "from app.config import RESOLUTION_M, OPENEO_BACKEND; print(f'Resolution: {RESOLUTION_M}m, Backend: {OPENEO_BACKEND}')"`
1549
+
1550
+ Expected: `Resolution: 100m, Backend: openeo.dataspace.copernicus.eu`
1551
+
1552
+ - [ ] **Step 4: Check git status**
1553
+
1554
+ Run: `cd /Users/kmini/Github/Aperture && git log --oneline -8`
1555
+
1556
+ Expected: 8 new commits from this plan:
1557
+ 1. `build: add openeo dependency for CDSE processing`
1558
+ 2. `feat: add centralized config module with resolution and openEO settings`
1559
+ 3. `feat: add openEO client with NDVI and true-color graph builders`
1560
+ 4. `test: add synthetic GeoTIFF fixtures for raster map tests`
1561
+ 5. `feat: add raster-on-true-color map renderer (render_raster_map)`
1562
+ 6. `feat: add pixel-level NDVI indicator via CDSE openEO`
1563
+ 7. `feat: register NDVI indicator and add raster map support to worker`
1564
+ 8. `test: add end-to-end smoke test for NDVI openEO pipeline`