Repository: natcap/invest (python)

Issue:
CV: edge case in landmass, AOI intersection
This turned up here: https://community.naturalcapitalproject.org/t/coastal-vulnerability-typeerror-cannot-unpack-non-iterable-nonetype-object/2949/7

```
2022-07-27 11:11:13,542 (natcap.invest.utils) utils.prepare_workspace(167) ERROR Exception while executing natcap.invest.coastal_vulnerability
Traceback (most recent call last):
File “natcap\invest\utils.py”, line 165, in prepare_workspace
File “natcap\invest\cli.py”, line 486, in main
File “natcap\invest\coastal_vulnerability.py”, line 476, in execute
File “taskgraph\Task.py”, line 674, in add_task
File “taskgraph\Task.py”, line 1093, in _call
File “natcap\invest\coastal_vulnerability.py”, line 807, in prepare_landmass_line_index_and_interpolate_shore_points
File “shapely\ops.py”, line 113, in linemerge
File “shapely\geometry\multilinestring.py”, line 49, in init
File “shapely\geometry\multilinestring.py”, line 141, in geos_multilinestring_from_py
TypeError: cannot unpack non-iterable NoneType object
```

In this case the landmass polygon has two vertices that are basically identical. The model converts the polygons to lines and then intersects each line with the AOI polygon. We end up trying to intersect a polygon with a line where the start and end coordinate pairs are the same, like this:

```python
from shapely.geometry import Point, LineString
polygon = Point(0, 0).buffer(2)
polygon.intersection(LineString([(1, 1), (1, 1)]))
```

This returns an object of the expected type `shapely.geometry.linestring.LineString`, but it's `coords` property is an empty sequence. 

The `LineString` from the user's data has these coordinates, but the return value from the intersection is the same as the simple example above.
```
(420275.798461657,  6042939.285698998)
(420275.7984616568, 6042939.285698998)
```

We actually already try to guard against a `LineString` like this in CV, probably because duplicate vertices are not that uncommon in GIS data. But in this case we probably just need to be using `numpy.isclose` to do so.


Pull request description:
Bugfix CV landmass-AOI intersection
I think the bug is well-defined in #1040 . This PR adds a test case and a fix. I had a hard time re-creating the bug with synthetic data, but ultimately was able to by using precisely the same vertices that appeared in the user's data. 

Fixes #1040 

## Checklist
- [x] Updated HISTORY.rst (if these changes are user-facing)
- [ ] Updated the user's guide (if needed)
- [ ] Tested the affected models' UIs (if relevant)


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index bd145c749..50eb101a0 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -46,6 +46,9 @@ Unreleased Changes
     * Fixed a bug where using unaligned rasters in the preprocessor would cause
       an error.  The preprocessor will now correctly align input landcover
       rasters and determine transitions from the aligned rasters.
+* Coastal Vulnerability
+    * Fixed a bug where redundant vertices in the landmass polygon could
+      raise an error during shore point creation.
 * NDR
     * Added parameters to the sample data to support nitrogen calculations.
 * Urban Flood Risk
diff --git a/Makefile b/Makefile
index 01fa21dce..0ce17eac9 100644
--- a/Makefile
+++ b/Makefile
@@ -214,16 +214,16 @@ $(GIT_SAMPLE_DATA_REPO_PATH): | $(DATA_DIR)
 	-git clone $(GIT_SAMPLE_DATA_REPO) $(GIT_SAMPLE_DATA_REPO_PATH)
 	git -C $(GIT_SAMPLE_DATA_REPO_PATH) fetch
 	git -C $(GIT_SAMPLE_DATA_REPO_PATH) lfs install
-	git -C $(GIT_SAMPLE_DATA_REPO_PATH) checkout $(GIT_SAMPLE_DATA_REPO_REV)
 	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)
 	git -C $(GIT_TEST_DATA_REPO_PATH) fetch
 	git -C $(GIT_TEST_DATA_REPO_PATH) lfs install
-	git -C $(GIT_TEST_DATA_REPO_PATH) checkout $(GIT_TEST_DATA_REPO_REV)
 	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/README_PYTHON.rst b/README_PYTHON.rst
