package cli import ( "context" "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/config" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/biking_home/web" "github.com/martinlehoux/kagamigo/kcore" "github.com/schollz/progressbar/v3" ) func Run(db *sql.DB, configPath string, appConfig config.Config, args []string, enablePprof bool) { if len(args) == 0 { runServer(db, configPath, appConfig, enablePprof) return } switch args[0] { case "fetch-osm": err := mountain_pass.FetchFrancePBF("france-latest.osm.pbf") kcore.Expect(err, "failed to download France OSM PBF") case "extract-osm": if len(args) < 2 { fmt.Fprintln(os.Stderr, "extract-osm requires a PBF file path argument") printUsage() os.Exit(2) } pbfPath := args[1] enabled := progressBarEnabled() var bar *progressbar.ProgressBar _, err := mountain_pass.ExtractMountainPasses(context.Background(), pbfPath, db, func(current, total int64) { if bar == nil && enabled { bar = progressbar.DefaultBytes(total, "Extracting mountain passes") } if bar != nil { bar.Set64(current) } }) if bar != nil { bar.Finish() } kcore.Expect(err, "failed to extract mountain passes from OSM") case "backfill": enabled := progressBarEnabled() var bar *progressbar.ProgressBar count, err := ride.Backfill(db, func(current, total int) { if bar == nil && enabled { 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) case "backtest": kcore.Expect(runBacktest(db), "failed to run backtest") case "elevation": if len(args) < 2 { fmt.Fprintln(os.Stderr, "elevation requires subcommand: analyse | fix") printElevationUsage() os.Exit(2) } switch args[1] { case "analyse", "analyze": kcore.Expect(runElevationAnalyse(db, args[2:]), "elevation analyse failed") case "fix": kcore.Expect(runElevationFix(db, args[2:]), "elevation fix failed") default: fmt.Fprintf(os.Stderr, "unknown elevation subcommand %q\n", args[1]) printElevationUsage() os.Exit(2) } case "serve": pprof := enablePprof for _, arg := range args[1:] { if arg == "--pprof" || arg == "-pprof" || arg == "pprof" { pprof = true } } runServer(db, configPath, appConfig, pprof) case "help", "-h", "--help": printUsage() default: fmt.Fprintf(os.Stderr, "unknown command %q\n", args[0]) printUsage() os.Exit(2) } } func printUsage() { fmt.Fprintf(os.Stderr, `Usage: biking_home [global flags] [args] Global flags: -config path to YAML configuration file (default "config.yaml") -pprof expose pprof endpoints at /debug/pprof (also: "serve --pprof") Commands: serve start web server (default when no command given) fetch-osm download the France OSM PBF into france-latest.osm.pbf extract-osm extract mountain passes from an OSM PBF file backfill recompute all stored ride values backtest run data benchmarks comparing computed values against stored reference values elevation analyse detect elevation outliers (tunnel spikes) elevation fix linearly fix elevation between anchors `) } // progressBarEnabled reports whether stderr is attached to a terminal, so the // progress bar only renders for interactive runs and stays out of piped or // redirected output. func progressBarEnabled() bool { fi, err := os.Stderr.Stat() return err == nil && fi.Mode()&os.ModeCharDevice != 0 } func runServer(db *sql.DB, configPath string, appConfig config.Config, enablePprof bool) { server := web.NewServer(db, configPath).WithPprof(enablePprof) if enablePprof { slog.Info("pprof enabled", "endpoint", appConfig.Server.PublicURL+"/debug/pprof/") } 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 := ride.List(db) if err != nil { return err } var samples []backtestSample var bar *progressbar.ProgressBar if 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 := ride.ParseFile(ride.FITRideParser{}, item.FitPath) if err != nil { if errors.Is(err, ride.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 := 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 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 ride.StoredRide) bool { if item.FitPath == "" || item.ExternalMovingTimeS <= 0 || item.ElapsedTimeS <= 0 { return false } if !item.IsCategorised() { return false } if item.RideType == ride.RideTypeIndoor || item.RideType == ride.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 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 `) } type elevationAnalyseResult struct { RideID int64 `json:"rideId"` Name string `json:"name"` Distance float64 `json:"distanceM"` Cotacol float64 `json:"cotacol"` Zones []ride.FixZone `json:"zones"` } func runElevationAnalyse(db *sql.DB, args []string) error { var target string var jsonOut bool var threshold float64 = ride.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 []ride.StoredRide if target == "all" { all, err := ride.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 := ride.GetByID(db, id) if err != nil { return err } if !found { return fmt.Errorf("ride %d not found", id) } rides = []ride.StoredRide{item} } results := make([]elevationAnalyseResult, 0, len(rides)) for _, item := range rides { if item.FitPath == "" && item.GPXPath == "" { continue } var parsed ride.Ride var err error if item.FitPath != "" { parsed, err = ride.ParseFile(ride.FITRideParser{}, item.FitPath) if err != nil && item.GPXPath != "" { parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) } } else { parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) } if err != nil { slog.Warn("Failed to parse ride for elevation analyse", "ride", item.ID, "error", err) continue } outliers := ride.DetectElevationOutliers(parsed.Distances, parsed.Elevations, threshold) if len(outliers) == 0 && target != "all" { // still report empty } else if len(outliers) == 0 { continue } zones := ride.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 = ride.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 := ride.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) } var parsed ride.Ride if item.FitPath != "" { parsed, err = ride.ParseFile(ride.FITRideParser{}, item.FitPath) if err != nil && item.GPXPath != "" { parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) } } else { parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) } if err != nil { return fmt.Errorf("parse ride %d: %w", id, err) } before := parsed.Cotacol() var zones []ride.FixZone if auto { outliers := ride.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 = ride.SuggestFixZones(outliers) } else { fromIdx, err := ride.ResolveIndex(parsed.Distances, fromRaw) if err != nil { return fmt.Errorf("invalid --from: %w", err) } toIdx, err := ride.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 = []ride.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) ride.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 := ride.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 := ride.Save(db, updated); err != nil { return fmt.Errorf("save ride %d: %w", id, err) } // Report reparsed, _ := ride.ParseFile(ride.FITRideParser{}, updated.FitPath) if reparsed.Len() == 0 && updated.GPXPath != "" { reparsed, _ = ride.ParseFile(ride.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 }