package cli import ( "context" "database/sql" "errors" "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/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 "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 `) } // 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 }