Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e45a2d3
first portion of algorithm
frey-perez Dec 19, 2024
a41a24d
interpolation correction
frey-perez Jan 8, 2025
3def235
removing place holders
frey-perez Jan 17, 2025
f86e31a
removing placeholder
frey-perez Jan 17, 2025
140ddc8
Mims interpolate rebase (#158)
frey-perez Jan 22, 2025
eeff57c
Mims extrapolate (#169)
frey-perez Mar 21, 2025
46531f4
Mims bandpass (#181)
frey-perez Apr 14, 2025
c3ca41e
Mims assembled (#192)
frey-perez May 5, 2025
4257962
All changes for implementing MIMS
frey-perez May 6, 2025
c8eba12
Merge branch '127-implement-mims' of https://github.com/childmindrese…
frey-perez May 6, 2025
3fbf9af
organize imports
frey-perez May 6, 2025
14a335a
regenerating lock file.
frey-perez May 6, 2025
122f597
ruff error.
frey-perez May 6, 2025
4fb8aec
adding mims to cli, adding device range to watchdata.
frey-perez May 8, 2025
a65f83f
documentation
frey-perez May 9, 2025
b95ce39
citation for threshold values for mims.
frey-perez May 9, 2025
3cdc786
deleted leftover truncation, truncation happens in aggregate_mims.
frey-perez May 9, 2025
cf98353
timestamp fix
frey-perez May 9, 2025
24a91f9
metric added to run_dir
frey-perez May 9, 2025
63a76dd
testing for readerss helper function.
frey-perez May 12, 2025
68c8aa1
ruff error.
frey-perez May 12, 2025
7e33a83
dynamic range change, literal casting.
frey-perez May 15, 2025
0a8fc3a
import block
frey-perez May 15, 2025
b9b293c
doc string.
frey-perez May 15, 2025
a58181e
Merge remote-tracking branch 'origin/main' into 127-implement-mims
frey-perez May 16, 2025
94950db
mypy issue with float vs np. float
frey-perez May 16, 2025
c08fc68
mypy literal issue
frey-perez May 16, 2025
b78e02e
Merge remote-tracking branch 'origin/main' into 127-implement-mims
frey-perez May 16, 2025
d9b66dc
Update src/wristpy/core/orchestrator.py
frey-perez May 20, 2025
7bee464
adam review.
frey-perez May 20, 2025
023ec70
Merge branch '127-implement-mims' of https://github.com/childmindrese…
frey-perez May 20, 2025
b178ed3
review changes.
frey-perez May 23, 2025
4f1374b
unused import.
frey-perez May 23, 2025
4f04c46
doc change.
frey-perez May 23, 2025
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
76 changes: 11 additions & 65 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/wristpy/core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class ActivityMetric(str, Enum):
enmo = "enmo"
mad = "mad"
ag_count = "ag_count"
mims = "mims"


class NonwearAlgorithms(str, Enum):
Expand Down Expand Up @@ -85,7 +86,7 @@ def main(
"-a",
"--activity-metric",
help="Metric used for physical activity categorization. "
"Choose from 'enmo', 'mad', or 'ag_count'.",
"Choose from 'enmo', 'mad', 'ag_count', or 'mims'. ",
case_sensitive=False,
),
thresholds: tuple[float, float, float] = typer.Option(
Expand Down
1 change: 1 addition & 0 deletions src/wristpy/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class WatchData(BaseModel):
capsense: Optional[Measurement] = None
temperature: Optional[Measurement] = None
idle_sleep_mode_flag: Optional[bool] = None
dynamic_range: Optional[tuple[float, float]] = None

@field_validator("acceleration")
def validate_acceleration(cls, v: Measurement) -> Measurement:
Expand Down
53 changes: 45 additions & 8 deletions src/wristpy/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
Literal["ggir", "gradient"],
] = "gradient",
epoch_length: float = 5,
activity_metric: Literal["enmo", "mad", "ag_count"] = "enmo",
activity_metric: Literal["enmo", "mad", "ag_count", "mims"] = "enmo",
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
verbosity: int = logging.WARNING,
output_filetype: Optional[Literal[".csv", ".parquet"]] = None,
Expand All @@ -51,7 +51,7 @@
path should end in the save file name in either .csv or .parquet formats.
thresholds: The cut points for the light, moderate, and vigorous thresholds,
given in that order. Values must be asscending, unique, and greater than 0.
Default values are optimized for subjects ages 7-11 [1].
Default values are optimized for subjects ages 7-11 [1][3].
calibrator: The calibrator to be used on the input data.
epoch_length: The temporal resolution in seconds, the data will be down sampled
to. It must be > 0.0.
Expand Down Expand Up @@ -79,6 +79,10 @@
Going S, Norman JE, Pate R. Defining accelerometer thresholds for activity
intensities in adolescent girls. Med Sci Sports Exerc. 2004 Jul;36(7):1259-66.
PMID: 15235335; PMCID: PMC2423321.
[3] Karas M, Muschelli J, Leroux A, Urbanek J, Wanigatunga A, Bai J,
Crainiceanu C, Schrack J Comparison of Accelerometry-Based Measures of Physical
Activity: Retrospective Observational Data Analysis Study JMIR Mhealth Uhealth
2022;10(7):e38077 URL: https://mhealth.jmir.org/2022/7/e38077 DOI: 10.2196/38077
"""
logger.setLevel(verbosity)

Expand All @@ -91,8 +95,10 @@
thresholds = thresholds or (0.029, 0.338, 0.604)
elif activity_metric == "ag_count":
thresholds = thresholds or (100, 3000, 5200)
elif activity_metric == "mims":
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can probably add a test for mims in test_orchestrator? To make sure it handles the dynamic range/thresholds properly?

thresholds = thresholds or (10.558, 15.047, 19.614)

if not (0 <= thresholds[0] < thresholds[1] < thresholds[2]):
if not (0 <= thresholds[0] < thresholds[1] < thresholds[2]): # type: ignore
message = "Threshold values must be >=0, unique, and in ascending order."
logger.error(message)
raise ValueError(message)
Expand Down Expand Up @@ -122,6 +128,7 @@
thresholds=thresholds,
calibrator=calibrator,
epoch_length=epoch_length,
activity_metric=activity_metric,
verbosity=verbosity,
output_filetype=output_filetype,
nonwear_algorithm=nonwear_algorithm,
Expand All @@ -140,6 +147,7 @@
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
verbosity: int = logging.WARNING,
output_filetype: Optional[Literal[".csv", ".parquet"]] = None,
activity_metric: Literal["enmo", "mad", "ag_count", "mims"] = "enmo",
) -> Dict[str, models.OrchestratorResults]:
"""Runs main processing steps for wristpy on directories.

Expand All @@ -155,13 +163,14 @@
output: Path to directory data will be saved to.
thresholds: The cut points for the light, moderate, and vigorous thresholds,
given in that order. Values must be asscending, unique, and greater than 0.
Default values are optimized for subjects ages 7-11 [1].
Default values are optimized for subjects ages 7-11 [1][2].
calibrator: The calibrator to be used on the input data.
epoch_length: The temporal resolution in seconds, the data will be down sampled
to. It must be > 0.0.
nonwear_algorithm: The algorithm to be used for nonwear detection.
verbosity: The logging level for the logger.
output_filetype: Specifies the data format for the save files.
activity_metric: The metric to be used for physical activity categorization.

Returns:
All calculated data in a save ready format as a dictionary of
Expand All @@ -177,6 +186,10 @@
[1] Hildebrand, M., et al. (2014). Age group comparability of raw accelerometer
output from wrist- and hip-worn monitors. Medicine and Science in Sports and
Exercise, 46(9), 1816-1824.
[2] Karas M, Muschelli J, Leroux A, Urbanek J, Wanigatunga A, Bai J,
Crainiceanu C, Schrack J Comparison of Accelerometry-Based Measures of Physical
Activity: Retrospective Observational Data Analysis Study JMIR Mhealth Uhealth
2022;10(7):e38077 URL: https://mhealth.jmir.org/2022/7/e38077 DOI: 10.2196/38077
"""
if output is None and output_filetype is not None:
raise ValueError("If no output is given, output_filetype must be None.")
Expand Down Expand Up @@ -219,6 +232,7 @@
epoch_length=epoch_length,
verbosity=verbosity,
nonwear_algorithm=nonwear_algorithm,
activity_metric=activity_metric,
)
except Exception as e:
logger.error("Did not run file: %s, Error: %s", file, e)
Expand All @@ -235,7 +249,7 @@
Literal["ggir", "gradient"],
] = "gradient",
epoch_length: float = 5.0,
activity_metric: Literal["enmo", "mad", "ag_count"] = "enmo",
activity_metric: Literal["enmo", "mad", "ag_count", "mims"] = "enmo",
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
verbosity: int = logging.WARNING,
) -> models.OrchestratorResults:
Expand All @@ -254,7 +268,7 @@
either .csv or .parquet formats.
thresholds: The cut points for the light, moderate, and vigorous thresholds,
given in that order. Values must be ascending, unique, and greater than 0.
Default values are optimized for subjects ages 7-11 [1].
Default values are optimized for subjects ages 7-11 [1] - [3].
calibrator: The calibrator to be used on the input data.
epoch_length: The temporal resolution in seconds, the data will be down sampled
to. It must be > 0.0.
Expand All @@ -278,6 +292,10 @@
calculated from raw acceleration data: a novel method for classifying the
intensity of adolescents' physical activity irrespective of accelerometer brand.
BMC Sports Sci Med Rehabil 7, 18 (2015). https://doi.org/10.1186/s13102-015-0010-0.
[3] Karas M, Muschelli J, Leroux A, Urbanek J, Wanigatunga A, Bai J,
Crainiceanu C, Schrack J Comparison of Accelerometry-Based Measures of Physical
Activity: Retrospective Observational Data Analysis Study JMIR Mhealth Uhealth
2022;10(7):e38077 URL: https://mhealth.jmir.org/2022/7/e38077 DOI: 10.2196/38077
"""
logger.setLevel(verbosity)
if output is not None:
Expand Down Expand Up @@ -318,7 +336,10 @@
calibrated_acceleration, epoch_length=epoch_length
)
activity_measurement = _compute_activity(
calibrated_acceleration, activity_metric, epoch_length
calibrated_acceleration,
activity_metric,
epoch_length,
dynamic_range=watch_data.dynamic_range,
)

sleep_detector = analytics.GgirSleepDetection(anglez)
Expand All @@ -329,6 +350,7 @@
temperature=watch_data.temperature,
non_wear_algorithms=nonwear_algorithm,
)

nonwear_epoch = nonwear_utils.nonwear_array_cleanup(
nonwear_array=nonwear_array,
reference_measurement=activity_measurement,
Expand Down Expand Up @@ -371,8 +393,9 @@

def _compute_activity(
acceleration: models.Measurement,
activity_metric: Literal["ag_count", "mad", "enmo"],
activity_metric: Literal["ag_count", "mad", "enmo", "mims"],
epoch_length: float,
dynamic_range: Optional[tuple[float, float]],
) -> models.Measurement:
"""This is a helper function to organize the computation of the activity metric.

Expand All @@ -384,6 +407,10 @@
activity_metric: The metric to be used for physical activity categorization.
epoch_length: The temporal resolution in seconds, the data will be down sampled
to.
dynamic_range: Tuple of the minimum and maximum accelerometer values. This
argument is only relevant to the mims metric. Values are taken from watch
metadata, if no metadata could be extracted, the default
values of (-8,8) are used.

Returns:
A Measurement object with the computed physical activity metric.
Expand All @@ -395,4 +422,14 @@
)
elif activity_metric == "mad":
return metrics.mean_amplitude_deviation(acceleration, epoch_length=epoch_length)
elif activity_metric == "mims":
if dynamic_range is None:
return metrics.monitor_independent_movement_summary_units(

Check warning on line 427 in src/wristpy/core/orchestrator.py

View check run for this annotation

Codecov / codecov/patch

src/wristpy/core/orchestrator.py#L427

Added line #L427 was not covered by tests
acceleration,
epoch=epoch_length,
)
return metrics.monitor_independent_movement_summary_units(
acceleration, epoch=epoch_length, dynamic_range=dynamic_range
)

return metrics.euclidean_norm_minus_one(acceleration, epoch_length=epoch_length)
43 changes: 41 additions & 2 deletions src/wristpy/io/readers/readers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Function to read accelerometer data from a file."""

import os
import pathlib
from typing import Literal, Union

Expand Down Expand Up @@ -39,22 +38,62 @@
measurements[sensor_name] = models.Measurement(
measurements=sensor_values, time=time
)

file_type = pathlib.Path(file_name).suffix
idle_sleep_mode_flag = False
if os.path.splitext(file_name)[1] == ".gt3x":
if file_type == ".gt3x":
idle_sleep_mode_flag = (
data["metadata"]["device_feature_enabled"]["sleep_mode"].lower() == "true"
)

dynamic_range = _extract_dynamic_range(
metadata=data["metadata"],
file_type=file_type, # type: ignore[arg-type]
)

return models.WatchData(
acceleration=measurements["acceleration"],
lux=measurements.get("light"),
battery=measurements.get("battery_voltage"),
capsense=measurements.get("capsense"),
temperature=measurements.get("temperature"),
idle_sleep_mode_flag=idle_sleep_mode_flag,
dynamic_range=dynamic_range,
)


def _extract_dynamic_range(
metadata: dict, file_type: Literal[".gt3x", ".bin"]
) -> tuple[float, float]:
"""Extract the dynamic range from metadata.

Args:
metadata: Metadata subdictionary where accelerometer range values can be found.
file_type: Accelerometer data file type. Supports .gt3x and .bin.

Returns:
A tuple containing the accelerometer range.

Raises:
ValueError: If file type is not supported.
"""
if file_type == ".gt3x":
return (
float(metadata.get("info", {}).get("Acceleration Min")),
float(metadata.get("info", {}).get("Acceleration Max")),
)
elif file_type == ".bin":
range_str = (
metadata.get("Device Capabilities", {})
.get("Accelerometer Range")
.strip()
.split(" to ")
)
return (float(range_str[0]), float(range_str[1]))

raise ValueError(f"Unsupported file type given: {file_type}")

Check warning on line 94 in src/wristpy/io/readers/readers.py

View check run for this annotation

Codecov / codecov/patch

src/wristpy/io/readers/readers.py#L94

Added line #L94 was not covered by tests


def unix_epoch_time_to_polars_datetime(
time: np.ndarray, units: Literal["ns", "us", "ms", "s", "d"] = "ns"
) -> pl.Series:
Expand Down
79 changes: 79 additions & 0 deletions src/wristpy/processing/metrics.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""Calculate base metrics, anglez and enmo."""

from typing import Literal, Tuple

import numpy as np
import polars as pl
from scipy import interpolate, signal
from skdh.preprocessing import wear_detection

from wristpy.core import computations, config, models
from wristpy.io.readers import readers
from wristpy.processing import mims

logger = config.get_logger()

Expand Down Expand Up @@ -605,3 +608,79 @@ def _pre_process_temperature(
[pl.mean("temperature")]
)
return low_pass_temp


def monitor_independent_movement_summary_units(
acceleration: models.Measurement,
combination_method: Literal["sum", "vector_magnitude"] = "sum",
epoch: float = 60.0,
interpolation_frequency: int = 100,
dynamic_range: tuple[float, float] = (-8.0, 8.0),
cutoffs: Tuple[float, float] = (0.2, 5.0),
order: int = 4,
*,
rectify: bool = True,
) -> models.Measurement:
"""Calculates monitor independent movement summary units (MIMS).

This function processes raw acceleration data to calculate MIMS units,
based on the original R implementation by John et al.(2019). The main processing
steps as described in the original paper are interpolation (100Hz by default),
extrapolation of any values that went beyond the dynamic range of the device,
filtering using a 4th order butterworth filter, aggregation by calculating the area
under the curve over a given epoch, and finally trunction of small values.
The MIMS value per axis is then combined through a sum or vector magnitude, and
returned as a single vector.

Args:
acceleration: Triaxial acceleration data to be processed.
combination_method: Method to combine MIMS values accross axes.
epoch: Duration over which each MIMS value will be calculated. Measured in
seconds.
interpolation_frequency: Frequency to interpolate acceleration data, defaults
to 100 Hz as described in John et al. 2019.
dynamic_range: Tuple specifying the minimum and maximum values (in g) of the
accelerometer's dynamic range.
cutoffs: Tuple specifying the low and high cutoff frequencies (in Hz) for the
Butterworth filter.
order: Order of the Butterworth filter.
rectify: Specifies if data should be rectified before integration. If True any
value below -150 will assign the value of that axis to -1 for that epoch.
Additionally the absolute value of accelerometer data will be used for
integration.

Returns:
Processed MIMS values after combining the values of each axis with the provided
method.

References:
John, D., Tang, Q., Albinali, F. and Intille, S., 2019. An Open-Source
Monitor-Independent Movement Summary for Accelerometer Data Processing. Journal
for the Measurement of Physical Behaviour, 2(4), pp.268-281.

"""
interpolated_measure = mims.interpolate_measure(
acceleration=acceleration,
new_frequency=interpolation_frequency,
)
extrapolated_measure = mims.extrapolate_points(
acceleration=interpolated_measure,
dynamic_range=dynamic_range,
sampling_rate=interpolation_frequency,
)
filtered_measure = mims.butterworth_filter(
acceleration=extrapolated_measure,
sampling_rate=interpolation_frequency,
cutoffs=cutoffs,
order=order,
)
aggregated_measure = mims.aggregate_mims(
acceleration=filtered_measure,
epoch=epoch,
sampling_rate=interpolation_frequency,
rectify=rectify,
)
combined_mims = mims.combine_mims(
acceleration=aggregated_measure, combination_method=combination_method
)
return combined_mims
Loading