"""InVEST Coastal Vulnerability."""
import logging
import math
import os
import pickle
import shutil
import tempfile
import time

import numpy
import pandas
import pygeoprocessing
import pygeoprocessing.kernels
import rtree
import shapely
import shapely.errors
import shapely.ops
import shapely.speedups
import shapely.wkb
import taskgraph
from osgeo import gdal
from osgeo import ogr
from osgeo import osr
from shapely.geometry import LineString, MultiLineString
from shapely.geometry.base import BaseMultipartGeometry
from shapely.strtree import STRtree

from . import gettext
from . import spec_utils
from . import utils
from . import validation
from .model_metadata import MODEL_METADATA
from .unit_registry import u

LOGGER = logging.getLogger(__name__)

POLYLINE_VECTOR_MSG = gettext('Must be a polyline vector')
POINT_GEOMETRY_MSG = gettext('Must be a point or multipoint geometry.')

FINAL_OUTPUT_FIELDS = {
    "shore_id": {
        "type": "integer",
        "about": "Unique shore point identifier"
    },
    "R_hab": {
        "type": "integer",
        "about": "Habitat rank"
    },
    "R_wind": {
        "type": "integer",
        "about": "Wind exposure rank"
    },
    "R_wave": {
        "type": "integer",
        "about": "Wave exposure rank"
    },
    "R_surge": {
        "type": "integer",
        "about": "Surge potential rank"
    },
    "R_relief": {
        "type": "integer",
        "about": "Relief rank"
    },
    "R_geomorph": {
        "type": "integer",
        "about": "Geomorphology rank"
    },
    "exposure": {
        "type": "number",
        "units": u.none,
        "about": "this is the final exposure index (EI)"
    },
    "exposure_no_habitats": {
        "type": "number",
        "units": u.none,
        "about": "this is the same exposure index as in exposure, except it is calculated as if R_hab is always 5. In other words, it is the coastal exposure if no protective habitats were present near that point."
    },
    "habitat_role": {
        "type": "number",
        "units": u.none,
        "about": "the difference between exposure_no_habitats and exposure."
    },
    "population": {
        "about": "if a human population input raster was used, this is the average population density around each point.",
        "type": "number",
        "units": u.count/u.kilometer**2
    }
}

WWIII_FIELDS = {
    "rei_pct[SECTOR]": {
        "type": "percent",
        "about": gettext(
            "Proportion of the highest 10% of wind speeds in "
            "the record of interest that blow in the direction of "
            "each sector.")},
    "rei_v[SECTOR]": {
        "type": "number",
        "units": u.meter/u.second,
        "about": gettext(
            "Average of the highest 10% of wind speeds that blow "
            "in the direction of each sector.")},
    "wavppct[SECTOR]": {
        "type": "percent",
        "about": gettext(
            "Proportion of the highest 10% of wave power values "
            "on record that are in each sector.")},
    "wavp_[SECTOR]": {
        "type": "number",
        "units": u.kilowatt/u.meter,
        "about": gettext(
            "Average of the highest 10% of wave power values on "
            "record in the direction of each sector.")},
    "v10pct_[SECTOR]": {
        "type": "percent",
        "about": gettext(
            "Average of the highest 10% of wind speeds that are "
            "centered on each main sector direction X.")}
}

MODEL_SPEC = {
    "model_id": "coastal_vulnerability",
    "model_name": MODEL_METADATA["coastal_vulnerability"].model_title,
    "pyname": MODEL_METADATA["coastal_vulnerability"].pyname,
    "userguide": MODEL_METADATA["coastal_vulnerability"].userguide,
    "args_with_spatial_overlap": {
        "spatial_keys": [
            "aoi_vector_path",
            "landmass_vector_path",
            "wwiii_vector_path",
            "dem_path",
            "bathymetry_raster_path",
            "geomorphology_vector_path",
            "population_raster_path",
        ],
        "different_projections_ok": True,
    },
    "args": {
        "workspace_dir": spec_utils.WORKSPACE,
        "results_suffix": spec_utils.SUFFIX,
        "n_workers": spec_utils.N_WORKERS,
        "aoi_vector_path": {
            **spec_utils.AOI,
            "projected": True,
            "projection_units": u.meter,
            "about": gettext("Map of the region over which to run the model.")
        },
        "model_resolution": {
            "type": "number",
            "expression": "value > 0",
            "units": u.meter,
            "about": gettext(
                "Interval at which to space shore points along the coastline."),
            "name": gettext("model resolution")
        },
        "landmass_vector_path": {
            "type": "vector",
            "fields": {},
            "geometries": spec_utils.POLYGONS,
            "about": gettext(
                "Map of all landmasses in and around the region of interest. "
                "It is not recommended to clip this landmass to the AOI "
                "polygon because some functions in the model require "
                "searching for landmasses around shore points up to the "
                "distance defined in Maximum Fetch Distance, which likely "
                "extends beyond the AOI polygon."),
            "name": gettext("landmasses")
        },
        "wwiii_vector_path": {
            "type": "vector",
            "fields": WWIII_FIELDS,
            "geometries": spec_utils.POINT,
            "about": gettext(
                "Map of gridded wind and wave data that represent storm "
                "conditions. This global dataset is provided with the InVEST "
                "sample data. There are 80 required columns; each of the 5 "
                "types is repeated for each sixteenth sector of the 360° "
                "compass: "
                "[0,22,45,67,90,112,135,157,180,202,225,247,270,292,315,337]. "
                "For example: REI_PCT0, V10PCT_90."),
            "name": gettext("WaveWatchIII")
        },
        "max_fetch_distance": {
            "type": "number",
            "units": u.meter,
            "expression": "value > 0",
            "about": gettext(
                "Maximum distance in meters to extend rays from shore points. "
                "Points with rays equal to this distance accumulate ocean- "
                "driven wave exposure along those rays and local-wind-driven "
                "wave exposure along the shorter rays."),
            "name": gettext("maximum fetch distance")
        },
        "bathymetry_raster_path": {
            "type": "raster",
            "bands": {1: {
                "type": "number",
                "units": u.meter
            }},
            "about": gettext(
                "Map of bathymetry (ocean depth). Bathymetry values "
                "should be negative, and any positive values will be ignored. This "
                "should cover the area extending beyond the AOI to the "
                "maximum fetch distance."),

            "name": gettext("Bathymetry")
        },
        "shelf_contour_vector_path": {
            "type": "vector",
            "fields": {},
            "geometries": spec_utils.LINES,
            "about": gettext(
                "Map of the edges of the continental shelf or other locally "
                "relevant bathymetry contour."),
            "name": gettext("continental shelf contour")
        },
        "dem_path": {
            **spec_utils.DEM,
            "bands": {1: {
                "type": "number",
                "units": u.other  # any unit of length is ok
            }},
            "about": gettext(
                "Map of elevation above sea level on land. This should cover "
                "the area extending beyond the AOI by at least the elevation "
                "averaging radius. Elevation may be measured in any unit.")
        },
        "dem_averaging_radius": {
            "type": "number",
            "units": u.meter,
            "expression": "value > 0",
            "about": gettext(
                "A radius around each shore point within which to average the "
                "elevation values in the DEM raster."),
            "name": gettext("elevation averaging radius")
        },
        "habitat_table_path": {
            "type": "csv",
            "columns": {
                "id": {
                    "type": "freestyle_string",
                    "about": gettext("Unique name for the habitat. No spaces allowed.")},
                "path": {
                    "type": {"raster", "vector"},
                    "fields": {},
                    "geometries": {"POLYGON", "MULTIPOLYGON"},
                    "bands": {1: {"type": "number", "units": u.none}},
                    "about": gettext(
                        "Map of area(s) where the habitat is present. "
                        "If raster, presence of the habitat can be "
                        "represented by any value and absence of the habitat "
                        "can be represented by 0 and nodata values.")},
                "rank": {
                    "type": "integer",
                    "about": gettext(
                        "Relative amount of coastline protection this habitat "
                        "provides, from 1 (very high protection) to 5 "
                        "(very low protection.")
                },
                "protection distance (m)": {
                    "type": "number",
                    "units": u.meter,
                    "expression": "value >= 0",
                    "about": gettext(
                        "The distance beyond which this habitat will provide "
                        "no protection to the coastline.")
                },
            },
            "index_col": "id",
            "about": gettext(
                "Table that specifies spatial habitat data and parameters."),
            "name": gettext("habitats table")
        },
        "geomorphology_vector_path": {
            "type": "vector",
            "fields": {
                "rank": {
                    "type": "option_string",
                    "options": {
                        "1": {"description": gettext("very low exposure")},
                        "2": {"description": gettext("low exposure")},
                        "3": {"description": gettext("moderate exposure")},
                        "4": {"description": gettext("high exposure")},
                        "5": {"description": gettext("very high exposure")}
                    },
                    "about": gettext("Relative exposure of the segment of coastline.")
                }
            },
            "geometries": spec_utils.LINES,
            "required": False,
            "about": gettext("Map of relative exposure of each segment of coastline."),
            "name": gettext("geomorphology")
        },
        "geomorphology_fill_value": {
            "type": "option_string",
            "options": {
                "1": {"display_name": gettext("1: very low exposure")},
                "2": {"display_name": gettext("2: low exposure")},
                "3": {"display_name": gettext("3: moderate exposure")},
                "4": {"display_name": gettext("4: high exposure")},
                "5": {"display_name": gettext("5: very high exposure")}
            },
            "required": "geomorphology_vector_path",
            "about": gettext(
                "Exposure rank to assign to any shore points that are not "
                "near to any segment in the geomorphology vector. "
                "Required if a Geomorphology vector is provided."),
            "name": gettext("geomorphology fill value")
        },
        "population_raster_path": {
            "type": "raster",
            "bands": {
                1: {"type": "number", "units": u.none}
            },
            "required": False,
            "about": gettext("Map of total human population on each pixel."),
            "name": gettext("human population")
        },
        "population_radius": {
            "type": "number",
            "units": u.meter,
            "expression": "value > 0",
            "required": "population_raster_path",
            "about": gettext(
                "The radius around each shore point within which to compute "
                "the average population density. "
                "Required if a Human Population map is provided."),
            "name": gettext("population search radius")
        },
        "slr_vector_path": {
            "type": "vector",
            "fields": {
                "[SLR_FIELD]": {
                    "about": gettext(
                        "Sea level rise rate or amount. This field name must "
                        "be chosen as the Sea Level Rise Field."),
                    "type": "number",
                    "units": u.none
                }
            },
            "geometries": spec_utils.POINT,
            "required": False,
            "about": gettext(
                "Map of sea level rise rates or amounts. May be any sea level "
                "rise metric of interest, such as rate, or net rise/fall."),
            "name": gettext("sea level rise")
        },
        "slr_field": {
            "type": "option_string",
            "options": {},
            "required": "slr_vector_path",
            "about": gettext(
                "Name of the field in the sea level rise vector which "
                "contains the sea level rise metric of interest. "
                "Required if a Sea Level Rise vector is provided."),
            "name": gettext("sea level rise field")
        }
    },
    "outputs": {
        "coastal_exposure.gpkg": {
            "about": "This point vector file contains the final outputs of the model. The points are created based on the input model resolution, landmass, and AOI.",
            "geometries": spec_utils.POINT,
            "fields": FINAL_OUTPUT_FIELDS
        },
        "coastal_exposure.csv": {
            "about": "This is an identical copy of the attribute table of coastal_exposure.gpkg provided in csv format for convenience. Users may wish to modify or add to the columns of this table in order to calculate exposure indices for custom scenarios.",
            "index_col": "shore_id",
            "columns": FINAL_OUTPUT_FIELDS
        },
        "intermediate": {
            "type": "directory",
            "contents": {
                "intermediate_exposure.gpkg": {
                    "about": (
                        "Shore points with associated exposure variables"),
                    "geometries": spec_utils.POINT,
                    "fields": {
                        "shore_id": {
                            "type": "integer",
                            "about": "Unique shore point identifier"
                        },
                        "wind": {
                            "type": "number",
                            "units": u.none,
                            "about": "Wind exposure rank"
                        },
                        "wave": {
                            "type": "number",
                            "units": u.none,
                            "about": "Wave exposure rank"
                        },
                        "surge": {
                            "type": "number",
                            "units": u.none,
                            "about": "Surge potential rank"
                        },
                        "relief": {
                            "type": "number",
                            "units": u.none,
                            "about": "Relief rank"
                        }
                    }
                },
                "geomorphology": {
                    "type": "directory",
                    "about": "Intermediate outputs related to geomorphology.",
                    "contents": {
                        "geomorph.pickle": {"about": "Pickled geomorphology data"},
                        "geomorphology_protected.gpkg": {
                            "about": (
                                "Geomorphology vector reprojected to match the AOI"),
                            "geometries": spec_utils.LINES,
                            "fields": {
                                "rank": {
                                    "type": "option_string",
                                    "options": {
                                        "1": {"description": gettext("very low exposure")},
                                        "2": {"description": gettext("low exposure")},
                                        "3": {"description": gettext("moderate exposure")},
                                        "4": {"description": gettext("high exposure")},
                                        "5": {"description": gettext("very high exposure")}
                                    },
                                    "about": gettext("Relative exposure of the segment of coastline.")
                                }
                            }
                        },
                        "shore_points_missing_geomorphology.gpkg": {
                            "about": (
                                "The shore points that received the geomorphology "
                                "fill value because no geomorphology segments "
                                "were found within the search radius of the "
                                "point. If very many points are missing data, "
                                "it might be explained by spatial inaccuracy "
                                "of either the geomorphology or landmass polygon "
                                "inputs. Editing the geometory of one or both in "
                                "GIS could help resolve this."),
                            "geometries": spec_utils.POINT,
                            "fields": {
                                "shore_id": {
                                    "type": "integer",
                                    "about": "Unique shore point identifier"
                                }
                            }
                        }
                    }
                },
                "habitats": {
                    "type": "directory",
                    "about": "Intermediate outputs related to habitats.",
                    "contents": {
                        "[HABITAT].pickle": {"about": "Pickled habitat data"},
                        "clipped_projected_[HABITAT].tif": {
                            "about": "Clipped and reprojected habitat map",
                            "bands": {1: {"type": "integer"}}
                        },
                        "[HABITAT_FILENAME]_clipped.tif": {
                            "about": "Clipped habitat map",
                            "bands": {1: {"type": "integer"}}
                        },
                        "habitat_protection.csv": {
                            "about": (
                                "Shore points with associated habitat data"),
                            "index_col": "shore_id",
                            "columns": {
                                # shore_id and R_hab come first so that they get
                                # matched before [HABITAT], which matches everything
                                "shore_id": {
                                    "type": "integer",
                                    "about": "Shore point ID"
                                },
                                "R_hab": {
                                    "about": (
                                        "Overall habitat exposure rank, the "
                                        "result of equation (15)"),
                                    "type": "number",
                                    "units": u.none
                                },
                                "[HABITAT]": {
                                    "about": (
                                        "Habitat exposure rank for the given "
                                        "habitat. A value of 5 indicates that "
                                        "habitat was not found within the "
                                        "habitat’s protection distance from the "
                                        "shore point. A value less than 5 means "
                                        "the habitat was present in proximity "
                                        "to the shore point, and the value is the "
                                        "rank defined in the Habitats Table input."),
                                    "type": "number",
                                    "units": u.none
                                }
                            }
                        }
                    }
                },
                "population": {
                    "type": "directory",
                    "about": "Intermediate outputs related to population.",
                    "contents": {
                        "clipped_projected_pop.tif": {
                            "about": "Clipped and reprojected population map",
                            "bands": {1: {"type": "number", "units": u.none}}
                        },
                        "[POPULATION_FILENAME]_clipped.tif": {
                            "about": "Clipped population map",
                            "bands": {1: {"type": "integer"}}
                        },
                        "population.pickle": {"about": "Pickled population data"},
                    }
                },
                "relief": {
                    "type": "directory",
                    "about": "Intermediate outputs related to relief.",
                    "contents": {
                        "clipped_projected_dem.tif": {
                            "about": "Clipped and reprojected DEM",
                            "bands": {1: {"type": "number", "units": u.meter}}
                        },
                        "[DEM_FILENAME]_clipped.tif": {
                            "about": "Clipped DEM",
                            "bands": {1: {"type": "number", "units": u.meter}}
                        },
                        "positive_dem.tif": {
                            "about": "DEM thresholded to positive values only",
                            "bands": {1: {"type": "number", "units": u.meter}}
                        },
                        "relief.pickle": {"about": "Pickled relief data"},
                    }
                },
                "sealevelrise": {
                    "type": "directory",
                    "about": "Intermediate outputs related to sea level rise.",
                    "contents": {}
                },
                "shore_points": {
                    "type": "directory",
                    "about": "Intermediate outputs related to shore points.",
                    "contents": {
                        "clipped_projected_landmass.gpkg": {
                            "about": "Clipped and reprojected landmass map",
                            "fields": {},
                            "geometries": spec_utils.POLYGONS
                        },
                        "landmass_line_index.pickle": {
                            "about": "Pickled landmass index"
                        },
                        "landmass_line_rtree.dat": {"about": "Rtree index"},
                        "landmass_line_rtree.idx": {"about": "Rtree index"},
                        "landmass_polygon.pickle": {
                            "about": "Pickled landmass polygon"
                        },
                        "shore_points.gpkg": {
                            "about": "Map of shore points",
                            "geometries": spec_utils.POINT,
                            "fields": {
                                "shore_id": {
                                    "type": "integer",
                                    "about": "Unique shore point identifier"
                                }
                            }
                        },
                        "tmp_clipped_landmass.gpkg": {
                            "about": "Clipped landmass map",
                            "fields": {},
                            "geometries": spec_utils.POLYGONS
                        }
                    }
                },
                "surge": {
                    "type": "directory",
                    "about": "Intermediate outputs related to surge potential.",
                    "contents": {
                        "surge.pickle": {"about": "Pickled surge data"},
                    }
                },
                "wind_wave": {
                    "type": "directory",
                    "about": "Intermediate outputs related to wind and wave exposure.",
                    "contents": {
                        "clipped_projected_bathy.tif": {
                            "about": (
                                "Copy of bathymetry input, clipped and "
                                "reprojected to match the AOI"),
                            "bands": {1: {"type": "number", "units": u.meter}}
                        },
                        "fetch_points.gpkg": {
                            "about": "Shore points with added fetch ray data",
                            "geometries": spec_utils.POINT,
                            "fields": {
                                **WWIII_FIELDS,
                                "fdist_[SECTOR]": {
                                    "about": (
                                        "Fetch ray distance in the given sector"),
                                    "type": "number",
                                    "units": u.meter
                                },
                                "fdepth_[SECTOR]": {
                                    "about": (
                                        "Average water depth along the fetch "
                                        "ray in the given sector"),
                                    "type": "number",
                                    "units": u.meter
                                }
                            }
                        },
                        "fetch_rays.gpkg": {
                            "about": (
                                "Map of fetch rays around each shore point."),
                            "geometries": spec_utils.LINESTRING,
                            "fields": {
                                "fetch_dist": {
                                    "about": "Fetch distance along the ray",
                                    "type": "number",
                                    "units": u.meter
                                },
                                "direction": {
                                    "about": "Compass direction of the ray",
                                    "type": "number",
                                    "units": u.degree
                                }
                            }
                        },
                        "negative_bathymetry.tif": {
                            "about": (
                                "Copy of bathymetry input with any positive "
                                "values masked out"),
                            "bands": {1: {"type": "number", "units": u.meter}},
                        },
                        "wave_energies.gpkg": {
                            "about": "Shore points with associated wave energy data",
                            "geometries": spec_utils.POINT,
                            "fields": {
                                "E_ocean": {
                                    "about": (
                                        "Average power of oceanic waves at the "
                                        "point (equation (18)"),
                                    "type": "number",
                                    "units": u.kilowatt/u.meter
                                },
                                "E_local": {
                                    "about": (
                                        "Average power of local wind-generated "
                                        "waves at the point (equation (21)"),
                                    "type": "number",
                                    "units": u.kilowatt/u.meter
                                },
                                "Eo_El_diff": {
                                    "about": (
                                        "Difference in power between oceanic "
                                        "and local waves"),
                                    "type": "number",
                                    "units": u.kilowatt/u.meter
                                },
                                "max_E_type": {
                                    "about": "A label indicating whether E_ocean or E_local has the larger value.",
                                    "type": "option_string",
                                    "options": {
                                        "ocean": "E_ocean has the larger value.",
                                        "local": "E_local has the larger value."
                                    }
                                },
                                "maxH_local": {
                                    "about": "the maximum of the wave heights across the 16 rays (equation (22))",
                                    "type": "number",
                                    "units": u.meter
                                },
                                "minH_local": {
                                    "about": "the minimum of the wave heights across the 16 rays (equation (22))",
                                    "type": "number",
                                    "units": u.meter
                                },
                                "maxT_local": {
                                    "about": "the maximum of the wave periods across the 16 rays (equation (22))",
                                    "type": "number",
                                    "units": u.second
                                },
                                "minT_local": {
                                    "about": "the minimum of the wave periods across the 16 rays (equation (22))",
                                    "type": "number",
                                    "units": u.second
                                }
                            }
                        },
                        "wave.pickle": {"about": "Pickled wave data"},
                        "wind.pickle": {"about": "Pickled wind data"},
                        "wwiii_shore_points.gpkg": {
                            "about": "WaveWatch 3 data interpolated to shore points",
                            "geometries": spec_utils.POINT,
                            "fields": WWIII_FIELDS
                        }
                    }
                }
            }
        },
        "taskgraph_cache": spec_utils.TASKGRAPH_DIR
    }
}


