Skip to content

Glaciers — API reference#

Glacier outlines / fluctuations backend subpackage — earthlens.glaciers. Background and usage are covered under the other pages in this section (Introduction, Usage, Available datasets); this page is the rendered API. All three sources are open, so the backend ships no auth module; vector file I/O goes through pyramids FeatureCollection.read_file and the WGMS path is pure pandas.

earthlens.glaciers #

Glacier data backend (earthlens.glaciers).

One mixed backend over three open glacier sources:

  • RGI 7.0 (Randolph Glacier Inventory) — global glacier outlines, one shapefile per GTN-G first-order region, served openly by UNESCO IHP-WINS; the backend maps the request bbox to the overlapping region(s), downloads + caches each region ZIP, reads it via pyramids FeatureCollection.read_file, and clips to the bbox; vector.
  • GLIMS (Global Land Ice Measurements from Space) — time-series glacier outlines, queried by bbox through the open GLIMS GeoServer WFS; vector.
  • WGMS — Fluctuations of Glaciers database (mass balance, front variation / length change, glacier state) as tabular CSV tables; tabular.

Output is per instance: a dataset's output_kind decides whether :meth:~earthlens.glaciers.backend.Glaciers.download returns a pyramids :class:~pyramids.feature.collection.FeatureCollection (vector, rgi/glims) or a :class:pandas.DataFrame (tabular, wgms). All three sources are open, so the backend ships no auth. Vector file I/O always goes through pyramids FeatureCollection.read_file — never a bare geopandas file read — and the WGMS path is pure pandas with no array/NetCDF stack.

The public surface is the :class:Catalog (dataset id -> source + output kind + request detail, plus the GTN-G :class:Region table) and its :class:Dataset rows, the backend :class:Glaciers, and the stateless query/read helpers.

Catalog #

Bases: AbstractCatalog

Dataset + region catalog for the glaciers backend.

Reads the bundled sharded catalog/ directory (shipped as package data) and exposes its datasets: blocks as a map of :class:Dataset rows keyed by id, plus the GTN-G regions: table as a map of :class:Region rows. Instantiate with no arguments (Catalog()). Resolve one row with :meth:get, list the shipped ids with :meth:available, and read the region table via :attr:regions.

Attributes:

Name Type Description
datasets dict[str, Dataset]

Map from dataset id to its :class:Dataset row.

regions dict[str, Region]

Map from GTN-G region id to its :class:Region row.

Examples:

  • Resolve rows and a region:
    >>> from earthlens.glaciers import Catalog
    >>> cat = Catalog()
    >>> cat.get("rgi:outlines").output_kind
    'vector'
    >>> cat.get("wgms:mass_balance").output_kind
    'tabular'
    >>> cat.regions["11"].name
    'Central Europe'
    
  • An unknown but close id raises with a did-you-mean hint:
    >>> from earthlens.glaciers import Catalog
    >>> Catalog().get("rgi:outline")
    Traceback (most recent call last):
        ...
    ValueError: 'rgi:outline' is not in the glaciers catalog. Known datasets: [...]. Did you mean 'rgi:outlines'?
    
Source code in src/earthlens/glaciers/catalog.py
class Catalog(AbstractCatalog):
    """Dataset + region catalog for the glaciers backend.

    Reads the bundled sharded `catalog/` directory (shipped as package data) and
    exposes its `datasets:` blocks as a map of :class:`Dataset` rows keyed by id,
    plus the GTN-G `regions:` table as a map of :class:`Region` rows.
    Instantiate with no arguments (`Catalog()`). Resolve one row with
    :meth:`get`, list the shipped ids with :meth:`available`, and read the region
    table via :attr:`regions`.

    Attributes:
        datasets: Map from dataset id to its :class:`Dataset` row.
        regions: Map from GTN-G region id to its :class:`Region` row.

    Examples:
        - Resolve rows and a region:
            ```python
            >>> from earthlens.glaciers import Catalog
            >>> cat = Catalog()
            >>> cat.get("rgi:outlines").output_kind
            'vector'
            >>> cat.get("wgms:mass_balance").output_kind
            'tabular'
            >>> cat.regions["11"].name
            'Central Europe'

            ```
        - An unknown but close id raises with a did-you-mean hint:
            ```python
            >>> from earthlens.glaciers import Catalog
            >>> Catalog().get("rgi:outline")
            Traceback (most recent call last):
                ...
            ValueError: 'rgi:outline' is not in the glaciers catalog. Known datasets: [...]. Did you mean 'rgi:outlines'?

            ```
    """

    _catalog_kind: str = "glaciers catalog"
    _entry_noun: str = "datasets"

    #: The dataset rows live in the base :attr:`datasets` field so the inherited
    #: dict surface (`len`, `in`, `[]`, iteration) and :meth:`get_dataset`'s
    #: did-you-mean hint work unchanged.
    datasets: dict[str, Dataset] = Field(default_factory=dict)

    #: GTN-G first-order region id -> :class:`Region` (RGI download metadata).
    regions: dict[str, Region] = Field(default_factory=dict)

    def model_post_init(self, __context: Any) -> None:
        """Auto-load the bundled catalog when no rows were supplied.

        `Catalog()` with no args reads :data:`CATALOG_PATH`; passing
        `datasets=...` skips the disk read.

        Raises:
            ValueError: Propagated from :meth:`load` when the YAML is missing,
                empty, or has a malformed row.
        """
        if not self.datasets:
            loaded = Catalog.load()
            self.datasets = loaded.datasets
            self.regions = loaded.regions
            self.available_datasets = loaded.available_datasets
        super().model_post_init(__context)

    @classmethod
    def load(cls, catalog_path: Path | None = None) -> Catalog:
        """Read the glaciers catalog from disk.

        Args:
            catalog_path: Path to the catalog directory or a single YAML.
                Defaults to the module-level :data:`CATALOG_PATH`.

        Returns:
            A fully-populated :class:`Catalog`.

        Raises:
            ValueError: If the catalog has no `datasets:` block, or a row fails
                validation.
        """
        catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
        datasets, regions, available = _load_catalog_data(catalog_path)
        return cls(
            datasets=dict(datasets),
            regions=dict(regions),
            available_datasets=list(available),
        )

    def get_catalog(self) -> dict[str, Dataset]:
        """Return the dataset map (satisfies the abstract contract).

        Returns:
            dict[str, Dataset]: Same object as :attr:`datasets`.
        """
        return self.datasets

    def get(self, dataset_id: str) -> Dataset:
        """Resolve a dataset id to its :class:`Dataset` row.

        Thin wrapper over the inherited :meth:`get_dataset`, which raises a
        `ValueError` with a did-you-mean hint on an unknown id.

        Args:
            dataset_id: A shipped dataset id (`"rgi:outlines"`).

        Returns:
            Dataset: The matching catalog row.

        Raises:
            ValueError: If `dataset_id` is not a known dataset; the message names
                the catalog kind and, when a close match exists, adds a
                did-you-mean hint.
        """
        return self.get_dataset(dataset_id)

    def available(self) -> list[str]:
        """Return the sorted list of shipped dataset ids.

        Returns:
            list[str]: Every catalog key, sorted.
        """
        return sorted(self.datasets)

available() #

Return the sorted list of shipped dataset ids.

Returns:

Type Description
list[str]

list[str]: Every catalog key, sorted.

Source code in src/earthlens/glaciers/catalog.py
def available(self) -> list[str]:
    """Return the sorted list of shipped dataset ids.

    Returns:
        list[str]: Every catalog key, sorted.
    """
    return sorted(self.datasets)

get(dataset_id) #

Resolve a dataset id to its :class:Dataset row.

Thin wrapper over the inherited :meth:get_dataset, which raises a ValueError with a did-you-mean hint on an unknown id.

Parameters:

Name Type Description Default
dataset_id str

A shipped dataset id ("rgi:outlines").

required

Returns:

Name Type Description
Dataset Dataset

The matching catalog row.

Raises:

Type Description
ValueError

If dataset_id is not a known dataset; the message names the catalog kind and, when a close match exists, adds a did-you-mean hint.

Source code in src/earthlens/glaciers/catalog.py
def get(self, dataset_id: str) -> Dataset:
    """Resolve a dataset id to its :class:`Dataset` row.

    Thin wrapper over the inherited :meth:`get_dataset`, which raises a
    `ValueError` with a did-you-mean hint on an unknown id.

    Args:
        dataset_id: A shipped dataset id (`"rgi:outlines"`).

    Returns:
        Dataset: The matching catalog row.

    Raises:
        ValueError: If `dataset_id` is not a known dataset; the message names
            the catalog kind and, when a close match exists, adds a
            did-you-mean hint.
    """
    return self.get_dataset(dataset_id)

get_catalog() #

Return the dataset map (satisfies the abstract contract).

Returns:

Type Description
dict[str, Dataset]

dict[str, Dataset]: Same object as :attr:datasets.

Source code in src/earthlens/glaciers/catalog.py
def get_catalog(self) -> dict[str, Dataset]:
    """Return the dataset map (satisfies the abstract contract).

    Returns:
        dict[str, Dataset]: Same object as :attr:`datasets`.
    """
    return self.datasets

load(catalog_path=None) classmethod #

Read the glaciers catalog from disk.

Parameters:

Name Type Description Default
catalog_path Path | None

Path to the catalog directory or a single YAML. Defaults to the module-level :data:CATALOG_PATH.

None

Returns:

Type Description
Catalog

A fully-populated :class:Catalog.

Raises:

Type Description
ValueError

If the catalog has no datasets: block, or a row fails validation.

Source code in src/earthlens/glaciers/catalog.py
@classmethod
def load(cls, catalog_path: Path | None = None) -> Catalog:
    """Read the glaciers catalog from disk.

    Args:
        catalog_path: Path to the catalog directory or a single YAML.
            Defaults to the module-level :data:`CATALOG_PATH`.

    Returns:
        A fully-populated :class:`Catalog`.

    Raises:
        ValueError: If the catalog has no `datasets:` block, or a row fails
            validation.
    """
    catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
    datasets, regions, available = _load_catalog_data(catalog_path)
    return cls(
        datasets=dict(datasets),
        regions=dict(regions),
        available_datasets=list(available),
    )

model_post_init(__context) #

Auto-load the bundled catalog when no rows were supplied.

Catalog() with no args reads :data:CATALOG_PATH; passing datasets=... skips the disk read.

Raises:

Type Description
ValueError

Propagated from :meth:load when the YAML is missing, empty, or has a malformed row.

Source code in src/earthlens/glaciers/catalog.py
def model_post_init(self, __context: Any) -> None:
    """Auto-load the bundled catalog when no rows were supplied.

    `Catalog()` with no args reads :data:`CATALOG_PATH`; passing
    `datasets=...` skips the disk read.

    Raises:
        ValueError: Propagated from :meth:`load` when the YAML is missing,
            empty, or has a malformed row.
    """
    if not self.datasets:
        loaded = Catalog.load()
        self.datasets = loaded.datasets
        self.regions = loaded.regions
        self.available_datasets = loaded.available_datasets
    super().model_post_init(__context)

Dataset #

Bases: BaseModel

One glaciers catalog row.

The dataset id is the parent key in :attr:Catalog.datasets and is also stored on the row as :attr:id so a resolved :class:Dataset is self-describing. Which source-specific fields are populated depends on :attr:source; a cross-field validator enforces that the right ones are present.

Attributes:

Name Type Description
id str

The dataset id ("rgi:outlines", "wgms:mass_balance").

source Source

Which source serves it — "rgi", "glims", or "wgms".

output_kind OutputKind

"vector" (a FeatureCollection, rgi/glims) or "tabular" (a DataFrame, wgms). Copied onto the backend's OUTPUT_KIND per instance.

long_name str

Human-readable label.

citation str

The source's citation string, logged once on use.

table str | None

WGMS only — the CSV table name inside the FoG zip ("mass_balance", "front_variation", "state").

archive_url str | None

WGMS only — the FoG database zip download URL.

wfs_url str | None

GLIMS only — the GeoServer WFS endpoint URL.

wfs_typename str | None

GLIMS only — the WFS feature-type name.

Examples:

  • Build an RGI row directly:
    >>> from earthlens.glaciers import Dataset
    >>> row = Dataset(id="rgi:outlines", source="rgi", output_kind="vector")
    >>> row.source
    'rgi'
    >>> row.output_kind
    'vector'
    
