Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ To align with CF conventions (`[time, y, x]`) and reduce the need for transposes
Your AOI may fall outside the dataset extent or the CRS mismatch caused an unexpected reprojection. Try matching source grid first to confirm availability.

## Do I need shapely geometries?
Helpers accept shapely for convenience. If you already have an EE geometry, you can convert it to shapely with `shapely.geometry.shape(ee_geom.getInfo())`. Shapely makes reprojection and area reasoning simpler client-side.
Helpers accept shapely for convenience, as it makes reprojection and area reasoning simpler client-side. However, `fit_geometry` also accepts an Earth Engine geometry (e.g. `ee.Geometry`) directly and converts it for you via `shapely.geometry.shape(ee_geom.getInfo())`. That said, if you do need a shapely geometry elsewhere, you may want to handle this conversion explicitly.

## `ds.to_netcdf()` fails with `ValueError: could not safely cast array from int64 to int32`
Xee time coordinates are stored as `int64` (nanoseconds since epoch). The `scipy` netCDF writer only supports netCDF3, which is limited to `int32`, so the write fails when `scipy` is the only available backend.
Expand Down
34 changes: 34 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ grid_params = helpers.fit_geometry(
ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid_params)
```

## Using an Earth Engine Geometry as the AOI

`fit_geometry` accepts an `ee.Geometry` directly. It is auto-converted to shapely via `shapely.geometry.shape(geometry.getInfo())`, so you can stay in an Earth Engine-native workflow without converting by hand.

```python
import ee
import xarray as xr
from xee import helpers

aoi_ee = ee.Geometry.Rectangle([113.33, -43.63, 153.56, -10.66])
grid_params = helpers.fit_geometry(
geometry=aoi_ee,
grid_crs='EPSG:4326',
grid_shape=(256, 256),
)

ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid_params)
```

If you prefer to convert explicitly or need the shapely geometry elsewhere, the equivalent end-to-end conversion is as follows:

```python
import shapely

aoi_shapely = shapely.geometry.shape(aoi_ee.getInfo())
grid_params = helpers.fit_geometry(
geometry=aoi_shapely,
grid_crs='EPSG:4326',
grid_shape=(256, 256),
)
```

Non-geometry inputs (or geometries that can't be converted) raise a `TypeError` that includes the above conversion snippet.

## Custom Region at Source Resolution

Fit an AOI but keep original pixel size.
Expand Down
6 changes: 6 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ grid = helpers.fit_geometry(
ds = xr.open_dataset('ee://ECMWF/ERA5_LAND/MONTHLY_AGGR', engine='ee', **grid)
```

```{admonition} Already have an Earth Engine geometry?
:class: tip

`helpers.fit_geometry` accepts an `ee.Geometry` directly. See the [User Guide](guide.md) for an end-to-end example.
```

## 7. Having trouble?

