From 90614736f2a0c556f31eca5361b3c032f5f309f6 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 24 Sep 2026 08:37:33 +0200 Subject: Match official climbs against their recorded sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An official climb matched on endpoint coordinates alone. On a loop ridden the other way the route meets the climb's top before its bottom, so pairing each start with the first end inside its lap window credited a 5.9 km passage as the 850 m Chemin de la Tour Pey Blanc, and a 49 km one on another ride. Climbs now keep the sample of the segment they were created from: the recorded length and elevation gain of the selected route points, captured by the ride detail and preview handlers and stored NOT NULL. Matching takes the whole route and drops any candidate outside ±50% of that sample on either axis, and attempts are refreshed by upsert and prune so re-analyzing a ride keeps the recorded-at stamp of the passages that survive. The backfill leaves the web package: backfill.Run recomputes ride values then official climb attempts as a single `go run . backfill` command (climbs-backfill is gone), rideanalysis owns the attempt snapshot, and the mountain-pass loading margin is shared. Applied to the stored rides it stamps 78 samples, drops the four bogus attempts and leaves ride 800 with its three Pont Rout passages. --- official_climb/attempt.go | 41 +++++++---- official_climb/attempt_test.go | 36 ++++++++-- official_climb/list_test.go | 4 +- official_climb/matcher.go | 42 ++++++----- official_climb/model.go | 10 ++- official_climb/official_climb_test.go | 128 +++++++++++++++++++++++++++++++--- official_climb/repository.go | 10 +-- 7 files changed, 215 insertions(+), 56 deletions(-) (limited to 'official_climb') 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} -- cgit v1.3