Source code for ecgdatakit.processing.leads

"""ECG lead derivation utilities.

Derives missing leads from Einthoven's triangle and Goldberger's equations.
Pure numpy, no scipy required.

Scaling: raw leads that share the same ``resolution`` and
``resolution_unit`` are combined in ADC counts (offsets folded in) and the
derived leads stay raw on that scale.  Otherwise both leads are converted
to physical values (in lead I's unit) first, and the derived leads are
physical.  Raw leads with different scales and no voltage unit raise
``ValueError``.  Derived leads carry no file-specific metadata
(``annotations``, ``quality``, ``transducer``, ``adc_resolution`` are reset).
"""

from __future__ import annotations

import numpy as np

from ecgdatakit.models import Lead, LeadLike, _normalize_unit, derive_is_raw
from ecgdatakit.processing._core import ensure_lead, fold_offset, new_lead


def _check_compatible(a: Lead, b: Lead) -> None:
    if a.sampling_rate != b.sampling_rate:
        raise ValueError(
            f"Sample rates must match: {a.label}={a.sampling_rate} Hz, "
            f"{b.label}={b.sampling_rate} Hz"
        )
    if len(a.samples) != len(b.samples):
        raise ValueError(
            f"Sample counts must match: {a.label}={len(a.samples)}, "
            f"{b.label}={len(b.samples)}"
        )


def _to_physical(lead: Lead) -> Lead:
    if not lead.is_raw:
        return lead
    if not _normalize_unit(lead.resolution_unit) or lead.resolution == 0.0:
        raise ValueError(
            f"Lead {lead.label!r} is raw ADC counts without a usable voltage "
            "scale, and its scale differs from the other lead; cannot "
            "combine them"
        )
    return lead.to_physical()


def _common_scale(a: Lead, b: Lead) -> tuple[Lead, Lead]:
    """Bring two leads onto one scale so samples can be added.

    Raw leads with identical ``resolution`` and ``resolution_unit`` stay in
    ADC counts (offsets folded into the samples).  Any other combination is
    converted to physical values in lead *a*'s unit.
    """
    if (
        a.is_raw and b.is_raw
        and a.resolution == b.resolution
        and a.resolution_unit == b.resolution_unit
    ):
        if a.offset != b.offset and a.resolution == 0.0:
            raise ValueError(
                f"Leads {a.label!r} and {b.label!r} have different offsets "
                "and no resolution; cannot combine them"
            )
        return fold_offset(a), fold_offset(b)
    a, b = _to_physical(a), _to_physical(b)
    ua, ub = _normalize_unit(a.units), _normalize_unit(b.units)
    if ua and ub:
        if ua != ub:
            b = b.convert_units(a.units)
    elif a.units != b.units:
        raise ValueError(
            f"Leads have incompatible units: {a.label}={a.units!r}, "
            f"{b.label}={b.units!r}"
        )
    return a, b


def _derived(template: Lead, other: Lead, samples, label: str) -> Lead:
    """Lead computed from *template* and *other*, without file-specific metadata."""
    if template.is_raw:
        scale = dict(
            resolution=template.resolution,
            resolution_unit=template.resolution_unit,
            is_raw=derive_is_raw(template.resolution, 0.0, template.resolution_unit),
        )
    else:
        scale = dict(resolution=1.0, resolution_unit=template.units, is_raw=False)
    return new_lead(
        template,
        samples=np.asarray(samples, dtype=np.float64),
        label=label,
        offset=0.0,
        adc_resolution=0.0,
        adc_resolution_unit="",
        quality=None,
        transducer="",
        prefiltering=(
            template.prefiltering
            if template.prefiltering == other.prefiltering else ""
        ),
        annotations={},
        **scale,
    )


def _prepare_limb(lead_i: LeadLike, lead_ii: LeadLike, fs: int | None) -> tuple[Lead, Lead]:
    lead_i = ensure_lead(lead_i, fs=fs, label="I")
    lead_ii = ensure_lead(lead_ii, fs=fs, label="II")
    _check_compatible(lead_i, lead_ii)
    return _common_scale(lead_i, lead_ii)


