diff --git a/src/main/java/net/javahippie/fitpub/controller/DebugController.java b/src/main/java/net/javahippie/fitpub/controller/DebugController.java index 3cb4a8b..5d66b3f 100644 --- a/src/main/java/net/javahippie/fitpub/controller/DebugController.java +++ b/src/main/java/net/javahippie/fitpub/controller/DebugController.java @@ -28,6 +28,8 @@ public class DebugController { private final UserRepository userRepository; private final net.javahippie.fitpub.service.PeakDetectionService peakDetectionService; + private final net.javahippie.fitpub.service.ActivityFileService activityFileService; + private final net.javahippie.fitpub.repository.ActivityRepository activityRepository; @GetMapping("/validate-keys") public Map validateKeys() { @@ -117,4 +119,49 @@ public class DebugController { peakDetectionService.backfillAllActivities(); return org.springframework.http.ResponseEntity.ok(Map.of("status", "started")); } + + private final java.util.concurrent.atomic.AtomicBoolean elevationReprocessRunning = + new java.util.concurrent.atomic.AtomicBoolean(false); + + @org.springframework.web.bind.annotation.PostMapping("/reprocess-gpx-elevation") + public org.springframework.http.ResponseEntity> reprocessGpxElevation() { + if (!elevationReprocessRunning.compareAndSet(false, true)) { + return org.springframework.http.ResponseEntity.ok(Map.of("status", "already running")); + } + + new Thread(() -> { + try { + var ids = activityRepository.findAllIds(); + log.info("Starting GPX elevation reprocessing for {} activities", ids.size()); + + int updated = 0, skipped = 0, failed = 0; + for (int i = 0; i < ids.size(); i++) { + try { + var activity = activityRepository.findById(ids.get(i)).orElse(null); + if (activity == null || !"GPX".equals(activity.getSourceFileFormat())) { + skipped++; + continue; + } + if (activityFileService.reprocessGpxElevation(activity)) { + updated++; + } else { + skipped++; + } + } catch (Exception e) { + failed++; + log.warn("Failed to reprocess elevation for {}: {}", ids.get(i), e.getMessage()); + } + if ((i + 1) % 100 == 0) { + log.info("GPX elevation reprocess progress: {} / {} (updated={}, skipped={}, failed={})", + i + 1, ids.size(), updated, skipped, failed); + } + } + log.info("GPX elevation reprocessing complete: updated={}, skipped={}, failed={}", updated, skipped, failed); + } finally { + elevationReprocessRunning.set(false); + } + }, "gpx-elevation-reprocess").start(); + + return org.springframework.http.ResponseEntity.ok(Map.of("status", "started")); + } } diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityPeakRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityPeakRepository.java index 978ec25..67605df 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityPeakRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityPeakRepository.java @@ -29,6 +29,7 @@ public interface ActivityPeakRepository extends JpaRepository { /** * Find all peaks within a given distance (meters) of an activity's simplified track. - * Uses ST_DWithin on geography type for accurate meter-based distance. + * Uses a two-stage approach: + * 1. ST_Expand + && for fast GIST index lookup on geometry + * 2. ST_DWithin on geography for precise meter-based filtering */ @Query(value = """ SELECT p.* FROM peaks p JOIN activities a ON a.id = :activityId WHERE a.simplified_track IS NOT NULL + AND p.geom && ST_Expand(a.simplified_track, 0.002) AND ST_DWithin(CAST(p.geom AS geography), CAST(a.simplified_track AS geography), :distanceMeters) ORDER BY p.name """, diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java index 98a5c7d..ac5e4b3 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java @@ -196,6 +196,42 @@ public class ActivityFileService { } } + /** + * Reprocess elevation for a GPX activity from its stored raw file. + * Only works for GPX files — FIT files use their session summary. + * + * @param activity the activity to reprocess + * @return true if elevation was updated + */ + @Transactional + public boolean reprocessGpxElevation(Activity activity) { + if (!"GPX".equals(activity.getSourceFileFormat())) { + return false; + } + + byte[] rawFile = activity.getRawActivityFile(); + if (rawFile == null || rawFile.length == 0) { + log.debug("No raw file stored for activity {}, skipping", activity.getId()); + return false; + } + + try { + ParsedActivityData parsedData = gpxParser.parse(rawFile); + + BigDecimal oldGain = activity.getElevationGain(); + activity.setElevationGain(parsedData.getElevationGain()); + activity.setElevationLoss(parsedData.getElevationLoss()); + activityRepository.save(activity); + + log.info("Reprocessed GPX elevation for activity {}: {}m -> {}m", + activity.getId(), oldGain, parsedData.getElevationGain()); + return true; + } catch (Exception e) { + log.warn("Failed to reprocess elevation for activity {}: {}", activity.getId(), e.getMessage()); + return false; + } + } + /** * Detects file format from content and filename. * Priority: magic bytes > XML header > file extension diff --git a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java index 1b66b08..4afff75 100644 --- a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java @@ -43,6 +43,7 @@ public class BatchImportService { private final BatchImportJobRepository batchImportJobRepository; private final BatchImportFileResultRepository batchImportFileResultRepository; private final ActivityFileService activityFileService; + private final PeakDetectionService peakDetectionService; private final ActivityRepository activityRepository; private final UserRepository userRepository; private final PersonalRecordService personalRecordService; @@ -63,6 +64,7 @@ public class BatchImportService { HeatmapGridService heatmapGridService, TrainingLoadService trainingLoadService, ActivitySummaryService activitySummaryService, + PeakDetectionService peakDetectionService, @org.springframework.context.annotation.Lazy BatchImportService self) { this.batchImportJobRepository = batchImportJobRepository; this.batchImportFileResultRepository = batchImportFileResultRepository; @@ -74,6 +76,7 @@ public class BatchImportService { this.heatmapGridService = heatmapGridService; this.trainingLoadService = trainingLoadService; this.activitySummaryService = activitySummaryService; + this.peakDetectionService = peakDetectionService; this.self = self; } @@ -258,6 +261,13 @@ public class BatchImportService { ActivityFileService.ProcessingOptions.batchImportMode() ); + // Detect peaks (now fast thanks to GIST index hit) + try { + peakDetectionService.detectPeaksForActivity(activity); + } catch (Exception e) { + log.warn("Peak detection failed for activity {}: {}", activity.getId(), e.getMessage()); + } + // Mark file as success fileResult.setStatus(BatchImportFileResult.FileStatus.SUCCESS); fileResult.setActivityId(activity.getId()); diff --git a/src/main/java/net/javahippie/fitpub/util/GpxParser.java b/src/main/java/net/javahippie/fitpub/util/GpxParser.java index 816d13a..d5461c0 100644 --- a/src/main/java/net/javahippie/fitpub/util/GpxParser.java +++ b/src/main/java/net/javahippie/fitpub/util/GpxParser.java @@ -38,7 +38,7 @@ 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 ELEVATION_NOISE_THRESHOLD = 0.0; // Sum all elevation changes (barometric data is smooth enough) 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 diff --git a/src/test/java/net/javahippie/fitpub/util/DatePersistenceTest.java b/src/test/java/net/javahippie/fitpub/util/DatePersistenceTest.java index 27f894e..c800ff9 100644 --- a/src/test/java/net/javahippie/fitpub/util/DatePersistenceTest.java +++ b/src/test/java/net/javahippie/fitpub/util/DatePersistenceTest.java @@ -125,15 +125,6 @@ class DatePersistenceTest { assertEquals(expectedEndTime, queriedActivity.getEndedAt(), "Queried end time should match original parsed value"); - // Also verify it's recent (within last 2 months) - LocalDateTime now = LocalDateTime.now(); - LocalDateTime twoMonthsAgo = now.minusMonths(2); - - assertTrue(queriedActivity.getStartedAt().isAfter(twoMonthsAgo), - String.format("Activity should be recent. Expected after %s, got %s", - twoMonthsAgo.format(FORMATTER), - queriedActivity.getStartedAt().format(FORMATTER))); - log.info(""); log.info("✅ FIT file date persistence: PASSED"); log.info(" Expected: {}", expectedStartTime); diff --git a/src/test/java/net/javahippie/fitpub/util/FitElevationAnalysis.java b/src/test/java/net/javahippie/fitpub/util/FitElevationAnalysis.java new file mode 100644 index 0000000..1edba25 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/util/FitElevationAnalysis.java @@ -0,0 +1,111 @@ +package net.javahippie.fitpub.util; + +import com.garmin.fit.*; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** + * Standalone analysis of a FIT file's elevation data. + * Compares session summary vs track point calculation. + */ +public class FitElevationAnalysis { + + @org.junit.jupiter.api.Test + public void analyzeElevation() { + analyze(new String[]{}); + } + + public static void analyze(String[] args) { + try (InputStream is = FitElevationAnalysis.class.getResourceAsStream("/69287079d5e0a4532ba818ee.fit")) { + if (is == null) { + System.out.println("File not found on classpath"); + return; + } + + Decode decode = new Decode(); + MesgBroadcaster broadcaster = new MesgBroadcaster(decode); + + // Collect session data + final Integer[] sessionAscent = {null}; + final Integer[] sessionDescent = {null}; + + // Collect track point elevations + List elevations = new ArrayList<>(); + + broadcaster.addListener((SessionMesgListener) session -> { + sessionAscent[0] = session.getTotalAscent(); + sessionDescent[0] = session.getTotalDescent(); + System.out.println("=== SESSION DATA ==="); + System.out.println("Total Ascent (raw): " + session.getTotalAscent() + " (type: " + + (session.getTotalAscent() != null ? session.getTotalAscent().getClass().getSimpleName() : "null") + ")"); + System.out.println("Total Descent (raw): " + session.getTotalDescent() + " (type: " + + (session.getTotalDescent() != null ? session.getTotalDescent().getClass().getSimpleName() : "null") + ")"); + System.out.println("Sport: " + session.getSport()); + System.out.println("Sub Sport: " + session.getSubSport()); + }); + + broadcaster.addListener((RecordMesgListener) record -> { + if (record.getAltitude() != null) { + elevations.add(record.getAltitude().doubleValue()); + } + }); + + decode.read(is, broadcaster); + + System.out.println("\n=== TRACK POINT ELEVATION ANALYSIS ==="); + System.out.println("Total track points with elevation: " + elevations.size()); + + if (elevations.isEmpty()) { + System.out.println("No elevation data in track points!"); + return; + } + + System.out.println("First elevation: " + elevations.get(0) + "m"); + System.out.println("Last elevation: " + elevations.get(elevations.size() - 1) + "m"); + + // Find min/max + double min = elevations.stream().mapToDouble(d -> d).min().orElse(0); + double max = elevations.stream().mapToDouble(d -> d).max().orElse(0); + System.out.println("Min elevation: " + min + "m"); + System.out.println("Max elevation: " + max + "m"); + + // Calculate gain/loss with different thresholds + for (double threshold : new double[]{0, 1.0, 2.0, 3.0, 5.0}) { + double gain = 0, loss = 0; + for (int i = 1; i < elevations.size(); i++) { + double delta = elevations.get(i) - elevations.get(i - 1); + if (Math.abs(delta) > threshold) { + if (delta > 0) gain += delta; + else loss += Math.abs(delta); + } + } + System.out.printf("Threshold %.1fm: gain=%.1fm, loss=%.1fm%n", threshold, gain, loss); + } + + // Show first 20 elevation deltas to check for anomalies + System.out.println("\n=== FIRST 20 ELEVATION CHANGES ==="); + for (int i = 1; i < Math.min(21, elevations.size()); i++) { + double delta = elevations.get(i) - elevations.get(i - 1); + System.out.printf(" Point %d: %.2fm -> %.2fm (delta: %+.2fm)%n", + i, elevations.get(i - 1), elevations.get(i), delta); + } + + System.out.println("\n=== COMPARISON ==="); + System.out.println("Session ascent: " + sessionAscent[0] + "m"); + System.out.println("Session descent: " + sessionDescent[0] + "m"); + double gain2m = 0; + for (int i = 1; i < elevations.size(); i++) { + double delta = elevations.get(i) - elevations.get(i - 1); + if (delta > 2.0) gain2m += delta; + } + System.out.printf("Calculated gain (2m threshold): %.1fm%n", gain2m); + + } catch (Exception e) { + e.printStackTrace(); + } + } +}