pvlib 0.11.0__py3-none-any.whl → 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. pvlib/atmosphere.py +157 -1
  2. pvlib/bifacial/__init__.py +4 -4
  3. pvlib/bifacial/loss_models.py +163 -0
  4. pvlib/clearsky.py +18 -29
  5. pvlib/data/pvgis_tmy_meta.json +32 -93
  6. pvlib/data/pvgis_tmy_test.dat +8761 -8761
  7. pvlib/data/tmy_45.000_8.000_2005_2020.csv +8789 -0
  8. pvlib/data/tmy_45.000_8.000_2005_2020.epw +8768 -0
  9. pvlib/data/tmy_45.000_8.000_2005_2020.json +1 -0
  10. pvlib/data/tmy_45.000_8.000_2005_2020.txt +8761 -0
  11. pvlib/data/tmy_45.000_8.000_userhorizon.json +1 -1
  12. pvlib/data/variables_style_rules.csv +2 -1
  13. pvlib/iotools/pvgis.py +39 -3
  14. pvlib/irradiance.py +141 -120
  15. pvlib/location.py +5 -5
  16. pvlib/modelchain.py +1 -1
  17. pvlib/pvsystem.py +2 -2
  18. pvlib/shading.py +8 -8
  19. pvlib/singlediode.py +1 -1
  20. pvlib/solarposition.py +55 -50
  21. pvlib/spa.py +24 -22
  22. pvlib/spectrum/__init__.py +9 -4
  23. pvlib/spectrum/irradiance.py +272 -0
  24. pvlib/spectrum/mismatch.py +118 -508
  25. pvlib/spectrum/response.py +280 -0
  26. pvlib/spectrum/spectrl2.py +16 -16
  27. pvlib/tests/bifacial/test_losses_models.py +54 -0
  28. pvlib/tests/iotools/test_pvgis.py +57 -11
  29. pvlib/tests/spectrum/__init__.py +0 -0
  30. pvlib/tests/spectrum/conftest.py +40 -0
  31. pvlib/tests/spectrum/test_irradiance.py +138 -0
  32. pvlib/tests/{test_spectrum.py → spectrum/test_mismatch.py} +32 -306
  33. pvlib/tests/spectrum/test_response.py +124 -0
  34. pvlib/tests/spectrum/test_spectrl2.py +72 -0
  35. pvlib/tests/test_atmosphere.py +71 -0
  36. pvlib/tests/test_clearsky.py +37 -25
  37. pvlib/tests/test_irradiance.py +6 -6
  38. pvlib/tests/test_solarposition.py +84 -36
  39. pvlib/tests/test_spa.py +1 -1
  40. pvlib/tools.py +26 -2
  41. pvlib/tracking.py +53 -47
  42. {pvlib-0.11.0.dist-info → pvlib-0.11.1.dist-info}/METADATA +31 -29
  43. {pvlib-0.11.0.dist-info → pvlib-0.11.1.dist-info}/RECORD +47 -38
  44. {pvlib-0.11.0.dist-info → pvlib-0.11.1.dist-info}/WHEEL +1 -1
  45. pvlib/data/tmy_45.000_8.000_2005_2016.csv +0 -8789
  46. pvlib/data/tmy_45.000_8.000_2005_2016.epw +0 -8768
  47. pvlib/data/tmy_45.000_8.000_2005_2016.json +0 -1
  48. pvlib/data/tmy_45.000_8.000_2005_2016.txt +0 -8761
  49. {pvlib-0.11.0.dist-info → pvlib-0.11.1.dist-info}/AUTHORS.md +0 -0
  50. {pvlib-0.11.0.dist-info → pvlib-0.11.1.dist-info}/LICENSE +0 -0
  51. {pvlib-0.11.0.dist-info → pvlib-0.11.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,72 @@
1
+ import pytest
2
+ import pandas as pd
3
+ import numpy as np
4
+ from pvlib import spectrum
5
+ from numpy.testing import assert_allclose
6
+
7
+
8
+ def test_spectrl2(spectrl2_data):
9
+ # compare against output from solar_utils wrapper around NREL spectrl2_2.c
10
+ kwargs, expected = spectrl2_data
11
+ actual = spectrum.spectrl2(**kwargs)
12
+ assert_allclose(expected['wavelength'].values, actual['wavelength'])
13
+ assert_allclose(expected['specdif'].values, actual['dhi'].ravel(),
14
+ atol=7e-5)
15
+ assert_allclose(expected['specdir'].values, actual['dni'].ravel(),
16
+ atol=1.5e-4)
17
+ assert_allclose(expected['specetr'], actual['dni_extra'].ravel(),
18
+ atol=2e-4)
19
+ assert_allclose(expected['specglo'], actual['poa_global'].ravel(),
20
+ atol=1e-4)
21
+
22
+
23
+ def test_spectrl2_array(spectrl2_data):
24
+ # test that supplying arrays instead of scalars works
25
+ kwargs, expected = spectrl2_data
26
+ kwargs = {k: np.array([v, v, v]) for k, v in kwargs.items()}
27
+ actual = spectrum.spectrl2(**kwargs)
28
+
29
+ assert actual['wavelength'].shape == (122,)
30
+
31
+ keys = ['dni_extra', 'dhi', 'dni', 'poa_sky_diffuse', 'poa_ground_diffuse',
32
+ 'poa_direct', 'poa_global']
33
+ for key in keys:
34
+ assert actual[key].shape == (122, 3)
35
+
36
+
37
+ def test_spectrl2_series(spectrl2_data):
38
+ # test that supplying Series instead of scalars works
39
+ kwargs, expected = spectrl2_data
40
+ kwargs.pop('dayofyear')
41
+ index = pd.to_datetime(['2020-03-15 10:45:59']*3)
42
+ kwargs = {k: pd.Series([v, v, v], index=index) for k, v in kwargs.items()}
43
+ actual = spectrum.spectrl2(**kwargs)
44
+
45
+ assert actual['wavelength'].shape == (122,)
46
+
47
+ keys = ['dni_extra', 'dhi', 'dni', 'poa_sky_diffuse', 'poa_ground_diffuse',
48
+ 'poa_direct', 'poa_global']
49
+ for key in keys:
50
+ assert actual[key].shape == (122, 3)
51
+
52
+
53
+ def test_dayofyear_missing(spectrl2_data):
54
+ # test that not specifying dayofyear with non-pandas inputs raises error
55
+ kwargs, expected = spectrl2_data
56
+ kwargs.pop('dayofyear')
57
+ with pytest.raises(ValueError, match='dayofyear must be specified'):
58
+ _ = spectrum.spectrl2(**kwargs)
59
+
60
+
61
+ def test_aoi_gt_90(spectrl2_data):
62
+ # test that returned irradiance values are non-negative when aoi > 90
63
+ # see GH #1348
64
+ kwargs, _ = spectrl2_data
65
+ kwargs['apparent_zenith'] = 70
66
+ kwargs['aoi'] = 130
67
+ kwargs['surface_tilt'] = 60
68
+
69
+ spectra = spectrum.spectrl2(**kwargs)
70
+ for key in ['poa_direct', 'poa_global']:
71
+ message = f'{key} contains negative values for aoi>90'
72
+ assert np.all(spectra[key] >= 0), message
@@ -131,3 +131,74 @@ def test_bird_hulstrom80_aod_bb():
131
131
  aod380, aod500 = 0.22072480948195175, 0.1614279181106312
132
132
  bird_hulstrom = atmosphere.bird_hulstrom80_aod_bb(aod380, aod500)
133
133
  assert np.isclose(0.11738229553812768, bird_hulstrom)
134
+
135
+
136
+ @pytest.fixture
137
+ def windspeeds_data_powerlaw():
138
+ data = pd.DataFrame(
139
+ index=pd.date_range(start="2015-01-01 00:00", end="2015-01-01 05:00",
140
+ freq="1h"),
141
+ columns=["wind_ref", "height_ref", "height_desired", "wind_calc"],
142
+ data=[
143
+ (10, -2, 5, np.nan),
144
+ (-10, 2, 5, np.nan),
145
+ (5, 4, 5, 5.067393209486324),
146
+ (7, 6, 10, 7.2178684911195905),
147
+ (10, 8, 20, 10.565167835216586),
148
+ (12, 10, 30, 12.817653329393977)
149
+ ]
150
+ )
151
+ return data
152
+
153
+
154
+ def test_windspeed_powerlaw_ndarray(windspeeds_data_powerlaw):
155
+ # test wind speed estimation by passing in surface_type
156
+ result_surface = atmosphere.windspeed_powerlaw(
157
+ windspeeds_data_powerlaw["wind_ref"].to_numpy(),
158
+ windspeeds_data_powerlaw["height_ref"],
159
+ windspeeds_data_powerlaw["height_desired"],
160
+ surface_type='unstable_air_above_open_water_surface')
161
+ assert_allclose(windspeeds_data_powerlaw["wind_calc"].to_numpy(),
162
+ result_surface)
163
+ # test wind speed estimation by passing in the exponent corresponding
164
+ # to the surface_type above
165
+ result_exponent = atmosphere.windspeed_powerlaw(
166
+ windspeeds_data_powerlaw["wind_ref"].to_numpy(),
167
+ windspeeds_data_powerlaw["height_ref"],
168
+ windspeeds_data_powerlaw["height_desired"],
169
+ exponent=0.06)
170
+ assert_allclose(windspeeds_data_powerlaw["wind_calc"].to_numpy(),
171
+ result_exponent)
172
+
173
+
174
+ def test_windspeed_powerlaw_series(windspeeds_data_powerlaw):
175
+ result = atmosphere.windspeed_powerlaw(
176
+ windspeeds_data_powerlaw["wind_ref"],
177
+ windspeeds_data_powerlaw["height_ref"],
178
+ windspeeds_data_powerlaw["height_desired"],
179
+ surface_type='unstable_air_above_open_water_surface')
180
+ assert_series_equal(windspeeds_data_powerlaw["wind_calc"],
181
+ result, check_names=False)
182
+
183
+
184
+ def test_windspeed_powerlaw_invalid():
185
+ with pytest.raises(ValueError, match="Either a 'surface_type' or an "
186
+ "'exponent' parameter must be given"):
187
+ # no exponent or surface_type given
188
+ atmosphere.windspeed_powerlaw(wind_speed_reference=10,
189
+ height_reference=5,
190
+ height_desired=10)
191
+ with pytest.raises(ValueError, match="Either a 'surface_type' or an "
192
+ "'exponent' parameter must be given"):
193
+ # no exponent or surface_type given
194
+ atmosphere.windspeed_powerlaw(wind_speed_reference=10,
195
+ height_reference=5,
196
+ height_desired=10,
197
+ exponent=1.2,
198
+ surface_type="surf")
199
+ with pytest.raises(KeyError, match='not_an_exponent'):
200
+ # invalid surface_type
201
+ atmosphere.windspeed_powerlaw(wind_speed_reference=10,
202
+ height_reference=5,
203
+ height_desired=10,
204
+ surface_type='not_an_exponent')
@@ -675,13 +675,18 @@ def test_detect_clearsky_missing_index(detect_clearsky_data):
675
675
  def test_detect_clearsky_not_enough_data(detect_clearsky_data):
676
676
  expected, cs = detect_clearsky_data
677
677
  with pytest.raises(ValueError, match='have at least'):
678
- clearsky.detect_clearsky(expected['GHI'], cs['ghi'], window_length=60)
678
+ clearsky.detect_clearsky(expected['GHI'], cs['ghi'], window_length=60)
679
679
 
680
680
 
681
- def test_detect_clearsky_optimizer_failed(detect_clearsky_data):
681
+ @pytest.mark.parametrize("window_length", [5, 10, 15, 20, 25])
682
+ def test_detect_clearsky_optimizer_not_failed(
683
+ detect_clearsky_data, window_length
684
+ ):
682
685
  expected, cs = detect_clearsky_data
683
- with pytest.raises(RuntimeError, match='Optimizer exited unsuccessfully'):
684
- clearsky.detect_clearsky(expected['GHI'], cs['ghi'], window_length=15)
686
+ clear_samples = clearsky.detect_clearsky(
687
+ expected["GHI"], cs["ghi"], window_length=window_length
688
+ )
689
+ assert isinstance(clear_samples, pd.Series)
685
690
 
686
691
 
687
692
  @pytest.fixture
@@ -749,6 +754,7 @@ def test_bird():
749
754
  tz = -7 # test timezone
750
755
  gmt_tz = pytz.timezone('Etc/GMT%+d' % -(tz))
751
756
  times = times.tz_localize(gmt_tz) # set timezone
757
+ times_utc = times.tz_convert('UTC')
752
758
  # match test data from BIRD_08_16_2012.xls
753
759
  latitude = 40.
754
760
  longitude = -105.
@@ -759,9 +765,9 @@ def test_bird():
759
765
  aod_380nm = 0.15
760
766
  b_a = 0.85
761
767
  alb = 0.2
762
- eot = solarposition.equation_of_time_spencer71(times.dayofyear)
768
+ eot = solarposition.equation_of_time_spencer71(times_utc.dayofyear)
763
769
  hour_angle = solarposition.hour_angle(times, longitude, eot) - 0.5 * 15.
764
- declination = solarposition.declination_spencer71(times.dayofyear)
770
+ declination = solarposition.declination_spencer71(times_utc.dayofyear)
765
771
  zenith = solarposition.solar_zenith_analytical(
766
772
  np.deg2rad(latitude), np.deg2rad(hour_angle), declination
767
773
  )
@@ -777,32 +783,35 @@ def test_bird():
777
783
  Eb, Ebh, Gh, Dh = (irrads[_] for _ in field_names)
778
784
  data_path = DATA_DIR / 'BIRD_08_16_2012.csv'
779
785
  testdata = pd.read_csv(data_path, usecols=range(1, 26), header=1).dropna()
780
- testdata.index = times[1:48]
781
- assert np.allclose(testdata['DEC'], np.rad2deg(declination[1:48]))
782
- assert np.allclose(testdata['EQT'], eot[1:48], rtol=1e-4)
783
- assert np.allclose(testdata['Hour Angle'], hour_angle[1:48])
784
- assert np.allclose(testdata['Zenith Ang'], zenith[1:48])
786
+ testdata[['DEC', 'EQT']] = testdata[['DEC', 'EQT']].shift(tz)
787
+ testdata = testdata[:tz]
788
+ end = 48 + tz
789
+ testdata.index = times[1:end]
790
+ assert np.allclose(testdata['DEC'], np.rad2deg(declination[1:end]))
791
+ assert np.allclose(testdata['EQT'], eot[1:end], rtol=1e-4)
792
+ assert np.allclose(testdata['Hour Angle'], hour_angle[1:end], rtol=1e-2)
793
+ assert np.allclose(testdata['Zenith Ang'], zenith[1:end], rtol=1e-2)
785
794
  dawn = zenith < 88.
786
795
  dusk = testdata['Zenith Ang'] < 88.
787
796
  am = pd.Series(np.where(dawn, airmass, 0.), index=times).fillna(0.0)
788
797
  assert np.allclose(
789
- testdata['Air Mass'].where(dusk, 0.), am[1:48], rtol=1e-3
798
+ testdata['Air Mass'].where(dusk, 0.), am[1:end], rtol=1e-3
790
799
  )
791
800
  direct_beam = pd.Series(np.where(dawn, Eb, 0.), index=times).fillna(0.)
792
801
  assert np.allclose(
793
- testdata['Direct Beam'].where(dusk, 0.), direct_beam[1:48], rtol=1e-3
802
+ testdata['Direct Beam'].where(dusk, 0.), direct_beam[1:end], rtol=1e-3
794
803
  )
795
804
  direct_horz = pd.Series(np.where(dawn, Ebh, 0.), index=times).fillna(0.)
796
805
  assert np.allclose(
797
- testdata['Direct Hz'].where(dusk, 0.), direct_horz[1:48], rtol=1e-3
806
+ testdata['Direct Hz'].where(dusk, 0.), direct_horz[1:end], rtol=1e-3
798
807
  )
799
808
  global_horz = pd.Series(np.where(dawn, Gh, 0.), index=times).fillna(0.)
800
809
  assert np.allclose(
801
- testdata['Global Hz'].where(dusk, 0.), global_horz[1:48], rtol=1e-3
810
+ testdata['Global Hz'].where(dusk, 0.), global_horz[1:end], rtol=1e-3
802
811
  )
803
812
  diffuse_horz = pd.Series(np.where(dawn, Dh, 0.), index=times).fillna(0.)
804
813
  assert np.allclose(
805
- testdata['Dif Hz'].where(dusk, 0.), diffuse_horz[1:48], rtol=1e-3
814
+ testdata['Dif Hz'].where(dusk, 0.), diffuse_horz[1:end], rtol=1e-3
806
815
  )
807
816
  # repeat test with albedo as a Series
808
817
  alb_series = pd.Series(0.2, index=times)
@@ -813,19 +822,19 @@ def test_bird():
813
822
  Eb, Ebh, Gh, Dh = (irrads[_] for _ in field_names)
814
823
  direct_beam = pd.Series(np.where(dawn, Eb, 0.), index=times).fillna(0.)
815
824
  assert np.allclose(
816
- testdata['Direct Beam'].where(dusk, 0.), direct_beam[1:48], rtol=1e-3
825
+ testdata['Direct Beam'].where(dusk, 0.), direct_beam[1:end], rtol=1e-3
817
826
  )
818
827
  direct_horz = pd.Series(np.where(dawn, Ebh, 0.), index=times).fillna(0.)
819
828
  assert np.allclose(
820
- testdata['Direct Hz'].where(dusk, 0.), direct_horz[1:48], rtol=1e-3
829
+ testdata['Direct Hz'].where(dusk, 0.), direct_horz[1:end], rtol=1e-3
821
830
  )
822
831
  global_horz = pd.Series(np.where(dawn, Gh, 0.), index=times).fillna(0.)
823
832
  assert np.allclose(
824
- testdata['Global Hz'].where(dusk, 0.), global_horz[1:48], rtol=1e-3
833
+ testdata['Global Hz'].where(dusk, 0.), global_horz[1:end], rtol=1e-3
825
834
  )
826
835
  diffuse_horz = pd.Series(np.where(dawn, Dh, 0.), index=times).fillna(0.)
827
836
  assert np.allclose(
828
- testdata['Dif Hz'].where(dusk, 0.), diffuse_horz[1:48], rtol=1e-3
837
+ testdata['Dif Hz'].where(dusk, 0.), diffuse_horz[1:end], rtol=1e-3
829
838
  )
830
839
 
831
840
  # test keyword parameters
@@ -835,22 +844,25 @@ def test_bird():
835
844
  Eb2, Ebh2, Gh2, Dh2 = (irrads2[_] for _ in field_names)
836
845
  data_path = DATA_DIR / 'BIRD_08_16_2012_patm.csv'
837
846
  testdata2 = pd.read_csv(data_path, usecols=range(1, 26), header=1).dropna()
838
- testdata2.index = times[1:48]
847
+ testdata2[['DEC', 'EQT']] = testdata2[['DEC', 'EQT']].shift(tz)
848
+ testdata2 = testdata2[:tz]
849
+ testdata2.index = times[1:end]
839
850
  direct_beam2 = pd.Series(np.where(dawn, Eb2, 0.), index=times).fillna(0.)
840
851
  assert np.allclose(
841
- testdata2['Direct Beam'].where(dusk, 0.), direct_beam2[1:48], rtol=1e-3
852
+ testdata2['Direct Beam'].where(dusk, 0.), direct_beam2[1:end],
853
+ rtol=1e-3
842
854
  )
843
855
  direct_horz2 = pd.Series(np.where(dawn, Ebh2, 0.), index=times).fillna(0.)
844
856
  assert np.allclose(
845
- testdata2['Direct Hz'].where(dusk, 0.), direct_horz2[1:48], rtol=1e-3
857
+ testdata2['Direct Hz'].where(dusk, 0.), direct_horz2[1:end], rtol=1e-3
846
858
  )
847
859
  global_horz2 = pd.Series(np.where(dawn, Gh2, 0.), index=times).fillna(0.)
848
860
  assert np.allclose(
849
- testdata2['Global Hz'].where(dusk, 0.), global_horz2[1:48], rtol=1e-3
861
+ testdata2['Global Hz'].where(dusk, 0.), global_horz2[1:end], rtol=1e-3
850
862
  )
851
863
  diffuse_horz2 = pd.Series(np.where(dawn, Dh2, 0.), index=times).fillna(0.)
852
864
  assert np.allclose(
853
- testdata2['Dif Hz'].where(dusk, 0.), diffuse_horz2[1:48], rtol=1e-3
865
+ testdata2['Dif Hz'].where(dusk, 0.), diffuse_horz2[1:end], rtol=1e-3
854
866
  )
855
867
  # test scalars just at noon
856
868
  # XXX: calculations start at 12am so noon is at index = 12
@@ -59,7 +59,7 @@ def ephem_data(times):
59
59
 
60
60
 
61
61
  @pytest.fixture
62
- def dni_et(times):
62
+ def dni_et():
63
63
  return np.array(
64
64
  [1321.1655834833093, 1321.1655834833093, 1321.1655834833093,
65
65
  1321.1655834833093])
@@ -106,7 +106,7 @@ def test_get_extra_radiation_epoch_year():
106
106
  @requires_numba
107
107
  def test_get_extra_radiation_nrel_numba(times):
108
108
  with warnings.catch_warnings():
109
- # don't warn on method reload or num threads
109
+ # don't warn on method reload
110
110
  warnings.simplefilter("ignore")
111
111
  result = irradiance.get_extra_radiation(
112
112
  times, method='nrel', how='numba', numthreads=4)
@@ -603,11 +603,11 @@ def test_poa_components(irrad_data, ephem_data, dni_et, relative_airmass):
603
603
 
604
604
  @pytest.mark.parametrize('pressure,expected', [
605
605
  (93193, [[830.46567, 0.79742, 0.93505],
606
- [676.09497, 0.63776, 3.02102]]),
606
+ [676.18340, 0.63782, 3.02102]]),
607
607
  (None, [[868.72425, 0.79742, 1.01664],
608
- [680.66679, 0.63776, 3.28463]]),
608
+ [680.73800, 0.63782, 3.28463]]),
609
609
  (101325, [[868.72425, 0.79742, 1.01664],
610
- [680.66679, 0.63776, 3.28463]])
610
+ [680.73800, 0.63782, 3.28463]])
611
611
  ])
