Source code for ecgdatakit.processing.normalize

"""ECG signal normalization utilities.

Pure numpy, no scipy required.

Min-max and z-score outputs are dimensionless: returned leads have
``units=""``, ``resolution=1.0``, ``resolution_unit=""``, ``offset=0.0`` and
``is_raw=False`` so they cannot be mistaken for voltages.  Amplitude
normalization is a pure gain and keeps the lead's units.

NaN samples (gaps in the file) are ignored when computing the statistics
and stay NaN in the output.  A constant signal normalizes to zeros; a
signal whose peak-to-peak range is at most ``1e-12`` times its largest
magnitude counts as constant, so floating-point rounding is not blown up
to +-1.  A flat line around zero (e.g. a high-passed constant, which is
pure rounding noise near 0) has no scale to compare with and is
normalized like any other signal.
"""

from __future__ import annotations

import dataclasses
from typing import Callable, overload

import numpy as np
from numpy.typing import NDArray

from ecgdatakit.models import _TO_UV, ECGRecord, Lead, _normalize_unit
from ecgdatakit.processing._core import new_lead


# ---------------------------------------------------------------------------
# normalize_minmax
# ---------------------------------------------------------------------------

@overload
def normalize_minmax(data: Lead) -> Lead: ...
@overload
def normalize_minmax(data: list[Lead]) -> list[Lead]: ...
@overload
def normalize_minmax(data: ECGRecord) -> ECGRecord: ...
@overload
def normalize_minmax(data: list[ECGRecord]) -> list[ECGRecord]: ...
@overload
def normalize_minmax(data: NDArray[np.float64]) -> NDArray[np.float64]: ...

