Water Quality Monitoring in Reservoirs#
This tutorial shows how to use ndvi2gif to monitor inland water bodies with Sentinel-2: detect the flooded extent each month, compute a monthly chlorophyll-a proxy, and study its inter-annual evolution. The workflow relies on the standard NdviSeasonality composites and on TimeSeriesAnalyzer for trend detection.
A complete, runnable companion notebook with a reservoirs shapefile (CHG — Confederación Hidrográfica del Guadalquivir) is available in examples_notebooks/Water_Quality_Embalses.ipynb.
Overview#
Reservoirs are heterogeneous systems: shorelines move with the water level, turbidity varies seasonally, and eutrophication episodes (cyanobacterial blooms) are concentrated in the warmer months. Any water-quality analysis therefore needs two layers:
Water detection — a binary mask of “where is water right now”.
Quality indicator — a biogeochemical proxy evaluated only on water pixels of that same period.
ndvi2gif makes this straightforward: water indices (NDWI, MNDWI, AWEI, WI2015) and the Red-Edge chlorophyll index NDCI are all available out of the box, all using the same standardised band names.
Index |
Formula (S2) |
Role |
|---|---|---|
|
|
Classic water detection |
|
|
Default water mask — more robust |
|
Multi-band (Blue, Green, NIR, SWIR1, SWIR2) |
Automatic water extraction |
|
NIR + SWIR1 + SWIR2 linear combination |
Complementary water index |
|
|
Chlorophyll-a proxy — Sentinel-2 only |
NDCI exploits the Red-Edge band B5 (705 nm), exclusive to Sentinel-2 among the sensors ndvi2gif supports, and is sensitive to chlorophyll-a concentration and potential cyanobacterial blooms.
1. Setup and ROI#
The ROI is a reservoir polygon. The notebook loads a CHG reservoir shapefile and selects one reservoir; any shapefile or GeoJSON works. A 500 m buffer is recommended so that the shoreline and the transition zone are included in every monthly composite, regardless of the water level:
import ee, geemap, geopandas as gpd
from ndvi2gif import NdviSeasonality, TimeSeriesAnalyzer
ee.Initialize()
gdf = gpd.read_file('CHG.ZonasProtegidas.CaptacionesEmbalses.shp')
gdf['area_m2'] = gdf.geometry.to_crs('EPSG:32630').area
# Pick the largest reservoir and buffer it by 500 m
reservoir = gdf.loc[[gdf['area_m2'].idxmax()]]
roi_buffer = reservoir.to_crs('EPSG:32630').buffer(500).to_crs('EPSG:4326')
roi_path = '/tmp/reservoir_buffer.shp'
gpd.GeoDataFrame(geometry=roi_buffer, crs='EPSG:4326').to_file(roi_path)
Why the buffer? In water-quality work, forgetting the buffer silently clips the shoreline when the reservoir is full, and creates a ring of dry pixels at low water that contaminate the mask. Buffering by 200–1000 m in projected coordinates fixes both issues.
2. Monthly water composites#
periods=12 produces one band per calendar month per year. The median reducer is the right choice for water indices: it is robust to residual cloud shadows and sun-glint outliers that would pull a mean toward false positives.
s2_mndwi = NdviSeasonality(
roi=roi_path,
periods=12,
start_year=2020,
end_year=2025,
sat='S2',
key='median',
index='mndwi',
)
composite_mndwi = s2_mndwi.get_year_composite()
# -> ee.ImageCollection of 6 images (2020..2025), each with 12 bands
# named january..december
Repeat with index='ndwi', 'aweinsh', or 'wi2015' to compare detectors. Quick visual check for July, inter-annual median:
water_viz = {
'bands': ['july'], 'min': -0.3, 'max': 0.5,
'palette': ['#d73027', '#fc8d59', '#fee090',
'#e0f3f8', '#74add1', '#313695'],
}
Map = geemap.Map()
Map.centerObject(composite_mndwi, zoom=11)
Map.addLayer(composite_mndwi.select('july').median(), water_viz,
'MNDWI July (interannual median)')
Map
Choosing the detector. For most Iberian reservoirs
mndwi > 0.0is a very good default. In highly turbid water it may under-detect — tryaweinshor lower the threshold (e.g.-0.1). AWEInsh is usually preferable over AWEI in reservoirs in flat terrain; AWEI’s shadow term is designed for mountainous settings and can over-suppress legitimate water.
3. Binary water masks#
Thresholding the monthly composite gives a binary mask per month and per year. Keep the threshold explicit — it is the single most sensitive parameter in water-quality work.
WATER_THRESHOLD = 0.0 # typical range: 0.0 to 0.1
def build_water_masks(composite, threshold=WATER_THRESHOLD):
def to_binary(img):
return img.gt(threshold).rename(img.bandNames())
return composite.map(to_binary)
water_masks = build_water_masks(composite_mndwi)
Each image in the returned collection still has 12 bands (january…december), but each pixel is now 1 (water) or 0 (not water).
Flooded surface per month#
Summing the mask × pixel area gives the monthly flooded surface in km²:
MONTHS = ['january','february','march','april','may','june',
'july','august','september','october','november','december']
imgs = water_masks.toList(water_masks.size())
years = list(range(s2_mndwi.start_year, s2_mndwi.end_year + 1))
records = []
for i, year in enumerate(years):
sums = ee.Image(imgs.get(i)).reduceRegion(
reducer=ee.Reducer.sum(),
geometry=s2_mndwi.roi,
scale=10, maxPixels=1e9,
).getInfo()
for m in MONTHS:
if sums.get(m) is not None:
records.append({'year': year, 'month': m,
'area_km2': sums[m] * 10 / 1e6})
# Note: `scale=10` for S2; factor 10/1e6 converts pixel-count at 10 m
# (100 m² per pixel) to km² (divide by 10 000).
Plotting area_km2 per month with one line per year makes the hydrological regime of the reservoir immediately visible: filling in winter/spring, draw-down in summer, and the inter-annual variability of each month.
4. Chlorophyll-a with NDCI#
The Normalised Difference Chlorophyll Index (Mishra & Mishra, 2012) uses Red-Edge 1 (B5) and Red (B4) and is the standard chlorophyll-a proxy for inland waters from Sentinel-2.
s2_ndci = NdviSeasonality(
roi=roi_path,
periods=12,
start_year=2020,
end_year=2025,
sat='S2',
key='median',
index='ndci', # Sentinel-2 only
)
composite_ndci = s2_ndci.get_year_composite()
Applying the water mask to NDCI#
NDCI is only meaningful over water. Any interpretation of NDCI on dry, vegetated, or mixed pixels is wrong by construction. Apply the monthly water mask before reducing or visualising:
def mask_water_quality(index_coll, mask_coll, threshold=0.0):
idx_list = index_coll.toList(index_coll.size())
mask_list = mask_coll.toList(mask_coll.size())
n = index_coll.size().min(mask_coll.size())
def mask_i(i):
idx = ee.Image(idx_list.get(i))
mask = ee.Image(mask_list.get(i)).gt(threshold)
return idx.updateMask(mask)
return ee.ImageCollection(
ee.List.sequence(0, n.subtract(1)).map(mask_i)
)
ndci_water = mask_water_quality(composite_ndci, composite_mndwi)
This pairs each year’s NDCI with that same year’s MNDWI mask — not with a single static water polygon. It correctly handles reservoirs whose shape changes season to season.
NDCI interpretation thresholds#
NDCI |
Interpretation |
|---|---|
< 0 |
Clear water, low productivity |
0.0 – 0.1 |
Moderate chlorophyll |
0.1 – 0.2 |
Elevated — monitor |
> 0.2 |
Potential algal bloom — alert |
These are practical reference values for Mediterranean reservoirs; local calibration against in situ samples is always recommended if you need absolute concentrations.
Visualising#
chl_palette = ['#053061', '#2166ac', '#4393c3', '#92c5de', '#d1e5f0',
'#f7f7f7', '#fddbc7', '#f4a582', '#d6604d', '#b2182b', '#67001f']
Map.addLayer(
ndci_water.select('july').median(),
{'bands': ['july'], 'min': -0.2, 'max': 0.3, 'palette': chl_palette},
'NDCI July — chlorophyll-a (water only)'
)
July is a good default month to inspect in Iberia: water temperature is high and solar radiation peaks, so any tendency to algal blooms shows up clearly.
5. Time series of water quality#
Extract mean/median/std NDCI per month per year directly from the masked composite:
MONTH_NUM = {m: i + 1 for i, m in enumerate(MONTHS)}
imgs = ndci_water.toList(ndci_water.size())
stats_reducer = (
ee.Reducer.mean()
.combine(ee.Reducer.median(), sharedInputs=True)
.combine(ee.Reducer.stdDev(), sharedInputs=True)
)
rows = []
for i, year in enumerate(years):
stats = ee.Image(imgs.get(i)).reduceRegion(
reducer=stats_reducer,
geometry=s2_ndci.roi,
scale=20, maxPixels=1e9,
).getInfo()
for m in MONTHS:
rows.append({
'year': year, 'month': m, 'month_num': MONTH_NUM[m],
'mean': stats.get(f'{m}_mean'),
'median': stats.get(f'{m}_median'),
'std': stats.get(f'{m}_stdDev'),
})
Scale. NDCI is reduced at
scale=20because the Red-Edge B5 band is natively 20 m. Reducing at 10 m forces a resample and does not improve the signal.
A typical plot stacks two panels: one curve per year (phenology-like view of each year’s chlorophyll cycle) and an year × month heatmap (highlights anomalous years at a glance). The companion notebook contains the full Matplotlib code.
6. Trend detection with TimeSeriesAnalyzer#
TimeSeriesAnalyzer wraps an NdviSeasonality instance and provides trend tests on its composites:
ts = TimeSeriesAnalyzer(s2_ndci)
series = ts.extract_time_series(reducer='mean', scale=20)
trend = ts.analyze_trend()
mk = trend['mann_kendall']
print(f"tau = {mk['tau']:.3f}, p = {mk['p_value']:.4f}")
print(trend['interpretation'])
ts.plot_comprehensive_analysis()
The Mann-Kendall test is non-parametric and robust to the seasonal structure of NDCI. A positive, significant tau on chlorophyll-a means the reservoir is becoming more eutrophic over time — this is the kind of signal reservoir managers need.
7. Reference table of water indices in ndvi2gif#
Key |
Name |
Bands |
Sensors |
Purpose |
|---|---|---|---|---|
|
NDWI (Gao 1996) |
Green, NIR |
All optical |
Water detection (classic) |
|
Modified NDWI (Xu 2006) |
Green, SWIR1 |
All optical |
Recommended water mask |
|
AWEI shade (Feyisa 2014) |
Blue, Green, NIR, SWIR1, SWIR2 |
All optical |
Automatic water extraction, shadowed terrain |
|
AWEI no-shadow (Feyisa 2014) |
Green, NIR, SWIR1, SWIR2 |
All optical |
Automatic water extraction, flat terrain |
|
Water Index 2015 (Fisher 2016) |
NIR, SWIR1, SWIR2 |
All optical |
Complementary water index |
|
NDCI (Mishra & Mishra 2012) |
RedEdge1, Red |
S2 only |
Chlorophyll-a / cyanobacteria |
|
NDMI (Gao 1996) |
NIR, SWIR1 |
All optical |
Moisture (riparian/aquatic vegetation) |
Sentinel-3 OLCI (300 m) adds dedicated water-quality indices — oci, tsi, cdom, turbidity, spm, kd490, floating_algae — useful for large lakes and reservoirs where the coarser resolution is acceptable.
8. Exporting results#
The binary water masks and the masked NDCI are ordinary ee.Image / ee.ImageCollection objects, so the standard Earth Engine export API applies:
task = ee.batch.Export.image.toDrive(
image=water_masks.median(), # interannual median of monthly masks
description='water_mask_monthly_mndwi',
folder='ndvi2gif_outputs',
fileNamePrefix='water_mask_mndwi_2020_2025',
region=s2_mndwi.roi,
scale=10,
crs='EPSG:4326',
maxPixels=1e10,
)
task.start()
Export the NDCI composite the same way (at scale=20). Monitor running exports at code.earthengine.google.com/tasks.
Tips and caveats#
Always mask NDCI with the monthly water mask, not a static polygon. Reservoirs change shape; a static polygon contaminates the dry season with vegetation signal.
Threshold calibration. MNDWI > 0.0 works for most Iberian reservoirs. Turbid or shallow systems may need 0.05–0.1. Document the threshold you used — results are not comparable across studies otherwise.
Median over mean. For both water indices and NDCI,
key='median'suppresses cloud-shadow and sun-glint outliers far better thanmean.Scale factors. Sentinel-2 scaling (0.0001) is applied automatically by
NdviSeasonality. You do not need to scale bands manually before computing indices.Buffer your ROI. A 200–1000 m buffer captures the full shoreline range and the transition zone, and makes the statistics representative of the reservoir as a hydrological unit, not just its polygon.
References#
Gao, B. (1996). NDWI — A normalized difference water index for remote sensing of vegetation liquid water from space. Remote Sensing of Environment, 58(3), 257–266.
Xu, H. (2006). Modification of normalised difference water index (NDWI) to enhance open water features. International Journal of Remote Sensing, 27(14), 3025–3033.
Feyisa, G.L., Meilby, H., Fensholt, R., Proud, S.R. (2014). Automated Water Extraction Index: A new technique for surface water mapping using Landsat imagery. Remote Sensing of Environment, 140, 23–35.
Fisher, A., Flood, N., Danaher, T. (2016). Comparing Landsat water index methods for automated water classification in eastern Australia. Remote Sensing of Environment, 175, 167–182.
Mishra, S., Mishra, D.R. (2012). Normalized difference chlorophyll index: a novel model for remote estimation of chlorophyll-a concentration in turbid productive waters. Remote Sensing of Environment, 117, 394–406.