diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-13 21:55:59 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-13 21:55:59 +0200 |
| commit | e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (patch) | |
| tree | ec8234d3442c498ec170b6444bd2cfae5b29a476 /official_climb/aggregate.go | |
| parent | db4d2b40895db41ce188549f49a6f277ba95c890 (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.go | 147 |
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 +} |
