From 7be6a08013189aa48da68a27b5f11d4db844b812 Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Tue, 12 May 2026 12:05:13 +0200 Subject: [PATCH] Add DTI enrichment evaluation endpoint --- ...eventhub-esper-poc.postman_collection.json | 84 ++- .../esperpoc/api/EsperPocController.java | 49 +- .../esperpoc/dto/DtiBoundaryPositionDto.java | 25 + .../esperpoc/dto/DtiBoundaryVehicleDto.java | 17 + .../dto/DtiBoundaryVicinityEventDto.java | 25 + .../esperpoc/dto/EnrichedDtiIntervalDto.java | 25 + .../dto/EsperDtiEnrichmentRequest.java | 38 + .../dto/EsperDtiEnrichmentResultDto.java | 25 + .../dto/EsperOperatingPeriodRequest.java | 1 + .../dto/EsperOperatingPeriodResultDto.java | 1 + .../dto/EsperSourceSelectionMode.java | 6 + .../esperpoc/dto/EsperSupportEventDto.java | 30 + .../EsperPocDtiEnrichmentRepository.java | 125 ++++ .../service/EsperDtiEnrichmentService.java | 649 ++++++++++++++++++ ...EsperOperatingPeriodEvaluationService.java | 34 +- .../esperpoc/api/EsperPocControllerTest.java | 59 ++ .../EsperDtiEnrichmentServiceTest.java | 320 +++++++++ ...rOperatingPeriodEvaluationServiceTest.java | 75 ++ 18 files changed, 1578 insertions(+), 10 deletions(-) create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryPositionDto.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVehicleDto.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVicinityEventDto.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/EnrichedDtiIntervalDto.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentRequest.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentResultDto.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/EsperSourceSelectionMode.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/dto/EsperSupportEventDto.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/persistence/EsperPocDtiEnrichmentRepository.java create mode 100644 src/main/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentService.java create mode 100644 src/test/java/at/procon/eventhub/esperpoc/api/EsperPocControllerTest.java create mode 100644 src/test/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentServiceTest.java diff --git a/postman/eventhub-esper-poc.postman_collection.json b/postman/eventhub-esper-poc.postman_collection.json index 0d9a95b..7c92bf6 100644 --- a/postman/eventhub-esper-poc.postman_collection.json +++ b/postman/eventhub-esper-poc.postman_collection.json @@ -64,7 +64,7 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/eventhub/esper-poc/tachograph/operating-period-evaluation?tenantKey={{tenantKey}}&driverId={{driverId}}&occurredFrom={{occurredFrom}}&occurredTo={{occurredTo}}&guardHours=24&operatingSplitIdleHours=7&significantDrivingMinutes=3&mergeGapSeconds=0&gapDetectionToleranceSeconds=0&unknownTreatmentMode=AS_BREAK_REST", + "raw": "{{baseUrl}}/api/eventhub/esper-poc/tachograph/operating-period-evaluation?tenantKey={{tenantKey}}&driverId={{driverId}}&occurredFrom={{occurredFrom}}&occurredTo={{occurredTo}}&guardHours=24&operatingSplitIdleHours=7&significantDrivingMinutes=3&mergeGapSeconds=0&gapDetectionToleranceSeconds=0&sourceSelectionMode=MIXED&unknownTreatmentMode=AS_BREAK_REST", "host": [ "{{baseUrl}}" ], @@ -112,6 +112,10 @@ "key": "gapDetectionToleranceSeconds", "value": "0" }, + { + "key": "sourceSelectionMode", + "value": "MIXED" + }, { "key": "unknownTreatmentMode", "value": "AS_BREAK_REST" @@ -119,6 +123,84 @@ ] } } + }, + { + "name": "Evaluate tachograph DTI enrichment", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/eventhub/esper-poc/tachograph/dti-enrichment?tenantKey={{tenantKey}}&driverId={{driverId}}&occurredFrom={{occurredFrom}}&occurredTo={{occurredTo}}&guardHours=24&operatingSplitIdleHours=7&significantDrivingMinutes=3&mergeGapSeconds=0&gapDetectionToleranceSeconds=0&sourceSelectionMode=MIXED&unknownTreatmentMode=AS_BREAK_REST&vehicleEvidenceLookbackHours=720&geoSearchWindowMinutes=180&vicinityWindowMinutes=180", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "esper-poc", + "tachograph", + "dti-enrichment" + ], + "query": [ + { + "key": "tenantKey", + "value": "{{tenantKey}}" + }, + { + "key": "driverId", + "value": "{{driverId}}" + }, + { + "key": "occurredFrom", + "value": "{{occurredFrom}}" + }, + { + "key": "occurredTo", + "value": "{{occurredTo}}" + }, + { + "key": "guardHours", + "value": "24" + }, + { + "key": "operatingSplitIdleHours", + "value": "7" + }, + { + "key": "significantDrivingMinutes", + "value": "3" + }, + { + "key": "mergeGapSeconds", + "value": "0" + }, + { + "key": "gapDetectionToleranceSeconds", + "value": "0" + }, + { + "key": "sourceSelectionMode", + "value": "MIXED" + }, + { + "key": "unknownTreatmentMode", + "value": "AS_BREAK_REST" + }, + { + "key": "vehicleEvidenceLookbackHours", + "value": "720" + }, + { + "key": "geoSearchWindowMinutes", + "value": "180" + }, + { + "key": "vicinityWindowMinutes", + "value": "180" + } + ] + } + } } ], "variable": [ diff --git a/src/main/java/at/procon/eventhub/esperpoc/api/EsperPocController.java b/src/main/java/at/procon/eventhub/esperpoc/api/EsperPocController.java index 5315971..e4c4881 100644 --- a/src/main/java/at/procon/eventhub/esperpoc/api/EsperPocController.java +++ b/src/main/java/at/procon/eventhub/esperpoc/api/EsperPocController.java @@ -1,13 +1,17 @@ package at.procon.eventhub.esperpoc.api; import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode; +import at.procon.eventhub.esperpoc.dto.EsperDtiEnrichmentRequest; +import at.procon.eventhub.esperpoc.dto.EsperDtiEnrichmentResultDto; import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodEngineMode; import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodRequest; import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodResultDto; import at.procon.eventhub.esperpoc.dto.EsperPocRequest; import at.procon.eventhub.esperpoc.dto.EsperPocResultDto; import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode; +import at.procon.eventhub.esperpoc.dto.EsperSourceSelectionMode; import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode; +import at.procon.eventhub.esperpoc.service.EsperDtiEnrichmentService; import at.procon.eventhub.esperpoc.service.EsperOperatingPeriodEvaluationService; import at.procon.eventhub.esperpoc.service.EsperPocDriverCardActivityService; import java.time.OffsetDateTime; @@ -25,13 +29,16 @@ public class EsperPocController { private final EsperPocDriverCardActivityService service; private final EsperOperatingPeriodEvaluationService operatingPeriodEvaluationService; + private final EsperDtiEnrichmentService dtiEnrichmentService; public EsperPocController( EsperPocDriverCardActivityService service, - EsperOperatingPeriodEvaluationService operatingPeriodEvaluationService + EsperOperatingPeriodEvaluationService operatingPeriodEvaluationService, + EsperDtiEnrichmentService dtiEnrichmentService ) { this.service = service; this.operatingPeriodEvaluationService = operatingPeriodEvaluationService; + this.dtiEnrichmentService = dtiEnrichmentService; } @GetMapping("/tachograph/driver-card-activities") @@ -77,6 +84,7 @@ public class EsperPocController { @RequestParam(required = false) Integer significantDrivingMinutes, @RequestParam(required = false) Integer mergeGapSeconds, @RequestParam(required = false) Integer gapDetectionToleranceSeconds, + @RequestParam(required = false) EsperSourceSelectionMode sourceSelectionMode, @RequestParam(required = false) EsperUnknownTreatmentMode unknownTreatmentMode, @RequestParam(required = false) EsperOperatingPeriodEngineMode engineMode ) { @@ -90,9 +98,48 @@ public class EsperPocController { significantDrivingMinutes, mergeGapSeconds, gapDetectionToleranceSeconds, + sourceSelectionMode, unknownTreatmentMode, engineMode ); return ResponseEntity.ok(operatingPeriodEvaluationService.evaluate(request)); } + + @GetMapping("/tachograph/dti-enrichment") + public ResponseEntity evaluateDtiEnrichment( + @RequestParam String tenantKey, + @RequestParam UUID driverId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime occurredFrom, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime occurredTo, + @RequestParam(defaultValue = "24") Integer guardHours, + @RequestParam(required = false) Integer operatingSplitIdleHours, + @RequestParam(required = false) Integer significantDrivingMinutes, + @RequestParam(required = false) Integer mergeGapSeconds, + @RequestParam(required = false) Integer gapDetectionToleranceSeconds, + @RequestParam(required = false) EsperSourceSelectionMode sourceSelectionMode, + @RequestParam(required = false) EsperUnknownTreatmentMode unknownTreatmentMode, + @RequestParam(required = false) EsperOperatingPeriodEngineMode engineMode, + @RequestParam(required = false) Integer vehicleEvidenceLookbackHours, + @RequestParam(required = false) Integer geoSearchWindowMinutes, + @RequestParam(required = false) Integer vicinityWindowMinutes + ) { + EsperDtiEnrichmentRequest request = new EsperDtiEnrichmentRequest( + tenantKey, + driverId, + occurredFrom, + occurredTo, + guardHours, + operatingSplitIdleHours, + significantDrivingMinutes, + mergeGapSeconds, + gapDetectionToleranceSeconds, + sourceSelectionMode, + unknownTreatmentMode, + engineMode, + vehicleEvidenceLookbackHours, + geoSearchWindowMinutes, + vicinityWindowMinutes + ); + return ResponseEntity.ok(dtiEnrichmentService.evaluate(request)); + } } diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryPositionDto.java b/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryPositionDto.java new file mode 100644 index 0000000..6c317cc --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryPositionDto.java @@ -0,0 +1,25 @@ +package at.procon.eventhub.esperpoc.dto; + +import at.procon.eventhub.dto.GeoPointDto; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record DtiBoundaryPositionDto( + OffsetDateTime evidenceAt, + GeoPointDto position, + String eventDomain, + String eventType, + String sourceKind, + String extractionCode, + UUID vehicleId, + UUID vehicleRegistrationId, + String country, + String region, + String countryFrom, + String countryTo, + String operation, + long deltaSeconds, + int confidence, + String evidenceSourceRowId +) { +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVehicleDto.java b/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVehicleDto.java new file mode 100644 index 0000000..98e9b92 --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVehicleDto.java @@ -0,0 +1,17 @@ +package at.procon.eventhub.esperpoc.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public record DtiBoundaryVehicleDto( + UUID vehicleId, + UUID vehicleRegistrationId, + String vehicleVin, + String resolutionSource, + int confidence, + OffsetDateTime evidenceStartedAt, + OffsetDateTime evidenceEndedAt, + List evidenceSourceRowIds +) { +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVicinityEventDto.java b/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVicinityEventDto.java new file mode 100644 index 0000000..da876db --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/DtiBoundaryVicinityEventDto.java @@ -0,0 +1,25 @@ +package at.procon.eventhub.esperpoc.dto; + +import at.procon.eventhub.dto.GeoPointDto; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record DtiBoundaryVicinityEventDto( + OffsetDateTime occurredAt, + String eventDomain, + String eventType, + String lifecycle, + String sourceKind, + String extractionCode, + UUID vehicleId, + UUID vehicleRegistrationId, + GeoPointDto position, + String country, + String region, + String countryFrom, + String countryTo, + String operation, + long deltaSeconds, + String sourceRowId +) { +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/EnrichedDtiIntervalDto.java b/src/main/java/at/procon/eventhub/esperpoc/dto/EnrichedDtiIntervalDto.java new file mode 100644 index 0000000..a12b046 --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/EnrichedDtiIntervalDto.java @@ -0,0 +1,25 @@ +package at.procon.eventhub.esperpoc.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public record EnrichedDtiIntervalDto( + String dtiId, + UUID driverId, + String intervalKind, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + long durationSeconds, + long operatingPeriodNo, + OffsetDateTime operatingPeriodStartedAt, + String previousDrivingSourceRowId, + String nextDrivingSourceRowId, + DtiBoundaryVehicleDto beginVehicle, + DtiBoundaryVehicleDto endVehicle, + DtiBoundaryPositionDto beginPosition, + DtiBoundaryPositionDto endPosition, + List beginVicinityEvents, + List endVicinityEvents +) { +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentRequest.java b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentRequest.java new file mode 100644 index 0000000..06581b7 --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentRequest.java @@ -0,0 +1,38 @@ +package at.procon.eventhub.esperpoc.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record EsperDtiEnrichmentRequest( + @NotBlank String tenantKey, + @NotNull UUID driverId, + @NotNull OffsetDateTime occurredFrom, + @NotNull OffsetDateTime occurredTo, + Integer guardHours, + Integer operatingSplitIdleHours, + Integer significantDrivingMinutes, + Integer mergeGapSeconds, + Integer gapDetectionToleranceSeconds, + EsperSourceSelectionMode sourceSelectionMode, + EsperUnknownTreatmentMode unknownTreatmentMode, + EsperOperatingPeriodEngineMode engineMode, + Integer vehicleEvidenceLookbackHours, + Integer geoSearchWindowMinutes, + Integer vicinityWindowMinutes +) { + public EsperDtiEnrichmentRequest { + if (occurredFrom != null && occurredTo != null && !occurredFrom.isBefore(occurredTo)) { + throw new IllegalArgumentException("occurredFrom must be before occurredTo"); + } + guardHours = guardHours == null ? 24 : Math.max(0, guardHours); + operatingSplitIdleHours = operatingSplitIdleHours == null ? null : Math.max(1, operatingSplitIdleHours); + significantDrivingMinutes = significantDrivingMinutes == null ? null : Math.max(1, significantDrivingMinutes); + mergeGapSeconds = mergeGapSeconds == null ? null : Math.max(0, mergeGapSeconds); + gapDetectionToleranceSeconds = gapDetectionToleranceSeconds == null ? null : Math.max(0, gapDetectionToleranceSeconds); + vehicleEvidenceLookbackHours = vehicleEvidenceLookbackHours == null ? 24 * 30 : Math.max(1, vehicleEvidenceLookbackHours); + geoSearchWindowMinutes = geoSearchWindowMinutes == null ? 180 : Math.max(1, geoSearchWindowMinutes); + vicinityWindowMinutes = vicinityWindowMinutes == null ? 180 : Math.max(1, vicinityWindowMinutes); + } +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentResultDto.java b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentResultDto.java new file mode 100644 index 0000000..5817a29 --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperDtiEnrichmentResultDto.java @@ -0,0 +1,25 @@ +package at.procon.eventhub.esperpoc.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public record EsperDtiEnrichmentResultDto( + String tenantKey, + UUID driverId, + OffsetDateTime requestedFrom, + OffsetDateTime requestedTo, + OffsetDateTime loadedFrom, + OffsetDateTime loadedTo, + OffsetDateTime supportFrom, + OffsetDateTime supportTo, + int pureDtiCount, + int supportEventCount, + int vehicleUsageIntervalCount, + int geoSearchWindowMinutes, + int vicinityWindowMinutes, + int vehicleEvidenceLookbackHours, + List dtiIntervals, + List notes +) { +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodRequest.java b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodRequest.java index 2c8b846..39e2b7f 100644 --- a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodRequest.java +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodRequest.java @@ -15,6 +15,7 @@ public record EsperOperatingPeriodRequest( Integer significantDrivingMinutes, Integer mergeGapSeconds, Integer gapDetectionToleranceSeconds, + EsperSourceSelectionMode sourceSelectionMode, EsperUnknownTreatmentMode unknownTreatmentMode, EsperOperatingPeriodEngineMode engineMode ) { diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodResultDto.java b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodResultDto.java index 525a312..ea496dc 100644 --- a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodResultDto.java +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperOperatingPeriodResultDto.java @@ -26,6 +26,7 @@ public record EsperOperatingPeriodResultDto( int significantDrivingMinutes, int mergeGapSeconds, int gapDetectionToleranceSeconds, + EsperSourceSelectionMode sourceSelectionMode, EsperUnknownTreatmentMode unknownTreatmentMode, EsperOperatingPeriodEngineMode engineMode, List rawEvents, diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperSourceSelectionMode.java b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperSourceSelectionMode.java new file mode 100644 index 0000000..f074d2e --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperSourceSelectionMode.java @@ -0,0 +1,6 @@ +package at.procon.eventhub.esperpoc.dto; + +public enum EsperSourceSelectionMode { + MIXED, + DRIVER_CARD_ONLY +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/dto/EsperSupportEventDto.java b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperSupportEventDto.java new file mode 100644 index 0000000..3ded863 --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/dto/EsperSupportEventDto.java @@ -0,0 +1,30 @@ +package at.procon.eventhub.esperpoc.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record EsperSupportEventDto( + UUID eventId, + OffsetDateTime occurredAt, + String sourceRowId, + String externalSourceEventId, + String sourceKind, + String extractionCode, + UUID driverId, + UUID driverCardId, + UUID vehicleId, + UUID vehicleRegistrationId, + String eventDomain, + String eventType, + String lifecycle, + String cardSlot, + BigDecimal latitude, + BigDecimal longitude, + String country, + String region, + String countryFrom, + String countryTo, + String operation +) { +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/persistence/EsperPocDtiEnrichmentRepository.java b/src/main/java/at/procon/eventhub/esperpoc/persistence/EsperPocDtiEnrichmentRepository.java new file mode 100644 index 0000000..ff31232 --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/persistence/EsperPocDtiEnrichmentRepository.java @@ -0,0 +1,125 @@ +package at.procon.eventhub.esperpoc.persistence; + +import at.procon.eventhub.esperpoc.dto.EsperSupportEventDto; +import at.procon.eventhub.esperpoc.dto.EsperSourceSelectionMode; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class EsperPocDtiEnrichmentRepository { + + private final JdbcTemplate jdbcTemplate; + + public EsperPocDtiEnrichmentRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List findDriverSupportEvents( + String tenantKey, + UUID driverId, + OffsetDateTime supportFrom, + OffsetDateTime supportTo, + int precedingDriverCardEventCount, + EsperSourceSelectionMode sourceSelectionMode + ) { + return jdbcTemplate.query( + """ + with candidate as ( + select + event.id, + event.occurred_at, + coalesce( + event.payload #>> '{raw,sourceRowId}', + regexp_replace(event.external_source_event_id, ':(START|END|INSERT|WITHDRAW)$', '') + ) as source_row_id, + event.external_source_event_id, + source.source_kind, + coalesce(pkg.extraction_code, '') as extraction_code, + event.driver_id, + event.driver_card_id, + event.vehicle_id, + event.vehicle_registration_id, + event.event_domain, + event.event_type, + event.lifecycle, + detail.attributes ->> 'cardSlot' as card_slot, + st_y(event.position::geometry) as latitude, + st_x(event.position::geometry) as longitude, + detail.attributes ->> 'country' as country, + detail.attributes ->> 'region' as region, + detail.attributes ->> 'countryFrom' as country_from, + detail.attributes ->> 'countryTo' as country_to, + detail.attributes ->> 'operation' as operation + from eventhub.event event + join eventhub.event_source source on source.id = event.event_source_id + join eventhub.data_package pkg on pkg.id = event.data_package_id + left join lateral ( + select detail.attributes + from eventhub.event_detail detail + where detail.event_occurred_at = event.occurred_at + and detail.event_id = event.id + order by detail.detail_type + limit 1 + ) detail on true + where pkg.tenant_key = ? + and source.provider_key = 'TACHOGRAPH' + and event.driver_id = ? + and (? <> 'DRIVER_CARD_ONLY' or source.source_kind = 'DRIVER_CARD') + and event.event_domain in ('DRIVER_CARD', 'POSITION', 'PLACE', 'BORDER_CROSSING', 'LOAD_UNLOAD', 'SPECIFIC_CONDITION', 'SPEEDING') + and event.occurred_at < ? + ), + in_range as ( + select * from candidate where occurred_at >= ? + ), + preceding_driver_card as ( + select * + from candidate + where occurred_at < ? + and event_domain = 'DRIVER_CARD' + order by occurred_at desc, id desc + limit ? + ) + select * + from ( + select * from in_range + union all + select * from preceding_driver_card + ) result + order by occurred_at, lifecycle, event_domain, event_type, id + """, + (rs, rowNum) -> new EsperSupportEventDto( + (UUID) rs.getObject("id"), + rs.getObject("occurred_at", OffsetDateTime.class), + rs.getString("source_row_id"), + rs.getString("external_source_event_id"), + rs.getString("source_kind"), + rs.getString("extraction_code"), + (UUID) rs.getObject("driver_id"), + (UUID) rs.getObject("driver_card_id"), + (UUID) rs.getObject("vehicle_id"), + (UUID) rs.getObject("vehicle_registration_id"), + rs.getString("event_domain"), + rs.getString("event_type"), + rs.getString("lifecycle"), + rs.getString("card_slot"), + rs.getBigDecimal("latitude"), + rs.getBigDecimal("longitude"), + rs.getString("country"), + rs.getString("region"), + rs.getString("country_from"), + rs.getString("country_to"), + rs.getString("operation") + ), + tenantKey, + driverId, + sourceSelectionMode == null ? EsperSourceSelectionMode.MIXED.name() : sourceSelectionMode.name(), + supportTo, + supportFrom, + supportFrom, + precedingDriverCardEventCount + ); + } +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentService.java b/src/main/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentService.java new file mode 100644 index 0000000..fe86593 --- /dev/null +++ b/src/main/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentService.java @@ -0,0 +1,649 @@ +package at.procon.eventhub.esperpoc.service; + +import at.procon.eventhub.dto.GeoPointDto; +import at.procon.eventhub.esperpoc.dto.DtiBoundaryPositionDto; +import at.procon.eventhub.esperpoc.dto.DtiBoundaryVehicleDto; +import at.procon.eventhub.esperpoc.dto.DtiBoundaryVicinityEventDto; +import at.procon.eventhub.esperpoc.dto.EnrichedDtiIntervalDto; +import at.procon.eventhub.esperpoc.dto.EsperDtiEnrichmentRequest; +import at.procon.eventhub.esperpoc.dto.EsperDtiEnrichmentResultDto; +import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodRequest; +import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodResultDto; +import at.procon.eventhub.esperpoc.dto.OperatingPeriodDto; +import at.procon.eventhub.esperpoc.dto.EsperSupportEventDto; +import at.procon.eventhub.esperpoc.persistence.EsperPocDtiEnrichmentRepository; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; + +@Service +public class EsperDtiEnrichmentService { + + private static final Set VEHICLE_INTERVAL_EXTRACTION_CODES = Set.of("IW_CYCLE", "CARD_VEHICLES_USED"); + private static final int PRECEDING_DRIVER_CARD_EVENT_COUNT = 50; + + private final EsperOperatingPeriodEvaluationService operatingPeriodEvaluationService; + private final EsperPocDtiEnrichmentRepository enrichmentRepository; + + public EsperDtiEnrichmentService( + EsperOperatingPeriodEvaluationService operatingPeriodEvaluationService, + EsperPocDtiEnrichmentRepository enrichmentRepository + ) { + this.operatingPeriodEvaluationService = operatingPeriodEvaluationService; + this.enrichmentRepository = enrichmentRepository; + } + + public EsperDtiEnrichmentResultDto evaluate(EsperDtiEnrichmentRequest request) { + EsperOperatingPeriodRequest operatingRequest = new EsperOperatingPeriodRequest( + request.tenantKey(), + request.driverId(), + request.occurredFrom(), + request.occurredTo(), + request.guardHours(), + request.operatingSplitIdleHours(), + request.significantDrivingMinutes(), + request.mergeGapSeconds(), + request.gapDetectionToleranceSeconds(), + request.sourceSelectionMode(), + request.unknownTreatmentMode(), + request.engineMode() + ); + EsperOperatingPeriodResultDto pureDtiResult = operatingPeriodEvaluationService.evaluate(operatingRequest); + + OffsetDateTime supportFrom = request.occurredFrom().minusHours(request.vehicleEvidenceLookbackHours()); + OffsetDateTime supportTo = request.occurredTo().plusHours(Math.max(request.guardHours(), 24)); + List rawSupportEvents = enrichmentRepository.findDriverSupportEvents( + request.tenantKey(), + request.driverId(), + supportFrom, + supportTo, + PRECEDING_DRIVER_CARD_EVENT_COUNT, + request.sourceSelectionMode() + ); + List vehicleUsageIntervals = mergeVehicleUsageIntervals(buildVehicleUsageIntervals(rawSupportEvents)); + List supportEvents = condenseSupportEvents(rawSupportEvents); + List pureDtiIntervals = extractPureDtiIntervals(pureDtiResult); + List enrichedIntervals = pureDtiIntervals.stream() + .map(interval -> enrichInterval(interval, supportEvents, vehicleUsageIntervals, request)) + .toList(); + + return new EsperDtiEnrichmentResultDto( + request.tenantKey(), + request.driverId(), + pureDtiResult.requestedFrom(), + pureDtiResult.requestedTo(), + pureDtiResult.loadedFrom(), + pureDtiResult.loadedTo(), + supportFrom, + supportTo, + pureDtiIntervals.size(), + supportEvents.size(), + vehicleUsageIntervals.size(), + request.geoSearchWindowMinutes(), + request.vicinityWindowMinutes(), + request.vehicleEvidenceLookbackHours(), + enrichedIntervals, + notes(request) + ); + } + + EnrichedDtiIntervalDto enrichInterval( + PureDtiInterval interval, + List supportEvents, + List vehicleUsageIntervals, + EsperDtiEnrichmentRequest request + ) { + DtiBoundaryVehicleDto beginVehicle = resolveBoundaryVehicle(interval.startedAt(), vehicleUsageIntervals); + DtiBoundaryVehicleDto endVehicle = resolveBoundaryVehicle(interval.endedAt(), vehicleUsageIntervals); + DtiBoundaryPositionDto beginPosition = resolveBoundaryPosition( + interval.startedAt(), + beginVehicle, + supportEvents, + request.geoSearchWindowMinutes() + ); + DtiBoundaryPositionDto endPosition = resolveBoundaryPosition( + interval.endedAt(), + endVehicle, + supportEvents, + request.geoSearchWindowMinutes() + ); + List beginVicinity = resolveBoundaryVicinityEvents( + interval.startedAt(), + supportEvents, + request.vicinityWindowMinutes() + ); + List endVicinity = resolveBoundaryVicinityEvents( + interval.endedAt(), + supportEvents, + request.vicinityWindowMinutes() + ); + return new EnrichedDtiIntervalDto( + interval.dtiId(), + interval.driverId(), + interval.intervalKind(), + interval.startedAt(), + interval.endedAt(), + interval.durationSeconds(), + interval.operatingPeriodNo(), + interval.operatingPeriodStartedAt(), + interval.previousDrivingSourceRowId(), + interval.nextDrivingSourceRowId(), + beginVehicle, + endVehicle, + beginPosition, + endPosition, + beginVicinity, + endVicinity + ); + } + + List extractPureDtiIntervals(EsperOperatingPeriodResultDto result) { + if (result == null || result.operatingPeriods() == null || result.operatingPeriods().isEmpty()) { + return List.of(); + } + List intervals = new ArrayList<>(); + for (OperatingPeriodDto operatingPeriod : result.operatingPeriods()) { + if (operatingPeriod.drivingTimeInterruptionEvaluation() == null + || operatingPeriod.drivingTimeInterruptionEvaluation().interruptionsBetweenSignificantDrivingPeriods() == null) { + continue; + } + if (operatingPeriod.drivingTimeInterruptionEvaluation().departureAt() != null + && operatingPeriod.startedAt().isBefore(operatingPeriod.drivingTimeInterruptionEvaluation().departureAt())) { + intervals.add(new PureDtiInterval( + "DTI-PRE-" + operatingPeriod.operatingPeriodNo() + "-" + operatingPeriod.startedAt().toInstant().toEpochMilli(), + result.driverId(), + "BEFORE_FIRST_SIGNIFICANT_DRIVING", + operatingPeriod.startedAt(), + operatingPeriod.drivingTimeInterruptionEvaluation().departureAt(), + Duration.between(operatingPeriod.startedAt(), operatingPeriod.drivingTimeInterruptionEvaluation().departureAt()).getSeconds(), + operatingPeriod.operatingPeriodNo(), + operatingPeriod.startedAt(), + null, + operatingPeriod.drivingTimeInterruptionEvaluation().firstSignificantDrivingPeriod() == null + ? null + : operatingPeriod.drivingTimeInterruptionEvaluation().firstSignificantDrivingPeriod().sourceRowId() + )); + } + operatingPeriod.drivingTimeInterruptionEvaluation().interruptionsBetweenSignificantDrivingPeriods() + .forEach(interruption -> intervals.add(new PureDtiInterval( + "DTI-" + operatingPeriod.operatingPeriodNo() + "-" + interruption.from().toInstant().toEpochMilli(), + result.driverId(), + "BETWEEN_SIGNIFICANT_DRIVING", + interruption.from(), + interruption.to(), + interruption.durationSeconds(), + operatingPeriod.operatingPeriodNo(), + operatingPeriod.startedAt(), + interruption.previousDrivingSourceRowId(), + interruption.nextDrivingSourceRowId() + ))); + if (operatingPeriod.drivingTimeInterruptionEvaluation().arrivalAt() != null + && operatingPeriod.drivingTimeInterruptionEvaluation().arrivalAt().isBefore(operatingPeriod.endedAt())) { + intervals.add(new PureDtiInterval( + "DTI-POST-" + operatingPeriod.operatingPeriodNo() + "-" + operatingPeriod.drivingTimeInterruptionEvaluation().arrivalAt().toInstant().toEpochMilli(), + result.driverId(), + "AFTER_LAST_SIGNIFICANT_DRIVING", + operatingPeriod.drivingTimeInterruptionEvaluation().arrivalAt(), + operatingPeriod.endedAt(), + Duration.between(operatingPeriod.drivingTimeInterruptionEvaluation().arrivalAt(), operatingPeriod.endedAt()).getSeconds(), + operatingPeriod.operatingPeriodNo(), + operatingPeriod.startedAt(), + operatingPeriod.drivingTimeInterruptionEvaluation().lastSignificantDrivingPeriod() == null + ? null + : operatingPeriod.drivingTimeInterruptionEvaluation().lastSignificantDrivingPeriod().sourceRowId(), + null + )); + } + } + return intervals; + } + + List buildVehicleUsageIntervals(List supportEvents) { + record VehicleIntervalSeed( + EsperSupportEventDto insertEvent, + EsperSupportEventDto withdrawEvent + ) { + } + java.util.Map bySourceRow = new java.util.LinkedHashMap<>(); + supportEvents.stream() + .filter(event -> "DRIVER_CARD".equals(event.eventDomain())) + .filter(event -> VEHICLE_INTERVAL_EXTRACTION_CODES.contains(event.extractionCode())) + .filter(event -> "CARD_INSERTED".equals(event.eventType()) || "CARD_WITHDRAWN".equals(event.eventType())) + .filter(event -> event.sourceRowId() != null) + .forEach(event -> { + String key = event.extractionCode() + ":" + event.sourceRowId(); + VehicleIntervalSeed current = bySourceRow.get(key); + EsperSupportEventDto insert = current == null ? null : current.insertEvent(); + EsperSupportEventDto withdraw = current == null ? null : current.withdrawEvent(); + if ("CARD_INSERTED".equals(event.eventType())) { + if (insert == null || event.occurredAt().isBefore(insert.occurredAt())) { + insert = event; + } + } else if (withdraw == null || event.occurredAt().isAfter(withdraw.occurredAt())) { + withdraw = event; + } + bySourceRow.put(key, new VehicleIntervalSeed(insert, withdraw)); + }); + + List result = new ArrayList<>(); + for (VehicleIntervalSeed seed : bySourceRow.values()) { + EsperSupportEventDto anchor = seed.insertEvent() != null ? seed.insertEvent() : seed.withdrawEvent(); + if (anchor == null || anchor.vehicleId() == null) { + continue; + } + OffsetDateTime startedAt = seed.insertEvent() == null ? null : seed.insertEvent().occurredAt(); + OffsetDateTime endedAt = seed.withdrawEvent() == null ? null : seed.withdrawEvent().occurredAt(); + if (startedAt == null && endedAt == null) { + continue; + } + result.add(new ResolvedVehicleUsageInterval( + anchor.driverId(), + anchor.driverCardId(), + anchor.vehicleId(), + anchor.vehicleRegistrationId(), + anchor.extractionCode(), + startedAt, + endedAt, + sourcePriority(anchor.extractionCode()), + sourceConfidence(anchor.extractionCode()), + collectSourceRowIds(seed.insertEvent(), seed.withdrawEvent()) + )); + } + return result.stream() + .sorted(Comparator.comparing(ResolvedVehicleUsageInterval::startedAt, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(ResolvedVehicleUsageInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + + List mergeVehicleUsageIntervals(List intervals) { + if (intervals.isEmpty()) { + return List.of(); + } + List sorted = intervals.stream() + .sorted(Comparator.comparing(ResolvedVehicleUsageInterval::startedAt, Comparator.nullsFirst(Comparator.naturalOrder())) + .thenComparing(ResolvedVehicleUsageInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + List merged = new ArrayList<>(); + ResolvedVehicleUsageInterval current = null; + for (ResolvedVehicleUsageInterval next : sorted) { + if (current == null) { + current = next; + continue; + } + if (canMerge(current, next)) { + current = current.merge(next); + } else { + merged.add(current); + current = next; + } + } + if (current != null) { + merged.add(current); + } + return merged; + } + + List condenseSupportEvents(List supportEvents) { + if (supportEvents == null || supportEvents.isEmpty()) { + return List.of(); + } + record VehicleIntervalSeed( + EsperSupportEventDto insertEvent, + EsperSupportEventDto withdrawEvent + ) { + } + java.util.Map seedsByKey = new java.util.LinkedHashMap<>(); + supportEvents.stream() + .filter(event -> "DRIVER_CARD".equals(event.eventDomain())) + .filter(event -> "CARD_VEHICLES_USED".equals(event.extractionCode())) + .filter(event -> "CARD_INSERTED".equals(event.eventType()) || "CARD_WITHDRAWN".equals(event.eventType())) + .filter(event -> event.sourceRowId() != null) + .forEach(event -> { + String key = event.extractionCode() + ":" + event.sourceRowId(); + VehicleIntervalSeed current = seedsByKey.get(key); + EsperSupportEventDto insert = current == null ? null : current.insertEvent(); + EsperSupportEventDto withdraw = current == null ? null : current.withdrawEvent(); + if ("CARD_INSERTED".equals(event.eventType())) { + if (insert == null || event.occurredAt().isBefore(insert.occurredAt())) { + insert = event; + } + } else if (withdraw == null || event.occurredAt().isAfter(withdraw.occurredAt())) { + withdraw = event; + } + seedsByKey.put(key, new VehicleIntervalSeed(insert, withdraw)); + }); + + List mergedCardVehicleIntervals = mergeVehicleUsageIntervals( + buildVehicleUsageIntervals(supportEvents).stream() + .filter(interval -> "CARD_VEHICLES_USED".equals(interval.authoritativeSource())) + .toList() + ); + Set keptDriverCardEventIds = new LinkedHashSet<>(); + for (ResolvedVehicleUsageInterval interval : mergedCardVehicleIntervals) { + EsperSupportEventDto earliestInsert = null; + EsperSupportEventDto latestWithdraw = null; + for (String sourceRowId : interval.sourceRowIds()) { + VehicleIntervalSeed seed = seedsByKey.get("CARD_VEHICLES_USED:" + sourceRowId); + if (seed == null) { + continue; + } + if (seed.insertEvent() != null + && (earliestInsert == null || seed.insertEvent().occurredAt().isBefore(earliestInsert.occurredAt()))) { + earliestInsert = seed.insertEvent(); + } + if (seed.withdrawEvent() != null + && (latestWithdraw == null || seed.withdrawEvent().occurredAt().isAfter(latestWithdraw.occurredAt()))) { + latestWithdraw = seed.withdrawEvent(); + } + } + if (earliestInsert != null) { + keptDriverCardEventIds.add(earliestInsert.eventId()); + } + if (latestWithdraw != null) { + keptDriverCardEventIds.add(latestWithdraw.eventId()); + } + } + + return supportEvents.stream() + .filter(event -> !("DRIVER_CARD".equals(event.eventDomain()) + && "CARD_VEHICLES_USED".equals(event.extractionCode()) + && !keptDriverCardEventIds.contains(event.eventId()))) + .sorted(Comparator.comparing(EsperSupportEventDto::occurredAt) + .thenComparing(EsperSupportEventDto::lifecycle, Comparator.nullsLast(String::compareTo)) + .thenComparing(EsperSupportEventDto::eventDomain, Comparator.nullsLast(String::compareTo)) + .thenComparing(EsperSupportEventDto::eventType, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + DtiBoundaryVehicleDto resolveBoundaryVehicle( + OffsetDateTime boundary, + List intervals + ) { + return intervals.stream() + .map(interval -> intervalCandidate(boundary, interval)) + .filter(Objects::nonNull) + .sorted(Comparator.comparing(VehicleBoundaryCandidate::score).reversed() + .thenComparing(VehicleBoundaryCandidate::deltaSeconds) + .thenComparing(candidate -> candidate.interval.startedAt(), Comparator.nullsLast(Comparator.reverseOrder()))) + .map(candidate -> new DtiBoundaryVehicleDto( + candidate.interval.vehicleId(), + candidate.interval.vehicleRegistrationId(), + null, + candidate.interval.authoritativeSource(), + candidate.interval.confidence(), + candidate.interval.startedAt(), + candidate.interval.endedAt(), + candidate.interval.sourceRowIds() + )) + .findFirst() + .orElse(null); + } + + DtiBoundaryPositionDto resolveBoundaryPosition( + OffsetDateTime boundary, + DtiBoundaryVehicleDto boundaryVehicle, + List supportEvents, + int searchWindowMinutes + ) { + long maxDeltaSeconds = Duration.ofMinutes(searchWindowMinutes).getSeconds(); + return supportEvents.stream() + .filter(this::hasPosition) + .filter(event -> Math.abs(Duration.between(boundary, event.occurredAt()).getSeconds()) <= maxDeltaSeconds) + .sorted(Comparator + .comparing((EsperSupportEventDto event) -> vehicleMatch(event, boundaryVehicle)).reversed() + .thenComparing(event -> Math.abs(Duration.between(boundary, event.occurredAt()).getSeconds())) + .thenComparing((EsperSupportEventDto event) -> geoDomainPriority(event.eventDomain()), Comparator.reverseOrder()) + .thenComparing(EsperSupportEventDto::occurredAt, Comparator.reverseOrder())) + .map(event -> toBoundaryPosition(boundary, boundaryVehicle, event)) + .findFirst() + .orElse(null); + } + + List resolveBoundaryVicinityEvents( + OffsetDateTime boundary, + List supportEvents, + int vicinityWindowMinutes + ) { + long maxDeltaSeconds = Duration.ofMinutes(vicinityWindowMinutes).getSeconds(); + return supportEvents.stream() + .filter(event -> Math.abs(Duration.between(boundary, event.occurredAt()).getSeconds()) <= maxDeltaSeconds) + .sorted(Comparator + .comparing((EsperSupportEventDto event) -> Math.abs(Duration.between(boundary, event.occurredAt()).getSeconds())) + .thenComparing((EsperSupportEventDto event) -> supportEventPriority(event.eventDomain()), Comparator.reverseOrder()) + .thenComparing(EsperSupportEventDto::occurredAt, Comparator.reverseOrder())) + .limit(20) + .map(event -> new DtiBoundaryVicinityEventDto( + event.occurredAt(), + event.eventDomain(), + event.eventType(), + event.lifecycle(), + event.sourceKind(), + event.extractionCode(), + event.vehicleId(), + event.vehicleRegistrationId(), + toGeoPoint(event), + event.country(), + event.region(), + event.countryFrom(), + event.countryTo(), + event.operation(), + Math.abs(Duration.between(boundary, event.occurredAt()).getSeconds()), + event.sourceRowId() + )) + .toList(); + } + + private VehicleBoundaryCandidate intervalCandidate(OffsetDateTime boundary, ResolvedVehicleUsageInterval interval) { + if (interval.startedAt() == null) { + return null; + } + boolean covers = !interval.startedAt().isAfter(boundary) && (interval.endedAt() == null || interval.endedAt().isAfter(boundary) || interval.endedAt().isEqual(boundary)); + long deltaSeconds; + int baseScore; + if (covers) { + deltaSeconds = 0; + baseScore = interval.priority() + 200; + } else if (interval.endedAt() != null && interval.endedAt().isBefore(boundary)) { + deltaSeconds = Duration.between(interval.endedAt(), boundary).getSeconds(); + baseScore = interval.priority(); + } else { + deltaSeconds = Math.abs(Duration.between(boundary, interval.startedAt()).getSeconds()); + baseScore = interval.priority() - 25; + } + if (deltaSeconds > Duration.ofHours(24).getSeconds()) { + return null; + } + return new VehicleBoundaryCandidate(interval, baseScore, deltaSeconds); + } + + private boolean canMerge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) { + if (!Objects.equals(left.driverId(), right.driverId())) { + return false; + } + if (!Objects.equals(left.vehicleId(), right.vehicleId())) { + return false; + } + boolean midnightCardVehiclesUsedContinuation = + "CARD_VEHICLES_USED".equals(left.authoritativeSource()) + && "CARD_VEHICLES_USED".equals(right.authoritativeSource()) + && left.endedAt() != null + && right.startedAt() != null + && Duration.between(left.endedAt(), right.startedAt()).getSeconds() == 1; + if (!Objects.equals(left.vehicleRegistrationId(), right.vehicleRegistrationId()) + && !midnightCardVehiclesUsedContinuation) { + return false; + } + if (left.endedAt() == null || right.startedAt() == null) { + return true; + } + return !right.startedAt().isAfter(left.endedAt().plusSeconds(60)); + } + + private boolean hasPosition(EsperSupportEventDto event) { + return event.latitude() != null && event.longitude() != null; + } + + private boolean vehicleMatch(EsperSupportEventDto event, DtiBoundaryVehicleDto boundaryVehicle) { + if (boundaryVehicle == null) { + return false; + } + return Objects.equals(event.vehicleId(), boundaryVehicle.vehicleId()) + || (boundaryVehicle.vehicleRegistrationId() != null + && Objects.equals(event.vehicleRegistrationId(), boundaryVehicle.vehicleRegistrationId())); + } + + private DtiBoundaryPositionDto toBoundaryPosition( + OffsetDateTime boundary, + DtiBoundaryVehicleDto boundaryVehicle, + EsperSupportEventDto event + ) { + int confidence = geoDomainPriority(event.eventDomain()) + (vehicleMatch(event, boundaryVehicle) ? 100 : 0); + return new DtiBoundaryPositionDto( + event.occurredAt(), + toGeoPoint(event), + event.eventDomain(), + event.eventType(), + event.sourceKind(), + event.extractionCode(), + event.vehicleId(), + event.vehicleRegistrationId(), + event.country(), + event.region(), + event.countryFrom(), + event.countryTo(), + event.operation(), + Math.abs(Duration.between(boundary, event.occurredAt()).getSeconds()), + confidence, + event.sourceRowId() + ); + } + + private GeoPointDto toGeoPoint(EsperSupportEventDto event) { + if (!hasPosition(event)) { + return null; + } + return new GeoPointDto(event.latitude(), event.longitude()); + } + + private int geoDomainPriority(String eventDomain) { + return switch (eventDomain) { + case "POSITION" -> 400; + case "PLACE" -> 350; + case "BORDER_CROSSING" -> 300; + case "LOAD_UNLOAD" -> 250; + case "SPECIFIC_CONDITION" -> 200; + default -> 100; + }; + } + + private int supportEventPriority(String eventDomain) { + return switch (eventDomain) { + case "DRIVER_CARD" -> 500; + case "POSITION" -> 450; + case "PLACE" -> 400; + case "BORDER_CROSSING" -> 350; + case "LOAD_UNLOAD" -> 300; + case "SPECIFIC_CONDITION" -> 250; + case "SPEEDING" -> 200; + default -> 100; + }; + } + + private int sourcePriority(String extractionCode) { + return "IW_CYCLE".equals(extractionCode) ? 1000 : "CARD_VEHICLES_USED".equals(extractionCode) ? 900 : 100; + } + + private int sourceConfidence(String extractionCode) { + return "IW_CYCLE".equals(extractionCode) ? 100 : "CARD_VEHICLES_USED".equals(extractionCode) ? 90 : 50; + } + + private List collectSourceRowIds(EsperSupportEventDto first, EsperSupportEventDto second) { + Set ids = new LinkedHashSet<>(); + if (first != null && first.sourceRowId() != null) { + ids.add(first.sourceRowId()); + } + if (second != null && second.sourceRowId() != null) { + ids.add(second.sourceRowId()); + } + return List.copyOf(ids); + } + + private List notes(EsperDtiEnrichmentRequest request) { + return List.of( + "Pure DTI intervals come from operating-period driving interruption evaluation, including before-first and after-last significant driving intervals.", + "Vehicle evidence prefers IW_CYCLE over CARD_VEHICLES_USED when both describe the same time span.", + "Boundary geo selection prefers POSITION, then PLACE, then BORDER_CROSSING and LOAD_UNLOAD.", + "Vicinity events include non-activity tachograph events around each DTI boundary.", + "Vehicle lookback window is " + request.vehicleEvidenceLookbackHours() + " hours." + ); + } + + record ResolvedVehicleUsageInterval( + UUID driverId, + UUID driverCardId, + UUID vehicleId, + UUID vehicleRegistrationId, + String authoritativeSource, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + int priority, + int confidence, + List sourceRowIds + ) { + ResolvedVehicleUsageInterval merge(ResolvedVehicleUsageInterval other) { + OffsetDateTime mergedStart = startedAt == null ? other.startedAt : other.startedAt == null ? startedAt + : startedAt.isBefore(other.startedAt) ? startedAt : other.startedAt; + OffsetDateTime mergedEnd; + if (endedAt == null || other.endedAt == null) { + mergedEnd = null; + } else { + mergedEnd = endedAt.isAfter(other.endedAt) ? endedAt : other.endedAt; + } + String source = priority >= other.priority ? authoritativeSource : other.authoritativeSource; + int mergedPriority = Math.max(priority, other.priority); + int mergedConfidence = Math.max(confidence, other.confidence); + LinkedHashSet mergedIds = new LinkedHashSet<>(sourceRowIds); + mergedIds.addAll(other.sourceRowIds); + return new ResolvedVehicleUsageInterval( + driverId, + driverCardId != null ? driverCardId : other.driverCardId, + vehicleId, + vehicleRegistrationId != null ? vehicleRegistrationId : other.vehicleRegistrationId, + source, + mergedStart, + mergedEnd, + mergedPriority, + mergedConfidence, + List.copyOf(mergedIds) + ); + } + } + + record VehicleBoundaryCandidate( + ResolvedVehicleUsageInterval interval, + int score, + long deltaSeconds + ) { + } + + record PureDtiInterval( + String dtiId, + UUID driverId, + String intervalKind, + OffsetDateTime startedAt, + OffsetDateTime endedAt, + long durationSeconds, + long operatingPeriodNo, + OffsetDateTime operatingPeriodStartedAt, + String previousDrivingSourceRowId, + String nextDrivingSourceRowId + ) { + } +} diff --git a/src/main/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationService.java b/src/main/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationService.java index 51b6718..fb3d15b 100644 --- a/src/main/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationService.java +++ b/src/main/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationService.java @@ -6,6 +6,7 @@ import at.procon.eventhub.esperpoc.dto.DrivingInterruptionDto; import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodEngineMode; import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodRequest; import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodResultDto; +import at.procon.eventhub.esperpoc.dto.EsperSourceSelectionMode; import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode; import at.procon.eventhub.esperpoc.dto.NonDrivingIntervalDto; import at.procon.eventhub.esperpoc.dto.OperatingPeriodActivityIntervalDto; @@ -68,6 +69,7 @@ public class EsperOperatingPeriodEvaluationService { Duration significantDrivingThreshold = Duration.ofMinutes(resolveSignificantDrivingMinutes(request)); Duration mergeGapTolerance = Duration.ofSeconds(resolveMergeGapSeconds(request)); Duration gapDetectionTolerance = Duration.ofSeconds(resolveGapDetectionToleranceSeconds(request)); + EsperSourceSelectionMode sourceSelectionMode = resolveSourceSelectionMode(request); EsperUnknownTreatmentMode unknownTreatmentMode = resolveUnknownTreatmentMode(request); EsperOperatingPeriodEngineMode engineMode = resolveEngineMode(request); @@ -82,9 +84,14 @@ public class EsperOperatingPeriodEvaluationService { List driverCardRawEvents = rawEvents.stream() .filter(event -> "DRIVER_CARD".equals(event.sourceKind())) .toList(); - List vehicleUnitRawEvents = rawEvents.stream() + List vehicleUnitRawEvents = sourceSelectionMode == EsperSourceSelectionMode.DRIVER_CARD_ONLY + ? List.of() + : rawEvents.stream() .filter(event -> "VEHICLE_UNIT".equals(event.sourceKind())) .toList(); + List selectedRawEvents = sourceSelectionMode == EsperSourceSelectionMode.DRIVER_CARD_ONLY + ? driverCardRawEvents + : rawEvents; long cardIntervalsStartedNanos = System.nanoTime(); List driverCardRawIntervals = activityEngine.buildIntervals(driverCardRawEvents); @@ -94,7 +101,9 @@ public class EsperOperatingPeriodEvaluationService { long vuIntervalsElapsedMs = elapsedMillis(vuIntervalsStartedNanos); long vuGapFillStartedNanos = System.nanoTime(); - List resolvedKnownLoadedIntervals = resolveVuFillGaps(driverCardRawIntervals, vehicleUnitRawIntervals); + List resolvedKnownLoadedIntervals = sourceSelectionMode == EsperSourceSelectionMode.DRIVER_CARD_ONLY + ? driverCardRawIntervals + : resolveVuFillGaps(driverCardRawIntervals, vehicleUnitRawIntervals); long vuGapFillElapsedMs = elapsedMillis(vuGapFillStartedNanos); long unknownGapStartedNanos = System.nanoTime(); @@ -153,16 +162,17 @@ public class EsperOperatingPeriodEvaluationService { ); long totalElapsedMs = elapsedMillis(startedNanos); - log.info("Esper operating-period evaluation tenant={} driverId={} requestedFrom={} requestedTo={} loadedFrom={} loadedTo={} unknownMode={} engineMode={} rawEvents={} cardRawEvents={} vuRawEvents={} cardIntervals={} vuIntervals={} resolvedKnownIntervals={} evaluationIntervals={} periodizedIntervals={} mergedIntervals={} nonDrivingIntervals={} operatingPeriods={} timingsMs={{dbRetrieve={}, cardIntervalEsper={}, vuIntervalEsper={}, vuGapFill={}, synthUnknown={}, periodizeEsper={}, merge={}, nonDriving={}, total={}}}", + log.info("Esper operating-period evaluation tenant={} driverId={} requestedFrom={} requestedTo={} loadedFrom={} loadedTo={} sourceSelectionMode={} unknownMode={} engineMode={} rawEvents={} cardRawEvents={} vuRawEvents={} cardIntervals={} vuIntervals={} resolvedKnownIntervals={} evaluationIntervals={} periodizedIntervals={} mergedIntervals={} nonDrivingIntervals={} operatingPeriods={} timingsMs={{dbRetrieve={}, cardIntervalEsper={}, vuIntervalEsper={}, vuGapFill={}, synthUnknown={}, periodizeEsper={}, merge={}, nonDriving={}, total={}}}", request.tenantKey(), request.driverId(), requestedFrom, requestedTo, loadedFrom, loadedTo, + sourceSelectionMode, unknownTreatmentMode, engineMode, - rawEvents.size(), + selectedRawEvents.size(), driverCardRawEvents.size(), vehicleUnitRawEvents.size(), driverCardRawIntervals.size(), @@ -190,7 +200,7 @@ public class EsperOperatingPeriodEvaluationService { requestedTo, loadedFrom, loadedTo, - rawEvents.size(), + selectedRawEvents.size(), driverCardRawEvents.size(), vehicleUnitRawEvents.size(), driverCardRawIntervals.size(), @@ -205,9 +215,10 @@ public class EsperOperatingPeriodEvaluationService { resolveSignificantDrivingMinutes(request), resolveMergeGapSeconds(request), resolveGapDetectionToleranceSeconds(request), + sourceSelectionMode, unknownTreatmentMode, engineMode, - rawEvents, + selectedRawEvents, resolvedKnownLoadedIntervals, evaluationLoadedIntervals, periodizedIntervals, @@ -219,7 +230,8 @@ public class EsperOperatingPeriodEvaluationService { unknownTreatmentMode, resolveOperatingSplitIdleHours(request), resolveSignificantDrivingMinutes(request), - resolveGapDetectionToleranceSeconds(request) + resolveGapDetectionToleranceSeconds(request), + sourceSelectionMode ) ); } @@ -723,15 +735,21 @@ public class EsperOperatingPeriodEvaluationService { : properties.getEsperPoc().getOperatingPeriodEvaluation().getEngineMode(); } + private EsperSourceSelectionMode resolveSourceSelectionMode(EsperOperatingPeriodRequest request) { + return request.sourceSelectionMode() == null ? EsperSourceSelectionMode.MIXED : request.sourceSelectionMode(); + } + private List notes( EsperOperatingPeriodEngineMode engineMode, EsperUnknownTreatmentMode unknownTreatmentMode, int operatingSplitIdleHours, int significantDrivingMinutes, - int gapDetectionToleranceSeconds + int gapDetectionToleranceSeconds, + EsperSourceSelectionMode sourceSelectionMode ) { return List.of( "This endpoint runs in parallel to the existing working-shift PoC and does not change its semantics.", + "Source selection mode is " + sourceSelectionMode + ".", "BREAK_REST events are ignored for activity evaluation but still prevent synthetic UNKNOWN intervals from being created over covered rest spans.", "Synthetic UNKNOWN intervals are created only for uncovered gaps between non-rest activities.", "UNKNOWN treatment mode is " + unknownTreatmentMode + ".", diff --git a/src/test/java/at/procon/eventhub/esperpoc/api/EsperPocControllerTest.java b/src/test/java/at/procon/eventhub/esperpoc/api/EsperPocControllerTest.java new file mode 100644 index 0000000..0a591c6 --- /dev/null +++ b/src/test/java/at/procon/eventhub/esperpoc/api/EsperPocControllerTest.java @@ -0,0 +1,59 @@ +package at.procon.eventhub.esperpoc.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import at.procon.eventhub.esperpoc.dto.EsperDtiEnrichmentResultDto; +import at.procon.eventhub.esperpoc.service.EsperDtiEnrichmentService; +import at.procon.eventhub.esperpoc.service.EsperOperatingPeriodEvaluationService; +import at.procon.eventhub.esperpoc.service.EsperPocDriverCardActivityService; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class EsperPocControllerTest { + + @Test + void exposesDtiEnrichmentEndpoint() throws Exception { + EsperPocDriverCardActivityService activityService = org.mockito.Mockito.mock(EsperPocDriverCardActivityService.class); + EsperOperatingPeriodEvaluationService operatingService = org.mockito.Mockito.mock(EsperOperatingPeriodEvaluationService.class); + EsperDtiEnrichmentService enrichmentService = org.mockito.Mockito.mock(EsperDtiEnrichmentService.class); + EsperPocController controller = new EsperPocController(activityService, operatingService, enrichmentService); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + when(enrichmentService.evaluate(any())).thenReturn(new EsperDtiEnrichmentResultDto( + "default", + UUID.fromString("00000000-0000-0000-0000-000000000123"), + OffsetDateTime.parse("2026-04-01T00:00:00Z"), + OffsetDateTime.parse("2026-04-02T00:00:00Z"), + OffsetDateTime.parse("2026-03-31T00:00:00Z"), + OffsetDateTime.parse("2026-04-03T00:00:00Z"), + OffsetDateTime.parse("2026-03-01T00:00:00Z"), + OffsetDateTime.parse("2026-04-03T00:00:00Z"), + 1, + 2, + 1, + 180, + 180, + 720, + List.of(), + List.of("note") + )); + + mockMvc.perform(get("/api/eventhub/esper-poc/tachograph/dti-enrichment") + .param("tenantKey", "default") + .param("driverId", "00000000-0000-0000-0000-000000000123") + .param("occurredFrom", "2026-04-01T00:00:00Z") + .param("occurredTo", "2026-04-02T00:00:00Z")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tenantKey").value("default")) + .andExpect(jsonPath("$.pureDtiCount").value(1)) + .andExpect(jsonPath("$.vehicleUsageIntervalCount").value(1)); + } +} diff --git a/src/test/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentServiceTest.java b/src/test/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentServiceTest.java new file mode 100644 index 0000000..88c2499 --- /dev/null +++ b/src/test/java/at/procon/eventhub/esperpoc/service/EsperDtiEnrichmentServiceTest.java @@ -0,0 +1,320 @@ +package at.procon.eventhub.esperpoc.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.esperpoc.dto.DtiBoundaryPositionDto; +import at.procon.eventhub.esperpoc.dto.DtiBoundaryVehicleDto; +import at.procon.eventhub.esperpoc.dto.DtiBoundaryVicinityEventDto; +import at.procon.eventhub.esperpoc.dto.DrivingInterruptionDto; +import at.procon.eventhub.esperpoc.dto.EnrichedDtiIntervalDto; +import at.procon.eventhub.esperpoc.dto.EsperDtiEnrichmentRequest; +import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodEngineMode; +import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodResultDto; +import at.procon.eventhub.esperpoc.dto.EsperSourceSelectionMode; +import at.procon.eventhub.esperpoc.dto.EsperSupportEventDto; +import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode; +import at.procon.eventhub.esperpoc.dto.OperatingPeriodDto; +import at.procon.eventhub.esperpoc.dto.ShiftDrivingEvaluationDto; +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class EsperDtiEnrichmentServiceTest { + + private final EsperDtiEnrichmentService service = new EsperDtiEnrichmentService(null, null); + + @Test + void mergesVehicleIntervalsAndPrefersIwCycleAsAuthoritativeSource() { + UUID driverId = UUID.randomUUID(); + UUID vehicleId = UUID.randomUUID(); + UUID registrationId = UUID.randomUUID(); + + List merged = service.mergeVehicleUsageIntervals( + service.buildVehicleUsageIntervals(List.of( + supportEvent(driverId, vehicleId, registrationId, "DRIVER_CARD", "CARD_INSERTED", "INSERT", "CARD_VEHICLES_USED", "cv1", "2026-04-01T08:00:00Z", null, null), + supportEvent(driverId, vehicleId, registrationId, "DRIVER_CARD", "CARD_WITHDRAWN", "WITHDRAW", "CARD_VEHICLES_USED", "cv1", "2026-04-01T12:00:00Z", null, null), + supportEvent(driverId, vehicleId, registrationId, "DRIVER_CARD", "CARD_INSERTED", "INSERT", "IW_CYCLE", "iw1", "2026-04-01T08:05:00Z", null, null), + supportEvent(driverId, vehicleId, registrationId, "DRIVER_CARD", "CARD_WITHDRAWN", "WITHDRAW", "IW_CYCLE", "iw1", "2026-04-01T11:55:00Z", null, null) + )) + ); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T08:00:00Z")); + assertThat(merged.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T12:00:00Z")); + assertThat(merged.get(0).authoritativeSource()).isEqualTo("IW_CYCLE"); + assertThat(merged.get(0).sourceRowIds()).containsExactly("cv1", "iw1"); + } + + @Test + void mergesCardVehiclesUsedAcrossMidnightBoundaryWhenCardRemainsInVehicle() { + UUID driverId = UUID.randomUUID(); + UUID vehicleId = UUID.randomUUID(); + UUID firstRegistrationId = UUID.randomUUID(); + UUID secondRegistrationId = UUID.randomUUID(); + + List merged = service.mergeVehicleUsageIntervals( + service.buildVehicleUsageIntervals(List.of( + supportEvent(driverId, vehicleId, firstRegistrationId, "DRIVER_CARD", "CARD_INSERTED", "INSERT", "CARD_VEHICLES_USED", "cv1", "2026-04-01T08:00:00Z", null, null), + supportEvent(driverId, vehicleId, firstRegistrationId, "DRIVER_CARD", "CARD_WITHDRAWN", "WITHDRAW", "CARD_VEHICLES_USED", "cv1", "2026-04-01T23:59:59Z", null, null), + supportEvent(driverId, vehicleId, secondRegistrationId, "DRIVER_CARD", "CARD_INSERTED", "INSERT", "CARD_VEHICLES_USED", "cv2", "2026-04-02T00:00:00Z", null, null), + supportEvent(driverId, vehicleId, secondRegistrationId, "DRIVER_CARD", "CARD_WITHDRAWN", "WITHDRAW", "CARD_VEHICLES_USED", "cv2", "2026-04-02T12:00:00Z", null, null) + )) + ); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).authoritativeSource()).isEqualTo("CARD_VEHICLES_USED"); + assertThat(merged.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T08:00:00Z")); + assertThat(merged.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T12:00:00Z")); + assertThat(merged.get(0).sourceRowIds()).containsExactly("cv1", "cv2"); + } + + @Test + void condensesConsecutiveCardVehiclesUsedSupportEventsToMergedBoundaryEvents() { + UUID driverId = UUID.randomUUID(); + UUID vehicleId = UUID.randomUUID(); + UUID firstRegistrationId = UUID.randomUUID(); + UUID secondRegistrationId = UUID.randomUUID(); + + List condensed = service.condenseSupportEvents(List.of( + supportEvent(driverId, vehicleId, firstRegistrationId, "DRIVER_CARD", "CARD_INSERTED", "INSERT", "CARD_VEHICLES_USED", "cv1", "2026-04-01T08:00:00Z", null, null), + supportEvent(driverId, vehicleId, firstRegistrationId, "DRIVER_CARD", "CARD_WITHDRAWN", "WITHDRAW", "CARD_VEHICLES_USED", "cv1", "2026-04-01T23:59:59Z", null, null), + supportEvent(driverId, vehicleId, secondRegistrationId, "DRIVER_CARD", "CARD_INSERTED", "INSERT", "CARD_VEHICLES_USED", "cv2", "2026-04-02T00:00:00Z", null, null), + supportEvent(driverId, vehicleId, secondRegistrationId, "DRIVER_CARD", "CARD_WITHDRAWN", "WITHDRAW", "CARD_VEHICLES_USED", "cv2", "2026-04-02T12:00:00Z", null, null), + supportEvent(driverId, vehicleId, secondRegistrationId, "POSITION", "POSITION_RECORDED", "SNAPSHOT", "CARD_POSITION", "pos1", "2026-04-02T00:05:00Z", "48.2082", "16.3738") + )); + + assertThat(condensed).hasSize(3); + assertThat(condensed) + .extracting(EsperSupportEventDto::extractionCode, EsperSupportEventDto::eventType, EsperSupportEventDto::sourceRowId) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("CARD_VEHICLES_USED", "CARD_INSERTED", "cv1"), + org.assertj.core.groups.Tuple.tuple("CARD_POSITION", "POSITION_RECORDED", "pos1"), + org.assertj.core.groups.Tuple.tuple("CARD_VEHICLES_USED", "CARD_WITHDRAWN", "cv2") + ); + } + + @Test + void resolvesBoundaryVehicleGeoAndVicinityFromSupportEvents() { + UUID driverId = UUID.randomUUID(); + UUID vehicleId = UUID.randomUUID(); + UUID registrationId = UUID.randomUUID(); + OffsetDateTime start = OffsetDateTime.parse("2026-04-01T09:00:00Z"); + OffsetDateTime end = OffsetDateTime.parse("2026-04-01T10:00:00Z"); + + List supportEvents = List.of( + supportEvent(driverId, vehicleId, registrationId, "DRIVER_CARD", "CARD_INSERTED", "INSERT", "IW_CYCLE", "iw1", "2026-04-01T08:00:00Z", null, null), + supportEvent(driverId, vehicleId, registrationId, "DRIVER_CARD", "CARD_WITHDRAWN", "WITHDRAW", "IW_CYCLE", "iw1", "2026-04-01T12:00:00Z", null, null), + supportEvent(driverId, vehicleId, registrationId, "POSITION", "POSITION_RECORDED", "SNAPSHOT", "VU_POSITION", "p1", "2026-04-01T09:01:00Z", "48.2082", "16.3738"), + supportEvent(driverId, vehicleId, registrationId, "LOAD_UNLOAD", "LOAD", "SNAPSHOT", "VU_LOAD_UNLOAD", "lu1", "2026-04-01T09:03:00Z", "48.2085", "16.3740"), + supportEvent(driverId, vehicleId, registrationId, "PLACE", "WORKING_DAY_PLACE_RECORDED", "END", "VU_PLACE", "pl1", "2026-04-01T09:59:00Z", "48.2090", "16.3750") + ); + List usageIntervals = service.mergeVehicleUsageIntervals( + service.buildVehicleUsageIntervals(supportEvents) + ); + + EnrichedDtiIntervalDto enriched = service.enrichInterval( + new EsperDtiEnrichmentService.PureDtiInterval( + "DTI-1", + driverId, + "BETWEEN_SIGNIFICANT_DRIVING", + start, + end, + 3600, + 1, + OffsetDateTime.parse("2026-04-01T08:00:00Z"), + "d-prev", + "d-next" + ), + supportEvents, + usageIntervals, + new EsperDtiEnrichmentRequest( + "default", + driverId, + OffsetDateTime.parse("2026-04-01T00:00:00Z"), + OffsetDateTime.parse("2026-04-02T00:00:00Z"), + 24, + 7, + 3, + 0, + 0, + EsperSourceSelectionMode.MIXED, + null, + null, + 24 * 30, + 180, + 180 + ) + ); + + DtiBoundaryVehicleDto beginVehicle = enriched.beginVehicle(); + assertThat(beginVehicle).isNotNull(); + assertThat(beginVehicle.vehicleId()).isEqualTo(vehicleId); + assertThat(beginVehicle.resolutionSource()).isEqualTo("IW_CYCLE"); + assertThat(enriched.intervalKind()).isEqualTo("BETWEEN_SIGNIFICANT_DRIVING"); + + DtiBoundaryPositionDto beginPosition = enriched.beginPosition(); + assertThat(beginPosition).isNotNull(); + assertThat(beginPosition.eventDomain()).isEqualTo("POSITION"); + assertThat(beginPosition.position().latitude()).isEqualByComparingTo("48.2082"); + + DtiBoundaryPositionDto endPosition = enriched.endPosition(); + assertThat(endPosition).isNotNull(); + assertThat(endPosition.eventDomain()).isEqualTo("PLACE"); + + List beginVicinity = enriched.beginVicinityEvents(); + assertThat(beginVicinity).extracting(DtiBoundaryVicinityEventDto::eventDomain) + .contains("DRIVER_CARD", "POSITION", "LOAD_UNLOAD"); + } + + @Test + void extractsPureDtiFromOperatingPeriodDrivingInterruptions() { + UUID driverId = UUID.randomUUID(); + OffsetDateTime requestedFrom = OffsetDateTime.parse("2026-04-01T00:00:00Z"); + OffsetDateTime requestedTo = OffsetDateTime.parse("2026-04-02T00:00:00Z"); + + EsperOperatingPeriodResultDto result = new EsperOperatingPeriodResultDto( + "default", + driverId, + requestedFrom, + requestedTo, + requestedFrom.minusHours(24), + requestedTo.plusHours(24), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 7, + 3, + 0, + 0, + EsperSourceSelectionMode.MIXED, + EsperUnknownTreatmentMode.AS_BREAK_REST, + EsperOperatingPeriodEngineMode.STREAM_COLLECTOR, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(new OperatingPeriodDto( + 4, + OffsetDateTime.parse("2026-04-01T06:00:00Z"), + OffsetDateTime.parse("2026-04-01T18:00:00Z"), + 12 * 3600L, + "FLUSH", + List.of(), + 0, + 0, + 0, + 0, + 0, + 0, + new ShiftDrivingEvaluationDto( + 3, + OffsetDateTime.parse("2026-04-01T08:00:00Z"), + OffsetDateTime.parse("2026-04-01T16:00:00Z"), + activity(driverId, "2026-04-01T08:00:00Z", "2026-04-01T10:00:00Z", "d1"), + activity(driverId, "2026-04-01T14:00:00Z", "2026-04-01T16:00:00Z", "d2"), + List.of(new DrivingInterruptionDto( + OffsetDateTime.parse("2026-04-01T10:00:00Z"), + OffsetDateTime.parse("2026-04-01T14:00:00Z"), + 14400, + "d1", + "d2" + )) + ), + false + )), + List.of("note") + ); + + List pureDtiIntervals = service.extractPureDtiIntervals(result); + + assertThat(pureDtiIntervals).hasSize(3); + assertThat(pureDtiIntervals).extracting(EsperDtiEnrichmentService.PureDtiInterval::intervalKind) + .containsExactly("BEFORE_FIRST_SIGNIFICANT_DRIVING", "BETWEEN_SIGNIFICANT_DRIVING", "AFTER_LAST_SIGNIFICANT_DRIVING"); + assertThat(pureDtiIntervals.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T06:00:00Z")); + assertThat(pureDtiIntervals.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T08:00:00Z")); + assertThat(pureDtiIntervals.get(0).previousDrivingSourceRowId()).isNull(); + assertThat(pureDtiIntervals.get(0).nextDrivingSourceRowId()).isEqualTo("d1"); + + assertThat(pureDtiIntervals.get(1).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T10:00:00Z")); + assertThat(pureDtiIntervals.get(1).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T14:00:00Z")); + assertThat(pureDtiIntervals.get(1).operatingPeriodNo()).isEqualTo(4); + assertThat(pureDtiIntervals.get(1).previousDrivingSourceRowId()).isEqualTo("d1"); + assertThat(pureDtiIntervals.get(1).nextDrivingSourceRowId()).isEqualTo("d2"); + + assertThat(pureDtiIntervals.get(2).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T16:00:00Z")); + assertThat(pureDtiIntervals.get(2).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T18:00:00Z")); + assertThat(pureDtiIntervals.get(2).previousDrivingSourceRowId()).isEqualTo("d2"); + assertThat(pureDtiIntervals.get(2).nextDrivingSourceRowId()).isNull(); + } + + private at.procon.eventhub.esperpoc.dto.ActivityIntervalDto activity( + UUID driverId, + String from, + String to, + String sourceRowId + ) { + return at.procon.eventhub.esperpoc.dto.ActivityIntervalDto.raw( + driverId, + null, + null, + "DRIVE", + "DRIVER", + "INSERTED", + "KNOWN", + "DRIVER_CARD", + OffsetDateTime.parse(from), + OffsetDateTime.parse(to), + sourceRowId + ); + } + + private EsperSupportEventDto supportEvent( + UUID driverId, + UUID vehicleId, + UUID registrationId, + String eventDomain, + String eventType, + String lifecycle, + String extractionCode, + String sourceRowId, + String occurredAt, + String latitude, + String longitude + ) { + return new EsperSupportEventDto( + UUID.randomUUID(), + OffsetDateTime.parse(occurredAt), + sourceRowId, + extractionCode + ":" + sourceRowId + ":" + eventType, + extractionCode.startsWith("VU_") || "IW_CYCLE".equals(extractionCode) ? "VEHICLE_UNIT" : "DRIVER_CARD", + extractionCode, + driverId, + UUID.randomUUID(), + vehicleId, + registrationId, + eventDomain, + eventType, + lifecycle, + "DRIVER", + latitude == null ? null : new BigDecimal(latitude), + longitude == null ? null : new BigDecimal(longitude), + null, + null, + null, + null, + "LOAD".equals(eventType) ? "LOAD" : null + ); + } +} diff --git a/src/test/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationServiceTest.java b/src/test/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationServiceTest.java index bebb916..0f12bbb 100644 --- a/src/test/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationServiceTest.java +++ b/src/test/java/at/procon/eventhub/esperpoc/service/EsperOperatingPeriodEvaluationServiceTest.java @@ -4,7 +4,11 @@ import static org.assertj.core.api.Assertions.assertThat; import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto; import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodEngineMode; +import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodRequest; +import at.procon.eventhub.esperpoc.dto.EsperSourceSelectionMode; import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode; +import at.procon.eventhub.esperpoc.dto.RawActivityEventDto; +import at.procon.eventhub.esperpoc.persistence.EsperPocActivityRepository; import at.procon.eventhub.esperpoc.dto.NonDrivingIntervalDto; import at.procon.eventhub.esperpoc.dto.OperatingPeriodActivityIntervalDto; import java.time.Duration; @@ -12,6 +16,10 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class EsperOperatingPeriodEvaluationServiceTest { @@ -132,6 +140,46 @@ class EsperOperatingPeriodEvaluationServiceTest { .isEqualTo(collectorEvaluation.closedPeriods()); } + @Test + void driverCardOnlyModeIgnoresVehicleUnitGapFill() { + UUID driverId = UUID.randomUUID(); + EsperPocActivityRepository repository = mock(EsperPocActivityRepository.class); + EsperOperatingPeriodEvaluationService evaluationService = new EsperOperatingPeriodEvaluationService( + repository, + new EsperDriverActivityEngine(), + operatingPeriodEngine + ); + when(repository.findDriverActivityEvents(eq("default"), eq(driverId), any(), any())).thenReturn(List.of( + raw(driverId, "DRIVER_CARD", "DRIVE", "START", "2026-04-01T08:00:00Z", "card-1"), + raw(driverId, "DRIVER_CARD", "DRIVE", "END", "2026-04-01T09:00:00Z", "card-1"), + raw(driverId, "VEHICLE_UNIT", "DRIVE", "START", "2026-04-01T09:00:00Z", "vu-1"), + raw(driverId, "VEHICLE_UNIT", "DRIVE", "END", "2026-04-01T10:00:00Z", "vu-1") + )); + + var result = evaluationService.evaluate(new EsperOperatingPeriodRequest( + "default", + driverId, + OffsetDateTime.parse("2026-04-01T00:00:00Z"), + OffsetDateTime.parse("2026-04-02T00:00:00Z"), + 24, + 7, + 3, + 0, + 0, + EsperSourceSelectionMode.DRIVER_CARD_ONLY, + EsperUnknownTreatmentMode.AS_BREAK_REST, + EsperOperatingPeriodEngineMode.STREAM_COLLECTOR + )); + + assertThat(result.rawEventCount()).isEqualTo(2); + assertThat(result.driverCardRawEventCount()).isEqualTo(2); + assertThat(result.vehicleUnitRawEventCount()).isZero(); + assertThat(result.driverCardIntervalCount()).isEqualTo(1); + assertThat(result.vehicleUnitIntervalCount()).isZero(); + assertThat(result.resolvedKnownIntervalCount()).isEqualTo(1); + assertThat(result.rawEvents()).extracting(RawActivityEventDto::sourceKind).containsOnly("DRIVER_CARD"); + } + private ActivityIntervalDto activity( UUID driverId, String activity, @@ -184,4 +232,31 @@ class EsperOperatingPeriodEvaluationServiceTest { 0L ); } + + private RawActivityEventDto raw( + UUID driverId, + String sourceKind, + String eventType, + String lifecycle, + String occurredAt, + String sourceRowId + ) { + return new RawActivityEventDto( + UUID.randomUUID(), + OffsetDateTime.parse(occurredAt), + sourceRowId, + sourceKind + ":" + sourceRowId + ":" + lifecycle, + sourceKind, + "DRIVER_CARD".equals(sourceKind) ? "CARD_ACTIVITY" : "VU_ACTIVITY", + driverId, + null, + null, + eventType, + lifecycle, + "DRIVER", + "INSERTED", + "KNOWN", + null + ); + } }