-
Notifications
You must be signed in to change notification settings - Fork 1.1k
ENH: Create psm3.py #694
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ENH: Create psm3.py #694
Changes from all commits
e0859f7
6535a04
f7003ac
15f93d3
4c8c073
2b9cff5
c52d5b7
2809897
6cf57a4
05cf879
13c196a
02c143f
64d7cb9
f9
8000
df42d
b6f79cf
8884c7f
1935215
24c1f3b
4059db5
f12c36b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
|
||
""" | ||
Get PSM3 TMY | ||
see https://developer.nrel.gov/docs/solar/nsrdb/psm3_data_download/ | ||
""" | ||
|
||
import io | ||
import requests | ||
import pandas as pd | ||
# Python-2 compatible JSONDecodeError | ||
try: | ||
from json import JSONDecodeError | ||
except ImportError: | ||
JSONDecodeError = ValueError | ||
|
||
URL = "http://developer.nrel.gov/api/solar/nsrdb_psm3_download.csv" | ||
|
||
# 'relative_humidity', 'total_precipitable_water' are not available | ||
ATTRIBUTES = [ | ||
'air_temperature', 'dew_point', 'dhi', 'dni', 'ghi', 'surface_albedo', | ||
'surface_pressure', 'wind_direction', 'wind_speed'] | ||
PVLIB_PYTHON = 'pvlib python' | ||
|
||
|
||
def get_psm3(latitude, longitude, api_key, email, names='tmy', interval=60, | ||
full_name=PVLIB_PYTHON, affiliation=PVLIB_PYTHON): | ||
""" | ||
Get PSM3 data | ||
|
||
Parameters | ||
---------- | ||
latitude : float or int | ||
in decimal degrees, between -90 and 90, north is positive | ||
longitude : float or int | ||
in decimal degrees, between -180 and 180, east is positive | ||
api_key : str | ||
NREL Developer Network API key | ||
email : str | ||
NREL API uses this to automatically communicate messages back | ||
to the user only if necessary | ||
names : str, default 'tmy' | ||
PSM3 API parameter specifing year or TMY variant to download, see notes | ||
below for options | ||
interval : int, default 60 | ||
interval size in minutes, can only be either 30 or 60 | ||
full_name : str, default 'pvlib python' | ||
optional | ||
affiliation : str, default 'pvlib python' | ||
optional | ||
|
||
Returns | ||
------- | ||
headers : dict | ||
metadata from NREL PSM3 about the record, see notes for fields | ||
data : pandas.DataFrame | ||
timeseries data from NREL PSM3 | ||
|
||
Raises | ||
------ | ||
requests.HTTPError | ||
if the request response status is not ok, then the ``'errors'`` field | ||
from the JSON response or any error message in the content will be | ||
raised as an exception, for example if the `api_key` was rejected or if | ||
the coordinates were not found in the NSRDB | ||
|
||
Notes | ||
----- | ||
The required NREL developer key, `api_key`, is available for free by | ||
registering at the `NREL Developer Network <https://developer.nrel.gov/>`_. | ||
|
||
.. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may | ||
result in rejected requests. | ||
|
||
The PSM3 API `names` parameter must be a single value from the following | ||
list:: | ||
|
||
['1998', '1999', '2000', '2001', '2002', '2003', '2004', '2005', | ||
'2006', '2007', '2008', '2009', '2010', '2011', '2012', '2013', | ||
'2014', '2015', '2016', '2017', 'tmy', 'tmy-2016', 'tmy-2017', | ||
cwhanse marked this conversation as resolved.
Show resolved
Hide resolved
|
||
'tdy-2017', 'tgy-2017'] | ||
|
||
The return is a tuple with two items. The first item is a header with | ||
metadata from NREL PSM3 about the record containing the following fields: | ||
|
||
* Source | ||
* Location ID | ||
* City | ||
* State | ||
* Country | ||
* Latitude | ||
* Longitude | ||
* Time Zone | ||
* Elevation | ||
* Local Time Zone | ||
* Dew Point Units | ||
* DHI Units | ||
* DNI Units | ||
* GHI Units | ||
* Temperature Units | ||
* Pressure Units | ||
* Wind Direction Units | ||
* Wind Speed | ||
* Surface Albedo Units | ||
* Version | ||
|
||
The second item is a dataframe with the timeseries data downloaded. | ||
|
||
.. warning:: PSM3 is limited to data found in the NSRDB, please consult the | ||
references below for locations with available data | ||
|
||
See Also | ||
-------- | ||
pvlib.iotools.read_tmy2, pvlib.iotools.read_tmy3 | ||
|
||
References | ||
---------- | ||
|
||
* `NREL Developer Network - Physical Solar Model (PSM) v3 | ||
<https://developer.nrel.gov/docs/solar/nsrdb/psm3_data_download/>`_ | ||
* `NREL National Solar Radiation Database (NSRDB) | ||
<https://nsrdb.nrel.gov/>`_ | ||
|
||
""" | ||
# The well know text (WKT) representation of geometry notation is strict. | ||
# A POINT object is a string with longitude first, then the latitude, with | ||
# four decimals each, and exactly one space between them. | ||
longitude = ('%9.4f' % longitude).strip() | ||
mikofski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
latitude = ('%8.4f' % latitude).strip() | ||
# TODO: make format_WKT(object_type, *args) in tools.py | ||
|
||
# required query-string parameters for request to PSM3 API | ||
params = { | ||
mikofski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
'api_key': api_key, | ||
'full_name': full_name, | ||
'email': email, | ||
'affiliation': affiliation, | ||
'reason': PVLIB_PYTHON, | ||
'mailing_list': 'false', | ||
'wkt': 'POINT(%s %s)' % (longitude, latitude), | ||
'names': names, | ||
'attributes': ','.join(ATTRIBUTES), | ||
'leap_day': 'false', | ||
'utc': 'false', | ||
'interval': interval | ||
} | ||
# request CSV download from NREL PSM3 | ||
response = requests.get(URL, params=params) | ||
if not response.ok: | ||
# if the API key is rejected, then the response status will be 403 | ||
# Forbidden, and then the error is in the content and there is no JSON | ||
try: | ||
errors = response.json()['errors'] | ||
except JSONDecodeError: | ||
errors = response.content.decode('utf-8') | ||
mikofski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
raise requests.HTTPError(errors, response=response) | ||
# the CSV is in the response content as a UTF-8 bytestring | ||
# to use pandas we need to create a file buffer from the response | ||
fbuf = io.StringIO(response.content.decode('utf-8')) | ||
# The first 2 lines of the response are headers with metadat | ||
header_fields = fbuf.readline().split(',') | ||
header_fields[-1] = header_fields[-1].strip() # strip trailing newline | ||
header_values = fbuf.readline().split(',') | ||
header_values[-1] = header_values[-1].strip() # strip trailing newline | ||
header = dict(zip(header_fields, header_values)) | ||
# the response is all strings, so set some header types to numbers | ||
header['Local Time Zone'] = int(header['Local Time Zone']) | ||
header['Time Zone'] = int(header['Time Zone']) | ||
header['Latitude'] = float(header['Latitude']) | ||
header['Longitude'] = float(header['Longitude']) | ||
header['Elevation'] = int(header['Elevation']) | ||
# get the column names so we can set the dtypes | ||
columns = fbuf.readline().split(',') | ||
columns[-1] = columns[-1].strip() # strip trailing newline | ||
dtypes = dict.fromkeys(columns, float) # all floats except datevec | ||
dtypes.update(Year=int, Month=int, Day=int, Hour=int, Minute=int) | ||
data = pd.read_csv( | ||
fbuf, header=None, names=columns, dtype=dtypes, | ||
delimiter=',', lineterminator='\n') # skip carriage returns \r | ||
# the response 1st 5 columns are a date vector, convert to datetime | ||
dtidx = pd.to_datetime( | ||
data[['Year', 'Month', 'Day', 'Hour', 'Minute']]) | ||
# in USA all timezones are intergers | ||
tz = 'Etc/GMT%+d' % -header['Time Zone'] | ||
data.index = pd.DatetimeIndex(dtidx).tz_localize(tz) | ||
return header, data |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
""" | ||
test iotools for PSM3 | ||
""" | ||
|
||
import os | ||
from pvlib.iotools import psm3 | ||
from conftest import needs_pandas_0_22 | ||
import numpy as np | ||
import pandas as pd | ||
import pytest | ||
from requests import HTTPError | ||
|
||
BASEDIR = os.path.abspath(os.path.dirname(__file__)) | ||
PROJDIR = os.path.dirname(BASEDIR) | ||
DATADIR = os.path.join(PROJDIR, 'data') | ||
TEST_DATA = os.path.join(DATADIR, 'test_psm3.csv') | ||
LATITUDE, LONGITUDE = 40.5137, -108.5449 | ||
HEADER_FIELDS = [ | ||
'Source', 'Location ID', 'City', 'State', 'Country', 'Latitude', | ||
'Longitude', 'Time Zone', 'Elevation', 'Local Time Zone', | ||
'Dew Point Units', 'DHI Units', 'DNI Units', 'GHI Units', | ||
'Temperature Units', 'Pressure Units', 'Wind Direction Units', | ||
'Wind Speed', 'Surface Albedo Units', 'Version'] | ||
PVLIB_EMAIL = 'pvlib-admin@googlegroups.com' | ||
DEMO_KEY = 'DEMO_KEY' | ||
|
||
|
||
@needs_pandas_0_22 | ||
def test_get_psm3(): | ||
"""test get_psm3""" | ||
header, data = psm3.get_psm3(LATITUDE, LONGITUDE, DEMO_KEY, PVLIB_EMAIL) | ||
expected = pd.read_csv(TEST_DATA) | ||
# check datevec columns | ||
assert np.allclose(data.Year, expected.Year) | ||
assert np.allclose(data.Month, expected.Month) | ||
assert np.allclose(data.Day, expected.Day) | ||
assert np.allclose(data.Hour, expected.Hour) | ||
assert np.allclose(data.Minute, expected.Minute) | ||
# check data columns | ||
assert np.allclose(data.GHI, expected.GHI) | ||
assert np.allclose(data.DNI, expected.DNI) | ||
assert np.allclose(data.DHI, expected.DHI) | ||
assert np.allclose(data.Temperature, expected.Temperature) | ||
assert np.allclose(data.Pressure, expected.Pressure) | ||
assert np.allclose(data['Dew Point'], expected['Dew Point']) | ||
assert np.allclose(data['Surface Albedo'], expected['Surface Albedo']) | ||
assert np.allclose(data['Wind Speed'], expected['Wind Speed']) | ||
assert np.allclose(data['Wind Direction'], expected['Wind Direction']) | ||
# check header | ||
for hf in HEADER_FIELDS: | ||
assert hf in header | ||
# check timezone | ||
assert (data.index.tzinfo.zone == 'Etc/GMT%+d' % -header['Time Zone']) | ||
# check errors | ||
with pytest.raises(HTTPError): | ||
# HTTP 403 forbidden because api_key is rejected | ||
psm3.get_psm3(LATITUDE, LONGITUDE, api_key='BAD', email=PVLIB_EMAIL) | ||
with pytest.raises(HTTPError): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why does this one fail? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
perhaps I should add some more comments here to explain these tests - like I did here - for future devs? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BAD was self explanatory. I don't think we need to test that it fails outside the US. Not our problem. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PSM3 has data in a lat/long rectangle around the lower 48 states, so there's coverage in northern Mexico for example. But it has a coastline filter and excludes data in the Gulf of Mexico. I think we should test for failure at lat/long where PSM3 does not have data. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does PSM3 return a sensible error if lat/lon outside of its area? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Returns
The first line is what I expect, the next 2 are json decoding errors that puzzle me. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aha, I suspect the 2nd and 3rd messages are empty responses because of the once-per-2second throttle, so Confirmed. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This could be confusing to pvlib users because we're asking the to specify lat/lon instead of API. So I'm ok with catching this in the function and raising a more descriptive error about lat/lon. |
||
# coordinates were not found in the NSRDB | ||
psm3.get_psm3(51, -5, DEMO_KEY, PVLIB_EMAIL) | ||
with pytest.raises(HTTPError): | ||
# names is not one of the available options | ||
psm3.get_psm3(LATITUDE, LONGITUDE, DEMO_KEY, PVLIB_EMAIL, names='bad') | ||
with pytest.raises(HTTPError): | ||
# intervals can only be 30 or 60 minutes | ||
psm3.get_psm3(LATITUDE, LONGITUDE, DEMO_KEY, PVLIB_EMAIL, interval=15) |
Uh oh!
There was an error while loading. Please reload this page.