Skip to content

Extents and warnings#

The frozen pydantic value objects that describe where and when a request covers. A backend's _create_grid() returns a SpatialExtent and its _check_input_dates() returns a TemporalExtent; the base class captures them into self.space and self.time.

from earthlens.base import SpatialExtent, TemporalExtent

These are values, not dicts — they are immutable, and SpatialExtent is comparable and hashable.

TemporalExtent is neither, because its dates field holds a DatetimeIndex. hash() always raises TypeError. == is worse than simply failing: it returns True when both extents share the same dates object, and raises ValueError ("truth value of an array ... is ambiguous") when they hold equal-but-distinct ones — so it appears to work until it doesn't. Compare the scalar fields (start_date, end_date, resolution) rather than the object.

SpatialExtent#

earthlens.base.SpatialExtent #

Bases: BaseModel

Geographic bounding box (WGS84) for a download request.

Backend-agnostic. Coordinates are in degrees:

  • latitude in [-90, 90] (south negative, north positive)
  • longitude in [-180, 180] (west negative, east positive)

Each concrete data source converts this to whatever format its protocol expects (CDS: [north, west, south, east]; CHIRPS: per-row clipping; S3: prefix filter; GEE: ee.Geometry.Rectangle(west, south, east, north)). For projected coordinates, define a separate ProjectedExtent type — do not reuse this one with metric values.

Attributes:

Name Type Description
latitude_min float

Inclusive south edge of the bbox, in degrees.

latitude_max float

Inclusive north edge of the bbox, in degrees.

longitude_min float

Inclusive west edge of the bbox, in degrees.

longitude_max float

Inclusive east edge of the bbox, in degrees.

resolution float | None

Grid cell size in degrees, applied to both latitude and longitude. None for backends that work on irregular grids or do not need a cell size for their request shape (e.g. CHIRPS FTP file lookup, S3 prefix listing). Mirrors :attr:TemporalExtent.resolution — the spatial counterpart of the temporal cadence.

