aboutsummaryrefslogtreecommitdiff
path: root/cli
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-20 15:47:52 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-20 15:47:52 +0200
commit57e48b23987630936c84300d87ea5be682cb64bd (patch)
tree96e840749bb658c2162788cedca627165747a254 /cli
parent992c36c6c7ce7e1e732866b3cd4fb8269a905875 (diff)
feat(ride): compute moving time from GPX with backtest
Add ride.MovingTime, a speed-threshold algorithm over track timestamps. Add a -backtest CLI command comparing computed values against Strava's reference across all stored rides, with a percentile distribution summary. Baseline: mean err 3.9%, p50 1.6, p75 4.6, p90 8.4, p95 12.1, p99 44.9.
Diffstat (limited to 'cli')
-rw-r--r--cli/cli.go117
1 files changed, 117 insertions, 0 deletions
diff --git a/cli/cli.go b/cli/cli.go
index eb96356..d95f06f 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -4,12 +4,18 @@ import (
"context"
"database/sql"
"flag"
+ "fmt"
"log/slog"
+ "math"
+ "os"
+ "sort"
+ "text/tabwriter"
"time"
"github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/osmpass"
+ "github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/biking_home/rides"
"github.com/martinlehoux/biking_home/web"
"github.com/martinlehoux/kagamigo/kcore"
@@ -23,6 +29,7 @@ var (
extractOSM = flag.String("extract-osm", "", "extract mountain passes from an OSM PBF file into the database")
enrich = flag.Bool("enrich", false, "backfill mountain pass coordinates from OSM data")
backfill = flag.Bool("backfill", false, "recompute all stored ride values")
+ backtest = flag.Bool("backtest", false, "run data benchmarks comparing computed values against stored reference values")
)
func Run(db *sql.DB, configPath string, appConfig config.Config) {
@@ -46,6 +53,8 @@ func Run(db *sql.DB, configPath string, appConfig config.Config) {
count, err := rides.Backfill(db)
kcore.Expect(err, "failed to backfill ride values")
slog.Info("Backfilled ride values", "rides", count)
+ case *backtest:
+ kcore.Expect(runBacktest(db), "failed to run backtest")
default:
runServer(db, configPath, appConfig)
}
@@ -56,3 +65,111 @@ func runServer(db *sql.DB, configPath string, appConfig config.Config) {
slog.Info("Starting web server", "address", appConfig.Server.PublicURL)
kcore.Expect(server.ListenAndServe(appConfig.Server.Address), "web server stopped")
}
+
+type backtestSample struct {
+ externalID string
+ name string
+ computed time.Duration
+ reference time.Duration
+ relErrorPct float64
+}
+
+func runBacktest(db *sql.DB) error {
+ items, err := rides.List(db)
+ if err != nil {
+ return err
+ }
+ var samples []backtestSample
+ for i, item := range items {
+ if i > 0 && i%50 == 0 {
+ slog.Info("Backtest progress", "processed", i, "total", len(items))
+ }
+ if item.GPXPath == "" {
+ continue
+ }
+ parsed, err := ride.ParseFile(ride.GPXRideParser{}, item.GPXPath)
+ if err != nil {
+ slog.Warn("Failed to parse ride GPX", "ride", item.ExternalID, "file", item.GPXPath, "error", err)
+ continue
+ }
+ reference := time.Duration(item.MovingTimeS) * time.Second
+ if reference <= 0 {
+ continue
+ }
+ computed := ride.MovingTime(parsed)
+ samples = append(samples, backtestSample{
+ externalID: item.ExternalID,
+ name: item.Name,
+ computed: computed,
+ reference: reference,
+ relErrorPct: (float64(computed) - float64(reference)) / float64(reference) * 100,
+ })
+ }
+ if len(samples) == 0 {
+ slog.Info("No rides with GPX to backtest", "rides", len(items))
+ return nil
+ }
+ sort.Slice(samples, func(i, j int) bool {
+ return math.Abs(samples[i].relErrorPct) > math.Abs(samples[j].relErrorPct)
+ })
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "computed\treference\terr %\tride")
+ limit := 10
+ if len(samples) < limit {
+ limit = len(samples)
+ }
+ for _, s := range samples[:limit] {
+ fmt.Fprintf(w, "%s\t%s\t%+.1f\t%s (%s)\n", s.computed.Round(time.Second), s.reference.Round(time.Second), s.relErrorPct, s.name, s.externalID)
+ }
+ w.Flush()
+
+ absErrors := make([]float64, 0, len(samples))
+ for _, s := range samples {
+ absErrors = append(absErrors, math.Abs(s.relErrorPct))
+ }
+ percentiles := []float64{50, 75, 90, 95, 99}
+ distribution := errorDistribution(absErrors, percentiles)
+ table := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(table, "percentile\tabs err %")
+ for _, p := range percentiles {
+ 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))
+ return nil
+}
+
+func meanAbsErr(absErrors []float64) float64 {
+ var sum float64
+ for _, err := range absErrors {
+ sum += err
+ }
+ return math.Round(sum/float64(len(absErrors))*10) / 10
+}
+
+func stdErrAbsErr(absErrors []float64) float64 {
+ mean := meanAbsErr(absErrors)
+ var sumSquares float64
+ for _, err := range absErrors {
+ sumSquares += (err - mean) * (err - mean)
+ }
+ variance := sumSquares / float64(len(absErrors)-1)
+ return math.Round(math.Sqrt(variance/float64(len(absErrors)))*10) / 10
+}
+
+func errorDistribution(absErrors []float64, percentiles []float64) map[float64]float64 {
+ sorted := append([]float64(nil), absErrors...)
+ sort.Float64s(sorted)
+ distribution := map[float64]float64{}
+ for _, p := range percentiles {
+ index := int(math.Ceil(p/100*float64(len(sorted))) - 1)
+ if index < 0 {
+ index = 0
+ }
+ if index >= len(sorted) {
+ index = len(sorted) - 1
+ }
+ distribution[p] = math.Round(sorted[index]*10) / 10
+ }
+ return distribution
+}