Source code in src/earthlens/glaciers/catalog.py
class Dataset(BaseModel):
    """One glaciers catalog row.

    The dataset id is the parent key in :attr:`Catalog.datasets` and is also
    stored on the row as :attr:`id` so a resolved :class:`Dataset` is
    self-describing. Which source-specific fields are populated depends on
    :attr:`source`; a cross-field validator enforces that the right ones are
    present.

    Attributes:
        id: The dataset id (`"rgi:outlines"`, `"wgms:mass_balance"`).
        source: Which source serves it — `"rgi"`, `"glims"`, or `"wgms"`.
        output_kind: `"vector"` (a `FeatureCollection`, rgi/glims) or
            `"tabular"` (a `DataFrame`, wgms). Copied onto the backend's
            `OUTPUT_KIND` per instance.
        long_name: Human-readable label.
        citation: The source's citation string, logged once on use.
        table: WGMS only — the CSV table name inside the FoG zip
            (`"mass_balance"`, `"front_variation"`, `"state"`).
        archive_url: WGMS only — the FoG database zip download URL.
        wfs_url: GLIMS only — the GeoServer WFS endpoint URL.
        wfs_typename: GLIMS only — the WFS feature-type name.

    Examples:
        - Build an RGI row directly:
            ```python
            >>> from earthlens.glaciers import Dataset
            >>> row = Dataset(id="rgi:outlines", source="rgi", output_kind="vector")
            >>> row.source
            'rgi'
            >>> row.output_kind
            'vector'

            ```
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    id: str
    source: Source
    output_kind: OutputKind
    long_name: str = ""
    citation: str = ""

    # WGMS
    table: str | None = None
    archive_url: str | None = None
    # GLIMS
    wfs_url: str | None = None
    wfs_typename: str | None = None

    @model_validator(mode="after")
    def _check_source_fields(self) -> Dataset:
        """Enforce the per-source required fields and output kind.

        Returns:
            The validated row.

        Raises:
            ValueError: If the row's `output_kind` does not match its `source`,
                a `wgms` row omits `table` / `archive_url`, or a `glims` row
                omits `wfs_url` / `wfs_typename`.
        """
        if self.source in ("rgi", "glims") and self.output_kind != "vector":
            raise ValueError(
                f"{self.source} dataset {self.id!r} must be output_kind 'vector'"
            )
        if self.source == "wgms":
            if self.output_kind != "tabular":
                raise ValueError(
                    f"wgms dataset {self.id!r} must be output_kind 'tabular'"
                )
            if not (self.table and self.archive_url):
                raise ValueError(
                    f"wgms dataset {self.id!r} needs table and archive_url"
                )
        if self.source == "glims" and not (self.wfs_url and self.wfs_typename):
            raise ValueError(
                f"glims dataset {self.id!r} needs wfs_url and wfs_typename"
            )
        return self

Glaciers #

Bases: AbstractDataSource

Glacier outlines / fluctuations backend (mixed output).

Resolves one dataset id to its catalog row, routes the request to that row's source (RGI / GLIMS / WGMS), and returns a pyramids :class:~pyramids.feature.collection.FeatureCollection (vector) or a :class:pandas.DataFrame (tabular) per the row's output_kind. The query is a search/fetch split: :meth:_search pins the products (one per overlapping RGI region, or a single GLIMS / WGMS product) and :meth:_fetch downloads + reads each.

All three sources are open, so the backend needs no credentials. aggregate= is rejected — outlines / fluctuations are pre-computed inventories, not gridded rasters (G8).

Attributes:

Name Type Description
OUTPUT_KIND OutputKind

Set per instance in :meth:__init__ from the resolved dataset's output_kind ("vector" or "tabular"). The facade reads it to gate aggregate= and to know the return shape.

Source code in src/earthlens/glaciers/backend.py
class Glaciers(AbstractDataSource):
    """Glacier outlines / fluctuations backend (mixed output).

    Resolves one dataset id to its catalog row, routes the request to that row's
    source (RGI / GLIMS / WGMS), and returns a pyramids
    :class:`~pyramids.feature.collection.FeatureCollection` (`vector`) or a
    :class:`pandas.DataFrame` (`tabular`) per the row's `output_kind`. The query
    is a search/fetch split: :meth:`_search` pins the products (one per
    overlapping RGI region, or a single GLIMS / WGMS product) and :meth:`_fetch`
    downloads + reads each.

    All three sources are open, so the backend needs no credentials. `aggregate=`
    is rejected — outlines / fluctuations are pre-computed inventories, not
    gridded rasters (`G8`).

    Attributes:
        OUTPUT_KIND: Set **per instance** in :meth:`__init__` from the resolved
            dataset's `output_kind` (`"vector"` or `"tabular"`). The facade reads
            it to gate `aggregate=` and to know the return shape.
    """

    OUTPUT_KIND: OutputKind = "vector"

    def __init__(
        self,
        start: str | None = None,
        end: str | None = None,
        variables: list[str] | None = None,
        lat_lim: list[float] | None = None,
        lon_lim: list[float] | None = None,
        temporal_resolution: str = "annual",
        path: Path | str = "",
        fmt: str = "%Y-%m-%d",
        region: str | list[str] | None = None,
        max_features: int = 10000,
        glacier_id: int | str | list | None = None,
        glacier_name: str | None = None,
        output_format: OutputFormat = "csv",
    ):
        """Initialise a glaciers backend instance.

        Args:
            start: Inclusive start of an optional window, parsed with `fmt`;
                `None` is allowed (outlines are an inventory, not a time series).
            end: Inclusive end of the optional window; `None` allowed.
            variables: A one-element list naming the dataset id
                (`["rgi:outlines"]`). Exactly one dataset is resolved per
                instance, because `OUTPUT_KIND` is per instance.
            lat_lim: `[lat_min, lat_max]` AOI. Required (with `lon_lim`) for an
                RGI request without a `region=` override, and for GLIMS; the
                WGMS path defaults to global.
            lon_lim: `[lon_min, lon_max]` AOI.
            temporal_resolution: Recorded as the resolution label only.
            path: Output directory; also the parent of the download cache.
            fmt: `strptime` format for `start` / `end`.
            region: RGI only — one GTN-G region id (`"11"`) or a list, overriding
                the bbox -> region mapping. Also accepted as a WGMS region filter.
            max_features: GLIMS only — cap on WFS-returned features.
            glacier_id: WGMS only — one glacier id or a list to filter on.
            glacier_name: WGMS only — a case-insensitive substring filter.
            output_format: On-disk format for a written WGMS result — `"csv"`
                (default) or `"parquet"`.

        Raises:
            TypeError: If `variables` is a mapping (pass a list of one id).
            ValueError: If `variables` is not exactly one dataset id,
                `output_format` is unrecognised, or the required spatial selector
                for the resolved source is missing.
        """
        if isinstance(variables, dict):
            raise TypeError(
                "Glaciers `variables` must be a one-element list naming the "
                "dataset id (e.g. ['rgi:outlines']), not a mapping."
            )
        ids = list(dict.fromkeys(variables)) if variables else []
        if len(ids) != 1:
            raise ValueError(
                "Glaciers needs exactly one dataset id in variables= "
                "(OUTPUT_KIND is per instance); got "
                f"{ids!r}. Available: {Catalog().available()}."
            )
        if output_format not in OUTPUT_FORMATS:
            raise ValueError(
                f"output_format must be one of {list(OUTPUT_FORMATS)}, "
                f"got {output_format!r}."
            )

        self._catalog = Catalog()
        self._dataset: Dataset = self._catalog.get(ids[0])
        self._region = region
        self._max_features = max_features
        self._glacier_id = glacier_id
        self._glacier_name = glacier_name
        self._output_format: OutputFormat = output_format

        # G1 — the per-instance output shape comes from the resolved dataset.
        self.OUTPUT_KIND = self._dataset.output_kind

        self._has_bbox = lat_lim is not None and lon_lim is not None
        self._validate_selector()

        super().__init__(
            start=start,
            end=end,
            variables=ids,
            temporal_resolution=temporal_resolution,
            lat_lim=lat_lim if lat_lim is not None else _GLOBAL_LAT,
            lon_lim=lon_lim if lon_lim is not None else _GLOBAL_LON,
            fmt=fmt,
            path=path,
        )
        self._cache_dir = self.root_dir / "_glaciers_cache"

    def _validate_selector(self) -> None:
        """Check the request carries the spatial selector the source needs.

        RGI needs a bbox or a `region=` override (else it would pull all 19
        regions); GLIMS needs a bbox (a global WFS query is rejected); WGMS needs
        nothing (its filters are optional).

        Raises:
            ValueError: When a required selector is missing.
        """
        source = self._dataset.source
        if source == "rgi" and not (self._has_bbox or self._region):
            raise ValueError(
                f"dataset {self._dataset.id!r} (RGI) needs a bbox "
                "(lat_lim=/lon_lim= or aoi=) or a region= override; refusing to "
                "download every GTN-G region."
            )
        if source == "glims" and not self._has_bbox:
            raise ValueError(
                f"dataset {self._dataset.id!r} (GLIMS) needs a bbox "
                "(lat_lim=/lon_lim= or aoi=) — a global WFS query is too large."
            )

    def _initialize(self):
        """No client and no auth — every source is open (`G5`).

        Returns:
            None: No per-instance client object.
        """
        return None

    def _create_grid(self, lat_lim: list, lon_lim: list) -> SpatialExtent:
        """Build the request :class:`SpatialExtent` from the AOI corners.

        Args:
            lat_lim: `[lat_min, lat_max]`.
            lon_lim: `[lon_min, lon_max]`.

        Returns:
            SpatialExtent: The request extent.
        """
        return SpatialExtent.from_pairs(lat_lim=lat_lim, lon_lim=lon_lim)

    def _check_input_dates(
        self,
        start: str | None,
        end: str | None,
        temporal_resolution: str,
        fmt: str,
    ) -> TemporalExtent:
        """Parse the optional `[start, end]` window into a :class:`TemporalExtent`.

        Dates are not part of the request (outlines are an inventory), so `None`
        bounds are allowed and yield a `None`-dated extent.

        Args:
            start: Inclusive start date string, or `None`.
            end: Inclusive end date string, or `None`.
            temporal_resolution: Recorded as the resolution label.
            fmt: `strptime` format applied to `start` and `end`.

        Returns:
            TemporalExtent: Frozen model with the parsed (or `None`) endpoints.

        Raises:
            ValueError: If `start` parses to a date later than `end`.
        """
        start_dt = dt.datetime.strptime(start, fmt) if start else None
        end_dt = dt.datetime.strptime(end, fmt) if end else None
        dates = (
            pd.DatetimeIndex([start_dt, end_dt])
            if start_dt is not None and end_dt is not None
            else pd.DatetimeIndex([])
        )
        return TemporalExtent(
            start_date=start_dt,
            end_date=end_dt,
            resolution=temporal_resolution,
            dates=dates,
        )

    @property
    def _bbox(self) -> list[float]:
        """The request AOI as `[west, south, east, north]` in EPSG:4326."""
        space = self.space
        return [space.west, space.south, space.east, space.north]

    def _api(self) -> list:
        """Compose `_search` and `_fetch` into the canonical shape."""
        return self._api_via_search_fetch()

    def _search(self) -> list[RemoteProduct]:
        """List the products to fetch for the resolved source.

        For RGI, one product per overlapping GTN-G region (`G6`); for GLIMS and
        WGMS, a single product.

        Returns:
            list[RemoteProduct]: The products. Empty when an RGI bbox overlaps no
                glacier region (the vector path then returns an empty
                collection).
        """
        source = self._dataset.source
        if source == "rgi":
            if self._region is not None:
                region_ids = [str(r) for r in _helpers._as_list(self._region)]
            else:
                region_ids = _helpers.regions_for_bbox(
                    self._bbox, self._catalog.regions
                )
            products = []
            for region_id in region_ids:
                region = self._catalog.regions[region_id]
                products.append(
                    RemoteProduct(
                        id=f"{self._dataset.id}/{region_id}",
                        href=region.url,
                        metadata={"region_id": region_id},
                    )
                )
            return products
        return [RemoteProduct(id=self._dataset.id)]

    def _fetch(self, products: list[RemoteProduct]) -> list:
        """Download + read every product `_search` returned.

        Args:
            products: The list from :meth:`_search`.

        Returns:
            list: One :class:`~pyramids.feature.collection.FeatureCollection`
                per RGI region / the GLIMS query (`vector`), or a single
                :class:`pandas.DataFrame` (`tabular`, wgms).
        """
        return [self._fetch_one(product) for product in products]

    def _fetch_one(self, product: RemoteProduct):
        """Fetch and read one product per its source.

        Args:
            product: One :class:`RemoteProduct` from :meth:`_search`.

        Returns:
            A `FeatureCollection` (rgi/glims) or a `DataFrame` (wgms).

        Raises:
            requests.HTTPError: If a download / WFS query fails after retries.
        """
        dataset = self._dataset
        if dataset.source == "rgi":
            zip_path = _helpers.download_zip(product.href, self._cache_dir)
            return _helpers.read_outlines(zip_path, self._bbox)
        if dataset.source == "glims":
            dest = self._cache_dir / "glims_query.geojson"
            return _helpers.fetch_glims(
                dataset.wfs_url,
                dataset.wfs_typename,
                self._bbox,
                dest,
                max_features=self._max_features,
            )
        # source == "wgms"
        zip_path = _helpers.download_zip(dataset.archive_url, self._cache_dir)
        table = _helpers.parse_wgms_csv(zip_path, dataset.table)
        glaciers = _helpers.wgms_glacier_table(zip_path)
        bbox = self._bbox if self._has_bbox else None
        return _helpers.filter_wgms(
            table,
            glaciers,
            glacier_id=self._glacier_id,
            glacier_name=self._glacier_name,
            region=self._region,
            bbox=bbox,
        )

    def download(
        self,
        progress_bar: bool = True,
        aggregate: AggregationConfig | None = None,
    ):
        """Fetch the dataset and return its per-instance shape.

        Args:
            progress_bar: Accepted for signature parity; the per-region RGI
                fetch is a small loop, so this is a no-op.
            aggregate: Must be `None`. Outlines / fluctuations are pre-computed
                inventories, so there is no gridded reduction; the facade already
                rejects a non-`None` `aggregate=` for `vector` / `tabular`, and
                this is the belt-and-suspenders guard for direct callers (`G8`).

        Returns:
            A pyramids :class:`~pyramids.feature.collection.FeatureCollection`
            (`vector`, rgi/glims; clipped, EPSG:4326) or a
            :class:`pandas.DataFrame` (`tabular`, wgms; also written to
            `root_dir`).

        Raises:
            NotImplementedError: If `aggregate` is not `None` (`G8`).
            requests.HTTPError: If a download / WFS query fails after retries.
        """
        if aggregate is not None:
            raise NotImplementedError(
                "Glaciers.download(aggregate=...) is not supported: glacier "
                "outlines / fluctuations are pre-computed inventories, not "
                "gridded rasters, so there is no meaningful gridded reduction. "
                "Call download() without aggregate=."
            )
        results = self._api()
        self._log_citation()
        if self.OUTPUT_KIND == "vector":
            if not results:
                collection = _helpers.empty_feature_collection()
            else:
                collection = _helpers.concat_outlines(results)
            logger.info(
                f"Glaciers {self._dataset.id}: returned a FeatureCollection "
                f"({len(collection)} outline(s))."
            )
            return collection
        df = results[0] if results else _helpers.empty_canonical(["glacier_id"])
        out_path = self._write_table(df)
        if len(df):
            logger.info(
                f"Glaciers {self._dataset.id}: {len(df)} row(s) written to "
                f"{out_path}."
            )
        else:
            logger.warning(
                f"Glaciers {self._dataset.id}: no rows matched; wrote an empty "
                f"(schema-only) table to {out_path}."
            )
        return df

    def _write_table(self, df: pd.DataFrame) -> Path:
        """Write a WGMS result to `root_dir` and return the path.

        Args:
            df: The result frame.

        Returns:
            Path: The written CSV / Parquet file path.

        Raises:
            ImportError: If `output_format="parquet"` but `pyarrow` is missing.
        """
        stem = self._dataset.id.replace(":", "_")
        ext = "parquet" if self._output_format == "parquet" else "csv"
        out_path = self.root_dir / f"{stem}.{ext}"
        if self._output_format == "parquet":
            try:
                df.to_parquet(out_path, index=False)
            except ImportError as exc:  # pragma: no cover - depends on env
                raise ImportError(
                    "Writing Parquet requires 'pyarrow'. Install it (pip "
                    "install pyarrow) or use output_format='csv'."
                ) from exc
        else:
            df.to_csv(out_path, index=False)
        return out_path

    def _log_citation(self) -> None:
        """Log the resolved dataset's source citation once (info, not a warning)."""
        citation = self._dataset.citation
        if citation:
            logger.info(f"Glaciers source citation: {citation}")

__init__(start=None, end=None, variables=None, lat_lim=None, lon_lim=None, temporal_resolution='annual', path='', fmt='%Y-%m-%d', region=None, max_features=10000, glacier_id=None, glacier_name=None, output_format='csv') #

Initialise a glaciers backend instance.

Parameters:

Name Type Description Default
start str | None

Inclusive start of an optional window, parsed with fmt; None is allowed (outlines are an inventory, not a time series).

None
end str | None

Inclusive end of the optional window; None allowed.

None
variables list[str] | None

A one-element list naming the dataset id (["rgi:outlines"]). Exactly one dataset is resolved per instance, because OUTPUT_KIND is per instance.

None
lat_lim list[float] | None

[lat_min, lat_max] AOI. Required (with lon_lim) for an RGI request without a region= override, and for GLIMS; the WGMS path defaults to global.

None
lon_lim list[float] | None

[lon_min, lon_max] AOI.

None
temporal_resolution str

Recorded as the resolution label only.

'annual'
path Path | str

Output directory; also the parent of the download cache.

''
fmt str

strptime format for start / end.

'%Y-%m-%d'
region str | list[str] | None

RGI only — one GTN-G region id ("11") or a list, overriding the bbox -> region mapping. Also accepted as a WGMS region filter.

None
max_features int

GLIMS only — cap on WFS-returned features.

10000
glacier_id int | str | list | None

WGMS only — one glacier id or a list to filter on.

None
glacier_name str | None

WGMS only — a case-insensitive substring filter.

None
output_format OutputFormat

On-disk format for a written WGMS result — "csv" (default) or "parquet".

