Skip to content

Commit 100d1f4

Browse files
committed
sam iotools pvlib#1371
adds a sam.py, and pytest
1 parent 73965c2 commit 100d1f4

File tree

3 files changed

+194
-0
lines changed

3 files changed

+194
-0
lines changed

pvlib/iotools/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@
2121
from pvlib.iotools.sodapro import get_cams # noqa: F401
2222
from pvlib.iotools.sodapro import read_cams # noqa: F401
2323
from pvlib.iotools.sodapro import parse_cams # noqa: F401
24+
from pvlib.iotools.sam import saveSAM_WeatherFile, tz_convert # noqa: F401

pvlib/iotools/sam.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Functions for reading and writing SAM data files."""
2+
3+
import pandas as pd
4+
5+
def saveSAM_WeatherFile(data, metadata, savefile='SAM_WeatherFile.csv', standardSAM = True, includeminute=False):
6+
"""
7+
Saves a dataframe with weather data from pvlib format on SAM-friendly format.
8+
9+
Parameters
10+
-----------
11+
data : pandas.DataFrame
12+
timeseries data in PVLib format. Should be TZ converted (not UTC). Ideally it is one sequential year data; if not suggested to use standardSAM = False.
13+
metdata : dictionary
14+
Dictionary with at least 'latitude', 'longitude', 'elevation', 'source', and 'TZ' for timezone.
15+
savefile : str
16+
Name of file to save output as.
17+
standardSAM : boolean
18+
This checks the dataframe to avoid having a leap day, then averages it to SAM style (closed to the right),
19+
and fills the years so it starst on YEAR/1/1 0:0 and ends on YEAR/12/31 23:00.
20+
includeminute ; Bool
21+
For hourly data, if SAM input does not have Minutes, it calculates the sun position 30 minutes
22+
prior to the hour (i.e. 12 timestamp means sun position at 11:30)
23+
If minutes are included, it will calculate the sun position at the time of the timestamp (12:00 at 12:00)
24+
Set to true if resolution of data is sub-hourly.
25+
26+
Returns
27+
-------
28+
Nothing, it just writes the file.
29+
30+
"""
31+
32+
def _is_leap_and_29Feb(s):
33+
''' Creates a mask to help remove Leap Years. Obtained from:
34+
https://stackoverflow.com/questions/34966422/remove-leap-year-day-from-pandas-dataframe/34966636
35+
'''
36+
return (s.index.year % 4 == 0) & \
37+
((s.index.year % 100 != 0) | (s.index.year % 400 == 0)) & \
38+
(s.index.month == 2) & (s.index.day == 29)
39+
40+
def _averageSAMStyle(df, interval='60T', closed='right', label='right'):
41+
''' Averages subhourly data into hourly data in SAM's expected format.
42+
'''
43+
try:
44+
df = df.resample(interval, closed=closed, label=label).mean() #
45+
except:
46+
print('Warning - unable to average')
47+
return df
48+
49+
def _fillYearSAMStyle(df, freq='60T'):
50+
''' Fills year
51+
'''
52+
# add zeros for the rest of the year
53+
if freq is None:
54+
try:
55+
freq = pd.infer_freq(df.index)
56+
except:
57+
freq = '60T' # 15 minute data by default
58+
# add a timepoint at the end of the year
59+
# idx = df.index
60+
# apply correct TZ info (if applicable)
61+
tzinfo = df.index.tzinfo
62+
starttime = pd.to_datetime('%s-%s-%s %s:%s' % (df.index.year[0],1,1,0,0 ) ).tz_localize(tzinfo)
63+
endtime = pd.to_datetime('%s-%s-%s %s:%s' % (df.index.year[-1],12,31,23,60-int(freq[:-1])) ).tz_localize(tzinfo)
64+
65+
df2 = _averageSAMStyle(df, freq)
66+
df2.iloc[0] = 0 # set first datapt to zero to forward fill w zeros
67+
df2.iloc[-1] = 0 # set last datapt to zero to forward fill w zeros
68+
df2.loc[starttime] = 0
69+
df2.loc[endtime] = 0
70+
df2 = df2.resample(freq).ffill()
71+
return df2
72+
73+
74+
# Modify this to cut into different years. Right now handles partial year and sub-hourly interval.
75+
if standardSAM:
76+
filterdatesLeapYear = ~(_is_leap_and_29Feb(data))
77+
data = data[filterdatesLeapYear]
78+
data = _fillYearSAMStyle(data)
79+
80+
81+
# metadata
82+
latitude=metadata['latitude']
83+
longitude=metadata['longitude']
84+
elevation=metadata['elevation']
85+
timezone_offset = metadata['TZ']
86+
source = metadata['source']
87+
88+
# make a header
89+
header = '\n'.join(
90+
[ 'Source,Latitude,Longitude,Time Zone,Elevation',
91+
source + ',' + str(latitude) + ',' + str(longitude) + ',' + str(timezone_offset) + ',' + str(elevation)]) + '\n'
92+
93+
savedata = pd.DataFrame({'Year':data.index.year, 'Month':data.index.month, 'Day':data.index.day,
94+
'Hour':data.index.hour})
95+
96+
if includeminute:
97+
savedata['Minute'] = data.index.minute
98+
99+
windspeed = list(data.wind_speed)
100+
temp_amb = list(data.temp_air)
101+
savedata['Wspd'] = windspeed
102+
savedata['Tdry'] = temp_amb
103+
104+
if 'dni' in data:
105+
dni = list(data.dni)
106+
savedata['DHI'] = dni
107+
108+
if 'dhi' in data:
109+
dhi = list(data.dhi)
110+
savedata['DNI'] = dhi
111+
112+
if 'ghi' in data:
113+
ghi = list(data.ghi)
114+
savedata['GHI'] = ghi
115+
116+
if 'poa' in data: # This is a nifty function of SAM for using field measured POA irradiance!
117+
poa = list(data.poa)
118+
savedata['POA'] = poa
119+
120+
if 'albedo' in data:
121+
albedo = list(data.albedo)
122+
savedata['Albedo'] = albedo
123+
124+
with open(savefile, 'w', newline='') as ict:
125+
# Write the header lines, including the index variable for
126+
# the last one if you're letting Pandas produce that for you.
127+
# (see above).
128+
for line in header:
129+
ict.write(line)
130+
131+
savedata.to_csv(ict, index=False)
132+
133+
134+
def tz_convert(df, tz_convert_val, metadata=None):
135+
"""
136+
Support function to convert metdata to a different local timezone. Particularly for
137+
GIS weather files which are returned in UTC by default.
138+
139+
Parameters
140+
----------
141+
df : DataFrame
142+
A dataframe in UTC timezone
143+
tz_convert_val : int
144+
Convert timezone to this fixed value, following ISO standard
145+
(negative values indicating West of UTC.)
146+
Returns: metdata, metadata
147+
148+
149+
Returns
150+
-------
151+
df : DataFrame
152+
Dataframe in the converted local timezone.
153+
metadata : dict
154+
Adds (or updates) the existing Timezone in the metadata dictionary
155+
156+
"""
157+
import pytz
158+
if (type(tz_convert_val) == int) | (type(tz_convert_val) == float):
159+
df = df.tz_convert(pytz.FixedOffset(tz_convert_val*60))
160+
161+
if metadata is not None:
162+
metadata['TZ'] = tz_convert_val
163+
return df, metadata
164+
return df

pvlib/tests/iotools/test_sam.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
test the SAM IO tools
3+
"""
4+
import pandas as pd
5+
from pvlib.iotools import get_pvgis_tmy, read_pvgis_hourly
6+
from pvlib.iotools import saveSAM_WeatherFile, tz_convert
7+
from ..conftest import (DATA_DIR, RERUNS, RERUNS_DELAY, assert_frame_equal,
8+
fail_on_pvlib_version)
9+
10+
# PVGIS Hourly tests
11+
# The test files are actual files from PVGIS where the data section have been
12+
# reduced to only a few lines
13+
testfile_radiation_csv = DATA_DIR / \
14+
'pvgis_hourly_Timeseries_45.000_8.000_SA_30deg_0deg_2016_2016.csv'
15+
16+
# REMOVE
17+
testfile_radiation_csv = r'C:\Users\sayala\Documents\GitHub\pvlib-python\pvlib\data\pvgis_hourly_Timeseries_45.000_8.000_SA_30deg_0deg_2016_2016.csv'
18+
19+
def test_saveSAM_WeatherFile():
20+
data, inputs, metadata = read_pvgis_hourly(testfile_radiation_csv, map_variables=True)#, pvgis_format=pvgis_format)
21+
metadata = {'latitude': inputs['latitude'],
22+
'longitude': inputs['longitude'],
23+
'elevation': inputs['elevation'],
24+
'source': 'User-generated'}
25+
metadata['TZ'] = -7
26+
data = tz_convert(data, tz_convert_val=metadata['TZ'])
27+
coerce_year=2021 # read_pvgis_hourly does not coerce_year, so doing it here.
28+
data.index = data.index.map(lambda dt: dt.replace(year=coerce_year))
29+
saveSAM_WeatherFile(data, metadata, savefile='test_SAMWeatherFile.csv', standardSAM=True)

0 commit comments

Comments
 (0)