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
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -7,7 +7,7 @@ FitPub is a decentralized fitness tracking application that integrates with the
|
|||
## Core Concept
|
||||
|
||||
The platform bridges the gap between fitness tracking and social networking by leveraging the open ActivityPub standard. Users can:
|
||||
- Upload FIT files from GPS-enabled fitness devices (Garmin, Wahoo, etc.)
|
||||
- Upload FIT files from GPS-enabled fitness devices (Garmin, Wahoo, etc.) or GPX files from apps (Strava, Komoot, etc.)
|
||||
- View their tracks rendered on interactive maps
|
||||
- Share activities with followers on Mastodon, Pleroma, and other Fediverse platforms
|
||||
- Follow other athletes and see their public workouts
|
||||
|
|
@ -508,14 +508,17 @@ For ActivityPub federated posts and thumbnails:
|
|||
|
||||
### Phase 1: MVP (Minimum Viable Product)
|
||||
|
||||
**System Component 1: FIT File Processing Module** ✅
|
||||
**System Component 1: Activity File Processing Module** ✅
|
||||
- [x] FIT file upload and parsing (FitParser)
|
||||
- [x] FIT file validation (FitFileValidator)
|
||||
- [x] GPX file upload and parsing (GpxParser)
|
||||
- [x] GPX file validation (GpxFileValidator)
|
||||
- [x] Unified ActivityFileService with automatic format detection
|
||||
- [x] Activity entity with JSONB track points and simplified LineString
|
||||
- [x] Activity metrics extraction and storage
|
||||
- [x] Activity metrics extraction and storage (calculated from track points for GPX)
|
||||
- [x] Track simplification using Douglas-Peucker algorithm
|
||||
- [x] FIT file service with comprehensive tests
|
||||
- [x] Integration test with real FIT file
|
||||
- [x] File service with comprehensive tests
|
||||
- [x] Integration tests with real FIT and GPX files
|
||||
|
||||
**User Management & Security** ✅
|
||||
- [x] User entity with ActivityPub keys
|
||||
|
|
@ -538,7 +541,7 @@ For ActivityPub federated posts and thumbnails:
|
|||
- [x] Profile-specific configs (application-dev.yml, application-prod.yml)
|
||||
|
||||
**Activity REST API** ✅
|
||||
- [x] POST /api/activities/upload - Upload FIT file
|
||||
- [x] POST /api/activities/upload - Upload FIT or GPX file (automatic format detection)
|
||||
- [x] GET /api/activities/{id} - Get activity details
|
||||
- [x] GET /api/activities - List user's activities (paginated)
|
||||
- [x] PUT /api/activities/{id} - Update activity metadata
|
||||
|
|
@ -636,7 +639,7 @@ For ActivityPub federated posts and thumbnails:
|
|||
- [x] Authenticated API fetch helper (authenticatedFetch in auth.js)
|
||||
|
||||
**Activity Upload & Management UI** ✅
|
||||
- [x] FIT file upload form with drag-and-drop
|
||||
- [x] Activity file upload form with drag-and-drop (FIT and GPX)
|
||||
- [x] Upload progress indicator
|
||||
- [x] Activity metadata form (title, description, visibility)
|
||||
- [x] Activity list view (user's own activities)
|
||||
|
|
@ -750,7 +753,7 @@ For ActivityPub federated posts and thumbnails:
|
|||
## Phase 1 (MVP) - ✅ COMPLETE!
|
||||
|
||||
**All core features implemented and working:**
|
||||
- ✅ FIT file upload and processing
|
||||
- ✅ FIT and GPX file upload and processing (automatic format detection)
|
||||
- ✅ GPS track visualization with Leaflet maps
|
||||
- ✅ Activity management (CRUD operations)
|
||||
- ✅ User authentication and profiles
|
||||
|
|
@ -849,11 +852,12 @@ For ActivityPub federated posts and thumbnails:
|
|||
- [x] Cross-platform activity sync
|
||||
|
||||
### Phase 5: Mobile & Integrations
|
||||
- [x] GPX file support (completed - import from Strava, Komoot, etc. via GPX export)
|
||||
- [ ] Progressive Web App (PWA)
|
||||
- [ ] Native mobile apps (optional)
|
||||
- [ ] Direct device sync (Garmin Connect API)
|
||||
- [ ] Webhook integrations
|
||||
- [ ] Import from Strava, Garmin, etc.
|
||||
- [ ] TCX file support
|
||||
- [ ] Avatar file upload (currently URL-based)
|
||||
|
||||
### Phase 6: Testing & Documentation
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import org.operaton.fitpub.model.entity.User;
|
|||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.util.FitParser;
|
||||
import org.operaton.fitpub.util.ParsedActivityData;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
|
@ -67,7 +68,7 @@ class ActivityImageServiceTest {
|
|||
assertTrue(fitFileData.length > 0, "FIT file should not be empty");
|
||||
|
||||
// Parse FIT file
|
||||
FitParser.ParsedFitData parsedData = fitParser.parse(fitFileData);
|
||||
ParsedActivityData parsedData = fitParser.parse(fitFileData);
|
||||
assertNotNull(parsedData);
|
||||
assertNotNull(parsedData.getStartTime());
|
||||
assertNotNull(parsedData.getEndTime());
|
||||
|
|
@ -182,7 +183,7 @@ class ActivityImageServiceTest {
|
|||
/**
|
||||
* Helper method to convert parsed track points to JSON format.
|
||||
*/
|
||||
private String convertTrackPointsToJson(FitParser.ParsedFitData parsedData) throws IOException {
|
||||
private String convertTrackPointsToJson(ParsedActivityData parsedData) throws IOException {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import org.operaton.fitpub.repository.ActivityMetricsRepository;
|
|||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
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.mock.web.MockMultipartFile;
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ class FitFileServiceTest {
|
|||
|
||||
private UUID testUserId;
|
||||
private MockMultipartFile testFile;
|
||||
private FitParser.ParsedFitData testParsedData;
|
||||
private ParsedActivityData testParsedData;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
|
|
@ -512,8 +513,8 @@ class FitFileServiceTest {
|
|||
/**
|
||||
* Creates test parsed FIT data with realistic values.
|
||||
*/
|
||||
private FitParser.ParsedFitData createTestParsedData() {
|
||||
FitParser.ParsedFitData data = new FitParser.ParsedFitData();
|
||||
private ParsedActivityData createTestParsedData() {
|
||||
ParsedActivityData data = new ParsedActivityData();
|
||||
|
||||
LocalDateTime startTime = LocalDateTime.of(2024, 1, 15, 8, 0, 0);
|
||||
data.setStartTime(startTime);
|
||||
|
|
@ -525,10 +526,10 @@ class FitFileServiceTest {
|
|||
data.setElevationLoss(BigDecimal.valueOf(95.0));
|
||||
|
||||
// Add test track points
|
||||
List<FitParser.TrackPointData> trackPoints = new ArrayList<>();
|
||||
List<ParsedActivityData.TrackPointData> trackPoints = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
FitParser.TrackPointData tp = new FitParser.TrackPointData();
|
||||
ParsedActivityData.TrackPointData tp = new ParsedActivityData.TrackPointData();
|
||||
tp.setTimestamp(startTime.plusMinutes(i * 3));
|
||||
tp.setLatitude(47.0 + i * 0.001);
|
||||
tp.setLongitude(8.0 + i * 0.001);
|
||||
|
|
@ -541,7 +542,7 @@ class FitFileServiceTest {
|
|||
data.setTrackPoints(trackPoints);
|
||||
|
||||
// Add test metrics
|
||||
FitParser.ActivityMetricsData metrics = new FitParser.ActivityMetricsData();
|
||||
ParsedActivityData.ActivityMetricsData metrics = new ParsedActivityData.ActivityMetricsData();
|
||||
metrics.setAverageSpeed(BigDecimal.valueOf(10.0));
|
||||
metrics.setMaxSpeed(BigDecimal.valueOf(15.0));
|
||||
metrics.setAverageHeartRate(150);
|
||||
|
|
|
|||
|
|
@ -280,8 +280,9 @@ class TrainingLoadServiceTest {
|
|||
// Given
|
||||
int days = 30;
|
||||
LocalDate startDate = LocalDate.now().minusDays(days - 1);
|
||||
LocalDate recentDate = LocalDate.now().minusDays(5); // Use a date within the last 30 days
|
||||
List<TrainingLoad> existingLoad = List.of(
|
||||
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
|
||||
createTrainingLoad(userId, recentDate, BigDecimal.valueOf(100.0))
|
||||
);
|
||||
|
||||
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
|
||||
|
|
@ -297,7 +298,7 @@ class TrainingLoadServiceTest {
|
|||
|
||||
// Verify that the existing load is included
|
||||
assertTrue(result.stream().anyMatch(tl ->
|
||||
tl.getDate().equals(testDate) &&
|
||||
tl.getDate().equals(recentDate) &&
|
||||
tl.getTrainingStressScore() != null &&
|
||||
tl.getTrainingStressScore().compareTo(BigDecimal.valueOf(100.0)) == 0
|
||||
));
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class FitParserIntegrationTest {
|
|||
"Real FIT file should pass validation");
|
||||
|
||||
// Parse the file
|
||||
FitParser.ParsedFitData parsedData = assertDoesNotThrow(
|
||||
ParsedActivityData parsedData = assertDoesNotThrow(
|
||||
() -> parser.parse(fileData),
|
||||
"Real FIT file should parse without errors"
|
||||
);
|
||||
|
|
@ -93,7 +93,7 @@ class FitParserIntegrationTest {
|
|||
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
|
||||
|
||||
// Verify track point data quality
|
||||
FitParser.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
|
||||
ParsedActivityData.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
|
||||
assertNotNull(firstPoint, "First track point should not be null");
|
||||
assertNotEquals(0.0, firstPoint.getLatitude(), "Latitude should be set");
|
||||
assertNotEquals(0.0, firstPoint.getLongitude(), "Longitude should be set");
|
||||
|
|
@ -116,7 +116,7 @@ class FitParserIntegrationTest {
|
|||
|
||||
// Verify metrics if present
|
||||
if (parsedData.getMetrics() != null) {
|
||||
FitParser.ActivityMetricsData metrics = parsedData.getMetrics();
|
||||
ParsedActivityData.ActivityMetricsData metrics = parsedData.getMetrics();
|
||||
log.info("Metrics:");
|
||||
|
||||
if (metrics.getAverageSpeed() != null) {
|
||||
|
|
@ -159,7 +159,7 @@ class FitParserIntegrationTest {
|
|||
inputStream.close();
|
||||
|
||||
// Parse the file
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Test converting to entity structures
|
||||
Activity.ActivityType activityType = parsedData.getActivityType();
|
||||
|
|
@ -167,7 +167,7 @@ class FitParserIntegrationTest {
|
|||
|
||||
// Verify we can convert track points to entities
|
||||
if (!parsedData.getTrackPoints().isEmpty()) {
|
||||
FitParser.TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
|
||||
ParsedActivityData.TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
|
||||
|
||||
// Test geometry creation
|
||||
assertDoesNotThrow(() -> trackPointData.toGeometry(),
|
||||
|
|
@ -209,13 +209,13 @@ class FitParserIntegrationTest {
|
|||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify track points are in chronological order
|
||||
if (parsedData.getTrackPoints().size() > 1) {
|
||||
for (int i = 0; i < parsedData.getTrackPoints().size() - 1; i++) {
|
||||
FitParser.TrackPointData current = parsedData.getTrackPoints().get(i);
|
||||
FitParser.TrackPointData next = parsedData.getTrackPoints().get(i + 1);
|
||||
ParsedActivityData.TrackPointData current = parsedData.getTrackPoints().get(i);
|
||||
ParsedActivityData.TrackPointData next = parsedData.getTrackPoints().get(i + 1);
|
||||
|
||||
if (current.getTimestamp() != null && next.getTimestamp() != null) {
|
||||
assertTrue(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.util.ParsedActivityData.TrackPointData;
|
||||
import org.operaton.fitpub.util.ParsedActivityData.ActivityMetricsData;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration test for GpxParser using a real GPX file.
|
||||
* Tests parsing of GPX files exported from Strava with Garmin extensions.
|
||||
*/
|
||||
@Slf4j
|
||||
class GpxParserIntegrationTest {
|
||||
|
||||
private GpxParser parser;
|
||||
private GpxFileValidator validator;
|
||||
private SpeedSmoother speedSmoother;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
speedSmoother = new SpeedSmoother();
|
||||
parser = new GpxParser(speedSmoother);
|
||||
validator = new GpxFileValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully parse real GPX file from test resources")
|
||||
void testParseRealGpxFile() throws IOException {
|
||||
// Load the real GPX file from test resources
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
|
||||
assertNotNull(inputStream, "GPX file should exist in test resources: " + gpxFileName);
|
||||
|
||||
// Read file into byte array
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Validate the file
|
||||
assertDoesNotThrow(() -> validator.validate(fileData),
|
||||
"Real GPX file should pass validation");
|
||||
|
||||
// Parse the file
|
||||
ParsedActivityData parsedData = assertDoesNotThrow(
|
||||
() -> parser.parse(fileData),
|
||||
"Real GPX file should parse without errors"
|
||||
);
|
||||
|
||||
// Verify parsed data structure
|
||||
assertNotNull(parsedData, "Parsed data should not be null");
|
||||
assertEquals("GPX", parsedData.getSourceFormat(), "Source format should be GPX");
|
||||
|
||||
// Verify track points
|
||||
assertNotNull(parsedData.getTrackPoints(), "Track points should not be null");
|
||||
assertFalse(parsedData.getTrackPoints().isEmpty(), "Track points should not be empty");
|
||||
|
||||
log.info("Successfully parsed real GPX file:");
|
||||
log.info(" Track points: {}", parsedData.getTrackPoints().size());
|
||||
log.info(" Activity type: {}", parsedData.getActivityType());
|
||||
|
||||
if (parsedData.getStartTime() != null) {
|
||||
log.info(" Start time: {}", parsedData.getStartTime());
|
||||
}
|
||||
|
||||
if (parsedData.getEndTime() != null) {
|
||||
log.info(" End time: {}", parsedData.getEndTime());
|
||||
}
|
||||
|
||||
if (parsedData.getTotalDistance() != null) {
|
||||
log.info(" Total distance: {} meters", parsedData.getTotalDistance());
|
||||
}
|
||||
|
||||
if (parsedData.getTotalDuration() != null) {
|
||||
long minutes = parsedData.getTotalDuration().toMinutes();
|
||||
long seconds = parsedData.getTotalDuration().getSeconds() % 60;
|
||||
log.info(" Total duration: {}m {}s", minutes, seconds);
|
||||
}
|
||||
|
||||
if (parsedData.getElevationGain() != null) {
|
||||
log.info(" Elevation gain: {} meters", parsedData.getElevationGain());
|
||||
}
|
||||
|
||||
if (parsedData.getElevationLoss() != null) {
|
||||
log.info(" Elevation loss: {} meters", parsedData.getElevationLoss());
|
||||
}
|
||||
|
||||
if (parsedData.getTimezone() != null) {
|
||||
log.info(" Timezone: {}", parsedData.getTimezone());
|
||||
}
|
||||
|
||||
// Verify at least some basic data
|
||||
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
|
||||
assertEquals(Activity.ActivityType.RUN, parsedData.getActivityType(),
|
||||
"Activity type should be RUN (from GPX <type>running</type>)");
|
||||
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
|
||||
|
||||
// Verify track point data quality
|
||||
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
|
||||
assertNotNull(firstPoint, "First track point should not be null");
|
||||
assertNotEquals(0.0, firstPoint.getLatitude(), "Latitude should be set");
|
||||
assertNotEquals(0.0, firstPoint.getLongitude(), "Longitude should be set");
|
||||
|
||||
// Verify GPS coordinates are in valid range
|
||||
assertTrue(firstPoint.getLatitude() >= -90 && firstPoint.getLatitude() <= 90,
|
||||
"Latitude should be in valid range (-90 to 90)");
|
||||
assertTrue(firstPoint.getLongitude() >= -180 && firstPoint.getLongitude() <= 180,
|
||||
"Longitude should be in valid range (-180 to 180)");
|
||||
|
||||
log.info(" First point: lat={}, lon={}", firstPoint.getLatitude(), firstPoint.getLongitude());
|
||||
|
||||
if (firstPoint.getElevation() != null) {
|
||||
log.info(" First point elevation: {} meters", firstPoint.getElevation());
|
||||
}
|
||||
|
||||
if (firstPoint.getHeartRate() != null) {
|
||||
log.info(" First point heart rate: {} bpm", firstPoint.getHeartRate());
|
||||
}
|
||||
|
||||
// Verify calculated metrics (GPX doesn't have session summaries, so we calculate them)
|
||||
if (parsedData.getMetrics() != null) {
|
||||
ActivityMetricsData metrics = parsedData.getMetrics();
|
||||
log.info("Calculated Metrics:");
|
||||
|
||||
if (metrics.getAverageSpeed() != null) {
|
||||
log.info(" Average speed: {} km/h", metrics.getAverageSpeed());
|
||||
assertTrue(metrics.getAverageSpeed().compareTo(BigDecimal.ZERO) > 0,
|
||||
"Average speed should be positive");
|
||||
}
|
||||
|
||||
if (metrics.getMaxSpeed() != null) {
|
||||
log.info(" Max speed: {} km/h", metrics.getMaxSpeed());
|
||||
assertTrue(metrics.getMaxSpeed().compareTo(BigDecimal.ZERO) > 0,
|
||||
"Max speed should be positive");
|
||||
}
|
||||
|
||||
if (metrics.getAverageHeartRate() != null) {
|
||||
log.info(" Average heart rate: {} bpm", metrics.getAverageHeartRate());
|
||||
assertTrue(metrics.getAverageHeartRate() > 0 && metrics.getAverageHeartRate() < 220,
|
||||
"Average heart rate should be in reasonable range");
|
||||
}
|
||||
|
||||
if (metrics.getMaxHeartRate() != null) {
|
||||
log.info(" Max heart rate: {} bpm", metrics.getMaxHeartRate());
|
||||
assertTrue(metrics.getMaxHeartRate() > 0 && metrics.getMaxHeartRate() < 220,
|
||||
"Max heart rate should be in reasonable range");
|
||||
}
|
||||
|
||||
if (metrics.getMinElevation() != null) {
|
||||
log.info(" Min elevation: {} meters", metrics.getMinElevation());
|
||||
}
|
||||
|
||||
if (metrics.getMaxElevation() != null) {
|
||||
log.info(" Max elevation: {} meters", metrics.getMaxElevation());
|
||||
}
|
||||
|
||||
if (metrics.getMovingTime() != null) {
|
||||
log.info(" Moving time: {} seconds", metrics.getMovingTime().getSeconds());
|
||||
}
|
||||
|
||||
if (metrics.getStoppedTime() != null) {
|
||||
log.info(" Stopped time: {} seconds", metrics.getStoppedTime().getSeconds());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract heart rate data from Garmin extensions")
|
||||
void testExtractHeartRateFromExtensions() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Parse the file
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify heart rate data is extracted from extensions
|
||||
long pointsWithHeartRate = parsedData.getTrackPoints().stream()
|
||||
.filter(tp -> tp.getHeartRate() != null)
|
||||
.count();
|
||||
|
||||
assertTrue(pointsWithHeartRate > 0,
|
||||
"Should have extracted heart rate data from Garmin TrackPointExtension");
|
||||
|
||||
log.info("Points with heart rate data: {} out of {}",
|
||||
pointsWithHeartRate, parsedData.getTrackPoints().size());
|
||||
|
||||
// Verify first point has heart rate
|
||||
TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
|
||||
assertNotNull(firstPoint.getHeartRate(),
|
||||
"First point should have heart rate from extension");
|
||||
assertTrue(firstPoint.getHeartRate() > 0 && firstPoint.getHeartRate() < 220,
|
||||
"Heart rate should be in reasonable range (0-220 bpm)");
|
||||
|
||||
log.info("First point heart rate: {} bpm", firstPoint.getHeartRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should calculate distance from GPS coordinates using Haversine formula")
|
||||
void testCalculateDistanceFromCoordinates() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify total distance was calculated
|
||||
assertNotNull(parsedData.getTotalDistance(), "Total distance should be calculated");
|
||||
assertTrue(parsedData.getTotalDistance().compareTo(BigDecimal.ZERO) > 0,
|
||||
"Total distance should be positive");
|
||||
|
||||
log.info("Calculated total distance: {} meters", parsedData.getTotalDistance());
|
||||
|
||||
// Verify distance is reasonable for a running activity
|
||||
// (GPX files don't have session summaries, so we calculate from track points)
|
||||
double distanceKm = parsedData.getTotalDistance().doubleValue() / 1000.0;
|
||||
assertTrue(distanceKm > 0 && distanceKm < 100,
|
||||
"Distance should be reasonable for a running activity");
|
||||
|
||||
log.info("Distance in km: {}", distanceKm);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should calculate elevation gain and loss from track points")
|
||||
void testCalculateElevationMetrics() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify elevation metrics were calculated
|
||||
assertNotNull(parsedData.getElevationGain(), "Elevation gain should be calculated");
|
||||
assertNotNull(parsedData.getElevationLoss(), "Elevation loss should be calculated");
|
||||
|
||||
// Elevation gain/loss should be non-negative
|
||||
assertTrue(parsedData.getElevationGain().compareTo(BigDecimal.ZERO) >= 0,
|
||||
"Elevation gain should be non-negative");
|
||||
assertTrue(parsedData.getElevationLoss().compareTo(BigDecimal.ZERO) >= 0,
|
||||
"Elevation loss should be non-negative");
|
||||
|
||||
log.info("Calculated elevation gain: {} meters", parsedData.getElevationGain());
|
||||
log.info("Calculated elevation loss: {} meters", parsedData.getElevationLoss());
|
||||
|
||||
// Verify min/max elevation in metrics
|
||||
if (parsedData.getMetrics() != null) {
|
||||
BigDecimal minElev = parsedData.getMetrics().getMinElevation();
|
||||
BigDecimal maxElev = parsedData.getMetrics().getMaxElevation();
|
||||
|
||||
if (minElev != null && maxElev != null) {
|
||||
assertTrue(maxElev.compareTo(minElev) >= 0,
|
||||
"Max elevation should be >= min elevation");
|
||||
log.info("Elevation range: {} - {} meters", minElev, maxElev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate real GPX file successfully")
|
||||
void testValidateRealGpxFile() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Should pass all validation checks
|
||||
assertDoesNotThrow(() -> validator.validate(fileData),
|
||||
"Real GPX file should pass validation");
|
||||
|
||||
// File should have valid extension
|
||||
assertTrue(validator.hasValidExtension(gpxFileName),
|
||||
"File should have valid .gpx extension");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle track points in chronological order")
|
||||
void testTrackPointsChronologicalOrder() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify track points are in chronological order
|
||||
if (parsedData.getTrackPoints().size() > 1) {
|
||||
for (int i = 0; i < parsedData.getTrackPoints().size() - 1; i++) {
|
||||
TrackPointData current = parsedData.getTrackPoints().get(i);
|
||||
TrackPointData next = parsedData.getTrackPoints().get(i + 1);
|
||||
|
||||
if (current.getTimestamp() != null && next.getTimestamp() != null) {
|
||||
assertTrue(
|
||||
!current.getTimestamp().isAfter(next.getTimestamp()),
|
||||
"Track points should be in chronological order at index " + i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Track points are in chronological order");
|
||||
log.info(" First timestamp: {}", parsedData.getTrackPoints().get(0).getTimestamp());
|
||||
log.info(" Last timestamp: {}",
|
||||
parsedData.getTrackPoints().get(parsedData.getTrackPoints().size() - 1).getTimestamp());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should calculate speed from consecutive GPS points")
|
||||
void testSpeedCalculation() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify speed was calculated for track points
|
||||
long pointsWithSpeed = parsedData.getTrackPoints().stream()
|
||||
.filter(tp -> tp.getSpeed() != null)
|
||||
.count();
|
||||
|
||||
assertTrue(pointsWithSpeed > 0,
|
||||
"Should have calculated speed for track points");
|
||||
|
||||
log.info("Points with calculated speed: {} out of {}",
|
||||
pointsWithSpeed, parsedData.getTrackPoints().size());
|
||||
|
||||
// Verify speeds are reasonable for running
|
||||
for (TrackPointData point : parsedData.getTrackPoints()) {
|
||||
if (point.getSpeed() != null) {
|
||||
double speedKmh = point.getSpeed().doubleValue();
|
||||
assertTrue(speedKmh >= 0 && speedKmh < 50,
|
||||
"Running speed should be reasonable (0-50 km/h)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract complete activity data from real GPX file")
|
||||
void testExtractCompleteActivityData() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Parse the file
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Test converting to entity structures
|
||||
Activity.ActivityType activityType = parsedData.getActivityType();
|
||||
assertNotNull(activityType, "Activity type should be extracted");
|
||||
assertEquals(Activity.ActivityType.RUN, activityType,
|
||||
"Activity should be detected as RUN from GPX <type>running</type>");
|
||||
|
||||
// Verify we can convert track points to entities
|
||||
if (!parsedData.getTrackPoints().isEmpty()) {
|
||||
TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
|
||||
|
||||
// Test geometry creation
|
||||
assertDoesNotThrow(() -> trackPointData.toGeometry(),
|
||||
"Should be able to create Point geometry from track point");
|
||||
|
||||
var point = trackPointData.toGeometry();
|
||||
assertNotNull(point, "Point geometry should not be null");
|
||||
assertEquals(trackPointData.getLongitude(), point.getX(), 0.0001,
|
||||
"Point X coordinate should match longitude");
|
||||
assertEquals(trackPointData.getLatitude(), point.getY(), 0.0001,
|
||||
"Point Y coordinate should match latitude");
|
||||
}
|
||||
|
||||
// Verify timezone was determined from GPS coordinates
|
||||
assertNotNull(parsedData.getTimezone(), "Timezone should be determined from GPS");
|
||||
assertFalse(parsedData.getTimezone().isEmpty(), "Timezone should not be empty");
|
||||
log.info("Determined timezone: {}", parsedData.getTimezone());
|
||||
|
||||
// Verify start and end times
|
||||
assertNotNull(parsedData.getStartTime(), "Start time should be set");
|
||||
assertNotNull(parsedData.getEndTime(), "End time should be set");
|
||||
assertTrue(!parsedData.getStartTime().isAfter(parsedData.getEndTime()),
|
||||
"Start time should be before or equal to end time");
|
||||
|
||||
// Verify total duration
|
||||
assertNotNull(parsedData.getTotalDuration(), "Total duration should be calculated");
|
||||
assertTrue(parsedData.getTotalDuration().getSeconds() > 0,
|
||||
"Total duration should be positive");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should apply speed smoothing to remove GPS artifacts")
|
||||
void testSpeedSmoothing() throws IOException {
|
||||
// Load the real GPX file
|
||||
String gpxFileName = "/7410863774.gpx";
|
||||
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
ParsedActivityData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify speed smoothing was applied (max speed should be reasonable after smoothing)
|
||||
if (parsedData.getMetrics() != null && parsedData.getMetrics().getMaxSpeed() != null) {
|
||||
double maxSpeedKmh = parsedData.getMetrics().getMaxSpeed().doubleValue();
|
||||
|
||||
// For running, max speed should be reasonable after smoothing (typically < 30 km/h)
|
||||
assertTrue(maxSpeedKmh > 0 && maxSpeedKmh < 30,
|
||||
"Max speed should be reasonable for running after smoothing: " + maxSpeedKmh);
|
||||
|
||||
log.info("Max speed after smoothing: {} km/h", maxSpeedKmh);
|
||||
}
|
||||
}
|
||||
}
|
||||
5736
src/test/resources/7410863774.gpx
Normal file
5736
src/test/resources/7410863774.gpx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue