""" Activity Metrics Calculation Engine Implements A1-A8 from visualization concept: - A1: Training volume per week - A2: Intensity distribution - A3: Training quality matrix - A4: Ability balance radar - A5: Load monitoring (proxy-based) - A6: Activity goal alignment score - A7: Rest day compliance - A8: VO2max development All calculations work with training_types abilities system. """ from datetime import datetime, timedelta from typing import Optional, Dict, List import statistics from db import get_db, get_cursor # ============================================================================ # A1: Training Volume Calculations # ============================================================================ def calculate_training_minutes_week(profile_id: str) -> Optional[int]: """Calculate total training minutes last 7 days""" with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT SUM(duration_min) as total_minutes FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' """, (profile_id,)) row = cur.fetchone() return int(row['total_minutes']) if row and row['total_minutes'] else None def calculate_training_frequency_7d(profile_id: str) -> Optional[int]: """Calculate number of training sessions last 7 days""" with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT COUNT(*) as session_count FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' """, (profile_id,)) row = cur.fetchone() return int(row['session_count']) if row else None def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]: """Calculate percentage of quality sessions (good or better) last 28 days""" with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' """, (profile_id,)) row = cur.fetchone() if not row or row['total'] == 0: return None pct = (row['quality_count'] / row['total']) * 100 return int(pct) # ============================================================================ # A2: Intensity Distribution (Proxy-based) # ============================================================================ def calculate_intensity_proxy_distribution(profile_id: str) -> Optional[Dict]: """ Calculate intensity distribution (proxy until HR zones available) Returns dict: {'low': X, 'moderate': Y, 'high': Z} in minutes """ with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT duration_min, hr_avg, hr_max FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' """, (profile_id,)) activities = cur.fetchall() if not activities: return None low_min = 0 moderate_min = 0 high_min = 0 for activity in activities: duration = activity['duration_min'] avg_hr = activity['hr_avg'] max_hr = activity['hr_max'] # Simple proxy classification if avg_hr: # Rough HR-based classification (assumes max HR ~190) if avg_hr < 120: low_min += duration elif avg_hr < 150: moderate_min += duration else: high_min += duration else: # Fallback: assume moderate moderate_min += duration return { 'low': low_min, 'moderate': moderate_min, 'high': high_min } # ============================================================================ # A4: Ability Balance Calculations # ============================================================================ def calculate_ability_balance(profile_id: str) -> Optional[Dict]: """ Calculate ability balance from training_types.abilities Returns dict with scores per ability dimension (0-100) """ with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT a.duration_min, tt.abilities FROM activity_log a JOIN training_types tt ON a.training_category = tt.category WHERE a.profile_id = %s AND a.date >= CURRENT_DATE - INTERVAL '28 days' AND tt.abilities IS NOT NULL """, (profile_id,)) activities = cur.fetchall() if not activities: return None # Accumulate ability load (duration × ability weight) ability_loads = { 'strength': 0, 'endurance': 0, 'mental': 0, 'coordination': 0, 'mobility': 0 } for activity in activities: duration = activity['duration_min'] abilities = activity['abilities'] # JSONB if not abilities: continue for ability, weight in abilities.items(): if ability in ability_loads: ability_loads[ability] += duration * weight # Normalize to 0-100 scale max_load = max(ability_loads.values()) if ability_loads else 1 if max_load == 0: return None normalized = { ability: int((load / max_load) * 100) for ability, load in ability_loads.items() } return normalized def calculate_ability_balance_strength(profile_id: str) -> Optional[int]: """Get strength ability score""" balance = calculate_ability_balance(profile_id) return balance['strength'] if balance else None def calculate_ability_balance_endurance(profile_id: str) -> Optional[int]: """Get endurance ability score""" balance = calculate_ability_balance(profile_id) return balance['endurance'] if balance else None def calculate_ability_balance_mental(profile_id: str) -> Optional[int]: """Get mental ability score""" balance = calculate_ability_balance(profile_id) return balance['mental'] if balance else None def calculate_ability_balance_coordination(profile_id: str) -> Optional[int]: """Get coordination ability score""" balance = calculate_ability_balance(profile_id) return balance['coordination'] if balance else None def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]: """Get mobility ability score""" balance = calculate_ability_balance(profile_id) return balance['mobility'] if balance else None # ============================================================================ # A5: Load Monitoring (Proxy-based) # ============================================================================ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]: """ Calculate proxy internal load (last 7 days) Formula: duration × intensity_factor × quality_factor """ intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0} quality_factors = { 'excellent': 1.15, 'very_good': 1.05, 'good': 1.0, 'acceptable': 0.9, 'poor': 0.75, 'excluded': 0.0 } with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT duration_min, hr_avg, rpe FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' """, (profile_id,)) activities = cur.fetchall() if not activities: return None total_load = 0 for activity in activities: duration = activity['duration_min'] avg_hr = activity['hr_avg'] # Map RPE to quality (rpe 8-10 = excellent, 6-7 = good, 4-5 = moderate, <4 = poor) rpe = activity.get('rpe') if rpe and rpe >= 8: quality = 'excellent' elif rpe and rpe >= 6: quality = 'good' elif rpe and rpe >= 4: quality = 'moderate' else: quality = 'good' # default # Determine intensity if avg_hr: if avg_hr < 120: intensity = 'low' elif avg_hr < 150: intensity = 'moderate' else: intensity = 'high' else: intensity = 'moderate' load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0) total_load += load return int(total_load) def calculate_monotony_score(profile_id: str) -> Optional[float]: """ Calculate training monotony (last 7 days) Monotony = mean daily load / std dev daily load Higher = more monotonous """ with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT date, SUM(duration_min) as daily_duration FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' GROUP BY date ORDER BY date """, (profile_id,)) daily_loads = [float(row['daily_duration']) for row in cur.fetchall() if row['daily_duration']] if len(daily_loads) < 4: return None mean_load = sum(daily_loads) / len(daily_loads) std_dev = statistics.stdev(daily_loads) if std_dev == 0: return None monotony = mean_load / std_dev return round(monotony, 2) def calculate_strain_score(profile_id: str) -> Optional[int]: """ Calculate training strain (last 7 days) Strain = weekly load × monotony """ weekly_load = calculate_proxy_internal_load_7d(profile_id) monotony = calculate_monotony_score(profile_id) if weekly_load is None or monotony is None: return None strain = weekly_load * monotony return int(strain) # ============================================================================ # A6: Activity Goal Alignment Score (Dynamic Focus Areas) # ============================================================================ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]: """ Activity goal alignment score 0-100 Weighted by user's activity-related focus areas """ if focus_weights is None: from calculations.scores import get_user_focus_weights focus_weights = get_user_focus_weights(profile_id) # Activity-related focus areas activity_focus = { 'kraftaufbau': focus_weights.get('kraftaufbau', 0), 'cardio': focus_weights.get('cardio', 0), 'bewegungsumfang': focus_weights.get('bewegungsumfang', 0), 'trainingsqualität': focus_weights.get('trainingsqualität', 0), 'ability_balance': focus_weights.get('ability_balance', 0), } total_activity_weight = sum(activity_focus.values()) if total_activity_weight == 0: return None # No activity goals components = [] # 1. Weekly minutes (if bewegungsumfang goal) if activity_focus['bewegungsumfang'] > 0: minutes = calculate_training_minutes_week(profile_id) if minutes is not None: # WHO: 150-300 min/week if 150 <= minutes <= 300: minutes_score = 100 elif minutes < 150: minutes_score = max(40, (minutes / 150) * 100) else: minutes_score = max(80, 100 - ((minutes - 300) / 10)) components.append(('minutes', minutes_score, activity_focus['bewegungsumfang'])) # 2. Quality sessions (if trainingsqualität goal) if activity_focus['trainingsqualität'] > 0: quality_pct = calculate_quality_sessions_pct(profile_id) if quality_pct is not None: components.append(('quality', quality_pct, activity_focus['trainingsqualität'])) # 3. Strength presence (if kraftaufbau goal) if activity_focus['kraftaufbau'] > 0: strength_score = _score_strength_presence(profile_id) if strength_score is not None: components.append(('strength', strength_score, activity_focus['kraftaufbau'])) # 4. Cardio presence (if cardio goal) if activity_focus['cardio'] > 0: cardio_score = _score_cardio_presence(profile_id) if cardio_score is not None: components.append(('cardio', cardio_score, activity_focus['cardio'])) # 5. Ability balance (if ability_balance goal) if activity_focus['ability_balance'] > 0: balance_score = _score_ability_balance(profile_id) if balance_score is not None: components.append(('balance', balance_score, activity_focus['ability_balance'])) if not components: return None # Weighted average total_score = sum(score * weight for _, score, weight in components) total_weight = sum(weight for _, _, weight in components) return int(total_score / total_weight) def _score_strength_presence(profile_id: str) -> Optional[int]: """Score strength training presence (0-100)""" with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT COUNT(DISTINCT date) as strength_days FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' AND training_category = 'strength' """, (profile_id,)) row = cur.fetchone() if not row: return None strength_days = row['strength_days'] # Target: 2-4 days/week if 2 <= strength_days <= 4: return 100 elif strength_days == 1: return 60 elif strength_days == 5: return 85 elif strength_days == 0: return 0 else: return 70 def _score_cardio_presence(profile_id: str) -> Optional[int]: """Score cardio training presence (0-100)""" with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT COUNT(DISTINCT date) as cardio_days, SUM(duration_min) as cardio_minutes FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' AND training_category = 'cardio' """, (profile_id,)) row = cur.fetchone() if not row: return None cardio_days = row['cardio_days'] cardio_minutes = row['cardio_minutes'] or 0 # Target: 3-5 days/week, 150+ minutes day_score = min(100, (cardio_days / 4) * 100) minute_score = min(100, (cardio_minutes / 150) * 100) return int((day_score + minute_score) / 2) def _score_ability_balance(profile_id: str) -> Optional[int]: """Score ability balance (0-100)""" balance = calculate_ability_balance(profile_id) if not balance: return None # Good balance = all abilities > 40, std_dev < 30 values = list(balance.values()) min_value = min(values) std_dev = statistics.stdev(values) if len(values) > 1 else 0 # Score based on minimum coverage and balance min_score = min(100, min_value * 2) # Want all > 50 balance_score = max(0, 100 - (std_dev * 2)) # Want low std_dev return int((min_score + balance_score) / 2) # ============================================================================ # A7: Rest Day Compliance # ============================================================================ def calculate_rest_day_compliance(profile_id: str) -> Optional[int]: """ Calculate rest day compliance percentage (last 28 days) Returns percentage of planned rest days that were respected """ with get_db() as conn: cur = get_cursor(conn) # Get planned rest days cur.execute(""" SELECT date, rest_config->>'focus' as rest_type FROM rest_days WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' """, (profile_id,)) rest_days = {row['date']: row['rest_type'] for row in cur.fetchall()} if not rest_days: return None # Check if training occurred on rest days cur.execute(""" SELECT date, training_category FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' """, (profile_id,)) training_days = {} for row in cur.fetchall(): if row['date'] not in training_days: training_days[row['date']] = [] training_days[row['date']].append(row['training_category']) # Count compliance compliant = 0 total = len(rest_days) for rest_date, rest_type in rest_days.items(): if rest_date not in training_days: # Full rest = compliant compliant += 1 else: # Check if training violates rest type categories = training_days[rest_date] if rest_type == 'strength_rest' and 'strength' not in categories: compliant += 1 elif rest_type == 'cardio_rest' and 'cardio' not in categories: compliant += 1 # If rest_type == 'recovery', any training = non-compliant compliance_pct = (compliant / total) * 100 return int(compliance_pct) # ============================================================================ # A8: VO2max Development # ============================================================================ def calculate_vo2max_trend_28d(profile_id: str) -> Optional[float]: """Calculate VO2max trend (change over 28 days)""" with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT vo2_max, date FROM vitals_baseline WHERE profile_id = %s AND vo2_max IS NOT NULL AND date >= CURRENT_DATE - INTERVAL '28 days' ORDER BY date DESC """, (profile_id,)) measurements = cur.fetchall() if len(measurements) < 2: return None recent = measurements[0]['vo2_max'] oldest = measurements[-1]['vo2_max'] change = recent - oldest return round(change, 1) # ============================================================================ # Data Quality Assessment # ============================================================================ def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]: """ Assess data quality for activity metrics Returns dict with quality score and details """ with get_db() as conn: cur = get_cursor(conn) # Activity entries last 28 days cur.execute(""" SELECT COUNT(*) as total, COUNT(hr_avg) as with_hr, COUNT(rpe) as with_quality FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' """, (profile_id,)) counts = cur.fetchone() total_entries = counts['total'] hr_coverage = counts['with_hr'] / total_entries if total_entries > 0 else 0 quality_coverage = counts['with_quality'] / total_entries if total_entries > 0 else 0 # Score components frequency_score = min(100, (total_entries / 15) * 100) # 15 = ~4 sessions/week hr_score = hr_coverage * 100 quality_score = quality_coverage * 100 # Overall score overall_score = int( frequency_score * 0.5 + hr_score * 0.25 + quality_score * 0.25 ) if overall_score >= 80: confidence = "high" elif overall_score >= 60: confidence = "medium" else: confidence = "low" return { "overall_score": overall_score, "confidence": confidence, "measurements": { "activities_28d": total_entries, "hr_coverage_pct": int(hr_coverage * 100), "quality_coverage_pct": int(quality_coverage * 100) }, "component_scores": { "frequency": int(frequency_score), "hr": int(hr_score), "quality": int(quality_score) } }