How to Make a MODIS Snow Cover Time Series in Python

On this page

Problem statement

MODIS has mapped snow cover every day since 2000 at 500 m resolution, and its daily product, MOD10A1, is the standard way to follow a snowpack across a whole mountain range rather than at a handful of stations. Turning it into a time series runs into three problems at once: clouds hide the ground on about half the days, the data come in a sinusoidal projection that no other dataset uses, and the snow value is a normalised difference index rather than a yes or no. Ignore them and a "snow-covered area" curve becomes a cloud-cover curve.

This page builds the water-year 2024 snow-cover record for a 34 × 44 km box over Rocky Mountain National Park, Colorado, from MOD10A1 version 6.1 on Microsoft's Planetary Computer, fills the cloud gaps, and checks the result against seven SNOTEL stations inside the box.

Quick answer

Search the STAC catalogue, read just your window from each daily file, treat values 0–100 as observations and everything above as no data, call NDSI ≥ 10 snow, and carry each pixel's last clear observation forward a few days:

items = [i for i in catalog.search(collections=["modis-10A1-061"], bbox=BOX, datetime="2023-10-01/2024-09-30").items()
         if i.id.startswith("MOD10A1") and ".h09v04." in i.id]
stack, dates = read_window(items, BOX)                  # (days, rows, cols) of NDSI_Snow_Cover, one per date
valid = stack <= 100                                     # 0-100: NDSI x 100; 200+: cloud, night, water, fill
state = np.where(valid, (stack >= 10).astype(float), np.nan)
filled = pd.DataFrame(state.reshape(len(dates), -1), index=dates).ffill(limit=5).to_numpy().reshape(state.shape)
print(f"{len(dates)} days; clear {valid.mean():.0%} of pixel-days; unknown after filling {np.isnan(filled).mean():.0%}")
366 days; clear 51% of pixel-days; unknown after filling 6%
Map of a 34 by 44 km box over Rocky Mountain National Park coloured by the number of days each 500 m MODIS pixel was snow-covered in water year 2024, from under 50 in the eastern valleys to over 200 on the high ground, with 3,000 and 3,500 m contours and seven SNOTEL stations marked. Map of a 34 by 44 km box over Rocky Mountain National Park coloured by the number of days each 500 m MODIS pixel was snow-covered in water year 2024, from under 50 in the eastern valleys to over 200 on the high ground, with 3,000 and 3,500 m contours and seven SNOTEL stations marked.
Gap-filled MOD10A1 snow cover (NDSI ≥ 10), 1 October 2023 to 30 September 2024. Contours: Copernicus DEM GLO-30.

Find tiles, read a window, decode, fill gaps

From MOD10A1 daily files to a gap-filled snow cover time series checked against SNOTEL. a year of MODIS snow, checked on the groundSTAC searchMOD10A1, h09v04window reads366 days, ~1 s eachmask codescloud 48% ofpixel-daysfill ≤ 5 days6% still unknownSNOTEL checkmedian 5 days earlyThe ground check is what makes the curve usable spatialworkflow.io
Five steps from daily files to a checked series.

1. Find the tiles

MODIS land products are cut into 10° tiles on a sinusoidal grid, named by horizontal and vertical index. The park is in tile h09v04; the tile boundary runs along 40° N, so a box that crossed it would need two tiles per day. The collection holds both Terra (MOD) and Aqua (MYD) files; Terra passes in the morning, before afternoon cloud builds, so use it alone or Terra first:

ids = [i.id for i in catalog.search(collections=["modis-10A1-061"], bbox=BOX, datetime="2024-03-05/2024-03-05").items()]
print(ids)
print(len(items), "Terra files for water year 2024 in tile h09v04")
['MYD10A1.A2024065.h09v04.061.2024067050202', 'MOD10A1.A2024065.h09v04.061.2024067050143']
370 Terra files for water year 2024 in tile h09v04

2. Read a window, not the tile

Each tile is 2400 × 2400 pixels; the box is a few thousand of them. A Cloud-Optimised GeoTIFF read with a window fetches only the blocks needed — about a second per day. The window is a rectangle in the sinusoidal projection, so it covers a little more than the longitude–latitude box:

with rasterio.open(items[0].assets["NDSI_Snow_Cover"].href) as src:
    print(src.crs.to_proj4() if src.crs.to_epsg() is None else src.crs, round(src.res[0], 1), "m pixels")
