Repository: leftfield-geospatial/geedim (language: python)

Issue:
Unbounded search and download errors
- Searching an unbounded collection without a *region* is not possible due to `ee.Image.reduceRegion()` in `MaskedImage._set_region_stats()`.  The *ee.EEException* that is raised should be communicated to the user in a more helpful way.
- MODIS NBAR images are unbounded but not recognised as such by the checks in `BaseImage._prepare_for_export()`, resulting in a cryptic EE error.  The unbounded check should also check for a footprint that is global.
MODIS SR-ORG:6974 CRS download
GEE reports the MODIS CRS as *SR-ORG:6974* but downloads it as *SR-ORG:6842* resulting in incorrect image position.


Discussion / hints:

Fix patch:
diff --git a/geedim/cli.py b/geedim/cli.py
index 73404b5..3592649 100644
--- a/geedim/cli.py
+++ b/geedim/cli.py
@@ -476,7 +476,7 @@ def search(
             custom_filter=custom_filter, **obj.cloud_kwargs
         )
         # retrieve search result properties from EE
-        num_images = gd_collection.properties
+        num_images = len(gd_collection.properties)
 
     if num_images == 0:
         logger.info('No images found\n')
diff --git a/geedim/collection.py b/geedim/collection.py
index 43820dd..942056b 100644
--- a/geedim/collection.py
+++ b/geedim/collection.py
@@ -332,7 +332,14 @@ def aggregrate_props(ee_image, coll_list):
             return ee.List(coll_list).add(im_dict)
 
         # retrieve list of dicts of properties of images in ee_collection
-        props_list = ee.List(ee_collection.iterate(aggregrate_props, ee.List([]))).getInfo()
+        props_list = []
+        try:
+            props_list = ee.List(ee_collection.iterate(aggregrate_props, ee.List([]))).getInfo()
+        except ee.EEException as ex:
+            if 'geometry' in str(ex) and 'unbounded' in str(ex):
+                raise ValueError('This collection is unbounded and needs a `region` to be specified.')
+            else:
+                raise
         # add image properties to the return dict in the same order as the underlying collection
         props_dict = OrderedDict()
         for prop_dict in props_list:
diff --git a/geedim/download.py b/geedim/download.py
index 4a4c759..a2ace11 100644
--- a/geedim/download.py
+++ b/geedim/download.py
@@ -229,6 +229,13 @@ def profile(self) -> Dict:
             count=self.count
         )
 
