diff --git a/reV/bespoke/bespoke.py b/reV/bespoke/bespoke.py index 3632dfbe9..6dac62149 100644 --- a/reV/bespoke/bespoke.py +++ b/reV/bespoke/bespoke.py @@ -1382,32 +1382,45 @@ def run_plant_optimization(self): eos_mult = (self.plant_optimizer.capital_cost / self.plant_optimizer.capacity / baseline_cost) - reg_mult = self.sam_sys_inputs.get("capital_cost_multiplier", 1) + reg_mult_cc = self.sam_sys_inputs.get( + "capital_cost_multiplier", 1) + reg_mult_foc = self.sam_sys_inputs.get( + "fixed_operating_cost_multiplier", 1) + reg_mult_voc = self.sam_sys_inputs.get( + "variable_operating_cost_multiplier", 1) + reg_mult_bos = self.sam_sys_inputs.get( + "balance_of_system_cost_multiplier", 1) self._meta[SupplyCurveField.EOS_MULT] = eos_mult - self._meta[SupplyCurveField.REG_MULT] = reg_mult + self._meta[SupplyCurveField.REG_MULT] = reg_mult_cc - cap_cost = ( - self.plant_optimizer.capital_cost - + self.plant_optimizer.balance_of_system_cost - ) self._meta[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] = ( - cap_cost / capacity_ac_mw + (self.plant_optimizer.capital_cost + + self.plant_optimizer.balance_of_system_cost) + / capacity_ac_mw ) self._meta[SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW] = ( - cap_cost / eos_mult / reg_mult / capacity_ac_mw + (self.plant_optimizer.capital_cost / eos_mult / reg_mult_cc + + self.plant_optimizer.balance_of_system_cost / reg_mult_bos) + / capacity_ac_mw ) self._meta[SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW] = ( - self.plant_optimizer.fixed_operating_cost / capacity_ac_mw + self.plant_optimizer.fixed_operating_cost + / capacity_ac_mw ) self._meta[SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW] = ( - self.plant_optimizer.fixed_operating_cost / capacity_ac_mw + self.plant_optimizer.fixed_operating_cost + / reg_mult_foc + / capacity_ac_mw ) self._meta[SupplyCurveField.COST_SITE_VOC_USD_PER_AC_MW] = ( - self.plant_optimizer.variable_operating_cost / capacity_ac_mw + self.plant_optimizer.variable_operating_cost + / capacity_ac_mw ) self._meta[SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW] = ( - self.plant_optimizer.variable_operating_cost / capacity_ac_mw + self.plant_optimizer.variable_operating_cost + / reg_mult_voc + / capacity_ac_mw ) self._meta[SupplyCurveField.FIXED_CHARGE_RATE] = ( self.plant_optimizer.fixed_charge_rate @@ -1489,8 +1502,9 @@ def __init__(self, excl_fpath, res_fpath, tm_dset, objective_function, ws_bins=(0.0, 20.0, 5.0), wd_bins=(0.0, 360.0, 45.0), excl_dict=None, area_filter_kernel='queen', min_area=None, resolution=64, excl_area=None, data_layers=None, - pre_extract_inclusions=False, prior_run=None, gid_map=None, - bias_correct=None, pre_load_data=False): + pre_extract_inclusions=False, eos_mult_baseline_cap_mw=200, + prior_run=None, gid_map=None, bias_correct=None, + pre_load_data=False): """reV bespoke analysis class. Much like generation, ``reV`` bespoke analysis runs SAM @@ -1855,6 +1869,13 @@ def __init__(self, excl_fpath, res_fpath, tm_dset, objective_function, the `excl_dict` input. It is typically faster to compute the inclusion mask on the fly with parallel workers. By default, ``False``. + eos_mult_baseline_cap_mw : int | float, optional + Baseline plant capacity (MW) used to calculate economies of + scale (EOS) multiplier from the `capital_cost_function`. EOS + multiplier is calculated as the $-per-kW of the wind plant + divided by the $-per-kW of a plant with this baseline + capacity. By default, `200` (MW), which aligns the baseline + with ATB assumptions. See here: https://tinyurl.com/y85hnu6h. prior_run : str, optional Optional filepath to a bespoke output HDF5 file belonging to a prior run. If specified, this module will only run the @@ -1980,6 +2001,7 @@ def __init__(self, excl_fpath, res_fpath, tm_dset, objective_function, self._ws_bins = ws_bins self._wd_bins = wd_bins self._data_layers = data_layers + self._eos_mult_baseline_cap_mw = eos_mult_baseline_cap_mw self._prior_meta = self._parse_prior_run(prior_run) self._gid_map = BespokeSinglePlant._parse_gid_map(gid_map) self._bias_correct = Gen._parse_bc(bias_correct) @@ -2453,8 +2475,8 @@ def run_serial(cls, excl_fpath, res_fpath, tm_dset, area_filter_kernel='queen', min_area=None, resolution=64, excl_area=0.0081, data_layers=None, gids=None, exclusion_shape=None, slice_lookup=None, - prior_meta=None, gid_map=None, bias_correct=None, - pre_loaded_data=None): + eos_mult_baseline_cap_mw=200, prior_meta=None, + gid_map=None, bias_correct=None, pre_loaded_data=None): """ Standalone serial method to run bespoke optimization. See BespokeWindPlants docstring for parameter description. @@ -2520,6 +2542,7 @@ def run_serial(cls, excl_fpath, res_fpath, tm_dset, excl_area=excl_area, data_layers=data_layers, exclusion_shape=exclusion_shape, + eos_mult_baseline_cap_mw=eos_mult_baseline_cap_mw, prior_meta=prior_meta, gid_map=gid_map, bias_correct=bias_correct, @@ -2612,6 +2635,7 @@ def run_parallel(self, max_workers=None): gids=gid, exclusion_shape=self.shape, slice_lookup=copy.deepcopy(self.slice_lookup), + eos_mult_baseline_cap_mw=self._eos_mult_baseline_cap_mw, prior_meta=self._get_prior_meta(gid), gid_map=self._gid_map, bias_correct=self._get_bc_for_gid(gid), @@ -2676,6 +2700,7 @@ def run(self, out_fpath=None, max_workers=None): afk = self._area_filter_kernel wlm = self._wake_loss_multiplier i_bc = self._get_bc_for_gid(gid) + ebc = self._eos_mult_baseline_cap_mw si = self.run_serial(self._excl_fpath, self._res_fpath, @@ -2700,6 +2725,7 @@ def run(self, out_fpath=None, max_workers=None): excl_area=self._excl_area, data_layers=self._data_layers, slice_lookup=slice_lookup, + eos_mult_baseline_cap_mw=ebc, prior_meta=prior_meta, gid_map=self._gid_map, bias_correct=i_bc, diff --git a/reV/supply_curve/cli_sc_aggregation.py b/reV/supply_curve/cli_sc_aggregation.py index 3d1117db0..59282f571 100644 --- a/reV/supply_curve/cli_sc_aggregation.py +++ b/reV/supply_curve/cli_sc_aggregation.py @@ -50,12 +50,25 @@ def _preprocessor(config, out_dir): def _format_res_fpath(config): """Format res_fpath with year, if need be. """ res_fpath = config.setdefault("res_fpath", None) - if isinstance(res_fpath, str) and '{}' in res_fpath: - for year in range(1998, 2018): - if os.path.exists(res_fpath.format(year)): - break - - config["res_fpath"] = res_fpath.format(year) + if isinstance(res_fpath, str): + if '{}' in res_fpath: + for year in range(1950, 2100): + if os.path.exists(res_fpath.format(year)): + break + else: + msg = ("Could not find any files that match the pattern" + "{!r}".format(res_fpath.format(""))) + logger.error(msg) + raise FileNotFoundError(msg) + + res_fpath = res_fpath.format(year) + + elif not os.path.exists(res_fpath): + msg = "Could not find resource file: {!r}".format(res_fpath) + logger.error(msg) + raise FileNotFoundError(msg) + + config["res_fpath"] = res_fpath return config diff --git a/requirements.txt b/requirements.txt index eea8e393b..2b9a1e612 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ NREL-gaps>=0.6.11 NREL-NRWAL>=0.0.7 NREL-PySAM~=4.1.0 -NREL-rex>=0.2.85 +NREL-rex>=0.2.89 numpy~=1.24.4 packaging>=20.3 plotly>=4.7.1 diff --git a/tests/test_bespoke.py b/tests/test_bespoke.py index 9a9bb3da5..360b36846 100644 --- a/tests/test_bespoke.py +++ b/tests/test_bespoke.py @@ -595,6 +595,9 @@ def test_bespoke(): SiteDataField.GID: [33, 35], SiteDataField.CONFIG: ["default"] * 2, "extra_unused_data": [0, 42], + "capital_cost_multiplier": [1, 2], + "fixed_operating_cost_multiplier": [3, 4], + "variable_operating_cost_multiplier": [5, 6] } ) fully_excluded_points = pd.DataFrame( @@ -674,6 +677,16 @@ def test_bespoke(): assert f[dset].shape[1] == len(meta) assert f[dset].any() # not all zeros + assert not np.allclose( + meta[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW], + meta[SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW]) + assert not np.allclose( + meta[SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW], + meta[SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW]) + assert not np.allclose( + meta[SupplyCurveField.COST_SITE_VOC_USD_PER_AC_MW], + meta[SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW]) + fcr = meta[SupplyCurveField.FIXED_CHARGE_RATE] cap_cost = (meta[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] * meta[SupplyCurveField.CAPACITY_AC_MW]) @@ -689,12 +702,15 @@ def test_bespoke(): * meta[SupplyCurveField.REG_MULT] * meta[SupplyCurveField.EOS_MULT]) foc = (meta[SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW] - * meta[SupplyCurveField.CAPACITY_AC_MW]) + * meta[SupplyCurveField.CAPACITY_AC_MW] + * np.array([3, 4])) voc = (meta[SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW] - * meta[SupplyCurveField.CAPACITY_AC_MW]) + * meta[SupplyCurveField.CAPACITY_AC_MW] + * np.array([5, 6])) lcoe_base = lcoe_fcr(fcr, cap_cost, foc, aep, voc) assert np.allclose(lcoe_site, lcoe_base) + assert np.allclose(meta[SupplyCurveField.REG_MULT], [1, 2]) out_fpath_pre = os.path.join(td, 'bespoke_out_pre.h5') bsp = BespokeWindPlants(excl_fp, res_fp, TM_DSET, OBJECTIVE_FUNCTION, diff --git a/tests/test_supply_curve_sc_aggregation.py b/tests/test_supply_curve_sc_aggregation.py index a924b4afe..590f604d7 100644 --- a/tests/test_supply_curve_sc_aggregation.py +++ b/tests/test_supply_curve_sc_aggregation.py @@ -26,9 +26,11 @@ SupplyCurveAggregation, _warn_about_large_datasets, ) +from reV.supply_curve.cli_sc_aggregation import _format_res_fpath from reV.handlers.exclusions import LATITUDE from reV.utilities import ModuleName, SupplyCurveField + EXCL = os.path.join(TESTDATADIR, 'ri_exclusions/ri_exclusions.h5') RES = os.path.join(TESTDATADIR, 'nsrdb/ri_100_nsrdb_2012.h5') GEN = os.path.join(TESTDATADIR, 'gen_out/ri_my_pv_gen.h5') @@ -665,6 +667,48 @@ def test_cli_basic_agg(runner, clear_loggers, tm_dset, pre_extract): assert out_csv_fn in fn_list +def test_format_res_fpath(): + """Test the format_res_fpath function.""" + assert _format_res_fpath({"test": 1}) == {"test": 1, "res_fpath": None} + + with tempfile.TemporaryDirectory() as td: + test_file = os.path.join(td, "gen.h5") + config = {"res_fpath": test_file} + with pytest.raises(FileNotFoundError) as error: + _format_res_fpath(config) + assert "Could not find resource file" in str(error) + assert "gen.h5" in str(error) + + with open(test_file, 'w'): + pass + + assert _format_res_fpath(config) == config + + +def test_format_res_fpath_with_year_pattern(): + """Test the format_res_fpath function with {} substitute for year.""" + + with tempfile.TemporaryDirectory() as td: + tf = os.path.join(td, "gen_{}.h5") + config = {"res_fpath": tf} + with pytest.raises(FileNotFoundError) as error: + _format_res_fpath(config) + assert "Could not find any files that match the pattern" in str(error) + assert "gen_.h5" in str(error) + + with open(tf.format(2012), 'w'): + pass + + config = {"res_fpath": tf} + assert _format_res_fpath(config) == {"res_fpath": tf.format(2012)} + + with open(tf.format(2010), 'w'): + pass + + config = {"res_fpath": tf} + assert _format_res_fpath(config) == {"res_fpath": tf.format(2010)} + + def execute_pytest(capture="all", flags="-rapP"): """Execute module as pytest with detailed summary report.