Coverage for physiodsp / activity / activity_score.py: 89%

146 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-26 21:14 +0000

1from typing import Literal 

2 

3import numpy as np 

4from pandas import DataFrame 

5from pydantic import BaseModel, Field, PositiveInt, PositiveFloat 

6 

7from physiodsp.base import BaseAlgorithm 

8 

9 

10class ActivityScoreSettings(BaseModel): 

11 """Configuration settings for Activity Score algorithm""" 

12 

13 # Baseline window (number of days to use for personalization) 

14 baseline_window_days: PositiveInt = Field(default=30, description="Number of days to use for computing baselines") 

15 

16 # Factor weights (must sum to 1.0) 

17 step_weight: PositiveFloat = Field(default=0.25, description="Weight for step count factor") 

18 sleep_weight: PositiveFloat = Field(default=0.35, description="Weight for sleep factor") 

19 training_weight: PositiveFloat = Field(default=0.25, description="Weight for training time factor") 

20 resting_weight: PositiveFloat = Field(default=0.15, description="Weight for resting time factor") 

21 

22 # Step targets (personalized) 

23 baseline_daily_steps: PositiveInt = Field(default=8000, description="User's baseline daily steps") 

24 step_ceiling: PositiveInt = Field(default=15000, description="Maximum steps for optimal score") 

25 

26 # Sleep targets (hours) 

27 min_sleep_hours: PositiveFloat = Field(default=6.0, description="Minimum healthy sleep duration") 

28 optimal_sleep_hours: PositiveFloat = Field(default=8.0, description="Optimal sleep duration") 

29 max_sleep_hours: PositiveFloat = Field(default=10.0, description="Maximum sleep before penalty") 

30 

31 # Training targets (minutes) 

32 min_training_minutes: PositiveInt = Field(default=0, description="Minimum training minutes per day") 

33 optimal_training_minutes: PositiveInt = Field(default=60, description="Optimal training minutes per day") 

34 max_training_minutes: PositiveInt = Field(default=120, description="Maximum training before overtraining penalty") 

35 

36 # Resting targets (minutes) 

37 min_resting_minutes: PositiveInt = Field(default=480, description="Minimum resting minutes (8 hours)") 

38 optimal_resting_minutes: PositiveInt = Field(default=540, description="Optimal resting minutes (9 hours)") 

39 max_resting_minutes: PositiveInt = Field(default=720, description="Maximum resting minutes (12 hours)") 

40 

41 # Scoring method 

42 scoring_method: Literal["gaussian", "sigmoid", "linear"] = Field( 

43 default="gaussian", 

44 description="Method to map normalized values to scores" 

45 ) 

46 

47 

48class ActivityScore(BaseAlgorithm): 

49 """ 

50 Activity Score Algorithm - Personalized Daily Activity Assessment 

51 

52 Combines multiple health metrics (steps, sleep, training, resting) into a single 

53 0-100 score tailored to individual user baselines and targets. 

54 

55 Based on principles from Oura Ring and WHOOP band scoring algorithms. 

56 """ 

57 

58 _algorithm_name = "ActivityScore" 

59 _version = "v0.1.0" 

60 

61 def __init__(self, settings: ActivityScoreSettings = ActivityScoreSettings()) -> None: 

62 self.settings = settings 

63 self._validate_weights() 

64 self.user_stats = None 

65 self.daily_scores = None 

66 self.baseline_stats = None 

67 return None 

68 

69 def _validate_weights(self) -> None: 

70 """Validate that weights sum to 1.0""" 

71 total_weight = ( 

72 self.settings.step_weight + 

73 self.settings.sleep_weight + 

74 self.settings.training_weight + 

75 self.settings.resting_weight 

76 ) 

77 if not np.isclose(total_weight, 1.0, atol=0.01): 

78 raise ValueError(f"Weights must sum to 1.0, got {total_weight}") 

79 

80 def _compute_baseline_stats(self, baseline_data: DataFrame) -> dict: 

81 """ 

82 Compute personalized baseline statistics from historical data. 

83 

84 Args: 

85 baseline_data: DataFrame with previous N-1 days 

86 

87 Returns: 

88 Dictionary with computed statistics 

89 """ 

90 return { 

91 'steps_median': baseline_data['steps'].median(), 

92 'steps_std': baseline_data['steps'].std(), 

93 'steps_p75': baseline_data['steps'].quantile(0.75), 

94 'sleep_median': baseline_data['sleep_hours'].median(), 

95 'sleep_std': baseline_data['sleep_hours'].std(), 

96 'training_median': baseline_data['training_minutes'].median(), 

97 'training_std': baseline_data['training_minutes'].std(), 

98 'resting_median': baseline_data['resting_minutes'].median(), 

99 'resting_std': baseline_data['resting_minutes'].std() 

100 } 

