Skip to content

Commit ccba394

Browse files
authored
Merge pull request #2 from simon-p-2000/main
Adding new classifiers, extending feature extraction (cbrnr#262)
2 parents 054ad45 + d1fe7b3 commit ccba394

4 files changed

Lines changed: 46 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
## [UNRELEASED] - YYYY-MM-DD
2+
## Added
3+
- Add support for activity counts feature ([#262](https://github.com/cbrnr/sleepecg/pull/262) by [Simon Pusterhofer](https://github.com/simon-p-2000))
24

35
## [0.5.9] - 2025-02-01
46
### Added

docs/feature_extraction.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
# Feature extraction
22

33
## Heart rate variability features
4+
45
Features are based on standards of heart rate variability (HRV) measurement and interpretation described in [Task Force of the European Society of Cardiology (1996)](https://doi.org/10.1161/01.CIR.93.5.1043) and [Shaffer & Ginsberg (2017)](https://doi.org/10.3389/fpubh.2017.00258).
56

7+
68
### Time domain
9+
710
Group identifier: `hrv-time`
811

912
All time domain HRV features are either derived from normal-to-normal (NN) intervals, from successive differences between NN intervals (SD), or from the [Poincaré plot (PP)](https://en.wikipedia.org/wiki/Poincar%C3%A9_plot).
@@ -41,7 +44,9 @@ All time domain HRV features are either derived from normal-to-normal (NN) inter
4144
|`CSI`|cardiac sympathetic index|PP|
4245
|`CVI`|cardiac vagal index|PP|
4346

47+
4448
### Frequency domain
49+
4550
Group identifier: `hrv-frequency`
4651

4752
For calculating frequency domain HRV features, the RR time series is resampled at regular intervals, after which the power spectral density (PSD) is estimated using [Welch's method](https://en.wikipedia.org/wiki/Welch%27s_method).
@@ -58,6 +63,7 @@ For calculating frequency domain HRV features, the RR time series is resampled a
5863

5964

6065
## Metadata features
66+
6167
Group identifier: `metadata`
6268

6369
|Feature|Description|
@@ -66,3 +72,12 @@ Group identifier: `metadata`
6672
|`age`|age of the subject in years|
6773
|`gender`|`0` (female) or `1` (male)|
6874
|`weight`|weight of the subject in kg|
75+
76+
77+
## Actigraphy features
78+
79+
Group identifier: `actigraphy`
80+
81+
| Feature | Description |
82+
|-------------------|--------------------------------------------------------------------------------------------------------|
83+
| `activity_counts` | Philips Actiwatch proprietary metric to quantify amount of patient movement measured via accelerometry |

src/sleepecg/io/sleep_readers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,12 +493,13 @@ def read_mesa(
493493
activity_counts = np.array(activity_counts)
494494

495495
diff = len(activity_counts) - len(parsed_xml.sleep_stages)
496-
if np.abs(diff) > 2:
496+
497+
if abs(diff) > 2:
497498
print(f"Skipping {record_id} due to invalid activity counts.")
498499
continue
499-
elif 0 < diff <= 2:
500+
elif diff > 0:
500501
activity_counts = activity_counts[:-diff]
501-
elif 0 < diff * -1 <= 2:
502+
elif diff < 0:
502503
activity_counts = np.append(activity_counts, activity_counts[diff:])
503504

504505
activity_counts[activity_counts == ""] = "0"

tests/test_sleep_readers.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,26 @@ def _dummy_nsrr_overlap(filename: str, mesa_ids: list[int]):
2424
csv.write(f"{mesa_ids[i][-1]},1,20:30:00,20:29:59\n")
2525

2626

27-
def _dummy_nsrr_actigraphy(filename: str, mesa_id: str):
27+
def _dummy_nsrr_actigraphy(filename: str, mesa_id: str, hours: float):
2828
"""Create dummy actigraphy file with four usable activity counts."""
2929
base_time = datetime.datetime(2024, 1, 1, 20, 30, 0)
30-
30+
# hours * 3600 / 30 second epoch, additional 20 counts for safety
31+
number_activity_counts = int(hours * 120) + 20
3132
linetimes = [
3233
(base_time + datetime.timedelta(seconds=30 * i)).strftime("%H:%M:%S")
33-
for i in range(10)
34+
for i in range(number_activity_counts)
3435
]
3536

3637
with open(filename, "w") as csv:
3738
csv.write("mesaid,line,linetime,activity\n")
38-
for i in range(10):
39+
for i in range(number_activity_counts):
3940
csv.write(f"{mesa_id[-1]},{1 + i},{linetimes[i]},10\n")
4041

4142

42-
def _dummy_nsrr_actigraphy_cached(filename: str):
43+
def _dummy_nsrr_actigraphy_cached(filename: str, hours: float):
4344
"""Create dummy npy file that resembles cached activity counts."""
44-
activity_counts = np.array([10, 10, 10, 10, 10, 10])
45+
number_activity_counts = int(hours * 120)
46+
activity_counts = np.array([10 for i in range(number_activity_counts)])
4547
np.save(filename, activity_counts)
4648

4749

@@ -54,7 +56,6 @@ def _dummy_nsrr_edf(filename: str, hours: float, ecg_channel: str):
5456

5557
def _dummy_nsrr_xml(filename: str, hours: float, random_state: int):
5658
EPOCH_LENGTH = 30
57-
RECORDING_DURATION = 154.0
5859
STAGES = [
5960
"Wake|0",
6061
"Stage 1 sleep|1",
@@ -66,7 +67,7 @@ def _dummy_nsrr_xml(filename: str, hours: float, random_state: int):
6667
]
6768

6869
rng = np.random.default_rng(random_state)
69-
70+
record_duration = hours * 60 * 60
7071
with open(filename, "w") as xml_file:
7172
xml_file.write(
7273
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n'
@@ -76,16 +77,16 @@ def _dummy_nsrr_xml(filename: str, hours: float, random_state: int):
7677
"<ScoredEvent>\n"
7778
"<EventType/>\n"
7879
"<EventConcept>Recording Start Time</EventConcept>\n"
79-
f"<Duration>{RECORDING_DURATION}</Duration>\n"
80+
f"<Duration>{record_duration}</Duration>\n"
8081
"<ClockTime>01.01.85 20.29.59</ClockTime>\n"
8182
"</ScoredEvent>\n",
8283
)
83-
record_duration = hours * 60 * 60
8484
start = 0
85-
while True:
86-
if start > record_duration:
87-
break
88-
epoch_duration = rng.choice(np.arange(4, 21)) * EPOCH_LENGTH
85+
while start < record_duration:
86+
# choose a candidate epoch duration in seconds.
87+
epoch_duration_candidate = rng.choice(np.arange(4, 21)) * EPOCH_LENGTH
88+
# use the remaining time if the candidate overshoots the record duration
89+
epoch_duration = min(epoch_duration_candidate, record_duration - start)
8990
stage = rng.choice(STAGES)
9091
xml_file.write(
9192
"<ScoredEvent>\n"
@@ -134,9 +135,11 @@ def _create_dummy_mesa(
134135
_dummy_nsrr_edf(f"{edf_dir}/{record_id}.edf", hours, ecg_channel="EKG")
135136
_dummy_nsrr_xml(f"{annotations_dir}/{record_id}-nsrr.xml", hours, random_state)
136137
if actigraphy:
137-
_dummy_nsrr_actigraphy(f"{activity_dir}/{record_id}.csv", mesa_id=record_id)
138+
_dummy_nsrr_actigraphy(
139+
f"{activity_dir}/{record_id}.csv", mesa_id=record_id, hours=hours
140+
)
138141
_dummy_nsrr_actigraphy_cached(
139-
f"{activity_counts_dir}/{record_id}-activity-counts.npy"
142+
f"{activity_counts_dir}/{record_id}-activity-counts.npy", hours
140143
)
141144
record_ids.append(record_id)
142145

@@ -213,10 +216,12 @@ def test_read_mesa_actigraphy(tmp_path):
213216

214217
assert len(records) == 2
215218

216-
for rec in records:
219+
for i, rec in enumerate(records):
217220
assert rec.sleep_stage_duration == 30
218221
assert set(rec.sleep_stages) - valid_stages == set()
219-
assert len(rec.activity_counts) == 4
222+
# multiply with 3600 to convert duration (hours) to seconds, divide by 30 (epoch
223+
# length for this test)
224+
assert len(rec.activity_counts) == int(durations[i] * 120)
220225
assert Path(
221226
f"{tmp_path}/mesa/preprocessed/activity_counts/{rec.id}-activity-counts.npy"
222227
).exists()
@@ -239,10 +244,10 @@ def test_read_mesa_actigraphy_cached(tmp_path):
239244

240245
assert len(records) == 2
241246

242-
for rec in records:
247+
for i, rec in enumerate(records):
243248
assert rec.sleep_stage_duration == 30
244249
assert set(rec.sleep_stages) - valid_stages == set()
245-
assert len(rec.activity_counts) == 6
250+
assert len(rec.activity_counts) == int(durations[i] * 120)
246251

247252

248253
def test_read_shhs(tmp_path):

0 commit comments

Comments
 (0)