- 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.
952 lines
29 KiB
Python
952 lines
29 KiB
Python
"""
|
|
Body Metrics Data Layer
|
|
|
|
Provides structured data for body composition and measurements.
|
|
|
|
Functions:
|
|
- get_latest_weight_data(): Most recent weight entry
|
|
- get_bmi_data(): BMI from latest weight + profile height
|
|
- get_profile_goal_weight_data(): Zielgewicht (Profilfeld)
|
|
- get_profile_goal_bf_pct_data(): Ziel-KFA % (Profilfeld)
|
|
- get_weight_trend_data(): Weight trend with slope and direction
|
|
- get_body_composition_data(): Body fat percentage and lean mass
|
|
- get_circumference_summary_data(): Latest circumference measurements
|
|
|
|
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, Tuple
|
|
from datetime import datetime, timedelta, date
|
|
import statistics
|
|
from db import get_db, get_cursor, r2d
|
|
from data_layer.utils import calculate_confidence, safe_float
|
|
|
|
|
|
def get_latest_weight_data(
|
|
profile_id: str
|
|
) -> Dict:
|
|
"""
|
|
Get most recent weight entry.
|
|
|
|
Args:
|
|
profile_id: User profile ID
|
|
|
|
Returns:
|
|
{
|
|
"weight": float, # kg
|
|
"date": date,
|
|
"confidence": str
|
|
}
|
|
|
|
Migration from Phase 0b:
|
|
OLD: get_latest_weight() returned formatted string "85.0 kg"
|
|
NEW: Returns structured data {"weight": 85.0, "date": ...}
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""SELECT weight, date FROM weight_log
|
|
WHERE profile_id=%s
|
|
ORDER BY date DESC
|
|
LIMIT 1""",
|
|
(profile_id,)
|
|
)
|
|
row = cur.fetchone()
|
|
|
|
if not row:
|
|
return {
|
|
"weight": 0.0,
|
|
"date": None,
|
|
"confidence": "insufficient"
|
|
}
|
|
|
|
return {
|
|
"weight": safe_float(row['weight']),
|
|
"date": row['date'],
|
|
"confidence": "high"
|
|
}
|
|
|
|
|
|
def get_bmi_data(profile_id: str) -> Dict:
|
|
"""
|
|
BMI from latest weight_log entry and profiles.height (cm).
|
|
|
|
Returns:
|
|
{
|
|
"bmi": float | None,
|
|
"weight_kg": float | None,
|
|
"height_cm": float | None,
|
|
"confidence": "high" | "insufficient",
|
|
}
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"""
|
|
SELECT pr.height,
|
|
(SELECT wl.weight FROM weight_log wl
|
|
WHERE wl.profile_id = pr.id
|
|
ORDER BY wl.date DESC
|
|
LIMIT 1) AS weight
|
|
FROM profiles pr
|
|
WHERE pr.id = %s
|
|
""",
|
|
(profile_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return {
|
|
"bmi": None,
|
|
"weight_kg": None,
|
|
"height_cm": None,
|
|
"confidence": "insufficient",
|
|
}
|
|
|
|
height_cm = row["height"]
|
|
weight = row["weight"]
|
|
if height_cm is None or weight is None:
|
|
return {
|
|
"bmi": None,
|
|
"weight_kg": safe_float(weight) if weight is not None else None,
|
|
"height_cm": safe_float(height_cm) if height_cm is not None else None,
|
|
"confidence": "insufficient",
|
|
}
|
|
|
|
h = safe_float(height_cm)
|
|
w = safe_float(weight)
|
|
if h <= 0:
|
|
return {
|
|
"bmi": None,
|
|
"weight_kg": w,
|
|
"height_cm": h,
|
|
"confidence": "insufficient",
|
|
}
|
|
|
|
height_m = h / 100.0
|
|
bmi = w / (height_m ** 2)
|
|
return {
|
|
"bmi": bmi,
|
|
"weight_kg": w,
|
|
"height_cm": h,
|
|
"confidence": "high",
|
|
}
|
|
|
|
|
|
def get_profile_goal_weight_data(profile_id: str) -> Dict:
|
|
"""Strategisches Zielgewicht aus profiles.goal_weight (kg), nicht goals-Tabelle."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"SELECT goal_weight FROM profiles WHERE id=%s",
|
|
(profile_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row or row.get("goal_weight") is None:
|
|
return {"goal_weight_kg": None, "confidence": "insufficient"}
|
|
return {
|
|
"goal_weight_kg": safe_float(row["goal_weight"]),
|
|
"confidence": "high",
|
|
}
|
|
|
|
|
|
def get_profile_goal_bf_pct_data(profile_id: str) -> Dict:
|
|
"""Strategisches Ziel-KFA aus profiles.goal_bf_pct (%), nicht goals-Tabelle."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(
|
|
"SELECT goal_bf_pct FROM profiles WHERE id=%s",
|
|
(profile_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row or row.get("goal_bf_pct") is None:
|
|
return {"goal_bf_pct": None, "confidence": "insufficient"}
|
|
return {
|
|
"goal_bf_pct": safe_float(row["goal_bf_pct"]),
|
|
"confidence": "high",
|
|
}
|
|
|
|
|
|
def get_weight_trend_data(
|
|
profile_id: str,
|
|
days: int = 28
|
|
) -> Dict:
|
|
"""
|
|
Calculate weight trend with slope and direction.
|
|
|
|
Args:
|
|
profile_id: User profile ID
|
|
days: Analysis window (default 28)
|
|
|
|
Returns:
|
|
{
|
|
"first_value": float,
|
|
"last_value": float,
|
|
"delta": float, # kg change
|
|
"direction": str, # "increasing" | "decreasing" | "stable"
|
|
"data_points": int,
|
|
"confidence": str,
|
|
"days_analyzed": int,
|
|
"first_date": date,
|
|
"last_date": date,
|
|
"series": [{"date": date, "weight": float}, ...], # für Charts ohne zweites Query
|
|
}
|
|
|
|
Confidence Rules:
|
|
- high: >= 18 points (28d) or >= 4 points (7d)
|
|
- medium: >= 12 points (28d) or >= 3 points (7d)
|
|
- low: >= 8 points (28d) or >= 2 points (7d)
|
|
- insufficient: < thresholds
|
|
|
|
Migration from Phase 0b:
|
|
OLD: get_weight_trend() returned formatted string
|
|
NEW: Returns structured data for reuse in charts + AI
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
cur.execute(
|
|
"""SELECT weight, date FROM weight_log
|
|
WHERE profile_id=%s AND date >= %s
|
|
ORDER BY date""",
|
|
(profile_id, cutoff)
|
|
)
|
|
rows = [r2d(r) for r in cur.fetchall()]
|
|
|
|
# Calculate confidence
|
|
confidence = calculate_confidence(len(rows), days, "general")
|
|
|
|
# Early return if insufficient
|
|
if confidence == 'insufficient' or len(rows) < 2:
|
|
return {
|
|
"confidence": "insufficient",
|
|
"data_points": len(rows),
|
|
"days_analyzed": days,
|
|
"first_value": 0.0,
|
|
"last_value": 0.0,
|
|
"delta": 0.0,
|
|
"direction": "unknown",
|
|
"first_date": None,
|
|
"last_date": None,
|
|
"series": [],
|
|
}
|
|
|
|
# Extract values
|
|
first_value = safe_float(rows[0]['weight'])
|
|
last_value = safe_float(rows[-1]['weight'])
|
|
delta = last_value - first_value
|
|
|
|
# Determine direction
|
|
if abs(delta) < 0.3:
|
|
direction = "stable"
|
|
elif delta > 0:
|
|
direction = "increasing"
|
|
else:
|
|
direction = "decreasing"
|
|
|
|
return {
|
|
"first_value": first_value,
|
|
"last_value": last_value,
|
|
"delta": delta,
|
|
"direction": direction,
|
|
"data_points": len(rows),
|
|
"confidence": confidence,
|
|
"days_analyzed": days,
|
|
"first_date": rows[0]['date'],
|
|
"last_date": rows[-1]['date'],
|
|
"series": [
|
|
{"date": r["date"], "weight": safe_float(r["weight"])}
|
|
for r in rows
|
|
],
|
|
}
|
|
|
|
|
|
def get_body_composition_data(
|
|
profile_id: str,
|
|
days: int = 90
|
|
) -> Dict:
|
|
"""
|
|
Get latest body composition data (body fat, lean mass).
|
|
|
|
Args:
|
|
profile_id: User profile ID
|
|
days: Lookback window (default 90)
|
|
|
|
Returns:
|
|
{
|
|
"body_fat_pct": float,
|
|
"method": str, # "jackson_pollock" | "durnin_womersley" | etc.
|
|
"date": date,
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Migration from Phase 0b:
|
|
OLD: get_latest_bf() returned formatted string "15.2%"
|
|
NEW: Returns structured data {"body_fat_pct": 15.2, ...}
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
|
|
cur.execute(
|
|
"""SELECT body_fat_pct, sf_method, date
|
|
FROM caliper_log
|
|
WHERE profile_id=%s
|
|
AND body_fat_pct IS NOT NULL
|
|
AND date >= %s
|
|
ORDER BY date DESC
|
|
LIMIT 1""",
|
|
(profile_id, cutoff)
|
|
)
|
|
row = r2d(cur.fetchone()) if cur.rowcount > 0 else None
|
|
|
|
if not row:
|
|
return {
|
|
"confidence": "insufficient",
|
|
"data_points": 0,
|
|
"body_fat_pct": 0.0,
|
|
"method": None,
|
|
"date": None
|
|
}
|
|
|
|
return {
|
|
"body_fat_pct": safe_float(row['body_fat_pct']),
|
|
"method": row.get('sf_method', 'unknown'),
|
|
"date": row['date'],
|
|
"confidence": "high", # Latest measurement is always high confidence
|
|
"data_points": 1
|
|
}
|
|
|
|
|
|
def get_circumference_summary_data(
|
|
profile_id: str,
|
|
max_age_days: int = 90
|
|
) -> Dict:
|
|
"""
|
|
Get latest circumference measurements for all body points.
|
|
|
|
For each measurement point, fetches the most recent value (even if from different dates).
|
|
Returns measurements with age in days for each point.
|
|
|
|
Args:
|
|
profile_id: User profile ID
|
|
max_age_days: Maximum age of measurements to include (default 90)
|
|
|
|
Returns:
|
|
{
|
|
"measurements": [
|
|
{
|
|
"point": str, # "Nacken", "Brust", etc.
|
|
"field": str, # "c_neck", "c_chest", etc.
|
|
"value": float, # cm
|
|
"date": date,
|
|
"age_days": int
|
|
},
|
|
...
|
|
],
|
|
"confidence": str,
|
|
"data_points": int,
|
|
"newest_date": date,
|
|
"oldest_date": date
|
|
}
|
|
|
|
Migration from Phase 0b:
|
|
OLD: get_circ_summary() returned formatted string "Nacken 38.0cm (vor 2 Tagen), ..."
|
|
NEW: Returns structured array for charts + AI formatting
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Define all circumference points
|
|
fields = [
|
|
('c_neck', 'Nacken'),
|
|
('c_chest', 'Brust'),
|
|
('c_waist', 'Taille'),
|
|
('c_belly', 'Bauch'),
|
|
('c_hip', 'Hüfte'),
|
|
('c_thigh', 'Oberschenkel'),
|
|
('c_calf', 'Wade'),
|
|
('c_arm', 'Arm')
|
|
]
|
|
|
|
measurements = []
|
|
today = datetime.now().date()
|
|
|
|
# Get latest value for each field individually
|
|
for field_name, label in fields:
|
|
cur.execute(
|
|
f"""SELECT {field_name}, date,
|
|
CURRENT_DATE - date AS age_days
|
|
FROM circumference_log
|
|
WHERE profile_id=%s
|
|
AND {field_name} IS NOT NULL
|
|
AND date >= %s
|
|
ORDER BY date DESC
|
|
LIMIT 1""",
|
|
(profile_id, (today - timedelta(days=max_age_days)).isoformat())
|
|
)
|
|
row = r2d(cur.fetchone()) if cur.rowcount > 0 else None
|
|
|
|
if row:
|
|
measurements.append({
|
|
"point": label,
|
|
"field": field_name,
|
|
"value": safe_float(row[field_name]),
|
|
"date": row['date'],
|
|
"age_days": row['age_days']
|
|
})
|
|
|
|
# Calculate confidence based on how many points we have
|
|
confidence = calculate_confidence(len(measurements), 8, "general")
|
|
|
|
if not measurements:
|
|
return {
|
|
"measurements": [],
|
|
"confidence": "insufficient",
|
|
"data_points": 0,
|
|
"newest_date": None,
|
|
"oldest_date": None
|
|
}
|
|
|
|
# Find newest and oldest dates
|
|
dates = [m['date'] for m in measurements]
|
|
newest_date = max(dates)
|
|
oldest_date = min(dates)
|
|
|
|
return {
|
|
"measurements": measurements,
|
|
"confidence": confidence,
|
|
"data_points": len(measurements),
|
|
"newest_date": newest_date,
|
|
"oldest_date": oldest_date
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Calculated Metrics (migrated from calculations/body_metrics.py)
|
|
# Phase 0c: Single Source of Truth for KI + Charts
|
|
# ============================================================================
|
|
|
|
# ── Weight Trend Calculations ──────────────────────────────────────────────
|
|
|
|
def calculate_weight_7d_median(profile_id: str) -> Optional[float]:
|
|
"""Calculate 7-day median weight (reduces daily noise)"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
SELECT weight
|
|
FROM weight_log
|
|
WHERE profile_id = %s
|
|
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
ORDER BY date DESC
|
|
""", (profile_id,))
|
|
|
|
weights = [
|
|
safe_float(row['weight'])
|
|
for row in cur.fetchall()
|
|
if row['weight'] is not None
|
|
]
|
|
|
|
if len(weights) < 4: # Need at least 4 measurements
|
|
return None
|
|
|
|
return round(float(statistics.median(weights)), 1)
|
|
|
|
|
|
def calculate_weight_28d_slope(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day weight slope (kg/day)"""
|
|
return _calculate_weight_slope(profile_id, days=28)
|
|
|
|
|
|
def calculate_weight_90d_slope(profile_id: str) -> Optional[float]:
|
|
"""Calculate 90-day weight slope (kg/day)"""
|
|
return _calculate_weight_slope(profile_id, days=90)
|
|
|
|
|
|
def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
|
"""
|
|
Calculate weight slope using linear regression
|
|
Returns kg/day (negative = weight loss)
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
SELECT date, weight
|
|
FROM weight_log
|
|
WHERE profile_id = %s
|
|
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
ORDER BY date
|
|
""", (profile_id, days))
|
|
|
|
data = [
|
|
(row['date'], safe_float(row['weight']))
|
|
for row in cur.fetchall()
|
|
if row['weight'] is not None
|
|
]
|
|
|
|
# Need minimum data points based on period
|
|
min_points = max(18, int(days * 0.6)) # 60% coverage
|
|
if len(data) < min_points:
|
|
return None
|
|
|
|
# Convert dates to days since start
|
|
start_date = data[0][0]
|
|
x_values = [(date - start_date).days for date, _ in data]
|
|
y_values = [w for _, w in data]
|
|
|
|
# Linear regression (alles float: PostgreSQL numeric → Decimal in Python)
|
|
n = len(data)
|
|
x_mean = float(sum(x_values)) / n
|
|
y_mean = float(sum(y_values)) / n
|
|
|
|
numerator = sum(float(x - x_mean) * float(y - y_mean) for x, y in zip(x_values, y_values))
|
|
denominator = float(sum((x - x_mean) ** 2 for x in x_values))
|
|
|
|
if denominator == 0:
|
|
return None
|
|
|
|
slope = numerator / denominator
|
|
return round(float(slope), 4) # kg/day
|
|
|
|
|
|
def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]:
|
|
"""
|
|
Calculate projected date to reach goal based on 28d trend
|
|
Returns ISO date string or None if unrealistic
|
|
"""
|
|
from goal_utils import get_goal_by_id
|
|
|
|
goal = get_goal_by_id(goal_id)
|
|
if not goal or goal['goal_type'] != 'weight':
|
|
return None
|
|
|
|
slope = calculate_weight_28d_slope(profile_id)
|
|
if not slope or slope == 0:
|
|
return None
|
|
|
|
current = goal['current_value']
|
|
target = goal['target_value']
|
|
remaining = target - current
|
|
|
|
days_needed = remaining / slope
|
|
|
|
# Unrealistic if >2 years or negative
|
|
if days_needed < 0 or days_needed > 730:
|
|
return None
|
|
|
|
projection_date = datetime.now().date() + timedelta(days=int(days_needed))
|
|
return projection_date.isoformat()
|
|
|
|
|
|
def calculate_goal_progress_pct(current: float, target: float, start: float) -> int:
|
|
"""
|
|
Calculate goal progress percentage
|
|
Returns 0-100 (can exceed 100 if target surpassed)
|
|
"""
|
|
if start == target:
|
|
return 100 if current == target else 0
|
|
|
|
progress = ((current - start) / (target - start)) * 100
|
|
return max(0, min(100, int(progress)))
|
|
|
|
|
|
# ── Fat Mass / Lean Mass Calculations ───────────────────────────────────────
|
|
|
|
def calculate_fm_28d_change(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day fat mass change (kg)"""
|
|
return _calculate_body_composition_change(profile_id, 'fm', 28)
|
|
|
|
|
|
def calculate_lbm_28d_change(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day lean body mass change (kg)"""
|
|
return _calculate_body_composition_change(profile_id, 'lbm', 28)
|
|
|
|
|
|
def _calculate_body_composition_change(profile_id: str, metric: str, days: int) -> Optional[float]:
|
|
"""
|
|
Calculate change in body composition over period
|
|
metric: 'fm' (fat mass) or 'lbm' (lean mass)
|
|
"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# Get weight and caliper measurements
|
|
cur.execute("""
|
|
SELECT w.date, w.weight, c.body_fat_pct
|
|
FROM weight_log w
|
|
LEFT JOIN caliper_log c ON w.profile_id = c.profile_id
|
|
AND w.date = c.date
|
|
WHERE w.profile_id = %s
|
|
AND w.date >= CURRENT_DATE - INTERVAL '%s days'
|
|
ORDER BY w.date DESC
|
|
""", (profile_id, days))
|
|
|
|
data = [
|
|
{
|
|
'date': row['date'],
|
|
'weight': row['weight'],
|
|
'bf_pct': row['body_fat_pct']
|
|
}
|
|
for row in cur.fetchall()
|
|
if row['body_fat_pct'] is not None
|
|
]
|
|
|
|
if len(data) < 2:
|
|
return None
|
|
|
|
# Most recent and oldest measurement
|
|
recent = data[0]
|
|
oldest = data[-1]
|
|
|
|
# Calculate FM and LBM (DB numeric → Decimal; für Regression/Scores nur float)
|
|
rw = float(safe_float(recent['weight']) or 0)
|
|
ob = float(safe_float(recent['bf_pct']) or 0)
|
|
ow = float(safe_float(oldest['weight']) or 0)
|
|
obf = float(safe_float(oldest['bf_pct']) or 0)
|
|
|
|
recent_fm = rw * (ob / 100)
|
|
recent_lbm = rw - recent_fm
|
|
|
|
oldest_fm = ow * (obf / 100)
|
|
oldest_lbm = ow - oldest_fm
|
|
|
|
if metric == 'fm':
|
|
change = recent_fm - oldest_fm
|
|
else:
|
|
change = recent_lbm - oldest_lbm
|
|
|
|
return round(float(change), 2)
|
|
|
|
|
|
# ── Circumference Calculations ──────────────────────────────────────────────
|
|
|
|
def calculate_waist_28d_delta(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day waist circumference change (cm)"""
|
|
return _calculate_circumference_delta(profile_id, 'c_waist', 28)
|
|
|
|
|
|
def calculate_hip_28d_delta(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day hip circumference change (cm)"""
|
|
return _calculate_circumference_delta(profile_id, 'c_hip', 28)
|
|
|
|
|
|
def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day chest circumference change (cm)"""
|
|
return _calculate_circumference_delta(profile_id, 'c_chest', 28)
|
|
|
|
|
|
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day arm circumference change (cm)"""
|
|
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
|
|
|
|
|
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
|
"""Calculate 28-day thigh circumference change (cm)"""
|
|
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
|
if delta is None:
|
|
return None
|
|
return round(delta, 1)
|
|
|
|
|
|
def _calculate_circumference_delta(profile_id: str, column: str, days: int) -> Optional[float]:
|
|
"""Calculate change in circumference measurement"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute(f"""
|
|
SELECT {column}
|
|
FROM circumference_log
|
|
WHERE profile_id = %s
|
|
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
AND {column} IS NOT NULL
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""", (profile_id, days))
|
|
|
|
recent = cur.fetchone()
|
|
if not recent:
|
|
return None
|
|
|
|
cur.execute(f"""
|
|
SELECT {column}
|
|
FROM circumference_log
|
|
WHERE profile_id = %s
|
|
AND date < CURRENT_DATE - INTERVAL '%s days'
|
|
AND {column} IS NOT NULL
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""", (profile_id, days))
|
|
|
|
oldest = cur.fetchone()
|
|
if not oldest:
|
|
return None
|
|
|
|
change = recent[column] - oldest[column]
|
|
return round(change, 1)
|
|
|
|
|
|
def calculate_waist_hip_ratio(profile_id: str) -> Optional[float]:
|
|
"""Calculate current waist-to-hip ratio"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
SELECT c_waist, c_hip
|
|
FROM circumference_log
|
|
WHERE profile_id = %s
|
|
AND c_waist IS NOT NULL
|
|
AND c_hip IS NOT NULL
|
|
ORDER BY date DESC
|
|
LIMIT 1
|
|
""", (profile_id,))
|
|
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return None
|
|
|
|
ratio = row['c_waist'] / row['c_hip']
|
|
return round(ratio, 3)
|
|
|
|
|
|
# ── Recomposition Detector ───────────────────────────────────────────────────
|
|
|
|
def calculate_recomposition_quadrant(profile_id: str) -> Optional[str]:
|
|
"""
|
|
Determine recomposition quadrant based on 28d changes:
|
|
- optimal: FM down, LBM up
|
|
- cut_with_risk: FM down, LBM down
|
|
- bulk: FM up, LBM up
|
|
- unfavorable: FM up, LBM down
|
|
"""
|
|
fm_change = calculate_fm_28d_change(profile_id)
|
|
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
|
|
if fm_change is None or lbm_change is None:
|
|
return None
|
|
|
|
if fm_change < 0 and lbm_change > 0:
|
|
return "optimal"
|
|
elif fm_change < 0 and lbm_change < 0:
|
|
return "cut_with_risk"
|
|
elif fm_change > 0 and lbm_change > 0:
|
|
return "bulk"
|
|
else:
|
|
return "unfavorable"
|
|
|
|
|
|
# ── Body Progress Score ───────────────────────────────────────────────────────
|
|
|
|
def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
"""Calculate body progress score (0-100) weighted by user's focus areas"""
|
|
if focus_weights is None:
|
|
from data_layer.scores import get_user_focus_weights
|
|
focus_weights = get_user_focus_weights(profile_id)
|
|
|
|
weight_loss = float(focus_weights.get('weight_loss', 0) or 0)
|
|
muscle_gain = float(focus_weights.get('muscle_gain', 0) or 0)
|
|
body_recomp = float(focus_weights.get('body_recomposition', 0) or 0)
|
|
|
|
total_body_weight = weight_loss + muscle_gain + body_recomp
|
|
|
|
if total_body_weight == 0:
|
|
return None
|
|
|
|
components = []
|
|
|
|
if weight_loss > 0:
|
|
weight_score = _score_weight_trend(profile_id)
|
|
if weight_score is not None:
|
|
components.append(('weight', weight_score, weight_loss))
|
|
|
|
if muscle_gain > 0 or body_recomp > 0:
|
|
comp_score = _score_body_composition(profile_id)
|
|
if comp_score is not None:
|
|
components.append(('composition', comp_score, muscle_gain + body_recomp))
|
|
|
|
waist_score = _score_waist_trend(profile_id)
|
|
if waist_score is not None:
|
|
waist_weight = 20 + (weight_loss * 0.3)
|
|
components.append(('waist', waist_score, waist_weight))
|
|
|
|
if not components:
|
|
return None
|
|
|
|
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_weight_trend(profile_id: str) -> Optional[int]:
|
|
"""Score weight trend alignment with goals (0-100)"""
|
|
from goal_utils import get_active_goals
|
|
|
|
goals = get_active_goals(profile_id)
|
|
weight_goals = [g for g in goals if g.get('goal_type') == 'weight']
|
|
if not weight_goals:
|
|
return None
|
|
|
|
goal = next((g for g in weight_goals if g.get('is_primary')), weight_goals[0])
|
|
|
|
current = goal.get('current_value')
|
|
target = goal.get('target_value')
|
|
start = goal.get('start_value')
|
|
|
|
if None in [current, target]:
|
|
return None
|
|
|
|
current = float(current)
|
|
target = float(target)
|
|
|
|
if start is None:
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
SELECT weight
|
|
FROM weight_log
|
|
WHERE profile_id = %s
|
|
AND date >= CURRENT_DATE - INTERVAL '90 days'
|
|
ORDER BY date ASC
|
|
LIMIT 1
|
|
""", (profile_id,))
|
|
row = cur.fetchone()
|
|
start = float(row['weight']) if row else current
|
|
else:
|
|
start = float(start)
|
|
|
|
progress_pct = calculate_goal_progress_pct(current, target, start)
|
|
|
|
slope = calculate_weight_28d_slope(profile_id)
|
|
if slope is not None:
|
|
desired_direction = -1 if target < start else 1
|
|
actual_direction = -1 if slope < 0 else 1
|
|
|
|
if desired_direction == actual_direction:
|
|
score = min(100, progress_pct + 10)
|
|
else:
|
|
score = max(0, progress_pct - 20)
|
|
else:
|
|
score = progress_pct
|
|
|
|
return int(score)
|
|
|
|
|
|
def _score_body_composition(profile_id: str) -> Optional[int]:
|
|
"""Score body composition changes (0-100)"""
|
|
fm_change = calculate_fm_28d_change(profile_id)
|
|
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
|
|
if fm_change is None or lbm_change is None:
|
|
return None
|
|
|
|
quadrant = calculate_recomposition_quadrant(profile_id)
|
|
|
|
if quadrant == "optimal":
|
|
return 100
|
|
elif quadrant == "cut_with_risk":
|
|
penalty = min(30, abs(lbm_change) * 15)
|
|
return max(50, 80 - int(penalty))
|
|
elif quadrant == "bulk":
|
|
if lbm_change > 0 and fm_change > 0:
|
|
ratio = lbm_change / fm_change
|
|
if ratio >= 3:
|
|
return 90
|
|
elif ratio >= 2:
|
|
return 75
|
|
elif ratio >= 1:
|
|
return 60
|
|
else:
|
|
return 45
|
|
return 60
|
|
else:
|
|
return 20
|
|
|
|
|
|
def _score_waist_trend(profile_id: str) -> Optional[int]:
|
|
"""Score waist circumference trend (0-100)"""
|
|
delta = calculate_waist_28d_delta(profile_id)
|
|
|
|
if delta is None:
|
|
return None
|
|
|
|
if delta <= -3:
|
|
return 100
|
|
elif delta <= -2:
|
|
return 90
|
|
elif delta <= -1:
|
|
return 80
|
|
elif delta <= 0:
|
|
return 70
|
|
elif delta <= 1:
|
|
return 55
|
|
elif delta <= 2:
|
|
return 40
|
|
else:
|
|
return 20
|
|
|
|
|
|
# ── Data Quality Assessment ───────────────────────────────────────────────────
|
|
|
|
def calculate_body_data_quality(profile_id: str) -> Dict[str, any]:
|
|
"""Assess data quality for body metrics"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
cur.execute("""
|
|
SELECT COUNT(*) as count
|
|
FROM weight_log
|
|
WHERE profile_id = %s
|
|
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
""", (profile_id,))
|
|
weight_count = cur.fetchone()['count']
|
|
|
|
cur.execute("""
|
|
SELECT COUNT(*) as count
|
|
FROM caliper_log
|
|
WHERE profile_id = %s
|
|
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
""", (profile_id,))
|
|
caliper_count = cur.fetchone()['count']
|
|
|
|
cur.execute("""
|
|
SELECT COUNT(*) as count
|
|
FROM circumference_log
|
|
WHERE profile_id = %s
|
|
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
""", (profile_id,))
|
|
circ_count = cur.fetchone()['count']
|
|
|
|
weight_score = min(100, (weight_count / 18) * 100)
|
|
caliper_score = min(100, (caliper_count / 4) * 100)
|
|
circ_score = min(100, (circ_count / 4) * 100)
|
|
|
|
overall_score = int(
|
|
weight_score * 0.5 +
|
|
caliper_score * 0.3 +
|
|
circ_score * 0.2
|
|
)
|
|
|
|
if overall_score >= 80:
|
|
confidence = "high"
|
|
elif overall_score >= 60:
|
|
confidence = "medium"
|
|
else:
|
|
confidence = "low"
|
|
|
|
return {
|
|
"overall_score": overall_score,
|
|
"confidence": confidence,
|
|
"measurements": {
|
|
"weight_28d": weight_count,
|
|
"caliper_28d": caliper_count,
|
|
"circumference_28d": circ_count
|
|
},
|
|
"component_scores": {
|
|
"weight": int(weight_score),
|
|
"caliper": int(caliper_score),
|
|
"circumference": int(circ_score)
|
|
}
|
|
}
|