tripmergeservice does not recognize consecutive trips (#44)

* reworked the whole processing pipeline
* processing starts every 5 minutes with the location points which are not already processed
* added geojson support
This commit is contained in:
Daniel Graf
2025-06-12 15:41:26 +02:00
committed by GitHub
parent 60bcf6e5ef
commit 7fc502b220
76 changed files with 4292 additions and 1635 deletions

View File

@@ -9,9 +9,6 @@
name: Branch build
on:
push:
branches-ignore:
- main
pull_request:
branches:
- main
@@ -27,7 +24,7 @@ jobs:
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn --batch-mode --update-snapshots verify
run: mvn verify
- name: Create bundle
run: mkdir staging && cp target/*.jar staging
- name: Upload packages

View File

@@ -22,7 +22,7 @@ jobs:
distribution: 'temurin'
cache: maven
- name: Build
run: mvn --batch-mode verify
run: mvn verify
- name: Create bundle
run: mkdir staging && cp target/*.jar staging
- name: Upload packages

View File

@@ -110,6 +110,12 @@
<artifactId>rabbitmq</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>de.siegmar</groupId>
<artifactId>fastcsv</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>

View File

@@ -0,0 +1,14 @@
package com.dedicatedcode.reitti.config;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.PrecisionModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean
public GeometryFactory geometryFactory() {
return new GeometryFactory(new PrecisionModel(), 4326);
}
}

View File

@@ -17,15 +17,14 @@ public class RabbitMQConfig {
public static final String EXCHANGE_NAME = "reitti-exchange";
public static final String LOCATION_DATA_QUEUE = "location-data-queue";
public static final String LOCATION_DATA_ROUTING_KEY = "location.data";
public static final String STAY_DETECTION_QUEUE = "stay-detection-queue";
public static final String STAY_DETECTION_ROUTING_KEY = "stay.detection.created";
public static final String MERGE_VISIT_QUEUE = "merge-visit-queue";
public static final String MERGE_VISIT_ROUTING_KEY = "merge.visit.created";
public static final String SIGNIFICANT_PLACE_QUEUE = "significant-place-queue";
public static final String SIGNIFICANT_PLACE_ROUTING_KEY = "significant.place.created";
public static final String DETECT_TRIP_QUEUE = "detect-trip-queue";
public static final String DETECT_TRIP_ROUTING_KEY = "detect.trip.created";
public static final String MERGE_TRIP_QUEUE = "merge-trip-queue";
public static final String MERGE_TRIP_ROUTING_KEY = "merge.trip.created";
public static final String MERGE_VISIT_QUEUE = "merge-visit-queue";
public static final String MERGE_VISIT_ROUTING_KEY = "merge.visit.created";
@Bean
public TopicExchange exchange() {
@@ -42,11 +41,6 @@ public class RabbitMQConfig {
return new Queue(DETECT_TRIP_QUEUE, true);
}
@Bean
public Queue mergeTripQueue() {
return new Queue(MERGE_TRIP_QUEUE, true);
}
@Bean
public Queue mergeVisitQueue() {
return new Queue(MERGE_VISIT_QUEUE, true);
@@ -57,6 +51,11 @@ public class RabbitMQConfig {
return new Queue(SIGNIFICANT_PLACE_QUEUE, true);
}
@Bean
public Queue stayDetectionQueue() {
return new Queue(STAY_DETECTION_QUEUE, true);
}
@Bean
public Binding locationDataBinding(Queue locationDataQueue, TopicExchange exchange) {
return BindingBuilder.bind(locationDataQueue).to(exchange).with(LOCATION_DATA_ROUTING_KEY);
@@ -78,8 +77,8 @@ public class RabbitMQConfig {
}
@Bean
public Binding mergeTripBinding(Queue mergeTripQueue, TopicExchange exchange) {
return BindingBuilder.bind(mergeTripQueue).to(exchange).with(MERGE_TRIP_ROUTING_KEY);
public Binding stayDetectionBinding(Queue stayDetectionQueue, TopicExchange exchange) {
return BindingBuilder.bind(stayDetectionQueue).to(exchange).with(STAY_DETECTION_ROUTING_KEY);
}
@Bean

View File

@@ -1,14 +1,11 @@
package com.dedicatedcode.reitti.controller;
import com.dedicatedcode.reitti.dto.TimelineResponse;
import com.dedicatedcode.reitti.model.ApiToken;
import com.dedicatedcode.reitti.model.GeocodeService;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.model.*;
import com.dedicatedcode.reitti.repository.GeocodeServiceRepository;
import com.dedicatedcode.reitti.service.*;
import com.dedicatedcode.reitti.service.processing.RawLocationPointProcessingTrigger;
import jakarta.servlet.http.HttpServletRequest;
import org.geolatte.geom.V;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -35,20 +32,26 @@ public class SettingsController {
private final PlaceService placeService;
private final ImportHandler importHandler;
private final GeocodeServiceRepository geocodeServiceRepository;
private final RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger;
private final int maxErrors;
private final boolean dataManagementEnabled;
public SettingsController(ApiTokenService apiTokenService, UserService userService,
QueueStatsService queueStatsService, PlaceService placeService,
ImportHandler importHandler,
GeocodeServiceRepository geocodeServiceRepository,
@Value("${reitti.geocoding.max-errors}") int maxErrors) {
RawLocationPointProcessingTrigger rawLocationPointProcessingTrigger,
@Value("${reitti.geocoding.max-errors}") int maxErrors,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
this.apiTokenService = apiTokenService;
this.userService = userService;
this.queueStatsService = queueStatsService;
this.placeService = placeService;
this.importHandler = importHandler;
this.geocodeServiceRepository = geocodeServiceRepository;
this.rawLocationPointProcessingTrigger = rawLocationPointProcessingTrigger;
this.maxErrors = maxErrors;
this.dataManagementEnabled = dataManagementEnabled;
}
@GetMapping("/api-tokens-content")
@@ -295,35 +298,58 @@ public class SettingsController {
}
@PostMapping("/import/gpx")
public String importGpx(@RequestParam("file") MultipartFile file,
public String importGpx(@RequestParam("files") MultipartFile[] files,
Authentication authentication,
Model model) {
User user = (User) authentication.getPrincipal();
if (file.isEmpty()) {
model.addAttribute("uploadErrorMessage", "File is empty");
if (files.length == 0) {
model.addAttribute("uploadErrorMessage", "No files selected");
return "fragments/settings :: file-upload-content";
}
if (!file.getOriginalFilename().endsWith(".gpx")) {
model.addAttribute("uploadErrorMessage", "Only GPX files are supported");
return "fragments/settings :: file-upload-content";
}
int totalProcessed = 0;
int successCount = 0;
StringBuilder errorMessages = new StringBuilder();
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGpx(inputStream, user);
if ((Boolean) result.get("success")) {
model.addAttribute("uploadSuccessMessage", result.get("message"));
} else {
model.addAttribute("uploadErrorMessage", result.get("error"));
for (MultipartFile file : files) {
if (file.isEmpty()) {
errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
continue;
}
return "fragments/settings :: file-upload-content";
} catch (IOException e) {
model.addAttribute("uploadErrorMessage", "Error processing file: " + e.getMessage());
return "fragments/settings :: file-upload-content";
if (!file.getOriginalFilename().endsWith(".gpx")) {
errorMessages.append("File ").append(file.getOriginalFilename()).append(" is not a GPX file. ");
continue;
}
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGpx(inputStream, user);
if ((Boolean) result.get("success")) {
totalProcessed += (Integer) result.get("pointsReceived");
successCount++;
} else {
errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
.append(result.get("error")).append(". ");
}
} catch (IOException e) {
errorMessages.append("Error processing ").append(file.getOriginalFilename()).append(": ")
.append(e.getMessage()).append(". ");
}
}
if (successCount > 0) {
String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points";
if (errorMessages.length() > 0) {
message += ". Errors: " + errorMessages.toString();
}
model.addAttribute("uploadSuccessMessage", message);
} else {
model.addAttribute("uploadErrorMessage", "No files were processed successfully. " + errorMessages.toString());
}
return "fragments/settings :: file-upload-content";
}
@PostMapping("/import/google-takeout")
@@ -358,6 +384,88 @@ public class SettingsController {
}
}
@PostMapping("/import/geojson")
public String importGeoJson(@RequestParam("files") MultipartFile[] files,
Authentication authentication,
Model model) {
User user = (User) authentication.getPrincipal();
if (files.length == 0) {
model.addAttribute("uploadErrorMessage", "No files selected");
return "fragments/settings :: file-upload-content";
}
int totalProcessed = 0;
int successCount = 0;
StringBuilder errorMessages = new StringBuilder();
for (MultipartFile file : files) {
if (file.isEmpty()) {
errorMessages.append("File ").append(file.getOriginalFilename()).append(" is empty. ");
continue;
}
String filename = file.getOriginalFilename();
if (filename == null || (!filename.endsWith(".geojson") && !filename.endsWith(".json"))) {
errorMessages.append("File ").append(filename).append(" is not a GeoJSON file. ");
continue;
}
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGeoJson(inputStream, user);
if ((Boolean) result.get("success")) {
totalProcessed += (Integer) result.get("pointsReceived");
successCount++;
} else {
errorMessages.append("Error processing ").append(filename).append(": ")
.append(result.get("error")).append(". ");
}
} catch (IOException e) {
errorMessages.append("Error processing ").append(filename).append(": ")
.append(e.getMessage()).append(". ");
}
}
if (successCount > 0) {
String message = "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points";
if (errorMessages.length() > 0) {
message += ". Errors: " + errorMessages.toString();
}
model.addAttribute("uploadSuccessMessage", message);
} else {
model.addAttribute("uploadErrorMessage", "No files were processed successfully. " + errorMessages.toString());
}
return "fragments/settings :: file-upload-content";
}
@GetMapping("/manage-data-content")
public String getManageDataContent(Model model) {
if (!dataManagementEnabled) {
throw new RuntimeException("Data management is not enabled");
}
return "fragments/settings :: manage-data-content";
}
@PostMapping("/manage-data/process-visits-trips")
public String processVisitsTrips(Authentication authentication, Model model) {
if (!dataManagementEnabled) {
throw new RuntimeException("Data management is not enabled");
}
try {
rawLocationPointProcessingTrigger.start();
model.addAttribute("successMessage", "Processing started successfully. Check the Job Status tab to monitor progress.");
} catch (Exception e) {
model.addAttribute("errorMessage", "Error starting processing: " + e.getMessage());
}
return "fragments/settings :: manage-data-content";
}
@GetMapping("/geocode-services-content")
public String getGeocodeServicesContent(Model model) {
model.addAttribute("geocodeServices", geocodeServiceRepository.findAllByOrderByNameAsc());

View File

@@ -131,8 +131,9 @@ public class TimelineViewController {
if (trip.getTransportModeInferred() != null) {
tripEntry.put("transportMode", trip.getTransportModeInferred().toLowerCase());
}
if (trip.getEstimatedDistanceMeters() != null) {
if (trip.getTravelledDistanceMeters() != null) {
tripEntry.put("distanceMeters", trip.getTravelledDistanceMeters());
} else if (trip.getEstimatedDistanceMeters() != null) {
tripEntry.put("distanceMeters", trip.getEstimatedDistanceMeters());
}

View File

@@ -28,6 +28,8 @@ import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.Comparator;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
@RestController
@@ -75,31 +77,120 @@ public class ImportDataApiController {
@PostMapping("/import/gpx")
public ResponseEntity<?> importGpx(
@RequestParam("file") MultipartFile file) {
@RequestParam("files") MultipartFile[] files) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();
if (file.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "File is empty"));
if (files.length == 0) {
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "No files provided"));
}
if (!file.getOriginalFilename().endsWith(".gpx")) {
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Only GPX files are supported"));
}
int totalProcessed = 0;
int successCount = 0;
List<String> errors = new ArrayList<>();
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGpx(inputStream, user);
if ((Boolean) result.get("success")) {
return ResponseEntity.accepted().body(result);
} else {
return ResponseEntity.badRequest().body(result);
for (MultipartFile file : files) {
if (file.isEmpty()) {
errors.add("File " + file.getOriginalFilename() + " is empty");
continue;
}
} catch (IOException e) {
logger.error("Error processing GPX file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("success", false, "error", "Error processing file: " + e.getMessage()));
if (!file.getOriginalFilename().endsWith(".gpx")) {
errors.add("File " + file.getOriginalFilename() + " is not a GPX file");
continue;
}
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGpx(inputStream, user);
if ((Boolean) result.get("success")) {
totalProcessed += (Integer) result.get("pointsReceived");
successCount++;
} else {
errors.add("Error processing " + file.getOriginalFilename() + ": " + result.get("error"));
}
} catch (IOException e) {
logger.error("Error processing GPX file: " + file.getOriginalFilename(), e);
errors.add("Error processing " + file.getOriginalFilename() + ": " + e.getMessage());
}
}
Map<String, Object> response = new HashMap<>();
if (successCount > 0) {
response.put("success", true);
response.put("message", "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points");
response.put("pointsReceived", totalProcessed);
response.put("filesProcessed", successCount);
if (!errors.isEmpty()) {
response.put("errors", errors);
}
return ResponseEntity.accepted().body(response);
} else {
response.put("success", false);
response.put("error", "No files were processed successfully");
response.put("errors", errors);
return ResponseEntity.badRequest().body(response);
}
}
@PostMapping("/import/geojson")
public ResponseEntity<?> importGeoJson(
@RequestParam("files") MultipartFile[] files) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();
if (files.length == 0) {
return ResponseEntity.badRequest().body(Map.of("success", false, "error", "No files provided"));
}
int totalProcessed = 0;
int successCount = 0;
List<String> errors = new ArrayList<>();
for (MultipartFile file : files) {
if (file.isEmpty()) {
errors.add("File " + file.getOriginalFilename() + " is empty");
continue;
}
String filename = file.getOriginalFilename();
if (filename == null || (!filename.endsWith(".geojson") && !filename.endsWith(".json"))) {
errors.add("File " + filename + " is not a GeoJSON file");
continue;
}
try (InputStream inputStream = file.getInputStream()) {
Map<String, Object> result = importHandler.importGeoJson(inputStream, user);
if ((Boolean) result.get("success")) {
totalProcessed += (Integer) result.get("pointsReceived");
successCount++;
} else {
errors.add("Error processing " + filename + ": " + result.get("error"));
}
} catch (IOException e) {
logger.error("Error processing GeoJSON file: " + filename, e);
errors.add("Error processing " + filename + ": " + e.getMessage());
}
}
Map<String, Object> response = new HashMap<>();
if (successCount > 0) {
response.put("success", true);
response.put("message", "Successfully processed " + successCount + " file(s) with " + totalProcessed + " location points");
response.put("pointsReceived", totalProcessed);
response.put("filesProcessed", successCount);
if (!errors.isEmpty()) {
response.put("errors", errors);
}
return ResponseEntity.accepted().body(response);
} else {
response.put("success", false);
response.put("error", "No files were processed successfully");
response.put("errors", errors);
return ResponseEntity.badRequest().body(response);
}
}

View File

@@ -15,15 +15,20 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.io.Serializable;
import java.util.Collections;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/ingest")
public class IngestApiController {
private static final Logger logger = LoggerFactory.getLogger(IngestApiController.class);
private static final Map<String, ? extends Serializable> SUCCESS = Map.of(
"success", true,
"message", "Successfully queued Owntracks location point for processing"
);
private final RabbitTemplate rabbitTemplate;
@Autowired
@@ -46,9 +51,13 @@ public class IngestApiController {
UserDetails user = (UserDetails) authentication.getPrincipal();
try {
// Convert Owntracks format to our LocationPoint format
// Convert an Owntracks format to our LocationPoint format
LocationDataRequest.LocationPoint locationPoint = request.toLocationPoint();
if (locationPoint.getTimestamp() == null) {
logger.warn("Ignoring location point [{}] because timestamp is null", locationPoint);
return ResponseEntity.ok(Map.of());
}
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getUsername(),
@@ -61,13 +70,10 @@ public class IngestApiController {
event
);
logger.info("Successfully received and queued Owntracks location point for user {}",
logger.debug("Successfully received and queued Owntracks location point for user {}",
user.getUsername());
return ResponseEntity.accepted().body(Map.of(
"success", true,
"message", "Successfully queued Owntracks location point for processing"
));
return ResponseEntity.accepted().body(SUCCESS);
} catch (Exception e) {
logger.error("Error processing Owntracks data", e);

View File

@@ -121,6 +121,11 @@ public class TimelineApiController {
);
}
List<RawLocationPoint> path = this.rawLocationPointRepository.findByUserAndTimestampBetweenOrderByTimestampAsc(trip.getUser(), trip.getStartTime(), trip.getEndTime());
List<TimelineResponse.PointInfo> pathPoints = new ArrayList<>();
pathPoints.add(new TimelineResponse.PointInfo(trip.getStartPlace().getLatitudeCentroid(), trip.getStartPlace().getLongitudeCentroid(), trip.getStartTime(), 0.0));
pathPoints.addAll(path.stream().map(p -> new TimelineResponse.PointInfo(p.getLatitude(), p.getLongitude(), p.getTimestamp(), p.getAccuracyMeters())).toList());
pathPoints.add(new TimelineResponse.PointInfo(trip.getEndPlace().getLatitudeCentroid(), trip.getEndPlace().getLongitudeCentroid(), trip.getEndTime(), 0.0));
entries.add(new TimelineResponse.TimelineEntry(
"TRIP",
trip.getId(),
@@ -130,9 +135,9 @@ public class TimelineApiController {
null,
startPlace,
endPlace,
trip.getEstimatedDistanceMeters(),
trip.getTravelledDistanceMeters() != null ? trip.getTravelledDistanceMeters() : trip.getEstimatedDistanceMeters(),
trip.getTransportModeInferred(),
path.stream().map(p -> new TimelineResponse.PointInfo(p.getLatitude(), p.getLongitude(), p.getTimestamp(), p.getAccuracyMeters())).toList()
pathPoints
));
}

View File

@@ -1,64 +0,0 @@
package com.dedicatedcode.reitti.controller.api;
import com.dedicatedcode.reitti.model.Trip;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.service.processing.TripDetectionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/trips/detection")
public class TripDetectionController {
private static final Logger logger = LoggerFactory.getLogger(TripDetectionController.class);
private final TripDetectionService tripDetectionService;
private final UserRepository userRepository;
public TripDetectionController(TripDetectionService tripDetectionService, UserRepository userRepository) {
this.tripDetectionService = tripDetectionService;
this.userRepository = userRepository;
}
@DeleteMapping("/clear-all")
public ResponseEntity<?> clearAllTrips() {
logger.info("Received request to clear all trips");
tripDetectionService.clearAllTrips();
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("message", "Cleared all trips");
return ResponseEntity.ok(response);
}
@DeleteMapping("/clear/{userId}")
public ResponseEntity<?> clearTripsForUser(@PathVariable Long userId) {
logger.info("Received request to clear trips for user ID: {}", userId);
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
tripDetectionService.clearTrips(userOpt.get());
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("message", "Cleared trips for user: " + userOpt.get().getUsername());
response.put("userId", userId);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,38 @@
package com.dedicatedcode.reitti.event;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
public class LocationProcessEvent implements Serializable {
private final String username;
private final Instant earliest;
private final Instant latest;
@JsonCreator
public LocationProcessEvent(
@JsonProperty("username") String username,
@JsonProperty("earliest") Instant earliest,
@JsonProperty("latest") Instant latest) {
this.username = username;
this.earliest = earliest;
this.latest = latest;
}
public String getUsername() {
return username;
}
public Instant getEarliest() {
return earliest;
}
public Instant getLatest() {
return latest;
}
}

View File

@@ -1,41 +0,0 @@
package com.dedicatedcode.reitti.event;
public class MergeVisitEvent {
private String userName;
private Long startTime;
private Long endTime;
// Default constructor for Jackson
public MergeVisitEvent() {
}
public MergeVisitEvent(String userName, Long startTime, Long endTime) {
this.userName = userName;
this.startTime = startTime;
this.endTime = endTime;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public Long getStartTime() {
return startTime;
}
public void setStartTime(Long startTime) {
this.startTime = startTime;
}
public Long getEndTime() {
return endTime;
}
public void setEndTime(Long endTime) {
this.endTime = endTime;
}
}

View File

@@ -0,0 +1,22 @@
package com.dedicatedcode.reitti.event;
import com.fasterxml.jackson.annotation.JsonProperty;
public class ProcessedVisitCreatedEvent {
private final String username;
private final long visitId;
public ProcessedVisitCreatedEvent(
@JsonProperty String username,
@JsonProperty long visitId) {
this.username = username;
this.visitId = visitId;
}
public String getUsername() {
return username;
}
public long getVisitId() {
return visitId;
}
}

View File

@@ -0,0 +1,22 @@
package com.dedicatedcode.reitti.event;
import com.fasterxml.jackson.annotation.JsonProperty;
public class VisitCreatedEvent {
private final String username;
private final long visitId;
public VisitCreatedEvent(
@JsonProperty String username,
@JsonProperty long visitId) {
this.username = username;
this.visitId = visitId;
}
public String getUsername() {
return username;
}
public long getVisitId() {
return visitId;
}
}

View File

@@ -0,0 +1,22 @@
package com.dedicatedcode.reitti.event;
import com.fasterxml.jackson.annotation.JsonProperty;
public class VisitUpdatedEvent {
private final String username;
private final long visitId;
public VisitUpdatedEvent(
@JsonProperty String username,
@JsonProperty long visitId) {
this.username = username;
this.visitId = visitId;
}
public String getUsername() {
return username;
}
public long getVisitId() {
return visitId;
}
}

View File

@@ -0,0 +1,9 @@
package com.dedicatedcode.reitti.model;
public record GeoPoint(double latitude, double longitude) {
@Override
public String toString() {
return "lat=" + latitude + ", lon=" + longitude + " -> (https://www.openstreetmap.org/#map=19/" + latitude + "/" + longitude + ")";
}
}

View File

@@ -1,6 +1,8 @@
package com.dedicatedcode.reitti.model;
import com.dedicatedcode.reitti.service.processing.StayPoint;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class GeoUtils {
private GeoUtils() {
@@ -51,4 +53,23 @@ public final class GeoUtils {
return new double[] { latitudeDegrees, longitudeDegrees };
}
public static double calculateTripDistance(List<RawLocationPoint> points) {
if (points.size() < 2) {
return 0.0;
}
List<RawLocationPoint> tmp = new ArrayList<>(points);
tmp.sort(Comparator.comparing(RawLocationPoint::getTimestamp));
double totalDistance = 0.0;
for (int i = 0; i < tmp.size() - 1; i++) {
RawLocationPoint p1 = tmp.get(i);
RawLocationPoint p2 = tmp.get(i + 1);
totalDistance += distanceInMeters(p1, p2);
}
return totalDistance;
}
}

View File

@@ -1,7 +1,6 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import org.locationtech.jts.geom.Point;
import java.time.Instant;
@@ -17,7 +16,7 @@ public class ProcessedVisit {
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "place_id", nullable = false)
private SignificantPlace place;

View File

@@ -1,6 +1,7 @@
package com.dedicatedcode.reitti.model;
import jakarta.persistence.*;
import org.locationtech.jts.geom.Point;
import java.time.Instant;
import java.util.Objects;
@@ -20,26 +21,25 @@ public class RawLocationPoint {
@Column(nullable = false)
private Instant timestamp;
@Column(nullable = false)
private Double latitude;
@Column(nullable = false)
private Double longitude;
@Column(nullable = false)
private Double accuracyMeters;
@Column
private String activityProvided;
@Column(columnDefinition = "geometry(Point,4326)", nullable = false)
private Point geom;
@Column(nullable = false)
private boolean processed;
public RawLocationPoint() {
}
public RawLocationPoint(User user, Instant timestamp, Double latitude, Double longitude, Double accuracyMeters) {
public RawLocationPoint(User user, Instant timestamp, Point geom, Double accuracyMeters) {
this.user = user;
this.timestamp = timestamp;
this.latitude = latitude;
this.longitude = longitude;
this.accuracyMeters = accuracyMeters;
this.geom = geom;
}
public Long getId() {
@@ -67,19 +67,11 @@ public class RawLocationPoint {
}
public Double getLatitude() {
return latitude;
}
public void setLatitude(Double latitude) {
this.latitude = latitude;
return this.geom.getY();
}
public Double getLongitude() {
return longitude;
}
public void setLongitude(Double longitude) {
this.longitude = longitude;
return this.geom.getCoordinate().getX();
}
public Double getAccuracyMeters() {
@@ -98,6 +90,26 @@ public class RawLocationPoint {
this.activityProvided = activityProvided;
}
public Point getGeom() {
return geom;
}
public void setGeom(Point geom) {
this.geom = geom;
}
public boolean isProcessed() {
return processed;
}
public void setProcessed(boolean processed) {
this.processed = processed;
}
public void markProcessed() {
this.processed = true;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;

View File

@@ -1,6 +1,4 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.model.RawLocationPoint;
package com.dedicatedcode.reitti.model;
import java.time.Duration;
import java.time.Instant;

View File

@@ -43,9 +43,17 @@ public class Trip {
@Column
private String transportModeInferred;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "start_visit_id")
private ProcessedVisit startVisit;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "end_visit_id")
private ProcessedVisit endVisit;
public Trip() {}
public Trip(User user, SignificantPlace startPlace, SignificantPlace endPlace, Instant startTime, Instant endTime, Double estimatedDistanceMeters, String transportModeInferred) {
public Trip(User user, SignificantPlace startPlace, SignificantPlace endPlace, Instant startTime, Instant endTime, Double estimatedDistanceMeters, String transportModeInferred, ProcessedVisit startVisit, ProcessedVisit endVisit) {
this.user = user;
this.startPlace = startPlace;
this.endPlace = endPlace;
@@ -53,6 +61,8 @@ public class Trip {
this.endTime = endTime;
this.estimatedDistanceMeters = estimatedDistanceMeters;
this.transportModeInferred = transportModeInferred;
this.startVisit = startVisit;
this.endVisit = endVisit;
}
public Long getId() {
@@ -135,6 +145,22 @@ public class Trip {
this.transportModeInferred = transportModeInferred;
}
public ProcessedVisit getStartVisit() {
return startVisit;
}
public void setStartVisit(ProcessedVisit startVisit) {
this.startVisit = startVisit;
}
public ProcessedVisit getEndVisit() {
return endVisit;
}
public void setEndVisit(ProcessedVisit endVisit) {
this.endVisit = endVisit;
}
@PrePersist
@PreUpdate
private void calculateDuration() {

View File

@@ -3,13 +3,16 @@ package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.ProcessedVisit;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import org.springframework.data.domain.Sort;
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 org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Repository
public interface ProcessedVisitRepository extends JpaRepository<ProcessedVisit, Long> {
@@ -28,28 +31,16 @@ public interface ProcessedVisitRepository extends JpaRepository<ProcessedVisit,
@Param("startTime") Instant startTime,
@Param("endTime") Instant endTime);
@Query("SELECT pv FROM ProcessedVisit pv WHERE pv.user = :user " +
"AND ((pv.startTime <= :endTime AND pv.endTime >= :startTime) OR " +
"(pv.startTime >= :startTime AND pv.startTime <= :endTime) OR " +
"(pv.endTime >= :startTime AND pv.endTime <= :endTime))")
List<ProcessedVisit> findByUserAndTimeOverlap(
@Param("user") User user,
@Param("startTime") Instant startTime,
@Param("endTime") Instant endTime);
@Query("SELECT pv FROM ProcessedVisit pv WHERE pv.user = :user AND " +
"pv.startTime <= :endTime AND pv.endTime >= :startTime")
List<ProcessedVisit> findByUserAndTimeOverlap(User user, Instant startTime, Instant endTime);
List<ProcessedVisit> findByUserAndStartTimeBetweenOrderByStartTimeAsc(
User user, Instant startTime, Instant endTime);
@Query("SELECT pv FROM ProcessedVisit pv WHERE pv.user = ?1 AND pv.place = ?2 AND " +
"((pv.startTime <= ?3 AND pv.endTime >= ?3) OR " +
"(pv.startTime <= ?4 AND pv.endTime >= ?4) OR " +
"(pv.startTime >= ?3 AND pv.endTime <= ?4))")
List<ProcessedVisit> findOverlappingVisits(User user, SignificantPlace place,
Instant startTime, Instant endTime);
@Query("SELECT pv FROM ProcessedVisit pv WHERE pv.user = ?1 AND pv.place = ?2 AND " +
"((pv.endTime >= ?3 AND pv.endTime <= ?4) OR " +
"(pv.startTime >= ?3 AND pv.startTime <= ?4))")
List<ProcessedVisit> findVisitsWithinTimeRange(User user, SignificantPlace place,
Instant startThreshold, Instant endThreshold);
List<ProcessedVisit> findByUserAndEndTimeBetweenOrderByStartTimeAsc(User user, Instant endTimeAfter, Instant endTimeBefore);
Optional<ProcessedVisit> findByUserAndId(User user, long id);
List<ProcessedVisit> findByUserAndStartTimeGreaterThanEqualAndEndTimeLessThanEqual(User user, Instant startTimeIsGreaterThan, Instant endTimeIsLessThan);
}

View File

@@ -2,8 +2,11 @@ package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import org.locationtech.jts.geom.Point;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.NativeQuery;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
@@ -12,14 +15,28 @@ import java.util.Optional;
@Repository
public interface RawLocationPointRepository extends JpaRepository<RawLocationPoint, Long> {
List<RawLocationPoint> findByUserOrderByTimestampAsc(User user);
List<RawLocationPoint> findByUserAndTimestampBetweenOrderByTimestampAsc(
User user, Instant startTime, Instant endTime);
@Query("SELECT r FROM RawLocationPoint r WHERE r.user = ?1 AND DATE(r.timestamp) = DATE(?2) ORDER BY r.timestamp ASC")
List<RawLocationPoint> findByUserAndDate(User user, Instant date);
Optional<RawLocationPoint> findByUserAndTimestamp(User user, Instant timestamp);
@Query("SELECT p as point, ST_ClusterDBSCAN(p.geom, :distance, :minPoints) over () AS clusterId " +
"FROM RawLocationPoint p " +
"WHERE p.user = :user " +
"AND p.timestamp BETWEEN :start AND :end")
List<ClusteredPoint> findClusteredPointsInTimeRangeForUser(
@Param("user") User user,
@Param("start") Instant startTime,
@Param("end") Instant endTime,
@Param("minPoints") int minimumPoints,
@Param("distance") double distanceInMeters
);
List<RawLocationPoint> findByUserAndProcessedIsFalseOrderByTimestamp(User user);
interface ClusteredPoint {
RawLocationPoint getPoint();
Integer getClusterId();
}
}

View File

@@ -32,4 +32,6 @@ public interface TripRepository extends JpaRepository<Trip, Long> {
boolean existsByUserAndStartTimeAndEndTime(User user, Instant startTime, Instant endTime);
boolean existsByUserAndStartPlaceAndEndPlaceAndStartTimeAndEndTime(User user, SignificantPlace startPlace, SignificantPlace endPlace, Instant startTime, Instant endTime);
List<Trip> findByUserAndStartVisitOrEndVisit(User user, ProcessedVisit startVisit, ProcessedVisit endVisit);
}

View File

@@ -1,13 +1,16 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.model.Visit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Repository
public interface VisitRepository extends JpaRepository<Visit, Long> {
@@ -15,9 +18,14 @@ public interface VisitRepository extends JpaRepository<Visit, Long> {
List<Visit> findByUser(User user);
List<Visit> findByUserAndProcessedFalse(User user);
List<Visit> findByUserAndStartTimeBetweenOrderByStartTimeAsc(
User user, Instant startTime, Instant endTime);
List<Visit> findByUserAndStartTimeBetweenAndProcessedFalseOrderByStartTimeAsc(User user, Instant startTime, Instant endTime);
Optional<Visit> findByUserAndStartTime(User user, Instant startTime);
Optional<Visit> findByUserAndEndTime(User user, Instant departureTime);
List<Visit> findByUserAndStartTimeBetweenOrderByStartTimeAsc(User user, Instant startTime, Instant endTime);
List<Visit> findByUserAndStartTimeBeforeAndEndTimeAfter(User user, Instant startTimeBefore, Instant endTimeAfter);
}

View File

@@ -1,24 +0,0 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class EventPublisherService {
private static final Logger logger = LoggerFactory.getLogger(EventPublisherService.class);
private final ApplicationEventPublisher eventPublisher;
public EventPublisherService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void publishLocationDataEvent(LocationDataEvent event) {
logger.info("Publishing location data event for user {} with {} points",
event.getUsername(), event.getPoints().size());
eventPublisher.publishEvent(event);
}
}

View File

@@ -1,7 +1,10 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.model.User;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
@@ -12,6 +15,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
@@ -21,6 +25,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@@ -31,16 +36,16 @@ public class ImportHandler {
private static final Logger logger = LoggerFactory.getLogger(ImportHandler.class);
private final ObjectMapper objectMapper;
private final ImportListener importListener;
private final RabbitTemplate rabbitTemplate;
private final int batchSize;
@Autowired
public ImportHandler(
ObjectMapper objectMapper,
ImportListener importListener,
RabbitTemplate rabbitTemplate,
@Value("${reitti.import.batch-size:100}") int batchSize) {
this.objectMapper = objectMapper;
this.importListener = importListener;
this.rabbitTemplate = rabbitTemplate;
this.batchSize = batchSize;
}
@@ -55,7 +60,7 @@ public class ImportHandler {
// Find the "locations" array
while (parser.nextToken() != null) {
if (parser.getCurrentToken() == JsonToken.FIELD_NAME &&
"locations".equals(parser.getCurrentName())) {
"locations".equals(parser.currentName())) {
// Move to the array
parser.nextToken(); // Should be START_ARRAY
@@ -79,26 +84,24 @@ public class ImportHandler {
processedCount.incrementAndGet();
// Process in batches to avoid memory issues
if (batch.size() >= batchSize) {
this.importListener.handleImport(user, new ArrayList<>(batch));
logger.info("Queued batch of {} locations for processing", batch.size());
sendToQueue(user, batch);
batch.clear();
}
}
} catch (Exception e) {
logger.warn("Error processing location entry: {}", e.getMessage());
// Continue with next location
}
}
}
// Process any remaining locations
if (!batch.isEmpty()) {
this.importListener.handleImport(user, new ArrayList<>(batch));
logger.info("Queued final batch of {} locations for processing", batch.size());
sendToQueue(user, batch);
}
break; // We've processed the locations array, no need to continue
break;
}
}
@@ -116,39 +119,7 @@ public class ImportHandler {
return Map.of("success", false, "error", "Error processing Google Takeout file: " + e.getMessage());
}
}
/**
* Converts a Google Takeout location entry to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGoogleTakeoutLocation(JsonNode locationNode) {
// Check if we have the required fields
if (!locationNode.has("latitudeE7") ||
!locationNode.has("longitudeE7") ||
!locationNode.has("timestamp")) {
return null;
}
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
// Convert latitudeE7 and longitudeE7 to standard decimal format
// Google stores these as integers with 7 decimal places of precision
double latitude = locationNode.get("latitudeE7").asDouble() / 10000000.0;
double longitude = locationNode.get("longitudeE7").asDouble() / 10000000.0;
point.setLatitude(latitude);
point.setLongitude(longitude);
point.setTimestamp(locationNode.get("timestamp").asText());
// Set accuracy if available
if (locationNode.has("accuracy")) {
point.setAccuracyMeters(locationNode.get("accuracy").asDouble());
} else {
point.setAccuracyMeters(100.0);
}
return point;
}
public Map<String, Object> importGpx(InputStream inputStream, User user) {
AtomicInteger processedCount = new AtomicInteger(0);
@@ -177,8 +148,7 @@ public class ImportHandler {
// Process in batches to avoid memory issues
if (batch.size() >= batchSize) {
this.importListener.handleImport(user, new ArrayList<>(batch));
logger.info("Queued batch of {} locations for processing", batch.size());
sendToQueue(user, batch);
batch.clear();
}
}
@@ -190,12 +160,10 @@ public class ImportHandler {
// Process any remaining locations
if (!batch.isEmpty()) {
// Create and publish event to RabbitMQ
this.importListener.handleImport(user, new ArrayList<>(batch));
logger.info("Queued final batch of {} locations for processing", batch.size());
sendToQueue(user, batch);
}
logger.info("Successfully imported and queued {} location points from GPX file for user {}",
logger.info("Successfully imported and queued {} location points from GPX file for user {}",
processedCount.get(), user.getUsername());
return Map.of(
@@ -209,7 +177,83 @@ public class ImportHandler {
return Map.of("success", false, "error", "Error processing GPX file: " + e.getMessage());
}
}
public Map<String, Object> importGeoJson(InputStream inputStream, User user) {
AtomicInteger processedCount = new AtomicInteger(0);
try {
JsonNode rootNode = objectMapper.readTree(inputStream);
// Check if it's a valid GeoJSON
if (!rootNode.has("type")) {
return Map.of("success", false, "error", "Invalid GeoJSON: missing 'type' field");
}
String type = rootNode.get("type").asText();
List<LocationDataRequest.LocationPoint> batch = new ArrayList<>(batchSize);
switch (type) {
case "FeatureCollection" -> {
// Process FeatureCollection
if (!rootNode.has("features")) {
return Map.of("success", false, "error", "Invalid FeatureCollection: missing 'features' array");
}
JsonNode features = rootNode.get("features");
for (JsonNode feature : features) {
LocationDataRequest.LocationPoint point = convertGeoJsonFeature(feature);
if (point != null) {
batch.add(point);
processedCount.incrementAndGet();
if (batch.size() >= batchSize) {
sendToQueue(user, batch);
batch.clear();
}
}
}
}
case "Feature" -> {
// Process single Feature
LocationDataRequest.LocationPoint point = convertGeoJsonFeature(rootNode);
if (point != null) {
batch.add(point);
processedCount.incrementAndGet();
}
}
case "Point" -> {
// Process single Point geometry
LocationDataRequest.LocationPoint point = convertGeoJsonGeometry(rootNode, null);
if (point != null) {
batch.add(point);
processedCount.incrementAndGet();
}
}
case null, default -> {
return Map.of("success", false, "error", "Unsupported GeoJSON type: " + type + ". Only FeatureCollection, Feature, and Point are supported.");
}
}
// Process any remaining locations
if (!batch.isEmpty()) {
sendToQueue(user, batch);
}
logger.info("Successfully imported and queued {} location points from GeoJSON file for user {}",
processedCount.get(), user.getUsername());
return Map.of(
"success", true,
"message", "Successfully queued " + processedCount.get() + " location points for processing",
"pointsReceived", processedCount.get()
);
} catch (IOException e) {
logger.error("Error processing GeoJSON file", e);
return Map.of("success", false, "error", "Error processing GeoJSON file: " + e.getMessage());
}
}
/**
* Converts a GPX track point to our LocationPoint format
*/
@@ -232,7 +276,11 @@ public class ImportHandler {
NodeList timeElements = trackPoint.getElementsByTagName("time");
if (timeElements.getLength() > 0) {
String timeStr = timeElements.item(0).getTextContent();
point.setTimestamp(timeStr);
if (StringUtils.hasText(timeStr)) {
point.setTimestamp(timeStr);
} else {
return null;
}
} else {
return null;
}
@@ -242,4 +290,125 @@ public class ImportHandler {
return point;
}
/**
* Converts a Google Takeout location entry to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGoogleTakeoutLocation(JsonNode locationNode) {
// Check if we have the required fields
if (!locationNode.has("latitudeE7") ||
!locationNode.has("longitudeE7") ||
!locationNode.has("timestamp")) {
return null;
}
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
// Convert latitudeE7 and longitudeE7 to standard decimal format
// Google stores these as integers with 7 decimal places of precision
double latitude = locationNode.get("latitudeE7").asDouble() / 10000000.0;
double longitude = locationNode.get("longitudeE7").asDouble() / 10000000.0;
point.setLatitude(latitude);
point.setLongitude(longitude);
point.setTimestamp(locationNode.get("timestamp").asText());
// Set accuracy if available
if (locationNode.has("accuracy")) {
point.setAccuracyMeters(locationNode.get("accuracy").asDouble());
} else {
point.setAccuracyMeters(100.0);
}
return point;
}
/**
* Converts a GeoJSON Feature to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGeoJsonFeature(JsonNode feature) {
if (!feature.has("geometry")) {
return null;
}
JsonNode geometry = feature.get("geometry");
JsonNode properties = feature.has("properties") ? feature.get("properties") : null;
return convertGeoJsonGeometry(geometry, properties);
}
/**
* Converts a GeoJSON geometry (Point) to our LocationPoint format
*/
private LocationDataRequest.LocationPoint convertGeoJsonGeometry(JsonNode geometry, JsonNode properties) {
if (!geometry.has("type") || !"Point".equals(geometry.get("type").asText())) {
return null; // Only support Point geometries for location data
}
if (!geometry.has("coordinates")) {
return null;
}
JsonNode coordinates = geometry.get("coordinates");
if (!coordinates.isArray() || coordinates.size() < 2) {
return null;
}
LocationDataRequest.LocationPoint point = new LocationDataRequest.LocationPoint();
// GeoJSON coordinates are [longitude, latitude]
double longitude = coordinates.get(0).asDouble();
double latitude = coordinates.get(1).asDouble();
point.setLatitude(latitude);
point.setLongitude(longitude);
// Try to extract timestamp from properties
String timestamp = null;
if (properties != null) {
// Common timestamp field names in GeoJSON
String[] timestampFields = {"timestamp", "time", "datetime", "date", "when"};
for (String field : timestampFields) {
if (properties.has(field)) {
timestamp = properties.get(field).asText();
break;
}
}
}
if (timestamp == null || timestamp.isEmpty()) {
logger.warn("Could not determine timestamp for point {}. Will discard it", point);
return null;
}
point.setTimestamp(timestamp);
// Try to extract accuracy from properties
Double accuracy = null;
String[] accuracyFields = {"accuracy", "acc", "precision", "hdop"};
for (String field : accuracyFields) {
if (properties.has(field)) {
accuracy = properties.get(field).asDouble();
break;
}
}
point.setAccuracyMeters(accuracy != null ? accuracy : 50.0); // Default accuracy of 50 meters
return point;
}
private void sendToQueue(User user, List<LocationDataRequest.LocationPoint> batch) {
LocationDataEvent event = new LocationDataEvent(
user.getUsername(),
batch
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
logger.info("Queued batch of {} locations for processing", batch.size());
}
}

View File

@@ -1,11 +0,0 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.User;
import java.util.List;
public interface ImportListener {
void handleImport(User user, List<LocationDataRequest.LocationPoint> data);
}

View File

@@ -34,7 +34,6 @@ public class PlaceService {
SignificantPlace place = placeOpt.get();
// Security check: ensure the place belongs to the current user
if (!place.getUser().getId().equals(currentUser.getId())) {
throw new AccessDeniedException("You don't have permission to update this place");
}

View File

@@ -21,8 +21,9 @@ public class QueueStatsService {
private final List<String> QUEUES = List.of(
RabbitMQConfig.LOCATION_DATA_QUEUE,
RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE,
RabbitMQConfig.STAY_DETECTION_QUEUE,
RabbitMQConfig.MERGE_VISIT_QUEUE,
RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE,
RabbitMQConfig.DETECT_TRIP_QUEUE);
@Autowired

View File

@@ -1,36 +0,0 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.model.User;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class RabbitMQImportDispatcher implements ImportListener{
private final RabbitTemplate rabbitTemplate;
public RabbitMQImportDispatcher(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
@Override
public void handleImport(User user, List<LocationDataRequest.LocationPoint> data) {
// Create and publish event to RabbitMQ
LocationDataEvent event = new LocationDataEvent(
user.getUsername(),
new ArrayList<>(data) // Create a copy to avoid reference issues
);
rabbitTemplate.convertAndSend(
RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.LOCATION_DATA_ROUTING_KEY,
event
);
}
}

View File

@@ -1,4 +1,4 @@
package com.dedicatedcode.reitti.service;
package com.dedicatedcode.reitti.service.geocoding;
import com.dedicatedcode.reitti.model.GeocodeService;
import com.dedicatedcode.reitti.repository.GeocodeServiceRepository;
@@ -15,23 +15,22 @@ import org.springframework.web.client.RestTemplate;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class GeocodeServiceManager {
public class DefaultGeocodeServiceManager implements GeocodeServiceManager {
private static final Logger logger = LoggerFactory.getLogger(GeocodeServiceManager.class);
private static final Logger logger = LoggerFactory.getLogger(DefaultGeocodeServiceManager.class);
private final GeocodeServiceRepository geocodeServiceRepository;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final int maxErrors;
public GeocodeServiceManager(GeocodeServiceRepository geocodeServiceRepository,
RestTemplate restTemplate,
ObjectMapper objectMapper,
@Value("${reitti.geocoding.max-errors}") int maxErrors) {
public DefaultGeocodeServiceManager(GeocodeServiceRepository geocodeServiceRepository,
RestTemplate restTemplate,
ObjectMapper objectMapper,
@Value("${reitti.geocoding.max-errors}") int maxErrors) {
this.geocodeServiceRepository = geocodeServiceRepository;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
@@ -39,6 +38,7 @@ public class GeocodeServiceManager {
}
@Transactional
@Override
public Optional<GeocodeResult> reverseGeocode(double latitude, double longitude) {
List<GeocodeService> availableServices = geocodeServiceRepository.findByEnabledTrueOrderByLastUsedAsc();

View File

@@ -1,4 +1,4 @@
package com.dedicatedcode.reitti.service;
package com.dedicatedcode.reitti.service.geocoding;
public record GeocodeResult(String label, String street, String city, String district) {
}

View File

@@ -0,0 +1,10 @@
package com.dedicatedcode.reitti.service.geocoding;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
public interface GeocodeServiceManager {
@Transactional
Optional<GeocodeResult> reverseGeocode(double latitude, double longitude);
}

View File

@@ -1,21 +1,14 @@
package com.dedicatedcode.reitti.service.geocoding;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
import com.dedicatedcode.reitti.model.SignificantPlace;
import com.dedicatedcode.reitti.repository.SignificantPlaceRepository;
import com.dedicatedcode.reitti.service.GeocodeResult;
import com.dedicatedcode.reitti.service.GeocodeServiceManager;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import java.util.Optional;

View File

@@ -1,4 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
public record GeoPoint(double latitude, double longitude) {
}

View File

@@ -0,0 +1,55 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class LocationDataIngestPipeline {
private static final Logger logger = LoggerFactory.getLogger(LocationDataIngestPipeline.class);
private final UserRepository userRepository;
private final LocationDataService locationDataService;
@Autowired
public LocationDataIngestPipeline(
UserRepository userRepository,
LocationDataService locationDataService) {
this.userRepository = userRepository;
this.locationDataService = locationDataService;
}
@RabbitListener(queues = RabbitMQConfig.LOCATION_DATA_QUEUE, concurrency = "4-16")
public void processLocationData(LocationDataEvent event) {
logger.debug("Starting processing pipeline for user {} with {} points",
event.getUsername(), event.getPoints().size());
Optional<User> userOpt = userRepository.findByUsername(event.getUsername());
if (userOpt.isEmpty()) {
logger.warn("User not found for name: {}", event.getUsername());
return;
}
User user = userOpt.get();
List<RawLocationPoint> savedPoints = locationDataService.processLocationData(user, event.getPoints());
if (savedPoints.isEmpty()) {
logger.debug("No new points to process for user {}", user.getUsername());
} else {
logger.info("Saved {} new location points for user {}", savedPoints.size(), user.getUsername());
}
}
}

View File

@@ -1,14 +1,15 @@
package com.dedicatedcode.reitti.service;
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZonedDateTime;
@@ -21,13 +22,14 @@ public class LocationDataService {
private static final Logger logger = LoggerFactory.getLogger(LocationDataService.class);
private final RawLocationPointRepository rawLocationPointRepository;
private final GeometryFactory geometryFactory;
@Autowired
public LocationDataService(RawLocationPointRepository rawLocationPointRepository) {
public LocationDataService(RawLocationPointRepository rawLocationPointRepository,
GeometryFactory geometryFactory) {
this.rawLocationPointRepository = rawLocationPointRepository;
this.geometryFactory = geometryFactory;
}
@Transactional
public List<RawLocationPoint> processLocationData(User user, List<LocationDataRequest.LocationPoint> points) {
List<RawLocationPoint> savedPoints = new ArrayList<>();
int duplicatesSkipped = 0;
@@ -46,13 +48,12 @@ public class LocationDataService {
}
if (duplicatesSkipped > 0) {
logger.info("Skipped {} duplicate points for user {}", duplicatesSkipped, user.getUsername());
logger.debug("Skipped {} duplicate points for user {}", duplicatesSkipped, user.getUsername());
}
return savedPoints;
}
@Transactional
public Optional<RawLocationPoint> processSingleLocationPoint(User user, LocationDataRequest.LocationPoint point) {
ZonedDateTime parse = ZonedDateTime.parse(point.getTimestamp());
Instant timestamp = parse.toInstant();
@@ -67,8 +68,7 @@ public class LocationDataService {
RawLocationPoint locationPoint = new RawLocationPoint();
locationPoint.setUser(user);
locationPoint.setLatitude(point.getLatitude());
locationPoint.setLongitude(point.getLongitude());
locationPoint.setGeom(geometryFactory.createPoint(new Coordinate(point.getLongitude(), point.getLatitude())));
locationPoint.setTimestamp(timestamp);
locationPoint.setAccuracyMeters(point.getAccuracyMeters());
locationPoint.setActivityProvided(point.getActivity());

View File

@@ -1,91 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.LocationDataEvent;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.service.LocationDataService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitMessageOperations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
@Service
public class LocationProcessingPipeline {
private static final Logger logger = LoggerFactory.getLogger(LocationProcessingPipeline.class);
private final UserRepository userRepository;
private final LocationDataService locationDataService;
private final StayPointDetectionService stayPointDetectionService;
private final VisitService visitService;
private final RabbitMessageOperations rabbitTemplate;
private final int tripVisitMergeTimeRange;
@Autowired
public LocationProcessingPipeline(
UserRepository userRepository,
LocationDataService locationDataService,
StayPointDetectionService stayPointDetectionService,
VisitService visitService,
RabbitMessageOperations rabbitTemplate,
@Value("${reitti.process-visits-trips.merge-time-range:1}") int tripVisitMergeTimeRange) {
this.userRepository = userRepository;
this.locationDataService = locationDataService;
this.stayPointDetectionService = stayPointDetectionService;
this.visitService = visitService;
this.rabbitTemplate = rabbitTemplate;
this.tripVisitMergeTimeRange = tripVisitMergeTimeRange;
}
@RabbitListener(queues = RabbitMQConfig.LOCATION_DATA_QUEUE, concurrency = "4-16")
public void processLocationData(LocationDataEvent event) {
logger.debug("Starting processing pipeline for user {} with {} points",
event.getUsername(), event.getPoints().size());
Optional<User> userOpt = userRepository.findByUsername(event.getUsername());
if (userOpt.isEmpty()) {
logger.warn("User not found for name: {}", event.getUsername ());
return;
}
User user = userOpt.get();
// Step 1: Save raw location points (with duplicate checking)
List<RawLocationPoint> savedPoints = locationDataService.processLocationData(user, event.getPoints());
if (savedPoints.isEmpty()) {
logger.debug("No new points to process for user {}", user.getUsername());
return;
}
logger.info("Saved {} new location points for user {}", savedPoints.size(), user.getUsername());
// Step 2: Detect stay points from the new data
List<StayPoint> stayPoints = stayPointDetectionService.detectStayPoints(user, savedPoints);
if (!stayPoints.isEmpty()) {
logger.trace("Detected {} stay points", stayPoints.size());
visitService.processStayPoints(user, stayPoints);
Instant startTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).min(Instant::compareTo).orElse(Instant.now());
Instant endTime = savedPoints.stream().map(RawLocationPoint::getTimestamp).max(Instant::compareTo).orElse(Instant.now());
long searchStart = startTime.minus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
long searchEnd = endTime.plus(tripVisitMergeTimeRange, ChronoUnit.DAYS).toEpochMilli();
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), searchStart, searchEnd));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), searchStart, searchEnd));
}
logger.info("Completed processing pipeline for user {}", user.getUsername());
}
}

View File

@@ -0,0 +1,59 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.LocationProcessEvent;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
public class RawLocationPointProcessingTrigger {
private static final Logger log = LoggerFactory.getLogger(RawLocationPointProcessingTrigger.class);
private static final int BATCH_SIZE = 100;
private final RawLocationPointRepository rawLocationPointRepository;
private final UserRepository userRepository;
private final RabbitTemplate rabbitTemplate;
public RawLocationPointProcessingTrigger(RawLocationPointRepository rawLocationPointRepository,
UserRepository userRepository, RabbitTemplate rabbitTemplate) {
this.rawLocationPointRepository = rawLocationPointRepository;
this.userRepository = userRepository;
this.rabbitTemplate = rabbitTemplate;
}
@Scheduled(cron = "${reitti.process-data.schedule}")
public void start() {
for (User user : userRepository.findAll()) {
List<RawLocationPoint> allUnprocessedPoints = rawLocationPointRepository.findByUserAndProcessedIsFalseOrderByTimestamp(user);
log.debug("Found [{}] unprocessed points for user [{}]", allUnprocessedPoints.size(), user.getId());
int i = 0;
while (i * BATCH_SIZE < allUnprocessedPoints.size()) {
int fromIndex = i * BATCH_SIZE;
int toIndex = Math.min((i + 1) * BATCH_SIZE, allUnprocessedPoints.size());
List<RawLocationPoint> currentPoints = allUnprocessedPoints.subList(fromIndex, toIndex);
Instant earliest = currentPoints.getFirst().getTimestamp();
Instant latest = currentPoints.getLast().getTimestamp();
this.rabbitTemplate
.convertAndSend(RabbitMQConfig.EXCHANGE_NAME,
RabbitMQConfig.STAY_DETECTION_ROUTING_KEY,
new LocationProcessEvent(user.getUsername(), earliest, latest));
currentPoints.forEach(RawLocationPoint::markProcessed);
this.rawLocationPointRepository.saveAll(currentPoints);
i++;
}
}
}
}

View File

@@ -1,171 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.model.GeoUtils;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class StayPointDetectionService {
private static final Logger logger = LoggerFactory.getLogger(StayPointDetectionService.class);
// Parameters for stay point detection
private final double distanceThreshold; // meters
private final long timeThreshold; // seconds
private final int minPointsInCluster; // Minimum points to form a valid cluster
private final RawLocationPointRepository rawLocationPointRepository;
@Autowired
public StayPointDetectionService(
RawLocationPointRepository rawLocationPointRepository,
@Value("${reitti.staypoint.distance-threshold-meters:50}") double distanceThreshold,
@Value("${reitti.staypoint.time-threshold-seconds:1200}") long timeThreshold,
@Value("${reitti.staypoint.min-points:5}") int minPointsInCluster) {
this.rawLocationPointRepository = rawLocationPointRepository;
this.distanceThreshold = distanceThreshold;
this.timeThreshold = timeThreshold;
this.minPointsInCluster = minPointsInCluster;
logger.info("StayPointDetectionService initialized with: distanceThreshold={}m, timeThreshold={}s, minPointsInCluster={}",
distanceThreshold, timeThreshold, minPointsInCluster);
}
@Transactional(readOnly = true)
public List<StayPoint> detectStayPoints(User user, List<RawLocationPoint> newPoints) {
logger.info("Detecting stay points for user {} with {} new points", user.getUsername(), newPoints.size());
// Get a window of points around the new points to ensure continuity
Optional<Instant> earliestNewPoint = newPoints.stream()
.map(RawLocationPoint::getTimestamp)
.min(Instant::compareTo);
Optional<Instant> latestNewPoint = newPoints.stream()
.map(RawLocationPoint::getTimestamp)
.max(Instant::compareTo);
if (earliestNewPoint.isPresent() && latestNewPoint.isPresent()) {
// Get points from 1 hour before the earliest new point
Instant windowStart = earliestNewPoint.get().minus(Duration.ofHours(1));
// Get points from 1 hour after the latest new point
Instant windowEnd = latestNewPoint.get().plus(Duration.ofHours(1));
List<RawLocationPoint> pointsInWindow = rawLocationPointRepository
.findByUserAndTimestampBetweenOrderByTimestampAsc(user, windowStart, windowEnd);
logger.info("Found {} points in the processing window", pointsInWindow.size());
// Apply the stay point detection algorithm
List<StayPoint> stayPoints = detectStayPointsFromTrajectory(pointsInWindow);
logger.info("Detected {} stay points", stayPoints.size());
return stayPoints;
}
return Collections.emptyList();
}
private List<StayPoint> detectStayPointsFromTrajectory(List<RawLocationPoint> points) {
if (points.size() < minPointsInCluster) {
return Collections.emptyList();
}
logger.info("Starting cluster-based stay point detection with {} points", points.size());
List<List<RawLocationPoint>> clusters = new ArrayList<>();
List<RawLocationPoint> currentCluster = new ArrayList<>();
RawLocationPoint currentPoint = null;
GeoPoint currentCenter = null;
for (RawLocationPoint point : points) {
if (currentPoint == null) {
currentPoint = point;
currentCluster.add(point);
currentCenter = new GeoPoint(point.getLatitude(), point.getLongitude());
} else if (GeoUtils.distanceInMeters(currentCenter.latitude(), currentCenter.longitude(), point.getLatitude(), point.getLongitude()) <= distanceThreshold) {
currentCluster.add(point);
currentCenter = weightedCenter(currentCluster);
} else {
clusters.add(currentCluster);
currentCluster = new ArrayList<>();
currentCluster.add(point);
currentPoint = point;
currentCenter = new GeoPoint(point.getLatitude(), point.getLongitude());
}
}
clusters.add(currentCluster);
logger.info("Created {} initial spatial clusters", clusters.size());
List<List<RawLocationPoint>> validClusters = filterClustersByTimeThreshold(clusters);
logger.info("Found {} valid clusters after time threshold filtering", validClusters.size());
// Step 3: Convert valid clusters to stay points
return validClusters.stream()
.map(this::createStayPoint)
.collect(Collectors.toList());
}
private List<List<RawLocationPoint>> filterClustersByTimeThreshold(List<List<RawLocationPoint>> clusters) {
List<List<RawLocationPoint>> validClusters = new ArrayList<>();
for (List<RawLocationPoint> cluster : clusters) {
// Calculate the total time span of the cluster
Instant firstTimestamp = cluster.getFirst().getTimestamp();
Instant lastTimestamp = cluster.getLast().getTimestamp();
long timeSpanSeconds = Duration.between(firstTimestamp, lastTimestamp).getSeconds();
if (timeSpanSeconds >= timeThreshold) {
validClusters.add(cluster);
}
}
return validClusters;
}
private StayPoint createStayPoint(List<RawLocationPoint> clusterPoints) {
GeoPoint result = weightedCenter(clusterPoints);
// Get the time range
Instant arrivalTime = clusterPoints.getFirst().getTimestamp();
Instant departureTime = clusterPoints.getLast().getTimestamp();
return new StayPoint(result.latitude(), result.longitude(), arrivalTime, departureTime, clusterPoints);
}
private static GeoPoint weightedCenter(List<RawLocationPoint> clusterPoints) {
// Calculate the centroid of the cluster using weighted average based on accuracy
// Points with better accuracy (lower meters value) get higher weight
double weightSum = 0;
double weightedLatSum = 0;
double weightedLngSum = 0;
for (RawLocationPoint point : clusterPoints) {
// Use inverse of accuracy as weight (higher accuracy = higher weight)
double weight = point.getAccuracyMeters() != null && point.getAccuracyMeters() > 0
? 1.0 / point.getAccuracyMeters()
: 1.0; // default weight if accuracy is null or zero
weightSum += weight;
weightedLatSum += point.getLatitude() * weight;
weightedLngSum += point.getLongitude() * weight;
}
double latCentroid = weightedLatSum / weightSum;
double lngCentroid = weightedLngSum / weightSum;
return new GeoPoint(latCentroid, lngCentroid);
}
}

View File

@@ -1,7 +1,7 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.event.ProcessedVisitCreatedEvent;
import com.dedicatedcode.reitti.model.*;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
@@ -11,176 +11,161 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
@Service
public class TripDetectionService {
private static final Logger logger = LoggerFactory.getLogger(TripDetectionService.class);
private final ProcessedVisitRepository processedVisitRepository;
private final RawLocationPointRepository rawLocationPointRepository;
private final TripRepository tripRepository;
private final UserRepository userRepository;
public TripDetectionService(ProcessedVisitRepository processedVisitRepository,
RawLocationPointRepository rawLocationPointRepository,
TripRepository tripRepository,
UserRepository userRepository) {
RawLocationPointRepository rawLocationPointRepository,
TripRepository tripRepository,
UserRepository userRepository) {
this.processedVisitRepository = processedVisitRepository;
this.rawLocationPointRepository = rawLocationPointRepository;
this.tripRepository = tripRepository;
this.userRepository = userRepository;
}
@Transactional
@RabbitListener(queues = RabbitMQConfig.DETECT_TRIP_QUEUE, concurrency = "1-16")
public void detectTripsForUser(MergeVisitEvent event) {
Optional<User> user = userRepository.findByUsername(event.getUserName());
if (user.isEmpty()) {
logger.warn("User not found for userName: {}", event.getUserName());
return;
}
logger.info("Detecting trips for user: {}", user.get().getUsername());
// Get all processed visits for the user, sorted by start time
List<ProcessedVisit> visits;
if (event.getStartTime() == null || event.getEndTime() == null) {
visits = processedVisitRepository.findByUser(user.orElse(null));
} else {
visits = processedVisitRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(user.orElse(null), Instant.ofEpochMilli(event.getStartTime()), Instant.ofEpochMilli(event.getEndTime()));
}
findDetectedTrips(user.orElse(null), visits);
}
private List<Trip> findDetectedTrips(User user, List<ProcessedVisit> visits) {
visits.sort(Comparator.comparing(ProcessedVisit::getStartTime));
@RabbitListener(queues = RabbitMQConfig.DETECT_TRIP_QUEUE, concurrency = "1")
public void visitCreated(ProcessedVisitCreatedEvent event) {
User user = this.userRepository.findByUsername(event.getUsername()).orElseThrow();
if (visits.size() < 2) {
logger.info("Not enough visits to detect trips for user: {}", user.getUsername());
return new ArrayList<>();
}
Optional<ProcessedVisit> createdVisit = this.processedVisitRepository.findByUserAndId(user, event.getVisitId());
createdVisit.ifPresent(visit -> {
//find visits in timerange
Instant searchStart = visit.getStartTime().minus(1, ChronoUnit.DAYS);
Instant searchEnd = visit.getEndTime().plus(1, ChronoUnit.DAYS);
List<Trip> detectedTrips = new ArrayList<>();
List<ProcessedVisit> visits = this.processedVisitRepository.findByUserAndTimeOverlap(user, searchStart, searchEnd);
// Iterate through consecutive visits to detect trips
for (int i = 0; i < visits.size() - 1; i++) {
ProcessedVisit startVisit = visits.get(i);
ProcessedVisit endVisit = visits.get(i + 1);
visits.sort(Comparator.comparing(ProcessedVisit::getStartTime));
// Create a trip between these two visits
Trip trip = createTripBetweenVisits(user, startVisit, endVisit);
if (trip != null) {
detectedTrips.add(trip);
if (visits.size() < 2) {
logger.info("Not enough visits to detect trips for user: {}", user.getUsername());
return;
}
}
logger.info("Detected {} trips for user: {}", detectedTrips.size(), user.getUsername());
return detectedTrips;
// Iterate through consecutive visits to detect trips
for (int i = 0; i < visits.size() - 1; i++) {
ProcessedVisit startVisit = visits.get(i);
ProcessedVisit endVisit = visits.get(i + 1);
// Create a trip between these two visits
createTripBetweenVisits(user, startVisit, endVisit);
}
});
}
private Trip createTripBetweenVisits(User user, ProcessedVisit startVisit, ProcessedVisit endVisit) {
private void createTripBetweenVisits(User user, ProcessedVisit startVisit, ProcessedVisit endVisit) {
// Trip starts when the first visit ends
Instant tripStartTime = startVisit.getEndTime();
// Trip ends when the second visit starts
Instant tripEndTime = endVisit.getStartTime();
if (this.processedVisitRepository.findById(startVisit.getId()).isEmpty() || this.processedVisitRepository.findById(endVisit.getId()).isEmpty()) {
logger.debug("One of the following visits [{},{}] where already deleted. Will skip trip creation.", startVisit.getId(), endVisit.getId());
return;
}
// If end time is before or equal to start time, this is not a valid trip
if (tripEndTime.isBefore(tripStartTime) || tripEndTime.equals(tripStartTime)) {
logger.debug("Invalid trip time range detected for user {}: {} to {}",
logger.warn("Invalid trip time range detected for user {}: {} to {}",
user.getUsername(), tripStartTime, tripEndTime);
return null;
return;
}
// Check if a trip already exists with the same start and end times
if (tripRepository.existsByUserAndStartTimeAndEndTime(user, tripStartTime, tripEndTime)) {
logger.debug("Trip already exists for user {} from {} to {}",
logger.debug("Trip already exists for user {} from {} to {}",
user.getUsername(), tripStartTime, tripEndTime);
return null;
return;
}
if (tripRepository.existsByUserAndStartPlaceAndEndPlaceAndStartTimeAndEndTime(user, startVisit.getPlace(), endVisit.getPlace(), tripStartTime, tripEndTime)) {
logger.debug("Duplicated trip detected, will not store it");
return;
}
// Get location points between the two visits
List<RawLocationPoint> tripPoints = rawLocationPointRepository
.findByUserAndTimestampBetweenOrderByTimestampAsc(
user, tripStartTime, tripEndTime);
// Create a new trip
Trip trip = new Trip();
trip.setUser(user);
trip.setStartTime(tripStartTime);
trip.setEndTime(tripEndTime);
// Set start and end places
trip.setStartPlace(startVisit.getPlace());
trip.setEndPlace(endVisit.getPlace());
// Calculate estimated distance (straight-line distance between places)
double distanceMeters = calculateDistanceBetweenPlaces(
startVisit.getPlace(), endVisit.getPlace());
trip.setEstimatedDistanceMeters(distanceMeters);
double estimatedDistanceInMeters = calculateDistanceBetweenPlaces(startVisit.getPlace(), endVisit.getPlace());
trip.setEstimatedDistanceMeters(estimatedDistanceInMeters);
// Calculate travelled distance (sum of distances between consecutive points)
double travelledDistanceMeters = calculateTripDistance(tripPoints);
double travelledDistanceMeters = GeoUtils.calculateTripDistance(tripPoints);
trip.setTravelledDistanceMeters(travelledDistanceMeters);
// Infer transport mode based on speed and distance
// Use travelled distance if available, otherwise use estimated distance
double distanceForSpeed = travelledDistanceMeters > 0 ? travelledDistanceMeters : distanceMeters;
String transportMode = inferTransportMode(distanceForSpeed, tripStartTime, tripEndTime);
String transportMode = inferTransportMode(travelledDistanceMeters != 0 ? travelledDistanceMeters : estimatedDistanceInMeters, tripStartTime, tripEndTime);
trip.setTransportModeInferred(transportMode);
logger.debug("Created trip from {} to {}: estimated distance={}m, travelled distance={}m, mode={}",
startVisit.getPlace().getName(), endVisit.getPlace().getName(),
Math.round(distanceMeters), Math.round(travelledDistanceMeters), transportMode);
trip.setStartVisit(startVisit);
trip.setEndVisit(endVisit);
logger.debug("Created trip from {} to {}: travelled distance={}m, mode={}",
startVisit.getPlace().getName(), endVisit.getPlace().getName(), Math.round(travelledDistanceMeters), transportMode);
// Save and return the trip
return tripRepository.save(trip);
try {
if (this.processedVisitRepository.existsById(trip.getStartVisit().getId()) && this.processedVisitRepository.existsById(trip.getEndVisit().getId())) {
try {
tripRepository.save(trip);
} catch (Exception e) {
logger.warn("Could not save trip.");
}
}
} catch (Exception e) {
logger.debug("Duplicated trip: [{}] detected. Will not store it.", trip);
}
}
private double calculateDistanceBetweenPlaces(SignificantPlace place1, SignificantPlace place2) {
return GeoUtils.distanceInMeters(
place1.getLatitudeCentroid(), place1.getLongitudeCentroid(),
place2.getLatitudeCentroid(), place2.getLongitudeCentroid());
}
private double calculateTripDistance(List<RawLocationPoint> points) {
if (points.size() < 2) {
return 0.0;
}
double totalDistance = 0.0;
for (int i = 0; i < points.size() - 1; i++) {
RawLocationPoint p1 = points.get(i);
RawLocationPoint p2 = points.get(i + 1);
totalDistance += GeoUtils.distanceInMeters(p1, p2);
}
return totalDistance;
}
private String inferTransportMode(double distanceMeters, Instant startTime, Instant endTime) {
// Calculate duration in seconds
long durationSeconds = endTime.getEpochSecond() - startTime.getEpochSecond();
// Avoid division by zero
if (durationSeconds <= 0) {
return "UNKNOWN";
}
// Calculate speed in meters per second
double speedMps = distanceMeters / durationSeconds;
// Convert to km/h for easier interpretation
double speedKmh = speedMps * 3.6;
// Simple transport mode inference based on average speed
if (speedKmh < 7) {
return "WALKING";
@@ -192,17 +177,4 @@ public class TripDetectionService {
return "TRANSIT"; // High-speed transit like train
}
}
@Transactional
public void clearTrips(User user) {
List<Trip> userTrips = tripRepository.findByUser(user);
tripRepository.deleteAll(userTrips);
logger.info("Cleared {} trips for user: {}", userTrips.size(), user.getUsername());
}
@Transactional
public void clearAllTrips() {
tripRepository.deleteAll();
logger.info("Cleared all trips");
}
}

View File

@@ -1,210 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.GeoUtils;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.Trip;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.TripRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
@Service
public class TripMergingService {
private static final Logger logger = LoggerFactory.getLogger(TripMergingService.class);
private final TripRepository tripRepository;
private final UserRepository userRepository;
private final RawLocationPointRepository rawLocationPointRepository;
@Autowired
public TripMergingService(TripRepository tripRepository,
UserRepository userRepository,
RawLocationPointRepository rawLocationPointRepository) {
this.tripRepository = tripRepository;
this.userRepository = userRepository;
this.rawLocationPointRepository = rawLocationPointRepository;
}
@Transactional
@RabbitListener(queues = RabbitMQConfig.MERGE_TRIP_QUEUE)
public void mergeDuplicateTripsForUser(MergeVisitEvent event) {
Optional<User> user = userRepository.findByUsername(event.getUserName());
if (user.isEmpty()) {
logger.warn("User not found for userName: {}", event.getUserName());
return;
}
logger.info("Merging duplicate trips for user: {}", user.get().getUsername());
// Get all trips for the user
List<Trip> allTrips;
if (event.getStartTime() == null || event.getEndTime() == null) {
allTrips = tripRepository.findByUser(user.orElse(null));
} else {
allTrips = tripRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(user.orElse(null), Instant.ofEpochMilli(event.getStartTime()).minus(1, ChronoUnit.DAYS), Instant.ofEpochMilli(event.getEndTime()).plus(1, ChronoUnit.DAYS));
}
mergeTrips(user.orElse(null), allTrips, true);
mergeTrips(user.orElse(null), allTrips, false);
}
private void mergeTrips(User user, List<Trip> allTrips, boolean withStart) {
if (allTrips.isEmpty()) {
logger.info("No trips found for user: {}", user.getUsername());
return;
}
// Group trips by start place, end place, and similar time range
Map<String, List<Trip>> tripGroups = groupSimilarTrips(allTrips, withStart);
// Process each group to merge duplicates
List<Trip> tripsToDelete = new ArrayList<>();
for (List<Trip> tripGroup : tripGroups.values()) {
if (tripGroup.size() > 1) {
mergeTrips(tripGroup, user);
tripsToDelete.addAll(tripGroup);
}
}
// Delete the original trips that were merged
if (!tripsToDelete.isEmpty()) {
tripRepository.deleteAll(tripsToDelete);
logger.info("Deleted {} duplicate trips for user: {}", tripsToDelete.size(), user.getUsername());
}
}
private Map<String, List<Trip>> groupSimilarTrips(List<Trip> trips, boolean withStart) {
Map<String, List<Trip>> tripGroups = new HashMap<>();
for (Trip trip : trips) {
// Create a key based on start place, end place, and approximate time
// We use minute precision for time to allow for small differences
String key = createTripGroupKey(trip, withStart);
tripGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(trip);
}
return tripGroups;
}
private String createTripGroupKey(Trip trip, boolean withStart) {
// Create a key that identifies similar trips
// Format: userId_startPlaceId_endPlaceId_startTimeMinute_endTimeMinute
long timeKey = withStart ? trip.getStartTime().getEpochSecond() / 60 : trip.getEndTime().getEpochSecond() / 60;
Long startPlaceId = trip.getStartPlace() != null ? trip.getStartPlace().getId() : 0;
Long endPlaceId = trip.getEndPlace() != null ? trip.getEndPlace().getId() : 0;
return String.format("%d_%d_%d_%d",
trip.getUser().getId(),
startPlaceId,
endPlaceId,
timeKey);
}
private Trip mergeTrips(List<Trip> trips, User user) {
// Use the first trip as a base
Trip baseTrip = trips.getFirst();
// Find the earliest start time and latest end time
Instant earliestStart = baseTrip.getStartTime();
Instant latestEnd = baseTrip.getEndTime();
for (Trip trip : trips) {
if (trip.getStartTime().isBefore(earliestStart)) {
earliestStart = trip.getStartTime();
}
if (trip.getEndTime().isAfter(latestEnd)) {
latestEnd = trip.getEndTime();
}
}
// Create a new merged trip
Trip mergedTrip = new Trip();
mergedTrip.setUser(user);
mergedTrip.setStartPlace(baseTrip.getStartPlace());
mergedTrip.setEndPlace(baseTrip.getEndPlace());
mergedTrip.setStartTime(earliestStart);
mergedTrip.setEndTime(latestEnd);
// Recalculate distance based on raw location points
recalculateDistance(mergedTrip);
// Set transport mode (use the most common one from the trips)
mergedTrip.setTransportModeInferred(getMostCommonTransportMode(trips));
if (tripRepository.existsByUserAndStartPlaceAndEndPlaceAndStartTimeAndEndTime(user, baseTrip.getStartPlace(), baseTrip.getEndPlace(), earliestStart, latestEnd)) {
logger.warn("Duplicate trip found for user: {}, will ignore it.", user.getUsername());
return mergedTrip;
} else {
return tripRepository.save(mergedTrip);
}
}
private void recalculateDistance(Trip trip) {
// Get all raw location points for this user within the trip's time range
List<RawLocationPoint> points = rawLocationPointRepository.findByUserAndTimestampBetweenOrderByTimestampAsc(
trip.getUser(), trip.getStartTime(), trip.getEndTime());
if (points.size() < 2) {
// Not enough points to calculate distance
trip.setTravelledDistanceMeters(0.0);
return;
}
// Calculate total distance
double totalDistance = 0.0;
for (int i = 0; i < points.size() - 1; i++) {
RawLocationPoint p1 = points.get(i);
RawLocationPoint p2 = points.get(i + 1);
double distance = GeoUtils.distanceInMeters(
p1.getLatitude(), p1.getLongitude(),
p2.getLatitude(), p2.getLongitude());
totalDistance += distance;
}
trip.setTravelledDistanceMeters(totalDistance);
// Also update the estimated distance
if (trip.getStartPlace() != null && trip.getEndPlace() != null) {
double directDistance = GeoUtils.distanceInMeters(
trip.getStartPlace().getLatitudeCentroid(), trip.getStartPlace().getLongitudeCentroid(),
trip.getEndPlace().getLatitudeCentroid(), trip.getEndPlace().getLongitudeCentroid());
trip.setEstimatedDistanceMeters(directDistance);
} else {
trip.setEstimatedDistanceMeters(totalDistance);
}
}
private String getMostCommonTransportMode(List<Trip> trips) {
Map<String, Integer> modeCounts = new HashMap<>();
for (Trip trip : trips) {
String mode = trip.getTransportModeInferred();
if (mode != null) {
modeCounts.put(mode, modeCounts.getOrDefault(mode, 0) + 1);
}
}
// Find the mode with the highest count
return modeCounts.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("UNKNOWN");
}
}

View File

@@ -0,0 +1,220 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.LocationProcessEvent;
import com.dedicatedcode.reitti.event.VisitCreatedEvent;
import com.dedicatedcode.reitti.event.VisitUpdatedEvent;
import com.dedicatedcode.reitti.model.*;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.VisitRepository;
import com.dedicatedcode.reitti.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class VisitDetectionService {
private static final Logger logger = LoggerFactory.getLogger(VisitDetectionService.class);
// Parameters for stay point detection
private final double distanceThreshold; // meters
private final long timeThreshold; // seconds
private final int minPointsInCluster; // Minimum points to form a valid cluster
private final UserService userService;
private final RawLocationPointRepository rawLocationPointRepository;
private final VisitRepository visitRepository;
private final RabbitTemplate rabbitTemplate;
@Autowired
public VisitDetectionService(
RawLocationPointRepository rawLocationPointRepository,
@Value("${reitti.staypoint.distance-threshold-meters:50}") double distanceThreshold,
@Value("${reitti.visit.merge-threshold-seconds:300}") long timeThreshold,
@Value("${reitti.staypoint.min-points:5}") int minPointsInCluster,
UserService userService,
VisitRepository visitRepository,
RabbitTemplate rabbitTemplate) {
this.rawLocationPointRepository = rawLocationPointRepository;
this.distanceThreshold = distanceThreshold;
this.timeThreshold = timeThreshold;
this.minPointsInCluster = minPointsInCluster;
this.userService = userService;
this.visitRepository = visitRepository;
this.rabbitTemplate = rabbitTemplate;
logger.info("StayPointDetectionService initialized with: distanceThreshold={}m, timeThreshold={}s, minPointsInCluster={}",
distanceThreshold, timeThreshold, minPointsInCluster);
}
@RabbitListener(queues = RabbitMQConfig.STAY_DETECTION_QUEUE, concurrency = "1-16")
public void detectStayPoints(LocationProcessEvent incoming) {
logger.debug("Detecting stay points for user {} from {} to {} ", incoming.getUsername(), incoming.getEarliest(), incoming.getLatest());
User user = userService.getUserByUsername(incoming.getUsername());
// Get points from 1 day before the earliest new point
Instant windowStart = incoming.getEarliest().minus(Duration.ofDays(1));
// Get points from 1 day after the latest new point
Instant windowEnd = incoming.getLatest().plus(Duration.ofDays(1));
List<RawLocationPointRepository.ClusteredPoint> clusteredPointsInTimeRangeForUser = this.rawLocationPointRepository.findClusteredPointsInTimeRangeForUser(user, windowStart, windowEnd, minPointsInCluster, GeoUtils.metersToDegreesAtPosition(distanceThreshold, 50)[0]);
Map<Integer, List<RawLocationPoint>> clusteredByLocation = new HashMap<>();
for (RawLocationPointRepository.ClusteredPoint clusteredPoint : clusteredPointsInTimeRangeForUser) {
if (clusteredPoint.getClusterId() != null) {
clusteredByLocation.computeIfAbsent(clusteredPoint.getClusterId(), k -> new ArrayList<>()).add(clusteredPoint.getPoint());
}
}
logger.debug("Found {} point clusters in the processing window", clusteredByLocation.size());
// Apply the stay point detection algorithm
List<StayPoint> stayPoints = detectStayPointsFromTrajectory(clusteredByLocation);
logger.info("Detected {} stay points for user {}", stayPoints.size(), user.getUsername());
for (StayPoint stayPoint : stayPoints) {
Optional<Visit> existingVisitByStart = this.visitRepository.findByUserAndStartTime(user, stayPoint.getArrivalTime());
Optional<Visit> existingVisitByEnd = this.visitRepository.findByUserAndEndTime(user, stayPoint.getDepartureTime());
List<Visit> overlappingVisits = this.visitRepository.findByUserAndStartTimeBeforeAndEndTimeAfter(user, stayPoint.getDepartureTime(), stayPoint.getArrivalTime());
Set<Visit> visitsToUpdate = new HashSet<>();
existingVisitByStart.ifPresent(visitsToUpdate::add);
existingVisitByEnd.ifPresent(visitsToUpdate::add);
visitsToUpdate.addAll(overlappingVisits);
for (Visit visit : visitsToUpdate) {
boolean changed = false;
if (stayPoint.getDepartureTime().isAfter(visit.getEndTime())) {
visit.setEndTime(stayPoint.getDepartureTime());
visit.setProcessed(false);
changed = true;
}
if (stayPoint.getArrivalTime().isBefore(visit.getEndTime())) {
visit.setStartTime(stayPoint.getArrivalTime().isBefore(visit.getStartTime()) ? stayPoint.getArrivalTime() : visit.getStartTime());
visit.setProcessed(false);
changed = true;
}
if (changed) {
try {
visitRepository.save(visit);
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new VisitUpdatedEvent(user.getUsername(), visit.getId()));
} catch (Exception e) {
logger.debug("Could not save updated visit: {}", visit);
}
}
}
if (visitsToUpdate.isEmpty()) {
Visit visit = createVisit(user, stayPoint.getLongitude(), stayPoint.getLatitude(), stayPoint);
logger.debug("Creating new visit: {}", visit);
try {
visit = visitRepository.save(visit);
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new VisitCreatedEvent(user.getUsername(), visit.getId()));
} catch (Exception e) {
logger.debug("Could not save new visit: {}", visit);
}
}
}
}
private List<StayPoint> detectStayPointsFromTrajectory(Map<Integer, List<RawLocationPoint>> points) {
logger.debug("Starting cluster-based stay point detection with {} different spatial clusters.", points.size());
List<List<RawLocationPoint>> clusters = new ArrayList<>();
//split them up when time is x seconds between
for (List<RawLocationPoint> clusteredByLocation : points.values()) {
logger.debug("Start splitting up geospatial cluster with [{}] elements based on minimum time [{}]s between points", clusteredByLocation.size(), timeThreshold);
//first sort them by timestamp
clusteredByLocation.sort(Comparator.comparing(RawLocationPoint::getTimestamp));
List<RawLocationPoint> currentTimedCluster = new ArrayList<>();
clusters.add(currentTimedCluster);
currentTimedCluster.add(clusteredByLocation.getFirst());
Instant currentTime = clusteredByLocation.getFirst().getTimestamp();
for (int i = 1; i < clusteredByLocation.size(); i++) {
RawLocationPoint next = clusteredByLocation.get(i);
if (Duration.between(currentTime, next.getTimestamp()).getSeconds() < timeThreshold) {
currentTimedCluster.add(next);
} else {
currentTimedCluster = new ArrayList<>();
currentTimedCluster.add(next);
clusters.add(currentTimedCluster);
}
currentTime = next.getTimestamp();
}
}
logger.debug("Detected {} stay points after splitting them up.", clusters.size());
//filter them by duration
List<List<RawLocationPoint>> filteredByMinimumDuration = clusters.stream()
.filter(c -> Duration.between(c.getFirst().getTimestamp(), c.getLast().getTimestamp()).toSeconds() > timeThreshold)
.toList();
logger.debug("Found {} valid clusters after duration filtering", filteredByMinimumDuration.size());
// Step 3: Convert valid clusters to stay points
return filteredByMinimumDuration.stream()
.map(this::createStayPoint)
.collect(Collectors.toList());
}
private StayPoint createStayPoint(List<RawLocationPoint> clusterPoints) {
GeoPoint result = weightedCenter(clusterPoints);
// Get the time range
Instant arrivalTime = clusterPoints.getFirst().getTimestamp();
Instant departureTime = clusterPoints.getLast().getTimestamp();
return new StayPoint(result.latitude(), result.longitude(), arrivalTime, departureTime, clusterPoints);
}
private GeoPoint weightedCenter(List<RawLocationPoint> clusterPoints) {
// Calculate the centroid of the cluster using weighted average based on accuracy
// Points with better accuracy (lower meters value) get higher weight
double weightSum = 0;
double weightedLatSum = 0;
double weightedLngSum = 0;
for (RawLocationPoint point : clusterPoints) {
// Use inverse of accuracy as weight (higher accuracy = higher weight)
double weight = point.getAccuracyMeters() != null && point.getAccuracyMeters() > 0
? 1.0 / point.getAccuracyMeters()
: 1.0; // default weight if accuracy is null or zero
weightSum += weight;
weightedLatSum += point.getLatitude() * weight;
weightedLngSum += point.getLongitude() * weight;
}
double latCentroid = weightedLatSum / weightSum;
double lngCentroid = weightedLngSum / weightSum;
return new GeoPoint(latCentroid, lngCentroid);
}
private Visit createVisit(User user, Double longitude, Double latitude, StayPoint stayPoint) {
Visit visit = new Visit();
visit.setUser(user);
visit.setLongitude(longitude);
visit.setLatitude(latitude);
visit.setStartTime(stayPoint.getArrivalTime());
visit.setEndTime(stayPoint.getDepartureTime());
visit.setDurationSeconds(stayPoint.getDurationSeconds());
return visit;
}
}

View File

@@ -1,39 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.temporal.ChronoUnit;
@Component
public class VisitMergingRunner {
private static final Logger logger = LoggerFactory.getLogger(VisitMergingRunner.class);
private final UserService userService;
private final RabbitTemplate rabbitTemplate;
public VisitMergingRunner(UserService userService,
RabbitTemplate rabbitTemplate) {
this.userService = userService;
this.rabbitTemplate = rabbitTemplate;
}
@Scheduled(cron = "${reitti.process-visits-trips.schedule}")
public void run() {
userService.getAllUsers().forEach(user -> {
logger.info("Schedule visit merging process for user {}", user.getUsername());
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_VISIT_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), null, null));
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.MERGE_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), null, null));
});
}
}

View File

@@ -1,17 +1,15 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.event.ProcessedVisitCreatedEvent;
import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
import com.dedicatedcode.reitti.event.VisitCreatedEvent;
import com.dedicatedcode.reitti.event.VisitUpdatedEvent;
import com.dedicatedcode.reitti.model.*;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.SignificantPlaceRepository;
import com.dedicatedcode.reitti.repository.UserRepository;
import com.dedicatedcode.reitti.repository.VisitRepository;
import com.dedicatedcode.reitti.repository.*;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
@@ -22,6 +20,7 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@@ -30,58 +29,98 @@ public class VisitMergingService {
private static final Logger logger = LoggerFactory.getLogger(VisitMergingService.class);
private static final int SRID = 4326;
private final VisitRepository visitRepository;
private final ProcessedVisitRepository processedVisitRepository;
private final UserRepository userRepository;
private final RabbitTemplate rabbitTemplate;
private final SignificantPlaceRepository significantPlaceRepository;
private final RawLocationPointRepository rawLocationPointRepository;
private final GeometryFactory geometryFactory;
@Value("${reitti.visit.merge-threshold-seconds:300}")
private long mergeThresholdSeconds;
@Value("${reitti.detect-trips-after-merging:true}")
private boolean detectTripsAfterMerging;
@Value("${reitti.visit.merge-threshold-meters:100}")
private long mergeThresholdMeters;
@Autowired
public VisitMergingService(VisitRepository visitRepository,
ProcessedVisitRepository processedVisitRepository,
UserRepository userRepository, RabbitTemplate rabbitTemplate,
SignificantPlaceRepository significantPlaceRepository) {
UserRepository userRepository,
RabbitTemplate rabbitTemplate,
SignificantPlaceRepository significantPlaceRepository,
RawLocationPointRepository rawLocationPointRepository,
GeometryFactory geometryFactory) {
this.visitRepository = visitRepository;
this.processedVisitRepository = processedVisitRepository;
this.userRepository = userRepository;
this.rabbitTemplate = rabbitTemplate;
this.significantPlaceRepository = significantPlaceRepository;
this.geometryFactory = new GeometryFactory(new PrecisionModel(), SRID);
this.rawLocationPointRepository = rawLocationPointRepository;
this.geometryFactory = geometryFactory;
}
@RabbitListener(queues = RabbitMQConfig.MERGE_VISIT_QUEUE)
public void mergeVisits(MergeVisitEvent event) {
Optional<User> user = userRepository.findByUsername(event.getUserName());
if (user.isEmpty()) {
logger.warn("User not found for userName: {}", event.getUserName());
return;
public void visitCreated(VisitCreatedEvent event) {
try {
handleEvent(event.getUsername(), event.getVisitId());
} catch (Exception e) {
logger.error("Could not handle event: {}", event);
}
processAndMergeVisits(user.get(), event.getStartTime(), event.getEndTime());
}
private List<ProcessedVisit> processAndMergeVisits(User user, Long startTime, Long endTime) {
logger.info("Processing and merging visits for user: {}", user.getUsername());
List<Visit> allVisits;
@RabbitListener(queues = RabbitMQConfig.MERGE_VISIT_QUEUE)
public void visitUpdated(VisitUpdatedEvent event) {
try {
handleEvent(event.getUsername(), event.getVisitId());
} catch (Exception e) {
logger.debug("Could not handle event: {}", event);
}
}
// Get all unprocessed visits for the user
if (startTime == null || endTime == null) {
allVisits = this.visitRepository.findByUserAndProcessedFalse(user);
} else {
allVisits = this.visitRepository.findByUserAndStartTimeBetweenAndProcessedFalseOrderByStartTimeAsc(user, Instant.ofEpochMilli(startTime), Instant.ofEpochMilli(endTime));
private void handleEvent(String username, long visitId) {
Optional<User> user = userRepository.findByUsername(username);
if (user.isEmpty()) {
logger.warn("User not found for userName: {}", username);
return;
}
Optional<Visit> visitOpt = this.visitRepository.findById(visitId);
if (visitOpt.isEmpty()) {
logger.debug("Visit not found for visitId: {}", visitId);
return;
}
Visit visit = visitOpt.get();
Instant searchStart = visit.getStartTime().minus(1, ChronoUnit.DAYS);
Instant searchEnd = visit.getEndTime().plus(1, ChronoUnit.DAYS);
processAndMergeVisits(user.get(), searchStart.toEpochMilli(), searchEnd.toEpochMilli());
}
private void processAndMergeVisits(User user, Long start, Long end) {
logger.info("Processing and merging visits for user: {}", user.getUsername());
Instant searchStart = Instant.ofEpochMilli(start);
Instant searchEnd = Instant.ofEpochMilli(end);
List<ProcessedVisit> visitsAtStart = this.processedVisitRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(user, searchStart, searchEnd);
List<ProcessedVisit> visitsAtEnd = this.processedVisitRepository.findByUserAndEndTimeBetweenOrderByStartTimeAsc(user, searchStart, searchEnd);
if (!visitsAtStart.isEmpty()) {
searchStart = visitsAtStart.getFirst().getStartTime().minus(1, ChronoUnit.DAYS);
}
if (!visitsAtEnd.isEmpty()) {
searchEnd = visitsAtEnd.getLast().getEndTime().plus(1, ChronoUnit.DAYS);
}
logger.debug("found {} processed visits at start and {} processed visits at end, will extend search window to {} and {}", visitsAtStart.size(), visitsAtEnd.size(), searchStart, searchEnd);
List<ProcessedVisit> allProcessedVisitsInRange = this.processedVisitRepository.findByUserAndStartTimeGreaterThanEqualAndEndTimeLessThanEqual(user, searchStart, searchEnd);
this.processedVisitRepository.deleteAll(allProcessedVisitsInRange);
List<Visit> allVisits = this.visitRepository.findByUserAndStartTimeBetweenOrderByStartTimeAsc(user, searchStart, searchEnd);
if (allVisits.isEmpty()) {
logger.info("No visits found for user: {}", user.getUsername());
return Collections.emptyList();
return;
}
// Sort all visits chronologically
@@ -90,20 +129,11 @@ public class VisitMergingService {
// Process all visits chronologically to avoid overlaps
List<ProcessedVisit> processedVisits = mergeVisitsChronologically(user, allVisits);
// Mark all visits as processed
if (!allVisits.isEmpty()) {
allVisits.forEach(visit -> visit.setProcessed(true));
visitRepository.saveAll(allVisits);
logger.info("Marked {} visits as processed for user: {}", allVisits.size(), user.getUsername());
}
logger.info("Processed {} visits into {} merged visits for user: {}",
processedVisits.stream()
.sorted(Comparator.comparing(ProcessedVisit::getStartTime))
.forEach(processedVisit -> this.rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new ProcessedVisitCreatedEvent(user.getUsername(), processedVisit.getId())));
logger.debug("Processed {} visits into {} merged visits for user: {}",
allVisits.size(), processedVisits.size(), user.getUsername());
if (!processedVisits.isEmpty() && detectTripsAfterMerging) {
rabbitTemplate.convertAndSend(RabbitMQConfig.EXCHANGE_NAME, RabbitMQConfig.DETECT_TRIP_ROUTING_KEY, new MergeVisitEvent(user.getUsername(), startTime, endTime));
}
return processedVisits;
}
private List<ProcessedVisit> mergeVisitsChronologically(User user, List<Visit> visits) {
@@ -115,34 +145,48 @@ public class VisitMergingService {
// Sort visits chronologically
visits.sort(Comparator.comparing(Visit::getStartTime));
// Start with the first visit
Visit currentVisit = visits.getFirst();
Instant currentStartTime = currentVisit.getStartTime();
Instant currentEndTime = currentVisit.getEndTime();
Set<Long> mergedVisitIds = new HashSet<>();
mergedVisitIds.add(currentVisit.getId());
// Find or create a place for the first visit
List<SignificantPlace> nearbyPlaces = findNearbyPlaces(user, currentVisit.getLatitude(), currentVisit.getLongitude());
SignificantPlace currentPlace = nearbyPlaces.isEmpty() ?
createSignificantPlace(user, currentVisit) :
SignificantPlace currentPlace = nearbyPlaces.isEmpty() ?
createSignificantPlace(user, currentVisit) :
findClosestPlace(currentVisit, nearbyPlaces);
for (int i = 1; i < visits.size(); i++) {
Visit nextVisit = visits.get(i);
// Find nearby places for the next visit
nearbyPlaces = findNearbyPlaces(user, nextVisit.getLatitude(), nextVisit.getLongitude());
SignificantPlace nextPlace = nearbyPlaces.isEmpty() ?
createSignificantPlace(user, nextVisit) :
SignificantPlace nextPlace = nearbyPlaces.isEmpty() ?
createSignificantPlace(user, nextVisit) :
findClosestPlace(nextVisit, nearbyPlaces);
// Check if the next visit is at the same place and within the time threshold
boolean samePlace = nextPlace.getId().equals(currentPlace.getId());
boolean withinTimeThreshold = Duration.between(currentEndTime, nextVisit.getStartTime()).getSeconds() <= mergeThresholdSeconds;
if (samePlace && withinTimeThreshold) {
boolean shouldMergeWithNextVisit = samePlace && withinTimeThreshold;
//fluke detections
if (samePlace && !withinTimeThreshold) {
List<RawLocationPoint> pointsBetweenVisits = this.rawLocationPointRepository.findByUserAndTimestampBetweenOrderByTimestampAsc(user, currentEndTime, nextVisit.getStartTime());
if (pointsBetweenVisits.size() > 2) {
double travelledDistanceInMeters = GeoUtils.calculateTripDistance(pointsBetweenVisits);
shouldMergeWithNextVisit = travelledDistanceInMeters < mergeThresholdMeters;
} else {
logger.debug("Skipping creation of new visit because there are no points tracked between {} and {}", currentEndTime, nextVisit.getStartTime());
shouldMergeWithNextVisit = true;
}
}
if (shouldMergeWithNextVisit) {
// Merge this visit with the current one
currentEndTime = nextVisit.getEndTime().isAfter(currentEndTime) ?
nextVisit.getEndTime() : currentEndTime;
@@ -165,18 +209,19 @@ public class VisitMergingService {
// Add the last merged set
ProcessedVisit processedVisit = createProcessedVisit(user, currentPlace, currentStartTime,
currentEndTime, mergedVisitIds);
result.add(processedVisit);
return result;
}
private SignificantPlace findClosestPlace(Visit visit, List<SignificantPlace> places) {
return places.stream()
.min(Comparator.comparingDouble(place ->
GeoUtils.distanceInMeters(
visit.getLatitude(), visit.getLongitude(),
place.getLatitudeCentroid(), place.getLongitudeCentroid())))
.orElseThrow(() -> new IllegalStateException("No places found"));
.min(Comparator.comparingDouble(place ->
GeoUtils.distanceInMeters(
visit.getLatitude(), visit.getLongitude(),
place.getLatitudeCentroid(), place.getLongitudeCentroid())))
.orElseThrow(() -> new IllegalStateException("No places found"));
}
@@ -206,53 +251,20 @@ public class VisitMergingService {
}
private ProcessedVisit createProcessedVisit(User user,
SignificantPlace place,
Instant startTime, Instant endTime,
Set<Long> originalVisitIds) {
// Check if a processed visit already exists for this time range and place
List<ProcessedVisit> existingVisits = processedVisitRepository.findByUserAndPlaceAndTimeOverlap(
user, place, startTime, endTime);
SignificantPlace place,
Instant startTime, Instant endTime,
Set<Long> originalVisitIds) {
// Create a new processed visit
ProcessedVisit processedVisit = new ProcessedVisit(user, place, startTime, endTime);
processedVisit.setMergedCount(originalVisitIds.size());
if (!existingVisits.isEmpty()) {
// Use the existing processed visit
ProcessedVisit existingVisit = existingVisits.getFirst();
logger.debug("Found existing processed visit for place ID {}", place.getId());
// Store original visit IDs as comma-separated string
String visitIdsStr = originalVisitIds.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
processedVisit.setOriginalVisitIds(visitIdsStr);
// Update the existing visit if needed (e.g., extend time range)
if (startTime.isBefore(existingVisit.getStartTime())) {
existingVisit.setStartTime(startTime);
}
if (endTime.isAfter(existingVisit.getEndTime())) {
existingVisit.setEndTime(endTime);
}
// Add original visit IDs to the existing one
String existingIds = existingVisit.getOriginalVisitIds();
String newIds = originalVisitIds.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
if (existingIds == null || existingIds.isEmpty()) {
existingVisit.setOriginalVisitIds(newIds);
} else {
existingVisit.setOriginalVisitIds(existingIds + "," + newIds);
}
existingVisit.setMergedCount(existingVisit.getMergedCount() + originalVisitIds.size());
return processedVisitRepository.save(existingVisit);
} else {
// Create a new processed visit
ProcessedVisit processedVisit = new ProcessedVisit(user, place, startTime, endTime);
processedVisit.setMergedCount(originalVisitIds.size());
// Store original visit IDs as comma-separated string
String visitIdsStr = originalVisitIds.stream()
.map(Object::toString)
.collect(Collectors.joining(","));
processedVisit.setOriginalVisitIds(visitIdsStr);
return processedVisitRepository.save(processedVisit);
}
return processedVisitRepository.save(processedVisit);
}
private void publishSignificantPlaceCreatedEvent(SignificantPlace place) {

View File

@@ -1,45 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.model.Visit;
import com.dedicatedcode.reitti.repository.VisitRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class VisitService {
private static final Logger logger = LoggerFactory.getLogger(VisitService.class);
private final VisitRepository visitRepository;
@Autowired
public VisitService(
VisitRepository visitRepository) {
this.visitRepository = visitRepository;
}
public void processStayPoints(User user, List<StayPoint> stayPoints) {
logger.info("Processing {} stay points for user {}", stayPoints.size(), user.getUsername());
for (StayPoint stayPoint : stayPoints) {
Visit visit = createVisit(user, stayPoint.getLongitude(), stayPoint.getLatitude(), stayPoint);
visitRepository.save(visit);
}
}
private Visit createVisit(User user, Double longitude, Double latitude, StayPoint stayPoint) {
Visit visit = new Visit();
visit.setUser(user);
visit.setLongitude(longitude);
visit.setLatitude(latitude);
visit.setStartTime(stayPoint.getArrivalTime());
visit.setEndTime(stayPoint.getDepartureTime());
visit.setDurationSeconds(stayPoint.getDurationSeconds());
return visit;
}
}

View File

@@ -0,0 +1,4 @@
# Data management configuration
reitti.data-management.enabled=true
logging.level.com.dedicatedcode = DEBUG

View File

@@ -10,3 +10,6 @@ spring.rabbitmq.host=${RABBITMQ_HOST:rabbitmq}
spring.rabbitmq.port=${RABBITMQ_PORT:5672}
spring.rabbitmq.username=${RABBITMQ_USER:reitti}
spring.rabbitmq.password=${RABBITMQ_PASSWORD:reitti}
logging.level.root = INFO

View File

@@ -1,6 +1,11 @@
# Server configuration
server.port=8080
# Logging configuration
logging.level.root = INFO
logging.level.org.hibernate.engine.jdbc.spi.SqlExceptionHelper = FATAL
# PostgreSQL configuration (commented out for now, uncomment for production)
spring.datasource.url=jdbc:postgresql://localhost:5432/reittidb
spring.datasource.username=reitti
@@ -23,19 +28,21 @@ spring.rabbitmq.listener.simple.retry.max-attempts=3
spring.rabbitmq.listener.simple.retry.multiplier=1.5
spring.rabbitmq.listener.simple.prefetch=10
# Application specific test settings
# Application specific settings
reitti.visit.merge-threshold-seconds=300
reitti.detect-trips-after-merging=true
reitti.visit.merge-threshold-meters=50
# Stay point detection settings
reitti.staypoint.distance-threshold-meters=50
reitti.staypoint.time-threshold-seconds=300
reitti.staypoint.min-points=1
reitti.staypoint.min-points=5
reitti.process-visits-trips.schedule=0 */10 * * * *
reitti.process-data.schedule=0 */10 * * * *
# Geocoding service configuration
reitti.geocoding.max-errors=10
# Data management configuration
reitti.data-management.enabled=false
spring.servlet.multipart.max-file-size=5GB
spring.servlet.multipart.max-request-size=5GB

View File

@@ -0,0 +1,5 @@
ALTER TABLE raw_location_points ADD COLUMN geom geometry(Point, 4326);
UPDATE raw_location_points SET geom = st_setsrid(st_makepoint(longitude, latitude), 4326);
ALTER TABLE raw_location_points DROP COLUMN latitude;
ALTER TABLE raw_location_points DROP COLUMN longitude;

View File

@@ -0,0 +1,19 @@
DELETE FROM trips;
DELETE FROM raw_location_points;
DELETE FROM processed_visits;
DELETE FROM visits;
ALTER TABLE trips ADD COLUMN start_visit_id bigint not null;
ALTER TABLE trips ADD COLUMN end_visit_id bigint not null;
alter table if exists trips
add constraint FK8wb14dx6dasdasasd3planbay88u
foreign key (start_visit_id)
references processed_visits ON DELETE CASCADE ;
alter table if exists trips
add constraint FK8wb14dx6dasdasasd3planasdd12u
foreign key (end_visit_id)
references processed_visits ON DELETE CASCADE ;

View File

@@ -0,0 +1,4 @@
ALTER TABLE raw_location_points ADD COLUMN processed BOOLEAN DEFAULT false;
CREATE INDEX raw_location_points_processed
on raw_location_points (processed);

View File

@@ -0,0 +1,3 @@
ALTER TABLE visits
ADD CONSTRAINT visits_pk
UNIQUE (user_id, start_time, end_time);

View File

@@ -228,7 +228,7 @@
hx-target="#file-upload"
hx-encoding="multipart/form-data">
<div class="form-group">
<input type="file" name="file" accept=".gpx" required>
<input type="file" name="files" accept=".gpx" multiple required>
</div>
<button type="submit" class="btn">Upload GPX File</button>
</form>
@@ -266,6 +266,32 @@
});
</script>
</div>
<div class="upload-option">
<h3>GeoJSON Files</h3>
<p class="description">
Upload GeoJSON files containing Point features with location data. GeoJSON files
should contain Point geometries with coordinates and optional timestamp properties.
Supports both single Feature and FeatureCollection formats.
</p>
<form id="geojson-upload-form"
hx-post="/settings/import/geojson"
hx-target="#file-upload"
hx-swap="innerHTML"
hx-encoding="multipart/form-data">
<div class="form-group">
<input type="file" name="files" accept=".geojson,.json" multiple required>
</div>
<button type="submit" class="btn">Upload GeoJSON File</button>
</form>
<progress id='progress-geojson' value='0' max='100' style="display: none"></progress>
<script>
htmx.on('#geojson-upload-form', 'htmx:xhr:progress', function(evt) {
htmx.find('#progress-geojson').setAttribute('value', evt.detail.loaded/evt.detail.total * 100)
htmx.find('#progress-geojson').setAttribute('style', null)
});
</script>
</div>
</div>
</div>
@@ -339,6 +365,38 @@
</div>
</div>
<!-- Manage Data Content Fragment -->
<div th:fragment="manage-data-content">
<h2>Manage Data</h2>
<div th:if="${successMessage}" class="alert alert-success" style="display: block;">
<span th:text="${successMessage}">Processing started successfully</span>
</div>
<div th:if="${errorMessage}" class="alert alert-danger" style="display: block;">
<span th:text="${errorMessage}">Error message</span>
</div>
<div style="border: 1px solid #b3d9ff; border-radius: 4px; padding: 15px; margin-bottom: 20px;">
<h4>About Data Processing</h4>
<p>This section allows you to manually trigger data processing operations. These operations normally run automatically on a schedule, but you can trigger them manually here if needed.</p>
<p><strong>Warning:</strong> Manual processing may take some time depending on the amount of data to process.</p>
</div>
<div class="data-management-actions">
<div class="action-card" style="border: 1px solid #dee2e6; border-radius: 4px; padding: 15px; margin-bottom: 15px;">
<h4>Process Visits and Trips</h4>
<p>Manually trigger the processing of raw location data into visits and trips. This will analyze unprocessed location points and create meaningful visits and trips from them.</p>
<button class="btn"
hx-post="/settings/manage-data/process-visits-trips"
hx-target="#manage-data"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to start processing? This may take some time.">
Start Processing
</button>
</div>
</div>
</div>
<!-- Geocode Services Content Fragment -->
<div th:fragment="geocode-services-content">
<h2>Geocoding Services</h2>

View File

@@ -33,6 +33,7 @@
<div class="settings-nav-item" data-target="places-management">Places</div>
<div class="settings-nav-item" data-target="geocode-services">Geocoding</div>
<div class="settings-nav-item" data-target="integrations" hx-get="/settings/integrations-content" hx-target="#integrations">Integrations</div>
<div class="settings-nav-item" data-target="manage-data">Manage Data</div>
<div class="settings-nav-item" data-target="job-status">Job Status</div>
<div class="settings-nav-item" data-target="file-upload">Import Data</div>
</div>
@@ -63,6 +64,11 @@
<div class="htmx-indicator">Loading integrations...</div>
</div>
<!-- Manage Data Section -->
<div id="manage-data" class="settings-section">
<div class="htmx-indicator">Loading data management...</div>
</div>
<script>
// Function to initialize maps for place cards
function initPlaceMaps() {
@@ -228,6 +234,14 @@
htmx.process(section);
}
// If this is the manage data tab, load its content if not already loaded
if (target === 'manage-data' && !section.hasAttribute('hx-triggered')) {
section.setAttribute('hx-get', '/settings/manage-data-content');
section.setAttribute('hx-trigger', 'load once');
section.setAttribute('hx-triggered', 'true');
htmx.process(section);
}
}
});
});

View File

@@ -1,151 +0,0 @@
package com.dedicatedcode.reitti;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.*;
import com.dedicatedcode.reitti.service.ImportHandler;
import com.dedicatedcode.reitti.service.ImportListener;
import com.dedicatedcode.reitti.service.LocationDataService;
import com.dedicatedcode.reitti.service.processing.StayPoint;
import com.dedicatedcode.reitti.service.processing.StayPointDetectionService;
import com.dedicatedcode.reitti.service.processing.VisitMergingService;
import com.dedicatedcode.reitti.service.processing.VisitService;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.RabbitMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.io.InputStream;
import java.util.List;
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
@DirtiesContext
@Import(AbstractIntegrationTest.TestConfig.class)
public abstract class AbstractIntegrationTest {
@Container
static PostgreSQLContainer<?> timescaledb = new PostgreSQLContainer<>(DockerImageName.parse("postgis/postgis:17-3.5-alpine")
.asCompatibleSubstituteFor("postgres"))
.withDatabaseName("reitti")
.withUsername("test")
.withPassword("test");
@Container
static RabbitMQContainer rabbitmq = new RabbitMQContainer("rabbitmq:3-management")
.withExposedPorts(5672, 15672);
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
// Database properties
registry.add("spring.datasource.url", timescaledb::getJdbcUrl);
registry.add("spring.datasource.username", timescaledb::getUsername);
registry.add("spring.datasource.password", timescaledb::getPassword);
// RabbitMQ properties
registry.add("spring.rabbitmq.host", rabbitmq::getHost);
registry.add("spring.rabbitmq.port", rabbitmq::getAmqpPort);
registry.add("spring.rabbitmq.username", rabbitmq::getAdminUsername);
registry.add("spring.rabbitmq.password", rabbitmq::getAdminPassword);
}
@Autowired
protected UserRepository userRepository;
@Autowired
protected VisitRepository visitRepository;
@Autowired
protected ProcessedVisitRepository processedVisitRepository;
@Autowired
protected PasswordEncoder passwordEncoder;
@Autowired
protected RawLocationPointRepository rawLocationPointRepository;
@Autowired
protected SignificantPlaceRepository significantPlaceRepository;
@Autowired
protected MockImportListener importListener;
@Autowired
protected TripRepository tripsRepository;
@Autowired
private LocationDataService locationDataService;
@Autowired
private StayPointDetectionService stayPointDetectionService;
@Autowired
private VisitService visitService;
@Autowired
private ImportHandler importHandler;
@Autowired
private VisitMergingService visitMergingService;
protected User user;
@BeforeEach
void setUp() {
// Clean up repositories
tripsRepository.deleteAll();
significantPlaceRepository.deleteAll();
processedVisitRepository.deleteAll();
visitRepository.deleteAll();
rawLocationPointRepository.deleteAll();
userRepository.deleteAll();
importListener.clearAll();
// Create test user
user = new User();
user.setUsername("testuser");
user.setDisplayName("testuser");
user.setPassword(passwordEncoder.encode("password"));
user = userRepository.save(user);
}
@TestConfiguration
public static class TestConfig {
@Bean(name = "importListener")
public MockImportListener importListener() {
return new MockImportListener();
}
}
protected List<RawLocationPoint> importGpx(String filename) {
InputStream is = getClass().getResourceAsStream(filename);
importHandler.importGpx(is, user);
List<LocationDataRequest.LocationPoint> allPoints = this.importListener.getPoints();
return locationDataService.processLocationData(user, allPoints);
}
protected void importUntilVisits(String fileName) {
List<RawLocationPoint> savedPoints = importGpx(fileName);
List<StayPoint> stayPoints = stayPointDetectionService.detectStayPoints(user, savedPoints);
if (!stayPoints.isEmpty()) {
visitService.processStayPoints(user, stayPoints);
}
}
protected void importUntilProcessedVisits(String fileName) {
importUntilVisits(fileName);
visitMergingService.mergeVisits(new MergeVisitEvent(user.getUsername(), null, null));
}
}

View File

@@ -0,0 +1,19 @@
package com.dedicatedcode.reitti;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
@Import({TestContainerConfiguration.class, TestConfiguration.class})
@Retention(RetentionPolicy.RUNTIME)
@DirtiesContext
public @interface IntegrationTest {
}

View File

@@ -1,25 +0,0 @@
package com.dedicatedcode.reitti;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.service.ImportListener;
import java.util.ArrayList;
import java.util.List;
public class MockImportListener implements ImportListener {
private final List<LocationDataRequest.LocationPoint> points = new ArrayList<>();
@Override
public void handleImport(User user, List<LocationDataRequest.LocationPoint> data) {
points.addAll(data);
}
public List<LocationDataRequest.LocationPoint> getPoints() {
return points;
}
public void clearAll() {
this.points.clear();
}
}

View File

@@ -0,0 +1,20 @@
package com.dedicatedcode.reitti;
import com.dedicatedcode.reitti.service.geocoding.GeocodeResult;
import com.dedicatedcode.reitti.service.geocoding.GeocodeServiceManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Optional;
@Configuration
public class TestConfiguration {
@Bean
public GeocodeServiceManager geocodeServiceManager() {
return (latitude, longitude) -> {
String label = latitude + "," + longitude;
return Optional.of(new GeocodeResult(label, "Test Street 1", "Test City", "Test District"));
};
}
}

View File

@@ -0,0 +1,14 @@
package com.dedicatedcode.reitti;
import com.dedicatedcode.reitti.model.GeoPoint;
public class TestConstants {
public static class Points {
public static final GeoPoint MOLTKESTR = new GeoPoint(53.863149, 10.700927);
public static final GeoPoint DIELE = new GeoPoint(53.868977437711706, 10.680643284930158);
public static final GeoPoint OBI = new GeoPoint(53.87172110622166, 10.747495611916795);
public static final GeoPoint GARTEN = new GeoPoint(53.87318065243313, 10.732683669587999);
public static final GeoPoint FAMILA = new GeoPoint(53.87107192953535, 10.745880070233135);
public static final GeoPoint RETELSDORF = new GeoPoint(53.835091927631545, 10.982332869999997);
}
}

View File

@@ -0,0 +1,29 @@
package com.dedicatedcode.reitti;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.RabbitMQContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
public class TestContainerConfiguration {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> timescaledb() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgis/postgis:17-3.5-alpine")
.asCompatibleSubstituteFor("postgres"))
.withDatabaseName("reitti")
.withUsername("test")
.withPassword("test");
}
@Bean
@ServiceConnection
public RabbitMQContainer rabbitmq() {
return new RabbitMQContainer("rabbitmq:3-management")
.withExposedPorts(5672, 15672);
}
}

View File

@@ -0,0 +1,36 @@
package com.dedicatedcode.reitti;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import de.siegmar.fastcsv.reader.CsvReader;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import java.io.InputStreamReader;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
public class TestUtils {
private static final GeometryFactory FACTORY = new GeometryFactory();
public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.n ZZZZZ");
public static List<RawLocationPoint> loadFromCsv(String name) {
CsvReader reader = CsvReader.builder().build(new InputStreamReader(TestUtils.class.getResourceAsStream(name)));
return reader.stream().filter(csvRow -> csvRow.getOriginalLineNumber() > 1)
.map(row -> {
Instant timestamp = ZonedDateTime.parse(row.getField(3), DATE_TIME_FORMATTER).toInstant();
String pointString = row.getField(5);
pointString = pointString.substring(7);
double x = Double.parseDouble(pointString.substring(0, pointString.indexOf(" ")));
double y = Double.parseDouble(pointString.substring(pointString.indexOf(" ") + 1, pointString.length() - 1));
Point point = FACTORY.createPoint(new Coordinate(x, y));
return new RawLocationPoint(null, timestamp, point, Double.parseDouble(row.getField(1)));
}).toList();
}
}

View File

@@ -0,0 +1,100 @@
package com.dedicatedcode.reitti;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import com.dedicatedcode.reitti.repository.TripRepository;
import com.dedicatedcode.reitti.repository.VisitRepository;
import com.dedicatedcode.reitti.service.ImportHandler;
import com.dedicatedcode.reitti.service.UserService;
import com.dedicatedcode.reitti.service.processing.RawLocationPointProcessingTrigger;
import org.awaitility.Awaitility;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class TestingService {
private static final List<String> QUEUES_TO_CHECK = List.of(
RabbitMQConfig.MERGE_VISIT_QUEUE,
RabbitMQConfig.STAY_DETECTION_QUEUE,
RabbitMQConfig.LOCATION_DATA_QUEUE,
RabbitMQConfig.DETECT_TRIP_QUEUE,
RabbitMQConfig.SIGNIFICANT_PLACE_QUEUE
);
private final AtomicLong lastRun = new AtomicLong(0);
@Autowired
private UserService userService;
@Autowired
private ImportHandler importHandler;
@Autowired
private RawLocationPointRepository rawLocationPointRepository;
@Autowired
private RabbitAdmin rabbitAdmin;
@Autowired
private TripRepository tripRepository;
@Autowired
private ProcessedVisitRepository processedVisitRepository;
@Autowired
private VisitRepository visitRepository;
@Autowired
private RawLocationPointProcessingTrigger trigger;
public void importData(String path) {
User admin = userService.getUserById(1L);
InputStream is = getClass().getResourceAsStream(path);
if (path.endsWith(".gpx")) {
importHandler.importGpx(is, admin);
} else if (path.endsWith(".geojson")) {
importHandler.importGeoJson(is, admin);
} else {
throw new IllegalStateException("Unsupported file type: " + path);
}
}
public void triggerProcessingPipeline() {
trigger.start();
}
public void awaitDataImport(int seconds) {
this.lastRun.set(0);
Awaitility.await()
.pollInterval(10, TimeUnit.SECONDS)
.atMost(seconds, TimeUnit.SECONDS)
.alias("Wait for Queues to be empty").until(() -> {
boolean queuesArEmpty = QUEUES_TO_CHECK.stream().allMatch(name -> this.rabbitAdmin.getQueueInfo(name).getMessageCount() == 0);
if (!queuesArEmpty){
return false;
}
long currentCount = rawLocationPointRepository.count();
return currentCount == lastRun.getAndSet(currentCount);
});
}
public void clearData() {
//first, purge all messages from rabbit mq
lastRun.set(0);
QUEUES_TO_CHECK.forEach(name -> this.rabbitAdmin.purgeQueue(name));
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//now clear the database
this.tripRepository.deleteAll();
this.processedVisitRepository.deleteAll();
this.visitRepository.deleteAll();
this.rawLocationPointRepository.deleteAll();
}
}

View File

@@ -1,7 +1,10 @@
package com.dedicatedcode.reitti.model;
import com.dedicatedcode.reitti.TestUtils;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class GeoUtilsTest {
@@ -29,4 +32,12 @@ class GeoUtilsTest {
assertEquals(0.0009, degreesFor100m[0], 0.0001); // About 0.0009 degrees latitude
assertEquals(0.00127, degreesFor100m[1], 0.0001); // About 0.00127 degrees longitude at 45°N
}
@Test
void shouldCalculateCorrectTripDistances() {
List<RawLocationPoint> points = TestUtils.loadFromCsv("/data/raw/trip_1.csv");
double calculatedDistances = GeoUtils.calculateTripDistance(points);
assertEquals(22664.67856, calculatedDistances, 0.01);
}
}

View File

@@ -1,35 +0,0 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.AbstractIntegrationTest;
import com.dedicatedcode.reitti.MockImportListener;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.io.InputStream;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
class ImportHandlerTest extends AbstractIntegrationTest {
@Autowired
private ImportHandler importHandler;
@MockitoBean
private MockImportListener importListener;
@Test
void shouldImportGPX() {
InputStream is = getClass().getResourceAsStream("/data/gpx/20250531.gpx");
Map<String, Object> result = importHandler.importGpx(is, user);
assertEquals(2567, result.get("pointsReceived"));
assertEquals(true, result.get("success"));
verify(importListener, times(26)).handleImport(eq(user), ArgumentMatchers.any());
}
}

View File

@@ -1,53 +0,0 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.AbstractIntegrationTest;
import com.dedicatedcode.reitti.MockImportListener;
import com.dedicatedcode.reitti.dto.LocationDataRequest;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.io.InputStream;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class LocationDataServiceTest extends AbstractIntegrationTest {
@Autowired
private LocationDataService locationDataService;
@Autowired
private ImportHandler importHandler;
@Autowired
private MockImportListener importListener;
@Autowired
private RawLocationPointRepository rawLocationPointRepository;
@Test
void shouldStoreAllRawLocationPoints() {
InputStream is = getClass().getResourceAsStream("/data/gpx/20250531.gpx");
importHandler.importGpx(is, user);
List<LocationDataRequest.LocationPoint> allPoints = this.importListener.getPoints();
locationDataService.processLocationData(user, allPoints);
assertEquals(2567, rawLocationPointRepository.count());
}
@Test
void shouldSkipDuplicatedRawLocationPoints() {
InputStream is = getClass().getResourceAsStream("/data/gpx/20250531.gpx");
importHandler.importGpx(is, user);
List<LocationDataRequest.LocationPoint> allPoints = this.importListener.getPoints();
locationDataService.processLocationData(user, allPoints);
locationDataService.processLocationData(user, allPoints);
assertEquals(2567, rawLocationPointRepository.count());
}
}

View File

@@ -0,0 +1,35 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.*;
@IntegrationTest
class LocationDataIngestPipelineTest {
@Autowired
private RawLocationPointRepository repository;
@Autowired
private TestingService helper;
@Autowired
private TestingService testingService;
@BeforeEach
void setUp() {
this.testingService.clearData();
}
@Test
@Transactional
void shouldStoreLocationDataIntoRepository() {
helper.importData("/data/gpx/20250601.gpx");
testingService.awaitDataImport(600);
assertEquals(2463, this.repository.count());
}
}

View File

@@ -1,72 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.AbstractIntegrationTest;
import com.dedicatedcode.reitti.model.GeoUtils;
import com.dedicatedcode.reitti.model.RawLocationPoint;
import com.dedicatedcode.reitti.repository.RawLocationPointRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StayPointDetectionServiceTest extends AbstractIntegrationTest {
@Autowired
private RawLocationPointRepository rawLocationPointRepository;
@Autowired
private StayPointDetectionService stayPointDetectionService;
@Test
void shouldCalculateCorrectStayPoints() {
importGpx("/data/gpx/20250531.gpx");
List<RawLocationPoint> all = rawLocationPointRepository.findByUserOrderByTimestampAsc(user);
int splitSize = 100;
List<List<StayPoint>> stayPoints = new ArrayList<>();
while (all.size() >= splitSize) {
List<RawLocationPoint> current = new ArrayList<>(all.subList(0, splitSize));
all.removeAll(current);
stayPoints.add(stayPointDetectionService.detectStayPoints(user, current));
}
if (!all.isEmpty()) {
stayPoints.add(stayPointDetectionService.detectStayPoints(user, all));
}
List<StayPoint> expectedStayPointsInOrder = new ArrayList<>();
expectedStayPointsInOrder.add(new StayPoint(53.86334557300011, 10.701107468000021, null, null, null)); //Moltkestr.
expectedStayPointsInOrder.add(new StayPoint(53.86889230000001, 10.680612066666669, null, null, null)); //Diele.
expectedStayPointsInOrder.add(new StayPoint(53.86334557300011, 10.701107468000021, null, null, null)); //Moltkestr.
expectedStayPointsInOrder.add(new StayPoint(53.86889230000001, 10.680612066666669, null, null, null)); //Diele.
expectedStayPointsInOrder.add(new StayPoint(53.87306318052629, 10.732658768947365, null, null, null)); //Garten.
expectedStayPointsInOrder.add(new StayPoint(53.87101884785715, 10.745859928571429, null, null, null)); //Fimila
expectedStayPointsInOrder.add(new StayPoint(53.871636138461504, 10.747298292564096, null, null, null)); //Obi
expectedStayPointsInOrder.add(new StayPoint(53.87216447272729, 10.747552527272727, null, null, null)); //Obi
expectedStayPointsInOrder.add(new StayPoint(53.871564058,10.747507870888889, null, null, null)); //Obi
expectedStayPointsInOrder.add(new StayPoint(53.873079353158, 10.73264953157896, null, null, null)); //Garten
expectedStayPointsInOrder.add(new StayPoint(53.86334557300011, 10.701107468000021, null, null, null)); //Moltkestr.
List<StayPoint> distinctStayPoints = new ArrayList<>();
List<StayPoint> flatStayPoints = stayPoints.stream().flatMap(Collection::stream).sorted(Comparator.comparing(StayPoint::getArrivalTime)).toList();
StayPoint last = null;
int checkThresholdInMeters = 50;
for (StayPoint point : flatStayPoints) {
if (last == null || GeoUtils.distanceInMeters(last, point) >= checkThresholdInMeters) {
last = point;
distinctStayPoints.add(point);
}
}
assertEquals(expectedStayPointsInOrder.size(), distinctStayPoints.size());
for (int i = 0; i < expectedStayPointsInOrder.size(); i++) {
StayPoint expected = expectedStayPointsInOrder.get(i);
StayPoint actual = distinctStayPoints.get(i);
assertTrue(GeoUtils.distanceInMeters(actual, expected) < checkThresholdInMeters,
"Distance between " + actual + " and " + expected + " is too large. Should be less than " + checkThresholdInMeters + "m but was " + GeoUtils.distanceInMeters(actual, expected) + "m for index " + i + ".");
}
}
}

View File

@@ -1,70 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.AbstractIntegrationTest;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.ProcessedVisit;
import com.dedicatedcode.reitti.model.Trip;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.TripRepository;
import org.junit.jupiter.api.Test;
import org.locationtech.jts.triangulate.tri.Tri;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class TripDetectionServiceTest extends AbstractIntegrationTest {
@Autowired
private TripDetectionService tripDetectionService;
@Autowired
private TripRepository tripRepository;
@Autowired
private ProcessedVisitRepository processedVisitRepository;
@Test
@Transactional
void shouldDetectTripsBetweenVisits() {
this.importUntilProcessedVisits("/data/gpx/20250531.gpx");
this.tripDetectionService.detectTripsForUser(new MergeVisitEvent(user.getUsername(), null, null));
List<Trip> persistedTrips = tripRepository.findByUser(user);
List<ProcessedVisit> processedVisits = this.processedVisitRepository.findByUserOrderByStartTime(user);
List<Trip> expectedTrips = new ArrayList<>();
for (int i = 0; i < processedVisits.size() - 1; i++) {
ProcessedVisit start = processedVisits.get(i);
ProcessedVisit end = processedVisits.get(i + 1);
if (!end.getPlace().equals(start.getPlace())) {
expectedTrips.add(new Trip(user, start.getPlace(), end.getPlace(), start.getStartTime(), start.getEndTime(), null, null));
}
}
print(expectedTrips);
assertEquals(expectedTrips.size(), persistedTrips.size());
for (int i = 0; i < expectedTrips.size(); i++) {
Trip expected = expectedTrips.get(i);
Trip actual = persistedTrips.get(i);
assertEquals(expected.getStartPlace(), actual.getStartPlace());
assertEquals(expected.getEndPlace(), actual.getEndPlace());
}
}
void print(List<Trip> trips) {
for (int i = 0; i < trips.size(); i++) {
Trip trip = trips.get(i);
System.out.println(i + ": [" + trip.getStartPlace().getLatitudeCentroid() + "," + trip.getStartPlace().getLongitudeCentroid() + "] " +
"-> [" + trip.getEndPlace().getLatitudeCentroid() + "," + trip.getEndPlace().getLongitudeCentroid() + "] @" + trip.getTransportModeInferred());
}
}
}

View File

@@ -0,0 +1,70 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.IntegrationTest;
import com.dedicatedcode.reitti.TestingService;
import com.dedicatedcode.reitti.model.ProcessedVisit;
import com.dedicatedcode.reitti.model.Visit;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.VisitRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@IntegrationTest
class VisitDetectionServiceTest {
@Autowired
private TestingService testingService;
@Autowired
private VisitRepository visitRepository;
@Autowired
private ProcessedVisitRepository processedVisitRepository;
@Value("${reitti.visit.merge-threshold-seconds}")
private int visitMergeThresholdSeconds;
@BeforeEach
void setUp() {
this.testingService.clearData();
}
@Test
@Transactional
void shouldDetectVisits() {
this.testingService.importData("/data/gpx/20250531.gpx");
this.testingService.awaitDataImport(600);
this.testingService.triggerProcessingPipeline();
this.testingService.awaitDataImport(600);
List<Visit> persistedVisits = this.visitRepository.findAll(Sort.by(Sort.Direction.ASC, "startTime"));
assertEquals(11, persistedVisits.size());
List<ProcessedVisit> processedVisits = this.processedVisitRepository.findAll(Sort.by(Sort.Direction.ASC, "startTime"));
assertEquals(10, processedVisits.size());
for (int i = 0; i < processedVisits.size() - 1; i++) {
ProcessedVisit visit = processedVisits.get(i);
ProcessedVisit nextVisit = processedVisits.get(i + 1);
long durationBetweenVisits = Duration.between(visit.getEndTime(), nextVisit.getStartTime()).toSeconds();
assertTrue(durationBetweenVisits >= visitMergeThresholdSeconds || !visit.getPlace().equals(nextVisit.getPlace()),
"Duration between same place visit at index [" + i + "] should not be lower than [" + visitMergeThresholdSeconds + "]s but was [" + durationBetweenVisits + "]s");
}
System.out.println();
}
}

View File

@@ -1,62 +0,0 @@
package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.AbstractIntegrationTest;
import com.dedicatedcode.reitti.event.MergeVisitEvent;
import com.dedicatedcode.reitti.model.GeoUtils;
import com.dedicatedcode.reitti.repository.ProcessedVisitRepository;
import com.dedicatedcode.reitti.repository.VisitRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class VisitMergingServiceTest extends AbstractIntegrationTest {
@Autowired
private VisitMergingService visitMergingService;
@Autowired
private VisitRepository visitRepository;
@Autowired
private ProcessedVisitRepository processedVisitRepository;
@Test
@Transactional
void shouldMergeVisitsInTimeFrame() {
importUntilVisits("/data/gpx/20250531.gpx");
visitMergingService.mergeVisits(new MergeVisitEvent(user.getUsername(), null, null));
assertEquals(0, visitRepository.findByUserAndProcessedFalse(user).size());
List<GeoPoint> expectedVisits = new ArrayList<>();
expectedVisits.add(new GeoPoint(53.86334539659948,10.701105248045259)); // Moltke
expectedVisits.add(new GeoPoint(53.86889230000001,10.680612066666669)); // Diele
expectedVisits.add(new GeoPoint(53.86334539659948,10.701105248045259)); // Moltke
expectedVisits.add(new GeoPoint(53.86889230000001,10.680612066666669)); // Diele
expectedVisits.add(new GeoPoint(53.87306318052629,10.732658768947365)); // Garten
expectedVisits.add(new GeoPoint(53.871003894,10.7458164105)); // Famila
expectedVisits.add(new GeoPoint(53.8714586375,10.747866387499998)); // Obi 1
expectedVisits.add(new GeoPoint(53.87214355833334,10.747553500000002)); // Obi 2
expectedVisits.add(new GeoPoint(53.8714586375,10.747866387499998)); // Obi 1
expectedVisits.add(new GeoPoint(53.87306318052629,10.732658768947365)); // Garten
expectedVisits.add(new GeoPoint(53.86334539659948,10.701105248045259)); // Moltke
List<GeoPoint> actualVisits = this.processedVisitRepository.findByUserOrderByStartTime(user).stream().map(pv -> new GeoPoint(pv.getPlace().getLatitudeCentroid(), pv.getPlace().getLongitudeCentroid())).toList();
assertEquals(expectedVisits.size(), actualVisits.size());
for (int i = 0; i < actualVisits.size(); i++) {
GeoPoint expected = expectedVisits.get(i);
GeoPoint actual = actualVisits.get(i);
double distanceInMeters = GeoUtils.distanceInMeters(actual.latitude(), actual.longitude(), expected.latitude(), expected.longitude());
assertTrue(distanceInMeters < 50, "Distance between " + actual + " and " + expected + " is too large. Should be less than 25m but was " + distanceInMeters + "m for index " + i + ".");
}
}
}

View File

@@ -1,17 +1,19 @@
# Test profile configuration
spring.jpa.hibernate.ddl-auto=none
# Disable Flyway migrations for tests
spring.flyway.enabled=true
# RabbitMQ test configuration
spring.rabbitmq.listener.simple.auto-startup=true
# Application specific test settings
reitti.visit.merge-threshold-seconds=600
reitti.detect-trips-after-merging=true
# Application-specific test settings
reitti.visit.merge-threshold-seconds=300
# Stay point detection settings
reitti.staypoint.distance-threshold-meters=50
reitti.staypoint.time-threshold-seconds=300
reitti.staypoint.min-points=1
reitti.staypoint.min-points=5
#Disable cron job for testing
reitti.process-data.schedule=-
logging.level.root = INFO
logging.level.org.hibernate.engine.jdbc.spi.SqlExceptionHelper = FATAL

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
id,accuracy_meters,activity_provided,timestamp,user_id,geom
157224,10,,2025-06-01 13:02:03.000000 +00:00,1,POINT (10.70340326 53.86314754)
157226,10,,2025-06-01 13:02:41.000000 +00:00,1,POINT (10.70773477 53.86341877)
157228,10,,2025-06-01 13:03:15.000000 +00:00,1,POINT (10.71101706 53.86351594)
157230,10,,2025-06-01 13:03:46.056000 +00:00,1,POINT (10.7167924 53.8641967)
157232,10,,2025-06-01 13:04:21.000000 +00:00,1,POINT (10.72034874 53.86403256)
157234,10,,2025-06-01 13:04:52.049000 +00:00,1,POINT (10.7257403 53.8616621)
157236,10,,2025-06-01 13:05:32.000000 +00:00,1,POINT (10.73177311 53.85895003)
157238,10,,2025-06-01 13:06:08.000000 +00:00,1,POINT (10.73519309 53.8557508)
157240,10,,2025-06-01 13:06:43.000000 +00:00,1,POINT (10.73719514 53.85293658)
157242,10,,2025-06-01 13:07:19.000000 +00:00,1,POINT (10.7407481 53.84992879)
157244,10,,2025-06-01 13:07:56.000000 +00:00,1,POINT (10.7452297 53.84808651)
157246,10,,2025-06-01 13:08:35.000000 +00:00,1,POINT (10.74961349 53.84643717)
157248,10,,2025-06-01 13:09:10.000000 +00:00,1,POINT (10.75282385 53.84494685)
157250,10,,2025-06-01 13:09:47.000000 +00:00,1,POINT (10.75562262 53.84165993)
157251,10,,2025-06-01 13:10:17.109000 +00:00,1,POINT (10.7578338 53.8386337)
157253,10,,2025-06-01 13:10:48.071000 +00:00,1,POINT (10.7601936 53.8354671)
157255,10,,2025-06-01 13:11:23.000000 +00:00,1,POINT (10.76588192 53.83401121)
157258,10,,2025-06-01 13:11:54.136000 +00:00,1,POINT (10.7726991 53.8334669)
157260,10,,2025-06-01 13:12:33.000000 +00:00,1,POINT (10.78036638 53.83250978)
157261,10,,2025-06-01 13:13:09.000000 +00:00,1,POINT (10.78971638 53.83203866)
157263,10,,2025-06-01 13:13:44.000000 +00:00,1,POINT (10.79757395 53.83256922)
157265,10,,2025-06-01 13:14:22.000000 +00:00,1,POINT (10.79966779 53.82792802)
157267,10,,2025-06-01 13:14:58.000000 +00:00,1,POINT (10.80781316 53.82256306)
157269,10,,2025-06-01 13:15:37.000000 +00:00,1,POINT (10.82125731 53.82214848)
157271,10,,2025-06-01 13:16:24.000000 +00:00,1,POINT (10.82919938 53.81716004)
157273,10,,2025-06-01 13:17:04.000000 +00:00,1,POINT (10.83345473 53.81064093)
157275,10,,2025-06-01 13:17:39.000000 +00:00,1,POINT (10.83525936 53.80988404)
157277,10,,2025-06-01 13:18:13.000000 +00:00,1,POINT (10.85102651 53.81207393)
157279,10,,2025-06-01 13:18:47.000000 +00:00,1,POINT (10.868952 53.81453849)
157281,10,,2025-06-01 13:19:23.000000 +00:00,1,POINT (10.88843497 53.81569066)
157283,10,,2025-06-01 13:19:58.000000 +00:00,1,POINT (10.90687814 53.81839274)
157285,10,,2025-06-01 13:20:35.000000 +00:00,1,POINT (10.92600807 53.82130154)
157287,10,,2025-06-01 13:21:09.000000 +00:00,1,POINT (10.94337276 53.82032642)
157289,10,,2025-06-01 13:21:46.000000 +00:00,1,POINT (10.95892816 53.82354364)
157291,10,,2025-06-01 13:22:22.000000 +00:00,1,POINT (10.96674951 53.82570103)
157293,10,,2025-06-01 13:23:00.000000 +00:00,1,POINT (10.97305914 53.82274974)
157295,10,,2025-06-01 13:23:42.000000 +00:00,1,POINT (10.97730181 53.82370438)
157297,10,,2025-06-01 13:24:17.000000 +00:00,1,POINT (10.97974469 53.82795952)
157299,10,,2025-06-01 13:25:04.000000 +00:00,1,POINT (10.98252018 53.8338487)
1 id accuracy_meters activity_provided timestamp user_id geom
2 157224 10 2025-06-01 13:02:03.000000 +00:00 1 POINT (10.70340326 53.86314754)
3 157226 10 2025-06-01 13:02:41.000000 +00:00 1 POINT (10.70773477 53.86341877)
4 157228 10 2025-06-01 13:03:15.000000 +00:00 1 POINT (10.71101706 53.86351594)
5 157230 10 2025-06-01 13:03:46.056000 +00:00 1 POINT (10.7167924 53.8641967)
6 157232 10 2025-06-01 13:04:21.000000 +00:00 1 POINT (10.72034874 53.86403256)
7 157234 10 2025-06-01 13:04:52.049000 +00:00 1 POINT (10.7257403 53.8616621)
8 157236 10 2025-06-01 13:05:32.000000 +00:00 1 POINT (10.73177311 53.85895003)
9 157238 10 2025-06-01 13:06:08.000000 +00:00 1 POINT (10.73519309 53.8557508)
10 157240 10 2025-06-01 13:06:43.000000 +00:00 1 POINT (10.73719514 53.85293658)
11 157242 10 2025-06-01 13:07:19.000000 +00:00 1 POINT (10.7407481 53.84992879)
12 157244 10 2025-06-01 13:07:56.000000 +00:00 1 POINT (10.7452297 53.84808651)
13 157246 10 2025-06-01 13:08:35.000000 +00:00 1 POINT (10.74961349 53.84643717)
14 157248 10 2025-06-01 13:09:10.000000 +00:00 1 POINT (10.75282385 53.84494685)
15 157250 10 2025-06-01 13:09:47.000000 +00:00 1 POINT (10.75562262 53.84165993)
16 157251 10 2025-06-01 13:10:17.109000 +00:00 1 POINT (10.7578338 53.8386337)
17 157253 10 2025-06-01 13:10:48.071000 +00:00 1 POINT (10.7601936 53.8354671)
18 157255 10 2025-06-01 13:11:23.000000 +00:00 1 POINT (10.76588192 53.83401121)
19 157258 10 2025-06-01 13:11:54.136000 +00:00 1 POINT (10.7726991 53.8334669)
20 157260 10 2025-06-01 13:12:33.000000 +00:00 1 POINT (10.78036638 53.83250978)
21 157261 10 2025-06-01 13:13:09.000000 +00:00 1 POINT (10.78971638 53.83203866)
22 157263 10 2025-06-01 13:13:44.000000 +00:00 1 POINT (10.79757395 53.83256922)
23 157265 10 2025-06-01 13:14:22.000000 +00:00 1 POINT (10.79966779 53.82792802)
24 157267 10 2025-06-01 13:14:58.000000 +00:00 1 POINT (10.80781316 53.82256306)
25 157269 10 2025-06-01 13:15:37.000000 +00:00 1 POINT (10.82125731 53.82214848)
26 157271 10 2025-06-01 13:16:24.000000 +00:00 1 POINT (10.82919938 53.81716004)
27 157273 10 2025-06-01 13:17:04.000000 +00:00 1 POINT (10.83345473 53.81064093)
28 157275 10 2025-06-01 13:17:39.000000 +00:00 1 POINT (10.83525936 53.80988404)
29 157277 10 2025-06-01 13:18:13.000000 +00:00 1 POINT (10.85102651 53.81207393)
30 157279 10 2025-06-01 13:18:47.000000 +00:00 1 POINT (10.868952 53.81453849)
31 157281 10 2025-06-01 13:19:23.000000 +00:00 1 POINT (10.88843497 53.81569066)
32 157283 10 2025-06-01 13:19:58.000000 +00:00 1 POINT (10.90687814 53.81839274)
33 157285 10 2025-06-01 13:20:35.000000 +00:00 1 POINT (10.92600807 53.82130154)
34 157287 10 2025-06-01 13:21:09.000000 +00:00 1 POINT (10.94337276 53.82032642)
35 157289 10 2025-06-01 13:21:46.000000 +00:00 1 POINT (10.95892816 53.82354364)
36 157291 10 2025-06-01 13:22:22.000000 +00:00 1 POINT (10.96674951 53.82570103)
37 157293 10 2025-06-01 13:23:00.000000 +00:00 1 POINT (10.97305914 53.82274974)
38 157295 10 2025-06-01 13:23:42.000000 +00:00 1 POINT (10.97730181 53.82370438)
39 157297 10 2025-06-01 13:24:17.000000 +00:00 1 POINT (10.97974469 53.82795952)
40 157299 10 2025-06-01 13:25:04.000000 +00:00 1 POINT (10.98252018 53.8338487)