[docs] def derive_lead_iii( lead_i: LeadLike, lead_ii: LeadLike, *, fs: int | None = None, ) -> Lead: """Derive Lead III from Leads I and II (Einthoven's law: III = II - I). Parameters ---------- lead_i : Lead | NDArray[np.float64] Lead I signal. lead_ii : Lead | NDArray[np.float64] Lead II signal. fs : int | None Sample rate in Hz. Required when passing numpy arrays. """ lead_i, lead_ii = _prepare_limb(lead_i, lead_ii, fs) return _derived(lead_i, lead_ii, lead_ii.samples - lead_i.samples, "III")
[docs] def derive_augmented( lead_i: LeadLike, lead_ii: LeadLike, *, fs: int | None = None, ) -> list[Lead]: """Derive augmented limb leads aVR, aVL, aVF from Leads I and II. Parameters ---------- lead_i : Lead | NDArray[np.float64] Lead I signal. lead_ii : Lead | NDArray[np.float64] Lead II signal. fs : int | None Sample rate in Hz. Required when passing numpy arrays. Returns ------- list[Lead] [aVR, aVL, aVF] in that order. """ lead_i, lead_ii = _prepare_limb(lead_i, lead_ii, fs) i, ii = lead_i.samples, lead_ii.samples return [ _derived(lead_i, lead_ii, -(i + ii) / 2.0, "aVR"), _derived(lead_i, lead_ii, i - ii / 2.0, "aVL"), _derived(lead_i, lead_ii, ii - i / 2.0, "aVF"), ]
[docs] def derive_standard_12( lead_i: LeadLike, lead_ii: LeadLike, v1: LeadLike, v2: LeadLike, v3: LeadLike, v4: LeadLike, v5: LeadLike, v6: LeadLike, *, fs: int | None = None, ) -> list[Lead]: """Assemble a full 12-lead ECG, deriving III, aVR, aVL, aVF. Parameters ---------- lead_i, lead_ii : Lead | NDArray[np.float64] Limb leads I and II. v1..v6 : Lead | NDArray[np.float64] Precordial leads. fs : int | None Sample rate in Hz. Required when passing numpy arrays. Returns ------- list[Lead] 12 leads in standard order: I, II, III, aVR, aVL, aVF, V1–V6. When the limb leads had to be converted to physical values (see the module notes), I, II and V1–V6 are returned converted as well, so all 12 leads share one scale. """ lead_i, lead_ii = _prepare_limb(lead_i, lead_ii, fs) precordial = [] for lead, label in zip((v1, v2, v3, v4, v5, v6), ("V1", "V2", "V3", "V4", "V5", "V6")): lead = ensure_lead(lead, fs=fs, label=label) _check_compatible(lead_i, lead) if not lead_i.is_raw: # Limb leads were scaled to physical values; match them lead = _to_physical(lead) unit, target = _normalize_unit(lead.units), _normalize_unit(lead_i.units) if unit and target and unit != target: lead = lead.convert_units(lead_i.units) precordial.append(lead) i, ii = lead_i.samples, lead_ii.samples derived = [ _derived(lead_i, lead_ii, ii - i, "III"), _derived(lead_i, lead_ii, -(i + ii) / 2.0, "aVR"), _derived(lead_i, lead_ii, i - ii / 2.0, "aVL"), _derived(lead_i, lead_ii, ii - i / 2.0, "aVF"), ] return [lead_i, lead_ii, *derived, *precordial]
[docs] def find_lead(leads: list[Lead], label: str) -> Lead | None: """Find a lead by label (case-insensitive). Parameters ---------- leads : list[Lead] List of leads to search. label : str Lead label to find (e.g., "II", "avl", "V1"). """ target = label.lower() for lead in leads: if lead.label.lower() == target: return lead return None