diff --git a/README_HOME_TO_HOME_TRIP_PATCH.md b/README_HOME_TO_HOME_TRIP_PATCH.md
new file mode 100644
index 0000000..0bf69c5
--- /dev/null
+++ b/README_HOME_TO_HOME_TRIP_PATCH.md
@@ -0,0 +1,52 @@
+# HOME-to-HOME classified trip patch
+
+## Definition
+
+A complete trip is created from two consecutive NDI classifications with status `HOME`.
+
+- Active trip start: `startHomeClassification.evidence.endedAt`
+- Active trip end: `endHomeClassification.evidence.startedAt`
+- Boundary evidence: both complete HOME classification objects
+- Contained evidence: every `NOT_HOME` classification fully contained in the active trip window
+- Country segmentation: calculated separately inside each completed trip
+
+A middle HOME classification is shared: it closes the preceding trip and starts the following trip.
+
+Classifications before the first HOME and after the last HOME are not assigned to a completed trip. Their count is exposed as `unassignedNonHomeClassificationCount`.
+
+## Result additions
+
+`DriverCountryTripSegmentationResult` now contains:
+
+- `tripCount`
+- `unassignedNonHomeClassificationCount`
+- `trips`
+
+Each `DriverClassifiedTrip` contains:
+
+- `tripId`
+- `startedAt`, `endedAt`, `durationSeconds`
+- `startHomeClassification`
+- `endHomeClassification`
+- `containedNonHomeClassifications`
+- `drivingIntervalCount`
+- `countrySegmentCount`
+- `countrySegments`
+
+Each `DriverCountryTripSegment` now contains:
+
+- `tripId`
+- `countryCode`
+
+The former flat `segments` collection remains available and is the concatenation of all per-trip segments. Compatibility constructors are retained for Java callers using the former record signatures.
+
+## Pipeline order
+
+`country-trip-segmentation` now explicitly depends on:
+
+```text
+support-evidence-normalization
+ndi-home-classification
+```
+
+This guarantees that the trip boundaries are available before country segmentation starts.
diff --git a/README_NDI_HOME_CLASSIFICATION.md b/README_NDI_HOME_CLASSIFICATION.md
index 930dcac..d713fb7 100644
--- a/README_NDI_HOME_CLASSIFICATION.md
+++ b/README_NDI_HOME_CLASSIFICATION.md
@@ -61,9 +61,29 @@ The in-memory cache:
Clustering uses Java DBSCAN with Haversine distance. Defaults are 150 metres and three points. Noise observations remain in the denominator for visit-share calculations but are never home clusters.
-## Country trip segmentation
+## HOME-to-HOME trips and country segmentation
-`DriverCountryTripSegmentationService` builds country segments over driving intervals.
+A complete trip is now defined by two consecutive `HOME` classifications:
+
+```text
+start HOME NDI --active trip time and contained NOT_HOME NDIs--> end HOME NDI
+```
+
+The active trip starts at the end timestamp of the start HOME NDI and ends at the start timestamp of the end HOME NDI. The result retains both HOME classifications as boundary evidence and attaches every `NOT_HOME` classification fully contained between those boundaries. A HOME classification can close one trip and simultaneously become the start boundary of the next trip.
+
+Data before the first HOME classification and after the last HOME classification is not emitted as a complete trip. The number of `NOT_HOME` classifications outside complete trips is returned as `unassignedNonHomeClassificationCount`.
+
+Every `DriverClassifiedTrip` contains:
+
+- deterministic `tripId`;
+- active `startedAt`, `endedAt`, and duration;
+- `startHomeClassification`;
+- `endHomeClassification`;
+- `containedNonHomeClassifications`;
+- driving-interval count;
+- country segments calculated only inside that trip.
+
+`DriverCountryTripSegmentationService` calculates country segments independently for each complete trip. The flat `segments` list remains available for compatibility, but is now the concatenation of all per-trip segments. Every segment contains its owning `tripId` and an explicit `countryCode`.
Evidence precedence is:
@@ -81,7 +101,7 @@ VEHICLE_CHANGE
FINAL
```
-The result includes segment counts, explicit-border counts, remote lookup counts, cache-hit counts, unresolved-coordinate counts, warnings, and OpenStreetMap attribution.
+The result includes trip and segment counts, unassigned classification counts, explicit-border counts, remote lookup counts, cache-hit counts, unresolved-coordinate counts, warnings, and OpenStreetMap attribution.
## Nominatim integration
@@ -159,3 +179,30 @@ countryTripSegmentation
```
The fields are omitted when their optional modules were not executed, preserving the existing JSON shape for normal `driver-working-time-v1` calls.
+
+### Trip response shape
+
+```json
+{
+ "countryTripSegmentation": {
+ "tripCount": 1,
+ "unassignedNonHomeClassificationCount": 0,
+ "trips": [
+ {
+ "tripId": "DRIVER_TRIP|...",
+ "startedAt": "2026-05-01T08:00:00Z",
+ "endedAt": "2026-05-03T18:00:00Z",
+ "startHomeClassification": { "intervalId": "NDI-START", "status": "HOME" },
+ "endHomeClassification": { "intervalId": "NDI-END", "status": "HOME" },
+ "containedNonHomeClassifications": [
+ { "intervalId": "NDI-AWAY-REST", "status": "NOT_HOME" }
+ ],
+ "countrySegments": [
+ { "tripId": "DRIVER_TRIP|...", "countryCode": "AT" },
+ { "tripId": "DRIVER_TRIP|...", "countryCode": "DE" }
+ ]
+ }
+ ]
+ }
+}
+```
diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverClassifiedTrip.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverClassifiedTrip.java
new file mode 100644
index 0000000..81aa91e
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverClassifiedTrip.java
@@ -0,0 +1,35 @@
+package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model;
+
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassification;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+/**
+ * A complete driver trip bounded by two HOME-classified non-driving intervals.
+ *
+ *
The active trip time starts when the start HOME interval ends and finishes when the
+ * end HOME interval starts. The two HOME classifications are retained as explicit boundary
+ * evidence. Every NOT_HOME classification between those boundaries is attached to the trip.
+ */
+public record DriverClassifiedTrip(
+ String tripId,
+ String driverKey,
+ OffsetDateTime startedAt,
+ OffsetDateTime endedAt,
+ long durationSeconds,
+ DriverNdiHomeClassification startHomeClassification,
+ DriverNdiHomeClassification endHomeClassification,
+ List containedNonHomeClassifications,
+ int drivingIntervalCount,
+ int countrySegmentCount,
+ List countrySegments,
+ List notes
+) {
+ public DriverClassifiedTrip {
+ containedNonHomeClassifications = containedNonHomeClassifications == null
+ ? List.of()
+ : List.copyOf(containedNonHomeClassifications);
+ countrySegments = countrySegments == null ? List.of() : List.copyOf(countrySegments);
+ notes = notes == null ? List.of() : List.copyOf(notes);
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegment.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegment.java
index a2afeb1..56f159a 100644
--- a/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegment.java
+++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegment.java
@@ -5,11 +5,13 @@ import java.time.OffsetDateTime;
public record DriverCountryTripSegment(
String segmentId,
+ String tripId,
String driverKey,
String registrationKey,
String vehicleKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
+ String countryCode,
String countryFrom,
String countryTo,
BigDecimal latitudeFrom,
@@ -22,4 +24,48 @@ public record DriverCountryTripSegment(
String boundaryEventId,
boolean boundaryCountryReverseGeocoded
) {
+ /**
+ * Compatibility constructor for callers that still create an unscoped segment.
+ */
+ public DriverCountryTripSegment(
+ String segmentId,
+ String driverKey,
+ String registrationKey,
+ String vehicleKey,
+ OffsetDateTime startedAt,
+ OffsetDateTime endedAt,
+ String countryFrom,
+ String countryTo,
+ BigDecimal latitudeFrom,
+ BigDecimal longitudeFrom,
+ BigDecimal latitudeTo,
+ BigDecimal longitudeTo,
+ String positionFromEventId,
+ String positionToEventId,
+ DriverCountryTripSegmentBoundarySource endBoundarySource,
+ String boundaryEventId,
+ boolean boundaryCountryReverseGeocoded
+ ) {
+ this(
+ segmentId,
+ null,
+ driverKey,
+ registrationKey,
+ vehicleKey,
+ startedAt,
+ endedAt,
+ countryFrom,
+ countryFrom,
+ countryTo,
+ latitudeFrom,
+ longitudeFrom,
+ latitudeTo,
+ longitudeTo,
+ positionFromEventId,
+ positionToEventId,
+ endBoundarySource,
+ boundaryEventId,
+ boundaryCountryReverseGeocoded
+ );
+ }
}
diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegmentationResult.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegmentationResult.java
index 27a9049..290866d 100644
--- a/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegmentationResult.java
+++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/model/DriverCountryTripSegmentationResult.java
@@ -11,14 +11,54 @@ public record DriverCountryTripSegmentationResult(
int reverseGeocodingCacheHitCount,
int unresolvedCoordinateCount,
int segmentCount,
+ int tripCount,
+ int unassignedNonHomeClassificationCount,
String reverseGeocodingAttribution,
List segments,
+ List trips,
List notes,
List warnings
) {
public DriverCountryTripSegmentationResult {
segments = segments == null ? List.of() : List.copyOf(segments);
+ trips = trips == null ? List.of() : List.copyOf(trips);
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
+
+ /**
+ * Compatibility constructor for the former flat country-segmentation result.
+ */
+ public DriverCountryTripSegmentationResult(
+ String driverKey,
+ int drivingIntervalCount,
+ int supportingGeoEventCount,
+ int explicitBorderCrossingCount,
+ int reverseGeocodingRemoteRequestCount,
+ int reverseGeocodingCacheHitCount,
+ int unresolvedCoordinateCount,
+ int segmentCount,
+ String reverseGeocodingAttribution,
+ List segments,
+ List notes,
+ List warnings
+ ) {
+ this(
+ driverKey,
+ drivingIntervalCount,
+ supportingGeoEventCount,
+ explicitBorderCrossingCount,
+ reverseGeocodingRemoteRequestCount,
+ reverseGeocodingCacheHitCount,
+ unresolvedCoordinateCount,
+ segmentCount,
+ 0,
+ 0,
+ reverseGeocodingAttribution,
+ segments,
+ List.of(),
+ notes,
+ warnings
+ );
+ }
}
diff --git a/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationService.java b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationService.java
index 33bb927..74eec92 100644
--- a/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationService.java
+++ b/src/main/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationService.java
@@ -4,8 +4,13 @@ import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.geocoding.model.GeoCountryResolution;
import at.procon.eventhub.geocoding.model.GeoCountryResolutionStatus;
import at.procon.eventhub.geocoding.service.GeoCountryResolver;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassification;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationResult;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeStatus;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
+import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverClassifiedTrip;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegment;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentBoundarySource;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationResult;
@@ -15,6 +20,7 @@ import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
@@ -30,6 +36,8 @@ import org.springframework.stereotype.Service;
@Service
public class DriverCountryTripSegmentationService {
+ private static final String ATTRIBUTION = "Data © OpenStreetMap contributors, ODbL 1.0";
+
private final GeoCountryResolver countryResolver;
private final EventHubProperties properties;
@@ -41,8 +49,22 @@ public class DriverCountryTripSegmentationService {
this.properties = properties;
}
+ /**
+ * Compatibility entry point retaining the former flat whole-timeline segmentation.
+ */
public DriverCountryTripSegmentationScopeResult segmentPreparedInputs(
Map preparedInputs
+ ) {
+ return segmentPreparedInputs(preparedInputs, null);
+ }
+
+ /**
+ * Builds complete trips between consecutive HOME classifications and calculates country
+ * segments independently inside every trip.
+ */
+ public DriverCountryTripSegmentationScopeResult segmentPreparedInputs(
+ Map preparedInputs,
+ DriverNdiHomeClassificationScopeResult homeClassificationScope
) {
int maxRemoteLookups = properties.getReverseGeocoding()
.getNominatim()
@@ -59,11 +81,12 @@ public class DriverCountryTripSegmentationService {
&& entry.getValue().processingInput() != null)
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> {
- DriverCountryTripSegmentationResult result = segmentDriver(
- entry.getKey(),
- entry.getValue(),
- budget
- );
+ DriverNdiHomeClassificationResult homeResult = homeClassificationScope == null
+ ? null
+ : homeClassificationScope.resultForDriver(entry.getKey());
+ DriverCountryTripSegmentationResult result = homeClassificationScope == null
+ ? segmentDriverLegacy(entry.getKey(), entry.getValue(), budget)
+ : segmentDriverTrips(entry.getKey(), entry.getValue(), homeResult, budget);
driverResults.put(entry.getKey(), result);
scopeWarnings.addAll(result.warnings());
});
@@ -72,36 +95,185 @@ public class DriverCountryTripSegmentationService {
int segmentCount = driverResults.values().stream()
.mapToInt(DriverCountryTripSegmentationResult::segmentCount)
.sum();
+ int tripCount = driverResults.values().stream()
+ .mapToInt(DriverCountryTripSegmentationResult::tripCount)
+ .sum();
int cacheHitCount = driverResults.values().stream()
.mapToInt(DriverCountryTripSegmentationResult::reverseGeocodingCacheHitCount)
.sum();
int unresolvedCount = driverResults.values().stream()
.mapToInt(DriverCountryTripSegmentationResult::unresolvedCoordinateCount)
.sum();
- List notes = List.of(
- "Country trip segmentation preferred explicit tachograph border-crossing events and existing source country codes before Nominatim.",
- "Nominatim remote lookups performed: " + budget.usedRemoteLookups() + " of configured maximum "
- + maxRemoteLookups + ".",
- "Nominatim requests use a shared coordinate cache and the configured minimum request interval."
- );
+ List notes = new ArrayList<>();
+ if (homeClassificationScope == null) {
+ notes.add("Country segmentation ran in compatibility mode over each complete driver timeline because no HOME-classification scope was supplied.");
+ } else {
+ notes.add("Built " + tripCount + " complete trip(s); each trip is bounded by consecutive HOME classifications and owns its contained NOT_HOME classifications and country segments.");
+ notes.add("Data before the first HOME classification and after the last HOME classification is not emitted as a complete trip.");
+ }
+ notes.add("Country segmentation preferred explicit tachograph border-crossing events and existing source country codes before Nominatim.");
+ notes.add("Nominatim remote lookups performed: " + budget.usedRemoteLookups()
+ + " of configured maximum " + maxRemoteLookups + ".");
+ notes.add("Nominatim requests use a shared coordinate cache and the configured minimum request interval.");
+
return new DriverCountryTripSegmentationScopeResult(
driverResults.size(),
segmentCount,
budget.usedRemoteLookups(),
cacheHitCount,
unresolvedCount,
- "Data © OpenStreetMap contributors, ODbL 1.0",
+ ATTRIBUTION,
driverResults,
notes,
distinctLimited(scopeWarnings, 50)
);
}
- private DriverCountryTripSegmentationResult segmentDriver(
+ private DriverCountryTripSegmentationResult segmentDriverTrips(
+ String driverKey,
+ DriverWorkingTimePreparedInput preparedInput,
+ DriverNdiHomeClassificationResult homeResult,
+ LookupBudget budget
+ ) {
+ DriverTimeline timeline = timeline(preparedInput);
+ List warnings = new ArrayList<>();
+ TripWindowBuildResult windowBuild = buildTripWindows(
+ driverKey,
+ homeResult == null ? List.of() : homeResult.classifications(),
+ warnings
+ );
+
+ DriverStats stats = new DriverStats();
+ List trips = new ArrayList<>();
+ List flatSegments = new ArrayList<>();
+ int usedDrivingIntervalCount = 0;
+
+ for (TripWindow window : windowBuild.windows()) {
+ WindowSegmentation segmentation = segmentWindow(
+ driverKey,
+ window.tripId(),
+ window.startedAt(),
+ window.endedAt(),
+ timeline.drivingIntervals(),
+ timeline.supportEvents(),
+ budget,
+ stats,
+ warnings
+ );
+ usedDrivingIntervalCount += segmentation.drivingIntervalCount();
+ flatSegments.addAll(segmentation.segments());
+ trips.add(new DriverClassifiedTrip(
+ window.tripId(),
+ driverKey,
+ window.startedAt(),
+ window.endedAt(),
+ Duration.between(window.startedAt(), window.endedAt()).getSeconds(),
+ window.startHomeClassification(),
+ window.endHomeClassification(),
+ window.containedNonHomeClassifications(),
+ segmentation.drivingIntervalCount(),
+ segmentation.segments().size(),
+ segmentation.segments(),
+ List.of(
+ "Trip starts at the end of HOME interval " + window.startHomeClassification().intervalId() + ".",
+ "Trip ends at the start of HOME interval " + window.endHomeClassification().intervalId() + "."
+ )
+ ));
+ }
+
+ if (budget.exhausted()) {
+ warnings.add("The configured Nominatim remote-lookup budget was exhausted; later uncached coordinates remained unresolved.");
+ }
+ List notes = List.of(
+ "Built " + trips.size() + " complete trip(s) from "
+ + (homeResult == null ? 0 : homeResult.classifications().size()) + " NDI classification(s).",
+ "Each HOME classification may close one trip and start the next trip.",
+ "Attached " + windowBuild.assignedNonHomeClassificationCount()
+ + " NOT_HOME classification(s); " + windowBuild.unassignedNonHomeClassificationCount()
+ + " remained outside complete HOME-to-HOME trips.",
+ "Explicit border crossings are authoritative; Nominatim is used only when a positioned event has no usable source country code."
+ );
+
+ return new DriverCountryTripSegmentationResult(
+ driverKey,
+ usedDrivingIntervalCount,
+ stats.supportingGeoEventCount,
+ stats.explicitBorderCrossingCount,
+ stats.remoteRequestCount,
+ stats.cacheHitCount,
+ stats.unresolvedCoordinateCount,
+ flatSegments.size(),
+ trips.size(),
+ windowBuild.unassignedNonHomeClassificationCount(),
+ ATTRIBUTION,
+ flatSegments,
+ trips,
+ notes,
+ distinctLimited(warnings, 30)
+ );
+ }
+
+ private DriverCountryTripSegmentationResult segmentDriverLegacy(
String driverKey,
DriverWorkingTimePreparedInput preparedInput,
LookupBudget budget
) {
+ DriverTimeline timeline = timeline(preparedInput);
+ if (timeline.drivingIntervals().isEmpty()) {
+ return new DriverCountryTripSegmentationResult(
+ driverKey,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ATTRIBUTION,
+ List.of(),
+ List.of("No driving intervals were available for country trip segmentation."),
+ List.of()
+ );
+ }
+
+ DriverStats stats = new DriverStats();
+ List warnings = new ArrayList<>();
+ OffsetDateTime start = timeline.drivingIntervals().getFirst().startedAt();
+ OffsetDateTime end = timeline.drivingIntervals().getLast().endedAt();
+ WindowSegmentation segmentation = segmentWindow(
+ driverKey,
+ null,
+ start,
+ end,
+ timeline.drivingIntervals(),
+ timeline.supportEvents(),
+ budget,
+ stats,
+ warnings
+ );
+ if (budget.exhausted()) {
+ warnings.add("The configured Nominatim remote-lookup budget was exhausted; later uncached coordinates remained unresolved.");
+ }
+ return new DriverCountryTripSegmentationResult(
+ driverKey,
+ segmentation.drivingIntervalCount(),
+ stats.supportingGeoEventCount,
+ stats.explicitBorderCrossingCount,
+ stats.remoteRequestCount,
+ stats.cacheHitCount,
+ stats.unresolvedCoordinateCount,
+ segmentation.segments().size(),
+ ATTRIBUTION,
+ segmentation.segments(),
+ List.of(
+ "Compatibility mode built flat country segments from the complete available driver timeline.",
+ "Country codes in the result are normalized to ISO 3166-1 alpha-2 where a mapping is known."
+ ),
+ distinctLimited(warnings, 20)
+ );
+ }
+
+ private DriverTimeline timeline(DriverWorkingTimePreparedInput preparedInput) {
List drivingIntervals = preparedInput.processingInput()
.activityIntervals()
.stream()
@@ -123,40 +295,148 @@ public class DriverCountryTripSegmentationService {
.comparing(RuntimeSupportEvidenceEvent::occurredAt)
.thenComparing(event -> nullToEmpty(event.eventId())))
.toList();
+ return new DriverTimeline(drivingIntervals, supportEvents);
+ }
- if (drivingIntervals.isEmpty()) {
- return new DriverCountryTripSegmentationResult(
- driverKey,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- "Data © OpenStreetMap contributors, ODbL 1.0",
- List.of(),
- List.of("No driving intervals were available for country trip segmentation."),
- List.of()
- );
+ private TripWindowBuildResult buildTripWindows(
+ String driverKey,
+ List classifications,
+ List warnings
+ ) {
+ List ordered = classifications == null
+ ? List.of()
+ : classifications.stream()
+ .filter(Objects::nonNull)
+ .filter(classification -> classification.evidence() != null)
+ .sorted(Comparator
+ .comparing(
+ (DriverNdiHomeClassification value) -> value.evidence().startedAt(),
+ Comparator.nullsLast(Comparator.naturalOrder())
+ )
+ .thenComparing(
+ value -> value.evidence().endedAt(),
+ Comparator.nullsLast(Comparator.naturalOrder())
+ )
+ .thenComparing(value -> nullToEmpty(value.intervalId())))
+ .toList();
+
+ List windows = new ArrayList<>();
+ List pendingNonHome = new ArrayList<>();
+ DriverNdiHomeClassification startHome = null;
+ int unassignedNonHomeCount = 0;
+ int assignedNonHomeCount = 0;
+
+ for (DriverNdiHomeClassification classification : ordered) {
+ if (classification.status() != DriverNdiHomeStatus.HOME) {
+ if (startHome == null) {
+ unassignedNonHomeCount++;
+ } else {
+ pendingNonHome.add(classification);
+ }
+ continue;
+ }
+
+ if (startHome == null) {
+ startHome = classification;
+ pendingNonHome.clear();
+ continue;
+ }
+
+ OffsetDateTime tripStart = startHome.evidence().endedAt();
+ OffsetDateTime tripEnd = classification.evidence().startedAt();
+ if (tripStart == null || tripEnd == null || !tripEnd.isAfter(tripStart)) {
+ unassignedNonHomeCount += pendingNonHome.size();
+ warnings.add("Could not create HOME-to-HOME trip between intervals "
+ + nullToEmpty(startHome.intervalId()) + " and " + nullToEmpty(classification.intervalId())
+ + " because the active trip boundaries were missing or not increasing.");
+ } else {
+ List contained = pendingNonHome.stream()
+ .filter(value -> fullyContained(value, tripStart, tripEnd))
+ .toList();
+ assignedNonHomeCount += contained.size();
+ unassignedNonHomeCount += pendingNonHome.size() - contained.size();
+ String tripId = tripId(driverKey, startHome.intervalId(), classification.intervalId(), tripStart, tripEnd);
+ windows.add(new TripWindow(
+ tripId,
+ tripStart,
+ tripEnd,
+ startHome,
+ classification,
+ contained
+ ));
+ }
+ startHome = classification;
+ pendingNonHome = new ArrayList<>();
}
- DriverStats stats = new DriverStats();
- List warnings = new ArrayList<>();
- List segments = new ArrayList<>();
+ unassignedNonHomeCount += pendingNonHome.size();
+ return new TripWindowBuildResult(
+ List.copyOf(windows),
+ assignedNonHomeCount,
+ unassignedNonHomeCount
+ );
+ }
- DriverWorkingTimeActivityInterval firstDrive = drivingIntervals.getFirst();
+ private boolean fullyContained(
+ DriverNdiHomeClassification classification,
+ OffsetDateTime tripStart,
+ OffsetDateTime tripEnd
+ ) {
+ if (classification == null || classification.evidence() == null) {
+ return false;
+ }
+ OffsetDateTime start = classification.evidence().startedAt();
+ OffsetDateTime end = classification.evidence().endedAt();
+ return start != null
+ && end != null
+ && !start.isBefore(tripStart)
+ && !end.isAfter(tripEnd);
+ }
+
+ private WindowSegmentation segmentWindow(
+ String driverKey,
+ String tripId,
+ OffsetDateTime windowStart,
+ OffsetDateTime windowEnd,
+ List allDrivingIntervals,
+ List supportEvents,
+ LookupBudget budget,
+ DriverStats stats,
+ List warnings
+ ) {
+ if (windowStart == null || windowEnd == null || !windowEnd.isAfter(windowStart)) {
+ return new WindowSegmentation(0, List.of());
+ }
+
+ List drivingIntervals = allDrivingIntervals.stream()
+ .filter(interval -> overlaps(interval.startedAt(), interval.endedAt(), windowStart, windowEnd))
+ .map(interval -> new ClippedDrive(
+ interval,
+ max(interval.startedAt(), windowStart),
+ min(interval.endedAt(), windowEnd)
+ ))
+ .filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
+ .sorted(Comparator
+ .comparing(ClippedDrive::startedAt)
+ .thenComparing(ClippedDrive::endedAt))
+ .toList();
+ if (drivingIntervals.isEmpty()) {
+ return new WindowSegmentation(0, List.of());
+ }
+
+ List segments = new ArrayList<>();
+ ClippedDrive firstDrive = drivingIntervals.getFirst();
SegmentState state = new SegmentState(
firstDrive.startedAt(),
- firstDrive.registrationKey(),
- firstDrive.vehicleKey()
+ firstDrive.source().registrationKey(),
+ firstDrive.source().vehicleKey()
);
- DriverWorkingTimeActivityInterval previousDrive = null;
+ ClippedDrive previousDrive = null;
- for (DriverWorkingTimeActivityInterval drive : drivingIntervals) {
+ for (ClippedDrive drive : drivingIntervals) {
List driveEvents = supportEvents.stream()
.filter(event -> within(event.occurredAt(), drive.startedAt(), drive.endedAt()))
- .filter(event -> compatibleVehicle(event, drive))
+ .filter(event -> compatibleVehicle(event, drive.source()))
.toList();
stats.supportingGeoEventCount += (int) driveEvents.stream()
.filter(this::hasCoordinateOrBorderEvidence)
@@ -165,10 +445,11 @@ public class DriverCountryTripSegmentationService {
.filter(this::isExplicitBorderCrossing)
.count();
- if (previousDrive != null && vehicleChanged(previousDrive, drive)) {
+ if (previousDrive != null && vehicleChanged(previousDrive.source(), drive.source())) {
String previousCountry = state.country;
addSegment(
segments,
+ tripId,
driverKey,
state,
previousDrive.endedAt(),
@@ -181,8 +462,8 @@ public class DriverCountryTripSegmentationService {
);
state = new SegmentState(
drive.startedAt(),
- drive.registrationKey(),
- drive.vehicleKey()
+ drive.source().registrationKey(),
+ drive.source().vehicleKey()
);
state.country = previousCountry;
}
@@ -198,6 +479,7 @@ public class DriverCountryTripSegmentationService {
if (isExplicitBorderCrossing(event)) {
processExplicitBorderCrossing(
segments,
+ tripId,
driverKey,
state,
event,
@@ -208,6 +490,7 @@ public class DriverCountryTripSegmentationService {
} else if (hasCoordinate(event)) {
processPosition(
segments,
+ tripId,
driverKey,
state,
event,
@@ -220,9 +503,10 @@ public class DriverCountryTripSegmentationService {
previousDrive = drive;
}
- DriverWorkingTimeActivityInterval lastDrive = drivingIntervals.getLast();
+ ClippedDrive lastDrive = drivingIntervals.getLast();
addSegment(
segments,
+ tripId,
driverKey,
state,
lastDrive.endedAt(),
@@ -233,34 +517,12 @@ public class DriverCountryTripSegmentationService {
state.lastPositionEvent == null ? null : state.lastPositionEvent.eventId(),
false
);
-
- List notes = List.of(
- "Built country trip segments from " + drivingIntervals.size() + " driving interval(s) and "
- + stats.supportingGeoEventCount + " supporting geo/border event(s).",
- "Explicit border crossings are authoritative; Nominatim is used only when a positioned event has no usable source country code.",
- "Country codes in the result are normalized to ISO 3166-1 alpha-2 where a mapping is known."
- );
- if (budget.exhausted()) {
- warnings.add("The configured Nominatim remote-lookup budget was exhausted; later uncached coordinates remained unresolved.");
- }
- return new DriverCountryTripSegmentationResult(
- driverKey,
- drivingIntervals.size(),
- stats.supportingGeoEventCount,
- stats.explicitBorderCrossingCount,
- stats.remoteRequestCount,
- stats.cacheHitCount,
- stats.unresolvedCoordinateCount,
- segments.size(),
- "Data © OpenStreetMap contributors, ODbL 1.0",
- segments,
- notes,
- distinctLimited(warnings, 20)
- );
+ return new WindowSegmentation(drivingIntervals.size(), List.copyOf(segments));
}
private void processExplicitBorderCrossing(
List segments,
+ String tripId,
String driverKey,
SegmentState state,
RuntimeSupportEvidenceEvent event,
@@ -287,6 +549,7 @@ public class DriverCountryTripSegmentationService {
addSegment(
segments,
+ tripId,
driverKey,
state,
event.occurredAt(),
@@ -302,6 +565,7 @@ public class DriverCountryTripSegmentationService {
private void processPosition(
List segments,
+ String tripId,
String driverKey,
SegmentState state,
RuntimeSupportEvidenceEvent event,
@@ -327,6 +591,7 @@ public class DriverCountryTripSegmentationService {
: DriverCountryTripSegmentBoundarySource.GNSS_SOURCE_COUNTRY_CHANGE;
addSegment(
segments,
+ tripId,
driverKey,
state,
event.occurredAt(),
@@ -385,6 +650,7 @@ public class DriverCountryTripSegmentationService {
private void addSegment(
List segments,
+ String tripId,
String driverKey,
SegmentState state,
OffsetDateTime endedAt,
@@ -399,13 +665,16 @@ public class DriverCountryTripSegmentationService {
return;
}
int index = segments.size();
+ String countryCode = firstNonBlank(countryFrom, countryTo);
segments.add(new DriverCountryTripSegment(
- segmentId(driverKey, state.startedAt, endedAt, index),
+ segmentId(driverKey, tripId, state.startedAt, endedAt, index),
+ tripId,
driverKey,
state.registrationKey,
state.vehicleKey,
state.startedAt,
endedAt,
+ countryCode,
countryFrom,
countryTo,
state.startLatitude,
@@ -420,6 +689,26 @@ public class DriverCountryTripSegmentationService {
));
}
+ private boolean overlaps(
+ OffsetDateTime intervalStart,
+ OffsetDateTime intervalEnd,
+ OffsetDateTime windowStart,
+ OffsetDateTime windowEnd
+ ) {
+ return intervalStart != null
+ && intervalEnd != null
+ && intervalEnd.isAfter(windowStart)
+ && intervalStart.isBefore(windowEnd);
+ }
+
+ private OffsetDateTime max(OffsetDateTime first, OffsetDateTime second) {
+ return first.isAfter(second) ? first : second;
+ }
+
+ private OffsetDateTime min(OffsetDateTime first, OffsetDateTime second) {
+ return first.isBefore(second) ? first : second;
+ }
+
private boolean within(OffsetDateTime value, OffsetDateTime start, OffsetDateTime end) {
return value != null
&& start != null
@@ -469,17 +758,38 @@ public class DriverCountryTripSegmentationService {
|| event.countryTo() != null;
}
+ private String tripId(
+ String driverKey,
+ String startHomeIntervalId,
+ String endHomeIntervalId,
+ OffsetDateTime startedAt,
+ OffsetDateTime endedAt
+ ) {
+ String raw = nullToEmpty(driverKey)
+ + "|" + nullToEmpty(startHomeIntervalId)
+ + "|" + nullToEmpty(endHomeIntervalId)
+ + "|" + startedAt
+ + "|" + endedAt;
+ return "DRIVER_TRIP|" + shortDigest(raw);
+ }
+
private String segmentId(
String driverKey,
+ String tripId,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
int index
) {
- String raw = nullToEmpty(driverKey) + "|" + startedAt + "|" + endedAt + "|" + index;
+ String raw = nullToEmpty(driverKey) + "|" + nullToEmpty(tripId)
+ + "|" + startedAt + "|" + endedAt + "|" + index;
+ return "TRIP_COUNTRY|" + shortDigest(raw);
+ }
+
+ private String shortDigest(String raw) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(raw.getBytes(StandardCharsets.UTF_8));
- return "TRIP_COUNTRY|" + java.util.HexFormat.of().formatHex(digest, 0, 12);
+ return java.util.HexFormat.of().formatHex(digest, 0, 12);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is unavailable.", ex);
}
@@ -590,4 +900,40 @@ public class DriverCountryTripSegmentationService {
return new ResolvedEventCountry(null, false);
}
}
+
+ private record DriverTimeline(
+ List drivingIntervals,
+ List supportEvents
+ ) {
+ }
+
+ private record ClippedDrive(
+ DriverWorkingTimeActivityInterval source,
+ OffsetDateTime startedAt,
+ OffsetDateTime endedAt
+ ) {
+ }
+
+ private record WindowSegmentation(
+ int drivingIntervalCount,
+ List segments
+ ) {
+ }
+
+ private record TripWindow(
+ String tripId,
+ OffsetDateTime startedAt,
+ OffsetDateTime endedAt,
+ DriverNdiHomeClassification startHomeClassification,
+ DriverNdiHomeClassification endHomeClassification,
+ List containedNonHomeClassifications
+ ) {
+ }
+
+ private record TripWindowBuildResult(
+ List windows,
+ int assignedNonHomeClassificationCount,
+ int unassignedNonHomeClassificationCount
+ ) {
+ }
}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverCountryTripSegmentationModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverCountryTripSegmentationModule.java
index 1c8cac8..bb9c170 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverCountryTripSegmentationModule.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverCountryTripSegmentationModule.java
@@ -1,5 +1,6 @@
package at.procon.eventhub.processing.eventprocessing.module;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.service.DriverCountryTripSegmentationService;
@@ -29,11 +30,17 @@ public class DriverCountryTripSegmentationModule implements RuntimeProcessingMod
public RuntimeProcessingModuleDescriptorDto descriptor() {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
- "Country trip segmentation",
- "Builds per-driver country trip segments from explicit tachograph border crossings and GNSS country changes, using cached/rate-limited Nominatim reverse geocoding when source country codes are absent.",
+ "Classified trips and country segmentation",
+ "Builds complete trips between consecutive HOME-classified NDIs, attaches the start/end HOME classifications and contained NOT_HOME classifications, then calculates country segments independently inside every trip.",
"JAVA+HTTP",
- Set.of(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION),
- Set.of("Map"),
+ Set.of(
+ DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION,
+ DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION
+ ),
+ Set.of(
+ "Map",
+ "DriverNdiHomeClassificationScopeResult"
+ ),
Set.of("DriverCountryTripSegmentationScopeResult")
);
}
@@ -41,10 +48,15 @@ public class DriverCountryTripSegmentationModule implements RuntimeProcessingMod
@Override
public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) {
DriverCountryTripSegmentationScopeResult result = segmentationService.segmentPreparedInputs(
- preparedInputs(context)
+ preparedInputs(context),
+ homeClassificationScope(context)
);
+ int tripCount = result.driverResults().values().stream()
+ .mapToInt(driverResult -> driverResult.tripCount())
+ .sum();
Map metadata = new LinkedHashMap<>();
metadata.put("driverCount", result.driverCount());
+ metadata.put("tripCount", tripCount);
metadata.put("segmentCount", result.segmentCount());
metadata.put("reverseGeocodingRemoteRequestCount", result.reverseGeocodingRemoteRequestCount());
metadata.put("reverseGeocodingCacheHitCount", result.reverseGeocodingCacheHitCount());
@@ -59,6 +71,18 @@ public class DriverCountryTripSegmentationModule implements RuntimeProcessingMod
);
}
+ private DriverNdiHomeClassificationScopeResult homeClassificationScope(
+ RuntimeProcessingModuleContext context
+ ) {
+ Object output = context.requireResult(DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION).output();
+ if (output instanceof DriverNdiHomeClassificationScopeResult result) {
+ return result;
+ }
+ throw new IllegalStateException("Module " + moduleKey()
+ + " requires DriverNdiHomeClassificationScopeResult from "
+ + DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION + ".");
+ }
+
@SuppressWarnings("unchecked")
private Map preparedInputs(RuntimeProcessingModuleContext context) {
Object output = context.requireResult(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION).output();
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java
index 22a539f..7a5c2bc 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/DriverWorkingTimeDerivedProjectionsModule.java
@@ -67,7 +67,7 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Driving-derived projections",
- "Executes the shared driver working-time core from typed per-driver module outputs for driving interruptions, rest candidates, card-absence coverage, overnight candidates, and trip candidates; optional NDI and country-trip module results are attached when present.",
+ "Executes the shared driver working-time core from typed per-driver module outputs for driving interruptions, rest candidates, card-absence coverage, overnight candidates, and trip candidates; optional NDI classifications and HOME-to-HOME trip/country-segmentation results are attached when present.",
"ESPER+JAVA",
Set.of(
DriverWorkingTimeModuleKeys.EVENT_TO_ACTIVITY_INTERVALS,
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverHomeClassificationRuntimeProcessingPlan.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverHomeClassificationRuntimeProcessingPlan.java
index d000949..4917b81 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverHomeClassificationRuntimeProcessingPlan.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverHomeClassificationRuntimeProcessingPlan.java
@@ -44,7 +44,7 @@ public class DriverHomeClassificationRuntimeProcessingPlan implements RuntimePro
@Override
public String description() {
- return "Builds enriched non-driving intervals, learns company and driver home locations, applies ordered HOME/NOT_HOME rules, and creates country trip segments using explicit border events plus Nominatim-backed GNSS country resolution.";
+ return "Builds enriched non-driving intervals, learns company and driver home locations, applies ordered HOME/NOT_HOME rules, creates complete trips between consecutive HOME classifications, attaches contained NOT_HOME classifications, and calculates per-trip country segments using explicit border events plus Nominatim-backed GNSS country resolution.";
}
@Override
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverWorkingTimeRuntimeProcessingPlan.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverWorkingTimeRuntimeProcessingPlan.java
index b28caf2..d09e6ea 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverWorkingTimeRuntimeProcessingPlan.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/plan/DriverWorkingTimeRuntimeProcessingPlan.java
@@ -523,7 +523,9 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
RuntimeProcessingModuleStatus.SUCCESS,
driverResult.countryTripSegmentation(),
Map.of(
+ "tripCount", driverResult.countryTripSegmentation().tripCount(),
"segmentCount", driverResult.countryTripSegmentation().segmentCount(),
+ "unassignedNonHomeClassificationCount", driverResult.countryTripSegmentation().unassignedNonHomeClassificationCount(),
"explicitBorderCrossingCount", driverResult.countryTripSegmentation().explicitBorderCrossingCount(),
"reverseGeocodingRemoteRequestCount", driverResult.countryTripSegmentation().reverseGeocodingRemoteRequestCount(),
"reverseGeocodingCacheHitCount", driverResult.countryTripSegmentation().reverseGeocodingCacheHitCount(),
@@ -575,7 +577,9 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
}
if (driverResult.countryTripSegmentation() != null) {
metadata.put("countryTripSegmentation", Map.of(
+ "tripCount", driverResult.countryTripSegmentation().tripCount(),
"segmentCount", driverResult.countryTripSegmentation().segmentCount(),
+ "unassignedNonHomeClassificationCount", driverResult.countryTripSegmentation().unassignedNonHomeClassificationCount(),
"explicitBorderCrossingCount", driverResult.countryTripSegmentation().explicitBorderCrossingCount(),
"reverseGeocodingRemoteRequestCount", driverResult.countryTripSegmentation().reverseGeocodingRemoteRequestCount(),
"reverseGeocodingCacheHitCount", driverResult.countryTripSegmentation().reverseGeocodingCacheHitCount(),
diff --git a/src/test/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationServiceTest.java b/src/test/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationServiceTest.java
index 4d00757..7aceefc 100644
--- a/src/test/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationServiceTest.java
+++ b/src/test/java/at/procon/eventhub/processing/driverworkingtime/tripsegmentation/service/DriverCountryTripSegmentationServiceTest.java
@@ -6,7 +6,13 @@ import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.geocoding.model.GeoCountryResolution;
import at.procon.eventhub.geocoding.model.GeoCountryResolutionStatus;
import at.procon.eventhub.geocoding.service.GeoCountryResolver;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassification;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationReason;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationResult;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
+import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeStatus;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval;
+import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeRestCoverageInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDriverPartition;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeProcessingInput;
@@ -104,12 +110,177 @@ class DriverCountryTripSegmentationServiceTest {
assertThat(result.segments().get(0).boundaryCountryReverseGeocoded()).isTrue();
}
+
+ @Test
+ void buildsCompleteTripBetweenHomeClassificationsAndAttachesContainedNonHomeIntervals() {
+ GeoCountryResolver resolver = (latitude, longitude, allowRemoteLookup) ->
+ unresolved(latitude, longitude);
+ DriverCountryTripSegmentationService service = new DriverCountryTripSegmentationService(
+ resolver,
+ properties()
+ );
+ List supportEvents = List.of(
+ position("p-at", "2026-05-01T08:05:00Z", "48.2082", "16.3738", "A"),
+ border("b-at-de", "2026-05-01T10:00:00Z", "48.75", "13.84", "A", "D"),
+ position("p-de", "2026-05-01T11:00:00Z", "48.90", "13.40", "D")
+ );
+ List classifications = List.of(
+ classification("leading", "2026-05-01T05:00:00Z", "2026-05-01T06:00:00Z", DriverNdiHomeStatus.NOT_HOME),
+ classification("home-start", "2026-05-01T06:00:00Z", "2026-05-01T08:00:00Z", DriverNdiHomeStatus.HOME),
+ classification("away-rest", "2026-05-01T12:00:00Z", "2026-05-01T13:00:00Z", DriverNdiHomeStatus.NOT_HOME),
+ classification("home-end", "2026-05-01T16:00:00Z", "2026-05-02T00:00:00Z", DriverNdiHomeStatus.HOME),
+ classification("trailing", "2026-05-02T01:00:00Z", "2026-05-02T02:00:00Z", DriverNdiHomeStatus.NOT_HOME)
+ );
+
+ DriverCountryTripSegmentationResult result = service.segmentPreparedInputs(
+ Map.of(DRIVER, preparedInput(supportEvents)),
+ homeScope(classifications)
+ ).resultForDriver(DRIVER);
+
+ assertThat(result.tripCount()).isEqualTo(1);
+ assertThat(result.unassignedNonHomeClassificationCount()).isEqualTo(2);
+ assertThat(result.trips()).singleElement().satisfies(trip -> {
+ assertThat(trip.startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:00:00Z"));
+ assertThat(trip.endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T16:00:00Z"));
+ assertThat(trip.startHomeClassification().intervalId()).isEqualTo("home-start");
+ assertThat(trip.endHomeClassification().intervalId()).isEqualTo("home-end");
+ assertThat(trip.containedNonHomeClassifications())
+ .extracting(DriverNdiHomeClassification::intervalId)
+ .containsExactly("away-rest");
+ assertThat(trip.countrySegments()).hasSize(2);
+ assertThat(trip.countrySegments())
+ .allSatisfy(segment -> assertThat(segment.tripId()).isEqualTo(trip.tripId()));
+ assertThat(trip.countrySegments())
+ .extracting(segment -> segment.countryCode())
+ .containsExactly("AT", "DE");
+ });
+ }
+
+
+ @Test
+ void reusesMiddleHomeClassificationAsPreviousTripEndAndNextTripStart() {
+ GeoCountryResolver resolver = (latitude, longitude, allowRemoteLookup) ->
+ unresolved(latitude, longitude);
+ DriverCountryTripSegmentationService service = new DriverCountryTripSegmentationService(
+ resolver,
+ properties()
+ );
+ List classifications = List.of(
+ classification("home-1", "2026-05-01T06:00:00Z", "2026-05-01T08:00:00Z", DriverNdiHomeStatus.HOME),
+ classification("away-1", "2026-05-01T12:00:00Z", "2026-05-01T13:00:00Z", DriverNdiHomeStatus.NOT_HOME),
+ classification("home-2", "2026-05-01T16:00:00Z", "2026-05-01T18:00:00Z", DriverNdiHomeStatus.HOME),
+ classification("away-2", "2026-05-01T20:00:00Z", "2026-05-01T21:00:00Z", DriverNdiHomeStatus.NOT_HOME),
+ classification("home-3", "2026-05-02T02:00:00Z", "2026-05-02T08:00:00Z", DriverNdiHomeStatus.HOME)
+ );
+
+ DriverCountryTripSegmentationResult result = service.segmentPreparedInputs(
+ Map.of(DRIVER, preparedInput(List.of())),
+ homeScope(classifications)
+ ).resultForDriver(DRIVER);
+
+ assertThat(result.trips()).hasSize(2);
+ assertThat(result.trips().get(0).endHomeClassification().intervalId()).isEqualTo("home-2");
+ assertThat(result.trips().get(1).startHomeClassification().intervalId()).isEqualTo("home-2");
+ assertThat(result.trips().get(0).containedNonHomeClassifications())
+ .extracting(DriverNdiHomeClassification::intervalId)
+ .containsExactly("away-1");
+ assertThat(result.trips().get(1).containedNonHomeClassifications())
+ .extracting(DriverNdiHomeClassification::intervalId)
+ .containsExactly("away-2");
+ }
+
private EventHubProperties properties() {
EventHubProperties properties = new EventHubProperties();
properties.getReverseGeocoding().getNominatim().setMaxRemoteLookupsPerExecution(10);
return properties;
}
+
+ private DriverNdiHomeClassificationScopeResult homeScope(
+ List classifications
+ ) {
+ DriverNdiHomeClassificationResult result = new DriverNdiHomeClassificationResult(
+ DRIVER,
+ classifications.size(),
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ classifications,
+ List.of(),
+ java.util.Set.of(),
+ java.util.Set.of(),
+ List.of()
+ );
+ return new DriverNdiHomeClassificationScopeResult(
+ "test-corpus",
+ 0,
+ 0,
+ 0,
+ Map.of(DRIVER, result),
+ List.of()
+ );
+ }
+
+ private DriverNdiHomeClassification classification(
+ String intervalId,
+ String startedAt,
+ String endedAt,
+ DriverNdiHomeStatus status
+ ) {
+ OffsetDateTime start = OffsetDateTime.parse(startedAt);
+ OffsetDateTime end = OffsetDateTime.parse(endedAt);
+ DriverWorkingTimeRestCoverageInterval evidence = new DriverWorkingTimeRestCoverageInterval(
+ UUID.randomUUID(),
+ DRIVER,
+ start,
+ end,
+ java.time.Duration.between(start, end).getSeconds(),
+ 0,
+ 0.0d,
+ "previous-" + intervalId,
+ "next-" + intervalId,
+ REGISTRATION,
+ REGISTRATION,
+ VEHICLE,
+ VEHICLE,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+ return new DriverNdiHomeClassification(
+ intervalId,
+ evidence,
+ null,
+ null,
+ null,
+ null,
+ false,
+ status,
+ status == DriverNdiHomeStatus.HOME
+ ? DriverNdiHomeClassificationReason.REST_OVER_VERY_LONG_THRESHOLD
+ : DriverNdiHomeClassificationReason.SHORT_REST
+ );
+ }
+
private DriverWorkingTimePreparedInput preparedInput(List supportEvents) {
UUID sessionId = UUID.randomUUID();
DriverWorkingTimeActivityInterval drive = new DriverWorkingTimeActivityInterval(