@@ -36,8 +36,6 @@ class DimensionAlreadyExistsException(MetadataException):
3636
3737
3838# TODO: make these dimension classes immutable data classes
39- # TODO: align better with STAC datacube extension
40- # TODO: align/adapt/integrate with pystac's datacube extension implementation?
4139class Dimension :
4240 """Base class for dimensions."""
4341
@@ -72,7 +70,6 @@ def rename_labels(self, target, source) -> Dimension:
7270
7371class SpatialDimension (Dimension ):
7472 # TODO: align better with STAC datacube extension: e.g. support "axis" (x or y)
75-
7673 DEFAULT_CRS = 4326
7774
7875 def __init__ (
@@ -685,30 +682,16 @@ def metadata_from_stac(url: str) -> CubeMetadata:
685682 """
686683 Reads the band metadata a static STAC catalog or a STAC API Collection and returns it as a :py:class:`CubeMetadata`
687684
685+ Policy:
686+ - If cube:dimensions exists: treat it as source of truth (it may omit x/y/t/bands).
687+ - Otherwise: apply openEO-style defaults (x, y, t) and (for Collection/Item) keep bands dimension even if empty.
688+
688689 :param url: The URL to a static STAC catalog (STAC Item, STAC Collection, or STAC Catalog) or a specific STAC API Collection
689690 :return: A :py:class:`CubeMetadata` containing the DataCube band metadata from the url.
690691 """
691692 stac_object = pystac .read_file (href = url )
692- bands = _StacMetadataParser ().bands_from_stac_object (stac_object )
693-
694- # At least assume there are spatial dimensions
695- # TODO #743: are there conditions in which we even should not assume the presence of spatial dimensions?
696- dimensions = [
697- SpatialDimension (name = "x" , extent = [None , None ]),
698- SpatialDimension (name = "y" , extent = [None , None ]),
699- ]
700-
701- # TODO #743: conditionally include band dimension when there was actual indication of band metadata?
702- band_dimension = BandDimension (name = "bands" , bands = bands )
703- dimensions .append (band_dimension )
704-
705- # TODO: is it possible to derive the actual name of temporal dimension that the backend will use?
706- temporal_dimension = _StacMetadataParser ().get_temporal_dimension (stac_object )
707- if temporal_dimension :
708- dimensions .append (temporal_dimension )
709-
710- metadata = CubeMetadata (dimensions = dimensions )
711- return metadata
693+ parser = _StacMetadataParser ()
694+ return parser .metadata_from_stac_object (stac_object )
712695
713696# Sniff for PySTAC extension API since version 1.9.0 (which is not available below Python 3.9)
714697# TODO: remove this once support for Python 3.7 and 3.8 is dropped
@@ -766,39 +749,165 @@ def __init__(self, *, logger=_log, log_level=logging.DEBUG, supress_duplicate_wa
766749 # Use caching trick to avoid duplicate warnings
767750 self ._warn = functools .lru_cache (maxsize = 1000 )(self ._warn )
768751
752+ def metadata_from_stac_object (self , stac_object : pystac .STACObject ) -> CubeMetadata :
753+ """
754+ Build cube metadata from a STAC object.
755+ """
756+ dimensions = self .dimensions_from_stac_object (stac_object = stac_object )
757+ return CubeMetadata (dimensions = dimensions )
758+
759+ def dimensions_from_stac_object (self , stac_object : pystac .STACObject ) -> List [Dimension ]:
760+ """
761+ Build dimension metadata from a STAC object.
762+
763+ Philosophy:
764+ - If cube:dimensions exists: treat it as source of truth (it may omit x/y/t/bands).
765+ - Otherwise: apply openEO-style defaults (x, y, bands, optional t).
766+ """
767+ bands = self .bands_from_stac_object (stac_object )
768+ if self ._has_cube_dimensions (stac_object ):
769+ return self ._parse_declared_dimensions (stac_object = stac_object , bands = bands )
770+
771+ dimensions : List [Dimension ] = [
772+ SpatialDimension (name = "x" , extent = [None , None ]),
773+ SpatialDimension (name = "y" , extent = [None , None ]),
774+ BandDimension (name = "bands" , bands = list (bands )),
775+ ]
776+ temporal_dimension = self .get_temporal_dimension (stac_object )
777+ if temporal_dimension :
778+ dimensions .append (temporal_dimension )
779+ return dimensions
780+
769781 def get_temporal_dimension (self , stac_obj : pystac .STACObject ) -> Union [TemporalDimension , None ]:
770782 """
771783 Extract the temporal dimension from a STAC Collection/Item (if any)
772784 """
773- # TODO: also extract temporal dimension from assets?
774- if _PYSTAC_1_9_EXTENSION_INTERFACE :
775- if stac_obj .ext .has ("cube" ) and hasattr (stac_obj .ext , "cube" ):
776- temporal_dims = [
777- (n , d .extent or [None , None ])
778- for (n , d ) in stac_obj .ext .cube .dimensions .items ()
779- if d .dim_type == pystac .extensions .datacube .DimensionType .TEMPORAL
785+ if self ._has_cube_dimensions (stac_obj ):
786+ temporal_dimensions = [
787+ d
788+ for d in self ._parse_declared_dimensions (stac_object = stac_obj , bands = _BandList ([]))
789+ if isinstance (d , TemporalDimension )
790+ ]
791+ if len (temporal_dimensions ) == 1 :
792+ return temporal_dimensions [0 ]
793+
794+ if isinstance (stac_obj , pystac .Collection ) and stac_obj .extent and stac_obj .extent .temporal :
795+ extent = [Rfc3339 (propagate_none = True ).normalize (d ) for d in stac_obj .extent .temporal .intervals [0 ]]
796+ return TemporalDimension (name = "t" , extent = extent )
797+
798+ if isinstance (stac_obj , pystac .Item ):
799+ props = stac_obj .properties
800+ start = props .get ("start_datetime" )
801+ end = props .get ("end_datetime" )
802+ if start or end :
803+ extent = [
804+ Rfc3339 (propagate_none = True ).normalize (start ),
805+ Rfc3339 (propagate_none = True ).normalize (end ),
780806 ]
781- if len (temporal_dims ) == 1 :
782- name , extent = temporal_dims [0 ]
783- return TemporalDimension (name = name , extent = extent )
784- elif isinstance (stac_obj , pystac .Collection ) and stac_obj .extent .temporal :
785- # No explicit "cube:dimensions": build fallback from "extent.temporal",
786- # with dimension name "t" (openEO API recommendation).
787- extent = [Rfc3339 (propagate_none = True ).normalize (d ) for d in stac_obj .extent .temporal .intervals [0 ]]
788807 return TemporalDimension (name = "t" , extent = extent )
789- else :
790- if isinstance (stac_obj , pystac .Item ):
791- cube_dimensions = stac_obj .properties .get ("cube:dimensions" , {})
792- elif isinstance (stac_obj , pystac .Collection ):
793- cube_dimensions = stac_obj .extra_fields .get ("cube:dimensions" , {})
808+
809+ dt = props .get ("datetime" )
810+ if dt :
811+ norm = Rfc3339 (propagate_none = True ).normalize (dt )
812+ return TemporalDimension (name = "t" , extent = [norm , norm ])
813+
814+ def _has_cube_dimensions (self , stac_object : pystac .STACObject ) -> bool :
815+ cube_dimensions = self ._cube_dimensions_dict (stac_object )
816+ return isinstance (cube_dimensions , dict ) and len (cube_dimensions ) > 0
817+
818+ @staticmethod
819+ def _cube_dimensions_dict (stac_object : pystac .STACObject ) -> Dict [str , dict ]:
820+ """
821+ Return raw cube:dimensions dict from a Collection/Item, or {}.
822+ """
823+ if isinstance (stac_object , pystac .Item ):
824+ return stac_object .properties .get ("cube:dimensions" , {}) or {}
825+ if isinstance (stac_object , pystac .Collection ):
826+ return stac_object .extra_fields .get ("cube:dimensions" , {}) or {}
827+ return {}
828+
829+ @staticmethod
830+ def _safe_extent_from_pystac_cube_dim (dim : pystac .extensions .datacube .Dimension ) -> List [Any ]:
831+ """
832+ PySTAC cube dimension wrapper may raise if 'extent' is missing.
833+ Also, depending on serialization/version, extent might live in extra_fields.
834+ """
835+ ext = getattr (dim , "extent" , None )
836+
837+ if not ext :
838+ extra = getattr (dim , "extra_fields" , {}) or {}
839+ ext = extra .get ("extent" )
840+
841+ return ext or [None , None ]
842+
843+ def _parse_declared_dimensions (self , stac_object : pystac .STACObject , bands : _BandList ) -> List [Dimension ]:
844+ """
845+ Parse dimensions declared through cube:dimensions.
846+ """
847+ if (
848+ _PYSTAC_1_9_EXTENSION_INTERFACE
849+ and getattr (stac_object , "ext" , None ) is not None
850+ and stac_object .ext .has ("cube" )
851+ and hasattr (stac_object .ext , "cube" )
852+ ):
853+ return self ._parse_cube_dimensions_from_pystac_extension (stac_object = stac_object , bands = bands )
854+ return self ._parse_cube_dimensions_from_raw_dict (stac_object = stac_object , bands = bands )
855+
856+ def _parse_cube_dimensions_from_pystac_extension (
857+ self , stac_object : pystac .STACObject , bands : _BandList
858+ ) -> List [Dimension ]:
859+ """
860+ Parse dimensions from PySTAC's cube extension wrapper (assumed to be present).
861+ Supports 'spatial', 'temporal', and 'bands' (or 'spectral' as an alias).
862+ Also see ``_parse_cube_dimensions_from_raw_dict``
863+ """
864+ dimensions = []
865+ for name , dim in stac_object .ext .cube .dimensions .items ():
866+ dim_type = dim .dim_type
867+ extent = self ._safe_extent_from_pystac_cube_dim (dim )
868+
869+ if dim_type == pystac .extensions .datacube .DimensionType .SPATIAL :
870+ dimensions .append (SpatialDimension (name = name , extent = extent ))
871+ elif dim_type == pystac .extensions .datacube .DimensionType .TEMPORAL :
872+ dimensions .append (TemporalDimension (name = name , extent = extent ))
873+ elif dim_type in ("bands" , "spectral" ):
874+ dimensions .append (BandDimension (name = name , bands = list (bands )))
875+ elif dim_type == pystac .extensions .datacube .DimensionType .GEOMETRIES :
876+ dimensions .append (GeometryDimension (name = name ))
794877 else :
795- cube_dimensions = {}
796- temporal_dims = [
797- (n , d .get ("extent" , [None , None ])) for (n , d ) in cube_dimensions .items () if d .get ("type" ) == "temporal"
798- ]
799- if len (temporal_dims ) == 1 :
800- name , extent = temporal_dims [0 ]
801- return TemporalDimension (name = name , extent = extent )
878+ self ._warn (f"Unhandled cube:dimensions type { dim_type !r} for dimension { name !r} " )
879+ dimensions .append (Dimension (name = name , type = dim_type ))
880+
881+ return dimensions
882+
883+ def _parse_cube_dimensions_from_raw_dict (self , stac_object : pystac .STACObject , bands : _BandList ) -> List [Dimension ]:
884+ """
885+ Parse dimensions from raw cube:dimensions dict.
886+ Supports 'spatial', 'temporal', and 'bands' (or 'spectral' as an alias).
887+ Also see ``_parse_cube_dimensions_from_pystac_extension``
888+ """
889+ dimensions = []
890+ cube_dimensions = self ._cube_dimensions_dict (stac_object )
891+
892+ for name , dim in cube_dimensions .items ():
893+ if not isinstance (dim , dict ):
894+ continue
895+
896+ dim_type = dim .get ("type" )
897+ extent = dim .get ("extent" , [None , None ])
898+
899+ if dim_type == "spatial" :
900+ dimensions .append (SpatialDimension (name = name , extent = extent ))
901+ elif dim_type == "temporal" :
902+ dimensions .append (TemporalDimension (name = name , extent = extent ))
903+ elif dim_type in ("bands" , "spectral" ):
904+ dimensions .append (BandDimension (name = name , bands = list (bands )))
905+ elif dim_type == "geometries" :
906+ dimensions .append (GeometryDimension (name = name ))
907+ else :
908+ dimensions .append (Dimension (name = name , type = dim_type ))
909+
910+ return dimensions
802911
803912 def _band_from_eo_bands_metadata (self , band : Union [dict , pystac .extensions .eo .Band ]) -> Band :
804913 """Construct band from metadata in eo v1.1 style"""
0 commit comments