Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
251 lines
8.2 KiB
Go
251 lines
8.2 KiB
Go
// Command seedsample inserts synthetic activities, laps, and workout kinds
|
|
// directly into the SQLite database, then runs them through the real
|
|
// classification engine -- so the frontend (Dashboard/ReviewQueue/
|
|
// WorkoutKinds) can be visually verified with realistic data without a live
|
|
// Garmin account.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"geniusrun/backend/internal/classify"
|
|
"geniusrun/backend/internal/garmin"
|
|
"geniusrun/backend/internal/store"
|
|
)
|
|
|
|
func main() {
|
|
dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed")
|
|
flag.Parse()
|
|
|
|
ctx := context.Background()
|
|
db, err := store.Open(*dbPath)
|
|
if err != nil {
|
|
log.Fatalf("open db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
userID, err := db.ProvisionUser(ctx, "seedsample-user", "Sample")
|
|
must(err)
|
|
|
|
// Set a max heart rate on the profile so avg_hr_pct_max is computed
|
|
// during classification below (it's nil/unset by default).
|
|
profile, err := db.GetProfile(ctx, userID)
|
|
must(err)
|
|
maxHR := 190.0
|
|
profile.MaxHeartRate = &maxHR
|
|
must(db.UpdateProfile(ctx, userID, profile))
|
|
|
|
// Easy Run and Tempo's pace/HR ranges deliberately overlap a little
|
|
// (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap
|
|
// zone demonstrates the ambiguous-multi-match review path, not just a
|
|
// gap between disjoint ranges. The taxonomy migration already seeded
|
|
// these rows (by fixed name) -- update their rules in place rather than
|
|
// creating new ones, since names are unique.
|
|
easyID := mustFindKindID(ctx, db, userID, "Easy")
|
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
|
ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
|
|
RuleJSON: `{"match":"all","conditions":[
|
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
|
{"metric":"avg_hr_pct_max","op":"<=","value":0.85}
|
|
]}`,
|
|
IsActive: true,
|
|
}))
|
|
|
|
tempoID := mustFindKindID(ctx, db, userID, "Tempo")
|
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
|
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
|
RuleJSON: `{"match":"all","conditions":[
|
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
|
{"metric":"avg_hr_pct_max","op":">=","value":0.70}
|
|
]}`,
|
|
IsActive: true,
|
|
}))
|
|
|
|
intervalID := mustFindKindID(ctx, db, userID, "Intervals")
|
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
|
ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444",
|
|
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
|
IsActive: true,
|
|
}))
|
|
|
|
m := &garmin.MockClient{}
|
|
svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
|
|
|
|
today := time.Now()
|
|
activityIDs := []int64{}
|
|
|
|
// 5 Easy runs, pace/HR trending slightly faster over time (progression).
|
|
for i := 0; i < 5; i++ {
|
|
start := today.AddDate(0, 0, -60+i*10)
|
|
speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km
|
|
hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling
|
|
id := seedActivity(ctx, db, userID, seedParams{
|
|
garminID: 1000 + int64(i), name: "Easy morning run", start: start,
|
|
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
|
|
aerobicTE: 2.5, anaerobicTE: 0.3,
|
|
})
|
|
activityIDs = append(activityIDs, id)
|
|
}
|
|
|
|
// 3 Tempo runs.
|
|
for i := 0; i < 3; i++ {
|
|
start := today.AddDate(0, 0, -45+i*15)
|
|
speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range
|
|
id := seedActivity(ctx, db, userID, seedParams{
|
|
garminID: 2000 + int64(i), name: "Tempo run", start: start,
|
|
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
|
|
aerobicTE: 3.8, anaerobicTE: 1.2,
|
|
})
|
|
activityIDs = append(activityIDs, id)
|
|
}
|
|
|
|
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
|
|
// showing drift on the work intervals and recovery on the rest ones.
|
|
{
|
|
id := seedActivity(ctx, db, userID, seedParams{
|
|
garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5),
|
|
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
|
|
aerobicTE: 3.0, anaerobicTE: 3.5,
|
|
})
|
|
seedIntervalLapsAndSamples(ctx, db, userID, id)
|
|
activityIDs = append(activityIDs, id)
|
|
}
|
|
|
|
// 1 ambiguous run: pace 335 sec/km sits inside both Easy's [330,420] and
|
|
// Tempo's [300,340] ranges, and HR% (0.78) satisfies both Easy's <=0.85
|
|
// and Tempo's >=0.70 -- so it should land in the review queue with two
|
|
// candidates, not a clean single match.
|
|
{
|
|
id := seedActivity(ctx, db, userID, seedParams{
|
|
garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2),
|
|
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
|
|
aerobicTE: 3.0, anaerobicTE: 0.8,
|
|
})
|
|
activityIDs = append(activityIDs, id)
|
|
}
|
|
|
|
for _, id := range activityIDs {
|
|
must(svc.ClassifyActivity(ctx, id))
|
|
}
|
|
|
|
fmt.Printf("Seeded %d activities (kinds: Easy=%d, Tempo=%d) into %s\n", len(activityIDs), easyID, tempoID, *dbPath)
|
|
}
|
|
|
|
type seedParams struct {
|
|
garminID int64
|
|
name string
|
|
start time.Time
|
|
distance float64
|
|
duration float64
|
|
speedMps float64
|
|
avgHR float64
|
|
aerobicTE float64
|
|
anaerobicTE float64
|
|
}
|
|
|
|
func seedActivity(ctx context.Context, db *store.DB, userID int64, p seedParams) int64 {
|
|
speed := p.speedMps
|
|
hr := p.avgHR
|
|
aerobic := p.aerobicTE
|
|
anaerobic := p.anaerobicTE
|
|
id, err := db.UpsertActivity(ctx, userID, store.Activity{
|
|
GarminActivityID: p.garminID,
|
|
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
|
DurationSeconds: p.duration,
|
|
DistanceMeters: p.distance,
|
|
AvgSpeedMps: &speed,
|
|
AvgHR: &hr,
|
|
AerobicTrainingEffect: &aerobic,
|
|
AnaerobicTrainingEffect: &anaerobic,
|
|
// ActivityName/ActivityType aren't stored columns anymore -- the API
|
|
// decodes them from RawJSON, so seeded activities need them here to
|
|
// still show a sensible name/type in the dev UI.
|
|
RawJSON: fmt.Sprintf(`{"activityName":%q,"activityType":{"typeKey":"running"}}`, p.name),
|
|
})
|
|
must(err)
|
|
return id
|
|
}
|
|
|
|
// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST
|
|
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
|
|
// and falling HR within each REST lap (recovery).
|
|
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activityID int64) {
|
|
var laps []store.Lap
|
|
var samples []store.Sample
|
|
elapsed := 0.0
|
|
baseHR := 140.0
|
|
|
|
for i := 0; i < 6; i++ {
|
|
isActive := i%2 == 0
|
|
lapDuration := 180.0
|
|
intensity := "REST"
|
|
if isActive {
|
|
intensity = "ACTIVE"
|
|
}
|
|
|
|
var lapSamples []classify.SampleInfo
|
|
for s := 0.0; s < lapDuration; s += 5 {
|
|
var hr float64
|
|
if isActive {
|
|
hr = baseHR + s/10 // rising through the work interval
|
|
} else {
|
|
hr = baseHR + 20 - s/8 // falling through the rest interval
|
|
}
|
|
samples = append(samples, store.Sample{
|
|
ElapsedSeconds: elapsed + s,
|
|
TimestampMs: int64((elapsed + s) * 1000),
|
|
HeartRate: &hr,
|
|
})
|
|
lapSamples = append(lapSamples, classify.SampleInfo{ElapsedSeconds: elapsed + s, HeartRate: &hr})
|
|
}
|
|
|
|
var drift, recovery *float64
|
|
if isActive {
|
|
if v, ok := classify.HRDrift(lapSamples); ok {
|
|
drift = &v
|
|
}
|
|
} else {
|
|
if v, ok := classify.HRRecovery(lapSamples); ok {
|
|
recovery = &v
|
|
}
|
|
}
|
|
|
|
laps = append(laps, store.Lap{
|
|
LapIndex: i + 1,
|
|
IntensityType: intensity,
|
|
// DurationSeconds/AvgHR aren't stored columns anymore -- the API
|
|
// decodes them from RawJSON, so seeded laps need them here to
|
|
// still show a sensible duration/HR on the dev chart.
|
|
RawJSON: fmt.Sprintf(`{"duration":%v,"distance":400,"averageHR":%v}`, lapDuration, baseHR),
|
|
HRDriftBpmPerMin: drift,
|
|
HRRecoveryBpmPerMin: recovery,
|
|
})
|
|
elapsed += lapDuration
|
|
}
|
|
|
|
must(db.ReplaceActivitySamples(ctx, userID, activityID, samples))
|
|
must(db.ReplaceLaps(ctx, userID, activityID, laps))
|
|
}
|
|
|
|
func must(err error) {
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
|
|
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
|
must(err)
|
|
for _, k := range kinds {
|
|
if k.Name == name {
|
|
return k.ID
|
|
}
|
|
}
|
|
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name)
|
|
return 0
|
|
}
|