package classify import ( "math" "strings" ) // LapInfo is the subset of a lap's fields needed for interval-pattern // detection, independent of internal/store's row representation. type LapInfo struct { IntensityType string } // DetectIntervalPattern reports whether an activity's laps look like a // structured interval workout, using Garmin's own per-lap IntensityType // tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it // from pace variance -- the device/app already knows which laps were work // vs rest segments when the activity was recorded as a structured workout. func DetectIntervalPattern(laps []LapInfo) bool { active, rest := 0, 0 for _, l := range laps { switch strings.ToUpper(l.IntensityType) { case "ACTIVE": active++ case "REST", "RECOVERY", "COOLDOWN", "WARMUP": rest++ } } return active >= 2 && rest >= 2 } // LapPaceStdDev returns the standard deviation of per-lap pace (any // consistent unit, e.g. sec/km), a fallback signal for "uneven pacing" on // activities without formal interval tagging. func LapPaceStdDev(paces []float64) float64 { n := float64(len(paces)) if n == 0 { return 0 } var sum float64 for _, p := range paces { sum += p } mean := sum / n var variance float64 for _, p := range paces { variance += (p - mean) * (p - mean) } return math.Sqrt(variance / n) } // SampleInfo is one HR-bearing telemetry sample within a lap's time window. type SampleInfo struct { ElapsedSeconds float64 HeartRate *float64 } // minSamplesForTrend is the fewest HR readings needed before a drift/recovery // slope is considered meaningful rather than noise. const minSamplesForTrend = 10 // HRTrend fits a line to heart rate vs elapsed time across samples and // returns its slope in bpm/minute. Returns ok=false if there aren't enough // HR readings to trust the result. func HRTrend(samples []SampleInfo) (bpmPerMin float64, ok bool) { var xs, ys []float64 for _, s := range samples { if s.HeartRate == nil { continue } xs = append(xs, s.ElapsedSeconds) ys = append(ys, *s.HeartRate) } if len(xs) < minSamplesForTrend { return 0, false } slope, ok := linregSlope(xs, ys) if !ok { return 0, false } return slope * 60, true } // HRDrift is HRTrend applied to an active/effort lap's samples: a positive // result means heart rate is climbing over the interval (cardiac drift) for // a comparable effort level. func HRDrift(samples []SampleInfo) (bpmPerMin float64, ok bool) { return HRTrend(samples) } // HRRecovery is HRTrend applied to a recovery/rest lap's samples, sign- // flipped so a positive result means heart rate is dropping (higher = // better recovery) and a negative result flags heart rate still rising // during what was supposed to be a rest interval. func HRRecovery(samples []SampleInfo) (bpmDropPerMin float64, ok bool) { slope, ok := HRTrend(samples) if !ok { return 0, false } return -slope, true } func linregSlope(xs, ys []float64) (float64, bool) { n := float64(len(xs)) if n < 2 { return 0, false } var sumX, sumY float64 for i := range xs { sumX += xs[i] sumY += ys[i] } xbar, ybar := sumX/n, sumY/n var num, den float64 for i := range xs { dx := xs[i] - xbar num += dx * (ys[i] - ybar) den += dx * dx } if den == 0 { return 0, false } return num / den, true }