Skip to content

Commit 4538bef

Browse files
mikofskiwholmgren
authored andcommitted
ENH: geometric calculations for sunrise, sunset, and transit (#583)
* add analytical methods from Frank Vignola's book for sunrise, sunset, and sun transit times * ENH: remove test data file for sunrise/sunset/transit * use hardcoded values for 10-20 expected values * remove DIRNAME, etc * make stickler happy * no rst markup in numpydoc parameters * add references to duffie/beckman and Frank Vignola * get pytz timezone info first so it's not weird * add returns to docstring * ENH: PERF: use numpy instead of loop to improve performance * add one-liners to explain helpers * remove import os from test * ENH: use ducktyping to handle different versions of pandas * if old pandas change tz directly, and hours is already a numpy array * if new pandas then use tz_localize to strip tz, and change hours from Float64Index to numpy array * move operator to end to match existing style in pvlib * ducktype hours if Float64index or not, convert datetime64[D] to ns before adding * ENH: use "geometric" instead of "analytical" * change name in solarposition.py and test_solarposition.py * enforce that hours is a numpy array in _hours instead of in _times * add more useful comments, separate into steps to explain process * ENH: use more verbose, meaningful names for helpers _hours and _times * change _hours to _hour_angle_to_hours * change _times to _local_times_from_hours_since_midnite * fix typos in warning circular orbit * fix reference numbers * clarify sign conventions for latitude and longitude * remove EXPECTED_ANALYTICAL_SUNRISE_SUNSET_TRANSIT * instead use a test pytest.fixture with a few dates like the ephem and spa.c test * stickler - missing whitespace after comma in list * use np.asarray to ensure consistent return * remove try-except, doesn't work anyway, wait for pandas >=0.15.0 * also remove utcoffset(), unreliable with pandas dataframes, only really works with scalar datetimes and timestamps * instead use tz_localise(None) to get naive, but still local time, and subtract the localized time, to get a sequence of timezones * reuse expected_rise_set_spa test fixture * remove geometric expected sunrise/sunset/transite test fixture * check abs tolerance between geometric and spa are less than max absolute error difference which is typically 4-6 minutes for sunrise/sunset, and about 10-40 seconds for transite * move _times_to_hours to solarposition, and use the same approach as other hour/time conversions, use pandas>= tz_localize(None) or normalize to get the local, naive times, and time at previous midnight, then convert to np.int64, and then convert to hours from nanoseconds * since fixture is a dataframe, get column, convert to MST timezone, before calling _times_to_hours(<pd.DatetimeIndex>) * get times from expected_rise_set_spa.index * get lat/lon from golden_mst test_fixture * stickler * ENH: update what's new and api.rst * wrap long lines, link to issues * add constant NS_PER_HR and substiture for (3600. * 1.e9) * change arg name to hourangle avoid shadowing function hour_angle() * fix spelling in _local_times_from_hours_since_midnight() * use normalize() and astype(np.int64) instead of naive_times.astype('datetime64[D]').astype('datetim64[ns]') and midnight_times + (hours*NS_PER_HR).astype('timedelta64[ns]') for stability and consistency with other hour/times conversion functions * stickler - line break before operator * group sunrise/sunset enhancements together, separate iotools * change private func name to be more descriptive * _times_to_hours -> _times_to_hours_after_local_midnight * change corresponding usage in test_solarposition.py and wrap some long lines * in docstring specify that times should be localized to the timzeone of the lat/lon args in hour_angle() and sunrise_sunset_transit_geometric() * change arg in solar_azimuth_analytical() and solar_zenith_analytical() from hour_angle -> hourangle to avoid shadowing func name hour_angle() from outer scope and remove redundant parentheses from return * TST: change name to test_sunrise_sunset_transit_geometric * change description to say "geometric" vs. analytical * improve description of _local_times_from_hours_since_midnight to be more specific by adding "hours since midnight"
1 parent 5ee82b1 commit 4538bef

File tree

4 files changed

+172
-22
lines changed

4 files changed

+172
-22
lines changed

docs/sphinx/source/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ calculations.
7979
solarposition.equation_of_time_spencer71
8080
solarposition.equation_of_time_pvcdrom
8181
solarposition.hour_angle
82+
solarposition.sunrise_sunset_transit_geometric
8283

8384

8485
Clear sky

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
v0.6.1 (EST October, 2018)
44
--------------------------
55

6-
This is a minor release. We recommend all users of 0.6.0 upgrade to this
6+
This is a minor release. We recommend all users of v0.6.0 upgrade to this
77
release.
88

99
**Python 2.7 support will end on June 1, 2019**. Releases made after this
@@ -16,27 +16,35 @@ API Changes
1616
* Deprecated ``tmy``, ``tmy.readtmy2`` and ``tmy.readtmy3``;
1717
they will be removed in v0.7. Use the new :py:func:`pvlib.iotools.read_tmy2`
1818
and :py:func:`pvlib.iotools.read_tmy3` instead. (:issue:`261`)
19-
* Added kwarg `horizon` to :func:`~pvlib.solarposition.pyephem` and :func:`~pvlib.solarposition.calc_time` with default value `'+0:00'`
19+
* Added keyword argument ``horizon`` to :func:`~pvlib.solarposition.pyephem`
20+
and :func:`~pvlib.solarposition.calc_time` with default value ``'+0:00'``
2021

2122

2223
Enhancements
2324
~~~~~~~~~~~~
24-
* :func:`~pvlib.solarposition.rise_set_transit_ephem` returns sunrise, sunset and transit times using pyephem
25-
* Created :py:func:`pvlib.iotools.read_srml` and :py:func:`pvlib.iotools.read_srml_month_from_solardat`
26-
to read University of Oregon Solar Radiation Monitoring Laboratory data. (:issue:`589`)
27-
25+
* :func:`~pvlib.solarposition.rise_set_transit_ephem` returns sunrise, sunset
26+
and transit times using pyephem (:issue:`114`)
27+
* Add geometric functions for sunrise, sunset, and sun transit times,
28+
:func:`~pvlib.solarposition.sunrise_sunset_transit_geometric` (:issue:`114`)
29+
* Created :py:func:`pvlib.iotools.read_srml` and
30+
:py:func:`pvlib.iotools.read_srml_month_from_solardat` to read University of
31+
Oregon Solar Radiation Monitoring Laboratory data. (:issue:`589`)
32+
2833

2934
Bug fixes
3035
~~~~~~~~~
3136
* Fix when building documentation using Matplotlib 3.0 or greater.
37+
* Fix and improve :func:`~pvlib.solarposition.hour_angle` (:issue:`598`)
3238

3339

3440
Testing
3541
~~~~~~~
42+
* Add test for :func:`~pvlib.solarposition.hour_angle` (:issue:`597`)
3643

3744

3845
Contributors
3946
~~~~~~~~~~~~
4047
* Will Holmgren (:ghuser:`wholmgren`)
4148
* Cliff Hansen (:ghuser:`cwhanse`)
4249
* Leland Boeman (:ghuser:`lboeman`)
50+
* Mark Mikofski (:ghuser:`mikofski`)

pvlib/solarposition.py

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from pvlib import atmosphere
2626
from pvlib.tools import datetime_to_djd, djd_to_datetime
2727

28+
NS_PER_HR = 1.e9 * 3600. # nanoseconds per hour
29+
2830

2931
def get_solarposition(time, latitude, longitude,
3032
altitude=None, pressure=None,
@@ -1175,7 +1177,7 @@ def declination_cooper69(dayofyear):
11751177
return dec
11761178

11771179

1178-
def solar_azimuth_analytical(latitude, hour_angle, declination, zenith):
1180+
def solar_azimuth_analytical(latitude, hourangle, declination, zenith):
11791181
"""
11801182
Analytical expression of solar azimuth angle based on spherical
11811183
trigonometry.
@@ -1184,7 +1186,7 @@ def solar_azimuth_analytical(latitude, hour_angle, declination, zenith):
11841186
----------
11851187
latitude : numeric
11861188
Latitude of location in radians.
1187-
hour_angle : numeric
1189+
hourangle : numeric
11881190
Hour angle in the local solar time in radians.
11891191
declination : numeric
11901192
Declination of the sun in radians.
@@ -1240,12 +1242,12 @@ def solar_azimuth_analytical(latitude, hour_angle, declination, zenith):
12401242

12411243
# when NaN values occur in input, ignore and pass to output
12421244
with np.errstate(invalid='ignore'):
1243-
sign_ha = np.sign(hour_angle)
1245+
sign_ha = np.sign(hourangle)
12441246

1245-
return (sign_ha * np.arccos(cos_azi) + np.pi)
1247+
return sign_ha * np.arccos(cos_azi) + np.pi
12461248

12471249

1248-
def solar_zenith_analytical(latitude, hour_angle, declination):
1250+
def solar_zenith_analytical(latitude, hourangle, declination):
12491251
"""
12501252
Analytical expression of solar zenith angle based on spherical
12511253
trigonometry.
@@ -1257,7 +1259,7 @@ def solar_zenith_analytical(latitude, hour_angle, declination):
12571259
----------
12581260
latitude : numeric
12591261
Latitude of location in radians.
1260-
hour_angle : numeric
1262+
hourangle : numeric
12611263
Hour angle in the local solar time in radians.
12621264
declination : numeric
12631265
Declination of the sun in radians.
@@ -1288,7 +1290,7 @@ def solar_zenith_analytical(latitude, hour_angle, declination):
12881290
declination_spencer71 declination_cooper69 hour_angle
12891291
"""
12901292
return np.arccos(
1291-
np.cos(declination) * np.cos(latitude) * np.cos(hour_angle) +
1293+
np.cos(declination) * np.cos(latitude) * np.cos(hourangle) +
12921294
np.sin(declination) * np.sin(latitude)
12931295
)
12941296

@@ -1300,7 +1302,8 @@ def hour_angle(times, longitude, equation_of_time):
13001302
Parameters
13011303
----------
13021304
times : :class:`pandas.DatetimeIndex`
1303-
Corresponding timestamps, must be timezone aware.
1305+
Corresponding timestamps, must be localized to the timezone for the
1306+
``longitude``.
13041307
longitude : numeric
13051308
Longitude in degrees
13061309
equation_of_time : numeric
@@ -1329,9 +1332,99 @@ def hour_angle(times, longitude, equation_of_time):
13291332
"""
13301333
naive_times = times.tz_localize(None) # naive but still localized
13311334
# hours - timezone = (times - normalized_times) - (naive_times - times)
1332-
hrs_minus_tzs = 1 / (3600. * 1.e9) * (
1335+
hrs_minus_tzs = 1 / NS_PER_HR * (
13331336
2 * times.astype(np.int64) - times.normalize().astype(np.int64) -
13341337
naive_times.astype(np.int64))
13351338
# ensure array return instead of a version-dependent pandas <T>Index
13361339
return np.asarray(
13371340
15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.)
1341+
1342+
1343+
def _hour_angle_to_hours(times, hourangle, longitude, equation_of_time):
1344+
"""converts hour angles in degrees to hours as a numpy array"""
1345+
naive_times = times.tz_localize(None) # naive but still localized
1346+
tzs = 1 / NS_PER_HR * (
1347+
naive_times.astype(np.int64) - times.astype(np.int64))
1348+
hours = (hourangle - longitude - equation_of_time / 4.) / 15. + 12. + tzs
1349+
return np.asarray(hours)
1350+
1351+
1352+
def _local_times_from_hours_since_midnight(times, hours):
1353+
"""
1354+
converts hours since midnight from an array of floats to localized times
1355+
"""
1356+
tz_info = times.tz # pytz timezone info
1357+
naive_times = times.tz_localize(None) # naive but still localized
1358+
# normalize local, naive times to previous midnight and add the hours until
1359+
# sunrise, sunset, and transit
1360+
return pd.DatetimeIndex(
1361+
(naive_times.normalize().astype(np.int64) +
1362+
(hours * NS_PER_HR).astype(np.int64)).astype('datetime64[ns]'),
1363+
tz=tz_info)
1364+
1365+
1366+
def _times_to_hours_after_local_midnight(times):
1367+
"""convert local pandas datetime indices to array of hours as floats"""
1368+
times = times.tz_localize(None)
1369+
hrs = 1 / NS_PER_HR * (
1370+
times.astype(np.int64) - times.normalize().astype(np.int64))
1371+
return np.array(hrs)
1372+
1373+
1374+
def sunrise_sunset_transit_geometric(times, latitude, longitude, declination,
1375+
equation_of_time):
1376+
"""
1377+
Geometric calculation of solar sunrise, sunset, and transit.
1378+
1379+
.. warning:: The geometric calculation assumes a circular earth orbit with
1380+
the sun as a point source at its center, and neglects the effect of
1381+
atmospheric refraction on zenith. The error depends on location and
1382+
time of year but is of order 10 minutes.
1383+
1384+
Parameters
1385+
----------
1386+
times : pandas.DatetimeIndex
1387+
Corresponding timestamps, must be localized to the timezone for the
1388+
``latitude`` and ``longitude``.
1389+
latitude : float
1390+
Latitude in degrees, positive north of equator, negative to south
1391+
longitude : float
1392+
Longitude in degrees, positive east of prime meridian, negative to west
1393+
declination : numeric
1394+
declination angle in radians at ``times``
1395+
equation_of_time : numeric
1396+
difference in time between solar time and mean solar time in minutes
1397+
1398+
Returns
1399+
-------
1400+
sunrise : datetime
1401+
localized sunrise time
1402+
sunset : datetime
1403+
localized sunset time
1404+
transit : datetime
1405+
localized sun transit time
1406+
1407+
References
1408+
----------
1409+
[1] J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal
1410+
Processes, 3rd Edition," J. Wiley and Sons, New York (2006)
1411+
1412+
[2] Frank Vignola et al., "Solar And Infrared Radiation Measurements,"
1413+
CRC Press (2012)
1414+
1415+
"""
1416+
latitude_rad = np.radians(latitude) # radians
1417+
sunset_angle_rad = np.arccos(-np.tan(declination) * np.tan(latitude_rad))
1418+
sunset_angle = np.degrees(sunset_angle_rad) # degrees
1419+
# solar noon is at hour angle zero
1420+
# so sunrise is just negative of sunset
1421+
sunrise_angle = -sunset_angle
1422+
sunrise_hour = _hour_angle_to_hours(
1423+
times, sunrise_angle, longitude, equation_of_time)
1424+
sunset_hour = _hour_angle_to_hours(
1425+
times, sunset_angle, longitude, equation_of_time)
1426+
transit_hour = _hour_angle_to_hours(times, 0, longitude, equation_of_time)
1427+
sunrise = _local_times_from_hours_since_midnight(times, sunrise_hour)
1428+
sunset = _local_times_from_hours_since_midnight(times, sunset_hour)
1429+
transit = _local_times_from_hours_since_midnight(times, transit_hour)
1430+
return sunrise, sunset, transit

pvlib/test/test_solarposition.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -529,11 +529,13 @@ def test_get_solarposition_altitude(altitude, expected, golden):
529529

530530
@pytest.mark.parametrize(
531531
"delta_t, method, expected", [
532-
(None, 'nrel_numpy', expected_solpos_multi()),
533-
(67.0, 'nrel_numpy', expected_solpos_multi()),
534-
pytest.mark.xfail(raises=ValueError, reason = 'spa.calculate_deltat not implemented for numba yet')
535-
((None, 'nrel_numba', expected_solpos_multi())),
536-
(67.0, 'nrel_numba', expected_solpos_multi())
532+
(None, 'nrel_numpy', expected_solpos_multi()),
533+
(67.0, 'nrel_numpy', expected_solpos_multi()),
534+
pytest.mark.xfail(
535+
raises=ValueError,
536+
reason='spa.calculate_deltat not implemented for numba yet')
537+
((None, 'nrel_numba', expected_solpos_multi())),
538+
(67.0, 'nrel_numba', expected_solpos_multi())
537539
])
538540
def test_get_solarposition_deltat(delta_t, method, expected, golden):
539541
times = pd.date_range(datetime.datetime(2003,10,17,13,30,30),
@@ -690,8 +692,9 @@ def test_analytical_azimuth():
690692
[ -30., -180., 5.],
691693
[ -30., 180., 10.]]))
692694

693-
zeniths = solarposition.solar_zenith_analytical(*test_angles.T)
694-
azimuths = solarposition.solar_azimuth_analytical(*test_angles.T, zenith=zeniths)
695+
zeniths = solarposition.solar_zenith_analytical(*test_angles.T)
696+
azimuths = solarposition.solar_azimuth_analytical(*test_angles.T,
697+
zenith=zeniths)
695698

696699
assert not np.isnan(azimuths).any()
697700

@@ -718,3 +721,48 @@ def test_hour_angle():
718721
# sunrise: 4 seconds, sunset: 48 seconds, transit: 0.2 seconds
719722
# but the differences may be due to other SPA input parameters
720723
assert np.allclose(hours, expected)
724+
725+
726+
def test_sunrise_sunset_transit_geometric(expected_rise_set_spa, golden_mst):
727+
"""Test geometric calculations for sunrise, sunset, and transit times"""
728+
times = expected_rise_set_spa.index
729+
latitude = golden_mst.latitude
730+
longitude = golden_mst.longitude
731+
eot = solarposition.equation_of_time_spencer71(times.dayofyear) # minutes
732+
decl = solarposition.declination_spencer71(times.dayofyear) # radians
733+
sr, ss, st = solarposition.sunrise_sunset_transit_geometric(
734+
times, latitude=latitude, longitude=longitude, declination=decl,
735+
equation_of_time=eot)
736+
# sunrise: 2015-01-02 07:26:39.763224487, 2015-08-02 05:04:35.688533801
737+
# sunset: 2015-01-02 16:41:29.951096777, 2015-08-02 19:09:46.597355085
738+
# transit: 2015-01-02 12:04:04.857160632, 2015-08-02 12:07:11.142944443
739+
test_sunrise = solarposition._times_to_hours_after_local_midnight(sr)
740+
test_sunset = solarposition._times_to_hours_after_local_midnight(ss)
741+
test_transit = solarposition._times_to_hours_after_local_midnight(st)
742+
# convert expected SPA sunrise, sunset, transit to local datetime indices
743+
expected_sunrise = pd.DatetimeIndex(expected_rise_set_spa.sunrise.values,
744+
tz='UTC').tz_convert(golden_mst.tz)
745+
expected_sunset = pd.DatetimeIndex(expected_rise_set_spa.sunset.values,
746+
tz='UTC').tz_convert(golden_mst.tz)
747+
expected_transit = pd.DatetimeIndex(expected_rise_set_spa.transit.values,
748+
tz='UTC').tz_convert(golden_mst.tz)
749+
# convert expected times to hours since midnight as arrays of floats
750+
expected_sunrise = solarposition._times_to_hours_after_local_midnight(
751+
expected_sunrise)
752+
expected_sunset = solarposition._times_to_hours_after_local_midnight(
753+
expected_sunset)
754+
expected_transit = solarposition._times_to_hours_after_local_midnight(
755+
expected_transit)
756+
# geometric time has about 4-6 minute error compared to SPA sunset/sunrise
757+
expected_sunrise_error = np.array(
758+
[0.07910089555555544, 0.06908014805555496]) # 4.8[min], 4.2[min]
759+
expected_sunset_error = np.array(
760+
[-0.1036246955555562, -0.06983406805555603]) # -6.2[min], -4.2[min]
761+
expected_transit_error = np.array(
762+
[-0.011150788888889096, 0.0036508177777765383]) # -40[sec], 13.3[sec]
763+
assert np.allclose(test_sunrise, expected_sunrise,
764+
atol=np.abs(expected_sunrise_error).max())
765+
assert np.allclose(test_sunset, expected_sunset,
766+
atol=np.abs(expected_sunset_error).max())
767+
assert np.allclose(test_transit, expected_transit,
768+
atol=np.abs(expected_transit_error).max())

0 commit comments

Comments
 (0)