mirror of
https://github.com/sstent/AICycling_mcp.git
synced 2025-12-06 08:01:57 +00:00
491 lines
19 KiB
Python
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 |