See the [FAQ](faq.md) and open a [discussion](https://github.com/google/Xee/discussions) if needed.
58 changes: 58 additions & 0 deletions xee/ext_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,64 @@ def test_fit_geometry_with_rounding(self):
self.assertAlmostEqual(grid_dict['crs_transform'][0], 0.1)
self.assertAlmostEqual(grid_dict['crs_transform'][4], -0.1)

def test_fit_geometry_accepts_ee_like_geometry(self):
"""Test that an Earth Engine-like geometry is auto-converted via getInfo()."""

class _FakeEEGeometry:
"""Minimal ee.Geometry stand-in exposing getInfo()."""

def __init__(self, geojson):
self._geojson = geojson

def getInfo(self):
return self._geojson

ee_like_geometry = _FakeEEGeometry(
{
'type': 'Polygon',
'coordinates': [[[10.1, 10.1], [10.1, 10.9], [11.9, 10.1]]],
}
)

grid_dict = helpers.fit_geometry(
geometry=ee_like_geometry,
grid_crs='EPSG:4326',
grid_scale=(0.5, -0.5),
)

self.assertEqual(
grid_dict['crs_transform'], (0.5, 0.0, 10.0, 0.0, -0.5, 11.0)
)
self.assertEqual(grid_dict['shape_2d'], (4, 2))

def test_fit_geometry_ee_like_invalid_payload_raises(self):
"""Test that an Earth Engine-like geometry with an invalid payload raises TypeError."""

class _FakeEEGeometry:

def getInfo(self):
return {'type': 'not a geojson geometry'}

with self.assertRaisesRegex(
TypeError, r'shapely\.geometry\.shape\(ee_geom\.getInfo\(\)\)'
):
helpers.fit_geometry(
geometry=_FakeEEGeometry(),
grid_crs='EPSG:4326',
grid_scale=(0.5, -0.5),
)

def test_fit_geometry_unsupported_type_raises(self):
"""Test that an unrelated input type raises a clear TypeError with guidance."""
with self.assertRaisesRegex(
TypeError, r'shapely\.geometry\.shape\(ee_geom\.getInfo\(\)\)'
):
helpers.fit_geometry(
geometry=42,
grid_crs='EPSG:4326',
grid_scale=(0.5, -0.5),
)


if __name__ == '__main__':
absltest.main()
59 changes: 57 additions & 2 deletions xee/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,61 @@ def set_scale(
affine_transform = affine.Affine(*crs_transform)
return list(affine_transform)[:6]

def _coerce_to_shapely_geometry(
geometry: Union[shapely.geometry.base.BaseGeometry, ee.Geometry],
) -> shapely.geometry.base.BaseGeometry:
"""Normalize a supported geometry input to a shapely geometry.

Shapely geometries are returned unchanged. Earth Engine-like geometries are
automatically detected and converted. Any other input raises a ``TypeError``
that names the expected type and includes the explicit conversion snippet.

Args:
geometry: A shapely geometry or an Earth Engine-like geometry exposing
``getInfo``.

Returns:
An equivalent shapely geometry.

Raises:
TypeError: If ``geometry`` is neither a shapely geometry nor convertible
from an Earth Engine-like geometry.
"""
if isinstance(geometry, shapely.geometry.base.BaseGeometry):
return geometry

get_info = getattr(geometry, "getInfo", None)

if callable(get_info):
# NOTE(abi): ``getInfo`` runs outside the try clock so that genuine EE
# runtime errors propagate unchanged.
geojson = get_info()

try:
return shapely.geometry.shape(geojson)
except (
AttributeError,
KeyError,
TypeError,
ValueError,
shapely.errors.GeometryTypeError,
) as e:
raise TypeError(
"Could not convert the Earth Engine-like geometry to a shapely "
"geometry. Convert it explicitly before calling fit_geometry:\n"
" shapely.geometry.shape(ee_geom.getInfo())"
) from e

raise TypeError(
"fit_geometry expected a shapely geometry, but got "
f"{type(geometry).__name__!r}. If this is an Earth Engine geometry, "
"convert it with:\n"
" shapely.geometry.shape(ee_geom.getInfo())"
)


def fit_geometry(
geometry: shapely.geometry.base.BaseGeometry,
geometry: Union[shapely.geometry.base.BaseGeometry, ee.Geometry],
# All following parameters are keyword-only.
*,
geometry_crs: str = 'EPSG:4326',
Expand All @@ -119,7 +171,8 @@ def fit_geometry(

Args:
geometry: Shapely geometry defining the area of interest (in
``geometry_crs`` units).
``geometry_crs`` units). An Earth Engine-like geometry exposing
``getInfo`` is also accepted and converted automatically.
geometry_crs: CRS of the input geometry (default WGS84).
buffer: Optional positive distance in CRS units to expand the geometry.
grid_crs: Target CRS for the output grid.
Expand All @@ -142,6 +195,8 @@ def fit_geometry(
"Exactly one of 'grid_scale' or 'grid_shape' must be specified."
)

geometry = _coerce_to_shapely_geometry(geometry)

transformer = Transformer.from_crs(
crs_from=geometry_crs, crs_to=grid_crs, always_xy=True
)
Expand Down
Loading