mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 17:37:57 -05:00
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:
5
.github/workflows/maven.yml
vendored
5
.github/workflows/maven.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
6
pom.xml
6
pom.xml
@@ -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>
|
||||
|
||||
14
src/main/java/com/dedicatedcode/reitti/config/AppConfig.java
Normal file
14
src/main/java/com/dedicatedcode/reitti/config/AppConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 + ")";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.dedicatedcode.reitti.service.processing;
|
||||
|
||||
public record GeoPoint(double latitude, double longitude) {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
4
src/main/resources/application-dev.properties
Normal file
4
src/main/resources/application-dev.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
# Data management configuration
|
||||
reitti.data-management.enabled=true
|
||||
|
||||
logging.level.com.dedicatedcode = DEBUG
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE visits
|
||||
ADD CONSTRAINT visits_pk
|
||||
UNIQUE (user_id, start_time, end_time);
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
19
src/test/java/com/dedicatedcode/reitti/IntegrationTest.java
Normal file
19
src/test/java/com/dedicatedcode/reitti/IntegrationTest.java
Normal 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 {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/test/java/com/dedicatedcode/reitti/TestConstants.java
Normal file
14
src/test/java/com/dedicatedcode/reitti/TestConstants.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
36
src/test/java/com/dedicatedcode/reitti/TestUtils.java
Normal file
36
src/test/java/com/dedicatedcode/reitti/TestUtils.java
Normal 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();
|
||||
}
|
||||
}
|
||||
100
src/test/java/com/dedicatedcode/reitti/TestingService.java
Normal file
100
src/test/java/com/dedicatedcode/reitti/TestingService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 + ".");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
2464
src/test/resources/data/gpx/20250601.gpx
Normal file
2464
src/test/resources/data/gpx/20250601.gpx
Normal file
File diff suppressed because it is too large
Load Diff
40
src/test/resources/data/raw/trip_1.csv
Normal file
40
src/test/resources/data/raw/trip_1.csv
Normal 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)
|
||||
|
Reference in New Issue
Block a user