- Updated the Gitea issues index to reflect the latest state as of 2026-04-11, adding issue #76 to the list. - Refined data handling in `activity_metrics.py`, `body_metrics.py`, `nutrition_metrics.py`, and `scores.py` to ensure consistent float conversions for calculations, improving accuracy in metric evaluations. - Enhanced the calculation logic for various metrics to handle potential None values more robustly, ensuring smoother data processing and improved reliability across the application. These changes improve the clarity of the Gitea issues documentation and enhance the overall accuracy and reliability of health and fitness metrics.
1171 lines
38 KiB
Python
1171 lines
38 KiB
Python
"""
|
||
Activity Metrics Data Layer
|
||
|
||
Provides structured data for training tracking and analysis.
|
||
|
||
Functions:
|
||
- get_activity_summary_data(): Count, total duration, calories, averages
|
||
- get_activity_detail_data(): Detailed activity log entries
|
||
- get_training_type_distribution_data(): Training category percentages
|
||
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
|
||
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
|
||
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
|
||
|
||
All functions return structured data (dict) without formatting.
|
||
Use placeholder_resolver.py for formatted strings for AI.
|
||
|
||
Phase 0c: Multi-Layer Architecture
|
||
Version: 1.0
|
||
"""
|
||
|
||
from typing import Dict, List, Optional, Any
|
||
from datetime import datetime, timedelta, date, time
|
||
import statistics
|
||
from db import get_db, get_cursor, r2d
|
||
from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates
|
||
|
||
|
||
def get_activity_summary_data(
|
||
profile_id: str,
|
||
days: int = 14
|
||
) -> Dict:
|
||
"""
|
||
Get activity summary statistics.
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
days: Analysis window (default 14)
|
||
|
||
Returns:
|
||
{
|
||
"activity_count": int,
|
||
"total_duration_min": int,
|
||
"total_kcal": int,
|
||
"avg_duration_min": int,
|
||
"avg_kcal_per_session": int,
|
||
"sessions_per_week": float,
|
||
"confidence": str,
|
||
"days_analyzed": int
|
||
}
|
||
|
||
Migration from Phase 0b:
|
||
OLD: get_activity_summary(pid, days) formatted string
|
||
NEW: Structured data with all metrics
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
cur.execute(
|
||
"""SELECT
|
||
COUNT(*) as count,
|
||
SUM(duration_min) as total_min,
|
||
SUM(kcal_active) as total_kcal
|
||
FROM activity_log
|
||
WHERE profile_id=%s AND date >= %s""",
|
||
(profile_id, cutoff)
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if not row or row['count'] == 0:
|
||
return {
|
||
"activity_count": 0,
|
||
"total_duration_min": 0,
|
||
"total_kcal": 0,
|
||
"avg_duration_min": 0,
|
||
"avg_kcal_per_session": 0,
|
||
"sessions_per_week": 0.0,
|
||
"confidence": "insufficient",
|
||
"days_analyzed": days
|
||
}
|
||
|
||
activity_count = row['count']
|
||
total_min = safe_int(row['total_min'])
|
||
total_kcal = safe_int(row['total_kcal'])
|
||
|
||
avg_duration = int(total_min / activity_count) if activity_count > 0 else 0
|
||
avg_kcal = int(total_kcal / activity_count) if activity_count > 0 else 0
|
||
sessions_per_week = (activity_count / days * 7) if days > 0 else 0.0
|
||
|
||
confidence = calculate_confidence(activity_count, days, "general")
|
||
|
||
return {
|
||
"activity_count": activity_count,
|
||
"total_duration_min": total_min,
|
||
"total_kcal": total_kcal,
|
||
"avg_duration_min": avg_duration,
|
||
"avg_kcal_per_session": avg_kcal,
|
||
"sessions_per_week": round(sessions_per_week, 1),
|
||
"confidence": confidence,
|
||
"days_analyzed": days
|
||
}
|
||
|
||
|
||
def get_activity_detail_data(
|
||
profile_id: str,
|
||
days: int = 14,
|
||
limit: int = 50
|
||
) -> Dict:
|
||
"""
|
||
Get detailed activity log entries.
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
days: Analysis window (default 14)
|
||
limit: Maximum entries to return (default 50)
|
||
|
||
Returns:
|
||
{
|
||
"activities": [
|
||
{
|
||
"date": date,
|
||
"activity_type": str,
|
||
"duration_min": int,
|
||
"kcal_active": int,
|
||
"hr_avg": int | None,
|
||
"training_category": str | None
|
||
},
|
||
...
|
||
],
|
||
"total_count": int,
|
||
"confidence": str,
|
||
"days_analyzed": int
|
||
}
|
||
|
||
Migration from Phase 0b:
|
||
OLD: get_activity_detail(pid, days) formatted string list
|
||
NEW: Structured array with all fields
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
cur.execute(
|
||
"""SELECT
|
||
date,
|
||
activity_type,
|
||
duration_min,
|
||
kcal_active,
|
||
hr_avg,
|
||
training_category
|
||
FROM activity_log
|
||
WHERE profile_id=%s AND date >= %s
|
||
ORDER BY date DESC
|
||
LIMIT %s""",
|
||
(profile_id, cutoff, limit)
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
if not rows:
|
||
return {
|
||
"activities": [],
|
||
"total_count": 0,
|
||
"confidence": "insufficient",
|
||
"days_analyzed": days
|
||
}
|
||
|
||
activities = []
|
||
for row in rows:
|
||
activities.append({
|
||
"date": row['date'],
|
||
"activity_type": row['activity_type'],
|
||
"duration_min": safe_int(row['duration_min']),
|
||
"kcal_active": safe_int(row['kcal_active']),
|
||
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
|
||
"training_category": row.get('training_category')
|
||
})
|
||
|
||
confidence = calculate_confidence(len(activities), days, "general")
|
||
|
||
return {
|
||
"activities": activities,
|
||
"total_count": len(activities),
|
||
"confidence": confidence,
|
||
"days_analyzed": days
|
||
}
|
||
|
||
|
||
def get_training_type_distribution_data(
|
||
profile_id: str,
|
||
days: int = 14
|
||
) -> Dict:
|
||
"""
|
||
Calculate training category distribution.
|
||
|
||
Args:
|
||
profile_id: User profile ID
|
||
days: Analysis window (default 14)
|
||
|
||
Returns:
|
||
{
|
||
"distribution": [
|
||
{
|
||
"category": str,
|
||
"count": int,
|
||
"percentage": float
|
||
},
|
||
...
|
||
],
|
||
"total_sessions": int,
|
||
"categorized_sessions": int,
|
||
"uncategorized_sessions": int,
|
||
"confidence": str,
|
||
"days_analyzed": int
|
||
}
|
||
|
||
Migration from Phase 0b:
|
||
OLD: get_trainingstyp_verteilung(pid, days) top 3 formatted
|
||
NEW: Complete distribution with percentages
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||
|
||
# Get categorized activities
|
||
cur.execute(
|
||
"""SELECT
|
||
training_category,
|
||
COUNT(*) as count
|
||
FROM activity_log
|
||
WHERE profile_id=%s
|
||
AND date >= %s
|
||
AND training_category IS NOT NULL
|
||
GROUP BY training_category
|
||
ORDER BY count DESC""",
|
||
(profile_id, cutoff)
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
# Get total activity count (including uncategorized)
|
||
cur.execute(
|
||
"""SELECT COUNT(*) as total
|
||
FROM activity_log
|
||
WHERE profile_id=%s AND date >= %s""",
|
||
(profile_id, cutoff)
|
||
)
|
||
total_row = cur.fetchone()
|
||
total_sessions = total_row['total'] if total_row else 0
|
||
|
||
if not rows or total_sessions == 0:
|
||
return {
|
||
"distribution": [],
|
||
"total_sessions": total_sessions,
|
||
"categorized_sessions": 0,
|
||
"uncategorized_sessions": total_sessions,
|
||
"confidence": "insufficient",
|
||
"days_analyzed": days
|
||
}
|
||
|
||
categorized_count = sum(row['count'] for row in rows)
|
||
uncategorized_count = total_sessions - categorized_count
|
||
|
||
distribution = []
|
||
for row in rows:
|
||
count = row['count']
|
||
percentage = (count / total_sessions * 100) if total_sessions > 0 else 0
|
||
distribution.append({
|
||
"category": row['training_category'],
|
||
"count": count,
|
||
"percentage": round(percentage, 1)
|
||
})
|
||
|
||
confidence = calculate_confidence(categorized_count, days, "general")
|
||
|
||
return {
|
||
"distribution": distribution,
|
||
"total_sessions": total_sessions,
|
||
"categorized_sessions": categorized_count,
|
||
"uncategorized_sessions": uncategorized_count,
|
||
"confidence": confidence,
|
||
"days_analyzed": days
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# Calculated Metrics (migrated from calculations/activity_metrics.py)
|
||
# ============================================================================
|
||
# These functions return simple values for placeholders and scoring.
|
||
# Use get_*_data() functions above for structured chart data.
|
||
|
||
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 data_layer.scores import get_user_focus_weights
|
||
focus_weights = get_user_focus_weights(profile_id)
|
||
|
||
# Activity-related focus areas (English keys from DB); Gewichte float (kein Decimal×float)
|
||
strength = float(focus_weights.get('strength', 0) or 0)
|
||
strength_endurance = float(focus_weights.get('strength_endurance', 0) or 0)
|
||
power = float(focus_weights.get('power', 0) or 0)
|
||
total_strength = strength + strength_endurance + power
|
||
|
||
aerobic = float(focus_weights.get('aerobic_endurance', 0) or 0)
|
||
anaerobic = float(focus_weights.get('anaerobic_endurance', 0) or 0)
|
||
cardiovascular = float(focus_weights.get('cardiovascular_health', 0) or 0)
|
||
total_cardio = aerobic + anaerobic + cardiovascular
|
||
|
||
flexibility = float(focus_weights.get('flexibility', 0) or 0)
|
||
mobility = float(focus_weights.get('mobility', 0) or 0)
|
||
balance = float(focus_weights.get('balance', 0) or 0)
|
||
reaction = float(focus_weights.get('reaction', 0) or 0)
|
||
rhythm = float(focus_weights.get('rhythm', 0) or 0)
|
||
coordination = float(focus_weights.get('coordination', 0) or 0)
|
||
total_ability = flexibility + mobility + balance + reaction + rhythm + coordination
|
||
|
||
total_activity_weight = total_strength + total_cardio + total_ability
|
||
|
||
if total_activity_weight == 0:
|
||
return None # No activity goals
|
||
|
||
components = []
|
||
|
||
# 1. Weekly minutes (general activity volume)
|
||
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))
|
||
|
||
# Volume relevant for all activity types (20% base weight)
|
||
components.append(('minutes', minutes_score, total_activity_weight * 0.2))
|
||
|
||
# 2. Quality sessions (always relevant)
|
||
quality_pct = calculate_quality_sessions_pct(profile_id)
|
||
if quality_pct is not None:
|
||
# Quality gets 10% base weight
|
||
components.append(('quality', quality_pct, total_activity_weight * 0.1))
|
||
|
||
# 3. Strength presence (if strength focus active)
|
||
if total_strength > 0:
|
||
strength_score = _score_strength_presence(profile_id)
|
||
if strength_score is not None:
|
||
components.append(('strength', strength_score, total_strength))
|
||
|
||
# 4. Cardio presence (if cardio focus active)
|
||
if total_cardio > 0:
|
||
cardio_score = _score_cardio_presence(profile_id)
|
||
if cardio_score is not None:
|
||
components.append(('cardio', cardio_score, total_cardio))
|
||
|
||
# 5. Ability balance (if mobility/coordination focus active)
|
||
if total_ability > 0:
|
||
balance_score = _score_ability_balance(profile_id)
|
||
if balance_score is not None:
|
||
components.append(('balance', balance_score, total_ability))
|
||
|
||
if not components:
|
||
return None
|
||
|
||
# Weighted average (float: DB-Aggregate können Decimal sein)
|
||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||
total_weight = sum(float(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
|
||
|
||
# psycopg2: SUM() → oft Decimal — vor Mix mit float konvertieren
|
||
cardio_days = int(row['cardio_days'] or 0)
|
||
cardio_minutes = float(row['cardio_minutes'] or 0)
|
||
|
||
# Target: 3-5 days/week, 150+ minutes
|
||
day_score = min(100.0, (cardio_days / 4) * 100)
|
||
minute_score = min(100.0, (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)
|
||
}
|
||
}
|
||
|
||
|
||
def _session_sort_ts(row: Dict) -> datetime:
|
||
"""Einheitlicher Zeitstempel für Sortierung und Pausenberechnung."""
|
||
d = row["date"]
|
||
if isinstance(d, str):
|
||
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
|
||
st = row.get("start_time")
|
||
if st is None:
|
||
t = time(12, 0, 0)
|
||
else:
|
||
t = st
|
||
return datetime.combine(d, t)
|
||
|
||
|
||
def get_training_frequency_by_type_data(
|
||
profile_id: str,
|
||
days: int = 28,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Pro activity_type (Roh-Label aus Import/Anzeige): Häufigkeit & Intensitätskennzahlen.
|
||
|
||
Returns:
|
||
{
|
||
"days_analyzed": int,
|
||
"confidence": str,
|
||
"by_type": [
|
||
{
|
||
"activity_type": str,
|
||
"session_count": int,
|
||
"sessions_per_week": float,
|
||
"avg_duration_min": float | None,
|
||
"avg_kcal_active": float | None,
|
||
"avg_hr_avg": float | None,
|
||
"avg_hr_max": float | None,
|
||
"avg_rpe": float | None,
|
||
"avg_kcal_per_min": float | None, # grobe Intensität, wenn kcal & Dauer
|
||
},
|
||
...
|
||
],
|
||
}
|
||
"""
|
||
weeks = max(days / 7.0, 0.01)
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
activity_type,
|
||
COUNT(*)::int AS session_count,
|
||
AVG(duration_min)::float AS avg_duration_min,
|
||
AVG(kcal_active)::float AS avg_kcal_active,
|
||
AVG(hr_avg)::float AS avg_hr_avg,
|
||
AVG(hr_max)::float AS avg_hr_max,
|
||
AVG(rpe)::float AS avg_rpe,
|
||
SUM(COALESCE(duration_min, 0))::float AS sum_duration,
|
||
SUM(COALESCE(kcal_active, 0))::float AS sum_kcal
|
||
FROM activity_log
|
||
WHERE profile_id = %s AND date >= %s
|
||
GROUP BY activity_type
|
||
ORDER BY session_count DESC
|
||
""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = [r2d(r) for r in cur.fetchall()]
|
||
|
||
if not rows:
|
||
return {
|
||
"days_analyzed": days,
|
||
"confidence": "insufficient",
|
||
"by_type": [],
|
||
}
|
||
|
||
by_type = []
|
||
for r in rows:
|
||
sc = int(r["session_count"])
|
||
sum_dur = float(r["sum_duration"] or 0)
|
||
sum_kcal = float(r["sum_kcal"] or 0)
|
||
kcal_per_min = (sum_kcal / sum_dur) if sum_dur > 0 else None
|
||
by_type.append(
|
||
{
|
||
"activity_type": r["activity_type"],
|
||
"session_count": sc,
|
||
"sessions_per_week": round(sc / weeks, 2),
|
||
"avg_duration_min": r["avg_duration_min"],
|
||
"avg_kcal_active": r["avg_kcal_active"],
|
||
"avg_hr_avg": r["avg_hr_avg"],
|
||
"avg_hr_max": r["avg_hr_max"],
|
||
"avg_rpe": r["avg_rpe"],
|
||
"avg_kcal_per_min": round(kcal_per_min, 2) if kcal_per_min is not None else None,
|
||
}
|
||
)
|
||
|
||
total_sessions = sum(x["session_count"] for x in by_type)
|
||
confidence = calculate_confidence(total_sessions, days, "general")
|
||
return {
|
||
"days_analyzed": days,
|
||
"confidence": confidence,
|
||
"by_type": by_type,
|
||
}
|
||
|
||
|
||
def get_training_inter_session_gap_data(
|
||
profile_id: str,
|
||
days: int = 28,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Mittlere/median Pausen zwischen aufeinanderfolgenden Trainingseinheiten (Stunden).
|
||
|
||
Sortierung: Datum + start_time (fehlend → 12:00), dann created.
|
||
"""
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
cur.execute(
|
||
"""
|
||
SELECT date, start_time, created
|
||
FROM activity_log
|
||
WHERE profile_id = %s AND date >= %s
|
||
ORDER BY date ASC, start_time ASC NULLS LAST, created ASC
|
||
""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = [r2d(r) for r in cur.fetchall()]
|
||
|
||
if len(rows) < 2:
|
||
return {
|
||
"days_analyzed": days,
|
||
"confidence": "insufficient",
|
||
"gap_hours_median": None,
|
||
"gap_hours_mean": None,
|
||
"gap_hours_min": None,
|
||
"gaps_count": 0,
|
||
}
|
||
|
||
gaps = []
|
||
prev_ts = None
|
||
for r in rows:
|
||
ts = _session_sort_ts(r)
|
||
if prev_ts is not None:
|
||
gaps.append((ts - prev_ts).total_seconds() / 3600.0)
|
||
prev_ts = ts
|
||
|
||
if not gaps:
|
||
return {
|
||
"days_analyzed": days,
|
||
"confidence": "insufficient",
|
||
"gap_hours_median": None,
|
||
"gap_hours_mean": None,
|
||
"gap_hours_min": None,
|
||
"gaps_count": 0,
|
||
}
|
||
|
||
gaps_sorted = sorted(gaps)
|
||
mid = len(gaps_sorted) // 2
|
||
median = (
|
||
gaps_sorted[mid]
|
||
if len(gaps_sorted) % 2
|
||
else (gaps_sorted[mid - 1] + gaps_sorted[mid]) / 2.0
|
||
)
|
||
confidence = calculate_confidence(len(rows), days, "general")
|
||
return {
|
||
"days_analyzed": days,
|
||
"confidence": confidence,
|
||
"gap_hours_median": round(median, 1),
|
||
"gap_hours_mean": round(statistics.mean(gaps), 1),
|
||
"gap_hours_min": round(min(gaps), 1),
|
||
"gaps_count": len(gaps),
|
||
}
|
||
|
||
|
||
def get_training_sessions_recent_weeks_data(
|
||
profile_id: str,
|
||
weeks: int = 4,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
|
||
|
||
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4).
|
||
"""
|
||
days = max(weeks * 7, 7)
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
a.id,
|
||
a.date,
|
||
a.start_time,
|
||
a.activity_type,
|
||
a.training_category,
|
||
a.duration_min,
|
||
a.kcal_active,
|
||
a.hr_avg,
|
||
a.hr_max,
|
||
a.rpe,
|
||
tt.name_de AS training_type_name
|
||
FROM activity_log a
|
||
LEFT JOIN training_types tt ON tt.id = a.training_type_id
|
||
WHERE a.profile_id = %s AND a.date >= %s
|
||
ORDER BY a.date ASC, a.start_time ASC NULLS LAST, a.created ASC
|
||
""",
|
||
(profile_id, cutoff),
|
||
)
|
||
rows = [r2d(r) for r in cur.fetchall()]
|
||
|
||
if not rows:
|
||
return {
|
||
"weeks": [],
|
||
"meta": {
|
||
"weeks_requested": weeks,
|
||
"days_loaded": days,
|
||
"session_count": 0,
|
||
"confidence": "insufficient",
|
||
},
|
||
}
|
||
|
||
by_week: Dict[str, List[Dict]] = {}
|
||
for r in rows:
|
||
d = r["date"]
|
||
if isinstance(d, str):
|
||
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
|
||
iso = d.isocalendar()
|
||
wk = f"{iso.year}-W{iso.week:02d}"
|
||
if wk not in by_week:
|
||
by_week[wk] = []
|
||
dur = r.get("duration_min")
|
||
dur_f = float(dur) if dur is not None else None
|
||
kcal = r.get("kcal_active")
|
||
kcal_f = float(kcal) if kcal is not None else None
|
||
hr_a = r.get("hr_avg")
|
||
hr_m = r.get("hr_max")
|
||
by_week[wk].append(
|
||
{
|
||
"date": d,
|
||
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
|
||
"activity_type": r.get("activity_type"),
|
||
"training_category": r.get("training_category"),
|
||
"training_type_name": r.get("training_type_name"),
|
||
"duration_min": dur_f,
|
||
"kcal_active": kcal_f,
|
||
"hr_avg": int(hr_a) if hr_a is not None else None,
|
||
"hr_max": int(hr_m) if hr_m is not None else None,
|
||
"rpe": int(r["rpe"]) if r.get("rpe") is not None else None,
|
||
}
|
||
)
|
||
|
||
week_keys = sorted(by_week.keys())
|
||
weeks_out = [{"week_iso": wk, "sessions": by_week[wk]} for wk in week_keys]
|
||
confidence = calculate_confidence(len(rows), days, "general")
|
||
return serialize_dates(
|
||
{
|
||
"weeks": weeks_out,
|
||
"meta": {
|
||
"weeks_requested": weeks,
|
||
"days_loaded": days,
|
||
"session_count": len(rows),
|
||
"confidence": confidence,
|
||
},
|
||
}
|
||
)
|