Source code for devctrl.data.transforms

from typing import Callable, Optional
import scipy as scp
import numpy as np

from devctrl.data.data import Columns, NDArray, Transform
import functools

import logging
log = logging.getLogger(__name__)

"""
REGISTERING TRANSFORMS
Transforms can be registered using the decorator `_register_transform`.
Registered transforms can then be redefined with different default parameters,
for example to allow specialized transform modules to set default columns to skip.
"""
_REGISTERED_TRANSFORMS: dict[str, Transform] = {}

def _register_transform(func: Callable) -> Callable:
    """
    Decorator to register a transform
    """
    _REGISTERED_TRANSFORMS[func.__name__] = func
    return func

[docs] def export_functions(prefix='', **override_kwargs) -> dict[str, Callable]: """ Create new versions of all registered functions with new default arguments `override_kwargs` """ global _REGISTERED_TRANSFORMS # print("reg trans:", _REGISTERED_TRANSFORMS) new_funcs = {} for name, func in _REGISTERED_TRANSFORMS.items(): new_name = f"{prefix}{name}" # have to use factory function here because otherwise all wrappers are the same object and will always point the function that was last registered def make_wrapper(f): @functools.wraps(f) def wrapper(*args, **kwargs): return f(*args, **(override_kwargs | kwargs)) return wrapper new_funcs[new_name] = make_wrapper(func) return new_funcs
[docs] def apply_to_columns(f: Callable, sdata: NDArray, columns: Columns, skipcols: Optional[Columns]=None, docols: Optional[Columns]=None, *args, **kwargs): """ Apply a transform to all selected columns. :param f: Transform taking a numpy array (data column) and *args and **kwargs and returns new numpy array of same length :param sdata: 2D-data :param columns: List of columns :param skipcols: Optional list of columns to not apply the transform to (takes precedence over docols) :param docols: Optional list of columns to apply the transform to :return: data, columns """ for i, col in enumerate(columns): if skipcols is not None and col in skipcols: continue if docols is not None and col not in docols: continue sdata[:,i] = f(sdata[:,i], *args, **kwargs) return sdata, columns
[docs] @_register_transform def remove_nans(sdata: NDArray, columns: Columns): """Delete all rows where any number is nan""" rows_no_nan = np.isfinite(sdata).all(axis=1) return sdata[rows_no_nan,:], columns
def _shift(data: NDArray, value): return data + value
[docs] @_register_transform def shift(sdata, columns, value: float, **apply_kw): """ Add `shift` to data """ return apply_to_columns(_shift, sdata, columns, **apply_kw, value=value)
def _shift_to_zero(data: NDArray): _min = np.min(data) _max = np.max(data) if _max < 0: return data - _max elif _min > 0: return data - _min return data
[docs] @_register_transform def shift_to_zero(sdata, columns, **apply_kw): """ Shift data so that either the min or max value is zero """ return apply_to_columns(_shift_to_zero, sdata, columns, **apply_kw)
def _scale(data: NDArray, factor): return data * factor
[docs] @_register_transform def scale(sdata, columns, factor: float, **apply_kw): """ Multiply data by `factor` """ return apply_to_columns(_scale, sdata, columns, **apply_kw, factor=factor)
[docs] def get_data_normalize_scale(data: np.ndarray): """ Return the scale factor that data must be multiplied with to be entirely within the interval [-1, 1] """ min_ = np.abs(np.nanmin(data)) max_ = np.abs(np.nanmax(data)) max_abs = max(min_, max_) scale = 1/max_abs return scale
[docs] def normalize_data(data: np.ndarray): """ Return data scaled to be within [-1, 1] """ return data * get_data_normalize_scale(data)
[docs] @_register_transform def normalize(sdata, columns, **apply_kw): """ Normalize all columns except skipcols so that the largest absolute value becomes either 1 or -1. """ return apply_to_columns(normalize_data, sdata, columns, **apply_kw)
def _normalize_to_index(data, idx): val = np.abs(data[idx]) return data / val def _normalize_to_index_range(data, idx_lo, idx_hi): val = np.abs(np.mean(data[idx_lo:idx_hi])) return data / val
[docs] @_register_transform def normalize_to(sdata, columns, xkey: str, x: float, dx: float|None=None, **apply_kw): """ Normalize the data so that for each column, the value at `x` or the mean value of the range `x-dx,x+dx` is set to (-)1. :param xkey: Column key of the x-data :param x: x value :param dx: Optional delta x """ if not xkey in columns: raise KeyError(f"'{xkey}' is not in columns '{columns}'") xdata = sdata[:,columns.index(xkey)] if dx is not None: idx1 = np.argmin(np.abs(xdata - (x - dx))) idx2 = np.argmin(np.abs(xdata - (x + dx))) idx_lo = min(idx1, idx2) idx_hi = max(idx1, idx2) # interval so small it falls on a single value if idx_lo != idx_hi: return apply_to_columns(_normalize_to_index_range, sdata, columns, idx_lo=idx_lo, idx_hi=idx_hi, **apply_kw) log.warning(f"Range dx={dx} too small, using single value") idx = np.argmin(np.abs(xdata - x)) return apply_to_columns(_normalize_to_index, sdata, columns, idx=idx, **apply_kw)
""" CALCULUS """ def _integrate(data, n=1, pre_func=None): for i in range(n): if pre_func: data = pre_func(data) data = scp.integrate.cumulative_trapezoid(data, initial=0) return data
[docs] @_register_transform def integrate(sdata: NDArray, columns: Columns, n=1, pre_func=None, **apply_kw): """Numerically integrate the data of column(s) <key> n times""" return apply_to_columns(_integrate, sdata, columns, n=n, pre_func=pre_func, **apply_kw)
def _derivative(data, n=1, pre_func=None): for i in range(n): if pre_func: data = pre_func(data) data = np.gradient(data) return data
[docs] @_register_transform def derivative(sdata: NDArray, columns: Columns, n=1, pre_func=None, **apply_kw): """ Calculate the n-th derivative of column(s) <key> numerically. :param n: The derivative to calculate :param pre_func: A function taking and returning a 1d numpy array. If given, it is applied before every derivative. This may, for example, be useful if the data should be smoothened before taking the derivatives. """ return apply_to_columns(_derivative, sdata, columns, n=n, pre_func=pre_func, **apply_kw)
""" FILTERING """ def _notch(data, w0:float, Q: float, **iirnotch_kw): b, a = scp.signal.iirnotch(w0, Q, **iirnotch_kw) filtered = scp.signal.lfilter(b, a, data) return filtered
[docs] @_register_transform def notch(sdata, columns, w0: float, Q: float, **kw): """ Notch filter at frequency w0 with quality factor Q. See scipy.signal.iirnotch """ return apply_to_columns(_notch, sdata, columns, w0=w0, Q=Q, **kw)
def _smoothen(data, **savgol_kw): data = scp.signal.savgol_filter(data, **(dict(window_length=100, polyorder=4, mode="nearest") | (savgol_kw or {}))) return data
[docs] @_register_transform def smoothen(sdata, columns, **kw): return apply_to_columns(_smoothen, sdata, columns, **kw)
""" DOWNSAMPLING """ try: from mqutil.calc.downsample import get_gradient_downsample_indices, get_log_downsample_indices
[docs] def downsample_log(data, columns, N, **kw): indices = get_log_downsample_indices(data.shape[0], N, **kw) data = data[indices,:] return data, columns
except ImportError as e: log.warning(f"Downsampling transforms not available: {e}")