aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-20 16:20:44 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-20 16:20:44 +0200
commit862f71901c3e11f9d21a33b39a110357ccc6d4c4 (patch)
treed8cea4aeea10db7b380a1b3f0fb6fe26bbb50026
parent15ce334f357ca4f061977ad342292b21c5e60ea6 (diff)
fix(ride): use effort metrics for moving time
-rw-r--r--cli/cli.go8
-rw-r--r--cli/cli_test.go22
-rw-r--r--ride/moving_time.go19
-rw-r--r--ride/moving_time_test.go30
4 files changed, 70 insertions, 9 deletions
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 {
`</trkpt>`
}
+func metricTrackPoint(lat, lon, timestamp string, cadence, power int) string {
+ return `<trkpt lat="` + lat + `" lon="` + lon + `">` +
+ `<ele>100</ele>` +
+ `<time>` + timestamp + `</time>` +
+ `<extensions><gpxtpx:TrackPointExtension>` +
+ `<gpxtpx:cad>` + fmt.Sprint(cadence) + `</gpxtpx:cad>` +
+ `<gpxtpx:watts>` + fmt.Sprint(power) + `</gpxtpx:watts>` +
+ `</gpxtpx:TrackPointExtension></extensions>` +
+ `</trkpt>`
+}
+
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 +
`<trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt>` +
@@ -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)
+}