index f817338c5..a09a7eb64 100644
--- a/README_PYTHON.rst
+++ b/README_PYTHON.rst
@@ -33,12 +33,18 @@ Installing InVEST
 Python Dependencies
 -------------------
 
-Dependencies for ``natcap.invest`` are listed in ``requirements.txt``.
+Dependencies for ``natcap.invest`` are listed in ``requirements.txt``:
+
+.. code-block::
+
+{requirements}
+
 Additionally, a python binding for Qt is needed to use the InVEST GUI, but is
 not required for development against ``natcap.invest``.  InVEST uses the
 interface library ``qtpy`` to support ``PyQt5`` and ``PySide2``.  In our 
-experience, ``PyQt5`` and ``PySide2`` have been easiest to work with.  One of
-these bindings for Qt must be installed in order to use the GUI.
+experience, ``PyQt5`` and ``PySide2`` have been easiest
+to work with.  One of these bindings for Qt must be installed in order to use
+the GUI.
 
 
 Installing from Source
diff --git a/pyproject.toml b/pyproject.toml
index 09b44fe9d..19ff92b54 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,40 +1,3 @@
-[project]
-name = "natcap.invest"
-description = "InVEST Ecosystem Service models"
-readme = "README_PYTHON.rst"
-requires-python = ">=3.8,<3.11"
-license = {file = "LICENSE.txt"}
-maintainers = [
-    {name = "Natural Capital Project Software Team"}
-]
-keywords = ["gis", "invest", "ecosystem services"]
-classifiers = [
-    "Intended Audience :: Developers",
-    "Development Status :: 5 - Production/Stable",
-    "Intended Audience :: Science/Research",
-    "Natural Language :: English",
-    "Operating System :: MacOS :: MacOS X",
-    "Operating System :: Microsoft",
-    "Operating System :: POSIX",
-    "Programming Language :: Python :: 3",
-    "Programming Language :: Python :: 3.8",
-    "Programming Language :: Python :: 3.9",
-    "Programming Language :: Python :: 3.10",
-    "Programming Language :: Cython",
-    "License :: OSI Approved :: BSD License",
-    "Topic :: Scientific/Engineering :: GIS"
-]
-# the version is provided dynamically by setuptools_scm
-# `dependencies` and `optional-dependencies` are provided by setuptools
-# using the corresponding setup args `install_requires` and `extras_require`
-dynamic = ["version", "dependencies", "optional-dependencies"]
-
-[project.urls]
-homepage = "http://github.com/natcap/invest"
-
-[project.scripts]
-invest = "natcap.invest.cli:main"
-
 [build-system]
 # these are the minimum packages needed in order to execute the natcap.invest
 # build.
@@ -54,6 +17,3 @@ build-backend = "setuptools.build_meta"
 [tool.setuptools_scm]
 version_scheme = "post-release"
 local_scheme = "node-and-date"
-
-[tool.setuptools.packages.find]
-where = ["src"]
diff --git a/setup.py b/setup.py
index 7bc822d38..89373c75c 100644
--- a/setup.py
+++ b/setup.py
@@ -1,8 +1,20 @@
+# encoding=UTF-8
+"""setup.py module for natcap.invest.
+
+InVEST - Integrated Valuation of Ecosystem Services and Tradeoffs
+
+Common functionality provided by setup.py:
+    build_sphinx
+
+For other commands, try `python setup.py --help-commands`
+"""
+import os
 import platform
+import subprocess
 
 import Cython.Build
 import numpy
-from setuptools import setup
+from setuptools import find_namespace_packages, setup
 from setuptools.command.build_py import build_py as _build_py
 from setuptools.extension import Extension
 
@@ -16,6 +28,8 @@ _GUI_REQUIREMENTS = [req.split(';')[0].split('#')[0].strip() for req in
                      open('requirements-gui.txt').readlines()
                      if not (req.startswith(('#', 'hg+'))
                              and len(req.strip()) > 0)]
+README = open('README_PYTHON.rst').read().format(
+    requirements='\n'.join(['    ' + r for r in _REQUIREMENTS]))
 
 # Since OSX Mavericks, the stdlib has been renamed.  So if we're on OSX, we
 # need to be sure to define which standard c++ library to use.  I don't have