print(stack.shape, "window per day")
+proj=sinu +lon_0=0 +x_0=0 +y_0=0 +R=6371007.181 +units=m +no_defs=True 463.3 m pixels
(366, 96, 188) window per day

3. Read the codes

Values 0–100 are the NDSI multiplied by 100. Everything above 100 is a code:

codes = {200: "missing", 201: "no decision", 211: "night", 237: "inland water", 239: "ocean",
         250: "cloud", 254: "detector saturated", 255: "fill"}
u, c = np.unique(stack[stack > 100], return_counts=True)
print({codes.get(int(k), int(k)): f"{n / stack.size:.1%}" for k, n in zip(u, c)})
{'missing': '0.0%', 'no decision': '0.4%', 'inland water': '0.4%', 'cloud': '47.6%', 'fill': '0.5%'}

Cloud is by far the largest. Every day's clear area is different, so a daily snow-covered percentage must be computed over that day's clear pixels only — and days with little clear sky give little information.

4. Fill the gaps

Snow cover changes slowly compared with the weather. Carrying each pixel's last clear observation forward for up to five days fills most cloudy days with a value that is very likely still right:

raw = (stack >= 10).sum((1, 2)) / np.maximum(valid.sum((1, 2)), 1)
clear_share = valid.mean((1, 2))
print(f"days with less than 10% clear sky: {(clear_share < 0.1).sum()}; "
      f"snow-covered share, monthly mean of gap-filled pixels:")
print(pd.Series(np.nanmean(filled, axis=(1, 2)), index=dates).resample("MS").mean().round(2).to_string())
days with less than 10% clear sky: 75; snow-covered share, monthly mean of gap-filled pixels:
2023-10-01    0.11
2023-11-01    0.52
2023-12-01    0.79
2024-01-01    0.73
2024-02-01    0.83
2024-03-01    0.78
2024-04-01    0.62
2024-05-01    0.35
2024-06-01    0.10
2024-07-01    0.03
2024-08-01    0.01
2024-09-01    0.01
Freq: MS
Two panels for water year 2024: top, the snow-covered share of the box, raw values on clear days as dots and the gap-filled line, rising to about 80 percent from December to March and falling to near zero by July; bottom, the daily clear-sky share, often near zero in winter. Two panels for water year 2024: top, the snow-covered share of the box, raw values on clear days as dots and the gap-filled line, rising to about 80 percent from December to March and falling to near zero by July; bottom, the daily clear-sky share, often near zero in winter.
Raw daily values jump with the weather; the filled series follows the snowpack.

5. Check against the ground

Seven SNOTEL stations stand inside the box. For each, compare the day its snow pillow first read zero after the peak with the day its MODIS pixel first went snow-free:

for name, elev, ground, sat in station_check(filled, dates):
    print(f"{name:16} {elev:5.0f} m   SNOTEL {ground:%d %b}   MODIS {sat:%d %b}   {(sat - ground).days:+3d} days")
Copeland Lake     2612 m   SNOTEL 13 Apr   MODIS 09 Apr    -4 days
Phantom Valley    2752 m   SNOTEL 21 May   MODIS 16 May    -5 days
Wild Basin        2868 m   SNOTEL 04 Jun   MODIS 13 May   -22 days
Bear Lake         2893 m   SNOTEL 05 Jun   MODIS 23 May   -13 days
Sawtooth          2938 m   SNOTEL 17 Jun   MODIS 28 May   -20 days
Lake Irene        3255 m   SNOTEL 10 Jun   MODIS 07 Jun    -3 days
Willow Park       3264 m   SNOTEL 10 Jun   MODIS 11 Jun    +1 days
Dot plot of melt-out dates in 2024 at seven SNOTEL stations against the MODIS pixel containing each: close at Copeland Lake, Phantom Valley, Lake Irene and Willow Park, and two to three weeks early at Bear Lake, Wild Basin and Sawtooth. Dot plot of melt-out dates in 2024 at seven SNOTEL stations against the MODIS pixel containing each: close at Copeland Lake, Phantom Valley, Lake Irene and Willow Park, and two to three weeks early at Bear Lake, Wild Basin and Sawtooth.
Stations ordered by SNOTEL melt-out; elevations in brackets.

MODIS called the snow gone earlier than the ground did, by a median of five days and by up to three weeks at three stations. Those three — Wild Basin, Sawtooth and Bear Lake — sit in forest; a 500 m pixel there sees mostly canopy, and once the snow on open ground and on the trees has gone, the snow beneath the trees is invisible from orbit. The two highest, open stations agree within three days.

