Skip to content

Area of interest (earthlens.base.spatial)#

Every request needs to know where. There are two mutually exclusive channels:

  • aoi= — one argument that accepts seven different shapes;
  • lat_lim= / lon_lim= — the legacy pair, each [min, max] in degrees.

Passing both raises ValueError: pass either aoi= or lat_lim=/lon_lim=, not both. Passing neither requests the whole globe (lat_lim=[-90, 90], lon_lim=[-180, 180]).

Whatever goes into aoi= is normalised by resolve_aoi down to that same lat_lim / lon_lim pair — so no backend has to change — and a genuinely non-rectangular shape is kept alongside it as a clip mask.

Every form below is demonstrated end to end, against anonymous GEBCO, in the Choosing an area of interest notebook.

The accepted aoi forms#

Form Example Mask kept?
bbox sequence aoi=[-29.5, 36.2, -27.7, 38.0] —
bbox mapping aoi={"min_lon": -29.5, "min_lat": 36.2, "max_lon": -27.7, "max_lat": 38.0} —
point + buffer= aoi=(-28.6, 37.1), buffer=0.9 —
WKT string aoi="POLYGON((-29.5 36.2, ...))" ✅
GeoJSON mapping aoi={"type": "Polygon", "coordinates": [...]} ✅
GeoDataFrame / GeoSeries aoi=gdf ✅ (polygonal frames)
shapely geometry aoi=Polygon([...]) ✅

All coordinates are read as WGS84 degrees, with one exception — a GeoDataFrame that declares another CRS, which is reprojected for you (see below).

Coordinate order#

The bbox sequence is [W, S, E, N] — the GeoJSON / STAC order, longitude first. That is deliberately not the order of the legacy pair, which is latitude-first (lat_lim=[S, N], lon_lim=[W, E]). The same box written both ways:

lens = EarthLens(data_source="gebco", aoi=[-29.5, 36.2, -27.7, 38.0])
lens = EarthLens(data_source="gebco", lat_lim=[36.2, 38.0], lon_lim=[-29.5, -27.7])

Bbox mappings accept several key spellings#

A mapping that is not GeoJSON is read as the four bbox edges. Each edge is resolved against a list of aliases — GeoJSON min_lon, eodag lonmin, shapely / geopandas minx, compass west — matched case-insensitively, so a box arriving as another tool's JSON usually needs no translation. A missing edge is named in the error (no key found for the 'north' edge).

Points need a buffer#

A two-value aoi is read as (lon, lat). A point has no area, so buffer= — a half-width in degrees — is required rather than defaulted, and the resulting square is clamped to the valid lon/lat ranges. buffer= without a point aoi= raises.

GeoDataFrames are reprojected for you#

The check is duck-typed on total_bounds, so geopandas is never imported merely to test a type, and a GeoSeries works as well as a GeoDataFrame. Because total_bounds reports in the frame's own CRS, a projected frame is reprojected to EPSG:4326 first — otherwise a UTM or Web Mercator frame would yield a metre-valued, out-of-range bbox. A frame with no declared CRS is taken as already lon/lat.

Only polygonal frames contribute a mask; a points or lines frame contributes its bounding box alone.

Polygon masks are honoured only where supported#

resolve_aoi always returns the mask when the input had a real shape, but whether it is used is the backend's decision, advertised through SUPPORTS_POLYGON_AOI (see Base contracts). A backend that cannot clip to a polygon falls back to the bounding box and emits a PolygonAoiWarning. Its own docstring says why it exists: the download still succeeds, it just covers the bbox, and that is "the most dangerous kind of wrong result — a valid raster of the right variable over roughly the right area". Watch for that warning whenever you pass a polygon.

Backends that do support it apply the mask through crop_to_aoi (crop and mask in one step) or mask_to_geometry (mask a raster that some other route already cropped to the bbox, such as a server-side area parameter). Masking a raster that declares no no-data value logs a warning, because the mask can then only trim to the polygon's bounding box.

Inputs that are rejected rather than guessed#