612
612
  def test_disc_value(pressure, expected):
613
613
  # see GH 449 for pressure=None vs. 101325.
@@ -1080,7 +1080,7 @@ def test_dirindex(times):
1080
1080
  pressure=pressure,
1081
1081
  use_delta_kt_prime=True,
1082
1082
  temp_dew=tdew).values
1083
- expected_out = np.array([np.nan, 0., 748.31562753, 630.72592644])
1083
+ expected_out = np.array([np.nan, 0., 748.31562800, 630.73752100])
1084
1084
 
1085
1085
  tolerance = 1e-8
1086
1086
  assert np.allclose(out, expected_out, rtol=tolerance, atol=0,
@@ -139,7 +139,8 @@ def test_spa_python_numpy_physical_dst(expected_solpos, golden):
139
139
  assert_frame_equal(expected_solpos, ephem_data[expected_solpos.columns])
140
140
 
141
141
 
142
- def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden):
142
+ @pytest.mark.parametrize('delta_t', [65.0, None, np.array([65, 65])])
143
+ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t):
143
144
  # solution from NREL SAP web calculator
144
145
  south = Location(-35.0, 0.0, tz='UTC')
145
146
  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):
160
161
 
161
162
  result = solarposition.sun_rise_set_transit_spa(times, south.latitude,
162
163
  south.longitude,
163
- delta_t=65.0)
164
+ delta_t=delta_t)
164
165
  result_rounded = pd.DataFrame(index=result.index)
