Repository: natcap/invest (python)

Issue:
Validation: check spatial overlap on layer with no projection
If a layer's projection wkt is `None`, `check_spatial_overlap` errors like this:

```
Traceback (most recent call last):
  File "site-packages\natcap\invest\ui\model.py", line 1649, in _logged_target
  File "site-packages\natcap\invest\coastal_vulnerability.py", line 286, in execute
  File "site-packages\natcap\invest\validation.py", line 916, in _wrapped_validate_func
  File "site-packages\natcap\invest\coastal_vulnerability.py", line 2952, in validate
  File "site-packages\natcap\invest\validation.py", line 843, in validate
  File "site-packages\natcap\invest\validation.py", line 580, in check_spatial_overlap
  File "site-packages\pygeoprocessing\geoprocessing.py", line 2878, in transform_bounding_box
  File "site-packages\osgeo\osr.py", line 891, in ImportFromWkt
TypeError: not a string
```

Validation does check if the SRS is `None` during the `check_vector` and `check_raster` steps. They append a validation message when SRS is None, but validation continues to `check_spatial_overlap`. And since that func raises an uncaught error, we never get to see the validation message reported from `check_vector`.

If this happens in the UI, I think the model is allowed to run because no validation messages are returned. CV caught this issue because it happens to call `validate` during `execute` (questionable but useful here!). 


Pull request:
Make check_spatial_overlap work with undefined projection
# Description
Fixes #519 
The error is only raised when `different_projections_ok=True`, because that triggers the call to `transform_bounding_box`, which expects a projection. I added a conditional before that step that returns a message if the file has no projection.

Still not explicitly checking that the projections are the same because of the issue described in #558.

# Checklist
- [x] Updated HISTORY.rst (if these changes are user-facing)

~~- [ ] Updated the user's guide (if needed)~~


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index c68329381..db1634b95 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -67,6 +67,8 @@ Unreleased Changes (3.9.1)
       Coastal Blue Carbon, SDR, DelineateIt, and Seasonal Water Yield models.
       These models will no longer attempt to copy intermediate artifacts that
       could have been computed by previous runs.
+    * Validation now returns a more helpful message when a spatial input has
+      no projection defined.
 * Carbon
     * Fixed a bug where, if rate change and discount rate were set to 0, the
       valuation results were in $/year rather than $, too small by a factor of
