aboutsummaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
Diffstat (limited to 'ride')
-rw-r--r--ride/fit.go215
-rw-r--r--ride/store.go49
2 files changed, 259 insertions, 5 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)
+}
diff --git a/ride/store.go b/ride/store.go
index b906b42..f6de535 100644
--- a/ride/store.go
+++ b/ride/store.go
@@ -4,6 +4,8 @@ import (
"database/sql"
"fmt"
"log/slog"
+ "os"
+ "path/filepath"
"strings"
"time"
@@ -15,6 +17,7 @@ type StoredRide struct {
ID int64
ExternalID string
GPXPath string
+ FitPath string
Name string
Type string
StartDate time.Time
@@ -46,7 +49,7 @@ const (
SortCotacolKm SortColumn = "cotacol_100km"
)
-const columns = "id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type, created_at, updated_at"
+const columns = "id, external_id, gpx_path, fit_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type, created_at, updated_at"
func Save(db *sql.DB, r StoredRide) error {
var cotacolScore any
@@ -70,10 +73,11 @@ func Save(db *sql.DB, r StoredRide) error {
return fmt.Errorf("invalid ride_type %q", r.RideType)
}
_, err := db.Exec(`
- INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO rides (external_id, gpx_path, fit_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(external_id) DO UPDATE SET
gpx_path = excluded.gpx_path,
+ fit_path = excluded.fit_path,
name = excluded.name,
type = excluded.type,
start_date = excluded.start_date,
@@ -89,7 +93,7 @@ func Save(db *sql.DB, r StoredRide) error {
bike_type = CASE WHEN excluded.bike_type = '' THEN rides.bike_type ELSE excluded.bike_type END,
ride_type = CASE WHEN excluded.ride_type = '' THEN rides.ride_type ELSE excluded.ride_type END,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
- `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion, string(r.BikeType), string(r.RideType))
+ `, r.ExternalID, r.GPXPath, r.FitPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion, string(r.BikeType), string(r.RideType))
return err
}
@@ -124,6 +128,41 @@ func Backfill(db *sql.DB, reporters ...func(current, total int)) (int, error) {
}
for i, item := range pending {
report(i+1, len(pending))
+ if item.GPXPath != "" {
+ fitPath := item.FitPath
+ if fitPath == "" {
+ ext := filepath.Ext(item.GPXPath)
+ if ext == "" {
+ fitPath = item.GPXPath + ".fit"
+ } else {
+ fitPath = strings.TrimSuffix(item.GPXPath, ext) + ".fit"
+ }
+ }
+ if _, err := os.Stat(fitPath); os.IsNotExist(err) {
+ parsed, err := ParseFile(GPXRideParser{}, item.GPXPath)
+ if err != nil {
+ slog.Warn("Failed to backfill FIT", "ride", item.ExternalID, "file", item.GPXPath, "error", err)
+ } else {
+ f, err := os.Create(fitPath)
+ if err != nil {
+ slog.Warn("Failed to create FIT file", "ride", item.ExternalID, "file", fitPath, "error", err)
+ } else {
+ if err := EncodeFIT(parsed, f); err != nil {
+ _ = f.Close()
+ _ = os.Remove(fitPath)
+ slog.Warn("Failed to encode FIT", "ride", item.ExternalID, "file", fitPath, "error", err)
+ } else {
+ _ = f.Close()
+ item.FitPath = fitPath
+ }
+ }
+ }
+ } else if err == nil && item.FitPath == "" {
+ item.FitPath = fitPath
+ } else if err != nil && !os.IsNotExist(err) {
+ slog.Warn("Failed to stat FIT file", "ride", item.ExternalID, "file", fitPath, "error", err)
+ }
+ }
if err := Save(db, item); err != nil {
slog.Warn("Failed to backfill ride values", "ride", item.ExternalID, "file", item.GPXPath, "error", err)
continue
@@ -227,7 +266,7 @@ func scanRide(s scanner) (StoredRide, error) {
createdAt string
updatedAt string
)
- err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &bikeType, &rideType, &createdAt, &updatedAt)
+ err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.FitPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &bikeType, &rideType, &createdAt, &updatedAt)
if err != nil {
return StoredRide{}, err
}