aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/ridetest/ride.go286
-rw-r--r--internal/ridetest/ride_test.go146
-rw-r--r--ride/climb_test.go68
-rw-r--r--ride/difficulty_test.go17
-rw-r--r--ride/ride.go7
5 files changed, 455 insertions, 69 deletions
diff --git a/internal/ridetest/ride.go b/internal/ridetest/ride.go
new file mode 100644
index 0000000..a6413a9
--- /dev/null
+++ b/internal/ridetest/ride.go
@@ -0,0 +1,286 @@
+// Package ridetest builds ride.Ride test fixtures from a compact string DSL.
+//
+// Grammar: sections separated by "|", each "<distance>km at <slope>%" with
+// optional per-section metrics "hr=150 cad=80 w=200":
+//
+// ridetest.MustRide("2km at 7% | 1km at -7%")
+//
+// Options adjust the generated columns: Sampling (step in meters, default
+// 100), StartCoord (first point; later points progress northeast by 0.01° of
+// latitude and longitude per kilometer), StartTime (default the zero time,
+// one second between points) and StartElevation (default 0).
+package ridetest
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/ride"
+)
+
+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
+ startTime time.Time
+ startElevM float64
+}
+
+// Option configures the ride generated by Ride.
+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 by 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} }
+}
+
+// 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 }
+}
+
+// Ride parses a ride description into a ride.Ride.
+//
+// Example: "2km at 7% | 1km at -7%".
+func Ride(dsl string, opts ...Option) (ride.Ride, error) {
+ o := options{samplingM: defaultSamplingM}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.samplingM <= 0 {
+ return ride.Ride{}, fmt.Errorf("ridetest: sampling must be positive, got %v", o.samplingM)
+ }
+ sections, err := parseSections(dsl)
+ if err != nil {
+ return ride.Ride{}, err
+ }
+ if len(sections) == 0 {
+ return ride.Ride{}, fmt.Errorf("ridetest: 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 ride.Ride{}, fmt.Errorf("ridetest: %s= must be set on every section or none", metric.name)
+ }
+ }
+ return buildRide(sections, o), nil
+}
+
+// MustRide is like Ride but panics on a malformed description; use it inside
+// tests where the description is a static program constant.
+func MustRide(dsl string, opts ...Option) ride.Ride {
+ ride, err := Ride(dsl, opts...)
+ if err != nil {
+ panic(err)
+ }
+ return ride
+}
+
+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("ridetest: 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("ridetest: section %q must look like \"2km at 7%%\"", raw)
+ }
+ if fields[1] != "at" {
+ return section{}, fmt.Errorf("ridetest: 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("ridetest: section %q: %w", raw, err)
+ }
+ slopePercent, err := parseUnit(fields[2], "%")
+ if err != nil {
+ return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err)
+ }
+ if distanceKm <= 0 {
+ return section{}, fmt.Errorf("ridetest: 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("ridetest: section %q: unknown metric %q (want hr=, cad= or w=)", raw, token)
+ }
+ if err != nil {
+ return section{}, fmt.Errorf("ridetest: 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 buildRide(sections []section, o options) ride.Ride {
+ points := buildPoints(sections, o)
+ distances := make([]float64, len(points))
+ elevations := make([]float64, len(points))
+ coords := make([]geodist.Coord, len(points))
+ timestamps := make([]time.Time, len(points))
+ var heartRates, cadences, powers []float64
+ if sections[0].heartRate != nil {
+ heartRates = make([]float64, len(points))
+ }
+ if sections[0].cadence != nil {
+ cadences = make([]float64, len(points))
+ }
+ if sections[0].power != nil {
+ powers = make([]float64, len(points))
+ }
+ for i, p := range points {
+ distances[i] = p.distanceM
+ elevations[i] = p.elevationM
+ coords[i] = p.coord
+ timestamps[i] = p.timestamp
+ if heartRates != nil {
+ heartRates[i] = *p.section.heartRate
+ }
+ if cadences != nil {
+ cadences[i] = *p.section.cadence
+ }
+ if powers != nil {
+ powers[i] = *p.section.power
+ }
+ }
+ return ride.FromColumnsWithMetrics(distances, elevations, coords, timestamps, heartRates, cadences, powers)
+}
+
+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 (0.01° x km as float64) stay exact; a running
+ // accumulation drifts after a few points.
+ coord := func(distanceM float64) geodist.Coord {
+ return geodist.Coord{Lat: lat + headingPerKm*(distanceM/1000), Lon: lon + headingPerKm*(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
+}
diff --git a/internal/ridetest/ride_test.go b/internal/ridetest/ride_test.go
new file mode 100644
index 0000000..69c084d
--- /dev/null
+++ b/internal/ridetest/ride_test.go
@@ -0,0 +1,146 @@
+package ridetest
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRideSingleSection(t *testing.T) {
+ r, err := Ride("1km at 10%")
+ require.NoError(t, err)
+
+ assert.Len(t, r.Distances(), 11)
+ assert.Equal(t, 0.0, r.Distances()[0])
+ assert.Equal(t, 1000.0, r.Distances()[10])
+ assert.Equal(t, 0.0, r.Elevations()[0])
+ assert.Equal(t, 100.0, r.Elevations()[10])
+}
+
+func TestRideSectionsWithDescentAndFlat(t *testing.T) {
+ r, err := Ride("0.5km at 10% | 0.5km at -10%")
+ require.NoError(t, err)
+
+ assert.Len(t, r.Distances(), 11)
+ assert.Equal(t, 1000.0, r.Distances()[10])
+ assert.Equal(t, 50.0, r.Elevations()[5]) // top of the first section
+ assert.Equal(t, 0.0, r.Elevations()[10]) // back to start elevation
+}
+
+func TestRideSamplingOption(t *testing.T) {
+ r, err := Ride("1km at 10%", Sampling(50))
+ require.NoError(t, err)
+
+ assert.Len(t, r.Distances(), 21)
+ assert.Equal(t, 1000.0, r.Distances()[20])
+ assert.Equal(t, 5.0, r.Elevations()[1])
+}
+
+func TestRideStartCoordHeading(t *testing.T) {
+ r, err := Ride("2km at 0%", StartCoord(43.10, 5.10), Sampling(1000))
+ require.NoError(t, err)
+
+ // Coordinates advance northeast by 0.01° per km; float64 literals like
+ // 43.11 drift in the last bits, so compare with a small tolerance.
+ require.Len(t, r.Coords(), 3)
+ assert.InDelta(t, 43.10, r.Coords()[0].Lat, 1e-9)
+ assert.InDelta(t, 5.10, r.Coords()[0].Lon, 1e-9)
+ assert.InDelta(t, 43.11, r.Coords()[1].Lat, 1e-9)
+ assert.InDelta(t, 5.11, r.Coords()[1].Lon, 1e-9)
+ assert.InDelta(t, 43.12, r.Coords()[2].Lat, 1e-9)
+ assert.InDelta(t, 5.12, r.Coords()[2].Lon, 1e-9)
+}
+
+func TestRideStartTime(t *testing.T) {
+ start := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ r, err := Ride("1km at 0%", StartTime(start), Sampling(500))
+ require.NoError(t, err)
+
+ assert.Len(t, r.Timestamps(), 3)
+ assert.Equal(t, start, r.Timestamps()[0])
+ assert.Equal(t, start.Add(time.Second), r.Timestamps()[1])
+ assert.Equal(t, start.Add(2*time.Second), r.Timestamps()[2])
+}
+
+func TestRideStartElevation(t *testing.T) {
+ r, err := Ride("1km at 0%", StartElevation(100))
+ require.NoError(t, err)
+
+ assert.Equal(t, 100.0, r.Elevations()[0])
+ assert.Equal(t, 100.0, r.Elevations()[10])
+}
+
+func TestRideMetricsCompleteColumns(t *testing.T) {
+ r, err := Ride("0.5km at 0% hr=150 cad=80 w=200 | 0.5km at 0% hr=140 cad=70 w=150")
+ require.NoError(t, err)
+
+ heartRates, ok := r.HeartRates()
+ require.True(t, ok)
+ assert.Equal(t, 150.0, heartRates[0])
+ assert.Equal(t, 150.0, heartRates[5])
+ assert.Equal(t, 140.0, heartRates[6])
+ assert.Equal(t, 140.0, heartRates[10])
+
+ cadences, ok := r.Cadences()
+ require.True(t, ok)
+ assert.Equal(t, 80.0, cadences[0])
+ assert.Equal(t, 70.0, cadences[10])
+
+ powers, ok := r.Powers()
+ require.True(t, ok)
+ assert.Equal(t, 200.0, powers[0])
+ assert.Equal(t, 150.0, powers[10])
+}
+
+func TestRideMetricsWithoutOptions(t *testing.T) {
+ r, err := Ride("1km at 0%")
+ require.NoError(t, err)
+
+ _, ok := r.HeartRates()
+ assert.False(t, ok)
+ _, ok = r.Cadences()
+ assert.False(t, ok)
+ _, ok = r.Powers()
+ assert.False(t, ok)
+}
+
+func TestRideMetricsPartialColumnFails(t *testing.T) {
+ _, err := Ride("0.5km at 0% hr=150 | 0.5km at 0%")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "hr=")
+}
+
+func TestRideParseErrors(t *testing.T) {
+ cases := []string{
+ "",
+ " ",
+ "|",
+ "1km at 0% |",
+ "at 7%",
+ "1km 7%",
+ "0km at 7%",
+ "-1km at 7%",
+ "1km at abc%",
+ "xyz at 7%",
+ "1km away 7%",
+ "1km at 7% foo=1",
+ "1km at 7% hr=abc",
+ }
+ for _, dsl := range cases {
+ _, err := Ride(dsl)
+ require.Error(t, err, "dsl %q", dsl)
+ }
+}
+
+func TestRideRejectsNonPositiveSampling(t *testing.T) {
+ _, err := Ride("1km at 0%", Sampling(0))
+ require.Error(t, err)
+ _, err = Ride("1km at 0%", Sampling(-10))
+ require.Error(t, err)
+}
+
+func TestMustRidePanicsOnMalformedDSL(t *testing.T) {
+ assert.Panics(t, func() { MustRide("nope") })
+}
diff --git a/ride/climb_test.go b/ride/climb_test.go
index 6b5c097..3632da7 100644
--- a/ride/climb_test.go
+++ b/ride/climb_test.go
@@ -1,72 +1,18 @@
package ride_test
import (
- "strconv"
- "strings"
"testing"
- "time"
- "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/internal/ridetest"
"github.com/martinlehoux/biking_home/ride"
- "github.com/martinlehoux/kagamigo/kcore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-type RideBuilderSection struct {
- slope float64
- distance float64
-}
-
-type RideBuilder struct {
- precision float64
- sections []RideBuilderSection
-}
-
-// Example: 13.4km at 7.5%
-func (b RideBuilder) WithSection(input string) RideBuilder {
- inputs := strings.Split(input, " ")
- kcore.Assert(strings.HasSuffix(inputs[0], "km"), "wrong unit")
- distanceKm, err := strconv.ParseFloat(strings.TrimSuffix(inputs[0], "km"), 64)
- kcore.Expect(err, "faile to parse kms")
- kcore.Assert(strings.HasSuffix(inputs[2], "%"), "wrong unit")
- slopePercent, err := strconv.ParseFloat(strings.TrimSuffix(inputs[2], "%"), 64)
- kcore.Expect(err, "failed to parse %")
-
- b.sections = append(b.sections, RideBuilderSection{
- slope: slopePercent / 100,
- distance: distanceKm * 1000,
- })
-
- return b
-}
-
-func (b RideBuilder) Build() ride.Ride {
- distances := []float64{0}
- elevations := []float64{0}
- coords := []geodist.Coord{{}}
- timestamps := []time.Time{{}}
- distance := 0.0
- elevation := 0.0
- for _, section := range b.sections {
- curSecDist := 0.0
- for curSecDist < section.distance {
- curSecDist += b.precision
- elevation += b.precision * section.slope
- distances = append(distances, distance+curSecDist)
- elevations = append(elevations, elevation)
- coords = append(coords, geodist.Coord{})
- timestamps = append(timestamps, timestamps[len(timestamps)-1].Add(time.Second))
- }
- distance += curSecDist
- }
- return ride.FromColumns(distances, elevations, coords, timestamps)
-}
-
var parser = ride.GPXRideParser{}
func TestClimbThenFalseFlat(t *testing.T) {
- r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("10km at 1%").Build()
+ r := ridetest.MustRide("2km at 7% | 10km at 1%")
climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
assert.Len(t, climbs, 1)
@@ -74,7 +20,7 @@ func TestClimbThenFalseFlat(t *testing.T) {
}
func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) {
- r := RideBuilder{precision: 100}.WithSection("10km at 1%").WithSection("2km at 7%").WithSection("1km at -7%").WithSection("10km at 1%").Build()
+ r := ridetest.MustRide("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%")
climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
assert.Len(t, climbs, 1)
@@ -147,7 +93,7 @@ func TestAlpesVerdonTour20230617(t *testing.T) {
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.
- r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("1km at -7%").WithSection("2km at 7%").Build()
+ r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%")
climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}}, ride.ClimbSlopeMinimum)
// No free region falls under the min distance except the two climbs.
@@ -158,13 +104,13 @@ func TestDetectClimbsSkipsMiddleClaim(t *testing.T) {
func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) {
// Claiming the ascent region entirely swallows the climb.
- r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build()
+ r := ridetest.MustRide("2km at 7% | 20km at 0%")
climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}, ride.ClimbSlopeMinimum)
assert.Empty(t, climbs)
}
func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) {
- r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build()
+ r := ridetest.MustRide("2km at 7% | 20km at 0%")
climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}}, ride.ClimbSlopeMinimum)
require.Len(t, climbs, 1)
assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
@@ -176,7 +122,7 @@ func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) {
// 2140-2627 before Lauriers 1215-1588) caused freeRegions to skip the
// earlier claim and leave the overlapping Cat 4 1352-1601 visible.
// Minimal reproduction: two climbs 0-2km and 3-5km separated by a descent.
- r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("1km at -7%").WithSection("2km at 7%").Build()
+ r := ridetest.MustRide("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 := []ride.Range{{Start: 0, End: 20}, {Start: 30, End: 50}}
diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go
index 2e7cf56..bd316b4 100644
--- a/ride/difficulty_test.go
+++ b/ride/difficulty_test.go
@@ -4,35 +4,36 @@ import (
"math"
"testing"
+ "github.com/martinlehoux/biking_home/internal/ridetest"
"github.com/martinlehoux/biking_home/ride"
"github.com/stretchr/testify/assert"
)
func TestCotacolConstantClimb(t *testing.T) {
- r := RideBuilder{precision: 100}.WithSection("2km at 7%").Build()
+ r := ridetest.MustRide("2km at 7%")
assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9)
}
func TestCotacolIgnoresDescent(t *testing.T) {
- r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("2km at -7%").Build()
+ r := ridetest.MustRide("2km at 7% | 2km at -7%")
assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9)
}
func TestCotacolFlatIsZero(t *testing.T) {
- r := RideBuilder{precision: 100}.WithSection("5km at 0%").Build()
+ r := ridetest.MustRide("5km at 0%")
assert.Zero(t, ride.Cotacol(r))
}
func TestCotacolSteepShortCountsMore(t *testing.T) {
- steep := RideBuilder{precision: 100}.WithSection("0.5km at 14%").Build()
- long := RideBuilder{precision: 100}.WithSection("1km at 7%").Build()
+ steep := ridetest.MustRide("0.5km at 14%")
+ long := ridetest.MustRide("1km at 7%")
assert.InDelta(t, 98.0, ride.Cotacol(steep), 1e-9)
assert.InDelta(t, 49.0, ride.Cotacol(long), 1e-9)
}
func TestCotacolPrecisionInvariant(t *testing.T) {
- coarse := RideBuilder{precision: 100}.WithSection("3km at 5%").Build()
- fine := RideBuilder{precision: 50}.WithSection("3km at 5%").Build()
+ coarse := ridetest.MustRide("3km at 5%")
+ fine := ridetest.MustRide("3km at 5%", ridetest.Sampling(50))
assert.InDelta(t, ride.Cotacol(coarse), ride.Cotacol(fine), 1e-9)
}
@@ -43,7 +44,7 @@ func TestCotacolExampleRide(t *testing.T) {
}
func TestCotacolClimbNotAtStart(t *testing.T) {
- r := RideBuilder{precision: 100}.WithSection("1km at 0%").WithSection("2km at 7%").Build()
+ r := ridetest.MustRide("1km at 0% | 2km at 7%")
climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
assert.Len(t, climbs, 1)
assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9)
diff --git a/ride/ride.go b/ride/ride.go
index 1de8b07..7863878 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -161,6 +161,13 @@ func FromColumns(distances []float64, elevations []float64, coords []geodist.Coo
return fromColumns(distances, elevations, coords, timestamps, nil, nil, nil)
}
+// FromColumnsWithMetrics builds a Ride from its columns, including optional
+// heart-rate, cadence and power components of the same length as the other
+// columns.
+func FromColumnsWithMetrics(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time, heartRates, cadences, powers []float64) Ride {
+ return fromColumns(distances, elevations, coords, timestamps, heartRates, cadences, powers)
+}
+
func fromColumns(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time, heartRates, cadences, powers []float64) Ride {
ride := Ride{
distances: distances,