diff --git a/src/natcap/invest/validation.py b/src/natcap/invest/validation.py
index 3e21baf56..859444712 100644
--- a/src/natcap/invest/validation.py
+++ b/src/natcap/invest/validation.py
@@ -258,7 +258,8 @@ def _check_projection(srs, projected, projection_units):
         A string error message if an error was found.  ``None`` otherwise.
 
     """
-    if srs is None:
+    empty_srs = osr.SpatialReference()
+    if srs is None or srs.IsSame(empty_srs):
         return "Dataset must have a valid projection."
 
     if projected:
@@ -575,11 +576,15 @@ def check_spatial_overlap(spatial_filepaths_list,
             info = pygeoprocessing.get_raster_info(filepath)
         except ValueError:
             info = pygeoprocessing.get_vector_info(filepath)
-        bounding_box = info['bounding_box']
+
+        if info['projection_wkt'] is None:
+            return f'Spatial file {filepath} has no projection'
 
         if different_projections_ok:
             bounding_box = pygeoprocessing.transform_bounding_box(
-                bounding_box, info['projection_wkt'], wgs84_wkt)
+                info['bounding_box'], info['projection_wkt'], wgs84_wkt)
+        else:
+            bounding_box = info['bounding_box']
 
         if all([numpy.isinf(coord) for coord in bounding_box]):
             LOGGER.warning(
@@ -620,6 +625,7 @@ def timeout(func, *args, timeout=5, **kwargs):
     # use a queue to share the return value from the file checking thread
     # the target function puts the return value from `func` into shared memory
     message_queue = queue.Queue()
+
     def wrapper_func():
         message_queue.put(func(*args, **kwargs))
 
@@ -631,8 +637,8 @@ def timeout(func, *args, timeout=5, **kwargs):
     if thread.is_alive():
         # first arg to `check_csv`, `check_raster`, `check_vector` is the path
         warnings.warn(f'Validation of file {args[0]} timed out. If this file '
-            'is stored in a file streaming service, it may be taking a long '
-            'time to download. Try storing it locally instead.')
+                      'is stored in a file streaming service, it may be taking a long '
+                      'time to download. Try storing it locally instead.')
         return None
 
     else:
@@ -873,7 +879,7 @@ def invest_validator(validate_func):
         AssertionError when an invalid format is found.
 
     Example::
-    
+
         from natcap.invest import validation
         @validation.invest_validator
         def validate(args, limit_to=None):
@@ -906,7 +912,7 @@ def invest_validator(validate_func):
             model_module = importlib.import_module(validate_func.__module__)
         except:
             LOGGER.warning('Unable to import module %s: assuming no ARGS_SPEC.',
-                            validate_func.__module__)
+                           validate_func.__module__)
             model_module = None
 
         # If the module has an ARGS_SPEC defined, validate against that.
@@ -943,7 +949,6 @@ def invest_validator(validate_func):
                     input_type = args_key_spec['type']
                     validator_func = _VALIDATION_FUNCS[input_type]
 
-
                     try:
                         validation_options = (
                             args_key_spec['validation_options'])


Test patch:
diff --git a/tests/test_habitat_quality.py b/tests/test_habitat_quality.py
index c04aae349..1f77e6c1f 100644
--- a/tests/test_habitat_quality.py
+++ b/tests/test_habitat_quality.py
@@ -230,7 +230,7 @@ class HabitatQualityTests(unittest.TestCase):
         # Assert values were obtained by summing each output raster.
         for output_filename, assert_value in {
                 'deg_sum_c_regression.tif': 18.91135,
-                'deg_sum_f_regression.tif': 33.931896, 
+                'deg_sum_f_regression.tif': 33.931896,
                 'quality_c_regression.tif': 7499.983,
                 'quality_f_regression.tif': 4999.9893,
                 'rarity_c_regression.tif': 2500.0000000,
@@ -294,7 +294,7 @@ class HabitatQualityTests(unittest.TestCase):
         # Assert values were obtained by summing each output raster.
         for output_filename, assert_value in {
                 'deg_sum_c_regression.tif': 27.153614,
-                'deg_sum_f_regression.tif': 46.279358, 
+                'deg_sum_f_regression.tif': 46.279358,
                 'quality_c_regression.tif': 7499.9414,
                 'quality_f_regression.tif': 4999.955,
                 'rarity_c_regression.tif': 2500.0000000,
@@ -328,7 +328,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -355,7 +355,7 @@ class HabitatQualityTests(unittest.TestCase):
         # Assert values were obtained by summing each output raster.
         for output_filename, assert_value in {
                 'deg_sum_c_regression.tif': 27.153614,
-                'deg_sum_f_regression.tif': 46.279358, 
+                'deg_sum_f_regression.tif': 46.279358,
                 'quality_c_regression.tif': 7499.9414,
                 'quality_f_regression.tif': 4999.955,
                 'rarity_c_regression.tif': 2500.0000000,
@@ -376,7 +376,7 @@ class HabitatQualityTests(unittest.TestCase):
             'results_suffix': 'regression',
             'workspace_dir': self.workspace_dir,
             'n_workers': -1,
-            }
+        }
 
         args['access_vector_path'] = os.path.join(
             args['workspace_dir'], 'access_samp.shp')
@@ -417,7 +417,7 @@ class HabitatQualityTests(unittest.TestCase):
         # Assert values were obtained by summing each output raster.
         for output_filename, assert_value in {
                 'deg_sum_c_regression.tif': 27.153614,
-                'deg_sum_f_regression.tif': 46.279358, 
+                'deg_sum_f_regression.tif': 46.279358,
                 'quality_c_regression.tif': 7499.9414,
                 'quality_f_regression.tif': 4999.955,
                 'rarity_c_regression.tif': 2500.0000000,
@@ -451,7 +451,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -546,11 +546,11 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         with open(args['sensitivity_table_path'], 'w') as open_table:
             open_table.write(
-                    'LULC,NAME,HABITAT,%s,%s\n' % tuple(threatnames))
+                'LULC,NAME,HABITAT,%s,%s\n' % tuple(threatnames))
             open_table.write('1,"lulc 1",1,1,1\n')
             open_table.write('2,"lulc 2",0.5,0.5,1\n')
             open_table.write('3,"lulc 3",0,0.3,1\n')
@@ -560,7 +560,7 @@ class HabitatQualityTests(unittest.TestCase):
         # Assert values were obtained by summing each output raster.
         for output_filename, assert_value in {
                 'deg_sum_c_regression.tif': 27.153614,
-                'deg_sum_f_regression.tif': 46.279358, 
+                'deg_sum_f_regression.tif': 46.279358,
                 'quality_c_regression.tif': 7499.9414,
                 'quality_f_regression.tif': 4999.955,
                 'rarity_c_regression.tif': 2500.0000000,
@@ -664,7 +664,7 @@ class HabitatQualityTests(unittest.TestCase):
             'results_suffix': 'regression',
             'workspace_dir': self.workspace_dir,
             'n_workers': -1,
-            }
+        }
 
         args['access_vector_path'] = os.path.join(
             args['workspace_dir'], 'access_samp.shp')
@@ -712,7 +712,7 @@ class HabitatQualityTests(unittest.TestCase):
             'results_suffix': 'regression',
             'workspace_dir': self.workspace_dir,
             'n_workers': -1,
-            }
+        }
 
         args['access_vector_path'] = os.path.join(
             args['workspace_dir'], 'access_samp.shp')
@@ -814,7 +814,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -865,7 +865,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1053,7 +1053,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         make_threats_raster(args['workspace_dir'])
 
@@ -1102,7 +1102,7 @@ class HabitatQualityTests(unittest.TestCase):
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             args['lulc' + scenario + 'path'] = path
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
             # Add a nodata value to this raster to make sure we don't include
             # the nodata value in the error message.
@@ -1185,7 +1185,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1235,7 +1235,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1292,7 +1292,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1345,7 +1345,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1399,7 +1399,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1452,7 +1452,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1505,7 +1505,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1558,7 +1558,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1611,7 +1611,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1664,7 +1664,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1717,7 +1717,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1733,11 +1733,11 @@ class HabitatQualityTests(unittest.TestCase):
             for (i, threat), value in zip(
                     enumerate(threat_names), threat_values):
                 raster_path = os.path.join(
-                        args['workspace_dir'], threat + suffix + '.tif')
+                    args['workspace_dir'], threat + suffix + '.tif')
                 # making variations among threats
                 threat_array[100//(i+1):, :] = value
                 make_raster_from_array(
-                        threat_array, raster_path, nodata_val=-1)
+                    threat_array, raster_path, nodata_val=-1)
 
         # create threats table for the test
         args['threats_table_path'] = os.path.join(
@@ -1784,7 +1784,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -1800,11 +1800,11 @@ class HabitatQualityTests(unittest.TestCase):
             for (i, threat), value in zip(
                     enumerate(threat_names), threat_values):
                 raster_path = os.path.join(
-                        args['workspace_dir'], threat + suffix + '.tif')
+                    args['workspace_dir'], threat + suffix + '.tif')
                 # making variations among threats
                 threat_array[100//(i+1):, :] = value
                 make_raster_from_array(
-                        threat_array, raster_path, nodata_val=-1)
+                    threat_array, raster_path, nodata_val=-1)
 
         # create threats table for the test
         args['threats_table_path'] = os.path.join(
@@ -1854,11 +1854,11 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         # make future LULC with different origin
         args['lulc_fut_path'] = os.path.join(
-                args['workspace_dir'], 'lc_samp_fut_b.tif')
+            args['workspace_dir'], 'lc_samp_fut_b.tif')
         lulc_array = numpy.ones((100, 100), dtype=numpy.int8)
         lulc_array[50:, :] = 3
         srs = osr.SpatialReference()
@@ -1894,11 +1894,11 @@ class HabitatQualityTests(unittest.TestCase):
             for (i, threat), value in zip(
                     enumerate(threat_names), threat_values):
                 raster_path = os.path.join(
-                        args['workspace_dir'], threat + suffix + '.tif')
+                    args['workspace_dir'], threat + suffix + '.tif')
                 # making variations among threats
                 threat_array[100//(i+1):, :] = value
                 make_raster_from_array(
-                        threat_array, raster_path, nodata_val=-1)
+                    threat_array, raster_path, nodata_val=-1)
 
         # create threats table for the test
         args['threats_table_path'] = os.path.join(
@@ -1921,8 +1921,8 @@ class HabitatQualityTests(unittest.TestCase):
             "Bounding boxes do not intersect" in
             validate_result[0][1], validate_result[0][1])
 
-    def test_habitat_quality_argspec_projected(self):
-        """Habitat Quality: raise error on incorrect projection."""
+    def test_habitat_quality_argspec_missing_projection(self):
+        """Habitat Quality: raise error on missing projection."""
         from natcap.invest import habitat_quality
 
         args = {
@@ -1974,11 +1974,11 @@ class HabitatQualityTests(unittest.TestCase):
             for (i, threat), value in zip(
                     enumerate(threat_names), threat_values):
                 raster_path = os.path.join(
-                        args['workspace_dir'], threat + suffix + '.tif')
+                    args['workspace_dir'], threat + suffix + '.tif')
                 # making variations among threats
                 threat_array[100//(i+1):, :] = value
                 make_raster_from_array(
-                        threat_array, raster_path, nodata_val=-1)
+                    threat_array, raster_path, nodata_val=-1)
 
         # create threats table for the test
         args['threats_table_path'] = os.path.join(
@@ -1994,12 +1994,9 @@ class HabitatQualityTests(unittest.TestCase):
                 'threat_2_f.tif\n')
 
         validate_result = habitat_quality.validate(args)
-        self.assertTrue(
-            validate_result,
-            "expected failed validations instead didn't get any.")
-        self.assertTrue(
-            "Dataset must be projected in linear units" in
-            validate_result[0][1], validate_result[0][1])
+        expected = [
+            (['lulc_cur_path'], 'Dataset must have a valid projection.')]
+        self.assertEqual(validate_result, expected)
 
     def test_habitat_quality_argspec_missing_threat_header(self):
         """Habitat Quality: test validate for a threat header."""
@@ -2023,7 +2020,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -2074,7 +2071,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
@@ -2126,7 +2123,7 @@ class HabitatQualityTests(unittest.TestCase):
             args['lulc' + scenario + 'path'] = os.path.join(
                 args['workspace_dir'], 'lc_samp' + scenario + 'b.tif')
             make_raster_from_array(
-                    lulc_array, args['lulc' + scenario + 'path'])
+                lulc_array, args['lulc' + scenario + 'path'])
 
         args['sensitivity_table_path'] = os.path.join(
             args['workspace_dir'], 'sensitivity_samp.csv')
diff --git a/tests/test_validation.py b/tests/test_validation.py
index 70d3442cc..69966c1b7 100644
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -16,6 +16,7 @@ import pandas
 
 class SpatialOverlapTest(unittest.TestCase):
     """Test Spatial Overlap."""
+
     def setUp(self):
         """Create a new workspace to use for each test."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -24,8 +25,8 @@ class SpatialOverlapTest(unittest.TestCase):
         """Remove the workspace created for this test."""
         shutil.rmtree(self.workspace_dir)
 
