Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Adding support for single threshold non-wear detection across watch platforms #105

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions src/wristpy/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,19 +230,7 @@ def run(
enmo = computations.moving_mean(enmo, epoch_length=epoch_length)
anglez = computations.moving_mean(anglez, epoch_length=epoch_length)

# Watches require different criteria due to differences in the sensor values on the
# lower end of the distribution.

if input.suffix == ".bin":
range_criterion = 0.5
elif input.suffix == ".gt3x":
range_criterion = 0.05
else:
raise exceptions.InvalidFileTypeError("Unknown input file type.")

non_wear_array = metrics.detect_nonwear(
calibrated_acceleration, range_criteria=range_criterion
)
non_wear_array = metrics.detect_nonwear(calibrated_acceleration)

sleep_detector = analytics.GGIRSleepDetection(anglez)
sleep_windows = sleep_detector.run_sleep_detection()
Expand Down
23 changes: 10 additions & 13 deletions src/wristpy/processing/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,15 @@ def detect_nonwear(
short_epoch_length: int = 900,
n_short_epoch_in_long_epoch: int = 4,
std_criteria: float = 0.013,
range_criteria: float = 0.05,
) -> models.Measurement:
"""Set non_wear_flag based on accelerometer data.

This implements GGIR "2023" non-wear detection algorithm.
This implements a modified version of the GGIR "2023" non-wear detection algorithm.
Briefly, the algorithm, creates a sliding window of long epoch length that steps
forward by the short epoch length. The long epoch length is an integer multiple of
the short epoch length, that can be specified by the user.
It checks if the acceleration data in that long window, for each axis, meets certain
criteria thresholds for the standard deviation and range of acceleration values to
It checks if the acceleration data in that long window, for each axis, meets the
criteria threshold for the standard deviation of acceleration values to
compute a non-wear value. The total non-wear value (0, 1, 2, 3) for the long window
is the sum of each axis.
The non-wear value is applied to all the short windows that make up the long
Expand All @@ -90,7 +89,7 @@ def detect_nonwear(
short_epoch_length: The short window size, in seconds.
n_short_epoch_in_long_epoch: Number of short epochs that makeup one long epoch.
std_criteria: Threshold criteria for standard deviation.
range_criteria: Threshold criteria for range of acceleration.


Returns:
A new Measurment instance with the non-wear flag and corresponding timestamps.
Expand All @@ -104,7 +103,6 @@ def detect_nonwear(
acceleration_grouped_by_short_window,
n_short_epoch_in_long_epoch,
std_criteria,
range_criteria,
)

nonwear_value_array_cleaned = _cleanup_isolated_ones_nonwear_value(
Expand Down Expand Up @@ -152,7 +150,6 @@ def _compute_nonwear_value_array(
grouped_acceleration: pl.DataFrame,
n_short_epoch_in_long_epoch: int,
std_criteria: float,
range_criteria: float,
) -> np.ndarray:
"""Helper function to calculate the nonwear value array.

Expand All @@ -167,7 +164,6 @@ def _compute_nonwear_value_array(
grouped_acceleration: The acceleration data grouped into short windows.
n_short_epoch_in_long_epoch: Number of short epochs that makeup one long epoch.
std_criteria: Threshold criteria for standard deviation.
range_criteria: Threshold criteria for range of acceleration.

Returns:
Non-wear value array.
Expand All @@ -183,7 +179,8 @@ def _compute_nonwear_value_array(
calculated_nonwear_value = acceleration_selected_long_window.select(
pl.col("X", "Y", "Z").map_batches(
lambda df: _compute_nonwear_value_per_axis(
df, std_criteria, range_criteria
df,
std_criteria,
)
)
).sum_horizontal()
Expand All @@ -200,7 +197,8 @@ def _compute_nonwear_value_array(


def _compute_nonwear_value_per_axis(
axis_acceleration_data: pl.Series, std_criteria: float, range_criteria: float
axis_acceleration_data: pl.Series,
std_criteria: float,
) -> bool:
"""Helper function to calculate the nonwear criteria per axis.

Expand All @@ -210,16 +208,15 @@ def _compute_nonwear_value_per_axis(
acceleration data of one axis (length of each list is the number of samples
that make up short_epoch_length in seconds).
std_criteria: Threshold criteria for standard deviation
range_criteria: Threshold criteria for range of acceleration


Returns:
Non-wear value for the axis.
"""
axis_long_window_data = pl.concat(axis_acceleration_data, how="vertical")
axis_std = axis_long_window_data.std()
axis_range = axis_long_window_data.max() - axis_long_window_data.min()
criteria_boolean = axis_std < std_criteria

criteria_boolean = (axis_std < std_criteria) & (axis_range < range_criteria)
return criteria_boolean


Expand Down
22 changes: 11 additions & 11 deletions tests/smoke/test_orchestrator_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from wristpy.core import orchestrator
from wristpy.core import models, orchestrator


@pytest.mark.parametrize(
Expand All @@ -18,11 +18,11 @@ def test_orchestrator_happy_path(

assert (tmp_path / file_name).exists()
assert isinstance(results, orchestrator.Results)
assert results.enmo is not None
assert results.anglez is not None
assert results.nonwear_epoch is not None
assert results.sleep_windows_epoch is not None
assert results.physical_activity_levels is not None
assert isinstance(results.enmo, models.Measurement)
assert isinstance(results.anglez, models.Measurement)
assert isinstance(results.nonwear_epoch, models.Measurement)
assert isinstance(results.sleep_windows_epoch, models.Measurement)
assert isinstance(results.physical_activity_levels, models.Measurement)


def test_orchestrator_different_epoch(
Expand All @@ -35,8 +35,8 @@ def test_orchestrator_different_epoch(

assert (tmp_path / "good_file.csv").exists()
assert isinstance(results, orchestrator.Results)
assert results.enmo is not None
assert results.anglez is not None
assert results.nonwear_epoch is not None
assert results.sleep_windows_epoch is not None
assert results.physical_activity_levels is not None
assert isinstance(results.enmo, models.Measurement)
assert isinstance(results.anglez, models.Measurement)
assert isinstance(results.nonwear_epoch, models.Measurement)
assert isinstance(results.sleep_windows_epoch, models.Measurement)
assert isinstance(results.physical_activity_levels, models.Measurement)
10 changes: 2 additions & 8 deletions tests/unit/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,13 @@ def test_compute_nonwear_value_per_axis(
create_acceleration: pl.DataFrame, modifier: int, expected_result: int
) -> None:
"""Test the nonwear value per axis function."""
std_criteria = modifier
range_criteria = modifier
acceleration = create_acceleration.with_columns(pl.col("time").set_sorted())
acceleration_grouped = acceleration.group_by_dynamic(
index_column="time", every="5s"
).agg([pl.all().exclude(["time"])])

test_resultx = metrics._compute_nonwear_value_per_axis(
acceleration_grouped["X"], std_criteria, range_criteria
acceleration_grouped["X"], std_criteria=modifier
)

assert (
Expand All @@ -185,7 +183,6 @@ def test_compute_nonwear_value_array(create_acceleration: pl.DataFrame) -> None:
acceleration_grouped,
n_short_epoch_in_long_epoch,
std_criteria=1,
range_criteria=1,
)

assert np.all(
Expand All @@ -209,8 +206,6 @@ def test_detect_nonwear(
"""Test the detect nonwear function."""
short_epoch_length = 5
n_short_epoch_in_long_epoch = int(4)
std_criteria = modifier
range_criteria = modifier
acceleration_df = create_acceleration
acceleration = models.Measurement(
measurements=acceleration_df.select(["X", "Y", "Z"]).to_numpy(),
Expand All @@ -222,8 +217,7 @@ def test_detect_nonwear(
acceleration,
short_epoch_length,
n_short_epoch_in_long_epoch,
std_criteria,
range_criteria,
std_criteria=modifier,
)

assert np.all(
Expand Down