From 52e4ca8f3a10d8de5b7710e378abf3243dddbf0b Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 30 Mar 2026 10:18:07 +0200 Subject: [PATCH] fix athlete page (power curve calculation) --- bincio/render/merge.py | 8 +++ scripts/__init__.py | 0 scripts/backfill_mmp.py | 146 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 scripts/__init__.py create mode 100644 scripts/backfill_mmp.py diff --git a/bincio/render/merge.py b/bincio/render/merge.py index ece0a62..c3be9c6 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -140,6 +140,14 @@ def merge_all(data_dir: Path) -> int: if not dest_img.exists(): dest_img.symlink_to(img_dir.resolve()) + # Symlink athlete.json if present + athlete_src = data_dir / "athlete.json" + athlete_dest = merged_dir / "athlete.json" + if athlete_dest.exists() or athlete_dest.is_symlink(): + athlete_dest.unlink() + if athlete_src.exists(): + athlete_dest.symlink_to(athlete_src.resolve()) + # Write merged index.json (private filtered, highlight sorted) index_path = data_dir / "index.json" if index_path.exists(): diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/backfill_mmp.py b/scripts/backfill_mmp.py new file mode 100644 index 0000000..6a35687 --- /dev/null +++ b/scripts/backfill_mmp.py @@ -0,0 +1,146 @@ +"""Backfill MMP curve into existing BAS activity JSONs and index.json. + +Reads power_w from the already-extracted 1Hz timeseries — no need to re-parse +source FIT files. Run once after upgrading to the MMP-enabled extract pipeline. + +Usage: + uv run python scripts/backfill_mmp.py [--data-dir ~/src/bincio_data] +""" + +import json +import sys +from pathlib import Path + +import click +from rich.console import Console +from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn + +console = Console() + +MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600] + + +def compute_mmp_from_timeseries(power_w: list) -> list[list[int]] | None: + """Compute MMP from an already-extracted 1Hz power array (nulls dropped).""" + samples = [w for w in power_w if w is not None] + if len(samples) < 2: + return None + + n = len(samples) + results = [] + for d in MMP_DURATIONS_S: + if d > n: + break + window_sum = sum(samples[:d]) + best = window_sum + for i in range(1, n - d + 1): + window_sum += samples[i + d - 1] - samples[i - 1] + if window_sum > best: + best = window_sum + results.append([d, round(best / d)]) + + return results if results else None + + +@click.command() +@click.option("--data-dir", default="~/src/bincio_data", show_default=True, + help="Path to the BAS data directory.") +@click.option("--dry-run", is_flag=True, help="Compute but don't write anything.") +def main(data_dir: str, dry_run: bool) -> None: + """Backfill mmp field into existing activity JSONs and index.json.""" + data = Path(data_dir).expanduser() + acts_dir = data / "activities" + + if not acts_dir.exists(): + console.print(f"[red]Activities dir not found: {acts_dir}[/red]") + sys.exit(1) + + jsons = sorted(acts_dir.glob("*.json")) + console.print(f"Found [bold]{len(jsons)}[/bold] activity JSONs in {acts_dir}") + + updated = skipped = no_power = 0 + + with Progress( + TextColumn("[progress.description]{task.description}"), + BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task("Backfilling MMP…", total=len(jsons)) + + for path in jsons: + progress.advance(task) + try: + detail = json.loads(path.read_text()) + except Exception: + skipped += 1 + continue + + # Skip if already has mmp + if detail.get("mmp") is not None: + skipped += 1 + continue + + ts = detail.get("timeseries") or {} + power_w = ts.get("power_w") or [] + mmp = compute_mmp_from_timeseries(power_w) + + if not mmp: + no_power += 1 + continue + + detail["mmp"] = mmp + if not dry_run: + path.write_text(json.dumps(detail, indent=2, ensure_ascii=False)) + updated += 1 + + console.print( + f"\n[green]Done.[/green] " + f"Updated [bold]{updated}[/bold], " + f"already had mmp [bold]{skipped}[/bold], " + f"no power data [bold]{no_power}[/bold]." + ) + + if dry_run: + console.print("[yellow]Dry run — nothing written.[/yellow]") + return + + # Rebuild index.json summaries with mmp + console.print("Updating index.json summaries…") + index_path = data / "index.json" + if index_path.exists(): + index = json.loads(index_path.read_text()) + # Build a lookup from the now-updated detail JSONs + mmp_by_id: dict[str, list] = {} + for path in acts_dir.glob("*.json"): + try: + d = json.loads(path.read_text()) + if d.get("mmp"): + mmp_by_id[d["id"]] = d["mmp"] + except Exception: + pass + + for s in index.get("activities", []): + if s["id"] in mmp_by_id and not s.get("mmp"): + s["mmp"] = mmp_by_id[s["id"]] + + index_path.write_text(json.dumps(index, indent=2, ensure_ascii=False)) + console.print(f" Patched {len(mmp_by_id)} summaries in index.json.") + + # Rebuild athlete.json + console.print("Rebuilding athlete.json…") + from bincio.extract.writer import write_athlete_json + index = json.loads(index_path.read_text()) + owner = index.get("owner", {}) + athlete_cfg = {k: v for k, v in (owner.get("athlete") or {}).items() if v is not None} + write_athlete_json(index.get("activities", []), data, athlete_cfg) + console.print(" athlete.json written.") + + # Re-run merge_all so _merged picks up the changes + console.print("Running merge_all…") + from bincio.render.merge import merge_all + n = merge_all(data) + console.print(f" merge_all done ({n} sidecars).") + + +if __name__ == "__main__": + main()