mitai-jinkendo/backend/data_layer/scores.py
Lars dba6814bc2
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: Phase 0c - migrate scores calculations to data_layer (14 functions)
- Created NEW data_layer/scores.py with all 14 scoring functions
- Functions: Focus weights & mapping (get_user_focus_weights, get_focus_area_category, map_focus_to_score_components, map_category_de_to_en)
- Functions: Category weight calculation
- Functions: Progress scores (goal progress, health stability)
- Functions: Health score helpers (blood pressure, sleep quality scorers)
- Functions: Data quality score
- Functions: Top priority/focus (get_top_priority_goal, get_top_focus_area, calculate_focus_area_progress)
- Functions: Category progress
- Updated data_layer/__init__.py to import scores module and export 12 functions
- Refactored placeholder_resolver.py to import scores from data_layer

Module 5/6 complete. Single Source of Truth for scoring metrics established.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 20:26:23 +01:00

584 lines
20 KiB
Python

"""
Scoring Metrics Data Layer
Provides structured scoring and focus weight functions for all metrics.
Functions:
- get_user_focus_weights(): User focus area weights (from DB)
- get_focus_area_category(): Category for a focus area
- map_focus_to_score_components(): Mapping of focus areas to score components
- map_category_de_to_en(): Category translation DE→EN
- calculate_category_weight(): Weight for a category
- calculate_goal_progress_score(): Goal progress scoring
- calculate_health_stability_score(): Health stability scoring
- calculate_data_quality_score(): Overall data quality
- get_top_priority_goal(): Top goal by weight
- get_top_focus_area(): Top focus area by weight
- calculate_focus_area_progress(): Progress for specific focus area
- calculate_category_progress(): Progress for category
All functions return structured data (dict) or simple values.
Use placeholder_resolver.py for formatted strings for AI.
Phase 0c: Multi-Layer Architecture
Version: 1.0
"""
from typing import Dict, List, Optional
from datetime import datetime, timedelta, date
from db import get_db, get_cursor, r2d
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