pastas-plugins 0.1.0__tar.gz

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 (33) hide show
  1. pastas_plugins-0.1.0/LICENSE +21 -0
  2. pastas_plugins-0.1.0/PKG-INFO +137 -0
  3. pastas_plugins-0.1.0/README.md +66 -0
  4. pastas_plugins-0.1.0/pastas_plugins/__init__.py +35 -0
  5. pastas_plugins-0.1.0/pastas_plugins/cross_correlation/__init__.py +8 -0
  6. pastas_plugins-0.1.0/pastas_plugins/cross_correlation/cross_correlation.py +188 -0
  7. pastas_plugins-0.1.0/pastas_plugins/cross_correlation/plots.py +245 -0
  8. pastas_plugins-0.1.0/pastas_plugins/cross_correlation/version.py +1 -0
  9. pastas_plugins-0.1.0/pastas_plugins/modflow/__init__.py +4 -0
  10. pastas_plugins-0.1.0/pastas_plugins/modflow/modflow.py +177 -0
  11. pastas_plugins-0.1.0/pastas_plugins/modflow/stressmodels.py +114 -0
  12. pastas_plugins-0.1.0/pastas_plugins/modflow/version.py +1 -0
  13. pastas_plugins-0.1.0/pastas_plugins/reservoirs/__init__.py +4 -0
  14. pastas_plugins-0.1.0/pastas_plugins/reservoirs/reservoir.py +210 -0
  15. pastas_plugins-0.1.0/pastas_plugins/reservoirs/stressmodels.py +145 -0
  16. pastas_plugins-0.1.0/pastas_plugins/reservoirs/version.py +1 -0
  17. pastas_plugins-0.1.0/pastas_plugins/responses/__init__.py +3 -0
  18. pastas_plugins-0.1.0/pastas_plugins/responses/rfunc.py +125 -0
  19. pastas_plugins-0.1.0/pastas_plugins/responses/rfunc_utils.py +32 -0
  20. pastas_plugins-0.1.0/pastas_plugins/responses/version.py +1 -0
  21. pastas_plugins-0.1.0/pastas_plugins/version.py +1 -0
  22. pastas_plugins-0.1.0/pastas_plugins.egg-info/PKG-INFO +137 -0
  23. pastas_plugins-0.1.0/pastas_plugins.egg-info/SOURCES.txt +31 -0
  24. pastas_plugins-0.1.0/pastas_plugins.egg-info/dependency_links.txt +1 -0
  25. pastas_plugins-0.1.0/pastas_plugins.egg-info/requires.txt +34 -0
  26. pastas_plugins-0.1.0/pastas_plugins.egg-info/top_level.txt +1 -0
  27. pastas_plugins-0.1.0/pyproject.toml +112 -0
  28. pastas_plugins-0.1.0/setup.cfg +4 -0
  29. pastas_plugins-0.1.0/tests/test_crosscorrelation.py +111 -0
  30. pastas_plugins-0.1.0/tests/test_modflow.py +2 -0
  31. pastas_plugins-0.1.0/tests/test_pastas_plugins_base.py +51 -0
  32. pastas_plugins-0.1.0/tests/test_reservoirs.py +85 -0
  33. pastas_plugins-0.1.0/tests/test_responses.py +49 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Pastas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.1
