Skip to content

Commit ee47994

Browse files
authored
Merge branch 'main' into pr1725-continuation-shaded-fraction-mikofski
2 parents b35b910 + 1e4d6f5 commit ee47994

File tree

5 files changed

+163
-60
lines changed

5 files changed

+163
-60
lines changed

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ Enhancements
1717

1818
Bug fixes
1919
~~~~~~~~~
20+
* Fixed an error in solar position calculations when using
21+
:py:class:`pandas.DatetimeIndex` with ``unit`` other than ``'ns'`` (:issue:`1932`).
22+
The following functions were affected:
23+
24+
- :py:class:`~pvlib.modelchain.ModelChain` and :py:func:`~pvlib.solarposition.get_solarposition` with the ``nrel_numpy`` and ``nrel_numba`` methods
25+
- :py:func:`~pvlib.solarposition.spa_python`
26+
- :py:func:`~pvlib.solarposition.sun_rise_set_transit_spa`
27+
- :py:func:`~pvlib.solarposition.nrel_earthsun_distance`
28+
- :py:func:`~pvlib.solarposition.hour_angle`
29+
- :py:func:`~pvlib.solarposition.sun_rise_set_transit_geometric`
30+
2031
* :py:class:`~pvlib.modelchain.ModelChain` now raises a more useful error when
2132
``temperature_model_parameters`` are specified on the passed ``system`` instead of on its ``arrays``. (:issue:`1759`).
2233
* :py:func:`pvlib.irradiance.ghi_from_poa_driesse_2023` now correctly makes use
@@ -37,8 +48,11 @@ Requirements
3748

3849
Contributors
3950
~~~~~~~~~~~~
51+
* Patrick Sheehan (:ghuser:`patricksheehan`)
52+
* Echedey Luis (:ghuser:`echedey-ls`)
53+
* Kevin Anderson (:ghuser:`kandersolar`)
4054
* Cliff Hansen (:ghuser:`cwhanse`)
4155
* :ghuser:`matsuobasho`
4256
* Adam R. Jensen (:ghuser:`AdamRJensen`)
57+
* Peter Dudfield (:ghuser:`peterdudfield`)
4358
* Mark A. Mikofski (:ghuser:`mikofski`)
44-
* Echedey Luis (:ghuser:`echedey-ls`)

pvlib/solarposition.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@
2828
from pvlib.tools import datetime_to_djd, djd_to_datetime
2929

3030

