From 497b9b8cca0a62453190b3582807b172def47fa0 Mon Sep 17 00:00:00 2001 From: Wolfgang Kastaun <wolfgang.kastaun@aei.mpg.de> Date: Thu, 19 Dec 2024 16:04:04 +0100 Subject: [PATCH] split module dynamic_delay_numpy, move core interpolation engine into own module --- lisainstrument/dynamic_delay_numpy.py | 518 ++---------------------- lisainstrument/regular_interpolators.py | 502 +++++++++++++++++++++++ 2 files changed, 534 insertions(+), 486 deletions(-) create mode 100644 lisainstrument/regular_interpolators.py diff --git a/lisainstrument/dynamic_delay_numpy.py b/lisainstrument/dynamic_delay_numpy.py index 75dc127..639a312 100644 --- a/lisainstrument/dynamic_delay_numpy.py +++ b/lisainstrument/dynamic_delay_numpy.py @@ -5,498 +5,21 @@ Use make_dynamic_shift_lagrange_numpy to create a Lagrange interpolator for nump from __future__ import annotations -import functools -import operator from dataclasses import dataclass from enum import Enum -from typing import Final, Protocol +from typing import Final import numpy as np -from numpy.polynomial import Polynomial from typing_extensions import assert_never -from lisainstrument.fir_filters_numpy import ( - DefFilterFIR, - EdgeHandling, - FilterFirNumpyType, - NumpyArray1D, - make_filter_fir_numpy, - make_numpy_array_1d, +from lisainstrument.fir_filters_numpy import NumpyArray1D +from lisainstrument.regular_interpolators import ( + RegularInterpolator, + make_regular_interpolator_lagrange, + make_regular_interpolator_linear, ) -class RegularInterpCore(Protocol): - """Protocol for interpolator engine interface for regularly spaced samples - - This defines an interface for core functionality of interpolating regularly - spaced samples in 1D, using numpy arrays. It is not intended for direct use - but for use in the RegularInterpolator class. - - Boundary treatment is not part of this protocol. Implementations should only - accept locations that can be interpolated to without using any form of boundary - conditions, and raise an exception otherwise. The margin sizes required by the - interpolation method are exposed as properties. - """ - - @property - def margin_left(self) -> int: - """Margin size (>= 0) on the left boundary - - The interpolator cannot be called with locations within this margin from the leftmost sample. - """ - - @property - def margin_right(self) -> int: - """Margin size (>= 0) on the right boundary - - The interpolator cannot be called with locations within this margin from the rightmost sample. - """ - - def apply( - self, - samples: NumpyArray1D, - locations: NumpyArray1D, - int_offsets: NumpyArray1D | int = 0, - ) -> np.ndarray: - """Interpolate regularly spaced data to location in index-space - - The locations to interpolate to are the sum of the locations and int_offsets arguments. - The location argument is an 1D array with arbitrary floating point locations, and the - int_offset argument is an integer or integer array with additional integer offsets. The locations - argument is not restricted, i.e. it is not necessarily the residual fractional locations but - can have arbitrary values. - - The locations refer to the index of the array with the sampled data, starting at 0. - The length of samples array does not have to match the size of the location arrays. - If int_offsets is an array, it needs to have same size arrays location and. All - arrays need to be onedimensional. - - Implementations do not need to check the array dimensionality, size consistency, and types. - This is done in RegularInterpolator. - - Arguments: - samples: 1D numpy array with sampled data - locations: real-valued 1D numpy array with locations to interpolate to - int_offsets: integer or integer 1D array with additional offsets to the locations - - Returns: - Interpolated samples - """ - - def apply_shift( - self, - samples: NumpyArray1D, - shift: NumpyArray1D, - shift_offset: int, - ) -> np.ndarray: - """Iterpolate to location specified in terms of shifts instead absolute locations - - The locations are specified via an array s of real-valued shifts. For the element s[i] of - the shift array with array index i, the absolute location within the index space of the - input samples is given by i - s[i] + ofs, where ofs is a constant integer offset. A zero - shift means the output sample with index i is the input sample with index i+ofs. - The offset can be positive or negative. Shift values that would require samples not - in the input are not allowed. - - Arguments: - samples: 1D numpy array with sampled data - shifts: 1D float numpy array with shifts - shift_offset: constant integer offset - - Returns: - Interpolated samples - """ - - -def make_lagrange_polynomials(length: int, offset: int) -> list[Polynomial]: - r"""Construct lagrange interpolating polynomials - - This constructs Lagrange interpolation polynomials with given order, - specialized to regularly spaced coordinates with spacing of one, with a - center specified in terms of an integer offset. - - This produces $N$ polynomials $p_j(x)$ of order $N-1$, which satisfy - $p_j(k) = 1$ if $k = j+D$ and $p_j(k) = 0$ for integers $k=0 \ldots N-1, k \ne j$ - - Arguments: - length: The number $N$ of polynomials of order $N-1$ - offset: The offset $D$ - - Returns: - List of polynomials $p_j$ given as numpy Polynomial objects - """ - - def k_j(j: int) -> Polynomial: - x = Polynomial([0.0, 1.0]) - ms = [i for i in range(length) if i != j] - pm = [(x - offset - m) / (j - m) for m in ms] - return functools.reduce(operator.mul, pm) - - return [k_j(j) for j in range(length)] - - -class RegularInterpLagrange(RegularInterpCore): - r"""Class implementing interpolation of regularly spaced 1D data using Lagrange polynomials. - - The algorithm uses Lagrange interpolation is specialized to regularly spaced data. - The coefficients of the Lagrange polynomials are computed in advance, and converted - to a set of FIR filters. The FIR filters will be applied to the samples and the result - at the integer locations multiplied with the corresponding power of the fractional - locations. - - For each interpolation location, this uses a stencil with center as close to the - location as possible. For odd length, the center point is obtained by rounding the - location, and that the remaining fractional shift is within $[-1/2,1/2]$. For even - locations, the center points is the floor of the location, with remaining fractional - shift within $[0,1)$ - - See RegularInterpCore for general properties not specific to the interpolation method. - """ - - @staticmethod - def _make_firs(length: int, offset: int) -> list[FilterFirNumpyType]: - """Set up lagrange polynomials and convert coefficients to FIR filters""" - plag = make_lagrange_polynomials(length, offset) - coeffs = np.array([p.convert().coef for p in plag]).T - filts: list[FilterFirNumpyType] = [] - for c in coeffs: - fdef = DefFilterFIR(filter_coeffs=c, offset=offset) - filt = make_filter_fir_numpy(fdef, EdgeHandling.VALID, EdgeHandling.VALID) - filts.append(filt) - return filts - - def __init__(self, length: int): - """Set up interpolation parameters. - - The length parameter specifies the number of interpolation polynomials, - which have order length - 1. The length is also the number of samples used - for each interpolation point. The order of the interpolating polynomials - is also the order of plynomials that are interpolated with zero error. - - Arguments: - length: Size of the interpolation stencil - """ - - if length <= 1: - msg = ( - f"RegularInterpLagrange: stencil length must be 2 or more, got {length}" - ) - raise ValueError(msg) - - offset = -((length - 1) // 2) - self._length: Final[int] = length - self._offset: Final[int] = offset - self._fir_filt: Final[list[FilterFirNumpyType]] = self._make_firs( - length, offset - ) - - @property - def margin_left(self) -> int: - """Margin size (>= 0) on the left boundary - - The interpolator cannot be called with locations within this margin from the leftmost sample. - """ - return -self._offset - - @property - def margin_right(self) -> int: - """Margin size (>= 0) on the right boundary - - The interpolator cannot be called with locations within this margin from the rightmost sample. - """ - return self._length - 1 + self._offset - - def apply( - self, - samples: NumpyArray1D, - locations: NumpyArray1D, - int_offsets: NumpyArray1D | int = 0, - ) -> np.ndarray: - """Interpolate regularly spaced data to location in index-space - - See RegularInterpCore.apply() - - Arguments: - samples: real-valued 1D numpy array with sampled data - locations: real-valued 1D numpy array with locations to interpolate to - int_offsets: integer or integer 1D array with additional offsets to the locations. - - Returns: - Interpolated samples. - """ - - if self._length % 2 == 0: - loc_int = np.floor(locations).astype(int) - else: - loc_int = np.round(locations).astype(int) - loc_frac = locations - loc_int - k = loc_int + int_offsets - self.margin_left - - if np.any(k < 0): - msg = "RegularInterpLagrange: interpolation requires samples below provided range" - raise RuntimeError(msg) - if np.any(k >= samples.shape[0] - self._length + 1): - msg = "RegularInterpLagrange: interpolation requires samples above provided range" - raise RuntimeError(msg) - - result = np.zeros(locations.shape[0], dtype=samples.dtype) - xpow = np.ones_like(loc_frac) - for fir in self._fir_filt: - result[:] += fir(samples)[k] * xpow - xpow[:] *= loc_frac - - return result - - def apply_shift( - self, - samples: NumpyArray1D, - shift: NumpyArray1D, - shift_offset: int, - ) -> np.ndarray: - """Iterpolate to location specified in terms of shifts instead absolute locations - - See RegularInterpCore.apply_shift(). - - Arguments: - samples: 1D numpy array with sampled data - shifts: 1D float numpy array with shifts - shift_offset: constant integer offset - - Returns: - Interpolated samples - """ - loc = -shift - offsets = shift_offset + np.arange(shift.shape[0]) - return self.apply(samples, make_numpy_array_1d(loc), make_numpy_array_1d(offsets)) - - -class RegularInterpLinear(RegularInterpCore): - """Class implementing interpolation of regularly spaced 1D data using linear interpolation. - - See RegularInterpCore for general properties not specific to the interpolation method. - """ - - @property - def margin_left(self) -> int: - """Margin size (= 0) on the left boundary - - The linear interpolator can be called for all locations within the sample range. - """ - return 0 - - @property - def margin_right(self) -> int: - """Margin size (= 0) on the right boundary - - The linear interpolator can be called for all locations within the sample range. - """ - return 0 - - def apply( - self, - samples: NumpyArray1D, - locations: NumpyArray1D, - int_offsets: NumpyArray1D | int = 0, - ) -> np.ndarray: - """Interpolate regularly spaced data to location in index-space - - See RegularInterpCore.apply() - - Arguments: - samples: 1D numpy array with sampled data - locations: real-valued 1D numpy array with locations to interpolate to - int_offsets: integer or integer 1D array with additional offsets to the locations. - - Returns: - Interpolated samples. - """ - - loc_floor = np.floor(locations) - loc_frac = locations - loc_floor - - k = loc_floor.astype(int) + int_offsets - - if np.any(k < 0) or np.any(k + 1 >= samples.shape[0]): - msg = "RegularInterpLinear: interpolation requires samples out of provided range" - raise RuntimeError(msg) - - return samples[k] * (1.0 - loc_frac) + samples[k + 1] * loc_frac - - def apply_shift( - self, - samples: NumpyArray1D, - shift: NumpyArray1D, - shift_offset: int, - ) -> np.ndarray: - """Iterpolate to location specified in terms of shifts instead absolute locations - - See RegularInterpCore.apply_shift(). - - Arguments: - samples: 1D numpy array with sampled data - shifts: 1D float numpy array with shifts - shift_offset: constant integer offset - - Returns: - Interpolated samples - """ - loc = -shift - offsets = shift_offset + np.arange(shift.shape[0]) - return self.apply(samples, make_numpy_array_1d(loc), make_numpy_array_1d(offsets)) - - -class RegularInterpolator: - """User-facing class for interpolation of regularly spaced data - - The interpolation method is not fixed but given by an interpolation engine. - The main purpose of this class is to provide the parameter checks common - to all interpolation methods. - """ - - def __init__(self, core: RegularInterpCore): - """Constructor not intended for direct use. - - Use named constructors make_regular_interpolator_lagrange() or - make_regular_interpolator_linear() to get interpolators employing specific methods. - """ - self._core: Final = core - - @property - def margin_left(self) -> int: - """Margin size (>= 0) on the left boundary - - The interpolator cannot be called with locations within this margin from the leftmost sample. - """ - return self._core.margin_left - - @property - def margin_right(self) -> int: - """Margin size (>= 0) on the right boundary - - The interpolator cannot be called with locations within this margin from the rightmost sample. - """ - return self._core.margin_right - - def __call__( - self, - samples_: np.ndarray, - locations_: np.ndarray, - int_offsets_: np.ndarray | int, - ) -> NumpyArray1D: - """Interpolate regularly spaced data to location in index-space - - The locations to interpolate to are the sum of the locations and int_offsets arguments. - The location argument is an 1D array with arbitrary floating point locations, and the - int_offset argument is an integer or integer array with additional integer offsets. The locations - argument is not restricted, i.e. it is not necessarily the residual fractional locations but - can have arbitrary values. - - The locations refer to the index of the array with the sampled data, starting at 0. - The length of samples array does not have to match the size of the location arrays. - If int_offsets is an array, it needs to have same size arrays location and. All - arrays need to be onedimensional. - - The locations must be within the margins given by the margin_left and margin_right - properties. - - Arguments: - samples: 1D numpy array with sampled data - locations: real-valued 1D numpy array with locations to interpolate to - int_offsets: integer or integer 1D array with additional offsets to the locations. - - Returns: - Interpolated samples. - """ - - samples = make_numpy_array_1d(samples_) - if not np.issubdtype(samples_.dtype, np.floating): - msg = "RegularInterpolator: non-float dtype for samples not allowed" - raise TypeError(msg) - - locations = make_numpy_array_1d(locations_) - if not np.issubdtype(locations_.dtype, np.floating): - msg = "RegularInterpolator: non-float dtype for locations not allowed" - raise TypeError(msg) - - int_offsets: NumpyArray1D | int - - if isinstance(int_offsets_, np.ndarray): - int_offsets = make_numpy_array_1d(int_offsets_) - if int_offsets_.shape != locations_.shape: - msg = ( - f"RegularInterpolator: inconsistent arrays sizes of " - f"locations ({locations_.shape}) and offsets ({int_offsets_.shape})" - ) - raise ValueError(msg) - if not np.issubdtype(int_offsets_.dtype, np.integer): - msg = ( - "RegularInterpolator: non-integer dtype for int_offsets not allowed" - ) - raise TypeError(msg) - elif isinstance(int_offsets_, int): - int_offsets = int_offsets_ - else: - msg = "RegularInterpolator: int_offset must be integer or integer array" - raise TypeError(msg) - - res = self._core.apply(samples, locations, int_offsets) - return make_numpy_array_1d(res) - - def apply_shift( - self, - samples_: np.ndarray, - shift_: np.ndarray, - shift_offset: int, - ) -> NumpyArray1D: - """Iterpolate to location specified in terms of shifts instead absolute locations - - See RegularInterpCore.apply_shift(). - - Arguments: - samples: 1D numpy array with sampled data - shifts: 1D float numpy array with shifts - shift_offset: constant integer offset - - Returns: - Interpolated samples - """ - - samples = make_numpy_array_1d(samples_) - if not np.issubdtype(samples_.dtype, np.floating): - msg = "RegularInterpolator: non-float dtype for samples not allowed" - raise TypeError(msg) - - shift = make_numpy_array_1d(shift_) - if not np.issubdtype(shift_.dtype, np.floating): - msg = "RegularInterpolator: non-float dtype for shifts not allowed" - raise TypeError(msg) - - res = self._core.apply_shift(samples, shift, shift_offset) - return make_numpy_array_1d(res) - - -def make_regular_interpolator_lagrange(length: int) -> RegularInterpolator: - """Create an interpolator using Lagrange interpolation - - See RegularInterpLagrange for details of the method. - - Arguments: - length: size of interpolation stencil - Returns: - Interpolation function - """ - return RegularInterpolator(RegularInterpLagrange(length)) - - -def make_regular_interpolator_linear() -> RegularInterpolator: - """Create an interpolator using linear interpolation - - Returns: - Interpolation function - """ - return RegularInterpolator(RegularInterpLinear()) - - class DynShiftBC(Enum): """Enum for various methods of handling boundaries in dynamic shift @@ -601,6 +124,7 @@ class DynamicShiftNumpy: Shifts are floating point values.A shift of +1 refers to the sample on the left, -1 the sample on the right, etc. All arrays have to be 1D. + Arguments: samples: 1D numpy array with data samples shift: 1D numpy array with shifts @@ -608,6 +132,8 @@ class DynamicShiftNumpy: Returns: Numpy array with interpolated samples """ + out_size = len(shift) + npad_left = 0 if self.margin_left > 0: match self._cfg.left_bound: @@ -644,11 +170,9 @@ class DynamicShiftNumpy: assert_never(unreachable) pos = 0 - shift_shape = len(shift) - n_size = npad_left + shift_shape + self.margin_right + n_size = npad_left + out_size + self.margin_right n_first = pos + npad_left - self.margin_left samples_needed = samples[n_first : n_first + n_size] - return self._interp_np.apply_shift(samples_needed, shift, self.margin_left) @@ -674,3 +198,25 @@ def make_dynamic_shift_lagrange_numpy( interp = make_regular_interpolator_lagrange(length) cfg = DynShiftCfg(min_delay, max_delay, left_bound, right_bound) return DynamicShiftNumpy(cfg, interp) + + +def make_dynamic_shift_linear_numpy( + min_delay: float, + max_delay: float, + left_bound: DynShiftBC, + right_bound: DynShiftBC, +) -> DynamicShiftNumpy: + """Set up DynamicShiftNumpy instance with linear interpolation method. + + Arguments: + min_delay: Assume that any shift < -min_delay + max_delay: Assume that any shift > -max_delay + left_bound: Treatment of left boundary + right_bound: Treatment of right boundary + + Returns: + Interpolation function of type DynamicShiftNumpy + """ + interp = make_regular_interpolator_linear() + cfg = DynShiftCfg(min_delay, max_delay, left_bound, right_bound) + return DynamicShiftNumpy(cfg, interp) diff --git a/lisainstrument/regular_interpolators.py b/lisainstrument/regular_interpolators.py new file mode 100644 index 0000000..a8433c7 --- /dev/null +++ b/lisainstrument/regular_interpolators.py @@ -0,0 +1,502 @@ +"""Functions for interpolating numpy arrays with 1D regularly spaced data + +This provides a generic interface RegularInterpolator as well as two interpolation +methods, linear and Lagrange. The latter is written from scratch, see module +regular_interpolator_dsp for another one based on the dsp.timeshift Lagrange interpolator. +""" + +from __future__ import annotations + +import functools +import operator +from typing import Final, Protocol + +import numpy as np +from numpy.polynomial import Polynomial + +from lisainstrument.fir_filters_numpy import ( + DefFilterFIR, + EdgeHandling, + FilterFirNumpyType, + NumpyArray1D, + make_filter_fir_numpy, + make_numpy_array_1d, +) + + +class RegularInterpCore(Protocol): + """Protocol for interpolator engine interface for regularly spaced samples + + This defines an interface for core functionality of interpolating regularly + spaced samples in 1D, using numpy arrays. It is not intended for direct use + but for use in the RegularInterpolator class. + + Boundary treatment is not part of this protocol. Implementations should only + accept locations that can be interpolated to without using any form of boundary + conditions, and raise an exception otherwise. The margin sizes required by the + interpolation method are exposed as properties. + """ + + @property + def margin_left(self) -> int: + """Margin size (>= 0) on the left boundary + + The interpolator cannot be called with locations within this margin from the leftmost sample. + """ + + @property + def margin_right(self) -> int: + """Margin size (>= 0) on the right boundary + + The interpolator cannot be called with locations within this margin from the rightmost sample. + """ + + def apply( + self, + samples: NumpyArray1D, + locations: NumpyArray1D, + int_offsets: NumpyArray1D | int = 0, + ) -> np.ndarray: + """Interpolate regularly spaced data to location in index-space + + The locations to interpolate to are the sum of the locations and int_offsets arguments. + The location argument is an 1D array with arbitrary floating point locations, and the + int_offset argument is an integer or integer array with additional integer offsets. The locations + argument is not restricted, i.e. it is not necessarily the residual fractional locations but + can have arbitrary values. + + The locations refer to the index of the array with the sampled data, starting at 0. + The length of samples array does not have to match the size of the location arrays. + If int_offsets is an array, it needs to have same size arrays location and. All + arrays need to be onedimensional. + + Implementations do not need to check the array dimensionality, size consistency, and types. + This is done in RegularInterpolator. + + Arguments: + samples: 1D numpy array with sampled data + locations: real-valued 1D numpy array with locations to interpolate to + int_offsets: integer or integer 1D array with additional offsets to the locations + + Returns: + Interpolated samples + """ + + def apply_shift( + self, + samples: NumpyArray1D, + shift: NumpyArray1D, + shift_offset: int, + ) -> np.ndarray: + """Iterpolate to location specified in terms of shifts instead absolute locations + + The locations are specified via an array s of real-valued shifts. For the element s[i] of + the shift array with array index i, the absolute location within the index space of the + input samples is given by i - s[i] + ofs, where ofs is a constant integer offset. A zero + shift means the output sample with index i is the input sample with index i+ofs. + The offset can be positive or negative. Shift values that would require samples not + in the input are not allowed. The output should be the same as for + + apply(samples, -shift, shift_offset + np.arange(shift.shape[0])) + + Arguments: + samples: 1D numpy array with sampled data + shift: 1D float numpy array with shifts + shift_offset: constant integer offset + + Returns: + Interpolated samples + """ + + +def make_lagrange_polynomials(length: int, offset: int) -> list[Polynomial]: + r"""Construct lagrange interpolating polynomials + + This constructs Lagrange interpolation polynomials with given order, + specialized to regularly spaced coordinates with spacing of one, with a + center specified in terms of an integer offset. + + This produces $N$ polynomials $p_j(x)$ of order $N-1$, which satisfy + $p_j(k) = 1$ if $k = j+D$ and $p_j(k) = 0$ for integers $k=0 \ldots N-1, k \ne j$ + + Arguments: + length: The number $N$ of polynomials of order $N-1$ + offset: The offset $D$ + + Returns: + List of polynomials $p_j$ given as numpy Polynomial objects + """ + + def k_j(j: int) -> Polynomial: + x = Polynomial([0.0, 1.0]) + ms = [i for i in range(length) if i != j] + pm = [(x - offset - m) / (j - m) for m in ms] + return functools.reduce(operator.mul, pm) + + return [k_j(j) for j in range(length)] + + +class RegularInterpLagrange(RegularInterpCore): + r"""Class implementing interpolation of regularly spaced 1D data using Lagrange polynomials. + + The algorithm uses Lagrange interpolation is specialized to regularly spaced data. + The coefficients of the Lagrange polynomials are computed in advance, and converted + to a set of FIR filters. The FIR filters will be applied to the samples and the result + at the integer locations multiplied with the corresponding power of the fractional + locations. + + For each interpolation location, this uses a stencil with center as close to the + location as possible. For odd length, the center point is obtained by rounding the + location, and that the remaining fractional shift is within $[-1/2,1/2]$. For even + locations, the center points is the floor of the location, with remaining fractional + shift within $[0,1)$ + + See RegularInterpCore for general properties not specific to the interpolation method. + """ + + @staticmethod + def _make_firs(length: int, offset: int) -> list[FilterFirNumpyType]: + """Set up lagrange polynomials and convert coefficients to FIR filters""" + plag = make_lagrange_polynomials(length, offset) + coeffs = np.array([p.convert().coef for p in plag]).T + filts: list[FilterFirNumpyType] = [] + for c in coeffs: + fdef = DefFilterFIR(filter_coeffs=c, offset=offset) + filt = make_filter_fir_numpy(fdef, EdgeHandling.VALID, EdgeHandling.VALID) + filts.append(filt) + return filts + + def __init__(self, length: int): + """Set up interpolation parameters. + + The length parameter specifies the number of interpolation polynomials, + which have order length - 1. The length is also the number of samples used + for each interpolation point. The order of the interpolating polynomials + is also the order of plynomials that are interpolated with zero error. + + Arguments: + length: Size of the interpolation stencil + """ + + if length <= 1: + msg = ( + f"RegularInterpLagrange: stencil length must be 2 or more, got {length}" + ) + raise ValueError(msg) + + offset = -((length - 1) // 2) + self._length: Final[int] = length + self._offset: Final[int] = offset + self._fir_filt: Final[list[FilterFirNumpyType]] = self._make_firs( + length, offset + ) + + @property + def margin_left(self) -> int: + """Margin size (>= 0) on the left boundary + + The interpolator cannot be called with locations within this margin from the leftmost sample. + """ + return -self._offset + + @property + def margin_right(self) -> int: + """Margin size (>= 0) on the right boundary + + The interpolator cannot be called with locations within this margin from the rightmost sample. + """ + return self._length - 1 + self._offset + + def apply( + self, + samples: NumpyArray1D, + locations: NumpyArray1D, + int_offsets: NumpyArray1D | int = 0, + ) -> np.ndarray: + """Interpolate regularly spaced data to location in index-space + + See RegularInterpCore.apply() + + Arguments: + samples: real-valued 1D numpy array with sampled data + locations: real-valued 1D numpy array with locations to interpolate to + int_offsets: integer or integer 1D array with additional offsets to the locations. + + Returns: + Interpolated samples. + """ + + if self._length % 2 == 0: + loc_int = np.floor(locations).astype(int) + else: + loc_int = np.round(locations).astype(int) + loc_frac = locations - loc_int + k = loc_int + int_offsets - self.margin_left + + if np.any(k < 0): + msg = "RegularInterpLagrange: interpolation requires samples below provided range" + raise RuntimeError(msg) + if np.any(k >= samples.shape[0] - self._length + 1): + msg = "RegularInterpLagrange: interpolation requires samples above provided range" + raise RuntimeError(msg) + + result = np.zeros(locations.shape[0], dtype=samples.dtype) + xpow = np.ones_like(loc_frac) + for fir in self._fir_filt: + result[:] += fir(samples)[k] * xpow + xpow[:] *= loc_frac + + return result + + def apply_shift( + self, + samples: NumpyArray1D, + shift: NumpyArray1D, + shift_offset: int, + ) -> np.ndarray: + """Iterpolate to location specified in terms of shifts instead absolute locations + + See RegularInterpCore.apply_shift(). + + Arguments: + samples: 1D numpy array with sampled data + shift: 1D float numpy array with shifts + shift_offset: constant integer offset + + Returns: + Interpolated samples + """ + loc = -shift + offsets = shift_offset + np.arange(shift.shape[0]) + return self.apply( + samples, make_numpy_array_1d(loc), make_numpy_array_1d(offsets) + ) + + +class RegularInterpLinear(RegularInterpCore): + """Class implementing interpolation of regularly spaced 1D data using linear interpolation. + + See RegularInterpCore for general properties not specific to the interpolation method. + """ + + @property + def margin_left(self) -> int: + """Margin size (= 0) on the left boundary + + The linear interpolator can be called for all locations within the sample range. + """ + return 0 + + @property + def margin_right(self) -> int: + """Margin size (= 0) on the right boundary + + The linear interpolator can be called for all locations within the sample range. + """ + return 0 + + def apply( + self, + samples: NumpyArray1D, + locations: NumpyArray1D, + int_offsets: NumpyArray1D | int = 0, + ) -> np.ndarray: + """Interpolate regularly spaced data to location in index-space + + See RegularInterpCore.apply() + + Arguments: + samples: 1D numpy array with sampled data + locations: real-valued 1D numpy array with locations to interpolate to + int_offsets: integer or integer 1D array with additional offsets to the locations. + + Returns: + Interpolated samples. + """ + + loc_floor = np.floor(locations) + loc_frac = locations - loc_floor + + k = loc_floor.astype(int) + int_offsets + + if np.any(k < 0) or np.any(k + 1 >= samples.shape[0]): + msg = "RegularInterpLinear: interpolation requires samples out of provided range" + raise RuntimeError(msg) + + return samples[k] * (1.0 - loc_frac) + samples[k + 1] * loc_frac + + def apply_shift( + self, + samples: NumpyArray1D, + shift: NumpyArray1D, + shift_offset: int, + ) -> np.ndarray: + """Iterpolate to location specified in terms of shifts instead absolute locations + + See RegularInterpCore.apply_shift(). + + Arguments: + samples: 1D numpy array with sampled data + shift: 1D float numpy array with shifts + shift_offset: constant integer offset + + Returns: + Interpolated samples + """ + loc = -shift + offsets = shift_offset + np.arange(shift.shape[0]) + return self.apply( + samples, make_numpy_array_1d(loc), make_numpy_array_1d(offsets) + ) + + +class RegularInterpolator: + """User-facing class for interpolation of regularly spaced data + + The interpolation method is not fixed but given by an interpolation engine. + The main purpose of this class is to provide the parameter checks common + to all interpolation methods. + """ + + def __init__(self, core: RegularInterpCore): + """Constructor not intended for direct use. + + Use named constructors make_regular_interpolator_lagrange() or + make_regular_interpolator_linear() to get interpolators employing specific methods. + """ + self._core: Final = core + + @property + def margin_left(self) -> int: + """Margin size (>= 0) on the left boundary + + The interpolator cannot be called with locations within this margin from the leftmost sample. + """ + return self._core.margin_left + + @property + def margin_right(self) -> int: + """Margin size (>= 0) on the right boundary + + The interpolator cannot be called with locations within this margin from the rightmost sample. + """ + return self._core.margin_right + + def __call__( + self, + samples_: np.ndarray, + locations_: np.ndarray, + int_offsets_: np.ndarray | int, + ) -> NumpyArray1D: + """Interpolate regularly spaced data to location in index-space + + The locations to interpolate to are the sum of the locations and int_offsets arguments. + The location argument is an 1D array with arbitrary floating point locations, and the + int_offset argument is an integer or integer array with additional integer offsets. The locations + argument is not restricted, i.e. it is not necessarily the residual fractional locations but + can have arbitrary values. + + The locations refer to the index of the array with the sampled data, starting at 0. + The length of samples array does not have to match the size of the location arrays. + If int_offsets is an array, it needs to have same size arrays location and. All + arrays need to be onedimensional. + + The locations must be within the margins given by the margin_left and margin_right + properties. + + Arguments: + samples: 1D numpy array with sampled data + locations: real-valued 1D numpy array with locations to interpolate to + int_offsets: integer or integer 1D array with additional offsets to the locations. + + Returns: + Interpolated samples. + """ + + samples = make_numpy_array_1d(samples_) + if not np.issubdtype(samples_.dtype, np.floating): + msg = "RegularInterpolator: non-float dtype for samples not allowed" + raise TypeError(msg) + + locations = make_numpy_array_1d(locations_) + if not np.issubdtype(locations_.dtype, np.floating): + msg = "RegularInterpolator: non-float dtype for locations not allowed" + raise TypeError(msg) + + int_offsets: NumpyArray1D | int + + if isinstance(int_offsets_, np.ndarray): + int_offsets = make_numpy_array_1d(int_offsets_) + if int_offsets_.shape != locations_.shape: + msg = ( + f"RegularInterpolator: inconsistent arrays sizes of " + f"locations ({locations_.shape}) and offsets ({int_offsets_.shape})" + ) + raise ValueError(msg) + if not np.issubdtype(int_offsets_.dtype, np.integer): + msg = ( + "RegularInterpolator: non-integer dtype for int_offsets not allowed" + ) + raise TypeError(msg) + elif isinstance(int_offsets_, int): + int_offsets = int_offsets_ + else: + msg = "RegularInterpolator: int_offset must be integer or integer array" + raise TypeError(msg) + + res = self._core.apply(samples, locations, int_offsets) + return make_numpy_array_1d(res) + + def apply_shift( + self, + samples_: np.ndarray, + shift_: np.ndarray, + shift_offset: int, + ) -> NumpyArray1D: + """Iterpolate to location specified in terms of shifts instead absolute locations + + See RegularInterpCore.apply_shift(). + + Arguments: + samples: 1D numpy array with sampled data + shifts: 1D float numpy array with shifts + shift_offset: constant integer offset + + Returns: + Interpolated samples + """ + + samples = make_numpy_array_1d(samples_) + if not np.issubdtype(samples_.dtype, np.floating): + msg = "RegularInterpolator: non-float dtype for samples not allowed" + raise TypeError(msg) + + shift = make_numpy_array_1d(shift_) + if not np.issubdtype(shift_.dtype, np.floating): + msg = "RegularInterpolator: non-float dtype for shifts not allowed" + raise TypeError(msg) + + res = self._core.apply_shift(samples, shift, shift_offset) + return make_numpy_array_1d(res) + + +def make_regular_interpolator_lagrange(length: int) -> RegularInterpolator: + """Create an interpolator using Lagrange interpolation + + See RegularInterpLagrange for details of the method. + + Arguments: + length: size of interpolation stencil + Returns: + Interpolation function + """ + return RegularInterpolator(RegularInterpLagrange(length)) + + +def make_regular_interpolator_linear() -> RegularInterpolator: + """Create an interpolator using linear interpolation + + Returns: + Interpolation function + """ + return RegularInterpolator(RegularInterpLinear()) -- GitLab