Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
262 lines
8.6 KiB
Go
262 lines
8.6 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/slog"
|
|
"os"
|
|
"time"
|
|
|
|
"geniusrun/backend/internal/classify"
|
|
"geniusrun/backend/internal/garmin"
|
|
applog "geniusrun/backend/internal/log"
|
|
"geniusrun/backend/internal/store"
|
|
)
|
|
|
|
func main() {
|
|
slog.SetDefault(applog.NewLogger("info", os.Stdout))
|
|
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 {
|
|
fatal("open db", 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 {
|
|
fatal("seedsample", err)
|
|
}
|
|
}
|
|
|
|
// fatal logs through the application JSON logger and exits -- the
|
|
// structured replacement for log.Fatal.
|
|
func fatal(msg string, err error) {
|
|
applog.App("main", "fatal").Error(msg, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
applog.App("main", "mustFindKindID").Error("no workout kind with that name for the seeded user (did ProvisionUser seed the taxonomy?)", "kind", name)
|
|
os.Exit(1)
|
|
return 0
|
|
}
|