165
166
  # need to iterate because to_datetime does not accept 2D data
166
167
  # the rounding fails on pandas < 0.17
@@ -172,7 +173,7 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden):
172
173
  # test for Golden, CO compare to NREL SPA
173
174
  result = solarposition.sun_rise_set_transit_spa(
174
175
  expected_rise_set_spa.index, golden.latitude, golden.longitude,
175
- delta_t=65.0)
176
+ delta_t=delta_t)
176
177
 
177
178
  # round to nearest minute
178
179
  result_rounded = pd.DataFrame(index=result.index)
@@ -477,20 +478,20 @@ def test_get_solarposition_altitude(
477
478
 
478
479
 
479
480
  @pytest.mark.parametrize("delta_t, method", [
480
- (None, 'nrel_numpy'),
481
- pytest.param(
482
- None, 'nrel_numba',
483
- marks=[pytest.mark.xfail(
484
- reason='spa.calculate_deltat not implemented for numba yet')]),
481
+ (None, 'nrel_numba'),
485
482
  (67.0, 'nrel_numba'),
483
+ (np.array([67.0, 67.0]), 'nrel_numba'),
484
+ # minimize reloads, with numpy being last
485
+ (None, 'nrel_numpy'),
486
486
  (67.0, 'nrel_numpy'),
487
- ])
487
+ (np.array([67.0, 67.0]), 'nrel_numpy'),
488
+ ])
488
489
  def test_get_solarposition_deltat(delta_t, method, expected_solpos_multi,
489
490
  golden):
