aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cli/cli_test.go55
-rw-r--r--main.go64
-rw-r--r--mountain_pass/cli.go39
-rw-r--r--ride/cli.go (renamed from cli/cli.go)253
-rw-r--r--ride/cli_test.go54
-rw-r--r--web/cli.go18
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])
-}
diff --git a/main.go b/main.go
index 397b3d6..93bfcd6 100644
--- a/main.go
+++ b/main.go
@@ -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")
+}
diff --git a/cli/cli.go b/ride/cli.go
index 66ebb98..a460260 100644
--- a/cli/cli.go
+++ b/ride/cli.go
@@ -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")
+}