OSCR

EEG-Pype: An accessible MNE-Python pipeline with graphical user interface for preprocessing and analysis of resting-state electroencephalography data.

Code ↔ Paper

8 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 8 matches
  1. [1] § Design and implementation › Workflow for preprocessing ↔ src/eeg_processing_script.py, lines 2314–2406 · score 0.97 · boundary element model, noise covariance matrix, unit noise gain, weight normalization, source space, spatial filter
  2. [2] § Design and implementation › Quantitative analysis ↔ src/eeg_quantitative_analysis.py, lines 1620–1723 · score 0.85 · concatenated broadband, band power, Spectral variability, relative power, Window lengths, coefficient
  3. [3] § Design and implementation › Workflow for preprocessing ↔ src/eeg_processing_script.py, lines 2999–3049 · score 0.79 · custom parser, configurable header, raw EEG, CNT, FIF, generic
  4. [4] § Design and implementation › Quantitative analysis ↔ src/eeg_quantitative_analysis.py, lines 1941–2070 · score 0.70 · minimum spanning tree, connectivity matrices, graph, weighted, network, efficiency
  5. [5] § Results › 5. Deriving quantitative EEG measures ↔ src/eeg_quantitative_analysis.py, lines 3233–3313 · score 0.68 · approximate entropy, sample entropy, Power spectral density, peak frequency, relative power, Multitaper
  6. [6] § Design and implementation › Quantitative analysis ↔ src/eeg_quantitative_analysis.py, lines 2283–2330 · score 0.65 · joint permutation entropy, volume conduction, JPE
  7. [7] § Design and implementation › Workflow for preprocessing ↔ src/eeg_processing_script.py, lines 3427–3482 · score 0.62 · channel noise, ICA components, heart, ICALabel, muscle, eye
  8. [8] § Design and implementation › Quantitative analysis ↔ src/eeg_quantitative_analysis.py, lines 2213–2254 · score 0.56 · embedding dimension, Permutation entropy

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 3,945 lines · 163 KB · Apache-2.0 · 5 matches

  1. """@authors: Herman van Dellen en Yorben Lodema."""
  2. import itertools
  3. import json
  4. import logging
  5. import math
  6. import os
  7. import pickle
  8. import random
  9. import re
  10. import time
  11. from collections import defaultdict
  12. from datetime import datetime
  13. from multiprocessing import Pool, cpu_count
  14. from typing import Optional
  15. import mne
  16. import networkx as nx
  17. import numpy as np
  18. import pandas as pd
  19. import psutil
  20. from antropy import lziv_complexity, sample_entropy
  21. from scipy import signal
  22. from scipy.signal import hilbert
  23. from scipy.integrate import trapezoid
  24. from scipy.sparse.csgraph import minimum_spanning_tree
  25. import FreeSimpleGUI as sg
  26. EEG_version = "v4.6.0"
  27. logger = logging.getLogger(__name__)
  28. # Configuration
  29. FOLDER_EXTENSION = "bdf" # Change this to match your folder extension (e.g., 'bdf', 'edf', etc.)
  30. MAX_MEMORY_PERCENT = 70 # Maximum memory usage percentage
  31. MIN_WINDOW_SIZE = 100 # Minimum window size for spectral variability in ms
  32. # Be careful, option to change frequency bands (both those recognized in the epoch file names
  33. # and bands used for power and spectral variability calculations. Don't change the format. You can add additional
  34. # bands in the same format. Connectivity measures will still be calculated on epochs with bands not
  35. # listed here though that might affect the naming of the output columns in the Excel output.
  36. # It is probably advisable to limit the broadband range to something like 20-30 Hz to make sure the
  37. # total power used in relative power and spectral variability contain less EMG noise.
  38. FREQUENCY_BANDS = {
  39. "delta": {"pattern": r"0\.5-4\.0|delta", "range": (0.5, 4.0)},
  40. "theta": {"pattern": r"4\.0-8\.0|theta", "range": (4.0, 8.0)},
  41. "alpha": {"pattern": r"8\.0-13\.0|alpha", "range": (8.0, 13.0)},
  42. "alpha1": {"pattern": r"8\.0-10\.0|alpha1", "range": (8.0, 10.0)},
  43. "alpha2": {"pattern": r"10\.0-13\.0|alpha2", "range": (10.0, 13.0)},
  44. "beta": {"pattern": r"13\.0-30\.0|beta", "range": (13.0, 30.0)},
  45. "beta1": {"pattern": r"13\.0-20\.0|beta1", "range": (13.0, 20.0)},
  46. "beta2": {"pattern": r"20\.0-30\.0|beta2", "range": (20.0, 30.0)},
  47. "broadband": {"pattern": r"0\.5-47|broadband", "range": (0.5, 47.0)},
  48. }
  49. def validate_frequency_bands():
  50. """Validate FREQUENCY_BANDS configuration."""
  51. if not FREQUENCY_BANDS:
  52. msg = "FREQUENCY_BANDS dictionary is empty"
  53. raise ValueError(msg)
  54. for band_name, band_info in FREQUENCY_BANDS.items():
  55. if "pattern" not in band_info or "range" not in band_info:
  56. msg = f"Band {band_name} missing required keys (pattern, range)"
  57. raise ValueError(msg)
  58. fmin, fmax = band_info["range"]
  59. if not (isinstance(fmin, (int, float)) and isinstance(fmax, (int, float))):
  60. msg = f"Band {band_name} range values must be numeric"
  61. raise TypeError(msg)
  62. if fmin >= fmax:
  63. msg = f"Band {band_name} minimum frequency must be less than maximum"
  64. raise ValueError(msg)
  65. if not isinstance(band_info["pattern"], str):
  66. msg = f"Band {band_name} pattern must be a string"
  67. raise TypeError(msg)
  68. # ---------------------------------------------------------------------------
  69. # Preprocessing pass-band discovery
  70. #
  71. # The preprocessing script (EEG-Pype) writes a batch config as
  72. # <batch_output_subdirectory>/<batch_name>.pkl, holding the band cut-offs under
  73. # tuple keys such as ("cut_off_frequency", "broadband_low"/"broadband_high"),
  74. # and a per-recording sidecar <file_output_subdirectory>/EXPORT_METADATA_NAME
  75. # containing filter metadata recorded from raw.info (base_highpass_hz /
  76. # base_lowpass_hz). For this release, the identity-matched batch config is
  77. # authoritative; sidecars are used only as a fallback when no valid config can
  78. # be found.
  79. #
  80. # Knowing this pass-band matters for relative power: the denominator should not
  81. # extend beyond the range that actually contains signal. Nyquist is a far weaker
  82. # bound (e.g. 125 Hz at fs=250) than the real 0.5-47 Hz pass-band, so validating
  83. # against Nyquist alone lets a user silently include filter roll-off in the
  84. # total-power reference.
  85. # ---------------------------------------------------------------------------
  86. EXPORT_METADATA_NAME = "eeg_pype_export_metadata.json"
  87. PASSBAND_HINT_DEFAULT = "(denominator for rel. power & spectral variability)"
  88. PASSBAND_SEARCH_PARENT_LEVELS = 3 # how far up from the selected folder to look
  89. PASSBAND_MATCH_ABS_TOL = 1e-6
  90. class _RestrictedUnpickler(pickle.Unpickler):
  91. """Unpickler that refuses to import anything outside a small allowlist.
  92. The batch config is a plain dict of scalars, strings and lists, so nothing
  93. exotic is required. Restricting find_class means a corrupted or untrusted
  94. .pkl in the analysis folder cannot execute arbitrary imports while we are
  95. only trying to read two floats out of it.
  96. """
  97. _ALLOWED = {
  98. "builtins": {
  99. "dict", "list", "tuple", "set", "frozenset",
  100. "str", "int", "float", "bool", "complex", "bytes",
  101. },
  102. "collections": {"OrderedDict", "defaultdict"},
  103. "numpy": {"dtype", "ndarray"},
  104. "numpy.core.multiarray": {"_reconstruct", "scalar"},
  105. "numpy._core.multiarray": {"_reconstruct", "scalar"},
  106. }
  107. def find_class(self, module, name):
  108. if name in self._ALLOWED.get(module, ()):
  109. return super().find_class(module, name)
  110. msg = f"Blocked unpickling of {module}.{name}"
  111. raise pickle.UnpicklingError(msg)
  112. def _coerce_positive_float(value):
  113. """Return value as a finite float, or None if it is not usable."""
  114. if isinstance(value, bool) or value is None:
  115. return None
  116. try:
  117. out = float(value)
  118. except (TypeError, ValueError):
  119. return None
  120. if not math.isfinite(out) or out < 0:
  121. return None
  122. return out
  123. def _passband_from_batch_config(path):
  124. """Read and validate broadband cut-offs from a preprocessing batch config."""
  125. with open(path, "rb") as f:
  126. cfg = _RestrictedUnpickler(f).load()
  127. if not isinstance(cfg, dict):
  128. return None
  129. # EEG-Pype creates:
  130. #
  131. # <batch_name>/<batch_name>.pkl
  132. #
  133. # Require the filename and the batch name stored inside the config to agree.
  134. # This prevents an unrelated or copied .pkl from being selected silently.
  135. expected_batch_name = os.path.splitext(os.path.basename(path))[0]
  136. configured_batch_name = cfg.get("batch_name")
  137. if (
  138. configured_batch_name is not None
  139. and str(configured_batch_name) != expected_batch_name
  140. ):
  141. logger.warning(
  142. f"Ignoring batch config {path}: internal batch name "
  143. f"{configured_batch_name!r} does not match filename "
  144. f"{expected_batch_name!r}."
  145. )
  146. return None
  147. # Also check the basename of the stored batch output directory when this
  148. # information is available. Comparing only the basename keeps moved batch
  149. # folders usable while still detecting an internally inconsistent config.
  150. configured_batch_directory = cfg.get("batch_output_subdirectory")
  151. if configured_batch_directory:
  152. configured_directory_name = os.path.basename(
  153. os.path.normpath(str(configured_batch_directory))
  154. )
  155. if configured_directory_name != expected_batch_name:
  156. logger.warning(
  157. f"Ignoring batch config {path}: stored output directory "
  158. f"{configured_directory_name!r} does not match batch name "
  159. f"{expected_batch_name!r}."
  160. )
  161. return None
  162. low = _coerce_positive_float(
  163. cfg.get(("cut_off_frequency", "broadband_low"))
  164. )
  165. high = _coerce_positive_float(
  166. cfg.get(("cut_off_frequency", "broadband_high"))
  167. )
  168. if low is None or high is None or low >= high:
  169. return None
  170. return {
  171. "low": low,
  172. "high": high,
  173. "origin": "config",
  174. "detail": (
  175. f"broadband filter cut-offs in "
  176. f"{os.path.basename(path)}"
  177. ),
  178. }
  179. def _passband_from_export_metadata(path):
  180. """Read the applied filter from one EEG-Pype JSON sidecar."""
  181. with open(path, encoding="utf-8") as f:
  182. meta = json.load(f)
  183. if not isinstance(meta, dict):
  184. return None
  185. # Ignore JSON belonging to another application or schema.
  186. schema_name = meta.get("schema_name")
  187. if schema_name not in (None, "EEG-Pype export metadata"):
  188. return None
  189. # An in-progress or failed recording should not determine the analysis
  190. # pass-band. Missing status is accepted for backwards compatibility.
  191. status = meta.get("status")
  192. if status not in (None, "complete"):
  193. logger.debug(
  194. f"Ignoring pass-band metadata from {path}: "
  195. f"recording status is {status!r}."
  196. )
  197. return None
  198. output = meta.get("output")
  199. if not isinstance(output, dict):
  200. return None
  201. low = _coerce_positive_float(
  202. output.get("base_highpass_hz")
  203. )
  204. high = _coerce_positive_float(
  205. output.get("base_lowpass_hz")
  206. )
  207. if low is None or high is None or low >= high:
  208. return None
  209. return {
  210. "low": low,
  211. "high": high,
  212. "origin": "sidecar",
  213. "detail": f"filter recorded in {os.path.basename(path)}",
  214. }
  215. def _iter_candidate_config_paths(folder_path):
  216. """Yield only identity-matched batch configs, nearest folder first.
  217. EEG-Pype names the batch configuration after its containing batch folder:
  218. <batch_name>/<batch_name>.pkl
  219. Therefore, do not scan arbitrary .pkl files. At every candidate directory,
  220. only inspect the .pkl whose basename exactly matches that directory.
  221. """
  222. current = os.path.abspath(folder_path)
  223. for _ in range(PASSBAND_SEARCH_PARENT_LEVELS + 1):
  224. directory_name = os.path.basename(
  225. os.path.normpath(current)
  226. )
  227. if directory_name:
  228. candidate = os.path.join(
  229. current,
  230. f"{directory_name}.pkl",
  231. )
  232. if os.path.isfile(candidate):
  233. yield candidate
  234. parent = os.path.dirname(current)
  235. if parent == current:
  236. return
  237. current = parent
  238. def _iter_candidate_sidecar_paths(folder_path):
  239. """Yield sidecars in the selected folder and its direct subfolders."""
  240. root = os.path.abspath(folder_path)
  241. yielded_paths = set()
  242. direct = os.path.join(
  243. root,
  244. EXPORT_METADATA_NAME,
  245. )
  246. if os.path.isfile(direct):
  247. yielded_paths.add(direct)
  248. yield direct
  249. try:
  250. entries = sorted(os.listdir(root))
  251. except OSError:
  252. return
  253. for entry in entries:
  254. candidate = os.path.join(
  255. root,
  256. entry,
  257. EXPORT_METADATA_NAME,
  258. )
  259. if (
  260. os.path.isfile(candidate)
  261. and candidate not in yielded_paths
  262. ):
  263. yielded_paths.add(candidate)
  264. yield candidate
  265. def _passbands_match(first, second):
  266. """Return whether two discovered pass-bands are numerically equal."""
  267. return (
  268. math.isclose(
  269. first["low"],
  270. second["low"],
  271. rel_tol=0.0,
  272. abs_tol=PASSBAND_MATCH_ABS_TOL,
  273. )
  274. and math.isclose(
  275. first["high"],
  276. second["high"],
  277. rel_tol=0.0,
  278. abs_tol=PASSBAND_MATCH_ABS_TOL,
  279. )
  280. )
  281. def _discover_consistent_sidecar_passband(folder_path):
  282. """Return one pass-band only when all usable sidecars agree.
  283. Returns
  284. -------
  285. tuple
  286. ``(result, conflict_found)``.
  287. - result is a pass-band dict or None.
  288. - conflict_found is True when valid sidecars contained different
  289. pass-bands. Sidecars are fallback metadata, so a conflict matters
  290. only when no valid identity-matched batch config is available.
  291. """
  292. sidecar_results = []
  293. for path in _iter_candidate_sidecar_paths(folder_path):
  294. try:
  295. result = _passband_from_export_metadata(path)
  296. except (
  297. OSError,
  298. json.JSONDecodeError,
  299. TypeError,
  300. ValueError,
  301. ) as exc:
  302. logger.debug(
  303. f"Could not read pass-band from {path}: {exc}"
  304. )
  305. continue
  306. if result is not None:
  307. sidecar_results.append(
  308. (path, result)
  309. )
  310. if not sidecar_results:
  311. return None, False
  312. reference_path, reference_result = sidecar_results[0]
  313. conflicting_results = [
  314. (path, result)
  315. for path, result in sidecar_results[1:]
  316. if not _passbands_match(
  317. reference_result,
  318. result,
  319. )
  320. ]
  321. if conflicting_results:
  322. ranges = {
  323. (
  324. round(result["low"], 9),
  325. round(result["high"], 9),
  326. )
  327. for _, result in sidecar_results
  328. }
  329. formatted_ranges = ", ".join(
  330. f"{low:g}-{high:g} Hz"
  331. for low, high in sorted(ranges)
  332. )
  333. logger.warning(
  334. "No valid identity-matched batch config was found, and "
  335. "the available export metadata sidecars contain different "
  336. f"pass-bands: {formatted_ranges}. Automatic pass-band "
  337. "discovery has been disabled."
  338. )
  339. return None, True
  340. return {
  341. "low": reference_result["low"],
  342. "high": reference_result["high"],
  343. "origin": "sidecar",
  344. "source": reference_path,
  345. "detail": (
  346. f"consistent filters in "
  347. f"{len(sidecar_results)} usable export metadata "
  348. f"sidecar(s)"
  349. ),
  350. }, False
  351. def discover_preprocessing_passband(folder_path):
  352. """Discover the preprocessing pass-band for the selected data folder.
  353. Precedence for this release is deliberately simple and conservative:
  354. 1. Use the nearest identity-matched <batch_name>/<batch_name>.pkl config.
  355. This is the authoritative source for the configured broadband export
  356. range.
  357. 2. Only when no valid matching config is found, inspect completed
  358. recording-level JSON sidecars as a fallback.
  359. 3. Fallback sidecars must agree with one another. If they disagree,
  360. automatic discovery is disabled and the user-entered range is retained.
  361. The config and sidecars are not compared directly because the current JSON
  362. fields are derived from raw.info and do not yet unambiguously describe the
  363. pass-band of every exported broadband epoch file.
  364. """
  365. if not folder_path or not os.path.isdir(folder_path):
  366. return None
  367. # First try the nearest identity-matched batch config. As soon as a valid
  368. # config is found, return it without scanning recording sidecars. Besides
  369. # reflecting the intended precedence, this avoids potentially opening
  370. # hundreds of JSON files on the GUI thread for a normal batch selection.
  371. for path in _iter_candidate_config_paths(folder_path):
  372. try:
  373. result = _passband_from_batch_config(path)
  374. except (
  375. OSError,
  376. pickle.UnpicklingError,
  377. EOFError,
  378. AttributeError,
  379. TypeError,
  380. ValueError,
  381. ) as exc:
  382. logger.debug(
  383. f"Could not read pass-band from {path}: {exc}"
  384. )
  385. continue
  386. if result is not None:
  387. result["source"] = path
  388. logger.info(
  389. f"Detected preprocessing pass-band "
  390. f"{result['low']:g}-{result['high']:g} Hz "
  391. f"from {result['detail']}."
  392. )
  393. return result
  394. # No valid identity-matched config was found. Use sidecars only as a
  395. # fallback, and only when all usable sidecars agree.
  396. sidecar_result, sidecar_conflict = (
  397. _discover_consistent_sidecar_passband(folder_path)
  398. )
  399. if sidecar_conflict:
  400. return None
  401. if sidecar_result is not None:
  402. logger.info(
  403. f"Detected preprocessing pass-band "
  404. f"{sidecar_result['low']:g}-{sidecar_result['high']:g} Hz "
  405. f"from fallback {sidecar_result['detail']}."
  406. )
  407. return sidecar_result
  408. logger.info(
  409. "No unambiguous EEG-Pype preprocessing pass-band was found near "
  410. "the selected folder. The relative-power reference range will be "
  411. "bounded by the PSD and Nyquist limits only."
  412. )
  413. return None
  414. def effective_reference_ceiling(power_fs, psd_method, passband=None):
  415. """Return the highest usable top edge for the relative-power reference range.
  416. Combines the three independent ceilings: Nyquist, the multitaper PSD cut-off
  417. (the multitaper path computes up to 60 Hz), and the pass-band of the
  418. preprocessed data when it could be discovered. Returns (ceiling, reason).
  419. """
  420. ceiling = power_fs / 2.0
  421. reason = f"Nyquist ({ceiling:g} Hz)"
  422. if psd_method == "multitaper":
  423. mt_ceiling = min(60.0, power_fs / 2.0)
  424. if mt_ceiling < ceiling:
  425. ceiling = mt_ceiling
  426. reason = f"multitaper PSD cut-off ({ceiling:g} Hz)"
  427. if passband is not None and passband["high"] < ceiling:
  428. ceiling = passband["high"]
  429. reason = f"preprocessing low-pass ({ceiling:g} Hz)"
  430. return ceiling, reason
  431. BATCH_SIZE = 10 # Number of subjects to process in parallel
  432. DEFAULT_THREADS = max(1, int(cpu_count() * 0.7))
  433. class MemoryMonitor:
  434. @staticmethod
  435. def get_memory_usage():
  436. """Get current memory usage percentage."""
  437. return psutil.Process().memory_percent()
  438. @staticmethod
  439. def check_memory():
  440. """Check if memory usage is too high."""
  441. return MemoryMonitor.get_memory_usage() > MAX_MEMORY_PERCENT
  442. @staticmethod
  443. def check_concatenation_safety(data_size, num_epochs):
  444. """Check if concatenation is likely to exceed memory limits."""
  445. try:
  446. # Get system memory info
  447. system_memory = psutil.virtual_memory()
  448. available_memory = system_memory.available
  449. # Calculate estimated memory needed (add 20% buffer)
  450. estimated_memory = data_size * num_epochs * 1.2
  451. # Check if we'll exceed the threshold
  452. memory_threshold = (available_memory * MAX_MEMORY_PERCENT) / 100
  453. if estimated_memory > memory_threshold:
  454. logger.warning(
  455. f"Concatenation may exceed memory limits. "
  456. f"Estimated need: {estimated_memory / 1e9:.2f}GB, "
  457. f"Available: {memory_threshold / 1e9:.2f}GB"
  458. )
  459. return False
  460. return True
  461. except Exception:
  462. logger.exception("Error checking memory for concatenation")
  463. return False
  464. def setup_logging(folder_path):
  465. """Set up logging for the current analysis run."""
  466. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  467. log_filename = os.path.join(folder_path, f"eeg_analysis_{timestamp}.log")
  468. # Clear any existing handlers
  469. logging.getLogger().handlers = []
  470. # Configure logging with both file and console output
  471. logging.basicConfig(
  472. level=logging.INFO,
  473. format="%(asctime)s - %(levelname)s - %(message)s",
  474. handlers=[logging.FileHandler(log_filename, mode="w"), logging.StreamHandler()],
  475. )
  476. # Test the logging setup
  477. logger.info("Logging initialized")
  478. logger.info(f"Log file created at: {log_filename}")
  479. return log_filename
  480. def compute_resolution_readout(values):
  481. """Build a human-readable frequency-resolution summary for the selected PSD method.
  482. Uses the sampling rate and the manually entered epoch length to derive bin
  483. spacing, and for multitaper also the resolution bandwidth and implied taper
  484. count. Never raises — returns a hint string if inputs are incomplete.
  485. """
  486. method = values.get("-PSD_METHOD-", "Multitaper").lower()
  487. try:
  488. fs = float(values.get("-POWER_FS-", ""))
  489. if fs <= 0:
  490. raise ValueError
  491. except (ValueError, TypeError):
  492. return "Frequency resolution: enter a valid sampling rate"
  493. try:
  494. epoch_sec = float(values.get("-EPOCH_LENGTH-", ""))
  495. if epoch_sec <= 0:
  496. raise ValueError
  497. n_samples = int(round(epoch_sec * fs))
  498. except (ValueError, TypeError):
  499. epoch_sec = None
  500. n_samples = None
  501. if method == "welch":
  502. try:
  503. win_ms = float(values.get("-WELCH_WINDOW-", ""))
  504. if win_ms <= 0:
  505. raise ValueError
  506. except (ValueError, TypeError):
  507. return "Frequency resolution: enter a valid Welch window"
  508. return f"Welch bin spacing: {1000.0 / win_ms:.3f} Hz (= 1000 / {win_ms:.0f} ms)"
  509. if method == "fft":
  510. if epoch_sec is None:
  511. return "FFT bin spacing: enter epoch length to compute"
  512. return (f"FFT bin spacing: {1.0 / epoch_sec:.3f} Hz "
  513. f"(= 1 / {epoch_sec:g} s, fixed by epoch length)")
  514. # Multitaper
  515. if epoch_sec is None:
  516. return "Multitaper: enter epoch length to compute resolution"
  517. bin_hz = fs / n_samples
  518. bw_raw = values.get("-MT_BANDWIDTH-", "").strip()
  519. if bw_raw == "" or bw_raw.lower() == "auto":
  520. bandwidth = 8.0 * fs / n_samples # MNE default
  521. bw_label = f"{bandwidth:.3f} Hz (auto)"
  522. else:
  523. try:
  524. bandwidth = float(bw_raw)
  525. if bandwidth <= 0:
  526. raise ValueError
  527. bw_label = f"{bandwidth:.3f} Hz"
  528. except (ValueError, TypeError):
  529. return "Multitaper: enter a valid bandwidth (or 'Auto')"
  530. n_tapers = int(np.floor(epoch_sec * bandwidth - 1))
  531. taper_note = f"{n_tapers} taper(s)"
  532. if n_tapers < 1:
  533. taper_note += " — TOO LOW, will fail"
  534. elif n_tapers < 3:
  535. taper_note += " — low, little variance benefit"
  536. return (f"Multitaper bandwidth {bw_label}, smoothing ±{bandwidth / 2:.3f} Hz; "
  537. f"bin spacing {bin_hz:.3f} Hz; ~{taper_note}")
  538. def create_gui():
  539. """Create the GUI layout for EEG analysis settings (Pipeline Layout)."""
  540. suggested_threads = DEFAULT_THREADS
  541. # Adapt to the available screen height. The log box is the one element that
  542. # can give back rows without hiding a setting, so it shrinks on short
  543. # screens (e.g. 1366x768 laptops) and expands again on tall ones. It also
  544. # has expand_y=True, so it reclaims any spare height at run time.
  545. try:
  546. _screen_h = sg.Window.get_screen_size()[1]
  547. except Exception: # noqa: BLE001 - screen probing must never block the GUI
  548. _screen_h = 1080
  549. log_rows = 8 if _screen_h < 900 else 12
  550. # Seed the relative-power reference range from the 'broadband' band definition
  551. # so the GUI default matches the historical behaviour, while still letting the
  552. # user override it independently of the band definitions.
  553. if "broadband" in FREQUENCY_BANDS:
  554. default_total_min, default_total_max = FREQUENCY_BANDS["broadband"]["range"]
  555. else:
  556. default_total_min, default_total_max = 0.5, 47.0
  557. HEADER_BG = "#2C5784"
  558. HEADER_TEXT = "#FFFFFF"
  559. MAIN_BG = "#F0F2F6"
  560. BUTTON_COLOR = ("#FFFFFF", "#2C5784")
  561. STEP_TEXT_COLOR = "#505050"
  562. sg.theme("Default1")
  563. sg.set_options(font=("Helvetica", 10))
  564. # --- Header ---
  565. header = [
  566. [
  567. sg.Text(
  568. "EEG-Pype Quantitative Analysis",
  569. font=("Helvetica", 20, "bold"),
  570. text_color=HEADER_TEXT,
  571. background_color=HEADER_BG,
  572. pad=(10, 5),
  573. )
  574. ],
  575. [
  576. sg.Text(
  577. "Author: Yorben Lodema",
  578. font=("Helvetica", 10, "italic"),
  579. text_color=HEADER_TEXT,
  580. background_color=HEADER_BG,
  581. pad=(10, 5),
  582. )
  583. ],
  584. ]
  585. # --- Step 1: Setup (Inputs & Epochs) ---
  586. step_1_layout = [
  587. [sg.Text("Step 1: Setup", font=("Helvetica", 12, "bold"), text_color=STEP_TEXT_COLOR, background_color=MAIN_BG)],
  588. [
  589. sg.Frame(
  590. "Input Settings",
  591. [
  592. [sg.Text("Select data folder:", font=("Helvetica", 11, "bold"), background_color=MAIN_BG)],
  593. [sg.Input(key="-FOLDER-", size=(25, 1), enable_events=True),
  594. sg.FolderBrowse(button_color=BUTTON_COLOR)],
  595. [
  596. sg.Text("Folder extension:", background_color=MAIN_BG),
  597. sg.Input(FOLDER_EXTENSION, key="-EXTENSION-", size=(8, 1)),
  598. ],
  599. [
  600. sg.Text("Processing threads:", background_color=MAIN_BG),
  601. sg.Input(suggested_threads, key="-THREADS-", size=(5, 1)),
  602. ],
  603. [
  604. sg.Checkbox(
  605. "Epoch files have headers", key="-HAS_HEADERS-", default=True, background_color=MAIN_BG
  606. )
  607. ],
  608. ],
  609. background_color=MAIN_BG, expand_x=True
  610. )
  611. ],
  612. [
  613. sg.Frame(
  614. "Epoch Selection",
  615. [
  616. [
  617. sg.Checkbox(
  618. "Limit number of epochs", key="-LIMIT_EPOCHS-", default=False, background_color=MAIN_BG
  619. )
  620. ],
  621. [
  622. sg.Text("Number of epochs:", background_color=MAIN_BG),
  623. sg.Input("5", key="-MAX_EPOCHS-", size=(5, 1)),
  624. ],
  625. [
  626. sg.Text("Random Seed:", background_color=MAIN_BG),
  627. sg.Input("111", key="-SEED-", size=(5, 1)),
  628. ],
  629. ],
  630. background_color=MAIN_BG, expand_x=True
  631. )
  632. ],
  633. # Step 2 continues here: the complexity measures belong to the same
  634. # analysis step as the spectral and connectivity blocks in the next
  635. # column, and are placed here to keep the window short enough for
  636. # smaller screens.
  637. [sg.Text("Step 2: Complexity", font=("Helvetica", 12, "bold"), text_color=STEP_TEXT_COLOR,
  638. background_color=MAIN_BG, pad=((5, 3), (12, 3)))],
  639. [
  640. sg.Frame(
  641. "Complexity Measures",
  642. [
  643. [sg.Checkbox("Calculate JPE/PE", key="-CALC_JPE-", default=False, background_color=MAIN_BG)],
  644. [sg.Text("Time step (tau):", background_color=MAIN_BG), sg.Input("1", key="-JPE_ST-", size=(5, 1))],
  645. [sg.Checkbox("Invert JPE (1-entropy)", key="-INVERT-", default=True, background_color=MAIN_BG)],
  646. [
  647. sg.Checkbox(
  648. "Calculate Sample Entropy", key="-CALC_SAMPEN-", default=False, background_color=MAIN_BG
  649. )
  650. ],
  651. [sg.Text("Order (m):", background_color=MAIN_BG), sg.Input("2", key="-SAMPEN_M-", size=(3, 1))],
  652. [
  653. sg.Checkbox(
  654. "Calculate Approx. Entropy", key="-CALC_APEN-", default=False, background_color=MAIN_BG
  655. )
  656. ],
  657. [sg.Text("Order (m):", background_color=MAIN_BG), sg.Input("2", key="-APEN_M-", size=(3, 1))],
  658. [
  659. sg.Text("Tolerance (r):", background_color=MAIN_BG),
  660. sg.Input("0.25", key="-APEN_R-", size=(3, 1)),
  661. ],
  662. [
  663. sg.Checkbox(
  664. "Calculate Lempel-Ziv Complexity", key="-CALC_LZC-", default=False, background_color=MAIN_BG
  665. )
  666. ],
  667. [
  668. sg.Text("Threshold:", background_color=MAIN_BG),
  669. sg.Combo(
  670. ["median", "mean"], default_value="median", key="-LZC_THRESH-", size=(8, 1)
  671. ),
  672. ],
  673. ],
  674. background_color=MAIN_BG, expand_x=True
  675. )
  676. ],
  677. ]
  678. # --- Step 2: Analysis Configuration ---
  679. step_2_layout = [
  680. [sg.Text("Step 2: Spectral & Connectivity", font=("Helvetica", 12, "bold"), text_color=STEP_TEXT_COLOR, background_color=MAIN_BG)],
  681. [
  682. sg.Frame(
  683. "Spectral Analysis",
  684. [
  685. [sg.Text("Sampling rate (Hz):", background_color=MAIN_BG),
  686. sg.Input(key="-POWER_FS-", size=(8, 1), enable_events=True)],
  687. [sg.Text("Epoch length (s):", background_color=MAIN_BG),
  688. sg.Input(key="-EPOCH_LENGTH-", size=(8, 1), enable_events=True),
  689. sg.Text("(for fr. resolution readout)", background_color=MAIN_BG,
  690. font=("Helvetica", 9, "italic"))],
  691. [
  692. sg.Text("PSD Method:", background_color=MAIN_BG),
  693. sg.Combo(
  694. ["Multitaper", "Welch", "FFT"], default_value="Multitaper",
  695. key="-PSD_METHOD-", size=(10, 1), enable_events=True
  696. ),
  697. ],
  698. [sg.HorizontalSeparator(color="#D0D0D0")],
  699. [
  700. sg.Text("Welch window (ms):", background_color=MAIN_BG, size=(18, 1)),
  701. sg.Input("1000", key="-WELCH_WINDOW-", size=(7, 1), enable_events=True),
  702. sg.Text("overlap (%):", background_color=MAIN_BG),
  703. sg.Input("50", key="-WELCH_OVERLAP-", size=(5, 1)),
  704. ],
  705. [
  706. sg.Text("Multitaper bandwidth (Hz):", background_color=MAIN_BG, size=(18, 1)),
  707. sg.Input("Auto", key="-MT_BANDWIDTH-", size=(7, 1), enable_events=True),
  708. ],
  709. [
  710. sg.Text("", key="-FREQ_RES-", background_color=MAIN_BG,
  711. font=("Helvetica", 10, "italic"), size=(46, 3)),
  712. ],
  713. [sg.HorizontalSeparator(color="#D0D0D0")],
  714. [
  715. sg.Checkbox("Calculate power bands", key="-CALC_POWER-",
  716. default=False, background_color=MAIN_BG),
  717. sg.Text("ref. (Hz):", background_color=MAIN_BG),
  718. sg.Input(f"{default_total_min:g}", key="-TOTAL_MIN-", size=(5, 1)),
  719. sg.Text("-", background_color=MAIN_BG),
  720. sg.Input(f"{default_total_max:g}", key="-TOTAL_MAX-", size=(5, 1)),
  721. ],
  722. [
  723. sg.Text(PASSBAND_HINT_DEFAULT, key="-PASSBAND_INFO-", background_color=MAIN_BG,
  724. text_color="#606060", font=("Helvetica", 9, "italic"), size=(48, 1)),
  725. ],
  726. [
  727. sg.Checkbox("Calculate peak frequency", key="-CALC_PEAK-",
  728. default=False, background_color=MAIN_BG),
  729. sg.Text("range:", background_color=MAIN_BG),
  730. sg.Input("4", key="-PEAK_MIN-", size=(4, 1)),
  731. sg.Text("-", background_color=MAIN_BG),
  732. sg.Input("13", key="-PEAK_MAX-", size=(4, 1)),
  733. ],
  734. [
  735. sg.Checkbox("Calc. spectral variability", key="-CALC_SV-",
  736. default=False, background_color=MAIN_BG),
  737. sg.Text("window (ms):", background_color=MAIN_BG),
  738. sg.Input("2000", key="-SV_WINDOW-", size=(6, 1)),
  739. ],
  740. ],
  741. background_color=MAIN_BG, expand_x=True
  742. )
  743. ],
  744. [
  745. sg.Frame(
  746. "Connectivity",
  747. [
  748. [sg.Checkbox("Calculate PLI", key="-CALC_PLI-", default=False, background_color=MAIN_BG)],
  749. [
  750. sg.Checkbox(
  751. "Calc. PLI MST measures", key="-CALC_PLI_MST-", default=False, background_color=MAIN_BG
  752. )
  753. ],
  754. [sg.Checkbox("Calculate PLT", key="-CALC_PLT-", default=False, background_color=MAIN_BG)],
  755. [
  756. sg.Text("PLT Threshold (ms):", background_color=MAIN_BG),
  757. sg.Input("30", key="-PLT_THRESH-", size=(4, 1)),
  758. ],
  759. [
  760. sg.Checkbox(
  761. "Calc. PLT MST measures", key="-CALC_PLT_MST-", default=False, background_color=MAIN_BG
  762. )
  763. ],
  764. [sg.Checkbox("Calculate AEC", key="-CALC_AEC-", default=False, background_color=MAIN_BG)],
  765. [
  766. sg.Checkbox(
  767. "Use orthogonalization (AECc)", key="-USE_AECC-", default=False, background_color=MAIN_BG
  768. )
  769. ],
  770. [
  771. sg.Checkbox(
  772. "Concatenate for AEC(c)",
  773. key="-CONCAT_AECC-",
  774. default=False,
  775. background_color=MAIN_BG,
  776. )
  777. ],
  778. [
  779. sg.Checkbox(
  780. "Calc. AEC(c) MST measures",
  781. key="-CALC_AEC_MST-",
  782. default=False,
  783. background_color=MAIN_BG,
  784. )
  785. ],
  786. [
  787. sg.Checkbox(
  788. "AEC force positive",
  789. key="-AEC_FORCE_POSITIVE-",
  790. default=True,
  791. background_color=MAIN_BG,
  792. )
  793. ],
  794. ],
  795. background_color=MAIN_BG, expand_x=True
  796. )
  797. ],
  798. ]
  799. # --- Step 3: Output & Execution ---
  800. step_3_layout = [
  801. [sg.Text("Step 3: Output & Run", font=("Helvetica", 12, "bold"), text_color=STEP_TEXT_COLOR, background_color=MAIN_BG)],
  802. [
  803. sg.Frame(
  804. "Matrix & Metric Export",
  805. [
  806. [
  807. sg.Checkbox(
  808. "Save conn. matrices (Per Epoch)", key="-SAVE_MATRICES-", default=False, background_color=MAIN_BG
  809. )
  810. ],
  811. [
  812. sg.Text("Matrix folder:", background_color=MAIN_BG),
  813. sg.Input("connectivity_matrices", key="-MATRIX_FOLDER-", size=(25, 1)),
  814. ],
  815. [sg.Checkbox("Save MST matrices (Per Epoch)", key="-SAVE_MST-", default=False, background_color=MAIN_BG)],
  816. [
  817. sg.Text("MST folder:", background_color=MAIN_BG),
  818. sg.Input("mst_matrices", key="-MST_FOLDER-", size=(25, 1)),
  819. ],
  820. [
  821. sg.Checkbox(
  822. "Export Per-Epoch metrics to Excel sheet", key="-SAVE_EPOCH_METRICS-", default=True, background_color=MAIN_BG
  823. )
  824. ],
  825. [
  826. sg.Checkbox(
  827. "Save channel-level averages",
  828. key="-SAVE_CHANNEL_AVERAGES-",
  829. default=False,
  830. background_color=MAIN_BG,
  831. )
  832. ],
  833. ],
  834. background_color=MAIN_BG, expand_x=True
  835. )
  836. ],
  837. [
  838. sg.Frame(
  839. "PSD Export",
  840. [
  841. [sg.Checkbox("Save averaged PSD (epochs + ch/region)", key="-SAVE_PSD_AVG-", default=False, background_color=MAIN_BG)],
  842. [sg.Checkbox("Save per-channel/region PSD (epoch-avg)", key="-SAVE_PSD_PERCHANNEL-", default=False, background_color=MAIN_BG)],
  843. [sg.Checkbox("Save PSD plots (PNG, MNE-style)", key="-SAVE_PSD_PLOTS-", default=False, background_color=MAIN_BG)],
  844. [
  845. sg.Text("PSD folder:", background_color=MAIN_BG),
  846. sg.Input("psd_output", key="-PSD_FOLDER-", size=(25, 1)),
  847. ],
  848. [sg.Text("(PSD uses broadband epochs + sampling rate)",
  849. background_color=MAIN_BG, font=("Helvetica", 10, "italic"))],
  850. ],
  851. background_color=MAIN_BG, expand_x=True
  852. )
  853. ],
  854. [
  855. sg.Frame(
  856. "Progress & Execution",
  857. [
  858. [
  859. sg.Button(
  860. "Process",
  861. size=(12, 1),
  862. button_color=BUTTON_COLOR,
  863. font=("Helvetica", 12, "bold"),
  864. bind_return_key=True
  865. ),
  866. sg.Button(
  867. "Exit",
  868. size=(8, 1),
  869. button_color=(HEADER_TEXT, "#AB4F4F"),
  870. font=("Helvetica", 12),
  871. )
  872. ],
  873. [sg.ProgressBar(100, orientation="h", size=(45, 20), key="-PROGRESS-", bar_color=(HEADER_BG, "#FFFFFF"))],
  874. [sg.Text("Log Output:", background_color=MAIN_BG, font=("Helvetica", 9, "bold"))],
  875. [
  876. sg.Multiline(
  877. size=(60, log_rows),
  878. key="-LOG-",
  879. autoscroll=True,
  880. reroute_stdout=True,
  881. disabled=True,
  882. background_color="#FFFFFF",
  883. text_color="#000000",
  884. font=("Courier New", 8),
  885. expand_x=True, # Allow horizontal expansion
  886. expand_y=True # Allow vertical expansion
  887. )
  888. ],
  889. ],
  890. background_color=MAIN_BG, expand_x=True, expand_y=True
  891. )
  892. ],
  893. ]
  894. # --- Main Layout Assembly ---
  895. layout = [
  896. [sg.Column(header, background_color=HEADER_BG, expand_x=True)],
  897. [
  898. sg.Column(step_1_layout, background_color=MAIN_BG, vertical_alignment='top', expand_y=True),
  899. sg.VerticalSeparator(color="#D0D0D0"), # Visual divider
  900. sg.Column(step_2_layout, background_color=MAIN_BG, vertical_alignment='top', expand_y=True),
  901. sg.VerticalSeparator(color="#D0D0D0"), # Visual divider
  902. sg.Column(step_3_layout, background_color=MAIN_BG, vertical_alignment='top', expand_y=True, expand_x=True),
  903. ],
  904. ]
  905. return sg.Window("EEG-Pype", layout, background_color=MAIN_BG, finalize=True, margins=(0, 0), resizable=True)
  906. def create_matrix_folder_structure(base_folder, matrix_folder_name, mst_folder_name=None):
  907. """Create folder structure with subject subfolders."""
  908. folders = {
  909. "jpe": os.path.join(base_folder, matrix_folder_name, "jpe"),
  910. "pli": os.path.join(base_folder, matrix_folder_name, "pli"),
  911. "plt": os.path.join(base_folder, matrix_folder_name, "plt"),
  912. "aec": os.path.join(base_folder, matrix_folder_name, "aec"),
  913. }
  914. if mst_folder_name:
  915. folders.update(
  916. {
  917. "pli_mst": os.path.join(base_folder, mst_folder_name, "pli_mst"),
  918. "aec_mst": os.path.join(base_folder, mst_folder_name, "aec_mst"),
  919. "plt_mst": os.path.join(base_folder, mst_folder_name, "plt_mst"),
  920. }
  921. )
  922. # Create base folders
  923. for folder in folders.values():
  924. os.makedirs(folder, exist_ok=True)
  925. return folders
  926. def extract_freq_band(condition):
  927. """
  928. Parse the filename or condition string to identify frequency band.
  929. Based on the FREQUENCY_BANDS config.
  930. """
  931. for band_name, band_info in FREQUENCY_BANDS.items():
  932. pattern = band_info["pattern"]
  933. # Add Hz to pattern if not already included
  934. if not pattern.endswith("Hz"):
  935. # Wrap in a non-capturing group: '|' has the lowest precedence, so an
  936. # unwrapped pattern like 'a|b' would attach the optional Hz to 'b' only.
  937. search_pattern = f"(?:{pattern})(\\s*Hz)?"
  938. else:
  939. search_pattern = pattern
  940. if re.search(search_pattern, condition, re.IGNORECASE):
  941. return band_name
  942. return "unknown"
  943. def is_broadband_condition(condition):
  944. """
  945. Check if condition matches broadband pattern from FREQUENCY_BANDS config.
  946. """
  947. if "broadband" not in FREQUENCY_BANDS:
  948. return False
  949. pattern = FREQUENCY_BANDS["broadband"]["pattern"]
  950. return bool(re.search(pattern, condition, re.IGNORECASE))
  951. def save_connectivity_matrix(matrix, folder_path, subject, freq_band, feature, channel_names, level_type=None):
  952. """Save connectivity matrix to CSV with proper channel names, prepending subject to the filename."""
  953. subject_folder = os.path.join(folder_path, subject)
  954. os.makedirs(subject_folder, exist_ok=True)
  955. # Construct the latter part of the filename as before
  956. if level_type:
  957. base_filename_part = f"{level_type}_{freq_band}_{feature}"
  958. else:
  959. base_filename_part = f"{freq_band}_{feature}"
  960. # Prepend the subject identifier
  961. filename = f"{subject}_{base_filename_part}.csv"
  962. # Example: "eeg_filename_id_source_delta_pli.csv" or "eeg_filename_id_delta_pli.csv"
  963. filepath = os.path.join(subject_folder, filename)
  964. # Convert matrix to DataFrame with channel names
  965. df = pd.DataFrame(matrix)
  966. df.index = channel_names
  967. df.columns = channel_names
  968. df.to_csv(filepath, float_format='%.5f')
  969. return filepath
  970. def linear_detrend(data):
  971. """Apply linear detrending to each channel."""
  972. return signal.detrend(data, axis=0, type="linear")
  973. def read_epoch_array(file_path, has_headers, detrend=True):
  974. """Read one epoch file into a (samples x channels) array, linearly detrended by default.
  975. Single source of truth for epoch reading, used by both the per-epoch loop and the
  976. concatenated-AEC / spectral-variability paths so they all handle files identically:
  977. - has_headers=True -> first row is treated as channel names (pandas default).
  978. - has_headers=False -> the first row is auto-inspected; a non-numeric row is
  979. dropped as a stray header, and all columns are coerced to numeric.
  980. Returns
  981. -------
  982. tuple (np.ndarray, list[str] | None)
  983. The (detrended) data array, and the channel names parsed from the header
  984. when has_headers=True, else None (caller generates generic names).
  985. """
  986. if has_headers:
  987. df = pd.read_csv(file_path, sep=None, engine="python")
  988. channel_names = df.columns.tolist()
  989. else:
  990. # Detect and drop a stray non-numeric header row, matching the legacy per-epoch logic.
  991. first_row = pd.read_csv(file_path, sep=None, engine="python", header=None, nrows=1)
  992. is_header = False
  993. try:
  994. first_row.astype(float)
  995. except (ValueError, TypeError):
  996. is_header = True
  997. df = pd.read_csv(
  998. file_path, sep=None, engine="python", header=None, skiprows=1 if is_header else 0
  999. )
  1000. for col in df.columns:
  1001. df[col] = pd.to_numeric(df[col], errors="coerce")
  1002. channel_names = None
  1003. arr = df.to_numpy()
  1004. if detrend:
  1005. arr = linear_detrend(arr)
  1006. return arr, channel_names
  1007. def calculate_PSD(
  1008. data: np.ndarray,
  1009. fs: float,
  1010. method: str = "multitaper",
  1011. freq_range: Optional[tuple[float, float]] = None,
  1012. **kwargs,
  1013. ) -> dict[str, np.ndarray]:
  1014. """
  1015. Calculate Power Spectral Density (PSD) using specified method.
  1016. Parameters
  1017. ----------
  1018. data : np.ndarray
  1019. Time series data (samples x channels)
  1020. fs : float
  1021. Sampling frequency in Hz
  1022. method : str
  1023. Method to use for PSD calculation ('multitaper', 'welch', 'fft')
  1024. freq_range : tuple, optional
  1025. Frequency range to return (min_freq, max_freq)
  1026. **kwargs : dict
  1027. Method-specific parameters:
  1028. Welch:
  1029. window_length_ms : float (window length in milliseconds)
  1030. overlap_percent : float (0 to 100)
  1031. Multitaper:
  1032. time_bandwidth : float (default 4)
  1033. n_tapers : int (optional, computed from time_bandwidth)
  1034. Returns
  1035. -------
  1036. dict
  1037. Dictionary containing:
  1038. 'frequencies' : np.ndarray
  1039. Frequency values
  1040. 'psd' : np.ndarray
  1041. Power spectral density (frequencies x channels)
  1042. 'spectrogram' : np.ndarray, optional
  1043. Time-frequency representation (only if compute_spectrogram=True)
  1044. """
  1045. if method not in ["multitaper", "welch", "fft"]:
  1046. msg = f"Unknown method: {method}"
  1047. raise ValueError(msg)
  1048. # Input validation
  1049. if not isinstance(data, np.ndarray):
  1050. msg = "Data must be a numpy array"
  1051. raise TypeError(msg)
  1052. if data.ndim != 2: # noqa: PLR2004
  1053. msg = "Data must be 2D array (samples x channels)"
  1054. raise ValueError(msg)
  1055. if fs <= 0:
  1056. msg = "Sampling frequency must be positive"
  1057. raise ValueError(msg)
  1058. # Initialize return dictionary
  1059. result = {}
  1060. # Calculate PSD based on method
  1061. if method == "multitaper":
  1062. try:
  1063. bandwidth = kwargs.get("bandwidth", None)
  1064. frequencies, psd = _calculate_multitaper_psd(data, fs, bandwidth=bandwidth)
  1065. result["frequencies"] = frequencies
  1066. result["psd"] = psd
  1067. except Exception:
  1068. logger.exception("Error calculating multitaper PSD")
  1069. raise
  1070. elif method == "welch":
  1071. try:
  1072. window_length_ms = kwargs.get("window_length_ms", 1000) # Default 1000ms
  1073. overlap_percent = kwargs.get("overlap_percent", 50) # Default 50%
  1074. frequencies, psd = _calculate_welch_psd(
  1075. data, fs, window_length_ms=window_length_ms, overlap_percent=overlap_percent
  1076. )
  1077. result["frequencies"] = frequencies
  1078. result["psd"] = psd
  1079. except Exception:
  1080. logger.exception("Error calculating Welch PSD")
  1081. raise
  1082. elif method == "fft":
  1083. try:
  1084. frequencies, psd = _calculate_fft_psd(data, fs)
  1085. result["frequencies"] = frequencies
  1086. result["psd"] = psd
  1087. except Exception:
  1088. logger.exception("Error calculating FFT PSD")
  1089. raise
  1090. # Apply frequency range if specified
  1091. if freq_range is not None:
  1092. fmin, fmax = freq_range
  1093. if not (0 <= fmin < fmax <= fs / 2):
  1094. msg = f"Invalid frequency range: {freq_range}"
  1095. raise ValueError(msg)
  1096. freq_mask = (result["frequencies"] >= fmin) & (result["frequencies"] <= fmax)
  1097. result["frequencies"] = result["frequencies"][freq_mask]
  1098. result["psd"] = result["psd"][freq_mask]
  1099. return result
  1100. def _calculate_welch_psd(
  1101. data: np.ndarray, fs: float, window_length_ms: float = 1000, overlap_percent: float = 50
  1102. ) -> tuple[np.ndarray, np.ndarray]:
  1103. """
  1104. Calculate PSD using Welch's method.
  1105. Parameters
  1106. ----------
  1107. data : np.ndarray
  1108. Time series data (samples x channels)
  1109. fs : float
  1110. Sampling frequency in Hz
  1111. window_length_ms : float
  1112. Length of each segment in milliseconds
  1113. overlap_percent : float
  1114. Overlap between segments in percentage (0-100)
  1115. Returns
  1116. -------
  1117. frequencies : np.ndarray
  1118. Frequency values
  1119. psd : np.ndarray
  1120. Power spectral density (frequencies x channels)
  1121. """
  1122. # Convert window length from ms to samples
  1123. nperseg = int((window_length_ms / 1000) * fs)
  1124. # Convert overlap from percentage to samples
  1125. noverlap = int(nperseg * (overlap_percent / 100))
  1126. # Initialize array for PSD results
  1127. n_channels = data.shape[1]
  1128. # Calculate PSD for first channel to get frequency axis
  1129. frequencies, temp_psd = signal.welch(
  1130. data[:, 0], fs=fs, nperseg=nperseg, noverlap=noverlap, detrend=False, scaling="density"
  1131. )
  1132. # Initialize PSD array with correct dimensions
  1133. psd = np.zeros((len(frequencies), n_channels))
  1134. psd[:, 0] = temp_psd
  1135. # Calculate for remaining channels
  1136. for ch in range(1, n_channels):
  1137. _, psd[:, ch] = signal.welch(
  1138. data[:, ch],
  1139. fs=fs,
  1140. nperseg=nperseg,
  1141. noverlap=noverlap,
  1142. detrend=False,
  1143. scaling="density",
  1144. )
  1145. return frequencies, psd
  1146. def _calculate_fft_psd(data: np.ndarray, fs: float) -> tuple[np.ndarray, np.ndarray]:
  1147. """Single-segment PSD via a Hann-windowed FFT over the whole epoch.
  1148. Uses the same one-sided density scaling as scipy.signal.welch so absolute
  1149. power is consistent across FFT, Welch and multitaper: every bin except DC
  1150. (and Nyquist, when present) is doubled to account for the discarded
  1151. negative-frequency half.
  1152. """
  1153. n_samples = data.shape[0]
  1154. n_channels = data.shape[1]
  1155. window = signal.windows.hann(n_samples)
  1156. # scipy-style density normalisation: 1 / (fs * sum(window**2))
  1157. scale = 1.0 / (fs * np.sum(window ** 2))
  1158. frequencies = np.fft.rfftfreq(n_samples, d=1 / fs)
  1159. psd = np.zeros((len(frequencies), n_channels))
  1160. for ch in range(n_channels):
  1161. fft_data = np.fft.rfft(data[:, ch] * window)
  1162. ch_psd = (np.abs(fft_data) ** 2) * scale
  1163. # One-sided correction: double all but DC and (if present) Nyquist.
  1164. ch_psd[1:] *= 2
  1165. if n_samples % 2 == 0:
  1166. ch_psd[-1] /= 2 # Nyquist bin is unique; undo its doubling
  1167. psd[:, ch] = ch_psd
  1168. return frequencies, psd
  1169. def _calculate_multitaper_psd(data: np.ndarray, fs: float, bandwidth=None):
  1170. """Calculate PSD using MNE's multitaper implementation.
  1171. Parameters
  1172. ----------
  1173. data : np.ndarray
  1174. Time series data (samples x channels).
  1175. fs : float
  1176. Sampling frequency in Hz.
  1177. bandwidth : float or None
  1178. Full multitaper resolution bandwidth in Hz (frequencies within
  1179. ± bandwidth/2 are smoothed together). None uses MNE's default of
  1180. 8 * fs / n_samples.
  1181. """
  1182. fmax = min(60.0, fs / 2.0)
  1183. psds, freqs = mne.time_frequency.psd_array_multitaper(
  1184. data.T,
  1185. sfreq=fs,
  1186. fmin=0,
  1187. fmax=fmax,
  1188. bandwidth=bandwidth,
  1189. normalization="full",
  1190. n_jobs=1,
  1191. verbose=False,
  1192. )
  1193. return freqs, psds.T
  1194. def save_psd_outputs(
  1195. mean_psd,
  1196. frequencies,
  1197. channel_names,
  1198. subject,
  1199. condition,
  1200. freq_band,
  1201. level_type,
  1202. psd_folder,
  1203. save_avg=False,
  1204. save_perchannel=False,
  1205. save_plots=False,
  1206. ):
  1207. """Write the epoch-averaged PSD to CSV and/or PNG (broadband conditions only).
  1208. Parameters
  1209. ----------
  1210. mean_psd : np.ndarray
  1211. Epoch-averaged PSD, shape (n_freqs x n_channels).
  1212. frequencies : np.ndarray
  1213. Frequency axis (length n_freqs).
  1214. channel_names : list[str] | None
  1215. Channel / region labels; generic names are generated if missing.
  1216. psd_folder : str
  1217. Base output folder. A per-subject subfolder is created inside it.
  1218. save_avg : bool
  1219. Write the channel-averaged PSD (single curve) to CSV.
  1220. save_perchannel : bool
  1221. Write the per-channel PSD (frequencies x channels) to CSV.
  1222. save_plots : bool
  1223. Also write MNE-style PNG plots (power in dB vs frequency).
  1224. """
  1225. if mean_psd is None or frequencies is None:
  1226. return
  1227. n_channels = mean_psd.shape[1]
  1228. if not channel_names or len(channel_names) != n_channels:
  1229. channel_names = [f"Channel_{i + 1}" for i in range(n_channels)]
  1230. global_psd = np.nanmean(mean_psd, axis=1) # channel-averaged curve
  1231. subject_folder = os.path.join(psd_folder, subject)
  1232. os.makedirs(subject_folder, exist_ok=True)
  1233. stem = f"{subject}_{level_type}_{freq_band}_psd"
  1234. if save_avg:
  1235. pd.DataFrame({"frequency_Hz": frequencies, "psd": global_psd}).to_csv(
  1236. os.path.join(subject_folder, f"{stem}_global_avg.csv"),
  1237. index=False, float_format="%.6e",
  1238. )
  1239. if save_perchannel:
  1240. df_pc = pd.DataFrame(mean_psd, index=frequencies, columns=channel_names)
  1241. df_pc.index.name = "frequency_Hz"
  1242. df_pc.to_csv(
  1243. os.path.join(subject_folder, f"{stem}_per_channel.csv"),
  1244. float_format="%.6e",
  1245. )
  1246. if save_plots:
  1247. try:
  1248. _plot_psd(frequencies, mean_psd, global_psd, subject_folder, stem,
  1249. title=f"{subject} — {condition}")
  1250. except Exception:
  1251. logger.exception(f"Error plotting PSD for {subject} - {condition}")
  1252. def _plot_psd(frequencies, mean_psd, global_psd, out_folder, stem, title=""):
  1253. """Render two MNE-style PSD figures (power in dB vs frequency) as PNGs."""
  1254. import matplotlib
  1255. matplotlib.use("Agg") # headless / multiprocessing-safe backend
  1256. import matplotlib.pyplot as plt
  1257. eps = 1e-20 # guard against log10(0)
  1258. # Per-channel: faint per-channel traces + bold channel mean.
  1259. fig, ax = plt.subplots(figsize=(8, 5))
  1260. ax.plot(frequencies, 10 * np.log10(mean_psd + eps),
  1261. color="0.7", linewidth=0.5, alpha=0.6)
  1262. ax.plot(frequencies, 10 * np.log10(global_psd + eps),
  1263. color="C0", linewidth=2.0, label="Mean")
  1264. ax.set_xlabel("Frequency (Hz)")
  1265. ax.set_ylabel("Power (dB)")
  1266. ax.set_title(f"{title} — per channel")
  1267. ax.legend(loc="upper right")
  1268. fig.tight_layout()
  1269. fig.savefig(os.path.join(out_folder, f"{stem}_per_channel.png"), dpi=300)
  1270. plt.close(fig)
  1271. # Channel-averaged: single mean curve.
  1272. fig, ax = plt.subplots(figsize=(8, 5))
  1273. ax.plot(frequencies, 10 * np.log10(global_psd + eps),
  1274. color="C0", linewidth=2.0)
  1275. ax.set_xlabel("Frequency (Hz)")
  1276. ax.set_ylabel("Power (dB)")
  1277. ax.set_title(f"{title} — channel-averaged")
  1278. fig.tight_layout()
  1279. fig.savefig(os.path.join(out_folder, f"{stem}_global_avg.png"), dpi=300)
  1280. plt.close(fig)
  1281. def calculate_sampen_for_channels(data, m=2):
  1282. """
  1283. Calculate Sample Entropy for each channel using antropy.
  1284. This version includes robust type casting to match Numba's requirements.
  1285. """
  1286. n_channels = data.shape[1]
  1287. sampen_values = np.zeros(n_channels)
  1288. # 1. Force the order parameter to be a 32-BIT INTEGER (i4).
  1289. # This is required by the explicit Numba signature in antropy.
  1290. order_m = np.int32(m)
  1291. for ch in range(n_channels):
  1292. try:
  1293. # 2. Force the data to be a C-contiguous array of 64-bit floats (f8[:]).
  1294. # Numba can be sensitive to array memory layout.
  1295. channel_data = np.ascontiguousarray(data[:, ch], dtype=np.float64)
  1296. if np.std(channel_data) == 0:
  1297. sampen_values[ch] = 0
  1298. continue
  1299. sampen_values[ch] = sample_entropy(channel_data, order=order_m)
  1300. if ch % 10 == 0:
  1301. logger.info(f"Processed SampEn for {ch}/{n_channels} channels")
  1302. except Exception:
  1303. logger.exception(f"Error calculating SampEn for channel {ch}")
  1304. sampen_values[ch] = np.nan
  1305. return sampen_values
  1306. def calculate_lzc_for_channels(data, threshold="median"):
  1307. """Calculate Lempel-Ziv Complexity for each channel using antropy.
  1308. Parameters
  1309. ----------
  1310. data : np.ndarray
  1311. Time series data (samples x channels).
  1312. threshold : str or float
  1313. Binarization strategy. ``"median"`` (default) uses the per-channel
  1314. median; ``"mean"`` uses the per-channel mean; a float value is used
  1315. directly as a fixed threshold.
  1316. Returns
  1317. -------
  1318. np.ndarray
  1319. Normalized LZC value for each channel.
  1320. """
  1321. n_channels = data.shape[1]
  1322. lzc_values = np.zeros(n_channels)
  1323. for ch in range(n_channels):
  1324. try:
  1325. x = np.ascontiguousarray(data[:, ch], dtype=np.float64)
  1326. if len(x) < 2 or np.std(x) == 0:
  1327. lzc_values[ch] = 0.0
  1328. continue
  1329. # Binarize
  1330. if threshold == "median":
  1331. thr = np.median(x)
  1332. elif threshold == "mean":
  1333. thr = np.mean(x)
  1334. else:
  1335. thr = float(threshold)
  1336. binary = (x >= thr).astype(np.int32)
  1337. # antropy handles LZ76 + normalization internally
  1338. lzc_values[ch] = lziv_complexity(binary, normalize=True)
  1339. if ch % 10 == 0:
  1340. logger.info(f"Processed LZC for {ch}/{n_channels} channels")
  1341. except Exception:
  1342. logger.exception(f"Error calculating LZC for channel {ch}")
  1343. lzc_values[ch] = np.nan
  1344. return lzc_values
  1345. def calculate_apen_for_channels(data, m=2, r=0.25):
  1346. """Calculate Approximate Entropy for each channel.
  1347. Follows Pincus 1995, with optimized implementation using vectorization.
  1348. """
  1349. n_channels = data.shape[1]
  1350. apen_values = np.zeros(n_channels)
  1351. for ch in range(n_channels):
  1352. try:
  1353. # Get channel data
  1354. x = data[:, ch]
  1355. # Scale r by standard deviation of the data
  1356. r_scaled = r * np.std(x)
  1357. # Calculate phi(m) and phi(m+1)
  1358. phi_m = _phi_vectorized(x, m, r_scaled)
  1359. phi_m_plus_1 = _phi_vectorized(x, m + 1, r_scaled)
  1360. # Calculate ApEn
  1361. apen_values[ch] = phi_m - phi_m_plus_1
  1362. if ch % 10 == 0: # Log progress every 10 channels
  1363. logger.info(f"Processed ApEn for {ch}/{n_channels} channels")
  1364. except Exception:
  1365. logger.exception(f"Error calculating ApEn for channel {ch}")
  1366. apen_values[ch] = np.nan
  1367. return apen_values
  1368. def _phi_vectorized(x, m, r):
  1369. """
  1370. Vectorized calculation of Φᵐ(r) following Pincus 1995.
  1371. """
  1372. N = len(x)
  1373. N_m = N - m + 1
  1374. # Create embedding matrix efficiently
  1375. # Each row is a pattern of length m
  1376. patterns = np.zeros((N_m, m))
  1377. for i in range(m):
  1378. patterns[:, i] = x[i : i + N_m]
  1379. # Calculate distances using broadcasting
  1380. # This computes the maximum absolute difference between all pairs of patterns
  1381. diff = np.abs(patterns[:, None, :] - patterns[None, :, :])
  1382. max_diff = np.max(diff, axis=2)
  1383. # Count similar patterns (within tolerance r)
  1384. similar_patterns = np.sum(max_diff <= r, axis=1)
  1385. # Normalize counts by N_m
  1386. C = similar_patterns / N_m
  1387. # Calculate Φᵐ(r) with small constant to avoid log(0)
  1388. return np.mean(np.log(C + 1e-10))
  1389. def calculate_spectral_variability(data_values, fs, window_length=2000, total_power_range=None):
  1390. """Calculate spectral variability per channel from concatenated broadband data.
  1391. Uses FREQUENCY_BANDS for band definitions.
  1392. - Expects pre-concatenated data with channel means already removed.
  1393. - ``total_power_range`` sets the denominator for the relative-power time
  1394. series whose coefficient of variation is reported. It defaults to
  1395. FREQUENCY_BANDS["broadband"]["range"], and should normally be the same
  1396. range passed to calculate_power_bands so the two outputs are comparable.
  1397. """
  1398. try:
  1399. num_samples, num_channels = data_values.shape
  1400. samples_per_window = int(window_length * fs / 1000)
  1401. # Require at least 3 windows for a meaningful coefficient of variation
  1402. if num_samples < 3 * samples_per_window:
  1403. logger.warning(
  1404. f"Data length ({num_samples}) too short for meaningful "
  1405. f"variability calculation with window length {samples_per_window} samples."
  1406. )
  1407. return None
  1408. # 1) Identify the reference range used for total power
  1409. if total_power_range is None:
  1410. if "broadband" not in FREQUENCY_BANDS:
  1411. logger.error(
  1412. "No reference range available for spectral variability "
  1413. "('broadband' missing from FREQUENCY_BANDS and no explicit range given)"
  1414. )
  1415. return None
  1416. total_power_range = FREQUENCY_BANDS["broadband"]["range"]
  1417. broadband_min, broadband_max = total_power_range
  1418. # Prepare output dict of CV values
  1419. cv_values = {}
  1420. for band_name in FREQUENCY_BANDS:
  1421. if band_name.lower() == "broadband":
  1422. continue
  1423. cv_values[band_name] = np.zeros(num_channels)
  1424. # 2) Loop over channels and calculate spectrogram
  1425. for channel in range(num_channels):
  1426. try:
  1427. # Compute spectrogram for this channel
  1428. f, t, Sxx = signal.spectrogram(
  1429. data_values[:, channel],
  1430. fs=fs,
  1431. nperseg=samples_per_window,
  1432. noverlap=samples_per_window // 2,
  1433. detrend="constant",
  1434. window="hann",
  1435. )
  1436. # Create mask for broadband total power
  1437. total_mask = (f >= broadband_min) & (f < broadband_max)
  1438. if not np.any(total_mask):
  1439. logger.error(
  1440. f"No spectrogram bins in the reference range "
  1441. f"{broadband_min}-{broadband_max} Hz."
  1442. )
  1443. for band_name in cv_values: # noqa: PLC0206
  1444. cv_values[band_name][channel] = np.nan
  1445. continue
  1446. total_power = np.sum(Sxx[total_mask, :], axis=0) # shape: (time_windows,)
  1447. # 3) Loop over the user-defined frequency bands
  1448. for band_name, band_info in FREQUENCY_BANDS.items():
  1449. if band_name.lower() == "broadband":
  1450. continue # skip calculating a separate "broadband" measure
  1451. low_freq, high_freq = band_info["range"]
  1452. band_mask = (f >= low_freq) & (f < high_freq)
  1453. if not np.any(band_mask):
  1454. # If no frequencies found in this range, skip
  1455. cv_values[band_name][channel] = np.nan
  1456. continue
  1457. band_power = np.sum(Sxx[band_mask, :], axis=0) # shape: (time_windows,)
  1458. # Compute relative power time series
  1459. with np.errstate(divide="ignore", invalid="ignore"):
  1460. relative_power = np.where(total_power > 0, band_power / total_power, 0)
  1461. # Remove NaN / Inf
  1462. valid_power = relative_power[np.isfinite(relative_power)]
  1463. if len(valid_power) > 0:
  1464. # Coefficient of Variation: std / mean
  1465. cv_values[band_name][channel] = np.std(valid_power) / np.mean(valid_power)
  1466. else:
  1467. cv_values[band_name][channel] = np.nan
  1468. except Exception:
  1469. logger.exception(f"Error processing channel {channel}")
  1470. # Fill with NaN for all bands on this channel
  1471. for band_name in cv_values: # noqa: PLC0206
  1472. cv_values[band_name][channel] = np.nan
  1473. return cv_values
  1474. except Exception:
  1475. logger.exception("Error in spectral variability calculation")
  1476. return None
  1477. def smooth_spectrum_savgol(power_spectrum: np.ndarray, window_length: int = 5, polyorder: int = 2) -> np.ndarray:
  1478. """
  1479. Apply Savitzky-Golay smoothing to a power spectrum.
  1480. """
  1481. if window_length % 2 == 0:
  1482. raise ValueError("window_length must be an odd integer.")
  1483. if polyorder >= window_length:
  1484. raise ValueError("polyorder must be less than window_length.")
  1485. if len(power_spectrum) < window_length:
  1486. # Not enough data points to apply the filter with the given window length.
  1487. return power_spectrum
  1488. smoothed_spectrum = signal.savgol_filter(power_spectrum, window_length, polyorder)
  1489. return smoothed_spectrum
  1490. def calculate_avg_peak_frequency(frequencies, psd, freq_range=(4, 13), sg_window_length=5, sg_polyorder=2):
  1491. """
  1492. Calculate peak frequency using pre-computed PSD with improved peak detection.
  1493. """
  1494. num_channels = psd.shape[1]
  1495. peak_frequencies = np.zeros(num_channels)
  1496. # Create frequency mask
  1497. freq_mask = (frequencies >= freq_range[0]) & (frequencies <= freq_range[1])
  1498. freq_range_idx = np.where(freq_mask)[0]
  1499. if len(freq_range_idx) == 0:
  1500. logger.warning(f"No frequencies found in range {freq_range[0]}-{freq_range[1]} Hz")
  1501. return np.full(num_channels, np.nan)
  1502. # Get masked frequencies and PSD
  1503. frequencies_masked = frequencies[freq_mask]
  1504. psd_masked = psd[freq_mask, :]
  1505. for channel in range(num_channels):
  1506. try:
  1507. channel_psd = psd_masked[:, channel]
  1508. # Apply Savitzky-Golay smoothing
  1509. smoothed_psd = smooth_spectrum_savgol(channel_psd,
  1510. window_length=sg_window_length,
  1511. polyorder=sg_polyorder)
  1512. # Find all peaks (using the basic signal.find_peaks for initial candidates)
  1513. peak_indices = signal.find_peaks(smoothed_psd)[0]
  1514. if len(peak_indices) == 0:
  1515. peak_frequencies[channel] = np.nan
  1516. continue
  1517. # Calculate peak properties (prominences) for these peaks
  1518. # Ensure smoothed_psd is not empty and peak_indices are valid
  1519. if smoothed_psd.size > 0 and peak_indices.size > 0 and np.all(peak_indices < len(smoothed_psd)):
  1520. peak_props = signal.peak_prominences(smoothed_psd, peak_indices)
  1521. prominences = peak_props[0]
  1522. else:
  1523. peak_frequencies[channel] = np.nan # Not enough data or invalid indices for prominence
  1524. continue
  1525. # Sort peaks by prominence
  1526. if prominences.size > 0:
  1527. sorted_peak_indices = peak_indices[np.argsort(-prominences)]
  1528. else:
  1529. peak_frequencies[channel] = np.nan # No prominences to sort by
  1530. continue
  1531. if len(sorted_peak_indices) > 0:
  1532. # Get the frequency of the most prominent peak
  1533. peak_frequencies[channel] = frequencies_masked[sorted_peak_indices[0]]
  1534. else:
  1535. peak_frequencies[channel] = np.nan
  1536. except Exception:
  1537. logger.exception(f"Error calculating peak frequency for channel {channel}")
  1538. peak_frequencies[channel] = np.nan
  1539. return peak_frequencies
  1540. def integrate_psd_band(frequencies, psd, frequency_mask):
  1541. """Integrate PSD over a selected frequency range.
  1542. Parameters
  1543. ----------
  1544. frequencies : np.ndarray
  1545. One-dimensional frequency axis in Hz.
  1546. psd : np.ndarray
  1547. PSD values with shape frequencies x channels. Values are expected to
  1548. be power spectral density, for example µV²/Hz or V²/Hz.
  1549. frequency_mask : np.ndarray
  1550. Boolean mask selecting the frequencies to integrate.
  1551. Returns
  1552. -------
  1553. np.ndarray
  1554. Integrated power for each channel.
  1555. """
  1556. selected_frequencies = frequencies[frequency_mask]
  1557. selected_psd = psd[frequency_mask, :]
  1558. if selected_frequencies.size == 0:
  1559. raise ValueError("No frequency bins available for PSD integration.")
  1560. if selected_frequencies.size == 1:
  1561. # Trapezoidal integration over a single point would return zero.
  1562. # Approximate the integral using the frequency-bin width.
  1563. if frequencies.size < 2:
  1564. raise ValueError(
  1565. "At least two PSD frequency bins are required to estimate "
  1566. "integrated power."
  1567. )
  1568. frequency_resolution = float(np.median(np.diff(frequencies)))
  1569. return selected_psd[0, :] * frequency_resolution
  1570. return trapezoid(
  1571. selected_psd,
  1572. x=selected_frequencies,
  1573. axis=0,
  1574. )
  1575. def calculate_power_bands(frequencies, psd, total_power_range=None):
  1576. """Calculate integrated absolute and relative power.
  1577. The input PSD is numerically integrated over each frequency band. Absolute
  1578. power therefore has units of signal squared rather than signal squared per Hz,
  1579. and does not scale directly with the PSD frequency resolution.
  1580. The denominator used for relative power ("total power") is an explicit
  1581. parameter rather than being implicitly tied to the 'broadband' entry, so the
  1582. reference range can be chosen independently of the band definitions.
  1583. Parameters
  1584. ----------
  1585. frequencies : np.ndarray
  1586. 1D array of frequency values.
  1587. psd : np.ndarray
  1588. 2D array of power spectral density (frequencies x channels).
  1589. total_power_range : tuple (fmin, fmax), optional
  1590. Frequency range used as the denominator for relative power. Defaults to
  1591. FREQUENCY_BANDS["broadband"]["range"] for backwards compatibility.
  1592. Returns
  1593. -------
  1594. tuple
  1595. (powers, channel_powers)
  1596. - powers: dict with mean abs/rel power across channels per band
  1597. - channel_powers: dict with channel-level arrays (one entry per band)
  1598. """
  1599. if total_power_range is None:
  1600. total_power_range = FREQUENCY_BANDS["broadband"]["range"]
  1601. total_min, total_max = total_power_range
  1602. total_mask = (frequencies >= total_min) & (frequencies < total_max)
  1603. if not np.any(total_mask):
  1604. msg = f"No PSD bins in reference range {total_min}-{total_max} Hz"
  1605. raise ValueError(msg)
  1606. # Warn if the PSD does not actually reach the requested top edge (e.g. the
  1607. # multitaper path truncates at 60 Hz, or fs/2 is lower than requested).
  1608. if frequencies.max() < total_max:
  1609. logger.warning(
  1610. f"Reference range top edge {total_max} Hz exceeds highest PSD bin "
  1611. f"({frequencies.max():.2f} Hz); total power is effectively "
  1612. f"{total_min}-{frequencies.max():.2f} Hz."
  1613. )
  1614. # Integrate PSD over the relative-power reference range
  1615. total_power = integrate_psd_band(
  1616. frequencies,
  1617. psd,
  1618. total_mask,
  1619. )
  1620. # Prepare output containers
  1621. powers = {}
  1622. channel_powers = {}
  1623. # Loop over the frequency bands
  1624. for band_name, band_info in FREQUENCY_BANDS.items():
  1625. # Calculate for all bands including 'broadband'
  1626. fmin, fmax = band_info["range"]
  1627. band_mask = (frequencies >= fmin) & (frequencies < fmax)
  1628. # Integrate PSD over this frequency band
  1629. if not np.any(band_mask):
  1630. logger.warning(
  1631. f"No PSD bins available for frequency band "
  1632. f"{band_name} ({fmin}-{fmax} Hz)."
  1633. )
  1634. abs_power = np.full(psd.shape[1], np.nan)
  1635. else:
  1636. abs_power = integrate_psd_band(
  1637. frequencies,
  1638. psd,
  1639. band_mask,
  1640. )
  1641. # Compute relative power. Note that the 'broadband' band is no longer
  1642. # forced to 1.0: with a user-chosen reference range its relative power is
  1643. # a useful diagnostic (how much of the reference range it covers).
  1644. with np.errstate(divide="ignore", invalid="ignore"):
  1645. rel_power = np.where(total_power > 0, abs_power / total_power, np.nan)
  1646. # Store mean abs/rel power across channels
  1647. powers[f"{band_name}_abs_power"] = np.nanmean(abs_power)
  1648. powers[f"{band_name}_rel_power"] = np.nanmean(rel_power)
  1649. # Also store channel-level arrays
  1650. channel_powers[f"{band_name}_abs_power"] = np.nan_to_num(abs_power, nan=np.nan)
  1651. channel_powers[f"{band_name}_rel_power"] = np.nan_to_num(rel_power, nan=np.nan)
  1652. return powers, channel_powers
  1653. def calculate_mst_measures(connectivity_matrix, used_channels=None):
  1654. """
  1655. Calculate MST measures from a connectivity matrix with additional error handling for disconnected graphs.
  1656. Args:
  1657. connectivity_matrix (numpy.ndarray): Square connectivity matrix (e.g., PLI matrix)
  1658. used_channels (numpy.ndarray, optional): Boolean array indicating which channels are used.
  1659. If None, all channels are considered used.
  1660. Returns
  1661. -------
  1662. tuple: (dict of MST measures, MST matrix, bool indicating success)
  1663. """
  1664. # Initialize used_channels if not provided
  1665. if used_channels is None:
  1666. used_channels = np.ones(len(connectivity_matrix), dtype=bool)
  1667. # Get number of total channels (N) and used channels (M)
  1668. n_total = len(connectivity_matrix) # N in BrainWave
  1669. n_used = np.sum(used_channels) # M in BrainWave
  1670. norm_factor = n_used - 1 # (M-1) for initial normalization
  1671. # Create MST from connectivity matrix
  1672. # Using -connectivity to get maximum spanning tree
  1673. mst_matrix = minimum_spanning_tree(-connectivity_matrix).toarray()
  1674. # Convert to NetworkX graph to check connectivity
  1675. G = nx.from_numpy_array(-mst_matrix)
  1676. # Check if the graph is connected
  1677. if not nx.is_connected(G):
  1678. return None, None, False
  1679. mst_matrix = -mst_matrix
  1680. G = nx.from_numpy_array(mst_matrix)
  1681. measures = {}
  1682. try:
  1683. # 1. Maximum degree calculation
  1684. degrees = defaultdict(float)
  1685. for edge in G.edges():
  1686. degrees[edge[0]] += 1.0 / norm_factor # norm_factor is (M-1)
  1687. degrees[edge[1]] += 1.0 / norm_factor
  1688. measures["degree"] = max(degrees.values()) if degrees else 0
  1689. # 2. Eccentricity - normalize by (M-1)
  1690. eccentricity = nx.eccentricity(G)
  1691. normalized_eccentricity = {node: ecc / norm_factor for node, ecc in eccentricity.items() if used_channels[node]}
  1692. measures["eccentr"] = np.mean(list(normalized_eccentricity.values()))
  1693. # 3. Betweenness centrality
  1694. betweenness = nx.betweenness_centrality(G)
  1695. measures["betweenness"] = max(list(betweenness.values()))
  1696. # 4. Diameter - normalize by (M-1)
  1697. raw_diameter = nx.diameter(G)
  1698. measures["diameter"] = raw_diameter / norm_factor
  1699. # 5. Leaf fraction
  1700. leaf_nodes = sum(1 for node, deg in degrees.items() if abs(deg - 1.0 / norm_factor) < 1e-10) # noqa: PLR2004
  1701. measures["leaf"] = leaf_nodes / n_used
  1702. max_betweenness = max(betweenness.values()) if betweenness else 0
  1703. if max_betweenness > 0:
  1704. measures["hierarchy"] = leaf_nodes / (2 * max_betweenness * norm_factor)
  1705. else:
  1706. measures["hierarchy"] = 0
  1707. # 6. Kappa (degree divergence)
  1708. sum_x = sum((n_total - 1) * deg for node, deg in degrees.items() if used_channels[node])
  1709. sum_x2 = sum(((n_total - 1) * deg) ** 2 for node, deg in degrees.items() if used_channels[node])
  1710. measures["kappa"] = sum_x2 / sum_x if sum_x > 0 else 0
  1711. # 7. Average shortest path (ASP)
  1712. paths = dict(nx.all_pairs_shortest_path_length(G))
  1713. sum_distances = 0
  1714. for i in range(n_total):
  1715. if used_channels[i]:
  1716. node_distances = 0
  1717. for j in range(n_total):
  1718. if used_channels[j] and i != j and i in paths and j in paths[i]:
  1719. node_distances += paths[i][j]
  1720. sum_distances += node_distances
  1721. measures["asp"] = sum_distances / (n_used * (n_used - 1)) if n_used > 1 else 0
  1722. # 8. Tree efficiency (Teff)
  1723. normalized_diam = raw_diameter / norm_factor
  1724. measures["teff"] = 1.0 - (normalized_diam * (n_used - 1)) / (n_used - (n_used - 1) * measures["leaf"] + 1.0)
  1725. # 9. R (degree correlation)
  1726. degree_pairs = []
  1727. for edge in G.edges():
  1728. i, j = edge[0], edge[1]
  1729. if used_channels[i] and used_channels[j]:
  1730. degree_pairs.append((degrees[i], degrees[j]))
  1731. if degree_pairs:
  1732. deg_i, deg_j = zip(*degree_pairs)
  1733. deg_i = np.array(deg_i)
  1734. deg_j = np.array(deg_j)
  1735. mean_i = np.mean(deg_i)
  1736. mean_j = np.mean(deg_j)
  1737. cov = np.mean((deg_i - mean_i) * (deg_j - mean_j))
  1738. var_i = np.mean((deg_i - mean_i) ** 2)
  1739. var_j = np.mean((deg_j - mean_j) ** 2)
  1740. if var_i * var_j > 0:
  1741. measures["r"] = cov / np.sqrt(var_i * var_j)
  1742. else:
  1743. measures["r"] = 0
  1744. else:
  1745. measures["r"] = 0
  1746. # 10. Mean edge weight
  1747. edge_weights = [abs(d.get("weight", 1.0)) for _, _, d in G.edges(data=True)]
  1748. measures["mean"] = np.mean(edge_weights) if edge_weights else 0
  1749. # 11. Reference value
  1750. mst_sum = np.sum(abs(mst_matrix[used_channels][:, used_channels]))
  1751. orig_sum = np.sum(connectivity_matrix[used_channels][:, used_channels])
  1752. measures["ref"] = mst_sum / orig_sum if orig_sum > 0 else 0
  1753. return measures, mst_matrix, True
  1754. except Exception:
  1755. logger.exception("Error in MST measures calculation")
  1756. return None, None, False
  1757. def calculate_pli(data):
  1758. """Optimized PLI calculation using vectorization."""
  1759. analytic_signal = hilbert(data, axis=0)
  1760. phases = np.angle(analytic_signal)
  1761. n_channels = data.shape[1]
  1762. pli = np.zeros((n_channels, n_channels))
  1763. # Vectorized phase difference calculation
  1764. for i in range(n_channels):
  1765. phase_diffs = phases[:, i : i + 1] - phases[:, i:]
  1766. signs = np.sign(np.sin(phase_diffs))
  1767. means = np.abs(np.mean(signs, axis=0))
  1768. pli[i, i:] = means
  1769. pli[i:, i] = means
  1770. return pli
  1771. def PLT(data, fs, threshold_ms=30):
  1772. """
  1773. Calculates the Phase Lag Time (PLT) index.
  1774. Expects data in (samples, channels) or (channels, samples).
  1775. """
  1776. # 1. Data Standardisation
  1777. if hasattr(data, 'values'): # Check if pandas DataFrame
  1778. data = data.values
  1779. # Ensure shape is (channels x samples) for Hilbert
  1780. if data.shape[0] > data.shape[1]:
  1781. data = data.T
  1782. n_channels = data.shape[0]
  1783. n_samples = data.shape[1]
  1784. # 2. Calculate Threshold in Samples
  1785. min_samples = int((threshold_ms / 1000) * fs)
  1786. # 3. Analytic Signal
  1787. analytic = hilbert(data, axis=1)
  1788. PLT_matrix = np.zeros((n_channels, n_channels))
  1789. total_time_seconds = n_samples / fs
  1790. for i in range(n_channels):
  1791. for j in range(i + 1, n_channels):
  1792. # Phase Difference & Sign Logic
  1793. phase_diff_complex = analytic[i] / analytic[j]
  1794. sign_diff = np.sign(phase_diff_complex.imag)
  1795. sign_diff[sign_diff == 0] = 1
  1796. # Detect Crossings
  1797. crossings = np.where(np.abs(np.diff(sign_diff)) > 1)[0]
  1798. # 4. Apply Time-Based Threshold
  1799. if crossings.size > 1 and min_samples > 0:
  1800. valid_crossings = [crossings[0]]
  1801. for k in range(1, len(crossings)):
  1802. if crossings[k] - valid_crossings[-1] > min_samples:
  1803. valid_crossings.append(crossings[k])
  1804. crossings = np.array(valid_crossings)
  1805. num_crossings = crossings.size
  1806. # 5. Calculate Score
  1807. if num_crossings == 0:
  1808. PLT_matrix[i, j] = 1.0
  1809. else:
  1810. avg_t = total_time_seconds / (num_crossings + 1)
  1811. PLT_matrix[i, j] = 1 - np.exp(-avg_t)
  1812. # Mirror matrix
  1813. PLT_matrix = PLT_matrix + PLT_matrix.T
  1814. return PLT_matrix
  1815. def calculate_aecc(data, orthogonalize=False, force_positive=True):
  1816. """
  1817. Calculate amplitude envelope correlation with optional orthogonalization.
  1818. Parameters
  1819. ----------
  1820. data : numpy array (time points * channels)
  1821. EEG data array
  1822. orthogonalize : bool, optional
  1823. Whether to perform orthogonalization
  1824. force_positive : bool, optional
  1825. Whether to force negative correlations to zero
  1826. Returns
  1827. -------
  1828. numpy array (channels * channels)
  1829. AEC(c) correlation matrix
  1830. """
  1831. def process_correlation(corr):
  1832. """Process correlation based on force_positive setting."""
  1833. return max(0.0, corr) if force_positive else corr
  1834. n_channels = data.shape[1]
  1835. correlation_matrix = np.zeros((n_channels, n_channels))
  1836. if orthogonalize:
  1837. # Process all channels pairwise
  1838. for i in range(n_channels):
  1839. for j in range(i + 1, n_channels):
  1840. # Orthogonalize in both directions
  1841. d_orth_ij = data[:, j] - np.dot(data[:, j], data[:, i]) * data[:, i] / np.dot(data[:, i], data[:, i])
  1842. d_orth_ji = data[:, i] - np.dot(data[:, i], data[:, j]) * data[:, j] / np.dot(data[:, j], data[:, j])
  1843. # Calculate envelopes
  1844. env_i = np.abs(hilbert(data[:, i]))
  1845. env_j = np.abs(hilbert(data[:, j]))
  1846. env_orth_ij = np.abs(hilbert(d_orth_ij))
  1847. env_orth_ji = np.abs(hilbert(d_orth_ji))
  1848. # Calculate correlations
  1849. corr_ij = process_correlation(np.corrcoef(env_i, env_orth_ij)[0, 1])
  1850. corr_ji = process_correlation(np.corrcoef(env_j, env_orth_ji)[0, 1])
  1851. # Update correlation matrix
  1852. correlation_matrix[i, j] = (corr_ij + corr_ji) / 2
  1853. correlation_matrix[j, i] = correlation_matrix[i, j]
  1854. # Clean up memory
  1855. del env_i, env_j, env_orth_ij, env_orth_ji, d_orth_ij, d_orth_ji
  1856. else:
  1857. # Simple amplitude envelope correlation without orthogonalization
  1858. envs = np.abs(hilbert(data, axis=0))
  1859. correlation_matrix = np.corrcoef(envs.T)
  1860. if force_positive:
  1861. correlation_matrix = np.maximum(correlation_matrix, 0)
  1862. del envs
  1863. # Zero the diagonal
  1864. np.fill_diagonal(correlation_matrix, 0)
  1865. return correlation_matrix
  1866. def calculate_pe(data, n=4, st=1):
  1867. """Calculate Permutation Entropy for each channel.
  1868. Parameters
  1869. ----------
  1870. data : numpy array (time points * channels)
  1871. n : int, embedding dimension
  1872. st : int, time delay (should scale with sampling frequency)
  1873. Returns
  1874. -------
  1875. numpy array : PE values for each channel
  1876. """
  1877. sz = data.shape[0]
  1878. combinations = list(itertools.permutations(np.arange(0, n), n))
  1879. PEs = []
  1880. for ch in range(data.shape[1]):
  1881. pattern_counts = np.zeros(len(combinations))
  1882. # Step size for moving between patterns should be fixed (e.g., 1)
  1883. # Only the sampling interval (st) within patterns should scale with frequency
  1884. for i in range(0, sz - n * st, 1):
  1885. dat_array = data[i : i + n * st : st, ch]
  1886. if len(dat_array) < n:
  1887. break
  1888. dat_order = dat_array.argsort()
  1889. rank = dat_order.argsort()
  1890. pattern_idx = combinations.index(tuple(rank))
  1891. pattern_counts[pattern_idx] += 1
  1892. # Calculate PE
  1893. total_patterns = np.sum(pattern_counts)
  1894. if total_patterns > 0:
  1895. prob = pattern_counts[pattern_counts > 0] / total_patterns
  1896. entr = -np.sum(prob * np.log(prob))
  1897. pe_norm = entr / np.log(math.factorial(n))
  1898. PEs.append(pe_norm)
  1899. else:
  1900. PEs.append(np.nan)
  1901. return np.array(PEs)
  1902. def find_mirror_patterns(combinations):
  1903. """Create a lookup dictionary for mirror patterns (assumes 0-based ranks)."""
  1904. if not combinations:
  1905. return {}
  1906. mirrors = {}
  1907. n = len(combinations[0]) # Determine embedding dimension from the permutation length
  1908. mirror_sum = n - 1
  1909. for i, perm1 in enumerate(combinations):
  1910. # Optimization: only need to check j > i
  1911. for j in range(i + 1, len(combinations)):
  1912. perm2 = combinations[j]
  1913. # Check if perm2 is the mirror of perm1
  1914. is_mirror = True
  1915. for k in range(n): # Iterate through elements of the permutations
  1916. if perm1[k] + perm2[k] != mirror_sum:
  1917. is_mirror = False
  1918. break
  1919. if is_mirror:
  1920. mirrors[i] = j
  1921. mirrors[j] = i
  1922. return mirrors
  1923. def is_volume_conduction(pattern1, pattern2, mirrors):
  1924. """Check for volume conduction."""
  1925. return pattern1 == pattern2 or pattern2 == mirrors.get(pattern1, -1)
  1926. def calculate_jpe(data, n=4, st=1, invert=True):
  1927. """Calculate joint permutation entropy with corrected time delay handling.
  1928. Parameters
  1929. ----------
  1930. data : numpy array (time points * channels)
  1931. n : int, embedding dimension
  1932. st : int, time delay (should scale with sampling frequency)
  1933. invert : bool, whether to return 1-JPE
  1934. """
  1935. data = np.asarray(data)
  1936. sz = data.shape[0]
  1937. combinations = list(itertools.permutations(np.arange(0,n), n))
  1938. mirrors = find_mirror_patterns(combinations)
  1939. rank_inds = []
  1940. for i in range(0, sz - n * st, 1):
  1941. dat_array = data[i : i + n * st : st, :]
  1942. if dat_array.shape[0] < n: # Safety check
  1943. break
  1944. dat_order = dat_array.argsort(axis=0)
  1945. rank = dat_order.argsort(axis=0)
  1946. rank_inds.append([combinations.index(tuple(r)) for r in rank.T])
  1947. rank_inds = np.array(rank_inds).T
  1948. JPE = np.zeros((data.shape[1], data.shape[1]))
  1949. for ch, x in enumerate(rank_inds):
  1950. for ind, y in enumerate(rank_inds):
  1951. if ind > ch:
  1952. pattern_counts = 0
  1953. jpe_mat = np.zeros((len(combinations), len(combinations)))
  1954. for i, j in zip(x, y):
  1955. if not is_volume_conduction(i, j, mirrors):
  1956. pattern_counts += 1
  1957. jpe_mat[i, j] += 1
  1958. if pattern_counts > 0:
  1959. jpe_mat = jpe_mat / pattern_counts
  1960. prob = jpe_mat[jpe_mat > 0]
  1961. entr = -np.sum(prob * np.log(prob))
  1962. jpe_norm = entr / np.log(math.factorial(n) * math.factorial(n) - 2 * math.factorial(n))
  1963. JPE[ch, ind] = 1 - jpe_norm if invert else jpe_norm
  1964. return JPE + JPE.T
  1965. def extract_epoch_id(filename):
  1966. """Extract epoch identifier from filename for logging and saving."""
  1967. match = re.search(r'Epoch_?(\d+)', os.path.basename(filename), re.IGNORECASE)
  1968. return f"Epoch_{match.group(1)}" if match else "Epoch_?"
  1969. def parse_epoch_filename(filename):
  1970. """Parse epoch filename to extract components.
  1971. Handles both legacy format:
  1972. subject_Sensor_level_4.0-8.0 Hz_Epoch_1.txt
  1973. subject_Source_level_4.0-8.0 Hz_Epoch_1.txt
  1974. And new multi-atlas format:
  1975. subject_Source_desikan_cortical_4.0-8.0 Hz_Epoch_1.txt
  1976. subject_Source_bna_full_4.0-8.0 Hz_Epoch_1.txt
  1977. """
  1978. base_name = filename.split("_")[0]
  1979. # Try new method+atlas source format first: _Source_{method}_{atlas}_{region}_
  1980. method_atlas_match = re.search(
  1981. r"_(Source)_(beamformer|sLORETA|eLORETA|dSPM|MNE)_(desikan|bna|aal2|aal3)_(cortical|full)_",
  1982. filename,
  1983. re.IGNORECASE,
  1984. )
  1985. # Also try atlas-only format (no method): _Source_{atlas}_{region}_
  1986. atlas_match = re.search(
  1987. r"_(Source)_(desikan|bna|aal2|aal3)_(cortical|full)_",
  1988. filename,
  1989. re.IGNORECASE,
  1990. )
  1991. if method_atlas_match:
  1992. method = method_atlas_match.group(2)
  1993. atlas = method_atlas_match.group(3).lower()
  1994. region = method_atlas_match.group(4).lower()
  1995. level_type = "source"
  1996. level_detail = f"source_{method}_{atlas}_{region}"
  1997. elif atlas_match:
  1998. atlas = atlas_match.group(2).lower()
  1999. region = atlas_match.group(3).lower()
  2000. level_type = "source"
  2001. level_detail = f"source_{atlas}_{region}"
  2002. else:
  2003. # Legacy format: _Source_level_ or _Sensor_level_
  2004. level_match = re.search(r"(Source|Sensor)_level", filename, re.IGNORECASE)
  2005. level_type = level_match.group(1).lower() if level_match else "unknown"
  2006. level_detail = level_type
  2007. # Extract frequency band - try numerical range first
  2008. freq_match = re.search(r"(\d+\.?\d*-\d+\.?\d*)\s*Hz", filename)
  2009. if freq_match:
  2010. freq_band = freq_match.group(1)
  2011. else:
  2012. # Try to extract broadband text from filename parts
  2013. parts = filename.split("_")
  2014. for i, part in enumerate(parts):
  2015. if part.lower() == "level" and i + 1 < len(parts):
  2016. freq_band = parts[i + 1]
  2017. break
  2018. else:
  2019. freq_band = "unknown"
  2020. return {
  2021. "base_name": base_name,
  2022. "level_type": level_type,
  2023. "level_detail": level_detail,
  2024. "freq_band": freq_band,
  2025. "condition": f"{level_detail}_{freq_band}",
  2026. }
  2027. def process_subject_condition(args):
  2028. """Process a single subject-condition combination and save matrices directly to disk."""
  2029. (
  2030. subject, condition, epoch_files, invert, calc_jpe, calc_pli, calc_pli_mst,
  2031. calc_aec, use_aecc, force_positive, jpe_st, calc_aec_mst, calc_power,
  2032. power_fs, calc_peak, peak_min, peak_max, calc_sampen, sampen_m, calc_apen,
  2033. apen_m, apen_r, calc_sv, sv_window, save_matrices, save_mst,
  2034. save_channel_averages, concat_aecc, has_headers, psd_method,
  2035. welch_window_ms, welch_overlap, calc_plt, plt_threshold_ms,
  2036. calc_lzc, lzc_threshold, calc_plt_mst, matrix_folders, save_epoch_metrics,
  2037. mt_bandwidth,
  2038. save_psd_avg, save_psd_perchannel, save_psd_plots, psd_folder,
  2039. total_power_range,
  2040. ) = args
  2041. MST_MEASURES = ["degree", "eccentr", "betweenness", "kappa", "r",
  2042. "diameter", "leaf", "hierarchy", "teff", "asp", "ref", "mean"]
  2043. try:
  2044. channel_results = defaultdict(lambda: defaultdict(list))
  2045. jpe_values, pe_values, pli_values, plt_values, aec_values = [], [], [], [], []
  2046. pli_mst_values = defaultdict(list)
  2047. aec_mst_values = defaultdict(list)
  2048. plt_mst_values = defaultdict(list)
  2049. power_values = defaultdict(list)
  2050. apen_values, sampen_values, lzc_values = [], [], []
  2051. sv_values = {}
  2052. channel_names = None
  2053. # PSD export accumulators (running mean over broadband epochs)
  2054. save_psd = save_psd_avg or save_psd_perchannel or save_psd_plots
  2055. psd_sum = None
  2056. psd_count = 0
  2057. psd_frequencies = None
  2058. successful_mst_epochs = 0
  2059. epoch_level_results = []
  2060. logger.info(f"Processing {subject} - {condition} ({len(epoch_files)} epochs)")
  2061. # Determine whether anything is actually computed per-epoch. If not (e.g. only
  2062. # spectral variability or only concatenated AEC is requested), we still need
  2063. # channel_names but can skip re-reading every epoch in this loop.
  2064. per_epoch_work = (
  2065. calc_jpe or calc_pli or calc_plt or calc_sampen or calc_apen or calc_lzc
  2066. or (calc_aec and not concat_aecc)
  2067. or ((calc_power or calc_peak or save_psd) and is_broadband_condition(condition))
  2068. )
  2069. for i, file_path in enumerate(epoch_files):
  2070. if MemoryMonitor.check_memory():
  2071. logger.warning(f"High memory usage detected while processing {subject}")
  2072. time.sleep(1)
  2073. try:
  2074. epoch_id = extract_epoch_id(file_path)
  2075. current_epoch_data = {"Subject": subject, "Condition": condition, "Epoch": epoch_id}
  2076. # --- Read Data (centralized in read_epoch_array) ---
  2077. data_values, file_channel_names = read_epoch_array(file_path, has_headers)
  2078. if channel_names is None:
  2079. channel_names = (
  2080. file_channel_names
  2081. if file_channel_names is not None
  2082. else [f"Channel_{j + 1}" for j in range(data_values.shape[1])]
  2083. )
  2084. # channel_names is now set; if there is no per-epoch work to do, stop
  2085. # after the first file rather than re-reading every epoch needlessly.
  2086. if not per_epoch_work:
  2087. del data_values
  2088. break
  2089. # Use level_detail (not level_type) so multi-atlas / multi-method source
  2090. # files don't collide on disk at the same band + epoch.
  2091. file_info = parse_epoch_filename(os.path.basename(file_path))
  2092. level_type = file_info["level_detail"]
  2093. freq_band = file_info["freq_band"]
  2094. # --- Spectral (Power / Peak) ---
  2095. need_spectral = (calc_power or calc_peak or save_psd) and is_broadband_condition(condition)
  2096. if need_spectral:
  2097. try:
  2098. # psd_kwargs = {"window_length_ms": welch_window_ms, "overlap_percent": welch_overlap} if psd_method == "welch" else {}
  2099. if psd_method == "welch":
  2100. psd_kwargs = {"window_length_ms": welch_window_ms, "overlap_percent": welch_overlap}
  2101. elif psd_method == "multitaper":
  2102. psd_kwargs = {"bandwidth": mt_bandwidth}
  2103. else:
  2104. psd_kwargs = {}
  2105. spectral_data = calculate_PSD(data=data_values, fs=power_fs, method=psd_method, **psd_kwargs)
  2106. if calc_power:
  2107. powers, channel_powers = calculate_power_bands(
  2108. spectral_data["frequencies"],
  2109. spectral_data["psd"],
  2110. total_power_range=total_power_range,
  2111. )
  2112. for measure, value in powers.items():
  2113. power_values[measure].append(value)
  2114. current_epoch_data[measure] = value
  2115. if save_channel_averages:
  2116. for band_name in FREQUENCY_BANDS:
  2117. for ch in range(len(channel_names)):
  2118. channel_results[channel_names[ch]][f"{band_name}_abs_power"].append(channel_powers[f"{band_name}_abs_power"][ch])
  2119. channel_results[channel_names[ch]][f"{band_name}_rel_power"].append(channel_powers[f"{band_name}_rel_power"][ch])
  2120. if calc_peak:
  2121. peak_freqs = calculate_avg_peak_frequency(spectral_data["frequencies"], spectral_data["psd"], freq_range=(peak_min, peak_max))
  2122. current_epoch_data["peak_frequency"] = np.nanmean(peak_freqs)
  2123. power_values["peak_frequency"].append(np.nanmean(peak_freqs))
  2124. power_values["channels_without_peak"].append(np.sum(np.isnan(peak_freqs)))
  2125. if save_channel_averages:
  2126. for ch in range(len(channel_names)):
  2127. channel_results[channel_names[ch]]["peak_frequency"].append(peak_freqs[ch])
  2128. if save_psd:
  2129. cur_psd = spectral_data["psd"]
  2130. if psd_sum is None:
  2131. psd_sum = cur_psd.astype(np.float64).copy()
  2132. psd_frequencies = spectral_data["frequencies"]
  2133. psd_count = 1
  2134. elif cur_psd.shape == psd_sum.shape:
  2135. psd_sum += cur_psd
  2136. psd_count += 1
  2137. else:
  2138. logger.warning(f"PSD shape mismatch in {epoch_id}; excluded from PSD average.")
  2139. del spectral_data
  2140. except Exception:
  2141. logger.exception(f"Error in spectral calculations for {epoch_id}")
  2142. # --- JPE / PE ---
  2143. if calc_jpe:
  2144. try:
  2145. jpe_matrix = calculate_jpe(data_values, n=4, st=jpe_st, invert=invert)
  2146. mask = ~np.eye(jpe_matrix.shape[0], dtype=bool)
  2147. mean_jpe = jpe_matrix[mask].mean()
  2148. jpe_values.append(mean_jpe)
  2149. current_epoch_data["JPE"] = mean_jpe
  2150. pe_values_array = calculate_pe(data_values, n=4, st=jpe_st)
  2151. pe_values.append(pe_values_array.mean())
  2152. current_epoch_data["PE"] = pe_values_array.mean()
  2153. if save_channel_averages:
  2154. channel_jpe = np.mean(jpe_matrix, axis=1)
  2155. for ch in range(len(channel_names)):
  2156. channel_results[channel_names[ch]]["jpe"].append(channel_jpe[ch])
  2157. channel_results[channel_names[ch]]["pe"].append(pe_values_array[ch])
  2158. if save_matrices and matrix_folders:
  2159. save_connectivity_matrix(jpe_matrix, matrix_folders["jpe"], subject, freq_band, f"jpe_{epoch_id}", channel_names, level_type)
  2160. except Exception:
  2161. logger.exception(f"Error calculating JPE/PE for {epoch_id}")
  2162. # --- SampEn ---
  2163. if calc_sampen:
  2164. try:
  2165. sampen_values_ch = calculate_sampen_for_channels(data_values, m=sampen_m)
  2166. sampen_values.append(np.nanmean(sampen_values_ch))
  2167. current_epoch_data["SampEn"] = np.nanmean(sampen_values_ch)
  2168. if save_channel_averages:
  2169. for ch in range(len(channel_names)):
  2170. channel_results[channel_names[ch]]["sampen"].append(sampen_values_ch[ch])
  2171. except Exception:
  2172. logger.exception(f"Error calculating SampEn for {epoch_id}")
  2173. # --- ApEn ---
  2174. if calc_apen:
  2175. try:
  2176. apen_values_ch = calculate_apen_for_channels(data_values, m=apen_m, r=apen_r)
  2177. apen_values.append(np.nanmean(apen_values_ch))
  2178. current_epoch_data["ApEn"] = np.nanmean(apen_values_ch)
  2179. if save_channel_averages:
  2180. for ch in range(len(channel_names)):
  2181. channel_results[channel_names[ch]]["apen"].append(apen_values_ch[ch])
  2182. except Exception:
  2183. logger.exception(f"Error calculating ApEn for {epoch_id}")
  2184. # --- LZC ---
  2185. if calc_lzc:
  2186. try:
  2187. lzc_values_ch = calculate_lzc_for_channels(data_values, threshold=lzc_threshold)
  2188. lzc_values.append(np.nanmean(lzc_values_ch))
  2189. current_epoch_data["LZC"] = np.nanmean(lzc_values_ch)
  2190. if save_channel_averages:
  2191. for ch in range(len(channel_names)):
  2192. channel_results[channel_names[ch]]["lzc"].append(lzc_values_ch[ch])
  2193. except Exception:
  2194. logger.exception(f"Error calculating LZC for {epoch_id}")
  2195. # --- PLI ---
  2196. if calc_pli:
  2197. try:
  2198. pli_matrix = calculate_pli(data_values)
  2199. mask = ~np.eye(pli_matrix.shape[0], dtype=bool)
  2200. current_epoch_data["PLI"] = pli_matrix[mask].mean()
  2201. pli_values.append(current_epoch_data["PLI"])
  2202. if save_channel_averages:
  2203. channel_pli = np.mean(pli_matrix, axis=1)
  2204. for ch in range(len(channel_names)):
  2205. channel_results[channel_names[ch]]["pli"].append(channel_pli[ch])
  2206. if save_matrices and matrix_folders:
  2207. save_connectivity_matrix(pli_matrix, matrix_folders["pli"], subject, freq_band, f"pli_{epoch_id}", channel_names, level_type)
  2208. if calc_pli_mst:
  2209. mst_measures, mst_matrix, success = calculate_mst_measures(pli_matrix)
  2210. if success:
  2211. for measure, value in mst_measures.items():
  2212. pli_mst_values[measure].append(value)
  2213. current_epoch_data[f"PLI_MST_{measure}"] = value
  2214. if save_mst and matrix_folders:
  2215. save_connectivity_matrix(mst_matrix + mst_matrix.T, matrix_folders["pli_mst"], subject, freq_band, f"pli_mst_{epoch_id}", channel_names, level_type)
  2216. except Exception:
  2217. logger.exception(f"Error calculating PLI for {epoch_id}")
  2218. # --- PLT ---
  2219. if calc_plt:
  2220. try:
  2221. plt_matrix = PLT(data_values, fs=power_fs, threshold_ms=plt_threshold_ms)
  2222. mask = ~np.eye(plt_matrix.shape[0], dtype=bool)
  2223. current_epoch_data["PLT"] = plt_matrix[mask].mean()
  2224. plt_values.append(current_epoch_data["PLT"])
  2225. if save_channel_averages:
  2226. channel_plt = np.mean(plt_matrix, axis=1)
  2227. for ch in range(len(channel_names)):
  2228. channel_results[channel_names[ch]]["plt"].append(channel_plt[ch])
  2229. if save_matrices and matrix_folders:
  2230. save_connectivity_matrix(plt_matrix, matrix_folders["plt"], subject, freq_band, f"plt_{epoch_id}", channel_names, level_type)
  2231. if calc_plt_mst:
  2232. mst_measures, mst_matrix, success = calculate_mst_measures(plt_matrix)
  2233. if success:
  2234. for measure, value in mst_measures.items():
  2235. plt_mst_values[measure].append(value)
  2236. current_epoch_data[f"PLT_MST_{measure}"] = value
  2237. if save_mst and matrix_folders:
  2238. save_connectivity_matrix(mst_matrix + mst_matrix.T, matrix_folders["plt_mst"], subject, freq_band, f"plt_mst_{epoch_id}", channel_names, level_type)
  2239. except Exception:
  2240. logger.exception(f"Error calculating PLT for {epoch_id}")
  2241. # --- AEC (Standard Epoch) ---
  2242. if calc_aec and not concat_aecc:
  2243. try:
  2244. aec_matrix = calculate_aecc(data_values, orthogonalize=use_aecc, force_positive=force_positive)
  2245. mask = ~np.eye(aec_matrix.shape[0], dtype=bool)
  2246. current_epoch_data["AEC"] = aec_matrix[mask].mean()
  2247. aec_values.append(current_epoch_data["AEC"])
  2248. if save_channel_averages:
  2249. channel_aec = np.mean(aec_matrix, axis=1)
  2250. for ch in range(len(channel_names)):
  2251. channel_results[channel_names[ch]]["aec"].append(channel_aec[ch])
  2252. if save_matrices and matrix_folders:
  2253. save_connectivity_matrix(aec_matrix, matrix_folders["aec"], subject, freq_band, f"aec_{epoch_id}", channel_names, level_type)
  2254. if calc_aec_mst:
  2255. mst_measures, mst_matrix, success = calculate_mst_measures(aec_matrix)
  2256. if success:
  2257. successful_mst_epochs += 1
  2258. for measure, value in mst_measures.items():
  2259. aec_mst_values[measure].append(value)
  2260. current_epoch_data[f"AEC_MST_{measure}"] = value
  2261. if save_mst and matrix_folders:
  2262. save_connectivity_matrix(mst_matrix + mst_matrix.T, matrix_folders["aec_mst"], subject, freq_band, f"aec_mst_{epoch_id}", channel_names, level_type)
  2263. except Exception:
  2264. logger.exception(f"Error calculating AEC for {epoch_id}")
  2265. if save_epoch_metrics:
  2266. epoch_level_results.append(current_epoch_data)
  2267. except Exception:
  2268. logger.exception(f"Error processing file {os.path.basename(file_path)}")
  2269. continue
  2270. # --- AEC (Concatenated Edge Case) ---
  2271. if calc_aec and concat_aecc and epoch_files:
  2272. try:
  2273. all_data = []
  2274. first_nbytes = None
  2275. for aec_epoch_file_path in epoch_files:
  2276. try:
  2277. arr, _ = read_epoch_array(aec_epoch_file_path, has_headers) # now linearly detrended
  2278. if first_nbytes is None:
  2279. first_nbytes = arr.nbytes
  2280. if not MemoryMonitor.check_concatenation_safety(first_nbytes, len(epoch_files)):
  2281. logger.warning("Memory may be insufficient for concatenated AEC; proceeding cautiously")
  2282. all_data.append(arr)
  2283. except Exception:
  2284. logger.exception(f"Error reading {os.path.basename(aec_epoch_file_path)} for concatenated AEC")
  2285. if all_data:
  2286. data_values = np.concatenate(all_data, axis=0)
  2287. del all_data
  2288. aec_matrix = calculate_aecc(data_values, orthogonalize=use_aecc, force_positive=force_positive)
  2289. mask = ~np.eye(aec_matrix.shape[0], dtype=bool)
  2290. mean_aec = aec_matrix[mask].mean()
  2291. aec_values.append(mean_aec)
  2292. file_info = parse_epoch_filename(os.path.basename(epoch_files[0]))
  2293. level_type = file_info["level_detail"]
  2294. freq_band = file_info["freq_band"]
  2295. concat_epoch_data = {"Subject": subject, "Condition": condition, "Epoch": "Concatenated", "AEC": mean_aec}
  2296. if save_channel_averages:
  2297. channel_aec = np.mean(aec_matrix, axis=1)
  2298. for ch in range(len(channel_names)):
  2299. channel_results[channel_names[ch]]["aec"].append(channel_aec[ch])
  2300. if save_matrices and matrix_folders:
  2301. save_connectivity_matrix(aec_matrix, matrix_folders["aec"], subject, freq_band, "aec_concatenated", channel_names, level_type)
  2302. if calc_aec_mst:
  2303. mst_measures, mst_matrix, success = calculate_mst_measures(aec_matrix)
  2304. if success:
  2305. successful_mst_epochs += 1
  2306. for measure, value in mst_measures.items():
  2307. aec_mst_values[measure].append(value)
  2308. concat_epoch_data[f"AEC_MST_{measure}"] = value
  2309. if save_mst and matrix_folders:
  2310. save_connectivity_matrix(mst_matrix + mst_matrix.T, matrix_folders["aec_mst"], subject, freq_band, "aec_mst_concatenated", channel_names, level_type)
  2311. if save_epoch_metrics:
  2312. epoch_level_results.append(concat_epoch_data)
  2313. except Exception:
  2314. logger.exception("Error processing concatenated AEC")
  2315. # --- Spectral Variability (computed once on concatenated broadband data) ---
  2316. if calc_sv and is_broadband_condition(condition) and epoch_files:
  2317. try:
  2318. all_data_sv = []
  2319. first_nbytes = None
  2320. for sv_fp in epoch_files:
  2321. try:
  2322. sv_arr, _ = read_epoch_array(sv_fp, has_headers) # now linearly detrended
  2323. if first_nbytes is None:
  2324. first_nbytes = sv_arr.nbytes
  2325. if not MemoryMonitor.check_concatenation_safety(first_nbytes, len(epoch_files)):
  2326. logger.warning("Memory may be insufficient for concatenated SV; proceeding cautiously")
  2327. all_data_sv.append(sv_arr)
  2328. except Exception:
  2329. logger.exception(f"Error reading {os.path.basename(sv_fp)} for SV")
  2330. if all_data_sv:
  2331. concatenated_sv = np.concatenate(all_data_sv, axis=0)
  2332. del all_data_sv
  2333. sv_results = calculate_spectral_variability(
  2334. concatenated_sv,
  2335. fs=power_fs,
  2336. window_length=sv_window,
  2337. total_power_range=total_power_range,
  2338. )
  2339. del concatenated_sv
  2340. if sv_results:
  2341. for band_name, values in sv_results.items():
  2342. sv_values[f"sv_{band_name}"] = np.nanmean(values)
  2343. if save_channel_averages:
  2344. for ch in range(len(channel_names)):
  2345. channel_results[channel_names[ch]][f"sv_{band_name}"] = [values[ch]]
  2346. except Exception:
  2347. logger.exception("Error in spectral variability calculation")
  2348. # --- Save PSD export (epoch-averaged, broadband only) ---
  2349. if save_psd and is_broadband_condition(condition) and psd_sum is not None and psd_count > 0:
  2350. try:
  2351. mean_psd = psd_sum / psd_count
  2352. psd_info = parse_epoch_filename(os.path.basename(epoch_files[0]))
  2353. save_psd_outputs(
  2354. mean_psd,
  2355. psd_frequencies,
  2356. channel_names,
  2357. subject,
  2358. condition,
  2359. psd_info["freq_band"],
  2360. psd_info["level_detail"],
  2361. psd_folder,
  2362. save_avg=save_psd_avg,
  2363. save_perchannel=save_psd_perchannel,
  2364. save_plots=save_psd_plots,
  2365. )
  2366. except Exception:
  2367. logger.exception("Error saving PSD export")
  2368. # --- Channel-level averages across epochs ---
  2369. channel_averages = None
  2370. if save_channel_averages and channel_names:
  2371. channel_averages = {}
  2372. for channel in channel_names:
  2373. channel_averages[channel] = {
  2374. measure: np.mean(values) for measure, values in channel_results[channel].items()
  2375. }
  2376. # --- Build whole-brain averaged results dict (Legacy Wide Format) ---
  2377. used_epoch_indices = [extract_epoch_id(fp).replace("Epoch_", "") for fp in epoch_files]
  2378. results = {
  2379. "avg_jpe": np.mean(jpe_values) if jpe_values else np.nan,
  2380. "avg_pe": np.mean(pe_values) if pe_values else np.nan,
  2381. "avg_pli": np.mean(pli_values) if pli_values else np.nan,
  2382. "avg_plt": np.mean(plt_values) if plt_values else np.nan,
  2383. "avg_aec": np.mean(aec_values) if aec_values else np.nan,
  2384. "avg_sampen": np.mean(sampen_values) if sampen_values else np.nan,
  2385. "avg_apen": np.mean(apen_values) if apen_values else np.nan,
  2386. "avg_lzc": np.mean(lzc_values) if lzc_values else np.nan,
  2387. "n_epochs": len(epoch_files),
  2388. "used_epochs": ", ".join(used_epoch_indices),
  2389. "channel_names": channel_names if channel_names else [],
  2390. "channel_averages": channel_averages,
  2391. }
  2392. # Average MST values for wide format
  2393. for mst_dict, prefix in [(aec_mst_values, "aec"), (pli_mst_values, "pli"), (plt_mst_values, "plt")]:
  2394. for measure in MST_MEASURES:
  2395. if mst_dict[measure]:
  2396. results[f"{prefix}_mst_{measure}"] = mst_dict[measure][0] if (prefix == "aec" and concat_aecc) else np.mean(mst_dict[measure])
  2397. results[f"{prefix}_mst_{measure}_valid_epochs"] = 1 if (prefix == "aec" and concat_aecc) else len(mst_dict[measure])
  2398. else:
  2399. results[f"{prefix}_mst_{measure}"] = np.nan
  2400. results[f"{prefix}_mst_{measure}_valid_epochs"] = 0
  2401. results["aec_mst_successful_epochs"] = successful_mst_epochs
  2402. results["aec_mst_total_epochs"] = 1 if concat_aecc else len(epoch_files)
  2403. results["pli_mst_successful_epochs"] = len(pli_mst_values["degree"]) if pli_mst_values["degree"] else 0
  2404. results["pli_mst_total_epochs"] = len(epoch_files)
  2405. results["plt_mst_successful_epochs"] = len(plt_mst_values["degree"]) if plt_mst_values["degree"] else 0
  2406. results["plt_mst_total_epochs"] = len(epoch_files)
  2407. if calc_power and power_values:
  2408. for band_name in FREQUENCY_BANDS:
  2409. results[f"{band_name}_abs_power"] = np.mean(power_values[f"{band_name}_abs_power"]) if power_values[f"{band_name}_abs_power"] else np.nan
  2410. results[f"{band_name}_rel_power"] = np.mean(power_values[f"{band_name}_rel_power"]) if power_values[f"{band_name}_rel_power"] else np.nan
  2411. if calc_peak:
  2412. results["peak_frequency"] = np.mean(power_values["peak_frequency"]) if power_values["peak_frequency"] else np.nan
  2413. results["channels_without_peak"] = np.mean(power_values["channels_without_peak"]) if power_values["channels_without_peak"] else np.nan
  2414. if calc_sv:
  2415. for band_name in FREQUENCY_BANDS:
  2416. results[f"sv_{band_name}"] = sv_values.get(f"sv_{band_name}", np.nan)
  2417. return subject, condition, results, epoch_level_results
  2418. except Exception:
  2419. logger.exception(f"Error processing {subject} - {condition}")
  2420. return subject, condition, {"n_epochs": 0, "used_epochs": "Error", "channel_names": [], "channel_averages": None}, []
  2421. def process_all_subjects(
  2422. grouped_files, invert, n_threads, calc_jpe, calc_pli, calc_pli_mst, calc_aec,
  2423. use_aecc, force_positive=True, jpe_st=1, calc_aec_mst=False, calc_power=False,
  2424. power_fs=256, calc_peak=False, peak_min=3, peak_max=13, calc_sampen=False,
  2425. sampen_m=2, calc_apen=False, apen_m=1, apen_r=0.25, calc_lzc=False,
  2426. lzc_threshold="median", calc_sv=False, sv_window=1000, save_matrices=False,
  2427. save_mst=False, save_channel_averages=False, concat_aecc=False, has_headers=True,
  2428. psd_method="multitaper", welch_window_ms=1000, welch_overlap=50, calc_plt=False,
  2429. plt_threshold_ms=30, calc_plt_mst=False, limit_epochs=False, max_epochs=5,
  2430. random_seed=111, progress_callback=None, matrix_folders=None, save_epoch_metrics=True,
  2431. mt_bandwidth=None,
  2432. save_psd_avg=False, save_psd_perchannel=False, save_psd_plots=False, psd_folder=None,
  2433. total_power_range=None,
  2434. ):
  2435. process_args = []
  2436. for subject, conditions in grouped_files.items():
  2437. for condition, epoch_files in conditions.items():
  2438. files_to_process = epoch_files
  2439. if limit_epochs and len(epoch_files) > max_epochs:
  2440. sorted_files = sorted(epoch_files)
  2441. subject_specific_seed = random_seed + sum(ord(c) for c in str(subject))
  2442. rng = random.Random(subject_specific_seed)
  2443. files_to_process = sorted(rng.sample(sorted_files, max_epochs))
  2444. process_args.append((
  2445. subject, condition, files_to_process, invert, calc_jpe, calc_pli, calc_pli_mst,
  2446. calc_aec, use_aecc, force_positive, jpe_st, calc_aec_mst, calc_power,
  2447. power_fs, calc_peak, peak_min, peak_max, calc_sampen, sampen_m, calc_apen,
  2448. apen_m, apen_r, calc_sv, sv_window, save_matrices, save_mst,
  2449. save_channel_averages, concat_aecc, has_headers, psd_method,
  2450. welch_window_ms, welch_overlap, calc_plt, plt_threshold_ms,
  2451. calc_lzc, lzc_threshold, calc_plt_mst, matrix_folders, save_epoch_metrics,
  2452. mt_bandwidth,
  2453. save_psd_avg, save_psd_perchannel, save_psd_plots, psd_folder,
  2454. total_power_range,
  2455. ))
  2456. total_tasks = len(process_args)
  2457. results = defaultdict(dict)
  2458. all_epoch_results = []
  2459. completed = 0
  2460. for i in range(0, len(process_args), BATCH_SIZE):
  2461. batch = process_args[i : i + BATCH_SIZE]
  2462. batch_results = process_batch(batch, n_threads)
  2463. for subject, condition, result, epoch_results in batch_results:
  2464. results[subject][condition] = result
  2465. all_epoch_results.extend(epoch_results)
  2466. completed += 1
  2467. if progress_callback: progress_callback(completed / total_tasks * 100)
  2468. return dict(results), all_epoch_results
  2469. def process_batch(batch_args, n_threads):
  2470. """Process a batch of subjects using multiprocessing with fallback."""
  2471. try:
  2472. with Pool(processes=n_threads, maxtasksperchild=1) as pool:
  2473. return list(pool.imap_unordered(process_subject_condition, batch_args))
  2474. except Exception:
  2475. logger.exception("Pool processing failed, falling back to single thread")
  2476. return [process_subject_condition(args) for args in batch_args]
  2477. def group_epochs_by_condition(folder_path, folder_ext):
  2478. """Group epoch files by their base name and condition.
  2479. Only processes folders containing valid epoch files
  2480. Returns a dictionary: {base_name: {condition: [epoch_files]}}.
  2481. """
  2482. grouped_files = defaultdict(lambda: defaultdict(list))
  2483. # Get immediate subdirectories
  2484. try:
  2485. subdirs = [
  2486. d for d in os.listdir(folder_path) if os.path.isdir(os.path.join(folder_path, d)) and d.endswith(folder_ext)
  2487. ]
  2488. except Exception:
  2489. sg.popup_error("Error accessing directory")
  2490. return grouped_files
  2491. if not subdirs:
  2492. sg.popup_error(f"No folders ending with '{folder_ext}' found in the selected directory.")
  2493. return grouped_files
  2494. for subdir in subdirs:
  2495. subdir_path = os.path.join(folder_path, subdir)
  2496. # Look for epoch files in this directory
  2497. for file in os.listdir(subdir_path):
  2498. if file.startswith((".", "._")):
  2499. continue
  2500. if not file.endswith(".txt"):
  2501. continue
  2502. # Check if file matches epoch pattern
  2503. is_legacy_format = "_level_" in file
  2504. is_new_atlas_format = bool(
  2505. re.search(r"_(Source|Sensor)_(beamformer|sLORETA|eLORETA|dSPM|MNE)_(desikan|bna|aal2|aal3)_", file, re.IGNORECASE)
  2506. or re.search(r"_(Source|Sensor)_(desikan|bna|aal2|aal3)_(cortical|full)_", file, re.IGNORECASE)
  2507. )
  2508. if (is_legacy_format or is_new_atlas_format) and ("_Epoch_" in file or "_Epoch" in file):
  2509. try:
  2510. file_info = parse_epoch_filename(file)
  2511. full_path = os.path.join(subdir_path, file)
  2512. base_name = subdir.replace(folder_ext, "") # Use folder name without extension
  2513. condition = file_info["condition"]
  2514. grouped_files[base_name][condition].append(full_path)
  2515. except Exception:
  2516. print(f"Skipping file {file}")
  2517. continue
  2518. # Print summary of what was found
  2519. print("\nFound the following data:")
  2520. for base_name, conditions in grouped_files.items():
  2521. print(f"\nSubject: {base_name}")
  2522. for condition, files in conditions.items():
  2523. print(f" {condition}: {len(files)} epochs")
  2524. found_bands = set()
  2525. unknown_conditions = set()
  2526. has_broadband = False
  2527. for conditions in grouped_files.values():
  2528. for condition in conditions:
  2529. band = extract_freq_band(condition)
  2530. if band != "unknown":
  2531. found_bands.add(band)
  2532. if band == "broadband":
  2533. has_broadband = True
  2534. else:
  2535. unknown_conditions.add(condition)
  2536. logger.info("Found the following frequency bands in the data:")
  2537. for band in sorted(found_bands):
  2538. logger.info(f" - {band} ({FREQUENCY_BANDS[band]['pattern']})")
  2539. if has_broadband:
  2540. logger.info("Broadband epochs are present - spectral calculations will be performed on these epochs")
  2541. else:
  2542. logger.warning("No broadband epochs found - spectral calculations will be skipped")
  2543. if unknown_conditions:
  2544. logger.warning("Found conditions with unrecognized frequency bands:")
  2545. for cond in sorted(unknown_conditions):
  2546. logger.warning(f" - {cond}")
  2547. return grouped_files
  2548. def save_results_to_excel(
  2549. results_dict,
  2550. output_path,
  2551. invert,
  2552. calc_pli_mst,
  2553. epoch_level_data=None,
  2554. calc_jpe=True,
  2555. calc_pli=True,
  2556. calc_plt=False,
  2557. calc_plt_mst=False,
  2558. calc_aec=False,
  2559. use_aecc=False,
  2560. force_positive=True,
  2561. calc_aec_mst=False,
  2562. calc_power=False,
  2563. power_fs=256,
  2564. calc_peak=False,
  2565. peak_min=None,
  2566. peak_max=None,
  2567. calc_sampen=False,
  2568. calc_apen=False,
  2569. calc_lzc=False,
  2570. lzc_threshold="median",
  2571. calc_sv=False,
  2572. save_channel_averages=False,
  2573. concat_aecc=False,
  2574. has_headers=True,
  2575. sv_window=None,
  2576. psd_method="multitaper",
  2577. welch_window_ms=None,
  2578. welch_overlap=None,
  2579. mt_bandwidth=None,
  2580. plt_threshold_ms=None,
  2581. save_psd_avg=False,
  2582. save_psd_perchannel=False,
  2583. save_psd_plots=False,
  2584. total_power_range=None,
  2585. reference_range_source=None,
  2586. ):
  2587. """
  2588. Save results to Excel with organized columns by condition.
  2589. - Moves 'used_epochs' to a specific Metadata sheet.
  2590. - Ensures epoch numbers are sorted numerically (1, 2, 10) instead of alphabetically (1, 10, 2).
  2591. """
  2592. with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
  2593. # 1) Gather all unique conditions
  2594. all_conditions = set()
  2595. for subject_data in results_dict.values():
  2596. all_conditions.update(subject_data.keys())
  2597. # 2) Build a list of primary columns; start with 'subject'
  2598. columns = ["subject"]
  2599. for condition in sorted(all_conditions):
  2600. # --- Complexity Measures (JPE/PE) ---
  2601. if calc_jpe:
  2602. measure_name = "jpe_inv" if invert else "jpe"
  2603. columns.append(f"{condition}_avg_{measure_name}")
  2604. columns.append(f"{condition}_avg_pe")
  2605. # --- PLI ---
  2606. if calc_pli:
  2607. columns.append(f"{condition}_avg_pli")
  2608. if calc_pli_mst:
  2609. mst_measures = [
  2610. "degree", "eccentr", "betweenness", "kappa", "r",
  2611. "diameter", "leaf", "hierarchy", "teff", "asp", "ref", "mean",
  2612. ]
  2613. for mm in mst_measures:
  2614. columns.append(f"{condition}_pli_mst_{mm}")
  2615. columns.append(f"{condition}_pli_mst_successful_epochs")
  2616. columns.append(f"{condition}_pli_mst_total_epochs")
  2617. # --- PLT ---
  2618. if calc_plt:
  2619. columns.append(f"{condition}_avg_plt")
  2620. if calc_plt_mst:
  2621. mst_measures = [
  2622. "degree", "eccentr", "betweenness", "kappa", "r",
  2623. "diameter", "leaf", "hierarchy", "teff", "asp", "ref", "mean",
  2624. ]
  2625. for mm in mst_measures:
  2626. columns.append(f"{condition}_plt_mst_{mm}")
  2627. columns.append(f"{condition}_plt_mst_successful_epochs")
  2628. columns.append(f"{condition}_plt_mst_total_epochs")
  2629. # --- AEC ---
  2630. if calc_aec:
  2631. columns.append(f"{condition}_avg_aec")
  2632. if calc_aec_mst:
  2633. mst_measures = [
  2634. "degree", "eccentr", "betweenness", "kappa", "r",
  2635. "diameter", "leaf", "hierarchy", "teff", "asp", "ref", "mean",
  2636. ]
  2637. for mm in mst_measures:
  2638. columns.append(f"{condition}_aec_mst_{mm}")
  2639. columns.append(f"{condition}_aec_mst_successful_epochs")
  2640. columns.append(f"{condition}_aec_mst_total_epochs")
  2641. # --- SampEn, ApEn and LZC ---
  2642. if calc_sampen:
  2643. columns.append(f"{condition}_avg_sampen")
  2644. if calc_apen:
  2645. columns.append(f"{condition}_avg_apen")
  2646. if calc_lzc:
  2647. columns.append(f"{condition}_avg_lzc")
  2648. is_broadband_cond = is_broadband_condition(condition)
  2649. # --- Power band measures - only for broadband conditions ---
  2650. if calc_power and is_broadband_cond:
  2651. for band_name in FREQUENCY_BANDS:
  2652. if band_name.lower() != "broadband": # Skip broadband
  2653. columns.extend([f"{condition}_{band_name}_abs_power", f"{condition}_{band_name}_rel_power"])
  2654. # --- Peak frequency ---
  2655. if calc_peak and is_broadband_cond:
  2656. columns.append(f"{condition}_peak_frequency")
  2657. columns.append(f"{condition}_channels_without_peak")
  2658. # --- Spectral Variability ---
  2659. if calc_sv and is_broadband_cond:
  2660. for band_name in FREQUENCY_BANDS:
  2661. if band_name.lower() == "broadband":
  2662. continue
  2663. columns.append(f"{condition}_sv_{band_name}")
  2664. # Always add epoch count for each condition
  2665. columns.append(f"{condition}_n_epochs")
  2666. # 3) Build rows of data
  2667. rows = []
  2668. for subject, conditions in results_dict.items():
  2669. row = {"subject": subject}
  2670. for condition in sorted(all_conditions):
  2671. data_for_condition = conditions.get(condition, {})
  2672. # JPE/PE measures
  2673. if calc_jpe:
  2674. measure_name = "jpe_inv" if invert else "jpe"
  2675. row[f"{condition}_avg_{measure_name}"] = data_for_condition.get("avg_jpe", np.nan)
  2676. row[f"{condition}_avg_pe"] = data_for_condition.get("avg_pe", np.nan)
  2677. # PLI
  2678. if calc_pli:
  2679. row[f"{condition}_avg_pli"] = data_for_condition.get("avg_pli", np.nan)
  2680. if calc_pli_mst:
  2681. mst_measures = [
  2682. "degree", "eccentr", "betweenness", "kappa", "r",
  2683. "diameter", "leaf", "hierarchy", "teff", "asp", "ref", "mean",
  2684. ]
  2685. for mm in mst_measures:
  2686. row[f"{condition}_pli_mst_{mm}"] = data_for_condition.get(f"pli_mst_{mm}", np.nan)
  2687. row[f"{condition}_pli_mst_successful_epochs"] = data_for_condition.get("pli_mst_successful_epochs", np.nan)
  2688. row[f"{condition}_pli_mst_total_epochs"] = data_for_condition.get("pli_mst_total_epochs", np.nan)
  2689. # PLT
  2690. if calc_plt:
  2691. row[f"{condition}_avg_plt"] = data_for_condition.get("avg_plt", np.nan)
  2692. if calc_plt_mst:
  2693. mst_measures = [
  2694. "degree", "eccentr", "betweenness", "kappa", "r",
  2695. "diameter", "leaf", "hierarchy", "teff", "asp", "ref", "mean",
  2696. ]
  2697. for mm in mst_measures:
  2698. row[f"{condition}_plt_mst_{mm}"] = data_for_condition.get(f"plt_mst_{mm}", np.nan)
  2699. row[f"{condition}_plt_mst_successful_epochs"] = data_for_condition.get("plt_mst_successful_epochs", np.nan)
  2700. row[f"{condition}_plt_mst_total_epochs"] = data_for_condition.get("plt_mst_total_epochs", np.nan)
  2701. # AEC
  2702. if calc_aec:
  2703. row[f"{condition}_avg_aec"] = data_for_condition.get("avg_aec", np.nan)
  2704. if calc_aec_mst:
  2705. mst_measures = [
  2706. "degree", "eccentr", "betweenness", "kappa", "r",
  2707. "diameter", "leaf", "hierarchy", "teff", "asp", "ref", "mean",
  2708. ]
  2709. for mm in mst_measures:
  2710. row[f"{condition}_aec_mst_{mm}"] = data_for_condition.get(f"aec_mst_{mm}", np.nan)
  2711. row[f"{condition}_aec_mst_successful_epochs"] = data_for_condition.get("aec_mst_successful_epochs", np.nan)
  2712. row[f"{condition}_aec_mst_total_epochs"] = data_for_condition.get("aec_mst_total_epochs", np.nan)
  2713. # SampEn, ApEn and LZC
  2714. if calc_sampen:
  2715. row[f"{condition}_avg_sampen"] = data_for_condition.get("avg_sampen", np.nan)
  2716. if calc_apen:
  2717. row[f"{condition}_avg_apen"] = data_for_condition.get("avg_apen", np.nan)
  2718. if calc_lzc:
  2719. row[f"{condition}_avg_lzc"] = data_for_condition.get("avg_lzc", np.nan)
  2720. # Power & Peak Frequency
  2721. is_broadband_cond = is_broadband_condition(condition)
  2722. if calc_power and is_broadband_cond:
  2723. for band_name in FREQUENCY_BANDS:
  2724. if band_name.lower() == "broadband":
  2725. continue
  2726. abs_key = f"{band_name}_abs_power"
  2727. rel_key = f"{band_name}_rel_power"
  2728. row[f"{condition}_{band_name}_abs_power"] = data_for_condition.get(abs_key, np.nan)
  2729. row[f"{condition}_{band_name}_rel_power"] = data_for_condition.get(rel_key, np.nan)
  2730. if calc_peak and is_broadband_cond:
  2731. row[f"{condition}_peak_frequency"] = data_for_condition.get("peak_frequency", np.nan)
  2732. row[f"{condition}_channels_without_peak"] = data_for_condition.get("channels_without_peak", np.nan)
  2733. if calc_sv and is_broadband_cond:
  2734. for band_name in FREQUENCY_BANDS:
  2735. if band_name.lower() == "broadband":
  2736. continue
  2737. sv_key = f"sv_{band_name}"
  2738. row[f"{condition}_sv_{band_name}"] = data_for_condition.get(sv_key, np.nan)
  2739. # n_epochs
  2740. row[f"{condition}_n_epochs"] = data_for_condition.get("n_epochs", 0)
  2741. rows.append(row)
  2742. # 4) Create the DataFrame, reorder columns, and export
  2743. df = pd.DataFrame(rows)
  2744. df = df[columns] # Force the column order we built above
  2745. df.to_excel(writer, sheet_name="Whole Brain Results", index=False)
  2746. # Add analysis information sheet
  2747. info_data = {
  2748. "Parameter": [
  2749. "Analysis Date",
  2750. "JPE Inversion",
  2751. "PLI MST Calculated",
  2752. "AEC Type",
  2753. "AEC Concatenated Epochs",
  2754. "AEC MST Calculated",
  2755. "PLT Calculated",
  2756. "PLT MST Calculated",
  2757. "PLT Threshold (ms)",
  2758. "AEC Force Positive",
  2759. "Power Bands Calculated",
  2760. "Relative Power Reference Range (Hz)",
  2761. "Reference Range Source",
  2762. "Sampling Frequency (Hz)",
  2763. "PSD Method",
  2764. "Welch Window Length (ms)",
  2765. "Welch Overlap (%)",
  2766. "Multitaper Bandwidth (Hz)",
  2767. "Peak Frequency Analysis",
  2768. "Peak Frequency Range (Hz)",
  2769. "Sample Entropy Calculated",
  2770. "Approximate Entropy Calculated",
  2771. "Lempel-Ziv Complexity Calculated",
  2772. "LZC Binarization Threshold",
  2773. "Spectral Variability Calculated",
  2774. "Spectral Variability Window (ms)",
  2775. "Channel Averages Calculated",
  2776. "Channel Names Source",
  2777. "PSD Averaged CSV Saved",
  2778. "PSD Per-Channel CSV Saved",
  2779. "PSD Plots Saved",
  2780. ],
  2781. "Value": [
  2782. datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  2783. "Yes" if invert else "No",
  2784. "Yes" if calc_pli_mst else "No",
  2785. "AECc (orthogonalized)" if calc_aec and use_aecc else "AEC" if calc_aec else "Not calculated",
  2786. "Yes" if concat_aecc else "No",
  2787. "Yes" if calc_aec_mst else "No",
  2788. "Yes" if calc_plt else "No",
  2789. "Yes" if calc_plt_mst else "No",
  2790. str(plt_threshold_ms) if calc_plt else "N/A",
  2791. "Yes" if force_positive else "No",
  2792. "Yes" if calc_power else "No",
  2793. (
  2794. f"{total_power_range[0]:g}-{total_power_range[1]:g}"
  2795. if (calc_power or calc_sv) and total_power_range is not None
  2796. else "N/A"
  2797. ),
  2798. (
  2799. reference_range_source
  2800. if (calc_power or calc_sv) and reference_range_source
  2801. else "N/A"
  2802. ),
  2803. str(power_fs),
  2804. psd_method,
  2805. str(welch_window_ms) if psd_method == "welch" else "N/A",
  2806. str(welch_overlap) if psd_method == "welch" else "N/A",
  2807. ("Auto (8 x sfreq/n_samples)" if mt_bandwidth is None else str(mt_bandwidth))
  2808. if psd_method == "multitaper" else "N/A",
  2809. "Yes" if calc_peak else "No",
  2810. f"{peak_min}-{peak_max}" if calc_peak else "N/A",
  2811. "Yes" if calc_sampen else "No",
  2812. "Yes" if calc_apen else "No",
  2813. "Yes" if calc_lzc else "No",
  2814. str(lzc_threshold) if calc_lzc else "N/A",
  2815. "Yes" if calc_sv else "No",
  2816. str(sv_window) if calc_sv else "N/A",
  2817. "Yes" if save_channel_averages else "No",
  2818. "File Headers" if has_headers else "Auto-generated",
  2819. "Yes" if save_psd_avg else "No",
  2820. "Yes" if save_psd_perchannel else "No",
  2821. "Yes" if save_psd_plots else "No",
  2822. ],
  2823. }
  2824. info_df = pd.DataFrame(info_data)
  2825. info_df.to_excel(writer, sheet_name="Analysis Information", index=False)
  2826. # Save channel-level averages if requested
  2827. if save_channel_averages:
  2828. all_channels = set()
  2829. for conditions in results_dict.values():
  2830. for result in conditions.values():
  2831. if result.get("channel_averages"):
  2832. all_channels.update(result["channel_averages"].keys())
  2833. channel_rows = []
  2834. measures_with_data = set()
  2835. for subject, conditions in results_dict.items():
  2836. for channel in sorted(all_channels):
  2837. row = {"subject": subject, "channel": channel}
  2838. for condition in sorted(all_conditions):
  2839. if condition in conditions and conditions[condition].get("channel_averages"):
  2840. channel_data = conditions[condition]["channel_averages"].get(channel, {})
  2841. for measure, value in channel_data.items():
  2842. column_name = f"{condition}_{measure}"
  2843. row[column_name] = value
  2844. if not pd.isna(value):
  2845. measures_with_data.add(column_name)
  2846. channel_rows.append(row)
  2847. if channel_rows:
  2848. df_channels = pd.DataFrame(channel_rows)
  2849. base_cols = ["subject", "channel"]
  2850. data_cols = sorted(measures_with_data, key=lambda x: (x.split("_")[0], x))
  2851. column_order = base_cols + data_cols
  2852. df_channels = df_channels[column_order]
  2853. df_channels.to_excel(writer, sheet_name="Channel Averages", index=False)
  2854. # --- Save metadata ---
  2855. metadata_rows = []
  2856. for subject, conditions in results_dict.items():
  2857. for condition in sorted(all_conditions):
  2858. if condition in conditions:
  2859. # Get the string (e.g., "1, 10, 2")
  2860. used_str = conditions[condition].get("used_epochs", "")
  2861. # --- Sort the epochs numerically ---
  2862. if used_str:
  2863. try:
  2864. # Split, convert to integers for proper sorting, then back to string
  2865. # We use (0, int(x)) for numbers and (1, x) for non-numbers to handle "?" gracefully
  2866. parts = [p.strip() for p in used_str.split(',')]
  2867. parts.sort(key=lambda x: (0, int(x)) if x.isdigit() else (1, x))
  2868. used_str = ", ".join(parts)
  2869. except Exception:
  2870. pass # Keep original string if logic fails
  2871. # ----------------------------------------
  2872. meta_row = {
  2873. "subject": subject,
  2874. "condition": condition,
  2875. "n_epochs_used": conditions[condition].get("n_epochs", 0),
  2876. "used_epochs": used_str,
  2877. }
  2878. if "channel_names" in conditions[condition]:
  2879. meta_row["n_channels"] = len(conditions[condition]["channel_names"])
  2880. meta_row["channels"] = ", ".join(conditions[condition]["channel_names"])
  2881. metadata_rows.append(meta_row)
  2882. if metadata_rows:
  2883. metadata_df = pd.DataFrame(metadata_rows)
  2884. desired_cols = ["subject", "condition", "n_epochs_used", "used_epochs", "n_channels", "channels"]
  2885. existing_cols = [c for c in desired_cols if c in metadata_df.columns]
  2886. metadata_df = metadata_df[existing_cols]
  2887. metadata_df.to_excel(writer, sheet_name="Epoch & Channel Info", index=False)
  2888. # --- Save Per-Epoch Metrics (Long Format) ---
  2889. if epoch_level_data:
  2890. df_epochs = pd.DataFrame(epoch_level_data)
  2891. # Reorder columns to ensure Subject, Condition, Epoch are first
  2892. base_cols = ['Subject', 'Condition', 'Epoch']
  2893. existing_base = [c for c in base_cols if c in df_epochs.columns]
  2894. other_cols = [c for c in df_epochs.columns if c not in existing_base]
  2895. df_epochs = df_epochs[existing_base + sorted(other_cols)]
  2896. # Sort rows logically. imap_unordered returns conditions in completion order,
  2897. # and within a condition rows follow filesystem order, so the raw frame is
  2898. # unordered. Sort by Subject, Condition, then numeric epoch number; any
  2899. # non-numeric label (e.g. "Concatenated") sorts last within its condition.
  2900. if {'Subject', 'Condition', 'Epoch'}.issubset(df_epochs.columns):
  2901. epoch_nums = df_epochs['Epoch'].astype(str).str.extract(r'(\d+)')[0]
  2902. df_epochs['_is_special'] = epoch_nums.isna().astype(int)
  2903. df_epochs['_epoch_num'] = pd.to_numeric(epoch_nums, errors='coerce')
  2904. df_epochs = (
  2905. df_epochs
  2906. .sort_values(by=['Subject', 'Condition', '_is_special', '_epoch_num'])
  2907. .drop(columns=['_is_special', '_epoch_num'])
  2908. .reset_index(drop=True)
  2909. )
  2910. df_epochs.to_excel(writer, sheet_name="Epoch-Level Results", index=False)
  2911. logger.info(f"Results saved to: {output_path}")
  2912. print(f"\nResults saved to {output_path}")
  2913. def main():
  2914. """
  2915. Main entry point for the EEG-Pype quantitative analysis GUI.
  2916. Handles the event loop, thorough parameter validation, directory creation,
  2917. multiprocessing execution, and final result compilation.
  2918. """
  2919. window = create_gui()
  2920. # Prime the live frequency-resolution readout with the default field values.
  2921. initial_event, initial_values = window.read(timeout=0)
  2922. if initial_values is not None:
  2923. window["-FREQ_RES-"].update(compute_resolution_readout(initial_values))
  2924. # Tracks the last folder we probed for a preprocessing pass-band, so that
  2925. # typing a path by hand does not re-read the config on every keystroke.
  2926. last_passband_folder = None
  2927. # --- GUI Event Loop ---
  2928. while True:
  2929. event, values = window.read()
  2930. if event in (sg.WIN_CLOSED, "Exit"):
  2931. break
  2932. # Live-update the frequency-resolution readout when any relevant field changes.
  2933. if event in ("-PSD_METHOD-", "-POWER_FS-", "-EPOCH_LENGTH-", "-WELCH_WINDOW-", "-MT_BANDWIDTH-"):
  2934. window["-FREQ_RES-"].update(compute_resolution_readout(values))
  2935. window.refresh()
  2936. # When a new folder is picked, seed the relative-power reference range
  2937. # from the pass-band the preprocessing script recorded, if we can find it.
  2938. if event == "-FOLDER-":
  2939. candidate = values["-FOLDER-"]
  2940. if candidate and candidate != last_passband_folder and os.path.isdir(candidate):
  2941. last_passband_folder = candidate
  2942. detected = discover_preprocessing_passband(candidate)
  2943. if detected is not None:
  2944. window["-TOTAL_MIN-"].update(f"{detected['low']:g}")
  2945. window["-TOTAL_MAX-"].update(f"{detected['high']:g}")
  2946. window["-PASSBAND_INFO-"].update(
  2947. f"Detected pass-band: {detected['low']:g}-{detected['high']:g} Hz",
  2948. text_color="#1A6B3C",
  2949. )
  2950. else:
  2951. window["-PASSBAND_INFO-"].update(
  2952. "No unambiguous preprocessing pass-band found",
  2953. text_color="#8A6D1F",
  2954. )
  2955. window.refresh()
  2956. if event == "Process":
  2957. # --- 1. Basic Setup & File Path Extraction ---
  2958. folder_path = values["-FOLDER-"]
  2959. folder_ext = values["-EXTENSION-"].strip()
  2960. # Initialize logging immediately for this run
  2961. log_file = setup_logging(folder_path)
  2962. logger.info("=== Starting new analysis run ===")
  2963. logger.info(f"Folder path: {folder_path}")
  2964. logger.info(f"Extension: {folder_ext}")
  2965. logger.info(f"Processing files with{'out' if not values['-HAS_HEADERS-'] else ''} headers")
  2966. if not values["-HAS_HEADERS-"]:
  2967. logger.info("Channel names will be auto-generated")
  2968. if not folder_path or not folder_ext:
  2969. sg.popup_error("Please select a folder and specify the folder extension.")
  2970. continue
  2971. try:
  2972. validate_frequency_bands()
  2973. except ValueError:
  2974. sg.popup_error("Invalid frequency band configuration in FREQUENCY_BANDS.")
  2975. continue
  2976. # --- 2. Parameter Extraction & Validation ---
  2977. # Threads
  2978. try:
  2979. n_threads = int(values["-THREADS-"])
  2980. if n_threads < 1 or n_threads > cpu_count():
  2981. raise ValueError(f"Number of threads must be between 1 and {cpu_count()}")
  2982. except ValueError:
  2983. sg.popup_error("Invalid number of threads.")
  2984. continue
  2985. # Matrix Export Configuration
  2986. save_matrices = values["-SAVE_MATRICES-"]
  2987. matrix_folder = values["-MATRIX_FOLDER-"]
  2988. save_mst = values["-SAVE_MST-"]
  2989. mst_folder = values["-MST_FOLDER-"]
  2990. save_epoch_metrics = values.get("-SAVE_EPOCH_METRICS-", True)
  2991. # PSD Export Configuration
  2992. save_psd_avg = values["-SAVE_PSD_AVG-"]
  2993. save_psd_perchannel = values["-SAVE_PSD_PERCHANNEL-"]
  2994. save_psd_plots = values["-SAVE_PSD_PLOTS-"]
  2995. psd_folder_name = values["-PSD_FOLDER-"].strip()
  2996. save_psd_any = save_psd_avg or save_psd_perchannel or save_psd_plots
  2997. if save_psd_any and not psd_folder_name:
  2998. sg.popup_error("Please specify a folder name for saving PSD output.")
  2999. continue
  3000. if save_matrices and not matrix_folder.strip():
  3001. sg.popup_error("Please specify a folder name for saving connectivity matrices.")
  3002. continue
  3003. if save_mst and not mst_folder.strip():
  3004. sg.popup_error("Please specify a folder name for saving MST matrices.")
  3005. continue
  3006. # Epoch Limiting Configuration
  3007. limit_epochs = values["-LIMIT_EPOCHS-"]
  3008. max_epochs = 5
  3009. random_seed = 111
  3010. if limit_epochs:
  3011. try:
  3012. max_epochs = int(values["-MAX_EPOCHS-"])
  3013. random_seed = int(values["-SEED-"])
  3014. if max_epochs < 1:
  3015. raise ValueError("Max epochs must be at least 1")
  3016. except ValueError:
  3017. sg.popup_error("Invalid Max Epochs or Seed value.")
  3018. continue
  3019. # Spectral & Welch Parameters
  3020. psd_method = values["-PSD_METHOD-"].lower()
  3021. welch_window_ms = None
  3022. welch_overlap = None
  3023. if psd_method == "welch":
  3024. try:
  3025. welch_window_ms = float(values["-WELCH_WINDOW-"])
  3026. welch_overlap = float(values["-WELCH_OVERLAP-"])
  3027. if welch_window_ms <= 0:
  3028. raise ValueError("Welch window length must be greater than 0")
  3029. if not 0 <= welch_overlap < 100:
  3030. raise ValueError("Welch overlap must be between 0 and 100")
  3031. except ValueError:
  3032. sg.popup_error("Invalid Welch parameters.")
  3033. continue
  3034. # Multitaper bandwidth (Hz). "Auto"/blank -> None (MNE default).
  3035. mt_bandwidth = None
  3036. if psd_method == "multitaper":
  3037. bw_raw = values["-MT_BANDWIDTH-"].strip()
  3038. if bw_raw and bw_raw.lower() != "auto":
  3039. try:
  3040. mt_bandwidth = float(bw_raw)
  3041. if mt_bandwidth <= 0:
  3042. raise ValueError("Bandwidth must be positive")
  3043. except ValueError:
  3044. sg.popup_error("Invalid multitaper bandwidth. Use a positive number or 'Auto'.")
  3045. continue
  3046. # Guardrail: check implied taper count against the entered epoch length.
  3047. if mt_bandwidth is not None and (values["-CALC_POWER-"] or values["-CALC_PEAK-"]
  3048. or save_psd_avg or save_psd_perchannel or save_psd_plots):
  3049. epoch_raw = values["-EPOCH_LENGTH-"].strip()
  3050. if epoch_raw:
  3051. try:
  3052. epoch_sec = float(epoch_raw)
  3053. implied_tapers = int(math.floor(epoch_sec * mt_bandwidth - 1))
  3054. if implied_tapers < 1:
  3055. if sg.popup_yes_no(
  3056. f"Bandwidth {mt_bandwidth} Hz with epoch length {epoch_sec} s "
  3057. f"yields {implied_tapers} tapers — multitaper will likely fail.\n\n"
  3058. "Proceed anyway?",
  3059. title="Bandwidth too small",
  3060. ) != "Yes":
  3061. continue
  3062. elif implied_tapers < 3:
  3063. logger.warning(
  3064. f"Multitaper bandwidth {mt_bandwidth} Hz with epoch length "
  3065. f"{epoch_sec} s yields only {implied_tapers} taper(s)."
  3066. )
  3067. except ValueError:
  3068. pass # Non-critical; skip guardrail if epoch length is unparseable
  3069. power_fs = 256
  3070. try:
  3071. power_fs = float(values["-POWER_FS-"])
  3072. if power_fs <= 0:
  3073. raise ValueError("Sampling frequency must be greater than 0")
  3074. except ValueError:
  3075. sg.popup_error("Invalid sampling frequency value.")
  3076. continue
  3077. # Relative-power reference range (denominator for rel. power and SV)
  3078. total_power_range = (
  3079. FREQUENCY_BANDS["broadband"]["range"] if "broadband" in FREQUENCY_BANDS else (0.5, 47.0)
  3080. )
  3081. passband = None
  3082. reference_range_source = None
  3083. was_clamped = False
  3084. if values["-CALC_POWER-"] or values["-CALC_SV-"]:
  3085. # Prefer the pass-band the preprocessing script actually applied
  3086. # over the much weaker Nyquist bound.
  3087. passband = discover_preprocessing_passband(folder_path)
  3088. ceiling, ceiling_reason = effective_reference_ceiling(
  3089. power_fs, psd_method, passband
  3090. )
  3091. try:
  3092. total_min = float(values["-TOTAL_MIN-"])
  3093. total_max = float(values["-TOTAL_MAX-"])
  3094. if total_min < 0:
  3095. raise ValueError("Reference minimum must be >= 0 Hz.")
  3096. if total_min >= total_max:
  3097. raise ValueError("Reference minimum must be below the reference maximum.")
  3098. if total_max > power_fs / 2:
  3099. raise ValueError(
  3100. f"Reference maximum must not exceed Nyquist ({power_fs / 2:g} Hz)."
  3101. )
  3102. except ValueError as exc:
  3103. sg.popup_error(f"Invalid relative-power reference range.\n\n{exc}")
  3104. continue
  3105. if total_max > ceiling:
  3106. if total_min >= ceiling:
  3107. sg.popup_error(
  3108. "Invalid relative-power reference range.\n\n"
  3109. f"The reference minimum is {total_min:g} Hz, but the "
  3110. f"highest usable frequency is {ceiling:g} Hz "
  3111. f"because of the {ceiling_reason}.\n\n"
  3112. "Choose a lower reference minimum."
  3113. )
  3114. continue
  3115. detail = (
  3116. passband["detail"]
  3117. if passband is not None
  3118. else None
  3119. )
  3120. message = (
  3121. f"The reference range you entered ({total_min:g}-{total_max:g} Hz) "
  3122. f"extends beyond the {ceiling_reason}.\n\n"
  3123. )
  3124. if detail:
  3125. message += f"Detected from: {detail}.\n\n"
  3126. message += (
  3127. f"Above {ceiling:g} Hz the data contains only filter roll-off, so "
  3128. f"including it dilutes every relative power value.\n\n"
  3129. f"Clamp the reference range to {total_min:g}-{ceiling:g} Hz?"
  3130. )
  3131. choice = sg.popup_yes_no(message, title="Reference range exceeds usable band")
  3132. if choice == "Yes":
  3133. total_max = ceiling
  3134. was_clamped = True
  3135. window["-TOTAL_MAX-"].update(f"{total_max:g}")
  3136. logger.info(
  3137. f"Reference range clamped to {total_min:g}-{total_max:g} Hz "
  3138. f"({ceiling_reason})."
  3139. )
  3140. else:
  3141. logger.warning(
  3142. f"Reference range {total_min:g}-{total_max:g} Hz exceeds the "
  3143. f"{ceiling_reason}; relative power will include out-of-band content."
  3144. )
  3145. if passband is not None and total_min < passband["low"]:
  3146. logger.warning(
  3147. f"Reference minimum {total_min:g} Hz is below the preprocessing "
  3148. f"high-pass ({passband['low']:g} Hz); the bottom of the reference "
  3149. f"range contains attenuated signal only."
  3150. )
  3151. total_power_range = (total_min, total_max)
  3152. # Record where the bound came from, so the Excel metadata makes
  3153. # a clamped range traceable rather than silent.
  3154. if passband is None:
  3155. reference_range_source = (
  3156. "user-specified "
  3157. "(no unambiguous preprocessing pass-band found)"
  3158. )
  3159. else:
  3160. origin_label = {
  3161. "config": "batch config",
  3162. "sidecar": "fallback export metadata",
  3163. }.get(
  3164. passband["origin"],
  3165. str(passband["origin"]),
  3166. )
  3167. reference_range_source = (
  3168. f"pass-band {passband['low']:g}-{passband['high']:g} Hz "
  3169. f"from {origin_label}"
  3170. )
  3171. if was_clamped:
  3172. reference_range_source += "; range clamped to fit"
  3173. # Flag bands that stick out of the denominator.
  3174. outside = [
  3175. name for name, info in FREQUENCY_BANDS.items()
  3176. if name != "broadband"
  3177. and (info["range"][0] < total_min or info["range"][1] > total_max)
  3178. ]
  3179. if outside:
  3180. logger.warning(
  3181. f"Bands partly outside the reference range {total_min:g}-{total_max:g} Hz: "
  3182. f"{', '.join(outside)}. Their relative power will be misleading."
  3183. )
  3184. logger.info(f"Relative power reference range: {total_min:g}-{total_max:g} Hz")
  3185. peak_min = peak_max = None
  3186. if values["-CALC_PEAK-"]:
  3187. try:
  3188. peak_min = float(values["-PEAK_MIN-"])
  3189. peak_max = float(values["-PEAK_MAX-"])
  3190. if peak_min >= peak_max:
  3191. raise ValueError("Minimum frequency must be less than maximum")
  3192. if peak_min < 0 or peak_max > (power_fs / 2):
  3193. raise ValueError(f"Frequency range must be between 0 and {power_fs / 2} Hz")
  3194. except ValueError:
  3195. sg.popup_error("Invalid peak frequency range.")
  3196. continue
  3197. # Connectivity Parameters (PLT, JPE)
  3198. jpe_st = 1
  3199. if values["-CALC_JPE-"]:
  3200. try:
  3201. jpe_st = int(values["-JPE_ST-"])
  3202. if jpe_st < 1:
  3203. raise ValueError("Time step must be greater than 0")
  3204. except ValueError:
  3205. sg.popup_error("Invalid JPE time step value.")
  3206. continue
  3207. calc_plt = values.get("-CALC_PLT-", False)
  3208. plt_threshold_ms = 30
  3209. if calc_plt:
  3210. try:
  3211. plt_threshold_ms = float(values["-PLT_THRESH-"])
  3212. if plt_threshold_ms < 0:
  3213. raise ValueError("PLT Threshold must be positive")
  3214. except ValueError:
  3215. sg.popup_error("Invalid PLT Threshold value.")
  3216. continue
  3217. # Complexity Parameters
  3218. sampen_m = 2
  3219. if values["-CALC_SAMPEN-"]:
  3220. try:
  3221. sampen_m = int(values["-SAMPEN_M-"])
  3222. if sampen_m < 1:
  3223. raise ValueError("Order m must be greater than 0")
  3224. except ValueError:
  3225. sg.popup_error("Invalid SampEn order parameter.")
  3226. continue
  3227. apen_m = 1
  3228. apen_r = 0.25
  3229. if values["-CALC_APEN-"]:
  3230. try:
  3231. apen_m = int(values["-APEN_M-"])
  3232. apen_r = float(values["-APEN_R-"])
  3233. if apen_m <= 0 or apen_r <= 0:
  3234. raise ValueError("Order m and tolerance r must be greater than 0")
  3235. except ValueError:
  3236. sg.popup_error("Invalid ApEn parameter.")
  3237. continue
  3238. sv_window = None
  3239. if values["-CALC_SV-"]:
  3240. try:
  3241. sv_window = int(values["-SV_WINDOW-"])
  3242. if sv_window < MIN_WINDOW_SIZE:
  3243. raise ValueError(f"Window length must be at least {MIN_WINDOW_SIZE} ms")
  3244. except ValueError:
  3245. sg.popup_error("Invalid spectral variability window.")
  3246. continue
  3247. # --- 3. Directory Parsing & Setup ---
  3248. grouped_files = group_epochs_by_condition(folder_path, folder_ext)
  3249. if not grouped_files:
  3250. continue # Grouping function handles its own error popups
  3251. # Create destination folders BEFORE processing so workers can save to disk
  3252. matrix_folders = None
  3253. if save_matrices or save_mst:
  3254. matrix_folders = create_matrix_folder_structure(
  3255. folder_path, matrix_folder, mst_folder if save_mst else None
  3256. )
  3257. logger.info("Initialized output directories for per-epoch matrix saving.")
  3258. psd_folder = None
  3259. if save_psd_any:
  3260. psd_folder = os.path.join(folder_path, psd_folder_name)
  3261. os.makedirs(psd_folder, exist_ok=True)
  3262. logger.info(f"PSD export directory ready: {psd_folder}")
  3263. def update_progress(value):
  3264. window["-PROGRESS-"].update(value)
  3265. window.refresh()
  3266. # --- 4. Parallel Execution ---
  3267. try:
  3268. # Capture both wide-format (results) and long-format (epoch_level_data) output
  3269. results, epoch_level_data = process_all_subjects(
  3270. grouped_files,
  3271. invert=values["-INVERT-"],
  3272. n_threads=n_threads,
  3273. calc_jpe=values["-CALC_JPE-"],
  3274. calc_pli=values["-CALC_PLI-"],
  3275. calc_pli_mst=values["-CALC_PLI_MST-"],
  3276. calc_aec=values["-CALC_AEC-"],
  3277. use_aecc=values["-USE_AECC-"],
  3278. force_positive=values["-AEC_FORCE_POSITIVE-"],
  3279. concat_aecc=values["-CONCAT_AECC-"],
  3280. has_headers=values["-HAS_HEADERS-"],
  3281. jpe_st=jpe_st,
  3282. calc_aec_mst=values["-CALC_AEC_MST-"],
  3283. calc_power=values["-CALC_POWER-"],
  3284. power_fs=power_fs,
  3285. calc_peak=values["-CALC_PEAK-"],
  3286. peak_min=peak_min,
  3287. peak_max=peak_max,
  3288. calc_sampen=values["-CALC_SAMPEN-"],
  3289. sampen_m=sampen_m,
  3290. calc_apen=values["-CALC_APEN-"],
  3291. apen_m=apen_m,
  3292. apen_r=apen_r,
  3293. calc_lzc=values["-CALC_LZC-"],
  3294. lzc_threshold=values["-LZC_THRESH-"],
  3295. calc_sv=values["-CALC_SV-"],
  3296. sv_window=sv_window,
  3297. save_matrices=save_matrices,
  3298. save_mst=save_mst,
  3299. save_channel_averages=values["-SAVE_CHANNEL_AVERAGES-"],
  3300. psd_method=psd_method,
  3301. welch_window_ms=welch_window_ms,
  3302. welch_overlap=welch_overlap,
  3303. mt_bandwidth=mt_bandwidth,
  3304. calc_plt=calc_plt,
  3305. plt_threshold_ms=plt_threshold_ms,
  3306. calc_plt_mst=values["-CALC_PLT_MST-"],
  3307. progress_callback=update_progress,
  3308. limit_epochs=limit_epochs,
  3309. max_epochs=max_epochs,
  3310. random_seed=random_seed,
  3311. matrix_folders=matrix_folders, # Routes paths to workers
  3312. save_epoch_metrics=save_epoch_metrics, # Tells workers to collect row data
  3313. save_psd_avg=save_psd_avg,
  3314. save_psd_perchannel=save_psd_perchannel,
  3315. save_psd_plots=save_psd_plots,
  3316. psd_folder=psd_folder,
  3317. total_power_range=total_power_range,
  3318. )
  3319. # --- 5. Export Results ---
  3320. if results:
  3321. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  3322. output_path = os.path.join(folder_path, f"EEG_analysis_{timestamp}.xlsx")
  3323. try:
  3324. save_results_to_excel(
  3325. results,
  3326. output_path,
  3327. values["-INVERT-"],
  3328. values["-CALC_PLI_MST-"],
  3329. epoch_level_data=epoch_level_data, # Pass long-format data
  3330. calc_jpe=values["-CALC_JPE-"],
  3331. calc_pli=values["-CALC_PLI-"],
  3332. calc_aec=values["-CALC_AEC-"],
  3333. use_aecc=values["-USE_AECC-"],
  3334. force_positive=values["-AEC_FORCE_POSITIVE-"],
  3335. calc_aec_mst=values["-CALC_AEC_MST-"],
  3336. calc_power=values["-CALC_POWER-"],
  3337. power_fs=power_fs,
  3338. calc_peak=values["-CALC_PEAK-"],
  3339. peak_min=peak_min,
  3340. peak_max=peak_max,
  3341. calc_sampen=values["-CALC_SAMPEN-"],
  3342. calc_apen=values["-CALC_APEN-"],
  3343. calc_lzc=values["-CALC_LZC-"],
  3344. lzc_threshold=values["-LZC_THRESH-"],
  3345. calc_sv=values["-CALC_SV-"],
  3346. sv_window=sv_window if values["-CALC_SV-"] else None,
  3347. save_channel_averages=values["-SAVE_CHANNEL_AVERAGES-"],
  3348. concat_aecc=values["-CONCAT_AECC-"],
  3349. has_headers=values["-HAS_HEADERS-"],
  3350. psd_method=psd_method,
  3351. welch_window_ms=welch_window_ms,
  3352. welch_overlap=welch_overlap,
  3353. mt_bandwidth=mt_bandwidth,
  3354. calc_plt=calc_plt,
  3355. calc_plt_mst=values["-CALC_PLT_MST-"],
  3356. plt_threshold_ms=plt_threshold_ms,
  3357. save_psd_avg=save_psd_avg,
  3358. save_psd_perchannel=save_psd_perchannel,
  3359. save_psd_plots=save_psd_plots,
  3360. total_power_range=total_power_range,
  3361. reference_range_source=reference_range_source,
  3362. )
  3363. # Generate dynamic success summary
  3364. success_msg = f"Analysis complete!\nResults saved to:\n{output_path}"
  3365. if save_matrices:
  3366. success_msg += f"\n\nConnectivity matrices saved per-epoch in:\n{matrix_folder}"
  3367. if save_mst:
  3368. success_msg += f"\n\nMST matrices saved per-epoch in:\n{mst_folder}"
  3369. logger.info("Analysis run finished successfully.")
  3370. sg.popup(success_msg, title="Success")
  3371. except Exception:
  3372. logger.exception("Error during Excel export")
  3373. sg.popup_error("Analysis completed, but an error occurred while saving the Excel file. Check logs.")
  3374. else:
  3375. logger.warning("No results were generated")
  3376. sg.popup_error("Processing finished but no valid results were generated.")
  3377. except Exception:
  3378. logger.exception("Fatal error during parallel processing")
  3379. sg.popup_error("A fatal error occurred during processing. Please check the log file for details.")
  3380. finally:
  3381. logger.info("--- Run Terminated ---")
  3382. window.close()
  3383. if __name__ == "__main__":
  3384. main()

