diff --git a/bincio/edit/cli.py b/bincio/edit/cli.py index afe0d62..8aec131 100644 --- a/bincio/edit/cli.py +++ b/bincio/edit/cli.py @@ -24,6 +24,8 @@ console = Console() help="Strava API client ID (enables Strava sync in the UI). Also reads STRAVA_CLIENT_ID env var.") @click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET", help="Strava API client secret. Also reads STRAVA_CLIENT_SECRET env var.") +@click.option("--dem-url", default=None, envvar="DEM_URL", + help="Base URL of an Open-Elevation-compatible API (enables 'Recalculate elevation' button).") def edit( data_dir: Optional[str], port: int, @@ -31,6 +33,7 @@ def edit( config_path: Optional[str], strava_client_id: Optional[str], strava_client_secret: Optional[str], + dem_url: Optional[str], ) -> None: """Start a local web UI for editing activity sidecar files. @@ -69,11 +72,16 @@ def edit( srv.site_url = site_url srv.strava_client_id = strava_client_id or "" srv.strava_client_secret = strava_client_secret or "" + srv.dem_url = dem_url or "" if strava_client_id: console.print(f"Strava sync: [green]enabled[/green] (client {strava_client_id})") else: console.print("Strava sync: [yellow]disabled[/yellow] (pass --strava-client-id to enable)") + if dem_url: + console.print(f"DEM: [green]enabled[/green] ({dem_url})") + else: + console.print("DEM: [yellow]disabled[/yellow] (pass --dem-url to enable elevation recalculation)") uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning") diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 167fbf3..648a4a3 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -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 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() diff --git a/bincio/extract/dem.py b/bincio/extract/dem.py new file mode 100644 index 0000000..8a1c846 --- /dev/null +++ b/bincio/extract/dem.py @@ -0,0 +1,221 @@ +"""DEM (Digital Elevation Model) lookup and elevation recalculation. + +Queries any Open-Elevation-compatible HTTP API to replace noisy GPS altitude +with terrain altitude, then re-applies hysteresis-based accumulation. + +Compatible APIs: + - https://api.open-elevation.com (free, SRTM, accepts large batches) + - https://api.opentopodata.org/v1/srtm30m (more reliable, max 100 pts/req) + +Pass the base URL (without path) to bincio serve/edit via --dem-url. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from pathlib import Path +from typing import Optional + +# Sample one GPS point per N seconds when building the DEM query. +# SRTM30 resolution is ~30 m; at 30 km/h cycling that's ~3 s per tile — +# sampling every 10 s is more than enough. +_DEFAULT_SAMPLE_INTERVAL_S = 10 + +# Maximum locations per API request. Open-Elevation supports ~512 per POST. +_DEFAULT_BATCH_SIZE = 512 + +# Hysteresis threshold after DEM correction. DEM data is already smooth +# (30 m horizontal resolution) so 5 m is a generous dead-band. +_DEM_HYSTERESIS_M = 5.0 + + +def lookup_elevations( + latlons: list[tuple[float, float]], + api_url: str, + batch_size: int = _DEFAULT_BATCH_SIZE, + timeout_s: int = 30, +) -> list[Optional[float]]: + """Query a DEM API for terrain elevation at the given (lat, lon) pairs. + + Uses the Open-Elevation API format:: + + POST {api_url}/api/v1/lookup + {"locations": [{"latitude": lat, "longitude": lon}, ...]} + + Returns a list the same length as *latlons*. Elements are ``None`` + wherever the API returned no data (network error, ocean, etc.). + """ + if not latlons: + return [] + + results: list[Optional[float]] = [None] * len(latlons) + url = f"{api_url.rstrip('/')}/api/v1/lookup" + + for start in range(0, len(latlons), batch_size): + batch = latlons[start : start + batch_size] + payload = json.dumps( + {"locations": [{"latitude": lat, "longitude": lon} for lat, lon in batch]} + ).encode("utf-8") + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + data = json.loads(resp.read().decode("utf-8")) + for i, item in enumerate(data.get("results", [])): + elev = item.get("elevation") + if elev is not None: + results[start + i] = float(elev) + except (urllib.error.URLError, json.JSONDecodeError, KeyError, ValueError): + pass # leave this batch as None; caller checks overall coverage + + return results + + +def recalculate_elevation( + user_dir: Path, + activity_id: str, + dem_url: str, + sample_interval_s: int = _DEFAULT_SAMPLE_INTERVAL_S, +) -> dict: + """Replace GPS elevation with DEM terrain elevation and recompute gain/loss. + + Algorithm + --------- + 1. Read the activity's 1 Hz timeseries for lat / lon / t arrays. + 2. Subsample GPS points every *sample_interval_s* seconds. + 3. Query the DEM API for those points (batched). + 4. Linearly interpolate DEM elevation back to every GPS-valid second. + 5. Apply :data:`_DEM_HYSTERESIS_M` hysteresis to compute gain / loss. + 6. Write the updated ``elevation_m`` array to the timeseries JSON. + 7. Patch ``elevation_gain_m`` / ``elevation_loss_m`` in the detail JSON. + 8. Patch ``elevation_gain_m`` in ``index.json`` (summary entry for the feed). + + Returns + ------- + dict with keys ``elevation_gain_m``, ``elevation_loss_m``, + ``points_queried`` (DEM responses received). + + Raises + ------ + FileNotFoundError + Activity detail or timeseries file not found. + ValueError + Activity has no GPS coordinates, or the DEM API returned too few results. + """ + acts_dir = user_dir / "activities" + json_path = acts_dir / f"{activity_id}.json" + ts_path = acts_dir / f"{activity_id}.timeseries.json" + + if not json_path.exists(): + raise FileNotFoundError(f"Activity not found: {activity_id}") + if not ts_path.exists(): + raise ValueError("Activity has no timeseries data") + + ts = json.loads(ts_path.read_text(encoding="utf-8")) + lat_arr: list[Optional[float]] = ts.get("lat") or [] + lon_arr: list[Optional[float]] = ts.get("lon") or [] + t_arr: list[int] = ts.get("t") or [] + + if not lat_arr or not lon_arr: + raise ValueError( + "Activity has no GPS coordinates " + "(privacy=no_gps or data recorded without GPS)" + ) + + n = len(t_arr) + + # ── 1. Subsample GPS-valid indices ──────────────────────────────────────── + gps_idx = [ + i for i in range(n) + if lat_arr[i] is not None and lon_arr[i] is not None + ] + if len(gps_idx) < 2: + raise ValueError("Too few GPS points for DEM lookup") + + sampled_idx = gps_idx[::sample_interval_s] + if gps_idx[-1] not in sampled_idx: + sampled_idx.append(gps_idx[-1]) # always include the last point + + # ── 2. Query DEM ────────────────────────────────────────────────────────── + latlons = [(float(lat_arr[i]), float(lon_arr[i])) for i in sampled_idx] # type: ignore[arg-type] + dem_elev = lookup_elevations(latlons, dem_url) + + # Build (t, elevation) pairs for valid DEM responses only + valid_pairs: list[tuple[int, float]] = [ + (t_arr[sampled_idx[k]], dem_elev[k]) + for k in range(len(sampled_idx)) + if dem_elev[k] is not None + ] + n_queried = len(valid_pairs) + if n_queried < 2: + raise ValueError( + f"DEM API returned too few results " + f"({n_queried} of {len(sampled_idx)} points). " + f"Check the DEM URL: {dem_url}" + ) + + # ── 3. Linear interpolation → full 1 Hz series ─────────────────────────── + new_ele: list[Optional[float]] = [None] * n + j = 0 + for i in gps_idx: + t = t_arr[i] + # Advance j so valid_pairs[j] is the left anchor for t + while j + 1 < len(valid_pairs) - 1 and valid_pairs[j + 1][0] <= t: + j += 1 + t0, e0 = valid_pairs[j] + if j + 1 < len(valid_pairs): + t1, e1 = valid_pairs[j + 1] + frac = max(0.0, min(1.0, (t - t0) / (t1 - t0))) if t1 > t0 else 0.0 + new_ele[i] = round(e0 + frac * (e1 - e0), 1) + else: + new_ele[i] = round(e0, 1) + + # ── 4. Hysteresis accumulation ──────────────────────────────────────────── + valid_eles = [e for e in new_ele if e is not None] + gain = loss = 0.0 + committed = valid_eles[0] + for e in valid_eles[1:]: + diff = e - committed + if abs(diff) >= _DEM_HYSTERESIS_M: + if diff > 0: + gain += diff + else: + loss += diff + committed = e + + gain_r = round(gain, 1) + loss_r = round(abs(loss), 1) + + # ── 5. Write timeseries ─────────────────────────────────────────────────── + ts["elevation_m"] = new_ele + ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8") + + # ── 6. Patch activity detail JSON ───────────────────────────────────────── + detail = json.loads(json_path.read_text(encoding="utf-8")) + detail["elevation_gain_m"] = gain_r + detail["elevation_loss_m"] = loss_r + json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8") + + # ── 7. Patch index.json summary (so merge_all picks up the new value) ───── + index_path = user_dir / "index.json" + if index_path.exists(): + index = json.loads(index_path.read_text(encoding="utf-8")) + for s in index.get("activities", []): + if s.get("id") == activity_id: + s["elevation_gain_m"] = gain_r + break + index_path.write_text( + json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8" + ) + + return { + "elevation_gain_m": gain_r, + "elevation_loss_m": loss_r, + "points_queried": n_queried, + } diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 81b3c18..2cbf9f6 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -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() diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 51e5104..5b57f4d 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -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 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, diff --git a/site/src/components/EditDrawer.svelte b/site/src/components/EditDrawer.svelte index 7d8a215..ab4520e 100644 --- a/site/src/components/EditDrawer.svelte +++ b/site/src/components/EditDrawer.svelte @@ -24,6 +24,11 @@ let confirmDelete = false; let deleting = false; + // Elevation recalculation from DEM + let recalculating = false; + let recalcStatus = ''; + let recalcOk = false; + // Form state let title = ''; let sport: Sport = 'cycling'; @@ -119,6 +124,26 @@ : [...hideStats, key]; } + async function recalculateElevation() { + recalculating = true; + recalcStatus = ''; + recalcOk = false; + try { + const res = await fetch(`${api}/recalculate-elevation`, { method: 'POST' }); + const d = await res.json(); + if (!res.ok) throw new Error(d.detail ?? await res.text()); + recalcOk = true; + const gain = d.elevation_gain_m != null ? `↑ ${Math.round(d.elevation_gain_m)} m` : ''; + const loss = d.elevation_loss_m != null ? `↓ ${Math.round(d.elevation_loss_m)} m` : ''; + recalcStatus = [gain, loss].filter(Boolean).join(' '); + } catch (e: any) { + recalcStatus = e.message; + recalcOk = false; + } finally { + recalculating = false; + } + } + async function deleteActivity() { if (!confirmDelete) { confirmDelete = true; return; } deleting = true; @@ -264,6 +289,24 @@ + +
+

Elevation

+ + {#if recalcStatus} +

+ {recalcStatus} +

+ {/if} +
+