Coverage for tests / test_activity_step_count.py: 100%

167 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-07-14 19:36 +0000

1from __future__ import annotations 

2 

3import numpy as np 

4import pytest 

5 

6from physiodsp.activity.step_count import ( 

7 StepBout, 

8 StepCount, 

9 StepCountSettings, 

10 autocorrelation_peak, 

11 detect_steps_in_window, 

12 filter_short_bouts, 

13 highpass_filter, 

14 interpolate_missed_steps, 

15 is_locomotion_window, 

16 merge_close_bouts, 

17 steps_to_cadence_series, 

18) 

19from physiodsp.sensors.imu.accelerometer import AccelerometerData 

20 

21 

22# --------------------------------------------------------------------------- 

23# Helpers 

24# --------------------------------------------------------------------------- 

25 

26FS = 50.0 # Hz — a common sampling rate for testing 

27 

28 

29def make_sine_accel( 

30 freq_hz: float, 

31 duration_s: float, 

32 amplitude: float = 0.5, 

33 fs: float = FS, 

34 noise_std: float = 0.0, 

35) -> np.ndarray: 

36 """Periodic sinusoidal signal centred at 0, simulating dynamic acceleration.""" 

37 t = np.arange(int(duration_s * fs)) / fs 

38 sig = amplitude * np.sin(2 * np.pi * freq_hz * t) 

39 if noise_std > 0: 

40 rng = np.random.default_rng(42) 

41 sig += rng.normal(0, noise_std, size=len(sig)) 

42 return sig 

43 

44 

45def make_accel_data( 

46 dynamic_signal: np.ndarray, 

47 fs: float = FS, 

48 gravity: float = 1.0, 

49) -> AccelerometerData: 

50 """Wrap a 1-D dynamic signal into an AccelerometerData instance. 

51 The signal is injected on the Z axis; X and Y carry only gravity / noise. 

52 """ 

53 n = len(dynamic_signal) 

54 t = np.arange(n) / fs 

55 x = np.zeros(n) 

56 y = np.zeros(n) 

57 z = gravity + dynamic_signal # gravity on Z; dynamic component added 

58 return AccelerometerData(timestamps=t, x=x, y=y, z=z, fs=int(fs)) 

59 

60 

61def run_step_count_on_sine( 

62 freq_hz: float, 

63 duration_s: float = 30.0, 

64 amplitude: float = 0.5, 

65 noise_std: float = 0.0, 

66 settings: StepCountSettings | None = None, 

67) -> StepCount: 

68 """Helper to run StepCount algorithm on a synthetic sine signal.""" 

69 sig = make_sine_accel(freq_hz, duration_s, amplitude, noise_std=noise_std) 

70 accel = make_accel_data(sig) 

71 sc = StepCount(settings=settings or StepCountSettings()) 

72 return sc.run(accel) 

73 

74 

75def make_test_bouts() -> list[StepBout]: 

76 """Helper to create a set of sample StepBout objects.""" 

77 return [ 

78 StepBout(0.0, 5.0, 8, 100.0, "walk", [0.5 * i for i in range(8)]), 

79 StepBout(20.0, 25.0, 2, 80.0, "walk", [20.0 + 0.5 * i for i in range(2)]), 

80 StepBout(30.0, 40.0, 15, 120.0, "walk", [30.0 + 0.5 * i for i in range(15)]), 

81 ] 

82 

83 

84# --------------------------------------------------------------------------- 

85# Pre-processing Tests 

86# --------------------------------------------------------------------------- 

87 

88def test_step_count_highpass_removes_dc(): 

89 """HP filter must remove the 1 g DC gravity component.""" 

90 n = 1000 

91 sig = np.ones(n) * 1.0 + 0.3 * np.sin(2 * np.pi * 1.5 * np.arange(n) / FS) 

92 filtered = highpass_filter(sig, fs=FS, cutoff_hz=0.5) 

93 # DC component must be removed: mean of filtered signal ≈ 0 

94 assert abs(np.mean(filtered)) < 0.05 

95 

96 

97def test_step_count_highpass_preserves_gait_frequencies(): 

98 """HP filter must not attenuate a 1.5 Hz gait signal significantly.""" 

99 duration_s = 10.0 

100 sig = make_sine_accel(freq_hz=1.5, duration_s=duration_s) 

