Skip to content

ENH: geometric calculations for sunrise, sunset, and transit #583

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 19 commits into from
Oct 9, 2018
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3b0e597
add analytical methods from Frank Vignola's book for sunrise, sunset,…
mikofski Sep 19, 2018
af206f2
ENH: remove test data file for sunrise/sunset/transit
mikofski Sep 23, 2018
e6319fe
ENH: PERF: use numpy instead of loop to improve performance
mikofski Sep 24, 2018
59323d3
ENH: use ducktyping to handle different versions of pandas
mikofski Sep 24, 2018
44aed34
ducktype hours if Float64index or not, convert datetime64[D] to ns be…
mikofski Sep 24, 2018
f1e8970
ENH: use "geometric" instead of "analytical"
mikofski Sep 24, 2018
7fa6953
ENH: use more verbose, meaningful names for helpers _hours and _times
mikofski Sep 25, 2018
199a5b5
remove EXPECTED_ANALYTICAL_SUNRISE_SUNSET_TRANSIT
mikofski Sep 30, 2018
a8661f5
stickler - missing whitespace after comma in list
mikofski Sep 30, 2018
5d4012c
use np.asarray to ensure consistent return
mikofski Oct 5, 2018
8b257de
remove try-except, doesn't work anyway, wait for pandas >=0.15.0
mikofski Oct 6, 2018
c15f19b
Merge branch 'master' into analytical_sunrise_sunset_transit
mikofski Oct 6, 2018
a74f944
reuse expected_rise_set_spa test fixture
mikofski Oct 7, 2018
2ace87e
stickler
mikofski Oct 7, 2018
344f679
ENH: update what's new and api.rst
mikofski Oct 8, 2018
bf492cc
stickler - line break before operator
mikofski Oct 9, 2018
887fe29
group sunrise/sunset enhancements together, separate iotools
mikofski Oct 9, 2018
7c90b9c
change private func name to be more descriptive
mikofski Oct 9, 2018
3077923
TST: change name to test_sunrise_sunset_transit_geometric
mikofski Oct 9, 2018
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
86 changes: 86 additions & 0 deletions pvlib/solarposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -1335,3 +1335,89 @@ def hour_angle(times, longitude, equation_of_time):
# ensure array return instead of a version-dependent pandas <T>Index
return np.asarray(
15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.)


def _hour_angle_to_hours(times, hour_angle, longitude, equation_of_time):
"""converts hour angles in degrees to hours as a numpy array"""
naive_times = times.tz_localize(None) # naive but still localized
tzs = 1 / (3600. * 1.e9) * (
naive_times.astype(np.int64) - times.astype(np.int64))
Copy link

Choose a reason for hiding this comment

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

E126 continuation line over-indented for hanging indent

hours = (hour_angle - longitude - equation_of_time / 4.) / 15. + 12. + tzs
return np.asarray(hours)


def _local_times_from_hours_since_midnite(times, hours):
"""converts hours from an array of floats to localized times"""
Copy link
Member

Choose a reason for hiding this comment

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

This implies (I think) that hours is hours since local (?) midnight? I think the function description should define what is expected for times and hours. times only serves to communicate a date. hours appears to be hours since midnight.

tz_info = times.tz # pytz timezone info
times = times.tz_localize(None) # naive but still localized
# convert times to previous naive, local midnight
times = times.values.astype('datetime64[D]').astype('datetime64[ns]')
# add the hours until sunrise/sunset/transit
return pd.DatetimeIndex(
times + (hours * 3600. * 1.e9).astype('timedelta64[ns]'), tz=tz_info)


def _times_to_hours(times):
Copy link
Member

Choose a reason for hiding this comment

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

I think the function name should be more specific, perhaps _hours_after_local_midnight

The function description should also be more specific.

"""convert local pandas datetime indices to array of hours as floats"""
times = times.tz_localize(None)
hrs = 1 / (3600. * 1.e9) * (
times.astype(np.int64) - times.normalize().astype(np.int64))
return np.array(hrs)