Input Outcome
a point aoi with no buffer= ValueError — a point has no area
west east of east, e.g. [170, -20, -170, -10] ValueError — an antimeridian crossing (see below)
south north of north ValueError — inverted latitude bounds
any other type TypeError naming the accepted forms

A west-of-east box is the GeoJSON / STAC spelling of an antimeridian crossing, not a typo, so it is named rather than silently reinterpreted. Split it at ±180 and issue the two halves as separate requests — the error message spells out both boxes for you.

Backends that interpret aoi= themselves#

One backend declares its own, richer aoi parameter and therefore receives the value verbatim, bypassing resolve_aoi: worldpop, whose aoi= also accepts an ISO3 country code or a list of them. Passing buffer= to such a backend raises, since the buffer is a resolve_aoi concept.

API#

earthlens.base.spatial.resolve_aoi(aoi, buffer=None) #

Coerce a flexible area-of-interest into (lat_lim, lon_lim, geometry).

The single aoi channel accepts every shape the popular EO packages accept, so a caller never has to remember EarthLens's legacy lat-then-lon two-list convention. Accepted forms:

  • a bbox sequence [min_lon, min_lat, max_lon, max_lat] — the GeoJSON / STAC W, S, E, N order;
  • a bbox mapping with any spelling of the four edges (min_lon / lonmin / minx / west, …);
  • a (lon, lat) point — requires buffer (a half-width in degrees), which is grown into a square box;
  • a shapely geometry, or any object exposing __geo_interface__ (e.g. a geopandas row);
  • a GeoJSON geometry / Feature / FeatureCollection mapping;
  • a WKT string (parsed with shapely);
  • a GeoDataFrame / GeoSeries (via its total_bounds, reprojected to WGS84 first when the frame declares another CRS).

All coordinates are WGS84 degrees, and the returned pairs use the internal [min, max] shape that every backend's _create_grid consumes.

Unlike normalize_aoi, this also returns the polygon mask when the area of interest had a real (non-rectangular) shape: a (multi)polygon keeps its geometry, while a bbox, a point, or a points / lines frame yields None and a plain bbox clip is exact. Raster backends pass the mask to pyramids.Dataset.crop as its mask= argument — but only those advertising SUPPORTS_POLYGON_AOI do so. The rest fall back to the bounding box and emit a PolygonAoiWarning.

Parameters:

Name Type Description Default
aoi Any

The area of interest in any of the accepted forms above.

required
buffer float | None

Half-width in degrees. Required for, and only used by, the (lon, lat) point form; a buffered point near a pole is clamped to the valid latitude / longitude ranges.

None

Returns:

Type Description
list[float]

(lat_lim, lon_lim, geometry) where the pairs are [min, max]

list[float]

floats in degrees and geometry is a WGS84 GeoDataFrame polygon

Any

mask (or None for a bbox / point aoi, or when geopandas is

tuple[list[float], list[float], Any]

unavailable).

Raises:

Type Description
ValueError

If aoi is malformed, a point is given without buffer, or the resulting box is degenerate / inverted — including a west-of-east box, which denotes an antimeridian crossing and must be split into two requests.

TypeError

If aoi is of an unsupported type.

Examples:

  • A bbox is read as W, S, E, N and needs no mask:
    >>> lat_lim, lon_lim, geometry = resolve_aoi([-75.0, 4.0, -74.0, 5.0])
    >>> lat_lim, lon_lim
    ([4.0, 5.0], [-75.0, -74.0])
    >>> geometry is None
    True
    
  • A WKT polygon keeps its shape as a WGS84 clip mask:
    >>> lat_lim, lon_lim, geometry = resolve_aoi(
    ...     "POLYGON ((-75 4, -74 4, -74 5, -75 5, -75 4))"
    ... )
    >>> lon_lim
    [-75.0, -74.0]
    >>> geometry.crs.to_epsg()
    4326
    >>> geometry.geometry.iloc[0].geom_type
    'Polygon'
    
  • A point is grown into a square box by buffer:
    >>> resolve_aoi((-74.5, 4.5), buffer=0.5)[:2]
    ([4.0, 5.0], [-75.0, -74.0])
    
  • Omitting buffer for a point raises:
    >>> resolve_aoi((-74.5, 4.5))
    Traceback (most recent call last):
        ...
    ValueError: a point aoi=(lon, lat) requires buffer= (a half-width in degrees) to define an area
    
