Records: apply sidecars before computing; fix best_climb_m for long mountain climbs

- _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.
This commit is contained in:
Davide Scaini
2026-05-15 00:30:58 +02:00
parent de07d8d4cf
commit 9f1e9e4d3b
2 changed files with 125 additions and 21 deletions
+95 -1
View File
@@ -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)