101 filtered = highpass_filter(sig, fs=FS, cutoff_hz=0.5) 

102 # RMS of filtered signal should be > 80 % of input RMS 

103 assert np.std(filtered) > 0.8 * np.std(sig) 

104 

105 

106# --------------------------------------------------------------------------- 

107# Motion Gate Tests 

108# --------------------------------------------------------------------------- 

109 

110def test_step_count_sleep_window_rejected(): 

111 """Below-sleep-threshold SMA -> is_locomotion_window returns False.""" 

112 sig = np.random.randn(int(2.0 * FS)) * 0.005 

113 sma = float(np.mean(np.abs(sig))) 

114 is_loco, _, state = is_locomotion_window( 

115 sig, fs=FS, sma=sma, 

116 sma_sleep_thr=0.02, sma_adl_thr=0.05, 

117 periodicity_threshold=0.5, spectral_purity_threshold=8.0 

118 ) 

119 assert not is_loco 

120 assert state == "sleep" 

121 

122 

123# --------------------------------------------------------------------------- 

124# Periodicity Detector Tests 

125# --------------------------------------------------------------------------- 

126 

127@pytest.mark.parametrize("freq", [1.2, 1.6, 2.0, 2.5, 3.0]) 

128def test_step_count_pure_sine_is_periodic(freq: float): 

129 """A pure sinusoid at any valid gait frequency must be detected as periodic.""" 

130 sig = make_sine_accel(freq_hz=freq, duration_s=4.0) 

131 peak, detected_freq = autocorrelation_peak(sig, fs=FS) 

132 assert peak > 0.5, f"AC peak {peak:.3f} too low for {freq} Hz sine" 

133 assert abs(detected_freq - freq) < 0.3, ( 

134 f"Detected freq {detected_freq:.2f} Hz too far from {freq} Hz" 

135 ) 

136 

137 

138def test_step_count_noise_is_not_periodic(): 

139 """White noise must not exceed the periodicity threshold.""" 

140 rng = np.random.default_rng(0) 

141 sig = rng.normal(0, 0.3, size=int(4.0 * FS)) 

142 peak, _ = autocorrelation_peak(sig, fs=FS) 

143 assert peak < 0.5, f"Noise produced AC peak {peak:.3f} >= 0.5" 

144 

145 

146def test_step_count_walk_window_detected(): 

147 """Walking-frequency signal above SMA threshold -> locomotion detected.""" 

148 sig = make_sine_accel(freq_hz=1.6, duration_s=4.0, amplitude=0.4) 

149 sma = float(np.mean(np.abs(sig))) 

150 is_loco, cadence, state = is_locomotion_window( 

151 sig, fs=FS, sma=sma, 

152 sma_sleep_thr=0.02, sma_adl_thr=0.05, 

153 periodicity_threshold=0.5, spectral_purity_threshold=8.0 

154 ) 

155 assert is_loco 

156 assert state in ("walk", "run") 

157 assert 1.0 < cadence < 2.5 

158 

159 

160def test_step_count_run_window_detected(): 

161 """Running-frequency signal (2.8 Hz) must be classified as run.""" 

162 sig = make_sine_accel(freq_hz=2.8, duration_s=4.0, amplitude=1.5) 

163 sma = float(np.mean(np.abs(sig))) 

164 is_loco, cadence, state = is_locomotion_window( 

165 sig, fs=FS, sma=sma, 

166 sma_sleep_thr=0.02, sma_adl_thr=0.05, 

167 periodicity_threshold=0.5, spectral_purity_threshold=8.0 

168 ) 

169 assert is_loco 

170 assert state == "run" 

171 

172 

173# --------------------------------------------------------------------------- 

174# Adaptive Peak Detector Tests 

175# --------------------------------------------------------------------------- 

176 

177def test_step_count_slow_walk_accuracy(): 

178 """Slow walking at 1.3 Hz for 10 s -> ~13 steps expected.""" 

179 freq = 1.3 

180 duration = 10.0 

181 sig = make_sine_accel(freq_hz=freq, duration_s=duration, amplitude=0.3) 

182 peaks = detect_steps_in_window(sig, fs=FS, cadence_hz=freq) 

183 expected = int(freq * duration) 

