Spaces:
Running on Zero
Running on Zero
| """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) | |