# -*- coding: utf-8 -*-
"""reV-to-SAM generation interface module.
Wraps the NREL-PySAM pvwattsv5, windpower, and tcsmolensalt modules with
additional reV features.
"""
import copy
import logging
import os
from abc import ABC, abstractmethod
from tempfile import TemporaryDirectory
from warnings import warn
import numpy as np
import pandas as pd
import PySAM.Geothermal as PySamGeothermal
import PySAM.LinearFresnelDsgIph as PySamLds
import PySAM.MhkWave as PySamMhkWave
import PySAM.Pvsamv1 as PySamDetailedPv
import PySAM.Pvwattsv5 as PySamPv5
import PySAM.Pvwattsv7 as PySamPv7
import PySAM.Pvwattsv8 as PySamPv8
import PySAM.Swh as PySamSwh
import PySAM.TcsmoltenSalt as PySamCSP
import PySAM.TroughPhysicalProcessHeat as PySamTpph
import PySAM.Windpower as PySamWindPower
from reV.losses import PowerCurveLossesMixin, ScheduledLossesMixin
from reV.SAM.defaults import (
DefaultGeothermal,
DefaultLinearFresnelDsgIph,
DefaultMhkWave,
DefaultPvSamv1,
DefaultPvWattsv5,
DefaultPvWattsv8,
DefaultSwh,
DefaultTcsMoltenSalt,
DefaultTroughPhysicalProcessHeat,
DefaultWindPower,
)
from reV.SAM.econ import LCOE, SingleOwner
from reV.SAM.SAM import RevPySam
from reV.utilities import ResourceMetaField, SupplyCurveField
from reV.utilities.curtailment import curtail
from reV.utilities.exceptions import (
InputError,
SAMExecutionError,
SAMInputWarning,
)
logger = logging.getLogger(__name__)
[docs]class AbstractSamGeneration(RevPySam, ScheduledLossesMixin, ABC):
"""Base class for SAM generation simulations."""
def __init__(
self,
resource,
meta,
sam_sys_inputs,
site_sys_inputs=None,
output_request=None,
drop_leap=False,
):
"""Initialize a SAM generation object.
Parameters
----------
resource : pd.DataFrame
Timeseries solar or wind resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation. Remapping will be
done to convert typical NSRDB/WTK names into SAM names (e.g. DNI ->
dn and wind_speed -> windspeed)
meta : pd.DataFrame | pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
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
Requested SAM outputs (e.g., 'cf_mean', 'annual_energy',
'cf_profile', 'gen_profile', 'energy_yield', 'ppa_price',
'lcoe_fcr').
drop_leap : bool
Drops February 29th from the resource data. If False, December
31st is dropped from leap years.
"""
# drop the leap day
if drop_leap:
resource = self.drop_leap(resource)
# make sure timezone and elevation are in the meta data
meta = self.tz_elev_check(sam_sys_inputs, site_sys_inputs, meta)
# don't pass resource to base class,
# set in concrete generation classes instead
super().__init__(
meta,
sam_sys_inputs,
output_request,
site_sys_inputs=site_sys_inputs,
)
# Set the site number using resource
if hasattr(resource, "name"):
self._site = resource.name
else:
self._site = None
# let children pass in None resource
if resource is not None:
self.check_resource_data(resource)
self.set_resource_data(resource, meta)
self.add_scheduled_losses(resource)
@classmethod
def _get_res(cls, res_df, output_request):
"""Get the resource arrays and pass through for output (single site).
Parameters
----------
res_df : pd.DataFrame
2D table with resource data.
output_request : list
Outputs to retrieve from SAM.
Returns
-------
res_mean : dict | None
Dictionary object with variables for resource arrays.
out_req_cleaned : list
Output request list with the resource request entries removed.
"""
out_req_cleaned = copy.deepcopy(output_request)
res_out = None
res_reqs = []
ti = res_df.index
for req in out_req_cleaned:
if req in res_df:
res_reqs.append(req)
if res_out is None:
res_out = {}
res_out[req] = cls.ensure_res_len(res_df[req].values, ti)
for req in res_reqs:
out_req_cleaned.remove(req)
return res_out, out_req_cleaned
@staticmethod
def _get_res_mean(resource, res_gid, output_request):
"""Get the resource annual means (single site).
Parameters
----------
resource : rex.sam_resource.SAMResource
SAM resource object for WIND resource
res_gid : int
Site to extract means for
output_request : list
Outputs to retrieve from SAM.
Returns
-------
res_mean : dict | None
Dictionary object with variables for resource means.
out_req_nomeans : list
Output request list with the resource mean entries removed.
"""
out_req_nomeans = copy.deepcopy(output_request)
res_mean = None
idx = resource.sites.index(res_gid)
irrad_means = (
"dni_mean",
"dhi_mean",
"ghi_mean",
"clearsky_dni_mean",
"clearsky_dhi_mean",
"clearsky_ghi_mean",
)
if "ws_mean" in out_req_nomeans:
out_req_nomeans.remove("ws_mean")
res_mean = {}
res_mean["ws_mean"] = resource["mean_windspeed", idx]
else:
for var in resource.var_list:
label_1 = "{}_mean".format(var)
label_2 = "mean_{}".format(var)
if label_1 in out_req_nomeans:
out_req_nomeans.remove(label_1)
if res_mean is None:
res_mean = {}
res_mean[label_1] = resource[label_2, idx]
if label_1 in irrad_means:
# convert to kWh/m2/day
res_mean[label_1] /= 1000
res_mean[label_1] *= 24
return res_mean, out_req_nomeans
[docs] def check_resource_data(self, resource):
"""Check resource dataframe for NaN values
Parameters
----------
resource : pd.DataFrame
Timeseries solar or wind resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation. Remapping will be
done to convert typical NSRDB/WTK names into SAM names (e.g. DNI ->
dn and wind_speed -> windspeed)
"""
if pd.isna(resource).any().any():
bad_vars = pd.isna(resource).any(axis=0)
bad_vars = resource.columns[bad_vars].values.tolist()
msg = "Found NaN values for site {} in variables {}".format(
self.site, bad_vars
)
logger.error(msg)
raise InputError(msg)
if len(resource) < 8760:
msg = (f"Detected resource time series of length {len(resource)}, "
"which is less than 8760. This may yield unexpected "
"results or fail altogether. If this is not intentional, "
"try setting 'time_index_step: 1' in your SAM config or "
"double check the resource input you're using.")
logger.warning(msg)
warn(msg)
[docs] @abstractmethod
def set_resource_data(self, resource, meta):
"""Placeholder for resource data setting (nsrdb or wtk)"""
[docs] @staticmethod
def tz_elev_check(sam_sys_inputs, site_sys_inputs, meta):
"""Check timezone+elevation input and use json config
timezone+elevation if not in resource meta.
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.
meta : pd.DataFrame | pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
Returns
-------
meta : pd.DataFrame | pd.Series
Dataframe or series for a single site. Will include "timezone"
and "elevation" from the sam and site system inputs if found.
"""
if meta is not None:
axis = 0 if isinstance(meta, pd.core.series.Series) else 1
meta = meta.rename(
SupplyCurveField.map_to(ResourceMetaField), axis=axis
)
if sam_sys_inputs is not None:
if ResourceMetaField.ELEVATION in sam_sys_inputs:
meta[ResourceMetaField.ELEVATION] = sam_sys_inputs[
ResourceMetaField.ELEVATION
]
if ResourceMetaField.TIMEZONE in sam_sys_inputs:
meta[ResourceMetaField.TIMEZONE] = int(
sam_sys_inputs[ResourceMetaField.TIMEZONE]
)
# site-specific inputs take priority over generic system inputs
if site_sys_inputs is not None:
if ResourceMetaField.ELEVATION in site_sys_inputs:
meta[ResourceMetaField.ELEVATION] = site_sys_inputs[
ResourceMetaField.ELEVATION
]
if ResourceMetaField.TIMEZONE in site_sys_inputs:
meta[ResourceMetaField.TIMEZONE] = int(
site_sys_inputs[ResourceMetaField.TIMEZONE]
)
if ResourceMetaField.TIMEZONE not in meta:
msg = (
"Need timezone input to run SAM gen. Not found in "
"resource meta or technology json input config."
)
raise SAMExecutionError(msg)
return meta
@property
def has_timezone(self):
"""Returns true if instance has a timezone set"""
if self._meta is not None and ResourceMetaField.TIMEZONE in self.meta:
return True
return False
[docs] def cf_mean(self):
"""Get mean capacity factor (fractional) from SAM.
Returns
-------
output : float
Mean capacity factor (fractional).
"""
return self["capacity_factor"] / 100
[docs] def cf_profile(self):
"""Get hourly capacity factor (frac) profile in local timezone.
See self.outputs attribute for collected output data in UTC.
Returns
-------
cf_profile : np.ndarray
1D numpy array of capacity factor profile.
Datatype is float32 and array length is 8760*time_interval.
"""
return self.gen_profile() / self.sam_sys_inputs["system_capacity"]
[docs] def annual_energy(self):
"""Get annual energy generation value in kWh from SAM.
Returns
-------
output : float
Annual energy generation (kWh).
"""
return self["annual_energy"]
[docs] def energy_yield(self):
"""Get annual energy yield value in kwh/kw from SAM.
Returns
-------
output : float
Annual energy yield (kwh/kw).
"""
return self["kwh_per_kw"]
[docs] def gen_profile(self):
"""Get power generation profile (local timezone) in kW.
See self.outputs attribute for collected output data in UTC.
Returns
-------
output : np.ndarray
1D array of hourly power generation in kW.
Datatype is float32 and array length is 8760*time_interval.
"""
return np.array(self["gen"], dtype=np.float32)
[docs] def collect_outputs(self, output_lookup=None):
"""Collect SAM output_request, convert timeseries outputs to UTC, and
save outputs to self.outputs property.
Parameters
----------
output_lookup : dict | None
Lookup dictionary mapping output keys to special output methods.
None defaults to generation default outputs.
"""
if output_lookup is None:
output_lookup = {
"cf_mean": self.cf_mean,
"cf_profile": self.cf_profile,
"annual_energy": self.annual_energy,
"energy_yield": self.energy_yield,
"gen_profile": self.gen_profile,
}
super().collect_outputs(output_lookup=output_lookup)
[docs] def run_gen_and_econ(self):
"""Run SAM generation with possibility for follow on econ analysis."""
lcoe_out_reqs = None
so_out_reqs = None
lcoe_vars = (
"lcoe_fcr",
"fixed_charge_rate",
"capital_cost",
"fixed_operating_cost",
"variable_operating_cost",
)
so_vars = (
"ppa_price",
"lcoe_real",
"lcoe_nom",
"project_return_aftertax_npv",
"flip_actual_irr",
"gross_revenue",
)
if "lcoe_fcr" in self.output_request:
lcoe_out_reqs = [r for r in self.output_request if r in lcoe_vars]
self.output_request = [
r for r in self.output_request if r not in lcoe_out_reqs
]
elif any(x in self.output_request for x in so_vars):
so_out_reqs = [r for r in self.output_request if r in so_vars]
self.output_request = [
r for r in self.output_request if r not in so_out_reqs
]
# Execute the SAM generation compute module (pvwattsv7, windpower, etc)
self.run()
# Execute a follow-on SAM econ compute module
# (lcoe_fcr, singleowner, etc)
if lcoe_out_reqs is not None:
self.sam_sys_inputs["annual_energy"] = self.annual_energy()
lcoe = LCOE(self.sam_sys_inputs, output_request=lcoe_out_reqs)
lcoe.assign_inputs()
lcoe.execute()
lcoe.collect_outputs()
self.outputs.update(lcoe.outputs)
elif so_out_reqs is not None:
self.sam_sys_inputs["gen"] = self.gen_profile()
so = SingleOwner(self.sam_sys_inputs, output_request=so_out_reqs)
so.assign_inputs()
so.execute()
so.collect_outputs()
self.outputs.update(so.outputs)
[docs] def run(self):
"""Run a reV-SAM generation object by assigning inputs, executing the
SAM simulation, collecting outputs, and converting all arrays to UTC.
"""
self.assign_inputs()
self.execute()
self.collect_outputs()
[docs] @classmethod
def reV_run(
cls,
points_control,
res_file,
site_df,
lr_res_file=None,
output_request=("cf_mean",),
drop_leap=False,
gid_map=None,
nn_map=None,
bias_correct=None,
):
"""Execute SAM generation based on a reV points control instance.
Parameters
----------
points_control : config.PointsControl
PointsControl instance containing project points site and SAM
config info.
res_file : str
Resource file with full path.
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.
lr_res_file : str | None
Optional low resolution resource file that will be dynamically
mapped+interpolated to the nominal-resolution res_file. This
needs to be of the same format as resource_file, e.g. they both
need to be handled by the same rex Resource handler such as
WindResource
output_request : list | tuple
Outputs to retrieve from SAM.
drop_leap : bool
Drops February 29th from the resource data. If False, December
31st is dropped from leap years.
gid_map : None | dict
Mapping of unique integer generation gids (keys) to single integer
resource gids (values). This enables the user to input unique
generation gids in the project points that map to non-unique
resource gids. This can be None or a pre-extracted dict.
nn_map : np.ndarray
Optional 1D array of nearest neighbor mappings associated with the
res_file to lr_res_file spatial mapping. For details on this
argument, see the rex.MultiResolutionResource docstring.
bias_correct : None | pd.DataFrame
Optional DataFrame or CSV filepath to a wind or solar
resource bias correction table. This has columns:
- ``gid``: GID of site (can be index name of dataframe)
- ``method``: function name from ``rex.bias_correction`` module
The ``gid`` field should match the true resource ``gid`` regardless
of the optional ``gid_map`` input. Only ``windspeed`` **or**
``GHI`` + ``DNI`` + ``DHI`` are corrected, depending on the
technology (wind for the former, PV or CSP for the latter). See the
functions in the ``rex.bias_correction`` module for available
inputs for ``method``. Any additional kwargs required for the
requested ``method`` can be input as additional columns in the
``bias_correct`` table e.g., for linear bias correction functions
you can include ``scalar`` and ``adder`` inputs as columns in the
``bias_correct`` table on a site-by-site basis. If ``None``, no
corrections are applied. By default, ``None``.
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.
"""
# initialize output dictionary
out = {}
# Get the RevPySam resource object
resources = RevPySam.get_sam_res(
res_file,
points_control.project_points,
points_control.project_points.tech,
output_request=output_request,
gid_map=gid_map,
lr_res_file=lr_res_file,
nn_map=nn_map,
bias_correct=bias_correct,
)
# run resource through curtailment filter if applicable
curtailment = points_control.project_points.curtailment
if curtailment is not None:
resources = curtail(
resources, curtailment, random_seed=curtailment.random_seed
)
# iterate through project_points gen_gid values
for gen_gid in points_control.project_points.sites:
# Lookup the resource gid if there's a mapping and get the resource
# data from the SAMResource object using the res_gid.
res_gid = gen_gid if gid_map is None else gid_map[gen_gid]
site_res_df, site_meta = resources._get_res_df(res_gid)
# drop the leap day
if drop_leap:
site_res_df = cls.drop_leap(site_res_df)
_, inputs = points_control.project_points[gen_gid]
# get resource data pass-throughs and resource means
res_outs, out_req_cleaned = cls._get_res(
site_res_df, output_request
)
res_mean, out_req_cleaned = cls._get_res_mean(
resources, res_gid, out_req_cleaned
)
# iterate through requested sites.
sim = cls(
resource=site_res_df,
meta=site_meta,
sam_sys_inputs=inputs,
output_request=out_req_cleaned,
site_sys_inputs=dict(site_df.loc[gen_gid, :]),
)
sim.run_gen_and_econ()
# collect outputs to dictout
out[gen_gid] = sim.outputs
if res_outs is not None:
out[gen_gid].update(res_outs)
if res_mean is not None:
out[gen_gid].update(res_mean)
return out
[docs]class AbstractSamGenerationFromWeatherFile(AbstractSamGeneration, ABC):
"""Base class for running sam generation with a weather file on disk."""
WF_META_DROP_COLS = {
ResourceMetaField.LATITUDE,
ResourceMetaField.LONGITUDE,
ResourceMetaField.ELEVATION,
ResourceMetaField.TIMEZONE,
ResourceMetaField.COUNTRY,
ResourceMetaField.STATE,
ResourceMetaField.COUNTY,
"urban",
"population",
"landcover",
}
@property
@abstractmethod
def PYSAM_WEATHER_TAG(self):
"""Name of the weather file input used by SAM generation module."""
raise NotImplementedError
[docs] def set_resource_data(self, resource, meta):
"""Generate the weather file and set the path as an input.
Some PySAM models require a data file, not raw data. This method
generates the weather data, writes it to a file on disk, and
then sets the file as an input to the generation module. The
function
:meth:`~AbstractSamGenerationFromWeatherFile.run_gen_and_econ`
deletes the file on disk after a run is complete.
Parameters
----------
resource : pd.DataFrame
Time series resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the
required variables to run the respective SAM simulation.
Remapping will be done to convert typical NSRDB/WTK names
into SAM names (e.g. DNI -> dn and wind_speed -> windspeed).
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude,
elevation, and timezone.
"""
meta = self._parse_meta(meta)
self.time_interval = self.get_time_interval(resource.index.values)
pysam_w_fname = self._create_pysam_wfile(resource, meta)
self[self.PYSAM_WEATHER_TAG] = pysam_w_fname
def _create_pysam_wfile(self, resource, meta):
"""Create PySAM weather input file.
Parameters
----------
resource : pd.DataFrame
Time series resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the
required variables to run the respective SAM simulation.
Remapping will be done to convert typical NSRDB/WTK names
into SAM names (e.g. DNI -> dn and wind_speed -> windspeed).
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude,
elevation, and timezone.
Returns
-------
fname : str
Name of weather csv file.
Notes
-----
PySAM will not accept data on Feb 29th. For leap years,
December 31st is dropped and time steps are shifted to relabel
Feb 29th as March 1st, March 1st as March 2nd, etc.
"""
# pylint: disable=attribute-defined-outside-init,consider-using-with
self._temp_dir = TemporaryDirectory()
fname = os.path.join(self._temp_dir.name, "weather.csv")
logger.debug("Creating PySAM weather data file: {}".format(fname))
# ------- Process metadata
m = pd.DataFrame(meta).T
timezone = m[ResourceMetaField.TIMEZONE]
m["Source"] = "NSRDB"
m["Location ID"] = meta.name
m["City"] = "-"
m["State"] = m["state"].apply(lambda x: "-" if x == "None" else x)
m["Country"] = m["country"].apply(lambda x: "-" if x == "None" else x)
m["Latitude"] = m[ResourceMetaField.LATITUDE]
m["Longitude"] = m[ResourceMetaField.LONGITUDE]
m["Time Zone"] = timezone
m["Elevation"] = m[ResourceMetaField.ELEVATION]
m["Local Time Zone"] = timezone
m["Dew Point Units"] = "c"
m["DHI Units"] = "w/m2"
m["DNI Units"] = "w/m2"
m["Temperature Units"] = "c"
m["Pressure Units"] = "mbar"
m["Wind Speed"] = "m/s"
keep_cols = [c for c in m.columns if c not in self.WF_META_DROP_COLS]
m[keep_cols].to_csv(fname, index=False, mode="w")
# --------- Process data
var_map = {
"dni": "DNI",
"dhi": "DHI",
"wind_speed": "Wind Speed",
"air_temperature": "Temperature",
"dew_point": "Dew Point",
"surface_pressure": "Pressure",
}
resource = resource.rename(mapper=var_map, axis="columns")
time_index = resource.index
# Adjust from UTC to local time
local = np.roll(
resource.values, int(timezone * self.time_interval), axis=0
)
resource = pd.DataFrame(
local, columns=resource.columns, index=time_index
)
mask = (time_index.month == 2) & (time_index.day == 29)
time_index = time_index[~mask]
df = pd.DataFrame(index=time_index)
df["Year"] = time_index.year
df["Month"] = time_index.month
df["Day"] = time_index.day
df["Hour"] = time_index.hour
df["Minute"] = time_index.minute
df = df.join(resource.loc[~mask])
df.to_csv(fname, index=False, mode="a")
return fname
[docs] def run_gen_and_econ(self):
"""Run SAM generation and possibility follow-on econ analysis."""
try:
super().run_gen_and_econ()
finally:
temp_dir = getattr(self, "_temp_dir", None)
if temp_dir is not None:
temp_dir.cleanup()
[docs]class AbstractSamSolar(AbstractSamGeneration, ABC):
"""Base Class for Solar generation from SAM"""
[docs] @staticmethod
def agg_albedo(time_index, albedo):
"""Aggregate a timeseries of albedo data to monthly values w len 12 as
required by pysam Pvsamv1
Tech spec from pysam docs:
https://nrel-pysam.readthedocs.io/en/master/modules/Pvsamv1.html
#PySAM.Pvsamv1.Pvsamv1.SolarResource.albedo
Parameters
----------
time_index : pd.DatetimeIndex
Timeseries solar resource datetimeindex
albedo : list
Timeseries Albedo data to be aggregated. Should be 0-1 and likely
hourly or less.
Returns
-------
monthly_albedo : list
1D list of monthly albedo values with length 12
"""
monthly_albedo = np.zeros(12).tolist()
albedo = np.array(albedo)
for month in range(1, 13):
m = np.where(time_index.month == month)[0]
monthly_albedo[int(month - 1)] = albedo[m].mean()
return monthly_albedo
[docs] def set_resource_data(self, resource, meta):
"""Set NSRDB resource data arrays.
Parameters
----------
resource : pd.DataFrame
Timeseries solar or wind resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation. Remapping will be
done to convert typical NSRDB/WTK names into SAM names (e.g. DNI ->
dn and wind_speed -> windspeed)
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
"""
meta = self._parse_meta(meta)
time_index = resource.index
self.time_interval = self.get_time_interval(resource.index.values)
# map resource data names to SAM required data names
var_map = {
"dni": "dn",
"dhi": "df",
"ghi": "gh",
"clearskydni": "dn",
"clearskydhi": "df",
"clearskyghi": "gh",
"windspeed": "wspd",
"airtemperature": "tdry",
"temperature": "tdry",
"temp": "tdry",
"dewpoint": "tdew",
"surfacepressure": "pres",
"pressure": "pres",
"surfacealbedo": "albedo",
}
lower_case = {
k: k.lower().replace(" ", "").replace("_", "")
for k in resource.columns
}
irrad_vars = ["dn", "df", "gh"]
resource = resource.rename(mapper=lower_case, axis="columns")
resource = resource.rename(mapper=var_map, axis="columns")
time_index = resource.index
resource = {
k: np.array(v)
for (k, v) in resource.to_dict(orient="list").items()
}
# set resource variables
for var, arr in resource.items():
if var != "time_index":
# ensure that resource array length is multiple of 8760
arr = self.ensure_res_len(arr, time_index)
n_roll = int(
self._meta[ResourceMetaField.TIMEZONE] * self.time_interval
)
arr = np.roll(arr, n_roll)
if var in irrad_vars and np.min(arr) < 0:
warn(
'Solar irradiance variable "{}" has a minimum '
"value of {}. Truncating to zero.".format(
var, np.min(arr)
),
SAMInputWarning,
)
arr = np.where(arr < 0, 0, arr)
resource[var] = arr.tolist()
resource["lat"] = meta[ResourceMetaField.LATITUDE]
resource["lon"] = meta[ResourceMetaField.LONGITUDE]
resource["tz"] = meta[ResourceMetaField.TIMEZONE]
resource["elev"] = meta.get(ResourceMetaField.ELEVATION, 0.0)
time_index = self.ensure_res_len(time_index, time_index)
resource["minute"] = time_index.minute
resource["hour"] = time_index.hour
resource["month"] = time_index.month
resource["year"] = time_index.year
resource["day"] = time_index.day
if "albedo" in resource:
self["albedo"] = self.agg_albedo(
time_index, resource.pop("albedo")
)
self["solar_resource_data"] = resource
[docs]class AbstractSamPv(AbstractSamSolar, ABC):
"""Photovoltaic (PV) generation with either pvwatts of detailed pv."""
# set these class attrs in concrete subclasses
MODULE = None
PYSAM = None
# pylint: disable=line-too-long
def __init__(
self,
resource,
meta,
sam_sys_inputs,
site_sys_inputs=None,
output_request=None,
drop_leap=False,
):
"""Initialize a SAM solar object.
See the PySAM :py:class:`~PySAM.Pvwattsv8.Pvwattsv8` (or older
version model) or :py:class:`~PySAM.Pvsamv1.Pvsamv1` documentation for
the configuration keys required in the `sam_sys_inputs` config for the
respective models. Some notable keys include the following to enable a
lifetime simulation (non-exhaustive):
- ``system_use_lifetime_output`` : Integer flag indicating whether
or not to run a full lifetime model (0 for off, 1 for on). If
running a lifetime model, the resource file will be repeated
for the number of years specified as the lifetime of the
plant and a performance degradation term will be used to
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`` 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`` is set to 1.
You may also include the following ``reV``-specific keys:
- ``reV_outages`` : Specification for ``reV``-scheduled
stochastic outage losses. For example::
outage_info = [
{
'count': 6,
'duration': 24,
'percentage_of_capacity_lost': 100,
'allowed_months': ['January', 'March'],
'allow_outage_overlap': True
},
{
'count': 10,
'duration': 1,
'percentage_of_capacity_lost': 10,
'allowed_months': ['January'],
'allow_outage_overlap': False
},
...
]
See the description of
:meth:`~reV.losses.scheduled.ScheduledLossesMixin.add_scheduled_losses`
or the
`reV losses demo notebook <https://tinyurl.com/4d7uutt3/>`_
for detailed instructions on how to specify this input.
- ``reV_outages_seed`` : Integer value used to seed the RNG
used to compute stochastic outage losses.
- ``time_index_step`` : Integer representing the step size
used to sample the ``time_index`` in the resource data.
This can be used to reduce temporal resolution (i.e. for
30 minute NSRDB input data, ``time_index_step=1`` yields
the full 30 minute time series as output, while
``time_index_step=2`` yields hourly output, and so forth).
.. Note:: The reduced data shape (i.e. after applying a
step size of `time_index_step`) must still be an
integer multiple of 8760, or the execution will
fail.
- ``clearsky`` : Boolean flag value indicating wether
computation should use clearsky resource data to compute
generation data.
Parameters
----------
resource : pd.DataFrame
Timeseries solar or wind resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation. Remapping will be
done to convert typical NSRDB/WTK names into SAM names (e.g. DNI ->
dn and wind_speed -> windspeed)
meta : pd.DataFrame | pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
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
Requested SAM outputs (e.g., 'cf_mean', 'annual_energy',
'cf_profile', 'gen_profile', 'energy_yield', 'ppa_price',
'lcoe_fcr').
drop_leap : bool
Drops February 29th from the resource data. If False, December
31st is dropped from leap years.
"""
# need to check tilt=lat and azimuth for pv systems
meta = self._parse_meta(meta)
sam_sys_inputs = self.set_latitude_tilt_az(sam_sys_inputs, meta)
super().__init__(
resource,
meta,
sam_sys_inputs,
site_sys_inputs=site_sys_inputs,
output_request=output_request,
drop_leap=drop_leap,
)
[docs] def set_resource_data(self, resource, meta):
"""Set NSRDB resource data arrays.
Parameters
----------
resource : pd.DataFrame
Timeseries solar or wind resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation. Remapping will be
done to convert typical NSRDB/WTK names into SAM names (e.g. DNI ->
dn and wind_speed -> windspeed)
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
Raises
------
ValueError : If lat/lon outside of -90 to 90 and -180 to 180,
respectively.
"""
bad_location_input = (
(meta[ResourceMetaField.LATITUDE] < -90)
| (meta[ResourceMetaField.LATITUDE] > 90)
| (meta[ResourceMetaField.LONGITUDE] < -180)
| (meta[ResourceMetaField.LONGITUDE] > 180)
)
if bad_location_input.any():
raise ValueError(
"Detected latitude/longitude values outside of "
"the range -90 to 90 and -180 to 180, "
"respectively. Please ensure input resource data"
"locations conform to these ranges. "
)
return super().set_resource_data(resource, meta)
[docs] @staticmethod
def set_latitude_tilt_az(sam_sys_inputs, meta):
"""Check if tilt is specified as latitude and set tilt=lat, az=180 or 0
Parameters
----------
sam_sys_inputs : dict
Site-agnostic SAM system model inputs arguments.
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
Returns
-------
sam_sys_inputs : dict
Site-agnostic SAM system model inputs arguments.
If for a pv simulation the "tilt" parameter was originally not
present or set to 'lat' or MetaKeyName.LATITUDE, the tilt will be
set to the absolute value of the latitude found in meta and the
azimuth will be 180 if lat>0, 0 if lat<0.
"""
set_tilt = False
if sam_sys_inputs is not None and meta is not None:
if "tilt" not in sam_sys_inputs:
warn(
"No tilt specified, setting at latitude.", SAMInputWarning
)
set_tilt = True
elif (
sam_sys_inputs["tilt"] == "lat"
or sam_sys_inputs["tilt"] == ResourceMetaField.LATITUDE
) or (
sam_sys_inputs["tilt"] == "lat"
or sam_sys_inputs["tilt"] == ResourceMetaField.LATITUDE
):
set_tilt = True
if set_tilt:
# set tilt to abs(latitude)
sam_sys_inputs["tilt"] = np.abs(meta[ResourceMetaField.LATITUDE])
if meta[ResourceMetaField.LATITUDE] > 0:
# above the equator, az = 180
sam_sys_inputs["azimuth"] = 180
else:
# below the equator, az = 0
sam_sys_inputs["azimuth"] = 0
logger.debug(
'Tilt specified at "latitude", setting tilt to: {}, '
"azimuth to: {}".format(
sam_sys_inputs["tilt"], sam_sys_inputs["azimuth"]
)
)
return sam_sys_inputs
[docs] def system_capacity_ac(self):
"""Get AC system capacity from SAM inputs.
NOTE: AC nameplate = DC nameplate / ILR
Returns
-------
cf_profile : float
AC nameplate = DC nameplate / ILR
"""
return (
self.sam_sys_inputs["system_capacity"]
/ self.sam_sys_inputs["dc_ac_ratio"]
)
[docs] def cf_mean(self):
"""Get mean capacity factor (fractional) from SAM.
NOTE: PV capacity factor is the AC power production / the DC nameplate
Returns
-------
output : float
Mean capacity factor (fractional).
PV CF is calculated as AC power / DC nameplate.
"""
return self["capacity_factor"] / 100
[docs] def cf_mean_ac(self):
"""Get mean AC capacity factor (fractional) from SAM.
NOTE: This value only available in PVWattsV8 and up.
Returns
-------
output : float
Mean AC capacity factor (fractional).
PV AC CF is calculated as AC power / AC nameplate.
"""
return self["capacity_factor_ac"] / 100
[docs] def cf_profile(self):
"""Get hourly capacity factor (frac) profile in local timezone.
See self.outputs attribute for collected output data in UTC.
NOTE: PV capacity factor is the AC power production / the DC nameplate
Returns
-------
cf_profile : np.ndarray
1D numpy array of capacity factor profile.
Datatype is float32 and array length is 8760*time_interval.
PV CF is calculated as AC power / DC nameplate.
"""
return self.gen_profile() / self.sam_sys_inputs["system_capacity"]
[docs] def cf_profile_ac(self):
"""Get hourly AC capacity factor (frac) profile in local timezone.
See self.outputs attribute for collected output data in UTC.
NOTE: PV AC capacity factor is the AC power production / the AC
nameplate. AC nameplate = DC nameplate / ILR
Returns
-------
cf_profile : np.ndarray
1D numpy array of capacity factor profile.
Datatype is float32 and array length is 8760*time_interval.
PV AC CF is calculated as AC power / AC nameplate.
"""
return self.gen_profile() / self.system_capacity_ac()
[docs] def gen_profile(self):
"""Get AC inverter power generation profile (local timezone) in kW.
This is an alias of the "ac" SAM output variable if PySAM version>=3.
See self.outputs attribute for collected output data in UTC.
Returns
-------
output : np.ndarray
1D array of AC inverter power generation in kW.
Datatype is float32 and array length is 8760*time_interval.
"""
return np.array(self["gen"], dtype=np.float32)
[docs] def ac(self):
"""Get AC inverter power generation profile (local timezone) in kW.
See self.outputs attribute for collected output data in UTC.
Returns
-------
output : np.ndarray
1D array of AC inverter power generation in kW.
Datatype is float32 and array length is 8760*time_interval.
"""
return np.array(self["ac"], dtype=np.float32) / 1000
[docs] def dc(self):
"""
Get DC array power generation profile (local timezone) in kW.
See self.outputs attribute for collected output data in UTC.
Returns
-------
output : np.ndarray
1D array of DC array power generation in kW.
Datatype is float32 and array length is 8760*time_interval.
"""
return np.array(self["dc"], dtype=np.float32) / 1000
[docs] def clipped_power(self):
"""
Get the clipped DC power generated behind the inverter
(local timezone) in kW.
See self.outputs attribute for collected output data in UTC.
Returns
-------
clipped : np.ndarray
1D array of clipped DC power in kW.
Datatype is float32 and array length is 8760*time_interval.
"""
ac = self.ac()
dc = self.dc()
return np.where(ac < ac.max(), 0, dc - ac)
[docs] @staticmethod
@abstractmethod
def default():
"""Get the executed default pysam object."""
[docs] def collect_outputs(self, output_lookup=None):
"""Collect SAM output_request, convert timeseries outputs to UTC, and
save outputs to self.outputs property.
Parameters
----------
output_lookup : dict | None
Lookup dictionary mapping output keys to special output methods.
None defaults to generation default outputs.
"""
if output_lookup is None:
output_lookup = {
"cf_mean": self.cf_mean,
"cf_mean_ac": self.cf_mean_ac,
"cf_profile": self.cf_profile,
"cf_profile_ac": self.cf_profile_ac,
"annual_energy": self.annual_energy,
"energy_yield": self.energy_yield,
"gen_profile": self.gen_profile,
"ac": self.ac,
"dc": self.dc,
"clipped_power": self.clipped_power,
"system_capacity_ac": self.system_capacity_ac,
}
super().collect_outputs(output_lookup=output_lookup)
[docs]class PvWattsv5(AbstractSamPv):
"""Photovoltaic (PV) generation with pvwattsv5."""
MODULE = "pvwattsv5"
PYSAM = PySamPv5
[docs] @staticmethod
def default():
"""Get the executed default pysam PVWATTSV5 object.
Returns
-------
PySAM.Pvwattsv5
"""
return DefaultPvWattsv5.default()
[docs]class PvWattsv7(AbstractSamPv):
"""Photovoltaic (PV) generation with pvwattsv7."""
MODULE = "pvwattsv7"
PYSAM = PySamPv7
[docs] @staticmethod
def default():
"""Get the executed default pysam PVWATTSV7 object.
Returns
-------
PySAM.Pvwattsv7
"""
raise NotImplementedError("Pvwattsv7 default file no longer exists!")
[docs]class PvWattsv8(AbstractSamPv):
"""Photovoltaic (PV) generation with pvwattsv8."""
MODULE = "pvwattsv8"
PYSAM = PySamPv8
[docs] @staticmethod
def default():
"""Get the executed default pysam PVWATTSV8 object.
Returns
-------
PySAM.Pvwattsv8
"""
return DefaultPvWattsv8.default()
[docs]class PvSamv1(AbstractSamPv):
"""Detailed PV model"""
MODULE = "Pvsamv1"
PYSAM = PySamDetailedPv
[docs] def ac(self):
"""Get AC inverter power generation profile (local timezone) in kW.
See self.outputs attribute for collected output data in UTC.
Returns
-------
output : np.ndarray
1D array of AC inverter power generation in kW.
Datatype is float32 and array length is 8760*time_interval.
"""
return np.array(self["gen"], dtype=np.float32)
[docs] def dc(self):
"""
Get DC array power generation profile (local timezone) in kW.
See self.outputs attribute for collected output data in UTC.
Returns
-------
output : np.ndarray
1D array of DC array power generation in kW.
Datatype is float32 and array length is 8760*time_interval.
"""
return np.array(self["dc_net"], dtype=np.float32)
[docs] @staticmethod
def default():
"""Get the executed default pysam Pvsamv1 object.
Returns
-------
PySAM.Pvsamv1
"""
return DefaultPvSamv1.default()
[docs]class TcsMoltenSalt(AbstractSamSolar):
"""Concentrated Solar Power (CSP) generation with tower molten salt"""
MODULE = "tcsmolten_salt"
PYSAM = PySamCSP
[docs] def cf_profile(self):
"""Get absolute value hourly capacity factor (frac) profile in
local timezone.
See self.outputs attribute for collected output data in UTC.
Returns
-------
cf_profile : np.ndarray
1D numpy array of capacity factor profile.
Datatype is float32 and array length is 8760*time_interval.
"""
x = np.abs(self.gen_profile() / self.sam_sys_inputs["system_capacity"])
return x
[docs] @staticmethod
def default():
"""Get the executed default pysam CSP object.
Returns
-------
PySAM.TcsmoltenSalt
"""
return DefaultTcsMoltenSalt.default()
[docs]class SolarWaterHeat(AbstractSamGenerationFromWeatherFile):
"""
Solar Water Heater generation
"""
MODULE = "solarwaterheat"
PYSAM = PySamSwh
PYSAM_WEATHER_TAG = "solar_resource_file"
[docs] @staticmethod
def default():
"""Get the executed default pysam swh object.
Returns
-------
PySAM.Swh
"""
return DefaultSwh.default()
[docs]class LinearDirectSteam(AbstractSamGenerationFromWeatherFile):
"""
Process heat linear Fresnel direct steam generation
"""
MODULE = "lineardirectsteam"
PYSAM = PySamLds
PYSAM_WEATHER_TAG = "file_name"
[docs] def cf_mean(self):
"""Calculate mean capacity factor (fractional) from SAM.
Returns
-------
output : float
Mean capacity factor (fractional).
"""
net_power = (
self["annual_field_energy"] - self["annual_thermal_consumption"]
) # kW-hr
# q_pb_des is in MW, convert to kW-hr
name_plate = self["q_pb_des"] * 8760 * 1000
return net_power / name_plate
[docs] @staticmethod
def default():
"""Get the executed default pysam linear Fresnel object.
Returns
-------
PySAM.LinearFresnelDsgIph
"""
return DefaultLinearFresnelDsgIph.default()
[docs]class TroughPhysicalHeat(AbstractSamGenerationFromWeatherFile):
"""
Trough Physical Process Heat generation
"""
MODULE = "troughphysicalheat"
PYSAM = PySamTpph
PYSAM_WEATHER_TAG = "file_name"
[docs] def cf_mean(self):
"""Calculate mean capacity factor (fractional) from SAM.
Returns
-------
output : float
Mean capacity factor (fractional).
"""
net_power = (
self["annual_gross_energy"] - self["annual_thermal_consumption"]
) # kW-hr
# q_pb_des is in MW, convert to kW-hr
name_plate = self["q_pb_design"] * 8760 * 1000
return net_power / name_plate
[docs] @staticmethod
def default():
"""Get the executed default pysam trough object.
Returns
-------
PySAM.TroughPhysicalProcessHeat
"""
return DefaultTroughPhysicalProcessHeat.default()
# pylint: disable=line-too-long
[docs]class Geothermal(AbstractSamGenerationFromWeatherFile):
"""reV-SAM geothermal generation.
As of 12/20/2022, the resource potential input in SAM is only used
to calculate the number of well replacements during the lifetime of
a geothermal plant. It was decided that reV would not model well
replacements. Therefore, reV sets the resource potential to match
(or be just above) the gross potential so that SAM does not throw
any errors.
Also as of 12/20/2022, the SAM GETEM module requires a weather file,
but does not actually require any weather data to run. Therefore,
reV currently generates an empty weather file to pass to SAM. This
behavior can be easily updated in the future should the SAM GETEM
module start using weather data.
See the PySAM :py:class:`~PySAM.Geothermal.Geothermal` documentation
for the configuration keys required in the `sam_sys_inputs` config.
Some notable keys include (non-exhaustive):
- ``resource_type`` : Integer flag representing either
Hydrothermal (0) or EGS (1) resource. Only values of 0 or 1
allowed.
- ``resource_potential`` : Total resource potential at location
(in MW).
.. Important:: ``reV`` automatically sets the resource
potential to match the gross potential (see documentation
above), so this key should be left out of the config (it
will be overridden in any case).
- ``resource_temp`` : Temperature of resource (in C).
.. Important:: This value is set by ``reV`` based on the
user's geothermal resource data input. To override this
behavior, users *may* specify their own ``resource_temp``
value (either a single value for all sites in the SAM
geothermal config or a site-dependent value in the project
points CSV). In this case, the resource temperature from
the input data will be ignored completely, and the
temperature at each location will be determined solely from
this input.
- ``resource_depth`` : Depth to geothermal resource (in m).
- ``analysis_type`` : Integer flag representing the plant
configuration. If the ``nameplate`` input is to be used to
specify the plant capacity, then this flag should be set to 0
(this is the default ``reV`` assumption). Otherwise, if the
``num_wells`` input is to be used to specify the plant site,
then this flag should be set to 1. Only values of 0 or 1
allowed.
- ``nameplate`` : Geothermal plant size (in kW). Only affects
the output if ``analysis_type=0``.
.. Important:: Unlike wind or solar, ``reV`` geothermal
dynamically sets the size of a geothermal plant. In
particular, the plant capacity is set to match the resource
potential (obtained from the input data) for each site. For
this to work, users **must** leave out the ``nameplate``
key from the SAM config.
Alternatively, users *may* specify their own ``nameplate``
capacity value (either a single value for all sites in the
SAM geothermal config or a site-dependent value in the
project points CSV). In this case, the resource potential
from the input data will be ignored completely, and the
capacity at each location will be determined solely from
this input.
- ``num_wells`` : Number of wells at each plant. This value is
used to determined plant capacity if ``analysis_type=1``.
Otherwise this input has no effect.
- ``num_wells_getem`` : Number of wells assumed at each plant
for power block calculations. Only affects power block outputs
if ``analysis_type=0`` (otherwise the ``num_wells`` input is
used in power block calculations).
.. Note:: ``reV`` does not currently adjust this value based
on the resource input (as it probably should). If any
power block outputs are required in the future, there may
need to be extra development to set this value based on
the dynamically calculated plant size.
- ``conversion_type`` : Integer flag representing the conversion
plant type. Either Binary (0) or Flash (1). Only values of 0
or 1 allowed.
- ``geotherm.cost.inj_prod_well_ratio`` : Fraction representing
the injection to production well ratio (0-1). SAM GUI defaults
to 0.5 for this value, but it is recommended to set this to
the GETEM default of 0.75.
You may also include the following ``reV``-specific keys:
- ``num_confirmation_wells`` : Number of confirmation wells that
can also be used as production wells. This number is used to
determined to total number of wells required at each plant,
and therefore the total drilling costs. This value defaults to
2 (to match the SAM GUI as of 8/1/2023). However, the default
value can lead to negative costs if the plant size is small
(e.g. only 1 production well is needed, so the costs equal
-1 * ``drill_cost_per_well``). This is a limitation of the
SAM calculations (as of 8/1/2023), and it is therefore useful
to set ``num_confirmation_wells=0`` when performing ``reV``
runs for small plant sizes.
- ``capital_cost_per_kw`` : Capital cost values in $/kW. If
this value is specified in the config, reV calculates and
overrides the total ``capital_cost`` value based on the
geothermal plant size (capacity) at each location.
- ``fixed_operating_cost`` : Fixed operating cost values in
$/kW. If this value is specified in the config, reV calculates
and overrides the total ``fixed_operating_cost`` value based
on the geothermal plant size (capacity) at each location.
- ``drill_cost_per_well`` : Drilling cost per well, in $. If
this value is specified in the config, reV calculates the
total drilling costs based on the number of wells that need to
be drilled at each location. The drilling costs are added to
the total ``capital_cost`` at each location.
- ``reV_outages`` : Specification for ``reV``-scheduled
stochastic outage losses. For example::
outage_info = [
{
'count': 6,
'duration': 24,
'percentage_of_capacity_lost': 100,
'allowed_months': ['January', 'March'],
'allow_outage_overlap': True
},
{
'count': 10,
'duration': 1,
'percentage_of_capacity_lost': 10,
'allowed_months': ['January'],
'allow_outage_overlap': False
},
...
]
See the description of
:meth:`~reV.losses.scheduled.ScheduledLossesMixin.add_scheduled_losses`
or the
`reV losses demo notebook <https://tinyurl.com/4d7uutt3/>`_
for detailed instructions on how to specify this input.
- ``reV_outages_seed`` : Integer value used to seed the RNG
used to compute stochastic outage losses.
- ``time_index_step`` : Integer representing the step size
used to sample the ``time_index`` in the resource data.
This can be used to reduce temporal resolution (i.e. for
30 minute NSRDB input data, ``time_index_step=1`` yields
the full 30 minute time series as output, while
``time_index_step=2`` yields hourly output, and so forth).
"""
MODULE = "geothermal"
PYSAM = PySamGeothermal
PYSAM_WEATHER_TAG = "file_name"
_RESOURCE_POTENTIAL_MULT = 1.001
_DEFAULT_NUM_CONFIRMATION_WELLS = 2 # SAM GUI default as of 5/26/23
[docs] @staticmethod
def default():
"""Get the executed default PySAM Geothermal object.
Returns
-------
PySAM.Geothermal
"""
return DefaultGeothermal.default()
[docs] def cf_profile(self):
"""Get hourly capacity factor (frac) profile in local timezone.
See self.outputs attribute for collected output data in UTC.
Returns
-------
cf_profile : np.ndarray
1D numpy array of capacity factor profile.
Datatype is float32 and array length is 8760*time_interval.
"""
return self.gen_profile() / self.sam_sys_inputs["nameplate"]
[docs] def set_resource_data(self, resource, meta):
"""Generate the weather file and set the path as an input.
The Geothermal PySAM model requires a data file, not raw data.
This method generates the weather data, writes it to a file on
disk, and then sets the file as an input to the Geothermal
generation module. The function
:meth:`~AbstractSamGenerationFromWeatherFile.run_gen_and_econ`
deletes the file on disk after a run is complete.
Parameters
----------
resource : pd.DataFrame
Time series resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the
required variables to run the respective SAM simulation.
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude,
elevation, and timezone.
"""
super().set_resource_data(resource, meta)
self._set_resource_temperature(resource)
self._set_egs_plant_design_temperature()
self._set_nameplate_to_match_resource_potential(resource)
self._set_resource_potential_to_match_gross_output()
self._set_costs()
def _set_resource_temperature(self, resource):
"""Set resource temp from data if user did not specify it."""
if "resource_temp" in self.sam_sys_inputs:
logger.debug(
"Found 'resource_temp' value in SAM config: {:.2f}".format(
self.sam_sys_inputs["resource_temp"]
)
)
return
val = set(resource["temperature"].unique())
logger.debug(
"Found {} value(s) for 'temperature' in resource data".format(
len(val)
)
)
if len(val) > 1:
msg = (
"Found multiple values for 'temperature' for site "
"{}: {}".format(self.site, val)
)
logger.error(msg)
raise InputError(msg)
val = val.pop()
logger.debug(
"Input 'resource_temp' not found in SAM config - setting "
"to {:.2f} based on input resource data.".format(val)
)
self.sam_sys_inputs["resource_temp"] = val
def _set_egs_plant_design_temperature(self):
"""Set the EGS plant temp to match resource (avoids cf > 1)"""
if self.sam_sys_inputs.get("resource_type") != 1:
return # Not EGS run
resource_temp = self.sam_sys_inputs["resource_temp"]
logger.debug("Setting EGS plant design temperature to match "
"resource temperature ({}C)".format(resource_temp))
self.sam_sys_inputs["design_temp"] = resource_temp
def _set_nameplate_to_match_resource_potential(self, resource):
"""Set the nameplate capacity to match the resource potential."""
if "nameplate" in self.sam_sys_inputs:
msg = (
'Found "nameplate" input in config! Resource potential '
"from input data will be ignored. Nameplate capacity is "
"{}".format(self.sam_sys_inputs["nameplate"])
)
logger.info(msg)
# required for downstream LCOE calcs
self.sam_sys_inputs["system_capacity"] = (
self.sam_sys_inputs["nameplate"]
)
return
val = set(resource["potential_MW"].unique())
if len(val) > 1:
msg = (
'Found multiple values for "potential_MW" for site '
"{}: {}".format(self.site, val)
)
logger.error(msg)
raise InputError(msg)
val = val.pop() * 1000
logger.debug("Setting the nameplate to {}".format(val))
self.sam_sys_inputs["nameplate"] = val
# required for downstream LCOE calcs
self.sam_sys_inputs["system_capacity"] = val
def _set_resource_potential_to_match_gross_output(self):
"""Set the resource potential input to match the gross generation.
If SAM throws an error during the UI calculation of the gross
output, the resource_potential is simply set to -1 since
SAM will error out for this point regardless of the
resource_potential input.
"""
super().assign_inputs()
self["ui_calculations_only"] = 1
try:
self.execute()
except SAMExecutionError:
self["ui_calculations_only"] = 0
self.sam_sys_inputs["resource_potential"] = -1
return
gross_gen = (
self.pysam.Outputs.gross_output * self._RESOURCE_POTENTIAL_MULT
)
if "resource_potential" in self.sam_sys_inputs:
msg = (
'Setting "resource_potential" is not allowed! Updating '
"user input of {} to match the gross generation: {}".format(
self.sam_sys_inputs["resource_potential"], gross_gen
)
)
logger.warning(msg)
warn(msg)
logger.debug(
"Setting the resource potential to {} MW".format(gross_gen)
)
self.sam_sys_inputs["resource_potential"] = gross_gen
ncw = self.sam_sys_inputs.pop(
"num_confirmation_wells", self._DEFAULT_NUM_CONFIRMATION_WELLS
)
self.sam_sys_inputs["prod_and_inj_wells_to_drill"] = (
self.pysam.Outputs.num_wells_getem_output
- ncw
+ self.pysam.Outputs.num_wells_getem_inj
)
self["ui_calculations_only"] = 0
def _set_costs(self):
"""Set the costs based on gross plant generation."""
plant_size_kw = (
self.sam_sys_inputs["resource_potential"]
/ self._RESOURCE_POTENTIAL_MULT
) * 1000
cc_per_kw = self.sam_sys_inputs.pop("capital_cost_per_kw", None)
if cc_per_kw is not None:
capital_cost = cc_per_kw * plant_size_kw
logger.debug(
"Setting the capital_cost to ${:,.2f}".format(capital_cost)
)
reg_mult = self.sam_sys_inputs.get("capital_cost_multiplier", 1)
self.sam_sys_inputs["base_capital_cost"] = capital_cost
self.sam_sys_inputs["capital_cost"] = capital_cost * reg_mult
dc_per_well = self.sam_sys_inputs.pop("drill_cost_per_well", None)
num_wells = self.sam_sys_inputs.pop(
"prod_and_inj_wells_to_drill", None
)
if dc_per_well is not None:
if num_wells is None:
msg = (
"Could not determine number of wells to be drilled. "
"No drilling costs added!"
)
logger.warning(msg)
warn(msg)
else:
capital_cost = self.sam_sys_inputs["capital_cost"]
drill_cost = dc_per_well * num_wells
logger.debug(
"Setting the drilling cost to ${:,.2f} "
"({:.2f} wells at ${:,.2f} per well)".format(
drill_cost, num_wells, dc_per_well
)
)
reg_mult = self.sam_sys_inputs.get(
"capital_cost_multiplier", 1
)
base_cc = capital_cost / reg_mult
new_base_cc = base_cc + drill_cost
self.sam_sys_inputs["base_capital_cost"] = new_base_cc
self.sam_sys_inputs["capital_cost"] = new_base_cc * reg_mult
foc_per_kw = self.sam_sys_inputs.pop(
"fixed_operating_cost_per_kw", None
)
if foc_per_kw is not None:
foc = foc_per_kw * plant_size_kw
logger.debug(
"Setting the fixed_operating_cost to ${:,.2f}".format(foc)
)
self.sam_sys_inputs["base_fixed_operating_cost"] = foc
self.sam_sys_inputs["fixed_operating_cost"] = foc
voc_per_kw = self.sam_sys_inputs.pop(
"variable_operating_cost_per_kw", None
)
if voc_per_kw is not None:
voc = voc_per_kw * plant_size_kw
logger.debug(
"Setting the variable_operating_cost to ${:,.2f}".format(voc)
)
self.sam_sys_inputs["base_variable_operating_cost"] = voc
self.sam_sys_inputs["variable_operating_cost"] = voc
def _create_pysam_wfile(self, resource, meta):
"""Create PySAM weather input file.
Geothermal module requires a weather file, but does not actually
require any weather data to run. Therefore, an empty file is
generated and passed through.
Parameters
----------
resource : pd.DataFrame
Time series resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the
required variables to run the respective SAM simulation.
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude,
and timezone.
Returns
-------
fname : str
Name of weather csv file.
Notes
-----
PySAM will not accept data on Feb 29th. For leap years,
December 31st is dropped and time steps are shifted to relabel
Feb 29th as March 1st, March 1st as March 2nd, etc.
"""
# pylint: disable=attribute-defined-outside-init, consider-using-with
self._temp_dir = TemporaryDirectory()
fname = os.path.join(self._temp_dir.name, "weather.csv")
logger.debug("Creating PySAM weather data file: {}".format(fname))
# ------- Process metadata
m = pd.DataFrame(meta).T
m = m.rename(
{
"latitude": "Latitude",
"longitude": "Longitude",
"timezone": "Time Zone",
},
axis=1,
)
m[["Latitude", "Longitude", "Time Zone"]].to_csv(
fname, index=False, mode="w"
)
# --------- Process data, blank for geothermal
time_index = resource.index
mask = (time_index.month == 2) & (time_index.day == 29)
time_index = time_index[~mask]
df = pd.DataFrame(index=time_index)
df["Year"] = time_index.year
df["Month"] = time_index.month
df["Day"] = time_index.day
df["Hour"] = time_index.hour
df["Minute"] = time_index.minute
df.to_csv(fname, index=False, mode="a")
return fname
[docs] def run_gen_and_econ(self):
"""Run SAM generation and possibility follow-on econ analysis."""
try:
super().run_gen_and_econ()
except SAMExecutionError as e:
logger.error(
"Skipping site {}; received sam error: {}".format(
self._site, str(e)
)
)
self.outputs = {}
[docs]class AbstractSamWind(AbstractSamGeneration, PowerCurveLossesMixin, ABC):
"""AbstractSamWind"""
# pylint: disable=line-too-long
def __init__(self, *args, **kwargs):
"""Wind generation from SAM.
See the PySAM :py:class:`~PySAM.Windpower.Windpower`
documentation for the configuration keys required in the
`sam_sys_inputs` config. You may also include the following
``reV``-specific keys:
- ``reV_power_curve_losses`` : A dictionary that can be used
to initialize
:class:`~reV.losses.power_curve.PowerCurveLossesInput`.
For example::
reV_power_curve_losses = {
'target_losses_percent': 9.8,
'transformation': 'exponential_stretching'
}
See the description of the class mentioned above or the
`reV losses demo notebook <https://tinyurl.com/4d7uutt3/>`_
for detailed instructions on how to specify this input.
- ``reV_outages`` : Specification for ``reV``-scheduled
stochastic outage losses. For example::
outage_info = [
{
'count': 6,
'duration': 24,
'percentage_of_capacity_lost': 100,
'allowed_months': ['January', 'March'],
'allow_outage_overlap': True
},
{
'count': 10,
'duration': 1,
'percentage_of_capacity_lost': 10,
'allowed_months': ['January'],
'allow_outage_overlap': False
},
...
]
See the description of
:meth:`~reV.losses.scheduled.ScheduledLossesMixin.add_scheduled_losses`
or the
`reV losses demo notebook <https://tinyurl.com/4d7uutt3/>`_
for detailed instructions on how to specify this input.
- ``reV_outages_seed`` : Integer value used to seed the RNG
used to compute stochastic outage losses.
- ``time_index_step`` : Integer representing the step size
used to sample the ``time_index`` in the resource data.
This can be used to reduce temporal resolution (i.e. for
30 minute input data, ``time_index_step=1`` yields the
full 30 minute time series as output, while
``time_index_step=2`` yields hourly output, and so forth).
.. Note:: The reduced data shape (i.e. after applying a
step size of `time_index_step`) must still be
an integer multiple of 8760, or the execution
will fail.
Parameters
----------
resource : pd.DataFrame
Timeseries solar or wind resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation. Remapping will be
done to convert typical NSRDB/WTK names into SAM names (e.g. DNI ->
dn and wind_speed -> windspeed)
meta : pd.DataFrame | pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
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
Requested SAM outputs (e.g., 'cf_mean', 'annual_energy',
'cf_profile', 'gen_profile', 'energy_yield', 'ppa_price',
'lcoe_fcr').
drop_leap : bool
Drops February 29th from the resource data. If False, December
31st is dropped from leap years.
"""
super().__init__(*args, **kwargs)
self.add_power_curve_losses()
[docs]class WindPower(AbstractSamWind):
"""Class for Wind generation from SAM"""
MODULE = "windpower"
PYSAM = PySamWindPower
[docs] def set_resource_data(self, resource, meta):
"""Set WTK resource data arrays.
Parameters
----------
resource : pd.DataFrame
Timeseries solar or wind resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation. Remapping will be
done to convert typical NSRDB/WTK names into SAM names (e.g. DNI ->
dn and wind_speed -> windspeed)
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
"""
meta = self._parse_meta(meta)
# map resource data names to SAM required data names
var_map = {
"speed": "windspeed",
"direction": "winddirection",
"airtemperature": "temperature",
"temp": "temperature",
"surfacepressure": "pressure",
"relativehumidity": "rh",
"humidity": "rh",
}
lower_case = {
k: k.lower().replace(" ", "").replace("_", "")
for k in resource.columns
}
resource = resource.rename(mapper=lower_case, axis="columns")
resource = resource.rename(mapper=var_map, axis="columns")
data_dict = {}
var_list = ["temperature", "pressure", "windspeed", "winddirection"]
if "winddirection" not in resource:
resource["winddirection"] = 0.0
time_index = resource.index
self.time_interval = self.get_time_interval(resource.index.values)
data_dict["fields"] = [1, 2, 3, 4]
data_dict["heights"] = 4 * [self.sam_sys_inputs["wind_turbine_hub_ht"]]
if "rh" in resource:
# set relative humidity for icing.
rh = self.ensure_res_len(resource["rh"].values, time_index)
n_roll = int(meta[ResourceMetaField.TIMEZONE] * self.time_interval)
rh = np.roll(rh, n_roll, axis=0)
data_dict["rh"] = rh.tolist()
# must be set as matrix in [temperature, pres, speed, direction] order
# ensure that resource array length is multiple of 8760
# roll the truncated resource array to local timezone
temp = self.ensure_res_len(resource[var_list].values, time_index)
n_roll = int(meta[ResourceMetaField.TIMEZONE] * self.time_interval)
temp = np.roll(temp, n_roll, axis=0)
data_dict["data"] = temp.tolist()
data_dict["lat"] = float(meta[ResourceMetaField.LATITUDE])
data_dict["lon"] = float(meta[ResourceMetaField.LONGITUDE])
data_dict["tz"] = int(meta[ResourceMetaField.TIMEZONE])
data_dict["elev"] = float(meta[ResourceMetaField.ELEVATION])
time_index = self.ensure_res_len(time_index, time_index)
data_dict["minute"] = time_index.minute.tolist()
data_dict["hour"] = time_index.hour.tolist()
data_dict["year"] = time_index.year.tolist()
data_dict["month"] = time_index.month.tolist()
data_dict["day"] = time_index.day.tolist()
# add resource data to self.data and clear
self["wind_resource_data"] = data_dict
self["wind_resource_model_choice"] = 0
[docs] @staticmethod
def default():
"""Get the executed default pysam WindPower object.
Returns
-------
PySAM.Windpower
"""
return DefaultWindPower.default()
# pylint: disable=too-many-ancestors
[docs]class WindPowerPD(AbstractSamGeneration, PowerCurveLossesMixin):
"""WindPower analysis with wind speed/direction joint probabilty
distrubtion input"""
MODULE = "windpower"
PYSAM = PySamWindPower
def __init__(
self,
ws_edges,
wd_edges,
wind_dist,
meta,
sam_sys_inputs,
site_sys_inputs=None,
output_request=None,
):
"""Initialize a SAM generation object for windpower with a
speed/direction joint probability distribution.
Parameters
----------
ws_edges : np.ndarray
1D array of windspeed (m/s) values that set the bin edges for the
wind probability distribution. Same len as wind_dist.shape[0] + 1
wd_edges : np.ndarray
1D array of winddirections (deg) values that set the bin edges
for the wind probability dist. Same len as wind_dist.shape[1] + 1
wind_dist : np.ndarray
2D array probability distribution of (windspeed, winddirection).
meta : pd.DataFrame | pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
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
Requested SAM outputs (e.g., 'cf_mean', 'annual_energy',
'cf_profile', 'gen_profile', 'energy_yield', 'ppa_price',
'lcoe_fcr').
"""
# make sure timezone and elevation are in the meta data
meta = self.tz_elev_check(sam_sys_inputs, site_sys_inputs, meta)
# don't pass resource to base class,
# set in concrete generation classes instead
super().__init__(
None,
meta,
sam_sys_inputs,
site_sys_inputs=site_sys_inputs,
output_request=output_request,
drop_leap=False,
)
# Set the site number using meta data
if hasattr(meta, "name"):
self._site = meta.name
else:
self._site = None
self.set_resource_data(ws_edges, wd_edges, wind_dist)
self.add_power_curve_losses()
[docs] def set_resource_data(self, ws_edges, wd_edges, wind_dist):
"""Send wind PD to pysam
Parameters
----------
ws_edges : np.ndarray
1D array of windspeed (m/s) values that set the bin edges for the
wind probability distribution. Same len as wind_dist.shape[0] + 1
wd_edges : np.ndarray
1D array of winddirections (deg) values that set the bin edges
for the wind probability dist. Same len as wind_dist.shape[1] + 1
wind_dist : np.ndarray
2D array probability distribution of (windspeed, winddirection).
"""
assert len(ws_edges) == wind_dist.shape[0] + 1
assert len(wd_edges) == wind_dist.shape[1] + 1
wind_dist /= wind_dist.sum()
# SAM wants the midpoints of the sample bins
ws_points = ws_edges[:-1] + np.diff(ws_edges) / 2
wd_points = wd_edges[:-1] + np.diff(wd_edges) / 2
wd_points, ws_points = np.meshgrid(wd_points, ws_points)
vstack = (
ws_points.flatten(),
wd_points.flatten(),
wind_dist.flatten(),
)
wrd = np.vstack(vstack).T.tolist()
self["wind_resource_model_choice"] = 2
self["wind_resource_distribution"] = wrd
[docs]class MhkWave(AbstractSamGeneration):
"""Class for Wave generation from SAM"""
MODULE = "mhkwave"
PYSAM = PySamMhkWave
[docs] def set_resource_data(self, resource, meta):
"""Set Hindcast US Wave resource data arrays.
Parameters
----------
resource : pd.DataFrame
Timeseries resource data for a single location with a
pandas DatetimeIndex. There must be columns for all the required
variables to run the respective SAM simulation.
meta : pd.Series
Meta data corresponding to the resource input for the single
location. Should include values for latitude, longitude, elevation,
and timezone.
"""
meta = self._parse_meta(meta)
# map resource data names to SAM required data names
var_map = {
"significantwaveheight": "significant_wave_height",
"waveheight": "significant_wave_height",
"height": "significant_wave_height",
"swh": "significant_wave_height",
"energyperiod": "energy_period",
"waveperiod": "energy_period",
"period": "energy_period",
"ep": "energy_period",
}
lower_case = {
k: k.lower().replace(" ", "").replace("_", "")
for k in resource.columns
}
resource = resource.rename(mapper=lower_case, axis="columns")
resource = resource.rename(mapper=var_map, axis="columns")
data_dict = {}
time_index = resource.index
self.time_interval = self.get_time_interval(resource.index.values)
# must be set as matrix in [temperature, pres, speed, direction] order
# ensure that resource array length is multiple of 8760
# roll the truncated resource array to local timezone
for var in ["significant_wave_height", "energy_period"]:
arr = self.ensure_res_len(resource[var].values, time_index)
n_roll = int(meta[ResourceMetaField.TIMEZONE] * self.time_interval)
data_dict[var] = np.roll(arr, n_roll, axis=0).tolist()
data_dict["lat"] = meta[ResourceMetaField.LATITUDE]
data_dict["lon"] = meta[ResourceMetaField.LONGITUDE]
data_dict["tz"] = meta[ResourceMetaField.TIMEZONE]
time_index = self.ensure_res_len(time_index, time_index)
data_dict["minute"] = time_index.minute
data_dict["hour"] = time_index.hour
data_dict["year"] = time_index.year
data_dict["month"] = time_index.month
data_dict["day"] = time_index.day
# add resource data to self.data and clear
self["wave_resource_data"] = data_dict
[docs] @staticmethod
def default():
"""Get the executed default PySAM MhkWave object.
Returns
-------
PySAM.MhkWave
"""
return DefaultMhkWave.default()