See Also

normalize_aoi: The same channel, bbox only, when no mask is needed. crop_to_aoi: Applies the resolved bbox and mask to a dataset.

Source code in libs/core/src/earthlens/base/spatial.py
def resolve_aoi(
    aoi: Any, buffer: float | None = None
) -> tuple[list[float], list[float], Any]:
    """Coerce a flexible area-of-interest into `(lat_lim, lon_lim, geometry)`.

    The single `aoi` channel accepts every shape the popular EO packages
    accept, so a caller never has to remember EarthLens's legacy
    lat-then-lon two-list convention. Accepted forms:

    * a bbox sequence `[min_lon, min_lat, max_lon, max_lat]` — the
      GeoJSON / STAC **W, S, E, N** order;
    * a bbox mapping with any spelling of the four edges (`min_lon` /
      `lonmin` / `minx` / `west`, …);
    * a `(lon, lat)` point — requires `buffer` (a half-width in degrees),
      which is grown into a square box;
    * a shapely geometry, or any object exposing `__geo_interface__`
      (e.g. a `geopandas` row);
    * a GeoJSON geometry / `Feature` / `FeatureCollection` mapping;
    * a WKT string (parsed with shapely);
    * a `GeoDataFrame` / `GeoSeries` (via its `total_bounds`, reprojected
      to WGS84 first when the frame declares another CRS).

    All coordinates are WGS84 degrees, and the returned pairs use the
    internal `[min, max]` shape that every backend's `_create_grid`
    consumes.

    Unlike `normalize_aoi`, this also returns the polygon mask when the
    area of interest had a real (non-rectangular) shape: a (multi)polygon
    keeps its geometry, while a bbox, a point, or a points / lines frame
    yields `None` and a plain bbox clip is exact. Raster backends pass the
    mask to `pyramids.Dataset.crop` as its `mask=` argument — but only
    those advertising `SUPPORTS_POLYGON_AOI` do so. The rest fall back to
    the bounding box and emit a `PolygonAoiWarning`.

    Args:
        aoi: The area of interest in any of the accepted forms above.
        buffer: Half-width in degrees. Required for, and only used by,
            the `(lon, lat)` point form; a buffered point near a pole is
            clamped to the valid latitude / longitude ranges.

    Returns:
        `(lat_lim, lon_lim, geometry)` where the pairs are `[min, max]`
        floats in degrees and `geometry` is a WGS84 `GeoDataFrame` polygon
        mask (or `None` for a bbox / point aoi, or when `geopandas` is
        unavailable).

    Raises:
        ValueError: If `aoi` is malformed, a point is given without
            `buffer`, or the resulting box is degenerate / inverted —
            including a west-of-east box, which denotes an antimeridian
            crossing and must be split into two requests.
        TypeError: If `aoi` is of an unsupported type.

    Examples:
        - A bbox is read as W, S, E, N and needs no mask:
            ```python
            >>> lat_lim, lon_lim, geometry = resolve_aoi([-75.0, 4.0, -74.0, 5.0])
            >>> lat_lim, lon_lim
            ([4.0, 5.0], [-75.0, -74.0])
            >>> geometry is None
            True

            ```
        - A WKT polygon keeps its shape as a WGS84 clip mask:
            ```python
            >>> lat_lim, lon_lim, geometry = resolve_aoi(
            ...     "POLYGON ((-75 4, -74 4, -74 5, -75 5, -75 4))"
            ... )
            >>> lon_lim
            [-75.0, -74.0]
            >>> geometry.crs.to_epsg()
            4326
            >>> geometry.geometry.iloc[0].geom_type
            'Polygon'

            ```
        - A point is grown into a square box by `buffer`:
            ```python
            >>> resolve_aoi((-74.5, 4.5), buffer=0.5)[:2]
            ([4.0, 5.0], [-75.0, -74.0])

            ```
        - Omitting `buffer` for a point raises:
            ```python
            >>> resolve_aoi((-74.5, 4.5))
            Traceback (most recent call last):
                ...
            ValueError: a point aoi=(lon, lat) requires buffer= (a half-width in degrees) to define an area

            ```

    See Also:
        normalize_aoi: The same channel, bbox only, when no mask is needed.
        crop_to_aoi: Applies the resolved bbox and mask to a dataset.
    """
    geom: Any = None
    if isinstance(aoi, str):
        from shapely import wkt

        shape = wkt.loads(aoi)
        min_lon, min_lat, max_lon, max_lat = shape.bounds
        geom = _polygon_or_none(shape)
    elif isinstance(aoi, Mapping):
        if "type" in aoi or "coordinates" in aoi or "bbox" in aoi:
            min_lon, min_lat, max_lon, max_lat = _geojson_bounds(aoi)
            geom = _geojson_polygon(aoi)
        else:
            min_lon, min_lat, max_lon, max_lat = _bbox_dict_bounds(aoi)
    elif hasattr(aoi, "total_bounds"):  # geopandas GeoDataFrame / GeoSeries
        # `total_bounds` is in the frame's own CRS; reproject to WGS84
        # lon/lat first so a projected frame (e.g. UTM / Web Mercator)
        # does not yield a metre-valued, out-of-range bbox. A frame with
        # no CRS is taken as-is (already lon/lat).
        crs = getattr(aoi, "crs", None)
        if crs is not None and crs.to_epsg() != 4326:
            aoi = aoi.to_crs(4326)
        min_lon, min_lat, max_lon, max_lat = (float(v) for v in aoi.total_bounds)
        geom_type = getattr(aoi, "geom_type", None)
        if geom_type is not None and any(
            t in _POLYGONAL_GEOM_TYPES for t in geom_type.unique()
        ):
            geom = aoi
    elif hasattr(aoi, "__geo_interface__"):  # shapely geometry, etc.
        gi = aoi.__geo_interface__
        min_lon, min_lat, max_lon, max_lat = _geojson_bounds(gi)
        geom = _geojson_polygon(gi)
    elif isinstance(aoi, Sequence) and not isinstance(aoi, str):
        if len(aoi) == 4:
            min_lon, min_lat, max_lon, max_lat = (float(v) for v in aoi)
        elif len(aoi) == 2:
            if buffer is None:
                raise ValueError(
                    "a point aoi=(lon, lat) requires buffer= (a half-width "
                    "in degrees) to define an area"
                )
            lon, lat = float(aoi[0]), float(aoi[1])
            min_lon, max_lon = lon - buffer, lon + buffer
            min_lat, max_lat = lat - buffer, lat + buffer
            min_lat, max_lat = max(min_lat, -90.0), min(max_lat, 90.0)
            min_lon, max_lon = max(min_lon, -180.0), min(max_lon, 180.0)
        else:
            raise ValueError(
                f"a bbox aoi must have 4 values [W, S, E, N] or a point 2 "
                f"values [lon, lat]; got {len(aoi)}"
            )
    else:
        raise TypeError(
            f"unsupported aoi type {type(aoi).__name__}; pass a bbox "
            "[W, S, E, N], a bbox mapping (min_lon / lonmin / minx / west "
            "keys), a (lon, lat) point with buffer=, a shapely geometry / "
            "GeoJSON / WKT, or a GeoDataFrame"
        )

    if min_lon > max_lon:
        # West-of-east is the GeoJSON / STAC spelling of an antimeridian
        # crossing rather than a typo, so name the case and the remedy.
        raise ValueError(
            f"aoi has west ({min_lon}) east of east ({max_lon}), which denotes "
            f"an antimeridian crossing. Split it at ±180 and pass the two "
            f"halves as separate requests (e.g. aoi=[{min_lon}, {min_lat}, "
            f"180, {max_lat}] and aoi=[-180, {min_lat}, {max_lon}, {max_lat}])."
        )
    if min_lat > max_lat:
        raise ValueError(
            f"aoi has inverted latitude bounds: [{min_lat}, {max_lat}] "
            f"(south edge north of the north edge)."
        )
    return [min_lat, max_lat], [min_lon, max_lon], _to_clip_gdf(geom)

