KSvend Claude Happy commited on
Commit
866c331
·
1 Parent(s): 364b8f4

feat: add openEO client with NDVI and true-color graph builders

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>

Files changed (2) hide show
  1. app/openeo_client.py +138 -0
  2. tests/test_openeo_client.py +80 -0
app/openeo_client.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """openEO connection management and processing graph builders.
2
+
3
+ All heavy raster processing happens server-side on CDSE. This module
4
+ defines the processing graphs and handles authentication. The returned
5
+ datacubes are downloaded as GeoTIFFs by indicator code.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ import openeo
13
+
14
+ from app.config import OPENEO_BACKEND, OPENEO_CLIENT_ID, OPENEO_CLIENT_SECRET
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _connection: openeo.Connection | None = None
19
+
20
+
21
+ def get_connection() -> openeo.Connection:
22
+ """Get or create an authenticated openEO connection to CDSE.
23
+
24
+ The connection is cached for reuse across indicator calls within a job.
25
+ """
26
+ global _connection
27
+ if _connection is not None:
28
+ return _connection
29
+
30
+ logger.info("Connecting to openEO backend: %s", OPENEO_BACKEND)
31
+ conn = openeo.connect(OPENEO_BACKEND)
32
+
33
+ if OPENEO_CLIENT_ID and OPENEO_CLIENT_SECRET:
34
+ conn.authenticate_oidc(
35
+ client_id=OPENEO_CLIENT_ID,
36
+ client_secret=OPENEO_CLIENT_SECRET,
37
+ )
38
+ else:
39
+ conn.authenticate_oidc()
40
+
41
+ _connection = conn
42
+ return conn
43
+
44
+
45
+ def _reset_connection() -> None:
46
+ """Reset the cached connection (for testing)."""
47
+ global _connection
48
+ _connection = None
49
+
50
+
51
+ def _bbox_dict(bbox: list[float]) -> dict[str, float]:
52
+ """Convert [min_lon, min_lat, max_lon, max_lat] to openEO spatial_extent."""
53
+ return {
54
+ "west": bbox[0],
55
+ "south": bbox[1],
56
+ "east": bbox[2],
57
+ "north": bbox[3],
58
+ }
59
+
60
+
61
+ def build_ndvi_graph(
62
+ *,
63
+ conn: openeo.Connection,
64
+ bbox: dict[str, float],
65
+ temporal_extent: list[str],
66
+ resolution_m: int = 100,
67
+ ) -> openeo.DataCube:
68
+ """Build an openEO process graph for monthly median NDVI composites.
69
+
70
+ Loads Sentinel-2 L2A, masks clouds using the SCL band, computes
71
+ NDVI = (B08 - B04) / (B08 + B04), and aggregates to monthly medians.
72
+
73
+ Returns an openEO DataCube (not yet executed).
74
+ """
75
+ cube = conn.load_collection(
76
+ collection_id="SENTINEL2_L2A",
77
+ spatial_extent=bbox,
78
+ temporal_extent=temporal_extent,
79
+ bands=["B04", "B08", "SCL"],
80
+ )
81
+
82
+ # Cloud mask: keep only vegetation, bare soil, water (SCL classes 4,5,6)
83
+ scl = cube.band("SCL")
84
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
85
+ cube = cube.mask(~cloud_mask)
86
+
87
+ # NDVI
88
+ b08 = cube.band("B08")
89
+ b04 = cube.band("B04")
90
+ ndvi = (b08 - b04) / (b08 + b04)
91
+
92
+ # Monthly median composite
93
+ monthly = ndvi.aggregate_temporal_period("month", reducer="median")
94
+
95
+ # Resample to target resolution
96
+ if resolution_m > 10:
97
+ monthly = monthly.resample_spatial(resolution=resolution_m / 111320)
98
+
99
+ return monthly
100
+
101
+
102
+ def build_true_color_graph(
103
+ *,
104
+ conn: openeo.Connection,
105
+ bbox: dict[str, float],
106
+ temporal_extent: list[str],
107
+ resolution_m: int = 100,
108
+ ) -> openeo.DataCube:
109
+ """Build an openEO process graph for a true-color Sentinel-2 composite.
110
+
111
+ Loads B04 (Red), B03 (Green), B02 (Blue), masks clouds,
112
+ and creates a median composite over the temporal extent.
113
+
114
+ Returns an openEO DataCube (not yet executed).
115
+ """
116
+ cube = conn.load_collection(
117
+ collection_id="SENTINEL2_L2A",
118
+ spatial_extent=bbox,
119
+ temporal_extent=temporal_extent,
120
+ bands=["B02", "B03", "B04", "SCL"],
121
+ )
122
+
123
+ # Cloud mask
124
+ scl = cube.band("SCL")
125
+ cloud_mask = (scl == 4) | (scl == 5) | (scl == 6)
126
+ cube = cube.mask(~cloud_mask)
127
+
128
+ # Drop SCL, keep RGB
129
+ rgb = cube.filter_bands(["B02", "B03", "B04"])
130
+
131
+ # Temporal median composite
132
+ composite = rgb.reduce_dimension(dimension="t", reducer="median")
133
+
134
+ # Resample
135
+ if resolution_m > 10:
136
+ composite = composite.resample_spatial(resolution=resolution_m / 111320)
137
+
138
+ return composite
tests/test_openeo_client.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for app.openeo_client — openEO connection and graph builders."""
2
+ from __future__ import annotations
3
+
4
+ from unittest.mock import MagicMock, patch
5
+ import pytest
6
+
7
+
8
+ def test_get_connection_creates_authenticated_connection():
9
+ """get_connection() connects to CDSE and authenticates."""
10
+ mock_conn = MagicMock()
11
+ with patch("openeo.connect", return_value=mock_conn) as mock_connect:
12
+ from app.openeo_client import get_connection, _reset_connection
13
+ _reset_connection()
14
+ conn = get_connection()
15
+
16
+ mock_connect.assert_called_once_with("openeo.dataspace.copernicus.eu")
17
+ mock_conn.authenticate_oidc.assert_called_once()
18
+ assert conn is mock_conn
19
+ _reset_connection()
20
+
21
+
22
+ def test_get_connection_reuses_cached():
23
+ """Subsequent calls return the same connection object."""
24
+ mock_conn = MagicMock()
25
+ with patch("openeo.connect", return_value=mock_conn):
26
+ from app.openeo_client import get_connection, _reset_connection
27
+ _reset_connection()
28
+ conn1 = get_connection()
29
+ conn2 = get_connection()
30
+ assert conn1 is conn2
31
+ _reset_connection()
32
+
33
+
34
+ def test_build_ndvi_graph():
35
+ """build_ndvi_graph() loads Sentinel-2 with correct bands."""
36
+ mock_conn = MagicMock()
37
+ mock_cube = MagicMock()
38
+ mock_conn.load_collection.return_value = mock_cube
39
+
40
+ from app.openeo_client import build_ndvi_graph
41
+
42
+ bbox = {"west": 32.45, "south": 15.65, "east": 32.65, "north": 15.8}
43
+ result = build_ndvi_graph(
44
+ conn=mock_conn,
45
+ bbox=bbox,
46
+ temporal_extent=["2025-03-01", "2026-03-01"],
47
+ resolution_m=100,
48
+ )
49
+
50
+ mock_conn.load_collection.assert_called_once()
51
+ call_kwargs = mock_conn.load_collection.call_args
52
+ assert call_kwargs[1]["collection_id"] == "SENTINEL2_L2A"
53
+ assert call_kwargs[1]["spatial_extent"] == bbox
54
+ assert "B04" in call_kwargs[1]["bands"]
55
+ assert "B08" in call_kwargs[1]["bands"]
56
+ assert "SCL" in call_kwargs[1]["bands"]
57
+
58
+
59
+ def test_build_true_color_graph():
60
+ """build_true_color_graph() loads RGB bands."""
61
+ mock_conn = MagicMock()
62
+ mock_cube = MagicMock()
63
+ mock_conn.load_collection.return_value = mock_cube
64
+
65
+ from app.openeo_client import build_true_color_graph
66
+
67
+ bbox = {"west": 32.45, "south": 15.65, "east": 32.65, "north": 15.8}
68
+ result = build_true_color_graph(
69
+ conn=mock_conn,
70
+ bbox=bbox,
71
+ temporal_extent=["2025-03-01", "2026-03-01"],
72
+ resolution_m=100,
73
+ )
74
+
75
+ mock_conn.load_collection.assert_called_once()
76
+ call_kwargs = mock_conn.load_collection.call_args
77
+ bands = call_kwargs[1]["bands"]
78
+ assert "B04" in bands
79
+ assert "B03" in bands
80
+ assert "B02" in bands