184 assert abs(len(peaks) - expected) <= 2, ( 

185 f"Expected ~{expected} steps, got {len(peaks)}" 

186 ) 

187 

188 

189def test_step_count_running_accuracy(): 

190 """Running at 2.8 Hz for 10 s -> ~28 steps expected.""" 

191 freq = 2.8 

192 duration = 10.0 

193 sig = make_sine_accel(freq_hz=freq, duration_s=duration, amplitude=1.5) 

194 peaks = detect_steps_in_window(sig, fs=FS, cadence_hz=freq) 

195 expected = int(freq * duration) 

196 assert abs(len(peaks) - expected) <= 3, ( 

197 f"Expected ~{expected} steps, got {len(peaks)}" 

198 ) 

199 

200 

201def test_step_count_amplitude_invariance(): 

202 """Step count should be the same regardless of signal amplitude (adaptive threshold).""" 

203 freq = 1.6 

204 duration = 10.0 

205 steps_low = len(detect_steps_in_window( 

206 make_sine_accel(freq, duration, amplitude=0.3), fs=FS, cadence_hz=freq)) 

207 steps_high = len(detect_steps_in_window( 

208 make_sine_accel(freq, duration, amplitude=2.0), fs=FS, cadence_hz=freq)) 

209 assert abs(steps_low - steps_high) <= 2, ( 

210 f"Amplitude should not affect count: low={steps_low}, high={steps_high}" 

211 ) 

212 

213 

214# --------------------------------------------------------------------------- 

215# Post-processing Tests 

216# --------------------------------------------------------------------------- 

217 

218def test_step_count_filter_short_bouts(): 

219 bouts = make_test_bouts() 

220 filtered = filter_short_bouts(bouts, min_steps=3) 

221 assert all(b.step_count >= 3 for b in filtered) 

222 assert len(filtered) == 2 

223 

224 

225def test_step_count_merge_close_bouts(): 

226 """Two bouts separated by 2 s gap should be merged with merge_gap_s=3.""" 

227 b1 = StepBout(0.0, 5.0, 8, 100.0, "walk", list(np.linspace(0, 5, 8))) 

228 b2 = StepBout(7.0, 12.0, 8, 100.0, "walk", list(np.linspace(7, 12, 8))) 

229 merged = merge_close_bouts([b1, b2], merge_gap_s=3.0) 

230 assert len(merged) == 1 

231 assert merged[0].step_count == 16 

232 

233 

234def test_step_count_no_merge_far_bouts(): 

235 """Bouts separated by > merge_gap_s must NOT be merged.""" 

236 b1 = StepBout(0.0, 5.0, 8, 100.0, "walk", list(np.linspace(0, 5, 8))) 

237 b2 = StepBout(15.0, 20.0, 8, 100.0, "walk", list(np.linspace(15, 20, 8))) 

238 merged = merge_close_bouts([b1, b2], merge_gap_s=3.0) 

239 assert len(merged) == 2 

240 

241 

242def test_step_count_interpolate_missed_steps(): 

243 """A gap of ~2 periods should produce one interpolated step.""" 

244 cadence_hz = 2.0 # 0.5 s per step 

245 steps = [0.0, 0.5, 1.5, 2.0] 

246 filled = interpolate_missed_steps(steps, cadence_hz=cadence_hz) 

247 assert len(filled) == 5 

248 

249 

250def test_step_count_cadence_series_bin_count(): 

251 """steps_to_cadence_series must produce exactly ceil(duration/bin_s) bins.""" 

252 steps = np.array([1.0, 2.0, 3.0, 5.0, 6.0]) 

253 bin_ts, cad = steps_to_cadence_series(steps, total_duration_s=10.0, bin_s=1.0) 

254 assert len(bin_ts) == 10 

255 assert len(cad) == 10 

256 

257 

258def test_step_count_cadence_series_values(): 

259 """Bins with exactly 2 steps at bin_s=1 s should give 120 spm.""" 

260 steps = np.array([0.1, 0.9]) 

261 _, cad = steps_to_cadence_series(steps, total_duration_s=3.0, bin_s=1.0) 

262 assert cad[0] == pytest.approx(120.0) 

263 

264 

265# --------------------------------------------------------------------------- 

266# End-to-end Integration Tests 

267# --------------------------------------------------------------------------- 

268 