Snow-cover duration against elevation

The data used on this page

MODIS/Terra Snow Cover Daily L3 Global 500m SIN Grid, Version 61 (MOD10A1.061), Hall, D. K. and G. A. Riggs, NASA NSIDC DAAC, via Microsoft Planetary Computer; NRCS SNOTEL daily SWE from the AWDB REST API; Copernicus DEM GLO-30 (© DLR e.V. 2010–2014 and © Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA) from the AWS Open Data registry. Water year 2024: 1 October 2023 to 30 September 2024.

import numpy as np
import pandas as pd
import planetary_computer
import pystac_client
import rasterio
import requests
from pyproj import Transformer
from rasterio.warp import Resampling, reproject, transform_bounds
from rasterio.windows import from_bounds

BOX = (-105.9, 40.1, -105.5, 40.5)                        # Rocky Mountain National Park, lon/lat
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
                                    modifier=planetary_computer.sign_inplace)
AWDB = "https://wcc.sc.egov.usda.gov/awdbRestApi/services/v1"
STATIONS = ["322:CO:SNTL", "1042:CO:SNTL", "412:CO:SNTL", "565:CO:SNTL", "688:CO:SNTL", "1251:CO:SNTL", "870:CO:SNTL"]

def read_window(items, box):
    """Read the same window of NDSI_Snow_Cover from each item; keep the last file for each date."""
    by_date = {}
    for it in sorted(items, key=lambda i: i.id):
        href = planetary_computer.sign(it).assets["NDSI_Snow_Cover"].href
        with rasterio.open(href) as src:
            w = from_bounds(*transform_bounds("EPSG:4326", src.crs, *box), src.transform).round_offsets().round_lengths()
            by_date[it.properties["start_datetime"][:10]] = src.read(1, window=w)
            read_window.transform, read_window.crs = src.window_transform(w), src.crs
    dates = pd.to_datetime(sorted(by_date))
    return np.array([by_date[d.strftime("%Y-%m-%d")] for d in dates]), dates

def station_check(filled, dates):
    meta = requests.get(f"{AWDB}/stations?stationTriplets={','.join(STATIONS)}", timeout=60).json()
    to_sin = Transformer.from_crs(4326, read_window.crs, always_xy=True)
    out = []
    for m in sorted(meta, key=lambda m: m["elevation"]):
        col, row = ~read_window.transform * to_sin.transform(m["longitude"], m["latitude"])
        v = requests.get(f"{AWDB}/data?stationTriplets={m['stationTriplet']}&elements=WTEQ&duration=DAILY"
                         "&beginDate=2023-10-01&endDate=2024-09-30", timeout=60).json()[0]["data"][0]["values"]
        swe = pd.Series([x["value"] for x in v], index=pd.to_datetime([x["date"] for x in v]))
        peak = swe.idxmax()
        ground = swe[peak:][swe[peak:] <= 0].index.min()
        pix = pd.Series(filled[:, int(row), int(col)], index=dates)[peak:]
        out.append((m["name"], m["elevation"] * 0.3048, ground, pix[pix == 0].index.min()))
    return out

print(len(STATIONS), "SNOTEL stations in the box")
7 SNOTEL stations in the box

Example 1: snow-cover duration against elevation

Snow-covered days per pixel, against elevation from the Copernicus DEM resampled to the MODIS grid:

dem = np.zeros(stack.shape[1:], np.float32)
dem_url = ("https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N40_00_W106_00_DEM/"
           "Copernicus_DSM_COG_10_N40_00_W106_00_DEM.tif")
with rasterio.open(dem_url) as src:
    reproject(src.read(1), dem, src_transform=src.transform, src_crs=src.crs,
              dst_transform=read_window.transform, dst_crs=read_window.crs, resampling=Resampling.average)
days = np.nansum(filled == 1, axis=0)
for lo in range(2400, 3800, 200):
    m = (dem >= lo) & (dem < lo + 200)
    print(f"{lo}-{lo + 200} m: {m.sum():5d} pixels, median {np.median(days[m]):.0f} snow days")
2400-2600 m:  2039 pixels, median 118 snow days
2600-2800 m:  1753 pixels, median 139 snow days
2800-3000 m:  1349 pixels, median 160 snow days
3000-3200 m:  1488 pixels, median 180 snow days
3200-3400 m:  1610 pixels, median 197 snow days
3400-3600 m:  1247 pixels, median 203 snow days
3600-3800 m:   497 pixels, median 203 snow days

