Skip to content

Commit 18c0f42

Browse files
committed
feat: resolve VU-AMS condition codes to human labels via .cfg file
The EDF loader now looks for a VU-AMS condition-label file (.cfg) next to the .edf file. Search order: 1. <same_stem>.cfg (exact match) 2. Any single *.cfg in the same directory (fallback for mismatched names) The .cfg format is "<number> <label>" lines (# comment lines ignored). Annotation codes like "10_" are stripped of their trailing underscore and looked up in the mapping, so epochs are named "Lying_supine", "Standing", "Sitting", etc. instead of raw numeric codes. Also adds ExampleData/data/VU-AMS_5fs_example_data_multiplesigs.cfg with the 39-condition protocol used in the example recording. https://claude.ai/code/session_012UqMHtVPfm4SkQiFnTYBmF
1 parent edccd44 commit 18c0f42

2 files changed

Lines changed: 115 additions & 3 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#Experimental Condition
2+
10 Lying_supine
3+
11 Standing
4+
12 Sitting
5+
13 TA_practice
6+
14 TA
7+
15 Recov1
8+
16 SSST_Read1
9+
17 SSST_Countdown1
10+
18 SSST_Read2
11+
19 SSST_Countdown2
12+
20 SSST_Read-Speak
13+
21 SSST_Speak_countdown
14+
22 SSST_Speak
15+
23 SSST_Read3
16+
24 SSST_Countdown3
17+
25 SSST_Read-sing
18+
26 SSST_Sing_countdown
19+
27 SSST_Sing
20+
28 Recov2
21+
29 Pasat_practice
22+
30 Pasat
23+
31 Recov3
24+
32 Raven
25+
33 Walking_own_pace
26+
34 Walking_fast_pace
27+
35 Cycling
28+
36 stairs_up_and_down
29+
37 Recov_standing
30+
38 Dishes
31+
39 Vacuum
32+
40 Recov4
33+
41 TA_repeat
34+
42 Recov5
35+
43 Pasat_repeat
36+
44 Treadmill1
37+
45 Treadmill2
38+
46 Treadmill3
39+
47 Treadmill4
40+
48 Recov6
41+

src/spectHR/DataSet/loaders/edf_loader.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""EDF / EDF+C loader — primary target is VU-AMS 5fs exports."""
44
from __future__ import annotations
55

6+
from pathlib import Path
67
import numpy as np
78

89
from spectHR.DataSet.Series.TimeSeries import TimeSeries
@@ -217,6 +218,72 @@ def _acc_to_rsp(acc: np.ndarray, fs: float) -> np.ndarray:
217218

218219
# ---------------------------------------------------------------------------
219220
# VU-AMS EDF / EDF+C loader
221+
# ---------------------------------------------------------------------------
222+
# VU-AMS .cfg condition-label parser
223+
# ---------------------------------------------------------------------------
224+
225+
def _load_vuams_cfg(edf_path: str) -> dict[str, str]:
226+
"""
227+
Look for a VU-AMS condition-label file (.cfg) alongside the EDF file and
228+
return a mapping from condition number string to human-readable label.
229+
230+
Search order
231+
------------
232+
1. ``<same_stem>.cfg`` in the same directory (exact name match)
233+
2. Any single ``*.cfg`` file in the same directory (fallback for the
234+
common case where VU-AMS exports the cfg under a different base name)
235+
3. Nothing found → return empty dict (labels stay as raw codes)
236+
237+
Format expected
238+
---------------
239+
Lines are ``<number> <label>``; lines starting with ``#`` are comments.
240+
"""
241+
edf_path_obj = Path(edf_path)
242+
candidates: list[Path] = []
243+
244+
# 1. exact stem match
245+
exact = edf_path_obj.with_suffix(".cfg")
246+
if exact.exists():
247+
candidates = [exact]
248+
else:
249+
# 2. any .cfg in the same directory
250+
candidates = list(edf_path_obj.parent.glob("*.cfg"))
251+
252+
if not candidates:
253+
return {}
254+
if len(candidates) > 1:
255+
logger.warning(
256+
"EDF loader: multiple .cfg files found; skipping condition-label "
257+
"lookup. Place a single .cfg next to the .edf to enable labeling."
258+
)
259+
return {}
260+
261+
cfg_path = candidates[0]
262+
mapping: dict[str, str] = {}
263+
try:
264+
with open(cfg_path, encoding="utf-8", errors="replace") as f:
265+
for line in f:
266+
line = line.strip()
267+
if not line or line.startswith("#"):
268+
continue
269+
parts = line.split(None, 1)
270+
if len(parts) == 2:
271+
mapping[parts[0]] = parts[1]
272+
logger.info(
273+
f"EDF loader: loaded {len(mapping)} condition labels from {cfg_path.name}"
274+
)
275+
except OSError as exc:
276+
logger.warning(f"EDF loader: could not read {cfg_path}: {exc}")
277+
278+
return mapping
279+
280+
281+
def _vuams_label(code: str, cfg: dict[str, str]) -> str:
282+
"""Resolve a VU-AMS annotation code (e.g. '10_') to a human label."""
283+
key = code.rstrip("_")
284+
return cfg.get(key, code)
285+
286+
220287
# ---------------------------------------------------------------------------
221288

222289
@register_loader(".edf")
@@ -360,20 +427,24 @@ def _timestamps(n_per_rec: int, total_samples: int) -> np.ndarray:
360427
# EDF+C annotations → EventSeries
361428
# Duration-based annotations become start/stop epoch pairs so that
362429
# spectHR's epoch builder can derive epochs automatically.
430+
# A VU-AMS .cfg file (same dir) maps numeric codes to readable labels.
363431
# ------------------------------------------------------------------
432+
cfg = _load_vuams_cfg(filename)
433+
364434
if annotations:
365435
ev_times: list[float] = []
366436
ev_labels: list[str] = []
367437

368438
for onset, duration, text in annotations:
439+
label = _vuams_label(text, cfg)
369440
if duration > 0:
370441
ev_times.append(onset)
371-
ev_labels.append(f"start {text}")
442+
ev_labels.append(f"start {label}")
372443
ev_times.append(onset + duration)
373-
ev_labels.append(f"stop {text}")
444+
ev_labels.append(f"stop {label}")
374445
else:
375446
ev_times.append(onset)
376-
ev_labels.append(text)
447+
ev_labels.append(label)
377448

378449
sort_order = np.argsort(ev_times)
379450
ev_times_arr = np.array(ev_times, dtype=float)[sort_order]

0 commit comments

Comments
 (0)