diff --git a/src/main/java/org/operaton/fitpub/service/AchievementService.java b/src/main/java/org/operaton/fitpub/service/AchievementService.java index 5249cd7..1e13d05 100644 --- a/src/main/java/org/operaton/fitpub/service/AchievementService.java +++ b/src/main/java/org/operaton/fitpub/service/AchievementService.java @@ -362,15 +362,15 @@ public class AchievementService { List achievements = new ArrayList<>(); if (activity.getMetrics() != null && activity.getMetrics().getMaxSpeed() != null) { - // Convert m/s to km/h - double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue() * 3.6; + // maxSpeed is already in km/h from FitParser + double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue(); - if (maxSpeedKmh >= 50 && !hasAchievement(userId, Achievement.AchievementType.SPEED_DEMON)) { + if (maxSpeedKmh >= 40 && !hasAchievement(userId, Achievement.AchievementType.SPEED_DEMON)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.SPEED_DEMON, "Speed Demon", - "Reached 50+ km/h!", + "Reached 40+ km/h!", "⚡", "#ffd700", activity.getId(), diff --git a/src/main/java/org/operaton/fitpub/service/ActivitySummaryService.java b/src/main/java/org/operaton/fitpub/service/ActivitySummaryService.java index 66908c1..dec67b5 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivitySummaryService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivitySummaryService.java @@ -143,14 +143,17 @@ public class ActivitySummaryService { .map(a -> a.getElevationGain() != null ? a.getElevationGain() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - // Calculate max speed - BigDecimal maxSpeed = activities.stream() + // Calculate max speed (convert from km/h to m/s for storage) + BigDecimal maxSpeedKmh = activities.stream() .filter(a -> a.getMetrics() != null && a.getMetrics().getMaxSpeed() != null) .map(a -> a.getMetrics().getMaxSpeed()) .max(BigDecimal::compareTo) .orElse(null); + BigDecimal maxSpeed = maxSpeedKmh != null + ? maxSpeedKmh.divide(BigDecimal.valueOf(3.6), 2, RoundingMode.HALF_UP) + : null; - // Calculate average speed + // Calculate average speed (in m/s) BigDecimal avgSpeed = null; if (totalDuration > 0 && totalDistance.compareTo(BigDecimal.ZERO) > 0) { avgSpeed = totalDistance.divide(BigDecimal.valueOf(totalDuration), 2, RoundingMode.HALF_UP); diff --git a/src/main/java/org/operaton/fitpub/util/FitParser.java b/src/main/java/org/operaton/fitpub/util/FitParser.java index 062cd85..de78929 100644 --- a/src/main/java/org/operaton/fitpub/util/FitParser.java +++ b/src/main/java/org/operaton/fitpub/util/FitParser.java @@ -42,6 +42,12 @@ public class FitParser { // Lazy-loaded timezone engine (expensive to initialize) private static TimeZoneEngine timezoneEngine = null; + private final SpeedSmoother speedSmoother; + + public FitParser(SpeedSmoother speedSmoother) { + this.speedSmoother = speedSmoother; + } + /** * Parses a FIT file and returns the extracted data. * @@ -104,6 +110,9 @@ public class FitParser { // Determine timezone from first GPS coordinate determineTimezone(parsedData); + // Apply speed smoothing and recalculate max speed + smoothSpeedData(parsedData); + log.info("Successfully parsed FIT file: {} track points, activity type: {}, timezone: {}", parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone()); @@ -288,6 +297,33 @@ public class FitParser { } } + /** + * Applies speed smoothing to track points and updates max speed in metrics. + * Removes unrealistic GPS speed spikes and recalculates max speed. + */ + private void smoothSpeedData(ParsedFitData parsedData) { + if (parsedData.getTrackPoints().isEmpty() || parsedData.getMetrics() == null) { + return; + } + + // Smooth speed data and get recalculated max speed + BigDecimal smoothedMaxSpeed = speedSmoother.smoothAndCalculateMaxSpeed( + parsedData.getTrackPoints(), + parsedData.getActivityType() + ); + + // Update max speed in metrics if we got a valid smoothed value + if (smoothedMaxSpeed != null) { + BigDecimal originalMaxSpeed = parsedData.getMetrics().getMaxSpeed(); + parsedData.getMetrics().setMaxSpeed(smoothedMaxSpeed); + + if (originalMaxSpeed != null && smoothedMaxSpeed.compareTo(originalMaxSpeed) < 0) { + log.info("Smoothed max speed from {} km/h to {} km/h (removed GPS artifacts)", + originalMaxSpeed, smoothedMaxSpeed); + } + } + } + /** * Determines the timezone based on the first GPS coordinate. * Uses TimeZoneEngine library for accurate timezone lookup from coordinates. diff --git a/src/main/java/org/operaton/fitpub/util/SpeedSmoother.java b/src/main/java/org/operaton/fitpub/util/SpeedSmoother.java new file mode 100644 index 0000000..620c205 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/util/SpeedSmoother.java @@ -0,0 +1,240 @@ +package org.operaton.fitpub.util; + +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.entity.Activity; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Utility for smoothing speed data from GPS tracks. + * Removes unrealistic spikes caused by GPS inaccuracies. + */ +@Component +@Slf4j +public class SpeedSmoother { + + // Maximum realistic speeds in m/s by activity type + private static final double MAX_RUNNING_SPEED_MPS = 15.0; // ~54 km/h (elite sprinter) + private static final double MAX_CYCLING_SPEED_MPS = 30.0; // ~108 km/h (downhill/sprint) + private static final double MAX_WALKING_SPEED_MPS = 3.0; // ~11 km/h + private static final double MAX_HIKING_SPEED_MPS = 4.0; // ~14 km/h + private static final double MAX_SWIMMING_SPEED_MPS = 3.0; // ~11 km/h + private static final double MAX_DEFAULT_SPEED_MPS = 20.0; // ~72 km/h + + // Maximum realistic acceleration in m/s² (human physical limits) + private static final double MAX_ACCELERATION_RUNNING = 4.0; + private static final double MAX_ACCELERATION_CYCLING = 5.0; + private static final double MAX_ACCELERATION_DEFAULT = 6.0; + + // Moving median window size for smoothing + private static final int MEDIAN_WINDOW_SIZE = 5; + + /** + * Smooths speed data for track points and recalculates max speed. + * + * @param trackPoints the track points with speed data + * @param activityType the activity type + * @return smoothed maximum speed in km/h, or null if no valid speeds + */ + public BigDecimal smoothAndCalculateMaxSpeed( + List trackPoints, + Activity.ActivityType activityType + ) { + if (trackPoints == null || trackPoints.isEmpty()) { + return null; + } + + double maxSpeedMps = getMaxSpeedThreshold(activityType); + double maxAcceleration = getMaxAccelerationThreshold(activityType); + + // Step 1: Apply speed threshold to remove obvious outliers + applySpeedThreshold(trackPoints, maxSpeedMps); + + // Step 2: Apply acceleration-based filtering + applyAccelerationFilter(trackPoints, maxAcceleration); + + // Step 3: Apply moving median filter for smoothing + applyMedianFilter(trackPoints); + + // Step 4: Calculate max speed from smoothed data + BigDecimal maxSpeed = calculateMaxSpeed(trackPoints); + + if (maxSpeed != null) { + log.debug("Smoothed max speed: {} km/h for activity type: {}", + maxSpeed, activityType); + } + + return maxSpeed; + } + + /** + * Gets the maximum realistic speed threshold for an activity type. + */ + private double getMaxSpeedThreshold(Activity.ActivityType activityType) { + if (activityType == null) { + return MAX_DEFAULT_SPEED_MPS; + } + + return switch (activityType) { + case RUN -> MAX_RUNNING_SPEED_MPS; + case RIDE -> MAX_CYCLING_SPEED_MPS; + case WALK -> MAX_WALKING_SPEED_MPS; + case HIKE -> MAX_HIKING_SPEED_MPS; + case SWIM -> MAX_SWIMMING_SPEED_MPS; + default -> MAX_DEFAULT_SPEED_MPS; + }; + } + + /** + * Gets the maximum realistic acceleration for an activity type. + */ + private double getMaxAccelerationThreshold(Activity.ActivityType activityType) { + if (activityType == null) { + return MAX_ACCELERATION_DEFAULT; + } + + return switch (activityType) { + case RUN -> MAX_ACCELERATION_RUNNING; + case RIDE -> MAX_ACCELERATION_CYCLING; + default -> MAX_ACCELERATION_DEFAULT; + }; + } + + /** + * Applies speed threshold to remove obvious outliers. + * Replaces speeds exceeding threshold with null. + */ + private void applySpeedThreshold(List trackPoints, double maxSpeedMps) { + int outlierCount = 0; + + for (FitParser.TrackPointData point : trackPoints) { + if (point.getSpeed() != null) { + // Convert km/h to m/s for comparison + double speedMps = point.getSpeed().doubleValue() / 3.6; + + if (speedMps > maxSpeedMps) { + point.setSpeed(null); + outlierCount++; + } + } + } + + if (outlierCount > 0) { + log.debug("Removed {} speed outliers exceeding threshold {} m/s", + outlierCount, maxSpeedMps); + } + } + + /** + * Applies acceleration-based filtering. + * Removes points with unrealistic acceleration changes. + */ + private void applyAccelerationFilter( + List trackPoints, + double maxAcceleration + ) { + int outlierCount = 0; + + for (int i = 1; i < trackPoints.size(); i++) { + FitParser.TrackPointData prev = trackPoints.get(i - 1); + FitParser.TrackPointData curr = trackPoints.get(i); + + if (prev.getSpeed() == null || curr.getSpeed() == null || + prev.getTimestamp() == null || curr.getTimestamp() == null) { + continue; + } + + // Calculate time delta in seconds + Duration timeDelta = Duration.between(prev.getTimestamp(), curr.getTimestamp()); + double seconds = timeDelta.getSeconds() + timeDelta.getNano() / 1_000_000_000.0; + + if (seconds <= 0) { + continue; + } + + // Calculate acceleration (m/s²) + double speedPrevMps = prev.getSpeed().doubleValue() / 3.6; + double speedCurrMps = curr.getSpeed().doubleValue() / 3.6; + double acceleration = Math.abs(speedCurrMps - speedPrevMps) / seconds; + + if (acceleration > maxAcceleration) { + // Mark current point's speed as invalid + curr.setSpeed(null); + outlierCount++; + } + } + + if (outlierCount > 0) { + log.debug("Removed {} speed values with unrealistic acceleration (> {} m/s²)", + outlierCount, maxAcceleration); + } + } + + /** + * Applies moving median filter to smooth speed data. + * Fills in nulls with interpolated values where possible. + */ + private void applyMedianFilter(List trackPoints) { + int windowSize = Math.min(MEDIAN_WINDOW_SIZE, trackPoints.size()); + if (windowSize < 3) { + return; // Not enough points for meaningful smoothing + } + + List smoothedSpeeds = new ArrayList<>(trackPoints.size()); + + for (int i = 0; i < trackPoints.size(); i++) { + int start = Math.max(0, i - windowSize / 2); + int end = Math.min(trackPoints.size(), i + windowSize / 2 + 1); + + // Collect valid speeds in window + List windowSpeeds = new ArrayList<>(); + for (int j = start; j < end; j++) { + if (trackPoints.get(j).getSpeed() != null) { + windowSpeeds.add(trackPoints.get(j).getSpeed().doubleValue()); + } + } + + if (!windowSpeeds.isEmpty()) { + // Calculate median + Collections.sort(windowSpeeds); + double median; + int size = windowSpeeds.size(); + if (size % 2 == 0) { + median = (windowSpeeds.get(size / 2 - 1) + windowSpeeds.get(size / 2)) / 2.0; + } else { + median = windowSpeeds.get(size / 2); + } + + smoothedSpeeds.add(BigDecimal.valueOf(median).setScale(2, RoundingMode.HALF_UP)); + } else { + smoothedSpeeds.add(null); + } + } + + // Apply smoothed speeds back to track points + for (int i = 0; i < trackPoints.size(); i++) { + if (smoothedSpeeds.get(i) != null) { + trackPoints.get(i).setSpeed(smoothedSpeeds.get(i)); + } + } + + log.debug("Applied moving median filter with window size {}", windowSize); + } + + /** + * Calculates maximum speed from smoothed track points. + */ + private BigDecimal calculateMaxSpeed(List trackPoints) { + return trackPoints.stream() + .map(FitParser.TrackPointData::getSpeed) + .filter(speed -> speed != null) + .max(BigDecimal::compareTo) + .orElse(null); + } +} diff --git a/src/test/java/org/operaton/fitpub/service/AchievementServiceTest.java b/src/test/java/org/operaton/fitpub/service/AchievementServiceTest.java index b4e881d..ca52705 100644 --- a/src/test/java/org/operaton/fitpub/service/AchievementServiceTest.java +++ b/src/test/java/org/operaton/fitpub/service/AchievementServiceTest.java @@ -277,10 +277,10 @@ class AchievementServiceTest { @Test @DisplayName("Should award speed demon achievement") void testCheckAndAwardAchievements_SpeedDemon() { - // Given - Activity with 50+ km/h speed (13.89+ m/s) + // Given - Activity with 40+ km/h speed (maxSpeed is stored in km/h) Activity activity = createActivity(Activity.ActivityType.RIDE, 20000L, BigDecimal.ZERO); ActivityMetrics metrics = new ActivityMetrics(); - metrics.setMaxSpeed(BigDecimal.valueOf(15.0)); // 54 km/h + metrics.setMaxSpeed(BigDecimal.valueOf(45.0)); // 45 km/h (realistic for cycling) activity.setMetrics(metrics); when(activityRepository.countByUserId(userId)).thenReturn(10L); diff --git a/src/test/java/org/operaton/fitpub/util/FitParserIntegrationTest.java b/src/test/java/org/operaton/fitpub/util/FitParserIntegrationTest.java index 38597f4..ab7c3cc 100644 --- a/src/test/java/org/operaton/fitpub/util/FitParserIntegrationTest.java +++ b/src/test/java/org/operaton/fitpub/util/FitParserIntegrationTest.java @@ -19,10 +19,12 @@ class FitParserIntegrationTest { private FitParser parser; private FitFileValidator validator; + private SpeedSmoother speedSmoother; @BeforeEach void setUp() { - parser = new FitParser(); + speedSmoother = new SpeedSmoother(); + parser = new FitParser(speedSmoother); validator = new FitFileValidator(); }