diff --git a/pyproject.toml b/pyproject.toml index 408cc902e1d23acbb8a8c90949ccafe3f7e7a6bb..c3ea258abb781795e7e54741b3bd8a591b4b0e0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,5 +129,7 @@ filterwarnings = [ # deleting tempdir with weakref.finalize() fails sometimes 'ignore::pytest.PytestUnraisableExceptionWarning', # plt.pause() in headless mode - 'ignore:FigureCanvasAgg is non-interactive:UserWarning' + 'ignore:FigureCanvasAgg is non-interactive:UserWarning', + # warnings from doctests for warnings don't need to be reported + "ignore:Keyword argument '.*' of .* is deprecated.*:DeprecationWarning", ] diff --git a/qutil/misc.py b/qutil/misc.py index 875e4e876688701843ab5ef93fbfbb07eaf98b66..0a6ac5d884c1291073c3b674f352a03e93f96547 100644 --- a/qutil/misc.py +++ b/qutil/misc.py @@ -9,7 +9,7 @@ from collections.abc import MutableMapping, Callable, Iterator from contextlib import contextmanager from importlib import import_module from types import ModuleType -from typing import Dict, Optional, Union, Any, Hashable, ContextManager +from typing import Dict, Optional, Union, Any, Hashable from unittest import mock from .functools import wraps @@ -299,3 +299,74 @@ def timeout(value: float, on_exceeded: Callable[[float], None] | None = None, if raise_exc: raise TimeoutError(msg.format(exceeded.elapsed)) + +def deprecate_kwarg(old: str, new: str | None = None): + """Decorator factory to deprecate the keyword argument *old*. + + Optionally in favor of *new*. + + Examples + -------- + Deprecate a kwarg in favor of a new name: + + >>> @deprecate_kwarg('old', 'new') + ... def foo(new=3): + ... print(new) + + Promote warnings to errors for doctest: + + >>> with filter_warnings(action='error', category=DeprecationWarning): + ... foo(old=4) + Traceback (most recent call last): + ... + DeprecationWarning: Keyword argument 'old' of foo is deprecated. Use 'new' instead. + + Using the new name works as expected: + + >>> foo(new=5) + 5 + + Only deprecate a kwarg without giving a replacement: + + >>> @deprecate_kwarg('old') + ... def foo(new=3): + ... print(new) + + Again promote the warning to an error: + + >>> with filter_warnings(action='error', category=DeprecationWarning): + ... foo(old=4) # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + DeprecationWarning: Keyword argument 'old' of foo is deprecated. + + Without promoting the warning, a regular :class:`TypeError` is raised: + + >>> foo(old=4) # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + TypeError: foo() got an unexpected keyword argument 'old' + + """ + + def decorator(func): + + @wraps(func) + def wrapped(*args, **kwargs): + if old not in kwargs: + return func(*args, **kwargs) + + msg = f"Keyword argument '{old}' of {func.__qualname__} is deprecated." + if new is not None: + if new in kwargs: + raise TypeError(msg + f" Cannot also specify '{new}'.") + else: + msg = msg + f" Use '{new}' instead." + kwargs[new] = kwargs.pop(old) + + warnings.warn(msg, DeprecationWarning, stacklevel=2) + return func(*args, **kwargs) + + return wrapped + + return decorator diff --git a/qutil/signal_processing/fourier_space.py b/qutil/signal_processing/fourier_space.py index 73677a8cad23027b0b480c343691630142e82773..ad512796e1aa66cef7f810d12ff785974876faf8 100644 --- a/qutil/signal_processing/fourier_space.py +++ b/qutil/signal_processing/fourier_space.py @@ -46,6 +46,7 @@ import numpy as np from qutil import math from qutil.caching import cache from qutil.functools import wraps +from qutil.misc import deprecate_kwarg from qutil.signal_processing._common import _parse_filter_edges from qutil.typecheck import check_literals from scipy import integrate @@ -113,31 +114,77 @@ def Id(x: _S, f: _T, *_, **__) -> Tuple[_S, _T]: return x, f -def derivative(x, f, deriv_order: int = 0, overwrite_x: bool = False, +@deprecate_kwarg('deriv_order', 'order') +def derivative(x, f, order: int = 0, overwrite_x: bool = False, **_) -> Tuple[np.ndarray, np.ndarray]: - """Perform (anti-)derivatives. + r"""Compute the (anti-)derivative. + + The sign convention is according to the following defintion of the + Fourier transform: + + .. math:: + \hat{f}(\omega) &= \int_{-\infty}^\infty\mathrm{d}t + e^{-i\omega t} f(t) \\ + f(t) &= \int_{-\infty}^\infty\frac{\mathrm{d}\omega}{2\pi} + e^{i\omega t} \hat{f}(\omega) .. note:: - For negative antiderivatives, the zero-frequency component is - set to zero (due to zero-division). + For negative ``order`` (antiderivatives), the zero-frequency + component is set to zero (due to zero-division). Parameters ---------- x : array_like - The data to be filtered. + Target data. f : array_like Frequencies corresponding to the last axis of `x`. - deriv_order : int + order : int The order of the derivative. If negative, the antiderivative is computed (indefinite integral). Default: 0. overwrite_x : bool, default False Overwrite the input data array. + + Examples + -------- + Compare finite-differences to the Fourier space derivative: + + >>> dt = .1; n = 51 + >>> t = np.arange(n * dt, step=dt) + >>> xt = np.sin(5 * t) + >>> f = np.fft.rfftfreq(n, dt) + >>> xf = np.fft.rfft(xt) + + Compute the derivatives: + + >>> import matplotlib.pyplot as plt + >>> xfd, f = derivative(xf, f, order=1) + >>> xtd = np.gradient(xt, t) + >>> plt.plot(t, 5 * np.cos(5 * t), label='Analytical') # doctest: +SKIP + >>> plt.plot(t, xtd, label='Finite differences') # doctest: +SKIP + >>> plt.plot(t, np.fft.irfft(xfd, n=n), label='FFT') # doctest: +SKIP + >>> plt.legend(); plt.xlabel('$t$'); plt.ylabel('$dx/dt$') # doctest: +SKIP + + Integrals: + + >>> from scipy import integrate + >>> xfii, f = derivative(xf, f, order=-2) + >>> xti = integrate.cumulative_simpson(xt, x=t, initial=0) + >>> xtii = integrate.cumulative_simpson(xti, x=t, initial=0) + >>> plt.plot(t, -xt / 25, label='Analytical') # doctest: +SKIP + >>> # cheating a bit here because indefinite integration is tricky + >>> plt.plot(t, xtii - np.linspace(0, 1, n), label='Finite differences') # doctest: +SKIP + >>> plt.plot(t, np.fft.irfft(xfii, n=n), label='FFT') # doctest: +SKIP + >>> plt.legend(); plt.xlabel('$t$'); plt.ylabel(r'$\iint x dt$') # doctest: +SKIP + + Note that the backtransform method naturally only works well for + periodic data. + """ x = np.array(x, copy=not overwrite_x) f = np.asanyarray(f) with np.errstate(invalid='ignore', divide='ignore'): - x *= (2*np.pi*f)**deriv_order - if deriv_order < 0: + x *= (2j*np.pi*f)**order + if order < 0: x[..., (f == 0).nonzero()] = 0 return x, f