From 873af122910a50590995a11fece7ea021731a8b0 Mon Sep 17 00:00:00 2001 From: ppinchuk Date: Mon, 22 Apr 2024 16:31:31 -0600 Subject: [PATCH] Add `extent` option --- reV/supply_curve/exclusions.py | 93 ++++++++++++--- tests/test_supply_curve_exclusions.py | 159 +++++++++++++++++++++++++- 2 files changed, 232 insertions(+), 20 deletions(-) diff --git a/reV/supply_curve/exclusions.py b/reV/supply_curve/exclusions.py index e71a21b79..9fcb70238 100644 --- a/reV/supply_curve/exclusions.py +++ b/reV/supply_curve/exclusions.py @@ -32,6 +32,7 @@ def __init__(self, layer, weight=1.0, exclude_nodata=False, nodata_value=None, + extent=None, **kwargs): """ Parameters @@ -49,36 +50,37 @@ def __init__(self, layer, By default, ``None``. exclude_range : list | tuple, optional - Two-item list of (min threshold, max threshold) for values - to exclude. Mutually exclusive with other inputs - see info - in the description of `exclude_values`. - By default, ``None``. + Two-item list of [min threshold, max threshold] (ends are + inclusive) for values to exclude. Mutually exclusive + with other inputs (see info in the description of + `exclude_values`). By default, ``None``. include_values : int | float | list, optional Single value or list of values to include. Mutually - exclusive with other inputs - see info in the description of - `exclude_values`. By default, ``None``. + exclusive with other inputs (see info in the description of + `exclude_values`). By default, ``None``. include_range : list | tuple, optional - Two-item list of (min threshold, max threshold) for values - to include. Mutually exclusive with other inputs - see info - in the description of `exclude_values`. - By default, ``None``. + Two-item list of [min threshold, max threshold] (ends are + inclusive) for values to include. Mutually exclusive with + other inputs (see info in the description of + `exclude_values`). By default, ``None``. include_weights : dict, optional A dictionary of ``{value: weight}`` pairs, where the ``value`` in the layer that should be included with the - given ``weight``. Mutually exclusive with other inputs - see - info in the description of `exclude_values`. + given ``weight``. Mutually exclusive with other inputs (see + info in the description of `exclude_values`). By default, ``None``. force_include_values : int | float | list, optional Force the inclusion of the given value(s). This input completely replaces anything provided as `include_values` - and is mutually exclusive with other inputs - see info in - the description of `exclude_values`. By default, ``None``. + and is mutually exclusive with other inputs (eee info in + the description of `exclude_values`). By default, ``None``. force_include_range : list | tuple, optional Force the inclusion of given values in the range - (min threshold, max threshold). This input completely - replaces anything provided as `include_range` - and is mutually exclusive with other inputs - see info in - the description of `exclude_values`. By default, ``None``. + [min threshold, max threshold] (ends are inclusive). This + input completely replaces anything provided as + `include_range` and is mutually exclusive with other inputs + (see info in the description of `exclude_values`). + By default, ``None``. use_as_weights : bool, optional Option to use layer as final inclusion weights (i.e. 1 = fully included, 0.75 = 75% included, 0.5 = 50% included, @@ -102,6 +104,37 @@ def __init__(self, layer, inferred when LayerMask is added to :class:`reV.supply_curve.exclusions.ExclusionMask`. By default, ``None``. + extent : dict, optional + Optional dictionary with values that can be used to + initialize this class (i.e. `layer`, `exclude_values`, + `include_range`, etc.). This dictionary should contain the + specifications to create a boolean mask that defines the + extent to which the original mask should be applied. + For example, suppose you specify the input the following + way: + + input_dict = { + "viewsheds": { + "exclude_values": 1, + "extent": { + "layer": "federal_parks", + "include_range": [1, 5] + } + } + } + + for layer_name, kwargs in input_dict.items(): + layer = LayerMask(layer_name, **kwargs) + ... + + This would mean that you are masking out all viewshed layer + values equal to 1, **but only where the "federal_parks" + layer is equal to 1, 2, 3, 4, or 5**. Outside of this + region (i.e. outside of federal park regions), the viewshed + exclusion is **NOT** applied. If the mask created by these + options is not boolean, an error is thrown (i.e. do not + specify `weight` or `use_as_weights`). By default ``None``, + which applies the original layer mask to the full extent. **kwargs Optional inputs to maintain legacy kwargs of ``inclusion_*`` instead of ``include_*``. @@ -129,13 +162,14 @@ def __init__(self, layer, self.nodata_value = nodata_value if weight > 1 or weight < 0: - msg = ('Invalide weight ({}) provided for layer {}:' + msg = ('Invalid weight ({}) provided for layer {}:' '\nWeight must fall between 0 and 1!'.format(weight, layer)) logger.error(msg) raise ValueError(msg) self._weight = weight self._mask_type = self._check_mask_type() + self.extent = LayerMask(**extent) if extent is not None else None def __repr__(self): msg = ('{} for "{}" exclusion, of type "{}"' @@ -914,6 +948,7 @@ def _add_layer_to_mask(self, mask, layer, ds_slice, check_layers, def _compute_layer_mask(self, layer, ds_slice, check_layers=False): """Compute mask for single layer, including extent. """ layer_mask = self._masked_layer_data(layer, ds_slice) + layer_mask = self._apply_layer_mask_extent(layer, layer_mask, ds_slice) logger.debug('Computed exclusions {} for {}. Layer has average value ' 'of {:.2f}.' @@ -927,6 +962,25 @@ def _compute_layer_mask(self, layer, ds_slice, check_layers=False): return layer_mask + def _apply_layer_mask_extent(self, layer, layer_mask, ds_slice): + """Apply extent to layer mask, if any. """ + if layer.extent is None: + return layer_mask + + layer_extent = self._masked_layer_data(layer.extent, ds_slice) + if not np.array_equal(layer_extent, layer_extent.astype(bool)): + msg = ("Extent layer must be boolean (i.e. 0 and 1 values " + "only)! Please check your extent definition for layer " + "{} to ensure you are producing a boolean layer!" + .format(layer.name)) + logger.error(msg) + raise ExclusionLayerError(msg) + + logger.debug("Filtering mask for layer %s down to specified extent", + layer.name) + layer_mask = np.where(layer_extent, layer_mask, 1) + return layer_mask + def _masked_layer_data(self, layer, ds_slice): """Extract masked data for layer. """ return layer[self.excl_h5[(layer.name, ) + ds_slice]] @@ -959,6 +1013,7 @@ def _generate_mask(self, *ds_slice, check_layers=False): ds_slice, sub_slice = self._parse_ds_slice(ds_slice) if self.layers: + print("Generating mask from layers") force_include = [] for layer in self.layers: if layer.force_include: diff --git a/tests/test_supply_curve_exclusions.py b/tests/test_supply_curve_exclusions.py index c27cd1693..b1ea568db 100644 --- a/tests/test_supply_curve_exclusions.py +++ b/tests/test_supply_curve_exclusions.py @@ -2,10 +2,14 @@ """ Exclusions unit test module """ -import numpy as np import os +import h5py +import shutil import pytest import warnings +import tempfile + +import numpy as np from reV import TESTDATADIR from reV.handlers.exclusions import ExclusionLayers @@ -167,6 +171,159 @@ def test_layer_mask(layer_name, inclusion_range, exclude_values, assert np.allclose(truth, dict_test) + +@pytest.mark.parametrize(('layer_name', 'inclusion_range', 'exclude_values', + 'include_values', 'weight', 'exclude_nodata'), [ + ('ri_padus', (None, None), [1, ], None, 1, False), + ('ri_padus', (None, None), [1, ], None, 1, True), + ('ri_padus', (None, None), [1, ], None, 0.5, False), + ('ri_padus', (None, None), [1, ], None, 0.5, True), + ('ri_smod', (None, None), None, [1, ], 1, False), + ('ri_smod', (None, None), None, [1, ], 1, True), + ('ri_smod', (None, None), None, [1, ], 0.5, False), + ('ri_srtm_slope', (None, 5), None, None, 1, False), + ('ri_srtm_slope', (0, 5), None, None, 1, False), + ('ri_srtm_slope', (0, 5), None, None, 1, True), + ('ri_srtm_slope', (None, 5), None, None, 0.5, False), + ('ri_srtm_slope', (None, 5), None, None, 0.5, True)]) +@pytest.mark.parametrize("extent_type", ['iv', 'ev', 'ir', 'er']) +def test_layer_mask_with_extent(layer_name, inclusion_range, exclude_values, + include_values, weight, exclude_nodata, + extent_type): + """ + Test creation of layer masks with extent + + Parameters + ---------- + layer_name : str + Layer name + inclusion_range : tuple + (min threshold, max threshold) for values to include + exclude_values : list + list of values to exclude + Note: Only supply exclusions OR inclusions + include_values : list + List of values to include + Note: Only supply inclusions OR exclusions + """ + extent = {"layer": "testing_regions"} + if extent_type == "iv": + extent["include_values"] = 0 + elif extent_type == "ev": + extent["exclude_values"] = 1 + elif extent_type == "ir": + extent["include_range"] = [0, 0] + elif extent_type == "er": + extent["exclude_range"] = [1, None] + + excl_h5_og = os.path.join(TESTDATADIR, 'ri_exclusions', 'ri_exclusions.h5') + with tempfile.TemporaryDirectory() as td: + excl_fp = os.path.join(td, 'ri_exclusions.h5') + shutil.copy(excl_h5_og, excl_fp) + + with ExclusionLayers(excl_fp) as f: + data = f[layer_name] + nodata_value = f.get_nodata_value(layer_name) + + with h5py.File(excl_fp, mode="a") as fh: + halfway_x = data.shape[1] // 2 + regions_bool = np.zeros(data.shape, dtype=np.float32) + regions_bool[:, halfway_x:] = 1 + fh.create_dataset('testing_regions', data=regions_bool) + + truth = mask_data(data, inclusion_range, exclude_values, + include_values, weight, exclude_nodata, nodata_value) + truth[:, halfway_x:] = 1 + + layer = LayerMask(layer_name, include_range=inclusion_range, + exclude_values=exclude_values, + include_values=include_values, weight=weight, + exclude_nodata=exclude_nodata, + nodata_value=nodata_value, + extent=extent) + + mask_test = ExclusionMask.run(excl_fp, layers=layer) + assert np.allclose(truth, mask_test) + + layer_dict = {layer_name: {"include_range": inclusion_range, + "exclude_values": exclude_values, + "include_values": include_values, + "weight": weight, + "exclude_nodata": exclude_nodata, + "extent": extent}} + dict_test = ExclusionMaskFromDict.run(excl_fp, layers_dict=layer_dict) + assert np.allclose(truth, dict_test) + + +def test_layer_mask_with_bad_extent(): + """ + Test creation of layer masks with bad extent + + Parameters + ---------- + layer_name : str + Layer name + inclusion_range : tuple + (min threshold, max threshold) for values to include + exclude_values : list + list of values to exclude + Note: Only supply exclusions OR inclusions + include_values : list + List of values to include + Note: Only supply inclusions OR exclusions + """ + layer_name = 'ri_padus' + inclusion_range = (None, None) + exclude_values = [1, ] + include_values = None + weight = 1 + exclude_nodata = False + + excl_h5_og = os.path.join(TESTDATADIR, 'ri_exclusions', 'ri_exclusions.h5') + with tempfile.TemporaryDirectory() as td: + excl_fp = os.path.join(td, 'ri_exclusions.h5') + shutil.copy(excl_h5_og, excl_fp) + + with ExclusionLayers(excl_fp) as f: + data = f[layer_name] + nodata_value = f.get_nodata_value(layer_name) + + with h5py.File(excl_fp, mode="a") as fh: + halfway_x = data.shape[1] // 2 + regions_bool = np.zeros(data.shape, dtype=np.float32) + regions_bool[:, halfway_x:] = 1 + fh.create_dataset('testing_regions', data=regions_bool) + + regions_bool = np.zeros(data.shape, dtype=np.float32) + regions_bool[:, halfway_x:] = 2 + fh.create_dataset('testing_regions_1', data=regions_bool) + + layer = LayerMask(layer_name, include_range=inclusion_range, + exclude_values=exclude_values, + include_values=include_values, weight=weight, + exclude_nodata=exclude_nodata, + nodata_value=nodata_value, + extent={"layer": "testing_regions", + "include_values": 0, + "weight": 0.5}) + + with pytest.raises(ExclusionLayerError) as error: + ExclusionMask.run(excl_fp, layers=layer) + + assert "Extent layer must be boolean" in str(error) + + layer_dict = {layer_name: {"include_range": inclusion_range, + "exclude_values": exclude_values, + "include_values": include_values, + "weight": weight, + "exclude_nodata": exclude_nodata, + "extent": {"layer": "testing_regions_1", + "use_as_weights": True}}} + with pytest.raises(ExclusionLayerError) as error: + ExclusionMaskFromDict.run(excl_fp, layers_dict=layer_dict) + assert "Extent layer must be boolean" in str(error) + + @pytest.mark.parametrize(('scenario'), ['urban_pv', 'rural_pv', 'wind', 'weighted']) def test_inclusion_mask(scenario):