This commit is contained in:
2025-09-26 07:30:15 -07:00
parent 9d7e855713
commit 478d1bb06c
30 changed files with 5584 additions and 31 deletions

6
visualizers/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Visualization modules for workout data."""
from .chart_generator import ChartGenerator
from .report_generator import ReportGenerator
__all__ = ['ChartGenerator', 'ReportGenerator']

View File

@@ -0,0 +1,557 @@
"""Chart generator for workout data visualization."""
import logging
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
import numpy as np
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
from ..models.workout import WorkoutData
logger = logging.getLogger(__name__)
class ChartGenerator:
"""Generate various charts and visualizations for workout data."""
def __init__(self, output_dir: Path = None):
"""Initialize chart generator.
Args:
output_dir: Directory to save charts
"""
self.output_dir = output_dir or Path('charts')
self.output_dir.mkdir(exist_ok=True)
# Set style
plt.style.use('seaborn-v0_8')
sns.set_palette("husl")
def generate_workout_charts(self, workout: WorkoutData, analysis: Dict[str, Any]) -> Dict[str, str]:
"""Generate all workout charts.
Args:
workout: WorkoutData object
analysis: Analysis results from WorkoutAnalyzer
Returns:
Dictionary mapping chart names to file paths
"""
charts = {}
# Time series charts
charts['power_time_series'] = self._create_power_time_series(workout)
charts['heart_rate_time_series'] = self._create_heart_rate_time_series(workout)
charts['speed_time_series'] = self._create_speed_time_series(workout)
charts['elevation_time_series'] = self._create_elevation_time_series(workout)
# Distribution charts
charts['power_distribution'] = self._create_power_distribution(workout, analysis)
charts['heart_rate_distribution'] = self._create_heart_rate_distribution(workout, analysis)
charts['speed_distribution'] = self._create_speed_distribution(workout, analysis)
# Zone charts
charts['power_zones'] = self._create_power_zones_chart(analysis)
charts['heart_rate_zones'] = self._create_heart_rate_zones_chart(analysis)
# Correlation charts
charts['power_vs_heart_rate'] = self._create_power_vs_heart_rate(workout)
charts['power_vs_speed'] = self._create_power_vs_speed(workout)
# Summary dashboard
charts['workout_dashboard'] = self._create_workout_dashboard(workout, analysis)
return charts
def _create_power_time_series(self, workout: WorkoutData) -> str:
"""Create power vs time chart.
Args:
workout: WorkoutData object
Returns:
Path to saved chart
"""
if not workout.power or not workout.power.power_values:
return None
fig, ax = plt.subplots(figsize=(12, 6))
power_values = workout.power.power_values
time_minutes = np.arange(len(power_values)) / 60
ax.plot(time_minutes, power_values, linewidth=0.5, alpha=0.8)
ax.axhline(y=workout.power.avg_power, color='r', linestyle='--',
label=f'Avg: {workout.power.avg_power:.0f}W')
ax.axhline(y=workout.power.max_power, color='g', linestyle='--',
label=f'Max: {workout.power.max_power:.0f}W')
ax.set_xlabel('Time (minutes)')
ax.set_ylabel('Power (W)')
ax.set_title('Power Over Time')
ax.legend()
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'power_time_series.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_heart_rate_time_series(self, workout: WorkoutData) -> str:
"""Create heart rate vs time chart.
Args:
workout: WorkoutData object
Returns:
Path to saved chart
"""
if not workout.heart_rate or not workout.heart_rate.heart_rate_values:
return None
fig, ax = plt.subplots(figsize=(12, 6))
hr_values = workout.heart_rate.heart_rate_values
time_minutes = np.arange(len(hr_values)) / 60
ax.plot(time_minutes, hr_values, linewidth=0.5, alpha=0.8, color='red')
ax.axhline(y=workout.heart_rate.avg_hr, color='darkred', linestyle='--',
label=f'Avg: {workout.heart_rate.avg_hr:.0f} bpm')
ax.axhline(y=workout.heart_rate.max_hr, color='darkgreen', linestyle='--',
label=f'Max: {workout.heart_rate.max_hr:.0f} bpm')
ax.set_xlabel('Time (minutes)')
ax.set_ylabel('Heart Rate (bpm)')
ax.set_title('Heart Rate Over Time')
ax.legend()
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'heart_rate_time_series.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_speed_time_series(self, workout: WorkoutData) -> str:
"""Create speed vs time chart.
Args:
workout: WorkoutData object
Returns:
Path to saved chart
"""
if not workout.speed or not workout.speed.speed_values:
return None
fig, ax = plt.subplots(figsize=(12, 6))
speed_values = workout.speed.speed_values
time_minutes = np.arange(len(speed_values)) / 60
ax.plot(time_minutes, speed_values, linewidth=0.5, alpha=0.8, color='blue')
ax.axhline(y=workout.speed.avg_speed, color='darkblue', linestyle='--',
label=f'Avg: {workout.speed.avg_speed:.1f} km/h')
ax.axhline(y=workout.speed.max_speed, color='darkgreen', linestyle='--',
label=f'Max: {workout.speed.max_speed:.1f} km/h')
ax.set_xlabel('Time (minutes)')
ax.set_ylabel('Speed (km/h)')
ax.set_title('Speed Over Time')
ax.legend()
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'speed_time_series.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_elevation_time_series(self, workout: WorkoutData) -> str:
"""Create elevation vs time chart.
Args:
workout: WorkoutData object
Returns:
Path to saved chart
"""
if not workout.elevation or not workout.elevation.elevation_values:
return None
fig, ax = plt.subplots(figsize=(12, 6))
elevation_values = workout.elevation.elevation_values
time_minutes = np.arange(len(elevation_values)) / 60
ax.plot(time_minutes, elevation_values, linewidth=1, alpha=0.8, color='brown')
ax.fill_between(time_minutes, elevation_values, alpha=0.3, color='brown')
ax.set_xlabel('Time (minutes)')
ax.set_ylabel('Elevation (m)')
ax.set_title('Elevation Profile')
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'elevation_time_series.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_power_distribution(self, workout: WorkoutData, analysis: Dict[str, Any]) -> str:
"""Create power distribution histogram.
Args:
workout: WorkoutData object
analysis: Analysis results
Returns:
Path to saved chart
"""
if not workout.power or not workout.power.power_values:
return None
fig, ax = plt.subplots(figsize=(10, 6))
power_values = workout.power.power_values
ax.hist(power_values, bins=50, alpha=0.7, color='orange', edgecolor='black')
ax.axvline(x=workout.power.avg_power, color='red', linestyle='--',
label=f'Avg: {workout.power.avg_power:.0f}W')
ax.set_xlabel('Power (W)')
ax.set_ylabel('Frequency')
ax.set_title('Power Distribution')
ax.legend()
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'power_distribution.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_heart_rate_distribution(self, workout: WorkoutData, analysis: Dict[str, Any]) -> str:
"""Create heart rate distribution histogram.
Args:
workout: WorkoutData object
analysis: Analysis results
Returns:
Path to saved chart
"""
if not workout.heart_rate or not workout.heart_rate.heart_rate_values:
return None
fig, ax = plt.subplots(figsize=(10, 6))
hr_values = workout.heart_rate.heart_rate_values
ax.hist(hr_values, bins=30, alpha=0.7, color='red', edgecolor='black')
ax.axvline(x=workout.heart_rate.avg_hr, color='darkred', linestyle='--',
label=f'Avg: {workout.heart_rate.avg_hr:.0f} bpm')
ax.set_xlabel('Heart Rate (bpm)')
ax.set_ylabel('Frequency')
ax.set_title('Heart Rate Distribution')
ax.legend()
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'heart_rate_distribution.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_speed_distribution(self, workout: WorkoutData, analysis: Dict[str, Any]) -> str:
"""Create speed distribution histogram.
Args:
workout: WorkoutData object
analysis: Analysis results
Returns:
Path to saved chart
"""
if not workout.speed or not workout.speed.speed_values:
return None
fig, ax = plt.subplots(figsize=(10, 6))
speed_values = workout.speed.speed_values
ax.hist(speed_values, bins=30, alpha=0.7, color='blue', edgecolor='black')
ax.axvline(x=workout.speed.avg_speed, color='darkblue', linestyle='--',
label=f'Avg: {workout.speed.avg_speed:.1f} km/h')
ax.set_xlabel('Speed (km/h)')
ax.set_ylabel('Frequency')
ax.set_title('Speed Distribution')
ax.legend()
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'speed_distribution.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_power_zones_chart(self, analysis: Dict[str, Any]) -> str:
"""Create power zones pie chart.
Args:
analysis: Analysis results
Returns:
Path to saved chart
"""
if 'power_analysis' not in analysis or 'power_zones' not in analysis['power_analysis']:
return None
power_zones = analysis['power_analysis']['power_zones']
fig, ax = plt.subplots(figsize=(8, 8))
labels = list(power_zones.keys())
sizes = list(power_zones.values())
colors = plt.cm.Set3(np.linspace(0, 1, len(labels)))
ax.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90)
ax.set_title('Time in Power Zones')
filepath = self.output_dir / 'power_zones.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_heart_rate_zones_chart(self, analysis: Dict[str, Any]) -> str:
"""Create heart rate zones pie chart.
Args:
analysis: Analysis results
Returns:
Path to saved chart
"""
if 'heart_rate_analysis' not in analysis or 'hr_zones' not in analysis['heart_rate_analysis']:
return None
hr_zones = analysis['heart_rate_analysis']['hr_zones']
fig, ax = plt.subplots(figsize=(8, 8))
labels = list(hr_zones.keys())
sizes = list(hr_zones.values())
colors = plt.cm.Set3(np.linspace(0, 1, len(labels)))
ax.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90)
ax.set_title('Time in Heart Rate Zones')
filepath = self.output_dir / 'heart_rate_zones.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_power_vs_heart_rate(self, workout: WorkoutData) -> str:
"""Create power vs heart rate scatter plot.
Args:
workout: WorkoutData object
Returns:
Path to saved chart
"""
if (not workout.power or not workout.power.power_values or
not workout.heart_rate or not workout.heart_rate.heart_rate_values):
return None
power_values = workout.power.power_values
hr_values = workout.heart_rate.heart_rate_values
# Align arrays
min_len = min(len(power_values), len(hr_values))
if min_len == 0:
return None
power_values = power_values[:min_len]
hr_values = hr_values[:min_len]
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(power_values, hr_values, alpha=0.5, s=1)
# Add trend line
z = np.polyfit(power_values, hr_values, 1)
p = np.poly1d(z)
ax.plot(power_values, p(power_values), "r--", alpha=0.8)
ax.set_xlabel('Power (W)')
ax.set_ylabel('Heart Rate (bpm)')
ax.set_title('Power vs Heart Rate')
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'power_vs_heart_rate.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_power_vs_speed(self, workout: WorkoutData) -> str:
"""Create power vs speed scatter plot.
Args:
workout: WorkoutData object
Returns:
Path to saved chart
"""
if (not workout.power or not workout.power.power_values or
not workout.speed or not workout.speed.speed_values):
return None
power_values = workout.power.power_values
speed_values = workout.speed.speed_values
# Align arrays
min_len = min(len(power_values), len(speed_values))
if min_len == 0:
return None
power_values = power_values[:min_len]
speed_values = speed_values[:min_len]
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(power_values, speed_values, alpha=0.5, s=1)
# Add trend line
z = np.polyfit(power_values, speed_values, 1)
p = np.poly1d(z)
ax.plot(power_values, p(power_values), "r--", alpha=0.8)
ax.set_xlabel('Power (W)')
ax.set_ylabel('Speed (km/h)')
ax.set_title('Power vs Speed')
ax.grid(True, alpha=0.3)
filepath = self.output_dir / 'power_vs_speed.png'
plt.tight_layout()
plt.savefig(filepath, dpi=300, bbox_inches='tight')
plt.close()
return str(filepath)
def _create_workout_dashboard(self, workout: WorkoutData, analysis: Dict[str, Any]) -> str:
"""Create comprehensive workout dashboard.
Args:
workout: WorkoutData object
analysis: Analysis results
Returns:
Path to saved chart
"""
fig = make_subplots(
rows=3, cols=2,
subplot_titles=('Power Over Time', 'Heart Rate Over Time',
'Speed Over Time', 'Elevation Profile',
'Power Distribution', 'Heart Rate Distribution'),
specs=[[{"secondary_y": False}, {"secondary_y": False}],
[{"secondary_y": False}, {"secondary_y": False}],
[{"secondary_y": False}, {"secondary_y": False}]]
)
# Power time series
if workout.power and workout.power.power_values:
power_values = workout.power.power_values
time_minutes = np.arange(len(power_values)) / 60
fig.add_trace(
go.Scatter(x=time_minutes, y=power_values, name='Power', line=dict(color='orange')),
row=1, col=1
)
# Heart rate time series
if workout.heart_rate and workout.heart_rate.heart_rate_values:
hr_values = workout.heart_rate.heart_rate_values
time_minutes = np.arange(len(hr_values)) / 60
fig.add_trace(
go.Scatter(x=time_minutes, y=hr_values, name='Heart Rate', line=dict(color='red')),
row=1, col=2
)
# Speed time series
if workout.speed and workout.speed.speed_values:
speed_values = workout.speed.speed_values
time_minutes = np.arange(len(speed_values)) / 60
fig.add_trace(
go.Scatter(x=time_minutes, y=speed_values, name='Speed', line=dict(color='blue')),
row=2, col=1
)
# Elevation profile
if workout.elevation and workout.elevation.elevation_values:
elevation_values = workout.elevation.elevation_values
time_minutes = np.arange(len(elevation_values)) / 60
fig.add_trace(
go.Scatter(x=time_minutes, y=elevation_values, name='Elevation', line=dict(color='brown')),
row=2, col=2
)
# Power distribution
if workout.power and workout.power.power_values:
power_values = workout.power.power_values
fig.add_trace(
go.Histogram(x=power_values, name='Power Distribution', nbinsx=50),
row=3, col=1
)
# Heart rate distribution
if workout.heart_rate and workout.heart_rate.heart_rate_values:
hr_values = workout.heart_rate.heart_rate_values
fig.add_trace(
go.Histogram(x=hr_values, name='HR Distribution', nbinsx=30),
row=3, col=2
)
# Update layout
fig.update_layout(
height=1200,
title_text=f"Workout Dashboard - {workout.metadata.activity_name}",
showlegend=False
)
# Update axes labels
fig.update_xaxes(title_text="Time (minutes)", row=1, col=1)
fig.update_yaxes(title_text="Power (W)", row=1, col=1)
fig.update_xaxes(title_text="Time (minutes)", row=1, col=2)
fig.update_yaxes(title_text="Heart Rate (bpm)", row=1, col=2)
fig.update_xaxes(title_text="Time (minutes)", row=2, col=1)
fig.update_yaxes(title_text="Speed (km/h)", row=2, col=1)
fig.update_xaxes(title_text="Time (minutes)", row=2, col=2)
fig.update_yaxes(title_text="Elevation (m)", row=2, col=2)
fig.update_xaxes(title_text="Power (W)", row=3, col=1)
fig.update_xaxes(title_text="Heart Rate (bpm)", row=3, col=2)
filepath = self.output_dir / 'workout_dashboard.html'
fig.write_html(str(filepath))
return str(filepath)

View File

@@ -0,0 +1,526 @@
"""Report generator for creating comprehensive workout reports."""
import logging
from pathlib import Path
from typing import Dict, Any, List, Optional
from datetime import datetime
import jinja2
import pandas as pd
from markdown import markdown
from weasyprint import HTML, CSS
import json
from ..models.workout import WorkoutData
logger = logging.getLogger(__name__)
class ReportGenerator:
"""Generate comprehensive workout reports in various formats."""
def __init__(self, template_dir: Path = None):
"""Initialize report generator.
Args:
template_dir: Directory containing report templates
"""
self.template_dir = template_dir or Path(__file__).parent / 'templates'
self.template_dir.mkdir(exist_ok=True)
# Initialize Jinja2 environment
self.jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(self.template_dir),
autoescape=jinja2.select_autoescape(['html', 'xml'])
)
# Add custom filters
self.jinja_env.filters['format_duration'] = self._format_duration
self.jinja_env.filters['format_distance'] = self._format_distance
self.jinja_env.filters['format_speed'] = self._format_speed
self.jinja_env.filters['format_power'] = self._format_power
self.jinja_env.filters['format_heart_rate'] = self._format_heart_rate
def generate_workout_report(self, workout: WorkoutData, analysis: Dict[str, Any],
format: str = 'html') -> str:
"""Generate comprehensive workout report.
Args:
workout: WorkoutData object
analysis: Analysis results from WorkoutAnalyzer
format: Report format ('html', 'pdf', 'markdown')
Returns:
Path to generated report
"""
# Prepare report data
report_data = self._prepare_report_data(workout, analysis)
# Generate report based on format
if format == 'html':
return self._generate_html_report(report_data)
elif format == 'pdf':
return self._generate_pdf_report(report_data)
elif format == 'markdown':
return self._generate_markdown_report(report_data)
else:
raise ValueError(f"Unsupported format: {format}")
def _prepare_report_data(self, workout: WorkoutData, analysis: Dict[str, Any]) -> Dict[str, Any]:
"""Prepare data for report generation.
Args:
workout: WorkoutData object
analysis: Analysis results
Returns:
Dictionary with report data
"""
return {
'workout': {
'metadata': workout.metadata,
'summary': analysis.get('summary', {}),
'power_analysis': analysis.get('power_analysis', {}),
'heart_rate_analysis': analysis.get('heart_rate_analysis', {}),
'speed_analysis': analysis.get('speed_analysis', {}),
'elevation_analysis': analysis.get('elevation_analysis', {}),
'intervals': analysis.get('intervals', []),
'zones': analysis.get('zones', {}),
'efficiency': analysis.get('efficiency', {})
},
'report': {
'generated_at': datetime.now().isoformat(),
'version': '1.0.0',
'tool': 'Garmin Analyser'
}
}
def _generate_html_report(self, report_data: Dict[str, Any]) -> str:
"""Generate HTML report.
Args:
report_data: Report data
Returns:
Path to generated HTML report
"""
template = self.jinja_env.get_template('workout_report.html')
html_content = template.render(**report_data)
output_path = Path('reports') / f"workout_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
output_path.parent.mkdir(exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
return str(output_path)
def _generate_pdf_report(self, report_data: Dict[str, Any]) -> str:
"""Generate PDF report.
Args:
report_data: Report data
Returns:
Path to generated PDF report
"""
# First generate HTML
html_path = self._generate_html_report(report_data)
# Convert to PDF
pdf_path = html_path.replace('.html', '.pdf')
HTML(html_path).write_pdf(pdf_path)
return pdf_path
def _generate_markdown_report(self, report_data: Dict[str, Any]) -> str:
"""Generate Markdown report.
Args:
report_data: Report data
Returns:
Path to generated Markdown report
"""
template = self.jinja_env.get_template('workout_report.md')
markdown_content = template.render(**report_data)
output_path = Path('reports') / f"workout_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
output_path.parent.mkdir(exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(markdown_content)
return str(output_path)
def generate_summary_report(self, workouts: List[WorkoutData],
analyses: List[Dict[str, Any]]) -> str:
"""Generate summary report for multiple workouts.
Args:
workouts: List of WorkoutData objects
analyses: List of analysis results
Returns:
Path to generated summary report
"""
# Aggregate data
summary_data = self._aggregate_workout_data(workouts, analyses)
# Generate summary report
template = self.jinja_env.get_template('summary_report.html')
html_content = template.render(**summary_data)
output_path = Path('reports') / f"summary_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
output_path.parent.mkdir(exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
return str(output_path)
def _aggregate_workout_data(self, workouts: List[WorkoutData],
analyses: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Aggregate data from multiple workouts.
Args:
workouts: List of WorkoutData objects
analyses: List of analysis results
Returns:
Dictionary with aggregated data
"""
# Create DataFrame for analysis
workout_data = []
for workout, analysis in zip(workouts, analyses):
data = {
'date': workout.metadata.start_time,
'activity_type': workout.metadata.activity_type,
'duration_minutes': analysis.get('summary', {}).get('duration_minutes', 0),
'distance_km': analysis.get('summary', {}).get('distance_km', 0),
'avg_power': analysis.get('summary', {}).get('avg_power', 0),
'avg_heart_rate': analysis.get('summary', {}).get('avg_heart_rate', 0),
'avg_speed': analysis.get('summary', {}).get('avg_speed_kmh', 0),
'elevation_gain': analysis.get('summary', {}).get('elevation_gain_m', 0),
'calories': analysis.get('summary', {}).get('calories', 0),
'tss': analysis.get('summary', {}).get('training_stress_score', 0)
}
workout_data.append(data)
df = pd.DataFrame(workout_data)
# Calculate aggregations
aggregations = {
'total_workouts': len(workouts),
'total_duration_hours': df['duration_minutes'].sum() / 60,
'total_distance_km': df['distance_km'].sum(),
'total_elevation_m': df['elevation_gain'].sum(),
'total_calories': df['calories'].sum(),
'avg_workout_duration': df['duration_minutes'].mean(),
'avg_power': df['avg_power'].mean(),
'avg_heart_rate': df['avg_heart_rate'].mean(),
'avg_speed': df['avg_speed'].mean(),
'total_tss': df['tss'].sum(),
'weekly_tss': df['tss'].sum() / 4, # Assuming 4 weeks
'workouts_by_type': df['activity_type'].value_counts().to_dict(),
'weekly_volume': df.groupby(pd.Grouper(key='date', freq='W'))['duration_minutes'].sum().to_dict()
}
return {
'workouts': workouts,
'analyses': analyses,
'aggregations': aggregations,
'report': {
'generated_at': datetime.now().isoformat(),
'version': '1.0.0',
'tool': 'Garmin Analyser'
}
}
def _format_duration(self, seconds: float) -> str:
"""Format duration in seconds to human-readable format.
Args:
seconds: Duration in seconds
Returns:
Formatted duration string
"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
seconds = int(seconds % 60)
if hours > 0:
return f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
return f"{minutes}m {seconds}s"
else:
return f"{seconds}s"
def _format_distance(self, meters: float) -> str:
"""Format distance in meters to human-readable format.
Args:
meters: Distance in meters
Returns:
Formatted distance string
"""
if meters >= 1000:
return f"{meters/1000:.2f} km"
else:
return f"{meters:.0f} m"
def _format_speed(self, kmh: float) -> str:
"""Format speed in km/h to human-readable format.
Args:
kmh: Speed in km/h
Returns:
Formatted speed string
"""
return f"{kmh:.1f} km/h"
def _format_power(self, watts: float) -> str:
"""Format power in watts to human-readable format.
Args:
watts: Power in watts
Returns:
Formatted power string
"""
return f"{watts:.0f} W"
def _format_heart_rate(self, bpm: float) -> str:
"""Format heart rate in bpm to human-readable format.
Args:
bpm: Heart rate in bpm
Returns:
Formatted heart rate string
"""
return f"{bpm:.0f} bpm"
def create_report_templates(self):
"""Create default report templates."""
self.template_dir.mkdir(exist_ok=True)
# HTML template
html_template = """<!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>"""
with open(self.template_dir / 'workout_report.html', 'w') as f:
f.write(html_template)
# Markdown template
md_template = """# 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 }}*"""
with open(self.template_dir / 'workout_report.md', 'w') as f:
f.write(md_template)
logger.info("Report templates created successfully")