2
+ Name: pastas_plugins
3
+ Version: 0.1.0
4
+ Summary: Repository containing plugins to further enhance your pastas experience.
5
+ Author: Pastas Developers Team
6
+ Maintainer-email: "D.A. Brakenhoff" <d.brakenhoff@artesia-water.nl>, "M.A. Vonk" <m.vonk@artesia-water.nl>, "M. Bakker" <markbak@gmail.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2024 Pastas
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Project-URL: homepage, https://pastas.dev
30
+ Project-URL: repository, https://github.com/pastas/pastas-plugins
31
+ Project-URL: documentation, https://pastas.readthedocs.io
32
+ Keywords: hydrology,groundwater,timeseries,analysis
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Science/Research
35
+ Classifier: Intended Audience :: Other Audience
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Programming Language :: Python :: 3 :: Only
38
+ Classifier: Programming Language :: Python :: 3.9
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Topic :: Scientific/Engineering :: Hydrology
43
+ Requires-Python: >=3.9
44
+ Description-Content-Type: text/markdown
45
+ License-File: LICENSE
46
+ Requires-Dist: pastas
47
+ Provides-Extra: all
48
+ Requires-Dist: pastas_plugins[crosscorrelation,modflow]; extra == "all"
49
+ Provides-Extra: crosscorrelation
50
+ Requires-Dist: statsmodels; extra == "crosscorrelation"
51
+ Provides-Extra: modflow
52
+ Requires-Dist: flopy; extra == "modflow"
53
+ Provides-Extra: reservoirs
54
+ Requires-Dist: pastas_plugins; extra == "reservoirs"
55
+ Provides-Extra: responses
56
+ Requires-Dist: pastas_plugins; extra == "responses"
57
+ Provides-Extra: ruffing
58
+ Requires-Dist: ruff; extra == "ruffing"
59
+ Provides-Extra: pytesting
60
+ Requires-Dist: pytest>=7; extra == "pytesting"
61
+ Requires-Dist: pytest-cov; extra == "pytesting"
62
+ Requires-Dist: pytest-sugar; extra == "pytesting"
63
+ Provides-Extra: ci
64
+ Requires-Dist: coverage; extra == "ci"
65
+ Provides-Extra: rtd
66
+ Requires-Dist: pastas_plugins[all]; extra == "rtd"
67
+ Requires-Dist: sphinx_rtd_theme; extra == "rtd"
68
+ Requires-Dist: myst_nb; extra == "rtd"
69
+ Requires-Dist: numpydoc; extra == "rtd"
70
+ Requires-Dist: sphinx_design; extra == "rtd"
71
+
72
+ # pastas-plugins
73
+
74
+ Welcome to the pastas-plugins repository, your one-stop-shop for customized
75
+ cutting-edge additions to Pastas.
76
+
77
+
78
+ ## Current plugins
79
+
80
+ The following plugins are now available:
81
+
82
+ - **`cross_correlation`**: analyze and visualize the cross-correlation between two time series.
83
+ - **`modflow`**: use modflow models as response functions.
84
+ - **`reservoirs`**: use reservoir models to simulate time series.
85
+ - **`responses`**: custom response functions for Pastas.
86
+
87
+ ## Installation
88
+
89
+ <!-- TODO: add repo to PYPI so this becomes true: -->
90
+ Install `pastas-plugins` with:
91
+
92
+ ```bash
93
+ pip install pastas_plugins
94
+ ```
95
+
96
+ If you want to use a specific plugin and want to ensure you install all the requisite
97
+ dependencies, you can use the following command:
98
+
99
+ ```bash
100
+ pip install pastas-plugins[<name of plugin>]
101
+ ```
102
+
103
+ If you want to install them all:
104
+ ```bash
105
+ pip install pastas-plugins[all]
106
+ ```
107
+
108
+ ## Usage
109
+
110
+ Import the pastas-plugins module with:
111
+
112
+ ```python
113
+ import pastas_plugins as pp
114
+ ```
115
+
116
+ This gives you access to the following functions:
117
+
118
+ ```python
119
+ pp.list_plugins() # list of all plugins
120
+ pp.show_plugin_versions() # show plugin versions
121
+ ```
122
+
123
+ The function `pp.show_plugin_versions()` will indicate if any dependencies are missing
124
+ for a particular plugin. See the [Installation](#installation) section above for tips
125
+ on how to install dependencies for a particular plugin.
126
+
127
+ To use a particular plugin, you'll have to import it explicitly, e.g.:
128
+
129
+ ```python
130
+ from pastas_plugins import responses
131
+
132
+ rfunc = responses.Theis()
133
+ ```
134
+
135
+ Separate plugins are each stored in a separate submodule within the pastas-plugins
136
+ package. You do not need to install the dependencies for each plugin if you're only
137
+ interested in one particular plugin.
@@ -0,0 +1,66 @@
1
+ # pastas-plugins
2
+
3
+ Welcome to the pastas-plugins repository, your one-stop-shop for customized
4
+ cutting-edge additions to Pastas.
5
+
6
+
7
+ ## Current plugins
8
+
9
+ The following plugins are now available:
10
+
11
+ - **`cross_correlation`**: analyze and visualize the cross-correlation between two time series.
12
+ - **`modflow`**: use modflow models as response functions.
13
+ - **`reservoirs`**: use reservoir models to simulate time series.
14
+ - **`responses`**: custom response functions for Pastas.
15
+
16
+ ## Installation
17
+
18
+ <!-- TODO: add repo to PYPI so this becomes true: -->
19
+ Install `pastas-plugins` with:
20
+
21
+ ```bash
22
+ pip install pastas_plugins
23
+ ```
24
+
25
+ If you want to use a specific plugin and want to ensure you install all the requisite
26
+ dependencies, you can use the following command:
27
+
28
+ ```bash
29
+ pip install pastas-plugins[<name of plugin>]
30
+ ```
31
+
32
+ If you want to install them all:
33
+ ```bash
34
+ pip install pastas-plugins[all]
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ Import the pastas-plugins module with:
40
+
41
+ ```python
42
+ import pastas_plugins as pp
43
+ ```
44
+
45
+ This gives you access to the following functions:
46
+
47
+ ```python
48
+ pp.list_plugins() # list of all plugins
49
+ pp.show_plugin_versions() # show plugin versions
50
+ ```
51
+
52
+ The function `pp.show_plugin_versions()` will indicate if any dependencies are missing
53
+ for a particular plugin. See the [Installation](#installation) section above for tips
54
+ on how to install dependencies for a particular plugin.
55
+
56
+ To use a particular plugin, you'll have to import it explicitly, e.g.:
57
+
58
+ ```python
59
+ from pastas_plugins import responses
60
+
61
+ rfunc = responses.Theis()
62
+ ```
63
+
64
+ Separate plugins are each stored in a separate submodule within the pastas-plugins
65
+ package. You do not need to install the dependencies for each plugin if you're only
66
+ interested in one particular plugin.
@@ -0,0 +1,35 @@
1
+ # ruff : noqa: F401
2
+ import pathlib
3
+ from importlib import import_module
4
+
5
+ # from platform import python_version
6
+ # from packaging.version import parse as parse_version
7
+ from pastas_plugins.version import __version__
8
+
9
+
10
+ def list_plugins():
11
+ plugins = pathlib.Path(__file__).parent.iterdir()
12
+ plugins = [
13
+ plugin.stem
14
+ for plugin in plugins
15
+ if plugin.is_dir() and not plugin.stem.startswith("_")
16
+ ]
17
+ plugins.sort()
18
+ return plugins
19
+
20
+
21
+ def show_plugin_versions():
22
+ showtip = False
23
+ plugins = list_plugins()
24
+ msg = f"pastas_plugins version : {__version__}\n"
25
+ for plugin in plugins:
26
+ try:
27
+ module = import_module(f"pastas_plugins.{plugin}.version")
28
+ version = module.__version__
29
+ except ModuleNotFoundError:
30
+ showtip = True
31
+ version = "not available (check dependencies)"
32
+ msg += f"- {(plugin + ' version'):25s} : {version}\n"
33
+ if showtip:
34
+ msg += "\nNote: To install missing dependencies use `pip install pastas-plugins[<plugin-name>]`"
35
+ print(msg)
@@ -0,0 +1,8 @@
1
+ # ruff: noqa: F401
2
+ from pastas_plugins.cross_correlation.cross_correlation import (
3
+ ccf,
4
+ fit_response,
5
+ prewhiten,
6
+ )
7
+ from pastas_plugins.cross_correlation.plots import plot_ccf_overview, plot_corr
8
+ from pastas_plugins.cross_correlation.version import __version__
@@ -0,0 +1,188 @@
1
+ from typing import Tuple, Union
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import pastas as ps
6
+ import scipy as sc
7
+ from statsmodels.tsa.ar_model import AutoReg
8
+ from statsmodels.tsa.arima.model import ARIMA
9
+ from statsmodels.tsa.filters.filtertools import convolution_filter
10
+
11
+
12
+ def ccf(
13
+ x: pd.Series,
14
+ y: pd.Series,
15
+ nlags: Union[int, None] = None,
16
+ adjusted: bool = True,
17
+ alpha: Union[float, None] = None,
18
+ ) -> Union[pd.Series, pd.DataFrame]:
19
+ """Cross-correlation of two time series.
20
+
21
+ Parameters
22
+ ----------
23
+ x : pd.Series
24
+ Time series
25
+ y : pd.Series
26
+ Time series, len(y) should be equal to len(x).
27
+ nlags : int or None, optional
28
+ Number of lags to return cross-correlations for, by default None which
29
+ uses nlags equal to len(x).
30
+ adjusted : bool, optional
31
+ If True, denominators for cross-correlation are len(x)-k, otherwise
32
+ len(x), by default True
33
+ alpha : float or None, optional
34
+ If a float between 0 and 1 is given, the confidence intervals for the
35
+ given level are returned in a DataFrame. For instance if alpha=0.05,
36
+ 95% confidence intervals are returned where the standard deviation is
37
+ computed according to 1/sqrt(len(x)).
38
+
39
+ Returns
40
+ -------
41
+ pandas Series or DataFrame
42
+ """
43
+ # check if lengths are equal
44
+ assert len(x) == len(y), "Length of series x and y should be equal"
45
+ # check if series are equidistant
46
+ for series in (x, y):
47
+ if pd.infer_freq(series.index) is None:
48
+ msg = (
49
+ "The frequency of the index of time series %s could not be "
50
+ "inferred. Please provide a time series with a equidistant time step."
51
+ )
52
+ raise ValueError(msg % series.name)
53
+
54
+ n = len(x)
55
+
56
+ xbar = x - x.mean()
57
+ ybar = y - y.mean()
58
+
59
+ d = np.arange(n, 0, -1) if adjusted else n
60
+ cc = sc.signal.correlate(xbar, ybar, mode="full", method="fft")
61
+ cvf = cc[n - 1 :] / (np.std(x) * np.std(y) * d)
62
+
63
+ nlags = n if nlags is None else nlags
64
+ index = pd.Index(np.arange(nlags), name="Lags")
65
+ ret = cvf[:nlags]
66
+
67
+ if alpha is not None:
68
+ interval = sc.stats.norm.ppf(1.0 - alpha / 2.0) / np.sqrt(n)
69
+ crosscorr = pd.DataFrame(
70
+ data=np.vstack([ret, ret - interval, ret + interval]).T,
71
+ index=index,
72
+ columns=["Cross-correlation", f"CI {alpha / 2}", f"CI {1 - alpha / 2}"],
73
+ )
74
+ else:
75
+ crosscorr = pd.Series(
76
+ data=ret,
77
+ index=index,
78
+ name="Cross-correlation",
79
+ )
80
+ return crosscorr
81
+
82
+
83
+ def prewhiten(
84
+ x: pd.Series, y: Union[pd.Series, None] = None, ar: int = 20, arima: bool = False
85
+ ) -> Union[pd.Series, Tuple[pd.Series]]:
86
+ """Prewhiten time series using AR(ar) model.
87
+
88
+ An AR(ar) model is fitted on time series x. The goal is to obtain residuals that
89
+ adhere to a white noise process. Next, the AR(ar) model is applied to time series Y.
90
+
91
+ Note
92
+ ----
93
+ If prewhitened time series for x still shows significant autocorrelation or partial
94
+ autocorrelation, try increasing the number of autoregressive parameters.
95
+
96
+ Parameters
97
+ ----------
98
+ x : pd.Series
99
+ time series on which AR(ar) model will be fitted
100
+ y : pd.Series, optional
101
+ time series that will be filtered using the AR(ar) model fitted on x
102
+ ar : int, optional
103
+ number of autoregressive parameters (sometimes called `p`), by default 20
104
+ arima: bool, optional
105
+ use an ARIMA(ar,0,0) model instead of an AR(ar) model, by default False
106
+ which causes a significant speedup at the cost of a very small accuracy
107
+ penalty
108
+
109
+ Returns
110
+ -------
111
+ pwx : pd.Series
112
+ prewhitened time series for x (should no longer show significant
113
+ autocorrelation or partial autocorrelation)
114
+ pwy : pd.Series, optional
115
+ prewhitened time series for y, if y is provided
116
+ """
117
+
118
+ # fit AR model on x
119
+ if arima:
120
+ ml = ARIMA(x.values, order=(ar, 0, 0), trend="c").fit()
121
+ else:
122
+ ml = AutoReg(x.values, lags=ar, trend="c").fit()
123
+
124
+ # get model filtered model residuals
125
+ residuals = ml.resid[ar:] if arima else ml.resid
126
+ pwx = pd.Series(residuals, index=x.index[ar:])
127
+
128
+ if y is not None:
129
+ # apply same filter on y
130
+ arparams = ml.arparams if arima else ml.params[1:]
131
+ filt = np.append(1.0, -arparams)
132
+ pwy = convolution_filter(y.values, filt=filt, nsides=1)
133
+ pwy = pd.Series(data=pwy[ar:], index=y.index[ar:])
134
+ return pwx, pwy
135
+ else:
136
+ return pwx
137
+
138
+
139
+ def fit_response(
140
+ ccf: pd.Series,
141
+ rfunc: ps.typing.RFunc,
142
+ scale_factor: float = 1.0,
143
+ dt: float = 1.0,
144
+ ) -> np.ndarray[float]:
145
+ """Fit the response function to the cross-correlation function using least
146
+ squares optimization.
147
+
148
+ Parameters:
149
+ -----------
150
+ ccf : pd.Series
151
+ The cross-correlation function.
152
+ rfunc : ps.typing.RFunc
153
+ The response function to fit on the impulse response.
154
+ scale_factor : float, optional
155
+ Scale factor applied to the cross-correlation function to obtain the
156
+ impulse response, by default 1.0.
157
+ dt : float, optional
158
+ Time step of the response function, by default 1.0.
159
+
160
+ Returns:
161
+ --------
162
+ np.ndarray[float]
163
+ The optimized parameters of the response function.
164
+
165
+ """
166
+
167
+ def obj_func(p):
168
+ """Objective function for least squares optimization."""
169
+ impulse_response = (ccf * scale_factor).values
170
+ blockr = rfunc.block(p, dt=dt, cutoff=rfunc.cutoff)
171
+
172
+ # make sure length of residuals is constant
173
+ if len(blockr) > len(impulse_response):
174
+ blockr = blockr[: len(impulse_response)]
175
+ elif len(blockr) < len(impulse_response):
176
+ blockr = np.append(blockr, np.zeros(len(impulse_response) - len(blockr)))
177
+
178
+ return impulse_response - blockr
179
+
180
+ params = rfunc.get_init_parameters(rfunc._name)
181
+ pini = params["initial"].values
182
+ bounds = (
183
+ params["pmin"].fillna(-np.inf).values,
184
+ params["pmax"].fillna(np.inf).values,
185
+ )
186
+
187
+ res = sc.optimize.least_squares(obj_func, x0=pini, bounds=bounds)
188
+ return res.x
@@ -0,0 +1,245 @@
1
+ import matplotlib.pyplot as plt
2
+ import numpy as np
3
+ import pandas as pd
4
+ from statsmodels.graphics.tsaplots import _plot_corr, plot_acf, plot_pacf
5
+
6
+ from pastas_plugins.cross_correlation.cross_correlation import ccf
7
+
8
+
9
+ def plot_corr(
10
+ corr: pd.Series | pd.DataFrame,
11
+ ax: plt.Axes | None = None,
12
+ vlines_kwargs: dict | None = None,
13
+ **kwargs,
14
+ ):
15
+ """Helper function for the statsmodels _plot_corr function.
16
+
17
+ Parameters
18
+ ----------
19
+ corr : pd.Series or pd.DataFrame
20
+ the correlation result to plot
21
+ ax : plt.Axes, optional
22
+ axes to plot on, by default None
23
+ vlines_kwargs : dict, optional
24
+ keyword arguments for the vlines function, by default None
25
+
26
+ Returns
27
+ -------
28
+ plt.Axes
29
+ axes with the plot
30
+ """
31
+
32
+ if ax is None:
33
+ _, ax = plt.subplots(**kwargs)
34
+
35
+ acf_x = corr.values if isinstance(corr, pd.Series) else corr.iloc[:, 0].values
36
+ confint = corr.iloc[:, 1:3].values if isinstance(corr, pd.DataFrame) else None
37
+ lags = corr.index.values
38
+ vlines_kwargs = {} if vlines_kwargs is None else vlines_kwargs
39
+ _plot_corr(
40
+ ax=ax,
41
+ title="",
42
+ acf_x=acf_x,
43
+ confint=confint,
44
+ lags=lags,
45
+ irregular=False,
46
+ use_vlines=True,
47
+ vlines_kwargs=vlines_kwargs,
48
+ )
49
+ return ax
50
+
51
+
52
+ def plot_ccf_overview(x, y, nlags=None, tmin=None, tmax=None, axes=None):
53
+ """Plot an overview of the cross-correlation between two time series.
54
+
55
+ Parameters
56
+ ----------
57
+ x : pd.Series
58
+ Time series 1
59
+ y : pd.Series
60
+ Time series 2
61
+ nlags : int, optional
62
+ number of lags to return cross-correlations for, by default None which
63
+ uses number of lags equal to len(x).
64
+ tmin : str or pd.Timestamp, optional
65
+ tmin for both time series, by default None
66
+ tmax : str or pd.Timestamp, optional
67
+ tmax for both time series, by default None
68
+ axes : Axes mosaic, optional
69
+ if provided, use axes from previous plot
70
+
71
+ Returns
72
+ -------
73
+ axes : Axes mosaic
74
+ return axes of subplots mosaic
75
+ """
76
+ if tmin is None:
77
+ tmin = np.min([x.index[0], y.index[0]])
78
+ if tmax is None:
79
+ tmax = np.max([x.index[-1], y.index[-1]])
80
+
81
+ x = x.loc[tmin:tmax]
82
+ y = y.loc[tmin:tmax]
83
+
84
+ if axes is None:
85
+ mosaic = [
86
+ ["x", "x", "norm", "norm"],
87
+ ["y", "y", "norm", "norm"],
88
+ ["x-acf", "y-acf", "ccf", "ccf"],
89
+ ["x-pacf", "y-pacf", "ccf", "ccf"],
90
+ ]
91
+
92
+ fig, axes = plt.subplot_mosaic(mosaic, figsize=(16, 8))
93
+ rescale_axes = False
94
+ newaxes = True
95
+ color1 = "C0"
96
+ color2 = "C1"
97
+ else:
98
+ fig = axes["x"].figure
99
+ rescale_axes = True
100
+ newaxes = False
101
+ color1 = "C2"
102
+ color2 = "C3"
103
+
104
+ # set names if not provided
105
+ if x.name is None:
106
+ x.name = "x"
107
+ if y.name is None:
108
+ y.name = "y"
109
+
110
+ # plot time series
111
+ axes["x"].plot(x.index, x, label=x.name, color=color1)
112
+ axes["x"].legend(loc=(0, 1), frameon=False)
113
+ axes["x"].set_ylabel("x")
114
+ axes["x"].set_xlim(pd.Timestamp(tmin), pd.Timestamp(tmax))
115
+ axes["y"].plot(y.index, y, label=y.name, c=color2)
116
+ axes["y"].legend(loc=(0, 1), frameon=False)
117
+ axes["y"].set_ylabel("y")
118
+ axes["y"].set_xlim(pd.Timestamp(tmin), pd.Timestamp(tmax))
119
+
120
+ # plot normalized series
121
+ xnorm = (x - x.mean()) / x.std()
122
+ ynorm = (y - y.mean()) / y.std()
123
+ axes["norm"].plot(
124
+ xnorm.index, xnorm, label=x.name + " (normalized)", alpha=0.7, color=color1
125
+ )
126
+ axes["norm"].plot(
127
+ ynorm.index, ynorm, label=y.name + " (normalized)", alpha=0.7, color=color2
128
+ )
129
+ axes["norm"].legend(loc=(0, 1), frameon=False, ncol=2)
130
+ axes["norm"].set_ylabel("normalized [-]")
131
+ axes["norm"].set_xlim(pd.Timestamp(tmin), pd.Timestamp(tmax))
132
+ handles, _ = axes["norm"].get_legend_handles_labels()
133
+
134
+ # plot acf, pacf
135
+ plot_acf(
136
+ xnorm,
137
+ ax=axes["x-acf"],
138
+ color=color1,
139
+ alpha=0.05,
140
+ title="",
141
+ zero=False,
142
+ auto_ylims=True,
143
+ vlines_kwargs={"color": "k"},
144
+ )
145
+ plot_acf(
146
+ ynorm,
147
+ ax=axes["y-acf"],
148
+ color=color2,
149
+ alpha=0.05,
150
+ title="",
151
+ zero=False,
152
+ auto_ylims=True,
153
+ vlines_kwargs={"color": "k"},
154
+ )
155
+ axes["x-acf"].set_xlim(left=0.0)
156
+ axes["x-acf"].set_ylabel("ACF [-]")
157
+ (p1,) = axes["x-acf"].plot([], [], marker="o", ls="none", color=color1)
158
+ if not newaxes:
159
+ leg = axes["x-acf"].get_legend()
160
+ handles = leg.legend_handles
161
+ labels = [t.get_text() for t in leg.get_texts()]
162
+ handles += [p1]
163
+ labels += [x.name]
164
+ else:
165
+ handles = [p1]
166
+ labels = [x.name]
167
+
168
+ axes["x-acf"].legend(handles, labels, loc=(0, 1), frameon=False)
169
+ axes["y-acf"].set_xlim(left=0.0)
170
+ axes["y-acf"].get_children()[3].set_facecolor(color2)
171
+ (p2,) = axes["y-acf"].plot([], [], marker="o", ls="none", color=color2)
172
+ if not newaxes:
173
+ leg = axes["y-acf"].get_legend()
174
+ handles = leg.legend_handles
175
+ labels = [t.get_text() for t in leg.get_texts()]
176
+ handles += [p2]
177
+ labels += [y.name]
178
+ else:
179
+ handles = [p2]
180
+ labels = [y.name]
181
+ axes["y-acf"].legend(handles, labels, loc=(0, 1), frameon=False)
182
+
183
+ plot_pacf(
184
+ xnorm,
185
+ method="ywm",
186
+ ax=axes["x-pacf"],
187
+ color=color1,
188
+ alpha=0.05,
189
+ title="",
190
+ zero=False,
191
+ auto_ylims=True,
192
+ vlines_kwargs={"color": "k"},
193
+ )
194
+ plot_pacf(
195
+ ynorm,
196
+ method="ywm",
197
+ ax=axes["y-pacf"],
198
+ color=color2,
199
+ alpha=0.05,
200
+ title="",
201
+ zero=False,
202
+ auto_ylims=True,
203
+ vlines_kwargs={"color": "k"},
204
+ )
205
+ axes["x-pacf"].set_xlim(left=0.0)
206
+ axes["x-pacf"].set_ylabel("PACF [-]")
207
+ axes["x-pacf"].set_xlabel("Lags")
208
+
209
+ axes["y-pacf"].set_xlim(left=0.0)
210
+ axes["y-pacf"].get_children()[3].set_facecolor("C1")
211
+ axes["y-pacf"].set_xlabel("Lags")
212
+
213
+ # ccf
214
+ cc = ccf(x, y, nlags=nlags)
215
+ axes["ccf"].bar(
216
+ cc.index,
217
+ cc,
218
+ width=1.0,
219
+ linewidth=0.5,
220
+ alpha=0.5,
221
+ label=f"CCF ({x.name}|{y.name})",
222
+ )
223
+ axes["ccf"].set_ylabel("CCF [-]")
224
+ axes["ccf"].set_xlabel("Lags")
225
+ axes["ccf"].legend(loc=(0, 1), frameon=False)
226
+ axes["ccf"].set_xlim(left=0.0)
227
+
228
+ share_x = [axes["x"], axes["y"], axes["norm"]]
229
+ for i, iax in enumerate(share_x):
230
+ if i < (len(share_x) - 1):
231
+ iax.sharex(share_x[-1])
232
+
233
+ # share_x = [axes["x-acf"], axes["x-pacf"], axes["y-acf"], axes["y-pacf"]]
234
+ # for i, iax in enumerate(share_x):
235
+ # if i < (len(share_x) - 1):
236
+ # iax.sharex(share_x[-1])
237
+
238
+ fig.tight_layout()
239
+ fig.align_ylabels()
240
+
241
+ if rescale_axes:
242
+ for iax in axes.values():
243
+ iax.autoscale()
244
+
245
+ return axes