Files
Christophe Vila f689f74ae0 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>
2026-07-17 18:33:06 +02:00

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
}