Skip to content

Commit 7ca639c

Browse files
authored
Data gap improvements (#352)
* Adjusted logic for data gaps to make it more user friendly * Refactored data gap logic
1 parent aae009c commit 7ca639c

File tree

10 files changed

+946
-138
lines changed

10 files changed

+946
-138
lines changed

backend/src/main/java/org/github/tess1o/geopulse/streaming/engine/DataGapDetectionEngine.java

Lines changed: 29 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public class DataGapDetectionEngine {
3434
@Inject
3535
StreamingDataGapService dataGapService;
3636

37+
@Inject
38+
GapStayInferenceService gapStayInferenceService;
39+
3740
@Inject
3841
TravelClassification travelClassification;
3942

@@ -64,11 +67,14 @@ public List<TimelineEvent> checkForDataGap(GPSPoint currentPoint, UserState user
6467
timeDelta, lastPoint.getTimestamp(), currentPoint.getTimestamp());
6568

6669
// Priority 1: Check if we should infer a stay instead of creating a gap
67-
boolean shouldInferStay = shouldInferStayDuringGap(currentPoint, userState, config, timeDelta);
70+
GapStayInferencePlan stayInferencePlan =
71+
gapStayInferenceService.tryInfer(currentPoint, userState, config, timeDelta);
72+
boolean shouldInferStay = stayInferencePlan.isInferred();
6873
log.info("Gap stay inference check: shouldInfer={}, enabled={}",
6974
shouldInferStay, config.getGapStayInferenceEnabled());
7075
if (shouldInferStay) {
7176
log.info("Gap stay inference applied - skipping gap creation, points are at same location");
77+
applyStayInferencePlan(stayInferencePlan, userState, config, gapEvents);
7278
// Don't create gap, don't reset state - let state machine process the point normally
7379
// The point will be added to activePoints and duration calculation will span the gap
7480
return gapEvents;
@@ -125,72 +131,35 @@ public List<TimelineEvent> checkForDataGap(GPSPoint currentPoint, UserState user
125131
return gapEvents;
126132
}
127133

128-
/**
129-
* Determines whether to infer a stay during a data gap instead of creating a gap.
130-
* This feature helps capture overnight stays at home or extended stays where
131-
* the app doesn't send GPS data but the user remains at the same location.
132-
*
133-
* @param currentPoint the GPS point after the gap
134-
* @param userState current user processing state
135-
* @param config timeline configuration
136-
* @param gapDuration duration of the gap
137-
* @return true if a stay should be inferred instead of creating a gap
138-
*/
139-
private boolean shouldInferStayDuringGap(GPSPoint currentPoint, UserState userState,
140-
TimelineConfig config, Duration gapDuration) {
141-
// Check if feature is enabled
142-
Boolean enabled = config.getGapStayInferenceEnabled();
143-
if (enabled == null || !enabled) {
144-
log.debug("Gap stay inference is disabled (enabled={})", enabled);
145-
return false;
146-
}
147-
148-
// Check if we have active points to compare against
149-
if (!userState.hasActivePoints()) {
150-
log.debug("No active points for gap stay inference comparison");
151-
return false;
152-
}
153-
154-
// Check if current mode is POTENTIAL_STAY or CONFIRMED_STAY (not IN_TRIP)
155-
ProcessorMode mode = userState.getCurrentMode();
156-
if (mode == ProcessorMode.IN_TRIP || mode == ProcessorMode.UNKNOWN) {
157-
log.debug("Gap stay inference not applicable for mode: {}", mode);
158-
return false;
159-
}
160-
161-
// Check if gap is within max duration
162-
Integer maxGapHours = config.getGapStayInferenceMaxGapHours();
163-
if (maxGapHours != null && maxGapHours > 0) {
164-
long gapHours = gapDuration.toHours();
165-
if (gapHours > maxGapHours) {
166-
log.debug("Gap duration {}h exceeds max allowed {}h for stay inference",
167-
gapHours, maxGapHours);
168-
return false;
134+
private void applyStayInferencePlan(GapStayInferencePlan plan, UserState userState,
135+
TimelineConfig config, List<TimelineEvent> gapEvents) {
136+
if (plan.hasTripToFinalize()) {
137+
Trip finalizedTrip = finalizeTripFromPoints(plan.getTripPointsToFinalize(), config);
138+
if (finalizedTrip != null) {
139+
gapEvents.add(finalizedTrip);
169140
}
170141
}
171142

172-
// Calculate distance between centroid and current point
173-
GPSPoint centroid = userState.calculateCentroid();
174-
if (centroid == null) {
175-
log.debug("Could not calculate centroid for gap stay inference");
176-
return false;
143+
if (plan.hasReplacementStayPoints()) {
144+
userState.setCurrentMode(ProcessorMode.CONFIRMED_STAY);
145+
userState.clearActivePoints();
146+
for (GPSPoint point : plan.getReplacementStayPoints()) {
147+
userState.addActivePoint(point);
148+
}
177149
}
150+
}
178151

179-
double distance = centroid.distanceTo(currentPoint);
180-
Integer radiusMeters = config.getStaypointRadiusMeters();
181-
if (radiusMeters == null) {
182-
radiusMeters = 50; // default
152+
private Trip finalizeTripFromPoints(List<GPSPoint> tripPoints, TimelineConfig config) {
153+
if (tripPoints == null || tripPoints.size() < 2) {
154+
return null;
183155
}
184156

185-
if (distance > radiusMeters) {
186-
log.debug("Distance {}m from centroid exceeds stay radius {}m - creating gap instead",
187-
String.format("%.1f", distance), radiusMeters);
188-
return false;
157+
UserState tripState = new UserState();
158+
tripState.setCurrentMode(ProcessorMode.IN_TRIP);
159+
for (GPSPoint point : tripPoints) {
160+
tripState.addActivePoint(point);
189161
}
190-
191-
log.info("Gap stay inference conditions met: mode={}, gap={}h, distance={}m (radius={}m)",
192-
mode, gapDuration.toHours(), String.format("%.1f", distance), radiusMeters);
193-
return true;
162+
return finalizationService.finalizeTrip(tripState, config);
194163
}
195164

196165
/**
@@ -349,4 +318,4 @@ private TimelineEvent finalizeActiveEvent(UserState userState, GPSPoint lastPoin
349318
return null;
350319
}
351320
}
352-
}
321+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package org.github.tess1o.geopulse.streaming.engine;
2+
3+
import org.github.tess1o.geopulse.streaming.model.domain.GPSPoint;
4+
5+
import java.util.Collections;
6+
import java.util.List;
7+
8+
/**
9+
* Internal result object describing how gap stay inference should be applied.
10+
* Keeps decisioning separate from event finalization/state mutation in the engine.
11+
*/
12+
final class GapStayInferencePlan {
13+
14+
private static final GapStayInferencePlan NONE =
15+
new GapStayInferencePlan(false, Collections.emptyList(), null);
16+
private static final GapStayInferencePlan CONTINUE_EXISTING_STAY =
17+
new GapStayInferencePlan(true, Collections.emptyList(), null);
18+
19+
private final boolean inferred;
20+
private final List<GPSPoint> tripPointsToFinalize;
21+
private final List<GPSPoint> replacementStayPoints;
22+
23+
private GapStayInferencePlan(boolean inferred,
24+
List<GPSPoint> tripPointsToFinalize,
25+
List<GPSPoint> replacementStayPoints) {
26+
this.inferred = inferred;
27+
this.tripPointsToFinalize = tripPointsToFinalize;
28+
this.replacementStayPoints = replacementStayPoints;
29+
}
30+
31+
static GapStayInferencePlan none() {
32+
return NONE;
33+
}
34+
35+
static GapStayInferencePlan continueExistingStay() {
36+
return CONTINUE_EXISTING_STAY;
37+
}
38+
39+
static GapStayInferencePlan replaceWithConfirmedStay(List<GPSPoint> stayPoints) {
40+
return new GapStayInferencePlan(true, List.of(), List.copyOf(stayPoints));
41+
}
42+
43+
static GapStayInferencePlan finalizeTripAndReplaceWithConfirmedStay(List<GPSPoint> tripPoints,
44+
List<GPSPoint> stayPoints) {
45+
return new GapStayInferencePlan(true, List.copyOf(tripPoints), List.copyOf(stayPoints));
46+
}
47+
48+
boolean isInferred() {
49+
return inferred;
50+
}
51+
52+
boolean hasTripToFinalize() {
53+
return !tripPointsToFinalize.isEmpty();
54+
}
55+
56+
List<GPSPoint> getTripPointsToFinalize() {
57+
return tripPointsToFinalize;
58+
}
59+
60+
boolean hasReplacementStayPoints() {
61+
return replacementStayPoints != null && !replacementStayPoints.isEmpty();
62+
}
63+
64+
List<GPSPoint> getReplacementStayPoints() {
65+
return replacementStayPoints;
66+
}
67+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package org.github.tess1o.geopulse.streaming.engine;
2+
3+
import jakarta.enterprise.context.ApplicationScoped;
4+
import jakarta.inject.Inject;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.github.tess1o.geopulse.streaming.config.TimelineConfig;
7+
import org.github.tess1o.geopulse.streaming.model.domain.GPSPoint;
8+
import org.github.tess1o.geopulse.streaming.model.domain.ProcessorMode;
9+
import org.github.tess1o.geopulse.streaming.model.domain.UserState;
10+
11+
import java.time.Duration;
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
15+
/**
16+
* Evaluates whether a data gap can be treated as stay continuity instead of creating a DataGap.
17+
*
18+
* This service intentionally performs no event finalization and does not mutate the caller's state.
19+
* It returns an internal plan that the engine can apply.
20+
*/
21+
@Slf4j
22+
@ApplicationScoped
23+
class GapStayInferenceService {
24+
25+
private static final Duration MAX_IN_TRIP_LOCAL_EXCURSION_DURATION = Duration.ofMinutes(30);
26+
private static final double IN_TRIP_LOCAL_EXCURSION_RADIUS_MULTIPLIER = 2;
27+
28+
@Inject
29+
TripStopHeuristicsService tripStopHeuristicsService;
30+
31+
GapStayInferencePlan tryInfer(GPSPoint currentPoint, UserState userState,
32+
TimelineConfig config, Duration gapDuration) {
33+
Boolean enabled = config.getGapStayInferenceEnabled();
34+
if (enabled == null || !enabled) {
35+
log.debug("Gap stay inference is disabled (enabled={})", enabled);
36+
return GapStayInferencePlan.none();
37+
}
38+
39+
if (!userState.hasActivePoints()) {
40+
log.debug("No active points for gap stay inference comparison");
41+
return GapStayInferencePlan.none();
42+
}
43+
44+
Integer maxGapHours = config.getGapStayInferenceMaxGapHours();
45+
if (maxGapHours != null && maxGapHours > 0) {
46+
long gapHours = gapDuration.toHours();
47+
if (gapHours > maxGapHours) {
48+
log.debug("Gap duration {}h exceeds max allowed {}h for stay inference",
49+
gapHours, maxGapHours);
50+
return GapStayInferencePlan.none();
51+
}
52+
}
53+
54+
ProcessorMode mode = userState.getCurrentMode();
55+
if (mode == ProcessorMode.UNKNOWN) {
56+
log.debug("Gap stay inference not applicable for mode: {}", mode);
57+
return GapStayInferencePlan.none();
58+
}
59+
60+
int stayRadiusMeters = tripStopHeuristicsService.getStayRadiusMeters(config);
61+
62+
if (mode == ProcessorMode.IN_TRIP) {
63+
GapStayInferencePlan localTripPlan =
64+
tryInferForShortLocalTrip(currentPoint, userState, stayRadiusMeters, gapDuration);
65+
if (localTripPlan.isInferred()) {
66+
return localTripPlan;
67+
}
68+
return tryInferFromTripTailArrival(currentPoint, userState, config, stayRadiusMeters, gapDuration);
69+
}
70+
71+
return tryInferForStayModes(currentPoint, userState, stayRadiusMeters, gapDuration, mode);
72+
}
73+
74+
private GapStayInferencePlan tryInferForStayModes(GPSPoint currentPoint, UserState userState,
75+
int stayRadiusMeters, Duration gapDuration,
76+
ProcessorMode mode) {
77+
GPSPoint centroid = userState.calculateCentroid();
78+
if (centroid == null) {
79+
log.debug("Could not calculate centroid for gap stay inference");
80+
return GapStayInferencePlan.none();
81+
}
82+
83+
double distance = centroid.distanceTo(currentPoint);
84+
if (distance > stayRadiusMeters) {
85+
log.debug("Distance {}m from centroid exceeds stay radius {}m - creating gap instead",
86+
String.format("%.1f", distance), stayRadiusMeters);
87+
return GapStayInferencePlan.none();
88+
}
89+
90+
log.info("Gap stay inference conditions met: mode={}, gap={}h, distance={}m (radius={}m)",
91+
mode, gapDuration.toHours(), String.format("%.1f", distance), stayRadiusMeters);
92+
return GapStayInferencePlan.continueExistingStay();
93+
}
94+
95+
private GapStayInferencePlan tryInferForShortLocalTrip(GPSPoint currentPoint, UserState userState,
96+
int stayRadiusMeters, Duration gapDuration) {
97+
List<GPSPoint> activeTripPoints = userState.copyActivePoints();
98+
if (activeTripPoints.size() < 2) {
99+
log.debug("Gap stay inference not applicable for IN_TRIP with fewer than 2 active points");
100+
return GapStayInferencePlan.none();
101+
}
102+
103+
GPSPoint firstTripPoint = activeTripPoints.get(0);
104+
GPSPoint lastTripPoint = activeTripPoints.get(activeTripPoints.size() - 1);
105+
if (firstTripPoint.getTimestamp() == null || lastTripPoint.getTimestamp() == null) {
106+
log.debug("Gap stay inference not applicable for IN_TRIP with missing timestamps");
107+
return GapStayInferencePlan.none();
108+
}
109+
110+
Duration pendingTripDuration = Duration.between(firstTripPoint.getTimestamp(), lastTripPoint.getTimestamp());
111+
if (pendingTripDuration.compareTo(MAX_IN_TRIP_LOCAL_EXCURSION_DURATION) > 0) {
112+
log.debug("Pending IN_TRIP duration {} exceeds local excursion limit {} for gap stay inference",
113+
pendingTripDuration, MAX_IN_TRIP_LOCAL_EXCURSION_DURATION);
114+
return GapStayInferencePlan.none();
115+
}
116+
117+
double resumeDistance = lastTripPoint.distanceTo(currentPoint);
118+
if (resumeDistance > stayRadiusMeters) {
119+
log.debug("IN_TRIP resume distance {}m exceeds stay radius {}m - creating gap instead",
120+
String.format("%.1f", resumeDistance), stayRadiusMeters);
121+
return GapStayInferencePlan.none();
122+
}
123+
124+
double maxDistanceFromTripEnd = 0.0;
125+
for (GPSPoint tripPoint : activeTripPoints) {
126+
maxDistanceFromTripEnd = Math.max(maxDistanceFromTripEnd, tripPoint.distanceTo(lastTripPoint));
127+
}
128+
129+
double localExcursionLimitMeters = stayRadiusMeters * IN_TRIP_LOCAL_EXCURSION_RADIUS_MULTIPLIER;
130+
if (maxDistanceFromTripEnd > localExcursionLimitMeters) {
131+
log.debug("Pending IN_TRIP spread {}m exceeds local excursion limit {}m (radius={}m x {})",
132+
String.format("%.1f", maxDistanceFromTripEnd),
133+
String.format("%.1f", localExcursionLimitMeters),
134+
stayRadiusMeters,
135+
IN_TRIP_LOCAL_EXCURSION_RADIUS_MULTIPLIER);
136+
return GapStayInferencePlan.none();
137+
}
138+
139+
List<GPSPoint> localPoints = collectPointsWithinRadius(activeTripPoints, lastTripPoint, stayRadiusMeters);
140+
141+
log.info("Gap stay inference conditions met for short local IN_TRIP: gap={}h, pendingTrip={}, " +
142+
"resumeDistance={}m, spread={}m (radius={}m)",
143+
gapDuration.toHours(),
144+
pendingTripDuration,
145+
String.format("%.1f", resumeDistance),
146+
String.format("%.1f", maxDistanceFromTripEnd),
147+
stayRadiusMeters);
148+
return GapStayInferencePlan.replaceWithConfirmedStay(localPoints);
149+
}
150+
151+
private GapStayInferencePlan tryInferFromTripTailArrival(GPSPoint currentPoint, UserState userState,
152+
TimelineConfig config, int stayRadiusMeters,
153+
Duration gapDuration) {
154+
List<GPSPoint> activeTripPoints = userState.copyActivePoints();
155+
TripStopHeuristicsService.TailArrivalClusterMatch tailMatch =
156+
tripStopHeuristicsService.findGapTailArrivalClusterMatch(activeTripPoints, currentPoint, config);
157+
if (!tailMatch.isMatched()) {
158+
return GapStayInferencePlan.none();
159+
}
160+
int stopClusterStartIndex = tailMatch.getStartIndex();
161+
162+
List<GPSPoint> tripPoints = new ArrayList<>(activeTripPoints.subList(0, stopClusterStartIndex));
163+
List<GPSPoint> stoppedTailPoints = new ArrayList<>(activeTripPoints.subList(stopClusterStartIndex, activeTripPoints.size()));
164+
165+
log.info("Gap stay inference conditions met for IN_TRIP tail arrival: gap={}h, tailPoints={}, tailDuration={}, " +
166+
"resumeDistance={}m (radius={}m), finalizedTripPoints={}",
167+
gapDuration.toHours(),
168+
stoppedTailPoints.size(),
169+
tailMatch.getTailDuration(),
170+
String.format("%.1f", tailMatch.getResumeDistanceMeters()),
171+
tailMatch.getStayRadiusMeters(),
172+
tripPoints.size());
173+
174+
return GapStayInferencePlan.finalizeTripAndReplaceWithConfirmedStay(tripPoints, stoppedTailPoints);
175+
}
176+
177+
private List<GPSPoint> collectPointsWithinRadius(List<GPSPoint> points, GPSPoint anchorPoint, int radiusMeters) {
178+
List<GPSPoint> localPoints = new ArrayList<>();
179+
for (GPSPoint point : points) {
180+
if (point.distanceTo(anchorPoint) <= radiusMeters) {
181+
localPoints.add(point);
182+
}
183+
}
184+
if (localPoints.isEmpty()) {
185+
localPoints.add(anchorPoint);
186+
}
187+
return localPoints;
188+
}
189+
190+
}

0 commit comments

Comments
 (0)