aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 12:07:11 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 12:07:11 +0200
commit522512197de372aa9b9c5584711619a76a5546d8 (patch)
tree2f005e279adc1f7da1afe7155d5b87c80c8c4840
parent1c5fc2e4364063884f33b78f45f351201705fae4 (diff)
clenaup
-rw-r--r--README.md103
-rw-r--r--climb/detection.go44
-rw-r--r--climb/detection_test.go12
-rw-r--r--core/core.go12
-rw-r--r--geo/interval.go15
-rw-r--r--rideanalysis/analysis.go16
6 files changed, 105 insertions, 97 deletions
diff --git a/README.md b/README.md
index 1ae3a8b..9c042d4 100644
--- a/README.md
+++ b/README.md
@@ -4,52 +4,29 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
## Features
-- **GPX parsing** — reads elevation-tracked rides from Garmin/Strava GPX exports into a `Ride` model
-- **Climb detection** — automatically finds climbs within a ride, each scored and categorized (Cat 4 … HC), requiring at least a 3.5% average slope (gentler stretches are re-segmented into their qualifying parts)
-- **Difficulty score (Cotacol)** — scores any ride or climb by splitting it into 100 m segments and summing `0.1 km × slope²` per segment. This fixed step is a deliberate compromise: smaller steps increase sensitivity to elevation noise, while unconstrained variable steps maximize noise rather than meaningful difficulty.
-- **Similar-climb matching** — finds the same climb across rides by matching start/end coordinates, so times can be compared
-- **Mountain pass extraction** — extracts French mountain passes from an OpenStreetMap PBF export (nodes tagged `mountain_pass=yes` / `natural=mountain_pass`) into a SQLite database
-- **Pass crossing detection** — flags which passes a ride crosses and names each climb after the pass it tops (e.g. "Col de Castellaras")
-- **Interactive ride profiles** — explores elevation, climbs, and mountain-pass crossings in the web ride detail view
-- **Official climb cards** — separates matched climbs from pending matches and expands each matched climb with its own elevation profile
-- **Repeated official climbs** — matches the same official climb once per lap on looping rides, respecting direction
-- **Web ride library** — starts a local web server by default, imports Strava rides over a date range, stores metadata in SQLite, and keeps their GPX files on disk
-- **Materialized ride values** — computes Cotacol on import, stores its algorithm version in SQLite, and refreshes all computed values with `-backfill`
-- **Strava stream metrics** — preserves heart rate, cadence, and power in Garmin-compatible GPX files and in-memory ride columns
-- **Metadata-only ride imports** — stores Strava activity statistics for rides without GPS tracks, without Cotacol or route data
-- **Ride table sorting** — sorts every ride-library column through clickable server-side headers and query parameters
-- **Ride library filters** — filters rides by distance, moving duration, start date, Cotacol score, and route availability through shareable query URLs
-- **Computed moving time** — computes moving time from the GPX track with a speed-threshold algorithm, persists it as the canonical value (keeping Strava's as `external_moving_time_s`), and benchmarks against that reference via `-backtest`
-- **CLI progress bars** — shows a live progress bar for the long-running CLI processes (`-fetch-osm`, `-extract-osm`, `-backfill`, `-backtest`)
-- **Verified OSM download** — verifies the France PBF against geofabrik's published MD5 after downloading (and on already-complete files), keeping a corrupt file for inspection instead of silently trusting its size
-- **Ride detail maps** — opens a stored ride with its recorded route on an interactive OpenStreetMap map
-- **Strava import progress** — streams live import progress with an aggregate progress bar and prevents overlapping syncs
-- **Analytics second chart** — plots normalized difficulty (Cotacol per 100 km) against moving duration alongside speed vs. Cotacol
-- **Ride categorisation** — mandatory bike type (road/mtb) and ride type (training/indoor/race/transport) per ride, bulk-categorised via a data-quality todo, filtered on library and analytics, and benchmarked excluding indoor/transport
-- **Ride type multiselect filter** — pick multiple ride types at once via a checkbox group on the library and analytics, defaulting to all except transport
+**Data import** - Sync rides from Strava, geo, time, heart rate, power, cadence.
+Rate limit aware and visible progress.
-## Getting started
+**Climb detection** - Auto detect climbs with difficulty. Save official climbs for rematching.
+Climb profile by 100m step.
-```bash
-mise run build
-```
+**Mountain pass** - Match climbs with mountain pass from OSM, and display on map.
-## Usage
+**Rides** - Display rides forever, with filtering and sorting. Categorise all rides.
-```bash
-# Start the web server on http://localhost:8080
-mise run dev-watch
-```
+**Analytics** - Plot speed vs difficulty, difficulty vs duration. Color points by type or season.
-## Options / Configuration
+## Getting started
-| Flag | Description | Default |
-| --- | --- | --- |
-| `-fetch-osm` | Download the France OSM PBF (resumable) | `false` |
-| `-extract-osm` | Extract mountain passes from a France OSM PBF | `""` |
-| `-backfill` | Recompute and persist all ride computed values | `false` |
-| `-backtest` | Benchmark computed vs stored ride values | `false` |
-| `-pprof` | Expose pprof endpoints at `/debug/pprof` | `false` |
+```bash
+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 . -backtest # Benchmark computed vs stored ride values
+go run . -pprof # Expose pprof endpoints at `/debug/pprof`
+```
Configuration is stored in `config.yaml`. Start from `config.example.yaml` and set the Strava credentials before launching the server.
@@ -68,34 +45,62 @@ Standard apps are rate-limited to 100 calls per 15 minutes and 1,000 per day.
## Architecture
- Go 1.27; SQLite via `mattn/go-sqlite3`
-- `ride` — GPX parsing, climb detection, difficulty scores (KOM + Cotacol), and SQLite persistence for imported ride metadata and versioned computed values
-- `mountain_pass` — OSM PBF extraction (`mountain_pass=yes` / `natural=mountain_pass` nodes), pass storage and querying, and crossings/climb matching
+- `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
+- `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
- `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
+- test builders `internal/routetest`, `internal/ridetest`, `internal/dbtest`
```mermaid
flowchart TB
%% Arrow X --> Y means: X depends on Y
main["main (bootstrap: main.go)"]
-
- ride["ride (GPX parsing, climbs, Cotacol, persistence)"]
- mpass["mountain_pass (OSM PBF, crossings)"]
+ cli["cli (import, backfill, benchmark)"]
+ web["web (HTTP + templ, sync)"]
+ rideanalysis["rideanalysis (climbs + crossings)"]
+ ride["ride (GPX parsing, Ride value, metrics, persistence)"]
+ climb["climb (Route, detection, Cotacol)"]
+ official_climb["official_climb (official climb matching)"]
+ mpass["mountain_pass (OSM PBF, crossings, matching)"]
+ geo["geo (distance, nearest, Interval)"]
strava["strava (OAuth, List, Get)"]
config["config (YAML)"]
- web["web (HTTP + templ)"]
- cli["cli (import, backfill, benchmark)"]
main --> cli
+ main --> config
+ cli --> web
cli --> ride
cli --> mpass
- cli --> web
- mpass --> ride
+ cli --> config
+
+ web --> rideanalysis
web --> strava
web --> ride
+ web --> mpass
+ web --> official_climb
web --> config
+ web --> climb
+
+ rideanalysis --> ride
+ rideanalysis --> climb
+ rideanalysis --> mpass
+ rideanalysis --> official_climb
+
+ ride --> climb
+
+ mpass --> climb
+ mpass --> geo
+
+ official_climb --> geo
+
+ config --> official_climb
```
## Research
diff --git a/climb/detection.go b/climb/detection.go
index 9269c2f..a7b92e9 100644
--- a/climb/detection.go
+++ b/climb/detection.go
@@ -5,7 +5,7 @@ import (
"math"
"slices"
- "github.com/martinlehoux/biking_home/core"
+ "github.com/martinlehoux/biking_home/geo"
"github.com/martinlehoux/kagamigo/kcore"
)
@@ -15,13 +15,13 @@ type DetectedClimb struct {
}
// returns the detected climbs but never a climb that overlaps a claimed range.
-func Detect(route Route, claimed []core.Interval, minSlope float64) []DetectedClimb {
+func Detect(route Route, claimed []geo.Interval, minSlope float64) []DetectedClimb {
distances, elevations, coords := route.Distances, route.Elevations, route.Coords
- bounds := make([]core.Interval, 0)
+ bounds := make([]geo.Interval, 0)
for _, region := range freeRegions(0, len(distances)-1, claimed) {
- bounds = append(bounds, climbsBetween(distances, elevations, core.NewInterval(region.Start, region.End), minSlope)...)
+ bounds = append(bounds, climbsBetween(distances, elevations, geo.NewInterval(region.Start, region.End), minSlope)...)
}
- slices.SortFunc(bounds, func(a, b core.Interval) int { return a.Start - b.Start })
+ slices.SortFunc(bounds, func(a, b geo.Interval) int { return a.Start - b.Start })
climbs := make([]DetectedClimb, 0, len(bounds))
for _, b := range bounds {
climbs = append(climbs, DetectedClimb{
@@ -38,8 +38,8 @@ func Detect(route Route, claimed []core.Interval, minSlope float64) []DetectedCl
// freeRegions returns the maximal sub-ranges of [rStart, rEnd] that are not
// covered by any claimed range, after discarding claims that fall outside.
-func freeRegions(rStart, rEnd int, claimed []core.Interval) []core.Interval {
- valid := make([]core.Interval, 0, len(claimed))
+func freeRegions(rStart, rEnd int, claimed []geo.Interval) []geo.Interval {
+ valid := make([]geo.Interval, 0, len(claimed))
for _, claim := range claimed {
// Discard claims outside the route or with a degenerate range.
if claim.Start < 0 || claim.End >= rEnd+1 || claim.Start >= claim.End {
@@ -47,31 +47,31 @@ func freeRegions(rStart, rEnd int, claimed []core.Interval) []core.Interval {
}
valid = append(valid, claim)
}
- slices.SortFunc(valid, func(a, b core.Interval) int { return a.Start - b.Start })
- regions := make([]core.Interval, 0, len(valid)+1)
+ slices.SortFunc(valid, func(a, b geo.Interval) int { return a.Start - b.Start })
+ regions := make([]geo.Interval, 0, len(valid)+1)
current := rStart
for _, claim := range valid {
if claim.End < current {
continue
}
if claim.Start > current {
- regions = append(regions, core.NewInterval(current, claim.Start-1))
+ regions = append(regions, geo.NewInterval(current, claim.Start-1))
}
if claim.End+1 > current {
current = claim.End + 1
}
}
if current <= rEnd {
- regions = append(regions, core.NewInterval(current, rEnd))
+ regions = append(regions, geo.NewInterval(current, rEnd))
}
return regions
}
// climbsBetween returns the detected climb bounds inside bounds from the
// distance and elevation components.
-func climbsBetween(distances, elevations []float64, bounds core.Interval, minSlope float64) []core.Interval {
+func climbsBetween(distances, elevations []float64, bounds geo.Interval, minSlope float64) []geo.Interval {
start, end := bounds.Start, bounds.End
- climbs := []core.Interval{}
+ climbs := []geo.Interval{}
if distances[end]-distances[start] < ClimbDistanceMinimum {
return climbs
}
@@ -84,12 +84,12 @@ func climbsBetween(distances, elevations []float64, bounds core.Interval, minSlo
}
// TODO: Use descent to reduce recursion
if distances[highest]-distances[start] < ClimbDistanceMinimum {
- return climbsBetween(distances, elevations, core.NewInterval(start+1, end), minSlope)
+ return climbsBetween(distances, elevations, geo.NewInterval(start+1, end), minSlope)
}
climb, found := bestClimbBetween(distances[start:highest+1], elevations[start:highest+1], minSlope)
if !found {
// No qualifying climb up to the top; only the part past it can hold climbs.
- return climbsBetween(distances, elevations, core.NewInterval(highest, end), minSlope)
+ return climbsBetween(distances, elevations, geo.NewInterval(highest, end), minSlope)
}
// bestClimbBetween returns an interval relative to its sliced input;
// re-anchor it into this slice's index space.
@@ -97,10 +97,10 @@ func climbsBetween(distances, elevations []float64, bounds core.Interval, minSlo
climbEnd := start + climb.End
if climbDetectionScore(distances[climbStart:climbEnd+1], elevations[climbStart:climbEnd+1]) >= detectionMinimumScore && distances[climbEnd]-distances[climbStart] >= ClimbDistanceMinimum {
slog.Debug("Found climb between", slog.Int("start", int(distances[climbStart])), slog.Int("end", int(distances[climbEnd])))
- climbs = append(climbs, core.NewInterval(climbStart, climbEnd))
+ climbs = append(climbs, geo.NewInterval(climbStart, climbEnd))
}
- climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(start, climbStart), minSlope)...)
- climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(climbEnd, end), minSlope)...)
+ climbs = append(climbs, climbsBetween(distances, elevations, geo.NewInterval(start, climbStart), minSlope)...)
+ climbs = append(climbs, climbsBetween(distances, elevations, geo.NewInterval(climbEnd, end), minSlope)...)
return climbs
}
@@ -121,12 +121,12 @@ func climbDetectionScore(distances, elevations []float64) float64 {
// returns the best qualifying sub-segment (found=false when none exists). The
// returned interval is relative to the start of the given (already sliced)
// slices; callers add back their offset to recover absolute indices.
-func bestClimbBetween(distances, elevations []float64, minSlope float64) (core.Interval, bool) {
+func bestClimbBetween(distances, elevations []float64, minSlope float64) (geo.Interval, bool) {
end := len(distances) - 1
kcore.Assert(end > 0, "empty points")
bestDetectionScore := climbDetectionScore(distances, elevations)
- best := core.NewInterval(0, end)
+ best := geo.NewInterval(0, end)
for i := range end {
detectionScore := climbDetectionScore(distances[i:end], elevations[i:end])
if detectionScore > bestDetectionScore {
@@ -154,7 +154,7 @@ func bestClimbBetween(distances, elevations []float64, minSlope float64) (core.I
}
// Classic best too gentle: re-segment into the best qualifying sub-segment.
bestDetectionScore = math.Inf(-1)
- best = core.NewInterval(-1, -1)
+ best = geo.NewInterval(-1, -1)
for i := range end {
for j := i + 1; j <= end; j++ {
if Slope(distances[i:j+1], elevations[i:j+1]) < minSlope {
@@ -169,7 +169,7 @@ func bestClimbBetween(distances, elevations []float64, minSlope float64) (core.I
}
}
if best.Start < 0 {
- return core.Interval{}, false
+ return geo.Interval{}, false
}
return best, true
}
diff --git a/climb/detection_test.go b/climb/detection_test.go
index 3c6a33b..e02c02b 100644
--- a/climb/detection_test.go
+++ b/climb/detection_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"github.com/martinlehoux/biking_home/climb"
- "github.com/martinlehoux/biking_home/core"
+ "github.com/martinlehoux/biking_home/geo"
"github.com/martinlehoux/biking_home/internal/routetest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -43,7 +43,7 @@ func TestDetectClimbsSkipsMiddleClaim(t *testing.T) {
// A climb, a false flat giving a saddle, then a climb again. Claiming the
// saddle's descent base should leave both climbs detected separately.
route := routetest.MustRoute("2km at 7% | 1km at -7% | 2km at 7%")
- detected := climb.Detect(route, []core.Interval{core.NewInterval(20, 20)}, climb.ClimbSlopeMinimum)
+ detected := climb.Detect(route, []geo.Interval{geo.NewInterval(20, 20)}, climb.ClimbSlopeMinimum)
// No free region falls under the min distance except the two climbs.
assert.Len(t, detected, 2)
@@ -54,13 +54,13 @@ func TestDetectClimbsSkipsMiddleClaim(t *testing.T) {
func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) {
// Claiming the ascent region entirely swallows the climb.
route := routetest.MustRoute("2km at 7% | 20km at 0%")
- detected := climb.Detect(route, []core.Interval{core.NewInterval(0, 40)}, climb.ClimbSlopeMinimum)
+ detected := climb.Detect(route, []geo.Interval{geo.NewInterval(0, 40)}, climb.ClimbSlopeMinimum)
assert.Empty(t, detected)
}
func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) {
route := routetest.MustRoute("2km at 7% | 20km at 0%")
- detected := climb.Detect(route, []core.Interval{core.NewInterval(-5, -1)}, climb.ClimbSlopeMinimum)
+ detected := climb.Detect(route, []geo.Interval{geo.NewInterval(-5, -1)}, climb.ClimbSlopeMinimum)
require.Len(t, detected, 1)
assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0]))
}
@@ -74,10 +74,10 @@ func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) {
route := routetest.MustRoute("2km at 7% | 1km at -7% | 2km at 7%")
// Both climbs claimed, but in reverse order (mimics ORDER BY id returning 30-50 before 0-20).
// Sorted input correctly yields 0 detected climbs.
- sorted := []core.Interval{core.NewInterval(0, 20), core.NewInterval(30, 50)}
+ sorted := []geo.Interval{geo.NewInterval(0, 20), geo.NewInterval(30, 50)}
assert.Empty(t, climb.Detect(route, sorted, climb.ClimbSlopeMinimum))
- unsorted := []core.Interval{core.NewInterval(30, 50), core.NewInterval(0, 20)}
+ unsorted := []geo.Interval{geo.NewInterval(30, 50), geo.NewInterval(0, 20)}
detected := climb.Detect(route, unsorted, climb.ClimbSlopeMinimum)
// Before fix this fails: freeRegions skips {0,20} because 20 < 30 and returns 1 climb.
assert.Empty(t, detected, "unsorted claimed ranges must still suppress overlapping climbs — expected 0, got %v", detected)
diff --git a/core/core.go b/core/core.go
deleted file mode 100644
index e2923c1..0000000
--- a/core/core.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package core
-
-type Interval struct {
- Start, End int
-}
-
-func NewInterval(start, end int) Interval {
- return Interval{
- Start: start,
- End: end,
- }
-}
diff --git a/geo/interval.go b/geo/interval.go
new file mode 100644
index 0000000..33985d0
--- /dev/null
+++ b/geo/interval.go
@@ -0,0 +1,15 @@
+package geo
+
+// Interval is an inclusive index range [Start, End] over position data, used
+// to refer to a stretch of a route by point indexes.
+type Interval struct {
+ Start, End int
+}
+
+// NewInterval returns an Interval with the given start and end indexes.
+func NewInterval(start, end int) Interval {
+ return Interval{
+ Start: start,
+ End: end,
+ }
+}
diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go
index 883d72c..bf3c2bb 100644
--- a/rideanalysis/analysis.go
+++ b/rideanalysis/analysis.go
@@ -4,7 +4,7 @@ import (
"slices"
"github.com/martinlehoux/biking_home/climb"
- "github.com/martinlehoux/biking_home/core"
+ "github.com/martinlehoux/biking_home/geo"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
@@ -31,7 +31,7 @@ type AnalyzedClimb struct {
Cotacol float64
OfficialClimbID int64
OfficialName string
- Interval core.Interval
+ Interval geo.Interval
}
func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) Result {
@@ -40,21 +40,21 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
Crossings: make([]mountain_pass.Crossing, 0),
}
claimedClimbs := make([]AnalyzedClimb, 0)
- claimedRanges := make([]core.Interval, 0)
+ claimedRanges := make([]geo.Interval, 0)
for _, official := range officialClimbs {
for _, matched := range official_climb.MatchClimbs(parsed.Coords, official, matchPolicy.EndpointRadiusM) {
- interval := core.NewInterval(matched.StartIndex, matched.EndIndex)
+ interval := geo.NewInterval(matched.StartIndex, matched.EndIndex)
claimedRanges = append(claimedRanges, interval)
claimedClimbs = append(claimedClimbs, analyzedClimb(routeClimb(parsed.Route, interval), interval, official.Name, official.ID))
}
}
- slices.SortFunc(claimedRanges, func(a, b core.Interval) int { return a.Start - b.Start })
+ slices.SortFunc(claimedRanges, func(a, b geo.Interval) int { return a.Start - b.Start })
for _, detected := range climb.Detect(parsed.Route, claimedRanges, climb.ClimbSlopeMinimum) {
name := ""
if matchedPass, ok := mountain_pass.MatchClimb(detected.Climb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok {
name = matchedPass.Name
}
- interval := core.NewInterval(detected.Start, detected.Start+len(detected.Climb.Distances)-1)
+ interval := geo.NewInterval(detected.Start, detected.Start+len(detected.Climb.Distances)-1)
result.Climbs = append(result.Climbs, analyzedClimb(detected.Climb, interval, name, 0))
}
climbs := make([]AnalyzedClimb, 0, len(claimedClimbs)+len(result.Climbs))
@@ -71,7 +71,7 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
}
// routeClimb returns the climb segment of route covered by interval.
-func routeClimb(route climb.Route, interval core.Interval) climb.Climb {
+func routeClimb(route climb.Route, interval geo.Interval) climb.Climb {
return climb.Climb{
Distances: route.Distances[interval.Start : interval.End+1],
Elevations: route.Elevations[interval.Start : interval.End+1],
@@ -79,7 +79,7 @@ func routeClimb(route climb.Route, interval core.Interval) climb.Climb {
}
}
-func analyzedClimb(segment climb.Climb, interval core.Interval, name string, officialID int64) AnalyzedClimb {
+func analyzedClimb(segment climb.Climb, interval geo.Interval, name string, officialID int64) AnalyzedClimb {
cotacol := segment.DifficultyScore()
return AnalyzedClimb{
Segment: segment,