Repository: pvlib/pvlib-python

Issue:
More descriptive errors for `detect_clearsky`
**Is your feature request related to a problem? Please describe.**
Using a window length that is too short relative to data period in `detect_clearsky` produces cryptic errors

**Describe the solution you'd like**
Raise a ValueError that directly explains the problem

Example:
```
import pvlib
import pandas as pd
import numpy as np
start = '2012-01-01'
end = '2015-01-01'
freq = '60T'
times = pd.date_range(start=start, end=end, freq=freq)
x1 = pd.Series(np.random.rand(len(times)), index=times)
x2 = pd.Series(np.random.rand(len(times)), index=times)

pvlib.clearsky.detect_clearsky(x1, x2,
                          window_length=90, mean_diff=75, max_diff=75,
                          lower_line_length=-45, upper_line_length=80,
                          var_diff=0.032, slope_dev=75)
```
Results in the following:
```
ValueError                                Traceback (most recent call last)
Cell In[28], line 11
      8 x1 = pd.Series(np.random.rand(len(times)), index=times)
      9 x2 = pd.Series(np.random.rand(len(times)), index=times)
---> 11 pvlib.clearsky.detect_clearsky(x1, x2,
     12                           window_length=90, mean_diff=75, max_diff=75,
     13                           lower_line_length=-45, upper_line_length=80,
     14                           var_diff=0.032, slope_dev=75)

File ~/opt/anaconda3/envs/rdtools3_testing/lib/python3.10/site-packages/pvlib/clearsky.py:854, in detect_clearsky(measured, clearsky, times, infer_limits, window_length, mean_diff, max_diff, lower_line_length, upper_line_length, var_diff, slope_dev, max_iterations, return_components)
    850 clear_line_length = _line_length_windowed(
    851     scaled_clear, H, samples_per_window, sample_interval)
    853 line_diff = meas_line_length - clear_line_length
--> 854 slope_max_diff = _max_diff_windowed(
    855     meas - scaled_clear, H, samples_per_window)
    856 # evaluate comparison criteria
    857 c1 = np.abs(meas_mean - alpha*clear_mean) < mean_diff

File ~/opt/anaconda3/envs/rdtools3_testing/lib/python3.10/site-packages/pvlib/clearsky.py:602, in _max_diff_windowed(data, H, samples_per_window)
    600 def _max_diff_windowed(data, H, samples_per_window):
    601     raw = np.diff(data)
--> 602     raw = np.abs(raw[H[:-1, ]]).max(axis=0)
    603     return _to_centered_series(raw, data.index, samples_per_window)

File ~/opt/anaconda3/envs/rdtools3_testing/lib/python3.10/site-packages/numpy/core/_methods.py:40, in _amax(a, axis, out, keepdims, initial, where)
     38 def _amax(a, axis=None, out=None, keepdims=False,
     39           initial=_NoValue, where=True):
---> 40     return umr_maximum(a, axis, None, out, keepdims, initial, where)

ValueError: zero-size array to reduction operation maximum which has no identity
```

And the following produces a different error, even though the `window_length` > period as required by the docstring (Does the window length need to be a multiple of the period?)
```
import pvlib
import pandas as pd
import numpy as np
start = '2012-01-01'
end = '2015-01-01'
freq = '60T'
times = pd.date_range(start=start, end=end, freq=freq)
x1 = pd.Series(np.random.rand(len(times)), index=times)
x2 = pd.Series(np.random.rand(len(times)), index=times)

pvlib.clearsky.detect_clearsky(x1, x2,
                          window_length=175, mean_diff=75, max_diff=75,
                          lower_line_length=-45, upper_line_length=80,
                          var_diff=0.032, slope_dev=75)
```
result:
```
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[32], line 11
      8 x1 = pd.Series(np.random.rand(len(times)), index=times)
      9 x2 = pd.Series(np.random.rand(len(times)), index=times)
---> 11 pvlib.clearsky.detect_clearsky(x1, x2,
     12                           window_length=175, mean_diff=75, max_diff=75,
     13                           lower_line_length=-45, upper_line_length=80,
     14                           var_diff=0.032, slope_dev=75)

File ~/opt/anaconda3/envs/rdtools3_testing/lib/python3.10/site-packages/pvlib/clearsky.py:891, in detect_clearsky(measured, clearsky, times, infer_limits, window_length, mean_diff, max_diff, lower_line_length, upper_line_length, var_diff, slope_dev, max_iterations, return_components)
    885     except AttributeError:
    886         message = "Optimizer exited unsuccessfully: \
    887                    No message explaining the failure was returned. \
    888                    If you would like to see this message, please \
    889                    update your scipy version (try version 1.8.0 \
    890                    or beyond)."
--> 891     raise RuntimeError(message)
    893 else:
    894     alpha = optimize_result.x

RuntimeError: Optimizer exited unsuccessfully: NaN result encountered.
```


