aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md2
-rw-r--r--cli/cli.go328
-rw-r--r--ride/elevation_outlier.go336
-rw-r--r--ride/elevation_outlier_test.go58
-rw-r--r--web/frontend/ride-detail-boundaries.ts3
-rw-r--r--web/frontend/ride-detail.ts2
-rw-r--r--web/frontend/types.d.ts1
-rw-r--r--web/views.go2
9 files changed, 731 insertions, 2 deletions
diff --git a/.gitignore b/.gitignore
index 25fd4da..59e9e1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ config.yaml
*.png
data/rides/*.gpx
data/rides/*.fit
+data/rides/*.bak
debug_department_*.csv
france-latest.osm.pbf
biking_home
diff --git a/README.md b/README.md
index f583471..b957d90 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ Climb profile by 100m step.
**Analytics** - Plot speed vs difficulty, difficulty vs duration. Color points by type or season.
+**Elevation fix** - Hover the profile to see `#index · km · m`, run `go run . elevation analyse <id|all>` to flag unrealistic grades (>50% over <30m) and `go run . elevation fix <id> --from <index|km> --to <index|km> [--dry-run]` (or `--auto`) to linearly interpolate elevations between anchors, rewriting FIT+GPX (`.bak` kept) and recomputing Cotacol.
+
## Getting started
```bash
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
+}
diff --git a/ride/elevation_outlier.go b/ride/elevation_outlier.go
new file mode 100644
index 0000000..aa4c7ab
--- /dev/null
+++ b/ride/elevation_outlier.go
@@ -0,0 +1,336 @@
+package ride
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "math"
+ "os"
+
+ "github.com/tkrajina/gpxgo/gpx"
+)
+
+// ElevationOutlier describes a contiguous burst of unrealistic grades.
+type ElevationOutlier struct {
+ StartIdx int `json:"startIndex"`
+ EndIdx int `json:"endIndex"`
+ StartDist float64 `json:"startDistM"`
+ EndDist float64 `json:"endDistM"`
+ MaxGrade float64 `json:"maxGrade"`
+ DeltaE float64 `json:"deltaEM"`
+}
+
+// DefaultGradeThreshold is the grade beyond which a segment is considered
+// unrealistic for road cycling (50% = 0.5). Real road walls rarely exceed 25-30%
+// even on short ramps; 50% over <30m is a strong tunnel/GPS signal.
+const DefaultGradeThreshold = 0.5
+
+// MaxGapM is the maximum horizontal distance for a segment to be considered
+// for outlier detection. Longer segments with high grade are kept (e.g. a
+// sustained 10% climb over 500m is fine).
+const MaxGapM = 30
+
+// DetectElevationOutliers flags indices where |Δe|/Δd exceeds threshold within MaxGapM.
+// Consecutive flagged indices are merged into intervals.
+func DetectElevationOutliers(distances, elevations []float64, gradeThreshold float64) []ElevationOutlier {
+ if gradeThreshold <= 0 {
+ gradeThreshold = DefaultGradeThreshold
+ }
+ if len(distances) < 2 || len(distances) != len(elevations) {
+ return nil
+ }
+ // Collect raw flagged indices (the end point of each bad segment).
+ flagged := make([]int, 0)
+ maxGradeByEnd := map[int]float64{}
+ deltaByEnd := map[int]float64{}
+ for i := 1; i < len(distances); i++ {
+ dd := distances[i] - distances[i-1]
+ if dd <= 0 || dd > MaxGapM {
+ continue
+ }
+ de := elevations[i] - elevations[i-1]
+ // Ignore tiny elevation jitter (e.g. 0.3m over 0.5m = 60% but noise).
+ if math.Abs(de) < 1.5 {
+ continue
+ }
+ grade := math.Abs(de) / dd
+ if grade > gradeThreshold {
+ flagged = append(flagged, i)
+ maxGradeByEnd[i] = grade
+ deltaByEnd[i] = de
+ }
+ }
+ if len(flagged) == 0 {
+ return nil
+ }
+ // Merge consecutive flagged indices into intervals.
+ // A gap of 1 index means contiguous; allow a small gap of 2-3 non-flagged
+ // points to still merge (tunnel burst often alternates).
+ outliers := make([]ElevationOutlier, 0)
+ start := flagged[0]
+ prev := flagged[0]
+ maxGrade := maxGradeByEnd[prev]
+ sumDelta := deltaByEnd[prev]
+ for _, idx := range flagged[1:] {
+ if idx-prev <= 10 {
+ // same burst (tunnel glitch spans ~125 points with small clean stretches)
+ prev = idx
+ if maxGradeByEnd[idx] > maxGrade {
+ maxGrade = maxGradeByEnd[idx]
+ }
+ sumDelta += deltaByEnd[idx]
+ continue
+ }
+ // close current burst: expand by one anchor point on each side when possible
+ sIdx := max(start-1, 0)
+ eIdx := min(prev+1, len(distances)-1)
+ // For burst we report the flagged core, but anchors are for fix suggestion.
+ // Keep outlier as core; fix will use anchors.
+ outliers = append(outliers, ElevationOutlier{
+ StartIdx: sIdx,
+ EndIdx: eIdx,
+ StartDist: distances[sIdx],
+ EndDist: distances[eIdx],
+ MaxGrade: maxGrade,
+ DeltaE: sumDelta,
+ })
+ start = idx
+ prev = idx
+ maxGrade = maxGradeByEnd[idx]
+ sumDelta = deltaByEnd[idx]
+ }
+ sIdx := max(start-1, 0)
+ eIdx := min(prev+1, len(distances)-1)
+ outliers = append(outliers, ElevationOutlier{
+ StartIdx: sIdx,
+ EndIdx: eIdx,
+ StartDist: distances[sIdx],
+ EndDist: distances[eIdx],
+ MaxGrade: maxGrade,
+ DeltaE: sumDelta,
+ })
+ return outliers
+}
+
+// FixZone is a suggested linear-interpolation interval for the CLI.
+type FixZone struct {
+ StartIdx int `json:"startIndex"`
+ EndIdx int `json:"endIndex"`
+ StartKm float64 `json:"startKm"`
+ EndKm float64 `json:"endKm"`
+ Reason string `json:"reason"`
+}
+
+// SuggestFixZones converts outliers into fix zones with human-readable reason.
+func SuggestFixZones(outliers []ElevationOutlier) []FixZone {
+ zones := make([]FixZone, 0, len(outliers))
+ for _, o := range outliers {
+ zones = append(zones, FixZone{
+ StartIdx: o.StartIdx,
+ EndIdx: o.EndIdx,
+ StartKm: o.StartDist / 1000,
+ EndKm: o.EndDist / 1000,
+ Reason: fmt.Sprintf("max grade %.0f%% Δe %.0fm", o.MaxGrade*100, o.DeltaE),
+ })
+ }
+ return zones
+}
+
+// LinearFix interpolates elevations linearly between each zone's anchors by distance.
+// It mutates elevations in place and returns the number of points modified.
+func LinearFix(distances, elevations []float64, zones []FixZone) int {
+ modified := 0
+ for _, z := range zones {
+ if z.StartIdx < 0 || z.EndIdx >= len(distances) || z.StartIdx >= z.EndIdx {
+ continue
+ }
+ startDist := distances[z.StartIdx]
+ endDist := distances[z.EndIdx]
+ startEle := elevations[z.StartIdx]
+ endEle := elevations[z.EndIdx]
+ span := endDist - startDist
+ if span <= 0 {
+ continue
+ }
+ for i := z.StartIdx + 1; i < z.EndIdx; i++ {
+ t := (distances[i] - startDist) / span
+ elevations[i] = startEle + t*(endEle-startEle)
+ modified++
+ }
+ }
+ return modified
+}
+
+// FixRide mutates ride elevations by linearly fixing the given zones.
+func FixRide(r *Ride, zones []FixZone) int {
+ return LinearFix(r.Distances, r.Elevations, zones)
+}
+
+// ResolveIndex converts a user-provided km or index string to a ride index.
+// Accepts "123" (index) or "4.5" / "4.5km" (distance in km).
+func ResolveIndex(distances []float64, raw string) (int, error) {
+ // Try index first if no "km" suffix and value is integer-like.
+ var kmVal float64
+ var isKm bool
+ s := raw
+ if len(s) > 2 && s[len(s)-2:] == "km" {
+ s = s[:len(s)-2]
+ isKm = true
+ }
+ // If isKm or contains dot, treat as km.
+ if isKm || containsDot(s) {
+ _, err := fmt.Sscan(s, &kmVal)
+ if err != nil {
+ return 0, fmt.Errorf("invalid distance %q", raw)
+ }
+ target := kmVal * 1000
+ // nearest index by distance
+ best := 0
+ bestDiff := math.Abs(distances[0] - target)
+ for i, d := range distances {
+ diff := math.Abs(d - target)
+ if diff < bestDiff {
+ bestDiff = diff
+ best = i
+ }
+ }
+ return best, nil
+ }
+ var idx int
+ _, err := fmt.Sscan(s, &idx)
+ if err != nil {
+ return 0, fmt.Errorf("invalid index %q", raw)
+ }
+ if idx < 0 || idx >= len(distances) {
+ return 0, fmt.Errorf("index %d out of range [0,%d)", idx, len(distances))
+ }
+ return idx, nil
+}
+
+func containsDot(s string) bool {
+ for _, c := range s {
+ if c == '.' {
+ return true
+ }
+ }
+ return false
+}
+
+// EncodeGPX writes the ride as a GPX file. Used by elevation fix to persist
+// corrected elevations while preserving coordinates and timestamps.
+func EncodeGPX(r Ride, w io.Writer) error {
+ gpxContent := &gpx.GPX{
+ Version: "1.1",
+ Creator: "biking_home",
+ }
+ track := gpx.GPXTrack{
+ Type: "Ride",
+ }
+ segment := gpx.GPXTrackSegment{
+ Points: make([]gpx.GPXPoint, r.Len()),
+ }
+ for i := 0; i < r.Len(); i++ {
+ segment.Points[i] = gpx.GPXPoint{
+ Point: gpx.Point{
+ Latitude: r.Coords[i].Lat,
+ Longitude: r.Coords[i].Lon,
+ Elevation: *gpx.NewNullableFloat64(r.Elevations[i]),
+ },
+ Timestamp: r.timestamps[i],
+ }
+ }
+ track.Segments = append(track.Segments, segment)
+ gpxContent.Tracks = append(gpxContent.Tracks, track)
+ xmlBytes, err := gpxContent.ToXml(gpx.ToXmlParams{Version: "1.1", Indent: true})
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(xmlBytes)
+ return err
+}
+
+// FixRideFiles rewrites FIT and/or GPX files for ride with linearly fixed elevations.
+// Zones are applied to the parsed ride, then both files are rewritten (if present)
+// keeping a .bak copy. It does not touch the DB; caller should Save afterwards.
+func FixRideFiles(ride Ride, zones []FixZone, gpxPath, fitPath string) error {
+ // Apply fix to a copy so caller can inspect.
+ fixed := ride
+ // Copy elevations slice to avoid mutating original ride's backing array aliasing?
+ elevCopy := make([]float64, len(ride.Elevations))
+ copy(elevCopy, ride.Elevations)
+ fixed.Elevations = elevCopy
+ LinearFix(fixed.Distances, fixed.Elevations, zones)
+
+ if gpxPath != "" {
+ if err := backupAndWrite(gpxPath, func(w io.Writer) error { return EncodeGPX(fixed, w) }); err != nil {
+ return fmt.Errorf("write gpx %q: %w", gpxPath, err)
+ }
+ }
+ if fitPath != "" {
+ if err := backupAndWrite(fitPath, func(w io.Writer) error { return EncodeFIT(fixed, w) }); err != nil {
+ return fmt.Errorf("write fit %q: %w", fitPath, err)
+ }
+ }
+ // If only one of the two exists, create the missing counterpart from fixed ride
+ // to keep both in sync (optional, but helpful).
+ if gpxPath != "" && fitPath == "" {
+ // try to derive fit path
+ derived := deriveFitPath(gpxPath)
+ if err := writeNew(derived, func(w io.Writer) error { return EncodeFIT(fixed, w) }); err != nil {
+ return err
+ }
+ }
+ if fitPath != "" && gpxPath == "" {
+ derived := deriveGpxPath(fitPath)
+ if err := writeNew(derived, func(w io.Writer) error { return EncodeGPX(fixed, w) }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func backupAndWrite(path string, write func(io.Writer) error) error {
+ if _, err := os.Stat(path); err == nil {
+ bak := path + ".bak"
+ // Keep first backup only.
+ if _, err := os.Stat(bak); os.IsNotExist(err) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(bak, data, 0o600); err != nil {
+ return err
+ }
+ }
+ }
+ var buf bytes.Buffer
+ if err := write(&buf); err != nil {
+ return err
+ }
+ return os.WriteFile(path, buf.Bytes(), 0o600)
+}
+
+func writeNew(path string, write func(io.Writer) error) error {
+ if _, err := os.Stat(path); err == nil {
+ return nil
+ }
+ var buf bytes.Buffer
+ if err := write(&buf); err != nil {
+ return err
+ }
+ return os.WriteFile(path, buf.Bytes(), 0o600)
+}
+
+func deriveFitPath(gpxPath string) string {
+ if len(gpxPath) > 4 && gpxPath[len(gpxPath)-4:] == ".gpx" {
+ return gpxPath[:len(gpxPath)-4] + ".fit"
+ }
+ return gpxPath + ".fit"
+}
+
+func deriveGpxPath(fitPath string) string {
+ if len(fitPath) > 4 && fitPath[len(fitPath)-4:] == ".fit" {
+ return fitPath[:len(fitPath)-4] + ".gpx"
+ }
+ return fitPath + ".gpx"
+}
diff --git a/ride/elevation_outlier_test.go b/ride/elevation_outlier_test.go
new file mode 100644
index 0000000..870be08
--- /dev/null
+++ b/ride/elevation_outlier_test.go
@@ -0,0 +1,58 @@
+package ride_test
+
+import (
+ "testing"
+
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDetectElevationOutliers_TunnelSpike(t *testing.T) {
+ // Simulate ride 754 tunnel: steady then spike 46m over 7m (grade 657%), then normal.
+ distances := []float64{0, 10, 20, 27, 34} // 7m gap at idx 3
+ elevations := []float64{100, 101, 102, 148, 149}
+ outliers := ride.DetectElevationOutliers(distances, elevations, 0.5)
+ require.Len(t, outliers, 1)
+ assert.Equal(t, 2, outliers[0].StartIdx) // expanded by 1
+ assert.Equal(t, 4, outliers[0].EndIdx)
+ assert.Greater(t, outliers[0].MaxGrade, 5.0)
+}
+
+func TestDetectElevationOutliers_IgnoresSmallJitter(t *testing.T) {
+ distances := []float64{0, 0.5, 1.0, 1.5}
+ elevations := []float64{100, 100.3, 100.6, 100.9} // 0.3m over 0.5m =60% but abs de <1.5
+ outliers := ride.DetectElevationOutliers(distances, elevations, 0.5)
+ assert.Empty(t, outliers)
+}
+
+func TestDetectElevationOutliers_IgnoresGentleSlope(t *testing.T) {
+ distances := []float64{0, 10, 20, 30}
+ elevations := []float64{100, 102, 104, 106} // 20% grade
+ outliers := ride.DetectElevationOutliers(distances, elevations, 0.5)
+ assert.Empty(t, outliers)
+}
+
+func TestLinearFix_Interpolates(t *testing.T) {
+ distances := []float64{0, 10, 20, 30, 40}
+ elevations := []float64{100, 110, 999, 130, 140}
+ zones := []ride.FixZone{{StartIdx: 1, EndIdx: 3, StartKm: 0.01, EndKm: 0.03}}
+ n := ride.LinearFix(distances, elevations, zones)
+ assert.Equal(t, 1, n)
+ assert.InDelta(t, 120, elevations[2], 0.01)
+}
+
+func TestResolveIndex_ByKm(t *testing.T) {
+ distances := []float64{0, 1000, 2000, 3000}
+ idx, err := ride.ResolveIndex(distances, "1.5km")
+ require.NoError(t, err)
+ // nearest to 1500 is idx 1 or 2 (both 500 away, picks first)
+ assert.Contains(t, []int{1, 2}, idx)
+}
+
+func TestResolveIndex_ByIndex(t *testing.T) {
+ distances := []float64{0, 1000, 2000}
+ idx, err := ride.ResolveIndex(distances, "2")
+ require.NoError(t, err)
+ assert.Equal(t, 2, idx)
+}
diff --git a/web/frontend/ride-detail-boundaries.ts b/web/frontend/ride-detail-boundaries.ts
index 22a1255..a9bb8e1 100644
--- a/web/frontend/ride-detail-boundaries.ts
+++ b/web/frontend/ride-detail-boundaries.ts
@@ -118,6 +118,7 @@ export class RideBoundaryController {
boundaryLabel(index: number): string {
const point = this.points[index];
- return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ const idx = point.index ?? index;
+ return `#${idx} · ${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
}
}
diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts
index 045372e..b8d2898 100644
--- a/web/frontend/ride-detail.ts
+++ b/web/frontend/ride-detail.ts
@@ -93,7 +93,7 @@ export const mountRideDetail = (): void => {
}
const point = points[index];
mapController.showPoint(index);
- hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ hoverOutput.textContent = `#${point.index ?? index} · ${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
},
});
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 790abe6..87be024 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -8,6 +8,7 @@ export interface ProfilePoint {
}
export interface RideProfilePoint extends ProfilePoint {
+ index: number;
latitude: number;
longitude: number;
}
diff --git a/web/views.go b/web/views.go
index ddea27e..952b045 100644
--- a/web/views.go
+++ b/web/views.go
@@ -113,6 +113,7 @@ func (profile RideProfile) UnmatchedClimbs() []RideProfileClimb {
}
type RideProfilePoint struct {
+ Index int `json:"index"`
DistanceKm float64 `json:"distanceKm"`
ElevationM float64 `json:"elevationM"`
Latitude float64 `json:"latitude"`
@@ -353,6 +354,7 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
}
for i, coordinate := range coords {
profile.Points[i] = RideProfilePoint{
+ Index: i,
DistanceKm: distances[i] / 1000,
ElevationM: elevations[i],
Latitude: coordinate.Lat,