From 90614736f2a0c556f31eca5361b3c032f5f309f6 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 24 Sep 2026 08:37:33 +0200 Subject: Match official climbs against their recorded sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An official climb matched on endpoint coordinates alone. On a loop ridden the other way the route meets the climb's top before its bottom, so pairing each start with the first end inside its lap window credited a 5.9 km passage as the 850 m Chemin de la Tour Pey Blanc, and a 49 km one on another ride. Climbs now keep the sample of the segment they were created from: the recorded length and elevation gain of the selected route points, captured by the ride detail and preview handlers and stored NOT NULL. Matching takes the whole route and drops any candidate outside ±50% of that sample on either axis, and attempts are refreshed by upsert and prune so re-analyzing a ride keeps the recorded-at stamp of the passages that survive. The backfill leaves the web package: backfill.Run recomputes ride values then official climb attempts as a single `go run . backfill` command (climbs-backfill is gone), rideanalysis owns the attempt snapshot, and the mountain-pass loading margin is shared. Applied to the stored rides it stamps 78 samples, drops the four bogus attempts and leaves ride 800 with its three Pont Rout passages. --- README.md | 17 ++- backfill/backfill.go | 135 +++++++++++++++++++++ climb/route.go | 17 +++ .../20260924000000_official_climb_sample.sql | 7 ++ ...260925000000_official_climb_sample_not_null.sql | 38 ++++++ db/schema.sql | 51 +++++--- main.go | 8 +- official_climb/attempt.go | 41 ++++--- official_climb/attempt_test.go | 36 +++++- official_climb/list_test.go | 4 +- official_climb/matcher.go | 42 ++++--- official_climb/model.go | 10 +- official_climb/official_climb_test.go | 128 +++++++++++++++++-- official_climb/repository.go | 10 +- rideanalysis/analysis.go | 10 +- rideanalysis/attempts.go | 29 +++++ rideanalysis/attempts_test.go | 46 +++++++ web/climb-backfill.go | 81 ------------- web/climbs.go | 10 +- web/climbs_test.go | 7 +- web/frontend/ride-detail-boundaries.ts | 26 +++- web/frontend/ride-detail-logic.test.ts | 16 +++ web/frontend/ride-detail-logic.ts | 32 +++-- web/frontend/types.d.ts | 5 + web/official-climbs.go | 43 +------ web/official-climbs_test.go | 28 +---- web/preview.go | 25 +++- web/rides.go | 7 +- web/server_test.go | 27 +++-- web/templates.templ | 8 ++ 30 files changed, 673 insertions(+), 271 deletions(-) create mode 100644 backfill/backfill.go create mode 100644 db/migrations/20260924000000_official_climb_sample.sql create mode 100644 db/migrations/20260925000000_official_climb_sample_not_null.sql create mode 100644 rideanalysis/attempts.go create mode 100644 rideanalysis/attempts_test.go delete mode 100644 web/climb-backfill.go diff --git a/README.md b/README.md index 3491395..69206ad 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ mise run build mise run dev-watch # Start the web server on http://localhost:8080 go run . fetch-osm # Download the France OSM PBF (resumable) go run . extract-osm france-latest.osm.pbf # Extract mountain passes from a France OSM PBF -go run . backfill # Recompute and persist all ride computed values +go run . backfill # Recompute ride values and official climb attempts for every stored ride go run . backtest # Benchmark computed vs stored ride values go run . -- -pprof # Expose pprof endpoints at `/debug/pprof` docker compose up -d pyroscope # Start the local Pyroscope server on http://localhost:4040 @@ -65,13 +65,14 @@ An OSM way is an ordered list of nodes; its `highway` tag describes the road typ - `geo` — coordinate distance, nearest-point, and shared `Interval` primitives; dependency-free - `climb` — climb detection and scoring. A `Route` is the geometry-only trace (distances, elevations, coords), and `Detect` returns `[]DetectedClimb` (a climb plus its ride-relative start index). Provides slope and Cotacol difficulty scoring. Deliberately decoupled from `ride` - `ride` — GPX parsing and the ride value (embeds `climb.Route` for geometry, adding timestamps and telemetry), moving-time metrics, and SQLite persistence for imported ride metadata and versioned computed values -- `official_climb` — matches official climbs against a route by endpoint coordinates +- `official_climb` — matches official climbs against a route by endpoint coordinates and the recorded sample of the segment the climb was created from: a candidate passage outside ±50% of that sample's length or elevation gain is not the climb - `mountain_pass` — OSM PBF extraction (`mountain_pass=yes` / `natural=mountain_pass` nodes), pass storage and querying, and crossings/climb matching on `climb.Climb` -- `rideanalysis` — orchestrates a ride into analyzed climbs and mountain-pass crossings +- `rideanalysis` — orchestrates a ride into analyzed climbs and mountain-pass crossings, and snapshots its official-climb attempts - `strava` — OAuth2 client returning activity metadata and GPX data - `config` — typed YAML configuration and atomic persistence - `web` — HTTP server, OAuth callback, sync orchestration, and templ pages -- `cli` — command handlers for data import, backfill, and benchmarking +- `backfill` — recomputes the data derived from stored rides: ride values first, then official climb attempts. The migration tool behind `go run . backfill` +- `cli` — command entry points: `main.go` dispatches, and command bodies live with the packages they drive (`ride` import/benchmarks, `backfill`, `mountain_pass` OSM) - test builders `internal/routetest`, `internal/ridetest`, `internal/dbtest` ```mermaid @@ -80,6 +81,7 @@ flowchart TB main["main (bootstrap: main.go)"] cli["cli (import, backfill, benchmark)"] + backfill["backfill (ride values, climb attempts)"] web["web (HTTP + templ, sync)"] rideanalysis["rideanalysis (climbs + crossings)"] ride["ride (GPX parsing, Ride value, metrics, persistence)"] @@ -96,6 +98,13 @@ flowchart TB cli --> ride cli --> mpass cli --> config + cli --> backfill + + backfill --> ride + backfill --> rideanalysis + backfill --> official_climb + backfill --> mpass + backfill --> config web --> rideanalysis web --> strava diff --git a/backfill/backfill.go b/backfill/backfill.go new file mode 100644 index 0000000..4cf934c --- /dev/null +++ b/backfill/backfill.go @@ -0,0 +1,135 @@ +// Package backfill recomputes the data derived from stored rides: ride values and official climb attempts. +package backfill + +import ( + "database/sql" + "fmt" + "log/slog" + "slices" + + "github.com/martinlehoux/biking_home/config" + "github.com/martinlehoux/biking_home/mountain_pass" + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/martinlehoux/kagamigo/kcore" + "github.com/schollz/progressbar/v3" +) + +// Run recomputes ride values, then official climb attempts. +func Run(db *sql.DB, configPath string) { + ride.BackfillCmd(db) + var bar *progressbar.ProgressBar + count, err := ClimbAttempts(db, configPath, func(current, total int) { + if bar == nil && mountain_pass.ProgressBarEnabled() { + bar = progressbar.Default(int64(total), "Backfilling climb attempts") + } + if bar != nil { + bar.Set(current) + } + }) + kcore.Expect(err, "failed to backfill climb attempts") + slog.Info("Backfilled climb attempts", "rides", count) +} + +// ClimbAttempts re-records the official climb attempts of every route-bearing ride and flags the climbs the sample band emptied. +func ClimbAttempts(db *sql.DB, configPath string, reporters ...func(current, total int)) (int, error) { + appConfig, err := config.Load(configPath) + if err != nil { + return 0, err + } + matchPolicy := official_climb.DefaultMatchPolicy() + matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM + before, err := climbAttemptCounts(db) + if err != nil { + return 0, err + } + items, err := ride.List(db) + if err != nil { + return 0, err + } + officialClimbs, err := official_climb.List(db) + if err != nil { + return 0, err + } + report := func(int, int) {} + if len(reporters) > 0 { + report = reporters[0] + } + backfilled := 0 + for i, item := range items { + report(i+1, len(items)) + if item.FitPath == "" && item.GPXPath == "" { + continue + } + parsed, err := ride.ParseStoredRide(item) + if err != nil { + slog.Warn("Failed to parse ride for climb backfill", "ride_id", item.ID, "error", err) + continue + } + passes, err := mountain_pass.LoadMountainPassesAroundRide(db, parsed.Coords, rideanalysis.PassMarginM) + if err != nil { + slog.Warn("Failed to load passes for climb backfill", "ride_id", item.ID, "error", err) + } + analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) + attempts := rideanalysis.Attempts(item.ID, analysis, parsed) + if err := official_climb.ReplaceRideAttempts(db, item.ID, attempts); err != nil { + slog.Warn("Failed to record climb attempts", "ride_id", item.ID, "error", err) + continue + } + backfilled++ + } + if err := reportAttemptDrops(db, before); err != nil { + return backfilled, err + } + return backfilled, nil +} + +// climbAttemptCounts returns the number of recorded attempts per climb. +func climbAttemptCounts(db *sql.DB) (map[int64]int, error) { + rows, err := db.Query("SELECT climb_id, COUNT(*) FROM ride_official_climb_attempts GROUP BY climb_id") + if err != nil { + return nil, fmt.Errorf("count climb attempts: %w", err) + } + defer rows.Close() + counts := make(map[int64]int) + for rows.Next() { + var ( + climbID int64 + count int + ) + if err := rows.Scan(&climbID, &count); err != nil { + return nil, fmt.Errorf("scan climb attempt count: %w", err) + } + counts[climbID] = count + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate climb attempt counts: %w", err) + } + return counts, nil +} + +// reportAttemptDrops flags the climbs that lost attempts to the sample band; one keeping less than half needs review. +func reportAttemptDrops(db *sql.DB, before map[int64]int) error { + after, err := climbAttemptCounts(db) + if err != nil { + return err + } + climbIDs := make([]int64, 0, len(before)) + for climbID := range before { + climbIDs = append(climbIDs, climbID) + } + slices.Sort(climbIDs) + for _, climbID := range climbIDs { + previous, kept := before[climbID], after[climbID] + if kept >= previous { + continue + } + if kept*2 < previous { + slog.Warn("Climb kept less than half of its attempts; review its sample", "climb_id", climbID, "before", previous, "after", kept) + continue + } + slog.Info("Climb attempts dropped outside the sample band", "climb_id", climbID, "before", previous, "after", kept) + } + return nil +} diff --git a/climb/route.go b/climb/route.go index 80a9a43..4ef3465 100644 --- a/climb/route.go +++ b/climb/route.go @@ -7,3 +7,20 @@ type Route struct { Elevations []float64 Coords []geodist.Coord } + +// SegmentSample is a recorded passage: the length and elevation gain ridden between two route points. +type SegmentSample struct { + DistanceM float64 + ElevationGainM float64 +} + +// Sample returns the recorded length and elevation gain of the route slice between start and end (inclusive), counting only upward steps in the gain. +func (route Route) Sample(start, end int) SegmentSample { + sample := SegmentSample{DistanceM: route.Distances[end] - route.Distances[start]} + for i := start + 1; i <= end; i++ { + if diff := route.Elevations[i] - route.Elevations[i-1]; diff > 0 { + sample.ElevationGainM += diff + } + } + return sample +} diff --git a/db/migrations/20260924000000_official_climb_sample.sql b/db/migrations/20260924000000_official_climb_sample.sql new file mode 100644 index 0000000..6a3990f --- /dev/null +++ b/db/migrations/20260924000000_official_climb_sample.sql @@ -0,0 +1,7 @@ +-- migrate:up +ALTER TABLE official_climbs ADD COLUMN sample_distance_m real; +ALTER TABLE official_climbs ADD COLUMN sample_elevation_gain_m real; + +-- migrate:down +ALTER TABLE official_climbs DROP COLUMN sample_distance_m; +ALTER TABLE official_climbs DROP COLUMN sample_elevation_gain_m; diff --git a/db/migrations/20260925000000_official_climb_sample_not_null.sql b/db/migrations/20260925000000_official_climb_sample_not_null.sql new file mode 100644 index 0000000..a3f80c2 --- /dev/null +++ b/db/migrations/20260925000000_official_climb_sample_not_null.sql @@ -0,0 +1,38 @@ +-- Every climb carries its segment sample, so the columns stop being optional; SQLite cannot alter nullability, hence the rebuild (it fails loudly on a missing sample). +-- migrate:up transaction:false +PRAGMA foreign_keys=OFF; +CREATE TABLE official_climbs_rebuild ( + id integer primary key, + name text not null, + start_latitude real not null, + start_longitude real not null, + end_latitude real not null, + end_longitude real not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + sample_distance_m real not null, + sample_elevation_gain_m real not null +); +INSERT INTO official_climbs_rebuild (id, name, start_latitude, start_longitude, end_latitude, end_longitude, created_at, updated_at, sample_distance_m, sample_elevation_gain_m) +SELECT id, name, start_latitude, start_longitude, end_latitude, end_longitude, created_at, updated_at, sample_distance_m, sample_elevation_gain_m FROM official_climbs; +DROP TABLE official_climbs; +ALTER TABLE official_climbs_rebuild RENAME TO official_climbs; + +-- migrate:down transaction:false +PRAGMA foreign_keys=OFF; +CREATE TABLE official_climbs_rebuild ( + id integer primary key, + name text not null, + start_latitude real not null, + start_longitude real not null, + end_latitude real not null, + end_longitude real not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + sample_distance_m real, + sample_elevation_gain_m real +); +INSERT INTO official_climbs_rebuild (id, name, start_latitude, start_longitude, end_latitude, end_longitude, created_at, updated_at, sample_distance_m, sample_elevation_gain_m) +SELECT id, name, start_latitude, start_longitude, end_latitude, end_longitude, created_at, updated_at, sample_distance_m, sample_elevation_gain_m FROM official_climbs; +DROP TABLE official_climbs; +ALTER TABLE official_climbs_rebuild RENAME TO official_climbs; diff --git a/db/schema.sql b/db/schema.sql index e149762..48bd02f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,5 +1,13 @@ CREATE TABLE IF NOT EXISTS "schema_migrations" (version varchar(255) primary key); -CREATE TABLE rides ( +CREATE TABLE IF NOT EXISTS "mountain_passes" ( + id integer primary key, + osm_id integer unique not null, + name text, + elevation integer, + latitude real not null, + longitude real not null +, category TEXT NOT NULL DEFAULT 'unknown' CHECK (category IN ('road', 'track', 'path', 'hiking', 'unknown'))); +CREATE TABLE IF NOT EXISTS "rides" ( id integer primary key, external_id text unique not null, gpx_path text not null, @@ -12,17 +20,14 @@ CREATE TABLE rides ( total_elevation_gain_m real not null, average_speed_mps real not null, created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -, cotacol_score real, cotacol_algo_version text, moving_time_s integer, moving_time_algo_version text, bike_type TEXT NOT NULL DEFAULT '' CHECK (bike_type IN ('', 'road', 'mtb')), ride_type TEXT NOT NULL DEFAULT '' CHECK (ride_type IN ('', 'training', 'indoor', 'race', 'transport', 'touring')), fit_path TEXT NOT NULL DEFAULT ''); -CREATE TABLE official_climbs ( - id integer primary key, - name text not null, - start_latitude real not null, - start_longitude real not null, - end_latitude real not null, - end_longitude real not null, - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + cotacol_score real, + cotacol_algo_version text, + moving_time_s integer, + moving_time_algo_version text, + bike_type TEXT NOT NULL DEFAULT '' CHECK (bike_type IN ('', 'road', 'mtb')), + ride_type TEXT NOT NULL DEFAULT '' CHECK (ride_type IN ('', 'training', 'indoor', 'race', 'transport', 'touring')), + fit_path TEXT NOT NULL DEFAULT '' ); CREATE TABLE ride_official_climb_attempts ( ride_id integer not null references rides(id), @@ -38,14 +43,18 @@ CREATE TABLE ride_official_climb_attempts ( created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), primary key (ride_id, climb_id, occurrence) ); -CREATE TABLE IF NOT EXISTS "mountain_passes" ( +CREATE TABLE IF NOT EXISTS "official_climbs" ( id integer primary key, - osm_id integer unique not null, - name text, - elevation integer, - latitude real not null, - longitude real not null -, category TEXT NOT NULL DEFAULT 'unknown' CHECK (category IN ('road', 'track', 'path', 'hiking', 'unknown'))); + name text not null, + start_latitude real not null, + start_longitude real not null, + end_latitude real not null, + end_longitude real not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + sample_distance_m real not null, + sample_elevation_gain_m real not null +); -- Dbmate schema migrations INSERT INTO "schema_migrations" (version) VALUES ('20250802140659'), @@ -60,4 +69,6 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260828000000'), ('20260829000000'), ('20260831000000'), - ('20260913000000'); + ('20260913000000'), + ('20260924000000'), + ('20260925000000'); diff --git a/main.go b/main.go index 8befddc..d475a90 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os" "github.com/grafana/pyroscope-go" + "github.com/martinlehoux/biking_home/backfill" "github.com/martinlehoux/biking_home/config" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/ride" @@ -55,9 +56,7 @@ func runCommand(db *sql.DB, configPath string, appConfig config.Config, args []s } mountain_pass.ExtractOSM(db, args[1]) case "backfill": - ride.BackfillCmd(db) - case "climbs-backfill": - web.BackfillOfficialClimbAttemptsCmd(db, configPath) + backfill.Run(db, configPath) case "backtest": ride.BacktestCmd(db) case "elevation": @@ -89,8 +88,7 @@ Commands: serve start web server (default when no command given) fetch-osm download the France OSM PBF into france-latest.osm.pbf extract-osm extract mountain passes from an OSM PBF file - backfill recompute all stored ride values - climbs-backfill record official climb attempts for every stored ride + backfill recompute stored ride values and record official climb attempts 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 diff --git a/official_climb/attempt.go b/official_climb/attempt.go index 43778f0..c340fea 100644 --- a/official_climb/attempt.go +++ b/official_climb/attempt.go @@ -3,14 +3,13 @@ package official_climb import ( "database/sql" "fmt" + "strings" "time" "github.com/martinlehoux/biking_home/climb" ) -// Attempt is one recorded passage of an official climb during one ride, -// identified by (rideID, climbID, occurrence). Scalar stats are snapshotted at -// recording time so climb listings never re-parse ride files. +// Attempt is one recorded passage of an official climb in a ride, keyed by (rideID, climbID, occurrence); stats are snapshotted so listings never re-parse ride files. type Attempt struct { RideID int64 ClimbID int64 @@ -27,14 +26,12 @@ type Attempt struct { const attemptColumns = "a.ride_id, a.climb_id, a.occurrence, a.distance_m, a.elevation_gain_m, a.slope_percent, a.cotacol, a.cotacol_algo_version, a.started_at, a.ended_at, a.created_at" -// AttemptsForClimb returns every recorded attempt of one official climb, -// oldest ride first. +// AttemptsForClimb returns every recorded attempt of one official climb, oldest ride first. func AttemptsForClimb(db *sql.DB, climbID int64) ([]Attempt, error) { return listAttempts(db, "climb_id = ?", climbID, "r.start_date, a.ride_id, a.occurrence") } -// AttemptsForRide returns every recorded attempt within one ride, in route -// order. +// AttemptsForRide returns every recorded attempt within one ride, in route order. func AttemptsForRide(db *sql.DB, rideID int64) ([]Attempt, error) { return listAttempts(db, "ride_id = ?", rideID, "a.occurrence") } @@ -64,29 +61,45 @@ func listAttempts(db *sql.DB, where string, arg any, order string) ([]Attempt, e return attempts, nil } -// ReplaceRideAttempts atomically rewrites every attempt row of one ride, -// dropping stale matches and inserting the freshly analyzed ones. +// ReplaceRideAttempts refreshes a ride's attempt rows: fresh matches are written, stale ones dropped, the recorded-at stamp of survivors kept. func ReplaceRideAttempts(db *sql.DB, rideID int64, attempts []Attempt) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("replace ride attempts: %w", err) } defer func() { _ = tx.Rollback() }() - if _, err := tx.Exec("DELETE FROM ride_official_climb_attempts WHERE ride_id = ?", rideID); err != nil { - return fmt.Errorf("delete ride attempts: %w", err) - } + keys := make([]string, 0, len(attempts)) + args := make([]any, 0, 2*len(attempts)+1) + args = append(args, rideID) for _, attempt := range attempts { if attempt.RideID != rideID { return fmt.Errorf("replace ride attempts: attempt references ride %d, expected %d", attempt.RideID, rideID) } + keys = append(keys, "(?, ?)") + args = append(args, attempt.ClimbID, attempt.Occurrence) if _, err := tx.Exec(` INSERT INTO ride_official_climb_attempts (ride_id, climb_id, occurrence, distance_m, elevation_gain_m, slope_percent, cotacol, cotacol_algo_version, started_at, ended_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ride_id, climb_id, occurrence) DO UPDATE SET + distance_m = excluded.distance_m, + elevation_gain_m = excluded.elevation_gain_m, + slope_percent = excluded.slope_percent, + cotacol = excluded.cotacol, + cotacol_algo_version = excluded.cotacol_algo_version, + started_at = excluded.started_at, + ended_at = excluded.ended_at `, attempt.RideID, attempt.ClimbID, attempt.Occurrence, attempt.DistanceM, attempt.ElevationGainM, attempt.SlopePercent, attempt.Cotacol, attempt.CotacolAlgoVersion, attempt.StartedAt.UTC().Format(time.RFC3339), attempt.EndedAt.UTC().Format(time.RFC3339)); err != nil { return fmt.Errorf("insert ride attempt: %w", err) } } + prune := "DELETE FROM ride_official_climb_attempts WHERE ride_id = ?" + if len(keys) > 0 { + prune += " AND (climb_id, occurrence) NOT IN (VALUES " + strings.Join(keys, ", ") + ")" + } + if _, err := tx.Exec(prune, args...); err != nil { + return fmt.Errorf("drop stale ride attempts: %w", err) + } if err := tx.Commit(); err != nil { return fmt.Errorf("commit ride attempts: %w", err) } @@ -132,9 +145,7 @@ type AttemptStats struct { EndIndex int } -// AttemptFromStats builds the persisted snapshot of one analyzed climb -// occurrence. Timestamps come from the ride's recorded points bounding the -// climb interval. +// AttemptFromStats snapshots one analyzed climb occurrence; timestamps come from the ride points bounding the climb interval. func AttemptFromStats(rideID int64, occurrence int, stats AttemptStats, timestamps []time.Time) Attempt { startedAt, endedAt := time.Time{}, time.Time{} if stats.StartIndex < len(timestamps) { diff --git a/official_climb/attempt_test.go b/official_climb/attempt_test.go index 147c80b..8913807 100644 --- a/official_climb/attempt_test.go +++ b/official_climb/attempt_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/biking_home/official_climb" "github.com/stretchr/testify/assert" @@ -16,6 +17,11 @@ func coord(lat, lon float64) geodist.Coord { return geodist.Coord{Lat: lat, Lon: lon} } +// sampleFixture is a plausible sample for tests that do not exercise matching. +func sampleFixture() climb.SegmentSample { + return climb.SegmentSample{DistanceM: 3000, ElevationGainM: 200} +} + func insertRide(t *testing.T, db *sql.DB, opts ...func(*rideInsert)) int64 { t.Helper() insert := rideInsert{startDate: "2026-09-01T08:00:00Z"} @@ -57,7 +63,7 @@ func attemptFixture(rideID, climbID int64, occurrence int) official_climb.Attemp func TestReplaceRideAttemptsRoundTrip(t *testing.T) { db := dbtest.New(t) - created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) @@ -80,11 +86,31 @@ func TestReplaceRideAttemptsRoundTrip(t *testing.T) { require.Len(t, rideAttempts, 2) } +func TestReplaceRideAttemptsKeepsRecordedAt(t *testing.T) { + db := dbtest.New(t) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) + require.NoError(t, err) + rideID := insertRide(t, db) + attempts := []official_climb.Attempt{attemptFixture(rideID, created.ID, 0)} + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, attempts)) + + recorded := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + _, err = db.Exec("UPDATE ride_official_climb_attempts SET created_at = ? WHERE ride_id = ?", recorded.Format(time.RFC3339), rideID) + require.NoError(t, err) + + // Re-analyzing the same ride must not restamp the attempt it already has. + require.NoError(t, official_climb.ReplaceRideAttempts(db, rideID, attempts)) + stored, err := official_climb.AttemptsForRide(db, rideID) + require.NoError(t, err) + require.Len(t, stored, 1) + assert.Equal(t, recorded, stored[0].CreatedAt.UTC()) +} + func TestReplaceRideAttemptsDropsStaleRows(t *testing.T) { db := dbtest.New(t) - first, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col A", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + first, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col A", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) - second, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col B", StartCoord: coord(43.7, 5.5), EndCoord: coord(43.72, 5.52)}) + second, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col B", StartCoord: coord(43.7, 5.5), EndCoord: coord(43.72, 5.52), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) @@ -101,7 +127,7 @@ func TestReplaceRideAttemptsDropsStaleRows(t *testing.T) { func TestReplaceRideAttemptsRejectsForeignRide(t *testing.T) { db := dbtest.New(t) - created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) @@ -112,7 +138,7 @@ func TestReplaceRideAttemptsRejectsForeignRide(t *testing.T) { func TestAttemptsForClimbOrdersByRideDate(t *testing.T) { db := dbtest.New(t) - created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + created, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) newer := insertRide(t, db, withStartDate("2026-09-10T08:00:00Z")) older := insertRide(t, db, withStartDate("2026-09-02T08:00:00Z")) diff --git a/official_climb/list_test.go b/official_climb/list_test.go index 6a65f80..4701416 100644 --- a/official_climb/list_test.go +++ b/official_climb/list_test.go @@ -11,9 +11,9 @@ import ( func TestListWithAggregatesMediansAndUnridden(t *testing.T) { db := dbtest.New(t) - unridden, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fantome", StartCoord: coord(43.9, 5.9), EndCoord: coord(43.92, 5.92)}) + unridden, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fantome", StartCoord: coord(43.9, 5.9), EndCoord: coord(43.92, 5.92), Sample: sampleFixture()}) require.NoError(t, err) - climbed, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fréquente", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42)}) + climbed, err := official_climb.Create(db, official_climb.OfficialClimb{Name: "Col Fréquente", StartCoord: coord(43.6, 5.4), EndCoord: coord(43.62, 5.42), Sample: sampleFixture()}) require.NoError(t, err) rideID := insertRide(t, db) diff --git a/official_climb/matcher.go b/official_climb/matcher.go index 00f7e9b..657d2ef 100644 --- a/official_climb/matcher.go +++ b/official_climb/matcher.go @@ -4,22 +4,22 @@ import ( "math" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/geo" ) -// MatchedClimb is an official climb that lies on a route, together with the -// route point indexes of its start and end. It lets the climb detector know -// which segment of the route is already claimed by an official climb. +// MatchedClimb is an official climb lying on a route, with the route indexes of its start and end: the segment the climb detector must not re-report. type MatchedClimb struct { OfficialClimb StartIndex int EndIndex int } -// MatchClimb matches an official climb against a route purely by -// coordinates: both the start and end coordinates must be within radiusM of a -// route point, and the matched start point must precede the matched end point. -func MatchClimb(route []geodist.Coord, official OfficialClimb, radiusM float64) (MatchedClimb, bool) { +// SegmentSampleBandRatio is the half-width of the band a candidate passage must land in, relative to the climb's sample. +const SegmentSampleBandRatio = 0.5 + +// MatchClimb matches an official climb against a route: endpoints within radiusM, start before end, and the passage inside the climb's sample band. +func MatchClimb(route climb.Route, official OfficialClimb, radiusM float64) (MatchedClimb, bool) { matches := MatchClimbs(route, official, radiusM) if len(matches) == 0 { return MatchedClimb{}, false @@ -27,26 +27,22 @@ func MatchClimb(route []geodist.Coord, official OfficialClimb, radiusM float64) return matches[0], true } -// MatchClimbs returns all non-overlapping occurrences of an official climb on -// a route. Each occurrence is defined by a start and end point both within -// radiusM, with start strictly before end and direction preserved. Loops are -// handled by clustering contiguous approaches into single candidates and pairing -// each start cluster with the first end cluster inside its lap window. -func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) []MatchedClimb { +// MatchClimbs returns every occurrence of an official climb on a route: each start cluster pairs with the first end cluster in its lap window, and pairings that contradict the recorded sample are dropped — what a loop ridden the other way produces. +func MatchClimbs(route climb.Route, official OfficialClimb, radiusM float64) []MatchedClimb { if math.IsNaN(radiusM) || math.IsInf(radiusM, 0) || radiusM <= 0 { return nil } - if len(route) == 0 { + if len(route.Coords) == 0 { return nil } - startClusters := clustersInRadius(route, official.StartCoord, radiusM) - endClusters := clustersInRadius(route, official.EndCoord, radiusM) + startClusters := clustersInRadius(route.Coords, official.StartCoord, radiusM) + endClusters := clustersInRadius(route.Coords, official.EndCoord, radiusM) if len(startClusters) == 0 || len(endClusters) == 0 { return nil } matches := make([]MatchedClimb, 0) for i, start := range startClusters { - nextStartIdx := len(route) + nextStartIdx := len(route.Coords) if i+1 < len(startClusters) { nextStartIdx = startClusters[i+1].Index } @@ -61,6 +57,9 @@ func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) if matchedEnd == nil { continue } + if !withinSampleBand(official.Sample, route.Sample(start.Index, matchedEnd.Index)) { + continue + } matches = append(matches, MatchedClimb{ OfficialClimb: official, StartIndex: start.Index, @@ -70,6 +69,15 @@ func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) return matches } +// withinSampleBand reports whether a candidate passage matches both axes of the climb's sample. +func withinSampleBand(sample, candidate climb.SegmentSample) bool { + return inSampleBand(candidate.DistanceM, sample.DistanceM) && inSampleBand(candidate.ElevationGainM, sample.ElevationGainM) +} + +func inSampleBand(value, reference float64) bool { + return value >= reference*(1-SegmentSampleBandRatio) && value <= reference*(1+SegmentSampleBandRatio) +} + type clusterCandidate struct { Index int Distance float64 diff --git a/official_climb/model.go b/official_climb/model.go index ae66f46..beecd02 100644 --- a/official_climb/model.go +++ b/official_climb/model.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" ) const DefaultMatchRadiusM = 100.0 @@ -16,8 +17,10 @@ type OfficialClimb struct { Name string StartCoord geodist.Coord EndCoord geodist.Coord - CreatedAt time.Time - UpdatedAt time.Time + // Sample is the passage the climb was created from; matching rejects candidates outside its band. + Sample climb.SegmentSample + CreatedAt time.Time + UpdatedAt time.Time } type MatchPolicy struct { @@ -42,6 +45,9 @@ func (climb OfficialClimb) validate() error { if !validCoord(climb.StartCoord) || !validCoord(climb.EndCoord) { return fmt.Errorf("official climb coordinates are invalid") } + if climb.Sample.DistanceM <= 0 || climb.Sample.ElevationGainM <= 0 { + return fmt.Errorf("official climb sample must record a positive distance and elevation gain") + } return nil } diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go index 6ddce5a..7fb2a32 100644 --- a/official_climb/official_climb_test.go +++ b/official_climb/official_climb_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/biking_home/internal/ridetest" "github.com/martinlehoux/biking_home/official_climb" @@ -19,6 +20,7 @@ func TestCreateListAndGetOfficialClimb(t *testing.T) { Name: "Col de Test", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, + Sample: climb.SegmentSample{DistanceM: 1200, ElevationGainM: 90}, }) require.NoError(t, err) assert.Positive(t, created.ID) @@ -44,8 +46,16 @@ func TestCreateRejectsInvalidOfficialClimb(t *testing.T) { Name: " ", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, + Sample: climb.SegmentSample{DistanceM: 1200, ElevationGainM: 90}, }) assert.EqualError(t, err, "official climb name is required") + + _, err = official_climb.Create(db, official_climb.OfficialClimb{ + Name: "Col Sans Sample", + StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, + EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, + }) + assert.EqualError(t, err, "official climb sample must record a positive distance and elevation gain") } func TestMatchPolicyRejectsInvalidRadius(t *testing.T) { @@ -58,8 +68,9 @@ func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) { Name: "On route", StartCoord: parsed.Coords[1], EndCoord: parsed.Coords[2], + Sample: parsed.Sample(1, 2), } - matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -71,8 +82,9 @@ func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) { Name: "Offset from route", StartCoord: geodist.Coord{Lat: 43.1101, Lon: 5.1101}, EndCoord: geodist.Coord{Lat: 43.1201, Lon: 5.1201}, + Sample: parsed.Sample(1, 2), } - matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -84,8 +96,9 @@ func TestMatchClimbRejectsReversedEndpoints(t *testing.T) { Name: "Reversed", StartCoord: parsed.Coords[2], EndCoord: parsed.Coords[1], + Sample: parsed.Sample(1, 2), } - _, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + _, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) assert.False(t, found) } @@ -95,8 +108,9 @@ func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) { Name: "Too far", StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09}, EndCoord: parsed.Coords[2], + Sample: parsed.Sample(1, 2), } - _, found := official_climb.MatchClimb(parsed.Coords, official, 10) + _, found := official_climb.MatchClimb(parsed.Route, official, 10) assert.False(t, found) } @@ -121,15 +135,16 @@ func TestMatchClimbsMatchesTwoLaps(t *testing.T) { Name: "Loop", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(0, 2), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 2, matches[0].EndIndex) assert.Equal(t, 4, matches[1].StartIndex) assert.Equal(t, 6, matches[1].EndIndex) // Single-match helper still returns first occurrence for backward compatibility. - single, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + single, found := official_climb.MatchClimb(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 0, single.StartIndex) } @@ -153,8 +168,9 @@ func TestMatchClimbsMatchesOnlyCompleteLaps(t *testing.T) { Name: "Partial", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(3, 5), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 1) assert.Equal(t, 3, matches[0].StartIndex) assert.Equal(t, 5, matches[0].EndIndex) @@ -176,8 +192,9 @@ func TestMatchClimbsPreservesDirectionAcrossLaps(t *testing.T) { Name: "Directional", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(0, 1), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) assert.Empty(t, matches) } @@ -193,7 +210,7 @@ func TestMatchClimbsClustersContiguousPoints(t *testing.T) { {Lat: 43.12, Lon: 5.12}, } parsed := ride.FromColumns( - []float64{0, 100, 200, 1000, 2000, 3000, 3100, 4000}, + []float64{0, 100, 200, 1000, 2000, 3000, 3100, 5000}, []float64{100, 101, 102, 150, 200, 100, 101, 200}, coords, []time.Time{time.Unix(0, 0), time.Unix(10, 0), time.Unix(20, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(190, 0), time.Unix(240, 0)}, @@ -202,11 +219,102 @@ func TestMatchClimbsClustersContiguousPoints(t *testing.T) { Name: "Clustered", StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, + Sample: parsed.Sample(0, 4), } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 4, matches[0].EndIndex) assert.Equal(t, 5, matches[1].StartIndex) assert.Equal(t, 7, matches[1].EndIndex) } + +func TestCreateOfficialClimbStoresSample(t *testing.T) { + db := dbtest.New(t) + created, err := official_climb.Create(db, official_climb.OfficialClimb{ + Name: "Col Sampled", + StartCoord: coord(43.6, 5.4), + EndCoord: coord(43.62, 5.42), + Sample: climb.SegmentSample{DistanceM: 1200, ElevationGainM: 90}, + }) + require.NoError(t, err) + require.NotNil(t, created.Sample) + assert.Equal(t, 1200.0, created.Sample.DistanceM) + assert.Equal(t, 90.0, created.Sample.ElevationGainM) + + got, found, err := official_climb.GetByID(db, created.ID) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, got.Sample) + assert.Equal(t, 1200.0, got.Sample.DistanceM) + assert.Equal(t, 90.0, got.Sample.ElevationGainM) +} + +func TestCreateRejectsSampleWithoutGain(t *testing.T) { + db := dbtest.New(t) + _, err := official_climb.Create(db, official_climb.OfficialClimb{ + Name: "Col Plat", + StartCoord: coord(43.6, 5.4), + EndCoord: coord(43.62, 5.42), + Sample: climb.SegmentSample{DistanceM: 1200}, + }) + assert.Error(t, err) +} + +func TestMatchClimbsAcceptsSegmentInsideSampleBand(t *testing.T) { + parsed := ride.FromColumns( + []float64{0, 850}, + []float64{100, 170}, + []geodist.Coord{{Lat: 43.1000, Lon: 5.1000}, {Lat: 43.1100, Lon: 5.1100}}, + []time.Time{time.Unix(0, 0), time.Unix(120, 0)}, + ) + official := official_climb.OfficialClimb{ + Name: "Sampled climb", + StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, + EndCoord: geodist.Coord{Lat: 43.11, Lon: 5.11}, + Sample: climb.SegmentSample{DistanceM: 850, ElevationGainM: 70}, + } + matches := official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM) + require.Len(t, matches, 1) + assert.Equal(t, 0, matches[0].StartIndex) + assert.Equal(t, 1, matches[0].EndIndex) +} + +func TestMatchClimbsRejectsSegmentOutsideSampleGainBand(t *testing.T) { + parsed := ride.FromColumns( + []float64{0, 850}, + []float64{100, 105}, + []geodist.Coord{{Lat: 43.1000, Lon: 5.1000}, {Lat: 43.1100, Lon: 5.1100}}, + []time.Time{time.Unix(0, 0), time.Unix(120, 0)}, + ) + official := official_climb.OfficialClimb{ + Name: "Sampled climb", + StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, + EndCoord: geodist.Coord{Lat: 43.11, Lon: 5.11}, + Sample: climb.SegmentSample{DistanceM: 850, ElevationGainM: 70}, + } + assert.Empty(t, official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM)) +} + +func TestMatchClimbsRejectsSegmentOutsideSampleBand(t *testing.T) { + // Reverse loop: only a cross-lap passage pairs the endpoints, and the sample band rejects it. + parsed := ride.FromColumns( + []float64{0, 850, 6800, 7650, 13600}, + []float64{170, 100, 235, 100, 235}, + []geodist.Coord{ + {Lat: 43.1100, Lon: 5.1100}, + {Lat: 43.1000, Lon: 5.1000}, + {Lat: 43.1100, Lon: 5.1100}, + {Lat: 43.1000, Lon: 5.1000}, + {Lat: 43.1100, Lon: 5.1100}, + }, + []time.Time{time.Unix(0, 0), time.Unix(120, 0), time.Unix(600, 0), time.Unix(720, 0), time.Unix(1200, 0)}, + ) + official := official_climb.OfficialClimb{ + Name: "Reverse loop", + StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, + EndCoord: geodist.Coord{Lat: 43.11, Lon: 5.11}, + Sample: climb.SegmentSample{DistanceM: 850, ElevationGainM: 70}, + } + assert.Empty(t, official_climb.MatchClimbs(parsed.Route, official, official_climb.DefaultMatchRadiusM)) +} diff --git a/official_climb/repository.go b/official_climb/repository.go index 1feb6a9..33c2798 100644 --- a/official_climb/repository.go +++ b/official_climb/repository.go @@ -8,16 +8,16 @@ import ( "github.com/jftuga/geodist" ) -const columns = "id, name, start_latitude, start_longitude, end_latitude, end_longitude, created_at, updated_at" +const columns = "id, name, start_latitude, start_longitude, end_latitude, end_longitude, sample_distance_m, sample_elevation_gain_m, created_at, updated_at" func Create(db *sql.DB, climb OfficialClimb) (OfficialClimb, error) { if err := climb.validate(); err != nil { return OfficialClimb{}, err } result, err := db.Exec(` - INSERT INTO official_climbs (name, start_latitude, start_longitude, end_latitude, end_longitude) - VALUES (?, ?, ?, ?, ?) - `, climb.Name, climb.StartCoord.Lat, climb.StartCoord.Lon, climb.EndCoord.Lat, climb.EndCoord.Lon) + INSERT INTO official_climbs (name, start_latitude, start_longitude, end_latitude, end_longitude, sample_distance_m, sample_elevation_gain_m) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, climb.Name, climb.StartCoord.Lat, climb.StartCoord.Lon, climb.EndCoord.Lat, climb.EndCoord.Lon, climb.Sample.DistanceM, climb.Sample.ElevationGainM) if err != nil { return OfficialClimb{}, fmt.Errorf("create official climb: %w", err) } @@ -79,7 +79,7 @@ func scan(s scanner) (OfficialClimb, error) { endLatitude, endLongitude float64 createdAt, updatedAt string ) - if err := s.Scan(&climb.ID, &climb.Name, &startLatitude, &startLongitude, &endLatitude, &endLongitude, &createdAt, &updatedAt); err != nil { + if err := s.Scan(&climb.ID, &climb.Name, &startLatitude, &startLongitude, &endLatitude, &endLongitude, &climb.Sample.DistanceM, &climb.Sample.ElevationGainM, &createdAt, &updatedAt); err != nil { return OfficialClimb{}, err } climb.StartCoord = geodist.Coord{Lat: startLatitude, Lon: startLongitude} diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go index bf3c2bb..81c9a98 100644 --- a/rideanalysis/analysis.go +++ b/rideanalysis/analysis.go @@ -17,6 +17,9 @@ const ( passCrossingElevationToleranceM = 25 ) +// PassMarginM is how far around a ride's track mountain passes are loaded for matching and display. +const PassMarginM = 10_000 + type Result struct { Climbs []AnalyzedClimb Crossings []mountain_pass.Crossing @@ -27,6 +30,7 @@ type AnalyzedClimb struct { Name string Category string DistanceM float64 + ElevationGainM float64 SlopePercent float64 Cotacol float64 OfficialClimbID int64 @@ -42,7 +46,7 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim claimedClimbs := make([]AnalyzedClimb, 0) claimedRanges := make([]geo.Interval, 0) for _, official := range officialClimbs { - for _, matched := range official_climb.MatchClimbs(parsed.Coords, official, matchPolicy.EndpointRadiusM) { + for _, matched := range official_climb.MatchClimbs(parsed.Route, official, matchPolicy.EndpointRadiusM) { interval := geo.NewInterval(matched.StartIndex, matched.EndIndex) claimedRanges = append(claimedRanges, interval) claimedClimbs = append(claimedClimbs, analyzedClimb(routeClimb(parsed.Route, interval), interval, official.Name, official.ID)) @@ -81,11 +85,13 @@ func routeClimb(route climb.Route, interval geo.Interval) climb.Climb { func analyzedClimb(segment climb.Climb, interval geo.Interval, name string, officialID int64) AnalyzedClimb { cotacol := segment.DifficultyScore() + sample := segment.Sample(0, len(segment.Distances)-1) return AnalyzedClimb{ Segment: segment, Name: name, Category: climb.CategoryOf(cotacol).String(), - DistanceM: segment.Distances[len(segment.Distances)-1] - segment.Distances[0], + DistanceM: sample.DistanceM, + ElevationGainM: sample.ElevationGainM, SlopePercent: climb.Slope(segment.Distances, segment.Elevations) * 100, Cotacol: cotacol, OfficialClimbID: officialID, diff --git a/rideanalysis/attempts.go b/rideanalysis/attempts.go new file mode 100644 index 0000000..faf02e9 --- /dev/null +++ b/rideanalysis/attempts.go @@ -0,0 +1,29 @@ +package rideanalysis + +import ( + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" +) + +// Attempts extracts one attempt record per official-climb occurrence: numbered per climb in route order, timestamped from the ride's recorded points. +func Attempts(rideID int64, analysis Result, parsed ride.Ride) []official_climb.Attempt { + occurrences := make(map[int64]int) + attempts := make([]official_climb.Attempt, 0) + for _, analyzed := range analysis.Climbs { + if analyzed.OfficialClimbID == 0 { + continue + } + occurrence := occurrences[analyzed.OfficialClimbID] + occurrences[analyzed.OfficialClimbID] = occurrence + 1 + attempts = append(attempts, official_climb.AttemptFromStats(rideID, occurrence, official_climb.AttemptStats{ + OfficialClimbID: analyzed.OfficialClimbID, + DistanceM: analyzed.DistanceM, + ElevationGainM: analyzed.ElevationGainM, + SlopePercent: analyzed.SlopePercent, + Cotacol: analyzed.Cotacol, + StartIndex: analyzed.Interval.Start, + EndIndex: analyzed.Interval.End, + }, parsed.Timestamps())) + } + return attempts +} diff --git a/rideanalysis/attempts_test.go b/rideanalysis/attempts_test.go new file mode 100644 index 0000000..6fab8e1 --- /dev/null +++ b/rideanalysis/attempts_test.go @@ -0,0 +1,46 @@ +package rideanalysis_test + +import ( + "testing" + "time" + + "github.com/martinlehoux/biking_home/internal/ridetest" + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var attemptRideStart = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) + +func attemptRideForClimb(t *testing.T) (ride.Ride, official_climb.OfficialClimb) { + t.Helper() + parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000), ridetest.StartTime(attemptRideStart)) + return parsed, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1], Sample: parsed.Sample(0, 1)} +} + +func TestAttemptsSnapshotsOfficialClimbs(t *testing.T) { + parsed, climb := attemptRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{{ID: 42, Name: climb.Name, StartCoord: climb.StartCoord, EndCoord: climb.EndCoord, Sample: climb.Sample}}, official_climb.DefaultMatchPolicy()) + + attempts := rideanalysis.Attempts(7, analysis, parsed) + require.Len(t, attempts, 1) + assert.Equal(t, int64(7), attempts[0].RideID) + assert.Equal(t, int64(42), attempts[0].ClimbID) + assert.Equal(t, 0, attempts[0].Occurrence) + assert.InDelta(t, 1000, attempts[0].DistanceM, 1) + assert.InDelta(t, 147, attempts[0].ElevationGainM, 1) + assert.InDelta(t, 14.7, attempts[0].SlopePercent, 0.1) + assert.Positive(t, attempts[0].Cotacol) + assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion) + assert.Equal(t, attemptRideStart, attempts[0].StartedAt.UTC()) + assert.False(t, attempts[0].EndedAt.IsZero(), "attempt end must come from ride timestamps") +} + +func TestAttemptsSkipsUnmatchedClimbs(t *testing.T) { + parsed, _ := attemptRideForClimb(t) + analysis := rideanalysis.Analyze(parsed, nil, nil, official_climb.DefaultMatchPolicy()) + + assert.Empty(t, rideanalysis.Attempts(7, analysis, parsed)) +} diff --git a/web/climb-backfill.go b/web/climb-backfill.go deleted file mode 100644 index 13a8c02..0000000 --- a/web/climb-backfill.go +++ /dev/null @@ -1,81 +0,0 @@ -package web - -import ( - "database/sql" - "log/slog" - - "github.com/martinlehoux/biking_home/config" - "github.com/martinlehoux/biking_home/mountain_pass" - "github.com/martinlehoux/biking_home/official_climb" - "github.com/martinlehoux/biking_home/ride" - "github.com/martinlehoux/biking_home/rideanalysis" - "github.com/martinlehoux/kagamigo/kcore" - "github.com/schollz/progressbar/v3" -) - -// BackfillOfficialClimbAttempts parses every route-bearing ride, matches its -// official climbs, and persists the resulting attempts. An optional reporter -// is invoked with progress for CLI rendering. -func BackfillOfficialClimbAttempts(db *sql.DB, configPath string, reporters ...func(current, total int)) (int, error) { - appConfig, err := config.Load(configPath) - if err != nil { - return 0, err - } - matchPolicy := official_climb.DefaultMatchPolicy() - matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM - items, err := ride.List(db) - if err != nil { - return 0, err - } - officialClimbs, err := official_climb.List(db) - if err != nil { - return 0, err - } - var bar *progressbar.ProgressBar - report := func(int, int) {} - if len(reporters) > 0 { - report = reporters[0] - } - backfilled := 0 - for i, item := range items { - report(i+1, len(items)) - if item.FitPath == "" && item.GPXPath == "" { - continue - } - parsed, err := ride.ParseStoredRide(item) - if err != nil { - slog.Warn("Failed to parse ride for climb backfill", "ride_id", item.ID, "error", err) - continue - } - passes, err := mountain_pass.LoadMountainPassesAroundRide(db, parsed.Coords, rideDetailMountainPassMarginM) - if err != nil { - slog.Warn("Failed to load passes for climb backfill", "ride_id", item.ID, "error", err) - } - analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) - attempts := attemptsFromAnalysis(item.ID, analysis, parsed) - if err := official_climb.ReplaceRideAttempts(db, item.ID, attempts); err != nil { - slog.Warn("Failed to record climb attempts", "ride_id", item.ID, "error", err) - continue - } - backfilled++ - } - if bar != nil { - bar.Finish() - } - return backfilled, nil -} - -// BackfillOfficialClimbAttemptsCmd runs the climb attempt backfill from the CLI. -func BackfillOfficialClimbAttemptsCmd(db *sql.DB, configPath string) { - var bar *progressbar.ProgressBar - count, err := BackfillOfficialClimbAttempts(db, configPath, func(current, total int) { - if bar == nil && mountain_pass.ProgressBarEnabled() { - bar = progressbar.Default(int64(total), "Backfilling climb attempts") - } - if bar != nil { - bar.Set(current) - } - }) - kcore.Expect(err, "failed to backfill climb attempts") - slog.Info("Backfilled climb attempts", "rides", count) -} diff --git a/web/climbs.go b/web/climbs.go index 6859545..6416509 100644 --- a/web/climbs.go +++ b/web/climbs.go @@ -101,9 +101,7 @@ func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) { }), w) } -// buildClimbProfile computes the median normalized elevation profile from the -// linked rides: each ride is parsed, its climb occurrences matched, and every -// occurrence resampled onto the shared normalized axis. +// buildClimbProfile computes the median normalized elevation profile from the linked rides, resampling every occurrence onto a shared axis. func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, aggregate official_climb.Aggregate, attempts []official_climb.Attempt) ClimbProfileData { profile := ClimbProfileData{DistanceKm: aggregate.DistanceM / 1000} if len(attempts) == 0 { @@ -127,7 +125,7 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a slog.Warn("Failed to parse ride for climb profile", "climb_id", officialClimb.ID, "ride_id", attempt.RideID, "error", err) continue } - for _, matched := range official_climb.MatchClimbs(parsed.Coords, officialClimb, matchPolicy.EndpointRadiusM) { + for _, matched := range official_climb.MatchClimbs(parsed.Route, officialClimb, matchPolicy.EndpointRadiusM) { segment := climb.Climb{Route: climb.Route{ Distances: parsed.Distances[matched.StartIndex : matched.EndIndex+1], Elevations: parsed.Elevations[matched.StartIndex : matched.EndIndex+1], @@ -152,9 +150,7 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a return profile } -// medianRoute picks the recorded route of the attempt whose distance is the -// median across occurrences: full recorded precision, no resampling, and -// robust to one GPS-drifted ride. +// medianRoute picks the recorded route of the attempt whose distance is the median: full precision, no resampling, robust to one drifted ride. func medianRoute(candidates []routeCandidate) []geodist.Coord { if len(candidates) == 0 { return nil diff --git a/web/climbs_test.go b/web/climbs_test.go index fbaf527..3b30c07 100644 --- a/web/climbs_test.go +++ b/web/climbs_test.go @@ -67,7 +67,7 @@ func TestBuildClimbProfileMediansAcrossRides(t *testing.T) { storeRideFixtureFile(t, db, rideID) parsed, _ := testRideForClimb(t) analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) - attempts := attemptsFromAnalysis(rideID, analysis, parsed) + attempts := rideanalysis.Attempts(rideID, analysis, parsed) profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) assert.InDelta(t, 1.0, profile.DistanceKm, 0.01) @@ -84,12 +84,11 @@ func TestBuildClimbProfileRouteKeepsRecordedPoints(t *testing.T) { rideID := insertTestRide(t, db) storeRideFixtureFile(t, db, rideID) analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) - attempts := attemptsFromAnalysis(rideID, analysis, parsed) + attempts := rideanalysis.Attempts(rideID, analysis, parsed) profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) require.NotEmpty(t, profile.Route) - // Full recorded precision: every matched segment point is present, not a - // fixed 50-step resample. + // Full recorded precision: every matched segment point is present, not a fixed resample. assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0][0], 1e-6) assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0][1], 1e-6) assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1][0], 1e-6) diff --git a/web/frontend/ride-detail-boundaries.ts b/web/frontend/ride-detail-boundaries.ts index a9bb8e1..f1d80e7 100644 --- a/web/frontend/ride-detail-boundaries.ts +++ b/web/frontend/ride-detail-boundaries.ts @@ -1,4 +1,4 @@ -import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js"; +import { climbMetrics, climbSample, formatDistance, formatElevation } from "./ride-detail-logic.js"; import { requireElement } from "./dom.js"; import type { BoundarySource, BoundaryTarget, ClimbBounds, RideProfilePoint } from "./types.js"; @@ -104,11 +104,13 @@ export class RideBoundaryController { const item = form.closest("[data-climb-item]"); if (!item) return; const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); - const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]); - if (!metrics) { + const bounds = this.climbBounds[climbIndex]; + const metrics = climbMetrics(this.points, bounds); + if (!bounds || !metrics) { preview.textContent = "Choose an end point after the start point."; return; } + this.syncSnapshot(form, bounds); const summary = requireElement(item.querySelector("[data-climb-summary]"), "climb summary"); const metricsOutput = requireElement(item.querySelector("[data-climb-metrics]"), "climb metrics"); summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`; @@ -116,6 +118,24 @@ export class RideBoundaryController { preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`; } + // syncSnapshot fills the fields the preview save posts: the preview ride is never persisted. + syncSnapshot(form: HTMLFormElement, bounds: ClimbBounds): void { + const sample = climbSample(this.points, bounds); + if (!sample) return; + const snapshot: Array<[string, number]> = [ + ["start_lat", this.points[bounds.startIndex].latitude], + ["start_lon", this.points[bounds.startIndex].longitude], + ["end_lat", this.points[bounds.endIndex].latitude], + ["end_lon", this.points[bounds.endIndex].longitude], + ["sample_distance_m", sample.distanceM], + ["sample_elevation_gain_m", sample.elevationGainM], + ]; + for (const [name, value] of snapshot) { + const input = form.querySelector(`input[name="${name}"]`); + if (input) input.value = String(value); + } + } + boundaryLabel(index: number): string { const point = this.points[index]; const idx = point.index ?? index; diff --git a/web/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts index 471eb49..5104516 100644 --- a/web/frontend/ride-detail-logic.test.ts +++ b/web/frontend/ride-detail-logic.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { categoryForCotacol, climbMetrics, + climbSample, cotacolForClimb, elevationAtDistance, formatClimbLabel, @@ -41,6 +42,21 @@ test("calculates climb metrics with Cotacol", () => { assert.equal(metrics.category, categoryForCotacol(metrics.cotacol)); }); +test("computes the recorded sample of a climb segment", () => { + assert.deepEqual(climbSample(points, { startIndex: 0, endIndex: 3 }), { distanceM: 250, elevationGainM: 20 }); + assert.equal(climbSample(points, { startIndex: 3, endIndex: 0 }), null); +}); + +test("climb sample counts only upward steps", () => { + const rolling = [ + { distanceKm: 0, elevationM: 100 }, + { distanceKm: 0.1, elevationM: 130 }, + { distanceKm: 0.2, elevationM: 110 }, + { distanceKm: 0.3, elevationM: 140 }, + ]; + assert.deepEqual(climbSample(rolling, { startIndex: 0, endIndex: 3 }), { distanceM: 300, elevationGainM: 60 }); +}); + test("formats profile climb labels with category", () => { assert.equal(formatClimbLabel({ name: "", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Cat 4 45.9"); assert.equal(formatClimbLabel({ name: "Col de Test", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Col de Test"); diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts index b4d4dba..e7fc822 100644 --- a/web/frontend/ride-detail-logic.ts +++ b/web/frontend/ride-detail-logic.ts @@ -1,4 +1,4 @@ -import type { ClimbBounds, ClimbMetrics, ProfilePoint } from "./types.js"; +import type { ClimbBounds, ClimbMetrics, ClimbSample, ProfilePoint } from "./types.js"; export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); @@ -71,15 +71,13 @@ export const cotacolForClimb = (points: ProfilePoint[], startIndex: number, endI return cotacol; }; +const boundsAreValid = (points: ProfilePoint[], bounds: ClimbBounds | undefined): bounds is ClimbBounds => { + if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex)) return false; + return bounds.startIndex >= 0 && bounds.endIndex < points.length && bounds.startIndex < bounds.endIndex; +}; + export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbMetrics | null => { - if ( - !bounds || - !Number.isInteger(bounds.startIndex) || - !Number.isInteger(bounds.endIndex) || - bounds.startIndex < 0 || - bounds.endIndex >= points.length || - bounds.startIndex >= bounds.endIndex - ) { + if (!boundsAreValid(points, bounds)) { return null; } const start = points[bounds.startIndex]; @@ -98,3 +96,19 @@ export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undef category: categoryForCotacol(cotacol), }; }; + +// climbSample returns the segment's recorded length and upward-only gain, as the server computes it for an official climb sample. +export const climbSample = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbSample | null => { + if (!boundsAreValid(points, bounds)) { + return null; + } + const sample: ClimbSample = { + distanceM: (points[bounds.endIndex].distanceKm - points[bounds.startIndex].distanceKm) * 1000, + elevationGainM: 0, + }; + for (let index = bounds.startIndex + 1; index <= bounds.endIndex; index++) { + const gain = points[index].elevationM - points[index - 1].elevationM; + if (gain > 0) sample.elevationGainM += gain; + } + return sample; +}; diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index 87be024..74e0765 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -81,6 +81,11 @@ export interface ClimbMetrics { category: string; } +export interface ClimbSample { + distanceM: number; + elevationGainM: number; +} + export interface OfficialProfileColors { downhill: string; "0-3": string; diff --git a/web/official-climbs.go b/web/official-climbs.go index ce9d7cc..b312bb5 100644 --- a/web/official-climbs.go +++ b/web/official-climbs.go @@ -9,51 +9,14 @@ import ( "github.com/martinlehoux/biking_home/rideanalysis" ) -// analysisFor runs the ride analysis pipeline the same way the ride profile -// does, so recorded attempts and displayed climbs always agree. +// analysisFor runs the same pipeline as the ride profile, so recorded attempts and displayed climbs agree. func analysisFor(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) rideanalysis.Result { return rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) } -// attemptsFromAnalysis extracts one attempt per official-climb occurrence. -// Occurrences are numbered per climb in route order (0, 1, … for laps). -func attemptsFromAnalysis(rideID int64, analysis rideanalysis.Result, parsed ride.Ride) []official_climb.Attempt { - occurrences := make(map[int64]int) - attempts := make([]official_climb.Attempt, 0) - for _, analyzed := range analysis.Climbs { - if analyzed.OfficialClimbID == 0 { - continue - } - occurrence := occurrences[analyzed.OfficialClimbID] - occurrences[analyzed.OfficialClimbID] = occurrence + 1 - attempts = append(attempts, official_climb.AttemptFromStats(rideID, occurrence, official_climb.AttemptStats{ - OfficialClimbID: analyzed.OfficialClimbID, - DistanceM: analyzed.DistanceM, - ElevationGainM: elevationGain(analyzed), - SlopePercent: analyzed.SlopePercent, - Cotacol: analyzed.Cotacol, - StartIndex: analyzed.Interval.Start, - EndIndex: analyzed.Interval.End, - }, parsed.Timestamps())) - } - return attempts -} - -func elevationGain(analyzed rideanalysis.AnalyzedClimb) float64 { - gain := 0.0 - for i := 1; i < len(analyzed.Segment.Elevations); i++ { - if diff := analyzed.Segment.Elevations[i] - analyzed.Segment.Elevations[i-1]; diff > 0 { - gain += diff - } - } - return gain -} - -// recordOfficialClimbAttempts persists the ride's official-climb attempts, -// replacing any previously recorded ones. Failures are logged, never fatal: -// attempts are an optimization over on-the-fly matching. +// recordOfficialClimbAttempts refreshes the ride's official-climb attempts; failures are logged, never fatal. func (s *Server) recordOfficialClimbAttempts(rideID int64, parsed ride.Ride, analysis rideanalysis.Result) { - attempts := attemptsFromAnalysis(rideID, analysis, parsed) + attempts := rideanalysis.Attempts(rideID, analysis, parsed) if err := official_climb.ReplaceRideAttempts(s.db, rideID, attempts); err != nil { slog.Warn("Failed to record official climb attempts", "ride_id", rideID, "error", err) } diff --git a/web/official-climbs_test.go b/web/official-climbs_test.go index ba71f19..8fab49c 100644 --- a/web/official-climbs_test.go +++ b/web/official-climbs_test.go @@ -18,7 +18,7 @@ var testRideStart = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) func testRideForClimb(t *testing.T) (ride.Ride, official_climb.OfficialClimb) { t.Helper() parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000), ridetest.StartTime(testRideStart)) - return parsed, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1]} + return parsed, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1], Sample: parsed.Sample(0, 1)} } func insertTestRide(t *testing.T, db *sql.DB) int64 { @@ -33,32 +33,6 @@ func insertTestRide(t *testing.T, db *sql.DB) int64 { return id } -func TestAttemptsFromAnalysisSnapshotsOfficialClimbs(t *testing.T) { - parsed, climb := testRideForClimb(t) - analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{{ID: 42, Name: climb.Name, StartCoord: climb.StartCoord, EndCoord: climb.EndCoord}}, official_climb.DefaultMatchPolicy()) - - attempts := attemptsFromAnalysis(7, analysis, parsed) - require.Len(t, attempts, 1) - assert.Equal(t, int64(7), attempts[0].RideID) - assert.Equal(t, int64(42), attempts[0].ClimbID) - assert.Equal(t, 0, attempts[0].Occurrence) - assert.InDelta(t, 1000, attempts[0].DistanceM, 1) - assert.InDelta(t, 147, attempts[0].ElevationGainM, 1) - assert.InDelta(t, 14.7, attempts[0].SlopePercent, 0.1) - assert.Positive(t, attempts[0].Cotacol) - assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion) - assert.Equal(t, testRideStart, attempts[0].StartedAt.UTC()) - assert.False(t, attempts[0].EndedAt.IsZero(), "attempt end must come from ride timestamps") -} - -func TestAttemptsFromAnalysisSkipsUnmatchedClimbs(t *testing.T) { - parsed, _ := testRideForClimb(t) - analysis := rideanalysis.Analyze(parsed, nil, nil, official_climb.DefaultMatchPolicy()) - - attempts := attemptsFromAnalysis(7, analysis, parsed) - assert.Empty(t, attempts) -} - func TestRecordOfficialClimbAttemptsPersistsAndReplaces(t *testing.T) { server, db := newWebTestServer(t) rideID := insertTestRide(t, db) diff --git a/web/preview.go b/web/preview.go index 2c36b71..88978b8 100644 --- a/web/preview.go +++ b/web/preview.go @@ -15,6 +15,7 @@ import ( "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" "github.com/martinlehoux/kagamigo/kcore" ) @@ -53,7 +54,7 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { s.renderPreviewForm(w, r, "The file could not be parsed as a ride (GPX without elevation or an unsupported format?).") return } - passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideDetailMountainPassMarginM) + passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideanalysis.PassMarginM) if err != nil { slog.Warn("Failed to load nearby mountain passes for preview", "error", err) } @@ -82,8 +83,7 @@ func rideParserFor(filename string) (ride.RideParser, error) { } } -// buildPreviewRideView derives the ride summary metrics from the parsed route, -// the same values ride.Save persists for imported rides. +// buildPreviewRideView derives the ride summary metrics from the parsed route, the same values ride.Save persists. func buildPreviewRideView(filename string, parsed ride.Ride) RideView { distanceM := parsed.Distances[len(parsed.Distances)-1] elevationGainM := 0.0 @@ -151,10 +151,16 @@ func (s *Server) handlePreviewOfficialClimb(w http.ResponseWriter, r *http.Reque writePreviewSaveError(w, "valid end coordinates are required") return } + sample, err := parseSampleForm(r) + if err != nil { + writePreviewSaveError(w, "a valid recorded sample is required") + return + } created, err := official_climb.Create(s.db, official_climb.OfficialClimb{ Name: r.FormValue("name"), StartCoord: startCoord, EndCoord: endCoord, + Sample: sample, }) if err != nil { slog.Warn("Failed to create official climb from preview", "error", err) @@ -166,6 +172,19 @@ func (s *Server) handlePreviewOfficialClimb(w http.ResponseWriter, r *http.Reque _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "name": created.Name}) } +// parseSampleForm reads the sample the preview client computed for the selected boundaries. +func parseSampleForm(r *http.Request) (climb.SegmentSample, error) { + distanceM, err := strconv.ParseFloat(r.FormValue("sample_distance_m"), 64) + if err != nil { + return climb.SegmentSample{}, err + } + elevationGainM, err := strconv.ParseFloat(r.FormValue("sample_elevation_gain_m"), 64) + if err != nil { + return climb.SegmentSample{}, err + } + return climb.SegmentSample{DistanceM: distanceM, ElevationGainM: elevationGainM}, nil +} + func parseCoordForm(r *http.Request, prefix string) (coord geodist.Coord, err error) { lat, err := strconv.ParseFloat(r.FormValue(prefix+"_lat"), 64) if err != nil { diff --git a/web/rides.go b/web/rides.go index 10fbb75..1eea98e 100644 --- a/web/rides.go +++ b/web/rides.go @@ -10,6 +10,7 @@ import ( "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" "github.com/martinlehoux/kagamigo/kcore" ) @@ -78,7 +79,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { }), w) return } - passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideDetailMountainPassMarginM) + passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideanalysis.PassMarginM) if err != nil { slog.Warn("Failed to load nearby mountain passes for ride detail", "ride_id", id, "error", err) } @@ -167,10 +168,12 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques http.Error(w, "the official climb boundaries must be ordered route points", http.StatusBadRequest) return } + sample := parsed.Sample(startIndex, endIndex) created, err := official_climb.Create(s.db, official_climb.OfficialClimb{ Name: r.FormValue("name"), StartCoord: bounds[startIndex], EndCoord: bounds[endIndex], + Sample: sample, }) if err != nil { slog.Warn("Failed to create official climb", "ride_id", id, "error", err) @@ -209,8 +212,6 @@ func (s *Server) listRides(rideSort RideSort) ([]ride.StoredRide, error) { return ride.ListSorted(s.db, column, rideSort.Descending) } -const rideDetailMountainPassMarginM = 10_000 - func buildRideViews(items []ride.StoredRide) []RideView { views := make([]RideView, 0, len(items)) for _, item := range items { diff --git a/web/server_test.go b/web/server_test.go index e90f69a..440ac5f 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -244,6 +244,7 @@ func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) { Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1], + Sample: parsed.Sample(0, 1), }}, official_climb.DefaultMatchPolicy()) require.Len(t, profile.Climbs, 1) @@ -261,6 +262,7 @@ func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) { Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[2], + Sample: parsed.Sample(0, 2), }}, official_climb.DefaultMatchPolicy()) require.Len(t, profile.Climbs, 1) @@ -314,6 +316,9 @@ func TestHandlerCreatesOfficialClimbFromRoutePoints(t *testing.T) { assert.Equal(t, "Col de Test", climbs[0].Name) assert.Equal(t, geodist.Coord{Lat: 43.0, Lon: 5.0}, climbs[0].StartCoord) assert.Equal(t, geodist.Coord{Lat: 43.005, Lon: 5.005}, climbs[0].EndCoord) + require.NotNil(t, climbs[0].Sample) + assert.Equal(t, 200.0, climbs[0].Sample.ElevationGainM) + assert.InDelta(t, 689, climbs[0].Sample.DistanceM, 2) getRequest = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/rides/%d?official_climb=created", item.ID), nil) getResponse = httptest.NewRecorder() @@ -952,6 +957,9 @@ func TestPreviewParsesGPXWithoutPersisting(t *testing.T) { assert.Contains(t, body, "ride-route") assert.Contains(t, body, "ride-profile") assert.Contains(t, body, "data-official-climb-form") + assert.Contains(t, body, `name="sample_distance_m"`) + assert.Contains(t, body, `name="sample_elevation_gain_m"`) + assert.Contains(t, body, `name="start_lat"`) assert.NotContains(t, body, "Curation") var count int require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM rides").Scan(&count)) @@ -979,13 +987,15 @@ func TestPreviewShowsInlineErrorForInvalidRideFile(t *testing.T) { func TestPreviewCreatesOfficialClimbWithoutPersistingRide(t *testing.T) { server, db := newWebTestServer(t) form := url.Values{ - "name": {"Col du Preview"}, - "start_index": {"0"}, - "end_index": {"2"}, - "start_lat": {"43.0"}, - "start_lon": {"5.0"}, - "end_lat": {"43.01"}, - "end_lon": {"5.01"}, + "name": {"Col du Preview"}, + "start_index": {"0"}, + "end_index": {"2"}, + "start_lat": {"43.0"}, + "start_lon": {"5.0"}, + "end_lat": {"43.01"}, + "end_lon": {"5.01"}, + "sample_distance_m": {"1400"}, + "sample_elevation_gain_m": {"200"}, } request := httptest.NewRequest(http.MethodPost, "/preview/official-climbs", strings.NewReader(form.Encode())) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -996,6 +1006,9 @@ func TestPreviewCreatesOfficialClimbWithoutPersistingRide(t *testing.T) { require.NoError(t, err) require.Len(t, climbs, 1) assert.Equal(t, "Col du Preview", climbs[0].Name) + require.NotNil(t, climbs[0].Sample) + assert.Equal(t, 1400.0, climbs[0].Sample.DistanceM) + assert.Equal(t, 200.0, climbs[0].Sample.ElevationGainM) var count int require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM rides").Scan(&count)) assert.Equal(t, 0, count, "preview climb creation must not persist a ride") diff --git a/web/templates.templ b/web/templates.templ index d41dd39..aceeeee 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -342,6 +342,14 @@ templ RideDetailContent(data RideDetailView) { + if data.Preview { + + + + + + + }
Start{ formatProfileDistance(climb.StartKm) }
End{ formatProfileDistance(climb.EndKm) }
-- cgit v1.3