-    def test_no_overlap_no_reference(self):
-        """Validation: verify lack of overlap without a reference."""
+    def test_no_overlap(self):
+        """Validation: verify lack of overlap."""
         from natcap.invest import validation
 
         driver = gdal.GetDriverByName('GTiff')
@@ -45,8 +46,8 @@ class SpatialOverlapTest(unittest.TestCase):
         error_msg = validation.check_spatial_overlap([filepath_1, filepath_2])
         self.assertTrue('Bounding boxes do not intersect' in error_msg)
 
-    def test_overlap_no_reference(self):
-        """Validation: verify overlap without a reference."""
+    def test_overlap(self):
+        """Validation: verify overlap."""
         from natcap.invest import validation
 
         driver = gdal.GetDriverByName('GTiff')
@@ -66,47 +67,71 @@ class SpatialOverlapTest(unittest.TestCase):
         self.assertEqual(
             None, validation.check_spatial_overlap([filepath_1, filepath_2]))
 
-    def test_no_overlap_with_reference(self):
-        """Validation: verify lack of overlap given reference projection."""
+    def test_check_overlap_undefined_projection(self):
+        """Validation: check overlap of raster with an undefined projection."""
         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')
-        reference_filepath = os.path.join(self.workspace_dir, 'reference.gpkg')
 