'csv'

Raises:

Type Description
TypeError

If variables is a mapping (pass a list of one id).

ValueError

If variables is not exactly one dataset id, output_format is unrecognised, or the required spatial selector for the resolved source is missing.

Source code in src/earthlens/glaciers/backend.py
def __init__(
    self,
    start: str | None = None,
    end: str | None = None,
    variables: list[str] | None = None,
    lat_lim: list[float] | None = None,
    lon_lim: list[float] | None = None,
    temporal_resolution: str = "annual",
    path: Path | str = "",
    fmt: str = "%Y-%m-%d",
    region: str | list[str] | None = None,
    max_features: int = 10000,
    glacier_id: int | str | list | None = None,
    glacier_name: str | None = None,
    output_format: OutputFormat = "csv",
):
    """Initialise a glaciers backend instance.

    Args:
        start: Inclusive start of an optional window, parsed with `fmt`;
            `None` is allowed (outlines are an inventory, not a time series).
        end: Inclusive end of the optional window; `None` allowed.
        variables: A one-element list naming the dataset id
            (`["rgi:outlines"]`). Exactly one dataset is resolved per
            instance, because `OUTPUT_KIND` is per instance.
        lat_lim: `[lat_min, lat_max]` AOI. Required (with `lon_lim`) for an
            RGI request without a `region=` override, and for GLIMS; the
            WGMS path defaults to global.
        lon_lim: `[lon_min, lon_max]` AOI.
        temporal_resolution: Recorded as the resolution label only.
        path: Output directory; also the parent of the download cache.
        fmt: `strptime` format for `start` / `end`.
        region: RGI only — one GTN-G region id (`"11"`) or a list, overriding
            the bbox -> region mapping. Also accepted as a WGMS region filter.
        max_features: GLIMS only — cap on WFS-returned features.
        glacier_id: WGMS only — one glacier id or a list to filter on.
        glacier_name: WGMS only — a case-insensitive substring filter.
        output_format: On-disk format for a written WGMS result — `"csv"`
            (default) or `"parquet"`.

    Raises:
        TypeError: If `variables` is a mapping (pass a list of one id).
        ValueError: If `variables` is not exactly one dataset id,
            `output_format` is unrecognised, or the required spatial selector
            for the resolved source is missing.
    """
    if isinstance(variables, dict):
        raise TypeError(
            "Glaciers `variables` must be a one-element list naming the "
            "dataset id (e.g. ['rgi:outlines']), not a mapping."
        )
    ids = list(dict.fromkeys(variables)) if variables else []
    if len(ids) != 1:
        raise ValueError(
            "Glaciers needs exactly one dataset id in variables= "
            "(OUTPUT_KIND is per instance); got "
            f"{ids!r}. Available: {Catalog().available()}."
        )
    if output_format not in OUTPUT_FORMATS:
        raise ValueError(
            f"output_format must be one of {list(OUTPUT_FORMATS)}, "
            f"got {output_format!r}."
        )

    self._catalog = Catalog()
    self._dataset: Dataset = self._catalog.get(ids[0])
    self._region = region
    self._max_features = max_features
    self._glacier_id = glacier_id
    self._glacier_name = glacier_name
    self._output_format: OutputFormat = output_format

    # G1 — the per-instance output shape comes from the resolved dataset.
    self.OUTPUT_KIND = self._dataset.output_kind

    self._has_bbox = lat_lim is not None and lon_lim is not None
    self._validate_selector()

    super().__init__(
        start=start,
        end=end,
        variables=ids,
        temporal_resolution=temporal_resolution,
        lat_lim=lat_lim if lat_lim is not None else _GLOBAL_LAT,
        lon_lim=lon_lim if lon_lim is not None else _GLOBAL_LON,
        fmt=fmt,
        path=path,
    )
    self._cache_dir = self.root_dir / "_glaciers_cache"

download(progress_bar=True, aggregate=None) #

Fetch the dataset and return its per-instance shape.

Parameters:

Name Type Description Default
progress_bar bool

Accepted for signature parity; the per-region RGI fetch is a small loop, so this is a no-op.

True
aggregate AggregationConfig | None

Must be None. Outlines / fluctuations are pre-computed inventories, so there is no gridded reduction; the facade already rejects a non-None aggregate= for vector / tabular, and this is the belt-and-suspenders guard for direct callers (G8).

None

Returns:

Type Description

A pyramids :class:~pyramids.feature.collection.FeatureCollection

(vector, rgi/glims; clipped, EPSG:4326) or a

class:pandas.DataFrame (tabular, wgms; also written to

root_dir).

Raises:

Type Description
NotImplementedError

If aggregate is not None (G8).

HTTPError

If a download / WFS query fails after retries.

Source code in src/earthlens/glaciers/backend.py
def download(
    self,
    progress_bar: bool = True,
    aggregate: AggregationConfig | None = None,
):
    """Fetch the dataset and return its per-instance shape.

    Args:
        progress_bar: Accepted for signature parity; the per-region RGI
            fetch is a small loop, so this is a no-op.
        aggregate: Must be `None`. Outlines / fluctuations are pre-computed
            inventories, so there is no gridded reduction; the facade already
            rejects a non-`None` `aggregate=` for `vector` / `tabular`, and
            this is the belt-and-suspenders guard for direct callers (`G8`).

    Returns:
        A pyramids :class:`~pyramids.feature.collection.FeatureCollection`
        (`vector`, rgi/glims; clipped, EPSG:4326) or a
        :class:`pandas.DataFrame` (`tabular`, wgms; also written to
        `root_dir`).

    Raises:
        NotImplementedError: If `aggregate` is not `None` (`G8`).
        requests.HTTPError: If a download / WFS query fails after retries.
    """
    if aggregate is not None:
        raise NotImplementedError(
            "Glaciers.download(aggregate=...) is not supported: glacier "
            "outlines / fluctuations are pre-computed inventories, not "
            "gridded rasters, so there is no meaningful gridded reduction. "
            "Call download() without aggregate=."
        )
    results = self._api()
    self._log_citation()
    if self.OUTPUT_KIND == "vector":
        if not results:
            collection = _helpers.empty_feature_collection()
        else:
            collection = _helpers.concat_outlines(results)
        logger.info(
            f"Glaciers {self._dataset.id}: returned a FeatureCollection "
            f"({len(collection)} outline(s))."
        )
        return collection
    df = results[0] if results else _helpers.empty_canonical(["glacier_id"])
    out_path = self._write_table(df)
    if len(df):
        logger.info(
            f"Glaciers {self._dataset.id}: {len(df)} row(s) written to "
            f"{out_path}."
        )
    else:
        logger.warning(
            f"Glaciers {self._dataset.id}: no rows matched; wrote an empty "
            f"(schema-only) table to {out_path}."
        )
    return df

Region #

Bases: BaseModel

One GTN-G first-order region row (RGI per-region download metadata).

Attributes:

Name Type Description
id str

The two-digit GTN-G region id ("01""19").

name str

The region's full name ("Central Europe").

bboxes list[list[float]]

One or more [west, south, east, north] bounding boxes in EPSG:4326. Most regions have a single box; region 10 (North Asia) crosses the antimeridian and carries two.

url str

The full IHP-WINS resource download URL for this region's outline shapefile zip.

Examples:

  • A region's corners and download URL:
    >>> from earthlens.glaciers import Catalog
    >>> region = Catalog().regions["11"]
    >>> region.name
    'Central Europe'
    >>> region.bboxes[0]
    [-6.0, 40.0, 26.0, 50.0]
    
