""" Score Calculation Engine Implements meta-scores with Dynamic Focus Areas v2.0 integration: - Goal Progress Score (weighted by user's focus areas) - Data Quality Score - Helper functions for focus area weighting All scores are 0-100 with confidence levels. """ from typing import Dict, Optional, List import json from db import get_db, get_cursor # ============================================================================ # Focus Area Weighting System # ============================================================================ def get_user_focus_weights(profile_id: str) -> Dict[str, float]: """ Get user's focus area weights as dictionary Returns: {'körpergewicht': 30.0, 'kraftaufbau': 25.0, ...} """ with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT ufw.focus_area_id, ufw.weight as weight_pct, fa.key FROM user_focus_area_weights ufw JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id WHERE ufw.profile_id = %s AND ufw.weight > 0 """, (profile_id,)) return { row['key']: float(row['weight_pct']) for row in cur.fetchall() } def get_focus_area_category(focus_area_id: str) -> Optional[str]: """Get category for a focus area""" with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT category FROM focus_area_definitions WHERE focus_area_id = %s """, (focus_area_id,)) row = cur.fetchone() return row['category'] if row else None def map_focus_to_score_components() -> Dict[str, str]: """ Map focus areas to score components Keys match focus_area_definitions.key (English lowercase) Returns: {'weight_loss': 'body', 'strength': 'activity', ...} """ return { # Body Composition → body_progress_score 'weight_loss': 'body', 'muscle_gain': 'body', 'body_recomposition': 'body', # Training - Strength → activity_score 'strength': 'activity', 'strength_endurance': 'activity', 'power': 'activity', # Training - Mobility → activity_score 'flexibility': 'activity', 'mobility': 'activity', # Endurance → activity_score (could also map to health) 'aerobic_endurance': 'activity', 'anaerobic_endurance': 'activity', 'cardiovascular_health': 'health', # Coordination → activity_score 'balance': 'activity', 'reaction': 'activity', 'rhythm': 'activity', 'coordination': 'activity', # Mental → recovery_score (mental health is part of recovery) 'stress_resistance': 'recovery', 'concentration': 'recovery', 'willpower': 'recovery', 'mental_health': 'recovery', # Recovery → recovery_score 'sleep_quality': 'recovery', 'regeneration': 'recovery', 'rest': 'recovery', # Health → health 'metabolic_health': 'health', 'blood_pressure': 'health', 'hrv': 'health', 'general_health': 'health', # Nutrition → nutrition_score 'protein_intake': 'nutrition', 'calorie_balance': 'nutrition', 'macro_consistency': 'nutrition', 'meal_timing': 'nutrition', 'hydration': 'nutrition', } def map_category_de_to_en(category_de: str) -> str: """ Map German category names to English database names """ mapping = { 'körper': 'body_composition', 'ernährung': 'nutrition', # Note: no nutrition category in DB, returns empty 'aktivität': 'training', 'recovery': 'recovery', 'vitalwerte': 'health', 'mental': 'mental', 'lebensstil': 'health', # Maps to general health } return mapping.get(category_de, category_de) def calculate_category_weight(profile_id: str, category: str) -> float: """ Calculate total weight for a category Accepts German or English category names Returns sum of all focus area weights in this category """ # Map German to English if needed category_en = map_category_de_to_en(category) focus_weights = get_user_focus_weights(profile_id) with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT key FROM focus_area_definitions WHERE category = %s """, (category_en,)) focus_areas = [row['key'] for row in cur.fetchall()] total_weight = sum( focus_weights.get(fa, 0) for fa in focus_areas ) return total_weight # ============================================================================ # Goal Progress Score (Meta-Score with Dynamic Weighting) # ============================================================================ def calculate_goal_progress_score(profile_id: str) -> Optional[int]: """ Calculate overall goal progress score (0-100) Weighted dynamically based on user's focus area priorities This is the main meta-score that combines all sub-scores """ focus_weights = get_user_focus_weights(profile_id) if not focus_weights: return None # No goals/focus areas configured # Calculate sub-scores from calculations.body_metrics import calculate_body_progress_score from calculations.nutrition_metrics import calculate_nutrition_score from calculations.activity_metrics import calculate_activity_score from calculations.recovery_metrics import calculate_recovery_score_v2 body_score = calculate_body_progress_score(profile_id, focus_weights) nutrition_score = calculate_nutrition_score(profile_id, focus_weights) activity_score = calculate_activity_score(profile_id, focus_weights) recovery_score = calculate_recovery_score_v2(profile_id) health_risk_score = calculate_health_stability_score(profile_id) # Map focus areas to score components focus_to_component = map_focus_to_score_components() # Calculate weighted sum total_score = 0.0 total_weight = 0.0 for focus_area_id, weight in focus_weights.items(): component = focus_to_component.get(focus_area_id) if component == 'body' and body_score is not None: total_score += body_score * weight total_weight += weight elif component == 'nutrition' and nutrition_score is not None: total_score += nutrition_score * weight total_weight += weight elif component == 'activity' and activity_score is not None: total_score += activity_score * weight total_weight += weight elif component == 'recovery' and recovery_score is not None: total_score += recovery_score * weight total_weight += weight elif component == 'health' and health_risk_score is not None: total_score += health_risk_score * weight total_weight += weight if total_weight == 0: return None # Normalize to 0-100 final_score = total_score / total_weight return int(final_score) def calculate_health_stability_score(profile_id: str) -> Optional[int]: """ Health stability score (0-100) Components: - Blood pressure status - Sleep quality - Movement baseline - Weight/circumference risk factors - Regularity """ with get_db() as conn: cur = get_cursor(conn) components = [] # 1. Blood pressure status (30%) cur.execute(""" SELECT systolic, diastolic FROM blood_pressure_log WHERE profile_id = %s AND measured_at >= CURRENT_DATE - INTERVAL '28 days' ORDER BY measured_at DESC """, (profile_id,)) bp_readings = cur.fetchall() if bp_readings: bp_score = _score_blood_pressure(bp_readings) components.append(('bp', bp_score, 30)) # 2. Sleep quality (25%) cur.execute(""" SELECT duration_minutes, deep_minutes, rem_minutes FROM sleep_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' ORDER BY date DESC """, (profile_id,)) sleep_data = cur.fetchall() if sleep_data: sleep_score = _score_sleep_quality(sleep_data) components.append(('sleep', sleep_score, 25)) # 3. Movement baseline (20%) cur.execute(""" SELECT duration_min FROM activity_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '7 days' """, (profile_id,)) activities = cur.fetchall() if activities: total_minutes = sum(a['duration_min'] for a in activities) # WHO recommends 150-300 min/week moderate activity movement_score = min(100, (total_minutes / 150) * 100) components.append(('movement', movement_score, 20)) # 4. Waist circumference risk (15%) cur.execute(""" SELECT c_waist FROM circumference_log WHERE profile_id = %s AND c_waist IS NOT NULL ORDER BY date DESC LIMIT 1 """, (profile_id,)) waist = cur.fetchone() if waist: # Gender-specific thresholds (simplified - should use profile gender) # Men: <94cm good, 94-102 elevated, >102 high risk # Women: <80cm good, 80-88 elevated, >88 high risk # Using conservative thresholds waist_cm = waist['c_waist'] if waist_cm < 88: waist_score = 100 elif waist_cm < 94: waist_score = 75 elif waist_cm < 102: waist_score = 50 else: waist_score = 25 components.append(('waist', waist_score, 15)) # 5. Regularity (10%) - sleep timing consistency if len(sleep_data) >= 7: sleep_times = [s['duration_minutes'] for s in sleep_data] avg = sum(sleep_times) / len(sleep_times) variance = sum((x - avg) ** 2 for x in sleep_times) / len(sleep_times) std_dev = variance ** 0.5 # Lower std_dev = better consistency regularity_score = max(0, 100 - (std_dev * 2)) components.append(('regularity', regularity_score, 10)) 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_blood_pressure(readings: List) -> int: """Score blood pressure readings (0-100)""" # Average last 28 days avg_systolic = sum(r['systolic'] for r in readings) / len(readings) avg_diastolic = sum(r['diastolic'] for r in readings) / len(readings) # ESC 2024 Guidelines: # Optimal: <120/80 # Normal: 120-129 / 80-84 # Elevated: 130-139 / 85-89 # Hypertension: ≥140/90 if avg_systolic < 120 and avg_diastolic < 80: return 100 elif avg_systolic < 130 and avg_diastolic < 85: return 85 elif avg_systolic < 140 and avg_diastolic < 90: return 65 else: return 40 def _score_sleep_quality(sleep_data: List) -> int: """Score sleep quality (0-100)""" # Average sleep duration and quality avg_total = sum(s['duration_minutes'] for s in sleep_data) / len(sleep_data) avg_total_hours = avg_total / 60 # Duration score (7+ hours = good) if avg_total_hours >= 8: duration_score = 100 elif avg_total_hours >= 7: duration_score = 85 elif avg_total_hours >= 6: duration_score = 65 else: duration_score = 40 # Quality score (deep + REM percentage) quality_scores = [] for s in sleep_data: if s['deep_minutes'] and s['rem_minutes']: quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100 # 40-60% deep+REM is good if quality_pct >= 45: quality_scores.append(100) elif quality_pct >= 35: quality_scores.append(75) elif quality_pct >= 25: quality_scores.append(50) else: quality_scores.append(30) if quality_scores: avg_quality = sum(quality_scores) / len(quality_scores) # Weighted: 60% duration, 40% quality return int(duration_score * 0.6 + avg_quality * 0.4) else: return duration_score # ============================================================================ # Data Quality Score # ============================================================================ def calculate_data_quality_score(profile_id: str) -> int: """ Overall data quality score (0-100) Combines quality from all modules """ from calculations.body_metrics import calculate_body_data_quality from calculations.nutrition_metrics import calculate_nutrition_data_quality from calculations.activity_metrics import calculate_activity_data_quality from calculations.recovery_metrics import calculate_recovery_data_quality body_quality = calculate_body_data_quality(profile_id) nutrition_quality = calculate_nutrition_data_quality(profile_id) activity_quality = calculate_activity_data_quality(profile_id) recovery_quality = calculate_recovery_data_quality(profile_id) # Weighted average (all equal weight) total_score = ( body_quality['overall_score'] * 0.25 + nutrition_quality['overall_score'] * 0.25 + activity_quality['overall_score'] * 0.25 + recovery_quality['overall_score'] * 0.25 ) return int(total_score) # ============================================================================ # Top-Weighted Helpers (instead of "primary goal") # ============================================================================ def get_top_priority_goal(profile_id: str) -> Optional[Dict]: """ Get highest priority goal based on: - Progress gap (distance to target) - Focus area weight Returns goal dict or None """ from goal_utils import get_active_goals goals = get_active_goals(profile_id) if not goals: return None focus_weights = get_user_focus_weights(profile_id) for goal in goals: # Progress gap (0-100, higher = further from target) goal['progress_gap'] = 100 - (goal.get('progress_pct') or 0) # Get focus areas for this goal with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT fa.key as focus_area_key FROM goal_focus_contributions gfc JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id WHERE gfc.goal_id = %s """, (goal['id'],)) goal_focus_areas = [row['focus_area_key'] for row in cur.fetchall()] # Sum focus weights goal['total_focus_weight'] = sum( focus_weights.get(fa, 0) for fa in goal_focus_areas ) # Priority score goal['priority_score'] = goal['progress_gap'] * (goal['total_focus_weight'] / 100) # Return goal with highest priority score return max(goals, key=lambda g: g.get('priority_score', 0)) def get_top_focus_area(profile_id: str) -> Optional[Dict]: """ Get focus area with highest user weight Returns dict with focus_area_id, label, weight, progress """ focus_weights = get_user_focus_weights(profile_id) if not focus_weights: return None top_fa_id = max(focus_weights, key=focus_weights.get) with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT key, name_de, category FROM focus_area_definitions WHERE key = %s """, (top_fa_id,)) fa_def = cur.fetchone() if not fa_def: return None # Calculate progress for this focus area progress = calculate_focus_area_progress(profile_id, top_fa_id) return { 'focus_area_id': top_fa_id, 'label': fa_def['name_de'], 'category': fa_def['category'], 'weight': focus_weights[top_fa_id], 'progress': progress } def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Optional[int]: """ Calculate progress for a specific focus area (0-100) Average progress of all goals contributing to this focus area """ with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT g.id, g.progress_pct, gfc.contribution_weight FROM goals g JOIN goal_focus_contributions gfc ON g.id = gfc.goal_id WHERE g.profile_id = %s AND gfc.focus_area_id = ( SELECT id FROM focus_area_definitions WHERE key = %s ) AND g.status = 'active' """, (profile_id, focus_area_id)) goals = cur.fetchall() if not goals: return None # Weighted average by contribution_weight total_progress = sum(g['progress_pct'] * g['contribution_weight'] for g in goals) total_weight = sum(g['contribution_weight'] for g in goals) return int(total_progress / total_weight) if total_weight > 0 else None def calculate_category_progress(profile_id: str, category: str) -> Optional[int]: """ Calculate progress score for a focus area category (0-100). Args: profile_id: User's profile ID category: Category name ('körper', 'ernährung', 'aktivität', 'recovery', 'vitalwerte', 'mental', 'lebensstil') Returns: Progress score 0-100 or None if no data """ # Map category to score calculation functions category_scores = { 'körper': 'body_progress_score', 'ernährung': 'nutrition_score', 'aktivität': 'activity_score', 'recovery': 'recovery_score', 'vitalwerte': 'recovery_score', # Use recovery score as proxy for vitals 'mental': 'recovery_score', # Use recovery score as proxy for mental (sleep quality) 'lebensstil': 'data_quality_score', # Use data quality as proxy for lifestyle consistency } score_func_name = category_scores.get(category.lower()) if not score_func_name: return None # Call the appropriate score function if score_func_name == 'body_progress_score': from calculations.body_metrics import calculate_body_progress_score return calculate_body_progress_score(profile_id) elif score_func_name == 'nutrition_score': from calculations.nutrition_metrics import calculate_nutrition_score return calculate_nutrition_score(profile_id) elif score_func_name == 'activity_score': from calculations.activity_metrics import calculate_activity_score return calculate_activity_score(profile_id) elif score_func_name == 'recovery_score': from calculations.recovery_metrics import calculate_recovery_score_v2 return calculate_recovery_score_v2(profile_id) elif score_func_name == 'data_quality_score': return calculate_data_quality_score(profile_id) return None