earthlens.base.spatial.normalize_aoi(aoi, buffer=None) #

Coerce a flexible area-of-interest into (lat_lim, lon_lim) pairs.

The single aoi channel accepts every shape the popular EO packages accept, so a caller never has to remember EarthLens's legacy lat-then-lon two-list convention. Accepted forms:

  • a bbox sequence [min_lon, min_lat, max_lon, max_lat] — the GeoJSON / STAC W, S, E, N order;
  • a bbox mapping with any spelling of the four edges (min_lon / lonmin / minx / west, …);
  • a (lon, lat) point — requires buffer (a half-width in degrees), which is grown into a square box;
  • a shapely geometry, or any object exposing __geo_interface__ (e.g. a geopandas row), reduced to its envelope;
  • a GeoJSON geometry / Feature / FeatureCollection mapping;
  • a WKT string (parsed with shapely);
  • a GeoDataFrame / GeoSeries (via its total_bounds).

All coordinates are assumed to be WGS84 degrees. The returned pairs use the internal [min, max] shape that every backend's _create_grid already consumes, so no backend has to change. This is the bbox-only view of resolve_aoi; use that when you also need the polygon mask for precise (non-rectangular) clipping.

Parameters:

Name Type Description Default
aoi Any

The area of interest in any of the accepted forms above.