Source code in src/earthlens/glaciers/catalog.py
class Region(BaseModel):
    """One GTN-G first-order region row (RGI per-region download metadata).

    Attributes:
        id: The two-digit GTN-G region id (`"01"` … `"19"`).
        name: The region's full name (`"Central Europe"`).
        bboxes: One or more `[west, south, east, north]` bounding boxes in
            EPSG:4326. Most regions have a single box; region 10 (North Asia)
            crosses the antimeridian and carries two.
        url: The full IHP-WINS resource download URL for this region's outline
            shapefile zip.

    Examples:
        - A region's corners and download URL:
            ```python
            >>> from earthlens.glaciers import Catalog
            >>> region = Catalog().regions["11"]
            >>> region.name
            'Central Europe'
            >>> region.bboxes[0]
            [-6.0, 40.0, 26.0, 50.0]

            ```
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    id: str
    name: str
    bboxes: list[list[float]]
    url: str

    @model_validator(mode="after")
    def _check_bboxes(self) -> Region:
        """Validate every bbox is a `[west, south, east, north]` quadruple.

        Returns:
            The validated row.

        Raises:
            ValueError: If `bboxes` is empty or any box is not length 4.
        """
        if not self.bboxes:
            raise ValueError(f"region {self.id!r} has no bboxes")
        for box in self.bboxes:
            if len(box) != 4:
                raise ValueError(
                    f"region {self.id!r} bbox {box!r} must be "
                    "[west, south, east, north]"
                )
        return self

concat_outlines(fragments) #

Merge per-region outline fragments into one collection.

Parameters:

Name Type Description Default
fragments list[FeatureCollection]

One :class:FeatureCollection per region (or query).

required

Returns:

Name Type Description
FeatureCollection FeatureCollection

The concatenated collection in EPSG:4326. The first non-empty fragment when only one carries features; an empty collection when every fragment is empty.

Raises:

Type Description
ValueError

If fragments is empty.

Source code in src/earthlens/glaciers/_helpers.py
def concat_outlines(fragments: list[FeatureCollection]) -> FeatureCollection:
    """Merge per-region outline fragments into one collection.

    Args:
        fragments: One :class:`FeatureCollection` per region (or query).

    Returns:
        FeatureCollection: The concatenated collection in EPSG:4326. The first
            non-empty fragment when only one carries features; an empty
            collection when every fragment is empty.

    Raises:
        ValueError: If `fragments` is empty.
    """
    if not fragments:
        raise ValueError("concat_outlines() needs at least one fragment")
    non_empty = [fc for fc in fragments if len(fc) > 0]
    if not non_empty:
        return FeatureCollection(fragments[0])
    if len(non_empty) == 1:
        return FeatureCollection(non_empty[0])
    merged = pd.concat(non_empty, ignore_index=True)
    return FeatureCollection(merged, crs=non_empty[0].crs)

download_zip(url, dest_dir, *, session=None, retries=3, backoff=2.0, timeout=180.0, chunk_size=1 << 20) #

Stream a .zip to dest_dir and return the local path (idempotent).

The glaciers vector / tabular paths read inside the zip (RGI via /vsizip/, WGMS via pandas), so — unlike the ghsl raster path — the archive is kept, not extracted. If the file already exists it is returned without re-downloading. Retries transient HTTP failures with exponential backoff.

Parameters:

Name Type Description Default
url str

The .zip URL (an IHP-WINS RGI region or the WGMS FoG archive).

required
dest_dir Path

Directory to cache into (created if absent).

required
session Session | None

Optional shared requests.Session for connection reuse.

None
retries int

Number of attempts before giving up.

3
backoff float

Base seconds for exponential backoff between retries.

2.0
timeout float

Per-request timeout in seconds.

180.0
chunk_size int

Streaming chunk size in bytes.

1 << 20

Returns:

Type Description
Path

pathlib.Path: The cached local .zip path.

Raises:

Type Description
HTTPError

If every attempt fails (the last error is raised).

Source code in src/earthlens/glaciers/_helpers.py
def download_zip(
    url: str,
    dest_dir: Path,
    *,
    session: requests.Session | None = None,
    retries: int = 3,
    backoff: float = 2.0,
    timeout: float = 180.0,
    chunk_size: int = 1 << 20,
) -> Path:
    """Stream a `.zip` to `dest_dir` and return the local path (idempotent).

    The glaciers vector / tabular paths read *inside* the zip (RGI via
    `/vsizip/`, WGMS via pandas), so — unlike the ghsl raster path — the archive
    is kept, not extracted. If the file already exists it is returned without
    re-downloading. Retries transient HTTP failures with exponential backoff.

    Args:
        url: The `.zip` URL (an IHP-WINS RGI region or the WGMS FoG archive).
        dest_dir: Directory to cache into (created if absent).
        session: Optional shared `requests.Session` for connection reuse.
        retries: Number of attempts before giving up.
        backoff: Base seconds for exponential backoff between retries.
        timeout: Per-request timeout in seconds.
        chunk_size: Streaming chunk size in bytes.

    Returns:
        pathlib.Path: The cached local `.zip` path.

    Raises:
        requests.HTTPError: If every attempt fails (the last error is raised).
    """
    dest_dir.mkdir(parents=True, exist_ok=True)
    zip_path = dest_dir / url.rsplit("/", 1)[-1]
    if zip_path.exists() and zip_path.stat().st_size > 0:
        return zip_path
    _stream_download(url, zip_path, session, retries, backoff, timeout, chunk_size)
    return zip_path

empty_canonical(columns) #

Build a schema-only (zero-row) frame with the given columns.

Parameters:

Name Type Description Default
columns list[str]

The column names to materialise.

required

Returns:

Type Description
DataFrame

pandas.DataFrame: An empty frame carrying exactly columns.

Source code in src/earthlens/glaciers/_helpers.py
def empty_canonical(columns: list[str]) -> pd.DataFrame:
    """Build a schema-only (zero-row) frame with the given columns.

    Args:
        columns: The column names to materialise.

    Returns:
        pandas.DataFrame: An empty frame carrying exactly `columns`.
    """
    return pd.DataFrame({c: pd.Series(dtype="object") for c in columns})

empty_feature_collection() #

Build an empty EPSG:4326 :class:FeatureCollection (no features).

Returned by the vector path when a request's bbox overlaps no glacier region / matches no outline, so download() always yields a collection.

Returns:

Name Type Description
FeatureCollection FeatureCollection

A zero-feature collection in EPSG:4326.

Source code in src/earthlens/glaciers/_helpers.py
def empty_feature_collection() -> FeatureCollection:
    """Build an empty EPSG:4326 :class:`FeatureCollection` (no features).

    Returned by the vector path when a request's bbox overlaps no glacier
    region / matches no outline, so `download()` always yields a collection.

    Returns:
        FeatureCollection: A zero-feature collection in EPSG:4326.
    """
    import geopandas as gpd

    return FeatureCollection(gpd.GeoDataFrame(geometry=[], crs="EPSG:4326"))

fetch_glims(wfs_url, typename, bbox, dest_path, *, max_features=10000, session=None, timeout=120.0) #

Query the GLIMS WFS for a bbox, save the GeoJSON, read + clip it (G3).

Issues a WFS GetFeature bbox query, writes the GeoJSON response to dest_path, reads it via pyramids FeatureCollection.read_file (not a bare geopandas file read), and clips to the bbox.

Parameters:

Name Type Description Default
wfs_url str

The GLIMS GeoServer WFS endpoint.

required
typename str

The WFS feature-type name.

required
bbox list[float]

[west, south, east, north] AOI in EPSG:4326.

required
dest_path Path

Local path to write the GeoJSON cache file.

required
max_features int

Cap on returned features (the WFS count).

10000
session Session | None

Optional shared requests.Session.

None
timeout float

Per-request timeout in seconds.

120.0

Returns:

Name Type Description
FeatureCollection FeatureCollection

GLIMS outline polygons intersecting the bbox, in EPSG:4326. Empty when the query matched nothing.

Raises:

Type Description
HTTPError

If the WFS returns a non-2xx status.

Source code in src/earthlens/glaciers/_helpers.py
def fetch_glims(
    wfs_url: str,
    typename: str,
    bbox: list[float],
    dest_path: Path,
    *,
    max_features: int = 10000,
    session: requests.Session | None = None,
    timeout: float = 120.0,
) -> FeatureCollection:
    """Query the GLIMS WFS for a bbox, save the GeoJSON, read + clip it (`G3`).

    Issues a WFS GetFeature bbox query, writes the GeoJSON response to
    `dest_path`, reads it via pyramids `FeatureCollection.read_file` (not a bare
    geopandas file read), and clips to the bbox.

    Args:
        wfs_url: The GLIMS GeoServer WFS endpoint.
        typename: The WFS feature-type name.
        bbox: `[west, south, east, north]` AOI in EPSG:4326.
        dest_path: Local path to write the GeoJSON cache file.
        max_features: Cap on returned features (the WFS `count`).
        session: Optional shared `requests.Session`.
        timeout: Per-request timeout in seconds.

    Returns:
        FeatureCollection: GLIMS outline polygons intersecting the bbox, in
            EPSG:4326. Empty when the query matched nothing.

    Raises:
        requests.HTTPError: If the WFS returns a non-2xx status.
    """
    url, params = glims_wfs_url(wfs_url, typename, bbox, max_features)
    get = session.get if session is not None else requests.get
    resp = get(url, params=params, timeout=timeout)
    resp.raise_for_status()
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    dest_path.write_text(resp.text, encoding="utf-8")
    fc = FeatureCollection.read_file(str(Path(dest_path).resolve()))
    if len(fc) == 0:
        return fc
    return _clip_to_bbox(fc, bbox)

filter_wgms(df, glaciers=None, *, glacier_id=None, glacier_name=None, region=None, bbox=None) #

Filter a WGMS fluctuations table by glacier / region / bbox.

glacier_id and glacier_name match the table directly; region and bbox need the glacier join table (its id == the table's glacier_id, its gtng_region is id-prefixed like "11_central_europe", and it carries latitude / longitude).

Parameters:

Name Type Description Default
df DataFrame

A fluctuations table with a glacier_id (and glacier_name) column.

required
glaciers DataFrame | None

The glacier join table; required for region / bbox.

None
glacier_id Any

One id or a list of ids (matched against glacier_id).

None
glacier_name str | None

A case-insensitive substring matched against glacier_name.

None
region Any

One GTN-G region id ("11") or a list, matched against the glacier table's gtng_region prefix.

None
bbox list[float] | None

[west, south, east, north] filtering glaciers by their point coordinates in the glacier table.

None

Returns:

Type Description
DataFrame

pandas.DataFrame: The filtered rows, with a reset index.

Source code in src/earthlens/glaciers/_helpers.py
def filter_wgms(
    df: pd.DataFrame,
    glaciers: pd.DataFrame | None = None,
    *,
    glacier_id: Any = None,
    glacier_name: str | None = None,
    region: Any = None,
    bbox: list[float] | None = None,
) -> pd.DataFrame:
    """Filter a WGMS fluctuations table by glacier / region / bbox.

    `glacier_id` and `glacier_name` match the table directly; `region` and
    `bbox` need the `glacier` join table (its `id` == the table's `glacier_id`,
    its `gtng_region` is id-prefixed like `"11_central_europe"`, and it carries
    `latitude` / `longitude`).

    Args:
        df: A fluctuations table with a `glacier_id` (and `glacier_name`) column.
        glaciers: The `glacier` join table; required for `region` / `bbox`.
        glacier_id: One id or a list of ids (matched against `glacier_id`).
        glacier_name: A case-insensitive substring matched against
            `glacier_name`.
        region: One GTN-G region id (`"11"`) or a list, matched against the
            `glacier` table's `gtng_region` prefix.
        bbox: `[west, south, east, north]` filtering glaciers by their point
            coordinates in the `glacier` table.

    Returns:
        pandas.DataFrame: The filtered rows, with a reset index.
    """
    out = df
    if glacier_id is not None:
        ids = {int(i) for i in _as_list(glacier_id)}
        out = out[out["glacier_id"].isin(ids)]
    if glacier_name is not None:
        names = out["glacier_name"].astype(str)
        out = out[names.str.contains(glacier_name, case=False, na=False)]
    if (region is not None or bbox is not None) and glaciers is not None:
        sel = glaciers
        if region is not None:
            wanted = {str(r) for r in _as_list(region)}
            prefix = sel["gtng_region"].astype(str).str.split("_").str[0]
            sel = sel[prefix.isin(wanted)]
        if bbox is not None:
            west, south, east, north = bbox
            sel = sel[
                sel["longitude"].between(west, east)
                & sel["latitude"].between(south, north)
            ]
        out = out[out["glacier_id"].isin(set(sel["id"]))]
    return out.reset_index(drop=True)

glims_wfs_url(wfs_url, typename, bbox, max_features) #

Build the GLIMS WFS GetFeature request (URL + params) for a bbox query.

Axis-order landmine: with the URN CRS urn:ogc:def:crs:EPSG::4326 the WFS bbox is south,west,north,east (lat,lon); a plain EPSG:4326 srs returns nothing. This builds the URN form.

Parameters:

Name Type Description Default
wfs_url str

The GLIMS GeoServer WFS endpoint.

required
typename str

The WFS feature-type name ("GLIMS:GLIMS_Glacier_Outlines").

required
bbox list[float]

[west, south, east, north] AOI in EPSG:4326.

required
max_features int

Cap on returned features (the WFS count).

required

Returns:

Type Description
tuple[str, dict[str, Any]]

A (url, params) pair to pass to requests.get.

Source code in src/earthlens/glaciers/_helpers.py
def glims_wfs_url(
    wfs_url: str, typename: str, bbox: list[float], max_features: int
) -> tuple[str, dict[str, Any]]:
    """Build the GLIMS WFS GetFeature request (URL + params) for a bbox query.

    Axis-order landmine: with the URN CRS `urn:ogc:def:crs:EPSG::4326` the WFS
    bbox is `south,west,north,east` (lat,lon); a plain `EPSG:4326` srs returns
    nothing. This builds the URN form.

    Args:
        wfs_url: The GLIMS GeoServer WFS endpoint.
        typename: The WFS feature-type name
            (`"GLIMS:GLIMS_Glacier_Outlines"`).
        bbox: `[west, south, east, north]` AOI in EPSG:4326.
        max_features: Cap on returned features (the WFS `count`).

    Returns:
        A `(url, params)` pair to pass to `requests.get`.
    """
    west, south, east, north = bbox
    crs_urn = "urn:ogc:def:crs:EPSG::4326"
    params = {
        "service": "WFS",
        "version": "2.0.0",
        "request": "GetFeature",
        "typeNames": typename,
        "outputFormat": "application/json",
        "srsName": crs_urn,
        "count": str(max_features),
        "bbox": f"{south},{west},{north},{east},{crs_urn}",
    }
    return wfs_url, params

parse_wgms_csv(zip_path, table) #

Read one WGMS FoG table out of the cached zip (G8).

The FoG tables are already long-format (glacier_id / year-or-date / value), so this is a plain pandas.read_csv of the data/<table>.csv member — no reshaping, no array/NetCDF stack.

Parameters:

Name Type Description Default
zip_path Path

The cached WGMS FoG .zip (from :func:download_zip).

required
table str

The table name ("mass_balance", "front_variation", "state").

required

Returns:

Type Description
DataFrame

pandas.DataFrame: The table rows.

Raises:

Type Description
KeyError

If the zip has no data/<table>.csv member.

Source code in src/earthlens/glaciers/_helpers.py
def parse_wgms_csv(zip_path: Path, table: str) -> pd.DataFrame:
    """Read one WGMS FoG table out of the cached zip (`G8`).

    The FoG tables are already long-format (`glacier_id` / year-or-date /
    value), so this is a plain `pandas.read_csv` of the `data/<table>.csv`
    member — no reshaping, no array/NetCDF stack.

    Args:
        zip_path: The cached WGMS FoG `.zip` (from :func:`download_zip`).
        table: The table name (`"mass_balance"`, `"front_variation"`,
            `"state"`).

    Returns:
        pandas.DataFrame: The table rows.

    Raises:
        KeyError: If the zip has no `data/<table>.csv` member.
    """
    member = f"data/{table}.csv"
    with zipfile.ZipFile(zip_path) as zf:
        if member not in zf.namelist():
            raise KeyError(
                f"WGMS FoG zip has no {member!r} member "
                f"(available: {sorted(zf.namelist())})"
            )
        with zf.open(member) as handle:
            return pd.read_csv(io.BytesIO(handle.read()), low_memory=False)

read_outlines(zip_path, bbox, inner_shp=None) #

Read RGI outlines from a cached region zip and clip to the bbox (G3/G4).

Reads the shapefile inside the region ZIP via pyramids FeatureCollection.read_file("/vsizip/<zip>/<shp>") — never a bare geopandas file read (porting policy) — reprojects to EPSG:4326 if needed, and clips to the bbox.

Parameters:

Name Type Description Default
zip_path Path

The local RGI region .zip (from :func:download_zip).

required
bbox list[float]

[west, south, east, north] AOI in EPSG:4326.

required
inner_shp str | None

The inner .shp member name; resolved from the zip when None.

None

Returns:

Name Type Description
FeatureCollection FeatureCollection

Glacier-outline polygons intersecting the bbox, in EPSG:4326.

Source code in src/earthlens/glaciers/_helpers.py
def read_outlines(
    zip_path: Path, bbox: list[float], inner_shp: str | None = None
) -> FeatureCollection:
    """Read RGI outlines from a cached region zip and clip to the bbox (`G3`/`G4`).

    Reads the shapefile *inside* the region ZIP via pyramids
    `FeatureCollection.read_file("/vsizip/<zip>/<shp>")` — never a bare geopandas
    file read (porting policy) — reprojects to EPSG:4326 if needed, and clips to
    the bbox.

    Args:
        zip_path: The local RGI region `.zip` (from :func:`download_zip`).
        bbox: `[west, south, east, north]` AOI in EPSG:4326.
        inner_shp: The inner `.shp` member name; resolved from the zip when
            `None`.

    Returns:
        FeatureCollection: Glacier-outline polygons intersecting the bbox, in
            EPSG:4326.
    """
    shp = inner_shp or _inner_shapefile(zip_path)
    vsi = f"/vsizip/{Path(zip_path).resolve()}/{shp}"
    fc = FeatureCollection.read_file(vsi)
    return _clip_to_bbox(fc, bbox)

regions_for_bbox(bbox, regions) #

Map a request bbox to the overlapping GTN-G region id(s) (G6).

Parameters:

Name Type Description Default
bbox list[float]

The request AOI as [west, south, east, north] in EPSG:4326.

required
regions dict[str, Any]

The catalog region map (id -> a row exposing .bboxes, each a [west, south, east, north] quadruple).

required

Returns:

Type Description
list[str]

list[str]: The sorted ids of every region with a bbox intersecting the AOI. Empty when the AOI overlaps no glacier region.

Source code in src/earthlens/glaciers/_helpers.py
def regions_for_bbox(bbox: list[float], regions: dict[str, Any]) -> list[str]:
    """Map a request bbox to the overlapping GTN-G region id(s) (`G6`).

    Args:
        bbox: The request AOI as `[west, south, east, north]` in EPSG:4326.
        regions: The catalog region map (id -> a row exposing `.bboxes`, each
            a `[west, south, east, north]` quadruple).

    Returns:
        list[str]: The sorted ids of every region with a bbox intersecting the
            AOI. Empty when the AOI overlaps no glacier region.
    """
    aoi = box(*bbox)
    hits = []
    for region_id, region in regions.items():
        if any(box(*region_box).intersects(aoi) for region_box in region.bboxes):
            hits.append(region_id)
    return sorted(hits)

shapely_bbox(space) #

Build the AOI box a SpatialExtent does not carry (G4).

SpatialExtent exposes only north / south / east / west, so the bbox intersect-filter needs this small adapter.

Parameters:

Name Type Description Default
space SpatialExtent

The request's :class:~earthlens.base.SpatialExtent.

required

Returns:

Type Description
Any

A shapely.geometry.box(west, south, east, north) polygon.

Source code in src/earthlens/glaciers/_helpers.py
def shapely_bbox(space: SpatialExtent) -> Any:
    """Build the AOI box a `SpatialExtent` does not carry (`G4`).

    `SpatialExtent` exposes only `north` / `south` / `east` / `west`, so the
    bbox intersect-filter needs this small adapter.

    Args:
        space: The request's :class:`~earthlens.base.SpatialExtent`.

    Returns:
        A `shapely.geometry.box(west, south, east, north)` polygon.
    """
    return box(space.west, space.south, space.east, space.north)

wgms_glacier_table(zip_path) #

Read the WGMS glacier join table (coordinates + region) from the zip.

The glacier table carries id (== the other tables' glacier_id), latitude / longitude, and gtng_region, so it is the key for a region / bbox filter over a fluctuations table.

Parameters:

Name Type Description Default
zip_path Path

The cached WGMS FoG .zip.

required

Returns:

Type Description
DataFrame

pandas.DataFrame: The glacier table.

Raises:

Type Description
KeyError

If the zip has no data/glacier.csv member.

Source code in src/earthlens/glaciers/_helpers.py
def wgms_glacier_table(zip_path: Path) -> pd.DataFrame:
    """Read the WGMS `glacier` join table (coordinates + region) from the zip.

    The `glacier` table carries `id` (== the other tables' `glacier_id`),
    `latitude` / `longitude`, and `gtng_region`, so it is the key for a
    region / bbox filter over a fluctuations table.

    Args:
        zip_path: The cached WGMS FoG `.zip`.

    Returns:
        pandas.DataFrame: The `glacier` table.

    Raises:
        KeyError: If the zip has no `data/glacier.csv` member.
    """
    return parse_wgms_csv(zip_path, "glacier")

earthlens.glaciers.backend #

Backend that fetches glacier outlines / fluctuations from three open sources.

Glaciers(AbstractDataSource) routes one request to whichever of three open glacier sources the chosen dataset names — RGI 7.0 (per-region outline shapefiles via UNESCO IHP-WINS), GLIMS (time-series outlines via the GLIMS GeoServer WFS), or WGMS (Fluctuations of Glaciers tabular CSV tables) — and returns the result in the shape that dataset declares. A request is one dataset id (variables=["rgi:outlines"]) plus a bbox (rgi/glims) or an optional glacier / region selector (wgms).

Three design points carry this backend:

  • Per-instance OUTPUT_KIND (G1). The resolved dataset's output_kind is copied onto self.OUTPUT_KIND in __init__: vector returns a pyramids :class:~pyramids.feature.collection.FeatureCollection (rgi/glims outlines), tabular returns a :class:pandas.DataFrame (wgms fluctuations). The :class:earthlens.earthlens.EarthLens facade reads the instance attribute to gate aggregate= (rejected for both) and to know the return shape.
  • Region-download-then-clip (G6). RGI ships per GTN-G region; the backend maps the request bbox to the overlapping region(s) (a region= override is accepted), downloads + caches each region ZIP once (the ghsl model), reads it via pyramids FeatureCollection.read_file, clips to the bbox, and merges.
  • No auth (G5). All three sources are open, so the backend ships no auth module and never touches the Earthdata-gated NSIDC host.

Outlines / fluctuations are pre-computed inventories, so aggregate= is rejected (G8), vector file I/O always goes through pyramids (G3), and the WGMS path is pure pandas.

Glaciers #

Bases: AbstractDataSource

Glacier outlines / fluctuations backend (mixed output).

Resolves one dataset id to its catalog row, routes the request to that row's source (RGI / GLIMS / WGMS), and returns a pyramids :class:~pyramids.feature.collection.FeatureCollection (vector) or a :class:pandas.DataFrame (tabular) per the row's output_kind. The query is a search/fetch split: :meth:_search pins the products (one per overlapping RGI region, or a single GLIMS / WGMS product) and :meth:_fetch downloads + reads each.

All three sources are open, so the backend needs no credentials. aggregate= is rejected — outlines / fluctuations are pre-computed inventories, not gridded rasters (G8).

Attributes:

Name Type Description
OUTPUT_KIND OutputKind

Set per instance in :meth:__init__ from the resolved dataset's output_kind ("vector" or "tabular"). The facade reads it to gate aggregate= and to know the return shape.

Source code in src/earthlens/glaciers/backend.py
class Glaciers(AbstractDataSource):
    """Glacier outlines / fluctuations backend (mixed output).

    Resolves one dataset id to its catalog row, routes the request to that row's
    source (RGI / GLIMS / WGMS), and returns a pyramids
    :class:`~pyramids.feature.collection.FeatureCollection` (`vector`) or a
    :class:`pandas.DataFrame` (`tabular`) per the row's `output_kind`. The query
    is a search/fetch split: :meth:`_search` pins the products (one per
    overlapping RGI region, or a single GLIMS / WGMS product) and :meth:`_fetch`
    downloads + reads each.

    All three sources are open, so the backend needs no credentials. `aggregate=`
    is rejected — outlines / fluctuations are pre-computed inventories, not
    gridded rasters (`G8`).

    Attributes:
        OUTPUT_KIND: Set **per instance** in :meth:`__init__` from the resolved
            dataset's `output_kind` (`"vector"` or `"tabular"`). The facade reads
            it to gate `aggregate=` and to know the return shape.
    """

    OUTPUT_KIND: OutputKind = "vector"

    def __init__(
        self,
        start: str | None = None,
        end: str | None = None,
        variables: list[str] | None = None,
        lat_lim: list[float] | None = None,
        lon_lim: list[float] | None = None,
        temporal_resolution: str = "annual",
        path: Path | str = "",
        fmt: str = "%Y-%m-%d",
        region: str | list[str] | None = None,
        max_features: int = 10000,
        glacier_id: int | str | list | None = None,
        glacier_name: str | None = None,
        output_format: OutputFormat = "csv",
    ):
        """Initialise a glaciers backend instance.

        Args:
            start: Inclusive start of an optional window, parsed with `fmt`;
                `None` is allowed (outlines are an inventory, not a time series).
            end: Inclusive end of the optional window; `None` allowed.
            variables: A one-element list naming the dataset id
                (`["rgi:outlines"]`). Exactly one dataset is resolved per
                instance, because `OUTPUT_KIND` is per instance.
            lat_lim: `[lat_min, lat_max]` AOI. Required (with `lon_lim`) for an
                RGI request without a `region=` override, and for GLIMS; the
                WGMS path defaults to global.
            lon_lim: `[lon_min, lon_max]` AOI.
            temporal_resolution: Recorded as the resolution label only.
            path: Output directory; also the parent of the download cache.
            fmt: `strptime` format for `start` / `end`.
            region: RGI only — one GTN-G region id (`"11"`) or a list, overriding
                the bbox -> region mapping. Also accepted as a WGMS region filter.
            max_features: GLIMS only — cap on WFS-returned features.
            glacier_id: WGMS only — one glacier id or a list to filter on.
            glacier_name: WGMS only — a case-insensitive substring filter.
            output_format: On-disk format for a written WGMS result — `"csv"`
                (default) or `"parquet"`.

        Raises:
            TypeError: If `variables` is a mapping (pass a list of one id).
            ValueError: If `variables` is not exactly one dataset id,
                `output_format` is unrecognised, or the required spatial selector
                for the resolved source is missing.
        """
        if isinstance(variables, dict):
            raise TypeError(
                "Glaciers `variables` must be a one-element list naming the "
                "dataset id (e.g. ['rgi:outlines']), not a mapping."
            )
        ids = list(dict.fromkeys(variables)) if variables else []
        if len(ids) != 1:
            raise ValueError(
                "Glaciers needs exactly one dataset id in variables= "
                "(OUTPUT_KIND is per instance); got "
                f"{ids!r}. Available: {Catalog().available()}."
            )
        if output_format not in OUTPUT_FORMATS:
            raise ValueError(
                f"output_format must be one of {list(OUTPUT_FORMATS)}, "
                f"got {output_format!r}."
            )

        self._catalog = Catalog()
        self._dataset: Dataset = self._catalog.get(ids[0])
        self._region = region
        self._max_features = max_features
        self._glacier_id = glacier_id
        self._glacier_name = glacier_name
        self._output_format: OutputFormat = output_format

        # G1 — the per-instance output shape comes from the resolved dataset.
        self.OUTPUT_KIND = self._dataset.output_kind

        self._has_bbox = lat_lim is not None and lon_lim is not None
        self._validate_selector()

        super().__init__(
            start=start,
            end=end,
            variables=ids,
            temporal_resolution=temporal_resolution,
            lat_lim=lat_lim if lat_lim is not None else _GLOBAL_LAT,
            lon_lim=lon_lim if lon_lim is not None else _GLOBAL_LON,
            fmt=fmt,
            path=path,
        )
        self._cache_dir = self.root_dir / "_glaciers_cache"

    def _validate_selector(self) -> None:
        """Check the request carries the spatial selector the source needs.

        RGI needs a bbox or a `region=` override (else it would pull all 19
        regions); GLIMS needs a bbox (a global WFS query is rejected); WGMS needs
        nothing (its filters are optional).

        Raises:
            ValueError: When a required selector is missing.
        """
        source = self._dataset.source
        if source == "rgi" and not (self._has_bbox or self._region):
            raise ValueError(
                f"dataset {self._dataset.id!r} (RGI) needs a bbox "
                "(lat_lim=/lon_lim= or aoi=) or a region= override; refusing to "
                "download every GTN-G region."
            )
        if source == "glims" and not self._has_bbox:
            raise ValueError(
                f"dataset {self._dataset.id!r} (GLIMS) needs a bbox "
                "(lat_lim=/lon_lim= or aoi=) — a global WFS query is too large."
            )

    def _initialize(self):
        """No client and no auth — every source is open (`G5`).

        Returns:
            None: No per-instance client object.
        """
        return None

    def _create_grid(self, lat_lim: list, lon_lim: list) -> SpatialExtent:
        """Build the request :class:`SpatialExtent` from the AOI corners.

        Args:
            lat_lim: `[lat_min, lat_max]`.
            lon_lim: `[lon_min, lon_max]`.

        Returns:
            SpatialExtent: The request extent.
        """
        return SpatialExtent.from_pairs(lat_lim=lat_lim, lon_lim=lon_lim)

    def _check_input_dates(
        self,
        start: str | None,
        end: str | None,
        temporal_resolution: str,
        fmt: str,
    ) -> TemporalExtent:
        """Parse the optional `[start, end]` window into a :class:`TemporalExtent`.

        Dates are not part of the request (outlines are an inventory), so `None`
        bounds are allowed and yield a `None`-dated extent.

        Args:
            start: Inclusive start date string, or `None`.
            end: Inclusive end date string, or `None`.
            temporal_resolution: Recorded as the resolution label.
            fmt: `strptime` format applied to `start` and `end`.

        Returns:
            TemporalExtent: Frozen model with the parsed (or `None`) endpoints.

        Raises:
            ValueError: If `start` parses to a date later than `end`.
        """
        start_dt = dt.datetime.strptime(start, fmt) if start else None
        end_dt = dt.datetime.strptime(end, fmt) if end else None
        dates = (
            pd.DatetimeIndex([start_dt, end_dt])
            if start_dt is not None and end_dt is not None
            else pd.DatetimeIndex([])
        )
        return TemporalExtent(
            start_date=start_dt,
            end_date=end_dt,
            resolution=temporal_resolution,
            dates=dates,
        )

    @property
    def _bbox(self) -> list[float]:
        """The request AOI as `[west, south, east, north]` in EPSG:4326."""
        space = self.space
        return [space.west, space.south, space.east, space.north]

    def _api(self) -> list:
        """Compose `_search` and `_fetch` into the canonical shape."""
        return self._api_via_search_fetch()

    def _search(self) -> list[RemoteProduct]:
        """List the products to fetch for the resolved source.

        For RGI, one product per overlapping GTN-G region (`G6`); for GLIMS and
        WGMS, a single product.

        Returns:
            list[RemoteProduct]: The products. Empty when an RGI bbox overlaps no
                glacier region (the vector path then returns an empty
                collection).
        """
        source = self._dataset.source
        if source == "rgi":
            if self._region is not None:
                region_ids = [str(r) for r in _helpers._as_list(self._region)]
            else:
                region_ids = _helpers.regions_for_bbox(
                    self._bbox, self._catalog.regions
                )
            products = []
            for region_id in region_ids:
                region = self._catalog.regions[region_id]
                products.append(
                    RemoteProduct(
                        id=f"{self._dataset.id}/{region_id}",
                        href=region.url,
                        metadata={"region_id": region_id},
                    )
                )
            return products
        return [RemoteProduct(id=self._dataset.id)]

    def _fetch(self, products: list[RemoteProduct]) -> list:
        """Download + read every product `_search` returned.

        Args:
            products: The list from :meth:`_search`.

        Returns:
            list: One :class:`~pyramids.feature.collection.FeatureCollection`
                per RGI region / the GLIMS query (`vector`), or a single
                :class:`pandas.DataFrame` (`tabular`, wgms).
        """
        return [self._fetch_one(product) for product in products]

    def _fetch_one(self, product: RemoteProduct):
        """Fetch and read one product per its source.

        Args:
            product: One :class:`RemoteProduct` from :meth:`_search`.

        Returns:
            A `FeatureCollection` (rgi/glims) or a `DataFrame` (wgms).

        Raises:
            requests.HTTPError: If a download / WFS query fails after retries.
        """
        dataset = self._dataset
        if dataset.source == "rgi":
            zip_path = _helpers.download_zip(product.href, self._cache_dir)
            return _helpers.read_outlines(zip_path, self._bbox)
        if dataset.source == "glims":
            dest = self._cache_dir / "glims_query.geojson"
            return _helpers.fetch_glims(
                dataset.wfs_url,
                dataset.wfs_typename,
                self._bbox,
                dest,
                max_features=self._max_features,
            )
        # source == "wgms"
        zip_path = _helpers.download_zip(dataset.archive_url, self._cache_dir)
        table = _helpers.parse_wgms_csv(zip_path, dataset.table)
        glaciers = _helpers.wgms_glacier_table(zip_path)
        bbox = self._bbox if self._has_bbox else None
        return _helpers.filter_wgms(
            table,
            glaciers,
            glacier_id=self._glacier_id,
            glacier_name=self._glacier_name,
            region=self._region,
            bbox=bbox,
        )

    def download(
        self,
        progress_bar: bool = True,
        aggregate: AggregationConfig | None = None,
    ):
        """Fetch the dataset and return its per-instance shape.

        Args:
            progress_bar: Accepted for signature parity; the per-region RGI
                fetch is a small loop, so this is a no-op.
            aggregate: Must be `None`. Outlines / fluctuations are pre-computed
                inventories, so there is no gridded reduction; the facade already
                rejects a non-`None` `aggregate=` for `vector` / `tabular`, and
                this is the belt-and-suspenders guard for direct callers (`G8`).

        Returns:
            A pyramids :class:`~pyramids.feature.collection.FeatureCollection`
            (`vector`, rgi/glims; clipped, EPSG:4326) or a
            :class:`pandas.DataFrame` (`tabular`, wgms; also written to
            `root_dir`).

        Raises:
            NotImplementedError: If `aggregate` is not `None` (`G8`).
            requests.HTTPError: If a download / WFS query fails after retries.
        """
        if aggregate is not None:
            raise NotImplementedError(
                "Glaciers.download(aggregate=...) is not supported: glacier "
                "outlines / fluctuations are pre-computed inventories, not "
                "gridded rasters, so there is no meaningful gridded reduction. "
                "Call download() without aggregate=."
            )
        results = self._api()
        self._log_citation()
        if self.OUTPUT_KIND == "vector":
            if not results:
                collection = _helpers.empty_feature_collection()
            else:
                collection = _helpers.concat_outlines(results)
            logger.info(
                f"Glaciers {self._dataset.id}: returned a FeatureCollection "
                f"({len(collection)} outline(s))."
            )
            return collection
        df = results[0] if results else _helpers.empty_canonical(["glacier_id"])
        out_path = self._write_table(df)
        if len(df):
            logger.info(
                f"Glaciers {self._dataset.id}: {len(df)} row(s) written to "
                f"{out_path}."
            )
        else:
            logger.warning(
                f"Glaciers {self._dataset.id}: no rows matched; wrote an empty "
                f"(schema-only) table to {out_path}."
            )
        return df

    def _write_table(self, df: pd.DataFrame) -> Path:
        """Write a WGMS result to `root_dir` and return the path.

        Args:
            df: The result frame.

        Returns:
            Path: The written CSV / Parquet file path.

        Raises:
            ImportError: If `output_format="parquet"` but `pyarrow` is missing.
        """
        stem = self._dataset.id.replace(":", "_")
        ext = "parquet" if self._output_format == "parquet" else "csv"
        out_path = self.root_dir / f"{stem}.{ext}"
        if self._output_format == "parquet":
            try:
                df.to_parquet(out_path, index=False)
            except ImportError as exc:  # pragma: no cover - depends on env
                raise ImportError(
                    "Writing Parquet requires 'pyarrow'. Install it (pip "
                    "install pyarrow) or use output_format='csv'."
                ) from exc
        else:
            df.to_csv(out_path, index=False)
        return out_path

    def _log_citation(self) -> None:
        """Log the resolved dataset's source citation once (info, not a warning)."""
        citation = self._dataset.citation
        if citation:
            logger.info(f"Glaciers source citation: {citation}")

__init__(start=None, end=None, variables=None, lat_lim=None, lon_lim=None, temporal_resolution='annual', path='', fmt='%Y-%m-%d', region=None, max_features=10000, glacier_id=None, glacier_name=None, output_format='csv') #

Initialise a glaciers backend instance.

Parameters:

Name Type Description Default
start str | None

Inclusive start of an optional window, parsed with fmt; None is allowed (outlines are an inventory, not a time series).

None
end str | None

Inclusive end of the optional window; None allowed.

None
variables list[str] | None

A one-element list naming the dataset id (["rgi:outlines"]). Exactly one dataset is resolved per instance, because OUTPUT_KIND is per instance.

None
lat_lim list[float] | None

[lat_min, lat_max] AOI. Required (with lon_lim) for an RGI request without a region= override, and for GLIMS; the WGMS path defaults to global.

None
lon_lim list[float] | None

[lon_min, lon_max] AOI.

None
temporal_resolution str

Recorded as the resolution label only.

'annual'
path Path | str

Output directory; also the parent of the download cache.

''
fmt str

strptime format for start / end.

'%Y-%m-%d'
region str | list[str] | None

RGI only — one GTN-G region id ("11") or a list, overriding the bbox -> region mapping. Also accepted as a WGMS region filter.

None
max_features int

GLIMS only — cap on WFS-returned features.

10000
glacier_id int | str | list | None

WGMS only — one glacier id or a list to filter on.

None
glacier_name str | None

WGMS only — a case-insensitive substring filter.

None
output_format OutputFormat

On-disk format for a written WGMS result — "csv" (default) or "parquet".

'csv'

Raises:

Type Description
TypeError

If variables is a mapping (pass a list of one id).

ValueError

If variables is not exactly one dataset id, output_format is unrecognised, or the required spatial selector for the resolved source is missing.

Source code in src/earthlens/glaciers/backend.py
def __init__(
    self,
    start: str | None = None,
    end: str | None = None,
    variables: list[str] | None = None,
    lat_lim: list[float] | None = None,
    lon_lim: list[float] | None = None,
    temporal_resolution: str = "annual",
    path: Path | str = "",
    fmt: str = "%Y-%m-%d",
    region: str | list[str] | None = None,
    max_features: int = 10000,
    glacier_id: int | str | list | None = None,
    glacier_name: str | None = None,
    output_format: OutputFormat = "csv",
):
    """Initialise a glaciers backend instance.

    Args:
        start: Inclusive start of an optional window, parsed with `fmt`;
            `None` is allowed (outlines are an inventory, not a time series).
        end: Inclusive end of the optional window; `None` allowed.
        variables: A one-element list naming the dataset id
            (`["rgi:outlines"]`). Exactly one dataset is resolved per
            instance, because `OUTPUT_KIND` is per instance.
        lat_lim: `[lat_min, lat_max]` AOI. Required (with `lon_lim`) for an
            RGI request without a `region=` override, and for GLIMS; the
            WGMS path defaults to global.
        lon_lim: `[lon_min, lon_max]` AOI.
        temporal_resolution: Recorded as the resolution label only.
        path: Output directory; also the parent of the download cache.
        fmt: `strptime` format for `start` / `end`.
        region: RGI only — one GTN-G region id (`"11"`) or a list, overriding
            the bbox -> region mapping. Also accepted as a WGMS region filter.
        max_features: GLIMS only — cap on WFS-returned features.
        glacier_id: WGMS only — one glacier id or a list to filter on.
        glacier_name: WGMS only — a case-insensitive substring filter.
        output_format: On-disk format for a written WGMS result — `"csv"`
            (default) or `"parquet"`.

    Raises:
        TypeError: If `variables` is a mapping (pass a list of one id).
        ValueError: If `variables` is not exactly one dataset id,
            `output_format` is unrecognised, or the required spatial selector
            for the resolved source is missing.
    """
    if isinstance(variables, dict):
        raise TypeError(
            "Glaciers `variables` must be a one-element list naming the "
            "dataset id (e.g. ['rgi:outlines']), not a mapping."
        )
    ids = list(dict.fromkeys(variables)) if variables else []
    if len(ids) != 1:
        raise ValueError(
            "Glaciers needs exactly one dataset id in variables= "
            "(OUTPUT_KIND is per instance); got "
            f"{ids!r}. Available: {Catalog().available()}."
        )
    if output_format not in OUTPUT_FORMATS:
        raise ValueError(
            f"output_format must be one of {list(OUTPUT_FORMATS)}, "
            f"got {output_format!r}."
        )

    self._catalog = Catalog()
    self._dataset: Dataset = self._catalog.get(ids[0])
    self._region = region
    self._max_features = max_features
    self._glacier_id = glacier_id
    self._glacier_name = glacier_name
    self._output_format: OutputFormat = output_format

    # G1 — the per-instance output shape comes from the resolved dataset.
    self.OUTPUT_KIND = self._dataset.output_kind

    self._has_bbox = lat_lim is not None and lon_lim is not None
    self._validate_selector()

    super().__init__(
        start=start,
        end=end,
        variables=ids,
        temporal_resolution=temporal_resolution,
        lat_lim=lat_lim if lat_lim is not None else _GLOBAL_LAT,
        lon_lim=lon_lim if lon_lim is not None else _GLOBAL_LON,
        fmt=fmt,
        path=path,
    )
    self._cache_dir = self.root_dir / "_glaciers_cache"

download(progress_bar=True, aggregate=None) #

Fetch the dataset and return its per-instance shape.

Parameters:

Name Type Description Default
progress_bar bool

Accepted for signature parity; the per-region RGI fetch is a small loop, so this is a no-op.

True
aggregate AggregationConfig | None

Must be None. Outlines / fluctuations are pre-computed inventories, so there is no gridded reduction; the facade already rejects a non-None aggregate= for vector / tabular, and this is the belt-and-suspenders guard for direct callers (G8).

None

Returns:

Type Description

A pyramids :class:~pyramids.feature.collection.FeatureCollection

(vector, rgi/glims; clipped, EPSG:4326) or a

class:pandas.DataFrame (tabular, wgms; also written to

root_dir).

Raises:

Type Description
NotImplementedError

If aggregate is not None (G8).

HTTPError

If a download / WFS query fails after retries.

Source code in src/earthlens/glaciers/backend.py
def download(
    self,
    progress_bar: bool = True,
    aggregate: AggregationConfig | None = None,
):
    """Fetch the dataset and return its per-instance shape.

    Args:
        progress_bar: Accepted for signature parity; the per-region RGI
            fetch is a small loop, so this is a no-op.
        aggregate: Must be `None`. Outlines / fluctuations are pre-computed
            inventories, so there is no gridded reduction; the facade already
            rejects a non-`None` `aggregate=` for `vector` / `tabular`, and
            this is the belt-and-suspenders guard for direct callers (`G8`).

    Returns:
        A pyramids :class:`~pyramids.feature.collection.FeatureCollection`
        (`vector`, rgi/glims; clipped, EPSG:4326) or a
        :class:`pandas.DataFrame` (`tabular`, wgms; also written to
        `root_dir`).

    Raises:
        NotImplementedError: If `aggregate` is not `None` (`G8`).
        requests.HTTPError: If a download / WFS query fails after retries.
    """
    if aggregate is not None:
        raise NotImplementedError(
            "Glaciers.download(aggregate=...) is not supported: glacier "
            "outlines / fluctuations are pre-computed inventories, not "
            "gridded rasters, so there is no meaningful gridded reduction. "
            "Call download() without aggregate=."
        )
    results = self._api()
    self._log_citation()
    if self.OUTPUT_KIND == "vector":
        if not results:
            collection = _helpers.empty_feature_collection()
        else:
            collection = _helpers.concat_outlines(results)
        logger.info(
            f"Glaciers {self._dataset.id}: returned a FeatureCollection "
            f"({len(collection)} outline(s))."
        )
        return collection
    df = results[0] if results else _helpers.empty_canonical(["glacier_id"])
    out_path = self._write_table(df)
    if len(df):
        logger.info(
            f"Glaciers {self._dataset.id}: {len(df)} row(s) written to "
            f"{out_path}."
        )
    else:
        logger.warning(
            f"Glaciers {self._dataset.id}: no rows matched; wrote an empty "
            f"(schema-only) table to {out_path}."
        )
    return df

earthlens.glaciers.catalog #

Dataset + GTN-G region catalog for the glaciers backend.

The glaciers backend routes a request to one of three open glacier sources — the Randolph Glacier Inventory 7.0 (rgi, per-region outline shapefiles), the GLIMS time-series outline database (glims, WFS bbox query), and the WGMS Fluctuations of Glaciers database (wgms, tabular CSV tables). This module is the bridge from a dataset id ("rgi:outlines", "glims:outlines", "wgms:mass_balance", …) to its source, output kind, and the source-specific request detail needed to fetch it.

:class:Catalog is a thin :class:earthlens.base.AbstractCatalog subclass that loads the bundled sharded catalog/ directory (rgi.yaml / glims.yaml / wgms.yaml + _index.yaml) and exposes each row as a :class:Dataset. The rgi.yaml file also ships a regions: block — the GTN-G first-order region table (id → name + bbox(es) + per-region download URL) that drives the bbox → region(s) mapping — exposed as :attr:Catalog.regions of :class:Region rows. Resolve one dataset with :meth:Catalog.get (a did-you-mean hint on an unknown id); list the shipped ids with :meth:Catalog.available.

:data:CATALOG_PATH is the path to the bundled catalog/ directory; tests may monkey-patch it at a temporary directory or a single YAML file.

Catalog #

Bases: AbstractCatalog

Dataset + region catalog for the glaciers backend.

Reads the bundled sharded catalog/ directory (shipped as package data) and exposes its datasets: blocks as a map of :class:Dataset rows keyed by id, plus the GTN-G regions: table as a map of :class:Region rows. Instantiate with no arguments (Catalog()). Resolve one row with :meth:get, list the shipped ids with :meth:available, and read the region table via :attr:regions.

Attributes:

Name Type Description
datasets dict[str, Dataset]

Map from dataset id to its :class:Dataset row.

regions dict[str, Region]

Map from GTN-G region id to its :class:Region row.

Examples:

  • Resolve rows and a region:
    >>> from earthlens.glaciers import Catalog
    >>> cat = Catalog()
    >>> cat.get("rgi:outlines").output_kind
    'vector'
    >>> cat.get("wgms:mass_balance").output_kind
    'tabular'
    >>> cat.regions["11"].name
    'Central Europe'
    
  • An unknown but close id raises with a did-you-mean hint:
    >>> from earthlens.glaciers import Catalog
    >>> Catalog().get("rgi:outline")
    Traceback (most recent call last):
        ...
    ValueError: 'rgi:outline' is not in the glaciers catalog. Known datasets: [...]. Did you mean 'rgi:outlines'?
    
Source code in src/earthlens/glaciers/catalog.py
class Catalog(AbstractCatalog):
    """Dataset + region catalog for the glaciers backend.

    Reads the bundled sharded `catalog/` directory (shipped as package data) and
    exposes its `datasets:` blocks as a map of :class:`Dataset` rows keyed by id,
    plus the GTN-G `regions:` table as a map of :class:`Region` rows.
    Instantiate with no arguments (`Catalog()`). Resolve one row with
    :meth:`get`, list the shipped ids with :meth:`available`, and read the region
    table via :attr:`regions`.

    Attributes:
        datasets: Map from dataset id to its :class:`Dataset` row.
        regions: Map from GTN-G region id to its :class:`Region` row.

    Examples:
        - Resolve rows and a region:
            ```python
            >>> from earthlens.glaciers import Catalog
            >>> cat = Catalog()
            >>> cat.get("rgi:outlines").output_kind
            'vector'
            >>> cat.get("wgms:mass_balance").output_kind
            'tabular'
            >>> cat.regions["11"].name
            'Central Europe'

            ```
        - An unknown but close id raises with a did-you-mean hint:
            ```python
            >>> from earthlens.glaciers import Catalog
            >>> Catalog().get("rgi:outline")
            Traceback (most recent call last):
                ...
            ValueError: 'rgi:outline' is not in the glaciers catalog. Known datasets: [...]. Did you mean 'rgi:outlines'?

            ```
    """

    _catalog_kind: str = "glaciers catalog"
    _entry_noun: str = "datasets"

    #: The dataset rows live in the base :attr:`datasets` field so the inherited
    #: dict surface (`len`, `in`, `[]`, iteration) and :meth:`get_dataset`'s
    #: did-you-mean hint work unchanged.
    datasets: dict[str, Dataset] = Field(default_factory=dict)

    #: GTN-G first-order region id -> :class:`Region` (RGI download metadata).
    regions: dict[str, Region] = Field(default_factory=dict)

    def model_post_init(self, __context: Any) -> None:
        """Auto-load the bundled catalog when no rows were supplied.

        `Catalog()` with no args reads :data:`CATALOG_PATH`; passing
        `datasets=...` skips the disk read.

        Raises:
            ValueError: Propagated from :meth:`load` when the YAML is missing,
                empty, or has a malformed row.
        """
        if not self.datasets:
            loaded = Catalog.load()
            self.datasets = loaded.datasets
            self.regions = loaded.regions
            self.available_datasets = loaded.available_datasets
        super().model_post_init(__context)

    @classmethod
    def load(cls, catalog_path: Path | None = None) -> Catalog:
        """Read the glaciers catalog from disk.

        Args:
            catalog_path: Path to the catalog directory or a single YAML.
                Defaults to the module-level :data:`CATALOG_PATH`.

        Returns:
            A fully-populated :class:`Catalog`.

        Raises:
            ValueError: If the catalog has no `datasets:` block, or a row fails
                validation.
        """
        catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
        datasets, regions, available = _load_catalog_data(catalog_path)
        return cls(
            datasets=dict(datasets),
            regions=dict(regions),
            available_datasets=list(available),
        )

    def get_catalog(self) -> dict[str, Dataset]:
        """Return the dataset map (satisfies the abstract contract).

        Returns:
            dict[str, Dataset]: Same object as :attr:`datasets`.
        """
        return self.datasets

    def get(self, dataset_id: str) -> Dataset:
        """Resolve a dataset id to its :class:`Dataset` row.

        Thin wrapper over the inherited :meth:`get_dataset`, which raises a
        `ValueError` with a did-you-mean hint on an unknown id.

        Args:
            dataset_id: A shipped dataset id (`"rgi:outlines"`).

        Returns:
            Dataset: The matching catalog row.

        Raises:
            ValueError: If `dataset_id` is not a known dataset; the message names
                the catalog kind and, when a close match exists, adds a
                did-you-mean hint.
        """
        return self.get_dataset(dataset_id)

    def available(self) -> list[str]:
        """Return the sorted list of shipped dataset ids.

        Returns:
            list[str]: Every catalog key, sorted.
        """
        return sorted(self.datasets)

available() #

Return the sorted list of shipped dataset ids.

Returns:

Type Description
list[str]

list[str]: Every catalog key, sorted.

Source code in src/earthlens/glaciers/catalog.py
def available(self) -> list[str]:
    """Return the sorted list of shipped dataset ids.

    Returns:
        list[str]: Every catalog key, sorted.
    """
    return sorted(self.datasets)

get(dataset_id) #

Resolve a dataset id to its :class:Dataset row.

Thin wrapper over the inherited :meth:get_dataset, which raises a ValueError with a did-you-mean hint on an unknown id.

Parameters:

Name Type Description Default
dataset_id str

A shipped dataset id ("rgi:outlines").

required

Returns:

Name Type Description
Dataset Dataset

The matching catalog row.

Raises:

Type Description
ValueError

If dataset_id is not a known dataset; the message names the catalog kind and, when a close match exists, adds a did-you-mean hint.

Source code in src/earthlens/glaciers/catalog.py
def get(self, dataset_id: str) -> Dataset:
    """Resolve a dataset id to its :class:`Dataset` row.

    Thin wrapper over the inherited :meth:`get_dataset`, which raises a
    `ValueError` with a did-you-mean hint on an unknown id.

    Args:
        dataset_id: A shipped dataset id (`"rgi:outlines"`).

    Returns:
        Dataset: The matching catalog row.

    Raises:
        ValueError: If `dataset_id` is not a known dataset; the message names
            the catalog kind and, when a close match exists, adds a
            did-you-mean hint.
    """
    return self.get_dataset(dataset_id)

get_catalog() #

Return the dataset map (satisfies the abstract contract).

Returns:

Type Description
dict[str, Dataset]

dict[str, Dataset]: Same object as :attr:datasets.

Source code in src/earthlens/glaciers/catalog.py
def get_catalog(self) -> dict[str, Dataset]:
    """Return the dataset map (satisfies the abstract contract).

    Returns:
        dict[str, Dataset]: Same object as :attr:`datasets`.
    """
    return self.datasets

load(catalog_path=None) classmethod #

Read the glaciers catalog from disk.

Parameters:

Name Type Description Default
catalog_path Path | None

Path to the catalog directory or a single YAML. Defaults to the module-level :data:CATALOG_PATH.

None

Returns:

Type Description
Catalog

A fully-populated :class:Catalog.

Raises:

Type Description
ValueError

If the catalog has no datasets: block, or a row fails validation.

Source code in src/earthlens/glaciers/catalog.py
@classmethod
def load(cls, catalog_path: Path | None = None) -> Catalog:
    """Read the glaciers catalog from disk.

    Args:
        catalog_path: Path to the catalog directory or a single YAML.
            Defaults to the module-level :data:`CATALOG_PATH`.

    Returns:
        A fully-populated :class:`Catalog`.

    Raises:
        ValueError: If the catalog has no `datasets:` block, or a row fails
            validation.
    """
    catalog_path = catalog_path if catalog_path is not None else CATALOG_PATH
    datasets, regions, available = _load_catalog_data(catalog_path)
    return cls(
        datasets=dict(datasets),
        regions=dict(regions),
        available_datasets=list(available),
    )

model_post_init(__context) #

Auto-load the bundled catalog when no rows were supplied.

Catalog() with no args reads :data:CATALOG_PATH; passing datasets=... skips the disk read.

Raises:

Type Description
ValueError

Propagated from :meth:load when the YAML is missing, empty, or has a malformed row.

Source code in src/earthlens/glaciers/catalog.py
def model_post_init(self, __context: Any) -> None:
    """Auto-load the bundled catalog when no rows were supplied.

    `Catalog()` with no args reads :data:`CATALOG_PATH`; passing
    `datasets=...` skips the disk read.

    Raises:
        ValueError: Propagated from :meth:`load` when the YAML is missing,
            empty, or has a malformed row.
    """
    if not self.datasets:
        loaded = Catalog.load()
        self.datasets = loaded.datasets
        self.regions = loaded.regions
        self.available_datasets = loaded.available_datasets
    super().model_post_init(__context)

Dataset #

Bases: BaseModel

One glaciers catalog row.

The dataset id is the parent key in :attr:Catalog.datasets and is also stored on the row as :attr:id so a resolved :class:Dataset is self-describing. Which source-specific fields are populated depends on :attr:source; a cross-field validator enforces that the right ones are present.

Attributes:

Name Type Description
id str

The dataset id ("rgi:outlines", "wgms:mass_balance").

source Source

Which source serves it — "rgi", "glims", or "wgms".

output_kind OutputKind

"vector" (a FeatureCollection, rgi/glims) or "tabular" (a DataFrame, wgms). Copied onto the backend's OUTPUT_KIND per instance.

long_name str

Human-readable label.

citation str

The source's citation string, logged once on use.

table str | None

WGMS only — the CSV table name inside the FoG zip ("mass_balance", "front_variation", "state").

archive_url str | None

WGMS only — the FoG database zip download URL.

wfs_url str | None

GLIMS only — the GeoServer WFS endpoint URL.

wfs_typename str | None

GLIMS only — the WFS feature-type name.

Examples:

  • Build an RGI row directly:
    >>> from earthlens.glaciers import Dataset
    >>> row = Dataset(id="rgi:outlines", source="rgi", output_kind="vector")
    >>> row.source
    'rgi'
    >>> row.output_kind
    'vector'
    
Source code in src/earthlens/glaciers/catalog.py
class Dataset(BaseModel):
    """One glaciers catalog row.

    The dataset id is the parent key in :attr:`Catalog.datasets` and is also
    stored on the row as :attr:`id` so a resolved :class:`Dataset` is
    self-describing. Which source-specific fields are populated depends on
    :attr:`source`; a cross-field validator enforces that the right ones are
    present.

    Attributes:
        id: The dataset id (`"rgi:outlines"`, `"wgms:mass_balance"`).
        source: Which source serves it — `"rgi"`, `"glims"`, or `"wgms"`.
        output_kind: `"vector"` (a `FeatureCollection`, rgi/glims) or
            `"tabular"` (a `DataFrame`, wgms). Copied onto the backend's
            `OUTPUT_KIND` per instance.
        long_name: Human-readable label.
        citation: The source's citation string, logged once on use.
        table: WGMS only — the CSV table name inside the FoG zip
            (`"mass_balance"`, `"front_variation"`, `"state"`).
        archive_url: WGMS only — the FoG database zip download URL.
        wfs_url: GLIMS only — the GeoServer WFS endpoint URL.
        wfs_typename: GLIMS only — the WFS feature-type name.

    Examples:
        - Build an RGI row directly:
            ```python
            >>> from earthlens.glaciers import Dataset
            >>> row = Dataset(id="rgi:outlines", source="rgi", output_kind="vector")
            >>> row.source
            'rgi'
            >>> row.output_kind
            'vector'

            ```
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    id: str
    source: Source
    output_kind: OutputKind
    long_name: str = ""
    citation: str = ""

    # WGMS
    table: str | None = None
    archive_url: str | None = None
    # GLIMS
    wfs_url: str | None = None
    wfs_typename: str | None = None

    @model_validator(mode="after")
    def _check_source_fields(self) -> Dataset:
        """Enforce the per-source required fields and output kind.

        Returns:
            The validated row.

        Raises:
            ValueError: If the row's `output_kind` does not match its `source`,
                a `wgms` row omits `table` / `archive_url`, or a `glims` row
                omits `wfs_url` / `wfs_typename`.
        """
        if self.source in ("rgi", "glims") and self.output_kind != "vector":
            raise ValueError(
                f"{self.source} dataset {self.id!r} must be output_kind 'vector'"
            )
        if self.source == "wgms":
            if self.output_kind != "tabular":
                raise ValueError(
                    f"wgms dataset {self.id!r} must be output_kind 'tabular'"
                )
            if not (self.table and self.archive_url):
                raise ValueError(
                    f"wgms dataset {self.id!r} needs table and archive_url"
                )
        if self.source == "glims" and not (self.wfs_url and self.wfs_typename):
            raise ValueError(
                f"glims dataset {self.id!r} needs wfs_url and wfs_typename"
            )
        return self

Region #

Bases: BaseModel

One GTN-G first-order region row (RGI per-region download metadata).

Attributes:

Name Type Description
id str

The two-digit GTN-G region id ("01""19").

name str

The region's full name ("Central Europe").

bboxes list[list[float]]

One or more [west, south, east, north] bounding boxes in EPSG:4326. Most regions have a single box; region 10 (North Asia) crosses the antimeridian and carries two.

url str

The full IHP-WINS resource download URL for this region's outline shapefile zip.

Examples:

  • A region's corners and download URL:
    >>> from earthlens.glaciers import Catalog
    >>> region = Catalog().regions["11"]
    >>> region.name
    'Central Europe'
    >>> region.bboxes[0]
    [-6.0, 40.0, 26.0, 50.0]
    
Source code in src/earthlens/glaciers/catalog.py
class Region(BaseModel):
    """One GTN-G first-order region row (RGI per-region download metadata).

    Attributes:
        id: The two-digit GTN-G region id (`"01"` … `"19"`).
        name: The region's full name (`"Central Europe"`).
        bboxes: One or more `[west, south, east, north]` bounding boxes in
            EPSG:4326. Most regions have a single box; region 10 (North Asia)
            crosses the antimeridian and carries two.
        url: The full IHP-WINS resource download URL for this region's outline
            shapefile zip.

    Examples:
        - A region's corners and download URL:
            ```python
            >>> from earthlens.glaciers import Catalog
            >>> region = Catalog().regions["11"]
            >>> region.name
            'Central Europe'
            >>> region.bboxes[0]
            [-6.0, 40.0, 26.0, 50.0]

            ```
    """

    model_config = ConfigDict(frozen=True, extra="forbid")

    id: str
    name: str
    bboxes: list[list[float]]
    url: str

    @model_validator(mode="after")
    def _check_bboxes(self) -> Region:
        """Validate every bbox is a `[west, south, east, north]` quadruple.

        Returns:
            The validated row.

        Raises:
            ValueError: If `bboxes` is empty or any box is not length 4.
        """
        if not self.bboxes:
            raise ValueError(f"region {self.id!r} has no bboxes")
        for box in self.bboxes:
            if len(box) != 4:
                raise ValueError(
                    f"region {self.id!r} bbox {box!r} must be "
                    "[west, south, east, north]"
                )
        return self

clear_catalog_cache() #

Empty the module-level catalog parse cache.

Useful in tests that rewrite the catalog on disk and want to force a re-parse. Production callers do not need this — the cache keys include every contributing file's st_mtime_ns, so any real file mutation invalidates the entry on its own.

Source code in src/earthlens/glaciers/catalog.py
def clear_catalog_cache() -> None:
    """Empty the module-level catalog parse cache.

    Useful in tests that rewrite the catalog on disk and want to force a
    re-parse. Production callers do not need this — the cache keys include every
    contributing file's `st_mtime_ns`, so any real file mutation invalidates the
    entry on its own.
    """
    _CATALOG_CACHE.clear()

earthlens.glaciers._helpers #

Stateless helpers for the glaciers backend.

Grouped by source:

  • Spatial / region mapping — :func:shapely_bbox builds the AOI box that SpatialExtent does not carry, and :func:regions_for_bbox maps a request bbox to the overlapping GTN-G region id(s) (G4 / G6).
  • Vector read (rgi/glims) — :func:download_zip caches a region ZIP the ghsl way; :func:read_outlines reads the inner shapefile via pyramids FeatureCollection.read_file("/vsizip/<zip>/<shp>") and clips it to the bbox; :func:fetch_glims issues a GLIMS WFS bbox query, saves the GeoJSON, reads it via FeatureCollection.read_file, and clips. Vector file I/O always goes through pyramids — never a bare geopandas file read (G3). :func:concat_outlines merges per-region fragments.
  • Tabular read (wgms) — :func:parse_wgms_csv reads one FoG table out of the cached zip with pandas (G8); :func:wgms_glacier_table reads the glacier join table (coordinates + region); :func:empty_canonical builds a schema-only frame. Pure pandas — no array/NetCDF stack.

concat_outlines(fragments) #

Merge per-region outline fragments into one collection.

Parameters:

Name Type Description Default
fragments list[FeatureCollection]

One :class:FeatureCollection per region (or query).

required

Returns:

Name Type Description
FeatureCollection FeatureCollection

The concatenated collection in EPSG:4326. The first non-empty fragment when only one carries features; an empty collection when every fragment is empty.

Raises:

Type Description
ValueError

If fragments is empty.

Source code in src/earthlens/glaciers/_helpers.py
def concat_outlines(fragments: list[FeatureCollection]) -> FeatureCollection:
    """Merge per-region outline fragments into one collection.

    Args:
        fragments: One :class:`FeatureCollection` per region (or query).

    Returns:
        FeatureCollection: The concatenated collection in EPSG:4326. The first
            non-empty fragment when only one carries features; an empty
            collection when every fragment is empty.

    Raises:
        ValueError: If `fragments` is empty.
    """
    if not fragments:
        raise ValueError("concat_outlines() needs at least one fragment")
    non_empty = [fc for fc in fragments if len(fc) > 0]
    if not non_empty:
        return FeatureCollection(fragments[0])
    if len(non_empty) == 1:
        return FeatureCollection(non_empty[0])
    merged = pd.concat(non_empty, ignore_index=True)
    return FeatureCollection(merged, crs=non_empty[0].crs)

download_zip(url, dest_dir, *, session=None, retries=3, backoff=2.0, timeout=180.0, chunk_size=1 << 20) #

Stream a .zip to dest_dir and return the local path (idempotent).

The glaciers vector / tabular paths read inside the zip (RGI via /vsizip/, WGMS via pandas), so — unlike the ghsl raster path — the archive is kept, not extracted. If the file already exists it is returned without re-downloading. Retries transient HTTP failures with exponential backoff.

Parameters:

Name Type Description Default
url str

The .zip URL (an IHP-WINS RGI region or the WGMS FoG archive).

required
dest_dir Path

Directory to cache into (created if absent).

required
session Session | None

Optional shared requests.Session for connection reuse.

None
retries int

Number of attempts before giving up.

3
backoff float

Base seconds for exponential backoff between retries.

2.0
timeout float

Per-request timeout in seconds.

180.0
chunk_size int

Streaming chunk size in bytes.

1 << 20

Returns:

Type Description
Path

pathlib.Path: The cached local .zip path.

Raises:

Type Description
HTTPError

If every attempt fails (the last error is raised).

Source code in src/earthlens/glaciers/_helpers.py
def download_zip(
    url: str,
    dest_dir: Path,
    *,
    session: requests.Session | None = None,
    retries: int = 3,
    backoff: float = 2.0,
    timeout: float = 180.0,
    chunk_size: int = 1 << 20,
) -> Path:
    """Stream a `.zip` to `dest_dir` and return the local path (idempotent).

    The glaciers vector / tabular paths read *inside* the zip (RGI via
    `/vsizip/`, WGMS via pandas), so — unlike the ghsl raster path — the archive
    is kept, not extracted. If the file already exists it is returned without
    re-downloading. Retries transient HTTP failures with exponential backoff.

    Args:
        url: The `.zip` URL (an IHP-WINS RGI region or the WGMS FoG archive).
        dest_dir: Directory to cache into (created if absent).
        session: Optional shared `requests.Session` for connection reuse.
        retries: Number of attempts before giving up.
        backoff: Base seconds for exponential backoff between retries.
        timeout: Per-request timeout in seconds.
        chunk_size: Streaming chunk size in bytes.

    Returns:
        pathlib.Path: The cached local `.zip` path.

    Raises:
        requests.HTTPError: If every attempt fails (the last error is raised).
    """
    dest_dir.mkdir(parents=True, exist_ok=True)
    zip_path = dest_dir / url.rsplit("/", 1)[-1]
    if zip_path.exists() and zip_path.stat().st_size > 0:
        return zip_path
    _stream_download(url, zip_path, session, retries, backoff, timeout, chunk_size)
    return zip_path

empty_canonical(columns) #

Build a schema-only (zero-row) frame with the given columns.

Parameters:

Name Type Description Default
columns list[str]

The column names to materialise.

required

Returns:

Type Description
DataFrame

pandas.DataFrame: An empty frame carrying exactly columns.

Source code in src/earthlens/glaciers/_helpers.py
def empty_canonical(columns: list[str]) -> pd.DataFrame:
    """Build a schema-only (zero-row) frame with the given columns.

    Args:
        columns: The column names to materialise.

    Returns:
        pandas.DataFrame: An empty frame carrying exactly `columns`.
    """
    return pd.DataFrame({c: pd.Series(dtype="object") for c in columns})

empty_feature_collection() #

Build an empty EPSG:4326 :class:FeatureCollection (no features).

Returned by the vector path when a request's bbox overlaps no glacier region / matches no outline, so download() always yields a collection.

Returns:

Name Type Description
FeatureCollection FeatureCollection

A zero-feature collection in EPSG:4326.

Source code in src/earthlens/glaciers/_helpers.py
def empty_feature_collection() -> FeatureCollection:
    """Build an empty EPSG:4326 :class:`FeatureCollection` (no features).

    Returned by the vector path when a request's bbox overlaps no glacier
    region / matches no outline, so `download()` always yields a collection.

    Returns:
        FeatureCollection: A zero-feature collection in EPSG:4326.
    """
    import geopandas as gpd

    return FeatureCollection(gpd.GeoDataFrame(geometry=[], crs="EPSG:4326"))

fetch_glims(wfs_url, typename, bbox, dest_path, *, max_features=10000, session=None, timeout=120.0) #

Query the GLIMS WFS for a bbox, save the GeoJSON, read + clip it (G3).

Issues a WFS GetFeature bbox query, writes the GeoJSON response to dest_path, reads it via pyramids FeatureCollection.read_file (not a bare geopandas file read), and clips to the bbox.

Parameters:

Name Type Description Default
wfs_url str

The GLIMS GeoServer WFS endpoint.

required
typename str

The WFS feature-type name.

required
bbox list[float]

[west, south, east, north] AOI in EPSG:4326.

required
dest_path Path

Local path to write the GeoJSON cache file.

required
max_features int

Cap on returned features (the WFS count).

10000
session Session | None

Optional shared requests.Session.

None
timeout float

Per-request timeout in seconds.

120.0

Returns:

Name Type Description
FeatureCollection FeatureCollection

GLIMS outline polygons intersecting the bbox, in EPSG:4326. Empty when the query matched nothing.

Raises:

Type Description
HTTPError

If the WFS returns a non-2xx status.

Source code in src/earthlens/glaciers/_helpers.py
def fetch_glims(
    wfs_url: str,
    typename: str,
    bbox: list[float],
    dest_path: Path,
    *,
    max_features: int = 10000,
    session: requests.Session | None = None,
    timeout: float = 120.0,
) -> FeatureCollection:
    """Query the GLIMS WFS for a bbox, save the GeoJSON, read + clip it (`G3`).

    Issues a WFS GetFeature bbox query, writes the GeoJSON response to
    `dest_path`, reads it via pyramids `FeatureCollection.read_file` (not a bare
    geopandas file read), and clips to the bbox.

    Args:
        wfs_url: The GLIMS GeoServer WFS endpoint.
        typename: The WFS feature-type name.
        bbox: `[west, south, east, north]` AOI in EPSG:4326.
        dest_path: Local path to write the GeoJSON cache file.
        max_features: Cap on returned features (the WFS `count`).
        session: Optional shared `requests.Session`.
        timeout: Per-request timeout in seconds.

    Returns:
        FeatureCollection: GLIMS outline polygons intersecting the bbox, in
            EPSG:4326. Empty when the query matched nothing.

    Raises:
        requests.HTTPError: If the WFS returns a non-2xx status.
    """
    url, params = glims_wfs_url(wfs_url, typename, bbox, max_features)
    get = session.get if session is not None else requests.get
    resp = get(url, params=params, timeout=timeout)
    resp.raise_for_status()
    dest_path.parent.mkdir(parents=True, exist_ok=True)
    dest_path.write_text(resp.text, encoding="utf-8")
    fc = FeatureCollection.read_file(str(Path(dest_path).resolve()))
    if len(fc) == 0:
        return fc
    return _clip_to_bbox(fc, bbox)

filter_wgms(df, glaciers=None, *, glacier_id=None, glacier_name=None, region=None, bbox=None) #

Filter a WGMS fluctuations table by glacier / region / bbox.

glacier_id and glacier_name match the table directly; region and bbox need the glacier join table (its id == the table's glacier_id, its gtng_region is id-prefixed like "11_central_europe", and it carries latitude / longitude).

Parameters:

Name Type Description Default
df DataFrame

A fluctuations table with a glacier_id (and glacier_name) column.

required
glaciers DataFrame | None

The glacier join table; required for region / bbox.

None
glacier_id Any

One id or a list of ids (matched against glacier_id).

None
glacier_name str | None

A case-insensitive substring matched against glacier_name.

None
region Any

One GTN-G region id ("11") or a list, matched against the glacier table's gtng_region prefix.

None
bbox list[float] | None

[west, south, east, north] filtering glaciers by their point coordinates in the glacier table.

None

Returns:

Type Description
DataFrame

pandas.DataFrame: The filtered rows, with a reset index.

Source code in src/earthlens/glaciers/_helpers.py
def filter_wgms(
    df: pd.DataFrame,
    glaciers: pd.DataFrame | None = None,
    *,
    glacier_id: Any = None,
    glacier_name: str | None = None,
    region: Any = None,
    bbox: list[float] | None = None,
) -> pd.DataFrame:
    """Filter a WGMS fluctuations table by glacier / region / bbox.

    `glacier_id` and `glacier_name` match the table directly; `region` and
    `bbox` need the `glacier` join table (its `id` == the table's `glacier_id`,
    its `gtng_region` is id-prefixed like `"11_central_europe"`, and it carries
    `latitude` / `longitude`).

    Args:
        df: A fluctuations table with a `glacier_id` (and `glacier_name`) column.
        glaciers: The `glacier` join table; required for `region` / `bbox`.
        glacier_id: One id or a list of ids (matched against `glacier_id`).
        glacier_name: A case-insensitive substring matched against
            `glacier_name`.
        region: One GTN-G region id (`"11"`) or a list, matched against the
            `glacier` table's `gtng_region` prefix.
        bbox: `[west, south, east, north]` filtering glaciers by their point
            coordinates in the `glacier` table.

    Returns:
        pandas.DataFrame: The filtered rows, with a reset index.
    """
    out = df
    if glacier_id is not None:
        ids = {int(i) for i in _as_list(glacier_id)}
        out = out[out["glacier_id"].isin(ids)]
    if glacier_name is not None:
        names = out["glacier_name"].astype(str)
        out = out[names.str.contains(glacier_name, case=False, na=False)]
    if (region is not None or bbox is not None) and glaciers is not None:
        sel = glaciers
        if region is not None:
            wanted = {str(r) for r in _as_list(region)}
            prefix = sel["gtng_region"].astype(str).str.split("_").str[0]
            sel = sel[prefix.isin(wanted)]
        if bbox is not None:
            west, south, east, north = bbox
            sel = sel[
                sel["longitude"].between(west, east)
                & sel["latitude"].between(south, north)
            ]
        out = out[out["glacier_id"].isin(set(sel["id"]))]
    return out.reset_index(drop=True)

glims_wfs_url(wfs_url, typename, bbox, max_features) #

Build the GLIMS WFS GetFeature request (URL + params) for a bbox query.

Axis-order landmine: with the URN CRS urn:ogc:def:crs:EPSG::4326 the WFS bbox is south,west,north,east (lat,lon); a plain EPSG:4326 srs returns nothing. This builds the URN form.

Parameters:

Name Type Description Default
wfs_url str

The GLIMS GeoServer WFS endpoint.

required
typename str

The WFS feature-type name ("GLIMS:GLIMS_Glacier_Outlines").

required
bbox list[float]

[west, south, east, north] AOI in EPSG:4326.

required
max_features int

Cap on returned features (the WFS count).

required

Returns:

Type Description
tuple[str, dict[str, Any]]

A (url, params) pair to pass to requests.get.

Source code in src/earthlens/glaciers/_helpers.py
def glims_wfs_url(
    wfs_url: str, typename: str, bbox: list[float], max_features: int
) -> tuple[str, dict[str, Any]]:
    """Build the GLIMS WFS GetFeature request (URL + params) for a bbox query.

    Axis-order landmine: with the URN CRS `urn:ogc:def:crs:EPSG::4326` the WFS
    bbox is `south,west,north,east` (lat,lon); a plain `EPSG:4326` srs returns
    nothing. This builds the URN form.

    Args:
        wfs_url: The GLIMS GeoServer WFS endpoint.
        typename: The WFS feature-type name
            (`"GLIMS:GLIMS_Glacier_Outlines"`).
        bbox: `[west, south, east, north]` AOI in EPSG:4326.
        max_features: Cap on returned features (the WFS `count`).

    Returns:
        A `(url, params)` pair to pass to `requests.get`.
    """
    west, south, east, north = bbox
    crs_urn = "urn:ogc:def:crs:EPSG::4326"
    params = {
        "service": "WFS",
        "version": "2.0.0",
        "request": "GetFeature",
        "typeNames": typename,
        "outputFormat": "application/json",
        "srsName": crs_urn,
        "count": str(max_features),
        "bbox": f"{south},{west},{north},{east},{crs_urn}",
    }
    return wfs_url, params

parse_wgms_csv(zip_path, table) #

Read one WGMS FoG table out of the cached zip (G8).

The FoG tables are already long-format (glacier_id / year-or-date / value), so this is a plain pandas.read_csv of the data/<table>.csv member — no reshaping, no array/NetCDF stack.

Parameters:

Name Type Description Default
zip_path Path

The cached WGMS FoG .zip (from :func:download_zip).

required
table str

The table name ("mass_balance", "front_variation", "state").

required

Returns:

Type Description
DataFrame

pandas.DataFrame: The table rows.

Raises:

Type Description
KeyError

If the zip has no data/<table>.csv member.

Source code in src/earthlens/glaciers/_helpers.py
def parse_wgms_csv(zip_path: Path, table: str) -> pd.DataFrame:
    """Read one WGMS FoG table out of the cached zip (`G8`).

    The FoG tables are already long-format (`glacier_id` / year-or-date /
    value), so this is a plain `pandas.read_csv` of the `data/<table>.csv`
    member — no reshaping, no array/NetCDF stack.

    Args:
        zip_path: The cached WGMS FoG `.zip` (from :func:`download_zip`).
        table: The table name (`"mass_balance"`, `"front_variation"`,
            `"state"`).

    Returns:
        pandas.DataFrame: The table rows.

    Raises:
        KeyError: If the zip has no `data/<table>.csv` member.
    """
    member = f"data/{table}.csv"
    with zipfile.ZipFile(zip_path) as zf:
        if member not in zf.namelist():
            raise KeyError(
                f"WGMS FoG zip has no {member!r} member "
                f"(available: {sorted(zf.namelist())})"
            )
        with zf.open(member) as handle:
            return pd.read_csv(io.BytesIO(handle.read()), low_memory=False)

read_outlines(zip_path, bbox, inner_shp=None) #

Read RGI outlines from a cached region zip and clip to the bbox (G3/G4).

Reads the shapefile inside the region ZIP via pyramids FeatureCollection.read_file("/vsizip/<zip>/<shp>") — never a bare geopandas file read (porting policy) — reprojects to EPSG:4326 if needed, and clips to the bbox.

Parameters:

Name Type Description Default
zip_path Path

The local RGI region .zip (from :func:download_zip).

required
bbox list[float]

[west, south, east, north] AOI in EPSG:4326.

required
inner_shp str | None

The inner .shp member name; resolved from the zip when None.

None

Returns:

Name Type Description
FeatureCollection FeatureCollection

Glacier-outline polygons intersecting the bbox, in EPSG:4326.

Source code in src/earthlens/glaciers/_helpers.py
def read_outlines(
    zip_path: Path, bbox: list[float], inner_shp: str | None = None
) -> FeatureCollection:
    """Read RGI outlines from a cached region zip and clip to the bbox (`G3`/`G4`).

    Reads the shapefile *inside* the region ZIP via pyramids
    `FeatureCollection.read_file("/vsizip/<zip>/<shp>")` — never a bare geopandas
    file read (porting policy) — reprojects to EPSG:4326 if needed, and clips to
    the bbox.

    Args:
        zip_path: The local RGI region `.zip` (from :func:`download_zip`).
        bbox: `[west, south, east, north]` AOI in EPSG:4326.
        inner_shp: The inner `.shp` member name; resolved from the zip when
            `None`.

    Returns:
        FeatureCollection: Glacier-outline polygons intersecting the bbox, in
            EPSG:4326.
    """
    shp = inner_shp or _inner_shapefile(zip_path)
    vsi = f"/vsizip/{Path(zip_path).resolve()}/{shp}"
    fc = FeatureCollection.read_file(vsi)
    return _clip_to_bbox(fc, bbox)

regions_for_bbox(bbox, regions) #

Map a request bbox to the overlapping GTN-G region id(s) (G6).

Parameters:

Name Type Description Default
bbox list[float]

The request AOI as [west, south, east, north] in EPSG:4326.

required
regions dict[str, Any]

The catalog region map (id -> a row exposing .bboxes, each a [west, south, east, north] quadruple).

required

Returns:

Type Description
list[str]

list[str]: The sorted ids of every region with a bbox intersecting the AOI. Empty when the AOI overlaps no glacier region.

Source code in src/earthlens/glaciers/_helpers.py
def regions_for_bbox(bbox: list[float], regions: dict[str, Any]) -> list[str]:
    """Map a request bbox to the overlapping GTN-G region id(s) (`G6`).

    Args:
        bbox: The request AOI as `[west, south, east, north]` in EPSG:4326.
        regions: The catalog region map (id -> a row exposing `.bboxes`, each
            a `[west, south, east, north]` quadruple).

    Returns:
        list[str]: The sorted ids of every region with a bbox intersecting the
            AOI. Empty when the AOI overlaps no glacier region.
    """
    aoi = box(*bbox)
    hits = []
    for region_id, region in regions.items():
        if any(box(*region_box).intersects(aoi) for region_box in region.bboxes):
            hits.append(region_id)
    return sorted(hits)

shapely_bbox(space) #

Build the AOI box a SpatialExtent does not carry (G4).

SpatialExtent exposes only north / south / east / west, so the bbox intersect-filter needs this small adapter.

Parameters:

Name Type Description Default
space SpatialExtent

The request's :class:~earthlens.base.SpatialExtent.

required

Returns:

Type Description
Any

A shapely.geometry.box(west, south, east, north) polygon.

Source code in src/earthlens/glaciers/_helpers.py
def shapely_bbox(space: SpatialExtent) -> Any:
    """Build the AOI box a `SpatialExtent` does not carry (`G4`).

    `SpatialExtent` exposes only `north` / `south` / `east` / `west`, so the
    bbox intersect-filter needs this small adapter.

    Args:
        space: The request's :class:`~earthlens.base.SpatialExtent`.

    Returns:
        A `shapely.geometry.box(west, south, east, north)` polygon.
    """
    return box(space.west, space.south, space.east, space.north)

wgms_glacier_table(zip_path) #

Read the WGMS glacier join table (coordinates + region) from the zip.

The glacier table carries id (== the other tables' glacier_id), latitude / longitude, and gtng_region, so it is the key for a region / bbox filter over a fluctuations table.

Parameters:

Name Type Description Default
zip_path Path

The cached WGMS FoG .zip.

required

Returns:

Type Description
DataFrame

pandas.DataFrame: The glacier table.

Raises:

Type Description
KeyError

If the zip has no data/glacier.csv member.

Source code in src/earthlens/glaciers/_helpers.py
def wgms_glacier_table(zip_path: Path) -> pd.DataFrame:
    """Read the WGMS `glacier` join table (coordinates + region) from the zip.

    The `glacier` table carries `id` (== the other tables' `glacier_id`),
    `latitude` / `longitude`, and `gtng_region`, so it is the key for a
    region / bbox filter over a fluctuations table.

    Args:
        zip_path: The cached WGMS FoG `.zip`.

    Returns:
        pandas.DataFrame: The `glacier` table.

    Raises:
        KeyError: If the zip has no `data/glacier.csv` member.
    """
    return parse_wgms_csv(zip_path, "glacier")