summaryrefslogtreecommitdiff
path: root/mountain_pass
diff options
context:
space:
mode:
Diffstat (limited to 'mountain_pass')
-rw-r--r--mountain_pass/detection.go115
-rw-r--r--mountain_pass/detection_test.go109
-rw-r--r--mountain_pass/downloader.go61
-rw-r--r--mountain_pass/downloader_test.go6
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)