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.
@@ -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,5 @@
1
+ """Exertion marker computations."""
2
+
3
+ from digital_markers.exertion.markers import compute_exertion_markers
4
+
5
+ __all__ = ["compute_exertion_markers"]
@@ -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,5 @@
1
+ """Bout-based gait markers."""
2
+
3
+ from digital_markers.gait.markers import compute_gait_markers
4
+
5
+ __all__ = ["compute_gait_markers"]
@@ -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"]