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.
This commit is contained in:
@@ -351,6 +351,13 @@ def recalculate_elevation_hysteresis(user_dir: Path, activity_id: str) -> dict:
|
|||||||
altitude_source = detail.get("altitude_source", "unknown")
|
altitude_source = detail.get("altitude_source", "unknown")
|
||||||
threshold = 1.0 if altitude_source == "barometric" else 3.0
|
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
|
# Pre-smooth to suppress noise, then accumulate with low dead-band
|
||||||
smoothed = _moving_average(elevations, _MA_WINDOW_S)
|
smoothed = _moving_average(elevations, _MA_WINDOW_S)
|
||||||
gain, loss = _hysteresis_gain_loss(smoothed, threshold)
|
gain, loss = _hysteresis_gain_loss(smoothed, threshold)
|
||||||
|
|||||||
@@ -371,9 +371,23 @@ def _elevation(
|
|||||||
if len(elevations) < 2:
|
if len(elevations) < 2:
|
||||||
return None, None
|
return None, None
|
||||||
threshold = _ELEVATION_THRESHOLD.get(altitude_source, 10.0)
|
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
|
gain = loss = 0.0
|
||||||
committed = elevations[0]
|
committed = elevations[start]
|
||||||
for e in elevations[1:]:
|
for e in elevations[start + 1:]:
|
||||||
diff = e - committed
|
diff = e - committed
|
||||||
if abs(diff) >= threshold:
|
if abs(diff) >= threshold:
|
||||||
if diff > 0:
|
if diff > 0:
|
||||||
|
|||||||
@@ -1900,6 +1900,58 @@ async def recalculate_elevation_hysteresis_endpoint(
|
|||||||
raise HTTPException(422, str(e))
|
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)
|
@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
|
||||||
async def delete_activity(
|
async def delete_activity(
|
||||||
activity_id: str,
|
activity_id: str,
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ import Base from '../../layouts/Base.astro';
|
|||||||
data-handle="${u.handle}"
|
data-handle="${u.handle}"
|
||||||
title="Re-run merge_all and trigger a site rebuild"
|
title="Re-run merge_all and trigger a site rebuild"
|
||||||
>Rebuild</button>
|
>Rebuild</button>
|
||||||
|
<button
|
||||||
|
class="recompute-elev-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
|
data-handle="${u.handle}"
|
||||||
|
title="Recompute elevation gain/loss from stored timeseries (fixes leading-zero no-fix artefacts)"
|
||||||
|
>Recompute elev.</button>
|
||||||
<button
|
<button
|
||||||
class="pwreset-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
class="pwreset-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
data-handle="${u.handle}"
|
data-handle="${u.handle}"
|
||||||
@@ -336,6 +341,32 @@ import Base from '../../layouts/Base.astro';
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tbodyEl.querySelectorAll<HTMLButtonElement>('.recompute-elev-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const h = btn.dataset.handle!;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Running…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/users/${h}/recompute-elevation`, {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
btn.textContent = `Done (${d.patched} patched)`;
|
||||||
|
btn.classList.add('text-green-400');
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Error: ' + (d.detail ?? r.status);
|
||||||
|
btn.classList.add('text-red-400');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.classList.add('text-red-400');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
tbodyEl.querySelectorAll<HTMLButtonElement>('.pwreset-btn').forEach(btn => {
|
tbodyEl.querySelectorAll<HTMLButtonElement>('.pwreset-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const h = btn.dataset.handle!;
|
const h = btn.dataset.handle!;
|
||||||
|
|||||||
Reference in New Issue
Block a user