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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user