From 3b675a68b0ee538f0608368d4c5bf8c03ff6d383 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 15 May 2026 01:21:34 +0200 Subject: [PATCH] Elevation: skip near-zero dropout values mid-recording Devices (Apple Watch, some GPS units) record 0.0 when they lose barometric/GPS lock mid-activity. The old accumulation committed these as real sea-level points, inflating both gain and loss by the current elevation (e.g. 792m dropout on the Cosmo Walk added ~1584m of phantom gain+loss). Fix: skip any elevation value < 1.0m when the current committed elevation is significantly above zero (> threshold). Gradual legitimate descents to sea level are unaffected because intermediate values are committed along the way. Add --recompute-elevation flag to bincio render to backfill existing activities. --- bincio/extract/metrics.py | 5 ++ bincio/render/cli.py | 99 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 3bf0198..364cba1 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -422,6 +422,11 @@ def _elevation( gain = loss = 0.0 committed = elevations[start] for e in elevations[start + 1:]: + # Skip near-zero values that appear mid-recording while we are at a + # significant elevation — these are sensor dropouts (device lost GPS/ + # barometric lock), not genuine sea-level crossings. + if abs(e) < 1.0 and abs(committed) > threshold: + continue diff = e - committed if abs(diff) >= threshold: if diff > 0: diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 7af7462..0b4f41a 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -220,6 +220,97 @@ def _recompute_best_climbs(data: Path, handle: str | None = None) -> None: console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} climb(s) recomputed") +def _recompute_elevation(data: Path, handle: str | None = None) -> None: + """Recompute elevation_gain_m / elevation_loss_m for all activities. + + Applies the dropout-skip fix (near-zero values mid-recording) so stored + values computed by older code are corrected. Updates activities/*.json + and index.json in-place. + """ + import json + from bincio.extract.metrics import _ELEVATION_THRESHOLD + + def _accumulate(elevations: list[float], altitude_source: str) -> tuple[float, float]: + if len(elevations) < 2: + return 0.0, 0.0 + threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0) + # Skip leading near-zeros (device acquiring lock) + start = 0 + if abs(elevations[0]) < 0.5: + n_leading = sum(1 for e in elevations if abs(e) < 0.5) + if n_leading > 1: + for i, e in enumerate(elevations): + if abs(e) > threshold: + start = i + break + gain = loss = 0.0 + committed = elevations[start] + for e in elevations[start + 1:]: + if abs(e) < 1.0 and abs(committed) > threshold: + continue + diff = e - committed + if abs(diff) >= threshold: + if diff > 0: + gain += diff + else: + loss += diff + committed = e + return gain, loss + + 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")) + ts = json.loads(ts_path.read_text(encoding="utf-8")) + raw = ts.get("elevation_m", []) + elevations = [e for e in raw if e is not None] + if len(elevations) < 2: + continue + alt_src = detail.get("altitude_source", "unknown") + new_gain, new_loss = _accumulate(elevations, alt_src) + new_gain_r = round(new_gain, 1) if new_gain else None + new_loss_r = round(abs(new_loss), 1) if new_loss else None + if (new_gain_r == detail.get("elevation_gain_m") and + new_loss_r == detail.get("elevation_loss_m")): + continue + detail["elevation_gain_m"] = new_gain_r + detail["elevation_loss_m"] = new_loss_r + 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["elevation_gain_m"] = new_gain_r + s["elevation_loss_m"] = new_loss_r + 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} elevation(s) recomputed") + + def _write_root_manifest(data: Path) -> None: """Rewrite the root index.json shard manifest from current user dirs.""" import json @@ -306,6 +397,9 @@ def _link_data(site: Path, data: Path) -> None: @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).") +@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).") def render( config_path: Optional[str], data_dir: Optional[str], @@ -316,6 +410,7 @@ def render( handle: Optional[str], no_build: bool, recompute_climbs: bool, + recompute_elevation: bool, ) -> None: """Build (or serve) the BincioActivity static site from a BAS data store.""" @@ -329,6 +424,10 @@ def render( console.print("Recomputing best climbs from timeseries…") _recompute_best_climbs(data, handle=handle) + if recompute_elevation: + console.print("Recomputing elevation gain/loss from timeseries…") + _recompute_elevation(data, handle=handle) + _merge_edits(data, handle=handle) _rebuild_athlete_json(data, handle=handle) _bake_tracks(data, handle=handle)