Fit File Processing and Persistence
This commit is contained in:
commit
0bc4fb3118
24 changed files with 3533 additions and 0 deletions
|
|
@ -0,0 +1,15 @@
|
|||
package org.operaton.fitpub.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when FIT file processing fails.
|
||||
*/
|
||||
public class FitFileProcessingException extends RuntimeException {
|
||||
|
||||
public FitFileProcessingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public FitFileProcessingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.operaton.fitpub.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a FIT file is invalid or corrupted.
|
||||
*/
|
||||
public class InvalidFitFileException extends FitFileProcessingException {
|
||||
|
||||
public InvalidFitFileException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InvalidFitFileException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
68
src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java
Normal file
68
src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package org.operaton.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* DTO for Activity data transfer.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActivityDTO {
|
||||
|
||||
private UUID id;
|
||||
private UUID userId;
|
||||
private String activityType;
|
||||
private String title;
|
||||
private String description;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime endedAt;
|
||||
private String visibility;
|
||||
private BigDecimal totalDistance;
|
||||
private Long totalDurationSeconds;
|
||||
private BigDecimal elevationGain;
|
||||
private BigDecimal elevationLoss;
|
||||
private ActivityMetricsDTO metrics;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Creates a DTO from an Activity entity.
|
||||
*/
|
||||
public static ActivityDTO fromEntity(Activity activity) {
|
||||
ActivityDTOBuilder builder = ActivityDTO.builder()
|
||||
.id(activity.getId())
|
||||
.userId(activity.getUserId())
|
||||
.activityType(activity.getActivityType().name())
|
||||
.title(activity.getTitle())
|
||||
.description(activity.getDescription())
|
||||
.startedAt(activity.getStartedAt())
|
||||
.endedAt(activity.getEndedAt())
|
||||
.visibility(activity.getVisibility().name())
|
||||
.totalDistance(activity.getTotalDistance())
|
||||
.elevationGain(activity.getElevationGain())
|
||||
.elevationLoss(activity.getElevationLoss())
|
||||
.createdAt(activity.getCreatedAt())
|
||||
.updatedAt(activity.getUpdatedAt());
|
||||
|
||||
if (activity.getTotalDuration() != null) {
|
||||
builder.totalDurationSeconds(activity.getTotalDuration().getSeconds());
|
||||
}
|
||||
|
||||
if (activity.getMetrics() != null) {
|
||||
builder.metrics(ActivityMetricsDTO.fromEntity(activity.getMetrics()));
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package org.operaton.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* DTO for ActivityMetrics data transfer.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActivityMetricsDTO {
|
||||
|
||||
private BigDecimal averageSpeed;
|
||||
private BigDecimal maxSpeed;
|
||||
private Long averagePaceSeconds;
|
||||
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 Long movingTimeSeconds;
|
||||
private Long stoppedTimeSeconds;
|
||||
private Integer totalSteps;
|
||||
private BigDecimal trainingStressScore;
|
||||
|
||||
/**
|
||||
* Creates a DTO from an ActivityMetrics entity.
|
||||
*/
|
||||
public static ActivityMetricsDTO fromEntity(ActivityMetrics metrics) {
|
||||
ActivityMetricsDTOBuilder builder = ActivityMetricsDTO.builder()
|
||||
.averageSpeed(metrics.getAverageSpeed())
|
||||
.maxSpeed(metrics.getMaxSpeed())
|
||||
.averageHeartRate(metrics.getAverageHeartRate())
|
||||
.maxHeartRate(metrics.getMaxHeartRate())
|
||||
.averageCadence(metrics.getAverageCadence())
|
||||
.maxCadence(metrics.getMaxCadence())
|
||||
.averagePower(metrics.getAveragePower())
|
||||
.maxPower(metrics.getMaxPower())
|
||||
.normalizedPower(metrics.getNormalizedPower())
|
||||
.calories(metrics.getCalories())
|
||||
.averageTemperature(metrics.getAverageTemperature())
|
||||
.maxElevation(metrics.getMaxElevation())
|
||||
.minElevation(metrics.getMinElevation())
|
||||
.totalAscent(metrics.getTotalAscent())
|
||||
.totalDescent(metrics.getTotalDescent())
|
||||
.totalSteps(metrics.getTotalSteps())
|
||||
.trainingStressScore(metrics.getTrainingStressScore());
|
||||
|
||||
if (metrics.getAveragePace() != null) {
|
||||
builder.averagePaceSeconds(metrics.getAveragePace().getSeconds());
|
||||
}
|
||||
|
||||
if (metrics.getMovingTime() != null) {
|
||||
builder.movingTimeSeconds(metrics.getMovingTime().getSeconds());
|
||||
}
|
||||
|
||||
if (metrics.getStoppedTime() != null) {
|
||||
builder.stoppedTimeSeconds(metrics.getStoppedTime().getSeconds());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
150
src/main/java/org/operaton/fitpub/model/entity/Activity.java
Normal file
150
src/main/java/org/operaton/fitpub/model/entity/Activity.java
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.Type;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.locationtech.jts.geom.LineString;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity representing a fitness activity (workout).
|
||||
* Stores metadata, simplified track for map rendering, and full track data as JSONB.
|
||||
* This design optimizes for scalability by avoiding normalized track_points table.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "activities", indexes = {
|
||||
@Index(name = "idx_activity_user_id", columnList = "user_id"),
|
||||
@Index(name = "idx_activity_started_at", columnList = "started_at"),
|
||||
@Index(name = "idx_activity_type", columnList = "activity_type")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Activity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "activity_type", nullable = false, length = 50)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private ActivityType activityType;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "ended_at", nullable = false)
|
||||
private LocalDateTime endedAt;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Visibility visibility;
|
||||
|
||||
/**
|
||||
* Simplified track for map rendering (50-200 points).
|
||||
* Uses Douglas-Peucker algorithm to reduce point count while maintaining shape.
|
||||
*/
|
||||
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
|
||||
private LineString simplifiedTrack;
|
||||
|
||||
/**
|
||||
* Full track data stored as JSONB for detail view.
|
||||
* Contains all original track points with sensor data.
|
||||
* Much more efficient than normalized track_points table.
|
||||
*/
|
||||
@Type(JsonBinaryType.class)
|
||||
@Column(name = "track_points_json", columnDefinition = "jsonb")
|
||||
private String trackPointsJson;
|
||||
|
||||
@Column(name = "total_distance", precision = 10, scale = 2)
|
||||
private BigDecimal totalDistance;
|
||||
|
||||
@Column(name = "total_duration")
|
||||
private Duration totalDuration;
|
||||
|
||||
@Column(name = "elevation_gain", precision = 8, scale = 2)
|
||||
private BigDecimal elevationGain;
|
||||
|
||||
@Column(name = "elevation_loss", precision = 8, scale = 2)
|
||||
private BigDecimal elevationLoss;
|
||||
|
||||
/**
|
||||
* Original FIT file for re-processing if needed.
|
||||
* Allows us to re-parse with updated algorithms.
|
||||
*/
|
||||
@Column(name = "raw_fit_file")
|
||||
@Lob
|
||||
private byte[] rawFitFile;
|
||||
|
||||
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private ActivityMetrics metrics;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Helper method to set metrics for this activity
|
||||
*/
|
||||
public void setMetrics(ActivityMetrics metrics) {
|
||||
this.metrics = metrics;
|
||||
if (metrics != null) {
|
||||
metrics.setActivity(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity types supported by the platform
|
||||
*/
|
||||
public enum ActivityType {
|
||||
RUN,
|
||||
RIDE,
|
||||
HIKE,
|
||||
WALK,
|
||||
SWIM,
|
||||
ALPINE_SKI,
|
||||
BACKCOUNTRY_SKI,
|
||||
NORDIC_SKI,
|
||||
SNOWBOARD,
|
||||
ROWING,
|
||||
KAYAKING,
|
||||
CANOEING,
|
||||
INLINE_SKATING,
|
||||
ROCK_CLIMBING,
|
||||
MOUNTAINEERING,
|
||||
YOGA,
|
||||
WORKOUT,
|
||||
OTHER
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility levels for activities
|
||||
*/
|
||||
public enum Visibility {
|
||||
PUBLIC,
|
||||
FOLLOWERS,
|
||||
PRIVATE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity storing calculated metrics and statistics for an activity.
|
||||
* Includes average/max values, pace information, and other derived data.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "activity_metrics")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ActivityMetrics {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@OneToOne
|
||||
@JoinColumn(name = "activity_id", nullable = false)
|
||||
private Activity activity;
|
||||
|
||||
@Column(name = "average_speed", precision = 8, scale = 2)
|
||||
private BigDecimal averageSpeed;
|
||||
|
||||
@Column(name = "max_speed", precision = 8, scale = 2)
|
||||
private BigDecimal maxSpeed;
|
||||
|
||||
@Column(name = "average_pace")
|
||||
private Duration averagePace;
|
||||
|
||||
@Column(name = "average_heart_rate")
|
||||
private Integer averageHeartRate;
|
||||
|
||||
@Column(name = "max_heart_rate")
|
||||
private Integer maxHeartRate;
|
||||
|
||||
@Column(name = "average_cadence")
|
||||
private Integer averageCadence;
|
||||
|
||||
@Column(name = "max_cadence")
|
||||
private Integer maxCadence;
|
||||
|
||||
@Column(name = "average_power")
|
||||
private Integer averagePower;
|
||||
|
||||
@Column(name = "max_power")
|
||||
private Integer maxPower;
|
||||
|
||||
@Column(name = "normalized_power")
|
||||
private Integer normalizedPower;
|
||||
|
||||
@Column(name = "calories")
|
||||
private Integer calories;
|
||||
|
||||
@Column(name = "average_temperature", precision = 5, scale = 2)
|
||||
private BigDecimal averageTemperature;
|
||||
|
||||
@Column(name = "max_elevation", precision = 8, scale = 2)
|
||||
private BigDecimal maxElevation;
|
||||
|
||||
@Column(name = "min_elevation", precision = 8, scale = 2)
|
||||
private BigDecimal minElevation;
|
||||
|
||||
@Column(name = "total_ascent", precision = 8, scale = 2)
|
||||
private BigDecimal totalAscent;
|
||||
|
||||
@Column(name = "total_descent", precision = 8, scale = 2)
|
||||
private BigDecimal totalDescent;
|
||||
|
||||
@Column(name = "moving_time")
|
||||
private Duration movingTime;
|
||||
|
||||
@Column(name = "stopped_time")
|
||||
private Duration stoppedTime;
|
||||
|
||||
@Column(name = "total_steps")
|
||||
private Integer totalSteps;
|
||||
|
||||
@Column(name = "training_stress_score")
|
||||
private BigDecimal trainingStressScore;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository for ActivityMetrics entities.
|
||||
*/
|
||||
@Repository
|
||||
public interface ActivityMetricsRepository extends JpaRepository<ActivityMetrics, UUID> {
|
||||
|
||||
/**
|
||||
* Find metrics for a specific activity.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
* @return optional metrics
|
||||
*/
|
||||
@Query("SELECT am FROM ActivityMetrics am WHERE am.activity.id = :activityId")
|
||||
Optional<ActivityMetrics> findByActivityId(@Param("activityId") UUID activityId);
|
||||
|
||||
/**
|
||||
* Delete metrics for a specific activity.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
*/
|
||||
@Query("DELETE FROM ActivityMetrics am WHERE am.activity.id = :activityId")
|
||||
void deleteByActivityId(@Param("activityId") UUID activityId);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository for Activity entities.
|
||||
*/
|
||||
@Repository
|
||||
public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||
|
||||
/**
|
||||
* Find all activities for a specific user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdOrderByStartedAtDesc(UUID userId);
|
||||
|
||||
/**
|
||||
* Find all activities for a user within a date range.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param startDate the start date
|
||||
* @param endDate the end date
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
|
||||
UUID userId,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate
|
||||
);
|
||||
|
||||
/**
|
||||
* Find all public activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdAndVisibilityOrderByStartedAtDesc(
|
||||
UUID userId,
|
||||
Activity.Visibility visibility
|
||||
);
|
||||
|
||||
/**
|
||||
* Find activities by type for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param activityType the activity type
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdAndActivityTypeOrderByStartedAtDesc(
|
||||
UUID userId,
|
||||
Activity.ActivityType activityType
|
||||
);
|
||||
|
||||
/**
|
||||
* Count activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return count of activities
|
||||
*/
|
||||
long countByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Find an activity by ID and user ID.
|
||||
*
|
||||
* @param id the activity ID
|
||||
* @param userId the user ID
|
||||
* @return optional activity
|
||||
*/
|
||||
Optional<Activity> findByIdAndUserId(UUID id, UUID userId);
|
||||
|
||||
/**
|
||||
* Delete all activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
*/
|
||||
void deleteByUserId(UUID userId);
|
||||
}
|
||||
349
src/main/java/org/operaton/fitpub/service/FitFileService.java
Normal file
349
src/main/java/org/operaton/fitpub/service/FitFileService.java
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
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.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
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.TrackSimplifier;
|
||||
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.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for processing FIT files and creating activities.
|
||||
* Uses JSONB for track points and simplified LineString for map rendering.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FitFileService {
|
||||
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
private final FitFileValidator validator;
|
||||
private final FitParser parser;
|
||||
private final TrackSimplifier trackSimplifier;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ActivityMetricsRepository metricsRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Processes an uploaded FIT file and creates an activity.
|
||||
*
|
||||
* @param file the uploaded FIT 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 processing fails
|
||||
*/
|
||||
@Transactional
|
||||
public Activity processFitFile(
|
||||
MultipartFile file,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility
|
||||
) {
|
||||
try {
|
||||
// Validate file
|
||||
log.info("Processing FIT file: {}, size: {} bytes", file.getOriginalFilename(), file.getSize());
|
||||
validator.validate(file.getInputStream(), file.getSize());
|
||||
|
||||
// Parse FIT file
|
||||
byte[] fileData = file.getBytes();
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
|
||||
// Create activity entity
|
||||
Activity activity = createActivity(parsedData, userId, title, description, visibility, fileData);
|
||||
|
||||
// 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
|
||||
Coordinate[] coordinates = fullTrack.getCoordinates();
|
||||
LineString simplifiedTrack = trackSimplifier.simplify(coordinates);
|
||||
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)",
|
||||
savedActivity.getId(),
|
||||
parsedData.getTrackPoints().size(),
|
||||
simplifiedTrack.getNumPoints());
|
||||
|
||||
return savedActivity;
|
||||
} catch (IOException e) {
|
||||
throw new FitFileProcessingException("Failed to read FIT file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes FIT file data directly (for testing or non-upload scenarios).
|
||||
*
|
||||
* @param fileData the FIT file bytes
|
||||
* @param userId the user ID
|
||||
* @param visibility visibility level
|
||||
* @return the created activity
|
||||
*/
|
||||
@Transactional
|
||||
public Activity processFitFile(byte[] fileData, UUID userId, Activity.Visibility visibility) {
|
||||
validator.validate(fileData);
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
return createActivityFromParsedData(parsedData, userId, null, null, visibility, fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an activity entity from parsed FIT data.
|
||||
*/
|
||||
private Activity createActivity(
|
||||
FitParser.ParsedFitData parsedData,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility,
|
||||
byte[] rawFile
|
||||
) {
|
||||
String activityTitle = title != null && !title.isBlank()
|
||||
? title
|
||||
: generateTitle(parsedData);
|
||||
|
||||
return Activity.builder()
|
||||
.userId(userId)
|
||||
.activityType(parsedData.getActivityType())
|
||||
.title(activityTitle)
|
||||
.description(description)
|
||||
.startedAt(parsedData.getStartTime())
|
||||
.endedAt(parsedData.getEndTime())
|
||||
.visibility(visibility)
|
||||
.totalDistance(parsedData.getTotalDistance())
|
||||
.totalDuration(parsedData.getTotalDuration())
|
||||
.elevationGain(parsedData.getElevationGain())
|
||||
.elevationLoss(parsedData.getElevationLoss())
|
||||
.rawFitFile(rawFile)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an activity from parsed data (internal method).
|
||||
*/
|
||||
private Activity createActivityFromParsedData(
|
||||
FitParser.ParsedFitData parsedData,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility,
|
||||
byte[] rawFile
|
||||
) {
|
||||
Activity activity = createActivity(parsedData, userId, title, description, visibility, rawFile);
|
||||
|
||||
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
|
||||
activity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
|
||||
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
|
||||
activity.setSimplifiedTrack(simplifiedTrack);
|
||||
|
||||
if (parsedData.getMetrics() != null) {
|
||||
ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity);
|
||||
calculateAdditionalMetrics(metrics, parsedData.getTrackPoints());
|
||||
activity.setMetrics(metrics);
|
||||
}
|
||||
|
||||
return activityRepository.save(activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts track points to JSON string for JSONB storage.
|
||||
*/
|
||||
private String convertTrackPointsToJson(List<FitParser.TrackPointData> trackPoints) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(trackPoints);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new FitFileProcessingException("Failed to serialize track points to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PostGIS LineString from track points.
|
||||
*/
|
||||
private LineString createLineStringFromTrackPoints(List<FitParser.TrackPointData> trackPoints) {
|
||||
Coordinate[] coordinates = trackPoints.stream()
|
||||
.map(tp -> new Coordinate(tp.getLongitude(), tp.getLatitude()))
|
||||
.toArray(Coordinate[]::new);
|
||||
|
||||
return GEOMETRY_FACTORY.createLineString(coordinates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default title for an activity.
|
||||
*/
|
||||
private String generateTitle(FitParser.ParsedFitData parsedData) {
|
||||
String activityType = formatActivityType(parsedData.getActivityType());
|
||||
String date = parsedData.getStartTime().toLocalDate().toString();
|
||||
return String.format("%s - %s", activityType, date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats activity type for display.
|
||||
*/
|
||||
private String formatActivityType(Activity.ActivityType type) {
|
||||
switch (type) {
|
||||
case RUN:
|
||||
return "Run";
|
||||
case RIDE:
|
||||
return "Ride";
|
||||
case HIKE:
|
||||
return "Hike";
|
||||
case WALK:
|
||||
return "Walk";
|
||||
case SWIM:
|
||||
return "Swim";
|
||||
case ALPINE_SKI:
|
||||
return "Alpine Ski";
|
||||
case BACKCOUNTRY_SKI:
|
||||
return "Backcountry Ski";
|
||||
case NORDIC_SKI:
|
||||
return "Nordic Ski";
|
||||
case SNOWBOARD:
|
||||
return "Snowboard";
|
||||
case ROWING:
|
||||
return "Rowing";
|
||||
case KAYAKING:
|
||||
return "Kayaking";
|
||||
case CANOEING:
|
||||
return "Canoeing";
|
||||
case INLINE_SKATING:
|
||||
return "Inline Skating";
|
||||
case ROCK_CLIMBING:
|
||||
return "Rock Climbing";
|
||||
case MOUNTAINEERING:
|
||||
return "Mountaineering";
|
||||
case YOGA:
|
||||
return "Yoga";
|
||||
case WORKOUT:
|
||||
return "Workout";
|
||||
default:
|
||||
return "Activity";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates additional metrics not provided by the FIT file.
|
||||
*/
|
||||
private void calculateAdditionalMetrics(
|
||||
ActivityMetrics metrics,
|
||||
List<FitParser.TrackPointData> trackPoints
|
||||
) {
|
||||
if (trackPoints.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate min/max elevation
|
||||
BigDecimal minElevation = null;
|
||||
BigDecimal maxElevation = null;
|
||||
|
||||
for (FitParser.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics.setMinElevation(minElevation);
|
||||
metrics.setMaxElevation(maxElevation);
|
||||
|
||||
// Calculate average temperature
|
||||
BigDecimal tempSum = BigDecimal.ZERO;
|
||||
int tempCount = 0;
|
||||
|
||||
for (FitParser.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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an activity and all associated data.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
* @param userId the user ID (for authorization)
|
||||
* @return true if deleted, false if not found or unauthorized
|
||||
*/
|
||||
@Transactional
|
||||
public boolean deleteActivity(UUID activityId, UUID userId) {
|
||||
return activityRepository.findByIdAndUserId(activityId, userId)
|
||||
.map(activity -> {
|
||||
activityRepository.delete(activity);
|
||||
log.info("Deleted activity {} for user {}", activityId, userId);
|
||||
return true;
|
||||
})
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an activity by ID.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
* @param userId the user ID (for authorization)
|
||||
* @return the activity or null if not found
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Activity getActivity(UUID activityId, UUID userId) {
|
||||
return activityRepository.findByIdAndUserId(activityId, userId).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of activities
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Activity> getUserActivities(UUID userId) {
|
||||
return activityRepository.findByUserIdOrderByStartedAtDesc(userId);
|
||||
}
|
||||
}
|
||||
137
src/main/java/org/operaton/fitpub/util/FitFileValidator.java
Normal file
137
src/main/java/org/operaton/fitpub/util/FitFileValidator.java
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.exception.InvalidFitFileException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Validates FIT files before processing.
|
||||
* Checks file size, header, and basic integrity.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FitFileValidator {
|
||||
|
||||
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
private static final int MIN_FILE_SIZE = 14; // Minimum FIT file header size
|
||||
private static final byte[] FIT_HEADER_SIGNATURE = {'.', 'F', 'I', 'T'};
|
||||
private static final int HEADER_SIZE_OFFSET = 0;
|
||||
private static final int PROTOCOL_VERSION_OFFSET = 1;
|
||||
private static final int SIGNATURE_OFFSET = 8;
|
||||
|
||||
/**
|
||||
* Validates a FIT file from byte array.
|
||||
*
|
||||
* @param fileData the FIT file data
|
||||
* @throws InvalidFitFileException if the file is invalid
|
||||
*/
|
||||
public void validate(byte[] fileData) {
|
||||
if (fileData == null || fileData.length == 0) {
|
||||
throw new InvalidFitFileException("FIT file is empty");
|
||||
}
|
||||
|
||||
validateFileSize(fileData.length);
|
||||
validateFitHeader(fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a FIT file from input stream.
|
||||
*
|
||||
* @param inputStream the input stream
|
||||
* @param contentLength the content length
|
||||
* @throws InvalidFitFileException if the file is invalid
|
||||
*/
|
||||
public void validate(InputStream inputStream, long contentLength) throws IOException {
|
||||
validateFileSize(contentLength);
|
||||
|
||||
byte[] header = new byte[14];
|
||||
int bytesRead = inputStream.read(header);
|
||||
|
||||
if (bytesRead < MIN_FILE_SIZE) {
|
||||
throw new InvalidFitFileException("FIT file is too small. Minimum size is " + MIN_FILE_SIZE + " bytes");
|
||||
}
|
||||
|
||||
validateFitHeader(header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the file size.
|
||||
*
|
||||
* @param size the file size in bytes
|
||||
* @throws InvalidFitFileException if the size is invalid
|
||||
*/
|
||||
private void validateFileSize(long size) {
|
||||
if (size < MIN_FILE_SIZE) {
|
||||
throw new InvalidFitFileException(
|
||||
String.format("FIT file is too small. Size: %d bytes, minimum: %d bytes", size, MIN_FILE_SIZE)
|
||||
);
|
||||
}
|
||||
|
||||
if (size > MAX_FILE_SIZE) {
|
||||
throw new InvalidFitFileException(
|
||||
String.format("FIT file is too large. Size: %d bytes, maximum: %d bytes", size, MAX_FILE_SIZE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the FIT file header.
|
||||
*
|
||||
* @param data the file data (at least first 14 bytes)
|
||||
* @throws InvalidFitFileException if the header is invalid
|
||||
*/
|
||||
private void validateFitHeader(byte[] data) {
|
||||
if (data.length < MIN_FILE_SIZE) {
|
||||
throw new InvalidFitFileException("Insufficient data to validate FIT header");
|
||||
}
|
||||
|
||||
// Check header size
|
||||
int headerSize = data[HEADER_SIZE_OFFSET] & 0xFF;
|
||||
if (headerSize != 12 && headerSize != 14) {
|
||||
throw new InvalidFitFileException(
|
||||
String.format("Invalid FIT header size: %d. Expected 12 or 14", headerSize)
|
||||
);
|
||||
}
|
||||
|
||||
// Check protocol version
|
||||
int protocolVersion = data[PROTOCOL_VERSION_OFFSET] & 0xFF;
|
||||
int majorVersion = protocolVersion >> 4;
|
||||
if (majorVersion == 0 || majorVersion > 20) {
|
||||
log.warn("Unusual FIT protocol version: {}.{}", majorVersion, protocolVersion & 0x0F);
|
||||
}
|
||||
|
||||
// Check signature
|
||||
boolean signatureValid = true;
|
||||
for (int i = 0; i < FIT_HEADER_SIGNATURE.length; i++) {
|
||||
if (data[SIGNATURE_OFFSET + i] != FIT_HEADER_SIGNATURE[i]) {
|
||||
signatureValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
throw new InvalidFitFileException(
|
||||
"Invalid FIT file signature. Expected '.FIT' at offset " + SIGNATURE_OFFSET
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("FIT file header validated successfully. Header size: {}, Protocol version: {}.{}",
|
||||
headerSize, majorVersion, protocolVersion & 0x0F);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file appears to be a valid FIT file based on extension.
|
||||
*
|
||||
* @param filename the filename
|
||||
* @return true if the filename has a .fit extension
|
||||
*/
|
||||
public boolean hasValidExtension(String filename) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return filename.toLowerCase().endsWith(".fit");
|
||||
}
|
||||
}
|
||||
401
src/main/java/org/operaton/fitpub/util/FitParser.java
Normal file
401
src/main/java/org/operaton/fitpub/util/FitParser.java
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import com.garmin.fit.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.exception.FitFileProcessingException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Parser for Garmin FIT files.
|
||||
* Extracts GPS coordinates, activity metrics, and sensor data.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FitParser {
|
||||
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31);
|
||||
private static final double MPS_TO_KPH = 3.6;
|
||||
|
||||
/**
|
||||
* Parses a FIT file and returns the extracted data.
|
||||
*
|
||||
* @param fileData the FIT file data
|
||||
* @return ParsedFitData containing activity information
|
||||
* @throws FitFileProcessingException if parsing fails
|
||||
*/
|
||||
public ParsedFitData parse(byte[] fileData) {
|
||||
try (InputStream inputStream = new ByteArrayInputStream(fileData)) {
|
||||
return parse(inputStream);
|
||||
} catch (Exception e) {
|
||||
throw new FitFileProcessingException("Failed to parse FIT file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a FIT file from an input stream.
|
||||
*
|
||||
* @param inputStream the input stream
|
||||
* @return ParsedFitData containing activity information
|
||||
* @throws FitFileProcessingException if parsing fails
|
||||
*/
|
||||
public ParsedFitData parse(InputStream inputStream) {
|
||||
ParsedFitData parsedData = new ParsedFitData();
|
||||
Decode decode = new Decode();
|
||||
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
|
||||
|
||||
// Listen for record messages (GPS points)
|
||||
broadcaster.addListener((RecordMesgListener) record -> {
|
||||
TrackPointData trackPoint = extractTrackPoint(record);
|
||||
if (trackPoint != null) {
|
||||
parsedData.getTrackPoints().add(trackPoint);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for session messages (summary data)
|
||||
broadcaster.addListener((SessionMesgListener) session -> {
|
||||
extractSessionData(session, parsedData);
|
||||
});
|
||||
|
||||
// Listen for activity messages
|
||||
broadcaster.addListener((ActivityMesgListener) activity -> {
|
||||
extractActivityData(activity, parsedData);
|
||||
});
|
||||
|
||||
// Listen for lap messages
|
||||
broadcaster.addListener((LapMesgListener) lap -> {
|
||||
log.debug("Lap data: distance={}, time={}", lap.getTotalDistance(), lap.getTotalTimerTime());
|
||||
});
|
||||
|
||||
try {
|
||||
if (!decode.read(inputStream, broadcaster)) {
|
||||
throw new FitFileProcessingException("Failed to decode FIT file");
|
||||
}
|
||||
|
||||
if (parsedData.getTrackPoints().isEmpty()) {
|
||||
throw new FitFileProcessingException("No GPS track points found in FIT file");
|
||||
}
|
||||
|
||||
log.info("Successfully parsed FIT file: {} track points, activity type: {}",
|
||||
parsedData.getTrackPoints().size(), parsedData.getActivityType());
|
||||
|
||||
return parsedData;
|
||||
} catch (FitRuntimeException e) {
|
||||
throw new FitFileProcessingException("Error decoding FIT file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a track point from a record message.
|
||||
*/
|
||||
private TrackPointData extractTrackPoint(RecordMesg record) {
|
||||
Integer positionLat = record.getPositionLat();
|
||||
Integer positionLong = record.getPositionLong();
|
||||
|
||||
if (positionLat == null || positionLong == null) {
|
||||
return null; // Skip points without GPS coordinates
|
||||
}
|
||||
|
||||
TrackPointData point = new TrackPointData();
|
||||
|
||||
// Convert semicircles to degrees
|
||||
double latitude = positionLat * SEMICIRCLES_TO_DEGREES;
|
||||
double longitude = positionLong * SEMICIRCLES_TO_DEGREES;
|
||||
|
||||
point.setLatitude(latitude);
|
||||
point.setLongitude(longitude);
|
||||
|
||||
// Extract timestamp
|
||||
if (record.getTimestamp() != null) {
|
||||
point.setTimestamp(convertDateTime(record.getTimestamp()));
|
||||
}
|
||||
|
||||
// Extract elevation
|
||||
if (record.getAltitude() != null) {
|
||||
point.setElevation(BigDecimal.valueOf(record.getAltitude()).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract heart rate
|
||||
if (record.getHeartRate() != null) {
|
||||
point.setHeartRate(record.getHeartRate().intValue());
|
||||
}
|
||||
|
||||
// Extract cadence
|
||||
if (record.getCadence() != null) {
|
||||
point.setCadence(record.getCadence().intValue());
|
||||
}
|
||||
|
||||
// Extract power
|
||||
if (record.getPower() != null) {
|
||||
point.setPower(record.getPower());
|
||||
}
|
||||
|
||||
// Extract speed (convert m/s to km/h)
|
||||
if (record.getSpeed() != null) {
|
||||
point.setSpeed(BigDecimal.valueOf(record.getSpeed() * MPS_TO_KPH)
|
||||
.setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract distance
|
||||
if (record.getDistance() != null) {
|
||||
point.setDistance(BigDecimal.valueOf(record.getDistance()).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract temperature
|
||||
if (record.getTemperature() != null) {
|
||||
point.setTemperature(BigDecimal.valueOf(record.getTemperature()).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session data from a session message.
|
||||
*/
|
||||
private void extractSessionData(SessionMesg session, ParsedFitData parsedData) {
|
||||
if (session.getStartTime() != null) {
|
||||
parsedData.setStartTime(convertDateTime(session.getStartTime()));
|
||||
}
|
||||
|
||||
if (session.getTotalElapsedTime() != null) {
|
||||
long totalSeconds = session.getTotalElapsedTime().longValue();
|
||||
parsedData.setTotalDuration(Duration.ofSeconds(totalSeconds));
|
||||
|
||||
if (parsedData.getStartTime() != null) {
|
||||
parsedData.setEndTime(parsedData.getStartTime().plus(Duration.ofSeconds(totalSeconds)));
|
||||
}
|
||||
}
|
||||
|
||||
if (session.getTotalDistance() != null) {
|
||||
parsedData.setTotalDistance(
|
||||
BigDecimal.valueOf(session.getTotalDistance()).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getTotalAscent() != null) {
|
||||
parsedData.setElevationGain(
|
||||
BigDecimal.valueOf(session.getTotalAscent()).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getTotalDescent() != null) {
|
||||
parsedData.setElevationLoss(
|
||||
BigDecimal.valueOf(session.getTotalDescent()).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract metrics
|
||||
ActivityMetricsData metrics = new ActivityMetricsData();
|
||||
|
||||
if (session.getAvgSpeed() != null) {
|
||||
metrics.setAverageSpeed(
|
||||
BigDecimal.valueOf(session.getAvgSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getMaxSpeed() != null) {
|
||||
metrics.setMaxSpeed(
|
||||
BigDecimal.valueOf(session.getMaxSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getAvgHeartRate() != null) {
|
||||
metrics.setAverageHeartRate(session.getAvgHeartRate().intValue());
|
||||
}
|
||||
|
||||
if (session.getMaxHeartRate() != null) {
|
||||
metrics.setMaxHeartRate(session.getMaxHeartRate().intValue());
|
||||
}
|
||||
|
||||
if (session.getAvgCadence() != null) {
|
||||
metrics.setAverageCadence(session.getAvgCadence().intValue());
|
||||
}
|
||||
|
||||
if (session.getMaxCadence() != null) {
|
||||
metrics.setMaxCadence(session.getMaxCadence().intValue());
|
||||
}
|
||||
|
||||
if (session.getAvgPower() != null) {
|
||||
metrics.setAveragePower(session.getAvgPower());
|
||||
}
|
||||
|
||||
if (session.getMaxPower() != null) {
|
||||
metrics.setMaxPower(session.getMaxPower());
|
||||
}
|
||||
|
||||
if (session.getNormalizedPower() != null) {
|
||||
metrics.setNormalizedPower(session.getNormalizedPower());
|
||||
}
|
||||
|
||||
if (session.getTotalCalories() != null) {
|
||||
metrics.setCalories(session.getTotalCalories());
|
||||
}
|
||||
|
||||
if (session.getTotalMovingTime() != null) {
|
||||
metrics.setMovingTime(Duration.ofSeconds(session.getTotalMovingTime().longValue()));
|
||||
}
|
||||
|
||||
if (session.getTotalStrides() != null) {
|
||||
metrics.setTotalSteps(session.getTotalStrides().intValue() * 2); // Strides to steps
|
||||
}
|
||||
|
||||
parsedData.setMetrics(metrics);
|
||||
|
||||
// Determine activity type
|
||||
if (session.getSport() != null) {
|
||||
parsedData.setActivityType(mapSportToActivityType(session.getSport()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts activity data from an activity message.
|
||||
*/
|
||||
private void extractActivityData(ActivityMesg activity, ParsedFitData parsedData) {
|
||||
if (activity.getTimestamp() != null) {
|
||||
parsedData.setActivityTimestamp(convertDateTime(activity.getTimestamp()));
|
||||
}
|
||||
|
||||
if (activity.getTotalTimerTime() != null) {
|
||||
log.debug("Activity total timer time: {}", activity.getTotalTimerTime());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts FIT DateTime to LocalDateTime.
|
||||
*/
|
||||
private LocalDateTime convertDateTime(DateTime dateTime) {
|
||||
long timestamp = dateTime.getTimestamp();
|
||||
Instant instant = Instant.ofEpochSecond(timestamp);
|
||||
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps FIT sport type to our activity type.
|
||||
*/
|
||||
private Activity.ActivityType mapSportToActivityType(Sport sport) {
|
||||
if (sport == Sport.RUNNING) {
|
||||
return Activity.ActivityType.RUN;
|
||||
} else if (sport == Sport.CYCLING) {
|
||||
return Activity.ActivityType.RIDE;
|
||||
} else if (sport == Sport.HIKING) {
|
||||
return Activity.ActivityType.HIKE;
|
||||
} else if (sport == Sport.WALKING) {
|
||||
return Activity.ActivityType.WALK;
|
||||
} else if (sport == Sport.SWIMMING) {
|
||||
return Activity.ActivityType.SWIM;
|
||||
} else if (sport == Sport.ROWING) {
|
||||
return Activity.ActivityType.ROWING;
|
||||
} else {
|
||||
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)
|
||||
.averagePace(averagePace)
|
||||
.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)
|
||||
.movingTime(movingTime)
|
||||
.stoppedTime(stoppedTime)
|
||||
.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 BigDecimal totalDistance;
|
||||
private Duration totalDuration;
|
||||
private BigDecimal elevationGain;
|
||||
private BigDecimal elevationLoss;
|
||||
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
|
||||
private ActivityMetricsData metrics;
|
||||
}
|
||||
}
|
||||
182
src/main/java/org/operaton/fitpub/util/TrackSimplifier.java
Normal file
182
src/main/java/org/operaton/fitpub/util/TrackSimplifier.java
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
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.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Simplifies GPS tracks using the Douglas-Peucker algorithm.
|
||||
* Reduces the number of points while maintaining the overall shape of the track.
|
||||
* This is critical for scalability - we don't want to render 1000+ points on a map.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class TrackSimplifier {
|
||||
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
// Default epsilon: ~10 meters tolerance (in degrees, roughly 0.0001 degrees)
|
||||
private static final double DEFAULT_EPSILON = 0.0001;
|
||||
|
||||
// Target: 50-200 points for map rendering
|
||||
private static final int TARGET_POINTS_MIN = 50;
|
||||
private static final int TARGET_POINTS_MAX = 200;
|
||||
|
||||
/**
|
||||
* Simplifies a track to a target number of points.
|
||||
* Automatically adjusts epsilon to achieve the desired point count.
|
||||
*
|
||||
* @param coordinates original track coordinates
|
||||
* @return simplified LineString
|
||||
*/
|
||||
public LineString simplify(Coordinate[] coordinates) {
|
||||
if (coordinates == null || coordinates.length == 0) {
|
||||
return GEOMETRY_FACTORY.createLineString(new Coordinate[0]);
|
||||
}
|
||||
|
||||
if (coordinates.length <= TARGET_POINTS_MAX) {
|
||||
// Already small enough, no simplification needed
|
||||
log.debug("Track has {} points, no simplification needed", coordinates.length);
|
||||
return GEOMETRY_FACTORY.createLineString(coordinates);
|
||||
}
|
||||
|
||||
// Try to find epsilon that gives us TARGET_POINTS_MIN to TARGET_POINTS_MAX points
|
||||
double epsilon = DEFAULT_EPSILON;
|
||||
List<Coordinate> simplified = douglasPeucker(coordinates, epsilon);
|
||||
|
||||
// Adjust epsilon if needed
|
||||
int iterations = 0;
|
||||
while (simplified.size() > TARGET_POINTS_MAX && iterations < 10) {
|
||||
epsilon *= 1.5; // Increase tolerance
|
||||
simplified = douglasPeucker(coordinates, epsilon);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
while (simplified.size() < TARGET_POINTS_MIN && epsilon > 0.00001 && iterations < 10) {
|
||||
epsilon *= 0.7; // Decrease tolerance
|
||||
simplified = douglasPeucker(coordinates, epsilon);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
log.info("Simplified track from {} to {} points (epsilon: {})",
|
||||
coordinates.length, simplified.size(), epsilon);
|
||||
|
||||
return GEOMETRY_FACTORY.createLineString(simplified.toArray(new Coordinate[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Douglas-Peucker algorithm implementation.
|
||||
* Recursively removes points that deviate less than epsilon from the line.
|
||||
*
|
||||
* @param coordinates input coordinates
|
||||
* @param epsilon tolerance (maximum distance from line)
|
||||
* @return simplified list of coordinates
|
||||
*/
|
||||
private List<Coordinate> douglasPeucker(Coordinate[] coordinates, double epsilon) {
|
||||
if (coordinates.length < 3) {
|
||||
List<Coordinate> result = new ArrayList<>();
|
||||
for (Coordinate coord : coordinates) {
|
||||
result.add(coord);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find point with maximum distance from line between first and last point
|
||||
double maxDistance = 0;
|
||||
int maxIndex = 0;
|
||||
|
||||
Coordinate start = coordinates[0];
|
||||
Coordinate end = coordinates[coordinates.length - 1];
|
||||
|
||||
for (int i = 1; i < coordinates.length - 1; i++) {
|
||||
double distance = perpendicularDistance(coordinates[i], start, end);
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
maxIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
List<Coordinate> result = new ArrayList<>();
|
||||
|
||||
// If max distance is greater than epsilon, recursively simplify
|
||||
if (maxDistance > epsilon) {
|
||||
// Recursive call for first part
|
||||
Coordinate[] firstPart = new Coordinate[maxIndex + 1];
|
||||
System.arraycopy(coordinates, 0, firstPart, 0, maxIndex + 1);
|
||||
List<Coordinate> left = douglasPeucker(firstPart, epsilon);
|
||||
|
||||
// Recursive call for second part
|
||||
Coordinate[] secondPart = new Coordinate[coordinates.length - maxIndex];
|
||||
System.arraycopy(coordinates, maxIndex, secondPart, 0, coordinates.length - maxIndex);
|
||||
List<Coordinate> right = douglasPeucker(secondPart, epsilon);
|
||||
|
||||
// Combine results (remove duplicate point at junction)
|
||||
result.addAll(left.subList(0, left.size() - 1));
|
||||
result.addAll(right);
|
||||
} else {
|
||||
// All points can be removed except endpoints
|
||||
result.add(start);
|
||||
result.add(end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates perpendicular distance from a point to a line segment.
|
||||
*
|
||||
* @param point the point
|
||||
* @param lineStart start of line segment
|
||||
* @param lineEnd end of line segment
|
||||
* @return perpendicular distance
|
||||
*/
|
||||
private double perpendicularDistance(Coordinate point, Coordinate lineStart, Coordinate lineEnd) {
|
||||
double x = point.x;
|
||||
double y = point.y;
|
||||
double x1 = lineStart.x;
|
||||
double y1 = lineStart.y;
|
||||
double x2 = lineEnd.x;
|
||||
double y2 = lineEnd.y;
|
||||
|
||||
double A = x - x1;
|
||||
double B = y - y1;
|
||||
double C = x2 - x1;
|
||||
double D = y2 - y1;
|
||||
|
||||
double dot = A * C + B * D;
|
||||
double lenSq = C * C + D * D;
|
||||
|
||||
if (lenSq == 0) {
|
||||
// Line segment is actually a point
|
||||
return Math.sqrt(A * A + B * B);
|
||||
}
|
||||
|
||||
double param = dot / lenSq;
|
||||
|
||||
double xx, yy;
|
||||
|
||||
if (param < 0) {
|
||||
xx = x1;
|
||||
yy = y1;
|
||||
} else if (param > 1) {
|
||||
xx = x2;
|
||||
yy = y2;
|
||||
} else {
|
||||
xx = x1 + param * C;
|
||||
yy = y1 + param * D;
|
||||
}
|
||||
|
||||
double dx = x - xx;
|
||||
double dy = y - yy;
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue