YourGymBuddy / app /utils /analytics.py
PedroRuizCode's picture
Initial deploy to Hugging Face Space
3f00f74
Raw
History Blame Contribute Delete
10.1 kB
"""Training analytics computed from parsed Sessions.
Pure functions over the normalized data from `parser.py`. Output is JSON-serializable
so it can be returned straight to the frontend and also fed to the chat model as context.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta
from statistics import mean
from typing import Any
from .parser import Session
def estimated_1rm(weight_kg: float, reps: int) -> float:
"""Epley formula. Caps reps so very high-rep sets don't explode the estimate."""
if weight_kg <= 0 or reps <= 0:
return 0.0
reps = min(reps, 20)
return round(weight_kg * (1 + reps / 30.0), 1)
def _iso_week(dt: datetime) -> str:
y, w, _ = dt.isocalendar()
return f"{y}-W{w:02d}"
def summarize(sessions: list[Session]) -> dict[str, Any]:
"""Top-level totals for the dashboard header."""
if not sessions:
return {
"total_sessions": 0,
"total_sets": 0,
"total_volume_kg": 0.0,
"total_reps": 0,
"first_session": None,
"last_session": None,
"avg_session_minutes": None,
"unique_exercises": 0,
}
total_sets = sum(s.total_sets for s in sessions)
total_volume = sum(s.volume_kg for s in sessions)
total_reps = sum(
st.reps or 0 for s in sessions for e in s.exercises for st in e.sets
)
durations = [s.duration_minutes for s in sessions if s.duration_minutes]
dated = [s for s in sessions if s.start_time]
unique_ex = {e.name for s in sessions for e in s.exercises}
return {
"total_sessions": len(sessions),
"total_sets": total_sets,
"total_volume_kg": round(total_volume, 1),
"total_reps": total_reps,
"first_session": dated[0].start_time.isoformat() if dated else None,
"last_session": dated[-1].start_time.isoformat() if dated else None,
"avg_session_minutes": round(mean(durations), 1) if durations else None,
"unique_exercises": len(unique_ex),
}
def session_volume_series(sessions: list[Session]) -> list[dict[str, Any]]:
"""Per-session volume / sets, in chronological order (for line charts)."""
series = []
for s in sessions:
series.append({
"date": s.start_time.isoformat() if s.start_time else None,
"title": s.title,
"volume_kg": round(s.volume_kg, 1),
"sets": s.total_sets,
"duration_minutes": s.duration_minutes,
})
return series
def weekly_volume(sessions: list[Session]) -> list[dict[str, Any]]:
"""Volume aggregated by ISO week."""
buckets: dict[str, dict[str, float]] = defaultdict(lambda: {"volume_kg": 0.0, "sessions": 0, "sets": 0})
for s in sessions:
if not s.start_time:
continue
key = _iso_week(s.start_time)
buckets[key]["volume_kg"] += s.volume_kg
buckets[key]["sessions"] += 1
buckets[key]["sets"] += s.total_sets
return [
{"week": k, "volume_kg": round(v["volume_kg"], 1),
"sessions": int(v["sessions"]), "sets": int(v["sets"])}
for k, v in sorted(buckets.items())
]
def muscle_group_balance(sessions: list[Session]) -> list[dict[str, Any]]:
"""Volume and set count grouped by muscle group, with share of total."""
by_group: dict[str, dict[str, float]] = defaultdict(lambda: {"volume_kg": 0.0, "sets": 0})
for s in sessions:
for e in s.exercises:
by_group[e.muscle_group]["volume_kg"] += e.volume_kg
by_group[e.muscle_group]["sets"] += len(e.sets)
total_sets = sum(v["sets"] for v in by_group.values()) or 1
rows = [
{
"muscle_group": g,
"volume_kg": round(v["volume_kg"], 1),
"sets": int(v["sets"]),
"set_share_pct": round(100 * v["sets"] / total_sets, 1),
}
for g, v in by_group.items()
]
rows.sort(key=lambda r: r["sets"], reverse=True)
return rows
def exercise_progress(sessions: list[Session]) -> list[dict[str, Any]]:
"""Per-exercise progression: best est-1RM, top set, trend, and PR history."""
history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for s in sessions:
if not s.start_time:
continue
for e in s.exercises:
best_1rm = 0.0
best_set = None
for st in e.sets:
if st.weight_kg and st.reps:
e1 = estimated_1rm(st.weight_kg, st.reps)
if e1 > best_1rm:
best_1rm = e1
best_set = st
if best_set is not None:
history[e.name].append({
"date": s.start_time.isoformat(),
"est_1rm_kg": best_1rm,
"top_weight_kg": best_set.weight_kg,
"top_reps": best_set.reps,
"muscle_group": e.muscle_group,
})
results = []
for name, points in history.items():
points.sort(key=lambda p: p["date"])
first = points[0]["est_1rm_kg"]
last = points[-1]["est_1rm_kg"]
best = max(points, key=lambda p: p["est_1rm_kg"])
delta = round(last - first, 1)
results.append({
"exercise": name,
"muscle_group": points[0]["muscle_group"],
"sessions": len(points),
"first_est_1rm_kg": first,
"latest_est_1rm_kg": last,
"best_est_1rm_kg": best["est_1rm_kg"],
"best_weight_kg": best["top_weight_kg"],
"best_reps": best["top_reps"],
"change_kg": delta,
"change_pct": round(100 * delta / first, 1) if first else 0.0,
"history": points,
})
results.sort(key=lambda r: r["sessions"], reverse=True)
return results
def detect_plateaus(progress: list[dict[str, Any]], min_sessions: int = 3) -> list[dict[str, Any]]:
"""Flag exercises trained >= min_sessions whose est-1RM has stalled or dropped."""
flags = []
for p in progress:
if p["sessions"] < min_sessions:
continue
recent = p["history"][-min_sessions:]
values = [pt["est_1rm_kg"] for pt in recent]
if max(values) - min(values) <= max(values) * 0.02: # within 2%
flags.append({
"exercise": p["exercise"],
"muscle_group": p["muscle_group"],
"status": "plateau",
"latest_est_1rm_kg": p["latest_est_1rm_kg"],
"note": f"No meaningful change over the last {min_sessions} sessions.",
})
elif p["change_kg"] < 0:
flags.append({
"exercise": p["exercise"],
"muscle_group": p["muscle_group"],
"status": "regression",
"latest_est_1rm_kg": p["latest_est_1rm_kg"],
"note": f"Estimated 1RM down {abs(p['change_kg'])}kg vs first session.",
})
return flags
def training_frequency(sessions: list[Session]) -> dict[str, Any]:
"""Rough cadence: sessions per week over the logged span."""
dated = [s.start_time for s in sessions if s.start_time]
if len(dated) < 2:
return {"sessions_per_week": None, "span_days": None, "days_since_last": None}
span_days = (max(dated) - min(dated)).days or 1
per_week = round(len(dated) / (span_days / 7.0), 2)
days_since_last = (datetime.now() - max(dated)).days
return {
"sessions_per_week": per_week,
"span_days": span_days,
"days_since_last": max(days_since_last, 0),
}
def build_report(sessions: list[Session]) -> dict[str, Any]:
"""Full analytics payload returned to the frontend."""
progress = exercise_progress(sessions)
return {
"summary": summarize(sessions),
"session_volume": session_volume_series(sessions),
"weekly_volume": weekly_volume(sessions),
"muscle_balance": muscle_group_balance(sessions),
"exercise_progress": progress,
"plateaus": detect_plateaus(progress),
"frequency": training_frequency(sessions),
}
def build_coach_context(sessions: list[Session], max_exercises: int = 12) -> str:
"""Compact, token-friendly text summary of the athlete for the chat model."""
if not sessions:
return "No workout data has been imported yet."
report = build_report(sessions)
s = report["summary"]
freq = report["frequency"]
lines: list[str] = ["ATHLETE TRAINING SUMMARY", ""]
span = ""
if s["first_session"] and s["last_session"]:
span = f" between {s['first_session'][:10]} and {s['last_session'][:10]}"
lines.append(
f"- {s['total_sessions']} sessions{span}, {s['total_sets']} sets, "
f"{s['total_volume_kg']:.0f} kg total volume, {s['unique_exercises']} distinct exercises."
)
if freq["sessions_per_week"] is not None:
lines.append(
f"- Frequency: ~{freq['sessions_per_week']} sessions/week; "
f"{freq['days_since_last']} day(s) since last session."
)
lines.append("")
lines.append("MUSCLE-GROUP BALANCE (by set share):")
for row in report["muscle_balance"]:
lines.append(
f" - {row['muscle_group']}: {row['sets']} sets ({row['set_share_pct']}%), "
f"{row['volume_kg']:.0f} kg"
)
lines.append("")
lines.append("KEY LIFT PROGRESSION (estimated 1RM):")
for p in report["exercise_progress"][:max_exercises]:
trend = "+" if p["change_kg"] >= 0 else ""
lines.append(
f" - {p['exercise']} [{p['muscle_group']}]: now ~{p['latest_est_1rm_kg']}kg "
f"(best {p['best_est_1rm_kg']}kg, {trend}{p['change_kg']}kg over {p['sessions']} sessions)"
)
if report["plateaus"]:
lines.append("")
lines.append("FLAGS:")
for f in report["plateaus"]:
lines.append(f" - {f['exercise']} [{f['status']}]: {f['note']}")
return "\n".join(lines)