Files
geniusrun/backend/internal/sync/mapping.go
Christophe Vila 9818d35910 Tolerate exactly one extra trailing lap when aligning workout targets
Confirmed via Garmin Connect against a real activity ("Auriol - W3-3-Double
Barrel"): the workout's step count didn't match its 18 recorded laps
because the athlete kept running 6:46 past the prescribed 5-minute
cool-down, logged as an 18th lap the workout never defined. The strict
equality check meant this one extra lap discarded every other lap's real
target too, not just its own.

alignWorkoutTargets now tolerates exactly one extra recorded lap beyond the
step count: the steps that do exist still zip to their laps normally, and
only the trailing extra lap is left without a target. Any larger mismatch
still falls back to nil for every lap, since that can't be trusted at all.
2026-07-19 18:01:06 +02:00

326 lines
11 KiB
Go

package sync
import (
"encoding/json"
"strings"
"time"
"smartrun/backend/internal/classify"
"smartrun/backend/internal/garmin"
"smartrun/backend/internal/store"
)
// isRunningActivityType reports whether a Garmin activityType.typeKey
// represents a running activity (running, trail_running, treadmill_running,
// track_running, indoor_running, virtual_run, ...) as opposed to other
// sports (padel, cycling, strength training, ...) that also show up in
// get_activities().
func isRunningActivityType(typeKey string) bool {
return strings.Contains(strings.ToLower(typeKey), "run")
}
func toActivityRow(a garmin.Activity) store.Activity {
return store.Activity{
GarminActivityID: a.ActivityID,
ActivityName: a.ActivityName,
ActivityType: a.ActivityType.TypeKey,
EventTypeKey: a.EventType.TypeKey,
WorkoutID: a.WorkoutID,
StartTimeUTC: a.StartTimeGMT,
BeginTimestampMs: a.BeginTimestamp,
DurationSeconds: a.Duration,
DistanceMeters: a.Distance,
AvgHR: nonZero(a.AverageHR),
MaxHR: nonZero(a.MaxHR),
AvgSpeedMps: nonZero(a.AverageSpeed),
MaxSpeedMps: nonZero(a.MaxSpeed),
ElevationGainM: a.ElevationGain,
ElevationLossM: a.ElevationLoss,
Calories: nonZero(a.Calories),
LapCount: a.LapCount,
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
TrainingEffectLabel: a.TrainingEffectLabel,
VO2MaxValue: a.VO2MaxValue,
HrTimeInZone1: nonZero(a.HrTimeInZone1),
HrTimeInZone2: nonZero(a.HrTimeInZone2),
HrTimeInZone3: nonZero(a.HrTimeInZone3),
HrTimeInZone4: nonZero(a.HrTimeInZone4),
HrTimeInZone5: nonZero(a.HrTimeInZone5),
RawJSON: string(a.Raw),
}
}
// nonZero returns nil for a zero value so store columns stay NULL instead of
// a misleading 0 when Garmin simply didn't report that field.
func nonZero(v float64) *float64 {
if v == 0 {
return nil
}
return &v
}
// toLapRows converts garmin lap DTOs into store rows, computing each lap's
// HR drift (active laps) or recovery rate (rest laps) from the samples that
// fall within that lap's time window. Lap boundaries are derived from
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
// are contiguous and this sidesteps timezone parsing entirely.
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
rows := make([]store.Lap, 0, len(laps))
var elapsedStart float64
for i, l := range laps {
elapsedEnd := elapsedStart + l.ElapsedDuration
var driftPtr, recoveryPtr *float64
lapSamples := samplesInWindow(samples, elapsedStart, elapsedEnd)
switch l.IntensityType {
case "ACTIVE":
if v, ok := classify.HRDrift(lapSamples); ok {
driftPtr = &v
}
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
if v, ok := classify.HRRecovery(lapSamples); ok {
recoveryPtr = &v
}
}
var paceLow, paceHigh, hrLow, hrHigh *float64
if i < len(targets) && targets[i] != nil {
paceLow, paceHigh = targetPaceRange(*targets[i])
hrLow, hrHigh = targetHRRange(*targets[i], profile)
}
raw, _ := json.Marshal(l)
rows = append(rows, store.Lap{
LapIndex: l.LapIndex,
StartTimeUTC: l.StartTimeGMT,
DurationSeconds: l.Duration,
DistanceMeters: l.Distance,
AvgHR: nonZero(l.AverageHR),
MaxHR: nonZero(l.MaxHR),
AvgSpeedMps: nonZero(l.AverageSpeed),
MaxSpeedMps: nonZero(l.MaxSpeed),
ElevationGainM: nonZero(l.ElevationGain),
ElevationLossM: nonZero(l.ElevationLoss),
IntensityType: l.IntensityType,
HRDriftBpmPerMin: driftPtr,
HRRecoveryBpmPerMin: recoveryPtr,
TargetPaceLowMps: paceLow,
TargetPaceHighMps: paceHigh,
TargetHRLowBpm: hrLow,
TargetHRHighBpm: hrHigh,
RawJSON: string(raw),
})
elapsedStart = elapsedEnd
}
return rows
}
// alignWorkoutTargets zips an activity's recorded laps against its
// structured workout's flattened steps, returning one *garmin.WorkoutStep
// per lap (nil where unavailable).
//
// Confirmed against a real activity (via Garmin Connect's own workout view)
// that recording sometimes continues one lap past the end of the workout's
// last step -- e.g. a 5-minute prescribed cool-down followed by another
// 6:46 the athlete just kept running, logged as a further lap Garmin never
// defined a target for. That shows up here as exactly one more recorded lap
// than the workout has steps, so that specific case zips the steps that do
// exist and leaves the trailing extra lap unmapped, rather than discarding
// every other lap's real target along with it.
//
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
// etc.) can't be trusted at all, so every entry comes back nil rather than
// risk showing a target against the wrong lap.
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
steps := workout.FlattenSteps()
out := make([]*garmin.WorkoutStep, len(laps))
switch len(laps) - len(steps) {
case 0, 1:
for i := range steps {
s := steps[i]
out[i] = &s
}
}
return out
}
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
// pace-zone target, or (nil, nil) if it doesn't target pace.
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
return nil, nil
}
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
if lo > hi {
lo, hi = hi, lo
}
return &lo, &hi
}
// targetHRRange returns the (low, high) bpm bounds of a workout step's
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
// range are resolved via the user's Karvonen profile.
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
if step.TargetType.TypeKey != "heart.rate.zone" {
return nil, nil
}
if step.TargetValueOne != nil && step.TargetValueTwo != nil {
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
if lo > hi {
lo, hi = hi, lo
}
return &lo, &hi
}
if step.ZoneNumber != nil {
if lo, hi, ok := karvonenBounds(profile, *step.ZoneNumber); ok {
return &lo, &hi
}
}
return nil, nil
}
// karvonenBounds resolves a named HR zone (1-5) to bpm bounds using the
// user's max/resting heart rate and the zone's %HRR range, both from
// Profile. ok is false when max/resting heart rate aren't configured, or
// the zone number is out of range.
func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool) {
if p.MaxHeartRate == nil || p.RestingHeartRate == nil {
return 0, 0, false
}
maxHR, restHR := *p.MaxHeartRate, *p.RestingHeartRate
var minPct, maxPct float64
switch zone {
case 1:
minPct, maxPct = p.HRZone1MinPct, p.HRZone1MaxPct
case 2:
minPct, maxPct = p.HRZone2MinPct, p.HRZone2MaxPct
case 3:
minPct, maxPct = p.HRZone3MinPct, p.HRZone3MaxPct
case 4:
minPct, maxPct = p.HRZone4MinPct, p.HRZone4MaxPct
case 5:
minPct, maxPct = p.HRZone5MinPct, p.HRZone5MaxPct
default:
return 0, 0, false
}
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
}
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
var out []classify.SampleInfo
for _, s := range samples {
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
continue
}
out = append(out, classify.SampleInfo{ElapsedSeconds: s.ElapsedSeconds, HeartRate: s.HeartRate})
}
return out
}
func toSampleRows(samples []garmin.Sample) []store.Sample {
rows := make([]store.Sample, len(samples))
for i, s := range samples {
rows[i] = store.Sample{
ElapsedSeconds: s.ElapsedSeconds,
TimestampMs: s.TimestampMS,
HeartRate: s.HeartRate,
SpeedMps: s.SpeedMps,
DistanceM: s.DistanceM,
ElevationM: s.ElevationM,
}
}
return rows
}
// buildMetricContext computes the classify.MetricContext for one activity
// from its stored summary and laps, ready to evaluate against workout kind
// rules. maxHR is the user's configured max heart rate, used only to derive
// avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly).
func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext {
isRace := 0.0
if a.EventTypeKey == "race" {
isRace = 1.0
}
ctx := classify.MetricContext{
"duration_seconds": a.DurationSeconds,
"distance_meters": a.DistanceMeters,
"is_race": isRace,
}
if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 {
ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps
}
if a.AvgHR != nil {
ctx["avg_hr"] = *a.AvgHR
if maxHR > 0 {
ctx["avg_hr_pct_max"] = *a.AvgHR / maxHR
}
}
if a.MaxHR != nil {
ctx["max_hr"] = *a.MaxHR
}
if a.ElevationGainM != nil {
ctx["elevation_gain_m"] = *a.ElevationGainM
}
if a.AerobicTrainingEffect != nil {
ctx["aerobic_training_effect"] = *a.AerobicTrainingEffect
}
if a.AnaerobicTrainingEffect != nil {
ctx["anaerobic_training_effect"] = *a.AnaerobicTrainingEffect
}
if a.VO2MaxValue != nil {
ctx["vo2max_value"] = *a.VO2MaxValue
}
lapInfos := make([]classify.LapInfo, len(laps))
var paces []float64
var maxDrift, maxRecovery float64
haveDrift, haveRecovery := false, false
for i, l := range laps {
lapInfos[i] = classify.LapInfo{IntensityType: l.IntensityType}
if l.AvgSpeedMps != nil && *l.AvgSpeedMps > 0 {
paces = append(paces, 1000 / *l.AvgSpeedMps)
}
if l.HRDriftBpmPerMin != nil && (!haveDrift || *l.HRDriftBpmPerMin > maxDrift) {
maxDrift, haveDrift = *l.HRDriftBpmPerMin, true
}
if l.HRRecoveryBpmPerMin != nil && (!haveRecovery || *l.HRRecoveryBpmPerMin > maxRecovery) {
maxRecovery, haveRecovery = *l.HRRecoveryBpmPerMin, true
}
}
if len(laps) > 0 {
if classify.DetectIntervalPattern(lapInfos) {
ctx["lap_interval_pattern"] = 1
} else {
ctx["lap_interval_pattern"] = 0
}
}
if len(paces) > 0 {
ctx["lap_pace_stddev"] = classify.LapPaceStdDev(paces)
}
if haveDrift {
ctx["lap_hr_drift_bpm_per_min"] = maxDrift
}
if haveRecovery {
ctx["lap_hr_recovery_bpm_per_min"] = maxRecovery
}
return ctx
}
func loadRuleKinds(kinds []store.WorkoutKind) ([]classify.RuleKind, error) {
rules := make([]classify.RuleKind, 0, len(kinds))
for _, k := range kinds {
var node classify.Node
if err := json.Unmarshal([]byte(k.RuleJSON), &node); err != nil {
return nil, err
}
rules = append(rules, classify.RuleKind{WorkoutKindID: k.ID, Name: k.Name, Rule: node})
}
return rules, nil
}
func dateStr(t time.Time) string { return t.Format("2006-01-02") }