From 9f1e9e4d3bc46f4c797459552f7e71c22600c76a Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 15 May 2026 00:30:58 +0200 Subject: [PATCH] Records: apply sidecars before computing; fix best_climb_m for long mountain climbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _rebuild_athlete_json now applies sidecar edits (sub_sport, sport, etc.) in-memory before passing summaries to write_athlete_json, so activities marked indoor via sidecar are correctly excluded from records. - _best_climb now runs Kadane's over cumulative distance (not 1Hz dense time) so recording pauses don't create None gaps that falsely reset the climbing window. Grappa: 811m→1603m; Nivolet: 311m→2009m. - Add bincio render --recompute-climbs to backfill existing activities from their stored timeseries. --- bincio/extract/metrics.py | 50 ++++++++++++-------- bincio/render/cli.py | 96 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 1823bd0..3bf0198 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -231,7 +231,23 @@ def compute_best_efforts( best_climb_m: Optional[float] = None if sport == "cycling": - best_climb_m = _best_climb(ele_1hz) + # Use cumulative device distance as the x-axis so recording pauses + # (where distance doesn't increase) don't create gaps that reset the window. + # Fall back to elapsed-time ordering when no device distance is recorded. + dist_ele = sorted( + (p.distance_m, p.elevation_m) + for p in pts + if p.distance_m is not None and p.elevation_m is not None + ) + if not dist_ele: + dist_ele = sorted( + (int((p.timestamp - started_at).total_seconds()), p.elevation_m) + for p in pts + if p.elevation_m is not None + and int((p.timestamp - started_at).total_seconds()) >= 0 + ) + if len(dist_ele) >= 2: + best_climb_m = _best_climb(dist_ele) return best_efforts, best_climb_m @@ -261,32 +277,26 @@ def _fastest_time_for_distance(speed_1hz: list[float], target_km: float) -> Opti return best_s -def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]: - """Maximum net elevation gain over any contiguous window (Kadane's on deltas). +def _best_climb(pts_sorted: list[tuple[float, float]]) -> Optional[float]: + """Maximum net elevation gain over any contiguous uphill window (Kadane's). - None samples are treated as breaks between segments — the Kadane window is - reset to 0 at each gap so non-contiguous elevation data is never joined. - Returns None if fewer than two non-None samples exist. + pts_sorted: list of (x, elevation_m) pairs sorted by x, where x is + cumulative distance (m) or elapsed time (s). Using cumulative distance + means recording pauses (x doesn't increase while stopped) don't create + gaps that falsely reset the climbing window. """ - non_null = sum(1 for e in ele_1hz if e is not None) - if non_null < 2: + if len(pts_sorted) < 2: return None max_gain = 0.0 current = 0.0 - prev: Optional[float] = None + prev_e = pts_sorted[0][1] - for e in ele_1hz: - if e is None: - # Gap — reset window so we don't bridge the discontinuity - current = 0.0 - prev = None - continue - if prev is not None: - current = max(0.0, current + (e - prev)) - if current > max_gain: - max_gain = current - prev = e + for _, e in pts_sorted[1:]: + current = max(0.0, current + (e - prev_e)) + if current > max_gain: + max_gain = current + prev_e = e return round(max_gain, 1) if max_gain > 0 else None diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 489233e..7af7462 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -106,9 +106,14 @@ def _bake_tracks(data: Path, handle: str | None = None) -> None: def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None: - """Rebuild athlete.json for one user or all users from their current index.json.""" + """Rebuild athlete.json for one user or all users. + + Reads raw index.json summaries, applies any sidecar edits in-memory (so + overrides like sub_sport: indoor are respected), then calls write_athlete_json. + """ import json from bincio.extract.writer import write_athlete_json + from bincio.render.merge import parse_sidecar, _apply_sidecar_summary targets = [data / handle] if handle else _user_dirs(data) _COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"} @@ -121,6 +126,25 @@ def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None: summaries = index_data.get("activities", []) if not summaries: continue + + # Apply sidecar edits so overrides (e.g. sub_sport: indoor) are visible + # to write_athlete_json without stripping best_efforts/best_climb_m. + edits_dir = user_dir / "edits" + if edits_dir.exists(): + sidecars: dict[str, dict] = {} + for sc_path in edits_dir.glob("*.md"): + try: + fm, _ = parse_sidecar(sc_path) + sidecars[sc_path.stem] = fm + except Exception: + pass + if sidecars: + summaries = [ + _apply_sidecar_summary(s, sidecars[s["id"]]) + if s.get("id") in sidecars else s + for s in summaries + ] + athlete_config: dict = {} athlete_path = user_dir / "athlete.json" if athlete_path.exists(): @@ -134,6 +158,68 @@ def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None: console.print(f" [yellow]{user_dir.name}[/yellow]: rebuild_athlete failed: {exc}") +def _recompute_best_climbs(data: Path, handle: str | None = None) -> None: + """Recompute best_climb_m for all cycling activities from their stored timeseries. + + Updates activities/*.json and index.json in-place. Run this once after + upgrading the climb algorithm to fix values computed by the old code. + """ + import json + from bincio.extract.metrics import _best_climb + + 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")) + if detail.get("sport") != "cycling": + continue + ts = json.loads(ts_path.read_text(encoding="utf-8")) + t_vals = ts.get("t", []) + e_vals = ts.get("elevation_m", []) + pairs = sorted( + (t, e) for t, e in zip(t_vals, e_vals) if e is not None + ) + if len(pairs) < 2: + continue + new_val = _best_climb(pairs) + if new_val == detail.get("best_climb_m"): + continue + detail["best_climb_m"] = new_val + 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["best_climb_m"] = new_val + 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} climb(s) recomputed") + + def _write_root_manifest(data: Path) -> None: """Rewrite the root index.json shard manifest from current user dirs.""" import json @@ -217,6 +303,9 @@ def _link_data(site: Path, data: Path) -> None: help="(Multi-user) Incrementally re-merge one user's shard only.") @click.option("--no-build", "no_build", is_flag=True, help="Skip the Astro build step (just merge sidecars and update manifests).") +@click.option("--recompute-climbs", "recompute_climbs", is_flag=True, + help="Recompute best_climb_m for all cycling activities from stored timeseries " + "(run once after upgrading the climb algorithm).") def render( config_path: Optional[str], data_dir: Optional[str], @@ -226,6 +315,7 @@ def render( deploy: Optional[str], handle: Optional[str], no_build: bool, + recompute_climbs: bool, ) -> None: """Build (or serve) the BincioActivity static site from a BAS data store.""" @@ -235,6 +325,10 @@ def render( console.print(f"Site: [cyan]{site}[/cyan]") console.print(f"Data: [cyan]{data}[/cyan]") + if recompute_climbs: + console.print("Recomputing best climbs from timeseries…") + _recompute_best_climbs(data, handle=handle) + _merge_edits(data, handle=handle) _rebuild_athlete_json(data, handle=handle) _bake_tracks(data, handle=handle)