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
+6 -1
View File
@@ -21,10 +21,11 @@ console = Console()
@click.option("--max-users", default=None, type=int, help="Override max users for this instance (0 = unlimited; updates the DB setting)")
@click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.")
@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
@click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (enables 'Recalculate elevation' button in the edit drawer).")
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
strava_client_id: Optional[str], strava_client_secret: Optional[str],
max_users: Optional[int], public_url: Optional[str],
webroot: Optional[str]) -> None:
webroot: Optional[str], dem_url: Optional[str]) -> None:
"""Start the bincio multi-user application server.
Handles auth, user management, and write operations.
@@ -58,6 +59,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
srv.public_url = public_url
if webroot and site_dir:
srv.webroot = Path(webroot).expanduser().resolve()
if dem_url:
srv.dem_url = dem_url
db = open_db(dd)
current_limit = get_setting(db, "max_users")
@@ -74,6 +77,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
else:
console.print(f" Users: [dim]unlimited[/dim]")
if dem_url:
console.print(f" DEM: [cyan]{dem_url}[/cyan]")
console.print()
log_config = uvicorn.config.LOGGING_CONFIG.copy()
+35
View File
@@ -158,6 +158,7 @@ webroot: Path | None = None # nginx webroot — when set, trigger full rebuil
strava_client_id: str = ""
strava_client_secret: str = ""
public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs
dem_url: str = "" # Open-Elevation-compatible API base URL; empty = feature disabled
_db = None # sqlite3.Connection, opened lazily
@@ -404,6 +405,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
"display_name": user.display_name,
"is_admin": user.is_admin,
"store_originals_default": store_orig != "false",
"dem_configured": bool(dem_url),
})
@@ -1265,6 +1267,39 @@ async def post_activity(
return JSONResponse({"ok": True})
@app.post("/api/activity/{activity_id}/recalculate-elevation")
async def recalculate_elevation_endpoint(
activity_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
Requires --dem-url to be set when starting bincio serve.
"""
user = _require_user(bincio_session)
_check_id(activity_id)
if not dem_url:
raise HTTPException(
503,
"DEM URL not configured. "
"Pass --dem-url <api-url> to bincio serve (e.g. https://api.open-elevation.com).",
)
dd = _get_data_dir() / user.handle
if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, "Activity not found")
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)
_trigger_rebuild(user.handle)
return JSONResponse(result)
except FileNotFoundError as e:
raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(422, str(e))
@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
async def delete_activity(
activity_id: str,