Repository: natcap/invest
Pull request #561 (2021-06-02 13:11:04)

Pr resolution for an auto main -> release PR.

This PR fixed the conflict in https://github.com/natcap/invest/pull/544


Patch:
diff --git a/.github/workflows/binary-applications.yml b/.github/workflows/binary-applications.yml
index 494df1117..8ad66ca38 100644
--- a/.github/workflows/binary-applications.yml
+++ b/.github/workflows/binary-applications.yml
@@ -76,19 +76,6 @@ jobs:
                   $env:PYTHON = python -c "import sys, os; print(os.path.dirname(sys.executable))"
                   ./ci/windows-ci-binary-install.ps1
 
-            - name: Clone sample data repo (non-lfs)
-              shell: bash -l {0}
-              run: |
-                  GIT_URL=$(make jprint-GIT_SAMPLE_DATA_REPO)
-                  LOCALPATH=$(make jprint-GIT_SAMPLE_DATA_REPO_PATH)
-                  GIT_REV=$(make jprint-GIT_SAMPLE_DATA_REPO_REV)
-
-                  # Only clone the files ... deliberately not cloning LFS data
-                  # Installer needs .invest.json files only, and these are not
-                  # stored in LFS.
-                  GIT_LFS_SKIP_SMUDGE=1 git clone $GIT_URL $LOCALPATH
-                  git -C $LOCALPATH checkout $GIT_REV
-
             - name: Build userguide, binaries, installer
               shell: bash -l {0}
               run: |
diff --git a/HISTORY.rst b/HISTORY.rst
index 9b0fb73bc..74cade8a5 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -51,6 +51,8 @@ workbench-alpha
 Unreleased Changes (3.9.1)
 --------------------------
 * General:
+    * Moved the sample data JSON files out of the root sample_data folder and
+      into their respective model folders.
     * Updated documentation on installing InVEST from source.
     * Restructured API reference docs and removed outdated and redundant pages.
     * Include logger name in the logging format. This is helpful for the cython
@@ -80,10 +82,16 @@ 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
       ``lulc_fut_year - lulc_cur_year``.
+    * Improved UI to indicate that Calendar Year inputs are only required for
+      valuation, not also for sequestration.
+    * Increasing the precision of ``numpy.sum`` from Float32 to Float64 when
+      aggregating raster values for the HTML report.
 * DelineateIt:
     * The DelineateIt UI has been updated so that the point-snapping options
       will always be interactive.
@@ -97,8 +105,12 @@ Unreleased Changes (3.9.1)
     * Fixed divide-by-zero bug that was causing a RuntimeWarning in the logs.
       This bug did not affect the output.
 * HRA
-    * Fixed bugs that allowed zeros in DQ & Weight columns of criteria 
+    * Fixed bugs that allowed zeros in DQ & Weight columns of criteria
       table to raise DivideByZero errors.
+* Pollination
+    * Updated so that the ``total_pollinator_abundance_[season].tif`` outputs
+      are always created. Before, they weren't created if a farm vector was
+      not supplied, even though they are independent.
 * Seasonal Water Yield
     * Fixed a bug where ``qf.tif`` outputs weren't properly masking nodata
       values and could show negative numbers.
diff --git a/Makefile b/Makefile
index 3bf688b1a..d73c20095 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@
 DATA_DIR := data
 GIT_SAMPLE_DATA_REPO        := https://bitbucket.org/natcap/invest-sample-data.git
 GIT_SAMPLE_DATA_REPO_PATH   := $(DATA_DIR)/invest-sample-data
-GIT_SAMPLE_DATA_REPO_REV    := b7a51f189315e08484b5ba997a5c1de88ab7f06d
+GIT_SAMPLE_DATA_REPO_REV    := 598664165b911186e16a987c556f56f993cc78dd
 
 GIT_TEST_DATA_REPO          := https://bitbucket.org/natcap/invest-test-data.git
 GIT_TEST_DATA_REPO_PATH     := $(DATA_DIR)/invest-test-data