eeg_quantitative_analysis.py at commit f25ba96, under Apache-2.0 · at the source

Overview

Authors: D. Yorben Lodema1, Herman J. van Dellen1, Willem de Haan2, Margot van Hest1, Arjan Hillebrand3,4,5, Edwin van Dellen1,6
ORCID iDs: D. Yorben Lodema
  1. Department of Psychiatry, University Medical Center Utrecht, Utrecht, the Netherlands
  2. Alzheimer Center and Department of Neurology, Amsterdam Neuroscience, VU University Medical Center, Amsterdam UMC, Amsterdam, the Netherlands
  3. Amsterdam Neuroscience, Brain Imaging, Amsterdam, the Netherlands
  4. Amsterdam Neuroscience, Systems and Network Neurosciences, Amsterdam, the Netherlands
  5. Department of Clinical Neurophysiology and MEG Center, Department of Neurology, Amsterdam Neuroscience, Vrije Universiteit, Amsterdam, the Netherlands
  6. Department of Neurology, UZ Brussel and Vrije Universiteit Brussel, Brussels, Belgium
Journal: PLoS computational biology, volume 22, issue 3, article e1014043
Dates: received 30 October 2025; accepted 19 February 2026; published online 2 March 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1371/journal.pcbi.1014043 · PMID 41770742 · PMCID PMC12970966 · OpenAlex W7133208668
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: EEG (modality), human (organism), methods / tools (subfield)
Methods: Spectral & time-frequency, Statistics, Smoothing, state filtering, decompositions, Complexity, Preprocessing, Connectivity, Source localization, Physiology & signal measures
MeSH: Electroencephalography*, Signal Processing, Computer-Assisted*, Software*, User-Computer Interface*, Algorithms, Brain, Computational Biology, Computer Graphics, Humans, Reproducibility of Results (* major topic)
Journal subjects: Software, Research and Analysis Methods, Bioassays and Physiological Analysis, Electrophysiological Techniques, Brain Electrophysiology, Electroencephalography, Biology and Life Sciences, Physiology, Electrophysiology, Neurophysiology, Neuroscience, Brain Mapping, Medicine and Health Sciences, Clinical Medicine, Clinical Neurophysiology, Imaging Techniques, Neuroimaging, Computer and Information Sciences, Software Engineering, Preprocessing, Engineering and Technology, Physical Sciences, Physics, Thermodynamics, Entropy, Computer Software, Open Source Software, Science Policy, Open Science, Mathematics, Discrete Mathematics, Combinatorics, Permutation, Signal Processing, Signal Filtering, Research Design, Quantitative Analysis, Electronics Engineering, Computer Engineering, Man-Computer Interface, Graphical User Interfaces, Computer Architecture, User Interfaces
Topic: Functional Brain Connectivity Studies (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 23 references in the paper

Abstract

Processing of electroencephalography (EEG) data requires multiple steps to remove noise and artifacts and select good-quality data. While powerful open-source toolboxes like MNE-Python exist, their command-line nature can pose a barrier for researchers without programming experience. Here, we present EEG-Pype, an open-source (Apache-2.0 licensed) graphical user interface application using MNE-Python functions. EEG-Pype provides an intuitive workflow tailored for preprocessing of resting-state EEG data, including frequency band filtering, independent component analysis and atlas-based beamforming for source-level analysis. The application supports several common raw EEG input file formats and guides users through a comprehensive pipeline focused on manual bad channel and epoch selection. Manual steps are streamlined using MNE-Python’s interactive plots, resulting in a user-friendly experience. Configuration saving and loading allows for batch (re)runs, while a separate log is also saved, improving reproducibility and documentation. Output can be saved after filtering in canonical frequency bands, ready for further analysis. EEG-Pype includes a module for calculating quantitative EEG measures on preprocessed data, including spectral, functional connectivity and network analysis metrics. This software aims to lower the entry barrier for standardized EEG preprocessing, promoting reproducible research practices among neuroscientists and clinicians without requiring programming knowledge. EEG-Pype can be downloaded from: https://github.com/yorbenlodema/EEG-Pype and is not dependent on a specific operating system.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repository

Its files are read in the Code ↔ Paper reader above, with 8 matches between paragraphs and lines of code.

yorbenlodema/EEG-Pype

License: Apache-2.0
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: f25ba96d55174d4a5d21c9016fed933bfd8873b6, 6 August 2026
Languages: Python (5)
Size: 29 files, 5 scripts
Software Heritage: not archived
Found in: “Availability and future directions”
Holds: README, license file, CITATION.cff, environment (Environment.yml, pyproject.toml)
Not found: tests, continuous integration, documentation
Tools: Matplotlib (4 files), MNE-Python (4 files), NumPy (3 files), pandas (3 files), SciPy (2 files), ICLabel (1 file), NetworkX (1 file), specparam (formerly FOOOF) (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
7 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 5 scripts, each with its path and the digest of its content;
  • 8 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data Availability

All relevant data are within the manuscript and its Supporting Information files, excluding our test.bdf EEG file due to file size constraints. This file is, and will stay, available on our GitHub page: https://github.com/yorbenlodema/EEG-Pype.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 30 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 6 authors, 10 MeSH terms, 20 references.

Cite

This paper

Lodema, D. Y., van Dellen, H. J., de Haan, W., van Hest, M., Hillebrand, A., & van Dellen, E. (2026). EEG-Pype: An accessible MNE-Python pipeline with graphical user interface for preprocessing and analysis of resting-state electroencephalography data. PLoS computational biology, 22(3), e1014043. https://doi.org/10.1371/journal.pcbi.1014043

BibTeX

@article{lodema2026eeg,
author = {Lodema, D. Yorben and van Dellen, Herman J. and de Haan, Willem and van Hest, Margot and Hillebrand, Arjan and van Dellen, Edwin},
title = {{EEG-Pype: An accessible MNE-Python pipeline with graphical user interface for preprocessing and analysis of resting-state electroencephalography data}},
journal = {PLoS computational biology},
year = {2026},
month = mar,
volume = {22},
number = {3},
pages = {e1014043},
publisher = {PLOS},
issn = {1553-734X},
doi = {10.1371/journal.pcbi.1014043},
url = {https://doi.org/10.1371/journal.pcbi.1014043},
pmid = {41770742},
pmcid = {PMC12970966}
}

RIS

TY - JOUR
AU - Lodema, D. Yorben
AU - van Dellen, Herman J.
AU - de Haan, Willem
AU - van Hest, Margot
AU - Hillebrand, Arjan
AU - van Dellen, Edwin
TI - EEG-Pype: An accessible MNE-Python pipeline with graphical user interface for preprocessing and analysis of resting-state electroencephalography data
T2 - PLoS computational biology
J2 - PLoS Comput Biol
PY - 2026
DA - 2026/03/02
VL - 22
IS - 3
SP - e1014043
SN - 1553-734X
PB - PLOS
DO - 10.1371/journal.pcbi.1014043
UR - https://doi.org/10.1371/journal.pcbi.1014043
LA - en
ER -

CSL-JSON

{
"id": "10.1371/journal.pcbi.1014043",
"type": "article-journal",
"title": "EEG-Pype: An accessible MNE-Python pipeline with graphical user interface for preprocessing and analysis of resting-state electroencephalography data",
"container-title": "PLoS computational biology",
"author": [
{
"family": "Lodema",
"given": "D. Yorben"
},
{
"family": "van Dellen",
"given": "Herman J."
},
{
"family": "de Haan",
"given": "Willem"
},
{
"family": "van Hest",
"given": "Margot"
},
{
"family": "Hillebrand",
"given": "Arjan"
},
{
"family": "van Dellen",
"given": "Edwin"
}
],
"container-title-short": "PLoS Comput Biol",
"volume": "22",
"issue": "3",
"page": "e1014043",
"DOI": "10.1371/journal.pcbi.1014043",
"PMID": "41770742",
"PMCID": "PMC12970966",
"ISSN": "1553-734X",
"publisher": "PLOS",
"URL": "https://doi.org/10.1371/journal.pcbi.1014043",
"language": "en",
"issued": {
"date-parts": [
[
2026,
3,
2
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.3390/s26134019 [code]
NeuroStat: An Open-Source EEG Connectivity Platform for Randomised Controlled Trials.
Journal: Sensors (Basel, Switzerland)
In common: ICLabel, MNE-Python, NetworkX, 4 other tools, EEG, 8 references
[2] doi:10.1093/cercor/bhag113 [code]
Long-term reliability and stability of parameterized resting state EEG: evidence from a five-year follow-up.
Journal: Cerebral cortex (New York, N.Y. : 1991)
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 4 other tools, methods / tools, EEG, 2 references
[3] doi:10.1002/mds.70348 [code]
Electroencephalography-Based Clustering Reveals Robust Neurophysiological Subtypes in Parkinson's Disease.
Journal: Movement disorders : official journal of the Movement Disorder Society
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 4 other tools, EEG, 2 references
[4] doi:10.1162/imag.a.1269 [code]
From early to contemporary normative modeling: Mapping individual differences in neurophysiological signals.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 4 other tools, EEG, 2 references
[5] doi:10.3389/fncom.2026.1786996 [code]
Schumann-anchored golden ratio organization of human neural oscillations.
Journal: Frontiers in computational neuroscience
In common: specparam (formerly FOOOF), MNE-Python, NetworkX, 4 other tools, EEG, 2 references
[6] doi:10.1097/j.pain.0000000000004044 [code]
No effect of rhythmic visual stimulation on experimental pain perception.
Journal: Pain
In common: specparam (formerly FOOOF), ICLabel, MNE-Python, 4 other tools, EEG, 1 reference
[7] doi:10.1162/imag.a.1245 [code]
Towards precision EEG connectomics: Evaluating the benefits of dense sampling.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: ICLabel, MNE-Python, pandas, 3 other tools, EEG, 3 references
[8] doi:10.1002/hbm.70628 [code]
EEG Biomarkers for Affective Disorders Diagnosis: An Evaluation and Validation Study.
Journal: Human brain mapping
In common: ICLabel, MNE-Python, pandas, 3 other tools, EEG, 2 references
[9] doi:10.1038/s42003-026-10394-7 [code]
Cognitive load weakens neural speech tracking without altering response timing.
Journal: Communications biology
In common: MNE-Python, NetworkX, pandas, 3 other tools, EEG, 3 references
[10] doi:10.7554/elife.100605 [code]
Age-related changes in ‘cortical’ 1/f dynamics are linked to cardiac activity
Journal: n/a
In common: specparam (formerly FOOOF), MNE-Python, NetworkX, 4 other tools, 1 reference

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.