1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
|
package official_climb
import (
"database/sql"
"fmt"
"math"
"strings"
"time"
"github.com/jftuga/geodist"
"github.com/martinlehoux/biking_home/ride"
)
const DefaultMatchRadiusM = 100.0
type OfficialClimb struct {
ID int64
Name string
StartCoord geodist.Coord
EndCoord geodist.Coord
CreatedAt time.Time
UpdatedAt time.Time
}
type MatchPolicy struct {
EndpointRadiusM float64
}
func DefaultMatchPolicy() MatchPolicy {
return MatchPolicy{EndpointRadiusM: DefaultMatchRadiusM}
}
func (policy MatchPolicy) Validate() error {
if math.IsNaN(policy.EndpointRadiusM) || math.IsInf(policy.EndpointRadiusM, 0) || policy.EndpointRadiusM <= 0 {
return fmt.Errorf("official climb endpoint radius must be greater than zero")
}
return nil
}
func (climb OfficialClimb) validate() error {
if strings.TrimSpace(climb.Name) == "" {
return fmt.Errorf("official climb name is required")
}
if !validCoord(climb.StartCoord) || !validCoord(climb.EndCoord) {
return fmt.Errorf("official climb coordinates are invalid")
}
return nil
}
func validCoord(coord geodist.Coord) bool {
return coord.Lat >= -90 && coord.Lat <= 90 && coord.Lon >= -180 && coord.Lon <= 180
}
const columns = "id, name, start_latitude, start_longitude, end_latitude, end_longitude, 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)
if err != nil {
return OfficialClimb{}, fmt.Errorf("create official climb: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return OfficialClimb{}, fmt.Errorf("read official climb id: %w", err)
}
created, found, err := GetByID(db, id)
if err != nil {
return OfficialClimb{}, err
}
if !found {
return OfficialClimb{}, fmt.Errorf("official climb %d was not found after creation", id)
}
return created, nil
}
func List(db *sql.DB) ([]OfficialClimb, error) {
rows, err := db.Query("SELECT " + columns + " FROM official_climbs ORDER BY id")
if err != nil {
return nil, fmt.Errorf("list official climbs: %w", err)
}
defer rows.Close()
climbs := make([]OfficialClimb, 0)
for rows.Next() {
climb, err := scan(rows)
if err != nil {
return nil, err
}
climbs = append(climbs, climb)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate official climbs: %w", err)
}
return climbs, nil
}
func GetByID(db *sql.DB, id int64) (OfficialClimb, bool, error) {
row := db.QueryRow("SELECT "+columns+" FROM official_climbs WHERE id = ?", id)
climb, err := scan(row)
if err == sql.ErrNoRows {
return OfficialClimb{}, false, nil
}
if err != nil {
return OfficialClimb{}, false, fmt.Errorf("get official climb %d: %w", id, err)
}
return climb, true, nil
}
type scanner interface {
Scan(dest ...any) error
}
func scan(s scanner) (OfficialClimb, error) {
var (
climb OfficialClimb
startLatitude, startLongitude float64
endLatitude, endLongitude float64
createdAt, updatedAt string
)
if err := s.Scan(&climb.ID, &climb.Name, &startLatitude, &startLongitude, &endLatitude, &endLongitude, &createdAt, &updatedAt); err != nil {
return OfficialClimb{}, err
}
climb.StartCoord = geodist.Coord{Lat: startLatitude, Lon: startLongitude}
climb.EndCoord = geodist.Coord{Lat: endLatitude, Lon: endLongitude}
var err error
climb.CreatedAt, err = time.Parse(time.RFC3339, createdAt)
if err != nil {
return OfficialClimb{}, fmt.Errorf("invalid official_climbs.created_at: %w", err)
}
climb.UpdatedAt, err = time.Parse(time.RFC3339, updatedAt)
if err != nil {
return OfficialClimb{}, fmt.Errorf("invalid official_climbs.updated_at: %w", err)
}
return climb, nil
}
func MatchClimb(climb ride.Climb, officialClimbs []OfficialClimb, policy MatchPolicy) (OfficialClimb, bool) {
if policy.Validate() != nil {
return OfficialClimb{}, false
}
bestDistance := math.Inf(1)
var best OfficialClimb
found := false
for _, official := range officialClimbs {
startIndex, startDistance := nearestClimbPoint(climb, official.StartCoord)
endIndex, endDistance := nearestClimbPoint(climb, official.EndCoord)
if startDistance > policy.EndpointRadiusM || endDistance > policy.EndpointRadiusM {
continue
}
if startIndex >= endIndex {
continue
}
totalDistance := startDistance + endDistance
if totalDistance < bestDistance || (totalDistance == bestDistance && (!found || official.ID < best.ID)) {
best = official
bestDistance = totalDistance
found = true
}
}
return best, found
}
func nearestClimbPoint(climb ride.Climb, target geodist.Coord) (int, float64) {
bestIndex := climb.StartIndex()
bestDistance := math.Inf(1)
for index := climb.StartIndex(); index <= climb.EndIndex(); index++ {
distance := distanceM(climb.PointCoord(index), target)
if distance < bestDistance {
bestIndex = index
bestDistance = distance
}
}
return bestIndex, bestDistance
}
func distanceM(a, b geodist.Coord) float64 {
_, distanceKm := geodist.HaversineDistance(a, b)
return distanceKm * 1000
}
|