diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-06 16:40:17 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-06 16:40:17 +0200 |
| commit | db4d2b40895db41ce188549f49a6f277ba95c890 (patch) | |
| tree | 0f9ff048726f6ec030606041eebe808b462ebe7d /cli/cli.go | |
| parent | 8e0c55230ef59869bda1ca19ca8697b447d57698 (diff) | |
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.
Diffstat (limited to 'cli/cli.go')
| -rw-r--r-- | cli/cli.go | 568 |
1 files changed, 0 insertions, 568 deletions
diff --git a/cli/cli.go b/cli/cli.go deleted file mode 100644 index 66ebb98..0000000 --- a/cli/cli.go +++ /dev/null @@ -1,568 +0,0 @@ -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] <command> [args] - -Global flags: - -config <path> 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 <path> 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 <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 -`) -} - -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 <ride-id|all>") - } - 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 <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 := 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 -} |
