diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 5618499..56dd57c 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -377,6 +377,71 @@ 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 climbing_time_s for all activities. + + Reads the stored timeseries, re-runs the VAM algorithm, and patches both + activities/*.json and index.json in-place. Run once after adding + climbing_time_s to the schema so the NerdCorner VAM chart can filter short + climbs and opacity-encode confidence. + """ + import json + from bincio.extract.metrics import _VAM_SPORTS, _build_ele_1hz, _vam_from_ele_1hz + + 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 + + index_by_id = {s["id"]: s for s in index_data.get("activities", [])} + updated = 0 + + for act_path in sorted(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")) + if detail.get("sport") not in _VAM_SPORTS: + continue + ts = json.loads(ts_path.read_text(encoding="utf-8")) + t_vals = ts.get("t", []) + e_vals = ts.get("elevation_m", []) + sparse: dict[int, float | None] = {int(t): e for t, e in zip(t_vals, e_vals)} + ele_1hz = _build_ele_1hz(sparse) + result = _vam_from_ele_1hz(ele_1hz) if ele_1hz else None + new_vam, new_climb_t = result if result else (None, None) + if (new_vam == detail.get("climbing_vam_mh") and + new_climb_t == detail.get("climbing_time_s")): + continue + detail["climbing_vam_mh"] = new_vam + detail["climbing_time_s"] = new_climb_t + act_path.write_text( + json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8" + ) + summary = index_by_id.get(act_path.stem) + if summary is not None: + summary["climbing_vam_mh"] = new_vam + summary["climbing_time_s"] = new_climb_t + 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} VAM(s) recomputed") + + def _backfill_vam_summary(data: Path, handle: str | None = None) -> None: """Copy climbing_vam_mh from detail JSONs into index.json summaries. @@ -475,6 +540,9 @@ def _backfill_speed(data: Path, handle: str | None = None) -> 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 climbing_time_s for all activities from stored " + "timeseries (run once after adding climbing_time_s to the schema).") @click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True, help="Copy climbing_vam_mh from detail JSONs into index.json summaries " "(run once after the VAM curve → summary migration).") @@ -492,6 +560,7 @@ def render( no_build: bool, recompute_climbs: bool, recompute_elevation: bool, + recompute_vam: bool, backfill_vam_summary: bool, backfill_speed: bool, ) -> None: @@ -511,6 +580,10 @@ def render( console.print("Recomputing elevation gain/loss from timeseries…") _recompute_elevation(data, handle=handle) + if recompute_vam: + console.print("Recomputing VAM and climbing time from timeseries…") + _recompute_vam(data, handle=handle) + if backfill_vam_summary: console.print("Backfilling climbing_vam_mh into summaries…") _backfill_vam_summary(data, handle=handle)