From eb4e04f1447461364f7781713335e128829c7e8b Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Wed, 13 May 2026 12:35:39 +0200 Subject: [PATCH] Add esper driving interruption projections --- ...eventhub-esper-poc.postman_collection.json | 32 +++ .../api/TachographFileSessionController.java | 10 + ...hographEsperDriverProcessingResultDto.java | 5 + ...achographEsperEventsProcessingRequest.java | 13 + ...EsperDrivingInterruptionIntervalEvent.java | 17 ++ .../service/DriverTimelineBuilder.java | 146 ++++++++++- ...achographFileSessionProcessingService.java | 226 +++++++++++++++++- ...h-driving-interruption-interval-events.epl | 64 +++++ ...hograph-vu-card-absent-interval-events.epl | 35 ++- .../TachographFileSessionControllerTest.java | 37 ++- .../service/DriverTimelineBuilderTest.java | 76 ++++++ ...graphFileSessionProcessingServiceTest.java | 88 +++++++ 12 files changed, 717 insertions(+), 32 deletions(-) create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperEventsProcessingRequest.java create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/model/TachographEsperDrivingInterruptionIntervalEvent.java create mode 100644 src/main/resources/esper/tachograph-driving-interruption-interval-events.epl diff --git a/postman/eventhub-esper-poc.postman_collection.json b/postman/eventhub-esper-poc.postman_collection.json index a675bd3..5dfb96c 100644 --- a/postman/eventhub-esper-poc.postman_collection.json +++ b/postman/eventhub-esper-poc.postman_collection.json @@ -374,6 +374,38 @@ } } }, + { + "name": "Process tachograph file session Esper events", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"occurredFrom\": \"{{occurredFrom}}\",\n \"occurredTo\": \"{{occurredTo}}\",\n \"significantDrivingMinutes\": 3\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers/{{driverKey}}/processing/esper-events", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "tachograph-file-sessions", + "{{sessionId}}", + "drivers", + "{{driverKey}}", + "processing", + "esper-events" + ] + } + } + }, { "name": "Process tachograph file session operating periods", "request": { diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java b/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java index 46a8da3..0504df4 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionController.java @@ -1,6 +1,7 @@ package at.procon.eventhub.tachographfilesession.api; import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperEventsProcessingRequest; import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto; import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest; @@ -75,6 +76,15 @@ public class TachographFileSessionController { return ResponseEntity.ok(processingService.getEsperDriverProcessingResults(sessionId, driverKey)); } + @PostMapping("/{sessionId}/drivers/{driverKey}/processing/esper-events") + public ResponseEntity evaluateEsperDriverProcessingResults( + @PathVariable UUID sessionId, + @PathVariable String driverKey, + @RequestBody(required = false) TachographEsperEventsProcessingRequest request + ) { + return ResponseEntity.ok(processingService.getEsperDriverProcessingResults(sessionId, driverKey, request)); + } + @PostMapping("/{sessionId}/drivers/{driverKey}/processing/operating-periods") public ResponseEntity evaluateOperatingPeriods( @PathVariable UUID sessionId, diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperDriverProcessingResultDto.java b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperDriverProcessingResultDto.java index a1fd860..098e0b8 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperDriverProcessingResultDto.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperDriverProcessingResultDto.java @@ -1,6 +1,7 @@ package at.procon.eventhub.tachographfilesession.dto; import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent; import java.time.OffsetDateTime; @@ -13,12 +14,16 @@ public record TachographEsperDriverProcessingResultDto( String sourceKind, OffsetDateTime loadedFrom, OffsetDateTime loadedTo, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, int activityIntervalCount, int drivingIntervalCount, + int drivingInterruptionIntervalCount, int vehicleUsageIntervalCount, int vuCardAbsentIntervalCount, List activityIntervals, List drivingIntervals, + List drivingInterruptionIntervals, List vehicleUsageIntervals, List vuCardAbsentIntervals, List notes diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperEventsProcessingRequest.java b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperEventsProcessingRequest.java new file mode 100644 index 0000000..40f4a70 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/dto/TachographEsperEventsProcessingRequest.java @@ -0,0 +1,13 @@ +package at.procon.eventhub.tachographfilesession.dto; + +import java.time.OffsetDateTime; + +public record TachographEsperEventsProcessingRequest( + OffsetDateTime occurredFrom, + OffsetDateTime occurredTo, + Integer significantDrivingMinutes +) { + public TachographEsperEventsProcessingRequest { + significantDrivingMinutes = significantDrivingMinutes == null ? null : Math.max(1, significantDrivingMinutes); + } +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/model/TachographEsperDrivingInterruptionIntervalEvent.java b/src/main/java/at/procon/eventhub/tachographfilesession/model/TachographEsperDrivingInterruptionIntervalEvent.java new file mode 100644 index 0000000..43c10d8 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/model/TachographEsperDrivingInterruptionIntervalEvent.java @@ -0,0 +1,17 @@ +package at.procon.eventhub.tachographfilesession.model; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public record TachographEsperDrivingInterruptionIntervalEvent( + UUID sessionId, + String driverKey, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + long durationSeconds, + String previousDrivingSourceIntervalId, + String nextDrivingSourceIntervalId, + String previousVehicleKey, + String nextVehicleKey +) { +} diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java index 70fe361..9a0fff1 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilder.java @@ -19,12 +19,15 @@ import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval; import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval; import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -47,6 +50,8 @@ public class DriverTimelineBuilder { loadResource("esper/tachograph-activity-interval-events.epl"); private static final String DRIVING_INTERVAL_EVENTS_EPL = loadResource("esper/tachograph-driving-interval-events.epl"); + private static final String DRIVING_INTERRUPTION_INTERVAL_EVENTS_EPL_TEMPLATE = + loadResource("esper/tachograph-driving-interruption-interval-events.epl"); private static final String VEHICLE_USAGE_INTERVAL_EVENTS_EPL = loadResource("esper/tachograph-vehicle-usage-interval-events.epl"); private static final String VU_CARD_ABSENT_INTERVAL_EVENTS_EPL = @@ -139,6 +144,38 @@ public class DriverTimelineBuilder { return timeline == null ? List.of() : buildEsperVehicleUsageIntervalEvents(timeline.vehicleUsageIntervals()); } + public List buildEsperDrivingInterruptionIntervalEvents( + TachographFileSession session, + DriverExtractionSession driverSession, + int significantDrivingMinutes + ) { + String sourceKind = session.metadata().driverCardFile() ? "DRIVER_CARD" : "VEHICLE_UNIT"; + List activityIntervals = resolveActivities(driverSession.cardActivityIntervals(), sourceKind); + return buildEsperDrivingInterruptionIntervalEvents( + session.sessionId(), + driverSession.driverKey(), + activityIntervals, + significantDrivingMinutes + ); + } + + public List buildEsperDrivingInterruptionIntervalEvents( + UUID sessionId, + String driverKey, + ResolvedDriverTimeline timeline, + int significantDrivingMinutes + ) { + if (timeline == null) { + return List.of(); + } + return buildEsperDrivingInterruptionIntervalEvents( + sessionId, + driverKey, + timeline.activityIntervals(), + significantDrivingMinutes + ); + } + public List buildEsperVuCardAbsentIntervalEvents( TachographFileSession session, DriverExtractionSession driverSession @@ -244,6 +281,36 @@ public class DriverTimelineBuilder { return result; } + private List buildEsperDrivingInterruptionIntervalEvents( + UUID sessionId, + String driverKey, + List activityIntervals, + int significantDrivingMinutes + ) { + if (activityIntervals == null || activityIntervals.size() < 2) { + return List.of(); + } + List result = new ArrayList<>(); + executeWithRuntime( + configuration -> configuration.getCommon().addEventType( + "TachographActivityIntervalInputEvent", + activityIntervalInputDefinition() + ), + renderDrivingInterruptionIntervalEventsEpl(significantDrivingMinutes), + "drivingInterruptionIntervals", + newData -> collectDrivingInterruptionIntervalEvents(newData, result), + runtime -> { + for (ResolvedActivityInterval interval : activityIntervals) { + runtime.getEventService().sendEventMap( + toActivityIntervalInputMap(sessionId, driverKey, interval), + "TachographActivityIntervalInputEvent" + ); + } + } + ); + return result; + } + private List buildEsperVuCardAbsentIntervalEvents( List vehicleUsageIntervals ) { @@ -477,7 +544,10 @@ public class DriverTimelineBuilder { sender.accept(runtime); } catch (EPCompileException | EPDeployException e) { - throw new IllegalStateException("Cannot compile/deploy tachograph projection EPL", e); + throw new IllegalStateException( + "Cannot compile/deploy tachograph projection EPL for statement '" + statementName + "'", + e + ); } finally { if (runtime != null) { runtime.destroy(); @@ -497,8 +567,12 @@ public class DriverTimelineBuilder { definition.put("registrationKey", String.class); definition.put("vehicleKey", String.class); definition.put("sourceKind", String.class); + definition.put("firstSourceIntervalId", String.class); + definition.put("lastSourceIntervalId", String.class); definition.put("startedAt", OffsetDateTime.class); definition.put("endedAt", OffsetDateTime.class); + definition.put("startedAtEpochSecond", long.class); + definition.put("endedAtEpochSecond", long.class); definition.put("durationSeconds", long.class); definition.put("sourceIntervalIds", java.util.List.class); definition.put("synthetic", boolean.class); @@ -512,8 +586,12 @@ public class DriverTimelineBuilder { definition.put("sessionId", UUID.class); definition.put("driverKey", String.class); definition.put("intervalId", String.class); + definition.put("firstSourceIntervalId", String.class); + definition.put("lastSourceIntervalId", String.class); definition.put("startedAt", OffsetDateTime.class); definition.put("endedAt", OffsetDateTime.class); + definition.put("startedAtEpochSecond", long.class); + definition.put("endedAtEpochSecond", Long.class); definition.put("durationSeconds", long.class); definition.put("odometerBeginKm", Long.class); definition.put("odometerEndKm", Long.class); @@ -540,8 +618,12 @@ public class DriverTimelineBuilder { event.put("registrationKey", interval.registrationKey()); event.put("vehicleKey", interval.vehicleKey()); event.put("sourceKind", interval.sourceKind()); + event.put("firstSourceIntervalId", firstSourceIntervalId(interval)); + event.put("lastSourceIntervalId", lastSourceIntervalId(interval)); event.put("startedAt", interval.from()); event.put("endedAt", interval.to()); + event.put("startedAtEpochSecond", interval.from().toEpochSecond()); + event.put("endedAtEpochSecond", interval.to().toEpochSecond()); event.put("durationSeconds", interval.durationSeconds()); event.put("sourceIntervalIds", interval.sourceIntervalIds()); event.put("synthetic", interval.synthetic()); @@ -550,13 +632,27 @@ public class DriverTimelineBuilder { return event; } + private String firstSourceIntervalId(ResolvedActivityInterval interval) { + return interval.sourceIntervalIds().isEmpty() ? interval.intervalId() : interval.sourceIntervalIds().get(0); + } + + private String lastSourceIntervalId(ResolvedActivityInterval interval) { + return interval.sourceIntervalIds().isEmpty() + ? interval.intervalId() + : interval.sourceIntervalIds().get(interval.sourceIntervalIds().size() - 1); + } + private Map toVehicleUsageIntervalInputMap(ResolvedVehicleUsageInterval interval) { Map event = new LinkedHashMap<>(); event.put("sessionId", interval.sessionId()); event.put("driverKey", interval.driverKey()); event.put("intervalId", interval.intervalId()); + event.put("firstSourceIntervalId", firstSourceIntervalId(interval)); + event.put("lastSourceIntervalId", lastSourceIntervalId(interval)); event.put("startedAt", interval.from()); event.put("endedAt", interval.to()); + event.put("startedAtEpochSecond", interval.from().toEpochSecond()); + event.put("endedAtEpochSecond", interval.to() == null ? null : interval.to().toEpochSecond()); event.put("durationSeconds", interval.durationSeconds()); event.put("odometerBeginKm", interval.odometerBeginKm()); event.put("odometerEndKm", interval.odometerEndKm()); @@ -567,6 +663,16 @@ public class DriverTimelineBuilder { return event; } + private String firstSourceIntervalId(ResolvedVehicleUsageInterval interval) { + return interval.sourceIntervalIds().isEmpty() ? interval.intervalId() : interval.sourceIntervalIds().get(0); + } + + private String lastSourceIntervalId(ResolvedVehicleUsageInterval interval) { + return interval.sourceIntervalIds().isEmpty() + ? interval.intervalId() + : interval.sourceIntervalIds().get(interval.sourceIntervalIds().size() - 1); + } + private void collectActivityIntervalEvents( EventBean[] newData, List target @@ -622,6 +728,30 @@ public class DriverTimelineBuilder { } } + private void collectDrivingInterruptionIntervalEvents( + EventBean[] newData, + List target + ) { + if (newData == null) { + return; + } + for (EventBean event : newData) { + long startedAtEpochSecond = (Long) event.get("startedAtEpochSecond"); + long endedAtEpochSecond = (Long) event.get("endedAtEpochSecond"); + target.add(new TachographEsperDrivingInterruptionIntervalEvent( + (UUID) event.get("sessionId"), + (String) event.get("driverKey"), + OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC), + OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC), + (Long) event.get("durationSeconds"), + (String) event.get("previousDrivingSourceIntervalId"), + (String) event.get("nextDrivingSourceIntervalId"), + (String) event.get("previousVehicleKey"), + (String) event.get("nextVehicleKey") + )); + } + } + private void collectVuCardAbsentIntervalEvents( EventBean[] newData, List target @@ -630,11 +760,13 @@ public class DriverTimelineBuilder { return; } for (EventBean event : newData) { + long startedAtEpochSecond = (Long) event.get("startedAtEpochSecond"); + long endedAtEpochSecond = (Long) event.get("endedAtEpochSecond"); target.add(new TachographEsperVuCardAbsentIntervalEvent( (UUID) event.get("sessionId"), (String) event.get("driverKey"), - (OffsetDateTime) event.get("startedAt"), - (OffsetDateTime) event.get("endedAt"), + OffsetDateTime.ofInstant(Instant.ofEpochSecond(startedAtEpochSecond), ZoneOffset.UTC), + OffsetDateTime.ofInstant(Instant.ofEpochSecond(endedAtEpochSecond), ZoneOffset.UTC), (Long) event.get("durationSeconds"), (String) event.get("previousUsageIntervalId"), (String) event.get("nextUsageIntervalId"), @@ -659,4 +791,12 @@ public class DriverTimelineBuilder { throw new IllegalStateException("Cannot load EPL resource: " + path, e); } } + + private String renderDrivingInterruptionIntervalEventsEpl(int significantDrivingMinutes) { + long thresholdSeconds = Math.max(1, significantDrivingMinutes) * 60L; + return DRIVING_INTERRUPTION_INTERVAL_EVENTS_EPL_TEMPLATE.replace( + "${SIGNIFICANT_DRIVING_THRESHOLD_SECONDS}", + Long.toString(thresholdSeconds) + ); + } } diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java index aa7bd57..9dc3652 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingService.java @@ -1,6 +1,7 @@ package at.procon.eventhub.tachographfilesession.service; import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperEventsProcessingRequest; import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest; import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto; @@ -12,6 +13,7 @@ import at.procon.eventhub.tachographfilesession.model.ProcessedShiftDrivingEvalu import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval; import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent; @@ -121,6 +123,17 @@ public class TachographFileSessionProcessingService { UUID sessionId, String driverKey ) { + return getEsperDriverProcessingResults(sessionId, driverKey, null); + } + + public TachographEsperDriverProcessingResultDto getEsperDriverProcessingResults( + UUID sessionId, + String driverKey, + TachographEsperEventsProcessingRequest request + ) { + TachographEsperEventsProcessingRequest effectiveRequest = request == null + ? new TachographEsperEventsProcessingRequest(null, null, null) + : request; TachographFileSession session = repository.find(sessionId) .orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId)); DriverExtractionSession driver = session.driversByKey().get(driverKey); @@ -129,14 +142,44 @@ public class TachographFileSessionProcessingService { } ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driver); - List activityIntervals = - driverTimelineBuilder.buildEsperActivityIntervalEvents(sessionId, driverKey, timeline); - List drivingIntervals = - driverTimelineBuilder.buildEsperDrivingIntervalEvents(sessionId, driverKey, timeline); - List vehicleUsageIntervals = - driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline); - List vuCardAbsentIntervals = - driverTimelineBuilder.buildEsperVuCardAbsentIntervalEvents(timeline); + OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null ? timeline.loadedFrom() : utc(effectiveRequest.occurredFrom()); + OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null ? timeline.loadedTo() : utc(effectiveRequest.occurredTo()); + if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) { + throw new IllegalArgumentException("occurredTo must not be before occurredFrom."); + } + int significantDrivingMinutes = resolveEsperSignificantDrivingMinutes(effectiveRequest); + + List activityIntervals = clipEsperActivityIntervalEvents( + driverTimelineBuilder.buildEsperActivityIntervalEvents(sessionId, driverKey, timeline), + requestedFrom, + requestedTo + ); + List drivingIntervals = clipEsperActivityIntervalEvents( + driverTimelineBuilder.buildEsperDrivingIntervalEvents(sessionId, driverKey, timeline), + requestedFrom, + requestedTo + ); + List drivingInterruptionIntervals = + clipEsperDrivingInterruptionIntervalEvents( + driverTimelineBuilder.buildEsperDrivingInterruptionIntervalEvents( + sessionId, + driverKey, + timeline, + significantDrivingMinutes + ), + requestedFrom, + requestedTo + ); + List vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents( + driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline), + requestedFrom, + requestedTo + ); + List vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents( + driverTimelineBuilder.buildEsperVuCardAbsentIntervalEvents(timeline), + requestedFrom, + requestedTo + ); return new TachographEsperDriverProcessingResultDto( sessionId, @@ -144,18 +187,174 @@ public class TachographFileSessionProcessingService { timeline.sourceKind(), timeline.loadedFrom(), timeline.loadedTo(), + requestedFrom, + requestedTo, activityIntervals.size(), drivingIntervals.size(), + drivingInterruptionIntervals.size(), vehicleUsageIntervals.size(), vuCardAbsentIntervals.size(), activityIntervals, drivingIntervals, + drivingInterruptionIntervals, vehicleUsageIntervals, vuCardAbsentIntervals, esperProjectionNotes() ); } + private List clipEsperActivityIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean clipped = interval.clippedToRequestedPeriod() + || !start.equals(interval.startedAt()) + || !end.equals(interval.endedAt()); + return new TachographEsperActivityIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + interval.activityType(), + interval.cardSlot(), + interval.cardStatus(), + interval.drivingStatus(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.sourceIntervalIds(), + interval.synthetic(), + clipped, + interval.level() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperActivityIntervalEvent::startedAt) + .thenComparing(TachographEsperActivityIntervalEvent::endedAt) + .thenComparing(TachographEsperActivityIntervalEvent::activityType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperVehicleUsageIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + boolean startClipped = !start.equals(interval.startedAt()); + boolean endClipped = !end.equals(interval.endedAt()); + return new TachographEsperVehicleUsageIntervalEvent( + interval.sessionId(), + interval.driverKey(), + interval.intervalId(), + start, + end, + Duration.between(start, end).getSeconds(), + startClipped ? null : interval.odometerBeginKm(), + endClipped ? null : interval.odometerEndKm(), + interval.registrationKey(), + interval.vehicleKey(), + interval.sourceKind(), + interval.sourceIntervalIds() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt) + .thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List clipEsperDrivingInterruptionIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + return new TachographEsperDrivingInterruptionIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.previousDrivingSourceIntervalId(), + interval.nextDrivingSourceIntervalId(), + interval.previousVehicleKey(), + interval.nextVehicleKey() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperDrivingInterruptionIntervalEvent::startedAt) + .thenComparing(TachographEsperDrivingInterruptionIntervalEvent::endedAt)) + .toList(); + } + + private List clipEsperVuCardAbsentIntervalEvents( + List intervals, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo + ) { + if (requestedFrom == null || requestedTo == null) { + return List.of(); + } + return intervals.stream() + .map(interval -> { + OffsetDateTime start = max(interval.startedAt(), requestedFrom); + OffsetDateTime end = min(interval.endedAt(), requestedTo); + if (!end.isAfter(start)) { + return null; + } + return new TachographEsperVuCardAbsentIntervalEvent( + interval.sessionId(), + interval.driverKey(), + start, + end, + Duration.between(start, end).getSeconds(), + interval.previousUsageIntervalId(), + interval.nextUsageIntervalId(), + interval.previousRegistrationKey(), + interval.nextRegistrationKey(), + interval.previousVehicleKey(), + interval.nextVehicleKey() + ); + }) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(TachographEsperVuCardAbsentIntervalEvent::startedAt) + .thenComparing(TachographEsperVuCardAbsentIntervalEvent::endedAt)) + .toList(); + } + private List synthesizeUnknownGaps( List knownIntervals, Duration gapDetectionTolerance @@ -694,6 +893,12 @@ public class TachographFileSessionProcessingService { : request.gapDetectionToleranceSeconds(); } + private int resolveEsperSignificantDrivingMinutes(TachographEsperEventsProcessingRequest request) { + return request.significantDrivingMinutes() == null + ? properties.getTachographFileSession().getProcessing().getSignificantDrivingMinutes() + : request.significantDrivingMinutes(); + } + private List notes() { return List.of( "This endpoint evaluates operating periods from the in-memory tachograph file-session model.", @@ -707,7 +912,10 @@ public class TachographFileSessionProcessingService { return List.of( "This endpoint returns Esper-backed per-driver interval projections from the in-memory tachograph file-session model.", "Driving intervals are a filtered projection of activity intervals where activityType = DRIVE.", - "VU card-absent intervals are gaps between consecutive normalized vehicle-usage intervals for the same driver." + "Driving interruption intervals are gaps between consecutive driving intervals longer than the configured significant-driving threshold.", + "VU card-absent intervals are gaps between consecutive normalized vehicle-usage intervals for the same driver.", + "occurredFrom and occurredTo clip the returned interval projections to the requested UTC time window.", + "Vehicle-usage intervals clear clipped odometer endpoints because boundary odometer values cannot be recomputed safely from the source interval." ); } diff --git a/src/main/resources/esper/tachograph-driving-interruption-interval-events.epl b/src/main/resources/esper/tachograph-driving-interruption-interval-events.epl new file mode 100644 index 0000000..5c659cd --- /dev/null +++ b/src/main/resources/esper/tachograph-driving-interruption-interval-events.epl @@ -0,0 +1,64 @@ +create schema SignificantDrivingInterval( + sessionId java.util.UUID, + driverKey string, + firstSourceIntervalId string, + lastSourceIntervalId string, + startedAtEpochSecond long, + endedAtEpochSecond long, + durationSeconds long, + vehicleKey string +); + +create schema DrivingInterruptionInterval( + sessionId java.util.UUID, + driverKey string, + startedAtEpochSecond long, + endedAtEpochSecond long, + durationSeconds long, + previousDrivingSourceIntervalId string, + nextDrivingSourceIntervalId string, + previousVehicleKey string, + nextVehicleKey string +); + +insert into SignificantDrivingInterval +select + sessionId, + driverKey, + firstSourceIntervalId, + lastSourceIntervalId, + startedAtEpochSecond, + endedAtEpochSecond, + durationSeconds, + vehicleKey +from TachographActivityIntervalInputEvent(activityType = 'DRIVE', durationSeconds > ${SIGNIFICANT_DRIVING_THRESHOLD_SECONDS}); + +create window PreviousSignificantDrivingInterval#unique(driverKey) as SignificantDrivingInterval; + +on SignificantDrivingInterval as next +insert into DrivingInterruptionInterval +select + priorInterval.sessionId as sessionId, + priorInterval.driverKey as driverKey, + priorInterval.endedAtEpochSecond as startedAtEpochSecond, + next.startedAtEpochSecond as endedAtEpochSecond, + next.startedAtEpochSecond - priorInterval.endedAtEpochSecond as durationSeconds, + priorInterval.lastSourceIntervalId as previousDrivingSourceIntervalId, + next.firstSourceIntervalId as nextDrivingSourceIntervalId, + priorInterval.vehicleKey as previousVehicleKey, + next.vehicleKey as nextVehicleKey +from PreviousSignificantDrivingInterval as priorInterval +where priorInterval.driverKey = next.driverKey + and next.startedAtEpochSecond > priorInterval.endedAtEpochSecond; + +@Priority(20) +on SignificantDrivingInterval +delete from PreviousSignificantDrivingInterval; + +@Priority(10) +on SignificantDrivingInterval as current +insert into PreviousSignificantDrivingInterval +select *; + +@name('drivingInterruptionIntervals') +select * from DrivingInterruptionInterval; diff --git a/src/main/resources/esper/tachograph-vu-card-absent-interval-events.epl b/src/main/resources/esper/tachograph-vu-card-absent-interval-events.epl index bd5ac2b..83b082f 100644 --- a/src/main/resources/esper/tachograph-vu-card-absent-interval-events.epl +++ b/src/main/resources/esper/tachograph-vu-card-absent-interval-events.epl @@ -3,8 +3,8 @@ create context PerDriver partition by driverKey from TachographVehicleUsageInter create schema VuCardAbsentInterval( sessionId java.util.UUID, driverKey string, - startedAt java.time.OffsetDateTime, - endedAt java.time.OffsetDateTime, + startedAtEpochSecond long, + endedAtEpochSecond long, durationSeconds long, previousUsageIntervalId string, nextUsageIntervalId string, @@ -17,38 +17,37 @@ create schema VuCardAbsentInterval( context PerDriver create window PreviousVehicleUsageInterval#lastevent as TachographVehicleUsageIntervalInputEvent; -context PerDriver @Priority(30) +context PerDriver on TachographVehicleUsageIntervalInputEvent as next insert into VuCardAbsentInterval select - prev.sessionId as sessionId, - prev.driverKey as driverKey, - prev.endedAt.plusSeconds(1) as startedAt, - next.startedAt as endedAt, - java.time.Duration.between(prev.endedAt.plusSeconds(1), next.startedAt).getSeconds() as durationSeconds, - prev.intervalId as previousUsageIntervalId, - next.intervalId as nextUsageIntervalId, - prev.registrationKey as previousRegistrationKey, + priorInterval.sessionId as sessionId, + priorInterval.driverKey as driverKey, + priorInterval.endedAtEpochSecond + 1L as startedAtEpochSecond, + next.startedAtEpochSecond as endedAtEpochSecond, + next.startedAtEpochSecond - (priorInterval.endedAtEpochSecond + 1L) as durationSeconds, + priorInterval.lastSourceIntervalId as previousUsageIntervalId, + next.firstSourceIntervalId as nextUsageIntervalId, + priorInterval.registrationKey as previousRegistrationKey, next.registrationKey as nextRegistrationKey, - prev.vehicleKey as previousVehicleKey, + priorInterval.vehicleKey as previousVehicleKey, next.vehicleKey as nextVehicleKey -from PreviousVehicleUsageInterval as prev -where prev.endedAt is not null +from PreviousVehicleUsageInterval as priorInterval +where priorInterval.endedAt is not null and next.startedAt is not null - and next.startedAt.isAfter(prev.endedAt.plusSeconds(1)); + and next.startedAtEpochSecond > priorInterval.endedAtEpochSecond + 1L; -context PerDriver @Priority(20) +context PerDriver on TachographVehicleUsageIntervalInputEvent delete from PreviousVehicleUsageInterval; -context PerDriver @Priority(10) +context PerDriver on TachographVehicleUsageIntervalInputEvent as current insert into PreviousVehicleUsageInterval select *; @name('vuCardAbsentIntervals') -context PerDriver select * from VuCardAbsentInterval; diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java index e0b1755..0385229 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/api/TachographFileSessionControllerTest.java @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperEventsProcessingRequest; import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto; import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto; @@ -19,6 +20,7 @@ import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDri import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; import at.procon.eventhub.tachographfilesession.model.ExtractionStats; import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService; @@ -63,15 +65,22 @@ class TachographFileSessionControllerTest { when(service.getSession(sessionId)).thenReturn(summary); when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver))); when(service.getDriver(sessionId, "12:123")).thenReturn(new TachographFileDriverDetailDto(sessionId, "12:123", null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of())); - when(processingService.getEsperDriverProcessingResults(sessionId, "12:123")) + when(processingService.getEsperDriverProcessingResults( + eq(sessionId), + eq("12:123"), + org.mockito.ArgumentMatchers.any(TachographEsperEventsProcessingRequest.class) + )) .thenReturn(new TachographEsperDriverProcessingResultDto( sessionId, "12:123", "DRIVER_CARD", OffsetDateTime.parse("2026-05-12T08:00:00Z"), OffsetDateTime.parse("2026-05-12T12:00:00Z"), + OffsetDateTime.parse("2026-05-12T08:30:00Z"), + OffsetDateTime.parse("2026-05-12T11:30:00Z"), 2, 1, + 1, 2, 1, List.of(new TachographEsperActivityIntervalEvent( @@ -112,6 +121,17 @@ class TachographFileSessionControllerTest { false, "RAW_INTERVAL" )), + List.of(new TachographEsperDrivingInterruptionIntervalEvent( + sessionId, + "12:123", + OffsetDateTime.parse("2026-05-12T10:00:00Z"), + OffsetDateTime.parse("2026-05-12T10:30:00Z"), + 1800L, + "ACT-2", + "ACT-3", + "VIN-1", + "VIN-2" + )), List.of(new TachographEsperVehicleUsageIntervalEvent( sessionId, "12:123", @@ -187,12 +207,25 @@ class TachographFileSessionControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.driverKey").value("12:123")); - mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/esper-events", sessionId, "12:123")) + mockMvc.perform(post("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/esper-events", sessionId, "12:123") + .contentType("application/json") + .content(""" + { + "occurredFrom": "2026-05-12T08:30:00Z", + "occurredTo": "2026-05-12T11:30:00Z", + "significantDrivingMinutes": 3 + } + """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.driverKey").value("12:123")) .andExpect(jsonPath("$.sourceKind").value("DRIVER_CARD")) + .andExpect(jsonPath("$.requestedFrom").value("2026-05-12T08:30:00Z")) + .andExpect(jsonPath("$.requestedTo").value("2026-05-12T11:30:00Z")) .andExpect(jsonPath("$.activityIntervalCount").value(2)) + .andExpect(jsonPath("$.drivingInterruptionIntervalCount").value(1)) .andExpect(jsonPath("$.vuCardAbsentIntervalCount").value(1)) + .andExpect(jsonPath("$.drivingInterruptionIntervals[0].previousVehicleKey").value("VIN-1")) + .andExpect(jsonPath("$.drivingInterruptionIntervals[0].nextVehicleKey").value("VIN-2")) .andExpect(jsonPath("$.drivingIntervals[0].activityType").value("DRIVE")); mockMvc.perform(post("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/operating-periods", sessionId, "12:123") diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java index 66c5be5..fa6150e 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverTimelineBuilderTest.java @@ -10,6 +10,7 @@ import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent; import at.procon.eventhub.tachographfilesession.model.ExtractionStats; import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline; import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent; +import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographFileSession; @@ -375,4 +376,79 @@ class DriverTimelineBuilderTest { assertThat(absentIntervals.get(0).previousVehicleKey()).isEqualTo("VIN-1"); assertThat(absentIntervals.get(0).nextVehicleKey()).isEqualTo("VIN-2"); } + + @Test + void buildsEsperDrivingInterruptionIntervalEventsFromSignificantDrivingGaps() { + DriverExtractionSession driver = new DriverExtractionSession( + "12:123", + null, + null, + List.of(), + List.of(), + List.of(), + List.of( + new ExtractedCardActivityInterval( + "ACT-1", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T08:02:00Z"), + "DRIVE", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "a" + ), + new ExtractedCardActivityInterval( + "ACT-2", + OffsetDateTime.parse("2026-05-01T08:02:00Z"), + OffsetDateTime.parse("2026-05-01T08:10:00Z"), + "WORK", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-1", + "VIN-1", + "b" + ), + new ExtractedCardActivityInterval( + "ACT-3", + OffsetDateTime.parse("2026-05-01T08:10:00Z"), + OffsetDateTime.parse("2026-05-01T08:15:00Z"), + "DRIVE", + "DRIVER", + "INSERTED", + "SINGLE", + "12:REG-2", + "VIN-2", + "c" + ) + ), + List.of(), + List.of() + ); + TachographFileSession session = new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 3, 0, 0, 0, 0), + List.of(), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + + List interruptions = + builder.buildEsperDrivingInterruptionIntervalEvents(session, driver, 1); + + assertThat(interruptions).hasSize(1); + assertThat(interruptions.get(0).sessionId()).isEqualTo(session.sessionId()); + assertThat(interruptions.get(0).driverKey()).isEqualTo("12:123"); + assertThat(interruptions.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:02:00Z")); + assertThat(interruptions.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:10:00Z")); + assertThat(interruptions.get(0).durationSeconds()).isEqualTo(480L); + assertThat(interruptions.get(0).previousDrivingSourceIntervalId()).isEqualTo("ACT-1"); + assertThat(interruptions.get(0).nextDrivingSourceIntervalId()).isEqualTo("ACT-3"); + assertThat(interruptions.get(0).previousVehicleKey()).isEqualTo("VIN-1"); + assertThat(interruptions.get(0).nextVehicleKey()).isEqualTo("VIN-2"); + } } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java index 503566b..be6865b 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionProcessingServiceTest.java @@ -3,6 +3,7 @@ package at.procon.eventhub.tachographfilesession.service; import static org.assertj.core.api.Assertions.assertThat; import at.procon.eventhub.config.EventHubProperties; +import at.procon.eventhub.tachographfilesession.dto.TachographEsperEventsProcessingRequest; import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto; import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingRequest; import at.procon.eventhub.tachographfilesession.dto.TachographOperatingPeriodsProcessingResultDto; @@ -84,12 +85,99 @@ class TachographFileSessionProcessingServiceTest { assertThat(result.sourceKind()).isEqualTo("DRIVER_CARD"); assertThat(result.activityIntervalCount()).isEqualTo(2); assertThat(result.drivingIntervalCount()).isEqualTo(1); + assertThat(result.drivingInterruptionIntervalCount()).isEqualTo(0); assertThat(result.vehicleUsageIntervalCount()).isEqualTo(2); assertThat(result.vuCardAbsentIntervalCount()).isEqualTo(1); assertThat(result.vuCardAbsentIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:01Z")); assertThat(result.vuCardAbsentIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T12:00:00Z")); } + @Test + void appliesOccurredWindowToEsperDriverProcessingResults() { + EventHubProperties properties = new EventHubProperties(); + TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); + TachographFileSessionProcessingService service = new TachographFileSessionProcessingService( + repository, + new DriverTimelineBuilder(), + properties + ); + + DriverExtractionSession driver = new DriverExtractionSession( + "12:123", + null, + null, + List.of(), + List.of(), + List.of( + new ExtractedCardVehicleUsageInterval( + "CVU-1", + OffsetDateTime.parse("2026-05-01T08:00:00Z"), + OffsetDateTime.parse("2026-05-01T11:00:00Z"), + 100L, + 200L, + "12:REG-1", + "VIN-1", + "vu-1" + ), + new ExtractedCardVehicleUsageInterval( + "CVU-2", + OffsetDateTime.parse("2026-05-01T12:00:00Z"), + OffsetDateTime.parse("2026-05-01T13:00:00Z"), + 201L, + 260L, + "12:REG-2", + "VIN-2", + "vu-2" + ) + ), + List.of( + new ExtractedCardActivityInterval("ACT-1", OffsetDateTime.parse("2026-05-01T08:30:00Z"), OffsetDateTime.parse("2026-05-01T09:00:00Z"), "DRIVE", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "a"), + new ExtractedCardActivityInterval("ACT-2", OffsetDateTime.parse("2026-05-01T09:00:00Z"), OffsetDateTime.parse("2026-05-01T10:00:00Z"), "WORK", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "b"), + new ExtractedCardActivityInterval("ACT-3", OffsetDateTime.parse("2026-05-01T10:00:00Z"), OffsetDateTime.parse("2026-05-01T10:05:00Z"), "DRIVE", "DRIVER", "INSERTED", "SINGLE", "12:REG-2", "VIN-2", "c") + ), + List.of(), + List.of() + ); + TachographFileSession session = new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null), + Map.of(driver.driverKey(), driver), + new ExtractionStats(1, 2, 2, 1, 1, 0), + List.of(), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + repository.save(session); + + TachographEsperDriverProcessingResultDto result = service.getEsperDriverProcessingResults( + session.sessionId(), + driver.driverKey(), + new TachographEsperEventsProcessingRequest( + OffsetDateTime.parse("2026-05-01T08:45:00Z"), + OffsetDateTime.parse("2026-05-01T12:30:00Z"), + 3 + ) + ); + + assertThat(result.requestedFrom()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:45:00Z")); + assertThat(result.requestedTo()).isEqualTo(OffsetDateTime.parse("2026-05-01T12:30:00Z")); + assertThat(result.activityIntervalCount()).isEqualTo(3); + assertThat(result.activityIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:45:00Z")); + assertThat(result.activityIntervals().get(0).clippedToRequestedPeriod()).isTrue(); + assertThat(result.drivingIntervalCount()).isEqualTo(2); + assertThat(result.drivingInterruptionIntervalCount()).isEqualTo(1); + assertThat(result.drivingInterruptionIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T09:00:00Z")); + assertThat(result.drivingInterruptionIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z")); + assertThat(result.drivingInterruptionIntervals().get(0).previousVehicleKey()).isEqualTo("VIN-1"); + assertThat(result.drivingInterruptionIntervals().get(0).nextVehicleKey()).isEqualTo("VIN-2"); + assertThat(result.vehicleUsageIntervalCount()).isEqualTo(2); + assertThat(result.vehicleUsageIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:45:00Z")); + assertThat(result.vehicleUsageIntervals().get(0).odometerBeginKm()).isNull(); + assertThat(result.vehicleUsageIntervals().get(1).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T12:30:00Z")); + assertThat(result.vehicleUsageIntervals().get(1).odometerEndKm()).isNull(); + assertThat(result.vuCardAbsentIntervalCount()).isEqualTo(1); + } + @Test void evaluatesOperatingPeriodsFromSessionTimeline() { EventHubProperties properties = new EventHubProperties();