_N_FETCH_RAYS = 16
SHORE_ID_FIELD = 'shore_id'


def execute(args):
    """Coastal Vulnerability.

    For points along a coastline, evaluate the relative exposure of points
    to coastal hazards based on up to eight biophysical hazard indices.
    Also quantify the role of habitats in reducing the hazard. Optionally
    summarize the population density in proximity to each shore point.

    Args:
        args['workspace_dir'] (string): (required) a path to the directory that
            will write output and other temporary files during calculation.
        args['results_suffix'] (string): (optional) appended to any output
            filename.
        args['aoi_vector_path'] (string): (required) path to a polygon vector
            that is projected in a coordinate system with units of meters.
            The polygon should intersect the landmass and the shelf contour
            line.
        args['model_resolution'] (string): (required) distance in meters.
            Points are spaced along the coastline at intervals of this
            distance.
        args['landmass_vector_path'] (string): (required) path to a polygon
            vector representing landmasses in the region of interest.
        args['wwiii_vector_path'] (string): (required) path to a point vector
            containing wind and wave information across the region of interest.
        args['max_fetch_distance'] (string): (required) maximum distance
            in meters to extend rays from shore points. Points with rays equal
            to this distance will accumulate ocean-driven wave exposure along
            those rays and local-wind-driven wave exposure along the shorter
            rays.
        args['bathymetry_raster_path'] (string): (required) path to a raster
            representing the depth below sea level, in negative meters. Should
            cover the area extending outward from the AOI to the
            max_fetch_distance.
        args['shelf_contour_vector_path'] (string): (required) path to a
            polyline vector delineating edges of the continental shelf
            or other bathymetry contour.
        args['dem_path'] (string): (required) path to a raster representing the
            elevation on land in the region of interest.
        args['dem_averaging_radius'] (int or float): (required) a value >= 0.
            The radius in meters around each shore point in which to compute
            the average elevation.
        args['habitat_table_path'] (string): (required) path to a CSV file with
            the following four fields:
            'id': unique string to represent each habitat;
            'path': absolute or relative path to a polygon vector;
            'rank': integer from 1 to 5 representing the relative
            protection offered by this habitat;
            'protection distance (m)': integer or float used as a search
            radius around each shore point.
        args['geomorphology_vector_path'] (string): (optional) path to a
            polyline vector that has a field called "RANK" with values from
            1 to 5 in the attribute table.
        args['geomorphology_fill_value'] (int): (optional) a value from 1 to 5
            that will be used as a geomorphology rank for any points not
            proximate to the geomorphology_vector_path.
        args['population_raster_path'] (string): (optional) path a raster with
            values of total population per pixel.
        args['population_radius'] (int or float): (optional) a value >= 0.
            The radius in meters around each shore point in which to compute
            the population density.
        args['slr_vector_path'] (string): (optional) path to point vector
            containing the field ``args['slr_field']``.
        args['slr_field'] (string): name of a field in
            ``args['slr_vector_path']`` containing numeric values.
        args['n_workers'] (int): (optional) The number of worker processes to
            use for processing this model.  If omitted, computation will take
            place in the current process.

    Returns:
        None

    """
    output_dir = os.path.join(args['workspace_dir'])
    intermediate_dir = os.path.join(
        args['workspace_dir'], 'intermediate')
    habitat_dir = os.path.join(
        intermediate_dir, 'habitats')
    shore_dir = os.path.join(
        intermediate_dir, 'shore_points')
    relief_dir = os.path.join(
        intermediate_dir, 'relief')
    geomorph_dir = os.path.join(
        intermediate_dir, 'geomorphology')
    wind_wave_dir = os.path.join(
        intermediate_dir, 'wind_wave')
    surge_dir = os.path.join(
        intermediate_dir, 'surge')
    population_dir = os.path.join(
        intermediate_dir, 'population')
    slr_dir = os.path.join(
        intermediate_dir, 'sealevelrise')

    utils.make_directories(
        [output_dir, intermediate_dir, habitat_dir, shore_dir, relief_dir,
         geomorph_dir, wind_wave_dir, surge_dir, population_dir, slr_dir])
    file_suffix = utils.make_suffix_string(args, 'results_suffix')

    try:
        n_workers = int(args['n_workers'])
    except (KeyError, ValueError, TypeError):
        # KeyError when n_workers is not present in args
        # ValueError when n_workers is an empty string.
        # TypeError when n_workers is None.
        n_workers = -1  # Single process mode.
    task_graph = taskgraph.TaskGraph(
        os.path.join(args['workspace_dir'], 'taskgraph_cache'), n_workers)

    model_resolution = float(args['model_resolution'])
    max_fetch_distance = float(args['max_fetch_distance'])

    aoi_vector_info = pygeoprocessing.get_vector_info(
        args['aoi_vector_path'])
    aoi_srs_wkt = aoi_vector_info['projection_wkt']
    aoi_bounding_box = aoi_vector_info['bounding_box']
    # add the max_fetch_distance to the bounding box so we can use
    # the clipped landmass in the ray casting routine.
    fetch_buffer = max_fetch_distance + model_resolution
    aoi_bounding_box[0] -= fetch_buffer
    aoi_bounding_box[1] -= fetch_buffer
    aoi_bounding_box[2] += fetch_buffer
    aoi_bounding_box[3] += fetch_buffer

    clipped_landmass_path = os.path.join(
        shore_dir, f'clipped_projected_landmass{file_suffix}.gpkg')
    tmp_clipped_path = os.path.join(
        shore_dir, f'tmp_clipped_landmass{file_suffix}.gpkg')
    clip_landmass_to_aoi_task = task_graph.add_task(
        func=clip_and_project_vector,
        args=(args['landmass_vector_path'], aoi_bounding_box,
              aoi_srs_wkt, tmp_clipped_path, clipped_landmass_path),
        target_path_list=[clipped_landmass_path, tmp_clipped_path],
        dependent_task_list=[],
        task_name='clip landmass to aoi')

    target_bathy_raster_path = os.path.join(
        wind_wave_dir, f'negative_bathymetry{file_suffix}.tif')
    prepare_bathymetry_task = task_graph.add_task(
        func=warp_and_mask_bathymetry,
        args=(args['bathymetry_raster_path'], aoi_srs_wkt,
              aoi_bounding_box, model_resolution, wind_wave_dir,
              file_suffix, target_bathy_raster_path),
        target_path_list=[target_bathy_raster_path],
        dependent_task_list=[],
        task_name='prepare bathymetry')

    target_polygon_pickle_path = os.path.join(
        shore_dir, f'landmass_polygon{file_suffix}.pickle')
    target_lines_pickle_path = os.path.join(
        shore_dir, f'landmass_line_index{file_suffix}.pickle')
    target_rtree_path = os.path.join(
        shore_dir, f'landmass_line_rtree{file_suffix}.dat')

    shore_point_vector_path = os.path.join(
        shore_dir, f'shore_points{file_suffix}.gpkg')
    landmass_geom_and_shore_points_task = task_graph.add_task(
        func=prepare_landmass_line_index_and_interpolate_shore_points,
        args=(args['aoi_vector_path'], clipped_landmass_path,
              model_resolution, shore_point_vector_path,
              target_polygon_pickle_path, target_lines_pickle_path,
              target_rtree_path),
        target_path_list=[
            shore_point_vector_path, target_polygon_pickle_path,
            target_lines_pickle_path],
        ignore_path_list=[target_rtree_path],
        dependent_task_list=[clip_landmass_to_aoi_task],
        task_name='index landmass geometry and interpolate shore points')

    exposure_variables_task_list = []
    exposure_variables_path_list = []  # list of 3-tuples like:
    # ('pickle path' (str), bin values (bool), 'var_name' (str))
    # bin values (bool):
    #     True when a variable contains values that need binning
    #         by percentile to convert to 1-5 ranks.
    #     False when variable is already on the 1-5 rank scale.
    # var_name (str):
    #     The prefix 'R_' should be used for any variable to be
    #     included in the final exposure equation. Other variables,
    #     e.g. 'population' should not include this prefix.

    target_wwiii_point_vector_path = os.path.join(
        wind_wave_dir, f'wwiii_shore_points{file_suffix}.gpkg')
    interpolate_wwiii_task = task_graph.add_task(
        func=interpolate_wwiii_to_shore,
        args=(shore_point_vector_path, args['wwiii_vector_path'],
              target_wwiii_point_vector_path),
        target_path_list=[target_wwiii_point_vector_path],
        dependent_task_list=[landmass_geom_and_shore_points_task],
        priority=10,  # the longest running task is dependent on this one.
        task_name='interpolate wwiii to shore points')
    exposure_variables_task_list.append(interpolate_wwiii_task)

    fetch_point_vector_path = os.path.join(
        wind_wave_dir, f'fetch_points{file_suffix}.gpkg')
    target_fetch_rays_path = os.path.join(
        wind_wave_dir, f'fetch_rays{file_suffix}.gpkg')
    target_wind_exposure_pickle_path = os.path.join(
        wind_wave_dir, f'wind{file_suffix}.pickle')
    exposure_variables_path_list.append(
        (target_wind_exposure_pickle_path, True, 'R_wind'))
    wind_exposure_task = task_graph.add_task(
        func=calculate_wind_exposure,
        args=(target_wwiii_point_vector_path, target_polygon_pickle_path,
              target_rtree_path, target_lines_pickle_path,
              target_bathy_raster_path, target_fetch_rays_path,
              max_fetch_distance, fetch_point_vector_path,
              target_wind_exposure_pickle_path),
        target_path_list=[fetch_point_vector_path,
                          target_fetch_rays_path,
                          target_wind_exposure_pickle_path],
        ignore_path_list=[target_rtree_path],
        dependent_task_list=[
            interpolate_wwiii_task, prepare_bathymetry_task],
        priority=10,  # start this long task as early as possible.
        task_name='calculate wind exposure')
    exposure_variables_task_list.append(wind_exposure_task)

    target_wave_exposure_path = os.path.join(
        wind_wave_dir, f'wave{file_suffix}.pickle')
    intermediate_wave_vector_path = os.path.join(
        wind_wave_dir, f'wave_energies{file_suffix}.gpkg')
    exposure_variables_path_list.append(
        (target_wave_exposure_path, True, 'R_wave'))
    exposure_variables_task_list.append(task_graph.add_task(
        func=calculate_wave_exposure,
        args=(fetch_point_vector_path, max_fetch_distance,
              intermediate_wave_vector_path,
              target_wave_exposure_path),
        target_path_list=[target_wave_exposure_path,
                          intermediate_wave_vector_path],
        dependent_task_list=[wind_exposure_task],
        task_name='calculate wave exposure'))

    target_surge_exposure_path = os.path.join(
        surge_dir, f'surge{file_suffix}.pickle')
    exposure_variables_path_list.append(
        (target_surge_exposure_path, True, 'R_surge'))
    exposure_variables_task_list.append(task_graph.add_task(
        func=calculate_surge_exposure,
        args=(shore_point_vector_path, args['shelf_contour_vector_path'],
              target_surge_exposure_path),
        target_path_list=[target_surge_exposure_path],
        dependent_task_list=[landmass_geom_and_shore_points_task],
        task_name='calculate surge exposure'))

    relief_point_pickle_path = os.path.join(
        relief_dir, f'relief{file_suffix}.pickle')
    exposure_variables_path_list.append(
        (relief_point_pickle_path, True, 'R_relief'))
    exposure_variables_task_list.append(task_graph.add_task(
        func=calculate_relief_exposure,
        args=(shore_point_vector_path, args['dem_path'],
              float(args['dem_averaging_radius']), model_resolution,
              relief_dir, file_suffix, relief_point_pickle_path),
        target_path_list=[relief_point_pickle_path],
        dependent_task_list=[landmass_geom_and_shore_points_task],
        task_name='calculate relief exposure'))

    # Joining this task instead of passing it to this scheduler function.
    # Tasks added by the scheduler are dependent on shore_points_task
    landmass_geom_and_shore_points_task.join()
    hab_tasks_list, hab_targets_list = _schedule_habitat_tasks(
        shore_point_vector_path, args['habitat_table_path'],
        model_resolution, habitat_dir, file_suffix, task_graph)

    target_habitat_protection_path = os.path.join(
        habitat_dir, f'habitat_protection{file_suffix}.csv')
    exposure_variables_task_list.append(task_graph.add_task(
        func=calculate_habitat_rank,
        args=(hab_targets_list,
              target_habitat_protection_path),
        target_path_list=[target_habitat_protection_path],
        dependent_task_list=hab_tasks_list,
        task_name='calculate habitat protection'))

    if ('geomorphology_vector_path' in args and
            args['geomorphology_vector_path'] != ''):
        projected_geomorphology_vector_path = os.path.join(
            geomorph_dir, f'geomorphology_projected{file_suffix}.gpkg')
        target_srs_wkt = pygeoprocessing.get_vector_info(
            args['aoi_vector_path'])['projection_wkt']
        project_geomorph_task = task_graph.add_task(
            func=pygeoprocessing.reproject_vector,
            args=(args['geomorphology_vector_path'], target_srs_wkt,
                  projected_geomorphology_vector_path),
            kwargs={'driver_name': 'GPKG'},
            target_path_list=[projected_geomorphology_vector_path],
            task_name='project geomorphology input')

        target_geomorphology_pickle_path = os.path.join(
            geomorph_dir, f'geomorph{file_suffix}.pickle')
        target_missing_data_path = os.path.join(
            geomorph_dir, f'shore_points_missing_geomorphology{file_suffix}.gpkg')
        exposure_variables_path_list.append(
            (target_geomorphology_pickle_path, False, 'R_geomorph'))
        exposure_variables_task_list.append(task_graph.add_task(
            func=calculate_geomorphology_exposure,
            args=(projected_geomorphology_vector_path,
                  int(args['geomorphology_fill_value']),
                  shore_point_vector_path, model_resolution,
                  target_geomorphology_pickle_path, target_missing_data_path),
            target_path_list=[
                target_geomorphology_pickle_path],
            dependent_task_list=[
                landmass_geom_and_shore_points_task, project_geomorph_task],
            task_name='calculate geomorphology exposure'))

    if 'slr_vector_path' in args and args['slr_vector_path'] != '':
        target_slr_pickle_path = os.path.join(
            slr_dir, f'slr{file_suffix}.pickle')
        exposure_variables_path_list.append(
            (target_slr_pickle_path, True, 'R_slr'))
        exposure_variables_task_list.append(task_graph.add_task(
            func=interpolate_sealevelrise_points,
            args=(shore_point_vector_path, args['slr_vector_path'],
                  args['slr_field'], target_slr_pickle_path),
            target_path_list=[target_slr_pickle_path],
            dependent_task_list=[landmass_geom_and_shore_points_task],
            task_name='interpolate sea-level rise values'))

    if ('population_raster_path' in args and
            args['population_raster_path'] != ''):
        target_population_pickle_path = os.path.join(
            population_dir, f'population{file_suffix}.pickle')
        exposure_variables_path_list.append(
            (target_population_pickle_path, False, 'population'))
        exposure_variables_task_list.append(task_graph.add_task(
            func=aggregate_population_density,
            args=(shore_point_vector_path, args['population_raster_path'],
                  float(args['population_radius']), model_resolution,
                  population_dir, file_suffix, target_population_pickle_path),
            target_path_list=[target_population_pickle_path],
            dependent_task_list=[landmass_geom_and_shore_points_task],
            task_name='aggregate population raster'))

    task_graph.close()
    task_graph.join()

    # Final data assembly and exposure calculation.
    # For now this is outside the task graph to make sure we always
    # re-calculate final exposure. Some of the intermediate variables
    # are in files only referenced within tuples within
    # ``exposure_variables_path_list``, so it would require some
    # re-factoring to make all those files considered by taskgraph.
    target_exposure_vector_path = os.path.join(
        output_dir, f'coastal_exposure{file_suffix}.gpkg')
    target_exposure_csv_path = os.path.splitext(
        target_exposure_vector_path)[0] + '.csv'
    target_intermediate_vector_path = os.path.join(
        intermediate_dir, f'intermediate_exposure{file_suffix}.gpkg')
    target_intermediate_csv_path = os.path.splitext(
        target_intermediate_vector_path)[0] + '.csv'
    assemble_results_and_calculate_exposure(
        exposure_variables_path_list, target_habitat_protection_path,
        shore_point_vector_path, target_intermediate_vector_path,
        target_intermediate_csv_path, target_exposure_vector_path,
        target_exposure_csv_path)