Discussion:
Thanks @mdeceglie. I've had on my list for a while to implement [this extension](https://research-hub.nrel.gov/en/publications/clear-sky-detection-for-pv-degradation-analysis-using-multiple-re-2) of the detect clearsky algorithm. In the meantime, PRs welcome to improve the error detection/docstrings.
@cwhanse do you have insights into the second example? Is the requirement specifically that the window length must be greater than the period and a multiple of the period? I am speculating on the nature of that problem.
In the second case: the window contains 2 data values. Buried in the detect_clearsky algorithm is the calculation of a _sample_ standard deviation of slopes between points in an interval. With window length 175 and data frequency of 60, there are two points per interval, hence one slope, hence the divisor in the standard deviation is N-1 = 0. That returns nan for the standard deviation which then results in no clear points detected, and subsequent failure of the optimizer.

I think the fix is either to edit the docstring for `window_length` to say "at least 3 periods". Or, we can build a fallback into that calculation of slope std. dev. in the case where there's only 1 slope per interval.
> In the meantime, PRs welcome to improve the error detection/docstrings.

I'm not sure if this is best to be added here or in a new issue, but I ran into a problem where `measured` and `clearsky` were int64 (used `pd.read_csv()` on a file with only integer values). This caused `ValueError: cannot convert float NaN to integer`, and it was not immediately obvious that inputs needed to be converted to floats.



Patch:
diff --git a/docs/sphinx/source/whatsnew/v0.11.2.rst b/docs/sphinx/source/whatsnew/v0.11.2.rst
index f3b1ec338a..c8e4b6c803 100644
--- a/docs/sphinx/source/whatsnew/v0.11.2.rst
+++ b/docs/sphinx/source/whatsnew/v0.11.2.rst
@@ -29,6 +29,8 @@ Documentation
   page. (:issue:`2202`, :pull:`2226`)
 * Updated :py:func:`~pvlib.irradiance.reindl` to include definitions of terms
   and a new "notes" section (:issue:`2183`, :pull:`2193`)
+* Clarified the error message in :py:func:`~pvlib.clearsky.detect_clearsky` when
+  windows contain fewer than three data points (:issue:`2005`, :pull:`2281`)
 * Added a new :ref:`nomenclature` page, in place of the Variables and Symbols
   page, using the sphinx glossary directive. (:issue:`1421`, :pull:`2234`)
 * Explained how to write docstrings for new functions in :ref:`example-docstring`
