diff --git a/bincio/extract/metrics.py b/bincio/extract/metrics.py index 378ebec..5c51415 100644 --- a/bincio/extract/metrics.py +++ b/bincio/extract/metrics.py @@ -112,10 +112,11 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis [duration_s, avg_watts] pairs (integers), or None when the activity has no power data. Only durations shorter than the total activity are included. """ - # 1 Hz downsample: at most one sample per second, skip sub-second duplicates. - # Seconds without a recorded sample are omitted (not zero-filled) so that - # paused-recording gaps don't silently lower power averages. - power_1hz: list[int] = [] + # Build a dense 1 Hz power array with gaps zero-filled. + # Zero-filling is the standard approach (matches GoldenCheetah / WKO): + # a recording gap counts as 0 W so windows cannot silently span pauses + # and inflate MMP values. + sparse: dict[int, int] = {} last_t = -1 for p in pts: t = int((p.timestamp - started_at).total_seconds()) @@ -123,11 +124,15 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis continue last_t = t if p.power_w is not None: - power_1hz.append(p.power_w) + sparse[t] = p.power_w - if len(power_1hz) < 2: + if len(sparse) < 2: return None + t_min = min(sparse) + t_max = max(sparse) + power_1hz: list[int] = [sparse.get(t, 0) for t in range(t_min, t_max + 1)] + n = len(power_1hz) results: list[list[int]] = [] @@ -166,17 +171,27 @@ def compute_best_efforts( """ targets = BEST_EFFORT_DISTANCES.get(sport, []) - # Build 1 Hz speed (km/h) and elevation (m) arrays — same downsampling as timeseries.py - speed_1hz: list[float] = [] - ele_1hz: list[Optional[float]] = [] + # Build dense 1 Hz speed (km/h) and elevation (m) arrays with gap zero-filling. + # Zero-filling speed gaps (0 km/h) prevents best-effort windows from spanning + # recording pauses and producing artificially fast times. + sparse_speed: dict[int, float] = {} + sparse_ele: dict[int, Optional[float]] = {} last_t = -1 for p in pts: t = int((p.timestamp - started_at).total_seconds()) if t < 0 or t == last_t: continue last_t = t - speed_1hz.append(p.speed_kmh if p.speed_kmh is not None else 0.0) - ele_1hz.append(p.elevation_m) + sparse_speed[t] = p.speed_kmh if p.speed_kmh is not None else 0.0 + sparse_ele[t] = p.elevation_m + + if not sparse_speed: + return None, None + + t_min = min(sparse_speed) + t_max = max(sparse_speed) + speed_1hz: list[float] = [sparse_speed.get(t, 0.0) for t in range(t_min, t_max + 1)] + ele_1hz: list[Optional[float]] = [sparse_ele.get(t) for t in range(t_min, t_max + 1)] best_efforts: Optional[list[list[float]]] = None if targets and speed_1hz: diff --git a/bincio/extract/writer.py b/bincio/extract/writer.py index f4d11fb..143e951 100644 --- a/bincio/extract/writer.py +++ b/bincio/extract/writer.py @@ -83,6 +83,13 @@ def write_activity( } json_path = acts_dir / f"{activity_id}.json" + # Collision guard: if a *different* activity already has this ID, append a + # short hash suffix to disambiguate (same hash = idempotent re-extract). + if json_path.exists(): + existing = json.loads(json_path.read_text(encoding="utf-8")) + if existing.get("source_hash") != activity.source_hash: + activity_id = f"{activity_id}-{activity.source_hash[-6:]}" + json_path = acts_dir / f"{activity_id}.json" json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False)) # ── GeoJSON track ──────────────────────────────────────────────────────── diff --git a/site/src/components/ActivityMap.svelte b/site/src/components/ActivityMap.svelte index e6f1ce9..653003d 100644 --- a/site/src/components/ActivityMap.svelte +++ b/site/src/components/ActivityMap.svelte @@ -85,13 +85,15 @@ $: if (map && MarkerClass && timeseries && !markersAdded) { markersAdded = true; const add = () => { - const lats = (timeseries!.lat ?? []).filter(v => v != null) as number[]; - const lons = (timeseries!.lon ?? []).filter(v => v != null) as number[]; - if (!lats.length) return; + // Filter lat/lon together so indices stay aligned + const pts = (timeseries!.lat ?? []) + .map((lat, i) => ({ lat, lon: (timeseries!.lon ?? [])[i] })) + .filter(p => p.lat != null && p.lon != null) as { lat: number; lon: number }[]; + if (!pts.length) return; new MarkerClass({ element: makeDot('#4ade80'), anchor: 'center' }) - .setLngLat([lons[0], lats[0]]).addTo(map); + .setLngLat([pts[0].lon, pts[0].lat]).addTo(map); new MarkerClass({ element: makeDot('#f87171'), anchor: 'center' }) - .setLngLat([lons[lons.length - 1], lats[lats.length - 1]]).addTo(map); + .setLngLat([pts[pts.length - 1].lon, pts[pts.length - 1].lat]).addTo(map); }; map.loaded() ? add() : map.once('load', add); }