"""Module for Regression Testing the InVEST Coastal Vulnerability module."""
import json
import os
import pickle
import shutil
import tempfile
import unittest

import numpy.testing
import pandas.testing
import pandas
import pygeoprocessing
import shapely.wkb
import taskgraph
from osgeo import gdal
from osgeo import ogr
from osgeo import osr
from shapely.geometry import LineString
from shapely.geometry import MultiLineString
from shapely.geometry import MultiPolygon
from shapely.geometry import Point
from shapely.geometry import Polygon

gdal.UseExceptions()
REGRESSION_DATA = os.path.join(
    os.path.dirname(__file__), '..', 'data', 'invest-test-data',
    'coastal_vulnerability')
INPUT_DATA = os.path.join(
    os.path.dirname(__file__), '..', 'data', 'invest-test-data',
    'coastal_vulnerability', 'input')


def _mean_op(array, mask):
    if numpy.any(mask):
        return numpy.mean(array[mask])
    return numpy.nan


class CoastalVulnerabilityTests(unittest.TestCase):
    """Tests for the Coastal Vulnerability Model."""

    def setUp(self):
        """Overriding setUp function to create temp workspace directory."""
        # this lets us delete the workspace after its done no matter the
        # the test result
        self.workspace_dir = tempfile.mkdtemp()

    def tearDown(self):
        """Overriding tearDown function to remove temporary directory."""
        shutil.rmtree(self.workspace_dir)

    @staticmethod
    def generate_base_args(workspace_dir):
        """Generate an args dict with default required args."""
        args = {'workspace_dir': workspace_dir,
                'n_workers': -1,
                'wwiii_vector_path': os.path.join(
                    INPUT_DATA, 'WaveWatchIII_subset.shp'),
                'landmass_vector_path': os.path.join(
                    INPUT_DATA, 'land_polygon_simple_utm.shp'),
                'aoi_vector_path': os.path.join(
                    INPUT_DATA, 'AOI_BarkClay.shp'),
                'model_resolution': 25000,
                'max_fetch_distance': 12000,
                'dem_path': os.path.join(
                    INPUT_DATA, 'dem_wgs84.tif'),
                'dem_averaging_radius': 33000.0,
                'bathymetry_raster_path': os.path.join(
                    INPUT_DATA, 'dem_wgs84.tif'),
                'habitat_table_path': os.path.join(
                    INPUT_DATA, "natural_habitats_wcvi.csv"),
                'shelf_contour_vector_path': os.path.join(
                    INPUT_DATA, 'continental_shelf_contour.gpkg')
                }
        return args

    def test_wind_and_wave_exposure(self):
        """CV: regression test for wind and wave exposure values.

        The wave calculation depends on an intermediate product from
        the wind calculation, so I'm testing them both in this scope.

        """
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir

        aoi_path = os.path.join(INPUT_DATA, 'AOI_BarkClay.shp')

        # these points have the WWIII values interpolated onto them.
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, "wwiii_shore_points_5000m.gpkg")
        max_fetch_distance = 12000
        target_fetch_point_vector_path = os.path.join(
            workspace_dir, 'fetch_points.gpkg')
        target_fetch_rays_vector_path = os.path.join(
            workspace_dir, 'fetch_rays.gpkg')
        target_wind_exposure_pickle_path = os.path.join(
            workspace_dir, 'wind.pickle')

        landmass_vector_path = os.path.join(
            INPUT_DATA, 'land_polygon_simple_utm.shp')
        landmass_polygon_pickle_path = os.path.join(
            workspace_dir, 'polygon.pickle')
        landmass_lines_pickle_path = os.path.join(
            workspace_dir, 'lines.pickle')
        landmass_lines_rtree_path = os.path.join(
            workspace_dir, 'lines_rtree.dat')

        base_bathy_raster_path = os.path.join(INPUT_DATA, 'dem_wgs84.tif')
        target_bathy_raster_path = os.path.join(
            workspace_dir, 'negative_bathymetry.tif')
        proxy_vector_info = pygeoprocessing.get_vector_info(
            base_shore_point_vector_path)
        proxy_srs_wkt = proxy_vector_info['projection_wkt']
        proxy_bounding_box = proxy_vector_info['bounding_box']
        # add the max_fetch_distance to the bounding box so we can use
        # the clipped raster in the fetch-ray-depth routine.
        model_resolution = 5000
        # add the model resolution, to be safe
        fetch_buffer = max_fetch_distance + model_resolution
        proxy_bounding_box[0] -= fetch_buffer
        proxy_bounding_box[1] -= fetch_buffer
        proxy_bounding_box[2] += fetch_buffer
        proxy_bounding_box[3] += fetch_buffer

        coastal_vulnerability.warp_and_mask_bathymetry(
            base_bathy_raster_path, proxy_srs_wkt, proxy_bounding_box,
            model_resolution, workspace_dir, '',
            target_bathy_raster_path)

        target_shore_points_path = os.path.join(
            workspace_dir, 'tmp-shore-pts.gpkg')

        coastal_vulnerability.prepare_landmass_line_index_and_interpolate_shore_points(
            aoi_path, landmass_vector_path, model_resolution,
            target_shore_points_path, landmass_polygon_pickle_path,
            landmass_lines_pickle_path, landmass_lines_rtree_path)

        coastal_vulnerability.calculate_wind_exposure(
            base_shore_point_vector_path,
            landmass_polygon_pickle_path,
            landmass_lines_rtree_path,
            landmass_lines_pickle_path,
            target_bathy_raster_path,
            target_fetch_rays_vector_path,
            max_fetch_distance,
            target_fetch_point_vector_path,
            target_wind_exposure_pickle_path)

        expected_raw_values_path = os.path.join(
            REGRESSION_DATA, 'expected_wind.json')
        assert_pickled_arrays_almost_equal(
            target_wind_exposure_pickle_path, expected_raw_values_path)

        target_wave_exposure_pickle_path = os.path.join(
            workspace_dir, 'wave.pickle')
        coastal_vulnerability.calculate_wave_exposure(
            target_fetch_point_vector_path, max_fetch_distance,
            os.path.join(workspace_dir, 'intermediate_wave.gpkg'),
            target_wave_exposure_pickle_path)

        expected_raw_values_path = os.path.join(
            REGRESSION_DATA, 'expected_wave.json')
        assert_pickled_arrays_almost_equal(
            target_wave_exposure_pickle_path, expected_raw_values_path)

    def test_warp_and_mask_bathymetry_with_no_nodata(self):
        """CV: warp_and_mask_bathymetry when bathy has no nodata."""
        from natcap.invest import coastal_vulnerability
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, "wwiii_shore_points_5000m.gpkg")
        workspace_dir = self.workspace_dir
        base_bathy_raster_path = os.path.join(INPUT_DATA, 'dem_wgs84.tif')

        # Copy the raster over to the workspace and unset the nodata value
        # there.
        no_nodata_bathy_raster_path = os.path.join(
            workspace_dir, 'no_nodata_dem_wgs84.tif')
        shutil.copyfile(base_bathy_raster_path, no_nodata_bathy_raster_path)
        try:
            raster = gdal.OpenEx(no_nodata_bathy_raster_path, gdal.GA_Update)
            band = raster.GetRasterBand(1)
            band.DeleteNoDataValue()
        finally:
            band = None
            raster = None
        self.assertEqual(  # Make sure we correctly deleted the nodata value.
            pygeoprocessing.get_raster_info(
                no_nodata_bathy_raster_path)['nodata'][0],
            None)
        target_bathy_raster_path = os.path.join(
            workspace_dir, 'negative_bathymetry.tif')
        proxy_vector_info = pygeoprocessing.get_vector_info(
            base_shore_point_vector_path)
        proxy_srs_wkt = proxy_vector_info['projection_wkt']
        proxy_bounding_box = proxy_vector_info['bounding_box']
        # add the max_fetch_distance to the bounding box so we can use
        # the clipped raster in the fetch-ray-depth routine.
        model_resolution = 5000
        # add the model resolution, to be safe
        max_fetch_distance = 12000
        fetch_buffer = max_fetch_distance + model_resolution
        proxy_bounding_box[0] -= fetch_buffer
        proxy_bounding_box[1] -= fetch_buffer
        proxy_bounding_box[2] += fetch_buffer
        proxy_bounding_box[3] += fetch_buffer

        coastal_vulnerability.warp_and_mask_bathymetry(
            no_nodata_bathy_raster_path, proxy_srs_wkt, proxy_bounding_box,
            model_resolution, workspace_dir, '',
            target_bathy_raster_path)

        # Check that the new raster has a nodata value set.
        self.assertNotEqual(
            pygeoprocessing.get_raster_info(
                target_bathy_raster_path)['nodata'][0],
            None)

    def test_extract_bathymetry(self):
        """CV: regression test for extracting bathymetry along ray."""
        # Make a simple raster
        from natcap.invest import coastal_vulnerability
        raster_path = os.path.join(self.workspace_dir, 'foo.tif')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()
        geotransform = [0, 1.0, 0.0, 0, 0.0, -1.0]
        n = 5
        band1_nodata = numpy.nan
        gtiff_driver = gdal.GetDriverByName('GTiff')
        new_raster = gtiff_driver.Create(
            raster_path, n, n, 2, gdal.GDT_Float32, options=[
                'TILED=YES', 'BIGTIFF=YES', 'COMPRESS=LZW',
                'BLOCKXSIZE=16', 'BLOCKYSIZE=16'])
        new_raster.SetProjection(projection_wkt)
        new_raster.SetGeoTransform(geotransform)
        valid_value = -10.0
        array = numpy.array([valid_value]*n*n).reshape((n, n))

        # nodata across the top row for Band 1
        new_band = new_raster.GetRasterBand(1)
        array[:1] = band1_nodata
        new_band.WriteArray(array)
        new_band.SetNoDataValue(band1_nodata)

        # all nodata for Band 2
        band2_nodata = 999.99
        nodata_band = new_raster.GetRasterBand(2)
        array[:] = band2_nodata
        nodata_band.WriteArray(array)
        nodata_band.SetNoDataValue(band2_nodata)

        new_raster.FlushCache()
        new_band = None
        nodata_band = None
        new_raster = None

        # Ray is only over valid pixels
        all_valid_ray = ogr.Geometry(ogr.wkbLineString)
        all_valid_ray.AddPoint(0, -2)
        all_valid_ray.AddPoint(3, -4)

        # Ray is over some valid and some nodata pixels
        some_nodata_ray = ogr.Geometry(ogr.wkbLineString)
        some_nodata_ray.AddPoint(0, 0)
        some_nodata_ray.AddPoint(4, -4)

        # Ray is only over nodata pixels,
        # so the final extract window will be expanded as needed
        all_nodata_ray = ogr.Geometry(ogr.wkbLineString)
        all_nodata_ray.AddPoint(-0.5, 0.5)
        all_nodata_ray.AddPoint(3.5, 0.5)

        # Ray is partially outside the bounds of the raster
        out_of_bounds_ray = ogr.Geometry(ogr.wkbLineString)
        out_of_bounds_ray.AddPoint(3, -3)
        out_of_bounds_ray.AddPoint(3, -7)

        raster = gdal.OpenEx(raster_path, gdal.OF_RASTER)
        band = raster.GetRasterBand(1)  # nodata across top row

        values = coastal_vulnerability.extract_bathymetry_along_ray(
            all_valid_ray, geotransform, band1_nodata, band)
        self.assertEqual(numpy.mean(values), valid_value)

        values = coastal_vulnerability.extract_bathymetry_along_ray(
            some_nodata_ray, geotransform, band1_nodata, band)
        self.assertEqual(numpy.mean(values), valid_value)

        values = coastal_vulnerability.extract_bathymetry_along_ray(
            all_nodata_ray, geotransform, band1_nodata, band)
        self.assertEqual(numpy.mean(values), valid_value)

        with self.assertRaises(ValueError):
            values = coastal_vulnerability.extract_bathymetry_along_ray(
                out_of_bounds_ray, geotransform, band1_nodata, band)

        nodata_band = raster.GetRasterBand(2)  # all nodata band
        with self.assertRaises(ValueError):
            values = coastal_vulnerability.extract_bathymetry_along_ray(
                all_valid_ray, geotransform, band2_nodata, nodata_band)

        raster = None
        band = None
        nodata_band = None

    def test_wave_height_and_period(self):
        """CV: unit test for wave height and period equaitons."""
        from natcap.invest import coastal_vulnerability
        # Testing with some reasonable values
        U = 15.0   # wind (meters / second)
        F = 10000  # fetch distance (meters)
        D = -20.0  # fetch depth (meters)
        height = coastal_vulnerability.compute_wave_height(U, F, D)
        period = coastal_vulnerability.compute_wave_period(U, F, D)
        numpy.testing.assert_allclose(height, 1.0260516, rtol=0, atol=1e-6)
        numpy.testing.assert_allclose(period, 3.6288811, rtol=0, atol=1e-6)

        # Test with U < 1 and expect U to be forced to 1.0
        F = 10000  # fetch distance (meters)
        D = -20.0  # fetch depth (meters)
        heightA = coastal_vulnerability.compute_wave_height(0.5, F, D)
        heightB = coastal_vulnerability.compute_wave_height(1.0, F, D)
        periodA = coastal_vulnerability.compute_wave_period(0.0, F, D)
        periodB = coastal_vulnerability.compute_wave_period(1.0, F, D)
        numpy.testing.assert_allclose(heightA, heightB, rtol=0, atol=1e-6)
        numpy.testing.assert_allclose(periodA, periodB, rtol=0, atol=1e-6)

    def test_habitat_rank(self):
        """CV: regression test for habitat ranks."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, "wwiii_shore_points_5000m.gpkg")
        habitat_table_path = os.path.join(
            INPUT_DATA, "natural_habitats_wcvi.csv")
        target_habitat_protection_path = os.path.join(
            workspace_dir, 'habitat_protection.csv')
        file_suffix = ''
        model_resolution = 5000

        task_graph = taskgraph.TaskGraph(
            os.path.join(workspace_dir, 'taskgraph_dir'), -1)

        task_list, pickle_list = coastal_vulnerability._schedule_habitat_tasks(
            base_shore_point_vector_path, habitat_table_path, model_resolution,
            workspace_dir, file_suffix, task_graph)

        coastal_vulnerability.calculate_habitat_rank(
            pickle_list, target_habitat_protection_path)

        expected_habitat_path = os.path.join(
            REGRESSION_DATA, 'expected_habitat_protection.csv')
        actual_values_df = pandas.read_csv(target_habitat_protection_path)
        expected_values_df = pandas.read_csv(expected_habitat_path)
        pandas.testing.assert_frame_equal(actual_values_df, expected_values_df)

    def test_invalid_habitats_outside_search_radius(self):
        """CV: test habitat search when no valid habitat within range."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        habitat_vector_path = os.path.join(
            workspace_dir, 'invalid_habitat.gpkg')
        _ = make_vector_of_invalid_geoms(habitat_vector_path)

        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, "wwiii_shore_points_5000m.gpkg")
        search_radius = 1  # meter, we don't want to find any habitat.
        habitat_rank = 1
        habitat_id = 'foo'
        target_habitat_pickle_path = os.path.join(
            workspace_dir, 'target.pickle')

        coastal_vulnerability.search_for_vector_habitat(
            base_shore_point_vector_path, search_radius, habitat_rank,
            habitat_id, habitat_vector_path, target_habitat_pickle_path)

        with open(target_habitat_pickle_path, 'rb') as file:
            result = pickle.load(file)
        # When no habitat is found near a point, it gets a protection rank of 5
        self.assertTrue(
            set(list(result.values())) == set([5]))

    def test_geomorphology_rank(self):
        """CV: regression test for geomorphology values."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        geomorphology_vector_path = os.path.join(
            INPUT_DATA, "geomorphology_few_ranks.shp")
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, "wwiii_shore_points_5000m.gpkg")
        target_pickle_path = os.path.join(
            workspace_dir, 'geomorphology.pickle')
        target_missing_data_path = os.path.join(
            workspace_dir, 'missing_data.gpkg')
        model_resolution = 5000

        coastal_vulnerability.calculate_geomorphology_exposure(
            geomorphology_vector_path, 3,
            base_shore_point_vector_path,
            model_resolution, target_pickle_path,
            target_missing_data_path)

        expected_values_pickle_path = os.path.join(
            REGRESSION_DATA, 'expected_geomorphology.json')
        assert_pickled_arrays_almost_equal(
            target_pickle_path, expected_values_pickle_path)

    def test_creation_of_missing_geomorphology_dataset(self):
        """CV: test vector is created when some points are missing geomorph."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        geomorphology_vector_path = os.path.join(
            INPUT_DATA, "geomorphology_few_ranks.shp")
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()
        simple_points_path = os.path.join(
            self.workspace_dir, 'simple_points.gpkg')
        # create a point vector very far from any geomorphology segments:
        point = [Point(0.0, 0.0)]
        fields = {'shore_id': ogr.OFTInteger}
        attrs = [{'shore_id': 0}]
        pygeoprocessing.shapely_geometry_to_vector(
            point, simple_points_path, projection_wkt, 'GPKG',
            fields=fields, attribute_list=attrs, ogr_geom_type=ogr.wkbPoint)
        target_pickle_path = os.path.join(
            workspace_dir, 'geomorphology.pickle')
        target_missing_data_path = os.path.join(
            workspace_dir, 'shore_points_missing_geomorphology.gpkg')
        model_resolution = 5  # makes for very small search radius

        coastal_vulnerability.calculate_geomorphology_exposure(
            geomorphology_vector_path, 3,
            simple_points_path,
            model_resolution, target_pickle_path,
            target_missing_data_path)

        expected_file_path = os.path.join(
            os.path.dirname(target_pickle_path),
            "shore_points_missing_geomorphology.gpkg")
        vector = gdal.OpenEx(expected_file_path, gdal.OF_VECTOR)
        layer = vector.GetLayer()
        n_features = layer.GetFeatureCount()
        layer = None
        vector = None
        self.assertEqual(len(point), n_features)

    def test_surge_exposure_rank(self):
        """CV: regression test for surge exposure values."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, "wwiii_shore_points_5000m.gpkg")
        shelf_contour_path = os.path.join(
            INPUT_DATA, 'continental_shelf_contour.gpkg')
        target_surge_pickle_path = os.path.join(
            workspace_dir, 'surge.pickle')
        coastal_vulnerability.calculate_surge_exposure(
            base_shore_point_vector_path, shelf_contour_path,
            target_surge_pickle_path)
        expected_raw_values_path = os.path.join(
            REGRESSION_DATA, 'expected_surge.json')

        assert_pickled_arrays_almost_equal(
            target_surge_pickle_path, expected_raw_values_path)

    def test_no_shelf_contour_near_aoi(self):
        """CV: test ValueError raised if shelf contour too far from shore."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, "wwiii_shore_points_5000m.gpkg")
        shore_info = pygeoprocessing.get_vector_info(
            base_shore_point_vector_path)
        bbox = shore_info['bounding_box']
        srs_wkt = shore_info['projection_wkt']

        shelf_contour_path = os.path.join(workspace_dir, 'surge.geojson')
        # Shelf line starts 2000km from bbox, further than the 1500km allowable
        line_a = LineString([
            (bbox[0] - 2e6, bbox[1] - 2e6),
            (bbox[0] - 2e6 - 100, bbox[1] - 2e6 - 100)])
        pygeoprocessing.shapely_geometry_to_vector(
            [line_a], shelf_contour_path, srs_wkt, 'GeoJSON',
            ogr_geom_type=ogr.wkbLineString)

        target_surge_pickle_path = os.path.join(workspace_dir, 'surge.pickle')
        with self.assertRaises(ValueError) as cm:
            coastal_vulnerability.calculate_surge_exposure(
                base_shore_point_vector_path, shelf_contour_path,
                target_surge_pickle_path)
        actual_message = str(cm.exception)
        expected_message = 'No portion of the shelf contour line'
        self.assertTrue(expected_message in actual_message)

    def test_surge_multilinestring_geometry(self):
        """CV: test surge calculation with multipart geometry input."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        aoi_vector_path = os.path.join(
            INPUT_DATA, 'AOI_BarkClay.shp')
        aoi_info = pygeoprocessing.get_vector_info(aoi_vector_path)
        bbox = aoi_info['bounding_box']
        srs_wkt = aoi_info['projection_wkt']

        fields = {'shore_id': ogr.OFTInteger}
        attributes = [{'shore_id': 0}]
        geometries = [
            Point((bbox[0] + bbox[2]) / 2.0, (bbox[1] + bbox[3]) / 2.0)]
        # Make a shore point in center of AOI bbox
        shore_point_path = os.path.join(workspace_dir, 'shore_point.shp')
        pygeoprocessing.shapely_geometry_to_vector(
            geometries, shore_point_path, srs_wkt, 'GeoJSON',
            fields=fields, attribute_list=attributes,
            ogr_geom_type=ogr.wkbPoint)

        # Make surge line as diagonal of AOI bounding box
        # And make it a multi-part geometry to test handling of that case.
        shelf_contour_path = os.path.join(workspace_dir, 'surge.geojson')
        line_a = LineString([(bbox[0], bbox[1]), (bbox[2], bbox[3])])
        line_b = LineString([(bbox[0], bbox[1]), (bbox[2], bbox[3])])
        geometries = [MultiLineString([line_a, line_b])]
        pygeoprocessing.shapely_geometry_to_vector(
            geometries, shelf_contour_path, srs_wkt, 'GeoJSON',
            ogr_geom_type=ogr.wkbMultiLineString)

        target_surge_pickle_path = os.path.join(
            workspace_dir, 'surge.pickle')

        coastal_vulnerability.calculate_surge_exposure(
            shore_point_path, shelf_contour_path,
            target_surge_pickle_path)

        # shore point is at center of AOI bounding box
        # shelf contour is the diagonal of the bounding box
        # So, expected value for distance to shelf contour is 0.0
        with open(target_surge_pickle_path, 'rb') as file:
            expected_vals = pickle.load(file)
        self.assertTrue(expected_vals[0] == 0.0)

    def test_relief_values(self):
        """CV: regression test for aggregated relief values."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, 'wwiii_shore_points_5000m.gpkg')
        dem_path = os.path.join(
            INPUT_DATA, 'dem_wgs84.tif')
        target_relief_pickle_path = os.path.join(
            workspace_dir, 'relief.pickle')
        dem_averaging_radius = 20000.0
        model_resolution = 5000.0
        file_suffix = ''

        coastal_vulnerability.calculate_relief_exposure(
            base_shore_point_vector_path, dem_path, dem_averaging_radius,
            model_resolution, workspace_dir, file_suffix,
            target_relief_pickle_path)

        expected_raw_values_path = os.path.join(
            REGRESSION_DATA, 'expected_relief.json')
        assert_pickled_arrays_almost_equal(
            target_relief_pickle_path, expected_raw_values_path)

    def test_population_values(self):
        """CV: regression test for aggregated population density."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, 'wwiii_shore_points_5000m.gpkg')
        population_path = os.path.join(
            INPUT_DATA, 'population.tif')
        target_population_pickle_path = os.path.join(
            workspace_dir, 'population.pickle')
        search_radius = 20000.0
        model_resolution = 5000.0
        file_suffix = ''

        coastal_vulnerability.aggregate_population_density(
            base_shore_point_vector_path, population_path, search_radius,
            model_resolution, workspace_dir, file_suffix,
            target_population_pickle_path)

        expected_raw_values_path = os.path.join(
            REGRESSION_DATA, 'expected_population.json')

        assert_pickled_arrays_almost_equal(
            target_population_pickle_path, expected_raw_values_path)

    def test_interpolate_slr(self):
        """CV: regression test for sea-level rise values.

        This tests an edge case where there is only one point in the
        SLR dataset, and it requires a coordinate transformation.
        """
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_shore_point_vector_path = os.path.join(
            INPUT_DATA, 'wwiii_shore_points_5000m.gpkg')

        # Make an SLR point vector
        slr_fieldname = 'Trend'
        slr_point_vector_path = os.path.join(
            workspace_dir, 'simple_points.shp')
        out_driver = ogr.GetDriverByName('ESRI Shapefile')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(4326)
        shapely_feature = Point(-125.65, 49.0)
        out_vector = out_driver.CreateDataSource(slr_point_vector_path)
        layer_name = os.path.basename(
            os.path.splitext(slr_point_vector_path)[0])
        out_layer = out_vector.CreateLayer(layer_name, srs=srs)
        field_defn = ogr.FieldDefn(slr_fieldname, ogr.OFTReal)
        out_layer.CreateField(field_defn)
        layer_defn = out_layer.GetLayerDefn()
        new_feature = ogr.Feature(layer_defn)
        new_geometry = ogr.CreateGeometryFromWkb(shapely_feature.wkb)
        new_feature.SetGeometry(new_geometry)
        new_feature.SetField(slr_fieldname, 1.3)
        out_layer.CreateFeature(new_feature)
        out_layer = None
        out_vector = None

        target_pickle_path = os.path.join(
            workspace_dir, 'slr.pickle')
        coastal_vulnerability.interpolate_sealevelrise_points(
            base_shore_point_vector_path, slr_point_vector_path,
            slr_fieldname, target_pickle_path)

        expected_raw_values_path = os.path.join(
            REGRESSION_DATA, 'expected_slr.json')

        assert_pickled_arrays_almost_equal(
            target_pickle_path, expected_raw_values_path)

    def test_interpolate_slr_beyond_maxdistance(self):
        """CV: test sea-level rise returns nan beyond max search distance."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir

        # Make a shore point
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()
        shore_point_path = os.path.join(workspace_dir, 'shore_point.shp')
        fields = {'shore_id': ogr.OFTInteger}
        attributes = [{'shore_id': 0}]

        pygeoprocessing.shapely_geometry_to_vector(
            [Point(0., 0.)], shore_point_path, projection_wkt, 'GeoJSON',
            fields=fields, attribute_list=attributes,
            ogr_geom_type=ogr.wkbPoint)

        slr_point_vector_path = os.path.join(workspace_dir, 'slr_point.shp')
        slr_fieldname = 'Trend'
        shapely_point = Point(1e6, 1e6)  # very far from the shore point
        make_slr_vector(
            slr_point_vector_path, slr_fieldname, shapely_point, srs)

        target_pickle_path = os.path.join(
            workspace_dir, 'slr.pickle')
        coastal_vulnerability.interpolate_sealevelrise_points(
            shore_point_path, slr_point_vector_path,
            slr_fieldname, target_pickle_path)

        with open(target_pickle_path, 'rb') as file:
            actual_values = pickle.load(file)
        expected_values = numpy.array([numpy.nan])

        numpy.testing.assert_allclose(
            list(actual_values.values()), expected_values, rtol=0, atol=1e-4)

    def test_slr_missing_field(self):
        """CV: test KeyError raised if slr field is not present in vector."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        # Make a shore point
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()
        shore_point_path = os.path.join(workspace_dir, 'shore_point.shp')
        fields = {'shore_id': ogr.OFTInteger}
        attributes = [{'shore_id': 0}]

        pygeoprocessing.shapely_geometry_to_vector(
            [Point(0., 0.)], shore_point_path, projection_wkt, 'GeoJSON',
            fields=fields, attribute_list=attributes,
            ogr_geom_type=ogr.wkbPoint)

        slr_point_vector_path = os.path.join(workspace_dir, 'slr_point.shp')
        slr_fieldname = 'Trend'
        shapely_point = Point(1., 1.)
        make_slr_vector(
            slr_point_vector_path, slr_fieldname, shapely_point, srs)
        nonexistent_field = 'foo'

        with self.assertRaises(KeyError) as cm:
            coastal_vulnerability.interpolate_sealevelrise_points(
                shore_point_path, slr_point_vector_path,
                nonexistent_field, 'target.pickle')
        actual_message = str(cm.exception)
        expected_message = 'fieldname %s not found in' % nonexistent_field
        self.assertTrue(expected_message in actual_message)

    def test_aggregate_raster_edge_cases(self):
        """CV: test literal edge-cases in raster aggregation."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        raster_path = os.path.join(workspace_dir, 'simple_raster.tif')
        target_pickle_path = os.path.join(workspace_dir, 'target.pickle')

        # Make a simple raster
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()
        geotransform = [0, 0.5, 0.0, 0, 0.0, -0.5]
        n = 5
        nodata_val = -1
        gtiff_driver = gdal.GetDriverByName('GTiff')
        new_raster = gtiff_driver.Create(
            raster_path, n, n, 1, gdal.GDT_Int32, options=[
                'TILED=YES', 'BIGTIFF=YES', 'COMPRESS=LZW',
                'BLOCKXSIZE=16', 'BLOCKYSIZE=16'])
        new_raster.SetProjection(projection_wkt)
        new_raster.SetGeoTransform(geotransform)
        new_band = new_raster.GetRasterBand(1)
        array = numpy.array(range(n**2)).reshape((n, n))
        new_band.WriteArray(array)
        if nodata_val is not None:
            new_band.SetNoDataValue(nodata_val)
        new_raster.FlushCache()
        new_band = None
        new_raster = None

        search_radius = geotransform[1] * 3
        geometries = [
            Point(0.1, -0.1),    # pixel (0,0): kernel upper-left out of bounds
            Point(1.25, -1.25),  # pixel (2,2): kernel upper-left & lower-right O.O.B
            Point(2.1, -2.1),    # pixel (4,4): kernel lower-right out of bounds
            Point(-10, 10)       # entire kernel out of bounds
        ]
        fields = {'shore_id': ogr.OFTInteger}
        attributes = [{'shore_id': 0}, {'shore_id': 1},
                      {'shore_id': 2}, {'shore_id': 3}]
        # Make a vector proximate to the raster
        simple_points_path = os.path.join(workspace_dir, 'simple_points.geojson')
        pygeoprocessing.shapely_geometry_to_vector(
            geometries, simple_points_path, projection_wkt, 'GeoJSON',
            fields=fields, attribute_list=attributes,
            ogr_geom_type=ogr.wkbPoint)

        coastal_vulnerability._aggregate_raster_values_in_radius(
            simple_points_path, raster_path, search_radius, self.workspace_dir,
            target_pickle_path, _mean_op)

        with open(target_pickle_path, 'rb') as file:
            actual_values = pickle.load(file)

        expected_values = numpy.array([6.5454, 12.0, 17.4545, numpy.nan])

        numpy.testing.assert_allclose(
            list(actual_values.values()), expected_values, rtol=0, atol=1e-4)

    def test_nodata_raster_aggregation(self):
        """CV: test raster aggregation over entirely nodata returns nan."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        raster_path = os.path.join(workspace_dir, 'nodata_raster.tif')
        target_pickle_path = os.path.join(workspace_dir, 'target.pickle')
        sample_distance = 1.5

        # Make a simple raster filled with all nodata
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()
        geotransform = [0, 0.5, 0.0, 0, 0.0, -0.5]
        n = 5
        nodata_val = -1
        gtiff_driver = gdal.GetDriverByName('GTiff')
        new_raster = gtiff_driver.Create(
            raster_path, n, n, 1, gdal.GDT_Int32, options=[
                'TILED=YES', 'BIGTIFF=YES', 'COMPRESS=LZW',
                'BLOCKXSIZE=16', 'BLOCKYSIZE=16'])
        new_raster.SetProjection(projection_wkt)
        new_raster.SetGeoTransform(geotransform)
        new_band = new_raster.GetRasterBand(1)
        array = numpy.array([nodata_val] * n**2).reshape((n, n))
        new_band.WriteArray(array)
        if nodata_val is not None:
            new_band.SetNoDataValue(nodata_val)
        new_raster.FlushCache()
        new_band = None
        new_raster = None

        # Make a vector proximate to the raster
        simple_points_path = os.path.join(workspace_dir, 'simple_points.shp')
        fields = {'shore_id': ogr.OFTInteger}
        attributes = [{'shore_id': 0}]
        pygeoprocessing.shapely_geometry_to_vector(
            [Point(0., 0.)], simple_points_path, projection_wkt, 'GeoJSON',
            fields=fields, attribute_list=attributes,
            ogr_geom_type=ogr.wkbPoint)

        coastal_vulnerability._aggregate_raster_values_in_radius(
            simple_points_path, raster_path, sample_distance,
            self.workspace_dir, target_pickle_path, _mean_op)

        with open(target_pickle_path, 'rb') as file:
            actual_values = pickle.load(file)

        expected_values = numpy.array([numpy.nan])
        numpy.testing.assert_allclose(
            list(actual_values.values()), expected_values, rtol=0, atol=1e-4)

    def test_complete_run(self):
        """CV: regression test for a complete run w/ all optional arguments."""
        from natcap.invest import coastal_vulnerability
        args = CoastalVulnerabilityTests.generate_base_args(self.workspace_dir)
        # these optional args aren't included in base_args:
        args['geomorphology_vector_path'] = os.path.join(
            INPUT_DATA, 'geomorphology_few_ranks.shp')
        args['geomorphology_fill_value'] = 3
        args['population_raster_path'] = os.path.join(
            INPUT_DATA, 'population.tif')
        args['population_radius'] = 16000
        args['slr_vector_path'] = os.path.join(
            INPUT_DATA, 'sea_level_rise.gpkg')
        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(
            os.path.join(
                args['workspace_dir'],
                'intermediate/intermediate_exposure.csv'))
        habitat_df = 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'])
        pandas.testing.assert_series_equal(
            actual_values_df['shore_id'], habitat_df['shore_id'])

    def test_final_risk_calc(self):
        """CV: regression test for the final risk score calculation."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir

        target_point_vector_path = os.path.join(
            workspace_dir, 'coastal_exposure.gpkg')
        target_point_csv_path = os.path.join(
            workspace_dir, 'coastal_exposure.csv')

        # Points with ranks for the final equation. Also includes a field
        # without the R_ prefix, which final equation should ignore.
        base_vector_path = os.path.join(
            REGRESSION_DATA, 'coastal_exposure.gpkg')

        # This input gets modified in place, so first copy to working dir
        base_shore_point_vector = gdal.OpenEx(base_vector_path, gdal.OF_VECTOR)
        gdal.VectorTranslate(target_point_vector_path, base_shore_point_vector)

        coastal_vulnerability.calculate_final_risk(
            target_point_vector_path, target_point_csv_path)

        actual_values_df = pandas.read_csv(target_point_csv_path)
        expected_values_df = pandas.read_csv(
            os.path.join(REGRESSION_DATA, 'expected_final_risk.csv'))
        pandas.testing.assert_frame_equal(
            actual_values_df, expected_values_df, check_dtype=False)

    def test_geometric_mean_with_nan(self):
        """CV: test geometric mean function retuns `nan` with missing data."""
        from natcap.invest import coastal_vulnerability
        array = numpy.array([1, 1, 1, 1, None], dtype=float)
        result = coastal_vulnerability._geometric_mean(array)
        self.assertTrue(numpy.isnan(result))

    def test_final_risk_calc_with_missing_data(self):
        """CV: test final risk calculation w/ missing data.

        Test missing data at feature propogates to empty field in output.
        """
        from natcap.invest import coastal_vulnerability
        target_vector_path = os.path.join(self.workspace_dir, 'target.gpkg')
        target_csv_path = os.path.join(self.workspace_dir, 'target.csv')

        # This gpkg has a feature with an empty field value for 'R_slr'
        # The function modifies the file in place, so copy to test workspace
        # first.
        base_vector_path = os.path.join(
            REGRESSION_DATA, 'test_missing_values.gpkg')
        gdal.VectorTranslate(target_vector_path, base_vector_path)

        coastal_vulnerability.calculate_final_risk(
            target_vector_path, target_csv_path)
        actual_values_df = pandas.read_csv(target_csv_path)

        # These fields should have missing values after the final calculations
        na_cols = ['exposure', 'habitat_role', 'exposure_no_habitats']
        na_data = [numpy.nan] * 3
        expected_df = pandas.DataFrame([na_data], columns=na_cols)

        pandas.testing.assert_frame_equal(
            actual_values_df[na_cols], expected_df)

    def test_binning_with_missing_data(self):
        """CV: test binning continuous values to ranks, w/ missing values."""
        from natcap.invest import coastal_vulnerability
        n = 50
        mask = [10, 20, 30, 40]
        keys = range(n)
        numpy.random.seed(0)
        values = numpy.random.uniform(0, 1, n)
        values[mask] = numpy.nan
        missing_values_dict = dict(zip(keys, values))

        ranks_dict = coastal_vulnerability._bin_values_to_percentiles(
            missing_values_dict)

        # with random uniform values, all 5 ranks should be present
        expected_ranks = set([1, 2, 3, 4, 5])
        self.assertTrue(expected_ranks.issubset(ranks_dict.values()))
        # and the masked indices should be nans
        self.assertTrue(
            all(numpy.isnan(numpy.array(list(ranks_dict.values()))[mask])))

    def test_calc_habitat_rank_with_missing_values(self):
        """CV: test calc Rhab when a habitat has a missing value."""
        from natcap.invest import coastal_vulnerability
        rank = coastal_vulnerability._calc_Rhab([3, 3, 3, numpy.nan])
        self.assertTrue(numpy.isnan(rank))

    def test_positive_dem_with_nodata_floats(self):
        """CV: test coercing negative DEM values to zero.

        More specifically, test the edge case where the input values are
        floats and very large/small values that overflow an Int16.

        """
        from natcap.invest import coastal_vulnerability
        n = 5
        nodata_val = -99999

        array = numpy.array(
            [nodata_val] * n**2, dtype=numpy.float32).reshape((n, n))

        pos_array = coastal_vulnerability.zero_negative_values(
            array, nodata_val)
        # It's all nodata going in, so should be all same nodata out.
        numpy.testing.assert_array_equal(array, pos_array)

    def test_dem_undefined_nodata(self):
        """CV: test coercing negative DEM values to zero, undefined nodata."""
        from natcap.invest import coastal_vulnerability
        n = 5
        nodata_val = -99999

        array = numpy.array(
            [nodata_val] * n**2, dtype=numpy.float32).reshape((n, n))

        pos_array = coastal_vulnerability.zero_negative_values(
            array, None)
        # It's all negative going in, so should be all 0s out.
        numpy.testing.assert_array_equal(numpy.zeros_like(array), pos_array)

    def test_shore_points_on_single_polygon(self):
        """CV: test shore point creation with single polygon landmass."""
        from natcap.invest import coastal_vulnerability
        aoi_path = os.path.join(self.workspace_dir, 'aoi.geojson')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        wkt = srs.ExportToWkt()

        aoi_geometries = [Polygon([
            (-200, -200), (200, -200), (200, 200), (-200, 200), (-200, -200)])]
        pygeoprocessing.shapely_geometry_to_vector(
            aoi_geometries, aoi_path, wkt, 'GeoJSON')

        landmass_path = os.path.join(
            self.workspace_dir, 'landmass.geojson')

        landmass_geometries = [Polygon([
            (-100, -100), (100, -100), (100, 100), (-100, 100), (-100, -100)])]
        pygeoprocessing.shapely_geometry_to_vector(
            landmass_geometries, landmass_path, wkt, 'GeoJSON')

        model_resolution = 100

        polygon_pickle = os.path.join(
            self.workspace_dir, 'polygon.pickle')
        lines_pickle = os.path.join(
            self.workspace_dir, 'lines.pickle')
        lines_rtree = os.path.join(
            self.workspace_dir, 'rtree.dat')
        target_vector_path = os.path.join(
            self.workspace_dir, 'shore_points.gpkg')

        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)

        vector = gdal.OpenEx(
            target_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
        layer = vector.GetLayer()
        n_points = layer.GetFeatureCount()
        self.assertTrue(n_points == 8)

    def test_shore_points_on_multi_polygon(self):
        """CV: test shore point creation with multipolygon landmass."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir

        aoi_path = os.path.join(workspace_dir, 'aoi.geojson')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        wkt = srs.ExportToWkt()

        aoi_geometries = [Polygon([
            (-200, -200), (200, -200), (200, 200), (-200, 200), (-200, -200)])]
        pygeoprocessing.shapely_geometry_to_vector(
            aoi_geometries, aoi_path, wkt, 'GeoJSON')

        landmass_path = os.path.join(workspace_dir, 'landmass.geojson')
        poly_a = Polygon([
            (-200, -200), (-100, -200), (-100, -100), (-200, -100),
            (-200, -200)])
        poly_b = Polygon([
            (100, 100), (200, 100), (200, 200), (100, 200), (100, 100)])
        landmass_geometries = [poly_a, poly_b, MultiPolygon([poly_a, poly_b])]
        pygeoprocessing.shapely_geometry_to_vector(
            landmass_geometries, landmass_path, wkt, 'GeoJSON',
            ogr_geom_type=ogr.wkbUnknown)

        model_resolution = 100

        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')

        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)

        vector = gdal.OpenEx(
            target_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
        layer = vector.GetLayer()
        n_points = layer.GetFeatureCount()
        self.assertTrue(n_points == 8)

    def test_zero_shorepoints_created(self):
        """CV: test RuntimeError raised if zero shore points are created."""
        from natcap.invest import coastal_vulnerability
        aoi_path = os.path.join(self.workspace_dir, 'aoi.geojson')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        wkt = srs.ExportToWkt()
        a = 100

        aoi_geometries = [Polygon([
            (-a, -a), (a, -a), (a, a), (-a, a), (-a, -a)])]
        pygeoprocessing.shapely_geometry_to_vector(
            aoi_geometries, aoi_path, wkt, 'GeoJSON')

        landmass_path = os.path.join(self.workspace_dir, 'landmass.geojson')
        # This lanmass fully encloses the AOI, but has no edges within the AOI.
        # Such a landmass would pass validation's spatial overlap check.
        b = a * 2

        landmass_geometries = [Polygon([
            (-b, -b), (b, -b), (b, b), (-b, b), (-b, -b)])]
        pygeoprocessing.shapely_geometry_to_vector(
            landmass_geometries, landmass_path, wkt, 'GeoJSON')

        model_resolution = 5

        polygon_pickle = os.path.join(
            self.workspace_dir, 'polygon.pickle')
        lines_pickle = os.path.join(
            self.workspace_dir, 'lines.pickle')
        lines_rtree = os.path.join(
            self.workspace_dir, 'rtree.dat')
        target_vector_path = os.path.join(
            self.workspace_dir, 'shore_points.gpkg')

        with self.assertRaises(RuntimeError):
            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)

    def test_aoi_multiple_features(self):
        """CV: test shore point creation in AOI with multiple features."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir

        aoi_path = os.path.join(workspace_dir, 'aoi.geojson')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        wkt = srs.ExportToWkt()
        # These two disjoint AOI polygons intersect the same landmass line
        # segment. This tests an edge case where a MultiLineString
        # geometry is created when landmass lines are clipped by the AOI.
        poly_a = Polygon([
            (-200, -200), (-100, -200), (-100, -100), (-200, -100),
            (-200, -200)])
        poly_b = Polygon([
            (100, -200), (200, -200), (200, -100), (100, -100),
            (100, -200)])

        pygeoprocessing.shapely_geometry_to_vector(
            [poly_a, poly_b], aoi_path, wkt, 'GeoJSON')

        landmass_path = os.path.join(workspace_dir, 'landmass.geojson')

        landmass_geometries = [Polygon([
            (-190, -190), (190, -190), (190, 190), (-190, 190), (-190, -190)])]
        pygeoprocessing.shapely_geometry_to_vector(
            landmass_geometries, landmass_path, wkt, 'GeoJSON')

        model_resolution = 80

        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')

        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)

        vector = gdal.OpenEx(
            target_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
        layer = vector.GetLayer()
        n_points = layer.GetFeatureCount()
        self.assertTrue(n_points == 6)

    def test_landmass_interior_holes(self):
        """CV: test no shore points created on interior holes."""
        from natcap.invest import coastal_vulnerability
        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 = 100
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        wkt = srs.ExportToWkt()

        aoi_poly = Polygon([
            (-200, -200), (200, -200), (200, 200), (-200, 200), (-200, -200)])
        pygeoprocessing.shapely_geometry_to_vector(
            [aoi_poly], aoi_path, wkt, 'GeoJSON')

        def count_shore_points(landmass_geometries):
            pygeoprocessing.shapely_geometry_to_vector(
                [landmass_geometries], 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)

            vector = gdal.OpenEx(
                target_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
            layer = vector.GetLayer()
            return layer.GetFeatureCount()

        # Count points created by a landmass with no holes, this will
        # be the expected count for the landmass with holes.
        exterior = [
            (-190, -190), (190, -190), (190, 190), (-190, 190), (-190, -190)]
        landmass_poly_no_holes = Polygon(exterior)
        n_points_expected = count_shore_points(landmass_poly_no_holes)

        interior = [
            (-100, -100), (100, -100), (100, 100), (-100, 100), (-100, -100)]
        landmass_poly_holes = Polygon(exterior, [interior])
        n_points_actual = count_shore_points(landmass_poly_holes)

        self.assertEqual(n_points_expected, n_points_actual)

    def test_landmass_with_redundant_vertices(self):
        """CV: test landmass processing with redundant landmass vertices."""
        from natcap.invest import coastal_vulnerability
        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."""
        from natcap.invest import coastal_vulnerability
        aoi_path = os.path.join(self.workspace_dir, 'aoi.gpkg')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        wkt = srs.ExportToWkt()
        _ = make_vector_of_invalid_geoms(aoi_path)

        # In this test, it won't matter if the landmass intersects the AOI.
        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)])],
            landmass_path, wkt, 'GeoJSON')

        model_resolution = 100

        polygon_pickle = os.path.join(
            self.workspace_dir, 'polygon.pickle')
        lines_pickle = os.path.join(
            self.workspace_dir, 'lines.pickle')
        lines_rtree = os.path.join(
            self.workspace_dir, 'rtree.dat')
        target_vector_path = os.path.join(
            self.workspace_dir, 'shore_points.gpkg')

        # Getting to the RuntimeError for zero shore points means
        # all the invalid geometries in the AOI were handled.
        with self.assertRaises(RuntimeError):
            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)

    def test_prepare_landmass_invalid_geometry(self):
        """CV: test handling invalid geometries in landmass vector."""
        from natcap.invest import coastal_vulnerability
        aoi_path = os.path.join(self.workspace_dir, 'aoi.geojson')
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        wkt = srs.ExportToWkt()

        aoi_geometries = [Polygon([
            (-200, -200), (200, -200), (200, 200), (-200, 200), (-200, -200)])]
        pygeoprocessing.shapely_geometry_to_vector(
            aoi_geometries, aoi_path, wkt, 'GeoJSON')

        landmass_vector_path = os.path.join(self.workspace_dir, 'vector.gpkg')
        n_features = make_vector_of_invalid_geoms(landmass_vector_path)

        target_polygon_pickle_path = os.path.join(
            self.workspace_dir, 'polygon.pickle')
        target_lines_pickle_path = os.path.join(
            self.workspace_dir, 'lines.pickle')
        target_rtree_path = os.path.join(self.workspace_dir, 'rtree.dat')
        # Create rtree files to exercise the function's logic of removing
        # pre-exisiting files
        target_rtree_path_base = os.path.splitext(target_rtree_path)[0]
        open(target_rtree_path, 'a').close()
        open(target_rtree_path_base + '.idx', 'a').close()

        model_resolution = 100
        target_vector_path = os.path.join(
            self.workspace_dir, 'temp-shore-pts.gpkg')
        coastal_vulnerability.prepare_landmass_line_index_and_interpolate_shore_points(
            aoi_path, landmass_vector_path, model_resolution,
            target_vector_path, target_polygon_pickle_path,
            target_lines_pickle_path, target_rtree_path)

        with open(target_polygon_pickle_path, 'rb') as polygon_file:
            shapely_geom = pickle.load(polygon_file)

        # Expect 1 input geometry to be skipped, and the rest to be in
        # shapely_geom_list.
        self.assertTrue(len(shapely_geom.geoms) == n_features - 1)

    def test_no_wwiii_coverage(self):
        """CV: test exception when shore points are outside max wwiii dist."""
        from natcap.invest import coastal_vulnerability
        args = CoastalVulnerabilityTests.generate_base_args(self.workspace_dir)
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()

        simple_points_path = os.path.join(
            self.workspace_dir, 'simple_points.shp')

        fields = {'shore_id': ogr.OFTInteger}
        attributes = [{'shore_id': 0}]

        pygeoprocessing.shapely_geometry_to_vector(
            [Point(0.0, 0.0)], simple_points_path, projection_wkt, 'GeoJSON',
            fields=fields, attribute_list=attributes,
            ogr_geom_type=ogr.wkbPoint)

        target_path = os.path.join(self.workspace_dir, 'target.gpkg')
        with self.assertRaises(ValueError):
            coastal_vulnerability.interpolate_wwiii_to_shore(
                simple_points_path, args['wwiii_vector_path'],
                target_path)

    def test_projected_wwiii_input(self):
        """CV: test wwiii interpolation with a projected wwiii input vector."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        args = CoastalVulnerabilityTests.generate_base_args(workspace_dir)
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()

        projected_wwiii_vector_path = os.path.join(
            workspace_dir, 'prj_wwiii.shp')
        pygeoprocessing.reproject_vector(
            args['wwiii_vector_path'], srs.ExportToWkt(),
            projected_wwiii_vector_path)

        bbox = pygeoprocessing.get_vector_info(
            projected_wwiii_vector_path)['bounding_box']
        simple_points_path = os.path.join(workspace_dir, 'simple_points.shp')

        geometries = [Point(
            (bbox[0] + bbox[2]) / 2.0, (bbox[1] + bbox[3]) / 2.0)]
        fields = {'shore_id': ogr.OFTInteger}
        attributes = [{'shore_id': 0}]

        pygeoprocessing.shapely_geometry_to_vector(
            geometries, simple_points_path, projection_wkt, 'GeoJSON',
            fields=fields, attribute_list=attributes,
            ogr_geom_type=ogr.wkbPoint)

        target_path = os.path.join(workspace_dir, 'target.gpkg')
        coastal_vulnerability.interpolate_wwiii_to_shore(
            simple_points_path, projected_wwiii_vector_path,
            target_path)

        vector = gdal.OpenEx(target_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
        layer = vector.GetLayer()
        layer_defn = layer.GetLayerDefn()
        n_fields = layer_defn.GetFieldCount()
        self.assertTrue(n_fields == 126)

        # Just spot-checking one field here. Overall correctness is verified by
        # other regression tests.
        feature = layer.GetFeature(1)  # gpkg FID index = 1
        value = feature.GetField('REI_PCT90')
        self.assertTrue(numpy.isclose(value, 0.0146246))

        feature = None
        layer_defn = None
        layer = None
        vector = None

    def test_clip_project_already_projected_raster(self):
        """CV: test clip_and_project_raster on an already projected raster."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_raster_path = os.path.join(workspace_dir, 'nodata_raster.tif')
        target_raster_path = os.path.join(workspace_dir, 'target.tiff')

        # Make a simple raster in a projected SRS
        srs = osr.SpatialReference()
        srs.ImportFromEPSG(26910)  # UTM Zone 10N
        projection_wkt = srs.ExportToWkt()
        geotransform = [0, 0.5, 0.0, 0, 0.0, -0.5]
        n = 5
        nodata_val = -1
        gtiff_driver = gdal.GetDriverByName('GTiff')
        new_raster = gtiff_driver.Create(
            base_raster_path, n, n, 1, gdal.GDT_Int32, options=[
                'TILED=YES', 'BIGTIFF=YES', 'COMPRESS=LZW',
                'BLOCKXSIZE=16', 'BLOCKYSIZE=16'])
        new_raster.SetProjection(projection_wkt)
        new_raster.SetGeoTransform(geotransform)
        new_band = new_raster.GetRasterBand(1)
        array = numpy.array([nodata_val] * n**2).reshape((n, n))
        new_band.WriteArray(array)
        if nodata_val is not None:
            new_band.SetNoDataValue(nodata_val)
        new_raster.FlushCache()
        new_band = None
        new_raster = None

        raster_info = pygeoprocessing.get_raster_info(base_raster_path)
        clipping_box = raster_info['bounding_box']
        target_srs_wkt = raster_info['projection_wkt']
        model_resolution = 9  # expect this to be ignored
        file_suffix = ''

        coastal_vulnerability.clip_and_project_raster(
            base_raster_path, clipping_box, target_srs_wkt, model_resolution,
            workspace_dir, file_suffix, target_raster_path)

        # If the input was unprojected, the output pixel size would be the
        # model_resolution. Instead, here we expect pixel_size to remain the
        # same as the original base raster.
        actual_pixel_size = pygeoprocessing.get_raster_info(
            target_raster_path)['pixel_size']
        self.assertTrue(
            actual_pixel_size == (geotransform[1], geotransform[5]))

    def test_clip_project_vector_on_invalid_geometry(self):
        """CV: test clip and project vector on input with invalid geometry."""
        from natcap.invest import coastal_vulnerability
        workspace_dir = self.workspace_dir
        base_vector_path = os.path.join(workspace_dir, 'invalid.gpkg')
        n_features = make_vector_of_invalid_geoms(base_vector_path)
        vector_info = pygeoprocessing.get_vector_info(base_vector_path)
        clipping_box = vector_info['bounding_box']
        target_srs_wkt = vector_info['projection_wkt']
        tmp_vector_path = os.path.join(workspace_dir, 'tmp_clipped.gpkg')
        target_vector_path = os.path.join(
            workspace_dir, 'clipped_projected.gpkg')

        coastal_vulnerability.clip_and_project_vector(
            base_vector_path, clipping_box, target_srs_wkt,
            tmp_vector_path, target_vector_path)

        # One of the invalid geometries cannot be loaded by shapely
        # and will be skipped, so expect the target to have one fewer
        # feature than the base.
        target_vector = gdal.OpenEx(
            target_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
        target_layer = target_vector.GetLayer()
        n_actual_features = target_layer.GetFeatureCount()
        self.assertTrue(n_actual_features == n_features - 1)

    def test_polygon_to_lines(self):
        """CV: test a helper function that converts polygons to linestrings."""
        from natcap.invest import coastal_vulnerability
        # Test a polygon with inner rings to cover all paths through function.
        donut_wkt = ('POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), '
                     '(20 30, 35 35, 30 20, 20 30))')
        geometry = ogr.CreateGeometryFromWkt(donut_wkt)
        shapely_geom = shapely.wkb.loads(bytes(geometry.ExportToWkb()))
        line_list = coastal_vulnerability.polygon_to_lines(shapely_geom)
        # Polygon has 4 sides on exterior, 3 on interior, expect 7 lines
        self.assertTrue(len(line_list) == 7)

    def test_assemble_results_and_calculate_exposure(self):
        """Test that assemble_results_and_calculate_exposure correctly
        calculates exposure"""
        from natcap.invest.coastal_vulnerability import \
            assemble_results_and_calculate_exposure

        def _make_shore_points_vector(shore_points_path):
            # create 4 points, each with a unique 'shore_id' in [0..3].
            shore_geometries = [Point(0, 0), Point(1, 0), Point(2, 1), Point(3, 2)]
            shore_fields = {'shore_id': ogr.OFTInteger}
            shore_attributes = [{'shore_id': i} for i in range(len(shore_geometries))]

            # Create a spatial reference (projected or geographic)
            srs = osr.SpatialReference()
            srs.ImportFromEPSG(26910)  # e.g. "NAD83 / UTM zone 10N"
            pygeoprocessing.shapely_geometry_to_vector(
                shore_geometries, shore_points_path, srs.ExportToWkt(),
                vector_format='GPKG',
                fields=shore_fields,
                attribute_list=shore_attributes,
                ogr_geom_type=ogr.wkbPoint
            )

        def _make_habitat_csv(habitat_csv_path):
            # Example: one habitat column named 'kelp', plus 'R_hab'
            # We have 4 shore IDs, so we add 4 rows. Values are arbitrary.
            habitat_df = pandas.DataFrame(
                {'shore_id': [0, 1, 2, 3], 'kelp': [5, 3, 5, 4],
                 'seagrass': [4, 1, 2, 4], 'R_hab': [5, 2, 5, 3]})
            habitat_df.to_csv(habitat_csv_path, index=False)

        def _make_risk_id_path_list():
            # Create pickles for risk data
            relief_pkl = os.path.join(self.workspace_dir, 'relief.pickle')
            slr_pkl = os.path.join(self.workspace_dir, 'slr.pickle')
            population_pkl = os.path.join(self.workspace_dir, 'population.pickle')

            relief_data = {0: 10.0, 1: 50.0, 2: 30.0, 3: 80.0}  # arbitrary data
            slr_data = {0: 0.1, 1: 0.2, 2: 0.9, 3: 0.5}
            population_data = {0: 123.0, 1: 999.0, 2: 55.0, 3: 0.0}

            for file_path, data_dict in zip([relief_pkl, slr_pkl, population_pkl],
                                            [relief_data, slr_data, population_data]):
                with open(file_path, 'wb') as f:
                    pickle.dump(data_dict, f)

            risk_id_path_list = [
                (relief_pkl, True, "R_relief"),  # "True" => bin to 1..5
                (slr_pkl, True, "R_slr"),
                (population_pkl, False, "population")
            ]
            return risk_id_path_list

        shore_points_path = os.path.join(self.workspace_dir, "shore_points.gpkg")
        _make_shore_points_vector(shore_points_path)

        habitat_csv_path = os.path.join(self.workspace_dir, 'habitat_protection.csv')
        _make_habitat_csv(habitat_csv_path)

        risk_id_path_list = _make_risk_id_path_list()

        intermediate_vector_path = os.path.join(self.workspace_dir,
                                                'intermediate_exposure.gpkg')
        intermediate_csv_path = os.path.join(self.workspace_dir,
                                             'intermediate_exposure.csv')
        output_vector_path = os.path.join(self.workspace_dir,
                                          'coastal_exposure.gpkg')
        output_csv_path = os.path.join(self.workspace_dir,
                                       'coastal_exposure.csv')

        # call function
        assemble_results_and_calculate_exposure(
            risk_id_path_list,
            habitat_csv_path,
            shore_points_path,
            intermediate_vector_path,
            intermediate_csv_path,
            output_vector_path,
            output_csv_path
        )

        # read field values in output vector and compare
        actual_df = pandas.read_csv(
            output_csv_path,
            usecols=["exposure", "habitat_role", "exposure_no_habitats"])

        expected_df = pandas.DataFrame({
            "exposure": [2.924018, 2.0, 4.641589, 2.289428],
            "habitat_role": [0, 0.714418, 0, 0.424989],
            "exposure_no_habitats": [2.924018, 2.714418, 4.641589, 2.714418]})

        pandas.testing.assert_frame_equal(
            actual_df, expected_df, check_dtype=False)


def assert_pickled_arrays_almost_equal(
        actual_values_pickle_path, expected_values_json_path):
    """Open a pickled dict and assert keys and values match expected.

    Expected data is stored in json as pickles are really only reliably
    read by the same program that wrote them.
    """
    with open(actual_values_pickle_path, 'rb') as pickle_file:
        actual_values_dict = pickle.load(pickle_file)
    actual_values = list(actual_values_dict.values())
    actual_fids = list(actual_values_dict.keys())

    with open(expected_values_json_path, 'r') as json_file:
        expected_values_dict = json.load(json_file)
    expected_values_dict = {
        int(x[0]): float(x[1]) for x in expected_values_dict.items()}
    # the dict items need sorting by FID to match the pre-sorted pickled items
    expected_fids = [x[0] for x in sorted(expected_values_dict.items())]
    expected_values = [x[1] for x in sorted(expected_values_dict.items())]

    numpy.testing.assert_allclose(
        actual_values, expected_values, rtol=0, atol=1e-2)
    numpy.testing.assert_array_equal(actual_fids, expected_fids)


class CoastalVulnerabilityValidationTests(unittest.TestCase):
    """Tests for the CV Model MODEL_SPEC and validation."""

    def setUp(self):
        """Create a temporary workspace."""
        self.workspace_dir = tempfile.mkdtemp()
        self.base_required_keys = [
            'workspace_dir',
            'aoi_vector_path',
            'model_resolution',
            'landmass_vector_path',
            'wwiii_vector_path',
            'max_fetch_distance',
            'bathymetry_raster_path',
            'shelf_contour_vector_path',
            'dem_path',
            'dem_averaging_radius',
            'habitat_table_path',
        ]

    def tearDown(self):
        """Remove the temporary workspace after a test."""
        shutil.rmtree(self.workspace_dir)

    def test_missing_keys(self):
        """CV Validate: assert missing required keys."""
        from natcap.invest import coastal_vulnerability
        from natcap.invest import validation

        # empty args dict.
        validation_errors = coastal_vulnerability.validate({})
        invalid_keys = validation.get_invalid_keys(validation_errors)
        expected_missing_keys = set(
            self.base_required_keys)
        self.assertEqual(invalid_keys, expected_missing_keys)

    def test_missing_keys_geomorphology(self):
        """CV Validate: assert missing geomorphology keys."""
        from natcap.invest import coastal_vulnerability
        from natcap.invest import validation

        args = {'geomorphology_vector_path': 'foo.shp'}
        validation_errors = coastal_vulnerability.validate(args)
        invalid_keys = validation.get_invalid_keys(validation_errors)
        expected_missing_keys = set(
            self.base_required_keys +
            ['geomorphology_fill_value',
             'geomorphology_vector_path'])
        self.assertEqual(invalid_keys, expected_missing_keys)

    def test_missing_keys_population(self):
        """CV Validate: assert missing population keys."""
        from natcap.invest import coastal_vulnerability
        from natcap.invest import validation

        args = {'population_raster_path': 'foo.tif'}
        validation_errors = coastal_vulnerability.validate(args)
        invalid_keys = validation.get_invalid_keys(validation_errors)
        expected_missing_keys = set(
            self.base_required_keys +
            ['population_raster_path',
             'population_radius'])
        self.assertEqual(invalid_keys, expected_missing_keys)

    def test_missing_keys_sealevelrise(self):
        """CV Validate: assert missing sealevelrise keys."""
        from natcap.invest import coastal_vulnerability
        from natcap.invest import validation

        args = {'slr_vector_path': 'foo.shp'}
        validation_errors = coastal_vulnerability.validate(args)
        invalid_keys = validation.get_invalid_keys(validation_errors)
        expected_missing_keys = set(
            self.base_required_keys +
            ['slr_vector_path',
             'slr_field'])
        self.assertEqual(invalid_keys, expected_missing_keys)

    def test_missing_sealevelrise_field(self):
        """CV Validate: test catching SLR field not present in vector."""
        from natcap.invest import coastal_vulnerability
        from natcap.invest import validation
        slr_vector_path = os.path.join(
            INPUT_DATA, 'sea_level_rise.gpkg')
        err_list = coastal_vulnerability.validate(
            {'slr_vector_path': slr_vector_path,
             'slr_field': 'foo'})
        # sea_level_rise.gpkg has one field called 'Trend'
        expected_err = (
            ['slr_field'],
            validation.MESSAGES['INVALID_OPTION'].format(option_list=['Trend']))
        self.assertTrue(expected_err in err_list)


def make_slr_vector(slr_point_vector_path, fieldname, shapely_feature, srs):
    """Create an SLR vector with a single point feature.

    Args:
        slr_point_vector_path (string): path to the target vector
        fieldname (string): name of a field to be created in target vector
        shapely_feautre (Point): shapely Point object
        srs (osr.SpatialReference)

    Returns:
        None

    """
    driver = ogr.GetDriverByName('ESRI Shapefile')
    out_vector = driver.CreateDataSource(slr_point_vector_path)
    layer_name = os.path.basename(os.path.splitext(slr_point_vector_path)[0])
    out_layer = out_vector.CreateLayer(layer_name, srs=srs)
    field_defn = ogr.FieldDefn(fieldname, ogr.OFTReal)
    out_layer.CreateField(field_defn)
    layer_defn = out_layer.GetLayerDefn()
    new_feature = ogr.Feature(layer_defn)
    new_geometry = ogr.CreateGeometryFromWkb(shapely_feature.wkb)
    new_feature.SetGeometry(new_geometry)
    new_feature.SetField(fieldname, 1.3)  # any value will do
    out_layer.CreateFeature(new_feature)
    out_layer = None
    out_vector = None


def make_vector_of_invalid_geoms(target_vector_path):
    """Make features out of various invalid geometries.

    Most example geometries come from: https://github.com/tudelft3d/prepair
    Three of the geomtries below can be fixed with a 0-width buffer.
    One geometry below cannot be loaded by shapely or fixed by a buffer.
    I don't have an example that CAN be loaded by shapely, but CANNOT be
    fixed by the buffer.

    Make these geometries non-overlapping because some of the CV functions
    being tested with these do a cascaded union. Isolating the geometries
    makes it possible to assert that the number of geometries going in equals
    the number of features coming out, regardless of that union.

    Returns:
        Integer representing the number of features created

    """
    # 1: Bowtie polygon - fixed by buffer
    invalid_bowtie_polygon = ogr.CreateGeometryFromWkt(
        'POLYGON ((-20 -20, -16 -20, -20 -16, -16 -16, -20 -20))')
    assert not invalid_bowtie_polygon.IsValid()

    # 2: Inner ring with one edge sharing part of an edge of the outer ring
    #  - fixed by buffer
    invalid_shared_edge_polygon = ogr.CreateGeometryFromWkt(
        'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0),(5 2,5 7,10 7, 10 2, 5 2))')
    assert not invalid_shared_edge_polygon.IsValid()

    # 3: Dangling edge - fixed by buffer
    dangle_geom = ('POLYGON((100 100, 110 100, 115 105, 110 100, 110 110, '
                   '100 110, 100 100))')
    invalid_dangling_edge_polygon = ogr.CreateGeometryFromWkt(dangle_geom)
    assert not invalid_dangling_edge_polygon.IsValid()

    # One invalid geom that cannot be loaded by shapely or fixed by buffer
    # We expect this polygon to be skipped by the CV functions being tested.
    # 4: invalid open ring polygon
    invalid_open_ring_polygon = ogr.CreateGeometryFromWkt(
        'POLYGON ((2 -2, 6 -2, 6 -6, 2 -6))')
    assert not invalid_open_ring_polygon.IsValid()

    gpkg_driver = gdal.GetDriverByName('GPKG')
    srs = osr.SpatialReference()
    srs.ImportFromEPSG(32731)  # WGS84/UTM zone 31s
    target_vector = gpkg_driver.Create(
        target_vector_path, 0, 0, 0, gdal.GDT_Unknown)
    target_layer = target_vector.CreateLayer(
        'target_layer', srs, ogr.wkbUnknown)

    target_layer.StartTransaction()
    input_geom_list = [invalid_bowtie_polygon,
                       invalid_shared_edge_polygon,
                       invalid_dangling_edge_polygon,
                       invalid_open_ring_polygon]
    for geometry in input_geom_list:
        outflow_feature = ogr.Feature(target_layer.GetLayerDefn())
        outflow_feature.SetGeometry(geometry)
        target_layer.CreateFeature(outflow_feature)
    target_layer.CommitTransaction()

    target_layer = None
    target_vector = None

    return len(input_geom_list)
