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:
+28
-18
@@ -231,7 +231,23 @@ def compute_best_efforts(
|
|||||||
|
|
||||||
best_climb_m: Optional[float] = None
|
best_climb_m: Optional[float] = None
|
||||||
if sport == "cycling":
|
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
|
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
|
return best_s
|
||||||
|
|
||||||
|
|
||||||
def _best_climb(ele_1hz: list[Optional[float]]) -> Optional[float]:
|
def _best_climb(pts_sorted: list[tuple[float, float]]) -> Optional[float]:
|
||||||
"""Maximum net elevation gain over any contiguous window (Kadane's on deltas).
|
"""Maximum net elevation gain over any contiguous uphill window (Kadane's).
|
||||||
|
|
||||||
None samples are treated as breaks between segments — the Kadane window is
|
pts_sorted: list of (x, elevation_m) pairs sorted by x, where x is
|
||||||
reset to 0 at each gap so non-contiguous elevation data is never joined.
|
cumulative distance (m) or elapsed time (s). Using cumulative distance
|
||||||
Returns None if fewer than two non-None samples exist.
|
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 len(pts_sorted) < 2:
|
||||||
if non_null < 2:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
max_gain = 0.0
|
max_gain = 0.0
|
||||||
current = 0.0
|
current = 0.0
|
||||||
prev: Optional[float] = None
|
prev_e = pts_sorted[0][1]
|
||||||
|
|
||||||
for e in ele_1hz:
|
for _, e in pts_sorted[1:]:
|
||||||
if e is None:
|
current = max(0.0, current + (e - prev_e))
|
||||||
# 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:
|
if current > max_gain:
|
||||||
max_gain = current
|
max_gain = current
|
||||||
prev = e
|
prev_e = e
|
||||||
|
|
||||||
return round(max_gain, 1) if max_gain > 0 else None
|
return round(max_gain, 1) if max_gain > 0 else None
|
||||||
|
|
||||||
|
|||||||
+95
-1
@@ -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:
|
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
|
import json
|
||||||
from bincio.extract.writer import write_athlete_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)
|
targets = [data / handle] if handle else _user_dirs(data)
|
||||||
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
|
_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", [])
|
summaries = index_data.get("activities", [])
|
||||||
if not summaries:
|
if not summaries:
|
||||||
continue
|
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_config: dict = {}
|
||||||
athlete_path = user_dir / "athlete.json"
|
athlete_path = user_dir / "athlete.json"
|
||||||
if athlete_path.exists():
|
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}")
|
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:
|
def _write_root_manifest(data: Path) -> None:
|
||||||
"""Rewrite the root index.json shard manifest from current user dirs."""
|
"""Rewrite the root index.json shard manifest from current user dirs."""
|
||||||
import json
|
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.")
|
help="(Multi-user) Incrementally re-merge one user's shard only.")
|
||||||
@click.option("--no-build", "no_build", is_flag=True,
|
@click.option("--no-build", "no_build", is_flag=True,
|
||||||
help="Skip the Astro build step (just merge sidecars and update manifests).")
|
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(
|
def render(
|
||||||
config_path: Optional[str],
|
config_path: Optional[str],
|
||||||
data_dir: Optional[str],
|
data_dir: Optional[str],
|
||||||
@@ -226,6 +315,7 @@ def render(
|
|||||||
deploy: Optional[str],
|
deploy: Optional[str],
|
||||||
handle: Optional[str],
|
handle: Optional[str],
|
||||||
no_build: bool,
|
no_build: bool,
|
||||||
|
recompute_climbs: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Build (or serve) the BincioActivity static site from a BAS data store."""
|
"""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"Site: [cyan]{site}[/cyan]")
|
||||||
console.print(f"Data: [cyan]{data}[/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)
|
_merge_edits(data, handle=handle)
|
||||||
_rebuild_athlete_json(data, handle=handle)
|
_rebuild_athlete_json(data, handle=handle)
|
||||||
_bake_tracks(data, handle=handle)
|
_bake_tracks(data, handle=handle)
|
||||||
|
|||||||
Reference in New Issue
Block a user