490
491
  times = pd.date_range(datetime.datetime(2003, 10, 17, 13, 30, 30),
491
492
  periods=2, freq='D', tz=golden.tz)
492
493
  with warnings.catch_warnings():
493
- # don't warn on method reload or num threads
494
+ # don't warn on method reload
494
495
  warnings.simplefilter("ignore")
495
496
  ephem_data = solarposition.get_solarposition(times, golden.latitude,
496
497
  golden.longitude,
@@ -505,6 +506,21 @@ def test_get_solarposition_deltat(delta_t, method, expected_solpos_multi,
505
506
  assert_frame_equal(this_expected, ephem_data[this_expected.columns])
506
507
 
507
508
 
509
+ @pytest.mark.parametrize("method", ['nrel_numba', 'nrel_numpy'])
510
+ def test_spa_array_delta_t(method):
511
+ # make sure that time-varying delta_t produces different answers
512
+ times = pd.to_datetime(["2019-01-01", "2019-01-01"]).tz_localize("UTC")
513
+ expected = pd.Series([257.26969492, 257.2701359], index=times)
514
+ with warnings.catch_warnings():
515
+ # don't warn on method reload
516
+ warnings.simplefilter("ignore")
517
+ ephem_data = solarposition.get_solarposition(times, 40, -80,
518
+ delta_t=np.array([67, 0]),
519
+ method=method)
520
+
521
+ assert_series_equal(ephem_data['azimuth'], expected, check_names=False)
522
+
523
+
508
524
  def test_get_solarposition_no_kwargs(expected_solpos, golden):
509
525
  times = pd.date_range(datetime.datetime(2003, 10, 17, 13, 30, 30),
510
526
  periods=1, freq='D', tz=golden.tz)
@@ -529,20 +545,22 @@ def test_get_solarposition_method_pyephem(expected_solpos, golden):
529
545
  assert_frame_equal(expected_solpos, ephem_data[expected_solpos.columns])
530
546
 
531
547
 
532
- def test_nrel_earthsun_distance():
548
+ @pytest.mark.parametrize('delta_t', [64.0, None, np.array([64, 64])])
549
+ def test_nrel_earthsun_distance(delta_t):
533
550
  times = pd.DatetimeIndex([datetime.datetime(2015, 1, 2),
534
551
  datetime.datetime(2015, 8, 2)]
535
552
  ).tz_localize('MST')
536
- result = solarposition.nrel_earthsun_distance(times, delta_t=64.0)
553
+ result = solarposition.nrel_earthsun_distance(times, delta_t=delta_t)
537
554
  expected = pd.Series(np.array([0.983289204601, 1.01486146446]),
538
555
  index=times)
539
556
  assert_series_equal(expected, result)
540
557
 
541
- times = datetime.datetime(2015, 1, 2)
542
- result = solarposition.nrel_earthsun_distance(times, delta_t=64.0)
543
- expected = pd.Series(np.array([0.983289204601]),
544
- index=pd.DatetimeIndex([times, ]))
545
- assert_series_equal(expected, result)
558
+ if np.size(delta_t) == 1: # skip the array delta_t
559
+ times = datetime.datetime(2015, 1, 2)
560
+ result = solarposition.nrel_earthsun_distance(times, delta_t=delta_t)
561
+ expected = pd.Series(np.array([0.983289204601]),
562
+ index=pd.DatetimeIndex([times, ]))
563
+ assert_series_equal(expected, result)
546
564
 
547
565
 
548
566
  def test_equation_of_time():
@@ -579,19 +597,20 @@ def test_declination():
579
597
  def test_analytical_zenith():
580
598
  times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00",
581
599
  freq="h").tz_localize('Etc/GMT+8')
600
+ times_utc = times.tz_convert('UTC')
582
601
  lat, lon = 37.8, -122.25
583
602
  lat_rad = np.deg2rad(lat)
584
603
  output = solarposition.spa_python(times, lat, lon, 100)
585
604
  solar_zenith = np.deg2rad(output['zenith']) # spa
586
605
  # spencer
587
- eot = solarposition.equation_of_time_spencer71(times.dayofyear)
606
+ eot = solarposition.equation_of_time_spencer71(times_utc.dayofyear)
588
607
  hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot))