[docs] def normalize_minmax( data: Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64], ) -> Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64]: """Scale signal to the [-1, 1] range. Normalization is applied **per lead, per ECG**. Accepted inputs: * :class:`Lead` — single lead. * ``list[Lead]`` — multiple leads (e.g. a 12-lead ECG). * :class:`ECGRecord` — all leads and median beats in the record. * ``list[ECGRecord]`` — multiple records. * numpy array: 1-D ``(n_samples,)``, 2-D ``(n_leads, n_samples)`` or 3-D ``(n_ecgs, n_leads, n_samples)``; each row along the last axis is normalized independently and the result is float64. Returns the same type as the input. """ return _dispatch(data, _minmax_samples, _dimensionless(_minmax_samples))
# --------------------------------------------------------------------------- # normalize_zscore # --------------------------------------------------------------------------- @overload def normalize_zscore(data: Lead) -> Lead: ... @overload def normalize_zscore(data: list[Lead]) -> list[Lead]: ... @overload def normalize_zscore(data: ECGRecord) -> ECGRecord: ... @overload def normalize_zscore(data: list[ECGRecord]) -> list[ECGRecord]: ... @overload def normalize_zscore(data: NDArray[np.float64]) -> NDArray[np.float64]: ...
[docs] def normalize_zscore( data: Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64], ) -> Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64]: """Normalize signal to zero mean and unit variance (z-score). Normalization is applied **per lead, per ECG**. Accepted inputs: * :class:`Lead` — single lead. * ``list[Lead]`` — multiple leads (e.g. a 12-lead ECG). * :class:`ECGRecord` — all leads and median beats in the record. * ``list[ECGRecord]`` — multiple records. * numpy array: 1-D ``(n_samples,)``, 2-D ``(n_leads, n_samples)`` or 3-D ``(n_ecgs, n_leads, n_samples)``; each row along the last axis is normalized independently and the result is float64. Returns the same type as the input. """ return _dispatch(data, _zscore_samples, _dimensionless(_zscore_samples))
# --------------------------------------------------------------------------- # normalize_amplitude # --------------------------------------------------------------------------- @overload def normalize_amplitude(data: Lead, target_mv: float = 1.0) -> Lead: ... @overload def normalize_amplitude(data: list[Lead], target_mv: float = 1.0) -> list[Lead]: ... @overload def normalize_amplitude(data: ECGRecord, target_mv: float = 1.0) -> ECGRecord: ... @overload def normalize_amplitude(data: list[ECGRecord], target_mv: float = 1.0) -> list[ECGRecord]: ... @overload def normalize_amplitude(data: NDArray[np.float64], target_mv: float = 1.0) -> NDArray[np.float64]: ...
[docs] def normalize_amplitude( data: Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64], target_mv: float = 1.0, ) -> Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64]: """Scale signal so that its maximum absolute amplitude equals *target_mv*. Normalization is applied **per lead, per ECG**. Accepted inputs: * :class:`Lead` — single lead. * ``list[Lead]`` — multiple leads (e.g. a 12-lead ECG). * :class:`ECGRecord` — all leads and median beats in the record. * ``list[ECGRecord]`` — multiple records. * numpy array: 1-D ``(n_samples,)``, 2-D ``(n_leads, n_samples)`` or 3-D ``(n_ecgs, n_leads, n_samples)``; each row along the last axis is normalized independently and the result is float64. Returns the same type as the input. The scaling is a pure gain on the physical signal, so the lead keeps its units and scaling metadata. For a lead in a known voltage unit (``units`` for physical leads, ``resolution_unit`` for raw leads) the peak of the physical signal becomes *target_mv* millivolts, expressed in that unit (e.g. 1000 for a ``"uV"`` lead). Without a known unit, and for numpy input, the peak becomes *target_mv* in the signal's own units. Parameters ---------- target_mv : float Target peak amplitude in mV (default 1.0). """ def _amp(samples: NDArray[np.float64]) -> NDArray[np.float64]: return _amplitude_samples(samples, target_mv) def _amp_lead(lead: Lead) -> Lead: return _amplitude_lead(lead, target_mv) return _dispatch(data, _amp, _amp_lead)
# --------------------------------------------------------------------------- # Internal dispatch # --------------------------------------------------------------------------- _SampleFn = Callable[[NDArray[np.float64]], NDArray[np.float64]] _LeadFn = Callable[[Lead], Lead] def _dimensionless(fn: _SampleFn) -> _LeadFn: """Wrap *fn* so the returned lead carries no physical unit or scaling.""" def apply(lead: Lead) -> Lead: samples = np.asarray(lead.samples, dtype=np.float64) return new_lead( lead, samples=fn(samples), units="", resolution=1.0, resolution_unit="", offset=0.0, is_raw=False, ) return apply def _amplitude_lead(lead: Lead, target_mv: float) -> Lead: samples = np.asarray(lead.samples, dtype=np.float64) unit = _normalize_unit(lead.resolution_unit if lead.is_raw else lead.units) target = target_mv * _TO_UV["mV"] / _TO_UV[unit] if unit else target_mv if lead.is_raw and lead.resolution != 0.0: physical = samples * lead.resolution + lead.offset else: physical = samples peak = _nan_peak(physical) if peak is None: return new_lead(lead, samples=samples.copy()) if peak == 0: return new_lead(lead, samples=np.where(np.isnan(samples), np.nan, 0.0), offset=0.0) gain = target / peak return new_lead(lead, samples=samples * gain, offset=lead.offset * gain) def _normalize_record(record: ECGRecord, fn: _LeadFn) -> ECGRecord: return dataclasses.replace( record, leads=[fn(ld) for ld in record.leads], median_beats=[fn(mb) for mb in record.median_beats], leads_enhanced=[fn(ld) for ld in record.leads_enhanced], ) def _normalize_array(data: NDArray, fn: _SampleFn) -> NDArray[np.float64]: """Normalize each row along the last axis of a 1-D, 2-D or 3-D array.""" data = np.asarray(data, dtype=np.float64) if data.ndim == 1: return fn(data) result = np.empty(data.shape, dtype=np.float64) for idx in np.ndindex(data.shape[:-1]): result[idx] = fn(data[idx]) return result def _dispatch( data: Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64], fn: _SampleFn, lead_fn: _LeadFn, ) -> Lead | list[Lead] | ECGRecord | list[ECGRecord] | NDArray[np.float64]: if isinstance(data, Lead): return lead_fn(data) if isinstance(data, ECGRecord): return _normalize_record(data, lead_fn) if isinstance(data, np.ndarray): if data.ndim not in (1, 2, 3): raise ValueError( "numpy input must be 1-D (n_samples,), 2-D (n_leads, n_samples) " f"or 3-D (n_ecgs, n_leads, n_samples), got {data.ndim}-D" ) return _normalize_array(data, fn) if isinstance(data, list): if not data: return [] if all(isinstance(ld, Lead) for ld in data): return [lead_fn(ld) for ld in data] if all(isinstance(rec, ECGRecord) for rec in data): return [_normalize_record(rec, lead_fn) for rec in data] raise TypeError( f"Expected Lead, list[Lead], ECGRecord, list[ECGRecord], " f"or a 1-D/2-D/3-D numpy array, got {type(data).__name__}" ) # --------------------------------------------------------------------------- # Sample-level normalization functions (NaN-aware) # --------------------------------------------------------------------------- def _nan_peak(samples: NDArray[np.float64]) -> float | None: """Largest absolute value ignoring NaN, ``None`` if there is none.""" if samples.size == 0 or np.isnan(samples).all(): return None return float(np.nanmax(np.abs(samples))) _CONSTANT_RTOL = 1e-12 """Peak-to-peak below this fraction of the peak magnitude counts as constant.""" def _constant_or_empty(samples: NDArray[np.float64]) -> NDArray[np.float64] | None: """Result for empty, all-NaN or constant input, else ``None``.""" if samples.size == 0 or np.isnan(samples).all(): return samples.astype(np.float64, copy=True) vmax, vmin = np.nanmax(samples), np.nanmin(samples) # Constant up to rounding (e.g. [0.1 + 0.2, 0.3]): scaling the last-bit # noise up to +-1 would invent a signal if vmax - vmin <= _CONSTANT_RTOL * max(abs(vmax), abs(vmin)): return np.where(np.isnan(samples), np.nan, 0.0) return None def _minmax_samples(samples: NDArray[np.float64]) -> NDArray[np.float64]: special = _constant_or_empty(samples) if special is not None: return special vmin, vmax = np.nanmin(samples), np.nanmax(samples) return (2.0 * (samples - vmin) / (vmax - vmin) - 1.0).astype(np.float64) def _zscore_samples(samples: NDArray[np.float64]) -> NDArray[np.float64]: special = _constant_or_empty(samples) if special is not None: return special return ((samples - np.nanmean(samples)) / np.nanstd(samples)).astype(np.float64) def _amplitude_samples(samples: NDArray[np.float64], target_mv: float) -> NDArray[np.float64]: peak = _nan_peak(samples) if peak is None: return samples.astype(np.float64, copy=True) if peak == 0: return np.where(np.isnan(samples), np.nan, 0.0) return (samples * (target_mv / peak)).astype(np.float64)