Skip to content

Commit c1b21bb

Browse files
authored
Merge pull request #29 from CU-ESIIL/codex/add-load_sentinel2_ndvi_cube-function
Add Sentinel-2 NDVI helper
2 parents 1f101e4 + 0573633 commit c1b21bb

File tree

6 files changed

+213
-40
lines changed

6 files changed

+213
-40
lines changed

README.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ CubeDynamics is a streaming-first climate cube math library with ggplot-style pi
44

55
## Features
66

7-
- **Streaming PRISM/gridMET/Sentinel-2 helpers** (`cd.load_prism_cube`, `cd.load_gridmet_cube`, `cd.load_s2_ndvi_cube`) for immediate analysis without bulk downloads.
7+
- **Streaming PRISM/gridMET/Sentinel-2 helpers** (`cd.load_prism_cube`, `cd.load_gridmet_cube`, `cd.load_s2_ndvi_cube`, `cd.load_sentinel2_ndvi_cube`) for immediate analysis without bulk downloads.
88
- **Climate variance, correlation, trend, and synchrony cubes** that run on `xarray` objects and scale from laptops to clusters.
99
- **Pipe + verb system** – build readable cube workflows with `pipe(cube) | v.month_filter(...) | v.variance(...)` syntax inspired by ggplot/dplyr.
1010
- **Verbs namespace (`cubedynamics.verbs`)** so transforms, stats, IO, and visualization live in focused modules.
@@ -95,10 +95,33 @@ pipe(cube) | v.month_filter([6, 7, 8]) | v.variance(dim="time")
9595
### Sentinel-2 → NDVI Anomaly (z-score) Cube
9696