589
- decl = solarposition.declination_spencer71(times.dayofyear)
608
+ decl = solarposition.declination_spencer71(times_utc.dayofyear)
590
609
  zenith_1 = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl)
591
610
  # pvcdrom and cooper
592
- eot = solarposition.equation_of_time_pvcdrom(times.dayofyear)
611
+ eot = solarposition.equation_of_time_pvcdrom(times_utc.dayofyear)
593
612
  hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot))
594
- decl = solarposition.declination_cooper69(times.dayofyear)
613
+ decl = solarposition.declination_cooper69(times_utc.dayofyear)
595
614
  zenith_2 = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl)
596
615
  assert np.allclose(zenith_1, solar_zenith, atol=0.015)
597
616
  assert np.allclose(zenith_2, solar_zenith, atol=0.025)
@@ -600,22 +619,23 @@ def test_analytical_zenith():
600
619
  def test_analytical_azimuth():
601
620
  times = pd.date_range(start="1/1/2015 0:00", end="12/31/2015 23:00",
602
621
  freq="h").tz_localize('Etc/GMT+8')
622
+ times_utc = times.tz_convert('UTC')
603
623
  lat, lon = 37.8, -122.25
604
624
  lat_rad = np.deg2rad(lat)
605
625
  output = solarposition.spa_python(times, lat, lon, 100)
