From ec24a16ebfa41f9d59028ddb0161040583c8f29c Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 11:42:37 +0100 Subject: [PATCH 01/21] Add renamed_kwarg_warning decorator --- pvlib/_deprecation.py | 63 ++++++++++++++++++++++++++++++++ pvlib/tests/test__deprecation.py | 38 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 pvlib/tests/test__deprecation.py diff --git a/pvlib/_deprecation.py b/pvlib/_deprecation.py index 56e704c308..ea617b514b 100644 --- a/pvlib/_deprecation.py +++ b/pvlib/_deprecation.py @@ -316,3 +316,66 @@ def wrapper(*args, **kwargs): return finalize(wrapper, new_doc) return deprecate + + +def renamed_kwarg_warning(since, old_param_name, new_param_name, removal=""): + """ + Decorator to mark a possible keyword argument as deprecated and replaced + with other name. + + Raises a warning when the deprecated argument is used, and replaces the + call with the new argument name. Does not modify the function signature. + + .. warning:: + Ensure ``removal`` date with a ``fail_on_pvlib_version`` decorator in + the test suite. + + .. note:: + Not compatible with positional-only arguments. + + .. note:: + Documentation for the function may updated to reflect the new parameter + name; it is suggested to add a |.. versionchanged::| directive. + + Parameters + ---------- + since : str + The release at which this API became deprecated. + old_param_name : str + The name of the deprecated parameter. + new_param_name : str + The name of the new parameter. + removal : str, optional + The expected removal version, in order to compose the Warning message. + + Examples + -------- + @renamed_kwarg('1.4.0', 'old_name', 'new_name') + def some_function(new_name=None): + pass + """ + + def deprecate(func, old=old_param_name, new=new_param_name, since=since): + def wrapper(*args, **kwargs): + if old in kwargs: + if new in kwargs: + raise ValueError( + f"{func.__name__} received both '{old}' and '{new}', " + "which are mutually exclusive since they refer to the " + f"same parameter. Please remove deprecated '{old}'." + ) + warnings.warn( + f"Parameter '{old}' has been renamed since {since}. " + f"and will be removed " + + ("in {removal}." if removal else "soon.") + + f"Please use '{new}' instead.", + _projectWarning, + stacklevel=2, + ) + kwargs[new] = kwargs.pop(old) + return func(*args, **kwargs) + + wrapper = functools.wraps(func)(wrapper) + return wrapper + + return deprecate diff --git a/pvlib/tests/test__deprecation.py b/pvlib/tests/test__deprecation.py new file mode 100644 index 0000000000..63e159dd0a --- /dev/null +++ b/pvlib/tests/test__deprecation.py @@ -0,0 +1,38 @@ +""" +Test the _deprecation module. +""" + +import pytest + +from pvlib import _deprecation + + +@pytest.fixture +def renamed_kwarg_func(): + """Returns a function decorated by renamed_kwarg_warning.""" + @_deprecation.renamed_kwarg_warning( + "0.1.0", "old_kwarg", "new_kwarg", "0.2.0" + ) + def func(new_kwarg): + return new_kwarg + + return func + + +def test_renamed_kwarg_warning(renamed_kwarg_func): + # assert no warning is raised when using the new kwarg + assert renamed_kwarg_func(new_kwarg=1) == 1 + + # assert a warning is raised when using the old kwarg + with pytest.warns(Warning, match="Parameter 'old_kwarg' has been renamed"): + assert renamed_kwarg_func(old_kwarg=1) == 1 + + # assert an error is raised when using both the old and new kwarg + with pytest.raises(ValueError, match="they refer to the same parameter."): + renamed_kwarg_func(old_kwarg=1, new_kwarg=2) + + # assert when not providing any of them + with pytest.raises( + TypeError, match="missing 1 required positional argument" + ): + renamed_kwarg_func() From 0ed889848069d242ca522d6c2fe154039bfebf88 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 11:43:02 +0100 Subject: [PATCH 02/21] Example at solarposition.hour_angle() --- pvlib/solarposition.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 2ddfe5082c..2bf3a73f95 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -25,6 +25,7 @@ from pvlib import atmosphere, tools from pvlib.tools import datetime_to_djd, djd_to_datetime +from pvlib._deprecation import renamed_kwarg_warning def get_solarposition(time, latitude, longitude, @@ -1340,15 +1341,20 @@ def solar_zenith_analytical(latitude, hourangle, declination): ) -def hour_angle(times, longitude, equation_of_time): +@renamed_kwarg_warning("0.11.2", "times", "time", "0.12.0") +def hour_angle(time, longitude, equation_of_time): """ Hour angle in local solar time. Zero at local solar noon. Parameters ---------- - times : :class:`pandas.DatetimeIndex` + time : :class:`pandas.DatetimeIndex` Corresponding timestamps, must be localized to the timezone for the ``longitude``. + + .. versionchanged:: 0.11.2 + Renamed from ``times`` to ``time``. + longitude : numeric Longitude in degrees equation_of_time : numeric @@ -1376,14 +1382,14 @@ def hour_angle(times, longitude, equation_of_time): equation_of_time_pvcdrom """ - # times must be localized - if not times.tz: - raise ValueError('times must be localized') + # time must be localized + if not time.tz: + raise ValueError('time must be localized') - # hours - timezone = (times - normalized_times) - (naive_times - times) - tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 + # hours - timezone = (time - normalized_time) - (naive_time - time) + tzs = np.array([ts.utcoffset().total_seconds() for ts in time]) / 3600 - hrs_minus_tzs = _times_to_hours_after_local_midnight(times) - tzs + hrs_minus_tzs = _times_to_hours_after_local_midnight(time) - tzs return 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4. From bade6708600a502b9f5ab664d9e7fefe045631d6 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 11:48:28 +0100 Subject: [PATCH 03/21] another day, another test --- pvlib/tests/test__deprecation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pvlib/tests/test__deprecation.py b/pvlib/tests/test__deprecation.py index 63e159dd0a..7f43e8faae 100644 --- a/pvlib/tests/test__deprecation.py +++ b/pvlib/tests/test__deprecation.py @@ -21,7 +21,8 @@ def func(new_kwarg): def test_renamed_kwarg_warning(renamed_kwarg_func): # assert no warning is raised when using the new kwarg - assert renamed_kwarg_func(new_kwarg=1) == 1 + assert renamed_kwarg_func(new_kwarg=1) == 1 # as keyword argument + assert renamed_kwarg_func(1) == 1 # as positional argument # assert a warning is raised when using the old kwarg with pytest.warns(Warning, match="Parameter 'old_kwarg' has been renamed"): From f5e46efb39e73960cdc1b4643ace5f8e1e4d62c8 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 12:00:39 +0100 Subject: [PATCH 04/21] Flake8 strikes again --- pvlib/_deprecation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/_deprecation.py b/pvlib/_deprecation.py index ea617b514b..1b95a34730 100644 --- a/pvlib/_deprecation.py +++ b/pvlib/_deprecation.py @@ -332,7 +332,7 @@ def renamed_kwarg_warning(since, old_param_name, new_param_name, removal=""): .. note:: Not compatible with positional-only arguments. - + .. note:: Documentation for the function may updated to reflect the new parameter name; it is suggested to add a |.. versionchanged::| directive. From 1fdecb2472477791f8fbd1385f560af5f5c67252 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:37:55 +0100 Subject: [PATCH 05/21] Fix no warning test Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> --- pvlib/tests/test__deprecation.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pvlib/tests/test__deprecation.py b/pvlib/tests/test__deprecation.py index 7f43e8faae..9586ece1e2 100644 --- a/pvlib/tests/test__deprecation.py +++ b/pvlib/tests/test__deprecation.py @@ -6,10 +6,13 @@ from pvlib import _deprecation +import warnings + @pytest.fixture def renamed_kwarg_func(): """Returns a function decorated by renamed_kwarg_warning.""" + @_deprecation.renamed_kwarg_warning( "0.1.0", "old_kwarg", "new_kwarg", "0.2.0" ) @@ -21,8 +24,10 @@ def func(new_kwarg): def test_renamed_kwarg_warning(renamed_kwarg_func): # assert no warning is raised when using the new kwarg - assert renamed_kwarg_func(new_kwarg=1) == 1 # as keyword argument - assert renamed_kwarg_func(1) == 1 # as positional argument + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert renamed_kwarg_func(new_kwarg=1) == 1 # as keyword argument + assert renamed_kwarg_func(1) == 1 # as positional argument # assert a warning is raised when using the old kwarg with pytest.warns(Warning, match="Parameter 'old_kwarg' has been renamed"): From 65e144b200b35d1c1c757158f6d1786c4066ac9b Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:39:42 +0100 Subject: [PATCH 06/21] Add test to remember removing the deprecation decorator --- pvlib/tests/test_solarposition.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index 9a69673d6c..cf5c9b8240 100644 --- a/pvlib/tests/test_solarposition.py +++ b/pvlib/tests/test_solarposition.py @@ -5,7 +5,11 @@ import numpy as np import pandas as pd -from .conftest import assert_frame_equal, assert_series_equal +from .conftest import ( + assert_frame_equal, + assert_series_equal, + fail_on_pvlib_version, +) from numpy.testing import assert_allclose import pytest @@ -711,6 +715,12 @@ def test_hour_angle(): solarposition._local_times_from_hours_since_midnight(times, hours) +@fail_on_pvlib_version('0.12') +def test_hour_angle_renamed_kwarg_warning(): + # test to remember to remove renamed_kwarg_warning + pass + + def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst): """Test geometric calculations for sunrise, sunset, and transit times""" times = expected_rise_set_spa.index From fd0974f7b23183d906e59139c5bcbf39827f59cc Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:44:03 +0100 Subject: [PATCH 07/21] test-driven-development for the win: unchanged remain properties Co-Authored-By: Kevin Anderson <57452607+kandersolar@users.noreply.github.com> --- pvlib/tests/test__deprecation.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pvlib/tests/test__deprecation.py b/pvlib/tests/test__deprecation.py index 9586ece1e2..177082ca9d 100644 --- a/pvlib/tests/test__deprecation.py +++ b/pvlib/tests/test__deprecation.py @@ -11,18 +11,25 @@ @pytest.fixture def renamed_kwarg_func(): - """Returns a function decorated by renamed_kwarg_warning.""" + """Returns a function decorated by renamed_kwarg_warning. + This function is called 'func' and has a docstring equal to 'docstring'. + """ @_deprecation.renamed_kwarg_warning( "0.1.0", "old_kwarg", "new_kwarg", "0.2.0" ) def func(new_kwarg): + """docstring""" return new_kwarg return func def test_renamed_kwarg_warning(renamed_kwarg_func): + # assert decorated function name and docstring are unchanged + assert renamed_kwarg_func.__name__ == "func" + assert renamed_kwarg_func.__doc__ == "docstring" + # assert no warning is raised when using the new kwarg with warnings.catch_warnings(): warnings.simplefilter("error") From 1eb44a5d5788b398ae3333ae5f96a04df6567dea Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 23:37:27 +0100 Subject: [PATCH 08/21] Update docs --- pvlib/_deprecation.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/pvlib/_deprecation.py b/pvlib/_deprecation.py index 1b95a34730..283e486074 100644 --- a/pvlib/_deprecation.py +++ b/pvlib/_deprecation.py @@ -350,9 +350,19 @@ def renamed_kwarg_warning(since, old_param_name, new_param_name, removal=""): Examples -------- - @renamed_kwarg('1.4.0', 'old_name', 'new_name') - def some_function(new_name=None): - pass + >>> @renamed_kwarg_warning("1.4.0", "old_name", "new_name", "1.6.0") + >>> def some_function(new_name=None): + >>> pass + >>> some_function(old_name=1) + Parameter 'old_name' has been renamed since 1.4.0. and + will be removed in 1.6.0. Please use 'new_name' instead. + + >>> @renamed_kwarg_warning("1.4.0", "old_name", "new_name") + >>> def some_function(new_name=None): + >>> pass + >>> some_function(old_name=1) + Parameter 'old_name' has been renamed since 1.4.0. and + will be removed soon. Please use 'new_name' instead. """ def deprecate(func, old=old_param_name, new=new_param_name, since=since): @@ -367,8 +377,8 @@ def wrapper(*args, **kwargs): warnings.warn( f"Parameter '{old}' has been renamed since {since}. " f"and will be removed " - + ("in {removal}." if removal else "soon.") - + f"Please use '{new}' instead.", + + (f"in {removal}" if removal else "soon") + + f". Please use '{new}' instead.", _projectWarning, stacklevel=2, ) From f58209fa68b97c1552bd768c0f12547baeb9b19f Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Wed, 2 Oct 2024 23:37:49 +0100 Subject: [PATCH 09/21] Update fail_on_pvlib_version test comment --- pvlib/tests/test_solarposition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index cf5c9b8240..4c0bf0a6eb 100644 --- a/pvlib/tests/test_solarposition.py +++ b/pvlib/tests/test_solarposition.py @@ -717,7 +717,7 @@ def test_hour_angle(): @fail_on_pvlib_version('0.12') def test_hour_angle_renamed_kwarg_warning(): - # test to remember to remove renamed_kwarg_warning + # test to remember to remove renamed_kwarg_warning after the grace period pass From 58c0191209a52974a5e85e58e23fd356abe4e812 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Thu, 3 Oct 2024 00:01:32 +0100 Subject: [PATCH 10/21] Rename&deprecate solarposition.sun_rise_set_transit_spa --- pvlib/solarposition.py | 29 +++++++++++++++++------------ pvlib/tests/test_solarposition.py | 7 +++++++ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 2bf3a73f95..ee38e67865 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -390,6 +390,7 @@ def spa_python(time, latitude, longitude, return result +@renamed_kwarg_warning("0.11.2", "times", "date", "0.12.0") def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', delta_t=67.0, numthreads=4): """ @@ -405,8 +406,12 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', Parameters ---------- - times : pandas.DatetimeIndex + date : pandas.DatetimeIndex Must be localized to the timezone for ``latitude`` and ``longitude``. + + .. deprecated:: 0.11.2 until 0.12.0 + Renamed from ``times`` to ``date``. + latitude : float Latitude in degrees, positive north of equator, negative to south longitude : float @@ -418,7 +423,7 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', delta_t : float or array, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat - using times.year and times.month from pandas.DatetimeIndex. + using date.year and date.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. numthreads : int, optional, default 4 Number of threads to use if how == 'numba'. @@ -426,7 +431,7 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', Returns ------- pandas.DataFrame - index is the same as input `times` argument + index is the same as input ``date`` argument columns are 'sunrise', 'sunset', and 'transit' References @@ -440,25 +445,25 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', lat = latitude lon = longitude - # times must be localized - if times.tz: - tzinfo = times.tz + # date must be localized + if date.tz: + tzinfo = date.tz else: - raise ValueError('times must be localized') + raise ValueError("'date' must be localized") # must convert to midnight UTC on day of interest - times_utc = times.tz_convert('UTC') - unixtime = _datetime_to_unixtime(times_utc.normalize()) + date_utc = date.tz_convert('UTC') + unixtime = _datetime_to_unixtime(date_utc.normalize()) spa = _spa_python_import(how) if delta_t is None: - delta_t = spa.calculate_deltat(times_utc.year, times_utc.month) + delta_t = spa.calculate_deltat(date_utc.year, date_utc.month) transit, sunrise, sunset = spa.transit_sunrise_sunset( unixtime, lat, lon, delta_t, numthreads) - # arrays are in seconds since epoch format, need to conver to timestamps + # arrays are in seconds since epoch format, need to convert to timestamps transit = pd.to_datetime(transit*1e9, unit='ns', utc=True).tz_convert( tzinfo).tolist() sunrise = pd.to_datetime(sunrise*1e9, unit='ns', utc=True).tz_convert( @@ -466,7 +471,7 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', sunset = pd.to_datetime(sunset*1e9, unit='ns', utc=True).tz_convert( tzinfo).tolist() - return pd.DataFrame(index=times, data={'sunrise': sunrise, + return pd.DataFrame(index=date, data={'sunrise': sunrise, 'sunset': sunset, 'transit': transit}) diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index 4c0bf0a6eb..709691c429 100644 --- a/pvlib/tests/test_solarposition.py +++ b/pvlib/tests/test_solarposition.py @@ -188,6 +188,13 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): assert_frame_equal(expected_rise_set_spa, result_rounded) +@fail_on_pvlib_version("0.12") +def test_sun_rise_set_transit_spa_renamed_kwarg_warning(): + # test to remember to remove renamed_kwarg_warning after the grace period + # and modify docs as needed + pass + + @requires_ephem def test_sun_rise_set_transit_ephem(expected_rise_set_ephem, golden): # test for Golden, CO compare to USNO, using local midnight From 9921ad715583e85efc6e1c4f504d24fdc2b5e772 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Thu, 3 Oct 2024 00:03:41 +0100 Subject: [PATCH 11/21] flake8 --- pvlib/_deprecation.py | 2 +- pvlib/solarposition.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pvlib/_deprecation.py b/pvlib/_deprecation.py index 283e486074..aedb4d5096 100644 --- a/pvlib/_deprecation.py +++ b/pvlib/_deprecation.py @@ -354,7 +354,7 @@ def renamed_kwarg_warning(since, old_param_name, new_param_name, removal=""): >>> def some_function(new_name=None): >>> pass >>> some_function(old_name=1) - Parameter 'old_name' has been renamed since 1.4.0. and + Parameter 'old_name' has been renamed since 1.4.0. and will be removed in 1.6.0. Please use 'new_name' instead. >>> @renamed_kwarg_warning("1.4.0", "old_name", "new_name") diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index ee38e67865..1917a709c7 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -390,8 +390,8 @@ def spa_python(time, latitude, longitude, return result -@renamed_kwarg_warning("0.11.2", "times", "date", "0.12.0") -def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', +@renamed_kwarg_warning("0.11.2", "times", "date", "0.12") +def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', delta_t=67.0, numthreads=4): """ Calculate the sunrise, sunset, and sun transit times using the @@ -408,7 +408,7 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', ---------- date : pandas.DatetimeIndex Must be localized to the timezone for ``latitude`` and ``longitude``. - + .. deprecated:: 0.11.2 until 0.12.0 Renamed from ``times`` to ``date``. From 30e3c656756a9a1262006c66512bf46509f186b3 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Thu, 3 Oct 2024 00:04:15 +0100 Subject: [PATCH 12/21] flake8 no more --- pvlib/solarposition.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 1917a709c7..6c774aad4b 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -471,9 +471,10 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', sunset = pd.to_datetime(sunset*1e9, unit='ns', utc=True).tz_convert( tzinfo).tolist() - return pd.DataFrame(index=date, data={'sunrise': sunrise, - 'sunset': sunset, - 'transit': transit}) + return pd.DataFrame( + index=date, + data={"sunrise": sunrise, "sunset": sunset, "transit": transit}, + ) def _ephem_convert_to_seconds_and_microseconds(date): From 2a8b56dee55fac7e63028e8c01902421c62ff6f6 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Thu, 3 Oct 2024 22:38:21 +0100 Subject: [PATCH 13/21] date -> time in solarposition.sun_rise_set_transit_spa --- pvlib/solarposition.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 6c774aad4b..5298d3a884 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -390,8 +390,8 @@ def spa_python(time, latitude, longitude, return result -@renamed_kwarg_warning("0.11.2", "times", "date", "0.12") -def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', +@renamed_kwarg_warning("0.11.2", "times", "time", "0.12") +def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', delta_t=67.0, numthreads=4): """ Calculate the sunrise, sunset, and sun transit times using the @@ -406,11 +406,11 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', Parameters ---------- - date : pandas.DatetimeIndex + time : pandas.DatetimeIndex Must be localized to the timezone for ``latitude`` and ``longitude``. - .. deprecated:: 0.11.2 until 0.12.0 - Renamed from ``times`` to ``date``. + .. deprecated:: 0.11.2 + Renamed from ``times`` to ``time``. Removal scheduled for v0.12.0. latitude : float Latitude in degrees, positive north of equator, negative to south @@ -423,7 +423,7 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', delta_t : float or array, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat - using date.year and date.month from pandas.DatetimeIndex. + using time.year and time.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. numthreads : int, optional, default 4 Number of threads to use if how == 'numba'. @@ -431,7 +431,7 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', Returns ------- pandas.DataFrame - index is the same as input ``date`` argument + index is the same as input ``time`` argument columns are 'sunrise', 'sunset', and 'transit' References @@ -445,20 +445,20 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', lat = latitude lon = longitude - # date must be localized - if date.tz: - tzinfo = date.tz + # time must be localized + if time.tz: + tzinfo = time.tz else: - raise ValueError("'date' must be localized") + raise ValueError("'time' must be localized") # must convert to midnight UTC on day of interest - date_utc = date.tz_convert('UTC') - unixtime = _datetime_to_unixtime(date_utc.normalize()) + time_utc = time.tz_convert('UTC') + unixtime = _datetime_to_unixtime(time_utc.normalize()) spa = _spa_python_import(how) if delta_t is None: - delta_t = spa.calculate_deltat(date_utc.year, date_utc.month) + delta_t = spa.calculate_deltat(time_utc.year, time_utc.month) transit, sunrise, sunset = spa.transit_sunrise_sunset( unixtime, lat, lon, delta_t, numthreads) @@ -472,7 +472,7 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', tzinfo).tolist() return pd.DataFrame( - index=date, + index=time, data={"sunrise": sunrise, "sunset": sunset, "transit": transit}, ) From 455e9ad0cec59423581d4c14469b347a689a29fe Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Fri, 11 Oct 2024 12:13:09 +0200 Subject: [PATCH 14/21] Change to ..versionchanged, same format. --- pvlib/solarposition.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 5298d3a884..7a8723dca2 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -409,8 +409,8 @@ def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', time : pandas.DatetimeIndex Must be localized to the timezone for ``latitude`` and ``longitude``. - .. deprecated:: 0.11.2 - Renamed from ``times`` to ``time``. Removal scheduled for v0.12.0. + .. versionchanged:: 0.11.2 + Renamed from ``times`` to ``time``. Removal in ``v0.12.0``. latitude : float Latitude in degrees, positive north of equator, negative to south @@ -1359,7 +1359,7 @@ def hour_angle(time, longitude, equation_of_time): ``longitude``. .. versionchanged:: 0.11.2 - Renamed from ``times`` to ``time``. + Renamed from ``times`` to ``time``. Removal in ``v0.12.0``. longitude : numeric Longitude in degrees From e779f79c3205c8a069e50a90763c78126630337e Mon Sep 17 00:00:00 2001 From: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> Date: Tue, 15 Oct 2024 15:49:01 +0200 Subject: [PATCH 15/21] Apply suggestions from Adam Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- pvlib/solarposition.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 7a8723dca2..b178b57e15 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -410,7 +410,8 @@ def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', Must be localized to the timezone for ``latitude`` and ``longitude``. .. versionchanged:: 0.11.2 - Renamed from ``times`` to ``time``. Removal in ``v0.12.0``. + The ``times` parameter has been renamed ``time``. The deprecated + ``times`` parameter will be removed in ``v0.12.0``. latitude : float Latitude in degrees, positive north of equator, negative to south From 981f3940a3057be5251cec8a68569b8cba9ea266 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Tue, 15 Oct 2024 15:59:22 +0200 Subject: [PATCH 16/21] Apply Adam review x2 Co-Authored-By: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- pvlib/solarposition.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index b178b57e15..1f62bbf241 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -1360,7 +1360,8 @@ def hour_angle(time, longitude, equation_of_time): ``longitude``. .. versionchanged:: 0.11.2 - Renamed from ``times`` to ``time``. Removal in ``v0.12.0``. + The ``times` parameter has been renamed ``time``. The deprecated + ``times`` parameter will be removed in ``v0.12.0``. longitude : numeric Longitude in degrees From 25bcf0291fa30cee9acdab283a03fa29939c0cf2 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:33:35 +0200 Subject: [PATCH 17/21] Update solarposition.py --- pvlib/solarposition.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 1f62bbf241..aa5a31d61b 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -390,8 +390,8 @@ def spa_python(time, latitude, longitude, return result -@renamed_kwarg_warning("0.11.2", "times", "time", "0.12") -def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', +@renamed_kwarg_warning("0.11.2", "times", "date", "0.12") +def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', delta_t=67.0, numthreads=4): """ Calculate the sunrise, sunset, and sun transit times using the @@ -406,12 +406,11 @@ def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', Parameters ---------- - time : pandas.DatetimeIndex + date : pandas.DatetimeIndex Must be localized to the timezone for ``latitude`` and ``longitude``. - .. versionchanged:: 0.11.2 - The ``times` parameter has been renamed ``time``. The deprecated - ``times`` parameter will be removed in ``v0.12.0``. + .. deprecated:: 0.11.2 until 0.12.0 + Renamed from ``times`` to ``date``. latitude : float Latitude in degrees, positive north of equator, negative to south @@ -424,7 +423,7 @@ def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', delta_t : float or array, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat - using time.year and time.month from pandas.DatetimeIndex. + using date.year and date.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. numthreads : int, optional, default 4 Number of threads to use if how == 'numba'. @@ -432,7 +431,7 @@ def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', Returns ------- pandas.DataFrame - index is the same as input ``time`` argument + index is the same as input ``date`` argument columns are 'sunrise', 'sunset', and 'transit' References @@ -446,20 +445,20 @@ def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', lat = latitude lon = longitude - # time must be localized - if time.tz: - tzinfo = time.tz + # date must be localized + if date.tz: + tzinfo = date.tz else: - raise ValueError("'time' must be localized") + raise ValueError("'date' must be localized") # must convert to midnight UTC on day of interest - time_utc = time.tz_convert('UTC') - unixtime = _datetime_to_unixtime(time_utc.normalize()) + date_utc = date.tz_convert('UTC') + unixtime = _datetime_to_unixtime(date_utc.normalize()) spa = _spa_python_import(how) if delta_t is None: - delta_t = spa.calculate_deltat(time_utc.year, time_utc.month) + delta_t = spa.calculate_deltat(date_utc.year, date_utc.month) transit, sunrise, sunset = spa.transit_sunrise_sunset( unixtime, lat, lon, delta_t, numthreads) @@ -473,7 +472,7 @@ def sun_rise_set_transit_spa(time, latitude, longitude, how='numpy', tzinfo).tolist() return pd.DataFrame( - index=time, + index=date, data={"sunrise": sunrise, "sunset": sunset, "transit": transit}, ) From a8f85768f9290899a79fbc862b7e1452d8261396 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:34:26 +0200 Subject: [PATCH 18/21] Update solarposition.py --- pvlib/solarposition.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index aa5a31d61b..123ea6029a 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -25,7 +25,6 @@ from pvlib import atmosphere, tools from pvlib.tools import datetime_to_djd, djd_to_datetime -from pvlib._deprecation import renamed_kwarg_warning def get_solarposition(time, latitude, longitude, @@ -1347,21 +1346,15 @@ def solar_zenith_analytical(latitude, hourangle, declination): ) -@renamed_kwarg_warning("0.11.2", "times", "time", "0.12.0") -def hour_angle(time, longitude, equation_of_time): +def hour_angle(times, longitude, equation_of_time): """ Hour angle in local solar time. Zero at local solar noon. Parameters ---------- - time : :class:`pandas.DatetimeIndex` + times : :class:`pandas.DatetimeIndex` Corresponding timestamps, must be localized to the timezone for the ``longitude``. - - .. versionchanged:: 0.11.2 - The ``times` parameter has been renamed ``time``. The deprecated - ``times`` parameter will be removed in ``v0.12.0``. - longitude : numeric Longitude in degrees equation_of_time : numeric @@ -1389,14 +1382,14 @@ def hour_angle(time, longitude, equation_of_time): equation_of_time_pvcdrom """ - # time must be localized - if not time.tz: - raise ValueError('time must be localized') + # times must be localized + if not times.tz: + raise ValueError('times must be localized') - # hours - timezone = (time - normalized_time) - (naive_time - time) - tzs = np.array([ts.utcoffset().total_seconds() for ts in time]) / 3600 + # hours - timezone = (times - normalized_times) - (naive_times - times) + tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 - hrs_minus_tzs = _times_to_hours_after_local_midnight(time) - tzs + hrs_minus_tzs = _times_to_hours_after_local_midnight(times) - tzs return 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4. From c185834e765059aac99b88a0fd69390dc201cbdb Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:42:11 +0200 Subject: [PATCH 19/21] Revert solarposition and test_solarposition changes --- pvlib/solarposition.py | 205 +++++++++++------------- pvlib/tests/test_solarposition.py | 257 +++++------------------------- 2 files changed, 132 insertions(+), 330 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 123ea6029a..cdcacd7ec6 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -22,11 +22,15 @@ import pandas as pd import scipy.optimize as so import warnings +import datetime -from pvlib import atmosphere, tools +from pvlib import atmosphere from pvlib.tools import datetime_to_djd, djd_to_datetime +NS_PER_HR = 1.e9 * 3600. # nanoseconds per hour + + def get_solarposition(time, latitude, longitude, altitude=None, pressure=None, method='nrel_numpy', @@ -47,13 +51,13 @@ def get_solarposition(time, latitude, longitude, Longitude in decimal degrees. Positive east of prime meridian, negative to west. - altitude : float, optional - If not specified, computed from ``pressure``. Assumed to be 0 m - if ``pressure`` is not supplied. + altitude : None or float, default None + If None, computed from pressure. Assumed to be 0 m + if pressure is also None. - pressure : float, optional - If not specified, computed from ``altitude``. Assumed to be 101325 Pa - if ``altitude`` is not supplied. + pressure : None or float, default None + If None, computed from altitude. Assumed to be 101325 Pa + if altitude is also None. method : string, default 'nrel_numpy' 'nrel_numpy' uses an implementation of the NREL SPA algorithm @@ -85,7 +89,7 @@ def get_solarposition(time, latitude, longitude, solar radiation applications. Solar Energy, vol. 81, no. 6, p. 838, 2007. - .. [3] NREL SPA code: https://midcdmz.nrel.gov/spa/ + .. [3] NREL SPA code: http://rredc.nrel.gov/solar/codesandalgorithms/spa/ """ if altitude is None and pressure is None: @@ -128,7 +132,7 @@ def get_solarposition(time, latitude, longitude, def spa_c(time, latitude, longitude, pressure=101325, altitude=0, temperature=12, delta_t=67.0, raw_spa_output=False): - r""" + """ Calculate the solar position using the C implementation of the NREL SPA code. @@ -157,7 +161,7 @@ def spa_c(time, latitude, longitude, pressure=101325, altitude=0, Temperature in C delta_t : float, default 67.0 Difference between terrestrial time and UT1. - USNO has previous values and predictions [3]_. + USNO has previous values and predictions. raw_spa_output : bool, default False If true, returns the raw SPA output. @@ -173,16 +177,17 @@ def spa_c(time, latitude, longitude, pressure=101325, altitude=0, References ---------- - .. [1] NREL SPA reference: https://midcdmz.nrel.gov/spa/ + .. [1] NREL SPA reference: + http://rredc.nrel.gov/solar/codesandalgorithms/spa/ + NREL SPA C files: https://midcdmz.nrel.gov/spa/ Note: The ``timezone`` field in the SPA C files is replaced with ``time_zone`` to avoid a nameclash with the function ``__timezone`` that is redefined by Python>=3.5. This issue is `Python bug 24643 `_. - .. [2] Delta T: https://en.wikipedia.org/wiki/%CE%94T_(timekeeping) - - .. [3] USNO delta T: https://maia.usno.navy.mil/products/deltaT + .. [2] USNO delta T: + http://www.usno.navy.mil/USNO/earth-orientation/eo-products/long-term See also -------- @@ -199,7 +204,11 @@ def spa_c(time, latitude, longitude, pressure=101325, altitude=0, raise ImportError('Could not import built-in SPA calculator. ' + 'You may need to recompile the SPA code.') - time_utc = tools._pandas_to_utc(time) + # if localized, convert to UTC. otherwise, assume UTC. + try: + time_utc = time.tz_convert('UTC') + except TypeError: + time_utc = time spa_out = [] @@ -265,19 +274,6 @@ def _spa_python_import(how): return spa -def _datetime_to_unixtime(dtindex): - # convert a pandas datetime index to unixtime, making sure to handle - # different pandas units (ns, us, etc) and time zones correctly - if dtindex.tz is not None: - # epoch is 1970-01-01 00:00 UTC, but we need to match the input tz - # for compatibility with older pandas versions (e.g. v1.3.5) - epoch = pd.Timestamp("1970-01-01", tz="UTC").tz_convert(dtindex.tz) - else: - epoch = pd.Timestamp("1970-01-01") - - return np.array((dtindex - epoch) / pd.Timedelta("1s")) - - def spa_python(time, latitude, longitude, altitude=0, pressure=101325, temperature=12, delta_t=67.0, atmos_refract=None, how='numpy', numthreads=4): @@ -308,13 +304,15 @@ def spa_python(time, latitude, longitude, avg. yearly air pressure in Pascals. temperature : int or float, optional, default 12 avg. yearly air temperature in degrees C. - delta_t : float or array, optional, default 67.0 + delta_t : float, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat using time.year and time.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. + *Note: delta_t = None will break code using nrel_numba, + this will be fixed in a future version.* The USNO has historical and forecasted delta_t [3]_. - atmos_refrac : float, optional + atmos_refrac : None or float, optional, default None The approximate atmospheric refraction (in degrees) at sunrise and sunset. how : str, optional, default 'numpy' @@ -346,7 +344,7 @@ def spa_python(time, latitude, longitude, 2007. .. [3] USNO delta T: - https://maia.usno.navy.mil/products/deltaT + http://www.usno.navy.mil/USNO/earth-orientation/eo-products/long-term See also -------- @@ -368,13 +366,11 @@ def spa_python(time, latitude, longitude, except (TypeError, ValueError): time = pd.DatetimeIndex([time, ]) - unixtime = _datetime_to_unixtime(time) + unixtime = np.array(time.view(np.int64)/10**9) spa = _spa_python_import(how) - if delta_t is None: - time_utc = tools._pandas_to_utc(time) - delta_t = spa.calculate_deltat(time_utc.year, time_utc.month) + delta_t = delta_t or spa.calculate_deltat(time.year, time.month) app_zenith, zenith, app_elevation, elevation, azimuth, eot = \ spa.solar_position(unixtime, lat, lon, elev, pressure, temperature, @@ -389,8 +385,7 @@ def spa_python(time, latitude, longitude, return result -@renamed_kwarg_warning("0.11.2", "times", "date", "0.12") -def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', +def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', delta_t=67.0, numthreads=4): """ Calculate the sunrise, sunset, and sun transit times using the @@ -405,12 +400,8 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', Parameters ---------- - date : pandas.DatetimeIndex + times : pandas.DatetimeIndex Must be localized to the timezone for ``latitude`` and ``longitude``. - - .. deprecated:: 0.11.2 until 0.12.0 - Renamed from ``times`` to ``date``. - latitude : float Latitude in degrees, positive north of equator, negative to south longitude : float @@ -419,18 +410,20 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', Options are 'numpy' or 'numba'. If numba >= 0.17.0 is installed, how='numba' will compile the spa functions to machine code and run them multithreaded. - delta_t : float or array, optional, default 67.0 + delta_t : float, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat - using date.year and date.month from pandas.DatetimeIndex. + using times.year and times.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. + *Note: delta_t = None will break code using nrel_numba, + this will be fixed in a future version.* numthreads : int, optional, default 4 Number of threads to use if how == 'numba'. Returns ------- pandas.DataFrame - index is the same as input ``date`` argument + index is the same as input `times` argument columns are 'sunrise', 'sunset', and 'transit' References @@ -444,25 +437,24 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', lat = latitude lon = longitude - # date must be localized - if date.tz: - tzinfo = date.tz + # times must be localized + if times.tz: + tzinfo = times.tz else: - raise ValueError("'date' must be localized") + raise ValueError('times must be localized') # must convert to midnight UTC on day of interest - date_utc = date.tz_convert('UTC') - unixtime = _datetime_to_unixtime(date_utc.normalize()) + utcday = pd.DatetimeIndex(times.date).tz_localize('UTC') + unixtime = np.array(utcday.view(np.int64)/10**9) spa = _spa_python_import(how) - if delta_t is None: - delta_t = spa.calculate_deltat(date_utc.year, date_utc.month) + delta_t = delta_t or spa.calculate_deltat(times.year, times.month) transit, sunrise, sunset = spa.transit_sunrise_sunset( unixtime, lat, lon, delta_t, numthreads) - # arrays are in seconds since epoch format, need to convert to timestamps + # arrays are in seconds since epoch format, need to conver to timestamps transit = pd.to_datetime(transit*1e9, unit='ns', utc=True).tz_convert( tzinfo).tolist() sunrise = pd.to_datetime(sunrise*1e9, unit='ns', utc=True).tz_convert( @@ -470,10 +462,9 @@ def sun_rise_set_transit_spa(date, latitude, longitude, how='numpy', sunset = pd.to_datetime(sunset*1e9, unit='ns', utc=True).tz_convert( tzinfo).tolist() - return pd.DataFrame( - index=date, - data={"sunrise": sunrise, "sunset": sunset, "transit": transit}, - ) + return pd.DataFrame(index=times, data={'sunrise': sunrise, + 'sunset': sunset, + 'transit': transit}) def _ephem_convert_to_seconds_and_microseconds(date): @@ -582,11 +573,12 @@ def sun_rise_set_transit_ephem(times, latitude, longitude, sunrise = [] sunset = [] trans = [] - for thetime in tools._pandas_to_utc(times): + for thetime in times: + thetime = thetime.to_pydatetime() # older versions of pyephem ignore timezone when converting to its # internal datetime format, so convert to UTC here to support # all versions. GH #1449 - obs.date = ephem.Date(thetime) + obs.date = ephem.Date(thetime.astimezone(datetime.timezone.utc)) sunrise.append(_ephem_to_timezone(rising(sun), tzinfo)) sunset.append(_ephem_to_timezone(setting(sun), tzinfo)) trans.append(_ephem_to_timezone(transit(sun), tzinfo)) @@ -644,7 +636,11 @@ def pyephem(time, latitude, longitude, altitude=0, pressure=101325, except ImportError: raise ImportError('PyEphem must be installed') - time_utc = tools._pandas_to_utc(time) + # if localized, convert to UTC. otherwise, assume UTC. + try: + time_utc = time.tz_convert('UTC') + except TypeError: + time_utc = time sun_coords = pd.DataFrame(index=time) @@ -713,11 +709,11 @@ def ephemeris(time, latitude, longitude, pressure=101325, temperature=12): * apparent_elevation : apparent sun elevation accounting for atmospheric refraction. - This is the complement of the apparent zenith angle. * elevation : actual elevation (not accounting for refraction) of the sun in decimal degrees, 0 = on horizon. The complement of the zenith angle. * azimuth : Azimuth of the sun in decimal degrees East of North. + This is the complement of the apparent zenith angle. * apparent_zenith : apparent sun zenith accounting for atmospheric refraction. * zenith : Solar zenith angle @@ -761,7 +757,11 @@ def ephemeris(time, latitude, longitude, pressure=101325, temperature=12): # the SPA algorithm needs time to be expressed in terms of # decimal UTC hours of the day of the year. - time_utc = tools._pandas_to_utc(time) + # if localized, convert to UTC. otherwise, assume UTC. + try: + time_utc = time.tz_convert('UTC') + except TypeError: + time_utc = time # strip out the day of the year and calculate the decimal hour DayOfYear = time_utc.dayofyear @@ -832,7 +832,7 @@ def ephemeris(time, latitude, longitude, pressure=101325, temperature=12): # Calculate refraction correction Elevation = SunEl TanEl = pd.Series(np.tan(np.radians(Elevation)), index=time_utc) - Refract = pd.Series(0., index=time_utc) + Refract = pd.Series(0, index=time_utc) Refract[(Elevation > 5) & (Elevation <= 85)] = ( 58.1/TanEl - 0.07/(TanEl**3) + 8.6e-05/(TanEl**5)) @@ -948,10 +948,7 @@ def pyephem_earthsun_distance(time): sun = ephem.Sun() earthsun = [] - for thetime in tools._pandas_to_utc(time): - # older versions of pyephem ignore timezone when converting to its - # internal datetime format, so convert to UTC here to support - # all versions. GH #1449 + for thetime in time: sun.compute(ephem.Date(thetime)) earthsun.append(sun.earth_distance) @@ -975,11 +972,13 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4): is installed, how='numba' will compile the spa functions to machine code and run them multithreaded. - delta_t : float or array, optional, default 67.0 + delta_t : float, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat using time.year and time.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. + *Note: delta_t = None will break code using nrel_numba, + this will be fixed in a future version.* numthreads : int, optional, default 4 Number of threads to use if how == 'numba'. @@ -1002,13 +1001,11 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4): except (TypeError, ValueError): time = pd.DatetimeIndex([time, ]) - unixtime = _datetime_to_unixtime(time) + unixtime = np.array(time.view(np.int64)/10**9) spa = _spa_python_import(how) - if delta_t is None: - time_utc = tools._pandas_to_utc(time) - delta_t = spa.calculate_deltat(time_utc.year, time_utc.month) + delta_t = delta_t or spa.calculate_deltat(time.year, time.month) dist = spa.earthsun_distance(unixtime, delta_t, numthreads) @@ -1330,9 +1327,9 @@ def solar_zenith_analytical(latitude, hourangle, declination): .. [4] `Wikipedia: Solar Zenith Angle `_ - .. [5] `PVCDROM: Elevation Angle - `_ + .. [5] `PVCDROM: Sun's Position + `_ See Also -------- @@ -1381,27 +1378,21 @@ def hour_angle(times, longitude, equation_of_time): equation_of_time_spencer71 equation_of_time_pvcdrom """ - - # times must be localized - if not times.tz: - raise ValueError('times must be localized') - + naive_times = times.tz_localize(None) # naive but still localized # hours - timezone = (times - normalized_times) - (naive_times - times) - tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 - - hrs_minus_tzs = _times_to_hours_after_local_midnight(times) - tzs - - return 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4. + hrs_minus_tzs = 1 / NS_PER_HR * ( + 2 * times.view(np.int64) - times.normalize().view(np.int64) - + naive_times.view(np.int64)) + # ensure array return instead of a version-dependent pandas Index + return np.asarray( + 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.) def _hour_angle_to_hours(times, hourangle, longitude, equation_of_time): """converts hour angles in degrees to hours as a numpy array""" - - # times must be localized - if not times.tz: - raise ValueError('times must be localized') - - tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 + naive_times = times.tz_localize(None) # naive but still localized + tzs = 1 / NS_PER_HR * ( + naive_times.view(np.int64) - times.view(np.int64)) hours = (hourangle - longitude - equation_of_time / 4.) / 15. + 12. + tzs return np.asarray(hours) @@ -1410,26 +1401,21 @@ def _local_times_from_hours_since_midnight(times, hours): """ converts hours since midnight from an array of floats to localized times """ - - # times must be localized - if not times.tz: - raise ValueError('times must be localized') - - # normalize local times to previous local midnight and add the hours until + tz_info = times.tz # pytz timezone info + naive_times = times.tz_localize(None) # naive but still localized + # normalize local, naive times to previous midnight and add the hours until # sunrise, sunset, and transit - return times.normalize() + pd.to_timedelta(hours, unit='h') + return pd.DatetimeIndex( + (naive_times.normalize().view(np.int64) + + (hours * NS_PER_HR).astype(np.int64)).astype('datetime64[ns]'), + tz=tz_info) def _times_to_hours_after_local_midnight(times): """convert local pandas datetime indices to array of hours as floats""" - - # times must be localized - if not times.tz: - raise ValueError('times must be localized') - - hrs = (times - times.normalize()) / pd.Timedelta('1h') - - # ensure array return instead of a version-dependent pandas Index + times = times.tz_localize(None) + hrs = 1 / NS_PER_HR * ( + times.view(np.int64) - times.normalize().view(np.int64)) return np.array(hrs) @@ -1475,11 +1461,6 @@ def sun_rise_set_transit_geometric(times, latitude, longitude, declination, CRC Press (2012) """ - - # times must be localized - if not times.tz: - raise ValueError('times must be localized') - 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 diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index 709691c429..17870de27e 100644 --- a/pvlib/tests/test_solarposition.py +++ b/pvlib/tests/test_solarposition.py @@ -5,24 +5,19 @@ import numpy as np import pandas as pd -from .conftest import ( - assert_frame_equal, - assert_series_equal, - fail_on_pvlib_version, -) +from .conftest import assert_frame_equal, assert_series_equal from numpy.testing import assert_allclose import pytest from pvlib.location import Location from pvlib import solarposition, spa -from .conftest import ( - requires_ephem, requires_spa_c, requires_numba, requires_pandas_2_0 -) +from .conftest import requires_ephem, requires_spa_c, requires_numba + # setup times and locations to be tested. times = pd.date_range(start=datetime.datetime(2014, 6, 24), - end=datetime.datetime(2014, 6, 26), freq='15min') + end=datetime.datetime(2014, 6, 26), freq='15Min') tus = Location(32.2, -111, 'US/Arizona', 700) # no DST issues possible times_localized = times.tz_localize(tus.tz) @@ -143,8 +138,7 @@ def test_spa_python_numpy_physical_dst(expected_solpos, golden): assert_frame_equal(expected_solpos, ephem_data[expected_solpos.columns]) -@pytest.mark.parametrize('delta_t', [65.0, None, np.array([65, 65])]) -def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): +def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden): # solution from NREL SAP web calculator south = Location(-35.0, 0.0, tz='UTC') times = pd.DatetimeIndex([datetime.datetime(1996, 7, 5, 0), @@ -165,7 +159,7 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): result = solarposition.sun_rise_set_transit_spa(times, south.latitude, south.longitude, - delta_t=delta_t) + delta_t=65.0) result_rounded = pd.DataFrame(index=result.index) # need to iterate because to_datetime does not accept 2D data # the rounding fails on pandas < 0.17 @@ -177,7 +171,7 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): # test for Golden, CO compare to NREL SPA result = solarposition.sun_rise_set_transit_spa( expected_rise_set_spa.index, golden.latitude, golden.longitude, - delta_t=delta_t) + delta_t=65.0) # round to nearest minute result_rounded = pd.DataFrame(index=result.index) @@ -188,13 +182,6 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): assert_frame_equal(expected_rise_set_spa, result_rounded) -@fail_on_pvlib_version("0.12") -def test_sun_rise_set_transit_spa_renamed_kwarg_warning(): - # test to remember to remove renamed_kwarg_warning after the grace period - # and modify docs as needed - pass - - @requires_ephem def test_sun_rise_set_transit_ephem(expected_rise_set_ephem, golden): # test for Golden, CO compare to USNO, using local midnight @@ -489,20 +476,20 @@ def test_get_solarposition_altitude( @pytest.mark.parametrize("delta_t, method", [ - (None, 'nrel_numba'), - (67.0, 'nrel_numba'), - (np.array([67.0, 67.0]), 'nrel_numba'), - # minimize reloads, with numpy being last (None, 'nrel_numpy'), + pytest.param( + None, 'nrel_numba', + marks=[pytest.mark.xfail( + reason='spa.calculate_deltat not implemented for numba yet')]), + (67.0, 'nrel_numba'), (67.0, 'nrel_numpy'), - (np.array([67.0, 67.0]), 'nrel_numpy'), -]) + ]) def test_get_solarposition_deltat(delta_t, method, expected_solpos_multi, golden): times = pd.date_range(datetime.datetime(2003, 10, 17, 13, 30, 30), periods=2, freq='D', tz=golden.tz) with warnings.catch_warnings(): - # don't warn on method reload + # don't warn on method reload or num threads warnings.simplefilter("ignore") ephem_data = solarposition.get_solarposition(times, golden.latitude, golden.longitude, @@ -517,21 +504,6 @@ def test_get_solarposition_deltat(delta_t, method, expected_solpos_multi, assert_frame_equal(this_expected, ephem_data[this_expected.columns]) -@pytest.mark.parametrize("method", ['nrel_numba', 'nrel_numpy']) -def test_spa_array_delta_t(method): - # make sure that time-varying delta_t produces different answers - times = pd.to_datetime(["2019-01-01", "2019-01-01"]).tz_localize("UTC") - expected = pd.Series([257.26969492, 257.2701359], index=times) - with warnings.catch_warnings(): - # don't warn on method reload - warnings.simplefilter("ignore") - ephem_data = solarposition.get_solarposition(times, 40, -80, - delta_t=np.array([67, 0]), - method=method) - - assert_series_equal(ephem_data['azimuth'], expected, check_names=False) - - def test_get_solarposition_no_kwargs(expected_solpos, golden): times = pd.date_range(datetime.datetime(2003, 10, 17, 13, 30, 30), periods=1, freq='D', tz=golden.tz) @@ -556,27 +528,25 @@ def test_get_solarposition_method_pyephem(expected_solpos, golden): assert_frame_equal(expected_solpos, ephem_data[expected_solpos.columns]) -@pytest.mark.parametrize('delta_t', [64.0, None, np.array([64, 64])]) -def test_nrel_earthsun_distance(delta_t): +def test_nrel_earthsun_distance(): times = pd.DatetimeIndex([datetime.datetime(2015, 1, 2), datetime.datetime(2015, 8, 2)] ).tz_localize('MST') - result = solarposition.nrel_earthsun_distance(times, delta_t=delta_t) + result = solarposition.nrel_earthsun_distance(times, delta_t=64.0) expected = pd.Series(np.array([0.983289204601, 1.01486146446]), index=times) assert_series_equal(expected, result) - if np.size(delta_t) == 1: # skip the array delta_t - times = datetime.datetime(2015, 1, 2) - result = solarposition.nrel_earthsun_distance(times, delta_t=delta_t) - expected = pd.Series(np.array([0.983289204601]), - index=pd.DatetimeIndex([times, ])) - assert_series_equal(expected, result) + times = datetime.datetime(2015, 1, 2) + result = solarposition.nrel_earthsun_distance(times, delta_t=64.0) + expected = pd.Series(np.array([0.983289204601]), + index=pd.DatetimeIndex([times, ])) + assert_series_equal(expected, result) def test_equation_of_time(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="h") + freq="H") output = solarposition.spa_python(times, 37.8, -122.25, 100) eot = output['equation_of_time'] eot_rng = eot.max() - eot.min() # range of values, around 30 minutes @@ -588,7 +558,7 @@ def test_equation_of_time(): def test_declination(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="h") + freq="H") atmos_refract = 0.5667 delta_t = spa.calculate_deltat(times.year, times.month) unixtime = np.array([calendar.timegm(t.timetuple()) for t in times]) @@ -607,21 +577,20 @@ def test_declination(): def test_analytical_zenith(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="h").tz_localize('Etc/GMT+8') - times_utc = times.tz_convert('UTC') + freq="H").tz_localize('Etc/GMT+8') lat, lon = 37.8, -122.25 lat_rad = np.deg2rad(lat) output = solarposition.spa_python(times, lat, lon, 100) solar_zenith = np.deg2rad(output['zenith']) # spa # spencer - eot = solarposition.equation_of_time_spencer71(times_utc.dayofyear) + eot = solarposition.equation_of_time_spencer71(times.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_spencer71(times_utc.dayofyear) + decl = solarposition.declination_spencer71(times.dayofyear) zenith_1 = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) # pvcdrom and cooper - eot = solarposition.equation_of_time_pvcdrom(times_utc.dayofyear) + eot = solarposition.equation_of_time_pvcdrom(times.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_cooper69(times_utc.dayofyear) + decl = solarposition.declination_cooper69(times.dayofyear) zenith_2 = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) assert np.allclose(zenith_1, solar_zenith, atol=0.015) assert np.allclose(zenith_2, solar_zenith, atol=0.025) @@ -629,24 +598,23 @@ def test_analytical_zenith(): def test_analytical_azimuth(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="h").tz_localize('Etc/GMT+8') - times_utc = times.tz_convert('UTC') + freq="H").tz_localize('Etc/GMT+8') lat, lon = 37.8, -122.25 lat_rad = np.deg2rad(lat) output = solarposition.spa_python(times, lat, lon, 100) solar_azimuth = np.deg2rad(output['azimuth']) # spa solar_zenith = np.deg2rad(output['zenith']) # spencer - eot = solarposition.equation_of_time_spencer71(times_utc.dayofyear) + eot = solarposition.equation_of_time_spencer71(times.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_spencer71(times_utc.dayofyear) + decl = solarposition.declination_spencer71(times.dayofyear) zenith = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) azimuth_1 = solarposition.solar_azimuth_analytical(lat_rad, hour_angle, decl, zenith) # pvcdrom and cooper - eot = solarposition.equation_of_time_pvcdrom(times_utc.dayofyear) + eot = solarposition.equation_of_time_pvcdrom(times.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_cooper69(times_utc.dayofyear) + decl = solarposition.declination_cooper69(times.dayofyear) zenith = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) azimuth_2 = solarposition.solar_azimuth_analytical(lat_rad, hour_angle, decl, zenith) @@ -696,51 +664,21 @@ def test_hour_angle(): '2015-01-02 12:04:44.6340' ]).tz_localize('Etc/GMT+7') eot = np.array([-3.935172, -4.117227, -4.026295]) - hourangle = solarposition.hour_angle(times, longitude, eot) + hours = solarposition.hour_angle(times, longitude, eot) expected = (-70.682338, 70.72118825000001, 0.000801250) # FIXME: there are differences from expected NREL SPA calculator values # sunrise: 4 seconds, sunset: 48 seconds, transit: 0.2 seconds # but the differences may be due to other SPA input parameters - assert np.allclose(hourangle, expected) - - hours = solarposition._hour_angle_to_hours( - times, hourangle, longitude, eot) - result = solarposition._times_to_hours_after_local_midnight(times) - assert np.allclose(result, hours) - - result = solarposition._local_times_from_hours_since_midnight(times, hours) - assert result.equals(times) - - times = times.tz_convert(None) - with pytest.raises(ValueError): - solarposition.hour_angle(times, longitude, eot) - with pytest.raises(ValueError): - solarposition._hour_angle_to_hours(times, hourangle, longitude, eot) - with pytest.raises(ValueError): - solarposition._times_to_hours_after_local_midnight(times) - with pytest.raises(ValueError): - solarposition._local_times_from_hours_since_midnight(times, hours) - - -@fail_on_pvlib_version('0.12') -def test_hour_angle_renamed_kwarg_warning(): - # test to remember to remove renamed_kwarg_warning after the grace period - pass + assert np.allclose(hours, expected) def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst): """Test geometric calculations for sunrise, sunset, and transit times""" times = expected_rise_set_spa.index - times_utc = times.tz_convert('UTC') latitude = golden_mst.latitude longitude = golden_mst.longitude - eot = solarposition.equation_of_time_spencer71( - times_utc.dayofyear) # minutes - decl = solarposition.declination_spencer71(times_utc.dayofyear) # radians - with pytest.raises(ValueError): - solarposition.sun_rise_set_transit_geometric( - times.tz_convert(None), latitude=latitude, longitude=longitude, - declination=decl, equation_of_time=eot) + eot = solarposition.equation_of_time_spencer71(times.dayofyear) # minutes + decl = solarposition.declination_spencer71(times.dayofyear) # radians sr, ss, st = solarposition.sun_rise_set_transit_geometric( times, latitude=latitude, longitude=longitude, declination=decl, equation_of_time=eot) @@ -779,123 +717,6 @@ def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst): atol=np.abs(expected_transit_error).max()) -@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) -def test__datetime_to_unixtime(tz): - # for pandas < 2.0 where "unit" doesn't exist in pd.date_range. note that - # unit of ns is the only option in pandas<2, and the default in pandas 2.x - times = pd.date_range(start='2019-01-01', freq='h', periods=3, tz=tz) - expected = times.view(np.int64)/10**9 - actual = solarposition._datetime_to_unixtime(times) - np.testing.assert_equal(expected, actual) - - -@requires_pandas_2_0 -@pytest.mark.parametrize('unit', ['ns', 'us', 's']) -@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) -def test__datetime_to_unixtime_units(unit, tz): - kwargs = dict(start='2019-01-01', freq='h', periods=3) - times = pd.date_range(**kwargs, unit='ns', tz='UTC') - expected = times.view(np.int64)/10**9 - - times = pd.date_range(**kwargs, unit=unit, tz='UTC').tz_convert(tz) - actual = solarposition._datetime_to_unixtime(times) - np.testing.assert_equal(expected, actual) - - -@requires_pandas_2_0 -@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) -@pytest.mark.parametrize('method', [ - 'nrel_numpy', - 'ephemeris', - pytest.param('pyephem', marks=requires_ephem), - pytest.param('nrel_numba', marks=requires_numba), - pytest.param('nrel_c', marks=requires_spa_c), -]) -def test_get_solarposition_microsecond_index(method, tz): - # https://github.com/pvlib/pvlib-python/issues/1932 - - kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) - - index_ns = pd.date_range(unit='ns', **kwargs) - index_us = pd.date_range(unit='us', **kwargs) - - with warnings.catch_warnings(): - # don't warn on method reload - warnings.simplefilter("ignore") - - sp_ns = solarposition.get_solarposition(index_ns, 0, 0, method=method) - sp_us = solarposition.get_solarposition(index_us, 0, 0, method=method) - - assert_frame_equal(sp_ns, sp_us, check_index_type=False) - - -@requires_pandas_2_0 -@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) -def test_nrel_earthsun_distance_microsecond_index(tz): - # https://github.com/pvlib/pvlib-python/issues/1932 - - kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) - - index_ns = pd.date_range(unit='ns', **kwargs) - index_us = pd.date_range(unit='us', **kwargs) - - esd_ns = solarposition.nrel_earthsun_distance(index_ns) - esd_us = solarposition.nrel_earthsun_distance(index_us) - - assert_series_equal(esd_ns, esd_us, check_index_type=False) - - -@requires_pandas_2_0 -@pytest.mark.parametrize('tz', ['utc', 'US/Eastern']) -def test_hour_angle_microsecond_index(tz): - # https://github.com/pvlib/pvlib-python/issues/1932 - - kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) - - index_ns = pd.date_range(unit='ns', **kwargs) - index_us = pd.date_range(unit='us', **kwargs) - - ha_ns = solarposition.hour_angle(index_ns, -80, 0) - ha_us = solarposition.hour_angle(index_us, -80, 0) - - np.testing.assert_equal(ha_ns, ha_us) - - -@requires_pandas_2_0 -@pytest.mark.parametrize('tz', ['utc', 'US/Eastern']) -def test_rise_set_transit_spa_microsecond_index(tz): - # https://github.com/pvlib/pvlib-python/issues/1932 - - kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) - - index_ns = pd.date_range(unit='ns', **kwargs) - index_us = pd.date_range(unit='us', **kwargs) - - rst_ns = solarposition.sun_rise_set_transit_spa(index_ns, 40, -80) - rst_us = solarposition.sun_rise_set_transit_spa(index_us, 40, -80) - - assert_frame_equal(rst_ns, rst_us, check_index_type=False) - - -@requires_pandas_2_0 -@pytest.mark.parametrize('tz', ['utc', 'US/Eastern']) -def test_rise_set_transit_geometric_microsecond_index(tz): - # https://github.com/pvlib/pvlib-python/issues/1932 - - kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) - - index_ns = pd.date_range(unit='ns', **kwargs) - index_us = pd.date_range(unit='us', **kwargs) - - args = (40, -80, 0, 0) - rst_ns = solarposition.sun_rise_set_transit_geometric(index_ns, *args) - rst_us = solarposition.sun_rise_set_transit_geometric(index_us, *args) - - for times_ns, times_us in zip(rst_ns, rst_us): - # can't use a fancy assert function here since the units are different - assert all(times_ns == times_us) - - # put numba tests at end of file to minimize reloading @requires_numba @@ -903,7 +724,7 @@ def test_spa_python_numba_physical(expected_solpos, golden_mst): times = pd.date_range(datetime.datetime(2003, 10, 17, 12, 30, 30), periods=1, freq='D', tz=golden_mst.tz) with warnings.catch_warnings(): - # don't warn on method reload + # don't warn on method reload or num threads # ensure that numpy is the most recently used method so that # we can use the warns filter below warnings.simplefilter("ignore") @@ -930,7 +751,7 @@ def test_spa_python_numba_physical_dst(expected_solpos, golden): periods=1, freq='D', tz=golden.tz) with warnings.catch_warnings(): - # don't warn on method reload + # don't warn on method reload or num threads warnings.simplefilter("ignore") ephem_data = solarposition.spa_python(times, golden.latitude, golden.longitude, pressure=82000, From 308e5f6aa1aba1a727a7291c36b42bce2c7c128d Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:43:10 +0200 Subject: [PATCH 20/21] Had to use main instead of master xd --- pvlib/solarposition.py | 87 ++++++++++---------- pvlib/tests/test_solarposition.py | 128 ++++++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 47 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index cdcacd7ec6..d01aa1f251 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -22,15 +22,11 @@ import pandas as pd import scipy.optimize as so import warnings -import datetime from pvlib import atmosphere from pvlib.tools import datetime_to_djd, djd_to_datetime -NS_PER_HR = 1.e9 * 3600. # nanoseconds per hour - - def get_solarposition(time, latitude, longitude, altitude=None, pressure=None, method='nrel_numpy', @@ -51,13 +47,13 @@ def get_solarposition(time, latitude, longitude, Longitude in decimal degrees. Positive east of prime meridian, negative to west. - altitude : None or float, default None - If None, computed from pressure. Assumed to be 0 m - if pressure is also None. + altitude : float, optional + If not specified, computed from ``pressure``. Assumed to be 0 m + if ``pressure`` is not supplied. - pressure : None or float, default None - If None, computed from altitude. Assumed to be 101325 Pa - if altitude is also None. + pressure : float, optional + If not specified, computed from ``altitude``. Assumed to be 101325 Pa + if ``altitude`` is not supplied. method : string, default 'nrel_numpy' 'nrel_numpy' uses an implementation of the NREL SPA algorithm @@ -89,7 +85,7 @@ def get_solarposition(time, latitude, longitude, solar radiation applications. Solar Energy, vol. 81, no. 6, p. 838, 2007. - .. [3] NREL SPA code: http://rredc.nrel.gov/solar/codesandalgorithms/spa/ + .. [3] NREL SPA code: https://midcdmz.nrel.gov/spa/ """ if altitude is None and pressure is None: @@ -132,7 +128,7 @@ def get_solarposition(time, latitude, longitude, def spa_c(time, latitude, longitude, pressure=101325, altitude=0, temperature=12, delta_t=67.0, raw_spa_output=False): - """ + r""" Calculate the solar position using the C implementation of the NREL SPA code. @@ -161,7 +157,7 @@ def spa_c(time, latitude, longitude, pressure=101325, altitude=0, Temperature in C delta_t : float, default 67.0 Difference between terrestrial time and UT1. - USNO has previous values and predictions. + USNO has previous values and predictions [3]_. raw_spa_output : bool, default False If true, returns the raw SPA output. @@ -177,17 +173,16 @@ def spa_c(time, latitude, longitude, pressure=101325, altitude=0, References ---------- - .. [1] NREL SPA reference: - http://rredc.nrel.gov/solar/codesandalgorithms/spa/ - NREL SPA C files: https://midcdmz.nrel.gov/spa/ + .. [1] NREL SPA reference: https://midcdmz.nrel.gov/spa/ Note: The ``timezone`` field in the SPA C files is replaced with ``time_zone`` to avoid a nameclash with the function ``__timezone`` that is redefined by Python>=3.5. This issue is `Python bug 24643 `_. - .. [2] USNO delta T: - http://www.usno.navy.mil/USNO/earth-orientation/eo-products/long-term + .. [2] Delta T: https://en.wikipedia.org/wiki/%CE%94T_(timekeeping) + + .. [3] USNO delta T: https://maia.usno.navy.mil/products/deltaT See also -------- @@ -274,6 +269,19 @@ def _spa_python_import(how): return spa +def _datetime_to_unixtime(dtindex): + # convert a pandas datetime index to unixtime, making sure to handle + # different pandas units (ns, us, etc) and time zones correctly + if dtindex.tz is not None: + # epoch is 1970-01-01 00:00 UTC, but we need to match the input tz + # for compatibility with older pandas versions (e.g. v1.3.5) + epoch = pd.Timestamp("1970-01-01", tz="UTC").tz_convert(dtindex.tz) + else: + epoch = pd.Timestamp("1970-01-01") + + return np.array((dtindex - epoch) / pd.Timedelta("1s")) + + def spa_python(time, latitude, longitude, altitude=0, pressure=101325, temperature=12, delta_t=67.0, atmos_refract=None, how='numpy', numthreads=4): @@ -312,7 +320,7 @@ def spa_python(time, latitude, longitude, *Note: delta_t = None will break code using nrel_numba, this will be fixed in a future version.* The USNO has historical and forecasted delta_t [3]_. - atmos_refrac : None or float, optional, default None + atmos_refrac : float, optional The approximate atmospheric refraction (in degrees) at sunrise and sunset. how : str, optional, default 'numpy' @@ -344,7 +352,7 @@ def spa_python(time, latitude, longitude, 2007. .. [3] USNO delta T: - http://www.usno.navy.mil/USNO/earth-orientation/eo-products/long-term + https://maia.usno.navy.mil/products/deltaT See also -------- @@ -366,7 +374,7 @@ def spa_python(time, latitude, longitude, except (TypeError, ValueError): time = pd.DatetimeIndex([time, ]) - unixtime = np.array(time.view(np.int64)/10**9) + unixtime = _datetime_to_unixtime(time) spa = _spa_python_import(how) @@ -445,7 +453,7 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', # must convert to midnight UTC on day of interest utcday = pd.DatetimeIndex(times.date).tz_localize('UTC') - unixtime = np.array(utcday.view(np.int64)/10**9) + unixtime = _datetime_to_unixtime(utcday) spa = _spa_python_import(how) @@ -578,7 +586,7 @@ def sun_rise_set_transit_ephem(times, latitude, longitude, # older versions of pyephem ignore timezone when converting to its # internal datetime format, so convert to UTC here to support # all versions. GH #1449 - obs.date = ephem.Date(thetime.astimezone(datetime.timezone.utc)) + obs.date = ephem.Date(thetime.astimezone(dt.timezone.utc)) sunrise.append(_ephem_to_timezone(rising(sun), tzinfo)) sunset.append(_ephem_to_timezone(setting(sun), tzinfo)) trans.append(_ephem_to_timezone(transit(sun), tzinfo)) @@ -832,7 +840,7 @@ def ephemeris(time, latitude, longitude, pressure=101325, temperature=12): # Calculate refraction correction Elevation = SunEl TanEl = pd.Series(np.tan(np.radians(Elevation)), index=time_utc) - Refract = pd.Series(0, index=time_utc) + Refract = pd.Series(0., index=time_utc) Refract[(Elevation > 5) & (Elevation <= 85)] = ( 58.1/TanEl - 0.07/(TanEl**3) + 8.6e-05/(TanEl**5)) @@ -1001,7 +1009,7 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4): except (TypeError, ValueError): time = pd.DatetimeIndex([time, ]) - unixtime = np.array(time.view(np.int64)/10**9) + unixtime = _datetime_to_unixtime(time) spa = _spa_python_import(how) @@ -1327,9 +1335,9 @@ def solar_zenith_analytical(latitude, hourangle, declination): .. [4] `Wikipedia: Solar Zenith Angle `_ - .. [5] `PVCDROM: Sun's Position - `_ + .. [5] `PVCDROM: Elevation Angle + `_ See Also -------- @@ -1378,11 +1386,13 @@ def hour_angle(times, longitude, equation_of_time): equation_of_time_spencer71 equation_of_time_pvcdrom """ - naive_times = times.tz_localize(None) # naive but still localized # hours - timezone = (times - normalized_times) - (naive_times - times) - hrs_minus_tzs = 1 / NS_PER_HR * ( - 2 * times.view(np.int64) - times.normalize().view(np.int64) - - naive_times.view(np.int64)) + if times.tz is None: + times = times.tz_localize('utc') + tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 + + hrs_minus_tzs = (times - times.normalize()) / pd.Timedelta('1h') - tzs + # ensure array return instead of a version-dependent pandas Index return np.asarray( 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.) @@ -1390,9 +1400,9 @@ def hour_angle(times, longitude, equation_of_time): def _hour_angle_to_hours(times, hourangle, 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 / NS_PER_HR * ( - naive_times.view(np.int64) - times.view(np.int64)) + if times.tz is None: + times = times.tz_localize('utc') + tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 hours = (hourangle - longitude - equation_of_time / 4.) / 15. + 12. + tzs return np.asarray(hours) @@ -1406,16 +1416,13 @@ def _local_times_from_hours_since_midnight(times, hours): # normalize local, naive times to previous midnight and add the hours until # sunrise, sunset, and transit return pd.DatetimeIndex( - (naive_times.normalize().view(np.int64) + - (hours * NS_PER_HR).astype(np.int64)).astype('datetime64[ns]'), - tz=tz_info) + naive_times.normalize() + pd.to_timedelta(hours, unit='h'), tz=tz_info) def _times_to_hours_after_local_midnight(times): """convert local pandas datetime indices to array of hours as floats""" times = times.tz_localize(None) - hrs = 1 / NS_PER_HR * ( - times.view(np.int64) - times.normalize().view(np.int64)) + hrs = (times - times.normalize()) / pd.Timedelta('1h') return np.array(hrs) diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index 17870de27e..472383acce 100644 --- a/pvlib/tests/test_solarposition.py +++ b/pvlib/tests/test_solarposition.py @@ -12,12 +12,13 @@ from pvlib.location import Location from pvlib import solarposition, spa -from .conftest import requires_ephem, requires_spa_c, requires_numba - +from .conftest import ( + requires_ephem, requires_spa_c, requires_numba, requires_pandas_2_0 +) # setup times and locations to be tested. times = pd.date_range(start=datetime.datetime(2014, 6, 24), - end=datetime.datetime(2014, 6, 26), freq='15Min') + end=datetime.datetime(2014, 6, 26), freq='15min') tus = Location(32.2, -111, 'US/Arizona', 700) # no DST issues possible times_localized = times.tz_localize(tus.tz) @@ -546,7 +547,7 @@ def test_nrel_earthsun_distance(): def test_equation_of_time(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="H") + freq="h") output = solarposition.spa_python(times, 37.8, -122.25, 100) eot = output['equation_of_time'] eot_rng = eot.max() - eot.min() # range of values, around 30 minutes @@ -558,7 +559,7 @@ def test_equation_of_time(): def test_declination(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="H") + freq="h") atmos_refract = 0.5667 delta_t = spa.calculate_deltat(times.year, times.month) unixtime = np.array([calendar.timegm(t.timetuple()) for t in times]) @@ -577,7 +578,7 @@ def test_declination(): def test_analytical_zenith(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="H").tz_localize('Etc/GMT+8') + freq="h").tz_localize('Etc/GMT+8') lat, lon = 37.8, -122.25 lat_rad = np.deg2rad(lat) output = solarposition.spa_python(times, lat, lon, 100) @@ -598,7 +599,7 @@ def test_analytical_zenith(): def test_analytical_azimuth(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", - freq="H").tz_localize('Etc/GMT+8') + freq="h").tz_localize('Etc/GMT+8') lat, lon = 37.8, -122.25 lat_rad = np.deg2rad(lat) output = solarposition.spa_python(times, lat, lon, 100) @@ -717,6 +718,119 @@ def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst): atol=np.abs(expected_transit_error).max()) +@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +def test__datetime_to_unixtime(tz): + # for pandas < 2.0 where "unit" doesn't exist in pd.date_range. note that + # unit of ns is the only option in pandas<2, and the default in pandas 2.x + times = pd.date_range(start='2019-01-01', freq='h', periods=3, tz=tz) + expected = times.view(np.int64)/10**9 + actual = solarposition._datetime_to_unixtime(times) + np.testing.assert_equal(expected, actual) + + +@requires_pandas_2_0 +@pytest.mark.parametrize('unit', ['ns', 'us', 's']) +@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +def test__datetime_to_unixtime_units(unit, tz): + kwargs = dict(start='2019-01-01', freq='h', periods=3) + times = pd.date_range(**kwargs, unit='ns', tz='UTC') + expected = times.view(np.int64)/10**9 + + times = pd.date_range(**kwargs, unit=unit, tz='UTC').tz_convert(tz) + actual = solarposition._datetime_to_unixtime(times) + np.testing.assert_equal(expected, actual) + + +@requires_pandas_2_0 +@pytest.mark.parametrize('method', [ + 'nrel_numpy', + 'ephemeris', + pytest.param('pyephem', marks=requires_ephem), + pytest.param('nrel_numba', marks=requires_numba), + pytest.param('nrel_c', marks=requires_spa_c), +]) +@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +def test_get_solarposition_microsecond_index(method, tz): + # https://github.com/pvlib/pvlib-python/issues/1932 + + kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) + + index_ns = pd.date_range(unit='ns', **kwargs) + index_us = pd.date_range(unit='us', **kwargs) + + sp_ns = solarposition.get_solarposition(index_ns, 40, -80, method=method) + sp_us = solarposition.get_solarposition(index_us, 40, -80, method=method) + + assert_frame_equal(sp_ns, sp_us, check_index_type=False) + + +@requires_pandas_2_0 +@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +def test_nrel_earthsun_distance_microsecond_index(tz): + # https://github.com/pvlib/pvlib-python/issues/1932 + + kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) + + index_ns = pd.date_range(unit='ns', **kwargs) + index_us = pd.date_range(unit='us', **kwargs) + + esd_ns = solarposition.nrel_earthsun_distance(index_ns) + esd_us = solarposition.nrel_earthsun_distance(index_us) + + assert_series_equal(esd_ns, esd_us, check_index_type=False) + + +@requires_pandas_2_0 +@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +def test_hour_angle_microsecond_index(tz): + # https://github.com/pvlib/pvlib-python/issues/1932 + + kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) + + index_ns = pd.date_range(unit='ns', **kwargs) + index_us = pd.date_range(unit='us', **kwargs) + + ha_ns = solarposition.hour_angle(index_ns, -80, 0) + ha_us = solarposition.hour_angle(index_us, -80, 0) + + np.testing.assert_equal(ha_ns, ha_us) + + +@requires_pandas_2_0 +@pytest.mark.parametrize('tz', ['utc', 'US/Eastern']) +def test_rise_set_transit_spa_microsecond_index(tz): + # https://github.com/pvlib/pvlib-python/issues/1932 + + kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) + + index_ns = pd.date_range(unit='ns', **kwargs) + index_us = pd.date_range(unit='us', **kwargs) + + rst_ns = solarposition.sun_rise_set_transit_spa(index_ns, 40, -80) + rst_us = solarposition.sun_rise_set_transit_spa(index_us, 40, -80) + + assert_frame_equal(rst_ns, rst_us, check_index_type=False) + + +@requires_pandas_2_0 +@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +def test_rise_set_transit_geometric_microsecond_index(tz): + # https://github.com/pvlib/pvlib-python/issues/1932 + + kwargs = dict(start='2019-01-01', freq='h', periods=24, tz=tz) + + index_ns = pd.date_range(unit='ns', **kwargs) + index_us = pd.date_range(unit='us', **kwargs) + + args = (40, -80, 0, 0) + rst_ns = solarposition.sun_rise_set_transit_geometric(index_ns, *args) + rst_us = solarposition.sun_rise_set_transit_geometric(index_us, *args) + + for times_ns, times_us in zip(rst_ns, rst_us): + # can't use a fancy assert function here since the units are different + assert all(times_ns == times_us) + + # put numba tests at end of file to minimize reloading @requires_numba From 3cd34f3397f9b3d299727f57c296d66a4ff110d7 Mon Sep 17 00:00:00 2001 From: echedey-ls <80125792+echedey-ls@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:50:41 +0200 Subject: [PATCH 21/21] :sob: --- pvlib/solarposition.py | 104 ++++++++++++++------------ pvlib/tests/test_solarposition.py | 120 +++++++++++++++++++++--------- 2 files changed, 139 insertions(+), 85 deletions(-) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index d01aa1f251..2ddfe5082c 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -23,7 +23,7 @@ import scipy.optimize as so import warnings -from pvlib import atmosphere +from pvlib import atmosphere, tools from pvlib.tools import datetime_to_djd, djd_to_datetime @@ -199,11 +199,7 @@ def spa_c(time, latitude, longitude, pressure=101325, altitude=0, raise ImportError('Could not import built-in SPA calculator. ' + 'You may need to recompile the SPA code.') - # if localized, convert to UTC. otherwise, assume UTC. - try: - time_utc = time.tz_convert('UTC') - except TypeError: - time_utc = time + time_utc = tools._pandas_to_utc(time) spa_out = [] @@ -312,13 +308,11 @@ def spa_python(time, latitude, longitude, avg. yearly air pressure in Pascals. temperature : int or float, optional, default 12 avg. yearly air temperature in degrees C. - delta_t : float, optional, default 67.0 + delta_t : float or array, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat using time.year and time.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. - *Note: delta_t = None will break code using nrel_numba, - this will be fixed in a future version.* The USNO has historical and forecasted delta_t [3]_. atmos_refrac : float, optional The approximate atmospheric refraction (in degrees) @@ -378,7 +372,9 @@ def spa_python(time, latitude, longitude, spa = _spa_python_import(how) - delta_t = delta_t or spa.calculate_deltat(time.year, time.month) + if delta_t is None: + time_utc = tools._pandas_to_utc(time) + delta_t = spa.calculate_deltat(time_utc.year, time_utc.month) app_zenith, zenith, app_elevation, elevation, azimuth, eot = \ spa.solar_position(unixtime, lat, lon, elev, pressure, temperature, @@ -418,13 +414,11 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', Options are 'numpy' or 'numba'. If numba >= 0.17.0 is installed, how='numba' will compile the spa functions to machine code and run them multithreaded. - delta_t : float, optional, default 67.0 + delta_t : float or array, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat using times.year and times.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. - *Note: delta_t = None will break code using nrel_numba, - this will be fixed in a future version.* numthreads : int, optional, default 4 Number of threads to use if how == 'numba'. @@ -452,12 +446,13 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', raise ValueError('times must be localized') # must convert to midnight UTC on day of interest - utcday = pd.DatetimeIndex(times.date).tz_localize('UTC') - unixtime = _datetime_to_unixtime(utcday) + times_utc = times.tz_convert('UTC') + unixtime = _datetime_to_unixtime(times_utc.normalize()) spa = _spa_python_import(how) - delta_t = delta_t or spa.calculate_deltat(times.year, times.month) + if delta_t is None: + delta_t = spa.calculate_deltat(times_utc.year, times_utc.month) transit, sunrise, sunset = spa.transit_sunrise_sunset( unixtime, lat, lon, delta_t, numthreads) @@ -581,12 +576,11 @@ def sun_rise_set_transit_ephem(times, latitude, longitude, sunrise = [] sunset = [] trans = [] - for thetime in times: - thetime = thetime.to_pydatetime() + for thetime in tools._pandas_to_utc(times): # older versions of pyephem ignore timezone when converting to its # internal datetime format, so convert to UTC here to support # all versions. GH #1449 - obs.date = ephem.Date(thetime.astimezone(dt.timezone.utc)) + obs.date = ephem.Date(thetime) sunrise.append(_ephem_to_timezone(rising(sun), tzinfo)) sunset.append(_ephem_to_timezone(setting(sun), tzinfo)) trans.append(_ephem_to_timezone(transit(sun), tzinfo)) @@ -644,11 +638,7 @@ def pyephem(time, latitude, longitude, altitude=0, pressure=101325, except ImportError: raise ImportError('PyEphem must be installed') - # if localized, convert to UTC. otherwise, assume UTC. - try: - time_utc = time.tz_convert('UTC') - except TypeError: - time_utc = time + time_utc = tools._pandas_to_utc(time) sun_coords = pd.DataFrame(index=time) @@ -717,11 +707,11 @@ def ephemeris(time, latitude, longitude, pressure=101325, temperature=12): * apparent_elevation : apparent sun elevation accounting for atmospheric refraction. + This is the complement of the apparent zenith angle. * elevation : actual elevation (not accounting for refraction) of the sun in decimal degrees, 0 = on horizon. The complement of the zenith angle. * azimuth : Azimuth of the sun in decimal degrees East of North. - This is the complement of the apparent zenith angle. * apparent_zenith : apparent sun zenith accounting for atmospheric refraction. * zenith : Solar zenith angle @@ -765,11 +755,7 @@ def ephemeris(time, latitude, longitude, pressure=101325, temperature=12): # the SPA algorithm needs time to be expressed in terms of # decimal UTC hours of the day of the year. - # if localized, convert to UTC. otherwise, assume UTC. - try: - time_utc = time.tz_convert('UTC') - except TypeError: - time_utc = time + time_utc = tools._pandas_to_utc(time) # strip out the day of the year and calculate the decimal hour DayOfYear = time_utc.dayofyear @@ -956,7 +942,10 @@ def pyephem_earthsun_distance(time): sun = ephem.Sun() earthsun = [] - for thetime in time: + for thetime in tools._pandas_to_utc(time): + # older versions of pyephem ignore timezone when converting to its + # internal datetime format, so convert to UTC here to support + # all versions. GH #1449 sun.compute(ephem.Date(thetime)) earthsun.append(sun.earth_distance) @@ -980,13 +969,11 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4): is installed, how='numba' will compile the spa functions to machine code and run them multithreaded. - delta_t : float, optional, default 67.0 + delta_t : float or array, optional, default 67.0 Difference between terrestrial time and UT1. If delta_t is None, uses spa.calculate_deltat using time.year and time.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. - *Note: delta_t = None will break code using nrel_numba, - this will be fixed in a future version.* numthreads : int, optional, default 4 Number of threads to use if how == 'numba'. @@ -1013,7 +1000,9 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4): spa = _spa_python_import(how) - delta_t = delta_t or spa.calculate_deltat(time.year, time.month) + if delta_t is None: + time_utc = tools._pandas_to_utc(time) + delta_t = spa.calculate_deltat(time_utc.year, time_utc.month) dist = spa.earthsun_distance(unixtime, delta_t, numthreads) @@ -1386,22 +1375,26 @@ def hour_angle(times, longitude, equation_of_time): equation_of_time_spencer71 equation_of_time_pvcdrom """ + + # times must be localized + if not times.tz: + raise ValueError('times must be localized') + # hours - timezone = (times - normalized_times) - (naive_times - times) - if times.tz is None: - times = times.tz_localize('utc') tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 - hrs_minus_tzs = (times - times.normalize()) / pd.Timedelta('1h') - tzs + hrs_minus_tzs = _times_to_hours_after_local_midnight(times) - tzs - # ensure array return instead of a version-dependent pandas Index - return np.asarray( - 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.) + return 15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4. def _hour_angle_to_hours(times, hourangle, longitude, equation_of_time): """converts hour angles in degrees to hours as a numpy array""" - if times.tz is None: - times = times.tz_localize('utc') + + # times must be localized + if not times.tz: + raise ValueError('times must be localized') + tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 hours = (hourangle - longitude - equation_of_time / 4.) / 15. + 12. + tzs return np.asarray(hours) @@ -1411,18 +1404,26 @@ def _local_times_from_hours_since_midnight(times, hours): """ converts hours since midnight from an array of floats to localized times """ - tz_info = times.tz # pytz timezone info - naive_times = times.tz_localize(None) # naive but still localized - # normalize local, naive times to previous midnight and add the hours until + + # times must be localized + if not times.tz: + raise ValueError('times must be localized') + + # normalize local times to previous local midnight and add the hours until # sunrise, sunset, and transit - return pd.DatetimeIndex( - naive_times.normalize() + pd.to_timedelta(hours, unit='h'), tz=tz_info) + return times.normalize() + pd.to_timedelta(hours, unit='h') def _times_to_hours_after_local_midnight(times): """convert local pandas datetime indices to array of hours as floats""" - times = times.tz_localize(None) + + # times must be localized + if not times.tz: + raise ValueError('times must be localized') + hrs = (times - times.normalize()) / pd.Timedelta('1h') + + # ensure array return instead of a version-dependent pandas Index return np.array(hrs) @@ -1468,6 +1469,11 @@ def sun_rise_set_transit_geometric(times, latitude, longitude, declination, CRC Press (2012) """ + + # times must be localized + if not times.tz: + raise ValueError('times must be localized') + 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 diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index 472383acce..9a69673d6c 100644 --- a/pvlib/tests/test_solarposition.py +++ b/pvlib/tests/test_solarposition.py @@ -139,7 +139,8 @@ def test_spa_python_numpy_physical_dst(expected_solpos, golden): assert_frame_equal(expected_solpos, ephem_data[expected_solpos.columns]) -def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden): +@pytest.mark.parametrize('delta_t', [65.0, None, np.array([65, 65])]) +def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): # solution from NREL SAP web calculator south = Location(-35.0, 0.0, tz='UTC') times = pd.DatetimeIndex([datetime.datetime(1996, 7, 5, 0), @@ -160,7 +161,7 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden): result = solarposition.sun_rise_set_transit_spa(times, south.latitude, south.longitude, - delta_t=65.0) + delta_t=delta_t) result_rounded = pd.DataFrame(index=result.index) # need to iterate because to_datetime does not accept 2D data # the rounding fails on pandas < 0.17 @@ -172,7 +173,7 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden): # test for Golden, CO compare to NREL SPA result = solarposition.sun_rise_set_transit_spa( expected_rise_set_spa.index, golden.latitude, golden.longitude, - delta_t=65.0) + delta_t=delta_t) # round to nearest minute result_rounded = pd.DataFrame(index=result.index) @@ -477,20 +478,20 @@ def test_get_solarposition_altitude( @pytest.mark.parametrize("delta_t, method", [ - (None, 'nrel_numpy'), - pytest.param( - None, 'nrel_numba', - marks=[pytest.mark.xfail( - reason='spa.calculate_deltat not implemented for numba yet')]), + (None, 'nrel_numba'), (67.0, 'nrel_numba'), + (np.array([67.0, 67.0]), 'nrel_numba'), + # minimize reloads, with numpy being last + (None, 'nrel_numpy'), (67.0, 'nrel_numpy'), - ]) + (np.array([67.0, 67.0]), 'nrel_numpy'), +]) def test_get_solarposition_deltat(delta_t, method, expected_solpos_multi, golden): times = pd.date_range(datetime.datetime(2003, 10, 17, 13, 30, 30), periods=2, freq='D', tz=golden.tz) with warnings.catch_warnings(): - # don't warn on method reload or num threads + # don't warn on method reload warnings.simplefilter("ignore") ephem_data = solarposition.get_solarposition(times, golden.latitude, golden.longitude, @@ -505,6 +506,21 @@ def test_get_solarposition_deltat(delta_t, method, expected_solpos_multi, assert_frame_equal(this_expected, ephem_data[this_expected.columns]) +@pytest.mark.parametrize("method", ['nrel_numba', 'nrel_numpy']) +def test_spa_array_delta_t(method): + # make sure that time-varying delta_t produces different answers + times = pd.to_datetime(["2019-01-01", "2019-01-01"]).tz_localize("UTC") + expected = pd.Series([257.26969492, 257.2701359], index=times) + with warnings.catch_warnings(): + # don't warn on method reload + warnings.simplefilter("ignore") + ephem_data = solarposition.get_solarposition(times, 40, -80, + delta_t=np.array([67, 0]), + method=method) + + assert_series_equal(ephem_data['azimuth'], expected, check_names=False) + + def test_get_solarposition_no_kwargs(expected_solpos, golden): times = pd.date_range(datetime.datetime(2003, 10, 17, 13, 30, 30), periods=1, freq='D', tz=golden.tz) @@ -529,20 +545,22 @@ def test_get_solarposition_method_pyephem(expected_solpos, golden): assert_frame_equal(expected_solpos, ephem_data[expected_solpos.columns]) -def test_nrel_earthsun_distance(): +@pytest.mark.parametrize('delta_t', [64.0, None, np.array([64, 64])]) +def test_nrel_earthsun_distance(delta_t): times = pd.DatetimeIndex([datetime.datetime(2015, 1, 2), datetime.datetime(2015, 8, 2)] ).tz_localize('MST') - result = solarposition.nrel_earthsun_distance(times, delta_t=64.0) + result = solarposition.nrel_earthsun_distance(times, delta_t=delta_t) expected = pd.Series(np.array([0.983289204601, 1.01486146446]), index=times) assert_series_equal(expected, result) - times = datetime.datetime(2015, 1, 2) - result = solarposition.nrel_earthsun_distance(times, delta_t=64.0) - expected = pd.Series(np.array([0.983289204601]), - index=pd.DatetimeIndex([times, ])) - assert_series_equal(expected, result) + if np.size(delta_t) == 1: # skip the array delta_t + times = datetime.datetime(2015, 1, 2) + result = solarposition.nrel_earthsun_distance(times, delta_t=delta_t) + expected = pd.Series(np.array([0.983289204601]), + index=pd.DatetimeIndex([times, ])) + assert_series_equal(expected, result) def test_equation_of_time(): @@ -579,19 +597,20 @@ def test_declination(): def test_analytical_zenith(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", freq="h").tz_localize('Etc/GMT+8') + times_utc = times.tz_convert('UTC') lat, lon = 37.8, -122.25 lat_rad = np.deg2rad(lat) output = solarposition.spa_python(times, lat, lon, 100) solar_zenith = np.deg2rad(output['zenith']) # spa # spencer - eot = solarposition.equation_of_time_spencer71(times.dayofyear) + eot = solarposition.equation_of_time_spencer71(times_utc.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_spencer71(times.dayofyear) + decl = solarposition.declination_spencer71(times_utc.dayofyear) zenith_1 = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) # pvcdrom and cooper - eot = solarposition.equation_of_time_pvcdrom(times.dayofyear) + eot = solarposition.equation_of_time_pvcdrom(times_utc.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_cooper69(times.dayofyear) + decl = solarposition.declination_cooper69(times_utc.dayofyear) zenith_2 = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) assert np.allclose(zenith_1, solar_zenith, atol=0.015) assert np.allclose(zenith_2, solar_zenith, atol=0.025) @@ -600,22 +619,23 @@ def test_analytical_zenith(): def test_analytical_azimuth(): times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00", freq="h").tz_localize('Etc/GMT+8') + times_utc = times.tz_convert('UTC') lat, lon = 37.8, -122.25 lat_rad = np.deg2rad(lat) output = solarposition.spa_python(times, lat, lon, 100) solar_azimuth = np.deg2rad(output['azimuth']) # spa solar_zenith = np.deg2rad(output['zenith']) # spencer - eot = solarposition.equation_of_time_spencer71(times.dayofyear) + eot = solarposition.equation_of_time_spencer71(times_utc.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_spencer71(times.dayofyear) + decl = solarposition.declination_spencer71(times_utc.dayofyear) zenith = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) azimuth_1 = solarposition.solar_azimuth_analytical(lat_rad, hour_angle, decl, zenith) # pvcdrom and cooper - eot = solarposition.equation_of_time_pvcdrom(times.dayofyear) + eot = solarposition.equation_of_time_pvcdrom(times_utc.dayofyear) hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot)) - decl = solarposition.declination_cooper69(times.dayofyear) + decl = solarposition.declination_cooper69(times_utc.dayofyear) zenith = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl) azimuth_2 = solarposition.solar_azimuth_analytical(lat_rad, hour_angle, decl, zenith) @@ -665,21 +685,45 @@ def test_hour_angle(): '2015-01-02 12:04:44.6340' ]).tz_localize('Etc/GMT+7') eot = np.array([-3.935172, -4.117227, -4.026295]) - hours = solarposition.hour_angle(times, longitude, eot) + hourangle = solarposition.hour_angle(times, longitude, eot) expected = (-70.682338, 70.72118825000001, 0.000801250) # FIXME: there are differences from expected NREL SPA calculator values # 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) + assert np.allclose(hourangle, expected) + + hours = solarposition._hour_angle_to_hours( + times, hourangle, longitude, eot) + result = solarposition._times_to_hours_after_local_midnight(times) + assert np.allclose(result, hours) + + result = solarposition._local_times_from_hours_since_midnight(times, hours) + assert result.equals(times) + + times = times.tz_convert(None) + with pytest.raises(ValueError): + solarposition.hour_angle(times, longitude, eot) + with pytest.raises(ValueError): + solarposition._hour_angle_to_hours(times, hourangle, longitude, eot) + with pytest.raises(ValueError): + solarposition._times_to_hours_after_local_midnight(times) + with pytest.raises(ValueError): + solarposition._local_times_from_hours_since_midnight(times, hours) def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst): """Test geometric calculations for sunrise, sunset, and transit times""" times = expected_rise_set_spa.index + times_utc = times.tz_convert('UTC') latitude = golden_mst.latitude longitude = golden_mst.longitude - eot = solarposition.equation_of_time_spencer71(times.dayofyear) # minutes - decl = solarposition.declination_spencer71(times.dayofyear) # radians + eot = solarposition.equation_of_time_spencer71( + times_utc.dayofyear) # minutes + decl = solarposition.declination_spencer71(times_utc.dayofyear) # radians + with pytest.raises(ValueError): + solarposition.sun_rise_set_transit_geometric( + times.tz_convert(None), latitude=latitude, longitude=longitude, + declination=decl, equation_of_time=eot) sr, ss, st = solarposition.sun_rise_set_transit_geometric( times, latitude=latitude, longitude=longitude, declination=decl, equation_of_time=eot) @@ -742,6 +786,7 @@ def test__datetime_to_unixtime_units(unit, tz): @requires_pandas_2_0 +@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) @pytest.mark.parametrize('method', [ 'nrel_numpy', 'ephemeris', @@ -749,7 +794,6 @@ def test__datetime_to_unixtime_units(unit, tz): pytest.param('nrel_numba', marks=requires_numba), pytest.param('nrel_c', marks=requires_spa_c), ]) -@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) def test_get_solarposition_microsecond_index(method, tz): # https://github.com/pvlib/pvlib-python/issues/1932 @@ -758,8 +802,12 @@ def test_get_solarposition_microsecond_index(method, tz): index_ns = pd.date_range(unit='ns', **kwargs) index_us = pd.date_range(unit='us', **kwargs) - sp_ns = solarposition.get_solarposition(index_ns, 40, -80, method=method) - sp_us = solarposition.get_solarposition(index_us, 40, -80, method=method) + with warnings.catch_warnings(): + # don't warn on method reload + warnings.simplefilter("ignore") + + sp_ns = solarposition.get_solarposition(index_ns, 0, 0, method=method) + sp_us = solarposition.get_solarposition(index_us, 0, 0, method=method) assert_frame_equal(sp_ns, sp_us, check_index_type=False) @@ -781,7 +829,7 @@ def test_nrel_earthsun_distance_microsecond_index(tz): @requires_pandas_2_0 -@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +@pytest.mark.parametrize('tz', ['utc', 'US/Eastern']) def test_hour_angle_microsecond_index(tz): # https://github.com/pvlib/pvlib-python/issues/1932 @@ -813,7 +861,7 @@ def test_rise_set_transit_spa_microsecond_index(tz): @requires_pandas_2_0 -@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) +@pytest.mark.parametrize('tz', ['utc', 'US/Eastern']) def test_rise_set_transit_geometric_microsecond_index(tz): # https://github.com/pvlib/pvlib-python/issues/1932 @@ -838,7 +886,7 @@ def test_spa_python_numba_physical(expected_solpos, golden_mst): times = pd.date_range(datetime.datetime(2003, 10, 17, 12, 30, 30), periods=1, freq='D', tz=golden_mst.tz) with warnings.catch_warnings(): - # don't warn on method reload or num threads + # don't warn on method reload # ensure that numpy is the most recently used method so that # we can use the warns filter below warnings.simplefilter("ignore") @@ -865,7 +913,7 @@ def test_spa_python_numba_physical_dst(expected_solpos, golden): periods=1, freq='D', tz=golden.tz) with warnings.catch_warnings(): - # don't warn on method reload or num threads + # don't warn on method reload warnings.simplefilter("ignore") ephem_data = solarposition.spa_python(times, golden.latitude, golden.longitude, pressure=82000,