Source code in libs/core/src/earthlens/base/abstractdatasource.py
class SpatialExtent(BaseModel):
    """Geographic bounding box (WGS84) for a download request.

    Backend-agnostic. Coordinates are in **degrees**:

    * latitude in `[-90, 90]` (south negative, north positive)
    * longitude in `[-180, 180]` (west negative, east positive)

    Each concrete data source converts this to whatever format its
    protocol expects (CDS: `[north, west, south, east]`; CHIRPS:
    per-row clipping; S3: prefix filter; GEE:
    `ee.Geometry.Rectangle(west, south, east, north)`). For
    projected coordinates, define a separate `ProjectedExtent`
    type — do not reuse this one with metric values.

    Attributes:
        latitude_min: Inclusive south edge of the bbox, in degrees.
        latitude_max: Inclusive north edge of the bbox, in degrees.
        longitude_min: Inclusive west edge of the bbox, in degrees.
        longitude_max: Inclusive east edge of the bbox, in degrees.
        resolution: Grid cell size in degrees, applied to both
            latitude and longitude. `None` for backends that work
            on irregular grids or do not need a cell size for their
            request shape (e.g. CHIRPS FTP file lookup, S3 prefix
            listing). Mirrors :attr:`TemporalExtent.resolution` —
            the spatial counterpart of the temporal cadence.
    """

    model_config = ConfigDict(frozen=True)

    latitude_min: float = Field(ge=-90.0, le=90.0, description="South edge in degrees")
    latitude_max: float = Field(ge=-90.0, le=90.0, description="North edge in degrees")
    longitude_min: float = Field(
        ge=-180.0, le=180.0, description="West edge in degrees"
    )
    longitude_max: float = Field(
        ge=-180.0, le=180.0, description="East edge in degrees"
    )
    resolution: float | None = Field(
        default=None, gt=0.0, description="Grid cell size in degrees"
    )
    geometry: Any = Field(
        default=None,
        exclude=True,
        repr=False,
        description=(
            "Optional WGS84 polygon mask (a geopandas `GeoDataFrame`) "
            "captured when the request's area of interest was a polygon "
            "rather than a plain bbox. Raster backends that clip via "
            "`pyramids.Dataset.crop` use it to mask the fetched bbox to "
            "the exact polygon; `None` means clip to the rectangular "
            "bbox only. Excluded from serialisation."
        ),
    )

    @model_validator(mode="after")
    def _check_min_le_max(self) -> SpatialExtent:
        """Validate that `min <= max` on both axes.

        Per-field range constraints (`Field(ge=..., le=...)`) cannot
        express the cross-field invariant.

        Raises:
            ValueError: If either `latitude_min > latitude_max` or
                `longitude_min > longitude_max`.
        """
        if self.latitude_min > self.latitude_max:
            raise ValueError(
                f"latitude_min ({self.latitude_min}) > "
                f"latitude_max ({self.latitude_max})"
            )
        if self.longitude_min > self.longitude_max:
            # A west > east box is how GeoJSON/STAC spell an antimeridian
            # crossing, so say so and name the remedy: only the stac backend
            # splits such a box today (via
            # `pyramids.feature.bbox.split_antimeridian`), and a bare
            # "min > max" reads like a typo rather than an unsupported case.
            raise ValueError(
                f"longitude_min ({self.longitude_min}) > longitude_max "
                f"({self.longitude_max}). A west-of-east box denotes an "
                f"antimeridian crossing, which this backend does not support; "
                f"split it at ±180 and issue the two halves as separate "
                f"requests (e.g. [{self.longitude_min}, 180] and "
                f"[-180, {self.longitude_max}])."
            )
        return self

    #: The scalar fields that define spatial identity. `geometry` is a
    #: heavy, unhashable `GeoDataFrame` whose pandas `==` is non-boolean,
    #: so it is deliberately excluded from equality / hashing (as it is
    #: from serialisation) — two extents over the same bbox are equal and
    #: hashable whether or not one carries a polygon mask.
    _IDENTITY_FIELDS = (
        "latitude_min",
        "latitude_max",
        "longitude_min",
        "longitude_max",
        "resolution",
    )

    def _identity(self) -> tuple[float | None, ...]:
        """Return the bbox-identity tuple used for equality / hashing."""
        return tuple(getattr(self, name) for name in self._IDENTITY_FIELDS)

    def __eq__(self, other: object) -> bool:
        """Compare two extents by bbox + resolution, ignoring `geometry`.

        Overrides pydantic's field-wise equality, which would otherwise
        evaluate `GeoDataFrame == GeoDataFrame` (a non-boolean pandas
        result) and raise for a polygon-`aoi=` extent.

        Args:
            other: The object to compare against.

        Returns:
            `True` when `other` is a `SpatialExtent` with the same bbox
            and resolution; `NotImplemented` for any other type.
        """
        if not isinstance(other, SpatialExtent):
            return NotImplemented
        return self._identity() == other._identity()

    def __hash__(self) -> int:
        """Hash by bbox + resolution, ignoring the unhashable `geometry`."""
        return hash(self._identity())

    @classmethod
    def from_pairs(
        cls,
        lat_lim: list[float],
        lon_lim: list[float],
        resolution: float | None = None,
    ) -> SpatialExtent:
        """Build from the legacy `[min, max]` pair shape.

        :class:`AbstractDataSource.__init__` accepts `lat_lim` /
        `lon_lim` as constructor kwargs in the public API; this
        classmethod adapts that shape to the four named fields.

        Args:
            lat_lim: `[lat_min, lat_max]` in degrees.
            lon_lim: `[lon_min, lon_max]` in degrees.
            resolution: Grid cell size in degrees. Defaults to
                `None` (unspecified — typical for backends that
                work off file listings rather than gridded request
                shapes).

        Returns:
            SpatialExtent: A validated, frozen instance.
        """
        return cls(
            latitude_min=lat_lim[0],
            latitude_max=lat_lim[1],
            longitude_min=lon_lim[0],
            longitude_max=lon_lim[1],
            resolution=resolution,
        )

    @property
    def north(self) -> float:
        """Northern edge of the bbox (== `latitude_max`)."""
        return self.latitude_max

    @property
    def south(self) -> float:
        """Southern edge of the bbox (== `latitude_min`)."""
        return self.latitude_min

    @property
    def east(self) -> float:
        """Eastern edge of the bbox (== `longitude_max`)."""
        return self.longitude_max

    @property
    def west(self) -> float:
        """Western edge of the bbox (== `longitude_min`)."""
        return self.longitude_min

    def estimate_pixel_dims(self, scale_m: float) -> tuple[int, int]:
        """Return `(width_px, height_px)` of this bbox sampled at `scale_m` metres.

        Thin wrapper over :func:`earthlens.base.spatial.estimate_pixel_dims`
        so every backend can pre-flight a request size without reaching
        into another subpackage. Useful e.g. for GEE's 32768-px
        synchronous-export cap or for any "will this download blow up?"
        check before queuing a job.

        Args:
            scale_m: Output pixel size in metres.

        Returns:
            `(width_px, height_px)` — both rounded up, each at least 1.

        Raises:
            ValueError: If `scale_m` is not positive.
        """
        # Local import to keep the existing import order untouched.
        from earthlens.base.spatial import estimate_pixel_dims

        return estimate_pixel_dims(
            self.longitude_min,
            self.latitude_min,
            self.longitude_max,
            self.latitude_max,
            scale_m,
        )

