"""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:
Rendered report content as a string (for html/markdown) or path to PDF file.
"""
# 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, workout.metadata.activity_name)
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
"""
# Normalize and alias data for template compatibility
summary = analysis.get('summary', {})
summary['avg_speed'] = summary.get('avg_speed_kmh')
summary['avg_heart_rate'] = summary.get('avg_hr')
power_analysis = analysis.get('power_analysis', {})
if 'avg_power' not in power_analysis and 'avg_power' in summary:
power_analysis['avg_power'] = summary['avg_power']
if 'max_power' not in power_analysis and 'max_power' in summary:
power_analysis['max_power'] = summary['max_power']
heart_rate_analysis = analysis.get('heart_rate_analysis', {})
if 'avg_hr' not in heart_rate_analysis and 'avg_hr' in summary:
heart_rate_analysis['avg_hr'] = summary['avg_hr']
if 'max_hr' not in heart_rate_analysis and 'max_hr' in summary:
heart_rate_analysis['max_hr'] = summary['max_hr']
# For templates using avg_heart_rate
heart_rate_analysis['avg_heart_rate'] = heart_rate_analysis.get('avg_hr')
heart_rate_analysis['max_heart_rate'] = heart_rate_analysis.get('max_hr')
speed_analysis = analysis.get('speed_analysis', {})
speed_analysis['avg_speed'] = speed_analysis.get('avg_speed_kmh')
speed_analysis['max_speed'] = speed_analysis.get('max_speed_kmh')
report_context = {
"workout": {
"metadata": workout.metadata,
"summary": summary,
"power_analysis": power_analysis,
"heart_rate_analysis": heart_rate_analysis,
"speed_analysis": 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",
},
}
# Add minute-by-minute aggregation if data is available
if workout.df is not None and not workout.df.empty:
report_context["minute_by_minute"] = self._aggregate_minute_by_minute(
workout.df, analysis
)
return report_context
def _generate_html_report(self, report_data: Dict[str, Any]) -> str:
"""Generate HTML report.
Args:
report_data: Report data
Returns:
Rendered HTML content as a string.
"""
template = self.jinja_env.get_template('workout_report.html')
html_content = template.render(**report_data)
# In a real application, you might save this to a file or return it directly
# For testing, we return the content directly
return html_content
def _generate_pdf_report(self, report_data: Dict[str, Any], activity_name: str) -> str:
"""Generate PDF report.
Args:
report_data: Report data
activity_name: Name of the activity for the filename.
Returns:
Path to generated PDF report.
"""
html_content = self._generate_html_report(report_data)
output_dir = Path('reports')
output_dir.mkdir(exist_ok=True)
# Sanitize activity_name for filename
sanitized_activity_name = "".join(
[c if c.isalnum() or c in (' ', '-', '_') else '_' for c in activity_name]
).replace(' ', '_')
pdf_path = output_dir / f"{sanitized_activity_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
HTML(string=html_content).write_pdf(str(pdf_path))
return str(pdf_path)
def _generate_markdown_report(self, report_data: Dict[str, Any]) -> str:
"""Generate Markdown report.
Args:
report_data: Report data
Returns:
Rendered Markdown content as a string.
"""
template = self.jinja_env.get_template('workout_report.md')
markdown_content = template.render(**report_data)
# In a real application, you might save this to a file or return it directly
# For testing, we return the content directly
return markdown_content
def generate_summary_report(self, workouts: List[WorkoutData],
analyses: List[Dict[str, Any]],
format: str = 'html') -> str:
"""Generate summary report for multiple workouts.
Args:
workouts: List of WorkoutData objects
analyses: List of analysis results
format: Report format ('html', 'pdf', 'markdown')
Returns:
Rendered summary report content as a string (for html/markdown) or path to PDF file.
"""
# Aggregate data
summary_data = self._aggregate_workout_data(workouts, analyses)
# Generate report based on format
if format == 'html':
template = self.jinja_env.get_template("summary_report.html")
return template.render(**summary_data)
elif format == 'pdf':
html_content = self._generate_summary_html_report(summary_data)
output_dir = Path('reports')
output_dir.mkdir(exist_ok=True)
pdf_path = output_dir / f"summary_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
HTML(string=html_content).write_pdf(str(pdf_path))
return str(pdf_path)
elif format == 'markdown':
template = self.jinja_env.get_template('summary_report.md')
return template.render(**summary_data)
else:
raise ValueError(f"Unsupported format: {format}")
def _generate_summary_html_report(self, report_data: Dict[str, Any]) -> str:
"""Helper to generate HTML for summary report, used by PDF generation."""
template = self.jinja_env.get_template('summary_report.html')
return template.render(**report_data)
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.sport or 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_hr', 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 _aggregate_minute_by_minute(
self, df: pd.DataFrame, analysis: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Aggregate workout data into minute-by-minute summaries.
Args:
df: Workout DataFrame.
analysis: Analysis results.
Returns:
A list of dictionaries, each representing one minute of the workout.
"""
if "timestamp" not in df.columns:
return []
df = df.copy()
df["elapsed_time"] = (
df["timestamp"] - df["timestamp"].iloc[0]
).dt.total_seconds()
df["minute_index"] = (df["elapsed_time"] // 60).astype(int)
agg_rules = {}
if "speed" in df.columns:
agg_rules["avg_speed_kmh"] = ("speed", "mean")
if "cadence" in df.columns:
agg_rules["avg_cadence"] = ("cadence", "mean")
if "heart_rate" in df.columns:
agg_rules["avg_hr"] = ("heart_rate", "mean")
agg_rules["max_hr"] = ("heart_rate", "max")
if "power" in df.columns:
agg_rules["avg_real_power"] = ("power", "mean")
elif "estimated_power" in df.columns:
agg_rules["avg_power_estimate"] = ("estimated_power", "mean")
if not agg_rules:
return []
minute_stats = df.groupby("minute_index").agg(**agg_rules).reset_index()
# Distance and elevation require special handling
if "distance" in df.columns:
minute_stats["distance_km"] = (
df.groupby("minute_index")["distance"]
.apply(lambda x: (x.max() - x.min()) / 1000.0)
.values
)
if "altitude" in df.columns:
minute_stats["elevation_change"] = (
df.groupby("minute_index")["altitude"]
.apply(lambda x: x.iloc[-1] - x.iloc[0] if not x.empty else 0)
.values
)
if "gradient" in df.columns:
minute_stats["avg_gradient"] = (
df.groupby("minute_index")["gradient"].mean().values
)
# Convert to km/h if speed is in m/s
if "avg_speed_kmh" in minute_stats.columns:
minute_stats["avg_speed_kmh"] *= 3.6
# Round and format
for col in minute_stats.columns:
if minute_stats[col].dtype == "float64":
minute_stats[col] = minute_stats[col].round(2)
return minute_stats.to_dict("records")
def _format_duration(self, seconds: float) -> str:
"""Format duration in seconds to human-readable format.
Args:
seconds: Duration in seconds
Returns:
Formatted duration string
"""
if pd.isna(seconds):
return ""
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 = """
Workout Report - {{ workout.metadata.activity_name }}
Workout Report: {{ workout.metadata.activity_name }}
Date: {{ workout.metadata.start_time }}
Activity Type: {{ workout.metadata.activity_type }}
Summary
Duration
{{ workout.summary.duration_minutes|format_duration }}
Distance
{{ workout.summary.distance_km|format_distance }}
Avg Power
{{ workout.summary.avg_power|format_power }}
Avg Heart Rate
{{ workout.summary.avg_heart_rate|format_heart_rate }}
Avg Speed
{{ workout.summary.avg_speed_kmh|format_speed }}
Calories
{{ workout.summary.calories|int }}
Detailed Analysis
Power Analysis
| Metric |
Value |
| 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
| Metric |
Value |
| 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
| Metric |
Value |
| Average Speed |
{{ workout.speed_analysis.avg_speed|format_speed }} |
| Maximum Speed |
{{ workout.speed_analysis.max_speed|format_speed }} |
{% if minute_by_minute %}
Minute-by-Minute Analysis
| Minute |
Distance (km) |
Avg Speed (km/h) |
Avg Cadence |
Avg HR |
Max HR |
Avg Gradient (%) |
Elevation Change (m) |
Avg Power (W) |
{% for row in minute_by_minute %}
| {{ row.minute_index }} |
{{ "%.2f"|format(row.distance_km) if row.distance_km is not none }} |
{{ "%.1f"|format(row.avg_speed_kmh) if row.avg_speed_kmh is not none }} |
{{ "%.0f"|format(row.avg_cadence) if row.avg_cadence is not none }} |
{{ "%.0f"|format(row.avg_hr) if row.avg_hr is not none }} |
{{ "%.0f"|format(row.max_hr) if row.max_hr is not none }} |
{{ "%.1f"|format(row.avg_gradient) if row.avg_gradient is not none }} |
{{ "%.1f"|format(row.elevation_change) if row.elevation_change is not none }} |
{{ "%.0f"|format(row.avg_real_power or row.avg_power_estimate) if (row.avg_real_power or row.avg_power_estimate) is not none }} |
{% endfor %}
{% endif %}
"""
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 }}
{% if minute_by_minute %}
### Minute-by-Minute Analysis
| Minute | Dist (km) | Speed (km/h) | Cadence | HR | Max HR | Grad (%) | Elev (m) | Power (W) |
|--------|-----------|--------------|---------|----|--------|----------|----------|-----------|
{% for row in minute_by_minute -%}
| {{ row.minute_index }} | {{ "%.2f"|format(row.distance_km) if row.distance_km is not none }} | {{ "%.1f"|format(row.avg_speed_kmh) if row.avg_speed_kmh is not none }} | {{ "%.0f"|format(row.avg_cadence) if row.avg_cadence is not none }} | {{ "%.0f"|format(row.avg_hr) if row.avg_hr is not none }} | {{ "%.0f"|format(row.max_hr) if row.max_hr is not none }} | {{ "%.1f"|format(row.avg_gradient) if row.avg_gradient is not none }} | {{ "%.1f"|format(row.elevation_change) if row.elevation_change is not none }} | {{ "%.0f"|format(row.avg_real_power or row.avg_power_estimate) if (row.avg_real_power or row.avg_power_estimate) is not none }} |
{% endfor %}
{% endif %}
---
*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")