Skip to content

Commit a156e6c

Browse files
mikofskiwholmgren
authored andcommitted
ENH: Create psm3.py (#694)
* Create psm3.py initial concept for psm3 reader * fix well known text for geometric POINT * need to strip all leading whitespace, and only one whitespace between, and longitude is first, then latitude * fix list() not [] * fix ATTRIBUTES and URL * coerce all to float, clean up types in header, cast datevec columns to int, create datetimeindex from datevec columns, set index, get errors from json when returning HTTPError Signed-off-by: Mark Mikofski <[email protected]> * TST: add test for getting psm3 * update docs, closes #592 * stickler, default interval * add psm3 to api * use astype, fix typos, change param names to match API * use pandas, better variable names, comments, set all dtypes * stickler fixes * api_key as param * TST: decorate test_get_psm3 with needs_pandas_0_22 * api_key and email are required according to NREL * other params full name and affiliation are kwargs * set reason to pvlib something as suggested by NREL * catch some errors, like 403 forbidden, are in content, not json, only some errors like bad well known text WKT gemetry are in JSON * use python-2 json decode error * add another test to capture both bad API key and bad WKT * add note on how to register for key, why they need email, and when it would be used, and warning about DEMO_KEY being rate limited Signed-off-by: Mark Mikofski <[email protected]> * stickler: remove unused JSON import * remove non-ASCII quotation marks around DEMO_KEY in warning * remove "required" for api_key param in get_psm3 Co-Authored-By: mikofski <[email protected]> * rewording for "email" param in get_psm3 Co-Authored-By: mikofski <[email protected]> * clarify WKT formating and errors * in raise docstring, change "returns" to "raises", and clarify that either r.json()['errors'] or r.content is the exception arg depending on the type of HTTP error, and give examples * add warning in docstring that data are limited to NSRDB locations * comment on WKT formatting and add TODO to consider making a format_WKT() function in tools.py one day * add response kwarg to HTTPError for better user exception handling * in test_psm3 add comments to clarify the tests for errors and add two more test for bad "names" and interval args * DOC: defaults in argtype for get_psm3
1 parent 9a69bf4 commit a156e6c

File tree

7 files changed

+9019
-0
lines changed

7 files changed

+9019
-0
lines changed

docs/sphinx/source/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ relevant to solar energy modeling.
341341
iotools.get_ecmwf_macc
342342
iotools.read_crn
343343
iotools.read_solrad
344+
iotools.get_psm3
344345

345346
A :py:class:`~pvlib.location.Location` object may be created from metadata
346347
in some files.

docs/sphinx/source/whatsnew/v0.6.2.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Enhancements
2929
* Add US CRN data reader to :ref:`iotools`.
3030
* Add SOLRAD data reader to :ref:`iotools`.
3131
* Add EPW data reader to :ref:`iotools`. (:issue:`591`)
32+
* Add PSM3 reader to :ref:`iotools`. (:issue:`592`)
3233

3334
Bug fixes
3435
~~~~~~~~~

pvlib/data/test_psm3.csv

Lines changed: 8761 additions & 0 deletions
Large diffs are not rendered by default.

pvlib/iotools/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
from pvlib.iotools.ecmwf_macc import get_ecmwf_macc # noqa: F401
1111
from pvlib.iotools.crn import read_crn # noqa: F401
1212
from pvlib.iotools.solrad import read_solrad # noqa: F401
13+
from pvlib.iotools.psm3 import get_psm3 # noqa: F401

pvlib/iotools/psm3.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
2+
"""
3+
Get PSM3 TMY
4+
see https://developer.nrel.gov/docs/solar/nsrdb/psm3_data_download/
5+
"""
6+
7+
import io
8+
import requests
9+
import pandas as pd
10+
# Python-2 compatible JSONDecodeError
11+
try:
12+
from json import JSONDecodeError
13+
except ImportError:
14+
JSONDecodeError = ValueError
15+
16+
URL = "http://developer.nrel.gov/api/solar/nsrdb_psm3_download.csv"
17+
18+
# 'relative_humidity', 'total_precipitable_water' are not available
19+
ATTRIBUTES = [
20+
'air_temperature', 'dew_point', 'dhi', 'dni', 'ghi', 'surface_albedo',
21+
'surface_pressure', 'wind_direction', 'wind_speed']
22+
PVLIB_PYTHON = 'pvlib python'
23+
24+
25+
def get_psm3(latitude, longitude, api_key, email, names='tmy', interval=60,
26+
full_name=PVLIB_PYTHON, affiliation=PVLIB_PYTHON):
27+
"""
28+
Get PSM3 data
29+
30+
Parameters
31+
----------
32+
latitude : float or int
33+
in decimal degrees, between -90 and 90, north is positive
34+
longitude : float or int
35+
in decimal degrees, between -180 and 180, east is positive
36+
api_key : str
37+
NREL Developer Network API key
38+
email : str
39+
NREL API uses this to automatically communicate messages back
40+
to the user only if necessary
41+
names : str, default 'tmy'
42+
PSM3 API parameter specifing year or TMY variant to download, see notes
43+
below for options
44+
interval : int, default 60
45+
interval size in minutes, can only be either 30 or 60
46+
full_name : str, default 'pvlib python'
47+
optional
48+
affiliation : str, default 'pvlib python'
49+
optional
50+
51+
Returns
52+
-------
53+
headers : dict
54+
metadata from NREL PSM3 about the record, see notes for fields
55+
data : pandas.DataFrame
56+
timeseries data from NREL PSM3
57+
58+
Raises
59+
------
60+
requests.HTTPError
61+
if the request response status is not ok, then the ``'errors'`` field
62+
from the JSON response or any error message in the content will be
63+
raised as an exception, for example if the `api_key` was rejected or if
64+
the coordinates were not found in the NSRDB
65+
66+
Notes
67+
-----
68+
The required NREL developer key, `api_key`, is available for free by
69+
registering at the `NREL Developer Network <https://developer.nrel.gov/>`_.
70+
71+
.. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may
72+
result in rejected requests.
73+
74+
The PSM3 API `names` parameter must be a single value from the following
75+
list::
76+
77+
['1998', '1999', '2000', '2001', '2002', '2003', '2004', '2005',
78+
'2006', '2007', '2008', '2009', '2010', '2011', '2012', '2013',
79+
'2014', '2015', '2016', '2017', 'tmy', 'tmy-2016', 'tmy-2017',
80+
'tdy-2017', 'tgy-2017']
81+
82+
The return is a tuple with two items. The first item is a header with
83+
metadata from NREL PSM3 about the record containing the following fields:
84+
85+
* Source
86+
* Location ID
87+
* City
88+
* State
89+
* Country
90+
* Latitude
91+
* Longitude
92+
* Time Zone
93+
* Elevation
94+
* Local Time Zone
95+
* Dew Point Units
96+
* DHI Units
97+
* DNI Units
98+
* GHI Units
99+
* Temperature Units
100+
* Pressure Units
101+
* Wind Direction Units
102+
* Wind Speed
103+
* Surface Albedo Units
104+
* Version
105+
106+
The second item is a dataframe with the timeseries data downloaded.
107+
108+
.. warning:: PSM3 is limited to data found in the NSRDB, please consult the
109+
references below for locations with available data
110+
111+
See Also
112+
--------
113+
pvlib.iotools.read_tmy2, pvlib.iotools.read_tmy3
114+
115+
References
116+
----------
117+
118+
* `NREL Developer Network - Physical Solar Model (PSM) v3
119+
<https://developer.nrel.gov/docs/solar/nsrdb/psm3_data_download/>`_
120+
* `NREL National Solar Radiation Database (NSRDB)
121+
<https://nsrdb.nrel.gov/>`_
122+
123+
"""
124+
# The well know text (WKT) representation of geometry notation is strict.
125+
# A POINT object is a string with longitude first, then the latitude, with
126+
# four decimals each, and exactly one space between them.
127+
longitude = ('%9.4f' % longitude).strip()
128+
latitude = ('%8.4f' % latitude).strip()
129+
# TODO: make format_WKT(object_type, *args) in tools.py
130+
131+
# required query-string parameters for request to PSM3 API
132+
params = {
133+
'api_key': api_key,
134+
'full_name': full_name,
135+
'email': email,
136+
'affiliation': affiliation,
137+
'reason': PVLIB_PYTHON,
138+
'mailing_list': 'false',
139+
'wkt': 'POINT(%s %s)' % (longitude, latitude),
140+
'names': names,
141+
'attributes': ','.join(ATTRIBUTES),
142+
'leap_day': 'false',
143+
'utc': 'false',
144+
'interval': interval
145+
}
146+
# request CSV download from NREL PSM3
147+
response = requests.get(URL, params=params)
148+
if not response.ok:
149+
# if the API key is rejected, then the response status will be 403
150+
# Forbidden, and then the error is in the content and there is no JSON
151+
try:
152+
errors = response.json()['errors']
153+
except JSONDecodeError:
154+
errors = response.content.decode('utf-8')
155+
raise requests.HTTPError(errors, response=response)
156+
# the CSV is in the response content as a UTF-8 bytestring
157+
# to use pandas we need to create a file buffer from the response
158+
fbuf = io.StringIO(response.content.decode('utf-8'))
159+
# The first 2 lines of the response are headers with metadat
160+
header_fields = fbuf.readline().split(',')
161+
header_fields[-1] = header_fields[-1].strip() # strip trailing newline
162+
header_values = fbuf.readline().split(',')
163+
header_values[-1] = header_values[-1].strip() # strip trailing newline
164+
header = dict(zip(header_fields, header_values))
165+
# the response is all strings, so set some header types to numbers
166+
header['Local Time Zone'] = int(header['Local Time Zone'])
167+
header['Time Zone'] = int(header['Time Zone'])
168+
header['Latitude'] = float(header['Latitude'])
169+
header['Longitude'] = float(header['Longitude'])
170+
header['Elevation'] = int(header['Elevation'])
171+
# get the column names so we can set the dtypes
172+
columns = fbuf.readline().split(',')
173+
columns[-1] = columns[-1].strip() # strip trailing newline
174+
dtypes = dict.fromkeys(columns, float) # all floats except datevec
175+
dtypes.update(Year=int, Month=int, Day=int, Hour=int, Minute=int)
176+
data = pd.read_csv(
177+
fbuf, header=None, names=columns, dtype=dtypes,
178+
delimiter=',', lineterminator='\n') # skip carriage returns \r
179+
# the response 1st 5 columns are a date vector, convert to datetime
180+
dtidx = pd.to_datetime(
181+
data[['Year', 'Month', 'Day', 'Hour', 'Minute']])
182+
# in USA all timezones are intergers
183+
tz = 'Etc/GMT%+d' % -header['Time Zone']
184+
data.index = pd.DatetimeIndex(dtidx).tz_localize(tz)
185+
return header, data

pvlib/test/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ def pandas_0_22():
9494
return parse_version(pd.__version__) >= parse_version('0.22.0')
9595

9696

97+
needs_pandas_0_22 = pytest.mark.skipif(
98+
not pandas_0_22(), reason='requires pandas 0.22 or greater')
99+
100+
97101
def has_spa_c():
98102
try:
99103
from pvlib.spa_c_files.spa_py import spa_calc

pvlib/test/test_psm3.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
test iotools for PSM3
3+
"""
4+
5+
import os
6+
from pvlib.iotools import psm3
7+
from conftest import needs_pandas_0_22
8+
import numpy as np
9+
import pandas as pd
10+
import pytest
11+
from requests import HTTPError
12+
13+
BASEDIR = os.path.abspath(os.path.dirname(__file__))
14+
PROJDIR = os.path.dirname(BASEDIR)
15+
DATADIR = os.path.join(PROJDIR, 'data')
16+
TEST_DATA = os.path.join(DATADIR, 'test_psm3.csv')
17+
LATITUDE, LONGITUDE = 40.5137, -108.5449
18+
HEADER_FIELDS = [
19+
'Source', 'Location ID', 'City', 'State', 'Country', 'Latitude',
20+
'Longitude', 'Time Zone', 'Elevation', 'Local Time Zone',
21+
'Dew Point Units', 'DHI Units', 'DNI Units', 'GHI Units',
22+
'Temperature Units', 'Pressure Units', 'Wind Direction Units',
23+
'Wind Speed', 'Surface Albedo Units', 'Version']
24+
PVLIB_EMAIL = '[email protected]'
25+
DEMO_KEY = 'DEMO_KEY'
26+
27+
28+
@needs_pandas_0_22
29+
def test_get_psm3():
30+
"""test get_psm3"""
31+
header, data = psm3.get_psm3(LATITUDE, LONGITUDE, DEMO_KEY, PVLIB_EMAIL)
32+
expected = pd.read_csv(TEST_DATA)
33+
# check datevec columns
34+
assert np.allclose(data.Year, expected.Year)
35+
assert np.allclose(data.Month, expected.Month)
36+
assert np.allclose(data.Day, expected.Day)
37+
assert np.allclose(data.Hour, expected.Hour)
38+
assert np.allclose(data.Minute, expected.Minute)
39+
# check data columns
40+
assert np.allclose(data.GHI, expected.GHI)
41+
assert np.allclose(data.DNI, expected.DNI)
42+
assert np.allclose(data.DHI, expected.DHI)
43+
assert np.allclose(data.Temperature, expected.Temperature)
44+
assert np.allclose(data.Pressure, expected.Pressure)
45+
assert np.allclose(data['Dew Point'], expected['Dew Point'])
46+
assert np.allclose(data['Surface Albedo'], expected['Surface Albedo'])
47+
assert np.allclose(data['Wind Speed'], expected['Wind Speed'])
48+
assert np.allclose(data['Wind Direction'], expected['Wind Direction'])
49+
# check header
50+
for hf in HEADER_FIELDS:
51+
assert hf in header
52+
# check timezone
53+
assert (data.index.tzinfo.zone == 'Etc/GMT%+d' % -header['Time Zone'])
54+
# check errors
55+
with pytest.raises(HTTPError):
56+
# HTTP 403 forbidden because api_key is rejected
57+
psm3.get_psm3(LATITUDE, LONGITUDE, api_key='BAD', email=PVLIB_EMAIL)
58+
with pytest.raises(HTTPError):
59+
# coordinates were not found in the NSRDB
60+
psm3.get_psm3(51, -5, DEMO_KEY, PVLIB_EMAIL)
61+
with pytest.raises(HTTPError):
62+
# names is not one of the available options
63+
psm3.get_psm3(LATITUDE, LONGITUDE, DEMO_KEY, PVLIB_EMAIL, names='bad')
64+
with pytest.raises(HTTPError):
65+
# intervals can only be 30 or 60 minutes
66+
psm3.get_psm3(LATITUDE, LONGITUDE, DEMO_KEY, PVLIB_EMAIL, interval=15)

0 commit comments

Comments
 (0)