Repository: natcap/invest (python)

Issue:
`RuntimeError` in validation `check_spatial_overlap`
A user reported this, I don't have the data to reproduce it yet. 
https://community.naturalcapitalproject.org/t/seasonal-water-yield-not-working-no-output-files-generated-previously-used-crop-pollination-successfully/5888

```python
[11:30:35.473] [undefined] 03/25/2025 11:30:35  natcap.invest.ui_server ERROR    Exception on /api/validate [POST]
Traceback (most recent call last):
  File "flask/app.py", line 1511, in wsgi_app
  File "flask/app.py", line 919, in full_dispatch_request
  File "flask_cors/extension.py", line 176, in wrapped_function
  File "flask/app.py", line 917, in full_dispatch_request
  File "flask/app.py", line 902, in dispatch_request
  File "natcap/invest/ui_server.py", line 105, in get_invest_validate
  File "natcap/invest/validation.py", line 1146, in _wrapped_validate_func
  File "natcap/invest/seasonal_water_yield/seasonal_water_yield.py", line 1421, in validate
  File "natcap/invest/validation.py", line 1070, in validate
  File "natcap/invest/validation.py", line 808, in check_spatial_overlap
  File "src/pygeoprocessing/geoprocessing_core.pyx", line 77, in pygeoprocessing.geoprocessing_core.gdal_use_exceptions.wrapper
  File "src/pygeoprocessing/geoprocessing_core.pyx", line 78, in pygeoprocessing.geoprocessing_core.gdal_use_exceptions.wrapper
  File "pygeoprocessing/geoprocessing.py", line 3594, in transform_bounding_box
  File "pygeoprocessing/geoprocessing.py", line 3574, in _transform_point
  File "osgeo/osr.py", line 2112, in TransformPoint
RuntimeError: Point outside of projection domain
```

Perhaps we should catch this one within `validation` and then return a validation message. That's the normal pattern for validation. In this specific case of `check_spatial_overlap`, one validation messages is associated with multiple args, which is not ideal for this error.

Also, we should consider catching exceptions like this at the flask app layer as well, for the benefit of Workbench users. See also #1610 

Pull request description:
Handle validation runtime error in `check_spatial_overlap`
When a model allows inputs to be in different projections, `check_spatial_overlap` tries to transform all input layer bounding boxes to EPSG:4326 in order to test that they all overlap. `transform_bounding_box` can raise `ValueError` or `RuntimeError` if a bounding box cannot be transformed. We need to handle those exceptions so that the process running validation does not crash.

I think it makes sense to skip the overlap check for the layer when this occurs, rather than issue a validation error. That matches the current behavior of `check_spatial_overlap` where there's already another case where a layer can be skipped. We don't want to prevent people from a running a model unless we're 100% certain the data is invalid.

Fixes #1849 

## Checklist
- [x] Updated HISTORY.rst and link to any relevant issue (if these changes are user-facing)
- [ ] Updated the user's guide (if needed)
- [x] Tested the Workbench UI (if relevant)


Patch:
diff --git a/.github/workflows/release-part-2.yml b/.github/workflows/release-part-2.yml
index 0d8ea240d..ad394dc37 100644
--- a/.github/workflows/release-part-2.yml
+++ b/.github/workflows/release-part-2.yml
@@ -107,7 +107,7 @@ jobs:
           twine upload \
             --username="__token__" \
             --password=${{ secrets.PYPI_NATCAP_INVEST_TOKEN }} \
-            artifacts/natcap_invest*
+            artifacts/natcap.invest* artifacts/natcap_invest*
 
       - name: Roll back on failure
         if: failure()
diff --git a/HISTORY.rst b/HISTORY.rst
index 4e196e441..7fee4b929 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -65,6 +65,12 @@
   Unreleased Changes
   ------------------
 
+General
+=======
+* Handle exceptions that can arise during ``validation.check_spatial_overlap``
+  when a layer's bounding box cannot be transformed to EPSG:4326.
+  (`#1849 <https://github.com/natcap/invest/issues/1849>`_).
+
 Workbench
 =========
 * Fixed a bug that did not allow users to select a folder as the location
diff --git a/src/natcap/invest/validation.py b/src/natcap/invest/validation.py
index a04014837..c1dacd787 100644
--- a/src/natcap/invest/validation.py
+++ b/src/natcap/invest/validation.py
@@ -805,14 +805,23 @@ def check_spatial_overlap(spatial_filepaths_list,
             return MESSAGES['NO_PROJECTION'].format(filepath=filepath)
 
         if different_projections_ok:
-            bounding_box = pygeoprocessing.transform_bounding_box(
-                info['bounding_box'], info['projection_wkt'], wgs84_wkt)
+            try:
+                bounding_box = pygeoprocessing.transform_bounding_box(
+                    info['bounding_box'], info['projection_wkt'], wgs84_wkt)
+            except (ValueError, RuntimeError) as err:
+                LOGGER.debug(err)
+                LOGGER.warning(
+                    f'Skipping spatial overlap check for {filepath}. '
+                    'Bounding box cannot be transformed to EPSG:4326')
+                continue
+
         else:
             bounding_box = info['bounding_box']
 
         if all([numpy.isinf(coord) for coord in bounding_box]):
             LOGGER.warning(
-                'Skipping infinite bounding box for file %s', filepath)
+                f'Skipping spatial overlap check for {filepath} '
+                f'because of infinite bounding box {bounding_box}')
             continue
 
         bounding_boxes.append(bounding_box)


Test patch:
diff --git a/tests/test_validation.py b/tests/test_validation.py
index ecb674f9c..4fe39e75f 100644
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -107,6 +107,36 @@ class SpatialOverlapTest(unittest.TestCase):
         expected = validation.MESSAGES['NO_PROJECTION'].format(filepath=filepath_2)
         self.assertEqual(error_msg, expected)
 
+    def test_check_overlap_unable_to_transform(self):
+        """Validation: check overlap when layer cannot transform to EPSG:4326."""
+        from natcap.invest import validation
+
+        driver = gdal.GetDriverByName('GTiff')
+        filepath_1 = os.path.join(self.workspace_dir, 'raster_1.tif')
+        filepath_2 = os.path.join(self.workspace_dir, 'raster_2.tif')
+
+        raster_1 = driver.Create(filepath_1, 3, 3, 1, gdal.GDT_Int32)
+        wgs84_srs = osr.SpatialReference()
+        wgs84_srs.ImportFromEPSG(4326)
+        raster_1.SetProjection(wgs84_srs.ExportToWkt())
+        raster_1.SetGeoTransform([1, 1, 0, 1, 0, 1])
+        raster_1 = None
+
+        # set up a raster with an outside-the-globe extent
+        raster_2 = driver.Create(filepath_2, 3, 3, 1, gdal.GDT_Int32)
+        eckert_srs = osr.SpatialReference()
+        proj4str = '+proj=eck4 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs +type=crs'
+        eckert_srs.ImportFromProj4(proj4str)
+        raster_2.SetProjection(eckert_srs.ExportToWkt())
+        raster_2.SetGeoTransform(
+            (-10000000.0, 19531.25, 0.0, 15000000.0, 0.0, -4882.8125))
+        raster_2 = None
+
+        # The improper raster should be skipped by validation, thus no errors
+        error_msg = validation.check_spatial_overlap(
+            [filepath_1, filepath_2], different_projections_ok=True)
+        self.assertEqual(None, error_msg)
+
     @unittest.skip("skipping due to unresolved projection comparison question")
     def test_different_projections_not_ok(self):
         """Validation: different projections not allowed by default.