def _list_geometry(geom):
    """Iterate over a possibly multipart geometry.


    Args:
        geom (shapely.Geometry): geometry to iterate over

    Returns:
        list of shapely single-part geometries
    """
    if isinstance(geom, BaseMultipartGeometry):
        return list(geom.geoms)
    return [geom]


def prepare_landmass_line_index_and_interpolate_shore_points(
        aoi_vector_path, landmass_vector_path, model_resolution,
        target_points_path, target_polygon_pickle_path,
        target_lines_pickle_path, target_rtree_path):
    """Prepare landmass for line operations and interpolate shore points.

    Converts landmass polygons to lines and creates shore points by
    interpolating at a specified interval over those lines. Saves spatial
    index of lines for fast intersections. Also saves unioned geometry
    polygons for fast point-in-polygon checks.

    Args:
        aoi_vector_path (string): path to polygon vector used to define
            boundaries for adding points.
        landmass_vector_path (string): path to polygon vector
        model_resolution (float): distance in meters for the point's spacing
        target_points_path (string): path to .gpkg point vector
        target_polygon_pickle_path (string): path to pickle
            storing shapely polygon geometry.
        target_lines_pickle_path (string): path to pickle
            storing list of shapely line geometries
        target_rtree_path (string): path to rtree file indexing
            bounds of line geometries.

    Returns:
        None

    """
    LOGGER.info("preparing landmass geometry and interpolating shore points")
    # Get shapely geometries from landmass
    landmass_polygon_shapely_list = _ogr_to_geometry_list(landmass_vector_path)
    landmass_shapely = shapely.ops.unary_union(landmass_polygon_shapely_list)
    landmass_polygon_shapely_list = None

    # store polygon geom for point-in-poly check later in ray-casting
    with open(target_polygon_pickle_path, 'wb') as polygon_pickle_file:
        pickle.dump(landmass_shapely, polygon_pickle_file)

    # Build index of landmass line geometries on disk
    target_rtree_base = os.path.splitext(target_rtree_path)[0]
    if os.path.exists(target_rtree_path):
        for ext in ['.dat', '.idx']:
            os.remove(target_rtree_base + ext)

    polygon_line_rtree = rtree.index.Index(target_rtree_base)
    shapely_line_index = []

    # id to use for building up rtree
    line_id = 0

    # create the spatial reference from the base vector
    aoi_vector_info = pygeoprocessing.get_vector_info(aoi_vector_path)
    aoi_spatial_reference = osr.SpatialReference()
    aoi_spatial_reference.ImportFromWkt(aoi_vector_info['projection_wkt'])

    aoi_shapely_list = _ogr_to_geometry_list(aoi_vector_path)
    aoi_shapely = shapely.ops.unary_union(aoi_shapely_list)
    aoi_shapely_prepped = shapely.prepared.prep(aoi_shapely)

    gpkg_driver = ogr.GetDriverByName("GPKG")
    target_vector = gpkg_driver.CreateDataSource(target_points_path)
    layer_name = os.path.splitext(
        os.path.basename(target_points_path))[0]
    target_layer = target_vector.CreateLayer(
        layer_name, aoi_spatial_reference, ogr.wkbPoint)
    target_defn = target_layer.GetLayerDefn()
    # It's important to have a user-facing unique ID field for post-processing
    # (e.g. table-joins) that is not the FID. FIDs are not stable across file
    # conversions that users might do. FIDs still used internally in this
    # module.
    target_layer.CreateField(
        ogr.FieldDefn(SHORE_ID_FIELD, ogr.OFTInteger64))

    # unique ID to iterate for shore points target SHORE_ID_FIELD field
    point_idx_tracker = 0

    LOGGER.info("indexing geometry of landmass and creating shore points")
    for landmass_polygon in _list_geometry(landmass_shapely):
        lines_in_aoi_list = []
        for landmass_line in geometry_to_lines(
                landmass_polygon, include_interiors=False):
            # 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
            shapely_line_index.append(landmass_line)

            # Already clipped landlines to AOI plus max-fetch extent
            # still want to clip lines by actual AOI for shore point creation
            if aoi_shapely_prepped.intersects(landmass_line):
                intersected_shapely_geom = aoi_shapely.intersection(
                    landmass_line)
                # intersection could generate a point geom,
                # or if somehow the intersection is empty,
                # type will be GeometryCollection.
                if isinstance(intersected_shapely_geom,
                              (LineString, MultiLineString)):
                    lines_in_aoi_list.extend(
                        _list_geometry(intersected_shapely_geom))

        # if none of the lines were disjoint before this linemerge,
        # unioned_line will now be a LineString.
        # If some lines were disjoint before the linemerge, unioned_line
        # will now be a MultiLineString.
        unioned_line = shapely.ops.linemerge(lines_in_aoi_list)
        point_list = []
        for line in _list_geometry(unioned_line):
            n_pts = int(math.ceil(line.length / model_resolution))
            points_along = [
                line.interpolate(float(i) / n_pts, normalized=True)
                for i in range(n_pts)]
            point_list.extend(points_along)

        if point_list:
            target_layer.StartTransaction()
            for point_feature in point_list:
                geometry = ogr.Geometry(ogr.wkbPoint)
                geometry.AddPoint_2D(
                    point_feature.coords[0][0], point_feature.coords[0][1])
                feature = ogr.Feature(target_defn)
                feature.SetGeometry(geometry)
                feature.SetField(SHORE_ID_FIELD, point_idx_tracker)
                target_layer.CreateFeature(feature)
                point_idx_tracker += 1
            target_layer.CommitTransaction()

    n_shore_points = target_layer.GetFeatureCount()
    if n_shore_points == 0:
        raise(RuntimeError(
            "No shoreline was found within the Area of Interest. The landmass "
            "vector must be a polygon with some edges within the AOI."))
    LOGGER.info(f"Finished creating {n_shore_points} shore points in AOI")
    target_layer = None
    target_vector = None

    with open(target_lines_pickle_path, 'wb') as lines_pickle_file:
        pickle.dump(shapely_line_index, lines_pickle_file)
    polygon_line_rtree.close()


def interpolate_wwiii_to_shore(
        base_shore_point_vector_path, wwiii_vector_path,
        target_shore_point_vector_path):
    """Spatial join of Wave Watch 3 data to shore points.

    Finds nearest WW3 points to each shore point and calculates
    a weighted average of values with distance weights.

    Args:
        base_shore_point_vector_path (string): path to point vector
        wwiii_vector_path (string): path to point shapefile representing
            the Wave Watch III data.
        target_shore_point_vector_path (string): path to point vector file
            with interpolated wwiii data.

    Returns:
        None

    """
    # Wave Watch III data does not cover the entire planet. Avoid taking values
    # from a WWIII point across the ocean by limiting the search distance.
    # 3 degrees is arbitrary, but seems reasonable and generous given the
    # WWIII points are spaced on a 0.5 degree grid.
    _max_wwiii_distance = 3  # degrees

    LOGGER.info("Building spatial index for Wave Watch III points")
    wwiii_rtree = rtree.index.Index()
    wwiii_vector = gdal.OpenEx(wwiii_vector_path, gdal.OF_VECTOR)
    wwiii_layer = wwiii_vector.GetLayer()
    for wwiii_feature in wwiii_layer:
        wwiii_geometry = wwiii_feature.GetGeometryRef()
        wwiii_x = wwiii_geometry.GetX()
        wwiii_y = wwiii_geometry.GetY()
        wwiii_rtree.insert(
            wwiii_feature.GetFID(), (wwiii_x, wwiii_y, wwiii_x, wwiii_y))

    # Copy shore point geometry and create fields for WWIII values
    _copy_point_vector_geom_to_gpkg(
        base_shore_point_vector_path, target_shore_point_vector_path,
        copy_field_list=[SHORE_ID_FIELD])
    points_vector = gdal.OpenEx(
        target_shore_point_vector_path, gdal.OF_VECTOR | gdal.GA_Update)
    points_layer = points_vector.GetLayer()
    wwiii_defn = wwiii_layer.GetLayerDefn()
    field_names = []
    for field_index in range(wwiii_defn.GetFieldCount()):
        field_defn = wwiii_defn.GetFieldDefn(field_index)
        field_name = field_defn.GetName()
        if field_name in ['I', 'J']:
            continue
        field_names.append(field_name)
        points_layer.CreateField(field_defn)

    wwiii_spatial_reference = osr.SpatialReference()
    wwiii_ref_wkt = pygeoprocessing.get_vector_info(
        wwiii_vector_path)['projection_wkt']
    wwiii_spatial_reference.ImportFromWkt(wwiii_ref_wkt)

    if bool(wwiii_spatial_reference.IsProjected()):
        # In case a user decided to project this input on their own,
        # we need a max search distance in meters instead of degrees
        _max_wwiii_distance = 210000  # meters, rough equivalent of 3 degrees

    points_spatial_reference = osr.SpatialReference()
    points_ref_wkt = pygeoprocessing.get_vector_info(
        target_shore_point_vector_path)['projection_wkt']
    points_spatial_reference.ImportFromWkt(points_ref_wkt)
    points_to_wwiii_transform = utils.create_coordinate_transformer(
        points_spatial_reference, wwiii_spatial_reference)

    points_layer.StartTransaction()
    LOGGER.info("Interpolating Wave Watch III data to shore points")
    wwiii_field_lookup = {}
    for shore_point_feature in points_layer:
        shore_point_geometry = shore_point_feature.GetGeometryRef()
        # Transform each shore point to match the wwiii SRS
        shore_point_longitude, shore_point_latitude, _ = (
            points_to_wwiii_transform.TransformPoint(
                shore_point_geometry.GetX(), shore_point_geometry.GetY()))
        # From wave watch III points within AOI, get nearest from shore point
        nearest_points = list(wwiii_rtree.nearest(
            (shore_point_longitude, shore_point_latitude,
             shore_point_longitude, shore_point_latitude), 3))[0:3]

        # create placeholders for point geometry and field values
        wwiii_points = numpy.empty((3, 2))
        wwiii_values = numpy.empty((3, len(field_names)))
        for fid_index, fid in enumerate(nearest_points):
            wwiii_feature = wwiii_layer.GetFeature(fid)
            wwiii_geometry = wwiii_feature.GetGeometryRef()
            wwiii_points[fid_index] = numpy.array(
                [wwiii_geometry.GetX(), wwiii_geometry.GetY()])
            try:
                wwiii_values[fid_index] = wwiii_field_lookup[fid]
            except KeyError:
                wwiii_field_lookup[fid] = numpy.array(
                    [float(wwiii_feature.GetField(field_name))
                     for field_name in field_names])
                wwiii_values[fid_index] = wwiii_field_lookup[fid]

        distances = numpy.linalg.norm(
            wwiii_points - numpy.array(
                (shore_point_longitude,
                 shore_point_latitude)), axis=1)

        # make sure points are within a valid data distance
        close_enough = distances < _max_wwiii_distance
        if not any(close_enough):
            raise ValueError(
                'No WaveWatchIII points were found near the area of interest.'
                'Is the area of interest far outside the coverage of '
                f'{wwiii_vector_path}?')

        wwiii_values = numpy.average(
            wwiii_values[close_enough],
            weights=(_max_wwiii_distance - distances[close_enough]),
            axis=0)

        for field_name_index, field_name in enumerate(field_names):
            shore_point_feature.SetField(
                field_name, wwiii_values[field_name_index])
        points_layer.SetFeature(shore_point_feature)
        shore_point_feature = None

    points_layer.CommitTransaction()
    points_layer = None
    points_vector = None

    LOGGER.info("Finished interpolating Wave Watch III data to shore points")


