aboutsummaryrefslogtreecommitdiff
path: root/official_climb/aggregate.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-13 21:55:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-13 21:55:59 +0200
commite3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (patch)
treeec8234d3442c498ec170b6444bd2cfae5b29a476 /official_climb/aggregate.go
parentdb4d2b40895db41ce188549f49a6f277ba95c890 (diff)
Persist official climb attempts with climbs pages
Record one attempt row per (ride, climb, occurrence) when a ride detail loads, snapshotting distance, elevation gain, slope, cotacol and attempt timestamps; a climbs-backfill CLI command seeds all existing rides. New /climbs list page aggregates attempts (medians computed in Go, best time as min duration) with sortable columns, and /climbs/{id} shows the median scalar stats, a median normalized elevation profile (50 steps over each attempt's own length), the full-precision recorded route of the median-distance attempt on an OSM map, and every attempt with time at second precision.
Diffstat (limited to 'official_climb/aggregate.go')
-rw-r--r--official_climb/aggregate.go147
1 files changed, 147 insertions, 0 deletions
diff --git a/official_climb/aggregate.go b/official_climb/aggregate.go
new file mode 100644
index 0000000..c2bd75d
--- /dev/null
+++ b/official_climb/aggregate.go
@@ -0,0 +1,147 @@
+package official_climb
+
+import (
+ "sort"
+ "time"
+
+ "github.com/martinlehoux/biking_home/climb"
+)
+
+const profileSteps = 50
+
+// Aggregate is the climb-level summary derived from its recorded attempts.
+type Aggregate struct {
+ Attempts int
+ DistanceM float64
+ ElevationGainM float64
+ SlopePercent float64
+ Cotacol float64
+ Category climb.Category
+ BestDuration time.Duration
+ HasBest bool
+}
+
+// AggregateAttempts summarizes a climb from its attempts: every scalar is the
+// median across attempts so one noisy ride cannot define the climb.
+func AggregateAttempts(attempts []Attempt) Aggregate {
+ if len(attempts) == 0 {
+ return Aggregate{}
+ }
+ distances := make([]float64, len(attempts))
+ gains := make([]float64, len(attempts))
+ slopes := make([]float64, len(attempts))
+ cotacols := make([]float64, len(attempts))
+ aggregate := Aggregate{Attempts: len(attempts)}
+ for i, attempt := range attempts {
+ distances[i] = attempt.DistanceM
+ gains[i] = attempt.ElevationGainM
+ slopes[i] = attempt.SlopePercent
+ cotacols[i] = attempt.Cotacol
+ duration := attempt.EndedAt.Sub(attempt.StartedAt)
+ if duration > 0 && (!aggregate.HasBest || duration < aggregate.BestDuration) {
+ aggregate.BestDuration = duration
+ aggregate.HasBest = true
+ }
+ }
+ aggregate.DistanceM = Median(distances)
+ aggregate.ElevationGainM = Median(gains)
+ aggregate.SlopePercent = Median(slopes)
+ aggregate.Cotacol = Median(cotacols)
+ aggregate.Category = climb.CategoryOf(aggregate.Cotacol)
+ return aggregate
+}
+
+// Median returns the median of values; for an even count it averages the two
+// middle values.
+func Median(values []float64) float64 {
+ if len(values) == 0 {
+ return 0
+ }
+ sorted := make([]float64, len(values))
+ copy(sorted, values)
+ sort.Float64s(sorted)
+ middle := len(sorted) / 2
+ if len(sorted)%2 == 1 {
+ return sorted[middle]
+ }
+ return (sorted[middle-1] + sorted[middle]) / 2
+}
+
+// normalizedSample locates one normalized step inside a segment: the index of
+// the recorded point preceding the target distance and the interpolation
+// fraction toward the next point.
+type normalizedSample struct {
+ index int
+ fraction float64
+}
+
+// normalizedSamples locates profileSteps equally spaced fractions of the
+// segment length, interpolating between recorded points. Distances are
+// relative: segments of different lengths stay comparable.
+func normalizedSamples(distances []float64) []normalizedSample {
+ if len(distances) < 2 {
+ return nil
+ }
+ start, end := distances[0], distances[len(distances)-1]
+ span := end - start
+ if span <= 0 {
+ return nil
+ }
+ samples := make([]normalizedSample, profileSteps)
+ i := 0
+ for step := range profileSteps {
+ target := start + span*float64(step)/float64(profileSteps-1)
+ for i < len(distances)-2 && distances[i+1] < target {
+ i++
+ }
+ segmentSpan := distances[i+1] - distances[i]
+ fraction := 0.0
+ if segmentSpan > 0 {
+ fraction = (target - distances[i]) / segmentSpan
+ }
+ samples[step] = normalizedSample{index: i, fraction: fraction}
+ }
+ return samples
+}
+
+// NormalizeProfile samples a climb segment's elevation at profileSteps
+// equally spaced fractions of the segment length, interpolating between
+// recorded points.
+func NormalizeProfile(segment climb.Climb) []float64 {
+ samples := normalizedSamples(segment.Distances)
+ if samples == nil {
+ return nil
+ }
+ elevations := make([]float64, len(samples))
+ for step, sample := range samples {
+ elevations[step] = sampleElevation(segment.Distances, segment.Elevations, sample)
+ }
+ return elevations
+}
+
+func sampleElevation(distances, elevations []float64, sample normalizedSample) float64 {
+ i := sample.index
+ if i+1 >= len(distances) {
+ return elevations[len(elevations)-1]
+ }
+ return elevations[i] + sample.fraction*(elevations[i+1]-elevations[i])
+}
+
+// MedianProfile merges per-attempt normalized elevation samples into one
+// canonical profile: the median elevation at every step.
+func MedianProfile(samples [][]float64) []float64 {
+ median := make([]float64, 0, profileSteps)
+ for step := range profileSteps {
+ values := make([]float64, 0, len(samples))
+ for _, sample := range samples {
+ if step < len(sample) {
+ values = append(values, sample[step])
+ }
+ }
+ if len(values) == 0 {
+ continue
+ }
+ median = append(median, Median(values))
+ }
+ return median
+}