// Package ridetest builds ride.Ride test fixtures from a compact string DSL. // // Grammar: sections separated by "|", each "km at %" 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). 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 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 } } } // 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 } } // 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) 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 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 }