Skip to content

Commit 51fa4b5

Browse files
Feature/issue 14/combined temperature acceleration nonwear detect (#141)
* Update metrics.py Initial implementation of CTA non wear detect * Update metrics.py switch to float as input * Update metrics.py Handling uneven time sampling between temp and acceleration Adding moving average as low pass filter * Added initial unit tests + rearrange function Added helper function and re-arranged +simplified main algorithm Added first unit tests * Update metrics.py Fixed acceleration SD criteria to be for at least 2 out of 3 axis to agree with paper publication * Update test_metrics.py Added coverage for second criteria when accel criteria not met * Update metrics.py Switch to if:elif logic for clarity * Integrate scikit support Adding skdh DETACH algorithm Addressed initial review comments for CTA * Update test_metrics.py Fix _pre_process_temperature naming * Modify tests for macos, added unit test for DETACH Added homebrew install for libomp for skdh support Added initial unit test for DETACH (not good) * Update metrics.py Remove == comparison since mean_temp is float Fixed scipy.interpolate import #Note directly importing skdh.preprocessing.DETACH (skdh did not properly set dependencies?) * Update test_metrics.py Fix for unit tests with non-decreasing temperature * Update metrics.py Fix imports * Update test_metrics.py Additional test for the final if (for decreasing temps under temp_threshold) * Adding majority vote function + resample function Initial version of majority vote function and the resample function to be used to assess nonwear detection performance * Modifications to enable cross comparison of nonwear Changed temporal resolution of DETACH to one minute Removed monotonically increasing requirement for resample * Update test_computations.py Test diff start time * Update test_computations.py Add coverage for ValueError because why not * Update computations.py Change default temporal resolution to one minute Rounding for nonwear measurements after resampling * Update test_computations.py Test needs to have different temporal resolution otherwise resample doesn't get called * Update poetry.lock * Update metrics.py * Update poetry.lock resolve merge conflicts * Update metrics.py * Added combined ggir_detach ensenmble Ensemble nonwear for ggir + detach Added tests and fixed some variable naming * Update poetry.lock * Update test_calibration.py * Adding in logic support for nonwear type into orchestrator First pass at logic support for the nonwear alogirthms. Defaults to GGIR for all cases (temperature sensor or not) * Test nonwear options Unit test failing due to data size of sample bin file * Add cli support for nonwear algo Adding in cli support for nonwear Adding test support Using argparse, will investigate typer soon to replace this * Add longer sample data for DETACH Tests for orchestrator, new example data + move nonwear regrouping into the nonwear definition * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix dependency addition Remove extras from pyproject.toml and poetry lock file * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Moving into nonwear_utils Moving some function into nonwear_utils module * Update orchestrator.py * Addressing PR comments * Initial changes for PR * Adding in support for nonwear dispatcher * Final edits to cli to support multiple nw algos * Fixes after chaning var name in get_nonwear_measurements() * Update metrics.py Forgot to change year of reference. * Refactoring nonwear implementation * Update orchestrator.py * First pass removing None epoch_length * Update nonwear_utils.py * Update nonwear_utils.py * Epoch length ValueError check * Removing support for epoch_length = None in enmo and anglez * Update orchestrator.py ValueError is raised in internal function. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d8ab060 commit 51fa4b5

13 files changed

Lines changed: 2140 additions & 402 deletions

File tree

.github/workflows/test.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ jobs:
2323
with:
2424
python-version: ${{ matrix.python_version }}
2525
cache: pip
26+
27+
- name: Install dependencies on macOS
28+
if: runner.os == 'macOS'
29+
run: |
30+
brew install libomp
31+
2632
- name: Install dependencies
2733
run: |
2834
poetry env use python

poetry.lock

Lines changed: 536 additions & 119 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/wristpy/core/cli.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,24 @@ def parse_arguments(args: Optional[List[str]] = None) -> argparse.Namespace:
7373
"Exactly three values must be given in ascending order, and comma seperated.",
7474
)
7575

76+
parser.add_argument(
77+
"-nw",
78+
"--nonwear_algorithm",
79+
type=parse_nonwear_algorithms,
80+
default="ggir",
81+
help="Specify the non-wear detection algorithm(s) to use."
82+
"Specify one or more of 'ggir', 'cta', 'detach' as a comma-separated list"
83+
"(e.g. 'ggir,detach')."
84+
"When multiple algorithms are specified, majority voting will be applied.",
85+
)
86+
7687
parser.add_argument(
7788
"-e",
7889
"--epoch_length",
7990
default=5,
8091
type=int,
81-
help="Specify the sampling rate in seconds for all metrics. To skip resampling,"
82-
" enter 0.",
92+
help="Specify the sampling rate in seconds for all metrics."
93+
"Must be greater than 0.",
8394
)
8495

