")
partial_row = [r for r in rows if "2024-01-02" in r][0]
cells = partial_row.split("| ")
# NP, IF, TSS are the last 3 cells. They should be empty or just contain whitespace.
assert " | " * 3 in partial_row.replace(" ", "").replace("\n", "")
assert " | " * 3 in partial_row.replace(" ", "").replace("\n", "")
================================================
FILE: tests/test_template_rendering_normalized_vars.py
================================================
"""
Tests for template rendering with normalized variables.
Validates that [ReportGenerator](visualizers/report_generator.py) can render
HTML and Markdown templates using normalized keys from analysis and metadata.
"""
import pytest
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
from analyzers.workout_analyzer import WorkoutAnalyzer
from models.workout import WorkoutData, WorkoutMetadata, SpeedData, HeartRateData
from visualizers.report_generator import ReportGenerator
from tests.test_analyzer_speed_and_normalized_naming import synthetic_workout_data
@pytest.fixture
def analysis_result(synthetic_workout_data):
"""Get analysis result from synthetic workout data."""
analyzer = WorkoutAnalyzer()
return analyzer.analyze_workout(synthetic_workout_data)
def test_template_rendering_with_normalized_variables(synthetic_workout_data, analysis_result):
"""
Test that HTML and Markdown templates render successfully with normalized
and sport/sub_sport variables.
Validates that templates can access:
- metadata.sport and metadata.sub_sport
- summary.avg_speed_kmh and summary.avg_hr
"""
report_gen = ReportGenerator()
# Test HTML template rendering
try:
html_output = report_gen.generate_workout_report(synthetic_workout_data, analysis_result, format='html')
assert isinstance(html_output, str)
assert len(html_output) > 0
# Check that sport and sub_sport appear in rendered output
assert synthetic_workout_data.metadata.sport in html_output
assert synthetic_workout_data.metadata.sub_sport in html_output
# Check that normalized keys appear (as numeric values)
# Check that normalized keys appear (as plausible numeric values)
assert "Average Speed\n 7.4 km/h" in html_output
assert "Average Heart Rate | \n 133 bpm" in html_output
except Exception as e:
pytest.fail(f"HTML template rendering failed: {e}")
# Test Markdown template rendering
try:
md_output = report_gen.generate_workout_report(synthetic_workout_data, analysis_result, format='markdown')
assert isinstance(md_output, str)
assert len(md_output) > 0
# Check that sport and sub_sport appear in rendered output
assert synthetic_workout_data.metadata.sport in md_output
assert synthetic_workout_data.metadata.sub_sport in md_output
# Check that normalized keys appear (as numeric values)
# Check that normalized keys appear (as plausible numeric values)
assert "Average Speed | 7.4 km/h" in md_output
assert "Average Heart Rate | 133 bpm" in md_output
except Exception as e:
pytest.fail(f"Markdown template rendering failed: {e}")
================================================
FILE: tests/test_workout_templates_minute_section.py
================================================
import pytest
from visualizers.report_generator import ReportGenerator
@pytest.fixture
def report_generator():
return ReportGenerator()
def _get_base_context():
"""Provides a minimal, valid context for rendering."""
return {
"workout": {
"metadata": {
"sport": "Cycling",
"sub_sport": "Road",
"start_time": "2024-01-01 10:00:00",
"total_duration": 120,
"total_distance_km": 5.0,
"avg_speed_kmh": 25.0,
"avg_hr": 150,
"avg_power": 200,
},
"summary": {
"np": 210,
"if": 0.8,
"tss": 30,
},
"zones": {},
"charts": {},
},
"report": {
"generated_at": "2024-01-01T12:00:00",
"version": "1.0.0",
},
}
def test_workout_report_renders_minute_section_when_present(report_generator):
context = _get_base_context()
context["minute_by_minute"] = [
{
"minute_index": 0,
"distance_km": 0.5,
"avg_speed_kmh": 30.0,
"avg_cadence": 90,
"avg_hr": 140,
"max_hr": 145,
"avg_gradient": 1.0,
"elevation_change": 5,
"avg_real_power": 210,
"avg_power_estimate": None,
}
]
# Test HTML
html_output = report_generator.generate_workout_report(context, None, "html")
assert "Minute-by-Minute Breakdown" in html_output
assert " | Minute | " in html_output
assert "0.50 | " in html_output # distance_km
assert "30.0 | " in html_output # avg_speed_kmh
assert "140 | " in html_output # avg_hr
assert "210 | " in html_output # avg_real_power
# Test Markdown
md_output = report_generator.generate_workout_report(context, None, "md")
assert "### Minute-by-Minute Breakdown" in md_output
assert "| Minute |" in md_output
assert "| 0.50 |" in md_output
assert "| 30.0 |" in md_output
assert "| 140 |" in md_output
assert "| 210 |" in md_output
def test_workout_report_omits_minute_section_when_absent(report_generator):
context = _get_base_context()
# Case 1: key is absent
context_absent = context.copy()
html_output_absent = report_generator.generate_workout_report(
context_absent, None, "html"
)
assert "Minute-by-Minute Breakdown
" not in html_output_absent
md_output_absent = report_generator.generate_workout_report(
context_absent, None, "md"
)
assert "### Minute-by-Minute Breakdown" not in md_output_absent
# Case 2: key is present but empty
context_empty = context.copy()
context_empty["minute_by_minute"] = []
html_output_empty = report_generator.generate_workout_report(
context_empty, None, "html"
)
assert "Minute-by-Minute Breakdown
" not in html_output_empty
md_output_empty = report_generator.generate_workout_report(
context_empty, None, "md"
)
assert "### Minute-by-Minute Breakdown" not in md_output_empty
================================================
FILE: utils/__init__.py
================================================
================================================
FILE: utils/gear_estimation.py
================================================
"""Gear estimation utilities for cycling workouts."""
import numpy as np
import pandas as pd
from typing import Dict, Any, Optional
from config.settings import BikeConfig
def estimate_gear_series(
df: pd.DataFrame,
wheel_circumference_m: float = BikeConfig.TIRE_CIRCUMFERENCE_M,
valid_configurations: dict = BikeConfig.VALID_CONFIGURATIONS,
) -> pd.Series:
"""Estimate gear per sample using speed and cadence data.
Args:
df: DataFrame with 'speed_mps' and 'cadence_rpm' columns
wheel_circumference_m: Wheel circumference in meters
valid_configurations: Dict of chainring -> list of cogs
Returns:
Series with gear strings (e.g., '38x16') aligned to input index
"""
pass
def compute_gear_summary(gear_series: pd.Series) -> dict:
"""Compute summary statistics from gear series.
Args:
gear_series: Series of gear strings
Returns:
Dict with summary metrics
"""
pass
================================================
FILE: visualizers/__init__.py
================================================
"""Visualization modules for workout data."""
from .chart_generator import ChartGenerator
from .report_generator import ReportGenerator
__all__ = ['ChartGenerator', 'ReportGenerator']
================================================
FILE: visualizers/chart_generator.py
================================================
"""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
from models.zones import ZoneCalculator
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)
self.zone_calculator = ZoneCalculator()
# Set style
plt.style.use('seaborn-v0_8')
sns.set_palette("husl")
def _get_avg_max_values(self, analysis: Dict[str, Any], data_type: str, workout: WorkoutData) -> Tuple[float, float]:
"""Get avg and max values from analysis dict or compute from workout data.
Args:
analysis: Analysis results from WorkoutAnalyzer
data_type: 'power', 'hr', or 'speed'
workout: WorkoutData object
Returns:
Tuple of (avg_value, max_value)
"""
if analysis and 'summary' in analysis:
summary = analysis['summary']
if data_type == 'power':
avg_key, max_key = 'avg_power', 'max_power'
elif data_type == 'hr':
avg_key, max_key = 'avg_hr', 'max_hr'
elif data_type == 'speed':
avg_key, max_key = 'avg_speed_kmh', 'max_speed_kmh'
else:
raise ValueError(f"Unsupported data_type: {data_type}")
avg_val = summary.get(avg_key)
max_val = summary.get(max_key)
if avg_val is not None and max_val is not None:
return avg_val, max_val
# Fallback: compute from workout data
if data_type == 'power' and workout.power and workout.power.power_values:
return np.mean(workout.power.power_values), np.max(workout.power.power_values)
elif data_type == 'hr' and workout.heart_rate and workout.heart_rate.heart_rate_values:
return np.mean(workout.heart_rate.heart_rate_values), np.max(workout.heart_rate.heart_rate_values)
elif data_type == 'speed' and workout.speed and workout.speed.speed_values:
return np.mean(workout.speed.speed_values), np.max(workout.speed.speed_values)
# Default fallback
return 0, 0
def _get_avg_max_labels(self, data_type: str, analysis: Dict[str, Any], workout: WorkoutData) -> Tuple[str, str]:
"""Get formatted average and maximum labels for chart annotations.
Args:
data_type: 'power', 'hr', or 'speed'
analysis: Analysis results from WorkoutAnalyzer
workout: WorkoutData object
Returns:
Tuple of (avg_label, max_label)
"""
avg_val, max_val = self._get_avg_max_values(analysis, data_type, workout)
if data_type == 'power':
avg_label = f'Avg: {avg_val:.0f}W'
max_label = f'Max: {max_val:.0f}W'
elif data_type == 'hr':
avg_label = f'Avg: {avg_val:.0f} bpm'
max_label = f'Max: {max_val:.0f} bpm'
elif data_type == 'speed':
avg_label = f'Avg: {avg_val:.1f} km/h'
max_label = f'Max: {max_val:.1f} km/h'
else:
avg_label = f'Avg: {avg_val:.1f}'
max_label = f'Max: {max_val:.1f}'
return avg_label, max_label
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, analysis, elevation_overlay=True, zone_shading=True)
charts['heart_rate_time_series'] = self._create_heart_rate_time_series(workout, analysis, elevation_overlay=True)
charts['speed_time_series'] = self._create_speed_time_series(workout, analysis, elevation_overlay=True)
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, analysis: Dict[str, Any] = None, elevation_overlay: bool = True, zone_shading: bool = True) -> str:
"""Create power vs time chart.
Args:
workout: WorkoutData object
analysis: Analysis results from WorkoutAnalyzer
elevation_overlay: Whether to add an elevation overlay
zone_shading: Whether to add power zone shading
Returns:
Path to saved chart
"""
if not workout.power or not workout.power.power_values:
return None
fig, ax1 = plt.subplots(figsize=(12, 6))
power_values = workout.power.power_values
time_minutes = np.arange(len(power_values)) / 60
# Plot power
ax1.plot(time_minutes, power_values, linewidth=0.5, alpha=0.8, color='blue')
ax1.set_xlabel('Time (minutes)')
ax1.set_ylabel('Power (W)', color='blue')
ax1.tick_params(axis='y', labelcolor='blue')
# Add avg/max annotations from analysis or fallback
avg_power_label, max_power_label = self._get_avg_max_labels('power', analysis, workout)
ax1.axhline(y=self._get_avg_max_values(analysis, 'power', workout)[0], color='red', linestyle='--',
label=avg_power_label)
ax1.axhline(y=self._get_avg_max_values(analysis, 'power', workout)[1], color='green', linestyle='--',
label=max_power_label)
# Add power zone shading
if zone_shading and analysis and 'power_analysis' in analysis:
power_zones = self.zone_calculator.get_power_zones()
# Try to get FTP from analysis, otherwise use a default or the zone calculator's default
ftp = analysis.get('power_analysis', {}).get('ftp', 250) # Fallback to 250W if not in analysis
# Recalculate zones based on FTP percentage
power_zones_percent = {
'Recovery': {'min': 0, 'max': 0.5}, # <50% FTP
'Endurance': {'min': 0.5, 'max': 0.75}, # 50-75% FTP
'Tempo': {'min': 0.75, 'max': 0.9}, # 75-90% FTP
'Threshold': {'min': 0.9, 'max': 1.05}, # 90-105% FTP
'VO2 Max': {'min': 1.05, 'max': 1.2}, # 105-120% FTP
'Anaerobic': {'min': 1.2, 'max': 10} # >120% FTP (arbitrary max for shading)
}
for zone_name, zone_def_percent in power_zones_percent.items():
min_power = ftp * zone_def_percent['min']
max_power = ftp * zone_def_percent['max']
# Find the corresponding ZoneDefinition to get the color
zone_color = next((z.color for z_name, z in power_zones.items() if z_name == zone_name), 'grey')
ax1.axhspan(min_power, max_power,
alpha=0.1, color=zone_color,
label=f'{zone_name} ({min_power:.0f}-{max_power:.0f}W)')
# Add elevation overlay if available
if elevation_overlay and workout.elevation and workout.elevation.elevation_values:
# Create twin axis for elevation
ax2 = ax1.twinx()
elevation_values = workout.elevation.elevation_values
# Apply light smoothing to elevation for visual stability
# Using a simple rolling mean, NaN-safe
elevation_smoothed = pd.Series(elevation_values).rolling(window=5, min_periods=1, center=True).mean().values
# Align lengths (assume same sampling rate)
min_len = min(len(power_values), len(elevation_smoothed))
elevation_aligned = elevation_smoothed[:min_len]
time_aligned = time_minutes[:min_len]
ax2.fill_between(time_aligned, elevation_aligned, alpha=0.2, color='brown', label='Elevation')
ax2.set_ylabel('Elevation (m)', color='brown')
ax2.tick_params(axis='y', labelcolor='brown')
# Combine legends
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
else:
ax1.legend()
ax1.set_title('Power Over Time')
ax1.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, analysis: Dict[str, Any] = None, elevation_overlay: bool = True) -> str:
"""Create heart rate vs time chart.
Args:
workout: WorkoutData object
analysis: Analysis results from WorkoutAnalyzer
elevation_overlay: Whether to add an elevation overlay
Returns:
Path to saved chart
"""
if not workout.heart_rate or not workout.heart_rate.heart_rate_values:
return None
fig, ax1 = plt.subplots(figsize=(12, 6))
hr_values = workout.heart_rate.heart_rate_values
time_minutes = np.arange(len(hr_values)) / 60
# Plot heart rate
ax1.plot(time_minutes, hr_values, linewidth=0.5, alpha=0.8, color='red')
ax1.set_xlabel('Time (minutes)')
ax1.set_ylabel('Heart Rate (bpm)', color='red')
ax1.tick_params(axis='y', labelcolor='red')
# Add avg/max annotations from analysis or fallback
avg_hr_label, max_hr_label = self._get_avg_max_labels('hr', analysis, workout)
ax1.axhline(y=self._get_avg_max_values(analysis, 'hr', workout)[0], color='darkred', linestyle='--',
label=avg_hr_label)
ax1.axhline(y=self._get_avg_max_values(analysis, 'hr', workout)[1], color='darkgreen', linestyle='--',
label=max_hr_label)
# Add elevation overlay if available
if elevation_overlay and workout.elevation and workout.elevation.elevation_values:
# Create twin axis for elevation
ax2 = ax1.twinx()
elevation_values = workout.elevation.elevation_values
# Apply light smoothing to elevation for visual stability
elevation_smoothed = pd.Series(elevation_values).rolling(window=5, min_periods=1, center=True).mean().values
# Align lengths (assume same sampling rate)
min_len = min(len(hr_values), len(elevation_smoothed))
elevation_aligned = elevation_smoothed[:min_len]
time_aligned = time_minutes[:min_len]
ax2.fill_between(time_aligned, elevation_aligned, alpha=0.2, color='brown', label='Elevation')
ax2.set_ylabel('Elevation (m)', color='brown')
ax2.tick_params(axis='y', labelcolor='brown')
# Combine legends
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
else:
ax1.legend()
ax1.set_title('Heart Rate Over Time')
ax1.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, analysis: Dict[str, Any] = None, elevation_overlay: bool = True) -> str:
"""Create speed vs time chart.
Args:
workout: WorkoutData object
analysis: Analysis results from WorkoutAnalyzer
elevation_overlay: Whether to add an elevation overlay
Returns:
Path to saved chart
"""
if not workout.speed or not workout.speed.speed_values:
return None
fig, ax1 = plt.subplots(figsize=(12, 6))
speed_values = workout.speed.speed_values
time_minutes = np.arange(len(speed_values)) / 60
# Plot speed
ax1.plot(time_minutes, speed_values, linewidth=0.5, alpha=0.8, color='blue')
ax1.set_xlabel('Time (minutes)')
ax1.set_ylabel('Speed (km/h)', color='blue')
ax1.tick_params(axis='y', labelcolor='blue')
# Add avg/max annotations from analysis or fallback
avg_speed_label, max_speed_label = self._get_avg_max_labels('speed', analysis, workout)
ax1.axhline(y=self._get_avg_max_values(analysis, 'speed', workout)[0], color='darkblue', linestyle='--',
label=avg_speed_label)
ax1.axhline(y=self._get_avg_max_values(analysis, 'speed', workout)[1], color='darkgreen', linestyle='--',
label=max_speed_label)
# Add elevation overlay if available
if elevation_overlay and workout.elevation and workout.elevation.elevation_values:
# Create twin axis for elevation
ax2 = ax1.twinx()
elevation_values = workout.elevation.elevation_values
# Apply light smoothing to elevation for visual stability
elevation_smoothed = pd.Series(elevation_values).rolling(window=5, min_periods=1, center=True).mean().values
# Align lengths (assume same sampling rate)
min_len = min(len(speed_values), len(elevation_smoothed))
elevation_aligned = elevation_smoothed[:min_len]
time_aligned = time_minutes[:min_len]
ax2.fill_between(time_aligned, elevation_aligned, alpha=0.2, color='brown', label='Elevation')
ax2.set_ylabel('Elevation (m)', color='brown')
ax2.tick_params(axis='y', labelcolor='brown')
# Combine legends
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
else:
ax1.legend()
ax1.set_title('Speed Over Time')
ax1.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)
================================================
FILE: visualizers/report_generator.py
================================================
"""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")
================================================
FILE: visualizers/templates/summary_report.html
================================================
Workout Summary Report
Workout Summary Report
All Workouts
| Date |
Sport |
Duration |
Distance (km) |
Avg Speed (km/h) |
Avg HR |
NP |
IF |
TSS |
{% for analysis in analyses %}
| {{ analysis.summary.start_time.strftime('%Y-%m-%d') if analysis.summary.start_time else 'N/A' }} |
{{ analysis.summary.sport if analysis.summary.sport else 'N/A' }} |
{{ analysis.summary.duration_minutes|format_duration if analysis.summary.duration_minutes else 'N/A' }} |
{{ "%.2f"|format(analysis.summary.distance_km) if analysis.summary.distance_km else 'N/A' }} |
{{ "%.1f"|format(analysis.summary.avg_speed_kmh) if analysis.summary.avg_speed_kmh else 'N/A' }} |
{{ "%.0f"|format(analysis.summary.avg_hr) if analysis.summary.avg_hr else 'N/A' }} |
{{ "%.0f"|format(analysis.summary.normalized_power) if analysis.summary.normalized_power else 'N/A' }} |
{{ "%.2f"|format(analysis.summary.intensity_factor) if analysis.summary.intensity_factor else 'N/A' }} |
{{ "%.1f"|format(analysis.summary.training_stress_score) if analysis.summary.training_stress_score else 'N/A' }} |
{% endfor %}
================================================
FILE: visualizers/templates/workout_report.html
================================================
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 %}
================================================
FILE: visualizers/templates/workout_report.md
================================================
# 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 }}*