Add DTI enrichment evaluation endpoint

This commit is contained in:
trifonovt 2026-05-12 12:05:13 +02:00
parent 94e1227ab3
commit 7be6a08013
18 changed files with 1578 additions and 10 deletions

View File

@ -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": [

View File

@ -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<EsperDtiEnrichmentResultDto> 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));
}
}

View File

@ -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
) {
}

View File

@ -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<String> evidenceSourceRowIds
) {
}

View File

@ -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
) {
}

View File

@ -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<DtiBoundaryVicinityEventDto> beginVicinityEvents,
List<DtiBoundaryVicinityEventDto> endVicinityEvents
) {
}

View File

@ -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);
}
}

View File

@ -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<EnrichedDtiIntervalDto> dtiIntervals,
List<String> notes
) {
}

View File

@ -15,6 +15,7 @@ public record EsperOperatingPeriodRequest(
Integer significantDrivingMinutes,
Integer mergeGapSeconds,
Integer gapDetectionToleranceSeconds,
EsperSourceSelectionMode sourceSelectionMode,
EsperUnknownTreatmentMode unknownTreatmentMode,
EsperOperatingPeriodEngineMode engineMode
) {

View File

@ -26,6 +26,7 @@ public record EsperOperatingPeriodResultDto(
int significantDrivingMinutes,
int mergeGapSeconds,
int gapDetectionToleranceSeconds,
EsperSourceSelectionMode sourceSelectionMode,
EsperUnknownTreatmentMode unknownTreatmentMode,
EsperOperatingPeriodEngineMode engineMode,
List<RawActivityEventDto> rawEvents,

View File

@ -0,0 +1,6 @@
package at.procon.eventhub.esperpoc.dto;
public enum EsperSourceSelectionMode {
MIXED,
DRIVER_CARD_ONLY
}

View File

@ -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
) {
}

View File

@ -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<EsperSupportEventDto> 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
);
}
}

View File

@ -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<String> 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<EsperSupportEventDto> rawSupportEvents = enrichmentRepository.findDriverSupportEvents(
request.tenantKey(),
request.driverId(),
supportFrom,
supportTo,
PRECEDING_DRIVER_CARD_EVENT_COUNT,
request.sourceSelectionMode()
);
List<ResolvedVehicleUsageInterval> vehicleUsageIntervals = mergeVehicleUsageIntervals(buildVehicleUsageIntervals(rawSupportEvents));
List<EsperSupportEventDto> supportEvents = condenseSupportEvents(rawSupportEvents);
List<PureDtiInterval> pureDtiIntervals = extractPureDtiIntervals(pureDtiResult);
List<EnrichedDtiIntervalDto> 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<EsperSupportEventDto> supportEvents,
List<ResolvedVehicleUsageInterval> 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<DtiBoundaryVicinityEventDto> beginVicinity = resolveBoundaryVicinityEvents(
interval.startedAt(),
supportEvents,
request.vicinityWindowMinutes()
);
List<DtiBoundaryVicinityEventDto> 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<PureDtiInterval> extractPureDtiIntervals(EsperOperatingPeriodResultDto result) {
if (result == null || result.operatingPeriods() == null || result.operatingPeriods().isEmpty()) {
return List.of();
}
List<PureDtiInterval> 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<ResolvedVehicleUsageInterval> buildVehicleUsageIntervals(List<EsperSupportEventDto> supportEvents) {
record VehicleIntervalSeed(
EsperSupportEventDto insertEvent,
EsperSupportEventDto withdrawEvent
) {
}
java.util.Map<String, VehicleIntervalSeed> 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<ResolvedVehicleUsageInterval> 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<ResolvedVehicleUsageInterval> mergeVehicleUsageIntervals(List<ResolvedVehicleUsageInterval> intervals) {
if (intervals.isEmpty()) {
return List.of();
}
List<ResolvedVehicleUsageInterval> sorted = intervals.stream()
.sorted(Comparator.comparing(ResolvedVehicleUsageInterval::startedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(ResolvedVehicleUsageInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
List<ResolvedVehicleUsageInterval> 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<EsperSupportEventDto> condenseSupportEvents(List<EsperSupportEventDto> supportEvents) {
if (supportEvents == null || supportEvents.isEmpty()) {
return List.of();
}
record VehicleIntervalSeed(
EsperSupportEventDto insertEvent,
EsperSupportEventDto withdrawEvent
) {
}
java.util.Map<String, VehicleIntervalSeed> 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<ResolvedVehicleUsageInterval> mergedCardVehicleIntervals = mergeVehicleUsageIntervals(
buildVehicleUsageIntervals(supportEvents).stream()
.filter(interval -> "CARD_VEHICLES_USED".equals(interval.authoritativeSource()))
.toList()
);
Set<UUID> 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<ResolvedVehicleUsageInterval> 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<EsperSupportEventDto> 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<DtiBoundaryVicinityEventDto> resolveBoundaryVicinityEvents(
OffsetDateTime boundary,
List<EsperSupportEventDto> 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<String> collectSourceRowIds(EsperSupportEventDto first, EsperSupportEventDto second) {
Set<String> 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<String> 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<String> 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<String> 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
) {
}
}

View File

@ -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<RawActivityEventDto> driverCardRawEvents = rawEvents.stream()
.filter(event -> "DRIVER_CARD".equals(event.sourceKind()))
.toList();
List<RawActivityEventDto> vehicleUnitRawEvents = rawEvents.stream()
List<RawActivityEventDto> vehicleUnitRawEvents = sourceSelectionMode == EsperSourceSelectionMode.DRIVER_CARD_ONLY
? List.of()
: rawEvents.stream()
.filter(event -> "VEHICLE_UNIT".equals(event.sourceKind()))
.toList();
List<RawActivityEventDto> selectedRawEvents = sourceSelectionMode == EsperSourceSelectionMode.DRIVER_CARD_ONLY
? driverCardRawEvents
: rawEvents;
long cardIntervalsStartedNanos = System.nanoTime();
List<ActivityIntervalDto> driverCardRawIntervals = activityEngine.buildIntervals(driverCardRawEvents);
@ -94,7 +101,9 @@ public class EsperOperatingPeriodEvaluationService {
long vuIntervalsElapsedMs = elapsedMillis(vuIntervalsStartedNanos);
long vuGapFillStartedNanos = System.nanoTime();
List<ActivityIntervalDto> resolvedKnownLoadedIntervals = resolveVuFillGaps(driverCardRawIntervals, vehicleUnitRawIntervals);
List<ActivityIntervalDto> 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<String> 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 + ".",

View File

@ -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));
}
}

View File

@ -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<EsperDtiEnrichmentService.ResolvedVehicleUsageInterval> 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<EsperDtiEnrichmentService.ResolvedVehicleUsageInterval> 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<EsperSupportEventDto> 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<EsperSupportEventDto> 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<EsperDtiEnrichmentService.ResolvedVehicleUsageInterval> 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<DtiBoundaryVicinityEventDto> 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<EsperDtiEnrichmentService.PureDtiInterval> 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
);
}
}

View File

@ -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
);
}
}