Source code for neurodent.core.utils.units

"""Unit conversion and MNE unit extraction."""

import logging

import numpy as np
try:
    import mne
    from mne.io.constants import FIFF
except Exception:  # pragma: no cover - optional at import time
    mne = None
    FIFF = None


# Bounds on the median absolute amplitude of a recording claiming to be in µV. Real per-channel
# medians run 17..369 µV. HARD bounds are wide enough that no physiological recording can cross them,
# but narrow enough to catch a 1e6 volts-as-µV slip. SOFT bounds would catch a 1e3 slip (mV/nV), but a
# loud recording can legitimately cross them, so those only warn.
UV_HARD_MIN, UV_HARD_MAX = 1e-2, 1e5
UV_SOFT_MIN, UV_SOFT_MAX = 1e0, 1e3


[docs] def assert_microvolts(data, context: str = "recording") -> float | None: """Raise if ``data`` cannot plausibly be in microvolts. Does not rescale. Tests the median absolute amplitude over finite, non-zero samples. Zeros are excluded so a dead channel does not drag the median to 0 and trip the floor. Args: data: voltages claimed to be in µV. context (str): what is being checked, for the error message. Returns: float | None: the median absolute amplitude in µV, or None if data was empty or all-zero. Raises: ValueError: if the median lies outside the hard bounds. """ arr = np.asarray(data, dtype=float) finite = arr[np.isfinite(arr)] nonzero = np.abs(finite[finite != 0]) if nonzero.size == 0: return None med = float(np.median(nonzero)) if not (UV_HARD_MIN <= med <= UV_HARD_MAX): likely = "volts mistaken for µV (a factor of 1e6)" if med < UV_HARD_MIN else ( "nanovolts mistaken for µV (a factor of 1e3)" ) raise ValueError( f"{context}: data claims to be in µV but its median amplitude is {med:.3g} µV, outside " f"the physiologically possible range [{UV_HARD_MIN:g}, {UV_HARD_MAX:g}] µV. " f"Most likely cause: {likely}. Fix the unit handling at the source; this will not " f"rescale for you." ) if not (UV_SOFT_MIN <= med <= UV_SOFT_MAX): logging.warning( "%s: median amplitude is %.3g µV, outside the usual [%g, %g] µV. Possible for an unusually " "quiet or loud recording, but also what a 1e3 unit slip (mV/nV) looks like.", context, med, UV_SOFT_MIN, UV_SOFT_MAX, ) return med
[docs] def convert_units_to_multiplier(current_units: str, target_units: str = "µV") -> float: """ Convert between different voltage units and return the multiplication factor. This function calculates the conversion factor needed to transform values from one voltage unit to another (e.g., from mV to µV). Args: current_units (str): The current unit of the values. Must be one of: 'µV', 'mV', 'V', 'nV'. target_units (str, optional): The target unit to convert to. Defaults to 'µV'. Must be one of: 'µV', 'mV', 'V', 'nV'. Returns: float: The multiplication factor to convert from current_units to target_units. To convert values, multiply your data by this factor. Raises: AssertionError: If current_units or target_units are not supported. Examples: >>> convert_units_to_multiplier("mV", "µV") 1000.0 >>> convert_units_to_multiplier("V", "mV") 1000.0 >>> convert_units_to_multiplier("µV", "V") 1e-06 """ units_to_mult = {"µV": 1e-6, "mV": 1e-3, "V": 1, "nV": 1e-9} assert current_units in units_to_mult.keys(), f"No valid current unit called '{current_units}' found" assert target_units in units_to_mult.keys(), f"No valid target unit called '{target_units}' found" return units_to_mult[current_units] / units_to_mult[target_units]
[docs] def extract_mne_unit_info(raw_info: dict) -> tuple[str | None, float | None]: """Extract unit information from MNE Raw info object. Args: raw_info (dict): MNE Raw.info object containing channel information Returns: tuple[str | None, float | None]: (unit_name, mult_to_uV) where unit_name is the consistent unit across all channels and mult_to_uV is the conversion factor to µV Raises: ValueError: If channel units are inconsistent across channels """ if mne is None or FIFF is None: logging.warning("MNE not available, cannot extract unit information") return None, None if "chs" not in raw_info or not raw_info["chs"]: logging.warning("No channel information found in MNE Raw.info, using default units") return None, None # Extract unit information from all channels channel_units = [] unit_muls = [] for ch_info in raw_info["chs"]: ch_name = ch_info.get("ch_name", "unknown") unit = ch_info.get("unit", None) unit_mul = ch_info.get("unit_mul", None) if unit is not None and unit_mul is not None: channel_units.append((ch_name, unit, unit_mul)) unit_muls.append(unit_mul) if not channel_units: logging.warning("No unit information found in any channels, using default units") return None, None # Check for consistency in unit values unique_units = set(unit for _, unit, _ in channel_units) unique_unit_muls = set(unit_muls) if len(unique_units) > 1: unit_details = [(ch, unit, mul) for ch, unit, mul in channel_units] raise ValueError( f"Inconsistent units across channels. Found different unit values: {unique_units}. " f"Channel details: {unit_details}" ) if len(unique_unit_muls) > 1: unit_details = [(ch, unit, mul) for ch, unit, mul in channel_units] raise ValueError( f"Inconsistent unit multipliers across channels. Found different unit_mul values: {unique_unit_muls}. " f"Channel details: {unit_details}" ) # Get the consistent unit values unit_code = list(unique_units)[0] unit_mul = list(unique_unit_muls)[0] # Convert MNE unit codes to string representation using FIFF constants # Based on MNE FIFF constants documentation unit_str = None if hasattr(FIFF, "FIFF_UNIT_V") and unit_code == FIFF.FIFF_UNIT_V: unit_str = "V" elif hasattr(FIFF, "FIFF_UNIT_T") and unit_code == FIFF.FIFF_UNIT_T: unit_str = "T" # Tesla - MEG magnetometer elif hasattr(FIFF, "FIFF_UNIT_T_M") and unit_code == FIFF.FIFF_UNIT_T_M: unit_str = "T/m" # Tesla/meter - MEG gradiometer else: logging.warning(f"Unknown MNE unit code {unit_code}, using default units") return None, None # Convert unit multipliers using FIFF constants multiplier = None if hasattr(FIFF, "FIFF_UNITM_NONE") and unit_mul == FIFF.FIFF_UNITM_NONE: multiplier = 1.0 elif hasattr(FIFF, "FIFF_UNITM_MU") and unit_mul == FIFF.FIFF_UNITM_MU: multiplier = 1e-6 # micro elif hasattr(FIFF, "FIFF_UNITM_M") and unit_mul == FIFF.FIFF_UNITM_M: multiplier = 1e-3 # milli elif hasattr(FIFF, "FIFF_UNITM_N") and unit_mul == FIFF.FIFF_UNITM_N: multiplier = 1e-9 # nano elif hasattr(FIFF, "FIFF_UNITM_P") and unit_mul == FIFF.FIFF_UNITM_P: multiplier = 1e-12 # pico elif hasattr(FIFF, "FIFF_UNITM_F") and unit_mul == FIFF.FIFF_UNITM_F: multiplier = 1e-15 # femto else: # Fallback to numerical interpretation if FIFF constants not available mul_mapping = { 0: 1.0, # FIFF_UNITM_NONE -3: 1e-3, # FIFF_UNITM_M (milli) -6: 1e-6, # FIFF_UNITM_MU (micro) -9: 1e-9, # FIFF_UNITM_N (nano) -12: 1e-12, # FIFF_UNITM_P (pico) -15: 1e-15, # FIFF_UNITM_F (femto) } multiplier = mul_mapping.get(unit_mul) if multiplier is None: logging.warning(f"Unknown MNE unit multiplier {unit_mul}, using default units") return None, None # For EEG data (voltage units), compute the final unit and conversion factor if unit_str == "V": # Apply the MNE multiplier to get the actual unit if multiplier == 1e-6: final_unit = "µV" elif multiplier == 1e-3: final_unit = "mV" elif multiplier == 1.0: final_unit = "V" elif multiplier == 1e-9: final_unit = "nV" else: logging.warning(f"Unusual voltage unit multiplier {multiplier}, treating as V") final_unit = "V" # Convert to µV multiplier using existing utility try: mult_to_uV = convert_units_to_multiplier(final_unit, "µV") logging.info(f"Extracted MNE units: {final_unit} -> mult_to_uV = {mult_to_uV}") return final_unit, mult_to_uV except (ValueError, AssertionError) as e: logging.warning(f"Failed to convert units {final_unit}: {e}") return None, None else: # Non-voltage units (MEG, etc.) - don't convert to µV logging.info(f"Non-voltage units detected: {unit_str}, not converting to µV") return None, None
[docs] def log_transform(rec: np.ndarray, **kwargs) -> np.ndarray: """Log transform the signal Args: rec (np.ndarray): The signal to log transform. Returns: np.ndarray: ln(rec + 1) """ if rec is not None: return np.log(rec + 1) else: return None