Skip to content

Commit 37a6568

Browse files
committed
Merge branch 'main' into feature/issue-200/update-output
2 parents 147c925 + 4ed8686 commit 37a6568

17 files changed

Lines changed: 18099 additions & 90 deletions

poetry.lock

Lines changed: 11 additions & 62 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class ActivityMetric(str, Enum):
4040
enmo = "enmo"
4141
mad = "mad"
4242
ag_count = "ag_count"
43+
mims = "mims"
4344

4445

4546
class NonwearAlgorithms(str, Enum):
@@ -85,7 +86,7 @@ def main(
8586
"-a",
8687
"--activity-metric",
8788
help="Metric used for physical activity categorization. "
88-
"Choose from 'enmo', 'mad', or 'ag_count'.",
89+
"Choose from 'enmo', 'mad', 'ag_count', or 'mims'. ",
8990
case_sensitive=False,
9091
),
9192
thresholds: tuple[float, float, float] = typer.Option(

src/wristpy/core/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ class WatchData(BaseModel):
112112
capsense: Optional[Measurement] = None
113113
temperature: Optional[Measurement] = None
114114
idle_sleep_mode_flag: Optional[bool] = None
115+
dynamic_range: Optional[tuple[float, float]] = None
115116

116117
@field_validator("acceleration")
117118
def validate_acceleration(cls, v: Measurement) -> Measurement:

src/wristpy/core/orchestrator.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def run(
3030
Literal["ggir", "gradient"],
3131
] = "gradient",
3232
epoch_length: float = 5,
33-
activity_metric: Literal["enmo", "mad", "ag_count"] = "enmo",
33+
activity_metric: Literal["enmo", "mad", "ag_count", "mims"] = "enmo",
3434
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
3535
verbosity: int = logging.WARNING,
3636
output_filetype: Optional[Literal[".csv", ".parquet"]] = None,
@@ -52,7 +52,7 @@ def run(
5252
path should end in the save file name in either .csv or .parquet formats.
5353
thresholds: The cut points for the light, moderate, and vigorous thresholds,
5454
given in that order. Values must be asscending, unique, and greater than 0.
55-
Default values are optimized for subjects ages 7-11 [1].
55+
Default values are optimized for subjects ages 7-11 [1][3].
5656
calibrator: The calibrator to be used on the input data.
5757
epoch_length: The temporal resolution in seconds, the data will be down sampled
5858
to. It must be > 0.0.
@@ -80,6 +80,10 @@ def run(
8080
Going S, Norman JE, Pate R. Defining accelerometer thresholds for activity
8181
intensities in adolescent girls. Med Sci Sports Exerc. 2004 Jul;36(7):1259-66.
8282
PMID: 15235335; PMCID: PMC2423321.
83+
[3] Karas M, Muschelli J, Leroux A, Urbanek J, Wanigatunga A, Bai J,
84+
Crainiceanu C, Schrack J Comparison of Accelerometry-Based Measures of Physical
85+
Activity: Retrospective Observational Data Analysis Study JMIR Mhealth Uhealth
86+
2022;10(7):e38077 URL: https://mhealth.jmir.org/2022/7/e38077 DOI: 10.2196/38077
8387
"""
8488
logger.setLevel(verbosity)
8589

@@ -92,8 +96,10 @@ def run(
9296
thresholds = thresholds or (0.029, 0.338, 0.604)
9397
elif activity_metric == "ag_count":
9498
thresholds = thresholds or (100, 3000, 5200)
99+
elif activity_metric == "mims":
100+
thresholds = thresholds or (10.558, 15.047, 19.614)
95101

96-
if not (0 <= thresholds[0] < thresholds[1] < thresholds[2]):
102+
if not (0 <= thresholds[0] < thresholds[1] < thresholds[2]): # type: ignore
97103
message = "Threshold values must be >=0, unique, and in ascending order."
98104
logger.error(message)
99105
raise ValueError(message)
@@ -123,6 +129,7 @@ def run(
123129
thresholds=thresholds,
124130
calibrator=calibrator,
125131
epoch_length=epoch_length,
132+
activity_metric=activity_metric,
126133
verbosity=verbosity,
127134
output_filetype=output_filetype,
128135
nonwear_algorithm=nonwear_algorithm,
@@ -141,6 +148,7 @@ def _run_directory(
141148
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
142149
verbosity: int = logging.WARNING,
143150
output_filetype: Optional[Literal[".csv", ".parquet"]] = None,
151+
activity_metric: Literal["enmo", "mad", "ag_count", "mims"] = "enmo",
144152
) -> Dict[str, writers.OrchestratorResults]:
145153
"""Runs main processing steps for wristpy on directories.
146154
@@ -156,13 +164,14 @@ def _run_directory(
156164
output: Path to directory data will be saved to.
157165
thresholds: The cut points for the light, moderate, and vigorous thresholds,
158166
given in that order. Values must be asscending, unique, and greater than 0.
159-
Default values are optimized for subjects ages 7-11 [1].
167+
Default values are optimized for subjects ages 7-11 [1][2].
160168
calibrator: The calibrator to be used on the input data.
161169
epoch_length: The temporal resolution in seconds, the data will be down sampled
162170
to. It must be > 0.0.
163171
nonwear_algorithm: The algorithm to be used for nonwear detection.
164172
verbosity: The logging level for the logger.
165173
output_filetype: Specifies the data format for the save files.
174+
activity_metric: The metric to be used for physical activity categorization.
166175
167176
Returns:
168177
All calculated data in a save ready format as a dictionary of
@@ -178,6 +187,10 @@ def _run_directory(
178187
[1] Hildebrand, M., et al. (2014). Age group comparability of raw accelerometer
179188
output from wrist- and hip-worn monitors. Medicine and Science in Sports and
180189
Exercise, 46(9), 1816-1824.
190+
[2] Karas M, Muschelli J, Leroux A, Urbanek J, Wanigatunga A, Bai J,
191+
Crainiceanu C, Schrack J Comparison of Accelerometry-Based Measures of Physical
192+
Activity: Retrospective Observational Data Analysis Study JMIR Mhealth Uhealth
193+
2022;10(7):e38077 URL: https://mhealth.jmir.org/2022/7/e38077 DOI: 10.2196/38077
181194
"""
182195
if output is None and output_filetype is not None:
183196
raise ValueError("If no output is given, output_filetype must be None.")
@@ -220,6 +233,7 @@ def _run_directory(
220233
epoch_length=epoch_length,
221234
verbosity=verbosity,
222235
nonwear_algorithm=nonwear_algorithm,
236+
activity_metric=activity_metric,
223237
)
224238
except Exception as e:
225239
logger.error("Did not run file: %s, Error: %s", file, e)
@@ -236,7 +250,7 @@ def _run_file(
236250
Literal["ggir", "gradient"],
237251
] = "gradient",
238252
epoch_length: float = 5.0,
239-
activity_metric: Literal["enmo", "mad", "ag_count"] = "enmo",
253+
activity_metric: Literal["enmo", "mad", "ag_count", "mims"] = "enmo",
240254
nonwear_algorithm: Sequence[Literal["ggir", "cta", "detach"]] = ["ggir"],
241255
verbosity: int = logging.WARNING,
242256
) -> writers.OrchestratorResults:
@@ -255,7 +269,7 @@ def _run_file(
255269
either .csv or .parquet formats.
256270
thresholds: The cut points for the light, moderate, and vigorous thresholds,
257271
given in that order. Values must be ascending, unique, and greater than 0.
258-
Default values are optimized for subjects ages 7-11 [1].
272+
Default values are optimized for subjects ages 7-11 [1] - [3].
259273
calibrator: The calibrator to be used on the input data.
260274
epoch_length: The temporal resolution in seconds, the data will be down sampled
261275
to. It must be > 0.0.
@@ -279,6 +293,10 @@ def _run_file(
279293
calculated from raw acceleration data: a novel method for classifying the
280294
intensity of adolescents' physical activity irrespective of accelerometer brand.
281295
BMC Sports Sci Med Rehabil 7, 18 (2015). https://doi.org/10.1186/s13102-015-0010-0.
296+
[3] Karas M, Muschelli J, Leroux A, Urbanek J, Wanigatunga A, Bai J,
297+
Crainiceanu C, Schrack J Comparison of Accelerometry-Based Measures of Physical
298+
Activity: Retrospective Observational Data Analysis Study JMIR Mhealth Uhealth
299+
2022;10(7):e38077 URL: https://mhealth.jmir.org/2022/7/e38077 DOI: 10.2196/38077
282300
"""
283301
logger.setLevel(verbosity)
284302
if output is not None:
@@ -328,7 +346,10 @@ def _run_file(
328346
calibrated_acceleration, epoch_length=epoch_length
329347
)
330348
activity_measurement = _compute_activity(
331-
calibrated_acceleration, activity_metric, epoch_length
349+
calibrated_acceleration,
350+
activity_metric,
351+
epoch_length,
352+
dynamic_range=watch_data.dynamic_range,
332353
)
333354

334355
sleep_detector = analytics.GgirSleepDetection(anglez)
@@ -339,6 +360,7 @@ def _run_file(
339360
temperature=watch_data.temperature,
340361
non_wear_algorithms=nonwear_algorithm,
341362
)
363+
342364
nonwear_epoch = nonwear_utils.nonwear_array_cleanup(
343365
nonwear_array=nonwear_array,
344366
reference_measurement=activity_measurement,
@@ -383,8 +405,9 @@ def _run_file(
383405

384406
def _compute_activity(
385407
acceleration: models.Measurement,
386-
activity_metric: Literal["ag_count", "mad", "enmo"],
408+
activity_metric: Literal["ag_count", "mad", "enmo", "mims"],
387409
epoch_length: float,
410+
dynamic_range: Optional[tuple[float, float]],
388411
) -> models.Measurement:
389412
"""This is a helper function to organize the computation of the activity metric.
390413
@@ -396,6 +419,10 @@ def _compute_activity(
396419
activity_metric: The metric to be used for physical activity categorization.
397420
epoch_length: The temporal resolution in seconds, the data will be down sampled
398421
to.
422+
dynamic_range: Tuple of the minimum and maximum accelerometer values. This
423+
argument is only relevant to the mims metric. Values are taken from watch
424+
metadata, if no metadata could be extracted, the default
425+
values of (-8,8) are used.
399426
400427
Returns:
401428
A Measurement object with the computed physical activity metric.
@@ -407,4 +434,14 @@ def _compute_activity(
407434
)
408435
elif activity_metric == "mad":
409436
return metrics.mean_amplitude_deviation(acceleration, epoch_length=epoch_length)
437+
elif activity_metric == "mims":
438+
if dynamic_range is None:
439+
return metrics.monitor_independent_movement_summary_units(
440+
acceleration,
441+
epoch=epoch_length,
442+
)
443+
return metrics.monitor_independent_movement_summary_units(
444+
acceleration, epoch=epoch_length, dynamic_range=dynamic_range
445+
)
446+
410447
return metrics.euclidean_norm_minus_one(acceleration, epoch_length=epoch_length)

src/wristpy/io/readers/readers.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Function to read accelerometer data from a file."""
22

3-
import os
43
import pathlib
54
from typing import Literal, Union
65

@@ -39,22 +38,62 @@ def read_watch_data(file_name: Union[pathlib.Path, str]) -> models.WatchData:
3938
measurements[sensor_name] = models.Measurement(
4039
measurements=sensor_values, time=time
4140
)
41+
42+
file_type = pathlib.Path(file_name).suffix
4243
idle_sleep_mode_flag = False
43-
if os.path.splitext(file_name)[1] == ".gt3x":
44+
if file_type == ".gt3x":
4445
idle_sleep_mode_flag = (
4546
data["metadata"]["device_feature_enabled"]["sleep_mode"].lower() == "true"
4647
)
4748

49+
dynamic_range = _extract_dynamic_range(
50+
metadata=data["metadata"],
51+
file_type=file_type, # type: ignore[arg-type]
52+
)
53+
4854
return models.WatchData(
4955
acceleration=measurements["acceleration"],
5056
lux=measurements.get("light"),
5157
battery=measurements.get("battery_voltage"),
5258
capsense=measurements.get("capsense"),
5359
temperature=measurements.get("temperature"),
5460
idle_sleep_mode_flag=idle_sleep_mode_flag,
61+
dynamic_range=dynamic_range,
5562
)
5663

5764

65+
def _extract_dynamic_range(
66+
metadata: dict, file_type: Literal[".gt3x", ".bin"]
67+
) -> tuple[float, float]:
68+
"""Extract the dynamic range from metadata.
69+
70+
Args:
71+
metadata: Metadata subdictionary where accelerometer range values can be found.
72+
file_type: Accelerometer data file type. Supports .gt3x and .bin.
73+
74+
Returns:
75+
A tuple containing the accelerometer range.
76+
77+
Raises:
78+
ValueError: If file type is not supported.
79+
"""
80+
if file_type == ".gt3x":
81+
return (
82+
float(metadata.get("info", {}).get("Acceleration Min")),
83+
float(metadata.get("info", {}).get("Acceleration Max")),
84+
)
85+
elif file_type == ".bin":
86+
range_str = (
87+
metadata.get("Device Capabilities", {})
88+
.get("Accelerometer Range")
89+
.strip()
90+
.split(" to ")
91+
)
92+
return (float(range_str[0]), float(range_str[1]))
93+
94+
raise ValueError(f"Unsupported file type given: {file_type}")
95+
96+
5897
def unix_epoch_time_to_polars_datetime(
5998
time: np.ndarray, units: Literal["ns", "us", "ms", "s", "d"] = "ns"
6099
) -> pl.Series:

src/wristpy/processing/metrics.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Calculate base metrics, anglez and enmo."""
22

3+
from typing import Literal, Tuple
4+
35
import numpy as np
46
import polars as pl
57
from scipy import interpolate, signal
68
from skdh.preprocessing import wear_detection
79

810
from wristpy.core import computations, config, models
911
from wristpy.io.readers import readers
12+
from wristpy.processing import mims
1013

1114
logger = config.get_logger()
1215

@@ -605,3 +608,79 @@ def _pre_process_temperature(
605608
[pl.mean("temperature")]
606609
)
607610
return low_pass_temp
611+
612+
613+
def monitor_independent_movement_summary_units(
614+
acceleration: models.Measurement,
615+
combination_method: Literal["sum", "vector_magnitude"] = "sum",
616+
epoch: float = 60.0,
617+
interpolation_frequency: int = 100,
618+
dynamic_range: tuple[float, float] = (-8.0, 8.0),
619+
cutoffs: Tuple[float, float] = (0.2, 5.0),
620+
order: int = 4,
621+
*,
622+
rectify: bool = True,
623+
) -> models.Measurement:
624+
"""Calculates monitor independent movement summary units (MIMS).
625+
626+
This function processes raw acceleration data to calculate MIMS units,
627+
based on the original R implementation by John et al.(2019). The main processing
628+
steps as described in the original paper are interpolation (100Hz by default),
629+
extrapolation of any values that went beyond the dynamic range of the device,
630+
filtering using a 4th order butterworth filter, aggregation by calculating the area
631+
under the curve over a given epoch, and finally trunction of small values.
632+
The MIMS value per axis is then combined through a sum or vector magnitude, and
633+
returned as a single vector.
634+
635+
Args:
636+
acceleration: Triaxial acceleration data to be processed.
637+
combination_method: Method to combine MIMS values accross axes.
638+
epoch: Duration over which each MIMS value will be calculated. Measured in
639+
seconds.
640+
interpolation_frequency: Frequency to interpolate acceleration data, defaults
641+
to 100 Hz as described in John et al. 2019.
642+
dynamic_range: Tuple specifying the minimum and maximum values (in g) of the
643+
accelerometer's dynamic range.
644+
cutoffs: Tuple specifying the low and high cutoff frequencies (in Hz) for the
645+
Butterworth filter.
646+
order: Order of the Butterworth filter.
647+
rectify: Specifies if data should be rectified before integration. If True any
648+
value below -150 will assign the value of that axis to -1 for that epoch.
649+
Additionally the absolute value of accelerometer data will be used for
650+
integration.
651+
652+
Returns:
653+
Processed MIMS values after combining the values of each axis with the provided
654+
method.
655+
656+
References:
657+
John, D., Tang, Q., Albinali, F. and Intille, S., 2019. An Open-Source
658+
Monitor-Independent Movement Summary for Accelerometer Data Processing. Journal
659+
for the Measurement of Physical Behaviour, 2(4), pp.268-281.
660+
661+
"""
662+
interpolated_measure = mims.interpolate_measure(
663+
acceleration=acceleration,
664+
new_frequency=interpolation_frequency,
665+
)
666+
extrapolated_measure = mims.extrapolate_points(
667+
acceleration=interpolated_measure,
668+
dynamic_range=dynamic_range,
669+
sampling_rate=interpolation_frequency,
670+
)
671+
filtered_measure = mims.butterworth_filter(
672+
acceleration=extrapolated_measure,
673+
sampling_rate=interpolation_frequency,
674+
cutoffs=cutoffs,
675+
order=order,
676+
)
677+
aggregated_measure = mims.aggregate_mims(
678+
acceleration=filtered_measure,
679+
epoch=epoch,
680+
sampling_rate=interpolation_frequency,
681+
rectify=rectify,
682+
)
683+
combined_mims = mims.combine_mims(
684+
acceleration=aggregated_measure, combination_method=combination_method
685+
)
686+
return combined_mims

0 commit comments

Comments
 (0)