Initial commit: smartrun MVP
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>
This commit is contained in:
125
backend/internal/classify/laps.go
Normal file
125
backend/internal/classify/laps.go
Normal file
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user