diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-06 16:40:17 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-06 16:40:17 +0200 |
| commit | db4d2b40895db41ce188549f49a6f277ba95c890 (patch) | |
| tree | 0f9ff048726f6ec030606041eebe808b462ebe7d | |
| parent | 8e0c55230ef59869bda1ca19ca8697b447d57698 (diff) | |
Split cli.go into per-domain command files
Move each CLI command into its owning package (mountain_pass, ride, web)
with dispatch and usage in main.go; remove the cli package.
| -rw-r--r-- | cli/cli_test.go | 55 | ||||
| -rw-r--r-- | main.go | 64 | ||||
| -rw-r--r-- | mountain_pass/cli.go | 39 | ||||
| -rw-r--r-- | ride/cli.go (renamed from cli/cli.go) | 253 | ||||
| -rw-r--r-- | ride/cli_test.go | 54 | ||||
| -rw-r--r-- | web/cli.go | 18 |
6 files changed, 256 insertions, 227 deletions
diff --git a/cli/cli_test.go b/cli/cli_test.go deleted file mode 100644 index e12ff7a..0000000 --- a/cli/cli_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/martinlehoux/biking_home/ride" - "github.com/stretchr/testify/assert" -) - -func TestBacktestEligibilityExcludesShortDistances(t *testing.T) { - short := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - minimum := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - - assert.True(t, isBacktestEligible(short)) - assert.True(t, isBacktestEligible(minimum)) -} - -func TestBacktestEligibilityExcludesLowMovingRatio(t *testing.T) { - low := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - boundary := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - noElapsed := ride.StoredRide{FitPath: "ride.fit", DistanceM: 10_000, ExternalMovingTimeS: 1, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - - assert.True(t, isBacktestEligible(low)) - assert.True(t, isBacktestEligible(boundary)) - assert.False(t, isBacktestEligible(noElapsed)) -} - -func TestBacktestEligibilityExcludesUncategorisedAndIndoorTransport(t *testing.T) { - base := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTraining} - uncategorised := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} - indoor := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeIndoor} - transport := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeRoad, RideType: ride.RideTypeTransport} - race := ride.StoredRide{FitPath: "ride.fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: ride.BikeTypeMTB, RideType: ride.RideTypeRace} - - assert.True(t, isBacktestEligible(base)) - assert.False(t, isBacktestEligible(uncategorised)) - assert.False(t, isBacktestEligible(indoor)) - assert.False(t, isBacktestEligible(transport)) - assert.True(t, isBacktestEligible(race)) -} - -func TestBacktestStatsUseUnroundedSamples(t *testing.T) { - absErrors := []float64{0, 0.1, 0.1} - - assert.InDelta(t, 1.0/15, meanAbsErr(absErrors), 1e-12) - assert.InDelta(t, 1.0/30, stdErrAbsErr(absErrors), 1e-12) -} - -func TestErrorDistributionUsesNearestRank(t *testing.T) { - distribution := errorDistribution([]float64{1, 2, 3, 4}, []float64{50, 75, 99}) - - assert.Equal(t, 2.0, distribution[50]) - assert.Equal(t, 3.0, distribution[75]) - assert.Equal(t, 4.0, distribution[99]) -} @@ -3,11 +3,15 @@ package main import ( "database/sql" "flag" + "fmt" "log/slog" + "os" "github.com/grafana/pyroscope-go" - "github.com/martinlehoux/biking_home/cli" "github.com/martinlehoux/biking_home/config" + "github.com/martinlehoux/biking_home/mountain_pass" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/web" "github.com/martinlehoux/kagamigo/kcore" _ "github.com/mattn/go-sqlite3" ) @@ -31,7 +35,63 @@ func main() { db, err := sql.Open("sqlite3", appConfig.Database.Path) kcore.Expect(err, "failed to open database") defer db.Close() - cli.Run(db, *configFile, appConfig, flag.Args(), *enablePprof) + runCommand(db, *configFile, appConfig, flag.Args(), *enablePprof) +} + +func runCommand(db *sql.DB, configPath string, appConfig config.Config, args []string, enablePprof bool) { + if len(args) == 0 { + web.ServeCmd(db, configPath, appConfig, enablePprof) + return + } + + switch args[0] { + case "fetch-osm": + mountain_pass.FetchOSM() + case "extract-osm": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "extract-osm requires a PBF file path argument") + printUsage() + os.Exit(2) + } + mountain_pass.ExtractOSM(db, args[1]) + case "backfill": + ride.BackfillCmd(db) + case "backtest": + ride.BacktestCmd(db) + case "elevation": + ride.ElevationCmd(db, args[1:]) + case "serve": + for _, arg := range args[1:] { + if arg == "--pprof" || arg == "-pprof" || arg == "pprof" { + enablePprof = true + } + } + web.ServeCmd(db, configPath, appConfig, enablePprof) + case "help", "-h", "--help": + printUsage() + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", args[0]) + printUsage() + os.Exit(2) + } +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `Usage: biking_home [global flags] <command> [args] + +Global flags: + -config <path> path to YAML configuration file (default "config.yaml") + -pprof expose pprof endpoints at /debug/pprof (also: "serve --pprof") + +Commands: + serve start web server (default when no command given) + fetch-osm download the France OSM PBF into france-latest.osm.pbf + extract-osm <path> extract mountain passes from an OSM PBF file + backfill recompute all stored ride values + backtest run data benchmarks comparing computed values against stored reference values + elevation analyse detect elevation outliers (tunnel spikes) + elevation fix linearly fix elevation between anchors +`) } // startPyroscope starts the continuous profiler when enabled in the config and diff --git a/mountain_pass/cli.go b/mountain_pass/cli.go new file mode 100644 index 0000000..ef8dfda --- /dev/null +++ b/mountain_pass/cli.go @@ -0,0 +1,39 @@ +package mountain_pass + +import ( + "context" + "database/sql" + "os" + + "github.com/martinlehoux/kagamigo/kcore" + "github.com/schollz/progressbar/v3" +) + +// ProgressBarEnabled reports whether stderr is attached to a terminal, so the +// progress bar only renders for interactive runs and stays out of piped or +// redirected output. +func ProgressBarEnabled() bool { + fi, err := os.Stderr.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 +} + +func FetchOSM() { + err := FetchFrancePBF("france-latest.osm.pbf") + kcore.Expect(err, "failed to download France OSM PBF") +} + +func ExtractOSM(db *sql.DB, pbfPath string) { + var bar *progressbar.ProgressBar + _, err := ExtractMountainPasses(context.Background(), pbfPath, db, func(current, total int64) { + if bar == nil && ProgressBarEnabled() { + bar = progressbar.DefaultBytes(total, "Extracting mountain passes") + } + if bar != nil { + bar.Set64(current) + } + }) + if bar != nil { + bar.Finish() + } + kcore.Expect(err, "failed to extract mountain passes from OSM") +} @@ -1,7 +1,6 @@ -package cli +package ride import ( - "context" "database/sql" "encoding/json" "errors" @@ -16,129 +15,26 @@ import ( "time" "github.com/martinlehoux/biking_home/climb" - "github.com/martinlehoux/biking_home/config" "github.com/martinlehoux/biking_home/mountain_pass" - "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/web" "github.com/martinlehoux/kagamigo/kcore" "github.com/schollz/progressbar/v3" ) -func Run(db *sql.DB, configPath string, appConfig config.Config, args []string, enablePprof bool) { - if len(args) == 0 { - runServer(db, configPath, appConfig, enablePprof) - return - } - - switch args[0] { - case "fetch-osm": - err := mountain_pass.FetchFrancePBF("france-latest.osm.pbf") - kcore.Expect(err, "failed to download France OSM PBF") - case "extract-osm": - if len(args) < 2 { - fmt.Fprintln(os.Stderr, "extract-osm requires a PBF file path argument") - printUsage() - os.Exit(2) - } - pbfPath := args[1] - enabled := progressBarEnabled() - var bar *progressbar.ProgressBar - _, err := mountain_pass.ExtractMountainPasses(context.Background(), pbfPath, db, func(current, total int64) { - if bar == nil && enabled { - bar = progressbar.DefaultBytes(total, "Extracting mountain passes") - } - if bar != nil { - bar.Set64(current) - } - }) - if bar != nil { - bar.Finish() +func BackfillCmd(db *sql.DB) { + var bar *progressbar.ProgressBar + count, err := Backfill(db, func(current, total int) { + if bar == nil && mountain_pass.ProgressBarEnabled() { + bar = progressbar.Default(int64(total), "Backfilling ride values") } - kcore.Expect(err, "failed to extract mountain passes from OSM") - case "backfill": - enabled := progressBarEnabled() - var bar *progressbar.ProgressBar - count, err := ride.Backfill(db, func(current, total int) { - if bar == nil && enabled { - bar = progressbar.Default(int64(total), "Backfilling ride values") - } - if bar != nil { - bar.Set(current) - } - }) if bar != nil { - bar.Finish() - } - kcore.Expect(err, "failed to backfill ride values") - slog.Info("Backfilled ride values", "rides", count) - case "backtest": - kcore.Expect(runBacktest(db), "failed to run backtest") - case "elevation": - if len(args) < 2 { - fmt.Fprintln(os.Stderr, "elevation requires subcommand: analyse | fix") - printElevationUsage() - os.Exit(2) + bar.Set(current) } - switch args[1] { - case "analyse", "analyze": - kcore.Expect(runElevationAnalyse(db, args[2:]), "elevation analyse failed") - case "fix": - kcore.Expect(runElevationFix(db, args[2:]), "elevation fix failed") - default: - fmt.Fprintf(os.Stderr, "unknown elevation subcommand %q\n", args[1]) - printElevationUsage() - os.Exit(2) - } - case "serve": - pprof := enablePprof - for _, arg := range args[1:] { - if arg == "--pprof" || arg == "-pprof" || arg == "pprof" { - pprof = true - } - } - runServer(db, configPath, appConfig, pprof) - case "help", "-h", "--help": - printUsage() - default: - fmt.Fprintf(os.Stderr, "unknown command %q\n", args[0]) - printUsage() - os.Exit(2) - } -} - -func printUsage() { - fmt.Fprintf(os.Stderr, `Usage: biking_home [global flags] <command> [args] - -Global flags: - -config <path> path to YAML configuration file (default "config.yaml") - -pprof expose pprof endpoints at /debug/pprof (also: "serve --pprof") - -Commands: - serve start web server (default when no command given) - fetch-osm download the France OSM PBF into france-latest.osm.pbf - extract-osm <path> extract mountain passes from an OSM PBF file - backfill recompute all stored ride values - backtest run data benchmarks comparing computed values against stored reference values - elevation analyse detect elevation outliers (tunnel spikes) - elevation fix linearly fix elevation between anchors -`) -} - -// progressBarEnabled reports whether stderr is attached to a terminal, so the -// progress bar only renders for interactive runs and stays out of piped or -// redirected output. -func progressBarEnabled() bool { - fi, err := os.Stderr.Stat() - return err == nil && fi.Mode()&os.ModeCharDevice != 0 -} - -func runServer(db *sql.DB, configPath string, appConfig config.Config, enablePprof bool) { - server := web.NewServer(db, configPath).WithPprof(enablePprof) - if enablePprof { - slog.Info("pprof enabled", "endpoint", appConfig.Server.PublicURL+"/debug/pprof/") + }) + if bar != nil { + bar.Finish() } - slog.Info("Starting web server", "address", appConfig.Server.PublicURL) - kcore.Expect(server.ListenAndServe(appConfig.Server.Address), "web server stopped") + kcore.Expect(err, "failed to backfill ride values") + slog.Info("Backfilled ride values", "rides", count) } type backtestSample struct { @@ -149,14 +45,18 @@ type backtestSample struct { relErrorPct float64 } +func BacktestCmd(db *sql.DB) { + kcore.Expect(runBacktest(db), "failed to run backtest") +} + func runBacktest(db *sql.DB) error { - items, err := ride.List(db) + items, err := List(db) if err != nil { return err } var samples []backtestSample var bar *progressbar.ProgressBar - if progressBarEnabled() { + if mountain_pass.ProgressBarEnabled() { bar = progressbar.Default(int64(len(items)), "Backtesting rides") defer bar.Finish() } @@ -167,16 +67,16 @@ func runBacktest(db *sql.DB) error { if !isBacktestEligible(item) { continue } - parsed, err := ride.ParseFile(ride.FITRideParser{}, item.FitPath) + parsed, err := ParseFile(FITRideParser{}, item.FitPath) if err != nil { - if errors.Is(err, ride.ErrNoTrackPoints) { + if errors.Is(err, ErrNoTrackPoints) { continue } slog.Warn("Failed to parse ride FIT", "ride", item.ExternalID, "file", item.FitPath, "error", err) continue } reference := time.Duration(item.ExternalMovingTimeS) * time.Second - computed := ride.MovingTime(parsed) + computed := MovingTime(parsed) samples = append(samples, backtestSample{ externalID: item.ExternalID, name: item.Name, @@ -216,14 +116,14 @@ func runBacktest(db *sql.DB) error { return nil } -func isBacktestEligible(item ride.StoredRide) bool { +func isBacktestEligible(item StoredRide) bool { if item.FitPath == "" || item.ExternalMovingTimeS <= 0 || item.ElapsedTimeS <= 0 { return false } if !item.IsCategorised() { return false } - if item.RideType == ride.RideTypeIndoor || item.RideType == ride.RideTypeTransport { + if item.RideType == RideTypeIndoor || item.RideType == RideTypeTransport { return false } return true @@ -261,6 +161,24 @@ func errorDistribution(absErrors []float64, percentiles []float64) map[float64]f return distribution } +func ElevationCmd(db *sql.DB, args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "elevation requires subcommand: analyse | fix") + printElevationUsage() + os.Exit(2) + } + switch args[0] { + case "analyse", "analyze": + kcore.Expect(runElevationAnalyse(db, args[1:]), "elevation analyse failed") + case "fix": + kcore.Expect(runElevationFix(db, args[1:]), "elevation fix failed") + default: + fmt.Fprintf(os.Stderr, "unknown elevation subcommand %q\n", args[0]) + printElevationUsage() + os.Exit(2) + } +} + func printElevationUsage() { fmt.Fprintf(os.Stderr, `Usage: biking_home elevation analyse <ride-id|all> [--json] [--threshold <grade>] @@ -276,18 +194,33 @@ Examples: `) } +// parseStoredRide parses the stored ride's route files, preferring FIT and +// falling back to GPX when FIT parsing fails or is missing. +func parseStoredRide(item StoredRide) (Ride, error) { + if item.FitPath == "" && item.GPXPath == "" { + return Ride{}, errors.New("no route files") + } + if item.FitPath != "" { + parsed, err := ParseFile(FITRideParser{}, item.FitPath) + if err == nil || item.GPXPath == "" { + return parsed, err + } + } + return ParseFile(GPXRideParser{}, item.GPXPath) +} + type elevationAnalyseResult struct { - RideID int64 `json:"rideId"` - Name string `json:"name"` - Distance float64 `json:"distanceM"` - Cotacol float64 `json:"cotacol"` - Zones []ride.FixZone `json:"zones"` + RideID int64 `json:"rideId"` + Name string `json:"name"` + Distance float64 `json:"distanceM"` + Cotacol float64 `json:"cotacol"` + Zones []FixZone `json:"zones"` } func runElevationAnalyse(db *sql.DB, args []string) error { var target string var jsonOut bool - var threshold float64 = ride.DefaultGradeThreshold + var threshold float64 = DefaultGradeThreshold for i := 0; i < len(args); i++ { switch args[i] { case "--json": @@ -316,9 +249,9 @@ func runElevationAnalyse(db *sql.DB, args []string) error { if target == "" { return errors.New("elevation analyse requires <ride-id|all>") } - var rides []ride.StoredRide + var rides []StoredRide if target == "all" { - all, err := ride.List(db) + all, err := List(db) if err != nil { return err } @@ -328,41 +261,29 @@ func runElevationAnalyse(db *sql.DB, args []string) error { if err != nil { return fmt.Errorf("invalid ride id %q", target) } - item, found, err := ride.GetByID(db, id) + item, found, err := GetByID(db, id) if err != nil { return err } if !found { return fmt.Errorf("ride %d not found", id) } - rides = []ride.StoredRide{item} + rides = []StoredRide{item} } results := make([]elevationAnalyseResult, 0, len(rides)) for _, item := range rides { - if item.FitPath == "" && item.GPXPath == "" { - continue - } - var parsed ride.Ride - var err error - if item.FitPath != "" { - parsed, err = ride.ParseFile(ride.FITRideParser{}, item.FitPath) - if err != nil && item.GPXPath != "" { - parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) - } - } else { - parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) - } + parsed, err := parseStoredRide(item) if err != nil { slog.Warn("Failed to parse ride for elevation analyse", "ride", item.ID, "error", err) continue } - outliers := ride.DetectElevationOutliers(parsed.Distances, parsed.Elevations, threshold) + outliers := DetectElevationOutliers(parsed.Distances, parsed.Elevations, threshold) if len(outliers) == 0 && target != "all" { // still report empty } else if len(outliers) == 0 { continue } - zones := ride.SuggestFixZones(outliers) + zones := SuggestFixZones(outliers) cotacol := parsed.Cotacol() results = append(results, elevationAnalyseResult{ RideID: item.ID, @@ -417,7 +338,7 @@ func runElevationFix(db *sql.DB, args []string) error { var fromRaw, toRaw string var auto bool var dryRun bool - var threshold float64 = ride.DefaultGradeThreshold + var threshold float64 = DefaultGradeThreshold for i := 0; i < len(args); i++ { switch args[i] { case "--auto": @@ -470,7 +391,7 @@ func runElevationFix(db *sql.DB, args []string) error { if err != nil { return fmt.Errorf("invalid ride id %q", rideIDRaw) } - item, found, err := ride.GetByID(db, id) + item, found, err := GetByID(db, id) if err != nil { return err } @@ -480,40 +401,32 @@ func runElevationFix(db *sql.DB, args []string) error { if item.FitPath == "" && item.GPXPath == "" { return fmt.Errorf("ride %d has no route files", id) } - var parsed ride.Ride - if item.FitPath != "" { - parsed, err = ride.ParseFile(ride.FITRideParser{}, item.FitPath) - if err != nil && item.GPXPath != "" { - parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) - } - } else { - parsed, err = ride.ParseFile(ride.GPXRideParser{}, item.GPXPath) - } + parsed, err := parseStoredRide(item) if err != nil { return fmt.Errorf("parse ride %d: %w", id, err) } before := parsed.Cotacol() - var zones []ride.FixZone + var zones []FixZone if auto { - outliers := ride.DetectElevationOutliers(parsed.Distances, parsed.Elevations, threshold) + outliers := DetectElevationOutliers(parsed.Distances, parsed.Elevations, threshold) if len(outliers) == 0 { fmt.Printf("Ride %d: no outliers detected (threshold %.0f%%)\n", id, threshold*100) return nil } - zones = ride.SuggestFixZones(outliers) + zones = SuggestFixZones(outliers) } else { - fromIdx, err := ride.ResolveIndex(parsed.Distances, fromRaw) + fromIdx, err := ResolveIndex(parsed.Distances, fromRaw) if err != nil { return fmt.Errorf("invalid --from: %w", err) } - toIdx, err := ride.ResolveIndex(parsed.Distances, toRaw) + toIdx, err := ResolveIndex(parsed.Distances, toRaw) if err != nil { return fmt.Errorf("invalid --to: %w", err) } if fromIdx >= toIdx { return errors.New("--from must be before --to") } - zones = []ride.FixZone{{ + zones = []FixZone{{ StartIdx: fromIdx, EndIdx: toIdx, StartKm: parsed.Distances[fromIdx] / 1000, @@ -529,13 +442,13 @@ func runElevationFix(db *sql.DB, args []string) error { // Preview cotacol after fix elevCopy := make([]float64, len(parsed.Elevations)) copy(elevCopy, parsed.Elevations) - ride.LinearFix(parsed.Distances, elevCopy, zones) + LinearFix(parsed.Distances, elevCopy, zones) afterPreview := climb.Cotacol(parsed.Distances, elevCopy) fmt.Printf(" cotacol preview: %.1f -> %.1f (Δ %.1f)\n", before, afterPreview, afterPreview-before) return nil } slog.Info("Fixing elevation", "ride", id, "zones", len(zones)) - if err := ride.FixRideFiles(parsed, zones, item.GPXPath, item.FitPath); err != nil { + if err := FixRideFiles(parsed, zones, item.GPXPath, item.FitPath); err != nil { return err } // Re-parse to compute new cotacol and persist via Save (recomputes stored values from files). @@ -548,13 +461,13 @@ func runElevationFix(db *sql.DB, args []string) error { updated.FitPath = "" } } - if err := ride.Save(db, updated); err != nil { + if err := Save(db, updated); err != nil { return fmt.Errorf("save ride %d: %w", id, err) } // Report - reparsed, _ := ride.ParseFile(ride.FITRideParser{}, updated.FitPath) + reparsed, _ := ParseFile(FITRideParser{}, updated.FitPath) if reparsed.Len() == 0 && updated.GPXPath != "" { - reparsed, _ = ride.ParseFile(ride.GPXRideParser{}, updated.GPXPath) + reparsed, _ = ParseFile(GPXRideParser{}, updated.GPXPath) } after := 0.0 if reparsed.Len() > 0 { diff --git a/ride/cli_test.go b/ride/cli_test.go new file mode 100644 index 0000000..7272836 --- /dev/null +++ b/ride/cli_test.go @@ -0,0 +1,54 @@ +package ride + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBacktestEligibilityExcludesShortDistances(t *testing.T) { + short := StoredRide{FitPath: "fit", DistanceM: 10_000 - 1, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining} + minimum := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining} + + assert.True(t, isBacktestEligible(short)) + assert.True(t, isBacktestEligible(minimum)) +} + +func TestBacktestEligibilityExcludesLowMovingRatio(t *testing.T) { + low := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 7_999, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining} + boundary := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining} + noElapsed := StoredRide{FitPath: "fit", DistanceM: 10_000, ExternalMovingTimeS: 1, BikeType: BikeTypeRoad, RideType: RideTypeTraining} + + assert.True(t, isBacktestEligible(low)) + assert.True(t, isBacktestEligible(boundary)) + assert.False(t, isBacktestEligible(noElapsed)) +} + +func TestBacktestEligibilityExcludesUncategorisedAndIndoorTransport(t *testing.T) { + base := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTraining} + uncategorised := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000} + indoor := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeIndoor} + transport := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeRoad, RideType: RideTypeTransport} + race := StoredRide{FitPath: "fit", ExternalMovingTimeS: 8_000, ElapsedTimeS: 10_000, BikeType: BikeTypeMTB, RideType: RideTypeRace} + + assert.True(t, isBacktestEligible(base)) + assert.False(t, isBacktestEligible(uncategorised)) + assert.False(t, isBacktestEligible(indoor)) + assert.False(t, isBacktestEligible(transport)) + assert.True(t, isBacktestEligible(race)) +} + +func TestBacktestStatsUseUnroundedSamples(t *testing.T) { + absErrors := []float64{0, 0.1, 0.1} + + assert.InDelta(t, 1.0/15, meanAbsErr(absErrors), 1e-12) + assert.InDelta(t, 1.0/30, stdErrAbsErr(absErrors), 1e-12) +} + +func TestErrorDistributionUsesNearestRank(t *testing.T) { + distribution := errorDistribution([]float64{1, 2, 3, 4}, []float64{50, 75, 99}) + + assert.Equal(t, 2.0, distribution[50]) + assert.Equal(t, 3.0, distribution[75]) + assert.Equal(t, 4.0, distribution[99]) +} diff --git a/web/cli.go b/web/cli.go new file mode 100644 index 0000000..f925a95 --- /dev/null +++ b/web/cli.go @@ -0,0 +1,18 @@ +package web + +import ( + "database/sql" + "log/slog" + + "github.com/martinlehoux/biking_home/config" + "github.com/martinlehoux/kagamigo/kcore" +) + +func ServeCmd(db *sql.DB, configPath string, appConfig config.Config, enablePprof bool) { + server := NewServer(db, configPath).WithPprof(enablePprof) + if enablePprof { + slog.Info("pprof enabled", "endpoint", appConfig.Server.PublicURL+"/debug/pprof/") + } + slog.Info("Starting web server", "address", appConfig.Server.PublicURL) + kcore.Expect(server.ListenAndServe(appConfig.Server.Address), "web server stopped") +} |
