Skip to content

Commit 8b389c7

Browse files
authored
Merge pull request #186 from MIT-LCP/fix/empatica-validation
Recognize Empatica E4 exports in dataset validation
2 parents f4d6721 + 9dbede2 commit 8b389c7

18 files changed

Lines changed: 805 additions & 69 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ dataset directory.
142142
- **privacy** - PHI patterns (SSN, email, phone numbers), date patterns, and
143143
sensitive file detection.
144144

145+
CSV exports from the Empatica E4 wearable are recognized, so their headerless
146+
layout, empty `tags.csv` files, and Unix timestamps don't produce false errors
147+
or privacy warnings. This doesn't check that timestamps are de-identified.
148+
145149
**Examples:**
146150

147151
```bash
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Choose the CSV layout used by all validation checks.
2+
3+
Additional formats can provide a detector returning CsvLayout or None and add
4+
it to _FORMAT_DETECTORS. Ordinary headered CSV remains the default.
5+
"""
6+
7+
from pathlib import Path
8+
from typing import List
9+
10+
from physionet.validate.checks.csv_layout import CsvLayout
11+
from physionet.validate.checks.empatica import get_e4_csv_layout
12+
13+
14+
_FORMAT_DETECTORS = (get_e4_csv_layout,)
15+
16+
17+
def find_files_by_suffix(path: Path, suffix: str) -> List[Path]:
18+
"""Find files under path whose extension matches suffix in any letter case."""
19+
suffix = suffix.lower()
20+
return [p for p in path.rglob("*") if p.suffix.lower() == suffix and p.is_file()]
21+
22+
23+
def get_csv_layout(path: Path) -> CsvLayout:
24+
"""Use a recognized layout or ordinary headered CSV rules."""
25+
for detect in _FORMAT_DETECTORS:
26+
layout = detect(path)
27+
if layout is not None:
28+
return layout
29+
return CsvLayout()
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Shared CSV layout information for validation checks."""
2+
3+
import csv
4+
from dataclasses import dataclass
5+
from typing import Optional, TextIO, Tuple
6+
7+
8+
@dataclass(frozen=True)
9+
class CsvLayout:
10+
"""Describe which CSV rows are metadata and how data columns are named.
11+
12+
With no explicit columns, the first row is treated as a header. Explicit
13+
columns describe a headerless file; metadata_rows precede its data rows.
14+
A file with no data rows gets a warning unless no_data_message is set, in
15+
which case that message is reported as information.
16+
"""
17+
18+
columns: Optional[Tuple[str, ...]] = None
19+
metadata_rows: int = 0
20+
allow_empty: bool = False
21+
empty_message: Optional[str] = None
22+
no_data_message: Optional[str] = None
23+
# Columns whose Unix timestamps are not reported as phone numbers.
24+
unix_timestamp_columns: Tuple[str, ...] = ()
25+
26+
def dict_reader(self, stream: TextIO) -> csv.DictReader:
27+
"""Read data rows under the layout's column names."""
28+
reader = csv.DictReader(stream, fieldnames=self.columns)
29+
for _ in range(self.metadata_rows):
30+
next(reader, None)
31+
return reader
32+
33+
34+
def is_unix_timestamp(value: str) -> bool:
35+
"""Recognize nine- or ten-digit Unix seconds, including shifted dates."""
36+
whole, separator, fraction = value.strip().partition(".")
37+
if len(whole) not in (9, 10) or not whole.isdigit() or whole[0] == "0":
38+
return False
39+
if separator and (not fraction or not fraction.isdigit()):
40+
return False
41+
return True
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Recognize the headerless CSV layout exported by Empatica E4 Connect.
2+
3+
Format: https://www.empatica.com/blog/decoding-wearable-sensor-signals-what-to-expect-from-your-e4-data/
4+
5+
- ACC, BVP, EDA, HR, TEMP: a row of Unix start times, a row of sample rates,
6+
then samples. Data columns are named acc_x/acc_y/acc_z, bvp, eda, hr, temp,
7+
which is what ValidationConfig.value_ranges keys should use.
8+
- IBI: a "<start time>, IBI" row, then time_offset,ibi rows. A file with only
9+
the first row means no beats were detected and is reported as information.
10+
- tags: one event time per row, no header; empty means no events. It is only
11+
treated as E4 when a recognized sensor or IBI file sits beside it, and only
12+
its event_time column is exempt from the phone-number pattern.
13+
14+
File names are matched in any letter case. tests/fixtures/e4_session holds a
15+
small synthetic example.
16+
"""
17+
18+
import csv
19+
from pathlib import Path
20+
from typing import Optional, Tuple
21+
22+
from physionet.validate.checks.csv_layout import CsvLayout, is_unix_timestamp
23+
24+
25+
# Keyed by lowercase file name; E4 Connect writes upper-case names, but
26+
# re-exported or hand-tidied datasets sometimes lowercase them.
27+
_SENSOR_LAYOUTS = {
28+
"acc.csv": (("acc_x", "acc_y", "acc_z"), 32),
29+
"bvp.csv": (("bvp",), 64),
30+
"eda.csv": (("eda",), 4),
31+
"hr.csv": (("hr",), 1),
32+
"temp.csv": (("temp",), 4),
33+
}
34+
35+
_IBI_COLUMNS = ("time_offset", "ibi")
36+
37+
38+
def _read_first_rows(file_path: Path, count: int) -> Optional[list]:
39+
"""Return the first rows of a CSV file, or None if it cannot supply them."""
40+
try:
41+
with file_path.open(newline="", encoding="utf-8") as stream:
42+
reader = csv.reader(stream)
43+
return [next(reader) for _ in range(count)]
44+
except (OSError, UnicodeError, StopIteration, csv.Error):
45+
return None
46+
47+
48+
def is_e4_sensor_file(file_path: Path) -> bool:
49+
"""Match a sensor's timestamp and sample-rate rows, without reading its data."""
50+
layout = _SENSOR_LAYOUTS.get(file_path.name.lower())
51+
if not layout or not file_path.is_file():
52+
return False
53+
54+
rows = _read_first_rows(file_path, 2)
55+
if rows is None:
56+
return False
57+
timestamps, rates = rows
58+
columns, sample_rate = layout
59+
channels = len(columns)
60+
try:
61+
return (
62+
len(timestamps) == channels
63+
and len(rates) == channels
64+
and all(is_unix_timestamp(value) for value in timestamps)
65+
and all(float(value) == sample_rate for value in rates)
66+
)
67+
except ValueError:
68+
return False
69+
70+
71+
def e4_sensor_columns(file_path: Path) -> Optional[Tuple[str, ...]]:
72+
"""Return positional channel names for a recognized E4 sensor file."""
73+
if is_e4_sensor_file(file_path):
74+
return _SENSOR_LAYOUTS[file_path.name.lower()][0]
75+
return None
76+
77+
78+
def is_e4_ibi_file(file_path: Path) -> bool:
79+
"""Match IBI.csv, whose first row is the start time followed by 'IBI'."""
80+
if file_path.name.lower() != "ibi.csv" or not file_path.is_file():
81+
return False
82+
rows = _read_first_rows(file_path, 1)
83+
if rows is None:
84+
return False
85+
first = rows[0]
86+
return len(first) == 2 and is_unix_timestamp(first[0]) and first[1].strip() == "IBI"
87+
88+
89+
def is_e4_tags_file(file_path: Path) -> bool:
90+
"""Identify event tags only when a neighboring E4 sensor or IBI file confirms the format."""
91+
if file_path.name.lower() != "tags.csv":
92+
return False
93+
try:
94+
siblings = list(file_path.parent.iterdir())
95+
except OSError:
96+
return False
97+
return any(is_e4_sensor_file(sibling) or is_e4_ibi_file(sibling) for sibling in siblings)
98+
99+
100+
def get_e4_csv_layout(file_path: Path) -> Optional[CsvLayout]:
101+
"""Describe recognized E4 sensor, IBI, and event-tag files for CSV checks."""
102+
columns = e4_sensor_columns(file_path)
103+
if columns is not None:
104+
return CsvLayout(columns=columns, metadata_rows=2)
105+
if is_e4_ibi_file(file_path):
106+
return CsvLayout(
107+
columns=_IBI_COLUMNS,
108+
metadata_rows=1,
109+
no_data_message="No Empatica E4 inter-beat intervals detected (IBI.csv has only its start-time row)",
110+
)
111+
if is_e4_tags_file(file_path):
112+
return CsvLayout(
113+
columns=("event_time",),
114+
allow_empty=True,
115+
empty_message="No Empatica E4 event markers recorded (empty tags.csv)",
116+
unix_timestamp_columns=("event_time",),
117+
)
118+
return None

‎physionet/validate/checks/filesystem.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from physionet.validate.models import CheckResult, ValidationIssue, CheckCategory, Severity
77
from physionet.validate.config import ValidationConfig
8+
from physionet.validate.checks.csv_formats import get_csv_layout
89

910
# Proprietary file formats and their recommended open alternatives
1011
PROPRIETARY_FORMATS = {
@@ -93,6 +94,17 @@ def check_filesystem(path: Path, config: ValidationConfig) -> CheckResult:
9394
try:
9495
size = file_path.stat().st_size
9596
if size == 0:
97+
layout = get_csv_layout(file_path)
98+
if layout.allow_empty:
99+
result.issues.append(
100+
ValidationIssue(
101+
severity=Severity.INFO,
102+
category=CheckCategory.FILESYSTEM,
103+
file=str(file_path.relative_to(path)),
104+
message=layout.empty_message or "Empty file is valid for this CSV layout",
105+
)
106+
)
107+
continue
96108
result.issues.append(
97109
ValidationIssue(
98110
severity=Severity.WARNING,

‎physionet/validate/checks/integrity.py‎

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from physionet.validate.models import CheckResult, ValidationIssue, CheckCategory, Severity
88
from physionet.validate.config import ValidationConfig
9+
from physionet.validate.checks.csv_formats import find_files_by_suffix, get_csv_layout
910

1011

1112
def check_integrity(path: Path, config: ValidationConfig, progress_callback: Optional[Callable[[str], None]] = None) -> CheckResult:
@@ -28,7 +29,7 @@ def check_integrity(path: Path, config: ValidationConfig, progress_callback: Opt
2829
result = CheckResult(category=CheckCategory.INTEGRITY)
2930

3031
# Find and validate CSV files
31-
csv_files = list(path.rglob("*.csv"))
32+
csv_files = find_files_by_suffix(path, ".csv")
3233
for i, csv_file in enumerate(csv_files):
3334
if progress_callback:
3435
progress_callback(f"Checking {csv_file.name} ({i+1}/{len(csv_files)} CSV files)")
@@ -43,24 +44,27 @@ def check_integrity(path: Path, config: ValidationConfig, progress_callback: Opt
4344

4445
def _validate_csv_structure(csv_file: Path, base_path: Path, result: CheckResult) -> None:
4546
"""Validate CSV file structure."""
47+
layout = get_csv_layout(csv_file)
4648
try:
4749
with open(csv_file, "r", encoding="utf-8") as f:
4850
# Try to detect dialect
4951
sample = f.read(1024)
5052
f.seek(0)
5153

5254
try:
53-
dialect = csv.Sniffer().sniff(sample)
55+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
5456
except csv.Error:
5557
# Use default dialect if detection fails
5658
dialect = csv.excel
5759

5860
reader = csv.reader(f, dialect)
5961

60-
# Read header
62+
# Read the first row, which is a header for ordinary CSV files.
6163
try:
62-
header = next(reader)
64+
first_row = next(reader)
6365
except StopIteration:
66+
if layout.allow_empty:
67+
return
6468
result.issues.append(
6569
ValidationIssue(
6670
severity=Severity.ERROR,
@@ -71,7 +75,7 @@ def _validate_csv_structure(csv_file: Path, base_path: Path, result: CheckResult
7175
)
7276
return
7377

74-
if not header:
78+
if not first_row:
7579
result.issues.append(
7680
ValidationIssue(
7781
severity=Severity.ERROR,
@@ -82,33 +86,41 @@ def _validate_csv_structure(csv_file: Path, base_path: Path, result: CheckResult
8286
)
8387
return
8488

85-
# Check for duplicate column names
86-
if len(header) != len(set(header)):
87-
duplicates = [col for col in header if header.count(col) > 1]
88-
result.issues.append(
89-
ValidationIssue(
90-
severity=Severity.ERROR,
91-
category=CheckCategory.INTEGRITY,
92-
file=str(csv_file.relative_to(base_path)),
93-
message=f"Duplicate column names found: {', '.join(set(duplicates))}",
89+
if layout.columns is None:
90+
header = first_row
91+
if len(header) != len(set(header)):
92+
duplicates = [col for col in header if header.count(col) > 1]
93+
result.issues.append(
94+
ValidationIssue(
95+
severity=Severity.ERROR,
96+
category=CheckCategory.INTEGRITY,
97+
file=str(csv_file.relative_to(base_path)),
98+
message=f"Duplicate column names found: {', '.join(set(duplicates))}",
99+
)
94100
)
95-
)
96-
97-
# Check for empty column names
98-
if any(not col.strip() for col in header):
99-
result.issues.append(
100-
ValidationIssue(
101-
severity=Severity.ERROR,
102-
category=CheckCategory.INTEGRITY,
103-
file=str(csv_file.relative_to(base_path)),
104-
message="CSV contains empty column names",
101+
if any(not col.strip() for col in header):
102+
result.issues.append(
103+
ValidationIssue(
104+
severity=Severity.ERROR,
105+
category=CheckCategory.INTEGRITY,
106+
file=str(csv_file.relative_to(base_path)),
107+
message="CSV contains empty column names",
108+
)
105109
)
106-
)
110+
expected_cols = len(header)
111+
first_data_line = 2
112+
else:
113+
# Headerless files have explicit names; validate their first data row.
114+
f.seek(0)
115+
reader = csv.reader(f, dialect)
116+
for _ in range(layout.metadata_rows):
117+
next(reader, None)
118+
expected_cols = len(layout.columns)
119+
first_data_line = layout.metadata_rows + 1
107120

108121
# Validate row consistency
109-
expected_cols = len(header)
110122
row_count = 0
111-
for line_num, row in enumerate(reader, start=2): # Start at 2 (after header)
123+
for line_num, row in enumerate(reader, start=first_data_line):
112124
row_count += 1
113125
if len(row) != expected_cols:
114126
result.issues.append(
@@ -132,13 +144,13 @@ def _validate_csv_structure(csv_file: Path, base_path: Path, result: CheckResult
132144
)
133145
break
134146

135-
if row_count == 0:
147+
if row_count == 0 and not layout.allow_empty:
136148
result.issues.append(
137149
ValidationIssue(
138-
severity=Severity.WARNING,
150+
severity=Severity.INFO if layout.no_data_message else Severity.WARNING,
139151
category=CheckCategory.INTEGRITY,
140152
file=str(csv_file.relative_to(base_path)),
141-
message="CSV file contains only header row (no data)",
153+
message=layout.no_data_message or "CSV file contains only header row (no data)",
142154
)
143155
)
144156

0 commit comments

Comments
 (0)