Files
geniusrun/backend/cmd/seedsample/main.go
Christophe Vila 6effb79097 refactor(log): unified schema — mandatory type/class/method, request_id removed
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>
2026-08-04 19:45:30 +02:00

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
}