Repository: unit8co/darts

Issue:
[BUG] TimeSeries slicing silently changes the TimeSeries' frequency
**Describe the bug**
When using BusinessDay as frequency, selecting a slice e.g. ts[1:5] can result in the slice having a different frequency (Day).
This is problematic because subsequent concatenations can fail because the two TimeSeries won't be contiguous at the altered frequency (but would be contiguous at the original frequency)

**To Reproduce**
```python
from darts import TimeSeries, concatenate
import pandas as pd
import numpy as np

offset = pd.offsets.BusinessDay()   # Closed on Saturdays & Sundays
dates1 = pd.date_range("2023-11-01","2023-11-26", freq=offset)   # the 26th is a sunday, last date is the 24th
values1 = np.ones(len(dates1))
ts1  = TimeSeries.from_times_and_values(dates1,values1)
dates2 = pd.date_range("2023-11-26","2023-12-06", freq=offset)  # First date is the 27th
values2 = np.ones(len(dates2))
ts2 = TimeSeries.from_times_and_values(dates2, values2)

concatenate((ts1,ts2)) # works
print(ts1.freq) # <BusinessDay>
ts_subset = ts1[-4:]
print(ts_subset.freq) # <Day>
concatenate((ts_subset, ts2)) # Fails 
````

**Expected behavior**
TimeSeries slicing should not silently alter the frequency

**System (please complete the following information):**
 - Python version: 3.10
 - darts version: Tested on master and 0.25



Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45fa2eb6..fa602543 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ but cannot always guarantee backwards compatibility. Changes that may **break co
 
 ### For users of the library:
 **Improved**
+- Improvements to `TimeSeries`:
+  - Improved the time series frequency inference when using slices or pandas DatetimeIndex as keys for `__getitem__`. [#2152](https://github.com/unit8co/darts/pull/2152) by [DavidKleindienst](https://github.com/DavidKleindienst).
 
 **Fixed**
 - Fixed a bug when using a `TorchForecastingModel` with `use_reversible_instance_norm=True` and predicting with `n > output_chunk_length`. The input normalized multiple times. [#2160](https://github.com/unit8co/darts/pull/2160) by [FourierMourier](https://github.com/FourierMourier).
diff --git a/darts/timeseries.py b/darts/timeseries.py
index 1792dfe3..0a715d10 100644
--- a/darts/timeseries.py
+++ b/darts/timeseries.py
@@ -4899,12 +4899,13 @@ class TimeSeries:
                 logger,
             )
 
-        def _set_freq_in_xa(xa_: xr.DataArray):
+        def _set_freq_in_xa(xa_: xr.DataArray, freq=None):
             # mutates the DataArray to make sure it contains the freq
             if isinstance(xa_.get_index(self._time_dim), pd.DatetimeIndex):
-                inferred_freq = xa_.get_index(self._time_dim).inferred_freq
-                if inferred_freq is not None:
-                    xa_.get_index(self._time_dim).freq = to_offset(inferred_freq)
+                if freq is None:
+                    freq = xa_.get_index(self._time_dim).inferred_freq
+                if freq is not None:
+                    xa_.get_index(self._time_dim).freq = to_offset(freq)
                 else:
                     xa_.get_index(self._time_dim).freq = self._freq
 
@@ -4920,8 +4921,9 @@ class TimeSeries:
             xa_ = self._xa.sel({self._time_dim: key})
 
             # indexing may discard the freq so we restore it...
-            # TODO: unit-test this
-            _set_freq_in_xa(xa_)
+            # if the DateTimeIndex already has an associated freq, use it
+            # otherwise key.freq is None and the freq will be inferred
+            _set_freq_in_xa(xa_, key.freq)
 
             return self.__class__(xa_)
         elif isinstance(key, pd.RangeIndex):
@@ -4951,18 +4953,43 @@ class TimeSeries:
                 key.stop, (int, np.int64)
             ):
                 xa_ = self._xa.isel({self._time_dim: key})
-                _set_freq_in_xa(
-                    xa_
-                )  # indexing may discard the freq so we restore it...
+                if isinstance(key.step, (int, np.int64)):
+                    # new frequency is multiple of original
+                    new_freq = key.step * self.freq
+                elif key.step is None:
+                    new_freq = self.freq
+                else:
+                    new_freq = None
+                    raise_log(
+                        ValueError(
+                            f"Invalid slice step={key.step}. Only supports integer steps or `None`."
+                        ),
+                        logger=logger,
+                    )
+                # indexing may discard the freq so we restore it...
+                _set_freq_in_xa(xa_, new_freq)
                 return self.__class__(xa_)
             elif isinstance(key.start, pd.Timestamp) or isinstance(
                 key.stop, pd.Timestamp
             ):
                 _check_dt()
+                if isinstance(key.step, (int, np.int64)):
+                    # new frequency is multiple of original
+                    new_freq = key.step * self.freq
+                elif key.step is None:
+                    new_freq = self.freq
+                else:
+                    new_freq = None
+                    raise_log(
+                        ValueError(
+                            f"Invalid slice step={key.step}. Only supports integer steps or `None`."
+                        ),
+                        logger=logger,
+                    )
 
                 # indexing may discard the freq so we restore it...
                 xa_ = self._xa.sel({self._time_dim: key})
-                _set_freq_in_xa(xa_)
+                _set_freq_in_xa(xa_, new_freq)
                 return self.__class__(xa_)
 
         # handle simple types:
@@ -5030,13 +5057,18 @@ class TimeSeries:
                     # We have to restore a RangeIndex. But first we need to
                     # check the list is corresponding to a RangeIndex.
                     min_idx, max_idx = min(key), max(key)
-                    raise_if_not(
-                        key[0] == min_idx
+                    if (
+                        not key[0] == min_idx
                         and key[-1] == max_idx
-                        and max_idx + 1 - min_idx == len(key),
-                        "Indexing a TimeSeries with a list requires the list to contain monotically "
-                        + "increasing integers with no gap.",
-                    )
+                        and max_idx + 1 - min_idx == len(key)
+                    ):
+                        raise_log(
+                            ValueError(
+                                "Indexing a TimeSeries with a list requires the list to "
+                                "contain monotonically increasing integers with no gap."
+                            ),
+                            logger=logger,
+                        )
                     new_idx = orig_idx[min_idx : max_idx + 1]
                     xa_ = xa_.assign_coords({self._time_dim: new_idx})
 


Test patch:
diff --git a/darts/tests/test_timeseries.py b/darts/tests/test_timeseries.py
index db5459fe..2a2b7c27 100644
--- a/darts/tests/test_timeseries.py
+++ b/darts/tests/test_timeseries.py
@@ -17,7 +17,6 @@ from darts.utils.timeseries_generation import (
 
 
 class TestTimeSeries:
-
     times = pd.date_range("20130101", "20130110", freq="D")
     pd_series1 = pd.Series(range(10), index=times)
     pd_series2 = pd.Series(range(5, 15), index=times)
@@ -933,6 +932,55 @@ class TestTimeSeries:
         with pytest.raises(KeyError):
             _ = series[pd.RangeIndex(start, stop=end + 2 * freq, step=freq)]
 
+    def test_getitem_frequency_inferrence(self):
+        ts = self.series1
+        assert ts.freq == "D"
+        ts_got = ts[1::2]
+        assert ts_got.freq == "2D"
+        ts_got = ts[pd.Timestamp("20130103") :: 2]
+        assert ts_got.freq == "2D"
+
+        idx = pd.DatetimeIndex(["20130102", "20130105", "20130108"])
+        ts_idx = ts[idx]
+        assert ts_idx.freq == "3D"
+
+        # With BusinessDay frequency
+        offset = pd.offsets.BusinessDay()  # Closed on Saturdays & Sundays
+        dates1 = pd.date_range("20231101", "20231126", freq=offset)
+        values1 = np.ones(len(dates1))
+        ts = TimeSeries.from_times_and_values(dates1, values1)
+        assert ts.freq == ts[-4:].freq
+
+        # Using a step parameter
+        assert ts[1::3].freq == 3 * ts.freq
+        assert ts[pd.Timestamp("20231102") :: 4].freq == 4 * ts.freq
+
+        # Indexing with datetime index
+        idx = pd.date_range("20231101", "20231126", freq=offset)
+        assert ts[idx].freq == idx.freq
+
+    def test_getitem_frequency_inferrence_integer_index(self):
+        start = 2
+        freq = 3
+        ts = TimeSeries.from_times_and_values(
+            times=pd.RangeIndex(
+                start=start, stop=start + freq * len(self.series1), step=freq
+            ),
+            values=self.series1.values(),
+        )
+
+        assert ts.freq == freq
+        ts_got = ts[1::2]
+        assert ts_got.start_time() == start + freq
+        assert ts_got.freq == 2 * freq
+
+        idx = pd.RangeIndex(
+            start=start + 2 * freq, stop=start + 4 * freq, step=2 * freq
+        )
+        ts_idx = ts[idx]
+        assert ts_idx.start_time() == idx[0]
+        assert ts_idx.freq == 2 * freq
+
     def test_fill_missing_dates(self):
         with pytest.raises(ValueError):
             # Series cannot have date holes without automatic filling
@@ -1050,7 +1098,6 @@ class TestTimeSeries:
 
             series_target = TimeSeries.from_dataframe(df_full, time_col="date")
             for df, df_name in zip([df_full, df_holes], ["full", "holes"]):
-
                 # fill_missing_dates will find multiple inferred frequencies (i.e. for 'B' it finds {'B', 'D'})
                 if offset_alias in offset_aliases_raise:
                     with pytest.raises(ValueError):
@@ -1519,7 +1566,6 @@ class TestTimeSeries:
 
 
 class TestTimeSeriesConcatenate:
-
     #
     # COMPONENT AXIS TESTS
     #
@@ -1735,7 +1781,6 @@ class TestTimeSeriesConcatenate:
 
 
 class TestTimeSeriesHierarchy:
-
     components = ["total", "a", "b", "x", "y", "ax", "ay", "bx", "by"]
 
     hierarchy = {
@@ -1912,7 +1957,6 @@ class TestTimeSeriesHierarchy:
 
 
 class TestTimeSeriesHeadTail:
-
     ts = TimeSeries(
         xr.DataArray(
             np.random.rand(10, 10, 10),
@@ -2185,7 +2229,6 @@ class TestTimeSeriesFromDataFrame:
 
 
 class TestSimpleStatistics:
-
     times = pd.date_range("20130101", "20130110", freq="D")
     values = np.random.rand(10, 2, 100)
     ar = xr.DataArray(