mirror of
https://github.com/sstent/Garmin_Analyser.git
synced 2025-12-06 08:01:40 +00:00
removing old cli.py
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Garmin Analyser
|
||||
|
||||
A comprehensive Python application for analyzing Garmin workout data from FIT, TCX, and GPX files, as well as direct integration with Garmin Connect. Provides detailed power, heart rate, and performance analysis with beautiful visualizations and comprehensive reports.
|
||||
A comprehensive Python application for analyzing Garmin workout data from FIT, TCX, and GPX files, as well as direct integration with Garmin Connect. Provides detailed power, heart rate, and performance analysis with beautiful visualizations and comprehensive reports via a modular command-line interface.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -112,6 +112,10 @@ Output:
|
||||
Charts saved to output/charts/ when --charts is used
|
||||
```
|
||||
|
||||
## Deprecation Notice
|
||||
|
||||
The Text User Interface (TUI) and legacy analyzer have been removed in favor of the more robust and maintainable modular command-line interface (CLI). The project now relies exclusively on the modular CLI (`main.py` and `cli.py`) for all operations. All functionality from the legacy components has been successfully migrated to the modular stack.
|
||||
|
||||
## Setup credentials
|
||||
|
||||
Canonical environment variables:
|
||||
|
||||
382
cli.py
382
cli.py
@@ -1,382 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Command-line interface for Garmin Cycling Analyzer.
|
||||
|
||||
This module provides CLI tools for analyzing cycling workouts from Garmin devices.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# Import from the new structure
|
||||
from parsers.file_parser import FileParser
|
||||
from analyzers.workout_analyzer import WorkoutAnalyzer
|
||||
from config import settings
|
||||
|
||||
# Import for Garmin Connect functionality
|
||||
try:
|
||||
from clients.garmin_client import GarminClient
|
||||
GARMIN_CLIENT_AVAILABLE = True
|
||||
except ImportError:
|
||||
GARMIN_CLIENT_AVAILABLE = False
|
||||
print("Warning: Garmin Connect client not available. Install garminconnect package for download functionality.")
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
"""Create the argument parser for CLI commands."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Analyze cycling workouts from Garmin devices',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s analyze file.fit --output results.json
|
||||
%(prog)s batch --input-dir ./workouts --output-dir ./results
|
||||
%(prog)s config --show
|
||||
"""
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Analyze command
|
||||
analyze_parser = subparsers.add_parser('analyze', help='Analyze a single workout file')
|
||||
analyze_parser.add_argument('file', help='Path to the workout file (.fit, .tcx, or .gpx)')
|
||||
analyze_parser.add_argument('--output', '-o', help='Output file for results (JSON format)')
|
||||
analyze_parser.add_argument('--cog-size', type=int, help='Chainring cog size (teeth)')
|
||||
analyze_parser.add_argument('--format', choices=['json', 'summary'], default='json',
|
||||
help='Output format (default: json)')
|
||||
analyze_parser.add_argument('--ftp', type=int, help='Functional Threshold Power (W)')
|
||||
analyze_parser.add_argument('--max-hr', type=int, help='Maximum heart rate (bpm)')
|
||||
|
||||
# Batch command
|
||||
batch_parser = subparsers.add_parser('batch', help='Analyze multiple workout files')
|
||||
batch_parser.add_argument('--input-dir', '-i', required=True, help='Directory containing workout files')
|
||||
batch_parser.add_argument('--output-dir', '-o', required=True, help='Directory for output files')
|
||||
batch_parser.add_argument('--cog-size', type=int, help='Chainring cog size (teeth)')
|
||||
batch_parser.add_argument('--pattern', default='*.fit', help='File pattern to match (default: *.fit)')
|
||||
batch_parser.add_argument('--ftp', type=int, help='Functional Threshold Power (W)')
|
||||
batch_parser.add_argument('--max-hr', type=int, help='Maximum heart rate (bpm)')
|
||||
|
||||
# Config command
|
||||
config_parser = subparsers.add_parser('config', help='Manage configuration')
|
||||
config_parser.add_argument('--show', action='store_true', help='Show current configuration')
|
||||
|
||||
# Download command (from original garmin_cycling_analyzer.py)
|
||||
download_parser = subparsers.add_parser('download', help='Download workouts from Garmin Connect')
|
||||
download_parser.add_argument('--workout-id', '-w', type=int, help='Download specific workout by ID')
|
||||
download_parser.add_argument('--download-all', action='store_true', help='Download all cycling activities')
|
||||
download_parser.add_argument('--limit', type=int, default=50, help='Maximum number of activities to download')
|
||||
|
||||
# Reanalyze command (from original garmin_cycling_analyzer.py)
|
||||
reanalyze_parser = subparsers.add_parser('reanalyze', help='Re-analyze downloaded workouts')
|
||||
reanalyze_parser.add_argument('--input-dir', '-i', default='data', help='Directory containing downloaded workouts (default: data)')
|
||||
reanalyze_parser.add_argument('--output-dir', '-o', default='reports', help='Directory for analysis reports (default: reports)')
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def analyze_file(file_path: str, cog_size: Optional[int] = None,
|
||||
ftp: Optional[int] = None, max_hr: Optional[int] = None,
|
||||
output_format: str = 'json') -> dict:
|
||||
"""
|
||||
Analyze a single workout file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the workout file
|
||||
cog_size: Chainring cog size for power estimation
|
||||
ftp: Functional Threshold Power
|
||||
max_hr: Maximum heart rate
|
||||
output_format: Output format ('json' or 'summary')
|
||||
|
||||
Returns:
|
||||
Analysis results as dictionary
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
# Override settings with provided parameters
|
||||
if ftp:
|
||||
settings.FTP = ftp
|
||||
if max_hr:
|
||||
settings.MAX_HEART_RATE = max_hr
|
||||
if cog_size:
|
||||
settings.COG_SIZE = cog_size
|
||||
|
||||
# Parse the file
|
||||
parser = FileParser()
|
||||
workout = parser.parse_file(Path(file_path))
|
||||
|
||||
if not workout:
|
||||
raise ValueError(f"Failed to parse file: {file_path}")
|
||||
|
||||
# Analyze the workout
|
||||
analyzer = WorkoutAnalyzer()
|
||||
results = analyzer.analyze_workout(workout)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def batch_analyze(input_dir: str, output_dir: str, cog_size: Optional[int] = None,
|
||||
ftp: Optional[int] = None, max_hr: Optional[int] = None,
|
||||
pattern: str = '*.fit') -> List[str]:
|
||||
"""
|
||||
Analyze multiple workout files in a directory.
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing workout files
|
||||
output_dir: Directory for output files
|
||||
cog_size: Chainring cog size for power estimation
|
||||
ftp: Functional Threshold Power
|
||||
max_hr: Maximum heart rate
|
||||
pattern: File pattern to match
|
||||
|
||||
Returns:
|
||||
List of processed file paths
|
||||
"""
|
||||
input_path = Path(input_dir)
|
||||
output_path = Path(output_dir)
|
||||
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_dir}")
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Override settings with provided parameters
|
||||
if ftp:
|
||||
settings.FTP = ftp
|
||||
if max_hr:
|
||||
settings.MAX_HEART_RATE = max_hr
|
||||
if cog_size:
|
||||
settings.COG_SIZE = cog_size
|
||||
|
||||
# Find matching files
|
||||
files = list(input_path.glob(pattern))
|
||||
processed_files = []
|
||||
|
||||
for file_path in files:
|
||||
try:
|
||||
print(f"Analyzing {file_path.name}...")
|
||||
results = analyze_file(str(file_path))
|
||||
|
||||
# Save results
|
||||
output_file = output_path / f"{file_path.stem}_analysis.json"
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
|
||||
processed_files.append(str(file_path))
|
||||
print(f" ✓ Results saved to {output_file.name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error analyzing {file_path.name}: {e}")
|
||||
|
||||
return processed_files
|
||||
|
||||
|
||||
def show_config():
|
||||
"""Display current configuration."""
|
||||
print("Current Configuration:")
|
||||
print("-" * 30)
|
||||
config_dict = {
|
||||
'FTP': settings.FTP,
|
||||
'MAX_HEART_RATE': settings.MAX_HEART_RATE,
|
||||
'COG_SIZE': getattr(settings, 'COG_SIZE', None),
|
||||
'ZONES_FILE': getattr(settings, 'ZONES_FILE', None),
|
||||
'REPORTS_DIR': settings.REPORTS_DIR,
|
||||
'DATA_DIR': settings.DATA_DIR,
|
||||
}
|
||||
|
||||
for key, value in config_dict.items():
|
||||
print(f"{key}: {value}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
if args.command == 'analyze':
|
||||
results = analyze_file(
|
||||
args.file,
|
||||
cog_size=getattr(args, 'cog_size', None),
|
||||
ftp=getattr(args, 'ftp', None),
|
||||
max_hr=getattr(args, 'max_hr', None),
|
||||
output_format=args.format
|
||||
)
|
||||
|
||||
if args.format == 'json':
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
print(f"Analysis complete. Results saved to {args.output}")
|
||||
else:
|
||||
print(json.dumps(results, indent=2, default=str))
|
||||
|
||||
elif args.format == 'summary':
|
||||
print_summary(results)
|
||||
|
||||
elif args.command == 'batch':
|
||||
processed = batch_analyze(
|
||||
args.input_dir,
|
||||
args.output_dir,
|
||||
cog_size=getattr(args, 'cog_size', None),
|
||||
ftp=getattr(args, 'ftp', None),
|
||||
max_hr=getattr(args, 'max_hr', None),
|
||||
pattern=args.pattern
|
||||
)
|
||||
print(f"\nBatch analysis complete. Processed {len(processed)} files.")
|
||||
|
||||
elif args.command == 'config':
|
||||
if args.show:
|
||||
show_config()
|
||||
else:
|
||||
show_config()
|
||||
|
||||
elif args.command == 'download':
|
||||
download_workouts(
|
||||
workout_id=getattr(args, 'workout_id', None),
|
||||
download_all=args.download_all,
|
||||
limit=getattr(args, 'limit', 50)
|
||||
)
|
||||
|
||||
elif args.command == 'reanalyze':
|
||||
reanalyze_workouts(
|
||||
input_dir=getattr(args, 'input_dir', 'data'),
|
||||
output_dir=getattr(args, 'output_dir', 'reports')
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def print_summary(results: dict):
|
||||
"""Print a human-readable summary of the analysis."""
|
||||
metadata = results.get('metadata', {})
|
||||
summary = results.get('summary', {})
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("WORKOUT SUMMARY")
|
||||
print("="*50)
|
||||
|
||||
if metadata:
|
||||
print(f"Activity: {metadata.get('activity_type', 'Unknown')}")
|
||||
print(f"Date: {metadata.get('start_time', 'Unknown')}")
|
||||
print(f"Duration: {summary.get('duration_minutes', 0):.1f} minutes")
|
||||
|
||||
if summary:
|
||||
print(f"\nDistance: {summary.get('distance_km', 0):.1f} km")
|
||||
print(f"Average Speed: {summary.get('avg_speed_kmh', 0):.1f} km/h")
|
||||
|
||||
if 'avg_power' in summary:
|
||||
print(f"Average Power: {summary['avg_power']:.0f} W")
|
||||
if 'max_power' in summary:
|
||||
print(f"Max Power: {summary['max_power']:.0f} W")
|
||||
|
||||
print(f"Average Heart Rate: {summary.get('avg_heart_rate', 0):.0f} bpm")
|
||||
print(f"Max Heart Rate: {summary.get('max_heart_rate', 0):.0f} bpm")
|
||||
|
||||
elevation = results.get('elevation_analysis', {})
|
||||
if elevation:
|
||||
print(f"Elevation Gain: {elevation.get('total_elevation_gain', 0):.0f} m")
|
||||
|
||||
zones = results.get('zones', {})
|
||||
if zones and 'power' in zones:
|
||||
print("\nPower Zone Distribution:")
|
||||
for zone, data in zones['power'].items():
|
||||
print(f" {zone}: {data['percentage']:.1f}% ({data['time_minutes']:.1f} min)")
|
||||
|
||||
print("="*50)
|
||||
|
||||
|
||||
def download_workouts(workout_id: Optional[int] = None, download_all: bool = False, limit: int = 50):
|
||||
"""
|
||||
Download workouts from Garmin Connect.
|
||||
|
||||
Args:
|
||||
workout_id: Specific workout ID to download
|
||||
download_all: Download all cycling activities
|
||||
limit: Maximum number of activities to download
|
||||
"""
|
||||
if not GARMIN_CLIENT_AVAILABLE:
|
||||
print("Error: Garmin Connect client not available. Install garminconnect package:")
|
||||
print(" pip install garminconnect")
|
||||
return
|
||||
|
||||
try:
|
||||
client = GarminClient()
|
||||
|
||||
if workout_id:
|
||||
print(f"Downloading workout {workout_id}...")
|
||||
success = client.download_workout(workout_id)
|
||||
if success:
|
||||
print(f"✓ Workout {workout_id} downloaded successfully")
|
||||
else:
|
||||
print(f"✗ Failed to download workout {workout_id}")
|
||||
|
||||
elif download_all:
|
||||
print(f"Downloading up to {limit} cycling activities...")
|
||||
downloaded = client.download_all_workouts(limit=limit)
|
||||
print(f"✓ Downloaded {len(downloaded)} activities")
|
||||
|
||||
else:
|
||||
print("Downloading latest cycling activity...")
|
||||
success = client.download_latest_workout()
|
||||
if success:
|
||||
print("✓ Latest activity downloaded successfully")
|
||||
else:
|
||||
print("✗ Failed to download latest activity")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading workouts: {e}")
|
||||
|
||||
|
||||
def reanalyze_workouts(input_dir: str = 'data', output_dir: str = 'reports'):
|
||||
"""
|
||||
Re-analyze all downloaded workouts.
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing downloaded workouts
|
||||
output_dir: Directory for analysis reports
|
||||
"""
|
||||
input_path = Path(input_dir)
|
||||
output_path = Path(output_dir)
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Input directory not found: {input_dir}")
|
||||
return
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find all workout files
|
||||
patterns = ['*.fit', '*.tcx', '*.gpx']
|
||||
files = []
|
||||
for pattern in patterns:
|
||||
files.extend(input_path.glob(pattern))
|
||||
|
||||
if not files:
|
||||
print(f"No workout files found in {input_dir}")
|
||||
return
|
||||
|
||||
print(f"Found {len(files)} workout files to re-analyze")
|
||||
|
||||
processed = batch_analyze(
|
||||
str(input_path),
|
||||
str(output_path),
|
||||
pattern='*.*' # Process all files
|
||||
)
|
||||
|
||||
print(f"\nRe-analysis complete. Processed {len(processed)} files.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
525
main.py
525
main.py
@@ -33,37 +33,18 @@ def setup_logging(verbose: bool = False):
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command line arguments.
|
||||
|
||||
Returns:
|
||||
Parsed arguments
|
||||
"""
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Analyze Garmin workout data from files or Garmin Connect',
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
epilog=(
|
||||
'Examples:\n'
|
||||
' Analyze latest workout from Garmin Connect: python main.py --garmin-connect\n'
|
||||
' Analyze specific workout by ID: python main.py --workout-id 123456789\n'
|
||||
' Download all cycling workouts: python main.py --download-all\n'
|
||||
' Re-analyze all downloaded workouts: python main.py --reanalyze-all\n'
|
||||
' Analyze local FIT file: python main.py --file path/to/workout.fit\n'
|
||||
' Analyze directory of workouts: python main.py --directory data/\n\n'
|
||||
'Configuration:\n'
|
||||
' Set Garmin credentials in .env file: GARMIN_EMAIL and GARMIN_PASSWORD\n'
|
||||
' Configure zones in config/config.yaml or use --zones flag\n'
|
||||
' Override FTP with --ftp flag, max HR with --max-hr flag\n\n'
|
||||
'Output:\n'
|
||||
' Reports saved to output/ directory by default\n'
|
||||
' Charts saved to output/charts/ when --charts is used'
|
||||
),
|
||||
formatter_class=argparse.RawTextHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config', '-c',
|
||||
type=str,
|
||||
default='config/config.yaml',
|
||||
help='Configuration file path'
|
||||
' %(prog)s analyze --file path/to/workout.fit\n'
|
||||
' %(prog)s batch --directory data/ --output-dir reports/\n'
|
||||
' %(prog)s download --all\n'
|
||||
' %(prog)s reanalyze --input-dir data/\n'
|
||||
' %(prog)s config --show'
|
||||
)
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
@@ -72,95 +53,135 @@ def parse_args() -> argparse.Namespace:
|
||||
help='Enable verbose logging'
|
||||
)
|
||||
|
||||
# Input options
|
||||
input_group = parser.add_mutually_exclusive_group(required=True)
|
||||
input_group.add_argument(
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Analyze command
|
||||
analyze_parser = subparsers.add_parser('analyze', help='Analyze a single workout or download from Garmin Connect')
|
||||
analyze_parser.add_argument(
|
||||
'--file', '-f',
|
||||
type=str,
|
||||
help='Path to workout file (FIT, TCX, or GPX)'
|
||||
)
|
||||
input_group.add_argument(
|
||||
'--directory', '-d',
|
||||
type=str,
|
||||
help='Directory containing workout files'
|
||||
)
|
||||
input_group.add_argument(
|
||||
analyze_parser.add_argument(
|
||||
'--garmin-connect',
|
||||
action='store_true',
|
||||
help='Download from Garmin Connect'
|
||||
help='Download and analyze latest workout from Garmin Connect'
|
||||
)
|
||||
input_group.add_argument(
|
||||
analyze_parser.add_argument(
|
||||
'--workout-id',
|
||||
type=int,
|
||||
help='Analyze specific workout by ID from Garmin Connect'
|
||||
)
|
||||
input_group.add_argument(
|
||||
'--download-all',
|
||||
action='store_true',
|
||||
help='Download all cycling activities from Garmin Connect (no analysis)'
|
||||
analyze_parser.add_argument(
|
||||
'--ftp', type=int, help='Functional Threshold Power (W)'
|
||||
)
|
||||
input_group.add_argument(
|
||||
'--reanalyze-all',
|
||||
action='store_true',
|
||||
help='Re-analyze all downloaded activities and generate reports'
|
||||
analyze_parser.add_argument(
|
||||
'--max-hr', type=int, help='Maximum heart rate (bpm)'
|
||||
)
|
||||
analyze_parser.add_argument(
|
||||
'--zones', type=str, help='Path to zones configuration file'
|
||||
)
|
||||
analyze_parser.add_argument(
|
||||
'--cog', type=int, help='Cog size (teeth) for power calculations. Auto-detected if not provided'
|
||||
)
|
||||
analyze_parser.add_argument(
|
||||
'--output-dir', type=str, default='output', help='Output directory for reports and charts'
|
||||
)
|
||||
analyze_parser.add_argument(
|
||||
'--format', choices=['html', 'pdf', 'markdown'], default='html', help='Report format'
|
||||
)
|
||||
analyze_parser.add_argument(
|
||||
'--charts', action='store_true', help='Generate charts'
|
||||
)
|
||||
analyze_parser.add_argument(
|
||||
'--report', action='store_true', help='Generate comprehensive report'
|
||||
)
|
||||
|
||||
# Analysis options
|
||||
parser.add_argument(
|
||||
'--ftp',
|
||||
type=int,
|
||||
help='Functional Threshold Power (W)'
|
||||
# Batch command
|
||||
batch_parser = subparsers.add_parser('batch', help='Analyze multiple workout files in a directory')
|
||||
batch_parser.add_argument(
|
||||
'--directory', '-d', required=True, type=str, help='Directory containing workout files'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--output-dir', type=str, default='output', help='Output directory for reports and charts'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--format', choices=['html', 'pdf', 'markdown'], default='html', help='Report format'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--charts', action='store_true', help='Generate charts'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--report', action='store_true', help='Generate comprehensive report'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--summary', action='store_true', help='Generate summary report for multiple workouts'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--ftp', type=int, help='Functional Threshold Power (W)'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--max-hr', type=int, help='Maximum heart rate (bpm)'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--zones', type=str, help='Path to zones configuration file'
|
||||
)
|
||||
batch_parser.add_argument(
|
||||
'--cog', type=int, help='Cog size (teeth) for power calculations. Auto-detected if not provided'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--max-hr',
|
||||
type=int,
|
||||
help='Maximum heart rate (bpm)'
|
||||
# Download command
|
||||
download_parser = subparsers.add_parser('download', help='Download activities from Garmin Connect')
|
||||
download_parser.add_argument(
|
||||
'--all', action='store_true', help='Download all cycling activities'
|
||||
)
|
||||
download_parser.add_argument(
|
||||
'--workout-id', type=int, help='Download specific workout by ID'
|
||||
)
|
||||
download_parser.add_argument(
|
||||
'--limit', type=int, default=50, help='Maximum number of activities to download (with --all)'
|
||||
)
|
||||
download_parser.add_argument(
|
||||
'--output-dir', type=str, default='data', help='Directory to save downloaded files'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--zones',
|
||||
type=str,
|
||||
help='Path to zones configuration file'
|
||||
# Reanalyze command
|
||||
reanalyze_parser = subparsers.add_parser('reanalyze', help='Re-analyze all downloaded activities')
|
||||
reanalyze_parser.add_argument(
|
||||
'--input-dir', type=str, default='data', help='Directory containing downloaded workouts'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--output-dir', type=str, default='output', help='Output directory for reports and charts'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--format', choices=['html', 'pdf', 'markdown'], default='html', help='Report format'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--charts', action='store_true', help='Generate charts'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--report', action='store_true', help='Generate comprehensive report'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--summary', action='store_true', help='Generate summary report for multiple workouts'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--ftp', type=int, help='Functional Threshold Power (W)'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--max-hr', type=int, help='Maximum heart rate (bpm)'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--zones', type=str, help='Path to zones configuration file'
|
||||
)
|
||||
reanalyze_parser.add_argument(
|
||||
'--cog', type=int, help='Cog size (teeth) for power calculations. Auto-detected if not provided'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--cog',
|
||||
type=int,
|
||||
help='Cog size (teeth) for power calculations. Auto-detected if not provided'
|
||||
)
|
||||
|
||||
# Output options
|
||||
parser.add_argument(
|
||||
'--output-dir',
|
||||
type=str,
|
||||
default='output',
|
||||
help='Output directory for reports and charts'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--format',
|
||||
choices=['html', 'pdf', 'markdown'],
|
||||
default='html',
|
||||
help='Report format'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--charts',
|
||||
action='store_true',
|
||||
help='Generate charts'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--report',
|
||||
action='store_true',
|
||||
help='Generate comprehensive report'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--summary',
|
||||
action='store_true',
|
||||
help='Generate summary report for multiple workouts'
|
||||
# Config command
|
||||
config_parser = subparsers.add_parser('config', help='Manage configuration')
|
||||
config_parser.add_argument(
|
||||
'--show', action='store_true', help='Show current configuration'
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
@@ -180,43 +201,57 @@ class GarminAnalyser:
|
||||
# Create report templates
|
||||
self.report_generator.create_report_templates()
|
||||
|
||||
def analyze_file(self, file_path: Path, cog_size: Optional[int] = None) -> dict:
|
||||
def _apply_analysis_overrides(self, args: argparse.Namespace):
|
||||
"""Apply FTP, Max HR, and zones overrides from arguments."""
|
||||
if hasattr(args, 'ftp') and args.ftp:
|
||||
self.settings.FTP = args.ftp
|
||||
if hasattr(args, 'max_hr') and args.max_hr:
|
||||
self.settings.MAX_HEART_RATE = args.max_hr
|
||||
if hasattr(args, 'zones') and args.zones:
|
||||
self.settings.ZONES_FILE = args.zones
|
||||
# Reload zones if the file path is updated
|
||||
self.settings.load_zones(Path(args.zones))
|
||||
|
||||
def analyze_file(self, file_path: Path, args: argparse.Namespace) -> dict:
|
||||
"""Analyze a single workout file.
|
||||
|
||||
Args:
|
||||
file_path: Path to workout file
|
||||
cog_size: Chainring teeth size for power calculations
|
||||
args: Command line arguments including analysis overrides
|
||||
|
||||
Returns:
|
||||
Analysis results
|
||||
"""
|
||||
logging.info(f"Analyzing file: {file_path}")
|
||||
self._apply_analysis_overrides(args)
|
||||
|
||||
# Parse workout file
|
||||
workout = self.file_parser.parse_file(file_path)
|
||||
if not workout:
|
||||
raise ValueError(f"Failed to parse file: {file_path}")
|
||||
|
||||
# Analyze workout
|
||||
# Determine cog size from args or auto-detect
|
||||
cog_size = None
|
||||
if hasattr(args, 'cog') and args.cog:
|
||||
cog_size = args.cog
|
||||
elif hasattr(args, 'auto_detect_cog') and args.auto_detect_cog:
|
||||
# Implement auto-detection logic if needed, or rely on analyzer's default
|
||||
pass
|
||||
|
||||
analysis = self.workout_analyzer.analyze_workout(workout, cog_size=cog_size)
|
||||
return {'workout': workout, 'analysis': analysis, 'file_path': file_path}
|
||||
|
||||
return {
|
||||
'workout': workout,
|
||||
'analysis': analysis,
|
||||
'file_path': file_path
|
||||
}
|
||||
|
||||
def analyze_directory(self, directory: Path, cog_size: Optional[int] = None) -> List[dict]:
|
||||
"""Analyze all workout files in a directory.
|
||||
def batch_analyze_directory(self, directory: Path, args: argparse.Namespace) -> List[dict]:
|
||||
"""Analyze multiple workout files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory containing workout files
|
||||
cog_size: Chainring teeth size for power calculations
|
||||
args: Command line arguments including analysis overrides
|
||||
|
||||
Returns:
|
||||
List of analysis results
|
||||
"""
|
||||
logging.info(f"Analyzing directory: {directory}")
|
||||
self._apply_analysis_overrides(args)
|
||||
|
||||
results = []
|
||||
supported_extensions = {'.fit', '.tcx', '.gpx'}
|
||||
@@ -224,167 +259,104 @@ class GarminAnalyser:
|
||||
for file_path in directory.rglob('*'):
|
||||
if file_path.suffix.lower() in supported_extensions:
|
||||
try:
|
||||
result = self.analyze_file(file_path, cog_size=cog_size)
|
||||
result = self.analyze_file(file_path, args)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing {file_path}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def download_from_garmin(self, days: int = 30, cog_size: Optional[int] = None) -> List[dict]:
|
||||
"""Download and analyze workouts from Garmin Connect.
|
||||
def download_workouts(self, args: argparse.Namespace) -> List[dict]:
|
||||
"""Download workouts from Garmin Connect.
|
||||
|
||||
Args:
|
||||
days: Number of days to download
|
||||
cog_size: Chainring teeth size for power calculations
|
||||
args: Command line arguments for download options
|
||||
|
||||
Returns:
|
||||
List of analysis results
|
||||
List of downloaded workout data or analysis results
|
||||
"""
|
||||
logging.info(f"Downloading workouts from Garmin Connect (last {days} days)")
|
||||
email, password = self.settings.get_garmin_credentials()
|
||||
client = GarminClient(email=email, password=password)
|
||||
|
||||
email, password = settings.get_garmin_credentials()
|
||||
client = GarminClient(
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
download_output_dir = Path(getattr(args, 'output_dir', 'data'))
|
||||
download_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download workouts
|
||||
workouts = client.get_all_cycling_workouts()
|
||||
downloaded_activities = []
|
||||
if getattr(args, 'all', False):
|
||||
logging.info(f"Downloading up to {getattr(args, 'limit', 50)} cycling activities...")
|
||||
downloaded_activities = client.download_all_workouts(limit=getattr(args, 'limit', 50), output_dir=download_output_dir)
|
||||
elif getattr(args, 'workout_id', None):
|
||||
logging.info(f"Downloading workout {args.workout_id}...")
|
||||
activity_path = client.download_activity_original(str(args.workout_id), output_dir=download_output_dir)
|
||||
if activity_path:
|
||||
downloaded_activities.append({'file_path': activity_path})
|
||||
else:
|
||||
logging.info("Downloading latest cycling activity...")
|
||||
activity_path = client.download_latest_workout(output_dir=download_output_dir)
|
||||
if activity_path:
|
||||
downloaded_activities.append({'file_path': activity_path})
|
||||
|
||||
# Analyze each workout
|
||||
results = []
|
||||
for workout_summary in workouts:
|
||||
try:
|
||||
activity_id = workout_summary.get('activityId')
|
||||
if not activity_id:
|
||||
logging.warning("Skipping workout with no activity ID.")
|
||||
continue
|
||||
# Check if any analysis-related flags are set
|
||||
if (getattr(args, 'charts', False)) or \
|
||||
(getattr(args, 'report', False)) or \
|
||||
(getattr(args, 'summary', False)) or \
|
||||
(getattr(args, 'ftp', None)) or \
|
||||
(getattr(args, 'max_hr', None)) or \
|
||||
(getattr(args, 'zones', None)) or \
|
||||
(getattr(args, 'cog', None)):
|
||||
logging.info("Analyzing downloaded workouts...")
|
||||
for activity_data in downloaded_activities:
|
||||
file_path = activity_data['file_path']
|
||||
try:
|
||||
result = self.analyze_file(file_path, args)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing downloaded file {file_path}: {e}")
|
||||
return results if results else downloaded_activities # Return analysis results if analysis was requested, else just downloaded file paths
|
||||
|
||||
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, cog_size=cog_size)
|
||||
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}")
|
||||
|
||||
return results
|
||||
|
||||
def download_all_workouts(self) -> List[dict]:
|
||||
"""Download all cycling activities from Garmin Connect without analysis.
|
||||
|
||||
Returns:
|
||||
List of downloaded workouts
|
||||
"""
|
||||
email, password = settings.get_garmin_credentials()
|
||||
client = GarminClient(
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
|
||||
# Download all cycling workouts
|
||||
workouts = client.get_all_cycling_workouts()
|
||||
|
||||
# Save workouts to files
|
||||
data_dir = Path('data')
|
||||
data_dir.mkdir(exist_ok=True)
|
||||
|
||||
downloaded_workouts = []
|
||||
for workout in workouts:
|
||||
try:
|
||||
# Generate filename
|
||||
date_str = workout.metadata.start_time.strftime('%Y-%m-%d')
|
||||
filename = f"{date_str}_{workout.metadata.activity_name.replace(' ', '_')}.fit"
|
||||
file_path = data_dir / filename
|
||||
|
||||
# Save workout data
|
||||
client.download_workout_file(workout.id, file_path)
|
||||
|
||||
downloaded_workouts.append({
|
||||
'workout': workout,
|
||||
'file_path': file_path
|
||||
})
|
||||
|
||||
logging.info(f"Downloaded: {filename}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error downloading workout {workout.id}: {e}")
|
||||
|
||||
logging.info(f"Downloaded {len(downloaded_workouts)} workouts")
|
||||
return downloaded_workouts
|
||||
|
||||
def reanalyze_all_workouts(self, cog_size: Optional[int] = None) -> List[dict]:
|
||||
def reanalyze_workouts(self, args: argparse.Namespace) -> List[dict]:
|
||||
"""Re-analyze all downloaded workout files.
|
||||
|
||||
Args:
|
||||
cog_size: Chainring teeth size for power calculations
|
||||
args: Command line arguments including input/output directories and analysis overrides
|
||||
|
||||
Returns:
|
||||
List of analysis results
|
||||
"""
|
||||
logging.info("Re-analyzing all downloaded workouts")
|
||||
self._apply_analysis_overrides(args)
|
||||
|
||||
data_dir = Path('data')
|
||||
if not data_dir.exists():
|
||||
logging.error("No data directory found. Use --download-all first.")
|
||||
input_dir = Path(getattr(args, 'input_dir', 'data'))
|
||||
if not input_dir.exists():
|
||||
logging.error(f"Input directory not found: {input_dir}. Please download workouts first.")
|
||||
return []
|
||||
|
||||
results = []
|
||||
supported_extensions = {'.fit', '.tcx', '.gpx'}
|
||||
|
||||
for file_path in data_dir.rglob('*'):
|
||||
for file_path in input_dir.rglob('*'):
|
||||
if file_path.suffix.lower() in supported_extensions:
|
||||
try:
|
||||
result = self.analyze_file(file_path, cog_size=cog_size)
|
||||
result = self.analyze_file(file_path, args)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logging.error(f"Error re-analyzing {file_path}: {e}")
|
||||
|
||||
logging.info(f"Re-analyzed {len(results)} workouts")
|
||||
return results
|
||||
|
||||
def analyze_workout_by_id(self, workout_id: int, cog_size: Optional[int] = None) -> dict:
|
||||
"""Analyze a specific workout by ID from Garmin Connect.
|
||||
|
||||
Args:
|
||||
workout_id: Garmin Connect workout ID
|
||||
cog_size: Chainring teeth size for power calculations
|
||||
|
||||
Returns:
|
||||
Analysis result
|
||||
"""
|
||||
logging.info(f"Analyzing workout ID: {workout_id}")
|
||||
|
||||
email, password = settings.get_garmin_credentials()
|
||||
client = GarminClient(
|
||||
email=email,
|
||||
password=password
|
||||
)
|
||||
|
||||
# Download specific workout
|
||||
workout = client.get_workout_by_id(workout_id)
|
||||
if not workout:
|
||||
raise ValueError(f"Workout not found: {workout_id}")
|
||||
|
||||
# Analyze workout
|
||||
analysis = self.workout_analyzer.analyze_workout(workout, cog_size=cog_size)
|
||||
|
||||
return {
|
||||
'workout': workout,
|
||||
'analysis': analysis,
|
||||
'file_path': None
|
||||
def show_config(self):
|
||||
"""Display current configuration."""
|
||||
logging.info("Current Configuration:")
|
||||
logging.info("-" * 30)
|
||||
config_dict = {
|
||||
'FTP': self.settings.FTP,
|
||||
'MAX_HEART_RATE': self.settings.MAX_HEART_RATE,
|
||||
'ZONES_FILE': getattr(self.settings, 'ZONES_FILE', 'N/A'),
|
||||
'REPORTS_DIR': self.settings.REPORTS_DIR,
|
||||
'DATA_DIR': self.settings.DATA_DIR,
|
||||
}
|
||||
for key, value in config_dict.items():
|
||||
logging.info(f"{key}: {value}")
|
||||
|
||||
def generate_outputs(self, results: List[dict], args: argparse.Namespace):
|
||||
"""Generate charts and reports based on results.
|
||||
@@ -393,26 +365,26 @@ class GarminAnalyser:
|
||||
results: Analysis results
|
||||
args: Command line arguments
|
||||
"""
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir = Path(getattr(args, 'output_dir', 'output'))
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
if args.charts:
|
||||
if getattr(args, 'charts', False):
|
||||
logging.info("Generating charts...")
|
||||
for result in results:
|
||||
charts = self.chart_generator.generate_workout_charts(
|
||||
self.chart_generator.generate_workout_charts(
|
||||
result['workout'], result['analysis']
|
||||
)
|
||||
logging.info(f"Charts saved to: {output_dir / 'charts'}")
|
||||
logging.info(f"Charts saved to: {output_dir / 'charts'}")
|
||||
|
||||
if args.report:
|
||||
if getattr(args, 'report', False):
|
||||
logging.info("Generating reports...")
|
||||
for result in results:
|
||||
report_path = self.report_generator.generate_workout_report(
|
||||
result['workout'], result['analysis'], args.format
|
||||
result['workout'], result['analysis'], getattr(args, 'format', 'html')
|
||||
)
|
||||
logging.info(f"Report saved to: {report_path}")
|
||||
|
||||
if args.summary and len(results) > 1:
|
||||
if getattr(args, 'summary', False) and len(results) > 1:
|
||||
logging.info("Generating summary report...")
|
||||
workouts = [r['workout'] for r in results]
|
||||
analyses = [r['analysis'] for r in results]
|
||||
@@ -428,56 +400,57 @@ def main():
|
||||
setup_logging(args.verbose)
|
||||
|
||||
try:
|
||||
# Override settings with command line arguments
|
||||
if args.ftp:
|
||||
settings.FTP = args.ftp
|
||||
if args.max_hr:
|
||||
settings.MAX_HEART_RATE = args.max_hr
|
||||
|
||||
# Initialize analyser
|
||||
analyser = GarminAnalyser()
|
||||
|
||||
# Analyze workouts
|
||||
results = []
|
||||
|
||||
if args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
logging.error(f"File not found: {file_path}")
|
||||
if args.command == 'analyze':
|
||||
if args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
logging.error(f"File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
results = [analyser.analyze_file(file_path, args)]
|
||||
elif args.garmin_connect or args.workout_id:
|
||||
results = analyser.download_workouts(args)
|
||||
else:
|
||||
logging.error("Please specify a file, --garmin-connect, or --workout-id for the analyze command.")
|
||||
sys.exit(1)
|
||||
results = [analyser.analyze_file(file_path, cog_size=args.cog)]
|
||||
|
||||
elif args.directory:
|
||||
if results: # Only generate outputs if there are results
|
||||
analyser.generate_outputs(results, args)
|
||||
|
||||
elif args.command == 'batch':
|
||||
directory = Path(args.directory)
|
||||
if not directory.exists():
|
||||
logging.error(f"Directory not found: {directory}")
|
||||
sys.exit(1)
|
||||
results = analyser.analyze_directory(directory, cog_size=args.cog)
|
||||
results = analyser.batch_analyze_directory(directory, args)
|
||||
|
||||
elif args.garmin_connect:
|
||||
results = analyser.download_from_garmin(cog_size=args.cog)
|
||||
if results: # Only generate outputs if there are results
|
||||
analyser.generate_outputs(results, args)
|
||||
|
||||
elif args.workout_id:
|
||||
try:
|
||||
results = [analyser.analyze_workout_by_id(args.workout_id, cog_size=args.cog)]
|
||||
except ValueError as e:
|
||||
logging.error(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
elif args.command == 'download':
|
||||
# Download workouts and potentially analyze them if analysis flags are present
|
||||
results = analyser.download_workouts(args)
|
||||
if results:
|
||||
# If analysis was part of download, generate outputs
|
||||
if (getattr(args, 'charts', False) or getattr(args, 'report', False) or getattr(args, 'summary', False)):
|
||||
analyser.generate_outputs(results, args)
|
||||
else:
|
||||
logging.info(f"Downloaded {len(results)} activities to {getattr(args, 'output_dir', 'data')}")
|
||||
logging.info("Download command complete!")
|
||||
|
||||
elif args.download_all:
|
||||
analyser.download_all_workouts()
|
||||
logging.info("Download complete! Use --reanalyze-all to analyze downloaded workouts.")
|
||||
return
|
||||
elif args.command == 'reanalyze':
|
||||
results = analyser.reanalyze_workouts(args)
|
||||
if results: # Only generate outputs if there are results
|
||||
analyser.generate_outputs(results, args)
|
||||
|
||||
elif args.reanalyze_all:
|
||||
results = analyser.reanalyze_all_workouts(cog_size=args.cog)
|
||||
elif args.command == 'config':
|
||||
if getattr(args, 'show', False):
|
||||
analyser.show_config()
|
||||
|
||||
# Generate outputs
|
||||
if results:
|
||||
analyser.generate_outputs(results, args)
|
||||
|
||||
# Print summary
|
||||
if results:
|
||||
# Print summary for analyze, batch, reanalyze commands if results are available
|
||||
if args.command in ['analyze', 'batch', 'reanalyze'] and results:
|
||||
logging.info(f"\nAnalysis complete! Processed {len(results)} workout(s)")
|
||||
for result in results:
|
||||
workout = result['workout']
|
||||
@@ -490,7 +463,7 @@ def main():
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error: {e}")
|
||||
logging.error(f"Error: {e}", file=sys.stderr)
|
||||
if args.verbose:
|
||||
logging.exception("Full traceback:")
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user