# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
.. Features (see parent package :mod:`sigima.algorithms.signal`)
"""
from __future__ import annotations
import numpy as np
from sigima.tools.checks import check_1d_array, check_1d_arrays
[docs]
@check_1d_array(min_size=2, finite_only=True)
def find_zero_crossings(y: np.ndarray) -> np.ndarray:
"""Find the left indices of the zero-crossing intervals in the given array.
A zero crossing is detected when consecutive non-zero samples have opposite
signs. Samples exactly equal to zero are skipped: a contiguous run of zero
samples that bridges samples of opposite sign counts as a single crossing,
located at the index of the last non-zero sample before the run; a run of
zeros that does not change the sign (e.g. ``[1, 0, 1]``) is not reported as
a crossing.
Args:
y: Input array.
Returns:
An array of indices where zero-crossings occur.
"""
sgn = np.sign(np.asarray(y))
nonzero_idx = np.flatnonzero(sgn)
if nonzero_idx.size < 2:
return np.array([], dtype=int)
# Detect sign changes between consecutive non-zero samples (skipping zeros).
changes = np.flatnonzero(np.diff(sgn[nonzero_idx]) != 0)
return nonzero_idx[changes]
[docs]
@check_1d_arrays(x_sorted=True)
def find_x_axis_crossings(x: np.ndarray, y: np.ndarray) -> np.ndarray:
"""Find the :math:`x_n` values where :math:`y = f(x)` intercepts the x-axis.
This function uses zero-crossing detection and interpolation to find the x values
where :math:`y = 0`.
Args:
x: X data.
y: Y data.
Returns:
Array of x-intercepts. The array is empty if no intercept is found.
"""
# Find zero crossings.
xi_before = find_zero_crossings(y)
if len(xi_before) == 0:
return np.array([])
# Interpolate to find x values at zero crossings.
xi_after = xi_before + 1
slope = (y[xi_after] - y[xi_before]) / (x[xi_after] - x[xi_before])
with np.errstate(divide="ignore"):
x0 = -y[xi_before] / slope + x[xi_before]
x0 = np.where(np.isfinite(x0), x0, (x[xi_before] + x[xi_after]) / 2)
# mask = ~np.isfinite(x0)
# x0[mask] = xi_before[mask]
return x0
[docs]
@check_1d_arrays(x_min_size=2, x_finite_only=True, x_sorted=True)
def find_y_at_x_value(x: np.ndarray, y: np.ndarray, x_target: float) -> float:
"""Return the y value at a specified x value using linear interpolation.
Args:
x: X data.
y: Y data.
x_target: Input x value.
Returns:
Interpolated y value at x_target, or `nan` if input value is not within the
interpolation range.
"""
if np.isnan(x_target):
return np.nan
return float(np.interp(x_target, x, y, left=np.nan, right=np.nan))
[docs]
@check_1d_arrays
def find_x_values_at_y(x: np.ndarray, y: np.ndarray, y_target: float) -> np.ndarray:
"""Find all x values where :math:`y = f(x)` equals the value :math:`y_target`.
Args:
x: X data.
y: Y data.
y_target: Target value.
Returns:
Array of x values where :math:`y = f(x)` equals :math:`y_target`.
"""
return find_x_axis_crossings(x, y - y_target)
[docs]
@check_1d_arrays(x_evenly_spaced=True)
def find_bandwidth_coordinates(
x: np.ndarray, y: np.ndarray, threshold: float = -3.0
) -> tuple[float, float, float, float] | None:
"""Compute the bandwidth of the signal at a given threshold relative to the maximum.
Args:
x: X data.
y: Y data.
threshold: Threshold in decibel (relative to the maximum) at which the bandwidth
is computed. Defaults to -3.0 dB.
Returns:
Segment coordinates of the bandwidth of the signal at the given threshold.
Returns None if the bandwidth cannot be determined.
"""
level: float = np.max(y) + threshold
crossings = find_x_values_at_y(x, y, level)
if len(crossings) == 1:
# One crossing: 1) baseband bandwidth if max is above crossing
# 2) passband bandwidth if max is below crossing
if x[np.argmax(y)] < crossings[0]: # Baseband bandwidth
coords = (0.0, level, crossings[0], level)
else:
coords = (crossings[0], level, x[-1], level)
elif len(crossings) == 2: # Passband bandwidth
# Two crossings: 1) passband bandwidth if max is above both crossings
# 2) no bandwidth if max is below both crossings
# 3) baseband bandwidth if max is between crossings
coords = (crossings[0], level, crossings[1], level)
else:
# No crossing or more than two crossings: cannot determine bandwidth
return None
return coords
[docs]
def contrast(y: np.ndarray) -> float:
"""Compute contrast.
The contrast is defined as ``(max - min) / (|max| + |min|)``. For
non-negative signals this matches the standard Michelson contrast
``(max - min) / (max + min)``. For signals containing negative samples,
the absolute values in the denominator keep the result well-defined and
bounded in ``[0, 1]``. If both ``max`` and ``min`` are zero, ``nan`` is
returned.
Args:
y: Input array
Returns:
Contrast value in ``[0, 1]``, or ``nan`` if undefined.
"""
max_, min_ = np.max(y), np.min(y)
denom = np.abs(max_) + np.abs(min_)
if denom == 0:
return float("nan")
return float((max_ - min_) / denom)