Two critical fixes for placeholder resolution: 1. Missing import in activity_metrics.py: - Added 'import statistics' at module level - Fixes calculate_monotony_score() and calculate_strain_score() - Error: NameError: name 'statistics' is not defined 2. Outdated focus_weights function in body_metrics.py: - Changed from goal_utils.get_focus_weights (uses old focus_areas table) - To data_layer.scores.get_user_focus_weights (uses new v2.0 system) - Fixes calculate_body_progress_score() - Error: UndefinedTable: relation "focus_areas" does not exist These were causing many placeholders to fail silently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
831 lines
25 KiB
Python
831 lines
25 KiB
Python
"""
|
|
Body Metrics Data Layer
|
|
|
|
Provides structured data for body composition and measurements.
|
|
|
|
Functions:
|
|
- get_latest_weight_data(): Most recent weight entry
|
|
- 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_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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
# 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']
|
|
}
|
|
|
|
|
|
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 = [row['weight'] for row in cur.fetchall()]
|
|
|
|
if len(weights) < 4: # Need at least 4 measurements
|
|
return None
|
|
|
|
return round(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'], row['weight']) for row in cur.fetchall()]
|
|
|
|
# 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 = [weight for _, weight in data]
|
|
|
|
# Linear regression
|
|
n = len(data)
|
|
x_mean = sum(x_values) / n
|
|
y_mean = sum(y_values) / n
|
|
|
|
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
|
denominator = sum((x - x_mean) ** 2 for x in x_values)
|
|
|
|
if denominator == 0:
|
|
return None
|
|
|
|
slope = numerator / denominator
|
|
return round(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
|
|
recent_fm = recent['weight'] * (recent['bf_pct'] / 100)
|
|
recent_lbm = recent['weight'] - recent_fm
|
|
|
|
oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100)
|
|
oldest_lbm = oldest['weight'] - oldest_fm
|
|
|
|
if metric == 'fm':
|
|
change = recent_fm - oldest_fm
|
|
else:
|
|
change = recent_lbm - oldest_lbm
|
|
|
|
return round(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 = focus_weights.get('weight_loss', 0)
|
|
muscle_gain = focus_weights.get('muscle_gain', 0)
|
|
body_recomp = focus_weights.get('body_recomposition', 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(score * weight for _, score, weight in components)
|
|
total_weight = sum(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)
|
|
}
|
|
}
|