aboutsummaryrefslogtreecommitdiff
path: root/cli/cli.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-06 16:40:17 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-06 16:40:17 +0200
commitdb4d2b40895db41ce188549f49a6f277ba95c890 (patch)
tree0f9ff048726f6ec030606041eebe808b462ebe7d /cli/cli.go
parent8e0c55230ef59869bda1ca19ca8697b447d57698 (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.go568
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
-}