aboutsummaryrefslogtreecommitdiff
path: root/cli
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-01 15:32:38 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-01 15:32:38 +0200
commit7b6bd3ae858c5d7877f9ef137a3ff3d6f71ce1fc (patch)
tree28a54e79a6d3c124adca760a38844187bc84e21b /cli
parent793d2032041335004427c10f2edb7002df20a990 (diff)
feat: Analyse and fix elevation issues
Diffstat (limited to 'cli')
-rw-r--r--cli/cli.go328
1 files changed, 328 insertions, 0 deletions
diff --git a/cli/cli.go b/cli/cli.go
index 672cc34..66ebb98 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -3,15 +3,19 @@ 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"
@@ -69,6 +73,22 @@ func Run(db *sql.DB, configPath string, appConfig config.Config, args []string,
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:] {
@@ -99,6 +119,8 @@ Commands:
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
`)
}
@@ -238,3 +260,309 @@ func errorDistribution(absErrors []float64, percentiles []float64) map[float64]f
}
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
+}