Source code for reV.SAM.econ

# -*- coding: utf-8 -*-
"""reV-to-SAM econ interface module.

Wraps the NREL-PySAM lcoefcr and singleowner modules with
additional reV features.
"""
import logging
from copy import deepcopy
from warnings import warn

import numpy as np
import PySAM.Lcoefcr as PySamLCOE
import PySAM.Singleowner as PySamSingleOwner

from reV.handlers.outputs import Outputs
from reV.SAM.defaults import DefaultLCOE, DefaultSingleOwner
from reV.SAM.SAM import RevPySam
from reV.SAM.windbos import WindBos
from reV.utilities.exceptions import SAMExecutionError
from reV.utilities import ResourceMetaField

logger = logging.getLogger(__name__)


[docs]class Economic(RevPySam): """Base class for SAM economic models.""" MODULE = None def __init__(self, sam_sys_inputs, site_sys_inputs=None, output_request='lcoe_fcr'): """Initialize a SAM economic model object. Parameters ---------- sam_sys_inputs : dict Site-agnostic SAM system model inputs arguments. site_sys_inputs : dict Optional set of site-specific SAM system inputs to complement the site-agnostic inputs. output_request : list | tuple | str Requested SAM output(s) (e.g., 'ppa_price', 'lcoe_fcr'). """ self._site = None if isinstance(output_request, (list, tuple)): self.output_request = output_request else: self.output_request = (output_request,) super().__init__(meta=None, sam_sys_inputs=sam_sys_inputs, site_sys_inputs=site_sys_inputs, output_request=output_request) @staticmethod def _parse_sys_cap(site, inputs, site_df): """Find the system capacity variable in either inputs or df. Parameters ---------- site : int Site gid. inputs : dict Generic system inputs (not site-specific). site_df : pd.DataFrame Site-specific inputs table with index = site gid's Returns ------- sys_cap : int | float System nameplate capacity in native units (SAM is kW). """ if ('system_capacity' not in inputs and 'turbine_capacity' not in inputs and 'system_capacity' not in site_df and 'turbine_capacity' not in site_df): raise SAMExecutionError('Input parameter "system_capacity" ' 'or "turbine_capacity" ' 'must be included in the SAM config ' 'inputs or site-specific inputs in ' 'order to calculate annual energy ' 'yield for LCOE. Received the following ' 'inputs, site_df:\n{}\n{}' .format(inputs, site_df.head())) if 'system_capacity' in inputs: sys_cap = inputs['system_capacity'] elif 'turbine_capacity' in inputs: sys_cap = inputs['turbine_capacity'] elif 'system_capacity' in site_df: sys_cap = site_df.loc[site, 'system_capacity'] elif 'turbine_capacity' in site_df: sys_cap = site_df.loc[site, 'turbine_capacity'] return sys_cap @classmethod def _get_annual_energy(cls, site, site_df, site_gids, cf_arr, inputs, calc_aey): """Get the single-site cf and annual energy and add to site_df. Parameters ---------- site : int Site gid. site_df : pd.DataFrame Dataframe of site-specific input variables. Row index corresponds to site number/gid (via df.loc not df.iloc), column labels are the variable keys that will be passed forward as SAM parameters. site_gids : list List of all site gid values from the cf_file. cf_arr : np.ndarray Array of cf_mean values for all sites in the cf_file for the given year. inputs : dict Dictionary of SAM input parameters. calc_aey : bool Flag to add annual_energy to df. Returns ------- site_df : pd.DataFrame Same as input but with added labels "capacity_factor" and "annual_energy" (latter is dependent on calc_aey flag). """ # get the index location of the site in question isite = site_gids.index(site) # calculate the capacity factor cf = cf_arr[isite] if cf > 1: warn('Capacity factor > 1. Dividing by 100.') cf /= 100 site_df.loc[site, 'capacity_factor'] = cf # calculate the annual energy yield if not input; if calc_aey: # get the system capacity sys_cap = cls._parse_sys_cap(site, inputs, site_df) # Calc annual energy, mult by 8760 to convert kW to kWh aey = sys_cap * cf * 8760 # add aey to site-specific inputs site_df.loc[site, 'annual_energy'] = aey return site_df @staticmethod def _get_cf_profiles(sites, cf_file, year): """Get the multi-site capacity factor time series profiles. Parameters ---------- sites : list List of all site GID's to get gen profiles for. cf_file : str reV generation capacity factor output file with path. year : int | str | None reV generation year to calculate econ for. Looks for cf_mean_{year} or cf_profile_{year}. None will default to a non-year-specific cf dataset (cf_mean, cf_profile). Returns ------- profiles : np.ndarray 2D array (time, n_sites) of all capacity factor profiles for all the requested sites. """ # Retrieve the generation profile for single owner input with Outputs(cf_file) as cfh: # get the index location of the site in question site_gids = list(cfh.get_meta_arr(ResourceMetaField.GID)) isites = [site_gids.index(s) for s in sites] # look for the cf_profile dataset if 'cf_profile' in cfh.datasets: dset = 'cf_profile' elif 'cf_profile-{}'.format(year) in cfh.datasets: dset = 'cf_profile-{}'.format(year) elif 'cf_profile_{}'.format(year) in cfh.datasets: dset = 'cf_profile_{}'.format(year) else: msg = ('Could not find cf_profile values for ' 'input to SingleOwner. Available datasets: {}' .format(cfh.datasets)) logger.error(msg) raise KeyError(msg) profiles = cfh[dset, :, isites] return profiles @classmethod def _make_gen_profile(cls, isite, site, profiles, site_df, inputs): """Get the single-site generation time series and add to inputs dict. Parameters ---------- isite : int Site index in the profiles array. site : int Site resource GID. profiles : np.ndarray 2D array (time, n_sites) of all capacity factor profiles for all the requested sites. site_df : pd.DataFrame Dataframe of site-specific input variables. Row index corresponds to site number/gid (via df.loc not df.iloc), column labels are the variable keys that will be passed forward as SAM parameters. inputs : dict Dictionary of SAM input parameters. Returns ------- inputs : dict Dictionary of SAM input parameters with the generation profile added. """ sys_cap = cls._parse_sys_cap(site, inputs, site_df) inputs['gen'] = profiles[:, isite] * sys_cap return inputs
[docs] def ppa_price(self): """Get PPA price ($/MWh). Native units are cents/kWh, mult by 10 for $/MWh. """ return self['ppa'] * 10
[docs] def npv(self): """Get net present value (NPV) ($). Native units are dollars. """ return self['project_return_aftertax_npv']
[docs] def lcoe_fcr(self): """Get LCOE ($/MWh). Native units are $/kWh, mult by 1000 for $/MWh. """ if 'lcoe_fcr' in self.outputs: lcoe = self.outputs['lcoe_fcr'] else: lcoe = self['lcoe_fcr'] * 1000 return lcoe
[docs] def lcoe_nom(self): """Get nominal LCOE ($/MWh) (from PPA/SingleOwner model). Native units are cents/kWh, mult by 10 for $/MWh. """ return self['lcoe_nom'] * 10
[docs] def lcoe_real(self): """Get real LCOE ($/MWh) (from PPA/SingleOwner model). Native units are cents/kWh, mult by 10 for $/MWh. """ return self['lcoe_real'] * 10
[docs] def flip_actual_irr(self): """Get actual IRR (from PPA/SingleOwner model). Native units are %. """ return self['flip_actual_irr']
[docs] def gross_revenue(self): """Get cash flow total revenue (from PPA/SingleOwner model). Native units are $. """ cf_tr = np.array(self['cf_total_revenue'], dtype=np.float32) cf_tr = np.sum(cf_tr, axis=0) return cf_tr
[docs] def collect_outputs(self): """Collect SAM output_request, convert timeseries outputs to UTC, and save outputs to self.outputs property. """ output_lookup = {'ppa_price': self.ppa_price, 'project_return_aftertax_npv': self.npv, 'lcoe_fcr': self.lcoe_fcr, 'lcoe_nom': self.lcoe_nom, 'lcoe_real': self.lcoe_real, 'flip_actual_irr': self.flip_actual_irr, 'gross_revenue': self.gross_revenue, } super().collect_outputs(output_lookup)
[docs] @classmethod def reV_run(cls, site, site_df, inputs, output_request): """Run the SAM econ model for a single site. Parameters ---------- site : int Site gid. site_df : pd.DataFrame Dataframe of site-specific input variables. Row index corresponds to site number/gid (via df.loc not df.iloc), column labels are the variable keys that will be passed forward as SAM parameters. inputs : dict Dictionary of SAM system input parameters. output_request : list | tuple | str Requested SAM output(s) (e.g., 'ppa_price', 'lcoe_fcr'). Returns ------- sim.outputs : dict Dictionary keyed by SAM variable names with SAM numerical results. """ # Create SAM econ instance and calculate requested output. sim = cls(sam_sys_inputs=inputs, site_sys_inputs=dict(site_df.loc[site, :]), output_request=output_request) sim._site = site sim.assign_inputs() sim.execute() sim.collect_outputs() return sim.outputs
[docs]class LCOE(Economic): """SAM LCOE model. """ MODULE = 'lcoefcr' PYSAM = PySamLCOE def __init__(self, sam_sys_inputs, site_sys_inputs=None, output_request=('lcoe_fcr',)): """Initialize a SAM LCOE economic model object.""" super().__init__(sam_sys_inputs, site_sys_inputs=site_sys_inputs, output_request=output_request) @staticmethod def _parse_lcoe_inputs(site_df, cf_file, year): """Parse for non-site-specific LCOE inputs. Parameters ---------- site_df : pd.DataFrame Dataframe of site-specific input variables. Row index corresponds to site number/gid (via df.loc not df.iloc), column labels are the variable keys that will be passed forward as SAM parameters. cf_file : str reV generation capacity factor output file with path. year : int | str | None reV generation year to calculate econ for. Looks for cf_mean_{year} or cf_profile_{year}. None will default to a non-year-specific cf dataset (cf_mean, cf_profile). Returns ------- site_gids : list List of all site gid values from the cf_file. calc_aey : bool Flag to require calculation of the annual energy yield before running LCOE. cf_arr : np.ndarray Array of cf_mean values for all sites in the cf_file for the given year. """ # get the cf_file meta data gid's to use as indexing tools with Outputs(cf_file) as cfh: site_gids = list(cfh.meta[ResourceMetaField.GID]) calc_aey = False if 'annual_energy' not in site_df: # annual energy yield has not been input, flag to calculate site_df.loc[:, 'annual_energy'] = np.nan calc_aey = True # make sure capacity factor is present in site-specific data if 'capacity_factor' not in site_df: site_df.loc[:, 'capacity_factor'] = np.nan # pull all cf mean values for LCOE calc with Outputs(cf_file) as cfh: if 'cf_mean' in cfh.datasets: cf_arr = cfh['cf_mean'] elif 'cf_mean-{}'.format(year) in cfh.datasets: cf_arr = cfh['cf_mean-{}'.format(year)] elif 'cf_mean_{}'.format(year) in cfh.datasets: cf_arr = cfh['cf_mean_{}'.format(year)] elif 'cf' in cfh.datasets: cf_arr = cfh['cf'] else: raise KeyError('Could not find cf_mean values for LCOE. ' 'Available datasets: {}'.format(cfh.datasets)) return site_gids, calc_aey, cf_arr
[docs] @staticmethod def default(): """Get the executed default pysam LCOE FCR object. Returns ------- PySAM.Lcoefcr """ return DefaultLCOE.default()
[docs] @classmethod def reV_run(cls, points_control, site_df, cf_file, year, output_request=('lcoe_fcr',)): """Execute SAM LCOE simulations based on a reV points control instance. Parameters ---------- points_control : config.PointsControl PointsControl instance containing project points site and SAM config info. site_df : pd.DataFrame Dataframe of site-specific input variables. Row index corresponds to site number/gid (via df.loc not df.iloc), column labels are the variable keys that will be passed forward as SAM parameters. cf_file : str reV generation capacity factor output file with path. year : int | str | None reV generation year to calculate econ for. Looks for cf_mean_{year} or cf_profile_{year}. None will default to a non-year-specific cf dataset (cf_mean, cf_profile). output_request : list | tuple | str Output(s) to retrieve from SAM. Returns ------- out : dict Nested dictionaries where the top level key is the site index, the second level key is the variable name, second level value is the output variable value. """ out = {} site_gids, calc_aey, cf_arr = cls._parse_lcoe_inputs(site_df, cf_file, year) for site in points_control.sites: # get SAM inputs from project_points based on the current site _, inputs = points_control.project_points[site] site_df = cls._get_annual_energy(site, site_df, site_gids, cf_arr, inputs, calc_aey) out[site] = super().reV_run(site, site_df, inputs, output_request) return out
[docs]class SingleOwner(Economic): """SAM single owner economic model. """ MODULE = 'singleowner' PYSAM = PySamSingleOwner def __init__(self, sam_sys_inputs, site_sys_inputs=None, output_request=('ppa_price',)): """Initialize a SAM single owner economic model object. """ super().__init__(sam_sys_inputs, site_sys_inputs=site_sys_inputs, output_request=output_request) # run balance of system cost model if required self.sam_sys_inputs, self.windbos_outputs = \ self._windbos(self.sam_sys_inputs) @staticmethod def _windbos(inputs): """Run SAM Wind Balance of System cost model if requested. Parameters ---------- inputs : dict Dictionary of SAM key-value pair inputs. "total_installed_cost": "windbos" will trigger the windbos method. Returns ------- inputs : dict Dictionary of SAM key-value pair inputs with the total installed cost replaced with WindBOS values if requested. output : dict Dictionary of windbos cost breakdowns. """ outputs = {} if (inputs is not None and 'total_installed_cost' in inputs and isinstance(inputs['total_installed_cost'], str) and inputs['total_installed_cost'].lower() == 'windbos'): wb = WindBos(inputs) inputs['total_installed_cost'] = wb.total_installed_cost outputs = wb.output return inputs, outputs
[docs] @staticmethod def default(): """Get the executed default pysam Single Owner object. Returns ------- PySAM.Singleowner """ return DefaultSingleOwner.default()
[docs] def collect_outputs(self): """Collect SAM output_request, convert timeseries outputs to UTC, and save outputs to self.outputs property. This includes windbos outputs. """ windbos_out_vars = [v for v in self.output_request if v in self.windbos_outputs] self.output_request = [v for v in self.output_request if v not in windbos_out_vars] super().collect_outputs() windbos_results = {} for request in windbos_out_vars: windbos_results[request] = self.windbos_outputs[request] self.outputs.update(windbos_results)
[docs] @classmethod def reV_run(cls, points_control, site_df, cf_file, year, output_request=('ppa_price',)): """Execute SAM SingleOwner simulations based on reV points control. Parameters ---------- points_control : config.PointsControl PointsControl instance containing project points site and SAM config info. site_df : pd.DataFrame Dataframe of site-specific input variables. Row index corresponds to site number/gid (via df.loc not df.iloc), column labels are the variable keys that will be passed forward as SAM parameters. cf_file : str reV generation capacity factor output file with path. year : int | str | None reV generation year to calculate econ for. Looks for cf_mean_{year} or cf_profile_{year}. None will default to a non-year-specific cf dataset (cf_mean, cf_profile). output_request : list | tuple | str Output(s) to retrieve from SAM. Returns ------- out : dict Nested dictionaries where the top level key is the site index, the second level key is the variable name, second level value is the output variable value. """ out = {} profiles = cls._get_cf_profiles(points_control.sites, cf_file, year) for i, site in enumerate(points_control.sites): # get SAM inputs from project_points based on the current site _, inputs = points_control.project_points[site] # ensure that site-specific data is not persisted to other sites site_inputs = deepcopy(inputs) # set the generation profile as an input. site_inputs = cls._make_gen_profile(i, site, profiles, site_df, site_inputs) out[site] = super().reV_run(site, site_df, site_inputs, output_request) return out