@@ -205,6 +205,7 @@ $(GIT_SAMPLE_DATA_REPO_PATH): | $(DATA_DIR)
 	git -C $(GIT_SAMPLE_DATA_REPO_PATH) lfs install
 	git -C $(GIT_SAMPLE_DATA_REPO_PATH) lfs fetch
 	git -C $(GIT_SAMPLE_DATA_REPO_PATH) checkout $(GIT_SAMPLE_DATA_REPO_REV)
+	git -C $(GIT_SAMPLE_DATA_REPO_PATH) lfs checkout
 
 $(GIT_TEST_DATA_REPO_PATH): | $(DATA_DIR)
 	-git clone $(GIT_TEST_DATA_REPO) $(GIT_TEST_DATA_REPO_PATH)
@@ -212,6 +213,7 @@ $(GIT_TEST_DATA_REPO_PATH): | $(DATA_DIR)
 	git -C $(GIT_TEST_DATA_REPO_PATH) lfs install
 	git -C $(GIT_TEST_DATA_REPO_PATH) lfs fetch
 	git -C $(GIT_TEST_DATA_REPO_PATH) checkout $(GIT_TEST_DATA_REPO_REV)
+	git -C $(GIT_TEST_DATA_REPO_PATH) lfs checkout
 
 fetch: $(GIT_UG_REPO_PATH) $(GIT_SAMPLE_DATA_REPO_PATH) $(GIT_TEST_DATA_REPO_PATH)
 
diff --git a/installer/windows/invest_installer.nsi b/installer/windows/invest_installer.nsi
index 6ee0d3aab..a4624a077 100644
--- a/installer/windows/invest_installer.nsi
+++ b/installer/windows/invest_installer.nsi
@@ -478,11 +478,6 @@ Section "InVEST Tools" Section_InVEST_Tools
     File ..\..\LICENSE.txt
     file ..\..\HISTORY.rst
 
-    SetOutPath "${SAMPLEDATADIR}"
-    ; Copy over all the sample parameter files
-    File ..\..\data\invest-sample-data\*.invs.json
-    File ..\..\data\invest-sample-data\*.invest.json
-
     SetOutPath "${INVEST_BINARIES}"
     File /r /x *.hg* /x *.svn* ..\..\${BINDIR}\*
     ; invest-autotest.bat is here to help automate testing the UIs.
diff --git a/requirements.txt b/requirements.txt
index 0b45a6541..334d2bc51 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,14 +12,14 @@
 # scripts/convert-requirements-to-conda-yml.py as though it can only be found
 # on pip.
 
-GDAL>=3.1.2
+GDAL>=3.1.2,<3.3.0
 Pyro4==4.77  # pip-only
 pandas>=1.2.1
 numpy>=1.11.0,!=1.16.0
 Rtree>=0.8.2,!=0.9.1
 Shapely>=1.7.1,<2.0.0
 scipy>=1.6.0  # pip-only
-pygeoprocessing>=2.1.1  # pip-only
+pygeoprocessing>=2.1.1,<2.2.0  # pip-only
 taskgraph[niced_processes]>=0.10.3  # pip-only
 psutil>=5.6.6
 chardet>=3.0.4
