digital-markers 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- digital_markers/__init__.py +13 -0
- digital_markers/context.py +13 -0
- digital_markers/exertion/__init__.py +5 -0
- digital_markers/exertion/markers.py +510 -0
- digital_markers/gait/__init__.py +5 -0
- digital_markers/gait/bouts.py +88 -0
- digital_markers/gait/markers.py +266 -0
- digital_markers/gait/spectral.py +21 -0
- digital_markers/gait/steps.py +44 -0
- digital_markers/location/__init__.py +5 -0
- digital_markers/location/distance.py +24 -0
- digital_markers/location/home.py +130 -0
- digital_markers/location/markers.py +360 -0
- digital_markers/location/staypoints.py +241 -0
- digital_markers/preprocessing/__init__.py +5 -0
- digital_markers/preprocessing/wear.py +104 -0
- digital_markers/requirements.py +8 -0
- digital_markers/results.py +14 -0
- digital_markers/routine/__init__.py +5 -0
- digital_markers/routine/markers.py +1078 -0
- digital_markers-0.1.dist-info/METADATA +310 -0
- digital_markers-0.1.dist-info/RECORD +24 -0
- digital_markers-0.1.dist-info/WHEEL +4 -0
- digital_markers-0.1.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Digital health markers derived from wearable and participant data."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from digital_markers.data.participant import ParticipantData
|
|
6
|
+
from digital_markers.results import MarkerResult
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("digital-markers")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "0.1"
|
|
12
|
+
|
|
13
|
+
__all__ = ["MarkerResult", "ParticipantData", "__version__"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from digital_markers.data.participant import ParticipantData
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class MarkerContext:
|
|
6
|
+
data: ParticipantData
|
|
7
|
+
derived: dict[str, object] = field(default_factory=dict)
|
|
8
|
+
|
|
9
|
+
def get_derived(self, name: str) -> object | None:
|
|
10
|
+
return self.derived.get(name)
|
|
11
|
+
|
|
12
|
+
def set_derived(self, name: str, value: object) -> None:
|
|
13
|
+
self.derived[name] = value
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Compute exertion markers from participant motion and optional heart-rate data."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from digital_markers.context import MarkerContext
|
|
7
|
+
from digital_markers.data.participant import ParticipantData
|
|
8
|
+
from digital_markers.data.validation import require_marker_columns
|
|
9
|
+
from digital_markers.preprocessing.wear import detect_nonwear
|
|
10
|
+
from digital_markers.requirements import MarkerRequirements
|
|
11
|
+
from digital_markers.results import MarkerResult
|
|
12
|
+
|
|
13
|
+
REQUIREMENTS = MarkerRequirements(
|
|
14
|
+
required=("motion",),
|
|
15
|
+
optional=("heart_rate",),
|
|
16
|
+
required_columns=(
|
|
17
|
+
"motion.timestamp",
|
|
18
|
+
"motion.acceleration_x",
|
|
19
|
+
"motion.acceleration_y",
|
|
20
|
+
"motion.acceleration_z",
|
|
21
|
+
"motion.rotation_rate_x",
|
|
22
|
+
"motion.rotation_rate_y",
|
|
23
|
+
"motion.rotation_rate_z",
|
|
24
|
+
"heart_rate.timestamp",
|
|
25
|
+
"heart_rate.heart_rate",
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
WINDOW_SECONDS = 30
|
|
30
|
+
HR_WEIGHT = 0.3
|
|
31
|
+
MOTION_WEIGHT = 0.7
|
|
32
|
+
MIN_HR = 35
|
|
33
|
+
MAX_HR = 220
|
|
34
|
+
MOTION_ROBUST_Z_MAX = 6.0
|
|
35
|
+
HR_ROBUST_Z_MAX = 4.0
|
|
36
|
+
MIN_MOTION_SAMPLES_PER_WINDOW = 5
|
|
37
|
+
MIN_HR_SAMPLES_PER_WINDOW = 1
|
|
38
|
+
MIN_HR_VALUES_FOR_MARKERS = 21
|
|
39
|
+
MOTION_SMOOTH_SECONDS = 5
|
|
40
|
+
HR_SMOOTH_SECONDS = 15
|
|
41
|
+
|
|
42
|
+
MOTION_COLUMNS = (
|
|
43
|
+
"acceleration_x",
|
|
44
|
+
"acceleration_y",
|
|
45
|
+
"acceleration_z",
|
|
46
|
+
"rotation_rate_x",
|
|
47
|
+
"rotation_rate_y",
|
|
48
|
+
"rotation_rate_z",
|
|
49
|
+
)
|
|
50
|
+
MOTION_REQUIRED_COLUMNS = ("timestamp", *MOTION_COLUMNS)
|
|
51
|
+
HEART_RATE_REQUIRED_COLUMNS = ("timestamp", "heart_rate")
|
|
52
|
+
|
|
53
|
+
DAILY_FIELDS = (
|
|
54
|
+
"daily_motion_exertion",
|
|
55
|
+
"daily_hr_motion_exertion",
|
|
56
|
+
"daily_peak_hr",
|
|
57
|
+
"motion_exertion_per_hour",
|
|
58
|
+
"hr_motion_exertion_per_hour",
|
|
59
|
+
"hr_motion_corr",
|
|
60
|
+
"hr_motion_gain",
|
|
61
|
+
"hr_residual_mean",
|
|
62
|
+
"motion_wear_time",
|
|
63
|
+
"hr_motion_wear_time",
|
|
64
|
+
)
|
|
65
|
+
HR_FIELDS = (
|
|
66
|
+
"daily_hr_motion_exertion",
|
|
67
|
+
"daily_peak_hr",
|
|
68
|
+
"hr_motion_exertion_per_hour",
|
|
69
|
+
"hr_motion_corr",
|
|
70
|
+
"hr_motion_gain",
|
|
71
|
+
"hr_residual_mean",
|
|
72
|
+
"hr_motion_wear_time",
|
|
73
|
+
)
|
|
74
|
+
HR_EXERTION_FIELDS = (
|
|
75
|
+
"daily_hr_motion_exertion",
|
|
76
|
+
"hr_motion_exertion_per_hour",
|
|
77
|
+
"hr_motion_corr",
|
|
78
|
+
"hr_motion_gain",
|
|
79
|
+
"hr_residual_mean",
|
|
80
|
+
"hr_motion_wear_time",
|
|
81
|
+
)
|
|
82
|
+
EXCLUDED_AGGREGATE_FIELDS = {
|
|
83
|
+
"motion_exertion_per_hour",
|
|
84
|
+
"hr_motion_exertion_per_hour",
|
|
85
|
+
"motion_wear_time",
|
|
86
|
+
"hr_motion_wear_time",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def robust_zscore(values: pd.Series) -> pd.Series:
|
|
91
|
+
"""Compute a median-absolute-deviation robust z-score."""
|
|
92
|
+
values = pd.Series(values, dtype=float)
|
|
93
|
+
median = values.median(skipna=True)
|
|
94
|
+
mad = np.nanmedian(np.abs(values - median))
|
|
95
|
+
if pd.isna(mad) or mad == 0:
|
|
96
|
+
return pd.Series(0.0, index=values.index)
|
|
97
|
+
return 0.6745 * (values - median) / mad
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compute_motion_energy(motion: pd.DataFrame) -> pd.Series:
|
|
101
|
+
"""Compute acceleration magnitude plus half the rotation-rate magnitude."""
|
|
102
|
+
acceleration = np.sqrt(
|
|
103
|
+
motion["acceleration_x"] ** 2
|
|
104
|
+
+ motion["acceleration_y"] ** 2
|
|
105
|
+
+ motion["acceleration_z"] ** 2
|
|
106
|
+
)
|
|
107
|
+
rotation = np.sqrt(
|
|
108
|
+
motion["rotation_rate_x"] ** 2
|
|
109
|
+
+ motion["rotation_rate_y"] ** 2
|
|
110
|
+
+ motion["rotation_rate_z"] ** 2
|
|
111
|
+
)
|
|
112
|
+
return acceleration + 0.5 * rotation
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def clean_heart_rate(
|
|
116
|
+
heart_rate: pd.DataFrame,
|
|
117
|
+
*,
|
|
118
|
+
min_hr: float = MIN_HR,
|
|
119
|
+
max_hr: float = MAX_HR,
|
|
120
|
+
robust_z_max: float = HR_ROBUST_Z_MAX,
|
|
121
|
+
) -> pd.DataFrame:
|
|
122
|
+
"""Remove implausible and participant-level robust HR outliers."""
|
|
123
|
+
if "heart_rate" not in heart_rate.columns:
|
|
124
|
+
return heart_rate
|
|
125
|
+
|
|
126
|
+
cleaned = heart_rate.copy()
|
|
127
|
+
values = pd.to_numeric(cleaned["heart_rate"], errors="coerce")
|
|
128
|
+
values = values.mask((values < min_hr) | (values > max_hr))
|
|
129
|
+
values = values.mask(robust_zscore(values).abs() > robust_z_max)
|
|
130
|
+
cleaned["heart_rate"] = values
|
|
131
|
+
return cleaned
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _smooth_signals(
|
|
135
|
+
motion: pd.DataFrame,
|
|
136
|
+
heart_rate: pd.DataFrame | None,
|
|
137
|
+
*,
|
|
138
|
+
use_short_smoothing: bool = True,
|
|
139
|
+
motion_smooth_seconds: int = MOTION_SMOOTH_SECONDS,
|
|
140
|
+
hr_smooth_seconds: int = HR_SMOOTH_SECONDS,
|
|
141
|
+
) -> tuple[pd.DataFrame, pd.DataFrame | None]:
|
|
142
|
+
if not use_short_smoothing:
|
|
143
|
+
return motion, heart_rate
|
|
144
|
+
|
|
145
|
+
smoothed_motion = motion.copy().set_index("timestamp")
|
|
146
|
+
smoothed_motion["motion_energy"] = smoothed_motion["motion_energy"].rolling(
|
|
147
|
+
f"{motion_smooth_seconds}s", min_periods=1
|
|
148
|
+
).median()
|
|
149
|
+
smoothed_motion = smoothed_motion.reset_index()
|
|
150
|
+
|
|
151
|
+
if heart_rate is None:
|
|
152
|
+
return smoothed_motion, None
|
|
153
|
+
|
|
154
|
+
smoothed_hr = heart_rate.copy().set_index("timestamp")
|
|
155
|
+
smoothed_hr["heart_rate"] = smoothed_hr["heart_rate"].rolling(
|
|
156
|
+
f"{hr_smooth_seconds}s", min_periods=1
|
|
157
|
+
).median()
|
|
158
|
+
return smoothed_motion, smoothed_hr.reset_index()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def exertion_raw(
|
|
162
|
+
window: pd.DataFrame,
|
|
163
|
+
hr_mean: float | None = None,
|
|
164
|
+
hr_std: float | None = None,
|
|
165
|
+
*,
|
|
166
|
+
min_motion_samples_per_window: int = MIN_MOTION_SAMPLES_PER_WINDOW,
|
|
167
|
+
min_hr_samples_per_window: int = MIN_HR_SAMPLES_PER_WINDOW,
|
|
168
|
+
hr_weight: float = HR_WEIGHT,
|
|
169
|
+
motion_weight: float = MOTION_WEIGHT,
|
|
170
|
+
) -> tuple[float, float, float, float]:
|
|
171
|
+
"""Compute motion-only and combined exertion for one time window."""
|
|
172
|
+
motion = np.nan
|
|
173
|
+
heart_rate_component = np.nan
|
|
174
|
+
|
|
175
|
+
if "motion_energy" in window:
|
|
176
|
+
motion_values = window["motion_energy"].dropna()
|
|
177
|
+
if len(motion_values) >= min_motion_samples_per_window:
|
|
178
|
+
raw_motion = motion_values.mean()
|
|
179
|
+
if np.isfinite(raw_motion):
|
|
180
|
+
motion = float(np.tanh(raw_motion))
|
|
181
|
+
elif set(MOTION_COLUMNS).issubset(window.columns):
|
|
182
|
+
raw_motion = compute_motion_energy(window).mean()
|
|
183
|
+
if np.isfinite(raw_motion):
|
|
184
|
+
motion = float(np.tanh(raw_motion))
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
"heart_rate" in window
|
|
188
|
+
and window["heart_rate"].notna().any()
|
|
189
|
+
and hr_mean is not None
|
|
190
|
+
and hr_std is not None
|
|
191
|
+
and hr_std > 0
|
|
192
|
+
):
|
|
193
|
+
hr_values = window["heart_rate"].dropna()
|
|
194
|
+
if len(hr_values) >= min_hr_samples_per_window:
|
|
195
|
+
raw_hr = hr_values.mean()
|
|
196
|
+
z_score = (raw_hr - hr_mean) / hr_std
|
|
197
|
+
heart_rate_component = float((np.tanh(z_score) + 1.0) / 2.0)
|
|
198
|
+
|
|
199
|
+
combined = np.nan
|
|
200
|
+
if np.isfinite(heart_rate_component) and np.isfinite(motion):
|
|
201
|
+
combined = hr_weight * heart_rate_component + motion_weight * motion
|
|
202
|
+
return motion, combined, heart_rate_component, motion
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _summarize_daily_values(values: pd.Series, dates: pd.Series) -> dict[str, float]:
|
|
206
|
+
numbers = values.to_numpy(dtype=float, na_value=np.nan)
|
|
207
|
+
valid = np.isfinite(numbers)
|
|
208
|
+
clean = numbers[valid]
|
|
209
|
+
if not len(clean):
|
|
210
|
+
return {"mean": np.nan, "std": np.nan, "ac1": np.nan, "slope": np.nan}
|
|
211
|
+
|
|
212
|
+
dated_values = pd.Series(
|
|
213
|
+
numbers,
|
|
214
|
+
index=pd.DatetimeIndex(pd.to_datetime(dates)).normalize(),
|
|
215
|
+
)
|
|
216
|
+
dated_values = dated_values[np.isfinite(dated_values.to_numpy())]
|
|
217
|
+
dated_values = dated_values.groupby(level=0).mean().sort_index()
|
|
218
|
+
ac1 = np.nan
|
|
219
|
+
if len(dated_values) >= 3:
|
|
220
|
+
dated_numbers = dated_values.to_numpy()
|
|
221
|
+
centered = dated_numbers - dated_numbers.mean()
|
|
222
|
+
denominator = np.sum(centered**2)
|
|
223
|
+
consecutive = np.diff(dated_values.index.to_numpy()) == np.timedelta64(1, "D")
|
|
224
|
+
consecutive_count = int(consecutive.sum())
|
|
225
|
+
if denominator > 0 and consecutive_count >= 2:
|
|
226
|
+
numerator = np.sum((centered[:-1] * centered[1:])[consecutive])
|
|
227
|
+
ac1 = float((numerator / consecutive_count) / (denominator / len(dated_numbers)))
|
|
228
|
+
|
|
229
|
+
slope = (
|
|
230
|
+
float(np.polyfit(np.arange(len(numbers))[valid], clean, 1)[0])
|
|
231
|
+
if len(clean) >= 2
|
|
232
|
+
else 0.0
|
|
233
|
+
)
|
|
234
|
+
return {
|
|
235
|
+
"mean": float(clean.mean()),
|
|
236
|
+
"std": float(np.std(clean, ddof=0)),
|
|
237
|
+
"ac1": ac1,
|
|
238
|
+
"slope": slope,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def compute_exertion_markers(
|
|
243
|
+
data: ParticipantData,
|
|
244
|
+
*,
|
|
245
|
+
time_window: str | None = None,
|
|
246
|
+
window_seconds: int = WINDOW_SECONDS,
|
|
247
|
+
hr_weight: float = HR_WEIGHT,
|
|
248
|
+
motion_weight: float = MOTION_WEIGHT,
|
|
249
|
+
min_hr: float = MIN_HR,
|
|
250
|
+
max_hr: float = MAX_HR,
|
|
251
|
+
motion_robust_z_max: float = MOTION_ROBUST_Z_MAX,
|
|
252
|
+
hr_robust_z_max: float = HR_ROBUST_Z_MAX,
|
|
253
|
+
min_motion_samples_per_window: int = MIN_MOTION_SAMPLES_PER_WINDOW,
|
|
254
|
+
min_hr_samples_per_window: int = MIN_HR_SAMPLES_PER_WINDOW,
|
|
255
|
+
min_hr_values_for_markers: int = MIN_HR_VALUES_FOR_MARKERS,
|
|
256
|
+
filter_signals: bool = False,
|
|
257
|
+
use_short_smoothing: bool | None = None,
|
|
258
|
+
motion_smooth_seconds: int = MOTION_SMOOTH_SECONDS,
|
|
259
|
+
hr_smooth_seconds: int = HR_SMOOTH_SECONDS,
|
|
260
|
+
) -> MarkerResult:
|
|
261
|
+
"""Compute exertion dataframes from a participant's motion and heart-rate frames."""
|
|
262
|
+
if data.motion is None or data.motion.empty:
|
|
263
|
+
raise ValueError("Missing required data for exertion: motion")
|
|
264
|
+
|
|
265
|
+
motion = data.motion.copy()
|
|
266
|
+
heart_rate = (
|
|
267
|
+
data.heart_rate.copy()
|
|
268
|
+
if data.heart_rate is not None and not data.heart_rate.empty
|
|
269
|
+
else None
|
|
270
|
+
)
|
|
271
|
+
frames = {"motion": motion}
|
|
272
|
+
if heart_rate is not None:
|
|
273
|
+
frames["heart_rate"] = heart_rate
|
|
274
|
+
require_marker_columns(
|
|
275
|
+
frames,
|
|
276
|
+
REQUIREMENTS.required_columns,
|
|
277
|
+
optional_streams=REQUIREMENTS.optional,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
for name, frame in frames.items():
|
|
281
|
+
if not pd.api.types.is_datetime64_any_dtype(frame["timestamp"]):
|
|
282
|
+
raise ValueError(f"{name}.timestamp must have a datetime dtype before calling exertion")
|
|
283
|
+
for column in MOTION_COLUMNS:
|
|
284
|
+
if not pd.api.types.is_numeric_dtype(motion[column]):
|
|
285
|
+
raise ValueError(f"motion.{column} must have a numeric dtype before calling exertion")
|
|
286
|
+
if heart_rate is not None and not pd.api.types.is_numeric_dtype(heart_rate["heart_rate"]):
|
|
287
|
+
raise ValueError("heart_rate.heart_rate must have a numeric dtype before calling exertion")
|
|
288
|
+
|
|
289
|
+
wear_mask = detect_nonwear(motion, rate=10, window_minutes=1)
|
|
290
|
+
motion_wear = (
|
|
291
|
+
pd.DataFrame({"timestamp": motion["timestamp"], "worn": wear_mask.to_numpy()})
|
|
292
|
+
.dropna(subset=["timestamp"])
|
|
293
|
+
.sort_values("timestamp")
|
|
294
|
+
.reset_index(drop=True)
|
|
295
|
+
)
|
|
296
|
+
motion = motion.loc[wear_mask.to_numpy(), MOTION_REQUIRED_COLUMNS].copy()
|
|
297
|
+
motion = motion.dropna(subset=["timestamp"]).sort_values("timestamp").reset_index(drop=True)
|
|
298
|
+
motion["motion_energy"] = compute_motion_energy(motion)
|
|
299
|
+
|
|
300
|
+
if filter_signals:
|
|
301
|
+
motion_z = robust_zscore(motion["motion_energy"])
|
|
302
|
+
motion.loc[motion_z.abs() > motion_robust_z_max, "motion_energy"] = np.nan
|
|
303
|
+
|
|
304
|
+
if heart_rate is not None:
|
|
305
|
+
heart_rate = heart_rate.loc[:, HEART_RATE_REQUIRED_COLUMNS].copy()
|
|
306
|
+
heart_rate = (
|
|
307
|
+
heart_rate.dropna(subset=["timestamp"])
|
|
308
|
+
.sort_values("timestamp")
|
|
309
|
+
.reset_index(drop=True)
|
|
310
|
+
)
|
|
311
|
+
if motion_wear.empty:
|
|
312
|
+
heart_rate = heart_rate.iloc[0:0].copy()
|
|
313
|
+
else:
|
|
314
|
+
heart_rate = pd.merge_asof(
|
|
315
|
+
heart_rate,
|
|
316
|
+
motion_wear,
|
|
317
|
+
on="timestamp",
|
|
318
|
+
direction="nearest",
|
|
319
|
+
)
|
|
320
|
+
heart_rate = heart_rate.loc[heart_rate["worn"]].drop(columns="worn")
|
|
321
|
+
heart_rate["heart_rate"] = pd.to_numeric(heart_rate["heart_rate"], errors="coerce")
|
|
322
|
+
if filter_signals:
|
|
323
|
+
heart_rate = clean_heart_rate(
|
|
324
|
+
heart_rate,
|
|
325
|
+
min_hr=min_hr,
|
|
326
|
+
max_hr=max_hr,
|
|
327
|
+
robust_z_max=hr_robust_z_max,
|
|
328
|
+
)
|
|
329
|
+
valid_hr = heart_rate["heart_rate"].dropna()
|
|
330
|
+
else:
|
|
331
|
+
valid_hr = pd.Series(dtype=float)
|
|
332
|
+
|
|
333
|
+
has_sufficient_hr = len(valid_hr) >= min_hr_values_for_markers
|
|
334
|
+
if has_sufficient_hr:
|
|
335
|
+
hr_mean = float(valid_hr.mean())
|
|
336
|
+
hr_std = float(valid_hr.std(ddof=1))
|
|
337
|
+
else:
|
|
338
|
+
hr_mean = hr_std = None
|
|
339
|
+
|
|
340
|
+
has_hr_stream = heart_rate is not None
|
|
341
|
+
if heart_rate is not None:
|
|
342
|
+
heart_rate["heart_rate_peak"] = heart_rate["heart_rate"]
|
|
343
|
+
|
|
344
|
+
smoothing = filter_signals if use_short_smoothing is None else use_short_smoothing
|
|
345
|
+
motion, heart_rate = _smooth_signals(
|
|
346
|
+
motion,
|
|
347
|
+
heart_rate,
|
|
348
|
+
use_short_smoothing=smoothing,
|
|
349
|
+
motion_smooth_seconds=motion_smooth_seconds,
|
|
350
|
+
hr_smooth_seconds=hr_smooth_seconds,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
motion_signals = motion[["timestamp", "motion_energy"]]
|
|
354
|
+
if heart_rate is None:
|
|
355
|
+
merged = motion_signals.copy()
|
|
356
|
+
merged["heart_rate"] = np.nan
|
|
357
|
+
merged["heart_rate_peak"] = np.nan
|
|
358
|
+
else:
|
|
359
|
+
merged = pd.concat(
|
|
360
|
+
[
|
|
361
|
+
motion_signals,
|
|
362
|
+
heart_rate[["timestamp", "heart_rate", "heart_rate_peak"]],
|
|
363
|
+
],
|
|
364
|
+
ignore_index=True,
|
|
365
|
+
)
|
|
366
|
+
merged = merged.sort_values("timestamp").reset_index(drop=True)
|
|
367
|
+
|
|
368
|
+
rows = []
|
|
369
|
+
windowed_rows = []
|
|
370
|
+
windowed_index = []
|
|
371
|
+
if not merged.empty:
|
|
372
|
+
for timestamp, group in merged.set_index("timestamp").groupby(
|
|
373
|
+
pd.Grouper(freq=time_window or f"{window_seconds}s")
|
|
374
|
+
):
|
|
375
|
+
motion_exertion, hr_motion_exertion, hr_component, motion_component = exertion_raw(
|
|
376
|
+
group,
|
|
377
|
+
hr_mean=hr_mean,
|
|
378
|
+
hr_std=hr_std,
|
|
379
|
+
min_motion_samples_per_window=min_motion_samples_per_window,
|
|
380
|
+
min_hr_samples_per_window=min_hr_samples_per_window,
|
|
381
|
+
hr_weight=hr_weight,
|
|
382
|
+
motion_weight=motion_weight,
|
|
383
|
+
)
|
|
384
|
+
if np.isfinite(hr_motion_exertion):
|
|
385
|
+
exertion = hr_motion_exertion
|
|
386
|
+
elif np.isfinite(motion_exertion):
|
|
387
|
+
exertion = motion_exertion
|
|
388
|
+
else:
|
|
389
|
+
exertion = np.nan
|
|
390
|
+
windowed_rows.append(
|
|
391
|
+
{
|
|
392
|
+
"motion_exertion": motion_exertion,
|
|
393
|
+
"hr_motion_exertion": hr_motion_exertion,
|
|
394
|
+
"hr_component": hr_component,
|
|
395
|
+
"motion_component": motion_component,
|
|
396
|
+
"heart_rate_peak": group["heart_rate_peak"].max(),
|
|
397
|
+
"exertion": exertion,
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
windowed_index.append(timestamp)
|
|
401
|
+
|
|
402
|
+
windowed = pd.DataFrame(
|
|
403
|
+
windowed_rows,
|
|
404
|
+
index=pd.DatetimeIndex(windowed_index, name="timestamp"),
|
|
405
|
+
columns=(
|
|
406
|
+
"motion_exertion",
|
|
407
|
+
"hr_motion_exertion",
|
|
408
|
+
"hr_component",
|
|
409
|
+
"motion_component",
|
|
410
|
+
"heart_rate_peak",
|
|
411
|
+
"exertion",
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
if not has_sufficient_hr:
|
|
415
|
+
for column in ("hr_motion_exertion", "hr_component"):
|
|
416
|
+
windowed[column] = pd.Series([None] * len(windowed), index=windowed.index, dtype=object)
|
|
417
|
+
if not has_hr_stream:
|
|
418
|
+
windowed["heart_rate_peak"] = pd.Series(
|
|
419
|
+
[None] * len(windowed), index=windowed.index, dtype=object
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
for date, day in windowed.groupby(windowed.index.normalize()):
|
|
423
|
+
motion_values = day["motion_exertion"].dropna()
|
|
424
|
+
if motion_values.empty:
|
|
425
|
+
continue
|
|
426
|
+
hr_values = day["hr_motion_exertion"].dropna() if has_sufficient_hr else pd.Series(dtype=float)
|
|
427
|
+
paired = day.loc[
|
|
428
|
+
day["hr_motion_exertion"].notna(),
|
|
429
|
+
["hr_component", "motion_component"],
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
hr_motion_corr = hr_motion_gain = hr_residual_mean = np.nan
|
|
433
|
+
if len(paired) >= 3:
|
|
434
|
+
hr_array = paired["hr_component"].to_numpy(dtype=float)
|
|
435
|
+
motion_array = paired["motion_component"].to_numpy(dtype=float)
|
|
436
|
+
hr_motion_corr = float(np.corrcoef(hr_array, motion_array)[0, 1])
|
|
437
|
+
slope, intercept = np.polyfit(motion_array, hr_array, 1)
|
|
438
|
+
hr_motion_gain = float(slope)
|
|
439
|
+
residuals = hr_array - (slope * motion_array + intercept)
|
|
440
|
+
positive_residuals = residuals[residuals > 0]
|
|
441
|
+
hr_residual_mean = float(positive_residuals.mean()) if len(positive_residuals) else 0.0
|
|
442
|
+
|
|
443
|
+
motion_wear_time = len(motion_values) * window_seconds
|
|
444
|
+
hr_wear_time = len(hr_values) * window_seconds
|
|
445
|
+
peak_values = day["heart_rate_peak"].dropna()
|
|
446
|
+
peak_values = peak_values[(peak_values >= min_hr) & (peak_values <= max_hr)]
|
|
447
|
+
rows.append(
|
|
448
|
+
{
|
|
449
|
+
"date": date,
|
|
450
|
+
"daily_motion_exertion": float(motion_values.mean()),
|
|
451
|
+
"daily_hr_motion_exertion": float(hr_values.mean()) if len(hr_values) else None,
|
|
452
|
+
"daily_peak_hr": (
|
|
453
|
+
float(peak_values.max()) if has_hr_stream and len(peak_values) else None
|
|
454
|
+
),
|
|
455
|
+
"motion_exertion_per_hour": float(motion_values.sum() / motion_wear_time),
|
|
456
|
+
"hr_motion_exertion_per_hour": (
|
|
457
|
+
float(hr_values.sum() / hr_wear_time) if hr_wear_time else None
|
|
458
|
+
),
|
|
459
|
+
"hr_motion_corr": hr_motion_corr if len(paired) >= 3 else None,
|
|
460
|
+
"hr_motion_gain": hr_motion_gain if len(paired) >= 3 else None,
|
|
461
|
+
"hr_residual_mean": hr_residual_mean if len(paired) >= 3 else None,
|
|
462
|
+
"motion_wear_time": motion_wear_time,
|
|
463
|
+
"hr_motion_wear_time": hr_wear_time if has_sufficient_hr else None,
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
daily = pd.DataFrame(rows, columns=("date", *DAILY_FIELDS))
|
|
468
|
+
daily["daily_peak_hr"] = daily["daily_peak_hr"].astype(object).where(
|
|
469
|
+
daily["daily_peak_hr"].notna(), None
|
|
470
|
+
)
|
|
471
|
+
if has_sufficient_hr:
|
|
472
|
+
for field in HR_FIELDS:
|
|
473
|
+
daily[field] = daily[field].astype(object).where(daily[field].notna(), None)
|
|
474
|
+
else:
|
|
475
|
+
for field in HR_EXERTION_FIELDS:
|
|
476
|
+
daily[field] = pd.Series([None] * len(daily), dtype=object)
|
|
477
|
+
|
|
478
|
+
summary = {}
|
|
479
|
+
for field in DAILY_FIELDS:
|
|
480
|
+
if field in EXCLUDED_AGGREGATE_FIELDS:
|
|
481
|
+
continue
|
|
482
|
+
stats = _summarize_daily_values(daily[field], daily["date"])
|
|
483
|
+
for statistic, value in stats.items():
|
|
484
|
+
summary[f"{statistic}_{field}"] = (
|
|
485
|
+
None
|
|
486
|
+
if (
|
|
487
|
+
field in HR_EXERTION_FIELDS and not has_sufficient_hr
|
|
488
|
+
or field == "daily_peak_hr" and not has_hr_stream
|
|
489
|
+
)
|
|
490
|
+
else value
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
markers = pd.DataFrame([summary])
|
|
494
|
+
context = MarkerContext(data=data, derived={"exertion": windowed["exertion"].to_dict()})
|
|
495
|
+
return MarkerResult(
|
|
496
|
+
markers=markers,
|
|
497
|
+
daily=daily,
|
|
498
|
+
details={"windowed_exertion": windowed},
|
|
499
|
+
metadata={"marker": "exertion"},
|
|
500
|
+
context=context,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
__all__ = [
|
|
505
|
+
"REQUIREMENTS",
|
|
506
|
+
"clean_heart_rate",
|
|
507
|
+
"compute_exertion_markers",
|
|
508
|
+
"compute_motion_energy",
|
|
509
|
+
"exertion_raw",
|
|
510
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Timestamp segmentation and conservative walking-bout detection."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from digital_markers.gait.spectral import bout_spectral_features
|
|
6
|
+
from digital_markers.gait.steps import detect_step_peaks
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def segment_indices(
|
|
10
|
+
stamps_ns: np.ndarray,
|
|
11
|
+
fs: float,
|
|
12
|
+
max_gap_factor: float,
|
|
13
|
+
) -> list[tuple[int, int]]:
|
|
14
|
+
deltas = np.diff(stamps_ns)
|
|
15
|
+
boundaries = np.flatnonzero(
|
|
16
|
+
(deltas <= 0) | (deltas > round(max_gap_factor * 1e9 / fs))
|
|
17
|
+
) + 1
|
|
18
|
+
return list(zip(np.r_[0, boundaries], np.r_[boundaries, len(stamps_ns)]))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def accepted_bouts(
|
|
22
|
+
acceleration: np.ndarray,
|
|
23
|
+
fs: float,
|
|
24
|
+
*,
|
|
25
|
+
min_steps: int,
|
|
26
|
+
min_bout_seconds: float,
|
|
27
|
+
max_step_gap_seconds: float,
|
|
28
|
+
min_cadence: float,
|
|
29
|
+
max_cadence: float,
|
|
30
|
+
max_interval_cv: float,
|
|
31
|
+
prominence_scale: float,
|
|
32
|
+
) -> list[dict[str, object]]:
|
|
33
|
+
if len(acceleration) < max(15, int(min_bout_seconds * fs)):
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
magnitude = np.linalg.norm(acceleration, axis=1)
|
|
37
|
+
peaks = detect_step_peaks(
|
|
38
|
+
magnitude,
|
|
39
|
+
fs,
|
|
40
|
+
max_cadence=max_cadence,
|
|
41
|
+
prominence_scale=prominence_scale,
|
|
42
|
+
)
|
|
43
|
+
if len(peaks) < min_steps:
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
gap_seconds = np.diff(peaks) / fs
|
|
47
|
+
breaks = np.flatnonzero(gap_seconds > max_step_gap_seconds) + 1
|
|
48
|
+
bouts = []
|
|
49
|
+
for indices in np.split(peaks, breaks):
|
|
50
|
+
if len(indices) < min_steps:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
intervals = np.diff(indices) / fs
|
|
54
|
+
duration = (indices[-1] - indices[0]) / fs
|
|
55
|
+
if duration < min_bout_seconds:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
mean_interval = np.mean(intervals)
|
|
59
|
+
cadence = 60 / mean_interval
|
|
60
|
+
interval_cv = float(np.std(intervals) / mean_interval)
|
|
61
|
+
if not (
|
|
62
|
+
min_cadence <= cadence <= max_cadence
|
|
63
|
+
and interval_cv <= max_interval_cv
|
|
64
|
+
):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
low = max(0, int(indices[0] - fs))
|
|
68
|
+
high = min(len(magnitude), int(indices[-1] + fs + 1))
|
|
69
|
+
window = magnitude[low:high]
|
|
70
|
+
dominant_frequency, spectral_entropy = bout_spectral_features(window, fs)
|
|
71
|
+
bouts.append(
|
|
72
|
+
{
|
|
73
|
+
"nsteps": len(indices),
|
|
74
|
+
"intervals": intervals,
|
|
75
|
+
"duration": duration,
|
|
76
|
+
"cadence": cadence,
|
|
77
|
+
"dominant_gait_freq": dominant_frequency,
|
|
78
|
+
"spectral_entropy": spectral_entropy,
|
|
79
|
+
"acc_sq_sum": float(np.square(magnitude[low:high]).sum()),
|
|
80
|
+
"acc_n": high - low,
|
|
81
|
+
"lo": low,
|
|
82
|
+
"hi": high,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
return bouts
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["accepted_bouts", "segment_indices"]
|