def interpolate_sealevelrise_points(
        base_shore_point_vector_path, slr_points_vector_path,
        slr_fieldname, target_pickle_path):
    """Spatial join of sea-level rise data to shore points.

    Finds nearest sea-level points to each shore point and calculates
    a weighted average of values with inverse-distance weights.

    Args:
        base_shore_point_vector_path (string): path to point vector
        slr_points_vector_path (string): path to point vector containing
            the field ``slr_fieldname``.
        slr_fieldname (string): name of a field containing numeric values
        target_pickle_path (string): path to pickle file storing dict
            mapping each shore point's `shore_id` to the distance-weighted
            average sea level rise at nearby SLR points.

    Returns:
        None

    """
    # In case SLR points map to very far away from the shoreline points
    # due to data preparation error, we don't want to silently join those
    # data to the shoreline points. So, limit the distance to search for SLR
    # points. 500km is arbitrary and generous, but it's only meant to
    # catch errors. A max distance is also useful for inverting distances
    # later on, to use as weights in a weighted average.
    max_slr_distance = 500000  # meters - since meters is mandated unit for AOI

    LOGGER.info("Interpolate sea-level rise values to shore points")

    base_srs_wkt = pygeoprocessing.get_vector_info(
        slr_points_vector_path)['projection_wkt']
    base_spatial_reference = osr.SpatialReference()
    base_spatial_reference.ImportFromWkt(base_srs_wkt)
    target_srs_wkt = pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)['projection_wkt']
    target_spatial_reference = osr.SpatialReference()
    target_spatial_reference.ImportFromWkt(target_srs_wkt)
    transform_slr_to_shore = utils.create_coordinate_transformer(
        base_spatial_reference, target_spatial_reference)

    slr_rtree = rtree.index.Index()
    slr_vector = gdal.OpenEx(
        slr_points_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    slr_layer = slr_vector.GetLayer()
    for feature in slr_layer:
        geometry = feature.GetGeometryRef()
        geometry.Transform(transform_slr_to_shore)
        slr_x = geometry.GetX()
        slr_y = geometry.GetY()
        slr_rtree.insert(
            feature.GetFID(), (slr_x, slr_y, slr_x, slr_y))
        geometry = None
        feature = None

    result = {}
    base_vector = gdal.OpenEx(
        base_shore_point_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    base_layer = base_vector.GetLayer()
    for feature in base_layer:
        shore_id = feature.GetField(SHORE_ID_FIELD)
        shore_geometry = feature.GetGeometryRef()
        shore_x = shore_geometry.GetX()
        shore_y = shore_geometry.GetY()
        nearest_points = list(slr_rtree.nearest(
            (shore_x, shore_y, shore_x, shore_y), 2))[0:2]

        nearest_slr_values = numpy.empty((len(nearest_points), 1))
        # nearest_slr_values = numpy.empty((2, 1))
        slr_points = numpy.empty((len(nearest_points), 2))
        for fid_index, fid in enumerate(nearest_points):
            slr_feature = slr_layer.GetFeature(fid)
            slr_geometry = slr_feature.GetGeometryRef()
            slr_geometry.Transform(transform_slr_to_shore)
            slr_points[fid_index] = numpy.array(
                [slr_geometry.GetX(), slr_geometry.GetY()])
            try:
                nearest_slr_values[fid_index] = float(
                    slr_feature.GetField(slr_fieldname))
            except KeyError:
                raise KeyError(
                    f'fieldname {slr_fieldname} not found in '
                    f'{slr_points_vector_path}')

        distances = numpy.linalg.norm(
            slr_points - numpy.array(
                (shore_x, shore_y)), axis=1)

        # make sure points are within a valid data distance
        close_enough = distances < max_slr_distance
        if not any(close_enough):
            result[shore_id] = numpy.nan
            continue

        slr_value = numpy.average(
            nearest_slr_values[close_enough],
            weights=(max_slr_distance - distances[close_enough]),
            axis=0)
        result[shore_id] = slr_value[0]

    slr_layer = None
    slr_vector = None
    base_layer = None
    base_vector = None

    with open(target_pickle_path, 'wb') as pickle_file:
        pickle.dump(result, pickle_file)
    LOGGER.info("Finished interpolating sea-level rise values to shore points")


def calculate_wind_exposure(
        base_shore_point_vector_path, landmass_polygon_pickle_path,
        landmass_line_rtree_path, landmass_lines_pickle_path,
        bathymetry_raster_path, target_fetch_rays_path, max_fetch_distance,
        target_shore_point_vector_path, target_wind_exposure_pickle_path):
    """Calculate wind exposure for each shore point.

    Args:
        base_shore_point_vector_path (string): path to a point vector
            with WWIII variables in the table.
        landmass_polygon_pickle_path (string): path to pickle
            storing shapely polygon geometry of the landmass.
        landmass_line_rtree_path (string): path to rtree file indexing
            bounds of line geometries.
        landmass_lines_pickle_path (string): path to pickle
            storing list of shapely line geometries. List index must match
            index of ``landmass_line_rtree_path``.
        bathymetry_raster_path (string): path to bathymetry raster that has
            already had positive pixel values clamped to 0.
        target_fetch_rays_path (string): path to target line string file
            representing the rays cast in 16 directions around each shore point
        max_fetch_distance (float): maximum fetch distance for a ray in
            meters.
        target_shore_point_vector_path (string): path to target point file,
            will be a copy of ``base_shore_point_vector_path``'s geometry with
            an 'REI' (relative exposure index) field added.
        target_wind_exposure_pickle_path (string): path to pickle file storing
            dict that maps each shore point's `shore_id` to the REI value at
            that point.

    Returns:
        None

    """
    LOGGER.info("Calculating wind exposure")

    # this should still match the user-defined SRS from the AOI:
    base_ref_wkt = pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)['projection_wkt']
    base_spatial_reference = osr.SpatialReference()
    base_spatial_reference.ImportFromWkt(base_ref_wkt)

    gpkg_driver = ogr.GetDriverByName('GPKG')

    # Get prepared polygon geom for point-in-poly checks
    with open(landmass_polygon_pickle_path, 'rb') as polygon_pickle_file:
        landmass_shapely = pickle.load(polygon_pickle_file)
    landmass_shapely_prep = shapely.prepared.prep(landmass_shapely)

    with open(landmass_lines_pickle_path, 'rb') as lines_pickle_file:
        shapely_line_index = pickle.load(lines_pickle_file)

    # load an existing rtree from disk
    polygon_line_rtree = rtree.index.Index(
        os.path.splitext(landmass_line_rtree_path)[0])

    # create fetch rays
    temp_fetch_rays_vector = gpkg_driver.CreateDataSource(
        target_fetch_rays_path)
    layer_name = os.path.splitext(
        os.path.basename(target_fetch_rays_path))[0]
    temp_fetch_rays_layer = (
        temp_fetch_rays_vector.CreateLayer(
            layer_name, base_spatial_reference, ogr.wkbLineString))
    temp_fetch_rays_defn = temp_fetch_rays_layer.GetLayerDefn()
    temp_fetch_rays_layer.CreateField(ogr.FieldDefn(
        'fetch_dist', ogr.OFTReal))
    temp_fetch_rays_layer.CreateField(ogr.FieldDefn(
        'direction', ogr.OFTReal))

    # These WWIII fields are the only ones needed for wind & wave equations
    # Copy them to a new vector which also gets more fields added with
    # computed values.
    fields_to_copy = [SHORE_ID_FIELD] + construct_field_list('REI_PCT') + \
        construct_field_list('REI_V') + \
        construct_field_list('WavP_') + \
        construct_field_list('WavPPCT') + \
        construct_field_list('V10PCT_')
    _copy_point_vector_geom_to_gpkg(
        base_shore_point_vector_path, target_shore_point_vector_path,
        copy_field_list=fields_to_copy)

    target_shore_point_vector = gdal.OpenEx(
        target_shore_point_vector_path, gdal.OF_VECTOR | gdal.GA_Update)
    target_shore_point_layer = target_shore_point_vector.GetLayer()
    target_shore_point_layer.CreateField(ogr.FieldDefn('REI', ogr.OFTReal))
    for ray_index in range(_N_FETCH_RAYS):
        compass_degree = int(ray_index * 360 / 16)
        target_shore_point_layer.CreateField(
            ogr.FieldDefn(f'fdist_{compass_degree}', ogr.OFTReal))
        target_shore_point_layer.CreateField(
            ogr.FieldDefn(f'fdepth_{compass_degree}', ogr.OFTReal))

    shore_point_logger = _make_logger_callback(
        "Wind exposure %.2f%% complete.", LOGGER)
    # Iterate over every shore point
    LOGGER.info("Casting rays and extracting bathymetry values")
    result_REI = {}
    bathy_raster = gdal.OpenEx(
        bathymetry_raster_path, gdal.OF_RASTER | gdal.GA_ReadOnly)
    bathy_band = bathy_raster.GetRasterBand(1)
    bathy_raster_info = pygeoprocessing.get_raster_info(bathymetry_raster_path)
    bathy_gt = bathy_raster_info['geotransform']
    bathy_nodata = bathy_raster_info['nodata'][0]

    target_shore_point_layer.StartTransaction()
    temp_fetch_rays_layer.StartTransaction()
    for shore_point_feature in target_shore_point_layer:
        shore_id = shore_point_feature.GetField(SHORE_ID_FIELD)
        shore_point_logger(
            float(shore_id) /
            target_shore_point_layer.GetFeatureCount())
        rei_value = 0
        # Iterate over every ray direction
        for sample_index in range(_N_FETCH_RAYS):
            compass_degree = int(sample_index * 360 / 16)
            compass_theta = float(sample_index) / _N_FETCH_RAYS * 360
            rei_pct = shore_point_feature.GetField(
                f'REI_PCT{int(compass_theta)}')
            rei_v = shore_point_feature.GetField(f'REI_V{int(compass_theta)}')
            cartesian_theta = -(compass_theta - 90)

            # Determine the direction the ray will point
            delta_x = math.cos(cartesian_theta * math.pi / 180)
            delta_y = math.sin(cartesian_theta * math.pi / 180)

            # Start a ray offset from the shore point
            # so that rays start outside of the landmass.
            # Shore points are interpolated onto the coastline,
            # but floating point error results in points being just
            # barely inside/outside the landmass.
            offset = 1  # 1 meter should be plenty
            shore_point_geometry = shore_point_feature.GetGeometryRef()
            point_a_x = (
                shore_point_geometry.GetX() + delta_x * offset)
            point_a_y = (
                shore_point_geometry.GetY() + delta_y * offset)
            point_b_x = point_a_x + delta_x * (
                max_fetch_distance)
            point_b_y = point_a_y + delta_y * (
                max_fetch_distance)
            shore_point_geometry = None

            # build ray geometry so we can intersect it later
            ray_geometry = ogr.Geometry(ogr.wkbLineString)
            ray_geometry.AddPoint_2D(point_a_x, point_a_y)
            ray_geometry.AddPoint_2D(point_b_x, point_b_y)

            # keep a shapely version of the ray so we can do fast intersection
            # with it and the entire landmass
            ray_point_origin_shapely = shapely.geometry.Point(
                point_a_x, point_a_y)

            ray_length = 0
            bathy_values = []
            if not landmass_shapely_prep.intersects(
                    ray_point_origin_shapely):
                # the origin is in ocean, so we'll get a ray length > 0

                # This algorithm searches for intersections, if one is found
                # the ray updates and a smaller intersection set is determined
                # by experimentation I've found this is significant, but not
                # an order of magnitude, faster than looping through all
                # original possible intersections.  Since this algorithm
                # will be run for a long time, it's worth the additional
                # complexity
                tested_indexes = set()
                while True:
                    intersection = False
                    ray_envelope = ray_geometry.GetEnvelope()
                    for poly_line_index in polygon_line_rtree.intersection(
                            [ray_envelope[i] for i in [0, 2, 1, 3]]):
                        if poly_line_index in tested_indexes:
                            continue
                        tested_indexes.add(poly_line_index)
                        line_shapely = (
                            shapely_line_index[poly_line_index])
                        line_segment = ogr.CreateGeometryFromWkb(
                            line_shapely.wkb)
                        if ray_geometry.Intersects(line_segment):
                            # if the ray intersects the poly line, test if
                            # the intersection is closer than any known
                            # intersection so far
                            intersection_point = ray_geometry.Intersection(
                                line_segment)
                            # replace the ray geometry with the new endpoint
                            ray_geometry = ogr.Geometry(ogr.wkbLineString)
                            ray_geometry.AddPoint_2D(point_a_x, point_a_y)
                            ray_geometry.AddPoint_2D(
                                intersection_point.GetX(),
                                intersection_point.GetY())
                            intersection = True
                            break
                    if not intersection:
                        break
                # when we get here, we have the final ray geometry
                ray_length = ray_geometry.Length()

                bathy_values = extract_bathymetry_along_ray(
                    ray_geometry, bathy_gt, bathy_nodata, bathy_band)

                ray_feature = ogr.Feature(temp_fetch_rays_defn)
                ray_feature.SetField('fetch_dist', ray_length)
                ray_feature.SetField('direction', compass_degree)
                ray_feature.SetGeometry(ray_geometry)
                temp_fetch_rays_layer.CreateFeature(ray_feature)

            # For rays of length 0, we have no bathy values
            # this avoids numpy's RuntimeWarning on numpy.mean([])
            if not bathy_values:
                avg_fetch_depth = numpy.nan
            else:
                avg_fetch_depth = numpy.mean(bathy_values)
            shore_point_feature.SetField(
                f'fdist_{compass_degree}', float(ray_length))
            shore_point_feature.SetField(
                f'fdepth_{compass_degree}', float(avg_fetch_depth))
            ray_feature = None
            ray_geometry = None
            rei_value += ray_length * rei_pct * rei_v
        shore_point_feature.SetField('REI', rei_value)
        target_shore_point_layer.SetFeature(shore_point_feature)
        result_REI[shore_id] = rei_value

    target_shore_point_layer.CommitTransaction()
    target_shore_point_layer.SyncToDisk()
    target_shore_point_layer = None
    target_shore_point_vector = None
    temp_fetch_rays_layer.CommitTransaction()
    temp_fetch_rays_layer.SyncToDisk()
    temp_fetch_rays_layer = None
    temp_fetch_rays_vector = None
    bathy_raster = None
    bathy_band = None

    with open(target_wind_exposure_pickle_path, 'wb') as pickle_file:
        pickle.dump(result_REI, pickle_file)
    LOGGER.info("Finished calculating wind exposure")


def extract_bathymetry_along_ray(
        ray_geometry, bathy_gt, bathy_nodata, bathy_band):
    """Extract valid raster values along a ray.

    Extract bathymetry values at points along a ray.
    Points are interpolated along the ray at an interval equal to the rasters
    pixel size, and points are explicitly placed at each ray endpoint.

    If only nodata values are extracted along the entire length of the ray,
    expand the extraction window as needed on the final ray point, until some
    valid pixels are found.

    Args:
        ray_geometry (ogr.wkbLineString): The ray along which to extract values
        bathy_gt (list): The bathymetry raster's geotransform
        bathy_nodata (number): The bathymetry raster's nodata value
        bathy_band (gdal raster band): An open gdal raster band containing the
            bathymetry values

    Raises:
        ValueError if an extraction point is outside the bounds of the raster.

    Returns:
        A list of the non-nodata pixel values extracted at each point.

    """
    bathy_values = []
    n_pts = int(math.ceil(ray_geometry.Length() / bathy_gt[1]))
    shapely_line = shapely.wkb.loads(bytes(ray_geometry.ExportToWkb()))
    points_along_ray = (
        shapely_line.interpolate(float(i) / n_pts, normalized=True)
        for i in range(n_pts + 1))  # +1 to get a point at the end
    for point in points_along_ray:
        ix = int((point.x - bathy_gt[0]) / bathy_gt[1])
        iy = int((point.y - bathy_gt[3]) / bathy_gt[5])
        win_size = 1

        try:
            value = bathy_band.ReadAsArray(
                xoff=ix, yoff=iy,
                win_xsize=win_size, win_ysize=win_size)
        except RuntimeError as ex:
            location = {'xoff': ix, 'yoff': iy, 'win_xsize': win_size,
                             'win_ysize': win_size}
            raise ValueError(
                f'Failed to read bathymetry at {location}. Does the bathymetry '
                'input fully cover the fetch ray area?') from ex
        if bathy_nodata is None or not numpy.isclose(
                value[0][0], bathy_nodata, equal_nan=True):
            bathy_values.append(value)

    # Gaps between shoreline and bathymetry input datasets could result in no
    # valid pixels along the ray. So expand the window on the last point,
    # if needed, until we find valid pixels. Pixels >= 0 were already masked.
    n_rows = bathy_band.YSize
    n_cols = bathy_band.XSize
    win_xsize = win_size
    win_ysize = win_size
    while not bathy_values:
        if ix == 0 and iy == 0 and win_xsize == n_cols and win_ysize == n_rows:
            # if entire raster is nodata, we're here to avoid the infinite loop
            raise ValueError(
                'searched entire bathymetry raster for valid values '
                'and found none')
        # Expand window symmetrically around the original point
        ix -= 1  # move left
        iy -= 1  # move up
        win_xsize += 2
        win_ysize += 2
        # but don't move off the edge
        if ix < 0:
            ix = 0
        if iy < 0:
            iy = 0
        if ix + win_xsize > n_cols:
            win_xsize -= ix + win_xsize - n_cols
        if iy + win_ysize > n_rows:
            win_ysize -= iy + win_ysize - n_rows
        values = bathy_band.ReadAsArray(
            xoff=ix, yoff=iy,
            win_xsize=win_xsize, win_ysize=win_ysize)

        # if nodata value is undefined, all pixels are valid
        valid_mask = slice(None)
        if bathy_nodata is not None:
            valid_mask = ~pygeoprocessing.array_equals_nodata(values, bathy_nodata)
        if numpy.any(valid_mask):
            # take mean of valids and move on
            value = numpy.mean(values[valid_mask])
            bathy_values.append(value)
    return bathy_values


def compute_wave_height(Un, Fn, dn):
    """Compute Wave Height by User Guide eq 10.

    This equation may not be suitable for wind speed values < 1 m/s
    The WWIII database tends to include some 0s, otherwise values > 2.

    Args:
        Un (float): wind velocity in meters per second.
        Fn (float): fetch ray length in meters.
        dn (float): water depth in negative meters.

    Returns:
        Float: Wave height in meters

    """
    if Un < 1:
        LOGGER.warning(f'Found wind velocity of {Un:.2f}, '
                       'using 1m/s in wave height calculation instead')
        Un = 1
    g = 9.81
    dn = -dn
    ds = g*dn/Un**2
    Fs = g*Fn/Un**2
    A = numpy.tanh(0.343*ds**1.14)
    B = numpy.tanh(4.41e-4*Fs**0.79/A)
    H_n = (0.24*Un**2/g)*(A*B)**0.572
    return H_n


def compute_wave_period(Un, Fn, dn):
    """Compute Wave Period by User Guide eq 10.

    This equation may not be suitable for wind speed values < 1 m/s
    The WWIII database tends to include some 0s, otherwise values > 2.

    Args:
        Un (float): wind velocity in meters per second.
        Fn (float): fetch ray length in meters.
        dn (float): water depth in negative meters.

    Returns:
        Float: Wave period in seconds

    """
    # This equation may not be suitable for wind speed values < 1 m/s
    # The WWIII database tends to include some 0s, otherwise values > 2
    if Un < 1:
        LOGGER.warning(f'Found wind velocity of {Un:.2f}, using 1m/s in wave '
                       'height calculation instead')
        Un = 1
    g = 9.81
    dn = -dn
    ds = g*dn/Un**2
    Fs = g*Fn/Un**2
    A = numpy.tanh(0.1*ds**2.01)
    B = numpy.tanh(2.77e-7*Fs**1.45/A)
    T_n = 7.69*Un/g*(A*B)**0.187
    return T_n


def calculate_wave_exposure(
        base_fetch_point_vector_path, max_fetch_distance,
        target_wave_vector_path,
        target_wave_exposure_pickle_path):
    """Calculate wave exposure values at each shore point.

    Args:
        base_fetch_point_vector_path (string): path to a point shapefile that
            contains 16 'WavP_[direction]' fields, 'WavPPCT[direction]'
            fields, V10PCT_[direction] fields, 'fdist_[direction]' fields,
            'fdepth_[direction]' fields.
        max_fetch_distance (float): max fetch distance before a wind fetch ray
            is terminated.
        target_wave_vector_path (string): path to GPKG vector that this
            function creates to store intermediate ocean-wave and
            local-wind-driven-wave energy values.
        target_wave_exposure_pickle_path (string): path to pickle file storing
            dict mapping each shore point's `shore_id` to its wave exposure

    Returns:
        None

    """
    LOGGER.info("Calculating wave exposure")
    result_Ew = {}  # max of the two types of wave energies by shore_id
    _copy_point_vector_geom_to_gpkg(
        base_fetch_point_vector_path, target_wave_vector_path,
        copy_field_list=[SHORE_ID_FIELD])

    target_wave_vector = gdal.OpenEx(
        target_wave_vector_path, gdal.OF_VECTOR | gdal.GA_Update)
    target_wave_layer = target_wave_vector.GetLayer()
    target_wave_layer.CreateField(  # ocean-driven wave energy
        ogr.FieldDefn('E_ocean', ogr.OFTReal))
    target_wave_layer.CreateField(  # local-wind-driven wave energy
        ogr.FieldDefn('E_local', ogr.OFTReal))
    target_wave_layer.CreateField(
        ogr.FieldDefn('Eo_El_diff', ogr.OFTReal))
    target_wave_layer.CreateField(
        ogr.FieldDefn('max_E_type', ogr.OFTString))
    # Instead of recording every value per ray, just record max and min
    # values for each shore point
    target_wave_layer.CreateField(  # local-wind-driven wave height
        ogr.FieldDefn('maxH_local', ogr.OFTReal))
    target_wave_layer.CreateField(  # local-wind-driven wave height
        ogr.FieldDefn('minH_local', ogr.OFTReal))
    target_wave_layer.CreateField(  # local-wind-driven wave period
        ogr.FieldDefn('maxT_local', ogr.OFTReal))
    target_wave_layer.CreateField(  # local-wind-driven wave period
        ogr.FieldDefn('minT_local', ogr.OFTReal))

    target_wave_layer.StartTransaction()
    base_fetch_point_vector = gdal.OpenEx(
        base_fetch_point_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    base_fetch_point_layer = base_fetch_point_vector.GetLayer()
    for base_fetch_point_feature in base_fetch_point_layer:
        # The base layer has all the data needed for calculations
        # The target will store computed values
        fid = base_fetch_point_feature.GetFID()
        target_feature = target_wave_layer.GetFeature(fid)

        shore_id = base_fetch_point_feature.GetField(SHORE_ID_FIELD)

        e_ocean = 0
        e_local = 0
        height_list = []
        period_list = []
        for sample_index in range(_N_FETCH_RAYS):
            compass_degree = int(sample_index * 360 / 16)
            fdist = base_fetch_point_feature.GetField(
                f'fdist_{compass_degree}')
            if numpy.isclose(fdist, max_fetch_distance):
                e_ocean += (
                    base_fetch_point_feature.GetField(
                        f'WavP_{compass_degree}') *
                    base_fetch_point_feature.GetField(
                        f'WavPPCT{compass_degree}'))
            if fdist > 0:
                # if fdist is 0, we can skip height & period calculations,
                # knowing there is no wave energy along a non-existent ray.
                velocity = base_fetch_point_feature.GetField(
                    f'V10PCT_{compass_degree}')
                depth = base_fetch_point_feature.GetField(
                    f'fdepth_{compass_degree}')
                occurrence = base_fetch_point_feature.GetField(
                    f'REI_PCT{compass_degree}')
                if depth is None:
                    # we're here if a very coarse bathymetry raster was used
                    # and there were no negative depth values to be extracted
                    # for this ray. We know the ray is over water, probably a
                    # channel too narrow to resolve in the bathy raster, so
                    # we're not sure how deep.
                    LOGGER.warning(
                        f'found fetch ray at shore_id {shore_id} with no '
                        'bathymetry data, assuming depth of -1 for wave '
                        'height & period calculations')
                    depth = -1
                height = compute_wave_height(velocity, fdist, depth)
                height_list.append(height)
                period = compute_wave_period(velocity, fdist, depth)
                period_list.append(period)
                power = 0.5 * float(height)**2 * float(period)  # UG Eq. 8
                e_local += power * occurrence  # UG Eq. 9

        # These vars are written purely for diagnostic purposes
        if height_list:
            target_feature.SetField('minH_local', min(height_list))
            target_feature.SetField('maxH_local', max(height_list))
        if period_list:
            target_feature.SetField('minT_local', min(period_list))
            target_feature.SetField('maxT_local', max(period_list))
        target_feature.SetField('E_ocean', e_ocean)
        target_feature.SetField('E_local', e_local)
        e_diff = float(e_ocean - e_local)
        target_feature.SetField('Eo_El_diff', e_diff)
        if e_diff > 0:
            target_feature.SetField('max_E_type', 'ocean')
        elif e_diff < 0:
            target_feature.SetField('max_E_type', 'local')
        target_wave_layer.SetFeature(target_feature)

        result_Ew[shore_id] = max(e_ocean, e_local)

    target_wave_layer.CommitTransaction()
    target_wave_layer.SyncToDisk()
    target_wave_layer = None
    target_wave_vector = None

    with open(target_wave_exposure_pickle_path, 'wb') as pickle_file:
        pickle.dump(result_Ew, pickle_file)
    LOGGER.info("Finished calculating wave exposure")


def calculate_surge_exposure(
        base_shore_point_vector_path, shelf_contour_path,
        target_surge_pickle_path):
    """Calculate surge potential as distance to nearest point on a contour.

    Args:
        base_shore_point_vector_path (string):  path to a point shapefile to
            for relief point analysis.
        shelf_contour_path (string): path to a polyline vector.
        target_surge_pickle_path (string): path to pickle file storing dict
            keyed by shore point id.

    Returns:
        None.

    """
    LOGGER.info("Calculating surge potential")
    shore_point_vector_info = pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)
    target_srs_wkt = shore_point_vector_info['projection_wkt']
    target_spatial_reference = osr.SpatialReference()
    target_spatial_reference.ImportFromWkt(target_srs_wkt)
    shore_bounding_box = shore_point_vector_info['bounding_box']

    # By examing the global continental shelf layer in our sample data, all
    # global coast is far less than this distance from the nearest shelf edge:
    max_surge_distance = 1.5e6  # meters
    shore_bounding_box[0] -= max_surge_distance
    shore_bounding_box[1] -= max_surge_distance
    shore_bounding_box[2] += max_surge_distance
    shore_bounding_box[3] += max_surge_distance

    base_srs_wkt = pygeoprocessing.get_vector_info(
        shelf_contour_path)['projection_wkt']
    base_spatial_reference = osr.SpatialReference()
    base_spatial_reference.ImportFromWkt(base_srs_wkt)
    transform = utils.create_coordinate_transformer(
        base_spatial_reference, target_spatial_reference)

    base_srs_clipping_box = pygeoprocessing.transform_bounding_box(
        shore_bounding_box, target_srs_wkt, base_srs_wkt, edge_samples=11)
    base_srs_clipping_geom = ogr.CreateGeometryFromWkt(
        shapely.geometry.box(*base_srs_clipping_box).wkt)

    base_vector = gdal.OpenEx(
        shelf_contour_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    base_layer = base_vector.GetLayer()

    shapely_geometry_list = []
    for feature in base_layer:
        geometry = feature.GetGeometryRef()
        clipped_geometry = geometry.Intersection(base_srs_clipping_geom)
        if not clipped_geometry.IsEmpty():
            err_code = clipped_geometry.Transform(transform)
            if err_code != 0:
                LOGGER.warning(
                    f"Could not transform feature from {shelf_contour_path} to"
                    f"spatial reference system {target_srs_wkt}")
                continue
            shapely_geom = shapely.wkb.loads(bytes(clipped_geometry.ExportToWkb()))
            shapely_geometry_list.extend(_list_geometry(shapely_geom))

    if not shapely_geometry_list:
        raise ValueError(
            f"No portion of the shelf contour line ({shelf_contour_path}) is "
            "within 1500km of the AOI.")

    shelf_shapely_union = shapely.ops.linemerge(shapely_geometry_list)

    shore_point_vector = gdal.OpenEx(
        base_shore_point_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    shore_point_layer = shore_point_vector.GetLayer()
    result = {}
    for point_feature in shore_point_layer:
        shore_id = point_feature.GetField(SHORE_ID_FIELD)
        point_geometry = point_feature.GetGeometryRef()
        point_shapely = shapely.wkb.loads(bytes(point_geometry.ExportToWkb()))
        result[shore_id] = point_shapely.distance(shelf_shapely_union)

    with open(target_surge_pickle_path, 'wb') as pickle_file:
        pickle.dump(result, pickle_file)
    LOGGER.info("Finished calculating surge potential")


def calculate_relief_exposure(
        base_shore_point_vector_path, base_dem_path, dem_averaging_radius,
        model_resolution, workspace_dir, file_suffix,
        target_relief_pickle_path):
    """Calculate average of DEM pixels within a radius of shore points.

    Args:
        base_shore_point_vector_path (string):  path to a shore point vector.
        base_dem_path (string): path to a DEM raster.
        dem_averaging_radius (float): distance in meters
        model_resolution (float): distance in meters of the shore_point
            spacing, used here as a target pixel size in warp raster.
        workspace_dir (string): path to a directory for intermediate files
        file_suffix (string): to be appended to output filenames
        target_relief_pickle_path (string): path to pickle file storing dict
            keyed by shore point id.

    Returns:
        None.

    """
    LOGGER.info("Calculating relief exposure")
    base_shore_point_info = pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)
    target_srs_wkt = base_shore_point_info['projection_wkt']

    shore_point_bounding_box = base_shore_point_info['bounding_box']
    shore_point_bounding_box[0] -= dem_averaging_radius
    shore_point_bounding_box[1] -= dem_averaging_radius
    shore_point_bounding_box[2] += dem_averaging_radius
    shore_point_bounding_box[3] += dem_averaging_radius

    clipped_projected_dem_path = os.path.join(
        workspace_dir, f'clipped_projected_dem{file_suffix}.tif')
    clip_and_project_raster(
        base_dem_path, shore_point_bounding_box, target_srs_wkt,
        model_resolution, workspace_dir, file_suffix,
        clipped_projected_dem_path)

    raster_info = pygeoprocessing.get_raster_info(clipped_projected_dem_path)
    nodata = raster_info['nodata'][0]
    target_dtype = raster_info['datatype']
    positive_dem_path = os.path.join(
        workspace_dir, f'positive_dem{file_suffix}.tif')
    pygeoprocessing.raster_calculator(
        [(clipped_projected_dem_path, 1), (nodata, 'raw')],
        zero_negative_values, positive_dem_path, target_dtype, nodata)

    missing_values = 0

    def mean_op(array, mask):
        nonlocal missing_values
        if numpy.any(mask):
            return numpy.mean(array[mask])
        missing_values += 1
        return numpy.nan

    _aggregate_raster_values_in_radius(
        base_shore_point_vector_path, positive_dem_path, dem_averaging_radius,
        workspace_dir, target_relief_pickle_path, mean_op)

    if missing_values:
        LOGGER.warning(
            f'{missing_values} shore points are missing values after '
            f'aggregating {positive_dem_path}. No valid pixels '
            f'were found within the search radius '
            f'({dem_averaging_radius} meters) around these points.')
    LOGGER.info('Finished calculating relief exposure')


def zero_negative_values(depth_array, nodata):
    """Convert negative values to zero for relief."""
    result_array = numpy.empty_like(depth_array)
    if nodata is not None:
        valid_mask = ~pygeoprocessing.array_equals_nodata(depth_array, nodata)
        result_array[:] = nodata
        result_array[valid_mask] = 0
    else:
        result_array[:] = 0
    positive_mask = depth_array > 0
    result_array[positive_mask] = depth_array[positive_mask]
    return result_array


def mask_positive_values_op(depth_array, nodata):
    """Convert positive values to nodata for bathymetry."""
    result_array = numpy.empty_like(depth_array)
    result_array[:] = nodata
    negative_mask = depth_array < 0
    result_array[negative_mask] = depth_array[negative_mask]
    return result_array


def warp_and_mask_bathymetry(
        bathymetry_raster_path, target_srs_wkt, clipping_box,
        model_resolution, working_dir, file_suffix,
        target_negative_bathy_path):
    """Mask non-negative values from bathymetry, and warp to AOI SRS.

    Args:
        bathymetry_raster_path (string): path to a gdal raster
        target_srs_wkt (string): well-known-text spatial reference system
        clipping_box (list): sequence of floats that are coordinates in the
            target_srs [minx, miny, maxx, maxy]
        model_resolution (float): value for target pixel size
        working_dir (string): path to directory for intermediate files
        file_suffix (string): appended to any output filename.
        target_negative_bathy_path (string): path to clipped and warped raster.

    Returns:
        None

    """
    LOGGER.info("Masking positive values from bathymetry")
    clipped_projected_bathy_path = os.path.join(
        working_dir, f'clipped_projected_bathy{file_suffix}.tif')
    bathy_info = pygeoprocessing.get_raster_info(
        bathymetry_raster_path)
    bathy_nodata = bathy_info['nodata'][0]
    if bathy_nodata is None:
        bathy_nodata = pygeoprocessing.choose_nodata(bathy_info['numpy_type'])
    bathy_target_dtype = bathy_info['datatype']

    clip_and_project_raster(
        bathymetry_raster_path, clipping_box, target_srs_wkt, model_resolution,
        working_dir, file_suffix, clipped_projected_bathy_path)

    pygeoprocessing.raster_calculator(
        [(clipped_projected_bathy_path, 1), (bathy_nodata, 'raw')],
        mask_positive_values_op, target_negative_bathy_path,
        bathy_target_dtype, bathy_nodata)


def aggregate_population_density(
        base_shore_point_vector_path, base_population_raster_path,
        search_radius, model_resolution, workspace_dir, file_suffix,
        target_pickle_path):
    """Get population density within a search radius of points.

    Args:
        base_shore_point_vector_path (string): path to point vector
        base_population_raster_path (string): path to raster with population
            values
        search_radius (float): radius in meters around each point to search
            for valid population pixels.
        model_resolution (float): distance in meters of the shore_point
            spacing, used here as a target pixel size in warp raster.
        workspace_dir (string): path to directory for intermediate files
        file_suffix (string): appended to any output filename.
        target_pickle_path (string): path to pickle file storing dict
            keyed by shore point id.

    Returns:
        None

    """
    LOGGER.info("Aggregating population data")
    # SRS here is inherited from the user's AOI
    base_shore_point_info = pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)
    target_srs_wkt = base_shore_point_info['projection_wkt']

    # extend the clipping box to accomodate the search radius,
    shore_point_bounding_box = base_shore_point_info['bounding_box']
    shore_point_bounding_box[0] -= search_radius
    shore_point_bounding_box[1] -= search_radius
    shore_point_bounding_box[2] += search_radius
    shore_point_bounding_box[3] += search_radius

    clipped_projected_pop_path = os.path.join(
        workspace_dir, f'clipped_projected_pop{file_suffix}.tif')
    clip_and_project_raster(
        base_population_raster_path, shore_point_bounding_box, target_srs_wkt,
        model_resolution, workspace_dir, file_suffix,
        clipped_projected_pop_path)

    pixel_size = pygeoprocessing.get_raster_info(
        clipped_projected_pop_path)['pixel_size']

    missing_values = 0

    def density_op(array, mask):
        nonlocal missing_values
        if numpy.any(mask):
            # validate guarantees pixel size units are meters
            pixel_area_km = abs(pixel_size[0] * pixel_size[1]) / 1e6
            return numpy.mean(array[mask]) / pixel_area_km
        missing_values += 1
        return numpy.nan

    _aggregate_raster_values_in_radius(
        base_shore_point_vector_path, clipped_projected_pop_path,
        search_radius, workspace_dir, target_pickle_path, density_op)

    if missing_values:
        LOGGER.warning(
            f'{missing_values} shore points are missing values after '
            f'aggregating {clipped_projected_pop_path}. No valid pixels '
            f'were found within the search radius ({search_radius} meters) '
            f'around these points.')
    LOGGER.info('Finished aggregating population data')


def _schedule_habitat_tasks(
        base_shore_point_vector_path, habitat_table_path, model_resolution,
        working_dir, file_suffix, task_graph):
    """Add a habitat processing task to the graph, for each habitat.

    Args:
        base_shore_point_vector_path (string): path to a shore point vector.
        habitat_table_path (string): path to a CSV file with these fields:
            'id': unique string to represent each habitat
            'path': absolute or relative path to a polygon vector or raster
            'rank': integer from 1 to 5 representing the relative protection
                offered by this habitat
            'protection distance (m)': integer used as a search radius around
                each shore point.
        model_resolution (float): to be the target pixel size in case habitat
            rasters are unprojected.
        working_dir (string): path to a directory for intermediate files
        file_suffix (string): to be appended to output filenames
        task_graph (Taskgraph): the graph that was initialized in ``execute``

    Returns:
        list of task objects
        list of pickle file path strings

    """
    habitat_dataframe = validation.get_validated_dataframe(
        habitat_table_path, **MODEL_SPEC['args']['habitat_table_path']
    ).rename(columns={'protection distance (m)': 'distance'})

    habitat_task_list = []
    habitat_pickles_list = []
    for _id, habitat_row in habitat_dataframe.iterrows():
        target_habitat_pickle_path = os.path.join(
            working_dir, f'{_id}{file_suffix}.pickle')
        habitat_pickles_list.append(target_habitat_pickle_path)
        gis_type = pygeoprocessing.get_gis_type(habitat_row.path)
        if gis_type == 2:
            habitat_task_list.append(task_graph.add_task(
                func=search_for_vector_habitat,
                args=(base_shore_point_vector_path,
                      habitat_row['distance'],
                      habitat_row['rank'],
                      _id,
                      habitat_row['path'],
                      target_habitat_pickle_path),
                target_path_list=[target_habitat_pickle_path],
                task_name=f'searching for {_id}'))
            continue
        if gis_type == 1:
            habitat_task_list.append(task_graph.add_task(
                func=search_for_raster_habitat,
                args=(base_shore_point_vector_path,
                      habitat_row['distance'],
                      habitat_row['rank'],
                      _id,
                      habitat_row['path'],
                      target_habitat_pickle_path,
                      model_resolution,
                      working_dir,
                      file_suffix),
                target_path_list=[target_habitat_pickle_path],
                task_name=f'searching for {_id}'))

    return habitat_task_list, habitat_pickles_list


def search_for_raster_habitat(
        base_shore_point_vector_path, search_radius, habitat_rank,
        habitat_id, habitat_raster_path, target_habitat_pickle_path,
        model_resolution, workspace_dir, file_suffix):
    """Search for habitat raster within a radius of each shore point.

    If habitat is present within the search radius, assign the habitat_rank
    to the shore point ID. If habitat is not present, assign rank of 5.

    Args:
        base_shore_point_vector_path (string): path to a shore point vector.
        search_radius (integer): distance around each point to search for
            habitat. units match units from base_shore_point_vector SRS.
        habitat_rank (integer): from 1 to 5 representing the relative
            protection offered by this habitat (5 = no protection).
        habitat_id (string): unique string to represent each habitat.
        habitat_raster_path (string): path to a raster where 0 and nodata
            represent absence of habitat, all other values represent presence.
        target_habitat_pickle_path (string): path to pickle file storing
            a dict keyed by shore point ID: { <id0>: 5, <id1>: 5, <id2>: 5}
        model_resolution (float): to be the target pixel size in case habitat
            rasters are unprojected.
        file_suffix (string): to be appended to output filenames

    Returns:
        None

    """
    LOGGER.info(
        f'Searching for {habitat_id} within {search_radius} meters '
        f'of shore points')
    # SRS here is inherited from the user's AOI
    base_shore_point_info = pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)
    target_srs_wkt = base_shore_point_info['projection_wkt']

    # extend the clipping box to accomodate the search radius,
    shore_point_bounding_box = base_shore_point_info['bounding_box']
    shore_point_bounding_box[0] -= search_radius
    shore_point_bounding_box[1] -= search_radius
    shore_point_bounding_box[2] += search_radius
    shore_point_bounding_box[3] += search_radius

    working_dir = os.path.dirname(target_habitat_pickle_path)
    clipped_projected_hab_path = os.path.join(
        working_dir, f'clipped_projected_{habitat_id}_{file_suffix}.tif')
    clip_and_project_raster(
        habitat_raster_path, shore_point_bounding_box, target_srs_wkt,
        model_resolution, working_dir, file_suffix,
        clipped_projected_hab_path)

    def habitat_op(array, mask):
        """Checking for non-zero or non-nodata values in array."""
        if numpy.any(array[mask]):
            return habitat_rank
        return 5  # the rank indicating no habitat protection

    _aggregate_raster_values_in_radius(
        base_shore_point_vector_path, clipped_projected_hab_path,
        search_radius, workspace_dir, target_habitat_pickle_path,
        habitat_op)
    LOGGER.info(f'Finished searching for {habitat_id}')