101 

102 def _score_steps(self, daily_steps: int, baseline_stats: dict = None) -> float: 

103 """ 

104 Score daily step count (0-100). 

105 

106 Gaussian distribution centered at user's median baseline. 

107 """ 

108 if baseline_stats is None: 

109 baseline = self.settings.baseline_daily_steps 

110 ceiling = self.settings.step_ceiling 

111 else: 

112 # Use personalized baseline from historical data 

113 baseline = baseline_stats['steps_median'] 

114 # Ceiling is 75th percentile or 1.5x median, whichever is higher 

115 ceiling = max(baseline * 1.5, baseline_stats['steps_p75']) 

116 

117 if self.settings.scoring_method == "gaussian": 

118 if baseline == 0: 

119 return 0.0 

120 normalized = daily_steps / baseline 

121 peak_normalized = ceiling / baseline 

122 std_dev = (peak_normalized - 1.0) / 2.0 

123 score = 100 * np.exp(-((normalized - 1.0) ** 2) / (2 * std_dev ** 2)) 

124 score = np.clip(score, 0, 100) 

125 else: 

126 if ceiling == 0: 

127 return 0.0 

128 score = min(100, (daily_steps / ceiling) * 100) 

129 

130 return float(score) 

131 

132 def _score_sleep(self, sleep_hours: float, baseline_stats: dict = None) -> float: 

133 """ 

134 Score sleep duration (0-100). 

135 

136 Centered at user's median sleep, with personalized thresholds. 

137 """ 

138 if baseline_stats is None: 

139 optimal = self.settings.optimal_sleep_hours 

140 min_sleep = self.settings.min_sleep_hours 

141 max_sleep = self.settings.max_sleep_hours 

142 else: 

143 median_sleep = baseline_stats['sleep_median'] 

144 std_sleep = baseline_stats['sleep_std'] or 0.5 

145 optimal = median_sleep 

146 min_sleep = max(4.0, median_sleep - std_sleep * 1.5) 

147 max_sleep = min(11.0, median_sleep + std_sleep * 1.5) 

148 

149 if sleep_hours < min_sleep: 

150 deficit = (min_sleep - sleep_hours) / min_sleep 

151 score = max(0, 50 - deficit * 50) 

152 elif sleep_hours <= optimal: 

153 score = ((sleep_hours - min_sleep) / (optimal - min_sleep)) * 100 

154 elif sleep_hours <= max_sleep: 

155 score = 100 - ((sleep_hours - optimal) / (max_sleep - optimal)) * 30 

156 else: 

157 excess = sleep_hours - max_sleep 

158 score = max(0, 70 - excess * 10) 

159 

160 return float(np.clip(score, 0, 100)) 

161 

162 def _score_training(self, training_minutes: int, baseline_stats: dict = None) -> float: 

163 """ 

164 Score training time (0-100). 

165 

166 Rewards consistent moderate training relative to user's baseline. 

167 """ 

168 if baseline_stats is None: 

169 optimal = self.settings.optimal_training_minutes 

170 max_train = self.settings.max_training_minutes 

171 else: 

172 median_training = baseline_stats['training_median'] 

173 std_training = baseline_stats['training_std'] or 15 

174 optimal = max(30, median_training) 

175 max_train = optimal + std_training * 2 

176 

177 if training_minutes < 5: 

178 score = 50 

179 elif training_minutes <= optimal: 

180 score = 50 + (training_minutes / optimal) * 50 

181 elif training_minutes <= max_train: 

182 excess_ratio = (training_minutes - optimal) / (max_train - optimal) 

183 score = 100 + (excess_ratio * 10) - 10 

184 else: 

185 excess = training_minutes - max_train 

186 score = max(0, 100 - excess * 0.5) 

187 

188 return float(np.clip(score, 0, 100)) 

189 

190 def _score_resting(self, resting_minutes: int, baseline_stats: dict = None) -> float: 

191 """ 

192 Score resting/recovery time (0-100). 

193 

194 Rewards recovery relative to user's baseline. 

195 """ 

196 resting_hours = resting_minutes / 60 

197 

198 if baseline_stats is None: 

199 optimal_hours = self.settings.optimal_resting_minutes / 60 

200 min_hours = self.settings.min_resting_minutes / 60 

201 max_hours = self.settings.max_resting_minutes / 60 

202 else: 

203 median_resting = baseline_stats['resting_median'] / 60 

204 std_resting = baseline_stats['resting_std'] / 60 or 0.5 

205 optimal_hours = median_resting 

206 min_hours = max(6.0, median_resting - std_resting * 1.5) 

