Repository: natcap/invest (python)

Issue:
Mysterious crash related to _log_gdal_errors
We've had two forums posts in recent memory that have similar exceptions:

* https://community.naturalcapitalproject.org/t/urban-cool-cooling-model-sample-error/899
* https://community.naturalcapitalproject.org/t/i-cannot-run-any-modules/2121/10

```python
TypeError: _log_gdal_errors() missing 3 required positional arguments: ‘err_level’, ‘err_no’, and ‘err_msg’

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
File “site-packages\taskgraph\Task.py”, line 607, in add_task
File “site-packages\taskgraph\Task.py”, line 1071, in _call
File “site-packages\pygeoprocessing\geoprocessing.py”, line 1763, in reclassify_raster
File “site-packages\pygeoprocessing\geoprocessing.py”, line 396, in raster_calculator
File “site-packages\osgeo\gdal.py”, line 2635, in ReadAsArray
File “”, line 983, in _find_and_load
File “”, line 967, in _find_and_load_unlocked
File “”, line 677, in _load_unlocked
File “C:\Python37\lib\site-packages\PyInstaller\loader\pyimod03_importers.py”, line 627, in exec_module
File “site-packages\osgeo\gdalnumeric.py”, line 2, in
File “”, line 983, in _find_and_load
File “”, line 967, in _find_and_load_unlocked
File “”, line 677, in _load_unlocked
File “C:\Python37\lib\site-packages\PyInstaller\loader\pyimod03_importers.py”, line 627, in exec_module
File “site-packages\osgeo\gdal_array.py”, line 166, in
File “site-packages\osgeo\gdal.py”, line 3032, in AllRegister
SystemError: returned a result with an error set
2020-03-27 18:13:58,749 model._logged_target(1648) ERROR Exception while executing <function execute at 0x13DFCC00>
TypeError: _log_gdal_errors() missing 3 required positional arguments: ‘err_level’, ‘err_no’, and ‘err_msg’

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
File “site-packages\natcap\invest\ui\model.py”, line 1645, in _logged_target
File “site-packages\natcap\invest\urban_cooling_model.py”, line 384, in execute
File “site-packages\taskgraph\Task.py”, line 607, in add_task
File “site-packages\taskgraph\Task.py”, line 1071, in _call
File “site-packages\pygeoprocessing\geoprocessing.py”, line 1763, in reclassify_raster
File “site-packages\pygeoprocessing\geoprocessing.py”, line 396, in raster_calculator
File “site-packages\osgeo\gdal.py”, line 2635, in ReadAsArray
File “”, line 983, in _find_and_load
File “”, line 967, in _find_and_load_unlocked
File “”, line 677, in _load_unlocked
File “C:\Python37\lib\site-packages\PyInstaller\loader\pyimod03_importers.py”, line 627, in exec_module
File “site-packages\osgeo\gdalnumeric.py”, line 2, in
File “”, line 983, in _find_and_load
File “”, line 967, in _find_and_load_unlocked
File “”, line 677, in _load_unlocked
File “C:\Python37\lib\site-packages\PyInstaller\loader\pyimod03_importers.py”, line 627, in exec_module
File “site-packages\osgeo\gdal_array.py”, line 166, in
File “site-packages\osgeo\gdal.py”, line 3032, in AllRegister
SystemError: returned a result with an error set
2020-03-27 18:13:58,752 model._logged_target(1651) INFO Execution finished
2020-03-27 18:13:58,757 utils.prepare_workspace(121) INFO Elapsed time: 0.25s
```

Pull request:
Bugfix/630 mysterious crash log gdal errors
# Description

This change introduces better logging for the GDAL error handler we add in InVEST runs.  We haven't yet been able to reproduce the original issue, but when it happens, at least we'll have more information for debugging.  I think it's safe to resolve #630, since the crash should hopefully be less mysterious when it happens next.

Fixes #630

# Checklist
- [x] Updated HISTORY.rst (if these changes are user-facing)

- [x] Updated the user's guide (if needed) _(not needed)_


