Repository: dlr-eoc/ukis-pysat

Issue:
Handle case where pad_width<0 in _pad_to_bbox
Currently [_pad_to_bbox](https://github.com/dlr-eoc/ukis-pysat/blob/96196d955f28b96249b9b00e56c24321963f0a79/ukis_pysat/raster.py#L112) cannot deal with cases where pad_width happens to be <0 (e.g., when bbox is smaller than image and no padding would be required). We should be able to handle such cases and not immediately throw an error. A simple way would be to set pad_width=0 in such cases. The following would be possible implementation:

```python
    def _pad_to_bbox(self, bbox, mode="constant", constant_values=0):
        """Buffers array with biggest difference to bbox and adjusts affine transform matrix. Can be used to fill
        array with nodata values before masking in case bbox only partially overlaps dataset bounds.
        :param bbox: bounding box of type tuple or Shapely Polygon
        :param mode: str, how to pad, see rasterio.pad. Optional (default: 'constant')
        :param constant_values: nodata value, padding should be filled with, optional (default: 0)
        :return: closed, buffered dataset in memory
        """
        if isinstance(bbox, polygon.Polygon):
            bbox = bbox.bounds
        elif isinstance(bbox, tuple):
            pass
        else:
            raise TypeError(f"bbox must be of type tuple or Shapely Polygon")

        max_diff_ur = np.max(np.subtract(bbox[2:], tuple(self.dataset.bounds[2:])))
        max_diff_ll = np.max(np.subtract(tuple(self.dataset.bounds[:2]), bbox[:2]))
        max_diff = max(max_diff_ll, max_diff_ur)  # buffer in units

        pad_width = math.ceil(max_diff / self.dataset.transform.to_gdal()[1])  # units / pixel_size

        if pad_width < 0:
            # make sure that pad_width is not negative
            pad_width = 0

        destination = np.zeros(
            (self.dataset.count, self.__arr.shape[1] + 2 * pad_width, self.__arr.shape[2] + 2 * pad_width,),
            self.__arr.dtype,
        )

        for i in range(0, self.dataset.count):
            destination[i], transform = rasterio.pad(
                self.__arr[i], self.dataset.transform, pad_width, mode, constant_values=constant_values,
            )

        self.__arr = destination

        self.dataset.close()
        return self.__update_dataset(self.dataset.crs, transform, nodata=self.dataset.nodata)
```

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index b7d5cab..9422790 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -20,11 +20,17 @@ Added
 - ``raster.Image()``: With Statement Context Manager for Image #45
 - ``raster.Image()``: Alter image array #67
 
+Fixed
+*****
+- ``data.Source()``: Fixed query metadata return for new EarthExplorer API #71
+- ``raster.Image()``: Consider all image bands in `pad()` #59
+
 Changed
 *******
 - ``data.Metadata()``: Corrected field types #58
 - ``data.MetadataCollection()``: Improved plotting of MetadataCollection to_pandas method #56
 - ``data.MetadataCollection()``: Made filter method more flexible with list and fuzzy filter options #55
+- ``raster.Image()``: Split `_pad_to_bbox()` into `pad()` and `_get_pad_width()`, updated `mask()` #59
 
 Removed
 *******
diff --git a/ukis_pysat/data.py b/ukis_pysat/data.py
index 71d054b..a21f7ff 100644
--- a/ukis_pysat/data.py
+++ b/ukis_pysat/data.py
@@ -116,7 +116,7 @@ class Source:
                 with open(meta_file) as f:
                     m = json.load(f)
                     try:
-                        self.construct_metadata(m)
+                        self.construct_metadata(meta_src=m, platform=platform)
                     except (json.decoder.JSONDecodeError, LookupError, TypeError) as e:
                         raise ValueError(f"{os.path.basename(meta_file)} not a valid metadata file. {e}.")
                     m_platform = m["properties"]["platformname"]
@@ -157,18 +157,19 @@ class Source:
             meta_src = self.api.to_geojson(meta_src)["features"]
 
         # construct MetadataCollection from list of Metadata objects
-        return MetadataCollection([self.construct_metadata(meta_src=m) for m in meta_src])
+        return MetadataCollection([self.construct_metadata(meta_src=m, platform=platform) for m in meta_src])
 
-    def construct_metadata(self, meta_src):
+    def construct_metadata(self, meta_src, platform):
         """Constructs a metadata object that is harmonized across the different satellite image sources.
 
         :param meta_src: Source metadata (GeoJSON-like mapping)
+        :param platform: Image platform (<enum 'Platform'>).
         :returns: Harmonized metadata (Metadata object)
         """
         if self.src == Datahub.File:
             meta = Metadata(
                 id=meta_src["properties"]["id"],
-                platformname=Platform(meta_src["properties"]["platformname"]),
+                platformname=platform,
                 producttype=meta_src["properties"]["producttype"],
                 orbitdirection=meta_src["properties"]["orbitdirection"],
                 orbitnumber=int(meta_src["properties"]["orbitnumber"]),
@@ -187,12 +188,7 @@ class Source:
         elif self.src == Datahub.EarthExplorer:
             meta = Metadata(
                 id=meta_src["displayId"],
-                platformname=Platform(
-                    meta_src["dataAccessUrl"][
-                        meta_src["dataAccessUrl"].find("dataset_name=")
-                        + len("dataset_name=") : meta_src["dataAccessUrl"].rfind("&ordered=")
-                    ]
-                ),
+                platformname=platform,
                 producttype="L1TP",
                 orbitdirection="DESCENDING",
                 orbitnumber=int(
@@ -203,7 +199,7 @@ class Source:
                 relativeorbitnumber=int(meta_src["summary"][meta_src["summary"].find("Row: ") + len("Row: ") :]),
                 acquisitiondate=meta_src["acquisitionDate"],
                 ingestiondate=meta_src["modifiedDate"],
-                cloudcoverpercentage=float(round(meta_src["cloudCover"], 2)) if "cloudCover" in meta_src else None,
+                cloudcoverpercentage=round(float(meta_src["cloudCover"]), 2) if "cloudCover" in meta_src else None,
                 format="GeoTIFF",
                 srcid=meta_src["displayId"],
                 srcurl=meta_src["dataAccessUrl"],
@@ -214,14 +210,14 @@ class Source:
         else:
             meta = Metadata(
                 id=meta_src["properties"]["identifier"],
-                platformname=Platform(meta_src["properties"]["platformname"]),
+                platformname=platform,
                 producttype=meta_src["properties"]["producttype"],
                 orbitdirection=meta_src["properties"]["orbitdirection"],
                 orbitnumber=int(meta_src["properties"]["orbitnumber"]),
                 relativeorbitnumber=int(meta_src["properties"]["relativeorbitnumber"]),
                 acquisitiondate=meta_src["properties"]["beginposition"],
                 ingestiondate=meta_src["properties"]["ingestiondate"],
-                cloudcoverpercentage=float(round(meta_src["properties"]["cloudcoverpercentage"], 2))
+                cloudcoverpercentage=round(float(meta_src["properties"]["cloudcoverpercentage"]), 2)
                 if "cloudcoverpercentage" in meta_src["properties"]
                 else None,
                 format=meta_src["properties"]["format"],
diff --git a/ukis_pysat/raster.py b/ukis_pysat/raster.py
index 23320de..ddaa012 100644
--- a/ukis_pysat/raster.py
+++ b/ukis_pysat/raster.py
@@ -102,20 +102,27 @@ class Image:
         valid_data_window = windows.get_data_window(self.__arr, nodata=nodata)
         return windows.bounds(valid_data_window, windows.transform(valid_data_window, self.dataset.transform))
 
-    def mask(self, bbox, crop=True, pad=False, mode="constant", constant_values=0):
-        """Mask the area outside of the input shapes with no data.
+    def mask(self, bbox, crop=True, pad=False, fill=False, mode="constant", constant_values=0):
+        """Mask raster to bbox.
 
         :param bbox: bounding box of type tuple or Shapely Polygon
         :param crop: bool, see rasterio.mask. Optional, (default: True)
-        :param pad: pads image, should only be used when bbox.bounds extent img.bounds, optional (default: False)
-        :param mode: str, how to pad, see rasterio.pad. Optional (default: 'constant')
+        :param fill: enforce raster to cover bbox. if raster extent is smaller than bbox it will be filled according to
+            mode and constant_values parameters. Optional (default: False)
+        :param mode: str, how to fill, see rasterio.pad. Optional (default: 'constant')
         :param constant_values: nodata value, padding should be filled with, optional (default: 0)
         """
-        # TODO https://github.com/mapbox/rasterio/issues/995
         if pad:
-            self.dataset.close()
-            # here we __update_dataset()
-            self.dataset = self._pad_to_bbox(bbox, mode, constant_values)
+            from warnings import warn
+            warn("`pad` was renamed to `fill` in `mask()` and will be removed with version 0.7.0", DeprecationWarning)
+            fill = pad
+
+        # TODO https://github.com/mapbox/rasterio/issues/995
+        if fill:
+            pad_width = self._get_pad_width(bbox)
+            if pad_width > 0:
+                # only pad raster if it is smaller than bbox
+                self.dataset = self.pad(pad_width, mode, constant_values)
 
         if isinstance(bbox, polygon.Polygon):
             self.__arr, transform = rasterio.mask.mask(self.dataset, [bbox], crop=crop)
@@ -129,14 +136,12 @@ class Image:
         # passing meta to open, because driver gets lost if it's not GTiff
         self.dataset = self.__update_dataset(self.dataset.crs, transform, nodata=self.dataset.nodata)
 
-    def _pad_to_bbox(self, bbox, mode="constant", constant_values=0):
-        """Buffers array with biggest difference to bbox and adjusts affine transform matrix. Can be used to fill
-        array with nodata values before masking in case bbox only partially overlaps dataset bounds.
+    def _get_pad_width(self, bbox):
+        """Calculates biggest difference from raster bounds to bbox. Can be used with pad() to fill array with
+        nodata values before masking in case bbox only partially overlaps dataset bounds.
 
-        :param bbox: bounding box of type tuple or Shapely Polygon
-        :param mode: str, how to pad, see rasterio.pad. Optional (default: 'constant')
-        :param constant_values: nodata value, padding should be filled with, optional (default: 0)
-        :return: closed, buffered dataset in memory
+        :param bbox: bounding box of type tuple or Shapely Polygon.
+        :return: pad width in pixels, int.
         """
         if isinstance(bbox, polygon.Polygon):
             bbox = bbox.bounds
@@ -149,8 +154,16 @@ class Image:
         max_diff_ll = np.max(np.subtract(tuple(self.dataset.bounds[:2]), bbox[:2]))
         max_diff = max(max_diff_ll, max_diff_ur)  # buffer in units
 
-        pad_width = math.ceil(max_diff / self.dataset.transform.to_gdal()[1])  # units / pixel_size
+        return math.ceil(max_diff / self.dataset.transform.to_gdal()[1])  # units / pixel_size
+
+    def pad(self, pad_width, mode="constant", constant_values=0):
+        """Pad raster in all directions.
 
+        :param pad_width: pad width in pixels, int.
+        :param mode: str, how to pad, see rasterio.pad. Optional (default: 'constant')
+        :param constant_values: nodata value, padding should be filled with, optional (default: 0)
+        :return: closed, buffered dataset in memory
+        """
         destination = np.zeros(
             (self.dataset.count, self.__arr.shape[1] + 2 * pad_width, self.__arr.shape[2] + 2 * pad_width,),
             self.__arr.dtype,
@@ -158,7 +171,7 @@ class Image:
 
         for i in range(0, self.dataset.count):
             destination[i], transform = rasterio.pad(
-                self.__arr[0], self.dataset.transform, pad_width, mode, constant_values=constant_values,
+                self.__arr[i], self.dataset.transform, pad_width, mode, constant_values=constant_values,
             )
 
         self.__arr = destination


Test patch:
diff --git a/tests/test_raster.py b/tests/test_raster.py
index d41b9d2..0bf67ca 100644
--- a/tests/test_raster.py
+++ b/tests/test_raster.py
@@ -156,7 +156,7 @@ class DataTest(unittest.TestCase):
         )
 
         self.img.mask(
-            box(11.8919236802142620, 51.4664152338322580, 11.9477435281016131, 51.5009522690838750,), pad=True,
+            box(11.8919236802142620, 51.4664152338322580, 11.9477435281016131, 51.5009522690838750,), fill=True,
         )
         self.assertEqual(
             self.img.dataset.bounds,