2222
2323from openeo .api .process import Parameter
2424from 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
2727from openeo .utils .datetime import DateTimeInterval
2828from 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
114174class 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
0 commit comments