aboutsummaryrefslogtreecommitdiff
path: root/internal/routetest
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
commit825cf388b55a6c8441e02149749bb6e2968477c0 (patch)
treee3394cef97aa43df9775a1465d3c99f386ce3a9b /internal/routetest
parent366e7b0d671395c2ad32f85ee7706a592db69d15 (diff)
Extract climb detection into a dedicated climb package
Move climb detection, slope, and Cotacol scoring out of ride into a new climb package that operates on a geometry-only Route (distances, elevations, coords). Ride embeds climb.Route and keeps its timing and telemetry columns. - climb.Detect returns []DetectedClimb (Climb + ride-absolute Start). - Climb owns its sliced geometry; indices are local, with Start reported at the detection boundary for absolute position. - Detected bounds use core.Interval; freeRegions claim suppression fixed. - ride no longer builds slices: ClimbFromDist/ClimbFromIndexes/climbFrom and ride.Climb/Interval/Range are removed; rideanalysis, mountain_pass MatchClimb, and web views are rewired to climb types. - New internal/routetest builds climb.Route from the ride DSL; ridetest reuses it and adds timestamps and telemetry. - Tests moved to climb (synthetic detection and difficulty) and kept in ride for GPX-example integration; ride.comparison detection coverage retained. - mountain_pass.nearestCrossing now reuses geo.Nearest.
Diffstat (limited to 'internal/routetest')
-rw-r--r--internal/routetest/routetest.go331
1 files changed, 331 insertions, 0 deletions
diff --git a/internal/routetest/routetest.go b/internal/routetest/routetest.go
new file mode 100644
index 0000000..a116608
--- /dev/null
+++ b/internal/routetest/routetest.go
@@ -0,0 +1,331 @@
+// Package routetest builds climb.Route test fixtures from a compact string DSL
+// describing climbs and descents. ridetest reuses it and layers ride timing and
+// telemetry on top, so climb tests do not depend on the ride package.
+//
+// Grammar: sections separated by "|", each "<distance>km at <slope>%" with
+// optional per-section metrics "hr=150 cad=80 w=200":
+//
+// routetest.MustRoute("2km at 7% | 1km at -7%")
+//
+// Options adjust the generated columns: Sampling (step in meters, default
+// 100), StartCoord (first point; later points progress northeast at the
+// Heading rate, default 0.01° of latitude and longitude per kilometer),
+// StartTime (default the zero time, one second between points) and
+// StartElevation (default 0).
+package routetest
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
+)
+
+const defaultSamplingM = 100
+
+// headingPerKm is the latitude and longitude offset applied to each point per
+// kilometer of ride, giving StartCoord rides a fixed northeast direction.
+const headingPerKm = 0.01
+
+type section struct {
+ distanceM float64
+ slope float64
+ heartRate *float64
+ cadence *float64
+ power *float64
+}
+
+type point struct {
+ distanceM float64
+ elevationM float64
+ coord geodist.Coord
+ timestamp time.Time
+ section section
+}
+
+type options struct {
+ samplingM float64
+ startCoord *geodist.Coord
+ headingLat float64
+ headingLon float64
+ startTime time.Time
+ startElevM float64
+}
+
+// Option configures the route generated by Build.
+type Option func(*options)
+
+// Sampling sets the distance between consecutive points in meters
+// (default 100).
+func Sampling(m float64) Option {
+ return func(o *options) { o.samplingM = m }
+}
+
+// StartCoord sets the coordinate of the first point. Each following point
+// progresses northeast at the rate set by Heading, default 0.01° of latitude
+// and longitude per kilometer.
+func StartCoord(lat, lon float64) Option {
+ return func(o *options) {
+ o.startCoord = &geodist.Coord{Lat: lat, Lon: lon}
+ if o.headingLat == 0 {
+ o.headingLat = headingPerKm
+ }
+ if o.headingLon == 0 {
+ o.headingLon = headingPerKm
+ }
+ }
+}
+
+// Heading sets the latitude and longitude offset applied to each point per
+// kilometer of ride, in degrees (default 0.01 northeast when StartCoord is
+// set). Only meaningful together with StartCoord.
+func Heading(latPerKm, lonPerKm float64) Option {
+ return func(o *options) {
+ o.headingLat = latPerKm
+ o.headingLon = lonPerKm
+ }
+}
+
+// StartTime sets the timestamp of the first point; each following point is
+// one second later (default: the zero time).
+func StartTime(t time.Time) Option {
+ return func(o *options) { o.startTime = t }
+}
+
+// StartElevation sets the elevation of the first point, in meters (default 0).
+func StartElevation(m float64) Option {
+ return func(o *options) { o.startElevM = m }
+}
+
+// Built is the fully sampled output of a description: the geometry columns,
+// timestamps, and, when every section provides them, the metric columns.
+type Built struct {
+ Distances []float64
+ Elevations []float64
+ Coords []geodist.Coord
+ Timestamps []time.Time
+ HeartRates []float64
+ Cadences []float64
+ Powers []float64
+}
+
+// Build parses a description into sampled columns. Metrics (heart rate,
+// cadence, power) are non-nil only when every section sets them.
+func Build(dsl string, opts ...Option) (Built, error) {
+ o := options{samplingM: defaultSamplingM, headingLat: headingPerKm, headingLon: headingPerKm}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.samplingM <= 0 {
+ return Built{}, fmt.Errorf("routetest: sampling must be positive, got %v", o.samplingM)
+ }
+ sections, err := parseSections(dsl)
+ if err != nil {
+ return Built{}, err
+ }
+ if len(sections) == 0 {
+ return Built{}, fmt.Errorf("routetest: empty ride description")
+ }
+ for _, metric := range []struct {
+ name string
+ get func(section) *float64
+ }{
+ {"hr", func(s section) *float64 { return s.heartRate }},
+ {"cad", func(s section) *float64 { return s.cadence }},
+ {"w", func(s section) *float64 { return s.power }},
+ } {
+ present, complete := metricComplete(sections, metric.get)
+ if present && !complete {
+ return Built{}, fmt.Errorf("routetest: %s= must be set on every section or none", metric.name)
+ }
+ }
+ points := buildPoints(sections, o)
+ built := Built{
+ Distances: make([]float64, len(points)),
+ Elevations: make([]float64, len(points)),
+ Coords: make([]geodist.Coord, len(points)),
+ Timestamps: make([]time.Time, len(points)),
+ }
+ if sections[0].heartRate != nil {
+ built.HeartRates = make([]float64, len(points))
+ }
+ if sections[0].cadence != nil {
+ built.Cadences = make([]float64, len(points))
+ }
+ if sections[0].power != nil {
+ built.Powers = make([]float64, len(points))
+ }
+ for i, p := range points {
+ built.Distances[i] = p.distanceM
+ built.Elevations[i] = p.elevationM
+ built.Coords[i] = p.coord
+ built.Timestamps[i] = p.timestamp
+ if built.HeartRates != nil {
+ built.HeartRates[i] = *p.section.heartRate
+ }
+ if built.Cadences != nil {
+ built.Cadences[i] = *p.section.cadence
+ }
+ if built.Powers != nil {
+ built.Powers[i] = *p.section.power
+ }
+ }
+ return built, nil
+}
+
+// Route parses a description into a climb.Route containing only its geometry.
+func Route(dsl string, opts ...Option) (climb.Route, error) {
+ built, err := Build(dsl, opts...)
+ if err != nil {
+ return climb.Route{}, err
+ }
+ return climb.Route{
+ Distances: built.Distances,
+ Elevations: built.Elevations,
+ Coords: built.Coords,
+ }, nil
+}
+
+// MustRoute is like Route but panics on a malformed description; use it inside
+// tests where the description is a static program constant.
+func MustRoute(dsl string, opts ...Option) climb.Route {
+ route, err := Route(dsl, opts...)
+ if err != nil {
+ panic(err)
+ }
+ return route
+}
+
+func parseSections(dsl string) ([]section, error) {
+ rawSections := strings.Split(dsl, "|")
+ sections := make([]section, 0, len(rawSections))
+ for _, raw := range rawSections {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, fmt.Errorf("routetest: empty section in %q", dsl)
+ }
+ sec, err := parseSection(raw)
+ if err != nil {
+ return nil, err
+ }
+ sections = append(sections, sec)
+ }
+ return sections, nil
+}
+
+// parseSection parses a single section like "2km at 7% hr=150 cad=80 w=200".
+func parseSection(raw string) (section, error) {
+ fields := strings.Fields(raw)
+ if len(fields) < 3 {
+ return section{}, fmt.Errorf("routetest: section %q must look like \"2km at 7%%\"", raw)
+ }
+ if fields[1] != "at" {
+ return section{}, fmt.Errorf("routetest: section %q: expected \"at\" between distance and slope, got %q", raw, fields[1])
+ }
+ distanceKm, err := parseUnit(fields[0], "km")
+ if err != nil {
+ return section{}, fmt.Errorf("routetest: section %q: %w", raw, err)
+ }
+ slopePercent, err := parseUnit(fields[2], "%")
+ if err != nil {
+ return section{}, fmt.Errorf("routetest: section %q: %w", raw, err)
+ }
+ if distanceKm <= 0 {
+ return section{}, fmt.Errorf("routetest: section %q: distance must be positive", raw)
+ }
+ sec := section{
+ distanceM: distanceKm * 1000,
+ slope: slopePercent / 100,
+ }
+ for _, token := range fields[3:] {
+ var value *float64
+ switch {
+ case strings.HasPrefix(token, "hr="):
+ value, err = parseMetric(token, "hr")
+ sec.heartRate = value
+ case strings.HasPrefix(token, "cad="):
+ value, err = parseMetric(token, "cad")
+ sec.cadence = value
+ case strings.HasPrefix(token, "w="):
+ value, err = parseMetric(token, "w")
+ sec.power = value
+ default:
+ return section{}, fmt.Errorf("routetest: section %q: unknown metric %q (want hr=, cad= or w=)", raw, token)
+ }
+ if err != nil {
+ return section{}, fmt.Errorf("routetest: section %q: %w", raw, err)
+ }
+ }
+ return sec, nil
+}
+
+func parseUnit(token, unit string) (float64, error) {
+ if !strings.HasSuffix(token, unit) {
+ return 0, fmt.Errorf("expected a value in %s, got %q", unit, token)
+ }
+ return strconv.ParseFloat(strings.TrimSuffix(token, unit), 64)
+}
+
+func parseMetric(token, name string) (*float64, error) {
+ value, err := strconv.ParseFloat(strings.TrimPrefix(token, name+"="), 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid %s value %q", name, token)
+ }
+ return &value, nil
+}
+
+// metricComplete reports whether a metric column is present (any section sets
+// it) and complete (every section sets it).
+func metricComplete(sections []section, get func(section) *float64) (present, complete bool) {
+ set := 0
+ for _, sec := range sections {
+ if get(sec) != nil {
+ set++
+ }
+ }
+ return set > 0, set == len(sections)
+}
+
+func buildPoints(sections []section, o options) []point {
+ lat, lon := 0.0, 0.0
+ if o.startCoord != nil {
+ lat, lon = o.startCoord.Lat, o.startCoord.Lon
+ }
+ // Coordinates derive from the total distance so that exact multiples of
+ // the per-kilometer heading stay exact; a running accumulation drifts
+ // after a few points.
+ coord := func(distanceM float64) geodist.Coord {
+ return geodist.Coord{Lat: lat + o.headingLat*(distanceM/1000), Lon: lon + o.headingLon*(distanceM/1000)}
+ }
+ points := make([]point, 0, len(sections)+1)
+ elevation := o.startElevM
+ timestamp := o.startTime
+ points = append(points, point{
+ distanceM: 0,
+ elevationM: elevation,
+ coord: coord(0),
+ timestamp: timestamp,
+ section: sections[0],
+ })
+ distance := 0.0
+ for _, sec := range sections {
+ curSecDist := 0.0
+ for curSecDist < sec.distanceM {
+ curSecDist += o.samplingM
+ elevation += o.samplingM * sec.slope
+ timestamp = timestamp.Add(time.Second)
+ points = append(points, point{
+ distanceM: distance + curSecDist,
+ elevationM: elevation,
+ coord: coord(distance + curSecDist),
+ timestamp: timestamp,
+ section: sec,
+ })
+ }
+ distance += curSecDist
+ }
+ return points
+}