aboutsummaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
Diffstat (limited to 'ride')
-rw-r--r--ride/cli.go481
-rw-r--r--ride/cli_test.go54
2 files changed, 535 insertions, 0 deletions
diff --git a/ride/cli.go b/ride/cli.go
new file mode 100644
index 0000000..a460260
--- /dev/null
+++ b/ride/cli.go
@@ -0,0 +1,481 @@
+package ride
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "math"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/kagamigo/kcore"
+ "github.com/schollz/progressbar/v3"
+)
+
+func BackfillCmd(db *sql.DB) {
+ var bar *progressbar.ProgressBar
+ count, err := Backfill(db, func(current, total int) {
+ if bar == nil && mountain_pass.ProgressBarEnabled() {
+ bar = progressbar.Default(int64(total), "Backfilling ride values")
+ }
+ if bar != nil {
+ bar.Set(current)
+ }
+ })
+ if bar != nil {
+ bar.Finish()
+ }
+ kcore.Expect(err, "failed to backfill ride values")
+ slog.Info("Backfilled ride values", "rides", count)
+}
+
+type backtestSample struct {
+ externalID string
+ name string
+ computed time.Duration
+ reference time.Duration
+ relErrorPct float64
+}
+
+func BacktestCmd(db *sql.DB) {
+ kcore.Expect(runBacktest(db), "failed to run backtest")
+}
+
+func runBacktest(db *sql.DB) error {
+ items, err := List(db)
+ if err != nil {
+ return err
+ }
+ var samples []backtestSample
+ var bar *progressbar.ProgressBar
+ if mountain_pass.ProgressBarEnabled() {
+ bar = progressbar.Default(int64(len(items)), "Backtesting rides")
+ defer bar.Finish()
+ }
+ for _, item := range items {
+ if bar != nil {
+ bar.Add(1)
+ }
+ if !isBacktestEligible(item) {
+ continue
+ }
+ parsed, err := ParseFile(FITRideParser{}, item.FitPath)
+ if err != nil {
+ if errors.Is(err, ErrNoTrackPoints) {
+ continue
+ }
+ slog.Warn("Failed to parse ride FIT", "ride", item.ExternalID, "file", item.FitPath, "error", err)
+ continue
+ }
+ reference := time.Duration(item.ExternalMovingTimeS) * time.Second
+ computed := 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 FIT 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 := min(len(samples), 10)
+ 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", math.Round(meanAbsErr(absErrors)*10)/10, "stderr_pct", math.Round(stdErrAbsErr(absErrors)*10)/10)
+ return nil
+}
+
+func isBacktestEligible(item StoredRide) bool {
+ if item.FitPath == "" || item.ExternalMovingTimeS <= 0 || item.ElapsedTimeS <= 0 {
+ return false
+ }
+ if !item.IsCategorised() {
+ return false
+ }
+ if item.RideType == RideTypeIndoor || item.RideType == RideTypeTransport {
+ return false
+ }
+ return true
+}
+
+func meanAbsErr(absErrors []float64) float64 {
+ var sum float64
+ for _, err := range absErrors {
+ sum += err
+ }
+ return sum / float64(len(absErrors))
+}
+
+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.Sqrt(variance / float64(len(absErrors)))
+}
+
+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 := max(int(math.Ceil(p/100*float64(len(sorted)))-1), 0)
+ if index >= len(sorted) {
+ index = len(sorted) - 1
+ }
+ distribution[p] = sorted[index]
+ }
+ return distribution
+}
+
+func ElevationCmd(db *sql.DB, args []string) {
+ if len(args) == 0 {
+ fmt.Fprintln(os.Stderr, "elevation requires subcommand: analyse | fix")
+ printElevationUsage()
+ os.Exit(2)
+ }
+ switch args[0] {
+ case "analyse", "analyze":
+ kcore.Expect(runElevationAnalyse(db, args[1:]), "elevation analyse failed")
+ case "fix":
+ kcore.Expect(runElevationFix(db, args[1:]), "elevation fix failed")
+ default:
+ fmt.Fprintf(os.Stderr, "unknown elevation subcommand %q\n", args[0])
+ printElevationUsage()
+ os.Exit(2)
+ }
+}
+
+func printElevationUsage() {
+ fmt.Fprintf(os.Stderr, `Usage:
+ biking_home elevation analyse <ride-id|all> [--json] [--threshold <grade>]
+ biking_home elevation fix <ride-id> --auto [--threshold <grade>] [--dry-run]
+ biking_home elevation fix <ride-id> --from <km|index> --to <km|index> [--dry-run]
+
+Examples:
+ biking_home elevation analyse 754
+ biking_home elevation analyse all --json | jq
+ biking_home elevation fix 754 --auto
+ biking_home elevation fix 754 --from 4.4km --to 4.8km
+ biking_home elevation fix 754 --from 927 --to 992
+`)
+}
+
+// parseStoredRide parses the stored ride's route files, preferring FIT and
+// falling back to GPX when FIT parsing fails or is missing.
+func parseStoredRide(item StoredRide) (Ride, error) {
+ if item.FitPath == "" && item.GPXPath == "" {
+ return Ride{}, errors.New("no route files")
+ }
+ if item.FitPath != "" {
+ parsed, err := ParseFile(FITRideParser{}, item.FitPath)
+ if err == nil || item.GPXPath == "" {
+ return parsed, err
+ }
+ }
+ return ParseFile(GPXRideParser{}, item.GPXPath)
+}
+
+type elevationAnalyseResult struct {
+ RideID int64 `json:"rideId"`
+ Name string `json:"name"`
+ Distance float64 `json:"distanceM"`
+ Cotacol float64 `json:"cotacol"`
+ Zones []FixZone `json:"zones"`
+}
+
+func runElevationAnalyse(db *sql.DB, args []string) error {
+ var target string
+ var jsonOut bool
+ var threshold float64 = DefaultGradeThreshold
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--json":
+ jsonOut = true
+ case "--threshold":
+ if i+1 >= len(args) {
+ return errors.New("--threshold requires a value")
+ }
+ i++
+ v, err := strconv.ParseFloat(args[i], 64)
+ if err != nil {
+ return fmt.Errorf("invalid threshold %q: %w", args[i], err)
+ }
+ threshold = v
+ default:
+ if strings.HasPrefix(args[i], "--") {
+ return fmt.Errorf("unknown flag %q", args[i])
+ }
+ if target == "" {
+ target = args[i]
+ } else {
+ return fmt.Errorf("unexpected arg %q", args[i])
+ }
+ }
+ }
+ if target == "" {
+ return errors.New("elevation analyse requires <ride-id|all>")
+ }
+ var rides []StoredRide
+ if target == "all" {
+ all, err := List(db)
+ if err != nil {
+ return err
+ }
+ rides = all
+ } else {
+ id, err := strconv.ParseInt(target, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid ride id %q", target)
+ }
+ item, found, err := GetByID(db, id)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return fmt.Errorf("ride %d not found", id)
+ }
+ rides = []StoredRide{item}
+ }
+ results := make([]elevationAnalyseResult, 0, len(rides))
+ for _, item := range rides {
+ parsed, err := parseStoredRide(item)
+ if err != nil {
+ slog.Warn("Failed to parse ride for elevation analyse", "ride", item.ID, "error", err)
+ continue
+ }
+ outliers := DetectElevationOutliers(parsed.Distances, parsed.Elevations, threshold)
+ if len(outliers) == 0 && target != "all" {
+ // still report empty
+ } else if len(outliers) == 0 {
+ continue
+ }
+ zones := SuggestFixZones(outliers)
+ cotacol := parsed.Cotacol()
+ results = append(results, elevationAnalyseResult{
+ RideID: item.ID,
+ Name: item.Name,
+ Distance: item.DistanceM,
+ Cotacol: cotacol,
+ Zones: zones,
+ })
+ }
+ if target == "all" {
+ sort.Slice(results, func(i, j int) bool {
+ maxI, maxJ := 0.0, 0.0
+ for _, z := range results[i].Zones {
+ // parse reason grade not stored; use zone span as proxy
+ span := z.EndKm - z.StartKm
+ if span > maxI {
+ maxI = span
+ }
+ }
+ for _, z := range results[j].Zones {
+ span := z.EndKm - z.StartKm
+ if span > maxJ {
+ maxJ = span
+ }
+ }
+ return len(results[i].Zones) > len(results[j].Zones) || (len(results[i].Zones) == len(results[j].Zones) && maxI > maxJ)
+ })
+ }
+ if jsonOut {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(results)
+ }
+ if len(results) == 0 {
+ fmt.Println("No elevation outliers detected.")
+ return nil
+ }
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+ fmt.Fprintln(w, "ride\tname\tcotacol\tzone\tkm\tgrade")
+ for _, r := range results {
+ for _, z := range r.Zones {
+ fmt.Fprintf(w, "%d\t%s\t%.1f\t%d→%d\t%.2f–%.2f\t%s\n", r.RideID, r.Name, r.Cotacol, z.StartIdx, z.EndIdx, z.StartKm, z.EndKm, z.Reason)
+ }
+ }
+ w.Flush()
+ slog.Info("Elevation analyse complete", "rides", len(results))
+ return nil
+}
+
+func runElevationFix(db *sql.DB, args []string) error {
+ var rideIDRaw string
+ var fromRaw, toRaw string
+ var auto bool
+ var dryRun bool
+ var threshold float64 = DefaultGradeThreshold
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--auto":
+ auto = true
+ case "--dry-run":
+ dryRun = true
+ case "--from":
+ if i+1 >= len(args) {
+ return errors.New("--from requires a value")
+ }
+ i++
+ fromRaw = args[i]
+ case "--to":
+ if i+1 >= len(args) {
+ return errors.New("--to requires a value")
+ }
+ i++
+ toRaw = args[i]
+ case "--threshold":
+ if i+1 >= len(args) {
+ return errors.New("--threshold requires a value")
+ }
+ i++
+ v, err := strconv.ParseFloat(args[i], 64)
+ if err != nil {
+ return fmt.Errorf("invalid threshold %q: %w", args[i], err)
+ }
+ threshold = v
+ default:
+ if strings.HasPrefix(args[i], "--") {
+ return fmt.Errorf("unknown flag %q", args[i])
+ }
+ if rideIDRaw == "" {
+ rideIDRaw = args[i]
+ } else {
+ return fmt.Errorf("unexpected arg %q", args[i])
+ }
+ }
+ }
+ if rideIDRaw == "" {
+ return errors.New("elevation fix requires <ride-id>")
+ }
+ if !auto && (fromRaw == "" || toRaw == "") {
+ return errors.New("elevation fix requires --auto or --from/--to")
+ }
+ if auto && (fromRaw != "" || toRaw != "") {
+ return errors.New("--auto cannot be combined with --from/--to")
+ }
+ id, err := strconv.ParseInt(rideIDRaw, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid ride id %q", rideIDRaw)
+ }
+ item, found, err := GetByID(db, id)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return fmt.Errorf("ride %d not found", id)
+ }
+ if item.FitPath == "" && item.GPXPath == "" {
+ return fmt.Errorf("ride %d has no route files", id)
+ }
+ parsed, err := parseStoredRide(item)
+ if err != nil {
+ return fmt.Errorf("parse ride %d: %w", id, err)
+ }
+ before := parsed.Cotacol()
+ var zones []FixZone
+ if auto {
+ outliers := DetectElevationOutliers(parsed.Distances, parsed.Elevations, threshold)
+ if len(outliers) == 0 {
+ fmt.Printf("Ride %d: no outliers detected (threshold %.0f%%)\n", id, threshold*100)
+ return nil
+ }
+ zones = SuggestFixZones(outliers)
+ } else {
+ fromIdx, err := ResolveIndex(parsed.Distances, fromRaw)
+ if err != nil {
+ return fmt.Errorf("invalid --from: %w", err)
+ }
+ toIdx, err := ResolveIndex(parsed.Distances, toRaw)
+ if err != nil {
+ return fmt.Errorf("invalid --to: %w", err)
+ }
+ if fromIdx >= toIdx {
+ return errors.New("--from must be before --to")
+ }
+ zones = []FixZone{{
+ StartIdx: fromIdx,
+ EndIdx: toIdx,
+ StartKm: parsed.Distances[fromIdx] / 1000,
+ EndKm: parsed.Distances[toIdx] / 1000,
+ Reason: "manual",
+ }}
+ }
+ if dryRun {
+ fmt.Printf("Ride %d %q: would fix %d zone(s), cotacol %.1f -> preview\n", id, item.Name, len(zones), before)
+ for _, z := range zones {
+ fmt.Printf(" %d→%d %.3f–%.3f km %s\n", z.StartIdx, z.EndIdx, z.StartKm, z.EndKm, z.Reason)
+ }
+ // Preview cotacol after fix
+ elevCopy := make([]float64, len(parsed.Elevations))
+ copy(elevCopy, parsed.Elevations)
+ LinearFix(parsed.Distances, elevCopy, zones)
+ afterPreview := climb.Cotacol(parsed.Distances, elevCopy)
+ fmt.Printf(" cotacol preview: %.1f -> %.1f (Δ %.1f)\n", before, afterPreview, afterPreview-before)
+ return nil
+ }
+ slog.Info("Fixing elevation", "ride", id, "zones", len(zones))
+ if err := FixRideFiles(parsed, zones, item.GPXPath, item.FitPath); err != nil {
+ return err
+ }
+ // Re-parse to compute new cotacol and persist via Save (recomputes stored values from files).
+ // Reload item to get potentially derived paths
+ updated := item
+ // If one path was missing, FixRideFiles may have created it; update derived path for Save
+ if updated.FitPath == "" && updated.GPXPath != "" {
+ updated.FitPath = updated.GPXPath[:len(updated.GPXPath)-4] + ".fit"
+ if _, err := os.Stat(updated.FitPath); err != nil {
+ updated.FitPath = ""
+ }
+ }
+ if err := Save(db, updated); err != nil {
+ return fmt.Errorf("save ride %d: %w", id, err)
+ }
+ // Report
+ reparsed, _ := ParseFile(FITRideParser{}, updated.FitPath)
+ if reparsed.Len() == 0 && updated.GPXPath != "" {
+ reparsed, _ = ParseFile(GPXRideParser{}, updated.GPXPath)
+ }
+ after := 0.0
+ if reparsed.Len() > 0 {
+ after = reparsed.Cotacol()
+ }
+ fmt.Printf("Ride %d %q fixed: %d zone(s), cotacol %.1f -> %.1f\n", id, item.Name, len(zones), before, after)
+ for _, z := range zones {
+ fmt.Printf(" %d→%d %.3f–%.3f km %s\n", z.StartIdx, z.EndIdx, z.StartKm, z.EndKm, z.Reason)
+ }
+ return nil
+}
diff --git a/ride/cli_test.go b/ride/cli_test.go
new file mode 100644
index 0000000..7272836
--- /dev/null
+++ b/ride/cli_test.go
@@ -0,0 +1,54 @@
+package ride
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBacktestEligibilityExcludesShortDistances(t *testing.T) {
+ short := StoredRide{FitPath: "fit", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining}
+ minimum := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining}
+
+ assert.True(t, isBacktestEligible(short))
+ assert.True(t, isBacktestEligible(minimum))
+}
+
+func TestBacktestEligibilityExcludesLowMovingRatio(t *testing.T) {
+ low := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining}
+ boundary := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining}
+ noElapsed := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 1, BikeType: BikeTypeRoad, RideType: RideTypeTraining}
+
+ assert.True(t, isBacktestEligible(low))
+ assert.True(t, isBacktestEligible(boundary))
+ assert.False(t, isBacktestEligible(noElapsed))
+}
+
+func TestBacktestEligibilityExcludesUncategorisedAndIndoorTransport(t *testing.T) {
+ base := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining}
+ uncategorised := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000}
+ indoor := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeIndoor}
+ transport := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTransport}
+ race := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeMTB, RideType: RideTypeRace}
+
+ assert.True(t, isBacktestEligible(base))
+ assert.False(t, isBacktestEligible(uncategorised))
+ assert.False(t, isBacktestEligible(indoor))
+ assert.False(t, isBacktestEligible(transport))
+ assert.True(t, isBacktestEligible(race))
+}
+
+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])
+}