Files
AICycling_mcp/cycling_metrics.py
2025-09-25 18:42:01 -07:00

491 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Cycling Metrics Calculator - Deterministic metrics for cycling workouts
"""
import math
import logging
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
@dataclass
class WorkoutMetrics:
"""Standardized workout metrics"""
# Basic metrics
duration_minutes: float
distance_km: float
avg_speed_kmh: float
max_speed_kmh: float
elevation_gain_m: float
# Heart rate metrics (if available)
avg_hr: Optional[float] = None
max_hr: Optional[float] = None
hr_zones: Optional[Dict[str, float]] = None # Time in each zone
# Power metrics (if available)
avg_power: Optional[float] = None
max_power: Optional[float] = None
normalized_power: Optional[float] = None
power_zones: Optional[Dict[str, float]] = None
# Calculated metrics
intensity_factor: Optional[float] = None
training_stress_score: Optional[float] = None
estimated_ftp: Optional[float] = None
variability_index: Optional[float] = None
# Single speed specific
estimated_gear_ratio: Optional[float] = None
estimated_chainring: Optional[int] = None
estimated_cog: Optional[int] = None
gear_usage_distribution: Optional[Dict[str, float]] = None
@dataclass
class TrainingLoad:
"""Training load metrics over time"""
acute_training_load: float # 7-day average
chronic_training_load: float # 42-day average
training_stress_balance: float # CTL - ATL
fitness: float # Chronic Training Load
fatigue: float # Acute Training Load
form: float # Training Stress Balance
class CyclingMetricsCalculator:
"""Calculate deterministic cycling metrics"""
def __init__(self, user_ftp: Optional[float] = None, user_max_hr: Optional[int] = None):
self.user_ftp = user_ftp
self.user_max_hr = user_max_hr
# Single speed gear options
self.chainrings = [46, 38] # teeth
self.cogs = [14, 15, 16, 17, 18, 19, 20] # teeth
self.wheel_circumference_m = 2.096 # 700x25c wheel circumference in meters
def calculate_workout_metrics(self, activity_data: Dict[str, Any]) -> WorkoutMetrics:
"""Calculate comprehensive metrics for a workout"""
# Extract basic data
duration_seconds = activity_data.get('duration', 0)
duration_minutes = duration_seconds / 60.0
distance_m = activity_data.get('distance', 0)
distance_km = distance_m / 1000.0
avg_speed_ms = activity_data.get('averageSpeed', 0)
avg_speed_kmh = avg_speed_ms * 3.6
max_speed_ms = activity_data.get('maxSpeed', 0)
max_speed_kmh = max_speed_ms * 3.6
elevation_gain = activity_data.get('elevationGain', 0)
# Heart rate data
avg_hr = activity_data.get('averageHR')
max_hr = activity_data.get('maxHR')
# Power data
avg_power = activity_data.get('avgPower')
max_power = activity_data.get('maxPower')
# Calculate derived metrics
metrics = WorkoutMetrics(
duration_minutes=duration_minutes,
distance_km=distance_km,
avg_speed_kmh=avg_speed_kmh,
max_speed_kmh=max_speed_kmh,
elevation_gain_m=elevation_gain,
avg_hr=avg_hr,
max_hr=max_hr,
avg_power=avg_power,
max_power=max_power
)
# Calculate advanced metrics if power data available
if avg_power and self.user_ftp:
metrics.intensity_factor = avg_power / self.user_ftp
metrics.training_stress_score = self._calculate_tss(duration_minutes, avg_power, self.user_ftp)
if max_power and avg_power:
metrics.variability_index = max_power / avg_power
# Estimate FTP if no power meter but have HR data
if not self.user_ftp and avg_hr and max_hr:
metrics.estimated_ftp = self._estimate_ftp_from_hr(avg_hr, max_hr, duration_minutes, distance_km, elevation_gain)
# Calculate gear ratios for single speed
if avg_speed_kmh > 0:
gear_analysis = self._analyze_single_speed_gears(avg_speed_kmh, duration_minutes, elevation_gain)
metrics.estimated_gear_ratio = gear_analysis['estimated_ratio']
metrics.estimated_chainring = gear_analysis['estimated_chainring']
metrics.estimated_cog = gear_analysis['estimated_cog']
metrics.gear_usage_distribution = gear_analysis['gear_distribution']
return metrics
def _calculate_tss(self, duration_minutes: float, avg_power: float, ftp: float) -> float:
"""Calculate Training Stress Score"""
if_factor = avg_power / ftp
tss = (duration_minutes * avg_power * if_factor) / (ftp * 60) * 100
return round(tss, 1)
def _estimate_ftp_from_hr(self, avg_hr: float, max_hr: float, duration_minutes: float,
distance_km: float, elevation_gain: float) -> float:
"""Estimate FTP from heart rate and performance data"""
# Basic estimation using heart rate zones and performance
# This is a simplified model - real FTP estimation requires more sophisticated analysis
# Calculate relative intensity from HR
if self.user_max_hr:
hr_intensity = avg_hr / self.user_max_hr
else:
# Estimate max HR using age formula (less accurate)
estimated_max_hr = 220 - 30 # Assuming 30 years old, should be configurable
hr_intensity = avg_hr / estimated_max_hr
# Calculate speed-based power estimate
# This is very rough and assumes flat terrain
avg_speed_ms = (distance_km * 1000) / (duration_minutes * 60)
# Rough power estimation based on speed (watts = speed^3 * factor)
# Adjusted for elevation gain
elevation_factor = 1 + (elevation_gain / distance_km / 1000) * 0.1
estimated_power = (avg_speed_ms ** 2.5) * 3.5 * elevation_factor
# Estimate FTP as power at ~75% max HR
ftp_ratio = 0.75 / hr_intensity if hr_intensity > 0.75 else 1.0
estimated_ftp = estimated_power * ftp_ratio
return round(estimated_ftp, 0)
def _analyze_single_speed_gears(self, avg_speed_kmh: float, duration_minutes: float,
elevation_gain: float) -> Dict[str, Any]:
"""Analyze single speed gear usage"""
# Calculate average cadence assumption (80-90 RPM is typical)
assumed_cadence = 85 # RPM
# Calculate required gear ratio for average speed
speed_ms = avg_speed_kmh / 3.6
distance_per_pedal_revolution = speed_ms * 60 / assumed_cadence # meters per revolution
required_gear_ratio = distance_per_pedal_revolution / self.wheel_circumference_m
# Find best matching gear combinations
gear_options = []
for chainring in self.chainrings:
for cog in self.cogs:
ratio = chainring / cog
ratio_error = abs(ratio - required_gear_ratio) / required_gear_ratio
gear_options.append({
'chainring': chainring,
'cog': cog,
'ratio': ratio,
'error': ratio_error
})
# Sort by best match
gear_options.sort(key=lambda x: x['error'])
best_gear = gear_options[0]
# Estimate gear usage distribution based on terrain
gear_distribution = self._estimate_gear_distribution(elevation_gain, duration_minutes, gear_options)
return {
'estimated_ratio': best_gear['ratio'],
'estimated_chainring': best_gear['chainring'],
'estimated_cog': best_gear['cog'],
'gear_distribution': gear_distribution,
'all_options': gear_options[:3] # Top 3 matches
}
def _estimate_gear_distribution(self, elevation_gain: float, duration_minutes: float,
gear_options: List[Dict]) -> Dict[str, float]:
"""Estimate how much time was spent in each gear"""
# Simplified model based on elevation profile
climbing_factor = elevation_gain / (duration_minutes * 10) # rough climbing intensity
distribution = {}
for gear in gear_options[:4]: # Top 4 gears
gear_name = f"{gear['chainring']}x{gear['cog']}"
if climbing_factor > 2.0: # Lots of climbing
# Favor easier gears (lower ratios)
weight = 1.0 / gear['ratio']
elif climbing_factor < 0.5: # Mostly flat
# Favor harder gears (higher ratios)
weight = gear['ratio']
else:
# Mixed terrain
weight = 1.0
distribution[gear_name] = weight
# Normalize to percentages
total_weight = sum(distribution.values())
if total_weight > 0:
distribution = {k: round(v / total_weight * 100, 1) for k, v in distribution.items()}
return distribution
def calculate_training_load(self, workout_history: List[Dict[str, Any]],
current_date: datetime = None) -> TrainingLoad:
"""Calculate training load metrics"""
if not current_date:
current_date = datetime.now()
# Calculate TSS for each workout
tss_by_date = {}
for workout in workout_history:
workout_date = datetime.fromisoformat(workout.get('startTimeGmt', '').replace('Z', '+00:00'))
metrics = self.calculate_workout_metrics(workout)
tss = metrics.training_stress_score or self._estimate_tss_without_power(metrics)
tss_by_date[workout_date.date()] = tss
# Calculate Acute Training Load (7-day average)
atl_days = 7
atl_total = 0
atl_count = 0
for i in range(atl_days):
date = (current_date - timedelta(days=i)).date()
if date in tss_by_date:
atl_total += tss_by_date[date]
atl_count += 1
atl = atl_total / atl_days if atl_count > 0 else 0
# Calculate Chronic Training Load (42-day average)
ctl_days = 42
ctl_total = 0
ctl_count = 0
for i in range(ctl_days):
date = (current_date - timedelta(days=i)).date()
if date in tss_by_date:
ctl_total += tss_by_date[date]
ctl_count += 1
ctl = ctl_total / ctl_days if ctl_count > 0 else 0
# Training Stress Balance
tsb = ctl - atl
return TrainingLoad(
acute_training_load=round(atl, 1),
chronic_training_load=round(ctl, 1),
training_stress_balance=round(tsb, 1),
fitness=round(ctl, 1),
fatigue=round(atl, 1),
form=round(tsb, 1)
)
def _estimate_tss_without_power(self, metrics: WorkoutMetrics) -> float:
"""Estimate TSS without power data using HR and duration"""
if metrics.avg_hr and self.user_max_hr:
# Use TRIMP method as TSS proxy
hr_ratio = metrics.avg_hr / self.user_max_hr
duration_hours = metrics.duration_minutes / 60
# Simplified TSS estimation
estimated_tss = duration_hours * 60 * (hr_ratio ** 1.92)
return round(estimated_tss, 1)
else:
# Very rough estimation based on duration and intensity
duration_hours = metrics.duration_minutes / 60
speed_factor = min(metrics.avg_speed_kmh / 25, 2.0) # Cap at 2x for high speeds
elevation_factor = 1 + (metrics.elevation_gain_m / (metrics.distance_km * 1000) * 0.1)
estimated_tss = duration_hours * 40 * speed_factor * elevation_factor
return round(estimated_tss, 1)
def get_performance_trends(self, workout_history: List[Dict[str, Any]],
days: int = 30) -> Dict[str, Any]:
"""Calculate performance trends over time"""
cutoff_date = datetime.now() - timedelta(days=days)
recent_workouts = []
for workout in workout_history:
workout_date = datetime.fromisoformat(workout.get('startTimeGmt', '').replace('Z', '+00:00'))
if workout_date >= cutoff_date:
recent_workouts.append(workout)
if not recent_workouts:
return {"error": "No recent workouts found"}
# Calculate metrics for each workout
metrics_list = [self.calculate_workout_metrics(w) for w in recent_workouts]
# Calculate trends
avg_speed_trend = [m.avg_speed_kmh for m in metrics_list]
avg_hr_trend = [m.avg_hr for m in metrics_list if m.avg_hr]
avg_power_trend = [m.avg_power for m in metrics_list if m.avg_power]
return {
"period_days": days,
"total_workouts": len(recent_workouts),
"avg_speed": {
"current": round(sum(avg_speed_trend) / len(avg_speed_trend), 1),
"max": round(max(avg_speed_trend), 1),
"min": round(min(avg_speed_trend), 1),
"trend": "improving" if len(avg_speed_trend) > 1 and avg_speed_trend[-1] > avg_speed_trend[0] else "stable"
},
"avg_heart_rate": {
"current": round(sum(avg_hr_trend) / len(avg_hr_trend), 1) if avg_hr_trend else None,
"trend": "improving" if len(avg_hr_trend) > 1 and avg_hr_trend[-1] < avg_hr_trend[0] else "stable"
} if avg_hr_trend else None,
"power_data_available": len(avg_power_trend) > 0,
"estimated_fitness_change": self._calculate_fitness_change(metrics_list)
}
def _calculate_fitness_change(self, metrics_list: List[WorkoutMetrics]) -> str:
"""Calculate estimated fitness change"""
if len(metrics_list) < 3:
return "insufficient_data"
# Look at speed and HR efficiency
recent_metrics = metrics_list[-3:] # Last 3 workouts
older_metrics = metrics_list[:3] if len(metrics_list) >= 6 else metrics_list[:-3]
if not older_metrics:
return "insufficient_data"
recent_speed_avg = sum(m.avg_speed_kmh for m in recent_metrics) / len(recent_metrics)
older_speed_avg = sum(m.avg_speed_kmh for m in older_metrics) / len(older_metrics)
speed_improvement = (recent_speed_avg - older_speed_avg) / older_speed_avg * 100
if speed_improvement > 5:
return "improving"
elif speed_improvement < -5:
return "declining"
else:
return "stable"
# Deterministic analysis helper
def generate_standardized_assessment(metrics: WorkoutMetrics,
training_load: TrainingLoad = None) -> Dict[str, Any]:
"""Generate standardized, deterministic workout assessment"""
assessment = {
"workout_classification": classify_workout(metrics),
"intensity_rating": rate_intensity(metrics),
"efficiency_score": calculate_efficiency_score(metrics),
"recovery_recommendation": recommend_recovery(metrics, training_load),
"key_metrics_summary": summarize_key_metrics(metrics)
}
return assessment
def classify_workout(metrics: WorkoutMetrics) -> str:
"""Classify workout type based on metrics"""
duration = metrics.duration_minutes
avg_speed = metrics.avg_speed_kmh
elevation_gain = metrics.elevation_gain_m / metrics.distance_km if metrics.distance_km > 0 else 0
if duration < 30:
return "short_intensity"
elif duration > 180:
return "long_endurance"
elif elevation_gain > 10: # >10m elevation per km
return "climbing_focused"
elif avg_speed > 35:
return "high_speed"
elif avg_speed < 20:
return "recovery_easy"
else:
return "moderate_endurance"
def rate_intensity(metrics: WorkoutMetrics) -> int:
"""Rate workout intensity 1-10"""
factors = []
# Speed factor
if metrics.avg_speed_kmh > 40:
factors.append(9)
elif metrics.avg_speed_kmh > 35:
factors.append(7)
elif metrics.avg_speed_kmh > 25:
factors.append(5)
else:
factors.append(3)
# Duration factor
duration_intensity = min(metrics.duration_minutes / 60 * 2, 6)
factors.append(duration_intensity)
# Elevation factor
if metrics.distance_km > 0:
elevation_per_km = metrics.elevation_gain_m / metrics.distance_km
if elevation_per_km > 15:
factors.append(8)
elif elevation_per_km > 10:
factors.append(6)
elif elevation_per_km > 5:
factors.append(4)
else:
factors.append(2)
# HR factor (if available)
if metrics.avg_hr and metrics.max_hr:
hr_ratio = metrics.avg_hr / metrics.max_hr
if hr_ratio > 0.85:
factors.append(9)
elif hr_ratio > 0.75:
factors.append(7)
elif hr_ratio > 0.65:
factors.append(5)
else:
factors.append(3)
return min(int(sum(factors) / len(factors)), 10)
def calculate_efficiency_score(metrics: WorkoutMetrics) -> float:
"""Calculate efficiency score (higher = more efficient)"""
# Speed per heart rate beat (if HR available)
if metrics.avg_hr and metrics.avg_hr > 0:
speed_hr_efficiency = metrics.avg_speed_kmh / metrics.avg_hr * 100
return round(speed_hr_efficiency, 2)
else:
# Fallback: speed per elevation gain
if metrics.elevation_gain_m > 0:
speed_elevation_efficiency = metrics.avg_speed_kmh / (metrics.elevation_gain_m / 100)
return round(speed_elevation_efficiency, 2)
else:
return metrics.avg_speed_kmh # Just speed as efficiency
def recommend_recovery(metrics: WorkoutMetrics, training_load: TrainingLoad = None) -> str:
"""Recommend recovery based on workout intensity"""
intensity = rate_intensity(metrics)
if training_load and training_load.training_stress_balance < -10:
return "high_fatigue_rest_recommended"
elif intensity >= 8:
return "24_48_hours_easy"
elif intensity >= 6:
return "24_hours_easy"
elif intensity >= 4:
return "active_recovery_optional"
else:
return "ready_for_next_workout"
def summarize_key_metrics(metrics: WorkoutMetrics) -> Dict[str, str]:
"""Summarize key metrics in human readable format"""
summary = {
"duration": f"{metrics.duration_minutes:.0f} minutes",
"distance": f"{metrics.distance_km:.1f} km",
"avg_speed": f"{metrics.avg_speed_kmh:.1f} km/h",
"elevation_gain": f"{metrics.elevation_gain_m:.0f} m"
}
if metrics.avg_hr:
summary["avg_heart_rate"] = f"{metrics.avg_hr:.0f} bpm"
if metrics.avg_power:
summary["avg_power"] = f"{metrics.avg_power:.0f} W"
if metrics.estimated_ftp:
summary["estimated_ftp"] = f"{metrics.estimated_ftp:.0f} W"
if metrics.estimated_gear_ratio:
summary["estimated_gear"] = f"{metrics.estimated_chainring}x{metrics.estimated_cog} ({metrics.estimated_gear_ratio:.1f} ratio)"
return summary