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 [--json] [--threshold ] biking_home elevation fix --auto [--threshold ] [--dry-run] biking_home elevation fix --from --to [--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) } // ParseStoredRide parses a 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) { return parseStoredRide(item) } 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 ") } 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 ") } 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 }