Areas of interest, time ranges and the catalog#

Every loader in the package takes the same two positional inputs, aoi and time, and they accept the same forms everywhere. esd.aoi.parse_aoi turns a bounding-box tuple, a shapely geometry, a GeoDataFrame in any CRS or an odc.geo GeoBox into one AOI with a footprint in EPSG:4326, a UTM zone and a grid on request. esd.temporal.parse_time turns "2023-10", a (start, end) pair or a STAC-style "2023-10/2024-06" interval into an inclusive pair of timestamps, expanding partial dates to the period they name.

The catalog is the third shared piece: one declarative entry per product, with its access routes, which of them need credentials, its variables and its citation. esd.catalog.list, search and describe read it, and so do the generated documentation pages and the weekly health probes, so the table here cannot disagree with the docs.

The one figure draws the four AOI forms and the 100 m UTM grid one of them implies over a basemap, in the UTM zone the AOI itself picked. Nothing here needs credentials.

import geopandas as gpd
import matplotlib.pyplot as plt
import shapely
from matplotlib.lines import Line2D

import easysnowdata as esd

print(f"easysnowdata {esd.__version__}")
easysnowdata 0.3.3.dev40+g1bd7bbbf4

Form 1: a (west, south, east, north) tuple in EPSG:4326. .bounds echoes it, .utm_crs is the UTM zone at the centroid (here zone 10N), and .to_geobox(resolution=100) is the grid a loader would resample onto with crs="utm": 100 m pixels, whole pixels, in metres.

bbox = (-121.94, 46.72, -121.54, 46.99)  # Mount Rainier
from_tuple = esd.aoi.parse_aoi(bbox)
print(from_tuple)
print("bounds  ", from_tuple.bounds)
print("utm_crs ", from_tuple.utm_crs)
geobox = from_tuple.to_geobox(resolution=100)
print("geobox  ", geobox.shape, geobox.crs, geobox.resolution)
AOI(bounds=(-121.9400, 46.7200, -121.5400, 46.9900), clip=True)
bounds   (-121.94, 46.72, -121.54, 46.99)
utm_crs  EPSG:32610
geobox   Shape2d(x=310, y=306) EPSG:32610 Resolution(x=100, y=-100)

Form 2: a shapely geometry, also in EPSG:4326. A 10 km circle around the summit, built in UTM and handed over in degrees the way a GeoJSON would be.

summit = gpd.GeoSeries([shapely.Point(-121.7603, 46.8523)], crs="EPSG:4326")
circle = summit.to_crs(from_tuple.utm_crs).buffer(10_000).to_crs("EPSG:4326")
from_shapely = esd.aoi.parse_aoi(circle.iloc[0])
print(from_shapely)
AOI(bounds=(-121.8914, 46.7623, -121.6292, 46.9423), clip=True)

Form 3: a GeoDataFrame in another CRS. Loaders reproject it themselves, so a basin outline in a projected CRS needs no to_crs first; source_crs remembers what came in.

square_utm_gdf = gpd.GeoDataFrame(
    geometry=[shapely.box(590_000, 5_180_000, 610_000, 5_200_000)], crs="EPSG:32610"
)
from_gdf = esd.aoi.parse_aoi(square_utm_gdf)
print(from_gdf, "source_crs:", from_gdf.source_crs.to_string())
AOI(bounds=(-121.8213, 46.7645, -121.5546, 46.9474), clip=True) source_crs: EPSG:32610

Form 4: an odc.geo GeoBox. This is the form to use when a product should land on a grid you already have (another product’s, say): the AOI keeps it as its native grid and to_geobox() with no arguments hands it back unchanged.

native = from_tuple.to_geobox(resolution=500)
from_geobox = esd.aoi.parse_aoi(native)
print(from_geobox, "native grid returned:", from_geobox.to_geobox() is native)
AOI(bounds=(-121.9466, 46.7147, -121.5270, 46.9981), clip=True, geobox=Shape2d(x=63, y=62)) native grid returned: True

clip=False is stored on the AOI, not acted on here: raster loaders then return the covering tiles or granules whole instead of clipping to the footprint, which is what you want when the AOI edge should not cut a scene.

print("clip:", from_tuple.clip, "->", esd.aoi.parse_aoi(bbox, clip=False).clip)
clip: True -> False

Time. A partial date expands to the whole period it names, a pair is taken as given (a date with no time runs to the end of that day), and the STAC start/end string does the same with one argument. An open end, as in "2024-06/.." or None, means now, computed when the loader is called.

for when in ("2023-10", ("2023-10-01", "2024-06-30"), "2023-10/2024-06", "2024-06/.."):
    start, end = esd.temporal.parse_time(when)
    print(f"{when!s:34} -> {start}  to  {end}")