+    @property
+    def bounded(self) -> bool:
+        """ True if the image is bounded, otherwise False. """
+        # TODO: an unbounded region could also have these bounds
+        unbounded_bounds = (-180, -90, 180, 90)
+        return (self.footprint is not None) and (features.bounds(self.footprint) != unbounded_bounds)
+
     @staticmethod
     def _get_projection(ee_info: Dict, min_scale=True) -> Dict:
         """
@@ -384,14 +391,13 @@ def _prepare_for_export(
         and ``shape``.  If no bounds are specified (with either ``region``, or ``crs_transform`` & ``shape``), the
         entire image granule is exported.
 
-        When ``crs`` and ``scale`` are not specified (or match those of the encapsulated image), and ``crs_transform``
-        & ``shape`` are not specified, the pixel grid of the exported image will coincide with that of the encapsulated
-        image.
+        When ``crs``, ``scale``, ``crs_transform`` & ``shape`` are not specified, the pixel grid of the exported
+        image will coincide with that of the encapsulated image.
 
         Parameters
         ----------
         crs : str, optional
-            WKT, EPSG etc. specification of CRS to export to.  Where image bands have different CRSs, all are
+            WKT or EPSG specification of CRS to export to.  Where image bands have different CRSs, all are
             re-projected to this CRS. Defaults to use the CRS of the minimum scale band if available.
         crs_transform: tuple of float, list of float, rio.Affine, optional
             List of 6 numbers specifying an affine transform in the specified CRS.  In row-major order:
@@ -431,7 +437,7 @@ def _prepare_for_export(
                     f'crs, crs_transform & shape.'
                 )
 
-        if (not self.footprint) and (not region and (not crs or not crs_transform or not shape)):
+        if (not self.bounded) and (not region and (not crs or not crs_transform or not shape)):
             # if the image has no footprint (i.e. it is 'unbounded'), either region; or crs, crs_transform and shape
             # must be specified
             raise ValueError(
@@ -445,12 +451,6 @@ def _prepare_for_export(
             # the default scale is in degrees.
             raise ValueError(f'This image is in EPSG:4326, you need to specify a scale (in meters); or a shape.')
 
-        if crs == 'SR-ORG:6974':
-            raise ValueError(
-                'There is an earth engine bug exporting in SR-ORG:6974, specify another CRS: '
-                'https://issuetracker.google.com/issues/194561313'
-            )
-
         if scale and shape:
             # This error is raised in later calls to ee.Image.getInfo(), but is neater to raise here first
             raise ValueError('You can specify one of scale or shape, but not both.')
@@ -492,7 +492,7 @@ def _prepare_for_export(
                     if isinstance(region, ee.Geometry):
                         region = region.getInfo()
                     region_crs = region['crs']['properties']['name'] if 'crs' in region else 'EPSG:4326'
-                    region_bounds = warp.transform_bounds(region_crs, crs, *features.bounds(region))
+                    region_bounds = warp.transform_bounds(region_crs, utils.rio_crs(crs), *features.bounds(region))
                     region_win = windows.from_bounds(*region_bounds, transform=self.transform)
                     region_win = utils.expand_window_to_grid(region_win)
                     crs_transform = self.transform * rio.Affine.translation(region_win.col_off, region_win.row_off)
@@ -538,7 +538,7 @@ def _prepare_for_download(self, set_nodata: bool = True, **kwargs) -> Tuple['Bas
         nodata = nodata_dict[exp_image.dtype] if set_nodata else None
         profile = dict(
             driver='GTiff', dtype=exp_image.dtype, nodata=nodata, width=exp_image.shape[1], height=exp_image.shape[0],
-            count=exp_image.count, crs=CRS.from_string(exp_image.crs), transform=exp_image.transform,
+            count=exp_image.count, crs=CRS.from_string(utils.rio_crs(exp_image.crs)), transform=exp_image.transform,
             compress='deflate', interleave='band', tiled=True, photometric=None,
         )
         # add BIGTIFF support if the uncompressed image is bigger than 4GB
@@ -713,9 +713,9 @@ def export(self, filename: str, folder: str = None, wait: bool = True, **kwargs)
         and ``shape``.  If no bounds are specified (with either ``region``, or ``crs_transform`` & ``shape``), the
         entire image granule is exported.
 
-        When ``crs`` and ``scale`` are not specified (or match those of the encapsulated image), and ``crs_transform``
-        & ``shape`` are not specified, the pixel grid of the exported image will coincide with that of the encapsulated
-        image.
+        When ``crs``, ``scale``, ``crs_transform`` & ``shape`` are not specified, the pixel grid of the exported
+        image will coincide with that of the encapsulated image.
+
 
         Parameters
         ----------
@@ -726,7 +726,7 @@ def export(self, filename: str, folder: str = None, wait: bool = True, **kwargs)
         wait : bool
             Wait for the export to complete before returning.
         crs : str, optional
-            WKT, EPSG etc specification of CRS to export to.  Where image bands have different CRSs, all are
+            WKT or EPSG specification of CRS to export to.  Where image bands have different CRSs, all are
             re-projected to this CRS. Defaults to use the CRS of the minimum scale band if available.
         crs_transform: tuple of float, list of float, rio.Affine, optional
             List of 6 numbers specifying an affine transform in the specified CRS.  In row-major order:
@@ -755,6 +755,11 @@ def export(self, filename: str, folder: str = None, wait: bool = True, **kwargs)
         """
 
         exp_image = self._prepare_for_export(**kwargs)
+        if exp_image.crs == 'SR-ORG:6974':
+            logger.warning(
+                'There is an earth engine bug exporting in SR-ORG:6974, you will need to edit the exported file to '
+                'replace the CRS with SR-ORG:6842. See: https://issuetracker.google.com/issues/194561313.'
+            )
 
         if logger.getEffectiveLevel() <= logging.DEBUG:
             logger.debug(f'Uncompressed size: {self._str_format_size(exp_image.size)}')
