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/supply_curve/supply_curve.py b/reV/supply_curve/supply_curve.py index a64ad88a8..1a7c59553 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,6 +25,27 @@ 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.POI_LAT: 13, + SupplyCurveField.POI_LON: 14, + SupplyCurveField.REINFORCEMENT_POI_LAT: 15, + SupplyCurveField.REINFORCEMENT_POI_LON: 16, + SupplyCurveField.REINFORCEMENT_COST_PER_MW: 9, + SupplyCurveField.REINFORCEMENT_DIST_KM: 5} +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""" @@ -299,15 +321,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] @@ -348,7 +374,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 @@ -426,11 +452,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 [] @@ -441,7 +468,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() @@ -667,9 +694,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) @@ -941,8 +968,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( @@ -954,11 +982,11 @@ 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 + self._trans_table[tcc_per_mw_col] = cost cost *= self._trans_table[self._sc_capacity_col] # align with "mean_cf" @@ -981,24 +1009,27 @@ def compute_total_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()) + + col_name = SupplyCurveField.REINFORCEMENT_COST_PER_MW + r_cost = self._trans_table[col_name].values.copy() + self._trans_table[tcc_per_mw_col] += r_cost + r_cost *= self._trans_table[self._sc_capacity_col] # align with "mean_cf" r_cost /= self._trans_table[self._costs_capacity_col] 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() @@ -1009,7 +1040,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 @@ -1106,15 +1137,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, ): @@ -1170,22 +1201,21 @@ 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) - - arrays = { - final_key: trans_table[source_key].values - for final_key, source_key in all_cols.items() - } + # TODO: Update this to list the uses SupplyCurveField + all_cols = [k for k in 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] + + 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 @@ -1195,7 +1225,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 @@ -1258,36 +1288,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, @@ -1300,13 +1344,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, @@ -1387,8 +1431,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 @@ -1436,14 +1482,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, @@ -1482,9 +1521,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 @@ -1512,19 +1550,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, @@ -1553,14 +1588,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, ): @@ -1628,8 +1656,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 @@ -1695,3 +1722,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 76560836f..90e3e0f59 100644 --- a/reV/utilities/__init__.py +++ b/reV/utilities/__init__.py @@ -146,27 +146,28 @@ class SupplyCurveField(FieldEnum): SC_POINT_ANNUAL_ENERGY_MW = "sc_point_annual_energy" MEAN_FRICTION = "mean_friction" MEAN_LCOE_FRICTION = "mean_lcoe_friction" - TOTAL_LCOE_FRICTION = "total_lcoe_friction" RAW_LCOE = "raw_lcoe" + EOS_MULT = "eos_mult" + REG_MULT = "reg_mult" + 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" + + # 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" N_TURBINES = "n_turbines" - EOS_MULT = "eos_mult" - REG_MULT = "reg_mult" 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" - 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" BESPOKE_AEP = "bespoke_aep" BESPOKE_OBJECTIVE = "bespoke_objective" BESPOKE_CAPITAL_COST = "bespoke_capital_cost" @@ -174,6 +175,23 @@ class SupplyCurveField(FieldEnum): BESPOKE_VARIABLE_OPERATING_COST = "bespoke_variable_operating_cost" BESPOKE_BALANCE_OF_SYSTEM_COST = "bespoke_balance_of_system_cost" + # Transmission outputs + TRANS_GID = "trans_gid_m" + TOTAL_LCOE_FRICTION = "total_lcoe_friction_m" + TOTAL_LCOE = "total_lcoe_m" + TRANS_TYPE = "trans_type_m" + TRANS_CAPACITY = "trans_capacity_m" + DIST_SPUR_KM = "dist_km_m" # "dist_spur_km" + LCOT = "lcot_m" + TOTAL_TRANS_CAP_COST_PER_MW = "trans_cap_cost_per_mw_m" + N_PARALLEL_TRANS = "n_parallel_trans_m" + POI_LAT = "poi_lat_m" + POI_LON = "poi_lon_m" + REINFORCEMENT_POI_LAT = "reinforcement_poi_lat_m" + REINFORCEMENT_POI_LON = "reinforcement_poi_lon_m" + REINFORCEMENT_COST_PER_MW = "reinforcement_cost_per_mw_m" + REINFORCEMENT_DIST_KM = "reinforcement_dist_km_m" + @classmethod def map_from_legacy(cls): """Map of legacy names to current values. @@ -204,6 +222,22 @@ class _LegacySCAliases(Enum): CAPACITY_AC_MW = "capacity" EOS_MULT = "capital_cost_multiplier" + TRANS_GID = "trans_gid" + TOTAL_LCOE_FRICTION = "total_lcoe_friction" + TOTAL_LCOE = "total_lcoe" + TRANS_TYPE = "trans_type", "category" + TRANS_CAPACITY = "trans_capacity", "avail_cap" + DIST_SPUR_KM = "dist_km" + LCOT = "lcot" + TOTAL_TRANS_CAP_COST_PER_MW = "trans_cap_cost_per_mw" + N_PARALLEL_TRANS = "n_parallel_trans" + POI_LAT = "poi_lat" + POI_LON = "poi_lon" + REINFORCEMENT_POI_LAT = "reinforcement_poi_lat" + REINFORCEMENT_POI_LON = "reinforcement_poi_lon" + REINFORCEMENT_COST_PER_MW = "reinforcement_cost_per_mw" + REINFORCEMENT_DIST_KM = "reinforcement_dist_km" + class ModuleName(str, Enum): """A collection of the module names available in reV. diff --git a/tests/test_supply_curve_compute.py b/tests/test_supply_curve_compute.py index ce419a648..49066eb4a 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(): @@ -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,24 @@ 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]) + + nan_cols = [SupplyCurveField.POI_LAT, + SupplyCurveField.POI_LON, + SupplyCurveField.REINFORCEMENT_POI_LAT, + SupplyCurveField.REINFORCEMENT_POI_LON] + for col in _REQUIRED_OUTPUT_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() # pylint: disable=no-member @@ -598,7 +626,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 +639,12 @@ 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() if not r_costs: lcot = 4244.5 / (sc_simple[SupplyCurveField.MEAN_CF_AC] * 8760) - assert np.allclose(lcot, sc_simple["lcot"], atol=0.001) + assert np.allclose(lcot, sc_simple[SupplyCurveField.LCOT], + atol=0.001) # pylint: disable=no-member @@ -673,14 +702,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 +715,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 +732,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 +753,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 +764,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(): @@ -790,15 +819,22 @@ def test_least_cost_simple_with_ac_capacity_column(): verify_trans_cap(sc_simple_ac_cap, trans_tables, 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"]) + 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])