Add GPX file support for activity imports
This commit adds comprehensive GPX file support alongside existing FIT file support, enabling users to import activities from Strava, Komoot, and other GPS apps. ## Key Features ### Core Components - **GpxParser**: Full GPX 1.1 parsing with Garmin TrackPointExtension support - **GpxFileValidator**: Validation for GPX file format and structure - **ActivityFileService**: Unified service with automatic format detection (FIT/GPX) - **ParsedActivityData**: Common data structure for both FIT and GPX files ### GPX Parsing Capabilities - GPS track point extraction (latitude, longitude, elevation, timestamp) - Garmin extension data (heart rate, cadence, temperature) - Activity type detection from GPX metadata - Distance calculation using Haversine formula - Elevation gain/loss calculation - Speed calculation from consecutive GPS points - Speed smoothing to remove GPS artifacts - Timezone detection from GPS coordinates - Moving time vs. stopped time analysis ### Database Changes - Migration V15: Renamed raw_fit_file → raw_activity_file - Added source_file_format column (FIT/GPX) with constraint - Index on source_file_format for performance - Updated Activity entity with new fields ### Controller & UI Updates - ActivityController: Now handles both FIT and GPX uploads - Upload form: Updated to accept .fit and .gpx files - Help text: Clarified both formats are supported ### Testing - GpxParserIntegrationTest: 9 comprehensive tests with real GPX file - Tests cover: parsing, validation, heart rate extraction, distance calculation, elevation metrics, speed calculation, chronological ordering, smoothing - Fixed TrainingLoadServiceTest date issue (testDate outside 30-day window) - All 97 unit tests passing (integration tests require Docker) ### Technical Details - Supports GPX 1.0 and 1.1 specifications - Handles multiple track segments - Processes Garmin TrackPointExtension v1 and v2 - Same track simplification as FIT (Douglas-Peucker algorithm) - Consistent JSONB storage format for track points - Compatible with existing analytics, heatmaps, and image generation ## Testing Summary - ✅ 9/9 GpxParserIntegrationTest tests passing - ✅ 4/4 FitParserIntegrationTest tests passing - ✅ 14/14 FitFileServiceTest tests passing - ✅ 97/97 total unit tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
66b14ebf7f
commit
f4be439002
21 changed files with 7466 additions and 160 deletions
|
|
@ -9,6 +9,7 @@ import org.operaton.fitpub.model.dto.ActivityUploadRequest;
|
|||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.ActivityFileService;
|
||||
import org.operaton.fitpub.service.ActivityImageService;
|
||||
import org.operaton.fitpub.service.FederationService;
|
||||
import org.operaton.fitpub.service.FitFileService;
|
||||
|
|
@ -31,7 +32,7 @@ import java.util.stream.Collectors;
|
|||
|
||||
/**
|
||||
* REST controller for activity management.
|
||||
* Handles FIT file uploads, activity retrieval, updates, and deletion.
|
||||
* Handles activity file uploads (FIT, GPX), activity retrieval, updates, and deletion.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/activities")
|
||||
|
|
@ -39,6 +40,7 @@ import java.util.stream.Collectors;
|
|||
@Slf4j
|
||||
public class ActivityController {
|
||||
|
||||
private final ActivityFileService activityFileService;
|
||||
private final FitFileService fitFileService;
|
||||
private final UserRepository userRepository;
|
||||
private final FederationService federationService;
|
||||
|
|
@ -62,9 +64,9 @@ public class ActivityController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Uploads a FIT file and creates a new activity.
|
||||
* Uploads an activity file (FIT or GPX) and creates a new activity.
|
||||
*
|
||||
* @param file the FIT file
|
||||
* @param file the activity file (FIT or GPX)
|
||||
* @param request the upload request with metadata
|
||||
* @param userDetails the authenticated user
|
||||
* @return the created activity
|
||||
|
|
@ -75,12 +77,12 @@ public class ActivityController {
|
|||
@Valid @ModelAttribute ActivityUploadRequest request,
|
||||
@AuthenticationPrincipal UserDetails userDetails
|
||||
) {
|
||||
log.info("User {} uploading FIT file: {}", userDetails.getUsername(), file.getOriginalFilename());
|
||||
log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename());
|
||||
|
||||
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
Activity activity = fitFileService.processFitFile(
|
||||
Activity activity = activityFileService.processActivityFile(
|
||||
file,
|
||||
user.getId(),
|
||||
request.getTitle(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
package org.operaton.fitpub.exception;
|
||||
|
||||
/**
|
||||
* Base exception for GPX file processing errors.
|
||||
* Thrown when an error occurs during GPX file parsing or processing.
|
||||
*/
|
||||
public class GpxFileProcessingException extends RuntimeException {
|
||||
|
||||
public GpxFileProcessingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public GpxFileProcessingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.operaton.fitpub.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a GPX file fails validation.
|
||||
* This includes malformed XML, missing required elements, or invalid GPX structure.
|
||||
*/
|
||||
public class InvalidGpxFileException extends GpxFileProcessingException {
|
||||
|
||||
public InvalidGpxFileException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InvalidGpxFileException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.operaton.fitpub.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when an uploaded file format is not supported.
|
||||
* Currently supported formats: FIT, GPX.
|
||||
*/
|
||||
public class UnsupportedFileFormatException extends RuntimeException {
|
||||
|
||||
public UnsupportedFileFormatException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public UnsupportedFileFormatException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -94,12 +94,18 @@ public class Activity {
|
|||
private BigDecimal elevationLoss;
|
||||
|
||||
/**
|
||||
* Original FIT file for re-processing if needed.
|
||||
* Original activity file (FIT or GPX) for re-processing if needed.
|
||||
* Allows us to re-parse with updated algorithms.
|
||||
*/
|
||||
@Lob
|
||||
@Column(name = "raw_fit_file")
|
||||
private byte[] rawFitFile;
|
||||
@Column(name = "raw_activity_file")
|
||||
private byte[] rawActivityFile;
|
||||
|
||||
/**
|
||||
* Source file format: "FIT" (Garmin/Wahoo devices) or "GPX" (GPS Exchange Format).
|
||||
*/
|
||||
@Column(name = "source_file_format", nullable = false, length = 10)
|
||||
private String sourceFileFormat;
|
||||
|
||||
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private ActivityMetrics metrics;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.LineString;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.operaton.fitpub.exception.FitFileProcessingException;
|
||||
import org.operaton.fitpub.exception.GpxFileProcessingException;
|
||||
import org.operaton.fitpub.exception.UnsupportedFileFormatException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.util.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Unified service for processing activity files (FIT, GPX, etc.) and creating activities.
|
||||
* Automatically detects file format and routes to the appropriate parser.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ActivityFileService {
|
||||
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
private final FitFileValidator fitValidator;
|
||||
private final GpxFileValidator gpxValidator;
|
||||
private final FitParser fitParser;
|
||||
private final GpxParser gpxParser;
|
||||
private final TrackSimplifier trackSimplifier;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PersonalRecordService personalRecordService;
|
||||
private final AchievementService achievementService;
|
||||
private final TrainingLoadService trainingLoadService;
|
||||
private final ActivitySummaryService activitySummaryService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
|
||||
/**
|
||||
* Processes an uploaded activity file (FIT or GPX) and creates an activity.
|
||||
*
|
||||
* @param file the uploaded file
|
||||
* @param userId the user ID
|
||||
* @param title optional custom title (will be auto-generated if null)
|
||||
* @param description optional description
|
||||
* @param visibility visibility level
|
||||
* @return the created activity
|
||||
* @throws FitFileProcessingException if FIT processing fails
|
||||
* @throws GpxFileProcessingException if GPX processing fails
|
||||
* @throws UnsupportedFileFormatException if file format is unknown
|
||||
*/
|
||||
@Transactional
|
||||
public Activity processActivityFile(
|
||||
MultipartFile file,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility
|
||||
) {
|
||||
try {
|
||||
byte[] fileData = file.getBytes();
|
||||
String filename = file.getOriginalFilename();
|
||||
|
||||
log.info("Processing activity file: {}, size: {} bytes", filename, file.getSize());
|
||||
|
||||
// Detect file format
|
||||
FileFormat format = detectFileFormat(fileData, filename);
|
||||
log.debug("Detected file format: {}", format);
|
||||
|
||||
// Parse based on format
|
||||
ParsedActivityData parsedData;
|
||||
if (format == FileFormat.FIT) {
|
||||
fitValidator.validate(fileData);
|
||||
parsedData = fitParser.parse(fileData);
|
||||
parsedData.setSourceFormat("FIT");
|
||||
} else if (format == FileFormat.GPX) {
|
||||
gpxValidator.validate(fileData);
|
||||
parsedData = gpxParser.parse(fileData);
|
||||
parsedData.setSourceFormat("GPX");
|
||||
} else {
|
||||
throw new UnsupportedFileFormatException("Unsupported file format: " + filename);
|
||||
}
|
||||
|
||||
// Common processing (same for both formats)
|
||||
return createActivityFromParsedData(parsedData, userId, title, description, visibility, fileData);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to read activity file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects file format from content and filename.
|
||||
* Priority: magic bytes > XML header > file extension
|
||||
*/
|
||||
private FileFormat detectFileFormat(byte[] fileData, String filename) {
|
||||
// Primary: Check magic bytes for FIT file signature at offset 8: ".FIT"
|
||||
if (fileData.length >= 12) {
|
||||
if (fileData[8] == '.' && fileData[9] == 'F' &&
|
||||
fileData[10] == 'I' && fileData[11] == 'T') {
|
||||
return FileFormat.FIT;
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: Check XML header for GPX
|
||||
if (fileData.length >= 100) {
|
||||
String header = new String(fileData, 0, Math.min(200, fileData.length), StandardCharsets.UTF_8);
|
||||
if (header.contains("<?xml") && header.contains("<gpx")) {
|
||||
return FileFormat.GPX;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: File extension
|
||||
if (filename != null && !filename.isEmpty()) {
|
||||
String lowerFilename = filename.toLowerCase();
|
||||
if (lowerFilename.endsWith(".fit")) {
|
||||
return FileFormat.FIT;
|
||||
}
|
||||
if (lowerFilename.endsWith(".gpx")) {
|
||||
return FileFormat.GPX;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnsupportedFileFormatException("Unable to detect file format from content or filename");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an activity from parsed data (internal method).
|
||||
* This method contains all the common logic for creating activities from any format.
|
||||
*/
|
||||
private Activity createActivityFromParsedData(
|
||||
ParsedActivityData parsedData,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility,
|
||||
byte[] rawFile
|
||||
) {
|
||||
// Generate title if not provided
|
||||
String activityTitle = title != null && !title.isBlank()
|
||||
? title
|
||||
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
|
||||
|
||||
// Default to PUBLIC if visibility not specified
|
||||
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PUBLIC;
|
||||
|
||||
// Create activity entity
|
||||
Activity activity = Activity.builder()
|
||||
.userId(userId)
|
||||
.activityType(parsedData.getActivityType())
|
||||
.title(activityTitle)
|
||||
.description(description)
|
||||
.startedAt(parsedData.getStartTime())
|
||||
.endedAt(parsedData.getEndTime())
|
||||
.timezone(parsedData.getTimezone())
|
||||
.visibility(activityVisibility)
|
||||
.totalDistance(parsedData.getTotalDistance())
|
||||
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
|
||||
.elevationGain(parsedData.getElevationGain())
|
||||
.elevationLoss(parsedData.getElevationLoss())
|
||||
.rawActivityFile(rawFile)
|
||||
.sourceFileFormat(parsedData.getSourceFormat())
|
||||
.build();
|
||||
|
||||
// Convert track points to JSONB
|
||||
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
|
||||
activity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
// Create full LineString from all points
|
||||
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
|
||||
|
||||
// Simplify track for map rendering
|
||||
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
|
||||
activity.setSimplifiedTrack(simplifiedTrack);
|
||||
|
||||
// Create metrics
|
||||
if (parsedData.getMetrics() != null) {
|
||||
ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity);
|
||||
calculateAdditionalMetrics(metrics, parsedData.getTrackPoints());
|
||||
activity.setMetrics(metrics);
|
||||
}
|
||||
|
||||
// Save activity (single INSERT instead of 855!)
|
||||
Activity savedActivity = activityRepository.save(activity);
|
||||
|
||||
log.info("Successfully created {} activity {} with {} track points (simplified to {} for map)",
|
||||
parsedData.getSourceFormat(),
|
||||
savedActivity.getId(),
|
||||
parsedData.getTrackPoints().size(),
|
||||
simplifiedTrack.getNumPoints());
|
||||
|
||||
// Check for personal records and achievements
|
||||
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
|
||||
achievementService.checkAndAwardAchievements(savedActivity);
|
||||
|
||||
// Update heatmap grid
|
||||
heatmapGridService.updateHeatmapForActivity(savedActivity);
|
||||
|
||||
// Update training load and summaries (async)
|
||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
activitySummaryService.updateSummariesForActivity(savedActivity);
|
||||
|
||||
// Fetch weather data (async, non-blocking)
|
||||
try {
|
||||
weatherService.fetchWeatherForActivity(savedActivity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch weather data for activity {}: {}", savedActivity.getId(), e.getMessage());
|
||||
// Don't fail the activity creation if weather fetching fails
|
||||
}
|
||||
|
||||
return savedActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts track points to JSON string for JSONB storage.
|
||||
*/
|
||||
private String convertTrackPointsToJson(List<ParsedActivityData.TrackPointData> trackPoints) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(trackPoints);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to serialize track points to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PostGIS LineString from track points.
|
||||
*/
|
||||
private LineString createLineStringFromTrackPoints(List<ParsedActivityData.TrackPointData> trackPoints) {
|
||||
Coordinate[] coordinates = trackPoints.stream()
|
||||
.map(tp -> new Coordinate(tp.getLongitude(), tp.getLatitude()))
|
||||
.toArray(Coordinate[]::new);
|
||||
|
||||
return GEOMETRY_FACTORY.createLineString(coordinates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates additional metrics that might not be in parsed data.
|
||||
* For GPX files, most metrics are already calculated in GpxParser.
|
||||
* For FIT files, some additional metrics like min/max elevation need calculation.
|
||||
*/
|
||||
private void calculateAdditionalMetrics(
|
||||
ActivityMetrics metrics,
|
||||
List<ParsedActivityData.TrackPointData> trackPoints
|
||||
) {
|
||||
if (trackPoints.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate min/max elevation if not already set
|
||||
if (metrics.getMinElevation() == null || metrics.getMaxElevation() == null) {
|
||||
BigDecimal minElevation = null;
|
||||
BigDecimal maxElevation = null;
|
||||
|
||||
for (ParsedActivityData.TrackPointData tp : trackPoints) {
|
||||
if (tp.getElevation() != null) {
|
||||
if (minElevation == null || tp.getElevation().compareTo(minElevation) < 0) {
|
||||
minElevation = tp.getElevation();
|
||||
}
|
||||
if (maxElevation == null || tp.getElevation().compareTo(maxElevation) > 0) {
|
||||
maxElevation = tp.getElevation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (metrics.getMinElevation() == null) metrics.setMinElevation(minElevation);
|
||||
if (metrics.getMaxElevation() == null) metrics.setMaxElevation(maxElevation);
|
||||
}
|
||||
|
||||
// Calculate average temperature if not already set
|
||||
if (metrics.getAverageTemperature() == null) {
|
||||
BigDecimal tempSum = BigDecimal.ZERO;
|
||||
int tempCount = 0;
|
||||
|
||||
for (ParsedActivityData.TrackPointData tp : trackPoints) {
|
||||
if (tp.getTemperature() != null) {
|
||||
tempSum = tempSum.add(tp.getTemperature());
|
||||
tempCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (tempCount > 0) {
|
||||
metrics.setAverageTemperature(
|
||||
tempSum.divide(BigDecimal.valueOf(tempCount), 2, BigDecimal.ROUND_HALF_UP)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for supported file formats.
|
||||
*/
|
||||
private enum FileFormat {
|
||||
FIT, GPX
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import org.operaton.fitpub.repository.ActivityRepository;
|
|||
import org.operaton.fitpub.util.ActivityFormatter;
|
||||
import org.operaton.fitpub.util.FitFileValidator;
|
||||
import org.operaton.fitpub.util.FitParser;
|
||||
import org.operaton.fitpub.util.ParsedActivityData;
|
||||
import org.operaton.fitpub.util.TrackSimplifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
|
@ -78,7 +79,7 @@ public class FitFileService {
|
|||
|
||||
// Parse FIT file
|
||||
byte[] fileData = file.getBytes();
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Create activity entity
|
||||
Activity activity = createActivity(parsedData, userId, title, description, visibility, fileData);
|
||||
|
|
@ -146,7 +147,7 @@ public class FitFileService {
|
|||
@Transactional
|
||||
public Activity processFitFile(byte[] fileData, UUID userId, Activity.Visibility visibility) {
|
||||
validator.validate(fileData);
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
return createActivityFromParsedData(parsedData, userId, null, null, visibility, fileData);
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +155,7 @@ public class FitFileService {
|
|||
* Creates an activity entity from parsed FIT data.
|
||||
*/
|
||||
private Activity createActivity(
|
||||
FitParser.ParsedFitData parsedData,
|
||||
ParsedActivityData parsedData,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
|
|
@ -181,7 +182,8 @@ public class FitFileService {
|
|||
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
|
||||
.elevationGain(parsedData.getElevationGain())
|
||||
.elevationLoss(parsedData.getElevationLoss())
|
||||
.rawFitFile(rawFile)
|
||||
.rawActivityFile(rawFile)
|
||||
.sourceFileFormat("FIT")
|
||||
.build();
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +191,7 @@ public class FitFileService {
|
|||
* Creates an activity from parsed data (internal method).
|
||||
*/
|
||||
private Activity createActivityFromParsedData(
|
||||
FitParser.ParsedFitData parsedData,
|
||||
ParsedActivityData parsedData,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
|
|
@ -228,7 +230,7 @@ public class FitFileService {
|
|||
/**
|
||||
* Converts track points to JSON string for JSONB storage.
|
||||
*/
|
||||
private String convertTrackPointsToJson(List<FitParser.TrackPointData> trackPoints) {
|
||||
private String convertTrackPointsToJson(List<ParsedActivityData.TrackPointData> trackPoints) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(trackPoints);
|
||||
} catch (JsonProcessingException e) {
|
||||
|
|
@ -239,7 +241,7 @@ public class FitFileService {
|
|||
/**
|
||||
* Creates a PostGIS LineString from track points.
|
||||
*/
|
||||
private LineString createLineStringFromTrackPoints(List<FitParser.TrackPointData> trackPoints) {
|
||||
private LineString createLineStringFromTrackPoints(List<ParsedActivityData.TrackPointData> trackPoints) {
|
||||
Coordinate[] coordinates = trackPoints.stream()
|
||||
.map(tp -> new Coordinate(tp.getLongitude(), tp.getLatitude()))
|
||||
.toArray(Coordinate[]::new);
|
||||
|
|
@ -251,7 +253,7 @@ public class FitFileService {
|
|||
* Generates a default title for an activity based on time of day.
|
||||
* Examples: "Morning Run", "Evening Ride", "Night Walk"
|
||||
*/
|
||||
private String generateTitle(FitParser.ParsedFitData parsedData) {
|
||||
private String generateTitle(ParsedActivityData parsedData) {
|
||||
return ActivityFormatter.generateActivityTitle(
|
||||
parsedData.getStartTime(),
|
||||
parsedData.getActivityType()
|
||||
|
|
@ -263,7 +265,7 @@ public class FitFileService {
|
|||
*/
|
||||
private void calculateAdditionalMetrics(
|
||||
ActivityMetrics metrics,
|
||||
List<FitParser.TrackPointData> trackPoints
|
||||
List<ParsedActivityData.TrackPointData> trackPoints
|
||||
) {
|
||||
if (trackPoints.isEmpty()) {
|
||||
return;
|
||||
|
|
@ -273,7 +275,7 @@ public class FitFileService {
|
|||
BigDecimal minElevation = null;
|
||||
BigDecimal maxElevation = null;
|
||||
|
||||
for (FitParser.TrackPointData tp : trackPoints) {
|
||||
for (ParsedActivityData.TrackPointData tp : trackPoints) {
|
||||
if (tp.getElevation() != null) {
|
||||
if (minElevation == null || tp.getElevation().compareTo(minElevation) < 0) {
|
||||
minElevation = tp.getElevation();
|
||||
|
|
@ -291,7 +293,7 @@ public class FitFileService {
|
|||
BigDecimal tempSum = BigDecimal.ZERO;
|
||||
int tempCount = 0;
|
||||
|
||||
for (FitParser.TrackPointData tp : trackPoints) {
|
||||
for (ParsedActivityData.TrackPointData tp : trackPoints) {
|
||||
if (tp.getTemperature() != null) {
|
||||
tempSum = tempSum.add(tp.getTemperature());
|
||||
tempCount++;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import org.locationtech.jts.geom.PrecisionModel;
|
|||
import org.operaton.fitpub.exception.FitFileProcessingException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
import org.operaton.fitpub.util.ParsedActivityData.ActivityMetricsData;
|
||||
import org.operaton.fitpub.util.ParsedActivityData.TrackPointData;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
|
|
@ -52,10 +54,10 @@ public class FitParser {
|
|||
* Parses a FIT file and returns the extracted data.
|
||||
*
|
||||
* @param fileData the FIT file data
|
||||
* @return ParsedFitData containing activity information
|
||||
* @return ParsedActivityData containing activity information
|
||||
* @throws FitFileProcessingException if parsing fails
|
||||
*/
|
||||
public ParsedFitData parse(byte[] fileData) {
|
||||
public ParsedActivityData parse(byte[] fileData) {
|
||||
try (InputStream inputStream = new ByteArrayInputStream(fileData)) {
|
||||
return parse(inputStream);
|
||||
} catch (Exception e) {
|
||||
|
|
@ -67,11 +69,11 @@ public class FitParser {
|
|||
* Parses a FIT file from an input stream.
|
||||
*
|
||||
* @param inputStream the input stream
|
||||
* @return ParsedFitData containing activity information
|
||||
* @return ParsedActivityData containing activity information
|
||||
* @throws FitFileProcessingException if parsing fails
|
||||
*/
|
||||
public ParsedFitData parse(InputStream inputStream) {
|
||||
ParsedFitData parsedData = new ParsedFitData();
|
||||
public ParsedActivityData parse(InputStream inputStream) {
|
||||
ParsedActivityData parsedData = new ParsedActivityData();
|
||||
Decode decode = new Decode();
|
||||
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
|
||||
|
||||
|
|
@ -189,7 +191,7 @@ public class FitParser {
|
|||
/**
|
||||
* Extracts session data from a session message.
|
||||
*/
|
||||
private void extractSessionData(SessionMesg session, ParsedFitData parsedData) {
|
||||
private void extractSessionData(SessionMesg session, ParsedActivityData parsedData) {
|
||||
if (session.getStartTime() != null) {
|
||||
parsedData.setStartTime(convertDateTime(session.getStartTime()));
|
||||
}
|
||||
|
|
@ -287,7 +289,7 @@ public class FitParser {
|
|||
/**
|
||||
* Extracts activity data from an activity message.
|
||||
*/
|
||||
private void extractActivityData(ActivityMesg activity, ParsedFitData parsedData) {
|
||||
private void extractActivityData(ActivityMesg activity, ParsedActivityData parsedData) {
|
||||
if (activity.getTimestamp() != null) {
|
||||
parsedData.setActivityTimestamp(convertDateTime(activity.getTimestamp()));
|
||||
}
|
||||
|
|
@ -301,7 +303,7 @@ 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) {
|
||||
private void smoothSpeedData(ParsedActivityData parsedData) {
|
||||
if (parsedData.getTrackPoints().isEmpty() || parsedData.getMetrics() == null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -328,7 +330,7 @@ public class FitParser {
|
|||
* Determines the timezone based on the first GPS coordinate.
|
||||
* Uses TimeZoneEngine library for accurate timezone lookup from coordinates.
|
||||
*/
|
||||
private void determineTimezone(ParsedFitData parsedData) {
|
||||
private void determineTimezone(ParsedActivityData parsedData) {
|
||||
if (parsedData.getTrackPoints().isEmpty()) {
|
||||
parsedData.setTimezone("UTC");
|
||||
return;
|
||||
|
|
@ -396,94 +398,4 @@ public class FitParser {
|
|||
return Activity.ActivityType.OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for track point information.
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class TrackPointData {
|
||||
private LocalDateTime timestamp;
|
||||
private double latitude;
|
||||
private double longitude;
|
||||
private BigDecimal elevation;
|
||||
private Integer heartRate;
|
||||
private Integer cadence;
|
||||
private Integer power;
|
||||
private BigDecimal speed;
|
||||
private BigDecimal temperature;
|
||||
private BigDecimal distance;
|
||||
|
||||
public Point toGeometry() {
|
||||
return GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for activity metrics.
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class ActivityMetricsData {
|
||||
private BigDecimal averageSpeed;
|
||||
private BigDecimal maxSpeed;
|
||||
private Duration averagePace;
|
||||
private Integer averageHeartRate;
|
||||
private Integer maxHeartRate;
|
||||
private Integer averageCadence;
|
||||
private Integer maxCadence;
|
||||
private Integer averagePower;
|
||||
private Integer maxPower;
|
||||
private Integer normalizedPower;
|
||||
private Integer calories;
|
||||
private BigDecimal averageTemperature;
|
||||
private BigDecimal maxElevation;
|
||||
private BigDecimal minElevation;
|
||||
private BigDecimal totalAscent;
|
||||
private BigDecimal totalDescent;
|
||||
private Duration movingTime;
|
||||
private Duration stoppedTime;
|
||||
private Integer totalSteps;
|
||||
|
||||
public ActivityMetrics toEntity(Activity activity) {
|
||||
return ActivityMetrics.builder()
|
||||
.activity(activity)
|
||||
.averageSpeed(averageSpeed)
|
||||
.maxSpeed(maxSpeed)
|
||||
.averagePaceSeconds(averagePace != null ? averagePace.getSeconds() : null)
|
||||
.averageHeartRate(averageHeartRate)
|
||||
.maxHeartRate(maxHeartRate)
|
||||
.averageCadence(averageCadence)
|
||||
.maxCadence(maxCadence)
|
||||
.averagePower(averagePower)
|
||||
.maxPower(maxPower)
|
||||
.normalizedPower(normalizedPower)
|
||||
.calories(calories)
|
||||
.averageTemperature(averageTemperature)
|
||||
.maxElevation(maxElevation)
|
||||
.minElevation(minElevation)
|
||||
.totalAscent(totalAscent)
|
||||
.totalDescent(totalDescent)
|
||||
.movingTimeSeconds(movingTime != null ? movingTime.getSeconds() : null)
|
||||
.stoppedTimeSeconds(stoppedTime != null ? stoppedTime.getSeconds() : null)
|
||||
.totalSteps(totalSteps)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class holding all parsed FIT file data.
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class ParsedFitData {
|
||||
private List<TrackPointData> trackPoints = new ArrayList<>();
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private LocalDateTime activityTimestamp;
|
||||
private String timezone; // IANA timezone ID (e.g., "Europe/Berlin")
|
||||
private BigDecimal totalDistance;
|
||||
private Duration totalDuration;
|
||||
private BigDecimal elevationGain;
|
||||
private BigDecimal elevationLoss;
|
||||
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
|
||||
private ActivityMetricsData metrics;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
116
src/main/java/org/operaton/fitpub/util/GpxFileValidator.java
Normal file
116
src/main/java/org/operaton/fitpub/util/GpxFileValidator.java
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.exception.InvalidGpxFileException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
|
||||
/**
|
||||
* Validates GPX files before processing.
|
||||
* Checks file size, XML well-formedness, and GPX structure.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class GpxFileValidator {
|
||||
|
||||
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
private static final int MIN_FILE_SIZE = 100; // Minimum XML file size
|
||||
|
||||
/**
|
||||
* Validates a GPX file from byte array.
|
||||
*
|
||||
* @param fileData the GPX file data
|
||||
* @throws InvalidGpxFileException if the file is invalid
|
||||
*/
|
||||
public void validate(byte[] fileData) {
|
||||
if (fileData == null || fileData.length == 0) {
|
||||
throw new InvalidGpxFileException("GPX file is empty");
|
||||
}
|
||||
|
||||
validateFileSize(fileData.length);
|
||||
validateGpxStructure(fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the file size.
|
||||
*
|
||||
* @param size the file size in bytes
|
||||
* @throws InvalidGpxFileException if the size is invalid
|
||||
*/
|
||||
private void validateFileSize(long size) {
|
||||
if (size < MIN_FILE_SIZE) {
|
||||
throw new InvalidGpxFileException(
|
||||
String.format("GPX file is too small. Size: %d bytes, minimum: %d bytes", size, MIN_FILE_SIZE)
|
||||
);
|
||||
}
|
||||
|
||||
if (size > MAX_FILE_SIZE) {
|
||||
throw new InvalidGpxFileException(
|
||||
String.format("GPX file is too large. Size: %d bytes, maximum: %d bytes", size, MAX_FILE_SIZE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates GPX XML structure.
|
||||
*
|
||||
* @param fileData the GPX file data
|
||||
* @throws InvalidGpxFileException if the structure is invalid
|
||||
*/
|
||||
private void validateGpxStructure(byte[] fileData) {
|
||||
try {
|
||||
// Parse XML to check well-formedness
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(new ByteArrayInputStream(fileData));
|
||||
|
||||
// Check root element is <gpx>
|
||||
Element root = doc.getDocumentElement();
|
||||
if (!"gpx".equals(root.getLocalName()) && !"gpx".equals(root.getNodeName())) {
|
||||
throw new InvalidGpxFileException("Root element must be <gpx>, found: <" + root.getNodeName() + ">");
|
||||
}
|
||||
|
||||
// Check for at least one <trk> or <rte> element
|
||||
NodeList tracks = doc.getElementsByTagName("trk");
|
||||
NodeList routes = doc.getElementsByTagName("rte");
|
||||
|
||||
// Also check for namespace-qualified names
|
||||
if (tracks.getLength() == 0) {
|
||||
tracks = doc.getElementsByTagNameNS("*", "trk");
|
||||
}
|
||||
if (routes.getLength() == 0) {
|
||||
routes = doc.getElementsByTagNameNS("*", "rte");
|
||||
}
|
||||
|
||||
if (tracks.getLength() == 0 && routes.getLength() == 0) {
|
||||
throw new InvalidGpxFileException("GPX file must contain at least one <trk> or <rte> element");
|
||||
}
|
||||
|
||||
log.debug("GPX validation successful. Tracks: {}, Routes: {}", tracks.getLength(), routes.getLength());
|
||||
} catch (InvalidGpxFileException e) {
|
||||
throw e; // Re-throw our custom exceptions
|
||||
} catch (Exception e) {
|
||||
throw new InvalidGpxFileException("Invalid GPX XML structure: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file appears to be a valid GPX file based on extension.
|
||||
*
|
||||
* @param filename the filename
|
||||
* @return true if the filename has a .gpx extension
|
||||
*/
|
||||
public boolean hasValidExtension(String filename) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return filename.toLowerCase().endsWith(".gpx");
|
||||
}
|
||||
}
|
||||
603
src/main/java/org/operaton/fitpub/util/GpxParser.java
Normal file
603
src/main/java/org/operaton/fitpub/util/GpxParser.java
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.iakovlev.timeshape.TimeZoneEngine;
|
||||
import org.operaton.fitpub.exception.GpxFileProcessingException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.util.ParsedActivityData.ActivityMetricsData;
|
||||
import org.operaton.fitpub.util.ParsedActivityData.TrackPointData;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Parser for GPX (GPS Exchange Format) files.
|
||||
* Extracts GPS coordinates, activity metrics from track points.
|
||||
* Since GPX files lack session summaries, metrics are calculated from track points.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class GpxParser {
|
||||
|
||||
private static final String GPX_NS = "http://www.topografix.com/GPX/1/1";
|
||||
private static final String GPXTPX_NS = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1";
|
||||
private static final double ELEVATION_NOISE_THRESHOLD = 2.0; // Ignore elevation changes < 2m
|
||||
private static final double STOPPED_SPEED_THRESHOLD = 0.5; // km/h - below this is considered stopped
|
||||
private static final long STOPPED_TIME_THRESHOLD = 30; // seconds - must be stopped this long to count
|
||||
|
||||
// Lazy-loaded timezone engine (expensive to initialize)
|
||||
private static TimeZoneEngine timezoneEngine = null;
|
||||
|
||||
private final SpeedSmoother speedSmoother;
|
||||
|
||||
/**
|
||||
* Parses a GPX file and returns the extracted data.
|
||||
*
|
||||
* @param fileData the GPX file data
|
||||
* @return ParsedActivityData containing activity information
|
||||
* @throws GpxFileProcessingException if parsing fails
|
||||
*/
|
||||
public ParsedActivityData parse(byte[] fileData) {
|
||||
try {
|
||||
ParsedActivityData parsedData = new ParsedActivityData();
|
||||
parsedData.setSourceFormat("GPX");
|
||||
|
||||
// Parse XML
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(new ByteArrayInputStream(fileData));
|
||||
|
||||
// Extract track points
|
||||
extractTrackPoints(doc, parsedData);
|
||||
|
||||
if (parsedData.getTrackPoints().isEmpty()) {
|
||||
throw new GpxFileProcessingException("No GPS track points found in GPX file");
|
||||
}
|
||||
|
||||
// Set start and end times from first and last track points
|
||||
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
|
||||
TrackPointData lastPoint = parsedData.getTrackPoints().get(parsedData.getTrackPoints().size() - 1);
|
||||
parsedData.setStartTime(firstPoint.getTimestamp());
|
||||
parsedData.setEndTime(lastPoint.getTimestamp());
|
||||
|
||||
// Calculate duration
|
||||
parsedData.setTotalDuration(Duration.between(firstPoint.getTimestamp(), lastPoint.getTimestamp()));
|
||||
|
||||
// Extract activity type from metadata
|
||||
extractActivityType(doc, parsedData);
|
||||
|
||||
// Determine timezone from first GPS coordinate
|
||||
determineTimezone(parsedData);
|
||||
|
||||
// Calculate metrics from track points
|
||||
calculateMetrics(parsedData);
|
||||
|
||||
// Apply speed smoothing
|
||||
smoothSpeedData(parsedData);
|
||||
|
||||
log.info("Successfully parsed GPX file: {} track points, activity type: {}, timezone: {}",
|
||||
parsedData.getTrackPoints().size(), parsedData.getActivityType(), parsedData.getTimezone());
|
||||
|
||||
return parsedData;
|
||||
} catch (GpxFileProcessingException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new GpxFileProcessingException("Failed to parse GPX file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts track points from GPX document.
|
||||
*/
|
||||
private void extractTrackPoints(Document doc, ParsedActivityData parsedData) {
|
||||
NodeList tracks = doc.getElementsByTagName("trk");
|
||||
if (tracks.getLength() == 0) {
|
||||
tracks = doc.getElementsByTagNameNS("*", "trk");
|
||||
}
|
||||
|
||||
for (int i = 0; i < tracks.getLength(); i++) {
|
||||
Element track = (Element) tracks.item(i);
|
||||
|
||||
// Get track segments
|
||||
NodeList segments = track.getElementsByTagName("trkseg");
|
||||
if (segments.getLength() == 0) {
|
||||
segments = track.getElementsByTagNameNS("*", "trkseg");
|
||||
}
|
||||
|
||||
for (int j = 0; j < segments.getLength(); j++) {
|
||||
Element segment = (Element) segments.item(j);
|
||||
|
||||
// Get track points
|
||||
NodeList trkpts = segment.getElementsByTagName("trkpt");
|
||||
if (trkpts.getLength() == 0) {
|
||||
trkpts = segment.getElementsByTagNameNS("*", "trkpt");
|
||||
}
|
||||
|
||||
for (int k = 0; k < trkpts.getLength(); k++) {
|
||||
Element trkptElement = (Element) trkpts.item(k);
|
||||
TrackPointData trackPoint = extractTrackPoint(trkptElement);
|
||||
if (trackPoint != null) {
|
||||
parsedData.getTrackPoints().add(trackPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a single track point from a <trkpt> element.
|
||||
*/
|
||||
private TrackPointData extractTrackPoint(Element trkptElement) {
|
||||
try {
|
||||
TrackPointData point = new TrackPointData();
|
||||
|
||||
// Extract latitude and longitude from attributes
|
||||
String latStr = trkptElement.getAttribute("lat");
|
||||
String lonStr = trkptElement.getAttribute("lon");
|
||||
|
||||
if (latStr.isEmpty() || lonStr.isEmpty()) {
|
||||
log.warn("Track point missing lat/lon attributes");
|
||||
return null;
|
||||
}
|
||||
|
||||
point.setLatitude(Double.parseDouble(latStr));
|
||||
point.setLongitude(Double.parseDouble(lonStr));
|
||||
|
||||
// Extract elevation
|
||||
String elevation = getElementText(trkptElement, "ele");
|
||||
if (elevation != null) {
|
||||
point.setElevation(new BigDecimal(elevation).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract time
|
||||
String time = getElementText(trkptElement, "time");
|
||||
if (time != null) {
|
||||
point.setTimestamp(parseIso8601DateTime(time));
|
||||
}
|
||||
|
||||
// Extract extensions (heart rate, cadence, power, temperature)
|
||||
extractExtensions(trkptElement, point);
|
||||
|
||||
return point;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract track point: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts Garmin/TrainingPeaks extensions from track point.
|
||||
*/
|
||||
private void extractExtensions(Element trkptElement, TrackPointData point) {
|
||||
NodeList extensions = trkptElement.getElementsByTagName("extensions");
|
||||
if (extensions.getLength() == 0) {
|
||||
extensions = trkptElement.getElementsByTagNameNS("*", "extensions");
|
||||
}
|
||||
|
||||
if (extensions.getLength() == 0) {
|
||||
return; // No extensions
|
||||
}
|
||||
|
||||
Element extensionsElement = (Element) extensions.item(0);
|
||||
|
||||
// Try Garmin TrackPointExtension namespace
|
||||
NodeList tpx = extensionsElement.getElementsByTagNameNS(GPXTPX_NS, "TrackPointExtension");
|
||||
if (tpx.getLength() == 0) {
|
||||
// Try without namespace
|
||||
tpx = extensionsElement.getElementsByTagName("TrackPointExtension");
|
||||
}
|
||||
|
||||
if (tpx.getLength() > 0) {
|
||||
Element tpxElement = (Element) tpx.item(0);
|
||||
|
||||
// Heart rate
|
||||
String hr = getElementTextNS(tpxElement, GPXTPX_NS, "hr");
|
||||
if (hr == null) hr = getElementText(tpxElement, "hr");
|
||||
if (hr != null) {
|
||||
point.setHeartRate(Integer.parseInt(hr));
|
||||
}
|
||||
|
||||
// Cadence
|
||||
String cad = getElementTextNS(tpxElement, GPXTPX_NS, "cad");
|
||||
if (cad == null) cad = getElementText(tpxElement, "cad");
|
||||
if (cad != null) {
|
||||
point.setCadence(Integer.parseInt(cad));
|
||||
}
|
||||
|
||||
// Temperature
|
||||
String atemp = getElementTextNS(tpxElement, GPXTPX_NS, "atemp");
|
||||
if (atemp == null) atemp = getElementText(tpxElement, "atemp");
|
||||
if (atemp != null) {
|
||||
point.setTemperature(new BigDecimal(atemp).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for power extension (different namespace)
|
||||
String power = getElementTextNS(extensionsElement, "*", "power");
|
||||
if (power == null) power = getElementText(extensionsElement, "power");
|
||||
if (power != null) {
|
||||
point.setPower(Integer.parseInt(power));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts activity type from GPX metadata.
|
||||
*/
|
||||
private void extractActivityType(Document doc, ParsedActivityData parsedData) {
|
||||
NodeList tracks = doc.getElementsByTagName("trk");
|
||||
if (tracks.getLength() == 0) {
|
||||
tracks = doc.getElementsByTagNameNS("*", "trk");
|
||||
}
|
||||
|
||||
if (tracks.getLength() > 0) {
|
||||
Element track = (Element) tracks.item(0);
|
||||
String type = getElementText(track, "type");
|
||||
if (type != null) {
|
||||
parsedData.setActivityType(mapGpxTypeToActivityType(type));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps GPX activity type string to ActivityType enum.
|
||||
* GPX type field is not standardized, so we support common values.
|
||||
*/
|
||||
private Activity.ActivityType mapGpxTypeToActivityType(String gpxType) {
|
||||
if (gpxType == null || gpxType.isEmpty()) {
|
||||
return Activity.ActivityType.OTHER;
|
||||
}
|
||||
|
||||
String type = gpxType.toLowerCase().trim();
|
||||
|
||||
// Running variations
|
||||
if (type.contains("run") || type.equals("9")) {
|
||||
return Activity.ActivityType.RUN;
|
||||
}
|
||||
|
||||
// Cycling variations
|
||||
if (type.contains("cycl") || type.contains("bik") || type.equals("1")) {
|
||||
return Activity.ActivityType.RIDE;
|
||||
}
|
||||
|
||||
// Hiking
|
||||
if (type.contains("hik")) {
|
||||
return Activity.ActivityType.HIKE;
|
||||
}
|
||||
|
||||
// Walking
|
||||
if (type.contains("walk")) {
|
||||
return Activity.ActivityType.WALK;
|
||||
}
|
||||
|
||||
// Swimming
|
||||
if (type.contains("swim")) {
|
||||
return Activity.ActivityType.SWIM;
|
||||
}
|
||||
|
||||
// Rowing
|
||||
if (type.contains("row")) {
|
||||
return Activity.ActivityType.ROWING;
|
||||
}
|
||||
|
||||
log.debug("Unknown GPX activity type '{}', defaulting to OTHER", gpxType);
|
||||
return Activity.ActivityType.OTHER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates all metrics from track points.
|
||||
* GPX files don't include session summaries, so we must calculate everything.
|
||||
*/
|
||||
private void calculateMetrics(ParsedActivityData parsedData) {
|
||||
List<TrackPointData> points = parsedData.getTrackPoints();
|
||||
if (points.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ActivityMetricsData metrics = new ActivityMetricsData();
|
||||
|
||||
double totalDistance = 0;
|
||||
double elevationGain = 0;
|
||||
double elevationLoss = 0;
|
||||
BigDecimal previousElevation = points.get(0).getElevation();
|
||||
BigDecimal maxElevation = previousElevation;
|
||||
BigDecimal minElevation = previousElevation;
|
||||
|
||||
List<BigDecimal> speeds = new ArrayList<>();
|
||||
List<Integer> heartRates = new ArrayList<>();
|
||||
List<Integer> cadences = new ArrayList<>();
|
||||
List<Integer> powers = new ArrayList<>();
|
||||
List<BigDecimal> temperatures = new ArrayList<>();
|
||||
|
||||
LocalDateTime lastStoppedTime = null;
|
||||
Duration stoppedTime = Duration.ZERO;
|
||||
Duration movingTime = Duration.ZERO;
|
||||
|
||||
// Iterate through consecutive pairs of points
|
||||
for (int i = 1; i < points.size(); i++) {
|
||||
TrackPointData prev = points.get(i - 1);
|
||||
TrackPointData curr = points.get(i);
|
||||
|
||||
// Calculate distance between points (Haversine formula)
|
||||
double distance = calculateDistance(prev, curr);
|
||||
totalDistance += distance;
|
||||
|
||||
// Calculate speed (km/h)
|
||||
if (prev.getTimestamp() != null && curr.getTimestamp() != null) {
|
||||
Duration timeDelta = Duration.between(prev.getTimestamp(), curr.getTimestamp());
|
||||
long seconds = timeDelta.getSeconds();
|
||||
|
||||
if (seconds > 0) {
|
||||
double speedKmh = (distance / 1000.0) / (seconds / 3600.0);
|
||||
BigDecimal speed = BigDecimal.valueOf(speedKmh).setScale(2, RoundingMode.HALF_UP);
|
||||
curr.setSpeed(speed);
|
||||
speeds.add(speed);
|
||||
|
||||
// Track moving vs stopped time
|
||||
if (speedKmh < STOPPED_SPEED_THRESHOLD) {
|
||||
if (lastStoppedTime == null) {
|
||||
lastStoppedTime = prev.getTimestamp();
|
||||
}
|
||||
Duration currentStopDuration = Duration.between(lastStoppedTime, curr.getTimestamp());
|
||||
if (currentStopDuration.getSeconds() > STOPPED_TIME_THRESHOLD) {
|
||||
stoppedTime = stoppedTime.plus(timeDelta);
|
||||
}
|
||||
} else {
|
||||
lastStoppedTime = null;
|
||||
movingTime = movingTime.plus(timeDelta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set cumulative distance
|
||||
curr.setDistance(BigDecimal.valueOf(totalDistance).setScale(2, RoundingMode.HALF_UP));
|
||||
|
||||
// Calculate elevation gain/loss
|
||||
if (curr.getElevation() != null && previousElevation != null) {
|
||||
double elevDelta = curr.getElevation().subtract(previousElevation).doubleValue();
|
||||
|
||||
// Ignore noise (GPS elevation is noisy)
|
||||
if (Math.abs(elevDelta) > ELEVATION_NOISE_THRESHOLD) {
|
||||
if (elevDelta > 0) {
|
||||
elevationGain += elevDelta;
|
||||
} else {
|
||||
elevationLoss += Math.abs(elevDelta);
|
||||
}
|
||||
}
|
||||
|
||||
// Track min/max elevation
|
||||
if (maxElevation == null || curr.getElevation().compareTo(maxElevation) > 0) {
|
||||
maxElevation = curr.getElevation();
|
||||
}
|
||||
if (minElevation == null || curr.getElevation().compareTo(minElevation) < 0) {
|
||||
minElevation = curr.getElevation();
|
||||
}
|
||||
|
||||
previousElevation = curr.getElevation();
|
||||
}
|
||||
|
||||
// Collect sensor data
|
||||
if (curr.getHeartRate() != null) {
|
||||
heartRates.add(curr.getHeartRate());
|
||||
}
|
||||
if (curr.getCadence() != null) {
|
||||
cadences.add(curr.getCadence());
|
||||
}
|
||||
if (curr.getPower() != null) {
|
||||
powers.add(curr.getPower());
|
||||
}
|
||||
if (curr.getTemperature() != null) {
|
||||
temperatures.add(curr.getTemperature());
|
||||
}
|
||||
}
|
||||
|
||||
// Set calculated values in parsedData
|
||||
parsedData.setTotalDistance(BigDecimal.valueOf(totalDistance).setScale(2, RoundingMode.HALF_UP));
|
||||
parsedData.setElevationGain(BigDecimal.valueOf(elevationGain).setScale(2, RoundingMode.HALF_UP));
|
||||
parsedData.setElevationLoss(BigDecimal.valueOf(elevationLoss).setScale(2, RoundingMode.HALF_UP));
|
||||
|
||||
// Calculate average and max values
|
||||
if (!speeds.isEmpty()) {
|
||||
metrics.setAverageSpeed(calculateAverage(speeds));
|
||||
metrics.setMaxSpeed(speeds.stream().max(BigDecimal::compareTo).orElse(null));
|
||||
}
|
||||
|
||||
if (!heartRates.isEmpty()) {
|
||||
metrics.setAverageHeartRate(calculateAverageInt(heartRates));
|
||||
metrics.setMaxHeartRate(heartRates.stream().max(Integer::compareTo).orElse(null));
|
||||
}
|
||||
|
||||
if (!cadences.isEmpty()) {
|
||||
metrics.setAverageCadence(calculateAverageInt(cadences));
|
||||
metrics.setMaxCadence(cadences.stream().max(Integer::compareTo).orElse(null));
|
||||
}
|
||||
|
||||
if (!powers.isEmpty()) {
|
||||
metrics.setAveragePower(calculateAverageInt(powers));
|
||||
metrics.setMaxPower(powers.stream().max(Integer::compareTo).orElse(null));
|
||||
}
|
||||
|
||||
if (!temperatures.isEmpty()) {
|
||||
metrics.setAverageTemperature(calculateAverage(temperatures));
|
||||
}
|
||||
|
||||
metrics.setMaxElevation(maxElevation);
|
||||
metrics.setMinElevation(minElevation);
|
||||
metrics.setTotalAscent(BigDecimal.valueOf(elevationGain).setScale(2, RoundingMode.HALF_UP));
|
||||
metrics.setTotalDescent(BigDecimal.valueOf(elevationLoss).setScale(2, RoundingMode.HALF_UP));
|
||||
metrics.setMovingTime(movingTime);
|
||||
metrics.setStoppedTime(stoppedTime);
|
||||
|
||||
parsedData.setMetrics(metrics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates distance between two GPS points using Haversine formula.
|
||||
* Returns distance in meters.
|
||||
*/
|
||||
private double calculateDistance(TrackPointData p1, TrackPointData p2) {
|
||||
final double EARTH_RADIUS = 6371000; // meters
|
||||
|
||||
double lat1 = Math.toRadians(p1.getLatitude());
|
||||
double lat2 = Math.toRadians(p2.getLatitude());
|
||||
double deltaLat = Math.toRadians(p2.getLatitude() - p1.getLatitude());
|
||||
double deltaLon = Math.toRadians(p2.getLongitude() - p1.getLongitude());
|
||||
|
||||
double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) *
|
||||
Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
|
||||
|
||||
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return EARTH_RADIUS * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the timezone based on the first GPS coordinate.
|
||||
*/
|
||||
private void determineTimezone(ParsedActivityData parsedData) {
|
||||
if (parsedData.getTrackPoints().isEmpty()) {
|
||||
parsedData.setTimezone("UTC");
|
||||
return;
|
||||
}
|
||||
|
||||
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
|
||||
double latitude = firstPoint.getLatitude();
|
||||
double longitude = firstPoint.getLongitude();
|
||||
|
||||
try {
|
||||
// Lazy-load timezone engine (expensive initialization ~200ms first time)
|
||||
if (timezoneEngine == null) {
|
||||
log.info("Initializing TimeZoneEngine for timezone lookup...");
|
||||
timezoneEngine = TimeZoneEngine.initialize();
|
||||
}
|
||||
|
||||
Optional<ZoneId> zoneId = timezoneEngine.query(latitude, longitude);
|
||||
if (zoneId.isPresent()) {
|
||||
parsedData.setTimezone(zoneId.get().getId());
|
||||
log.debug("Determined timezone: {} from coordinates ({}, {})",
|
||||
zoneId.get().getId(), latitude, longitude);
|
||||
} else {
|
||||
log.warn("Could not determine timezone for coordinates ({}, {}), defaulting to UTC",
|
||||
latitude, longitude);
|
||||
parsedData.setTimezone("UTC");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error determining timezone, defaulting to UTC", e);
|
||||
parsedData.setTimezone("UTC");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies speed smoothing to track points and updates max speed in metrics.
|
||||
*/
|
||||
private void smoothSpeedData(ParsedActivityData 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses ISO 8601 datetime string (GPX standard).
|
||||
*/
|
||||
private LocalDateTime parseIso8601DateTime(String dateTimeStr) {
|
||||
try {
|
||||
return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ISO_DATE_TIME);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse datetime: {}", dateTimeStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets text content of child element by tag name.
|
||||
*/
|
||||
private String getElementText(Element parent, String tagName) {
|
||||
NodeList nodes = parent.getElementsByTagName(tagName);
|
||||
if (nodes.getLength() == 0) {
|
||||
nodes = parent.getElementsByTagNameNS("*", tagName);
|
||||
}
|
||||
if (nodes.getLength() > 0) {
|
||||
Node node = nodes.item(0);
|
||||
String text = node.getTextContent();
|
||||
return (text != null && !text.trim().isEmpty()) ? text.trim() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets text content of child element by namespace and tag name.
|
||||
*/
|
||||
private String getElementTextNS(Element parent, String namespace, String tagName) {
|
||||
NodeList nodes = "*".equals(namespace)
|
||||
? parent.getElementsByTagNameNS("*", tagName)
|
||||
: parent.getElementsByTagNameNS(namespace, tagName);
|
||||
|
||||
if (nodes.getLength() > 0) {
|
||||
Node node = nodes.item(0);
|
||||
String text = node.getTextContent();
|
||||
return (text != null && !text.trim().isEmpty()) ? text.trim() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates average of BigDecimal list.
|
||||
*/
|
||||
private BigDecimal calculateAverage(List<BigDecimal> values) {
|
||||
if (values.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
BigDecimal sum = values.stream()
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates average of Integer list.
|
||||
*/
|
||||
private Integer calculateAverageInt(List<Integer> values) {
|
||||
if (values.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
double sum = values.stream()
|
||||
.mapToInt(Integer::intValue)
|
||||
.average()
|
||||
.orElse(0);
|
||||
return (int) Math.round(sum);
|
||||
}
|
||||
}
|
||||
111
src/main/java/org/operaton/fitpub/util/ParsedActivityData.java
Normal file
111
src/main/java/org/operaton/fitpub/util/ParsedActivityData.java
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.Data;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Common data structure for parsed activity files (FIT, GPX, etc.).
|
||||
* Contains track points, metrics, and metadata extracted from the file.
|
||||
*/
|
||||
@Data
|
||||
public class ParsedActivityData {
|
||||
private List<TrackPointData> trackPoints = new ArrayList<>();
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private LocalDateTime activityTimestamp;
|
||||
private String timezone; // IANA timezone ID (e.g., "Europe/Berlin")
|
||||
private BigDecimal totalDistance;
|
||||
private Duration totalDuration;
|
||||
private BigDecimal elevationGain;
|
||||
private BigDecimal elevationLoss;
|
||||
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
|
||||
private ActivityMetricsData metrics;
|
||||
private String sourceFormat; // "FIT" or "GPX"
|
||||
|
||||
/**
|
||||
* Data class for track point information.
|
||||
*/
|
||||
@Data
|
||||
public static class TrackPointData {
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
private LocalDateTime timestamp;
|
||||
private double latitude;
|
||||
private double longitude;
|
||||
private BigDecimal elevation;
|
||||
private Integer heartRate;
|
||||
private Integer cadence;
|
||||
private Integer power;
|
||||
private BigDecimal speed;
|
||||
private BigDecimal temperature;
|
||||
private BigDecimal distance;
|
||||
|
||||
public Point toGeometry() {
|
||||
return GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for activity metrics.
|
||||
*/
|
||||
@Data
|
||||
public static class ActivityMetricsData {
|
||||
private BigDecimal averageSpeed;
|
||||
private BigDecimal maxSpeed;
|
||||
private Duration averagePace;
|
||||
private Integer averageHeartRate;
|
||||
private Integer maxHeartRate;
|
||||
private Integer averageCadence;
|
||||
private Integer maxCadence;
|
||||
private Integer averagePower;
|
||||
private Integer maxPower;
|
||||
private Integer normalizedPower;
|
||||
private Integer calories;
|
||||
private BigDecimal averageTemperature;
|
||||
private BigDecimal maxElevation;
|
||||
private BigDecimal minElevation;
|
||||
private BigDecimal totalAscent;
|
||||
private BigDecimal totalDescent;
|
||||
private Duration movingTime;
|
||||
private Duration stoppedTime;
|
||||
private Integer totalSteps;
|
||||
|
||||
public ActivityMetrics toEntity(Activity activity) {
|
||||
return ActivityMetrics.builder()
|
||||
.activity(activity)
|
||||
.averageSpeed(averageSpeed)
|
||||
.maxSpeed(maxSpeed)
|
||||
.averagePaceSeconds(averagePace != null ? averagePace.getSeconds() : null)
|
||||
.averageHeartRate(averageHeartRate)
|
||||
.maxHeartRate(maxHeartRate)
|
||||
.averageCadence(averageCadence)
|
||||
.maxCadence(maxCadence)
|
||||
.averagePower(averagePower)
|
||||
.maxPower(maxPower)
|
||||
.normalizedPower(normalizedPower)
|
||||
.calories(calories)
|
||||
.averageTemperature(averageTemperature)
|
||||
.maxElevation(maxElevation)
|
||||
.minElevation(minElevation)
|
||||
.totalAscent(totalAscent)
|
||||
.totalDescent(totalDescent)
|
||||
.movingTimeSeconds(movingTime != null ? movingTime.getSeconds() : null)
|
||||
.stoppedTimeSeconds(stoppedTime != null ? stoppedTime.getSeconds() : null)
|
||||
.totalSteps(totalSteps)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ public class SpeedSmoother {
|
|||
* @return smoothed maximum speed in km/h, or null if no valid speeds
|
||||
*/
|
||||
public BigDecimal smoothAndCalculateMaxSpeed(
|
||||
List<FitParser.TrackPointData> trackPoints,
|
||||
List<ParsedActivityData.TrackPointData> trackPoints,
|
||||
Activity.ActivityType activityType
|
||||
) {
|
||||
if (trackPoints == null || trackPoints.isEmpty()) {
|
||||
|
|
@ -110,10 +110,10 @@ public class SpeedSmoother {
|
|||
* Applies speed threshold to remove obvious outliers.
|
||||
* Replaces speeds exceeding threshold with null.
|
||||
*/
|
||||
private void applySpeedThreshold(List<FitParser.TrackPointData> trackPoints, double maxSpeedMps) {
|
||||
private void applySpeedThreshold(List<ParsedActivityData.TrackPointData> trackPoints, double maxSpeedMps) {
|
||||
int outlierCount = 0;
|
||||
|
||||
for (FitParser.TrackPointData point : trackPoints) {
|
||||
for (ParsedActivityData.TrackPointData point : trackPoints) {
|
||||
if (point.getSpeed() != null) {
|
||||
// Convert km/h to m/s for comparison
|
||||
double speedMps = point.getSpeed().doubleValue() / 3.6;
|
||||
|
|
@ -136,14 +136,14 @@ public class SpeedSmoother {
|
|||
* Removes points with unrealistic acceleration changes.
|
||||
*/
|
||||
private void applyAccelerationFilter(
|
||||
List<FitParser.TrackPointData> trackPoints,
|
||||
List<ParsedActivityData.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);
|
||||
ParsedActivityData.TrackPointData prev = trackPoints.get(i - 1);
|
||||
ParsedActivityData.TrackPointData curr = trackPoints.get(i);
|
||||
|
||||
if (prev.getSpeed() == null || curr.getSpeed() == null ||
|
||||
prev.getTimestamp() == null || curr.getTimestamp() == null) {
|
||||
|
|
@ -180,7 +180,7 @@ public class SpeedSmoother {
|
|||
* Applies moving median filter to smooth speed data.
|
||||
* Fills in nulls with interpolated values where possible.
|
||||
*/
|
||||
private void applyMedianFilter(List<FitParser.TrackPointData> trackPoints) {
|
||||
private void applyMedianFilter(List<ParsedActivityData.TrackPointData> trackPoints) {
|
||||
int windowSize = Math.min(MEDIAN_WINDOW_SIZE, trackPoints.size());
|
||||
if (windowSize < 3) {
|
||||
return; // Not enough points for meaningful smoothing
|
||||
|
|
@ -230,9 +230,9 @@ public class SpeedSmoother {
|
|||
/**
|
||||
* Calculates maximum speed from smoothed track points.
|
||||
*/
|
||||
private BigDecimal calculateMaxSpeed(List<FitParser.TrackPointData> trackPoints) {
|
||||
private BigDecimal calculateMaxSpeed(List<ParsedActivityData.TrackPointData> trackPoints) {
|
||||
return trackPoints.stream()
|
||||
.map(FitParser.TrackPointData::getSpeed)
|
||||
.map(ParsedActivityData.TrackPointData::getSpeed)
|
||||
.filter(speed -> speed != null)
|
||||
.max(BigDecimal::compareTo)
|
||||
.orElse(null);
|
||||
|
|
|
|||
25
src/main/resources/db/migration/V15__support_gpx_files.sql
Normal file
25
src/main/resources/db/migration/V15__support_gpx_files.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-- Migration to support GPX files in addition to FIT files
|
||||
-- Renames raw_fit_file column and adds source_file_format tracking
|
||||
|
||||
-- Rename raw_fit_file column to raw_activity_file (more generic name)
|
||||
ALTER TABLE activities RENAME COLUMN raw_fit_file TO raw_activity_file;
|
||||
|
||||
-- Add source_file_format column to track the original file format
|
||||
ALTER TABLE activities ADD COLUMN source_file_format VARCHAR(10) DEFAULT 'FIT';
|
||||
|
||||
-- Backfill existing records (all existing activities are from FIT files)
|
||||
UPDATE activities SET source_file_format = 'FIT' WHERE source_file_format IS NULL;
|
||||
|
||||
-- Make source_file_format NOT NULL now that all records are backfilled
|
||||
ALTER TABLE activities ALTER COLUMN source_file_format SET NOT NULL;
|
||||
|
||||
-- Add check constraint to ensure only valid formats are stored
|
||||
ALTER TABLE activities ADD CONSTRAINT chk_source_file_format
|
||||
CHECK (source_file_format IN ('FIT', 'GPX'));
|
||||
|
||||
-- Add index for faster filtering by format (optional but helpful for analytics)
|
||||
CREATE INDEX idx_activities_source_format ON activities(source_file_format);
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN activities.source_file_format IS 'Original file format: FIT (Garmin/Wahoo devices) or GPX (GPS Exchange Format)';
|
||||
COMMENT ON COLUMN activities.raw_activity_file IS 'Raw activity file bytes for re-processing with updated algorithms';
|
||||
|
|
@ -37,27 +37,27 @@
|
|||
<!-- File Upload Area -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">
|
||||
FIT File <span class="text-danger">*</span>
|
||||
Activity File <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="file-upload-area" id="fileUploadArea">
|
||||
<input type="file"
|
||||
id="fitFile"
|
||||
name="file"
|
||||
accept=".fit"
|
||||
accept=".fit,.gpx"
|
||||
class="d-none"
|
||||
required>
|
||||
<div class="file-upload-icon">
|
||||
<i class="bi bi-cloud-arrow-up"></i>
|
||||
</div>
|
||||
<p class="mb-2"><strong>Drop your FIT file here</strong></p>
|
||||
<p class="mb-2"><strong>Drop your FIT or GPX file here</strong></p>
|
||||
<p class="text-muted mb-2">or click to browse</p>
|
||||
<p class="file-upload-label text-primary fw-bold" id="fileLabel">
|
||||
No file selected
|
||||
</p>
|
||||
<small class="text-muted">Supported: .fit files from Garmin, Wahoo, etc. (Max 50MB)</small>
|
||||
<small class="text-muted">Supported: .fit, .gpx files (Max 50MB)</small>
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
Please select a FIT file to upload.
|
||||
Please select a FIT or GPX file to upload.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -164,7 +164,8 @@
|
|||
<div class="card-body">
|
||||
<h6><i class="bi bi-lightbulb text-warning"></i> Upload Tips</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>FIT files can be exported from Garmin Connect, Strava, Wahoo, and most GPS devices</li>
|
||||
<li>FIT files from Garmin, Wahoo, and most GPS devices</li>
|
||||
<li>GPX files exported from Strava, Komoot, or other apps</li>
|
||||
<li>The activity will be processed to extract GPS tracks, metrics, and statistics</li>
|
||||
<li>You can add a title and description after uploading</li>
|
||||
<li>Public activities will appear in your followers' timelines on the Fediverse</li>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue