diff --git a/CHANGELOG.md b/CHANGELOG.md index acf61ac6b..060bf98e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed ### Fixed - +- `DataCube.resample_spatial()` now supports parameterized `resolution` and `projection` arguments. ([#897](https://github.com/Open-EO/openeo-python-client/issues/897)) ## [0.49.0] - 2026-04-01 diff --git a/openeo/metadata.py b/openeo/metadata.py index 49edef428..1f7fe7d01 100644 --- a/openeo/metadata.py +++ b/openeo/metadata.py @@ -19,6 +19,7 @@ import pystac.extensions.eo import pystac.extensions.item_assets +from openeo.api.process import Parameter from openeo.internal.jupyter import render_component from openeo.util import Rfc3339, deep_get from openeo.utils.normalize import normalize_resample_resolution, unique @@ -494,10 +495,14 @@ def drop_dimension(self, name: str = None) -> CubeMetadata: def resample_spatial( self, - resolution: Union[float, Tuple[float, float], List[float]] = 0.0, - projection: Union[int, str, None] = None, + resolution: Union[float, Tuple[float, float], List[float], Parameter] = 0.0, + projection: Union[int, str, Parameter, None] = None, ) -> CubeMetadata: - resolution = normalize_resample_resolution(resolution) + if isinstance(resolution, Parameter): + normalized_resolution = None, None + else: + normalized_resolution = normalize_resample_resolution(resolution) + if self._dimensions is None: # Best-effort fallback to work with dimensions = [ @@ -512,13 +517,14 @@ def resample_spatial( spatial_indices = [i for i, d in enumerate(dimensions) if isinstance(d, SpatialDimension)] if len(spatial_indices) != 2: raise MetadataException(f"Expected two spatial dimensions but found {spatial_indices=}") - assert len(resolution) == 2 - for i, r in zip(spatial_indices, resolution): + + assert len(normalized_resolution) == 2 + for i, r in zip(spatial_indices, normalized_resolution): dim: SpatialDimension = dimensions[i] dimensions[i] = SpatialDimension( name=dim.name, extent=dim.extent, - crs=projection or dim.crs, + crs=None if isinstance(projection, Parameter) else projection or dim.crs, step=r if r != 0.0 else dim.step, ) diff --git a/openeo/rest/datacube.py b/openeo/rest/datacube.py index 73ac8ae9e..8b113a16f 100644 --- a/openeo/rest/datacube.py +++ b/openeo/rest/datacube.py @@ -809,8 +809,8 @@ def band(self, band: Union[str, int]) -> DataCube: @openeo_process def resample_spatial( self, - resolution: Union[float, Tuple[float, float], List[float]] = 0.0, - projection: Union[int, str, None] = None, + resolution: Union[float, Tuple[float, float], List[float], Parameter] = 0.0, + projection: Union[int, str, Parameter, None] = None, method: str = "near", align: str = "upper-left", ) -> DataCube: diff --git a/tests/rest/datacube/test_datacube.py b/tests/rest/datacube/test_datacube.py index 4389d52ce..d6da103fd 100644 --- a/tests/rest/datacube/test_datacube.py +++ b/tests/rest/datacube/test_datacube.py @@ -873,6 +873,91 @@ def test_resample_spatial_no_metadata(s2cube_without_metadata): ] +def test_resample_spatial_parameter_resolution(s2cube): + """A Parameter object passed as resolution must not crash and must appear in the process graph.""" + param = Parameter.number("res", description="The spatial resolution.") + cube = s2cube.resample_spatial(resolution=param, projection=32631) + assert get_download_graph(cube, drop_load_collection=True, drop_save_result=True) == { + "resamplespatial1": { + "process_id": "resample_spatial", + "arguments": { + "data": {"from_node": "loadcollection1"}, + "resolution": {"from_parameter": "res"}, + "projection": 32631, + "method": "near", + "align": "upper-left", + }, + } + } + assert cube.metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=None, crs=32631, step=None), + SpatialDimension(name="y", extent=None, crs=32631, step=None), + ] + + +def test_resample_spatial_parameter_resolution_no_projection(s2cube): + """A Parameter resolution with no concrete projection leaves step and crs unchanged.""" + param = Parameter.number("res", description="The spatial resolution.") + cube = s2cube.resample_spatial(resolution=param) + assert get_download_graph(cube, drop_load_collection=True, drop_save_result=True) == { + "resamplespatial1": { + "process_id": "resample_spatial", + "arguments": { + "data": {"from_node": "loadcollection1"}, + "resolution": {"from_parameter": "res"}, + "projection": None, + "method": "near", + "align": "upper-left", + }, + } + } + + +def test_resample_spatial_parameter_projection(s2cube): + """A Parameter object passed as projection must appear in the process graph; metadata crs should be None.""" + proj_param = Parameter.integer("proj", description="The target projection.") + cube = s2cube.resample_spatial(resolution=10, projection=proj_param) + assert get_download_graph(cube, drop_load_collection=True, drop_save_result=True) == { + "resamplespatial1": { + "process_id": "resample_spatial", + "arguments": { + "data": {"from_node": "loadcollection1"}, + "resolution": 10, + "projection": {"from_parameter": "proj"}, + "method": "near", + "align": "upper-left", + }, + } + } + assert cube.metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=None, crs=None, step=10), + SpatialDimension(name="y", extent=None, crs=None, step=10), + ] + + +def test_resample_spatial_parameter_resolution_and_projection(s2cube): + """When both resolution and projection are Parameters, step and crs should both be None.""" + res_param = Parameter.number("res", description="The spatial resolution.") + proj_param = Parameter.integer("proj", description="The target projection.") + cube = s2cube.resample_spatial(resolution=res_param, projection=proj_param) + assert get_download_graph(cube, drop_load_collection=True, drop_save_result=True) == { + "resamplespatial1": { + "process_id": "resample_spatial", + "arguments": { + "data": {"from_node": "loadcollection1"}, + "resolution": {"from_parameter": "res"}, + "projection": {"from_parameter": "proj"}, + "method": "near", + "align": "upper-left", + }, + } + } + assert cube.metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=None, crs=None, step=None), + SpatialDimension(name="y", extent=None, crs=None, step=None), + ] + + def test_resample_cube_spatial(s2cube): cube1 = s2cube.resample_spatial(resolution=[2.0, 3.0], projection=4578) cube2 = s2cube.resample_spatial(resolution=10, projection=32631) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index ef8fbf65e..c536c061d 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -8,6 +8,7 @@ import pystac import pytest +from openeo.api.process import Parameter from openeo.metadata import ( _PYSTAC_1_9_EXTENSION_INTERFACE, Band, @@ -1279,6 +1280,64 @@ def test_metadata_resample_spatial(cube_metadata, kwargs, expected_x, expected_y assert metadata.band_dimension == cube_metadata.band_dimension +@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) +def test_metadata_resample_spatial_parameter_resolution_only(cube_metadata): + """When resolution is a Parameter, step should remain unchanged and crs should stay as-is.""" + param = Parameter.number("res", description="The spatial resolution.") + metadata = cube_metadata.resample_spatial(resolution=param) + assert isinstance(metadata, CubeMetadata) + # step must be set to None after parametrized resample_spatial. + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=4326, step=None), + SpatialDimension(name="y", extent=[49, 52], crs=4326, step=None), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + +@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) +def test_metadata_resample_spatial_parameter_resolution_with_projection(cube_metadata): + """When resolution is a Parameter but projection is concrete, crs should be updated.""" + param = Parameter.number("res", description="The spatial resolution.") + metadata = cube_metadata.resample_spatial(resolution=param, projection=32631) + assert isinstance(metadata, CubeMetadata) + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=32631, step=None), + SpatialDimension(name="y", extent=[49, 52], crs=32631, step=None), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + +@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) +def test_metadata_resample_spatial_parameter_projection_only(cube_metadata): + """When projection is a Parameter, crs should be set to None.""" + param = Parameter.integer("proj", description="The target projection.") + metadata = cube_metadata.resample_spatial(resolution=10, projection=param) + assert isinstance(metadata, CubeMetadata) + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=None, step=10), + SpatialDimension(name="y", extent=[49, 52], crs=None, step=10), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + +@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) +def test_metadata_resample_spatial_parameter_resolution_and_projection(cube_metadata): + """When both resolution and projection are Parameters, step and crs should both be None.""" + res_param = Parameter.number("res", description="The spatial resolution.") + proj_param = Parameter.integer("proj", description="The target projection.") + metadata = cube_metadata.resample_spatial(resolution=res_param, projection=proj_param) + assert isinstance(metadata, CubeMetadata) + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=None, step=None), + SpatialDimension(name="y", extent=[49, 52], crs=None, step=None), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + @pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) def test_metadata_resample_cube_spatial(cube_metadata): metadata1 = cube_metadata.resample_spatial(resolution=(11, 22), projection=32631)