9797
CubeDynamics works on remote-sensing image stacks in addition to climate
98-
archives. A typical Sentinel-2 workflow is:
98+
archives. The convenience helper `cd.load_sentinel2_ndvi_cube` streams
99+
Sentinel-2 Level-2A chips via [`cubo`](https://github.com/carbonplan/cubo),
100+
computes NDVI from B08 (NIR) and B04 (red), and standardizes the result across
101+
time. Install `cubo` alongside CubeDynamics to use the helper:
99102

100-
1. Stream Level-2A chips with [`cubo`](https://github.com/carbonplan/cubo)
101-
using bands B04 (red) and B08 (NIR).
103+
```python
104+
import cubedynamics as cd
105+
from cubedynamics import pipe, verbs as v
106+
107+
ndvi_z = cd.load_sentinel2_ndvi_cube(
108+
lat=40.0,
109+
lon=-105.25,
110+
start="2018-01-01",
111+
end="2020-12-31",
112+
)
113+
114+
pipe(ndvi_z) | v.show_cube_lexcube(title="Sentinel-2 NDVI z-score")
115+
```
116+
117+
`load_sentinel2_ndvi_cube` returns a `(time, y, x)` NDVI z-score cube ready for
118+
the rest of the verbs API. Pass ``return_raw=True`` to also receive the raw
119+
Sentinel-2 reflectance stack and intermediate NDVI cube.
120+
121+
If you prefer to customize every step, the helper simply wraps the manual
122+
pipeline below:
123+
124+
1. Stream Level-2A chips with `cubo` using bands B04 (red) and B08 (NIR).
102125
2. Convert the reflectance cube to NDVI with `v.ndvi_from_s2(...)`.
103126
3. Standardize NDVI over time with `v.zscore(dim="time")` to highlight
104127
greenness anomalies.
@@ -119,7 +142,6 @@ LON = -102.18
119142
START = "2023-06-01"
120143
END = "2024-09-30"
121144

122-
# 1. Stream Sentinel-2 reflectance without local downloads
123145
with warnings.catch_warnings():
124146
warnings.simplefilter("ignore")
125147
s2 = cubo.create(
@@ -134,19 +156,16 @@ with warnings.catch_warnings():
134156
query={"eo:cloud_cover": {"lt": 40}},
135157
)
136158

137-
# 2. Pipe reflectance -> NDVI -> z-scores
138159
ndvi_z = (
139160
pipe(s2)
140161
| v.ndvi_from_s2(nir_band="B08", red_band="B04")
141162
| v.zscore(dim="time")
142163
).unwrap()
143164

144-
# 3. Optional: visualize and QA in notebooks
145165
(pipe(ndvi_z) | v.show_cube_lexcube(title="Sentinel-2 NDVI z-score", clim=(-3, 3)))
146166
median_series = ndvi_z.median(dim=("y", "x"))
147167
median_series.plot.line(x="time", ylabel="Median NDVI z-score")
148168

149-
# 4. Optional: correlate with a PRISM anomaly cube at each pixel
150169
corr_cube = (
151170
pipe(ndvi_z)
152171
| v.correlation_cube(prism_anom_cube, dim="time")
@@ -189,7 +208,7 @@ Lexcube widgets require a live Python environment (Jupyter, Colab, Binder). They
189208

190209
- `pipe` for wrapping any cube before piping.
191210
- `verbs` (``from cubedynamics import verbs as v``) exposes transforms, statistics, IO, and visualization helpers.
192-
- Streaming helpers: `cd.load_prism_cube`, `cd.load_gridmet_cube`, `cd.load_s2_cube`, `cd.load_s2_ndvi_cube`.
211+
- Streaming helpers: `cd.load_prism_cube`, `cd.load_gridmet_cube`, `cd.load_s2_cube`, `cd.load_s2_ndvi_cube`, `cd.load_sentinel2_ndvi_cube`.
193212
- Vegetation helper: `v.ndvi_from_s2` for direct NDVI calculation on Sentinel-2 cubes.
194213
- Stats verbs: `v.anomaly`, `v.month_filter`, `v.variance`, `v.zscore`, `v.correlation_cube`, and more under `cubedynamics.ops.*`.
195214
- IO verbs: `v.to_netcdf`, `v.to_zarr`, etc.

docs/streaming_sources.md

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -66,44 +66,31 @@ cube = cd.load_gridmet_cube(
6666
## Sentinel-2 → NDVI anomaly (z-score) cube
6767

6868
Remote-sensing chips stream into the same pipe + verbs grammar, so you can
69-
combine vegetation signals with climate anomalies. Sentinel-2 Level-2A imagery
70-
exposes B04 (red) and B08 (NIR) bands, which flow into
71-
`v.ndvi_from_s2(...)` and `v.zscore(...)` inside a pipe chain.
69+
combine vegetation signals with climate anomalies. Use
70+
`cd.load_sentinel2_ndvi_cube` (requires the `cubo` package) for a one-function
71+
workflow:
7272

7373
```python
74-
from __future__ import annotations
75-
76-
import warnings
77-
78-
import cubo
79-
74+
import cubedynamics as cd
8075
from cubedynamics import pipe, verbs as v
8176

82-
with warnings.catch_warnings():
83-
warnings.simplefilter("ignore")
84-
s2 = cubo.create(
85-
lat=43.89,
86-
lon=-102.18,
87-
collection="sentinel-2-l2a",
88-
bands=["B04", "B08"],
89-
start_date="2023-06-01",
90-
end_date="2024-09-30",
91-
edge_size=512,
92-
resolution=10,
93-
query={"eo:cloud_cover": {"lt": 40}},
94-
)
95-
96-
ndvi_z = (
97-
pipe(s2)
98-
| v.ndvi_from_s2(nir_band="B08", red_band="B04")
99-
| v.zscore(dim="time")
100-
).unwrap()
77+
ndvi_z = cd.load_sentinel2_ndvi_cube(
78+
lat=40.0,
79+
lon=-105.25,
80+
start="2018-01-01",
81+
end="2020-12-31",
82+
)
10183

102-
(pipe(ndvi_z) | v.show_cube_lexcube(title="Sentinel-2 NDVI z-score", clim=(-3, 3)))
103-
median_series = ndvi_z.median(dim=("y", "x"))
104-
median_series.plot.line(x="time", ylabel="Median NDVI z-score")
84+
pipe(ndvi_z) | v.show_cube_lexcube(title="Sentinel-2 NDVI z-score")
10585
```
10686

87+
The helper streams Sentinel-2 Level-2A imagery via `cubo`, computes NDVI from
88+
bands B08 (NIR) and B04 (red), and runs `v.zscore(dim="time")` so the returned
89+
cube is standardized across time. Set ``return_raw=True`` to also grab the raw
90+
reflectance stack and intermediate NDVI cube. You can still manually reproduce
91+
the pipeline with `pipe(...) | v.ndvi_from_s2(...) | v.zscore(...)` if you need a
92+
different stacking order.
93+
10794
The resulting cube highlights unusual greenness events (drought stress,
10895
disturbance, rapid recovery). Because every cube shares `(time, y, x)` axes, you
10996
can correlate NDVI anomalies with PRISM or gridMET cubes using

src/cubedynamics/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .utils.chunking import coarsen_and_stride
2929
from .viz.lexcube_viz import show_cube_lexcube
3030
from .viz.qa_plots import plot_median_over_space
31+
from .sentinel import load_sentinel2_ndvi_cube
3132

3233
# Streaming-first stubs for the new architecture ---------------------------------
3334
from .streaming.gridmet import stream_gridmet_to_cube
@@ -50,6 +51,7 @@
5051
"load_s2_ndvi_cube",
5152
"load_gridmet_cube",
5253
"load_prism_cube",
54+
"load_sentinel2_ndvi_cube",
5355
"compute_ndvi_from_s2",
5456
"zscore_over_time",
5557
"temporal_anomaly",

src/cubedynamics/sentinel.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Sentinel-2 helper utilities for CubeDynamics."""
2+
3+
from __future__ import annotations
4+
5+
import warnings
6+
7+
import cubo
8+
import xarray as xr
9+
10+
from . import verbs as v
11+
from .piping import pipe
12+
13+
14+
def load_sentinel2_ndvi_cube(
15+
lat: float,
16+
lon: float,
17+
start: str,
18+
end: str,
19+
*,
20+
edge_size: int = 512,
21+
resolution: int = 10,
22+
max_cloud: int = 40,
23+
return_raw: bool = False,
24+
) -> xr.DataArray | tuple[xr.DataArray, xr.DataArray, xr.DataArray]:
25+
"""Stream Sentinel-2 L2A data and compute NDVI and NDVI z-score cubes.
26+
27+
Parameters
28+
----------
29+
lat, lon:
30+
Center point for the Sentinel-2 spatial subset.
31+
start, end:
32+
Date range (``YYYY-MM-DD``) to request.
33+
edge_size:
34+
Spatial window size in pixels (default ``512``).
35+
resolution:
36+
Spatial resolution in meters (default ``10``).
37+
max_cloud:
38+
Maximum allowed cloud cover percentage (default ``40``).
39+
return_raw:
40+
If ``True`` return ``(s2, ndvi, ndvi_z)``; otherwise only the NDVI
41+
z-score cube is returned.
42+
43+
Returns
44+
-------
45+
xarray.DataArray or tuple of DataArray
46+
NDVI z-score cube or ``(s2, ndvi, ndvi_z)`` if ``return_raw`` is true.
47+
"""
48+
49+
with warnings.catch_warnings():
50+
warnings.simplefilter("ignore")
51+
s2 = cubo.create(
52+
lat=lat,
53+
lon=lon,
54+
collection="sentinel-2-l2a",
55+
bands=["B04", "B08"],
56+
start_date=start,
57+
end_date=end,
58+
edge_size=edge_size,
59+
resolution=resolution,
60+
query={"eo:cloud_cover": {"lt": max_cloud}},
61+
)
62+
63+
if "band" in s2.dims:
64+
s2 = s2.transpose("time", "y", "x", "band")
65+
66+
ndvi = (pipe(s2) | v.ndvi_from_s2(nir_band="B08", red_band="B04")).unwrap()
67+
ndvi_z = (pipe(ndvi) | v.zscore(dim="time")).unwrap()
68+
69+
if return_raw:
70+
return s2, ndvi, ndvi_z
71+
return ndvi_z
72+
73+
74+
__all__ = ["load_sentinel2_ndvi_cube"]

tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@
22

33
from __future__ import annotations
44

5+
import sys
6+
from pathlib import Path
7+
58
import numpy as np
69
import pandas as pd
710
import pytest
811
import xarray as xr
912

13+
# Ensure ``src`` is importable even when cubedynamics is not installed in site-packages.
14+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
15+
SRC_PATH = PROJECT_ROOT / "src"
16+
SRC_STR = str(SRC_PATH)
17+
if SRC_STR in sys.path:
18+
sys.path.remove(SRC_STR)
19+
sys.path.insert(0, SRC_STR)
20+
1021
try:
1122
import dask.array as da
1223
except ImportError: # pragma: no cover
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Unit tests for the Sentinel-2 NDVI helper."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from pathlib import Path
7+
8+
import numpy as np
9+
import xarray as xr
10+
11+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
12+
SRC_PATH = PROJECT_ROOT / "src"
13+
SRC_STR = str(SRC_PATH)
14+
if SRC_STR in sys.path:
15+
sys.path.remove(SRC_STR)
16+
sys.path.insert(0, SRC_STR)
17+
18+
import cubedynamics
19+
from cubedynamics.sentinel import load_sentinel2_ndvi_cube
20+
21+
22+
class DummyCubo:
23+
"""Minimal cubo shim that returns a deterministic cube."""
24+
25+
@staticmethod
26+
def create(**kwargs):
27+
rng = np.random.default_rng(0)
28+
time = np.arange(3)
29+
y = np.arange(4)
30+
x = np.arange(5)
31+
band = ["B04", "B08"]
32+
data = rng.random((len(time), len(y), len(x), len(band))).astype(np.float32)
33+
return xr.DataArray(
34+
data,
35+
coords={"time": time, "y": y, "x": x, "band": band},
36+
dims=("time", "y", "x", "band"),
37+
name="reflectance",
38+
)
39+
40+
41+
def test_public_api_exposes_loader():
42+
assert hasattr(cubedynamics, "load_sentinel2_ndvi_cube")
43+
44+
45+
def test_load_sentinel2_ndvi_cube_returns_zscores(monkeypatch):
46+
import cubedynamics.sentinel as sentinel_mod
47+
48+
monkeypatch.setattr(sentinel_mod, "cubo", DummyCubo)
49+
50+
ndvi_z = load_sentinel2_ndvi_cube(
51+
lat=40.0,
52+
lon=-105.25,
53+
start="2018-01-01",
54+
end="2018-12-31",
55+
)
56+
57+
assert ndvi_z.dims == ("time", "y", "x")
58+
assert ndvi_z.name == "ndvi_zscore"
59+
mean_over_time = ndvi_z.mean(dim="time")
60+
assert mean_over_time.max().item() < 1e-6
61+
assert mean_over_time.min().item() > -1e-6
62+
63+
64+
def test_load_sentinel2_ndvi_cube_return_raw(monkeypatch):
65+
import cubedynamics.sentinel as sentinel_mod
66+
67+
monkeypatch.setattr(sentinel_mod, "cubo", DummyCubo)
68+
69+
raw_s2, ndvi, ndvi_z = load_sentinel2_ndvi_cube(
70+
lat=40.0,
71+
lon=-105.25,
72+
start="2018-01-01",
73+
end="2018-12-31",
74+
return_raw=True,
75+
)
76+
77+
assert raw_s2.dims == ("time", "y", "x", "band")
78+
assert set(raw_s2.coords["band"].values) == {"B04", "B08"}
79+
assert ndvi.shape == raw_s2.sel(band="B08").shape
80+
assert ndvi_z.shape == ndvi.shape

0 commit comments

Comments
 (0)