diff --git a/scripts/invest-autovalidate.py b/scripts/invest-autovalidate.py
index cd41128be..02ba843db 100644
--- a/scripts/invest-autovalidate.py
+++ b/scripts/invest-autovalidate.py
@@ -62,8 +62,11 @@ def main(sampledatadir):
         ValueError if any module's `validate` function issued warnings.
     """
     validation_messages = ''
-    for datastack_path in glob.glob(os.path.join(sampledatadir, '*.json')):
+    datastacks = glob.glob(os.path.join(sampledatadir, '**/*.json'))
+    if not datastacks:
+        raise ValueError(f'no json files found in {sampledatadir}')
 
+    for datastack_path in datastacks:
         paramset = datastack.extract_parameter_set(datastack_path)
         if 'workspace_dir' in paramset.args and \
                 paramset.args['workspace_dir'] != '':
diff --git a/src/natcap/invest/carbon.py b/src/natcap/invest/carbon.py
index b64f026d7..75ad67756 100644
--- a/src/natcap/invest/carbon.py
+++ b/src/natcap/invest/carbon.py
@@ -85,17 +85,17 @@ ARGS_SPEC = {
         },
         "carbon_pools_path": {
             "validation_options": {
-                "required_fields": ["LUCODE", "C_above", "C_below", "C_soil",
+                "required_fields": ["lucode", "C_above", "C_below", "C_soil",
                                     "C_dead"],
             },
             "type": "csv",
             "required": True,
             "about": (
-                "A table that maps the land-cover IDs to carbon pools.  "
-                "The table must contain columns of 'LULC', 'C_above', "
-                "'C_Below', 'C_Soil', 'C_Dead' as described in the User's "
-                "Guide.  The values in LULC must at least include the LULC "
-                "IDs in the land cover maps."),
+                "A table that maps each land cover class to its respective "
+                "carbon pools. The table must contain columns of 'lucode', "
+                "'C_above', 'C_below', 'C_soil', 'C_dead' as described in the "
+                "User's Guide. The 'lucode' column must include all the pixel "
+                "values present in the land cover maps."),
             "name": "carbon pools"
         },
         "lulc_cur_year": {
@@ -103,36 +103,31 @@ ARGS_SPEC = {
                 "expression": "int(value)"
             },
             "type": "number",
-            "required": "calc_sequestration",
+            "required": "do_valuation",
             "about": "The calendar year of the current scenario.",
-            "name": "current LULC year"
+            "name": "Current Land Cover Calendar Year"
         },
         "lulc_fut_year": {
             "validation_options": {
                 "expression": "int(value)"
             },
             "type": "number",
-            "required": "calc_sequestration",
+            "required": "do_valuation",
             "about": "The calendar year of the future scenario.",
-            "name": "future LULC year"
+            "name": "Future Land Cover Calendar Year"
         },
         "do_valuation": {
             "type": "boolean",
             "required": False,
             "about": (
-                "if true then run the valuation model on available outputs.  "
-                "At a minimum will run on carbon stocks, if sequestration "
-                "with a future scenario is done and/or a REDD scenario "
-                "calculate NPV for either and report in final HTML "
-                "document."),
-            "name": "do valuation"
+                "Calculate NPV for a future or REDD scenario "
+                "and report in final HTML document."),
+            "name": "Run Valuation Model"
         },
         "price_per_metric_ton_of_c": {
             "type": "number",
             "required": "do_valuation",
-            "about": (
-                "Is the present value of carbon per metric ton. Used if "
-                "``args['do_valuation']`` is present and True."),
+            "about": "The present value of carbon per metric ton.",
             "name": "Price/Metric ton of carbon"
         },
         "discount_rate": {
@@ -222,17 +217,15 @@ def execute(args):
             storage density to lulc codes. (required if 'do_uncertainty' is
             false)
         args['lulc_cur_year'] (int/string): an integer representing the year
-            of `args['lulc_cur_path']` used if `args['calc_sequestration']`
+            of `args['lulc_cur_path']` used if `args['do_valuation']`
             is True.
         args['lulc_fut_year'](int/string): an integer representing the year
             of `args['lulc_fut_path']` used in valuation if it exists.
             Required if  `args['do_valuation']` is True and
             `args['lulc_fut_path']` is present and well defined.
         args['do_valuation'] (bool): if true then run the valuation model on
-            available outputs.  At a minimum will run on carbon stocks, if
-            sequestration with a future scenario is done and/or a REDD
-            scenario calculate NPV for either and report in final HTML
-            document.
+            available outputs. Calculate NPV for a future scenario or a REDD
+            scenario and report in final HTML document.
         args['price_per_metric_ton_of_c'] (float): Is the present value of
             carbon per metric ton. Used if `args['do_valuation']` is present
             and True.
@@ -415,7 +408,11 @@ def _accumulate_totals(raster_path):
     nodata = pygeoprocessing.get_raster_info(raster_path)['nodata'][0]
     raster_sum = 0.0
     for _, block in pygeoprocessing.iterblocks((raster_path, 1)):
-        raster_sum += numpy.sum(block[block != nodata])
+        # The float64 dtype in the sum is needed to reduce numerical error in
+        # the sum.  Users calculated the sum with ArcGIS zonal statistics,
+        # noticed a difference and wrote to us about it on the forum.
+        raster_sum += numpy.sum(
+            block[~numpy.isclose(block, nodata)], dtype=numpy.float64)
     return raster_sum
 
 
@@ -501,7 +498,7 @@ def _calculate_valuation_constant(
     """
     n_years = lulc_fut_year - lulc_cur_year
     ratio = (
-        1 / ((1 + discount_rate / 100) * 
+        1 / ((1 + discount_rate / 100) *
              (1 + rate_change / 100)))
     valuation_constant = (price_per_metric_ton_of_c / n_years)
     # note: the valuation formula in the user's guide uses sum notation.
diff --git a/src/natcap/invest/pollination.py b/src/natcap/invest/pollination.py
index 3332ca9ae..335c2e4ff 100644
--- a/src/natcap/invest/pollination.py
+++ b/src/natcap/invest/pollination.py
@@ -199,7 +199,7 @@ _WILD_POLLINATOR_FARM_YIELD_FIELD_ID = 'y_wild'
 # dependent part of the yield
 _POLLINATOR_PROPORTION_FARM_YIELD_FIELD_ID = 'pdep_y_w'
 # output field for pollinator abundance on farm for the season of pollination
-_POLLINATOR_ABUDNANCE_FARM_FIELD_ID = 'p_abund'
+_POLLINATOR_ABUNDANCE_FARM_FIELD_ID = 'p_abund'
 # expected pattern for seasonal floral resources in input shapefile (season)
 _FARM_FLORAL_RESOURCES_HEADER_PATTERN = 'fr_%s'
 # regular expression version of _FARM_FLORAL_RESOURCES_PATTERN
@@ -233,7 +233,7 @@ def execute(args):
         args['guild_table_path'] (string): file path to a table indicating
             the bee species to analyze in this model run.  Table headers
             must include:
-            
+
                 * 'species': a bee species whose column string names will
                     be referred to in other tables and the model will output
                     analyses per species.
@@ -661,6 +661,30 @@ def execute(args):
             pollinator_abundance_path_map[(species, season)] = (
                 pollinator_abundance_path)
 
+    # calculate total abundance of all pollinators for each season
+    total_pollinator_abundance_task = {}
+    for season in scenario_variables['season_list']:
+        # total_pollinator_abundance_index[season] PAT(x,j)=sum_s PA(x,s,j)
+        total_pollinator_abundance_index_path = os.path.join(
+            output_dir, _TOTAL_POLLINATOR_ABUNDANCE_FILE_PATTERN % (
+                season, file_suffix))
+
+        pollinator_abundance_season_path_band_list = [
+            (pollinator_abundance_path_map[(species, season)], 1)
+            for species in scenario_variables['species_list']]
+
+        total_pollinator_abundance_task[season] = task_graph.add_task(
+            task_name='calculate_poll_abudnce_%s_%s' % (species, season),
+            func=pygeoprocessing.raster_calculator,
+            args=(
+                pollinator_abundance_season_path_band_list, _SumRasters(),
+                total_pollinator_abundance_index_path, gdal.GDT_Float32,
+                _INDEX_NODATA),
+            dependent_task_list=[
+                pollinator_abundance_task_map[(species, season)]
+                for species in scenario_variables['species_list']],
+            target_path_list=[total_pollinator_abundance_index_path])
+
     # next step is farm vector calculation, if no farms then okay to quit
     if farm_vector_path is None:
         task_graph.close()
@@ -681,28 +705,7 @@ def execute(args):
 
     farm_pollinator_season_path_list = []
     farm_pollinator_season_task_list = []
-    total_pollinator_abundance_task = {}
     for season in scenario_variables['season_list']:
-        # total_pollinator_abundance_index[season] PAT(x,j)=sum_s PA(x,s,j)
-        total_pollinator_abundance_index_path = os.path.join(
-            output_dir, _TOTAL_POLLINATOR_ABUNDANCE_FILE_PATTERN % (
-                season, file_suffix))
-
-        pollinator_abudnance_season_path_band_list = [
-            (pollinator_abundance_path_map[(species, season)], 1)
-            for species in scenario_variables['species_list']]
-
-        total_pollinator_abundance_task[season] = task_graph.add_task(
-            task_name='calculate_poll_abudnce_%s_%s' % (species, season),
-            func=pygeoprocessing.raster_calculator,
-            args=(
-                pollinator_abudnance_season_path_band_list, _SumRasters(),
-                total_pollinator_abundance_index_path, gdal.GDT_Float32,
-                _INDEX_NODATA),
-            dependent_task_list=[
-                pollinator_abundance_task_map[(species, season)]
-                for species in scenario_variables['species_list']],
-            target_path_list=[total_pollinator_abundance_index_path])
 
         half_saturation_raster_path = os.path.join(
             intermediate_output_dir, _HALF_SATURATION_FILE_PATTERN % (
@@ -848,7 +851,7 @@ def execute(args):
             # this is PAT ('p_abund')
             farm_season = farm_feature.GetField(_FARM_SEASON_FIELD)
             farm_feature.SetField(
-                _POLLINATOR_ABUDNANCE_FARM_FIELD_ID,
+                _POLLINATOR_ABUNDANCE_FARM_FIELD_ID,
                 pollinator_abundance_results[farm_season][fid]['sum'] /
                 float(pollinator_abundance_results[farm_season][fid]['count']))
 
@@ -920,7 +923,7 @@ def _create_farm_result_vector(
     target_layer = target_vector.GetLayer()
 
     farm_pollinator_abundance_defn = ogr.FieldDefn(
-        _POLLINATOR_ABUDNANCE_FARM_FIELD_ID, ogr.OFTReal)
+        _POLLINATOR_ABUNDANCE_FARM_FIELD_ID, ogr.OFTReal)
     farm_pollinator_abundance_defn.SetWidth(25)
     farm_pollinator_abundance_defn.SetPrecision(11)
     target_layer.CreateField(farm_pollinator_abundance_defn)
diff --git a/src/natcap/invest/ui/carbon.py b/src/natcap/invest/ui/carbon.py
index ebb81263d..4f1afffe3 100644
--- a/src/natcap/invest/ui/carbon.py
+++ b/src/natcap/invest/ui/carbon.py
@@ -31,16 +31,9 @@ class Carbon(model.InVESTModel):
             label='Carbon Pools',
             validator=self.validator)
         self.add_input(self.carbon_pools_path)
-        self.cur_lulc_year = inputs.Text(
-            args_key='lulc_cur_year',
-            helptext='The calendar year of the current scenario.',
-            interactive=False,
-            label='Current Landcover Calendar Year',
-            validator=self.validator)
-        self.add_input(self.cur_lulc_year)
         self.calc_sequestration = inputs.Checkbox(
             helptext=(
-                "Check to enable sequestration analysis.  This "
+                "Check to enable sequestration analysis. This "
                 "requires inputs of Land Use/Land Cover maps for both "
                 "current and future scenarios."),
             args_key='calc_sequestration',
@@ -55,16 +48,9 @@ class Carbon(model.InVESTModel):
                 "baseline, future scenario against which to compare "
                 "the REDD policy scenario."),
             interactive=False,
-            label='Future Landcover (Raster)',
+            label='Future Land Cover (Raster)',
             validator=self.validator)
         self.add_input(self.fut_lulc_raster)
-        self.fut_lulc_year = inputs.Text(
-            args_key='lulc_fut_year',
-            helptext='The calendar year of the future scenario.',
-            interactive=False,
-            label='Future Landcover Calendar Year',
-            validator=self.validator)
-        self.add_input(self.fut_lulc_year)
         self.redd = inputs.Checkbox(
             helptext=(
                 "Check to enable REDD scenario analysis.  This "
@@ -93,9 +79,23 @@ class Carbon(model.InVESTModel):
             interactive=False,
             label='Run Valuation Model')
         self.add_input(self.valuation_container)
+        self.cur_lulc_year = inputs.Text(
+            args_key='lulc_cur_year',
+            helptext='The calendar year of the current scenario.',
+            interactive=False,
+            label='Current Land Cover Calendar Year',
+            validator=self.validator)
+        self.valuation_container.add_input(self.cur_lulc_year)
+        self.fut_lulc_year = inputs.Text(
+            args_key='lulc_fut_year',
+            helptext='The calendar year of the future scenario.',
+            interactive=False,
+            label='Future Land Cover Calendar Year',
+            validator=self.validator)
+        self.valuation_container.add_input(self.fut_lulc_year)
         self.price_per_metric_ton_of_c = inputs.Text(
             args_key='price_per_metric_ton_of_c',
-            label='Price/Metric ton of carbon',
+            label='Price/Metric Ton of Carbon',
             validator=self.validator)
         self.valuation_container.add_input(self.price_per_metric_ton_of_c)
         self.discount_rate = inputs.Text(
@@ -114,12 +114,8 @@ class Carbon(model.InVESTModel):
         self.valuation_container.add_input(self.rate_change)
 
         # Set interactivity, requirement as input sufficiency changes
-        self.calc_sequestration.sufficiency_changed.connect(
-            self.cur_lulc_year.set_interactive)
         self.calc_sequestration.sufficiency_changed.connect(
             self.fut_lulc_raster.set_interactive)
-        self.calc_sequestration.sufficiency_changed.connect(
-            self.fut_lulc_year.set_interactive)
         self.calc_sequestration.sufficiency_changed.connect(
             self.redd.set_interactive)
         self.redd.sufficiency_changed.connect(
diff --git a/src/natcap/invest/validation.py b/src/natcap/invest/validation.py
index 6d5445c07..9105bde4d 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:
@@ -577,11 +578,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(
@@ -622,6 +627,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))
 
@@ -633,8 +639,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:
@@ -875,7 +881,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):
@@ -908,7 +914,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.
@@ -945,7 +951,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/scripts/invest-autotest.py b/scripts/invest-autotest.py
index 1406ca3cd..98dd73bbd 100644
--- a/scripts/invest-autotest.py
+++ b/scripts/invest-autotest.py
@@ -16,44 +16,44 @@ LOGGER = logging.getLogger('invest-autotest.py')
 # Mapping of model keys to datastacks to run through the model's UI.
 # Paths are assumed to be relative to the data root.
 DATASTACKS = {
-    'carbon': ['carbon_willamette.invs.json'],
-    'coastal_blue_carbon': ['cbc_galveston_bay.invs.json'],
-    'coastal_blue_carbon_preprocessor': ['cbc_pre_galveston_bay.invs.json'],
-    'coastal_vulnerability': ['coastal_vuln_grandbahama.invs.json'],
+    'carbon': ['Carbon/carbon_willamette.invs.json'],
+    'coastal_blue_carbon': ['CoastalBlueCarbon/cbc_galveston_bay.invs.json'],
+    'coastal_blue_carbon_preprocessor': ['CoastalBlueCarbon/cbc_pre_galveston_bay.invs.json'],
+    'coastal_vulnerability': ['CoastalVulnerability/coastal_vuln_grandbahama.invs.json'],
     'crop_production_percentile': [
-        'crop_production_percentile_demo.invs.json'],
+        'CropProduction/crop_production_percentile_demo.invs.json'],
     'crop_production_regression': [
-        'crop_production_regression_demo.invs.json'],
-    'delineateit': ['delineateit_gura.invs.json'],
-    'finfish_aquaculture': ['atlantic_salmon_british_columbia.invs.json'],
+        'CropProduction/crop_production_regression_demo.invs.json'],
+    'delineateit': ['DelineateIt/delineateit_gura.invs.json'],
+    'finfish_aquaculture': ['Aquaculture/atlantic_salmon_british_columbia.invs.json'],
     'fisheries': [
-        'blue_crab_galveston_bay.invs.json',
-        'dungeness_crab_hood_canal.invs.json',
-        'spiny_lobster_belize.invs.json',
-        'white_shrimp_galveston_bay.invs.json',
+        'Fisheries/blue_crab_galveston_bay.invs.json',
+        'Fisheries/dungeness_crab_hood_canal.invs.json',
+        'Fisheries/spiny_lobster_belize.invs.json',
+        'Fisheries/white_shrimp_galveston_bay.invs.json',
     ],
-    'fisheries_hst': ['fisheries_hst_demo.invs.json'],
-    'forest_carbon_edge_effect': ['forest_carbon_amazonia.invs.json'],
-    'globio': ['globio_demo.invs.json'],
-    'habitat_quality': ['habitat_quality_willamette.invs.json'],
-    'hra': ['hra_wcvi.invs.json'],
-    'hydropower_water_yield': ['annual_water_yield_gura.invs.json'],
-    'ndr': ['ndr_gura.invs.json'],
-    'pollination': ['pollination_willamette.invs.json'],
-    'recreation': ['recreation_andros.invs.json'],
-    'routedem': ['routedem_gura.invs.json'],
-    'scenario_generator_proximity': ['scenario_proximity_amazonia.invs.json'],
-    'scenic_quality': ['wind_turbines_wcvi.invs.json'],
-    'sdr': ['sdr_gura.invs.json'],
-    'seasonal_water_yield': ['seasonal_water_yield_gura.invs.json'],
-    'urban_flood_risk_mitigation': ['urban_flood_risk_mitigation.invs.json'],
-    'urban_cooling_model': ['urban_cooling_model_datastack.invest.json'],
-    'wind_energy': ['wind_energy_new_england.invs.json'],
+    'fisheries_hst': ['Fisheries/fisheries_hst_demo.invs.json'],
+    'forest_carbon_edge_effect': ['forest_carbon_edge_effect/forest_carbon_amazonia.invs.json'],
+    'globio': ['globio/globio_demo.invs.json'],
+    'habitat_quality': ['HabitatQuality/habitat_quality_willamette.invs.json'],
+    'hra': ['HabitatRiskAssess/hra_wcvi.invs.json'],
+    'hydropower_water_yield': ['Annual_Water_Yield/annual_water_yield_gura.invs.json'],
+    'ndr': ['NDR/ndr_gura.invs.json'],
+    'pollination': ['pollination/pollination_willamette.invs.json'],
+    'recreation': ['recreation/recreation_andros.invs.json'],
+    'routedem': ['RouteDEM/routedem_gura.invs.json'],
+    'scenario_generator_proximity': ['scenario_proximity/scenario_proximity_amazonia.invs.json'],
+    'scenic_quality': ['ScenicQuality/wind_turbines_wcvi.invs.json'],
+    'sdr': ['SDR/sdr_gura.invs.json'],
+    'seasonal_water_yield': ['Seasonal_Water_Yield/seasonal_water_yield_gura.invs.json'],
+    'urban_flood_risk_mitigation': ['UrbanFloodMitigation/urban_flood_risk_mitigation.invs.json'],
+    'urban_cooling_model': ['UrbanCoolingModel/urban_cooling_model_datastack.invest.json'],
+    'wind_energy': ['WindEnergy/wind_energy_new_england.invs.json'],
     'wave_energy': [
-        'wave_energy_aquabuoy_wcvi.invs.json',
-        'wave_energy_owc_wcvi.invs.json',
-        'wave_energy_pelamis_wcvi.invs.json',
-        'wave_energy_wavedragon_wcvi.invs.json',
+        'WaveEnergy/wave_energy_aquabuoy_wcvi.invs.json',
+        'WaveEnergy/wave_energy_owc_wcvi.invs.json',
+        'WaveEnergy/wave_energy_pelamis_wcvi.invs.json',
+        'WaveEnergy/wave_energy_wavedragon_wcvi.invs.json',
     ],
 }
 