-        # Filepaths 1 and 2 are obviously outside of UTM zone 31N.
-        for filepath, geotransform, epsg_code in (
+        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 undefined projection
+        raster_2 = driver.Create(filepath_2, 3, 3, 1, gdal.GDT_Int32)
+        raster_2.SetGeoTransform([2, 1, 0, 2, 0, 1])
+        raster_2 = None
+
+        error_msg = validation.check_spatial_overlap(
+            [filepath_1, filepath_2], different_projections_ok=True)
+        expected = f'Spatial file {filepath_2} has no projection'
+        self.assertEqual(error_msg, expected)
+
+    @unittest.skip("skipping due to unresolved projection comparison question")
+    def test_different_projections_not_ok(self):
+        """Validation: different projections not allowed by default.
+
+        This test illustrates a bug we don't yet have a good solution for
+        (natcap/invest#558)
+        When ``different_projections_ok is False``, we don't check that the
+        projections are actually the same, because there isn't a great way to
+        do so. So there's the possibility that some bounding boxes overlap
+        numerically, but have different projections, and thus pass validation
+        when they shouldn't.
+        """
+
+        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')
+
+        # bounding boxes overlap if we don't account for the projections
+        for filepath, geotransform, epsg in (
                 (filepath_1, [1, 1, 0, 1, 0, 1], 4326),
-                (filepath_2, [100, 1, 0, 100, 0, 1], 4326)):
+                (filepath_2, [2, 1, 0, 2, 0, 1], 2193)):
             raster = driver.Create(filepath, 3, 3, 1, gdal.GDT_Int32)
             wgs84_srs = osr.SpatialReference()
