diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | cli/cli.go | 117 | ||||
| -rw-r--r-- | ride/moving_time.go | 28 | ||||
| -rw-r--r-- | ride/moving_time_test.go | 72 |
4 files changed, 224 insertions, 0 deletions
@@ -18,6 +18,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c - **Metadata-only ride imports** — stores Strava activity statistics for rides without GPS tracks, without Cotacol or route data - **Ride table sorting** — sorts every ride-library column through clickable server-side headers and query parameters - **Ride library filters** — filters rides by distance, moving duration, start date, Cotacol score, and route availability through shareable query URLs +- **Computed moving time** — computes moving time from the GPX track with a speed-threshold algorithm, evaluated against Strava's reference via `-backtest` - **Ride detail maps** — opens a stored ride with its recorded route on an interactive OpenStreetMap map - **Strava import progress** — streams live import progress with an aggregate progress bar and prevents overlapping syncs @@ -36,6 +37,9 @@ mise run dev-watch # Recompute and persist all ride computed values ./biking_home -backfill +# Run data benchmarks comparing computed values against stored references +./biking_home -backtest + # Download French mountain passes into biking_home.db (SQLite) ./biking_home -download @@ -136,6 +140,9 @@ TypeScript checks can also be run directly with `npm run check`, covering format - Compute estimated power, speed - Plot speed and slope per segment, colored by heart rate - Plot speed vs ctc/100km +- http://localhost:8080/rides/378: Repentance partial climb +- regroup ride and rides packages +- screaming archi + hexagon - Blog with pictures and markdown - Road quality (Arbois = 2/5, Roquefavour = 4/5) - OffClimb @@ -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 +} diff --git a/ride/moving_time.go b/ride/moving_time.go new file mode 100644 index 0000000..8403772 --- /dev/null +++ b/ride/moving_time.go @@ -0,0 +1,28 @@ +package ride + +import "time" + +const MovingTimeAlgorithmVersion = "v1" + +// movingTimeMinSpeedMps is the minimum implied speed between two consecutive +// track points for the interval to count as moving (1 km/h). +const movingTimeMinSpeedMps = 1.0 / 3.6 + +// Baseline backtest (356 rides, `-backtest`): mean err 3.9% (stderr 0.4), +// p50 1.6, p75 4.6, p90 8.4, p95 12.1, p99 44.9. +// Worst offenders overcount stationary GPS jitter (lunch stops) and +// undercount trainer rides with little GPS movement. Tuned in future commits. +func MovingTime(r Ride) time.Duration { + var moving time.Duration + for i := 1; i < r.Len(); i++ { + dt := r.Timestamp(i).Sub(r.Timestamp(i - 1)) + if dt <= 0 { + continue + } + dd := r.DistanceM(i) - r.DistanceM(i - 1) + if dd/dt.Seconds() > movingTimeMinSpeedMps { + moving += dt + } + } + return moving +}
\ No newline at end of file diff --git a/ride/moving_time_test.go b/ride/moving_time_test.go new file mode 100644 index 0000000..402726f --- /dev/null +++ b/ride/moving_time_test.go @@ -0,0 +1,72 @@ +package ride_test + +import ( + "strings" + "testing" + "time" + + "github.com/martinlehoux/biking_home/ride" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gpxWithPoints(t *testing.T, points string) ride.Ride { + t.Helper() + data := parserTestGPXPrefix + points + parserTestGPXSuffix + parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data)) + require.NoError(t, err) + return parsed +} + +func trackPoint(lat, lon string, timestamp string) string { + return `<trkpt lat="` + lat + `" lon="` + lon + `">` + + `<ele>100</ele>` + + `<time>` + timestamp + `</time>` + + `</trkpt>` +} + +func TestMovingTimeCountsSteadyRiding(t *testing.T) { + parsed := gpxWithPoints(t, + trackPoint("43.0", "5.0", "2026-01-01T10:00:00Z")+ + trackPoint("43.009", "5.0", "2026-01-01T10:01:00Z")+ + trackPoint("43.018", "5.0", "2026-01-01T10:02:00Z"), + ) + + assert.Equal(t, 2*time.Minute, ride.MovingTime(parsed)) +} + +func TestMovingTimeExcludesSlowInterval(t *testing.T) { + parsed := gpxWithPoints(t, + trackPoint("43.0", "5.0", "2026-01-01T10:00:00Z")+ + trackPoint("43.0005", "5.0", "2026-01-01T10:10:00Z")+ + trackPoint("43.009", "5.0", "2026-01-01T10:12:00Z"), + ) + + assert.Equal(t, 2*time.Minute, ride.MovingTime(parsed)) +} + +func TestMovingTimeExcludesLunchBreak(t *testing.T) { + parsed := gpxWithPoints(t, + trackPoint("43.0", "5.0", "2026-01-01T10:00:00Z")+ + trackPoint("43.0001", "5.0", "2026-01-01T10:30:00Z")+ + trackPoint("43.009", "5.0", "2026-01-01T10:31:00Z"), + ) + + assert.Equal(t, time.Minute, ride.MovingTime(parsed)) +} + +func TestMovingTimeWithoutTimestamps(t *testing.T) { + data := parserTestGPXPrefix + + `<trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt>` + + `<trkpt lat="43.009" lon="5.0"><ele>100</ele></trkpt>` + + parserTestGPXSuffix + + parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data)) + require.NoError(t, err) + + assert.Equal(t, time.Duration(0), ride.MovingTime(parsed)) +} + +func TestMovingTimeAlgorithmVersion(t *testing.T) { + assert.Equal(t, "v1", ride.MovingTimeAlgorithmVersion) +}
\ No newline at end of file |
