LIBRE / tests /unit /test_signal_processor.py
RyZ
feat: adding full working local ETL Pipeline
e391a84
Raw
History Blame Contribute Delete
4.91 kB
"""
tests/unit/test_signal_processor.py
─────────────────────────────────────
Unit tests for ScipySignalProcessor.
No external services required β€” SciPy + NumPy only.
"""
from __future__ import annotations
import numpy as np
import pytest
from src.infrastructure.processing.scipy_signal_processor import ScipySignalProcessor
from src.shared.constants import PPG_SEGMENT_DURATION_SEC
@pytest.fixture
def processor() -> ScipySignalProcessor:
return ScipySignalProcessor()
@pytest.fixture
def clean_signal() -> tuple[np.ndarray, float]:
"""A clean 30-second sine wave at 125 Hz (2 Hz fundamental)."""
fs = 125.0
duration = 30.0
t = np.linspace(0, duration, int(fs * duration), endpoint=False)
signal = np.sin(2 * np.pi * 2.0 * t) # 2 Hz sine
return signal, fs
# ── filter_signal ─────────────────────────────────────────────────────────────
class TestFilterSignal:
def test_output_same_length_as_input(
self, processor: ScipySignalProcessor, clean_signal: tuple
) -> None:
sig, fs = clean_signal
filtered = processor.filter_signal(sig, fs)
assert filtered.shape == sig.shape
def test_output_is_numpy_array(
self, processor: ScipySignalProcessor, clean_signal: tuple
) -> None:
sig, fs = clean_signal
filtered = processor.filter_signal(sig, fs)
assert isinstance(filtered, np.ndarray)
def test_dc_component_removed(self, processor: ScipySignalProcessor) -> None:
"""DC offset (0 Hz) should be removed by the bandpass filter."""
fs = 125.0
t = np.linspace(0, 10, int(fs * 10), endpoint=False)
signal = np.ones_like(t) * 5.0 # constant DC
filtered = processor.filter_signal(signal, fs)
# After bandpass filtering, mean should be near zero
assert abs(np.mean(filtered)) < 0.1
# ── normalize ─────────────────────────────────────────────────────────────────
class TestNormalize:
def test_mean_near_zero(
self, processor: ScipySignalProcessor, clean_signal: tuple
) -> None:
sig, _ = clean_signal
normed = processor.normalize(sig)
assert abs(np.mean(normed)) < 1e-6
def test_std_near_one(
self, processor: ScipySignalProcessor, clean_signal: tuple
) -> None:
sig, _ = clean_signal
normed = processor.normalize(sig)
assert abs(np.std(normed) - 1.0) < 1e-6
def test_constant_signal_returns_zeros(self, processor: ScipySignalProcessor) -> None:
constant = np.ones(100) * 3.0
result = processor.normalize(constant)
assert np.allclose(result, 0.0)
# ── segment ───────────────────────────────────────────────────────────────────
class TestSegment:
def test_segment_shape(
self, processor: ScipySignalProcessor, clean_signal: tuple
) -> None:
sig, fs = clean_signal
segments = processor.segment(sig, fs)
window_size = int(PPG_SEGMENT_DURATION_SEC * fs)
expected_n = len(sig) // window_size
assert segments.shape == (expected_n, window_size)
def test_too_short_signal_returns_empty(self, processor: ScipySignalProcessor) -> None:
fs = 125.0
short_signal = np.zeros(10) # much less than 8s Γ— 125 = 1000 samples
segments = processor.segment(short_signal, fs)
assert segments.shape[0] == 0
def test_segments_are_contiguous(
self, processor: ScipySignalProcessor, clean_signal: tuple
) -> None:
sig, fs = clean_signal
segments = processor.segment(sig, fs)
window_size = int(PPG_SEGMENT_DURATION_SEC * fs)
# Check that first segment matches first window_size elements
np.testing.assert_array_equal(segments[0], sig[:window_size])
# ── process (full pipeline) ───────────────────────────────────────────────────
class TestProcessPipeline:
def test_process_returns_2d_array(
self, processor: ScipySignalProcessor, clean_signal: tuple
) -> None:
sig, fs = clean_signal
result = processor.process(sig.tolist(), fs)
assert result.ndim == 2
def test_process_list_input(self, processor: ScipySignalProcessor) -> None:
fs = 125.0
raw = [0.1] * int(30 * fs)
result = processor.process(raw, fs)
assert isinstance(result, np.ndarray)