diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-27 11:41:08 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-27 11:41:08 +0200 |
| commit | 36f0c229310a0527beaf24b4880b25f9afe437c2 (patch) | |
| tree | 4280768d1e7a5c8f66c3b8c957736956e196172c | |
| parent | 0eadc1fde03153ff4b60655f15ed86367dc6a3d3 (diff) | |
Parse FIT Course files as rides
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | ride/fit.go | 32 | ||||
| -rw-r--r-- | ride/fit_test.go | 74 |
3 files changed, 102 insertions, 6 deletions
@@ -16,7 +16,7 @@ Climb profile by 100m step. **Analytics** - Plot speed vs difficulty, difficulty vs duration. Color points by type or season. -**Preview** - Upload a raw GPX or FIT file at `/preview` to analyze it (climb detection, official-climb matching, mountain-pass crossings) without importing it; official climbs can be saved from a preview. +**Preview** - Upload a raw GPX or FIT file (Activity or Course) at `/preview` to analyze it (climb detection, official-climb matching, mountain-pass crossings) without importing it; official climbs can be saved from a preview. **Elevation fix** - Hover the profile to see `#index · km · m`, run `go run . elevation analyse <id|all>` to flag unrealistic grades (>50% over <30m) and `go run . elevation fix <id> --from <index|km> --to <index|km> [--dry-run]` (or `--auto`) to linearly interpolate elevations between anchors, rewriting FIT+GPX (`.bak` kept) and recomputing Cotacol. diff --git a/ride/fit.go b/ride/fit.go index db9c0b6..61d0a7b 100644 --- a/ride/fit.go +++ b/ride/fit.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/binary" "errors" + "fmt" "io" "math" "time" @@ -13,8 +14,9 @@ import ( "github.com/tormoder/fit" ) -// FITRideParser parses FIT files into Ride values. It is the FIT counterpart -// to GPXRideParser and keeps the same invariants. +// FITRideParser parses FIT Activity and Course files into Ride values. Both +// types store their track as Record messages. It is the FIT counterpart to +// GPXRideParser and keeps the same invariants. type FITRideParser struct{} func (p FITRideParser) Parse(reader io.Reader) (Ride, error) { @@ -26,17 +28,16 @@ func (p FITRideParser) Parse(reader io.Reader) (Ride, error) { if err != nil { return Ride{}, err } - activity, err := file.Activity() + records, err := fitRecords(file) if err != nil { return Ride{}, err } - if len(activity.Records) == 0 { + if len(records) == 0 { return Ride{}, ErrNoTrackPoints } // Collect points discarding invalid positions — FIT encodes missing lat/lon // as 0x7FFFFFFF (Invalid). We also require altitude. - records := activity.Records distances := make([]float64, 0, len(records)) elevations := make([]float64, 0, len(records)) coords := make([]geodist.Coord, 0, len(records)) @@ -123,6 +124,27 @@ func (p FITRideParser) Parse(reader io.Reader) (Ride, error) { return fromColumns(distances, elevations, coords, timestamps, heartRatesColumn, cadencesColumn, powersColumn), nil } +// fitRecords returns the track points of an Activity or Course FIT file; both +// store them as Record messages. +func fitRecords(file *fit.File) ([]*fit.RecordMsg, error) { + switch file.Type() { + case fit.FileTypeActivity: + activity, err := file.Activity() + if err != nil { + return nil, err + } + return activity.Records, nil + case fit.FileTypeCourse: + course, err := file.Course() + if err != nil { + return nil, err + } + return course.Records, nil + default: + return nil, fmt.Errorf("fit file type is %v, not Activity or Course", file.Type()) + } +} + // EncodeFIT writes the ride as a minimal FIT Activity file. It stores only the // fields consumed by Ride (lat/lon, altitude, time, heart rate, cadence, power) // as Record messages. diff --git a/ride/fit_test.go b/ride/fit_test.go new file mode 100644 index 0000000..9e9e0a3 --- /dev/null +++ b/ride/fit_test.go @@ -0,0 +1,74 @@ +package ride_test + +import ( + "bytes" + "encoding/binary" + "testing" + "time" + + "github.com/martinlehoux/biking_home/ride" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tormoder/fit" +) + +func encodeFIT(t *testing.T, fileType fit.FileType, records []*fit.RecordMsg) []byte { + t.Helper() + file, err := fit.NewFile(fileType, fit.NewHeader(fit.V20, true)) + require.NoError(t, err) + file.FileId.Manufacturer = fit.ManufacturerGarmin + file.FileId.Type = fileType + file.FileId.TimeCreated = time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC) + switch fileType { + case fit.FileTypeCourse: + course, err := file.Course() + require.NoError(t, err) + course.Records = records + case fit.FileTypeActivity: + activity, err := file.Activity() + require.NoError(t, err) + activity.Records = records + } + var buf bytes.Buffer + require.NoError(t, fit.Encode(&buf, file, binary.LittleEndian)) + return buf.Bytes() +} + +func fitRecord(lat, lon, elevationM float64, ts time.Time) *fit.RecordMsg { + rec := fit.NewRecordMsg() + rec.Timestamp = ts + rec.PositionLat = fit.NewLatitudeDegrees(lat) + rec.PositionLong = fit.NewLongitudeDegrees(lon) + rec.Altitude = uint16((elevationM + 500) * 5) + rec.HeartRate = 0xFF + rec.Cadence = 0xFF + rec.Power = 0xFFFF + return rec +} + +func TestFITRideParserParsesCourseFile(t *testing.T) { + base := time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC) + data := encodeFIT(t, fit.FileTypeCourse, []*fit.RecordMsg{ + fitRecord(43.0, 5.0, 100, base), + fitRecord(43.0, 5.0, 101, base.Add(5*time.Second)), // stationary: skipped + fitRecord(43.001, 5.001, 200, base.Add(10*time.Second)), + }) + + parsed, err := (ride.FITRideParser{}).Parse(bytes.NewReader(data)) + require.NoError(t, err) + require.Equal(t, 2, parsed.Len()) + assert.Equal(t, 0.0, parsed.Distances[0]) + assert.Greater(t, parsed.Distances[1], 0.0) + assert.Equal(t, []float64{100, 200}, parsed.Elevations) + assert.Equal(t, []time.Time{base, base.Add(10 * time.Second)}, parsed.Timestamps()) + _, hasHR := parsed.HeartRates() + assert.False(t, hasHR, "course records carry no telemetry") +} + +func TestFITRideParserRejectsFileWithoutTrack(t *testing.T) { + data := encodeFIT(t, fit.FileTypeSettings, nil) + + _, err := (ride.FITRideParser{}).Parse(bytes.NewReader(data)) + require.Error(t, err) + assert.Equal(t, "fit file type is Settings, not Activity or Course", err.Error()) +} |
