Merge remote-tracking branch 'origin/main'

This commit is contained in:
Hosted Weblate
2025-12-31 13:43:02 +00:00
13 changed files with 489 additions and 265 deletions

View File

@@ -6,7 +6,7 @@ import org.springframework.stereotype.Component;
@Component
public class LocationDensityConfig {
@Value("${reitti.location.density.target-points-per-minute:2}")
@Value("${reitti.location.density.target-points-per-minute:4}")
private int targetPointsPerMinute;
public int getTargetPointsPerMinute() {

View File

@@ -0,0 +1,21 @@
package com.dedicatedcode.reitti.model;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
public class ClusteredPoint {
private final RawLocationPoint point;
private final Integer clusterId;
public ClusteredPoint(RawLocationPoint point, Integer clusterId) {
this.point = point;
this.clusterId = clusterId;
}
public RawLocationPoint getPoint() {
return point;
}
public Integer getClusterId() {
return clusterId;
}
}

View File

@@ -14,18 +14,6 @@ public final class GeoUtils {
private static final double EARTH_RADIUS = 6371000;
public static double distanceInMeters(double lat1, double lon1, double lat2, double lon2) {
// For very small distances (< 1km), use faster approximation
double latDiff = Math.abs(lat2 - lat1);
double lonDiff = Math.abs(lon2 - lon1);
if (latDiff < 0.01 && lonDiff < 0.01) { // roughly < 1km
double avgLat = Math.toRadians((lat1 + lat2) / 2);
double latDistance = Math.toRadians(latDiff);
double lonDistance = Math.toRadians(lonDiff) * Math.cos(avgLat);
return EARTH_RADIUS * Math.sqrt(latDistance * latDistance + lonDistance * lonDistance);
}
// Use precise haversine formula for longer distances
double latDistance = Math.toRadians(lat2 - lat1);
double lonDistance = Math.toRadians(lon2 - lon1);

View File

@@ -0,0 +1,45 @@
package com.dedicatedcode.reitti.model.geo;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
public class StayPoint {
private final double latitude;
private final double longitude;
private final Instant arrivalTime;
private final Instant departureTime;
private final List<RawLocationPoint> points;
public StayPoint(double latitude, double longitude, Instant arrivalTime, Instant departureTime, List<RawLocationPoint> points) {
this.latitude = latitude;
this.longitude = longitude;
this.arrivalTime = arrivalTime;
this.departureTime = departureTime;
this.points = points;
}
public double getLatitude() {
return latitude;
}
public double getLongitude() {
return longitude;
}
public Instant getArrivalTime() {
return arrivalTime;
}
public Instant getDepartureTime() {
return departureTime;
}
public List<RawLocationPoint> getPoints() {
return points;
}
public long getDurationSeconds() {
return Duration.between(arrivalTime, departureTime).getSeconds();
}
}

View File

@@ -1,8 +1,7 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.ClusteredPoint;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
import com.dedicatedcode.reitti.model.geo.Visit;
import com.dedicatedcode.reitti.model.security.User;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@@ -57,75 +56,33 @@ public class PreviewRawLocationPointJdbcService {
"LIMIT ? OFFSET ?";
return jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId(), previewId, limit, offset);
}
public List<Visit> findVisitsInTimerangeForUser(
User user, String previewId, Instant startTime, Instant endTime, long minimumStayTime, double distanceInMeters) {
String sql = """
WITH smoothed_data AS (
-- Step 1: Smooth the track using a rolling centroid of the last 4 points
SELECT
*,
ST_Centroid(
ST_Collect(geom) OVER (
ORDER BY "timestamp"
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
)
) AS smoothed_geom
FROM preview_raw_location_points
WHERE user_id = ? AND preview_id = ? AND "timestamp" BETWEEN ? AND ?
),
lagged_data AS (
-- Step 2: Add the lagged smoothed geometry and timestamp
SELECT
*,
LAG(smoothed_geom, 4) OVER (ORDER BY "timestamp") AS prev_smoothed_geom,
LAG("timestamp", 4) OVER (ORDER BY "timestamp") AS prev_ts
FROM smoothed_data
),
island_flags AS (
-- Step 3: Identify breaks (islands) based on smoothed movement
SELECT
*,
CASE
WHEN prev_smoothed_geom IS NULL THEN 1
-- Check if current smoothed center is > 50m from 1 minute ago center
WHEN ST_Distance(smoothed_geom::geography, prev_smoothed_geom::geography) > ? THEN 1
-- Check if time gap is > 10 minutes (600 seconds)
WHEN EXTRACT(EPOCH FROM ("timestamp" - prev_ts)) > ? THEN 1
ELSE 0
END AS is_new_cluster
FROM lagged_data
),
clustered_points AS (
-- Step 4: Assign a unique ID by summing the flags
SELECT
*,
SUM(is_new_cluster) OVER (ORDER BY "timestamp") AS cluster_id
FROM island_flags
)
-- Step 5: Final Output - Group into "Stay Events"
SELECT
cluster_id,
MIN("timestamp") AS arrival,
MAX("timestamp") AS departure,
EXTRACT(EPOCH FROM (MAX("timestamp") - MIN("timestamp"))) AS duration,
COUNT(*) AS point_count,
ST_AsText(ST_Centroid(ST_Collect(geom))) AS cluster_center_geom
FROM clustered_points
GROUP BY cluster_id
HAVING COUNT(*) >= 4 -- Must stay for at least 1 minute to be a cluster
ORDER BY arrival;
""";
public List<ClusteredPoint> findClusteredPointsInTimeRangeForUser(
User user, String previewId, Instant startTime, Instant endTime, int minimumPoints, double distanceInMeters) {
String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.ignored, rlp.version , " +
"ST_ClusterDBSCAN(rlp.geom, ?, ?) over () AS cluster_id " +
"FROM preview_raw_location_points rlp " +
"WHERE rlp.user_id = ? AND rlp.timestamp BETWEEN ? AND ? AND preview_id = ?";
return jdbcTemplate.query(sql, (rs, _) -> {
GeoPoint geom = this.pointReaderWriter.read(rs.getString("cluster_center_geom"));
return new Visit(geom.longitude(),
geom.latitude(),
rs.getTimestamp("arrival").toInstant(),
rs.getTimestamp("departure").toInstant(),
rs.getLong("duration"),
false
);
}, user.getId(), previewId, Timestamp.from(startTime), Timestamp.from(endTime), distanceInMeters, minimumStayTime);
RawLocationPoint point = new RawLocationPoint(
rs.getLong("id"),
rs.getTimestamp("timestamp").toInstant(),
this.pointReaderWriter.read(rs.getString("geom")),
rs.getDouble("accuracy_meters"),
rs.getObject("elevation_meters", Double.class),
rs.getBoolean("processed"),
rs.getBoolean("synthetic"),
rs.getBoolean("ignored"),
rs.getLong("version")
);
Integer clusterId = rs.getObject("cluster_id", Integer.class);
return new ClusteredPoint(point, clusterId);
}, distanceInMeters, minimumPoints, user.getId(),
Timestamp.from(startTime), Timestamp.from(endTime), previewId);
}
public void bulkUpdateProcessedStatus(List<RawLocationPoint> points) {

View File

@@ -63,6 +63,20 @@ public class ProcessedVisitJdbcService {
Timestamp.from(endTime), Timestamp.from(startTime));
}
public Optional<ProcessedVisit> findFirstProcessedVisitBefore(User user, Instant time) {
String sql = "SELECT pv.* " +
"FROM processed_visits pv " +
"WHERE pv.user_id = ? AND pv.end_time < ? ORDER BY pv.end_time DESC LIMIT 1";
return jdbcTemplate.query(sql, PROCESSED_VISIT_ROW_MAPPER, user.getId(), Timestamp.from(time)).stream().findFirst();
}
public Optional<ProcessedVisit> findFirstProcessedVisitAfter(User user, Instant time) {
String sql = "SELECT pv.* " +
"FROM processed_visits pv " +
"WHERE pv.user_id = ? AND pv.start_time > ? ORDER BY pv.start_time LIMIT 1";
return jdbcTemplate.query(sql, PROCESSED_VISIT_ROW_MAPPER, user.getId(), Timestamp.from(time)).stream().findFirst();
}
public Optional<ProcessedVisit> findByUserAndId(User user, long id) {
String sql = "SELECT pv.* " +
"FROM processed_visits pv " +

View File

@@ -1,9 +1,8 @@
package com.dedicatedcode.reitti.repository;
import com.dedicatedcode.reitti.dto.LocationPoint;
import com.dedicatedcode.reitti.model.geo.GeoPoint;
import com.dedicatedcode.reitti.model.ClusteredPoint;
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
import com.dedicatedcode.reitti.model.geo.Visit;
import com.dedicatedcode.reitti.model.security.User;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
@@ -15,10 +14,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.time.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -36,7 +32,7 @@ public class RawLocationPointJdbcService {
public RawLocationPointJdbcService(JdbcTemplate jdbcTemplate, PointReaderWriter pointReaderWriter, GeometryFactory geometryFactory) {
this.jdbcTemplate = jdbcTemplate;
this.rawLocationPointRowMapper = (rs, _) -> new RawLocationPoint(
this.rawLocationPointRowMapper = (rs, rowNum) -> new RawLocationPoint(
rs.getLong("id"),
rs.getTimestamp("timestamp").toInstant(),
pointReaderWriter.read(rs.getString("geom")),
@@ -169,7 +165,7 @@ public class RawLocationPointJdbcService {
"FROM raw_location_points rlp " +
"WHERE rlp.id = ?";
List<RawLocationPoint> results = jdbcTemplate.query(sql, rawLocationPointRowMapper, id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public Optional<RawLocationPoint> findLatest(User user, Instant since) {
@@ -178,7 +174,7 @@ public class RawLocationPointJdbcService {
"WHERE rlp.user_id = ? AND rlp.timestamp >= ? " +
"ORDER BY rlp.timestamp LIMIT 1";
List<RawLocationPoint> results = jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId(), Timestamp.from(since));
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public Optional<RawLocationPoint> findLatest(User user) {
@@ -187,86 +183,41 @@ public class RawLocationPointJdbcService {
"WHERE rlp.user_id = ? " +
"ORDER BY rlp.timestamp DESC LIMIT 1";
List<RawLocationPoint> results = jdbcTemplate.query(sql, rawLocationPointRowMapper, user.getId());
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public List<Visit> findVisitsInTimerangeForUser(
User user, Instant startTime, Instant endTime, long minimumStayTime, double distanceInMeters) {
String sql = """
WITH smoothed_data AS (
-- Step 1: Smooth the track using a rolling centroid of the last 4 points
SELECT\s
*,
ST_Centroid(
ST_Collect(geom) OVER (
ORDER BY "timestamp"
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
)
) AS smoothed_geom
FROM raw_location_points
WHERE user_id = ? AND "timestamp" BETWEEN ? AND ?
),
lagged_data AS (
-- Step 2: Add the lagged smoothed geometry and timestamp
SELECT
*,
LAG(smoothed_geom, 4) OVER (ORDER BY "timestamp") AS prev_smoothed_geom,
LAG("timestamp", 4) OVER (ORDER BY "timestamp") AS prev_ts
FROM smoothed_data
),
island_flags AS (
-- Step 3: Identify breaks (islands) based on smoothed movement
SELECT
*,
CASE
WHEN prev_smoothed_geom IS NULL THEN 1
-- Check if current smoothed center is > 50m from 1 minute ago center
WHEN ST_Distance(smoothed_geom::geography, prev_smoothed_geom::geography) > ? THEN 1
-- Check if time gap is > 10 minutes (600 seconds)
WHEN EXTRACT(EPOCH FROM ("timestamp" - prev_ts)) > ? THEN 1
ELSE 0
END AS is_new_cluster
FROM lagged_data
),
clustered_points AS (
-- Step 4: Assign a unique ID by summing the flags
SELECT
*,
SUM(is_new_cluster) OVER (ORDER BY "timestamp") AS cluster_id
FROM island_flags
)
-- Step 5: Final Output - Group into "Stay Events"
SELECT
cluster_id,
MIN("timestamp") AS arrival,
MAX("timestamp") AS departure,
EXTRACT(EPOCH FROM (MAX("timestamp") - MIN("timestamp"))) AS duration,
COUNT(*) AS point_count,
ST_AsText(ST_Centroid(ST_Collect(geom))) AS cluster_center_geom
FROM clustered_points
GROUP BY cluster_id
HAVING COUNT(*) >= 4 -- Must stay for at least 1 minute to be a cluster
ORDER BY arrival;
""";
return jdbcTemplate.query(sql, (rs, _) -> {
GeoPoint geom = this.pointReaderWriter.read(rs.getString("cluster_center_geom"));
return new Visit(geom.longitude(),
geom.latitude(),
rs.getTimestamp("arrival").toInstant(),
rs.getTimestamp("departure").toInstant(),
rs.getLong("duration"),
false
);
}, user.getId(), Timestamp.from(startTime), Timestamp.from(endTime), distanceInMeters, minimumStayTime);
public List<ClusteredPoint> findClusteredPointsInTimeRangeForUser(
User user, Instant startTime, Instant endTime, int minimumPoints, double distanceInMeters) {
String sql = "SELECT rlp.id, rlp.accuracy_meters, rlp.elevation_meters, rlp.timestamp, rlp.user_id, ST_AsText(rlp.geom) as geom, rlp.processed, rlp.synthetic, rlp.ignored, rlp.version , " +
"ST_ClusterDBSCAN(rlp.geom, ?, ?) over () AS cluster_id " +
"FROM raw_location_points rlp " +
"WHERE rlp.user_id = ? AND rlp.timestamp BETWEEN ? AND ?";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
RawLocationPoint point = new RawLocationPoint(
rs.getLong("id"),
rs.getTimestamp("timestamp").toInstant(),
this.pointReaderWriter.read(rs.getString("geom")),
rs.getDouble("accuracy_meters"),
rs.getObject("elevation_meters", Double.class),
rs.getBoolean("processed"),
rs.getBoolean("synthetic"),
rs.getBoolean("ignored"),
rs.getLong("version")
);
Integer clusterId = rs.getObject("cluster_id", Integer.class);
return new ClusteredPoint(point, clusterId);
}, distanceInMeters, minimumPoints, user.getId(),
Timestamp.from(startTime), Timestamp.from(endTime));
}
@SuppressWarnings("DataFlowIssue")
public long count() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM raw_location_points", Long.class);
}
@SuppressWarnings("DataFlowIssue")
public List<RawLocationPoint> findPointsInBoxWithNeighbors(
User user,
Instant startTime,
@@ -441,7 +392,7 @@ public class RawLocationPointJdbcService {
String sql = """
WITH sampled_points AS (
SELECT DISTINCT ON (
date_trunc('hour', timestamp) +
date_trunc('hour', timestamp) +
(EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes'
)
id,
@@ -458,7 +409,7 @@ public class RawLocationPointJdbcService {
AND timestamp BETWEEN ? AND ?
AND ignored = false
ORDER BY
date_trunc('hour', timestamp) +
date_trunc('hour', timestamp) +
(EXTRACT(minute FROM timestamp)::int / %d) * interval '%d minutes',
timestamp
)
@@ -482,7 +433,6 @@ public class RawLocationPointJdbcService {
Timestamp.from(startTime), Timestamp.from(endTime));
}
@SuppressWarnings("DataFlowIssue")
public long countByUser(User user) {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM raw_location_points WHERE user_id = ?", Long.class, user.getId());
}

View File

@@ -47,8 +47,17 @@ public class SyntheticLocationPointGenerator {
// Calculate interpolation ratio (0.0 to 1.0)
long totalDuration = endTime.getEpochSecond() - startTime.getEpochSecond();
long currentDuration = currentTime.getEpochSecond() - startTime.getEpochSecond();
double ratio = (double) currentDuration / totalDuration;
double timeRatio = (double) currentDuration / totalDuration;
// Calculate speed and adjust the ratio based on it
double distance = GeoUtils.distanceInMeters(startPoint, endPoint);
double speed = distance / totalDuration; // meters per second
// Apply speed-based transformation: slower speeds shift points towards start
// Using a power function where speed < 1 m/s creates stronger clustering at start
double speedFactor = Math.min(speed / 5.0, 1.0); // normalize to 5 m/s as reference
double ratio = Math.pow(timeRatio, 1.0 / (speedFactor + 0.5));
// Interpolate coordinates
GeoPoint interpolatedCoords = interpolateCoordinates(
startPoint.getGeom(),

View File

@@ -3,6 +3,7 @@ package com.dedicatedcode.reitti.service.processing;
import com.dedicatedcode.reitti.config.RabbitMQConfig;
import com.dedicatedcode.reitti.event.LocationProcessEvent;
import com.dedicatedcode.reitti.event.SignificantPlaceCreatedEvent;
import com.dedicatedcode.reitti.model.ClusteredPoint;
import com.dedicatedcode.reitti.model.PlaceInformationOverride;
import com.dedicatedcode.reitti.model.geo.*;
import com.dedicatedcode.reitti.model.processing.DetectionParameter;
@@ -23,10 +24,8 @@ import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.stream.Collectors;
/**
* Unified service that processes the entire GPS pipeline atomically per user.
@@ -102,8 +101,7 @@ public class UnifiedLocationProcessingService {
String username = event.getUsername();
String previewId = event.getPreviewId();
logger.info("Processing location data for user [{}], mode: {}",
username, previewId == null ? "LIVE" : "PREVIEW");
logger.info("Processing location data for user [{}], mode: {}", username, previewId == null ? "LIVE" : "PREVIEW");
User user = userJdbcService.findByUsername(username)
.orElseThrow(() -> new IllegalStateException("User not found: " + username));
@@ -147,7 +145,7 @@ public class UnifiedLocationProcessingService {
}
long duration = System.currentTimeMillis() - startTime;
if (logger.isTraceEnabled()) {
// Tabular output for trace level logging
StringBuilder traceOutput = new StringBuilder();
@@ -155,13 +153,13 @@ public class UnifiedLocationProcessingService {
traceOutput.append("Event Period: ").append(event.getEarliest()).append("").append(event.getLatest()).append("\n");
traceOutput.append("Search Period: ").append(detectionResult.searchStart).append("").append(detectionResult.searchEnd).append("\n");
traceOutput.append("Duration: ").append(duration).append("ms\n\n");
// Input Visits Table
traceOutput.append("INPUT VISITS (").append(mergingResult.inputVisits.size()).append(") - took [").append(detectionResult.durationMillis).append("]ms:\n");
traceOutput.append("INPUT VISITS (").append(detectionResult.visits.size()).append(") - took [").append(detectionResult.durationInMillis).append("]ms:\n");
traceOutput.append("┌─────────────────────┬─────────────────────┬───────────┬─────────────┬─────────────┬──────────────────────────────────────────────────────────────────────┐\n");
traceOutput.append("│ Start Time │ End Time │ Duration │ Latitude │ Longitude │ Google Maps Link │\n");
traceOutput.append("├─────────────────────┼─────────────────────┼───────────┼─────────────┼─────────────┼──────────────────────────────────────────────────────────────────────┤\n");
for (Visit visit : mergingResult.inputVisits) {
for (Visit visit : detectionResult.visits) {
String googleMapsLink = "https://www.google.com/maps/search/?api=1&query=" + visit.getLatitude() + "," + visit.getLongitude();
traceOutput.append(String.format("│ %-19s │ %-19s │ %8ds │ %11.6f │ %11.6f │ %-68s │\n",
visit.getStartTime().toString().substring(0, 19),
@@ -172,9 +170,9 @@ public class UnifiedLocationProcessingService {
googleMapsLink));
}
traceOutput.append("└─────────────────────┴─────────────────────┴───────────┴─────────────┴─────────────┴──────────────────────────────────────────────────────────────────────┘\n\n");
// Processed Visits Table
traceOutput.append("PROCESSED VISITS (").append(mergingResult.processedVisits.size()).append(") - took [").append(mergingResult.durationMillis).append("]ms:\n");
traceOutput.append("PROCESSED VISITS (").append(mergingResult.processedVisits.size()).append(") - took [").append(mergingResult.durationInMillis).append("]ms:\n");
traceOutput.append("┌─────────────────────┬─────────────────────┬───────────┬─────────────┬─────────────┬──────────────────────┐\n");
traceOutput.append("│ Start Time │ End Time │ Duration │ Latitude │ Longitude │ Place Name │\n");
traceOutput.append("├─────────────────────┼─────────────────────┼───────────┼─────────────┼─────────────┼──────────────────────┤\n");
@@ -190,9 +188,9 @@ public class UnifiedLocationProcessingService {
placeName));
}
traceOutput.append("└─────────────────────┴─────────────────────┴───────────┴─────────────┴─────────────┴──────────────────────┘\n\n");
// Trips Table
traceOutput.append("TRIPS (").append(tripResult.trips.size()).append(") - took [").append(tripResult.durationMillis).append("]ms:\n");
traceOutput.append("TRIPS (").append(tripResult.trips.size()).append(") - took [").append(tripResult.durationInMillis).append("]ms:\n");
traceOutput.append("┌─────────────────────┬─────────────────────┬───────────┬───────────┬───────────┬─────────────────┐\n");
traceOutput.append("│ Start Time │ End Time │ Duration │ Distance │ Traveled │ Transport Mode │\n");
traceOutput.append("├─────────────────────┼─────────────────────┼───────────┼───────────┼───────────┼─────────────────┤\n");
@@ -206,10 +204,10 @@ public class UnifiedLocationProcessingService {
trip.getTransportModeInferred().toString()));
}
traceOutput.append("└─────────────────────┴─────────────────────┴───────────┴───────────┴───────────┴─────────────────┘\n");
logger.trace(traceOutput.toString());
}
logger.info("Completed processing for user [{}] in {}ms: {} visits → {} processed visits → {} trips",
username, duration, detectionResult.visits.size(),
mergingResult.processedVisits.size(), tripResult.trips.size());
@@ -245,7 +243,7 @@ public class UnifiedLocationProcessingService {
.findByUserAndStartTimeBeforeEqualAndEndTimeAfterEqual(user, previewId, windowEnd, windowStart);
}
// Expand the window based on found processed visits
// Expand window based on deleted processed visits
if (!existingProcessedVisits.isEmpty()) {
if (existingProcessedVisits.getFirst().getStartTime().isBefore(windowStart)) {
windowStart = existingProcessedVisits.getFirst().getStartTime();
@@ -254,15 +252,48 @@ public class UnifiedLocationProcessingService {
windowEnd = existingProcessedVisits.getLast().getEndTime();
}
}
List<Visit> visits;
// Get clustered points
double baseLatitude = existingProcessedVisits.isEmpty() ? 50 : existingProcessedVisits.getFirst().getPlace().getLatitudeCentroid();
double metersAsDegrees = GeoUtils.metersToDegreesAtPosition((double) currentConfiguration.getVisitMerging().getMinDistanceBetweenVisits() / 4, baseLatitude);
List<ClusteredPoint> clusteredPoints;
int minimumAdjacentPoints = Math.max(4, Math.toIntExact(detectionParams.getMinimumStayTimeInSeconds() / 60));
if (previewId == null) {
visits = rawLocationPointJdbcService.findVisitsInTimerangeForUser(
user, windowStart, windowEnd, detectionParams.getMinimumStayTimeInSeconds(), currentConfiguration.getVisitMerging().getMinDistanceBetweenVisits());
clusteredPoints = rawLocationPointJdbcService.findClusteredPointsInTimeRangeForUser(
user, windowStart, windowEnd, minimumAdjacentPoints, metersAsDegrees);
} else {
visits = previewRawLocationPointJdbcService.findVisitsInTimerangeForUser(
user, previewId, windowStart, windowEnd, detectionParams.getMinimumStayTimeInSeconds(), currentConfiguration.getVisitMerging().getMinDistanceBetweenVisits());
clusteredPoints = previewRawLocationPointJdbcService.findClusteredPointsInTimeRangeForUser(
user, previewId, windowStart, windowEnd, minimumAdjacentPoints, metersAsDegrees);
}
visits.sort(Comparator.comparing(Visit::getStartTime));
logger.debug("Searching for clustered points in range [{}, {}], minimum adjacent points: {} ", windowStart, windowEnd, minimumAdjacentPoints);
// Cluster by location and time
Map<Integer, List<RawLocationPoint>> clusteredByLocation = new TreeMap<>();
for (ClusteredPoint cp : clusteredPoints.stream().filter(clusteredPoint -> !clusteredPoint.getPoint().isIgnored()).toList()) {
if (cp.getClusterId() != null) {
clusteredByLocation
.computeIfAbsent(cp.getClusterId(), _ -> new ArrayList<>())
.add(cp.getPoint());
}
}
// Detect stay points
List<StayPoint> stayPoints = detectStayPointsFromTrajectory(clusteredByLocation, detectionParams);
// Create visits
List<Visit> visits = stayPoints.stream()
.map(sp -> new Visit(
sp.getLongitude(),
sp.getLatitude(),
sp.getArrivalTime(),
sp.getDepartureTime(),
sp.getDurationSeconds(),
false
))
.sorted(Comparator.comparing(Visit::getStartTime))
.toList();
return new VisitDetectionResult(visits, windowStart, windowEnd, System.currentTimeMillis() - start);
}
@@ -315,7 +346,6 @@ public class UnifiedLocationProcessingService {
return new VisitMergingResult(List.of(), List.of(), searchStart, searchEnd, System.currentTimeMillis() - start);
}
allVisits = allVisits.stream().sorted(Comparator.comparing(Visit::getStartTime)).toList();
// Merge visits chronologically
List<ProcessedVisit> processedVisits = mergeVisitsChronologically(
user, previewId, traceId, allVisits, mergeConfig);
@@ -363,6 +393,19 @@ public class UnifiedLocationProcessingService {
}
}
if (previewId == null) {
//recreate the trip between this run's first visit and the processed visit before. We deleted that when we cleared the processed visits in the search range. But only if it is max 24h apart
Optional<ProcessedVisit> firstProcessedVisitBefore = this.processedVisitJdbcService.findFirstProcessedVisitBefore(user, searchStart);
if (firstProcessedVisitBefore.isPresent() && Duration.between(firstProcessedVisitBefore.get().getEndTime(), processedVisits.getFirst().getStartTime()).toHours() <= 24) {
trips.add(createTripBetweenVisits(user, null, firstProcessedVisitBefore.get(), processedVisits.getFirst()));
}
Optional<ProcessedVisit> processedVisitAfter = this.processedVisitJdbcService.findFirstProcessedVisitAfter(user, searchEnd);
if (processedVisitAfter.isPresent() && Duration.between(processedVisits.getLast().getEndTime(), processedVisitAfter.get().getStartTime()).toHours() <= 24) {
trips.add(createTripBetweenVisits(user, null, processedVisits.getLast(), processedVisitAfter.get()));
}
}
trips.sort(Comparator.comparing(Trip::getStartTime));
// Save trips
if (previewId == null) {
trips = tripJdbcService.bulkInsert(user, trips);
@@ -373,6 +416,52 @@ public class UnifiedLocationProcessingService {
return new TripDetectionResult(trips, System.currentTimeMillis() - start);
}
private List<StayPoint> detectStayPointsFromTrajectory(
Map<Integer, List<RawLocationPoint>> points,
DetectionParameter.VisitDetection visitDetectionParameters) {
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(), visitDetectionParameters.getMaxMergeTimeBetweenSameStayPoints());
//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() < visitDetectionParameters.getMaxMergeTimeBetweenSameStayPoints()) {
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() > visitDetectionParameters.getMinimumStayTimeInSeconds())
.toList();
logger.debug("Found {} valid clusters after duration filtering with minimum stay time [{}]s", filteredByMinimumDuration.size(), visitDetectionParameters.getMinimumStayTimeInSeconds());
// Step 3: Convert valid clusters to stay points
return filteredByMinimumDuration.stream()
.map(this::createStayPoint)
.collect(Collectors.toList());
}
private List<ProcessedVisit> mergeVisitsChronologically(
User user, String previewId, String traceId, List<Visit> visits,
DetectionParameter.VisitMerging mergeConfiguration) {
@@ -394,13 +483,14 @@ public class UnifiedLocationProcessingService {
Visit nextVisit = visits.get(i);
if (nextVisit.getStartTime().isBefore(currentEndTime)) {
logger.debug("Skipping visit [{}] because it starts before the end time of the previous one [{}]", nextVisit, currentEndTime);
logger.error("Skipping visit [{}] because it starts before the end time of the previous one [{}]", nextVisit, currentEndTime);
continue;
}
SignificantPlace nextPlace = findOrCreateSignificantPlace(user, previewId, nextVisit.getLatitude(), nextVisit.getLongitude(), mergeConfiguration, traceId);
boolean samePlace = nextPlace.getId().equals(currentPlace.getId());
boolean withinTimeThreshold = Duration.between(currentEndTime, nextVisit.getStartTime()).getSeconds() <= mergeConfiguration.getMaxMergeTimeBetweenSameVisits();
boolean shouldMergeWithNextVisit = samePlace && withinTimeThreshold;
if (samePlace && !withinTimeThreshold) {
@@ -413,7 +503,7 @@ public class UnifiedLocationProcessingService {
if (pointsBetweenVisits.size() > 2) {
double travelledDistanceInMeters = GeoUtils.calculateTripDistance(pointsBetweenVisits);
shouldMergeWithNextVisit = travelledDistanceInMeters <= mergeConfiguration.getMinDistanceBetweenVisits();
} else {
} else {
logger.debug("There are no points tracked between {} and {}. Will merge consecutive visits because they are on the same place", currentEndTime, nextVisit.getStartTime());
shouldMergeWithNextVisit = true;
}
@@ -470,6 +560,166 @@ public class UnifiedLocationProcessingService {
return new ProcessedVisit(place, startTime, endTime, endTime.getEpochSecond() - startTime.getEpochSecond());
}
private StayPoint createStayPoint(List<RawLocationPoint> clusterPoints) {
GeoPoint result = weightedCenter(clusterPoints);
// Get the time range
Instant arrivalTime = clusterPoints.getFirst().getTimestamp();
Instant departureTime = clusterPoints.getLast().getTimestamp();
logger.trace("Creating stay point at [{}] with arrival time [{}] and departure time [{}]", result, arrivalTime, departureTime);
return new StayPoint(result.latitude(), result.longitude(), arrivalTime, departureTime, clusterPoints);
}
private GeoPoint weightedCenter(List<RawLocationPoint> clusterPoints) {
long start = System.currentTimeMillis();
GeoPoint result;
// For small clusters, use the original algorithm
if (clusterPoints.size() <= 100) {
result = weightedCenterSimple(clusterPoints);
} else {
// For large clusters, use spatial partitioning for better performance
result = weightedCenterOptimized(clusterPoints);
}
logger.trace("Weighted center calculation took {}ms for [{}] number of points", System.currentTimeMillis() - start, clusterPoints.size());
return result;
}
private GeoPoint weightedCenterSimple(List<RawLocationPoint> clusterPoints) {
RawLocationPoint bestPoint = null;
double maxDensityScore = 0;
// For each point, calculate a density score based on nearby points and accuracy
for (RawLocationPoint candidate : clusterPoints) {
double densityScore = 0;
for (RawLocationPoint neighbor : clusterPoints) {
if (candidate == neighbor) continue;
double distance = GeoUtils.distanceInMeters(candidate, neighbor);
double accuracy = candidate.getAccuracyMeters() != null && candidate.getAccuracyMeters() > 0
? candidate.getAccuracyMeters()
: 50.0; // default accuracy if null
// Points within accuracy radius contribute to density
// Closer points and better accuracy contribute more
if (distance <= accuracy * 2) {
double proximityWeight = Math.max(0, 1.0 - (distance / (accuracy * 2)));
double accuracyWeight = 1.0 / accuracy;
densityScore += proximityWeight * accuracyWeight;
}
}
// Add self-contribution based on accuracy
densityScore += 1.0 / (candidate.getAccuracyMeters() != null && candidate.getAccuracyMeters() > 0
? candidate.getAccuracyMeters()
: 50.0);
if (densityScore > maxDensityScore) {
maxDensityScore = densityScore;
bestPoint = candidate;
}
}
// Fallback to first point if no best point found
if (bestPoint == null) {
bestPoint = clusterPoints.getFirst();
}
return new GeoPoint(bestPoint.getLatitude(), bestPoint.getLongitude());
}
private GeoPoint weightedCenterOptimized(List<RawLocationPoint> clusterPoints) {
// Sample a subset of points for density calculation to improve performance
// Use every nth point or random sampling for very large clusters
int sampleSize = Math.min(200, clusterPoints.size());
List<RawLocationPoint> samplePoints = new ArrayList<>();
if (clusterPoints.size() <= sampleSize) {
samplePoints = clusterPoints;
} else {
// Take evenly distributed samples
int step = clusterPoints.size() / sampleSize;
for (int i = 0; i < clusterPoints.size(); i += step) {
samplePoints.add(clusterPoints.get(i));
}
}
// Use spatial grid approach to avoid distance calculations
// Create a grid based on the bounding box of all points
double minLat = clusterPoints.stream().mapToDouble(RawLocationPoint::getLatitude).min().orElse(0);
double minLon = clusterPoints.stream().mapToDouble(RawLocationPoint::getLongitude).min().orElse(0);
// Grid cell size approximately 10 meters (rough approximation)
double cellSizeLat = 0.0001; // ~11 meters
double cellSizeLon = 0.0001; // varies by latitude but roughly 11 meters
// Create grid map for fast neighbor lookup
Map<String, List<RawLocationPoint>> grid = new HashMap<>();
for (RawLocationPoint point : clusterPoints) {
int gridLat = (int) ((point.getLatitude() - minLat) / cellSizeLat);
int gridLon = (int) ((point.getLongitude() - minLon) / cellSizeLon);
String gridKey = gridLat + "," + gridLon;
grid.computeIfAbsent(gridKey, _ -> new ArrayList<>()).add(point);
}
RawLocationPoint bestPoint = null;
double maxDensityScore = 0;
// Calculate density scores for sample points using grid lookup
for (RawLocationPoint candidate : samplePoints) {
double accuracy = candidate.getAccuracyMeters() != null && candidate.getAccuracyMeters() > 0
? candidate.getAccuracyMeters()
: 50.0;
// Calculate grid coordinates for candidate
int candidateGridLat = (int) ((candidate.getLatitude() - minLat) / cellSizeLat);
int candidateGridLon = (int) ((candidate.getLongitude() - minLon) / cellSizeLon);
// Search radius in grid cells (conservative estimate)
int searchRadiusInCells = Math.max(1, (int) (accuracy / 100000)); // rough conversion
double densityScore = 0;
// Check neighboring grid cells
for (int latOffset = -searchRadiusInCells; latOffset <= searchRadiusInCells; latOffset++) {
for (int lonOffset = -searchRadiusInCells; lonOffset <= searchRadiusInCells; lonOffset++) {
String neighborKey = (candidateGridLat + latOffset) + "," + (candidateGridLon + lonOffset);
List<RawLocationPoint> neighbors = grid.get(neighborKey);
if (neighbors != null) {
for (RawLocationPoint neighbor : neighbors) {
if (candidate != neighbor) {
// Simple proximity weight based on grid distance
double gridDistance = Math.sqrt(latOffset * latOffset + lonOffset * lonOffset);
double proximityWeight = Math.max(0, 1.0 - (gridDistance / searchRadiusInCells));
densityScore += proximityWeight;
}
}
}
}
}
// Combine density with accuracy weight
double accuracyWeight = 1.0 / accuracy;
densityScore = (densityScore * accuracyWeight) + accuracyWeight;
if (densityScore > maxDensityScore) {
maxDensityScore = densityScore;
bestPoint = candidate;
}
}
// Fallback to first point if no best point found
if (bestPoint == null) {
bestPoint = clusterPoints.getFirst();
}
return new GeoPoint(bestPoint.getLatitude(), bestPoint.getLongitude());
}
private Trip createTripBetweenVisits(User user, String previewId,
ProcessedVisit startVisit, ProcessedVisit endVisit) {
// Trip starts when the first visit ends
@@ -611,13 +861,13 @@ public class UnifiedLocationProcessingService {
// ==================== Result Classes ====================
private record VisitDetectionResult(List<Visit> visits, Instant searchStart, Instant searchEnd, long durationMillis) {
private record VisitDetectionResult(List<Visit> visits, Instant searchStart, Instant searchEnd, long durationInMillis) {
}
private record VisitMergingResult(List<Visit> inputVisits, List<ProcessedVisit> processedVisits,
Instant searchStart, Instant searchEnd, long durationMillis) {
Instant searchStart, Instant searchEnd, long durationInMillis) {
}
private record TripDetectionResult(List<Trip> trips, long durationMillis) {
private record TripDetectionResult(List<Trip> trips, long durationInMillis) {
}
}

View File

@@ -69,7 +69,7 @@ reitti.security.oidc.registration.enabled=true
reitti.import.batch-size=10000
# How many seconds should we wait after the last data input before starting to process all unprocessed data?
reitti.import.processing-idle-start-time=15
reitti.import.processing-idle-start-time=5
reitti.events.concurrency=1-16
@@ -105,7 +105,7 @@ reitti.storage.path=data/
reitti.storage.cleanup.cron=0 0 4 * * *
# Location data density normalization
reitti.location.density.target-points-per-minute=2
reitti.location.density.target-points-per-minute=4
# Logging configuration
reitti.logging.buffer-size=1000

View File

@@ -236,11 +236,11 @@ label {
.toggle-buttons .btn {
padding: 0.375rem 0.75rem;
border: none;
cursor: pointer;
transition: all 0.15s ease-in-out;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--color-highlight);
}
.toggle-buttons .btn:not(:last-child) {

View File

@@ -23,7 +23,7 @@
hx-vals='js:{"timezone": getUserTimezone()}'
hx-target="#preview-area"
hx-include="#configuration-form"
hx-trigger="change[target.value != '']"
hx-trigger="change[target.value != ''] delay:1s"
name="previewDate">
</div>
</div>

View File

@@ -13,7 +13,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static com.dedicatedcode.reitti.TestConstants.Points.*;
@@ -44,43 +43,40 @@ public class ProcessingPipelineTest {
List<ProcessedVisit> processedVisits = currentVisits();
assertEquals(5, processedVisits.size());
assertVisit(processedVisits.get(0), "2025-06-16T22:01:43Z","2025-06-17T05:41:30Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:46:11Z","2025-06-17T05:55:03Z" , new GeoPoint(53.86925378333333,10.711828311111113));
assertVisit(processedVisits.get(2), "2025-06-17T06:00:30Z","2025-06-17T13:10:17Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:15:11Z","2025-06-17T13:19:22Z" , new GeoPoint(53.86753033888888,10.710380756666666));
assertVisit(processedVisits.get(4), "2025-06-17T13:23:48Z","2025-06-17T21:59:44Z" , MOLTKESTR);
assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:40:26Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:43:37.962Z", "2025-06-17T05:55:03.792Z" , ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-17T05:58:10.797Z", "2025-06-17T13:08:53.346Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:12:01.542Z", "2025-06-17T13:18:51.590Z" , ST_THOMAS);
assertVisit(processedVisits.get(4), "2025-06-17T13:21:28.334Z", "2025-06-17T21:59:44.876Z" , MOLTKESTR);
List<Trip> trips = currenTrips();
assertEquals(4, trips.size());
assertTrip(trips.get(0), "2025-06-17T05:41:30Z" , MOLTKESTR, "2025-06-17T05:46:11Z" , new GeoPoint(53.86925378333333,10.711828311111113));
assertTrip(trips.get(1), "2025-06-17T05:55:03Z" , new GeoPoint(53.86925378333333,10.711828311111113), "2025-06-17T06:00:30Z" , MOLTKESTR);
assertTrip(trips.get(2), "2025-06-17T13:10:17Z" , MOLTKESTR, "2025-06-17T13:15:11Z" , new GeoPoint(53.86753033888888,10.710380756666666));
assertTrip(trips.get(3), "2025-06-17T13:19:22Z" , new GeoPoint(53.86753033888888,10.710380756666666), "2025-06-17T13:23:48Z" , MOLTKESTR);
assertTrip(trips.get(0), "2025-06-17T05:40:26Z" , MOLTKESTR, "2025-06-17T05:43:37.962Z" , ST_THOMAS);
assertTrip(trips.get(1), "2025-06-17T05:55:03.792Z" , ST_THOMAS, "2025-06-17T05:58:10.797Z" , MOLTKESTR);
assertTrip(trips.get(2), "2025-06-17T13:08:53.346Z" , MOLTKESTR, "2025-06-17T13:12:01.542Z" , ST_THOMAS);
assertTrip(trips.get(3), "2025-06-17T13:18:51.590Z" , ST_THOMAS, "2025-06-17T13:21:28.334Z" , MOLTKESTR);
testingService.importAndProcess(user, "/data/gpx/20250618.gpx");
processedVisits = currentVisits();
assertEquals(12, processedVisits.size());
assertEquals(10, processedVisits.size());
//new visits
assertVisit(processedVisits.get(0), "2025-06-16T22:01:43Z", "2025-06-17T05:41:30Z", MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:46:11Z", "2025-06-17T05:55:03Z", new GeoPoint(53.86925378333333,10.711828311111113));
assertVisit(processedVisits.get(2), "2025-06-17T06:00:30Z", "2025-06-17T13:10:17Z", MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:15:11Z", "2025-06-17T13:19:22Z", new GeoPoint(53.86753033888888,10.710380756666666));
//should not touch visits before the new data
assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:41:00Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:41:30.989Z", "2025-06-17T05:57:07.729Z" , ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-17T05:57:41Z" , "2025-06-17T13:09:29Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:09:51.476Z", "2025-06-17T13:20:24.494Z" , ST_THOMAS);
//should extend the last visit of the old day
assertVisit(processedVisits.get(4), "2025-06-17T13:23:48Z" ,"2025-06-18T05:47:44Z", MOLTKESTR);
assertVisit(processedVisits.get(4), "2025-06-17T13:20:58Z" , "2025-06-18T05:46:43Z", MOLTKESTR);
//should not touch visits after the new data
assertVisit(processedVisits.get(5) ,"2025-06-18T05:48:15Z" ,"2025-06-18T05:53:33Z", new GeoPoint(53.863864, 10.708570));
assertVisit(processedVisits.get(6) ,"2025-06-18T05:56:46Z" ,"2025-06-18T06:03:00Z", new GeoPoint(53.86753033888888,10.710380756666666));
assertVisit(processedVisits.get(7) ,"2025-06-18T06:07:46Z" ,"2025-06-18T13:02:27Z", MOLTKESTR);
assertVisit(processedVisits.get(8) ,"2025-06-18T13:07:42Z" ,"2025-06-18T13:16:57Z", new GeoPoint(53.86925378333333,10.711828311111113));
assertVisit(processedVisits.get(9) ,"2025-06-18T13:20:10Z" ,"2025-06-18T13:29:46Z", new GeoPoint(53.872904, 10.720157));
assertVisit(processedVisits.get(10),"2025-06-18T13:35:46Z" ,"2025-06-18T15:52:19Z", GARTEN);
assertVisit(processedVisits.get(11),"2025-06-18T16:05:49Z" ,"2025-06-18T21:59:29Z", MOLTKESTR);
//new visits
assertVisit(processedVisits.get(5), "2025-06-18T05:47:13.682Z" ,"2025-06-18T06:04:02.435Z" , ST_THOMAS);
assertVisit(processedVisits.get(6), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR);
assertVisit(processedVisits.get(7), "2025-06-18T13:02:27.656Z" ,"2025-06-18T13:14:19.417Z" , ST_THOMAS);
assertVisit(processedVisits.get(8), "2025-06-18T13:33:05Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(9), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR);
}
@Test
@@ -88,40 +84,36 @@ public class ProcessingPipelineTest {
testingService.importAndProcess(user, "/data/gpx/20250618.gpx");
List<ProcessedVisit> processedVisits = currentVisits();
assertEquals(8, processedVisits.size());
assertEquals(6, processedVisits.size());
assertVisit(processedVisits.get(0), "2025-06-17T22:01:48Z" ,"2025-06-18T05:47:44Z", MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-18T05:48:15Z" ,"2025-06-18T05:53:33Z", new GeoPoint(53.863864, 10.708570));
assertVisit(processedVisits.get(2), "2025-06-18T05:56:46Z" ,"2025-06-18T06:03:00Z", ST_THOMAS);
assertVisit(processedVisits.get(3), "2025-06-18T06:07:46Z" ,"2025-06-18T13:02:27Z", MOLTKESTR);
assertVisit(processedVisits.get(4), "2025-06-18T13:07:42Z" ,"2025-06-18T13:16:57Z", ST_THOMAS);
assertVisit(processedVisits.get(5), "2025-06-18T13:20:10Z" ,"2025-06-18T13:29:46Z", new GeoPoint(53.872904, 10.720157));
assertVisit(processedVisits.get(6), "2025-06-18T13:35:46Z" ,"2025-06-18T15:52:19Z", GARTEN);
assertVisit(processedVisits.get(7), "2025-06-18T16:05:49Z" ,"2025-06-18T21:59:29Z", MOLTKESTR);
assertVisit(processedVisits.get(0), "2025-06-17T22:00:15.843Z" ,"2025-06-18T05:46:10Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-18T05:53:33.667Z" ,"2025-06-18T06:01:54.440Z" , ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-18T13:04:33.424Z" ,"2025-06-18T13:13:47.443Z" , ST_THOMAS);
assertVisit(processedVisits.get(4), "2025-06-18T13:33:35.626Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(5), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR);
testingService.importAndProcess(user, "/data/gpx/20250617.gpx");
processedVisits = currentVisits();
assertEquals(12, processedVisits.size());
assertEquals(10, processedVisits.size());
//new visits
assertVisit(processedVisits.get(0), "2025-06-16T22:01:43Z", "2025-06-17T05:41:30Z", MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:46:11Z", "2025-06-17T05:55:03Z", ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-17T06:00:30Z", "2025-06-17T13:10:17Z", MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:15:11Z", "2025-06-17T13:19:22Z", ST_THOMAS);
assertVisit(processedVisits.get(0), "2025-06-16T22:00:09.154Z", "2025-06-17T05:41:00Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-17T05:41:30.989Z", "2025-06-17T05:57:07.729Z" , ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-17T05:57:41Z" , "2025-06-17T13:09:29Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-17T13:09:51.476Z", "2025-06-17T13:20:24.494Z" , ST_THOMAS);
//should extend the last visit of the old day
assertVisit(processedVisits.get(4), "2025-06-17T13:23:48Z" ,"2025-06-18T05:47:44Z", MOLTKESTR);
assertVisit(processedVisits.get(4), "2025-06-17T13:20:58Z" , "2025-06-18T05:46:43Z" , MOLTKESTR);
//should not touch visits after the new data
assertVisit(processedVisits.get(5) ,"2025-06-18T05:48:15Z" ,"2025-06-18T05:53:33Z", new GeoPoint(53.863864, 10.708570));
assertVisit(processedVisits.get(6) ,"2025-06-18T05:56:46Z" ,"2025-06-18T06:03:00Z", ST_THOMAS);
assertVisit(processedVisits.get(7) ,"2025-06-18T06:07:46Z" ,"2025-06-18T13:02:27Z", MOLTKESTR);
assertVisit(processedVisits.get(8) ,"2025-06-18T13:07:42Z" ,"2025-06-18T13:16:57Z", ST_THOMAS);
assertVisit(processedVisits.get(9) ,"2025-06-18T13:20:10Z" ,"2025-06-18T13:29:46Z", new GeoPoint(53.872904, 10.720157));
assertVisit(processedVisits.get(10),"2025-06-18T13:35:46Z" ,"2025-06-18T15:52:19Z", GARTEN);
assertVisit(processedVisits.get(11),"2025-06-18T16:05:49Z" ,"2025-06-18T21:59:29Z", MOLTKESTR);
assertVisit(processedVisits.get(5), "2025-06-18T05:47:13.682Z" ,"2025-06-18T06:04:02.435Z" , ST_THOMAS);
assertVisit(processedVisits.get(6), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR);
assertVisit(processedVisits.get(7), "2025-06-18T13:02:27.656Z" ,"2025-06-18T13:14:19.417Z" , ST_THOMAS);
assertVisit(processedVisits.get(8), "2025-06-18T13:33:05Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(9), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR);
}
@Test
@@ -129,16 +121,14 @@ public class ProcessingPipelineTest {
testingService.importAndProcess(user, "/data/gpx/20250618.gpx");
List<ProcessedVisit> processedVisits = currentVisits();
assertEquals(8, processedVisits.size());
assertEquals(6, processedVisits.size());
assertVisit(processedVisits.get(0), "2025-06-17T22:01:48Z", "2025-06-18T05:47:44Z", MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-18T05:48:15Z", "2025-06-18T05:53:33Z", new GeoPoint(53.86386352818182,10.708570236363638));
assertVisit(processedVisits.get(2), "2025-06-18T05:56:46Z", "2025-06-18T06:03:00Z", ST_THOMAS);
assertVisit(processedVisits.get(3), "2025-06-18T06:07:46Z", "2025-06-18T13:02:27Z", MOLTKESTR);
assertVisit(processedVisits.get(4), "2025-06-18T13:07:42Z", "2025-06-18T13:16:57Z", new GeoPoint(53.868274,10.712731));
assertVisit(processedVisits.get(5), "2025-06-18T13:20:10Z", "2025-06-18T13:29:46Z", new GeoPoint(53.872904,10.720157));
assertVisit(processedVisits.get(6), "2025-06-18T13:35:46Z", "2025-06-18T15:52:19Z", GARTEN);
assertVisit(processedVisits.get(7), "2025-06-18T16:05:49Z", "2025-06-18T21:59:29Z", MOLTKESTR);
assertVisit(processedVisits.get(0), "2025-06-17T22:00:15.843Z" , "2025-06-18T05:46:10Z" , MOLTKESTR);
assertVisit(processedVisits.get(1), "2025-06-18T05:53:33.667Z" ,"2025-06-18T06:01:54.440Z" , ST_THOMAS);
assertVisit(processedVisits.get(2), "2025-06-18T06:04:36Z" ,"2025-06-18T13:01:57Z" , MOLTKESTR);
assertVisit(processedVisits.get(3), "2025-06-18T13:04:33.424Z" ,"2025-06-18T13:13:47.443Z" , ST_THOMAS);
assertVisit(processedVisits.get(4), "2025-06-18T13:33:35.626Z" ,"2025-06-18T15:50:40Z" , GARTEN);
assertVisit(processedVisits.get(5), "2025-06-18T16:02:38Z" ,"2025-06-18T21:59:29.055Z" , MOLTKESTR);
}
@@ -184,15 +174,15 @@ public class ProcessingPipelineTest {
for (int i = 0; i < processedVisitsOutOfOrder.size(); i++) {
ProcessedVisit processedVisit = processedVisitsOutOfOrder.get(i);
ProcessedVisit processedVisitInOrder = processedVisitsInOrder.get(i);
assertEquals(processedVisitInOrder.getStartTime().truncatedTo(ChronoUnit.SECONDS), processedVisit.getStartTime().truncatedTo(ChronoUnit.SECONDS));
assertEquals(processedVisitInOrder.getEndTime().truncatedTo(ChronoUnit.SECONDS), processedVisit.getEndTime().truncatedTo(ChronoUnit.SECONDS));
assertEquals(processedVisitInOrder.getStartTime(), processedVisit.getStartTime());
assertEquals(processedVisitInOrder.getEndTime(), processedVisit.getEndTime());
assertEquals(processedVisitInOrder.getPlace(), processedVisit.getPlace());
}
}
private static void assertVisit(ProcessedVisit processedVisit, String startTime, String endTime, GeoPoint location) {
assertEquals(Instant.parse(startTime).truncatedTo(ChronoUnit.SECONDS), processedVisit.getStartTime().truncatedTo(ChronoUnit.SECONDS));
assertEquals(Instant.parse(endTime).truncatedTo(ChronoUnit.SECONDS), processedVisit.getEndTime().truncatedTo(ChronoUnit.SECONDS));
assertEquals(Instant.parse(startTime), processedVisit.getStartTime());
assertEquals(Instant.parse(endTime), processedVisit.getEndTime());
GeoPoint currentLocation = new GeoPoint(processedVisit.getPlace().getLatitudeCentroid(), processedVisit.getPlace().getLongitudeCentroid());
assertTrue(location.near(currentLocation), "Locations are not near to each other. \nExpected [" + currentLocation + "] to be in range \nto [" + location + "]");
}
@@ -206,8 +196,8 @@ public class ProcessingPipelineTest {
}
private static void assertTrip(Trip trip, String startTime, GeoPoint startLocation, String endTime, GeoPoint endLocation) {
assertEquals(Instant.parse(startTime).truncatedTo(ChronoUnit.SECONDS), trip.getStartTime().truncatedTo(ChronoUnit.SECONDS));
assertEquals(Instant.parse(endTime).truncatedTo(ChronoUnit.SECONDS), trip.getEndTime().truncatedTo(ChronoUnit.SECONDS));
assertEquals(Instant.parse(startTime), trip.getStartTime());
assertEquals(Instant.parse(endTime), trip.getEndTime());
GeoPoint actualStartLocation = GeoPoint.from(trip.getStartVisit().getPlace().getLatitudeCentroid(), trip.getStartVisit().getPlace().getLongitudeCentroid());
assertTrue(startLocation.near(actualStartLocation),