From db4d2b40895db41ce188549f49a6f277ba95c890 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 6 Sep 2026 16:40:17 +0200 Subject: Split cli.go into per-domain command files Move each CLI command into its owning package (mountain_pass, ride, web) with dispatch and usage in main.go; remove the cli package. --- ride/cli.go | 481 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ ride/cli_test.go | 54 +++++++ 2 files changed, 535 insertions(+) create mode 100644 ride/cli.go create mode 100644 ride/cli_test.go (limited to 'ride') 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 [--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) +} + +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 +} 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]) +} -- cgit v1.3