@@ -49,29 +63,96 @@ class build_py(_build_py):
 
 
 setup(
+    name='natcap.invest',
+    description="InVEST Ecosystem Service models",
+    long_description=README,
+    maintainer='James Douglass',
+    maintainer_email='jdouglass@stanford.edu',
+    url='http://github.com/natcap/invest',
+    packages=find_namespace_packages('src'),
+    package_dir={'': 'src'},
+    include_package_data=True,
     install_requires=_REQUIREMENTS,
-    extras_require={
-        'ui': _GUI_REQUIREMENTS,
-    },
+    python_requires='>=3.8,<3.11',
+    license='BSD',
+    long_description_content_type='text/x-rst',
+    zip_safe=False,
+    keywords='gis invest',
+    classifiers=[
+        'Intended Audience :: Developers',
+        'Development Status :: 5 - Production/Stable',
+        'Intended Audience :: Science/Research',
+        'Natural Language :: English',
+        'Operating System :: MacOS :: MacOS X',
+        'Operating System :: Microsoft',
+        'Operating System :: POSIX',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
+        'Programming Language :: Python :: 3.10',
+        'Programming Language :: Cython',
+        'License :: OSI Approved :: BSD License',
+        'Topic :: Scientific/Engineering :: GIS'
+    ],
     ext_modules=[
         Extension(
-            name=f'natcap.invest.{package}.{module}',
-            sources=[f'src/natcap/invest/{package}/{module}.pyx'],
+            name="natcap.invest.delineateit.delineateit_core",
+            sources=['src/natcap/invest/delineateit/delineateit_core.pyx'],
             include_dirs=[numpy.get_include()],
             extra_compile_args=compiler_and_linker_args,
             extra_link_args=compiler_and_linker_args,
-            language='c++'
-        ) for package, module in [
-            ('delineateit', 'delineateit_core'),
-            ('recreation', 'out_of_core_quadtree'),
-            ('scenic_quality', 'viewshed'),
-            ('ndr', 'ndr_core'),
-            ('sdr', 'sdr_core'),
-            ('seasonal_water_yield', 'seasonal_water_yield_core')
-        ]
+            language="c++"),
+        Extension(
+            name="natcap.invest.recreation.out_of_core_quadtree",
+            sources=[
+                'src/natcap/invest/recreation/out_of_core_quadtree.pyx'],
+            include_dirs=[numpy.get_include()],
+            extra_compile_args=compiler_and_linker_args,
+            extra_link_args=compiler_and_linker_args,
+            language="c++"),
+        Extension(
+            name="natcap.invest.scenic_quality.viewshed",
+            sources=[
+                'src/natcap/invest/scenic_quality/viewshed.pyx'],
+            include_dirs=[numpy.get_include(),
+                          'src/natcap/invest/scenic_quality'],
+            extra_compile_args=compiler_and_linker_args,
+            extra_link_args=compiler_and_linker_args,
+            language="c++"),
+        Extension(
+            name="natcap.invest.ndr.ndr_core",
+            sources=['src/natcap/invest/ndr/ndr_core.pyx'],
+            include_dirs=[numpy.get_include()],
+            extra_compile_args=compiler_and_linker_args,
+            extra_link_args=compiler_and_linker_args,
+            language="c++"),
+        Extension(
+            name="natcap.invest.sdr.sdr_core",
+            sources=['src/natcap/invest/sdr/sdr_core.pyx'],
+            include_dirs=[numpy.get_include()],
+            extra_compile_args=compiler_and_linker_args,
+            extra_link_args=compiler_and_linker_args,
+            language="c++"),
+        Extension(
+            name=("natcap.invest.seasonal_water_yield."
+                  "seasonal_water_yield_core"),
+            sources=[
+                ("src/natcap/invest/seasonal_water_yield/"
+                 "seasonal_water_yield_core.pyx")],
+            include_dirs=[numpy.get_include()],
+            extra_compile_args=compiler_and_linker_args,
+            extra_link_args=compiler_and_linker_args,
+            language="c++"),
     ],
     cmdclass={
         'build_ext': Cython.Build.build_ext,
-        'build_py': build_py
+        'build_py': build_py},
+    entry_points={
+        'console_scripts': [
+            'invest = natcap.invest.cli:main'
+        ],
+    },
+    extras_require={
+        'ui': _GUI_REQUIREMENTS,
     }
 )