31-
NS_PER_HR = 1.e9 * 3600. # nanoseconds per hour
32-
33-
3431
def get_solarposition(time, latitude, longitude,
3532
altitude=None, pressure=None,
3633
method='nrel_numpy',
@@ -273,6 +270,19 @@ def _spa_python_import(how):
273270
return spa
274271

275272

273+
def _datetime_to_unixtime(dtindex):
274+
# convert a pandas datetime index to unixtime, making sure to handle
275+
# different pandas units (ns, us, etc) and time zones correctly
276+
if dtindex.tz is not None:
277+
# epoch is 1970-01-01 00:00 UTC, but we need to match the input tz
278+
# for compatibility with older pandas versions (e.g. v1.3.5)
279+
epoch = pd.Timestamp("1970-01-01", tz="UTC").tz_convert(dtindex.tz)
280+
else:
281+
epoch = pd.Timestamp("1970-01-01")
282+
283+
return np.array((dtindex - epoch) / pd.Timedelta("1s"))
284+
285+
276286
def spa_python(time, latitude, longitude,
277287
altitude=0, pressure=101325, temperature=12, delta_t=67.0,
278288
atmos_refract=None, how='numpy', numthreads=4):
@@ -365,7 +375,7 @@ def spa_python(time, latitude, longitude,
365375
except (TypeError, ValueError):
366376
time = pd.DatetimeIndex([time, ])
367377

368-
unixtime = np.array(time.view(np.int64)/10**9)
378+
unixtime = _datetime_to_unixtime(time)
369379

370380
spa = _spa_python_import(how)
371381

@@ -444,7 +454,7 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy',
444454

445455
# must convert to midnight UTC on day of interest
446456
utcday = pd.DatetimeIndex(times.date).tz_localize('UTC')
447-
unixtime = np.array(utcday.view(np.int64)/10**9)
457+
unixtime = _datetime_to_unixtime(utcday)
448458

449459
spa = _spa_python_import(how)
450460

@@ -1000,7 +1010,7 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4):
10001010
except (TypeError, ValueError):
10011011
time = pd.DatetimeIndex([time, ])
10021012

1003-
unixtime = np.array(time.view(np.int64)/10**9)
1013+
unixtime = _datetime_to_unixtime(time)
10041014

10051015
spa = _spa_python_import(how)
10061016

@@ -1377,21 +1387,23 @@ def hour_angle(times, longitude, equation_of_time):
13771387
equation_of_time_spencer71
13781388
equation_of_time_pvcdrom
13791389
"""
1380-
naive_times = times.tz_localize(None) # naive but still localized
13811390
# hours - timezone = (times - normalized_times) - (naive_times - times)
1382-
hrs_minus_tzs = 1 / NS_PER_HR * (
1383-
2 * times.view(np.int64) - times.normalize().view(np.int64) -
1384-
naive_times.view(np.int64))
1391+
if times.tz is None:
1392+
times = times.tz_localize('utc')
1393+
tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600
1394+
1395+
hrs_minus_tzs = (times - times.normalize()) / pd.Timedelta('1h') - tzs
1396+
13851397
# ensure array return instead of a version-dependent pandas <T>Index
13861398
return np.asarray(
13871399
15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.)
13881400

13891401

13901402
def _hour_angle_to_hours(times, hourangle, longitude, equation_of_time):
13911403
"""converts hour angles in degrees to hours as a numpy array"""
1392-
naive_times = times.tz_localize(None) # naive but still localized
1393-
tzs = 1 / NS_PER_HR * (
1394-
naive_times.view(np.int64) - times.view(np.int64))
1404+
if times.tz is None:
1405+
times = times.tz_localize('utc')
1406+
tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600
13951407
hours = (hourangle - longitude - equation_of_time / 4.) / 15. + 12. + tzs
13961408
return np.asarray(hours)
13971409

@@ -1405,16 +1417,13 @@ def _local_times_from_hours_since_midnight(times, hours):
14051417
# normalize local, naive times to previous midnight and add the hours until
14061418
# sunrise, sunset, and transit
14071419
return pd.DatetimeIndex(
1408-
(naive_times.normalize().view(np.int64) +
1409-
(hours * NS_PER_HR).astype(np.int64)).astype('datetime64[ns]'),
1410-
tz=tz_info)
1420+
naive_times.normalize() + pd.to_timedelta(hours, unit='h'), tz=tz_info)
14111421

14121422

14131423
def _times_to_hours_after_local_midnight(times):
14141424
"""convert local pandas datetime indices to array of hours as floats"""
14151425
times = times.tz_localize(None)
1416-
hrs = 1 / NS_PER_HR * (
1417-
times.view(np.int64) - times.normalize().view(np.int64))
1426+
hrs = (times - times.normalize()) / pd.Timedelta('1h')
14181427
return np.array(hrs)
14191428

14201429

pvlib/spa_c_files/SPA_NOTICE.md

Lines changed: 0 additions & 39 deletions
This file was deleted.

pvlib/tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ def has_numba():
174174
requires_pysam = pytest.mark.skipif(not has_pysam, reason="requires PySAM")
175175

176176

177+
has_pandas_2_0 = Version(pd.__version__) >= Version("2.0.0")
178+
requires_pandas_2_0 = pytest.mark.skipif(not has_pandas_2_0,
179+
reason="requires pandas>=2.0.0")
180+
181+
177182
@pytest.fixture()
178183
def golden():
179184
return Location(39.742476, -105.1786, 'America/Denver', 1830.14)

pvlib/tests/test_solarposition.py

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
from pvlib.location import Location
1313
from pvlib import solarposition, spa
1414

15-
from .conftest import requires_ephem, requires_spa_c, requires_numba
16-
15+
from .conftest import (
16+
requires_ephem, requires_spa_c, requires_numba, requires_pandas_2_0
17+
)
1718

1819
# setup times and locations to be tested.
1920
times = pd.date_range(start=datetime.datetime(2014, 6, 24),
@@ -717,6 +718,119 @@ def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst):
717718
atol=np.abs(expected_transit_error).max())
718719

719720

721+
@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
722+
def test__datetime_to_unixtime(tz):
723+
# for pandas < 2.0 where "unit" doesn't exist in pd.date_range. note that
724+
# unit of ns is the only option in pandas<2, and the default in pandas 2.x
725+
times = pd.date_range(start='2019-01-01', freq='h', periods=3, tz=tz)
726+
expected = times.view(np.int64)/10**9
727+
actual = solarposition._datetime_to_unixtime(times)
728+
np.testing.assert_equal(expected, actual)
729+
730+
731+
@requires_pandas_2_0
732+
@pytest.mark.parametrize('unit', ['ns', 'us', 's'])
733+
@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
734+
def test__datetime_to_unixtime_units(unit, tz):
735+
kwargs = dict(start='2019-01-01', freq='h', periods=3)
736+
times = pd.date_range(**kwargs, unit='ns', tz='UTC')
737+
expected = times.view(np.int64)/10**9
738+
739+
times = pd.date_range(**kwargs, unit=unit, tz='UTC').tz_convert(tz)
740+
actual = solarposition._datetime_to_unixtime(times)
741+
np.testing.assert_equal(expected, actual)
742+
743+
744+
@requires_pandas_2_0
745+
@pytest.mark.parametrize('method', [
746+
'nrel_numpy',
747+
'ephemeris',
748+
pytest.param('pyephem', marks=requires_ephem),
749+
pytest.param('nrel_numba', marks=requires_numba),
750+
pytest.param('nrel_c', marks=requires_spa_c),
751+
])
752+
@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
753+
def test_get_solarposition_microsecond_index(method, tz):
754+
# https://github.com/pvlib/pvlib-python/issues/1932
755+
756+
kwargs = dict(start='2019-01-01', freq='H', periods=24, tz=tz)
757+
758+
index_ns = pd.date_range(unit='ns', **kwargs)
759+
index_us = pd.date_range(unit='us', **kwargs)
760+
761+
sp_ns = solarposition.get_solarposition(index_ns, 40, -80, method=method)
762+
sp_us = solarposition.get_solarposition(index_us, 40, -80, method=method)
763+
764+
assert_frame_equal(sp_ns, sp_us, check_index_type=False)
765+
766+
767+
@requires_pandas_2_0
768+
@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
769+
def test_nrel_earthsun_distance_microsecond_index(tz):
770+
# https://github.com/pvlib/pvlib-python/issues/1932
771+
772+
kwargs = dict(start='2019-01-01', freq='H', periods=24, tz=tz)
773+
774+
index_ns = pd.date_range(unit='ns', **kwargs)
775+
index_us = pd.date_range(unit='us', **kwargs)
776+
777+
esd_ns = solarposition.nrel_earthsun_distance(index_ns)
778+
esd_us = solarposition.nrel_earthsun_distance(index_us)
779+
780+
assert_series_equal(esd_ns, esd_us, check_index_type=False)
781+
782+
783+
@requires_pandas_2_0
784+
@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
785+
def test_hour_angle_microsecond_index(tz):
786+
# https://github.com/pvlib/pvlib-python/issues/1932
787+
788+
kwargs = dict(start='2019-01-01', freq='H', periods=24, tz=tz)
789+
790+
index_ns = pd.date_range(unit='ns', **kwargs)
791+
index_us = pd.date_range(unit='us', **kwargs)
792+
793+
ha_ns = solarposition.hour_angle(index_ns, -80, 0)
794+
ha_us = solarposition.hour_angle(index_us, -80, 0)
795+
796+
np.testing.assert_equal(ha_ns, ha_us)
797+
798+
799+
@requires_pandas_2_0
800+
@pytest.mark.parametrize('tz', ['utc', 'US/Eastern'])
801+
def test_rise_set_transit_spa_microsecond_index(tz):
802+
# https://github.com/pvlib/pvlib-python/issues/1932
803+
804+
kwargs = dict(start='2019-01-01', freq='H', periods=24, tz=tz)
805+
806+
index_ns = pd.date_range(unit='ns', **kwargs)
807+
index_us = pd.date_range(unit='us', **kwargs)
808+
809+
rst_ns = solarposition.sun_rise_set_transit_spa(index_ns, 40, -80)
810+
rst_us = solarposition.sun_rise_set_transit_spa(index_us, 40, -80)
811+
812+
assert_frame_equal(rst_ns, rst_us, check_index_type=False)
813+
814+
815+
@requires_pandas_2_0
816+
@pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
817+
def test_rise_set_transit_geometric_microsecond_index(tz):
818+
# https://github.com/pvlib/pvlib-python/issues/1932
819+
820+
kwargs = dict(start='2019-01-01', freq='H', periods=24, tz=tz)
821+
822+
index_ns = pd.date_range(unit='ns', **kwargs)
823+
index_us = pd.date_range(unit='us', **kwargs)
824+
825+
args = (40, -80, 0, 0)
826+
rst_ns = solarposition.sun_rise_set_transit_geometric(index_ns, *args)
827+
rst_us = solarposition.sun_rise_set_transit_geometric(index_us, *args)
828+
829+
for times_ns, times_us in zip(rst_ns, rst_us):
830+
# can't use a fancy assert function here since the units are different
831+
assert all(times_ns == times_us)
832+
833+
720834
# put numba tests at end of file to minimize reloading
721835

722836
@requires_numba

0 commit comments

Comments
 (0)