207 max_hours = min(12.0, median_resting + std_resting * 1.5) 

208 

209 if resting_hours < min_hours: 

210 deficit = (min_hours - resting_hours) 

211 score = max(0, 50 - deficit * 15) 

212 elif resting_hours <= optimal_hours: 

213 score = ((resting_hours - min_hours) / (optimal_hours - min_hours)) * 100 

214 elif resting_hours <= max_hours: 

215 excess_hours = resting_hours - optimal_hours 

216 max_excess = max_hours - optimal_hours 

217 score = 100 - (excess_hours / max_excess) * 25 

218 else: 

219 excess_hours = resting_hours - max_hours 

220 score = max(0, 75 - excess_hours * 10) 

221 

222 return float(np.clip(score, 0, 100)) 

223 

224 def run(self, daily_activity_data: DataFrame): 

225 """ 

226 Calculate Activity Score for the most recent day using baseline statistics. 

227 

228 Args: 

229 daily_activity_data: DataFrame with all available data, sorted by date (oldest to newest). 

230 Must contain columns: 

231 - 'date' or index as date 

232 - 'steps': Daily step count 

233 - 'sleep_hours': Total sleep duration in hours 

234 - 'training_minutes': Total training time in minutes 

235 - 'resting_minutes': Total resting/recovery time in minutes 

236 

237 Should have at least baseline_window_days of data. 

238 

239 Returns: 

240 self with biomarker containing score for the most recent day 

241 """ 

242 if not all(col in daily_activity_data.columns for col in ['steps', 'sleep_hours', 'training_minutes', 'resting_minutes']): 

243 raise ValueError("DataFrame must contain: steps, sleep_hours, training_minutes, resting_minutes") 

244 

245 if len(daily_activity_data) < 2: 

246 raise ValueError(f"Need at least 2 days of data, got {len(daily_activity_data)}") 

247 

248 # Ensure we have the right number of baseline days 

249 window_size = min(self.settings.baseline_window_days, len(daily_activity_data) - 1) 

250 

251 # Split into baseline (all but last) and current day (last) 

252 baseline_data = daily_activity_data.iloc[:-1].tail(window_size) 

253 current_day_data = daily_activity_data.iloc[-1] 

254 

255 # Compute personalized baseline statistics 

256 self.baseline_stats = self._compute_baseline_stats(baseline_data) 

257 

258 # Calculate individual scores using personalized baselines 

259 step_score = self._score_steps(current_day_data['steps'], self.baseline_stats) 

260 sleep_score = self._score_sleep(current_day_data['sleep_hours'], self.baseline_stats) 

261 training_score = self._score_training(current_day_data['training_minutes'], self.baseline_stats) 

262 resting_score = self._score_resting(current_day_data['resting_minutes'], self.baseline_stats) 

263 

264 # Weighted combination 

265 activity_score = ( 

266 (step_score * self.settings.step_weight) + 

267 (sleep_score * self.settings.sleep_weight) + 

268 (training_score * self.settings.training_weight) + 

269 (resting_score * self.settings.resting_weight) 

270 ) 

271 

272 # Create output DataFrame 

273 score_record = { 

274 'date': current_day_data.get('date', daily_activity_data.index[-1]) if 'date' in daily_activity_data.columns else daily_activity_data.index[-1], 

275 'activity_score': round(activity_score, 1), 

276 'step_score': round(step_score, 1), 

277 'sleep_score': round(sleep_score, 1), 

278 'training_score': round(training_score, 1), 

279 'resting_score': round(resting_score, 1), 

280 'steps': current_day_data['steps'], 

281 'sleep_hours': current_day_data['sleep_hours'], 

282 'training_minutes': current_day_data['training_minutes'], 

283 'resting_minutes': current_day_data['resting_minutes'], 

284 'baseline_days_used': window_size 

285 } 

286 

287 self.biomarker_agg = DataFrame([score_record]) 

288 self.daily_scores = self.biomarker_agg.copy() 

289 

290 return self 

291 

292 def get_activity_score_interpretation(self, activity_score: float) -> str: 

293 """ 

294 Return interpretation of Activity Score. 

295 

296 Args: 

297 activity_score: Activity score (0-100) 

298 

299 Returns: 

300 Interpretation string 

301 """ 

302 if activity_score >= 85: 

303 return "Excellent - Outstanding activity and recovery balance" 

304 elif activity_score >= 70: 

305 return "Good - Healthy activity levels with adequate recovery" 

306 elif activity_score >= 50: 

307 return "Fair - Room for improvement in activity or recovery" 

308 elif activity_score >= 30: 

309 return "Poor - Significant imbalance in activity or recovery" 

310 else: 

311 return "Critical - Urgent attention needed to activity and recovery"