Skip to content

Orthogonal Subspace Projection

biosigpy.hrv.osp

Respiration-related HRV decomposition by orthogonal projection.

OspResult

Bases: NamedTuple

Named, unpackable output of :func:osp.

Source code in src/biosigpy/hrv/osp.py
16
17
18
19
20
21
class OspResult(NamedTuple):
    """Named, unpackable output of :func:`osp`."""

    m_resp: np.ndarray | float
    m_unrelated: np.ndarray | float
    delay: int | np.ndarray

osp

osp(m: ArrayLike, resp: ArrayLike, resp_pxx: ArrayLike, f: ArrayLike, fs: float, min_resp_frequency: float = 0.1) -> OspResult

Separate respiration-related and unrelated HRV modulation.

Parameters:

Name Type Description Default
m array_like

Uniformly sampled, dimensionless HRV modulating signal.

required
resp array_like

Respiration samples aligned with m on the same time grid.

required
resp_pxx array_like

Finite, nonnegative respiration power spectral density.

required
f array_like

Finite, strictly increasing frequency samples in hertz.

required
fs float

Positive sampling frequency in hertz.

required
min_resp_frequency float

Positive lower bound for the selected respiratory frequency.

0.1

Returns:

Type Description
OspResult

Respiration-related modulation, orthogonal residual, and adaptive delayed-respiration model order. The component arrays align with m[delay - 1:].

Raises:

Type Description
TypeError

If an input has an invalid numeric type.

ValueError

If vector shapes, spectrum values, frequencies, sampling parameters, or finite signal lengths violate the Biosiglib contract.

Notes

The Gram-matrix pseudoinverse uses the explicit Biosiglib binary64 threshold, rather than NumPy's default pseudoinverse tolerance.

Examples:

>>> result = osp(
...     [99, 1, 2, 3, 4, 5],
...     [1, 0, -1, 0, 1, 0],
...     [0, 0, 1],
...     [0, 0.5, 1],
...     1,
... )
>>> result.delay
2
>>> np.allclose(result.m_resp + result.m_unrelated, [1, 2, 3, 4, 5])
True
Source code in src/biosigpy/hrv/osp.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def osp(
    m: ArrayLike,
    resp: ArrayLike,
    resp_pxx: ArrayLike,
    f: ArrayLike,
    fs: float,
    min_resp_frequency: float = 0.1,
) -> OspResult:
    """Separate respiration-related and unrelated HRV modulation.

    Parameters
    ----------
    m : array_like
        Uniformly sampled, dimensionless HRV modulating signal.
    resp : array_like
        Respiration samples aligned with ``m`` on the same time grid.
    resp_pxx : array_like
        Finite, nonnegative respiration power spectral density.
    f : array_like
        Finite, strictly increasing frequency samples in hertz.
    fs : float
        Positive sampling frequency in hertz.
    min_resp_frequency : float, default=0.1
        Positive lower bound for the selected respiratory frequency.

    Returns
    -------
    OspResult
        Respiration-related modulation, orthogonal residual, and adaptive
        delayed-respiration model order. The component arrays align with
        ``m[delay - 1:]``.

    Raises
    ------
    TypeError
        If an input has an invalid numeric type.
    ValueError
        If vector shapes, spectrum values, frequencies, sampling parameters,
        or finite signal lengths violate the Biosiglib contract.

    Notes
    -----
    The Gram-matrix pseudoinverse uses the explicit Biosiglib binary64
    threshold, rather than NumPy's default pseudoinverse tolerance.

    Examples
    --------
    >>> result = osp(
    ...     [99, 1, 2, 3, 4, 5],
    ...     [1, 0, -1, 0, 1, 0],
    ...     [0, 0, 1],
    ...     [0, 0.5, 1],
    ...     1,
    ... )
    >>> result.delay
    2
    >>> np.allclose(result.m_resp + result.m_unrelated, [1, 2, 3, 4, 5])
    True
    """

    modulation = as_real_vector(m, name="m")
    respiration = as_real_vector(resp, name="resp")
    spectrum = as_real_vector(resp_pxx, name="resp_pxx")
    frequencies = as_real_vector(f, name="f")
    sampling_frequency = as_positive_real_scalar(fs, name="fs")
    minimum_frequency = as_positive_real_scalar(
        min_resp_frequency, name="min_resp_frequency"
    )

    _validate_spectrum(spectrum, frequencies)

    if modulation.size == 0 or respiration.size == 0:
        empty = np.asarray([], dtype=np.float64)
        return OspResult(empty.copy(), empty.copy(), empty.copy())
    if np.any(np.isnan(modulation)) or np.any(np.isnan(respiration)):
        empty = np.asarray([], dtype=np.float64)
        return OspResult(empty.copy(), empty.copy(), empty.copy())
    if np.any(np.isinf(modulation)) or np.any(np.isinf(respiration)):
        raise ValueError("m and resp must not contain infinite values")
    if modulation.size != respiration.size:
        raise ValueError("m and resp must have the same length")

    low_frequency, high_frequency = _occupied_power_limits(
        spectrum, frequencies
    )
    band_mask = (frequencies >= low_frequency) & (
        frequencies <= high_frequency
    )
    if not np.any(band_mask):
        band_mask = np.ones(frequencies.shape, dtype=bool)

    band_spectrum = spectrum[band_mask]
    band_frequencies = frequencies[band_mask]
    dominant_frequency = _dominant_frequency(
        band_spectrum, band_frequencies
    )
    dominant_frequency = max(dominant_frequency, minimum_frequency)
    delay = max(
        int(np.floor(2.0 * sampling_frequency / dominant_frequency + 0.5)),
        1,
    )

    if modulation.size < delay:
        return OspResult(np.nan, np.nan, delay)

    subspace = np.lib.stride_tricks.sliding_window_view(
        respiration, delay
    )
    gram = subspace.T @ subspace
    gram_inverse = _gram_pseudoinverse(gram)
    projection = subspace @ gram_inverse @ subspace.T
    delayed_modulation = modulation[delay - 1 :]
    m_resp = projection @ delayed_modulation
    m_unrelated = delayed_modulation - m_resp
    return OspResult(
        m_resp=np.asarray(m_resp, dtype=np.float64),
        m_unrelated=np.asarray(m_unrelated, dtype=np.float64),
        delay=delay,
    )

View executable example