Skip to content

Commit 1df0519

Browse files
authored
Merge pull request #30 from CU-ESIIL/codex/add-sentinel-2-loaders-to-cubedynamics
Add Sentinel-2 loaders and docs
2 parents c1b21bb + 51e2cea commit 1df0519

File tree

5 files changed

+242
-63
lines changed

5 files changed

+242
-63
lines changed

README.md

Lines changed: 48 additions & 10 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`, `cd.load_sentinel2_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_cube`, `cd.load_sentinel2_bands_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.
@@ -92,13 +92,49 @@ cube = cd.load_gridmet_cube(
9292
pipe(cube) | v.month_filter([6, 7, 8]) | v.variance(dim="time")
9393
```
9494

95-
### Sentinel-2 → NDVI Anomaly (z-score) Cube
95+
### Sentinel-2 loaders
9696

9797
CubeDynamics works on remote-sensing image stacks in addition to climate
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:
98+
archives. The Sentinel helpers stream Sentinel-2 Level-2A chips via
99+
[`cubo`](https://github.com/carbonplan/cubo) and plug directly into the verbs
100+
API.
101+
102+
#### All Sentinel-2 bands
103+
104+
```python
105+
import cubedynamics as cd
106+
107+
s2_all = cd.load_sentinel2_cube(
108+
lat=40.0,
109+
lon=-105.25,
110+
start="2018-01-01",
111+
end="2018-12-31",
112+
)
113+
114+
s2_all
115+
```
116+
117+
`load_sentinel2_cube` returns a `(time, y, x, band)` cube with all Sentinel-2
118+
L2A bands that `cubo` provides by default (or a user-specified subset via the
119+
``bands`` keyword). The helper keeps dimensions consistently ordered so it can
120+
feed directly into downstream verbs.
121+
122+
#### Selected bands
123+
124+
```python
125+
s2_rgbn = cd.load_sentinel2_bands_cube(
126+
lat=40.0,
127+
lon=-105.25,
128+
start="2018-01-01",
129+
end="2018-12-31",
130+
bands=["B02", "B03", "B04", "B08"], # blue, green, red, NIR
131+
)
132+
```
133+
134+
`load_sentinel2_bands_cube` enforces that a band list is provided (raising a
135+
``ValueError`` when empty) and otherwise mirrors the generic loader.
136+
137+
#### NDVI anomaly (z-score) cube
102138

103139
```python
104140
import cubedynamics as cd
@@ -114,9 +150,11 @@ ndvi_z = cd.load_sentinel2_ndvi_cube(
114150
pipe(ndvi_z) | v.show_cube_lexcube(title="Sentinel-2 NDVI z-score")
115151
```
116152

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.
153+
`load_sentinel2_ndvi_cube` uses the band loader to grab the red (B04) and NIR
154+
(B08) bands, runs `v.ndvi_from_s2(...)`, and standardizes NDVI over time via
155+
`v.zscore(dim="time")`. The helper returns a `(time, y, x)` NDVI z-score cube
156+
ready for the rest of the verbs API. Pass ``return_raw=True`` to also receive
157+
the raw Sentinel-2 reflectance stack and intermediate NDVI cube.
120158

121159
If you prefer to customize every step, the helper simply wraps the manual
122160
pipeline below:
@@ -208,7 +246,7 @@ Lexcube widgets require a live Python environment (Jupyter, Colab, Binder). They
208246

209247
- `pipe` for wrapping any cube before piping.
210248
- `verbs` (``from cubedynamics import verbs as v``) exposes transforms, statistics, IO, and visualization helpers.
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`.
249+
- Streaming helpers: `cd.load_prism_cube`, `cd.load_gridmet_cube`, `cd.load_s2_cube`, `cd.load_s2_ndvi_cube`, `cd.load_sentinel2_cube`, `cd.load_sentinel2_bands_cube`, `cd.load_sentinel2_ndvi_cube`.
212250
- Vegetation helper: `v.ndvi_from_s2` for direct NDVI calculation on Sentinel-2 cubes.
213251
- Stats verbs: `v.anomaly`, `v.month_filter`, `v.variance`, `v.zscore`, `v.correlation_cube`, and more under `cubedynamics.ops.*`.
214252
- IO verbs: `v.to_netcdf`, `v.to_zarr`, etc.

docs/streaming_sources.md

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,39 @@ cube = cd.load_gridmet_cube(
6363
)
6464
```
6565

66-
## Sentinel-2 → NDVI anomaly (z-score) cube
66+
## Sentinel-2 cubes
6767

6868
Remote-sensing chips stream into the same pipe + verbs grammar, so you can
69-
combine vegetation signals with climate anomalies. Use
70-
`cd.load_sentinel2_ndvi_cube` (requires the `cubo` package) for a one-function
71-
workflow:
69+
combine vegetation signals with climate anomalies. All Sentinel helpers require
70+
the `cubo` package.
71+
72+
### All or selected bands
73+
74+
```python
75+
import cubedynamics as cd
76+
77+
s2_all = cd.load_sentinel2_cube(
78+
lat=40.0,
79+
lon=-105.25,
80+
start="2018-01-01",
81+
end="2018-12-31",
82+
)
83+
84+
s2_rgbn = cd.load_sentinel2_bands_cube(
85+
lat=40.0,
86+
lon=-105.25,
87+
start="2018-01-01",
88+
end="2018-12-31",
89+
bands=["B02", "B03", "B04", "B08"],
90+
)
91+
```
92+
93+
`load_sentinel2_cube` streams all bands (or a user-provided subset), returning a
94+
`(time, y, x, band)` cube with consistent dimension order. The companion
95+
`load_sentinel2_bands_cube` helper enforces that the band subset is explicitly
96+
provided and raises ``ValueError`` when the list is empty.
97+
98+
### NDVI anomaly (z-score) cube
7299

73100
```python
74101
import cubedynamics as cd
@@ -84,12 +111,12 @@ ndvi_z = cd.load_sentinel2_ndvi_cube(
84111
pipe(ndvi_z) | v.show_cube_lexcube(title="Sentinel-2 NDVI z-score")
85112
```
86113

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.
114+
`load_sentinel2_ndvi_cube` builds on the band loader to fetch B04/B08, computes
115+
NDVI, and runs `v.zscore(dim="time")` so the returned cube is standardized over
116+
time. Set ``return_raw=True`` to also grab the raw reflectance stack and
117+
intermediate NDVI cube. You can still manually reproduce the pipeline with
118+
`pipe(...) | v.ndvi_from_s2(...) | v.zscore(...)` if you need a different
119+
stacking order.
93120

94121
The resulting cube highlights unusual greenness events (drought stress,
95122
disturbance, rapid recovery). Because every cube shares `(time, y, x)` axes, you

src/cubedynamics/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@
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
31+
from .sentinel import (
32+
load_sentinel2_bands_cube,
33+
load_sentinel2_cube,
34+
load_sentinel2_ndvi_cube,
35+
)
3236

3337
# Streaming-first stubs for the new architecture ---------------------------------
3438
from .streaming.gridmet import stream_gridmet_to_cube
@@ -51,6 +55,8 @@
5155
"load_s2_ndvi_cube",
5256
"load_gridmet_cube",
5357
"load_prism_cube",
58+
"load_sentinel2_cube",
59+
"load_sentinel2_bands_cube",
5460
"load_sentinel2_ndvi_cube",
5561
"compute_ndvi_from_s2",
5662
"zscore_over_time",

src/cubedynamics/sentinel.py

Lines changed: 105 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import warnings
6+
from typing import Sequence
67

78
import cubo
89
import xarray as xr
@@ -11,58 +12,130 @@
1112
from .piping import pipe
1213

1314

14-
def load_sentinel2_ndvi_cube(
15+
def load_sentinel2_cube(
1516
lat: float,
1617
lon: float,
1718
start: str,
1819
end: str,
1920
*,
21+
bands: Sequence[str] | None = None,
2022
edge_size: int = 512,
2123
resolution: int = 10,
2224
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.
25+
) -> xr.DataArray:
26+
"""Stream a Sentinel-2 L2A cube via ``cubo``.
2627
2728
Parameters
2829
----------
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:
30+
lat, lon
31+
Center point for the spatial subset.
32+
start, end
33+
Date range (``YYYY-MM-DD``).
34+
bands
35+
Optional Sentinel-2 band names (e.g., ``["B02", "B03", "B04", "B08"]``).
36+
If ``None``, ``cubo``'s default "all bands" behavior is used.
37+
edge_size
3438
Spatial window size in pixels (default ``512``).
35-
resolution:
39+
resolution
3640
Spatial resolution in meters (default ``10``).
37-
max_cloud:
41+
max_cloud
3842
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.
4243
4344
Returns
4445
-------
45-
xarray.DataArray or tuple of DataArray
46-
NDVI z-score cube or ``(s2, ndvi, ndvi_z)`` if ``return_raw`` is true.
46+
xarray.DataArray
47+
Sentinel-2 stack with dims ``(time, y, x, band)``.
4748
"""
4849

50+
create_kwargs = dict(
51+
lat=lat,
52+
lon=lon,
53+
collection="sentinel-2-l2a",
54+
start_date=start,
55+
end_date=end,
56+
edge_size=edge_size,
57+
resolution=resolution,
58+
query={"eo:cloud_cover": {"lt": max_cloud}},
59+
)
60+
61+
if bands is not None:
62+
create_kwargs["bands"] = list(bands)
63+
4964
with warnings.catch_warnings():
5065
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-
)
66+
s2 = cubo.create(**create_kwargs)
6267

6368
if "band" in s2.dims:
6469
s2 = s2.transpose("time", "y", "x", "band")
6570

71+
return s2
72+
73+
74+
def load_sentinel2_bands_cube(
75+
lat: float,
76+
lon: float,
77+
start: str,
78+
end: str,
79+
*,
80+
bands: Sequence[str],
81+
edge_size: int = 512,
82+
resolution: int = 10,
83+
max_cloud: int = 40,
84+
) -> xr.DataArray:
85+
"""Stream a Sentinel-2 L2A cube for a user-selected list of bands.
86+
87+
This is a convenience wrapper over :func:`load_sentinel2_cube` that requires
88+
an explicit band list. Parameters mirror the generic loader except that
89+
``bands`` is required.
90+
"""
91+
92+
if not bands:
93+
raise ValueError(
94+
"load_sentinel2_bands_cube requires a non-empty 'bands' list."
95+
)
96+
97+
return load_sentinel2_cube(
98+
lat=lat,
99+
lon=lon,
100+
start=start,
101+
end=end,
102+
bands=bands,
103+
edge_size=edge_size,
104+
resolution=resolution,
105+
max_cloud=max_cloud,
106+
)
107+
108+
109+
def load_sentinel2_ndvi_cube(
110+
lat: float,
111+
lon: float,
112+
start: str,
113+
end: str,
114+
*,
115+
edge_size: int = 512,
116+
resolution: int = 10,
117+
max_cloud: int = 40,
118+
return_raw: bool = False,
119+
) -> xr.DataArray | tuple[xr.DataArray, xr.DataArray, xr.DataArray]:
120+
"""Stream Sentinel-2 L2A data and compute NDVI and NDVI z-score cubes.
121+
122+
The helper loads the red (B04) and near-infrared (B08) bands via
123+
:func:`load_sentinel2_bands_cube`, computes NDVI with
124+
:func:`cubedynamics.verbs.ndvi_from_s2`, and standardizes the NDVI cube over
125+
time with :func:`cubedynamics.verbs.zscore`.
126+
"""
127+
128+
s2 = load_sentinel2_bands_cube(
129+
lat=lat,
130+
lon=lon,
131+
start=start,
132+
end=end,
133+
bands=["B04", "B08"],
134+
edge_size=edge_size,
135+
resolution=resolution,
136+
max_cloud=max_cloud,
137+
)
138+
66139
ndvi = (pipe(s2) | v.ndvi_from_s2(nir_band="B08", red_band="B04")).unwrap()
67140
ndvi_z = (pipe(ndvi) | v.zscore(dim="time")).unwrap()
68141

@@ -71,4 +144,8 @@ def load_sentinel2_ndvi_cube(
71144
return ndvi_z
72145

73146

74-
__all__ = ["load_sentinel2_ndvi_cube"]
147+
__all__ = [
148+
"load_sentinel2_cube",
149+
"load_sentinel2_bands_cube",
150+
"load_sentinel2_ndvi_cube",
151+
]

0 commit comments

Comments
 (0)