@@ -784,9 +789,8 @@ def download(
         and ``shape``.  If no bounds are specified (with either ``region``, or ``crs_transform`` & ``shape``), the
         entire image granule is downloaded.
 
-        When ``crs`` and ``scale`` are not specified (or match those of the encapsulated image), and ``crs_transform``
-        & ``shape`` are not specified, the pixel grid of the downloaded image will coincide with that of the
-        encapsulated image.
+        When ``crs``, ``scale``, ``crs_transform`` & ``shape`` are not specified, the pixel grid of the exported
+        image will coincide with that of the encapsulated image.
 
         Parameters
         ----------
@@ -801,7 +805,7 @@ def download(
         max_tile_dim: int, optional
             Maximum tile width/height (pixels).  If None, defaults to Earth Engine download limit (10000).
         crs : str, optional
-            WKT, EPSG etc. specification of CRS to export to.  Where image bands have different CRSs, all are
+            WKT or EPSG specification of CRS to export to.  Where image bands have different CRSs, all are
             re-projected to this CRS. Defaults to use the CRS of the minimum scale band if available.
         crs_transform: tuple of float, list of float, rio.Affine, optional
             List of 6 numbers specifying an affine transform in the specified CRS.  In row-major order:
diff --git a/geedim/utils.py b/geedim/utils.py
index 3fe3a8d..f38e77e 100644
--- a/geedim/utils.py
+++ b/geedim/utils.py
@@ -333,3 +333,23 @@ def expand_window_to_grid(win: Window, expand_pixels: Tuple[int, int] = (0, 0))
     exp_win = Window(int(col_off), int(row_off), int(width), int(height))
     return exp_win
 
+
+def rio_crs(crs: str) -> str:
+    """ Convert a GEE CRS string to a rasterio compatible CRS string. """
+    if crs == 'SR-ORG:6974':
+        # This is a workaround for https://issuetracker.google.com/issues/194561313, that replaces the alleged GEE
+        # SR-ORG:6974 with actual (rasterio) SR-ORG:6842. WKT taken from
+        # https://spatialreference.org/ref/sr-org/modis-sinusoidal/html/.
+        crs = """PROJCS["Sinusoidal",
+GEOGCS["GCS_Undefined",
+    DATUM["Undefined",
+        SPHEROID["User_Defined_Spheroid",6371007.181,0.0]],
+    PRIMEM["Greenwich",0.0],
+    UNIT["Degree",0.0174532925199433]],
+PROJECTION["Sinusoidal"],
+PARAMETER["False_Easting",0.0],
+PARAMETER["False_Northing",0.0],
+PARAMETER["Central_Meridian",0.0],
+UNIT["Meter",1.0]]"""
+    return crs
+


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index d74003a..5944840 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -307,6 +307,7 @@ def test_search_custom_filter_l9(region_25ha_file: pathlib.Path, runner: CliRunn
         ('l8_image_id', 'region_25ha_file'),
         ('s2_sr_hm_image_id', 'region_25ha_file'),
         ('gedi_cth_image_id', 'region_25ha_file'),
+        ('modis_nbar_image_id', 'region_25ha_file'),
     ]
 )  # yapf: disable
 def test_download_region_defaults(
diff --git a/tests/test_collection.py b/tests/test_collection.py
index 731a0f3..e98a754 100644
--- a/tests/test_collection.py
+++ b/tests/test_collection.py
@@ -545,3 +545,33 @@ def test_composite_mult_kwargs(region_100ha):
     cp_prob40 = comp_im_prob40.properties['CLOUDLESS_PORTION']
 
     assert cp_prob80 != pytest.approx(cp_prob40, abs=1e-1)
+
+
+@pytest.mark.parametrize(
+    'name', ['FAO/WAPOR/2/L1_RET_E', 'MODIS/006/MCD43A4']
+)
+def test_unbounded_search_no_region(name):
+    """
+    Test searching an unbounded collection without a region raises an exception.
+    """
+    start_date = '2022-01-01'
+    end_date = '2022-01-02'
+    gd_collection = MaskedCollection.from_name(name)
+    gd_collection = gd_collection.search(start_date, end_date)
+    with pytest.raises(ValueError) as ex:
+        _ = gd_collection.properties
+    assert 'unbounded' in str(ex) and 'region' in str(ex)
+
+
+def test_unknown_collection_error(region_25ha):
+    """
+    Test searching a non-existent collection gives an error.
+    """
+    start_date = '2022-01-01'
+    end_date = '2022-01-02'
+    gd_collection = MaskedCollection.from_name('Unknown')
+    with pytest.raises(ee.EEException) as ex:
+        gd_collection = gd_collection.search(start_date, end_date, region_25ha)
+        _ = gd_collection.properties
+    assert 'not found' in str(ex)
+
diff --git a/tests/test_download.py b/tests/test_download.py
index 35442fc..1b7befa 100644
--- a/tests/test_download.py
+++ b/tests/test_download.py
@@ -16,13 +16,17 @@
 import pathlib
 from datetime import datetime
 from typing import Dict, Tuple, List
+import copy
 
 import ee
 import numpy as np
 import pytest
 import rasterio as rio
+from rasterio import features, warp
+from rasterio.crs import CRS
 from geedim.download import BaseImage
 from geedim.enums import ResamplingMethod
+from geedim import utils
 from rasterio import Affine, windows
 
 
@@ -66,21 +70,28 @@ def l9_base_image(l9_image_id: str) -> BaseImage:
     """ A BaseImage instance encapsulating a Landsat-9 image.  Covers `region_*ha`.  """
     return BaseImage.from_id(l9_image_id)
 
+
 @pytest.fixture(scope='session')
 def l8_base_image(l8_image_id: str) -> BaseImage:
     """ A BaseImage instance encapsulating a Landsat-8 image.  Covers `region_*ha`.  """
     return BaseImage.from_id(l8_image_id)
 
+
 @pytest.fixture(scope='session')
 def landsat_ndvi_base_image(landsat_ndvi_image_id: str) -> BaseImage:
     """ A BaseImage instance encapsulating a Landsat NDVI composite image.  Covers `region_*ha`.  """
     return BaseImage.from_id(landsat_ndvi_image_id)
 
 
+@pytest.fixture(scope='session')
+def modis_nbar_base_image_unbnd(modis_nbar_image_id: str) -> BaseImage:
+    """ A BaseImage instance encapsulating an unbounded MODIS NBAR image in its native CRS.  Covers `region_*ha`.  """
+    return BaseImage(ee.Image(modis_nbar_image_id))
+
 @pytest.fixture(scope='session')
 def modis_nbar_base_image(modis_nbar_image_id: str, region_100ha: Dict) -> BaseImage:
     """ A BaseImage instance encapsulating a MODIS NBAR image.  Covers `region_*ha`.  """
-    return BaseImage(ee.Image(modis_nbar_image_id).clip(region_100ha).reproject(crs='EPSG:3857', scale=500))
+    return BaseImage(ee.Image(modis_nbar_image_id).clip(region_100ha))
 
 
 def bounds_polygon(left: float, bottom: float, right: float, top: float, crs: str = None):
@@ -98,23 +109,25 @@ def bounds_polygon(left: float, bottom: float, right: float, top: float, crs: st
     return poly
 
 
-def _test_export_profile(exp_profile, tgt_profile, crs_transform=False):
+def _test_export_profile(exp_profile, tgt_profile, transform_shape=False):
     """
     Test an export/download rasterio profile against a target rasterio profile.
     ``crs_transform`` specifies whether the export/download profile was generated by specifying crs_transform.
     """
 
-    compare_fn = (lambda x: x) if crs_transform else (lambda x: pytest.approx(x, rel=0.05))
-
-    for key in ['crs', 'dtype', 'count']:
+    for key in ['dtype', 'count']:
         assert exp_profile[key] == tgt_profile[key]
 
+    assert utils.rio_crs(exp_profile['crs']) == utils.rio_crs(tgt_profile['crs'])
+
     assert exp_profile['transform'][0] == tgt_profile['transform'][0]
 
-    for key in ['width', 'height']:
-        if crs_transform:
+    if transform_shape:
+        for key in ['width', 'height']:
             assert exp_profile[key] == tgt_profile[key]
-    assert exp_profile['transform'][:6] == compare_fn(tgt_profile['transform'][:6])
+        assert exp_profile['transform'][:6] == pytest.approx(tgt_profile['transform'][:6], rel=1e-9)
+    else:
+        assert exp_profile['transform'][:6] ==  pytest.approx(tgt_profile['transform'][:6], rel=0.05)
 
     tgt_bounds = windows.bounds(
         windows.Window(0, 0, tgt_profile['width'], tgt_profile['height']), tgt_profile['transform']
@@ -122,7 +135,10 @@ def _test_export_profile(exp_profile, tgt_profile, crs_transform=False):
     exp_bounds = windows.bounds(
         windows.Window(0, 0, exp_profile['width'], exp_profile['height']), exp_profile['transform']
     )
-    assert exp_bounds == compare_fn(tgt_bounds)
+    if transform_shape:
+        assert exp_bounds == pytest.approx(tgt_bounds, rel=1e-9)
+    else:
+        assert exp_bounds == pytest.approx(tgt_bounds, rel=0.05)
 
 
 def test_id_name(user_base_image: BaseImage, s2_sr_base_image: BaseImage):
@@ -254,22 +270,23 @@ def test_prepare_no_fixed_projection(user_base_image: BaseImage, params: Dict, r
 
 
 @pytest.mark.parametrize(
-    'params', [
-        dict(),
-        dict(crs='EPSG:4326'),
-        dict(crs='EPSG:4326', scale=100),
-        dict(crs='EPSG:4326', crs_transform=Affine.identity()),
-        dict(crs='EPSG:4326', shape=(100, 100)),
-        dict(crs_transform=Affine.identity(), shape=(100, 100)),
+    'base_image, params', [
+        ('user_fix_base_image', dict()),
+        ('modis_nbar_base_image_unbnd', dict(crs='EPSG:4326')),
+        ('modis_nbar_base_image_unbnd', dict(crs='EPSG:4326', scale=100)),
+        ('modis_nbar_base_image_unbnd', dict(crs='EPSG:4326', crs_transform=Affine.identity())),
+        ('modis_nbar_base_image_unbnd', dict(crs='EPSG:4326', shape=(100, 100))),
+        ('modis_nbar_base_image_unbnd', dict(crs_transform=Affine.identity(), shape=(100, 100))),
     ]
 )  # yapf: disable
-def test_prepare_unbounded(user_fix_base_image: BaseImage, params: Dict):
+def test_prepare_unbounded(base_image: BaseImage, params: Dict, request: pytest.FixtureRequest):
     """
     Test BaseImage._prepare_for_export raises an exception when the image is unbounded, and insufficient bounds
     defining parameters are specified.
     """
+    base_image: BaseImage = request.getfixturevalue(base_image)
     with pytest.raises(ValueError) as ex:
-        user_fix_base_image._prepare_for_export(**params)
+        base_image._prepare_for_export(**params)
     assert 'This image is unbounded' in str(ex)
 
 
@@ -278,9 +295,6 @@ def test_prepare_exceptions(user_base_image: BaseImage, user_fix_base_image: Bas
     with pytest.raises(ValueError):
         # EPSG:4326 and no scale
         user_fix_base_image._prepare_for_export(region=region_25ha)
-    with pytest.raises(ValueError):
-        # export in 'SR-ORG:6974'
-        user_fix_base_image._prepare_for_export(region=region_25ha, crs='SR-ORG:6974', scale=30)
     with pytest.raises(ValueError):
         # no fixed projection and resample
         user_base_image._prepare_for_export(
@@ -289,7 +303,20 @@ def test_prepare_exceptions(user_base_image: BaseImage, user_fix_base_image: Bas
 
 
 @pytest.mark.parametrize(
-    'src_image, tgt_image', [
+    'base_image', ['s2_sr_base_image', 'l9_base_image', 'modis_nbar_base_image'],
+)
+def test_prepare_defaults(base_image: str, request: pytest.FixtureRequest):
+    """ Test BaseImage._prepare_for_export() with no (i.e. default) arguments with bounded images. """
+    base_image: BaseImage = request.getfixturevalue(base_image)
+    exp_image = base_image._prepare_for_export()
+
+    tgt_profile = base_image.profile
+    _test_export_profile(exp_image.profile, tgt_profile, True)
+    assert exp_image.scale == base_image.scale
+
+
+@pytest.mark.parametrize(
+    'base_image, param_image', [
         ('s2_sr_base_image', 's2_sr_base_image'),
         ('l9_base_image', 's2_sr_base_image'),
         ('modis_nbar_base_image', 's2_sr_base_image'),
@@ -305,59 +332,89 @@ def test_prepare_exceptions(user_base_image: BaseImage, user_fix_base_image: Bas
         ('modis_nbar_base_image', 'modis_nbar_base_image'),
         ('user_base_image', 'modis_nbar_base_image'),
         ('user_fix_base_image', 'modis_nbar_base_image'),
-        # include the below 2 cases to test where export crs & scale match those of the encapsulated image,
-        # and export region is different to the encapsulated footprint
-        ('l9_base_image', 'l8_base_image'),
-        ('l8_base_image', 'l9_base_image'),
-    ]
-)  # yapf: disable
-def test_prepare_for_export(src_image: str, tgt_image: str, request: pytest.FixtureRequest):
-    """ Test BaseImage._prepare_for_export() generates an export image with the target properties. """
-    src_image: BaseImage = request.getfixturevalue(src_image)
-    tgt_image: BaseImage = request.getfixturevalue(tgt_image)
-    if tgt_image == src_image:
-        exp_params_list = [dict()] # test default arguments so crs_transform & shape are set to those of src_image
-    else:
-        exp_params_list = [
-            dict(crs=tgt_image.crs, crs_transform=tgt_image.transform, shape=tgt_image.shape, dtype=tgt_image.dtype),
-            dict(crs=tgt_image.crs, scale=tgt_image.scale, region=tgt_image.footprint, dtype=tgt_image.dtype)
-        ]
-    for exp_params in exp_params_list:
-        crs_transform = 'crs_transform' in exp_params or src_image == tgt_image
-        exp_image = src_image._prepare_for_export(**exp_params)
+    ]  # yapf: disable
+)
+def test_prepare_transform_shape(base_image: str, param_image: str, request: pytest.FixtureRequest):
+    """ Test BaseImage._prepare_for_export() with crs_transform and shape parameters. """
+    base_image: BaseImage = request.getfixturevalue(base_image)
+    param_image: BaseImage = request.getfixturevalue(param_image)
+
+    exp_params = dict(
+        crs=param_image.crs, crs_transform=param_image.transform, shape=param_image.shape, dtype=param_image.dtype
+    )
+    exp_image = base_image._prepare_for_export(**exp_params)
 
-        tgt_profile = tgt_image.profile
-        tgt_profile.update(count=src_image.count)
-        _test_export_profile(exp_image.profile, tgt_profile, crs_transform)
-        assert exp_image.scale == tgt_image.scale
+    tgt_profile = param_image.profile
+    tgt_profile.update(count=base_image.count)
+
+    _test_export_profile(exp_image.profile, tgt_profile, True)
+    assert exp_image.scale == param_image.scale
 
 
 @pytest.mark.parametrize(
-    'src_image, tgt_image', [
+    'base_image, param_image', [
         ('s2_sr_base_image', 's2_sr_base_image'),
+        ('l9_base_image', 's2_sr_base_image'),
+        ('modis_nbar_base_image', 's2_sr_base_image'),
         ('user_base_image', 's2_sr_base_image'),
+        ('user_fix_base_image', 's2_sr_base_image'),
+        ('l9_base_image', 'l9_base_image'),
+        ('s2_sr_base_image', 'l9_base_image'),
+        ('modis_nbar_base_image', 'l9_base_image'),
+        ('user_base_image', 'l9_base_image'),
+        ('user_fix_base_image', 'l9_base_image'),
+        ('l9_base_image', 'modis_nbar_base_image'),
+        ('s2_sr_base_image', 'modis_nbar_base_image'),
+        ('modis_nbar_base_image', 'modis_nbar_base_image'),
+        ('user_base_image', 'modis_nbar_base_image'),
+        ('user_fix_base_image', 'modis_nbar_base_image'),
     ]
+)  # yapf: disable
+def test_prepare_region_scale(base_image: str, param_image: str, region_25ha: dict, request: pytest.FixtureRequest):
+    """ Test BaseImage._prepare_for_export() with region and scale parameters. """
+    base_image: BaseImage = request.getfixturevalue(base_image)
+    _param_image: BaseImage = request.getfixturevalue(param_image)
+    param_image = copy.deepcopy(_param_image)   # avoid changing session fixture
+    # clip param_image, so that param_image.transform can be tested against below
+    param_image.ee_image = param_image.ee_image.clip(region_25ha)
+
+    exp_params = dict(
+        crs=param_image.crs, region=param_image.footprint, scale=param_image.scale, dtype=param_image.dtype
+    )
+    exp_image = base_image._prepare_for_export(**exp_params)
+
+    tgt_profile = param_image.profile
+    tgt_profile.update(count=base_image.count)
+
+    _test_export_profile(exp_image.profile, tgt_profile, base_image.id == param_image.id)
+    assert exp_image.scale == param_image.scale
+
+    # test export bounds contain target bounds
+    exp_bounds = features.bounds(exp_image.footprint)
+    tgt_bounds = features.bounds(param_image.footprint)
+    tgt_footprint_crs = (
+        param_image.footprint['crs']['properties']['name'] if 'crs' in param_image.footprint else 'EPSG:4326'
+    )
+    exp_crs = CRS.from_string(utils.rio_crs(exp_image.crs))
+    tgt_bounds = warp.transform_bounds(tgt_footprint_crs, exp_crs, *tgt_bounds)
+    assert (
+        (exp_bounds[0] <= tgt_bounds[0]) and (exp_bounds[1] <= tgt_bounds[1]) and
+        (exp_bounds[2] >= tgt_bounds[2]) and (exp_bounds[3] >= tgt_bounds[3])
+    )
+
+
+@pytest.mark.parametrize(
+    'base_image', ['s2_sr_base_image', 'l9_base_image', 'modis_nbar_base_image'],
 )
 def test_prepare_for_download(
-    src_image: str, tgt_image: str, region_25ha: Dict, request: pytest.FixtureRequest
+    base_image: str, request: pytest.FixtureRequest
 ):
     """ Test BaseImage._prepare_for_download() sets rasterio profile as expected.  """
-    src_image: BaseImage = request.getfixturevalue(src_image)
-    tgt_image: BaseImage = request.getfixturevalue(tgt_image)
-    if tgt_image == src_image:
-        exp_param_list = [dict()]
-    else:
-        exp_param_list = [
-            dict(crs=tgt_image.crs, crs_transform=tgt_image.transform, shape=tgt_image.shape, dtype=tgt_image.dtype),
-            dict(crs=tgt_image.crs, scale=tgt_image.scale, region=tgt_image.footprint, dtype=tgt_image.dtype),
-        ]
-
-    for exp_params in exp_param_list:
-        exp_image, exp_profile = src_image._prepare_for_download(**exp_params)
-        tgt_profile = tgt_image.profile
-        tgt_profile.update(count=src_image.count)
-        _test_export_profile(exp_profile, tgt_profile, ('crs_transform' in exp_params) or (tgt_image == src_image))
-        assert exp_profile['nodata'] is not None
+    base_image: BaseImage = request.getfixturevalue(base_image)
+    exp_image, exp_profile = base_image._prepare_for_download()
+    tgt_profile = base_image.profile
+    _test_export_profile(exp_profile, tgt_profile, True)
+    assert exp_profile['nodata'] is not None
 
 
 @pytest.mark.parametrize(
@@ -464,8 +521,30 @@ def test_tiles(image_shape: Tuple, tile_shape: Tuple, image_transform: Affine):
     assert (accum_window.height, accum_window.width) == exp_image.shape
 
 
-def test_download(user_base_image: str, tmp_path: pathlib.Path, request: pytest.FixtureRequest):
-    """ Test download file properties and pixel data. """
+def test_download_transform_shape(user_base_image: str, tmp_path: pathlib.Path, request: pytest.FixtureRequest):
+    """ Test download file properties and pixel data with crs_transform and shape arguments. """
+    tgt_prof = dict(crs='EPSG:3857', transform=Affine(1, 0, 0, 0, -1, 0), width=10, height=10, dtype='uint16', count=3)
+
+    # form download parameters from tgt_prof
+    shape = (tgt_prof['height'], tgt_prof['width'])
+    tgt_bounds = windows.bounds(
+        windows.Window(0, 0, *shape[::-1]), tgt_prof['transform']
+    )
+    download_params = dict(
+        crs=tgt_prof['crs'], crs_transform=tgt_prof['transform'], shape=shape, dtype=tgt_prof['dtype']
+    )
+    filename = tmp_path.joinpath('test.tif')
+    user_base_image.download(filename, **download_params)
+    assert filename.exists()
+    with rio.open(filename, 'r') as ds:
+        _test_export_profile(ds.profile, tgt_prof, 'crs_transform' in download_params)
+        array = ds.read()
+        for i in range(ds.count):
+            assert np.all(array[i] == i + 1)
+
+
+def test_download_region_scale(user_base_image: str, tmp_path: pathlib.Path, request: pytest.FixtureRequest):
+    """ Test download file properties and pixel data with region and scale arguments. """
     tgt_prof = dict(crs='EPSG:3857', transform=Affine(1, 0, 0, 0, -1, 0), width=10, height=10, dtype='uint16', count=3)
 
     # form download parameters from tgt_prof
@@ -474,19 +553,15 @@ def test_download(user_base_image: str, tmp_path: pathlib.Path, request: pytest.
         windows.Window(0, 0, *shape[::-1]), tgt_prof['transform']
     )
     region = bounds_polygon(*tgt_bounds, crs=tgt_prof['crs'])
-    download_params_list = [
-        dict(crs=tgt_prof['crs'], region=region, scale=tgt_prof['transform'][0], dtype=tgt_prof['dtype']),
-        dict(crs=tgt_prof['crs'], crs_transform=tgt_prof['transform'], shape=shape, dtype=tgt_prof['dtype'])
-    ]
-    filename_list = [tmp_path.joinpath('test1.tif'), tmp_path.joinpath('test2.tif')]
-    for filename, download_params in zip(filename_list, download_params_list):
-        user_base_image.download(filename, **download_params)
-        assert filename.exists()
-        with rio.open(filename, 'r') as ds:
-            _test_export_profile(ds.profile, tgt_prof, 'crs_transform' in download_params)
-            array = ds.read()
-            for i in range(ds.count):
-                assert np.all(array[i] == i + 1)
+    download_params = dict(crs=tgt_prof['crs'], region=region, scale=tgt_prof['transform'][0], dtype=tgt_prof['dtype'])
+    filename = tmp_path.joinpath('test.tif')
+    user_base_image.download(filename, **download_params)
+    assert filename.exists()
+    with rio.open(filename, 'r') as ds:
+        _test_export_profile(ds.profile, tgt_prof, 'crs_transform' in download_params)
+        array = ds.read()
+        for i in range(ds.count):
+            assert np.all(array[i] == i + 1)
 
 
 def test_overviews(user_base_image: BaseImage, region_25ha: Dict, tmp_path: pathlib.Path):
@@ -535,6 +610,44 @@ def test_prepare_ee_geom(l9_base_image: BaseImage, tmp_path: pathlib.Path):
     exp_image = l9_base_image._prepare_for_export(region=region)
     assert exp_image.scale == l9_base_image.scale
 
+
+@pytest.mark.parametrize(
+    'base_image, exp_value', [
+        ('s2_sr_base_image', True),
+        ('l9_base_image', True),
+        ('modis_nbar_base_image_unbnd', False),
+        ('user_base_image', False),
+        ('user_fix_base_image', False),
+    ]
+)  # yapf: disable
+def test_bounded(base_image: str, exp_value: bool, request: pytest.FixtureRequest):
+    """ Test BaseImage.bounded has correct value for different images. """
+    base_image: BaseImage = request.getfixturevalue(base_image)
+    assert base_image.bounded == exp_value
+
+
+# @pytest.mark.parametrize(
+#     'crs', [None, 'SR-ORG:6974',]
+# )  # yapf: disable
+# def test_modis_prepare_for_export(modis_nbar_base_image_unbnd, region_100ha, crs):
+#     """ Test BaseImage._prepare_for_export with workaround for MODIS in its native crs=SR-ORG:6974. """
+#     base_image: BaseImage = modis_nbar_base_image_unbnd
+#     tgt_profile = base_image.profile
+#     tgt_profile.update(crs='SR-ORG:6974', region=region_100ha, dtype=base_image.dtype)
+#     exp_image = base_image._prepare_for_export(crs=crs, region=region_100ha)
+#     _test_export_profile(exp_image.profile, tgt_profile, False)
+#     assert exp_image.scale == base_image.scale
+
+# def test_modis_crs_error(modis_nbar_base_image_unbnd, region_25ha):
+#     """ Test BaseImage._prepare_for_export raises an error when exporting in crs=SR-ORG:6974. """
+#     base_image = modis_nbar_base_image_unbnd
+#     with pytest.raises(ValueError) as ex:
+#         base_image.export('test_modis_crs_error', region=region_25ha)
+#     assert 'SR-ORG:6974' in str(ex)
+#     with pytest.raises(ValueError) as ex:
+#         base_image.export('test_modis_crs_error', region=region_25ha, crs='SR-ORG:6974')
+#     assert 'SR-ORG:6974' in str(ex)
+
 # TODO:
 # - export(): test an export of small file
 # - different generic collection images are downloaded ok (perhaps this goes with MaskedImage more than BaseImage)