From 862f71901c3e11f9d21a33b39a110357ccc6d4c4 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 20 Aug 2026 16:20:44 +0200 Subject: fix(ride): use effort metrics for moving time --- cli/cli.go | 8 ++++---- cli/cli_test.go | 22 ++++++++++++++++++++++ ride/moving_time.go | 19 ++++++++++++++++--- ride/moving_time_test.go | 30 ++++++++++++++++++++++++++++-- 4 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 cli/cli_test.go diff --git a/cli/cli.go b/cli/cli.go index d95f06f..d3aff22 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -135,7 +135,7 @@ func runBacktest(db *sql.DB) error { fmt.Fprintf(table, "p%g\t%.1f\n", p, distribution[p]) } table.Flush() - slog.Info("Moving time backtest", "rides", len(samples), "mean_err_pct", meanAbsErr(absErrors), "stderr_pct", stdErrAbsErr(absErrors)) + slog.Info("Moving time backtest", "rides", len(samples), "mean_err_pct", math.Round(meanAbsErr(absErrors)*10)/10, "stderr_pct", math.Round(stdErrAbsErr(absErrors)*10)/10) return nil } @@ -144,7 +144,7 @@ func meanAbsErr(absErrors []float64) float64 { for _, err := range absErrors { sum += err } - return math.Round(sum/float64(len(absErrors))*10) / 10 + return sum / float64(len(absErrors)) } func stdErrAbsErr(absErrors []float64) float64 { @@ -154,7 +154,7 @@ func stdErrAbsErr(absErrors []float64) float64 { sumSquares += (err - mean) * (err - mean) } variance := sumSquares / float64(len(absErrors)-1) - return math.Round(math.Sqrt(variance/float64(len(absErrors)))*10) / 10 + return math.Sqrt(variance / float64(len(absErrors))) } func errorDistribution(absErrors []float64, percentiles []float64) map[float64]float64 { @@ -169,7 +169,7 @@ func errorDistribution(absErrors []float64, percentiles []float64) map[float64]f if index >= len(sorted) { index = len(sorted) - 1 } - distribution[p] = math.Round(sorted[index]*10) / 10 + distribution[p] = sorted[index] } return distribution } diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..8290bce --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,22 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBacktestStatsUseUnroundedSamples(t *testing.T) { + absErrors := []float64{0, 0.1, 0.1} + + assert.InDelta(t, 1.0/15, meanAbsErr(absErrors), 1e-12) + assert.InDelta(t, 1.0/30, stdErrAbsErr(absErrors), 1e-12) +} + +func TestErrorDistributionUsesNearestRank(t *testing.T) { + distribution := errorDistribution([]float64{1, 2, 3, 4}, []float64{50, 75, 99}) + + assert.Equal(t, 2.0, distribution[50]) + assert.Equal(t, 3.0, distribution[75]) + assert.Equal(t, 4.0, distribution[99]) +} diff --git a/ride/moving_time.go b/ride/moving_time.go index ea9ec24..c98e6b1 100644 --- a/ride/moving_time.go +++ b/ride/moving_time.go @@ -2,18 +2,22 @@ package ride import "time" -const MovingTimeAlgorithmVersion = "v1" +const MovingTimeAlgorithmVersion = "v2" // movingTimeMinSpeedMps is the minimum implied speed between two consecutive // track points for the interval to count as moving (0.4 km/h). const movingTimeMinSpeedMps = 0.4 / 3.6 +// movingTimeMinPowerW is the minimum power output for an interval to count as +// moving when GPS speed is below the minimum speed. +const movingTimeMinPowerW = 20 + // movingTimeMaxGap is the maximum time gap between consecutive track points // that can count as moving. Longer gaps are pauses (stops, lost signal). const movingTimeMaxGap = 120 * time.Second // Bench (356 rides, `-backtest`): -// absMean 2.5%, bias -1.3, p50 0.8, p75 2.9, p90 6.0, p95 9.3, p99 26.2. +// absMean 2.4%, stderr 0.2, p50 0.7, p75 2.9, p90 6.7, p95 9.6, p99 19.9. func MovingTime(r Ride) time.Duration { return movingTime(r, movingTimeMinSpeedMps, movingTimeMaxGap) } @@ -26,7 +30,16 @@ func movingTime(r Ride, minSpeedMps float64, maxGap time.Duration) time.Duration continue } dd := r.DistanceM(i) - r.DistanceM(i-1) - if dd/dt.Seconds() > minSpeedMps { + gpsMoving := dd/dt.Seconds() > minSpeedMps + powerMoving := false + if power, ok := r.PowerW(i); ok { + powerMoving = power >= movingTimeMinPowerW + } + cadenceMoving := false + if cadence, ok := r.CadenceRpm(i); ok { + cadenceMoving = cadence > 0 + } + if gpsMoving || powerMoving || cadenceMoving { moving += dt } } diff --git a/ride/moving_time_test.go b/ride/moving_time_test.go index 402726f..06f9998 100644 --- a/ride/moving_time_test.go +++ b/ride/moving_time_test.go @@ -1,6 +1,7 @@ package ride_test import ( + "fmt" "strings" "testing" "time" @@ -25,6 +26,17 @@ func trackPoint(lat, lon string, timestamp string) string { `` } +func metricTrackPoint(lat, lon, timestamp string, cadence, power int) string { + return `` + + `100` + + `` + + `` + + `` + fmt.Sprint(cadence) + `` + + `` + fmt.Sprint(power) + `` + + `` + + `` +} + func TestMovingTimeCountsSteadyRiding(t *testing.T) { parsed := gpxWithPoints(t, trackPoint("43.0", "5.0", "2026-01-01T10:00:00Z")+ @@ -55,6 +67,20 @@ func TestMovingTimeExcludesLunchBreak(t *testing.T) { assert.Equal(t, time.Minute, ride.MovingTime(parsed)) } +func TestMovingTimeCountsEffortMetricsWhenGPSSpeedIsLow(t *testing.T) { + data := parserTestGPXMetricsPrefix + + metricTrackPoint("43.0", "5.0", "2026-01-01T10:00:00Z", 0, 0) + + metricTrackPoint("43.000001", "5.0", "2026-01-01T10:01:00Z", 80, 0) + + metricTrackPoint("43.000002", "5.0", "2026-01-01T10:02:00Z", 0, 50) + + metricTrackPoint("43.000003", "5.0", "2026-01-01T10:03:00Z", 0, 10) + + metricTrackPoint("43.000004", "5.0", "2026-01-01T10:04:00Z", 0, 0) + + parserTestGPXSuffix + parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data)) + require.NoError(t, err) + + assert.Equal(t, 2*time.Minute, ride.MovingTime(parsed)) +} + func TestMovingTimeWithoutTimestamps(t *testing.T) { data := parserTestGPXPrefix + `100` + @@ -68,5 +94,5 @@ func TestMovingTimeWithoutTimestamps(t *testing.T) { } func TestMovingTimeAlgorithmVersion(t *testing.T) { - assert.Equal(t, "v1", ride.MovingTimeAlgorithmVersion) -} \ No newline at end of file + assert.Equal(t, "v2", ride.MovingTimeAlgorithmVersion) +} -- cgit v1.3