File size: 12,872 Bytes
3f00f74 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 | """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]
|