aboutsummaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
Diffstat (limited to 'ride')
-rw-r--r--ride/elevation_outlier.go336
-rw-r--r--ride/elevation_outlier_test.go58
2 files changed, 394 insertions, 0 deletions
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)
+}