606
626
  solar_azimuth = np.deg2rad(output['azimuth']) # spa
607
627
  solar_zenith = np.deg2rad(output['zenith'])
608
628
  # spencer
609
- eot = solarposition.equation_of_time_spencer71(times.dayofyear)
629
+ eot = solarposition.equation_of_time_spencer71(times_utc.dayofyear)
610
630
  hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot))
611
- decl = solarposition.declination_spencer71(times.dayofyear)
631
+ decl = solarposition.declination_spencer71(times_utc.dayofyear)
612
632
  zenith = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl)
613
633
  azimuth_1 = solarposition.solar_azimuth_analytical(lat_rad, hour_angle,
614
634
  decl, zenith)
615
635
  # pvcdrom and cooper
616
- eot = solarposition.equation_of_time_pvcdrom(times.dayofyear)
636
+ eot = solarposition.equation_of_time_pvcdrom(times_utc.dayofyear)
617
637
  hour_angle = np.deg2rad(solarposition.hour_angle(times, lon, eot))
618
- decl = solarposition.declination_cooper69(times.dayofyear)
638
+ decl = solarposition.declination_cooper69(times_utc.dayofyear)
619
639
  zenith = solarposition.solar_zenith_analytical(lat_rad, hour_angle, decl)
620
640
  azimuth_2 = solarposition.solar_azimuth_analytical(lat_rad, hour_angle,
621
641
  decl, zenith)
