Smoothen Speed
This commit is contained in:
parent
75a4f6524c
commit
0e092e670b
6 changed files with 291 additions and 10 deletions
|
|
@ -362,15 +362,15 @@ public class AchievementService {
|
|||
List<Achievement> 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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
240
src/main/java/org/operaton/fitpub/util/SpeedSmoother.java
Normal file
240
src/main/java/org/operaton/fitpub/util/SpeedSmoother.java
Normal file
|
|
@ -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<FitParser.TrackPointData> 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<FitParser.TrackPointData> 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<FitParser.TrackPointData> 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<FitParser.TrackPointData> trackPoints) {
|
||||
int windowSize = Math.min(MEDIAN_WINDOW_SIZE, trackPoints.size());
|
||||
if (windowSize < 3) {
|
||||
return; // Not enough points for meaningful smoothing
|
||||
}
|
||||
|
||||
List<BigDecimal> 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<Double> 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<FitParser.TrackPointData> trackPoints) {
|
||||
return trackPoints.stream()
|
||||
.map(FitParser.TrackPointData::getSpeed)
|
||||
.filter(speed -> speed != null)
|
||||
.max(BigDecimal::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue