diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-24 08:37:33 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-24 08:37:33 +0200 |
| commit | 90614736f2a0c556f31eca5361b3c032f5f309f6 (patch) | |
| tree | 81fda954dc2bff8ae8b0023b502343c455995272 /official_climb/official_climb_test.go | |
| parent | e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (diff) | |
Match official climbs against their recorded sample
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.
Diffstat (limited to 'official_climb/official_climb_test.go')
| -rw-r--r-- | official_climb/official_climb_test.go | 128 |
1 files changed, 118 insertions, 10 deletions
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)) +} |
