"""Utility functions for handling EEG montage mappings and conversions."""
import os
from typing import Dict, List
import yaml
from autoclean.utils.logging import message
try:
from importlib import resources
except ImportError:
# Python < 3.9 compatibility
import importlib_resources as resources
[docs]
def load_valid_montages() -> Dict[str, str]:
"""Load valid montages from configuration file.
Returns
-------
Dict[str, str]
Dictionary of valid montages
"""
try:
# Try to load from package resources first (for installed package)
try:
import configs
config_data = (
resources.files(configs)
.joinpath("montages.yaml")
.read_text(encoding="utf-8")
)
except (ImportError, FileNotFoundError):
# Fallback to relative path (for development)
config_path = os.path.join(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
),
"configs",
"montages.yaml",
)
with open(config_path, "r", encoding="utf-8") as f:
config_data = f.read()
config = yaml.safe_load(config_data)
return config["valid_montages"]
except Exception as e: # pylint: disable=broad-except
message("error", f"Failed to load montages config: {e}")
raise
# Standard montage mappings and validation
#: List of valid montages loaded from MNE-Python
VALID_MONTAGES = load_valid_montages()
#: Standard 10-20 to GSN-HydroCel mapping based on official EGI GSN-HydroCel channel maps
GSN_TO_1020_MAPPING = {
# Frontal midline
"Fz": "E11",
"FCz": "E6",
"Cz": "E129", # Reference electrode in 129 montage
# Left frontal
"F3": "E24",
"F7": "E33",
"FC3": "E20",
# Right frontal
"F4": "E124",
"F8": "E122",
"FC4": "E118",
# Left central/temporal
"C3": "E36",
"T7": "E45",
"CP3": "E42",
# Right central/temporal
"C4": "E104",
"T8": "E108",
"CP4": "E93",
# Parietal midline
"Pz": "E62",
"POz": "E68",
# Left parietal/occipital
"P3": "E52",
"P7": "E58",
"O1": "E70",
# Right parietal/occipital
"P4": "E92",
"P8": "E96",
"O2": "E83",
}
#: Reverse mapping from GSN-HydroCel to 10-20 system
_1020_TO_GSN_MAPPING = {v: k for k, v in GSN_TO_1020_MAPPING.items()}
[docs]
def get_10_20_to_gsn_mapping() -> Dict[str, str]:
"""Get mapping from 10-20 system to GSN-HydroCel channel names.
Returns
-------
Dict[str, str]
Mapping from 10-20 system to GSN-HydroCel channel names
"""
return GSN_TO_1020_MAPPING.copy()
[docs]
def get_gsn_to_10_20_mapping() -> Dict[str, str]:
"""Get mapping from GSN-HydroCel to 10-20 system channel names.
Returns
-------
Dict[str, str]
Mapping from GSN-HydroCel to 10-20 system channel names
"""
return _1020_TO_GSN_MAPPING.copy()
[docs]
def convert_channel_names(channels: List[str], montage_type: str) -> List[str]:
"""Convert between 10-20 and GSN-HydroCel channel names.
Parameters
----------
channels : List[str]
List of channel names to convert
montage_type : str
Type of montage to convert to
Returns
-------
List[str]
List of converted channel names
"""
message("info", f"Converting channels: {channels}")
message("info", f"Montage type: {montage_type}")
# Always convert from 10-20 to GSN since we're working with standard sets
mapping = get_10_20_to_gsn_mapping()
message("info", f"Using 10-20 to GSN mapping: {mapping}")
# Handle special case for Cz in 124 montage
if "124" in montage_type and "Cz" in channels:
message("info", "Using 124 montage, adjusting Cz mapping")
mapping["Cz"] = "E31" # Cz is E31 in 124 montage
converted = []
for ch in channels:
if ch in mapping:
converted.append(mapping[ch])
message("info", f"Converted {ch} to {mapping[ch]}")
else:
message("warning", f"No mapping found for channel {ch}")
converted.append(ch) # Keep original if no mapping exists
message("info", f"Final converted channels: {converted}")
return converted
[docs]
def get_standard_set_in_montage(roi_set: str, montage_type: str) -> List[str]:
"""Get standard channel set converted to appropriate montage type.
Parameters
----------
roi_set : str
Name of standard channel set ('frontal', 'frontocentral', etc.)
montage_type : str
Type of montage ('GSN-HydroCel-128', 'GSN-HydroCel-129', '10-20', etc.)
Returns
-------
List[str]
List of channel names in appropriate montage format
"""
# Standard ROI sets in 10-20 system
standard_sets = {
"frontal": ["Fz", "F3", "F4"],
"frontocentral": ["Fz", "FCz", "Cz", "F3", "F4"],
"central": ["Cz", "C3", "C4"],
"temporal": ["T7", "T8"],
"parietal": ["Pz", "P3", "P4"],
"occipital": ["O1", "O2"],
"mmn_standard": ["Fz", "FCz", "Cz", "F3", "F4"], # Standard MMN analysis set
}
if roi_set not in standard_sets:
raise ValueError(
f"Unknown ROI set: {roi_set}. Available sets: {list(standard_sets.keys())}"
)
channels = standard_sets[roi_set]
return convert_channel_names(channels, montage_type)
[docs]
def validate_channel_set(
channels: List[str], available_channels: List[str]
) -> List[str]:
"""Validate and filter channel list based on available channels.
Parameters
----------
channels : List[str]
List of requested channel names
available_channels : List[str]
List of actually available channel names
Returns
-------
List[str]
List of valid channel names
"""
valid_channels = [ch for ch in channels if ch in available_channels]
if len(valid_channels) != len(channels):
missing = set(channels) - set(valid_channels)
message("warning", f"Some requested channels not found in data: {missing}")
return valid_channels