Duration climbs steeply up to about 3,300 m and then flattens: above that, almost every pixel holds snow for the whole season the satellite can see, and the length of the season is limited by the calendar rather than the elevation.

Example 2: Terra and Aqua on the same day

Aqua passes about three hours after Terra. Comparing clear-sky shares for the same dates shows why Terra is the first choice:

aqua = [i for i in catalog.search(collections=["modis-10A1-061"], bbox=BOX, datetime="2024-01-01/2024-01-31").items()
        if i.id.startswith("MYD10A1") and ".h09v04." in i.id]
a_stack, a_dates = read_window(aqua, BOX)
t_clear = pd.Series(valid.mean((1, 2)), index=dates).loc["2024-01"]
a_clear = pd.Series((a_stack <= 100).mean((1, 2)), index=a_dates)
print(f"January 2024 clear-sky share: Terra {t_clear.mean():.0%}, Aqua {a_clear.mean():.0%}")
January 2024 clear-sky share: Terra 31%, Aqua 27%

Codes, not just snow and no snow

MOD10A1 does not deliver "snow" and "no snow". It delivers the Normalised Difference Snow Index — the contrast between visible and short-wave infrared reflectance, high for snow because snow is bright in the visible and dark in the infrared — scaled by 100, with codes for everything the algorithm could not judge. Turning that into snow cover is the user's decision: NDSI 0.1 is the threshold the product's own documentation suggests for "any snow" in a pixel, and it is the one used here.

Values in the MOD10A1 NDSI_Snow_Cover layer and their shares over Rocky Mountain National Park, water year 2024. what the numbers in NDSI_Snow_Cover meanvaluemeaningshare0–100NDSI × 100, clear land51%250cloud47.6%201no decision0.4%237inland water0.4%255fill0.5%Snow is NDSI ≥ 10 here; everything above 100 is no data spatialworkflow.io
Half the record is cloud.

Cloud is the central problem. On a typical day half the box is unseen, and the unseen half is not random — storms bring cloud and new snow together. A daily percentage over clear pixels alone swings with the weather; filling each pixel from its own recent past uses the fact that snow cover persists for days, and limiting the fill to five days stops a single early-season observation from being carried through a storm.

The ground check is what makes the series usable. At open, high sites MODIS and the snow pillows agreed on melt-out within a few days; under forest MODIS ran up to three weeks early. That is the well-known canopy limitation of optical snow mapping, measured here rather than assumed — and a reason to quote satellite melt-out dates for forested areas with care.

Codes above 100, sinusoidal tiles and forest

Why MODIS melt-out agrees with SNOTEL at open sites and runs early in forest. open ground versus forestopen, high sites✓Lake Irene: −3 days✓Willow Park: +1 day✓satellite sees the snow✓melt-out dates agreeforested sites✓Wild Basin: −22 days✓Sawtooth: −20 days✓satellite sees canopy✓snow under trees hiddenA 500 m pixel in forest reports the trees, not the ground spatialworkflow.io
The limit of optical snow mapping, measured.
  • Values above 100 are codes, never snow fractions; mask them first.
  • Sinusoidal tiles meet along 10° lines; a box crossing one needs two tiles a day.
  • Duplicate dates occur when a file was reprocessed; keep one per date.
  • Terra before Aqua for fewer clouds over mountains.
  • Forest hides snow from above; expect early melt-out under canopy.
  • The threshold is a choice. NDSI ≥ 10 counts thin and partial snow; a higher threshold shortens the season.

FAQ

How do I get MODIS snow cover data in Python?

Search the modis-10A1-061 collection on Microsoft Planetary Computer with pystac-client, sign the items with planetary_computer, and read a window of the NDSI_Snow_Cover asset with rasterio.

What do the values in MOD10A1 NDSI_Snow_Cover mean?

0–100 is the NDSI × 100 for clear land pixels; 200 and above are codes such as 250 for cloud, 237 for inland water and 211 for night.

How do I deal with clouds in MODIS snow data?

Compute daily statistics over clear pixels only, and fill each pixel's cloudy days from its most recent clear observation for a limited number of days.

How accurate is MODIS snow melt-out?

At seven SNOTEL stations in Rocky Mountain National Park in 2024, MODIS melt-out was a median of five days early; at forested stations up to three weeks early, at open high-elevation stations within three days.