east property #

Eastern edge of the bbox (== longitude_max).

north property #

Northern edge of the bbox (== latitude_max).

south property #

Southern edge of the bbox (== latitude_min).

west property #

Western edge of the bbox (== longitude_min).

__eq__(other) #

Compare two extents by bbox + resolution, ignoring geometry.

Overrides pydantic's field-wise equality, which would otherwise evaluate GeoDataFrame == GeoDataFrame (a non-boolean pandas result) and raise for a polygon-aoi= extent.

Parameters:

Name Type Description Default
other object

The object to compare against.

required

Returns:

Type Description
bool

True when other is a SpatialExtent with the same bbox

bool

and resolution; NotImplemented for any other type.

Source code in libs/core/src/earthlens/base/abstractdatasource.py
def __eq__(self, other: object) -> bool:
    """Compare two extents by bbox + resolution, ignoring `geometry`.

    Overrides pydantic's field-wise equality, which would otherwise
    evaluate `GeoDataFrame == GeoDataFrame` (a non-boolean pandas
    result) and raise for a polygon-`aoi=` extent.

    Args:
        other: The object to compare against.

    Returns:
        `True` when `other` is a `SpatialExtent` with the same bbox
        and resolution; `NotImplemented` for any other type.
    """
    if not isinstance(other, SpatialExtent):
        return NotImplemented
    return self._identity() == other._identity()

__hash__() #

Hash by bbox + resolution, ignoring the unhashable geometry.

Source code in libs/core/src/earthlens/base/abstractdatasource.py
def __hash__(self) -> int:
    """Hash by bbox + resolution, ignoring the unhashable `geometry`."""
    return hash(self._identity())

estimate_pixel_dims(scale_m) #

Return (width_px, height_px) of this bbox sampled at scale_m metres.

Thin wrapper over :func:earthlens.base.spatial.estimate_pixel_dims so every backend can pre-flight a request size without reaching into another subpackage. Useful e.g. for GEE's 32768-px synchronous-export cap or for any "will this download blow up?" check before queuing a job.

Parameters:

Name Type Description Default
scale_m float

Output pixel size in metres.

required

Returns:

Type Description
tuple[int, int]

(width_px, height_px) — both rounded up, each at least 1.

Raises:

Type Description
ValueError

If scale_m is not positive.

