aboutsummaryrefslogtreecommitdiff
path: root/official_climb
diff options
context:
space:
mode:
Diffstat (limited to 'official_climb')
-rw-r--r--official_climb/attempt.go41
-rw-r--r--official_climb/attempt_test.go36
-rw-r--r--official_climb/list_test.go4
-rw-r--r--official_climb/matcher.go42
-rw-r--r--official_climb/model.go10
-rw-r--r--official_climb/official_climb_test.go128
-rw-r--r--official_climb/repository.go10
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}