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
|
gain = loss = 0.0
|
||||||
committed = elevations[start]
|
committed = elevations[start]
|
||||||
for e in elevations[start + 1:]:
|
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
|
diff = e - committed
|
||||||
if abs(diff) >= threshold:
|
if abs(diff) >= threshold:
|
||||||
if diff > 0:
|
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")
|
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:
|
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
|
||||||
@@ -306,6 +397,9 @@ def _link_data(site: Path, data: Path) -> None:
|
|||||||
@click.option("--recompute-climbs", "recompute_climbs", is_flag=True,
|
@click.option("--recompute-climbs", "recompute_climbs", is_flag=True,
|
||||||
help="Recompute best_climb_m for all cycling activities from stored timeseries "
|
help="Recompute best_climb_m for all cycling activities from stored timeseries "
|
||||||
"(run once after upgrading the climb algorithm).")
|
"(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(
|
def render(
|
||||||
config_path: Optional[str],
|
config_path: Optional[str],
|
||||||
data_dir: Optional[str],
|
data_dir: Optional[str],
|
||||||
@@ -316,6 +410,7 @@ def render(
|
|||||||
handle: Optional[str],
|
handle: Optional[str],
|
||||||
no_build: bool,
|
no_build: bool,
|
||||||
recompute_climbs: bool,
|
recompute_climbs: bool,
|
||||||
|
recompute_elevation: 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."""
|
||||||
|
|
||||||
@@ -329,6 +424,10 @@ def render(
|
|||||||
console.print("Recomputing best climbs from timeseries…")
|
console.print("Recomputing best climbs from timeseries…")
|
||||||
_recompute_best_climbs(data, handle=handle)
|
_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)
|
_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