Garmin run classification and progression tracker. Go backend (MCP client to mcp-garmin, SQLite store, deterministic rule engine, REST API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
126 lines
3.3 KiB
Go
126 lines
3.3 KiB
Go
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
|
|
}
|