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 }