From 825cf388b55a6c8441e02149749bb6e2968477c0 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 27 Aug 2026 11:21:17 +0200 Subject: 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. --- internal/ridetest/ride.go | 298 +++--------------------------------- internal/ridetest/ride_test.go | 48 +++--- internal/routetest/routetest.go | 331 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+), 305 deletions(-) create mode 100644 internal/routetest/routetest.go (limited to 'internal') 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 "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). +// 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 -} diff --git a/internal/ridetest/ride_test.go b/internal/ridetest/ride_test.go index 65483f2..46bd632 100644 --- a/internal/ridetest/ride_test.go +++ b/internal/ridetest/ride_test.go @@ -12,30 +12,30 @@ 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]) + 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 + 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]) + 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) { @@ -44,13 +44,13 @@ func TestRideStartCoordHeading(t *testing.T) { // 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) + 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 TestRideHeadingOption(t *testing.T) { @@ -59,9 +59,9 @@ func TestRideHeadingOption(t *testing.T) { // A single kilometer at 0.1°/km ends at (43.1, 5.1), matching rides whose // points are spaced 0.1° apart over a kilometer. - require.Len(t, r.Coords(), 2) - assert.InDelta(t, 43.1, r.Coords()[1].Lat, 1e-9) - assert.InDelta(t, 5.1, r.Coords()[1].Lon, 1e-9) + require.Len(t, r.Coords, 2) + assert.InDelta(t, 43.1, r.Coords[1].Lat, 1e-9) + assert.InDelta(t, 5.1, r.Coords[1].Lon, 1e-9) } func TestRideStartTime(t *testing.T) { @@ -79,8 +79,8 @@ 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]) + assert.Equal(t, 100.0, r.Elevations[0]) + assert.Equal(t, 100.0, r.Elevations[10]) } func TestRideMetricsCompleteColumns(t *testing.T) { 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 "km at %" 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 +} -- cgit v1.3