mitai-jinkendo/backend/data_layer/body_metrics.py
Lars 504581838c
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
feat: Phase 0c - migrate body_metrics calculations to data_layer (20 functions)
- Migrated all 20 calculation functions from calculations/body_metrics.py to data_layer/body_metrics.py
- Functions: weight trends (7d median, 28d/90d slopes, goal projection, progress)
- Functions: body composition (FM/LBM changes)
- Functions: circumferences (waist/hip/chest/arm/thigh deltas, WHR)
- Functions: recomposition quadrant
- Functions: scoring (body progress, data quality)
- Updated data_layer/__init__.py with 20 new exports
- Refactored placeholder_resolver.py to import body_metrics from data_layer

Module 1/6 complete. Single Source of Truth for body metrics established.

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

832 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 goal_utils import get_focus_weights
with get_db() as conn:
focus_weights = get_focus_weights(conn, 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)
}
}