8596
parser.add_argument(
@@ -141,7 +152,8 @@ def main(
141152
thresholds=None
142153
if arguments.thresholds is None
143154
else cast(Tuple[float, float, float], tuple(arguments.thresholds)),
144-
epoch_length=None if arguments.epoch_length == 0 else arguments.epoch_length,
155+
epoch_length=arguments.epoch_length,
156+
nonwear_algorithm=arguments.nonwear_algorithm,
145157
verbosity=log_level,
146158
output_filetype=arguments.output_filetype,
147159
)
@@ -180,3 +192,24 @@ def _none_or_float_list(value: str) -> Optional[List[float]]:
180192
raise argparse.ArgumentTypeError(
181193
f"Invalid value: {value}. Must be a comma-separated list or 'None'."
182194
)
195+
196+
197+
def parse_nonwear_algorithms(algorithm_name: str) -> List[str]:
198+
"""Parse comma-separated non-wear algorithm names.
199+
200+
Args:
201+
algorithm_name: Command line input string, comma-separated algorithm names.
202+
203+
Returns:
204+
The List of algorithm names.
205+
"""
206+
valid_algorithm_names = ["ggir", "cta", "detach"]
207+
algorithms = [algo.strip().lower() for algo in algorithm_name.split(",")]
208+
209+
for algo in algorithms:
210+
if algo not in valid_algorithm_names:
211+
raise argparse.ArgumentTypeError(
212+
f"Invalid algorithm: '{algo}'. Must be one of: "
213+
f"{', '.join(valid_algorithm_names)}."
214+
)
215+
return algorithms

src/wristpy/core/orchestrator.py

Lines changed: 41 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
"""Python based runner."""
22

3-
import datetime
43
import itertools
54
import logging
65
import pathlib
7-
from typing import Dict, List, Literal, Optional, Tuple, Union
6+
from typing import Dict, List, Literal, Optional, Sequence, Tuple, Union
87

98
import numpy as np
10-
import polars as pl
119

1210
from wristpy.core import config, exceptions, models
1311
from wristpy.io.readers import readers
@@ -16,6 +14,7 @@
1614
calibration,
1715
idle_sleep_mode_imputation,
1816
metrics,
17+
nonwear_utils,
1918
)
2019