required
buffer float | None

Half-width in degrees. Required for, and only used by, the (lon, lat) point form; a buffered point near a pole is clamped to the valid latitude / longitude ranges.

None

Returns:

Type Description
list[float]

(lat_lim, lon_lim) where each is a [min, max] float pair in

list[float]

degrees.

Raises:

Type Description
ValueError

If aoi is malformed, a point is given without buffer, or the resulting box is degenerate / inverted.

TypeError

If aoi is of an unsupported type.

Examples:

  • A bbox list is read as W, S, E, N:
    >>> normalize_aoi([-75.0, 4.0, -74.0, 5.0])
    ([4.0, 5.0], [-75.0, -74.0])
    
  • A bbox mapping accepts several spellings:
    >>> normalize_aoi(
    ...     {"min_lon": -75, "min_lat": 4, "max_lon": -74, "max_lat": 5}
    ... )
    ([4.0, 5.0], [-75.0, -74.0])
    
  • A point grows into a square box with buffer:
    >>> normalize_aoi((-74.5, 4.5), buffer=0.5)
    ([4.0, 5.0], [-75.0, -74.0])
    
  • A WKT polygon is reduced to its envelope:
    >>> normalize_aoi("POLYGON ((-75 4, -74 4, -74 5, -75 5, -75 4))")
    ([4.0, 5.0], [-75.0, -74.0])
    