-            wgs84_srs.ImportFromEPSG(epsg_code)
+            wgs84_srs.ImportFromEPSG(epsg)
             raster.SetProjection(wgs84_srs.ExportToWkt())
             raster.SetGeoTransform(geotransform)
             raster = None
 
-        gpkg_driver = gdal.GetDriverByName('GPKG')
-        vector = gpkg_driver.Create(reference_filepath, 0, 0, 0,
-                                    gdal.GDT_Unknown)
-        vector_srs = osr.SpatialReference()
-        vector_srs.ImportFromEPSG(32731)  # UTM 31N
-        layer = vector.CreateLayer('layer', vector_srs, ogr.wkbPoint)
-        new_feature = ogr.Feature(layer.GetLayerDefn())
-        new_feature.SetGeometry(ogr.CreateGeometryFromWkt('POINT 1 1'))
-
-        new_feature = None
-        layer = None
-        vector = None
-
-        error_msg = validation.check_spatial_overlap(
-            [filepath_1, filepath_2, reference_filepath],
-            vector_srs.ExportToWkt())
-        self.assertTrue('Bounding boxes do not intersect' in error_msg)
+        expected = (f'Spatial files {[filepath_1, filepath_2]} do not all '
+                    'have the same projection')
+        self.assertEqual(
+            validation.check_spatial_overlap([filepath_1, filepath_2]),
+            expected)
 
 
 class ValidatorTest(unittest.TestCase):
     """Test Validator."""
+
     def test_args_wrong_type(self):
         """Validation: check for error when args is the wrong type."""
         from natcap.invest import validation
@@ -236,6 +261,7 @@ class ValidatorTest(unittest.TestCase):
 
 class DirectoryValidation(unittest.TestCase):
     """Test Directory Validation."""
+
     def setUp(self):
         """Create a new workspace to use for each test."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -291,6 +317,7 @@ class DirectoryValidation(unittest.TestCase):
 
 class FileValidation(unittest.TestCase):
     """Test File Validator."""
+
     def setUp(self):
         """Create a new workspace to use for each test."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -320,6 +347,7 @@ class FileValidation(unittest.TestCase):
 
 class RasterValidation(unittest.TestCase):
     """Test Raster Validation."""
+
     def setUp(self):
         """Create a new workspace to use for each test."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -431,6 +459,7 @@ class RasterValidation(unittest.TestCase):
 
 class VectorValidation(unittest.TestCase):
     """Test Vector Validation."""
+
     def setUp(self):
         """Create a new workspace to use for each test."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -511,6 +540,7 @@ class VectorValidation(unittest.TestCase):
 
 class FreestyleStringValidation(unittest.TestCase):
     """Test Freestyle String Validation."""
