From cd10705bb7eb4c983ea412f3d14770bb5165e910 Mon Sep 17 00:00:00 2001 From: Lukas Adamowicz Date: Wed, 11 Mar 2026 09:57:19 -0400 Subject: [PATCH 1/5] allow individual bout processing to fail but continue processing the whole file --- src/skdh/gait/core.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/skdh/gait/core.py b/src/skdh/gait/core.py index 56dc8070..bfe4ef33 100644 --- a/src/skdh/gait/core.py +++ b/src/skdh/gait/core.py @@ -6,6 +6,7 @@ """ from collections.abc import Iterable +from sys import exception from warnings import warn from numpy import ndarray, asarray, mean, diff, sum, nan @@ -616,14 +617,18 @@ def predict( for ibout, bout in enumerate(gait_bouts): # run the bout processing pipeline - bout_res = self.bout_pipeline.run( - time=time_rs[bout], - accel=accel_rs[bout], - gyro=gyro_rs[bout] if gyro_rs is not None else None, - fs=goal_fs, - v_axis=v_axis, - ap_axis=ap_axis, - ) + try: + bout_res = self.bout_pipeline.run( + time=time_rs[bout], + accel=accel_rs[bout], + gyro=gyro_rs[bout] if gyro_rs is not None else None, + fs=goal_fs, + v_axis=v_axis, + ap_axis=ap_axis, + ) + except Exception as e: + warn(f"Caught error processing bout no. {ibout}: {e}") + continue # get the data we need n_strides = bout_res["qc_initial_contacts"].size From b64c8c2039494e371389af9774e7688ce5e7cfad Mon Sep 17 00:00:00 2001 From: Lukas Adamowicz Date: Wed, 18 Mar 2026 11:11:05 -0400 Subject: [PATCH 2/5] Update to round start/stop times from index intersections to be rounded --- meson.build | 2 +- src/skdh/activity/core.py | 12 ++++++++++++ src/skdh/utility/internal.py | 16 +++++++++++++++- tests/utility/conftest.py | 15 +++++++++++++++ tests/utility/test_internal.py | 23 +++++++++++++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/meson.build b/meson.build index 26c18f60..120f67bc 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'scikit-digital-health', 'c', - version: '0.17.10', + version: '0.17.11', license: 'MIT', meson_version: '>=1.1', ) diff --git a/src/skdh/activity/core.py b/src/skdh/activity/core.py index 7b1d4bb6..b5b54886 100644 --- a/src/skdh/activity/core.py +++ b/src/skdh/activity/core.py @@ -71,6 +71,11 @@ class ActivityLevelClassification(BaseProcess): If True then count breaks in a bout towards the bout duration. If False then only count time spent above the threshold towards the bout duration. Only used if `bout_metric=1`. Default is False. + wear_wake_times_round : {None, int}, optional + A value in seconds to round the wear/wake start/end times to, relative to the + start of the day. For example, if 15 is passed, and the wear/wake period + starts at xx:yy:10, (and the day starts at 00:00:00), the start time will + be rounded to xx:yy:15. min_wear_time : int, optional Minimum wear time in hours for a day to be analyzed. Default is 10 hours. cutpoints : {str, dict, list}, optional @@ -136,6 +141,7 @@ def __init__( bout_criteria=0.8, bout_metric=4, closed_bout=False, + wear_wake_times_round=None, min_wear_time=10, cutpoints="migueles_wrist_adult", day_window=(0, 24), @@ -165,6 +171,7 @@ def __init__( bout_criteria=bout_criteria, bout_metric=bout_metric, closed_bout=closed_bout, + wear_wake_times_round=wear_wake_times_round, min_wear_time=min_wear_time, cutpoints=cutpoints_, day_window=day_window, @@ -177,6 +184,7 @@ def __init__( self.boutcrit = bout_criteria self.boutmetric = bout_metric self.closedbout = closed_bout + self.wear_wake_times_round = wear_wake_times_round self.min_wear = min_wear_time self.cutpoints = cutpoints_ @@ -502,6 +510,8 @@ def predict(self, *, time, accel, fs=None, wear=None, tz_name=None, **kwargs): (True, False), # include wear time, exclude sleeping time day_start, day_stop, + ends_round=self.wear_wake_times_round, + fs=fs, ) sleep_wear_starts, sleep_wear_stops = get_day_index_intersection( (self.wear_idx[0], sleep_starts), @@ -509,6 +519,8 @@ def predict(self, *, time, accel, fs=None, wear=None, tz_name=None, **kwargs): (True, True), # now we want only sleep day_start, day_stop, + ends_round=self.wear_wake_times_round, + fs=fs, ) res["N wear wake hours"][iday] = around( diff --git a/src/skdh/utility/internal.py b/src/skdh/utility/internal.py index 5a219c6f..4147ee3a 100644 --- a/src/skdh/utility/internal.py +++ b/src/skdh/utility/internal.py @@ -9,6 +9,7 @@ all, asarray, argsort, + around, array, clip, nonzero, @@ -30,7 +31,7 @@ from scipy.signal import cheby1, sosfiltfilt -def get_day_index_intersection(starts, stops, for_inclusion, day_start, day_stop): +def get_day_index_intersection(starts, stops, for_inclusion, day_start, day_stop, ends_round=None, fs=None): """ Get the intersection between day start and stop indices and various start and stop indices that may or may not happen during the day, and are not necessarily for inclusion. @@ -50,6 +51,11 @@ def get_day_index_intersection(starts, stops, for_inclusion, day_start, day_stop Day start index. day_stop : int Day stop index. + ends_round : {None, int}, optional + If provided, will round the start and stop indices to the nearest multiple of this value + away from the day start. + fs : float, optional + Sampling frequency of the data. Required if `ends_round` is provided. Returns ------- @@ -158,6 +164,14 @@ def get_day_index_intersection(starts, stops, for_inclusion, day_start, day_stop valid_starts = asarray(valid_starts) valid_stops = asarray(valid_stops) + # handle rounding to nearest multiple of ends_round away from day_start + if ends_round is not None: + if fs is None: + raise ValueError("Sampling frequency `fs` must be provided if `ends_round` is provided.") + factor = int(ends_round * fs) + valid_starts = around((valid_starts - day_start) / factor, 0) * factor + day_start + valid_stops = around((valid_stops - day_start) / factor, 0) * factor + day_start + return ( valid_starts[valid_starts != valid_stops], valid_stops[valid_starts != valid_stops], diff --git a/tests/utility/conftest.py b/tests/utility/conftest.py index f4dd13cb..6a3a2df2 100644 --- a/tests/utility/conftest.py +++ b/tests/utility/conftest.py @@ -90,6 +90,21 @@ def true_intersect_ends(): return starts, stops +@pytest.fixture(scope="module") +def true_intersect_ends_15s(): + starts = { + 1: np.array([2795, 3005]), + 2: np.array([2495, 3005]), + 3: np.array([2300, 3005]), + } + stops = { + 1: np.array([2900, 3905]), + 2: np.array([2900, 3800]), + 3: np.array([2900, 3905]), + } + return starts, stops + + @pytest.fixture(scope="module") def true_sleep_only_ends(): starts = {1: np.array([2000, 2800]), 2: np.array([2500]), 3: np.array([2000])} diff --git a/tests/utility/test_internal.py b/tests/utility/test_internal.py index 4bccf16c..84b18104 100644 --- a/tests/utility/test_internal.py +++ b/tests/utility/test_internal.py @@ -32,6 +32,29 @@ def test(self, day_ends, sleep_ends, wear_ends, true_intersect_ends): assert allclose(p_starts, true_starts[i]) assert allclose(p_stops, true_stops[i]) + def test_round_15s(self, day_ends, sleep_ends, wear_ends, true_intersect_ends_15s): + day_start, day_stop = day_ends + sleep_starts, sleep_stops = sleep_ends + wear_starts, wear_stops = wear_ends + true_starts, true_stops = true_intersect_ends_15s + + for i in range(1, 4): + p_starts, p_stops = get_day_index_intersection( + (sleep_starts[i], wear_starts), + (sleep_stops[i], wear_stops), + (False, True), + day_start, + day_stop, + fs=1.0, + ends_round=15, + ) + + assert p_starts.size == true_starts[i].size + assert p_stops.size == true_stops[i].size + + assert allclose(p_starts, true_starts[i]) + assert allclose(p_stops, true_stops[i]) + def test_sleep_only(self, day_ends, sleep_ends, true_sleep_only_ends): day_start, day_stop = day_ends sleep_starts, sleep_stops = sleep_ends From ca67039ae5c1a9b7889f1c4d29c030ee7ecb794e Mon Sep 17 00:00:00 2001 From: Lukas Adamowicz Date: Wed, 18 Mar 2026 11:14:42 -0400 Subject: [PATCH 3/5] dont recalculate FS if its available --- src/skdh/preprocessing/wear_detection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/skdh/preprocessing/wear_detection.py b/src/skdh/preprocessing/wear_detection.py index ef7ca67d..f693d2a1 100644 --- a/src/skdh/preprocessing/wear_detection.py +++ b/src/skdh/preprocessing/wear_detection.py @@ -814,7 +814,7 @@ def predict(self, *, time, accel, fs=None, **kwargs): **kwargs, ) # dont start at zero due to timestamp weirdness with some devices - fs = 1 / mean(diff(time[1000:5000])) + fs = 1 / mean(diff(time[1000:5000])) if fs is None else fs n_wlen = int(self.wlen * 60 * fs) # samples in wlen minutes n_wskip = int(self.wskip * 60 * fs) # samples in wskip minutes From 7884bb0cc7f7bec35fed4248a94040070fbebcf8 Mon Sep 17 00:00:00 2001 From: Lukas Adamowicz Date: Wed, 18 Mar 2026 12:35:05 -0400 Subject: [PATCH 4/5] make sure indices get cast back to integers --- src/skdh/utility/internal.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/skdh/utility/internal.py b/src/skdh/utility/internal.py index 4147ee3a..22e75315 100644 --- a/src/skdh/utility/internal.py +++ b/src/skdh/utility/internal.py @@ -172,6 +172,10 @@ def get_day_index_intersection(starts, stops, for_inclusion, day_start, day_stop valid_starts = around((valid_starts - day_start) / factor, 0) * factor + day_start valid_stops = around((valid_stops - day_start) / factor, 0) * factor + day_start + # make sure they stay as integers + valid_starts = valid_starts.astype(int_) + valid_stops = valid_stops.astype(int_) + return ( valid_starts[valid_starts != valid_stops], valid_stops[valid_starts != valid_stops], From 469c1c4b6c3ac584bd195a300c67292df1c806a2 Mon Sep 17 00:00:00 2001 From: Lukas Adamowicz Date: Wed, 18 Mar 2026 13:35:49 -0400 Subject: [PATCH 5/5] make sure all index intersion function calls use rounding as appropriate --- src/skdh/activity/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/skdh/activity/core.py b/src/skdh/activity/core.py index b5b54886..a668a145 100644 --- a/src/skdh/activity/core.py +++ b/src/skdh/activity/core.py @@ -476,7 +476,7 @@ def predict(self, *, time, accel, fs=None, wear=None, tz_name=None, **kwargs): # get the intersection of wear time and day dwear_starts, dwear_stops = get_day_index_intersection( - *self.wear_idx, True, day_start, day_stop # include wear time + *self.wear_idx, True, day_start, day_stop, ends_round=self.wear_wake_times_round, fs=fs # include wear time ) # PLOTTING. handle here before returning for minimal wear hours, etc @@ -774,7 +774,7 @@ def _plot_day_sleep( start_hr -= 24 # get day-sleep intersection day_sleep_starts, day_sleep_stops = get_day_index_intersection( - sleep_starts, sleep_stops, True, day_start, day_stop + sleep_starts, sleep_stops, True, day_start, day_stop, ends_round=self.wear_wake_times_round, fs=fs ) sleep = []