feat: DEM-based elevation recalculation via edit drawer button

Adds a "Recalculate from terrain map (DEM)" button to the activity edit
drawer. On click it queries an Open-Elevation-compatible API to replace
GPS altitude with SRTM terrain data, applies 5m hysteresis, and updates
the activity's elevation stats and timeseries chart in place.

- bincio/extract/dem.py: lookup_elevations() (batched HTTP POST) +
  recalculate_elevation() (subsample → DEM → interpolate → hysteresis →
  patch activity JSON, timeseries JSON, index.json)
- POST /api/activity/{id}/recalculate-elevation on both serve and edit
  servers; serve endpoint is auth-gated and triggers merge + rebuild
- --dem-url flag (also DEM_URL env var) on bincio serve and bincio edit;
  logged at startup; missing URL returns a clear 503 with setup instructions
- /api/me response gains dem_configured bool
- EditDrawer: button with loading state, shows new ↑/↓ values on success
This commit is contained in:
Davide Scaini
2026-04-20 20:45:06 +02:00
parent 872651f471
commit 1940e2409b
6 changed files with 340 additions and 1 deletions
+27
View File
@@ -20,6 +20,7 @@ data_dir: Path | None = None
site_url: str = "http://localhost:4321"
strava_client_id: str = ""
strava_client_secret: str = ""
dem_url: str = "" # Open-Elevation-compatible API base URL; empty = feature disabled
# In-memory CSRF state tokens for OAuth flows (token → True); cleared after use
_oauth_states: set[str] = set()
@@ -427,6 +428,32 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
@app.post("/api/activity/{activity_id}/recalculate-elevation")
async def recalculate_elevation_endpoint(activity_id: str) -> JSONResponse:
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
Requires --dem-url to be set when starting bincio edit.
"""
if not dem_url:
raise HTTPException(
503,
"DEM URL not configured. "
"Pass --dem-url <api-url> to bincio edit (e.g. https://api.open-elevation.com).",
)
dd = _get_data_dir()
_check_id(activity_id)
try:
from bincio.extract.dem import recalculate_elevation
from bincio.render.merge import merge_one
result = recalculate_elevation(dd, activity_id, dem_url)
merge_one(dd, activity_id)
return JSONResponse(result)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(422, str(e))
@app.post("/api/activity/{activity_id}/images")
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
dd = _get_data_dir()