@@ -665,21 +685,45 @@ def test_hour_angle():
665
685
  '2015-01-02 12:04:44.6340'
666
686
  ]).tz_localize('Etc/GMT+7')
667
687
  eot = np.array([-3.935172, -4.117227, -4.026295])
668
- hours = solarposition.hour_angle(times, longitude, eot)
688
+ hourangle = solarposition.hour_angle(times, longitude, eot)
669
689
  expected = (-70.682338, 70.72118825000001, 0.000801250)
670
690
  # FIXME: there are differences from expected NREL SPA calculator values
671
691
  # sunrise: 4 seconds, sunset: 48 seconds, transit: 0.2 seconds
672
692
  # but the differences may be due to other SPA input parameters
673
- assert np.allclose(hours, expected)
693
+ assert np.allclose(hourangle, expected)
694
+
695
+ hours = solarposition._hour_angle_to_hours(
696
+ times, hourangle, longitude, eot)
697
+ result = solarposition._times_to_hours_after_local_midnight(times)
698
+ assert np.allclose(result, hours)
699
+
700
+ result = solarposition._local_times_from_hours_since_midnight(times, hours)
701
+ assert result.equals(times)
702
+
703
+ times = times.tz_convert(None)
704
+ with pytest.raises(ValueError):
705
+ solarposition.hour_angle(times, longitude, eot)
706
+ with pytest.raises(ValueError):
707
+ solarposition._hour_angle_to_hours(times, hourangle, longitude, eot)
708
+ with pytest.raises(ValueError):
709
+ solarposition._times_to_hours_after_local_midnight(times)
710
+ with pytest.raises(ValueError):
711
+ solarposition._local_times_from_hours_since_midnight(times, hours)
674
712
 
675
713
 
676
714
  def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst):
677
715
  """Test geometric calculations for sunrise, sunset, and transit times"""
678
716
  times = expected_rise_set_spa.index
717
+ times_utc = times.tz_convert('UTC')
679
718
  latitude = golden_mst.latitude
680
719
  longitude = golden_mst.longitude
681
- eot = solarposition.equation_of_time_spencer71(times.dayofyear) # minutes
682
- decl = solarposition.declination_spencer71(times.dayofyear) # radians
720
+ eot = solarposition.equation_of_time_spencer71(
721
+ times_utc.dayofyear) # minutes
722
+ decl = solarposition.declination_spencer71(times_utc.dayofyear) # radians
723
+ with pytest.raises(ValueError):
724
+ solarposition.sun_rise_set_transit_geometric(
725
+ times.tz_convert(None), latitude=latitude, longitude=longitude,
726
+ declination=decl, equation_of_time=eot)
683
727
  sr, ss, st = solarposition.sun_rise_set_transit_geometric(
684
728
  times, latitude=latitude, longitude=longitude, declination=decl,
685
729
  equation_of_time=eot)
@@ -742,6 +786,7 @@ def test__datetime_to_unixtime_units(unit, tz):
742
786
 
743
787
 
744
788
  @requires_pandas_2_0
789
+ @pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
745
790
  @pytest.mark.parametrize('method', [
746
791
  'nrel_numpy',
747
792
  'ephemeris',
@@ -749,7 +794,6 @@ def test__datetime_to_unixtime_units(unit, tz):
749
794
  pytest.param('nrel_numba', marks=requires_numba),
750
795
  pytest.param('nrel_c', marks=requires_spa_c),
751
796
  ])
