diff options
Diffstat (limited to 'mountain_pass')
| -rw-r--r-- | mountain_pass/detection.go | 115 | ||||
| -rw-r--r-- | mountain_pass/detection_test.go | 109 | ||||
| -rw-r--r-- | mountain_pass/downloader.go | 61 | ||||
| -rw-r--r-- | mountain_pass/downloader_test.go | 6 |
4 files changed, 269 insertions, 22 deletions
diff --git a/mountain_pass/detection.go b/mountain_pass/detection.go new file mode 100644 index 0000000..e30fddc --- /dev/null +++ b/mountain_pass/detection.go @@ -0,0 +1,115 @@ +package mountain_pass + +import ( + "database/sql" + "fmt" + + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/ride" +) + +type Crossing struct { + Pass MountainPass + DistanceToM float64 + RideDistanceM float64 + RideElevation float64 + ElevationDiff float64 +} + +func LoadMountainPasses(db *sql.DB) ([]MountainPass, error) { + rows, err := db.Query(` + SELECT external_id, name, country_code, department_code, elevation, latitude, longitude + FROM mountain_passes + ORDER BY elevation + `) + if err != nil { + return nil, err + } + defer rows.Close() + + mountainPasses := make([]MountainPass, 0) + for rows.Next() { + var mountainPass MountainPass + var latitude, longitude sql.NullFloat64 + if err := rows.Scan(&mountainPass.ExternalID, &mountainPass.Name, &mountainPass.CountryCode, &mountainPass.DepartmentCode, &mountainPass.Elevation, &latitude, &longitude); err != nil { + return nil, err + } + if latitude.Valid && longitude.Valid { + mountainPass.Coord = &geodist.Coord{Lat: latitude.Float64, Lon: longitude.Float64} + } + mountainPasses = append(mountainPasses, mountainPass) + } + return mountainPasses, rows.Err() +} + +func DetectCrossings(ride ride.Ride, passes []MountainPass, radiusM, elevationToleranceM float64) []Crossing { + crossings := make([]Crossing, 0) + for _, mountainPass := range passes { + if mountainPass.Coord == nil { + continue + } + crossing, found := nearestCrossing(ride, mountainPass) + if found && crossing.DistanceToM <= radiusM && crossing.ElevationDiff <= elevationToleranceM { + crossings = append(crossings, crossing) + } + } + return crossings +} + +// MatchClimb returns the pass whose coordinates lie within radiusM of the +// climb's highest point and whose elevation is within elevationToleranceM of +// that point's elevation, nearest first. Found is false when no pass matches. +func MatchClimb(climb ride.Climb, passes []MountainPass, radiusM, elevationToleranceM float64) (MountainPass, bool) { + top := climb.Top() + var best MountainPass + bestDistanceM := radiusM + found := false + for _, mountainPass := range passes { + if mountainPass.Coord == nil { + continue + } + distanceKm, _ := geodist.HaversineDistance(top.Coord, *mountainPass.Coord) + distanceM := distanceKm * 1000 + if distanceM > bestDistanceM { + continue + } + elevationDiff := absFloat64(top.ElevationM - float64(mountainPass.Elevation)) + if elevationDiff > elevationToleranceM { + continue + } + best = mountainPass + bestDistanceM = distanceM + found = true + } + return best, found +} + +func nearestCrossing(ride ride.Ride, mountainPass MountainPass) (Crossing, bool) { + best := Crossing{Pass: mountainPass, DistanceToM: 1e18} + for _, point := range ride.Points() { + distanceKm, _ := geodist.HaversineDistance(point.Coord, *mountainPass.Coord) + distanceM := distanceKm * 1000 + if distanceM < best.DistanceToM { + best.DistanceToM = distanceM + best.RideDistanceM = point.DistanceM + best.RideElevation = point.ElevationM + best.ElevationDiff = absFloat64(point.ElevationM - float64(mountainPass.Elevation)) + } + } + if best.DistanceToM > 1e17 { + return best, false + } + return best, true +} + +func absFloat64(value float64) float64 { + if value < 0 { + return -value + } + return value +} + +func (crossing Crossing) String() string { + return fmt.Sprintf("%s (%dm) at %.1fkm, %.0fm away, Δelev %.0fm", + crossing.Pass.Name, crossing.Pass.Elevation, crossing.RideDistanceM/1000, crossing.DistanceToM, crossing.ElevationDiff) +} diff --git a/mountain_pass/detection_test.go b/mountain_pass/detection_test.go new file mode 100644 index 0000000..3208061 --- /dev/null +++ b/mountain_pass/detection_test.go @@ -0,0 +1,109 @@ +package mountain_pass_test + +import ( + "testing" + + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/mountain_pass" + "github.com/martinlehoux/biking_home/ride" + "github.com/stretchr/testify/assert" +) + +func TestDetectCrossingsFindsPass(t *testing.T) { + r := rideFromPoint(t, 43.62, 5.43, 447) + pass := mountain_pass.MountainPass{ + Name: "Pas de Magnan", Elevation: 460, + Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, + } + crossings := mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{pass}, 100, 25) + assert.Len(t, crossings, 1) + assert.Equal(t, "Pas de Magnan", crossings[0].Pass.Name) + assert.Less(t, crossings[0].DistanceToM, 10.0) + assert.Less(t, crossings[0].ElevationDiff, 25.0) +} + +func TestDetectCrossingsIgnoresDistantPass(t *testing.T) { + r := rideFromPoint(t, 43.62, 5.43, 447) + far := mountain_pass.MountainPass{ + Name: "Col de la Faucille", Elevation: 1320, + Coord: &geodist.Coord{Lat: 46.37, Lon: 6.02}, + } + assert.Empty(t, mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{far}, 100, 25)) +} + +func TestDetectCrossingsIgnoresPassWithoutCoordinates(t *testing.T) { + r := rideFromPoint(t, 43.62, 5.43, 447) + noCoord := mountain_pass.MountainPass{Name: "Col de la Gatasse", Elevation: 120} + assert.Empty(t, mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{noCoord}, 100, 25)) +} + +func TestDetectCrossingsRequiresElevationAgreement(t *testing.T) { + r := rideFromPoint(t, 43.62, 5.43, 447) + pass := mountain_pass.MountainPass{ + Name: "Pas de Magnan", Elevation: 900, + Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, + } + assert.Empty(t, mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{pass}, 100, 25)) +} + +func rideFromPoint(t *testing.T, latitude, longitude, elevation float64) ride.Ride { + t.Helper() + points := []ride.Point{ + {DistanceM: 0, ElevationM: 100, Coord: geodist.Coord{Lat: latitude - 0.01, Lon: longitude - 0.01}}, + {DistanceM: 1000, ElevationM: elevation, Coord: geodist.Coord{Lat: latitude, Lon: longitude}}, + } + return ride.FromPoints(points) +} + +func TestMatchClimbFindsPassAtTop(t *testing.T) { + climb := climbFromPoints([]ride.Point{ + {DistanceM: 0, ElevationM: 300, Coord: geodist.Coord{Lat: 43.61, Lon: 5.42}}, + {DistanceM: 1000, ElevationM: 447, Coord: geodist.Coord{Lat: 43.62, Lon: 5.43}}, + {DistanceM: 2000, ElevationM: 380, Coord: geodist.Coord{Lat: 43.63, Lon: 5.44}}, + }) + pass := mountain_pass.MountainPass{ + Name: "Pas de Magnan", Elevation: 440, + Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, + } + matched, found := mountain_pass.MatchClimb(climb, []mountain_pass.MountainPass{pass}, 100, 25) + assert.True(t, found) + assert.Equal(t, "Pas de Magnan", matched.Name) +} + +func TestMatchClimbRequiresElevationAgreement(t *testing.T) { + climb := climbFromPoints([]ride.Point{ + {DistanceM: 0, ElevationM: 300, Coord: geodist.Coord{Lat: 43.61, Lon: 5.42}}, + {DistanceM: 1000, ElevationM: 447, Coord: geodist.Coord{Lat: 43.62, Lon: 5.43}}, + {DistanceM: 2000, ElevationM: 380, Coord: geodist.Coord{Lat: 43.63, Lon: 5.44}}, + }) + pass := mountain_pass.MountainPass{ + Name: "Pas de Magnan", Elevation: 900, + Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, + } + _, found := mountain_pass.MatchClimb(climb, []mountain_pass.MountainPass{pass}, 100, 25) + assert.False(t, found) +} + +func TestMatchClimbNearestOfTwo(t *testing.T) { + climb := climbFromPoints([]ride.Point{ + {DistanceM: 0, ElevationM: 300, Coord: geodist.Coord{Lat: 43.61, Lon: 5.42}}, + {DistanceM: 1000, ElevationM: 447, Coord: geodist.Coord{Lat: 43.62, Lon: 5.43}}, + {DistanceM: 2000, ElevationM: 380, Coord: geodist.Coord{Lat: 43.63, Lon: 5.44}}, + }) + near := mountain_pass.MountainPass{ + Name: "Pas de Magnan", Elevation: 440, + Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, + } + far := mountain_pass.MountainPass{ + Name: "Col lointain", Elevation: 440, + Coord: &geodist.Coord{Lat: 43.6201, Lon: 5.4301}, + } + matched, found := mountain_pass.MatchClimb(climb, []mountain_pass.MountainPass{near, far}, 100, 25) + assert.True(t, found) + assert.Equal(t, "Pas de Magnan", matched.Name) +} + +func climbFromPoints(points []ride.Point) ride.Climb { + ride := ride.FromPoints(points) + return ride.ClimbFromDist(0, 2000) +} diff --git a/mountain_pass/downloader.go b/mountain_pass/downloader.go index 685e0fe..00ff586 100644 --- a/mountain_pass/downloader.go +++ b/mountain_pass/downloader.go @@ -1,4 +1,4 @@ -package mountainpass +package mountain_pass import ( "bytes" @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/jftuga/geodist" "github.com/martinlehoux/kagamigo/kcore" "github.com/schollz/progressbar/v3" ) @@ -24,6 +25,7 @@ type MountainPass struct { CountryCode string DepartmentCode string Elevation int + Coord *geodist.Coord } func parseMountainPasses(reader io.Reader) ([]MountainPass, error) { @@ -146,12 +148,12 @@ func downloadDepartment(departmentCode string, resume bool) ([]MountainPass, err } mountainPasses, err := parseMountainPasses(bytes.NewReader(data)) if err != nil { - if writeErr := os.WriteFile(filename, data, 0644); writeErr != nil { + if writeErr := os.WriteFile(filename, data, 0o644); writeErr != nil { return nil, kcore.Wrap(writeErr, "Failed to write dump file") } return nil, err } - if writeErr := os.WriteFile(filename, data, 0644); writeErr != nil { + if writeErr := os.WriteFile(filename, data, 0o644); writeErr != nil { return nil, kcore.Wrap(writeErr, "Failed to write dump file") } return mountainPasses, nil @@ -159,22 +161,7 @@ func downloadDepartment(departmentCode string, resume bool) ([]MountainPass, err return nil, lastErr } -func DownloadMountainPasses(db *sql.DB, delay time.Duration, resume bool) error { - mountainPasses := make([]MountainPass, 0) - bar := progressbar.Default(90) - ticker := time.NewTicker(delay) - for i := 1; i <= 90; i++ { - <-ticker.C - bar.Add(1) - departmentCode := fmt.Sprintf("%02d", i) - bar.Describe(fmt.Sprintf("Downloading mountain passes for department_code=%s", departmentCode)) - departmentMountainPasses, err := downloadDepartment(departmentCode, resume) - if err != nil { - return kcore.Wrap(err, "Failed to download mountain passes for department "+departmentCode) - } - mountainPasses = append(mountainPasses, departmentMountainPasses...) - } - +func upsertMountainPasses(db *sql.DB, mountainPasses []MountainPass) error { tx, err := db.Begin() kcore.Expect(err, "Failed to begin transaction") defer tx.Rollback() @@ -197,3 +184,39 @@ func DownloadMountainPasses(db *sql.DB, delay time.Duration, resume bool) error return tx.Commit() } + +func DownloadMountainPasses(db *sql.DB, delay time.Duration, resume bool) error { + mountainPasses := make([]MountainPass, 0) + bar := progressbar.Default(90) + ticker := time.NewTicker(delay) + for i := 1; i <= 90; i++ { + <-ticker.C + bar.Add(1) + departmentCode := fmt.Sprintf("%02d", i) + bar.Describe(fmt.Sprintf("Downloading mountain passes for department_code=%s", departmentCode)) + departmentMountainPasses, err := downloadDepartment(departmentCode, resume) + if err != nil { + return kcore.Wrap(err, "Failed to download mountain passes for department "+departmentCode) + } + mountainPasses = append(mountainPasses, departmentMountainPasses...) + } + + return upsertMountainPasses(db, mountainPasses) +} + +func ImportCachedDepartments(db *sql.DB, departmentCodes []string) (int, error) { + mountainPasses := make([]MountainPass, 0) + for _, departmentCode := range departmentCodes { + departmentMountainPasses, found, err := loadCachedDepartment(departmentCacheFile(departmentCode)) + if err != nil { + return 0, err + } + if !found { + slog.Warn("No cached department file", "department", departmentCode) + continue + } + slog.Info("Loaded cached department", "department", departmentCode, "passes", len(departmentMountainPasses)) + mountainPasses = append(mountainPasses, departmentMountainPasses...) + } + return len(mountainPasses), upsertMountainPasses(db, mountainPasses) +} diff --git a/mountain_pass/downloader_test.go b/mountain_pass/downloader_test.go index f4ffcc8..f7f5dda 100644 --- a/mountain_pass/downloader_test.go +++ b/mountain_pass/downloader_test.go @@ -1,4 +1,4 @@ -package mountainpass +package mountain_pass import ( "os" @@ -47,7 +47,7 @@ func TestSplitCountryDepartment(t *testing.T) { func TestLoadCachedDepartment(t *testing.T) { cacheFile := filepath.Join(t.TempDir(), "debug_department_01.csv") - err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\tFR-01-1500\tCol du Colombier\t1498\n"), 0644) + err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\tFR-01-1500\tCol du Colombier\t1498\n"), 0o644) require.NoError(t, err) mountainPasses, found, err := loadCachedDepartment(cacheFile) @@ -65,7 +65,7 @@ func TestLoadCachedDepartmentMissing(t *testing.T) { func TestLoadCachedDepartmentCorrupt(t *testing.T) { cacheFile := filepath.Join(t.TempDir(), "debug_department_01.csv") - err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\n"), 0644) + err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\n"), 0o644) require.NoError(t, err) mountainPasses, found, err := loadCachedDepartment(cacheFile) |