print("today:", esd.temporal.today())
2023-10                            -> 2023-10-01 00:00:00  to  2023-10-31 23:59:59
('2023-10-01', '2024-06-30')       -> 2023-10-01 00:00:00  to  2024-06-30 23:59:59
2023-10/2024-06                    -> 2023-10-01 00:00:00  to  2024-06-30 23:59:59
2024-06/..                         -> 2024-06-01 00:00:00  to  2026-10-04 08:14:40.735083
today: 2026-10-04

The catalog: which themes exist, what one theme holds, and a free-text search across ids, titles, descriptions, tags, variables and source notes.

print(esd.catalog.themes())
columns = ["title", "default_source", "requires", "credential_free"]
print(esd.catalog.list(theme="snow")[columns].to_string())
print("\nproducts mentioning 'swe':", ", ".join(esd.catalog.search("swe").index))
['stations', 'snow', 'sar', 'optical', 'terrain', 'land', 'hydro', 'boundaries', 'climate']
                                                                         title default_source   requires  credential_free
id
modis-snow                       MODIS snow cover (MOD10A1, MOD10A2, MOD10A1F)          nsidc  earthdata             True
mountain-snow-mask                 Wrzesien global seasonal mountain snow mask         zenodo       none             True
snodas                             SNODAS snow water equivalent and snow depth          nsidc       none             True
snow-classification                Sturm & Liston seasonal snow classification          nsidc  earthdata             True
ucla-snow-reanalysis  UCLA snow reanalysis (Western US and High Mountain Asia)          nsidc  earthdata            False
viirs-snow                                VIIRS snow cover (VNP10A1, VNP10A1F)          nsidc  earthdata            False

products mentioning 'swe': koppen-geiger, grdc-wmo-basins, snodas, ucla-snow-reanalysis, awdb-stations, cdec-stations, databc-stations, nve-stations, yukon-stations, snow-station-archive

describe renders one product as Markdown: every access route with its credentials, resolution and latency, then the variables. It is the same text the generated docs page is built from.

print("\n".join(esd.catalog.describe("snodas").splitlines()[:15]))
# SNODAS snow water equivalent and snow depth (`snodas`)

NOHRSC Snow Data Assimilation System daily 1 km snow water equivalent, snow depth, melt, sublimation, and snowpack temperature over the CONUS (masked) or the wider modelling domain (unmasked), 2003-10 onward.

| source | provider | credentials | resolution | extent | temporal | latency | notes |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `nsidc` (default) | raster_http | none | 1000 m | CONUS (masked) / North America (unmasked) | 2003-10/present | ~1 day | authoritative NOHRSC archive: one tar per day of gzipped flat-binary grids plus text headers; no credentials, no cloud-native mirror exists |
| `gee-climate-engine` | gee | earthengine | 1000 m | CONUS | 2003-10/present | ~1 day | Climate Engine's community re-hosting: analysis-ready and lazy, but a non-authoritative mirror with only SWE and snow depth |

| variable | units | dtype | nodata | categorical |
| --- | --- | --- | --- | --- |
| `SWE` | m | float32 | — | no |
| `snow_depth` | m | float32 | — | no |
| `snow_melt` | m | float32 | — | no |

The four footprints and the 100 m grid outline in the AOI’s UTM zone. The tuple, the 500 m GeoBox built from it and the 100 m geobox almost coincide: a grid is a little larger than the footprint it covers because it snaps to whole pixels. The graticule, scale bar and basemap come from finish_map.

utm = from_tuple.utm_crs
layers = [
    ("tuple", from_tuple, "#1f77b4", "-", 2.2),
    ("shapely circle", from_shapely, "#d62728", "-", 2.2),
    ("GeoDataFrame in EPSG:32610", from_gdf, "#2ca02c", "-", 2.2),
    ("GeoBox, 500 m", from_geobox, "#9467bd", "-", 1.2),
]
fig, ax = plt.subplots(figsize=(7.5, 6.8))
handles = []
for name, parsed, color, style, width in layers:
    parsed.to_crs(utm).boundary.plot(
        ax=ax, color=color, linestyle=style, linewidth=width
    )
    handles.append(
        Line2D([], [], color=color, linestyle=style, linewidth=width, label=name)
    )
grid_outline = gpd.GeoSeries([geobox.extent.geom], crs=str(geobox.crs)).to_crs(utm)
grid_outline.boundary.plot(ax=ax, color="black", linestyle="--", linewidth=1.2)
handles.append(
    Line2D([], [], color="black", linestyle="--", label="100 m UTM geobox of the tuple")
)
x0, y0, x1, y1 = from_tuple.to_crs(utm).total_bounds
pad = 0.1 * (x1 - x0)
ax.set_xlim(x0 - pad, x1 + pad)
ax.set_ylim(y0 - pad, y1 + pad)
ax.legend(
    handles=handles,
    fontsize=8,
    loc="lower left",
    bbox_to_anchor=(0.0, 1.02),
    ncol=2,
    frameon=False,
)
esd.plotting.finish_map(ax, utm, basemap=True)
fig.tight_layout()
plot aoi time catalog