diff options
Diffstat (limited to 'official_climb')
| -rw-r--r-- | official_climb/attempt.go | 41 | ||||
| -rw-r--r-- | official_climb/attempt_test.go | 36 | ||||
| -rw-r--r-- | official_climb/list_test.go | 4 | ||||
| -rw-r--r-- | official_climb/matcher.go | 42 | ||||
| -rw-r--r-- | official_climb/model.go | 10 | ||||
| -rw-r--r-- | official_climb/official_climb_test.go | 128 | ||||
| -rw-r--r-- | official_climb/repository.go | 10 |
7 files changed, 215 insertions, 56 deletions
diff --git a/official_climb/attempt.go b/official_climb/attempt.go index 43778f0..c340fea 100644 --- a/official_climb/attempt.go +++ b/official_climb/attempt.go @@ -3,14 +3,13 @@ package official_climb import ( "database/sql" "fmt" + "strings" "time" "github.com/martinlehoux/biking_home/climb" ) -// Attempt is one recorded passage of an official climb during one ride, -// identified by (rideID, climbID, occurrence). Scalar stats are snapshotted at -// recording time so climb listings never re-parse ride files. +// Attempt is one recorded passage of an official climb in a ride, keyed by (rideID, climbID, occurrence); stats are snapshotted so listings never re-parse ride files. type Attempt struct { RideID int64 ClimbID int64 @@ -27,14 +26,12 @@ type Attempt struct { const attemptColumns = "a.ride_id, a.climb_id, a.occurrence, a.distance_m, a.elevation_gain_m, a.slope_percent, a.cotacol, a.cotacol_algo_version, a.started_at, a.ended_at, a.created_at" -// AttemptsForClimb returns every recorded attempt of one official climb, -// oldest ride first. +// AttemptsForClimb returns every recorded attempt of one official climb, oldest ride first. func AttemptsForClimb(db *sql.DB, climbID int64) ([]Attempt, error) { return listAttempts(db, "climb_id = ?", climbID, "r.start_date, a.ride_id, a.occurrence") } -// AttemptsForRide returns every recorded attempt within one ride, in route -// order. +// AttemptsForRide returns every recorded attempt within one ride, in route order. func AttemptsForRide(db *sql.DB, rideID int64) ([]Attempt, error) { return listAttempts(db, "ride_id = ?", rideID, "a.occurrence") } @@ -64,29 +61,45 @@ func listAttempts(db *sql.DB, where string, arg any, order string) ([]Attempt, e return attempts, nil } -// ReplaceRideAttempts atomically rewrites every attempt row of one ride, -// dropping stale matches and inserting the freshly analyzed ones. +// ReplaceRideAttempts refreshes a ride's attempt rows: fresh matches are written, stale ones dropped, the recorded-at stamp of survivors kept. func ReplaceRideAttempts(db *sql.DB, rideID int64, attempts []Attempt) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("replace ride attempts: %w", err) } defer func() { _ = tx.Rollback() }() - if _, err := tx.Exec("DELETE FROM ride_official_climb_attempts WHERE ride_id = ?", rideID); err != nil { - return fmt.Errorf("delete ride attempts: %w", err) - } + keys := make([]string, 0, len(attempts)) + args := make([]any, 0, 2*len(attempts)+1) + args = append(args, rideID) for _, attempt := range attempts { if attempt.RideID != rideID { return fmt.Errorf("replace ride attempts: attempt references ride %d, expected %d", attempt.RideID, rideID) } + keys = append(keys, "(?, ?)") + args = append(args, attempt.ClimbID, attempt.Occurrence) if _, err := tx.Exec(` INSERT INTO ride_official_climb_attempts (ride_id, climb_id, occurrence, distance_m, elevation_gain_m, slope_percent, cotacol, cotacol_algo_version, started_at, ended_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ride_id, climb_id, occurrence) DO UPDATE SET + distance_m = excluded.distance_m, + elevation_gain_m = excluded.elevation_gain_m, + slope_percent = excluded.slope_percent, + cotacol = excluded.cotacol, + cotacol_algo_version = excluded.cotacol_algo_version, + started_at = excluded.started_at, + ended_at = excluded.ended_at `, attempt.RideID, attempt.ClimbID, attempt.Occurrence, attempt.DistanceM, attempt.ElevationGainM, attempt.SlopePercent, attempt.Cotacol, attempt.CotacolAlgoVersion, attempt.StartedAt.UTC().Format(time.RFC3339), attempt.EndedAt.UTC().Format(time.RFC3339)); err != nil { return fmt.Errorf("insert ride attempt: %w", err) } } + prune := "DELETE FROM ride_official_climb_attempts WHERE ride_id = ?" + if len(keys) > 0 { + prune += " AND (climb_id, occurrence) NOT IN (VALUES " + strings.Join(keys, ", ") + ")" + } + if _, err := tx.Exec(prune, args...); err != nil { + return fmt.Errorf("drop stale ride attempts: %w", err) + } if err := tx.Commit(); err != nil { return fmt.Errorf("commit ride attempts: %w", err) } @@ -132,9 +145,7 @@ type AttemptStats struct { EndIndex int } -// AttemptFromStats builds the persisted snapshot of one analyzed climb -// occurrence. Timestamps come from the ride's recorded points bounding the -// climb interval. +// AttemptFromStats snapshots one analyzed climb occurrence; timestamps come from the ride points bounding the climb interval. func AttemptFromStats(rideID int64, occurrence int, stats AttemptStats, timestamps []time.Time) Attempt { startedAt, endedAt := time.Time{}, time.Time{} if stats.StartIndex < len(timestamps) { diff --git a/official_climb/attempt_test.go b/official_climb/attempt_test.go index 147c80b..8913807 100644 --- a/official_climb/attempt_test.go +++ b/official_climb/attempt_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/biking_home/official_climb" "github.com/stretchr/testify/assert" @@ -16,6 +17,11 @@ func coord(lat, lon float64) geodist.Coord { return geodist.Coord{Lat: lat, Lon: lon} } +// sampleFixture is a plausible sample for tests that do not exercise matching. +func sampleFixture() climb.SegmentSample { + return climb.SegmentSample{DistanceM: 3000, ElevationGainM: 200} +} + func insertRide(t *testing.T, db *sql.DB, opts ...func(*rideInsert)) int64 { t.Helper() insert := rideInsert{startDate: "2026-09-01T08:00:00Z"} @@ -57,7 +63,7 @@ func attemptFixture(rideID, climbID int64, occurrence int) official_climb.Attemp func TestReplaceRideAttemptsRoundTrip(t *testing.T) { db := dbtest.New(t) - created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) @@ -80,11 +86,31 @@ func TestReplaceRideAttemptsRoundTrip(t *testing.T) { require.Len(t, rideAttempts, 2) } +func TestReplaceRideAttemptsKeepsRecordedAt(t *testing.T) { + db := dbtest.New(t) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) + require.NoError(t, err) + rideID := insertRide(t, db) + attempts := []official_climb.Attempt{attemptFixture(rideID, created.ID, 0)} + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, attempts)) + + recorded := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + _, err = db.Exec("UPDATE ride_official_climb_attempts SET created_at = ? WHERE ride_id = ?", recorded.Format(time.RFC3339), rideID) + require.NoError(t, err) + + // Re-analyzing the same ride must not restamp the attempt it already has. + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, attempts)) + stored, err := official_climb.AttemptsForRide(db, rideID) + require.NoError(t, err) + require.Len(t, stored, 1) + assert.Equal(t, recorded, stored[0].CreatedAt.UTC()) +} + func TestReplaceRideAttemptsDropsStaleRows(t *testing.T) { db := dbtest.New(t) - first, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col A", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + first, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col A", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) - second, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col B", StartCoord: coord(43.7, 5.5), EndCoord: coord(43.72, 5.52)}) + second, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col B", StartCoord: coord(43.7, 5.5), EndCoord: coord(43.72, 5.52), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) @@ -101,7 +127,7 @@ func TestReplaceRideAttemptsDropsStaleRows(t *testing.T) { func TestReplaceRideAttemptsRejectsForeignRide(t *testing.T) { db := dbtest.New(t) - created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) @@ -112,7 +138,7 @@ func TestReplaceRideAttemptsRejectsForeignRide(t *testing.T) { func TestAttemptsForClimbOrdersByRideDate(t *testing.T) { db := dbtest.New(t) - created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) newer := insertRide(t, db, withStartDate("2026-09-10T08:00:00Z")) older := insertRide(t, db, withStartDate("2026-09-02T08:00:00Z")) diff --git a/official_climb/list_test.go b/official_climb/list_test.go index 6a65f80..4701416 100644 --- a/official_climb/list_test.go +++ b/official_climb/list_test.go @@ -11,9 +11,9 @@ import ( func TestListWithAggregatesMediansAndUnridden(t *testing.T) { db := dbtest.New(t) - unridden, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fantome", StartCoord: coord(43.9, 5.9), EndCoord: coord(43.92, 5.92)}) + unridden, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fantome", StartCoord: coord(43.9, 5.9), EndCoord: coord(43.92, 5.92), Sample: sampleFixture()}) require.NoError(t, err) - climbed, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fréquente", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + climbed, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fréquente", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) diff --git a/official_climb/matcher.go b/official_climb/matcher.go index 00f7e9b..657d2ef 100644 --- a/official_climb/matcher.go +++ b/official_climb/matcher.go @@ -4,22 +4,22 @@ import ( "math" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/geo" ) -// MatchedClimb is an official climb that lies on a route, together with the -// route point indexes of its start and end. It lets the climb detector know -// which segment of the route is already claimed by an official climb. +// MatchedClimb is an official climb lying on a route, with the route indexes of its start and end: the segment the climb detector must not re-report. type MatchedClimb struct { OfficialClimb StartIndex int EndIndex int } -// MatchClimb matches an official climb against a route purely by -// coordinates: both the start and end coordinates must be within radiusM of a -// route point, and the matched start point must precede the matched end point. -func MatchClimb(route []geodist.Coord, official OfficialClimb, radiusM float64) (MatchedClimb, bool) { +// SegmentSampleBandRatio is the half-width of the band a candidate passage must land in, relative to the climb's sample. +const SegmentSampleBandRatio = 0.5 + +// MatchClimb matches an official climb against a route: endpoints within radiusM, start before end, and the passage inside the climb's sample band. +func MatchClimb(route climb.Route, official OfficialClimb, radiusM float64) (MatchedClimb, bool) { matches := MatchClimbs(route, official, radiusM) if len(matches) == 0 { return MatchedClimb{}, false @@ -27,26 +27,22 @@ func MatchClimb(route []geodist.Coord, official OfficialClimb, radiusM float64) return matches[0], true } -// MatchClimbs returns all non-overlapping occurrences of an official climb on -// a route. Each occurrence is defined by a start and end point both within -// radiusM, with start strictly before end and direction preserved. Loops are -// handled by clustering contiguous approaches into single candidates and pairing -// each start cluster with the first end cluster inside its lap window. -func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) []MatchedClimb { +// MatchClimbs returns every occurrence of an official climb on a route: each start cluster pairs with the first end cluster in its lap window, and pairings that contradict the recorded sample are dropped — what a loop ridden the other way produces. +func MatchClimbs(route climb.Route, official OfficialClimb, radiusM float64) []MatchedClimb { if math.IsNaN(radiusM) || math.IsInf(radiusM, 0) || radiusM <= 0 { return nil } - if len(route) == 0 { + if len(route.Coords) == 0 { return nil } - startClusters := clustersInRadius(route, official.StartCoord, radiusM) - endClusters := clustersInRadius(route, official.EndCoord, radiusM) + startClusters := clustersInRadius(route.Coords, official.StartCoord, radiusM) + endClusters := clustersInRadius(route.Coords, official.EndCoord, radiusM) if len(startClusters) == 0 || len(endClusters) == 0 { return nil } matches := make([]MatchedClimb, 0) for i, start := range startClusters { - nextStartIdx := len(route) + nextStartIdx := len(route.Coords) if i+1 < len(startClusters) { nextStartIdx = startClusters[i+1].Index } @@ -61,6 +57,9 @@ func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) if matchedEnd == nil { continue } + if !withinSampleBand(official.Sample, route.Sample(start.Index, matchedEnd.Index)) { + continue + } matches = append(matches, MatchedClimb{ OfficialClimb: official, StartIndex: start.Index, @@ -70,6 +69,15 @@ func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) return matches } +// withinSampleBand reports whether a candidate passage matches both axes of the climb's sample. +func withinSampleBand(sample, candidate climb.SegmentSample) bool { + return inSampleBand(candidate.DistanceM, sample.DistanceM) && inSampleBand(candidate.ElevationGainM, sample.ElevationGainM) +} + +func inSampleBand(value, reference float64) bool { + return value >= reference*(1-SegmentSampleBandRatio) && value <= reference*(1+SegmentSampleBandRatio) +} + type clusterCandidate struct { Index int Distance float64 diff --git a/official_climb/model.go b/official_climb/model.go index ae66f46..beecd02 100644 --- a/official_climb/model.go +++ b/official_climb/model.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" ) const DefaultMatchRadiusM = 100.0 @@ -16,8 +17,10 @@ type OfficialClimb struct { Name string StartCoord geodist.Coord EndCoord geodist.Coord - CreatedAt time.Time - UpdatedAt time.Time + // Sample is the passage the climb was created from; matching rejects candidates outside its band. + Sample climb.SegmentSample + CreatedAt time.Time + UpdatedAt time.Time } type MatchPolicy struct { @@ -42,6 +45,9 @@ func (climb OfficialClimb) validate() error { if !validCoord(climb.StartCoord) || !validCoord(climb.EndCoord) { return fmt.Errorf("official climb coordinates are invalid") } + if climb.Sample.DistanceM <= 0 || climb.Sample.ElevationGainM <= 0 { + return fmt.Errorf("official climb sample must record a positive distance and elevation gain") + } return nil } diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go index 6ddce5a..7fb2a32 100644 --- a/official_climb/official_climb_test.go +++ b/official_climb/official_climb_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/biking_home/internal/ridetest" "github.com/martinlehoux/biking_home/official_climb" @@ -19,6 +20,7 @@ func TestCreateListAndGetOfficialClimb(t *testing.T) { Name: "Col de Test", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, + Sample: climb.SegmentSample{DistanceM: 1200, ElevationGainM: 90}, }) require.NoError(t, err) assert.Positive(t, created.ID) @@ -44,8 +46,16 @@ func TestCreateRejectsInvalidOfficialClimb(t *testing.T) { Name: " ", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, + Sample: climb.SegmentSample{DistanceM: 1200, ElevationGainM: 90}, }) assert.EqualError(t, err, "official climb name is required") + + _, err = official_climb.Create(db, official_climb.OfficialClimb{ + Name: "Col Sans Sample", + StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, + EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, + }) + assert.EqualError(t, err, "official climb sample must record a positive distance and elevation gain") } func TestMatchPolicyRejectsInvalidRadius(t *testing.T) { @@ -58,8 +68,9 @@ func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) { Name: "On route", StartCoord: parsed.Coords[1], EndCoord: parsed.Coords[2], + Sample: parsed.Sample(1, 2), } - matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -71,8 +82,9 @@ func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) { Name: "Offset from route", StartCoord: geodist.Coord{Lat: 43.1101, Lon: 5.1101}, EndCoord: geodist.Coord{Lat: 43.1201, Lon: 5.1201}, + Sample: parsed.Sample(1, 2), } - matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -84,8 +96,9 @@ func TestMatchClimbRejectsReversedEndpoints(t *testing.T) { Name: "Reversed", StartCoord: parsed.Coords[2], EndCoord: parsed.Coords[1], + Sample: parsed.Sample(1, 2), } - _, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + _, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) assert.False(t, found) } @@ -95,8 +108,9 @@ func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) { Name: "Too far", StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09}, EndCoord: parsed.Coords[2], + Sample: parsed.Sample(1, 2), } - _, found := official_climb.MatchClimb(parsed.Coords, official, 10) + _, found := official_climb.MatchClimb(parsed.Route, official, 10) assert.False(t, found) } @@ -121,15 +135,16 @@ func TestMatchClimbsMatchesTwoLaps(t *testing.T) { Name: "Loop", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(0, 2), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 2, matches[0].EndIndex) assert.Equal(t, 4, matches[1].StartIndex) assert.Equal(t, 6, matches[1].EndIndex) // Single-match helper still returns first occurrence for backward compatibility. - single, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + single, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 0, single.StartIndex) } @@ -153,8 +168,9 @@ func TestMatchClimbsMatchesOnlyCompleteLaps(t *testing.T) { Name: "Partial", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(3, 5), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 1) assert.Equal(t, 3, matches[0].StartIndex) assert.Equal(t, 5, matches[0].EndIndex) @@ -176,8 +192,9 @@ func TestMatchClimbsPreservesDirectionAcrossLaps(t *testing.T) { Name: "Directional", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(0, 1), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) assert.Empty(t, matches) } @@ -193,7 +210,7 @@ func TestMatchClimbsClustersContiguousPoints(t *testing.T) { {Lat: 43.12, Lon: 5.12}, } parsed := ride.FromColumns( - []float64{0, 100, 200, 1000, 2000, 3000, 3100, 4000}, + []float64{0, 100, 200, 1000, 2000, 3000, 3100, 5000}, []float64{100, 101, 102, 150, 200, 100, 101, 200}, coords, []time.Time{time.Unix(0, 0), time.Unix(10, 0), time.Unix(20, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(190, 0), time.Unix(240, 0)}, @@ -202,11 +219,102 @@ func TestMatchClimbsClustersContiguousPoints(t *testing.T) { Name: "Clustered", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(0, 4), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 4, matches[0].EndIndex) assert.Equal(t, 5, matches[1].StartIndex) assert.Equal(t, 7, matches[1].EndIndex) } + +func TestCreateOfficialClimbStoresSample(t *testing.T) { + db := dbtest.New(t) + created, err := official_climb.Create(db, official_climb.OfficialClimb{ + Name: "Col Sampled", + StartCoord: coord(43.6, 5.4), + EndCoord: coord(43.62, 5.42), + Sample: climb.SegmentSample{DistanceM: 1200, ElevationGainM: 90}, + }) + require.NoError(t, err) + require.NotNil(t, created.Sample) + assert.Equal(t, 1200.0, created.Sample.DistanceM) + assert.Equal(t, 90.0, created.Sample.ElevationGainM) + + got, found, err := official_climb.GetByID(db, created.ID) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, got.Sample) + assert.Equal(t, 1200.0, got.Sample.DistanceM) + assert.Equal(t, 90.0, got.Sample.ElevationGainM) +} + +func TestCreateRejectsSampleWithoutGain(t *testing.T) { + db := dbtest.New(t) + _, err := official_climb.Create(db, official_climb.OfficialClimb{ + Name: "Col Plat", + StartCoord: coord(43.6, 5.4), + EndCoord: coord(43.62, 5.42), + Sample: climb.SegmentSample{DistanceM: 1200}, + }) + assert.Error(t, err) +} + +func TestMatchClimbsAcceptsSegmentInsideSampleBand(t *testing.T) { + parsed := ride.FromColumns( + []float64{0, 850}, + []float64{100, 170}, + []geodist.Coord{{Lat: 43.1000, Lon: 5.1000}, {Lat: 43.1100, Lon: 5.1100}}, + []time.Time{time.Unix(0, 0), time.Unix(120, 0)}, + ) + official := official_climb.OfficialClimb{ + Name: "Sampled climb", + StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, + EndCoord: geodist.Coord{Lat: 43.11, Lon: 5.11}, + Sample: climb.SegmentSample{DistanceM: 850, ElevationGainM: 70}, + } + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) + require.Len(t, matches, 1) + assert.Equal(t, 0, matches[0].StartIndex) + assert.Equal(t, 1, matches[0].EndIndex) +} + +func TestMatchClimbsRejectsSegmentOutsideSampleGainBand(t *testing.T) { + parsed := ride.FromColumns( + []float64{0, 850}, + []float64{100, 105}, + []geodist.Coord{{Lat: 43.1000, Lon: 5.1000}, {Lat: 43.1100, Lon: 5.1100}}, + []time.Time{time.Unix(0, 0), time.Unix(120, 0)}, + ) + official := official_climb.OfficialClimb{ + Name: "Sampled climb", + StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, + EndCoord: geodist.Coord{Lat: 43.11, Lon: 5.11}, + Sample: climb.SegmentSample{DistanceM: 850, ElevationGainM: 70}, + } + assert.Empty(t, official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM)) +} + +func TestMatchClimbsRejectsSegmentOutsideSampleBand(t *testing.T) { + // Reverse loop: only a cross-lap passage pairs the endpoints, and the sample band rejects it. + parsed := ride.FromColumns( + []float64{0, 850, 6800, 7650, 13600}, + []float64{170, 100, 235, 100, 235}, + []geodist.Coord{ + {Lat: 43.1100, Lon: 5.1100}, + {Lat: 43.1000, Lon: 5.1000}, + {Lat: 43.1100, Lon: 5.1100}, + {Lat: 43.1000, Lon: 5.1000}, + {Lat: 43.1100, Lon: 5.1100}, + }, + []time.Time{time.Unix(0, 0), time.Unix(120, 0), time.Unix(600, 0), time.Unix(720, 0), time.Unix(1200, 0)}, + ) + official := official_climb.OfficialClimb{ + Name: "Reverse loop", + StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, + EndCoord: geodist.Coord{Lat: 43.11, Lon: 5.11}, + Sample: climb.SegmentSample{DistanceM: 850, ElevationGainM: 70}, + } + assert.Empty(t, official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM)) +} diff --git a/official_climb/repository.go b/official_climb/repository.go index 1feb6a9..33c2798 100644 --- a/official_climb/repository.go +++ b/official_climb/repository.go @@ -8,16 +8,16 @@ import ( "github.com/jftuga/geodist" ) -const columns = "id, name, start_latitude, start_longitude, end_latitude, end_longitude, created_at, updated_at" +const columns = "id, name, start_latitude, start_longitude, end_latitude, end_longitude, sample_distance_m, sample_elevation_gain_m, created_at, updated_at" func Create(db *sql.DB, climb OfficialClimb) (OfficialClimb, error) { if err := climb.validate(); err != nil { return OfficialClimb{}, err } result, err := db.Exec(` - INSERT INTO official_climbs (name, start_latitude, start_longitude, end_latitude, end_longitude) - VALUES (?, ?, ?, ?, ?) - `, climb.Name, climb.StartCoord.Lat, climb.StartCoord.Lon, climb.EndCoord.Lat, climb.EndCoord.Lon) + INSERT INTO official_climbs (name, start_latitude, start_longitude, end_latitude, end_longitude, sample_distance_m, sample_elevation_gain_m) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, climb.Name, climb.StartCoord.Lat, climb.StartCoord.Lon, climb.EndCoord.Lat, climb.EndCoord.Lon, climb.Sample.DistanceM, climb.Sample.ElevationGainM) if err != nil { return OfficialClimb{}, fmt.Errorf("create official climb: %w", err) } @@ -79,7 +79,7 @@ func scan(s scanner) (OfficialClimb, error) { endLatitude, endLongitude float64 createdAt, updatedAt string ) - if err := s.Scan(&climb.ID, &climb.Name, &startLatitude, &startLongitude, &endLatitude, &endLongitude, &createdAt, &updatedAt); err != nil { + if err := s.Scan(&climb.ID, &climb.Name, &startLatitude, &startLongitude, &endLatitude, &endLongitude, &climb.Sample.DistanceM, &climb.Sample.ElevationGainM, &createdAt, &updatedAt); err != nil { return OfficialClimb{}, err } climb.StartCoord = geodist.Coord{Lat: startLatitude, Lon: startLongitude} |