+
     def test_int(self):
         """Validation: test that an int can be a valid string."""
         from natcap.invest import validation
@@ -536,6 +566,7 @@ class FreestyleStringValidation(unittest.TestCase):
 
 class OptionStringValidation(unittest.TestCase):
     """Test Option String Validation."""
+
     def test_valid_option(self):
         """Validation: test that a string is a valid option."""
         from natcap.invest import validation
@@ -552,6 +583,7 @@ class OptionStringValidation(unittest.TestCase):
 
 class NumberValidation(unittest.TestCase):
     """Test Number Validation."""
+
     def test_string(self):
         """Validation: test when a string is not a number."""
         from natcap.invest import validation
@@ -588,6 +620,7 @@ class NumberValidation(unittest.TestCase):
 
 class BooleanValidation(unittest.TestCase):
     """Test Boolean Validation."""
+
     def test_actual_bool(self):
         """Validation: test when boolean type objects are passed."""
         from natcap.invest import validation
@@ -611,6 +644,7 @@ class BooleanValidation(unittest.TestCase):
 
 class CSVValidation(unittest.TestCase):
     """Test CSV Validation."""
+
     def setUp(self):
         """Create a new workspace to use for each test."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -761,7 +795,8 @@ class CSVValidation(unittest.TestCase):
 
         # make a copy of the real _VALIDATION_FUNCS and override the CSV function
         mock_validation_funcs = validation._VALIDATION_FUNCS.copy()
-        mock_validation_funcs['csv'] = functools.partial(validation.timeout, delay)
+        mock_validation_funcs['csv'] = functools.partial(
+            validation.timeout, delay)
 
         # replace the validation.check_csv with the mock function, and try to validate
         with unittest.mock.patch('natcap.invest.validation._VALIDATION_FUNCS',
@@ -776,6 +811,7 @@ class CSVValidation(unittest.TestCase):
 
 class TestValidationFromSpec(unittest.TestCase):
     """Test Validation From Spec."""
+
     def setUp(self):
         """Create a new workspace to use for each test."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -887,7 +923,6 @@ class TestValidationFromSpec(unittest.TestCase):
             validation_warnings = validation.validate(args, spec)
         self.assertTrue('some_var_not_in_args' in str(cm.exception))
 
-
     def test_conditional_requirement_not_required(self):
         """Validation: unrequired conditional requirement should always pass"""
         from natcap.invest import validation
@@ -932,7 +967,6 @@ class TestValidationFromSpec(unittest.TestCase):
         validation_warnings = validation.validate(args, spec)
         self.assertEqual(validation_warnings, [])
 
-
     def test_requirement_missing(self):
         """Validation: verify absolute requirement on missing key."""
         from natcap.invest import validation
@@ -1178,6 +1212,50 @@ class TestValidationFromSpec(unittest.TestCase):
         self.assertTrue('Bounding boxes do not intersect' in
                         validation_warnings[0][1])
 
+    def test_spatial_overlap_error_undefined_projection(self):
+        """Validation: check spatial overlap message when no projection"""
+        from natcap.invest import validation
+
+        spec = {
+            'raster_a': {
+                'type': 'raster',
+                'name': 'raster 1',
+                'about': 'raster 1',
+                'required': True,
+            },
+            'raster_b': {
+                'type': 'raster',
+                'name': 'raster 2',
+                'about': 'raster 2',
+                'required': True,
+            }
+        }
+
+        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
+
+        # don't define a projection for the second raster
+        driver.Create(filepath_2, 3, 3, 1, gdal.GDT_Int32)
+
+        args = {
+            'raster_a': filepath_1,
+            'raster_b': filepath_2
+        }
+
+        validation_warnings = validation.validate(
+            args, spec, {'spatial_keys': list(args.keys()),
+                         'different_projections_ok': True})
+        expected = [(['raster_b'], 'Dataset must have a valid projection.')]
+        self.assertEqual(validation_warnings, expected)
+
     def test_spatial_overlap_error_optional_args(self):
         """Validation: check for spatial mismatch with insufficient args."""
         from natcap.invest import validation