OSCR

An automated derivative-based method for detection of motor evoked potential onset latencies in multi-muscle transcranial magnetic stimulation studies.

Code ↔ Paper

3 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 3 matches
  1. [1] § Methods › Computational runtime ↔ MEP_latency_derivative_ratio.py, lines 527–604 · score 0.59 · perf counter, Elapsed, export, runtime, epoched, EMG
  2. [2] § Methods › Derivative-ratio algorithm › Algorithm process in detail › Amplitude gate ↔ MEP_latency_derivative_ratio.py, lines 56–168 · score 0.55 · 45 ms, latency detection, amplitude, baseline, peak, gate
  3. [3] § Methods › Derivative-ratio algorithm › Algorithm process in detail › True MEP filter ↔ MEP_latency_derivative_ratio.py, lines 389–521 · score 0.54 · Latency cap, onset latencies, bound, MEP

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 · 748 lines · 28 KB · no license · 3 matches

  1. #!/usr/bin/env python3
  2. """
  3. advanced_latency_analysis_v11a_light_refactor_v5.py
  4. ----------------------------------------------------
  5. Publication-ready MEP-latency pipeline with optional resampling and
  6. sampling-rate-aware timing parameters.
  7. Detect onsets in EMG epochs stored as NumPy *.npy files and save one wide CSV
  8. per input file (frames × channels, latencies in ms).
  9. Key flags
  10. ---------
  11. --fs 2000 # native input sampling rate
  12. --resample-to-hz 5000 # optional target sampling rate
  13. --task-mode {rest,active,auto} # prestim outlier rule
  14. --parallel 4 # workers; 0 = serial
  15. Sensitivity-analysis flags
  16. --------------------------
  17. --ptp-factor 1.1
  18. --derivative-block-ms 2.5
  19. --search-back-factor 1.75
  20. --derivative-ratio-thresh 0.85
  21. --peak2trough-min-ms 5.0
  22. --peak2trough-max-ms 7.5
  23. Important implementation detail
  24. -------------------------------
  25. Parameters that were previously hard-coded as sample counts are now stored in
  26. milliseconds and converted to samples from cfg.fs. Because cfg.fs is replaced
  27. with the effective sampling rate after optional resampling, these parameters
  28. preserve their approximate duration at either native or resampled rates.
  29. """
  30. from __future__ import annotations
  31. import argparse
  32. import logging
  33. import math
  34. import re
  35. import sys
  36. import time
  37. from dataclasses import dataclass, replace
  38. from pathlib import Path
  39. from typing import Sequence
  40. import numpy as np
  41. import pandas as pd
  42. from joblib import Parallel, delayed
  43. from scipy.ndimage import gaussian_filter1d, uniform_filter1d
  44. from scipy.signal import filtfilt, iirnotch, resample_poly
  45. ###############################################################################
  46. # 1 | config #
  47. ###############################################################################
  48. @dataclass(frozen=True)
  49. class Cfg:
  50. """Configuration for the derivative-ratio latency detector.
  51. Notes
  52. -----
  53. ``fs`` should always represent the sampling rate currently used by the
  54. algorithm. At file-loading time this is the native sampling rate. If
  55. ``resample_to_hz`` is set, ``process_file()`` resamples the data and then
  56. replaces ``cfg.fs`` with the effective sampling rate before filtering,
  57. template construction, and latency detection.
  58. """
  59. # Native/effective sampling and event windows -----------------------------
  60. fs: int = 2000 # Hz before resampling; becomes effective Hz afterwards
  61. stim_on: float = 0.25 # seconds from epoch start to TMS pulse
  62. mep_on: float = 0.005 # seconds relative to pulse
  63. mep_off: float = 0.045 # seconds relative to pulse
  64. prestim_start: float = -0.101 # seconds relative to pulse
  65. prestim_end: float = -0.001 # seconds relative to pulse
  66. resample_to_hz: int | None = None # None = native fs; e.g. 5000 = resample to 5 kHz
  67. # Amplitude / derivative-ratio parameters ---------------------------------
  68. ptp_factor: float = 1.1
  69. derivative_block_ms: float = 2.5
  70. derivative_ratio_thresh: float = 0.85
  71. search_back_factor: float = 1.75
  72. latency_cap: float = 0.035 # seconds relative to pulse
  73. # Peak-to-trough constraints, now in time units ---------------------------
  74. peak2trough_min_ms: float = 5.0
  75. peak2trough_max_ms: float = 7.5
  76. # Filtering / smoothing ---------------------------------------------------
  77. mains_filter: bool = True
  78. notch_q: float = 30.0
  79. smoothing: str | None = "rolling" # {'gaussian', 'rolling', None}
  80. rolling_smooth_ms: float = 2.5
  81. gaussian_sigma_ms: float = 1.0
  82. # Refinement / template-anchor parameters, now in time units --------------
  83. refine_chunk_ms: float = 2.0
  84. tpl_anchor_tol_ms: float = 7.5
  85. # Session/gating knobs ----------------------------------------------------
  86. rms_multiplier: float = 1.5 # window RMS > X × baseline RMS
  87. task_mode: str = "rest" # {'rest', 'active', 'auto'}
  88. active_token: str = "act" # token that marks active files
  89. # Cached indices ----------------------------------------------------------
  90. @property
  91. def idx(self) -> dict[str, int]:
  92. """Return key analysis-window indices for the current effective fs."""
  93. s2i = lambda t: int(round((t + self.stim_on) * self.fs))
  94. return {
  95. "stim": int(round(self.stim_on * self.fs)),
  96. "mep_on": s2i(self.mep_on),
  97. "mep_off": s2i(self.mep_off),
  98. "pre_start": s2i(self.prestim_start),
  99. "pre_end": s2i(self.prestim_end),
  100. }
  101. def ms_to_samples(self, ms: float, *, minimum: int = 1) -> int:
  102. """Convert milliseconds to samples using the current effective fs."""
  103. return max(minimum, int(math.floor((ms / 1000.0) * self.fs + 0.5)))
  104. @property
  105. def derivative_block(self) -> int:
  106. """Derivative-ratio block length in samples."""
  107. return self.ms_to_samples(self.derivative_block_ms)
  108. @property
  109. def peak2trough_min(self) -> int:
  110. """Minimum peak-to-trough interval in samples."""
  111. return self.ms_to_samples(self.peak2trough_min_ms)
  112. @property
  113. def peak2trough_max(self) -> int:
  114. """Maximum peak-to-trough interval in samples."""
  115. return max(self.peak2trough_min, self.ms_to_samples(self.peak2trough_max_ms))
  116. @property
  117. def rolling_smooth_n(self) -> int:
  118. """Rolling smoothing window in samples."""
  119. return self.ms_to_samples(self.rolling_smooth_ms)
  120. @property
  121. def gaussian_sigma_samples(self) -> float:
  122. """Gaussian smoothing sigma in samples."""
  123. return max(1.0, (self.gaussian_sigma_ms / 1000.0) * self.fs)
  124. @property
  125. def refine_chunk(self) -> int:
  126. """Initial refinement chunk length in samples."""
  127. return self.ms_to_samples(self.refine_chunk_ms)
  128. @property
  129. def tpl_anchor_tol(self) -> int:
  130. """Allowed template-anchor tolerance in samples."""
  131. return self.ms_to_samples(self.tpl_anchor_tol_ms)
  132. def derived_sample_summary(self) -> dict[str, int | float]:
  133. """Return derived sample-count parameters for logging/reproducibility."""
  134. return {
  135. "fs": self.fs,
  136. "derivative_block": self.derivative_block,
  137. "peak2trough_min": self.peak2trough_min,
  138. "peak2trough_max": self.peak2trough_max,
  139. "rolling_smooth_n": self.rolling_smooth_n,
  140. "gaussian_sigma_samples": round(self.gaussian_sigma_samples, 3),
  141. "refine_chunk": self.refine_chunk,
  142. "tpl_anchor_tol": self.tpl_anchor_tol,
  143. }
  144. ###############################################################################
  145. # 2 | helpers #
  146. ###############################################################################
  147. def notch_50hz(x: np.ndarray, cfg: Cfg) -> np.ndarray:
  148. """Apply a 50 Hz notch filter along the sample axis if requested."""
  149. if not cfg.mains_filter:
  150. return x
  151. nyquist = cfg.fs / 2.0
  152. if 50.0 >= nyquist:
  153. raise ValueError(f"Cannot apply 50 Hz notch when Nyquist is only {nyquist:.1f} Hz")
  154. w0 = 50.0 / nyquist
  155. b, a = iirnotch(w0, cfg.notch_q)
  156. return filtfilt(b, a, x, axis=0)
  157. def resample_emg_block(emg: np.ndarray, cfg: Cfg) -> tuple[np.ndarray, int]:
  158. """Optionally resample an epoched EMG block along the sample axis.
  159. Parameters
  160. ----------
  161. emg
  162. Epoched EMG block shaped ``samples × frames × channels``.
  163. cfg
  164. Configuration object. ``cfg.fs`` is interpreted as the native sampling
  165. rate before resampling, and ``cfg.resample_to_hz`` is the optional target.
  166. Returns
  167. -------
  168. tuple[np.ndarray, int]
  169. The possibly resampled EMG block and the effective sampling rate to use
  170. for all downstream indexing, filtering, and latency conversion.
  171. """
  172. target_fs = cfg.resample_to_hz
  173. original_fs = int(cfg.fs)
  174. if target_fs is None or int(target_fs) == original_fs:
  175. return emg, original_fs
  176. target_fs = int(target_fs)
  177. if target_fs <= 0:
  178. raise ValueError("resample_to_hz must be a positive integer or None")
  179. common = math.gcd(original_fs, target_fs)
  180. up = target_fs // common
  181. down = original_fs // common
  182. emg_rs = resample_poly(emg, up=up, down=down, axis=0)
  183. return emg_rs, target_fs
  184. def smooth(sig: np.ndarray, cfg: Cfg) -> np.ndarray:
  185. """Smooth an EMG trace using windows defined in milliseconds.
  186. The actual sample width is derived from ``cfg.fs``, so smoothing is
  187. preserved in time units if the signal is resampled.
  188. """
  189. if cfg.smoothing == "gaussian":
  190. return gaussian_filter1d(sig, cfg.gaussian_sigma_samples, axis=0)
  191. if cfg.smoothing == "rolling":
  192. n = cfg.rolling_smooth_n
  193. return uniform_filter1d(uniform_filter1d(sig, n, axis=0), n, axis=0)
  194. return sig
  195. def prestim_mask(baseline: np.ndarray, mode: str) -> np.ndarray:
  196. """Return a Boolean frame mask based on pre-stimulus RMS outliers.
  197. Parameters
  198. ----------
  199. baseline
  200. Array shaped ``samples_in_prestim × n_frames``.
  201. mode
  202. ``'rest'`` or ``'active'``. Active sessions use a looser z-score band.
  203. """
  204. if baseline.shape[0] == 0:
  205. return np.ones(baseline.shape[1], dtype=bool)
  206. rms = np.sqrt((baseline ** 2).mean(axis=0))
  207. mu, sd = rms.mean(), rms.std(ddof=0)
  208. if sd == 0 or not np.isfinite(sd):
  209. return np.ones_like(rms, dtype=bool)
  210. thr = 3.5 if mode == "active" else 2.0
  211. z = (rms - mu) / sd
  212. return np.abs(z) <= thr
  213. def is_active_file(name: str, token: str) -> bool:
  214. """Return True if ``token`` appears as a standalone chunk in ``name``."""
  215. s = name.lower()
  216. pat = rf"(?<![a-z]){re.escape(token.lower())}(?![a-z])"
  217. return re.search(pat, s) is not None
  218. ###############################################################################
  219. # 3 | template builder #
  220. ###############################################################################
  221. def build_templates(emg: np.ndarray, chans: Sequence[str], cfg: Cfg):
  222. """Build one normalised average MEP template per channel."""
  223. idx, tpl = cfg.idx, {}
  224. for i, ch in enumerate(chans):
  225. sig = emg[:, :, i]
  226. base = sig[idx["pre_start"]:idx["pre_end"]]
  227. mep = sig[idx["mep_on"]:idx["mep_off"]]
  228. if base.shape[0] == 0 or mep.shape[0] == 0:
  229. tpl[ch] = None
  230. continue
  231. good = prestim_mask(base, cfg.task_mode)
  232. good &= np.ptp(mep, axis=0) > cfg.ptp_factor * np.ptp(base, axis=0)
  233. if not good.any():
  234. tpl[ch] = None
  235. continue
  236. waves = mep[:, good]
  237. waves = (waves - waves.mean(0)) / (waves.std(0) + 1e-9)
  238. tpl[ch] = waves.mean(1)
  239. return tpl
  240. ###############################################################################
  241. # 4 | candidate refinement #
  242. ###############################################################################
  243. def _refine(sig: np.ndarray, diff: np.ndarray, cand: int, start: int,
  244. mean_d: float, std_d: float, p2t: int, base_rms: float,
  245. cfg: Cfg) -> int | None:
  246. """Refine a candidate onset using local derivative/RMS criteria.
  247. This is a NumPy equivalent of the earlier pandas/DataFrame implementation.
  248. It keeps the same decision logic but avoids repeated DataFrame construction
  249. and ``.iloc`` slicing inside the per-frame loop.
  250. """
  251. chunk = cfg.refine_chunk
  252. win = p2t * 2
  253. span = max(1, p2t // 4)
  254. min_neg_samples = max(1, math.ceil(0.75 * chunk))
  255. mep_on = cfg.idx["mep_on"]
  256. def ok(j: int) -> bool:
  257. sig_w = sig[j:j + win]
  258. diff_w = diff[j:j + win]
  259. if sig_w.size == 0 or diff_w.size == 0:
  260. return False
  261. # Previous code used: d = mean_d - diff; cond = d < 0.
  262. # This is equivalent to diff > mean_d.
  263. cond = diff_w > mean_d
  264. rms = math.sqrt(float(np.mean(sig_w ** 2)))
  265. first_chunk = cond[:chunk]
  266. return (
  267. float(np.mean(cond)) > 0.5
  268. and int(np.sum(first_chunk)) >= min_neg_samples
  269. and (
  270. rms > cfg.rms_multiplier * base_rms
  271. or float(np.mean(diff_w)) > mean_d + 1.5 * std_d
  272. )
  273. )
  274. if ok(cand):
  275. return cand
  276. lower = max(cand - span, mep_on)
  277. for j in range(cand - 1, lower - 1, -1):
  278. if ok(j):
  279. return j
  280. for j in range(cand + 1, cand + span + 1):
  281. if j > start:
  282. break
  283. if ok(j):
  284. return j
  285. return None
  286. ###############################################################################
  287. # 5 | per-channel pipeline #
  288. ###############################################################################
  289. def _window_means_from_cumsum(values: np.ndarray, starts: np.ndarray,
  290. width: int) -> np.ndarray:
  291. """Return fixed-width window means for many start indices.
  292. Parameters
  293. ----------
  294. values
  295. One-dimensional numeric array.
  296. starts
  297. Start indices for each window.
  298. width
  299. Number of samples in each window.
  300. Returns
  301. -------
  302. np.ndarray
  303. Mean value for ``values[start:start + width]`` for each start.
  304. Notes
  305. -----
  306. This replaces repeated small pandas/Python slices in the derivative-ratio
  307. scan. ``diff`` can contain a NaN at index 0 from ``np.diff(..., prepend)``;
  308. that value is converted to zero before cumulative sums. The derivative-ratio
  309. search windows are post-stimulus and should not include index 0 in normal
  310. use, so this conversion preserves practical behaviour while keeping the
  311. vectorised implementation robust.
  312. """
  313. clean = np.nan_to_num(values, nan=0.0)
  314. cs = np.concatenate(([0.0], np.cumsum(clean, dtype=float)))
  315. return (cs[starts + width] - cs[starts]) / width
  316. def process_channel(sig: np.ndarray, tpl: np.ndarray | None, cfg: Cfg) -> np.ndarray:
  317. """Detect onset latencies for all frames in one channel.
  318. Optimisation notes
  319. ------------------
  320. The original implementation created a pandas ``DataFrame`` for every kept
  321. frame and then used repeated ``.iloc`` slices during the derivative-ratio
  322. search and refinement checks. This version keeps the same algorithmic steps
  323. but performs the inner-loop operations with NumPy arrays. It also smooths all
  324. kept frames for a channel in one vectorised call, rather than smoothing each
  325. frame separately.
  326. """
  327. idx, nF = cfg.idx, sig.shape[1]
  328. lat = np.full(nF, np.nan, dtype=object)
  329. if tpl is None:
  330. lat[:] = "NaN"
  331. return lat
  332. base = sig[idx["pre_start"]:idx["pre_end"]]
  333. mep = sig[idx["mep_on"]:idx["mep_off"]]
  334. if base.shape[0] == 0 or mep.shape[0] == 0:
  335. lat[:] = "NaN"
  336. return lat
  337. good_frames = prestim_mask(base, cfg.task_mode)
  338. lat[~good_frames] = "NaN"
  339. keep = good_frames & (np.ptp(mep, axis=0) > cfg.ptp_factor * np.ptp(base, axis=0))
  340. lat[~keep & good_frames] = "NaN"
  341. keep_idx = np.flatnonzero(keep)
  342. if keep_idx.size == 0:
  343. return lat
  344. tpl_anchor = min(np.argmax(tpl), np.argmin(tpl)) + idx["mep_on"]
  345. db = cfg.derivative_block
  346. eps = 1e-6
  347. # Smooth all kept frames at once. This is mathematically equivalent to
  348. # smoothing each frame independently because smoothing is along axis 0 only.
  349. sig_keep = sig[:, keep_idx]
  350. smoothed_keep = smooth(sig_keep, cfg)
  351. for local_col, f in enumerate(keep_idx):
  352. frame = sig_keep[:, local_col]
  353. smoothed = smoothed_keep[:, local_col]
  354. diff = np.abs(np.diff(smoothed, prepend=np.nan))
  355. pre_d = diff[idx["pre_start"]:idx["pre_end"]]
  356. mean_d = float(np.nanmean(pre_d))
  357. std_d = float(np.nanstd(pre_d))
  358. mwin = smoothed[idx["mep_on"]:idx["mep_off"]]
  359. if mwin.size == 0 or np.all(np.isnan(mwin)):
  360. lat[f] = "null_onset"
  361. continue
  362. peak = int(np.nanargmax(mwin) + idx["mep_on"])
  363. trough = int(np.nanargmin(mwin) + idx["mep_on"])
  364. p2t = int(np.clip(abs(peak - trough), cfg.peak2trough_min, cfg.peak2trough_max))
  365. start_idx = min(peak, trough)
  366. if not (tpl_anchor - cfg.tpl_anchor_tol <= start_idx <= tpl_anchor + cfg.tpl_anchor_tol):
  367. lat[f] = "null_onset"
  368. continue
  369. lower = max(start_idx - int(p2t * cfg.search_back_factor), idx["mep_on"])
  370. first_i = start_idx - db
  371. if first_i < lower:
  372. lat[f] = "null_onset"
  373. continue
  374. # Preserve the original scan order: start_idx - db, then step backward.
  375. i_vals = np.arange(first_i, lower - 1, -1, dtype=int)
  376. prev_starts = i_vals - db
  377. nxt_starts = i_vals
  378. # Defensive bounds check. In normal use the lower bound prevents this.
  379. valid = (prev_starts >= 0) & (nxt_starts + db <= diff.size)
  380. if not np.any(valid):
  381. lat[f] = "null_onset"
  382. continue
  383. i_vals = i_vals[valid]
  384. prev_starts = prev_starts[valid]
  385. nxt_starts = nxt_starts[valid]
  386. prev = _window_means_from_cumsum(diff, prev_starts, db)
  387. nxt = _window_means_from_cumsum(diff, nxt_starts, db)
  388. ratios = np.abs(nxt) / (np.abs(prev) + eps)
  389. if ratios.size == 0:
  390. lat[f] = "null_onset"
  391. continue
  392. max_pos = int(np.argmax(ratios))
  393. mx = int(i_vals[max_pos])
  394. mxr = float(ratios[max_pos])
  395. thr = cfg.derivative_ratio_thresh * mxr
  396. # Recreate the previous neighbour expansion around mx using a dictionary
  397. # for exact key lookup. This avoids subtle changes in candidate ordering.
  398. ratio_lookup = dict(zip(i_vals.tolist(), ratios.tolist()))
  399. cands = [mx]
  400. j = mx - 1
  401. while ratio_lookup.get(j, 0) >= thr:
  402. cands.append(j)
  403. j -= 1
  404. j = mx + 1
  405. while ratio_lookup.get(j, 0) >= thr:
  406. cands.append(j)
  407. j += 1
  408. base_rms = math.sqrt(float(np.mean(frame[idx["pre_start"]:idx["pre_end"]] ** 2)))
  409. refined = None
  410. for c in cands:
  411. if (c - idx["stim"]) / cfg.fs > cfg.latency_cap:
  412. continue
  413. refined = _refine(frame, diff, c, start_idx, mean_d, std_d, p2t, base_rms, cfg)
  414. if refined is not None:
  415. break
  416. lat[f] = (
  417. "null_onset" if refined is None
  418. else round((refined - idx["stim"]) / cfg.fs * 1000, 3)
  419. )
  420. return lat
  421. ###############################################################################
  422. # 6 | file pipeline #
  423. ###############################################################################
  424. def process_file(path: Path, out_dir: Path, chans: Sequence[str], cfg: Cfg,
  425. log: logging.Logger):
  426. """Process one ``.npy`` EMG block, write a wide latency CSV, and report runtime.
  427. Runtime is measured per input file and includes loading, optional resampling,
  428. filtering, template construction, latency detection, DataFrame construction,
  429. and CSV export. The printed per-frame value treats each stimulation frame as
  430. one trial containing all recorded channels. The per-channel-frame value gives
  431. a rough per-epoch speed, where one epoch is one frame from one channel.
  432. """
  433. file_timer_start = time.perf_counter()
  434. log.info("Processing %s", path.name)
  435. emg = np.load(path)
  436. if emg.ndim == 2:
  437. emg = emg[:, :, None]
  438. emg, effective_fs = resample_emg_block(emg, cfg)
  439. if effective_fs != cfg.fs:
  440. log.info("↳ resampled %s from %d Hz to %d Hz", path.name, cfg.fs, effective_fs)
  441. if cfg.task_mode == "auto":
  442. file_mode = "active" if is_active_file(path.stem, cfg.active_token) else "rest"
  443. else:
  444. file_mode = cfg.task_mode
  445. eff_cfg = replace(
  446. cfg,
  447. fs=effective_fs,
  448. task_mode=file_mode,
  449. # Preserve the existing behaviour: stricter RMS gate at rest, looser during contraction.
  450. rms_multiplier=(2.0 if file_mode == "rest" else 1.5),
  451. )
  452. log.debug("Task mode for %s → %s", path.name, file_mode)
  453. log.info("↳ effective config: %s", eff_cfg.derived_sample_summary())
  454. emg = notch_50hz(emg, eff_cfg)
  455. tpls = build_templates(emg, chans, eff_cfg)
  456. out = {
  457. ch: process_channel(emg[:, :, i], tpls[ch], eff_cfg)
  458. for i, ch in enumerate(chans)
  459. }
  460. df = pd.DataFrame(out)
  461. df.index = np.arange(1, len(df) + 1)
  462. out_csv = out_dir / f"{path.stem}_latencies.csv"
  463. df.to_csv(out_csv, index_label="frame")
  464. log.info("↳ saved %s", out_csv.name)
  465. elapsed_s = time.perf_counter() - file_timer_start
  466. n_frames = int(df.shape[0])
  467. n_channels = int(df.shape[1])
  468. n_channel_frames = n_frames * n_channels
  469. sec_per_frame = elapsed_s / n_frames if n_frames else float("nan")
  470. sec_per_channel_frame = (
  471. elapsed_s / n_channel_frames if n_channel_frames else float("nan")
  472. )
  473. runtime_msg = (
  474. f"↳ runtime {path.name}: {elapsed_s:.3f} s total | "
  475. f"{sec_per_frame * 1000:.2f} ms/frame | "
  476. f"{sec_per_channel_frame * 1000:.2f} ms/channel-frame "
  477. f"({n_frames} frames × {n_channels} channels)"
  478. )
  479. log.info(runtime_msg)
  480. return {
  481. "file": path.name,
  482. "seconds": elapsed_s,
  483. "frames": n_frames,
  484. "channels": n_channels,
  485. "channel_frames": n_channel_frames,
  486. "seconds_per_frame": sec_per_frame,
  487. "seconds_per_channel_frame": sec_per_channel_frame,
  488. }
  489. ###############################################################################
  490. # 7 | CLI #
  491. ###############################################################################
  492. def cli_args() -> argparse.Namespace:
  493. """Parse command-line arguments."""
  494. p = argparse.ArgumentParser(description="MEP latency detector (wide CSV)")
  495. p.add_argument("--in-dir", required=True, type=Path)
  496. p.add_argument("--out-dir", required=True, type=Path)
  497. p.add_argument("--channels", required=True, type=Path)
  498. p.add_argument("--fs", type=int, default=Cfg.fs,
  499. help=f"Native input sampling rate in Hz before optional resampling (default {Cfg.fs}).")
  500. p.add_argument("--resample-to-hz", type=int, default=Cfg.resample_to_hz,
  501. help="Optional target sampling rate in Hz before latency detection, e.g. 5000. "
  502. "Omit to analyse at native --fs.")
  503. p.add_argument("--parallel", type=int, default=0)
  504. p.add_argument("--log", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
  505. p.add_argument("--task-mode", default=Cfg.task_mode, choices=["rest", "active", "auto"],
  506. help="Prestim outlier rule: 'rest', 'active', or 'auto' per file")
  507. p.add_argument("--active-token", default=Cfg.active_token,
  508. help="Token that marks active files when --task-mode=auto")
  509. p.add_argument("--ptp-factor", type=float, default=Cfg.ptp_factor,
  510. help="Amplitude gate: MEP ptp must exceed this × baseline ptp.")
  511. p.add_argument("--derivative-block-ms", type=float, default=Cfg.derivative_block_ms,
  512. help="Derivative-ratio block length in milliseconds.")
  513. p.add_argument("--derivative-ratio-thresh", type=float, default=Cfg.derivative_ratio_thresh,
  514. help="Candidate plateau threshold as a fraction of Rmax.")
  515. p.add_argument("--search-back-factor", type=float, default=Cfg.search_back_factor,
  516. help="Search-back limit as a multiple of peak-to-trough distance.")
  517. p.add_argument("--peak2trough-min-ms", type=float, default=Cfg.peak2trough_min_ms,
  518. help="Minimum peak-to-trough interval in milliseconds.")
  519. p.add_argument("--peak2trough-max-ms", type=float, default=Cfg.peak2trough_max_ms,
  520. help="Maximum peak-to-trough interval in milliseconds.")
  521. p.add_argument("--smoothing", default=Cfg.smoothing, choices=["rolling", "gaussian", "none"],
  522. help="Smoothing mode. Use 'none' to disable smoothing.")
  523. p.add_argument("--rolling-smooth-ms", type=float, default=Cfg.rolling_smooth_ms,
  524. help="Rolling smoothing window in milliseconds.")
  525. p.add_argument("--gaussian-sigma-ms", type=float, default=Cfg.gaussian_sigma_ms,
  526. help="Gaussian smoothing sigma in milliseconds.")
  527. p.add_argument("--refine-chunk-ms", type=float, default=Cfg.refine_chunk_ms,
  528. help="Initial refinement chunk length in milliseconds.")
  529. p.add_argument("--tpl-anchor-tol-ms", type=float, default=Cfg.tpl_anchor_tol_ms,
  530. help="Allowed template-anchor tolerance in milliseconds.")
  531. p.add_argument("--rms-multiplier", type=float, default=Cfg.rms_multiplier,
  532. help="Base RMS multiplier. Existing rest/active replacement is preserved in process_file().")
  533. return p.parse_args()
  534. def main(ns: argparse.Namespace | None = None):
  535. """Run batch latency detection."""
  536. ns = ns or cli_args()
  537. logging.basicConfig(level=getattr(logging, ns.log),
  538. format="%(levelname)s:%(name)s:%(message)s")
  539. log = logging.getLogger("latency")
  540. smoothing = None if ns.smoothing == "none" else ns.smoothing
  541. cfg = Cfg(
  542. fs=ns.fs,
  543. resample_to_hz=ns.resample_to_hz,
  544. task_mode=ns.task_mode,
  545. active_token=ns.active_token,
  546. rms_multiplier=ns.rms_multiplier,
  547. ptp_factor=ns.ptp_factor,
  548. derivative_block_ms=ns.derivative_block_ms,
  549. derivative_ratio_thresh=ns.derivative_ratio_thresh,
  550. search_back_factor=ns.search_back_factor,
  551. peak2trough_min_ms=ns.peak2trough_min_ms,
  552. peak2trough_max_ms=ns.peak2trough_max_ms,
  553. smoothing=smoothing,
  554. rolling_smooth_ms=ns.rolling_smooth_ms,
  555. gaussian_sigma_ms=ns.gaussian_sigma_ms,
  556. refine_chunk_ms=ns.refine_chunk_ms,
  557. tpl_anchor_tol_ms=ns.tpl_anchor_tol_ms,
  558. )
  559. chans = np.load(ns.channels, allow_pickle=True).tolist()
  560. ns.out_dir.mkdir(parents=True, exist_ok=True)
  561. files = sorted(ns.in_dir.glob("*.npy"))
  562. if not files:
  563. log.error("No .npy found in %s", ns.in_dir)
  564. sys.exit(1)
  565. def _safe_process(f: Path):
  566. try:
  567. return process_file(f, ns.out_dir, chans, cfg, log)
  568. except Exception as e:
  569. log.error("FAILED %s: %s", f.name, e, exc_info=True)
  570. (ns.out_dir / f"{f.stem}_latencies.csv").write_text("frame\n")
  571. return None
  572. if ns.parallel:
  573. runtime_results = Parallel(n_jobs=ns.parallel)(
  574. delayed(_safe_process)(f) for f in files
  575. )
  576. else:
  577. runtime_results = []
  578. for f in files:
  579. runtime_results.append(_safe_process(f))
  580. runtime_results = [r for r in runtime_results if isinstance(r, dict)]
  581. if runtime_results:
  582. total_seconds = sum(r["seconds"] for r in runtime_results)
  583. total_frames = sum(r["frames"] for r in runtime_results)
  584. total_channel_frames = sum(r["channel_frames"] for r in runtime_results)
  585. log.info(
  586. "Runtime summary: %.3f s total | %.2f ms/frame | %.2f ms/channel-frame "
  587. "(%d files, %d frames, %d channel-frames)",
  588. total_seconds,
  589. (total_seconds / total_frames * 1000) if total_frames else float("nan"),
  590. (total_seconds / total_channel_frames * 1000) if total_channel_frames else float("nan"),
  591. len(runtime_results),
  592. total_frames,
  593. total_channel_frames,
  594. )
  595. ###############################################################################
  596. # 8 | Spyder fallback #
  597. ###############################################################################
  598. if __name__ == "__main__":
  599. if len(sys.argv) == 1: # launched via F5 in Spyder
  600. sys.argv += [
  601. "--in-dir", r"C:\Users\rowbi\OneDrive - Imperial College London\BRC PhD Fellowship\Code\Neuromap\data\control_data\Practice",
  602. "--out-dir", r"C:\Users\rowbi\OneDrive - Imperial College London\BRC PhD Fellowship\Code\Neuromap\data\control_data\Practice",
  603. "--channels", "data/channels.npy",
  604. "--fs", "2000", # native input sampling rate
  605. # "--resample-to-hz", "5000", # remove these two entries to analyse at native --fs
  606. "--parallel", "4",
  607. "--task-mode", "auto",
  608. "--active-token", "act", # string to look for in filename denoting active trials
  609. "--rms-multiplier", "1.5",
  610. ]
  611. main()

MEP_latency_derivative_ratio.py at commit 5f95bbc, no license · at the source

Overview

Authors: Rowan Boyles1,2, Mikal Vicente1, Sofia Zibordi1, Jason Mallabone3, Paul H Strutton1
ORCID iDs: Rowan Boyles
  1. The Nick Davey Laboratory, Department of Surgery and Cancer, Imperial College London, London, UK
  2. Centre for Vestibular Neurology, Department of Brain Sciences, Imperial College London, London, UK
  3. Imperial College Healthcare NHS Trust, London, UK
Institutions: Imperial College London (United Kingdom); Imperial College Healthcare NHS Trust (United Kingdom)
Journal: Scientific reports, volume 16, issue 1, article 26643
Dates: received 15 April 2026; accepted 6 July 2026; published online 23 July 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41598-026-61560-0 · PMID 42642547 · PMCID PMC13507071 · OpenAlex W7170190401
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: other (modality), human (organism)
Methods: Spectral & time-frequency, Connectivity, Preprocessing, Evoked potentials, Physiology & signal measures, Statistics
Keywords: Transcranial magnetic stimulation, Motor cortex mapping, Motor evoked potentials, Upper limb, MEP latency, Automated detection, Neurology, Neuroscience
MeSH: Evoked Potentials, Motor*, Motor Cortex*, Muscle, Skeletal*, Transcranial Magnetic Stimulation*, Adult, Algorithms, Electromyography, Female, Humans, Male, Pilot Projects, Reaction Time, Reproducibility of Results, Young Adult (* major topic)
Topic: Transcranial Magnetic Stimulation Studies (Neurology, Neuroscience), according to OpenAlex
Citations: not cited yet (Europe PMC); 35 references in the paper

Abstract

Motor evoked potential (MEP) onset latency is a useful neurophysiological measure, but manual measurement is time-consuming in transcranial magnetic stimulation (TMS) studies with large numbers of trials. This is particularly relevant in cortical mapping studies recording from multiple muscles simultaneously, where automated methods could support more scalable analyses. Existing onset-detection methods have shown promise in more restricted datasets, but their performance in heterogeneous multi-muscle mapping data remains uncertain. In this pilot validation study, three healthy adults underwent TMS cortical mapping with simultaneous EMG recording from eight upper-limb muscles during resting and active conditions, yielding 3,840 EMG epochs. Three independent raters classified MEP presence and marked onset latency for all trials. Inter-rater agreement was assessed using Fleiss’ κ for detection and ICC(2,1) for latency. Human majority vote for MEP presence and mean latency ratings were used as the reference standard to benchmark a novel derivative-ratio algorithm against an existing method. Human raters showed moderate to strong agreement for MEP detection (Fleiss’ κ = 0.69) and high reliability for latency ratings (ICC(2,1) = 0.95), with a pooled mean absolute pairwise difference of 0.88 ms. The derivative-ratio algorithm showed strong detection performance and human-like latency estimates, outperforming a previously published algorithm. These pilot validation data suggest that the derivative-ratio method provides promising automatic MEP onset latency detection in complex multi-muscle cortical mapping data and may provide a basis for scalable latency analysis. By enabling reproducible extraction of conduction-related MEP features, this approach may support future biomarker studies in neurological disorders characterised by altered corticospinal excitability or corticospinal conduction, pending further validation in larger and clinically diverse datasets.

Supplementary Information: The online version contains supplementary material available at 10.1038/s41598-026-61560-0.

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

Repositories

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

Zenodo 16966802

License: CC-BY-4.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Data availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (1 file), pandas (1 file), SciPy (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
2 files

rowbe1/mep_latency

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 5f95bbca8f62ef92b7a53aef8ac6e20d55a0f61c, 27 May 2026
Languages: Python (1)
Size: 10 files, 1 script
Software Heritage: not archived
Found in: the Zenodo archive record
Holds: README, CITATION.cff
Not found: license file, environment file, tests, continuous integration, documentation
Tools: NumPy (1 file), pandas (1 file), SciPy (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
2 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:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 2 scripts, each with its path and the digest of its content;
  • 3 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 Python code implementing the derivative-ratio detector, together with anonymised example EMG, is openly available at https://doi.org/10.5281/zenodo.16966802.

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 2, 28 September 2026

  • Funding: added National Institute for Health and Care Research; Imperial College London; NIHR Imperial Biomedical Research Centre

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 5 authors, 8 keywords, 14 MeSH terms, 35 references.

Cite

This paper

Boyles, R., Vicente, M., Zibordi, S., Mallabone, J., & Strutton, P. H. (2026). An automated derivative-based method for detection of motor evoked potential onset latencies in multi-muscle transcranial magnetic stimulation studies. Scientific reports, 16(1), 26643. https://doi.org/10.1038/s41598-026-61560-0

BibTeX

@article{boyles2026automated,
author = {Boyles, Rowan and Vicente, Mikal and Zibordi, Sofia and Mallabone, Jason and Strutton, Paul H},
title = {{An automated derivative-based method for detection of motor evoked potential onset latencies in multi-muscle transcranial magnetic stimulation studies}},
journal = {Scientific reports},
year = {2026},
month = jul,
volume = {16},
number = {1},
pages = {26643},
publisher = {Nature Publishing Group},
issn = {2045-2322},
doi = {10.1038/s41598-026-61560-0},
url = {https://doi.org/10.1038/s41598-026-61560-0},
pmid = {42642547},
pmcid = {PMC13507071}
}

RIS

TY - JOUR
AU - Boyles, Rowan
AU - Vicente, Mikal
AU - Zibordi, Sofia
AU - Mallabone, Jason
AU - Strutton, Paul H
TI - An automated derivative-based method for detection of motor evoked potential onset latencies in multi-muscle transcranial magnetic stimulation studies
T2 - Scientific reports
J2 - Sci Rep
PY - 2026
DA - 2026/07/23
VL - 16
IS - 1
SP - 26643
SN - 2045-2322
PB - Nature Publishing Group
DO - 10.1038/s41598-026-61560-0
UR - https://doi.org/10.1038/s41598-026-61560-0
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41598-026-61560-0",
"type": "article-journal",
"title": "An automated derivative-based method for detection of motor evoked potential onset latencies in multi-muscle transcranial magnetic stimulation studies",
"container-title": "Scientific reports",
"author": [
{
"family": "Boyles",
"given": "Rowan"
},
{
"family": "Vicente",
"given": "Mikal"
},
{
"family": "Zibordi",
"given": "Sofia"
},
{
"family": "Mallabone",
"given": "Jason"
},
{
"family": "Strutton",
"given": "Paul H"
}
],
"container-title-short": "Sci Rep",
"volume": "16",
"issue": "1",
"page": "26643",
"DOI": "10.1038/s41598-026-61560-0",
"PMID": "42642547",
"PMCID": "PMC13507071",
"ISSN": "2045-2322",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41598-026-61560-0",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
23
]
]
}
}

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.1162/imag.a.1211 [code]
Effects of electric field direction on TMS-based motor cortex mapping.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: pandas, SciPy, NumPy, other, 2 references
[2] doi:10.1002/advs.202523009 [code]
Personalized Network-Guided Neuromodulation Enhances Human Working Memory.
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)
In common: pandas, SciPy, NumPy, other, 1 reference
[3] doi:10.1371/journal.pcbi.1014154 [code]
Complexity of resting cortical activity predicts neurophysiological responses to theta-burst stimulation but fails to generalize: A rigorous machine-learning approach.
Journal: PLoS computational biology
In common: pandas, SciPy, NumPy, other, 1 reference
[4] doi:10.1016/j.nicl.2026.104012 [code]
Structural-functional multilayer brain network properties and outcome of combined repetitive transcranial magnetic stimulation and psychotherapy for obsessive-compulsive disorder.
Journal: NeuroImage. Clinical
In common: pandas, SciPy, NumPy, other, 1 reference
[5] doi:10.1016/j.ebiom.2026.106293 [code]
Dynamic neural states underpin motor symptom severity in Parkinson's disease: a longitudinal analysis of chronic cortico-subthalamic nucleus recordings.
Journal: EBioMedicine
In common: pandas, SciPy, NumPy, 1 reference
[6] doi:10.1162/nol.a.244 [code]
A Novel Approach to Map the Causal Impact of Brain Stimulation on Semantic Processing With Language Models.
Journal: Neurobiology of language (Cambridge, Mass.)
In common: SciPy, NumPy, other, 1 reference
[7] doi:10.1113/jp291217 [code]
Transcranial direct current stimulation enhances delayed retention after 5 days of lower-limb motor skill learning.
Journal: The Journal of physiology
In common: NumPy, other, 1 reference
[8] doi:10.1113/jp290164 [code]
Improved subjective sleep quality in older adults by enhancing the GABAergic system in the sensorimotor cortex.
Journal: The Journal of physiology
In common: other, 1 reference
[9] doi:10.1111/ejn.70597
Superficial Ventral Premotor Pathways to Primary Motor Cortex Shape the Temporal Coordination of Precision Graspinnog.
Journal: The European journal of neuroscience
In common: other, 1 reference
[10] doi:10.1073/pnas.2604933123
Neuromotor modules revealed by direct electrical stimulation of the human primary motor cortex.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: other, 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.