Skip to content

Commit 1cb1141

Browse files
committed
Basic handling of filter_bbox in CubeMetadata, down to SpatialDimension
refs #827, #949
1 parent e2cc888 commit 1cb1141

6 files changed

Lines changed: 437 additions & 54 deletions

File tree

‎openeo/metadata/__init__.py‎

Lines changed: 98 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222

2323
from openeo.api.process import Parameter
2424
from openeo.internal.jupyter import render_component
25-
from openeo.metadata._extents import _EmptyExtent, _NonConcreteExtent
26-
from openeo.util import Rfc3339, deep_get
25+
from openeo.metadata._extents import NumericalInterval, _EmptyExtent, _NonConcreteExtent
26+
from openeo.util import BBoxDict, Rfc3339, deep_get
2727
from openeo.utils.datetime import DateTimeInterval
2828
from openeo.utils.normalize import normalize_resample_resolution, unique
2929

@@ -88,15 +88,17 @@ def __init__(
8888
*,
8989
# TODO: axis should be required (without None as default), but currently optional (with fallback guess) for backwards compatibility.
9090
axis: Literal["x", "y"] | None = None,
91-
extent: Union[Tuple[float, float], List[float]],
91+
extent: Tuple[float, float] | List[float] | NumericalInterval | _NonConcreteExtent | _EmptyExtent,
9292
crs: Union[str, int, dict] = DEFAULT_CRS,
9393
step=None,
9494
):
9595
"""
9696
@param name:
9797
@param axis: Axis of the spatial raster dimension ("x" or "y")
9898
@param extent:
99-
@param crs:
99+
@param crs: The spatial reference system for the data and datacube metadata (extent, values and step),
100+
specified as numerical EPSG code, WKT2 (ISO 19162) string or PROJJSON object.
101+
Defaults to EPSG code 4326.
100102
@param step: The space between the values. Use null for irregularly spaced steps.
101103
"""
102104
super().__init__(type="spatial", name=name)
@@ -110,6 +112,64 @@ def __init__(
110112
def rename(self, name) -> Dimension:
111113
return SpatialDimension(name=name, axis=self.axis, extent=self.extent, crs=self.crs, step=self.step)
112114

115+
def filter_extent(
116+
self,
117+
extent: (
118+
Tuple[float | int, float | int]
119+
| List[float | int]
120+
| NumericalInterval
121+
| _EmptyExtent
122+
| _NonConcreteExtent
123+
| None
124+
),
125+
):
126+
if (this_extent := NumericalInterval.try_from(self.extent)) and (
127+
other_extent := NumericalInterval.try_from(extent)
128+
):
129+
extent = this_extent.intersection(other_extent)
130+
elif isinstance(self.extent, _EmptyExtent) or isinstance(extent, _EmptyExtent):
131+
extent = _EmptyExtent()
132+
elif extent is None:
133+
extent = self.extent
134+
else:
135+
# As lenient fallback, handle as non-concrete extent
136+
extent = _NonConcreteExtent()
137+
return SpatialDimension(name=self.name, axis=self.axis, extent=extent, crs=self.crs, step=self.step)
138+
139+
140+
class SpatialDimensionsXY(NamedTuple):
141+
"""
142+
Simple container class for a pair of spatial dimensions (x and y).
143+
"""
144+
145+
x: SpatialDimension
146+
y: SpatialDimension
147+
148+
def filter_bbox(self, extent: Any) -> SpatialDimensionsXY:
149+
# TODO: support more types and forms of the extent (geometries, etc.) ... e.g. using the BBoxDict factories?
150+
if isinstance(extent, dict) and {"west", "east", "south", "north"}.issubset(extent):
151+
bbox = BBoxDict.from_dict(extent)
152+
bbox_crs = bbox.get("crs", SpatialDimension.DEFAULT_CRS)
153+
if bbox_crs == self.x.crs and bbox_crs == self.y.crs:
154+
# No reprojection needed
155+
extent_x = bbox["west"], bbox["east"]
156+
extent_y = bbox["south"], bbox["north"]
157+
else:
158+
# TODO: reproject
159+
extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent()
160+
elif isinstance(extent, _EmptyExtent):
161+
extent_x, extent_y = _EmptyExtent(), _EmptyExtent()
162+
elif isinstance(extent, _NonConcreteExtent):
163+
extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent()
164+
else:
165+
# As lenient fallback, handle as non-concrete extent
166+
extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent()
167+
168+
return SpatialDimensionsXY(
169+
x=self.x.filter_extent(extent=extent_x),
170+
y=self.y.filter_extent(extent=extent_y),
171+
)
172+
113173

114174
class TemporalDimension(Dimension):
115175
def __init__(
@@ -330,23 +390,24 @@ def __init__(self, dimensions: Optional[List[Dimension]] = None):
330390
# but most usage actually assumes it is a list that can be iterated over.
331391
# Can we handle this more consistently and less error-prone?
332392
self._dimensions: Union[List[Dimension], None] = dimensions
333-
self._band_dimension = None
334-
self._temporal_dimension = None
393+
self._band_dimension: BandDimension | None = None
394+
self._temporal_dimension: TemporalDimension | None = None
395+
self._spatial_dimensions: SpatialDimensionsXY | None = None
335396

336397
if dimensions is not None:
337-
for dim in self._dimensions:
338-
# TODO: here we blindly pick last bands or temporal dimension if multiple. Let user choose?
339-
# TODO: add spatial dimension handling?
340-
if dim.type == "bands":
341-
if isinstance(dim, BandDimension):
342-
self._band_dimension = dim
343-
else:
344-
raise MetadataException("Invalid band dimension {d!r}".format(d=dim))
345-
if dim.type == "temporal":
346-
if isinstance(dim, TemporalDimension):
347-
self._temporal_dimension = dim
348-
else:
349-
raise MetadataException("Invalid temporal dimension {d!r}".format(d=dim))
398+
if band_dims := [d for d in dimensions if isinstance(d, BandDimension)]:
399+
if len(band_dims) > 1:
400+
_log.warning(f"Multiple band dimensions, arbitrarily picking the first one: {band_dims=}")
401+
self._band_dimension = band_dims[0]
402+
if temporal_dims := [d for d in dimensions if isinstance(d, TemporalDimension)]:
403+
if len(temporal_dims) > 1:
404+
_log.warning(f"Multiple temporal dimensions, arbitrarily picking the first one: {temporal_dims=}")
405+
self._temporal_dimension = temporal_dims[0]
406+
if spatial_dims := sorted((d for d in dimensions if isinstance(d, SpatialDimension)), key=lambda d: d.axis):
407+
if [d.axis for d in spatial_dims] == ["x", "y"]:
408+
self._spatial_dimensions = SpatialDimensionsXY(x=spatial_dims[0], y=spatial_dims[1])
409+
else:
410+
_log.warning(f"Failed to detect x and y spatial dimensions: {spatial_dims=}")
350411

351412
def __eq__(self, o: Any) -> bool:
352413
return isinstance(o, type(self)) and self._dimensions == o._dimensions
@@ -411,9 +472,14 @@ def temporal_dimension(self) -> TemporalDimension:
411472
raise MetadataException("No temporal dimension")
412473
return self._temporal_dimension
413474

475+
def has_spatial_dimensions(self) -> bool:
476+
return isinstance(self._spatial_dimensions, SpatialDimensionsXY)
477+
414478
@property
415-
def spatial_dimensions(self) -> List[SpatialDimension]:
416-
return [d for d in self._dimensions if isinstance(d, SpatialDimension)]
479+
def spatial_dimensions(self) -> SpatialDimensionsXY:
480+
if not self.has_spatial_dimensions():
481+
raise MetadataException("No spatial dimensions")
482+
return self._spatial_dimensions
417483

418484
def has_geometry_dimension(self):
419485
return any(isinstance(d, GeometryDimension) for d in self._dimensions)
@@ -453,6 +519,17 @@ def filter_temporal(self, extent: Tuple[str | None, str | None] | List[str | Non
453519
]
454520
)
455521

522+
def filter_bbox(self, extent: Any) -> CubeMetadata:
523+
if self._dimensions and isinstance(self._spatial_dimensions, SpatialDimensionsXY):
524+
spatial_dimensions: SpatialDimensionsXY = self._spatial_dimensions.filter_bbox(extent=extent)
525+
dimensions = [
526+
getattr(spatial_dimensions, d.axis) if isinstance(d, SpatialDimension) else d for d in self._dimensions
527+
]
528+
return self._clone_and_update(dimensions=dimensions)
529+
530+
_log.warning("filter_bbox on cube metadata without spatial dimension. Returning unchanged metadata.")
531+
return self
532+
456533
def filter_bands(self, band_names: List[Union[int, str]]) -> CubeMetadata:
457534
"""
458535
Create new `CubeMetadata` with filtered band dimension

‎openeo/metadata/_extents.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import annotations
22

3+
import dataclasses
4+
import typing
5+
36

47
class _NonConcreteExtent:
58
"""
@@ -41,3 +44,44 @@ def __eq__(self, other) -> bool:
4144
if isinstance(other, _EmptyExtent):
4245
return True
4346
return NotImplemented
47+
48+
49+
class NumericalInterval(typing.NamedTuple):
50+
"""
51+
Simple numerical interval defined by a start and end point
52+
each of type float/int;
53+
or None to indicate being open-ended in that direction (no limit).
54+
"""
55+
56+
start: float | int | None
57+
end: float | int | None
58+
59+
@classmethod
60+
def try_from(cls, x: typing.Any) -> NumericalInterval | None:
61+
if isinstance(x, (list, tuple)) and len(x) == 2 and all(isinstance(v, (int, float)) or v is None for v in x):
62+
return NumericalInterval(*x)
63+
return None
64+
65+
def intersection(self, other: NumericalInterval) -> NumericalInterval | _EmptyExtent:
66+
"""
67+
Compute the intersection of this interval with another interval.
68+
If the intervals do not overlap, return an _EmptyExtent.
69+
"""
70+
if self.start is None:
71+
start = other.start
72+
elif other.start is None:
73+
start = self.start
74+
else:
75+
start = max(self.start, other.start)
76+
77+
if self.end is None:
78+
end = other.end
79+
elif other.end is None:
80+
end = self.end
81+
else:
82+
end = min(self.end, other.end)
83+
84+
if start is not None and end is not None and end < start:
85+
return _EmptyExtent()
86+
87+
return NumericalInterval(start, end)

‎openeo/rest/datacube.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ def load_collection(
256256
if (connection and fetch_metadata)
257257
else None
258258
)
259+
if metadata and spatial_extent:
260+
metadata = metadata.filter_bbox(extent=spatial_extent)
259261
if metadata and temporal_extent:
260262
metadata = metadata.filter_temporal(extent=temporal_extent)
261263
if bands is not None:
@@ -455,7 +457,7 @@ def load_stac(
455457
"""
456458
arguments: dict = {"url": url}
457459
if spatial_extent:
458-
arguments["spatial_extent"] = _get_geometry_argument(
460+
spatial_extent = _get_geometry_argument(
459461
argument=spatial_extent,
460462
valid_geojson_types=["Polygon", "MultiPolygon", "Feature", "FeatureCollection"],
461463
connection=connection,
@@ -465,6 +467,7 @@ def load_stac(
465467
argument_name="spatial_extent",
466468
process_id="load_stac",
467469
)
470+
arguments["spatial_extent"] = spatial_extent
468471

469472
if temporal_extent:
470473
temporal_extent = cls._get_temporal_extent(extent=temporal_extent)
@@ -510,6 +513,8 @@ def load_stac(
510513
)
511514
metadata = None
512515

516+
if metadata and spatial_extent:
517+
metadata = metadata.filter_bbox(extent=spatial_extent)
513518
if metadata and temporal_extent:
514519
metadata = metadata.filter_temporal(extent=temporal_extent)
515520

@@ -713,12 +718,14 @@ def filter_bbox(
713718
extent = {'west': west, 'east': east, 'north': north, 'south': south}
714719
extent.update(dict_no_none(crs=crs, base=base, height=height))
715720

721+
metadata = self.metadata.filter_bbox(extent=extent) if self.metadata else None
716722
return self.process(
717-
process_id='filter_bbox',
723+
process_id="filter_bbox",
718724
arguments={
719-
'data': THIS,
720-
'extent': extent
721-
}
725+
"data": THIS,
726+
"extent": extent,
727+
},
728+
metadata=metadata,
722729
)
723730

724731
@openeo_process
@@ -774,12 +781,14 @@ def filter_spatial(
774781
"FeatureCollection",
775782
]
776783
geometries = _get_geometry_argument(geometries, valid_geojson_types=valid_geojson_types, connection=self.connection, crs=None)
784+
metadata = self.metadata.filter_bbox(extent=geometries) if self.metadata else None
777785
return self.process(
778-
process_id='filter_spatial',
786+
process_id="filter_spatial",
779787
arguments={
780-
'data': THIS,
781-
'geometries': geometries
782-
}
788+
"data": THIS,
789+
"geometries": geometries,
790+
},
791+
metadata=metadata,
783792
)
784793

785794
@openeo_process

‎openeo/util.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,15 @@ class BBoxDict(dict):
535535
.. versionadded:: 0.10.1
536536
"""
537537

538-
def __init__(self, *, west: float, south: float, east: float, north: float, crs: Optional[Union[str, int]] = None):
538+
def __init__(
539+
self,
540+
*,
541+
west: float,
542+
south: float,
543+
east: float,
544+
north: float,
545+
crs: int | str | dict | None = None,
546+
):
539547
super().__init__(west=west, south=south, east=east, north=north)
540548
if crs is not None:
541549
self.update(crs=normalize_crs(crs))

‎tests/metadata/test_extents.py‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from openeo.metadata import _EmptyExtent
1+
import pytest
2+
3+
from openeo.metadata import NumericalInterval, _EmptyExtent
24

35

46
class TestEmptyExtent:
@@ -9,3 +11,62 @@ def test_equality(self):
911
def test_none(self):
1012
assert _EmptyExtent() != None
1113
assert None != _EmptyExtent()
14+
15+
16+
class TestNumericalInterval:
17+
def test_basic(self):
18+
interval = NumericalInterval(3, 5.5)
19+
assert interval.start == 3
20+
assert interval.end == 5.5
21+
22+
@pytest.mark.parametrize(["start", "end"], [(None, 5), (3, None), (None, None)])
23+
def test_unbounded(self, start, end):
24+
interval = NumericalInterval(start, end)
25+
assert interval.start == start
26+
assert interval.end == end
27+
28+
def test_equality(self):
29+
assert NumericalInterval(1, 2) == NumericalInterval(1, 2)
30+
assert NumericalInterval(1, 2) != NumericalInterval(1, 3)
31+
assert NumericalInterval(1, 2) != NumericalInterval(0, 2)
32+
assert NumericalInterval(None, 2) != NumericalInterval(0, 2)
33+
assert NumericalInterval(None, None) == NumericalInterval(None, None)
34+
35+
def test_try_from(self):
36+
assert NumericalInterval.try_from([1, 2]) == NumericalInterval(1, 2)
37+
assert NumericalInterval.try_from((None, 5)) == NumericalInterval(None, 5)
38+
assert NumericalInterval.try_from([3.5, None]) == NumericalInterval(3.5, None)
39+
assert NumericalInterval.try_from([None, None]) == NumericalInterval(None, None)
40+
assert NumericalInterval.try_from([1]) is None
41+
assert NumericalInterval.try_from([1, 2, 3]) is None
42+
assert NumericalInterval.try_from("not a list") is None
43+
assert NumericalInterval.try_from([1, "not a number"]) is None
44+
45+
@pytest.mark.parametrize(
46+
["interval1", "interval2", "expected"],
47+
[
48+
# Closed interval cases
49+
((3, 5), (1, 2), _EmptyExtent()),
50+
((3, 5), (1, 3.5), NumericalInterval(3, 3.5)),
51+
((3, 5), (3.5, 4.5), NumericalInterval(3.5, 4.5)),
52+
((3, 5), (4.5, 8), NumericalInterval(4.5, 5)),
53+
((3, 5), (6, 8), _EmptyExtent()),
54+
# Half-open interval cases
55+
((None, 5), (1, 2), NumericalInterval(1, 2)),
56+
((None, 5), (1, 7), NumericalInterval(1, 5)),
57+
((None, 5), (6, 7), _EmptyExtent()),
58+
((3, None), (1, 2), _EmptyExtent()),
59+
((3, None), (1, 4), NumericalInterval(3, 4)),
60+
((3, None), (5, 7), NumericalInterval(5, 7)),
61+
# Unbounded interval cases
62+
((None, None), (1, 2), NumericalInterval(1, 2)),
63+
((None, None), (None, 2), NumericalInterval(None, 2)),
64+
((None, None), (3, None), NumericalInterval(3, None)),
65+
((None, None), (None, None), NumericalInterval(None, None)),
66+
],
67+
)
68+
def test_intersection(self, interval1, interval2, expected):
69+
interval1 = NumericalInterval(*interval1)
70+
interval2 = NumericalInterval(*interval2)
71+
assert interval1.intersection(interval2) == expected
72+
assert interval2.intersection(interval1) == expected

0 commit comments

Comments
 (0)