From d26aa2dca1b14b7d52070825bfc73c5e8aed7a16 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 21 Aug 2026 11:04:57 +0200 Subject: refactor(ride,rideanalysis): replace magic numbers with named constants --- ride/climb.go | 67 ++++++++++++++++++++++++++++++++++++------------ ride/ride.go | 8 ++++-- rideanalysis/analysis.go | 13 +++++++--- 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 -- cgit v1.3