205 lines
6.5 KiB
Go
205 lines
6.5 KiB
Go
|
|
package sync
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"smartrun/backend/internal/classify"
|
||
|
|
"smartrun/backend/internal/garmin"
|
||
|
|
"smartrun/backend/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
func toActivityRow(a garmin.Activity) store.Activity {
|
||
|
|
return store.Activity{
|
||
|
|
GarminActivityID: a.ActivityID,
|
||
|
|
ActivityName: a.ActivityName,
|
||
|
|
ActivityType: a.ActivityType.TypeKey,
|
||
|
|
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) []store.Lap {
|
||
|
|
rows := make([]store.Lap, 0, len(laps))
|
||
|
|
var elapsedStart float64
|
||
|
|
for _, 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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
RawJSON: string(raw),
|
||
|
|
})
|
||
|
|
elapsedStart = elapsedEnd
|
||
|
|
}
|
||
|
|
return rows
|
||
|
|
}
|
||
|
|
|
||
|
|
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 {
|
||
|
|
ctx := classify.MetricContext{
|
||
|
|
"duration_seconds": a.DurationSeconds,
|
||
|
|
"distance_meters": a.DistanceMeters,
|
||
|
|
}
|
||
|
|
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") }
|