752
- @pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
753
797
  def test_get_solarposition_microsecond_index(method, tz):
754
798
  # https://github.com/pvlib/pvlib-python/issues/1932
755
799
 
@@ -758,8 +802,12 @@ def test_get_solarposition_microsecond_index(method, tz):
758
802
  index_ns = pd.date_range(unit='ns', **kwargs)
759
803
  index_us = pd.date_range(unit='us', **kwargs)
760
804
 
761
- sp_ns = solarposition.get_solarposition(index_ns, 40, -80, method=method)
762
- sp_us = solarposition.get_solarposition(index_us, 40, -80, method=method)
805
+ with warnings.catch_warnings():
806
+ # don't warn on method reload
807
+ warnings.simplefilter("ignore")
808
+
809
+ sp_ns = solarposition.get_solarposition(index_ns, 0, 0, method=method)
810
+ sp_us = solarposition.get_solarposition(index_us, 0, 0, method=method)
763
811
 
764
812
  assert_frame_equal(sp_ns, sp_us, check_index_type=False)
765
813
 
@@ -781,7 +829,7 @@ def test_nrel_earthsun_distance_microsecond_index(tz):
781
829
 
782
830
 
783
831
  @requires_pandas_2_0
784
- @pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
832
+ @pytest.mark.parametrize('tz', ['utc', 'US/Eastern'])
785
833
  def test_hour_angle_microsecond_index(tz):
786
834
  # https://github.com/pvlib/pvlib-python/issues/1932
787
835
 
@@ -813,7 +861,7 @@ def test_rise_set_transit_spa_microsecond_index(tz):
813
861
 
814
862
 
815
863
  @requires_pandas_2_0
816
- @pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern'])
864
+ @pytest.mark.parametrize('tz', ['utc', 'US/Eastern'])
817
865
  def test_rise_set_transit_geometric_microsecond_index(tz):
818
866
  # https://github.com/pvlib/pvlib-python/issues/1932
819
867
 
@@ -838,7 +886,7 @@ def test_spa_python_numba_physical(expected_solpos, golden_mst):
838
886
  times = pd.date_range(datetime.datetime(2003, 10, 17, 12, 30, 30),
839
887
  periods=1, freq='D', tz=golden_mst.tz)
840
888
  with warnings.catch_warnings():
841
- # don't warn on method reload or num threads
889
+ # don't warn on method reload
842
890
  # ensure that numpy is the most recently used method so that
843
891
  # we can use the warns filter below
844
892
  warnings.simplefilter("ignore")
@@ -865,7 +913,7 @@ def test_spa_python_numba_physical_dst(expected_solpos, golden):
865
913
  periods=1, freq='D', tz=golden.tz)
866
914
 
867
915
  with warnings.catch_warnings():
868
- # don't warn on method reload or num threads
916
+ # don't warn on method reload
869
917
  warnings.simplefilter("ignore")
870
918
  ephem_data = solarposition.spa_python(times, golden.latitude,
871
919
  golden.longitude, pressure=82000,
pvlib/tests/test_spa.py CHANGED
@@ -234,7 +234,7 @@ class SpaBase:
234
234
 
235
235
  def test_solar_position(self):
236
236
  with warnings.catch_warnings():
237
- # don't warn on method reload or num threads
237
+ # don't warn on method reload
238
238
  warnings.simplefilter("ignore")
239
239
  spa_out_0 = self.spa.solar_position(
240
240
  unixtimes, lat, lon, elev, pressure, temp, delta_t,
pvlib/tools.py CHANGED
@@ -206,8 +206,32 @@ def _pandas_to_doy(pd_object):
206
206
  Returns
207
207
  -------
208
208
  dayofyear
209
+
210
+ Notes
211
+ -----
212
+ Day of year is determined using UTC, since pandas uses local hour
213
+ """
214
+ return _pandas_to_utc(pd_object).dayofyear
215
+
216
+
217
+ def _pandas_to_utc(pd_object):
209
218
  """
210
- return pd_object.dayofyear
219
+ Converts a pandas datetime-like object to UTC, if localized.
220
+ Otherwise, assume UTC.
221
+
222
+ Parameters
223
+ ----------
224
+ pd_object : DatetimeIndex or Timestamp
225
+
226
+ Returns
227
+ -------
228
+ pandas object localized to or assumed to be UTC.
229
+ """
230
+ try:
231
+ pd_object_utc = pd_object.tz_convert('UTC')
232
+ except TypeError:
233
+ pd_object_utc = pd_object
234
+ return pd_object_utc
211
235
 
212
236
 
213
237
  def _doy_to_datetimeindex(doy, epoch_year=2014):
@@ -230,7 +254,7 @@ def _doy_to_datetimeindex(doy, epoch_year=2014):
230
254
 
231
255
 
232
256
  def _datetimelike_scalar_to_doy(time):
233
- return pd.DatetimeIndex([pd.Timestamp(time)]).dayofyear
257
+ return _pandas_to_doy(_datetimelike_scalar_to_datetimeindex(time))
234
258
 
235
259
 
236
260
  def _datetimelike_scalar_to_datetimeindex(time):