diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | cli/cli.go | 328 | ||||
| -rw-r--r-- | ride/elevation_outlier.go | 336 | ||||
| -rw-r--r-- | ride/elevation_outlier_test.go | 58 | ||||
| -rw-r--r-- | web/frontend/ride-detail-boundaries.ts | 3 | ||||
| -rw-r--r-- | web/frontend/ride-detail.ts | 2 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 1 | ||||
| -rw-r--r-- | web/views.go | 2 |
9 files changed, 731 insertions, 2 deletions
@@ -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 @@ -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 @@ -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, |
