Repository: natcap/invest (python)

Issue:
Urban Stormwater: soil raster as float type causes index error
A user on the [forum](https://community.naturalcapitalproject.org/t/urban-stormwater-retention-model-indexerror/3052/2) was encountering an interesting bug:

```python
2022-09-15 13:30:43,132 (pygeoprocessing.geoprocessing) geoprocessing.raster_calculator(498) ERROR exception encountered in raster_calculator
Traceback (most recent call last):
  File "pygeoprocessing\geoprocessing.py", line 443, in raster_calculator
  File "natcap\invest\stormwater.py", line 671, in ratio_op
IndexError: arrays used as indices must be of integer (or boolean) type
2022-09-15 13:30:43,133 (pygeoprocessing.geoprocessing) geoprocessing.raster_calculator(513) INFO Waiting for raster stats worker result.
2022-09-15 13:30:43,134 (pygeoprocessing.geoprocessing_core) threading.run(946) WARNING No valid pixels were received, sending None.
2022-09-15 13:30:43,134 (taskgraph.Task) Task.add_task(706) ERROR Something went wrong when adding task calculate stormwater retention ratio (1), terminating taskgraph.
Traceback (most recent call last):
  File "taskgraph\Task.py", line 674, in add_task
  File "taskgraph\Task.py", line 1093, in _call
  File "natcap\invest\stormwater.py", line 674, in lookup_ratios
  File "pygeoprocessing\geoprocessing.py", line 443, in raster_calculator
  File "natcap\invest\stormwater.py", line 671, in ratio_op
IndexError: arrays used as indices must be of integer (or boolean) type
```

This occurs in: https://github.com/natcap/invest/blob/41070ee205506d8000f14ba2d81a53567d1882b7/src/natcap/invest/stormwater.py#L658 

During a numpy array indexing call the soil array is being used as an index, but it has a floating point types, which causes the error.

The solution here could be to just handle floating point soil inputs or to validate upfront that the soil raster is of integer type.

Pull request:
Validation and test for stormwater - noninteger soils data
## Description
Fixes #1084 

## Checklist
- [x] Updated HISTORY.rst and link to any relevant issue (if these changes are user-facing)
- [ ] ~Updated the user's guide (if needed)~
- [ ] ~Tested the affected models' UIs (if relevant)~


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 320cacbfc..ca637327a 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -64,6 +64,9 @@ Unreleased Changes
       flag, leading to a perceived buffering of spatial criteria in certain
       cases.  In InVEST 3.9.0, these were rasterized with ``ALL_TOUCHED=FALSE``.
       https://github.com/natcap/invest/issues/1120
+* Urban Stormwater Retention
+    * Added validation to check that the input soil groups raster has an
+      integer data type
 * Urban Cooling
     * Updated the text for the ``building_intensity`` column in the biophysical
       table to clarify that the values of this column should be normalized
diff --git a/src/natcap/invest/stormwater.py b/src/natcap/invest/stormwater.py
index cf253e917..34becf5ef 100644
--- a/src/natcap/invest/stormwater.py
+++ b/src/natcap/invest/stormwater.py
@@ -23,6 +23,7 @@ LOGGER = logging.getLogger(__name__)
 FLOAT_NODATA = -1
 UINT8_NODATA = 255
 UINT16_NODATA = 65535
+NONINTEGER_SOILS_RASTER_MESSAGE = 'Soil group raster data type must be integer'
 
 ARGS_SPEC = {
     "model_name": MODEL_METADATA["stormwater"].model_title,
@@ -1059,5 +1060,14 @@ def validate(args, limit_to=None):
             the error message in the second part of the tuple. This should
             be an empty list if validation succeeds.
     """
-    return validation.validate(args, ARGS_SPEC['args'],
+    validation_warnings = validation.validate(args, ARGS_SPEC['args'],
                                ARGS_SPEC['args_with_spatial_overlap'])
+    invalid_keys = validation.get_invalid_keys(validation_warnings)
+    if 'soil_group_path' not in invalid_keys:
+        # check that soil group raster has integer type
+        soil_group_dtype = pygeoprocessing.get_raster_info(
+            args['soil_group_path'])['numpy_type']
+        if not numpy.issubdtype(soil_group_dtype, numpy.integer):
+            validation_warnings.append(
+                (['soil_group_path'], NONINTEGER_SOILS_RASTER_MESSAGE))
+    return validation_warnings


Test patch:
diff --git a/tests/test_stormwater.py b/tests/test_stormwater.py
index 7c8c265b2..ba5b2b973 100644
--- a/tests/test_stormwater.py
+++ b/tests/test_stormwater.py
@@ -798,6 +798,33 @@ class StormwaterTests(unittest.TestCase):
             if arg_list[0] in ['retention_radius', 'road_centerlines_path']:
                 self.assertEqual(message, validation.MESSAGES['MISSING_VALUE'])
 
+    def test_validate_noninteger_soil_raster(self):
+        """Stormwater: test arg validation."""
+        from natcap.invest import stormwater, validation
+
+        soil_array = numpy.array([[1, 2], [3, 4]], dtype=numpy.float32)
+        soil_path = os.path.join(self.workspace_dir, 'soils.tif')
+        to_raster(soil_array, soil_path, pixel_size=(10, -10))
+
+        args = {
+            'workspace_dir': self.workspace_dir,
+            'lulc_path': 'x',
+            'soil_group_path': soil_path,
+            'precipitation_path': 'x',
+            'biophysical_table': 'x',
+            'adjust_retention_ratios': True,
+            'retention_radius': None,
+            'road_centerlines_path': None,
+            'aggregate_areas_path': None,
+            'replacement_cost': None
+        }
+        messages = stormwater.validate(args)
+        messages = {tuple(k): v for k, v in messages}
+        self.assertIn(('soil_group_path',), messages)
+        self.assertEqual(
+            messages[('soil_group_path',)],
+            stormwater.NONINTEGER_SOILS_RASTER_MESSAGE)
+
     def test_lulc_signed_byte(self):
         """Stormwater: regression test for handling signed byte LULC input."""
         from natcap.invest import stormwater