diff --git a/CHANGELOG.md b/CHANGELOG.md index d97cefb5ca..6a5961e48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ but cannot always guarantee backwards compatibility. Changes that may **break co **Dependencies** +- Moved `xarray` from core dependencies to optional dependencies. [#2974](https://github.com/unit8co/darts/pull/2974) by [Jakub Chłapek](https://github.com/jakubchlapek). - We set an upper version cap on `scikit-learn<1.8.0` until CatBoost officially supports it. [#2972](https://github.com/unit8co/darts/pull/2972) by [Dennis Bader](https://github.com/dennisbader). ### For developers of the library: diff --git a/darts/ad/utils.py b/darts/ad/utils.py index 749312474b..842539080b 100644 --- a/darts/ad/utils.py +++ b/darts/ad/utils.py @@ -657,28 +657,31 @@ def _plot_series(series, ax_id, linewidth, label_name, **kwargs): label_name Name that will appear in the legend. """ - data_array = series.data_array(copy=False) - for i, c in enumerate(data_array.component[:10]): - comp = data_array.sel(component=c) - - if comp.sample.size > 1: - central_series = comp.mean(dim="sample") - low_series = comp.quantile(q=0.05, dim="sample") - high_series = comp.quantile(q=0.95, dim="sample") + for i, c in enumerate(series.components[:10]): + comp_series = series[c] + + if series.is_stochastic: + central_series = comp_series.mean(axis=2) + low_series = comp_series.quantile(q=0.05) + high_series = comp_series.quantile(q=0.95) else: - central_series = comp + central_series = comp_series label_to_use = ( (label_name + ("_" + str(i) if len(series.components) > 1 else "")) if label_name != "" - else "" + str(str(c.values)) + else "" + str(c) ) central_series.plot(ax=ax_id, linewidth=linewidth, label=label_to_use, **kwargs) - if comp.sample.size > 1: + if series.is_stochastic: ax_id.fill_between( - series.time_index, low_series, high_series, alpha=0.25, **kwargs + series.time_index, + low_series.values().flatten(), + high_series.values().flatten(), + alpha=0.25, + **kwargs, ) diff --git a/darts/dataprocessing/dtw/_plot.py b/darts/dataprocessing/dtw/_plot.py index 78c76fccab..232769f9d4 100644 --- a/darts/dataprocessing/dtw/_plot.py +++ b/darts/dataprocessing/dtw/_plot.py @@ -1,7 +1,6 @@ from typing import Union import numpy as np -import xarray as xr from matplotlib import pyplot as plt @@ -142,23 +141,17 @@ def plot_alignment( series1 += series1_y_offset series2 += series2_y_offset - xa1 = series1.data_array(copy=False) - xa2 = series2.data_array(copy=False) - path = self.path() n = len(path) - time_dim1 = series1.time_dim - time_dim2 = series2.time_dim - - x_coords1 = np.array(xa1[time_dim1], dtype=xa1[time_dim1].dtype)[path[:, 0]] - x_coords2 = np.array(xa2[time_dim2], dtype=xa2[time_dim2].dtype)[path[:, 1]] + x_coords1 = series1.time_index[path[:, 0]] + x_coords2 = series2.time_index[path[:, 1]] y_coords1 = series1.univariate_values()[path[:, 0]] y_coords2 = series2.univariate_values()[path[:, 1]] if series1.has_datetime_index: - x_dtype = xa1[time_dim1].dtype + x_dtype = series1.time_index.dtype x_nan = np.datetime64("NaT") else: x_dtype = np.float64 @@ -175,8 +168,7 @@ def plot_alignment( y_coords[1::3] = y_coords2 y_coords[2::3] = np.nan - arr = xr.DataArray(y_coords, dims=["value"], coords={"value": x_coords}) - xr.plot.line(arr, x="value", **args_line) - series1.plot(**args_series1) series2.plot(**args_series2) + + plt.plot(x_coords, y_coords, **args_line) diff --git a/darts/tests/dataprocessing/dtw/test_dtw.py b/darts/tests/dataprocessing/dtw/test_dtw.py index 7c1954012c..70cf1d6794 100644 --- a/darts/tests/dataprocessing/dtw/test_dtw.py +++ b/darts/tests/dataprocessing/dtw/test_dtw.py @@ -154,13 +154,9 @@ def test_itakura_window(self): assert 1 > dist def test_warp(self): - # Support different time dimension names - xa1 = self.series1.data_array().rename({"time": "time1"}) - xa2 = self.series2.data_array().rename({"time": "time2"}) - static_covs = pd.DataFrame([[0.0, 1.0]], columns=["st1", "st2"]) - series1 = TimeSeries.from_xarray(xa1).with_static_covariates(static_covs) - series2 = TimeSeries.from_xarray(xa2).with_static_covariates(static_covs) + series1 = self.series1.with_static_covariates(static_covs) + series2 = self.series2.with_static_covariates(static_covs) series1_copy = series1.copy() series2_copy = series2.copy() @@ -214,6 +210,17 @@ def test_plot(self): align.plot_alignment() plt.close() + def test_plot_alignment_range_index(self): + series1 = TimeSeries.from_values(np.sin(np.linspace(0, 2 * np.pi, 20))) + series2 = TimeSeries.from_values(np.cos(np.linspace(0, 2 * np.pi, 20))) + + assert not series1.has_datetime_index + assert not series2.has_datetime_index + + align = dtw.dtw(series1, series2) + align.plot_alignment() + plt.close() + def test_multivariate(self): n = 2 diff --git a/darts/tests/test_timeseries.py b/darts/tests/test_timeseries.py index 4490ceb2f9..31dee53839 100644 --- a/darts/tests/test_timeseries.py +++ b/darts/tests/test_timeseries.py @@ -7,7 +7,6 @@ import numpy as np import pandas as pd import pytest -import xarray as xr from scipy.stats import kurtosis, skew from darts import TimeSeries, concatenate, slice_intersect @@ -17,7 +16,7 @@ quantile_names, ) from darts.utils.timeseries_generation import constant_timeseries, linear_timeseries -from darts.utils.utils import expand_arr, freqs, generate_index +from darts.utils.utils import XARRAY_AVAILABLE, expand_arr, freqs, generate_index TEST_BACKENDS = ["pandas"] @@ -65,14 +64,38 @@ def test_creation(self): ) assert ts.components.tolist() == ["a", "b", "a_1", "a_2", "b_1"] - # creation using from_xarray() + @pytest.mark.skipif(not XARRAY_AVAILABLE, reason="xarray required") + def test_xarray_creation(self): + import xarray as xr + ar = xr.DataArray( np.random.randn(10, 2, 1), dims=("time", "component", "sample"), coords={"time": self.times, "component": ["a", "b"]}, - name="time series", ) - _ = TimeSeries.from_xarray(ar) + ts = TimeSeries.from_xarray(ar) + assert ts.components.tolist() == ["a", "b"] + + @pytest.mark.skipif(XARRAY_AVAILABLE, reason="xarray required disabled") + def test_xarray_import_error_from_xarray(self): + """Test that from_xarray raises ImportError when xarray is not available.""" + with pytest.raises(ImportError) as exc: + TimeSeries.from_xarray(None) + assert "xarray required" in str(exc.value) + + @pytest.mark.skipif(XARRAY_AVAILABLE, reason="xarray required disabled") + def test_xarray_import_error_data_array(self): + """Test that data_array raises ImportError when xarray is not available.""" + with pytest.raises(ImportError) as exc: + self.series1.data_array() + assert "xarray required" in str(exc.value) + + @pytest.mark.skipif(XARRAY_AVAILABLE, reason="xarray required disabled") + def test_xarray_import_error_resample(self): + """Test that resample raises ImportError when xarray is not available.""" + with pytest.raises(ImportError) as exc: + self.series1.resample(freq="2D") + assert "xarray required" in str(exc.value) def test_from_times_and_values(self): # Test creation from times and values @@ -401,7 +424,10 @@ def test_univariate_component(self): # only the right static covariate column should be retained assert univ_series.static_covariates.sum().sum() == 1.1 + @pytest.mark.skipif(not XARRAY_AVAILABLE, reason="requires xarray") def test_column_names(self): + import xarray as xr + # test the column names resolution columns_before = [ ["0", "1", "2"], @@ -810,7 +836,8 @@ def test_ops(self): # Cannot divide by 0. self.series1 / 0 - def test_ops_array(self): + @pytest.mark.skipif(not XARRAY_AVAILABLE, reason="xarray required") + def test_ops_xarray(self): # can work with xarray directly series2_x = self.series2.data_array(copy=False) assert self.series1 + self.series2 == self.series1 + series2_x @@ -818,6 +845,8 @@ def test_ops_array(self): assert self.series1 * self.series2 == self.series1 * series2_x assert self.series1 / self.series2 == self.series1 / series2_x assert self.series1**self.series2 == self.series1**series2_x + + def test_ops_ndarray(self): # can work with ndarray directly series2_nd = self.series2.all_values(copy=False) assert self.series1 + self.series2 == self.series1 + series2_nd @@ -861,6 +890,55 @@ def test_ops_broadcasting(self, broadcast_components, broadcast_samples): assert seriesA / arrayB == seriesDiv assert seriesA**arrayB == seriesPow + def test_ops_unsupported_types(self): + invalid_operands = ["string", [1, 2, 3], {"key": "value"}, (1, 2)] + + for invalid in invalid_operands: + # Test __add__ + with pytest.raises(TypeError) as exc: + _ = self.series1 + invalid + assert "unsupported operand type(s) for + or add()" in str(exc.value) + + # Test __sub__ + with pytest.raises(TypeError) as exc: + _ = self.series1 - invalid + assert "unsupported operand type(s) for - or sub()" in str(exc.value) + + # Test __mul__ + with pytest.raises(TypeError) as exc: + _ = self.series1 * invalid + assert "unsupported operand type(s) for * or mul()" in str(exc.value) + + # Test __truediv__ + with pytest.raises(TypeError) as exc: + _ = self.series1 / invalid + assert "unsupported operand type(s) for / or truediv()" in str(exc.value) + + # Test __pow__ + with pytest.raises(TypeError) as exc: + _ = self.series1**invalid + assert "unsupported operand type(s) for ** or pow()" in str(exc.value) + + # Test __lt__ + with pytest.raises(TypeError) as exc: + _ = self.series1 < invalid + assert "unsupported operand type(s) for <" in str(exc.value) + + # Test __gt__ + with pytest.raises(TypeError) as exc: + _ = self.series1 > invalid + assert "unsupported operand type(s) for >" in str(exc.value) + + # Test __le__ + with pytest.raises(TypeError) as exc: + _ = self.series1 <= invalid + assert "unsupported operand type(s) for <=" in str(exc.value) + + # Test __ge__ + with pytest.raises(TypeError) as exc: + _ = self.series1 >= invalid + assert "unsupported operand type(s) for >=" in str(exc.value) + def test_getitem_datetime_index(self): series_short: TimeSeries = self.series1.drop_after(pd.Timestamp("20130105")) series_stride_2: TimeSeries = self.series1.with_times_and_values( @@ -1277,6 +1355,7 @@ def test_fillna_value(self): assert not np.isnan(series_no_nan.all_values(copy=False)).any() assert series_1 == series_no_nan + @pytest.mark.skipif(not XARRAY_AVAILABLE, reason="xarray required") def test_resample_timeseries(self): # 01/01/2013 -> 10/01/2013, one value per day: 0 1 2 3 ... 9 times = pd.date_range("20130101", "20130110") @@ -2404,7 +2483,7 @@ def helper_test_prepend(test_series: TimeSeries): def helper_test_prepend_values(test_series: TimeSeries): # reconstruct series seriesA, seriesB = test_series.split_after(pd.Timestamp("20130106")) - arrayA = seriesA.data_array().values + arrayA = seriesA._values prepended = seriesB.prepend_values(arrayA) assert prepended == test_series assert test_series.time_index.equals(prepended.time_index) diff --git a/darts/tests/test_timeseries_static_covariates.py b/darts/tests/test_timeseries_static_covariates.py index 6c52eeb77f..d9ebc0e3e0 100644 --- a/darts/tests/test_timeseries_static_covariates.py +++ b/darts/tests/test_timeseries_static_covariates.py @@ -17,7 +17,7 @@ STATIC_COV_TAG, ) from darts.utils.timeseries_generation import linear_timeseries -from darts.utils.utils import generate_index +from darts.utils.utils import XARRAY_AVAILABLE, generate_index TEST_BACKENDS = ["pandas"] @@ -92,7 +92,8 @@ def test_ts_from_x(self, tag, tmpdir_module): ts, x = setup_tag(tag, ts) kwargs = {tag: x} - self.helper_test_transfer(tag, ts, TimeSeries.from_xarray(ts.data_array())) + if XARRAY_AVAILABLE: + self.helper_test_transfer(tag, ts, TimeSeries.from_xarray(ts.data_array())) self.helper_test_transfer( tag, ts, TimeSeries.from_dataframe(ts.to_dataframe(), **kwargs) ) @@ -922,7 +923,8 @@ def test_ts_methods(self, tag): self.helper_test_transfer(tag, ts, ts.diff()) self.helper_test_transfer(tag, ts, ts.univariate_component(0)) self.helper_test_transfer(tag, ts, ts.map(lambda x: x + 1)) - self.helper_test_transfer(tag, ts, ts.resample(ts.freq)) + if XARRAY_AVAILABLE: + self.helper_test_transfer(tag, ts, ts.resample(ts.freq)) self.helper_test_transfer(tag, ts, ts[:5].append(ts[5:])) self.helper_test_transfer(tag, ts, ts.append_values(ts.all_values())) diff --git a/darts/timeseries.py b/darts/timeseries.py index 9e44f2e747..40e1fb0de4 100644 --- a/darts/timeseries.py +++ b/darts/timeseries.py @@ -56,7 +56,6 @@ import narwhals as nw import numpy as np import pandas as pd -import xarray as xr from narwhals.utils import Implementation from pandas.tseries.frequencies import to_offset from scipy.stats import kurtosis, skew @@ -77,6 +76,7 @@ dataframe_col_to_time_index, expand_arr, generate_index, + is_dataarray, n_steps_between, ) @@ -506,7 +506,7 @@ def __init__( @classmethod def from_xarray( cls, - xa: xr.DataArray, + xa, fill_missing_dates: Optional[bool] = False, freq: Optional[Union[str, int]] = None, fillna_value: Optional[float] = None, @@ -579,6 +579,15 @@ def from_xarray( >>> series.shape (3, 1, 1) """ + try: + import xarray as xr # noqa: F401 + except ImportError: + raise_log( + ImportError( + "xarray required. Please install it with `pip install xarray`." + ), + logger=logger, + ) return cls( times=xa.get_index(xa.dims[TIME_AX]), values=xa.values, @@ -1711,7 +1720,7 @@ def duration(self) -> Union[pd.Timedelta, int]: ================ """ - def data_array(self, copy: bool = True) -> xr.DataArray: + def data_array(self, copy: bool = True): """Return an ``xarray.DataArray`` representation of the series. Parameters @@ -1724,6 +1733,15 @@ def data_array(self, copy: bool = True) -> xr.DataArray: xarray.DataArray An ``xarray.DataArray`` representation of represents the time series. """ + try: + import xarray as xr # noqa: F401 + except ImportError: + raise_log( + ImportError( + "xarray required. Please install it with `pip install xarray`." + ), + logger=logger, + ) xa = xr.DataArray( self._values, dims=(self._time_dim,) + DIMS[-2:], @@ -3638,6 +3656,15 @@ def resample( [2.5] [4.5]] """ + try: + import xarray as xr # noqa: F401 + except ImportError: + raise_log( + ImportError( + "xarray required. Please install it with `pip install xarray`." + ), + logger=logger, + ) method_kwargs = method_kwargs or {} if isinstance(freq, pd.DateOffset): freq = freq.freqstr @@ -3646,7 +3673,6 @@ def resample( indexer={self._time_dim: freq}, **kwargs, ) - if method in SUPPORTED_RESAMPLE_METHODS: applied_method = getattr(xr.core.resample.DataArrayResample, method) new_xa = applied_method(resample, **method_kwargs) @@ -4849,13 +4875,12 @@ def kurtosis(self, **kwargs) -> Self: def _extract_values( self, - other: Union[Self, xr.DataArray, np.ndarray], + other: Union[Self, np.ndarray], ) -> Self: """Extract values from another series or array and check for compatible shapes.""" - if isinstance(other, TimeSeries): other_vals = other._values - elif isinstance(other, xr.DataArray): + elif is_dataarray(other): other_vals = other.values else: other_vals = other @@ -5260,7 +5285,7 @@ def __len__(self): return len(self._values) def __add__(self, other): - if isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + if isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) elif not isinstance(other, (int, float, np.integer)): raise_log( @@ -5277,7 +5302,7 @@ def __radd__(self, other): return self + other def __sub__(self, other): - if isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + if isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) elif not isinstance(other, (int, float, np.integer)): raise_log( @@ -5294,7 +5319,7 @@ def __rsub__(self, other): return other + (-self) def __mul__(self, other): - if isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + if isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) elif not isinstance(other, (int, float, np.integer)): raise_log( @@ -5318,7 +5343,7 @@ def __pow__(self, n): logger, ) n = float(n) - elif isinstance(n, (TimeSeries, xr.DataArray, np.ndarray)): + elif isinstance(n, (TimeSeries, np.ndarray)) or is_dataarray(n): n = self._extract_values(n) # elementwise power else: raise_log( @@ -5335,7 +5360,7 @@ def __truediv__(self, other): if isinstance(other, (int, float, np.integer)): if other == 0: raise_log(ZeroDivisionError("Cannot divide by 0."), logger) - elif isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + elif isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) if (other == 0).any(): raise_log( @@ -5376,7 +5401,7 @@ def __round__(self, n=None): return ts def __lt__(self, other) -> np.ndarray: - if isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + if isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) elif not isinstance(other, (int, float, np.integer)): raise_log( @@ -5388,7 +5413,7 @@ def __lt__(self, other) -> np.ndarray: return np.less(self._values, other) def __gt__(self, other) -> np.ndarray: - if isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + if isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) elif not isinstance(other, (int, float, np.integer)): raise_log( @@ -5400,7 +5425,7 @@ def __gt__(self, other) -> np.ndarray: return np.greater(self._values, other) def __le__(self, other) -> np.ndarray: - if isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + if isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) elif not isinstance(other, (int, float, np.integer)): raise_log( @@ -5412,7 +5437,7 @@ def __le__(self, other) -> np.ndarray: return np.less_equal(self._values, other) def __ge__(self, other) -> np.ndarray: - if isinstance(other, (TimeSeries, xr.DataArray, np.ndarray)): + if isinstance(other, (TimeSeries, np.ndarray)) or is_dataarray(other): other = self._extract_values(other) elif not isinstance(other, (int, float, np.integer)): raise_log( diff --git a/darts/utils/utils.py b/darts/utils/utils.py index 908fcc9be8..5c1818e09e 100644 --- a/darts/utils/utils.py +++ b/darts/utils/utils.py @@ -36,6 +36,13 @@ except ImportError: TORCH_AVAILABLE = False +try: + import xarray # noqa: F401 + + XARRAY_AVAILABLE = True +except ImportError: + XARRAY_AVAILABLE = False + logger = get_logger(__name__) MAX_TORCH_SEED_VALUE = (1 << 31) - 1 # to accommodate 32-bit architectures @@ -797,3 +804,10 @@ def dataframe_col_to_time_index( if not time_index.name: time_index.name = time_col return time_index + + +def is_dataarray(obj: Any) -> bool: + """Return if the given object is a xarray DataArray""" + if not XARRAY_AVAILABLE: + return False + return isinstance(obj, xarray.DataArray) diff --git a/requirements/core.txt b/requirements/core.txt index aa05dc35db..eff396ceac 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -13,4 +13,3 @@ shap>=0.40.0 statsmodels>=0.14.0 tqdm>=4.60.0 typing-extensions -xarray>=0.17.0 diff --git a/requirements/optional.txt b/requirements/optional.txt index 1684ae1349..cd20eeb9f6 100644 --- a/requirements/optional.txt +++ b/requirements/optional.txt @@ -5,4 +5,5 @@ optuna-integration[pytorch_lightning] polars ray pydantic +xarray plotly