This commit is contained in:
2025-10-04 16:17:25 -07:00
parent 478d1bb06c
commit 38b4529ecf
10 changed files with 275 additions and 28 deletions

View File

@@ -55,9 +55,11 @@ python main.py --garmin-connect --report --charts --summary
### Command Line Options ### Command Line Options
``` ```
usage: main.py [-h] [--config CONFIG] [--verbose] (--file FILE | --directory DIRECTORY | --garmin-connect) usage: main.py [-h] [--config CONFIG] [--verbose]
[--ftp FTP] [--max-hr MAX_HR] [--zones ZONES] [--output-dir OUTPUT_DIR] (--file FILE | --directory DIRECTORY | --garmin-connect | --workout-id WORKOUT_ID | --download-all | --reanalyze-all)
[--format {html,pdf,markdown}] [--charts] [--report] [--summary] [--ftp FTP] [--max-hr MAX_HR] [--zones ZONES] [--cog COG]
[--output-dir OUTPUT_DIR] [--format {html,pdf,markdown}]
[--charts] [--report] [--summary]
Analyze Garmin workout data from files or Garmin Connect Analyze Garmin workout data from files or Garmin Connect
@@ -66,13 +68,24 @@ options:
--config CONFIG, -c CONFIG --config CONFIG, -c CONFIG
Configuration file path Configuration file path
--verbose, -v Enable verbose logging --verbose, -v Enable verbose logging
Input options:
--file FILE, -f FILE Path to workout file (FIT, TCX, or GPX) --file FILE, -f FILE Path to workout file (FIT, TCX, or GPX)
--directory DIRECTORY, -d DIRECTORY --directory DIRECTORY, -d DIRECTORY
Directory containing workout files Directory containing workout files
--garmin-connect Download from Garmin Connect --garmin-connect Download from Garmin Connect
--workout-id WORKOUT_ID
Analyze specific workout by ID from Garmin Connect
--download-all Download all cycling activities from Garmin Connect (no analysis)
--reanalyze-all Re-analyze all downloaded activities and generate reports
Analysis options:
--ftp FTP Functional Threshold Power (W) --ftp FTP Functional Threshold Power (W)
--max-hr MAX_HR Maximum heart rate (bpm) --max-hr MAX_HR Maximum heart rate (bpm)
--zones ZONES Path to zones configuration file --zones ZONES Path to zones configuration file
--cog COG Cog size (teeth) for power calculations. Auto-detected if not provided
Output options:
--output-dir OUTPUT_DIR --output-dir OUTPUT_DIR
Output directory for reports and charts Output directory for reports and charts
--format {html,pdf,markdown} --format {html,pdf,markdown}
@@ -80,6 +93,23 @@ options:
--charts Generate charts --charts Generate charts
--report Generate comprehensive report --report Generate comprehensive report
--summary Generate summary report for multiple workouts --summary Generate summary report for multiple workouts
Examples:
Analyze latest workout from Garmin Connect: python main.py --garmin-connect
Analyze specific workout by ID: python main.py --workout-id 123456789
Download all cycling workouts: python main.py --download-all
Re-analyze all downloaded workouts: python main.py --reanalyze-all
Analyze local FIT file: python main.py --file path/to/workout.fit
Analyze directory of workouts: python main.py --directory data/
Configuration:
Set Garmin credentials in .env file: GARMIN_EMAIL and GARMIN_PASSWORD
Configure zones in config/config.yaml or use --zones flag
Override FTP with --ftp flag, max HR with --max-hr flag
Output:
Reports saved to output/ directory by default
Charts saved to output/charts/ when --charts is used
``` ```
## Configuration ## Configuration

View File

@@ -6,8 +6,8 @@ import pandas as pd
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
from datetime import timedelta from datetime import timedelta
from ..models.workout import WorkoutData, PowerData, HeartRateData, SpeedData, ElevationData from models.workout import WorkoutData, PowerData, HeartRateData, SpeedData, ElevationData
from ..models.zones import ZoneCalculator from models.zones import ZoneCalculator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -12,7 +12,7 @@ try:
except ImportError: except ImportError:
raise ImportError("garminconnect package required. Install with: pip install garminconnect") raise ImportError("garminconnect package required. Install with: pip install garminconnect")
from ..config.settings import GARMIN_EMAIL, GARMIN_PASSWORD, DATA_DIR from config.settings import GARMIN_EMAIL, GARMIN_PASSWORD, DATA_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -176,7 +176,7 @@ class GarminClient:
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
# Download original file # Download original file
file_data = self.client.download_original_activity(activity_id) file_data = self.client.download_activity(activity_id, dl_fmt='ZIP')
# Save to temporary file first # Save to temporary file first
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_file: with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_file:

38
main.py
View File

@@ -241,23 +241,37 @@ class GarminAnalyser:
logging.info(f"Downloading workouts from Garmin Connect (last {days} days)") logging.info(f"Downloading workouts from Garmin Connect (last {days} days)")
client = GarminClient( client = GarminClient(
username=settings.GARMIN_EMAIL, email=settings.GARMIN_EMAIL,
password=settings.GARMIN_PASSWORD password=settings.GARMIN_PASSWORD
) )
# Download workouts # Download workouts
workouts = client.get_workouts(days=days) workouts = client.get_all_cycling_workouts()
# Analyze each workout # Analyze each workout
results = [] results = []
for workout in workouts: for workout_summary in workouts:
try: try:
analysis = self.workout_analyzer.analyze_workout(workout) activity_id = workout_summary.get('activityId')
results.append({ if not activity_id:
'workout': workout, logging.warning("Skipping workout with no activity ID.")
'analysis': analysis, continue
'file_path': None
}) logging.info(f"Downloading workout file for activity ID: {activity_id}")
workout_file_path = client.download_activity_original(str(activity_id))
if workout_file_path and workout_file_path.exists():
workout = self.file_parser.parse_file(workout_file_path)
if workout:
analysis = self.workout_analyzer.analyze_workout(workout)
results.append({
'workout': workout,
'analysis': analysis,
'file_path': workout_file_path
})
else:
logging.error(f"Failed to download workout file for activity ID: {activity_id}")
except Exception as e: except Exception as e:
logging.error(f"Error analyzing workout: {e}") logging.error(f"Error analyzing workout: {e}")
@@ -269,10 +283,8 @@ class GarminAnalyser:
Returns: Returns:
List of downloaded workouts List of downloaded workouts
""" """
logging.info("Downloading all cycling activities from Garmin Connect")
client = GarminClient( client = GarminClient(
username=settings.GARMIN_EMAIL, email=settings.GARMIN_EMAIL,
password=settings.GARMIN_PASSWORD password=settings.GARMIN_PASSWORD
) )
@@ -346,7 +358,7 @@ class GarminAnalyser:
logging.info(f"Analyzing workout ID: {workout_id}") logging.info(f"Analyzing workout ID: {workout_id}")
client = GarminClient( client = GarminClient(
username=settings.GARMIN_EMAIL, email=settings.GARMIN_EMAIL,
password=settings.GARMIN_PASSWORD password=settings.GARMIN_PASSWORD
) )

View File

@@ -11,8 +11,8 @@ try:
except ImportError: except ImportError:
raise ImportError("fitparse package required. Install with: pip install fitparse") raise ImportError("fitparse package required. Install with: pip install fitparse")
from ..models.workout import WorkoutData, WorkoutMetadata, PowerData, HeartRateData, SpeedData, ElevationData, GearData from models.workout import WorkoutData, WorkoutMetadata, PowerData, HeartRateData, SpeedData, ElevationData, GearData
from ..config.settings import SUPPORTED_FORMATS from config.settings import SUPPORTED_FORMATS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -1,13 +1,13 @@
fitparse==1.2.0 fitparse==1.2.0
garminconnect==0.2.28 garminconnect==0.2.30
Jinja2==3.1.6 Jinja2==3.1.6
Markdown==3.8.2 Markdown==3.9
matplotlib==3.10.6 matplotlib==3.10.6
numpy==2.3.2 numpy==2.3.3
pandas==2.3.2 pandas==2.3.2
plotly==6.3.0 plotly==6.3.0
python-dotenv==1.1.1 python-dotenv==1.1.1
python_magic==0.4.27 python_magic==0.4.27
seaborn==0.13.2 seaborn==0.13.2
setuptools==75.8.0 setuptools==80.9.0
weasyprint==66.0 weasyprint==66.0

View File