Source code in libs/core/src/earthlens/base/abstractdatasource.py
def estimate_pixel_dims(self, scale_m: float) -> tuple[int, int]:
    """Return `(width_px, height_px)` of this bbox sampled at `scale_m` metres.

    Thin wrapper over :func:`earthlens.base.spatial.estimate_pixel_dims`
    so every backend can pre-flight a request size without reaching
    into another subpackage. Useful e.g. for GEE's 32768-px
    synchronous-export cap or for any "will this download blow up?"
    check before queuing a job.

    Args:
        scale_m: Output pixel size in metres.

    Returns:
        `(width_px, height_px)` — both rounded up, each at least 1.

    Raises:
        ValueError: If `scale_m` is not positive.
    """
    # Local import to keep the existing import order untouched.
    from earthlens.base.spatial import estimate_pixel_dims

    return estimate_pixel_dims(
        self.longitude_min,
        self.latitude_min,
        self.longitude_max,
        self.latitude_max,
        scale_m,
    )

from_pairs(lat_lim, lon_lim, resolution=None) classmethod #

Build from the legacy [min, max] pair shape.

:class:AbstractDataSource.__init__ accepts lat_lim / lon_lim as constructor kwargs in the public API; this classmethod adapts that shape to the four named fields.

Parameters:

Name Type Description Default
lat_lim list[float]

[lat_min, lat_max] in degrees.

required
lon_lim list[float]

[lon_min, lon_max] in degrees.

required
resolution float | None

Grid cell size in degrees. Defaults to None (unspecified — typical for backends that work off file listings rather than gridded request shapes).

None

Returns:

Name Type Description
SpatialExtent SpatialExtent

A validated, frozen instance.

Source code in libs/core/src/earthlens/base/abstractdatasource.py
@classmethod
def from_pairs(
    cls,
    lat_lim: list[float],
    lon_lim: list[float],
    resolution: float | None = None,
) -> SpatialExtent:
    """Build from the legacy `[min, max]` pair shape.

    :class:`AbstractDataSource.__init__` accepts `lat_lim` /
    `lon_lim` as constructor kwargs in the public API; this
    classmethod adapts that shape to the four named fields.

    Args:
        lat_lim: `[lat_min, lat_max]` in degrees.
        lon_lim: `[lon_min, lon_max]` in degrees.
        resolution: Grid cell size in degrees. Defaults to
            `None` (unspecified — typical for backends that
            work off file listings rather than gridded request
            shapes).

    Returns:
        SpatialExtent: A validated, frozen instance.
    """
    return cls(
        latitude_min=lat_lim[0],
        latitude_max=lat_lim[1],
        longitude_min=lon_lim[0],
        longitude_max=lon_lim[1],
        resolution=resolution,
    )

TemporalExtent#

earthlens.base.TemporalExtent #

Bases: BaseModel

Per-instance temporal context produced by :meth:check_input_dates.

Replaces the self.time dict that earlier versions of :class:AbstractDataSource accepted from subclass overrides. The frozen pydantic model enforces presence of every consumer-visible field at construction time, so a subclass that returns a malformed container fails fast instead of surfacing as KeyError deep inside the download loop.

Attributes:

Name Type Description
start_date Any

Inclusive start of the requested window. Typed :data:~typing.Any because pandas / numpy timestamp types are not native pydantic primitives; the cross-field validator below enforces start_date <= end_date for anything that supports comparison.

end_date Any

Inclusive end of the requested window.

resolution str

Spacing between consecutive entries in :attr:dates, expressed as a pandas frequency alias — "D" for daily, "MS" for month-start. Same shorthand pandas uses for date_range(freq=...).

dates Any

The :class:pandas.DatetimeIndex the download loop iterates. Typed :data:~typing.Any to avoid a hard pandas import in the abstract module.