269@pytest.mark.parametrize("freq,speed_label", [ 

270 (1.2, "slow_walk"), 

271 (1.6, "normal_walk"), 

272 (2.0, "brisk_walk"), 

273 (2.5, "jog"), 

274 (3.0, "run"), 

275]) 

276def test_step_count_accuracy_all_speeds(freq: float, speed_label: str): 

277 duration = 30.0 

278 result = run_step_count_on_sine(freq, duration_s=duration, amplitude=0.5) 

279 expected = int(freq * duration) 

280 error_pct = abs(result.total_steps - expected) / expected * 100 

281 assert error_pct <= 15.0, ( 

282 f"[{speed_label}] Expected ~{expected} steps, got {result.total_steps} " 

283 f"(error {error_pct:.1f} %)" 

284 ) 

285 

286 

287def test_step_count_zero_steps_sleep(): 

288 """Near-zero acceleration (sleep) must produce 0 steps.""" 

289 rng = np.random.default_rng(42) 

290 n = int(30.0 * FS) 

291 t = np.arange(n) / FS 

292 sig = rng.normal(0, 0.005, n) 

293 accel = AccelerometerData( 

294 timestamps=t, x=sig, y=sig, 

295 z=np.ones(n) + sig, fs=int(FS) 

296 ) 

297 result = StepCount().run(accel) 

298 assert result.total_steps == 0 

299 

300 

301def test_step_count_biomarker_schema(): 

302 """biomarker DataFrame must have the required columns and correct length.""" 

303 result = run_step_count_on_sine(1.6, duration_s=20.0) 

304 assert "timestamps" in result.biomarker.columns 

305 assert "step_count" in result.biomarker.columns 

306 assert "cadence_spm" in result.biomarker.columns 

307 assert "activity_state" in result.biomarker.columns 

308 assert len(result.biomarker) == 20 

309 

310 

311def test_step_count_total_steps_consistency(): 

312 """The per-bin step_count column must sum to total_steps.""" 

313 result = run_step_count_on_sine(1.6, duration_s=20.0) 

314 assert int(result.biomarker["step_count"].sum()) == result.total_steps 

315 

316 

317def test_step_count_noisy_free_living_walk(): 

318 """Walking signal with realistic free-living noise should still be detected.""" 

319 result = run_step_count_on_sine( 

320 freq_hz=1.6, duration_s=30.0, amplitude=0.4, noise_std=0.15 

321 ) 

322 expected = int(1.6 * 30) 

323 error_pct = abs(result.total_steps - expected) / expected * 100 

324 assert error_pct <= 20.0, ( 

325 f"Free-living noisy walk: expected ~{expected}, got {result.total_steps} " 

326 f"(error {error_pct:.1f} %)" 

327 ) 

328 

329 

330def test_step_count_bout_merge_fragmentation(): 

331 """With merge_gap_s > 0, a walk-pause-walk sequence should produce one bout.""" 

332 freq = 1.6 

333 fs = FS 

334 walk1 = make_sine_accel(freq, 10.0, amplitude=0.5) 

335 pause = np.zeros(int(2.0 * fs)) 

336 walk2 = make_sine_accel(freq, 10.0, amplitude=0.5) 

337 sig = np.concatenate([walk1, pause, walk2]) 

338 t = np.arange(len(sig)) / fs 

339 z = 1.0 + sig 

340 accel = AccelerometerData( 

341 timestamps=t, x=np.zeros_like(sig), 

342 y=np.zeros_like(sig), z=z, fs=int(fs) 

343 ) 

344 

345 merged_result = StepCount(settings=StepCountSettings(bout_merge_gap_s=3.0)).run(accel) 

346 no_merge_result = StepCount(settings=StepCountSettings(bout_merge_gap_s=0.0)).run(accel) 

347 

348 assert len(merged_result.bouts) <= len(no_merge_result.bouts) 

349 

350 

351def test_step_count_aggregate_output_shape(): 

352 """aggregate() must produce a DataFrame with timestamps and values columns.""" 

353 result = run_step_count_on_sine(1.6, duration_s=120.0) 

354 result.aggregate(method="mean") 

355 assert hasattr(result, "biomarker_agg") 

356 assert "timestamps" in result.biomarker_agg.columns 

357 assert "values" in result.biomarker_agg.columns 

358 assert len(result.biomarker_agg) == 2