"""Pysam Integration for pvdeg, supports single site and geospatial calculations.
Produced to support Inspire Agrivoltaics: https://openei.org/wiki/InSPIRE
"""
import logging
import dask.array as da
import pandas as pd
import xarray as xr
import numpy as np
import json
from pvdeg import weather
from pvdeg.utilities import (
_load_gcr_from_config,
practical_gcr_pitch_bifiacial_fixed_tilt,
add_time_columns_tmy,
)
logger = logging.getLogger(__name__)
INSPIRE_NSRDB_ATTRIBUTES = [
"air_temperature",
"wind_speed",
"wind_direction",
"dhi",
"ghi",
"dni",
"relative_humidity",
"surface_albedo",
]
scalar = ("gid",)
temporal = ("gid", "time")
spatio_temporal = ("gid", "time", "distance")
INSPIRE_GEOSPATIAL_TEMPLATE_SHAPES = {
"tilt": scalar,
"pitch": scalar,
"annual_poa": scalar,
"annual_energy": scalar,
"dhi": temporal,
"ghi": temporal,
"dni": temporal,
"albedo": temporal,
"temp_air": temporal,
"wind_speed": temporal,
"wind_direction": temporal,
"relative_humidity": temporal,
"subarray1_poa_front": temporal,
"subarray1_poa_rear": temporal,
"subarray1_celltemp": temporal,
"subarray1_dc_gross": temporal,
"ground_irradiance": spatio_temporal,
}
[docs]
def pysam(
weather_df: pd.DataFrame,
meta: dict,
pv_model: str,
pv_model_default: str = None,
config_files: dict[str:str] = None,
results: list[str] = None,
practical_pitch_tilt_considerations: bool = False,
) -> dict:
"""
Run SAM solar simulation.
Parameters
-----------
weather_df: pd.DataFrame
DataFrame of weather data. As returned by ``pvdeg.weather.get``
meta: dict
Dictionary of metadata for the weather data. As returned by
``pvdeg.weather.get``
pv_model: str
choose pySam photovoltaic system model.
Some models are less thorough and run faster.
pvwatts8 is ~50x faster than pvsamv1 but only calculates 46 parameters while
pvsamv1 calculates 195.
options: ``pvwatts8``, ``pvsamv1``
Documentation Links
- [pvwatts8](https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html)
- [pvsamv1](https://nrel-pysam.readthedocs.io/en/main/modules/Pvsamv1.html)
pv_model_default: str
pysam config for pv model.
[Pysam Modules](https://nrel-pysam.readthedocs.io/en/main/ssc-modules.html)
On the docs some modules have availabile defaults listed.
For example:
[Pvwattsv8](https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html)
- "FuelCellCommercial"
- "FuelCellSingleOwner"
- "GenericPVWattsWindFuelCellBatteryHybridHostDeveloper"
- "GenericPVWattsWindFuelCellBatteryHybridSingleOwner"
- "PVWattsBatteryCommercial"
- "PVWattsBatteryHostDeveloper"
- "PVWattsBatteryResidential"
- "PVWattsBatteryThirdParty"
- "PVWattsWindBatteryHybridHostDeveloper"
- "PVWattsWindBatteryHybridSingleOwner"
- "PVWattsWindFuelCellBatteryHybridHostDeveloper"
- "PVWattsWindFuelCellBatteryHybridSingleOwner"
- "PVWattsAllEquityPartnershipFlip"
- "PVWattsCommercial"
- "PVWattsCommunitySolar"
- "PVWattsHostDeveloper"
- "PVWattsLCOECalculator"
- "PVWattsLeveragedPartnershipFlip"
- "PVWattsMerchantPlant"
- "PVWattsNone"
- "PVWattsResidential"
- "PVWattsSaleLeaseback"
- "PVWattsSingleOwner"
- "PVWattsThirdParty"
[Pvsamv1](https://nrel-pysam.readthedocs.io/en/main/modules/Pvsamv1.html)
- "FlatPlatePVAllEquityPartnershipFlip"
- "FlatPlatePVCommercial"
- "FlatPlatePVHostDeveloper"
- "FlatPlatePVLCOECalculator"
- "FlatPlatePVLeveragedPartnershipFlip"
- "FlatPlatePVMerchantPlant"
- "FlatPlatePVNone"
- "FlatPlatePVResidential"
- "FlatPlatePVSaleLeaseback"
- "FlatPlatePVSingleOwner"
- "FlatPlatePVThirdParty"
- "PVBatteryAllEquityPartnershipFlip"
- "PVBatteryCommercial"
- "PVBatteryHostDeveloper"
- "PVBatteryLeveragedPartnershipFlip"
- "PVBatteryMerchantPlant"
- "PVBatteryResidential"
- "PVBatterySaleLeaseback"
- "PVBatterySingleOwner"
- "PVBatteryThirdParty"
- "PhotovoltaicWindBatteryHybridHostDeveloper"
- "PhotovoltaicWindBatteryHybridSingleOwner"
grid_default: str
pysam default config for grid model.
[Grid Defaults](https://nrel-pysam.readthedocs.io/en/main/modules/Grid.html)
cashloan_default: str
pysam default config for cashloan model.
[Cashloan Defaults](https://nrel-pysam.readthedocs.io/en/main/modules/Cashloan.html) # noqa
- "FlatPlatePVCommercial"
- "FlatPlatePVResidential"
- "PVBatteryCommercial"
- "PVBatteryResidential"
- "PVWattsBatteryCommercial"
- "PVWattsBatteryResidential"
- "PVWattsCommercial"
- "PVWattsResidential"
utiltityrate_default: str
pysam default config for utilityrate5 model.
[Utilityrate5 Defaults](https://nrel-pysam.readthedocs.io/en/main/modules/Utilityrate5.html()) # noqa
config_files: dict
SAM configuration files. A dictionary containing a mapping to filepaths.
Keys must be `'pv', 'grid', 'utilityrate', 'cashloan'`.
Each key should contain a value as a string representing the file path to a SAM
config file. Cannot deal with the entire SAM config json.
```
files = {
'pv' : 'example/path/1/pv-file.json'
'grid' : 'example/path/1/grid-file.json'
'utilityrate' : 'example/path/1/utilityrate-file.json'
'cashloan' : 'example/path/1/cashloan-file.json'
}
```
results: list[str]
list of strings corresponding to pysam outputs to return.
Pysam models such as `Pvwatts8` and `Pvsamv1` return hundreds of results.
So we can chose to take only the specified results while throwing away the
others.
To grab only 'annual_energy' and 'ac' from the model results.
>>> results = ['annual_energy', 'ac']
This may cause some undesired behavior with geospatial calculations if the
lengths of the results within the list are different.
practical_pitch_tilt_considerations: bool
Use inspire practical considerations to limit/override defined pitch and tilt from SAM configs.
Calculates optimal GCR using `pvdeg.utilities.optimal_gcr_pitch` for fixed tilt bifacial systems.
Imposes a minimum pitch of 3.8m and maximum pitch of 12m.
Returns
-------
pysam_res: dict
dictionary of outputs. Keys are result name and value is the corresponding
result.
If `results` is not specified, the dictionary will contain every calculation
from the model.
"""
try:
import PySAM.Pvsamv1 as pv1
import PySAM.Pvwattsv8 as pv8
except ModuleNotFoundError:
logger.info(
"pysam not found. run `pip install pvdeg[sam]` to install the NREL-PySAM \
dependency"
)
return
sr = solar_resource_dict(weather_df=weather_df, meta=meta)
model_map = {
"pvwatts8": pv8,
"pvsamv1": pv1,
}
model_module = model_map[pv_model]
if pv_model_default:
pysam_model = model_module.default(pv_model_default)
elif pv_model_default is None:
pysam_model = model_module.new()
with open(config_files["pv"], "r") as f:
pv_inputs = json.load(f)
subarrays = set()
for k, v in pv_inputs.items():
if k not in ({"number_inputs", "solar_resource_file"}):
try:
pysam_model.value(k, v)
except AttributeError as e:
logger.warning(
f"failed to set pysam model key: {k} to value: {v}, "
f"skipping {k}. Original error: {e}"
)
# get all subarrays being used
if k.startswith("subarray"):
subarrays.add(k.split("_")[0])
if practical_pitch_tilt_considerations is True:
_apply_practical_pitch_tilt(
pysam_model=pysam_model, meta=meta, subarrays=subarrays
)
pysam_model.unassign("solar_resource_file")
# Duplicate Columns in the dataframe seem to cause this issue
# Error (-4) converting nested tuple 0 into row in matrix.
pysam_model.SolarResource.solar_resource_data = sr
pysam_model.execute()
outputs = pysam_model.Outputs.export()
if not results:
return outputs
logger.info("gcr used")
logger.info(pysam_model.value("subarray1_gcr"))
pysam_res = {key: outputs[key] for key in results}
return pysam_res
def _apply_practical_pitch_tilt(pysam_model, meta: dict, subarrays: set[str]) -> None:
"""
Apply practical pitch/tilt constraints to all subarrays on the model.
Mutates `pysam_model` in-place. Raises the same errors as the inlined code.
"""
logger.info("overriding pitch with practical considerations")
logger.info(f"subarrays {subarrays}")
# Build parameter name lists for all discovered subarrays
param_latitude_tilt = [f"{s}_tilt_eq_lat" for s in subarrays]
param_tracker_mode = [f"{s}_track_mode" for s in subarrays]
param_tilt = [f"{s}_tilt" for s in subarrays]
param_gcr = [f"{s}_gcr" for s in subarrays]
# Disable latitude-equals-tilt if set anywhere
if any(pysam_model.value(name) != 0 for name in param_latitude_tilt):
logger.info(
"config defined latitude tilt defined for one of the subarrays, "
"disabling config latitude tilt"
"(will be set later using practical consideration)"
)
for name in param_latitude_tilt:
pysam_model.value(name, 0)
# Disallow tracking
if any(pysam_model.value(name) != 0 for name in param_tracker_mode):
raise ValueError(
"Inspire Practical Pitch,Tilt Consideration Failed: "
"at least one subarray is using tracking"
)
# Disallow vertical fixed-tilt
if any(pysam_model.value(name) == 90 for name in param_tilt):
raise ValueError(
"Inspire Practical Pitch,Tilt Consideration Failed: "
"at least one subarray is vertical fixed tilt (tilt = 90 deg)"
)
# collector width of 2m for the inspire scenarios
tilt_prac, pitch_prac, gcr_prac = practical_gcr_pitch_bifiacial_fixed_tilt(
latitude=meta["latitude"], cw=2
)
# Apply practical tilt/GCR (pitch is implied via GCR in SAM)
for name in param_tilt:
pysam_model.value(name, tilt_prac)
for name in param_gcr:
pysam_model.value(name, gcr_prac)
def _handle_pysam_return(
pysam_res_dict: dict, weather_df: pd.DataFrame, tilt: float, pitch: float
) -> xr.Dataset:
"""Handle a pysam return object and transform it to an xarray"""
ground_irradiance = pysam_res_dict["subarray1_ground_rear_spatial"]
annual_poa = pysam_res_dict["annual_poa_front"]
annual_energy = pysam_res_dict["annual_energy"]
subarray1_poa_front = pysam_res_dict["subarray1_poa_front"][:8760]
subarray1_poa_rear = pysam_res_dict["subarray1_poa_rear"][:8760]
subarray1_celltemp = pysam_res_dict["subarray1_celltemp"][:8760]
subarray1_dc_gross = pysam_res_dict["subarray1_dc_gross"][:8760]
timeseries_index = weather_df.index
ground_irradiance_values = da.from_array([row[1:] for row in ground_irradiance[1:]])
single_location_ds = xr.Dataset(
data_vars={
# SCALARS
# for some configs these are caluclated with inspire_practical_pitch
"tilt": float(tilt),
"pitch": float(pitch),
"annual_poa": annual_poa,
"annual_energy": annual_energy,
# TIMESERIES (model outputs)
"subarray1_poa_front": (("time",), da.array(subarray1_poa_front)),
"subarray1_poa_rear": (("time",), da.array(subarray1_poa_rear)),
"subarray1_celltemp": (("time",), da.array(subarray1_celltemp)),
"subarray1_dc_gross": (("time",), da.array(subarray1_dc_gross)),
# TIMESERIES (weather inputs)
"temp_air": (("time",), da.array(weather_df["temp_air"].values)),
"wind_speed": (("time",), da.array(weather_df["wind_speed"].values)),
"wind_direction": (
("time",),
da.array(weather_df["wind_direction"].values),
),
"dhi": (("time",), da.array(weather_df["dhi"].values)),
"ghi": (("time",), da.array(weather_df["ghi"].values)),
"dni": (("time",), da.array(weather_df["dni"].values)),
"relative_humidity": (
("time",),
da.array(weather_df["relative_humidity"].values),
),
"albedo": (("time",), da.array(weather_df["albedo"].values)),
# SPATIO-TEMPORAL (model outputs)
"ground_irradiance": (("time", "distance"), ground_irradiance_values),
},
coords={
"time": timeseries_index,
# distances vary for config and locations (on fixed tilt configs)
# so we need to use a distance "index" that is not spatially meaningful
# convient way to match the distances in the template
"distance": np.arange(10),
},
)
return single_location_ds
[docs]
def inspire_ground_irradiance(weather_df, meta, config_files):
"""
Get ground irradiance array and annual poa irradiance
for a given point using pvsamv1
REQUIRES: input weather data time index in UTC time.
Parameters
----------
weather_df : pd.DataFrame
weather dataframe
meta : dict
meta data
config_files : dict[str]
see pvdeg.pysam.pysam
# config_files={'pv' : <stringpathtofile>},
Returns
--------
result : inspirePysamReturn
returns an custom class object so we can unpack it later.
"""
if not isinstance(weather_df, pd.DataFrame) or not isinstance(meta, dict):
raise ValueError(
f"""
weather_df must be pandas DataFrame, meta must be dict.
weather_df type : {type(weather_df)}
meta type : {type(meta)}
"""
)
# there is no pitch/gcr output from the model so we might have to do other checks
# to see that this is being applied correctly verify that our equations are correct.
# plot the world, view to see if practical applications have been applied.
# force localize utc from tmy to local time by moving rows
weather_df = weather.roll_tmy(weather_df, meta)
tracking_setups = ["01", "02", "03", "04", "05"]
# fixed tilt setups calculate pitch/gcr as a function of latitude capped at 40 deg
pratical_considerations_setups = ["06", "07", "08", "09"]
# vertical tilt (fixed spacing) 10
logger.info(f"config file string: {config_files['pv']} -- debug")
cw = 2 # collector width 2 [m]
pratical_consideration = False
if any(setup in config_files["pv"] for setup in pratical_considerations_setups):
logger.info(
"setup with practical consieration detected, "
"using pysam inspire_practical_consideration_pitch_tilt=True"
)
pratical_consideration = True
tilt_used, pitch_used, gcr_used = practical_gcr_pitch_bifiacial_fixed_tilt(
latitude=meta["latitude"], cw=cw
)
# why would this be not in
# this should check in "10" is in the config files
elif "10" in config_files["pv"]:
logger.info("using config 10 with vertical fixed tilt.")
gcr_used = _load_gcr_from_config(config_files=config_files)
logger.info(f"gcr used: {gcr_used}")
pitch_used = cw / gcr_used
tilt_used = 90.0
# conf 01- 05 using tracking, default gcr from pysam config
elif any(setup in config_files["pv"] for setup in tracking_setups):
logger.info("SAT scenario, using -999.0 as tilt fill value")
gcr_used = _load_gcr_from_config(config_files=config_files)
pitch_used = cw / gcr_used
# tracking doesnt have fixed tilt (use placeholder instead)
tilt_used = -999.0
else:
# this is not portable because it is custom for the calculation
raise ValueError(
"Valid config not found, " "config name must contain setup name from 01-10"
)
outputs = pysam(
weather_df=weather_df,
meta=meta,
pv_model="pvsamv1",
config_files=config_files,
# tell model to calculate practical tilt, pitch, gcr again inside function
practical_pitch_tilt_considerations=pratical_consideration,
)
ds_result = _handle_pysam_return(
pysam_res_dict=outputs, weather_df=weather_df, tilt=tilt_used, pitch=pitch_used
)
return ds_result
[docs]
def solar_resource_dict(weather_df, meta):
"""
Create a solar resource dict mapping from weather and metadata.
Works on PVGIS and kestrel NSRDB (NOT PSM3 NSRDB from NSRDB api).
"""
weather_df = add_time_columns_tmy(weather_df) # only supports hourly data
# enforce tmy scheme
times = pd.date_range(start="2001-01-01", periods=8760, freq="1h")
# all solar resource dict options
# lat,lon,tz,elev,year,month,hour,minute,gh,dn,df,poa,tdry,twet,tdew,rhum,pres,snow,alb,aod,wspd,wdir
sr = {
"lat": meta["latitude"],
"lon": meta["longitude"],
"tz": meta["tz"] if "tz" in meta.keys() else 0,
"elev": meta["altitude"],
"year": list(times.year), # list(weather_df['Year']),
"month": list(times.month),
"day": list(times.day),
"hour": list(times.hour),
"minute": list(times.minute),
"gh": list(weather_df["ghi"]),
"dn": list(weather_df["dni"]),
"df": list(weather_df["dhi"]),
"wspd": list(weather_df["wind_speed"]),
"tdry": list(weather_df["temp_air"]),
"alb": (
list(weather_df["albedo"])
if "albedo" in weather_df.columns.values
else [0.2] * len(weather_df)
),
}
# if we have wind direction then add it
if "wind_direction" in weather_df.columns.values:
sr["wdir"] = list(weather_df["wind_direction"])
return sr