aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 11:04:57 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 11:04:57 +0200
commitd26aa2dca1b14b7d52070825bfc73c5e8aed7a16 (patch)
tree5d2ad13f342316dcdb1258ecd8c6e739d9adf08d
parent44f7333d9deef81d3468e20b378c63692a9406ec (diff)
refactor(ride,rideanalysis): replace magic numbers with named constants
-rw-r--r--ride/climb.go67
-rw-r--r--ride/ride.go8
-rw-r--r--rideanalysis/analysis.go13
3 files changed, 67 insertions, 21 deletions
diff --git a/ride/climb.go b/ride/climb.go
index 56e47c6..8852cbf 100644
--- a/ride/climb.go
+++ b/ride/climb.go
@@ -13,6 +13,41 @@ import (
const ClimbDistanceMinimum = 500
+// Category is the difficulty class of a climb, ordered from easiest to
+// hardest.
+type Category int
+
+const (
+ CategoryNo Category = iota
+ CategoryFour
+ CategoryThree
+ CategoryTwo
+ CategoryOne
+ CategoryHC
+)
+
+type categoryInfo struct {
+ label string
+ maxScore float64
+}
+
+// categories describes each Category: its display label and the exclusive
+// Cotacol score ceiling it applies to. A score at or above CategoryOne's
+// ceiling is Hors Catégorie.
+var categories = map[Category]categoryInfo{
+ CategoryNo: {label: "NO", maxScore: 35},
+ CategoryFour: {label: "Cat 4", maxScore: 80},
+ CategoryThree: {label: "Cat 3", maxScore: 180},
+ CategoryTwo: {label: "Cat 2", maxScore: 250},
+ CategoryOne: {label: "Cat 1", maxScore: 600},
+ // Hors Catégorie has no ceiling.
+ CategoryHC: {label: "HC", maxScore: math.Inf(+1)},
+}
+
+// climbDetectionMinimumScore is the climb-detection score below which a
+// candidate segment is not reported as a climb.
+const climbDetectionMinimumScore = 35
+
type Climb struct {
ride Ride
rideStart int
@@ -80,7 +115,7 @@ func (climb Climb) String() string {
startDistance := climb.StartDistanceM()
endDistance := climb.EndDistanceM()
cotacol := climb.DifficultyScore()
- body := fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startDistance/1000, endDistance/1000, (endDistance-startDistance)/1000, Slope(climb.ride, climb.rideStart, climb.rideEnd)*100, int(cotacol), Category(cotacol))
+ body := fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startDistance/1000, endDistance/1000, (endDistance-startDistance)/1000, Slope(climb.ride, climb.rideStart, climb.rideEnd)*100, int(cotacol), CategoryOf(cotacol))
if climb.Name == "" {
return body
}
@@ -105,21 +140,21 @@ func climbDetectionScore(r Ride, start, end int) float64 {
return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0
}
-func Category(cotacol float64) string {
- switch {
- case cotacol < 35:
- return "NO"
- case cotacol < 80:
- return "Cat 4"
- case cotacol < 180:
- return "Cat 3"
- case cotacol < 250:
- return "Cat 2"
- case cotacol < 600:
- return "Cat 1"
- default:
- return "HC"
+func (c Category) String() string {
+ return categories[c].label
+}
+
+// CategoryOf returns the difficulty class of a Cotacol score.
+func CategoryOf(cotacol float64) Category {
+ best := CategoryHC
+ bestMax := math.Inf(+1)
+ for category, info := range categories {
+ if cotacol < info.maxScore && info.maxScore < bestMax {
+ best = category
+ bestMax = info.maxScore
+ }
}
+ return best
}
func bestClimbBetween(r Ride, start, end int) Climb {
@@ -170,7 +205,7 @@ func climbsBetween(r Ride, start, end int) []Climb {
return climbsBetween(r, start+1, end)
}
climb := bestClimbBetween(r, start, highest)
- if climbDetectionScore(r, climb.rideStart, climb.rideEnd) >= 35 && climb.EndDistanceM()-climb.StartDistanceM() >= ClimbDistanceMinimum {
+ if climbDetectionScore(r, climb.rideStart, climb.rideEnd) >= climbDetectionMinimumScore && climb.EndDistanceM()-climb.StartDistanceM() >= ClimbDistanceMinimum {
slog.Debug("Found climb between", slog.Int("start", int(climb.StartDistanceM())), slog.Int("end", int(climb.EndDistanceM())))
climbs = append(climbs, climb)
}
diff --git a/ride/ride.go b/ride/ride.go
index 278880f..a10d8a2 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -230,6 +230,10 @@ func metricValue(point gpx.GPXPoint, name string) (*float64, error) {
const CotacolAlgorithmVersion = "v1"
+// cotacolSegmentLengthM is the fixed length of the elevation segments the
+// Cotacol score is computed over.
+const cotacolSegmentLengthM = 100
+
func Cotacol(ride Ride) float64 {
return difficultyScore(ride, 0, ride.Len()-1)
}
@@ -245,8 +249,8 @@ func difficultyScore(r Ride, startIndex, endIndex int) float64 {
}
cotacol := 0.0
i := startIndex
- for start := startDistance; start < lastDistance; start += 100 {
- end := math.Min(start+100, lastDistance)
+ for start := startDistance; start < lastDistance; start += cotacolSegmentLengthM {
+ end := math.Min(start+cotacolSegmentLengthM, lastDistance)
for i < endIndex && r.DistanceM(i+1) <= start {
i++
}
diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go
index 632f729..a4c319f 100644
--- a/rideanalysis/analysis.go
+++ b/rideanalysis/analysis.go
@@ -9,6 +9,13 @@ import (
"github.com/martinlehoux/biking_home/ride"
)
+const (
+ passMatchRadiusM = 300
+ passMatchElevationToleranceM = 50
+ passCrossingRadiusM = 100
+ passCrossingElevationToleranceM = 25
+)
+
type Result struct {
Climbs []AnalyzedClimb
Crossings []mountain_pass.Crossing
@@ -32,7 +39,7 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
}
for _, detectedClimb := range parsed.AllClimbs() {
displayClimb := detectedClimb
- if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, 300, 50); ok {
+ if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok {
displayClimb.Name = matchedPass.Name
}
matchedOfficial, officialFound := official_climb.MatchClimb(parsed, detectedClimb, officialClimbs, matchPolicy)
@@ -51,7 +58,7 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
result.Climbs = append(result.Climbs, AnalyzedClimb{
Segment: displayClimb,
Name: displayClimb.Name,
- Category: ride.Category(cotacol),
+ Category: ride.CategoryOf(cotacol).String(),
DistanceM: displayClimb.EndDistanceM() - displayClimb.StartDistanceM(),
SlopePercent: ride.Slope(parsed, displayClimb.StartIndex(), displayClimb.EndIndex()) * 100,
Cotacol: cotacol,
@@ -59,7 +66,7 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
OfficialName: officialClimbName(matchedOfficial, officialFound),
})
}
- for _, crossing := range mountain_pass.DetectCrossings(parsed, passes, 100, 25) {
+ for _, crossing := range mountain_pass.DetectCrossings(parsed, passes, passCrossingRadiusM, passCrossingElevationToleranceM) {
result.Crossings = append(result.Crossings, crossing)
}
return result