From 8f028101c7ea7265cb2ec49a4908049fef5d0bd7 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sun, 10 May 2026 16:21:24 +0200 Subject: [PATCH] Fix elevation gain inflation from device no-fix leading zeros Apple Watch and similar devices record exactly 0.0 for elevation while waiting for barometric/GPS lock, then jump to the real altitude. The hysteresis accumulator was seeding from 0.0, counting the full jump as ascent. Fix: detect a leading near-zero run followed by a large jump and seed the accumulator from the first real value instead. Applied in both _elevation() (fresh extractions) and recalculate_elevation_hysteresis() (recompute path). Added a bulk admin endpoint POST /api/admin/users/{handle}/recompute-elevation and corresponding button to fix existing stored activities. --- bincio/extract/dem.py | 7 +++++ bincio/extract/metrics.py | 18 +++++++++-- bincio/serve/server.py | 52 ++++++++++++++++++++++++++++++++ site/src/pages/admin/index.astro | 31 +++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/bincio/extract/dem.py b/bincio/extract/dem.py index 3f89e61..bd9e381 100644 --- a/bincio/extract/dem.py +++ b/bincio/extract/dem.py @@ -351,6 +351,13 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict: altitude_source = detail.get("altitude_source", "unknown") threshold = 1.0 if altitude_source == "barometric" else 3.0 + # Strip leading no-fix zeros (same logic as metrics._elevation) + if elevations and abs(elevations[0]) < 0.5: + for i, e in enumerate(elevations): + if abs(e) > threshold: + elevations = elevations[i:] + break + # Pre-smooth to suppress noise, then accumulate with low dead-band smoothed = _moving_average(elevations, _MA_WINDOW_S) gain, loss = _hysteresis_gain_loss(smoothed, threshold) diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index cc0072a..b9ae67e 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -371,9 +371,23 @@ def _elevation( if len(elevations) < 2: return None, None threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0) + + # Some devices (e.g. Apple Watch) record exactly 0.0 for the initial samples + # while waiting for barometric/GPS lock, then jump to the real altitude. + # Detect this by checking for a leading near-zero run followed by a large + # jump: skip those zeros and seed the accumulator from the first real value. + # Safety: if the activity genuinely stays near sea level, no value in the + # series will exceed `threshold`, so `start` stays 0 — unchanged behaviour. + start = 0 + if abs(elevations[0]) < 0.5: + for i, e in enumerate(elevations): + if abs(e) > threshold: + start = i + break + gain = loss = 0.0 - committed = elevations[0] - for e in elevations[1:]: + committed = elevations[start] + for e in elevations[start + 1:]: diff = e - committed if abs(diff) >= threshold: if diff > 0: diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 06e7583..5c680c1 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -1900,6 +1900,58 @@ async def recalculate_elevation_hysteresis_endpoint( raise HTTPException(422, str(e)) +@app.post("/api/admin/users/{handle}/recompute-elevation") +async def admin_recompute_elevation( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Recompute elevation gain/loss for all activities of a user from stored timeseries. + + Skips activities with altitude_source == 'dem' (already DEM-corrected). + Applies the leading-zero no-fix fix and source-aware hysteresis. + Returns patched/skipped/error counts. + """ + _require_admin(bincio_session) + user_dir = _get_data_dir() / handle + if not user_dir.is_dir(): + raise HTTPException(404, f"No data directory for '{handle}'") + + from bincio.extract.dem import recalculate_elevation_hysteresis + from bincio.render.merge import merge_one + + patched = skipped = errors = 0 + acts_dir = user_dir / "activities" + for json_path in sorted(acts_dir.glob("*.json")): + if json_path.name.endswith(".timeseries.json"): + continue + activity_id = json_path.stem + try: + detail = json.loads(json_path.read_text(encoding="utf-8")) + if detail.get("altitude_source") == "dem": + skipped += 1 + continue + ts_path = acts_dir / f"{activity_id}.timeseries.json" + if not ts_path.exists(): + skipped += 1 + continue + ts = json.loads(ts_path.read_text(encoding="utf-8")) + ele_arr = ts.get("elevation_m") or [] + if not any(e for e in ele_arr if e is not None): + skipped += 1 + continue + recalculate_elevation_hysteresis(user_dir, activity_id) + merge_one(user_dir, activity_id) + patched += 1 + except Exception as exc: + log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc) + errors += 1 + + if patched > 0: + _trigger_rebuild(handle) + + return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors}) + + @app.delete("/api/activity/{activity_id}", response_model=GenericResponse) async def delete_activity( activity_id: str, diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 72e51c5..1cd14e7 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -174,6 +174,11 @@ import Base from '../../layouts/Base.astro'; data-handle="${u.handle}" title="Re-run merge_all and trigger a site rebuild" >Rebuild +