VAM: add --recompute-vam flag and compute_vam_from_timeseries helper

Refactors core VAM logic into _vam_from_ele_1hz() and _build_ele_1hz()
so both the DataPoint-based extract path and the timeseries-based backfill
path share the same implementation.

render --recompute-vam reads stored *.timeseries.json files and updates
climbing_vam_mh + vam_curve in activities/*.json and index.json in-place,
without re-parsing the original FIT/GPX files.
This commit is contained in:
Davide Scaini
2026-05-16 21:37:51 +02:00
parent baf20b51ba
commit 7cd8a6b030
2 changed files with 131 additions and 54 deletions
+60
View File
@@ -377,6 +377,58 @@ def _link_data(site: Path, data: Path) -> None:
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
def _recompute_vam(data: Path, handle: str | None = None) -> None:
"""Recompute climbing_vam_mh and vam_curve for all activities from stored timeseries."""
import json
from bincio.extract.metrics import compute_vam_from_timeseries
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
index_path = user_dir / "index.json"
if not acts_dir.exists() or not index_path.exists():
continue
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except Exception:
continue
updated = 0
for act_path in acts_dir.glob("*.json"):
if act_path.stem.endswith((".timeseries", ".geojson")):
continue
ts_path = acts_dir / f"{act_path.stem}.timeseries.json"
if not ts_path.exists():
continue
try:
detail = json.loads(act_path.read_text(encoding="utf-8"))
sport = detail.get("sport", "other")
ts = json.loads(ts_path.read_text(encoding="utf-8"))
new_vam, new_curve = compute_vam_from_timeseries(ts, sport)
if (new_vam == detail.get("climbing_vam_mh")
and new_curve == detail.get("vam_curve")):
continue
detail["climbing_vam_mh"] = new_vam
detail["vam_curve"] = new_curve
act_path.write_text(
json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8"
)
act_id = act_path.stem
for s in index_data.get("activities", []):
if s.get("id") == act_id:
s["vam_curve"] = new_curve
break
updated += 1
except Exception:
pass
if updated:
index_path.write_text(
json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8"
)
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} activity(ies) updated")
@click.command()
@click.option("--config", "config_path", default=None,
help="Path to extract_config.yaml (reads output.dir from it).")
@@ -400,6 +452,9 @@ def _link_data(site: Path, data: Path) -> None:
@click.option("--recompute-elevation", "recompute_elevation", is_flag=True,
help="Recompute elevation_gain_m/loss_m for all activities from stored timeseries "
"(run once after upgrading the dropout-skip fix).")
@click.option("--recompute-vam", "recompute_vam", is_flag=True,
help="Recompute climbing_vam_mh and vam_curve for all activities from stored "
"timeseries (run once after adding VAM support).")
def render(
config_path: Optional[str],
data_dir: Optional[str],
@@ -411,6 +466,7 @@ def render(
no_build: bool,
recompute_climbs: bool,
recompute_elevation: bool,
recompute_vam: bool,
) -> None:
"""Build (or serve) the BincioActivity static site from a BAS data store."""
@@ -428,6 +484,10 @@ def render(
console.print("Recomputing elevation gain/loss from timeseries…")
_recompute_elevation(data, handle=handle)
if recompute_vam:
console.print("Recomputing VAM from timeseries…")
_recompute_vam(data, handle=handle)
_merge_edits(data, handle=handle)
_rebuild_athlete_json(data, handle=handle)
_bake_tracks(data, handle=handle)