Spaces:
Running on Zero
Running on Zero
| """Generic gym-app workout parser. | |
| Designed to ingest CSV exports from a variety of tracking apps (Hevy, Strong, | |
| FitNotes, etc.), not just one. Instead of hard-coding column names, we map a set | |
| of *aliases* onto a normalized schema, auto-detect weight units, and group rows | |
| into sessions -> exercises -> sets. | |
| Normalized structures: | |
| Set -> one performed set (weight in kg + lbs, reps, rpe, distance, duration) | |
| Exercise -> named movement with its ordered sets | |
| Session -> a single workout (title, start/end, ordered exercises) | |
| """ | |
| from __future__ import annotations | |
| import io | |
| from dataclasses import dataclass, field | |
| from datetime import datetime | |
| from typing import Any, Iterable | |
| import pandas as pd | |
| LB_TO_KG = 0.45359237 | |
| # Column aliases: normalized_name -> possible source headers (lower-cased, stripped). | |
| COLUMN_ALIASES: dict[str, tuple[str, ...]] = { | |
| "session_title": ("title", "workout_name", "workout", "routine", "name"), | |
| "start_time": ("start_time", "start", "date", "datetime", "workout_date", "time"), | |
| "end_time": ("end_time", "end", "finish_time"), | |
| "exercise": ("exercise_title", "exercise_name", "exercise", "movement"), | |
| "superset_id": ("superset_id", "superset"), | |
| "notes": ("exercise_notes", "notes", "note", "comment"), | |
| "set_index": ("set_index", "set_number", "set", "set_order"), | |
| "set_type": ("set_type", "type"), | |
| "weight_lbs": ("weight_lbs", "weight_lb", "weight_pounds", "lbs"), | |
| "weight_kg": ("weight_kg", "weight_kgs", "kg", "kilograms"), | |
| "weight": ("weight",), # ambiguous unit; resolved via `weight_unit` | |
| "weight_unit": ("weight_unit", "unit", "units"), | |
| "reps": ("reps", "rep", "repetitions"), | |
| "distance_km": ("distance_km", "distance"), | |
| "duration_seconds": ("duration_seconds", "duration", "seconds", "time_seconds"), | |
| "rpe": ("rpe", "rir"), | |
| } | |
| DATE_FORMATS = ( | |
| "%d %b %Y, %H:%M", # "3 jun 2026, 12:45" | |
| "%Y-%m-%d %H:%M:%S", | |
| "%Y-%m-%d %H:%M", | |
| "%d/%m/%Y %H:%M", | |
| "%m/%d/%Y %H:%M", | |
| "%Y-%m-%dT%H:%M:%S", | |
| "%Y-%m-%d", | |
| ) | |
| # Spanish month abbreviations -> English, so strptime can read either language. | |
| _ES_MONTHS = { | |
| "ene": "Jan", "feb": "Feb", "mar": "Mar", "abr": "Apr", "may": "May", | |
| "jun": "Jun", "jul": "Jul", "ago": "Aug", "sep": "Sep", "set": "Sep", | |
| "oct": "Oct", "nov": "Nov", "dic": "Dec", | |
| } | |
| class WorkoutSet: | |
| set_index: int | |
| set_type: str = "normal" | |
| weight_kg: float | None = None | |
| reps: int | None = None | |
| rpe: float | None = None | |
| distance_km: float | None = None | |
| duration_seconds: float | None = None | |
| def weight_lbs(self) -> float | None: | |
| return None if self.weight_kg is None else round(self.weight_kg / LB_TO_KG, 2) | |
| def volume_kg(self) -> float: | |
| if self.weight_kg is None or self.reps is None: | |
| return 0.0 | |
| return self.weight_kg * self.reps | |
| def to_dict(self) -> dict[str, Any]: | |
| return { | |
| "set_index": self.set_index, | |
| "set_type": self.set_type, | |
| "weight_kg": self.weight_kg, | |
| "weight_lbs": self.weight_lbs, | |
| "reps": self.reps, | |
| "rpe": self.rpe, | |
| "distance_km": self.distance_km, | |
| "duration_seconds": self.duration_seconds, | |
| "volume_kg": round(self.volume_kg, 2), | |
| } | |
| class Exercise: | |
| name: str | |
| muscle_group: str = "other" | |
| notes: str = "" | |
| sets: list[WorkoutSet] = field(default_factory=list) | |
| def volume_kg(self) -> float: | |
| return sum(s.volume_kg for s in self.sets) | |
| def to_dict(self) -> dict[str, Any]: | |
| return { | |
| "name": self.name, | |
| "muscle_group": self.muscle_group, | |
| "notes": self.notes, | |
| "sets": [s.to_dict() for s in self.sets], | |
| "volume_kg": round(self.volume_kg, 2), | |
| } | |
| class Session: | |
| title: str | |
| start_time: datetime | None | |
| end_time: datetime | None | |
| exercises: list[Exercise] = field(default_factory=list) | |
| def volume_kg(self) -> float: | |
| return sum(e.volume_kg for e in self.exercises) | |
| def duration_minutes(self) -> float | None: | |
| if self.start_time and self.end_time: | |
| return round((self.end_time - self.start_time).total_seconds() / 60.0, 1) | |
| return None | |
| def total_sets(self) -> int: | |
| return sum(len(e.sets) for e in self.exercises) | |
| def to_dict(self) -> dict[str, Any]: | |
| return { | |
| "title": self.title, | |
| "start_time": self.start_time.isoformat() if self.start_time else None, | |
| "end_time": self.end_time.isoformat() if self.end_time else None, | |
| "duration_minutes": self.duration_minutes, | |
| "volume_kg": round(self.volume_kg, 2), | |
| "total_sets": self.total_sets, | |
| "exercises": [e.to_dict() for e in self.exercises], | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # Muscle-group inference (bilingual keyword match: English + Spanish) | |
| # -------------------------------------------------------------------------------------- | |
| _MUSCLE_KEYWORDS: dict[str, tuple[str, ...]] = { | |
| "chest": ("bench", "chest", "press de banca", "pecho", "aperturas", "fly", "vuelos", | |
| "pec", "dips", "fondos"), | |
| "back": ("row", "remo", "pull", "jalon", "jalón", "dominada", "pulldown", "lat", | |
| "espalda", "deadlift", "peso muerto", "pullover"), | |
| "shoulders": ("shoulder", "hombro", "press de hombros", "overhead", "lateral", | |
| "elevacion", "elevación", "raise", "delt", "arnold", "face pull", | |
| "posteriores"), | |
| "biceps": ("curl", "biceps", "bíceps", "predicador", "preacher"), | |
| "triceps": ("triceps", "tríceps", "extension de codo", "pushdown", "skull", | |
| "frances", "francés", "press cerrado"), | |
| "legs": ("squat", "sentadilla", "leg", "pierna", "lunge", "zancada", "press a una", | |
| "extension de pierna", "extensión de pierna", "leg press", "prensa", | |
| "curl femoral", "hamstring", "femoral", "hack", "goblet"), | |
| "glutes": ("glute", "gluteo", "glúteo", "hip thrust", "empuje de cadera", "puente"), | |
| "calves": ("calf", "calves", "gemelo", "gastrocnemio", "soleo", "pantorrilla"), | |
| "core": ("ab", "core", "plank", "plancha", "crunch", "abdominal", "oblicuo", | |
| "russian twist"), | |
| "cardio": ("run", "carrera", "treadmill", "cinta", "bike", "bicicleta", "row erg", | |
| "remo ergometro", "elliptical", "eliptica", "elíptica", "cardio"), | |
| "forearms": ("forearm", "antebrazo", "wrist", "muñeca", "invertido", "reverse curl", | |
| "grip", "agarre"), | |
| } | |
| def infer_muscle_group(exercise_name: str) -> str: | |
| name = exercise_name.lower() | |
| for group, keywords in _MUSCLE_KEYWORDS.items(): | |
| if any(kw in name for kw in keywords): | |
| return group | |
| return "other" | |
| # -------------------------------------------------------------------------------------- | |
| # Parsing helpers | |
| # -------------------------------------------------------------------------------------- | |
| def _normalize_headers(df: pd.DataFrame) -> dict[str, str]: | |
| """Map normalized field name -> actual column present in the dataframe.""" | |
| lower_to_actual = {str(c).strip().lower(): c for c in df.columns} | |
| resolved: dict[str, str] = {} | |
| for field_name, aliases in COLUMN_ALIASES.items(): | |
| for alias in aliases: | |
| if alias in lower_to_actual: | |
| resolved[field_name] = lower_to_actual[alias] | |
| break | |
| return resolved | |
| def _to_float(value: Any) -> float | None: | |
| if value is None: | |
| return None | |
| if isinstance(value, float) and pd.isna(value): | |
| return None | |
| s = str(value).strip().replace(",", ".") | |
| if s == "" or s.lower() in {"nan", "none", "null"}: | |
| return None | |
| try: | |
| return float(s) | |
| except ValueError: | |
| return None | |
| def _to_int(value: Any) -> int | None: | |
| f = _to_float(value) | |
| return None if f is None else int(round(f)) | |
| def parse_datetime(value: Any) -> datetime | None: | |
| if value is None or (isinstance(value, float) and pd.isna(value)): | |
| return None | |
| raw = str(value).strip() | |
| if not raw: | |
| return None | |
| lowered = raw.lower() | |
| for es, en in _ES_MONTHS.items(): | |
| lowered = lowered.replace(f" {es} ", f" {en} ") | |
| candidate = lowered.title().replace("Am", "AM").replace("Pm", "PM") | |
| for fmt in DATE_FORMATS: | |
| try: | |
| return datetime.strptime(candidate, fmt) | |
| except ValueError: | |
| continue | |
| parsed = pd.to_datetime(raw, errors="coerce", dayfirst=True) | |
| return None if pd.isna(parsed) else parsed.to_pydatetime() | |
| def _resolve_weight_kg(row: pd.Series, cols: dict[str, str]) -> float | None: | |
| """Return weight in kilograms regardless of the source unit.""" | |
| if "weight_kg" in cols: | |
| kg = _to_float(row.get(cols["weight_kg"])) | |
| if kg is not None: | |
| return round(kg, 4) | |
| if "weight_lbs" in cols: | |
| lbs = _to_float(row.get(cols["weight_lbs"])) | |
| if lbs is not None: | |
| return round(lbs * LB_TO_KG, 4) | |
| if "weight" in cols: | |
| w = _to_float(row.get(cols["weight"])) | |
| if w is None: | |
| return None | |
| unit = "" | |
| if "weight_unit" in cols: | |
| unit = str(row.get(cols["weight_unit"], "")).strip().lower() | |
| if unit in {"lb", "lbs", "pound", "pounds"}: | |
| return round(w * LB_TO_KG, 4) | |
| return round(w, 4) # assume kg by default | |
| return None | |
| # -------------------------------------------------------------------------------------- | |
| # Public API | |
| # -------------------------------------------------------------------------------------- | |
| def parse_workouts(source: str | bytes | io.IOBase | pd.DataFrame) -> list[Session]: | |
| """Parse a workout export into a chronologically sorted list of Sessions. | |
| `source` may be a file path, raw CSV bytes/str, a file-like object, or a DataFrame. | |
| """ | |
| if isinstance(source, pd.DataFrame): | |
| df = source.copy() | |
| elif isinstance(source, (bytes, bytearray)): | |
| df = pd.read_csv(io.BytesIO(source)) | |
| elif isinstance(source, str) and ("\n" in source or "," in source) and not source.endswith(".csv"): | |
| df = pd.read_csv(io.StringIO(source)) | |
| else: | |
| df = pd.read_csv(source) | |
| if df.empty: | |
| return [] | |
| cols = _normalize_headers(df) | |
| if "exercise" not in cols: | |
| raise ValueError( | |
| "Could not find an exercise column. Expected one of: " | |
| + ", ".join(COLUMN_ALIASES["exercise"]) | |
| ) | |
| sessions: dict[tuple[str, str], Session] = {} | |
| exercises: dict[tuple[str, str, str], Exercise] = {} | |
| for _, row in df.iterrows(): | |
| title = str(row.get(cols.get("session_title", ""), "") or "Workout").strip() or "Workout" | |
| start_raw = str(row.get(cols.get("start_time", ""), "") or "") | |
| start_dt = parse_datetime(start_raw) if "start_time" in cols else None | |
| end_dt = parse_datetime(row.get(cols["end_time"])) if "end_time" in cols else None | |
| session_key = (title, start_raw) | |
| if session_key not in sessions: | |
| sessions[session_key] = Session(title=title, start_time=start_dt, end_time=end_dt) | |
| session = sessions[session_key] | |
| ex_name = str(row.get(cols["exercise"], "") or "").strip() | |
| if not ex_name: | |
| continue | |
| ex_key = (*session_key, ex_name) | |
| if ex_key not in exercises: | |
| notes = "" | |
| if "notes" in cols: | |
| notes = str(row.get(cols["notes"], "") or "").strip() | |
| exercise = Exercise( | |
| name=ex_name, | |
| muscle_group=infer_muscle_group(ex_name), | |
| notes=notes, | |
| ) | |
| exercises[ex_key] = exercise | |
| session.exercises.append(exercise) | |
| exercise = exercises[ex_key] | |
| workout_set = WorkoutSet( | |
| set_index=_to_int(row.get(cols.get("set_index", ""))) or len(exercise.sets), | |
| set_type=str(row.get(cols.get("set_type", ""), "normal") or "normal").strip() or "normal", | |
| weight_kg=_resolve_weight_kg(row, cols), | |
| reps=_to_int(row.get(cols.get("reps", ""))), | |
| rpe=_to_float(row.get(cols.get("rpe", ""))), | |
| distance_km=_to_float(row.get(cols.get("distance_km", ""))), | |
| duration_seconds=_to_float(row.get(cols.get("duration_seconds", ""))), | |
| ) | |
| exercise.sets.append(workout_set) | |
| ordered = list(sessions.values()) | |
| ordered.sort(key=lambda s: s.start_time or datetime.min) | |
| return ordered | |
| def sessions_to_dicts(sessions: Iterable[Session]) -> list[dict[str, Any]]: | |
| return [s.to_dict() for s in sessions] | |