Skip to content

Commit b4cfcec

Browse files
committed
Merge branch 'issue690-resample-spatial-metadata'
2 parents d4f976c + 284997f commit b4cfcec

File tree

9 files changed

+326
-22
lines changed

9 files changed

+326
-22
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Changed
1313

14+
- Improved tracking of metadata changes with `resample_spatial` and `resample_cube_spatial` ([#690](https://github.com/Open-EO/openeo-python-client/issues/690))
15+
1416
### Removed
1517

1618
### Fixed

openeo/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.38.0a1"
1+
__version__ = "0.38.0a2"

openeo/metadata.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from openeo.internal.jupyter import render_component
1414
from openeo.util import Rfc3339, deep_get
15+
from openeo.utils.normalize import normalize_resample_resolution
1516

1617
_log = logging.getLogger(__name__)
1718

@@ -25,6 +26,8 @@ class DimensionAlreadyExistsException(MetadataException):
2526

2627

2728
# TODO: make these dimension classes immutable data classes
29+
# TODO: align better with STAC datacube extension
30+
# TODO: align/adapt/integrate with pystac's datacube extension implementation?
2831
class Dimension:
2932
"""Base class for dimensions."""
3033

@@ -58,6 +61,8 @@ def rename_labels(self, target, source) -> Dimension:
5861

5962

6063
class SpatialDimension(Dimension):
64+
# TODO: align better with STAC datacube extension: e.g. support "axis" (x or y)
65+
6166
DEFAULT_CRS = 4326
6267

6368
def __init__(
@@ -257,6 +262,10 @@ def __init__(self, dimensions: Optional[List[Dimension]] = None):
257262
def __eq__(self, o: Any) -> bool:
258263
return isinstance(o, type(self)) and self._dimensions == o._dimensions
259264

265+
def __str__(self) -> str:
266+
bands = self.band_names if self.has_band_dimension() else "no bands dimension"
267+
return f"CubeMetadata({bands} - {self.dimension_names()})"
268+
260269
def _clone_and_update(self, dimensions: Optional[List[Dimension]] = None, **kwargs) -> CubeMetadata:
261270
"""Create a new instance (of same class) with copied/updated fields."""
262271
cls = type(self)
@@ -411,10 +420,39 @@ def drop_dimension(self, name: str = None) -> CubeMetadata:
411420
raise ValueError("No dimension named {n!r} (valid names: {ns!r})".format(n=name, ns=dimension_names))
412421
return self._clone_and_update(dimensions=[d for d in self._dimensions if not d.name == name])
413422

414-
def __str__(self) -> str:
415-
bands = self.band_names if self.has_band_dimension() else "no bands dimension"
416-
return f"CubeMetadata({bands} - {self.dimension_names()})"
423+
def resample_spatial(
424+
self,
425+
resolution: Union[float, Tuple[float, float], List[float]] = 0.0,
426+
projection: Union[int, str, None] = None,
427+
) -> CubeMetadata:
428+
resolution = normalize_resample_resolution(resolution)
429+
if self._dimensions is None:
430+
# Best-effort fallback to work with
431+
dimensions = [
432+
SpatialDimension(name="x", extent=[None, None]),
433+
SpatialDimension(name="y", extent=[None, None]),
434+
]
435+
else:
436+
# Make sure to work with a copy (to edit in-place)
437+
dimensions = list(self._dimensions)
438+
439+
# Find and replace spatial dimensions
440+
spatial_indixes = [i for i, d in enumerate(dimensions) if isinstance(d, SpatialDimension)]
441+
if len(spatial_indixes) != 2:
442+
raise MetadataException(f"Expected two spatial resolutions but found {spatial_indixes=}")
443+
for i in spatial_indixes:
444+
dim: SpatialDimension = dimensions[i]
445+
dimensions[i] = SpatialDimension(
446+
name=dim.name,
447+
extent=dim.extent,
448+
crs=projection or dim.crs,
449+
step=resolution[i] if resolution[i] else dim.step,
450+
)
451+
452+
return self._clone_and_update(dimensions=dimensions)
417453

454+
def resample_cube_spatial(self, target: CubeMetadata) -> CubeMetadata:
455+
return self._clone_and_update(dimensions=list(target._dimensions))
418456

419457
class CollectionMetadata(CubeMetadata):
420458
"""

openeo/rest/datacube.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
Band,
4141
BandDimension,
4242
CollectionMetadata,
43+
CubeMetadata,
4344
SpatialDimension,
4445
TemporalDimension,
4546
metadata_from_stac,
@@ -69,6 +70,10 @@
6970
from openeo.udf import XarrayDataCube
7071

7172

73+
# Sentinel value for arguments that are unset (when `None` has a different meaning)
74+
_UNSET = object()
75+
76+
7277
log = logging.getLogger(__name__)
7378

7479

@@ -97,7 +102,7 @@ def process(
97102
self,
98103
process_id: str,
99104
arguments: Optional[dict] = None,
100-
metadata: Optional[CollectionMetadata] = None,
105+
metadata: Optional[CubeMetadata] = _UNSET,
101106
namespace: Optional[str] = None,
102107
**kwargs,
103108
) -> DataCube:
@@ -111,7 +116,11 @@ def process(
111116
:return: new DataCube instance
112117
"""
113118
pg = self._build_pgnode(process_id=process_id, arguments=arguments, namespace=namespace, **kwargs)
114-
return DataCube(graph=pg, connection=self._connection, metadata=metadata or self.metadata)
119+
return DataCube(
120+
graph=pg,
121+
connection=self._connection,
122+
metadata=self.metadata if metadata is _UNSET else metadata,
123+
)
115124

116125
graph_add_node = legacy_alias(process, "graph_add_node", since="0.1.1")
117126

@@ -749,16 +758,24 @@ def band(self, band: Union[str, int]) -> DataCube:
749758

750759
@openeo_process
751760
def resample_spatial(
752-
self, resolution: Union[float, Tuple[float, float]], projection: Union[int, str] = None,
753-
method: str = 'near', align: str = 'upper-left'
761+
self,
762+
resolution: Union[float, Tuple[float, float], List[float]] = 0.0,
763+
projection: Union[int, str, None] = None,
764+
method: str = "near",
765+
align: str = "upper-left",
754766
) -> DataCube:
755-
return self.process('resample_spatial', {
756-
'data': THIS,
757-
'resolution': resolution,
758-
'projection': projection,
759-
'method': method,
760-
'align': align
761-
})
767+
metadata = (self.metadata or CubeMetadata()).resample_spatial(resolution=resolution, projection=projection)
768+
return self.process(
769+
process_id="resample_spatial",
770+
arguments={
771+
"data": THIS,
772+
"resolution": resolution,
773+
"projection": projection,
774+
"method": method,
775+
"align": align,
776+
},
777+
metadata=metadata,
778+
)
762779

763780
def resample_cube_spatial(self, target: DataCube, method: str = "near") -> DataCube:
764781
"""
@@ -773,7 +790,15 @@ def resample_cube_spatial(self, target: DataCube, method: str = "near") -> DataC
773790
:param method: Resampling method to use.
774791
:return:
775792
"""
776-
return self.process("resample_cube_spatial", {"data": self, "target": target, "method": method})
793+
if target.metadata:
794+
metadata = (self.metadata or CubeMetadata()).resample_cube_spatial(target=target.metadata)
795+
else:
796+
metadata = None
797+
return self.process(
798+
process_id="resample_cube_spatial",
799+
arguments={"data": self, "target": target, "method": method},
800+
metadata=metadata,
801+
)
777802

778803
@openeo_process
779804
def resample_cube_temporal(

openeo/utils/__init__.py

Whitespace-only changes.

openeo/utils/normalize.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import Tuple, Union
2+
3+
4+
def normalize_resample_resolution(
5+
resolution: Union[int, float, Tuple[float, float], Tuple[int, int]]
6+
) -> Tuple[Union[int, float], Union[int, float]]:
7+
"""Normalize a resolution value, as used in the `resample_spatial` process to a two-element tuple."""
8+
if isinstance(resolution, (int, float)):
9+
return (resolution, resolution)
10+
elif (
11+
isinstance(resolution, (list, tuple))
12+
and len(resolution) == 2
13+
and all(isinstance(r, (int, float)) for r in resolution)
14+
):
15+
return tuple(resolution)
16+
raise ValueError(f"Invalid resolution {resolution!r}")

tests/rest/datacube/test_datacube.py

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from openeo import collection_property
2222
from openeo.api.process import Parameter
23+
from openeo.metadata import SpatialDimension
2324
from openeo.rest import BandMathException, OpenEoClientException
2425
from openeo.rest._testing import build_capabilities
2526
from openeo.rest.connection import Connection
@@ -730,12 +731,144 @@ def test_apply_kernel(s2cube):
730731

731732

732733
def test_resample_spatial(s2cube):
733-
im = s2cube.resample_spatial(resolution=[2.0, 3.0], projection=4578)
734-
graph = _get_leaf_node(im)
735-
assert graph["process_id"] == "resample_spatial"
736-
assert "data" in graph["arguments"]
737-
assert graph["arguments"]["resolution"] == [2.0, 3.0]
738-
assert graph["arguments"]["projection"] == 4578
734+
cube = s2cube.resample_spatial(resolution=[2.0, 3.0], projection=4578)
735+
assert get_download_graph(cube, drop_load_collection=True, drop_save_result=True) == {
736+
"resamplespatial1": {
737+
"process_id": "resample_spatial",
738+
"arguments": {
739+
"data": {"from_node": "loadcollection1"},
740+
"resolution": [2.0, 3.0],
741+
"projection": 4578,
742+
"method": "near",
743+
"align": "upper-left",
744+
},
745+
}
746+
}
747+
748+
assert cube.metadata.spatial_dimensions == [
749+
SpatialDimension(name="x", extent=None, crs=4578, step=2.0),
750+
SpatialDimension(name="y", extent=None, crs=4578, step=3.0),
751+
]
752+
753+
754+
def test_resample_spatial_no_metadata(s2cube_without_metadata):
755+
cube = s2cube_without_metadata.resample_spatial(resolution=(3, 5), projection=4578)
756+
assert get_download_graph(cube, drop_load_collection=True, drop_save_result=True) == {
757+
"resamplespatial1": {
758+
"process_id": "resample_spatial",
759+
"arguments": {
760+
"data": {"from_node": "loadcollection1"},
761+
"resolution": [3, 5],
762+
"projection": 4578,
763+
"method": "near",
764+
"align": "upper-left",
765+
},
766+
}
767+
}
768+
assert cube.metadata.spatial_dimensions == [
769+
SpatialDimension(name="x", extent=[None, None], crs=4578, step=3.0),
770+
SpatialDimension(name="y", extent=[None, None], crs=4578, step=5.0),
771+
]
772+
773+
774+
def test_resample_cube_spatial(s2cube):
775+
cube1 = s2cube.resample_spatial(resolution=[2.0, 3.0], projection=4578)
776+
cube2 = s2cube.resample_spatial(resolution=10, projection=32631)
777+
778+
cube12 = cube1.resample_cube_spatial(target=cube2)
779+
assert get_download_graph(cube12, drop_load_collection=True, drop_save_result=True) == {
780+
"resamplespatial1": {
781+
"process_id": "resample_spatial",
782+
"arguments": {
783+
"align": "upper-left",
784+
"data": {"from_node": "loadcollection1"},
785+
"method": "near",
786+
"projection": 4578,
787+
"resolution": [2.0, 3.0],
788+
},
789+
},
790+
"resamplespatial2": {
791+
"process_id": "resample_spatial",
792+
"arguments": {
793+
"align": "upper-left",
794+
"data": {"from_node": "loadcollection1"},
795+
"method": "near",
796+
"projection": 32631,
797+
"resolution": 10,
798+
},
799+
},
800+
"resamplecubespatial1": {
801+
"arguments": {
802+
"data": {"from_node": "resamplespatial1"},
803+
"method": "near",
804+
"target": {"from_node": "resamplespatial2"},
805+
},
806+
"process_id": "resample_cube_spatial",
807+
},
808+
}
809+
assert cube12.metadata.spatial_dimensions == [
810+
SpatialDimension(name="x", extent=None, crs=32631, step=10),
811+
SpatialDimension(name="y", extent=None, crs=32631, step=10),
812+
]
813+
814+
cube21 = cube2.resample_cube_spatial(target=cube1)
815+
assert get_download_graph(cube21, drop_load_collection=True, drop_save_result=True) == {
816+
"resamplespatial1": {
817+
"process_id": "resample_spatial",
818+
"arguments": {
819+
"align": "upper-left",
820+
"data": {"from_node": "loadcollection1"},
821+
"method": "near",
822+
"projection": 32631,
823+
"resolution": 10,
824+
},
825+
},
826+
"resamplespatial2": {
827+
"process_id": "resample_spatial",
828+
"arguments": {
829+
"align": "upper-left",
830+
"data": {"from_node": "loadcollection1"},
831+
"method": "near",
832+
"projection": 4578,
833+
"resolution": [2.0, 3.0],
834+
},
835+
},
836+
"resamplecubespatial1": {
837+
"arguments": {
838+
"data": {"from_node": "resamplespatial1"},
839+
"method": "near",
840+
"target": {"from_node": "resamplespatial2"},
841+
},
842+
"process_id": "resample_cube_spatial",
843+
},
844+
}
845+
assert cube21.metadata.spatial_dimensions == [
846+
SpatialDimension(name="x", extent=None, crs=4578, step=2.0),
847+
SpatialDimension(name="y", extent=None, crs=4578, step=3.0),
848+
]
849+
850+
851+
def test_resample_cube_spatial_no_source_metadata(s2cube, s2cube_without_metadata):
852+
cube = s2cube_without_metadata
853+
target = s2cube.resample_spatial(resolution=10, projection=32631)
854+
assert cube.metadata is None
855+
assert target.metadata is not None
856+
857+
result = cube.resample_cube_spatial(target=target)
858+
assert result.metadata.spatial_dimensions == [
859+
SpatialDimension(name="x", extent=None, crs=32631, step=10),
860+
SpatialDimension(name="y", extent=None, crs=32631, step=10),
861+
]
862+
863+
864+
def test_resample_cube_spatial_no_target_metadata(s2cube, s2cube_without_metadata):
865+
cube = s2cube.resample_spatial(resolution=10, projection=32631)
866+
target = s2cube_without_metadata
867+
assert cube.metadata is not None
868+
assert target.metadata is None
869+
870+
result = cube.resample_cube_spatial(target=target)
871+
assert result.metadata is None
739872

740873

741874
def test_merge(s2cube, api_version, test_data):

0 commit comments

Comments
 (0)