This commit is contained in:
2025-09-01 12:50:37 -07:00
parent e194ff2ffb
commit 9d7e855713
2 changed files with 75 additions and 12 deletions

View File

@@ -45,7 +45,7 @@ except ImportError as e:
class GarminWorkoutAnalyzer: class GarminWorkoutAnalyzer:
"""Main class for analyzing Garmin workout data.""" """Main class for analyzing Garmin workout data."""
def __init__(self, is_indoor=False): def __init__(self):
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -63,13 +63,16 @@ class GarminWorkoutAnalyzer:
38: [14, 16, 18, 20], 38: [14, 16, 18, 20],
46: [16] 46: [16]
} }
self.is_indoor = is_indoor self.is_indoor = False
self.selected_chainring = None self.selected_chainring = None
self.power_data_available = False self.power_data_available = False
self.CHAINRING_TEETH = 38 # Default, will be updated self.CHAINRING_TEETH = 38 # Default, will be updated
self.BIKE_WEIGHT_LBS = 22 self.BIKE_WEIGHT_LBS = 22
self.BIKE_WEIGHT_KG = self.BIKE_WEIGHT_LBS * 0.453592 self.BIKE_WEIGHT_KG = self.BIKE_WEIGHT_LBS * 0.453592
# Indoor activity keywords
self.INDOOR_KEYWORDS = ['indoor_cycling', 'indoor cycling', 'indoor bike', 'trainer', 'zwift', 'virtual']
# HR Zones (based on LTHR 170 bpm) # HR Zones (based on LTHR 170 bpm)
self.HR_ZONES = { self.HR_ZONES = {
'Z1': (0, 136), 'Z1': (0, 136),
@@ -103,6 +106,20 @@ class GarminWorkoutAnalyzer:
self.TIRE_CIRCUMFERENCE_MM = math.pi * (self.WHEEL_DIAMETER_MM + 2 * self.TIRE_WIDTH_MM) self.TIRE_CIRCUMFERENCE_MM = math.pi * (self.WHEEL_DIAMETER_MM + 2 * self.TIRE_WIDTH_MM)
self.TIRE_CIRCUMFERENCE_M = self.TIRE_CIRCUMFERENCE_MM / 1000 # ~2.23m self.TIRE_CIRCUMFERENCE_M = self.TIRE_CIRCUMFERENCE_MM / 1000 # ~2.23m
def _detect_indoor_activity(self, activity):
"""Detect if activity is indoor based on type and name."""
activity_type = activity.get('activityType', {}).get('typeKey', '').lower()
activity_name = activity.get('activityName', '').lower()
self.is_indoor = any(
keyword in activity_type or keyword in activity_name
for keyword in self.INDOOR_KEYWORDS
)
if self.is_indoor:
print(f"Detected indoor activity: {activity_name} (Type: {activity_type})")
else:
print(f"Detected outdoor activity: {activity_name} (Type: {activity_type})")
def connect_to_garmin(self) -> bool: def connect_to_garmin(self) -> bool:
"""Connect to Garmin Connect using credentials from .env file.""" """Connect to Garmin Connect using credentials from .env file."""
@@ -127,6 +144,11 @@ class GarminWorkoutAnalyzer:
try: try:
print(f"Downloading workout ID: {activity_id}") print(f"Downloading workout ID: {activity_id}")
self.last_activity_id = activity_id self.last_activity_id = activity_id
# Get activity details to detect indoor type
activity = self.garmin_client.get_activity(activity_id)
self._detect_indoor_activity(activity)
return self._download_workout(activity_id) return self._download_workout(activity_id)
except Exception as e: except Exception as e:
print(f"Error downloading workout {activity_id}: {e}") print(f"Error downloading workout {activity_id}: {e}")
@@ -157,7 +179,7 @@ class GarminWorkoutAnalyzer:
type_name = str(activity_type.get('typeId', '')).lower() type_name = str(activity_type.get('typeId', '')).lower()
activity_name = activity.get('activityName', '').lower() activity_name = activity.get('activityName', '').lower()
if any(keyword in type_key or keyword in type_name or keyword in activity_name if any(keyword in type_key or keyword in type_name or keyword in activity_name
for keyword in cycling_keywords): for keyword in cycling_keywords):
cycling_activity = activity cycling_activity = activity
print(f"Selected cycling activity: {activity['activityName']} (Type: {type_key})") print(f"Selected cycling activity: {activity['activityName']} (Type: {type_key})")
@@ -184,6 +206,10 @@ class GarminWorkoutAnalyzer:
activity_id = cycling_activity['activityId'] activity_id = cycling_activity['activityId']
self.last_activity_id = activity_id self.last_activity_id = activity_id
print(f"Found cycling activity: {cycling_activity['activityName']} ({activity_id})") print(f"Found cycling activity: {cycling_activity['activityName']} ({activity_id})")
# Detect indoor activity type
self._detect_indoor_activity(cycling_activity)
return self._download_workout(activity_id) return self._download_workout(activity_id)
except Exception as e: except Exception as e:
@@ -321,7 +347,7 @@ class GarminWorkoutAnalyzer:
type_name = str(activity_type.get('typeId', '')).lower() type_name = str(activity_type.get('typeId', '')).lower()
activity_name = activity.get('activityName', '').lower() activity_name = activity.get('activityName', '').lower()
if any(keyword in type_key or keyword in type_name or keyword in activity_name if any(keyword in type_key or keyword in type_name or keyword in activity_name
for keyword in cycling_keywords): for keyword in cycling_keywords):
cycling_activities.append(activity) cycling_activities.append(activity)
@@ -343,6 +369,9 @@ class GarminWorkoutAnalyzer:
print(f" Already exists: {existing_files[0]}") print(f" Already exists: {existing_files[0]}")
continue continue
# Detect indoor activity type for each activity
self._detect_indoor_activity(activity)
self._download_workout(activity_id) self._download_workout(activity_id)
print("\nAll cycling activities downloaded") print("\nAll cycling activities downloaded")
@@ -755,10 +784,10 @@ class GarminWorkoutAnalyzer:
if 'timestamp' in df.columns: if 'timestamp' in df.columns:
df = df.dropna(subset=['timestamp']) df = df.dropna(subset=['timestamp'])
# Fill other missing values with defaults # Fill other missing values with defaults using proper assignment
for col in ['heart_rate', 'cadence', 'speed', 'distance', 'altitude', 'temperature']: for col in ['heart_rate', 'cadence', 'speed', 'distance', 'altitude', 'temperature']:
if col in df.columns: if col in df.columns:
df[col].fillna(0, inplace=True) df[col] = df[col].fillna(0)
return self._process_workout_data(df, session_data, cog_size) return self._process_workout_data(df, session_data, cog_size)
@@ -1104,9 +1133,9 @@ class GarminWorkoutAnalyzer:
# For indoor workouts, gradient calculation is simulated # For indoor workouts, gradient calculation is simulated
df['gradient'] = 0 df['gradient'] = 0
# Fixed gear configuration for indoor bike # Fixed gear configuration for indoor bike (38t chainring + 14t cog)
self.selected_chainring = 38 self.selected_chainring = 38
cog_size = 16 cog_size = 14 # Set explicitly for indoor
self.CHAINRING_TEETH = self.selected_chainring self.CHAINRING_TEETH = self.selected_chainring
# Use physics model for indoor power estimation # Use physics model for indoor power estimation
@@ -1658,6 +1687,7 @@ class GarminWorkoutAnalyzer:
import argparse import argparse
def main(): def main():
"""Main function to run the workout analyzer.""" """Main function to run the workout analyzer."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -1674,12 +1704,12 @@ def main():
formatter_class=argparse.RawTextHelpFormatter formatter_class=argparse.RawTextHelpFormatter
) )
parser.add_argument('-w', '--workout-id', type=int, help='Analyze specific workout by ID') parser.add_argument('-w', '--workout-id', type=int, help='Analyze specific workout by ID')
parser.add_argument('--indoor', action='store_true', help='Process as indoor cycling workout')
parser.add_argument('--download-all', action='store_true', help='Download all cycling activities (no analysis)') parser.add_argument('--download-all', action='store_true', help='Download all cycling activities (no analysis)')
parser.add_argument('--reanalyze-all', action='store_true', help='Re-analyze all downloaded activities') parser.add_argument('--reanalyze-all', action='store_true', help='Re-analyze all downloaded activities')
# Removed deprecated --indoor flag
args = parser.parse_args() args = parser.parse_args()
analyzer = GarminWorkoutAnalyzer(is_indoor=args.indoor) analyzer = GarminWorkoutAnalyzer()
# Step 1: Connect to Garmin # Step 1: Connect to Garmin
if not analyzer.connect_to_garmin(): if not analyzer.connect_to_garmin():
@@ -1702,8 +1732,9 @@ def main():
print(f"Failed to download workout {activity_id}") print(f"Failed to download workout {activity_id}")
return return
# Auto-detect indoor/outdoor and get cog size
estimated_cog = analyzer.estimate_cog_from_cadence(fit_file_path) estimated_cog = analyzer.estimate_cog_from_cadence(fit_file_path)
confirmed_cog = analyzer.get_user_cog_confirmation(estimated_cog) confirmed_cog = 14 if analyzer.is_indoor else analyzer.get_user_cog_confirmation(estimated_cog)
print("Analyzing workout with enhanced power calculations...") print("Analyzing workout with enhanced power calculations...")
analysis_data = analyzer.analyze_fit_file(fit_file_path, confirmed_cog) analysis_data = analyzer.analyze_fit_file(fit_file_path, confirmed_cog)
@@ -1725,8 +1756,9 @@ def main():
print("Failed to download latest workout") print("Failed to download latest workout")
return return
# Auto-detect indoor/outdoor and get cog size
estimated_cog = analyzer.estimate_cog_from_cadence(fit_file_path) estimated_cog = analyzer.estimate_cog_from_cadence(fit_file_path)
confirmed_cog = analyzer.get_user_cog_confirmation(estimated_cog) confirmed_cog = 14 if analyzer.is_indoor else analyzer.get_user_cog_confirmation(estimated_cog)
print("Analyzing with enhanced power model...") print("Analyzing with enhanced power model...")
analysis_data = analyzer.analyze_fit_file(fit_file_path, confirmed_cog) analysis_data = analyzer.analyze_fit_file(fit_file_path, confirmed_cog)

31
todo.md Normal file
View File

@@ -0,0 +1,31 @@
# 46-Tooth Chainring Detection Implementation
## Analysis Phase
- [x] Review current code structure
- [x] Identify chainring usage locations
- [x] Plan implementation approach
## Core Implementation
- [ ] Modify bike specifications to support multiple chainrings
- [ ] Update gear estimation algorithm for chainring detection
- [ ] Enhance cog estimation to determine chainring usage
- [ ] Update power calculations to use detected chainring
- [ ] Modify reporting to show detected chainring
## Missing Methods Implementation
- [ ] Implement download_all_workouts method
- [ ] Implement reanalyze_all_workouts method
- [ ] Implement estimate_cog_from_cadence method
- [ ] Implement get_user_cog_confirmation method
- [ ] Fix chart generation issue
## Testing & Validation
- [ ] Test with 38T chainring data
- [ ] Test with 46T chainring data
- [ ] Verify power calculations accuracy
- [ ] Validate report generation
## Code Quality
- [ ] Add data validation and error handling
- [ ] Update documentation
- [ ] Add logging for chainring detection