diff --git a/tests/test_carbon.py b/tests/test_carbon.py
index 65f3a2589..5a6192cdb 100644
--- a/tests/test_carbon.py
+++ b/tests/test_carbon.py
@@ -8,6 +8,9 @@ import os
 from osgeo import gdal
 from osgeo import osr
 import numpy
+import numpy.random
+import numpy.testing
+import pygeoprocessing
 
 
 def make_simple_raster(base_raster_path, fill_val, nodata_val):
@@ -163,11 +166,11 @@ class CarbonTests(unittest.TestCase):
         carbon.execute(args)
 
         # Add assertions for npv for future and REDD scenarios.
-        # carbon change from cur to fut: 
+        # carbon change from cur to fut:
         # -58 Mg/ha * .0001 ha/pixel * 43 $/Mg = -0.2494 $/pixel
         assert_raster_equal_value(
             os.path.join(args['workspace_dir'], 'npv_fut.tif'), -0.2494)
-        # carbon change from cur to redd: 
+        # carbon change from cur to redd:
         # -78 Mg/ha * .0001 ha/pixel * 43 $/Mg = -0.3354 $/pixel
         assert_raster_equal_value(
             os.path.join(args['workspace_dir'], 'npv_redd.tif'), -0.3354)
@@ -301,9 +304,7 @@ class CarbonValidationTests(unittest.TestCase):
         invalid_keys = validation.get_invalid_keys(validation_errors)
         expected_missing_keys = set(
             self.base_required_keys +
-            ['lulc_cur_year',
-             'lulc_fut_year',
-             'lulc_fut_path'])
+            ['lulc_fut_path'])
         self.assertEqual(invalid_keys, expected_missing_keys)
 
     def test_missing_keys_redd(self):