def search_for_vector_habitat(
        base_shore_point_vector_path, search_radius, habitat_rank,
        habitat_id, habitat_vector_path, target_habitat_pickle_path):
    """Search for habitat polygon within a radius of each shore point.

    If habitat is present within the search radius, assign the habitat_rank
    to the shore point ID. If habitat is not present, assign rank of 5.

    Args:
        base_shore_point_vector_path (string): path to a shore point vector.
        search_radius (integer): distance around each point to search for
            habitat. units match units from base_shore_point_vector SRS.
        habitat_rank (integer): from 1 to 5 representing the relative
            protection offered by this habitat (5 = no protection).
        habitat_id (string): unique string to represent each habitat.
        habitat_vector_path (string): path to a polygon vector.
        target_habitat_pickle_path (string): path to pickle file storing
            a dict keyed by shore point ID: { <id0>: 5, <id1>: 5, <id2>: 5 }

    Returns:
        None

    """
    LOGGER.info(
        f'Searching for {habitat_id} within {search_radius} meters '
        f'of shore points')
    base_shore_point_info = pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)

    shore_point_bounding_box = base_shore_point_info['bounding_box']
    shore_point_bounding_box[0] -= search_radius
    shore_point_bounding_box[1] -= search_radius
    shore_point_bounding_box[2] += search_radius
    shore_point_bounding_box[3] += search_radius

    base_srs_wkt = pygeoprocessing.get_vector_info(
        habitat_vector_path)['projection_wkt']
    base_spatial_reference = osr.SpatialReference()
    base_spatial_reference.ImportFromWkt(base_srs_wkt)
    target_srs_wkt = base_shore_point_info['projection_wkt']
    target_spatial_reference = osr.SpatialReference()
    target_spatial_reference.ImportFromWkt(target_srs_wkt)
    transform = utils.create_coordinate_transformer(
        base_spatial_reference, target_spatial_reference)

    base_srs_clipping_box = pygeoprocessing.transform_bounding_box(
        shore_point_bounding_box, target_srs_wkt, base_srs_wkt,
        edge_samples=11)
    base_srs_clipping_geom = ogr.CreateGeometryFromWkt(
        shapely.geometry.box(*base_srs_clipping_box).wkt)

    habitat_vector = gdal.OpenEx(
        habitat_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    habitat_layer = habitat_vector.GetLayer()

    transform_habitat_logger = _make_logger_callback(
        f"transforming {habitat_id} " + "%.2f%% complete.", LOGGER)

    shapely_geometry_list = []
    for feature in habitat_layer:
        transform_habitat_logger(
            float(feature.GetFID()) /
            habitat_layer.GetFeatureCount())

        geometry = feature.GetGeometryRef()
        if not geometry.IsValid():
            try:
                geometry = geometry.Buffer(0)  # sometimes this fixes geometry
            except RuntimeError:
                LOGGER.warning(
                    f"FID {feature.GetFID()} in {habitat_vector_path} has invalid "
                    "geometry and will be excluded")
                continue
        clipped_geometry = geometry.Intersection(base_srs_clipping_geom)
        if not clipped_geometry.IsEmpty():
            if target_spatial_reference != base_spatial_reference:
                err_code = clipped_geometry.Transform(transform)
                if err_code != 0:
                    LOGGER.warning(
                        f"Could not transform feature from "
                        f"{habitat_vector_path} to spatial reference "
                        "system of AOI")
                    continue
            shapely_geom = shapely.wkb.loads(
                bytes(clipped_geometry.ExportToWkb()))
            shapely_geometry_list.extend(_list_geometry(shapely_geom))

    if not shapely_geometry_list:
        LOGGER.warning(f'No valid features exist in {habitat_vector_path}')
    habitat_rtree = STRtree(shapely_geometry_list)
    habitat_layer = None
    habitat_vector = None

    base_shore_point_vector = gdal.OpenEx(
        base_shore_point_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    base_shore_point_layer = base_shore_point_vector.GetLayer()

    search_habitat_logger = _make_logger_callback(
        f"searching for {habitat_id} " + "%.2f%% complete.", LOGGER)
    result = {}
    for point_feature in base_shore_point_layer:
        point_habitat_rank = 5  # represents no habitat protection
        shore_id = point_feature.GetField(SHORE_ID_FIELD)

        search_habitat_logger(
            float(shore_id) /
            base_shore_point_layer.GetFeatureCount())

        if len(habitat_rtree.geometries) == 0:
            result[shore_id] = point_habitat_rank
            continue

        point_feature_geometry = point_feature.GetGeometryRef()
        point_shapely = shapely.wkb.loads(
            bytes(point_feature_geometry.ExportToWkb()))

        found_habitat_geoms = habitat_rtree.geometries.take(
            habitat_rtree.query(point_shapely.buffer(search_radius))
        ).tolist()
        # the rtree query returns geometries in the envelope of the buffer,
        # so follow-up with a check for distance from actual point.
        for geom in found_habitat_geoms:
            if point_shapely.distance(geom) <= search_radius:
                point_habitat_rank = habitat_rank
                continue

        result[shore_id] = point_habitat_rank

    with open(target_habitat_pickle_path, 'wb') as pickle_file:
        pickle.dump(result, pickle_file)
    LOGGER.info(f'Finished searching for {habitat_id}')


def _calc_Rhab(row):
    """Equation 4 from User's Guide.

    Args:
        row (sequence): a sequence of integers 1 to 5, or numpy.nan

    Returns:
        float

    """
    if numpy.isnan(row).any():
        return numpy.nan
    sum_sq_rank = 0
    min_rank = 5  # 5 is least protection
    for r in row:
        if r < min_rank:
            min_rank = r
        sum_sq_rank += (5 - r)**2

    if sum_sq_rank > 0:
        r_hab_val = max(
            1, 4.8 - 0.5 * (
                (1.5 * (5 - min_rank))**2 + sum_sq_rank -
                (5 - min_rank)**2)**0.5)
    else:
        r_hab_val = 5
    return r_hab_val


def calculate_habitat_rank(
        habitat_pickle_list, target_habitat_protection_path):
    """Combine dicts of habitat ranks into a dataframe and calcuate Rhab.

    Args:
        habitat_pickle_list (list): list of file paths to pickled dictionaries
            in the form of: { <id0>: 5, <id1>: 5, <id2>: 5 }
        target_habitat_protection_path (string): path to a csv file with a row
            for each shore point, and a header like:
            'shore_id','kelp','eelgrass','coral','R_hab'

    Returns:
        None

    """
    habitat_id_list = []
    dataframe = pandas.DataFrame()
    for hab_pickle in habitat_pickle_list:
        with open(hab_pickle, 'rb') as file:
            data = pickle.load(file)
        habitat_id = os.path.splitext(
            os.path.basename(hab_pickle))[0]  # get habitat id to use in header
        habitat_id_list.append(habitat_id)
        dataframe[habitat_id] = data.values()
        # Count of points protected by habitat. 5 was assigned for no
        # protection.
        n_pts_protected = numpy.count_nonzero(dataframe[habitat_id] != 5)
        LOGGER.info(
            f'{habitat_id}: found protecting {n_pts_protected} shore points')
    dataframe[SHORE_ID_FIELD] = data.keys()

    # Apply _calc_Rhab to each row, excluding the ID value.
    dataframe['R_hab'] = (
        dataframe.drop(columns=SHORE_ID_FIELD)
        .apply(axis=1, func=_calc_Rhab))

    # reorder the columns so 'shore_id' is always first and 'R_hab' last
    (dataframe[[SHORE_ID_FIELD] + habitat_id_list + ['R_hab']]
        .to_csv(target_habitat_protection_path, index=False))


def calculate_geomorphology_exposure(
        geomorphology_vector_path, geomorphology_fill_value,
        base_shore_point_vector_path, model_resolution, target_pickle_path,
        target_missing_data_path):
    """Join geomorphology ranks to shore points by proximity.

    Buffer each shore point by half the model_resolution and find all
    geomorphology types present around each point. Return the average
    of the geomorphology ranks, unless none are present, then return the
    geomorphology_fill_value.

    Args:
        geomorphology_vector_path (string): path to polyline vector
            with an integer attribute named ``RANK`` that contains values
            in (1, 2, 3, 4, 5).
        geomorphology_fill_value (int): integer in (1, 2, 3, 4, 5).
        base_shore_point_vector_path (string): path to point vector
        model_resolution (float): distance in meters of the shore_point
            spacing.
        target_pickle_path (string): path to pickle file storing dict
            keyed by point `shore_id`.
        target_missing_data_path (string): path to create point vector showing
            which points received the geomorphology fill value because no
            geomorphology segments were found within the search radius.

    Returns:
        None
    """
    LOGGER.info("Assigning geomorphology rank")
    geomorph_vector = gdal.OpenEx(
        geomorphology_vector_path, gdal.OF_VECTOR | gdal.GA_Update)
    geomorph_layer = geomorph_vector.GetLayer()
    LOGGER.info("Build spatial index of geomorphology segments")
    # build up a list of geometries to enter into the STRtree, and a
    # corresponding list of those geometries' ranks, because STRtree queries
    # return indices:
    # https://shapely.readthedocs.io/en/stable/strtree.html#shapely.STRtree
    geomorph_line_shapely_list = []
    geom_rank_list = []
    for feature in geomorph_layer:
        geometry = feature.GetGeometryRef()
        shapely_geometry = shapely.wkb.loads(
            bytes(geometry.ExportToWkb()))
        if shapely_geometry.is_valid:
            geomorph_line_shapely_list.append(shapely_geometry)
            geom_rank_list.append(feature.GetField('RANK'))
        else:
            LOGGER.warning(f'geomorphology FID:{feature.GetFID()} is excluded '
                           'due to invalid geometry')
        geometry = None
        feature = None
    geomorph_layer = None
    geomorph_vector = None

    tree = STRtree(geomorph_line_shapely_list)

    # Vector to track which points ended up with the geomorphology_fill_value:
    gpkg_driver = ogr.GetDriverByName("GPKG")
    missing_data_vector = gpkg_driver.CreateDataSource(
        target_missing_data_path)
    base_srs = osr.SpatialReference()
    base_srs.ImportFromWkt(pygeoprocessing.get_vector_info(
        base_shore_point_vector_path)['projection_wkt'])
    layer_name = os.path.splitext(
        os.path.basename(target_missing_data_path))[0]
    missing_data_vector.CreateLayer(
        layer_name, base_srs, ogr.wkbPoint)
    missing_data_layer = missing_data_vector.GetLayer()
    missing_data_layer.CreateField(
        ogr.FieldDefn(SHORE_ID_FIELD, ogr.OFTInteger64))
    missing_data_layer.StartTransaction()

    points_vector = gdal.OpenEx(
        base_shore_point_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    points_layer = points_vector.GetLayer()
    results = {}
    LOGGER.info("searching for geomorphology in point buffers")
    # 'search_radius' avoids too much overlap in point buffers
    search_radius = model_resolution / 2
    for point_feature in points_layer:
        shore_id = point_feature.GetField(SHORE_ID_FIELD)
        point_geom = point_feature.GetGeometryRef()
        poly_geom = point_geom.Buffer(search_radius)
        poly_shapely = shapely.wkb.loads(bytes(poly_geom.ExportToWkb()))

        # indices of geoms whose bbox intersects the buffered point's bbox
        found_geom_indices = tree.query(poly_shapely)
        # filter to only those geoms that actually intersect
        intersecting_geom_indices = filter(
            lambda idx: poly_shapely.intersects(tree.geometries[idx]),
            found_geom_indices)
        # the tree.query returns geometries found in envelope of poly_shapely,
        # so follow-up with a check for intersection.˝
        found_ranks = set(geom_rank_list[i] for i in intersecting_geom_indices)
        if found_ranks:
            mean_geomorph = sum(found_ranks) / float(len(found_ranks))
        else:
            mean_geomorph = geomorphology_fill_value
            # write point to seperate layer so users can explore later
            missing_data_layer.CreateFeature(point_feature)
        results[shore_id] = mean_geomorph

    missing_data_layer.CommitTransaction()
    n_missing_data = missing_data_layer.GetFeatureCount()

    missing_data_layer.SyncToDisk()
    missing_data_layer = None
    missing_data_vector = None

    if n_missing_data:
        LOGGER.warning(
            "\n**************************************************\n"
            f"{n_missing_data} points inherited the geomorphology_fill_value "
            f"({geomorphology_fill_value}) because no geomorphology segments "
            f"were found within the search distance ({search_radius:.2f} "
            "meters) around those points. You may wish to add geomorphology "
            "segments near these points, or you may wish to view the "
            "geomorphology and landmass polygon inputs in a GIS and edit one "
            "or both layers to make them align more closely.\n"
            f"This subset of points has been saved to {target_missing_data_path}\n"
            "**************************************************")

    with open(target_pickle_path, 'wb') as pickle_file:
        pickle.dump(results, pickle_file)
    LOGGER.info("Finished assigning geomorphology rank")


def assemble_results_and_calculate_exposure(
        risk_id_path_list, habitat_protection_path, base_point_vector_path,
        target_intermediate_vector_path, target_intermediate_csv_path,
        target_output_vector_path, target_output_csv_path):
    """Calculate the final exposure score, with and without habitat protection.

    Args:
        risk_id_path_list (list): list of 3-tuples like:
            ('relief.pickle', True, 'R_relief')

                1. string: path to pickle with intermediate
                    exposure values for a single variable
                2. bool: if True, variable contains values that need binning
                    by percentile to convert to 1-5 ranks. If False, variable
                    is already on the 1-5 rank scale.
                3. string: This variable is used as the fieldname in
                    target_output_vector_path. And if the string includes
                    the prefix ``R_``, the variable is included in the
                    final exposure equation.

        habitat_protection_path (string): path to csv file with the
           intermediate habitat ranks.
        base_point_vector_path (string): path to shore point vector.
        target_intermediate_vector_path (string): path to point vector
            populated with fields and raw values for all risk variables
            that require binning to ranks.
        target_output_vector_path (string): path to point vector
            populated with fields and rank values for all risk variables,
            final exposure, exposure without habitats, and population density.
        target_output_csv_path (string): path to a csv copy of
            target_output_vector_path.

    Returns:
        None.

    """
    LOGGER.info("Assembling exposure variables")

    R_hab_name = 'R_hab'

    # A GPKG to store final exposure score and variables it is composed of:
    _copy_point_vector_geom_to_gpkg(
        base_point_vector_path, target_output_vector_path,
        copy_field_list=[SHORE_ID_FIELD])
    target_output_vector = gdal.OpenEx(
        target_output_vector_path, gdal.OF_VECTOR | gdal.GA_Update)
    output_layer = target_output_vector.GetLayer()
    output_layer.CreateField(ogr.FieldDefn(R_hab_name, ogr.OFTReal))

    # A GPKG to store intermediate values of variables prior to binning into
    # ranks:
    _copy_point_vector_geom_to_gpkg(
        base_point_vector_path, target_intermediate_vector_path,
        copy_field_list=[SHORE_ID_FIELD])
    target_intermediate_vector = gdal.OpenEx(
        target_intermediate_vector_path, gdal.OF_VECTOR | gdal.GA_Update)
    intermediate_layer = target_intermediate_vector.GetLayer()

    # Intermediate variables besides habitat are in pickles.
    # Bin values to ranks if needed, and copy ranks to output vector.
    # and copy pre-binned values to intermediate vector.
    final_values_dict = {}
    intermediate_values_dict = {}
    for pickle_path, to_bins, var_name in risk_id_path_list:
        exposure_field_defn = ogr.FieldDefn(str(var_name), ogr.OFTReal)
        exposure_field_defn.SetWidth(24)
        exposure_field_defn.SetPrecision(11)
        output_layer.CreateField(exposure_field_defn)

        if to_bins:
            with open(pickle_path, 'rb') as file:
                raw_values_dict = pickle.load(file)

            # For the intermeidate outputs, trim 'R_' prefix because the values
            # are the before-'Ranking' values.
            intermediate_var_name = var_name
            if intermediate_var_name.startswith('R_'):
                intermediate_var_name = intermediate_var_name[2:]
            intermediate_field_defn = ogr.FieldDefn(
                str(intermediate_var_name), ogr.OFTReal)
            intermediate_field_defn.SetWidth(24)
            intermediate_field_defn.SetPrecision(11)
            intermediate_layer.CreateField(intermediate_field_defn)

            invert_values = False
            if var_name == 'R_relief':
                invert_values = True
            values_dict = _bin_values_to_percentiles(
                raw_values_dict, invert_values)
            final_values_dict[var_name] = values_dict
            intermediate_values_dict[intermediate_var_name] = raw_values_dict
        else:
            with open(pickle_path, 'rb') as file:
                final_values_dict[var_name] = pickle.load(file)

    habitat_df = validation.get_validated_dataframe(
        habitat_protection_path, **MODEL_SPEC['outputs']['intermediate'][
            'contents']['habitats']['contents']['habitat_protection.csv']
    ).rename(columns={'r_hab': 'R_hab'})
    output_layer.StartTransaction()
    for feature in output_layer:
        shore_id = feature.GetField(SHORE_ID_FIELD)
        # The R_hab ranks were stored in a CSV, now this dataframe:
        rank = habitat_df.loc[shore_id, R_hab_name]
        feature.SetField(str(R_hab_name), float(rank))
        # The other variables were stored in pickles, now this dict:
        for fieldname in final_values_dict:
            feature.SetField(
                str(fieldname), float(final_values_dict[fieldname][shore_id]))
        output_layer.SetFeature(feature)
    output_layer.CommitTransaction()
    output_layer = None
    target_output_vector.FlushCache()
    target_output_vector = None

    # Copy pre-binned intermediate values to intermediate output vector
    intermediate_layer.StartTransaction()
    for feature in intermediate_layer:
        shore_id = feature.GetField(SHORE_ID_FIELD)
        for fieldname in intermediate_values_dict:
            feature.SetField(
                str(fieldname),
                float(intermediate_values_dict[fieldname][shore_id]))
        intermediate_layer.SetFeature(feature)
    intermediate_layer.CommitTransaction()
    intermediate_layer = None
    target_intermediate_vector.FlushCache()
    target_intermediate_vector = None
    _copy_vector_to_csv(
        target_intermediate_vector_path, target_intermediate_csv_path)

    calculate_final_risk(target_output_vector_path, target_output_csv_path)


def calculate_final_risk(output_vector_path, output_csv_path):
    """Apply geometric mean calculation to variables at each shore point.

    This function modifies the 'output_vector_path' file by adding new fields.

    Args:
        output_vector_path (string): path to shore point vector populated
            with numeric fields named with the ``R_`` prefix. These fields
            are included in the geometric mean.
        output_csv_path (string): path to csv copy of the final
            'output_vector_path'.

    Returns:
        None

    """
    LOGGER.info("calculating final risk scores")
    # These fields are added to the output vector and calculated.
    final_risk = 'exposure'
    habitat_role = 'habitat_role'
    risk_no_habitat = 'exposure_no_habitats'

    point_vector = gdal.OpenEx(
        output_vector_path, gdal.OF_VECTOR | gdal.GA_Update)
    point_layer = point_vector.GetLayer()
    point_layer.CreateField(ogr.FieldDefn(final_risk, ogr.OFTReal))
    point_layer.CreateField(ogr.FieldDefn(habitat_role, ogr.OFTReal))
    point_layer.CreateField(ogr.FieldDefn(risk_no_habitat, ogr.OFTReal))
    risk_id_list = [
        field.GetName() for field in point_layer.schema
        if field.GetName().startswith('R_')]

    point_layer.StartTransaction()
    for point_feature in point_layer:
        r_array = numpy.array([
            point_feature.GetField(risk_id)
            for risk_id in risk_id_list], dtype=float)
        r_tot = _geometric_mean(r_array)
        point_feature.SetField(final_risk, float(r_tot))
        risk_id_list_no_hab = [
            risk_id for risk_id in risk_id_list if risk_id != 'R_hab']
        # User's Guide: calculate "coastal_exposure_no_habitats"
        # by replacing R_hab with 5:
        r_array_nohab = numpy.array([
            point_feature.GetField(risk_id)
            for risk_id in risk_id_list_no_hab] + [5], dtype=float)
        r_tot_no_hab = _geometric_mean(r_array_nohab)
        point_feature.SetField(
            risk_no_habitat, float(r_tot_no_hab))
        point_feature.SetField(
            habitat_role, abs(float(r_tot) - float(r_tot_no_hab)))
        point_layer.SetFeature(point_feature)
    point_layer.CommitTransaction()
    point_vector.FlushCache()
    point_layer = None
    point_vector = None

    # Export results in CSV format for convenience
    _copy_vector_to_csv(output_vector_path, output_csv_path)


def _geometric_mean(array):
    """Calculate a geometric mean of numpy array of floats.

    Returns:
        float, unless array contains a ``nan``, then returns ``nan``.

    """
    return numpy.prod(array)**(1/len(array))


def _bin_values_to_percentiles(base_values_dict, invert_values=False):
    """Bin continuous values into percentile categories.

    ``nan`` should be ignored when calculating percentiles, and features
    with a ``nan`` value should get a ``nan`` rank as well.

    Args:
        base_values_dict (dictionary): values are numeric
        invert_values (bool): if True, flip sign of values before
            binning.

    Returns:
        Dictionary with same keys as base_values_dict and values
        of integers from 1:5.

    """
    base_values = list(base_values_dict.values())
    if invert_values:
        base_values = numpy.multiply(base_values, -1)

    percentiles = [20, 40, 60, 80, 100]
    rank_array = numpy.searchsorted(
        numpy.nanpercentile(base_values, percentiles),
        base_values).astype(float) + 1  # floats so we can hold nan
    # nan in base_values need to be re-assigned to nan:
    rank_array[rank_array > len(percentiles)] = numpy.nan

    keys = base_values_dict.keys()
    target_values_dict = {}
    for key, rank in zip(keys, rank_array):
        target_values_dict[key] = rank
    return target_values_dict


def clip_and_project_raster(
        base_raster_path, clipping_box, target_srs_wkt, model_resolution,
        working_dir, file_suffix, target_raster_path):
    """Clip a raster to a box in the raster's native SRS, then reproject.

    Args:
        base_raster_path (string): path to a gdal raster
        clipping_box (list): sequence of floats that are coordinates in the
            target_srs [minx, miny, maxx, maxy]
        target_srs_wkt (string): well-known-text spatial reference system
        model_resolution (float): value for target pixel size
        working_dir (string): path to directory for intermediate files
        file_suffix (string): appended to any output filename.
        target_raster_path (string): path to clipped and warped raster.

    Returns:
        None

    """
    base_srs_wkt = pygeoprocessing.get_raster_info(
        base_raster_path)['projection_wkt']

    # 'base' and 'target' srs are with respect to the base and target raster,
    # so first the clipping box needs to go from 'target' to 'base' srs
    base_srs_clipping_box = pygeoprocessing.transform_bounding_box(
        clipping_box, target_srs_wkt, base_srs_wkt, edge_samples=11)

    clipped_raster_path = os.path.join(
        working_dir,
        os.path.basename(
            os.path.splitext(
                base_raster_path)[0]) + f'_clipped{file_suffix}.tif')

    base_pixel_size = pygeoprocessing.get_raster_info(
        base_raster_path)['pixel_size']

    # Clip in the raster's native srs
    pygeoprocessing.warp_raster(
        base_raster_path, base_pixel_size, clipped_raster_path,
        'bilinear', target_bb=base_srs_clipping_box)

    # If base raster is projected, convert its pixel size to meters.
    # Otherwise use the model resolution as target pixel size in Warp.
    base_srs = osr.SpatialReference()
    base_srs.ImportFromWkt(base_srs_wkt)
    if bool(base_srs.IsProjected()):
        scalar_to_meters = base_srs.GetLinearUnits()
        target_pixel_size = tuple(
            numpy.multiply(base_pixel_size, scalar_to_meters))
    else:
        LOGGER.warning(
            f'{base_raster_path} is unprojected and will be warped to match '
            f'the AOI and resampled to a pixel size of {model_resolution} '
            'meters')
        target_pixel_size = (model_resolution, model_resolution * -1)

    # Warp to the target SRS
    pygeoprocessing.warp_raster(
        clipped_raster_path, target_pixel_size, target_raster_path,
        'bilinear', target_projection_wkt=target_srs_wkt)


def clip_and_project_vector(
        base_vector_path, clipping_box, target_srs_wkt,
        tmp_vector_path, target_vector_path):
    """Clip a vector to a box in the vector's native SRS, then reproject.

    Args:
        base_vector_path (string): path to polygon or multipolygon type vector
        clipping_box (list): sequence of floats that are coordinates in the
            target_srs [minx, miny, maxx, maxy]
        target_srs_wkt (string): well-known-text spatial reference system
        tmp_vector_path (string): path to clipped but unprojected .gpkg vector
        target_vector_path (string): path to clipped and projected .gpkg vector

    Returns:
        None

    """
    for path in [tmp_vector_path, target_vector_path]:
        if os.path.exists(path):
            os.remove(path)

    base_srs_wkt = pygeoprocessing.get_vector_info(
        base_vector_path)['projection_wkt']
    base_spatial_reference = osr.SpatialReference()
    base_spatial_reference.ImportFromWkt(base_srs_wkt)

    # 'base' and 'target' srs are with respect to the base and target vector,
    # so first the clipping box needs to go from 'target' to 'base'
    base_srs_clipping_box = pygeoprocessing.transform_bounding_box(
        clipping_box, target_srs_wkt, base_srs_wkt, edge_samples=11)
    base_srs_clipping_box_shapely = shapely.geometry.box(
        *base_srs_clipping_box)

    gpkg_driver = ogr.GetDriverByName("GPKG")
    clipped_vector = gpkg_driver.CreateDataSource(
        tmp_vector_path)
    layer_name = os.path.splitext(
        os.path.basename(tmp_vector_path))[0]
    clipped_layer = (
        clipped_vector.CreateLayer(  # intersection could yield MultiPolygon
            layer_name, base_spatial_reference, ogr.wkbMultiPolygon))
    clipped_defn = clipped_layer.GetLayerDefn()

    for shapely_geometry in _ogr_to_geometry_list(base_vector_path):
        if base_srs_clipping_box_shapely.intersects(shapely_geometry):
            intersection_shapely = base_srs_clipping_box_shapely.intersection(
                shapely_geometry)
            clipped_geometry = ogr.CreateGeometryFromWkt(
                intersection_shapely.wkt)
            if clipped_geometry.GetGeometryType() == ogr.wkbPolygon:
                clipped_geometry = ogr.ForceToMultiPolygon(clipped_geometry)
            clipped_feature = ogr.Feature(clipped_defn)
            clipped_feature.SetGeometry(clipped_geometry)
            clipped_layer.CreateFeature(clipped_feature)
            clipped_feature = None

    clipped_layer.SyncToDisk()
    clipped_layer = None
    clipped_vector = None

    pygeoprocessing.reproject_vector(
        tmp_vector_path, target_srs_wkt, target_vector_path,
        driver_name='GPKG')


def _aggregate_raster_values_in_radius(
        base_point_vector_path, base_raster_path, sample_distance,
        work_dir, target_pickle_path, aggregation_op):
    """Aggregate raster values in radius around a point.

    Do the radial search by constructing a rectangular kernel mask
    that approximates a circle.

    E.g. kernel_mask for a raster with pixel_size = (1,1)
    and sample_distance = 3:

    array([[False, False, False,  True, False, False, False],
           [False,  True,  True,  True,  True,  True, False],
           [False,  True,  True,  True,  True,  True, False],
           [ True,  True,  True,  True,  True,  True,  True],
           [False,  True,  True,  True,  True,  True, False],
           [False,  True,  True,  True,  True,  True, False],
           [False, False, False,  True, False, False, False]])

    Do the aggregation with an arbitrary function ``aggregation_op``.

    Args:
        base_point_vector_path (string): point vector with projected
            coordinates in units matching sample_disatnce.
        base_raster_path (string): raster file with square pixels and
            projection matching base_point_vector_path.
        sample_distance (float): radius around each point to search
            for valid pixels.
        work_dir (string): path to directory for intermediate files.
        target_pickle_path (string): path to pickle file storing dict
            keyed by point id.
        aggregation_op (function): takes a signal array and a mask array
            of same shape. Returns scalar or numpy.nan.

    Returns:
        None

    """
    raster = gdal.OpenEx(base_raster_path, gdal.OF_RASTER | gdal.GA_Update)
    band = raster.GetRasterBand(1)
    n_rows = band.YSize
    n_cols = band.XSize
    geotransform = raster.GetGeoTransform()
    nodata = band.GetNoDataValue()

    # we can assume square pixels at this point because
    # we already warped input raster and defined square pixels
    pixel_dist = int(abs(sample_distance / (geotransform[1])))

    temp_dir = tempfile.mkdtemp(dir=work_dir)
    kernel_path = os.path.join(temp_dir, 'kernel.tif')
    pygeoprocessing.kernels.dichotomous_kernel(
        target_kernel_path=kernel_path,
        max_distance=pixel_dist,
        normalize=False)
    radial_kernel_mask = pygeoprocessing.raster_to_numpy_array(
        kernel_path).astype(bool)
    shutil.rmtree(temp_dir, ignore_errors=True)

    result = {}
    vector = gdal.OpenEx(
        base_point_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    layer = vector.GetLayer()
    for point_feature in layer:
        warn_msg = 0
        win_xsize = radial_kernel_mask.shape[0]
        win_ysize = radial_kernel_mask.shape[1]

        shore_id = point_feature.GetField(SHORE_ID_FIELD)
        point_geometry = point_feature.GetGeometryRef()
        point_x = point_geometry.GetX()
        point_y = point_geometry.GetY()
        point_geometry = None

        # kernel origin (upper-left) in pixel-space
        pixel_x = int(
            (point_x - geotransform[0]) /
            geotransform[1]) - pixel_dist
        pixel_y = int(
            (point_y - geotransform[3]) /
            geotransform[5]) - pixel_dist

        kernel_mask = numpy.copy(radial_kernel_mask)
        # if kernel origin is outside bounds of the raster
        if pixel_x < 0:
            warn_msg = 1
            win_xsize += pixel_x
            pixel_x = 0
            # trim mask columns by indexing backward from the right
            kernel_mask = kernel_mask[:, (-1 * win_xsize):]
        if pixel_y < 0:
            warn_msg = 1
            win_ysize += pixel_y
            pixel_y = 0
            # trim mask rows by indexing backward from the bottom
            kernel_mask = kernel_mask[(-1 * win_ysize):, :]

        # if kernel extent is outside bounds of the raster
        if pixel_x + win_xsize > n_cols:
            warn_msg = 1
            win_xsize -= pixel_x + win_xsize - n_cols
            # trim mask columns by indexing forward from left
            kernel_mask = kernel_mask[:, :win_xsize]
        if pixel_y + win_ysize > n_rows:
            warn_msg = 1
            win_ysize -= pixel_y + win_ysize - n_rows
            # trim mask rows by indexing forward from top
            kernel_mask = kernel_mask[:win_ysize, :]
        if warn_msg:
            LOGGER.warning(
                f'search radius around point ({point_x}, {point_y}) '
                f'extends beyond bounds of {base_raster_path}')
        if win_xsize <= 0 or win_ysize <= 0:
            # We get here if the point is so far off the raster
            # that the kernel extent does not overlap raster extent.
            LOGGER.warning(
                f'search radius around point ({point_x}, {point_y}) '
                f'is completely outside the exent of {base_raster_path}.'
                f'nan value assigned to point.')
            result[shore_id] = numpy.nan
            continue

        array = band.ReadAsArray(
            xoff=pixel_x, yoff=pixel_y, win_xsize=win_xsize,
            win_ysize=win_ysize)
        if nodata is not None:
            kernel_mask &= ~pygeoprocessing.array_equals_nodata(array, nodata)

        result[shore_id] = aggregation_op(array, kernel_mask)
    with open(target_pickle_path, 'wb') as pickle_file:
        pickle.dump(result, pickle_file)


def geometry_to_lines(geometry, include_interiors=True):
    """Convert a geometry object to a list of lines."""
    if geometry.geom_type == 'Polygon':
        return polygon_to_lines(geometry, include_interiors=include_interiors)
    elif geometry.geom_type == 'MultiPolygon':
        line_list = []
        for geom in geometry.geoms:
            line_list.extend(geometry_to_lines(geom))
        return line_list
    else:
        return []


def polygon_to_lines(geometry, include_interiors=True):
    """Return a list of shapely lines given higher order shapely geometry."""
    line_list = []
    starting_point = geometry.exterior.coords[0]
    previous_point = geometry.exterior.coords[0]
    for point in geometry.exterior.coords[1::]:
        if point == starting_point:
            continue
        line_list.append(shapely.geometry.LineString([previous_point, point]))
        previous_point = point
    line_list.append(shapely.geometry.LineString([
        previous_point, starting_point]))
    if include_interiors:
        for interior in geometry.interiors:
            starting_point = interior.coords[0]
            previous_point = interior.coords[0]
            for point in interior.coords[1::]:
                if point == starting_point:
                    continue
                line_list.append(
                    shapely.geometry.LineString([previous_point, point]))
                previous_point = point
            line_list.append(shapely.geometry.LineString([
                previous_point, starting_point]))
    return line_list


def _ogr_to_geometry_list(vector_path):
    """Convert an OGR type with one layer to a list of shapely geometry.

    Iterates through the features in the ``vector_path``'s first layer and
    converts them to ``shapely`` geometry objects.  If the objects are not
    valid geometry, an attempt is made to buffer the object by 0 units
    before adding to the list. If the object cannot be loaded by shapely
    at all, it is left out of the list.

    Args:
        vector_path (string): path to an OGR vector

    Returns:
        list of shapely geometry objects representing the features in the
        ``vector_path`` layer.

    """
    vector = gdal.OpenEx(vector_path, gdal.OF_VECTOR)
    layer = vector.GetLayer()
    geometry_list = []
    for feature in layer:
        feature_geometry = feature.GetGeometryRef()
        try:
            shapely_geometry = shapely.wkb.loads(
                bytes(feature_geometry.ExportToWkb()))
        except shapely.errors.ShapelyError:
            # In my experience a geometry that can't be loaded by shapely
            # is also a geometry that can't be fixed with the buffer(0) trick,
            # so just skip it.
            LOGGER.warning(
                f'Could not load feature {feature.GetFID()} of {vector_path}')
            continue
        if not shapely_geometry.is_valid:
            shapely_geometry = shapely_geometry.buffer(0)
        geometry_list.append(shapely_geometry)
        feature_geometry = None
    layer = None
    vector = None
    return geometry_list


def _copy_vector_to_csv(base_vector_path, target_csv_path):
    """Copy all FIDs and fields of GDAL vector to a CSV.

    Args:
        base_vector_path (string): path to a GDAL-supported vector.
        target_csv_path (string): file path with a '.csv' extension.

    Returns:
        None

    """
    base_vector = gdal.OpenEx(
        base_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    base_layer = base_vector.GetLayer()
    fields = [field.GetName() for field in base_layer.schema]
    header = ['fid'] + fields
    with open(target_csv_path, 'w') as csvfile:
        csvfile.write(str(','.join(header) + '\n'))
        for feature in base_layer:
            values_list = [
                str(feature.GetField(f)) if feature.GetField(f) is not None
                else '' for f in fields]
            fid_list = [str(feature.GetFID())]
            row = fid_list + values_list
            csvfile.write(str(','.join(row) + '\n'))

    feature = None
    base_layer = None
    base_vector = None


def construct_field_list(basename):
    """Return list of strings with compass degree suffix on a basename.

    This is useful for constructing fieldnames that appear in the WWIII table
    and passing them to _copy_point_vector_geom_to_gpkg.
    """
    names = []
    for ray_index in range(_N_FETCH_RAYS):
        compass_degree = int(ray_index * 360 / 16)
        names.append(basename + str(int(compass_degree)))
    return names


def _copy_point_vector_geom_to_gpkg(
        base_vector_path, target_gpkg_path, copy_field_list=[]):
    """Copy only the geometry of one GDAL point vector to a geopackage.

    Args:
        base_vector_path (string): path to a GDAL-supported vector of
            type wkbPoint.
        target_gpkg_path (string): file path with a '.gpkg' extension.
        copy_field_list (list): list of fieldnames that should be copied.

    Returns:
        None

    """
    gpkg_driver = ogr.GetDriverByName("GPKG")
    aoi_srs = osr.SpatialReference()
    aoi_srs.ImportFromWkt(
        pygeoprocessing.get_vector_info(base_vector_path)['projection_wkt'])

    base_vector = gdal.OpenEx(
        base_vector_path, gdal.OF_VECTOR | gdal.GA_ReadOnly)
    base_layer = base_vector.GetLayer()

    target_vector = gpkg_driver.CreateDataSource(target_gpkg_path)
    layer_name = os.path.splitext(os.path.basename(target_gpkg_path))[0]
    target_layer = target_vector.CreateLayer(layer_name, aoi_srs, ogr.wkbPoint)
    if copy_field_list:
        # Find the field definition in the base layer, then create in the
        # target
        base_layer_defn = base_layer.GetLayerDefn()
        for i in range(0, base_layer_defn.GetFieldCount()):
            field_defn = base_layer_defn.GetFieldDefn(i)
            field_name = field_defn.GetName()
            if field_name in copy_field_list:
                target_layer.CreateField(field_defn)
    target_layer_defn = target_layer.GetLayerDefn()

    target_layer.StartTransaction()
    for feature in base_layer:
        target_geometry = ogr.Geometry(ogr.wkbPoint)
        base_geometry = feature.GetGeometryRef()
        target_geometry.AddPoint_2D(base_geometry.GetX(), base_geometry.GetY())
        target_feature = ogr.Feature(target_layer_defn)
        target_feature.SetGeometry(target_geometry)
        for fieldname in copy_field_list:
            target_feature.SetField(fieldname, feature.GetField(fieldname))
        target_layer.CreateFeature(target_feature)
        feature = None
        target_feature = None
    target_layer.CommitTransaction()

    target_layer_defn = None
    target_layer = None
    target_vector = None
    base_layer = None
    base_vector = None


def _make_logger_callback(message, logger):
    """Build a timed logger callback that prints ``message`` replaced.

    Args:
        message (string): a string that expects a %f replacement variable for
            ``proportion_complete``.

    Returns:
        Function with signature:
            logger_callback(proportion_complete, psz_message, p_progress_arg)

    """
    def logger_callback(proportion_complete):
        """Argument names come from the GDAL API for callbacks."""
        try:
            current_time = time.time()
            if ((current_time - logger_callback.last_time) > 5 or
                    (proportion_complete == 1 and
                     logger_callback.total_time >= 5)):
                logger.info(message, proportion_complete * 100)
                logger_callback.last_time = current_time
                logger_callback.total_time += current_time
        except AttributeError:
            logger_callback.last_time = time.time()
            logger_callback.total_time = 0

    return logger_callback


@validation.invest_validator
def validate(args, limit_to=None):
    """Validate args to ensure they conform to ``execute``'s contract.

    Args:
        args (dict): dictionary of key(str)/value pairs where keys and
            values are specified in ``execute`` docstring.
        limit_to (str): (optional) if not None indicates that validation
            should only occur on the ``args[limit_to]`` value. The intent that
            individual key validation could be significantly less expensive
            than validating the entire ``args`` dictionary.

    Returns:
        list of ([invalid key_a, invalid_key_b, ...], 'warning/error message')
            tuples. Where an entry indicates that the invalid keys caused
            the error message in the second part of the tuple. This should
            be an empty list if validation succeeds.

    """
    validation_warnings = validation.validate(
        args, MODEL_SPEC['args'], MODEL_SPEC['args_with_spatial_overlap'])

    invalid_keys = validation.get_invalid_keys(validation_warnings)
    sufficient_keys = validation.get_sufficient_keys(args)

    if ('slr_vector_path' not in invalid_keys and
            'slr_field' not in invalid_keys and
            'slr_vector_path' in sufficient_keys and
            'slr_field' in sufficient_keys):
        fieldnames = validation.load_fields_from_vector(
            args['slr_vector_path'])
        error_msg = validation.check_option_string(args['slr_field'],
                                                   fieldnames)
        if error_msg:
            validation_warnings.append((['slr_field'], error_msg))

    return validation_warnings
