aboutsummaryrefslogtreecommitdiff
path: root/ride/fit.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 19:03:34 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 19:03:34 +0200
commitd7b93325d4f70eeab369fa8a3c4cce9f0c4e0081 (patch)
tree76a913248fa04325e8e85f34df0cd03654e1ca76 /ride/fit.go
parent558f755f5746a4ee819de87579951b7533688e5d (diff)
Add dual GPX/FIT exporter and backfill for rides
Phase 1 writes both .gpx and .fit on Strava import, adds fit_path column, renames storage.gpx_dir to storage.rides_dir, and backfills missing FIT files from existing GPX without breaking CLI progress. FIT files are gitignored.
Diffstat (limited to 'ride/fit.go')
-rw-r--r--ride/fit.go215
1 files changed, 215 insertions, 0 deletions
diff --git a/ride/fit.go b/ride/fit.go
new file mode 100644
index 0000000..7c4ba2e
--- /dev/null
+++ b/ride/fit.go
@@ -0,0 +1,215 @@
+package ride
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "io"
+ "math"
+ "time"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/kagamigo/kcore"
+ "github.com/tormoder/fit"
+)
+
+// FITRideParser parses FIT files into Ride values. It is the FIT counterpart
+// to GPXRideParser and keeps the same invariants.
+type FITRideParser struct{}
+
+func (p FITRideParser) Parse(reader io.Reader) (Ride, error) {
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ return Ride{}, err
+ }
+ file, err := fit.Decode(bytes.NewReader(data))
+ if err != nil {
+ return Ride{}, err
+ }
+ activity, err := file.Activity()
+ if err != nil {
+ return Ride{}, err
+ }
+ if len(activity.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))
+ timestamps := make([]time.Time, 0, len(records))
+ heartRates := make([]float64, 0, len(records))
+ cadences := make([]float64, 0, len(records))
+ powers := make([]float64, 0, len(records))
+ heartRateComplete := true
+ cadenceComplete := true
+ powerComplete := true
+
+ distance := 0.0
+ var previous *geodist.Coord
+
+ for _, rec := range records {
+ if rec.PositionLat.Invalid() || rec.PositionLong.Invalid() {
+ continue
+ }
+ if rec.Altitude == 0xFFFF {
+ return Ride{}, errors.New("points without elevation")
+ }
+ altitude := rec.GetAltitudeScaled()
+ if math.IsNaN(altitude) {
+ return Ride{}, errors.New("points without elevation")
+ }
+ coord := geodist.Coord{Lat: rec.PositionLat.Degrees(), Lon: rec.PositionLong.Degrees()}
+ if previous != nil {
+ _, km := geodist.HaversineDistance(*previous, coord)
+ d := km * 1000
+ if d == 0 {
+ continue
+ }
+ distance += d
+ }
+ previous = &coord
+ distances = append(distances, distance)
+ elevations = append(elevations, altitude)
+ coords = append(coords, coord)
+ // FIT timestamps are absolute; if missing they are zero time.
+ ts := rec.Timestamp
+ if ts.IsZero() {
+ ts = file.FileId.TimeCreated
+ }
+ timestamps = append(timestamps, ts)
+
+ if rec.HeartRate == 0xFF {
+ heartRateComplete = false
+ heartRates = append(heartRates, 0)
+ } else {
+ heartRates = append(heartRates, float64(rec.HeartRate))
+ }
+ if rec.Cadence == 0xFF {
+ cadenceComplete = false
+ cadences = append(cadences, 0)
+ } else {
+ cadences = append(cadences, float64(rec.Cadence))
+ }
+ if rec.Power == 0xFFFF {
+ powerComplete = false
+ powers = append(powers, 0)
+ } else {
+ powers = append(powers, float64(rec.Power))
+ }
+ }
+
+ if len(distances) < 2 {
+ if len(distances) == 0 {
+ return Ride{}, ErrNoTrackPoints
+ }
+ return Ride{}, errors.New("zero distance")
+ }
+
+ var heartRatesColumn, cadencesColumn, powersColumn []float64
+ if heartRateComplete {
+ heartRatesColumn = heartRates
+ }
+ if cadenceComplete {
+ cadencesColumn = cadences
+ }
+ if powerComplete {
+ powersColumn = powers
+ }
+
+ return fromColumns(distances, elevations, coords, timestamps, heartRatesColumn, cadencesColumn, powersColumn), nil
+}
+
+// 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.
+func EncodeFIT(ride Ride, w io.Writer) error {
+ kcore.Assert(ride.Len() > 0, "no points in ride")
+ h := fit.NewHeader(fit.V20, true)
+ file, err := fit.NewFile(fit.FileTypeActivity, h)
+ if err != nil {
+ return err
+ }
+ file.FileId.Manufacturer = fit.ManufacturerGarmin
+ file.FileId.Type = fit.FileTypeActivity
+ if len(ride.timestamps) > 0 {
+ file.FileId.TimeCreated = ride.timestamps[0]
+ } else {
+ file.FileId.TimeCreated = time.Now()
+ }
+ activity, err := file.Activity()
+ if err != nil {
+ return err
+ }
+
+ heartRates, hasHR := ride.HeartRates()
+ cadences, hasCad := ride.Cadences()
+ powers, hasPower := ride.Powers()
+
+ for i := 0; i < ride.Len(); i++ {
+ rec := fit.NewRecordMsg()
+ rec.Timestamp = ride.timestamps[i]
+ coord := ride.Coords[i]
+ rec.PositionLat = fit.NewLatitudeDegrees(coord.Lat)
+ rec.PositionLong = fit.NewLongitudeDegrees(coord.Lon)
+ // Altitude: scaled (value+500)*5, invalid 0xFFFF
+ elev := ride.Elevations[i]
+ scaled := int((elev + 500) * 5)
+ if scaled < 0 {
+ scaled = 0
+ }
+ if scaled > 0xFFFE {
+ scaled = 0xFFFE
+ }
+ rec.Altitude = uint16(scaled)
+ rec.EnhancedAltitude = 0xFFFFFFFF
+
+ if hasHR {
+ v := int(heartRates[i])
+ if v < 0 {
+ v = 0
+ }
+ if v > 254 {
+ v = 254
+ }
+ rec.HeartRate = uint8(v)
+ } else {
+ rec.HeartRate = 0xFF
+ }
+ if hasCad {
+ v := int(cadences[i])
+ if v < 0 {
+ v = 0
+ }
+ if v > 254 {
+ v = 254
+ }
+ rec.Cadence = uint8(v)
+ } else {
+ rec.Cadence = 0xFF
+ }
+ if hasPower {
+ v := int(powers[i])
+ if v < 0 {
+ v = 0
+ }
+ if v > 65534 {
+ v = 65534
+ }
+ rec.Power = uint16(v)
+ } else {
+ rec.Power = 0xFFFF
+ }
+ // Explicitly mark unused fields as invalid to keep encoding minimal.
+ rec.Distance = 0xFFFFFFFF
+ rec.Speed = 0xFFFF
+ rec.Grade = 0x7FFF
+
+ activity.Records = append(activity.Records, rec)
+ }
+
+ return fit.Encode(w, file, binary.LittleEndian)
+}