diff --git a/src/natcap/invest/coastal_vulnerability.py b/src/natcap/invest/coastal_vulnerability.py
index 6130ea233..ec2492037 100644
--- a/src/natcap/invest/coastal_vulnerability.py
+++ b/src/natcap/invest/coastal_vulnerability.py
@@ -773,8 +773,12 @@ def prepare_landmass_line_index_and_interpolate_shore_points(
         lines_in_aoi_list = []
         for landmass_line in geometry_to_lines(
                 landmass_polygon, include_interiors=False):
-            if (landmass_line.bounds[0] == landmass_line.bounds[2] and
-                    landmass_line.bounds[1] == landmass_line.bounds[3]):
+            # If two landmass vertices were in the same location, they do
+            # not represent endpoints of a valid line segment.
+            if (numpy.isclose(landmass_line.bounds[0],
+                              landmass_line.bounds[2], rtol=1e-7) and
+                numpy.isclose(landmass_line.bounds[1],
+                              landmass_line.bounds[3], rtol=1e-7)):
                 continue
             polygon_line_rtree.insert(line_id, landmass_line.bounds)
             line_id += 1


Test patch:
diff --git a/tests/test_coastal_vulnerability.py b/tests/test_coastal_vulnerability.py
index 14eeac1b3..4ee2d1e74 100644
--- a/tests/test_coastal_vulnerability.py
+++ b/tests/test_coastal_vulnerability.py
@@ -1267,6 +1267,49 @@ class CoastalVulnerabilityTests(unittest.TestCase):
 
         self.assertEqual(n_points_expected, n_points_actual)
 
+    def test_landmass_with_redundant_vertices(self):
+        """CV: test landmass processing with redundant landmass vertices."""
+        workspace_dir = self.workspace_dir
+
+        aoi_path = os.path.join(workspace_dir, 'aoi.geojson')
+        landmass_path = os.path.join(workspace_dir, 'landmass.geojson')
+        polygon_pickle = os.path.join(
+            workspace_dir, 'polygon.pickle')
+        lines_pickle = os.path.join(
+            workspace_dir, 'lines.pickle')
+        lines_rtree = os.path.join(
+            workspace_dir, 'rtree.dat')
+        target_vector_path = os.path.join(
+            workspace_dir, 'shore_points.gpkg')
+        model_resolution = 1
+        srs = osr.SpatialReference()
+        srs.ImportFromEPSG(32634)  # UTM Zone 34N
+        wkt = srs.ExportToWkt()
+
+        # Nearly identical vertices are from real data that raised an error
+        landmass_coords = [
+            (420274.0, 6042938.0),
+            (420275.7984616568, 6042939.285698998),  # nearly identical
+            (420275.7984616570, 6042939.285698998),  # nearly identical
+            (420276.0, 6042938.0),
+            (420274.0, 6042938.0)]
+        landmass_geometry = Polygon(landmass_coords)
+        aoi_poly = Point(420274.0, 6042938.0).buffer(10)  # contains landmass
+        pygeoprocessing.shapely_geometry_to_vector(
+            [aoi_poly], aoi_path, wkt, 'GeoJSON')
+        pygeoprocessing.shapely_geometry_to_vector(
+                [landmass_geometry], landmass_path, wkt, 'GeoJSON')
+
+        coastal_vulnerability.prepare_landmass_line_index_and_interpolate_shore_points(
+            aoi_path, landmass_path, model_resolution, target_vector_path,
+            polygon_pickle, lines_pickle, lines_rtree)
+
+        # If we exclude the landmass segment with identical start & end points
+        # We should end up with this many segments in the prepared landmass
+        with open(lines_pickle, 'rb') as pickle_file:
+            lines = pickle.load(pickle_file)
+        self.assertEqual(len(lines), 3)
+
     def test_aoi_invalid_geometry(self):
         """CV: test shore point creation with invalid geom in AOI."""
         aoi_path = os.path.join(self.workspace_dir, 'aoi.gpkg')