Files
geniusrun/backend/cmd/seedsample/main.go

222 lines
6.8 KiB
Go
Raw Normal View History

// 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"
"smartrun/backend/internal/classify"
"smartrun/backend/internal/garmin/mock"
"smartrun/backend/internal/store"
appsync "smartrun/backend/internal/sync"
)
func main() {
dbPath := flag.String("db", "smartrun_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()
// Easy 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.
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{
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,
})
must(err)
tempoID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{
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,
})
must(err)
_, err = db.CreateWorkoutKind(ctx, store.WorkoutKind{
Name: "Interval", Description: "Structured work/rest intervals", Color: "#ef4444",
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
IsActive: true,
})
must(err)
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6, MaxHR: 190}, 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, 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, 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, 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, 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, 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, p seedParams) int64 {
speed := p.speedMps
hr := p.avgHR
aerobic := p.aerobicTE
anaerobic := p.anaerobicTE
id, err := db.UpsertActivity(ctx, store.Activity{
GarminActivityID: p.garminID,
ActivityName: p.name,
ActivityType: "running",
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
BeginTimestampMs: p.start.UnixMilli(),
DurationSeconds: p.duration,
DistanceMeters: p.distance,
AvgSpeedMps: &speed,
AvgHR: &hr,
AerobicTrainingEffect: &aerobic,
AnaerobicTrainingEffect: &anaerobic,
RawJSON: "{}",
})
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, 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, DurationSeconds: lapDuration,
DistanceMeters: 400, IntensityType: intensity, RawJSON: "{}",
HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery,
})
elapsed += lapDuration
}
must(db.ReplaceActivitySamples(ctx, activityID, samples))
must(db.ReplaceLaps(ctx, activityID, laps))
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}