Source code in libs/core/src/earthlens/base/spatial.py
def normalize_aoi(
    aoi: Any, buffer: float | None = None
) -> tuple[list[float], list[float]]:
    """Coerce a flexible area-of-interest into `(lat_lim, lon_lim)` pairs.

    The single `aoi` channel accepts every shape the popular EO packages
    accept, so a caller never has to remember EarthLens's legacy
    lat-then-lon two-list convention. Accepted forms:

    * a bbox sequence `[min_lon, min_lat, max_lon, max_lat]` — the
      GeoJSON / STAC **W, S, E, N** order;
    * a bbox mapping with any spelling of the four edges (`min_lon` /
      `lonmin` / `minx` / `west`, …);
    * a `(lon, lat)` point — requires `buffer` (a half-width in degrees),
      which is grown into a square box;
    * a shapely geometry, or any object exposing `__geo_interface__`
      (e.g. a `geopandas` row), reduced to its envelope;
    * a GeoJSON geometry / `Feature` / `FeatureCollection` mapping;
    * a WKT string (parsed with shapely);
    * a `GeoDataFrame` / `GeoSeries` (via its `total_bounds`).

    All coordinates are assumed to be WGS84 degrees. The returned pairs
    use the internal `[min, max]` shape that every backend's
    `_create_grid` already consumes, so no backend has to change. This is
    the bbox-only view of `resolve_aoi`; use that when you also need
    the polygon mask for precise (non-rectangular) clipping.

    Args:
        aoi: The area of interest in any of the accepted forms above.
        buffer: Half-width in degrees. Required for, and only used by,
            the `(lon, lat)` point form; a buffered point near a pole is
            clamped to the valid latitude / longitude ranges.

    Returns:
        `(lat_lim, lon_lim)` where each is a `[min, max]` float pair in
        degrees.

    Raises:
        ValueError: If `aoi` is malformed, a point is given without
            `buffer`, or the resulting box is degenerate / inverted.
        TypeError: If `aoi` is of an unsupported type.

    Examples:
        - A bbox list is read as W, S, E, N:
            ```python
            >>> normalize_aoi([-75.0, 4.0, -74.0, 5.0])
            ([4.0, 5.0], [-75.0, -74.0])

            ```
        - A bbox mapping accepts several spellings:
            ```python
            >>> normalize_aoi(
            ...     {"min_lon": -75, "min_lat": 4, "max_lon": -74, "max_lat": 5}
            ... )
            ([4.0, 5.0], [-75.0, -74.0])

            ```
        - A point grows into a square box with `buffer`:
            ```python
            >>> normalize_aoi((-74.5, 4.5), buffer=0.5)
            ([4.0, 5.0], [-75.0, -74.0])

            ```
        - A WKT polygon is reduced to its envelope:
            ```python
            >>> normalize_aoi("POLYGON ((-75 4, -74 4, -74 5, -75 5, -75 4))")
            ([4.0, 5.0], [-75.0, -74.0])

            ```
    """
    lat_lim, lon_lim, _geometry = resolve_aoi(aoi, buffer=buffer)
    return lat_lim, lon_lim

earthlens.base.spatial.crop_to_aoi(dataset, space, *, bbox, epsg=4326, touch=False) #

Crop a pyramids Dataset to a polygon mask if present, else a bbox.

When space carries a polygon geometry — set when the request's aoi= was a polygon rather than a plain bbox — the dataset is masked to that exact shape via Dataset.crop(mask=...), so pixels outside the polygon become no-data and the raster is trimmed to the polygon's cell extent. Otherwise it is cropped to the rectangular bbox. Centralising the choice lets every raster backend honour a polygon aoi= the same way without duplicating the branch. On the polygon path a raster that declares no no-data value logs a warning, since the mask can then only trim to the polygon's bounding box (see _crop_to_mask).

Parameters:

Name Type Description Default
dataset Any

A pyramids.Dataset (anything exposing crop).

required
space Any

A SpatialExtent (anything exposing an optional geometry).

required
bbox Sequence[float]

The fallback (west, south, east, north) quadruple, used when space has no polygon geometry.

required
epsg Any

CRS of bbox. Defaults to 4326.

4326
touch bool

For the bbox path, whether to keep cells merely touching the box. Ignored on the polygon-mask path, which always keeps touching cells. Defaults to False.

False

Returns:

Type Description
Any

A new cropped Dataset.