diff --git a/pvlib/clearsky.py b/pvlib/clearsky.py
index 66edf34926..eb97691ba3 100644
--- a/pvlib/clearsky.py
+++ b/pvlib/clearsky.py
@@ -683,22 +683,26 @@ def detect_clearsky(measured, clearsky, times=None, infer_limits=False,
                     var_diff=0.005, slope_dev=8, max_iterations=20,
                     return_components=False):
     """
-    Detects clear sky times according to the algorithm developed by Reno
-    and Hansen for GHI measurements. The algorithm [1]_ was designed and
-    validated for analyzing GHI time series only. Users may attempt to
-    apply it to other types of time series data using different filter
-    settings, but should be skeptical of the results.
+    Detects clear sky times using the algorithm developed by Reno
+    and Hansen.
 
-    The algorithm detects clear sky times by comparing statistics for a
+    The algorithm [1]_ was designed and
+    validated for analyzing GHI time series. Jordan and Hansen [2]_ extended
+    the algorithm to plane-of-array (POA) irradiance measurements.
+
+    The algorithm [1]_ detects clear sky times by comparing statistics for a
     measured time series and an expected clearsky time series.
     Statistics are calculated using a sliding time window (e.g., 10
     minutes). An iterative algorithm identifies clear periods, uses the
     identified periods to estimate bias in the clearsky data, scales the
     clearsky data and repeats.
 
-    Clear times are identified by meeting 5 criteria. Default values for
+    Clear times are identified by meeting five criteria. Default values for
     these thresholds are appropriate for 10 minute windows of 1 minute
-    GHI data.
+    GHI data. For data at longer intervals, it is recommended
+    to set ``infer_limits=True`` to use the thresholds from [2]_.
+
+    For POA data, ``clearsky`` must be on the same plane as ``measured``.
 
     Parameters
     ----------
@@ -713,8 +717,8 @@ def detect_clearsky(measured, clearsky, times=None, infer_limits=False,
         If True, does not use passed in kwargs (or defaults), but instead
         interpolates these values from Table 1 in [2]_.
     window_length : int, default 10
-        Length of sliding time window in minutes. Must be greater than 2
-        periods.
+        Length of sliding time window in minutes. Each window must contain at
+        least three data points.
     mean_diff : float, default 75
         Threshold value for agreement between mean values of measured
         and clearsky in each interval, see Eq. 6 in [1]. [W/m2]
@@ -723,8 +727,6 @@ def detect_clearsky(measured, clearsky, times=None, infer_limits=False,
         clearsky values in each interval, see Eq. 7 in [1]. [W/m2]
     lower_line_length : float, default -5
         Lower limit of line length criterion from Eq. 8 in [1].
-        Criterion satisfied when lower_line_length < line length difference
-        < upper_line_length.
     upper_line_length : float, default 10
         Upper limit of line length criterion from Eq. 8 in [1].
     var_diff : float, default 0.005
@@ -736,7 +738,7 @@ def detect_clearsky(measured, clearsky, times=None, infer_limits=False,
         change in successive values, see Eqs. 12 through 14 in [1].
     max_iterations : int, default 20
         Maximum number of times to apply a different scaling factor to
-        the clearsky and redetermine clear_samples. Must be 1 or larger.
+        the clearsky and redetermine ``clear_samples``. Must be 1 or larger.
     return_components : bool, default False
         Controls if additional output should be returned. See below.
 
@@ -748,19 +750,23 @@ def detect_clearsky(measured, clearsky, times=None, infer_limits=False,
 
     components : OrderedDict, optional
         Dict of arrays of whether or not the given time window is clear
-        for each condition. Only provided if return_components is True.
+        for each condition. Only provided if ``return_components`` is True.
 
     alpha : scalar, optional
-        Scaling factor applied to the clearsky_ghi to obtain the
-        detected clear_samples. Only provided if return_components is
+        Scaling factor applied to ``clearsky`` to obtain the
+        detected ``clear_samples``. Only provided if ``return_components`` is
         True.
 
     Raises
     ------
     ValueError
-        If measured is not a Series and times is not provided
+        If ``measured`` is not a Series and times is not provided.
+    ValueError
+        If a window contains less than three data points.
+    ValueError
+        If the measured data is not sufficient to fill a window.
     NotImplementedError
-        If timestamps are not equally spaced
+        If timestamps are not equally spaced.
 
     References
     ----------
@@ -812,6 +818,13 @@ def detect_clearsky(measured, clearsky, times=None, infer_limits=False,
     sample_interval, samples_per_window = \
         tools._get_sample_intervals(times, window_length)
 
+    if samples_per_window < 3:
+        raise ValueError(f"Samples per window of {samples_per_window}"
+                         " found. Each window must contain at least 3 data"
+                         " points."
+                         f" Window length of {window_length} found; increase"
+                         f" window length to {3*sample_interval} or longer.")
+
     # if infer_limits, find threshold values using the sample interval
     if infer_limits:
         window_length, mean_diff, max_diff, lower_line_length, \


Test patch:
diff --git a/pvlib/tests/test_clearsky.py b/pvlib/tests/test_clearsky.py
index 23d39a5205..f7300b98a9 100644
--- a/pvlib/tests/test_clearsky.py
+++ b/pvlib/tests/test_clearsky.py
@@ -674,10 +674,16 @@ def test_detect_clearsky_missing_index(detect_clearsky_data):
 
 def test_detect_clearsky_not_enough_data(detect_clearsky_data):
     expected, cs = detect_clearsky_data
-    with pytest.raises(ValueError, match='have at least'):
+    with pytest.raises(ValueError, match='times has only'):
         clearsky.detect_clearsky(expected['GHI'], cs['ghi'], window_length=60)
 
 
+def test_detect_clearsky_window_too_short(detect_clearsky_data):
+    expected, cs = detect_clearsky_data
+    with pytest.raises(ValueError, match="Samples per window of "):
+        clearsky.detect_clearsky(expected['GHI'], cs['ghi'], window_length=2)
+
+
 @pytest.mark.parametrize("window_length", [5, 10, 15, 20, 25])
 def test_detect_clearsky_optimizer_not_failed(
     detect_clearsky_data, window_length