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.
This commit is contained in:
Davide Scaini
2026-05-15 01:21:34 +02:00
parent c12f5336f5
commit 3b675a68b0
2 changed files with 104 additions and 0 deletions
+99
View File
@@ -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)