mitai-jinkendo/backend/calculations/activity_metrics.py
Lars dd3a4111fc
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
fix: Phase 0b - fix remaining calculation errors
Fixes applied:
1. WHERE clause column names (total_sleep_min → duration_minutes, resting_heart_rate → resting_hr)
2. COUNT() column names (avg_heart_rate → hr_avg, quality_label → rpe)
3. Type errors (Decimal * float) - convert to float before multiplication
4. rest_days table (type column removed in migration 010, now uses rest_config JSONB)
5. c_thigh_l → c_thigh (no separate left/right columns)
6. focus_area_definitions queries (focus_area_id → key, label_de → name_de)

Missing functions implemented:
- goal_utils.get_active_goals() - queries goals table for active goals
- goal_utils.get_goal_by_id() - gets single goal
- calculations.scores.calculate_category_progress() - maps categories to score functions

Changes:
- activity_metrics.py: Fixed Decimal/float type errors, rest_config JSONB, data quality query
- recovery_metrics.py: Fixed all WHERE clause column names
- body_metrics.py: Fixed c_thigh column reference
- scores.py: Fixed focus_area queries, added calculate_category_progress()
- goal_utils.py: Added get_active_goals(), get_goal_by_id()

All calculation functions should now work with correct schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 08:39:31 +01:00

634 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)
}
}