Source code in libs/core/src/earthlens/base/abstractdatasource.py
class TemporalExtent(BaseModel):
    """Per-instance temporal context produced by :meth:`check_input_dates`.

    Replaces the `self.time` dict that earlier versions of
    :class:`AbstractDataSource` accepted from subclass overrides. The
    frozen pydantic model enforces presence of every consumer-visible
    field at construction time, so a subclass that returns a malformed
    container fails fast instead of surfacing as `KeyError` deep
    inside the download loop.

    Attributes:
        start_date: Inclusive start of the requested window. Typed
            :data:`~typing.Any` because pandas / numpy timestamp types
            are not native pydantic primitives; the cross-field
            validator below enforces `start_date <= end_date` for
            anything that supports comparison.
        end_date: Inclusive end of the requested window.
        resolution: Spacing between consecutive entries in
            :attr:`dates`, expressed as a pandas frequency alias —
            `"D"` for daily, `"MS"` for month-start. Same
            shorthand pandas uses for `date_range(freq=...)`.
        dates: The :class:`pandas.DatetimeIndex` the download loop
            iterates. Typed :data:`~typing.Any` to avoid a hard
            pandas import in the abstract module.
    """

    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)

    start_date: Any
    end_date: Any
    resolution: str
    dates: Any

    @model_validator(mode="after")
    def _check_start_le_end(self) -> TemporalExtent:
        """Validate that `start_date <= end_date`.

        Raises:
            ValueError: If the window is inverted.
        """
        if self.start_date is not None and self.end_date is not None:
            if self.start_date > self.end_date:
                raise ValueError(
                    f"TemporalExtent has inverted bounds: start_date "
                    f"{self.start_date} > end_date {self.end_date}"
                )
        return self

PolygonAoiWarning#

Raised as a warning when a polygon aoi= is reduced to its bounding box because the chosen backend cannot clip to a polygon. Backends advertise the capability through SUPPORTS_POLYGON_AOI — see Base contracts.

earthlens.base.PolygonAoiWarning #

Bases: UserWarning

A polygon aoi= was reduced to its bounding box by the chosen backend.

Issued when a request passes a real polygon area of interest to a backend whose SUPPORTS_POLYGON_AOI is False. The download still succeeds, but it covers the polygon's bounding box, so cells outside the polygon are included. That is the most dangerous kind of wrong result — a valid raster of the right variable over roughly the right area — so it is surfaced rather than left silent.

A dedicated class (rather than a bare UserWarning) so callers can filter or escalate exactly this case:

Examples:

  • Turn the silent degradation into an error for a strict pipeline:
    >>> import warnings
    >>> from earthlens.base import PolygonAoiWarning
    >>> with warnings.catch_warnings(record=True) as caught:
    ...     warnings.simplefilter("always")
    ...     warnings.warn("bbox only", PolygonAoiWarning)
    >>> caught[0].category.__name__
    'PolygonAoiWarning'
    
  • It is a UserWarning, so existing broad filters still catch it:
    >>> from earthlens.base import PolygonAoiWarning
    >>> issubclass(PolygonAoiWarning, UserWarning)
    True
    
Source code in libs/core/src/earthlens/base/abstractdatasource.py
class PolygonAoiWarning(UserWarning):
    """A polygon `aoi=` was reduced to its bounding box by the chosen backend.

    Issued when a request passes a real polygon area of
    interest to a backend whose `SUPPORTS_POLYGON_AOI` is `False`. The
    download still succeeds, but it covers the polygon's **bounding box**, so
    cells outside the polygon are included. That is the most dangerous kind of
    wrong result — a valid raster of the right variable over roughly the right
    area — so it is surfaced rather than left silent.

    A dedicated class (rather than a bare `UserWarning`) so callers can filter
    or escalate exactly this case:

    Examples:
        - Turn the silent degradation into an error for a strict pipeline:
            ```python
            >>> import warnings
            >>> from earthlens.base import PolygonAoiWarning
            >>> with warnings.catch_warnings(record=True) as caught:
            ...     warnings.simplefilter("always")
            ...     warnings.warn("bbox only", PolygonAoiWarning)
            >>> caught[0].category.__name__
            'PolygonAoiWarning'

            ```
        - It is a `UserWarning`, so existing broad filters still catch it:
            ```python
            >>> from earthlens.base import PolygonAoiWarning
            >>> issubclass(PolygonAoiWarning, UserWarning)
            True

            ```
    """