2120
logger = config.get_logger()
@@ -48,53 +47,6 @@ def format_sleep_data(
4847
return sleep_array
4948

5049

51-
def format_nonwear_data(
52-
nonwear_data: models.Measurement,
53-
reference_measure: models.Measurement,
54-
original_temporal_resolution: float,
55-
) -> np.ndarray:
56-
"""Formats nonwear data to match the temporal resolution of the other measures.
57-
58-
The detect_nonweaer algorithm outputs non-wear values in 15-minute windows, where
59-
each timestamp represents the beginning of the window. This structure does not align
60-
well with the polars upsample function, which treats the last timestamp as the end
61-
of the time series. As a result, using the upsample function would cut off the
62-
final window. To avoid this, we manually map the non-wear data to the reference
63-
measure's resolution.
64-
65-
Args:
66-
nonwear_data: The nonwear array to be upsampled.
67-
reference_measure: The measurement we match the non_wear data's temporal
68-
resolution to.
69-
original_temporal_resolution: The original temporal resolution of the
70-
nonwear_data.
71-
72-
Returns:
73-
1-D np.ndarray with 1 indicating a nonwear timepoint. Will match the
74-
length of the reference measure.
75-
"""
76-
logger.debug("Upsampling nonwear data.")
77-
nonwear_df = pl.DataFrame(
78-
{
79-
"nonwear": nonwear_data.measurements.astype(np.int64),
80-
"time": nonwear_data.time,
81-
}
82-
).set_sorted("time")
83-
84-
nonwear_array = np.zeros(len(reference_measure.time), dtype=bool)
85-
86-
for row in nonwear_df.iter_rows(named=True):
87-
nonwear_value = row["nonwear"]
88-
time = row["time"]
89-
nonwear_mask = (reference_measure.time >= time) & (
90-
reference_measure.time
91-
<= time + datetime.timedelta(seconds=original_temporal_resolution)
92-
)
93-
nonwear_array[nonwear_mask] = nonwear_value
94-
95-
return nonwear_array
96-
97-
9850
def run(
9951
input: Union[pathlib.Path, str],
10052
output: Optional[Union[pathlib.Path, str]] = None,
@@ -103,8 +55,9 @@ def run(
10355
None,
10456
Literal["ggir", "gradient"],
10557
] = "gradient",
106-
epoch_length: Union[float, None] = 5,
58+
epoch_length: float = 5,
10759
activity_metric: Literal["enmo", "mad", "ag_count"] = "enmo",
60+
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
10861
verbosity: int = logging.WARNING,
10962
output_filetype: Optional[Literal[".csv", ".parquet"]] = None,
11063
) -> Union[models.OrchestratorResults, Dict[str, models.OrchestratorResults]]:
@@ -128,10 +81,9 @@ def run(
12881
Default values are optimized for subjects ages 7-11 [1].
12982
calibrator: The calibrator to be used on the input data.
13083
epoch_length: The temporal resolution in seconds, the data will be down sampled
131-
to. If None is given, and `enmo` is the chosen physical activity metric,
132-
no down sampling is preformed. Otherwise, for `mad` and `ag_count`, a
133-
ValueError will be raised.
84+
to. It must be > 0.0.
13485
activity_metric: The metric to be used for physical activity categorization.
86+
nonwear_algorithm: The algorithm to be used for nonwear detection.
13587
verbosity: The logging level for the logger.
13688
output_filetype: Specifies the data format for the save files. Must be None when
13789
processing files, must be a valid file type when processing directories.
@@ -188,6 +140,7 @@ def run(
188140
epoch_length=epoch_length,
189141
activity_metric=activity_metric,
190142
verbosity=verbosity,
143+
nonwear_algorithm=nonwear_algorithm,
191144
)
192145

193146
return _run_directory(
@@ -198,6 +151,7 @@ def run(
198151
epoch_length=epoch_length,
199152
verbosity=verbosity,
200153
output_filetype=output_filetype,
154+
nonwear_algorithm=nonwear_algorithm,
201155
)
202156

203157

@@ -209,7 +163,8 @@ def _run_directory(
209163
None,
210164
Literal["ggir", "gradient"],
211165
] = "gradient",
212-
epoch_length: Union[float, None] = 5,
166+
epoch_length: float = 5,
167+
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
213168
verbosity: int = logging.WARNING,
214169
output_filetype: Optional[Literal[".csv", ".parquet"]] = None,
215170
) -> Dict[str, models.OrchestratorResults]:
@@ -230,9 +185,8 @@ def _run_directory(
230185
Default values are optimized for subjects ages 7-11 [1].
231186
calibrator: The calibrator to be used on the input data.
232187
epoch_length: The temporal resolution in seconds, the data will be down sampled
233-
to. If None is given, and `enmo` is the chosen physical activity metric,
234-
no down sampling is preformed. Otherwise, for `mad` and `ag_count`, a
235-
ValueError will be raised.
188+
to. It must be > 0.0.
189+
nonwear_algorithm: The algorithm to be used for nonwear detection.
236190
verbosity: The logging level for the logger.
237191
output_filetype: Specifies the data format for the save files.
238192
@@ -291,6 +245,7 @@ def _run_directory(
291245
calibrator=calibrator,
292246
epoch_length=epoch_length,
293247
verbosity=verbosity,
248+
nonwear_algorithm=nonwear_algorithm,
294249
)
295250
except Exception as e:
296251
logger.error("Did not run file: %s, Error: %s", file, e)
@@ -306,8 +261,9 @@ def _run_file(
306261
None,
307262
Literal["ggir", "gradient"],
308263
] = "gradient",
309-
epoch_length: Union[float, None] = 5,
264+
epoch_length: float = 5.0,
310265
activity_metric: Literal["enmo", "mad", "ag_count"] = "enmo",
266+
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
311267
verbosity: int = logging.WARNING,
312268
) -> models.OrchestratorResults:
313269
"""Runs main processing steps for wristpy and returns data for analysis.
@@ -328,15 +284,19 @@ def _run_file(
328284
Default values are optimized for subjects ages 7-11 [1].
329285
calibrator: The calibrator to be used on the input data.
330286
epoch_length: The temporal resolution in seconds, the data will be down sampled
331-
to. If None is given, and `enmo` is the chosen physical activity metric,
332-
no down sampling is preformed. Otherwise, for `mad` and `ag_count`, a
333-
ValueError will be raised.
287+
to. It must be > 0.0.
334288
activity_metric: The metric to be used for physical activity categorization.
289+
nonwear_algorithm: The algorithm to be used for nonwear detection. A sequence of
290+
algorithms can be provided. If so, a majority vote will be taken.
335291
verbosity: The logging level for the logger.
336292
337293
Returns:
338294
All calculated data in a save ready format as a OrchestratorResults object.
339295
296+
Raises:
297+
ValueError: If an invalid Calibrator is chosen.
298+
ValueError: If epoch_length is not greater than 0.
299+
340300
References:
341301
[1] Hildebrand, M., et al. (2014). Age group comparability of raw accelerometer
342302
output from wrist- and hip-worn monitors. Medicine and Science in Sports and
@@ -358,6 +318,11 @@ def _run_file(
358318
logger.error(msg)
359319
raise ValueError(msg)
360320

321+
if epoch_length <= 0:
322+
msg = "Epoch_length must be greater than 0."
323+
logger.error(msg)
324+
raise ValueError(msg)
325+
361326
watch_data = readers.read_watch_data(input)
362327

363328
if calibrator is None:
@@ -386,10 +351,18 @@ def _run_file(
386351
sleep_detector = analytics.GgirSleepDetection(anglez)
387352
sleep_windows = sleep_detector.run_sleep_detection()
388353

389-
non_wear_array = metrics.detect_nonwear(calibrated_acceleration)
354+
nonwear_array = nonwear_utils.get_nonwear_measurements(
355+
calibrated_acceleration=calibrated_acceleration,
356+
temperature=watch_data.temperature,
357+
non_wear_algorithms=nonwear_algorithm,
358+
)
359+
nonwear_epoch = nonwear_utils.nonwear_array_cleanup(
360+
nonwear_array=nonwear_array,
361+
reference_measurement=activity_measurement,
362+
epoch_length=epoch_length,
363+
)
390364
physical_activity_levels = analytics.compute_physical_activty_categories(
391-
activity_measurement,
392-
thresholds,
365+
activity_measurement, thresholds
393366
)
394367

395368
sleep_array = models.Measurement(
@@ -398,14 +371,6 @@ def _run_file(
398371
),
399372
time=activity_measurement.time,
400373
)
401-
nonwear_epoch = models.Measurement(
402-
measurements=format_nonwear_data(
403-
nonwear_data=non_wear_array,
404-
reference_measure=activity_measurement,
405-
original_temporal_resolution=900,
406-
),
407-
time=activity_measurement.time,
408-
)
409374

410375
results = models.OrchestratorResults(
411376
physical_activity_metric=activity_measurement,
@@ -437,7 +402,7 @@ def _run_file(
437402
def _compute_activity(
438403
acceleration: models.Measurement,
439404
activity_metric: Literal["ag_count", "mad", "enmo"],
440-
epoch_length: Union[float, None],
405+
epoch_length: float,
441406
) -> models.Measurement:
442407
"""This is a helper function to organize the computation of the activity metric.
443408
@@ -452,19 +417,12 @@ def _compute_activity(
452417
453418
Returns:
454419
A Measurement object with the computed physical activity metric.
455-
456-
Raises:
457-
ValueError: If the activity_metric is 'ag_count' or 'mad' and epoch_length is
458-
None.
459420
"""
460-
if activity_metric in ("ag_count", "mad") and epoch_length is None:
461-
raise ValueError("If using 'ag_count' or 'mad', epoch_length must be provided.")
462-
463421
if activity_metric == "ag_count":
464422
return metrics.actigraph_activity_counts(
465423
acceleration,
466-
epoch_length=epoch_length, # type: ignore[arg-type] # Guarded by the ValueError statement above.
424+
epoch_length=epoch_length,
467425
)
468426
elif activity_metric == "mad":
469-
return metrics.mean_amplitude_deviation(acceleration, epoch_length=epoch_length) # type: ignore[arg-type] # Guarded by the ValueError statement above.
427+
return metrics.mean_amplitude_deviation(acceleration, epoch_length=epoch_length)
470428
return metrics.euclidean_norm_minus_one(acceleration, epoch_length=epoch_length)

0 commit comments

Comments
 (0)