Skip to content

Commit d45790a

Browse files
yhkee0404cwhansekandersolar
authored
Perform dayofyear-based calculations according to UTC, not local time (#2055)
* Add clearsky.ipynb (Remove before merge) * Fix clearsky.lookup_linke_turbidity to be timezone-aware * Fix irradiance.get_extra_radiation to be timezone-aware * Rerun and clear errors in clearsky.ipynb * Remove clearsky.ipynb * Update what's new * Add docstring Notes to lookup_linke_turbidity * Add tools._pandas_to_utc * Update what's new Co-authored-by: Cliff Hansen <[email protected]> * Add docstring Notes to _pandas_to_doy * Revert _datetimelike_scalar_to_datetimeindex * Revert tests.test_irradiance.times * Replace lowercase utc with uppercase * Fix clearsky lookup_linke_turbidity and _interpolate_turbidity * Fix linter * Fix clearsky._interpolate_turbidity to keep timezone * Revert irradiance.py * Clarify UTC input of ephem.Date * Fix tz-naive delta_t of dni_extra nrel * Fix tz-naive delta_t of get_solarposition nrel_numpy * Avoid future midnight of location.get_sun_rise_set_transit spa * Fix tz-naive delta_t of location.get_sun_rise_set_transit spa * Fix local midnight of hour_angle and sun_rise_set_transit * Fix dayofyear of solarposition decl and eot, i.e. clearsky.bird * Fix dayofyear of spectrum.spectrl2 dni_extra spencer * Fix monthly SeasonalTiltMount orientation * Fix year and dayofyear of solrad * Fix flake8 * resolve whatsnew * don't change v0.11.0 * minor adjustment to test_clearsky * undo edit to get_solrad * undo edit to get_solrad * Update pvlib/clearsky.py * Update docs/sphinx/source/whatsnew/v0.11.1.rst * Update docs/sphinx/source/whatsnew/v0.11.1.rst * Update docs/examples/irradiance-transposition/plot_seasonal_tilt.py Co-authored-by: Kevin Anderson <[email protected]> * Revert benchmarks/benchmarks/solarposition.py * Revert test_lookup_linke_turbidity_nointerp * Revert solrad.py * Revert plot_seasonal_tilt.py except notes * Update test_sun_rise_set_transit_spa with delta_t * Update test_nrel_earthsun_distance with delta_t * Update test_sun_rise_set_transit_geometric with naive tz * Update test_hour_angle with inversion and naive tz * Remove _is_leap_year from _interpolate_turbidity * Update docs/sphinx/source/whatsnew/v0.11.1.rst --------- Co-authored-by: Cliff Hansen <[email protected]> Co-authored-by: Kevin Anderson <[email protected]>
1 parent 339e6aa commit d45790a

File tree

10 files changed

+199
-107
lines changed

10 files changed

+199
-107
lines changed

Diff for: docs/examples/irradiance-transposition/plot_seasonal_tilt.py

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class SeasonalTiltMount(pvsystem.AbstractMount):
3232
surface_azimuth: float = 180.0
3333

3434
def get_orientation(self, solar_zenith, solar_azimuth):
35+
# note: determining tilt based on month may produce different
36+
# results depending on the time zone of the timestamps
3537
tilts = [self.monthly_tilts[m-1] for m in solar_zenith.index.month]
3638
return pd.DataFrame({
3739
'surface_tilt': tilts,

Diff for: docs/sphinx/source/whatsnew/v0.11.1.rst

+14-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,19 @@ Enhancements
3939

4040
Bug fixes
4141
~~~~~~~~~
42-
42+
* To prevent simulation output from differing slightly based on the time zone
43+
of the time stamps, models that use day of year for sun position and
44+
irradiance calculations now determine the day of year according to the UTC
45+
equivalent of the specified time stamps. The following functions are affected:
46+
:py:func:`~pvlib.clearsky.lookup_linke_turbidity`,
47+
:py:func:`~pvlib.irradiance.get_extra_radiation`,
48+
:py:func:`~pvlib.irradiance.disc`,
49+
:py:func:`~pvlib.irradiance.dirint`,
50+
:py:func:`~pvlib.spectrum.spectrl2`. (:issue:`2054`, :pull:`2055`)
51+
* :py:func:`~pvlib.solarposition.hour_angle` and
52+
:py:func:`~pvlib.solarposition.sun_rise_set_transit_geometric` now raise
53+
``ValueError`` when given timezone-naive inputs, instead of assuming UTC.
54+
(:pull:`2055`)
4355

4456
Testing
4557
~~~~~~~
@@ -70,6 +82,7 @@ Requirements
7082
Contributors
7183
~~~~~~~~~~~~
7284
* Echedey Luis (:ghuser:`echedey-ls`)
85+
* Yunho Kee (:ghuser:`yhkee0404`)
7386
* Chris Deline (:ghuser:`cdeline`)
7487
* Ioannis Sifnaios (:ghuser:`IoannisSifnaios`)
7588
* Leonardo Micheli (:ghuser:`lmicheli`)

Diff for: docs/tutorials/irradiance.ipynb

+13-11
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"This tutorial requires pvlib >= 0.6.0.\n",
1717
"\n",
1818
"Authors:\n",
19-
"* Will Holmgren (@wholmgren), University of Arizona. July 2014, April 2015, July 2015, March 2016, July 2016, February 2017, August 2018."
19+
"* Will Holmgren (@wholmgren), University of Arizona. July 2014, April 2015, July 2015, March 2016, July 2016, February 2017, August 2018.\n",
20+
"* Yunho Kee (@yhkee0404), Haezoom. Jun 2024."
2021
]
2122
},
2223
{
@@ -393,6 +394,7 @@
393394
"source": [
394395
"tus = pvlib.location.Location(32.2, -111, 'US/Arizona', 700, 'Tucson')\n",
395396
"times = pd.date_range(start='2016-01-01', end='2016-01-02', freq='1min', tz=tus.tz)\n",
397+
"times_utc = times.tz_convert('UTC')\n",
396398
"ephem_data = tus.get_solarposition(times)\n",
397399
"irrad_data = tus.get_clearsky(times)\n",
398400
"irrad_data.plot()\n",
@@ -810,7 +812,7 @@
810812
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
811813
"klucher_diffuse.plot(label='klucher diffuse')\n",
812814
"\n",
813-
"dni_et = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
815+
"dni_et = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
814816
"reindl_diffuse = pvlib.irradiance.reindl(surf_tilt, surf_az, \n",
815817
" irrad_data['dhi'], irrad_data['dni'], irrad_data['ghi'], dni_et,\n",
816818
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
@@ -858,7 +860,7 @@
858860
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
859861
"klucher_diffuse.plot(label='klucher diffuse')\n",
860862
"\n",
861-
"dni_et = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
863+
"dni_et = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
862864
"reindl_diffuse = pvlib.irradiance.reindl(surf_tilt, surf_az, \n",
863865
" irrad_data['dhi'], irrad_data['dni'], irrad_data['ghi'], dni_et,\n",
864866
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
@@ -913,7 +915,7 @@
913915
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
914916
"klucher_diffuse.plot(label='klucher diffuse')\n",
915917
"\n",
916-
"dni_et = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
918+
"dni_et = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
917919
"\n",
918920
"haydavies_diffuse = pvlib.irradiance.haydavies(surf_tilt, surf_az, \n",
919921
" irrad_data['dhi'], irrad_data['dni'], dni_et,\n",
@@ -967,7 +969,7 @@
967969
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
968970
"klucher_diffuse.plot(label='klucher diffuse')\n",
969971
"\n",
970-
"dni_et = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
972+
"dni_et = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
971973
"\n",
972974
"haydavies_diffuse = pvlib.irradiance.haydavies(surf_tilt, surf_az, \n",
973975
" irrad_data['dhi'], irrad_data['dni'], dni_et,\n",
@@ -1028,7 +1030,7 @@
10281030
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
10291031
"klucher_diffuse.plot(label='klucher diffuse')\n",
10301032
"\n",
1031-
"dni_et = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
1033+
"dni_et = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
10321034
"\n",
10331035
"haydavies_diffuse = pvlib.irradiance.haydavies(surf_tilt, surf_az, \n",
10341036
" irrad_data['dhi'], irrad_data['dni'], dni_et,\n",
@@ -1067,7 +1069,7 @@
10671069
"sun_az = ephem_data['azimuth']\n",
10681070
"DNI = irrad_data['dni']\n",
10691071
"DHI = irrad_data['dhi']\n",
1070-
"DNI_ET = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
1072+
"DNI_ET = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
10711073
"AM = pvlib.atmosphere.get_relative_airmass(sun_zen)\n",
10721074
"\n",
10731075
"surf_tilt = 32\n",
@@ -1316,7 +1318,7 @@
13161318
"sun_az = ephem_data['azimuth']\n",
13171319
"DNI = irrad_data['dni']\n",
13181320
"DHI = irrad_data['dhi']\n",
1319-
"DNI_ET = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
1321+
"DNI_ET = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
13201322
"AM = pvlib.atmosphere.get_relative_airmass(sun_zen)\n",
13211323
"\n",
13221324
"surf_tilt = 32\n",
@@ -1330,7 +1332,7 @@
13301332
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
13311333
"klucher_diffuse.plot(label='klucher diffuse')\n",
13321334
"\n",
1333-
"dni_et = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
1335+
"dni_et = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
13341336
"\n",
13351337
"haydavies_diffuse = pvlib.irradiance.haydavies(surf_tilt, surf_az, \n",
13361338
" irrad_data['dhi'], irrad_data['dni'], dni_et,\n",
@@ -1371,7 +1373,7 @@
13711373
"sun_az = ephem_data['azimuth']\n",
13721374
"DNI = irrad_data['dni']\n",
13731375
"DHI = irrad_data['dhi']\n",
1374-
"DNI_ET = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
1376+
"DNI_ET = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
13751377
"AM = pvlib.atmosphere.get_relative_airmass(sun_zen)\n",
13761378
"\n",
13771379
"surf_tilt = 32\n",
@@ -1385,7 +1387,7 @@
13851387
" ephem_data['apparent_zenith'], ephem_data['azimuth'])\n",
13861388
"klucher_diffuse.plot(label='klucher diffuse')\n",
13871389
"\n",
1388-
"dni_et = pvlib.irradiance.get_extra_radiation(times.dayofyear)\n",
1390+
"dni_et = pvlib.irradiance.get_extra_radiation(times_utc.dayofyear)\n",
13891391
"\n",
13901392
"haydavies_diffuse = pvlib.irradiance.haydavies(surf_tilt, surf_az, \n",
13911393
" irrad_data['dhi'], irrad_data['dni'], dni_et,\n",

Diff for: pvlib/clearsky.py

+12-8
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ def lookup_linke_turbidity(time, latitude, longitude, filepath=None,
168168
Returns
169169
-------
170170
turbidity : Series
171+
172+
Notes
173+
-----
174+
Linke turbidity is obtained from a file of historical monthly averages.
175+
The returned value for each time is either the monthly value or an
176+
interpolated value to smooth the transition between months.
177+
Interpolation is done on the day of year as determined by UTC.
171178
"""
172179

173180
# The .h5 file 'LinkeTurbidities.h5' contains a single 2160 x 4320 x 12
@@ -200,7 +207,7 @@ def lookup_linke_turbidity(time, latitude, longitude, filepath=None,
200207
if interp_turbidity:
201208
linke_turbidity = _interpolate_turbidity(lts, time)
202209
else:
203-
months = time.month - 1
210+
months = tools._pandas_to_utc(time).month - 1
204211
linke_turbidity = pd.Series(lts[months], index=time)
205212

206213
linke_turbidity /= 20.
@@ -246,14 +253,11 @@ def _interpolate_turbidity(lts, time):
246253
# Jan 1 - Jan 15 and Dec 16 - Dec 31.
247254
lts_concat = np.concatenate([[lts[-1]], lts, [lts[0]]])
248255

249-
# handle leap years
250-
try:
251-
isleap = time.is_leap_year
252-
except AttributeError:
253-
year = time.year
254-
isleap = _is_leap_year(year)
256+
time_utc = tools._pandas_to_utc(time)
257+
258+
isleap = time_utc.is_leap_year
255259

256-
dayofyear = time.dayofyear
260+
dayofyear = time_utc.dayofyear
257261
days_leap = _calendar_month_middles(2016)
258262
days_no_leap = _calendar_month_middles(2015)
259263

Diff for: pvlib/solarposition.py

+51-39
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import scipy.optimize as so
2424
import warnings
2525

26-
from pvlib import atmosphere
26+
from pvlib import atmosphere, tools
2727
from pvlib.tools import datetime_to_djd, djd_to_datetime
2828

2929

@@ -199,11 +199,7 @@ def spa_c(time, latitude, longitude, pressure=101325, altitude=0,
199199
raise ImportError('Could not import built-in SPA calculator. ' +
200200
'You may need to recompile the SPA code.')
201201

202-
# if localized, convert to UTC. otherwise, assume UTC.
203-
try:
204-
time_utc = time.tz_convert('UTC')
205-
except TypeError:
206-
time_utc = time
202+
time_utc = tools._pandas_to_utc(time)
207203

208204
spa_out = []
209205

@@ -378,7 +374,9 @@ def spa_python(time, latitude, longitude,
378374

379375
spa = _spa_python_import(how)
380376

381-
delta_t = delta_t or spa.calculate_deltat(time.year, time.month)
377+
if not delta_t:
378+
time_utc = tools._pandas_to_utc(time)
379+
delta_t = spa.calculate_deltat(time_utc.year, time_utc.month)
382380

383381
app_zenith, zenith, app_elevation, elevation, azimuth, eot = \
384382
spa.solar_position(unixtime, lat, lon, elev, pressure, temperature,
@@ -452,12 +450,13 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy',
452450
raise ValueError('times must be localized')
453451

454452
# must convert to midnight UTC on day of interest
455-
utcday = pd.DatetimeIndex(times.date).tz_localize('UTC')
456-
unixtime = _datetime_to_unixtime(utcday)
453+
times_utc = times.tz_convert('UTC')
454+
unixtime = _datetime_to_unixtime(times_utc.normalize())
457455

458456
spa = _spa_python_import(how)
459457

460-
delta_t = delta_t or spa.calculate_deltat(times.year, times.month)
458+
if not delta_t:
459+
delta_t = spa.calculate_deltat(times_utc.year, times_utc.month)
461460

462461
transit, sunrise, sunset = spa.transit_sunrise_sunset(
463462
unixtime, lat, lon, delta_t, numthreads)
@@ -581,12 +580,11 @@ def sun_rise_set_transit_ephem(times, latitude, longitude,
581580
sunrise = []
582581
sunset = []
583582
trans = []
584-
for thetime in times:
585-
thetime = thetime.to_pydatetime()
583+
for thetime in tools._pandas_to_utc(times):
586584
# older versions of pyephem ignore timezone when converting to its
587585
# internal datetime format, so convert to UTC here to support
588586
# all versions. GH #1449
589-
obs.date = ephem.Date(thetime.astimezone(dt.timezone.utc))
587+
obs.date = ephem.Date(thetime)
590588
sunrise.append(_ephem_to_timezone(rising(sun), tzinfo))
591589
sunset.append(_ephem_to_timezone(setting(sun), tzinfo))
592590
trans.append(_ephem_to_timezone(transit(sun), tzinfo))
@@ -644,11 +642,7 @@ def pyephem(time, latitude, longitude, altitude=0, pressure=101325,
644642
except ImportError:
645643
raise ImportError('PyEphem must be installed')
646644

647-
# if localized, convert to UTC. otherwise, assume UTC.
648-
try:
649-
time_utc = time.tz_convert('UTC')
650-
except TypeError:
651-
time_utc = time
645+
time_utc = tools._pandas_to_utc(time)
652646

653647
sun_coords = pd.DataFrame(index=time)
654648

@@ -765,11 +759,7 @@ def ephemeris(time, latitude, longitude, pressure=101325, temperature=12):
765759
# the SPA algorithm needs time to be expressed in terms of
766760
# decimal UTC hours of the day of the year.
767761

768-
# if localized, convert to UTC. otherwise, assume UTC.
769-
try:
770-
time_utc = time.tz_convert('UTC')
771-
except TypeError:
772-
time_utc = time
762+
time_utc = tools._pandas_to_utc(time)
773763

774764
# strip out the day of the year and calculate the decimal hour
775765
DayOfYear = time_utc.dayofyear
@@ -956,7 +946,10 @@ def pyephem_earthsun_distance(time):
956946

957947
sun = ephem.Sun()
958948
earthsun = []
959-
for thetime in time:
949+
for thetime in tools._pandas_to_utc(time):
950+
# older versions of pyephem ignore timezone when converting to its
951+
# internal datetime format, so convert to UTC here to support
952+
# all versions. GH #1449
960953
sun.compute(ephem.Date(thetime))
961954
earthsun.append(sun.earth_distance)
962955

@@ -1013,7 +1006,9 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4):
10131006

10141007
spa = _spa_python_import(how)
10151008

1016-
delta_t = delta_t or spa.calculate_deltat(time.year, time.month)
1009+
if not delta_t:
1010+
time_utc = tools._pandas_to_utc(time)
1011+
delta_t = spa.calculate_deltat(time_utc.year, time_utc.month)
10171012

10181013
dist = spa.earthsun_distance(unixtime, delta_t, numthreads)
10191014

@@ -1386,22 +1381,26 @@ def hour_angle(times, longitude, equation_of_time):
13861381
equation_of_time_spencer71
13871382
equation_of_time_pvcdrom
13881383
"""
1384+
1385+
# times must be localized
1386+
if not times.tz:
1387+
raise ValueError('times must be localized')
1388+
13891389
# hours - timezone = (times - normalized_times) - (naive_times - times)
1390-
if times.tz is None:
1391-
times = times.tz_localize('utc')
13921390
tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600
13931391

1394-
hrs_minus_tzs = (times - times.normalize()) / pd.Timedelta('1h') - tzs
1392+
hrs_minus_tzs = _times_to_hours_after_local_midnight(times) - tzs
13951393

1396-
# ensure array return instead of a version-dependent pandas <T>Index
1397-
return np.asarray(
1398-
15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.)
1394+
return 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.
13991395

14001396

14011397
def _hour_angle_to_hours(times, hourangle, longitude, equation_of_time):
14021398
"""converts hour angles in degrees to hours as a numpy array"""
1403-
if times.tz is None:
1404-
times = times.tz_localize('utc')
1399+
1400+
# times must be localized
1401+
if not times.tz:
1402+
raise ValueError('times must be localized')
1403+
14051404
tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600
14061405
hours = (hourangle - longitude - equation_of_time / 4.) / 15. + 12. + tzs
14071406
return np.asarray(hours)
@@ -1411,18 +1410,26 @@ def _local_times_from_hours_since_midnight(times, hours):
14111410
"""
14121411
converts hours since midnight from an array of floats to localized times
14131412
"""
1414-
tz_info = times.tz # pytz timezone info
1415-
naive_times = times.tz_localize(None) # naive but still localized
1416-
# normalize local, naive times to previous midnight and add the hours until
1413+
1414+
# times must be localized
1415+
if not times.tz:
1416+
raise ValueError('times must be localized')
1417+
1418+
# normalize local times to previous local midnight and add the hours until
14171419
# sunrise, sunset, and transit
1418-
return pd.DatetimeIndex(
1419-
naive_times.normalize() + pd.to_timedelta(hours, unit='h'), tz=tz_info)
1420+
return times.normalize() + pd.to_timedelta(hours, unit='h')
14201421

14211422

14221423
def _times_to_hours_after_local_midnight(times):
14231424
"""convert local pandas datetime indices to array of hours as floats"""
1424-
times = times.tz_localize(None)
1425+
1426+
# times must be localized
1427+
if not times.tz:
1428+
raise ValueError('times must be localized')
1429+
14251430
hrs = (times - times.normalize()) / pd.Timedelta('1h')
1431+
1432+
# ensure array return instead of a version-dependent pandas <T>Index
14261433
return np.array(hrs)
14271434

14281435

@@ -1468,6 +1475,11 @@ def sun_rise_set_transit_geometric(times, latitude, longitude, declination,
14681475
CRC Press (2012)
14691476
14701477
"""
1478+
1479+
# times must be localized
1480+
if not times.tz:
1481+
raise ValueError('times must be localized')
1482+
14711483
latitude_rad = np.radians(latitude) # radians
14721484
sunset_angle_rad = np.arccos(-np.tan(declination) * np.tan(latitude_rad))
14731485
sunset_angle = np.degrees(sunset_angle_rad) # degrees

Diff for: pvlib/spectrum/spectrl2.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ def spectrl2(apparent_zenith, aoi, surface_tilt, ground_albedo,
288288
aerosol_turbidity_500nm, scattering_albedo_400nm, alpha,
289289
wavelength_variation_factor, aerosol_asymmetry_factor]))
290290

291-
dayofyear = original_index.dayofyear.values
291+
dayofyear = pvlib.tools._pandas_to_doy(original_index).values
292292

293293
if not is_pandas and dayofyear is None:
294294
raise ValueError('dayofyear must be specified if not using pandas '

0 commit comments

Comments
 (0)