def sunrise_sunset_transit_geometric(times, latitude, longitude, declination,
equation_of_time):
"""
Geometric calculation of solar sunrise, sunset, and transit.

.. warning:: The geometric calculation assumes a circular earth orbit with
the sun as a point source at its center, and neglects the effect of
atmospheric refraction on zenith. The error depends on location and
time of year but is of order 10 minutes.

Parameters
----------
times : pandas.DatetimeIndex
Corresponding timestamps, must be timezone aware.
Copy link
Member

Choose a reason for hiding this comment

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

The description of times needs to specify localized to the timezone for latitude and longitude, it would help to state that sunrise, sunset and transit are calculated for the date part of times.

latitude : float
Latitude in degrees, positive north of equator, negative to south
longitude : float
Longitude in degrees, positive east of prime meridian, negative to west
declination : numeric
declination angle in radians at ``times``
equation_of_time : numeric
difference in time between solar time and mean solar time in minutes

Returns
-------
sunrise : datetime
localized sunrise time
sunset : datetime
localized sunset time
transit : datetime
localized sun transit time

References
----------
[1] J. A. Duffie and W. A. Beckman, "Solar Engineering of Thermal
Processes, 3rd Edition," J. Wiley and Sons, New York (2006)

[2] Frank Vignola et al., "Solar And Infrared Radiation Measurements,"
CRC Press (2012)

"""
latitude_rad = np.radians(latitude) # radians
sunset_angle_rad = np.arccos(-np.tan(declination) * np.tan(latitude_rad))
sunset_angle = np.degrees(sunset_angle_rad) # degrees
# solar noon is at hour angle zero
# so sunrise is just negative of sunset
sunrise_angle = -sunset_angle
sunrise_hour = _hour_angle_to_hours(
times, sunrise_angle, longitude, equation_of_time)
sunset_hour = _hour_angle_to_hours(
times, sunset_angle, longitude, equation_of_time)
transit_hour = _hour_angle_to_hours(times, 0, longitude, equation_of_time)
sunrise = _local_times_from_hours_since_midnite(times, sunrise_hour)
sunset = _local_times_from_hours_since_midnite(times, sunset_hour)
transit = _local_times_from_hours_since_midnite(times, transit_hour)
return sunrise, sunset, transit
51 changes: 48 additions & 3 deletions pvlib/test/test_solarposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,9 @@ def test_get_solarposition_altitude(altitude, expected, golden):
"delta_t, method, expected", [
(None, 'nrel_numpy', expected_solpos_multi()),
(67.0, 'nrel_numpy', expected_solpos_multi()),
pytest.mark.xfail(raises=ValueError, reason = 'spa.calculate_deltat not implemented for numba yet')
pytest.mark.xfail(
Copy link

Choose a reason for hiding this comment

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

E122 continuation line missing indentation or outdented

raises=ValueError,
reason = 'spa.calculate_deltat not implemented for numba yet')
Copy link

Choose a reason for hiding this comment

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

E251 unexpected spaces around keyword / parameter equals

((None, 'nrel_numba', expected_solpos_multi())),
(67.0, 'nrel_numba', expected_solpos_multi())
])
Expand Down Expand Up @@ -690,8 +692,9 @@ def test_analytical_azimuth():
[ -30., -180., 5.],
[ -30., 180., 10.]]))

zeniths = solarposition.solar_zenith_analytical(*test_angles.T)
azimuths = solarposition.solar_azimuth_analytical(*test_angles.T, zenith=zeniths)
zeniths = solarposition.solar_zenith_analytical(*test_angles.T)
azimuths = solarposition.solar_azimuth_analytical(*test_angles.T,
zenith=zeniths)

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

Expand All @@ -718,3 +721,45 @@ def test_hour_angle():
# sunrise: 4 seconds, sunset: 48 seconds, transit: 0.2 seconds
# but the differences may be due to other SPA input parameters
assert np.allclose(hours, expected)


def test_geometric_sunrise_sunset_transit(expected_rise_set_spa, golden_mst):
Copy link
Member

Choose a reason for hiding this comment

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

Change function name to test_sunrise_sunset_transit_geometric

"""Test analytical calculations for sunrise, sunset, and transit times"""
Copy link
Member

Choose a reason for hiding this comment

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

geometric rather than analytical

times = expected_rise_set_spa.index
latitude = golden_mst.latitude
longitude = golden_mst.longitude
eot = solarposition.equation_of_time_spencer71(times.dayofyear) # minutes
decl = solarposition.declination_spencer71(times.dayofyear) # radians
sr, ss, st = solarposition.sunrise_sunset_transit_geometric(
times, latitude=latitude, longitude=longitude, declination=decl,
equation_of_time=eot)
# sunrise: 2015-01-02 07:26:39.763224487, 2015-08-02 05:04:35.688533801
# sunset: 2015-01-02 16:41:29.951096777, 2015-08-02 19:09:46.597355085
# transit: 2015-01-02 12:04:04.857160632, 2015-08-02 12:07:11.142944443
test_sunrise = solarposition._times_to_hours(sr)
test_sunset = solarposition._times_to_hours(ss)
test_transit = solarposition._times_to_hours(st)
# convert expected SPA sunrise, sunset, transit to local datetime indices
expected_sunrise = pd.DatetimeIndex(expected_rise_set_spa.sunrise.values,
tz='UTC').tz_convert(golden_mst.tz)
expected_sunset = pd.DatetimeIndex(expected_rise_set_spa.sunset.values,
tz='UTC').tz_convert(golden_mst.tz)
expected_transit = pd.DatetimeIndex(expected_rise_set_spa.transit.values,
tz='UTC').tz_convert(golden_mst.tz)
# convert expected times to hours since midnight as arrays of floats
expected_sunrise = solarposition._times_to_hours(expected_sunrise)
expected_sunset = solarposition._times_to_hours(expected_sunset)
expected_transit = solarposition._times_to_hours(expected_transit)
# geometric time has about 4-6 minute error compared to SPA sunset/sunrise
expected_sunrise_error = np.array(
[0.07910089555555544, 0.06908014805555496]) # 4.8[min], 4.2[min]
expected_sunset_error = np.array(
[-0.1036246955555562, -0.06983406805555603]) # -6.2[min], -4.2[min]
expected_transit_error = np.array(
[-0.011150788888889096, 0.0036508177777765383]) # -40[sec], 13.3[sec]
assert np.allclose(test_sunrise, expected_sunrise,
atol=np.abs(expected_sunrise_error).max())
assert np.allclose(test_sunset, expected_sunset,
atol=np.abs(expected_sunset_error).max())
assert np.allclose(test_transit, expected_transit,
atol=np.abs(expected_transit_error).max())