Skip to content

Commit 4a5fe1f

Browse files
authored
Feature/issue 177/output sib spt windows (#225)
* Initial changes to return SIB/SPT * More small changes * output sib/spt Use the nonwear_utils package to fix timing for sib/spt Update orchestrator +tests to output data * Move cleanup to own function in analytics * Renaming utils module * Update documentation * Update wristpy_tutorial.md This line is missing from the tutorial * Changes for review Remove abstract sleepdetector class as we have no plans to extend to new sleep detection algorithms in the immediate future Add new sleep parameters data class to store internal sleep parameters to be used in post-proc * Remove tuple returns and run function twice * Update test_analytics.py No need for List in 3.10+
1 parent d83646e commit 4a5fe1f

10 files changed

Lines changed: 141 additions & 66 deletions

File tree

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The main processing pipeline of the wristpy module can be described as follows:
3636
- ***Data imputation*** In the special case when dealing with the Actigraph `idle_sleep_mode == enabled`, the gaps in acceleration are filled in after calibration, to avoid biasing the calibration phase.
3737
- **Metrics Calculation**: Calculates various activity metrics on the calibrated data, namely ENMO (Euclidean norm, minus one), MAD (mean amplitude deviation) <sup>1</sup>, Actigraph activity counts<sup>2</sup>, MIMS (monitor-independent movement summary) unit <sup>3</sup>, and angle-Z (angle of acceleration relative to the *x-y* axis).
3838
- **Non-wear detection**: We find periods of non-wear based on the acceleration data. Specifically, the standard deviation of the acceleration values in a given time window, along each axis, is used as a threshold to decide `wear` or `not wear`. Additionally, we can use the temperature sensor, when avaia\lable, to augment the acceleration data. This is used in the CTA (combined temperature and acceleration) algorithm <sup>4</sup>, and in the `skdh` DETACH algorithm <sup>5</sup>. Furthermore, ensemble classification of non-wear periods is possible by providing a list (of any length) of non-wear algorithm options.
39-
- **Sleep Detection**: Using the HDCZ<sup>6</sup> and HSPT<sup>7</sup> algorithms to analyze changes in arm angle we are able to find periods of sleep. We find the sleep onset-wakeup times for all sleep windows detected. Any sleep periods that overlap with detected non-wear times are removed, and any remaining sleep periods shorter than 15 minutes (default value) are removed.
39+
- **Sleep Detection**: Using the HDCZ<sup>6</sup> and HSPT<sup>7</sup> algorithms to analyze changes in arm angle we are able to find periods of sleep. We find the sleep onset-wakeup times for all sleep windows detected. Any sleep periods that overlap with detected non-wear times are removed, and any remaining sleep periods shorter than 15 minutes (default value) are removed. Additionally, the SIB (sustained inactivity bouts) and the SPT (sleep period time) windows are provided as part of the output to aid in sleep metric post-processing.
4040
- **Physical activity levels**: Using the chosen physical activity metric (aggregated into time bins, 5 second default) we compute activity levels into the following categories: [`inactive`, `light`, `moderate`, `vigorous`]. The threshold values can be defined by the user, while the default values are chosen based on the specific activity metric and the values found in the literature <sup>8-10</sup>.
4141
- **Data output**: The output results can be saved in `.csv` or `.parquet` data formats, with the run-time configuration parameters saved in a `.json` dictionary.
4242

@@ -102,8 +102,11 @@ results = orchestrator.run(
102102
physical_activity_metric = results.physical_activity_metric
103103
anglez = results.anglez
104104
physical_activity_levels = results.physical_activity_levels
105-
nonwear_array = results.nonwear_epoch
106-
sleep_windows = results.sleep_windows_epoch
105+
nonwear_array = results.nonwear_status
106+
sleep_windows = results.sleep_status
107+
sib_periods = results.sib_periods
108+
spt_periods = results.spt_periods
109+
107110
```
108111
#### Running entire directories:
109112
```Python
@@ -131,8 +134,11 @@ subject1 = results_dict['subject1']
131134
physical_activity_metric = subject1.physical_activity_metric
132135
anglez = subject1.anglez
133136
physical_activity_levels = subject1.physical_activity_levels
134-
nonwear_array = subject1.nonwear_epoch
135-
sleep_windows = subject1.sleep_windows_epoch
137+
nonwear_array = subject1.nonwear_status
138+
sleep_windows = subject1.sleep_status
139+
sib_periods = subject1.sib_periods
140+
spt_periods = subject1.spt_periods
141+
136142
```
137143

138144
### Using Wristpy Through Docker

docs/wristpy_tutorial.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ We can also view and process these outputs from the saved `.csv` output file:
9696
import polars as pl
9797
import matplotlib.pyplot as plt
9898

99+
output_results = pl.read_csv('path/to/save/file_name.csv', try_parse_dates=True)
100+
99101
activity_mapping = {
100102
"inactive": 0,
101103
"light": 1,

src/wristpy/core/orchestrator.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
calibration,
1414
idle_sleep_mode_imputation,
1515
metrics,
16-
nonwear_utils,
16+
processing_utils,
1717
)
1818

1919
logger = config.get_logger()
@@ -292,15 +292,6 @@ def _run_file(
292292
if output is not None:
293293
writers.OrchestratorResults.validate_output(output=output)
294294

295-
parameters_dictionary = {
296-
"thresholds": list(thresholds),
297-
"calibrator": calibrator,
298-
"epoch_length": epoch_length,
299-
"activity_metric": activity_metric,
300-
"nonwear_algorithm": list(nonwear_algorithm),
301-
"input_file": str(input),
302-
}
303-
304295
if calibrator is not None and calibrator not in ["ggir", "gradient"]:
305296
msg = (
306297
f"Invalid calibrator: {calibrator}. Choose: 'ggir', 'gradient'. "
@@ -342,17 +333,14 @@ def _run_file(
342333
dynamic_range=watch_data.dynamic_range,
343334
)
344335

345-
sleep_detector = analytics.GgirSleepDetection(anglez)
346-
sleep_windows = sleep_detector.run_sleep_detection()
347-
348-
nonwear_array = nonwear_utils.get_nonwear_measurements(
336+
nonwear_array = processing_utils.get_nonwear_measurements(
349337
calibrated_acceleration=calibrated_acceleration,
350338
temperature=watch_data.temperature,
351339
non_wear_algorithms=nonwear_algorithm,
352340
)
353341

354-
nonwear_epoch = nonwear_utils.nonwear_array_cleanup(
355-
nonwear_array=nonwear_array,
342+
nonwear_epoch = processing_utils.synchronize_measurements(
343+
data_measurement=nonwear_array,
356344
reference_measurement=activity_measurement,
357345
epoch_length=epoch_length,
358346
)
@@ -361,14 +349,40 @@ def _run_file(
361349
activity_measurement, thresholds
362350
)
363351

352+
sleep_detector = analytics.GgirSleepDetection(anglez)
353+
sleep_parameters = sleep_detector.run_sleep_detection()
364354
sleep_array = analytics.sleep_cleanup(
365-
sleep_windows=sleep_windows, nonwear_measurement=nonwear_epoch
355+
sleep_windows=sleep_parameters.sleep_windows, nonwear_measurement=nonwear_epoch
356+
)
357+
spt_windows = analytics.sleep_bouts_cleanup(
358+
sleep_parameter=sleep_parameters.spt_windows,
359+
nonwear_measurement=nonwear_epoch,
360+
time_reference_measurement=activity_measurement,
361+
epoch_length=epoch_length,
362+
)
363+
sib_periods = analytics.sleep_bouts_cleanup(
364+
sleep_parameter=sleep_parameters.sib_periods,
365+
nonwear_measurement=nonwear_epoch,
366+
time_reference_measurement=activity_measurement,
367+
epoch_length=epoch_length,
366368
)
369+
370+
parameters_dictionary = {
371+
"thresholds": list(thresholds),
372+
"calibrator": calibrator,
373+
"epoch_length": epoch_length,
374+
"activity_metric": activity_metric,
375+
"nonwear_algorithm": list(nonwear_algorithm),
376+
"input_file": str(input),
377+
}
378+
367379
results = writers.OrchestratorResults(
368380
physical_activity_metric=activity_measurement,
369381
anglez=anglez,
370382
physical_activity_levels=physical_activity_levels,
371383
sleep_status=sleep_array,
384+
sib_periods=sib_periods,
385+
spt_periods=spt_windows,
372386
nonwear_status=nonwear_epoch,
373387
processing_params=parameters_dictionary,
374388
)

src/wristpy/io/writers/writers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class OrchestratorResults(pydantic.BaseModel):
2323
physical_activity_levels: models.Measurement
2424
nonwear_status: models.Measurement
2525
sleep_status: models.Measurement
26+
sib_periods: models.Measurement
27+
spt_periods: models.Measurement
2628
processing_params: Optional[Dict[str, Any]] = None
2729

2830
def save_results(self, output: pathlib.Path) -> None:

src/wristpy/processing/analytics.py

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Calculate sleep onset and wake up times."""
22

3-
import abc
43
import datetime
54
from dataclasses import dataclass
65
from typing import List, Tuple, Union
@@ -9,6 +8,7 @@
98
import polars as pl
109

1110
from wristpy.core import computations, config, models
11+
from wristpy.processing import processing_utils
1212

1313
logger = config.get_logger()
1414

@@ -26,27 +26,23 @@ class SleepWindow:
2626
wakeup: Union[datetime.datetime, List]
2727

2828

29-
class AbstractSleepDetector(abc.ABC):
30-
"""Abstract class defining the interface for sleep detection algorithms."""
31-
32-
@abc.abstractmethod
33-
def __init__(self, anglez: models.Measurement) -> None:
34-
"""Initialization function for the sleep detection algorithm.
35-
36-
Must contain the anglez data as an input.
37-
"""
38-
pass
29+
@dataclass
30+
class SleepParameters:
31+
"""Dataclass to store sleep parameters used to compute sleep metrics.
3932
40-
@abc.abstractmethod
41-
def run_sleep_detection(self) -> List[SleepWindow]:
42-
"""Sleep Detector must contain a run_sleep_detection function.
33+
Attributes:
34+
sleep_windows: a list of SleepWindow objects, each representing a sleep period
35+
with an onset and wakeup time.
36+
spt_windows: a Measurement object with the sleep period guider windows.
37+
sib_periods: a Measurement object with the sustained inactivity bouts.
38+
"""
4339

44-
The function must return a list of SleepWindow objects.
45-
"""
46-
pass
40+
sleep_windows: List[SleepWindow]
41+
spt_windows: models.Measurement
42+
sib_periods: models.Measurement
4743

4844

49-
class GgirSleepDetection(AbstractSleepDetector):
45+
class GgirSleepDetection:
5046
"""Sleep Detection algorithm based on the GGIR method.
5147
5248
This class implements the GGIR method for sleep detection. The method uses the
@@ -68,7 +64,9 @@ def __init__(
6864
"""
6965
self.anglez = anglez
7066

71-
def run_sleep_detection(self) -> List[SleepWindow]:
67+
def run_sleep_detection(
68+
self,
69+
) -> SleepParameters:
7270
"""Run the GGIR sleep detection.
7371
7472
This algorithm uses the angle-z data to first find potential sleep periods
@@ -77,8 +75,10 @@ def run_sleep_detection(self) -> List[SleepWindow]:
7775
the SPT windows and SIB periods.
7876
7977
Returns:
80-
A list of SleepWindow instances, each instance contains a sleep onset/wakeup
81-
time pair.
78+
A SleepParameters instance containing the underlying sleep parameters:
79+
- sleep_windows
80+
- spt_window
81+
- sib_periods
8282
"""
8383
logger.debug("Beginning sleep detection.")
8484
spt_window = self._spt_window(self.anglez)
@@ -91,7 +91,11 @@ def run_sleep_detection(self) -> List[SleepWindow]:
9191
logger.debug(
9292
"Sleep detection complete. Windows detected: %s", len(sleep_onset_wakeup)
9393
)
94-
return sleep_onset_wakeup
94+
return SleepParameters(
95+
sleep_windows=sleep_onset_wakeup,
96+
spt_windows=spt_window,
97+
sib_periods=sib_periods,
98+
)
9599

96100
def _spt_window(
97101
self, anglez_data: models.Measurement, threshold: float = 0.2
@@ -263,7 +267,7 @@ def _find_periods(
263267
264268
This is a helper function to return the periods in the format of
265269
List [start_of_period, end_of_period], it is used in the
266-
GGIRSleepDetection class and when removing non-wear periods from sleep windows.
270+
GGIRSleepDetection class.
267271
268272
Args:
269273
window_measurement: the Measurement instance, intended to be
@@ -424,6 +428,43 @@ def sleep_cleanup(
424428
return models.Measurement(time=sleep.time, measurements=cleaned_sleep)
425429

426430

431+
def sleep_bouts_cleanup(
432+
sleep_parameter: models.Measurement,
433+
nonwear_measurement: models.Measurement,
434+
time_reference_measurement: models.Measurement,
435+
epoch_length: float,
436+
) -> models.Measurement:
437+
"""This function will synchronize and filter the SPT and SIB windows.
438+
439+
The time sychrnoization is based on the time_reference_measurement, while the
440+
filtering is based on the nonwear_measurement.
441+
442+
Args:
443+
sleep_parameter: The sleep parameter measurement data, which contains
444+
either the SPT and SIB windows.
445+
nonwear_measurement: The nonwear measurement data used for reference time
446+
stamps and to remove overlaps with periods of sleep.
447+
time_reference_measurement: The time reference measurement data used for
448+
reference time stamps.
449+
epoch_length: The epoch length in seconds, used for resampling the data.
450+
451+
Returns:
452+
A tuple of two Measurement instances with the cleaned SPT and SIB data.
453+
"""
454+
logger.debug("Starting the sleep bouts cleanup.")
455+
sleep_parameter_sync = processing_utils.synchronize_measurements(
456+
data_measurement=sleep_parameter,
457+
reference_measurement=time_reference_measurement,
458+
epoch_length=epoch_length,
459+
)
460+
sleep_parameter_sync.measurements = np.logical_and(
461+
sleep_parameter_sync.measurements,
462+
np.logical_not(nonwear_measurement.measurements.astype(bool)),
463+
)
464+
465+
return sleep_parameter_sync
466+
467+
427468
def _sleep_windows_as_measurement(
428469
ref_measurement_time: pl.Series, sleep_windows: List[SleepWindow]
429470
) -> models.Measurement:

src/wristpy/processing/nonwear_utils.py renamed to src/wristpy/processing/processing_utils.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""This module contains helper functions for aggregated nonwear detection outputs."""
1+
"""This module contains helper functions for general processing of timeseries data."""
22

33
import datetime
44
from typing import Literal, Sequence, Union
@@ -160,22 +160,23 @@ def get_nonwear_measurements(
160160
return results[0]
161161

162162

163-
def nonwear_array_cleanup(
164-
nonwear_array: models.Measurement,
163+
def synchronize_measurements(
164+
data_measurement: models.Measurement,
165165
reference_measurement: models.Measurement,
166166
epoch_length: float = 5.0,
167167
) -> models.Measurement:
168-
"""This function is used to match the nonwear array to a reference Measurement.
168+
"""This function is used to match a Measurement object to a reference Measurement.
169169
170-
This function ensures that the nonwear array and reference Measurement times
171-
are synced up. This is accomplished by first resampling the nonwear array to
170+
This function ensures that a Measurement object and reference Measurement times
171+
are synced up. This is accomplished by first resampling a Measurement object to
172172
the specified temporal resolution.
173-
It also ensures that the nonwear array is a binary array, where 1 indicates
173+
It also ensures that a Measurement object is a binary array, where 1 indicates
174174
nonwear and 0 indicates wear.
175-
It then truncates the nonwear array to match the reference Measurement time points.
175+
It then truncates a Measurement object to match the reference
176+
Measurement time points.
176177
177178
Args:
178-
nonwear_array: The nonwear array to clean up.
179+
data_measurement: The Measurement object that requires time syncing.
179180
reference_measurement: The reference measurement to use for resampling.
180181
epoch_length: The temporal resolution of the output, in seconds.
181182
Defaults to 5.0.
@@ -185,7 +186,7 @@ def nonwear_array_cleanup(
185186
returned as a boolean (True == nonwear).
186187
"""
187188
time_fix_nonwear = _time_fix(
188-
nonwear_array, reference_measurement.time[-1], reference_measurement.time[0]
189+
data_measurement, reference_measurement.time[-1], reference_measurement.time[0]
189190
)
190191
resampled_nonwear = computations.resample(time_fix_nonwear, epoch_length)
191192
binary_nonwear = np.where(resampled_nonwear.measurements >= 0.5, 1, 0)

tests/unit/test_analytics.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import datetime
44
import math
5-
from typing import List
65

76
import numpy as np
87
import polars as pl
@@ -160,8 +159,10 @@ def test_run_sleep_detection(sleep_detection: analytics.GgirSleepDetection) -> N
160159
"""Test the full sleep detection process."""
161160
result = sleep_detection.run_sleep_detection()
162161

163-
assert result == []
164-
assert isinstance(result, List)
162+
assert result.sleep_windows == []
163+
assert isinstance(result.sleep_windows, list)
164+
assert isinstance(result.spt_windows, models.Measurement)
165+
assert isinstance(result.sib_periods, models.Measurement)
165166

166167

167168
def test_physical_activity_thresholds() -> None:

tests/unit/test_orchestrator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def dummy_results() -> writers.OrchestratorResults:
2929
physical_activity_levels=dummy_measure,
3030
nonwear_status=dummy_measure,
3131
sleep_status=dummy_measure,
32+
sib_periods=dummy_measure,
33+
spt_periods=dummy_measure,
3234
)
3335

3436
return dummy_results

0 commit comments

Comments
 (0)