Patch:
diff --git a/.github/workflows/binary-applications.yml b/.github/workflows/binary-applications.yml
index 7cd1ee1eb..8ad66ca38 100644
--- a/.github/workflows/binary-applications.yml
+++ b/.github/workflows/binary-applications.yml
@@ -125,24 +125,17 @@ jobs:
 
             - name: Upload installer artifact
               if: always()
-              uses: actions/upload-artifact@v2
+              uses: actions/upload-artifact@v2-preview
               with:
                   name: InVEST-windows-installer
                   path: dist/*.exe
 
             - name: Upload user's guide artifact
-              uses: actions/upload-artifact@v2
+              uses: actions/upload-artifact@v2-preview
               with:
                   name: InVEST-user-guide
                   path: dist/InVEST_*_userguide.zip
 
-            - name: Upload workspace on failure
-              if: ${{ failure() }}
-              uses: actions/upload-artifact@v2
-              with:
-                  name: InVEST-failed-windows-workspace
-                  path: ${{ github.workspace }}
-
     build-mac-binaries:
         name: "Build mac binaries"
         needs: check-syntax-errors
@@ -221,29 +214,17 @@ jobs:
               run: make deploy
 
             - name: Upload binaries artifact
-              uses: actions/upload-artifact@v2
+              uses: actions/upload-artifact@v2-preview
               with:
                   name: InVEST-mac-binaries
                   path: dist/InVEST-*-mac.zip
 
             - name: Upload DMG artifact
-              uses: actions/upload-artifact@v2
+              uses: actions/upload-artifact@v2-preview
               with:
                   name: InVEST-dmg
                   path: dist/InVEST*.dmg
 
-            - name: Tar the workspace to preserve permissions
-              if: ${{ failure() }}
-              shell: bash -l {0}
-              run: tar -cvf InVEST-failed-mac-workspace.tar ${{ github.workspace }}
-
-            - name: Upload workspace on failure
-              if: ${{ failure() }}
-              uses: actions/upload-artifact@v2
-              with:
-                  name: InVEST-failed-mac-workspace
-                  path: InVEST-failed-mac-workspace.tar
-
     build-sampledata:
         name: Build sampledata archives
         needs: check-syntax-errors
@@ -278,7 +259,7 @@ jobs:
               run: make deploy
 
             - name: Upload sample data artifact
-              uses: actions/upload-artifact@v2
+              uses: actions/upload-artifact@v2-preview
               with:
                   name: InVEST-sample-data
                   path: dist/*.zip
diff --git a/HISTORY.rst b/HISTORY.rst
index 6c6c26d0a..1b3317824 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -71,6 +71,10 @@ Unreleased Changes (3.9.1)
       could have been computed by previous runs.
     * Validation now returns a more helpful message when a spatial input has
       no projection defined.
+    * Added some logging to ``natcap.invest.utils._log_gdal_errors`` to aid in
+      debugging some hard-to-reproduce GDAL logging errors that occasionally
+      cause InVEST models to crash.  If GDAL calls ``_log_gdal_errors`` with an
+      incorrect set of arguments, this is now logged.
 * 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/exe/hooks/rthook.py b/exe/hooks/rthook.py
index 53a9af14b..37eefaeab 100644
--- a/exe/hooks/rthook.py
+++ b/exe/hooks/rthook.py
@@ -12,10 +12,3 @@ if platform.system() == 'Darwin':
     # See https://bugreports.qt.io/browse/QTBUG-87014
     # and https://github.com/natcap/invest/issues/384
     os.environ['QT_MAC_WANTS_LAYER'] = '1'
-
-    # Rtree will look in this directory first for libspatialindex_c.dylib.
-    # In response to issues with github mac binary builds:
-    # https://github.com/natcap/invest/issues/594
-    # sys._MEIPASS is the path to where the pyinstaller entrypoint bundle
-    # lives.  See the pyinstaller docs for more details.
-    os.environ['SPATIALINDEX_C_LIBRARY'] = sys._MEIPASS
diff --git a/installer/darwin/build_app_bundle.sh b/installer/darwin/build_app_bundle.sh
index c54d3d9a0..447b2f4be 100755
--- a/installer/darwin/build_app_bundle.sh
+++ b/installer/darwin/build_app_bundle.sh
@@ -54,10 +54,6 @@ echo "# binary to run on OSX Big Sur." >> $new_command_file
 echo "#" >> $new_command_file
 echo "# Taken from https://stackoverflow.com/a/246128/299084" >> $new_command_file
 echo 'DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"' >> $new_command_file
-# QT_MAC_WANTS_LAYER is to address a hanging issue in Qt 5.13 with launching Qt
-# on OS 11 Big Sur.
-# QT_QPA_PLATFORM_PLUGIN_PATH addresses an issue where sometimes Qt can't find
-# the plugins where they are expected to be.
-echo 'QT_MAC_WANTS_LAYER=1 QT_QPA_PLATFORM_PLUGIN_PATH=$DIR/invest_dist/PySide2/Qt/plugins "$DIR/invest_dist/invest" launch' >> $new_command_file
+echo 'QT_MAC_WANTS_LAYER=1 "$DIR/invest_dist/invest" launch' >> $new_command_file
 chmod a+x $new_command_file
 
diff --git a/requirements-docs.yml b/requirements-docs.yml
index 4ee9246fd..4488758bc 100644
--- a/requirements-docs.yml
+++ b/requirements-docs.yml
@@ -2,14 +2,14 @@
 # ---------------------
 # This file records the packages needed to build on readthedocs.org.
 # It assumes that the natcap.invest package is being
-# installed to the readthedocs virtualenv separately.  
+# installed to the readthedocs virtualenv separately.
 channels:
 - conda-forge
 - defaults
 dependencies:
 - chardet>=3.0.4
 - Cython
-- GDAL>=3.1.2
+- GDAL>=3.1.2,<3.3.0
 - numpy>=1.11.0,!=1.16.0
 - pandas>=1.0,<1.2.0
 - pip
diff --git a/src/natcap/invest/cli.py b/src/natcap/invest/cli.py
index 2f40e6ff2..82c3f206d 100644
--- a/src/natcap/invest/cli.py
+++ b/src/natcap/invest/cli.py
@@ -587,7 +587,14 @@ def main(user_args=None):
 
             # We're deliberately not validating here because the user
             # can just call ``invest validate <datastack>`` to validate.
-            getattr(model_module, 'execute')(parsed_datastack.args)
+            try:
+                getattr(model_module, 'execute')(parsed_datastack.args)
+            # Graceful exit from this program, otherwise the pyinstaller-made
+            # executeable will exit with 'Failed to execute script cli' on an
+            # uncaught exception. And that only adds unhelpful noise to stderr.
+            except Exception as error:
+                LOGGER.exception(error)
+                parser.exit(DEFAULT_EXIT_CODE)
 
     # If we're running in a GUI (either through ``invest run`` or
     # ``invest quickrun``), we'll need to load the Model's GUI class,
diff --git a/src/natcap/invest/ui/sdr.py b/src/natcap/invest/ui/sdr.py
index 26bc44733..71d01f1c1 100644
--- a/src/natcap/invest/ui/sdr.py
+++ b/src/natcap/invest/ui/sdr.py
@@ -149,6 +149,7 @@ class SDR(model.InVESTModel):
             self.k_param.args_key: self.k_param.value(),
             self.ic_0_param.args_key: self.ic_0_param.value(),
             self.sdr_max.args_key: self.sdr_max.value(),
+            self.l_max.args_key: self.l_max.value(),
         }
 
         return args
diff --git a/src/natcap/invest/utils.py b/src/natcap/invest/utils.py
index 1a2ef246f..c65bd2060 100644
--- a/src/natcap/invest/utils.py
+++ b/src/natcap/invest/utils.py
@@ -18,6 +18,7 @@ import pygeoprocessing
 
 
 LOGGER = logging.getLogger(__name__)
+_OSGEO_LOGGER = logging.getLogger('osgeo')
 LOG_FMT = (
     "%(asctime)s "
     "(%(name)s) "
@@ -44,6 +45,60 @@ GDAL_ERROR_LEVELS = {
 DEFAULT_OSR_AXIS_MAPPING_STRATEGY = osr.OAMS_TRADITIONAL_GIS_ORDER
 
 
+def _log_gdal_errors(*args, **kwargs):
+    """Log error messages to osgeo.
+
+    All error messages are logged with reasonable ``logging`` levels based
+    on the GDAL error level.
+
+    Note:
+        This function is designed to accept any number of positional and
+        keyword arguments because of some odd forums questions where this
+        function was being called with an unexpected number of arguments.
+        With this catch-all function signature, we can at least guarantee
+        that in the off chance this function is called with the wrong
+        parameters, we can at least log what happened.  See the issue at
+        https://github.com/natcap/invest/issues/630 for details.
+
+    Args:
+        err_level (int): The GDAL error level (e.g. ``gdal.CE_Failure``)
+        err_no (int): The GDAL error number.  For a full listing of error
+            codes, see: http://www.gdal.org/cpl__error_8h.html
+        err_msg (string): The error string.
+
+    Returns:
+        ``None``
+    """
+    if len(args) + len(kwargs) != 3:
+        LOGGER.error(
+            '_log_gdal_errors was called with an incorrect number of '
+            f'arguments.  args: {args}, kwargs: {kwargs}')
+
+    try:
+        gdal_args = {}
+        for index, key in enumerate(('err_level', 'err_no', 'err_msg')):
+            try:
+                parameter = args[index]
+            except IndexError:
+                parameter = kwargs[key]
+            gdal_args[key] = parameter
+    except KeyError as missing_key:
+        LOGGER.exception(
+            f'_log_gdal_errors called without the argument {missing_key}. '
+            f'Called with args: {args}, kwargs: {kwargs}')
+
+        # Returning from the function because we don't have enough
+        # information to call the ``osgeo_logger`` in the way we intended.
+        return
+
+    err_level = gdal_args['err_level']
+    err_no = gdal_args['err_no']
+    err_msg = gdal_args['err_msg'].replace('\n', '')
+    _OSGEO_LOGGER.log(
+        level=GDAL_ERROR_LEVELS[err_level],
+        msg=f'[errno {err_no}] {err_msg}')
+
+
 @contextlib.contextmanager
 def capture_gdal_logging():
     """Context manager for logging GDAL errors with python logging.
@@ -58,28 +113,6 @@ def capture_gdal_logging():
     Returns:
         ``None``
     """
-    osgeo_logger = logging.getLogger('osgeo')
-
-    def _log_gdal_errors(err_level, err_no, err_msg):
-        """Log error messages to osgeo.
-
-        All error messages are logged with reasonable ``logging`` levels based
-        on the GDAL error level.
-
-        Args:
-            err_level (int): The GDAL error level (e.g. ``gdal.CE_Failure``)
-            err_no (int): The GDAL error number.  For a full listing of error
-                codes, see: http://www.gdal.org/cpl__error_8h.html
-            err_msg (string): The error string.
-
-        Returns:
-            ``None``
-        """
-        osgeo_logger.log(
-            level=GDAL_ERROR_LEVELS[err_level],
-            msg='[errno {err}] {msg}'.format(
-                err=err_no, msg=err_msg.replace('\n', ' ')))
-
     gdal.PushErrorHandler(_log_gdal_errors)
     try:
         yield
@@ -470,7 +503,7 @@ def build_lookup_from_csv(
             string values. default=True.
 
     Returns:
-        lookup_dict (dict): a dictionary of the form 
+        lookup_dict (dict): a dictionary of the form
         {key_field_0: {csv_header_0: value0, csv_header_1: value1...},
         key_field_1: {csv_header_0: valuea, csv_header_1: valueb...}}
 
@@ -846,7 +879,7 @@ def reclassify_raster(
             each key represent:
 
                 'raster_name' - string for the raster name being reclassified
-                'column_name' - name of the table column that ``value_map`` 
+                'column_name' - name of the table column that ``value_map``
                 dictionary keys came from.
                 'table_name' - table name that ``value_map`` came from.
 


Test patch:
diff --git a/tests/test_coastal_vulnerability.py b/tests/test_coastal_vulnerability.py
index 9a5447556..f0c90f4e1 100644
--- a/tests/test_coastal_vulnerability.py
+++ b/tests/test_coastal_vulnerability.py
@@ -713,93 +713,26 @@ class CoastalVulnerabilityTests(unittest.TestCase):
         args['slr_field'] = 'Trend'
         coastal_vulnerability.execute(args)
 
-        results_vector = gdal.OpenEx(
-            os.path.join(args['workspace_dir'], 'coastal_exposure.gpkg'),
-            gdal.OF_VECTOR)
-        results_layer = results_vector.GetLayer()
-
-        # shore points aren't necessarily created/numbered in the same order,
-        # so we can't compare by the fid or shore_id fields
-        # instead check that each point has the expected attributes
-        fields = [
-            'R_hab', 'R_wind', 'R_wave', 'R_surge', 'R_relief', 'R_geomorph',
-            'R_slr', 'population', 'exposure', 'habitat_role',
-            'exposure_no_habitats']
-
-        # sorted list of expected x, y point coords and their corresponding expected
-        # values for the fields in the order above
-        sorted_expected_data = [
-            [(252692.2222200262, 5482540.950248547),
-                [5, 3, 2, 1, 4, 1, 5, 0, 2.49389836245, 0, 2.49389836245]],
-            [(265195.9118871244, 5465342.359978485),
-                [4.05, 5, 5, 1, 5, 1, 5, 0, 3.063308387, 0.0936167899, 3.1569251777]],
-            [(268404.5296770451, 5479539.256472221),
-                [5, 2, 2, 1, 1, 1, 5, 0, 1.9306977288, 0, 1.9306977288]],
-            [(275802.584962168, 5469283.47691652),
-                [5, 3, 1, 1, 5, 1, 4, 0, 2.2587827631, 0, 2.2587827631]],
-            [(286971.1600469994, 5475571.525391179),
-                [5, 1, 1, 3, 1, 1, 4, 0, 1.7948229213, 0, 1.7948229213]],
-            [(290844.10673833836, 5460752.369983306),
-                [5, 4, 2, 2, 2, 1, 4, 0, 2.5169979012, 0, 2.5169979012]],
-            [(300130.0010361069, 5437435.4230010025),
-                [1.7999999, 4, 5, 2, 5, 1, 3, 0, 2.712353253, 0.426215219066, 3.138568472]],
-            [(306112.5574508078, 5450095.8865171345),
-                [5, 1, 3, 3, 2, 1, 3, 0, 2.225039271, 0, 2.225039271]],
-            [(318255.8192637532, 5423278.964182078),
-                [5, 4, 3, 2, 3, 2.5, 1, 0, 2.6426195539, 0, 2.6426195539]],
-            [(339388.60793905176, 5428895.077843302),
-                [5, 3, 4, 4, 2, 4, 1, 0, 2.94471340036, 0, 2.94471340036]],
-            [(344736.25795214524, 5411347.428706937),
-                [5, 2, 4, 3, 3, 4, 1, 0, 2.8261463109, 0, 2.8261463109]],
-            [(355807.04065901926, 5394736.771414153),
-                [5, 5, 4, 4, 3, 4, 2, 0, 3.70591872429, 0, 3.70591872429]],
-            [(361290.81087879254, 5427975.474574804),
-                [5, 1, 3, 5, 1, 4, 2, 0, 2.493898362454, 0, 2.493898362454]],
-            [(361341.1463245464, 5433678.995435326),
-                [5, 2, 1, 5, 1, 4, 1, 0, 2.1316631165, 0, 2.1316631165]],
-            [(368269.9048903524, 5449541.99543876),
-                [5, 1, 1, 5, 2, 4, 2, 0.008, 2.3535468936, 0, 2.3535468936]],
-            [(376479.9819538344, 5383636.197270025),
-                [4.05, 5, 5, 4, 4, 4, 3, numpy.nan, 4.0989337064, 0.125266204816, 4.2241999112]]
-        ]
-
-        actual_data = []
-        for feature in results_layer:
-            geom = feature.GetGeometryRef()
-            x = geom.GetX()
-            y = geom.GetY()
-            actual_row = []
-            for field in fields:
-                value = feature.GetField(field)
-                if value is None:
-                    actual_row.append(numpy.nan)
-                else:
-                    actual_row.append(value)
-            actual_data.append([(x, y), actual_row])
-
-        # the coords and corresponding field values should be close to expected
-        sorted_actual_data = sorted(actual_data)
-        for actual, expected in zip(sorted_actual_data, sorted_expected_data):
-            actual_coords, actual_data = actual[0], actual[1:]
-            expected_coords, expected_data = expected[0], expected[1:]
-            numpy.testing.assert_allclose(actual_coords, expected_coords)
-            numpy.testing.assert_allclose(actual_data, expected_data)
-
-        # Also expect matching shore_id field in all tabular outputs:
         actual_values_df = pandas.read_csv(
             os.path.join(args['workspace_dir'], 'coastal_exposure.csv'))
-        intermediate_df = pandas.read_csv(
+        expected_values_df = pandas.read_csv(
+            os.path.join(REGRESSION_DATA, 'expected_coastal_exposure.csv'))
+        pandas.testing.assert_frame_equal(
+            actual_values_df, expected_values_df, check_dtype=False)
+
+        # Also expect matching shore_id field in all tabular outputs:
+        intermediate_csv = pandas.read_csv(
             os.path.join(
                 args['workspace_dir'],
                 'intermediate/intermediate_exposure.csv'))
-        habitat_df = pandas.read_csv(
+        habitat_csv = pandas.read_csv(
             os.path.join(
                 args['workspace_dir'],
                 'intermediate/habitats/habitat_protection.csv'))
         pandas.testing.assert_series_equal(
-            actual_values_df['shore_id'], intermediate_df['shore_id'])
+            actual_values_df['shore_id'], intermediate_csv['shore_id'])
         pandas.testing.assert_series_equal(
-            actual_values_df['shore_id'], habitat_df['shore_id'])
+            actual_values_df['shore_id'], habitat_csv['shore_id'])
 
     def test_final_risk_calc(self):
         """CV: regression test for the final risk score calculation."""
@@ -1179,8 +1112,7 @@ class CoastalVulnerabilityTests(unittest.TestCase):
         landmass_path = os.path.join(
             self.workspace_dir, 'landmass.geojson')
         pygeoprocessing.shapely_geometry_to_vector(
-            [Polygon([(-100, -100), (100, -100), (100, 100),
-                      (-100, 100), (-100, -100)])],
+            [Polygon([(-100, -100), (100, -100), (100, 100), (-100, 100), (-100, -100)])],
             landmass_path, wkt, 'GeoJSON')
 
         model_resolution = 100
@@ -1458,7 +1390,6 @@ def assert_pickled_arrays_almost_equal(
 
 class CoastalVulnerabilityValidationTests(unittest.TestCase):
     """Tests for the CV Model ARGS_SPEC and validation."""
-
     def setUp(self):
         """Create a temporary workspace."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -1582,7 +1513,7 @@ class CoastalVulnerabilityValidationTests(unittest.TestCase):
         with self.assertRaises(ValueError):
             for keys, message in warning_messages:
                 if (keys == ['slr_vector_path'] and
-                        message == 'Must be a point or multipoint geometry.'):
+                    message == 'Must be a point or multipoint geometry.'):
                     raise ValueError(warning_messages)
 
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 835743536..3d524aab1 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -5,11 +5,13 @@ import os
 import tempfile
 import shutil
 import logging
+import logging.handlers
 import threading
 import warnings
 import re
 import glob
 import textwrap
+import queue
 
 import numpy
 from osgeo import gdal
@@ -452,6 +454,50 @@ class GDALWarningsLoggingTests(unittest.TestCase):
 
         self.assertEqual(len(messages), 1)
 
+    def test_log_gdal_errors_bad_n_args(self):
+        """utils: test error capture when number of args != 3."""
+        from natcap.invest import utils
+
+        log_queue = queue.Queue()
+        log_queue_handler = logging.handlers.QueueHandler(log_queue)
+        utils.LOGGER.addHandler(log_queue_handler)
+
+        try:
+            # 1 parameter, expected 3
+            utils._log_gdal_errors('foo')
+        finally:
+            utils.LOGGER.removeHandler(log_queue_handler)
+
+        record = log_queue.get()
+        self.assertEqual(record.name, 'natcap.invest.utils')
+        self.assertEqual(record.levelno, logging.ERROR)
+        self.assertIn(
+            '_log_gdal_errors was called with an incorrect number',
+            record.msg)
+
+    def test_log_gdal_errors_missing_param(self):
+        """utils: test error when specific parameters missing."""
+        from natcap.invest import utils
+
+        log_queue = queue.Queue()
+        log_queue_handler = logging.handlers.QueueHandler(log_queue)
+        utils.LOGGER.addHandler(log_queue_handler)
+
+        try:
+            # Missing third parameter, "err_msg"
+            utils._log_gdal_errors(
+                gdal.CE_Failure, 123,
+                bad_param='bad param')  # param obviously bad
+        finally:
+            utils.LOGGER.removeHandler(log_queue_handler)
+
+        record = log_queue.get()
+        self.assertEqual(record.name, 'natcap.invest.utils')
+        self.assertEqual(record.levelno, logging.ERROR)
+        self.assertIn(
+            "_log_gdal_errors called without the argument 'err_msg'",
+            record.msg)
+
 
 class PrepareWorkspaceTests(unittest.TestCase):
     """Test Prepare Workspace."""
@@ -914,7 +960,7 @@ class ReadCSVToDataframeTests(unittest.TestCase):
     def test_csv_with_integer_headers(self):
         """
         utils: CSV with integer headers should be read into strings.
-        
+
         This shouldn't matter for any of the models, but if a user inputs a CSV
         with extra columns that are labeled with numbers, it should still work.
         """
@@ -1408,7 +1454,7 @@ class AssertVectorsEqualTests(unittest.TestCase):
 
 class ReclassifyRasterOpTests(unittest.TestCase):
     """Tests for natcap.invest.utils.reclassify_raster."""
-    
+
     def setUp(self):
         """Setup workspace."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -1426,7 +1472,7 @@ class ReclassifyRasterOpTests(unittest.TestCase):
         projection_wkt = srs_copy.ExportToWkt()
         origin = (1180000, 690000)
         raster_path = os.path.join(self.workspace_dir, 'tmp_raster.tif')
-        
+
         array = numpy.array([[1,1,1], [2,2,2], [3,3,3]], dtype=numpy.int32)
 
         pygeoprocessing.numpy_array_to_raster(
@@ -1437,12 +1483,12 @@ class ReclassifyRasterOpTests(unittest.TestCase):
             self.workspace_dir, 'tmp_raster_out.tif')
 
         message_details = {
-            'raster_name': 'LULC', 'column_name': 'lucode', 
+            'raster_name': 'LULC', 'column_name': 'lucode',
             'table_name': 'Biophysical'}
 
         with self.assertRaises(ValueError) as context:
             utils.reclassify_raster(
-                (raster_path, 1), value_map, target_raster_path, 
+                (raster_path, 1), value_map, target_raster_path,
                 gdal.GDT_Int32, -1, error_details=message_details)
         expected_message = (
                 "Values in the LULC raster were found that are"