diff options
Diffstat (limited to 'ride')
| -rw-r--r-- | ride/fit.go | 215 | ||||
| -rw-r--r-- | ride/store.go | 49 |
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 } |
