diff --git a/RELEASE.md b/RELEASE.md index 36a2f0a24..965578a60 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,6 +1,8 @@ # Release Notes ## Unreleased, TBD +* Added option and functionality to load wind and solar resource data from NSRDB and Wind Toolkit data files if user-specified. +* Fixed a bug in site_info that set resource year to 2012 even if otherwise specified. + minor clean up to floris.py - removed unnecessary data exportation and fixed bug in value() ## Version 3.1.1, Dec. 18, 2024 diff --git a/docs/_toc.yml b/docs/_toc.yml index 05da9a2d2..eff23f32e 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -14,6 +14,13 @@ parts: chapters: - file: api/hopp_interface - file: api/site_info + sections: + - file: api/resource/index + sections: + - file: api/resource/solar_api + - file: api/resource/wind_api + - file: api/resource/solar_hpc + - file: api/resource/wind_hpc - file: api/hybrid_simulation - file: api/technology/index sections: diff --git a/docs/api/resource/index.md b/docs/api/resource/index.md new file mode 100644 index 000000000..1728c3e58 --- /dev/null +++ b/docs/api/resource/index.md @@ -0,0 +1,39 @@ +# Resource Data + +These are the primary methods for accessing wind and solar resource data. + +- [Solar Resource (API)](resource:solar-resource) +- [Wind Resource (API)](resource:wind-resource) +- [Solar Resource (NSRDB Dataset on NREL HPC)](resource:nsrdb-data) +- [Wind Resource (Wind Toolkit Dataset on NREL HPC)](resource:wtk-data) + +## NREL API Keys + +An NREL API key is required to use the functionality for [Solar Resource (API)](resource:solar-resource) and [Wind Resource (API)](resource:wind-resource). + +An NREL API key can be obtained from [here](https://developer.nrel.gov/signup/). + +Once an API key is obtained, create a file ".env" in the HOPP root directory (/path/to/HOPP/.env) that contains the lines: + +```bash +NREL_API_KEY=key +NREL_API_EMAIL=your.name@email.com +``` + +where `key` is your API key and `your.name@email.com` is the email that was used to get the API key. + +## NREL HPC Datasets + +To load resource data from datasets hosted on NREL's HPC, HOPP must be installed and run from the NREL HPC. Currently, loading resource data from HPC is only enabled for [wind](resource:wtk-data) and [solar](resource:nsrdb-data) resource. + + +(resource:resource-base)= +## Resource Base Class + +Base class for resource data + +```{eval-rst} +.. autoclass:: hopp.simulation.technologies.resource.Resource + :members: + :exclude-members: copy, plot, _abc_impl +``` diff --git a/docs/api/resource/solar_api.md b/docs/api/resource/solar_api.md new file mode 100644 index 000000000..84f64867f --- /dev/null +++ b/docs/api/resource/solar_api.md @@ -0,0 +1,10 @@ +(resource:solar-resource)= +# Solar Resource (API) + +By default, solar resource data is downloaded from the NREL Developer Network hosted National Solar Radiation Database (NSRDB) dataset [Physical Solar Model (PSM) v3.2.2](https://developer.nrel.gov/docs/solar/nsrdb/psm3-2-2-download/). Using this functionality requires an NREL API key. + +```{eval-rst} +.. autoclass:: hopp.simulation.technologies.resource.solar_resource.SolarResource + :members: + :exclude-members: _abc_impl, check_download_dir +``` diff --git a/docs/api/resource/solar_hpc.md b/docs/api/resource/solar_hpc.md new file mode 100644 index 000000000..35ba6e5e0 --- /dev/null +++ b/docs/api/resource/solar_hpc.md @@ -0,0 +1,11 @@ +(resource:nsrdb-data)= +# Solar Resource (NSRDB Dataset on NREL HPC) + +If enabled, solar resource data can be loaded from the NREL HPC (Kestrel) hosted National Solar Radiation Database (NSRDB) dataset. This functionality leverages the [NREL REsource eXtraction (rex) tool](https://github.com/NREL/rex). Information on NREL HPC file systems and datasets can be found [here](https://nrel.github.io/HPC/Documentation/Systems/Kestrel/Filesystems/#projectfs). + +```{eval-rst} +.. autoclass:: hopp.simulation.technologies.resource.nsrdb_data.HPCSolarData + :members: + :undoc-members: + :exclude-members: _abc_impl, check_download_dir, call_api +``` \ No newline at end of file diff --git a/docs/api/resource/wind_api.md b/docs/api/resource/wind_api.md new file mode 100644 index 000000000..ee8f2ab98 --- /dev/null +++ b/docs/api/resource/wind_api.md @@ -0,0 +1,10 @@ +(resource:wind-resource)= +# Wind Resource (API) + +By default, wind resource data is downloaded from the NREL Developer Network hosted Wind Integration National Dataset (WIND) Toolkit dataset [Wind Toolkit Data - SAM format (srw)](https://developer.nrel.gov/docs/wind/wind-toolkit/wtk-srw-download/). Using this functionality requires an NREL API key. + +```{eval-rst} +.. autoclass:: hopp.simulation.technologies.resource.wind_resource.WindResource + :members: + :exclude-members: _abc_impl, check_download_dir +``` \ No newline at end of file diff --git a/docs/api/resource/wind_hpc.md b/docs/api/resource/wind_hpc.md new file mode 100644 index 000000000..9196ad730 --- /dev/null +++ b/docs/api/resource/wind_hpc.md @@ -0,0 +1,11 @@ +(resource:wtk-data)= +# Wind Resource (Wind Toolkit Dataset on NREL HPC) + +If enabled, wind resource data can be loaded from the NREL HPC (Kestrel) hosted Wind Integration National Dataset (WIND) Toolkit dataset. This functionality leverages the [NREL REsource eXtraction (rex) tool](https://github.com/NREL/rex). Information on NREL HPC file systems and datasets can be found [here](https://nrel.github.io/HPC/Documentation/Systems/Kestrel/Filesystems/#projectfs). + +```{eval-rst} +.. autoclass:: hopp.simulation.technologies.resource.wind_toolkit_data.HPCWindData + :members: + :undoc-members: + :exclude-members: _abc_impl, check_download_dir, call_api +``` \ No newline at end of file diff --git a/docs/api/site_info.rst b/docs/api/site_info.md similarity index 62% rename from docs/api/site_info.rst rename to docs/api/site_info.md index 2465c51d9..0b429ea8a 100644 --- a/docs/api/site_info.rst +++ b/docs/api/site_info.md @@ -1,11 +1,9 @@ -.. _SiteInfo: - - -Hybrid Plant Site Information -============================== +# Hybrid Plant Site Information The purpose of this class is to house all site specific data, e.g., weather data. +```{eval-rst} .. autoclass:: hopp.simulation.technologies.sites.SiteInfo :members: - :undoc-members: \ No newline at end of file + :undoc-members: +``` diff --git a/hopp/simulation/technologies/resource/__init__.py b/hopp/simulation/technologies/resource/__init__.py index 7a8ea8f0b..0d3601457 100644 --- a/hopp/simulation/technologies/resource/__init__.py +++ b/hopp/simulation/technologies/resource/__init__.py @@ -5,3 +5,5 @@ from hopp.simulation.technologies.resource.resource import Resource from hopp.simulation.technologies.resource.greet_data import GREETData from hopp.simulation.technologies.resource.cambium_data import CambiumData +from hopp.simulation.technologies.resource.nsrdb_data import HPCSolarData +from hopp.simulation.technologies.resource.wind_toolkit_data import HPCWindData diff --git a/hopp/simulation/technologies/resource/nsrdb_data.py b/hopp/simulation/technologies/resource/nsrdb_data.py new file mode 100644 index 000000000..0b4b1180e --- /dev/null +++ b/hopp/simulation/technologies/resource/nsrdb_data.py @@ -0,0 +1,231 @@ +from rex import NSRDBX +from rex.sam_resource import SAMResource +import numpy as np +from hopp.simulation.technologies.resource.resource import Resource +from typing import Optional, Union +from pathlib import Path +import os +from hopp.utilities.validators import range_val +NSRDB_DEP = "/datasets/NSRDB/deprecated_v3/nsrdb_" + +# NOTE: Current version of PSM v3.2.2 which corresponds to /api/nsrdb/v2/solar/psm3-2-2-download +NSRDB_NEW = "/datasets/NSRDB/current/nsrdb_" + +# Pull Solar Resource Data directly from NSRDB on HPC +# To be called instead of SolarResource from hopp.simulation.technologies.resource +class HPCSolarData(Resource): + """ + Class to manage Solar Resource data from NSRDB Datasets. + + Attributes: + nsrdb_file: (str) path of file that resource data is pulled from. + site_gid: (int) id for NSRDB location that resource data was pulled from. + nsrdb_latitude: (float) latitude of NSRDB location corresponding to site_gid. + nsrdb_longitude: (float) longitude of NSRDB location corresponding to site_gid. + + """ + + + def __init__( + self, + lat: float, + lon: float, + year: int, + nsrdb_source_path: Union[str,Path] = "", + filepath: str = "", + ): + """Class to pull solar resource data from NSRDB datasets hosted on the HPC + + Args: + lat (float): latitude corresponding to location for solar resource data + lon (float): longitude corresponding to location for solar resource data + year (int): year for resource data. must be between 1998 and 2022 + nsrdb_source_path (Union[str,Path], optional): directory where NSRDB data is hosted on HPC. Defaults to "". + filepath (str, optional): filepath to NSRDB h5 file on HPC. Defaults to "". + - should be formatted as: /path/to/file/name_of_file.h5 + Raises: + ValueError: if year is not between 1998 and 2022 (inclusive) + FileNotFoundError: if nsrdb_file is not valid filepath + """ + + # NOTE: self.data must be compatible with PVWatts.SolarResource.solar_resource_data + # see: https://nrel-pysam.readthedocs.io/en/main/modules/Pvwattsv8.html#PySAM.Pvwattsv8.Pvwattsv8.SolarResource + super().__init__(lat, lon, year) + + if filepath == "" and nsrdb_source_path=="": + # use default filepath + self.nsrdb_file = NSRDB_NEW + f"{self.year}.h5" + elif filepath != "" and nsrdb_source_path == "": + # filepath (full h5 filepath) is provided by user + if ".h5" not in filepath: + filepath = filepath + ".h5" + self.nsrdb_file = str(filepath) + elif filepath == "" and nsrdb_source_path != "": + # directory of h5 files (nsrdb_source_path) is provided by user + self.nsrdb_file = os.path.join(str(nsrdb_source_path),f"nsrdb_{self.year}.h5") + else: + # use default filepaths + self.nsrdb_file = NSRDB_NEW + f"{self.year}.h5" + + # Check for valid year + if self.year < 1998 or self.year > 2022: + raise ValueError(f"Resource year for NSRDB Data must be between 1998 and 2022 but {self.year} was provided") + + # Check for valid filepath for NSRDB file + if not os.path.isfile(self.nsrdb_file): + raise FileNotFoundError(f"Cannot find NSRDB .h5 file, filepath {self.nsrdb_file} does not exist") + + # Pull data from HPC NSRDB dataset + self.download_resource() + + # Set solar resource data into SAM/PySAM digestible format + self.format_data() + + + def download_resource(self): + """load NSRDB h5 file using rex and get solar resource data for location + specified by (self.lat, self.lon) + """ + + # Open file with rex NSRDBX object + with NSRDBX(self.nsrdb_file, hsds=False) as f: + # get gid of location closest to given lat/lon coordinates + site_gid = f.lat_lon_gid((self.latitude,self.longitude)) + + # extract timezone, elevation, latitude and longitude from meta dataset with gid + self.time_zone = f.meta['timezone'].iloc[site_gid] + self.elevation = f.meta['elevation'].iloc[site_gid] + self.nsrdb_latitude = f.meta['latitude'].iloc[site_gid] + self.nsrdb_longitude = f.meta['longitude'].iloc[site_gid] + + # extract remaining datapoints: + # year, month, day, hour, minute, dn, df, gh, wspd,tdry, pres, tdew + + # 1) NOTE: datasets have readings at 0 and 30 minutes each hour, + # HOPP/SAM workflow requires only 30 minute reading values -> filter 0 minute readings with [1::2] + # 2) NOTE: datasets are not auto shifted by timezone offset + # -> wrap extraction in SAMResource.roll_timeseries(input_array, timezone, #steps in an hour=1) to roll timezones + # 3) NOTE: solar_resource.py code references solar_zenith_angle and RH = relative_humidity but I couldn't find them + # actually being utilized. Captured them below just in case. + self.year_arr = f.time_index.year.values[1::2] + self.month_arr = f.time_index.month.values[1::2] + self.day_arr = f.time_index.day.values[1::2] + self.hour_arr = f.time_index.hour.values[1::2] + self.minute_arr = f.time_index.minute.values[1::2] + self.dni_arr = SAMResource.roll_timeseries((f['dni', :, site_gid][1::2]), self.time_zone, 1) + self.dhi_arr = SAMResource.roll_timeseries((f['dhi', :, site_gid][1::2]), self.time_zone, 1) + self.ghi_arr = SAMResource.roll_timeseries((f['ghi', :, site_gid][1::2]), self.time_zone, 1) + self.wspd_arr = SAMResource.roll_timeseries((f['wind_speed', :, site_gid][1::2]), self.time_zone, 1) + self.tdry_arr = SAMResource.roll_timeseries((f['air_temperature', :, site_gid][1::2]), self.time_zone, 1) + # self.relative_humidity_arr = SAMResource.roll_timeseries((f['relative_humidity', :, site_gid][1::2]), self.time_zone, 1) + # self.solar_zenith_arr = SAMResource.roll_timeseries((f['solar_zenith_angle', :, site_gid][1::2]), self.time_zone, 1) + self.pres_arr = SAMResource.roll_timeseries((f['surface_pressure', :, site_gid][1::2]), self.time_zone, 1) + self.tdew_arr = SAMResource.roll_timeseries((f['dew_point', :, site_gid][1::2]), self.time_zone, 1) + + self.site_gid = site_gid + + + def format_data(self): + # Remove data from feb29 on leap years + if (self.year % 4) == 0: + feb29 = np.arange(1416,1440) + self.year_arr = np.delete(self.year_arr, feb29) + self.month_arr = np.delete(self.month_arr, feb29) + self.day_arr = np.delete(self.day_arr, feb29) + self.hour_arr = np.delete(self.hour_arr, feb29) + self.minute_arr = np.delete(self.minute_arr, feb29) + self.dni_arr = np.delete(self.dni_arr, feb29) + self.dhi_arr = np.delete(self.dhi_arr, feb29) + self.ghi_arr = np.delete(self.ghi_arr, feb29) + self.wspd_arr = np.delete(self.wspd_arr, feb29) + self.tdry_arr = np.delete(self.tdry_arr, feb29) + # self.relative_humidity_arr = np.delete(self.relative_humidity_arr, feb29) + # self.solar_zenith_arr = np.delete(self.solar_zenith_arr, feb29) + self.pres_arr = np.delete(self.pres_arr, feb29) + self.tdew_arr = np.delete(self.tdew_arr, feb29) + + # round to desired precision and convert to desired data type + # NOTE: unsure if SAM/PySAM is sensitive to data types and decimal precision. + # If not sensitive, can remove .astype() and round() to increase computational efficiency + self.time_zone = float(self.time_zone) + self.elevation = round(float(self.elevation), 0) + self.nsrdb_latitude = round(float(self.nsrdb_latitude), 2) + self.nsrdb_longitude = round(float(self.nsrdb_longitude),2) + self.year_arr = list(self.year_arr.astype(float, copy=False)) + self.month_arr = list(self.month_arr.astype(float, copy=False)) + self.day_arr = list(self.day_arr.astype(float, copy=False)) + self.hour_arr = list(self.hour_arr.astype(float, copy=False)) + self.minute_arr = list(self.minute_arr.astype(float, copy=False)) + self.dni_arr = list(self.dni_arr.astype(float, copy=False)) + self.dhi_arr = list(self.dhi_arr.astype(float, copy=False)) + self.ghi_arr = list(self.ghi_arr.astype(float, copy=False)) + self.wspd_arr = list(self.wspd_arr.astype(float, copy=False)) + self.tdry_arr = list(self.tdry_arr.astype(float, copy=False)) + # self.relative_humidity_arr = list(np.round(self.relative_humidity_arr, decimals=1)) + # self.solar_zenith_angle_arr = list(np.round(self.solar_zenith_angle_arr, decimals=1)) + self.pres_arr = list(self.pres_arr.astype(float, copy=False)) + self.tdew_arr = list(self.tdew_arr.astype(float, copy=False)) + + self.data = { + 'tz' : self.time_zone, + 'elev' : self.elevation, + 'lat' : self.nsrdb_latitude, + 'lon' : self.nsrdb_longitude, + 'year' : self.year_arr, + 'month' : self.month_arr, + 'day' : self.day_arr, + 'hour' : self.hour_arr, + 'minute' : self.minute_arr, + 'dn' : self.dni_arr, + 'df' : self.dhi_arr, + 'gh' : self.ghi_arr, + 'wspd' : self.wspd_arr, + 'tdry' : self.tdry_arr, + 'pres' : self.pres_arr, + 'tdew' : self.tdew_arr + } + + @Resource.data.setter + def data(self,data_dict): + """ + Sets data property with formatted solar resource data for SAM + data (dict): + :key tz (float): Time zone is for standard time in hours ahead of GMT + :key elev (float): Elevation is in meters above sea level + :key lat (float): degrees north of the equator + :key lon (float): degrees East of the prime meridian + :key year (list(int)): year + :key month (list(float)): number associated with month (1 = January) + :key day (list(float)): number indicating the day of month (Day = 1 is the first day of the month) + :key hour (list(float)): number indicating the hour of day (Hour = 0 is the first hour of the day) + :key minute (list(float)): number indicating minute of hour (Minute = 0 is the first minute of the hour) + :key dn (list(float)): Beam normal irradiance (W/m2) + :key df (list(float)): Diffuse horizontal irradiance (W/m2) + :key gh (list(float)): Global horizontal irradiance (W/m2) + :key wspd (list(float)): Wind speed at 10 meters above the ground (m/s) + :key tdry (list(float)): Ambient dry bulb temperature (°C) + :key pres (list(float)): Atmospheric pressure (millibar) + :key tdew (list(float)): Dew point temperature (°C) + """ + if "dn" not in data_dict.keys(): + dic = { + 'tz' : self.time_zone, + 'elev' : self.elevation, + 'lat' : self.nsrdb_latitude, + 'lon' : self.nsrdb_longitude, + 'year' : self.year_arr, + 'month' : self.month_arr, + 'day' : self.day_arr, + 'hour' : self.hour_arr, + 'minute' : self.minute_arr, + 'dn' : self.dni_arr, + 'df' : self.dhi_arr, + 'gh' : self.ghi_arr, + 'wspd' : self.wspd_arr, + 'tdry' : self.tdry_arr, + 'pres' : self.pres_arr, + 'tdew' : self.tdew_arr + } + self._data = dic + else: + self._data = data_dict \ No newline at end of file diff --git a/hopp/simulation/technologies/resource/wind_toolkit_data.py b/hopp/simulation/technologies/resource/wind_toolkit_data.py new file mode 100644 index 000000000..5a4da7f40 --- /dev/null +++ b/hopp/simulation/technologies/resource/wind_toolkit_data.py @@ -0,0 +1,206 @@ +from rex import WindX +from rex.sam_resource import SAMResource +import numpy as np +from typing import Optional, Union +from pathlib import Path +import os +from hopp.simulation.technologies.resource.resource import Resource + +WTK_V10_BASE = "/datasets/WIND/conus/v1.0.0/wtk_conus_" +WTK_V11_BASE = "/datasets/WIND/conus/v1.1.0/wtk_conus_" + + +class HPCWindData(Resource): + """ + Class to manage Wind Resource data from Wind Toolkit Datasets + + Attributes: + wtk_file: (str) path of file that resource data is pulled from + site_gid: (int) id for Wind Toolkit location that resource data was pulled from + wtk_latitude: (float) latitude of Wind Toolkit location corresponding to site_gid + wtk_longitude: (float) longitude of Wind Toolkit location corresponding to site_gid + """ + + + def __init__( + self, + lat: float, + lon: float, + year: int, + wind_turbine_hub_ht: float, + wtk_source_path: Union[str,Path] = "", + filepath: str = "", + ): + """Class to pull wind resource data from WIND Toolkit datasets hosted on the HPC + + Args: + lat (float): latitude corresponding to location for wind resource data + lon (float): longitude corresponding to location for wind resource data + year (int): year for resource data. must be between 2007 and 2014 + wind_turbine_hub_ht (float): turbine hub height (m) + wtk_source_path (Union[str,Path], optional): directory where Wind Toolkit data is hosted on HPC. Defaults to "". + filepath (str, optional): filepath to Wind Toolkit h5 file on HPC. Defaults to "". + - should be formatted as: /path/to/file/name_of_file.h5 + Raises: + ValueError: if year is not between 2007 and 2014 (inclusive) + FileNotFoundError: if wtk_file is not valid filepath + """ + super().__init__(lat, lon, year) + + self.hub_height_meters = wind_turbine_hub_ht + self.allowed_hub_heights_meters = [10, 40, 60, 80, 100, 120, 140, 160, 200] + self.data_hub_heights = self.calculate_heights_to_download() + + # Check for valid year + if self.year < 2007 or self.year > 2014: + raise ValueError(f"Resource year for WIND Toolkit Data must be between 2007 and 2014 but {self.year} was provided") + + if filepath == "" and wtk_source_path=="": + # use default filepaths based on resource year + if self.year < 2014 and self.year>=2007: + self.wtk_file = WTK_V10_BASE + f"{self.year}.h5" + elif self.year == 2014: + self.wtk_file = WTK_V11_BASE + f"{self.year}.h5" + elif filepath != "" and wtk_source_path == "": + # filepath (full h5 filepath) is provided by user + if ".h5" not in filepath: + filepath = filepath + ".h5" + self.wtk_file = str(filepath) + elif filepath == "" and wtk_source_path != "": + # directory of h5 files (wtk_source_path) is provided by user + self.wtk_file = os.path.join(str(wtk_source_path),f"wtk_conus_{self.year}.h5") + else: + # use default filepaths + if self.year < 2014 and self.year>=2007: + self.wtk_file = WTK_V10_BASE + f"{self.year}.h5" + elif self.year == 2014: + self.wtk_file = WTK_V11_BASE + f"{self.year}.h5" + + # Check for valid filepath for Wind Toolkit file + if not os.path.isfile(self.wtk_file): + raise FileNotFoundError(f"Cannot find Wind Toolkit .h5 file, filepath {self.wtk_file} does not exist") + + # Pull data from HPC Wind Toolkit dataset + self.download_resource() + + # Set wind resource data into SAM/PySAM digestible format + self.format_data() + + + def calculate_heights_to_download(self): + """ + Given the system hub height, and the available hub heights from WindToolkit, + determine which heights to download to bracket the hub height + """ + hub_height_meters = self.hub_height_meters + + # evaluate hub height, determine what heights to download + heights = [hub_height_meters] + if hub_height_meters not in self.allowed_hub_heights_meters: + height_low = self.allowed_hub_heights_meters[0] + height_high = self.allowed_hub_heights_meters[-1] + for h in self.allowed_hub_heights_meters: + if h < hub_height_meters: + height_low = h + elif h > hub_height_meters: + height_high = h + break + heights[0] = height_low + heights.append(height_high) + + return heights + + def download_resource(self): + """load WTK h5 file using rex and get wind resource data for location + specified by (self.lat, self.lon) + """ + # NOTE: Current setup of files on HPC WINDToolkit v1.0.0 = 2007-2013, v1.1.0 = 2014 + + # Open file with rex WindX object + with WindX(self.wtk_file, hsds=False) as f: + # get gid of location closest to given lat/lon coordinates and timezone offset + site_gid = f.lat_lon_gid((self.latitude, self.longitude)) + time_zone = f.meta['timezone'].iloc[site_gid] + + # instantiate temp dictionary to hold each attributes dataset + self.wind_dict = {} + # loop through hub heights to download, capture datasets + # NOTE: datasets are not auto shifted by timezone offset + # -> wrap extraction in SAMResource.roll_timeseries(input_array, timezone, #steps in an hour=1) to roll timezones + # NOTE: pressure datasets unit = Pa, convert to atm via division by 101325 + for h in self.data_hub_heights: + self.wind_dict['temperature_{height}m_arr'.format(height=h)] = SAMResource.roll_timeseries((f['temperature_{height}m'.format(height=h), :, site_gid]), time_zone, 1) + self.wind_dict['pressure_{height}m_arr'.format(height=h)] = SAMResource.roll_timeseries((f['pressure_{height}m'.format(height=h), :, site_gid]/101325), time_zone, 1) + self.wind_dict['windspeed_{height}m_arr'.format(height=h)] = SAMResource.roll_timeseries((f['windspeed_{height}m'.format(height=h), :, site_gid]), time_zone, 1) + self.wind_dict['winddirection_{height}m_arr'.format(height=h)] = SAMResource.roll_timeseries((f['winddirection_{height}m'.format(height=h), :, site_gid]), time_zone, 1) + + self.site_gid = site_gid + def format_data(self): + # Remove data from feb29 on leap years + if (self.year % 4) == 0: + feb29 = np.arange(1416,1440) + for key, value in self.wind_dict.items(): + self.wind_dict[key] = np.delete(value, feb29) + + # round to desired precision and concatenate data into format needed for data dictionary + if len(self.data_hub_heights) == 2: + # NOTE: Unsure if SAM/PySAM is sensitive to data types ie: floats with long precision vs to 2 or 3 decimals. + # If not sensitive, can remove following 8 lines of code to increase computational efficiency + self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=1) + self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=2) + self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=3) + self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=1) + self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[1])] = np.round((self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[1])]), decimals=1) + self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[1])] = np.round((self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[1])]), decimals=2) + self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[1])] = np.round((self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[1])]), decimals=3) + self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[1])] = np.round((self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[1])]), decimals=1) + # combine all data into one 2D list + self.combined_data = [list(a) for a in zip(self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[0])], + self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[0])], + self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[0])], + self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[0])], + self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[1])], + self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[1])], + self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[1])], + self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[1])])] + + elif len(self.data_hub_heights) == 1: + # NOTE: Unsure if SAM/PySAM is sensitive to data types ie: floats with long precision vs to 2 or 3 decimals. + # If not sensitive, can remove following 4 lines of code to increase computational efficiency + self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=1) + self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=2) + self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=3) + self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[0])] = np.round((self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[0])]), decimals=1) + # combine all data into one 2D list + self.combined_data = [list(a) for a in zip(self.wind_dict['temperature_{h}m_arr'.format(h=self.data_hub_heights[0])], + self.wind_dict['pressure_{h}m_arr'.format(h=self.data_hub_heights[0])], + self.wind_dict['windspeed_{h}m_arr'.format(h=self.data_hub_heights[0])], + self.wind_dict['winddirection_{h}m_arr'.format(h=self.data_hub_heights[0])])] + self.data = self.combined_data + + @Resource.data.setter + def data(self, combined_data): + """Sets data property with wind resource data formatted for SAM + + data (dict): + :key heights (list(float)): floats corresponding to hub-height for 'data' entry. + ex: [100, 100, 100, 100, 120, 120, 120, 120] + :key fields (list(int)): integers corresponding to data type for 'data' entry + ex: [1, 2, 3, 4, 1, 2, 3, 4] + for each field (int) the corresponding data is: + - 1: Ambient temperature in degrees Celsius + - 2: Atmospheric pressure in in atmospheres. + - 3: Wind speed in meters per second (m/s) + - 4: Wind direction in degrees east of north (degrees). + :key data (list(list(floats)): 8760 list with data of corresponding field and hub-height + ex. data[timestep] is [-23.5, 0.65, 7.6, 261.2, -23.7, 0.65, 7.58, 261.1] + - -23.5 is temperature at 100m at timestep + - 7.6 is wind speed at 100m at timestep + - 7.58 is wind speed at 120m at timestep + """ + dic = { + 'heights': [float(h) for h in self.data_hub_heights for i in range(4)], + 'fields': [1, 2, 3, 4] * len(self.data_hub_heights), + 'data': combined_data + } + self._data = dic \ No newline at end of file diff --git a/hopp/simulation/technologies/sites/site_info.py b/hopp/simulation/technologies/sites/site_info.py index b1c40ed4d..fed80d108 100644 --- a/hopp/simulation/technologies/sites/site_info.py +++ b/hopp/simulation/technologies/sites/site_info.py @@ -17,7 +17,9 @@ SolarResource, WindResource, WaveResource, - ElectricityPrices + ElectricityPrices, + HPCWindData, + HPCSolarData, ) from hopp.tools.layout.plot_tools import plot_shape from hopp.utilities.log import hybrid_logger as logger @@ -29,6 +31,7 @@ from hopp.simulation.base import BaseClass from hopp.utilities.validators import contains +from hopp import ROOT_DIR def plot_site(verts, plt_style, labels): for i in range(len(verts)): if i == 0: @@ -49,6 +52,14 @@ class SiteInfo(BaseClass): solar_resource_file: Path to solar resource file. Defaults to "". wind_resource_file: Path to wind resource file. Defaults to "". grid_resource_file: Path to grid pricing data file. Defaults to "". + path_resource: Path to folder to save resource files. + Defaults to ROOT/simulation/resource_files + wtk_source_path (Optional): directory of Wind Toolkit h5 files hosted on HPC. + Only used if renewable_resource_origin != "API" + nsrdb_source_path (Optional): directory of NSRDB h5 files hosted on HPC. + Only used if renewable_resource_origin != "API" + renewable_resource_origin (str): whether to download resource data from API or load directly from datasets files. + Options are "API" or "HPC". Defaults to "API" hub_height: Turbine hub height for resource download in meters. Defaults to 97.0. capacity_hours: Boolean list indicating hours for capacity payments. Defaults to []. desired_schedule: Absolute desired load profile in MWe. Defaults to []. @@ -65,6 +76,11 @@ class SiteInfo(BaseClass): wind_resource_file: Union[Path, str] = field(default="", converter=resource_file_converter) wave_resource_file: Union[Path, str] = field(default="", converter=resource_file_converter) grid_resource_file: Union[Path, str] = field(default="", converter=resource_file_converter) + + path_resource: Optional[Union[Path, str]] = field(default=ROOT_DIR / "simulation" / "resource_files") + wtk_source_path: Optional[Union[Path,str]] = field(default = "") + nsrdb_source_path: Optional[Union[Path,str]] = field(default = "") + hub_height: hopp_float_type = field(default=97., converter=hopp_float_type) capacity_hours: NDArray = field(default=[], converter=converter(bool)) desired_schedule: NDArrayFloat = field(default=[], converter=converter()) @@ -73,6 +89,7 @@ class SiteInfo(BaseClass): solar: bool = field(default=True) wind: bool = field(default=True) wave: bool = field(default=False) + renewable_resource_origin: str = field(default="API", validator=contains(["API", "HPC"])) wind_resource_origin: str = field(default="WTK", validator=contains(["WTK", "TAP"])) # Set in post init hook @@ -81,8 +98,8 @@ class SiteInfo(BaseClass): lon: hopp_float_type = field(init=False) year: int = field(init=False, default=2012) tz: Optional[int] = field(init=False, default=None) - solar_resource: Optional[SolarResource] = field(init=False, default=None) - wind_resource: Optional[WindResource] = field(init=False, default=None) + solar_resource: Optional[Union[SolarResource,HPCSolarData]] = field(default=None) + wind_resource: Optional[Union[WindResource,HPCWindData]] = field(default=None) wave_resoure: Optional[WaveResource] = field(init=False, default=None) elec_prices: Optional[ElectricityPrices] = field(init=False, default=None) n_periods_per_day: int = field(init=False) @@ -113,7 +130,8 @@ def __attrs_post_init__(self): urdb_label (str): Link to `Utility Rate DataBase `_ label for REopt runs. follow_desired_schedule (bool): Indicates if a desired schedule was provided. Defaults to False. """ - set_nrel_key_dot_env() + if self.renewable_resource_origin=="API": + set_nrel_key_dot_env() data = self.data if 'site_boundaries' in data: @@ -130,12 +148,19 @@ def __attrs_post_init__(self): self.lon = data['lon'] if 'year' not in data: - data['year'] = 2012 + data['year'] = self.year + + self.year = data["year"] + if 'tz' in data: self.tz = data['tz'] if self.solar: - self.solar_resource = SolarResource(data['lat'], data['lon'], data['year'], filepath=self.solar_resource_file) + if self.solar_resource is None: + if self.renewable_resource_origin=="API": + self.solar_resource = SolarResource(data['lat'], data['lon'], data['year'], path_resource=self.path_resource, filepath=self.solar_resource_file) + else: + self.solar_resource = HPCSolarData(data['lat'], data['lon'], data['year'],nsrdb_source_path = self.nsrdb_source_path, filepath=self.solar_resource_file) self.n_timesteps = len(self.solar_resource.data['gh']) // 8760 * 8760 if self.wave: self.wave_resource = WaveResource(data['lat'], data['lon'], data['year'], filepath = self.wave_resource_file) @@ -143,8 +168,13 @@ def __attrs_post_init__(self): if self.wind: # TODO: allow hub height to be used as an optimization variable - self.wind_resource = WindResource(data['lat'], data['lon'], data['year'], wind_turbine_hub_ht=self.hub_height, - filepath=self.wind_resource_file, source=self.wind_resource_origin) + if self.wind_resource is None: + if self.renewable_resource_origin=="API": + self.wind_resource = WindResource(data['lat'], data['lon'], data['year'], wind_turbine_hub_ht=self.hub_height, + path_resource=self.path_resource, filepath=self.wind_resource_file, source=self.wind_resource_origin) + else: + self.wind_resource = HPCWindData(data['lat'], data['lon'], data['year'], wind_turbine_hub_ht=self.hub_height, + wtk_source_path=self.wtk_source_path, filepath=self.wind_resource_file) n_timesteps = len(self.wind_resource.data['data']) // 8760 * 8760 if self.n_timesteps is None: self.n_timesteps = n_timesteps diff --git a/pyproject.toml b/pyproject.toml index 76156a499..94a822091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,8 @@ dependencies = [ "attrs", "utm", "pyyaml-include", - "profast" + "profast", + "NREL-rex" ] keywords = [ "python3", diff --git a/tests/hopp/test_resource_download.py b/tests/hopp/test_resource_download.py index b2a026578..553e5f8a4 100644 --- a/tests/hopp/test_resource_download.py +++ b/tests/hopp/test_resource_download.py @@ -5,12 +5,14 @@ from hopp import ROOT_DIR from hopp.simulation.technologies.resource.solar_resource import BASE_URL as SOLAR_URL from hopp.simulation.technologies.resource.wind_resource import WTK_BASE_URL, TAP_BASE_URL -from hopp.simulation.technologies.resource import SolarResource, WindResource, Resource +from hopp.simulation.technologies.resource import SolarResource, WindResource, Resource, HPCWindData, HPCSolarData from hopp.utilities.utils_for_tests import DEFAULT_WIND_RESOURCE_FILE import PySAM.Windpower as wp import PySAM.Pvwattsv8 as pv +import pytest + dir_path = os.path.dirname(os.path.realpath(__file__)) year = 2012 @@ -154,3 +156,47 @@ def test_from_file(): filepath=str(solar_file) ) assert(len(solar_resource.data['gh']) > 0) + +def test_wtk_resource_filenotfound_wtk_source_path(): + wtk_fake_dir = str(ROOT_DIR) + resource_year = 2012 + wtk_fake_fpath = os.path.join(str(ROOT_DIR),f"wtk_conus_{resource_year}.h5") + with pytest.raises(FileNotFoundError) as err: + HPCWindData(lat = 35.201, lon = -101.945, year = resource_year, wind_turbine_hub_ht = 110, wtk_source_path=wtk_fake_dir) + assert str(err.value) == f"Cannot find Wind Toolkit .h5 file, filepath {wtk_fake_fpath} does not exist" + +def test_wtk_resource_filenotfound_filepath(): + resource_year = 2012 + wtk_fake_fpath = os.path.join(str(ROOT_DIR),f"wtk_conus_{resource_year}.h5") + with pytest.raises(FileNotFoundError) as err: + HPCWindData(lat = 35.201, lon = -101.945, year = resource_year, wind_turbine_hub_ht = 110, filepath = wtk_fake_fpath) + assert str(err.value) == f"Cannot find Wind Toolkit .h5 file, filepath {wtk_fake_fpath} does not exist" + +def test_wtk_resource_invalid_year(): + wtk_fake_dir = str(ROOT_DIR) + resource_year = 2006 + with pytest.raises(ValueError) as err: + HPCWindData(lat = 35.201, lon = -101.945, year = resource_year, wind_turbine_hub_ht = 110, wtk_source_path=wtk_fake_dir) + assert str(err.value) == f"Resource year for WIND Toolkit Data must be between 2007 and 2014 but {resource_year} was provided" + +def test_nsrdb_resource_filenotfound_nsrdb_source_path(): + nsrdb_fake_dir = str(ROOT_DIR) + resource_year = 2012 + nsrdb_fake_fpath = os.path.join(str(ROOT_DIR),f"nsrdb_{resource_year}.h5") + with pytest.raises(FileNotFoundError) as err: + HPCSolarData(lat = 35.201, lon = -101.945, year = resource_year, nsrdb_source_path=nsrdb_fake_dir) + assert str(err.value) == f"Cannot find NSRDB .h5 file, filepath {nsrdb_fake_fpath} does not exist" + +def test_nsrdb_resource_filenotfound_filepath(): + resource_year = 2012 + nsrdb_fake_fpath = os.path.join(str(ROOT_DIR),f"nsrdb_{resource_year}.h5") + with pytest.raises(FileNotFoundError) as err: + HPCSolarData(lat = 35.201, lon = -101.945, year = resource_year, filepath = nsrdb_fake_fpath) + assert str(err.value) == f"Cannot find NSRDB .h5 file, filepath {nsrdb_fake_fpath} does not exist" + +def test_nsrdb_resource_invalid_year(): + nsrdb_fake_dir = str(ROOT_DIR) + resource_year = 2023 + with pytest.raises(ValueError) as err: + HPCSolarData(lat = 35.201, lon = -101.945, year = resource_year, nsrdb_source_path=nsrdb_fake_dir) + assert str(err.value) == f"Resource year for NSRDB Data must be between 1998 and 2022 but {resource_year} was provided" \ No newline at end of file