Skip to content

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

Merged
merged 20 commits into from
May 3, 2019
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/sphinx/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ relevant to solar energy modeling.
iotools.get_ecmwf_macc
iotools.read_crn
iotools.read_solrad
iotools.get_psm3

A :py:class:`~pvlib.location.Location` object may be created from metadata
in some files.
Expand Down
1 change: 1 addition & 0 deletions docs/sphinx/source/whatsnew/v0.6.2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Enhancements
* Add US CRN data reader to :ref:`iotools`.
* Add SOLRAD data reader to :ref:`iotools`.
* Add EPW data reader to :ref:`iotools`. (:issue:`591`)
* Add PSM3 reader to :ref:`iotools`. (:issue:`592`)

Bug fixes
~~~~~~~~~
Expand Down
8,761 changes: 8,761 additions & 0 deletions pvlib/data/test_psm3.csv

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pvlib/iotools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
from pvlib.iotools.ecmwf_macc import get_ecmwf_macc # noqa: F401
from pvlib.iotools.crn import read_crn # noqa: F401
from pvlib.iotools.solrad import read_solrad # noqa: F401
from pvlib.iotools.psm3 import get_psm3 # noqa: F401
174 changes: 174 additions & 0 deletions pvlib/iotools/psm3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@

"""
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
PSM3 API parameter specifing year or TMY variant to download, see notes
below for options, default is ``'tmy'``
interval : int
interval size in minutes, can only be either 30 or 60, default is 60
full_name : str
optional, default is "pvlib python"
affiliation : str
optional, default is "pvlib python"

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 return status is not ok then the ``'errors'`` from the
JSON response will be returned as an exception

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',
'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.

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/>`_

"""
longitude = ('%9.4f' % longitude).strip()
latitude = ('%8.4f' % latitude).strip()
params = {
'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')
raise requests.HTTPError(errors)
# 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
4 changes: 4 additions & 0 deletions pvlib/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ def pandas_0_22():
return parse_version(pd.__version__) >= parse_version('0.22.0')


needs_pandas_0_22 = pytest.mark.skipif(
not pandas_0_22(), reason='requires pandas 0.22 or greater')


def has_spa_c():
try:
from pvlib.spa_c_files.spa_py import spa_calc
Expand Down
57 changes: 57 additions & 0 deletions pvlib/test/test_psm3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
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 = '[email protected]'


@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):
psm3.get_psm3(LATITUDE, LONGITUDE, api_key='BAD', email=PVLIB_EMAIL)
with pytest.raises(HTTPError):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this one fail?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • api_key="BAD" fails because the response is HTTP/1.1 403 Forbidden and there's a long error message in the content, no JSON, that says something like, "please go to NREL developer network and register for a key"
  • Bristol-UK fails because PSM3 only works in the US, maybe South America, and I think some parts of India perhaps around Chennai? I can confirm - perhaps this should be a warning in the docstring?

perhaps I should add some more comments here to explain these tests - like I did here - for future devs?

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from get_psm3 import get_psm3

import api_keys

MY_KEY = api_keys.get_api_key('nsrdb')

for lat, lon in zip([25, 25, 25], [-125, -124.5, -124]):
    try:
        meta, data = get_psm3(lat, lon, names='tmy', interval=60,
                              api_key=MY_KEY)
    except Exception as e:
        print("{}, {}: ".format(lat, lon), e)

Returns

25, -125:  ["The 'wkt' parameter did not match any known data points."]
25, -124.5:  Expecting value: line 1 column 1 (char 0)
25, -124:  Expecting value: line 1 column 1 (char 0)

The first line is what I expect, the next 2 are json decoding errors that puzzle me.

Copy link
Member

@cwhanse cwhanse May 2, 2019

Choose a reason for hiding this comment

The 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 raise requests.HTTPError(response.json()['errors']) is throwing the exception about Expecting value:...

Confirmed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

["The 'wkt' parameter did not match any known data points."]

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.

psm3.get_psm3(51, -5, 'DEMO_KEY', PVLIB_EMAIL)