YourGymBuddy / app /utils /parser.py
PedroRuizCode's picture
Initial deploy to Hugging Face Space
3f00f74
Raw
History Blame Contribute Delete
12.9 kB
"""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",
}
@dataclass
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
@property
def weight_lbs(self) -> float | None:
return None if self.weight_kg is None else round(self.weight_kg / LB_TO_KG, 2)
@property
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),
}
@dataclass
class Exercise:
name: str
muscle_group: str = "other"
notes: str = ""
sets: list[WorkoutSet] = field(default_factory=list)
@property
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),
}
@dataclass
class Session:
title: str
start_time: datetime | None
end_time: datetime | None
exercises: list[Exercise] = field(default_factory=list)
@property
def volume_kg(self) -> float:
return sum(e.volume_kg for e in self.exercises)
@property
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
@property
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]