Classify home-to-home country trips

This commit is contained in:
trifonovt 2026-06-17 13:01:46 +02:00
parent 7584bb8578
commit 4caadf1270
11 changed files with 843 additions and 78 deletions

View File

@ -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.

View File

@ -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" }
]
}
]
}
}
```

View File

@ -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.
*
* <p>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.</p>
*/
public record DriverClassifiedTrip(
String tripId,
String driverKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
long durationSeconds,
DriverNdiHomeClassification startHomeClassification,
DriverNdiHomeClassification endHomeClassification,
List<DriverNdiHomeClassification> containedNonHomeClassifications,
int drivingIntervalCount,
int countrySegmentCount,
List<DriverCountryTripSegment> countrySegments,
List<String> 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);
}
}

View File

@ -4,6 +4,30 @@ import java.math.BigDecimal;
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,
BigDecimal longitudeFrom,
BigDecimal latitudeTo,
BigDecimal longitudeTo,
String positionFromEventId,
String positionToEventId,
DriverCountryTripSegmentBoundarySource endBoundarySource,
String boundaryEventId,
boolean boundaryCountryReverseGeocoded
) {
/**
* Compatibility constructor for callers that still create an unscoped segment.
*/
public DriverCountryTripSegment(
String segmentId,
String driverKey,
String registrationKey,
@ -21,5 +45,27 @@ public record DriverCountryTripSegment(
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
);
}
}

View File

@ -11,14 +11,54 @@ public record DriverCountryTripSegmentationResult(
int reverseGeocodingCacheHitCount,
int unresolvedCoordinateCount,
int segmentCount,
int tripCount,
int unassignedNonHomeClassificationCount,
String reverseGeocodingAttribution,
List<DriverCountryTripSegment> segments,
List<DriverClassifiedTrip> trips,
List<String> notes,
List<String> 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<DriverCountryTripSegment> segments,
List<String> notes,
List<String> warnings
) {
this(
driverKey,
drivingIntervalCount,
supportingGeoEventCount,
explicitBorderCrossingCount,
reverseGeocodingRemoteRequestCount,
reverseGeocodingCacheHitCount,
unresolvedCoordinateCount,
segmentCount,
0,
0,
reverseGeocodingAttribution,
segments,
List.of(),
notes,
warnings
);
}
}

View File

@ -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<String, DriverWorkingTimePreparedInput> preparedInputs
) {
return segmentPreparedInputs(preparedInputs, null);
}
/**
* Builds complete trips between consecutive HOME classifications and calculates country
* segments independently inside every trip.
*/
public DriverCountryTripSegmentationScopeResult segmentPreparedInputs(
Map<String, DriverWorkingTimePreparedInput> 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<String> 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<String> 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<String> warnings = new ArrayList<>();
TripWindowBuildResult windowBuild = buildTripWindows(
driverKey,
homeResult == null ? List.of() : homeResult.classifications(),
warnings
);
DriverStats stats = new DriverStats();
List<DriverClassifiedTrip> trips = new ArrayList<>();
List<DriverCountryTripSegment> 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<String> 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<String> 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<DriverWorkingTimeActivityInterval> 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<DriverNdiHomeClassification> classifications,
List<String> warnings
) {
List<DriverNdiHomeClassification> 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<TripWindow> windows = new ArrayList<>();
List<DriverNdiHomeClassification> 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<DriverNdiHomeClassification> 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<>();
}
unassignedNonHomeCount += pendingNonHome.size();
return new TripWindowBuildResult(
List.copyOf(windows),
assignedNonHomeCount,
unassignedNonHomeCount
);
}
DriverStats stats = new DriverStats();
List<String> warnings = new ArrayList<>();
List<DriverCountryTripSegment> segments = new ArrayList<>();
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);
}
DriverWorkingTimeActivityInterval firstDrive = drivingIntervals.getFirst();
private WindowSegmentation segmentWindow(
String driverKey,
String tripId,
OffsetDateTime windowStart,
OffsetDateTime windowEnd,
List<DriverWorkingTimeActivityInterval> allDrivingIntervals,
List<RuntimeSupportEvidenceEvent> supportEvents,
LookupBudget budget,
DriverStats stats,
List<String> warnings
) {
if (windowStart == null || windowEnd == null || !windowEnd.isAfter(windowStart)) {
return new WindowSegmentation(0, List.of());
}
List<ClippedDrive> 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<DriverCountryTripSegment> 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<RuntimeSupportEvidenceEvent> 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<String> 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<DriverCountryTripSegment> 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<DriverCountryTripSegment> 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<DriverCountryTripSegment> 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<DriverWorkingTimeActivityInterval> drivingIntervals,
List<RuntimeSupportEvidenceEvent> supportEvents
) {
}
private record ClippedDrive(
DriverWorkingTimeActivityInterval source,
OffsetDateTime startedAt,
OffsetDateTime endedAt
) {
}
private record WindowSegmentation(
int drivingIntervalCount,
List<DriverCountryTripSegment> segments
) {
}
private record TripWindow(
String tripId,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
DriverNdiHomeClassification startHomeClassification,
DriverNdiHomeClassification endHomeClassification,
List<DriverNdiHomeClassification> containedNonHomeClassifications
) {
}
private record TripWindowBuildResult(
List<TripWindow> windows,
int assignedNonHomeClassificationCount,
int unassignedNonHomeClassificationCount
) {
}
}

View File

@ -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<String, DriverWorkingTimePreparedInput>"),
Set.of(
DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION,
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION
),
Set.of(
"Map<String, DriverWorkingTimePreparedInput>",
"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<String, Object> 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<String, DriverWorkingTimePreparedInput> preparedInputs(RuntimeProcessingModuleContext context) {
Object output = context.requireResult(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION).output();

View File

@ -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,

View File

@ -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

View File

@ -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(),

View File

@ -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<RuntimeSupportEvidenceEvent> 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<DriverNdiHomeClassification> 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<DriverNdiHomeClassification> 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<DriverNdiHomeClassification> 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<RuntimeSupportEvidenceEvent> supportEvents) {
UUID sessionId = UUID.randomUUID();
DriverWorkingTimeActivityInterval drive = new DriverWorkingTimeActivityInterval(