aboutsummaryrefslogtreecommitdiff
path: root/internal/ridetest/ride.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ridetest/ride.go')
-rw-r--r--internal/ridetest/ride.go298
1 files changed, 17 insertions, 281 deletions
diff --git a/internal/ridetest/ride.go b/internal/ridetest/ride.go
index e74ce49..50a84cd 100644
--- a/internal/ridetest/ride.go
+++ b/internal/ridetest/ride.go
@@ -1,135 +1,36 @@
-// Package ridetest builds ride.Ride test fixtures from a compact string DSL.
+// Package ridetest builds ride.Ride test fixtures from a compact string DSL. It
+// reuses routetest to sample the route geometry and layers ride timestamps and
+// telemetry on top.
//
-// 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 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).
+// Grammar: see routetest.
package ridetest
import (
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/internal/routetest"
"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
- headingLat float64
- headingLon float64
- 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 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
- }
- }
-}
+// Option is re-exported from routetest so existing ridetest callers keep
+// working unchanged.
+type Option = routetest.Option
-// 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 }
-}
+var (
+ Sampling = routetest.Sampling
+ StartCoord = routetest.StartCoord
+ Heading = routetest.Heading
+ StartTime = routetest.StartTime
+ StartElevation = routetest.StartElevation
+)
// 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, headingLat: headingPerKm, headingLon: headingPerKm}
- 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)
+ built, err := routetest.Build(dsl, opts...)
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
+ return ride.FromColumnsWithMetrics(built.Distances, built.Elevations, built.Coords, built.Timestamps, built.HeartRates, built.Cadences, built.Powers), nil
}
// MustRide is like Ride but panics on a malformed description; use it inside
@@ -141,168 +42,3 @@ func MustRide(dsl string, opts ...Option) ride.Ride {
}
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 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
-}