diff --git a/bincio/extract/timeseries.py b/bincio/extract/timeseries.py index dc164b8..d47f33b 100644 --- a/bincio/extract/timeseries.py +++ b/bincio/extract/timeseries.py @@ -2,11 +2,45 @@ the BAS timeseries object (parallel arrays).""" from datetime import datetime +from math import atan2, cos, radians, sin, sqrt from typing import Optional from bincio.extract.models import DataPoint +def _gps_speed_kmh( + lat_vals: list[Optional[float]], + lon_vals: list[Optional[float]], + ts_vals: list[int], +) -> list[Optional[float]]: + """Compute speed (km/h) from consecutive GPS coordinates via haversine. + Applies a 5-point centred moving-average to reduce GPS noise. + """ + n = len(ts_vals) + raw: list[Optional[float]] = [None] * n + for i in range(1, n): + la0, lo0 = lat_vals[i - 1], lon_vals[i - 1] + la1, lo1 = lat_vals[i], lon_vals[i] + dt = ts_vals[i] - ts_vals[i - 1] + if la0 is None or lo0 is None or la1 is None or lo1 is None or dt <= 0: + continue + dlat = radians(la1 - la0) + dlon = radians(lo1 - lo0) + a = sin(dlat / 2) ** 2 + cos(radians(la0)) * cos(radians(la1)) * sin(dlon / 2) ** 2 + d_km = 2 * 6371.0 * atan2(sqrt(a), sqrt(1 - a)) + raw[i] = d_km / dt * 3600.0 + + # 5-point centred moving average (skip None anchors) + half = 2 + smoothed: list[Optional[float]] = [None] * n + for i in range(n): + vals = [raw[j] for j in range(max(0, i - half), min(n, i + half + 1)) if raw[j] is not None] + if vals: + smoothed[i] = round(sum(vals) / len(vals), 2) + + return smoothed + + def build_timeseries( points: list[DataPoint], started_at: datetime, @@ -40,6 +74,11 @@ def build_timeseries( lon_vals = [round(p.lon, 7) if p.lon is not None else None for p in sampled] if include_gps else None ele_vals = [round(p.elevation_m, 1) if p.elevation_m is not None else None for p in sampled] spd_vals = [round(p.speed_kmh, 2) if p.speed_kmh is not None else None for p in sampled] + + # Derive speed from GPS when the device didn't record per-second speed. + if include_gps and lat_vals and lon_vals and all(v is None for v in spd_vals): + spd_vals = _gps_speed_kmh(lat_vals, lon_vals, ts_vals) + hr_vals = [p.hr_bpm for p in sampled] cad_vals = [p.cadence_rpm for p in sampled] pwr_vals = [p.power_w for p in sampled] diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 00e27c7..5618499 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -418,6 +418,40 @@ def _backfill_vam_summary(data: Path, handle: str | None = None) -> None: console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated") +def _backfill_speed(data: Path, handle: str | None = None) -> None: + """Compute GPS-derived speed for timeseries files where speed_kmh is all null. + + Reads each *.timeseries.json, fills speed_kmh from haversine distances when + the device did not record per-second speed, and writes the file back. + """ + import json + from bincio.extract.timeseries import _gps_speed_kmh + + targets = [data / handle] if handle else _user_dirs(data) + for user_dir in targets: + acts_dir = user_dir / "activities" + if not acts_dir.exists(): + continue + updated = 0 + for ts_path in sorted(acts_dir.glob("*.timeseries.json")): + try: + ts = json.loads(ts_path.read_text(encoding="utf-8")) + except Exception: + continue + spd = ts.get("speed_kmh", []) + if not spd or any(v is not None for v in spd): + continue # already has speed data + lat_vals = ts.get("lat") or [] + lon_vals = ts.get("lon") or [] + t_vals = ts.get("t") or [] + if not lat_vals or not lon_vals or not t_vals: + continue + ts["speed_kmh"] = _gps_speed_kmh(lat_vals, lon_vals, t_vals) + ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8") + updated += 1 + console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} timeseries updated with GPS speed") + + @click.command() @click.option("--config", "config_path", default=None, help="Path to extract_config.yaml (reads output.dir from it).") @@ -444,6 +478,9 @@ def _backfill_vam_summary(data: Path, handle: str | None = None) -> None: @click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True, help="Copy climbing_vam_mh from detail JSONs into index.json summaries " "(run once after the VAM curve → summary migration).") +@click.option("--backfill-speed", "backfill_speed", is_flag=True, + help="Compute GPS-derived speed for timeseries where the device didn't record " + "per-second speed (run once to enable speed map coloring on older activities).") def render( config_path: Optional[str], data_dir: Optional[str], @@ -456,6 +493,7 @@ def render( recompute_climbs: bool, recompute_elevation: bool, backfill_vam_summary: bool, + backfill_speed: bool, ) -> None: """Build (or serve) the BincioActivity static site from a BAS data store.""" @@ -477,6 +515,10 @@ def render( console.print("Backfilling climbing_vam_mh into summaries…") _backfill_vam_summary(data, handle=handle) + if backfill_speed: + console.print("Backfilling GPS-derived speed into timeseries…") + _backfill_speed(data, handle=handle) + _merge_edits(data, handle=handle) _rebuild_athlete_json(data, handle=handle) _bake_tracks(data, handle=handle) diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 724c9a6..67d6023 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -235,11 +235,11 @@ stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'), stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'), stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—', 'heart_rate'), - stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'), ...(activity.avg_power_w != null ? [ stat('Avg power', `${activity.avg_power_w} W`, 'power'), stat('NP', npPower != null ? `${npPower} W` : '—', 'power'), ] : []), + stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'), ].filter(s => !s.key || !hiddenStats.has(s.key)); @@ -375,37 +375,50 @@ {/if} - -
- -
- {#if trackUrl} - - {#if legendInfo} -
- {legendInfo.min} -
- {legendInfo.max} - {legendInfo.label} + +
+ + +
+
+ {#if trackUrl} + + {#if legendInfo} +
+ {legendInfo.min} +
+ {legendInfo.max} + {legendInfo.label} +
+ {/if} + {:else} +
+ No GPS track
{/if} - {:else} -
- No GPS track +
+ + {#if error} +

{error}

+ {:else if timeseries && timeseries.t.length > 0} +
+
+ {:else if !detail || timeseriesLoading} +
{/if}
- -
+ +
{#each stats as s} {@const cm = s.key === 'speed' && hasSpeedTrack ? 'speed' : @@ -426,24 +439,14 @@
{/each} {#if detail?.gear} -
+

{detail.gear}

Gear

{/if}
-
- -{#if error} -

{error}

-{:else if timeseries && timeseries.t.length > 0} -
- -
-{:else if !detail || timeseriesLoading} -
-{/if} +
{#if detail?.laps?.length}