diff --git a/docs/sphinx/source/api.rst b/docs/sphinx/source/api.rst index defe3e97a9..13b8c6f8c9 100644 --- a/docs/sphinx/source/api.rst +++ b/docs/sphinx/source/api.rst @@ -308,25 +308,33 @@ Functions for fitting diode models ivtools.fit_sdm_cec_sam ivtools.fit_sdm_desoto -Losses ------- +Other +----- .. autosummary:: :toctree: generated/ - losses.soiling_hsu - losses.soiling_kimber + pvsystem.retrieve_sam + pvsystem.systemdef + pvsystem.scale_voltage_current_power -Other ------ +Effects on PV System Output +=========================== .. autosummary:: :toctree: generated/ - pvsystem.retrieve_sam - pvsystem.systemdef - pvsystem.scale_voltage_current_power + snow.coverage_nrel + snow.fully_covered_nrel + snow.dc_loss_nrel + +.. autosummary:: + :toctree: generated/ + + losses.soiling_hsu + losses.soiling_kimber + Tracking @@ -576,4 +584,4 @@ Methods for manipulating irradiance for temporal or spatial considerations .. autosummary:: :toctree: generated/ - scaling.wvm \ No newline at end of file + scaling.wvm diff --git a/docs/sphinx/source/whatsnew/v0.7.2.rst b/docs/sphinx/source/whatsnew/v0.7.2.rst index 2eda805c1e..5d1bb1774c 100644 --- a/docs/sphinx/source/whatsnew/v0.7.2.rst +++ b/docs/sphinx/source/whatsnew/v0.7.2.rst @@ -22,6 +22,9 @@ Enhancements * Add Kimber soiling model :py:func:`pvlib.losses.soiling_kimber`. (:pull:`860`) * Add :func:`~pvlib.iotools.read_pvgis_tmy` for files downloaded using the PVGIS tool. (:issue:`880`) +* Add new module :py:mod:`pvlib.snow` to contain models related to snow coverage and effects on a PV system. (:pull:`764`) +* Add snow coverage model :py:func:`pvlib.snow.coverage_nrel` and function to identify when modules are fully covered by snow :py:func:`pvlib.snow.fully_covered_nrel`. (:issue:`577`) +* Add function :py:func:`pvlib.snow.dc_loss_nrel` for effect of snow coverage on DC output. (:pull:`764`) Bug fixes ~~~~~~~~~ @@ -83,3 +86,4 @@ Contributors * Karthikeyan Singaravelan (:ghuser:`tirkarthi`) * Siyan (Veronica) Guo (:ghuser:`veronicaguo`) * Eric Fitch (:ghuser:`ericf900`) +* Joseph Palakapilly (:ghuser:`JPalakapilly`) diff --git a/pvlib/__init__.py b/pvlib/__init__.py index 098202c819..df2b3c5c74 100644 --- a/pvlib/__init__.py +++ b/pvlib/__init__.py @@ -15,3 +15,4 @@ from pvlib import singlediode # noqa: F401 from pvlib import bifacial # noqa: F401 from pvlib import losses # noqa: F401 +from pvlib import snow # noqa: F401 diff --git a/pvlib/snow.py b/pvlib/snow.py new file mode 100644 index 0000000000..bf40c7b995 --- /dev/null +++ b/pvlib/snow.py @@ -0,0 +1,187 @@ +""" +The ``snow`` module contains functions that model module snow cover and the +associated effects on PV module output +""" + +import numpy as np +import pandas as pd +from pvlib.tools import sind + + +def _time_delta_in_hours(times): + delta = times.to_series().diff() + return delta.dt.total_seconds().div(3600) + + +def fully_covered_nrel(snowfall, threshold_snowfall=1.): + ''' + Calculates the timesteps when the row's slant height is fully covered + by snow. + + Parameters + ---------- + snowfall : Series + Accumulated snowfall in each time period [cm] + + threshold_snowfall : float, default 1.0 + Hourly snowfall above which snow coverage is set to the row's slant + height. [cm/hr] + + Returns + ---------- + boolean: Series + True where the snowfall exceeds the defined threshold to fully cover + the panel. + + Notes + ----- + Implements the model described in [1]_ with minor improvements in [2]_. + + References + ---------- + .. [1] Marion, B.; Schaefer, R.; Caine, H.; Sanchez, G. (2013). + "Measured and modeled photovoltaic system energy losses from snow for + Colorado and Wisconsin locations." Solar Energy 97; pp.112-121. + .. [2] Ryberg, D; Freeman, J. "Integration, Validation, and Application + of a PV Snow Coverage Model in SAM" (2017) NREL Technical Report + NREL/TP-6A20-68705 + ''' + timestep = _time_delta_in_hours(snowfall.index) + hourly_snow_rate = snowfall / timestep + # if we can infer a time frequency, use first snowfall value + # otherwise the first snowfall value is ignored + freq = pd.infer_freq(snowfall.index) + if freq is not None: + timedelta = pd.tseries.frequencies.to_offset(freq) / pd.Timedelta('1h') + hourly_snow_rate.iloc[0] = snowfall[0] / timedelta + else: # can't infer frequency from index + hourly_snow_rate[0] = 0 # replaces NaN + return hourly_snow_rate > threshold_snowfall + + +def coverage_nrel(snowfall, poa_irradiance, temp_air, surface_tilt, + initial_coverage=0, threshold_snowfall=1., + can_slide_coefficient=-80., slide_amount_coefficient=0.197): + ''' + Calculates the fraction of the slant height of a row of modules covered by + snow at every time step. + + Implements the model described in [1]_ with minor improvements in [2]_, + with the change that the output is in fraction of the row's slant height + rather than in tenths of the row slant height. As described in [1]_, model + validation focused on fixed tilt systems. + + Parameters + ---------- + snowfall : Series + Accumulated snowfall within each time period. [cm] + poa_irradiance : Series + Total in-plane irradiance [W/m^2] + temp_air : Series + Ambient air temperature [C] + surface_tilt : numeric + Tilt of module's from horizontal, e.g. surface facing up = 0, + surface facing horizon = 90. [degrees] + initial_coverage : float, default 0 + Fraction of row's slant height that is covered with snow at the + beginning of the simulation. [unitless] + threshold_snowfall : float, default 1.0 + Hourly snowfall above which snow coverage is set to the row's slant + height. [cm/hr] + can_slide_coefficient : float, default -80. + Coefficient to determine if snow can slide given irradiance and air + temperature. [W/(m^2 C)] + slide_amount_coefficient : float, default 0.197 + Coefficient to determine fraction of snow that slides off in one hour. + [unitless] + + Returns + ------- + snow_coverage : Series + The fraction of the slant height of a row of modules that is covered + by snow at each time step. + + Notes + ----- + In [1]_, `can_slide_coefficient` is termed `m`, and the value of + `slide_amount_coefficient` is given in tenths of a module's slant height. + + References + ---------- + .. [1] Marion, B.; Schaefer, R.; Caine, H.; Sanchez, G. (2013). + "Measured and modeled photovoltaic system energy losses from snow for + Colorado and Wisconsin locations." Solar Energy 97; pp.112-121. + .. [2] Ryberg, D; Freeman, J. (2017). "Integration, Validation, and + Application of a PV Snow Coverage Model in SAM" NREL Technical Report + NREL/TP-6A20-68705 + ''' + + # find times with new snowfall + new_snowfall = fully_covered_nrel(snowfall, threshold_snowfall) + + # set up output Series + snow_coverage = pd.Series(np.nan, index=poa_irradiance.index) + + # determine amount that snow can slide in each timestep + can_slide = temp_air > poa_irradiance / can_slide_coefficient + slide_amt = slide_amount_coefficient * sind(surface_tilt) * \ + _time_delta_in_hours(poa_irradiance.index) + slide_amt[~can_slide] = 0. + # don't slide during snow events + slide_amt[new_snowfall] = 0. + # don't slide in the interval preceding the snowfall data + slide_amt.iloc[0] = 0 + + # build time series of cumulative slide amounts + sliding_period_ID = new_snowfall.cumsum() + cumulative_sliding = slide_amt.groupby(sliding_period_ID).cumsum() + + # set up time series of snow coverage without any sliding applied + snow_coverage[new_snowfall] = 1.0 + if np.isnan(snow_coverage.iloc[0]): + snow_coverage.iloc[0] = initial_coverage + snow_coverage.ffill(inplace=True) + snow_coverage -= cumulative_sliding + + # clean up periods where row is completely uncovered + return snow_coverage.clip(lower=0) + + +def dc_loss_nrel(snow_coverage, num_strings): + ''' + Calculates the fraction of DC capacity lost due to snow coverage. + + DC capacity loss assumes that if a string is partially covered by snow, + the string's capacity is lost; see [1]_, Eq. 11.8. + + Module orientation is accounted for by specifying the number of cell + strings in parallel along the slant height. + For example, a typical 60-cell module has 3 parallel strings, each + comprising 20 cells in series, with the cells arranged in 6 columns of 10 + cells each. For a row consisting of single modules, if the module is + mounted in portrait orientation, i.e., the row slant height is along a + column of 10 cells, there is 1 string in parallel along the row slant + height, so `num_strings=1`. In contrast, if the module is mounted in + landscape orientation with the row slant height comprising 6 cells, there + are 3 parallel strings along the row slant height, so `num_strings=3`. + + Parameters + ---------- + snow_coverage : numeric + The fraction of row slant height covered by snow at each time step. + + num_strings: int + The number of parallel-connected strings along a row slant height. + + Returns + ------- + loss : numeric + fraction of DC capacity loss due to snow coverage at each time step. + + References + ---------- + .. [1] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical + Reference Update", NREL Technical Report NREL/TP-6A20-67399. + Available at https://www.nrel.gov/docs/fy18osti/67399.pdf + ''' + return np.ceil(snow_coverage * num_strings) / num_strings diff --git a/pvlib/tests/test_snow.py b/pvlib/tests/test_snow.py new file mode 100644 index 0000000000..c5ba88a467 --- /dev/null +++ b/pvlib/tests/test_snow.py @@ -0,0 +1,97 @@ +import numpy as np +import pandas as pd + +from pandas.util.testing import assert_series_equal + +from pvlib import snow +from pvlib.tools import sind + + +def test_fully_covered_nrel(): + dt = pd.date_range(start="2019-1-1 12:00:00", end="2019-1-1 18:00:00", + freq='1h') + snowfall_data = pd.Series([1, 5, .6, 4, .23, -5, 19], index=dt) + expected = pd.Series([False, True, False, True, False, False, True], + index=dt) + fully_covered = snow.fully_covered_nrel(snowfall_data) + assert_series_equal(expected, fully_covered) + + +def test_coverage_nrel_hourly(): + surface_tilt = 45 + slide_amount_coefficient = 0.197 + dt = pd.date_range(start="2019-1-1 10:00:00", end="2019-1-1 17:00:00", + freq='1h') + poa_irradiance = pd.Series([400, 200, 100, 1234, 134, 982, 100, 100], + index=dt) + temp_air = pd.Series([10, 2, 10, 1234, 34, 982, 10, 10], index=dt) + snowfall_data = pd.Series([1, .5, .6, .4, .23, -5, .1, .1], index=dt) + snow_coverage = snow.coverage_nrel( + snowfall_data, poa_irradiance, temp_air, surface_tilt, + threshold_snowfall=0.6) + + slide_amt = slide_amount_coefficient * sind(surface_tilt) + covered = 1.0 - slide_amt * np.array([0, 1, 2, 3, 4, 5, 6, 7]) + expected = pd.Series(covered, index=dt) + assert_series_equal(expected, snow_coverage) + + +def test_coverage_nrel_subhourly(): + surface_tilt = 45 + slide_amount_coefficient = 0.197 + dt = pd.date_range(start="2019-1-1 11:00:00", end="2019-1-1 14:00:00", + freq='15T') + poa_irradiance = pd.Series([400, 200, 100, 1234, 134, 982, 100, 100, 100, + 100, 100, 100, 0], + index=dt) + temp_air = pd.Series([10, 2, 10, 1234, 34, 982, 10, 10, 10, 10, -10, -10, + 10], index=dt) + snowfall_data = pd.Series([1, .5, .6, .4, .23, -5, .1, .1, 0., 1., 0., 0., + 0.], index=dt) + snow_coverage = snow.coverage_nrel( + snowfall_data, poa_irradiance, temp_air, surface_tilt) + slide_amt = slide_amount_coefficient * sind(surface_tilt) * 0.25 + covered = np.append(np.array([1., 1., 1., 1.]), + 1.0 - slide_amt * np.array([1, 2, 3, 4, 5])) + covered = np.append(covered, np.array([1., 1., 1., 1. - slide_amt])) + expected = pd.Series(covered, index=dt) + assert_series_equal(expected, snow_coverage) + + +def test_fully_covered_nrel_irregular(): + # test when frequency is not specified and can't be inferred + dt = pd.DatetimeIndex(["2019-1-1 11:00:00", "2019-1-1 14:30:00", + "2019-1-1 15:07:00", "2019-1-1 14:00:00"]) + snowfall_data = pd.Series([1, .5, .6, .4], index=dt) + snow_coverage = snow.fully_covered_nrel(snowfall_data, + threshold_snowfall=0.5) + covered = np.array([False, False, True, False]) + expected = pd.Series(covered, index=dt) + assert_series_equal(expected, snow_coverage) + + +def test_coverage_nrel_initial(): + surface_tilt = 45 + slide_amount_coefficient = 0.197 + dt = pd.date_range(start="2019-1-1 10:00:00", end="2019-1-1 17:00:00", + freq='1h') + poa_irradiance = pd.Series([400, 200, 100, 1234, 134, 982, 100, 100], + index=dt) + temp_air = pd.Series([10, 2, 10, 1234, 34, 982, 10, 10], index=dt) + snowfall_data = pd.Series([0, .5, .6, .4, .23, -5, .1, .1], index=dt) + snow_coverage = snow.coverage_nrel( + snowfall_data, poa_irradiance, temp_air, surface_tilt, + initial_coverage=0.5, threshold_snowfall=1.) + slide_amt = slide_amount_coefficient * sind(surface_tilt) + covered = 0.5 - slide_amt * np.array([0, 1, 2, 3, 4, 5, 6, 7]) + covered = np.where(covered < 0, 0., covered) + expected = pd.Series(covered, index=dt) + assert_series_equal(expected, snow_coverage) + + +def test_dc_loss_nrel(): + num_strings = 8 + snow_coverage = pd.Series([1, 1, .5, .6, .2, .4, 0]) + expected = pd.Series([1, 1, .5, .625, .25, .5, 0]) + actual = snow.dc_loss_nrel(snow_coverage, num_strings) + assert_series_equal(expected, actual)