@@ -11,7 +11,7 @@ import plotly.graph_objects as go
import plotly.express as px import plotly.express as px
from plotly.subplots import make_subplots from plotly.subplots import make_subplots
from ..models.workout import WorkoutData from models.workout import WorkoutData
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -10,7 +10,7 @@ from markdown import markdown
from weasyprint import HTML, CSS from weasyprint import HTML, CSS
import json import json
from ..models.workout import WorkoutData from models.workout import WorkoutData
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workout Report - {{ workout.metadata.activity_name }}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1, h2, h3 {
color: #333;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.summary-card {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
text-align: center;
}
.summary-card h3 {
margin: 0 0 10px 0;
color: #666;
font-size: 14px;
}
.summary-card .value {
font-size: 24px;
font-weight: bold;
color: #007bff;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Workout Report: {{ workout.metadata.activity_name }}</h1>
<p><strong>Date:</strong> {{ workout.metadata.start_time }}</p>
<p><strong>Activity Type:</strong> {{ workout.metadata.activity_type }}</p>
<h2>Summary</h2>
<div class="summary-grid">
<div class="summary-card">
<h3>Duration</h3>
<div class="value">{{ workout.summary.duration_minutes|format_duration }}</div>
</div>
<div class="summary-card">
<h3>Distance</h3>
<div class="value">{{ workout.summary.distance_km|format_distance }}</div>
</div>
<div class="summary-card">
<h3>Avg Power</h3>
<div class="value">{{ workout.summary.avg_power|format_power }}</div>
</div>
<div class="summary-card">
<h3>Avg Heart Rate</h3>
<div class="value">{{ workout.summary.avg_heart_rate|format_heart_rate }}</div>
</div>
<div class="summary-card">
<h3>Avg Speed</h3>
<div class="value">{{ workout.summary.avg_speed_kmh|format_speed }}</div>
</div>
<div class="summary-card">
<h3>Calories</h3>
<div class="value">{{ workout.summary.calories|int }}</div>
</div>
</div>
<h2>Detailed Analysis</h2>
<h3>Power Analysis</h3>
<table>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
<tr>
<td>Average Power</td>
<td>{{ workout.power_analysis.avg_power|format_power }}</td>
</tr>
<tr>
<td>Maximum Power</td>
<td>{{ workout.power_analysis.max_power|format_power }}</td>
</tr>
<tr>
<td>Normalized Power</td>
<td>{{ workout.summary.normalized_power|format_power }}</td>
</tr>
<tr>
<td>Intensity Factor</td>
<td>{{ "%.2f"|format(workout.summary.intensity_factor) }}</td>
</tr>
</table>
<h3>Heart Rate Analysis</h3>
<table>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
<tr>
<td>Average Heart Rate</td>
<td>{{ workout.heart_rate_analysis.avg_heart_rate|format_heart_rate }}</td>
</tr>
<tr>
<td>Maximum Heart Rate</td>
<td>{{ workout.heart_rate_analysis.max_heart_rate|format_heart_rate }}</td>
</tr>
</table>
<h3>Speed Analysis</h3>
<table>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
<tr>
<td>Average Speed</td>
<td>{{ workout.speed_analysis.avg_speed|format_speed }}</td>
</tr>
<tr>
<td>Maximum Speed</td>
<td>{{ workout.speed_analysis.max_speed|format_speed }}</td>
</tr>
</table>
<div class="footer">
<p>Report generated on {{ report.generated_at }} using {{ report.tool }} v{{ report.version }}</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,38 @@
# Workout Report: {{ workout.metadata.activity_name }}
**Date:** {{ workout.metadata.start_time }}
**Activity Type:** {{ workout.metadata.activity_type }}
## Summary
| Metric | Value |
|--------|--------|
| Duration | {{ workout.summary.duration_minutes|format_duration }} |
| Distance | {{ workout.summary.distance_km|format_distance }} |
| Average Power | {{ workout.summary.avg_power|format_power }} |
| Average Heart Rate | {{ workout.summary.avg_heart_rate|format_heart_rate }} |
| Average Speed | {{ workout.summary.avg_speed_kmh|format_speed }} |
| Calories | {{ workout.summary.calories|int }} |
## Detailed Analysis
### Power Analysis
- **Average Power:** {{ workout.power_analysis.avg_power|format_power }}
- **Maximum Power:** {{ workout.power_analysis.max_power|format_power }}
- **Normalized Power:** {{ workout.summary.normalized_power|format_power }}
- **Intensity Factor:** {{ "%.2f"|format(workout.summary.intensity_factor) }}
### Heart Rate Analysis
- **Average Heart Rate:** {{ workout.heart_rate_analysis.avg_heart_rate|format_heart_rate }}
- **Maximum Heart Rate:** {{ workout.heart_rate_analysis.max_heart_rate|format_heart_rate }}
### Speed Analysis
- **Average Speed:** {{ workout.speed_analysis.avg_speed|format_speed }}
- **Maximum Speed:** {{ workout.speed_analysis.max_speed|format_speed }}
---
*Report generated on {{ report.generated_at }} using {{ report.tool }} v{{ report.version }}*