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
```
usage: main.py [-h] [--config CONFIG] [--verbose] (--file FILE | --directory DIRECTORY | --garmin-connect)
[--ftp FTP] [--max-hr MAX_HR] [--zones ZONES] [--output-dir OUTPUT_DIR]
[--format {html,pdf,markdown}] [--charts] [--report] [--summary]
usage: main.py [-h] [--config CONFIG] [--verbose]
(--file FILE | --directory DIRECTORY | --garmin-connect | --workout-id WORKOUT_ID | --download-all | --reanalyze-all)
[--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
@@ -66,13 +68,24 @@ options:
--config CONFIG, -c CONFIG
Configuration file path
--verbose, -v Enable verbose logging
Input options:
--file FILE, -f FILE Path to workout file (FIT, TCX, or GPX)
--directory DIRECTORY, -d DIRECTORY
Directory containing workout files
--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)
--max-hr MAX_HR Maximum heart rate (bpm)
--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 directory for reports and charts
--format {html,pdf,markdown}
@@ -80,6 +93,23 @@ options:
--charts Generate charts
--report Generate comprehensive report
--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

View File

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

View File

@@ -12,7 +12,7 @@ try:
except ImportError:
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__)
@@ -176,7 +176,7 @@ class GarminClient:
DATA_DIR.mkdir(exist_ok=True)
# 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
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)")
client = GarminClient(
username=settings.GARMIN_EMAIL,
email=settings.GARMIN_EMAIL,
password=settings.GARMIN_PASSWORD
)
# Download workouts
workouts = client.get_workouts(days=days)
workouts = client.get_all_cycling_workouts()
# Analyze each workout
results = []
for workout in workouts:
for workout_summary in workouts:
try:
analysis = self.workout_analyzer.analyze_workout(workout)
results.append({
'workout': workout,
'analysis': analysis,
'file_path': None
})
activity_id = workout_summary.get('activityId')
if not activity_id:
logging.warning("Skipping workout with no activity ID.")
continue
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:
logging.error(f"Error analyzing workout: {e}")
@@ -269,10 +283,8 @@ class GarminAnalyser:
Returns:
List of downloaded workouts
"""
logging.info("Downloading all cycling activities from Garmin Connect")
client = GarminClient(
username=settings.GARMIN_EMAIL,
email=settings.GARMIN_EMAIL,
password=settings.GARMIN_PASSWORD
)
@@ -346,7 +358,7 @@ class GarminAnalyser:
logging.info(f"Analyzing workout ID: {workout_id}")
client = GarminClient(
username=settings.GARMIN_EMAIL,
email=settings.GARMIN_EMAIL,
password=settings.GARMIN_PASSWORD
)

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ from markdown import markdown
from weasyprint import HTML, CSS
import json
from ..models.workout import WorkoutData
from models.workout import WorkoutData
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 }}*