diff --git a/examples/bespoke_wind_plants/single_run.py b/examples/bespoke_wind_plants/single_run.py index ff53a2faf..cd45b8bc8 100644 --- a/examples/bespoke_wind_plants/single_run.py +++ b/examples/bespoke_wind_plants/single_run.py @@ -23,7 +23,6 @@ EXCL = os.path.join(TESTDATADIR, 'ri_exclusions/ri_exclusions.h5') RES = os.path.join(TESTDATADIR, 'wtk/ri_100_wtk_{}.h5') TM_DSET = 'techmap_wtk_ri_100' -AGG_DSET = ('cf_mean', 'cf_profile') # note that this differs from the EXCL_DICT = {'ri_srtm_slope': {'inclusion_range': (None, 5), diff --git a/reV/SAM/SAM.py b/reV/SAM/SAM.py index 9ae66fbdd..fdd7040e2 100644 --- a/reV/SAM/SAM.py +++ b/reV/SAM/SAM.py @@ -626,6 +626,8 @@ def __init__( self._meta = self._parse_meta(meta) self._parse_site_sys_inputs(site_sys_inputs) + _add_cost_defaults(self.sam_sys_inputs) + _add_sys_capacity(self.sam_sys_inputs) @property def meta(self): @@ -916,3 +918,36 @@ def execute(self): msg += " for site {}".format(self.site) logger.exception(msg) raise SAMExecutionError(msg) from e + + +def _add_cost_defaults(sam_inputs): + """Add default values for required cost outputs if they are missing. """ + sam_inputs.setdefault("fixed_charge_rate", None) + + reg_mult = sam_inputs.setdefault("capital_cost_multiplier", 1) + capital_cost = sam_inputs.setdefault("capital_cost", None) + fixed_operating_cost = sam_inputs.setdefault("fixed_operating_cost", None) + variable_operating_cost = sam_inputs.setdefault( + "variable_operating_cost", None) + + sam_inputs["base_capital_cost"] = capital_cost + sam_inputs["base_fixed_operating_cost"] = fixed_operating_cost + sam_inputs["base_variable_operating_cost"] = variable_operating_cost + if capital_cost is not None: + sam_inputs["capital_cost"] = capital_cost * reg_mult + else: + sam_inputs["capital_cost"] = None + + +def _add_sys_capacity(sam_inputs): + """Add system capacity SAM input if it is missing. """ + cap = sam_inputs.get("system_capacity") + if cap is None: + cap = sam_inputs.get("turbine_capacity") + + if cap is None: + cap = sam_inputs.get("wind_turbine_powercurve_powerout") + if cap is not None: + cap = max(cap) + + sam_inputs["system_capacity"] = cap diff --git a/reV/SAM/generation.py b/reV/SAM/generation.py index d8eaf3f59..2ef711fd9 100644 --- a/reV/SAM/generation.py +++ b/reV/SAM/generation.py @@ -887,11 +887,11 @@ def __init__( simulate reduced performance over time. - ``analysis_period`` : Integer representing the number of years to include in the lifetime of the model generator. Required if - ``system_use_lifetime_output``=1. + ``system_use_lifetime_output`` is set to 1. - ``dc_degradation`` : List of percentage values representing the annual DC degradation of capacity factors. Maybe a single value that will be compound each year or a vector of yearly rates. - Required if ``system_use_lifetime_output``=1. + Required if ``system_use_lifetime_output`` is set to 1. You may also include the following ``reV``-specific keys: @@ -1546,7 +1546,7 @@ class Geothermal(AbstractSamGenerationFromWeatherFile): - The design temperature is lower than the resource temperature by a factor of ``MAX_RT_TO_EGS_RATIO`` - If either of these conditions are true, the ``design_temp`` is a + If either of these conditions are true, the ``design_temp`` is adjusted to match the resource temperature input in order to avoid SAM errors. - ``set_EGS_PDT_to_RT`` : Boolean flag to set EGS design diff --git a/reV/bespoke/bespoke.py b/reV/bespoke/bespoke.py index 5d5cbb878..0c38fa9c7 100644 --- a/reV/bespoke/bespoke.py +++ b/reV/bespoke/bespoke.py @@ -566,7 +566,7 @@ def _parse_prior_run(self): # {meta_column: sam_sys_input_key} required = { - SupplyCurveField.CAPACITY: "system_capacity", + SupplyCurveField.CAPACITY_AC_MW: "system_capacity", SupplyCurveField.TURBINE_X_COORDS: "wind_farm_xCoordinates", SupplyCurveField.TURBINE_Y_COORDS: "wind_farm_yCoordinates", } @@ -838,28 +838,23 @@ def meta(self): [float(np.round(n, 1)) for n in self.sc_point.gid_counts] ) - with SupplyCurveExtent( - self.sc_point._excl_fpath, resolution=self.sc_point.resolution - ) as sc: - row_ind, col_ind = sc.get_sc_row_col_ind(self.sc_point.gid) - self._meta = pd.DataFrame( { - SupplyCurveField.SC_POINT_GID: self.sc_point.gid, - SupplyCurveField.SC_ROW_IND: row_ind, - SupplyCurveField.SC_COL_IND: col_ind, - SupplyCurveField.GID: self.sc_point.gid, + "gid": self.sc_point.gid, # needed for collection SupplyCurveField.LATITUDE: self.sc_point.latitude, SupplyCurveField.LONGITUDE: self.sc_point.longitude, - SupplyCurveField.TIMEZONE: self.sc_point.timezone, SupplyCurveField.COUNTRY: self.sc_point.country, SupplyCurveField.STATE: self.sc_point.state, SupplyCurveField.COUNTY: self.sc_point.county, SupplyCurveField.ELEVATION: self.sc_point.elevation, - SupplyCurveField.OFFSHORE: self.sc_point.offshore, + SupplyCurveField.TIMEZONE: self.sc_point.timezone, + SupplyCurveField.SC_POINT_GID: self.sc_point.sc_point_gid, + SupplyCurveField.SC_ROW_IND: self.sc_point.sc_row_ind, + SupplyCurveField.SC_COL_IND: self.sc_point.sc_col_ind, SupplyCurveField.RES_GIDS: res_gids, SupplyCurveField.GID_COUNTS: gid_counts, SupplyCurveField.N_GIDS: self.sc_point.n_gids, + SupplyCurveField.OFFSHORE: self.sc_point.offshore, SupplyCurveField.AREA_SQ_KM: self.sc_point.area, }, index=[self.sc_point.gid], @@ -1077,11 +1072,9 @@ def recalc_lcoe(self): cc = lcoe_kwargs['capital_cost'] foc = lcoe_kwargs['fixed_operating_cost'] voc = lcoe_kwargs['variable_operating_cost'] - bos = lcoe_kwargs['balance_of_system_cost'] aep = self.outputs['annual_energy-means'] - cap_cost = cc + bos - my_mean_lcoe = lcoe_fcr(fcr, cap_cost, foc, aep, voc) + my_mean_lcoe = lcoe_fcr(fcr, cc, foc, aep, voc) self._outputs["lcoe_fcr-means"] = my_mean_lcoe self._meta[SupplyCurveField.MEAN_LCOE] = my_mean_lcoe @@ -1102,17 +1095,23 @@ def get_lcoe_kwargs(self): plant_optimizer, original_sam_sys_inputs, meta """ - kwargs_list = [ - "fixed_charge_rate", - "system_capacity", - "capital_cost", - "fixed_operating_cost", - "variable_operating_cost", - "balance_of_system_cost", - ] + kwargs_map = { + "fixed_charge_rate": SupplyCurveField.FIXED_CHARGE_RATE, + "system_capacity": SupplyCurveField.CAPACITY_AC_MW, + "capital_cost": SupplyCurveField.BESPOKE_CAPITAL_COST, + "fixed_operating_cost": ( + SupplyCurveField.BESPOKE_FIXED_OPERATING_COST + ), + "variable_operating_cost": ( + SupplyCurveField.BESPOKE_VARIABLE_OPERATING_COST + ), + "balance_of_system_cost": ( + SupplyCurveField.BESPOKE_BALANCE_OF_SYSTEM_COST + ), + } lcoe_kwargs = {} - for kwarg in kwargs_list: + for kwarg, meta_field in kwargs_map.items(): if kwarg in self.outputs: lcoe_kwargs[kwarg] = self.outputs[kwarg] elif getattr(self.plant_optimizer, kwarg, None) is not None: @@ -1122,11 +1121,13 @@ def get_lcoe_kwargs(self): elif kwarg in self.meta: value = float(self.meta[kwarg].values[0]) lcoe_kwargs[kwarg] = value + elif meta_field in self.meta: + value = float(self.meta[meta_field].values[0]) + if meta_field == SupplyCurveField.CAPACITY_AC_MW: + value *= 1000 # MW to kW + lcoe_kwargs[kwarg] = value - for k, v in lcoe_kwargs.items(): - self._meta[k] = v - - missing = [k for k in kwargs_list if k not in lcoe_kwargs] + missing = [k for k in kwargs_map if k not in lcoe_kwargs] if any(missing): msg = ( "Could not find these LCOE kwargs in outputs, " @@ -1137,6 +1138,8 @@ def get_lcoe_kwargs(self): logger.error(msg) raise KeyError(msg) + bos = lcoe_kwargs.pop("balance_of_system_cost") + lcoe_kwargs["capital_cost"] = lcoe_kwargs["capital_cost"] + bos return lcoe_kwargs @staticmethod @@ -1191,7 +1194,10 @@ def _check_sys_inputs(plant1, plant2, 'capital_cost', 'fixed_operating_cost', 'variable_operating_cost', - 'balance_of_system_cost')): + 'balance_of_system_cost', + 'base_capital_cost', + 'base_fixed_operating_cost', + 'base_variable_operating_cost')): """Check two reV-SAM models for matching system inputs. Parameters @@ -1256,9 +1262,14 @@ def run_wind_plant_ts(self): self._outputs.update(means) + self._meta[SupplyCurveField.MEAN_RES] = self.res_df["windspeed"].mean() + self._meta[SupplyCurveField.MEAN_CF_DC] = None + self._meta[SupplyCurveField.MEAN_CF_AC] = None + self._meta[SupplyCurveField.MEAN_LCOE] = None + self._meta[SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW] = None # copy dataset outputs to meta data for supply curve table summary if "cf_mean-means" in self.outputs: - self._meta.loc[:, SupplyCurveField.MEAN_CF] = self.outputs[ + self._meta.loc[:, SupplyCurveField.MEAN_CF_AC] = self.outputs[ "cf_mean-means" ] if "lcoe_fcr-means" in self.outputs: @@ -1266,6 +1277,10 @@ def run_wind_plant_ts(self): "lcoe_fcr-means" ] self.recalc_lcoe() + if "annual_energy-means" in self.outputs: + self._meta[SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW] = ( + self.outputs["annual_energy-means"] / 1000 + ) logger.debug("Timeseries analysis complete!") @@ -1294,9 +1309,12 @@ def run_plant_optimization(self): logger.exception(msg) raise RuntimeError(msg) from e - # TODO need to add: - # total cell area - # cell capacity density + self._outputs["full_polygons"] = self.plant_optimizer.full_polygons + self._outputs["packing_polygons"] = ( + self.plant_optimizer.packing_polygons + ) + system_capacity_kw = self.plant_optimizer.capacity + self._outputs["system_capacity"] = system_capacity_kw txc = [int(np.round(c)) for c in self.plant_optimizer.turbine_x] tyc = [int(np.round(c)) for c in self.plant_optimizer.turbine_y] @@ -1310,66 +1328,92 @@ def run_plant_optimization(self): self._meta[SupplyCurveField.TURBINE_X_COORDS] = txc self._meta[SupplyCurveField.TURBINE_Y_COORDS] = tyc - self._meta["possible_x_coords"] = pxc - self._meta["possible_y_coords"] = pyc + self._meta[SupplyCurveField.POSSIBLE_X_COORDS] = pxc + self._meta[SupplyCurveField.POSSIBLE_Y_COORDS] = pyc - self._outputs["full_polygons"] = self.plant_optimizer.full_polygons - self._outputs["packing_polygons"] = ( - self.plant_optimizer.packing_polygons - ) - self._outputs["system_capacity"] = self.plant_optimizer.capacity - - self._meta["n_turbines"] = self.plant_optimizer.nturbs - self._meta["avg_sl_dist_to_center_m"] = \ + self._meta[SupplyCurveField.N_TURBINES] = self.plant_optimizer.nturbs + self._meta["avg_sl_dist_to_center_m"] = ( self.plant_optimizer.avg_sl_dist_to_center_m - self._meta["avg_sl_dist_to_medoid_m"] = \ + ) + self._meta["avg_sl_dist_to_medoid_m"] = ( self.plant_optimizer.avg_sl_dist_to_medoid_m + ) self._meta["nn_conn_dist_m"] = self.plant_optimizer.nn_conn_dist_m - self._meta["bespoke_aep"] = self.plant_optimizer.aep - self._meta["bespoke_objective"] = self.plant_optimizer.objective - self._meta["bespoke_capital_cost"] = self.plant_optimizer.capital_cost - self._meta["bespoke_fixed_operating_cost"] = ( + self._meta[SupplyCurveField.BESPOKE_AEP] = self.plant_optimizer.aep + self._meta[SupplyCurveField.BESPOKE_OBJECTIVE] = ( + self.plant_optimizer.objective + ) + self._meta[SupplyCurveField.BESPOKE_CAPITAL_COST] = ( + self.plant_optimizer.capital_cost + ) + self._meta[SupplyCurveField.BESPOKE_FIXED_OPERATING_COST] = ( self.plant_optimizer.fixed_operating_cost ) - self._meta["bespoke_variable_operating_cost"] = ( + self._meta[SupplyCurveField.BESPOKE_VARIABLE_OPERATING_COST] = ( self.plant_optimizer.variable_operating_cost ) - self._meta["bespoke_balance_of_system_cost"] = ( + self._meta[SupplyCurveField.BESPOKE_BALANCE_OF_SYSTEM_COST] = ( self.plant_optimizer.balance_of_system_cost ) - self._meta["included_area"] = self.plant_optimizer.area - self._meta["included_area_capacity_density"] = ( + self._meta[SupplyCurveField.INCLUDED_AREA] = self.plant_optimizer.area + self._meta[SupplyCurveField.INCLUDED_AREA_CAPACITY_DENSITY] = ( self.plant_optimizer.capacity_density ) - self._meta["convex_hull_area"] = self.plant_optimizer.convex_hull_area - self._meta["convex_hull_capacity_density"] = ( + self._meta[SupplyCurveField.CONVEX_HULL_AREA] = ( + self.plant_optimizer.convex_hull_area + ) + self._meta[SupplyCurveField.CONVEX_HULL_CAPACITY_DENSITY] = ( self.plant_optimizer.convex_hull_capacity_density ) - self._meta["full_cell_capacity_density"] = ( + self._meta[SupplyCurveField.FULL_CELL_CAPACITY_DENSITY] = ( self.plant_optimizer.full_cell_capacity_density ) - logger.debug("Plant layout optimization complete!") - # copy dataset outputs to meta data for supply curve table summary # convert SAM system capacity in kW to reV supply curve cap in MW - self._meta[SupplyCurveField.CAPACITY] = ( - self.outputs["system_capacity"] / 1e3 - ) + capacity_ac_mw = system_capacity_kw / 1e3 + self._meta[SupplyCurveField.CAPACITY_AC_MW] = capacity_ac_mw + self._meta[SupplyCurveField.CAPACITY_DC_MW] = None # add required ReEDS multipliers to meta baseline_cost = self.plant_optimizer.capital_cost_per_kw( capacity_mw=self._baseline_cap_mw ) - self._meta[SupplyCurveField.EOS_MULT] = ( + eos_mult = (self.plant_optimizer.capital_cost + / self.plant_optimizer.capacity + / baseline_cost) + reg_mult = self.sam_sys_inputs.get("capital_cost_multiplier", 1) + + self._meta[SupplyCurveField.EOS_MULT] = eos_mult + self._meta[SupplyCurveField.REG_MULT] = reg_mult + + cap_cost = ( self.plant_optimizer.capital_cost - / self.plant_optimizer.capacity - / baseline_cost + + self.plant_optimizer.balance_of_system_cost + ) + self._meta[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] = ( + cap_cost / capacity_ac_mw + ) + self._meta[SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW] = ( + cap_cost / eos_mult / reg_mult / capacity_ac_mw + ) + self._meta[SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW] = ( + self.plant_optimizer.fixed_operating_cost / capacity_ac_mw ) - self._meta[SupplyCurveField.REG_MULT] = self.sam_sys_inputs.get( - "capital_cost_multiplier", 1 + self._meta[SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW] = ( + self.plant_optimizer.fixed_operating_cost / capacity_ac_mw + ) + self._meta[SupplyCurveField.COST_SITE_VOC_USD_PER_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._meta[SupplyCurveField.FIXED_CHARGE_RATE] = ( + self.plant_optimizer.fixed_charge_rate ) + logger.debug("Plant layout optimization complete!") return self.outputs def agg_data_layers(self): @@ -1963,7 +2007,7 @@ def _parse_points(points, sam_configs): Slice or list specifying project points, string pointing to a project points csv, or a fully instantiated PointsControl object. Can also be a single site integer value. Points csv should have - `SupplyCurveField.GID` and 'config' column, the config maps to the + `SiteDataField.GID` and 'config' column, the config maps to the sam_configs dict keys. sam_configs : dict | str | SAMConfig SAM input configuration ID(s) and file path(s). Keys are the SAM @@ -2018,6 +2062,7 @@ def _parse_prior_run(prior_run): with Outputs(prior_run, mode="r") as f: meta = f.meta + meta = meta.rename(columns=SupplyCurveField.map_from_legacy()) # pylint: disable=no-member for col in meta.columns: @@ -2043,7 +2088,7 @@ def _get_prior_meta(self, gid): meta = None if self._prior_meta is not None: - mask = self._prior_meta[SupplyCurveField.GID] == gid + mask = self._prior_meta[SupplyCurveField.SC_POINT_GID] == gid if any(mask): meta = self._prior_meta[mask] diff --git a/reV/config/output_request.py b/reV/config/output_request.py index 3cd9e7e95..84526c2cc 100644 --- a/reV/config/output_request.py +++ b/reV/config/output_request.py @@ -37,7 +37,7 @@ def __init__(self, inp): for request in inp: if request in self.CORRECTIONS.values(): self.append(request) - elif request in self.CORRECTIONS.keys(): + elif request in self.CORRECTIONS: self.append(self.CORRECTIONS[request]) msg = ('Correcting output request "{}" to "{}".' .format(request, self.CORRECTIONS[request])) @@ -61,6 +61,7 @@ class SAMOutputRequest(OutputRequest): # all available SAM output variables should be in the values CORRECTIONS = {'cf_means': 'cf_mean', 'cf': 'cf_mean', + 'capacity': 'system_capacity', 'capacity_factor': 'cf_mean', 'capacityfactor': 'cf_mean', 'cf_profiles': 'cf_profile', diff --git a/reV/config/project_points.py b/reV/config/project_points.py index c4b2ac637..907769817 100644 --- a/reV/config/project_points.py +++ b/reV/config/project_points.py @@ -623,9 +623,7 @@ def _parse_points(cls, points, res_file=None): # pylint: disable=no-member if SiteDataField.CONFIG not in df.columns: - df = cls._parse_sites( - df[SiteDataField.GID].values, res_file=res_file - ) + df[SiteDataField.CONFIG] = None gids = df[SiteDataField.GID].values if not np.array_equal(np.sort(gids), gids): diff --git a/reV/econ/econ.py b/reV/econ/econ.py index 1987657a5..a55985cdb 100644 --- a/reV/econ/econ.py +++ b/reV/econ/econ.py @@ -352,7 +352,7 @@ def _run_single_worker(pc, econ_fun, output_request, **kwargs): pc : reV.config.project_points.PointsControl Iterable points control object from reV config module. Must have project_points with df property with all relevant - site-specific inputs and a `SupplyCurveField.GID` column. + site-specific inputs and a `SiteDataField.GID` column. By passing site-specific inputs in this dataframe, which was split using points_control, only the data relevant to the current sites is passed. @@ -405,7 +405,7 @@ def _parse_output_request(self, req): Output variables requested from SAM. """ - output_request = self._output_request_type_check(req) + output_request = super()._parse_output_request(req) for request in output_request: if request not in self.OUT_ATTRS: diff --git a/reV/econ/economies_of_scale.py b/reV/econ/economies_of_scale.py index f47b6e259..5db110910 100644 --- a/reV/econ/economies_of_scale.py +++ b/reV/econ/economies_of_scale.py @@ -113,7 +113,8 @@ def vars(self): """ var_names = [] if self._eqn is not None: - delimiters = ("*", "/", "+", "-", " ", "(", ")", "[", "]", ",") + delimiters = (">", "<", ">=", "<=", "==", ",", "*", "/", "+", "-", + " ", "(", ")", "[", "]") regex_pattern = "|".join(map(re.escape, delimiters)) var_names = [] for sub in re.split(regex_pattern, str(self._eqn)): @@ -190,6 +191,33 @@ def capital_cost_scalar(self): """ return self._evaluate() + def _cost_from_cap(self, col_name): + """Get full cost value from cost per mw in data. + + Parameters + ---------- + col_name : str + Name of column containing the cost per mw value. + + Returns + ------- + float | None + Cost value if it was found in data, ``None`` otherwise. + """ + cap = self._data.get(SupplyCurveField.CAPACITY_AC_MW) + if cap is None: + return None + + cost_per_mw = self._data.get(col_name) + if cost_per_mw is None: + return None + + cost = cap * cost_per_mw + if cost > 0: + return cost + + return None + @property def raw_capital_cost(self): """Unscaled (raw) capital cost found in the data input arg. @@ -199,10 +227,13 @@ def raw_capital_cost(self): out : float | np.ndarray Unscaled (raw) capital_cost found in the data input arg. """ - key_list = [ - SupplyCurveField.CAPITAL_COST, - "mean_capital_cost", - ] + raw_capital_cost_from_cap = self._cost_from_cap( + SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW + ) + if raw_capital_cost_from_cap is not None: + return raw_capital_cost_from_cap + + key_list = ["capital_cost", "mean_capital_cost"] return self._get_prioritized_keys(self._data, key_list) @property @@ -220,18 +251,6 @@ def scaled_capital_cost(self): cc *= self.capital_cost_scalar return cc - @property - def system_capacity(self): - """Get the system capacity in kW (SAM input, not the reV supply - curve capacity). - - Returns - ------- - out : float | np.ndarray - """ - key_list = ["system_capacity", "mean_system_capacity"] - return self._get_prioritized_keys(self._data, key_list) - @property def fcr(self): """Fixed charge rate from input data arg @@ -241,12 +260,12 @@ def fcr(self): out : float | np.ndarray Fixed charge rate from input data arg """ - key_list = [ - SupplyCurveField.FIXED_CHARGE_RATE, - "mean_fixed_charge_rate", - "fcr", - "mean_fcr", - ] + fcr = self._data.get(SupplyCurveField.FIXED_CHARGE_RATE) + if fcr is not None and fcr > 0: + return fcr + + key_list = ["fixed_charge_rate", "mean_fixed_charge_rate", "fcr", + "mean_fcr"] return self._get_prioritized_keys(self._data, key_list) @property @@ -258,12 +277,14 @@ def foc(self): out : float | np.ndarray Fixed operating cost from input data arg """ - key_list = [ - SupplyCurveField.FIXED_OPERATING_COST, - "mean_fixed_operating_cost", - "foc", - "mean_foc", - ] + foc_from_cap = self._cost_from_cap( + SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW + ) + if foc_from_cap is not None: + return foc_from_cap + + key_list = ["fixed_operating_cost", "mean_fixed_operating_cost", + "foc", "mean_foc"] return self._get_prioritized_keys(self._data, key_list) @property @@ -275,12 +296,14 @@ def voc(self): out : float | np.ndarray Variable operating cost from input data arg """ - key_list = [ - SupplyCurveField.VARIABLE_OPERATING_COST, - "mean_variable_operating_cost", - "voc", - "mean_voc", - ] + voc_from_cap = self._cost_from_cap( + SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW + ) + if voc_from_cap is not None: + return voc_from_cap + + key_list = ["variable_operating_cost", "mean_variable_operating_cost", + "voc", "mean_voc"] return self._get_prioritized_keys(self._data, key_list) @property diff --git a/reV/generation/base.py b/reV/generation/base.py index f45ee25bc..2f5264b11 100644 --- a/reV/generation/base.py +++ b/reV/generation/base.py @@ -45,6 +45,21 @@ with open(os.path.join(ATTR_DIR, 'lcoe_fcr_inputs.json')) as f: LCOE_IN_ATTRS = json.load(f) +LCOE_REQUIRED_OUTPUTS = ("system_capacity", "capital_cost_multiplier", + "capital_cost", "fixed_operating_cost", + "variable_operating_cost", "base_capital_cost", + "base_fixed_operating_cost", + "base_variable_operating_cost", "fixed_charge_rate") +"""Required econ outputs in generation file.""" + + +def _add_lcoe_outputs(output_request): + """Add required lcoe outputs to output request. """ + for out_var in LCOE_REQUIRED_OUTPUTS: + if out_var not in output_request: + output_request.append(out_var) + return output_request + class BaseGen(ABC): """Base class for reV gen and econ classes to run SAM simulations.""" @@ -302,7 +317,7 @@ def meta(self): Meta data df for sites in project points. Column names are meta data variables, rows are different sites. The row index does not indicate the site number if the project points are - non-sequential or do not start from 0, so a `SupplyCurveField.GID` + non-sequential or do not start from 0, so a `SiteDataField.GID` column is added. """ return self._meta @@ -859,7 +874,6 @@ def add_site_data_to_pp(self, site_data): """ self.project_points.join_df(site_data, key=self.site_data.index.name) - @abstractmethod def _parse_output_request(self, req): """Set the output variables requested from the user. @@ -873,6 +887,12 @@ def _parse_output_request(self, req): output_request : list Output variables requested from SAM. """ + output_request = self._output_request_type_check(req) + + if "lcoe_fcr" in output_request: + output_request = _add_lcoe_outputs(output_request) + + return output_request def _get_data_shape(self, dset, n_sites): """Get the output array shape based on OUT_ATTRS or PySAM.Outputs. diff --git a/reV/generation/generation.py b/reV/generation/generation.py index 5e9a2e274..e5ae69094 100644 --- a/reV/generation/generation.py +++ b/reV/generation/generation.py @@ -113,7 +113,7 @@ def __init__( allowed and/or required SAM config file inputs. If economic parameters are supplied in the SAM config, then you can bundle a "follow-on" econ calculation by just adding the desired econ - output keys to the `output_request`. You can request ``reV`` to ' + output keys to the `output_request`. You can request ``reV`` to run the analysis for one or more "sites", which correspond to the meta indices in the resource data (also commonly called the ``gid's``). @@ -128,7 +128,7 @@ def __init__( >>> import os >>> from reV import Gen, TESTDATADIR >>> - >>> sam_tech = 'pvwattsv7' + >>> sam_tech = 'pvwattsv8' >>> sites = 0 >>> fp_sam = os.path.join(TESTDATADIR, 'SAM/naris_pv_1axis_inv13.json') >>> fp_res = os.path.join(TESTDATADIR, 'nsrdb/ri_100_nsrdb_2013.h5') @@ -145,15 +145,16 @@ def __init__( >>> gen.run() >>> >>> gen.out - {'lcoe_fcr': array([131.39166, 131.31221, 127.54539, 125.49656]), - 'cf_mean': array([0.17713654, 0.17724372, 0.1824783 , 0.1854574 ]), - : array([[0., 0., 0., 0.], - [0., 0., 0., 0.], - [0., 0., 0., 0.], - ..., - [0., 0., 0., 0.], - [0., 0., 0., 0.], - [0., 0., 0., 0.]])} + {'fixed_charge_rate': array([0.096, 0.096, 0.096, 0.096], + 'base_capital_cost': array([39767200, 39767200, 39767200, 39767200], + 'base_variable_operating_cost': array([0, 0, 0, 0], + 'base_fixed_operating_cost': array([260000, 260000, 260000, 260000], + 'capital_cost': array([39767200, 39767200, 39767200, 39767200], + 'fixed_operating_cost': array([260000, 260000, 260000, 260000], + 'variable_operating_cost': array([0, 0, 0, 0], + 'capital_cost_multiplier': array([1, 1, 1, 1], + 'cf_mean': array([0.17859147, 0.17869979, 0.1834818 , 0.18646291], + 'lcoe_fcr': array([130.32126, 130.24226, 126.84782, 124.81981]} Parameters ---------- @@ -454,7 +455,7 @@ def meta(self): Meta data df for sites in project points. Column names are meta data variables, rows are different sites. The row index does not indicate the site number if the project points are - non-sequential or do not start from 0, so a `SupplyCurveField.GID` + non-sequential or do not start from 0, so a `SiteDataField.GID` column is added. """ if self._meta is None: @@ -712,6 +713,8 @@ def _run_single_worker( for k in site_output.keys(): # iterate through variable names in each site's output dict if k in cls.OUT_ATTRS: + if out[site][k] is None: + continue # get dtype and scale for output variable name dtype = cls.OUT_ATTRS[k].get("dtype", "float32") scale_factor = cls.OUT_ATTRS[k].get("scale_factor", 1) @@ -947,12 +950,20 @@ def _parse_output_request(self, req): Output variables requested from SAM. """ - output_request = self._output_request_type_check(req) + output_request = super()._parse_output_request(req) # ensure that cf_mean is requested from output if "cf_mean" not in output_request: output_request.append("cf_mean") + if _is_solar_run_with_ac_outputs(self.tech): + if "dc_ac_ratio" not in output_request: + output_request.append("dc_ac_ratio") + for dset in ["cf_mean", "cf_profile"]: + ac_dset = f"{dset}_ac" + if dset in output_request and ac_dset not in output_request: + output_request.append(ac_dset) + for request in output_request: if request not in self.OUT_ATTRS: msg = ( @@ -1097,3 +1108,10 @@ def run(self, out_fpath=None, max_workers=1, timeout=1800, pool_size=None): raise e return self._out_fpath + + +def _is_solar_run_with_ac_outputs(tech): + """True if tech is pvwattsv8+""" + if "pvwatts" not in tech.casefold(): + return False + return tech.casefold() not in {f"pvwattsv{i}" for i in range(8)} diff --git a/reV/generation/output_attributes/lcoe_fcr_inputs.json b/reV/generation/output_attributes/lcoe_fcr_inputs.json index 32da77a92..af402ce6d 100644 --- a/reV/generation/output_attributes/lcoe_fcr_inputs.json +++ b/reV/generation/output_attributes/lcoe_fcr_inputs.json @@ -4,7 +4,7 @@ "dtype": "float32", "scale_factor": 1, "type": "scalar", - "units": "dollars" + "units": "usd" }, "fixed_charge_rate": { "chunks": null, @@ -18,13 +18,48 @@ "dtype": "float32", "scale_factor": 1, "type": "scalar", - "units": "dollars" + "units": "usd" }, "variable_operating_cost": { "chunks": null, "dtype": "float32", "scale_factor": 1, "type": "scalar", - "units": "dol/kWh" + "units": "usd/kWh" + }, + "base_capital_cost": { + "chunks": null, + "dtype": "float32", + "scale_factor": 1, + "type": "scalar", + "units": "usd" + }, + "base_fixed_operating_cost": { + "chunks": null, + "dtype": "float32", + "scale_factor": 1, + "type": "scalar", + "units": "usd" + }, + "base_variable_operating_cost": { + "chunks": null, + "dtype": "float32", + "scale_factor": 1, + "type": "scalar", + "units": "usd/kWh" + }, + "capital_cost_multiplier": { + "chunks": null, + "dtype": "float32", + "scale_factor": 1, + "type": "scalar", + "units": "unitless" + }, + "system_capacity": { + "chunks": null, + "dtype": "float32", + "scale_factor": 1, + "type": "scalar", + "units": "kW" } } diff --git a/reV/handlers/__init__.py b/reV/handlers/__init__.py index 50c9d4775..e73fcad17 100644 --- a/reV/handlers/__init__.py +++ b/reV/handlers/__init__.py @@ -3,5 +3,4 @@ Sub-package of data handlers """ from .exclusions import ExclusionLayers -from .multi_year import MultiYear from .outputs import Outputs diff --git a/reV/handlers/multi_year.py b/reV/handlers/multi_year.py index e8d3e52d5..6e3b3d418 100644 --- a/reV/handlers/multi_year.py +++ b/reV/handlers/multi_year.py @@ -18,6 +18,7 @@ parse_year, ) +from reV.generation.base import LCOE_REQUIRED_OUTPUTS from reV.config.output_request import SAMOutputRequest from reV.handlers.outputs import Outputs from reV.utilities import ModuleName, log_versions @@ -60,8 +61,12 @@ def __init__(self, name, out_dir, source_files=None, source files. This takes priority over `source_dir` and `source_prefix` but is not used if `source_files` are specified explicitly. By default, ``None``. - dsets : list | tuple, optional - List of datasets to collect. By default, ``('cf_mean',)``. + dsets : str | list | tuple, optional + List of datasets to collect. This can be set to + ``"PIPELINE"`` if running from the command line as part of a + reV pipeline. In this case, all the datasets from the + previous pipeline step will be collected. + By default, ``('cf_mean',)``. pass_through_dsets : list | tuple, optional Optional list of datasets that are identical in the multi-year files (e.g. input datasets that don't vary from @@ -76,10 +81,35 @@ def __init__(self, name, out_dir, source_files=None, self._source_prefix = source_prefix self._source_pattern = source_pattern self._pass_through_dsets = None - if pass_through_dsets is not None: - self._pass_through_dsets = SAMOutputRequest(pass_through_dsets) + self._dsets = None - self._dsets = self._parse_dsets(dsets) + self._parse_pass_through_dsets(dsets, pass_through_dsets or []) + self._parse_dsets(dsets) + + def _parse_pass_through_dsets(self, dsets, pass_through_dsets): + """Parse a multi-year pass-through dataset collection request. + + Parameters + ---------- + dsets : str | list + One or more datasets to collect, or "PIPELINE" + pass_through_dsets : list + List of pass through datasets. + """ + if isinstance(dsets, str) and dsets == 'PIPELINE': + files = parse_previous_status(self._dirout, ModuleName.MULTI_YEAR) + with Resource(files[0]) as res: + dsets = res.datasets + + if "lcoe_fcr" in dsets: + for dset in LCOE_REQUIRED_OUTPUTS: + if dset not in pass_through_dsets: + pass_through_dsets.append(dset) + if "dc_ac_ratio" in dsets: + if "dc_ac_ratio" not in pass_through_dsets: + pass_through_dsets.append("dc_ac_ratio") + + self._pass_through_dsets = SAMOutputRequest(pass_through_dsets) def _parse_dsets(self, dsets): """Parse a multi-year dataset collection request. Can handle PIPELINE @@ -90,11 +120,6 @@ def _parse_dsets(self, dsets): ---------- dsets : str | list One or more datasets to collect, or "PIPELINE" - - Returns - ------- - dsets : SAMOutputRequest - Dataset list object. """ if isinstance(dsets, str) and dsets == 'PIPELINE': files = parse_previous_status(self._dirout, ModuleName.MULTI_YEAR) @@ -104,9 +129,7 @@ def _parse_dsets(self, dsets): and d != 'meta' and d not in self.pass_through_dsets] - dsets = SAMOutputRequest(dsets) - - return dsets + self._dsets = SAMOutputRequest(dsets) @property def name(self): @@ -815,10 +838,15 @@ def my_collect_groups(out_fpath, groups, clobber=True): MultiYear.collect_means(out_fpath, group['source_files'], dset, group=group['group']) - if group.get('pass_through_dsets', None) is not None: - for dset in group['pass_through_dsets']: - MultiYear.pass_through(out_fpath, group['source_files'], - dset, group=group['group']) + pass_through_dsets = group.get('pass_through_dsets') or [] + if "lcoe_fcr" in group['dsets']: + for dset in LCOE_REQUIRED_OUTPUTS: + if dset not in pass_through_dsets: + pass_through_dsets.append(dset) + + for dset in pass_through_dsets: + MultiYear.pass_through(out_fpath, group['source_files'], + dset, group=group['group']) runtime = (time.time() - t0) / 60 logger.info('- {} collection completed in: {:.2f} min.' diff --git a/reV/handlers/transmission.py b/reV/handlers/transmission.py index c27713cf7..2734757d2 100644 --- a/reV/handlers/transmission.py +++ b/reV/handlers/transmission.py @@ -9,6 +9,7 @@ import pandas as pd from warnings import warn +from reV.utilities import SupplyCurveField from reV.utilities.exceptions import (HandlerWarning, HandlerKeyError, HandlerRuntimeError) @@ -153,12 +154,17 @@ def _parse_table(trans_table): raise trans_table = \ - trans_table.rename(columns={'trans_line_gid': 'trans_gid', - 'trans_gids': 'trans_line_gids'}) - - if 'dist_mi' in trans_table and 'dist_km' not in trans_table: - trans_table = trans_table.rename(columns={'dist_mi': 'dist_km'}) - trans_table['dist_km'] *= 1.60934 + trans_table.rename( + columns={'trans_line_gid': SupplyCurveField.TRANS_GID, + 'trans_gids': 'trans_line_gids'}) + + contains_dist_in_miles = "dist_mi" in trans_table + missing_km_dist = SupplyCurveField.DIST_SPUR_KM not in trans_table + if contains_dist_in_miles and missing_km_dist: + trans_table = trans_table.rename( + columns={"dist_mi": SupplyCurveField.DIST_SPUR_KM} + ) + trans_table[SupplyCurveField.DIST_SPUR_KM] *= 1.60934 return trans_table @@ -184,23 +190,28 @@ def _features_from_table(self, trans_table): features = {} cap_frac = self._avail_cap_frac - trans_features = trans_table.groupby('trans_gid').first() + trans_features = trans_table.groupby(SupplyCurveField.TRANS_GID) + trans_features = trans_features.first() for gid, feature in trans_features.iterrows(): - name = feature['category'].lower() + name = feature[SupplyCurveField.TRANS_TYPE].lower() feature_dict = {'type': name} if name == 'transline': - feature_dict['avail_cap'] = feature['ac_cap'] * cap_frac + feature_dict[SupplyCurveField.TRANS_CAPACITY] = ( + feature['ac_cap'] * cap_frac + ) elif name == 'substation': feature_dict['lines'] = json.loads(feature['trans_line_gids']) elif name == 'loadcen': - feature_dict['avail_cap'] = feature['ac_cap'] * cap_frac + feature_dict[SupplyCurveField.TRANS_CAPACITY] = ( + feature['ac_cap'] * cap_frac + ) elif name == 'pcaloadcen': - feature_dict['avail_cap'] = None + feature_dict[SupplyCurveField.TRANS_CAPACITY] = None else: msg = ('Cannot not recognize feature type "{}" ' @@ -297,7 +308,8 @@ def _substation_capacity(self, gid, line_gids): Substation available capacity """ try: - line_caps = [self[l_gid]['avail_cap'] for l_gid in line_gids] + line_caps = [self[l_gid][SupplyCurveField.TRANS_CAPACITY] + for l_gid in line_gids] except HandlerKeyError as e: msg = ('Could not find capacities for substation gid {} and ' 'connected lines: {}'.format(gid, line_gids)) @@ -331,8 +343,8 @@ def available_capacity(self, gid): feature = self[gid] - if 'avail_cap' in feature: - avail_cap = feature['avail_cap'] + if SupplyCurveField.TRANS_CAPACITY in feature: + avail_cap = feature[SupplyCurveField.TRANS_CAPACITY] elif 'lines' in feature: avail_cap = self._substation_capacity(gid, feature['lines']) @@ -387,7 +399,7 @@ def _connect(self, gid, capacity): capacity : float Capacity needed in MW """ - avail_cap = self[gid]['avail_cap'] + avail_cap = self[gid][SupplyCurveField.TRANS_CAPACITY] if avail_cap < capacity: msg = ("Cannot connect to {}: " @@ -397,7 +409,7 @@ def _connect(self, gid, capacity): logger.error(msg) raise RuntimeError(msg) - self[gid]['avail_cap'] -= capacity + self[gid][SupplyCurveField.TRANS_CAPACITY] -= capacity def _fill_lines(self, line_gids, line_caps, capacity): """ @@ -471,7 +483,7 @@ def _connect_to_substation(self, line_gids, capacity): Substation connection is limited by maximum capacity of the attached lines """ - line_caps = np.array([self[gid]['avail_cap'] + line_caps = np.array([self[gid][SupplyCurveField.TRANS_CAPACITY] for gid in line_gids]) if self._line_limited: gid = line_gids[np.argmax(line_caps)] @@ -603,8 +615,8 @@ def feature_capacity(cls, trans_table, avail_cap_frac=1): raise feature_cap = pd.Series(feature_cap) - feature_cap.name = 'avail_cap' - feature_cap.index.name = 'trans_gid' + feature_cap.name = SupplyCurveField.TRANS_CAPACITY + feature_cap.index.name = SupplyCurveField.TRANS_GID feature_cap = feature_cap.to_frame().reset_index() return feature_cap @@ -635,16 +647,20 @@ def _features_from_table(self, trans_table): features = {} - if 'avail_cap' not in trans_table: + if SupplyCurveField.TRANS_CAPACITY not in trans_table: kwargs = {'avail_cap_frac': self._avail_cap_frac} fc = TransmissionFeatures.feature_capacity(trans_table, **kwargs) - trans_table = trans_table.merge(fc, on='trans_gid') + trans_table = trans_table.merge(fc, on=SupplyCurveField.TRANS_GID) - trans_features = trans_table.groupby('trans_gid').first() + trans_features = trans_table.groupby(SupplyCurveField.TRANS_GID) + trans_features = trans_features.first() for gid, feature in trans_features.iterrows(): - name = feature['category'].lower() - feature_dict = {'type': name, 'avail_cap': feature['avail_cap']} + name = feature[SupplyCurveField.TRANS_TYPE].lower() + feature_dict = {'type': name, + SupplyCurveField.TRANS_CAPACITY: ( + feature[SupplyCurveField.TRANS_CAPACITY] + )} features[gid] = feature_dict return features @@ -665,7 +681,7 @@ def available_capacity(self, gid): default = 100% """ - return self[gid]['avail_cap'] + return self[gid][SupplyCurveField.TRANS_CAPACITY] @classmethod def feature_costs(cls, trans_table, capacity=None, line_tie_in_cost=14000, @@ -722,8 +738,9 @@ def feature_costs(cls, trans_table, capacity=None, line_tie_in_cost=14000, costs = [] for _, row in trans_table.iterrows(): tm = row.get('transmission_multiplier', 1) - costs.append(feature.cost(row['trans_gid'], - row['dist_km'], capacity=capacity, + costs.append(feature.cost(row[SupplyCurveField.TRANS_GID], + row[SupplyCurveField.DIST_SPUR_KM], + capacity=capacity, transmission_multiplier=tm)) except Exception: logger.exception("Error computing costs for all connections in {}" diff --git a/reV/hybrids/hybrid_methods.py b/reV/hybrids/hybrid_methods.py index 5b4b1c0c2..7d86a4ff6 100644 --- a/reV/hybrids/hybrid_methods.py +++ b/reV/hybrids/hybrid_methods.py @@ -31,9 +31,9 @@ def aggregate_solar_capacity(h): capacity ratio and the solar capacity is copied into this new column. """ - if f'hybrid_solar_{SupplyCurveField.CAPACITY}' in h.hybrid_meta: + if f'hybrid_solar_{SupplyCurveField.CAPACITY_AC_MW}' in h.hybrid_meta: return None - return h.hybrid_meta[f'solar_{SupplyCurveField.CAPACITY}'] + return h.hybrid_meta[f'solar_{SupplyCurveField.CAPACITY_AC_MW}'] def aggregate_wind_capacity(h): @@ -60,9 +60,9 @@ def aggregate_wind_capacity(h): exist, it is assumed that there is no limit on the solar to wind capacity ratio and the wind capacity is copied into this new column. """ - if f'hybrid_wind_{SupplyCurveField.CAPACITY}' in h.hybrid_meta: + if f'hybrid_wind_{SupplyCurveField.CAPACITY_AC_MW}' in h.hybrid_meta: return None - return h.hybrid_meta[f'wind_{SupplyCurveField.CAPACITY}'] + return h.hybrid_meta[f'wind_{SupplyCurveField.CAPACITY_AC_MW}'] def aggregate_capacity(h): @@ -81,8 +81,8 @@ def aggregate_capacity(h): A series of data containing the aggregated capacity, or `None` if the capacity columns are missing. """ - sc = f'hybrid_solar_{SupplyCurveField.CAPACITY}' - wc = f'hybrid_wind_{SupplyCurveField.CAPACITY}' + sc = f'hybrid_solar_{SupplyCurveField.CAPACITY_AC_MW}' + wc = f'hybrid_wind_{SupplyCurveField.CAPACITY_AC_MW}' missing_solar_cap = sc not in h.hybrid_meta.columns missing_wind_cap = wc not in h.hybrid_meta.columns if missing_solar_cap or missing_wind_cap: @@ -109,10 +109,10 @@ def aggregate_capacity_factor(h): if the capacity and/or mean_cf columns are missing. """ - sc = f'hybrid_solar_{SupplyCurveField.CAPACITY}' - wc = f'hybrid_wind_{SupplyCurveField.CAPACITY}' - scf = f'solar_{SupplyCurveField.MEAN_CF}' - wcf = f'wind_{SupplyCurveField.MEAN_CF}' + sc = f'hybrid_solar_{SupplyCurveField.CAPACITY_AC_MW}' + wc = f'hybrid_wind_{SupplyCurveField.CAPACITY_AC_MW}' + scf = f'solar_{SupplyCurveField.MEAN_CF_AC}' + wcf = f'wind_{SupplyCurveField.MEAN_CF_AC}' missing_solar_cap = sc not in h.hybrid_meta.columns missing_wind_cap = wc not in h.hybrid_meta.columns missing_solar_mean_cf = scf not in h.hybrid_meta.columns @@ -130,8 +130,10 @@ def aggregate_capacity_factor(h): HYBRID_METHODS = { - f'hybrid_solar_{SupplyCurveField.CAPACITY}': aggregate_solar_capacity, - f'hybrid_wind_{SupplyCurveField.CAPACITY}': aggregate_wind_capacity, - f'hybrid_{SupplyCurveField.CAPACITY}': aggregate_capacity, - f'hybrid_{SupplyCurveField.MEAN_CF}': aggregate_capacity_factor + f'hybrid_solar_{SupplyCurveField.CAPACITY_AC_MW}': ( + aggregate_solar_capacity + ), + f'hybrid_wind_{SupplyCurveField.CAPACITY_AC_MW}': aggregate_wind_capacity, + f'hybrid_{SupplyCurveField.CAPACITY_AC_MW}': aggregate_capacity, + f'hybrid_{SupplyCurveField.MEAN_CF_AC}': aggregate_capacity_factor } diff --git a/reV/hybrids/hybrids.py b/reV/hybrids/hybrids.py index a579408be..f29b0c62a 100644 --- a/reV/hybrids/hybrids.py +++ b/reV/hybrids/hybrids.py @@ -38,11 +38,11 @@ SupplyCurveField.SC_POINT_GID, SupplyCurveField.SC_ROW_IND, SupplyCurveField.SC_COL_IND } -DROPPED_COLUMNS = [SupplyCurveField.GID] -DEFAULT_FILL_VALUES = {f'solar_{SupplyCurveField.CAPACITY}': 0, - f'wind_{SupplyCurveField.CAPACITY}': 0, - f'solar_{SupplyCurveField.MEAN_CF}': 0, - f'wind_{SupplyCurveField.MEAN_CF}': 0} +HYBRIDS_GID_COL = "gid" +DEFAULT_FILL_VALUES = {f'solar_{SupplyCurveField.CAPACITY_AC_MW}': 0, + f'wind_{SupplyCurveField.CAPACITY_AC_MW}': 0, + f'solar_{SupplyCurveField.MEAN_CF_AC}': 0, + f'wind_{SupplyCurveField.MEAN_CF_AC}': 0} OUTPUT_PROFILE_NAMES = ['hybrid_profile', 'hybrid_solar_profile', 'hybrid_wind_profile'] @@ -702,7 +702,7 @@ def _format_meta_post_merge(self): self._propagate_duplicate_cols(duplicate_cols) self._drop_cols(duplicate_cols) self._hybrid_meta.rename(self.__col_name_map, inplace=True, axis=1) - self._hybrid_meta.index.name = SupplyCurveField.GID + self._hybrid_meta.index.name = HYBRIDS_GID_COL def _propagate_duplicate_cols(self, duplicate_cols): """Fill missing column values from outer merge.""" @@ -713,9 +713,9 @@ def _propagate_duplicate_cols(self, duplicate_cols): self._hybrid_meta.loc[null_idx, no_suffix] = non_null_vals def _drop_cols(self, duplicate_cols): - """Drop any remaning duplicate and 'DROPPED_COLUMNS' columns.""" + """Drop any remaining duplicate and 'HYBRIDS_GID_COL' columns.""" self._hybrid_meta.drop( - duplicate_cols + DROPPED_COLUMNS, + duplicate_cols + [HYBRIDS_GID_COL], axis=1, inplace=True, errors="ignore", @@ -1196,8 +1196,8 @@ def _compute_hybridized_profile_components(self): def __rep_profile_hybridization_params(self): """Zip the rep profile hybridization parameters.""" - cap_col_names = [f"hybrid_solar_{SupplyCurveField.CAPACITY}", - f"hybrid_wind_{SupplyCurveField.CAPACITY}"] + cap_col_names = [f"hybrid_solar_{SupplyCurveField.CAPACITY_AC_MW}", + f"hybrid_wind_{SupplyCurveField.CAPACITY_AC_MW}"] idx_maps = [ self.meta_hybridizer.solar_profile_indices_map, self.meta_hybridizer.wind_profile_indices_map, diff --git a/reV/nrwal/nrwal.py b/reV/nrwal/nrwal.py index 4ee73f356..4b192ace7 100644 --- a/reV/nrwal/nrwal.py +++ b/reV/nrwal/nrwal.py @@ -37,7 +37,7 @@ class RevNrwal: def __init__(self, gen_fpath, site_data, sam_files, nrwal_configs, output_request, save_raw=True, - meta_gid_col=ResourceMetaField.GID, + meta_gid_col=str(ResourceMetaField.GID), # str() to fix docs site_meta_cols=None): """Framework to handle reV-NRWAL analysis. diff --git a/reV/qa_qc/qa_qc.py b/reV/qa_qc/qa_qc.py index 739df9c63..00369c023 100644 --- a/reV/qa_qc/qa_qc.py +++ b/reV/qa_qc/qa_qc.py @@ -105,7 +105,7 @@ def create_scatter_plots( if file.endswith(".csv"): summary_csv = os.path.join(self.out_dir, file) summary = pd.read_csv(summary_csv) - has_right_cols = (SupplyCurveField.GID in summary + has_right_cols = ("gid" in summary and SupplyCurveField.LATITUDE in summary and SupplyCurveField.LONGITUDE in summary) if has_right_cols: diff --git a/reV/qa_qc/summary.py b/reV/qa_qc/summary.py index 386caf4cb..287444a39 100644 --- a/reV/qa_qc/summary.py +++ b/reV/qa_qc/summary.py @@ -597,11 +597,11 @@ def _extract_sc_data(self, lcoe=SupplyCurveField.MEAN_LCOE): sc_df : pandas.DataFrame Supply curve data """ - values = [SupplyCurveField.CAPACITY, lcoe] + values = [SupplyCurveField.CAPACITY_AC_MW, lcoe] self._check_value(self.summary, values, scatter=False) sc_df = self.summary[values].sort_values(lcoe) sc_df['cumulative_capacity'] = ( - sc_df[SupplyCurveField.CAPACITY].cumsum() + sc_df[SupplyCurveField.CAPACITY_AC_MW].cumsum() ) return sc_df @@ -800,11 +800,11 @@ def _extract_sc_data(self, lcoe=SupplyCurveField.MEAN_LCOE): sc_df : pandas.DataFrame Supply curve data """ - values = [SupplyCurveField.CAPACITY, lcoe] + values = [SupplyCurveField.CAPACITY_AC_MW, lcoe] self._check_value(self.sc_table, values, scatter=False) sc_df = self.sc_table[values].sort_values(lcoe) sc_df['cumulative_capacity'] = ( - sc_df[SupplyCurveField.CAPACITY].cumsum() + sc_df[SupplyCurveField.CAPACITY_AC_MW].cumsum() ) return sc_df diff --git a/reV/rep_profiles/rep_profiles.py b/reV/rep_profiles/rep_profiles.py index 9b12ee726..27704d6d6 100644 --- a/reV/rep_profiles/rep_profiles.py +++ b/reV/rep_profiles/rep_profiles.py @@ -953,7 +953,7 @@ class RepProfiles(RepProfilesBase): def __init__(self, gen_fpath, rev_summary, reg_cols, cf_dset='cf_profile', rep_method='meanoid', err_method='rmse', - weight=SupplyCurveField.GID_COUNTS, + weight=str(SupplyCurveField.GID_COUNTS), # str() to fix docs n_profiles=1, aggregate_profiles=False): """ReV rep profiles class. diff --git a/reV/supply_curve/exclusions.py b/reV/supply_curve/exclusions.py index 9055a0ad6..e47d7af7f 100644 --- a/reV/supply_curve/exclusions.py +++ b/reV/supply_curve/exclusions.py @@ -111,7 +111,7 @@ def __init__(self, layer, 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: + way:: input_dict = { "viewsheds": { diff --git a/reV/supply_curve/extent.py b/reV/supply_curve/extent.py index 9154e2cb1..b1d148710 100644 --- a/reV/supply_curve/extent.py +++ b/reV/supply_curve/extent.py @@ -391,7 +391,7 @@ def points(self): } ) - self._points.index.name = SupplyCurveField.GID # sc_point_gid + self._points.index.name = "gid" # sc_point_gid return self._points diff --git a/reV/supply_curve/points.py b/reV/supply_curve/points.py index a5c667193..8f1c801f6 100644 --- a/reV/supply_curve/points.py +++ b/reV/supply_curve/points.py @@ -51,9 +51,9 @@ def __init__(self, gid, exclusion_shape, resolution=64): self._gid = gid self._resolution = resolution - self._rows, self._cols = self._parse_slices( - gid, resolution, exclusion_shape - ) + self._rows = self._cols = self._sc_row_ind = self._sc_col_ind = None + self._parse_sc_row_col_ind(resolution, exclusion_shape) + self._parse_slices(resolution, exclusion_shape) @staticmethod def _ordered_unique(seq): @@ -74,32 +74,33 @@ def _ordered_unique(seq): return [x for x in seq if not (x in seen or seen.add(x))] - def _parse_slices(self, gid, resolution, exclusion_shape): - """Parse inputs for the definition of this SC point. + def _parse_sc_row_col_ind(self, resolution, exclusion_shape): + """Parse SC row and column index. Parameters ---------- - gid : int | None - gid for supply curve point to analyze. resolution : int | None SC resolution, must be input in combination with gid. exclusion_shape : tuple - Shape of the exclusions extent (rows, cols). Inputing this will - speed things up considerably. - - Returns - ------- - rows : slice - Row slice to index the high-res layer (exclusions) for the gid in - the agg layer (supply curve). - cols : slice - Col slice to index the high-res layer (exclusions) for the gid in - the agg layer (supply curve). + Shape of the exclusions extent (rows, cols). """ + n_sc_cols = int(np.ceil(exclusion_shape[1] / resolution)) + + self._sc_row_ind = self._gid // n_sc_cols + self._sc_col_ind = self._gid % n_sc_cols - rows, cols = self.get_agg_slices(gid, exclusion_shape, resolution) + def _parse_slices(self, resolution, exclusion_shape): + """Parse row and column resource/generation grid slices. - return rows, cols + Parameters + ---------- + resolution : int | None + SC resolution, must be input in combination with gid. + exclusion_shape : tuple + Shape of the exclusions extent (rows, cols). + """ + inds = self.get_agg_slices(self._gid, exclusion_shape, resolution) + self._rows, self._cols = inds @property def gid(self): @@ -117,6 +118,16 @@ def sc_point_gid(self): """ return self._gid + @property + def sc_row_ind(self): + """int: Supply curve row index""" + return self._sc_row_ind + + @property + def sc_col_ind(self): + """int: Supply curve column index""" + return self._sc_col_ind + @property def resolution(self): """Get the supply curve grid aggregation resolution""" @@ -1467,7 +1478,11 @@ def __init__( (resource) "gid" and "power_density columns". cf_dset : str | np.ndarray Dataset name from gen containing capacity factor mean values. - Can be pre-extracted generation output data in np.ndarray. + This name is used to infer AC capacity factor dataset for + solar runs (i.e. the AC vsersion of "cf_mean-means" would + be inferred to be "cf_mean_ac-means"). This input can also + be pre-extracted generation output data in np.ndarray, in + which case all DC solar outputs are set to `None`. lcoe_dset : str | np.ndarray Dataset name from gen containing LCOE mean values. Can be pre-extracted generation output data in np.ndarray. @@ -1489,7 +1504,7 @@ def __init__( recalc_lcoe : bool Flag to re-calculate the LCOE from the multi-year mean capacity factor and annual energy production data. This requires several - datasets to be aggregated in the h5_dsets input: system_capacity, + datasets to be aggregated in the gen input: system_capacity, fixed_charge_rate, capital_cost, fixed_operating_cost, and variable_operating_cost. apply_exclusions : bool @@ -1510,6 +1525,8 @@ def __init__( self._power_density = self._power_density_ac = power_density self._friction_layer = friction_layer self._recalc_lcoe = recalc_lcoe + self._ssc = None + self._slk = {} super().__init__( gid, @@ -1684,6 +1701,30 @@ def gen_data(self): return self._gen_data + @property + def gen_ac_data(self): + """Get the generation ac capacity factor data array. + + This output is only not `None` for solar runs where `cf_dset` + was specified as a string. + + Returns + ------- + gen_ac_data : np.ndarray | None + Multi-year-mean ac capacity factor data array for all sites + in the generation data output file or `None` if none + detected. + """ + + if isinstance(self._cf_dset, np.ndarray): + return None + + ac_cf_dset = _infer_cf_dset_ac(self._cf_dset) + if ac_cf_dset in self.gen.datasets: + return self.gen[ac_cf_dset] + + return None + @property def lcoe_data(self): """Get the LCOE data array. @@ -1710,6 +1751,11 @@ def mean_cf(self): factor is weighted by the exclusions (usually 0 or 1, but 0.5 exclusions will weight appropriately). + This value represents DC capacity factor for solar and AC + capacity factor for all other technologies. This is the capacity + factor that should be used for all cost calculations for ALL + technologies (to align with SAM). + Returns ------- mean_cf : float | None @@ -1721,6 +1767,45 @@ def mean_cf(self): return mean_cf + @property + def mean_cf_ac(self): + """Get the mean AC capacity factor for the non-excluded data. + + This output is only not `None` for solar runs. + + Capacity factor is weighted by the exclusions (usually 0 or 1, + but 0.5 exclusions will weight appropriately). + + Returns + ------- + mean_cf_ac : float | None + Mean capacity factor value for the non-excluded data. + """ + mean_cf_ac = None + if self.gen_ac_data is not None: + mean_cf_ac = self.exclusion_weighted_mean(self.gen_ac_data) + + return mean_cf_ac + + @property + def mean_cf_dc(self): + """Get the mean DC capacity factor for the non-excluded data. + + This output is only not `None` for solar runs. + + Capacity factor is weighted by the exclusions (usually 0 or 1, + but 0.5 exclusions will weight appropriately). + + Returns + ------- + mean_cf_dc : float | None + Mean capacity factor value for the non-excluded data. + """ + if self.mean_cf_ac is not None: + return self.mean_cf + + return None + @property def mean_lcoe(self): """Get the mean LCOE for the non-excluded data. @@ -1738,18 +1823,12 @@ def mean_lcoe(self): # year CF, but the output should be identical to the original LCOE and # so is not consequential). if self._recalc_lcoe: - required = ( - "fixed_charge_rate", - "capital_cost", - "fixed_operating_cost", - "variable_operating_cost", - "system_capacity", - ) - if self.mean_h5_dsets_data is not None and all( - k in self.mean_h5_dsets_data for k in required - ): + required = ("fixed_charge_rate", "capital_cost", + "fixed_operating_cost", "variable_operating_cost", + "system_capacity") + if all(self._sam_lcoe_kwargs.get(k) is not None for k in required): aep = ( - self.mean_h5_dsets_data["system_capacity"] + self._sam_lcoe_kwargs["system_capacity"] * self.mean_cf * 8760 ) @@ -1757,11 +1836,11 @@ def mean_lcoe(self): # `system_capacity`, so no need to scale `capital_cost` # or `fixed_operating_cost` by anything mean_lcoe = lcoe_fcr( - self.mean_h5_dsets_data["fixed_charge_rate"], - self.mean_h5_dsets_data["capital_cost"], - self.mean_h5_dsets_data["fixed_operating_cost"], + self._sam_lcoe_kwargs["fixed_charge_rate"], + self._sam_lcoe_kwargs["capital_cost"], + self._sam_lcoe_kwargs["fixed_operating_cost"], aep, - self.mean_h5_dsets_data["variable_operating_cost"], + self._sam_lcoe_kwargs["variable_operating_cost"], ) # alternative if lcoe was not able to be re-calculated from @@ -1943,6 +2022,11 @@ def capacity(self): """Get the estimated capacity in MW of the supply curve point in the current resource class with the applied exclusions. + This value represents DC capacity for solar and AC capacity for + all other technologies. This is the capacity that should be used + for all cost calculations for ALL technologies (to align with + SAM). + Returns ------- capacity : float @@ -1961,7 +2045,7 @@ def capacity_ac(self): """Get the AC estimated capacity in MW of the supply curve point in the current resource class with the applied exclusions. - This values is provided only for solar inputs that have + This value is provided only for solar inputs that have the "dc_ac_ratio" dataset in the generation file. If these conditions are not met, this value is `None`. @@ -1979,59 +2063,26 @@ def capacity_ac(self): return self.area * self.power_density_ac @property - def sc_point_capital_cost(self): - """Get the capital cost for the entire SC point. - - This method scales the capital cost based on the included-area - capacity. The calculation requires 'capital_cost' and - 'system_capacity' in the generation file and passed through as - `h5_dsets`, otherwise it returns `None`. - - Returns - ------- - sc_point_capital_cost : float | None - Total supply curve point capital cost ($). - """ - if self.mean_h5_dsets_data is None: - return None - - required = ("capital_cost", "system_capacity") - if not all(k in self.mean_h5_dsets_data for k in required): - return None - - cap_cost_per_mw = ( - self.mean_h5_dsets_data["capital_cost"] - / self.mean_h5_dsets_data["system_capacity"] - ) - return cap_cost_per_mw * self.capacity - - @property - def sc_point_fixed_operating_cost(self): - """Get the fixed operating cost for the entire SC point. + def capacity_dc(self): + """Get the DC estimated capacity in MW of the supply curve point + in the current resource class with the applied exclusions. - This method scales the fixed operating cost based on the - included-area capacity. The calculation requires - 'fixed_operating_cost' and 'system_capacity' in the generation - file and passed through as `h5_dsets`, otherwise it returns - `None`. + This value is provided only for solar inputs that have + the "dc_ac_ratio" dataset in the generation file. If these + conditions are not met, this value is `None`. Returns ------- - sc_point_fixed_operating_cost : float | None - Total supply curve point fixed operating cost ($). + capacity : float | None + Estimated AC capacity in MW of the supply curve point in the + current resource class with the applied exclusions. Only not + `None` for solar runs with "dc_ac_ratio" dataset in the + generation file """ - if self.mean_h5_dsets_data is None: - return None - - required = ("fixed_operating_cost", "system_capacity") - if not all(k in self.mean_h5_dsets_data for k in required): + if self.power_density_ac is None: return None - fixed_cost_per_mw = ( - self.mean_h5_dsets_data["fixed_operating_cost"] - / self.mean_h5_dsets_data["system_capacity"] - ) - return fixed_cost_per_mw * self.capacity + return self.area * self.power_density @property def sc_point_annual_energy(self): @@ -2051,25 +2102,6 @@ def sc_point_annual_energy(self): return self.mean_cf * self.capacity * 8760 - @property - def sc_point_annual_energy_ac(self): - """Get the total AC annual energy (MWh) for the entire SC point. - - This value is computed using the AC capacity of the supply curve - point as well as the mean capacity factor. If either the mean - capacity factor or the AC capacity value is `None`, this value - will also be `None`. - - Returns - ------- - sc_point_annual_energy_ac : float | None - Total AC annual energy (MWh) for the entire SC point. - """ - if self.mean_cf is None or self.capacity_ac is None: - return None - - return self.mean_cf * self.capacity_ac * 8760 - @property def h5_dsets_data(self): """Get any additional/supplemental h5 dataset data to summarize. @@ -2104,6 +2136,72 @@ def h5_dsets_data(self): return _h5_dsets_data + @property + def regional_multiplier(self): + """float: Mean regional capital cost multiplier, defaults to 1.""" + if "capital_cost_multiplier" not in self.gen.datasets: + return 1 + + multipliers = self.gen["capital_cost_multiplier"] + return self.exclusion_weighted_mean(multipliers) + + @property + def fixed_charge_rate(self): + """float: Mean fixed_charge_rate, defaults to 0.""" + if "fixed_charge_rate" not in self.gen.datasets: + return 0 + + return self.exclusion_weighted_mean(self.gen["fixed_charge_rate"]) + + @property + def _sam_system_capacity(self): + """float: Mean SAM generation system capacity input, defaults to 0. """ + if self._ssc is not None: + return self._ssc + + self._ssc = 0 + if "system_capacity" in self.gen.datasets: + self._ssc = self.exclusion_weighted_mean( + self.gen["system_capacity"] + ) + + return self._ssc + + @property + def _sam_lcoe_kwargs(self): + """dict: Mean LCOE inputs, as passed to SAM during generation.""" + if self._slk: + return self._slk + + self._slk = {"capital_cost": None, "fixed_operating_cost": None, + "variable_operating_cost": None, + "fixed_charge_rate": None, "system_capacity": None} + + for dset in self._slk: + if dset in self.gen.datasets: + self._slk[dset] = self.exclusion_weighted_mean( + self.gen[dset] + ) + + return self._slk + + def _compute_cost_per_ac_mw(self, dset): + """Compute a cost per AC MW for a given input. """ + if self._sam_system_capacity <= 0: + return 0 + + if dset not in self.gen.datasets: + return 0 + + sam_cost = self.exclusion_weighted_mean(self.gen[dset]) + sam_cost_per_mw = sam_cost / self._sam_system_capacity + sc_point_cost = sam_cost_per_mw * self.capacity + + ac_cap = (self.capacity + if self.capacity_ac is None + else self.capacity_ac) + return sc_point_cost / ac_cap + @property def mean_h5_dsets_data(self): """Get the mean supplemental h5 datasets data (optional) @@ -2194,39 +2292,55 @@ def point_summary(self, args=None): ARGS = { SupplyCurveField.LATITUDE: self.latitude, SupplyCurveField.LONGITUDE: self.longitude, - SupplyCurveField.TIMEZONE: self.timezone, SupplyCurveField.COUNTRY: self.country, SupplyCurveField.STATE: self.state, SupplyCurveField.COUNTY: self.county, SupplyCurveField.ELEVATION: self.elevation, + SupplyCurveField.TIMEZONE: self.timezone, + SupplyCurveField.SC_POINT_GID: self.sc_point_gid, + SupplyCurveField.SC_ROW_IND: self.sc_row_ind, + SupplyCurveField.SC_COL_IND: self.sc_col_ind, SupplyCurveField.RES_GIDS: self.res_gid_set, SupplyCurveField.GEN_GIDS: self.gen_gid_set, SupplyCurveField.GID_COUNTS: self.gid_counts, SupplyCurveField.N_GIDS: self.n_gids, - SupplyCurveField.MEAN_CF: self.mean_cf, + SupplyCurveField.OFFSHORE: self.offshore, + SupplyCurveField.MEAN_CF_AC: ( + self.mean_cf if self.mean_cf_ac is None else self.mean_cf_ac + ), + SupplyCurveField.MEAN_CF_DC: self.mean_cf_dc, SupplyCurveField.MEAN_LCOE: self.mean_lcoe, SupplyCurveField.MEAN_RES: self.mean_res, - SupplyCurveField.CAPACITY: self.capacity, SupplyCurveField.AREA_SQ_KM: self.area, - } - - extra_atts = { - SupplyCurveField.CAPACITY_AC: self.capacity_ac, - SupplyCurveField.OFFSHORE: self.offshore, - SupplyCurveField.SC_POINT_CAPITAL_COST: self.sc_point_capital_cost, - SupplyCurveField.SC_POINT_FIXED_OPERATING_COST: ( - self.sc_point_fixed_operating_cost + SupplyCurveField.CAPACITY_AC_MW: ( + self.capacity if self.capacity_ac is None else self.capacity_ac ), - SupplyCurveField.SC_POINT_ANNUAL_ENERGY: ( + SupplyCurveField.CAPACITY_DC_MW: self.capacity_dc, + SupplyCurveField.EOS_MULT: 1, # added later + SupplyCurveField.REG_MULT: self.regional_multiplier, + SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW: ( self.sc_point_annual_energy ), - SupplyCurveField.SC_POINT_ANNUAL_ENERGY_AC: ( - self.sc_point_annual_energy_ac + SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW: ( + self._compute_cost_per_ac_mw("capital_cost") + ), + SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW: ( + self._compute_cost_per_ac_mw("base_capital_cost") + ), + SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW: ( + self._compute_cost_per_ac_mw("fixed_operating_cost") + ), + SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW: ( + self._compute_cost_per_ac_mw("base_fixed_operating_cost") + ), + SupplyCurveField.COST_SITE_VOC_USD_PER_AC_MW: ( + self._compute_cost_per_ac_mw("variable_operating_cost") + ), + SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW: ( + self._compute_cost_per_ac_mw("base_variable_operating_cost") ), + SupplyCurveField.FIXED_CHARGE_RATE: self.fixed_charge_rate, } - for attr, value in extra_atts.items(): - if value is not None: - ARGS[attr] = value if self._friction_layer is not None: ARGS[SupplyCurveField.MEAN_FRICTION] = self.mean_friction @@ -2276,17 +2390,11 @@ def economies_of_scale(cap_cost_scale, summary): eos = EconomiesOfScale(cap_cost_scale, summary) summary[SupplyCurveField.RAW_LCOE] = eos.raw_lcoe summary[SupplyCurveField.MEAN_LCOE] = eos.scaled_lcoe - summary[SupplyCurveField.CAPITAL_COST_SCALAR] = eos.capital_cost_scalar - summary[SupplyCurveField.SCALED_CAPITAL_COST] = eos.scaled_capital_cost - if SupplyCurveField.SC_POINT_CAPITAL_COST in summary: - scaled_costs = ( - summary[SupplyCurveField.SC_POINT_CAPITAL_COST] - * eos.capital_cost_scalar - ) - summary[SupplyCurveField.SCALED_SC_POINT_CAPITAL_COST] = ( - scaled_costs - ) - + summary[SupplyCurveField.EOS_MULT] = eos.capital_cost_scalar + summary[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] = ( + summary[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] + * summary[SupplyCurveField.EOS_MULT] + ) return summary @classmethod @@ -2392,7 +2500,7 @@ def summarize( recalc_lcoe : bool Flag to re-calculate the LCOE from the multi-year mean capacity factor and annual energy production data. This requires several - datasets to be aggregated in the h5_dsets input: system_capacity, + datasets to be aggregated in the gen input: system_capacity, fixed_charge_rate, capital_cost, fixed_operating_cost, and variable_operating_cost. @@ -2430,3 +2538,13 @@ def summarize( summary = point.economies_of_scale(cap_cost_scale, summary) return summary + + +def _infer_cf_dset_ac(cf_dset): + """Infer AC dataset name from input. """ + parts = cf_dset.split("-") + if len(parts) == 1: + return f"{cf_dset}_ac" + + cf_name = "-".join(parts[:-1]) + return f"{cf_name}_ac-{parts[-1]}" diff --git a/reV/supply_curve/sc_aggregation.py b/reV/supply_curve/sc_aggregation.py index 59b297ba8..1b40b63f5 100644 --- a/reV/supply_curve/sc_aggregation.py +++ b/reV/supply_curve/sc_aggregation.py @@ -28,7 +28,7 @@ from reV.supply_curve.exclusions import FrictionMask from reV.supply_curve.extent import SupplyCurveExtent from reV.supply_curve.points import GenerationSupplyCurvePoint -from reV.utilities import SupplyCurveField, log_versions +from reV.utilities import ResourceMetaField, SupplyCurveField, log_versions from reV.utilities.exceptions import ( EmptySupplyCurvePointError, FileInputError, @@ -170,14 +170,14 @@ def _parse_power_density(self): if self._pdf.endswith(".csv"): self._power_density = pd.read_csv(self._pdf) - if (SupplyCurveField.GID in self._power_density + if (ResourceMetaField.GID in self._power_density and 'power_density' in self._power_density): self._power_density = \ - self._power_density.set_index(SupplyCurveField.GID) + self._power_density.set_index(ResourceMetaField.GID) else: msg = ('Variable power density file must include "{}" ' 'and "power_density" columns, but received: {}' - .format(SupplyCurveField.GID, + .format(ResourceMetaField.GID, self._power_density.columns.values)) logger.error(msg) raise FileInputError(msg) @@ -712,13 +712,6 @@ def __init__(self, excl_fpath, tm_dset, econ_fpath=None, logger.debug("Resource class bins: {}".format(self._res_class_bins)) - if self._cap_cost_scale is not None: - if self._h5_dsets is None: - self._h5_dsets = [] - - self._h5_dsets += list(BaseGen.LCOE_ARGS) - self._h5_dsets = list(set(self._h5_dsets)) - if self._power_density is None: msg = ( "Supply curve aggregation power density not specified. " @@ -784,7 +777,7 @@ def _check_data_layers( @staticmethod def _get_res_gen_lcoe_data( - gen, res_class_dset, res_class_bins, cf_dset, lcoe_dset + gen, res_class_dset, res_class_bins, lcoe_dset ): """Extract the basic resource / generation / lcoe data to be used in the aggregation process. @@ -800,8 +793,6 @@ def _get_res_gen_lcoe_data( res_class_bins : list | None List of two-entry lists dictating the resource class bins. None if no resource classes. - cf_dset : str - Dataset name from f_gen containing capacity factor mean values. lcoe_dset : str Dataset name from f_gen containing LCOE mean values. @@ -811,16 +802,14 @@ def _get_res_gen_lcoe_data( Extracted resource data from res_class_dset res_class_bins : list List of resouce class bin ranges. - cf_data : np.ndarray | None - Capacity factor data extracted from cf_dset in gen lcoe_data : np.ndarray | None LCOE data extracted from lcoe_dset in gen """ - dset_list = (res_class_dset, cf_dset, lcoe_dset) + dset_list = (res_class_dset, lcoe_dset) gen_dsets = [] if gen is None else gen.datasets - labels = ("res_class_dset", "cf_dset", "lcoe_dset") - temp = [None, None, None] + labels = ("res_class_dset", "lcoe_dset") + temp = [None, None] if isinstance(gen, Resource): source_fps = [gen.h5_file] @@ -847,12 +836,12 @@ def _get_res_gen_lcoe_data( logger.warning(w) warn(w, OutputWarning) - res_data, cf_data, lcoe_data = temp + res_data, lcoe_data = temp if res_class_dset is None or res_class_bins is None: res_class_bins = [None] - return res_data, res_class_bins, cf_data, lcoe_data + return res_data, res_class_bins, lcoe_data @staticmethod def _get_extra_dsets(gen, h5_dsets): @@ -886,7 +875,6 @@ def _get_extra_dsets(gen, h5_dsets): 'system_capacity') missing_lcoe_source = [k for k in lcoe_recalc_req if k not in gen_dsets] - missing_lcoe_request = [] if isinstance(gen, Resource): source_fps = [gen.h5_file] @@ -901,9 +889,6 @@ def _get_extra_dsets(gen, h5_dsets): h5_dsets_data = None if h5_dsets is not None: - missing_lcoe_request = [ - k for k in lcoe_recalc_req if k not in h5_dsets - ] if not isinstance(h5_dsets, (list, tuple)): e = ( @@ -937,17 +922,6 @@ def _get_extra_dsets(gen, h5_dsets): logger.warning(msg) warn(msg, InputWarning) - if any(missing_lcoe_request): - msg = ( - "It is strongly advised that you include the following " - "datasets in the h5_dsets request in order to re-calculate " - "the LCOE from the multi-year mean CF and AEP: {}".format( - missing_lcoe_request - ) - ) - logger.warning(msg) - warn(msg, InputWarning) - return h5_dsets_data @classmethod @@ -1079,7 +1053,6 @@ def run_serial( summary = [] with SupplyCurveExtent(excl_fpath, resolution=resolution) as sc: - points = sc.points exclusion_shape = sc.exclusions.shape if gids is None: gids = sc.valid_sc_points(tm_dset) @@ -1110,9 +1083,9 @@ def run_serial( excl_fpath, gen_fpath, **file_kwargs ) as fh: temp = cls._get_res_gen_lcoe_data( - fh.gen, res_class_dset, res_class_bins, cf_dset, lcoe_dset + fh.gen, res_class_dset, res_class_bins, lcoe_dset ) - res_data, res_class_bins, cf_data, lcoe_data = temp + res_data, res_class_bins, lcoe_data = temp h5_dsets_data = cls._get_extra_dsets(fh.gen, h5_dsets) n_finished = 0 @@ -1131,7 +1104,7 @@ def run_serial( gen_index, res_class_dset=res_data, res_class_bin=res_bin, - cf_dset=cf_data, + cf_dset=cf_dset, lcoe_dset=lcoe_data, h5_dsets=h5_dsets_data, data_layers=fh.data_layers, @@ -1151,11 +1124,6 @@ def run_serial( except EmptySupplyCurvePointError: logger.debug("SC point {} is empty".format(gid)) else: - pointsum[SupplyCurveField.SC_POINT_GID] = gid - pointsum[SupplyCurveField.SC_ROW_IND] = \ - points.loc[gid, 'row_ind'] - pointsum[SupplyCurveField.SC_COL_IND] = \ - points.loc[gid, 'col_ind'] pointsum['res_class'] = ri summary.append(pointsum) diff --git a/reV/supply_curve/supply_curve.py b/reV/supply_curve/supply_curve.py index e7c361725..8c1d3f415 100644 --- a/reV/supply_curve/supply_curve.py +++ b/reV/supply_curve/supply_curve.py @@ -7,6 +7,7 @@ import json import logging import os +from itertools import chain from copy import deepcopy from warnings import warn @@ -24,11 +25,38 @@ logger = logging.getLogger(__name__) +# map is column name to relative order in which it should appear in output file +_REQUIRED_COMPUTE_AND_OUTPUT_COLS = { + SupplyCurveField.TRANS_GID: 0, + SupplyCurveField.TRANS_TYPE: 1, + SupplyCurveField.N_PARALLEL_TRANS: 2, + SupplyCurveField.DIST_SPUR_KM: 3, + SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW: 10, + SupplyCurveField.LCOT: 11, + SupplyCurveField.TOTAL_LCOE: 12, +} +_REQUIRED_OUTPUT_COLS = {SupplyCurveField.DIST_EXPORT_KM: 4, + SupplyCurveField.REINFORCEMENT_DIST_KM: 5, + SupplyCurveField.TIE_LINE_COST_PER_MW: 6, + SupplyCurveField.CONNECTION_COST_PER_MW: 7, + SupplyCurveField.EXPORT_COST_PER_MW: 8, + SupplyCurveField.REINFORCEMENT_COST_PER_MW: 9, + SupplyCurveField.POI_LAT: 13, + SupplyCurveField.POI_LON: 14, + SupplyCurveField.REINFORCEMENT_POI_LAT: 15, + SupplyCurveField.REINFORCEMENT_POI_LON: 16} +DEFAULT_COLUMNS = tuple(str(field) + for field in chain(_REQUIRED_COMPUTE_AND_OUTPUT_COLS, + _REQUIRED_OUTPUT_COLS)) +"""Default output columns from supply chain computation (not ordered)""" + + class SupplyCurve: """SupplyCurve""" def __init__(self, sc_points, trans_table, sc_features=None, - sc_capacity_col=SupplyCurveField.CAPACITY): + # str() to fix docs + sc_capacity_col=str(SupplyCurveField.CAPACITY_AC_MW)): """ReV LCOT calculation and SupplyCurve sorting class. ``reV`` supply curve computes the transmission costs associated @@ -282,15 +310,19 @@ def _parse_trans_table(trans_table): # also xformer_cost_p_mw -> xformer_cost_per_mw (not sure why there # would be a *_p_mw but here we are...) rename_map = { - "trans_line_gid": "trans_gid", + "trans_line_gid": SupplyCurveField.TRANS_GID, "trans_gids": "trans_line_gids", "xformer_cost_p_mw": "xformer_cost_per_mw", } trans_table = trans_table.rename(columns=rename_map) - if "dist_mi" in trans_table and "dist_km" not in trans_table: - trans_table = trans_table.rename(columns={"dist_mi": "dist_km"}) - trans_table["dist_km"] *= 1.60934 + contains_dist_in_miles = "dist_mi" in trans_table + missing_km_dist = SupplyCurveField.DIST_SPUR_KM not in trans_table + if contains_dist_in_miles and missing_km_dist: + trans_table = trans_table.rename( + columns={"dist_mi": SupplyCurveField.DIST_SPUR_KM} + ) + trans_table[SupplyCurveField.DIST_SPUR_KM] *= 1.60934 drop_cols = [SupplyCurveField.SC_GID, 'cap_left', SupplyCurveField.SC_POINT_GID] @@ -302,7 +334,7 @@ def _parse_trans_table(trans_table): @staticmethod def _map_trans_capacity(trans_sc_table, - sc_capacity_col=SupplyCurveField.CAPACITY): + sc_capacity_col=SupplyCurveField.CAPACITY_AC_MW): """ Map SC gids to transmission features based on capacity. For any SC gids with capacity > the maximum transmission feature capacity, map @@ -331,7 +363,7 @@ def _map_trans_capacity(trans_sc_table, nx = trans_sc_table[sc_capacity_col] / trans_sc_table["max_cap"] nx = np.ceil(nx).astype(int) - trans_sc_table["n_parallel_trans"] = nx + trans_sc_table[SupplyCurveField.N_PARALLEL_TRANS] = nx if (nx > 1).any(): mask = nx > 1 @@ -409,11 +441,12 @@ def _check_sub_trans_lines(cls, features): """ features = features.rename( columns={ - "trans_line_gid": "trans_gid", + "trans_line_gid": SupplyCurveField.TRANS_GID, "trans_gids": "trans_line_gids", } ) - mask = features["category"].str.lower() == "substation" + mask = (features[SupplyCurveField.TRANS_TYPE].str.casefold() + == "substation") if not any(mask): return [] @@ -424,7 +457,7 @@ def _check_sub_trans_lines(cls, features): line_gids = np.unique(np.concatenate(line_gids.values)) - test = np.isin(line_gids, features["trans_gid"].values) + test = np.isin(line_gids, features[SupplyCurveField.TRANS_GID].values) return line_gids[~test].tolist() @@ -513,10 +546,11 @@ def _check_sc_trans_table(cls, sc_points, trans_table): @classmethod def _merge_sc_trans_tables(cls, sc_points, trans_table, sc_cols=(SupplyCurveField.SC_GID, - SupplyCurveField.CAPACITY, - SupplyCurveField.MEAN_CF, + SupplyCurveField.CAPACITY_AC_MW, + SupplyCurveField.MEAN_CF_AC, SupplyCurveField.MEAN_LCOE), - sc_capacity_col=SupplyCurveField.CAPACITY): + sc_capacity_col=SupplyCurveField.CAPACITY_AC_MW + ): """ Merge the supply curve table with the transmission features table. @@ -583,11 +617,13 @@ def _merge_sc_trans_tables(cls, sc_points, trans_table, if isinstance(sc_cols, tuple): sc_cols = list(sc_cols) - if SupplyCurveField.MEAN_LCOE_FRICTION in sc_points: - sc_cols.append(SupplyCurveField.MEAN_LCOE_FRICTION) - - if "transmission_multiplier" in sc_points: - sc_cols.append("transmission_multiplier") + extra_cols = [SupplyCurveField.CAPACITY_DC_MW, + SupplyCurveField.MEAN_CF_DC, + SupplyCurveField.MEAN_LCOE_FRICTION, + "transmission_multiplier"] + for col in extra_cols: + if col in sc_points: + sc_cols.append(col) sc_cols += merge_cols sc_points = sc_points[sc_cols].copy() @@ -600,10 +636,10 @@ def _merge_sc_trans_tables(cls, sc_points, trans_table, @classmethod def _map_tables(cls, sc_points, trans_table, sc_cols=(SupplyCurveField.SC_GID, - SupplyCurveField.CAPACITY, - SupplyCurveField.MEAN_CF, + SupplyCurveField.CAPACITY_AC_MW, + SupplyCurveField.MEAN_CF_AC, SupplyCurveField.MEAN_LCOE), - sc_capacity_col=SupplyCurveField.CAPACITY): + sc_capacity_col=SupplyCurveField.CAPACITY_AC_MW): """ Map supply curve points to transmission features @@ -618,8 +654,9 @@ def _map_tables(cls, sc_points, trans_table, sc_cols : tuple | list, optional List of column from sc_points to transfer into the trans table, If the `sc_capacity_col` is not included, it will get added. - by default (SupplyCurveField.SC_GID, SupplyCurveField.CAPACITY, - SupplyCurveField.MEAN_CF, SupplyCurveField.MEAN_LCOE) + by default (SupplyCurveField.SC_GID, + SupplyCurveField.CAPACITY_AC_MW, SupplyCurveField.MEAN_CF_AC, + SupplyCurveField.MEAN_LCOE) sc_capacity_col : str, optional Name of capacity column in `trans_sc_table`. The values in this column determine the size of transmission lines built. @@ -646,9 +683,9 @@ def _map_tables(cls, sc_points, trans_table, trans_sc_table, sc_capacity_col=scc ) - trans_sc_table = \ - trans_sc_table.sort_values( - [SupplyCurveField.SC_GID, 'trans_gid']).reset_index(drop=True) + sort_cols = [SupplyCurveField.SC_GID, SupplyCurveField.TRANS_GID] + trans_sc_table = trans_sc_table.sort_values(sort_cols) + trans_sc_table = trans_sc_table.reset_index(drop=True) cls._check_sc_trans_table(sc_points, trans_sc_table) @@ -718,7 +755,7 @@ def _parse_sc_gids(trans_table, gid_key=SupplyCurveField.SC_GID): @staticmethod def _get_capacity(sc_gid, sc_table, connectable=True, - sc_capacity_col=SupplyCurveField.CAPACITY): + sc_capacity_col=SupplyCurveField.CAPACITY_AC_MW): """ Get capacity of supply curve point @@ -767,7 +804,8 @@ def _get_capacity(sc_gid, sc_table, connectable=True, def _compute_trans_cap_cost(cls, trans_table, trans_costs=None, avail_cap_frac=1, max_workers=None, connectable=True, line_limited=False, - sc_capacity_col=SupplyCurveField.CAPACITY): + sc_capacity_col=( + SupplyCurveField.CAPACITY_AC_MW)): """ Compute levelized cost of transmission for all combinations of supply curve points and tranmission features in trans_table @@ -919,8 +957,9 @@ def compute_total_lcoe( Flag to consider friction layer on LCOE when "mean_lcoe_friction" is in the sc points input, by default True """ - if "trans_cap_cost_per_mw" in self._trans_table: - cost = self._trans_table["trans_cap_cost_per_mw"].values.copy() + tcc_per_mw_col = SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW + if tcc_per_mw_col in self._trans_table: + cost = self._trans_table[tcc_per_mw_col].values.copy() elif "trans_cap_cost" not in self._trans_table: scc = self._sc_capacity_col cost = self._compute_trans_cap_cost( @@ -932,17 +971,20 @@ def compute_total_lcoe( max_workers=max_workers, sc_capacity_col=scc, ) - self._trans_table["trans_cap_cost_per_mw"] = cost # $/MW + self._trans_table[tcc_per_mw_col] = cost # $/MW else: cost = self._trans_table["trans_cap_cost"].values.copy() # $ - cost /= self._trans_table[self._sc_capacity_col] # $/MW - self._trans_table["trans_cap_cost_per_mw"] = cost + cost /= self._trans_table[SupplyCurveField.CAPACITY_AC_MW] # $/MW + self._trans_table[tcc_per_mw_col] = cost - cost *= self._trans_table[self._sc_capacity_col] - # align with "mean_cf" - cost /= self._trans_table[SupplyCurveField.CAPACITY] - cf_mean_arr = self._trans_table[SupplyCurveField.MEAN_CF].values + self._trans_table[tcc_per_mw_col] = ( + self._trans_table[tcc_per_mw_col].astype("float32") + ) + cost = cost.astype("float32") + cf_mean_arr = self._trans_table[SupplyCurveField.MEAN_CF_AC] + cf_mean_arr = cf_mean_arr.values.astype("float32") resource_lcoe = self._trans_table[SupplyCurveField.MEAN_LCOE] + resource_lcoe = resource_lcoe.values.astype("float32") if 'reinforcement_cost_floored_per_mw' in self._trans_table: logger.info("'reinforcement_cost_floored_per_mw' column found in " @@ -950,33 +992,30 @@ def compute_total_lcoe( "cost LCOE as sorting option.") fr_cost = (self._trans_table['reinforcement_cost_floored_per_mw'] .values.copy()) - fr_cost *= self._trans_table[self._sc_capacity_col] - # align with "mean_cf" - fr_cost /= self._trans_table[SupplyCurveField.CAPACITY] lcot_fr = ((cost + fr_cost) * fcr) / (cf_mean_arr * 8760) lcoe_fr = lcot_fr + resource_lcoe self._trans_table['lcot_floored_reinforcement'] = lcot_fr self._trans_table['lcoe_floored_reinforcement'] = lcoe_fr - if 'reinforcement_cost_per_mw' in self._trans_table: - logger.info("'reinforcement_cost_per_mw' column found in " - "transmission table. Adding reinforcement costs " - "to total LCOE.") + if SupplyCurveField.REINFORCEMENT_COST_PER_MW in self._trans_table: + logger.info("%s column found in transmission table. Adding " + "reinforcement costs to total LCOE.", + SupplyCurveField.REINFORCEMENT_COST_PER_MW) lcot_nr = (cost * fcr) / (cf_mean_arr * 8760) lcoe_nr = lcot_nr + resource_lcoe self._trans_table['lcot_no_reinforcement'] = lcot_nr self._trans_table['lcoe_no_reinforcement'] = lcoe_nr - r_cost = (self._trans_table['reinforcement_cost_per_mw'] - .values.copy()) - r_cost *= self._trans_table[self._sc_capacity_col] - # align with "mean_cf" - r_cost /= self._trans_table[SupplyCurveField.CAPACITY] + + col_name = SupplyCurveField.REINFORCEMENT_COST_PER_MW + r_cost = self._trans_table[col_name].astype("float32") + r_cost = r_cost.values.copy() + self._trans_table[tcc_per_mw_col] += r_cost cost += r_cost # $/MW lcot = (cost * fcr) / (cf_mean_arr * 8760) - self._trans_table['lcot'] = lcot - self._trans_table['total_lcoe'] = lcot + resource_lcoe + self._trans_table[SupplyCurveField.LCOT] = lcot + self._trans_table[SupplyCurveField.TOTAL_LCOE] = lcot + resource_lcoe if consider_friction: self._calculate_total_lcoe_friction() @@ -987,7 +1026,7 @@ def _calculate_total_lcoe_friction(self): if SupplyCurveField.MEAN_LCOE_FRICTION in self._trans_table: lcoe_friction = ( - self._trans_table['lcot'] + self._trans_table[SupplyCurveField.LCOT] + self._trans_table[SupplyCurveField.MEAN_LCOE_FRICTION]) self._trans_table[SupplyCurveField.TOTAL_LCOE_FRICTION] = ( lcoe_friction @@ -1077,6 +1116,7 @@ def add_sum_cols(table, sum_cols): return table + # pylint: disable=C901 def _full_sort( self, trans_table, @@ -1084,15 +1124,15 @@ def _full_sort( avail_cap_frac=1, comp_wind_dirs=None, total_lcoe_fric=None, - sort_on="total_lcoe", + sort_on=SupplyCurveField.TOTAL_LCOE, columns=( - "trans_gid", - "trans_capacity", - "trans_type", - "trans_cap_cost_per_mw", - "dist_km", - "lcot", - "total_lcoe", + SupplyCurveField.TRANS_GID, + SupplyCurveField.TRANS_CAPACITY, + SupplyCurveField.TRANS_TYPE, + SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW, + SupplyCurveField.DIST_SPUR_KM, + SupplyCurveField.LCOT, + SupplyCurveField.TOTAL_LCOE, ), downwind=False, ): @@ -1148,22 +1188,20 @@ def _full_sort( trans_sc_gids = trans_table[SupplyCurveField.SC_GID].values.astype(int) # syntax is final_key: source_key (source from trans_table) - all_cols = {k: k for k in columns} - essentials = { - "trans_gid": "trans_gid", - "trans_capacity": "avail_cap", - "trans_type": "category", - "dist_km": "dist_km", - "trans_cap_cost_per_mw": "trans_cap_cost_per_mw", - "lcot": "lcot", - "total_lcoe": "total_lcoe", - } - all_cols.update(essentials) + all_cols = list(columns) + essentials = [SupplyCurveField.TRANS_GID, + SupplyCurveField.TRANS_CAPACITY, + SupplyCurveField.TRANS_TYPE, + SupplyCurveField.DIST_SPUR_KM, + SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW, + SupplyCurveField.LCOT, + SupplyCurveField.TOTAL_LCOE] - arrays = { - final_key: trans_table[source_key].values - for final_key, source_key in all_cols.items() - } + for col in essentials: + if col not in all_cols: + all_cols.append(col) + + arrays = {col: trans_table[col].values for col in all_cols} sc_capacities = trans_table[self._sc_capacity_col].values @@ -1173,7 +1211,7 @@ def _full_sort( sc_gid = trans_sc_gids[i] if self._mask[sc_gid]: connect = trans_features.connect( - arrays["trans_gid"][i], sc_capacities[i] + arrays[SupplyCurveField.TRANS_GID][i], sc_capacities[i] ) if connect: connected += 1 @@ -1236,36 +1274,50 @@ def _check_feature_capacity(self, avail_cap_frac=1): Add the transmission connection feature capacity to the trans table if needed """ - if "avail_cap" not in self._trans_table: + if SupplyCurveField.TRANS_CAPACITY not in self._trans_table: kwargs = {"avail_cap_frac": avail_cap_frac} fc = TF.feature_capacity(self._trans_table, **kwargs) - self._trans_table = self._trans_table.merge(fc, on="trans_gid") + self._trans_table = self._trans_table.merge( + fc, on=SupplyCurveField.TRANS_GID) def _adjust_output_columns(self, columns, consider_friction): """Add extra output columns, if needed.""" - # These are essentially should-be-defaults that are not - # backwards-compatible, so have to explicitly check for them - extra_cols = ['ba_str', 'poi_lat', 'poi_lon', 'reinforcement_poi_lat', - 'reinforcement_poi_lon', SupplyCurveField.EOS_MULT, - SupplyCurveField.REG_MULT, - 'reinforcement_cost_per_mw', 'reinforcement_dist_km', - 'n_parallel_trans', SupplyCurveField.TOTAL_LCOE_FRICTION] - if not consider_friction: - extra_cols -= {SupplyCurveField.TOTAL_LCOE_FRICTION} - - extra_cols = [ - col - for col in extra_cols - if col in self._trans_table and col not in columns - ] - - return columns + extra_cols + + for col in _REQUIRED_COMPUTE_AND_OUTPUT_COLS: + if col not in columns: + columns.append(col) + + for col in _REQUIRED_OUTPUT_COLS: + if col not in self._trans_table: + self._trans_table[col] = None + if col not in columns: + columns.append(col) + + missing_cols = [col for col in columns if col not in self._trans_table] + if missing_cols: + msg = (f"The following requested columns are not found in " + f"transmission table: {missing_cols}.\nSkipping...") + logger.warning(msg) + warn(msg) + + columns = [col for col in columns if col in self._trans_table] + + fric_col = SupplyCurveField.TOTAL_LCOE_FRICTION + if consider_friction and fric_col in self._trans_table: + columns.append(fric_col) + + return sorted(columns, key=_column_sort_key) def _determine_sort_on(self, sort_on): """Determine the `sort_on` column from user input and trans table""" - if "reinforcement_cost_per_mw" in self._trans_table: + r_cost_col = SupplyCurveField.REINFORCEMENT_COST_PER_MW + found_reinforcement_costs = ( + r_cost_col in self._trans_table + and not self._trans_table[r_cost_col].isna().all() + ) + if found_reinforcement_costs: sort_on = sort_on or "lcoe_no_reinforcement" - return sort_on or "total_lcoe" + return sort_on or SupplyCurveField.TOTAL_LCOE def full_sort( self, @@ -1278,13 +1330,13 @@ def full_sort( consider_friction=True, sort_on=None, columns=( - "trans_gid", - "trans_capacity", - "trans_type", - "trans_cap_cost_per_mw", - "dist_km", - "lcot", - "total_lcoe", + SupplyCurveField.TRANS_GID, + SupplyCurveField.TRANS_CAPACITY, + SupplyCurveField.TRANS_TYPE, + SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW, + SupplyCurveField.DIST_SPUR_KM, + SupplyCurveField.LCOT, + SupplyCurveField.TOTAL_LCOE, ), wind_dirs=None, n_dirs=2, @@ -1365,8 +1417,10 @@ def full_sort( sort_on = self._determine_sort_on(sort_on) trans_table = self._trans_table.copy() - pos = trans_table["lcot"].isnull() - trans_table = trans_table.loc[~pos].sort_values([sort_on, "trans_gid"]) + pos = trans_table[SupplyCurveField.LCOT].isnull() + trans_table = trans_table.loc[~pos].sort_values( + [sort_on, SupplyCurveField.TRANS_GID] + ) total_lcoe_fric = None col_in_table = SupplyCurveField.MEAN_LCOE_FRICTION in trans_table @@ -1414,14 +1468,7 @@ def simple_sort( max_workers=None, consider_friction=True, sort_on=None, - columns=( - "trans_gid", - "trans_type", - "lcot", - "total_lcoe", - "dist_km", - "trans_cap_cost_per_mw", - ), + columns=DEFAULT_COLUMNS, wind_dirs=None, n_dirs=2, downwind=False, @@ -1460,9 +1507,8 @@ def simple_sort( will be built first, by default `None`, which will use total LCOE without any reinforcement costs as the sort value. columns : list | tuple, optional - Columns to preserve in output connections dataframe, - by default ('trans_gid', 'trans_capacity', 'trans_type', - 'trans_cap_cost_per_mw', 'dist_km', 'lcot', 'total_lcoe') + Columns to preserve in output connections dataframe. + By default, :obj:`DEFAULT_COLUMNS`. wind_dirs : pandas.DataFrame | str, optional path to .csv or reVX.wind_dirs.wind_dirs.WindDirs output with the neighboring supply curve point gids and power-rose value at @@ -1490,19 +1536,16 @@ def simple_sort( max_workers=max_workers, consider_friction=consider_friction, ) - trans_table = self._trans_table.copy() + sort_on = self._determine_sort_on(sort_on) if isinstance(columns, tuple): columns = list(columns) - columns = self._adjust_output_columns(columns, consider_friction) - sort_on = self._determine_sort_on(sort_on) - connections = trans_table.sort_values([sort_on, 'trans_gid']) + trans_table = self._trans_table.copy() + connections = trans_table.sort_values( + [sort_on, SupplyCurveField.TRANS_GID]) connections = connections.groupby(SupplyCurveField.SC_GID).first() - rename = {'trans_gid': 'trans_gid', - 'category': 'trans_type'} - connections = connections.rename(columns=rename) connections = connections[columns].reset_index() supply_curve = self._sc_points.merge(connections, @@ -1531,14 +1574,7 @@ def run( transmission_costs=None, consider_friction=True, sort_on=None, - columns=( - "trans_gid", - "trans_type", - "trans_cap_cost_per_mw", - "dist_km", - "lcot", - "total_lcoe", - ), + columns=DEFAULT_COLUMNS, max_workers=None, competition=None, ): @@ -1606,8 +1642,7 @@ def run( By default ``None``. columns : list | tuple, optional Columns to preserve in output supply curve dataframe. - By default, ``('trans_gid', 'trans_type', - 'trans_cap_cost_per_mw', 'dist_km', 'lcot', 'total_lcoe')``. + By default, :obj:`DEFAULT_COLUMNS`. max_workers : int, optional Number of workers to use to compute LCOT. If > 1, computation is run in parallel. If ``None``, computation @@ -1673,3 +1708,14 @@ def _format_sc_out_fpath(out_fpath): project_dir, out_fn = os.path.split(out_fpath) out_fn = out_fn.replace("supply_curve", "supply-curve") return os.path.join(project_dir, out_fn) + + +def _column_sort_key(col): + """Determine the sort order of the input column. """ + col_value = _REQUIRED_COMPUTE_AND_OUTPUT_COLS.get(col) + if col_value is None: + col_value = _REQUIRED_OUTPUT_COLS.get(col) + if col_value is None: + col_value = 1e6 + + return col_value, str(col) diff --git a/reV/utilities/__init__.py b/reV/utilities/__init__.py index 97afa5256..af648c072 100644 --- a/reV/utilities/__init__.py +++ b/reV/utilities/__init__.py @@ -112,56 +112,91 @@ class ResourceMetaField(FieldEnum): class SupplyCurveField(FieldEnum): """An enumerated map to supply curve summary/meta keys. - Each output name should match the name of a key in - meth:`AggregationSupplyCurvePoint.summary` or - meth:`GenerationSupplyCurvePoint.point_summary` or - meth:`BespokeSinglePlant.meta` + This is a collection of known supply curve fields that reV outputs + across aggregation, supply curve, and bespoke outputs. + + Not all of these columns are guaranteed in every supply-curve like + output (e.g. "convex_hull_area" is a bespoke-only output). """ - SC_POINT_GID = "sc_point_gid" - SOURCE_GIDS = "source_gids" SC_GID = "sc_gid" - GID_COUNTS = "gid_counts" - GID = "gid" - N_GIDS = "n_gids" - RES_GIDS = "res_gids" - GEN_GIDS = "gen_gids" - AREA_SQ_KM = "area_sq_km" LATITUDE = "latitude" LONGITUDE = "longitude" - ELEVATION = "elevation" - TIMEZONE = "timezone" - COUNTY = "county" - STATE = "state" COUNTRY = "country" - MEAN_CF = "mean_cf" - MEAN_LCOE = "mean_lcoe" - MEAN_RES = "mean_res" - CAPACITY = "capacity" - OFFSHORE = "offshore" + STATE = "state" + COUNTY = "county" + ELEVATION = "elevation_m" + TIMEZONE = "timezone" + SC_POINT_GID = "sc_point_gid" SC_ROW_IND = "sc_row_ind" SC_COL_IND = "sc_col_ind" - CAPACITY_AC = "capacity_ac" - CAPITAL_COST = "capital_cost" - FIXED_OPERATING_COST = "fixed_operating_cost" - VARIABLE_OPERATING_COST = "variable_operating_cost" + SOURCE_GIDS = "source_gids" + RES_GIDS = "res_gids" + GEN_GIDS = "gen_gids" + GID_COUNTS = "gid_counts" + N_GIDS = "n_gids" + MEAN_RES = "resource" + MEAN_CF_AC = "capacity_factor_ac" + MEAN_CF_DC = "capacity_factor_dc" + MEAN_LCOE = "lcoe_site_usd_per_mwh" + CAPACITY_AC_MW = "capacity_ac_mw" + CAPACITY_DC_MW = "capacity_dc_mw" + OFFSHORE = "offshore" + AREA_SQ_KM = "area_developable_sq_km" + MEAN_FRICTION = "friction_site" + MEAN_LCOE_FRICTION = "lcoe_friction_usd_per_mwh" + RAW_LCOE = "lcoe_raw_usd_per_mwh" + EOS_MULT = "multiplier_cc_eos" + REG_MULT = "multiplier_cc_regional" + SC_POINT_ANNUAL_ENERGY_MW = "annual_energy_site_mwh" + COST_BASE_OCC_USD_PER_AC_MW = "cost_base_occ_usd_per_ac_mw" + COST_SITE_OCC_USD_PER_AC_MW = "cost_site_occ_usd_per_ac_mw" + COST_BASE_FOC_USD_PER_AC_MW = "cost_base_foc_usd_per_ac_mw" + COST_SITE_FOC_USD_PER_AC_MW = "cost_site_foc_usd_per_ac_mw" + COST_BASE_VOC_USD_PER_AC_MW = "cost_base_voc_usd_per_ac_mw" + COST_SITE_VOC_USD_PER_AC_MW = "cost_site_voc_usd_per_ac_mw" FIXED_CHARGE_RATE = "fixed_charge_rate" - SC_POINT_CAPITAL_COST = "sc_point_capital_cost" - SC_POINT_FIXED_OPERATING_COST = "sc_point_fixed_operating_cost" - SC_POINT_ANNUAL_ENERGY = "sc_point_annual_energy" - SC_POINT_ANNUAL_ENERGY_AC = "sc_point_annual_energy_ac" - MEAN_FRICTION = "mean_friction" - MEAN_LCOE_FRICTION = "mean_lcoe_friction" - TOTAL_LCOE_FRICTION = "total_lcoe_friction" - RAW_LCOE = "raw_lcoe" - CAPITAL_COST_SCALAR = "capital_cost_scalar" - SCALED_CAPITAL_COST = "scaled_capital_cost" - SCALED_SC_POINT_CAPITAL_COST = "scaled_sc_point_capital_cost" + + # Bespoke outputs + POSSIBLE_X_COORDS = "possible_x_coords" + POSSIBLE_Y_COORDS = "possible_y_coords" TURBINE_X_COORDS = "turbine_x_coords" TURBINE_Y_COORDS = "turbine_y_coords" - EOS_MULT = "eos_mult" - REG_MULT = "reg_mult" - + N_TURBINES = "n_turbines" + INCLUDED_AREA = "area_included_sq_km" + INCLUDED_AREA_CAPACITY_DENSITY = ( + "capacity_density_included_area_mw_per_km2" + ) + CONVEX_HULL_AREA = "area_convex_hull_sq_km" + CONVEX_HULL_CAPACITY_DENSITY = "capacity_density_convex_hull_mw_per_km2" + FULL_CELL_CAPACITY_DENSITY = "capacity_density_full_cell_mw_per_km2" + BESPOKE_AEP = "optimized_plant_aep" + BESPOKE_OBJECTIVE = "optimized_plant_objective" + BESPOKE_CAPITAL_COST = "optimized_plant_capital_cost" + BESPOKE_FIXED_OPERATING_COST = "optimized_plant_fixed_operating_cost" + BESPOKE_VARIABLE_OPERATING_COST = "optimized_plant_variable_operating_cost" + BESPOKE_BALANCE_OF_SYSTEM_COST = "optimized_plant_balance_of_system_cost" + + # Transmission outputs + TRANS_GID = "trans_gid" + TRANS_TYPE = "trans_type" + TOTAL_LCOE_FRICTION = "lcoe_total_friction_usd_per_mwh" + TRANS_CAPACITY = "trans_capacity" + DIST_SPUR_KM = "dist_spur_km" + DIST_EXPORT_KM = "dist_export_km" + REINFORCEMENT_DIST_KM = "dist_reinforcement_km" + TIE_LINE_COST_PER_MW = "cost_spur_usd_per_mw" + CONNECTION_COST_PER_MW = "cost_poi_usd_per_mw" + EXPORT_COST_PER_MW = "cost_export_usd_per_mw" + REINFORCEMENT_COST_PER_MW = "cost_reinforcement_usd_per_mw" + TOTAL_TRANS_CAP_COST_PER_MW = "cost_total_trans_usd_per_mw" + LCOT = "lcot_usd_per_mwh" + TOTAL_LCOE = "lcoe_all_in_usd_per_mwh" + N_PARALLEL_TRANS = "count_num_parallel_trans" + POI_LAT = "latitude_poi" + POI_LON = "longitude_poi" + REINFORCEMENT_POI_LAT = "latitude_reinforcement_poi" + REINFORCEMENT_POI_LON = "longitude_reinforcement_poi" @classmethod def map_from_legacy(cls): @@ -190,6 +225,46 @@ class _LegacySCAliases(Enum): values where each string value represents a previously known alias. """ + ELEVATION = "elevation" + MEAN_RES = "mean_res" + MEAN_CF_AC = "mean_cf" + MEAN_LCOE = "mean_lcoe" + CAPACITY_AC_MW = "capacity" + AREA_SQ_KM = "area_sq_km" + MEAN_FRICTION = "mean_friction" + MEAN_LCOE_FRICTION = "mean_lcoe_friction" + RAW_LCOE = "raw_lcoe" + TRANS_TYPE = "category" + TRANS_CAPACITY = "avail_cap" + DIST_SPUR_KM = "dist_km" + REINFORCEMENT_DIST_KM = "reinforcement_dist_km" + TIE_LINE_COST_PER_MW = "tie_line_cost_per_mw" + CONNECTION_COST_PER_MW = "connection_cost_per_mw" + REINFORCEMENT_COST_PER_MW = "reinforcement_cost_per_mw" + TOTAL_TRANS_CAP_COST_PER_MW = "trans_cap_cost_per_mw" + LCOT = "lcot" + TOTAL_LCOE = "total_lcoe" + TOTAL_LCOE_FRICTION = "total_lcoe_friction" + N_PARALLEL_TRANS = "n_parallel_trans" + EOS_MULT = "eos_mult", "capital_cost_multiplier" + REG_MULT = "reg_mult" + SC_POINT_ANNUAL_ENERGY_MW = "sc_point_annual_energy" + POI_LAT = "poi_lat" + POI_LON = "poi_lon" + REINFORCEMENT_POI_LAT = "reinforcement_poi_lat" + REINFORCEMENT_POI_LON = "reinforcement_poi_lon" + BESPOKE_AEP = "bespoke_aep" + BESPOKE_OBJECTIVE = "bespoke_objective" + BESPOKE_CAPITAL_COST = "bespoke_capital_cost" + BESPOKE_FIXED_OPERATING_COST = "bespoke_fixed_operating_cost" + BESPOKE_VARIABLE_OPERATING_COST = "bespoke_variable_operating_cost" + BESPOKE_BALANCE_OF_SYSTEM_COST = "bespoke_balance_of_system_cost" + INCLUDED_AREA = "included_area" + INCLUDED_AREA_CAPACITY_DENSITY = "included_area_capacity_density" + CONVEX_HULL_AREA = "convex_hull_area" + CONVEX_HULL_CAPACITY_DENSITY = "convex_hull_capacity_density" + FULL_CELL_CAPACITY_DENSITY = "full_cell_capacity_density" + class ModuleName(str, Enum): """A collection of the module names available in reV. diff --git a/reV/version.py b/reV/version.py index b657e3872..706db7cfc 100644 --- a/reV/version.py +++ b/reV/version.py @@ -2,4 +2,4 @@ reV Version number """ -__version__ = "0.8.9" +__version__ = "0.9.0" diff --git a/requirements.txt b/requirements.txt index 5eb0a30d7..eea8e393b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ NREL-gaps>=0.6.11 NREL-NRWAL>=0.0.7 NREL-PySAM~=4.1.0 NREL-rex>=0.2.85 +numpy~=1.24.4 packaging>=20.3 plotly>=4.7.1 plotting>=0.0.6 diff --git a/tests/test_bespoke.py b/tests/test_bespoke.py index ddb83dbf1..9a9bb3da5 100644 --- a/tests/test_bespoke.py +++ b/tests/test_bespoke.py @@ -17,6 +17,7 @@ from rex import Resource from reV import TESTDATADIR +from reV.econ.utilities import lcoe_fcr from reV.bespoke.bespoke import BespokeSinglePlant, BespokeWindPlants from reV.bespoke.place_turbines import PlaceTurbines, _compute_nn_conn_dist from reV.cli import main @@ -84,6 +85,40 @@ "(0.0975 * capital_cost + fixed_operating_cost) " "/ aep + variable_operating_cost" ) +EXPECTED_META_COLUMNS = ["gid", # needed for H5 collection to work properly + SupplyCurveField.SC_POINT_GID, + SupplyCurveField.TURBINE_X_COORDS, + SupplyCurveField.TURBINE_Y_COORDS, + SupplyCurveField.POSSIBLE_X_COORDS, + SupplyCurveField.POSSIBLE_Y_COORDS, + SupplyCurveField.N_TURBINES, + SupplyCurveField.RES_GIDS, + SupplyCurveField.MEAN_RES, + SupplyCurveField.CAPACITY_AC_MW, + SupplyCurveField.CAPACITY_DC_MW, + SupplyCurveField.MEAN_CF_AC, + SupplyCurveField.MEAN_CF_DC, + SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW, + SupplyCurveField.EOS_MULT, + SupplyCurveField.REG_MULT, + SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW, + SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW, + SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW, + SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW, + SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW, + SupplyCurveField.COST_SITE_VOC_USD_PER_AC_MW, + SupplyCurveField.FIXED_CHARGE_RATE, + SupplyCurveField.INCLUDED_AREA, + SupplyCurveField.INCLUDED_AREA_CAPACITY_DENSITY, + SupplyCurveField.CONVEX_HULL_AREA, + SupplyCurveField.CONVEX_HULL_CAPACITY_DENSITY, + SupplyCurveField.FULL_CELL_CAPACITY_DENSITY, + SupplyCurveField.BESPOKE_AEP, + SupplyCurveField.BESPOKE_OBJECTIVE, + SupplyCurveField.BESPOKE_CAPITAL_COST, + SupplyCurveField.BESPOKE_FIXED_OPERATING_COST, + SupplyCurveField.BESPOKE_VARIABLE_OPERATING_COST, + SupplyCurveField.BESPOKE_BALANCE_OF_SYSTEM_COST] def test_turbine_placement(gid=33): @@ -327,7 +362,7 @@ def test_bespoke_points(): for gid in pp.gids: assert pp[gid][0] == "default" - points = pd.DataFrame({SupplyCurveField.GID: [33, 34, 35]}) + points = pd.DataFrame({SiteDataField.GID: [33, 34, 35]}) pp = BespokeWindPlants._parse_points(points, {"default": SAM}) assert len(pp) == 3 assert SiteDataField.CONFIG in pp.df.columns @@ -369,7 +404,7 @@ def test_single(gid=33): assert "annual_energy-means" in out assert ( - TURB_RATING * bsp.meta["n_turbines"].values[0] + TURB_RATING * bsp.meta[SupplyCurveField.N_TURBINES].values[0] == out["system_capacity"] ) x_coords = json.loads( @@ -378,8 +413,8 @@ def test_single(gid=33): y_coords = json.loads( bsp.meta[SupplyCurveField.TURBINE_Y_COORDS].values[0] ) - assert bsp.meta["n_turbines"].values[0] == len(x_coords) - assert bsp.meta["n_turbines"].values[0] == len(y_coords) + assert bsp.meta[SupplyCurveField.N_TURBINES].values[0] == len(x_coords) + assert bsp.meta[SupplyCurveField.N_TURBINES].values[0] == len(y_coords) for y in (2012, 2013): cf = out[f"cf_profile-{y}"] @@ -469,8 +504,8 @@ def test_extra_outputs(gid=33): assert "lcoe_fcr-2013" in out assert "lcoe_fcr-means" in out - assert SupplyCurveField.CAPACITY in bsp.meta - assert SupplyCurveField.MEAN_CF in bsp.meta + assert SupplyCurveField.CAPACITY_AC_MW in bsp.meta + assert SupplyCurveField.MEAN_CF_AC in bsp.meta assert SupplyCurveField.MEAN_LCOE in bsp.meta assert "pct_slope" in bsp.meta @@ -503,8 +538,8 @@ def test_extra_outputs(gid=33): assert "lcoe_fcr-2013" in out assert "lcoe_fcr-means" in out - assert SupplyCurveField.CAPACITY in bsp.meta - assert SupplyCurveField.MEAN_CF in bsp.meta + assert SupplyCurveField.CAPACITY_AC_MW in bsp.meta + assert SupplyCurveField.MEAN_CF_AC in bsp.meta assert SupplyCurveField.MEAN_LCOE in bsp.meta assert "pct_slope" in bsp.meta @@ -571,6 +606,8 @@ def test_bespoke(): ) TechMapping.run(excl_fp, RES.format(2012), dset=TM_DSET, max_workers=1) + sam_configs = copy.deepcopy(SAM_CONFIGS) + sam_configs["default"]["fixed_charge_rate"] = 0.0975 # test no outputs with pytest.warns(UserWarning) as record: @@ -579,7 +616,7 @@ def test_bespoke(): OBJECTIVE_FUNCTION, CAP_COST_FUN, FOC_FUN, VOC_FUN, BOS_FUN, fully_excluded_points, - SAM_CONFIGS, ga_kwargs={'max_time': 5}, + sam_configs, ga_kwargs={'max_time': 5}, excl_dict=EXCL_DICT, output_request=output_request) test_fpath = bsp.run(max_workers=2, out_fpath=out_fpath_request) @@ -589,21 +626,17 @@ def test_bespoke(): assert not os.path.exists(out_fpath_truth) bsp = BespokeWindPlants(excl_fp, res_fp, TM_DSET, OBJECTIVE_FUNCTION, CAP_COST_FUN, FOC_FUN, VOC_FUN, BOS_FUN, - points, SAM_CONFIGS, ga_kwargs={'max_time': 5}, + points, sam_configs, ga_kwargs={'max_time': 5}, excl_dict=EXCL_DICT, output_request=output_request) test_fpath = bsp.run(max_workers=2, out_fpath=out_fpath_request) assert out_fpath_truth == test_fpath assert os.path.exists(out_fpath_truth) with Resource(out_fpath_truth) as f: - meta = f.meta + meta = f.meta.reset_index() assert len(meta) <= len(points) - assert SupplyCurveField.SC_POINT_GID in meta - assert SupplyCurveField.TURBINE_X_COORDS in meta - assert SupplyCurveField.TURBINE_Y_COORDS in meta - assert "possible_x_coords" in meta - assert "possible_y_coords" in meta - assert SupplyCurveField.RES_GIDS in meta + for col in EXPECTED_META_COLUMNS: + assert col in meta dsets_1d = ( "system_capacity", @@ -620,6 +653,13 @@ def test_bespoke(): assert len(f[dset]) == len(meta) assert f[dset].any() # not all zeros + assert np.allclose(meta[SupplyCurveField.MEAN_RES], f["ws_mean"], + atol=0.01) + assert np.allclose( + f["annual_energy-means"] / 1000, + meta[SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW] + ) + dsets_2d = ( "cf_profile-2012", "cf_profile-2013", @@ -634,6 +674,28 @@ def test_bespoke(): assert f[dset].shape[1] == len(meta) assert f[dset].any() # not all zeros + fcr = meta[SupplyCurveField.FIXED_CHARGE_RATE] + cap_cost = (meta[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] + * meta[SupplyCurveField.CAPACITY_AC_MW]) + foc = (meta[SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW] + * meta[SupplyCurveField.CAPACITY_AC_MW]) + voc = (meta[SupplyCurveField.COST_SITE_VOC_USD_PER_AC_MW] + * meta[SupplyCurveField.CAPACITY_AC_MW]) + aep = meta[SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW] + lcoe_site = lcoe_fcr(fcr, cap_cost, foc, aep, voc) + + cap_cost = (meta[SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW] + * meta[SupplyCurveField.CAPACITY_AC_MW] + * meta[SupplyCurveField.REG_MULT] + * meta[SupplyCurveField.EOS_MULT]) + foc = (meta[SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW] + * meta[SupplyCurveField.CAPACITY_AC_MW]) + voc = (meta[SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW] + * meta[SupplyCurveField.CAPACITY_AC_MW]) + lcoe_base = lcoe_fcr(fcr, cap_cost, foc, aep, voc) + + assert np.allclose(lcoe_site, lcoe_base) + out_fpath_pre = os.path.join(td, 'bespoke_out_pre.h5') bsp = BespokeWindPlants(excl_fp, res_fp, TM_DSET, OBJECTIVE_FUNCTION, CAP_COST_FUN, FOC_FUN, VOC_FUN, BOS_FUN, @@ -665,8 +727,8 @@ def test_collect_bespoke(): with Resource(h5_file) as fout: meta = fout.meta.rename(columns=SupplyCurveField.map_from_legacy()) assert all( - meta[SupplyCurveField.GID].values - == sorted(meta[SupplyCurveField.GID].values) + meta[SupplyCurveField.SC_POINT_GID].values + == sorted(meta[SupplyCurveField.SC_POINT_GID].values) ) ti = fout.time_index assert len(ti) == 8760 @@ -680,16 +742,16 @@ def test_collect_bespoke(): columns=SupplyCurveField.map_from_legacy()) assert all( np.isin( - src_meta[SupplyCurveField.GID].values, - meta[SupplyCurveField.GID].values, + src_meta[SupplyCurveField.SC_POINT_GID].values, + meta[SupplyCurveField.SC_POINT_GID].values, ) ) for isource, gid in enumerate( - src_meta[SupplyCurveField.GID].values + src_meta[SupplyCurveField.SC_POINT_GID].values ): - iout = np.where(meta[SupplyCurveField.GID].values == gid)[ - 0 - ] + gid_mask = (meta[SupplyCurveField.SC_POINT_GID].values + == gid) + iout = np.where(gid_mask)[0] truth = source["cf_profile-2012", :, isource].flatten() test = data[:, iout].flatten() assert np.allclose(truth, test) @@ -732,11 +794,10 @@ def test_consistent_eval_namespace(gid=33): ) _ = bsp.run_plant_optimization() - assert bsp.meta["bespoke_aep"].values[0] == bsp.plant_optimizer.aep - assert ( - bsp.meta["bespoke_objective"].values[0] - == bsp.plant_optimizer.objective - ) + assert (bsp.meta[SupplyCurveField.BESPOKE_AEP].values[0] + == bsp.plant_optimizer.aep) + assert (bsp.meta[SupplyCurveField.BESPOKE_OBJECTIVE].values[0] + == bsp.plant_optimizer.objective) bsp.close() @@ -774,6 +835,8 @@ def test_bespoke_supply_curve(): sc = SupplyCurve(bespoke_sc_fp, trans_tables) sc_full = sc.full_sort(fcr=0.1, avail_cap_frac=0.1) + assert SupplyCurveField.SC_GID in sc_full + assert all( gid in sc_full[SupplyCurveField.SC_GID] for gid in normal_sc_points[SupplyCurveField.SC_GID] @@ -785,13 +848,17 @@ def test_bespoke_supply_curve(): assert len(test_ind) == 1 test_row = sc_full.iloc[test_ind] assert ( - test_row["total_lcoe"].values[0] + test_row[SupplyCurveField.TOTAL_LCOE].values[0] > inp_row[SupplyCurveField.MEAN_LCOE] ) fpath_baseline = os.path.join(TESTDATADIR, "sc_out/sc_full_lc.csv") sc_baseline = pd.read_csv(fpath_baseline) - assert np.allclose(sc_baseline["total_lcoe"], sc_full["total_lcoe"]) + sc_baseline = sc_baseline.rename( + columns=SupplyCurveField.map_from_legacy() + ) + assert np.allclose(sc_baseline[SupplyCurveField.TOTAL_LCOE], + sc_full[SupplyCurveField.TOTAL_LCOE]) @pytest.mark.parametrize("wlm", [2, 100]) @@ -1190,10 +1257,31 @@ def test_bespoke_prior_run(): cols = [ SupplyCurveField.TURBINE_X_COORDS, SupplyCurveField.TURBINE_Y_COORDS, - SupplyCurveField.CAPACITY, + SupplyCurveField.CAPACITY_AC_MW, SupplyCurveField.N_GIDS, SupplyCurveField.GID_COUNTS, SupplyCurveField.RES_GIDS, + SupplyCurveField.N_TURBINES, + SupplyCurveField.EOS_MULT, + SupplyCurveField.REG_MULT, + SupplyCurveField.INCLUDED_AREA, + SupplyCurveField.INCLUDED_AREA_CAPACITY_DENSITY, + SupplyCurveField.CONVEX_HULL_AREA, + SupplyCurveField.CONVEX_HULL_CAPACITY_DENSITY, + SupplyCurveField.FULL_CELL_CAPACITY_DENSITY, + SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW, + SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW, + SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW, + SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW, + SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW, + SupplyCurveField.COST_SITE_VOC_USD_PER_AC_MW, + SupplyCurveField.FIXED_CHARGE_RATE, + SupplyCurveField.BESPOKE_AEP, + SupplyCurveField.BESPOKE_OBJECTIVE, + SupplyCurveField.BESPOKE_CAPITAL_COST, + SupplyCurveField.BESPOKE_FIXED_OPERATING_COST, + SupplyCurveField.BESPOKE_VARIABLE_OPERATING_COST, + SupplyCurveField.BESPOKE_BALANCE_OF_SYSTEM_COST, ] pd.testing.assert_frame_equal(meta1[cols], meta2[cols]) @@ -1243,7 +1331,7 @@ def test_gid_map(): ) gid_map = pd.DataFrame( - {SupplyCurveField.GID: [3, 4, 13, 12, 11, 10, 9]} + {SiteDataField.GID: [3, 4, 13, 12, 11, 10, 9]} ) new_gid = 50 gid_map["gid_map"] = new_gid @@ -1344,7 +1432,7 @@ def test_bespoke_bias_correct(): # intentionally leaving out WTK gid 13 which only has 5 included 90m # pixels in order to check that this is dynamically patched. bias_correct = pd.DataFrame( - {SupplyCurveField.GID: [3, 4, 12, 11, 10, 9]} + {SiteDataField.GID: [3, 4, 12, 11, 10, 9]} ) bias_correct["method"] = "lin_ws" bias_correct["scalar"] = 0.5 diff --git a/tests/test_econ_lcoe.py b/tests/test_econ_lcoe.py index 4a84023de..84afdfc87 100644 --- a/tests/test_econ_lcoe.py +++ b/tests/test_econ_lcoe.py @@ -22,6 +22,7 @@ from reV import TESTDATADIR from reV.cli import main from reV.econ.econ import Econ +from reV.generation.base import LCOE_REQUIRED_OUTPUTS from reV.handlers.outputs import Outputs from reV.utilities import ModuleName @@ -60,6 +61,9 @@ def test_lcoe(year, max_workers, spw): assert result + for output in LCOE_REQUIRED_OUTPUTS: + assert output in obj.out + @pytest.mark.parametrize('year', ('2012', '2013')) def test_fout(year): @@ -80,6 +84,8 @@ def test_fout(year): econ.run(max_workers=1, out_fpath=fpath) with Outputs(fpath) as f: lcoe = f['lcoe_fcr'] + for output in LCOE_REQUIRED_OUTPUTS: + assert output in f.datasets with h5py.File(r1f, mode='r') as f: year_rows = {'2012': 0, '2013': 1} @@ -116,6 +122,8 @@ def test_append_data(year): lcoe = f['lcoe_fcr'] meta = f.meta ti = f.time_index + for output in LCOE_REQUIRED_OUTPUTS: + assert output in f.datasets with Outputs(original_file) as f: og_dsets = f.dsets @@ -163,6 +171,8 @@ def test_append_multi_node(node): meta = out.meta data_test = out['lcoe_fcr'] test_cap_cost = out['capital_cost'] + for output in LCOE_REQUIRED_OUTPUTS: + assert output in out.datasets assert np.allclose(data_baseline, data_test) @@ -218,6 +228,8 @@ def test_econ_from_config(runner, clear_loggers): out_fpath = os.path.join(td, fn_out) with Outputs(out_fpath, 'r') as f: lcoe = f['lcoe_fcr'] + for output in LCOE_REQUIRED_OUTPUTS: + assert output in f.datasets with h5py.File(r1f, mode='r') as f: r1_lcoe = f['pv']['lcoefcr'][0, 0:10] * 1000 @@ -229,6 +241,43 @@ def test_econ_from_config(runner, clear_loggers): clear_loggers() +def test_capital_cost_multiplier_regional(clear_loggers): + """Gen PV CF profiles with write to disk and compare against rev1.""" + with tempfile.TemporaryDirectory() as dirout: + cf_file = os.path.join(TESTDATADIR, + 'gen_out/gen_ri_pv_2012_x000.h5') + sam_files = os.path.join(TESTDATADIR, 'SAM', + 'i_lcoe_naris_pv_1axis_inv13.json') + fpath = os.path.join(dirout, 'lcoe_out_econ_2012.h5') + mults = np.arange(0, 100) / 100 + points = pd.DataFrame({"gid": np.arange(0, 100), + "capital_cost_multiplier": mults}) + econ = Econ(points, sam_files, cf_file, + output_request='lcoe_fcr', + sites_per_worker=25) + econ.run(max_workers=1, out_fpath=fpath) + + with Outputs(cf_file) as f: + cf = f['cf_mean'] + + with Outputs(fpath) as f: + lcoe = f['lcoe_fcr'] + for output in LCOE_REQUIRED_OUTPUTS: + assert output in f.datasets + + with open(sam_files, "r") as fh: + sam_config = json.load(fh) + + cc = sam_config["capital_cost"] * mults + num = (cc * sam_config["fixed_charge_rate"] + + sam_config["fixed_operating_cost"]) + aep = cf * sam_config["system_capacity"] / 1000 * 8760 + lcoe_truth = num / aep + sam_config["variable_operating_cost"] + + assert np.allclose(lcoe, lcoe_truth, rtol=RTOL, atol=ATOL) + clear_loggers() + + def execute_pytest(capture='all', flags='-rapP'): """Execute module as pytest with detailed summary report. diff --git a/tests/test_econ_of_scale.py b/tests/test_econ_of_scale.py index 9532aa49e..2186aaa0e 100644 --- a/tests/test_econ_of_scale.py +++ b/tests/test_econ_of_scale.py @@ -81,47 +81,47 @@ def test_lcoe_calc_simple(): # from pvwattsv7 defaults data = { "aep": 35188456.00, - SupplyCurveField.CAPITAL_COST: 53455000.00, + "capital_cost": 53455000.00, "foc": 360000.00, "voc": 0, "fcr": 0.096, } - true_lcoe = (data["fcr"] * data[SupplyCurveField.CAPITAL_COST] + true_lcoe = (data["fcr"] * data["capital_cost"] + data["foc"]) / (data["aep"] / 1000) data[SupplyCurveField.MEAN_LCOE] = true_lcoe eos = EconomiesOfScale(eqn, data) assert eos.raw_capital_cost == eos.scaled_capital_cost - assert eos.raw_capital_cost == data[SupplyCurveField.CAPITAL_COST] + assert eos.raw_capital_cost == data["capital_cost"] assert np.allclose(eos.raw_lcoe, true_lcoe, rtol=0.001) assert np.allclose(eos.scaled_lcoe, true_lcoe, rtol=0.001) eqn = 1 eos = EconomiesOfScale(eqn, data) assert eos.raw_capital_cost == eos.scaled_capital_cost - assert eos.raw_capital_cost == data[SupplyCurveField.CAPITAL_COST] + assert eos.raw_capital_cost == data["capital_cost"] assert np.allclose(eos.raw_lcoe, true_lcoe, rtol=0.001) assert np.allclose(eos.scaled_lcoe, true_lcoe, rtol=0.001) eqn = 2 - true_scaled = ((data['fcr'] * eqn * data[SupplyCurveField.CAPITAL_COST] + true_scaled = ((data['fcr'] * eqn * data["capital_cost"] + data['foc']) / (data['aep'] / 1000)) eos = EconomiesOfScale(eqn, data) assert eqn * eos.raw_capital_cost == eos.scaled_capital_cost - assert eos.raw_capital_cost == data[SupplyCurveField.CAPITAL_COST] + assert eos.raw_capital_cost == data["capital_cost"] assert np.allclose(eos.raw_lcoe, true_lcoe, rtol=0.001) assert np.allclose(eos.scaled_lcoe, true_scaled, rtol=0.001) data['system_capacity'] = 2 eqn = '1 / system_capacity' - true_scaled = ((data['fcr'] * 0.5 * data[SupplyCurveField.CAPITAL_COST] + true_scaled = ((data['fcr'] * 0.5 * data["capital_cost"] + data['foc']) / (data['aep'] / 1000)) eos = EconomiesOfScale(eqn, data) assert 0.5 * eos.raw_capital_cost == eos.scaled_capital_cost - assert eos.raw_capital_cost == data[SupplyCurveField.CAPITAL_COST] + assert eos.raw_capital_cost == data["capital_cost"] assert np.allclose(eos.raw_lcoe, true_lcoe, rtol=0.001) assert np.allclose(eos.scaled_lcoe, true_scaled, rtol=0.001) @@ -147,9 +147,10 @@ def test_econ_of_scale_baseline(): with Resource(GEN) as res: cf = res["cf_mean-means"] - lcoe = (1000 * (data['fixed_charge_rate'] * data['capital_cost'] - + data['fixed_operating_cost']) - / (cf * data['system_capacity'] * 8760)) + lcoe = (1000 + * (data['fixed_charge_rate'] * data['capital_cost'] + + data['fixed_operating_cost']) + / (cf * data['system_capacity'] * 8760)) with h5py.File(gen_temp, "a") as res: res["lcoe_fcr-means"][...] = lcoe @@ -158,6 +159,10 @@ def test_econ_of_scale_baseline(): res.create_dataset(k, res["meta"].shape, data=arr) res[k].attrs["scale_factor"] = 1.0 + arr = np.full(res["meta"].shape, data["capital_cost"]) + res.create_dataset("base_capital_cost", + res["meta"].shape, data=arr) + out_fp_base = os.path.join(td, "base") base = SupplyCurveAggregation( EXCL, @@ -167,6 +172,12 @@ def test_econ_of_scale_baseline(): res_class_bins=RES_CLASS_BINS, data_layers=DATA_LAYERS, gids=list(np.arange(10)), + h5_dsets=[ + "capital_cost", + "fixed_operating_cost", + "fixed_charge_rate", + "variable_operating_cost" + ], ) base.run(out_fp_base, gen_fpath=gen_temp, max_workers=1) @@ -180,6 +191,12 @@ def test_econ_of_scale_baseline(): data_layers=DATA_LAYERS, gids=list(np.arange(10)), cap_cost_scale="1", + h5_dsets=[ + "capital_cost", + "fixed_operating_cost", + "fixed_charge_rate", + "variable_operating_cost" + ], ) sc.run(out_fp_sc, gen_fpath=gen_temp, max_workers=1) @@ -187,9 +204,12 @@ def test_econ_of_scale_baseline(): sc_df = pd.read_csv(out_fp_sc + ".csv") assert np.allclose(base_df[SupplyCurveField.MEAN_LCOE], sc_df[SupplyCurveField.MEAN_LCOE]) - assert (sc_df[SupplyCurveField.CAPITAL_COST_SCALAR] == 1).all() + assert (sc_df[SupplyCurveField.EOS_MULT] == 1).all() assert np.allclose(sc_df['mean_capital_cost'], - sc_df[SupplyCurveField.SCALED_CAPITAL_COST]) + sc_df[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] + * 20000) + assert np.allclose(sc_df[SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW], + sc_df[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW]) def test_sc_agg_econ_scale(): @@ -211,7 +231,11 @@ def test_sc_agg_econ_scale(): res.create_dataset(k, res["meta"].shape, data=arr) res[k].attrs["scale_factor"] = 1.0 - eqn = f"2 * np.multiply(1000, {SupplyCurveField.CAPACITY}) ** -0.3" + eqn = ( + f"2 * np.multiply(1000, {SupplyCurveField.CAPACITY_AC_MW}) ** -0.3" + f" * np.where(np.array([2, 5]) > 3)[0][0]" + f" * np.where(np.array([2, 1]) == 1)[0][0]" + ) out_fp_base = os.path.join(td, "base") base = SupplyCurveAggregation( EXCL, @@ -221,6 +245,12 @@ def test_sc_agg_econ_scale(): res_class_bins=RES_CLASS_BINS, data_layers=DATA_LAYERS, gids=list(np.arange(10)), + h5_dsets=[ + "capital_cost", + "fixed_operating_cost", + "fixed_charge_rate", + "variable_operating_cost" + ], ) base.run(out_fp_base, gen_fpath=gen_temp, max_workers=1) @@ -234,6 +264,12 @@ def test_sc_agg_econ_scale(): data_layers=DATA_LAYERS, gids=list(np.arange(10)), cap_cost_scale=eqn, + h5_dsets=[ + "capital_cost", + "fixed_operating_cost", + "fixed_charge_rate", + "variable_operating_cost" + ], ) sc.run(out_fp_sc, gen_fpath=gen_temp, max_workers=1) @@ -249,11 +285,11 @@ def test_sc_agg_econ_scale(): aep = ((sc_df['mean_fixed_charge_rate'] * sc_df['mean_capital_cost'] + sc_df['mean_fixed_operating_cost']) - / sc_df[SupplyCurveField.RAW_LCOE]) + / sc_df[SupplyCurveField.RAW_LCOE]) true_raw_lcoe = ((data['fixed_charge_rate'] * data['capital_cost'] + data['fixed_operating_cost']) - / aep + data['variable_operating_cost']) + / aep + data['variable_operating_cost']) eval_inputs = {k: sc_df[k].values.flatten() for k in sc_df.columns} # pylint: disable=eval-used @@ -264,14 +300,11 @@ def test_sc_agg_econ_scale(): + data["fixed_operating_cost"] ) / aep + data["variable_operating_cost"] - assert np.allclose(scalars, - sc_df[SupplyCurveField.CAPITAL_COST_SCALAR]) - assert np.allclose(scalars * sc_df['mean_capital_cost'], - sc_df[SupplyCurveField.SCALED_CAPITAL_COST]) + assert np.allclose(scalars, sc_df[SupplyCurveField.EOS_MULT]) assert np.allclose(true_scaled_lcoe, sc_df[SupplyCurveField.MEAN_LCOE]) assert np.allclose(true_raw_lcoe, sc_df[SupplyCurveField.RAW_LCOE]) - sc_df = sc_df.sort_values(SupplyCurveField.CAPACITY) + sc_df = sc_df.sort_values(SupplyCurveField.CAPACITY_AC_MW) assert all(sc_df[SupplyCurveField.MEAN_LCOE].diff()[1:] < 0) for i in sc_df.index.values: if sc_df.loc[i, 'scalars'] < 1: diff --git a/tests/test_gen_config.py b/tests/test_gen_config.py index 88cc2691f..bd5408d94 100644 --- a/tests/test_gen_config.py +++ b/tests/test_gen_config.py @@ -20,6 +20,7 @@ from reV import TESTDATADIR from reV.cli import main from reV.config.project_points import ProjectPoints +from reV.generation.base import LCOE_REQUIRED_OUTPUTS from reV.generation.generation import Gen from reV.handlers.outputs import Outputs from reV.utilities import SiteDataField @@ -107,6 +108,12 @@ def test_gen_from_config(runner, tech, clear_loggers): monthly = cf['monthly_energy'] assert monthly.shape == (12, 10) + for output in LCOE_REQUIRED_OUTPUTS: + if tech == 'pv': + assert output in cf.datasets + else: + assert output not in cf.datasets + break if rev2_profiles is None: diff --git a/tests/test_gen_geothermal.py b/tests/test_gen_geothermal.py index 4f27550c7..f1fdedab2 100644 --- a/tests/test_gen_geothermal.py +++ b/tests/test_gen_geothermal.py @@ -15,6 +15,7 @@ from rex import Outputs from reV import TESTDATADIR +from reV.generation.base import LCOE_REQUIRED_OUTPUTS from reV.generation.generation import Gen from reV.SAM.generation import Geothermal from reV.utilities import ResourceMetaField @@ -115,6 +116,9 @@ def test_gen_geothermal(depth, sample_resource_data): ) assert np.allclose(truth, test, rtol=RTOL, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output in gen.out + @pytest.mark.parametrize( "sample_resource_data", [{"temp": 60, "potential": 200}], indirect=True @@ -165,6 +169,9 @@ def test_gen_geothermal_temp_too_low(sample_resource_data): ) assert np.allclose(truth, test, rtol=RTOL, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output in gen.out + @pytest.mark.parametrize( "sample_resource_data", [{"temp": 150, "potential": 100}], indirect=True @@ -211,6 +218,9 @@ def test_per_kw_cost_inputs(sample_resource_data): ) assert np.allclose(truth, test, rtol=1e-6, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output in gen.out + @pytest.mark.parametrize( "sample_resource_data", [{"temp": 150, "potential": 100}], indirect=True @@ -258,6 +268,9 @@ def test_drill_cost_inputs(sample_resource_data): ) assert np.allclose(truth, test, rtol=1e-6, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output in gen.out + @pytest.mark.parametrize( "sample_resource_data", [{"temp": 150, "potential": 20}], indirect=True @@ -315,6 +328,9 @@ def test_gen_with_nameplate_input(sample_resource_data): ) assert np.allclose(truth, test, rtol=RTOL, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output in gen.out + @pytest.mark.parametrize( "sample_resource_data", [{"temp": 150, "potential": 20}], indirect=True @@ -359,6 +375,9 @@ def test_gen_egs_too_high_egs_plant_design_temp(sample_resource_data): ) assert np.allclose(truth, test, rtol=RTOL, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output not in gen.out + @pytest.mark.parametrize( "sample_resource_data", @@ -406,6 +425,9 @@ def test_gen_egs_too_low_egs_plant_design_temp(sample_resource_data): ) assert np.allclose(truth, test, rtol=RTOL, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output not in gen.out + @pytest.mark.parametrize( "sample_resource_data", @@ -454,6 +476,9 @@ def test_gen_egs_plant_design_temp_adjusted_from_user(sample_resource_data): ) assert np.allclose(truth, test, rtol=RTOL, atol=ATOL), msg + for output in LCOE_REQUIRED_OUTPUTS: + assert output not in gen.out + @pytest.mark.parametrize( "sample_resource_data", [{"temp": 150, "potential": 20}], indirect=True @@ -491,6 +516,9 @@ def test_gen_with_time_index_step_input(sample_resource_data): assert gen.out["cf_profile"].shape[0] == 8760 // 2 + for output in LCOE_REQUIRED_OUTPUTS: + assert output in gen.out + def execute_pytest(capture="all", flags="-rapP"): """Execute module as pytest with detailed summary report. diff --git a/tests/test_handlers_transmission.py b/tests/test_handlers_transmission.py index eade43156..b86f5d531 100644 --- a/tests/test_handlers_transmission.py +++ b/tests/test_handlers_transmission.py @@ -9,8 +9,8 @@ import pytest from reV import TESTDATADIR -from reV.handlers.transmission import TransmissionFeatures as TF from reV.utilities import SupplyCurveField +from reV.handlers.transmission import TransmissionFeatures as TF TRANS_COSTS_1 = { "line_tie_in_cost": 200, @@ -83,6 +83,9 @@ def trans_table(): """Get the transmission mapping table""" path = os.path.join(TESTDATADIR, "trans_tables/ri_transmission_table.csv") trans_table = pd.read_csv(path) + trans_table = trans_table.rename( + columns=SupplyCurveField.map_from_legacy() + ) return trans_table @@ -133,7 +136,7 @@ def test_connect(trans_costs, capacity, gid, trans_table): avail_cap_frac = tcosts.pop("available_capacity") tf = TF(trans_table, avail_cap_frac=avail_cap_frac, **tcosts) - avail_cap = tf[gid].get("avail_cap", None) + avail_cap = tf[gid].get(SupplyCurveField.TRANS_CAPACITY, None) if avail_cap is not None: if avail_cap > capacity: assert tf.connect(gid, capacity, apply=False) @@ -185,7 +188,9 @@ def test_substation_load_spreading(i, trans_costs, trans_table): assert not any(missing), "New gids not in baseline: {}".format(missing) for line_id in line_gids: msg = "Bad line cap: {}".format(line_id) - assert LINE_CAPS[i][line_id] == tf[line_id]["avail_cap"], msg + expected_match = (LINE_CAPS[i][line_id] + == tf[line_id][SupplyCurveField.TRANS_CAPACITY]) + assert expected_match, msg def execute_pytest(capture="all", flags="-rapP"): diff --git a/tests/test_hybrids.py b/tests/test_hybrids.py index 297ec3d4a..184c64eb7 100644 --- a/tests/test_hybrids.py +++ b/tests/test_hybrids.py @@ -45,12 +45,14 @@ def _fix_meta(fp): @pytest.fixture(scope="module") def module_td(): + """Module-level temporaty dirsctory""" with tempfile.TemporaryDirectory() as td: yield td @pytest.fixture(scope="module") def solar_fpath(module_td): + """Solar fpath with legacy columns renamed. """ new_fp = os.path.join(module_td, "solar.h5") shutil.copy(SOLAR_FPATH, new_fp) _fix_meta(new_fp) @@ -59,6 +61,7 @@ def solar_fpath(module_td): @pytest.fixture(scope="module") def wind_fpath(module_td): + """Wind fpath with legacy columns renamed. """ new_fp = os.path.join(module_td, "wind.h5") shutil.copy(WIND_FPATH, new_fp) _fix_meta(new_fp) @@ -67,6 +70,7 @@ def wind_fpath(module_td): @pytest.fixture(scope="module") def solar_fpath_30_min(module_td): + """Solar fpath (30 min data) with legacy columns renamed.""" new_fp = os.path.join(module_td, "solar_30min.h5") shutil.copy(SOLAR_FPATH_30_MIN, new_fp) _fix_meta(new_fp) @@ -75,6 +79,7 @@ def solar_fpath_30_min(module_td): @pytest.fixture(scope="module") def solar_fpath_mult(module_td): + """Solar fpath (with mult) with legacy columns renamed. """ new_fp = os.path.join(module_td, "solar_mult.h5") shutil.copy(SOLAR_FPATH_MULT, new_fp) _fix_meta(new_fp) @@ -91,7 +96,7 @@ def test_hybridization_profile_output_single_resource(solar_fpath, wind_fpath): res.meta[SupplyCurveField.SC_POINT_GID] == sc_point_gid )[0][0] - solar_cap = res.meta.loc[solar_idx, SupplyCurveField.CAPACITY] + solar_cap = res.meta.loc[solar_idx, SupplyCurveField.CAPACITY_AC_MW] solar_test_profile = res["rep_profiles_0", :, solar_idx] weighted_solar = solar_cap * solar_test_profile @@ -121,7 +126,7 @@ def test_hybridization_profile_output_with_ratio_none(solar_fpath, wind_fpath): res.meta[SupplyCurveField.SC_POINT_GID] == sc_point_gid )[0][0] - solar_cap = res.meta.loc[solar_idx, SupplyCurveField.CAPACITY] + solar_cap = res.meta.loc[solar_idx, SupplyCurveField.CAPACITY_AC_MW] solar_test_profile = res["rep_profiles_0", :, solar_idx] weighted_solar = solar_cap * solar_test_profile @@ -154,14 +159,14 @@ def test_hybridization_profile_output(solar_fpath, wind_fpath): solar_idx = np.where( res.meta[SupplyCurveField.SC_POINT_GID] == common_sc_point_gid )[0][0] - solar_cap = res.meta.loc[solar_idx, SupplyCurveField.CAPACITY] + solar_cap = res.meta.loc[solar_idx, SupplyCurveField.CAPACITY_AC_MW] solar_test_profile = res["rep_profiles_0", :, solar_idx] with Resource(wind_fpath) as res: wind_idx = np.where( res.meta[SupplyCurveField.SC_POINT_GID] == common_sc_point_gid )[0][0] - wind_cap = res.meta.loc[wind_idx, SupplyCurveField.CAPACITY] + wind_cap = res.meta.loc[wind_idx, SupplyCurveField.CAPACITY_AC_MW] wind_test_profile = res["rep_profiles_0", :, wind_idx] weighted_solar = solar_cap * solar_test_profile @@ -244,10 +249,10 @@ def test_meta_hybridization(input_combination, expected_shape, overlap, def test_limits_and_ratios_output_values(solar_fpath, wind_fpath): """Test that limits and ratios are properly applied in succession.""" - limits = {f"solar_{SupplyCurveField.CAPACITY}": 50, - f"wind_{SupplyCurveField.CAPACITY}": 0.5} - ratio_numerator = f"solar_{SupplyCurveField.CAPACITY}" - ratio_denominator = f"wind_{SupplyCurveField.CAPACITY}" + limits = {f"solar_{SupplyCurveField.CAPACITY_AC_MW}": 50, + f"wind_{SupplyCurveField.CAPACITY_AC_MW}": 0.5} + ratio_numerator = f"solar_{SupplyCurveField.CAPACITY_AC_MW}" + ratio_denominator = f"wind_{SupplyCurveField.CAPACITY_AC_MW}" ratio = "{}/{}".format(ratio_numerator, ratio_denominator) ratio_bounds = (0.3, 3.6) bounds = (0.3 - 1e6, 3.6 + 1e6) @@ -274,17 +279,17 @@ def test_limits_and_ratios_output_values(solar_fpath, wind_fpath): h.hybrid_meta["hybrid_{}".format(ratio_denominator)] <= h.hybrid_meta[ratio_denominator] ) - assert np.all(h.hybrid_meta[f"solar_{SupplyCurveField.CAPACITY}"] - <= limits[f"solar_{SupplyCurveField.CAPACITY}"]) - assert np.all(h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY}"] - <= limits[f"wind_{SupplyCurveField.CAPACITY}"]) + assert np.all(h.hybrid_meta[f"solar_{SupplyCurveField.CAPACITY_AC_MW}"] + <= limits[f"solar_{SupplyCurveField.CAPACITY_AC_MW}"]) + assert np.all(h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY_AC_MW}"] + <= limits[f"wind_{SupplyCurveField.CAPACITY_AC_MW}"]) @pytest.mark.parametrize( "ratio_cols", [ - (f"solar_{SupplyCurveField.CAPACITY}", - f"wind_{SupplyCurveField.CAPACITY}"), + (f"solar_{SupplyCurveField.CAPACITY_AC_MW}", + f"wind_{SupplyCurveField.CAPACITY_AC_MW}"), (f"solar_{SupplyCurveField.AREA_SQ_KM}", f"wind_{SupplyCurveField.AREA_SQ_KM}"), ], @@ -323,14 +328,14 @@ def test_ratios_input(ratio_cols, ratio_bounds, bounds, solar_fpath, <= h.hybrid_meta[ratio_denominator] ) - if SupplyCurveField.CAPACITY in ratio: - col = f"hybrid_solar_{SupplyCurveField.CAPACITY}" + if SupplyCurveField.CAPACITY_AC_MW in ratio: + col = f"hybrid_solar_{SupplyCurveField.CAPACITY_AC_MW}" max_solar_capacities = h.hybrid_meta[col] max_solar_capacities = max_solar_capacities.values.reshape(1, -1) assert np.all( h.profiles["hybrid_solar_profile"] <= max_solar_capacities ) - col = f"hybrid_wind_{SupplyCurveField.CAPACITY}" + col = f"hybrid_wind_{SupplyCurveField.CAPACITY_AC_MW}" max_wind_capacities = h.hybrid_meta[col] max_wind_capacities = max_wind_capacities.values.reshape(1, -1) assert np.all(h.profiles["hybrid_wind_profile"] <= max_wind_capacities) @@ -364,23 +369,23 @@ def test_rep_profile_idx_map(solar_fpath, wind_fpath): def test_limits_values(solar_fpath, wind_fpath): """Test that column values are properly limited on user input.""" - limits = {f"solar_{SupplyCurveField.CAPACITY}": 100, - f"wind_{SupplyCurveField.CAPACITY}": 0.5} + limits = {f"solar_{SupplyCurveField.CAPACITY_AC_MW}": 100, + f"wind_{SupplyCurveField.CAPACITY_AC_MW}": 0.5} h = Hybridization(solar_fpath, wind_fpath, limits=limits) h.run() - assert np.all(h.hybrid_meta[f"solar_{SupplyCurveField.CAPACITY}"] - <= limits[f"solar_{SupplyCurveField.CAPACITY}"]) - assert np.all(h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY}"] - <= limits[f"wind_{SupplyCurveField.CAPACITY}"]) + assert np.all(h.hybrid_meta[f"solar_{SupplyCurveField.CAPACITY_AC_MW}"] + <= limits[f"solar_{SupplyCurveField.CAPACITY_AC_MW}"]) + assert np.all(h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY_AC_MW}"] + <= limits[f"wind_{SupplyCurveField.CAPACITY_AC_MW}"]) def test_invalid_limits_column_name(solar_fpath, wind_fpath): """Test invalid inputs for limits columns.""" test_limits = {"un_prefixed_col": 0, - f"wind_{SupplyCurveField.CAPACITY}": 10} + f"wind_{SupplyCurveField.CAPACITY_AC_MW}": 10} with pytest.raises(InputError) as excinfo: Hybridization(solar_fpath, wind_fpath, limits=test_limits) @@ -392,7 +397,7 @@ def test_fillna_values(solar_fpath, wind_fpath): """Test that N/A values are filled properly based on user input.""" fill_vals = {f"solar_{SupplyCurveField.N_GIDS}": 0, - f"wind_{SupplyCurveField.CAPACITY}": -1} + f"wind_{SupplyCurveField.CAPACITY_AC_MW}": -1} h = Hybridization( solar_fpath, @@ -405,19 +410,20 @@ def test_fillna_values(solar_fpath, wind_fpath): assert not np.any(h.hybrid_meta[f"solar_{SupplyCurveField.N_GIDS}"].isna()) assert not np.any( - h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY}"].isna() + h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY_AC_MW}"].isna() ) assert np.any(h.hybrid_meta[f"solar_{SupplyCurveField.N_GIDS}"].values == 0) - assert np.any(h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY}"].values - == -1) + assert np.any( + h.hybrid_meta[f"wind_{SupplyCurveField.CAPACITY_AC_MW}"].values + == -1) def test_invalid_fillna_column_name(solar_fpath, wind_fpath): """Test invalid inputs for fillna columns.""" test_fillna = {"un_prefixed_col": 0, - f"wind_{SupplyCurveField.CAPACITY}": 10} + f"wind_{SupplyCurveField.CAPACITY_AC_MW}": 10} with pytest.raises(InputError) as excinfo: Hybridization(solar_fpath, wind_fpath, fillna=test_fillna) @@ -514,7 +520,8 @@ def test_invalid_ratio_bounds_length_input(solar_fpath, wind_fpath): """Test improper ratios input.""" ratio = ( - f"solar_{SupplyCurveField.CAPACITY}/wind_{SupplyCurveField.CAPACITY}" + f"solar_{SupplyCurveField.CAPACITY_AC_MW}" + f"/wind_{SupplyCurveField.CAPACITY_AC_MW}" ) with pytest.raises(InputError) as excinfo: Hybridization( @@ -531,7 +538,7 @@ def test_invalid_ratio_bounds_length_input(solar_fpath, wind_fpath): def test_ratio_column_missing(solar_fpath, wind_fpath): """Test missing ratio column.""" - ratio = f"solar_col_dne/wind_{SupplyCurveField.CAPACITY}" + ratio = f"solar_col_dne/wind_{SupplyCurveField.CAPACITY_AC_MW}" with pytest.raises(FileInputError) as excinfo: Hybridization( solar_fpath, wind_fpath, ratio=ratio, ratio_bounds=(1, 1) @@ -576,7 +583,7 @@ def test_invalid_ratio_format(ratio, solar_fpath, wind_fpath): def test_invalid_ratio_column_name(solar_fpath, wind_fpath): """Test invalid inputs for ratio columns.""" - ratio = f"un_prefixed_col/wind_{SupplyCurveField.CAPACITY}" + ratio = f"un_prefixed_col/wind_{SupplyCurveField.CAPACITY_AC_MW}" with pytest.raises(InputError) as excinfo: Hybridization( solar_fpath, wind_fpath, ratio=ratio, ratio_bounds=(1, 1) @@ -715,16 +722,17 @@ def test_hybrids_data_contains_col(solar_fpath, wind_fpath): """Test the 'contains_col' method of HybridsData for accuracy.""" h_data = HybridsData(solar_fpath, wind_fpath) - assert h_data.contains_col("trans_capacity") + assert h_data.contains_col(SupplyCurveField.TRANS_CAPACITY) assert h_data.contains_col("dist_mi") - assert h_data.contains_col("dist_km") + assert h_data.contains_col(SupplyCurveField.DIST_SPUR_KM) assert not h_data.contains_col("dne_col_for_test") @pytest.mark.parametrize("half_hour", [True, False]) @pytest.mark.parametrize( "ratio", - [f"solar_{SupplyCurveField.CAPACITY}/wind_{SupplyCurveField.CAPACITY}", + [f"solar_{SupplyCurveField.CAPACITY_AC_MW}" + f"/wind_{SupplyCurveField.CAPACITY_AC_MW}", f"solar_{SupplyCurveField.AREA_SQ_KM}" f"/wind_{SupplyCurveField.AREA_SQ_KM}"], ) @@ -738,8 +746,8 @@ def test_hybrids_cli_from_config( fv = -999 allow_solar_only, allow_wind_only = input_combination fill_vals = {f"solar_{SupplyCurveField.N_GIDS}": 0, - f"wind_{SupplyCurveField.CAPACITY}": -1} - limits = {f"solar_{SupplyCurveField.CAPACITY}": 100} + f"wind_{SupplyCurveField.CAPACITY_AC_MW}": -1} + limits = {f"solar_{SupplyCurveField.CAPACITY_AC_MW}": 100} if half_hour: sfp, wfp = solar_fpath_30_min, wind_fpath diff --git a/tests/test_supply_curve_aggregation.py b/tests/test_supply_curve_aggregation.py index 06d455d5c..60b4a841a 100644 --- a/tests/test_supply_curve_aggregation.py +++ b/tests/test_supply_curve_aggregation.py @@ -56,6 +56,7 @@ def check_agg(agg_out, baseline_h5): truth = truth.fillna('none') test = test.fillna('none') + test = test[truth.columns] assert_frame_equal(truth, test, check_dtype=False, rtol=0.0001, check_index_type=False) else: diff --git a/tests/test_supply_curve_compute.py b/tests/test_supply_curve_compute.py index af80190d7..61d77ba50 100644 --- a/tests/test_supply_curve_compute.py +++ b/tests/test_supply_curve_compute.py @@ -13,7 +13,7 @@ from pandas.testing import assert_frame_equal from reV import TESTDATADIR -from reV.supply_curve.supply_curve import SupplyCurve +from reV.supply_curve.supply_curve import SupplyCurve, _REQUIRED_OUTPUT_COLS from reV.utilities import SupplyCurveField from reV.utilities.exceptions import SupplyCurveInputError @@ -50,13 +50,13 @@ MULTIPLIERS = pd.read_csv(path).rename(columns=LEGACY_SC_COL_MAP) SC_FULL_COLUMNS = ( - "trans_gid", - "trans_type", - "trans_capacity", - "trans_cap_cost_per_mw", - "dist_km", - "lcot", - "total_lcoe", + SupplyCurveField.TRANS_GID, + SupplyCurveField.TRANS_TYPE, + SupplyCurveField.TRANS_CAPACITY, + SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW, + SupplyCurveField.DIST_SPUR_KM, + SupplyCurveField.LCOT, + SupplyCurveField.TOTAL_LCOE, ) @@ -71,12 +71,16 @@ def baseline_verify(sc_full, fpath_baseline): baseline = baseline.rename(columns=LEGACY_SC_COL_MAP) # double check useful for when tables are changing # but lcoe should be the same - check = np.allclose(baseline["total_lcoe"], sc_full["total_lcoe"]) + check = np.allclose(baseline[SupplyCurveField.TOTAL_LCOE], + sc_full[SupplyCurveField.TOTAL_LCOE]) if not check: diff = np.abs( - baseline["total_lcoe"].values - sc_full["total_lcoe"] + baseline[SupplyCurveField.TOTAL_LCOE].values + - sc_full[SupplyCurveField.TOTAL_LCOE].values + ) + rel_diff = ( + 100 * diff / baseline[SupplyCurveField.TOTAL_LCOE].values ) - rel_diff = 100 * diff / baseline["total_lcoe"].values msg = ( "Total LCOE values differed from baseline. " "Maximum difference is {:.1f} ({:.1f}%), " @@ -162,7 +166,8 @@ def test_integrated_sc_full_friction(): sc_full = pd.read_csv(sc_full) assert SupplyCurveField.MEAN_LCOE_FRICTION in sc_full assert SupplyCurveField.TOTAL_LCOE_FRICTION in sc_full - test = sc_full[SupplyCurveField.MEAN_LCOE_FRICTION] + sc_full['lcot'] + test = (sc_full[SupplyCurveField.MEAN_LCOE_FRICTION] + + sc_full[SupplyCurveField.LCOT]) assert np.allclose(test, sc_full[SupplyCurveField.TOTAL_LCOE_FRICTION]) fpath_baseline = os.path.join( @@ -185,7 +190,7 @@ def test_integrated_sc_simple_friction(): assert SupplyCurveField.MEAN_LCOE_FRICTION in sc_simple assert SupplyCurveField.TOTAL_LCOE_FRICTION in sc_simple test = (sc_simple[SupplyCurveField.MEAN_LCOE_FRICTION] - + sc_simple['lcot']) + + sc_simple[SupplyCurveField.LCOT]) assert np.allclose(test, sc_simple[SupplyCurveField.TOTAL_LCOE_FRICTION]) @@ -226,11 +231,11 @@ def test_sc_warning1(): def test_sc_warning2(): """Run the full SC test without PCA load centers and verify warning.""" - mask = TRANS_TABLE["category"] == "PCALoadCen" + mask = TRANS_TABLE[SupplyCurveField.TRANS_TYPE] == "PCALoadCen" trans_table = TRANS_TABLE[~mask] tcosts = TRANS_COSTS_1.copy() avail_cap_frac = tcosts.pop("available_capacity", 1) - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(record=True) as caught_warnings: warnings.simplefilter("always") sc = SupplyCurve(SC_POINTS, trans_table, sc_features=MULTIPLIERS) with tempfile.TemporaryDirectory() as td: @@ -244,11 +249,8 @@ def test_sc_warning2(): columns=SC_FULL_COLUMNS, ) s1 = "Unconnected sc_gid" - s2 = str(w[0].message) - msg = "Warning failed! Should have Unconnected sc_gid: " "{}".format( - s2 - ) - assert s1 in s2, msg + msg = "Warning failed! Should have Unconnected sc_gid in warning!" + assert any(s1 in str(w.message) for w in caught_warnings), msg def test_parallel(): @@ -284,7 +286,7 @@ def test_parallel(): def verify_trans_cap(sc_table, trans_tables, - cap_col=SupplyCurveField.CAPACITY): + cap_col=SupplyCurveField.CAPACITY_AC_MW): """ Verify that sc_points are connected to features in the correct capacity bins @@ -292,20 +294,24 @@ def verify_trans_cap(sc_table, trans_tables, trans_features = [] for path in trans_tables: - df = pd.read_csv(path) - trans_features.append(df[["trans_gid", "max_cap"]]) + df = pd.read_csv(path).rename(columns=LEGACY_SC_COL_MAP) + trans_features.append(df[[SupplyCurveField.TRANS_GID, "max_cap"]]) trans_features = pd.concat(trans_features) if isinstance(sc_table, str) and os.path.exists(sc_table): - sc_table = pd.read_csv(sc_table) + sc_table = pd.read_csv(sc_table).rename(columns=LEGACY_SC_COL_MAP) if "max_cap" in sc_table and "max_cap" in trans_features: sc_table = sc_table.drop("max_cap", axis=1) - test = sc_table.merge(trans_features, on='trans_gid', how='left') + test = sc_table.merge(trans_features, + on=SupplyCurveField.TRANS_GID, how='left') mask = test[cap_col] > test['max_cap'] - cols = [SupplyCurveField.SC_GID, 'trans_gid', cap_col, 'max_cap'] + cols = [SupplyCurveField.SC_GID, + SupplyCurveField.TRANS_GID, + cap_col, + 'max_cap'] msg = ("SC points connected to transmission features with " "max_cap < sc_cap:\n{}" .format(test.loc[mask, cols])) @@ -375,7 +381,8 @@ def test_substation_conns(): """ tcosts = TRANS_COSTS_1.copy() avail_cap_frac = tcosts.pop("available_capacity", 1) - drop_lines = np.where(TRANS_TABLE["category"] == "TransLine")[0] + drop_lines = np.where(TRANS_TABLE[SupplyCurveField.TRANS_TYPE] + == "TransLine")[0] drop_lines = np.random.choice(drop_lines, 10, replace=False) trans_table = TRANS_TABLE.drop(labels=drop_lines) @@ -407,12 +414,12 @@ def test_multi_parallel_trans(): """ columns = ( - "trans_gid", - "trans_type", - "n_parallel_trans", - "lcot", - "total_lcoe", - "trans_cap_cost_per_mw", + SupplyCurveField.TRANS_GID, + SupplyCurveField.TRANS_TYPE, + SupplyCurveField.N_PARALLEL_TRANS, + SupplyCurveField.LCOT, + SupplyCurveField.TOTAL_LCOE, + SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW, "max_cap", ) @@ -443,21 +450,21 @@ def test_multi_parallel_trans(): assert not (set(sc_2[SupplyCurveField.SC_POINT_GID]) - set(SC_POINTS[SupplyCurveField.SC_POINT_GID])) - assert (sc_2.n_parallel_trans > 1).any() + assert (sc_2[SupplyCurveField.N_PARALLEL_TRANS] > 1).any() - mask_2 = sc_2["n_parallel_trans"] > 1 + mask_2 = sc_2[SupplyCurveField.N_PARALLEL_TRANS] > 1 for gid in sc_2.loc[mask_2, SupplyCurveField.SC_GID]: nx_1 = sc_1.loc[(sc_1[SupplyCurveField.SC_GID] == gid), - 'n_parallel_trans'].values[0] + SupplyCurveField.N_PARALLEL_TRANS].values[0] nx_2 = sc_2.loc[(sc_2[SupplyCurveField.SC_GID] == gid), - 'n_parallel_trans'].values[0] + SupplyCurveField.N_PARALLEL_TRANS].values[0] assert nx_2 >= nx_1 if nx_1 != nx_2: lcot_1 = sc_1.loc[(sc_1[SupplyCurveField.SC_GID] == gid), - 'lcot'].values[0] + SupplyCurveField.LCOT].values[0] lcot_2 = sc_2.loc[(sc_2[SupplyCurveField.SC_GID] == gid), - 'lcot'].values[0] + SupplyCurveField.LCOT].values[0] assert lcot_2 > lcot_1 @@ -520,8 +527,10 @@ def test_least_cost_full_with_reinforcement(): sc_full_r = pd.read_csv(sc_full_r) verify_trans_cap(sc_full, trans_tables) - assert np.allclose(sc_full.trans_gid, sc_full_r.trans_gid) - assert not np.allclose(sc_full.total_lcoe, sc_full_r.total_lcoe) + assert np.allclose(sc_full[SupplyCurveField.TRANS_GID], + sc_full_r[SupplyCurveField.TRANS_GID]) + assert not np.allclose(sc_full[SupplyCurveField.TOTAL_LCOE], + sc_full_r[SupplyCurveField.TOTAL_LCOE]) # pylint: disable=no-member @@ -561,6 +570,10 @@ def test_least_cost_simple_with_reinforcement(): ) in_table = pd.read_csv(in_table) out_fp = os.path.join(td, f"costs_RI_{cap}MW.csv") + in_table["poi_lat"] = 1 + in_table["poi_lon"] = 2 + in_table["reinforcement_poi_lat"] = 3 + in_table["reinforcement_poi_lon"] = 4 in_table["reinforcement_cost_per_mw"] = 1e6 in_table["reinforcement_dist_km"] = 10 in_table.to_csv(out_fp, index=False) @@ -573,9 +586,39 @@ def test_least_cost_simple_with_reinforcement(): verify_trans_cap(sc_simple_r, trans_tables) - assert np.allclose(sc_simple.trans_gid, sc_simple_r.trans_gid) - assert not np.allclose(sc_simple.total_lcoe, - sc_simple_r.total_lcoe) + assert np.allclose(sc_simple[SupplyCurveField.TRANS_GID], + sc_simple_r[SupplyCurveField.TRANS_GID]) + assert not np.allclose(sc_simple[SupplyCurveField.TOTAL_LCOE], + sc_simple_r[SupplyCurveField.TOTAL_LCOE]) + + check_cols = [SupplyCurveField.POI_LAT, + SupplyCurveField.POI_LON, + SupplyCurveField.REINFORCEMENT_POI_LAT, + SupplyCurveField.REINFORCEMENT_POI_LON, + SupplyCurveField.REINFORCEMENT_COST_PER_MW, + SupplyCurveField.REINFORCEMENT_DIST_KM] + nan_cols = [SupplyCurveField.POI_LAT, + SupplyCurveField.POI_LON, + SupplyCurveField.REINFORCEMENT_POI_LAT, + SupplyCurveField.REINFORCEMENT_POI_LON] + for col in check_cols: + assert col in sc_simple + if col in nan_cols: + assert sc_simple[col].isna().all() + else: + assert np.allclose(sc_simple[col], 0) + + assert col in sc_simple_r + assert (sc_simple_r[col] > 0).all() + + assert np.allclose( + sc_simple[SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW] + * 0.1 + / sc_simple[SupplyCurveField.MEAN_CF_AC] + / 8760, + sc_simple[SupplyCurveField.LCOT], + atol=0.001 + ) # pylint: disable=no-member @@ -598,7 +641,7 @@ def test_least_cost_simple_with_trans_cap_cost_per_mw(r_costs): sort_on = "lcoe_no_reinforcement" in_table["reinforcement_cost_per_mw"] = t_gids[::-1] else: - sort_on = "total_lcoe" + sort_on = SupplyCurveField.TOTAL_LCOE in_table["reinforcement_cost_per_mw"] = 0 in_table["reinforcement_dist_km"] = 0 in_table["trans_cap_cost_per_mw"] = t_gids @@ -611,11 +654,21 @@ def test_least_cost_simple_with_trans_cap_cost_per_mw(r_costs): sc_simple = sc.run(out_fpath, fixed_charge_rate=0.1, simple=True, sort_on=sort_on) sc_simple = pd.read_csv(sc_simple) - assert (sc_simple["trans_gid"] == 42445).all() + assert (sc_simple[SupplyCurveField.TRANS_GID] == 42445).all() + + assert np.allclose( + sc_simple[SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW] + * 0.1 + / sc_simple[SupplyCurveField.MEAN_CF_AC] + / 8760, + sc_simple[SupplyCurveField.LCOT], + atol=0.001 + ) if not r_costs: - lcot = 4244.5 / (sc_simple[SupplyCurveField.MEAN_CF] * 8760) - assert np.allclose(lcot, sc_simple["lcot"], atol=0.001) + lcot = 4244.5 / (sc_simple[SupplyCurveField.MEAN_CF_AC] * 8760) + assert np.allclose(lcot, sc_simple[SupplyCurveField.LCOT], + atol=0.001) # pylint: disable=no-member @@ -673,14 +726,11 @@ def test_least_cost_simple_with_reinforcement_floor(): verify_trans_cap(sc_simple, trans_tables) -def test_least_cost_full_pass_through(): +@pytest.mark.parametrize("cols_exist", [True, False]) +def test_least_cost_full_pass_through(cols_exist): """ Test the full supply curve sorting passes through variables correctly """ - check_cols = {'poi_lat', 'poi_lon', 'reinforcement_poi_lat', - 'reinforcement_poi_lon', SupplyCurveField.EOS_MULT, - SupplyCurveField.REG_MULT, - 'reinforcement_cost_per_mw', 'reinforcement_dist_km'} with tempfile.TemporaryDirectory() as td: trans_tables = [] for cap in [100, 200, 400, 1000]: @@ -689,9 +739,9 @@ def test_least_cost_full_pass_through(): ) in_table = pd.read_csv(in_table) out_fp = os.path.join(td, f"costs_RI_{cap}MW.csv") - in_table["reinforcement_cost_per_mw"] = 0 - for col in check_cols: - in_table[col] = 0 + if cols_exist: + for col in _REQUIRED_OUTPUT_COLS: + in_table[col] = 0 in_table.to_csv(out_fp, index=False) trans_tables.append(out_fp) @@ -706,19 +756,19 @@ def test_least_cost_full_pass_through(): ) sc_full = pd.read_csv(sc_full) - for col in check_cols: + for col in _REQUIRED_OUTPUT_COLS: assert col in sc_full - assert np.allclose(sc_full[col], 0) + if cols_exist: + assert np.allclose(sc_full[col], 0) + else: + assert sc_full[col].isna().all() -def test_least_cost_simple_pass_through(): +@pytest.mark.parametrize("cols_exist", [True, False]) +def test_least_cost_simple_pass_through(cols_exist): """ Test the simple supply curve sorting passes through variables correctly """ - check_cols = {'poi_lat', 'poi_lon', 'reinforcement_poi_lat', - 'reinforcement_poi_lon', SupplyCurveField.EOS_MULT, - SupplyCurveField.REG_MULT, - 'reinforcement_cost_per_mw', 'reinforcement_dist_km'} with tempfile.TemporaryDirectory() as td: trans_tables = [] for cap in [100, 200, 400, 1000]: @@ -727,9 +777,9 @@ def test_least_cost_simple_pass_through(): ) in_table = pd.read_csv(in_table) out_fp = os.path.join(td, f"costs_RI_{cap}MW.csv") - in_table["reinforcement_cost_per_mw"] = 0 - for col in check_cols: - in_table[col] = 0 + if cols_exist: + for col in _REQUIRED_OUTPUT_COLS: + in_table[col] = 0 in_table.to_csv(out_fp, index=False) trans_tables.append(out_fp) @@ -738,9 +788,12 @@ def test_least_cost_simple_pass_through(): sc_simple = sc.run(out_fpath, fixed_charge_rate=0.1, simple=True) sc_simple = pd.read_csv(sc_simple) - for col in check_cols: + for col in _REQUIRED_OUTPUT_COLS: assert col in sc_simple - assert np.allclose(sc_simple[col], 0) + if cols_exist: + assert np.allclose(sc_simple[col], 0) + else: + assert sc_simple[col].isna().all() def test_least_cost_simple_with_ac_capacity_column(): @@ -778,23 +831,34 @@ def test_least_cost_simple_with_ac_capacity_column(): trans_tables.append(out_fp) sc = SC_POINTS.copy() - sc[SupplyCurveField.CAPACITY_AC] = sc[SupplyCurveField.CAPACITY] / 1.02 - + sc[SupplyCurveField.CAPACITY_DC_MW] = ( + sc[SupplyCurveField.CAPACITY_AC_MW].values + ) + sc[SupplyCurveField.CAPACITY_AC_MW] = ( + sc[SupplyCurveField.CAPACITY_DC_MW] / 1.02 + ) sc = SupplyCurve(sc, trans_tables, - sc_capacity_col=SupplyCurveField.CAPACITY_AC) + sc_capacity_col=SupplyCurveField.CAPACITY_AC_MW) sc_simple_ac_cap = sc.simple_sort(fcr=0.1) verify_trans_cap(sc_simple_ac_cap, trans_tables, - cap_col=SupplyCurveField.CAPACITY_AC) + cap_col=SupplyCurveField.CAPACITY_AC_MW) - assert np.allclose( - sc_simple["trans_cap_cost_per_mw"] * 1.02, - sc_simple_ac_cap["trans_cap_cost_per_mw"], + tcc_no_r_simple = ( + sc_simple[SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW] + - sc_simple[SupplyCurveField.REINFORCEMENT_COST_PER_MW] + ) + tcc_no_r_simple_ac_cap = ( + sc_simple_ac_cap[SupplyCurveField.TOTAL_TRANS_CAP_COST_PER_MW] + - sc_simple_ac_cap[SupplyCurveField.REINFORCEMENT_COST_PER_MW] ) + assert np.allclose(tcc_no_r_simple * 1.02, tcc_no_r_simple_ac_cap) assert np.allclose( - sc_simple["reinforcement_cost_per_mw"], - sc_simple_ac_cap["reinforcement_cost_per_mw"], + sc_simple[SupplyCurveField.REINFORCEMENT_COST_PER_MW], + sc_simple_ac_cap[SupplyCurveField.REINFORCEMENT_COST_PER_MW], ) - # Final reinforcement costs are slightly cheaper for AC capacity - assert np.all(sc_simple["lcot"] > sc_simple_ac_cap["lcot"]) - assert np.all(sc_simple["total_lcoe"] > sc_simple_ac_cap["total_lcoe"]) + # sc_simple_ac_cap lower capacity so higher cost per unit + assert np.all(sc_simple[SupplyCurveField.LCOT] + < sc_simple_ac_cap[SupplyCurveField.LCOT]) + assert np.all(sc_simple[SupplyCurveField.TOTAL_LCOE] + < sc_simple_ac_cap[SupplyCurveField.TOTAL_LCOE]) diff --git a/tests/test_supply_curve_sc_aggregation.py b/tests/test_supply_curve_sc_aggregation.py index f8d49ac15..a924b4afe 100644 --- a/tests/test_supply_curve_sc_aggregation.py +++ b/tests/test_supply_curve_sc_aggregation.py @@ -130,26 +130,48 @@ def test_agg_summary(): summary = summary.fillna("None") s_baseline = s_baseline.fillna("None") - summary = summary[list(s_baseline.columns)] - assert_frame_equal(summary, s_baseline, check_dtype=False, rtol=0.0001) + assert SupplyCurveField.CAPACITY_AC_MW in summary + assert SupplyCurveField.CAPACITY_DC_MW in summary + assert SupplyCurveField.MEAN_CF_AC in summary + assert SupplyCurveField.MEAN_CF_DC in summary + assert SupplyCurveField.REG_MULT in summary + assert SupplyCurveField.EOS_MULT in summary + + # dc outputs are `None` because old gen file does not have correct + # output dsets + assert not summary[SupplyCurveField.CAPACITY_AC_MW].isna().any() + assert not summary[SupplyCurveField.CAPACITY_DC_MW].isna().all() + assert not summary[SupplyCurveField.MEAN_CF_AC].isna().any() + assert not summary[SupplyCurveField.MEAN_CF_DC].isna().all() + + assert np.allclose(summary[SupplyCurveField.REG_MULT], 1) + assert np.allclose(summary[SupplyCurveField.EOS_MULT], 1) - assert "capacity_ac" not in summary + summary = summary[list(s_baseline.columns)] + assert_frame_equal(summary, s_baseline, check_dtype=False, rtol=0.0001) @pytest.mark.parametrize("pd", [None, 45]) def test_agg_summary_solar_ac(pd): """Test the aggregation summary method for solar ac outputs.""" + with Outputs(GEN, "r") as out: + cf_means_dc = out["cf_mean-means"] + with tempfile.TemporaryDirectory() as td: gen = os.path.join(td, "gen.h5") shutil.copy(GEN, gen) Outputs.add_dataset( gen, "dc_ac_ratio", np.array([1.3] * 188), np.float32 ) + Outputs.add_dataset( + gen, "cf_mean_ac-means", cf_means_dc * 1.3, np.float32 + ) with Outputs(gen, "r") as out: assert "dc_ac_ratio" in out.datasets + assert "cf_mean_ac-means" in out.datasets sca = SupplyCurveAggregation( EXCL, @@ -162,13 +184,34 @@ def test_agg_summary_solar_ac(pd): ) summary = sca.summarize(gen, max_workers=1) - assert SupplyCurveField.CAPACITY_AC in summary - assert np.allclose(summary[SupplyCurveField.CAPACITY] / 1.3, - summary[SupplyCurveField.CAPACITY_AC]) + assert SupplyCurveField.CAPACITY_AC_MW in summary + assert SupplyCurveField.CAPACITY_DC_MW in summary + assert SupplyCurveField.MEAN_CF_AC in summary + assert SupplyCurveField.MEAN_CF_DC in summary + + assert not summary[SupplyCurveField.CAPACITY_AC_MW].isna().any() + assert not summary[SupplyCurveField.CAPACITY_DC_MW].isna().any() + assert not summary[SupplyCurveField.MEAN_CF_AC].isna().any() + assert not summary[SupplyCurveField.MEAN_CF_DC].isna().any() + + assert np.allclose(summary[SupplyCurveField.CAPACITY_DC_MW] / 1.3, + summary[SupplyCurveField.CAPACITY_AC_MW]) + assert np.allclose(summary[SupplyCurveField.CAPACITY_DC_MW] + * summary[SupplyCurveField.MEAN_CF_DC], + summary[SupplyCurveField.CAPACITY_AC_MW] + * summary[SupplyCurveField.MEAN_CF_AC]) + assert np.allclose(summary[SupplyCurveField.CAPACITY_DC_MW] + * summary[SupplyCurveField.MEAN_CF_DC] + * 8760, + summary[SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW]) + assert np.allclose(summary[SupplyCurveField.CAPACITY_AC_MW] + * summary[SupplyCurveField.MEAN_CF_AC] + * 8760, + summary[SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW]) def test_multi_file_excl(): - """Test sc aggregation with multple exclusion file inputs.""" + """Test sc aggregation with multiple exclusion file inputs.""" excl_dict = { "ri_srtm_slope": { @@ -360,7 +403,7 @@ def test_agg_scalar_excl(): ) summary_with_weights = sca.summarize(GEN, max_workers=1) - dsets = [SupplyCurveField.AREA_SQ_KM, SupplyCurveField.CAPACITY] + dsets = [SupplyCurveField.AREA_SQ_KM, SupplyCurveField.CAPACITY_AC_MW] for dset in dsets: diff = summary_base[dset].values / summary_with_weights[dset].values msg = ("Fractional exclusions failed for {} which has values {} and {}" @@ -433,7 +476,7 @@ def test_data_layer_methods(): @pytest.mark.parametrize( "cap_cost_scale", - ["1", f"2 * np.multiply(1000, {SupplyCurveField.CAPACITY}) ** -0.3"] + ["1", f"2 * np.multiply(1000, {SupplyCurveField.CAPACITY_AC_MW}) ** -0.3"] ) def test_recalc_lcoe(cap_cost_scale): """Test supply curve aggregation with the re-calculation of lcoe using the @@ -458,6 +501,19 @@ def test_recalc_lcoe(cap_cost_scale): for k, v in data.items(): arr = np.full(res["meta"].shape, v) res.create_dataset(k, res["meta"].shape, data=arr) + + arr = np.full(res["meta"].shape, data["capital_cost"]) + res.create_dataset("base_capital_cost", res["meta"].shape, + data=arr) + + arr = np.full(res["meta"].shape, data["fixed_operating_cost"]) + res.create_dataset("base_fixed_operating_cost", res["meta"].shape, + data=arr) + + arr = np.full(res["meta"].shape, data["variable_operating_cost"]) + res.create_dataset("base_variable_operating_cost", + res["meta"].shape, data=arr) + for year, cf in zip(years, annual_cf): lcoe = lcoe_fcr(data["fixed_charge_rate"], data["capital_cost"], @@ -521,15 +577,46 @@ def test_recalc_lcoe(cap_cost_scale): assert not np.allclose(summary_base[SupplyCurveField.MEAN_LCOE], summary[SupplyCurveField.MEAN_LCOE]) - if cap_cost_scale == '1': - cc_dset = SupplyCurveField.SC_POINT_CAPITAL_COST + assert np.allclose(summary[SupplyCurveField.EOS_MULT], + summary[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] + / summary[SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW]) + + expected_recalc_lcoe = lcoe_fcr(data["fixed_charge_rate"], + data["capital_cost"], + data["fixed_operating_cost"], + data["system_capacity"] + * np.array(annual_cf).mean() + * 8760, + data["variable_operating_cost"]) + if cap_cost_scale == "1": + assert np.allclose(summary[SupplyCurveField.MEAN_LCOE], + expected_recalc_lcoe) else: - cc_dset = SupplyCurveField.SCALED_SC_POINT_CAPITAL_COST - lcoe = lcoe_fcr(summary['mean_fixed_charge_rate'], - summary[cc_dset], - summary[SupplyCurveField.SC_POINT_FIXED_OPERATING_COST], - summary[SupplyCurveField.SC_POINT_ANNUAL_ENERGY], - summary['mean_variable_operating_cost']) + assert not np.allclose(summary[SupplyCurveField.MEAN_LCOE], + expected_recalc_lcoe) + + fcr = summary[SupplyCurveField.FIXED_CHARGE_RATE] + cap_cost = (summary[SupplyCurveField.COST_SITE_OCC_USD_PER_AC_MW] + * summary[SupplyCurveField.CAPACITY_AC_MW]) + foc = (summary[SupplyCurveField.COST_SITE_FOC_USD_PER_AC_MW] + * summary[SupplyCurveField.CAPACITY_AC_MW]) + voc = (summary[SupplyCurveField.COST_SITE_VOC_USD_PER_AC_MW] + * summary[SupplyCurveField.CAPACITY_AC_MW]) + aep = summary[SupplyCurveField.SC_POINT_ANNUAL_ENERGY_MW] + + lcoe = lcoe_fcr(fcr, cap_cost, foc, aep, voc) + assert np.allclose(lcoe, summary[SupplyCurveField.MEAN_LCOE]) + + cap_cost = (summary[SupplyCurveField.COST_BASE_OCC_USD_PER_AC_MW] + * summary[SupplyCurveField.CAPACITY_AC_MW] + * summary[SupplyCurveField.REG_MULT] + * summary[SupplyCurveField.EOS_MULT]) + foc = (summary[SupplyCurveField.COST_BASE_FOC_USD_PER_AC_MW] + * summary[SupplyCurveField.CAPACITY_AC_MW]) + voc = (summary[SupplyCurveField.COST_BASE_VOC_USD_PER_AC_MW] + * summary[SupplyCurveField.CAPACITY_AC_MW]) + + lcoe = lcoe_fcr(fcr, cap_cost, foc, aep, voc) assert np.allclose(lcoe, summary[SupplyCurveField.MEAN_LCOE]) diff --git a/tests/test_supply_curve_vpd.py b/tests/test_supply_curve_vpd.py index 6b8e334c2..c082e6b59 100644 --- a/tests/test_supply_curve_vpd.py +++ b/tests/test_supply_curve_vpd.py @@ -50,7 +50,7 @@ def test_vpd(): summary = sca.summarize(GEN, max_workers=1) for i in summary.index: - capacity = summary.loc[i, SupplyCurveField.CAPACITY] + capacity = summary.loc[i, SupplyCurveField.CAPACITY_AC_MW] area = summary.loc[i, SupplyCurveField.AREA_SQ_KM] res_gids = np.array(summary.loc[i, SupplyCurveField.RES_GIDS]) gid_counts = np.array(summary.loc[i, SupplyCurveField.GID_COUNTS]) @@ -83,7 +83,8 @@ def test_vpd_fractional_excl(): sca_1 = SupplyCurveAggregation(EXCL, TM_DSET, excl_dict=excl_dict_1, res_class_dset=RES_CLASS_DSET, res_class_bins=RES_CLASS_BINS, - data_layers=DATA_LAYERS, power_density=tmp_path, + data_layers=DATA_LAYERS, + power_density=tmp_path, gids=gids_subset) summary_1 = sca_1.summarize(GEN, max_workers=1) @@ -96,8 +97,8 @@ def test_vpd_fractional_excl(): summary_2 = sca_2.summarize(GEN, max_workers=1) for i in summary_1.index: - cap_full = summary_1.loc[i, SupplyCurveField.CAPACITY] - cap_half = summary_2.loc[i, SupplyCurveField.CAPACITY] + cap_full = summary_1.loc[i, SupplyCurveField.CAPACITY_AC_MW] + cap_half = summary_2.loc[i, SupplyCurveField.CAPACITY_AC_MW] msg = ('Variable power density for fractional exclusions failed! ' 'Index {} has cap full {} and cap half {}' diff --git a/tests/test_supply_curve_wind_dirs.py b/tests/test_supply_curve_wind_dirs.py index 1dff451c7..709312b89 100644 --- a/tests/test_supply_curve_wind_dirs.py +++ b/tests/test_supply_curve_wind_dirs.py @@ -77,7 +77,7 @@ def test_sc_full_wind_dirs(downwind): baseline = pd.read_csv(baseline) baseline = baseline.rename(columns=SupplyCurveField.map_from_legacy()) - assert_frame_equal(sc_out, baseline, check_dtype=False) + assert_frame_equal(sc_out[baseline.columns], baseline, check_dtype=False) @pytest.mark.parametrize('downwind', [False, True]) @@ -100,7 +100,7 @@ def test_sc_simple_wind_dirs(downwind): baseline = pd.read_csv(baseline) baseline = baseline.rename(columns=SupplyCurveField.map_from_legacy()) - assert_frame_equal(sc_out, baseline, check_dtype=False) + assert_frame_equal(sc_out[baseline.columns], baseline, check_dtype=False) def test_upwind_exclusion():