@@ -333,5 +334,33 @@ class CarbonValidationTests(unittest.TestCase):
             ['calc_sequestration',
              'price_per_metric_ton_of_c',
              'discount_rate',
-             'rate_change'])
+             'rate_change',
+             'lulc_cur_year',
+             'lulc_fut_year'])
         self.assertEqual(invalid_keys, expected_missing_keys)
+
+    def test_carbon_totals_precision(self):
+        """Carbon: check float64 precision in pixel value summation."""
+        from natcap.invest import carbon
+
+        big_float32_array = numpy.random.default_rng(seed=1).random(
+            (1000, 1000), dtype=numpy.float32)
+
+        # Throw in some nodata values for good measure.
+        nodata = numpy.finfo(numpy.float32).min
+        big_float32_array[1:15] = nodata
+
+        srs = osr.SpatialReference()
+        srs.ImportFromEPSG(32731)  # WGS84/UTM zone 31s
+        wkt = srs.ExportToWkt()
+        raster_path = os.path.join(self.workspace_dir, 'raster.tif')
+        pygeoprocessing.numpy_array_to_raster(
+            big_float32_array, float(nodata), (2, -2), (2, -2), wkt,
+            raster_path)
+
+        # Verify better-than-float32 precision on raster summation.
+        # Using a numpy float32 in numpy.sum will pass up to rtol=1e-9.
+        numpy.testing.assert_allclose(
+            carbon._accumulate_totals(raster_path),
+            492919.73994,
+            rtol=1e-12)  # Note better precision
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