Source code in libs/core/src/earthlens/base/spatial.py
def crop_to_aoi(
    dataset: Any,
    space: Any,
    *,
    bbox: Sequence[float],
    epsg: Any = 4326,
    touch: bool = False,
) -> Any:
    """Crop a pyramids `Dataset` to a polygon mask if present, else a bbox.

    When `space` carries a polygon `geometry` — set when the request's
    `aoi=` was a polygon rather than a plain bbox — the dataset is masked
    to that exact shape via `Dataset.crop(mask=...)`, so pixels outside the
    polygon become no-data and the raster is trimmed to the polygon's cell
    extent. Otherwise it is cropped to the rectangular `bbox`. Centralising
    the choice lets every raster backend honour a polygon `aoi=` the same
    way without duplicating the branch. On the polygon path a raster that
    declares no no-data value logs a warning, since the mask can then only
    trim to the polygon's bounding box (see `_crop_to_mask`).

    Args:
        dataset: A `pyramids.Dataset` (anything exposing `crop`).
        space: A `SpatialExtent` (anything exposing an optional
            `geometry`).
        bbox: The fallback `(west, south, east, north)` quadruple, used
            when `space` has no polygon `geometry`.
        epsg: CRS of `bbox`. Defaults to `4326`.
        touch: For the bbox path, whether to keep cells merely touching the
            box. Ignored on the polygon-mask path, which always keeps
            touching cells. Defaults to `False`.

    Returns:
        A new cropped `Dataset`.
    """
    geometry = getattr(space, "geometry", None)
    if geometry is not None:
        return _crop_to_mask(dataset, geometry, touch=True)
    # Widen a point / cell-edge bbox to one pixel so a zero-area box is not handed
    # to `crop` (which would trim to nothing / error); a point AOI still yields a
    # 1x1 crop. The pixel size (`geo[1]`/`geo[5]`) is in the dataset's CRS units,
    # so only widen when the bbox is in that same CRS — `epsg is None` means "the
    # dataset's own CRS" (pyramids' default), and an explicit `epsg` must match
    # the dataset's. For a reprojecting crop (bbox `epsg` != the dataset's CRS)
    # mixing the units would push the edge out by a wrong amount, so leave the
    # bbox as-is. (A real `Dataset` exposes `geotransform` / `epsg`; the getattr
    # guards let a bare test double without them reach crop.)
    geo = getattr(dataset, "geotransform", None)
    if geo is not None and (epsg is None or getattr(dataset, "epsg", None) == epsg):
        bbox = widen_degenerate_bbox(bbox, geo[1], geo[5])
    return dataset.crop(bbox=list(bbox), epsg=epsg, touch=touch)

earthlens.base.spatial.mask_to_geometry(dataset, space, *, touch=True) #

Mask an already-bbox-clipped Dataset / NetCDF to a polygon, if any.

The counterpart to crop_to_aoi for backends that have already cropped to the bbox by another route — CHIRPS's in-array numpy clip, or a server-side bbox (ECMWF's CDS area, a NetCDF cube). When space carries a polygon geometry, the dataset is masked to that exact shape via crop(mask=...); otherwise it is returned unchanged. As with crop_to_aoi, masking a raster that declares no no-data value logs a warning, since the mask can then only trim to the polygon's bounding box (see _crop_to_mask).

Parameters:

Name Type Description Default
dataset Any

A pyramids.Dataset / NetCDF (anything exposing crop).

required
space Any

A SpatialExtent (anything exposing an optional geometry).

required
touch bool

Whether to keep cells merely touching the polygon. Defaults to True.

True

Returns:

Type Description
Any

The masked Dataset when a polygon geometry is present, else the

Any

original dataset untouched.

Source code in libs/core/src/earthlens/base/spatial.py
def mask_to_geometry(dataset: Any, space: Any, *, touch: bool = True) -> Any:
    """Mask an already-bbox-clipped `Dataset` / `NetCDF` to a polygon, if any.

    The counterpart to `crop_to_aoi` for backends that have *already*
    cropped to the bbox by another route — CHIRPS's in-array numpy clip, or
    a server-side bbox (ECMWF's CDS `area`, a NetCDF cube). When `space`
    carries a polygon `geometry`, the dataset is masked to that exact shape
    via `crop(mask=...)`; otherwise it is returned unchanged. As with
    `crop_to_aoi`, masking a raster that declares no no-data value logs a
    warning, since the mask can then only trim to the polygon's bounding box
    (see `_crop_to_mask`).

    Args:
        dataset: A `pyramids.Dataset` / `NetCDF` (anything exposing `crop`).
        space: A `SpatialExtent` (anything exposing an optional
            `geometry`).
        touch: Whether to keep cells merely touching the polygon. Defaults
            to `True`.

    Returns:
        The masked `Dataset` when a polygon `geometry` is present, else the
        original `dataset` untouched.
    """
    geometry = getattr(space, "geometry", None)
    if geometry is None:
        return dataset
    return _crop_to_mask(dataset, geometry, touch=touch)